想象你经营一家中餐厅。清晨开业前,你要做一套一次性准备:清洗食材、调配酱汁、把炉灶预热到合适温度——这些动作只做一遍,做完就正式开始营业。而营业中,每来一位客人,后厨大厨就要根据订单忙碌起来;客人走了一波又来一波,同样的炒菜步骤被一遍遍重复。 在Verilog的世界里,这两种节奏恰好对应两类“过程块”。Verilog是硬件描述语言,它不描述一段顺序执行的软件,而是描述一个持续运转的硬件电路。硬件不像软件那样“从main函数开始,一路执行到结束”,而是像餐厅一样:有些事只初始化一次(initial块),有些事则持续响应外部事件(always块)。 本文将以这个餐厅比喻为主线,带你理解过程块、敏感列表、组合与时序逻辑、赋值语句选择以及测试激励的写法。读完你会明白,所谓“写电路”,不过是把厨师的节奏翻译成代码。

岗位关联

对AI从业者而言,掌握过程块是读懂AI芯片(NPU/GPU)RTL代码的钥匙,也是参与智能硬件协同设计的基础能力。

目录
  1. 可视化演示
  2. 过程块初识
  3. 敏感列表设计
  4. 组合逻辑建模
  5. 时序逻辑建模
  6. 赋值语句选择
  7. initial块应用
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示always时序逻辑时钟驱动计数器、always组合逻辑多路选择器及initial测试激励波形,交互切换理解过程块核心机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

时序逻辑always块 posedge clk触发非阻塞赋值 计数器每个时钟沿递增 always @(posedge clk) 时序逻辑使用非阻塞赋值<=

过程块初识

开餐厅前,有两本手册要认真读:一本写“开业准备清单”,另一本写“日常经营SOP”。前者从开业那一刻起,从上到下执行一遍就归档;后者则成为餐厅每一天的运营框架,只要在营业时间,每次触发都要执行一遍。

Verilog中的initial块和always块正是这样两个截然不同的过程块。它们都写在module(模块)内部,是行为描述的基本单位。initial块从仿真0时刻开始执行一次,适合初始化变量、生成激励信号;always块则伴随敏感列表中的事件反复执行,用来建模实际硬件的持续行为。

过程块与assign连续赋值语句有本质区别:assign描述的是模块端口之间的实时连接,而过程块内部可以使用if-else、case、for等高级语句,描述更复杂的逻辑。一个module内可以同时存在多个initial块和always块,它们相互独立、并发执行——就像厨房可以同时开多个灶台,各自忙碌互不阻塞。

记住:initial是开店前的一把火,always是营业中不断响起的订单铃。两种过程块各司其职,构成了Verilog行为建模的双引擎。

initial: run once at t=0always: run on each trigger\text{initial}: \ \text{run once at } t=0 \qquad \text{always}: \ \text{run on each trigger}
这个公式说明两类过程块的本质差异:initial是一次性动作,always是重复触发动作。
  1. initial块语义仿真启动时自动执行一次,执行到最后一条语句后结束,如同开业前的备料清单只做一遍
    t=0 → 执行 → 结束t = 0 \ \rightarrow \ \text{执行} \ \rightarrow \ \text{结束}
  2. always块语义每个敏感事件发生时执行一次,仿真期间永不停止,如同营业中每来一单就进入操作流程
    事件触发 → 执行 → 等待下一事件\text{事件触发} \ \rightarrow \ \text{执行} \ \rightarrow \ \text{等待下一事件}
  3. 并发执行多个过程块在仿真中并行推进,相互独立,不依赖书写顺序
    block1∥block2∥⋯∥blockn\text{block}_1 \parallel \text{block}_2 \parallel \cdots \parallel \text{block}_n

敏感列表设计

餐厅里的大厨并不是时时刻刻都在炒菜,而是听到“订单提醒铃”或看到传菜口出现新单才动手。也就是说,厨师的工作必须被某个“信号”唤醒。在Verilog中,唤醒always块的机制叫做敏感列表。

