4. 行缓冲器设计

行缓冲器,说白了就是卷积加速器里的「数据搬运工」。

我刚开始做这个模块时,总觉得它不就是个FIFO嘛,有啥好设计的?结果第一次上板,图像边缘全是乱的,数据错位得一塌糊涂。嗯,从那以后我再也不敢小看这个模块了。

4.1 行缓冲器原理

卷积运算有个特点:它需要同时访问一个窗口内的所有像素。比如3x3卷积,你得同时拿到第1行、第2行、第3行的数据。但图像输入是一行一行串行进来的,怎么办?

行缓冲器就是干这个的——它把前面几行的数据缓存下来,等当前行数据到了,一起送给卷积计算单元。

核心思想:用空间换时间。用存储资源(BRAM/寄存器)缓存多行数据,实现并行访问。

我习惯把行缓冲器比作「流水线上的暂存区」。图像数据像流水一样流过,行缓冲器把前面几行的「样品」留下来,等当前行的「样品」到了,凑齐一个窗口再送检。

具体来说,对于一个KxK的卷积核,你需要缓存K-1行数据。为什么是K-1?因为当前行正在输入,只需要缓存它前面的K-1行就够了。

卷积核大小 需要缓存的行数 典型实现方式
3x3 2行 2个FIFO或移位寄存器
5x5 4行 4个FIFO或移位寄存器
7x7 6行 6个FIFO或移位寄存器

4.2 FIFO实现

行缓冲器最常用的实现方式就是FIFO。为什么选FIFO?因为它天然支持「先进先出」,正好符合图像数据逐行流动的特性。

我在项目中遇到过一个问题:用分布式RAM(LUT)搭FIFO,结果资源爆了。后来改用BRAM实现的FIFO,资源瞬间降下来。你想想看,一张1080p的图像,一行就有1920个像素,用LUT存?太奢侈了。

我的经验:行宽超过64像素,果断用BRAM FIFO。小于64像素,可以用移位寄存器或者分布式RAM FIFO,延迟更低。

FIFO的深度怎么定?很简单:等于图像的行宽。比如图像宽度是640,那FIFO深度就是640。为什么?因为你要存一整行数据,等下一行数据来了才能开始输出。

下面是一个典型的行缓冲器FIFO实现代码(Verilog):

module line_buffer #(
    parameter DATA_WIDTH = 8,
    parameter LINE_WIDTH = 640,
    parameter NUM_LINES  = 2  // 3x3卷积需要缓存2行
)(
    input  wire                clk,
    input  wire                rst_n,
    input  wire                wr_en,
    input  wire [DATA_WIDTH-1:0] din,
    output wire [DATA_WIDTH-1:0] dout_line0,  // 第0行输出
    output wire [DATA_WIDTH-1:0] dout_line1,  // 第1行输出
    output wire [DATA_WIDTH-1:0] dout_line2   // 第2行输出(当前行)
);

    // 实例化FIFO作为行缓冲器
    fifo #(
        .DATA_WIDTH(DATA_WIDTH),
        .FIFO_DEPTH(LINE_WIDTH)
    ) u_fifo_0 (
        .clk    (clk),
        .rst_n  (rst_n),
        .wr_en  (wr_en),
        .din    (din),
        .rd_en  (rd_en),
        .dout   (line0_data)
    );

    fifo #(
        .DATA_WIDTH(DATA_WIDTH),
        .FIFO_DEPTH(LINE_WIDTH)
    ) u_fifo_1 (
        .clk    (clk),
        .rst_n  (rst_n),
        .wr_en  (rd_en),
        .din    (line0_data),
        .rd_en  (rd_en_next),
        .dout   (line1_data)
    );

    // 当前行数据直接输入
    assign dout_line2 = din;

endmodule

注意看上面的代码:第一个FIFO写输入数据,第二个FIFO写第一个FIFO的输出。这样数据就一级一级往后传,形成了「流水线」效果。

注意:FIFO的读使能要小心控制。我曾经犯过一个错:读使能一直拉高,结果FIFO空的时候也在读,读出了垃圾数据,整张图像都是雪花点。排查了整整一天才找到问题。

4.3 数据对齐逻辑

行缓冲器把数据存好了,但还有个关键问题:怎么把三行数据同时送到卷积窗口?

