这一章在干嘛?

前四章讲的是「怎么把图存进去」——从 SD 卡解析像素,经写 FIFO、写状态机落进 SDRAM 双缓冲。这一章翻到反面,讲「怎么把图取出来」:屏幕不是「有数据才显示」,而是每 16.67 ms 雷打不动要刷一帧。所以读侧是「时钟驱动」的——先由时序模块产生 HS/VS/DE,再在场边界发起读请求,最后把像素对齐到同步信号一起送出。读完这一章,整条数据链路才真正闭环。

读侧是「时钟驱动」的

640×480 的时序:一段行、一场帧

hs / vs / de:三个计数器比较出来的信号

场同步握手:read_req / read_req_ack

读状态机:一帧是怎么被「吸」出来的

时序对齐:video_delay 与黑屏门控

三个时钟域在顶层汇合

读侧是「时钟驱动」的

先建立一个关键认知:写侧和读侧,驱动方式完全不同

  • 写侧(事件驱动)load_start 来了才干活,没有新图就闲着;
  • 读侧(时钟驱动):屏幕每秒必须刷 60 帧,不管有没有新图,video_clk 每 16.67 ms 都会逼着读侧去 SDRAM 取一整帧。

所以读侧的第一个模块不是「读数据的」,而是「发节拍的」——video_timing_data。它先输出行同步 hs、场同步 vs、数据有效 de 三个信号,再由这三个信号决定「什么时候该去取下一帧」。

640×480 的时序:一段行、一场帧

VGA/HDMI 的行场时序,本质就是「有效区 + 消隐区」的循环。以本项目 640×480 为例,color_bar.v 里定义的参数是这样的:

参数含义
H_ACTIVE640一行里有效的像素数
H_FP / H_SYNC / H_BP16 / 96 / 48行前肩 / 行同步 / 行后肩(消隐)
V_ACTIVE480一帧里有效的行数
V_FP / V_SYNC / V_BP10 / 2 / 33场前肩 / 场同步 / 场后肩(消隐)

把这些加起来:

  • 一行总共 H_TOTAL = 640 + 16 + 96 + 48 = 800 个像素;
  • 一帧总共 V_TOTAL = 480 + 10 + 2 + 33 = 525 行。

再用 25.175 MHz 的像素时钟一除:

  • 行频 = 25.175 MHz ÷ 800 ≈ 31.47 kHz;
  • 帧率 = 25.175 MHz ÷ (800 × 525) ≈ 59.94 Hz,也就是标准的 60 Hz。

这几个数字,正是第一章那个 tb_color_bar.v 里写的「行周期 32000 ns / 800 像素、一场 525 行」——仿真和板上的时序是同源的。

为什么要消隐区?

老式 CRT 显示器里,电子束扫完一行要飞回左边(行回扫)、扫完一场要飞回左上角(场回扫),这段时间不能发数据,就是「消隐」。现代数字显示器为兼容标准,仍然保留这段时序。 de 信号在有效区才为高,告诉下游「现在这些像素才是真正的画面」。

hs / vs / de:三个计数器比较出来的信号

video_timing_data 例化了一个 color_bar 来当「时序发生器」(注意它的 RGB 输出是悬空的,这里只借用它的时序)。时序信号全靠两个计数器 h_cnt(0799)和 v_cnt(0524)与参数比较得出。

行同步 hs 的翻转:

if (h_cnt == H_FP - 1)         // 15:行同步开始
    hs_reg <= HS_POL;
else if (h_cnt == H_FP + H_SYNC - 1)  // 111:行同步结束
    hs_reg <= ~hs_reg;

有效区标志 h_active 的开闭:

if (h_cnt == H_FP + H_SYNC + H_BP - 1)   // 159:有效区开始
    h_active <= 1'b1;
else if (h_cnt == H_TOTAL - 1)           // 799:有效区结束
    h_active <= 1'b0;

场方向的 vsv_active 逻辑完全对称,只是把 h_cnt 的判据换成 v_cnt,并且都要在 h_cnt == H_FP - 1(行边界)这个时刻才更新——因为「场」是以「行」为单位的。

最后,de 由两个有效标志相与、再延迟一拍得到:

assign video_active = h_active & v_active;   // 行有效 且 场有效
assign de = video_active_d0;                 // 延迟一拍对齐

三个信号就这么从计数器里「长」出来了,没有任何神秘的东西。

场同步握手:read_req / read_req_ack

时序有了,什么时候去取下一帧?答案藏在 video_timing_data 里的一段边沿检测:

if (video_vs_d0 & ~video_vs)     // 场同步的边沿(一帧的边界)
    read_req <= 1'b1;
else if (read_req_ack)
    read_req <= 1'b0;

场同步信号 vs 每帧翻转一次,它的边沿就是「一帧结束 / 下一帧开始」的时刻。在这个瞬间拉高 read_req,向读状态机「要」新的一帧。

