news 2026/10/5 4:01:02

STM32 SPI接口读写SD卡与FATFS文件系统移植实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 SPI接口读写SD卡与FATFS文件系统移植实战

1. 项目整体设计与方案选型

做这个项目的起因很直接:要给一块老掉牙的STM32F103开发板加一个数据记录功能,现场没有屏幕也没有上位机,最稳妥的办法就是把传感器数据写到SD卡里,回头把卡拔出来插电脑上看。最开始我图省事想过用SDIO接口,毕竟F103的SDIO在理论速度上碾压SPI,但翻了一圈发现板子上的SD卡座只引出了SPI引脚,PCB改不了,于是老老实实走SPI路线。

做完整个项目之后我的结论是:对于嵌入式日志记录、配置文件存储、批量数据导出这类场景,SPI模式读写SD卡其实一点不寒酸。1-2MB/s的实际吞吐量足够应付绝大多数传感器的采样率,而且SPI接口几乎所有STM32型号都有,引脚分配灵活,不像SDIO被固定占用PA8-PC12那套。更重要的是,SPI模式下SD卡协议栈简单很多——你只需要在SPI总线上发命令字节,完全不用管SDIO那套复杂的命令通道和数据通道分离机制,调试起来省心得多。

整个系统我把它拆成了四层,每一层各干各的:

层级职责关键实现
硬件接口层与SD卡物理通信STM32 SPI1外设 + 软件片选控制
SD卡协议层发送CMD命令、解析响应、读写扇区基于SPI命令字节封装
文件系统层管理FAT32目录结构和文件分配表FatFs R0.14b 移植
应用层创建文件、写入数据、模拟U盘枚举用户业务代码 + USB MSC设备类

这样分层的用意很明确:上层应用永远不直接接触SD卡的扇区地址,只跟文件名和目录打交道;底层驱动换硬件平台时也不用动业务代码。比如我后来把这套东西从F103挪到F411上,只改了SPI初始化和几个引脚宏,FATFS层和应用层一行没动。

注意一点,我这里用的是软件片选而不是SPI外设的硬件NSS。原因是SD卡的SPI协议要求严格的时序:每个命令传输期间片选必须保持低电平,命令结束后再拉高,而且片选跳变之间要插入额外的时钟周期。硬件NSS在接收完最后一个字节后自动释放,时机不受控,容易导致SD卡状态错乱。老老实实用一个GPIO去拉CS引脚,时序上完全可控。

模拟U盘的功能则属于同一个系统的扩展。Windows系统通过USB总线向设备发起SCSI命令,比如读容量、读扇区、写扇区,我只需要把这些命令翻译成FATFS的底层读写接口调用,也就是把逻辑块地址LBA映射到文件系统的扇区操作上,就能让电脑把STM32识别成一块普通的U盘。这样系统同时具备两条数据通路:SPI+SD卡是慢速但可靠的本地存储,USB MSC是给外部主机访问存储内容用的高速通道。

2. 硬件电路与接线要点

SD卡的SPI模式是标准的4线制:CLK、MOSI(主机输出从机输入)、MISO(主机输入从机输出)、CS。需要注意的是,SD卡的IO电平是3.3V,而且它平时兼容性最好的是直接对接3.3V的单片机系统。如果你用的是5V供电的STM32,必须加电平转换或者用MOS管做电平匹配,别直接怼上去。

我实际用的接线是这样:

STM32引脚功能连接目标
PA5SPI1_SCKSD卡 CLK
PA7SPI1_MOSISD卡 DI(CMD输入)
PA6SPI1_MISOSD卡 DO(数据输出)
PA4GPIO 输出SD卡 CS
3.3V电源SD卡 VCC
GND地SD卡 GND

有一点很多人第一次碰SD卡容易忽略:上电后不要急着初始化,等VCC稳定后再发命令。有些SD卡在上电瞬间处于未定义状态,如果此时总线上有毛刺或者片选跳动,可能直接卡死在未知模式。我习惯上电后等50ms以上再开始初始化序列,这样最保险。

还有一个坑是SD卡座上的卡检测引脚(CD)。有的卡座自带机械开关,卡插入和拔出会改变某个引脚电平,但很多STM32开发板并没有把这个引脚正确接出来。如果你的项目需要检测SD卡是否插入,务必在原理图上确认CD引脚接到了哪个GPIO,并且加了合适的上拉电阻——这个开关通常是开漏输出,需要外部上拉才能读出确定电平。

供电方面更要留意。SD卡写入时峰值电流能达到100mA级别,如果板子的3.3V电源是从USB口直接取的,并且没有加足够的滤波电容,写入大文件时可能出现电压跌落,导致USB枚举失败或者SD卡写入错误。我在电源输入端并联了一个100uF电解电容和两个0.1uF陶瓷电容,实测下来稳多了。

