1. Verilog 究竟是语言还是电路先掰正这几个思维上一篇聊了 FPGA 开发环境的搭建有位朋友照着步骤在 Vivado 里跑通了点灯实验很兴奋。结果没过两天他把自己写的数码管扫描代码发给我说仿真波形完全不是预期。我问他怎么写的他说我就是把 C 语言的 if-else 直接搬过来了。这就是很典型的 Verilog 语法入门问题——你不是不懂代码你是还在用软件思维写硬件。这一篇我就从 Verilog 语法基础开始把硬件描述语言和 C 语言之间最关键的几个差异讲透。适合刚买开发板、正在折腾点灯和数码管或者已经抄过几个例程但总觉得地基不稳的读者。1.1 并行执行所有语句不是从上到下而是同时通电很多人第一次看到 Verilog 代码会懵为什么一个模块里能同时出现好几个 always、好几个 assignC 语言里一个函数只能从上往下跑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 24d1_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 24d0; else if (cnt HALF_PERIOD - 1) cnt 24d0; else cnt cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) led 1b0; 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-2001Xilinx 和 Intel 的开发工具都支持。还有一个细节inout 端口内部通常用高阻态 z 表示释放总线比如三态缓冲器的写法是assign sda en ? data : 1bz;。很多新手一看到 inout 就头大其实记住不驱动时输出高阻这句话就够了。2.2 wire 与 reg 的分工协议wire 和 reg 的区别是所有语法书都会写但很多教程讲不明白的。我用一句大白话总结wire 是被别人驱动的线reg 是被 always 块或 initial 块驱动的变量。wire只能出现在 assign 左侧、作为模块输入端口、或连接例化的子模块端口。它不保存值只要驱动源变化就跟着变。reg可以在 always 或 initial 里被赋值。注意reg 不一定是真正的寄存器也可能被综合成导线比如组合逻辑的 always 块里输出声明为 reg但综合后只是组合电路。项目wirereg驱动方式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) 2d0: y a; 2d1: y b; 2d2: y c; 2d3: y d; default: y 8d0; endcase end endmodule这里 y 声明为 reg但它实际上是组合逻辑不是寄存器。判断方法很简单如果 always 块的敏感列表里只有输入信号没有时钟沿综合出来就是组合电路如果敏感列表里有posedge clk/negedge rst_n综合出来才是时序电路。这个判断方法比死记reg 等于寄存器有用得多。2.3 parameter 与 localparam写可复用的模块写 Verilog 最忌讳到处写死数字。比如数码管刷新用 1kHzUART 波特率需要 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 1cnt 只有 20 位比较时高位会被截断行为可能不符合预期。所以我一般会给 parameter 写明确位宽比如parameter [31:0] CLK_FREQ 50_000_000。3. 组合逻辑的三种写法assign、always 与运算符里的隐藏坑3.1 从布尔方程到 assign 连续赋值组合逻辑是输出由当前输入直接决定的逻辑Verilog 里最直接的描述就是 assign。比如做一个比较器assign result (a b) ? 1b1 : 1b0;也可以用逻辑表达式直接写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 8d0; // 默认值先写好 if (en) y a; end这个提前赋默认值的技巧非常实用它比在每个分支都补 else 要省事得多也能避免所有分支都覆盖的漏网之鱼。实际工程里不管是用 case 还是 if我都习惯在开头写一次默认赋值后面只写需要覆盖的条件。这样代码短也更容易 review。3.3 运算符优先级与位宽仿真和综合不一定是同一回事Verilog 运算符挺多算术、逻辑、按位、移位、拼接、关系、三目。常用的基本不会踩坑我重点提两个。第一位宽。Verilog 里未标注位宽的数字默认是 32 位比如 1、8d0 这种写法里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 1b1;会比法更清晰所以我建议常量一律写成带位宽的。第二是拼接运算符{}。{a, b}把两个字段拼成一个整体{4{1b1}}是重复等价于4b1111。拼接在移位和 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 1b0; 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 1b0; 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 32d0; pulse 1b0; end else if (cnt CLK_FREQ - 1) begin cnt 32d0; pulse 1b1; end else begin cnt cnt 1b1; pulse 1b0; 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 8d0; 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 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; 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 1b0) next_state START; START: next_state DATA; DATA: if (bit_cnt 3d7) next_state STOP; STOP: if (rx 1b1) next_state IDLE; default: next_state IDLE; endcase end always (posedge clk or negedge rst_n) begin if (!rst_n) rx_data 8d0; else if (state DATA bit_cnt 3d7) rx_data {rx_bit, rx_data[7:1]}; end第一段固定第二段组合逻辑可以先默认 next_statestate再按条件覆盖这样不容易产生锁存器。第三段时序逻辑输出寄存器化避免输出毛刺。你不需要现在就写出完整的 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 收发例程会发现那些代码基本都能看懂了。
阅读完成 · 觉得有帮助?