
很多朋友在刷 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) 2b00: q a; 2b01: q b; 2b10: q c; endcase endsel有 4 种取值当sel 2b11时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必须带defaultif必须带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 1b0; 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块中如果要做累加注意被累加变量有没有被正确赋初值。组合逻辑块里累加变量很容易因为循环展开而出现“多重驱动”的警告——多个地方对同一个变量赋值这在硬件上就是冲突。遇到这种问题别急着改代码先在纸上把循环展开后的硬件图画出来问题就容易定位了。