4. 组合逻辑设计:加法器、多路选择器、译码器、编码器的设计与实现

组合逻辑,说白了就是没有记忆功能的电路。输入一变,输出立马跟着变,不带半点延迟(当然,实际物理门电路是有延迟的,这个我们后面时序分析再聊)。今天咱们就把加法器、多路选择器、译码器、编码器这四个最常用的组合逻辑模块,从原理到实现,一次性讲透。

4.1 加法器:从半加器到超前进位

加法器是算术运算的基石。CPU里的ALU,说白了就是一堆加法器加上控制逻辑。我刚开始做设计时,总觉得加法器太简单,不就是个全加器嘛。直到有一次做FFT处理器,发现进位链太长导致时序不收敛,才意识到加法器的设计大有讲究。

4.1.1 半加器与全加器

先看最基本的半加器。它只加两个1位二进制数,不考虑低位进位。

ABS(和)C(进位)
0000
0110
1010
1101

逻辑表达式很简单:S = A ⊕ B,C = A & B。

全加器则多了一个进位输入Cin。三个输入,两个输出。真值表我就不列了,直接给表达式:

S = A ⊕ B ⊕ Cin
Cout = (A & B) | (A & Cin) | (B & Cin)

嗯,这里要注意:全加器的进位逻辑,用卡诺图化简后就是上面这个形式。我见过有人直接用(A+B+Cin) >= 2来判断,虽然功能对,但综合出来的面积会大一些。

4.1.2 行波进位加法器

把N个全加器串起来,就构成了行波进位加法器(Ripple Carry Adder)。每个全加器的Cout接到下一个的Cin。

// 4位行波进位加法器
module rca_4bit (
    input  [3:0] a, b,
    input        cin,
    output [3:0] sum,
    output       cout
);
    wire c1, c2, c3;
    
    full_adder fa0 (.a(a[0]), .b(b[0]), .cin(cin),  .sum(sum[0]), .cout(c1));
    full_adder fa1 (.a(a[1]), .b(b[1]), .cin(c1),   .sum(sum[1]), .cout(c2));
    full_adder fa2 (.a(a[2]), .b(b[2]), .cin(c2),   .sum(sum[2]), .cout(c3));
    full_adder fa3 (.a(a[3]), .b(b[3]), .cin(c3),   .sum(sum[3]), .cout(cout));
endmodule

这种结构最大的问题是什么?进位要一级一级传。32位的加法器,最坏情况下进位要经过32级门延迟。我曾经在一个项目中用行波进位做64位加法,结果时序分析报告一片红。后来改成超前进位,频率直接从200MHz干到了500MHz。

避坑指南:行波进位加法器虽然面积小,但位宽超过16位时,建议慎用。我曾经在FPGA上做过一个32位的行波进位加法器,综合后最高只能跑到80MHz,改成超前进位后直接飙到200MHz+。

4.1.3 超前进位加法器

超前进位(Carry Lookahead)的核心思想:提前算出进位,不用等前一级算完。

定义两个中间信号:

  • 生成信号 G:G = A & B,只要A和B都是1,就一定产生进位
  • 传播信号 P:P = A ⊕ B,只要A和B有一个是1,进位就能传过去

那么进位公式可以写成:

C1 = G0 | (P0 & C0)
C2 = G1 | (P1 & G0) | (P1 & P0 & C0)
C3 = G2 | (P2 & G1) | (P2 & P1 & G0) | (P2 & P1 & P0 & C0)
...

你看,每个进位只跟输入和C0有关,跟中间进位无关。这就是超前进位的精髓。

// 4位超前进位加法器
module cla_4bit (
    input  [3:0] a, b,
    input        cin,
    output [3:0] sum,
    output       cout
);
    wire [3:0] g, p, c;
    
    assign g = a & b;
    assign p = a ^ b;
    
    assign c[0] = cin;
    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 cout = g[3] | (p[3] & g[2]) | (p[3] & p[2] & g[1]) | (p[3] & p[2] & p[1] & g[0]) | (p[3] & p[2] & p[1] & p[0] & c[0]);
    
    assign sum = p ^ c;
endmodule
个人经验:超前进位加法器的扇出问题要注意。你看C3的表达式,P0、P1、P2、P3都要扇出到C3的与门。位宽一大,扇出就爆炸。我一般超过16位就用两级超前进位,或者直接用进位选择加法器。

4.2 多路选择器:数据通道的开关

多路选择器(MUX),说白了就是数据选择开关。2选1、4选1、8选1...选通信号决定哪路数据通过。

4.2.1 2选1多路选择器

module mux2_1 (
    input       a, b,
    input       sel,
    output reg  y
);
    always @(*) begin
        case (sel)
            1'b0: y = a;
            1'b1: y = b;
        endcase
    end
endmodule

也可以用三目运算符:assign y = sel ? b : a; 综合出来的结果是一样的。

