🧩 第 4 章:第四步:SD 卡读图与 BMP 文件解析

🧩 安路FPGA 入门 · 第 4 / 7 章
🗨️ 数据不会凭空出现——这一章追到「源头」:`bmp_read.v` 从 SD 卡里读出 BMP 文件,校验文件头、跳过 54 字节头、把 BGR 三字节拼成像素,一路送进写 FIFO。至此「SD 卡 → 像素 → 双缓冲 → HDMI」整条链路才算真正打通。
这一章在干嘛? 前面三章我们站在「像素之后」看世界:双缓冲怎么仲裁、索引怎么翻转。但像素是哪儿来的?这一章回到最上游的 `bmp_read.v`,看它如何用同一个状态机完成两件事——上电「扫描」SD 卡找到前几张 BMP,再按扇区「加载」某一张图、解析出像素送进 FIFO。理解它,你就掌握了 FPGA 读文件的第一步:解析二进制格式。
一句话:它是「侦察兵 + 搬运工」BMP 文件头:54 字节里藏着什么一张「合格图」的五个条件扫描模式:在整张卡里找到前几张图加载模式:write_req / write_req_ack 握手三字节拼一个像素:BGR 顺序数据流全链路:把前三章串起来

一句话:它是「侦察兵 + 搬运工」

bmp_read 是整条链路里离数据源最近的一个模块,它头顶两个任务:

  1. 扫描(scan):上电后从 scan_start_sector 开始,一个扇区一个扇区地读 SD 卡,把符合要求的前几张 BMP 的起始扇区记下来;
  2. 加载(load):拿到一个扇区号后,把那张 BMP 读出来,解析出像素数据,送进写 FIFO。

它的输出只有两个东西——像素(bmp_data + bmp_data_wr_en)和一个「我干完了」的信号(ready)。而 ready 正是上一章 sd_card_bmp 里用来置 source_done_seen 的那个 bmp_ready。所以这一章,本质是在回答上一章埋下的一个问题:bmp_ready 到底什么时候拉高,数据又是在哪一刻、以什么形式流进 FIFO 的。

BMP 文件头:54 字节里藏着什么

要读 BMP,先得懂它的格式。标准 BMP 由「文件头 + 像素数据」组成,代码里只解析文件头里的几个关键字段:

字节偏移字段位宽代码里的名字含义
0–1魔数2 字节header_0 / header_1固定为 "BM"(0x42 0x4D)
2–5文件大小4 字节file_len整个文件的字节数(小端)
10–13像素偏移4 字节pixel_offset像素数据从第几字节开始,标准为 54
18–21宽度4 字节width图像宽(本项目要求 640)
22–25高度4 字节height图像高(本项目要求 480)
28–29位深2 字节bit_count每像素位数(要求 24)
30–33压缩4 字节compression0=无压缩,其余都不支持

解析逻辑用了一个「字节计数器 + case」的经典写法。rd_cnt 在数据有效时自增,rd_cnt == N 正好对应文件第 N 个字节,于是按偏移把字段填进对应寄存器:

10'd0 : header_0 <= sd_sec_read_data;   // 'B'
10'd1 : header_1 <= sd_sec_read_data;   // 'M'
10'd2 : file_len[7:0]   <= sd_sec_read_data;
10'd3 : file_len[15:8]  <= sd_sec_read_data;
...
10'd18: width[7:0]   <= sd_sec_read_data;   // 宽度低字节在前(小端)

小端序是什么? BMP 的多字节字段按「低字节在前」存储。比如宽度 640 = 0x0280,文件里先存 0x80 再存 0x02。所以代码里 width[7:0] 接第一个字节、width[15:8] 接第二个字节,恰好把小端序还原成正确的 16 位数。

一张「合格图」的五个条件

读到头之后,怎么判断这一张是不是我们要的 BMP?靠一个组合逻辑:

