FPGA实战:Verilog实现SD卡图像读取与VGA显示
2026/9/16 12:58:19 网站建设 项目流程

简介:这份FPGA设计资源提供读取SD卡中图片并通过VGA屏显示输出的完整Verilog工程源码,基于Cyclone IV E系列EP4CE10F17C8器件,Quartus 18.0环境,面向FPGA学习者、嵌入式及数字图像显示方向开发者,解决SD卡SPI读取、SDRAM缓存与VGA时序控制联动问题。压缩包约9.86MB,共375个文件,以.v/.tdf源码、.qsf/.qpf工程配置、.sdc时序约束和.sof下载文件为主,另有.tcl脚本、图片素材及readme说明,目录结构完整。已有721人学习下载。工程顶层top_sd_photo_vga清晰划分sd_ctrl_top、sdram_top、vga_driver、sd_read_photo等子模块,涵盖SD卡读扇区、SDRAM缓存、640×480分辨率VGA输出,适合对照源码理解跨时钟域与接口时序,也可作为毕业设计或课程设计的基础框架。

1. 从SD卡到VGA,图片数据流的“节奏”才是设计核心

把SD卡读出来的图片直接接到VGA显示器上,画面只会是一团灰白的闪条——因为SD卡在SPI模式下是突发传输:一次读512字节,中间夹着命令、响应和CRC,而且FIFO随时可能被读空。而VGA要求每个像素时钟周期都稳定送出一个像素,停一拍画面就闪。这个工程真正要解决的问题,是把SPI读卡、FAT32文件解析、VGA时序生成和跨时钟域缓冲这四件事串成一条数据流水线。

对已经写过流水灯和UART的开发者来说,这套设计正好把状态机设计、异步FIFO、时序约束和板级调试这些高频技能串到了一起;对刚接触FPGA的人,它也是一个能看得见、摸得着的完整项目,比跑仿真更有成就感。下面就从最底层SD卡读命令讲起,一步步把这条链路搭建出来。

2. 用Verilog在SPI模式下初始化SD卡,先跑通单块读命令

2.1 为什么优先选SPI模式,以及SD卡的引脚怎么接

SD卡支持SD总线和SPI两种工作模式。SD总线用4bit数据线并行传输,理论带宽更高,但主机侧要处理CMD线、数据线、CRC校验和更复杂的协议状态,在FPGA里实现工作量明显大于SPI。SPI模式只需要SCK、MOSI、MISO、CS四根线,逻辑简单,FPGA侧只用移位寄存器和状态机就能驱动。图片显示这类应用,数据量一般在几十KB到几百KB之间,SPI模式下25MHz时钟、每字节8个时钟周期,理论吞吐约3MB/s,完全够用。

硬件连接上常见做法是:FPGA的3.3V IO直接连SD卡的SPI引脚,CS、MOSI、SCK各接一个10kΩ上拉到3.3V,MISO是SD卡输出,不需要上拉。VCC和VSS之间加大于10μF的退耦电容,因为SD卡读卡瞬间电流变化很大。

提示:SD卡的物理写保护滑块和SPI模式是两套独立机制,很多卡座上读卡失败都源于卡座弹片接触不良,在排错时先排除卡座问题,否则会一直怀疑代码。

2.2 SD卡进入SPI模式的完整时序

SD卡上电后默认进入SD总线模式,必须发送特定命令序列才能切到SPI模式。整个过程分两步:先让CS保持高电平,发送至少74个SCK脉冲,让卡完成内部上电初始化并同步时钟。然后把CS拉低,发送CMD0,如果收到响应0x01,表示卡已进入SPI模式。

SD卡SPI命令是6字节固定格式:第一个字节是01 + 命令号(命令号6bit),接着4字节是大端序参数,最后一个字节是CRC7校验加停止位1。下表列出了初始化时需要用到的命令。

