拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Verilog过程块:initial与always的并发模型与工程实战

写Verilog的人早晚会和这两个关键字纠缠不清initial和always。我在FPGA设计里摸爬滚打这么多年发现很多新手在刚接触过程块的时候习惯性地把它们当成C语言里的函数或者循环体来理解结果一上仿真就出各种莫名其妙的问题。其实过程块是Verilog描述硬件行为的核心载体它和软件语言最大的不同在于一段一段的过程块不是“顺序执行”的而是“并发执行”的。理解清楚这个概念你才算迈进了硬件描述语言的门槛。这篇内容主要围绕initial和always这两个过程块展开适合刚学Verilog语法、准备写testbench或者RTL代码的入门者也适合已经写了几个月代码但总被“仿真对、上板不对”折磨的开发者。我会从两者的执行机制讲起再逐步深入到可综合性、阻塞与非阻塞赋值、工程实战模板最后把常见的报错和坑位梳理一遍。整体走下来你基本能把这些语法规则内化成自己的设计习惯。1. 过程块是什么Verilog里“并发”的执行模型1.1 从仿真调度看过程块的定位Verilog和C、Python这类软件语言最大的差别就是它天生要模拟“同时工作的一堆硬件电路”。一个芯片内部时钟沿来的那一瞬间可能有几百个寄存器要同时更新几十个组合逻辑路径要同时计算。这种并发行为不可能用一句一句顺序执行的代码来描述所以Verilog引入了过程块。过程块本质上是一段封装好的行为描述代码它活在仿真器的调度队列里。仿真器在做事件调度的时候会维护一个时间队列当某个事件的触发条件满足对应的过程块就会被激活。更直白地说你把一个过程块看成“一个独立的小处理器”多个小处理器并行地处理各自的逻辑它们之间通过信号线wire或者reg互相通信。这样的模型才和真实硬件的行为对得上。所以当你在一个module里写下initial begin clk 0; end always #5 clk ~clk;这里的initial和always都是过程块它们并不是“从上往下逐条执行完第一个再执行第二个”而是仿真一开始就同时站在了起跑线上。initial把自己的事后处理完就退场了always则每隔5个时间单位就把clk翻转一次一直陪跑到仿真结束。从底层事件队列的角度看这种并发执行靠的是仿真器的“非阻塞事件”和“阻塞事件”调度机制这部分后面讲阻塞赋值时会再深入。现在你只要记住过程块并发执行的独立行为单元模块内的信号是它们之间交流的桥梁。1.2 initial和always的分工差异从名字上就能猜到两者的区别initial是“初始化”always是“一直做”。干的是完全不同的两件事。对比项initial过程块always过程块执行次数仿真开始时执行一次满足敏感条件后反复执行是否可综合通常不可综合仿真专用可综合是RTL设计的主力典型用途初始化信号、生成激励、打印信息描述组合逻辑和时序逻辑触发方式不需要触发条件自动开始需要敏感列表或延迟控制使用限制一个模块内可以写多个initial各自独立并发一个模块内可以写多个always各自独立并发实际项目里initial块几乎清一色出现在testbench仿真环境中用来给时钟、复位、数据激励赋初值。always块则是RTL代码的主角几乎所有的寄存器、组合逻辑和状态机都靠它搭出来。偶尔有些FPGA平台支持用initial给寄存器赋初值比如Xilinx的FPGA在综合时允许把initial语句映射成寄存器上电初值这属于一个灰科技后面细说。2. initial块从仿真初始化到RTL初始值2.1 initial的基本语法与执行规则initial语句的基本格式并不复杂initial begin // 你想要执行的语句 end需要注意的是initial块里的语句在仿真0时刻开始执行并且只执行一次。如果块里有多条语句必须用begin...end把它们包起来形成顺序块里面的语句才会按书写顺序依次执行。如果不加begin...end语法上会报错因为单条initial后面如果直接跟多条语句Verilog会把它当成一条语句来处理不符合“顺序执行多条语句”的需求。我们先看一个最简单的例子module tb_test; reg clk; reg rst_n; initial begin clk 0; rst_n 0; #20 rst_n 1; // 过了20个时间单位后释放复位 #100 $finish; // 再跑100个时间单位结束仿真 end always #5 clk ~clk; endmodule这段代码里面initial块里的语句从0时刻开始执行。clk和rst_n先赋值成0然后等待20个时间单位rst_n变成1。接着再等待100个时间单位通过$finish结束仿真。与此同时always块里的时钟一直在翻转。这种“initial负责环境初始化always负责持续产生时钟”的写法是testbench里最标准的搭档方式。这里有几个细节值得强调initial块和always块在仿真开始时会同时执行不存在谁先谁后的绝对顺序。如果你在多个initial块里同时对同一个变量赋值最终结果取决于仿真器的事件调度顺序这种写法应该尽量避免。initial块里的#延迟控制是仿真器专用的语法表示等待多少个时间单位。这个语法不可综合但在testbench中非常有用。initial块可以出现在多个位置比如也可以在模块中间写但为了可读性工程上一般会把所有的初始化语句集中在testbench顶部或专门的初始化任务里。2.2 仿真里怎么用initial造时钟、复位、激励仿真环境中最常见的三件事都可以用initial块搞定生成复位信号、生成初始化数据激励、打印调试信息。复位信号一般是一个异步脉冲最简单的写法是initial begin rst_n 0; #100; rst_n 1; end这条语句让复位在0时刻拉低持续100个时间单位后释放复位宽度可以通过#100来调节。需要注意的是如果复位信号本身是异步的你往往需要在initial里让它相对时钟沿错开一点避免在时钟上升沿附近变化导致时序仿真出现亚稳态问题。比如说时钟周期是10ns你可以在#101时释放复位让复位沿落在时钟低电平中间。时钟信号用initial也可以生成但在很多资料里大家更喜欢用always来产生时钟因为always带周期翻转更方便。比如initial clk 0; always #5 clk ~clk;这两行组合起来等价于一个周期为10个时间单位的时钟。initial在这里只负责把时钟初始电平定在0剩下的翻转交给always。如果你只写always #5 clk ~clk没有initial的初始赋值仿真器会默认clk初值为x那样产生的信号前面一段会变成无法确定的状态。数据激励就更多变了比如我们想测一个加法器模块initial begin a 0; b 0; #10 a 5; b 3; #10 a 8; b 9; #10 $finish; end每过10个时间单位换一组输入操作起来非常直观。这种写法在模块验证阶段几乎是写testbench的基本功。2.3 关于initial可综合性的几个经验很多初学者都会问initial到底能不能综合我在实际工程里的经验是绝大多数情况下initial语句在综合时会被工具忽略或者直接报warning。因为综合工具要生成的是实实在在的硬件网表它并不知道“在0时刻把某个信号赋成0”在真实硬件里应该映射成什么。不过这里有个例外很多FPGA综合工具支持通过initial语句给寄存器设置上电初值。比如Xilinx的Vivado当你写下reg [7:0] counter 8d0;或者initial begin counter 8d0; end综合器可能把它映射成寄存器类器件的初始值属性例如Xilinx的INIT属性这样FPGA配置完成后寄存器会先停在0这个状态。注意这跟异步复位不是一回事复位是运行时的强制归零初始值只是上电那一瞬间的状态。基于这个经验我的建议是RTL代码里尽量不要依赖initial来初始化寄存器除非你明确知道目标器件支持这一特性并且已经验证过综合结果。如果要用初始值更通用、更安全的方式是加一个异步复位或者同步复位逻辑所有寄存器在复位有效期间被拉到一个确定值。testbench里则完全不同请放心大胆地用initial来初始化信号这是它的主场。3. always块硬件行为建模的主力3.1 敏感列表的三种写法always块的核心是敏感列表它决定了这个块什么时候被激活。常见的三种写法是always (a or b)电平敏感列表当a或b任何一个发生变化时进入过程块常用于组合逻辑模型。always (posedge clk)上升沿敏感当时钟上升沿到来时进入过程块常用于时序逻辑模型。always (posedge clk or negedge rst_n)带异步复位的时序逻辑当时钟上升沿或者复位下降沿到来时进入过程块。除此之外还有一种非常通用的写法叫always (*)它的含义是“块内所有被读取的输入信号发生变化时都触发”。这种写法从Verilog-2001开始引入好处是你不用手动列出所有输入信号减少了漏写敏感列表的风险。下面是三种写法的简单对比敏感列表写法触发方式典型用途可综合性always (a or b)列表内信号电平变化简单组合逻辑可综合always (*)块内输入信号变化通用组合逻辑可综合always (posedge clk)时钟上升沿时序逻辑可综合always (posedge clk or negedge rst_n)时钟沿或异步复位沿带复位时序逻辑可综合我自己在写RTL时组合逻辑几乎只写always (*)时序逻辑则统一写成带异步复位的posedge clk版本。这样写的好处是既减少了敏感列表的维护成本也能让阅读代码的人一眼看出这个块是组合逻辑还是时序逻辑。3.2 组合逻辑模板与意外锁存器组合逻辑的特点是输出只取决于当前输入没有记忆功能。用always块描述组合逻辑时标准模板是always (*) begin case (sel) 2b00: y a b; 2b01: y a - b; 2b10: y a b; default: y a ^ b; endcase end这个模板的关键点有三个第一块内所有被赋值的信号都必须是reg类型。这里“reg”这个名字其实有点误导它不代表综合后一定是寄存器。在always块中只要被赋值的对象是reg类型综合器会根据赋值情况自动推断成组合逻辑或者寄存器。所以组合逻辑也可以用reg这一点困扰了不少新手。第二组合逻辑块内必须把所有分支写全否则综合器会在输出信号上推断出锁存器latch。什么叫分支写全比如上面的case语句如果sel只有00、01、10三种情况你却只写了这三种条件没有写default那么当sel变成11时y该保持什么值综合器没法判断它只能为了“保持上次的值”生成一个锁存器这往往不是你想要的。避免意外锁存器的办法很简单要么把所有分支都覆盖完整要么在最开始给输出赋一个默认值比如always (*) begin y 0; // 默认值 case (sel) 2b00: y a b; 2b01: y a - b; 2b10: y a b; endcase end这样即使sel是未列出的值y也会被默认赋值成0不会形成锁存器。第三组合逻辑的输出不能反馈到自身输入。如果你在always块中写了类似y y 1这样的代码从组合逻辑角度讲这会形成一个组合环路仿真时会出现信号死锁综合时也会报错。如果你想做计数应当使用时序逻辑放到always (posedge clk)里去做。3.3 时序逻辑模板与时序约束前的自查时序逻辑是指输出不仅取决于当前输入还取决于之前的状态也就是有记忆能力。用always块描述时序逻辑时标准模板是always (posedge clk or negedge rst_n) begin if (!rst_n) begin counter 8d0; end else begin counter counter 1b1; end end这个模板里的信号counter综合后会被映射为寄存器。这里有一个非常重要的原则时序逻辑块内应使用非阻塞赋值组合逻辑块内应使用阻塞赋值。这条原则是Verilog编码规范里最高频的一条后面会专门解释原因。在用这个模板之前我习惯先自查几件事时钟是不是规则地输入到了这个模块异步复位信号的极性对不对通常我们用低电平复位所以negedge rst_n触发。敏感列表里有没有同时出现时钟和别的电平信号如果有要确认是不是真需要异步控制。每个时钟沿到来时寄存器最多只能更新一次。如果你在同一个always块里对同一个信号赋了两次值后面的赋值会覆盖前面的赋值仿真结果会和你预想的不一致。如果这个模块要上板还要考虑时序约束问题。比如时钟频率、输入输出的外部延迟都需要在XDC或SDC约束文件里声明。不过这些是时序收敛的范畴了和语法本身关系不大今天不展开。4. 阻塞赋值与非阻塞赋值过程块里最容易被绕晕的规则4.1 两种赋值背后的仿真事件机制我相信每个学Verilog的人都被阻塞赋值“”和非阻塞赋值“”的差异折磨过。要理解这个差异核心是理解仿真事件队列里两种赋值发生在不同的阶段。先看阻塞赋值。它的特点是赋值语句执行时立即生效后面的语句会基于这个新值继续往后跑。从仿真器调度顺序来看阻塞赋值属于“活事件”范畴它在当前时刻就完成计算和更新。这跟C语言里的等于号行为很像所以比较容易理解。非阻塞赋值则完全不同。它先计算右侧表达式的值但不在当前时刻立即更新左侧变量而是把更新请求挂到事件队列的“非活动事件”区等当前仿真时刻所有阻塞赋值都处理完之后再统一更新。为了更好地记忆业界有一句经典口诀“非阻塞赋值先看右边最后更新左边。”这句话的意思是在同一仿真时刻非阻塞赋值右侧的值是在过程块入口处采样的左侧的更新会被延迟到该仿真时刻的末尾。这就解释了为什么交换两个寄存器值的经典写法必须是always (posedge clk) begin a b; b a; end如果是阻塞赋值always (posedge clk) begin a b; b a; end执行到a b后a已经变成了b的原值接着b a时b拿到的其实是b的原值结果a和b都变成了同一个值交换失败。这个例子是区分两种赋值最典型的入门题。从硬件的视角看非阻塞赋值其实更贴近寄存器真实的工作方式时钟上升沿到来的一瞬间所有寄存器同时采样输入然后在下一个时间段内输出更新。过程块里那一连串的“”语句描述的就是同一时钟沿下的一批寄存器的并行更新。4.2 什么时候用哪种赋值含易错案例实际编码时我的选择标准非常简单粗暴就三条写时序逻辑时一律用非阻塞赋值。写组合逻辑时一律用阻塞赋值。同一个always块内不要把“”和“”混用。混用是代码审查里最容易被提到的违例。比如下面这种代码always (posedge clk) begin reg_file[wr_addr] wr_data; data_out reg_file[rd_addr]; end这个代码在功能上能跑通可综合工具对它的理解很容易出现歧义。你应该把它拆开寄存器的写操作放在时序块里组合读取逻辑放到always (*)里。写惯了以后你会发现保持这种清晰的职责划分让代码里的每个always块都只承担一种角色是提升可维护性最有效的手段。还有一个我见过很多次的错误在always (*)组合逻辑块里对一个变量既用阻塞赋值又用非阻塞赋值仿真器会给出异常结果。排查起来非常麻烦因为报错位置往往不在具体那一句而是一大堆信号都变成了x状态。再举一个我实际遇到的案例。有一个模块需要生成一个字节计数器当时我图省事把计数器写成了组合逻辑always (*) begin count count 1b1; end结果一仿真count直接变成了不定态x因为组合逻辑的当前输入依赖于自身输出形成了组合环路。后来我把计数逻辑改成时序逻辑always (posedge clk) begin count count 1b1; end问题立刻消失了。记住凡是需要“记住历史状态”的存储型逻辑一定放到时序块里凡是只根据当前输入计算输出的逻辑才放到组合块里。5. 实战工程计数器、多字节收发与三段式状态机5.1 一个带使能和复位的计数器计数器是过程块最典型的应用场景。我们看一个完整的模块它包含时钟、异步复位、使能信号和8位计数器输出。module counter_8bit ( input wire clk, input wire rst_n, input wire en, output reg [7:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 8d0; end else if (en) begin cnt cnt 1b1; end else begin cnt cnt; end end endmodule这里有一个细节值得说最后的else cnt cnt其实可以不写因为非阻塞赋值本身会保持原有值。但我在工程里习惯把情况写全这样阅读代码的人能明确知道“en无效时计数器保持不变”是这个设计有意为之的行为而不是漏掉了分支。另外注意计数器溢出后自动回绕到0这种计数方式叫模256计数器。如果你需要0到99这种十进制计数就要在计数值等于99时清0一般用比较器加条件判断来实现。5.2 UART接收中的移位与采样多字节收发、UART这类关键词在搜Verilog相关内容时经常出现。UART接收端的关键操作就是利用一个高速采样时钟在每bit的中心位置采样电平然后通过移位寄存器把数据逐位拼起来。这个过程里always块扮演着核心角色。一个简化版的UART接收状态机如下省略波特率计数的细枝末节always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_shift 16d0; rx_byte 8d0; bit_cnt 4d0; state IDLE; end else begin case (state) IDLE: begin if (rx_start) begin state RECV; bit_cnt 4d0; rx_shift 16d0; end end RECV: begin if (bit_center) begin rx_shift {rx_shift[14:0], rx_data}; bit_cnt bit_cnt 1b1; if (bit_cnt 4d8) begin rx_byte rx_shift[7:0]; state IDLE; end end end endcase end end这里通过时序块里的非阻塞赋值把采样到的数据不断左移拼接8个bit后拼出一个字节。如果你要做多字节收发一般会在收到一个字节后触发一个“接收完成”标志FIFO或DMA再把这个字节搬走状态机置回IDLE继续等待。整条链路里的每个节点都是依赖always块在时钟沿到来时统一更新的。写这种代码时我个人强烈建议维护一个状态标记的文档把每个状态的定义、跳转条件、输出动作说清楚。否则过两周你自己回来看这堆状态机代码也很难一眼看懂。5.3 三段式状态机模板分析状态机是数字IC和FPGA设计里的高频考点也是过程块最集中的展示场景。三段式状态机分成三个always块第一段描述状态跳转的时序逻辑第二段描述状态转换条件的组合逻辑第三段描述输出逻辑。为了节省篇幅我用一个简单的“序列检测器”为例检测输入序列101// 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段状态转移组合逻辑 always (*) begin case (state) IDLE: next_state (data_in 1b1) ? S1 : IDLE; S1: next_state (data_in 1b0) ? S10 : IDLE; S10: next_state (data_in 1b1) ? MATCH : IDLE; MATCH: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) match_flag 1b0; else if (next_state MATCH) match_flag 1b1; else match_flag 1b0; end注意我在第二段组合逻辑里对next_state用的是阻塞赋值在第三段输出逻辑里对match_flag用的又是非阻塞赋值。这正是上一节讲的规则组合块用“”时序块用“”。三个块各司其职也是我面对复杂逻辑时的首选写法。它的优点在于易读、易调试、综合结果清晰出问题时可以单独查某一个块不用一个巨型always块里翻几百行代码。缺点就是代码行数多一点但对硬件描述语言来说行数的可读性收益远大于成本。6. 常见问题排查与工具建议6.1 过程块相关的编译仿真报错速查我整理了一张表格列一下我这几年来最常遇到的几个报错和解决办法。报错或现象根本原因解决办法“begin/end required”之类语法错误initial或always后面直接跟多条语句没有加begin...end用begin...end把语句包起来信号一直为x不定态寄存器没有复位或initial里没有初始化增加复位逻辑或在testbench的initial里初始化$finish提示多个或仿真结束不了initial块里$finish只执行一次可能写在循环里确保$finish在正确的仿真流程分支中latch inferred组合逻辑分支不完整或者敏感列表不完整补全条件分支或给输出赋默认值模块里没有时钟仿真卡死时钟初始电平错误或缺少翻转用initial clk0、always #5 clk~clk产生时钟综合前仿真正常综合后功能不对阻塞和非阻塞赋值用错导致仿真模型与综合网表不一致按照时序块用非阻塞、组合块用阻塞的规则重写这些报错基本上覆盖了初级开发者90%的踩坑场景。如果你用自己的开发环境碰见某一个对照表格挨个检查多数能快速定位。6.2 仿真与综合不一致的典型雷区仿真和综合不一致是FPGA开发里最痛苦的问题之一。我见过最典型的雷区是在always块里对同一个信号在多个地方进行赋值。比如always (posedge clk) begin if (rst_n) a 1b0; a b; end从语法上这能通过编译但仿真时你可能发现a最终变成什么取决于最后一句赋值而综合工具对于这种代码的解释会和你仿真时看到的行为有细微差别。我在工程上坚持的原则是一个always块里同一个信号只允许在一个条件分支中赋值绝不出现多位置重复赋值。如果确实需要多条件更新就把条件合并成if-else if结构。还有一个雷区是敏感列表不完整。以前看到有人写always (a or b) begin y a b c; end敏感列表漏了c。仿真时c的单独变化会触发不了这个块导致y不更新而综合工具却会把它综合成真实的三输入与门于是仿真和综合结果对不上。我现在的习惯是组合逻辑一律用always (*)从根源上消除这种隐患。6.3 顺手工具配置与调试习惯近年来大家喜欢用VS Code写Verilog配合插件可以做到语法高亮、自动补全、lint检查。我自己会在工程目录下放一个编译脚本用开源的仿真工具来做快速验证。这里只强调一个习惯调试initial和always相关问题时不要一上来就跑大规模仿真应该先写一个最小的testbench把时钟、复位、激励波形都打印出来。波形能直接告诉你过程块有没有按预期触发这是定位问题最高效的手段。另外我在代码里习惯在关键状态跳转处加$display打印比如if (state RECV bit_cnt 4d8) $display(Received byte: %h, rx_byte);这行代码写在时序always块末尾不影响综合结果但在仿真时能极大方便观察。RTL里加打印语句是可以的综合时会自动被工具忽略不过要注意别在性能敏感的循环里加太多因为仿真速度会变慢。关于调试最后再分享一个心得如果你的仿真波形里出现了莫名的高阻z或者不定态x优先检查是不是有信号没有驱动、多个initial块对同一个变量竞争赋值、或者组合逻辑自环。按这个顺序排查通常比盯着代码发呆快得多。我这些年带新人时经常说一句话Verilog语法本身不难难的是用“硬件思维”去理解过程块。initial和always就像两个员工initial负责入职时把环境收拾好然后休息always则是那种一有风吹草动就要跑起来干活的勤快人。你在设计里清楚划分好它们各自的职责再严格遵守阻塞赋值和非阻塞赋值的使用规则大部分仿真和综合上的坑都能提前躲开。如果现在再看以前写的那堆充满各种警告的代码你会发现大部分问题其实都出在这两个关键字周围。把今天这些规则内化成习惯你的代码质量会明显上一个台阶。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门