1. Verilog 究竟是"语言"还是"电路":先掰正这几个思维
上一篇聊了 FPGA 开发环境的搭建,有位朋友照着步骤在 Vivado 里跑通了点灯实验,很兴奋。结果没过两天,他把自己写的数码管扫描代码发给我,说"仿真波形完全不是预期"。我问他怎么写的,他说"我就是把 C 语言的 if-else 直接搬过来了"。这就是很典型的 Verilog 语法入门问题——你不是不懂代码,你是还在用软件思维写硬件。这一篇我就从 Verilog 语法基础开始,把硬件描述语言和 C 语言之间最关键的几个差异讲透。适合刚买开发板、正在折腾点灯和数码管,或者已经抄过几个例程但总觉得地基不稳的读者。
1.1 并行执行:所有语句不是"从上到下",而是"同时通电"
很多人第一次看到 Verilog 代码会懵:为什么一个模块里能同时出现好几个 always、好几个 assign?C 语言里一个函数只能从上往下跑,Verilog 里这些语句块更像一堆"并联的硬件电路"。在仿真一开始,所有的 assign 和 always 块就被同时激活:assign 语句只要右边信号变化就会立即重新计算;always 块则等着自己敏感列表里的信号发生变化。也就是说,代码顺序只是写在文件里的顺序,不是电路执行的顺序。
这带来的直接后果是:你不能指望"先定义这个 always 块,再定义另一个 always 块,后者能使用前者的中间结果"。两个 always 块之间没有先后依赖,它们各自独立。比如:
always @(posedge clk) begin a <= b; end always @(posedge clk) begin c <= a; end在同一个时钟沿,第二个 always 块里取到的 a,是上升沿之前的老 a,而不是第一个块里刚刚更新的新 a。如果这里用阻塞赋值,行为就会变得诡异。这个细节放到时序那一节细说,这里先树立"并行"的直觉。
1.2 用"连线"理解 assign,而不是用"函数"理解
一个最简单的 Verilog 模块,往往长这样:
module and_gate( input wire a, input wire b, output wire y ); assign y = a & b; endmoduleassign y = a & b;的意思不是"执行一次与运算然后把结果存给 y",而是"y 这条线永远由 a 和 b 通过与门驱动"。a 和 b 一变,y 立刻变,没有延迟(当然仿真时可以加延迟,那是另一回事)。所以 assign 只能用于 wire 类型,因为它本质上就是描述"线网如何被驱动"。
我见过不少新手在这里较劲:为什么不能用assign y = x;给 reg 类型的 x 赋值?因为 reg 的含义是"寄存器变量"吗?并不完全是。在 Verilog 里,wire 和 reg 的区别主要是赋值方式,而不是物理上是否真的是一块寄存器。wire 由 assign 或端口驱动,reg 由 always 或 initial 块赋值。理解这一点,数据类型的坑就不会踩。
1.3 一个最小模块的结构拆解
把所有基础元素凑在一起,就是下面这个计数器闪烁 LED 的例子:
module led_blink #( parameter HALF_PERIOD = 24'd1_200_000 )( input wire clk, input wire rst_n, output reg led ); reg [23:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 24'd0; else if (cnt == HALF_PERIOD - 1) cnt <= 24'd0; else cnt <= cnt + 1'b1; end always @(posedge clk or negedge rst_n) begin if (!rst_n) led <= 1'b0; else if (cnt == HALF_PERIOD - 1) led <= ~led; end endmodule这个模块里有端口列表、参数定义、reg 声明、两个 always 块、非阻塞赋值。把它看懂,Verilog 语法基础的一多半就通了:module/endmodule 是外壳,端口列表是接线端子,parameter 是可以在例化时修改的常量,always @(posedge clk or negedge rst_n)描述了同步时序逻辑的触发条件,<=是寄存器赋值。下面几节,我一个一个拆开讲。
2. 端口、数据类型与常量:先把"骨架"和"线缆"装对
2.1 端口方向 input/output/inout 怎么选
模块端口有三种方向。input 只能从外部读取,output 输出到外部,inout 是双向端口,比如 I2C 的 SDA、DDR 内存的数据线。新手最容易困惑的是:output 端口能不能在 always 块里赋值?当然可以,但端口方向声明和内部变量的类型是两个概念。如果要用 output 端口在 always 块里保存状态,正确的写法是output reg led或者内部定义一个 reg 信号再连到 output。Verilog-2001 支持这种带类型的方向声明,比如output reg led,省掉了内部信号中转的麻烦。老式写法是:
module m(out); output out; reg out; ... endmodule现在我已经不推荐这种写法了,ANSI 风格端口声明更清晰。工程上我们基本都用 Verilog-2001,Xilinx 和 Intel 的开发工具都支持。还有一个细节:inout 端口内部通常用高阻态 z 表示"释放总线",比如三态缓冲器的写法是assign sda = en ? data : 1'bz;。很多新手一看到 inout 就头大,其实记住"不驱动时输出高阻"这句话就够了。
2.2 wire 与 reg 的"分工协议"
wire 和 reg 的区别,是所有语法书都会写但很多教程讲不明白的。我用一句大白话总结:wire 是被别人驱动的线,reg 是被 always 块或 initial 块驱动的变量。
- wire:只能出现在 assign 左侧、作为模块输入端口、或连接例化的子模块端口。它不保存值,只要驱动源变化就跟着变。
- reg:可以在 always 或 initial 里被赋值。注意,reg 不一定是真正的寄存器,也可能被综合成导线(比如组合逻辑的 always 块里,输出声明为 reg,但综合后只是组合电路)。
| 项目 | wire | reg |
|---|---|---|
| 驱动方式 | assign、端口连接 | always 块、initial 块 |
| 默认值 | 高阻 z(三态时) | 未知 x |
| 物理含义 | 线网 | 可能是寄存器,也可能是导线 |
| 时序逻辑 | 一般不直接赋值 | 常用 reg 保存状态 |
| 组合逻辑 | assign 左侧常用 | always@(*) 里输出也常用 reg |
举个常见例子,用 always 写一个四选一多路器:
module mux4( input wire [1:0] sel, input wire [7:0] a, b, c, d, output reg [7:0] y ); always @(*) begin case (sel) 2'd0: y = a; 2'd1: y = b; 2'd2: y = c; 2'd3: y = d; default: y = 8'd0; endcase end endmodule这里 y 声明为 reg,但它实际上是组合逻辑,不是寄存器。判断方法很简单:如果 always 块的敏感列表里只有输入信号,没有时钟沿,综合出来就是组合电路;如果敏感列表里有posedge clk/negedge rst_n,综合出来才是时序电路。这个判断方法比死记"reg 等于寄存器"有用得多。
2.3 parameter 与 localparam:写可复用的模块
写 Verilog 最忌讳到处写死数字。比如数码管刷新用 1kHz,UART 波特率需要 9600,计数器分频系数应该用一个有名字的常量。parameter 就是干这个的。它可以在模块例化时通过 #(...) 修改,localparam 则不能,是模块内部的"局部常量",常用于状态机的状态编码。
module uart_rx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 9_600 )( input wire clk, input wire rx, output reg [7:0] data ); localparam BIT_CNT_MAX = CLK_FREQ / BAUD_RATE; ... endmodule这样顶层例化时如果换了一个 24MHz 晶振,只需要改 CLK_FREQ,不需要改内部逻辑。实际项目里我习惯把所有可调参数都提到 parameter 里,不仅便于复用,也方便 testbench 做快速仿真——仿真时可以把 CLK_FREQ 改小,否则 50MHz 仿真跑 1 秒波形要等很久。这个经验后面仿真那一节还会用到。另外一个常见坑是 parameter 的位宽:parameter TIMES = 50000000默认是 32 位,如果你在计数器里cnt <= cnt + 1,cnt 只有 20 位,比较时高位会被截断,行为可能不符合预期。所以我一般会给 parameter 写明确位宽,比如parameter [31:0] CLK_FREQ = 50_000_000。
3. 组合逻辑的三种写法:assign、always 与运算符里的隐藏坑
3.1 从布尔方程到 assign 连续赋值
组合逻辑是"输出由当前输入直接决定"的逻辑,Verilog 里最直接的描述就是 assign。比如做一个比较器:
assign result = (a == b) ? 1'b1 : 1'b0;也可以用逻辑表达式直接写:assign result = (a == b);。区别在于三目运算符的结果位宽是表达式中最大的位宽,而a == b的结果是 1 位。如果 result 声明为output reg [7:0],用assign result = (a == b);会把 1 位比较结果放到最低位,其余位补 0,这通常会出问题。更稳妥的是把结果写清楚,比如assign result[0] = (a == b);。
assign 特别适合描述简单的组合逻辑:数据选择器、加法器、译码器。如果逻辑复杂,比如多分支的优先级判断,我建议用 always@() 配合 if/case,可读性好很多。这里要记住一个原则:assign 和 always@() 描述的是同一种东西,可以互相替换,但不要在一个变量上混合使用两种驱动方式。同一个 reg/wire 既被 assign 驱动,又被 always 驱动,综合工具会报多驱动错误,仿真结果也是 x。
3.2 组合逻辑的 always@(*):敏感列表与锁存器
always @(*)是 Verilog-2001 提供的自动敏感列表写法。它会自动把 always 块里读取的所有信号都加入敏感列表,不用手动列。如果我看到老代码里写always @(a or b or c),第一件事就是检查有没有漏信号。漏了敏感列表的仿真结果和综合结果可能不一致:仿真时某个输入变化了,但敏感列表没有它,always 块不会重新计算,波形就"僵住"了。
组合逻辑的 always 块还有两个铁律:
- 用阻塞赋值
=,不要用<=。 - 所有可能出现的情况都要赋值,否则综合时推断出锁存器(latch)。
看这个例子:
always @(*) begin if (en) y = a; end如果 en 为 0 时 y 没有赋值,Verilog 默认保持原值,综合器就会生成一个锁存器。很多时候你并不是故意要 latch,但忘了写 else 就会白白吃掉资源。正确的写法是:
always @(*) begin y = 8'd0; // 默认值先写好 if (en) y = a; end这个"提前赋默认值"的技巧非常实用,它比在每个分支都补 else 要省事得多,也能避免"所有分支都覆盖"的漏网之鱼。实际工程里,不管是用 case 还是 if,我都习惯在开头写一次默认赋值,后面只写需要覆盖的条件。这样代码短,也更容易 review。
3.3 运算符优先级与位宽:仿真和综合不一定是同一回事
Verilog 运算符挺多:算术、逻辑、按位、移位、拼接、关系、三目。常用的基本不会踩坑,我重点提两个。
第一位宽。Verilog 里未标注位宽的数字默认是 32 位,比如 1、8'd0 这种写法里,1 是 32 位整数。做加法时,结果位宽取左右操作数的最大位宽。wire [7:0] a; wire [4:0] b; assign sum = a + b;,sum 建议声明为[8:0],否则相加可能被截断(不是报错,而是高位丢失)。新手最容易在计数器里写cnt <= cnt + 1,如果 cnt 是 8 位,1 默认是 32 位,但赋值给 8 位 cnt 会自动截断,通常没问题。可一旦出现在复杂表达式里,比如cnt <= cnt + 1'b1;会比法更清晰,所以我建议常量一律写成带位宽的。
第二是拼接运算符{}。{a, b}把两个字段拼成一个整体,{4{1'b1}}是重复,等价于4'b1111。拼接在移位和 CRC、FIFO 读写地址合并时特别常用。比如做 8 位数据串并转换时:
shift_reg <= {shift_reg[6:0], rx_bit};这句话相当于把所有 bit 整体左移一位,最低位填入 rx_bit。如果不用拼接,你得写好几行才能实现同样的效果。这也是为什么我说 Verilog 语法基础不需要死记硬背,真正要记的是这些"一句话对应一个电路行为"的惯用法。
| 运算符类别 | 常用符号 | 注意点 |
|---|---|---|
| 按位 | &, |, ^, ~ | 用在多位宽数据上 |
| 逻辑 | &&, ||, ! | 结果只有 0/1 |
| 移位 | <<, >> | 移位后的空位补 0 |
| 拼接 | {}, {{}} | 常用于移位寄存器 |
| 三目 | ? : | 结果位宽取最大操作数 |
| 关系 | == != < > | 结果是 1 位 |
4. 时序逻辑的核心:时钟、复位与阻塞/非阻塞赋值
4.1 为什么所有时序逻辑都离不开always @(posedge clk)
数字电路里,"时序"意味着"在某个时刻对信号进行采样并保存"。Verilog 里最常用的时序逻辑模板就一个:
always @(posedge clk or negedge rst_n) begin if (!rst_n) q <= 1'b0; else q <= d; end敏感列表里有posedge clk,表示这个 always 块只在时钟上升沿到来时被触发。一个模块里可能有很多这样的 always 块,但只要它们都使用同一个时钟,它们在同一个时钟沿一起动作,这就是同步设计的核心。如果有的块用posedge clk,有的用negedge clk,而且没有明确说明这边沿含义,时序分析会变得非常痛苦。所以我给新人的第一条建议是:能用 posedge 就不用 negedge,能有单时钟就不要双时钟。
为什么一定要在 always 块里写posedge clk而不是always @(clk)?因为前者表示"只在时钟沿采样",后者表示"时钟任何时候变化都触发",仿真时后者会在一整个周期里多次执行,完全不符合寄存器行为。这一点在看书时很容易忽略,但写代码时只要写错一次,波形就会乱七八糟。
4.2 阻塞赋值 = 与非阻塞赋值 <=:它们不是换了个符号
这是 Verilog 语法基础里最经典的坑。我直接给结论:
- 时序逻辑(always 里有时钟沿):用非阻塞赋值
<=。 - 组合逻辑(always@(*)):用阻塞赋值
=。 - assign 连续赋值只能用
=,因为没有 always 块里的先后问题。
为什么不用=写时序逻辑呢?看这个交换两个寄存器的例子:
// 错误示例:用阻塞赋值 always @(posedge clk) begin a = b; b = a; end这段代码仿真时,执行第一条语句 a 被更新为新值,执行第二条语句时右边的 a 已经是新值,所以 b 也变成新值,结果 a 和 b 都等于原来的 b,交换失败。综合出来的电路也不是你期望的两个寄存器互换。换成非阻塞赋值:
always @(posedge clk) begin a <= b; b <= a; end非阻塞赋值的核心是"先计算右边,再统一在块结束时更新左边"。两个语句都在同一个时钟沿采样老值,然后同时更新,结果就是干干净净的互换。这个"并行采样、统一更新"的语义,和真实寄存器电路的行为完全一致。
| 比较项 | 阻塞赋值 = | 非阻塞赋值 <= |
|---|---|---|
| 常用场景 | 组合逻辑 | 时序逻辑 |
| 执行方式 | 立即更新 | 块结束时统一更新 |
| 仿真结果 | 受语句顺序影响 | 与语句顺序无关 |
| 对应电路 | 导线/组合逻辑 | 寄存器/触发器 |
4.3 同步复位与异步复位,必须写对敏感列表
上面的模板用的是异步复位:or negedge rst_n表示复位信号的下落沿也会触发 always 块。如果在if (!rst_n)里复位,就是异步复位。它的优点是电路简单、复位响应快,缺点是对复位信号上的毛刺很敏感。同步复位则只在时钟沿采样复位信号:
always @(posedge clk) begin if (!rst_n) q <= 1'b0; else q <= d; end注意这里敏感列表没有or negedge rst_n,复位信号只在时钟沿被采到。两种写法都有大量工程在用,新手至少要知道:异步复位必须把 rst_n 写进敏感列表,否则综合工具可能会按同步复位处理,行为不一致。我自己在简单模块里习惯用异步复位、异步释放的电路,但在基础教学阶段,先掌握一种统一的posedge clk or negedge rst_n模板就够用。还要注意,异步复位的 always 块里,判断if (!rst_n)必须写在最前面,而且是唯一一个不带 clk 条件的判断,如果把其他组合逻辑也写进去,很容易产生复杂的异步路径。
4.4 实战:用计数器产生 1Hz 秒脉冲与数码管刷新时钟
把上面的语法串起来,就是分频计数器的实战。假设开发板时钟是 50MHz,要产生一个 1Hz 的脉冲(周期 1 秒,通常用一个周期宽度的脉冲)。
module pulse_gen #( parameter CLK_FREQ = 50_000_000 )( input wire clk, input wire rst_n, output reg pulse ); reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 32'd0; pulse <= 1'b0; end else if (cnt == CLK_FREQ - 1) begin cnt <= 32'd0; pulse <= 1'b1; end else begin cnt <= cnt + 1'b1; pulse <= 1'b0; end end endmodule注意 CLK_FREQ 必须与 cnt 位宽匹配。50_000_000 需要 26 位,所以我直接用 32 位 cnt。如果图省事写成 24 位,计数到 50_000_000 就溢出,综合也可能给出警告。再进一步,数码管动态显示需要约 1kHz 的刷新信号,只要把CLK_FREQ/1000作为计数目标,每个脉冲让一位数码管点亮即可。这里不展开数码管扫描的电路,但如果你能把 pulse_gen 理解透,后面看数码管例程会非常顺。
这里我额外提一个优化习惯:计数器里不要用cnt == 0作为判断条件,而是用cnt == MAX - 1。前者会让计数器多跑一个周期,而且复位后从 0 开始,和"计数到最大值回零"的直觉一致。为了少出错,我习惯所有计数器都写成"计数到限定值减 1 回零"。
5. 从语法到常见电路:移位寄存器、FIFO 骨架与三段式状态机
5.1 移位寄存器与滑动窗口滤波
很多初学者以为移位寄存器是高级话题,其实它只是拼接运算符的简单应用。8 位移位寄存器只需要一句:
always @(posedge clk or negedge rst_n) begin if (!rst_n) shift_reg <= 8'd0; else shift_reg <= {shift_reg[6:0], din}; end这句话的意思:把原来的低 7 位送到高 7 位,最低位填新数据。一个周期移动一位。如果把它和 ADC 采样数据结合起来,就实现了滑动窗口。比如求最近 4 个采样值的平均值:
wire [9:0] sum4 = sample[0] + sample[1] + sample[2] + sample[3]; assign avg2 = sum4 >> 2;这里的sample可以定义成reg [9:0] sample [0:3];,在时钟里做移位更新。Verilog 里二维数组的语法很多人在这一步才开始遇到,其实并不复杂:第一个维度是元素位宽,第二个维度是数组深度。对数组操作时,你可以把它当成内存看。滑动窗口滤波之所以在 Verilog 里流行,就是因为移动数据用拼接运算符,做加法用并行加法器,语法开销很小。实际用的时候要注意采样数据的位宽,尤其是做平均时加法结果位宽要留够余量,否则很容易溢出。
5.2 同步 FIFO 的骨架:理解 Verilog 的内存模型
每次看到"FIFO 的 verilog 代码实现"这个话题,评论区总有一堆人直接丢一个几百行的 IP 核。我理解,但学习语法时,自己写一个最简单的同步 FIFO 骨架反而更能建立底层感觉。Verilog 里定义存储器的标准写法是:
reg [7:0] mem [0:15]; // 16 字节的存储阵列读写某一位:mem[wr_ptr] <= din; assign dout = mem[rd_ptr];。一个基本同步 FIFO 需要读指针、写指针、计数器和存储阵列,读指针指向当前要读的地址,写指针指向当前要写的地址。当写使能和读使能同时有效且非空非满时,两个指针都递增。听着复杂,但代码里就是几个 always 块加上指针的加一操作。这一段的目的是让你理解:Verilog 完全可以用数组描述寄存器堆,这是后续学 RAM、FIFO、图像行缓存的基础。
FIFO 里最容易错的是"空"和"满"的判断。最笨的办法是维护一个计数器,写一次加一,读一次减一;当计数器为 0 是空,等于 DEPTH 是满。这个写法在语法上没有问题,只是高频下性能未必好。新手阶段先把这个写出来,比直接调用 IP 更能理解读指针和写指针的关系。之后再去用 Xilinx/Intel 的 FIFO IP,你会发现自己能看懂 IP 的端口和时序了。
5.3 三段式状态机:UART 接收的简化视角
状态机是 FPGA 工程师的"条件反射",Verilog 语法掌握之后,建议尽早学会三段式写法。三段式的核心是把状态机的三个部分分开:第一段时序逻辑做状态跳转,第二段组合逻辑根据当前状态和输入计算下一状态,第三段时序逻辑根据当前状态产生输出。
以一个 UART 接收单字节的简化状态机为例:
localparam IDLE = 2'd0; localparam START = 2'd1; localparam DATA = 2'd2; localparam STOP = 2'd3; reg [1:0] state, next_state; 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 (rx == 1'b0) next_state = START; START: next_state = DATA; DATA: if (bit_cnt == 3'd7) next_state = STOP; STOP: if (rx == 1'b1) next_state = IDLE; default: next_state = IDLE; endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) rx_data <= 8'd0; else if (state == DATA && bit_cnt == 3'd7) rx_data <= {rx_bit, rx_data[7:1]}; end第一段固定,第二段组合逻辑可以先默认 next_state=state,再按条件覆盖,这样不容易产生锁存器。第三段时序逻辑输出寄存器化,避免输出毛刺。你不需要现在就写出完整的 UART,但看懂这个骨架,就已经把 if/case、parameter、always、非阻塞赋值这些语法全部用上了。三段式的命名也很直观:第一段管跳转,第二段管"下一状态去哪",第三段管"当前状态输出什么"。很多面试题会问"三段式和二段式的区别",说到底就是输出是否打了一拍寄存器,以及组合逻辑是否容易出毛刺。
| 段 | 敏感列表 | 赋值方式 | 职责 |
|---|---|---|---|
| 第一段 | posedge clk | <= | 把 next_state 锁存到 state |
| 第二段 | @(*) | = | 根据 state 和输入算 next_state |
| 第三段 | posedge clk | <= | 根据 state 产生输出 |
6. 仿真验证:写一个像样的 testbench,把语法"跑"起来
6.1 最小可用的 testbench 模板
写 Verilog 不仿真,等于盲人摸象。testbench 的本质是你用 Verilog 写一个"测试环境",给被测模块提供时钟、复位和输入,然后观察输出。下面是一个针对 pulse_gen 的 testbench 模板:
`timescale 1ns / 1ps module tb_pulse_gen; reg clk; reg rst_n; wire pulse; pulse_gen #( .CLK_FREQ(50) // 仿真时把频率参数改小,快速看到脉冲 ) uut ( .clk (clk), .rst_n (rst_n), .pulse (pulse) ); initial begin clk = 0; rst_n = 0; #20; rst_n = 1; #5000; $finish; end always #10 clk = ~clk; // 50MHz => 20ns 周期 initial begin $monitor("time=%t, pulse=%b", $time, pulse); end endmoduletimescale 1ns/1ps定义了仿真时间单位:#20表示延迟 20ns。always #10 clk = ~clk生成周期 20ns 的时钟。注意 clk 的初值必须是 0,否则第一次取反后不是 50% 占空比。例化时通过#(.CLK_FREQ(50))把分频参数改小,仿真 5000ns 就能看到脉冲,不用等 50M 个周期。这种做法我们叫"参数化仿真",是写 testbench 最常用的加速手段。如果你把CLK_FREQ留在 50_000_000 仿真,1 秒脉冲意味着要跑 50M 个时钟周期,仿真器会卡很久。
6.2 常见仿真错误:从时钟死循环到复位释放
我整理几个新手高频踩坑,每一个都见过不止一次。
第一,时钟生成死循环。有人喜欢写always #10 clk = ~clk;但没给 clk 初始化,结果 clk 一直是 X,仿真器卡在那里。务必在 initial 里先clk = 0;。
第二,复位释放时序不对。testbench 里复位信号必须持续足够长时间,至少覆盖几个时钟沿。比如 50MHz 时钟下,复位至少保持一个周期,工程上我习惯给 20ns 以上:#20; rst_n = 1;。如果复位太短,被测模块可能没有正常复位,仿真波形出现未知态。
第三,仿真时间设置太短。有些人写完 testbench 发现波形没变化,一看仿真时间只跑了几十纳秒,而分频目标要几百万周期。用我上面提到的参数改写就会好很多。
第四,忘记$finish。虽然仿真器会在你关闭窗口时结束,但规范里应该在测试结束后显式$finish,否则守护进程可能一直占着内存。
第五,如果你用第三方仿真器,遇到类似 license 相关的报错,先检查环境变量和授权文件,或者直接换用 EDA 工具自带的仿真器,通常能绕开一堆配置问题。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 波形全是 X | 复位没释放/变量未初始化 | 检查 initial 和 rst_n 时序 |
| 时钟不翻转 | clk 没有初值或 always 写错 | initial 里赋初值 |
| 输出一直是复位态 | 复位时间过长/时钟没跑起来 | 检查always #10 clk = ~clk |
| 仿真卡死 | 组合逻辑死循环/时钟死循环 | 检查敏感列表和赋值方向 |
| 和综合结果不一致 | 敏感列表漏信号 | 改用always @(*) |
6.3 从波形里定位语法 bug 的排查思路
仿真结果不对时,我一般不看整条波形,而是先加三组信号:时钟、复位、被测模块内部的关键计数器。Vivado 和 Quartus 的仿真窗口都支持把内部信号拖进波形区,不必只盯输出端口。如果你发现输出没变化,先看复位有没有释放,再看计数器有没有递增,一路往前查。很多时候问题出在赋值符号上:如果想在时序块里用=,波形会出现"同一拍内信号立即变化"的效果,这往往就是和预期不一致的根源。把=换成<=再仿真一次,世界就清净了。这种排查法听着简单,但真的能解决 80% 的入门期问题。
我自己刚学的时候也犯过把所有信号都用 reg、所有模块都写成一个大 always 的毛病,后来发现改成一个模块一个功能块、内部用 wire 连接,仿真调试舒服得多。Verilog 语法基础,说到底不是为了让你背语法手册,而是让你在写每条语句时都能想到它对应的电路长什么样。你把这篇文章里的模块、数据类型、阻塞/非阻塞赋值、状态机骨架都练一遍,再回头去看数码管动态显示和 UART 收发例程,会发现那些代码基本都能看懂了。