命令编号字节参数说明
CMD00x400x00000000复位卡,进入SPI模式,返回0x01
CMD80x480x000001AA判断卡类型,SDHC卡返回0x01后跟4字节
CMD580x7A0x00000000读取OCR寄存器,判断是否支持SDHC
CMD170x51块地址(32bit)读取一个512字节的数据块

进入SPI模式后要发送CMD8,这个命令带0x1AA参数,用来区分SDSC和SDHC卡。随后循环发送ACMD41(先发CMD55再发CMD41),直到响应为0x00,表示卡初始化完成。ACMD41的HCS位(bit30)在支持SDHC时必须置1,否则卡可能以字节寻址方式工作,返回值会不同。

初始化完成后,读操作就固定用块寻址——SDHC卡的地址直接是逻辑块号,而不是字节偏移。这在后续FAT32解析时直接影响扇区地址的计算方式。FAT32的扇区号换算成字节地址后,要除以512才是CMD17的参数;但如果卡是SDSC,地址就是字节地址。两种卡混用是新手最常见的初始化错误。

2.3 单块读取的状态机设计:从发命令到收数据

SD卡在SPI模式下读一个块的完整流程:发CMD17,等待R1响应(0x00表示成功),然后继续等数据起始令牌0xFE,0xFE之后跟着512字节有效数据,最后是2字节CRC。注意SPI模式下的CRC可以不校验,但必须把两个字节读掉,否则卡发下一块数据时会错位。

下面是一个精简版的状态机代码片段,直接用Verilog实现CMD17的发送和数据接收。

// sd_read_fsm.v // 支持CMD17单块读取的状态机,数据输出到rd_data localparam IDLE = 3'd0; localparam SEND_CMD = 3'd1; localparam WAIT_R1 = 3'd2; localparam WAIT_TOKEN= 3'd3; localparam READ_DATA = 3'd4; localparam READ_CRC = 3'd5; reg [2:0] state; reg [5:0] bit_cnt; // 发送/接收的bit个数 reg [7:0] tx_byte; // 当前发送字节 reg [7:0] rx_byte; // 当前接收字节 reg [8:0] byte_cnt; // 数据字节计数 0~511 reg [7:0] cmd_buf [5:0]; // 6字节命令缓存 always @(posedge sclk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; end else begin case (state) IDLE: begin cs_n <= 1'b0; state <= SEND_CMD; end SEND_CMD: begin // 在每个bit的中间位置采样/更新 mosi <= cmd_buf[cmd_idx][bit_idx]; if (bit_cnt == 6'd47) begin bit_cnt <= 6'd0; state <= WAIT_R1; end else begin bit_cnt <= bit_cnt + 1'b1; end end // WAIT_R1状态:接收8bit,值不为0xFF表示卡有响应 endcase end end

这段代码演示了命令发送核心逻辑:状态机在SEND_CMD状态下逐bit移位发出6字节,bit_cnt计数48个SCK周期。WAIT_R1状态要求从MISO采样响应,如果读到0xFF说明卡还没准备好,继续等待;只有读到不是0xFF的字节才认为是有效R1响应。

实际使用时CMD17发送前要先在IDLE状态把卡选择信号CS拉低,命令发完后保持低电平直到整个数据块接收完。数据接收的完整过程是:等待0xFE令牌出现后,连续读512个字节,再跳过2个CRC字节,最后回到IDLE态并把CS拉高。这里的byte_cnt需要计数到512,超过视为超时错误,FPGA工程里要加一个超时计数器,避免卡无响应时状态机卡死。

提示:SPI的采样沿和输出沿要错开。数据在SCK下降沿变化、上升沿采样是常见做法。初始化阶段时钟频率设低一点(400kHz以下),正常读写时再切到25MHz,这个切换用Quartus里的PLL IP实现最方便。

3. 在FPGA内部手写一个FAT32解析器,定位图片文件的起始扇区

3.1 为什么不在FPGA里移植FatFs,而是自己写解析逻辑

