☰
Verilog组合逻辑电路设计实战:建模方法、编码规范与竞争冒险
2026/10/17 5:07:16 网站建设 项目流程

组合逻辑电路这个主题,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 endmodule

casez把?当作任意匹配,于是从上到下天然形成优先级链。我在实际项目里经常借鉴这个思路,但说句实话,当优先级条件变得很复杂时,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]; endmodule

generate循环里每个例化模块的名字叫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链查综合报告关键路径
波形有毛刺竞争冒险提高仿真精度、加寄存器采样
时序违例组合逻辑路径过长在中间插入寄存器流水线

最后说一个我自己在项目中反复验证过的体会:组合逻辑设计的很多坑,都是在"仿真通过但上板失败"的时候才暴露的,因为仿真只验证功能,不验证时序细节。所以从一开始就要用规范写法约束自己——分支写全、赋值方式用对、敏感列表用@(*)、位宽对齐、参数化复用。这些习惯看着琐碎,但每一个都是在给后面的自己排雷。另外,仿真里看到毛刺别急着改代码,先判断这个毛刺有没有可能被采样到,如果后续都经过寄存器,那它就只是"纸老虎";真正要警惕的是那些绕过寄存器、直接参与控制逻辑的毛刺信号,那种才是真会炸的雷。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询