
别看“过程块”三个字听起来像教材里的官方术语实际上它就是 Verilog 这门硬件描述语言的“心脏”。无论你写的是几十行的计数器还是上万行的 UART 收发器、FIFO 缓存、图像滤波模块最终落到底层都逃不开两个关键字initial和always。我见过不少刚接触 FPGA 的同学能把assign用得滚瓜烂熟一碰到过程块就开始懵——什么时候用always (*)、什么时候用always (posedge clk)、为什么有人说initial在综合时会被忽略、为什么一写状态机就报锁存器警告……这些问题本质上都指向同一个点你没把过程块的执行模型真正吃透。这篇博客我会按工程实践的逻辑把initial和always掰开揉碎。先从它们的执行机制讲起再深入到敏感列表、赋值方式、锁存器问题、仿真与综合的差异最后给出几个直接能抄的代码模板和排查技巧。不管你是刚摸 Verilog 的初学者还是写了几个月仍然被仿真波形坑到怀疑人生的进阶玩家这篇文章都能帮你把过程块这个地基重新打牢一次。1. 一张图看懂过程块initial 和 always 的本质分工1.1 initial 块只执行一次的“仿真初始化脚本”initial块的执行语义用一句话就能概括仿真从 0 时刻开始后它立即执行并且只执行一次。执行完最后一条语句这个块就“死掉”了不会再被触发。这里推荐所有初学者把initial想象成一段 C 语言里的main函数之前运行的初始化代码——但有一个关键区别C 程序运行到return就结束了而 Verilog 仿真里initial只是众多并行执行的进程之一它初始化完信号之后仿真器还有无数个always块在同时跑着。initial的典型语法结构如下initial begin clk 1b0; rst_n 1b0; data_in 8h00; end实际仿真中initial最常见的用途就是给 testbench 里的信号赋初值。比如在仿真刚开始的t0时刻把复位信号拉低、把时钟信号拉低、把所有输入信号置成安全电平。这些操作如果你不做仿真器里大多数信号默认是x未知态后面的波形根本没法看。还有一个高频用法是配合forever语句生成时钟initial begin clk 1b0; forever #10 clk ~clk; end这段代码的意思是先把 clk 置 0然后每隔 10 个时间单位翻转一次于是我们就得到了一个周期为 20 个时间单位的时钟信号。你在很多网上下载的 testbench 模板里都会看到这种写法它就是initialforever的经典组合。另一类常见应用是文件操作。仿真时需要把数据写入文本文件或者读取测试向量文件都可以在initial块里用$fopen、$fwrite、$readmemh完成。因为文件操作只需要做一轮天生就适合在initial里做。提示如果某个信号只是给仿真用的比如测试平台的参考信号、激励数据那你就放心大胆地放进initial。如果一个信号是要被综合到 FPGA 的寄存器那就要小心了——initial里的赋值综合工具通常会直接忽略或者给出错误提示。硬件的上电初始值由寄存器本身的复位值决定不归initial管。1.2 always 块反复触发的“执行引擎”always的意思是“一直、总是”它的执行模型是一旦满足触发条件就执行一次块内语句执行完之后继续等待下一次触发条件到来如此反复直到仿真结束。这个“触发条件”由敏感列表决定。敏感列表写在后面的括号里常用的写法有三种// 写法一电平敏感组合逻辑推荐 always (*) begin y a b | c; end // 写法二边沿敏感时序逻辑推荐 always (posedge clk) begin q d; end // 写法三带异步复位的边沿敏感 always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end理解always的关键在于块内的语句不是顺序执行一次就完了而是像一台循环等待的机器——条件一满足马上执行一遍执行完立刻回到等待状态。有人会问那我写了很多个always块它们之间是什么关系答案是并行关系。硬件世界里没有“主函数”和“子函数”这种调用链所有always块、initial块、assign连续赋值语句在仿真开始时就是同时存在的独立进程。它们在时间轴上交替执行互相之间通过信号来通信。这个观念如果扭不过来后面学状态机、学多模块通信会非常吃力。2. 必须刻在骨头里的规则敏感列表与赋值方式2.1 敏感列表的三种写法选错一个就等着仿真翻车先说组合逻辑的敏感列表。早期教材里经常写always (a or b or c)意思很直白只要a、b、c中任意一个信号发生变化就重新计算输出。这种写法有个致命弱点如果模块里有 10 个输入信号你少写了一个进敏感列表仿真时那个信号变化不会触发块内更新输出就会维持旧值。问题在于综合工具是按“块内使用到的所有信号”来推断硬件的它不会因为敏感列表少了信号就少接一根线。结果是仿真波形正常综合后上板行为不对。这种 bug 极难排查因为它对仿真和综合的结果表现不一致容易被误判成时序问题。所以现在的工程实践基本统一使用always (*)也写作always *意思是“由块内被读取的所有信号自动组成敏感列表”。这是最安全、最省心的写法我自己写 RTL 代码时组合逻辑一律用(*)从不用手写信号列表。再说时序逻辑。时序逻辑的敏感列表只需要写时钟边沿以及可选的异步复位信号。常见的两种// 同步复位敏感列表只有时钟 always (posedge clk) begin if (!rst_n) cnt 4d0; else cnt cnt 1b1; end // 异步复位敏感列表同时有时钟和复位 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4d0; else cnt cnt 1b1; end这里要特别提醒时序逻辑的敏感列表里千万不要写数据信号比如always (posedge clk or d)。这在语法上合法但行为会非常奇怪d一变块也会被触发执行一次导致输出多变化一次。真出现这种问题仿真波形会“抽风”式地多跳几拍排查起来非常痛苦。我见过有同学把always (posedge clk or negedge rst_n)写成了always (posedge clk or rst_n)导致复位无法正常生效波形乱成一团。2.2 阻塞赋值 vs 非阻塞赋值为什么说这是 RTL 设计的送分题也是送命题过程块里最绕不过去的知识点就是阻塞赋值和非阻塞赋值的区别。很多教材会告诉你一个口诀组合逻辑用阻塞赋值时序逻辑用非阻塞赋值异步复位里也一样用非阻塞。这个口诀当然要背下来但更重要的是理解背后的原因。阻塞赋值的含义是赋值语句立即生效下一条语句执行时左边的变量已经是新值了。它的行为模式和 C 语言里的赋值完全一致是一种“串行思维”。非阻塞赋值的含义是块内所有右值先统一采样左值在块结束后统一更新。也就是说你在同一个时钟沿触发的块里写了多条赋值它们在执行时读到的都是进入块之前的值更新则发生在块结束的时刻。这就是并行思维它模拟的是硬件里多个寄存器在同一时钟沿同时采样的真实物理行为。举个例子假设我们要在时钟上升沿做一个简单的移位寄存器// 用非阻塞赋值仿真结果符合硬件行为 always (posedge clk) begin q1 d; q2 q1; end在同一个时钟沿q1采样的老值是d之前的值q2采样的是q1的旧值。所以q2得到的是延迟两拍的数据符合移位寄存器预期。如果换成阻塞赋值always (posedge clk) begin q1 d; q2 q1; end这就出问题了。第一条语句先执行q1立刻变成d第二条语句再执行时右值q1读到的已经是新值d。结果q2和q1在同一拍变成了相同的数据等于你写了一个“一拍直通”的逻辑而仿真波形看起来又完全正常。但真正综合到硬件里这种写法会产生级联寄存器行为反而和仿真不一致。说到底阻塞和非阻塞的选择不是风格问题而是仿真语义与硬件结构一致性问题的根源。时序逻辑里混用和是仿真波形和上板行为不一致的头号嫌疑犯。3. 深入时钟的世界时序逻辑中的 always 实际用法3.1 同步复位与异步复位的写法差异上一节提到同步复位和异步复位是两种常见的时序逻辑写法这里展开多说几句。所谓同步复位是指复位信号只影响时钟有效沿来临时寄存器的工作状态所谓异步复位是指复位信号一旦有效不管时钟沿到没到寄存器立刻被清零或置位。实际工程中FPGA 设计大部分寄存器用异步复位而且通常是低电平复位。原因主要在于 FPGA 内部寄存器的复位端天然支持异步复位而且很多板卡上的按键复位信号也是低电平有效直接用negedge rst_n最方便。写异步复位时有一个老生常谈的注意点复位释放的时候要避免“亚稳态”。如果复位信号在时钟沿附近释放寄存器可能无法判断是复位还是正常工作。常见做法是加一个“复位同步器”用两级寄存器把异步复位信号同步到时钟域后再释放。这段逻辑虽然简单但能规避大量上板时的偶发复位异常。提供一段最标准的异步复位模板直接抄作业即可always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 5d0; flag 1b0; end else begin cnt cnt 1b1; if (cnt 5d24 flag 1b0) flag 1b1; end end3.2 计数器与分频器实战一个 always 玩遍时序逻辑计数器是 FPGA 开发里的“零号案例”UART 波特率产生、PWM 周期控制、定时器延时全都靠它。我们以一个模 10 计数器为例看看always在时序逻辑里完整的写法module counter_10( input clk, input rst_n, output reg [3:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4d0; else if (cnt 4d9) cnt 4d0; else cnt cnt 1b1; end endmodule这段代码里有一个非常值得注意的点cnt声明成了output reg而不是output wire。因为它在always块里被赋值必须用reg类型。很多初学者在这里犯晕不是说了reg才是寄存器吗其实这不是绝对的——always块中赋值的变量必须声明为reg但带always的组合逻辑也会产生reg声明的变量综合出来却可能只是连线。reg是语法层面的变量类型和硬件是不是寄存器没有直接对应关系。再说分频器。假设系统时钟 50MHz我们需要一个 1MHz 的使能脉冲。最简单的方式是做一个 25 分频计数器计到 24 时输出一个高电平脉冲而不是生成一个 1MHz 的时钟信号。因为 FPGA 里到处用全局时钟网络自己用always生成的“派生时钟”不仅时序难约束还容易造成时钟偏斜工程上通常不推荐。reg [4:0] div_cnt; reg clk_1m_en; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 5d0; clk_1m_en 1b0; end else if (div_cnt 5d24) begin div_cnt 5d0; clk_1m_en 1b1; end else begin div_cnt div_cnt 1b1; clk_1m_en 1b0; end end在后面的逻辑里直接使用clk_1m_en作为使能信号而不是当时钟用。这样做的好处是整个设计仍然只有一个时钟域时序收敛容易控制也不容易产生毛刺。这个经验属于做过几个项目之后才会明白的坑我在刚开始写代码时也曾经“图省事”直接分频出时钟结果上板后数据采集总是偶发错误后来改成时钟使能方案才彻底稳定。4. 组合逻辑中的 always*与锁存器的爱恨情仇4.1 分支没写全锁存器是怎么长出来的当你在always (*)块里描述组合逻辑时有一个必须刻进 DNA 的规则每个分支路径上所有被赋值的变量都必须被完整赋值。只要有一个分支漏了赋值综合工具就会推断出锁存器Latch。锁存器本身并不可怕但 FPGA 设计中大量意外产生的锁存器会带来两个严重问题一是时序分析变得困难二是毛刺容易传播到后续电路。所以在组合逻辑代码中我们要尽量避免工具“顺手”推断出锁存器。典型错误写法如下always (*) begin if (en) q d; // 缺少 else 分支en0 时 q 保持旧值推断出锁存器 end这段代码在en0时没有为q赋值工具只能靠锁存器让q保持原值。要避免锁存器补上else分支即可always (*) begin if (en) q d; else q 1b0; end同样的道理适用于case语句。如果你用case写状态机的输出逻辑default分支必须写而且每个case分支内部的所有输出信号都要赋齐。综合工具的警告信息里只要出现 “inferred latch” 或 “latch inferred”八成就是你漏分支了。补充一个排查小技巧在 Quartus 或 Vivado 的编译报告里搜索latch关键字能直接定位到产生锁存器的文件和行号。不要对这个警告视而不见它背后大概率有一个逻辑缺陷在等着你。4.2 组合逻辑到底该用 assign 还是 always *很多初学者纠结组合逻辑既能用assign连续赋值写也能用always (*)写二者到底有什么区别从综合结果看二者没有本质区别。assign适合描述简单逻辑比如assign sum a b;一行搞定。always (*)适合描述复杂组合逻辑尤其是有if-else、case分支的情况。比如状态机的次态逻辑、多路数据选择器、译码器用always (*)写的可读性会远高于多个assign的嵌套。举个例子状态机的次态逻辑如果是组合逻辑典型写法如下always (*) begin next_state current_state; case (current_state) IDLE: begin if (start) next_state RUN; end RUN: begin if (done) next_state IDLE; end default: next_state IDLE; endcase end这个风格属于状态机三割中的“第二段”即组合逻辑计算下一个状态。第一段是时序逻辑负责在时钟沿把next_state打给current_state第三段可以是时序逻辑输出也可以是组合逻辑输出。热词里提到“verilog 三段式状态机”其实就是用三个always块分别描述状态跳转、次态计算、输出逻辑这种写法规范清晰推荐新手直接采用。注意组合逻辑的always (*)块里一定要用阻塞赋值不要用非阻塞。虽然综合工具大多能容忍但仿真行为会变得难以预测尤其是在多路径级联的组合逻辑里容易出现莫名的中间态毛刺。5. initial 与 always 在测试平台中的实战技巧5.1 经典 testbench 骨架时钟和复位怎么生成写 testbench 大概是initial块最有存在感的地方。我直接给出一份常用的 testbench 模板几乎每个项目都可以套用module tb_top; reg clk; reg rst_n; reg tx_en; reg [7:0] tx_data; wire tx_line; // 时钟生成周期 20ns频率 50MHz initial begin clk 1b0; forever #10 clk ~clk; end // 复位与激励生成 initial begin rst_n 1b0; tx_en 1b0; tx_data 8h00; #100 rst_n 1b1; // 复位释放 #100 tx_en 1b1; tx_data 8hA5; #20 tx_en 1b0; #2000; $finish; end // 例化被测模块 uart_tx u_uart_tx ( .clk (clk), .rst_n (rst_n), .tx_en (tx_en), .tx_data (tx_data), .tx_line (tx_line) ); endmodule注意我在这里故意把时钟生成放在了initial forever里这比单纯用always #10 clk~clk;更容易控制初始时刻。如果你用always生成时钟仿真一开始clk没有初始值第一次翻转后变成 1第二次翻转后变成 0前半段波形总会缺半拍。当然你可以在另一个initial里给clk赋初值但两个进程都要维护稍微繁琐一点。5.2 用 initial 写激励task 调用、延时控制、文件输出testbench 里给信号产生激励时不可避免要用延时控制#。#100表示等待 100 个时间单位(posedge clk)表示等待下一个时钟上升沿。很多人把这两个用得很随意结果仿真的激励时机和预期的协议时序对不上。这里分享一个 UART 发送测试激励的示例说明initial配合task的写法task send_byte; input [7:0] data; integer i; begin tx_line 1b0; // 起始位 #10416; // 9600 波特率下每一位的时长 for (i 0; i 8; i i 1) begin tx_line data[i]; #10416; end tx_line 1b1; // 停止位 #10416; end endtask initial begin rst_n 1b0; #100; rst_n 1b1; #100; send_byte(8h5A); send_byte(8hA5); #500; $finish; end这样写的好处是结构清晰复用性强。如果后面要连续发送几十帧测试数据只需要反复调用send_byte即可不用在initial里重复铺一大片#和赋值语句。关于文件输出我经常用$fwrite把仿真结果记录下来方便和 MATLAB/Python 的参考模型做比对。示例integer fp; initial begin fp $fopen(output.txt, w); end always (posedge clk) begin if (data_valid) $fwrite(fp, %d\n, data_out); end当然initial块还可以配合$monitor打印变化值、配合$readmemh读取初始化文件、配合$finish结束仿真。这些功能全部天然属于“只做一次”的行为模板归initial管就对了。6. 综合陷阱与新手最容易踩的五个坑6.1 initial 块在 FPGA 里的真相先说一个常见误解有人以为在 RTL 代码里写initial块给寄存器赋值上电后寄存器就有初值了。这个说法一半对一半不对。如果综合工具是 Quartus 或 Vivado它们对initial的处理规则是支持在仿真模型里用initial初始化存储器和寄存器但在以寄存器为目标的综合中initial里的赋值通常会被忽略或转化为警告。真正决定 FPGA 寄存器上电值的是寄存器复位端的复位状态以及配置时生成的位流中的初始值。所以在 RTL 设计里给寄存器置初值最稳妥的思路是用显式复位。或者如果你确定某个信号在系统复位释放前不会被外部使用可以依赖 FPGA 的寄存器默认复位值一般是 0但这个依赖较大不同器件可能不同不建议赌。唯一一个可以放心用initial的 RTL 场景是只读存储器ROM的内容初始化比如reg [7:0] rom_data [0:255]; initial begin $readmemh(rom_init.hex, rom_data); end这类代码在 FPGA 综合时会被转成存储器初值配置仿真时也能正常加载属于两全其美的例外。6.2 多驱动、敏感列表遗漏、加工商锁存器问题速查表最后整理一个我在线下带新人和论坛答疑时经常提到的常见问题速查表每一条都是真实踩过的坑症状可能原因解决思路仿真波形正常上板行为异常敏感列表里漏写信号导致仿真少触发组合逻辑统一用always (*)不要手写敏感列表仿真波形多跳了一拍时序逻辑里误用了阻塞赋值时序逻辑统一改用非阻塞赋值综合报告出现 inferred latch组合逻辑分支未赋全补全else/default确保所有路径都赋值复位无效或复位后值不对异步复位敏感列表写法错误检查always (posedge clk or negedge rst_n)或posedeg的拼写多个always给同一个信号赋值多驱动问题一个信号只能在一个always块里赋值模块间用端口互联initial块里的值上电后不对综合工具忽略initial改用复位信号控制寄存器初始值仿真卡死或永远跑不完组合逻辑环路检查组合逻辑里是否存在a b; b a;之类的反馈环数据总差一个周期时序逻辑的使能没有对齐使用三段式状态机把输出寄存一拍能彻底规避组合毛刺这里面最隐蔽的是“多驱动”问题。一个信号被两个always块同时赋值综合工具往往只报一个警告甚至不报但最后生成的电路行为完全无法预判。我通常的做法是写完代码后用grep搜一下每个reg信号的赋值位置确保一个信号只出现在一个always块里。这个习惯帮我节省过大量调试时间。6.3 过程块之外别忘了复位同步和代码规范除了上面这些直接与initial、always相关的问题还有两个容易被忽略的实践习惯顺手也提醒一下。第一异步复位的释放最好做同步处理。前面提到复位释放容易产生亚稳态工程上典型做法是增加两级复位同步寄存器reg rst_n_r1, rst_n_r2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rst_n_r1 1b0; rst_n_r2 1b0; end else begin rst_n_r1 1b1; rst_n_r2 rst_n_r1; end end然后整个工程内部统一使用rst_n_r2作为复位信号而不是外部原始复位管脚。这样做可以让复位释放的时刻被时钟沿 “稳定捕获”避免寄存器进入亚稳态。第二always块里的代码规范要统一。比如每个always块只描述一个功能不要一个大块里既写状态寄存器又写输出逻辑条件判断层层嵌套不要超过三层超过就拆 case 或拆函数。过程块本身并没有限制代码长度但一个几百行的大always块调试起来绝对让人抓到头皮发麻。另外提一嘴工程里常见的状态机、UART、FIFO 场景。你写的always块数量会随着设计复杂度直线上升UART 发送端需要波特率计数器、发送移位寄存器、状态机三个以上时序逻辑块FIFO 读写指针更是一堆边沿触发的always。只要你在写这些之前把initial、always的执行语义和赋值规则彻底弄清楚后面从这些复杂模块里找出问题的速度会快一个量级。我到现在写代码时仍然会下意识先问自己三个问题这个信号是组合逻辑还是时序逻辑它应该在哪个敏感列表下触发块内赋值该用还是这三个问题想清楚了过程块这一关就算真正过了。