不少人学完组合逻辑的理论部分之后,会觉得“道理都懂,但编辑器一打开还是不知道第一行代码该怎么敲”。真值表会列、卡诺图能画、基本门电路也认识,问题往往卡在从“纸上电路”到“可综合Verilog”这一步上。上一篇Part1我们把组合逻辑的数学基础和门级建模铺开了,这一篇专门解决代码落地的问题:每个经典组合逻辑单元怎么写、为什么这样写、综合器看到代码后会给你生成什么,以及最容易让你仿真通过却上板翻车的几个细节。
适合读这篇文章的人有两类。一类是刚学完数字电路、正在用Verilog做实验的学生,另一类是已经能写一点时序逻辑、但组合逻辑代码风格还很随性的工程师。这篇里不会有太多故弄玄虚的术语,所有代码都可以直接拿去仿真和综合,但我会花不少篇幅解释代码背后的电路结构——搞懂这个,你才算真正会写组合逻辑。
1. 从真值表到代码:先想清楚你在写硬件还是写软件
1.1 组合逻辑和触发器逻辑的本质区别
很多刚接触Verilog的人最大的误区,是把always块当成“函数”在用。软件里的函数有明确的调用顺序和执行顺序,但硬件描述语言里的always块,综合出来是一堆逻辑门,代码里的顺序只决定你在文本里先写哪个门,不决定信号在时间上的先后。
组合逻辑的代码尤其如此。assign语句描述的是连续赋值,右侧任何一个输入变化,输出都会立刻重新计算。always @(*)块描述的是组合逻辑过程,等号左侧的信号在任何输入变化时被更新。整个过程没有任何“记忆”,没有时钟参与,也没有状态。你在代码里看到的所有变量,最终都会变成布线图上的一根根连线,而不是寄存器。
所以动笔写组合逻辑之前,最好先问自己一个问题:这个模块的输出,只依赖当前的输入,和之前的输入有没有关系?如果有,那它就不是纯组合逻辑,应该由时序逻辑来完成。如果答案是没有关系,恭喜你,你可以在组合逻辑的世界里放心地写代码。
1.2 由真值表到Verilog的标准三步法
我自己的习惯是严格按三步走:列真值表、确定布尔表达式、选描述方式。不要一上来就写代码,尤其是复杂一点的编码器和译码器,真值表列歪了,后面调试会浪费几倍时间。
举个例子,设计一个1位数值比较器,输入a和b,输出gt、eq、lt三个信号,分别表示大于、等于、小于。真值表很直观:
| a | b | gt | eq | lt |
|---|---|---|---|---|
| 0 | 0 | 0 | 1 | 0 |
| 0 | 1 | 0 | 0 | 1 |
| 1 | 0 | 1 | 0 | 0 |
| 1 | 1 | 0 | 1 | 0 |
化简后可以直接写出逻辑表达式:gt = a & ~b,eq = ~(a ^ b),lt = ~a & b。然后映射成Verilog非常简单:
module comparator_1bit ( input wire a, input wire b, output wire gt, output wire eq, output wire lt ); assign gt = a & ~b; assign eq = ~(a ^ b); assign lt = ~a & b; endmodule这段代码不需要always块,不需要case,三句assign就把电路描述完了。综合器会识别出这是一个纯组合逻辑的比较器,生成那个只要三四个门的电路。
但如果你处理的是多输入、多输出的复杂功能,比如编码器、译码器、ALU的运算单元,完全用逻辑表达式手推会非常痛苦。这时就应该用行为级描述,让综合工具替你做逻辑化简。这就是下一节要展开的内容。
2. 五种绕不开的组合逻辑单元:代码模板与设计思路
2.1 参数化多路选择器:case和if else谁先谁后
多路选择器是最经典的组合逻辑,工程里也最常用。我刚接触FPGA时写的第一个模块就是4选1多路选择器。写这个模块的关键点不在代码本身,而在理解case和if else在硬件语义上的差别。
if else描述的是一个带优先级的逻辑链,综合出来是一级接一级的MUX树;case描述的是并行选择,多条分支地位平等,综合出来是一个完整的MUX。比如下面这个参数化8位宽的4选1:
module mux4to1 #( parameter WIDTH = 8 ) ( input wire [1:0] sel, input wire [WIDTH-1:0] in0, input wire [WIDTH-1:0] in1, input wire [WIDTH-1:0] in2, input wire [WIDTH-1:0] in3, output reg [WIDTH-1:0] dout ); always @(*) begin case (sel) 2'b00: dout = in0; 2'b01: dout = in1; 2'b10: dout = in2; 2'b11: dout = in3; default: dout = in0; endcase end endmodule注意default分支不要省。虽然这里的sel是两位,理论上四种情况都覆盖了,但如果综合器检测到某些未定义的情况,它可能会帮你生成锁存器,后面第3节会详细说这个问题。
如果你想用if else实现同一个功能,逻辑上是等价的,但要注意优先级。if else天然适合优先编码器这类有明确优先级的功能,case则适合纯粹的并行选择。写出之前先想清楚你要的是“一票否决”还是“谁先到谁说话”,这决定了代码该用哪种结构。
2.2 优先编码器与casez通配符的使用
优先编码器在中断控制器、总线仲裁、按键扫描里经常出现。它的特点是多个请求同时有效时,只有优先级最高的那一个会被响应。这跟if else的语义天然匹配。
module priority_encoder #( parameter WIDTH = 8 ) ( input wire [WIDTH-1:0] req, output reg [2:0] code, output reg valid ); always @(*) begin code = 3'b000; valid = 1'b0; for (int i = 0; i < WIDTH; i = i + 1) begin if (req[i]) begin code = i[2:0]; valid = 1'b1; end end end endmodule这里code = i[2:0]的写法很多新手会看不懂,其实Verilog里循环变量i本身是integer类型,直接赋给reg向量时需要截断。你可能会想,为什么循环里每次都对code赋值,如果req[0]和req[3]同时为1,最后code到底是多少?答案是代码执行到最后一次满足条件时,code就等于那个最高的i。而这种“后写覆盖先写”的行为恰好实现了低位优先的逻辑——i越大,越靠后执行,优先级越高。
如果你想做高位优先,可以把循环从WIDTH-1往0递减。还有一个更简洁的写法是用casez:
always @(*) begin casez (req) 8'b1???????: code = 3'b111; 8'b01??????: code = 3'b110; // ... 其余分支类似 default: code = 3'b000; endcase endcasez把?当作任意匹配符,比较适合固定位宽、优先级明确的设计。但代码可读性一般,回头维护的人容易看糊涂,所以工程里我更喜欢用for循环那个版本。
2.3 七段译码器:完整case与非法输入的兜底
数码管驱动是FPGA入门必做的功能之一,本质是一个二进制到七段码的译码器。这里我直接给出共阴极接法、低电平点亮的完整映射表,也就是段码位0代表该段点亮,seg[6]对应g,seg[0]对应a。
module seg7 ( input wire [3:0] bcd, output reg [6:0] seg ); always @(*) begin case (bcd) 4'h0: seg = 7'b1000000; 4'h1: seg = 7'b1111001; 4'h2: seg = 7'b0100100; 4'h3: seg = 7'b0110000; 4'h4: seg = 7'b0011001; 4'h5: seg = 7'b0010010; 4'h6: seg = 7'b0000010; 4'h7: seg = 7'b1111000; 4'h8: seg = 7'b0000000; 4'h9: seg = 7'b0001000; default: seg = 7'b1111111; endcase end endmoduledefault分支让所有未定义的BCD输入(10到15)全部熄灭数码管。很多人会忽略这个兜底,觉得反正输入不会超出范围。但在硬件里,输入是可变的,一旦某个错误状态被采样进来,没有兜底的分支就可能综合出意外的锁存器,或者导致输出出现不可控的电平。
练习七段译码器的时候,建议你手动在纸上画一遍0到9的段点亮图,再对照代码里的映射值自己算一遍。你会发现每个段码都不是随便背的,而是从“哪几段组合起来像这个数字”一步步推出来的。算完一次之后,这段代码就再也忘不掉了。
2.4 超前进位加法器:把进位链“展开”到底层逻辑
加法器是ALU的地基。最简单的是行波进位加法器,每一位共同使用一个全加器,进位从低位逐级传给高位。缺点是位数一多,关键路径就变长,比如32位的加法器进位要穿31级门,频率很难拉高。
超前进位加法器的思路,是把每一位的进位输出直接写成所有低位输入的函数,靠逻辑展开换速度。以4位为例,定义进位生成信号g = a & b(这一位一定会产生进位)和进位传播信号p = a ^ b(如果低位有进位,这一位会把进位继续向上传),那么第i位进位c[i]可以展开成:
module cla4 ( input wire [3:0] a, input wire [3:0] b, input wire c_in, output wire [3:0] sum, output wire c_out ); wire [3:0] g, p, c; assign g = a & b; assign p = a ^ b; assign c[0] = c_in; assign c[1] = g[0] | (p[0] & c[0]); assign c[2] = g[1] | (p[1] & g[0]) | (p[1] & p[0] & c[0]); assign c[3] = g[2] | (p[2] & g[1]) | (p[2] & p[1] & g[0]) | (p[2] & p[1] & p[0] & c[0]); assign sum = p ^ c[3:0]; assign c_out = g[3] | (p[3] & c[3]); endmodule从代码里能清楚看到,第3位的进位表达式里同时出现了g[0]、p[0]、c[0],这说明第3位的进位不依赖中间进位一级级传上来,而是直接由最低位输入决定。这就是超前进位的本质。
用这个4位模块拼更大的加法器时,一般会在组内用超前进位、组间再用超前进位的分层设计,避免表达式指数级膨胀。你把它综合出来看一下资源报告,会发现最高位那个等式对应了非常多级逻辑,背后是面积换速度的典型思路。
2.5 使用generate for批量例化重复电路
写加法器遇到最常见的问题是:总不可能每次都手写很多位。如果我们要个8位行波进位加法器,一种办法是把1位全加器例化8次。例化8次还能接受,但位宽变成64、128时,手工复制粘贴只会让人发疯。这时候就该用generate for了。
module full_adder_1 ( input wire a, input wire b, input wire cin, output wire sum, output wire cout ); assign sum = a ^ b ^ cin; assign cout = (a & b) | (cin & (a ^ b)); endmodule module adder_n #( parameter N = 8 ) ( input wire [N-1:0] a, input wire [N-1:0] b, input wire cin, output wire [N-1:0] sum, output wire cout ); wire [N:0] carry; assign carry[0] = cin; genvar i; generate for (i = 0; i < N; i = i + 1) begin : gen_adder full_adder_1 u_adder ( .a(a[i]), .b(b[i]), .cin(carry[i]), .sum(sum[i]), .cout(carry[i+1]) ); end endgenerate assign cout = carry[N]; endmodulegenerate for和普通for最大的区别,在于普通for是仿真时展开,而generate for是综合时展开,它会真正例化出N份独立的硬件模块。循环体里的begin : gen_adder给每个例化出来的子模块命名,这样你在综合报告里能看到gen_adder[3].u_adder这样的层次结构,调试定位会很方便。
写这个模块时有个很容易踩的坑:进位链carry[N:0]的位宽是N+1,因为8位加法器需要9根进位线。一开始不熟练的人很容易把进位数组定义成[N-1:0],结果综合出来直接报连线长度不匹配的错误。
3. 三种描述方式的边界:assign、always与function
3.1 什么时候用assign直接连,什么时候用always块
同一个组合逻辑,Verilog提供了好几种写法。新手通常会纠结:到底用assign还是always @(*)?
我的经验是分场景。简单的逻辑门、总线拼接、三态门这些,用assign最直观,一两行解决问题,综合器看到的也是纯连线。复杂一点的、带多分支的判断逻辑,用always @(*)配合case或if else,可读性更好,逻辑关系更清楚。还有一种情况是通用计算,比如计算奇偶校验、做字节逆序,可以用function封装,在多个地方调用,避免重复代码。
举一个奇偶校验的例子:
function parity_8; input [7:0] data; begin parity_8 = ^data; end endfunction这里的^data是缩减异或运算符,对data所有位逐位异或,结果1表示奇数个1,0表示偶数个1。用function封装以后,可以在不同模块里反复调用,综合时它会被内联成一组异或门,不会像子模块例化那样增加层次。它本质上是“写一次文本、展开多处电路”的语法糖。
3.2 阻塞赋值和非阻塞赋值:组合逻辑必须坚持的原则
这可能是整个组合逻辑写代码中最重要的一个知识点。在时序逻辑里,规则是统一的:用非阻塞赋值<=。在组合逻辑里,标准建议是用阻塞赋值=。
原因要从这两种赋值的执行机制讲清楚。always块里如果写的是阻塞赋值,语句按顺序执行,前面的赋值结果立刻对后面的语句可见。如果写的是非阻塞赋值,所有右侧表达式在进入块时同时采样,然后统一更新左侧信号,前面的赋值结果对后面的语句不可见。
看一个很典型的坑:
// 错误示范:希望先加1再判断,但由于非阻塞赋值,flag判断的是旧值 always @(*) begin next_data <= data + 1; // 更新还没发生 flag <= (next_data > threshold); // 这里用的是旧的next_data end换成阻塞赋值就没问题:
always @(*) begin next_data = data + 1; // 先算出新值 flag = (next_data > threshold); // 再用新值判断 end最后综合出来的电路是一样的,因为这段逻辑本质上就是flag = (data + 1) > threshold。非阻塞赋值会让综合器误以为这是时序逻辑,仿真结果和综合结果可能不一致。所以我自己的代码规范里很明确:组合逻辑的always块里一律用=,时序逻辑的always块里一律用<=,混用是绝对不允许的。
3.3 没有default的case就是latch的温床
新手写组合逻辑最容易犯的错,是在case语句里漏掉一些分支。比如只写了0到6的分支,却忘了default,综合工具会怎么处理?它会认为这些未覆盖的状态需要“保持原值”。组合逻辑本身没有记忆能力,要保持原值就必须插入一个锁存器。于是,你的设计莫名其妙多了一堆latch。
来看一个故意写错的例子:
always @(*) begin case (sel) 2'b00: dout = din0; 2'b01: dout = din1; endcase end这个代码里sel的两种取值都没有写,综合器会警告你“incomplete case statement generated latch”。你可能觉得自己不会犯这么明显的错误,但实际项目里问题往往更隐蔽:case分支基本覆盖了,但某个分支只给了一部分位宽赋值,另一部分位宽没动,同样会产生latch。
规避方法很简单:要么把所有分支写全并加上default,要么在赋值前先给输出一个默认值。我习惯先把所有输出给默认值,再写case分支,这样即使某个分支漏掉也不会生成latch:
always @(*) begin dout = 8'b0; case (sel) 2'b00: dout = din0; 2'b01: dout = din1; default: dout = 8'b0; endcase end这是好习惯,哪怕最后的综合优化会把多余的默认电路优化掉,也比仿真通过、上板后功能混乱强得多。
4. 仿真、综合与毛刺:组合逻辑代码上板之前要过的关
4.1 功能仿真不冒泡不代表上板没有问题
很多工程上第一次跑仿真,看到波形和预期一致,就觉得万事大吉。其实功能仿真默认使用零延迟模型,两个信号同时变化时,你根本看不到竞争冒险现象。而真实电路里,每个逻辑门都有传播延迟,信号经过不同路径到达同一个门的时刻不可能完全相同,这就可能出现毛刺。
所以把功能仿真当作唯一标准是很危险的。至少要再做两件事:第一,用带延迟的仿真模型跑一遍,观察输出是否有意外的尖峰;第二,对关键路径做时序分析,确认组合逻辑级数没有超过时钟周期约束。如果这两步都没问题,再上板心里才有底。
4.2 输出毛刺的本质与抑制思路
毛刺本质上是竞争冒险。举个例子,假设输出表达式是out = a & b。在理想情况下,a和b同时从0变为1,out立刻变1。但真实世界里,a和b可能不是同时到达与门的,如果a先到了1而b还在0,输出为0;等b也到了1,输出才变为1,这段时间输出就保持0。反过来,如果两个信号一前一后发生过零点,输出会在中间短暂地出现一个1的尖峰,这就是毛刺。
抑制毛刺的方法工程上有几种。最常用的是在组合逻辑输出端加一个寄存器打一拍,让毛刺不足以被采样。但这等于把组合逻辑的输出转成了时序逻辑信号,使用时序逻辑的模块才能这么处理。如果严格要求纯组合逻辑输出,那就需要优化逻辑表达式,尽量减少输入到输出路径上各级信号的到达时间差,让逻辑级数更均衡。另一种思路是在设计层面用格雷码代替二进制码,减少多个信号同时翻转的概率。
需要注意的是,并不是所有毛刺都必须消除。毛刺的危害取决于它会不会被后续的触发器采样到,以及是否会影响异步控制信号。不需要过度恐慌,但要做识别和分析。
4.3 组合逻辑环路是永远不行的
组合逻辑环路指的是输出信号又反过来参与自身输入的计算,比如:
assign x = y & ~x;这类代码在仿真器里很容易出现振荡,因为x的值每更新一次,都会触发下一次更新,如果没有延迟建模,仿真器可能直接卡死。综合工具一般也会直接报错。有些新手在调试时企图用这类“反馈”实现延迟功能,这是绝对错误的思路。组合逻辑形成环路,在真实硬件里行为不可预测,属于设计红线。
我还见过一个不太容易察觉的变种,是在always @(*)块里对一个信号进行条件自赋值:
always @(*) begin if (enable) data_out = data_in; else data_out = data_out; // 自己写自己 end这个else分支的data_out = data_out,在综合器看来就是要求电路保持旧值,也就是生成锁存器,而且很容易被优化出奇怪的电路。正确做法是老老实实写完整赋值条件,不要依赖“保持原值”这种描述。
5. 练手项目:一个带七段显示输出的8位ALU
5.1 模块划分与接口定义
前面讲了这么多独立的组合逻辑单元,最后用一个练手项目把它们串起来。项目目标是做一个支持加减、与、或、异或、比较的8位ALU,输出结果用两位数码管显示。注意为了让练习只聚焦组合逻辑,这里不引入时钟,显示部分用选择开关直接切换高四位和低四位。
模块划分是标准的分层思路:
| 模块名 | 功能 | 输入 | 输出 |
|---|---|---|---|
| alu_8bit | 8位算术逻辑运算 | a, b, op | result |
| seg7 | BCD到七段码译码 | 4位BCD | 7位段码 |
| alu_top | 顶层级联 | a, b, op, sel | seg |
每个模块职责单一,不管后续是接按键、接数码管扫描还是接总线,都只需要改顶层,不需要动核心逻辑。这就是组合逻辑设计里追求的可复用性。
5.2 核心逻辑:ALU的always实现
ALU占比最大,8位运算如果全部用逻辑表达式手推,工程量太大,行为级描述就派上用场了。我在always @(*)里用case按操作码选择运算类型:
module alu_8bit ( input wire [7:0] a, input wire [7:0] b, input wire [2:0] op, output reg [7:0] result ); always @(*) begin result = 8'b0; case (op) 3'b000: result = a & b; 3'b001: result = a | b; 3'b010: result = a ^ b; 3'b011: result = a + b; 3'b100: result = a - b; 3'b101: result = (a < b) ? 8'h01 : 8'h00; default: result = 8'b0; endcase end endmodule每个分支都是纯组合逻辑表达式,case结构清晰,后续加乘法、移位都只要扩展分支和操作码位宽。第5个分支的比较运算在Verilog里可以直接用<,综合时会映射成比较器,不用自己手写比较器。
然后顶层把ALU和七段译码器接起来。选择信号sel决定当前显示的是结果的低四位还是高四位:
module alu_top ( input wire [7:0] a, input wire [7:0] b, input wire [2:0] op, input wire sel, output wire [6:0] seg ); wire [7:0] result; alu_8bit u_alu ( .a(a), .b(b), .op(op), .result(result) ); seg7 u_seg ( .bcd(sel ? result[7:4] : result[3:0]), .seg(seg) ); endmodule这个顶层代码完全体现了好几种组合逻辑单元的组合方式。测试不同运算时,只要给定a、b和op,观察seg对应的数字即可。没有时钟,所有输出都是输入一变就跟着变,非常直观。
5.3 仿真激励与上板验证要点
给这个ALU写testbench非常容易,因为没有时钟,只要在initial块里按顺序给不同输入组合即可。测试一个加法:
initial begin a = 8'h12; b = 8'h34; op = 3'b011; #10; $display("result = %h", result); end#10的作用是让组合逻辑有足够时间稳定输出,仿真器会计算表达式并更新result。如果你把op换成其他值,再重新给a、b赋值,观察输出是否符合预期。
上板验证的话,我的建议是先把a、b固定成简单值,比如8'h0F和8'h01,手动拨动op开关,逐个确认加法、减法、逻辑运算结果对应的数码管显示是否和计算器一致。如果显示乱码,优先检查七段译码器的段码映射和数码管公共端的接法,这部分出错的概率比ALU本身大得多。
最后再说一个个人习惯。我写完组合逻辑模块之后,一定会打开综合报告专门看两个地方:一是有没有意外生成的latch,二是关键路径延迟有没有达到约束。前者能立刻暴露case分支不完整之类的问题,后者决定这个组合逻辑模块能不能安全地跑在系统时钟下。很多所谓“仿真通过上板失败”的case,问题根本不在功能逻辑,而是在综合和时序环节,养成这个检查习惯之后会少走非常多弯路。