1. 从半减器开始:理解减法运算的“原子”
大家好,我是老李,在数字电路和芯片设计这块摸爬滚打了十几年。今天咱们不聊那些高大上的复杂架构,就从一个最基础、但也是所有算术运算基石的东西聊起——半减器。很多刚接触Verilog和数字逻辑的朋友,一上来就想搞懂CPU里的ALU(算术逻辑单元),结果往往被底层的门电路和真值表绕晕。我的经验是,把基础打牢,后面的一切都会豁然开朗。半减器,就是理解减法乃至整个有符号数运算的绝佳起点。
那么,半减器到底是什么呢?你可以把它想象成一个只能处理“个位数”减法的超迷你计算单元,而且它只管自己这一位,不考虑别人(也就是低位)有没有向它借过东西。它只有两个输入:被减数的一位(我们叫它 Xi)和减数的一位(Yi)。输出也有两个:这一位的差(Di)和一个“借位”信号(Bo),这个借位信号是告诉它左边更高位的那一位:“喂,我这儿不够减了,你得借我1当10用(在二进制里就是借1当2)”。
咱们直接来看最直观的真值表,我习惯用这种方式来理解逻辑:
| Xi (被减数) | Yi (减数) | Di (差) | Bo (输出借位) |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 1 | 1 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 0 |
我来给你翻译一下这个表。第一行,0减0等于0,当然不用借位。第二行是关键,0减1,这在我们十进制里就是个位不够减要向十位借,在这里也一样,个位(当前位)结果是 1(注意,二进制下 0-1 得到 1 是因为有借位,这个 1 是补码形式的体现,我们先这么理解),同时必须产生一个借位信号 Bo=1 给高位。第三行,1减0等于1,很简单。第四行,1减1等于0,也不用借位。
从真值表,我们可以推导出逻辑表达式。差 Di 什么时候是1呢?看表,当 Xi 和 Yi 不同的时候(一个0一个1),Di 就是1。这正好是异或(XOR) 运算的逻辑!所以 Di = Xi ^ Yi。那借位 Bo 呢?只有当 Xi 是0且 Yi 是1的时候,Bo 才是1。这正好是与(AND) 运算,但需要对 Xi 取反,即 Bo = (~Xi) & Yi。看,是不是很简单?半减器的门电路就是由一个异或门和一个与门(前面加个非门)构成的。
理解了原理,用Verilog实现就是分分钟的事。我们采用数据流描述方式,因为它最贴近逻辑表达式,一目了然:
module half_subtractor (
input Xi, Yi, // 1-bit inputs
output Di, Bo // Difference and Borrow-out
);
assign Di = Xi ^ Yi; // 差等于输入异或
assign Bo = (~Xi) & Yi; // 借位等于(非Xi)与Yi
endmodule
这个模块就是我们的“乐高积木”最基础的一块。我建议你马上在仿真工具里建个测试文件,把四种输入组合 (0,0), (0,1), (1,0), (1,1) 都跑一遍,看看波形输出是不是和真值表一模一样。动手验证是巩固记忆的最好方式。半减器虽然简单,但它清晰地定义了减法操作中“差”和“借位”这两个核心概念,这是我们构建更复杂电路的基石。
2. 升级到全减器:让计算“顾全大局”
半减器好用,但它有个明显的“缺陷”:它太“自私”了,只关心自己眼前的两个数,完全不管低位是不是已经欠了债(即借位)。在实际的多位数减法中,比如计算 1011 - 0111,每一位的计算都必须考虑来自右边低位的借位。这就需要功能更完整的“全减器”。
全减器在半减器的基础上,增加了一个关键的输入:来自低位的借位输入(Borrow-in, Bi)。现在,它有三个输入:Xi(被减数位)、Yi(减数位)、Bi(低位借来的)。输出依然是两个:Di(差位)和 Bo(向高位的借位输出)。它的计算逻辑是:Xi - Yi - Bi。因为 Bi 可能是0或1,所以情况比半减器复杂一些。
我们来看全减器的真值表,它包含了所有8种输入组合(2的3次方):
| Xi | Yi | Bi | Di | Bo |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 | 1 |
| 0 | 1 | 0 | 1 | 1 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 | 0 |
| 1 | 0 | 1 | 0 | 0 |
| 1 | 1 | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 | 1 |
这个表怎么读?我举两个典型的例子。第二行:Xi=0, Yi=0, Bi=1。意思是,我这一位本来是0减0,但低位借走了1(Bi=1),所以我相当于 0 - 0 - 1,不够减,结果 Di 应该是1(二进制补码运算下的结果),同时我必须向高位借1,所以 Bo=1。再看第七行:Xi=1, Yi=1, Bi=0,就是 1-1-0=0,不借位。
从真值表推导逻辑表达式稍微麻烦点,但我们可以用卡诺图化简,或者直接观察规律。差 Di 的输出,你会发现当三个输入 Xi, Yi, Bi 中1的个数是奇数个时,Di=1;偶数个时,Di=0。这其实就是三输入异或运算的特性!所以 Di = Xi ^ Yi ^ Bi。这个结论非常优美,它和全加器中“和(Sum)”的表达式形式完全一致。
借位输出 Bo 的逻辑稍微复杂:Bo = (~Xi & (Yi ^ Bi)) | (Yi & Bi)。这个式子可以这样理解:向高位借位发生在两种情况下:第一,Xi 是0,同时 Yi 和 Bi 至少有一个是1(即 Yi 和 Bi 异或结果为1,表示它们不同,但结合 Xi=0 的语境,其实就是 Yi 或 Bi 为1导致不够减);第二,Yi 和 Bi 同时为1,这时无论 Xi 是什么,都肯定需要借位(因为相当于减去了至少2)。这个表达式也可以用基本门电路实现。
Verilog实现全减器,我们可以用行为级描述直接体现其计算逻辑,这样写起来更直观,也便于综合工具优化:
module full_subtractor (
input Xi, Yi, Bi,
output Di, Bo
);
// 差是三个输入的异或
assign Di = Xi ^ Yi ^ Bi;
// 借位输出逻辑
assign Bo = (~Xi & (Yi ^ Bi)) | (Yi & Bi);
endmodule
当然,你也可以用两个半减器和一个或门来搭建一个全减器,这就像用基础积木拼装复杂模型一样,有助于理解模块间的层次关系。全减器这个模块,就是我们可以用来搭建任意位宽减法器的标准“砖块”了。在实际的芯片设计中,全减器和全加器在逻辑结构上有很强的对称性,理解这一点对后续设计共享的算术单元至关重要。
3. 构建完整的减法器:串行与共享设计
有了全减器这块“砖”,我们就可以盖“房子”——构建多位的减法器了。最直观的方法,就是把N个全减器像链条一样串起来,低位的借位输出(Bo)连接到高位的借位输入(Bi)。这种结构被称为行波借位减法器,它和数字电路里经典的“行波进位加法器”是孪生兄弟。
3.1 行波借位减法器
假设我们要做一个16位的减法器,计算 X[15:0] - Y[15:0]。最低位(第0位)比较特殊,因为没有更低的位,所以它的借位输入 Bi[0] 固定接0。它的全减器计算 X[0] - Y[0] - 0,产生差 D[0] 和借位 Bo[0](即 Bi[1])。然后 Bo[0] 连接到第1位全减器的 Bi[1],第1位计算 X[1] - Y[1] - Bi[1],以此类推,借位信号像波浪一样从最低位逐级传递到最高位。最高位的借位输出 Bo[15] 就是整个减法运算的最终借位,通常它表示结果是否为负(在无符号数减法中)或用于溢出判断。
用Verilog描述一个16位行波借位减法器非常简洁,我们可以实例化16个全减器模块并将它们连接:
module ripple_borrow_subtractor_16bit (
input [15:0] X, Y, // 16-bit inputs
output [15:0] D, // 16-bit difference
output Bout // Final borrow out
);
wire [16:0] borrow; // 内部借位连线,borrow[0]作为最低位输入,borrow[16]作为最终输出
assign borrow[0] = 1'b0; // 最低位没有借位输入
// 生成语句,实例化16个全减器
genvar i;
generate
for (i=0; i<16; i=i+1) begin: sub_chain
full_subtractor u_full_sub (
.Xi (X[i]),
.Yi (Y[i]),
.Bi (borrow[i]),
.Di (D[i]),
.Bo (borrow[i+1])
);
end
endgenerate
assign Bout = borrow[16]; // 最终借位输出
endmodule
这种方法的优点是结构清晰,直接反映了减法原理,非常适合教学和理解。但它的缺点也和行波进位加法器一样:速度慢。因为借位信号必须从最低位一步一步传到最高位,如果位宽很大(比如64位),这条链路的延迟就会成为性能瓶颈。在实际的高性能处理器中,我们很少直接用这种串行结构。
3.2 更巧妙的思路:加减法器共享结构
在真实的CPU设计里,面积和速度都是黄金。如果能为加法和减法设计一个共用的硬件单元,那就能节省大量的逻辑资源。幸运的是,利用二进制补码的特性,我们可以非常优雅地实现这一点。
这里的关键公式是:X - Y = X + (~Y) + 1。其中 ~Y 表示对Y的每一位取反。这个公式告诉我们,减法可以转化为加法来做:先把减数Y按位取反,然后和被减数X相加,最后再加1。
基于这个原理,我们可以改造一个加法器(比如一个行波进位加法器),让它既能做加法又能做减法。我们引入一个控制信号,通常叫 sub(减法)或者 add_sub。当 sub=0 时,执行加法 X+Y;当 sub=1 时,执行减法 X-Y。
具体电路怎么做呢?我们让这个控制信号同时控制两件事:
- 控制Y输入的数据:当
sub=1(做减法)时,将Y的每一位与sub信号进行异或。因为sub是1,异或的结果就是Y^1 = ~Y(取反)。当sub=0(做加法)时,Y^0 = Y,数据不变。 - 控制最低位的进位输入:加法器本身有一个进位输入
Cin。我们让Cin直接等于控制信号sub。这样,做加法时Cin=0;做减法时Cin=1,正好对应了公式里“+1”的那一步。
于是,这个单元的运算就变成了:Result = X + (Y ^ {sub, sub, ...}) + sub。完美地将加法和减法统一到了一个电路中。这种设计在处理器算术逻辑单元(ALU)中极为常见。我们来看一个简单的Verilog实现:
module add_sub_16bit (
input [15:0] A, B, // 两个操作数
input sub, // 控制信号:0=加法,1=减法
output [15:0] Result,
output Cout // 进位/借位输出
);
wire [15:0] B_modified;
wire Cin;
// 关键操作:通过异或实现B的原码/反码选择
assign B_modified = B ^ {16{sub}}; // 如果sub=1,则B每一位与1异或,即取反
assign Cin = sub; // 进位输入等于控制信号
// 调用一个标准的16位加法器(可以是行波进位,也可以是更快的结构)
ripple_carry_adder_16bit u_adder (
.A (A),
.B (B_modified),
.Cin (Cin),
.Sum (Result),
.Cout(Cout)
);
// 注意:对于减法,Cout的含义需要结合溢出判断来理解,有时需要取反。
endmodule
这里我调用了另一个模块 ripple_carry_adder_16bit,就是一个普通的16位加法器。你看,我们并没有重新设计减法逻辑,只是通过一个巧妙的控制信号,在加法器的基础上“免费”获得了减法功能。这就是数字电路设计的魅力:用最少的资源,实现最多的功能。
4. Verilog实现实战与关键优化策略
理论说了一大堆,是时候动手写代码了。在这一部分,我会带你实现两个版本的16位减法器,并深入探讨几个在实现和优化中你必须知道的“坑”和技巧。
4.1 两种实现方案的代码对比
首先,我们把前面提到的两种方法用完整的、可综合的Verilog代码实现出来。
方案一:基于全减器的行波借位减法器 这个方案需要我们先写好全减器模块,然后顶层例化。代码结构清晰,是学习的好材料。
// 全减器模块(复用之前的代码)
module full_subtractor (
input Xi, Yi, Bi,
output Di, Bo
);
assign Di = Xi ^ Yi ^ Bi;
assign Bo = (~Xi & (Yi ^ Bi)) | (Yi & Bi);
endmodule
// 顶层:16位行波借位减法器
module subtractor_ripple_16bit (
input [15:0] a, b,
output [15:0] diff,
output borrow_out
);
wire [16:0] borrow_wire; // 17位借位连线
assign borrow_wire[0] = 1'b0; // 最低位借位输入为0
// 使用generate循环例化16个全减器
genvar i;
generate
for (i = 0; i < 16; i = i + 1) begin: sub_block
full_subtractor u_sub (
.Xi (a[i]),
.Yi (b[i]),
.Bi (borrow_wire[i]),
.Di (diff[i]),
.Bo (borrow_wire[i+1])
);
end
endgenerate
assign borrow_out = borrow_wire[16];
endmodule
方案二:基于加法器改造的共享加减法器 这个方案更贴近实际应用。我们需要一个加法器核心,这里为了简单,我们先写一个行波进位加法器,然后再包装成加减法器。
// 首先,实现一个16位行波进位加法器
module adder_rc_16bit (
input [15:0] A, B,
input Cin,
output [15:0] Sum,
output Cout
);
wire [16:0] carry;
assign carry[0] = Cin;
genvar j;
generate
for (j = 0; j < 16; j = j + 1) begin: add_block
// 一位全加器逻辑:Sum = A^B^Cin, Cout = A&B | (A^B)&Cin
assign Sum[j] = A[j] ^ B[j] ^ carry[j];
assign carry[j+1] = (A[j] & B[j]) | ((A[j] ^ B[j]) & carry[j]);
end
endgenerate
assign Cout = carry[16];
endmodule
// 然后,构建加减法器
module add_sub_unit_16bit (
input [15:0] op1, op2,
input mode, // 0: add, 1: sub
output [15:0] result,
output overflow // 溢出标志,简化处理
);
wire [15:0] op2_modified;
wire cin;
wire cout;
// 核心转换逻辑
assign op2_modified = op2 ^ {16{mode}}; // mode=1时取反
assign cin = mode; // mode=1时Cin=1
// 调用加法器
adder_rc_16bit u_core_adder (
.A (op1),
.B (op2_modified),
.Cin (cin),
.Sum (result),
.Cout(cout)
);
// 溢出判断(对于有符号数):当两个正数相加得负,或两个负数相加得正时溢出
// 简化版:检查最高位的进位输入和进位输出是否不同
assign overflow = (op1[15] == op2_modified[15]) && (result[15] != op1[15]);
endmodule
4.2 仿真测试与结果分析
代码写好了,不仿真就是纸上谈兵。我们必须写一个全面的测试平台(Testbench)来验证功能。测试要覆盖边界情况,比如全0、全1、最大值减最小值等。
`timescale 1ns/1ps
module tb_add_sub();
reg [15:0] a, b;
reg mode;
wire [15:0] result;
wire overflow;
// 实例化被测单元
add_sub_unit_16bit uut (
.op1 (a),
.op2 (b),
.mode (mode),
.result (result),
.overflow (overflow)
);
initial begin
// 初始化
a = 0; b = 0; mode = 0;
#10;
// 测试加法
$display("=== Testing Addition (mode=0) ===");
mode = 0;
a = 16'h0005; b = 16'h0003; #10; $display("5 + 3 = %h, overflow=%b", result, overflow);
a = 16'h7FFF; b = 16'h0001; #10; $display("32767 + 1 = %h (overflow expected)", result, overflow);
// 测试减法
$display("\n=== Testing Subtraction (mode=1) ===");
mode = 1;
a = 16'h0005; b = 16'h0003; #10; $display("5 - 3 = %h", result);
a = 16'h0003; b = 16'h0005; #10; $display("3 - 5 = %h (negative result in two's complement)", result);
a = 16'h8000; b = 16'h0001; #10; $display("-32768 - 1 = %h (overflow expected)", result, overflow);
// 测试补码边界
a = 16'hFFFF; b = 16'hFFFF; #10; $display("-1 - (-1) = %h", result);
$finish;
end
endmodule
运行这个测试,在波形查看器里,你可以清晰地看到当 mode 切换时,op2_modified 信号的变化(取反或不取反),以及 cin 的变化。同时,观察 result 和 overflow 标志,确保它们符合预期。特别是对于减法,要理解结果是二进制补码形式,对于 3-5 这样的负数结果,result 会显示为 0xFFFE(即-2的16位补码)。
4.3 性能优化与高级设计考量
如果你以为到这里就结束了,那在实际项目中可能会吃亏。行波进位/借位结构只是入门,它的延迟是O(N),N是位宽。对于高性能设计,我们必须考虑优化。
策略一:超前进位(Lookahead Carry)
这是最经典的加速方法。其思想是提前计算出每一位的进位(或借位),而不是等待前一位算完。通过逻辑公式直接根据所有输入位计算高位的进位。例如,对于一个4位块,我们可以直接写出 C4 关于 A[3:0], B[3:0] 和 C0 的逻辑表达式。这样,无论位宽多大,进位链的延迟被压缩到几级门延迟。我们可以用多个小型的超前进位加法器(如4位CLA)组合成更大的加法器,构成树形结构,这就是超前进位加法器(CLA)。将其中的加法核心换成我们之前的加减法转换逻辑,就得到了超前进位加减法器,速度极大提升。
策略二:选择进位加法器(Carry-Select Adder) 另一种思路是“预计算”。例如,将16位加法器分成4个4位小组。对每个小组,我们同时计算两套结果:一套假设来自低组的进位是0,另一套假设是1。当低组的真实进位产生后,我们只需要用一个多路选择器(MUX)快速选择正确的那套结果即可。这种方法通过增加面积(约一倍)来换取速度,是一种典型的面积换速度策略。在减法器中同样适用。
策略三:关注综合结果与面积权衡
当你把Verilog代码交给综合工具(如Design Compiler)时,工具会根据你指定的时序和面积约束进行优化。有时候,你手写的行波结构可能被工具自动优化成更并行的结构。关键在于给你的设计加上正确的约束。例如,如果你在代码中明确写出了 if (mode) result = a - b; else result = a + b; 这样的行为级描述,优秀的综合工具可能会识别出这是加减法操作,并自动调用工艺库中高度优化的加减法器IP核,其性能远超我们手写的代码。所以,在行为级描述清晰和结构级描述可控之间,需要根据设计阶段做权衡。
一个容易忽略的坑:溢出与借位标志的处理
对于加法,进位输出 Cout 在无符号数运算中表示溢出。对于减法,情况更复杂。当我们用 X + ~Y + 1 做减法时,最终加法器产生的 Cout 并不直接等于减法需要的“借位”。实际上,对于无符号数减法,真正的借位 Bout 应该是 ~Cout(即加法器进位输出取反)。而对于有符号数,我们更关心的是“溢出”标志,它判断结果是否超出了有符号数的表示范围。在上面的示例代码中,我给出了一个简化的溢出判断逻辑。在实际的ALU设计中,溢出和借位标志位的生成电路需要精心设计,它与数据表示(补码、原码)和操作类型紧密相关,是验证的重点之一。
从我多年的流片经验来看,初学者往往在功能仿真阶段就止步了,但真正的挑战在于后仿真、时序验证和物理实现。一个能在1GHz频率下稳定工作的32位ALU,其进位链的设计绝非简单的行波结构。你需要考虑时钟偏差、信号完整性、以及功耗。现代处理器通常使用更复杂的结构,如并行前缀加法器(如Kogge-Stone、Brent-Kung),它们能以对数级延迟完成N位加法,是高性能计算核心的标配。理解从半减器、全减器到行波借位减法器这条路径,是为了打下坚实的理论基础,让你在未来面对这些复杂优化时,能清楚地知道它们到底在优化什么,以及为什么要这样优化。
303



被折叠的 条评论
为什么被折叠?



