3. Verilog基础与流水线设计:组合逻辑与时序逻辑、阻塞与非阻塞赋值、流水线设计思想、一个简单的加法器流水线实例

各位同学,欢迎来到第三章。这一章可以说是整个FPGA设计的「内功心法」。很多新手写Verilog,代码能跑,但综合出来的电路要么跑不快,要么逻辑混乱。说白了,就是没搞懂组合逻辑和时序逻辑的区别,也没弄明白阻塞和非阻塞赋值到底该怎么用。

我个人习惯,在开始一个新项目前,一定会先画一张「数据流图」。这张图决定了你的设计是「组合逻辑」还是「时序逻辑」。嗯,我们先从最基础的概念说起。

3.1 组合逻辑与时序逻辑

组合逻辑,说白了就是「输入一变,输出立马变」。它没有记忆功能,输出只取决于当前的输入。比如一个简单的与门、或门,或者一个加法器。

时序逻辑,则是有「记忆」的。它的输出不仅取决于当前输入,还取决于之前的状态。最典型的代表就是触发器(Flip-Flop)。

我在项目中遇到过一个问题:一个同事写了一个计数器,用了组合逻辑的写法,结果仿真没问题,上板子后计数乱跳。为什么?因为组合逻辑对毛刺太敏感了。你想想看,一个组合逻辑的输出,只要输入有抖动,输出就会跟着抖。而时序逻辑只在时钟沿采样,天然就带了一个「滤波器」。

核心区别一句话总结:

  • 组合逻辑:没有时钟,输出随输入变
  • 时序逻辑:有时钟,输出只在时钟沿更新

我们来看一个简单的例子。这是一个组合逻辑的加法器:

// 组合逻辑加法器
module comb_adder (
    input  [7:0] a,
    input  [7:0] b,
    output [8:0] sum
);
    assign sum = a + b;
endmodule

再看一个时序逻辑的加法器(带寄存器输出):

// 时序逻辑加法器
module seq_adder (
    input          clk,
    input  [7:0]   a,
    input  [7:0]   b,
    output reg [8:0] sum
);
    always @(posedge clk) begin
        sum <= a + b;
    end
endmodule

看到了吗?区别就在于那个 always @(posedge clk)。时序逻辑的加法结果,只在时钟上升沿才被锁存到 sum 里。这能有效过滤掉输入信号的毛刺。

3.2 阻塞与非阻塞赋值

这块是新手最容易踩坑的地方。我曾经见过一个老工程师,写了十年Verilog,还在 always 块里混用阻塞和非阻塞赋值,结果综合出来的电路跟他想的完全不一样。

阻塞赋值(=):说白了就是「先算完,再往下走」。它是顺序执行的,就像C语言一样。阻塞赋值一般用于组合逻辑的 always 块。

非阻塞赋值(<=):它是「大家一起算,算完再一起赋值」。所有非阻塞赋值在同一个时钟沿是并行执行的。非阻塞赋值一般用于时序逻辑的 always 块。

避坑指南:

我曾经在一个项目中,把非阻塞赋值用在了组合逻辑里。结果仿真波形看起来是对的,但综合工具报了一堆警告,最后生成的电路面积大了三倍。从那以后,我给自己定了个规矩:

  • 组合逻辑 always 块:用阻塞赋值(=)
  • 时序逻辑 always 块:用非阻塞赋值(<=)

这个规矩,我建议你也记住。

我们来看一个对比的例子。假设我们要交换两个寄存器的值:

// 错误写法:用阻塞赋值交换
always @(posedge clk) begin
    a = b;  // a 先被赋值为 b
    b = a;  // b 被赋值为 a,但此时 a 已经是 b 的值了
end
// 结果:a 和 b 都变成了 b 原来的值,交换失败!
// 正确写法:用非阻塞赋值交换
always @(posedge clk) begin
    a <= b;  // 先计算 b 的值
    b <= a;  // 先计算 a 的值