always块后面必须跟着一个括号,列出触发它执行的事件。例如always@(a or b)意味着只要a或b发生变化,块就会被重新执行一次。这个括号里的内容,就是敏感列表。

敏感列表分为两大类——电平敏感和边沿敏感。电平敏感列表(如always@(*)或always@(a,b,c))适合组合逻辑,它把所有被读取的输入信号都列为触发源;边沿敏感列表(如always@(posedge clk))适合时序逻辑,它只在时钟信号的上升沿瞬间触发一次。敏感列表的完备性至关重要:如果组合逻辑遗漏某个输入信号,仿真时该信号变化不会触发块重新执行,而综合生成的硬件却对所有输入敏感,最终产生“仿真与综合不一致”的陷阱。

给大厨安装好“通知系统”,他才不会漏单;给always块配好敏感列表,电路才不会漏响应。敏感列表设计是过程块编程的第一道门槛。

always@(∗) ≡ always@(a,b,c,… ) ;always@( ↑clk ) 边沿触发\mathrm{always@}(*) \ \equiv \ \mathrm{always@}(a,b,c,\dots) \ ; \quad \mathrm{always@}(\ \uparrow clk\ ) \ \text{边沿触发}
公式左边说明电平敏感列表的等价写法,右边说明边沿敏感的关键区别。
  1. 自动推导@(*)让综合工具自动列出块内所有读取的信号,常用于组合逻辑
    所有输入变化 → 重新执行\text{所有输入变化} \ \rightarrow \ \text{重新执行}
  2. 电平触发任意一个信号电平翻转都会唤醒块,执行与具体时延无关
    ∀ signal∈{a,b,c}: Δsignal → 执行\forall \ \text{signal} \in \{a,b,c\}: \ \Delta \text{signal} \ \rightarrow \ \text{执行}
  3. 边沿触发只在信号跳变的瞬间触发一次,上升沿posedge或下降沿negedge
    ↑clk 或 ↓clk → 执行\uparrow clk \ \text{或} \ \downarrow clk \ \rightarrow \ \text{执行}

组合逻辑建模

餐厅里的冷菜窗口讲究“即点即出”:客人说拍黄瓜,厨师手起刀落,黄瓜立刻出现在盘里。输出完全由当前订单决定,不问前世、不记旧账——这就是组合逻辑的哲学。

组合逻辑是指输出仅由当前输入决定的电路,没有记忆功能。最简单的描述方式是连续赋值assign,但复杂的组合逻辑需要if-else或case分支结构,这时就要借助always块了。

用always块描述组合逻辑的标准写法,是配合电平敏感的always@(*)。在块内部,用if-else或case对输入信号进行判断,以阻塞赋值“=“给输出赋新值。这里有一个常见陷阱:如果某个分支没有被覆盖,综合器会推断出一个锁存器(latch)——一个脆弱的存储单元——让组合逻辑偷偷”记忆”旧值,导致电路行为与设计意图不符。

即点即出的冷菜窗口,必须把每道菜谱都写在墙上;组合逻辑也要求把每种输入组合都写在分支里。漏写一道菜,顾客的订单就会被“锁”在窗口里不新鲜了。

