想象你是一家繁忙数字餐厅的后厨总管。后厨里有两个重要区域:进门处的“备菜台”和里面的“灶台区”。备菜台的特点是:食材一放到台面,切配师傅立刻按操作单处理,菜一上桌,台面清空,不保留任何关于上一位客人的记忆——这就是组合逻辑。灶台区则不同:厨师们盯着锅,但只有听到定时器铃声响起才动手翻炒,锅里始终保留着上一次的食材状态——这就是时序逻辑。Verilog 就是写给后厨的操作手册,它用两种完全不同的“笔法”分别管理这两个区域:写组合逻辑像写“即时响应单”,写时序逻辑像写“定时工序单”。这篇文章会带你走进这间后厨,看清两种建模方式的分工、配合与坑位,让你写出干净、可综合、能跑出正确波形的 RTL 代码。
掌握组合与时序两种建模范式,是数字前端设计与芯片验证的基本功;对 AI 从业者而言,这正是理解神经网络加速器、CPU 流水线等硬件实现如何“把算法变成电路”的关键钥匙。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
组合逻辑建模
备菜台上贴着一张“即时响应操作单”:只要台面上出现任何新的食材(输入),切配师傅就立刻根据操作单切出对应的菜(输出),台上不保存任何“昨天”的信息。在 Verilog 中,这张操作单就是 always @* 组合块。@* 相当于告诉综合工具:“把操作单里出现过的所有输入都装进敏感列表,任一输入一变,就重新算一次输出。”块内使用阻塞赋值 =,就像操作单上的步骤从上到下依次执行,中间结果能被后面的步骤直接看到,适合描述这种“看到什么算什么”的纯组合关系。
组合逻辑的衡量标准只有一个:输出是不是只由当前输入决定。如果某条输入路径下,输出没有被赋值,综合器就会认为“这里需要保存旧值”,于是偷偷生成一个锁存器(latch)——这就像上一桌客人的剩菜没被清走,被误当成了新菜端给下一桌。典型的安全写法是 if/else 配对完整,或在块首给所有输出赋默认值,确保每个输入组合都有唯一输出。
1. 输入与门:A 和 B 先经过一个与门,只有二者都为 1 时中间结果才为 1。
2. 输出或门:中间结果再与 C 进入或门,只要其中任意一个为 1,最终输出就为 1。
3. 信号更新:V 组合块中,A、B、C 都被隐含在 @* 敏感列表里,任意一个翻转都会触发重新计算。
所以,写组合 always 时,脑子里要有一张“备菜台即时响应单”:输入到输出的路径上没有任何记忆元件,所有赋值都用 =,并且务必让每种输入组合都有明确的去向,否则就会把上一桌的剩菜留给下一桌。
连续赋值与时延
备菜台旁边还有一台自动切菜机,只要食材从左边滑槽进去,切好的菜立刻就从右边滑槽出来,中间没有人工记忆。Verilog 的 assign 连续赋值就是这台切菜机:它把表达式的输出“钉”在一根线网(wire)上,只要右边任何输入变化,左边线网的电平立刻跟随更新。它和 always 组合块最大的不同在于,assign 写在过程块之外,更像一条永远生效的电气连线,而不是需要触发的工序单。
不过,真实的切菜机传送带也有物理延迟:食材滑进去后,要过 5 个单位时间才从出口出来。Verilog 允许你写 assign #5 Y = A & B; 来表示这种“传输时延”。但你一定要记住,这只是给仿真器看的慢动作提示,综合工具不会为 #5 生成任何实际的延迟电路——真实电路的延迟由门级物理决定,而不是这段代码。因此,可综合的 RTL 代码中几乎不该出现 #delay,否则仿真和实际硬件会对不上账。
1. 输入采样:在时刻 t 读取 A 和 B 的当前电平。
2. 逻辑运算:在线网内部完成与运算,得到中间结果。
3. 时延传递:中间结果经过 Δ 个时间单位后才反映到输出 Y 上。
assign 是描述组合逻辑最简洁的方式,它天生没有记忆,天然可综合;而 #5 这种时延写法只属于仿真世界的“传送带慢动作”,不要指望综合工具把它变成物理延迟。
时钟驱动时序
现在来到灶台区。这里的规则完全不同:食材(输入 D)放在灶台边,锅里有没有东西(输出 Q)不会立刻变化,只有听到定时器铃声(时钟上升沿)响一次,厨师才把灶台边的新食材放进锅里,同时倒掉锅里原本的旧菜。这个“铃声一响,整体换一下状态”的动作,就是时序逻辑的核心。在 Verilog 中,它写作 always @(posedge clk),posedge 表示只关心时钟信号从 0 跳变到 1 的那一瞬间。
时序块内部必须使用非阻塞赋值 <=。它和组合逻辑的 = 有本质区别:<= 的意思是“所有厨师的右手同时取旧锅里的记录,左手同时放新食材,然后统一落勺”。也就是说,右边表达式的值全部用进入时钟沿之前的状态计算,计算完后在同一时刻统一更新左边的寄存器。这正是为了防止多个寄存器互相串味:如果像组合逻辑那样一步步执行,后一个寄存器就会看到前一个刚更新的新值,导致电路行为完全错误。
1. 时钟边沿触发:posedge clk 表示只在时钟从低变高的瞬间才执行块内语句。
2. 采样旧值:进入边沿那一刻,先读取当前 D 的值,记为 D_n。
3. 统一更新:边沿结束后,Q 被更新为刚才采样的 D_n,其余时间 Q 保持不变。
时序逻辑的寄存器就像一口有记忆的锅,铃声不响不动勺。记住:时序 always 块中用 <=,组合 always 块中用 =,这两条规则就像后厨里“备菜台用手抓、灶台用长勺”的卫生分工,一旦混用,菜品的味道就全乱了。
寄存器与复位策略
每天开业前,后厨总管都要检查所有锅具:锅里绝不能残留昨天没卖完的菜。这个“开业清零”的动作对应数字电路中的复位(reset)策略。一个寄存器在刚上电时状态是未知的,如果不先复位,它输出的 X 值就会像馊掉的剩菜一样污染后续所有计算。复位通常有两种写法:同步复位和异步复位。
同步复位像“厨师只在闹钟响时顺便看一眼复位按钮”——复位信号只有在时钟上升沿到来时才能生效;它生成的电路更省资源,但对复位信号的持续时间要求较高。异步复位像“火灾警报一响,不管闹钟是否在响都立刻关火”——复位信号电平一变,输出立刻清零,不需要等时钟;它响应快,但复位撤销时若不靠近时钟沿,容易产生亚稳态。两者都要求 if 分支里写复位、else 分支里写正常工作逻辑,优先级由 if/else 嵌套顺序决定。
1. 复位判定:先看 rst_n 是否有效,低电平有效意味着 rst_n = 0 时触发复位。
2. 清零分支:复位有效时,Q 无条件变为 0,这就是“清空旧锅”。
3. 数据分支:复位无效时,Q 才在下一个时钟沿采样 D。
复位策略决定了一个电路能否在混乱的上电初期迅速走上正轨。异步复位负责“紧急清锅”,同步复位负责“按点清锅”,实际工程中常采用“异步复位、同步释放”的组合拳,但无论哪种,都要保证每个寄存器都能被可靠地拉回初始状态。
算术运算单元
后厨里有一台“分量换算台”:客人点了一桌菜,换算台要把所有食材用量加总。加法器就是数字电路里的分量换算台,它是纯组合逻辑——输入 A 和 B 一到,和 S 立刻算好。但这里有个后厨新手常踩的坑:进位(carry)。一位加法除了本位和,还要把低位的进位传上来,就像算总账时不能漏掉上一桌的零头。
多位加法器可以用一个表达式直接写出,综合工具会帮你生成进位链。乘法器则更像“多次称量”:一个 N 位乘 N 位的乘法,需要把 N 个部分积相加,组合路径很长,延迟很大。如果每个时钟周期都强求一次算完,时钟频率就会被拖慢。这时就需要把大运算拆成多拍,用寄存器暂存中间结果——这就从“备菜台”转移到了“灶台区”。另一个关键点是位宽:两个 N 位数相加,结果最多有 N+1 位;相乘则最多有 2N 位。位宽不够就像秤的量程不够,会直接把多的部分丢掉。
1. 加数输入:A 和 B 是 N 位二进制数,C_in 是来自低位的进位。
2. 模运算:结果可能达到 N+1 位,超出 N 位的部分会被舍去,等价于对 2^N 取模。
3. 输出拆分:取模结果作为本位和 Sum,而被舍去的最高位就是输出进位 C_out。
算术单元是 AI 加速器等计算芯片的心脏。加法器这类“即时称量”适合用组合逻辑描述;乘法器这类“多次称量”则要权衡位宽、级数和时钟频率,必要时用多拍寄存器把长路径切短。
流水线寄存器
想象后厨出菜要经过“切配 → 烹饪 → 装盘”三个工位,如果所有工序都由一个厨师从头做到尾,那么第一道菜做完之前,第二道菜只能在门口等着,整体翻台率很低。流水线的思路是:在每两个工位之间放一个传菜托盘(寄存器),切配师傅把半成品放上托盘就走,立刻开始切下一道菜;烹饪师傅从托盘取半成品加工,再放上下一个托盘。这样三个工位可以同时处理三道菜,虽然单道菜上桌的总时间没变短,但单位时间端出的菜多了——吞吐率提高了。
在 RTL 中,流水线就是在一段长组合逻辑中间插入寄存器,把单拍延迟从“一大段”切成“几小段”。因为时钟周期只需要满足最慢的一小段,而不是整个长路径,所以最高时钟频率能大幅提升。划分流水线时要注意时序平衡:如果一段切得很粗、另一段切得很细,快慢不均,整体还是要等最慢的那段,就像三个工位中最慢的厨师决定了整条线的节奏。
1. 时钟到输出:时钟沿到后,寄存器需要 t_clk-q 时间才能把 Q 端稳定输出。
2. 组合逻辑延迟:信号经过组合逻辑需要 t_logic,这是流水线要重点切割的对象。
3. 建立时间:下一个寄存器要求数据在时钟沿前提前 t_setup 到达。
4. 周期约束:这三者之和必须小于等于时钟周期,于是得到最大频率公式。
流水线寄存器就像传菜托盘,把原本漫长的一条路切成多个可以同时工作的隔间。它能显著提升吞吐率,代价是增加了寄存器数量和单条数据的延迟,这正是面积、延迟与吞吐率之间的经典权衡。
RTL建模规范
后厨如果卫生条例混乱,备菜台和灶台区混用,迟早会吃出问题。RTL 建模规范就是后厨的卫生条例,它不是可综合性的硬性红线,更是团队协作和仿真一致性的保障。第一条铁律是:组合逻辑和时序逻辑必须分开写在不同的 always 块里。组合块用 =,时序块用 <=,如果混在一起,仿真器逐条执行的行为和真实硬件并行更新的行为会产生差异,就像按“手抓菜”的规则去操作灶台,锅气全被搅乱了。
第二条铁律是敏感列表必须完整。always @* 是组合逻辑最安全的选择,它能自动收集所有被读到的信号;手动写敏感列表时一旦漏掉某个输入,仿真时该输入变化不会触发块内更新,而综合时工具会按“完整”电路生成,导致仿真和综合结果不一致——这就是著名的“仿真与综合不匹配”陷阱。第三条铁律是 if/else 必须配对完整,否则会生成锁存器;case 语句同样要写出 default 分支,确保穷尽所有情况。
1. 选择信号:sel 作为控制位,决定输出走 A 还是 B 这条路。
2. 各自通路:sel 与 A 相与,sel 取反后与 B 相与。
3. 完整输出:两个通路一加,任意 sel 取值都对应一个确定的 Y,不会出现“没赋值”的空档。
RTL 规范的本质,就是让代码的每一条分支都像后厨的每一道工序一样明确可追踪。组合与时序分离、敏感列表完整、条件分支穷尽,这三条纪律能避免绝大多数仿真与综合行为不一致的“后厨事故”。
综合优化技巧
后厨运营一段时间后,总管开始琢磨怎么用同样的人手服务更多客人:能不能让两台用不到的设备共用一个切菜机?能不能把两个闲着的传菜托盘合并?这就对应综合优化中经典的资源共享与寄存器合并。当两个模块不会同时使用某个乘法器时,可以用一个乘法器加多路选择器来替换,面积立刻缩小;当多个寄存器的使能条件相同时,也可以合并成一个更宽的寄存器,减少触发器数量。
另一类优化针对关键路径——整条电路中最慢的那一段路。它对时钟频率有决定性作用,就像最慢的厨师决定了整条流水线的节奏。常见的做法包括路径重组、逻辑等价变换、在路径中间插一级流水寄存器,以及调整运算符优先级减少级联层次。但优化永远是权衡:追求极端频率会消耗更多寄存器和面积,追求最小面积可能拖慢速度。综合工具通常先让开发者用约束指定目标,再由工具在面积、时序、功耗之间寻找平衡。
1. 独立实现面积:如果两个运算各用一个乘法器,总面积为两个乘法器面积之和。
2. 共享实现面积:共用一个乘法器,但需要多路选择器在两条数据通路间切换。
3. 大小比较:当选择器面积远小于一个乘法器时,共享方案明显更省面积。
综合优化就像后厨的精细化运营:共享设备、合并托盘、调整工序顺序,目标都是让同样的资源端出更多菜。但天下没有免费的午餐,每次优化都在面积、时序和功耗之间做取舍,而聪明的 RTL 工程师知道把功夫花在最关键的瓶颈路径上。
AI 应用场景
CPU流水线寄存器的RTL设计
阶段:芯片前端设计
解决的问题:现代 CPU 和 AI 加速器的主频动辄数 GHz,一条几十级门的长组合路径根本无法在一个时钟周期内算完。传统写法把所有逻辑堆在一个 always 块里,时序收敛困难。利用流水线寄存器把指令解码、执行、访存、写回切成多级,每级延迟大幅缩短,主频得以提升。
具体案例:某 RISC-V 处理器设计中,开发者将整数乘法器拆分为三级流水,每级插入一组寄存器暂存部分积,最终在 FPGA 上把时钟频率从 80MHz 提升到 150MHz。
AI加速器中乘加阵列的组合建模
阶段:硬件算子建模
解决的问题:神经网络推理的核心是乘加运算。设计 MAC(乘加)阵列时,大量组合逻辑同时计算部分和,需要严格遵循组合逻辑建模规范:用 assign 或 always @* 描述即时运算,杜绝因为 if 分支不完整而生成锁存器;同时为每个部分积预留足够位宽,防止累加溢出。
具体案例:在卷积加速器的设计中,工程师用 8 位输入、32 位累加器的位宽策略,配合组合逻辑建模,将一组 3×3 卷积的乘加结果在一个时钟周期内稳定输出,并通过流水线寄存器切分多路并行数据。
系统级仿真中的复位策略验证
阶段:仿真验证
解决的问题:芯片上电瞬间,所有寄存器的初始值未知,必须依靠复位策略将它们拉回已知状态。若复位逻辑建模不正确,仿真时会出现大量 X 态传播,导致验证环境无法判断对错。
具体案例:在 SoC 验证环境中,测试平台(testbench)在复位释放后检查所有关键寄存器的初值;若异步复位敏感列表漏写了 negedge rst_n,仿真时复位动作不生效,验证者可通过定向用例快速定位 RTL 建模错误。
Python 演示
# 用 Python 模拟 Verilog 组合逻辑与时序逻辑的两种建模方式class FlipFlop: def __init__(self): self.reg_q = 0 self._prev_clk = 0 def update(self, clk, d): # 等价于 always @(posedge clk) Q <= D; if self._prev_clk == 0 and clk == 1: self.reg_q = d self._prev_clk = clk def square_wave(t, period, high_len): cycle = t % period return 1 if cycle < high_len else 0 def xor_gate(a, b): # 组合逻辑: 输出只由当前输入决定 return a ^ b def main(): ff = FlipFlop() for t in range(1, 41): clk = square_wave(t, 8, 4) d = 1 if (t // 5) % 2 == 0 else 0 comb_out = xor_gate(d, 1) ff.update(clk, d) if t % 8 == 0: print(f"t={t:2d} D={d} C={comb_out} Q={ff.reg_q}") if __name__ == "__main__": main()
class FlipFlop:对应 Verilog 中的reg寄存器,用reg_q保存当前状态。if self._prev_clk == 0 and clk == 1:等价于always @(posedge clk),只有检测到上升沿才触发。self.reg_q = d等价于非阻塞赋值Q <= D,在时钟沿统一更新。def xor_gate(a, b): return a ^ b对应assign C = A ^ B;,输入变化输出立即变化。ff.update(clk, d)每个时钟周期驱动寄存器采样一次,模拟综合后硬件的并行行为。
常见误区
误区写时序逻辑时用了阻塞赋值 `=`,仿真波形看起来好像也差不多,于是觉得阻塞、非阻塞混着用没关系。
正解仿真能过往往是因为电路简单、数据依赖不明显。一旦多个寄存器互相赋值,阻塞赋值会像“厨师按顺序一个个放料”,导致后面的寄存器看到前面刚更新的新值;而非阻塞赋值才是“所有厨师同时按旧记录放料”。真实硬件是并行更新的,只用非阻塞赋值 <= 才能保证仿真行为和综合后电路一致。
为何常见:初学时只跑单一模块的小例子,数据依赖简单,阻塞和非阻塞的行为差异被掩盖了,容易让人误以为两者等价。
误区有人觉得 `always @*` 是偷懒写法,手动把信号一个一个写进敏感列表更“专业”。
正解手动写敏感列表非常容易漏信号。漏掉的信号在仿真时不会触发块内更新,而综合工具会按照完整逻辑生成电路,于是仿真波形和真实芯片行为不一致,产生难以排查的 bug。always @* 会自动收集所有被读到的信号,是组合逻辑最安全的选择。
为何常见:初学者从教学示例里看到别人手写 always @(a or b),误以为那是主流写法,不知道仿真与综合差异的严重性。
误区写 always 组合块时只写了 if 分支,觉得综合工具很聪明,会自动把没赋值的输出处理成 0。
正解综合工具不会替你选默认值,它只会认为“这个输入条件下要保存旧值”,于是推断出锁存器。锁存器是电平敏感的记忆元件,行为复杂、时序难收敛,在可综合 RTL 中应尽量避免。要在块首给所有输出赋默认值,或保证 if/else、case/default 完全配对。
为何常见:初级教程里示例代码比较短,分支少,漏写 else 也未必马上报错,于是形成“偷懒也没事”的错觉。
误区写 `assign #5 Y = A & B;` 时,以为综合工具会生成一个延迟 5 纳秒的物理电路。
正解#delay 只是仿真模型里的传输延迟,用来模拟门级走线的滞后现象。综合工具不会为它生成任何逻辑,真实延迟由工艺库中的门延时和布线延时决定。可综合 RTL 中写 #delay 通常会被忽略或导致综合告警。
为何常见:初学者先学仿真,看到波形确实晚 5 个单位更新,就误以为综合后也会如此,忽略了仿真模型和电路实物的区别。
误区异步复位信号不是数据通路,有人认为它不需要出现在 always 的敏感列表里。
正解恰恰相反,异步复位的含义就是“复位信号一变立即生效”,因此必须把它写进敏感列表。正确写法是 always @(posedge clk or negedge rst_n),块内用 if 判断复位优先级。漏写复位信号会导致综合工具把异步复位当作同步复位处理,紧急清锅的警报就失灵了。
为何常见:同步复位的写法是 always @(posedge clk),习惯之后容易把敏感列表的一致性忽略掉,混淆两种复位语义。
面试题
Q1. 组合逻辑输出的典型特征是什么?
- A.与历史输入有关
- B.只由当前输入决定← 正确答案
- C.依赖时钟上升沿
- D.必须复位后有效
答案:B。组合逻辑像备菜台,输出只看当前台面上的食材,与过去无关。A 是时序逻辑的特征,C 是时钟触发的特征,D 是复位相关,均与组合逻辑定义不符。
Q2. always @* 中的 @* 表示什么?
- A.所有输出信号
- B.所有被读到的输入信号← 正确答案
- C.所有时钟边沿
- D.所有 reg 类型变量
答案:B。@* 自动把块内被读取的所有信号放入敏感列表,任一变化都会触发块执行。它不等同于输出、时钟或变量类型,而是“读到的输入集合”。
Q3. 组合逻辑 always 块中,如果某个输入条件下没有给输出赋值,会综合成什么?
- A.触发器
- B.锁存器← 正确答案
- C.或门
- D.三态门
答案:B。综合工具认为“没赋值就要保存旧值”,从而推断出电平敏感的锁存器。触发器是边沿敏感,与组合块的语义不符;或门和三态门也不是这种情况下生成的。
Q4. 时序逻辑 always 块中应当使用哪种赋值方式?
- A.连续赋值 assign
- B.阻塞赋值 =
- C.非阻塞赋值 <=← 正确答案
- D.位拼接 {}
答案:C。时序逻辑模拟的是并行更新的硬件行为,非阻塞赋值 <= 让所有寄存器在同一时钟沿按旧值统一更新。assign 是连续赋值,只用于线网;阻塞赋值更适合组合逻辑。
Q5. assign #5 Y = A; 中的 #5 在综合时会怎样?
- A.生成 5 个寄存器
- B.生成 5ns 延迟线
- C.仅仿真有效,综合忽略← 正确答案
- D.产生 5 级组合门
答案:C。#5 是仿真中的传输时延,用来观察波形滞后;综合工具不会为它映射任何物理延迟,真实延迟由工艺库决定。因此可综合 RTL 应避免写这类时延。
Q6. 以下哪个是异步复位时序逻辑的正确敏感列表?
- A.
always @(posedge clk) - B.
always @(posedge clk or posedge rst_n) - C.
always @(posedge clk or negedge rst_n)← 正确答案 - D.
always @(*)
答案:C。低有效异步复位 rst_n 要求在复位信号下降沿也能触发,所以要用 negedge rst_n。写 posedge rst_n 仍能复位,但复位沿语义错误;B 和 D 都不能正确描述异步复位行为。
Q7. 流水线寄存器的主要作用是什么?
- A.增加组合逻辑延迟
- B.降低单拍关键路径长度,提高时钟频率← 正确答案
- C.减少寄存器数量
- D.消除所有亚稳态
答案:B。流水线通过在长组合路径中插入寄存器,把一条大延迟切成多段小延迟,从而缩短单拍关键路径、提升 Fmax。它反而会增加寄存器数量,也不能彻底消除亚稳态。
Q8. 哪些做法可以有效避免组合逻辑中生成锁存器?
- A.在每个 if 分支都给输出赋值← 正确答案
- B.只在 else 分支给输出赋值
- C.在 always 块开头给输出赋默认值← 正确答案
- D.在所有输出上使用非阻塞赋值
答案:AC。分支赋值完整和块首默认值是两种经典手段,能确保每个输入组合都有明确输出。B 忽略了 if 为真时的赋值,会导致锁存器;D 的非阻塞赋值属于时序建模,不能解决组合逻辑锁存器问题。
Q9. 关于阻塞赋值与非阻塞赋值,下列说法正确的是?
- A.组合逻辑多用阻塞赋值← 正确答案
- B.时序逻辑多用非阻塞赋值← 正确答案
- C.时序逻辑可以用阻塞赋值模拟寄存器功能
- D.非阻塞赋值的右值使用旧值统一更新← 正确答案
答案:ABD。组合逻辑用 =,时序逻辑用 <=,这是 RTL 铁律。非阻塞赋值先在边沿采样右值旧值,再统一更新左值。C 错误:阻塞赋值在仿真中模拟出的寄存器行为和真实并行硬件不一致,不能用于可综合 RTL。
Q10. 关于同步复位与异步复位的比较,正确的是?
- A.同步复位只在时钟有效沿生效← 正确答案
- B.异步复位不需要时钟就能复位← 正确答案
- C.异步复位可能带来复位撤销时序问题← 正确答案
- D.同步复位一定比异步复位响应快
答案:ABC。同步复位是“等闹钟响才清锅”,必须有时钟沿;异步复位是“警报一响立刻清锅”,不依赖时钟,但复位撤销时若不靠近时钟沿容易产生亚稳态。D 说反了,异步复位响应更快,同步复位受限于时钟周期。