上一章结尾留了个钩子:frame_read_write 只负责「按索引选基地址」,真正的切换逻辑在 sd_card_bmp 里。这一章就把它讲透。
如果说 frame_read_write 是搬运工——老老实实按地址把数据写进 SDRAM、读出来,那 sd_card_bmp 就是指挥官——它决定:
write_buf_idx);disp_buf_idx)。整个模块围绕一个目标:让「正在显示的缓冲」和「正在写入的缓冲」永远不是同一块。
读 sd_card_bmp 最容易晕的,是一堆 _idx 混在一起。先把它们归类,后面就顺了。
| 名称 | 位宽 | 含义 | 变化时机 |
|---|---|---|---|
img_idx | 2bit | 当前正在显示的图片编号 | 提交时 img_idx <= load_idx |
load_idx | 2bit | 当前正在写入的图片编号 | 加载开始时锁存「下一张」 |
pending_buf_idx | 2bit | 本次写入的目标缓冲区(提交用) | 加载开始时锁存 |
write_buf_idx | 2bit | 输出给 frame_read_write 的写索引 | 加载开始时锁存 |
disp_buf_idx | 2bit | 输出给 frame_read_write 的读/显示索引 | 提交时才翻转 |
这里其实有两层双缓冲:
write_buf_idx / disp_buf_idx——对应 SDRAM 里的 BUF0/BUF1,这是上一章的主角;load_idx / img_idx——对应「正在读的图」和「正在显示的图」,防止切图瞬间图号错乱。两层共用同一套「先写后切」的节奏,理解一层就懂了另一层。
pending_buf_idx 又有 write_buf_idx? write_buf_idx 是实时输出给搬运工的写地址索引;pending_buf_idx 是「提交目标」——加载开始时把这次写的是哪块记下来,提交那一刻直接拿它去翻 disp_buf_idx。因为从加载开始到提交中间隔了整整一帧的时间,必须有个寄存器把目标锁存住。双缓冲的核心,是一个简单到不像话的函数:
function [1:0] next_buf_lut;
input [1:0] cur_disp_buf;
input valid_now;
begin
if (!valid_now)
next_buf_lut = 2'd0; // 首图固定写 buffer0
else if (cur_disp_buf == 2'd0)
next_buf_lut = 2'd1;
else
next_buf_lut = 2'd0;
end
endfunction
逻辑就一句话:你显示 0,我就写 1;你显示 1,我就写 0。唯一的例外是 valid_now 为 0(屏幕还没亮,也就是首图阶段)时,固定写 buffer0。
配合提交逻辑一起看,乒乓就转起来了:
next_buf_lut 看到在显示 0 → 写 buffer1 → 提交 → 显示 buffer1;写和显永远错开,观众永远看不到半张画。
这是本模块最精妙的一处设计。切换显示缓冲不是「图读完了就切」,而要等两个条件同时成立。
两个标志位分别在各自的时机置位:
// 记住 bmp_read 已经把源图送完 FIFO,但还不能切显示,得等整帧写完
if (load_busy && bmp_ready)
source_done_seen <= 1'b1;
// 记住写帧完成脉冲,避免“先写完后源结束”导致脉冲丢失而卡死
if (load_busy && write_finish_pulse)
write_done_seen <= 1'b1;
bmp_ready 表示源图(BMP 解析出的像素)已经送进写 FIFO;write_finish_pulse 表示这些像素已经真正写进了 SDRAM 整帧。两个都到齐,才进入提交分支:
if (load_busy && source_done_seen && write_done_seen) begin
load_busy <= 1'b0;
disp_buf_idx <= pending_buf_idx; // 切换显示缓冲
img_idx <= load_idx; // 切换显示图号
display_valid <= 1'b1; // 点亮屏幕
first_image_committed <= 1'b1;
end
bmp_ready 就切显示,屏幕会读到一块只写了一半的缓冲,直接花屏。反过来只等 write_finish_pulse,源还没送完时它也可能先到。两个标志分别记录、一起校验,才万无一失。write_finish_pulse 是怎么来的?它来自 frame_read_write 所在的 mem_clk 域,而 sd_card_bmp 工作在 sd_card_clk 域,两边都是 100 MHz 但相位无关,属于跨时钟域信号。
处理办法是:不用电平,用跳变(toggle)传递。写侧每写完一帧就把 write_finish_toggle 翻转一次,读侧只关心「它变了没有」:
reg [2:0] wrfin_tgl_sync;
assign write_finish_pulse = wrfin_tgl_sync[2] ^ wrfin_tgl_sync[1];
// 每个时钟拍把 toggle 往右移一位
wrfin_tgl_sync <= {wrfin_tgl_sync[1:0], write_finish_toggle};
拆开看:
wrfin_tgl_sync[1:0], write_finish_toggle 这个移位,等于把 toggle 信号在本地时钟域「拍」一遍,消除亚稳态;wrfin_tgl_sync[2] ^ wrfin_tgl_sync[1] 用异或做边沿检测——前后两拍不一样,说明 toggle 翻转过,就产出一个单周期脉冲。用「翻转」而不是「高电平」传递跨域信号,是 FPGA 里最稳的握手方式之一:接收端不关心电平高低,只关心「有没有变化」,天然规避了跨域电平采样的不稳定。
frame_done 高电平脉冲,接收端可能恰好错过它(脉冲太窄,或相位错开没采到)。改成 toggle 翻转后,变化会一直保持到下一拍,接收端靠异或总能抓到。模块里加载新图有三个入口,优先级从高到低依次是:首图 → 手动下一张 → 自动播放。三个 if / else if 链就是这个优先级。
首图——上电扫描完自动加载,固定写 buffer0:
if (scan_done && !first_image_committed && bmp_ready && !load_busy && (img_found_count != 3'd0)) begin
load_idx <= 2'd0;
load_sector <= img_sector0;
pending_buf_idx <= 2'd0;
write_buf_idx <= 2'd0;
load_busy <= 1'b1;
...
end
手动下一张——按 key_next,写到「非当前显示」的那块:
else if (... display_valid && !load_busy && next_req_pending ...) begin
load_idx <= next_from_current;
load_sector <= sector_lut(next_from_current);
pending_buf_idx <= next_buf_lut(disp_buf_idx, display_valid);
write_buf_idx <= next_buf_lut(disp_buf_idx, display_valid);
load_busy <= 1'b1;
...
end
自动播放——按 key_auto 开启,每 1 秒翻一张,逻辑和手动几乎一样,只是触发源换成 auto_tick。
注意手动和自动里那句 next_req_pending:加载期间(load_busy)如果有按键,只把请求记一次,等当前这帧提交完再处理,不会因为手抖连跳两张。
最后是两个「工程细节」,让系统在真实环境里扛得住。
超时保护。如果读到一张坏图、或者文件被截断,source_done_seen / write_done_seen 可能永远等不齐,load_busy 卡死,之后所有按键失效。所以加了一个看门狗:
if (load_timeout_cnt > 32'd100_000_000) begin // 100 MHz 下约 1 秒
load_busy <= 1'b0; // 强制释放 busy
load_abort <= 1'b1; // 通知 bmp_read 中止本次加载
end
按键消抖。key_next / key_auto 是机械按键,按下会抖动几十毫秒,不消抖就会一按触发几十次。模块里例化了 key_press_debounce:
key_press_debounce #(.CLK_FREQ_HZ(100_000_000), .DEBOUNCE_MS(50)) u_key_next(...);
它内部先把电平拍两拍同步,再连续计数 DEBOUNCE_CYCLES = (CLK_FREQ_HZ/1000)*DEBOUNCE_MS 才承认一次「按下」,最后只在按下边沿吐出一个 press_pulse 单脉冲。50 ms 消抖,正好滤掉机械抖动又不影响手感。
next_buf_lut 选缓冲 → 双确认才提交 → disp_buf_idx 翻转。后面想加淡入淡出、多缓冲、甚至是「预加载下一张图」,手术点都在这个模块里。write_buf_idx(写索引)、disp_buf_idx(显示索引)、pending_buf_idx(提交目标),外加图片编号 img_idx/load_idxcur_disp_buf 和 valid_now 决定:首图固定写 buffer0,否则显示 0 写 1、显示 1 写 0load_timeout_cnt > 100_000_000 超时保护,强制释放 load_busy 并触发 load_abort 中止本次加载