这一篇在干嘛?

SD 卡是嵌入式设备里最常见的「小硬盘」,内部自带 NAND Flash 控制器,主机只需要发命令就能读写数据。这一篇我们让 FPGA 通过 SPI 总线对 SD 卡的扇区做最底层的读写:先初始化卡片,再把一个字节写进第一扇区、断电重启后读出来验证——相当于 FPGA 版的「数据掉电不丢」。

实验目标与背景

SD 卡内部集成了 NAND Flash 控制器,负责磨损均衡、坏块管理等麻烦事,对主机暴露的是一套简单的命令接口。本实验的核心目标是:

  1. 理解 SD 卡在 SPI 模式下的命令/响应协议;
  2. 掌握 SD 卡 2.0 版的初始化流程(CMD0 → CMD8 → CMD55+ACMD41);
  3. 完成单个扇区(512 字节)的读与写,并用数码管显示结果。

文件系统不在此实验范围内

实际的 SD 卡上通常有 FAT 等文件系统,可以按文件名和目录路径读写文件,但文件系统非常复杂,本实验不涉及。我们直接按扇区地址读写裸数据。后续实验会通过搜索特定文件头的方式读出音频、图片等文件,那是以本实验的扇区读写能力为基础的。

实验原理:SPI 模式与命令协议

为什么选 SPI 模式

SD 卡支持多种接口模式。在对读写速度要求不高的场合,SPI 模式是最佳选择——只需要四条线就能完成全部数据交换:

  • CS(片选):低电平选中卡片;
  • SCLK(时钟):主机提供同步时钟;
  • MOSI:主机 → 卡,命令与数据;
  • MISO:卡 → 主机,响应与数据。

命令格式:6 字节命令包

SD 卡的协议是一种「命令/响应」协议:全部命令由主机发起,卡收到命令后返回响应,响应的内容和长度随命令不同而不同。

SD 卡命令是一个 6 字节的命令包:

位置内容说明
第 1 字节命令号bit7、bit6 固定为 01,低 6 位是具体命令号
第 2~5 字节参数32 位命令参数,高位在前(MSB First)
第 6 字节CRC + 结束位7 bit CRC 校验加 1 bit 结束位(SPI 模式下 CRC 可选)

命令通常用十进制命名,例如 CMD17,其中 Command 就是十进制的 17。

响应格式:R1 / R2 / R3

SPI 模式下有三种响应格式。R1 只有一个字节,每一位的含义如下:

Bit含义
7起始位,恒为 0
6参数错误(Parameter Error)
5地址错误(Address Error)
4擦除序列错误(Erase Sequence Error)
3CRC 错误(CRC Error)
2非法命令(Illegal Command)
1擦除复位(Erase Reset)
0处于 Idle 状态(In Idle State)

R2 在 R1 的基础上追加第二个字节(Out of Range、Erase Parameter、Write Protect Violation、Card ECC Failed、Card Controller Error 等);R3 在 R1 的基础上追加 4 字节的操作条件寄存器(OCR,MSB First)。

SD 卡 2.0 版初始化步骤

初始化是 SD 卡驱动里最绕的部分,流程如下:

  1. 上电后延时至少 74 个时钟,等待 SD 卡内部操作完成;
  2. 片选 CS 拉低选中 SD 卡;
  3. 发送 CMD0,需要返回 0x01,表示进入 Idle 状态;
  4. 发送只有 SD 2.0 才有的 CMD8:若返回无错误,初步判断为 2.0 卡,接着循环发送 CMD55+ACMD41,直到返回 0x00,确定是 SD2.0 卡;
  5. 若 CMD8 返回错误,则是 1.0 卡或 MMC 卡:循环发送 CMD55+ACMD41,返回无错误即为 SD1.0 卡;若循环一定次数仍报错,再发送 CMD1 初始化,返回无错误即为 MMC 卡;若仍失败则无法识别该卡,初始化结束;
  6. 最后把 CS 拉高。

CMD16 可以改变块长度

通过 CMD16 命令可以修改 SD 卡一次性读写的长度,默认值是 512 字节,正好是一个扇区,本实验直接使用默认值。