硬件设计完,还有个容易出问题的地方是走线距离。SPI的时钟频率我最终跑在18MHz,在开发板上用杜邦线连接SD卡模块时,线长超过20cm就可能出现数据出错。原因很简单:SPI是同步串行协议,时钟线和数据线的信号延迟和串扰在高速下会破坏时序关系。如果你必须用长线,建议把SPI时钟降到1MHz以下,或者改用屏蔽线。我在原型阶段吃够了杜邦线的亏,最后直接飞了几根短粗线,问题立刻消失。

3. CubeMX工程配置与SD卡SPI驱动封装

我用STM32CubeMX生成工程基础配置,这是HAL库开发最舒服的路径。具体版本是CubeMX 6.10配合F1系列固件包1.8.5,这个组合很稳定。

CubeMX里的关键配置如下:

  • SPI1模式设为Full-Duplex Master,时钟源选择内部时钟
  • 波特率预分频器设置成64分频,初始SPI时钟约1.125MHz(基于72MHz系统时钟)
  • 数据大小8bit,时钟极性CPOL=Low,时钟相位CPHA=1Edge
  • 首字节MSB先行
  • NSS软件控制,不使能硬件片选

为什么初始化阶段把SPI时钟压低到1MHz左右?因为SD卡在进入SPI模式之前,无法通过SPI接口告知主机它支持多快的速率。协议规定主机必须以不超过400kHz的速率发送命令来唤醒SD卡,等它完成初始化之后才允许提高时钟。虽然实践中大多数卡在1MHz下也能正常完成初始化,但我还是老老实实按规范做,等ACMD41成功后再把SPI分频系数改成4分频(18MHz),这样最稳妥。

CubeMX生成的HAL库代码会做两件事:初始化SPI外设寄存器、配置GPIO复用功能。在此基础上我封装了三个底层函数,后面所有的SD卡操作都建立在这三者之上:

// 底层SPI读写一个字节 uint8_t SD_SPI_ReadWriteByte(uint8_t data) { uint8_t rx_data = 0; HAL_SPI_TransmitReceive(&hspi1, &data, &rx_data, 1, HAL_MAX_DELAY); return rx_data; } // 片选控制 void SD_CS_Low(void) { HAL_GPIO_WritePin(SD_CS_GPIO_Port, SD_CS_Pin, GPIO_PIN_RESET); } void SD_CS_High(void) { HAL_GPIO_WritePin(SD_CS_GPIO_Port, SD_CS_Pin, GPIO_PIN_SET); } // 发送哑字节,相当于给SD卡提供时钟 void SD_WriteDummyByte(void) { SD_SPI_ReadWriteByte(0xFF); }

这里有个关键细节:SD卡是半双工性质的SPI设备,主机的MISO引脚在读到的同时也在发数据,而SD卡在任何不发送有效数据的时刻,MOSI线都应该保持高电平(输出0xFF)。很多人踩的坑就是在片选拉高后没有补发时钟脉冲,导致SD卡内部的命令状态机没有及时复位,下一次命令直接被忽略。正确的做法是每次片选拉低之前和之后,都额外发送至少8个0xFF字节。

再往下是SD卡命令发送函数的核心实现。SD卡的命令格式是固定的6字节:1字节命令号+4字节参数+1字节CRC。在SPI模式下,除了CMD0和CMD8必须带有效CRC之外,其他命令的CRC字节可以填0xFF,因为SPI模式默认关闭了CRC校验。

