4. 组合逻辑设计:基本门电路、多路选择器、译码器、加法器、乘法器
组合逻辑,说白了就是没有记忆功能的电路。输入一变,输出立马跟着变,不带半点延迟(当然,实际门电路有物理延迟,但那是后话)。我刚开始学CPLD那会儿,觉得这玩意儿太简单了,不就是几个与门或门拼一拼嘛。后来做项目才发现,真正考验功力的地方,恰恰是这些基础模块的组合与优化。
这一章,咱们就把组合逻辑的几块“积木”拆开揉碎了讲。从最基础的门电路,到常用的多路选择器、译码器,再到运算核心的加法器和乘法器。每个模块我都会给出可综合的Verilog代码,以及对应的仿真测试方法。
核心要点:组合逻辑设计的关键在于理解“真值表”和“布尔代数”。你写的每一行代码,最终都会映射成硬件上的门电路网络。写代码的时候,脑子里要时刻想着“这个语句会综合成什么电路”。
4.1 基本门电路:与、或、非
先看最基础的。与门、或门、非门,这是所有数字电路的基石。在CPLD里,这些门电路通常由查找表(LUT)实现。小规模的CPLD,一个宏单元可能就包含一个4输入LUT,能实现任意4输入的组合逻辑。
下面是一个简单的与或非组合电路示例。我习惯用连续赋值语句assign来描述纯组合逻辑,这样最直观。
// 基本门电路示例
module basic_gates (
input wire a, // 输入a
input wire b, // 输入b
input wire c, // 输入c
output wire y_and, // 与门输出
output wire y_or, // 或门输出
output wire y_not, // 非门输出
output wire y_nand, // 与非门输出
output wire y_nor, // 或非门输出
output wire y_xor, // 异或门输出
output wire y_xnor // 同或门输出
);
// 连续赋值,组合逻辑
assign y_and = a & b; // 与
assign y_or = a | b; // 或
assign y_not = ~a; // 非
assign y_nand = ~(a & b); // 与非
assign y_nor = ~(a | b); // 或非
assign y_xor = a ^ b; // 异或
assign y_xnor = a ~^ b; // 同或
endmodule
这段代码很简单,但有个细节要注意:连续赋值语句的左边必须是wire类型。如果你用always块写组合逻辑,那左边就得用reg类型。我个人更推荐用assign写纯组合逻辑,代码更简洁,综合结果也更可控。
小技巧:在CPLD设计中,尽量用assign代替always @(*)来写组合逻辑。虽然两者功能等价,但assign的意图更明确——我就是想用连线实现,不是触发器。有些综合工具对assign的优化反而更好。
4.2 多路选择器:数据选择的艺术
多路选择器(MUX)在数字设计中无处不在。从数据通路选择到寄存器堆的读写,到处都有它的身影。实现MUX的方式有好几种,我分别说说。
4.2.1 用三目运算符实现
最简单的方式,就是用三目运算符。2选1MUX的代码长这样:
// 2选1多路选择器
module mux2to1 (
input wire [7:0] data0, // 数据输入0
input wire [7:0] data1, // 数据输入1
input wire sel, // 选择信号
output wire [7:0] y // 数据输出
);
assign y = sel ? data1 : data0;
endmodule
这段代码综合出来就是一个8位的2选1MUX。如果选择信号sel是0,输出data0;sel是1,输出data1。简单明了。
4.2.2 用case语句实现
当选择路数多了,比如4选1、8选1,用三目运算符嵌套会变得很难看。这时候就该case语句上场了。注意,在always块里用case,输出要声明为reg类型。
// 4选1多路选择器
module mux4to1 (
input wire [7:0] data0,
input wire [7:0] data1,
input wire [7:0] data2,
input wire [7:0] data3,
input wire [1:0] sel,
output reg [7:0] y
);
always @(*) begin
case (sel)
2'b00: y = data0;
2'b01: y = data1;
2'b10: y = data2;
2'b11: y = data3;
default: y = 8'b0; // 防止锁存器
endcase
end
endmodule
注意:写case语句时,一定要加default分支。不加default,综合工具可能会推断出锁存器(latch)。锁存器在CPLD设计中是个大坑,时序难分析,还容易出毛刺。我曾经在一个项目中漏了default,结果仿真没问题,上板子就随机出错,查了两天才找到原因。
4.3 译码器:地址解码的核心
译码器本质上就是把输入的二进制编码,翻译成“独热码”输出。最典型的就是3-8译码器。在CPLD系统里,译码器常用于片选信号的生成——比如根据地址总线的高几位,决定哪个外设被选中。
// 3-8译码器
module decoder3to8 (
input wire [2:0] addr, // 3位地址输入
input wire en, // 使能信号
output reg [7:0] y // 8位译码输出
);
always @(*) begin
if (!en) begin
y = 8'b0; // 使能无效,全部输出0
end else begin
case (addr)
3'b000: y = 8'b0000_0001;
3'b001: y = 8'b0000_0010;
3'b010: y = 8'b0000_0100;
3'b011: y = 8'b0000_1000;
3'b100: y = 8'b0001_0000;
3'b101: y = 8'b0010_0000;
3'b110: y = 8'b0100_0000;
3'b111: y = 8'b1000_0000;
default: y = 8'b0;
endcase
end
end
endmodule
你看,译码器的输出是“独热码”——只有一个bit为1,其他都是0。这种编码方式在后续的优先级仲裁、中断控制器里很常用。
4.4 加法器:从半加器到超前进位
加法器是算术运算的核心。从最简单的半加器,到全加器,再到多位加法器,一层层往上搭。
4.4.1 半加器与全加器
半加器只考虑两个加数,不考虑进位输入。全加器则多了进位输入cin。
// 全加器
module full_adder (
input wire a, // 加数a
input wire b, // 加数b
input wire cin, // 进位输入
output wire sum, // 和
output wire cout // 进位输出
);
assign {cout, sum} = a + b + cin;
endmodule
这里我用了一个拼接操作符{},直接把进位和和一起算出来了。综合工具会自动优化成全加器结构,不用自己手动搭门电路。
4.4.2 多位加法器
多位加法器有两种常见实现:行波进位加法器和超前进位加法器。行波进位结构简单,但延迟大——进位要一级一级传下去。超前进位则通过额外的逻辑提前算出进位,速度快,但面积大。
在CPLD里,我建议直接用“+”运算符,让综合工具自己去优化。现代的综合工具都很聪明,会根据你的时序约束自动选择最优结构。
// 8位加法器
module adder8 (
input wire [7:0] a,
input wire [7:0] b,
input wire cin,
output wire [7:0] sum,
output wire cout
);
assign {cout, sum} = a + b + cin;
endmodule
经验之谈:在CPLD里做加法器,如果位宽超过16位,建议拆成多个小加法器,中间加流水线寄存器。否则进位链太长,时序很难收敛。我做过一个32位加法器,直接写a+b,结果综合出来最高只能跑50MHz,拆成两级流水后轻松上100MHz。
4.5 乘法器:面积与速度的权衡
乘法器比加法器复杂得多。在CPLD里实现乘法,通常有三种方式:
- 直接用*运算符:最简单,综合工具会调用内部的DSP单元(如果有的话)或生成组合乘法器。
- 移位相加:适合小位宽乘法,面积小但速度慢。
- 查找表(LUT):把乘法结果预先算好存起来,适合固定系数的乘法。
先看最直接的写法:
// 8位乘法器
module multiplier8 (
input wire [7:0] a,
input wire [7:0] b,
output wire [15:0] y
);
assign y = a * b;
endmodule
这段代码综合出来就是一个8x8的组合乘法器。如果CPLD内部有硬件乘法器(DSP块),综合工具会自动映射过去。如果没有,就会用LUT拼出一个乘法器,面积会比较大。
再看移位相加的实现方式。这种方式适合位宽较小的乘法,比如4位乘4位:
// 4位乘法器(移位相加)
module multiplier4_shift (
input wire [3:0] a,
input wire [3:0] b,
output wire [7:0] y
);
reg [7:0] temp;
integer i;
always @(*) begin
temp = 8'b0;
for (i = 0; i < 4; i = i + 1) begin
if (b[i]) begin
temp = temp + (a << i);
end
end
end
assign y = temp;
endmodule
这段代码用for循环实现了移位相加。注意,这里的for循环是可综合的——因为循环次数是固定的(4次),综合工具会把它展开成4级加法器。
注意:for循环的边界必须是常数,不能是变量。如果你写成for(i=0; i<b; i=i+1),综合工具会报错——它不知道要展开多少次。我曾经见过有人这么写,结果综合出来一堆莫名其妙的逻辑,仿真还通不过。
4.6 仿真验证:让代码动起来
代码写完了,不仿真怎么行?我习惯用ModelSim或Vivado Simulator做仿真。下面是一个测试平台,用来验证我们写的加法器:
// 加法器测试平台
`timescale 1ns / 1ps
module tb_adder8;
reg [7:0] a;
reg [7:0] b;
reg cin;
wire [7:0] sum;
wire cout;
// 实例化被测试模块
adder8 uut (
.a(a),
.b(b),
.cin(cin),
.sum(sum),
.cout(cout)
);
// 生成测试激励
initial begin
// 初始化
a = 8'b0;
b = 8'b0;
cin = 1'b0;
// 测试用例1:0 + 0 = 0
#10;
a = 8'h00; b = 8'h00; cin = 1'b0;
// 测试用例2:1 + 1 = 2
#10;
a = 8'h01; b = 8'h01; cin = 1'b0;
// 测试用例3:255 + 1 = 0(溢出)
#10;
a = 8'hFF; b = 8'h01; cin = 1'b0;
// 测试用例4:带进位输入
#10;
a = 8'h0F; b = 8'hF0; cin = 1'b1;
// 测试用例5:随机值
#10;
a = 8'hA5; b = 8'h5A; cin = 1'b0;
#10;
$finish;
end
// 显示结果
initial begin
$monitor("Time=%0t: a=%h, b=%h, cin=%b, sum=%h, cout=%b",
$time, a, b, cin, sum, cout);
end
endmodule
仿真跑起来后,你会看到每个时钟周期输出一组结果。检查一下:1+1=2,255+1=0(溢出),这些都对不对?如果不对,回头查代码。
仿真要点:
- 边界条件一定要测:全0、全1、最大值、最小值。
- 进位传递路径要测:比如0xFF+0x01,看进位能不能正确传递。
- 随机测试也要做:用$random生成随机数,跑几百个周期看看。
4.7 本章知识体系
下面这张图总结了本章的核心内容。从基本门电路出发,逐步构建出多路选择器、译码器、加法器和乘法器。这些模块是CPLD数字系统设计的基石。
嗯,这一章的内容就到这里。组合逻辑是数字设计的基础,看似简单,但真正用好、用巧,需要大量的实践积累。我建议你把每个模块都亲手写一遍、仿真一遍,看看综合出来的RTL视图长什么样。看得多了,你自然就能在写代码的时候,脑子里浮现出对应的门电路结构。
记住一句话:写Verilog不是在写软件,而是在画电路。每一行代码,最终都会变成实实在在的硬件。想清楚再下笔,比写完再改要高效得多。