很多朋友在刷 Verilog 题目的时候,前几章组合逻辑还能靠assign一路平推,但到了 H D L B i t s 的 Procedures 这一章,突然就开始懵了。这不是你基础差,而是这一章的思维模式跟前几章完全是两个维度——它把“描述电路”变成了“描述行为”,把“连续驱动”变成了“过程赋值”。这章如果只是机械刷题,不做总结,很容易出现“对着波形改代码”的坏习惯。这篇笔记我就把自己在 Procedures 章节里踩过的坑、理清的思路、以及值得反复品味的细节,一次性整理出来。
1. 章节定位:从持续赋值到过程赋值的思维切换
1.1 为什么这一章是新手分水岭
前几章的题目里,你见到的大多数电路本质上都是组合逻辑,用assign一个表达式就能写完。assign的特点是:只要等号右边的信号一变,左边立刻跟着变。它的驱动是“持续不断”的,不需要你告诉它什么时候干活,它永远在干活。
但是 Procedures 这一章出现了一个新东西:always块。always块的核心是“什么时候干活”而不是“怎么干活”。它需要你显式地给出触发条件(敏感列表),然后在块内部用过程赋值语句去描述电路行为。这一下就带来了三个层面的变化:
- 表达方式变了。从用
assign写表达式,变成在always块里写语句。 - 思考方式变了。你需要开始关心信号变化的“时机”,而不只是“函数关系”。
- 硬件层面变了。不是所有写在
always里的东西都能被综合成纯组合逻辑,有时候一不小心就综合出了触发器。
这也是为什么很多人在这一章开始掉队:之前是在“画电路”,现在是在“描述事件”。我在这一章最大的体会是,一定要先想清楚“我想让这个信号什么时候更新”,再去写代码。如果没想清楚这个,写出来的代码经常仿真对了,上板就错,或者综合之后逻辑完全不是你想要的。
1.2 Procedures 到底在讲什么
这一章的核心关键词有三个:always、case、和阻塞/非阻塞赋值。围绕它们,题目的难度是层层递进的:
- 基本语法题:几种
always块的写法对比,比如组合逻辑用always @(*),时序逻辑用always @(posedge clk)。 - 多路选择与控制流:
if-else和case语句在过程块中的用法,特别是优先级和分支覆盖的问题。 - 阻塞赋值与非阻塞赋值:这个基本是必考的。很多题看起来代码一样,就赋值符号不同,综合出来的电路截然不同。
- 循环与生成块:
for循环在过程块里和块外的区别,以及硬件生成的时机。
我在刷这一章的时候有个很明显的感觉:它不像前面章节那么“按图索骥”了,反而更像是把你丢进一个零件库,让你自己根据需求挑零件、组装电路。比如同样是四选一多路器,你可以用assign加三目运算符嵌套,也可以用case语句,还可以用if-else链。功能上等价,但综合后的优先级结构、逻辑级数、代码可读性都不一样。
1.3 适合谁来读这篇笔记
如果你是刚刷完 HDLBits 前三章、正要进入 Procedures 的读者,这篇笔记可以帮你规避掉大部分初学者会犯的典型错误。如果你已经做完了 Procedures 的题,但对仿真结果和综合结果的差异还有疑惑,这篇笔记里的“为什么”部分也能帮你补上一些底层认知。
另外多说一句:这一章学完之后,你就可以开始折腾一些简单的状态机和时序逻辑了,所以值得认真对待。我见过不少人急匆匆往后刷,结果到了时序电路那一章很多东西理解不了,回头又翻过来补这一章的基础,反而更花时间。
2. 核心语法深度拆解:always 块的工作原理与正确用法
2.1 always 块就像“被事件驱动的工人”
always块可以这样来理解:你雇佣了一个工人,他平时不动手,但是只要等到触发信号发生变化(或者边沿到来),他就会把begin...end里面的语句从头到尾执行一遍。执行完后他就继续等着,直到下一次触发。
这个类比可以帮助你记住几个关键点:
always块不是“一直执行”的,它在仿真中只在敏感列表里的信号变化时才执行一次。- 块内语句是按书写顺序执行的吗?不一定。在时序逻辑中使用非阻塞赋值时,所有语句虽然写在
begin...end里,但执行顺序和最终效果与“读取旧值、计算新值、统一更新”的模式有关。 always块的触发事件消失后,它内部不会保留“正在执行”的状态。这使得它天生适合描述“边沿触发”的时序逻辑。
在 HDLBits 中,很多题会给你一个残缺的always块,让你补全。这时候关键是要判断题目想要的是组合逻辑还是时序逻辑。判断方法非常简单:
- 敏感列表是
@(*),那么它背后是纯组合逻辑。 - 敏感列表是
@(posedge clk),那么它背后是时序逻辑,输出端往往需要寄存器。
我在做题时给自己定了一条规矩:看到题目里有clk,先在草稿纸上画出触发器符号,再来写代码。没有clk的题目,先想想能不能用assign简化表达,比如数字比较器、算术运算等,能用assign表达清楚的,不一定非要写always。
2.2 组合逻辑用 always @(*) 而不是 always @(a or b or c)
很多初学着熟悉了always @(a or b)这种写法,就一直用下去。不出代码规模小的时候问题不大,但一旦组合逻辑稍微复杂一点,漏掉敏感列表里的一个信号,仿真结果就会出现莫名其妙的“保持上一刻值”的情况。比如:
always @(a or b) begin q = c & d; end上面这段代码里,c和d没有出现在敏感列表里。仿真中如果只有c变化,q不会更新。这显然不对。HDLBits 中有不少题目就是故意挖这个坑的,要求你把敏感列表补全。
所以,组合逻辑的always块,建议一律使用@(*)。它等价于“块内所有被读到的信号变化时都触发执行”,省略号写起来省心,而且永远不会漏变量。@(*)综合出来的电路和你用assign写出来的组合逻辑本质上没有区别。
2.3 时序逻辑中的边沿描述与同步复位
再往后,你会遇到always @(posedge clk)配合复位的写法。这里有一个特别容易混淆的点:同步复位和异步复位在敏感列表中的表达完全不同。
- 异步复位:
always @(posedge clk or negedge rst_n),复位信号一到,不管时钟在不在,立刻复位。 - 同步复位:
always @(posedge clk),复位只在时钟上升沿来临时才生效,写在if (rst_n == 0)的分支里。
我在学习的时候犯过一个错:把异步复位的写法写成了同步复位,结果综合出来的电路多了一个异步复位端口,功能上也不是自己想要的。HDLBits 的题目对复位信号的要求给得很清楚,做题前一定先读清楚是“高电平复位”还是“低电平复位”,是“异步”还是“同步”。这个不光影响代码,还影响综合后实际电路的上电初始化状态。
3. 阻塞赋值与非阻塞赋值:这章最容易懵的地方
3.1 两种赋值的行为差异
过程赋值里最核心的一对概念就是=和<=。哪怕你代码背得滚瓜烂熟,也一定要理解它们的硬件含义。
- 阻塞赋值(
=):“立刻生效”。执行到这条语句时,右边表达式用当前时刻的值计算,然后立即更新左边的值。后续语句马上就能看到这个新值。 - 非阻塞赋值(
<=):“预定更新”。执行这条语句时,右边表达式的值先算好,但并不会立即写入左端变量。要等到当前这个时间步结束(或者说所有语句都执行完)之后,左端才统一更新。
这个区别用一句话概括:阻塞赋值是“边算边改”,非阻塞赋值是“统算统改”。
3.2 用生活类比理解为什么时序逻辑必须用非阻塞赋值
想象一下,你在一个流水线上组装东西。每个人手里拿的是上一个工序传递过来的“前一版零件”。如果你在这个工序里直接把自己刚改好的新版零件递给下一个人,那么下一个人拿到的就不是“当前时刻的初始状态”,而是“你加工后的状态”——这就造成了信号在网上传播时出现了先后依赖关系,也就是竞争。
在时序逻辑中,我们希望的是:每个触发器在时钟沿到来时,同时采样自己输入端口上的旧值,然后在时钟沿之后统一翻转。非阻塞赋值天然满足这个行为:所有语句先读取旧值、计算新值,最后统一更新。因此时序电路里用<=可以避免出现“你更新了我还没更新”这类问题。
而在组合逻辑中,我们希望信号像电线一样直接传播,边读改边输出,此时用=更自然。组合逻辑里如果使用非阻塞赋值,反而会因为更新时机太晚而出错。
3.3 HDLBits 题目里怎么判断该用哪种赋值
做题多了,你会总结出这么一条经验:看赋值号右端信号的来源。
- 如果等号左边的信号是由
clk驱动的寄存器输出,一定要用非阻塞赋值(<=)。 - 如果等号左边的信号是纯组合逻辑决定的中间变量,用阻塞赋值(
=)。
举个例子,题目要求你实现一个移位寄存器。代码框架长这样:
always @(posedge clk) begin if (rst) begin q <= 0; end else begin q <= d; end end这个写法里用了<=,因为在时钟沿到来时,q要采样的是d在时钟沿之前的旧值,时钟沿之后再传递给下一级。如果改用=,仿真结果在个别情况下可能也能通过,但综合出的电路会出现复杂的前馈结构,甚至在仿真中暴露出时序违例的问题。
我是强烈建议你在做这一章的题目时,手里拿一张纸:写代码之前,先在纸上画清楚哪些信号是触发器输出,哪些是组合逻辑输出。然后触发器输出一律<=,组合逻辑输出一律=。等这个习惯养成了,你再往后写状态机就会顺很多。
4. case 语句与 if-else 的细节:分支覆盖和优先级陷阱
4.1 case 语句的完整匹配特性
case语句在过程块中常用于多路选择器。很多人直接用case做多路选择,却忽略了它的一个关键特点:case要求分支表达式和输入值“完全相等”时才匹配。注意,它不支持“包含”或“范围”匹配,除非你使用casez或casex。
这意味着什么?如果你写了一个四位输入的多路选择器,但分支只覆盖了其中 8 种情况,剩下 8 种情况会落到default分支。如果连default都没写,综合工具就会帮你生成一个锁存器(latch)。
下面这段代码就是一个反例:
always @(*) begin case (sel) 2'b00: q = a; 2'b01: q = b; 2'b10: q = c; endcase endsel有 4 种取值,当sel == 2'b11时,q没有任何赋值。组合逻辑要求信号永远有确定值,缺一种情况,就相当于告诉综合器“这里没赋值时保持原值”,而这个行为只能靠锁存器来实现。大部分情况下你是不想要这个锁存器的。所以case语句最好加上default: q = ...;补全所有未覆盖的情况。
4.2 if-else 的优先级问题
与case不同,if-else天然是“优先级”结构:先判断的条件优先级最高。多个条件同时为真时,输出取决于优先级最高的那个分支。
写复杂的if-else链时,我建议你心里默认一个“最短路径”的原则:最常用的条件放最前面,最不常用的放最后面。这样综合出来的逻辑链更短,时序更容易满足。但也要注意一点:过长的if-else链会级联出很深的组合逻辑。
如果你发现自己的if-else嵌套超过三四层,并且每个分支还牵扯到多位信号,就要考虑是不是可以用case来简化了。
4.3 避免生成锁存器的通用原则
前面提到了锁存器,这可能是 Procedures 这一章里最高频的避坑点。锁存器本质上是一种“电平敏感的存储单元”:时钟或使能信号为高时,输出跟输入走;为低时,输出保持。它并不是一无是处,但在绝大多数 HDLBits 题目里,你需要的不是锁存器。
锁存器生成的本质原因是:在某些情况下,输出没有被赋值。这常见于:
case缺少分支且没有default。if没有配套的else,且条件不满足时输出保持。- 逻辑上虽然考虑了所有情况,但综合工具无法证明覆盖完整。
避免办法就三条:case必须带default;if必须带else;如果用always @(*)描述组合逻辑,一定要确保所有输入组合下,输出都有确定值。只要把这三条刻在脑子里,锁存器问题基本能躲掉八成。
5. 基于 HDLBits 的实操流程:从读题到仿真验证
5.1 我的做题准备和路径
我刷 Procedures 这一章时,并不是直接硬怼题目,而是先做了一套准备动作。这里分享出来,你可以直接抄作业。
第一,先准备一个草稿本。HDLBits 的题目文字往往很短,但里面藏了时序关系、复位极性、位宽符号等无数细节。只看题面就写代码,很容易漏条件。我会把题目人工翻译成“一句话需求”,比如:“实现一个带同步高有效复位、使能信号的八位右移移位寄存器”。这句话写清楚之后,再动手写代码,思路清晰很多。
第二,打开 Verilog 参考手册或者翻出之前学过的语法清单。HDLBits 在线平台上没有语法提示,所以如果某些语法拿不准,比如casez的用法、for循环在always里的写法,我会快速翻一下手边的资料再开始。这个习惯能大大减少因为低级语法错误反复试错的时间。
第三,先写框架,再填逻辑。我的框架一般是先写模块端口声明,再写内部信号定义,最后补always块。顺序反过来的话,经常出现写到一半发现自己信号名忘记声明,加载仿真时疯狂报编译错误,特别影响节奏。
5.2 一个典型时序逻辑题的完整剖析
我这里挑一个移位寄存器与逻辑运算结合的题目,把它从头到尾过一遍。题面大意是写一个模块,输入为时钟、复位、使能和信号数据,输出为寄存器的当前值。
我拿到题目后的第一步,是把输出信号标记为“触发器输出”。这意味着我在赋值时要使用<=。
第二步,确定复位方式。如果题里给的复位是异步低有效复位,那代码框架应该是这样:
module shift_reg ( input clk, input rst_n, input en, input d, output reg q ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin q <= 1'b0; end else if (en) begin q <= d; end end endmodule这里q是所有输入共同作用后的结果,但导致它更新的核心事件是时钟边沿,所以这是一个时序模块。en信号在这里只是作为条件闸门,它并不在敏感列表里,因为它的变化本身不该直接引起q变化。
第三步,写完后我会在心里做一个“波形图推演”。比如时钟上升沿前d为高,复位无效且en为高,则上升沿后q变为高;若en为低,上升沿后q保持。这步能帮你提前发现边界条件错误,不用反复点仿真。
5.3 仿真通过后还要做的事
很多人看到“仿真成功”就收工了。但我在刷题实践中发现,仿真过了只能说明行为正确,还不能说明你理解到位了。
我会额外做两件事:
一件事是把题目改成“反义变式”。比如原来用同步复位,我就换成异步复位,看看代码要改哪几处;原来用case,我改成if-else,看看优先级变化会不会改变输出。这样一来,一道题就变成了三四道变式,思维锻炼的强度上去了,之后遇到同类题基本就能秒杀。
另一件事是把易混淆的写法单独存到笔记里。比如我会整理一张表格,对比“同一段代码用阻塞与非阻塞赋值时的仿真结果差异”,再配上我自己画的波形图。这比单纯抄答案有用得多——因为你在记录的是“为什么这样写”,而不是“这个题目的标准答案长什么样”。
6. 我在这一章里踩过的典型坑与排查心得
6.1 深浅复位之分导致的功能错乱
我在刷带有计数器的题目时,曾因把异步复位写成同步复位,导致仿真结果整体偏慢一个节拍。排查过程很典型:每次仿真输出和预期波形总是错位,一开始以为是计数逻辑问题,吓得检查了好几遍计数器代码,结果发现只是复位方式写错。
后面我总结出一条排查顺序,分享给你:
如果你发现输出结果“永远慢一拍”,优先检查复位是不是异步的写成了同步的。
如果你发现复位后第一个输出就不对,优先检查复位的极性是不是反了(高有效写成了低有效)。
如果你发现使能失效后有“莫名遗留值”,优先检查是不是缺了else或者default,导致锁存器生成。
6.2 分支条件覆盖不完整
有一次我做一个多路选择器,用例时每个条件都写好了,但漏了default。仿真结果在特定的输入组合下,输出一直保持上一个状态。我当时以为是自己数据处理流程的问题,改了老半天才发现是锁存器惹的祸。
这里教大家一个快速检测方法:看波形图中输出是否有“平坦保持段”。如果输出在输入变化后没有跟随变化,而是维持旧值,大概率是代码里出现了锁存器或漏了赋值。
另外,综合工具一般会输出警告,提示“inferring latch”。养成编译后看 warning 的习惯,很多问题能提前暴露。
6.3 信号位宽不匹配导致数值异常
Procedures 这一章不少题目涉及多位信号的运算和拼接。位宽不匹配的坑在于,仿真时它不会直接报错,而是按“截断高位”或“补零扩展”处理,结果跟预期不一致。排查这类问题,使用十六进制波形显示会直观非常多。
我的经验是:在多信号参与运算之前,先统一规划好位宽。比如两个 8 位信号相加,至少要用 9 位来存结果。如果你在always块里写的存储变量是 8 位,那把 8 位加 8 位的结果赋给 8 位变量,溢出部分会被砍掉,这在硬件上就是丢弃进位位,行为上就是“数值突然回绕”。
7. 从 HDLBits Procedures 延伸到后续学习路径
7.1 学完这一章之后你获得了什么能力
学完 Procedures 之后,你再回头看前面的组合逻辑章节,会发现自己能做的事情变多了。以前只能写数据通路,现在可以写控制器了。always块让你有能力描述“在某个时刻做某件事”,这才是数字系统设计真正的主角。后续的有限状态机、存储器控制、流水线设计,甚至简单 CPU 的寄存器堆和控制器,基础都在这一章。
7.2 配套练习建议
如果你刷完了 Procedures 还觉得意犹未尽,我建议你回到 HDLBits 往前再刷一遍章节中的“时序电路”入门题。那里面有些题思路简化之后就是 Procedures 的组合逻辑块加寄存器。
这一章学扎实后,下一步去看“More Verilog Features”一节时,几乎可以无缝衔接。那节课里讲的generate块和自定义函数,在过程块里也有应用场景。比如你在多个模块里都要做参数化位宽的数据比较,用function封装就能避免代码重复。
我个人在学完 Procedures 后的做法是,把以前用assign硬写过的组合逻辑题,全部用always @(*)重写了一遍。重写过程中,我发现了不少冗余信号,也因此理解了为什么很多老手推荐模块内部优先用always块实现控制逻辑,用assign只做简单的连续驱动。两种表达方式各有擅长,千万不要一个走天下。
7.3 最后分享一个小技巧
很多人在过程块里写for循环时,会下意识地把它当成软件循环来理解,觉得它是“依次执行多次”。但是在硬件描述语言里,for循环在综合时会被展开成多个并行的硬件单元。也就是说,循环变量i不是为了“依次计算”,而是为了“重复生成结构”。
知道这个之后,你在碰到“统计输入向量中 1 的个数”这类题目时,思路就清楚了:写一个循环,把为 1 的位逐个加总。综合时会生成一串加法器的并联结构。这个认知对你理解硬件资源的消耗非常有帮助。
再补一个具体建议:always块中如果要做累加,注意被累加变量有没有被正确赋初值。组合逻辑块里,累加变量很容易因为循环展开而出现“多重驱动”的警告——多个地方对同一个变量赋值,这在硬件上就是冲突。遇到这种问题,别急着改代码,先在纸上把循环展开后的硬件图画出来,问题就容易定位了。