FPGA图像链路实战:SD卡SPI读取FAT32文件与VGA显示 简介面向数字逻辑与图像处理方向的开发者FPGA读取SD卡图片并经VGA显示输出的完整Verilog工程提供了从SPI读取、SDRAM缓存到VGA驱动的全流程参考。工程基于Cyclone IV E系列EP4CE10F17C8Quartus 18.0环境顶层模块top_sd_photo_vga将SD卡、SDRAM和VGA接口分层清晰便于理解存储介质到屏幕显示的完整链路。压缩包共375个文件大小约9.86MB除20个.v源码和35个.tdf设计文件外还包含引脚/时序约束、Quartus工程配置以及可直接下载的.sof镜像。目前已有721人浏览学习适合中高级FPGA学习者作为项目参考。工程内保留常用模块备份可对照阅读sdram_top、vga_driver、sd_ctrl_top等设计快速移植和二次调试深入掌握SD卡接口、SDRAM控制器及VGA时序的协同设计。1. 先把读卡这件事讲清楚很多刚接触 FPGA 图像链路的工程师第一反应是 VGA 时序怎么写、像素时钟怎么分频。真正动手才发现整条链路里最花时间的不是 VGA而是怎么从 SD 卡里把图片数据抠出来SD 卡 SPI 模式初始化要处理老卡和新卡的差异FAT32 文件系统要在 Verilog 里用状态机解析扇区、簇和目录项中途还要解决跨扇区读取的地址连续性。这个工程的价值就在这里——它把“读卡、解析、缓存、显示”四段串成一条完整的数字链路适合已经会点灯、会跑 UART想往图像和存储方向走的开发者。下面从 SD 卡 SPI 初始化开始把每一步的 Verilog 实现和 Quartus 工程配置串起来讲。2. 先把数据读出来SD 卡 SPI 模式初始化与 FAT32 文件解析2.1 SD 卡 SPI 模式的引脚连接与命令格式SD 卡原生支持 SDIO 总线但 FPGA 工程里更常见的做法是切换到 SPI 模式原因很直接SPI 只需要 4 根信号线时序简单不需要 SDIO 那样复杂的多线同步逻辑。上电后向卡发送至少 74 个时钟周期再发送 CMD0卡就会进入 SPI 模式。四种信号对应关系如下信号名SPI 方向作用注意事项CS输入片选低有效整个命令周期内必须保持低电平DI输入主机发送数据命令和写数据都走这根线DO输出卡返回数据无数据时保持高阻需上拉SCLK输入时钟初始化阶段不超过 400kHz之后可提高SPI 模式下主机发送的每条命令都是固定 48 位1 位起始位0、1 位方向位1、6 位命令索引、32 位参数、7 位 CRC 校验位和 1 位停止位1。需要注意 CMD0 和 CMD8 的 CRC 是必须正确的其他命令在 SPI 模式下可以填0x00卡不会校验。下面是一段初始化状态机的关键代码用有限状态机逐条发送命令并等待响应。// 主机发送一个字节到 SD 卡 task send_byte; input [7:0] data; integer i; begin for (i 7; i 0; i i - 1) begin spi_mosi data[i]; #SPI_HALF_PERIOD spi_sclk 1b0; #SPI_HALF_PERIOD spi_sclk 1b1; end end endtask // 初始化状态机主体 always (posedge clk) begin if (init_done) begin state IDLE; end else begin case (state) INIT_POWER_ON: begin // 发送 80 个时钟周期让卡完成上电稳定 if (clk_cnt 80) begin spi_sclk 1b0; clk_cnt clk_cnt 1; end else begin clk_cnt 0; state SEND_CMD0; end end SEND_CMD0: begin // 0x40 | 0 发送 CMD0参数 0CRC 0x95 cs 1b0; send_byte(8h40); send_byte(8h00); send_byte(8h00); send_byte(8h00); send_byte(8h00); send_byte(8h95); state WAIT_R1; end // 后续状态依次处理 CMD8、ACMD41、CMD58 endcase end end上面代码里两个细节值得注意。第一send_byte先拉低 SCLK 再输出数据SCLK 空闲时为高这是 SPI Mode 0 的习惯写法SD 卡完全兼容。第二INIT_POWER_ON阶段没有给 CS 拉低这时卡还在识别 SDIO 还是 SPI 模式直到 CMD0 发送前再把 CS 拉低卡收到 CMD0 后才真正切换到 SPI。初始化失败时最常看到的现象是 R1 响应一直为0xFF说明卡根本没收到命令或时钟太快先检查 SCLK 是否超过 400kHz再看 CS 是否在整个命令期间保持低电平。2.2 CMD8 与 ACMD41 的区别为什么不能跳过新卡和旧卡在 SPI 模式下的初始化流程有差异。只支持 SD V1.x 的卡在 CMD0 之后可以直接循环发送 ACMD41但 SD V2.0 以上的卡要求先发 CMD8 确认工作电压。判断卡版本的方法很直观如果 CMD0 之后发 CMD8卡能返回0x01进入 IDLE 状态后收到 CMD8 会回到 IDLE 并返回合法 R7说明它是 V2.0 卡如果 CMD8 返回无效数据或超时那就是老卡或 MMC 卡。ACMD41 是一条特殊命令必须先用 CMD55 告诉卡“下一条是应用相关命令”然后才能发 ACMD41顺序不能反。SEND_CMD55: begin cs 1b0; send_byte(8h77); // CMD55 索引为 55 send_byte(8h00); send_byte(8h00); send_byte(8h00); send_byte(8h00); send_byte(8hFF); // SPI 模式 CRC 不检查填 FF state WAIT_R1; end SEND_ACMD41: begin cs 1b0; send_byte(8h69); // CMD41 send_byte(8h40); // HCS1表示主机支持高容量卡 send_byte(8h00); send_byte(8h00); send_byte(8h00); send_byte(8hFF); state WAIT_R1; end关于0x40这个参数需要展开说明一下它对应 32 位参数的最高字节最高位是 HCSHost Capacity Support标志置 1 表示主机支持 SDHC/SDXC 高容量卡。初始化期发送 ACMD41 时如果卡返回 R1 的 bit 0 仍为 1说明卡还在上电初始化中需要延时一段时间后重发。整个流程通常要做几百次轮询所以代码里一定要给 ACMD41 加一个超时计数器否则遇到坏卡或接触不良的卡座FPGA 会卡死在无限循环里。对 2GB 以上的卡看到 CMD58 返回 OCR 寄存器的 bit 30 为 1就能确认它工作在块寻址模式此后读扇区的地址直接填 LBA 块号单位是 512 字节而不是字节地址。这一点不搞清楚后面读 FAT32 的偏移量会全部错位。2.3 FAT32 文件解析跳过 MBR从 DBR 找数据图片文件在 SD 卡里按 FAT32 文件系统存放FPGA 里实现一个小型 FAT32 解析器是完全可行的但没必要完整实现只需要读四个结构MBR、DBR、FAT 表项和根目录。实际工程里我常做一个简化如果 SD 卡整卡格式化为 FAT32没有分区表部分 TF 卡出厂就是这种状态MBR 可以跳过直接认为卡偏移 0 处是 DBR。如果确认有分区表就先把 MBR 里偏移 0x1C6 处的分区起始 LBA 读出来再加到所有后续偏移上。要找到图片文件的数据按下面的顺序走一遍数据来源偏移位置关键字段作用DBR引导扇区扇区 LBA 起始偏移 0x0E 的 BPB 字段每扇区字节数通常 512DBR偏移 0x0D保留扇区数FAT 表开始的位置DBR偏移 0x10FAT 表数量通常为 2需跳过两份DBR偏移 0x0C每簇扇区数计算出簇到扇区的换算因子簇的换算公式是固定的数据区起始扇区 保留扇区数 FAT 表数量 × 每个 FAT 扇区数 隐藏扇区数文件数据扇区地址 数据区起始扇区 (簇号 - 2) × 每簇扇区数。写 Verilog 状态机时建议把这些值做成内部寄存器由一小段解析状态机在初始化阶段从卡的固定扇区读取不要硬编码因为不同容量的 SD 卡这几个数值不一样。根目录的定位方法是读取 DBR 偏移 0x10 处的字段得到根目录最大目录项数再根据这个值和每扇区字节数算出根目录占用几个扇区。FAT32 的根目录是一个普通链表可以存放在任意位置所以更可靠的办法是通过 FAT 表找到根目录第一簇再从第一簇推算扇区地址。目录项每个占 32 字节图片文件名通常用 8.3 短文件名格式存储比如PIC.BMP直接查文件名后缀BMP就能定位到起始簇号。如果遇到的是从 Windows 复制进去的长文件名目录里会出现多个 0x0F 属性的目录项必须跳过它们才能找到真正的 0x20 属性短名目录项否则会把文件名碎片当成文件本身。下面的代码展示了从目录项提取起始簇号的过程。// 假设 dir_data 是目录项所在的 32 字节缓冲 wire [15:0] cluster_low {dir_data[25:24], dir_data[21:20]}; wire [15:0] cluster_high {dir_data[27:26], dir_data[23:22]}; wire [31:0] start_cluster {cluster_high, cluster_low}; // 根据簇号计算字节偏移 wire [47:0] byte_offset data_start_sector * 512 (start_cluster - 2) * sectors_per_cluster * 512;注意 FAT32 的簇号是 32 位但单个文件的起始簇号存在目录项里分高低两段每段 16 位合成时要小心字节序。取到起始簇后还要通过 FAT 表项判断文件是否占用多个簇。如果图片大于一个簇的大小每读到簇末尾就要去 FAT 表查下一个簇号FAT32 表项存储在连续扇区中每次查表需要先读对应扇区再按偏移取出 32 位值对低 28 位做与运算得到下一个簇号。处理跨簇读取时状态机要新增一个LOOKUP_NEXT_CLUSTER状态否则读到簇边界会断流。一个常见的坑是 FAT 表项读到0x0FFFFFFF表示文件结束此时如果图片数据还没读够说明文件被放在多个分段簇里需要按链继续找如果读够了直接回到 IDLE 等待下一帧即可。3. 把读到的像素送进 VGA时序产生与缓存设计3.1 VGA 时序参数用计数器产生 HS 和 VSVGA 显示的本质是连续扫描逐行从左到右一行结束发一个行同步脉冲然后回到下一行扫到屏幕底部后发出场同步脉冲并回到第一行。FPGA 里产生这套时序只需要一个像素计数器和一个行计数器没有复杂逻辑。最常见的是 640x48060Hz 模式像素时钟 25.175MHz工程里经常直接用 25MHz不会造成肉眼可见的问题。计数范围和脉冲位置如下时序段行方向单位像素场方向单位行同步脉冲962后沿4833有效数据640480前沿1610总计800525// 行计数器0~799场计数器0~524 always (posedge clk_25m) begin if (h_cnt 799) begin h_cnt 0; if (v_cnt 524) v_cnt 0; else v_cnt v_cnt 1; end else begin h_cnt h_cnt 1; end end assign hsync (h_cnt 96) ? 1b0 : 1b1; assign vsync (v_cnt 2) ? 1b0 : 1b1; // 有效显示区域的标志 assign active (h_cnt 144 h_cnt 784) (v_cnt 35 v_cnt 515);这里的hsync和vsync都用了负极性同步期间输出低电平对应标准 VGA 时序里 HS 和 VS 都为负极性。显示起点在行方向是 144同步 96 后沿 48场方向是 35同步 2 后沿 33有效区的实时坐标分别是x h_cnt - 144和y v_cnt - 35。如果你手头的显示器对时序要求严格微调符号极性时要注意某些老显示器对极性敏感表现为画面整体偏移或无法锁定。3.2 图像缓存双口 RAM 同时服务 SPI 写入和 VGA 读取SD 卡 SPI 读回的图像数据是连续字节流VGA 是按扫描序访问像素两者天然是生产者消费者模型中间必须加缓存。最简单的做法是例化一个 FPGA 内部的简单双口 RAM写端口由 SPI 模块控制地址从 0 递增到图片总字节数读端口由 VGA 模块控制地址由当前扫描坐标换算得到。这个方案需要在 Quartus 里用 RAM 的 IP 核配置时选择“one write port, one read port”数据宽度设为 24 位RGB888地址宽度根据图片尺寸决定。// 双口 RAM写端口来自 SPI 数据读端口来自 VGA ram_dual #( .DATA_WIDTH(24), .ADDR_WIDTH(19) // 最深支持 524288 像素 ) u_ram ( .wclk (spi_clk), .waddr (wr_addr), .wdata ({r_data, g_data, b_data}), .wen (wr_en), .rclk (clk_25m), .raddr (rd_addr), .rdata (rgb_out) ); // VGA 读取地址y*640 x assign rd_addr (v_cnt - 35) * 640 (h_cnt - 144); assign pixel_valid active;读写时钟不同导致读地址和数据之间有一个周期的延迟如果发现画面整体错位在输出端补一拍寄存器即可。图片分辨率小于 640x480 时可以在计算 README 地址前对坐标做平移或缩放最简单的居中是给rd_addr加上一个固定偏移(480 - IMG_H) / 2 * 640 (640 - IMG_W) / 2。还有一点要提醒SPI 时钟不建议直接用 VGA 像素时钟因为它们职责不同SPI 时钟最好单独用一个 PLL 输出频率可以是 10MHz 到 50MHz按卡的能力来定。3.3 缓存不够大时怎么办分块读入与行缓冲如果图片是 640x480 的 24 位色 BMP单帧裸数据约 900KB而 Cyclone IV 这类入门级 FPGA 内部块 RAM 通常只有几百 Kb 到几十 Kb不可能把整帧缓存住。遇到这种情况我的习惯是改用分级策略先读 BMP 头解析出图片宽高再按行缓冲方式读卡每次只缓冲一行或几行像素VGA 边扫边读 SD 卡。具体做法是把双口 RAM 的地址空间改成一个环形行缓冲SPI 模块每读完一行向 VGA 模块发一个“行就绪”信号VGA 在消隐期间启动下一行读取。行缓冲的好处是不需要大容量 RAM但要求 SD 卡的读取速度必须跟上 VGA 的一行扫描时间。640x480 模式下一行有效像素占用约 31.7 微秒24 位色一行 1920 字节如果 SPI 时钟跑 25MHzSPI 模式一位一个时钟、一字节 8 位再加 8 位响应时间理论上还能跟上实际上必须开卡的多块读取命令CMD18连续读多个扇区不用重新发命令节省大量命令开销。如果读卡速度确实跟不上就把图像做成 16 位色RGB565一行数据量压缩到 1280 字节或者把图片缩小到 320x240 再用肉眼观看完全能接受。3.4 边界与最后一行避免读越界导致的画面闪烁VGA 有效区坐标是0 x 640且0 y 480但 VGA 时序里真正输出的像素位置由计数器决定。如果图片数据没有填满整帧例如IMG_W320、IMG_H240那么rd_addr超出图片地址范围时读出的数据是 RAM 里的残留值画面会出现花边或杂色条纹。处理办法是在地址计算逻辑里加一个范围判断assign inside_img (img_x 320) (img_y 240); assign rd_addr inside_img ? (img_y * 320 img_x) : 21d0; assign rgb_out inside_img ? ram_rdata : 24h000000;用inside_img同时控制读地址和输出数据越界区域强制输出黑色这是一个不消耗额外资源的干净方案。另一个隐蔽问题是最后一行的数据还没有完全写进 RAMVGA 已经把这一行扫出去了表现为屏幕顶部或底部有一条撕裂线。解决思路是让 SPI 读取模块提前若干行开始工作并用vsync信号做帧同步检测到场同步脉冲后让 SPI 模块从图片首地址重新开始读。这样即使上一帧的最后两行还没读到下一帧开始前也会重新对齐肉眼几乎察觉不到。4. 从 Verilog 到板上显示Quartus 工程搭建与调试技巧4.1 Quartus 工程结构与引脚分配拿到一个源码压缩包先不要急着点全编译。先把目录结构看清楚通常包含rtl目录存放所有.v源文件dcp或qip目录存放 IP 核配置顶层文件一般是sd_vga_top.v。在 Quartus 里新建工程后把源码文件添加进工程顶层实体选sd_vga_top然后立刻做两件事第一是分配器件型号第二是用 Assignment Editor 配置引脚。这两个步骤顺序不能反否则编译时引脚约束会冲突。下面是这个工程里最关键的引脚分配表以 Cyclone IV E 系列的 EP4CE10 为例实际引脚必须对应你自己的板卡原理图信号名方向建议引脚属性说明clk_50minput3.3V LVTTL板载晶振输入rst_ninput3.3V LVTTL复位低有效必须接上拉电阻sd_cs / sd_di / sd_sclkoutput3.3V LVTTLSD 卡 SPI 三根控制线sd_doinput3.3V LVTTL卡返回数据板上有上拉最稳vga_hs / vga_vsoutput3.3V LVTTL同步信号直连 VGA 接头vga_r / vga_g / vga_boutput3.3V LVTTL每个颜色 8 位接电阻网络或 DACVGA 的 RGB 信号如果直接接排针输出电压就是 3.3V对标准 VGA 显示器来说偏高容易过曝。常见做法是每个颜色信号串 330 欧姆电阻到 VGA 接头相当于简单 DAC虽然只能显示固定颜色深度但测试时序足够。如果板卡自带 ADV7123 这类视频 DAC要按 DAC 文档把 RGB 信号接到数据总线并额外处理时钟和消隐信号不能直接套用上面的电阻方案。4.2 工程里的时钟配置与 PLL 分频Quartus 工程里VGA 像素时钟和 SPI 时钟最好都由 PLL 产生不要用计数器分频。PLL 输出抖动小而且可以同时输出多个频率一个 50MHz 晶振同时产生 25MHz 像素时钟和 50MHz SPI 时钟。在 Quartus 中例化 PLL 的方法是打开 IP Catalog搜索ALTPLL配置输入时钟频率为 50MHz输出时钟分别设为 25MHz 和 50MHz。产生的 IP 核文件会包含一个.v文件和一个.qip文件添加进工程后顶层代码里通过对 IP 实例化来使用。pll u_pll ( .inclk0 (clk_50m), .c0 (clk_25m), .c1 (clk_spi), .locked (pll_locked) );这里locked信号值得单独提出来用PLL 刚上电时输出时钟不稳定如果复位逻辑直接复位整个 FPGA会导致 SD 卡初始化失败。正确的复位设计是用pll_locked作为全局复位的使能条件或者把locked和外部rst_n相与后再接入 SD 卡模块和 VGA 模块。常见错误是复位信号直接接按钮按钮按下时 PLL 可能还没锁住卡初始化就会收到时序混乱的 SPI 时钟。4.3 Modelsim 仿真与 SignalTap 抓信号的两步验证工程能编译通过不等于硬件正确VGA 时序可以在 Modelsim 里先仿真再上板。要验证 SD 卡读取逻辑仿真时需要给 SPI 的 DO 端口提供一个模拟卡返回的testbench手动构造 R1 响应和扇区数据比盲改代码效率高得多。用 Modelsim 跑仿真时注意SD 卡初始化阶段会反复发送 ACMD41testbench 里必须模拟卡返回0x00如果一直返回0xFF初始化状态机就永远跳不出去。仿真通过后再上板用 SignalTap 抓spi_mosi、sd_do和wr_en三个信号观察写使能是否周期性拉高能快速定位是卡初始化失败还是数据没写入 RAM。SignalTap 调试时设置触发条件很有讲究。如果把触发条件设为wr_en上升沿抓到的波形只会显示 RAM 写入的瞬间更实用的做法是把触发设为vsync下降沿然后观察一整个场周期内的写入活动这样能看到 SPI 读取是否只发生在图像数据段内。如果发现 VGA 输出蓝屏或黑屏先查locked信号是否拉高再查hsync和vsync波形是否正常最后再查 RAM 读数据线。VGA 的输出数据要给到rgb_out注意在active无效期间强制输出0x00否则消隐区会夹带毛刺显示器可能出现随机噪点。4.4 从 .sof 到 .jic固化到配置芯片的一些建议调试阶段用 Quartus Programmer 下载.sof文件JTAG 下载后 FPGA 直接运行掉电丢失适合反复改代码的场景。功能验证完成后如果想让板卡上电自动运行需要把配置文件固化到板载 EPCS 配置芯片里。步骤如下先执行File - Convert Programming Files选择编程文件类型为 JTAG 间接配置.jic配置芯片型号按板卡实际情况选然后添加.sof文件转换完成后下载.jic。这一步常遇到的坑是转换时提示 SOF 文件包含未分配引脚回到 Assignment Editor 补上所有引脚约束重新全编译后再转换。最后有一个关于 SD 卡调试顺序的个人经验先让 LED 闪烁确认时钟和复位正常再单独调 VGA 显示纯色画面确认时序无误最后才把 SD 卡读数据逻辑挂上去。这个顺序能把故障限定到具体模块避免一开始就面对黑屏却不知道是时序问题还是读卡问题。图像能正常显示后可以试着把读到的原始像素通过 UART 发回 PC用十六进制工具对比 SD 卡里的文件内容验证数据链路的正确性这一步虽然手动操作多一些但对排查 BMP 文件头字节序问题非常有效。本文还有配套的精品资源点击获取