1. 写在最前:这门语言到底在学什么
第一次接触Verilog的人,往往上来就被module、reg、wire、always这些关键字砸晕。我当年入门的时候也一样,翻了一堆教材,每一本都在讲语法,但没人告诉我:Verilog本质上不是在“写代码”,而是在“画电路”。你写的每一条语句,最后都会变成芯片上一堆真实的门电路和触发器。这种思维方式转换,才是新手最大的门槛。
这篇文章从零开始,把Verilog的完整学习路径捋一遍——从最小模块、组合逻辑、时序逻辑,到状态机、总线协议、仿真验证,再到编辑器配置和常见坑。你会知道每种写法背后的电路长什么样,为什么要这么写,以及实际工程中怎么用它去实现计数器、分频器、I2C读写EEPROM、滑动窗口滤波这类具体功能。适合正在入门数字电路和FPGA开发的人,也适合那些学了一段时间但总觉得“语法会了,动手就废”的朋友当成查漏补缺的笔记。
全文没有任何需要联网的依赖,纯手写Verilog,配合iverilog和GTKWave就能跑完所有例子。下面直接进入正题。
2. 最小系统:从最简模块看懂代码与电路的对应关系
2.1 module与端口声明:先搭好芯片的“外壳”
Verilog的最小单位是模块(module),一个模块对应一个硬件电路块。比如最简单的与门逻辑:
module and_gate ( input wire a, input wire b, output wire y ); assign y = a & b; endmodule这段代码综合出来就是两个输入引脚、一个输出引脚,中间一个与门。input、output定义了芯片对外接口的方向,wire表示连线型数据类型,约等于物理世界的一根导线。初学者最容易犯的第一个错误,是把wire和reg搞混——这里先记住一条准则:使用assign连续赋值时,左边必须是wire;使用always过程赋值时,左边必须是reg。
模块内部如果有多条assign语句,它们是并行执行的,跟书写顺序无关。这一点和C语言完全不同。学Verilog时,脑子里千万别想着“从上往下执行”,要想“每条语句都是同时接通的一根导线”。我从带新人的经验看,一旦转过这个弯,后面的学习速度会快很多。
2.2 仿真testbench:光写模块不够,还得有个“测试台”
写好的Verilog代码无法直接运行,它描述的是硬件,不是软件。要验证功能,得写一个testbench——相当于给芯片接上一个信号发生器,然后把输出结果拉出来观察。一个最小测试环境大概长这样:
`timescale 1ns / 1ps module tb_and_gate; reg a; reg b; wire y; and_gate u_and_gate ( .a(a), .b(b), .y(y) ); initial begin a = 0; b = 0; #10 a = 0; b = 1; #10 a = 1; b = 0; #10 a = 1; b = 1; #10 $finish; end endmodulereg类型在testbench里用来给输入信号赋值,initial块执行一次性的信号激励。#10表示延时10个时间单位。跑完这个测试台,再用$monitor或波形工具观察y,就能确认与门功能是否正确。
很多人写testbench时喜欢把所有信号往initial里丢,但实际项目里,时钟信号这种周期性信号,更常用的写法是always #5 clk = ~clk;。这也是testbench和设计代码思维差异的起点——设计代码里always是用来触发寄存器更新的,testbench里always则是纯粹为了生成激励。
2.3 编辑器与仿真工具链:VSCode和GVim下的Verilog配置
写Verilog建议直接从第一天就配好编辑器,否则后面代码复杂了,缩进和语法高亮会把人折磨疯。我在VSCode里用的是Verilog-HDL/SystemVerilog插件,配合verible-verilog-format做格式化,打开文件就能看到模块、端口、信号自动高亮,写always块的时候括号匹配也很准。
如果习惯用GVim,也可以通过安装vim-verilog或者自带的verilog.vim语法文件实现SystemVerilog的高亮显示。装好之后,在.vimrc里加上au BufNewFile,BufRead *.sv,*.svh set filetype=systemverilog,就能识别.sv文件。实际测下来,GVim的代码折叠功能对大模块特别友好——把一整块always或者几百行的状态机折叠起来,只看端口列表,调试效率会高不少。
仿真工具这边,免费方案首选Icarus Verilog(iverilog),配合GTKWave看波形。命令很简单:
iverilog -o tb_and_gate.vvp tb_and_gate.v and_gate.v vvp tb_and_gate.vvp写完代码就编译、跑simulation、开波形,这个循环是整个学习过程里最重要的基本功。另外多说一句,别一上来就学SystemVerilog的面向对象验证方法学,那属于验证工程师的范畴,做设计的人先把Verilog的RTL风格练扎实,后面学SystemVerilog只是锦上添花。
3. 运算符与参数:几个高频写法一次讲透
3.1 优先级、位宽与常见运算符
Verilog的开发效率很大程度上取决于对运算符的熟练度。下面是我整理的一张基础运算符优先级表,从高到低排列——这张表我建议打印出来贴在显示器边上:
| 优先级 | 运算符 | 说明 |
|---|---|---|
| 最高 | ~ | 按位取反 |
*/% | 乘、除、取模 | |
+- | 加、减 | |
<<>> | 逻辑移位 | |
& | 按位与 | |
^~^ | 按位异或、同或 | |
| | 按位或 | |
&& | 逻辑与 | |
|| | 逻辑或 | |
? : | 三目运算符 | |
| 最低 | {}{{}} | 位拼接、复制 |
初学阶段容易出的问题有三个。
第一个是&和&&混用。&是按位与,输入是两段多位宽数据,输出逐位相与;&&是逻辑与,输出只有0或1。比如4'b1010 & 4'b1100结果是4'b1000,而4'b1010 && 4'b1100结果是1'b1(因为两个数都不为0,逻辑为真)。
第二个是异或和同或记反。a ^ b是异或,相同为0、不同为1;a ~^ b(也可以写成a ^~ b)是同或,相同为1、不同为0。实际工程里,奇偶校验电路和CRC运算会大量用到异或,我每次写CRC之前都会先在纸上把多项式展开,再对照代码检查一遍,光靠脑子想很容易绕进去。
第三个是移位运算符的位宽问题。wire [7:0] a; wire [7:0] b; assign b = a << 1;这样写没问题,但如果写成assign b = a << 1'b1;,在部分老工具里可能会被当成1的位宽参与运算,导致结果的位宽不对。稳妥的写法是保证参与运算的信号位宽明确,必要时在表达式前加上{ }拼接。
3.2 parameter与localparam的工程化用法
parameter和localparam的本质区别是:parameter可以在模块例化时从外部覆盖,localparam不行。日常编码中,对外可配置的常量用parameter,模块内部的固定常量用localparam。工程上最常见的parameter用法是定义计数器位宽和数据宽度:
module counter #( parameter WIDTH = 8, parameter MAX_COUNT = 200 ) ( input wire clk, input wire rst_n, output reg [WIDTH-1:0] count ); localparam MAX = MAX_COUNT - 1; always @(posedge clk or negedge rst_n) begin if (!rst_n) count <= 0; else if (count == MAX) count <= 0; else count <= count + 1'b1; end endmodule顶层例化时,用#(.WIDTH(16), .MAX_COUNT(50000))覆盖默认参数,这样同一份计数器代码能复用到不同需求的场景。要注意的是,在Verilog-2001之后,都推荐用#(参数列表)写在模块名后面,而不是老式的defparam。老式写法在大型工程里修改不直观,而且多个模块同时defparam时很容易互相干扰。
另一个高频问题是位宽不一致。比如MAX_COUNT默认是200,但在顶层覆写成50000,可WIDTH没变还是8位,计数器就会溢出。这种参数之间的依赖关系,纯靠人脑确实很难检查,更好的习惯是让上位宽自己算:localparam COUNTER_WIDTH = clog2(MAX_COUNT);,其中clog2是用函数实现的对数向上取整。这个技巧能让你的模块适配性一下子提升一个档次。
3.3 例化与连接:模块之间到底怎么“接线”
模块例化相当于在FPGA里“插入”了一个子电路,然后把子电路的端口和外部信号连接起来。最常见的两种连接方式:
// 按端口名连接,推荐 counter #( .WIDTH(16), .MAX_COUNT(50000) ) u_counter ( .clk (clk), .rst_n(rst_n), .count(led_count) ); // 按端口顺序连接,不推荐 counter u_counter_2 (clk, rst_n, led_count_2);按端口名连接的好处是端口顺序随便调都不影响,代码可读性也高;按端口顺序连接一旦端口列表改一下,所有例化全部错位,排查起来极其痛苦。我在代码评审里看到按顺序例化,一般都会建议改掉——这不是风格问题,这是维护性的问题。
4. 时序逻辑实战:计数器、分频时钟与滤波器的Verilog实现
4.1 同步复位与异步复位:别忘了rst_n
主流设计里,复位信号一般低电平有效,叫rst_n。异步复位写法如下:
always @(posedge clk or negedge rst_n) begin if (!rst_n) count <= 0; else count <= count + 1'b1; end这里negedge rst_n出现在敏感列表里,意味着只要复位一拉低,不管时钟在哪,count立刻清零。这就是“异步复位”。而如果敏感列表里只有posedge clk,复位信号在always块里被当成普通条件判断,就必须等时钟上升沿到来才能复位,这叫“同步复位”。
实际工程中我几乎都用异步复位、同步释放的方式,因为它能保证复位信号释放时和时钟沿对齐,避免亚稳态。这个点展开讲很深,初学者只需要先记住:always @(posedge clk or negedge rst_n)是通用模板,先别乱改。
4.2 四分频电路:从计数器到分频器的通用套路
计数器是Verilog里最典型的时序逻辑,四分频也是最常见的入门题目。我们先把“计数”和“分频”这两个概念分清:计数器是数数,分频是把输入时钟频率变低。四分频意味着输出时钟每4个输入时钟周期翻转一次,最后占空比50%的输出频率正好是输入的1/4。
module clk_div4 ( input wire clk, input wire rst_n, output reg clk_out ); reg [1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 2'd0; clk_out <= 1'b0; end else if (cnt == 2'd1) begin cnt <= 2'd0; clk_out <= ~clk_out; end else begin cnt <= cnt + 1'b1; clk_out <= clk_out; end end endmodule关键点在cnt == 2'd1时翻转clk_out。从0数到1需要2个时钟,翻转一次输出半个周期;下次再从0数到1,再翻转一次,凑满一个完整输出周期。所以4分频只需要一个2位计数器,最大计数值到1就够。这个“计数值到N/2-1”的思路适用于任意偶数分频。奇数分频则复杂一些,通常用两个计数器分别在时钟上升沿和下降沿计数,再组合输出,这里不展开。
分频电路在FPGA项目里的使用要谨慎:能用时钟使能就不要分频。FPGA内部的全局时钟网络是专门为时钟信号设计的,如果随便将分频时钟接到普通逻辑上,时序约束很容易出问题,跑上高频后器件会变得不稳定。我遇到过的很多“FPGA跑飞了”的问题,最后查下来都是自己随手分出来的时钟惹的祸。
4.3 滑动窗口滤波与阿尔法贝塔滤波器:信号处理的硬件实现思路
热词里出现“滑动窗口滤波verilog”和“阿尔法贝塔滤波器verilog”,说明很多人在做传感器数据处理的FPGA实现。先解释一下滑动窗口滤波:它维护一个固定长度的窗口,每个时钟周期进来一个新数据,同时丢掉最老的数据,对所有窗口内数据求平均。硬件实现上,最简单的方案是FIFO加累加器。
module sliding_avg #( parameter WIDTH = 8, parameter DEPTH = 8 ) ( input wire clk, input wire rst_n, input wire valid_in, input wire [WIDTH-1:0] data_in, output reg [WIDTH-1:0] avg_out ); reg [WIDTH-1:0] buffer [0:DEPTH-1]; reg [WIDTH+3:0] sum; reg [3:0] ptr; reg [3:0] count; integer i; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sum <= 0; ptr <= 0; count <= 0; for (i = 0; i < DEPTH; i = i + 1) buffer[i] <= 0; end else if (valid_in) begin if (count == DEPTH) sum <= sum - buffer[ptr] + data_in; else begin sum <= sum + data_in; count <= count + 1'b1; end buffer[ptr] <= data_in; ptr <= ptr + 1'b1; end end always @(*) begin if (count == 0) avg_out = 0; else avg_out = sum / DEPTH; end endmodule代码里最核心的思路是避免每个周期都重新累加所有窗口数据,而是只做一次减法和一次加法,这样无论窗口多深,组合逻辑都不会因为加法器级联而变得很大。对FPGA的查找表资源来说,省下的加法器数量是很可观的。
阿尔法贝塔滤波器则是一阶稳态跟踪滤波器,适合对匀速运动的目标做平滑估计,原理上跟卡尔曼滤波有关系,但实现简单得多。它的Verilog实现一般分成两步:第一步做预测,第二步用测量值和预测值的差来做修正。硬件里大量使用定点数,所以alpha和beta通常被量化成2的幂次方(比如1/16、1/64),让乘除法变成移位操作,节省大量数字信号处理逻辑资源。
4.4 常见时序错误复盘:我踩过的三个坑
第一个坑是“always块里用阻塞赋值”。在时序逻辑的always里用了=,仿真时看似没问题,但综合出来的电路很可能产生你没预料到的额外触发器或竞争。规范做法是时序逻辑一律用非阻塞赋值<=,组合逻辑里才用阻塞赋值=。这条规则我前面已经强调过,这里再说一次,因为它真的是新手最高频错误。
第二个坑是“多驱动”。同一个信号被两个always块赋值,或者被assign和always同时赋值,综合工具直接报错,或者产生难以预知的X态。一个信号只能有一个驱动源,这条约束在写代码时要刻在脑子里。
第三个坑是“混合使用if和case的优先级误解”。if...else if天然有优先级,综合出来是多选器链;case是编码选择,综合出来近似查表。某些场景下你希望有优先级,比如中断控制器,可以用if;但纯译码场景用casex或casez会更清晰。用错之后,逻辑功能一般不会错,但路径延迟和面积会变差。
5. 状态机实战:从I2C读写EEPROM看FSM的完整落地
5.1 状态机的三段式写法框架
状态机是Verilog的“半壁江山”,几乎所有复杂控制逻辑(UART收发、I2C通信、DDR3读写控制、LRU替换算法)都是围绕状态机展开的。我推荐三段式写法:第一段做状态跳转,第二段做次态组合逻辑,第三段做输出寄存器化。框架如下:
// 第一段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 第二段:次态逻辑 always @(*) begin next_state = state; case (state) IDLE: if (start) next_state = READ; READ: next_state = DONE; default: next_state = IDLE; endcase end // 第三段:输出逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) data_valid <= 1'b0; else if (state == READ) data_valid <= 1'b1; else data_valid <= 1'b0; end关键好处有两个:一是状态跳转和输出生成分开,代码容易维护;二是输出全部打了一拍寄存器,避免了组合逻辑毛刺,对后端时序收敛更有利。
5.2 以I2C读写EEPROM为例的状态机设计过程
I2C是一个典型的串行通信协议,由时钟线SCL和数据线SDA组成。写EEPROM的操作流程大概是:起始信号、发送器件地址加写位、等待应答、发送寄存器地址、等待应答、发送数据、等待应答、停止信号。
用状态机表示,我会这么拆:
| 状态 | 动作 | 跳转条件 |
|---|---|---|
| IDLE | 等待触发 | 收到start信号跳START |
| START | 拉低SDA产生起始条件 | 完成后跳ADDR |
| ADDR | 移位发送器件地址+写位 | 发送完8位跳ACK1 |
| ACK1 | 采样SDA上的应答位 | 应答为低跳REG_ADDR |
| REG_ADDR | 发送寄存器地址 | 发送完8位跳ACK2 |
| ACK2 | 采样应答 | 应答为低跳DATA |
| DATA | 发送数据字节 | 发送完8位跳ACK3 |
| ACK3 | 采样应答 | 应答为低跳STOP |
| STOP | 产生停止条件 | 完成后回IDLE |
对应的大致Verilog框架:
localparam IDLE = 4'd0, START = 4'd1, ADDR = 4'd2, ACK1 = 4'd3, REG_ADDR = 4'd4, ACK2 = 4'd5, DATA = 4'd6, ACK3 = 4'd7, STOP = 4'd8; reg [3:0] state; reg [3:0] next_state; reg [2:0] bit_cnt; reg [7:0] shift_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end always @(*) begin next_state = state; case (state) IDLE: if (i2c_start) next_state = START; START: next_state = ADDR; ADDR: if (bit_cnt == 3'd7) next_state = ACK1; ACK1: next_state = REG_ADDR; REG_ADDR: if (bit_cnt == 3'd7) next_state = ACK2; ACK2: next_state = DATA; DATA: if (bit_cnt == 3'd7) next_state = ACK3; ACK3: next_state = STOP; STOP: next_state = IDLE; default: next_state = IDLE; endcase end这里bit_cnt用来记录当前发送到第几位。实际工程里,我还需要一个更细的clk_cnt来产生SCL的4分频时钟,因为I2C的时序要求SCL高电平期间SDA不能变化。状态机加时钟分频,两者配合才能把I2C时序调整到完全满足器件手册要求。
硬件上,EEPROM的写周期一般需要5到10毫秒,状态机在写完数据后不能立刻进行下一笔写操作,必须进入一个等待状态,延时到写周期结束。所以状态机里经常要加一个WAIT状态,用计数器延时。这个细节刚入门的人很容易漏,结果就是读回来的数据永远是0xFF,排查半天发现是写操作还没完成就被覆盖了。
5.3 其他高频状态机场景:UART收发与DDR3读写控制
UART的接收状态机是另一个经典练习题。它的状态一般包括IDLE、START、DATA、STOP,每个数据位用波特率时钟采样多次,取中间的值,避免信号边缘抖动带来的误采样。具体实现时,典型做法是先用一个波特率分频计数器产生采样脉冲,每16个计数采一次SDA/RX引脚。
DDR3读写控制的复杂度更高一层。它不仅要处理初始化校准,还要管理bank、行、列地址,以及预充电、刷新等操作。真正的DDR3控制器IP会内置一层非常复杂的状态机,用户侧通常读写FIFO就够了。但如果你要自己写一套简化的DDR3读写控制逻辑,至少要规划出IDLE、ACTIVE、READ、WRITE、PRECHARGE、REFRESH这6个状态,并在每个状态里处理精确的时序参数。难度确实大,但状态机的设计思路跟I2C是一脉相承的——先画状态转移图,再映射成代码。状态图画清楚,代码只是翻译问题。
6. 工程向技巧:内存管理、调度算法与流程化设计
6.1 用Verilog实现LRU和RR调度的思路
热词里出现lru的verilog和rr调度verilog,说明有人在接触缓存替换和任务调度方向。LRU(最近最少使用)在硬件里最常见的是用“位矩阵”方法实现:维护一个N x N的寄存器矩阵,每次访问某个way时,把这一行对应的列全部置1,再把其他行访问本way的列清0,最后按行向量里1的个数判断最久未使用的项。
举个例子,4路组相联缓存里,每次命中way 2,就执行:
for (i = 0; i < 4; i = i + 1) begin if (i == 2) lru_matrix[i] <= 4'b1110; else lru_matrix[i][2] <= 1'b0; end然后判断哪一路最久未用,就看哪行的|lru_matrix[i]按位或结果最小。这种方法不需要排序,也不需要复杂算法,纯寄存器和组合逻辑就能实现,非常适合FPGA。
RR(轮询调度)则简单不少,硬件上就是一个计数器,按顺序把权限交给下一个请求者。工程实现时要考虑的是“跳过无请求者”和“公平性之间的权衡”。比如4个发起方,可以设计成:每次仲裁后,指针+1,如果当前指向的发起方没有请求,就继续往后找,直到找到有请求的那一位。这个逻辑用case加组合逻辑实现,代码量不大。
6.2 文件读取与仿真辅助:$readmemh与文件不存在问题
热词里出现“verilog读文件时不存在”,对应的就是$readmemh、$readmemb系统函数。这个函数用来把外部十六进制或二进制文件加载到存储器数组里,常用于初始化ROM、加载测试向量。典型写法:
reg [7:0] mem [0:1023]; initial begin $readmemh("data.hex", mem); end文件不存在时,仿真器通常会报错,但不会直接停止仿真,而是把对应内存保持为X态,导致后面逻辑行为异常。排查这类问题,我通常是三步走:
第一步,确认仿真器当前工作目录。很多看似文件不存在的问题,其实是路径不对。用$display("Current dir: %s",FILE);`打印出来看。
第二步,确认文件格式。$readmemh要求每行是一个十六进制数,可以带注释,但不能有0x前缀。
第三步,确认文件内容长度是否超出数组大小。读入数据超过数组范围时,仿真器一般会打印warning,但容易被忽略。所以初始化内存后,我会顺手打印几个关键地址的值,确认数据真的进去了。
6.3 可综合风格与不可综合风格:别把仿真写法带进综合
写Verilog做FPGA或ASIC设计时,必须区分哪些代码能综合成电路,哪些只能用于仿真。initial、#10、$display、$readmemh这些语句都只是仿真用的,综合工具会忽略或者报错。真正可综合的RTL风格,核心就是assign、always阻塞/非阻塞赋值、module例化、generate和各种运算符。
热词里还出现“写了verilog dc”,应该是指Design Compiler这类逻辑综合工具。用DC综合时,对RTL代码的写法要求更严格,比如不能有未初始化寄存器、不能有组合逻辑环路、不能有多个时钟域交织。我的经验是,写RTL一开始就按可综合风格来写,养成“仿真通过不等于能上板”的意识,否则后面后端工程师很容易拿代码回来让你返工。
7. 常见问题与排查技巧实录
7.1 高频错误速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 仿真结果全是X | 复位没拉低、未初始化寄存器 | 检查testbench复位时序,确认rst_n先拉低再拉高 |
| 波形对但上板不对 | 时钟没加约束、跨时钟域问题 | 添加时序约束,查看时序报告 |
| 输出出现毛刺 | 组合逻辑未打拍、异步信号直接使用 | 关键输出加一级寄存器 |
| 状态机跑飞 | 状态未初始化、少数状态缺失 | 加default分支,复位状态明确 |
| $readmemh文件读取失败 | 路径错误、格式错误 | 打印当前路径,检查文件格式 |
| 综合面积过大 | 乘法器/除法器资源消耗 | 优化为移位加法和查找表,降低位宽 |
7.2 排查思路:从“功能错”到“时序错”的进阶顺序
我刚学Verilog时,遇到仿真结果不对,第一反应就是盯着波形瞎猜。后来形成了固定的排查套路,效率提高了非常多。
第一步,先确认复位释放时间。用$display打印关键信号的值,确认所有模块都处于正确初始状态。
第二步,缩小问题范围。把一个大模块拆成若干小模块,单独给每个小模块写testbench,逐个验证。组合逻辑有问题,多半是运算符或位宽问题;时序逻辑有问题,多半是阻塞赋值和非阻塞赋值混用。
第三步,检查跨时钟域信号。如果模块里有两个时钟域的数据交互,务必确认握手机制或异步FIFO的实现是否完整。这是最容易踩的深坑,也是很多人从仿真走向真实硬件时遇到的第一道坎。
第四步,如果功能都正常但时序不满足,换工具分析——看综合报告里的关键路径,找出组合逻辑深度过大的地方,做流水线切分。这是后端优化的话题,但做RTL的人需要尽早建立这个意识。
7.3 给新手的一句经验
Verilog表面上是语言问题,本质上是电路设计问题。语法学得再熟,如果不理解综合出来的电路长什么样,写出来的代码迟早要坑到自己。反过来,只要你能把每个常见写法对应的硬件结构想清楚——assign是连线、always是触发器和组合逻辑的混合体、状态机是一堆寄存器加译码逻辑——整个Verilog就豁然开朗了。我入行这些年带过的项目,但凡在“代码和电路对应关系”上想通了的人,后面学SystemVerilog、学总线协议、学时序收敛,都顺理成章。
最后再分享一个小习惯:我每写完一个模块,都会花五分钟画一张手写草图,把输入输出端口、内部寄存器、状态跳转画出来,再对照代码检查一遍。这张草图在后端调试和文档编写时都帮了大忙。硬件设计这件事,慢就是快,把每一步的电路模型想清楚,比为了赶进度多写几百行代码重要得多。