1. 从“点灯顺利”到“SD卡写崩”的落差
STM32H743这颗芯片,性能拉满的时候是真的猛,主频480MHz,带FPU和DP-FPU,跑起来比不少Linux级别应用的单片机都利索。但正是这颗芯片,让我在SD卡读写这件事上栽了差不多一个礼拜的跟头。如果你也以为“不就是SD卡嘛,F103上都跑过FATFS,H743照抄总行吧”,那我劝你冷静一下。
先说结论:STM32H743的SDMMC外设,配合CubeMX生成的代码,走MDMA搬运数据再挂上FATFS,这套组合在配置上不算难,真正难的是那些“配置了也不报错、跑起来就出鬼”的隐性坑。我这次把整个过程从头到尾复现了一遍,从CubeMX的引脚配置、时钟树设置、MDMA通道选择,到FatFs的底层适配,再到实际跑读写性能测试,每一步都记录下来了。这篇文章主要写给打算在H7系列上做数据记录、音频采集、大容量存储这类项目的朋友,尤其是刚从F1/F4平台迁过来的人,看完应该能帮你少走至少三天的弯路。
这次用的硬件平台是一块自制的H743核心板,SD卡座是标准的microSD push-push型,供电用3.3V LDO,信号线上拉了上拉电阻,SD卡用的是一张闪迪的16GB Class 10卡。软件环境是STM32CubeMX 6.10 + IAR 9.30,HAL库版本是1.11.1。下面直接进入正题。
2. CubeMX配置阶段最容易埋雷的几个地方
2.1 引脚分配:别让PCardDetect占错位置
CubeMX里配置SDMMC1,常规做法是把SDMMC1的4位数据线、时钟线和命令线都分配给固定引脚,然后勾选SD Card Detect引脚,用来检测SD卡是否存在。这一步看着简单,但很多人第一步就埋了雷。
H743的SDMMC1引脚映射,CK是PC12,CMD是PD2,数据线是PC8到PC11,这几个是硬件固定的,没得选。卡检测引脚就灵活了,CubeMX默认会给你分配一个PC6或者其他任意可用引脚,但这里有个问题——你选的这个引脚,必须能容忍3.3V电平,并且实际板子上这个引脚确实连到了卡座的CD开关上。很多人图省事,直接用了CubeMX自动分配的引脚,结果板子上根本没引这个信号出来,或者引到了5V容忍引脚上但没做电平匹配,最终就是系统检测不到卡。
我这次在原理图阶段就把PC6分配给了SD卡座的CD引脚,CubeMX里在GPIO设置中把它配置为输入模式,并使能内部上拉。为什么一定要内部上拉?因为microSD卡座的CD开关在插入卡时通常是闭合到地的,不插卡时悬空,内部上拉可以保证逻辑电平稳定。如果你用的卡座是常开型的,那就需要下拉,这个务必对照你自己板子的原理图确认,不要想当然。
2.2 时钟树:H7的SDMMC时钟不是你想的那么简单
H743的SDMMC时钟源有PLL1Q、PLL2P、PLL3R、SYSCLK等多种选择,CubeMX默认通常会选PLL1Q。这里我要重点提醒:SDMMC的最高工作时钟,在H743上有一个硬性上限,就是52MHz,但实际上有很多卡在超过48MHz之后就工作不稳定了。
很多从F4平台过来的人,习惯性把SDMMC时钟拉满。F4时代,SDIO的时钟是48MHz,大家都这么干,没问题。H743虽然标称最高支持52MHz,但实测下来Class 10的卡在50MHz时就有概率出现CRC错误,48MHz反而稳定。CubeMX的时钟树配置里,SDMMC1的时钟来源于PLL1Q,默认频率往往是120MHz左右,需要你在CubeMX的Clock Configuration页面里手动调整分频系数,让SDMMC1的时钟落在48MHz附近。
操作路径是:Clock Configuration页面 -> 找到SDMMC1的时钟树分支 -> 调整PLL1Q的分频器。比如PLL1Q输出120MHz,那么SDMMC1的时钟分频就设置成2.5(对应实际48MHz)。注意,CubeMX里的分频值是可以带小数的,因为SDMMC外设内部还有一个分频器可以和总线分频协同工作。配置完成后,编译生成的代码里会有一个类似SDMMC_ClkCard之类的计算函数,它会根据频率配置自动计算分频参数。
不要忽视这一步,我之前图省事,直接用了CubeMX的默认时钟配置,结果SDMMC1的时钟是60MHz,读写时偶发卡死,排查了整整一天才发现是超频导致的。
2.3 MDMA配置:绕过CPU才是H7的精髓
STM32H743的MDMA算是一个特色外设,它可以实现内存到内存、内存到外设的高效数据搬运,关键是搬运过程不占用CPU核心。对于SD卡这种需要大量数据搬移的场景,如果不用MDMA,直接用CPU读取SDMMC的数据FIFO,CPU占用率会飙升到难以接受,而且SDMMC的FIFO是32字节深度的,数据量大时CPU根本忙不过来。
CubeMX里配置MDMA,需要在MDMA页面添加一个通道,请求源选择SDMMC1_RX或SDMMC1_TX,方向根据数据流选择外设到内存(读)或内存到外设(写)。这里有个细节容易踩坑:MDMA的通道选择不是随便选的,H743的MDMA请求映射是固定的,SDMMC1_RX对应MDMA通道0,SDMMC1_TX对应MDMA通道1。
配置MDMA的Buffer Size时,要和SDMMC的数据长度一致。在实际使用中,FATFS读写的扇区大小通常是512字节,也就是一次读取的块大小是512字节,那么MDMA的Buffer Size就设置成512,Block Size设置为4字节(对应32位总线宽度),Block Count设置成128(512/4=128)。这个计算逻辑不复杂,但很多人容易把Block Size和Block Count搞反。
优先级方面,MDMA通道的优先级建议设置为Very High。为什么?因为SDMMC在接收数据时,如果FIFO满了而MDMA没有及时搬运,SDMMC就会触发OVERRUN错误。优先级设低了,一旦遇到中断风暴或者总线竞争,数据就容易丢。
2.4 FATFS的选择:这个真不是选个盘符那么随意
CubeMX的Middleware列表里可以勾选FATFS,但注意它有两种模式:一种是只挂载读写的通用模式,一种是带编码转换的LFN(长文件名)模式。如果只是做数据记录,文件名用8.3格式就够了,LFN模式反而会增加代码量和处理复杂度。但如果你要支持中文文件名或者长文件名,就必须开启LFN,并且需要额外移植cc936.c或cc932.c这类编码表文件。
CubeMX默认生成的FATFS配置,会在user_diskio.c文件里实现底层接口,包括disk_status、disk_initialize、disk_read、disk_write、disk_ioctl等函数。这些函数的实现基于HAL库的SDMMC驱动,大部分情况是开箱即用的。但有一个坑:disk_ioctl函数里有几个case,比如GET_SECTOR_COUNT和GET_BLOCK_SIZE。H7的HAL库在HAL_SD_GetCardInfo函数中已经提供了卡信息,但CubeMX生成的代码里,GET_SECTOR_COUNT的返回值需要手动改成pDrive->sector_count之类的内容,默认模板可能给的是固定的512字节或者干脆是0。
此外,还有个细节:H743的SDMMC底层驱动里,HAL_SD_ReadBlocks和HAL_SD_WriteBlocks在默认情况下是阻塞模式的,即使你在CubeMX里开了MDMA,生成的代码大概率还是调用阻塞模式的函数。这时候就需要手动修改user_diskio.c里的读写函数,把阻塞调用替换成HAL_SD_ReadBlocks_DMA或HAL_SD_WriteBlocks_DMA,并且要配合信号量或者事件标志来确保DMA传输完成。这个过程涉及同步机制,处理不好就会出现读写返回成功但数据没写完的问题。
3. 那些官方文档没细说的核心机制
3.1 SDMMC的数据通路:从SD卡到内存,中间经过了多少个环节
要理解后面的坑,得先弄明白H743上SD卡数据是怎么流动的。以读SD卡为例,数据从SD卡的存储单元出发,通过SDMMC总线的数据线进入SDMMC外设的接收FIFO,FIFO里的数据再通过总线传输到内存。
但这里有一个关键点:SDMMC外设本身有两个DMA接口,一个是传统的DMA1/DMA2,另一个是MDMA。CubeMX默认生成的代码里用的是DMA请求映射到MDMA,但如果你在MDMA配置里没有正确关联到SDMMC外设,实际搬数据的时候就会出现“第一个扇区读对了,第二个扇区数据错位”的诡异现象。
H7的SDMMC在DMA传输模式下,支持缓冲区的自动换行操作,也就是说,你可以配置MDMA在缓冲区到达末尾后自动回卷到起始地址,这样SDMMC就可以持续不断地向内存中写入数据,不需要CPU干预缓冲区切换。这项功能对于连续多扇区读写非常有意义,但前提是MDMA的配置要精准匹配SDMMC的FIFO深度。
SDMMC1的FIFO深度是32字,也就是128字节,但MDMA单次传输的最大burst长度可以配置为1、2、4、8、16、32、64、128等。这里最容易出的问题,就是MDMA的burst长度配置得过大,比如配置成64或128,而SDMMC每产生一次DMA请求只填充少量数据到FIFO,结果MDMA一搬就是一大块,数据就对不齐了。我在这次调试中把MDMA的burst长度设为4(对应一次搬运4个32位字),就很稳定。
3.2 为什么H7上SD卡中断必须配优先级,不配就死给你看
FATFS在读写SD卡时,底层disk_read函数是同步等待的,但MDMA传输是异步的。这就需要一个机制来通知CPU“数据搬运完成了”。CubeMX生成的代码里,SDMMC的中断处理函数SDMMC1_IRQHandler会调用HAL库的回调函数,其中HAL_SD_RxCpltCallback就是数据接收完成回调。
问题在于,这个回调函数的执行上下文是中断,如果你的NVIC里SDMMC中断优先级配得比MDMA低,或者和某些系统节拍中断优先级冲突,就可能出现回调一直不触发的情况。更常见的麻烦是:FATFS的f_read函数在等待数据时,如果底层用信号量做同步,信号量等待的超时时间设置短了,传输稍微慢一点就可能出现“读超时”。
一种稳妥的方案是把SDMMC中断优先级设置为最低(数值最大),同时确保任何时刻只有一个SDMMC传输在进行。实测在IAR环境里,把SDMMC中断优先级设为5(0-15范围),MDMA完成中断(如果有)设为7,这样既不干扰系统节拍,也不会被其他外设中断打断。
另外,官方文档里Cortex-M7内核的中断有一个坑:中断里不要调用任何阻塞函数,比如HAL_SD_ReadBlocks的同步版本。一旦你在中断回调里调了阻塞函数,轻则死等,重则HardFault。CubeMX生成的模板代码默认没这个问题,但如果你自己在回调里添加了处理逻辑,就要小心。
3.3 FATFS移植时容易忽略的“磁盘状态机”
FATFS本身是一个文件系统层,它对底层磁盘的接口抽象很好,但底层接口的实现质量高低,直接决定文件系统是否稳定。我这次在H743上遇到的最诡异的问题是这样的:系统启动后第一次f_mount挂载SD卡,返回成功,但紧接着f_open创建文件就失败,错误码是FR_NOT_READY。检查了卡、检查了接线,最后发现是disk_status函数返回的状态一直不对。
FATFS在操作前会调用disk_status查询磁盘状态,如果返回值里有STA_NOINIT标志,FATFS会认为磁盘没有初始化好,拒绝后续操作。在CubeMX生成的disk_status函数里,默认调用HAL_SD_GetCardStatus来获取状态,但这个函数在H743上有一个特性:如果SDMMC外设没有初始化,或者卡没有正确识别,它会一直返回超时。问题出在disk_initialize函数里,如果初始化失败但你不去处理状态标志,FATFS就会陷入“初始化失败->状态未就绪->拒绝操作”的死循环。
我在代码里加了一个全局变量SD_CardInit_Success,在disk_initialize里判断HAL_SD_Init和后续的卡初始化步骤是否完整走完,只有当所有步骤都成功后才把状态清除。这样FATFS就不会因为状态标志混乱而拒绝操作。这个方法虽然土,但放在这批坑里,反而是最实用的一条经验。
4. 实操:从CubeMX工程到跑通读写全流程
4.1 完整配置清单和生成代码前的确认
把上面的要点串起来,我现在写一份可以直接照着配的参数清单。打开CubeMX,建立一个新的STM32H743VIT6工程:
- 引脚配置:PC12(CLK)、PD2(CMD)、PC8~PC11(DATA0-DATA3)、PC6(CD),全部设为SDMMC1功能。PC12、PD2和PC8~PC11在CubeMX的芯片视图里直接点击,从下拉列表里选SDMMC1即可,CD引脚在SDMMC1的Card Detect选项里使能后手动指定。
- 时钟配置:外部HSE 25MHz,SYSCLK跑到480MHz,PLL1Q配置为120MHz,SDMMC1的时钟分频设为2.5。确认Clock Configuration页面里SDMMC1旁边的数字显示为48MHz。
- SDMMC参数:勾选SD卡支持,位宽选4 bits,时钟分频选择Bypass(对应50MHz以下),传输模式配置为DMA或者由代码动态切换。我这边最终选择使用DMA模式。
- 中断配置:SDMMC1全局中断使能,优先级设为5。同时使能MDMA的全局中断(不用也没关系,但建议留着备用,方便调试)。
- FATFS配置:勾选FATFS,模式选为Generic,TCHAR类型设置为char,LFN使能选择Disabled(如果你用不到长文件名),最大扇区保持512。
- MDMA配置:添加两个通道,RX请求源选SDMMC1_RX,TX请求源选SDMMC1_TX,Buffer Size设置为512,Block Size设为4字节,Block Count设为128,优先级设为Very High。
配置完这些,点击生成代码。工程生成后,打开user_diskio.c,先把GET_BLOCK_SIZE的返回值改成pdrv->block_size(实际上这个值在disk_initialize里从卡信息中获取,默认是512)。然后把disk_read和disk_write函数体里的HAL_SD_ReadBlocks和HAL_SD_WriteBlocks替换成DMA版本,并做好同步等待。
4.2 底层读写函数改写的关键代码解析
用DMA版本替换阻塞版本的核心代码如下(这是工程里最关键的一段改造):
void disk_read_sync(void) { while (SD_read_semaphore == 0) { // 等待DMA传输完成信号量 } SD_read_semaphore = 0; }在disk_read里:
if (HAL_SD_ReadBlocks_DMA(&hsd1, buff, sector, count) == HAL_OK) { while (SD_read_semaphore == 0) { // 等待 } SD_read_semaphore = 0; }然后在HAL_SD_RxCpltCallback回调里:
void HAL_SD_RxCpltCallback(SD_HandleTypeDef *hsd) { if (hsd->Instance == SDMMC1) { SD_read_semaphore = 1; } }这里的SD_read_semaphore只是在裸机环境下用一个volatile变量模拟信号量。如果在FreeRTOS环境里,建议换成二值信号量或者事件标志组,效果更好。另外注意,HAL_SD_ReadBlocks_DMA函数的第三个参数是扇区地址,不是字节地址,FATFS传入的sector参数本身已经是扇区号,所以直接透传即可。
disk_write的改写逻辑和读一样,只是把HAL_SD_ReadBlocks_DMA换成HAL_SD_WriteBlocks_DMA,回调换成HAL_SD_TxCpltCallback。
4.3 实际读写测试:数据到底能不能落盘
配置改写完成后,我写了一个测试程序,逻辑很简单:
FRESULT res; FATFS fs; FIL fil; UINT bytes_written, bytes_read; uint8_t write_buf[512]; uint8_t read_buf[512]; res = f_mount(&fs, "", 1); // 挂载SD卡 res = f_open(&fil, "test.bin", FA_CREATE_ALWAYS | FA_WRITE); for (int i = 0; i < 100; i++) { memset(write_buf, i, 512); f_write(&fil, write_buf, 512, &bytes_written); } f_close(&fil); res = f_open(&fil, "test.bin", FA_OPEN_EXISTING | FA_READ); f_lseek(&fil, 0); for (int i = 0; i < 100; i++) { f_read(&fil, read_buf, 512, &bytes_read); if (read_buf[0] != i) { // 数据校验失败 } } f_close(&fil);用这个程序跑了两轮测试。第一轮,写入100个扇区(50KB),校验全部通过。写入速度大约340KB/s,读取速度约450KB/s。第二轮,改成写入1024个扇区(512KB),校验同样全部通过。到这里,MDMA+FATFS这套方案就算是跑通了。
从性能角度讲,用MDMA相比直接CPU轮询读数据,CPU占用率下降非常明显。在跑写512KB数据的测试中,用逻辑分析仪抓SDMMC的数据线信号,MDMA模式下,CPU几乎只负责文件系统层的计算,数据搬运完全交给了DMA通道。
4.4 实测中遇到的性能瓶颈:不仅靠DMA就万事大吉
上面说了这个方案跑通后的效果,但你别以为这样就结束了。我在性能调优阶段还遇到了一个“看似正常但实际很慢”的问题:初始化挂载耗时特别长,每次上电要卡2秒多才能进入文件操作。排查后发现是FATFS的挂载过程中,内部会先读取SD卡的分区表。如果SD卡的MBR和引导扇区有大量填充数据,读取次数会非常多。
后来我在disk_ioctl的GET_SECTOR_COUNT实现对大数据块处理做了优化,同时在挂载前主动调用一次disk_initialize,并先读取几遍SD卡状态以唤醒卡片。这个做法减少了后续FATFS初始化时的重复握手时间。执行完这些小改动后,从上电到成功f_open的耗时从2秒多降到了600毫秒左右,体感明显好很多。
5. 常见问题速查表与避坑清单
5.1 我踩过的7个典型问题
| 现象 | 根因 | 解决方案 |
|---|---|---|
| FATFS返回FR_NOT_READY | disk_status里状态标志未清除 | 在disk_initialize末尾清除STA_NOINIT |
| 读写偶尔CRC错误 | SDMMC时钟超频或信号质量差 | 将SDMMC时钟降到48MHz以下;检查上拉电阻(建议10k) |
| 第一个扇区正常,后面数据错位 | MDMA burst长度配置过大 | 将MDMA burst长度设为4,block size设为4字节 |
| DMA传输后数据未真正落盘 | 写DMA完成回调未正确等待 | 使用信号量同步写完成,或调用HAL_SD_GetCardState确认 |
| f_mount卡死 | SD卡初始化握手超时 | 在初始化前增加几次SD卡供电唤醒循环 |
| 热插拔读卡失败 | Card Detect引脚电平不稳 | 确认CD引脚与卡座硬件连接,使能内部上拉,代码加消抖逻辑 |
| 写速度低于预期 | FATFS每次写一个扇区512字节,效率低 | 连续写大块数据,或调整FATFS的簇大小和缓冲区大小 |
5.2 一个被很多人忽略的写入坑:关闭写保护检测
CubeMX生成的FATFS代码里,默认会调用disk_ioctl的GET_WRITE_PROTECT和GET_DRIVE_NUM等case。如果GET_WRITE_PROTECT返回了错误值,FATFS会认为磁盘写保护,拒绝写入。我在一次调试中遇到的现象是:读取完全正常,一写入就返回FR_WRITE_PROTECTED。
最后发现是因为我在SD卡座的写保护检测引脚(通常是卡座的WP开关信号)接了上拉电阻,但当卡插入时这个引脚被拉低,而代码里的GET_WRITE_PROTECTcase直接返回了0(表示未写保护),导致逻辑反转。如果你的卡座上没有连接写保护检测引脚,妥善的做法是直接在disk_ioctl的这个case里返回0,并且不要让它走HAL库默认的检测逻辑,因为这个引脚是完全悬空的话,最终返回的电平是随机的,很容易触发写保护误判。
5.3 优先级、中断与缓存对齐:H7特有的三座大山
H7是Cortex-M7内核,带数据缓存(D-Cache),这就牵扯到一个F4平台从未遇到过的内容:如果MDMA要搬运的内存区域被D-Cache缓存了,而且缓存里的数据和实际内存不一致,那么MDMA搬运的数据大概率是错的。
我在工程里是这么处理的:给SD卡的读写缓冲区分配在特定的内存段,并保证地址32字节对齐(MDMA支持的最低对齐粒度),然后在每次DMA传输前执行SCB_CleanDCache(写操作前)和SCB_InvalidateDCache(读操作后)。CubeMX生成的链接脚本默认把所有变量放在DTCM RAM里,这没问题,但如果你把缓冲区放到了AXI SRAM或者SRAM1/SRAM2,就需要仔细核对cache一致性。
这个问题的症状也很典型:第一次读写数据正常,第二次开始出现数据偶尔不对,而且只有开优化后出现。基本就是D-Cache的一致性问题。如果你用的是FreeRTOS,还要注意任务栈的地址也要对齐,否则DMA写入时可能会跨越cache line边界,造成边界数据错乱。
5.4 CubeMX自定义引脚冲突排查要点
在配置阶段,如果你发现CubeMX里SDMMC1引脚和别的外设打架,不要试图通过调整“Alternate function”来迂回解决。H743的引脚复用是高度固定的,SDMMC1的引脚只能复用为SDMMC1功能。矛盾出现时,要么调整其他外设的引脚,要么换个SDMMC控制器(如果芯片支持SDMMC2)。
我在测试中还试过SDMMC2,它在H743上映射到了PD6(CLK)、PD7(CMD)、PD3~PD5(数据线)。如果你的设计里SDMMC1引脚被以太网或者FMC占用了,可以考虑SDMMC2,功能和SDMMC1完全一样。但注意,SDMMC2在部分型号上可能不支持CAD(卡检测)特性,这一点要细细看芯片参考手册。
6. 最终测试结果和后续扩展想法
经过这轮折腾,我最终平台上的SD卡读写方案是这样定型的:CubeMX配置SDMMC1 + MDMA(RX/TX)+ FATFS,底层用户代码里加了一个全局信号量做DMA同步,缓冲区放在SRAM3并做了32字节对齐,每次DMA操作前后处理D-Cache一致性。实测Class 10 SDHC卡,持续读速约480KB/S,持续写速约360KB/S,CPU占用率在读写期间几乎可以忽略,系统其余任务运行完全不受影响。对于大多数数据记录类项目,这个速度足够用。
后面我计划在这个基础上扩展两个方向:一是做SD卡热插拔监控,通过CD引脚的外部中断实时监测卡片状态,在拔卡前调用f_mount(NULL)释放文件系统;二是把FATFS的缓冲策略改成FF_USE_FASTSEEK,配合更大的簇大小,在连续数据采集场景下把写速度再往上拉一档。
跑数据记录类的项目,SD卡这套东西稳定比速度重要得多。H743的MDMA功能如果配得好,能做到数据搬移零CPU参与;但一定要记得,MDMA的每一个配置项,都有它存在的特定意义,改任何一个参数前,先问自己一个问题:这个参数的默认值背后的硬件逻辑,我搞明白了没有?这个习惯,能帮你避开至少九成藏在官方文档里的坑。
实测下来最顺手的组合分享一个:CubeMX配完SDMMC和MDMA后,FATFS底层不要急着用CubeMX生成的默认文件,对比一下user_diskio.c里disk_ioctl的各个case,把没用的检测功能全部改成最直接的返回值。这样一来,FATFS的访问路径最短,排查问题时也能少看几个分支。如果你正准备在H743上跑SD卡读写,不妨拿着这份checklist逐项过一遍,很多烦人的问题其实在配置阶段就已经注定了。