第四章 组合逻辑设计:从基本门到复杂功能

组合逻辑,说白了就是「输入一变,输出立马变」的电路。没有时钟,没有记忆,纯粹靠逻辑门搭出来的功能模块。我在刚接触FPGA时,觉得这玩意儿太简单了——不就是与或非嘛。但真正做项目才发现,组合逻辑里的坑,一个比一个深。

4.1 基本门电路实现

FPGA里的基本门,其实不是真的用晶体管搭的。它用的是查找表(LUT)。但咱们写代码时,还是按传统逻辑门来思考。

看个最简单的例子:

// 基本门电路
module basic_gates(
    input  a, b,
    output y_and,
    output y_or,
    output y_not,
    output y_nand,
    output y_nor,
    output y_xor
);

    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;   // 异或门

endmodule

嗯,这里要注意:Verilog里的位运算符和逻辑运算符是两码事。&是位与,&&是逻辑与。我见过有人把这两个搞混,仿真没问题,上板子就出bug。

我的习惯:写组合逻辑时,一律用assign语句。过程块(always @(*))虽然也能写,但容易不小心生成锁存器。assign更干净,更直观。

4.2 多路选择器

多路选择器,简称MUX。这东西在FPGA里太常见了。数据选择、地址译码、状态跳转,哪哪都有它。

实现方式有两种:

// 方式一:三目运算符
module mux_2to1(
    input  [7:0] data0, data1,
    input        sel,
    output [7:0] y
);
    assign y = sel ? data1 : data0;
endmodule

// 方式二:case语句
module mux_4to1(
    input  [7:0] data0, data1, data2, data3,
    input  [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
我曾经踩过的坑:写case语句时漏了default分支。仿真时sel只有00~11四种值,看着没问题。结果综合出来一堆锁存器,资源占用翻了一倍。从那以后,我写case必加default,哪怕你觉得「不可能出现其他值」。

为什么会这样?因为综合工具很死板。它看到case分支没覆盖全,就会认为「某些输入组合下输出要保持原值」,于是给你生成锁存器。你想想看,这根本不是你想要的行为。

4.3 加法器

加法器是算术运算的基础。FPGA里做加法,最简单就是用+号。但如果你想深入理解,就得看看全加器是怎么搭的。

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

// 4位加法器(行波进位)
module adder_4bit(
    input  [3:0] a, b,
    input        cin,
    output [3:0] sum,
    output       cout
);
    wire [3:0] c;
    
    full_adder u0(.a(a[0]), .b(b[0]), .cin(cin),   .sum(sum[0]), .cout(c[0]));
    full_adder u1(.a(a[1]), .b(b[1]), .cin(c[0]),  .sum(sum[1]), .cout(c[1]));
    full_adder u2(.a(a[2]), .b(b[2]), .cin(c[1]),  .sum(sum[2]), .cout(c[2]));
    full_adder u3(.a(a[3]), .b(b[3]), .cin(c[2]),  .sum(sum[3]), .cout(c[3]));
    
    assign cout = c[3];
endmodule

行波加法器结构简单,但速度慢。为什么呢?进位是一级一级传下去的。4位还好,32位的话,进位链就太长了。我在项目中用过一次32位行波加法器,时序跑不到200MHz,后来换成超前进位加法器才解决。

实际项目建议:除非你是在教学或做低速设计,否则直接用 + 号。Vivado综合工具会自动选择最优的加法器结构。你手动搭的行波加法器,大概率没工具生成的快。

4.4 编码器与译码器

编码器和译码器,这俩是互逆操作。编码器把多个输入线压缩成少数几位,译码器把少数几位展开成多个输出线。

// 8-3编码器(优先编码器)
module priority_encoder_8to3(
    input  [7:0] in,
    output [2:0] out,
    output       valid
);
    assign out = in[7] ? 3'd7 :
                 in[6] ? 3'd6 :
                 in[5] ? 3'd5 :
                 in[4] ? 3'd4 :
                 in[3] ? 3'd3 :
                 in[2] ? 3'd2 :
                 in[1] ? 3'd1 :
                 in[0] ? 3'd0 : 3'd0;
    assign valid = |in;  // 只要有一个输入为1,valid就为1
endmodule

// 3-8译码器
module decoder_3to8(
    input  [2:0] in,
    input        enable,
    output reg [7:0] out
);
    always @(*) begin
        out = 8'b0;
        if(enable) begin
            case(in)
                3'b000: out[0] = 1'b1;
                3'b001: out[1] = 1'b1;
                3'b010: out[2] = 1'b1;
                3'b011: out[3] = 1'b1;
                3'b100: out[4] = 1'b1;
                3'b101: out[5] = 1'b1;
                3'b110: out[6] = 1'b1;
                3'b111: out[7] = 1'b1;
            endcase
        end
    end
endmodule

优先编码器在实际中更常用。比如按键检测,多个按键同时按下时,优先编码器只响应优先级最高的那个。我做过一个键盘扫描模块,用的就是优先编码器,省了不少逻辑资源。

知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以看到,基本门是基础,往上搭出MUX和加法器,再往上就是编码器译码器这些功能模块。

组合逻辑设计知识体系 基本门电路 与、或、非、与非、或非、异或 多路选择器 2选1、4选1、case实现 加法器 全加器、行波进位 编码器 8-3优先编码器 译码器 3-8译码器 算术运算 加法器扩展应用 工程实践要点 避免锁存器 · 善用assign · 优先使用+号 从基本门到复杂功能,层层递进

避坑指南

做组合逻辑设计,有几个坑我反复踩过,今天一并告诉你:

  • 锁存器陷阱:always @(*) 里 if 没有 else,case 没有 default,都会生成锁存器。综合报告里看到 "Latch inferred" 就要警惕。
  • 组合逻辑环路:输出反馈到输入,中间没有寄存器。这种电路仿真可能振荡,综合可能报错。我见过有人把 assign a = b & c; assign c = a | d; 这么写,综合出来直接崩了。
  • 位宽不匹配:比如 4 位加 4 位,结果用 4 位存,进位就丢了。要么扩位宽,要么单独处理进位。
  • 敏感列表不全:always @(a or b) 里用了 c,但敏感列表没写 c。仿真时 c 变了不会触发 always,结果就错了。现在我都用 always @(*),省心。
我的小技巧:写完组合逻辑后,在Vivado里跑一下「Report Utilization」,看看有没有意外的Latch。再跑一下「Report Timing」,看看组合逻辑路径的延迟。如果延迟太大,考虑插入流水线寄存器。

组合逻辑是FPGA设计的基石。别看它简单,真正用好用对,需要积累经验。我建议你多写多练,把每个模块都自己敲一遍。遇到问题别怕,那是成长最快的时候。

可可精品资料库