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数字系统设计的基石。

组合逻辑设计知识体系 基本门电路 与、或、非、与非、或非 多路选择器 2选1、4选1、8选1 译码器 3-8译码器、地址解码 加法器 半加器 → 全加器 → 多位加法器 乘法器 直接乘法、移位相加、查找表 仿真验证 测试平台、边界测试、随机测试 从基本门电路出发,逐步构建复杂组合逻辑模块 每个模块都需经过仿真验证,确保功能正确

嗯,这一章的内容就到这里。组合逻辑是数字设计的基础,看似简单,但真正用好、用巧,需要大量的实践积累。我建议你把每个模块都亲手写一遍、仿真一遍,看看综合出来的RTL视图长什么样。看得多了,你自然就能在写代码的时候,脑子里浮现出对应的门电路结构。

记住一句话:写Verilog不是在写软件,而是在画电路。每一行代码,最终都会变成实实在在的硬件。想清楚再下笔,比写完再改要高效得多。

专注资料整理