end
// 结果:a 得到 b 的值,b 得到 a 的值,完美交换!

为什么会这样?因为非阻塞赋值在时钟沿来临时,会先「读取」所有右边的值,然后再「写入」左边的寄存器。这就保证了交换操作的原子性。

3.3 流水线设计思想

流水线,说白了就是「把一个大任务拆成多个小任务,每个小任务用一级寄存器隔开」。这样做的目的是提高时钟频率。

你想想看,一个复杂的组合逻辑,从输入到输出可能要经过很多级门电路。门电路越多,延迟就越大。如果这个延迟超过了时钟周期,那你的电路就跑不起来。

流水线的思路就是:在组合逻辑的中间插入寄存器,把长路径切成短路径。这样每级路径的延迟都变小了,时钟频率就能提上去。

流水线的代价:

  • 增加了延迟(Latency):数据需要多几个时钟周期才能输出
  • 增加了面积:每级流水线都需要额外的寄存器
  • 但换来了更高的吞吐量(Throughput):每个时钟都能输出一个结果

我在做量化交易系统时,对延迟极其敏感。但即便如此,我们仍然会在某些关键路径上使用流水线。为什么?因为如果不做流水线,时钟频率上不去,整个系统的吞吐量反而更低。这是一个「延迟 vs 吞吐量」的权衡。

3.4 一个简单的加法器流水线实例

好了,理论说完了,我们来看一个实际的例子。假设我们要做一个 4 级流水线的加法器,把 8 个 8 位数相加。

先画一张流水线结构图:

4级流水线加法器结构图 输入寄存器 加法器1 流水线寄存器 加法器2 输出寄存器 a0,b0 s0 s0_reg s1 clk 总延迟:4个时钟周期 吞吐量:每个时钟输出一个结果 输入 输出

好,现在我们来看代码实现。这个例子把 8 个 8 位数两两相加,经过 4 级流水线得到最终结果。

// 4级流水线加法器
module pipeline_adder (
    input          clk,
    input          rst_n,
    input  [7:0]   data_in [0:7],  // 8个输入数据
    output reg [11:0] result       // 最终结果
);

    // 第1级流水线:输入寄存器
    reg [7:0] d0 [0:7];
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            for (int i = 0; i < 8; i++) d0[i] <= 8'd0;
        end else begin
            for (int i = 0; i < 8; i++) d0[i] <= data_in[i];
        end
    end

    // 第2级流水线:第1次加法(4个加法器并行)
    reg [8:0] s1 [0:3];
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            for (int i = 0; i < 4; i++) s1[i] <= 9'd0;
        end else begin
            for (int i = 0; i < 4; i++) s1[i] <= d0[2*i] + d0[2*i+1];
        end
    end

    // 第3级流水线:流水线寄存器 + 第2次加法(2个加法器并行)
    reg [8:0] s2 [0:1];
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            for (int i = 0; i < 2; i++) s2[i] <= 9'd0;
        end else begin
            for (int i = 0; i < 2; i++) s2[i] <= s1[2*i] + s1[2*i+1];
        end
    end

    // 第4级流水线:输出寄存器 + 最后一次加法
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            result <= 12'd0;
        end else begin
            result <= s2[0] + s2[1];
        end
    end

endmodule

个人经验:

我在写流水线时,习惯给每一级都加上寄存器。这样虽然多用了几个触发器,但时序收敛会容易很多。尤其是在做高频交易系统时,时钟频率动辄 500MHz 以上,每一级组合逻辑的延迟都必须严格控制。

另外,我建议你在仿真时,把每一级流水线的中间结果都拉出来看看。这样能快速定位问题。我曾经就因为少写了一级寄存器,导致数据错位,查了整整两天才找到原因。

好了,这一章的内容就到这里。记住三个核心点:组合逻辑 vs 时序逻辑、阻塞 vs 非阻塞赋值、流水线的思想。这些是FPGA设计的基石,搞懂了它们,后面的路就好走了。


公众号:蓝海资料掘金营,微信deep3321