FatFs是STM32等MCU上常用的文件系统库,它依赖malloc、字符串比较、多任务锁等C语言运行环境。FPGA里想用FatFs,就得用软核处理器(如Nios II)跑软件,但这个工程标题限定为“Verilog设计”,意味着纯逻辑实现。纯RTL直接读FAT表、算簇号、按簇读数据,代码量不大,查一张目录表只需要几种固定状态,比在FPGA里挂一个CPU省资源也更直接。

FAT32的文件查找过程是固定的套路:先读BPB参数,确认每扇区字节数、每簇扇区数、FAT表起始扇区等;然后从根目录簇号开始,逐条扫描32字节的目录项,找到文件名匹配的项;目录项里记录了文件起始簇号高16位和低16位,拼成32bit簇号;最后按文件长度算出要读多少个簇。文件数据在磁盘上可能不连续,需要查FAT表拿到下一簇号。

3.2 BPB参数表:先读取这些偏移量

FAT32的BPB位于第0号扇区(主引导扇区),从偏移地址0x0B开始是一组定长字段。FPGA端把扇区读到内存后,按字节偏移取数即可。下面列出最关键的几个字段。

偏移地址字段长度内容说明
0x0B2字节每扇区字节数,FAT32固定为512
0x0D1字节每簇扇区数,常见值1、8、16、32
0x244字节FAT表起始扇区号
0x2C4字节根目录起始簇号
0x204字节总扇区数,用于判断卡容量

解析逻辑里最需要注意的是字节序:FAT32所有多字节字段都是小端存储,FPGA端接收到的数据每个字节独立进入FIFO,拼接时要把低字节放低位。比如读取0x24处的4字节,若依次读到0x00 0x00 0x00 0xF8,实际FAT起始扇区是0xF8000000。

在Verilog里做BPB解析,通常是把扇区的512字节存入RAM,然后用状态机逐个偏移读取。代码思路是:读扇区时将数据按字节写入reg [7:0] sector_buf[0:511],然后用一个case语句根据当前读取阶段取出对应字节组。

3.3 目录项查找:从“根目录簇”到“目标文件”的路径

FAT32的根目录不是一个固定区域,而是一个普通目录,从BPB偏移0x2C处的簇号开始。目录项每32字节一条,文件名偏移0x0B、扩展名偏移0x0C。目录项结构里,偏移0x14是文件起始簇号低16位,偏移0x1A是高16位,拼起来就是文件的起始簇号;偏移0x1C是文件字节长度。

FPGA端扫描目录时,要按“簇号→扇区→目录项”的顺序遍历。一个簇可能包含多个扇区,每扇区512字节包含16个目录项。常见做法是维护一个512字节的扇区缓冲,逐条比对目录项的前几个字节是否为目标文件名(如PIC.BMP)。

// fat_dir_scan.v // 目录项扫描,匹配文件名并输出起始簇号和文件长度 reg [4:0] dir_cnt; // 当前扇区内的目录项序号 0~15 reg [8:0] byte_idx; // 当前扇区内的字节偏移 reg [31:0] start_cluster; reg [31:0] file_size; always @(posedge clk) begin if (match_en) begin // 从sector_buf取32字节目录项 if (sector_buf[byte_idx+32'd11] == "P" && sector_buf[byte_idx+32'd12] == "I" && sector_buf[byte_idx+32'd13] == "C") begin start_cluster[15:0] <= {sector_buf[byte_idx+8'h15], sector_buf[byte_idx+8'h14]}; start_cluster[31:16] <= {sector_buf[byte_idx+8'h1B], sector_buf[byte_idx+8'h1A]}; file_size <= {sector_buf[byte_idx+8'h1F], sector_buf[byte_idx+8'h1E], sector_buf[byte_idx+8'h1D], sector_buf[byte_idx+8'h1C]}; found_flag <= 1'b1; end end end

