☰
Verilog过程块:initial与always的并发模型与工程实战
2026/10/3 21:40:14 网站建设 项目流程

写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 = 8'd0;

或者

initial begin counter = 8'd0; 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) 2'b00: y = a + b; 2'b01: y = a - b; 2'b10: 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) 2'b00: y = a + b; 2'b01: y = a - b; 2'b10: 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 <= 8'd0; end else begin counter <= counter + 1'b1; 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 + 1'b1; end

结果一仿真,count直接变成了不定态x,因为组合逻辑的当前输入依赖于自身输出,形成了组合环路。后来我把计数逻辑改成时序逻辑:

always @(posedge clk) begin count <= count + 1'b1; 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 <= 8'd0; end else if (en) begin cnt <= cnt + 1'b1; 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 <= 16'd0; rx_byte <= 8'd0; bit_cnt <= 4'd0; state <= IDLE; end else begin case (state) IDLE: begin if (rx_start) begin state <= RECV; bit_cnt <= 4'd0; rx_shift <= 16'd0; end end RECV: begin if (bit_center) begin rx_shift <= {rx_shift[14:0], rx_data}; bit_cnt <= bit_cnt + 1'b1; if (bit_cnt == 4'd8) 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 == 1'b1) ? S1 : IDLE; S1: next_state = (data_in == 1'b0) ? S10 : IDLE; S10: next_state = (data_in == 1'b1) ? 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 <= 1'b0; else if (next_state == MATCH) match_flag <= 1'b1; else match_flag <= 1'b0; 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 clk=0、always #5 clk=~clk产生时钟
综合前仿真正常,综合后功能不对阻塞和非阻塞赋值用错,导致仿真模型与综合网表不一致按照时序块用非阻塞、组合块用阻塞的规则重写

这些报错基本上覆盖了初级开发者90%的踩坑场景。如果你用自己的开发环境碰见某一个,对照表格挨个检查,多数能快速定位。

6.2 仿真与综合不一致的典型雷区

仿真和综合不一致,是FPGA开发里最痛苦的问题之一。我见过最典型的雷区,是在always块里对同一个信号在多个地方进行赋值。比如:

always @(posedge clk) begin if (rst_n) a <= 1'b0; 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 == 4'd8) $display("Received byte: %h", rx_byte);

这行代码写在时序always块末尾,不影响综合结果,但在仿真时能极大方便观察。RTL里加打印语句是可以的,综合时会自动被工具忽略,不过要注意别在性能敏感的循环里加太多,因为仿真速度会变慢。

关于调试,最后再分享一个心得:如果你的仿真波形里出现了莫名的高阻z或者不定态x,优先检查是不是有信号没有驱动、多个initial块对同一个变量竞争赋值、或者组合逻辑自环。按这个顺序排查,通常比盯着代码发呆快得多。

我这些年带新人时经常说一句话:Verilog语法本身不难,难的是用“硬件思维”去理解过程块。initial和always就像两个员工,initial负责入职时把环境收拾好然后休息,always则是那种一有风吹草动就要跑起来干活的勤快人。你在设计里清楚划分好它们各自的职责,再严格遵守阻塞赋值和非阻塞赋值的使用规则,大部分仿真和综合上的坑都能提前躲开。如果现在再看以前写的那堆充满各种警告的代码,你会发现,大部分问题其实都出在这两个关键字周围。把今天这些规则内化成习惯,你的代码质量会明显上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询