组合逻辑电路这个主题,Part1我们聊完了最基础的与或非门、布尔代数、卡诺图化简这些底子,那Part2就得上点真家伙了——直接上手用Verilog把组合逻辑写出来,并且写得好、写得稳。说实话,很多同学卡住的地方不是不会写逻辑,而是不知道怎么写才是规范的、可综合的、不给自己埋坑的,所以这篇文章重点解决两件事:一是常用组合逻辑模块的Verilog实现套路,二是那些在综合和仿真里才会炸出来的问题,比如竞争冒险、意外Latch、赋值方式用错等等。适合正在学数字电路和Verilog的初学者,也适合做FPGA开发但组合逻辑部分经常踩坑的工程师,看完可以直接把代码拿过去用。
1. 组合逻辑设计思维:先理顺三种建模方式
1.1 三种建模方式怎么选
Verilog描述组合逻辑,核心就三把刀:连续赋值assign、always块加阻塞赋值、generate结构。用对了是趁手工具,用错了就是给自己添乱,所以先把这个选型思路掰扯清楚。
assign适合描述简单的逻辑表达式,比如一个加法器、一个比较器、一个多路选择器的单bit判断。它的特点是并行、连续驱动,也就是说只要右边表达式里任何一个信号变化,左边就会立刻重新计算。我经常把assign比作水管,信号流过去,输出端跟着变,没有时序概念,也没有"先执行哪条语句"的问题。
always @(*)加上阻塞赋值=,适合描述带有分支判断的组合逻辑,比如多路选择器、译码器、优先级编码器。它的本质是用C语言式的if-else或case来描述真值表,综合工具会帮你翻译成门级电路。有人问:"那既然always能写,assign也能写,到底用哪个?"我的建议很简单:能一行assign写完的,不要非用always;一旦逻辑里出现多条件分支、优先级判断、多输出,才用always。道理不复杂,assign写多条件分支很别扭,读起来费劲,而always让条件和输出一目了然。
generate则用于参数化的结构复制,比如多位全加器的级联、参数化多路选择器、多个相同模块的例化。它的核心价值是避免手写重复代码。举个例子,例化8个相同的模块,手写8遍不仅容易抄错,改起来也是灾难,用generate循环一次搞定。
1.2 真值表到代码的转换套路
很多新手拿到一个功能需求,第一反应是直接开写代码,这是最常见的错误。我自己的习惯是先在纸上把真值表列出来,哪怕只有两三个输入,也老老实实写一遍。真值表是组合逻辑的"需求文档",代码只是它的翻译,跳过真值表直接写代码,相当于跳过高德地图直接开车,迟早翻车。
举个简单例子,设计一个3-8译码器。真值表就是一个输入编码对应一个输出拉高:000对应out=8'b0000_0001,001对应out=8'b0000_0010,以此类推。看到这种输入输出一一对应的结构,马上想到case语句最合适,因为它的语法本身就是"根据输入值匹配输出值",和真值表的形态天然对齐。
另一个经验:用FPGA做设计时,不必过度担心门级优化。很多人喜欢手工用卡诺图化简逻辑、减少门数,这在分立元件时代是必杀技,但FPGA综合工具会自动做逻辑优化和映射,你把真值表写完整、功能描述正确,工具会帮你搞定门级问题。真正需要关心的是:分支是否完整、位宽是否匹配、会不会综合出多余Latch。这就像你请了厨师,不用自己切菜,但得把菜单写清楚。
2. 常用模块的Verilog实现与优化思路
2.1 多路选择器:三种写法各有脾气
多路选择器是组合逻辑里最基础也最常用的模块。先看最简单的2选1,if-else版本:
module mux2_1 ( input wire a, input wire b, input wire sel, output reg y ); always @(*) begin if (sel) y = b; else y = a; end endmodule这段代码看起来很直白,但注意两点:第一,组合逻辑的always块里用阻塞赋值'=',这是硬性规定,后文会展开讲;第二,if-else隐含了优先级——if条件优先于else,所以多个if-else嵌套会综合成优先级结构,路径时延可能更长。如果你只想要一个纯粹的并行多路选择器,case才是更合适的描述方式。
用三目运算符可以一行搞定同样功能:
assign y = sel ? b : a;三目运算符适合做单bit或简单信号的二选一,可读性非常好。但一旦输入路数变多,三目嵌套会变成天书,这时候老老实实用case:
module mux4_1 ( input wire [1:0] sel, input wire [3:0] d, output reg y ); always @(*) begin case (sel) 2'b00: y = d[0]; 2'b01: y = d[1]; 2'b10: y = d[2]; 2'b11: y = d[3]; default: y = 1'b0; endcase end endmodule这里给个实操心得:case语句的default千万别省。虽然这个例子看起来所有分支都覆盖了,但综合工具对"未覆盖分支"的处理是保守的,一旦它认为某些输入值没有走任何分支,就可能推断出Latch,Latch在组合逻辑里是定时炸弹。与其让工具猜,不如用default明确告诉它"其他情况输出0"。
2.2 译码器和编码器:优先级是隐藏主角
译码器是case语句的经典应用,代码很好写:
module decoder3_8 ( input wire [2:0] in, output reg [7:0] out ); always @(*) begin case (in) 3'b000: out = 8'b0000_0001; 3'b001: out = 8'b0000_0010; 3'b010: out = 8'b0000_0100; 3'b011: out = 8'b0000_1000; 3'b100: out = 8'b0001_0000; 3'b101: out = 8'b0010_0000; 3'b110: out = 8'b0100_0000; 3'b111: out = 8'b1000_0000; default: out = 8'b0000_0000; endcase end endmodule还可以用移位操作一行实现同样的功能:assign out = 8'b1 << in;。这个写法输出直接是one-hot编码,综合后资源也不差。这里有个小坑:如果输入值大于7,左移结果会超出位宽被截断,所以最好确认输入范围或通过default兜底。
编码器稍微复杂一点,尤其是优先级编码器。什么叫优先级?就是多个输入同时有效,只承认最高的那个。这在实际系统里太常见了,比如多个中断源同时来了,系统得先响应优先级高的。Verilog里描述优先级最方便的是casez:
module priority_encoder ( input wire [7:0] in, output reg [2:0] out ); always @(*) begin casez (in) 8'b1???????: out = 3'd7; 8'b01??????: out = 3'd6; 8'b001?????: out = 3'd5; 8'b0001????: out = 3'd4; 8'b00001???: out = 3'd3; 8'b000001??: out = 3'd2; 8'b0000001?: out = 3'd1; 8'b00000001: out = 3'd0; default: out = 3'd0; endcase end endmodulecasez把?当作任意匹配,于是从上到下天然形成优先级链。我在实际项目里经常借鉴这个思路,但说句实话,当优先级条件变得很复杂时,casez的可读性会下降,这时候改用多个if-else反而更直观,虽然综合结果都是优先级结构,但代码的维护性更重要。
2.3 加法器:从半加器到超前进位
加法器是组合逻辑里的"硬骨头",也是面试和笔试的高频考点。先看半加器,它只考虑两个加数本身,不考虑低位进位:
module half_adder ( input wire a, input wire b, output wire sum, output wire cout ); assign sum = a ^ b; assign cout = a & b; endmodule全加器多了个进位输入cin:
module full_adder ( input wire a, input wire b, input wire cin, output wire sum, output wire cout ); assign sum = a ^ b ^ cin; assign cout = (a & b) | (a & cin) | (b & cin); endmodule全加器的进位逻辑可以这么理解:三个输入里只要有两个或以上的1,就产生进位。a&b是两个加数本身都是1,a&cin和b&cin是其中一个加数和进位同时为1,三种情况一或就是答案。
多位加法器最朴素的做法是行波进位加法器:把全加器一级级串起来,每一级的进位输出作为下一级的进位输入。用generate实现:
module rca #( parameter WIDTH = 4 )( input wire [WIDTH-1:0] a, input wire [WIDTH-1:0] b, input wire cin, output wire [WIDTH-1:0] sum, output wire cout ); wire [WIDTH:0] carry; assign carry[0] = cin; genvar i; generate for (i = 0; i < WIDTH; i = i + 1) begin : gen_adder full_adder u_full_adder ( .a (a[i]), .b (b[i]), .cin (carry[i]), .sum (sum[i]), .cout (carry[i+1]) ); end endgenerate assign cout = carry[WIDTH]; endmodulegenerate循环里每个例化模块的名字叫gen_adder[0]、gen_adder[1]这样,方便仿真和综合时报错定位。行波进位的最大问题是延迟:进位要一级级传下去,N位加法器进位链延迟近似N倍单级延迟,位数一高就成瓶颈了。
解决这个问题就要上超前进位加法器。核心思想是提前把进位算出来,而不是等低位算完。定义两个信号:g = a & b(generate,本级必然产生进位)和p = a | b(propagate,本级传播进位),那么进位公式可以写成:
c1 = g0 | (p0 & c0) c2 = g1 | (p1 & g0) | (p1 & p0 & c0)每一位的进位只取决于输入和初始进位c0,不再依赖前一级的进位计算结果,所以理论上进位计算只要两级门延迟。代价是逻辑表达式随位数指数膨胀,所以工程上不会一下子算32位,而是4位一组做超前进位,组间再用行波或再套一层超前进位。这也是为什么很多CPU设计里加法器是分层结构的。面试时能把这几层讲清楚,比背代码强得多。
3. 竞争与冒险:组合逻辑最隐蔽的坑
3.1 为什么会出现毛刺
竞争冒险大概是组合逻辑里最让人头疼的问题,因为它在仿真里能看到、在真实电路里会冒泡,但功能仿真结果可能表现不出来。
先说机理。现实中的门电路有延迟,同一个信号经过不同路径到达同一个逻辑门的时间可能不一样,这种现象叫竞争,而产生的短暂错误脉冲叫冒险(毛刺)。举个最经典的例子:
module glitch ( input wire a, output wire f ); wire not_a; assign not_a = ~a; assign f = a & not_a; // 理论上恒为0 endmodule从布尔代数看,a & ~a永远等于0。但真实电路里,a直接到达与门需要经过一个门的延迟,而not_a却要经过反相器再加与门,两条路径延迟不同。当a从0跳变为1的瞬间,not_a可能还没来得及变为0,此时与门的两个输入短时间里同时为1,输出就冒出一个高电平毛刺。
再如f = a & b,如果a从1变成0、b从0变成1,这两个信号在理想情况下是同时变化的,但真实电路里它们的延迟不同,可能有一小段窗口内两个输入都为1,输出就产生毛刺。组合逻辑层级越多、扇出越大,毛刺问题越严重。
3.2 四种消除毛刺的方案与选型
消除毛刺不能靠运气,要分场景选方案。我列个对比表:
| 方案 | 原理 | 适用场景 | 注意事项 |
|---|---|---|---|
| 卡诺图冗余项 | 增加冗余与项,让输出在信号变化窗口内保持稳定 | 门级手工设计、中低速电路 | 增加逻辑面积,只在关键路径用 |
| 输出端加D触发器 | 组合逻辑输出在时钟边沿采样,毛刺落在采样窗口外 | 同步数字系统最常用 | 适合的是同步设计,不是异步信号 |
| 输出滤波电容 | 利用RC低通特性吸收毛刺 | 板级模拟电路、低速信号 | 会拖慢信号边沿,高频场景不可用 |
| 选通脉冲 | 在输出稳定后再用脉冲"读取"结果 | 总线读写、存储器接口 | 需要额外时序控制 |
我几乎只在同步设计里用D触发器采样这一招。原因很简单:在FPGA里,前面那些门级优化手段都不太现实,而D触发器每个Slice都有,不用白不用。你把组合逻辑的输出打一拍,只要毛刺宽度小于时钟周期,采样到的就是稳定值。这本质上是让时序逻辑来容忍组合逻辑的不确定性,也是"同步设计"思想的精髓。
3.3 仿真中如何定位竞争冒险
功能仿真里定位毛刺,有个小技巧:把仿真的时间精度调高。比如用timescale 1ns/1ps,如果毛刺宽度在皮秒级,精度不够的仿真根本看不到。另外可以用$monitor实时打印输出变化,一旦输出出现理论真值表里不存在的状态,基本就能确定存在竞争冒险。
我自己的调试习惯是先看波形里有没有宽度极窄的脉冲,尤其是多个输入同时翻转的时刻。如果毛刺反复出现,再去回溯RTL代码,看哪些信号经过了不对称的路径。说句实在话,纯粹的组合逻辑毛刺在FPGA里一般不会导致系统崩溃,因为最终都会通过寄存器采样,真正致命的往往是毛刺被当成时钟或异步复位,所以下面这点要特别记住:不要把组合逻辑的输出直接接到时钟引脚或异步复位引脚,这类信号必须经过同步处理。
4. 组合逻辑的Verilog编码规范:守住红线
4.1 阻塞赋值与非阻塞:一条铁律
这个知识点可以说被讲烂了,但每次项目评审我还能看到有人栽跟头。规则就一条:时序逻辑用非阻塞赋值<=,组合逻辑用阻塞赋值=,两者绝不混用。
为什么这么规定?关键在于仿真器的调度机制。非阻塞赋值不是立刻生效的,它先把值放到"待更新队列",等当前时间步所有赋值语句都执行完再统一更新。如果组合逻辑里用了非阻塞赋值,你在语句块内读到的信号可能是旧值,仿真结果和你预想的真值表就对不上。而阻塞赋值是立即生效的,从上到下按顺序执行,正好符合组合逻辑的"输入变、输出跟着变"的特性。
举个反面例子,如果组合逻辑里两个信号互相依赖,用非阻塞赋值会导致仿真出现"同一时刻读不到更新值"的怪象,而在真实电路里组合逻辑本来是可以瞬间稳定的。这种仿真和实现行为不一致,才是这个规则最坑人的地方。顺便说一句,always块里给reg类型赋值,不代表综合出来一定是触发器,组合逻辑的always块综合结果是一堆门电路,只有时序逻辑的always块才会综合成寄存器。
4.2 完整分支与Latch的纠缠
Latch(锁存器)在组合逻辑设计里基本是个贬义词,但很多初学者不知道它的来源。我总结一下:只要always块里存在"某些输入条件下信号没有被赋值"的情况,综合工具就可能推断出Latch。
最常见的有三类写法问题:
- if语句缺少else分支,条件为假时保持原值,这就是存储器行为;
- case语句缺少default,未覆盖分支保持原值,同理;
- 在always块中对信号进行自己到自己的赋值,比如
y = y;,更是明摆着要Latch。
确保Latch不出现的做法只有一套组合拳:if必须配else,case必须配default,组合逻辑里别给自己赋值。我见过太多人写完代码综合完,莫名其妙面积大了一圈,打开网表一看全是Latch,根源就在这几行。用了default和else还有一层好处:综合工具能明确"未定义状态"的输出值,路径优化更干净。
4.3 参数化与generate:让代码可复用
参数化设计是组合逻辑进阶的必修课。核心是用parameter定义位宽、用generate批量例化,写一次代码、多处复用。
举个例子,把上面的2选1多路选择器改成参数化位宽版本:
module mux_n #( parameter N = 8 )( input wire sel, input wire [N-1:0] a, input wire [N-1:0] b, output wire [N-1:0] y ); assign y = sel ? b : a; endmodule例化时只需要传参:mux_n #(.N(16)) u_mux (...),同一个模块既能当2选1用,也能当16位总线选择器用。再配合generate,就能做任意位宽的加法器、移位器、交叉开关。
我自己常用的参数化场景还有localparam:它和parameter的区别是localparam不能从外部传参,适合模块内部定义常量。比如定义一个状态编码表、一个延迟参数,用localparam避免模块接口被无关参数污染。参数化不仅是省代码量,更是提高可维护性的关键,想改位宽的时候只动一个参数,而不是到处改constant,这种幸福感用过都懂。
5. 常见问题排查与调试实录
5.1 仿真波形不对从哪查起
仿真波形和预期不符,别急着怀疑工具,先从这几个方向排查。第一,敏感列表。如果你在always块里手写了always @(a or b),而实际逻辑里还用了信号c,那c变化时输出完全不更新,波形自然不对。解决办法是全部用always @(*),让工具自动推导敏感列表,这就是为什么我一直推荐@(*)而不是手写列表。
第二,未知态X。信号如果没初始化、或者有多个驱动源,仿真会出现红色的X态,之后所有依赖它的信号全部变X,看起来就是一片乱码。排查方法是全局搜索有没有未复位寄存器、有没有信号被多模块同时驱动(多驱动是设计错误,综合会直接报错)。
第三,位宽不匹配。比如一个4bit信号和一个8bit信号做运算,仿真器会按最大位宽扩展,符号数的符号扩展偶尔会带来意外结果。排查时先确认所有信号的位宽和有无符号属性。
5.2 综合后资源暴增的元凶
组合逻辑代码综合后资源异常,我碰到过几种典型原因。最坑的是在组合逻辑里写了除法。除法在FPGA里是个昂贵的东西,非除数的除法综合工具一般会生成大量的逻辑单元,面积和延迟都爆炸。能用移位实现就用移位,比如除2就是右移1位、除4就是右移2位,稍微复杂点的定值除法可以用乘法加移位近似。
第二种是过长的if-else优先级链。前面讲过if-else天然是优先级结构,十几层嵌套会让关键路径拉得很长。解决办法是重新审视逻辑结构,能用case就用case,能把长链拆成多段就拆开。
第三类是case条件写得不完整导致Latch,这个第4章讲过了。每回综合完,第一件事就是看综合报告里的Latch数量,不为0就回头查分支完整性。这个习惯能帮你把80%的资源问题扼杀在摇篮里。
5.3 经验速查表
| 症状 | 常见原因 | 快速排查手段 |
|---|---|---|
| 仿真输出一直是X | 未初始化、多驱动 | 查复位、查Q驱动源 |
| 输出和真值表对不上 | 敏感列表漏信号 | 改用always @(*) |
| 综合出Latch | 缺少else或default | 检查分支完整性 |
| 面积异常大 | 除法、长if-else链 | 查综合报告关键路径 |
| 波形有毛刺 | 竞争冒险 | 提高仿真精度、加寄存器采样 |
| 时序违例 | 组合逻辑路径过长 | 在中间插入寄存器流水线 |
最后说一个我自己在项目中反复验证过的体会:组合逻辑设计的很多坑,都是在"仿真通过但上板失败"的时候才暴露的,因为仿真只验证功能,不验证时序细节。所以从一开始就要用规范写法约束自己——分支写全、赋值方式用对、敏感列表用@(*)、位宽对齐、参数化复用。这些习惯看着琐碎,但每一个都是在给后面的自己排雷。另外,仿真里看到毛刺别急着改代码,先判断这个毛刺有没有可能被采样到,如果后续都经过寄存器,那它就只是"纸老虎";真正要警惕的是那些绕过寄存器、直接参与控制逻辑的毛刺信号,那种才是真会炸的雷。