4.2.2 多路选择器的级联与实现

4选1可以用3个2选1搭起来,也可以用case语句直接写。我个人习惯用case,代码可读性更好。

module mux4_1 (
    input       [3:0] data,
    input       [1:0] sel,
    output reg  y
);
    always @(*) begin
        case (sel)
            2'b00:   y = data[0];
            2'b01:   y = data[1];
            2'b10:   y = data[2];
            2'b11:   y = data[3];
            default: y = 1'b0;
        endcase
    end
endmodule
注意:case语句一定要写default分支。我曾经见过一个设计,没写default,结果综合出来一堆锁存器。为什么?因为case没覆盖所有情况,综合工具默认保持原值,就给你插了latch。这坑我踩过,后来写case必带default。

4.3 译码器:地址到选通的映射

译码器,把N位输入翻译成2^N位输出,每次只有一位有效。最典型的就是3-8译码器。

4.3.1 3-8译码器实现

module decoder_3to8 (
    input  [2:0] addr,
    output reg [7:0] y
);
    always @(*) begin
        y = 8'b0;
        case (addr)
            3'b000: y[0] = 1'b1;
            3'b001: y[1] = 1'b1;
            3'b010: y[2] = 1'b1;
            3'b011: y[3] = 1'b1;
            3'b100: y[4] = 1'b1;
            3'b101: y[5] = 1'b1;
            3'b110: y[6] = 1'b1;
            3'b111: y[7] = 1'b1;
        endcase
    end
endmodule

也可以用移位操作:assign y = 1'b1 << addr; 这个写法更简洁,但要注意addr不能越界。

4.3.2 带使能的译码器

实际项目中,译码器通常带使能信号。使能无效时,所有输出为0。

module decoder_3to8_en (
    input  [2:0] addr,
    input        en,
    output reg [7:0] y
);
    always @(*) begin
        y = 8'b0;
        if (en) begin
            case (addr)
                3'b000: y[0] = 1'b1;
                3'b001: y[1] = 1'b1;
                // ... 省略中间
                3'b111: y[7] = 1'b1;
            endcase
        end
    end
endmodule
经验之谈:译码器在地址译码、七段数码管显示、指令译码中大量使用。我做过一个RISC-V处理器,指令译码就是一堆译码器加控制逻辑。注意译码器的输出是独热码(one-hot),这个特性在后续的优先级编码器中会用到。

4.4 编码器:把独热码变成二进制

编码器是译码器的逆过程。输入是2^N位的独热码,输出是N位二进制数。

4.4.1 8-3编码器

module encoder_8to3 (
    input  [7:0] y,
    output reg [2:0] addr
);
    always @(*) begin
        case (y)
            8'b00000001: addr = 3'b000;
            8'b00000010: addr = 3'b001;
            8'b00000100: addr = 3'b010;
            8'b00001000: addr = 3'b011;
            8'b00010000: addr = 3'b100;
            8'b00100000: addr = 3'b101;
            8'b01000000: addr = 3'b110;
            8'b10000000: addr = 3'b111;
            default:     addr = 3'b000;
        endcase
    end
endmodule

4.4.2 优先级编码器

实际应用中,输入可能不是严格的独热码。比如多个按键同时按下,怎么办?优先级编码器应运而生:优先级高的输入优先编码。

module priority_encoder_8to3 (
    input  [7:0] y,
    output reg [2:0] addr,
    output reg       valid
);
    always @(*) begin
        valid = 1'b1;
        casez (y)
            8'b1???????: addr = 3'b111;
            8'b01??????: addr = 3'b110;
            8'b001?????: addr = 3'b101;
            8'b0001????: addr = 3'b100;
            8'b00001???: addr = 3'b011;
            8'b000001??: addr = 3'b010;
            8'b0000001?: addr = 3'b001;
            8'b00000001: addr = 3'b000;
            default: begin
                addr = 3'b000;
                valid = 1'b0;
            end
        endcase
    end
endmodule

注意这里用了casez,问号表示不关心该位。优先级从高到低:y[7] > y[6] > ... > y[0]。

核心要点:
  • 加法器:行波进位面积小但速度慢,超前进位速度快但面积大
  • 多路选择器:case语句要写default,避免生成锁存器
  • 译码器:输出是独热码,常用于地址译码和显示驱动
  • 编码器:优先级编码器用casez实现,问号表示不关心位

好了,这四种组合逻辑模块,是数字电路设计的基本功。你想想看,一个CPU里,指令译码用译码器,寄存器堆选通用多路选择器,ALU里全是加法器,中断控制器里用优先级编码器。把这些模块吃透了,复杂设计也就水到渠成。

下一章我们讲时序逻辑,触发器、计数器、移位寄存器。到时候我会分享一个我做SPI控制器时遇到的坑——计数器没考虑格雷码转换,导致跨时钟域采样出错。嗯,到时候细聊。