
1. 章节定位与核心价值翻到HDLBits的Procedures这一节很多初学者会误以为这只是“又讲了一遍always块”。实际练下来你会发现这一章是整个Verilog从“画电路图”过渡到“写行为描述”的分水岭。前面那些assign连续赋值、运算符、组合逻辑模块本质上还是在用“数据流”的视角描述硬件到了Procedures你才开始真正用“过程语句”去描述硬件在时间轴上的行为。这一章的核心对象有三个always块、initial块以及阻塞赋值与非阻塞赋值。三者之间相互关联但又各有各的坑。我在带人的时候经常说如果这一章你能做到不看答案把HDLBits的题目全过一遍并且能说清楚“为什么这里用always (*)而不是assign”、“为什么这里用非阻塞赋值而不是阻塞赋值”那你的Verilog基本算是入门了。我最初刷这一章的时候最直观的感受是题量不大但每一道题都像在“抠细节”。有些题你一眼就能写出答案但提交之后会发现自己忽略了变量声明的位置、漏了敏感列表里的信号或者把阻塞和非阻塞混用导致仿真结果不对。这一章的价值恰恰不在于题目的难度而在于它强迫你建立起“硬件行为描述”的思维习惯——这是从“写代码”到“写电路”的关键一步。适合谁来刷这一章我觉得两类人最需要一是刚学完数字电路、想通过Verilog把理论落到实际的人二是已经会用assign写组合逻辑、但对always和时序逻辑还比较模糊的人。如果你前面连续赋值部分已经比较熟练这一章大概是你在HDLBits上遇到的第一个真正的思维转换点。2. 整体设计思路拆解2.1 为什么HDLBits把这一章命名为“Procedures”“Procedure”直译是“过程”但在硬件描述语言里它不是软件里的“函数调用过程”而是指一种带有执行顺序语义的代码块。Verilog里最常见的过程块就是always块和initial块。你写在assign语句里的表达式理论上每个信号变化都会触发重新计算没有“先执行谁后执行谁”的问题但过程块不一样过程块里的语句是顺序执行的——就像C语言里一个函数体内的语句一样。这里就引出了硬件描述与软件描述的一个核心差异软件里顺序执行理所当然硬件里顺序执行却是“人为指定”的。实际综合出来的电路并不是真的在“顺序执行”而是被综合工具映射成了组合逻辑或者时序逻辑。所以你必须搞清楚你写的顺序语句最终会被综合成什么结构而不是仅仅追求“仿真结果对”。HDLBits在Procedures这一章恰恰就是让你通过一个个小题目去体会这种“过程描述”与“硬件结构”之间的对应关系。比如always (*)块里的语句不论你写的顺序如何综合出来的都是一堆组合逻辑门最终效果等价于一个等式但如果你在always (posedge clk)块里写非阻塞赋值综合出来的就是触发器。这种“代码形式”与“硬件本质”的对应就是这一章设计思路的底层逻辑。2.2 从连续赋值到过程赋值的思维转换在Procedures之前你大概率已经习惯了assign out a b;这种写法。连续赋值的核心特点是只要右边的信号变化左边的信号就跟着变没有“时刻”的概念。而过程赋值不同它必须依附于某个过程块并且在过程块内部遵循一定的执行规则。具体来说过程赋值有两种阻塞赋值和非阻塞赋值。阻塞赋值可以理解为“先算完右边再赋给左边然后才执行下一条语句”非阻塞赋值则是“先计算右边的所有表达式然后统一更新左边的变量”。这个差别在组合逻辑里可能不明显但在时序逻辑里会导致截然不同的仿真结果。我在实际工程里见过太多新手死在这一点上。举个例子你想写一个简单的移位寄存器用非阻塞赋值always (posedge clk) begin q d; end这没问题。但如果你改成阻塞赋值always (posedge clk) begin q d; end在只有一个触发器的情况下两者仿真结果似乎一样。可一旦多级串联always (posedge clk) begin q1 d; q2 q1; end阻塞赋值会让q2直接变成d而不是上一拍的q1。这个结果和真实硬件行为不符因为硬件上两个触发器是并行更新的新q1不应该影响同一个时钟沿上的q2。用非阻塞赋值always (posedge clk) begin q1 d; q2 q1; end仿真结果就是q2得到旧q1这才符合硬件行为。这个例子理解透了Procedures这一章你就通了50%。剩下的就是各种语法细节和题目陷阱。3. 核心语法与实操要点3.1always块的敏感列表与组合逻辑建模always块的基本语法是always (sensitivity_list) begin // 过程赋值语句 end敏感列表决定了这个块什么时候被触发执行。有两种最常见的写法always (*)对所有右侧表达式里出现的信号敏感也叫做“自动敏感列表”。推荐用于组合逻辑。always (posedge clk)只在时钟上升沿触发一般用于时序逻辑。在HDLBits的Procedures练习里第一类题目就是要求你用always块代替assign实现组合逻辑。比如实现一个三输入多数表决器你可以写module top_module( input a,b,c, output reg out ); always (*) begin if ((a b) || (b c) || (a c)) out 1b1; else out 1b0; end endmodule注意这里output必须声明为reg类型因为在过程块里被赋值的变量必须是reg。这是一个非常容易忽略的细节。很多初学者在写assign时用wire类型到了always里还沿用同样的声明结果编译报错。实际上reg并不是“寄存器”的意思它仅仅表示“这是一个过程赋值的变量”。在组合逻辑的always块里reg类型的变量综合出来仍然是wire——不对更准确地说综合出来的是组合逻辑网络并不一定是寄存器。这个误解很常见。敏感列表还有一个坑如果你在always块里用了某种信号但没有把它列在敏感列表里仿真时这个信号变化就不会触发块重新执行结果可能与实际电路不符。HDLBits早期题目会让你显式写always (a or b)这种老式写法这时候就特别容易漏信号。我建议你直接养成写(*)的习惯除非你有特殊需求比如只对某个信号的上升沿敏感否则不要手动罗列敏感信号。3.2initial块的作用与仿真语义initial块在HDLBits的题目里出现频率不算高但它是过程块的重要组成。initial块只在仿真开始时执行一次常用于测试平台testbench里初始化信号。比如initial begin clk 0; reset 1; #10 reset 0; end需要注意的是initial块是不可综合的。也就是说你不能指望这个语法真的在芯片里生成一个上电后执行一次的硬件电路。它只存在于仿真世界。很多初学者在写可综合RTL时也带一个initial块这在某些FPGA工具里会引起警告甚至导致行为异常。设计模块里要避免使用initial把初始化工作交给复位逻辑。在HDLBits中有些题目会给你一个已经写好的initial块让你补全其中的语句或者是让你理解initial块里阻塞赋值的执行顺序。这种题考的是“仿真语义”的掌握程度并不要求你综合。所以刷题时该写initial就写不用有心理负担但心里要清楚这不是RTL的常规写法。3.3 阻塞赋值与非阻塞赋值的黄金法则网络上关于阻塞赋值和非阻塞赋值的讨论已经很多了但真正能一句话讲清楚的很少。我总结一个最常见的工程共识组合逻辑的always块里用阻塞赋值。时序逻辑的always块里用非阻塞赋值。为什么组合逻辑用阻塞赋值因为组合逻辑的always块本质上是描述一个布尔表达式赋值语句的执行顺序不影响最终结果。比如always (*) begin out a; out out b; end等价于out a b。如果用非阻塞赋值always (*) begin out a; out out b; end这个写法仿真时会有问题因为非阻塞赋值的计算和更新是分开的第二条语句里的out还是旧值综合出来可能是一个奇怪的锁存器。所以在组合逻辑块里禁止用非阻塞赋值这是个铁律。为什么时序逻辑用非阻塞赋值前面移位寄存器的例子已经说明非阻塞赋值能正确描述寄存器在同一时钟沿并行更新的行为。如果你在时序块里混用阻塞赋值仿真结果很可能与真实硬件不符而且这种bug非常隐蔽——它能在仿真中通过大部分测试却在实际板子上表现出间歇性错误。我后来在看代码规范时发现有些公司直接把“时序逻辑只用非阻塞赋值”写进编码规范违反者代码审查都过不了。这不是教条而是无数血泪教训换来的规则。3.4case语句与if语句的使用陷阱在Procedures这一章里还有一类题目与case语句相关。case语句常在always块中用于多路选择逻辑比如实现一个七段数码管译码器或者ALU。基本写法是always (*) begin case (sel) 2b00: out a; 2b01: out b; 2b10: out c; 2b11: out d; default: out 1b0; endcase end这里有两个重点。第一case的所有分支应该写全否则综合时可能产生锁存器。什么叫分支不全就是某些输入组合下out没有被赋值。比如上面如果去掉2b11分支且不写default那么当sel2b11时always块里没有任何语句给out赋值这意味着out要保持原来的值——综合工具就会推断出一个锁存器。锁存器在绝大多数设计中是不希望出现的因为它会导致时序分析困难、毛刺风险增加。解决办法是要么把所有可能的分支都列全要么加上default分支要么在always块开头对out赋默认值。第二if语句也有类似问题。if语句如果没有配套的else在组合逻辑块里也会产生锁存器always (*) begin if (en) out a; end当en0时out没有被赋值就会生成锁存器。正确写法是always (*) begin if (en) out a; else out b; end或者赋默认值always (*) begin out b; if (en) out a; end这种“先赋默认值再覆盖”的模式在组合逻辑里非常常见也体现了过程语句顺序执行的特点。理解这一点你才算真正理解了always块的编码风格。4. 实操过程与核心环节实现4.1 从需求到代码一个典型的组合逻辑always块实现流程HDLBits的Procedures题目不会给你完整工程但你需要具备把题目翻译成代码的能力。以一道典型题目为例用always块实现一个优先编码器。优先编码器的功能是输入一个多位信号输出最低位1所在的位置。假设输入是4位in[3:0]输出是2位pos含义是“最低有效1的索引”。若in4b0100则pos2若没有1则输出一个默认值题目会规定比如pos0。我的第一反应是直接用casezmodule top_module ( input [3:0] in, output reg [1:0] pos ); always (*) begin casez (in) 4b???1: pos 2d0; 4b??10: pos 2d1; 4b?100: pos 2d2; 4b1000: pos 2d3; default: pos 2d0; endcase end endmodulecasez允许把?当作“不关心”这样从最低位开始逐级判断逻辑一目了然。注意default分支必不可缺否则当in4b0000时pos保持旧值会生成锁存器。这种题练习的价值在哪里它让你体会到case语句在“多分支条件判断”上比一串if-else更清晰。同时你还得注意casez里的分支顺序上面的顺序是从???1到1000优先匹配最低位为1的情况。如果把1000写在最前面那么in4b1001时也会匹配1000输出错误。所以分支的顺序隐含了优先级这在硬件里是真实的电路综合出来的优先级编码器本来就有优先级。这个例子做完你可以再试一遍用if-else实现同样的功能always (*) begin if (in[0]) pos 2d0; else if (in[1]) pos 2d1; else if (in[2]) pos 2d2; else if (in[3]) pos 2d3; else pos 2d0; end两种写法综合出来的电路在功能上等价但代码风格不同。casez更紧凑if-else更直观。工程上怎么选取决于个人习惯和团队规范。我个人的建议是只要条件不是特别复杂if-else更安全因为casez的“不关心”语义有时候会让阅读者困惑特别是当?使用不当时容易写出功能与意图不符的代码。但刷题时两种都要掌握因为HDLBits两种都会考。4.2 时序逻辑的always块计数器与移位寄存器Procedures这章里也有直接与时序逻辑相关的题目比如用always (posedge clk)实现一个计数器。这类题要求你写出“时钟沿触发的行为模型”。一个典型的4位递增计数器module top_module ( input clk, input reset, output reg [3:0] q ); always (posedge clk) begin if (reset) q 4b0; else q q 1b1; end endmodule这里有几个要点值得展开。第一q在always块内被赋值所以声明为reg。第二用非阻塞赋值。第三复位逻辑使用同步复位即只在时钟上升沿检查reset。如果你想要异步复位就得把reset加入到敏感列表always (posedge clk or posedge reset) begin if (reset) q 4b0; else q q 1b1; end异步复位和同步复位在硬件实现上完全不同。异步复位的触发器有专门的复位引脚复位信号不依赖时钟就能生效同步复位则是把复位当作一个数据输入通过时钟沿采样。这个区别在第一章可能不会考但Procedures的某些题目会要求你根据注释写出不同的复位风格。在实际工程中我见过一个非常普遍的问题有些新手把复位信号放进always块却忘了把它加进敏感列表导致仿真时复位不生效。比如你想写异步复位却写成always (posedge clk) begin if (reset) q 4b0; else q q 1b1; end这样reset变化并不会立即影响q只有时钟上升沿到来时才会检查reset——这其实变成了同步复位。如果你本意是同步复位那没问题如果本意是异步复位这就错了。HDLBits的题目有时候会故意在注释里说明“异步复位”这时候你就要敏感列表里加上or posedge reset。4.3 组合逻辑与时序逻辑混合一个完整的例子为了更贴近真实场景我们不妨把组合和时序结合起来做一个简单的题目模型每个时钟周期读入一个4位输入输出其中1的个数并且把计数结果锁存到输出寄存器。这其实是一个“组合逻辑计算 时序逻辑打拍”的结构在HDLBits里也会出现。我惯用的写法是分成两个always块一个组合块计算“下一拍的值”一个时序块打拍。或者用一个时序块在内部用阻塞赋值表达组合中间量、用非阻塞赋值表达寄存器更新。不过新手容易把两者混在一个块里导致错误。这里我展示一个清晰的两段式写法module top_module( input clk, input reset, input [3:0] d, output reg [2:0] cnt ); wire [2:0] next_cnt; assign next_cnt d[0] d[1] d[2] d[3]; always (posedge clk) begin if (reset) cnt 3b0; else cnt next_cnt; end endmodule这里用assign计算组合逻辑用always实现寄存器更新分工明确。但如果题目要求“必须在一个always块里完成”你也可以这样写always (posedge clk) begin if (reset) cnt 3b0; else cnt d[0] d[1] d[2] d[3]; end这个写法看起来更简洁但有一个隐患cnt的输入来源是组合逻辑而这个组合逻辑的计算结果直接在时钟沿被采样。只要组合逻辑的延迟不超过时钟周期功能没问题。不过在大型设计中我们更倾向于把组合逻辑单独写出来方便时序分析和调试。从工程角度来看我建议你尽早习惯“组合逻辑与寄存器分离”的编码风格。这不仅能让你更容易定位问题也让代码的可读性更高。4.4 HDLBits上实战建议如何高效刷完Procedures刷HDLBits的Procedures我建议你不要追求“一遍过”而是每一题都做三件事第一先看题目给出的模块声明。output reg还是output wire决定了你能否直接赋值。很多提交错误其实不是逻辑错而是端口类型不对。第二模拟仿真一遍。你可以先用自己习惯的仿真工具跑一下波形也可以直接在HDLBits的在线仿真里看结果。重点是观察阻塞和非阻塞赋值的仿真差异。比如你可以故意把一个时序逻辑中的非阻塞改成阻塞看看波形变成了什么样。这种“破坏性实验”比做十道题都更有收获。第三做完以后尝试用不同的写法重新实现一遍。比如明明可以用case你偏要用if-else明明可以写(*)你偏要写(a or b)并补全敏感列表。这样做的目的是让你感受不同写法的边界条件。HDLBits的题目是即时评测每道题都有预设的测试向量覆盖边界情况。如果你第一次提交失败不要急着看别人的答案。先检查你的敏感列表是否完整检查是否有分支遗漏导致锁存器检查时序块里是不是不小心用了阻塞赋值。这三个问题占了Procedures章节错误的大头。5. 常见问题与排查技巧实录5.1 “变量声明为reg但还是报错”是怎么回事这是Procedures章节最常见的报错之一。报错信息大致是“variable out is declared as reg but assigned with assign”或者反之。根本原因是assign语句的左侧只能是wire类型因为它是连续驱动而过程赋值always/initial块内的左侧只能是reg类型或者整数等其他变量类型。比如你写了module top_module( input a, b, output out ); always (*) begin out a b; end endmodule这里output out默认是wire类型但在always块内赋值编译器会报错。正确做法是output reg out或者output out reg out;两者等价。这个坑几乎每个新手都会踩但理解了以后就再也不会犯了。5.2 仿真波形正确但HDLBits评测失败锁存器问题有些题你可能在本地仿真时看到波形符合预期但HDLBits的测试向量判定错误。最常见的原因就是不小心生成了锁存器。举个例子你写了一个四选一多路器always (*) begin case (sel) 2b00: out a; 2b01: out b; 2b10: out c; // 漏掉了 2b11 endcase end当sel2b11时out保持原值。在真实电路中这个“保持原值”需要锁存器才能实现。HDLBits题目要求你实现的是一个多路器测试向量可能会覆盖sel2b11预期输出是某个值比如d。而你没有给out赋值所以仿真工具会保持上一次的值与预期不符。解决方式很简单要么补上2b11: out d;要么加default: out 1b0;要么在case之前先给out一个默认值。三种方式都行我比较推荐最后一种因为在大规模组合逻辑里先赋默认值再处理特殊情况代码更清晰always (*) begin out 1b0; case (sel) 2b00: out a; 2b01: out b; 2b10: out c; 2b11: out d; endcase end这样即使漏掉某个分支也有默认值兜底不会产生锁存器。5.3 非阻塞赋值在组合逻辑块里的诡异行为有时候你会看到有人在组合逻辑的always块里用然后仿真波形出现“延迟一拍”的效果但综合出来可能完全不对。这是HDLBits中比较容易踩的隐藏坑。比如always (*) begin out a b; end这个写法在仿真中会触发“非阻塞赋值更新延迟”的语义out不会立即变化而是等到当前时间步结束才更新。如果下游逻辑在同一时间步内读取out读到的是旧值。这会导致功能错误。为什么有人会这样写可能是因为复制了时序逻辑的模板忘记把改成。所以你在刷题时一定要在写完代码后检查一遍赋值符号。一个简单的方法是组合逻辑块内查找时序逻辑块内查找除了中间变量。虽然不是绝对但大多数情况都能帮你发现问题。5.4 敏感列表不完整导致功能异常前面已经提过但这里再强调一下。在HDLBits的早期题目里可能故意要求你使用always (a or b)这种老式敏感列表。如果你漏掉一个输入信号功能就错了。比如要实现out a b c却只写了always (a or b)那么当c变化时块不会重新执行out保持旧值。我建议你一律使用(*)这样工具会自动推断敏感列表不容易漏。但在考试或面试中如果遇到让你手动写敏感列表的题务必把块内引用的所有输入信号都列全。另外别忘了如果块内引用了某个信号它既出现在右侧表达式也出现在条件里这个信号也要列上。比如always (*) begin if (en) out in; else out 1b0; end这里的en和in都是块内引用的输入写(*)最稳妥。5.5 阻塞赋值在时序块里导致的仿真与综合不一致这个问题前面已经讲过去了但值得放在排查技巧里再说一遍。比如你要写一个脉冲展宽电路在时钟上升沿把输入d打一拍再和原值相或。如果写成always (posedge clk) begin q1 d; q2 q1; out q1 | q2; end这个写法在仿真时out会在同一拍变成d | d也就是d相当于没展宽。如果改成非阻塞always (posedge clk) begin q1 d; q2 q1; end assign out q1 | q2;那么q2是上一拍的q1out就等于d与旧d的或实现了脉冲展宽一个周期。这就是阻塞与非阻塞对功能的影响。在排查这类问题时我建议你画一个简单的时序图把时钟沿对齐标出每个变量在沿前后的值。很多时候看着波形想不明白但在纸上画一画就清楚了。6. 从刷题到工程Procedures背后的设计思想6.1 用“硬件思维”而不是“软件思维”写Verilog很多从C语言转向Verilog的人最容易犯的毛病是把always块当成“函数”把变量当成“内存单元”把if-else当成“分支跳转”。但实际上always块描述的是硬件的行为而不是处理器的指令流。一旦你用软件思维去写RTL很容易写出“仿真对但综合错”的代码。举个例子软件里你可以这么写if (a b) max a; else max b;在Verilog里always (*) begin if (a b) max a; else max b; end看起来差不多但硬件实现上综合工具会把if-else转换成一个多路选择器用比较器的输出来选择a或b。这里的“分支”不是动态跳转而是并行电路的选择。理解这一点你才能理解为什么if语句缺少else会生成锁存器——因为硬件必须为“条件不成立”的情况准备一个保持路径。6.2 为什么“过程块里的顺序”不等于“硬件里的顺序”过程块里的语句是顺序执行的但综合出来的硬件是并行计算的。这个矛盾的背后是Verilog作为硬件描述语言的一种妥协你用一种类似软件的方式来描述行为综合工具再把它转换成并行电路。以这段代码为例always (*) begin t a b; out t | c; end仿真时先计算t再计算out有先后关系。但综合出来的电路是两个并行运算的与门和或门t只是一个中间节点的名字并不代表信号会先到t再到out。实际上信号从a、b传到t再传到out是有传播延迟的但代码顺序不决定延迟大小。综合工具通过逻辑综合和优化可能会把这段代码变成不同的门级结构。所以在写RTL时你关心的不是“语句顺序”而是“数据依赖关系”。只要保证逻辑功能正确语句顺序怎么安排对最终电路没有影响除非你写的是时序逻辑中的非阻塞赋值那是另一套语义。6.3 锁存器数字设计最需要警惕的“幽灵”Procedures这章的题目里很多陷阱都是围绕锁存器展开的。锁存器本身并不是完全不能用的器件——某些低功耗设计或者异步设计中故意使用锁存器是有意义的。但在同步逻辑设计中锁存器通常是不受欢迎的。为什么第一锁存器是电平敏感器件容易在时钟工作期间受到输入毛刺的影响第二锁存器的时序分析和约束比寄存器困难得多第三EDA工具对锁存器的优化支持有限容易引起设计收敛问题。所以除非你明确知道自己在做什么否则应避免在设计中生成意外的锁存器。HDLBits的Procedures练习可以说是让你通过“踩坑”来理解锁存器的产生条件。每当你发现一个组合逻辑的always块没有覆盖所有分支或者一个case语句缺少default就要立刻警觉这里是不是会产生锁存器这种敏感度是数字设计工程师的基本素养。6.4 关于编码规范的一些个人经验在刷完Procedures之后我建议你趁热打铁整理一份自己的Verilog编码规范。不需要多复杂但至少要包含几条组合逻辑的always块使用阻塞赋值时序逻辑的always块使用非阻塞赋值。组合逻辑块内先对输出赋默认值再写条件分支。case语句要写全分支最好有defaultif语句要有配套的else。敏感列表建议写(*)避免漏信号。一个always块只描述一种功能不要把组合逻辑和时序逻辑混在一起。信号命名要有意义尽量不要用a、b、cnt这种含义模糊的名字在HDLBits里为了简洁可以用但工程项目里要避免。这些规范看起来像是“条条框框”但每一条背后都有事故作为代价。我在实际项目中就见过因为组合逻辑没有赋默认值导致综合后多出一大堆锁存器时序收敛困难最后花了几天时间才定位到问题。如果当时规范执行到位这些时间完全可以省下来。7. 写在最后一些实操中的体会刷Procedures这一章我最深的体会是Verilog的语法其实很少但语法背后的“电路意识”需要反复训练才能建立。HDLBits厉害的地方在于它通过一道又一道看似基础的小题不断逼问你对赋值方式、敏感列表、分支完整性这些细节的理解。很多题你可能一次就写对了但如果你说不清楚“为什么这样写就不会出问题”那大概率还是没吃透。我个人建议你在刷完这章以后回头再做一遍并且这次把每道题都按“组合逻辑/时序逻辑”“阻塞/非阻塞”“有无锁存器风险”这三个维度标注一遍。这个过程会让你对代码风格产生一种直觉看到一个always块大脑会自动分析它的敏感列表、赋值方式、分支完整性。这种直觉是任何IDE都无法给你的。另外如果你手里有FPGA开发板不妨把Procedures里的某个电路比如计数器、优先编码器实际下载到板子上跑一跑用LED或者数码管观察输出。仿真通过不等于硬件正确只有真实硬件上的行为才能验证你对Verilog的理解是否到位。我当年就是在板子上调一个“仿真正确但实际不工作”的计数器才真正理解了同步复位和异步复位的区别。最后想说的是Procedures只是一道门槛后面还有更复杂的时序电路、有限状态机、存储器等内容。但只要能把这章的基础打牢后面的路会顺畅很多。希望这份笔记能帮你少踩一些我当年踩过的坑。