Ycomb={A∧Bif sel=0A∨Bif sel=1Y_{\mathrm{comb}} = \begin{cases} A \wedge B & \mathrm{if}\ sel=0 \\ A \vee B & \mathrm{if}\ sel=1 \end{cases}
这是一个典型二选一组合逻辑:sel决定输出是A与B的与运算还是或运算。
  1. 功能定义输出Y是输入A、B和sel的纯函数,无状态记忆
    Y=f(A,B,sel)Y = f(A, B, sel)
  2. 分支覆盖if-else必须覆盖所有可能输入组合,否则会出现未定义的输出
    if-else/case 覆盖全部条件, 否则 → latch\text{if-else}/\text{case 覆盖全部条件, 否则} \ \rightarrow \ \text{latch}
  3. 阻塞赋值组合逻辑中使用=立即更新,保证后续语句能看到新值
    Y1=A∧B;Y2=A∨B;顺序执行Y_1 = A \wedge B; \quad Y_2 = A \vee B; \quad \text{顺序执行}

时序逻辑建模

厨房里有一位点心师傅,只在每个整点(时钟响起)时查看蒸笼并补充新品;没到点绝不掀盖,以免蒸汽散失。这种“按节拍行动”的节奏,正是时序逻辑的精髓。

时序逻辑是带记忆的电路,寄存器的输出不仅取决于当前输入,还取决于过去的历史状态。在Verilog中,我们通常用always@(posedge clk)描述时序逻辑,把时钟上升沿当作节拍器。

在时钟触发的always块内部,我们描述的是一个“状态更新”过程:每个时钟边沿到来时,寄存器把某个新值捕获并保存,直到下一个边沿。复位(reset)是时序逻辑中最常见的输入,它让电路回到确定的初始状态。复位有两种写法:同步复位仅在时钟沿查看复位信号;异步复位的敏感列表中同时包含时钟沿与复位沿,复位一旦有效立即生效,不受时钟控制。计数器是最典型的时序电路:每个时钟沿计数加一,或复位清零。

点心师傅守着的蒸笼,就是一个个寄存器;整点的钟声,就是时钟信号。复位如同“厨房紧急清空”按钮——按下即清(异步),或等到下一个整点再清理(同步)。掌握节拍,才驾驭得了大规模硬件。

qn+1={0复位有效qn+1时钟上升沿q_{n+1} = \begin{cases} 0 & \text{复位有效} \\ q_n + 1 & \text{时钟上升沿} \end{cases}
这是一个计数器的状态转移方程:复位时清零,否则每个时钟上升沿加一。
  1. 状态更新下一个状态是当前状态和输入的函数,只在时钟边沿提交
    qn+1 ← update(qn, input)q_{n+1} \ \leftarrow \ \text{update}(q_n, \ \text{input})
  2. 同步复位仅当时钟沿到达且复位信号有效时清零
    ↑clk 且 rst=1: qn+1=0\uparrow clk \ \text{且} \ rst=1: \ q_{n+1}=0
  3. 异步复位复位信号单独发生有效沿时立即清零,不依赖时钟
    rst有效沿 → qn+1=0 (无论时钟)\text{rst}_{\text{有效沿}} \ \rightarrow \ q_{n+1}=0 \ (\text{无论时钟})
  4. 计数器每个时钟边沿q加一,是时序逻辑的基础应用
    qn+1=qn+1q_{n+1} = q_n + 1

赋值语句选择

中餐讲究火候和顺序:有的菜必须一道一道完成,炒完糖色再放肉,这是“前一步做完才做后一步”;有的菜品则要先全部备菜,最后统一入锅,每道菜在锅里同时发生反应。Verilog中的阻塞赋值与非阻塞赋值,正对应这两种哲学。

阻塞赋值(=)按书写顺序立即更新变量,后一句能看见前一句的结果;非阻塞赋值(<=)则并行求值:所有右侧表达式在进入块时立即计算,但左侧变量的更新被推迟到整个块结束的时刻。这个看似微小的差异,在时序逻辑中会导致天壤之别的仿真结果。

业界有一条铁律:“组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值。”为什么时序逻辑中不能混用阻塞赋值?考虑always@(posedge clk)中两个寄存器a和b互相关联:若用阻塞赋值,先写a=b再写b=a+1,执行顺序会让二者在同一时钟沿“串行传递”,产生仿真与硬件行为的不一致;若用非阻塞赋值,两条赋值并行求值、块末统一提交,仿真结果才与真实寄存器行为一致。

一道菜做完再做下一道,还是所有菜同时备料一起下锅?在时序厨房里,选错顺序哲学,菜就会串味、电路就会乱套。记住金科玉律:时序用<=,组合用=。

a=b (立即生效, 顺序执行);(a,b)next=(b,a+1) (并行提交)a = b \ (\text{立即生效, 顺序执行}); \qquad (a,b)_{\text{next}} = (b, a+1) \ (\text{并行提交})
左边是阻塞赋值语义,右边示范非阻塞赋值的“先求值、后提交”行为。
  1. 阻塞赋值=右侧值立即写入左侧变量,后面的语句能看到新值
    执行序: a′=b; b′=f(a′)\text{执行序: } a' = b; \ b' = f(a')
  2. 非阻塞赋值<=右侧在赋值发生时求值,但写回推迟到块结束
    求值: anew=bold;块末: a ← anew\text{求值: } a_{\text{new}} = b_{\text{old}}; \quad \text{块末: } a \ \leftarrow \ a_{\text{new}}
  3. 适用场景组合逻辑用=,时序逻辑用<=,两者分开走
    组合 ⇒ 阻塞;时序 ⇒ 非阻塞\text{组合} \ \Rightarrow \ \text{阻塞}; \quad \text{时序} \ \Rightarrow \ \text{非阻塞}

initial块应用

每天餐厅开门前,都有固定的开机动作:点亮灯箱、拉出菜牌、摆好预订桌牌。这些动作只发生一次,却是整天运营的基石。initial块,就是Verilog仿真世界中的“开工仪式”。

initial块从仿真时间0开始执行,运行到块的最后一条语句后自动关闭。它无法被综合成实际硬件(除少数FPGA寄存器初始化场景外),因此在RTL设计中几乎不出现,但在测试平台(testbench)中它是绝对的主角。

testbench是验证设计(DUT)正确性的仿真环境。我们用initial块做三件事:第一,初始化信号变量,比如把clk设为0、把reset设为1;第二,生成激励波形,比如在特定时刻拉高valid信号;第三,驱动时钟源,通过initial配合forever循环每隔半周期翻转clk,形成周期震荡时钟。此外,#延时语句是initial块中的核心工具,它控制仿真时间的推进,让激励按设计时序注入。

有了开门前的精心布置,白天的营业才能忙而不乱;有了initial块的初始化与激励,testbench才能把电路驱动到正确的轨道上。它虽不能成为硬件,却是验证电路真实性的第一步。

时钟生成: clkinit=0; forever #10 clk=∼clk\text{时钟生成}: \ clk_{\text{init}}=0; \ \text{forever } \#10 \ clk = \sim clk
这是testbench中最经典的时钟生成写法:初始化时钟为0,每10个时间单位翻转一次。
  1. 初始化在0时刻给所有信号赋予一个稳定初值
    t=0: clk=0, rst=1, data=0t=0: \ clk=0, \ rst=1, \ data=0
  2. 延时推进#后面的数值表示仿真时间推进量
    #10 ⇒ 推进10个时间单位\#10 \ \Rightarrow \ \text{推进10个时间单位}
  3. forever循环配合延时在initial块内产生周期信号
    forever #10: clk ← ∼clk\text{forever} \ \#10: \ clk \ \leftarrow \ \sim clk

AI 应用场景

AI加速器RTL设计

阶段:AI芯片设计的RTL编码阶段

解决的问题:用always块实现CNN加速器中的乘累加单元与流水线寄存器,将并行数据流按时钟节拍推进;用always@(*)组合逻辑实现数据选择MUX,保证每个时钟周期完成一层卷积运算。

具体案例:某FPGA AI加速器项目中,设计者用always@(posedge clk)实例化输入寄存器、流水线寄存器与输出寄存器,用组合always块计算数据有效信号,在100MHz时钟下完成8路并行卷积数据流调度。

AI硬件仿真验证

阶段:验证仿真的testbench搭建阶段

解决的问题:用initial块生成AI加速器DUT的复位序列、输入图像像素激励与时钟信号,验证RTL在不同并行配置下的吞吐是否符合软硬件接口规范。

具体案例:验证团队使用initial块先拉高rst持续20个时钟周期,再释放复位,随后喂入一组随机化像素流,每个时钟沿比较DUT输出与软件参考模型的差值,快速定位流水线冲突。

AI芯片SoC互连

阶段:AI硬件集成阶段的握手协议实现

解决的问题:在AXI或自定义握手协议中,用always@(posedge clk)登记valid/ready状态,用组合always@(*)产生仲裁条件与FIFO满空标志,实现多AI核间的数据同步。

具体案例:在NPU多核互联设计中,设计者用always块实现同步FIFO的读写指针,使用组合逻辑产生almost-full/almost-empty信号,避免数据包拥塞导致推理延迟抖动。

Python 演示

python35 行
#!/usr/bin/env python3"""模拟Verilog过程块中阻塞与非阻塞赋值的时序差异"""  def simulate(nonblocking: bool, cycles: int):    """模拟一个时钟驱动的always块在两种赋值方式下的行为"""    a, b = 0, 1          # 寄存器初始值    results = []    for _ in range(cycles):        if nonblocking:            # 非阻塞赋值: 右侧并行求值, 块末统一提交            a_new, b_new = b, a + 1            a, b = a_new, b_new        else:            # 阻塞赋值: 按书写顺序立即生效            a = b            b = a + 1        results.append((a, b))    return results  def main():    print("Cycle | Non-blocking (a, b) | Blocking (a, b)")    print("------ | ------------------- | ----------------")    nb_list = simulate(True, 6)    bl_list = simulate(False, 6)    for i, (nb, bl) in enumerate(zip(nb_list, bl_list)):        print(f"  {i}   |      {nb[0]}, {nb[1]}       |     {bl[0]}, {bl[1]}")    print()    print("Non-blocking: a and b swap in parallel at each clock edge")    print("Blocking: a updates first, then b sees new a (serial)")  if __name__ == "__main__":    main()
目的:通过两张输出表直观展示同一段“always块”逻辑,在非阻塞与阻塞赋值下的仿真结果差异。
  1. a_new, b_new = b, a + 1模拟非阻塞赋值的“右侧先并行求值”
  2. a, b = a_new, b_new模拟非阻塞赋值的“块末统一提交”
  3. a = b阻塞赋值立即生效,后面的语句能看见新值
  4. b = a + 1阻塞赋值中这里读到的a已经是更新后的值
  5. simulate(True, 6)` 与 `simulate(False, 6)对比两种赋值语义在6个时钟周期的结果
  6. if nonblocking:用分支切换两种赋值策略,对应硬件设计中的编码风格选择

常见误区

误区

正解

误区

正解

误区

正解

误区

正解

面试题

Q1. 下列哪个事件会触发always块执行?

单选题 · easy

  • A.模块实例化时自动执行
  • B.敏感列表事件发生时执行← 正确答案
  • C.仿真开始时执行一次程序
  • D.编译过程中自动执行一遍

答案:B。always块由敏感列表中的事件驱动。选项A是模块实例化的作用,选项C更接近initial块行为,选项D是编译器的动作,都与always触发机制无关。敏感列表好比订单铃,只有铃响(事件发生)厨师才会动手。

Q2. initial块在仿真中的执行特征是?

单选题 · easy

  • A.无限循环执行,永不结束
  • B.仿真0时刻执行一次即结束← 正确答案
  • C.受时钟上升沿反复触发
  • D.只能写一个语句且必须完整

答案:B。initial块在仿真0时刻启动,执行完最后一条语句便结束,如同开店前的一次性备料。选项A是无休止运行,选项C是always块的边沿触发特征,选项D过于绝对,initial块可以包含多条语句。

Q3. 组合逻辑建模应使用哪种敏感列表?

单选题 · easy

  • A.时钟上升沿触发事件
  • B.时钟下降沿触发事件
  • C.所有输入电平变化事件← 正确答案
  • D.复位信号上升沿事件

答案:C。组合逻辑要求任何输入变化都立即影响输出,因此使用always@(*)电平敏感列表。时钟边沿触发的选项A、B用于时序逻辑,选项D仅对复位信号敏感,无法覆盖其他输入变化。

Q4. 关于非阻塞赋值,下列说法正确的是?

单选题 · mid

  • A.在赋值语句处立即更新
  • B.在时间步末统一更新← 正确答案
  • C.只在initial块内可用
  • D.只适合组合逻辑电路

答案:B。非阻塞赋值<=右侧先求值,左侧在时间步末统一提交,这种并行性正好符合寄存器在时钟边沿同时更新的硬件行为。选项A是阻塞赋值的特征,选项C、D的说法均以偏概全。

Q5. Verilog中异步复位的正确写法是?

单选题 · mid

  • A.敏感列表含时钟与复位沿← 正确答案
  • B.敏感列表只用时钟信号
  • C.在块外单独检测复位信号
  • D.用initial块完成复位动作

答案:A。异步复位要求复位信号一旦有效就立即复位,因此敏感列表必须同时包含时钟沿和复位沿,形如always@(posedge clk or negedge rst)。若只用时钟信号(选项B)则是同步复位;选项C、D都不是寄存器建模的复位方式。

Q6. 下列哪些是Verilog编码的最佳实践?

多选题 · mid

  • A.组合逻辑使用阻塞赋值← 正确答案
  • B.时序逻辑使用非阻塞赋值← 正确答案
  • C.组合逻辑使用always@(*)← 正确答案
  • D.时序逻辑使用always@(*)

答案:ABC。A、B分别对应组合和时序的赋值规范,是业界标准;C用always@(*)描述组合逻辑也正确。D错在时序逻辑必须使用边沿触发(如posedge clk),单纯电平敏感无法提供寄存器所需的时钟边沿采样。

Q7. 在时序逻辑中混用阻塞赋值的主要风险是?

单选题 · hard

  • A.仿真与综合行为不一致← 正确答案
  • B.电路功耗显著增大
  • C.综合工具自动纠正
  • D.输入信号被自动屏蔽

答案:A。阻塞赋值会强制寄存器同沿串行更新,例如a=b; b=a+1会让b使用a的新值,而真实硬件中两个寄存器在同一时钟沿并行采样。这种“仿真对、电路错”的偏差,是混用阻塞赋值最大的风险。

Q8. 在always@(*)块中遗漏一个输入信号,会导致什么问题?

单选题 · hard

  • A.仿真行为与硬件不一致← 正确答案
  • B.综合工具必定报错
  • C.时钟频率相较预期降低
  • D.端口方向无法被确定

答案:A。仿真时该信号变化不会触发块重新执行,输出保持旧值;而综合生成的硬件天然对所有输入敏感。两者行为出现偏差,表现为仿真与综合不一致,这正是敏感列表完备性要求的原因。

Q9. 下列关于锁存器(latch)的说法正确的有?

多选题 · hard

  • A.if分支不全会生成锁存器← 正确答案
  • B.case缺少default生成锁存器← 正确答案
  • C.时序逻辑中也一定会生成锁存器
  • D.组合逻辑应保证分支完整← 正确答案

答案:ABD。组合逻辑中分支不完整,综合器会推断锁存器保留旧值,A、B正确,D是避免锁存器的整改手段。C错误:时序逻辑中分支不完整时保留旧值属于寄存器行为,不会额外生成锁存器。

Q10. initial块的典型应用场景包括?

多选题 · extreme

  • A.在仿真中生成周期性时钟← 正确答案
  • B.在仿真前初始化信号值← 正确答案
  • C.在RTL中实现复杂状态机
  • D.在测试台中对DUT进行复位← 正确答案

答案:ABD。initial块常用于测试平台:配合forever生成时钟(A)、对信号赋初值(B)、在仿真开始阶段拉高或拉低复位信号(D)。C错误——RTL中的状态机必须用可综合的always块实现,initial块无法被综合成硬件逻辑。