assign header_match = (header_0 == "B")   &&
                      (header_1 == "M")   &&
                      (width[15:0]  == bmp_width)  &&   // 要求 640
                      (height[15:0] == bmp_height) &&   // 要求 480
                      (bit_count    == 16'd24)      &&
                      (compression  == 32'd0);

五个条件同时成立才算「合格」:

  1. 魔数是 "BM"——不是随便一个文件都能糊弄过去;
  2. 宽、高等于目标分辨率 bmp_width / bmp_height——不匹配的图直接跳过;
  3. 位深 24——这是最主流的真彩色格式;
  4. 无压缩——压缩过的 BMP 数据区不是裸像素,解析不了。

这套校验在扫描加载两个阶段都用到了,是「过滤垃圾文件」的第一道关卡。

扫描模式:在整张卡里找到前几张图

扫描模式的精妙之处在于「用文件大小跳扇区」。SD 卡一个扇区 512 字节,而一张 640×480×24bit 的 BMP 大约 90 万字节、占 1800 多个扇区。如果挨个扇区盲扫,得读几十万次;但读到头之后 file_len 已知,就能算出这张图占多少扇区、直接跳过去:

assign file_sector_count = (file_len == 32'd0) ? 32'd1 : ((file_len + 32'd511) >> 9);
assign next_scan_sector_if_match = scan_sector + file_sector_count;  // 命中:跳过整张图
assign next_scan_sector_if_miss  = scan_sector + 32'd1;              // 未命中:只进一个扇区

(file_len + 511) >> 9 就是「向上取整的 file_len / 512」,即文件占的扇区数。扫描状态机的流程:

ST_SCAN: begin
    sd_sec_read <= 1'b1;              // 持续读当前扇区
    if (sd_sec_read_end) begin
        if (header_match) begin
            scan_found_valid  <= 1'b1;
            scan_found_sector <= scan_sector;         // 记下这张图的起始扇区
            scan_found_total  <= scan_found_total + 3'd1;
            sd_sec_read_addr  <= next_scan_sector_if_match;  // 跳过整张图
            scan_sector       <= next_scan_sector_if_match;
            ...
        end else begin
            sd_sec_read_addr  <= next_scan_sector_if_miss;   // 只进一个扇区
            scan_sector       <= next_scan_sector_if_miss;
        end
    end
end

找到 scan_target_count 张、或者扫到 scan_max_sector 还没找够,就拉高 scan_done 收工。找到的每张图起始扇区,会通过 scan_found_sector 交给上一章的 sd_card_bmp 存进 img_sector0~3

扫描的收益 一次扫描,换来后面无数次「按扇区号直达」。之后的加载不再需要找,直接 load_sector 指哪读哪。这就是「先索引、后随机访问」的思路,和文件系统的目录表一个道理。

加载模式:write_req / write_req_ack 握手

加载一张图时,状态机走 ST_LOAD_HDR → ST_LOAD_WAIT → ST_LOAD_DATA。关键在于进入数据阶段之前,要先和下游的写侧握一次手:

ST_LOAD_HDR: begin
    sd_sec_read <= 1'b1;
    if (sd_sec_read_end) begin
        if (header_match) begin
            write_req        <= 1'b1;               // 向 frame_read_write 申请写
            sd_sec_read_addr <= load_sector_latched; // 回到文件头,准备重读像素
            state            <= ST_LOAD_WAIT;
        end else
            state <= ST_IDLE;                        // 头不对,放弃
    end
end

ST_LOAD_WAIT: begin
    if (write_req_ack) begin
        write_req <= 1'b0;
        state     <= ST_LOAD_DATA;                   // 下游答应,开读
    end
end

这里藏着两个细节:

  1. 为什么要握手? 写侧(frame_read_write 的写状态机)要准备好「基地址 + 长度」才能接数据。write_req 拉高、等 write_req_ack 回来,双方对齐后再开始灌像素,才不会丢开头的数据。
  2. 为什么读头后又回到文件头? ST_LOAD_HDR 已经读了一个扇区、解析了头,但像素数据要从 pixel_offset 开始。最简单粗暴的做法是:把地址设回 load_sector_latched,从头再读一遍,用 bmp_len_cnt 计数跳过前 54 字节头。多读一个扇区的代价,换来逻辑简单可靠。

三字节拼一个像素:BGR 顺序

BMP 的像素是 BGR 顺序存储的——先蓝、再绿、再红,和常见的 RGB 正好反着。代码用一个 2bit 计数器 bmp_byte_idx 在 0/1/2 之间循环,凑满 3 字节才输出一个像素:

case (bmp_byte_idx)
    2'd0: bmp_data[7:0]   <= sd_sec_read_data;   // 第 1 字节:蓝 B
    2'd1: bmp_data[15:8]  <= sd_sec_read_data;   // 第 2 字节:绿 G
    2'd2: begin
        bmp_data_wr_en  <= 1'b1;                 // 第 3 字节:红 R,凑满才写
        bmp_data[23:16] <= sd_sec_read_data;
    end
endcase

配套的还有一个「数据有效」判断,决定哪些字节算像素、哪些该跳过:

assign bmp_data_valid = (sd_sec_read_data_valid)   &&
                        (bmp_len_cnt >= pixel_offset) &&  // 跳过 54 字节文件头
                        (bmp_len_cnt <  file_len);        // 不超出文件

bmp_len_cnt 从进入 ST_LOAD_DATA 开始每字节自增,用 pixel_offset(默认 54)把文件头挡在门外,只有真正的像素字节才拼进 bmp_data

BGR vs RGB 如果直接把 BMP 的字节当 RGB 用,红蓝会互换,画面变成「蓝脸」。这里拼成 {R, G, B} 的高位是红、低位是蓝,正好把 BGR 顺序纠正成内部统一的 RGB。

数据流全链路:把前三章串起来

到这里,可以把四章的知识连成一条完整的线了。一个像素从 SD 卡到屏幕,要过这五道关卡:

环节模块干了什么
1bmp_read读 BMP 文件、校验头、拼出 24bit 像素
2sd_card_bmp决定写哪块缓冲、何时切换显示
3frame_read_write写 FIFO 跨域 → 写状态机突发写进 SDRAM
4frame_read_write读状态机突发读出 → 读 FIFO 跨域
5video_timing_data按行/场时序把像素推给 HDMI

bmp_read 输出的 24bit 像素,正是第三章里 sd_card_bmp 那行重打包的输入:

// sd_card_bmp.v 里:把 24bit RGB 扩成 32bit(低 8 位补零)
assign write_data = {bmp_data[23:16], bmp_data[15:8], bmp_data[7:0], 8'b0};

24bit 像素在这里被补成 32bit,对齐 SDRAM 的 32bit 数据总线,然后才进 frame_read_write 的写 FIFO。每一步都环环相扣——这就是官方例程把「读文件」和「存帧」拆成两个模块、用握手衔接的用意:各管一段,接口清晰,出了问题也知道该查谁。

这一章的手术点 想支持 PNG 或 JPEG?在 header_match 里放宽格式判断、在数据阶段加解码逻辑。想支持任意分辨率?把 bmp_width/bmp_height 从固定参数改成从文件头现读。改动都集中在这一个模块,不影响下游。
🧠 小测验
`bmp_read` 有哪两个任务?
扫描(上电找前几张 BMP 的起始扇区)和加载(按扇区读一张图并解析像素)
`header_match` 要求同时满足哪五个条件?
魔数 "BM"、宽等于 bmp_width、高等于 bmp_height、位深 24、无压缩
扫描时为什么能「跳扇区」而不是逐扇区盲扫?
读到头后 file_len 已知,用 (file_len+511)>>9 算出文件占的扇区数,命中后直接跳过整张图
加载进入数据阶段前为什么要 write_req/write_req_ack 握手?
让下游写状态机先准备好基地址和长度,双方对齐后再灌像素,避免丢开头数据
BMP 为什么是 BGR 顺序、代码怎么纠正?
BMP 蓝绿红存储,bmp_byte_idx 三字节拼成 {R,G,B},把 BGR 纠正成内部 RGB
← 上一篇🏠 顶层目录下一篇 →