读一个块的步骤

  1. 发送 CMD17(单块读)或 CMD18(多块读),返回 0x00 表示命令被接受;
  2. 接收数据:开始令牌 fe(多块时为 fc)+ 正式数据 512 字节 + CRC 校验 2 字节。

写一个块的步骤

  1. 发送 CMD24(单块写)或 CMD25(多块写),返回 0x00;
  2. 发送数据:开始令牌 fe(或 fc)+ 正式数据 512 字节 + CRC 校验 2 字节;
  3. 连续读取数据响应令牌,读到 8'bxxx00101 表示写成功;
  4. 继续读做忙检测:读到 0x00 表示卡正忙,读到 0xff 表示写操作完成。

程序设计

工程分为两层:底层是 spi_master 模块,负责 SPI 一个字节的收发(在其他实验中已讲解);上层是 sd_card_cmd 模块,负责 SD 卡基本命令、上电初始化的 88 个周期时钟以及数据块的读写。sd_card_cmd 的状态机如下:

命令号的「或 0x40」小机关

查阅其他 SD 卡资料时,你会看到命令号和例程里的写法有区别——其实只是表示方法不同。主机向 SD 卡写命令的实质是:把十六进制命令号的高 8 位与 0x40 做或运算后再写入(因为首字节高两位固定为 01,即 0x40)。所以代码里有了这一段:

