ARTICLE DETAIL

资讯详情

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

STM32 SPI模式SD卡+FATFS+USB模拟U盘数据记录仪方案详解

STM32 SPI模式SD卡+FATFS+USB模拟U盘数据记录仪方案详解 这题目一看就是老嵌入式人会做的事MCU端要存数据、要文件系统管理还得随时能被电脑直接读走。把SPI模式的SD卡、FATFS文件系统和USB模拟U盘这三样东西在STM32 HAL库环境下串起来用好了就是一个非常实用的数据记录仪基础平台。很多朋友第一次做这个组合容易卡在各种莫名其妙的地方——SPI模式下SD卡初始化不过、FATFS挂载失败、USB枚举不识别或者三者单独跑都正常、一连起来就互相干扰。这篇笔记我就围绕这个组合把从CubeMX配置、底层驱动移植到U盘功能对接的完整过程写清楚重点讲为什么这么做以及哪些坑必须避开。1. 整体设计思路与方案选型1.1 为什么选择SPI模式而不是SDIO做SD卡方案第一件事就是选通信接口。STM32通常有两个选择原生SDIO接口或者走SPI。SDIO的优势是速度快8位或4位总线并行传输读卡能力可以到几十MB/s适合需要高速连续存储的应用。但代价也很明显引脚占用多4条数据线加时钟命令线CubeMX配置起来稍麻烦而且PCB布线要求高对于很多非专业布线的板子容易出现信号完整性问题。SPI模式则相反。它走的是标准SPI协议任何一颗MCU只要有SPI外设就能跟SD卡通信。速度方面SPI模式单数据线一般实际跑个1~10MHz没问题写文件每秒几百KB到1MB左右。做数据记录、配置存储、日志写入这类应用完全够用。我实际项目里经常选SPI模式最核心的理由有三个CubeMX配置简单、引脚占用少4根线加片选共5根、调试方便。SPI模式可以用逻辑分析仪直接看时序也可以用软件模拟SPI来兜底排查问题的路径比SDIO宽很多。补充一句如果你的设计确实需要高速连续写入比如高清视频流采集那老老实实用SDIO这个方案不在本文范围内。1.2 FATFS的作用裸的SD卡存储是扇区级的要读写数据你得自己管理扇区分配、文件索引数据分散了还容易出错。FATFS就是来解决这件事的——它是个开源文件系统组件专门为嵌入式小型系统设计支持FAT12/FAT16/FAT32/exFAT占用资源少移植简单。有了FATFS你就能在单片机里像在电脑上一样用文件函数f_open建文件、f_write写数据、f_read读数据、f_mkdir建目录。记录的数据直接在电脑上插读卡器就能看到不需要额外写上位机解析工具。FATFS这种方案本身已经非常成熟标准移植只需要你提供6个底层接口函数。它跟HAL库本身没有天上地下的耦合关系只要把底层接口用HAL的SPI函数实现其余部分完全不动这也是这个方案调试起来相对可控的原因。1.3 模拟U盘的扩展价值模拟U盘本质上是STM32内置USB外设运行MSCMass Storage Class类把PC端发来的SCSI命令翻译成对SD卡的扇区读写。这带来的好处非常直观设备通过USB线连电脑电脑上直接出现一个可移动磁盘用户不需要装任何驱动也不需要拆开设备取卡就能直接访问SD卡里的文件。这对产品形态来说是体验上的一个跳跃——从需要读卡器才能取数据变成插上USB就是U盘。一个典型的应用场景设备离线采集数据用户拿回办公室插上USB线所有日志文件直接拖出来分析无比顺畅。再比如设备的配置文件直接在电脑上编辑好通过U盘模式拷进SD卡设备启动时读取。不过要注意模拟U盘不是独立功能它跟FATFS是共享同一个SD卡设备的。这里就涉及到一个很关键的设计问题U盘模式和MCU本地访问不能同时操作文件系统。通常做法是设计一个互斥逻辑——U盘连接时MCU不访问SD卡MCU访问完、释放SD卡后才允许U盘模式激活。2. 硬件连接与CubeMX工程配置2.1 SD卡SPI模式下的硬件接线SD卡在SPI模式下工作在3.3V电平这点极其重要。如果你的MCU是3.3V供电STM32绝大多数是那就直接连接如果板子上有5V部分绝对不要直接把5V逻辑电平接到SD卡上。标准的SPI模式接线SD卡引脚功能连接目标CS片选低有效MCU GPIO输出DI数据输入主机-卡MCU SPI MOSIDO数据输出卡-主机MCU SPI MISOSCLK时钟MCU SPI SCKVDD3.3V电源3.3V供电VSS地公共地TF卡与SD卡引脚定义略有不同但SPI模式下信号顺序一致只需注意封装差异即可。这里有个容易忽略的细节SPI模式下SD卡的DIMOSI和SCLK需要接上拉电阻一般10kΩ左右保证默认状态是确定的高电平。CS也是一样建议加上拉。很多模块电路板上已经集成这些电阻如果是裸卡座一定要补上否则初始化阶段极容易莫名其妙失败。另一个细节是电源去耦SD卡在擦写瞬间电流峰值可能到几十毫安用万用表看不出瞬时压降但用示波器能看到VDD上有毛刺。在卡座电源引脚旁边放一个10μF电解电容加一个0.1μF陶瓷电容稳定性会好很多。我踩过一次坑就是因为供电纹波太大导致大文件写入时偶发中断。2.2 CubeMX配置SPI外设用STM32CubeMX配置SPI时核心参数如下ModeTransmit Only Master或者Full-Duplex Master都行我做的是Full-Duplex方便读响应Hardware NSS SignalDisable片选用普通GPIO软件控制灵活性更高Clock Polarity (CPOL)HighClock Phase (CPHA)2 Edge数据传输顺序MSB First预分频器先设大分频比如/64得到约1MHz以下初始化完成后再切高速为什么CPOLHigh、CPHA2 Edge这是SD卡SPI模式的既定要求SD卡协议文档里明确规定在SPI模式下时钟空闲时是高电平数据在第二个沿采样。很多人初始化不通一查发现是CubeMX默认SPI模式跟SD卡要求不一致。时钟频率的选择也要讲究。SD卡上电初始化阶段要求SPI时钟不能超过400kHz。真正常见的做法是CubeMX里把SPI时钟配置成低速分频大然后在SD卡初始化完成后程序里再动态修改SPI预分频器把时钟提上去。读CSD、写单块、读单块这些命令都可以在高速时钟下跑但ACMD41初始化循环必须在低速下进行。用STM32F103的典型时钟树举例PCLK272MHzSPI1挂在APB2上。你初始化阶段分频到/256SCK大约281kHz安全符合规范。初始化完成后重新设置分频到/8SCK9MHz读写速度就有了保障。F103的SPI最高36MHz但实际上SD卡在SPI模式通常能跑到20~25MHz。我通常设到9~12MHz比较保守稳定性优先。2.3 CubeMX配置USB和FATFS组件USB部分在CubeMX里选择USB_DEVICE然后选MSCMass Storage Class。这里有几个关键配置USB时钟来源必须选择PLL的48MHz输出或者带内部专用PLL的型号这是USB协议要求的配置错的话枚举会失败或者不稳定MSC类参数一般默认即可但如果你的系统内存紧张注意调整缓冲区大小不要贪大FATFS组件在CubeMX里可以直接启用Middleware and Software Packs - FATFS。这里有个人经验要分享CubeMX自动生成的FATFS代码封装了底层接口但它默认用的是USER驱动模式你需要在user_diskio.c里实现对应的SPI读写函数。CubeMX生成代码的好处是省去很多宏定义和基础结构的搭建坏处是它帮你包了一层出了问题不好溯源。我平时喜欢用CubeMX生成工程框架但FATFS的diskio接口会自己重写一遍确保对底层行为完全可控。3. 关键代码实现与移植细节3.1 SD卡SPI模式初始化序列实操这段代码是整个方案中一旦出错最容易让人崩溃的部分。SD卡上电后状态机和初始化时序有严格步骤很多人上来直接发CMD1或者ACMD41死活过不去就是因为顺序不对或者时钟太快。标准的初始化流程// 1. 上电延时 至少74个SPI时钟 HAL_Delay(10); for (int i 0; i 10; i) { HAL_SPI_Transmit(hspi1, (uint8_t[]){0xFF}, 1, 100); } // 2. 进入SPI模式发送CMD0 // 只有卡处于SPI模式后才能使用SPI命令集 uint8_t cmd0[] {0x40, 0x00, 0x00, 0x00, 0x00, 0x95}; // 发送CMD0正确的响应是0x01表示进入Idle状态 // 3. 发送CMD8检查卡是否支持SDV2 // 响应0x01表示支持SDV20x05表示SDV1/MMC注意区分 // 4. 循环发送CMD55 ACMD41 // 注意ACMD41不是独立命令必须先发CMD55表示接下来是APP命令 // 响应中的busy位会从0x01慢慢变成0x00表示初始化完成 uint32_t timeout 10000; do { send_cmd(CMD55, 0); // 0x77 对应 CMD55 send_cmd(ACMD41, 0x40000000); // 0x69 对应 ACMD41HCS1 HAL_Delay(1); } while (response ! 0x00 timeout-- 0);这里有个非常关键的细节每次发送命令之前记得发送几个0xFF字节因为SPI是全双工模式发送的同时也在接收需要这些空字节来给卡足够的处理时间并获取响应数据。CubeMX生成的HAL_SPI_Transmit只是发送你可能感受不到但在判断响应时一定是在发送命令后再发送一个0xFF然后在同一个SPI事务里把返回数据读出来。我当时卡了很久的地方就是在这里读到的响应错位了一位或者两位实际上就是因为缺少一个dummy clock。初始化完成后最好把CSD寄存器的内容读出来解析出卡容量和块大小信息然后设置FATFS的扇区大小。大多数SD卡块大小是512字节这个可以直接写死但如果你用的是老卡或者特殊卡解析CSD是更稳妥的做法。读取CSD的代码不复杂uint8_t csd[16]; uint8_t cmd9[] {0x49, 0x00, 0x00, 0x00, 0x00, 0x00}; // CMD9 // 发送后读取16字节CSD数据最后还有2字节CRC3.2 FATFS的diskio接口移植必须手写一遍CubeMX自动生成的user_diskio.c里真正需要你动手的只有四个函数disk_initialize、disk_read、disk_write、disk_ioctl。其余的状态查询、时间戳可以简单实现。DRESULT disk_read(BYTE pdrv, BYTE* buff, LBA_t sector, UINT count) { // 发送CMD17读单块CMD18读多块 for (UINT i 0; i count; i) { send_cmd(CMD17, (sector i) 9); // 扇区号 - 字节地址 // 读取数据令牌 0xFE然后读取512字节 // 最后2字节CRC可忽略 read_data_token(buff i * 512); } return RES_OK; }disk_write则对应CMD24。这里有一个非常容易踩到的坑FATFS在写数据时有时候写入的地址不是扇区对齐的如果你的底层不支持部分写就必须先读出原始扇区修改后再整体写回。多数SD卡在SPI模式下支持单块写CMD24和多块写CMD25它们对地址对齐的要求不同。我个人的做法是直接用CMD24单块写FATFS层面有512字节扇区缓冲本身会处理对齐问题如果在disk_write里发现接收到的地址不是512的整数倍我再做读改写。实际中这种情况极少但加了这段逻辑后稳定性会显著提升。disk_ioctl实现的关键命令包括GET_SECTOR_COUNT返回总扇区数解析CSD得到的GET_SECTOR_SIZE返回512CTRL_SYNC等待SPI空闲确保写完GET_BLOCK_SIZE返回1表示每个块1个扇区FATFS擦除单位有个细节当SPI时钟从低速切换到高速后你的disk_initialize里再次访问SD卡时不需要重新执行CMD0~ACMD41的初始化序列因为卡已经处于SPI模式且完成了上电初始化。但每次重新上电后必须重新初始化。我在做低功耗休眠唤场景时就踩过这种坑唤醒后直接发CMD17读扇区结果是超时因为SD卡跟着系统一起掉电重新上电后进入了MMC模式必须重新做初始化流程。3.3 USB MSC对接SD卡的实现思路模拟U盘在STM32上的标准实现路径是USB设备枚举为MSC设备PC端通过SCSI命令INQUIRY、READ CAPACITY、READ10、WRITE10等访问存储介质。STM32的USB库已经帮我们把SCSI协议栈实现了我们需要做的只是提供底层存储接口。在CubeMX生成的代码里这个接口是usbd_storage_if.c里面有这么几个关键函数int8_t STORAGE_Init(uint8_t lun); int8_t STORAGE_GetCapacity(uint8_t lun, uint32_t* block_num, uint16_t* block_size); int8_t STORAGE_Read(uint8_t lun, uint8_t* buf, uint32_t blk_addr, uint16_t blk_len); int8_t STORAGE_Write(uint8_t lun, uint8_t* buf, uint32_t blk_addr, uint16_t blk_len);这些函数的实现完全可以复用刚才移植的SD卡底层代码。注意以下几点block_size固定返回512STORAGE_Read和STORAGE_Write的blk_len参数表示几个块要循环调SD卡的读写函数或者用CMD18/CMD25多块命令每次读写后要检查SD卡返回状态失败时返回错误码否则PC端会报I/O错误还有一个比较棘手的问题USB MSC和单片机本地文件系统共用SD卡如何避免冲突我的做法是加一个存储所有权标志volatile uint8_t storage_owner OWNER_MCU; // 或 OWNER_USB // USB枚举激活回调 void USB_MSC_Activate(void) { storage_owner OWNER_USB; f_mount(NULL, , 0); // 卸载FATFS } // USB断开回调 void USB_MSC_Deactivate(void) { storage_owner OWNER_MCU; f_mount(SDFatFS, , 1); // 重新挂载 }这个逻辑的优先级要理清楚当U盘模式连接时USB回调已经卸载FATFSMCU侧的任务无论如何不能访问SD卡。反之MCU占用SD卡时不能允许USB枚举为可用的MSC设备要么返回STORAGE_BUSY错误要么干脆不让USB枚举成功。实际工程中很多产品还会加一个机械开关或者GPIO来切换模式拨到U盘档位时MCU完全释放SD卡USB硬件使能拨到运行档位时USB禁用MCU用FATFS自由读写。这种物理隔离的做法虽然朴素但在稳定性上是最省心的。我后来做的几款数据记录仪都是这种设计——上位机软件通过USB发命令让设备切换模式本质上也是所有权标志的远程版本不是物理开关但逻辑相同。4. 常见问题与排查技巧实录4.1 初始化卡死在ACMD41循环这是最典型的问题几乎每个做SPI SD卡的人都会遇到。现象是程序卡在do...while循环里一直出不来或者等超时后报错。我的排查步骤固定如下确认上电顺序和延时足够。SD卡上电后需要至少1ms的稳定时间然后发74个以上SPI时钟周期。很多人上电马上初始化就容易失败。我通常延时10ms起步宁可慢一点。确认时钟速度不大于400kHz。初始化阶段用低速这点不能商量。如果你上来就是MHz级卡根本不会理你。检查响应数据是否错位。CMD0的响应应该是0x01但很多人读到的是0x41或者0xC1。这是SPI模式下最常见的错位现象原因就是发送命令时发送时钟和数据采样时序不对齐。检查CPOL和CPHA配置严格的CPOLHigh、CPHA2 Edge一般能解决。检查卡是否真的支持SPI协议。有些劣质扩容卡或特殊卡SPI模式支持不完整。换一张电脑上能正常用的品牌卡如果还是初始化失败那就不是卡的问题是你时序或者电平的问题。每次处理好这几个环节我都能把这个问题定位到具体某个原因。实际上90%的ACMD41卡死最后都是SPI时序参数配置错误。4.2 FATFS挂载成功但文件读写异常挂载f_mount能成功说明底层的初始化、读扇区都是正常的但读写文件时出问题比如f_open返回FR_NOT_READY或者FR_NO_FILESYSTEM检查disk_ioctl的GET_SECTOR_COUNT返回值是否正确。如果你返回的扇区数比实际大FATFS会认为SD卡上有个巨大的分区读取文件系统信息时就会出错。文件能创建但写几KB后报错FR_INT_ERR多半是写扇区时出错或者SPI速率太高导致数据不稳。尝试降低SPI时钟比如从18MHz降到9MHz。很多时候SPI在低速下读写一切正常高速下偶尔出错这是电平上升沿裕量不足的表现不是软件逻辑问题。文件系统被破坏如果你之前的代码断电时没有正确关闭文件f_close或者没有执行f_mount后马上写FATFS缓冲区里的数据没刷回SD卡下次挂载时容易出现目录损坏。开发阶段常用的修复办法是插到电脑上让Windows或者chkdsk修复但正式产品里一定要在关键位置上做好掉电保护。还有一点经验FATFS提供了f_printf、f_gets等高级接口但底层写缓冲默认是512字节如果你频繁写入少量数据比如每秒写一行日志性能会非常差。我一般会定义FATFS的配置宏FF_FS_TINY开启tiny模式减少内存占用同时在应用层用一个大缓冲区攒够一定数据量再一次性写入只有调用flush时才真正落盘。这不仅是性能考虑也是延长SD卡寿命的手段——频繁小幅写对Flash磨损更严重。4.3 USB枚举成功但电脑提示需要格式化U盘模式连电脑能看到盘符但双击打开提示需要格式化这种问题绝大部分出在SD卡上没有有效的FAT文件系统。原因一般是SD卡之前是MCU用FATFS初始化并格式化过但格式化的类型和参数与Windows的兼容性不够好。或者SD卡分区表MBR和FAT引导扇区没写对。排查思路分两步先确认SD卡单独插读卡器在电脑上是否正常。如果不正常说明卡本身文件系统有问题。你可以先用读卡器在电脑上格式化一遍选FAT32再插回MCU验证是否能正常读写文件。如果单独在电脑上正常但在MCUUSB模式下提示格式化那就是你的STORAGE_GetCapacity返回的参数跟实际SD卡参数不一致。Windows会先READ CAPACITY获取扇区总数和扇区大小然后用这个参数去解析文件系统。如果你返回的容量小于FATFS格式化时的分区大小Windows就解析不了。务必确保float32换算没有溢出。我说个具体的场景一张1GB SD卡格式化后实际可用扇区数可能只有1953456个扇区不是2000000。如果你在GET_SECTOR_COUNT里返回2000000Windows一看FAT表和根目录区域越界就直接判断为未格式化。解决方式就是严格按照CSD寄存器里的C_SIZE参数计算或者直接从FATFS的get_fattime和disk_ioctl接口顺着查不能用拍脑袋的整数。4.4 多文件连续写速度上不去还有一类问题是速度读文件很快写文件慢得像蜗牛。排除SPI时钟因素后最普遍的瓶颈在FATFS的分配策略上。FATFS默认是顺序分配簇但如果你的文件不断删除再新建FAT表里的空闲簇变得碎片化写文件时频繁跨簇查找速度立刻下降。针对这种场景我建议尽量在采集数据前预先分配一个固定大小的文件用f_lseek把文件指针移到最后再写入这样FATFS会连续分配簇如果数据量固定比如一条日志64字节一天86400条可以考虑直接用固定大小的大文件记录位置指针写入头部作为索引这样既有顺序写的优势又方便PC端解析定期对SD卡做碎片整理——最方便的做法就是通过USB U盘模式连电脑把文件拷贝出来格式化后再拷回去尤其要注意f_write是带缓存的写完记得f_sync否则缓冲区里的数据可能还停留在内存中直接掉电就丢了。f_close本身会sync但你真的不必等到关闭才落盘更多的做法是在关键节点主动fflush。4.5 电源波动引起的偶发读写失败这个比较隐蔽你单测时一切正常但在电机、继电器启动的瞬间偶尔出现一次读写失败。用示波器一抓VDD在电机启动瞬间被拉低了200~300mV超出了SD卡的工作电压范围。处理方式SD卡电源用单独的LDO供电不要跟电机驱动器共用一个电源轨SD卡VDD旁路电容加大我用的是100μF电解再加0.1μF陶瓷SPI信号线上串联22Ω的小电阻可以抑制振铃减少信号质量问题导致的偶发错误在低电压检测中断里加保护VDD低于阈值时禁止对SD卡写入防止写到一半掉电导致文件系统损坏5. 从开发到产出的几个关键建议5.1 做好调试阶段的可视化支撑调试这个组合功能时我强烈建议提前在板子上预留一个USB转串口或者SWO输出口。程序里加几个宏开关打开后可以把SD卡初始化过程中的关键状态码、FATFS返回值、USB枚举状态实时打印出来。这看着不起眼实际排查问题时效率能翻好几倍。比如f_mount返回FR_NO_FILESYSTEM你串口一眼就能看到不用反复猜测到底是底层读错了还是文件系统本身坏了。调试阶段可以做一个忙时灯SPI读写SD卡时把GPIO拉高空闲拉低。用示波器看这个GPIO的占空比就能直观判断系统是在高速读写还是频繁等待——很多时候性能瓶颈一眼就能看穿。5.2 掉电保护与文件系统健壮性设计产品形态如果允许用户随时拔电掉电保护这块必须做好。经验上最有效的三个手段关键写操作后立即f_sync把FAT表和目录项刷到SD卡在SD卡硬件上加一个大电容提供掉电后的缓冲时间让MCU有机会把最后一批数据写完并关闭文件SD卡写入期间检测到掉电事件时立即停止写入宁可丢最后几条数据也不要去动FAT表结构实践中我会结合方案1和2正常写数据时不做频繁sync保证速度当检测到掉电信号进入紧急中断后把最后一段数据写进一个固定的紧急存储区然后马上sync一次。5.3 U盘模式与MCU访问的互锁实现回到开头的互斥问题我的最终建议是做一个带超时的所有权切换机制MCU试图访问SD卡时如果发现当前所有者是USB就等待一段时间超时则报错USB激活时的回调里设置一个忙碌标志如果MCU正在写文件USB枚举不响应或者直接拒绝激活。代码结构上可以用一个简单的状态机typedef enum { STORAGE_FREE, STORAGE_OWNED_MCU, STORAGE_OWNED_USB } storage_state_t; // 请求获取存储所有权 storage_err_t storage_acquire(storage_state_t owner, uint32_t timeout_ms); // 释放存储所有权 void storage_release(storage_state_t owner);所有应用层读写文件之前先acquire完成后release。这套机制看起来简单但它能帮你挡住所有并发访问的坑尤其是你在上了RTOS之后多个任务同时调用FATFS函数如果没有这个锁文件系统损坏只是时间问题。写在后面这套SPI模式SD卡FATFSUSB模拟U盘的组合我前前后后在好几款数据记录仪和配置管理设备上跑过整体方案稳定性和可维护性都不错。SPI模式牺牲了一点传输速度换来了开发效率、稳定性和引脚资源上的巨大优势。最后再分享一个小技巧如果调试时遇到SD卡能被电脑识别但容量显示为0先别急着怀疑代码把SD卡放读卡器里在电脑上重新格式化一次FAT32格式很多时候是SD卡在MCU初始化时被写坏了引导扇区重新格式化能解决90%的问题。如果重新格式化后依然显示0再去查你的GET_SECTOR_COUNT返回值和底层读扇区有没有溢出。这套方案后续还可以扩展的方向不少比如通过SPI DMA提高读写吞吐量、加入TF卡热插拔检测CD引脚、把FATFS换成LittleFS以获得更强的掉电稳定性这些都是在你跑通基础组合之后可以考虑的优化项。先把基础版本跑稳再去谈优化这是我一贯的做法。
返回列表