2. FPGA基础回顾

好,我们正式开始第二讲。这一章是地基中的地基——Verilog基础与组合逻辑设计。别小看这些基础,我见过太多工程师在阻塞与非阻塞赋值上栽跟头,一查就是几个小时。咱们今天就把这些概念彻底理清楚。

2.1 模块化设计思想:把大问题拆成小积木

FPGA设计说白了就是搭积木。一个复杂的RISC-V处理器,你不可能在一个文件里写完所有逻辑。模块化设计就是把这个大系统拆成一个个功能独立的小模块,每个模块负责一件事。

我个人习惯这样划分模块:

  • 每个模块只做一件事——比如加法器只管加,多路选择器只管选
  • 接口清晰——输入输出定义明确,不搞隐式连接
  • 可复用——写一次,到处例化

举个例子,一个4位加法器模块的接口定义:

module adder_4bit (
    input  [3:0] a,      // 输入a
    input  [3:0] b,      // 输入b
    input        cin,    // 进位输入
    output [3:0] sum,    // 和
    output       cout    // 进位输出
);
    assign {cout, sum} = a + b + cin;
endmodule

你看,接口一目了然。别人拿到这个模块,不用看内部实现就知道怎么用。这就是模块化的价值。

我的经验:模块的端口命名要统一规范。我在项目中吃过亏——有人用"data_in",有人用"din",还有人用"input_data"。结果集成时对端口对到眼花。建议团队统一命名风格,比如输入用"i_"前缀,输出用"o_"前缀。

2.2 assign语句:连续赋值的精髓

assign是组合逻辑最常用的语句。它描述的是线网的连续驱动关系——只要右边表达式变化,左边立即更新。

说白了,assign就像一根硬连线,左边和右边时刻保持同步。

// 基本用法
assign y = a & b;          // 与门
assign z = (sel) ? a : b;  // 二选一多路器

// 位拼接
assign {cout, sum} = a + b;

这里要注意:assign的左边必须是wire类型,不能是reg。我刚开始学的时候总搞混,后来记住一句话就再也没错过——"assign赋值给线网,always赋值给寄存器"。

2.3 always块:敏感列表与组合逻辑描述

always块是Verilog的另一个核心。它既可以描述组合逻辑,也可以描述时序逻辑。区别在于敏感列表怎么写。

对于组合逻辑,敏感列表里要包含所有输入信号:

// 组合逻辑的always块
always @(*) begin
    if (sel)
        y = a;
    else
        y = b;
end

这里的@(*)表示"对所有输入信号变化敏感"。我建议组合逻辑一律用@(*),别手动列信号——万一漏了一个,仿真和综合结果就不一致了。我曾经因为这个坑,花了整整一个下午排查一个"看起来没问题但仿真就是不对"的bug。

重要提醒:组合逻辑的always块里,所有分支必须覆盖完整。如果漏掉某个条件,综合器会推断出锁存器(latch)。锁存器在FPGA里通常不是你想要的东西,它会导致时序问题和资源浪费。

2.4 阻塞赋值与非阻塞赋值:一字之差,天壤之别

这是Verilog里最容易踩的坑,没有之一。我面试FPGA工程师时,必问这个问题。

特性 阻塞赋值 (=) 非阻塞赋值 (<=)
执行顺序 顺序执行,阻塞后续语句 并行执行,不阻塞后续语句
适用场景 组合逻辑(always块内) 时序逻辑(always @(posedge clk))
硬件行为 立即更新 在时钟沿统一更新
典型错误 在时序逻辑中用阻塞赋值 在组合逻辑中用非阻塞赋值

记住这个黄金法则:

组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。

这个规则不要打破,除非你非常清楚自己在做什么。

为什么?我举个例子你就明白了:

// 错误示例:时序逻辑中用阻塞赋值
always @(posedge clk) begin
    a = b;
    c = a;  // 这里c拿到的是a更新后的值,不是a在时钟沿前的值
end

// 正确示例:时序逻辑中用非阻塞赋值
always @(posedge clk) begin
    a <= b;
    c <= a;  // 这里c拿到的是a在时钟沿前的值,符合寄存器行为
end

第一个例子综合出来的电路和你想象的可能完全不同。我见过有人把移位寄存器写成这样,结果仿真对了,上板子就乱套——因为综合器推断出的硬件和仿真模型不一致。

2.5 加法器实战:从半加器到全加器

加法器是CPU的核心运算单元。咱们从最简单的开始,一步步搭起来。

半加器:两个1位二进制数相加,产生和与进位。

module half_adder (
    input  a, b,
    output sum, cout
);
    assign sum  = a ^ b;   // 异或:和
    assign cout = a & b;   // 与:进位
endmodule

全加器:三个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

行波进位加法器:把多个全加器串联起来,前一级的进位输出接到后一级的进位输入。

module ripple_carry_adder_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

你看,这就是模块化的威力——写一个全加器,然后例化4次就得到一个4位加法器。

性能提示:行波进位加法器结构简单,但位宽越大延迟越大。在RISC-V处理器中,我们通常会用超前进位加法器(CLA)来加速。这个后面讲到CPU设计时会详细展开。

2.6 多路选择器实战:数据通路的选择开关

多路选择器(MUX)在数据通路中无处不在——寄存器堆的写地址选择、ALU的输入选择、访存数据的选择……

用assign实现二选一:

module mux2_1 (
    input  a, b, sel,
    output y
);
    assign y = sel ? b : a;
endmodule

用always块实现四选一:

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;  // 覆盖所有分支,避免latch
        endcase
    end
endmodule

这里有个细节:case语句一定要写default分支。不写的话,如果sel出现未覆盖的值(比如仿真中的'x'或'z'),综合器会推断出锁存器。

2.7 本章知识体系总览

下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速索引,复习时对照着看。

第二章知识体系总览 第二章:Verilog基础与组合逻辑设计 模块化设计思想 assign连续赋值 always过程块 接口清晰 功能独立 可复用 易维护 wire类型 连续驱动 组合逻辑 立即更新 敏感列表 @(*) 避免锁存器 阻塞赋值(=) vs 非阻塞赋值(<=) 加法器:半加器→全加器→行波进位 多路选择器:二选一 → 四选一 实战:数据通路中的MUX应用 核心原则:组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值 模块化设计:每个模块只做一件事,接口清晰

2.8 避坑指南:我踩过的那些坑

最后,分享几个我实际项目中遇到的教训:

  1. 敏感列表不完整——组合逻辑的always块里,如果敏感列表漏了某个输入,仿真和综合结果不一致。用@(*)就对了。
  2. 时序逻辑用阻塞赋值——仿真可能对,但综合出来的电路和你想象的不一样。我见过有人把移位寄存器写成阻塞赋值,结果上板子数据全乱套。
  3. case语句没有default——综合器会推断出锁存器。锁存器在FPGA里是"隐形炸弹",时序分析时很难发现。
  4. 模块端口不匹配——例化模块时,端口连接顺序搞错。建议用.port_name(signal)的显式连接方式,别用位置连接。