read_reqvideo_clk(25.175 MHz)域,读状态机在 mem_clk(100 MHz)域,中间隔着跨时钟域。读状态机一侧的处理是第三章见过的老朋友——三级同步 + 握手往返

  1. read_req 先进 read_req_d0 → d1 → d2 三级寄存器,消除亚稳态;
  2. 读状态机看到 read_req_d2,进入 S_ACK,拉高 read_req_ack
  3. video_timing_data 看到 read_req_ack,把 read_req 拉低;
  4. 三级同步后 read_req_d2 变 0,读状态机才离开 S_ACK,进入正式的读数据阶段。

一个「请求—应答」完整走一圈,两边才算对齐。

读侧和写侧共用的套路

上一章写侧有 write_req / write_req_ack ,这一章读侧有 read_req / read_req_ack ,都是「请求—应答」握手 + 三级同步。看懂一处,两处通吃。

读状态机:一帧是怎么被「吸」出来的

读状态机在第二章已经讲过六个状态,这里补上它和时序配合的几个关键动作。进入 S_ACK 时,它同时做了三件事:

read_req_ack <= 1'b1;        // 应答时序模块
fifo_aclr    <= 1'b1;        // 清空读 FIFO
read_len_latch <= read_len_d1; // 锁存帧长度(307200)
// 并按 read_addr_index 选中 read_addr_0 / read_addr_1 作为基地址

fifo_aclr 尤其关键——每帧开始前把读 FIFO 清空。上一帧如果还有几个像素没被显示取走,残留着就会「串帧」,画面出现一条错位的横纹。清空之后,这一帧的 307200 个像素从头开始装。

进入 S_READ_BURST 后,就是连续地「吸」:

App_rd_en   <= 1'b1;                  // 每个时钟都读一个字
App_rd_addr <= App_rd_addr + 1'b1;    // 地址递增,顺序扫过整帧

地址从 disp_buf_idx 选中的基地址(BUF0_ADDRBUF1_ADDR)开始,一个时钟加一,把 307200 个 32bit 像素顺序从 SDRAM 读进读 FIFO。read_cnt 累加到 read_len_latch 就收工——到这里,第二章说的「读延迟 10 拍」又登场了:rd_burst_finish = rd_vld && rd_delay == 10,命令发完还要等 10 拍数据才真正到齐。

时序对齐:video_delay 与黑屏门控

读 FIFO 的数据出来后,还不能直接上屏——SDRAM 读有延迟,像素和 hs/vs/de 已经「错位」了。所以顶层在中间插了一个 video_delay

video_delay video_delay_m0(
    .read_data (video_read_data[31:8]),  // 32bit 取高 24bit 像素
    .hs / .vs / .de (hs_0 / vs_0 / de_0),
    .hs_r / .vs_r / .de_r (hs / vs / de),      // 同步信号延迟同样拍数
    .vout_data (vout_data_raw)
);

它的作用就是把像素和同步信号一起延迟同样拍数,重新对齐。注意 video_read_data[31:8]——读出来是 32bit,取高 24bit 才是真正的 RGB 像素(低 8bit 是第四章补的零)。

最后还有一道「黑屏门控」:

assign vout_data = display_valid ? vout_data_raw : 24'd0;

display_valid 是第三章那个信号——首图提交前一直是 0。也就是说,系统刚上电、还没加载完第一张图时,屏幕是黑的(输出全零),而不是花屏或垃圾数据。等第一张图提交、display_valid 拉高后,才开始显示 SDRAM 里真正的内容。

三个时钟域在顶层汇合

走到这里,可以把整条链路的「时间轴」摆出来了。顶层用两个 PLL 从同一个晶振 clk 分出四路时钟:

时钟频率用途
sd_card_clk100 MHz写侧:SD 卡读 + BMP 解析 + sd_card_bmp
ext_mem_clk100 MHzSDRAM 控制器 + 读写状态机(mem_clk)
video_clk25.175 MHz读侧:视频时序 + 读 FIFO 输出
hdmi_5x_clk125.875 MHzHDMI 串行发送(5× 像素时钟)

数据流从 sd_card_clk 域写入、经 ext_mem_clk 域中转、在 video_clk 域读出上屏。三个域之间全部靠「异步 FIFO + 握手」衔接——这正是这套例程最值得学的地方。

还记得第三章的 write_finish_toggle 吗?它的源头就在顶层这段:

// mem_clk 域把 write_finish 单拍转成 toggle,供 sd_card_clk 域可靠同步
always @(posedge ext_mem_clk or posedge rst_all) begin
    if (rst_all)           frame_write_toggle_mem <= 1'b0;
    else if (frame_write_finish) frame_write_toggle_mem <= ~frame_write_toggle_mem;
end

写侧写完一帧,mem_clk 域把单拍脉冲翻成 toggle,再交给 sd_card_clk 域去同步、去异或。前面埋的每一颗「跨域钉子」,在这一章都能找到它的另一头。

这一章的手术点

想改分辨率?改 video_define.v 的宏定义 + color_bar 参数 + bmp_width/height + FRAME_WIDTH/HEIGHT 。想加 OSD 文字叠加?在 video_delay 之后、 vout_data 输出之前插一层像素混合。数据链路的每一环都留好了接口。