🧩 第 3 章:第三步:双缓冲切换的完整闭环

🧩 安路FPGA 入门 · 第 3 / 7 章
🗨️ 双缓冲的「开关」在这一章真正闭合:`sd_card_bmp.v` 通过 `write_buf_idx` 和 `disp_buf_idx` 两个索引,在「源图送完 + 整帧写完」双重确认后才翻转显示缓冲——屏幕从此永远读不到写了一半的画面。
这一章在干嘛? 上一章我们钻进 `frame_read_write.v`,知道它「只按索引选基地址、不决定何时切换」。这一章补上闭环——回到 `sd_card_bmp.v`,看它如何决定 `write_buf_idx` / `disp_buf_idx` 何时翻转:三个索引各司其职、`next_buf_lut` 选出「非当前显示」的那块缓冲、`source_done_seen + write_done_seen` 双确认才提交,以及跨时钟域的 toggle 边沿检测和超时保护。
一句话:它是双缓冲的「大脑」先分清:三个编号、三个索引选出「非当前显示」的那块双确认:源送完 + 帧写完才提交跨时钟域的 toggle 边沿检测三条触发路径:首图 / 手动 / 自动死锁保护 + 按键消抖

一句话:它是双缓冲的「大脑」

上一章结尾留了个钩子:frame_read_write 只负责「按索引选基地址」,真正的切换逻辑在 sd_card_bmp 里。这一章就把它讲透。

如果说 frame_read_write搬运工——老老实实按地址把数据写进 SDRAM、读出来,那 sd_card_bmp 就是指挥官——它决定:

  1. 什么时候加载下一张图;
  2. 写到哪块缓冲(write_buf_idx);
  3. 什么时候把刚写完的那块切给显示(disp_buf_idx)。

整个模块围绕一个目标:让「正在显示的缓冲」和「正在写入的缓冲」永远不是同一块

先分清:三个编号、三个索引

sd_card_bmp 最容易晕的,是一堆 _idx 混在一起。先把它们归类,后面就顺了。

名称位宽含义变化时机
img_idx2bit当前正在显示的图片编号提交时 img_idx <= load_idx
load_idx2bit当前正在写入的图片编号加载开始时锁存「下一张」
pending_buf_idx2bit本次写入的目标缓冲区(提交用)加载开始时锁存
write_buf_idx2bit输出给 frame_read_write写索引加载开始时锁存
disp_buf_idx2bit输出给 frame_read_write读/显示索引提交时才翻转

这里其实有两层双缓冲

两层共用同一套「先写后切」的节奏,理解一层就懂了另一层。

为什么有 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。

配合提交逻辑一起看,乒乓就转起来了:

  1. 首图 → 写 buffer0 → 提交 → 显示 buffer0;
  2. 下一张 → next_buf_lut 看到在显示 0 → 写 buffer1 → 提交 → 显示 buffer1;
  3. 再下一张 → 看到在显示 1 → 写 buffer0 → 提交 → 显示 buffer0。

写和显永远错开,观众永远看不到半张画。

双确认:源送完 + 帧写完才提交

这是本模块最精妙的一处设计。切换显示缓冲不是「图读完了就切」,而要等两个条件同时成立。

两个标志位分别在各自的时机置位:

// 记住 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 解析出的像素)已经送进写 FIFOwrite_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
为什么必须是「双确认」? 源图送完 ≠ 帧写完。送进 FIFO 只代表数据在排队,还没落到 SDRAM。如果只等 bmp_ready 就切显示,屏幕会读到一块只写了一半的缓冲,直接花屏。反过来只等 write_finish_pulse,源还没送完时它也可能先到。两个标志分别记录、一起校验,才万无一失。

跨时钟域的 toggle 边沿检测

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};

拆开看:

用「翻转」而不是「高电平」传递跨域信号,是 FPGA 里最稳的握手方式之一:接收端不关心电平高低,只关心「有没有变化」,天然规避了跨域电平采样的不稳定。

电平 vs 跳变 如果直接跨域传一个 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 翻转。后面想加淡入淡出、多缓冲、甚至是「预加载下一张图」,手术点都在这个模块里。
🧠 小测验
`sd_card_bmp` 里有哪三个索引各司其职?
write_buf_idx(写索引)、disp_buf_idx(显示索引)、pending_buf_idx(提交目标),外加图片编号 img_idx/load_idx
`next_buf_lut` 的返回值由什么决定?
由当前显示缓冲 cur_disp_bufvalid_now 决定:首图固定写 buffer0,否则显示 0 写 1、显示 1 写 0
为什么切换显示缓冲要「源送完 + 帧写完」双确认?
送进 FIFO 只代表数据在排队,还没落进 SDRAM;只等其一都可能切到写了一半的缓冲导致花屏
`write_finish_pulse` 用异或边沿检测解决了什么问题?
跨时钟域传递帧完成信号:用 toggle 翻转 + 三级同步 + 异或,避免窄脉冲被漏采
读到坏图时系统靠什么恢复?
load_timeout_cnt > 100_000_000 超时保护,强制释放 load_busy 并触发 load_abort 中止本次加载
← 上一篇🏠 顶层目录下一篇 →