简介:面向数字逻辑与图像处理方向的开发者,FPGA读取SD卡图片并经VGA显示输出的完整Verilog工程提供了从SPI读取、SDRAM缓存到VGA驱动的全流程参考。工程基于Cyclone IV E系列EP4CE10F17C8,Quartus 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 <= 1'b0; #SPI_HALF_PERIOD spi_sclk <= 1'b1; 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 <= 1'b0; clk_cnt <= clk_cnt + 1; end else begin clk_cnt <= 0; state <= SEND_CMD0; end end SEND_CMD0: begin // 0x40 | 0 = 发送 CMD0,参数 0,CRC 0x95 cs <= 1'b0; send_byte(8'h40); send_byte(8'h00); send_byte(8'h00); send_byte(8'h00); send_byte(8'h00); send_byte(8'h95); 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 <= 1'b0; send_byte(8'h77); // CMD55 索引为 55 send_byte(8'h00); send_byte(8'h00); send_byte(8'h00); send_byte(8'h00); send_byte(8'hFF); // SPI 模式 CRC 不检查,填 FF state <= WAIT_R1; end SEND_ACMD41: begin cs <= 1'b0; send_byte(8'h69); // CMD41 send_byte(8'h40); // HCS=1,表示主机支持高容量卡 send_byte(8'h00); send_byte(8'h00); send_byte(8'h00); send_byte(8'hFF); state <= WAIT_R1; end关于0x40这个参数需要展开说明一下:它对应 32 位参数的最高字节,最高位是 HCS(Host 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 字段 | 每扇区字节数,通常 512 |
| DBR | 偏移 0x0D | 保留扇区数 | FAT 表开始的位置 |
| DBR | 偏移 0x10 | FAT 表数量 | 通常为 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 和 VS
VGA 显示的本质是连续扫描:逐行从左到右,一行结束发一个行同步脉冲,然后回到下一行;扫到屏幕底部后发出场同步脉冲并回到第一行。FPGA 里产生这套时序只需要一个像素计数器和一个行计数器,没有复杂逻辑。最常见的是 640x480@60Hz 模式,像素时钟 25.175MHz,工程里经常直接用 25MHz,不会造成肉眼可见的问题。计数范围和脉冲位置如下:
| 时序段 | 行方向(单位:像素) | 场方向(单位:行) |
|---|---|---|
| 同步脉冲 | 96 | 2 |
| 后沿 | 48 | 33 |
| 有效数据 | 640 | 480 |
| 前沿 | 16 | 10 |
| 总计 | 800 | 525 |
// 行计数器: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) ? 1'b0 : 1'b1; assign vsync = (v_cnt < 2) ? 1'b0 : 1'b1; // 有效显示区域的标志 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 时钟跑 25MHz,SPI 模式一位一个时钟、一字节 8 位再加 8 位响应时间,理论上还能跟上,实际上必须开卡的多块读取命令(CMD18),连续读多个扇区不用重新发命令,节省大量命令开销。如果读卡速度确实跟不上,就把图像做成 16 位色(RGB565),一行数据量压缩到 1280 字节,或者把图片缩小到 320x240 再用,肉眼观看完全能接受。
3.4 边界与最后一行:避免读越界导致的画面闪烁
VGA 有效区坐标是0 <= x < 640且0 <= y < 480,但 VGA 时序里真正输出的像素位置由计数器决定。如果图片数据没有填满整帧,例如IMG_W=320、IMG_H=240,那么rd_addr超出图片地址范围时读出的数据是 RAM 里的残留值,画面会出现花边或杂色条纹。处理办法是在地址计算逻辑里加一个范围判断:
assign inside_img = (img_x < 320) && (img_y < 240); assign rd_addr = inside_img ? (img_y * 320 + img_x) : 21'd0; assign rgb_out = inside_img ? ram_rdata : 24'h000000;用inside_img同时控制读地址和输出数据,越界区域强制输出黑色,这是一个不消耗额外资源的干净方案。另一个隐蔽问题是最后一行的数据还没有完全写进 RAM,VGA 已经把这一行扫出去了,表现为屏幕顶部或底部有一条撕裂线。解决思路是让 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_50m | input | 3.3V LVTTL | 板载晶振输入 |
| rst_n | input | 3.3V LVTTL | 复位,低有效,必须接上拉电阻 |
| sd_cs / sd_di / sd_sclk | output | 3.3V LVTTL | SD 卡 SPI 三根控制线 |
| sd_do | input | 3.3V LVTTL | 卡返回数据,板上有上拉最稳 |
| vga_hs / vga_vs | output | 3.3V LVTTL | 同步信号直连 VGA 接头 |
| vga_r / vga_g / vga_b | output | 3.3V LVTTL | 每个颜色 8 位,接电阻网络或 DAC |
VGA 的 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 卡初始化阶段会反复发送 ACMD41,testbench 里必须模拟卡返回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 文件头字节序问题非常有效。
本文还有配套的精品资源,点击获取