if (byte_cnt == 16'd0)
    send_data <= (cmd[47:40] | 8'h40);
else if (byte_cnt == 16'd1)
    send_data <= cmd[39:32];
else if (byte_cnt == 16'd2)
    send_data <= cmd[31:24];
else if (byte_cnt == 16'd3)
    send_data <= cmd[23:16];
else if (byte_cnt == 16'd4)
    send_data <= cmd[15:8];
else if (byte_cnt == 16'd5)
    send_data <= cmd[7:0];
else
    send_data <= 8'hff;

可以看到 48 位命令被拆成 6 个字节依次发出:第 0 字节或上 0x40,后面字节原样发送,发完后补发 0xff 以产生时钟。

sd_card_cmd 模块端口

信号名称方向说明
sys_clkin时钟输入
rstin异步复位输入,高复位
spi_clk_divinSPI 时钟分频,SPI 时钟频率=系统时钟/((spi_clk_div+2)*2)
cmd_reqinsd 卡命令请求
cmd_req_ackoutsd 卡命令请求应答
cmd_req_erroroutsd 卡命令请求错误
cmdinsd 卡命令,命令+参数+CRC,一共 48bit
cmd_r1insd 卡命令期待的 R1 响应
cmd_data_leninsd 卡命令后读取的数据长度,大部分命令没有读取数据
block_read_reqin块数据读取请求
block_read_validout块数据读取数据有效
block_read_dataout块数据读取数据
block_read_req_ackout块数据读取请求应答
block_write_reqin块数据写请求
block_write_datain块数据写数据
block_write_data_rdout块数据写数据请求,提前 block_write_data 一个时钟周期
block_write_req_ackout块数据写请求应答
nCS_ctrlout到 SPI master 控制器,cs 片选控制
clk_divout到 SPI Master 控制器,时钟分频参数
spi_wr_reqout到 SPI Master 控制器,写一个字节请求
spi_wr_ackin来自 SPI Master 控制器,写请求应答
spi_data_inout到 SPI Master 控制器,写数据
spi_data_outin来自 SPI Master 控制器,读数据

模块里定义了两个分频参数,分别对应初始化阶段的慢时钟和数据读写阶段的快时钟:

parameter SPI_LOW_SPEED_DIV = 248
parameter SPI_HIGH_SPEED_DIV = 0

sd_card_sec_read_write 模块

sd_card_sec_read_write 模块继续完成 SD 卡初始化,然后等待扇区读写指令,并完成扇区的读写操作。状态机转换图如下:首先发送 CMD0 命令,然后发送 CMD8 命令,再发送 CMD55,接着发送 ACMD41,如果应答正常,SD 卡初始化完成,等待扇区的读写。

sd_card_sec_read_write 模块状态机

信号名称方向说明
clkin时钟输入
rstin异步复位输入,高复位
sd_init_doneoutsd 卡初始化完成
sd_sec_readinsd 卡扇区读请求
sd_sec_read_addrinsd 卡扇区读地址
sd_sec_read_dataoutsd 卡扇区读出的数据
sd_sec_read_data_validoutsd 卡扇区读出的数据有效
sd_sec_read_endoutsd 卡扇区读完成
sd_sec_writeinsd 卡扇区写请求
sd_sec_write_addrinsd 卡扇区写请求应答
sd_sec_write_datainsd 卡扇区写请求数据
sd_sec_write_data_reqoutsd 卡扇区写请求数据读取,提前 sd_sec_write_data 一个时钟周期
sd_sec_write_endoutsd 卡扇区写请求完成
spi_clk_divinSPI 时钟分频,SPI 时钟频率=系统时钟/((spi_clk_div+2)*2)
cmd_reqinsd 卡命令请求
cmd_req_ackoutsd 卡命令请求应答
cmd_req_erroroutsd 卡命令请求错误
cmdinsd 卡命令,命令+参数+CRC,一共 48bit
cmd_r1insd 卡命令期待的 R1 响应
cmd_data_leninsd 卡命令后读取的数据长度,大部分命令没有读取数据
block_read_reqin块数据读取请求
block_read_validout块数据读取数据有效
block_read_dataout块数据读取数据
block_read_req_ackout块数据读取请求应答
block_write_reqin块数据写请求
block_write_datain块数据写数据
block_write_data_rdout块数据写数据请求,提前 block_write_data 一个时钟周期
block_write_req_ackout块数据写请求应答

初始化必须用慢时钟,读写才能切快时钟

SD 卡的初始化过程必须先用慢时钟发送命令和配置,等初始化成功后才能切换到快时钟进行数据读写。这也是为什么模块里要定义 SPI_LOW_SPEED_DIV = 248(很慢)和 SPI_HIGH_SPEED_DIV = 0(很快)两个参数。上电就用高速时钟跟卡通信,是新手最常见的「初始化一直失败」的原因。

引脚约束

时钟 CLK 选择核心板时钟一>6,复位键1,键2累加,TF卡引脚根据《开发板手册》。

set_pin_assignment { SD_DCLK } { LOCATION = A14; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { SD_MISO } { LOCATION = B14; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { SD_MOSI } { LOCATION = A13; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { SD_nCS } { LOCATION = A12; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { clk } { LOCATION = R7; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { key1 } { LOCATION = A2; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { rst_n } { LOCATION = B2; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { seg_data[0] } { LOCATION = C15; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_data[1] } { LOCATION = C16; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_data[2] } { LOCATION = B6; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_data[3] } { LOCATION = A5; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_data[4] } { LOCATION = B5; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_data[5] } { LOCATION = A8; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_data[6] } { LOCATION = A7; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_data[7] } { LOCATION = A6; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_sel[0] } { LOCATION = D11; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_sel[1] } { LOCATION = E11; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_sel[2] } { LOCATION = F10; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_sel[3] } { LOCATION = C13; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_sel[4] } { LOCATION = E10; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { seg_sel[5] } { LOCATION = E12; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }

注意 SD_MISO(B14)配置了上拉(PULLUP),这是因为 SPI 主机在卡片未驱动 MISO 时需要一个确定的电平。

上板流程与现象

  1. 插好 TF 卡(卡槽在开发板背面),下载实验程序;
  2. 数码管显示一个数字——这是 SD 卡第一扇区的第一个数据,数据是随机的;
  3. 按下按键 2,数字加一,并写入 SD 卡;
  4. 再次下载程序(相当于重新上电读第一扇区),可以看到直接显示更新后的数据,证明写入确实保存到了卡里。

现象与总结

这个实验打通了 FPGA 访问大容量存储的「最后一公里」:从 SPI 字节收发,到 6 字节命令包组装(高 8 位或 0x40),到初始化状态机(CMD0 → CMD8 → CMD55+ACMD41),再到 512 字节扇区的读写与忙检测。掌握这套流程后,后续做音频播放、图片读取显示时,只需要在扇区数据之上按文件头搜索定位内容即可。

自测