ARTICLE DETAIL

资讯详情

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

STM32 SPI接口TF卡数据存储与USB MSC导出方案详解

STM32 SPI接口TF卡数据存储与USB MSC导出方案详解 做嵌入式设备两年之后我现在遇到“记录数据导出数据”的需求默认会直接这么干STM32这边用SPI挂一张TF卡跑FATFS写日志然后同一个卡再通过USB的MSC类模拟成U盘让用户像插U盘一样把文件拷走。这个组合看起来不复杂但真正调通尤其让FATFS和USB两个模块同时访问同一张SD卡不出乱子踩掉的坑比预想多得多。这篇应用笔记就把整个方案从硬件到中间件再到实测性能捋一遍给要照着做的人一个能落地的参考。我默认你用的是HAL库工程基于CubeMX生成。下面所有的函数名、配置路径、代码片段都是我实际用过的写法不是从官方例程原样抄的。1. 为什么选SPI模式而不是SDIO或模拟IO很多人看到SD卡第一反应是上SDIO因为SDIO跑得快F103、F4系列都有外设可以开。但实际做产品你会发现很多场景根本用不到SDIO的速度反而被它占掉的那几个引脚和时钟配置拖得头疼。1.1 SDIO和SPI的差距到底在哪SDIO是并行度更高的总线一条时钟线、一条命令线、四根数据线带宽能到48MHz甚至更高。代价是占用至少6个IO而且板子走线不好时高速模式很容易翻车。SPI模式只需要SCK、MOSI、MISO、CS四根线几乎所有STM32型号都带SPI外设引脚分配灵活得多。速度上SPI模式理论最高大概25MHzSTM32F103的SPI1最高18MHz实际顺序读写跑下来读1MB/s、写500KB/s左右。对大多数数据记录设备来说每秒几十KB的日志量已经完全够用。如果你的需求是连续存摄像头图像、或者要一边写卡一边做大量实时运算再去考虑SDIO都来得及。1.2 这套方案的完整数据链路我先说清楚整套方案的逻辑后面看代码就不容易乱STM32通过SPI访问TF卡裸扇区。FATFS中间件把裸扇区封装成文件系统MCU这边可以用f_open、f_write直接写日志文件。USB MSC类把同一张TF卡的裸扇区映射到PC端PC把设备识别成一块U盘。所以底层访问的是同一个卡区别只在上层。FATFS和USB MSC共用disk_read和disk_write这两组函数也正是这个“共用”埋了不少坑后面专门讲。2. 硬件上最容易翻车的几个细节软件问题debug起来还能看打印、加断点硬件问题经常是玄学。SD卡在SPI模式下对供电、上拉、走线长度都比较敏感我把踩过的坑按优先级列一下。2.1 引脚分配和电平匹配SPI外设具体用哪个先说结论优先选SPI1或SPI2尽量避免多个外设模块和SPI复用同一组引脚。我自己习惯把SCK、MOSI、MISO放在同一组CS用任意普通GPIO。CS最好用软件控制不推荐用SPI的硬件NSS。硬件NSS在SPI异常挂起时可能产生不可控的片选抖动SD卡对CS引脚上的毛刺很敏感。电平方面记住一句话SD卡是3.3V器件不能直接接5V。很多开发板上的“5V输出”是给外设供电用的千万不要接到TF卡的VCC。如果主控是5V逻辑中间必须加电平转换否则轻则读卡不稳定重则烧卡。MISO、CS这几个信号线上建议加10k上拉到3.3V。你可能会发现不加也能跑通一旦你开始用长杜邦线或者接触不良的卡座上拉电阻能救你很多次。2.2 TF卡座和去耦电路卡座选择上我强烈建议买带卡检测引脚CD的型号虽然平时可以不用但调试时把一个GPIO接在CD上能够直接判断卡有没有插好能省去大量“为什么读不到卡”的排查时间。供电不能只靠DC-DC输出就完事。SD卡在写入瞬间电流能冲到几十甚至上百毫安如果电源芯片余量不够VCC会瞬间跌落卡直接进入假死状态表现就是CMD0能通、ACMD41超时、f_open报FR_DISK_ERR。正确的做法是在卡座VCC旁边放一颗10uF钽电容再加一颗100nF陶瓷电容并且尽量靠近卡座引脚。如果是模块和主控板分离供电线不要太长。2.3 初始化频率和SPI工作模式SD卡的SPI初始化协议要求主机时钟必须先压到400kHz以下。这是硬性要求几乎所有卡在高速时钟下都不回应CMD0。所以软件上要保留一个“先低速初始化、再切换高速时钟”的过程。SPI模式选择上SD卡在SPI方式下并没有强制指定CPOL/CPHA大家用得最多的是Mode 0也就是CPOL0、CPHA0我也是用的Mode 0。如果你拿网上的代码对不上先检查SPI极性怀疑的时候可以试试Mode 3但以Mode 0为默认。3. CubeMX配置与工程骨架使用CubeMX生成工程时有几个选项很容易配错配错之后的报错又不太直观。我把每一步的选择按实际工程写清楚。3.1 SPI外设配置CubeMX里打开使用的SPI外设比如SPI1Mode选Full-Duplex MasterHardware NSS选Disable用软件控制CS帧格式8bitMSB first预分频先选最大的分频系数保证初始时钟低于400kHz等初始化完成后再在代码里改小CPOL/CPHA按Mode 0配置记得确认SPI1挂在哪条总线上比如F103的SPI1挂在APB2上72MHz主频下2分频就是36MHz已经超过SD卡SPI模式的理论上限所以不要一上来就开最小分频。3.2 FATFS中间件配置在Middleware里打开FATFS。如果你的CubeMX版本在FATFS配置页能看到底层外设选择直接选SPI对应的选项CubeMX会生成diskio_spi.c或者类似的框架文件。如果没有就选“user-provided”那类选项CubeMX只生成diskio.c、ff.c这些中间件文件SPI底层自己填。FatFS本身的配置有几个要点FF_USE_MKFS建议打开方便在代码里用f_mkfs格式化成FAT32FF_FS_EXFAT默认关闭如果不需要exFAT就别开能省不少RAMFF_USE_STRFUNC按需打开方便用f_printf3.3 USB MSC配置在Connectivity里打开USB_OTG_FS或USB设备相关外设Mode选Device然后在Middleware和Software Packs里打开USB_DEVICEClass选Mass Storage Class。USB相关的时钟配置一定要检查USB FS外设需要48MHz时钟。时钟树没配好的话插上电脑要么没反应要么反复枚举失败。ST的HAL库里很多板级初始化会去检查时钟但你最好在工程生成后看一眼SystemClock_Config确认HCLK和USB时钟都正常。堆栈大小也不能省。USB MSC加上FATFS尤其开了中文文件名支持之后内存占用明显变大。我一般在启动文件的堆栈设置里把Heap和Stack都提到8KB否则跑一阵子会出现无法解释的死机。4. 把SD卡挂到FATFSdiskio移植全流程CubeMX生成的FATFS中间件默认带一套diskio框架但里面的底层函数都是空的。这一步是整个工程的核心网上很多教程拷来拷去细节错误很多。我把每个函数实际怎么填、为什么这么填讲清楚。4.1 底层SPI字节读写怎么封装在搞diskio之前先保证最底层的SPI字节收发是稳的。HAL库里最简单的写法uint8_t SD_SPI_TransByte(uint8_t tx) { uint8_t rx 0; HAL_SPI_TransmitReceive(hspi1, tx, rx, 1, 10); return rx; }这里有两个容易踩的细节。第一SPI是全双工的你想读一个字节就得同时发送一个字节。SD卡在SPI模式下主机不发送时钟时从机不会输出MISO数据所以读操作之前必须先填0xFF把时钟“推”过去。第二这个函数的超时时间不要设太小10ms已经是比较紧张的值了有些慢卡在初始化阶段会卡很久我习惯设100ms只在传输大数据块时才用DMA或中断。接下来是发送命令并获取R1响应。SD卡命令帧固定6字节分别是命令号、4字节参数、1字节CRCstatic uint8_t SD_SendCmd(uint8_t cmd, uint32_t arg, uint8_t crc) { uint8_t frame[6]; frame[0] (cmd 0x3F) | 0x40; frame[1] (arg 24) 0xFF; frame[2] (arg 16) 0xFF; frame[3] (arg 8) 0xFF; frame[4] arg 0xFF; frame[5] crc; SD_CS_LOW(); for (uint8_t i 0; i 6; i) { SD_SPI_TransByte(frame[i]); } uint8_t resp; for (uint8_t i 0; i 10; i) { resp SD_SPI_TransByte(0xFF); if ((resp 0x80) 0) // R1响应的bit7为0 { break; } } return resp; }注意SD_CS_LOW()包含一个完整的CS操作CS拉低之前建议先发8个以上的无用时钟这是很多移植失败的源头。CS拉低后的第一个周期有的卡直接能回响应有的需要几个空闲时钟所以上面的代码里用了最多10次字节读来等待有效R1。4.2 SD卡初始化序列初始化的命令序列是固定的少一条都不行发送至少74个时钟周期且CS保持高电平让卡完成上电准备。CS拉低发送CMD0参数0CRC固定0x95等待R1响应为0x01。发送CMD8参数0x1AA等待R1响应如果返回不是0x01说明是旧版SD卡要按SD v1分支处理。循环发送ACMD41也就是先发CMD55再发CMD41参数带0x40000000使能HCS直到响应变成0x00表示卡初始化完成。通过CMD58读取OCR寄存器判断是SDHC还是标准SD卡SDHC卡后续可以用块寻址标准SD卡按字节寻址这个决定后面扇区号怎么传。初始化过程中的一个关键点ACMD41可能让卡忙很长时间循环次数一定要给够。我之前用过一个杂牌卡冷启动时ACMD41要循环上千次才通过循环次数一少就报初始化失败。同样循环里每次重发命令前要发至少8个时钟让卡结束忙状态。初始化完成后把SPI分频系数调小比如从72MHz主频下的128分频改成4分频等于把SPI时钟拉高到18MHz。然后再发送CMD16设置块长度为512字节。这里有个很多人忽略的点SD卡SPI模式下块长度必须和FATFS期望的扇区大小一致一般是512字节不能改成1024之类的值。4.3 对接diskio的六个函数FATFS只认DRESULT和DSTATUS不需要知道你底层用的是SPI还是SDIO。你需要填的六个核心函数是disk_status返回卡是否初始化、是否写保护disk_initialize调用上一步的SD初始化流程disk_read读单个或多个扇区disk_write写单个或多个扇区disk_ioctl给FATFS返回扇区数、扇区大小、擦除块大小等信息disk_read最容易写错的是多扇区读。单扇区用CMD17多扇区用CMD18多扇区读完之后必须发CMD12停止传输否则下一次命令会一直收不到正常响应。代码结构大致是uint8_t SD_ReadBlocks(uint32_t sector, uint8_t *buf, uint32_t count) { if (count 1) { if (SD_SendCmd(CMD17, sector, 0) ! 0x00) return SD_ERROR; // 等待0xFE数据起始令牌 // 收512字节 2字节CRC } else { if (SD_SendCmd(CMD18, sector, 0) ! 0x00) return SD_ERROR; for (uint32_t i 0; i count; i) { // 等待0xFE收512字节 2字节CRC } SD_SendCmd(CMD12, 0, 0); // 停止多扇区读 } return SD_OK; }每次读完一个扇区卡并不是立刻就能发下一段数据中间可能插入若干个0xFF填充字节。等待数据起始令牌时要容忍这些填充字节不能一上来就把第一个0xFF当成数据。disk_write类似单扇区用CMD24多扇区用CMD25。写入操作里最关键的一步是等待卡忙结束。写完一扇区后卡会把数据线拉低表示忙主机必须持续读状态字节一直到读到0xFF表示不忙才能发起下一次操作。4.4 读写不稳定的常见原因如果你把上面这层写好后仍然经常报FR_DISK_ERR优先查三件事。第一片选逻辑。SD卡在SPI下是半双工共享总线CS必须严格管理绝对不能一直拉低不放。每次命令前拉低命令响应读完后就可以拉高。第二SPI时钟频率。很多卡在18MHz下能初始化但读大文件出错这种时候不要折腾代码先降到9MHz或12MHz验证一遍多半是走线太长或者接触不良。第三电压跌落。前面说过写入峰值电流的问题用示波器看卡座VCC在写入时有没有毛刺比看代码更有用。5. USB模拟U盘与FATFS共存的坑CubeMX生成的USB MSC工程里真正需要改的文件是usbd_storage_if.c。默认模板返回的是内存盘或者示例扇区你要把它改成直接操作SD卡。5.1 USB MSC底层函数怎么填关键就是把USB的扇区读写重定向到disk_read和disk_writeint8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { return (disk_read(0, buf, blk_addr, blk_len) RES_OK) ? 0 : -1; } int8_t STORAGE_Write_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { return (disk_write(0, buf, blk_addr, blk_len) RES_OK) ? 0 : -1; } int8_t STORAGE_GetCapacity_FS(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_size 512; disk_ioctl(0, GET_SECTOR_COUNT, block_num); return 0; }这里面有个非常隐蔽的问题Windows枚举U盘时不一定只发整块读请求有时候会发长度不是512整数倍的SCSI命令尤其在做“安全删除硬件”的时候。如果你的disk_read对非512对齐的请求直接返回错误Windows那边可能会直接把设备判定为故障设备。稳妥的做法是把传入的blk_addr和blk_len严格按512对齐检查不满足时做扇区缓冲拼接而不是简单报错。5.2 FATFS和USB同时访问怎么保证不冲突这是整套方案里最容易出大问题的地方。MCU里的FATFS在打开文件后会在RAM里维护文件分配表缓存和工作目录状态而USB那一端PC通过SCSI命令直接读写磁盘扇区。如果MCU这边还开着f_writePC那边同步在写文件两边各写各的缓存最后磁盘上就是一堆交叉错乱的扇区文件系统直接报废。我的建议是不要试图做真正意义上的“并发访问”而是做访问模式切换。最简单的方案是加一个物理模式开关或者上位机命令。设备处于“日志模式”时USB外设不启动MCU正常挂载FATFS写数据。用户要倒出数据时先把当前文件f_close再调用f_mount(NULL, , 1)卸载文件系统最后再初始化USB并等待PC枚举。等USB断开后重新f_mount挂载。具体模式切换的动作可以放在主循环里轮询一个GPIO或者USB VBUS电平。如果非要自动检测USB插入可以用一个空闲IO做VBUS检测插上瞬间立刻停止所有文件操作并卸载FATFS。这样依然有极短的竞态窗口但对日志设备来说只要不是每次拔插都卡在写扇区的瞬间实际风险可控。5.3 Windows枚举失败或者提示格式化的排查思路USB枚举失败时先别管FATFS单独测USB MSC是否能作为一个空盘被识别。方法是在STORAGE_GetCapacity_FS里返回一个固定的小容量比如8MB的0x1000扇区并且STORAGE_Read_FS暂时返回全0STORAGE_Write_FS直接返回成功。如果这样能正常识别并弹出“需要格式化”的空盘说明USB协议栈本身没问题问题出在SD卡初始化和容量上报上。如果Windows弹的是“需要格式化”而你在电脑上能正常看到盘符说明USB链路已经通了只是文件系统结构不对。这种情况概率最高的是SD卡本身是FAT16或者exFAT而你的FATFS配置不支持对应格式。最简单的处理是先在电脑上把卡重新格式化为FAT32再用f_mkfs确认MCU这边也能识别然后再插回USB测试。还有一个我实际遇到过的奇怪现象同样的卡在电脑自带的读卡器上一切正常通过STM32模拟的U盘读写几次后就提示“参数错误”。最后排查结果是SPI模式下多扇区写没有正确处理卡忙等待PC发连续写入时中间漏拍导致扇区内容错位。也就是说USB端的“格式化”问题很多时候不是USB的问题而是底层SD读写在多扇区场景下并没有你想的那么稳。6. 实测速度、功耗与稳定性调优整个方案跑通之后真正决定能不能用在产品上的是长期稳定性。我拿一套F103VET6的板子实际测过一组数据给大家做个参考。6.1 不同SPI频率下的实测速度测速条件是SanDisk 32GB TF卡格式化为FAT32FATFS块大小默认SPI1在72MHz主频下使用不同分频系数数据量1MB读用f_read写用f_writeSPI时钟顺序读速度顺序写速度稳定性400kHz约25KB/s约20KB/s稳定仅用于初始化9MHz约680KB/s约360KB/s稳定12MHz约850KB/s约430KB/s稳定18MHz约1.1MB/s约510KB/s大部分卡稳定个别卡初始化后读大文件偶尔超时这个速度不是SD卡本身的上限而是HAL库逐字节TransmitReceive的开销在拖后腿。如果你改成SPI DMA加多扇区传输18MHz下读可以跑到接近1.5MB/s。日志类应用其实用12MHz就够了CPU占用还低。6.2 DMA和缓冲Log写的取舍实际工程里我建议读写函数至少把“等待卡忙”这段放在主循环外处理不要让CPU在18MHz速度下还一路阻塞等待SPI。尤其你的程序还要跑屏幕刷新、Modbus通信、按键扫描时阻塞式写卡会让整机感觉卡顿。我用得比较顺的方案是普通日志用中断方式逐扇区写写完后立刻返回真正的大批量数据导出走USB不经过FATFS。不用DMA的原因很简单HAL的DMA回调加上FATFS的多层缓冲代码复杂度一下子上去了而且DMA和SPI同时跑的时候如果CS控制不严谨DMA传输间隙卡会产生多余电平很难排查。如果你一定要用DMA建议先保证阻塞读写稳定跑通一周再动DMA。6.3 落雷级别的长期稳定性经验说几个我在长期运行后总结出来的土办法每个都是真金白银换来的。第一每写入固定大小数据就f_sync一次。日志设备最怕断电f_sync会把FATFS缓存刷到卡里最多损失最后一段数据不会整个文件系统挂掉。我习惯每1KB或者每1秒钟同步一次。第二打开文件时始终使用FA_OPEN_ALWAYS | FA_WRITE写完后手动定位到文件末尾再追加避免覆盖上一次的记录。第三写卡失败时不要立刻重试到死连续失败3次就把卡标记为错误并切换到RAM缓存等下次上电重新初始化这样可以避免卡在坏扇区上反复摩擦。另外FatFS的FF_FS_RPATH和FF_USE_LFN这两个选项对RAM占用影响很大。中文文件名看着方便但代价是每个文件操作都要额外的堆内存。安装在线的设备文件名尽量用简单的ASCII命名能省下几百字节RAM还能降低崩溃概率。这套方案我后续还会继续在做数据记录仪、环境监测箱这类项目里复用。如果你也打算在日志设备上做同样的组合我个人建议先把SD卡SPI的底层函数在12MHz下彻底跑稳再上USB MSC的映射。底层的单扇区读写只要能反复执行几千次不出错上面的FATFS和U盘功能就是水到渠成的事。
返回列表