这里的关键是目录项名称比较时用的是ASCII码字符串字面量,Verilog的字符串比较是按字节进行的。sector_buf[byte_idx+11]对应目录项的短文件名第一个字符,BMP文件在FAT32里如果文件名不超过8字符,就直接存储在偏移0x0B。文件名是PIC.BMP时,前三个字符匹配即可继续校验扩展名段。

拿到起始簇号后,读取文件数据时每次读完一个簇要查一次FAT表,找到下一簇号。FAT32的FAT表项是4字节,FAT表起始扇区加上簇号乘以4得到该簇项的扇区内部偏移。查FAT表就意味着每读一个簇的数据都要先读一个FAT表扇区——视频流这样查会慢,但图片只需要几十次查找,速度无所谓。

提示:如果文件名带中文或超过8.3短名格式,SD卡会按长文件名机制存储,逻辑复杂度大幅上升。做这个显示项目时,建议在上位机把图片改成8.3格式的短文件名再拷入SD卡,可以省掉LFN解析。

3.4 BMP文件在SD卡里的真实存储形态

图片文件建议直接采用BMP格式(对应搜索里常出现的“bmg”说法)。BMP无压缩、无跨行数据校正,文件头固定54字节,从偏移0x0A处读出像素数据偏移量,从0x12处读出图像宽度,从0x16处读出高度。RGB888的BMP每像素3字节;做成RGB565显示时,FPGA端需要把每两个字节拼成一个16bit像素。

常见做法是先用PC把图片转成RGB565格式的BMP,这样FPGA侧读到的数据流就是连续16bit一个像素,不需要做像素格式转换。转换命令用Python脚本几行就写完。

from PIL import Image img = Image.open("lena.png").convert("RGB") # 统一缩放到 320x240 便于VGA居中显示 img = img.resize((320, 240)) # 转RGB565:R取高5位,G取高6位,B取高5位 raw = bytearray() for r, g, b in img.getdata(): rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) raw += rgb565.to_bytes(2, "little") with open("pic.bmp", "wb") as f: # 简单BMP头,54字节固定 f.write(b"BM" + (len(raw)+54).to_bytes(4,"little") + b"\x00\x00\x00\x00" + (54).to_bytes(4,"little")) f.write((40).to_bytes(4,"little") + (320).to_bytes(4,"little") + (240).to_bytes(4,"little")) f.write((1).to_bytes(2,"little") + (16).to_bytes(2,"little") + (0).to_bytes(4,"little") + (len(raw)).to_bytes(4,"little") + (2835).to_bytes(4,"little") + (2835).to_bytes(4,"little") + (0).to_bytes(4,"little") + (0).to_bytes(4,"little")) f.write(raw)

这段脚本把任意图片转成320×240、RGB565的BMP,文件头54字节,像素区紧跟在后面。FPGA端读到文件数据后从文件头偏移54字节开始取数据即可,完全不需要动态解析BMP信息头。BMP的存储顺序是从下到上、从左到右,VGA显示时如果图像上下颠倒,把读地址镜像一下就行。

4. VGA时序驱动与FIFO缓冲:把“突发读”变成“像素流”

4.1 640x480@60Hz的VGA时序参数

VGA接口的针脚定义很规律:1脚R、2脚G、3脚B、13脚HSYNC、14脚VSYNC,同步信号是负极性TTL电平,RGB是模拟电压0~0.7V。FPGA输出端通常用3.3V的LVTTL通过电阻分压网络或专用DAC芯片(如ADV7123)转换成模拟信号,这个项目的Verilog代码一般直接输出数字RGB。

以最常用的640×480@60Hz为例,像素时钟25.175MHz,FPGA端一般直接用25MHz。整个行周期800个像素时钟:96个同步脉冲、48个后沿、640个有效像素、16个前沿。整个场周期525行:2行同步、33行后沿、480行有效、10行前沿。

参数H_SYNCH_BACKH_ACTIVEH_FRONT整行
像素时钟数964864016800
参数V_SYNCV_BACKV_ACTIVEV_FRONT整帧
行数23348010525

