
学Verilog的时候十有八九都问过这个问题为什么有些代码写在initial里有些写在always里干脆只用一个行不行这个问题我当年也琢磨了挺久直到真正理解“过程块”这三个字的含义才算是把Verilog的核心思维逻辑理清了。这篇就聊聊Verilog过程块里的两个当家花旦——initial和always。它们的作用、区别、综合边界以及新手最容易被坑的赋值规则和敏感列表问题都一次性捋清楚。不管你是刚接触硬件描述语言的学生还是准备做FPGA开发的工程师这篇文章都能帮你从“照抄模板”进步到“理解原理”。1. 为什么叫“过程块”先从C语言思维理解它1.1 过程块的本质一段顺序执行的语句如果你学过C语言那理解“过程”这个词就比较容易。C语言里的函数本质上就是一个过程——它有入口有出口里面的语句一条挨一条按顺序执行。Verilog里的initial和always块就是硬件描述语言里的“过程”。什么意思呢看下面这个例子initial begin a 1b0; b 1b1; c a b; end在这个initial块里三条语句是从上到下顺序执行的。先给a赋0再给b赋1最后把a和b相与的结果赋给c。在没有任何时间延迟的情况下这三条语句在同一个仿真时刻内完成但这个“顺序”关系是存在的。这和assign连续赋值有着本质区别assign c a b;assign描述的是一个持续存在的电路连接只要a或b发生变化c立即更新它没有“执行步骤”的概念不需要一个过程去驱动它。而过程块则像是一段“程序代码”需要一个“启动条件”才会开始执行启动之后内部语句按规则一条条跑。1.2 过程块和assign连续赋值的最大区别我见过不少初学者把assign写进always块里或者反过来在initial里写assign然后疯狂编译报错。这实际上是没有抓住两类描述方式的本质差异。描述方式赋值对象执行方式触发机制assign连续赋值必须是wire类型无顺序概念持续驱动右侧信号变化立即更新过程块赋值必须是reg类型块内语句按顺序执行由仿真启动或敏感列表触发这里面最要紧的是第一行和第三行assign不能写在过程块里过程块里给变量赋值这个变量也必须是reg类型。为什么因为reg代表的是一个“可以存储值”的变量过程赋值的本质是先计算、再保存这个过程需要存储载体。而wire代表的是物理导线它本身没有存储能力只能被动地被持续驱动。1.3 多个过程块之间是并行关系这是理解Verilog最基本的思维转变。C语言里函数之间是调用关系main函数调用了某个函数它才会执行。但Verilog模块里的多个initial块和always块它们是并行执行的没有“谁调用谁”的概念也没有绝对的先后顺序。initial begin $display(第一个initial块); end initial begin $display(第二个initial块); end上面这两个initial块理论上都在仿真0时刻开始执行但谁先谁后并不确定。在仿真器里它们的执行顺序取决于仿真器的调度算法所以不要写那种“依赖多个 initial 块执行先后顺序”的代码。always块之间同样如此。每个always块独立地被自己的敏感列表触发块和块之间并行运行。这一点在写状态机时特别重要你通常用一个always写时序逻辑状态跳转再用另一个always写组合逻辑输出这两个块是同时工作的一个负责存状态一个负责算输出。2. initial块仿真世界的启动程序2.1 语法结构与时序理解initial块的语法非常简单initial begin // 语句1; // 语句2; ... end它有两个显著特点仿真启动时自动执行整个仿真过程中只执行一次。这就像是程序里的“开机初始化例程”上电之后跑一遍就完事后面再也不会进来了。这里有个细节要注意initial块不是像always那样“反复执行”的它执行完最后一个语句就直接结束了。如果initial块里只有一个语句begin...end可以省略但为了避免后续加语句时忘记补我建议一律写上。2.2 典型的四类用法initial块的用武之地主要在 testbench测试平台里它有几种高频使用场景。第一类初始化信号reg clk; reg rst_n; reg [7:0] data_in; initial begin clk 1b0; rst_n 1b0; data_in 8h00; end这种初始化写法在可综合代码里是不被认可的但在仿真测试时非常常用。仿真开始的那一刻rst_n是低电平data_in是全0保证被测模块有一个确定性的初始状态。第二类生成时钟信号initial begin clk 1b0; forever #10 clk ~clk; // 每10个时间单位翻转一次周期20 end这里用了forever循环配合延迟控制模拟出一个周期为20ns的时钟。这是仿真里最经典的时钟生成方式。注意forever本身是不可综合的但在仿真代码里属于常规操作。第三类生成复位信号initial begin rst_n 1b0; #100; rst_n 1b1; // 释放复位 end先拉低复位100个时间单位让模块完成复位初始化然后拉高开始正常工作。也可以结合$display打印一下复位释放的仿真时间点方便观察波形。第四类调用仿真系统任务initial begin $monitor(time %0t, cnt %0d, $time, cnt); #1000; $finish; end$monitor是监控任务只要参数列表里的任何变量发生变化就自动打印当前信息。$finish是结束仿真任务。这两个搭配起来就是最基础的“自动跑完自动停”的仿真框架。一个小技巧很多$finish的问题在于仿真时间没跑够或者跑太久。建议用宏参数控制结束时间比如#1000 $finish;需要调时长时只改一个数。2.3 initial块能综合吗直接给结论常规情况下initial块不可综合。这里的“不可综合”指综合工具无法把它转换为逻辑门级电路。原因也直观——硬件电路上电后寄存器里的值是随机的有些FPGA是0有些是1没有一条物理机制能让某个寄存器在“上电瞬间”被赋予一个确定值。所以可综合代码里是不允许用initial初始化寄存器的。但这不代表initial一无是处。在测试仿真领域initial块就是御用工具。业界通常把代码分为“可综合代码”RTL设计代码和“仿真代码”testbenchinitial块就是仿真代码的主力干将。有同学可能会问那FPGA里的寄存器初值怎么设答案是靠复位信号。在always块里通过复位逻辑把寄存器设成初始值。这个下一章会详细讲。3. always块既能描述组合逻辑也能描述时序逻辑3.1 组合逻辑建模电平敏感的写法如果说initial是“跑一次拉倒”那always就是“条件满足就反复执行”。它的标准语法always (敏感列表) begin // 语句; end敏感列表决定了这个always块什么时候被触发执行。对于组合逻辑常见写法是(*)意思是“任何输入信号变化都触发”。// 用always描述一个2选1多路选择器 module mux2_1( input wire a, input wire b, input wire sel, output reg y ); always (*) begin if (sel) y a; else y b; end endmodule这里y被声明为reg类型因为它在过程块里被赋值。但注意这个reg对应的硬件并不是寄存器它最终会被综合成一个多路选择器是纯组合逻辑。这是理解reg的核心里程碑后面第4章专门展开。组合逻辑的always块里敏感列表也可以是(a or b or sel)即把所有输入信号列出来。这种写法在旧代码里很常见但容易漏写信号产生仿真与综合不一致的问题。现在业界统一推荐(*)自动包含所有输入。3.2 时序逻辑建模边沿敏感的写法时序逻辑是always块的另一大主场。标准的寄存器模型// 带异步复位的D触发器 module dff ( input wire clk, input wire rst_n, input wire d, output reg q ); always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end endmodule这里敏感列表里有两个信号clk的上升沿posedge clk和rst_n的下落沿negedge rst_n。意思就是时钟上升沿到来时把d的值打入q复位信号下降沿到来时q立即清零。前者是同步逻辑后者是异步复位。这种“异步复位、同步置位”的写法是业界标准模板直接背下来用就行。但要注意异步复位的敏感列表里必须包含复位信号的沿少了它仿真时复位就无法打断时钟对q的影响行为完全错误。3.3 组合逻辑和时序逻辑的边界感很多初学者容易把两种逻辑写在同一个always块里比如一方面在时钟沿触发时计算状态另一方面又直接在这个块里输出组合信号。这样写不是绝对不行但会带来两个麻烦一是代码可读性差二是最终生成的电路层级不清晰。业界更推荐的做法是分块时序always只处理寄存器的状态更新比如计数器加一、状态机跳转。组合always只处理输出逻辑根据当前状态计算输出值。拿计数器来说一个推荐写法是// 时序逻辑状态更新 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4b0; else cnt cnt 1b1; end如果还需要一个“溢出指示信号”应该另开一个组合逻辑块// 组合逻辑输出计算 always (*) begin if (cnt 4b1111) overflow 1b1; else overflow 1b0; end这样每个always块职责单一后综合出的电路也更干净。这种“一个时钟块一个组合块”的结构就是后面写状态机、写I2C/UART收发器的基础模板。4. 过程块里最容易被坑的细节4.1 reg类型的真实含义不是寄存器这个观点我记得提过好几次但每次都有新同学踩坑再强调一遍reg类型不等于寄存器它只是在过程块中被赋值的变量类型。为什么需要reg类型因为过程块里的赋值语句是顺序执行的在仿真过程中这个变量需要“记住”最近一次被赋的值。这个“记住”的动作在仿真模型里对应一个存储单元所以语法上要求它必须是reg类型。但它最终综合成什么硬件完全取决于你怎么写在always (posedge clk)里被赋值 → 综合成寄存器在always (*)里被赋值且所有分支完备 → 综合成组合逻辑在always (*)里被赋值但分支不完整 → 综合成锁存器latch所以reg类型是一个语法层面的概念和硬件寄存器没有必然联系。同理wire类型也不等于“一根导线”但在大多数场景下你可以这么理解。4.2 敏感列表漏写与锁存器推断这两个问题属于“过程块写作不规范导致的经典事故”。第一个事故敏感列表漏信号// 错误示范 always (a) begin y a b; end这里敏感列表只写了a没写b。仿真时就会出现一个诡异现象a变化时y立即更新但b变化时y纹丝不动。这跟真实硬件行为不符——硬件上b变了异或门输出肯定跟着变。综合工具通常会根据代码内容自动“修正”敏感列表这就导致仿真结果和综合结果不一致调试起来极其痛苦。解决方案就是统一用(*)。让仿真器自动识别所有输入信号一劳永逸。第二个事故分支不完整导致锁存器// 错误示范if没有else always (*) begin if (en) y a; end当en为0时y保持原值不变。这在过程块模型里意味着“需要把旧值存起来”综合工具就会推断出一个锁存器。锁存器不是不能用来设计但它很容易在时序分析里制造麻烦一般不是我们想要的。正确的做法是补全分支或者先给默认值always (*) begin y 1b0; // 先给默认值 if (en) y a; end这样任何情况下y都有确定的值不会推断出锁存器。case语句同理一定记得写default。4.3 begin...end 和语句顺序的执行陷阱begin...end本质上等同于 C 语言的花括号把多条语句打包成一个顺序块。没有它语法上也有一种单语句的写法比如always (posedge clk) q d;这合法但一旦想加第二句就麻烦了。好习惯是不管几条语句always和initial后面一律跟begin...end降低出错率。在同一个过程块里语句顺序会影响赋值结果尤其是在组合逻辑里用阻塞赋值时。看这个例子always (*) begin temp a b; y temp | c; end先算temp再用temp算y两条语句的顺序决定了逻辑关系。如果把两句调换y用的就是更新前的temp旧值仿真结果完全不同。这就是为什么组合逻辑里阻塞赋值的书写顺序必须仔细检查的原因。5. 阻塞赋值与非阻塞赋值过程块赋值规则的核心5.1 两种赋值的仿真执行机制过程块里有两类赋值运算符阻塞赋值和非阻塞赋值。它们的执行机制差异是Verilog新手最容易绕晕的地方。阻塞赋值先计算右边的值立即赋给左边变量然后才执行下一条语句。好比“算一步走一步”当前语句完全结束后才进入下一条。因此后续语句看到的已经是新值。非阻塞赋值先计算所有右边的值并“记下来”然后等当前仿真时间步结束时统一把记下来的值赋给左边变量。好比所有人先站在起跑线上拍照定格然后统一开跑。在同一时间步内后续语句看到的仍是旧值。5.2 为什么时序逻辑用非阻塞组合逻辑用阻塞这是《Verilog编码规范》里最核心的两条铁律时序逻辑always (posedge clk)里用非阻塞赋值。组合逻辑always (*)里用阻塞赋值。为什么时序逻辑必须用非阻塞因为真实硬件里寄存器在时钟边沿到来时是同时采样、同时更新的。D触发器的输出在时钟沿前是旧值时钟沿后才统一变为新值。非阻塞赋值恰好模拟了这个“沿后统一更新”的行为。为了展示差异看这个经典例子——两个寄存器的值交换// 用阻塞赋值结果不对 always (posedge clk) begin a b; b a; end阻塞赋值执行时a b;执行完a已经变成b的旧值了。接着执行b a;此时a已经是新值b也被赋成这个新值。两条语句跑完a和b都等于原来的b完全没实现交换。// 用非阻塞赋值正确交换 always (posedge clk) begin a b; b a; end非阻塞赋值先同时读入b的旧值和a的旧值然后在时钟沿后统一更新a变成原bb变成原a完美交换。这就叫“时钟沿处的并行采样”行为。组合逻辑则相反。组合电路本身没有存储能力输入变化后输出经过传播延迟马上变化。阻塞赋值立即更新、顺序执行正好模拟了这一过程。如果在组合逻辑里用非阻塞赋值会引入不必要的中间状态极易造成仿真和综合不一致。5.3 一个容易忽视的禁忌不要在多个块里赋值同一个变量过程块还有一个硬性规定同一个变量不能在多个always或initial块里被赋值。always (posedge clk) a 1b1; always (posedge clk) a 1b0;这种代码在仿真时会报多驱动错误综合时会直接报错。硬件上也确实无法实现——一个寄存器不可能同时被两个电路驱动否则就是电平冲突。唯一的例外是assign语句它和三态门搭配时可以出现多驱动那是总线结构的应用场景不在本次讨论范围。6. 可综合性边界与实践建议6.1 哪些过程块写法只能仿真、不能上板关于可综合性我总结了一个简易判断方法你写的代码能不能对应到实际的硬件结构如果不能基本就是不可综合的。常见不可综合的写法集中在下面几类写法原因典型用途initial块硬件无法在上电瞬间赋初始值testbench初始化#延迟硬件没有“等10ns再执行”的电路testbench时序控制forever/while不可综合或需要固定循环次数才能综合testbench时钟生成$display/$monitor纯仿真系统任务仿真信息打印integer不定循环循环次数必须边界改写为固定常量特殊数据处理在可综合的RTL代码里老老实实用always 时钟沿 复位逻辑来描述寄存器行为用always (*) 完整分支描述组合逻辑这两板斧够用很久。6.2 我见过的初学者错误汇总最后列几个我在带项目过程中最常见的过程块错误供大家自查错误一忘记复位信号放在敏感列表里// 正确写法 always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end如果漏掉or negedge rst_n仿真时复位永远不起作用。错误二在组合逻辑里用非阻塞赋值always (*) begin y a b; // 不推荐 end如果只是仿真可能看不出问题但时序仿真时可能导致组合逻辑的输出延迟一拍和真实硬件行为不符。错误三多个always块赋值同一个 reg这种情况在移植别人代码时特别常见一个变量既在这个块里更新又跑到另一个块里赋值。解决办法是重新规划代码结构把同一个变量的所有更新逻辑集中到一个always块里。错误四always块里出现不可综合的死循环always (a) begin while (a 1b1) begin // 没有延迟控制 end end仿真器会直接卡死因为while条件一直满足又没有延迟语句让它跳出去。遇到仿真“程序不跑”的情况优先检查有没有这种死循环。6.3 过程块的代码风格建议一个实用的小建议在写always块时把敏感列表、变量名、赋值方式三者统一成规矩的模板能减少绝大部分低级错误。我自己常用的模板有两种。时序逻辑模板寄存器更新:always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位赋值 end else begin // 功能赋值一律用 end end组合逻辑模板输出计算:always (*) begin // 先给所有输出赋默认值 y 1b0; // 然后写条件赋值一律用 end这两个模板基本覆盖了日常RTL设计的9成场景。后面学状态机、学FIFO读写、学I2C/UART收发都是在这两个模板上扩展而已。过程块这一关过了Verilog的地基就算打牢了。最后再分享一个个人体会学过程块最大的坎不是语法而是思维转变。从“顺序执行”的C语言思维切换到“并行触发”的硬件思维需要一个适应期。判断标准很简单——当你看到一堆always块时脑子里能自动浮现出对应的硬件结构而不是把它当C语言一样从头到尾读那这块语法就是真懂了。到那个程度再看任何模块级代码都会轻松得多。