ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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盘。这样系统同时具备两条数据通路SPISD卡是慢速但可靠的本地存储USB MSC是给外部主机访问存储内容用的高速通道。2. 硬件电路与接线要点SD卡的SPI模式是标准的4线制CLK、MOSI主机输出从机输入、MISO主机输入从机输出、CS。需要注意的是SD卡的IO电平是3.3V而且它平时兼容性最好的是直接对接3.3V的单片机系统。如果你用的是5V供电的STM32必须加电平转换或者用MOS管做电平匹配别直接怼上去。我实际用的接线是这样STM32引脚功能连接目标PA5SPI1_SCKSD卡 CLKPA7SPI1_MOSISD卡 DICMD输入PA6SPI1_MISOSD卡 DO数据输出PA4GPIO 输出SD卡 CS3.3V电源SD卡 VCCGND地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时钟极性CPOLLow时钟相位CPHA1Edge首字节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参数为0x00000000CRC为0x95。CMD0的作用是让SD卡进入空闲状态在SPI模式下同时完成模式切换。收到响应应该是0x01R1响应表示空闲状态。这个响应是整个初始化里最关键的标志。第三步发送CMD8参数为0x000001AACRC为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不是一个独立的命令它需要先发送CMD550x77告诉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寄存器。CSDCard Specific Data寄存器里存着卡的容量、块大小、最大读速率等关键参数。发送CMD9参数0CRC 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.0SDHC/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.0SDSC { 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 ClassMSC规范的设备。电脑端的操作系统会向设备发送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_readSTORAGE_Write写指定扇区调用disk_writeSTORAGE_IsReady判断介质是否就绪返回SD卡状态STORAGE_GetMaxLun返回逻辑单元数量单卡返回1STORAGE_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_readSPI模式下会非常慢。优化方案是在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确实是0x95CMD8的CRC确实是0x87这两个不能填0xFF如果CMD0已经成功返回0x01但ACMD41一直返回0x01即一直轮询不到0x00大概率是卡的类型判断出了问题。建议在CMD8返回的响应上做判断如果CMD8返回0x01且回显正常说明这张卡是SDHC/SDXCACMD41要设置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. 实际测试数据与性能调优代码都稳定跑通之后我把各环节的性能数据整理了一下。这里给出一组在STM32F103C8T672MHz主频 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. 项目扩展与个人体会这个项目做完你会发现SPISD卡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协议的理解、对存储设备工作方式的认知都上了一个台阶。下次再拿到一个不熟悉的存储芯片或传感器照着这个思路拆解、初始化、验证很快就能跑通。这也是嵌入式开发的乐趣所在底层越扎实上层越自由。
返回列表