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_clk → mem_clk 域 |
rfifo_32_32_512 read_buf | 异步 FIFO | 读数据跨 mem_clk → read_clk 域 |
frame_fifo_write | 写状态机 | 把写 FIFO 里的数据突发写进 SDRAM |
frame_fifo_read | 读状态机 | 从 SDRAM 突发读出来,塞进读 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
);
写状态机 frame_fifo_write 是一条六个状态的流水线。状态编号和转移条件如下:
| 状态 | 含义 | 转出条件 |
|---|---|---|
S_IDLE | 空闲等待 | write_req_d2 && Sdr_init_done → S_ACK |
S_ACK | 握手应答、锁存地址与长度 | write_req_d2 == 0 → S_CHECK_FIFO |
S_CHECK_FIFO | 检查 FIFO 是否攒够一个突发 | into_burst → S_WRITE_BURST |
S_WRITE_BURST | 突发写(一次 256 字) | burst_cnt >= BURST_SIZE → S_WRITE_BURST_END |
S_WRITE_BURST_END | 突发收尾 | write_cnt < write_len → S_CHECK_FIFO,否则 → S_END |
S_END | 一帧写完 | → S_IDLE |
有两点值得停下来理解:
write_req 来自 SD 卡时钟域,进到 mem_clk 域之前,先经过 write_req_d0 → d1 → d2 三级寄存器同步。注释里写得很直白:「Why do you need 3? Here's the design habit」——三级同步是消除亚稳态的工程惯例。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);
这条式子拆开看:
~App_rd_busy 才是关键——只要读正在突发,写就得等。反过来,读状态机的进入条件是 wrusedw < (FIFO_DEPTH - BURST_SIZE) && ~App_wr_busy,同样要「写不忙」才读。但两条 busy 信号交叉制约的结果是:写请求一旦就绪,读会被迫让路。这样设计是因为——显示侧如果短时间没数据,最多黑屏一下;但写侧如果被饿死,SD 卡数据会丢,画面就永久损坏了。两害相权,保写丢读。
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 输出端做像素混合。手术刀就握在这里。