先建立一个关键认知:写侧和读侧,驱动方式完全不同。
load_start 来了才干活,没有新图就闲着;video_clk 每 16.67 ms 都会逼着读侧去 SDRAM 取一整帧。所以读侧的第一个模块不是「读数据的」,而是「发节拍的」——video_timing_data。它先输出行同步 hs、场同步 vs、数据有效 de 三个信号,再由这三个信号决定「什么时候该去取下一帧」。
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 的像素时钟一除:
这几个数字,正是第一章那个 tb_color_bar.v 里写的「行周期 32000 ns / 800 像素、一场 525 行」——仿真和板上的时序是同源的。
de 信号在有效区才为高,告诉下游「现在这些像素才是真正的画面」。video_timing_data 例化了一个 color_bar 来当「时序发生器」(注意它的 RGB 输出是悬空的,这里只借用它的时序)。时序信号全靠两个计数器 h_cnt(0~799)和 v_cnt(0~524)与参数比较得出。
行同步 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; // 延迟一拍对齐
三个信号就这么从计数器里「长」出来了,没有任何神秘的东西。
时序有了,什么时候去取下一帧?答案藏在 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 拍数据才真正到齐。
读 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 输出之前插一层像素混合。数据链路的每一环都留好了接口。vout_data = display_valid ? vout_data_raw : 24'd0,display_valid 在首图提交前为 0,强制输出黑屏而非垃圾数据