说白了,就是数据对齐。你得保证第0行的第N个像素、第1行的第N个像素、第2行的第N个像素,在同一个时钟周期到达卷积计算单元。

我习惯用移位寄存器来做对齐。每个FIFO输出后面接一个K深度的移位寄存器,K是卷积核的宽度。这样每个时钟周期,移位寄存器里都保存着一个完整的窗口数据。

举个例子,3x3卷积的对齐逻辑:

// 数据对齐模块
module data_align #(
    parameter DATA_WIDTH = 8,
    parameter KERNEL_SIZE = 3
)(
    input  wire                clk,
    input  wire                rst_n,
    input  wire [DATA_WIDTH-1:0] line0_in,
    input  wire [DATA_WIDTH-1:0] line1_in,
    input  wire [DATA_WIDTH-1:0] line2_in,
    output wire [DATA_WIDTH-1:0] window [0:KERNEL_SIZE-1][0:KERNEL_SIZE-1]
);

    // 每行数据通过移位寄存器生成3个tap
    reg [DATA_WIDTH-1:0] shift_reg0 [0:KERNEL_SIZE-1];
    reg [DATA_WIDTH-1:0] shift_reg1 [0:KERNEL_SIZE-1];
    reg [DATA_WIDTH-1:0] shift_reg2 [0:KERNEL_SIZE-1];

    integer i;
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            for (i = 0; i < KERNEL_SIZE; i = i + 1) begin
                shift_reg0[i] <= 0;
                shift_reg1[i] <= 0;
                shift_reg2[i] <= 0;
            end
        end else begin
            // 数据右移
            shift_reg0[0] <= line0_in;
            shift_reg0[1] <= shift_reg0[0];
            shift_reg0[2] <= shift_reg0[1];

            shift_reg1[0] <= line1_in;
            shift_reg1[1] <= shift_reg1[0];
            shift_reg1[2] <= shift_reg1[1];

            shift_reg2[0] <= line2_in;
            shift_reg2[1] <= shift_reg2[0];
            shift_reg2[2] <= shift_reg2[1];
        end
    end

    // 输出对齐后的3x3窗口
    assign window[0][0] = shift_reg0[2];
    assign window[0][1] = shift_reg0[1];
    assign window[0][2] = shift_reg0[0];
    assign window[1][0] = shift_reg1[2];
    assign window[1][1] = shift_reg1[1];
    assign window[1][2] = shift_reg1[0];
    assign window[2][0] = shift_reg2[2];
    assign window[2][1] = shift_reg2[1];
    assign window[2][2] = shift_reg2[0];

endmodule

看到没?shift_reg0[2]是最早进来的数据,对应窗口的最左边。shift_reg0[0]是最新进来的数据,对应窗口的最右边。这样排列,刚好和卷积核的乘法顺序一致。

关键点:数据对齐的时序非常关键。每个时钟周期,移位寄存器更新一次,窗口数据就「滑动」一个像素。这就是滑动窗口的实现原理。

下面我用一张图来展示行缓冲器的整体数据流:

行缓冲器数据流架构图 输入数据流 像素串行输入 FIFO 0 缓存第0行数据 FIFO 1 缓存第1行数据 当前行 直接输入 移位寄存器组 Shift Reg 0 3个tap Shift Reg 1 3个tap Shift Reg 2 3个tap 3x3 窗口 0,0 0,1 0,2 1,0 1,1 1,2 2,0 2,1 2,2 → 卷积计算 数据流方向:输入 → FIFO缓存 → 移位寄存器对齐 → 3x3窗口输出

从这张图可以看得很清楚:数据从左边进来,经过FIFO缓存行数据,再经过移位寄存器做窗口对齐,最后输出一个完整的3x3窗口给卷积计算单元。

一个小技巧:如果你用的是Xilinx的FPGA,可以直接用XPM_FIFO原语,省去自己写FIFO的麻烦。我刚开始不知道有这个,自己手写FIFO,结果时序怎么都跑不满。换成XPM_FIFO后,问题迎刃而解。

嗯,行缓冲器这块内容就这些。核心就是三点:FIFO存行数据、移位寄存器做对齐、控制好读写使能。把这三点搞明白了,行缓冲器这块就不会出大问题。