4、组合逻辑设计:基本门电路实现(与或非)、多路选择器、加法器、编码器与译码器

组合逻辑,说白了就是电路里最直来直去的那部分——输入一变,输出立马跟着变,不带半点延迟记忆。我刚开始用紫光同创的Pango Design Suite时,第一件事就是把基本门电路跑通。你想想看,再复杂的数字系统,底层都是这些与或非门搭起来的。

4.1 基本门电路:与、或、非

在FPGA里,基本门电路其实不是真的用分立晶体管搭的,而是通过查找表(LUT)来实现。但作为设计者,我们直接用Verilog描述就行。

先看最简单的——非门。我习惯叫它反相器:

// 非门实现
module not_gate(
    input  wire a,
    output wire y
);
    assign y = ~a;
endmodule

与门和或门就更直接了:

// 与门
module and_gate(
    input  wire a, b,
    output wire y
);
    assign y = a & b;
endmodule

// 或门
module or_gate(
    input  wire a, b,
    output wire y
);
    assign y = a | b;
endmodule

个人习惯:我写组合逻辑时,能用assign尽量用assign。为什么?因为代码更简洁,综合器也更容易优化。但如果是复杂逻辑,我会改用always @(*)块,方便加中间变量做调试。

嗯,这里要注意——紫光同创的Pango综合工具对连续赋值语句支持得很好,但如果你用always块,记得把敏感列表写完整。我见过有人漏了敏感信号,结果综合出来的电路和仿真对不上,查了半天才发现问题。

4.2 多路选择器:数据选择的艺术

多路选择器(MUX)在项目中太常用了。比如你要从多个传感器数据中选一个送给处理模块,或者做寄存器写回路径选择。

最简单的二选一MUX:

// 2选1多路选择器
module mux2to1(
    input  wire [7:0] data0, data1,
    input  wire       sel,
    output reg  [7:0] y
);
    always @(*) begin
        case(sel)
            1'b0: y = data0;
            1'b1: y = data1;
            default: y = data0;
        endcase
    end
endmodule

我在项目中遇到过一个问题:用case语句写MUX时,一定要加default分支。为什么?因为如果不加,综合器可能会推断出锁存器。锁存器这东西,在组合逻辑里出现就是个大坑——时序分析会变得一团糟。

四选一MUX也很常见:

