这一章在干嘛?

上一章我们站在顶层看懂了「双缓冲已经存在」,这一章钻进 frame_read_write.v 这个双缓冲仲裁核心,逐行拆开它内部的四个部件:两个异步 FIFO、一个写状态机、一个读状态机,以及一条「写优先」的仲裁规则。读懂它,你就真正掌握了官方双缓冲的实现,也拿到了改造它的手术刀。

一句话:它在干什么

两个异步 FIFO:跨时钟域的隔离墙

写状态机:六个状态的流水线

读状态机:对称但多了读延迟

写优先仲裁:为什么写比读优先

垂直翻转:BMP 是倒着存的

索引选择:双缓冲的真正开关

一句话:它在干什么

frame_read_write 是整个工程里最忙的模块。它的任务是在两个不同频率的时钟域之间,安全地搬运一整帧像素

  • 写侧:sd_card_clk(100 MHz)把 SD 卡解析出的像素送进来;
  • 读侧:video_clk(25.175 MHz)把像素取走去显示;
  • 中间:mem_clk(100 MHz)的 SDRAM 控制器负责实际存取。

三个时钟域之间不能直接握手,所以它内部先各放一个异步 FIFO 做隔离,再用两个状态机驱动 SDRAM 的突发读写。

部件类型作用
wfifo_32_32_512 write_buf异步 FIFO写数据跨 write_clkmem_clk
rfifo_32_32_512 read_buf异步 FIFO读数据跨 mem_clkread_clk
frame_fifo_write写状态机把写 FIFO 里的数据突发写进 SDRAM
frame_fifo_read读状态机从 SDRAM 突发读出来,塞进读 FIFO

两个异步 FIFO:跨时钟域的隔离墙

异步 FIFO 是这个模块的「地基」。它内部用格雷码指针 + 双口 RAM,让两边的读写指针各走各的时钟,从而安全地在不同频率之间传递数据。

写 FIFO 的连接方式,看这段例化:

wfifo_32_32_512 write_buf(
    .clkr     (mem_clk),        // 读侧用 SDRAM 时钟
    .clkw     (write_clk),      // 写侧用 SD 卡时钟
    .we       (write_en),       // 写请求
    .re       (App_wr_en),      // 读请求
    .di       (write_data),     // 数据进
    .dout     (App_wr_din)      // 数据出,交给 SDRAM
);

为什么非要 FIFO?

如果没有这堵「隔离墙」,100 MHz 和 25.175 MHz 的相位完全对不上,直接连线就是亚稳态。FIFO 让两边各自在自己的时钟里读写,中间用指针同步,天然隔离了跨域风险。

写状态机:六个状态的流水线

写状态机 frame_fifo_write 是一条六个状态的流水线。状态编号和转移条件如下:

状态含义转出条件
S_IDLE空闲等待write_req_d2 && Sdr_init_doneS_ACK
S_ACK握手应答、锁存地址与长度write_req_d2 == 0S_CHECK_FIFO
S_CHECK_FIFO检查 FIFO 是否攒够一个突发into_burstS_WRITE_BURST
S_WRITE_BURST突发写(一次 256 字)burst_cnt >= BURST_SIZES_WRITE_BURST_END
S_WRITE_BURST_END突发收尾write_cnt < write_lenS_CHECK_FIFO,否则 → S_END
S_END一帧写完S_IDLE

有两点值得停下来理解:

  1. 跨域信号要「拍三下」write_req 来自 SD 卡时钟域,进到 mem_clk 域之前,先经过 write_req_d0 → d1 → d2 三级寄存器同步。注释里写得很直白:「Why do you need 3? Here’s the design habit」——三级同步是消除亚稳态的工程惯例。

  2. 地址与长度在 S_ACK 锁存。进入 S_ACK 时,把 write_addr_index 选中的基地址、write_len 数据长度都锁存下来,后面突发期间就不再关心输入端的抖动。

读状态机:对称但多了读延迟

读状态机 frame_fifo_read 和写状态机几乎是对称的,同样六个状态,但有两处关键不同。

第一处是进突发的条件,多了一个「读 FIFO 要留出空间」的判断:

else if(wrusedw < (FIFO_DEPTH - BURST_SIZE) && ~App_wr_busy)
    state <= S_READ_BURST;

第二处是读延迟。SDRAM 发出读命令后,数据不会立刻回来,要等若干时钟。所以读状态机里多了一个 rd_delay 计数器:

assign rd_vld = (state == S_READ_BURST && burst_cnt >= BURST_SIZE);
assign rd_burst_finish = (rd_vld && rd_delay == 4'd10);   // 再等 10 拍数据才真正读完

也就是说,读命令发完 BURST_SIZE 个之后,还得再等 10 个时钟,才认为这一突发真正完成。写侧没有这个等待,因为写是「送出即生效」。

写优先仲裁:为什么写比读优先

读写都要抢 SDRAM,谁先来?答案是写优先。看写状态机的进入条件:

assign into_burst = (((write_len_latch <= (rdusedw + write_cnt)) || rdusedw > BURST_SIZE)
                     && ~App_rd_busy);

这条式子拆开看:

  • 前一半判断「写 FIFO 里的数据已经够一次突发」;
  • 最后那个 ~App_rd_busy 才是关键——只要读正在突发,写就得等

反过来,读状态机的进入条件是 wrusedw &lt; (FIFO_DEPTH - BURST_SIZE) && ~App_wr_busy,同样要「写不忙」才读。但两条 busy 信号交叉制约的结果是:写请求一旦就绪,读会被迫让路。这样设计是因为——显示侧如果短时间没数据,最多黑屏一下;但写侧如果被饿死,SD 卡数据会丢,画面就永久损坏了。两害相权,保写丢读。

一句话记住

读缺数据 = 暂时闪一下;写丢数据 = 永久花屏。所以仲裁永远偏向写。

垂直翻转:BMP 是倒着存的

BMP 文件的像素是自下而上存储的(第一行是图像最底部),而显示是从上到下扫描。直接顺序写进去,画面会上下颠倒。所以写状态机里藏着一个地址重映射:

localparam VFLIP_FIRST_ADDR_OFFSET = (FRAME_HEIGHT - 1) * FRAME_WIDTH;  // 479*640
localparam VFLIP_LINE_JUMP         = (FRAME_WIDTH * 2) - 1;            // 1279

写第一行时,起始地址直接跳到「帧缓冲的最后一行」;写完一行,地址减去 1279,正好落到上一行。这样 BMP 的倒序存储就被「翻正」了。读侧不需要翻转,因为 SDRAM 里已经是正序。

索引选择:双缓冲的真正开关

理解了四个部件,最后回到双缓冲的本质。frame_read_write 本身不决定何时切换缓冲,它只负责「按索引选基地址」:

.read_addr_index  (disp_buf_idx),   // 读:显示缓冲索引
.write_addr_index (write_buf_idx),  // 写:写入缓冲索引
.read_addr_0      (BUF0_ADDR),      // BUF0 = 0
.read_addr_1      (BUF1_ADDR),      // BUF1 = 307200

真正的切换逻辑在 sd_card_bmp 里:写满一帧翻转 write_buf_idx,VSYNC 场边界翻转 disp_buf_idx。两个索引各翻各的,所以读写永远指向不同缓冲——这就是双缓冲的全部秘密。

改造从这里下手

想加三缓冲?把 read_addr_2 / write_addr_2 用起来,把索引从 2bit 用到 3 个状态。想加淡入淡出?在 read_data 输出端做像素混合。手术刀就握在这里。