uint8_t SD_SendCommand(uint8_t cmd, uint32_t arg, uint8_t crc) { uint8_t cmd_buf[6]; cmd_buf[0] = cmd | 0x40; cmd_buf[1] = (arg >> 24) & 0xFF; cmd_buf[2] = (arg >> 16) & 0xFF; cmd_buf[3] = (arg >> 8) & 0xFF; cmd_buf[4] = arg & 0xFF; cmd_buf[5] = crc; for (int i = 0; i < 6; i++) { SD_SPI_ReadWriteByte(cmd_buf[i]); } // 读取响应,最多等待8个字节 uint8_t resp = 0xFF; for (int i = 0; i < 8; i++) { resp = SD_SPI_ReadWriteByte(0xFF); if (!(resp & 0x80)) // 最高位为0表示有效响应 { break; } } return resp; }

等待响应时要注意:SD卡的响应不是在命令发完立刻出现,而是需要几个时钟周期的延迟。命令字节从MOSI发出后,SD卡内部处理需要时间,所以必须持续发送0xFF,并逐字节检查响应第一个字节的最高位。如果读到最高位是0,说明这是有效响应,否则继续往下读,最多读8个字节还等不到响应就返回超时。

HAL库的HAL_SPI_TransmitReceive在18MHz时钟下,单字节传输耗时大约0.05us,但加上函数调用和HAL库的忙等待开销,整体会放大。实际测下来每字节大约0.5us左右,所以一条SD卡命令的完整往返(命令6字节+响应)大概5-10us,这对于SD卡协议完全够用。

4. SD卡SPI模式初始化全过程

SD卡初始化是整个项目最容易让人抓狂的部分,没有之一。这一节我会把完整的初始化序列拆开讲,包括每条命令的作用、参数含义、响应格式和可能遇到的问题。

SD卡的初始化本质上是把卡从SD总线模式切换到SPI总线模式。上电的时候SD卡默认处于SD模式,想切换到SPI模式,有一个标准的握手过程:

第一步,上电延时。我前面提过,先等VCC稳定。代码实现就是HAL_Delay(50),然后向SD卡发送至少74个时钟周期的哑字节。这74个时钟的作用是让SD卡内部的电源上升检测电路有足够时间稳定,同时让它完成上电后的初始状态机。我一般直接发10次SD_WriteDummyByte(),一个字节8个时钟,共80周期,够用。

第二步,发送CMD0,参数为0x00000000,CRC为0x95。CMD0的作用是让SD卡进入空闲状态,在SPI模式下同时完成模式切换。收到响应应该是0x01(R1响应,表示空闲状态)。这个响应是整个初始化里最关键的标志。

第三步,发送CMD8,参数为0x000001AA,CRC为0x87。CMD8的作用是查询SD卡是否支持SDHC/SDXC规范,同时验证供电电压。参数的低12位0x1AA表示主机支持2.7V-3.6V电压范围。如果SD卡支持SDHC协议,它会返回R7响应:第一个字节是0x01(表示空闲状态),紧接着的4个字节中包含回显的电压信息和check pattern。如果卡不支持,CMD8会返回非法命令错误(0x05),那就说明这是一张老旧的SDSC卡,需要用另一种流程处理。

注意CMD8的CRC不是随便填的,它必须按标准算法计算。因为CMD0和CMD8是在SD卡进入SPI模式之前发送的,此时CRC校验还没有关闭,卡会验证命令的CRC。我上面给出的0x95和0x87是这两个命令的标准CRC值,查SD卡物理层规范就能找到,直接抄就行。

第四步,轮询ACMD41。ACMD41不是一个独立的命令,它需要先发送CMD55(0x77),告诉SD卡下一条命令是应用特定命令,然后再发送ACMD41(实际命令号是0x69)。参数0x40000000表示请求SDHC/SDXC,也就是让卡输出基于块的地映射方式。如果卡支持SDHC,它会在初始化完成后返回0x00;如果卡是SDSC,需要把HCS位清零后重新尝试。

轮询过程大概是:

do { SD_SendCommand(CMD55, 0, 0xFF); // 通知下一条是应用命令 res = SD_SendCommand(CMD41, 0x40000000, 0xFF); // ACMD41 } while (res == 0x01); // 0x01表示仍在初始化中,继续循环

这里有个执行次数的问题。SD卡内部初始化需要时间,有些卡可能要几百毫秒才能完成,所以轮询要有超时上限。我通常限制250次,每次之间加少量延时,防止死循环浪费时间。

第五步,读取OCR寄存器,发送CMD58。参数为0,响应R3,前5个字节中第2-5字节是OCR值。OCR的第30位(CCS位)如果是1,说明这是一张SDHC/SDXC卡,后续读写使用块寻址;如果是0,说明是SDSC卡,使用字节寻址。这一步不是必须的,但它能帮助你确认卡的容量类型,避免后续读写时的地址计算错误。

完成上面五步之后,发给SD卡的第一个实际操作是读CSD寄存器。CSD(Card Specific Data)寄存器里存着卡的容量、块大小、最大读速率等关键参数。发送CMD9(参数0,CRC 0xFF),响应R1成功后会输出16字节的CSD数据。我的实现里不解析完整的CSD,而是直接调用SD_GetCardInfo方法,从CSD的字段里提取卡容量和块数:

uint8_t SD_GetCardInfo(void) { uint8_t csd[16]; uint8_t res = SD_SendCommand(CMD9, 0, 0xFF); // 发送CMD9读取CSD if (res != 0x00) { return SD_ERROR; } // 读取16字节CSD数据,末尾有2字节CRC for (int i = 0; i < 16; i++) { csd[i] = SD_SPI_ReadWriteByte(0xFF); } // 根据CSD结构版本解析容量 uint8_t csd_structure = (csd[0] >> 6) & 0x03; if (csd_structure == 0x01) // CSD Version 2.0,SDHC/SDXC { uint32_t c_size = ((uint32_t)(csd[7] & 0x3F) << 16) | ((uint32_t)csd[8] << 8) | csd[9]; card_info.block_count = (c_size + 1) * 1024; // 块数 card_info.block_size = 512; // 块大小512字节 } else // CSD Version 1.0,SDSC { uint32_t c_size = ((uint32_t)(csd[6] & 0x03) << 10) | ((uint32_t)csd[7] << 2) | ((csd[8] >> 6) & 0x03); uint32_t read_bl_len = csd[5] & 0x0F; card_info.block_count = (c_size + 1) * (1 << (read_bl_len + 2)); card_info.block_size = 512; } return SD_OK; }

读CSD的过程中有一个常见的坑:读CSD数据前必须坚持读够16字节,且期间片选保持低电平。很多人在响应后只读了一两个字节就拉高片选,导致卡的状态机错乱。SD卡的传输模式是这样的:命令响应结束后,如果命令对应有数据输出,主机会收到一个数据起始令牌0xFE,之后是真正的数据块和2字节CRC。只有看到0xFE才能开始存数据,否则要一直读到超时。

5. FATFS文件系统移植与读写验证

SD卡协议层搞定之后,接下来就是把FATFS文件系统挂上去。FATFS是一个开源的FAT文件系统实现,专门为嵌入式设计,整个代码量几千行,功能完整支持FAT12/16/32,在STM32圈子里已经是事实上的标准方案。

我从ELM-Cha官网下载的是FATFS R0.14b,解压后需要关注的文件有这几个:

文件作用需要修改吗
ff.h / ff.cFATFS核心实现不用
ffsystem.c内存管理、时间获取等OS相关根据平台改
ffunicode.cUnicode支持,用于长文件名按需启用
diskio.h / diskio.c底层磁盘接口必须改,这是衔接的关键
ffconf.h功能配置宏根据需求改

移植的核心工作是实现diskio.c里的6个函数:disk_initialize、disk_status、disk_read、disk_write、disk_ioctl、get_fattime。这6个函数就是FATFS与具体存储介质的桥梁。

disk_initialize的任务是调用我前面写的SD卡初始化序列,让SD卡进入就绪状态。disk_read和disk_write则是按照给定的扇区地址和数量进行读写。这里的扇区地址是逻辑块地址(LBA),也就是从0开始编号的扇区,对应SD卡协议里的块地址。

DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != SD_DRIVE) return RES_PARERR; if (SD_ReadBlocks((uint32_t *)buff, (uint32_t)sector, count) == SD_OK) return RES_OK; return RES_ERROR; }

SD_ReadBlocks和SD_WriteBlocks是SD卡协议层提供的读写函数,核心是发送CMD17(读单块)或CMD24(写单块),然后传输数据。对于多块读写,SD卡还支持CMD18(读多块)和CMD25(写多块),但SPI模式下多块读写需要额外的停止命令,处理起来复杂一些,我实测下来单块循环读写速度也够用,就先用单块实现了。如果你对速度有更高要求,可以考虑上多块读写。

读写单块的完整流程:

uint8_t SD_ReadBlocks(uint32_t *buff, uint32_t sector, uint32_t count) { uint8_t res; for (uint32_t i = 0; i < count; i++) { res = SD_SendCommand(CMD17, sector + i, 0xFF); if (res != 0x00) { return SD_ERROR; } // 等待数据起始令牌0xFE uint8_t token = 0xFF; uint16_t timeout = 0; while (timeout++ < 10000) { token = SD_SPI_ReadWriteByte(0xFF); if (token == 0xFE) break; } if (token != 0xFE) { return SD_ERROR; } // 读取512字节数据 for (uint32_t j = 0; j < 256; j++) { *((uint16_t *)buff + j) = SD_SPI_ReadWriteByte(0xFF) | (SD_SPI_ReadWriteByte(0xFF) << 8); } SD_SPI_ReadWriteByte(0xFF); // 读掉CRC SD_SPI_ReadWriteByte(0xFF); } return SD_OK; }

写入单块稍有不同,需要先发CMD24,然后等待一个数据接受令牌:SD卡会返回0x00表示写入成功,或者0x05表示数据CRC错误,0x0B表示写入错误。最重要的是写入后要等待SD卡内部完成实际Flash编程,这段时间卡会拉低DO引脚,主机需要持续发送时钟直到DO恢复高电平。这个等待过程是保证数据持久化正确的关键。

FATFS移植完,第一次验证我建议写一个最简单的Demo:创建文件、写入字符串、关闭文件、再读出来打印。注意FATFS使用前必须调用f_mount挂载,挂载时如果文件系统不存在,需要在底层处理卷标。

FATFS fs; FIL file; FRESULT fr; // 挂载SD卡 fr = f_mount(&fs, "", 1); if (fr != FR_OK) { printf("Mount failed: %d\r\n", fr); return; } // 新建并写入文件 fr = f_open(&file, "test.txt", FA_CREATE_ALWAYS | FA_WRITE); if (fr != FR_OK) { printf("Open failed: %d\r\n", fr); return; } char buf[] = "Hello STM32 SD Card!\r\n"; UINT bytes_written; fr = f_write(&file, buf, strlen(buf), &bytes_written); if (fr != FR_OK || bytes_written != strlen(buf)) { printf("Write failed: %d, %d\r\n", fr, bytes_written); return; } f_close(&file); printf("Write OK, %d bytes\r\n", bytes_written);

这里要提醒一个ffconf.h的配置点:如果你的应用只是写日志、读配置文件,建议把_FS_READONLY设为0(可写),把_USE_MKFS设为1(支持格式化)。如果SD卡上还没有文件系统,底层的f_mount会返回FR_NO_FILESYSTEM,这时候可以用f_mkfs来格式化卡,或者把卡插到电脑上格式化成FAT32再插回来。我实际测试发现,电脑格式化的FAT32卡在FATFS里兼容性最好,如果使用f_mkfs格式化,建议设置FM_FAT32 | FM_SFD确保分区格式正确。

在你前面那些热搜词里频繁出现的“fatfs文件系统 sd卡 stm32”,大概率就是卡在f_mount返回值不是FR_OK这一关。最常见的失败原因是底层的disk_initialize根本没有正确返回SD卡的就绪状态,或者SD卡初始化过程中超时。排查的时候你可以先单独调用disk_initialize,看返回,再用逻辑分析仪抓SPI总线,确认CMD0之后确实收到了0x01响应。

6. 模拟U盘功能的实现思路

U盘功能这部分的本质,是让STM32通过USB接口向电脑提供一个符合Mass Storage Class(MSC)规范的设备。电脑端的操作系统会向设备发送SCSI命令,设备需要正确响应并返回数据。FATFS只是让片上程序能读写FAT结构,而MSC协议是让外部主机(电脑)能通过USB访问同一个存储介质。两者打通的关键是:把SCSI的READ/WRITE命令转化成FATFS底层的磁盘扇区读写。

这里我先强调一个概念:USB MSC设备和FATFS不是二选一的关系,而是上下层关系。USB MSC只负责传输扇区数据和控制命令,FATFS则负责解析这些扇区中的FAT表和目录结构。当电脑把一个文件写入U盘时,电脑上的文件系统驱动会计算出文件对应的所有扇区地址,然后通过SCSI WRITE(10)命令把这些扇区内容发送给设备;设备端只需要把这些扇区原封不动地存入SD卡对应位置。反过来,当电脑读取文件时,SCSI READ(10)命令指定起始扇区和扇区数,设备从SD卡对应位置读出数据返回给电脑。整个过程中,设备端并不需要理解FATFS的文件分配逻辑——真正干活的是电脑那边的文件系统驱动和设备的SCSI命令翻译器。

用STM32做USB MSC设备,有两条路可以走。一条是自己写USB设备栈,处理枚举、端点传输、SCSI命令解析,工作量很大;另一条是用STM32CubeMX直接生成USB MSC设备类代码,再配合中间层把SCSI命令接到FATFS上。我建议刚开始做这个功能的朋友直接用CubeMX生成的USB设备类,因为USB协议栈本身极其繁琐,自己写一遍纯粹是浪费时间和耐心。

CubeMX生成USB MSC设备类之后,核心需要修改的是两个文件的中间层:usbd_storage_if.c或者类似名称的存储接口文件。这个文件实现了USB设备栈与具体存储介质的桥接,需要提供几个关键函数:

USB MSC回调函数功能实现逻辑
STORAGE_GetCapacity返回存储介质容量从SD卡信息结构体取块数和块大小
STORAGE_Read读指定扇区调用disk_read
STORAGE_Write写指定扇区调用disk_write
STORAGE_IsReady判断介质是否就绪返回SD卡状态
STORAGE_GetMaxLun返回逻辑单元数量单卡返回1
STORAGE_Init初始化介质调用SD卡初始化

其中最容易出错的是容量获取。USB MSC规范要求INQUIRY命令返回的设备类型是直接访问块设备(Direct Access Block Device),而READ CAPACITY(10)命令返回的最后一个块地址必须是最大LBA(从0开始编号的最后一个扇区号),不是块总数。不少人在这里栽了跟头——把块总数直接填进返回值,电脑会认为存储介质比实际多了1个扇区,最后那个扇区读写就会异常。

实现上的具体做法是:在usbd_storage_if.c里定义一个大数组或者结构体保存容量信息,然后实现READ CAPACITY响应:

int8_t STORAGE_GetCapacity(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_num = card_info.block_count - 1; // 最大LBA,不是块数量 *block_size = card_info.block_size; // 通常512 return 0; }

SCSI命令解析方面,USB设备栈已经处理了大部分命令,比如INQUIRY、READ CAPACITY(10)、TEST UNIT READY、MODE SENSE(6)等,你基本上不用操心。核心要确保的是READ(10)和WRITE(10)命令能正确解析出LBA和传输长度,然后传给FATFS底层的disk_read和disk_write。这里有一个性能考量:如果电脑一次要求读1024个扇区(512KB),而你逐扇区地调用disk_read,SPI模式下会非常慢。优化方案是在disk_read里开启DMA传输,或者实现SD卡的多块读指令CMD18。

USB枚举过程中还需要注意USB D+引脚的上拉电阻。STM32F1系列内部有USB上拉电阻,通过PWR_CR寄存器控制接入,如果用的不是F1而是其他型号,要注意外部1.5k上拉电阻的连接。这个电阻缺失或者未正确使能,会导致电脑完全识别不到USB设备。

做一个模拟U盘的最小可用版本,建议先用现成的读卡器方案验证思路:先把SD卡插电脑格式化,插回STM32,打开USB功能,电脑上就能看到一个小容量U盘。这时候如果双击打开提示“需要格式化”,十有八九是容量参数返回错误或者SCSI READ请求返回的数据内容不对。这时候可以用Bus Hound或者USBlyzer抓一下USB总线上的SCSI命令,对照协议看哪个环节出了偏差。

7. 常见问题与排查技巧实录

这一节把我实际做项目过程中踩过的坑、以及不少网友在论坛上求助的问题集中整理一下,都是能直接抄作业的排查思路。

先说CMD0超时/响应异常。这个问题几乎每一个做SD卡SPI驱动的人都遇到过。排查思路按优先级排列:

  • 检查上电延时是否足够,至少50ms,少了部分卡会不稳定
  • 检查时钟频率,初始化阶段必须低于400kHz,我用的是1.125MHz也勉强可以,但保险起见降到最低分频
  • 检查MOSI线上是否在空闲时保持高电平,CS拉低前必须发送多个0xFF
  • 用示波器抓SPI波形,确认命令字节的每一位电平跳变正常、没有信号毛刺
  • 确认CMD0的CRC确实是0x95,CMD8的CRC确实是0x87,这两个不能填0xFF

如果CMD0已经成功返回0x01,但ACMD41一直返回0x01(即一直轮询不到0x00),大概率是卡的类型判断出了问题。建议在CMD8返回的响应上做判断:如果CMD8返回0x01且回显正常,说明这张卡是SDHC/SDXC,ACMD41要设置HCS位;如果CMD8返回0x05(非法命令),说明是SDSC老卡,ACMD41的参数改成0x00000000。把这两种情况的处理分开写,兼容性会好很多。

再有一个容易被忽略的问题:有些SD卡模块自带稳压和电平转换电路,它们可能会改变MOSI/MISO信号的逻辑电平。如果你的模块已经有板载电平转换,而STM32开发板供电是5V,那么MOSI的3.3V高电平经过转换器后输出可能推到5V,反过来MISO返回的是3.3V,这其实是符合规范的。但如果你从5V单片机的GPIO直接接SD卡模块而不做转换,SPI信号的高电平可能超出SD卡的绝对最大额定值,长期工作不稳定甚至损坏卡。稳妥的做法是统一3.3V供电,IO电平保持一致。

写入数据后检测到的字节数不对,这是FATFS使用中常见问题之一。我遇到过的情况是f_write返回FR_OK,但写入的字节数少于传入长度。原因多半是磁盘容量不足或FAT表损坏。排查时先检查f_open返回值和打开模式,确保用了FA_WRITE;再检查磁盘空间,用f_getfree查看剩余扇区数;最后再用电脑格式化一次SD卡,排除文件系统损坏的干扰。

USB枚举失败或者传输中途卡死,这个问题在模拟U盘功能里几乎必现一次。常见原因包括:

  • USB D+上拉电阻未正确使能,导致外设无法进入复位状态,枚举不成功
  • USB时钟频率不对。F103的USB需要48MHz时钟,必须启用PLL时钟输出并将PLLM/PLLN/PLLQ配置正确
  • 没有正确调用MX_USB_DEVICE_Init(),或暂停位设置错误
  • 在USB传输过程中,SPI读写占用了太长时间,导致USB主机的超时等待

最后这条我深有感触。当电脑向U盘写入大量数据时,MSC协议会连续发送多个WRITE(10)命令,设备端需要马不停蹄地处理。如果每一块扇区写入SD卡耗时过长(SPI 18MHz下单块500字节大约需要几百微秒),USB总线上可能积累多笔请求。如果你的USB中断优先级比SPI中断优先级低,而SPI又占用了大量中断时间,就可能出现USB端点缓冲溢出。解决方法是降低SPI DMA中断的优先级,或者改用只轮询的方式处理USB事务,让USB端点始终有足够的时间响应主机。

SD卡显示没有文件,这个热搜词也经常出现。如果你在STM32上往SD卡写入了文件,插到电脑上却看不到,检查三件事:

  • 是否用FATFS正确创建了文件并关闭(f_close),未关闭的文件缓冲区数据可能未落盘
  • 是否在插入电脑之前安全卸载了文件系统(f_mount(NULL)取消挂载),不然FAT表可能没有刷新
  • 是否格式化成了FAT32而不是exFAT或NTFS。我的经验是FATFS对FAT16和FAT32兼容性好,exFAT不支持,拿到电脑上如果显示为RAW格式,多半是分区表没写正确

块地址计算错误。SDSC使用字节寻址,SDHC使用块寻址,在FATFS的disk_read接口传入的sector参数是LBA(扇区号),底层需要转换成SD卡的块地址。对于SDSC卡,块大小是512字节,块地址 = LBA * 512;对于SDHC卡,块地址直接等于LBA。如果你初始化时没有正确区分卡类型,读到后面必然出错。代码里最稳妥的做法是初始化完成后存一个标志变量,比如card_info.card_type,在ReadBlocks/WriteBlocks里根据这个标志决定地址如何转换。

上面这些问题,其实建一个简单的调试接口就能大大提速排查。我习惯在工程里加一个命令行串口菜单,输入命令就能测试SPI读写、单块读写、FATFS挂载、文件创建删除等。不用每次改完代码重新烧录,直接在终端敲命令验证,效率高很多。

8. 实际测试数据与性能调优

代码都稳定跑通之后,我把各环节的性能数据整理了一下。这里给出一组在STM32F103C8T6(72MHz主频)+ SanDisk 16GB Class10 TF卡 + SPI1 18MHz时钟下实测的数据:

操作耗时/速率说明
单块读(512字节)约0.8ms平均约640KB/s
单块写(512字节)约2.5ms受Flash编程速度影响
FATFS连续写1MB文件约2.8s包含文件系统管理开销
FATFS连续读1MB文件约1.6s读比写快约40%
挂载/初始化SD卡约300ms主要是ACMD41轮询等待

这个速度用来看日志文件、存储传感器数据完全够用。但如果你要存音频采样或者图像数据,瓶颈就明显了。想要提速有几个方向:

第一,开启DMA传输。HAL_SPI_TransmitReceive是阻塞式传输,每次传输期间CPU都在空转。改为HAL_SPI_TransmitReceive_DMA后,可以在SPI搬运数据的同时处理其他任务。注意DMA传输完成后要等待SPI总线完全空闲再切换方向,否则可能丢失最后一两个字节。

第二,使用多块读命令CMD18。FATFS是按连续扇区读取文件的,底层实现可以把多个连续扇区的读取合并成一次CMD18请求,减少命令交互的开销。我实测过,64块合并读比64次单块读快接近一倍。同理写操作可以用CMD25。

第三,调整FATFS配置。ffconf.h里的_FS_MINIMIZE、_USE_FASTSEEK这些配置影响代码大小和运行速度。对于追求读速度的应用,建议把_FS_READONLY设为1(只读模式,减少磁盘写入检查)、把_FS_TINY设为1(使用缓存池,减少RAM占用)、把_MULTI_PARTITION设为0。

第四,提高SPI时钟到36MHz。部分Class10 SD卡支持更高的SPI时钟。我把SPI分频调到2(系统时钟72MHz/2 = 36MHz),实测SD卡仍能正常工作,单块读耗时降到约0.5ms。但要注意,如果你的SD卡座走线比较长,或者用了杜邦线连接,36MHz下信号质量可能变差,需要降到18MHz或者用短线。

性能调优的原则是:稳定性永远优先于速度。嵌入式存储最怕的就是写入过程中突然失败,导致整个FAT表损坏、之前的数据全部丢失。我最终把SPI时钟稳定在18MHz,既保证了速度又留出了裕量。

9. 项目扩展与个人体会

这个项目做完,你会发现SPI+SD卡+FATFS这套组合几乎可以套用在任何需要数据记录的嵌入式项目里。我自己后来又基于这个基础做了两个变体:一个是把数据通过FATFS按天生成日志文件,每天凌晨自动创建新文件;另一个是把USB MSC功能做成可配置开关,需要导出数据时插入USB线,电脑上就能直接读取TF卡里的所有记录文件。

如果你想在这个基础上继续深入,可以试试这几个方向:

  • 把底层的diskio.c改成操作外部NOR Flash或者W25Q系列Flash,实现FATFS在SPI Flash上的文件管理,这样一来SD卡的驱动代码可以完全复用
  • 在FATFS之上挂接一个轻量级的KV存储组件,比如LittleFS或者FlashDB,用来管理配置参数和系统状态,比直接操作FAT文件灵活
  • 增加掉电保护机制,比如引入日志写入的幂等机制和文件系统检查工具,防止拔卡断电导致数据损坏
  • 用RTOS把SPI驱动、FATFS和USB MSC拆成多个任务,FATFS在任务A里维护,USB在任务B里维护,中间用消息队列传递读写请求,这样系统扩展性更好

最后再分享一个我个人特别偏好的细节。调试SD卡或USB时,不要相信任何“应该没问题”的判断,所有关键信号都要用示波器或者逻辑分析仪亲眼确认。我项目早期遇到的很多诡异问题,最后追踪下来不是判断错误就是连线虚焊,SPI波形一抓全暴露了。我现在的调试流程是:先把系统时钟引脚和SPI引脚的波形抓到正常,再调SD卡初始化,再测单块读写,再挂FATFS,最后才开USB功能。每一步都用波形验证,少了这一步,后面排错的成本会成倍上涨。

这套流程走完之后,你会发现自己对SPI协议的理解、对存储设备工作方式的认知都上了一个台阶。下次再拿到一个不熟悉的存储芯片或传感器,照着这个思路拆解、初始化、验证,很快就能跑通。这也是嵌入式开发的乐趣所在:底层越扎实,上层越自由。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 4:01:02

H∞鲁棒控制MATLAB仿真全流程:从不确定性建模到闭环验证

写这篇文章的念头&#xff0c;源于我最近帮一位做机电系统的朋友排查控制问题。他调了一个月的PID参数&#xff0c;在标称工况下响应漂亮得无懈可击&#xff0c;结果换了一批负载、环境温度一变化&#xff0c;系统直接振荡发散。这其实是鲁棒性问题里最典型的一个场景——你设计…

作者头像 李华
网站建设 2026/10/5 4:01:00

HBM带宽如何决定智能体并发规模

1. 这不是芯片参数表&#xff0c;而是一份智能体规模的“水电容量”预估报告你可能已经看过不少关于HBM&#xff08;高带宽内存&#xff09;的技术解析——堆叠层数、TSV孔密度、微凸点间距、带宽计算公式……但这次&#xff0c;我们得换个视角&#xff1a;把HBM看作一种算力基…

作者头像 李华
网站建设 2026/10/5 3:59:52

JitWord实测:国产系统上协同AI文档的部署与应用

最近一个月&#xff0c;我在测试环境里反复折腾了几款面向国产操作系统的协同文档产品。说句实话&#xff0c;以前这套组合拳打下来体验很折磨&#xff1a;麒麟系统上能装WPS&#xff0c;但你想让团队多人同时编辑一份文档、想让AI帮你起草方案初稿、想在内网环境里把文档权限精…

作者头像 李华
网站建设 2026/10/5 3:59:27

LeetCode 49 字母异位词分组:排序键与计数键的哈希解法

1. 先把“字母异位词”这道题翻译成人话1.1 anagram的准确定义与题目原貌刷题圈有个老段子&#xff1a;跟不刷题的朋友提“字母异位词”&#xff0c;对方多半愣住&#xff1b;换成“就是字母重新排列”&#xff0c;他马上点头。所谓字母异位词&#xff08;anagram&#xff09;&…

作者头像 李华
网站建设 2026/10/5 3:59:16

不被定义的她:拿回生活解释权的五个实操方法

打开日历&#xff0c;3月8日临近。这几年的妇女节&#xff0c;我总有一种微妙的不适应&#xff1a;办公室会订花&#xff0c;群消息弹出“女神节快乐”&#xff0c;朋友圈满屏祝福与促销海报。热闹是真实的&#xff0c;可当我一个人坐下来认真问自己——抛开所有祝福、仪式和别…

作者头像 李华
网站建设 2026/10/5 3:58:20

深入 GPU 微架构:现代 C++ 视角下的 128 字节物理对齐与 mmap 显存直传

在大模型生产环境部署中,当单节点拉起 70B 规模(约 140GB 权重)的模型服务时,冷启动阶段的主机物理内存(Host RAM)常驻峰值往往比首 Token 延迟(TTFT)更早触发运维告警。一个采用半精度(FP16/BF16)存储的 7B 模型权重仅占 14GB 显存,但在传统的反序列化加载链路下,…

作者头像 李华