
简介这是一份基于FPGA实现SD卡读写功能的完整工程资料包面向数字逻辑设计学习者、电子竞赛参赛者及需要快速上手SD卡协议的工程师。资源内含FPGA工程源码、综合布局布线报告和配置文件覆盖SD卡初始化、SPI模式命令交互、块读写、CRC校验、错误处理及状态机设计等关键知识点可帮助读者掌握从硬件描述到板级调试的完整流程。包体共71个文件以Verilog源码.v、约束文件.ucf、比特流文件.bit、综合/实现网表.ngc/.ncd及各类工程报告.html/.xml/.xrpt为主压缩包仅2.09MB目录结构清晰便于按模块检索。已有617人学习使用借助这些工程文件可复现读写时序并独立完成SD卡控制器设计适合具备基础Verilog知识、希望深入研究SD卡协议与FPGA数据交互的读者。1. 为什么非得让FPGA直接读写SD卡一个没有OS兜底的存储工程很多从单片机转FPGA的工程师第一反应是SD卡读写不就是一个SPI外设吗。真把sd_test.rar解压开、把读卡逻辑写进状态机才发现SD卡在水面下藏了大半座冰山——没有OS兜底、没有驱动库上电时序、命令响应、busy轮询、扇区边界全靠自己写。标题里这串关键词其实是同一件事FPGA SD卡写入与读取。它解决的问题是高速采集场景里CPU来不及落盘比如FPGA高速ADC采样、图像处理之后的数据回传以及一切先把数据存下来再上PC分析的场合。适合正在做数据记录仪、存储卡测试工装或者只是想把Verilog存储链路写明白的人。2. 动手前先回答四个选型SPI还是SD模式裸扇区还是文件系统2.1 SPI模式为什么是FPGA入门的默认答案人看到“SD卡”三个字很容易直接奔着SD 4-bit模式去觉得带宽高才配得上FPGA。但SD 4-bit模式在FPGA里是个大坑DAT[3:0]有方向切换UHS模式下还要处理电压切换和时钟训练而这些逻辑在纯RTL里自己写调试周期是按周算的。SPI模式只有CS、SCLK、MOSI、MISO四根线时序完全由状态机掌控时钟想做多慢就做多慢非常适合先把读写链路跑通。从SD卡SPI模式的规范看它把SD卡变成了一个典型的SPI从设备主机发命令字节、参数、CRC卡回状态字节和数据。FPGA实现SPI主机只需要一个移位寄存器和两个半周期状态不需要任何IP核。25MHz的SCLK下理论带宽3.125MB/s对大多数ADC采集、工业记录仪、测试工装已经够用真要存720p30fps的图像数据再考虑换SD模式不迟。对比项SPI模式SD 4-bit模式连线数4根信号线6根信号线加电源实用时钟25MHz以内50MHz以上才划算理论带宽3.125MB/s 25MHz25MB/s 50MHz实现复杂度一个SPI状态机方向切换、高速对齐、CRC16典型场景低速采集、协议验证、数据记录高速图像、连续视频存储我一般的建议是第一次做FPGA SD卡读写或者工程里只是给采集系统加个“能把数据存下来”的能力直接SPI模式不要纠结。等物理层稳定后再评估是否值得为带宽复杂度买单。2.2 裸扇区读写和文件系统的选择决定你调试的是物理层还是协议栈选型里最容易被忽略的是“要不要在FPGA里跑文件系统”。裸扇区读写指的是直接用CMD17/CMD18读、CMD24/CMD25写按512字节扇区访问FPGA这边完全不关心FAT表、目录项只把SD卡当成一块线性地址空间。这个方案的优点是状态机干净、调试路径短适合做工程验证和专用数据记录。缺点也明显把卡插到PC上看到的是一堆裸数据不是能直接双击打开的文件。如果目标是在Windows上直接看到MP4或BIN文件FPGA侧就要维护FAT表。常见做法是用MicroBlaze或Nios II软核跑FatFS但这对纯RTL团队来说引入了一个完整的嵌入式软件栈。另一条路是FPGA仍然按裸扇区写PC端离线写个小工具把固定地址区间拼成一个文件。我就见过用纯RTL维护FAT32的工程最后败在簇链更新和断电一致性上连续写几十个扇区后目录项错乱折腾两星期还是换回裸扇区加离线恢复。所以选型顺序应该是先确认使用方能不能接受“离线拼文件”能接受就裸扇区一定要FAT就上软核不要在RTL里硬写FAT表。标题里的sd_test.rar如果只是测试读写裸扇区一定是起点先证明FPGA和卡能稳定交换数据再谈文件系统。2.3 初始化握手里最容易写错的时序CMD0到ACMD41SD卡的SPI模式不是上电就能用它有一个固定的初始化序列上电延迟、74个时钟脉冲、CMD0进入SPI模式、SD 2.0以上还要CMD8确认电压、ACMD41启动初始化、最后CMD58读OCR确认容量。这个序列每一步都有硬性要求尤其是初始化期间的SCLK频率必须低于400kHz很多工程师直接用25MHz去发CMD0卡根本不响应R1永远是0xFF。初始化命令的关键字节我整理成了一张表照着写状态机不容易丢位命令命令字节参数CRC/说明CMD00x400x000000000x95进入SPI模式CMD80x480x000001AA0x87SD 2.0电压确认CMD550x770x000000000x00告诉卡下一帧是ACMDACMD410x690x400000000x00HCS1表示支持高容量CMD580x7A0x000000000x00读OCR区分SDXC/SDHCCMD160x500x000002000x00设置块长为512字节命令字节的计算都在代码里写0x40是命令起始位加主机位后面加上命令号就是真正的发送字节。参数和CRC按规范填死整个初始化状态机不需要太聪明的逻辑重点是节奏CMD0失败要重新拉高CS做一次上电复位不要连续发CMD0ACMD41要循环轮询直到R1的第0位idle标志清零表示卡初始化完成。2.4 那张R1响应比你想象的容易判断错SD卡在SPI模式下MISO空闲时被拉高所以总线上没数据的时候主机读回来总是0xFF。这带来一个经典的误判很多新手写WAIT_R1逻辑时只检查“读到的字节是不是0x01”但R1的bit0是idle标志初始化过程中回0x01是正常的回0x00才是初始化完成。更隐蔽的是卡在响应前可能先吐若干个0xFF直接按“一次SPI传输”去抓第一个字节抓到的往往是垃圾。解析响应的通用做法是连续发送0xFF读卡丢弃所有0xFF第一个非0xFF字节才是真正的R1。R1的bit1是擦除复位、bit2是非法命令、bit3是CRC错误这些位在调试时要单独拉出来挂到调试端口上。我在工程里会把R1的每一位都引到LED或ILA上翻车的时候不用猜看一眼亮灯就知道是非法命令还是CRC错。3. 把写入状态机写成VerilogSPI主机、CMD24和busy等待的可抄代码3.1 SPI主机怎么写得既有复用性又不拖沓在写SD卡状态机之前先有一个干净的SPI主机模块。这里给出一个8位传输、Mode 3时序的简化实现空闲时SCLK为高数据在SCLK下降沿改变上升沿采样。CLK_DIV参数表示一个SCLK周期由多少个系统时钟组成初始化时把CLK_DIV调大比如8分频再叠加一个低速选择保证低于400kHz。// spi_master: 8bit传输, Mode 3空闲高, 下降沿驱动/高电平采样 module spi_master #( parameter CLK_DIV 8 // 系统时钟50MHz - SCLK6.25MHz )( input wire clk, input wire rst_n, input wire start, input wire [7:0] tx_data, output reg [7:0] rx_data, output reg busy, output reg sclk, output reg mosi, input wire miso ); reg [15:0] cnt; reg [2:0] bit_cnt; reg [7:0] tx_r, rx_r; reg [1:0] state; localparam IDLE 0, LOW_HALF 1, HIGH_HALF 2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cnt 0; bit_cnt 0; sclk 1b1; mosi 1b1; busy 1b0; end else case (state) IDLE: if (start) begin tx_r tx_data; sclk 1b0; mosi tx_data[7]; state LOW_HALF; cnt CLK_DIV / 2 - 1; busy 1b1; end LOW_HALF: begin sclk 1b0; mosi tx_r[7]; // 降沿输出 if (cnt 0) begin cnt CLK_DIV / 2 - 1; state HIGH_HALF; end else cnt cnt - 1; end HIGH_HALF: begin sclk 1b1; rx_r {rx_r[6:0], miso}; // 高电平采样 if (cnt 0) begin if (bit_cnt 3d7) begin bit_cnt 0; busy 1b0; rx_data {rx_r[6:0], miso}; state IDLE; end else begin bit_cnt bit_cnt 1; cnt CLK_DIV / 2 - 1; tx_r {tx_r[6:0], 1b0}; // 形成下一bit state LOW_HALF; end end else cnt cnt - 1; end endcase end endmodule这段代码的核心是状态的轮换LOW_HALF里把当前最高位放到MOSIHIGH_HALF里采样MISO一个bit一个bit地移。CLK_DIV是整个模块唯一需要动的参数初始化阶段要用低速时把CLK_DIV设成256这类大值SCLK自然降到几百kHz读正常数据时再切回来。调用方只需要控制start脉冲等待busy拉低就能拿到rx_data。写状态机的时候有个小知识点像这种串行链路的状态机状态数量不算多但状态之间切换很频繁用二进制编码比独热码省触发器。独热码更适合状态多、转移条件复杂的大状态机这里硬上独热码反而浪费。3.2 单扇区写入状态机从CMD24到数据token的逐拍拆解SPI主机就绪后核心是写扇区状态机。CMD24是单块写命令流程是发CMD24命令字节0x58加4字节地址加CRC→ 等R1响应 → 发数据token 0xFE → 连续发512字节数据 → 发2字节伪CRC → 等待卡内部编程完成。卡在编程期间会把MISO拉低等它释放为高才表示这个扇区真的持久化了。// sd_wr_sector: 单扇区写, 512字节, 地址按扇区编号送入 module sd_wr_sector #( parameter BYTE_NUM 512 )( input wire clk, input wire rst_n, input wire go, input wire [31:0] sector_addr, // 扇区号, 内部左移9位得到字节地址 input wire [7:0] din, input wire din_valid, output reg din_ready, output reg done, input wire [7:0] spI_rx, output reg [7:0] spi_tx, output reg spi_start, input wire spi_busy ); localparam IDLE0, CMD241, WAIT_R12, TOKEN3, DATA4, CRC5, WAIT_BUSY6, DONE7; reg [2:0] state; reg [9:0] byte_cnt; reg [7:0] r1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; byte_cnt 0; done 0; end else begin done 1b0; case (state) IDLE: begin if (go) begin spi_tx 8h58; // 0x40 | 24 spi_start 1b1; state CMD24; end end CMD24: begin // 子状态机按byte_cnt发送: // 0: 命令字节 1~4: sector_addr9的4字节 // 5: CRC, SPI模式发0x00 // byte_cnt6时spi_start拉低, 进入WAIT_R1 end WAIT_R1: begin if (!spi_busy) begin if (spi_rx ! 8hFF) begin r1 spi_rx; if (spi_rx[3]) $display(CRC error); else state TOKEN; end end end TOKEN: begin spi_tx 8hFE; // 单块写令牌 spi_start 1b1; state DATA; byte_cnt 0; end DATA: begin // 数据线每字节通过spi_master送出; // 这里按byte_cnt同步从din口取数, 512字节后进CRC end CRC: begin spi_tx 8hFF; // SPI模式CRC可发任意值 // 2字节后进WAIT_BUSY end WAIT_BUSY: begin // MISO拉低busy, 拉高完成 if (!spi_busy_observed) state WAIT_BUSY; else state DONE; end DONE: begin done 1b1; state IDLE; end endcase end end endmodule这里有个关键参数CMD24的地址是字节地址不是扇区号。如果外部传入的是扇区号发送前要左移9位。地址的4个字节按高到低分别送顺序错了卡会回非法命令错误。数据token 0xFE必须在R1收到之后间隔几个时钟再发有些卡在R1之后立即需要8个SCLK的间隔所以发送token前补几个0xFF是常见做法不会破坏协议。WAIT_BUSY状态最容易偷懒写完512字节后立刻进入下一扇区结果数据根本没写进去。MISO在编程期间被拉低必须轮询到它恢复为高才算一次完整的写完成。3.3 读扇区和多块写把边界条件提前处理掉读单扇区用CMD17流程是发命令、等R1、再继续读直到碰到0xFE数据token然后连续读512字节数据和2字节CRC。SPI模式下CRC可以不校验但这两个字节必须从总线上读走否则后面的命令会错位。读状态机同样要先丢弃0xFF再找0xFE不要看到非0xFF就当数据。多块连续写是提高吞吐的关键用CMD25加写入token 0xFC写完一个块后不需要等busy释放直接发下一块数据即可。卡内部会缓冲但会在缓冲区满时拉低MISO阻塞。结束多块写要发CMD12 STOP_TRANSMISSIONCMD12之后还得读一次R1和至少一个0xFF然后才能发其他命令。做连续写时一个常见错误是在所有块结束后直接切回IDLE导致卡还停在写状态下一轮CMD24会被忽略。3.4 仿真激励里最该关注的三个断言仿真阶段不用追求“跑通全部协议”穷举对FPGA没用真正有效的是把初始化、命令字节、busy轮询这几个关键路径锁住。建议在testbench里加三个断言第一命令字节序列必须严格等于0x40、0x48、0x77、0x69这一串任何一个字节错了初始化状态机就要停下来报错第二数据token出现前不允许出现任何数据写入能挡住把R1当数据的低级错误第三统计每个扇区从发起CMD24到busy释放的时钟数这组数据直接用于后面的吞吐量估算也为连续写FIFO深度提供了依据。// testbench骨架: 触发一次写扇区并统计周期 module tb_sd_wr; reg clk 0, rst_n 0, go 0; reg [7:0] din 8h00; reg din_valid 0; wire din_ready, done; reg [31:0] cyc_cnt; always #10 clk ~clk; initial begin #100 rst_n 1; #100 go 1; wait (done); // 第一次写完成 $display(write cycles %0d, cyc_cnt); #100 $finish; end always (posedge clk) begin if (go) cyc_cnt cyc_cnt 1; if (done) cyc_cnt 0; end endmodule把这个统计寄存器留着后面实测带宽时ILA抓到的周期数和仿真对得上才说明状态机没有因为综合优化跑飞。4. 避坑SD卡初始化、时钟相位和写后busy的典型翻车现场4.1 换了一张卡就完全不响应问题往往在时钟相位现象是最常见的同一套代码在A卡上写入读取都正常换一张B卡后初始化阶段R1一直读回0xFF状态机卡死在CMD0轮询里。试了很多命令组合都不行像玄学问题。原因不是卡坏了而是SPI时序在相位、边沿的余量上刚好贴着卡的规格边界A卡时序余量大扛得住B卡余量小就翻车。解决的办法是回抓到SCLK和MISO的波形确认发送数据是在SCLK下降沿之后改变、接收是在上升沿附近采样。我见过把CPOL和CPHA写反了也能用的工程因为某些卡对相位不敏感但这不是能依赖的行为。统一改成空闲高、下降沿驱动、上升沿采样也就是上面spi_master的写法是最稳妥的组合。4.2 单个扇区写成功读回来却全是0xFF或者错位现象是写入后立即读同一个扇区数据完全不对常见的是读到一堆0xFF或者第一个字节正确、后面全乱。原因有两个方向一是busy等待没做数据还没真正写进卡读到的还是旧内容或空白区二是写数据时每字节之间缺少间隔卡内部FIFO跟不上数据被丢弃。解决时要明确一点SD卡写命令的R1响应只表示命令被接受不代表数据已经落盘。WAIT_BUSY状态必须轮询到MISO释放并且要加超时保护。我一般写一个30万拍的看门狗计数器超过这个时间就报超时错误防止状态机挂死。数据错位的问题则要在DATA状态里给每两个字节之间补8个SCLK的间隔用计数器控制不要连续盲发。4.3 初始化时把74个时钟和CMD0连在一起发卡永远进不了SPI模式现象是上电延迟、74个时钟、CMD0连成一条流水线看起来完全符合手册但卡就是不回应。原因在于CS信号的电平顺序进入SPI模式前CS必须保持高电平让卡完成内部上电流程至少74个时钟之后再把CS拉低发CMD0。如果CS在74个时钟期间已经拉低卡可能错误地把它当成一个命令序列CMD0就无效了。解决的方法是严格按时间顺序做CS先拉高SCLK空跑至少74拍然后CS拉低延迟几个时钟后发CMD0CMD0之后不要立刻发CMD8等R1稳定。把“CS拉低”这个动作放在一个独立状态里不要和前面的空跑时钟放在同一个状态机分支里。这个坑排查起来最费时间因为波形上SCLK一直在走看不出明显异常。4.4 ILA抓不到MISO响应先检查触发条件再查时序现象是ILA触发后抓到的波形里MISO全是高电平看不到任何有效的R1响应误以为卡没进SPI模式。常见原因不是卡的问题而是ILA触发条件设在了MOSI的第一个下降沿抓包窗口提前结束错过了卡在命令末尾才返回的响应字节。ILA的采样深度有限SD卡命令发送本身要几十个时钟加上响应延迟很容易抓到一段空白的MSO。解决的方法是把触发条件改成一个具体的字节序列先捕获发送CMD8后的第40个时钟沿再加MISO非高电平的触发或者干脆用“MISO下降沿”作为触发只抓命令结束之后的窗口。另一个习惯是每次初始化只抓一条命令不要在同一帧里抓完CMD0到ACMD41数据太密反而看不出问题。4.5 SDXC大容量卡卡在ACMD41不是命令写错而是HCS位没置1现象是插上64GB卡CMD0和CMD8都正常ACMD41反复返回0x01初始化一直不完成。原因是高容量卡要求ACMD41的参数里HCS位置1否则卡认为主机不支持SDHC/SDXC不进入就绪状态。这个位在参数最高位很多初始化代码是从老工程复制来的参数固定为0x00000000老卡能用新卡就卡住。解决方法是把ACMD41参数改成0x40000000同时保证CMD8的参数是0x000001AA。OTG要注意ACMD41的轮询不能死等建议循环计数20次左右就报错并重新复位因为不同品牌的卡初始化时间差距很大从几毫秒到上百毫秒都有硬等容易让整个系统卡死。5. 从能写到能连续写FIFO深度、跨时钟域和吞吐量计算的取舍5.1 先算一笔账再选FIFO深度别凭感觉定单扇区写在SPI 25MHz下纯数据时间是512×8÷25MHz≈164us加上命令、R1、busy等待实测单扇区周期通常到300us到500us换算下来有效带宽只有1.5MB/s左右。如果高速ADC采样率2MSPS、16位宽数据率是4MB/s远超过SD卡写入能力这时候只要不缓冲数据必丢。FIFO深度不取决于平均速率而取决于“采集端突发长度”和“SD卡最长阻塞时间”的乘积。典型算法最坏阻塞取单扇区写周期的上限比如500us采集端突发速率4MB/s那么FIFO需要至少4MB/s×500us2KB。再留50%的margin4KB深度是稳妥起步。我用一张表列清楚优化手段解决的问题代价增大FIFO深度吸收瞬时写入阻塞触发器和BRAM开销上升连续写CMD25减少每扇区命令发包开销需要处理多块写结束双缓冲交替写避免单缓冲区被读写同时占用逻辑翻倍几乎满信号提前停流防止FIFO溢出采集端要能响应反压5.2 跨时钟域采集域和SPI域的接口一定要用异步FIFOFPGA高速ADC采样通常有自己的采样时钟SD卡状态机跑在SPI时钟域两个时钟没有相位关系。直接用一个同步FIFO跨域偶尔会读出错误数据而且这种错误在仿真里模拟不出来只有长时间连续写才会暴露。这个级别的异步问题要用异步FIFOXilinx平台就是xpm_fifo_asyncAltera对应dcfifo。// 异步FIFO实例化: 写侧32bitadc_clk, 读侧8bitspi_clk xpm_fifo_async #( .FIFO_WRITE_DEPTH(8192), // 32KB缓冲 .WRITE_DATA_WIDTH(32), .READ_DATA_WIDTH(8), // 转换为字节流给SD卡 .READ_MODE(fwft), .CDC_SYNC_STAGES(3) // 同步级数, 3级抗亚稳态 ) u_fifo ( .wr_clk (adc_clk), .wr_en (adc_valid), .din (adc_data), .rd_clk (spi_clk), .rd_en (spi_rd_en), .dout (spi_din), .almost_full (fifo_afull), .empty (fifo_empty) );这里写侧32bit、读侧8bit的位宽转换正好对应一次采集到多个字节的顺序省了额外的位宽转换逻辑。几乎满信号fifo_afull要接到采集端让它暂停写入而不是等FIFO满了才丢数据。复位要注意异步FIFO的rst必须同步到两侧时钟直接接全局复位偶尔会让FIFO状态错乱。5.3 掉电保护写在扇区里的不只有数据还要有状态连续写工程做到后期最大的风险是断电上一秒还在写扇区下一秒电源断了卡上留下半个扇区的脏数据下次上电读回来全是错的。裸扇区读写没有文件系统的日志机制这个问题必须在应用层解决。常见的做法是把扇区分成数据区和状态区写入时先在一个固定扇区写“正在写入第N块”的标记数据写完后再更新标记读到“未完成”标记就知道上一次写被中断了。这个方案会让写入带宽再打一些折扣但换来的是掉电后可恢复。我通常的做法是每256个数据扇区配1个状态扇区状态扇区记录当前批次的起始地址、扇区数和校验和恢复时从头扫描状态扇区就能定位断点。这个设计在数据记录仪里比任何高级FIFO策略都重要因为现场掉电是不可控的。6. 验证SD卡读写方案的三板斧回环测试、实测带宽和命令注入6.1 用伪随机pattern做回环别只用0x55/0xAA固定pattern能暴露的问题非常有限数据线粘连、错位在固定字节下可能看不出来。回环测试的输入建议用LFSR生成的伪随机序列写入后读回比对只有完全一致才算通过。// 伪随机写pattern: 16bit LFSR取低8bit always (posedge clk) begin if (!rst_n) lfsr_reg 16hACE1; else lfsr_reg {lfsr_reg[14:0], lfsr_reg[15] ^ lfsr_reg[13] ^ lfsr_reg[12] ^ lfsr_reg[10]}; end assign pattern_byte lfsr_reg[7:0];LFSR的初值就是种子读写两侧用同一个种子就能算出预期值。这个测试建议连续跑几百个扇区只要中间出一个错就把错误扇区号和预期值打印出来定位是固定地址错还是随机偶发错。6.2 用ILA实测单扇区周期按实测值设计FIFO写一个计数器从CMD24发起开始计数到busy释放停止用ILA把这个数值抓出来就是真实单扇区周期。这个值可能和仿真差很多因为卡内部编程时间受品牌、磨损状态影响很大。FIFO深度、连续写的节奏都应该按这个实测最差值来定而不是按仿真的乐观值。我习惯在同一个扇区上连续写20次每次重新读一遍把最大值记下来当设计余量。6.3 预留命令注入端口改测试case不再重新综合最影响调试效率的是改一次命令参数就要重新跑一次综合。在写状态机边上做一个简单的寄存器组UART或JTAG可以通过它直接修改CMD号、扇区地址、pattern种子然后再触发一次写入。这个端口一旦有验证速度和排障效率会大幅提升。你不需要为每个测试都改RTL并等待几分钟布局布线调初始化时序这种高频操作光省下的重综合时间就够撑起这个端口。这个技巧是我做SD卡工程尝到最大甜头的地方也希望你能养成这个习惯先搭命令注入再开始调协议。希望帮到你。本文还有配套的精品资源点击获取