这两个表是写VGA时序生成器的直接依据。行计数器在0~799循环,场计数器在0~524循环。当行计数落在96~143区间时HSYNC拉低,场计数落在2~34区间时VSYNC拉低;有效区域是行计数144~783、场计数35~514,这个区间内RGB输出像素数据,否则输出黑色(全0)。

// vga_timing.v // VGA 640x480@60Hz 时序生成 reg [9:0] h_cnt; reg [9:0] v_cnt; wire h_active = (h_cnt >= 144) && (h_cnt < 784); wire v_active = (v_cnt >= 35) && (v_cnt < 515); wire active = h_active && v_active; assign hsync = (h_cnt < 96) ? 1'b0 : 1'b1; assign vsync = (v_cnt < 2) ? 1'b0 : 1'b1; // 有效区域取像素坐标 wire [9:0] x = h_cnt - 144; wire [9:0] y = v_cnt - 35; always @(posedge clk) begin if (h_cnt == 799) begin h_cnt <= 0; if (v_cnt == 524) v_cnt <= 0; else v_cnt <= v_cnt + 1'b1; end else begin h_cnt <= h_cnt + 1'b1; end end assign rgb = active ? pixel_data : 16'h0000;

这段代码是VGA驱动的基础框架,xy就是当前像素在有效区域内的逻辑坐标。active信号作为读FIFO的使能信号,在有效区每个像素时钟读取一个像素数据;在消隐区强迫输出黑色。像素数据的位宽取决于RGB输出管脚数量,实例工程里通常是16bit(RGB565),通过3个电阻网络转成模拟信号。

4.2 用Quartus的FIFO IP核连接SD卡读模块和VGA模块

SD卡读出的数据是突发式的,一次读512字节停顿一段时间,而VGA每个像素时钟都要消费一个像素,中间需要缓冲。用Quartus的FIFO IP核解决跨时钟域和速率匹配:写时钟接SD卡模块的SPI得到的数据有效信号,读时钟接VGA的像素时钟。

创建FIFO IP时选择“FIFO Intel FPGA IP”,参数配置注意三点:数据宽度16bit、深度1024、读写使用独立时钟域。为什么深度选1024而不用2048?因为320×240的图片每行320像素,VGA水平消隐期(96+48+16=160个时钟)内SD卡可以把FIFO填满,深度1024足够覆盖两行缓冲。

参数项设置值说明
Data Width16RGB565每像素2字节
FIFO Depth10241024×16bit缓冲
Read Clock Domain独立读时钟VGA像素时钟25MHz
Write Clock Domain独立写时钟SD卡SPI时钟25MHz
Overflow Flag启用用于调试丢数据

FIFO的读使能由VGA有效区信号触发:每个像素时钟在active为高且FIFO非空时读一次,读出数据直接送到RGB输出。FIFO为空时输出黑色,这样画面不会出现错行,代价是空拍位置显示黑点。

FIFO在SD卡数据源源不断注入时能自然填满到某一水位,但VGA的消耗速率稳定,SD卡突发读完后FIFO会被逐渐抽空。如果FIFO空了,行的后面部分显示黑边,视觉上是整条竖带缺失。避免这个问题的常用做法是:在有效区开始前预填充FIFO,等到FIFO半满才开始显示,或者直接增大FIFO深度至2048。

提示:FIFO IP的读写计数器信号wrusedwrdusedw可以接到SignalTap里观察,调试时能看到缓冲水位变化曲线,比凭感觉猜快得多。

4.3 图片居中显示的逻辑处理

320×240的图放到640×480的屏幕上,像素坐标要加偏移量:水平方向(640-320)/2 = 160,垂直方向(480-240)/2 = 120。实现时在VGA时序模块里把xy分别减去偏移值,只有坐标落在源图范围内的像素才读FIFO,其余输出黑边。

