这一章在干嘛?
前四章讲的是「怎么把图存进去」——从 SD 卡解析像素,经写 FIFO、写状态机落进 SDRAM 双缓冲。这一章翻到反面,讲「怎么把图取出来」:屏幕不是「有数据才显示」,而是每 16.67 ms 雷打不动要刷一帧。所以读侧是「时钟驱动」的——先由时序模块产生 HS/VS/DE,再在场边界发起读请求,最后把像素对齐到同步信号一起送出。读完这一章,整条数据链路才真正闭环。
读侧是「时钟驱动」的
先建立一个关键认知:写侧和读侧,驱动方式完全不同。
- 写侧(事件驱动):
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_ACTIVE | 640 | 一行里有效的像素数 |
H_FP / H_SYNC / H_BP | 16 / 96 / 48 | 行前肩 / 行同步 / 行后肩(消隐) |
V_ACTIVE | 480 | 一帧里有效的行数 |
V_FP / V_SYNC / V_BP | 10 / 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)和 524)与参数比较得出。v_cnt(0
行同步 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;
场方向的 vs 和 v_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_req 在 video_clk(25.175 MHz)域,读状态机在 mem_clk(100 MHz)域,中间隔着跨时钟域。读状态机一侧的处理是第三章见过的老朋友——三级同步 + 握手往返:
read_req先进read_req_d0 → d1 → d2三级寄存器,消除亚稳态;- 读状态机看到
read_req_d2,进入S_ACK,拉高read_req_ack; video_timing_data看到read_req_ack,把read_req拉低;- 三级同步后
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_ADDR 或 BUF1_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_clk | 100 MHz | 写侧:SD 卡读 + BMP 解析 + sd_card_bmp |
ext_mem_clk | 100 MHz | SDRAM 控制器 + 读写状态机(mem_clk) |
video_clk | 25.175 MHz | 读侧:视频时序 + 读 FIFO 输出 |
hdmi_5x_clk | 125.875 MHz | HDMI 串行发送(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输出之前插一层像素混合。数据链路的每一环都留好了接口。
为什么说读侧是「时钟驱动」而写侧是「事件驱动」?
屏幕每 16.67ms 必须刷一帧,读侧被 video_clk 逼着周期取数;写侧只有 load_start 来了才干活
640×480@60Hz 的一行和一场各有多少个像素/行?
一行 H_TOTAL=800 像素(640 有效+160 消隐),一场 V_TOTAL=525 行(480 有效+45 消隐)
hs/vs/de三个信号是怎么产生的?由 h_cnt/v_cnt 两个计数器与 H_FP/H_SYNC/H_BP 等参数比较,在特定计数值翻转;de = 行有效 & 场有效
读状态机进入 S_ACK 时做的三件关键事是什么?
拉高 read_req_ack 应答、fifo_aclr 清空读 FIFO 防串帧、锁存帧长度并按 read_addr_index 选基地址
首图提交前屏幕为什么是黑的?
顶层
vout_data = display_valid ? vout_data_raw : 24'd0,display_valid 在首图提交前为 0,强制输出黑屏而非垃圾数据