// 4选1多路选择器
module mux4to1(
    input  wire [7:0] data0, data1, data2, 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 = data0;
        endcase
    end
endmodule

避坑指南:我曾经在紫光同创的PDS项目里,用三目运算符写了一个8选1的MUX,结果综合出来的LUT数量比用case多了30%。后来我查了文档才知道,Pango对case语句的优化更好。所以,复杂MUX建议用case,别偷懒用三目。

4.3 加法器:从半加器到全加器

加法器是算术运算的基础。半加器只考虑两个输入相加,不考虑进位输入:

// 半加器
module half_adder(
    input  wire a, b,
    output wire sum, cout
);
    assign sum  = a ^ b;   // 异或得到和
    assign cout = a & b;   // 与得到进位
endmodule

全加器则多了进位输入:

// 全加器
module full_adder(
    input  wire a, b, cin,
    output wire sum, cout
);
    assign sum  = a ^ b ^ cin;
    assign cout = (a & b) | (a & cin) | (b & cin);
endmodule

你想想看,如果要做8位加法器,最简单的方法就是把8个全加器串起来——这就是行波进位加法器。但问题是,进位要一级一级传,延迟会很大。

// 8位行波进位加法器
module ripple_carry_adder_8bit(
    input  wire [7:0] a, b,
    input  wire       cin,
    output wire [7:0] sum,
    output wire       cout
);
    wire [7:0] c;
    full_adder fa0 (a[0], b[0], cin,    sum[0], c[0]);
    full_adder fa1 (a[1], b[1], c[0],   sum[1], c[1]);
    full_adder fa2 (a[2], b[2], c[1],   sum[2], c[2]);
    full_adder fa3 (a[3], b[3], c[2],   sum[3], c[3]);
    full_adder fa4 (a[4], b[4], c[3],   sum[4], c[4]);
    full_adder fa5 (a[5], b[5], c[4],   sum[5], c[5]);
    full_adder fa6 (a[6], b[6], c[5],   sum[6], c[6]);
    full_adder fa7 (a[7], b[7], c[6],   sum[7], c[7]);
    assign cout = c[7];
endmodule

实际项目经验:我在做一款高速数据采集卡时,用了行波进位加法器做地址累加,结果时序跑不到200MHz。后来换成紫光同创IP核里的超前进位加法器,频率直接飙到350MHz。所以,如果对速度有要求,别自己手写加法器,直接用IP核或者用“+”运算符让综合器去优化。

4.4 编码器与译码器

编码器,说白了就是把多个输入信号压缩成更少的输出位。比如8线-3线优先编码器:

// 8线-3线优先编码器
module priority_encoder_8to3(
    input  wire [7:0] din,
    output reg  [2:0] dout,
    output reg        valid
);
    always @(*) begin
        casez(din)
            8'b1???????: dout = 3'd7;
            8'b01??????: dout = 3'd6;
            8'b001?????: dout = 3'd5;
            8'b0001????: dout = 3'd4;
            8'b00001???: dout = 3'd3;
            8'b000001??: dout = 3'd2;
            8'b0000001?: dout = 3'd1;
            8'b00000001: dout = 3'd0;
            default:     dout = 3'd0;
        endcase
        valid = |din;  // 只要有一位输入有效,valid就为1
    end
endmodule

译码器正好反过来,把少量输入展开成大量输出。3线-8线译码器:

// 3线-8线译码器
module decoder_3to8(
    input  wire [2:0] addr,
    output reg  [7:0] dout
);
    always @(*) begin
        case(addr)
            3'b000: dout = 8'b00000001;
            3'b001: dout = 8'b00000010;
            3'b010: dout = 8'b00000100;
            3'b011: dout = 8'b00001000;
            3'b100: dout = 8'b00010000;
            3'b101: dout = 8'b00100000;
            3'b110: dout = 8'b01000000;
            3'b111: dout = 8'b10000000;
            default: dout = 8'b00000000;
        endcase
    end
endmodule

我建议:写译码器时,可以用移位操作来简化代码——dout = 1'b1 << addr。这样不仅代码短,而且综合出来的结果和case语句一样。但要注意,如果addr超出范围,移位操作可能会出问题,所以最好加个范围检查。

4.5 组合逻辑的常见陷阱

做组合逻辑设计,有几个坑我踩过,分享给你:

  • 锁存器推断:在always块里,如果某个分支没给所有输出赋值,综合器就会推断出锁存器。解决办法是——要么在always块开头给所有输出赋默认值,要么确保case语句有default分支。
  • 组合逻辑环路:千万别把组合逻辑的输出直接反馈到自己的输入。我曾经在调试一个状态机时,不小心把组合逻辑的输出又打回了输入,结果仿真直接卡死,综合也报错。
  • 毛刺问题:组合逻辑的输出可能会有毛刺。如果后续接的是时钟触发的寄存器,问题不大。但如果直接驱动异步复位或时钟使能,那就麻烦了。我一般会在关键路径上加个输出寄存器来消除毛刺。

曾经踩过的坑:我在紫光同创的FPGA上做LED扫描显示时,直接用组合逻辑输出片选信号,结果LED老是闪烁。后来用示波器一看,片选信号上全是毛刺。加了一级寄存器打一拍,问题就解决了。所以,驱动外部引脚的信号,最好都经过寄存器输出。

好了,这一章的内容就这些。组合逻辑是数字设计的基础,看似简单,但细节很多。建议你拿到紫光同创的开发板后,先把这些基本门电路、MUX、加法器、编码器译码器都跑一遍仿真,再下载到板子上看看实际效果。下一章我们会讲时序逻辑,那才是FPGA设计的重头戏。