wire in_src = (x >= 160) && (x < 480) && (y >= 120) && (y < 360); wire rd_en = active && in_src && !fifo_empty;

坐标判断放在读使能端,不产生额外的存储成本。FIFO读出的数据直接输出,所有边界都用比较器完成,在一拍内就能决定当前像素是原图还是黑边,不会破坏VGA时序。这一步也是很多工程里“图片位置整体偏移”的根源:一旦x的计算基准错了,画面会整体往右或往下平移一段距离,检查坐标偏移量是定位此类问题的最快路径。

5. 排错三板斧:先证明VGA通道,再验证SD卡数据,最后查FAT解析

5.1 第一板斧:用纯色和棋盘格验证VGA通道

把SD卡相关模块全部注释掉,VGA驱动模块的输入数据改为固定常量,分别输出红色(0xF800)、绿色(0x07E0)、蓝色(0x001F),确认屏幕能正确显示纯色。再输出棋盘格图案:坐标(x/40 + y/40) % 2决定当前格是白色还是黑色,验证行场时序计数没有问题。棋盘格能看到整齐的方格,说明同步信号和消隐区间都正确,这时才能进入下一步。

这一步也顺手解决了一个常见问题:Quartus里Modelsim仿真VGA模块没有显示效果,只能看波形,无法验证颜色和位置是否匹配,而实板测试可以直观看到画面。如果纯色画面有斜条纹,大概率是RGB管脚顺序接反或者电阻网络匹配不对;如果棋盘格左右不对称,检查行前沿和行后沿是否按参数表实现。

5.2 第二板斧:用板上LED验证SD卡读回的字节

SD卡读模块单独验证的时候,一次CMD17读取后,把数据的低8位接到LED上。卡上放一个已知内容的文件,比如首字节是0xAA的文件,读回来如果LED显示0xAA,说明SPI通信链路是通的。再换几个不同的扇区地址,确认寻址方式正确。

具体做法是:写一个简单的testbench,用modelsim仿真时把SD卡SPI模型换成hex文件数组,但要真正验证物理链路,必须上板。把读到的512字节中第一、二个字节分别接到两组LED上,对比SD卡里文件的实际内容,能快速判断是位颠倒、字节序颠倒还是CS时序错误。位颠倒常见于SPI的CPOL/CPHA配置和SD卡规定不一致,字节序颠倒则几乎总是命令参数高低位拼接错误。

5.3 第三板斧:SignalTap抓FAT解析的中间变量

FAT解析最容易出错的地方是簇号拼接和扇区地址计算。在SignalTap里挂4个信号:current_clusterfat_entrysector_addrrd_data。先跳过FAT表直接读已知扇区,再用SignalTap观察目录扫描时读到的目录项字节,和PC上用WinHex打开SD卡镜像文件看到的字节逐位对比。

如果目录项读出来都是0xFF或全0,检查SPI命令发出的地址是否为扇区地址而非字节地址——SDHC卡上把扇区号当字节地址发导致的典型现象就是响应正常但数据全是0。如果名字匹配了但起始簇号明显偏大或偏小,重点检查高16位和低16位拼接时是否有位宽截断。

5.4 一个省事的调试技巧:把BMP头直接写进FIFO初始化文件

调试阶段可以绕过FAT32:用Quartus的$readmemh把一幅小尺寸图片的裸像素数据初始化到一块片上ROM里,VGA直接读这块ROM,先证明“内存→VGA”这条路是通的。跑通之后再把数据源换成SD卡模块,你会发现定位问题容易得多——画面花是图片解析问题,画面黑是SD卡链路问题,画面错位是FIFO时序问题。

这样分模块调试的另一个好处是,你手里始终有一个能出画面的工程备份,改坏了随时回退,不会陷入“满屏雪花不知道从哪查起”的困境。最后把ROM替换回SD卡+FAT模块,整个工程就完整了。如果出现画面只有上半部分,优先查BMP的存储方向——BMP是自底向上的,倒序读读地址即可修正。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询