STM32F4 SPI+DMA+FatFS驱动SD卡日志存储实战

发布时间:2026/11/5 4:34:15
STM32F4 SPI+DMA+FatFS驱动SD卡日志存储实战

简介:面向STM32F4嵌入式开发学习者的SPI、DMA与FatFS综合实践工程包,围绕MicroSD卡在资源受限环境中的高效文件读写展开。内容以STM32F4作为主控,串联SPI主机通信、DMA数据搬运、MicroSD卡驱动和FatFS文件系统移植等环节,适合已具备外设开发基础、希望理解存储访问链路的读者参考。压缩包共141个文件,约1.48MB,以C源文件和头文件为主体,辅以启动文件、Keil工程与调试配置、批处理脚本及说明文本,分别承载驱动实现、外设库、文件系统与构建配置,目录结构便于按模块查阅。已有1367人学习。工程保留较完整的源码和构建环境,可据此梳理SPI与DMA协同收发的配置关系、FatFS挂载以及文件打开、读取、写入和关闭的调用流程,也能参考现有工程组织排查时钟、引脚和中断设置中的常见问题,减少从零搭建与移植调试的时间成本。

1. 板子上只剩一对 SPI,却要接 SD 卡记数据

前段时间拆一块 STM32F4 的采集板,需求很朴素:把传感器数据持续写到 SD 卡里,插拔后还能在电脑上直接读。麻烦在于 SDIO 的 6 根线早被屏和网口占完了,板子边缘只引出一组 SPI2。于是问题变成:在 STM32F4 上用 SPI + DMA + FatFS 驱动 SD 卡,能不能扛住每秒几十 KB 的连续写入,并且断电后文件系统不崩?这份 STM32F4_SPI_DMA_FATFS 工程就是围绕这条链路搭的,跑通之后,一条 SPI 线也能稳定记日志。适合两类人:手上板子没有 SDIO、只能走 SPI 的;以及用 SDIO 但想搞清 FatFS 到底在 diskio 这一层问你要了什么的。SPI 天然比 SDIO 慢,这是选型时要接受的代价,但换来的是四根线、任意 GPIO、逻辑全在你手里。

2. 工程骨架与选型:SPI 模式挂 SD 卡省了什么、丢了什么

2.1 为什么这份工程宁可慢一点也要走 SPI

SDIO 走 4 bit 并行,48MHz 下理论带宽能到 24MB/s,实际写卡也能轻松几百 KB/s 到几 MB/s,从性能上看是首选。但它的代价很硬:CLK、CMD、D0~D3 一共 6 根线,还得占用 SDMMC 外设和一路 48MHz 时钟。很多小板子布线时这几根线根本没引出来,或者被 LCD 的 FSMC 抢了引脚。

SPI 模式只吃 4 根线(SCK/MISO/MOSI/CS),任何一组 SPI 都能上,甚至可以用软件模拟。更关键的是,SPI 模式把 SD 卡那套复杂的 4 bit 时序、CRC 校验、总线状态机全部屏蔽掉了——卡内部自己处理,MCU 这边只看到「发命令、等响应、收数据」三段式。调试的时候你能拿逻辑分析仪一根一根看,不用猜 SDMMC 的时序参数。

代价也很明确:

  • 单向传输,同一时刻只能收或只能发,实际吞吐大约是同频 SDIO 的 1/4。
  • SPI 模式规范上限 25MHz,再高就是赌卡的质量。
  • 没有硬件 CRC 校验(SPI 模式下 CRC 默认关闭),数据完整性只能靠上层保证。

对日志记录这类场景,几十 KB/s 到几百 KB/s 完全够用;对要录音频、存图片的场景,就别硬扛了,老老实实换 SDIO。这个取舍必须先想清楚,否则后面所有优化都是在错误的方向上使劲。

2.2 目录结构与 CubeMX 里必须对上的配置

工程是标准的 CubeMX 生成的骨架,FatFS 以源码形式放在FatFs/下,SPI 与 SD 卡协议单独抽成一对sd_spi.c/h,diskio.c只做薄薄一层转接。这样分层的好处是:以后换 SDIO,只要重写sd_spi.c里的几个函数,上层 FatFS 一行不用动。

STM32F4_SPI_DMA_FATFS/ ├── Core/ │ ├── Inc/ main.h sd_spi.h ffconf.h │ └── Src/ main.c sd_spi.c stm32f4xx_it.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ ├── FatFs/ │ └── src/ ff.c ff.h diskio.c ffconf.h ├── FATFS/ (可选) 一张格式化好的卡镜像,用于对照 └── README.md

引脚和 CubeMX 配置是整个工程最容易翻车的地方,建议照着表核一遍:

信号典型引脚配置要点
SCKPB13复用推挽,速度 High,长线串 22~33 欧
MISOPB14复用推挽,外部上拉 10k 必须有
MOSIPB15复用推挽
CSPB12普通推挽输出,初始电平高,上拉 10k
VDD3.3V卡座旁并 10uF + 0.1uF
CD任意 GPIO卡座检测,可省

CubeMX 里对应要改的六项:

配置项取值原因
ModeFull-Duplex Master收和发都要,半双工在 SPI 模式下不好控
Data Size / First Bit8 Bit / MSB FirstSD 卡命令固定 8 位、高位在前
CPOL / CPHALow / 1Edge即 Mode 0,SD 卡 SPI 模式只认这个
NSSSoftwareCS 必须由代码精确控制到字节级
Baud Rate Prescaler先 256,运行期改上电阶段必须慢
DMASPI2_RX=DMA1 Stream3 Ch0,SPI2_TX=DMA1 Stream4 Ch0都是 Normal 模式、非循环、优先级 Medium

提示:DMA 一定要选 Normal 而不是 Circular。Circular 模式下传输计数器会自动重装,读写大块数据时你根本不知道哪一刻 DMA 又把缓冲区覆盖了一遍。

SPI2 挂在 APB1 上,F407 的 APB1 最高 42MHz,所以分频系数直接决定速率:/256 约 164kHz,/8 约 5.25MHz,/4 约 10.5MHz,/2 约 21MHz。

2.3 时钟两档切换:初始化慢、读写快

SD 卡规范写得很死:上电后必须用 100~400kHz 的时钟喂至少 74 个脉冲,卡才会进入 SPI 模式。很多「卡不认」的问题,根源就是初始化阶段直接跑到了 MHz 级——卡还在上电复位,你已经把命令怼进去了。

常见做法是复用同一个 SPI 句柄,靠改分频在慢速和高速之间来回切:

/* sd_spi.c —— 同一个 SPI 句柄,靠改分频在“慢速初始化”和“高速读写”之间来回切 */ static void spi_set_speed(SPI_HandleTypeDef *h, uint32_t prescaler) { HAL_SPI_DeInit(h); /* 必须先把外设关掉,否则 PRESC 寄存器写不进去 */ h->Init.BaudRatePrescaler = prescaler; HAL_SPI_Init(h); /* 重新初始化后 SPI 才真正工作在目标速率 */ } void sd_spi_low_speed(void) { spi_set_speed(&hspi2, SPI_BAUDRATEPRESCALER_256); } /* 42MHz/256 ≈ 164kHz */ void sd_spi_high_speed(void) { spi_set_speed(&hspi2, SPI_BAUDRATEPRESCALER_4); } /* 42MHz/4 ≈ 10.5MHz */

HAL_SPI_DeInit这一步不能省。直接改hspi2.Init.BaudRatePrescaler再调HAL_SPI_Init在很多 HAL 版本里会被内部状态机挡回去,改了半天发现速率没变,最后只能用工具看波形才定位到。

为什么正常运行停在 10.5MHz 而不是 21MHz?因为 SDUC/SDXC 以外的卡在 SPI 模式下的 25MHz 是理想值,实际受卡座、排线、杜邦线影响很大。用杜邦线接的卡座,21MHz 下读写大文件经常出现FR_DISK_ERR,降到 /4 立刻稳定。这也是血泪经验:能跑通的最高频率,往往比手册标称低一档。

3. 底层链路拆解:SPI 收发、DMA 通道与 diskio 四件套

3.1 SPI2 与 DMA 通道的对应关系别再查表了

F4 系列每一路 SPI 对应的 DMA 映射是固定的,SPI2 只有一组可用:

方向DMA 控制器StreamChannel说明
SPI2_RXDMA1Stream 3Channel 0收数据
SPI2_TXDMA1Stream 4Channel 0发数据

如果你用的是 SPI1,映射会变成 DMA2 Stream0/Stream3 Channel3;SPI3 又是另一套。这一点在 CubeMX 里会自动配对,但手工移植代码时极容易配错,配错的典型表现是「DMA 传输完成中断一次都不进」。

3.2 把一次 512 字节传输封装成阻塞式调用

FatFS 的disk_read/disk_write是同步语义的,它调你之后必须拿到结果才能往下走。DMA 本身是异步的,所以中间要加一层「启动 DMA + 等完成」的封装。

/* sd_spi.c —— DMA 传输的最小封装 */ static uint8_t tx_dummy[512] __attribute__((aligned(4))); /* 收数据时用来“喂”时钟的哑元,必须放在 DMA 能访问的 RAM */ volatile uint8_t spi_dma_busy = 0; volatile uint8_t spi_dma_err = 0; /* DMA 完成回调只做一件事:清忙标志,业务逻辑一律不放在中断里 */ void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI2) spi_dma_busy = 0; } void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI2) { spi_dma_err = 1; spi_dma_busy = 0; } } HAL_StatusTypeDef spi_dma_xfer(uint8_t *dst, const uint8_t *src, uint16_t len) { if (len == 0 || len > sizeof(tx_dummy)) return HAL_ERROR; const uint8_t *tx = src ? src : tx_dummy; if (!src) memset(tx_dummy, 0xFF, len); /* 只收的时候,MOSI 要一直保持高电平 */ spi_dma_err = 0; spi_dma_busy = 1; if (HAL_SPI_TransmitReceive_DMA(&hspi2, (uint8_t *)tx, dst, len) != HAL_OK) { spi_dma_busy = 0; return HAL_ERROR; } uint32_t t0 = HAL_GetTick(); while (spi_dma_busy) { /* 超时兜底,绝不允许死等 */ if (HAL_GetTick() - t0 > 500) { HAL_SPI_Abort(&hspi2); spi_dma_busy = 0; return HAL_TIMEOUT; } } return spi_dma_err ? HAL_ERROR : HAL_OK; }

几个参数和细节值得说明:

  • tx_dummy加了 4 字节对齐。F4 的 DMA 对源地址没有严格对齐要求,但一旦你以后换到带 cache 的 H7/F7,非对齐的缓冲区会带来 cache line 维护问题,现在对齐好以后省事。
  • spi_dma_busy用volatile,因为它在中断里被改、在主循环里被读,不加volatile编译器可能把它优化成死循环。
  • 超时时间给 500ms,是因为一张烂卡在内部擦除时,SPI 单块写可能忙上百毫秒;给太短会误判成错误。
  • HAL_SPI_Abort在超时后必须调,否则 SPI 外设停在半传输状态,下一次传输会直接返回 BUSY,整个文件系统卡死。

3.3 diskio.c 的四个函数:写薄一点,别塞逻辑

FatFS 只通过diskio.c和硬件打交道,最核心的是四个函数。这一层写得越薄越好,不要在disk_read里做重试、做缓存、做日志,出问题时你根本分不清是卡的问题还是你自己的问题。

/* diskio.c —— 只保留一路物理盘 pdrv = 0 */ #include "ff.h" #include "diskio.h" #include "sd_spi.h" #define SD_BLOCK_SIZE 512U DSTATUS disk_status(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return sd_card_ready() ? 0 : STA_NOINIT; /* 卡是否已完成初始化并且还在位 */ } DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return (sd_init() == SD_OK) ? 0 : STA_NOINIT; /* 失败时 FatFS 会拿到 FR_NOT_READY */ } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0 || count == 0) return RES_PARERR; if (sd_read_blocks(buff, (uint32_t)sector, count) != SD_OK) return RES_ERROR; return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0 || count == 0) return RES_PARERR; if (sd_write_blocks((uint8_t *)buff, (uint32_t)sector, count) != SD_OK) return RES_ERROR; return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv != 0) return RES_PARERR; switch (cmd) { case CTRL_SYNC: return RES_OK; /* 写操作在 sd_write_blocks 内已等到卡不忙 */ case GET_SECTOR_COUNT: *(LBA_t *)buff = sd_card_blocks(); /* 整卡扇区数,缺了它 f_mkfs 会算错容量 */ return RES_OK; case GET_SECTOR_SIZE: *(WORD *)buff = SD_BLOCK_SIZE; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff = 1; /* 擦除块粒度,按扇区给 1 最保守 */ return RES_OK; default: return RES_PARERR; } }

LBA_t是 FatFS R0.13 之后才有的类型,老版本里是DWORD。移植旧代码时这里会编译报错,改类型就行,逻辑不用动。

GET_SECTOR_COUNT是很多人漏掉的一项。漏了之后f_mount可能正常,但一调f_getfree就返回 FR_NOT_READY,或者格式化出来的分区大小是 0。

3.4 命令层:从 CMD0 一路走到 CMD17/CMD24

SPI 模式下每条命令都是 6 个字节:0x40 | cmd、4 字节大端参数、1 字节 CRC。除了 CMD0(CRC 0x95)和 CMD8(CRC 0x87),其余命令 CRC 填 0x01 就行,卡在 SPI 模式下默认不校验。

/* 发送一条命令并等待响应:返回 0x00 表示成功,0x01 表示还在 idle */ static uint8_t sd_send_cmd(uint8_t cmd, uint32_t arg) { uint8_t frame[6], resp = 0xFF; frame[0] = 0x40 | cmd; frame[1] = (arg >> 24) & 0xFF; frame[2] = (arg >> 16) & 0xFF; frame[3] = (arg >> 8) & 0xFF; frame[4] = arg & 0xFF; frame[5] = (cmd == 0) ? 0x95 : ((cmd == 8) ? 0x87 : 0x01); CS_LOW(); spi_dma_xfer(NULL, frame, 6); for (uint32_t i = 0; i < 8; i++) { /* NCR 阶段:响应最多 8 个字节内到达 */ spi_dma_xfer(&resp, NULL, 1); if ((resp & 0x80) == 0) break; /* 最高位为 0 才是有效响应 */ } spi_dma_xfer(NULL, (const uint8_t *)"\xFF", 1); /* 补一个字节,让卡退出响应态 */ CS_HIGH(); return resp; }

初始化流程按规范一步步走,每一步的超时都要有:

SD_Status sd_init(void) { uint8_t resp, ocr[4]; sd_spi_low_speed(); /* 1. 上电必须慢速,164kHz 左右 */ CS_HIGH(); for (int i = 0; i < 10; i++) spi_send_byte(0xFF); /* 2. 至少 74 个时钟,让卡进入 SPI 模式 */ CS_LOW(); uint32_t t0 = HAL_GetTick(); /* 3. CMD0 进 idle */ while (sd_send_cmd(0, 0) != 0x01) { if (HAL_GetTick() - t0 > 1000) return SD_ERR_TIMEOUT; } if (sd_send_cmd(8, 0x1AA) == 0x01) { /* 4. CMD8 只有 2.0 以上的卡认 */ for (int i = 0; i < 4; i++) spi_dma_xfer(&ocr[i], NULL, 1); /* 正常情况下 ocr 应回 0x00 0x00 0x01 0xAA,不符说明卡不支持 2.0 协议 */ } uint32_t t1 = HAL_GetTick(); /* 5. ACMD41 等卡初始化完成 */ do { sd_send_cmd(55, 0); /* 所有 ACMD 前必须先发 CMD55 */ resp = sd_send_cmd(41, 0x40000000); /* HCS=1,请求高容量卡的块寻址模式 */ if (HAL_GetTick() - t1 > 2000) return SD_ERR_TIMEOUT; } while (resp != 0x00); if (sd_send_cmd(58, 0) == 0x00) { /* 6. 读 OCR,确认 CCS 位 */ for (int i = 0; i < 4; i++) spi_dma_xfer(&ocr[i], NULL, 1); sd_high_capacity = (ocr[0] & 0x40) ? 1 : 0; } sd_spi_high_speed(); /* 7. 初始化完成,切高速 */ return SD_OK; }

这里最容易忽略的是第 6 步。标准容量卡(SDSC)寻址单位是字节,高容量卡(SDHC/SDXC)寻址单位是扇区。如果不判 CCS 位,换一张 32GB 卡就会读到完全错误的位置——现象是f_mount返回FR_NO_FILESYSTEM,但同一张卡在电脑上好好的。

块读写时地址要跟着换算:

uint32_t addr = sd_high_capacity ? sector : sector * 512U; if (sd_send_cmd(17, addr) != 0x00) return SD_ERR_RESP; /* CMD17 单块读 */ if (sd_wait_token(0xFE, 200) != SD_OK) return SD_ERR_TOKEN; /* 等数据起始令牌 */ spi_dma_xfer(buf, NULL, 512); /* 512 字节数据 */ uint8_t crc[2]; spi_dma_xfer(crc, NULL, 2); /* CRC 丢弃,但必须收走 */

多块读用 CMD18,每收完 512 字节要重新等一次 0xFE 令牌,最后必须发 CMD12 结束传输,否则卡会一直往外吐数据,下一次命令直接失灵。多块写则是 CMD25 + 每块一个 0xFE 起始令牌 + 结束时发一个 0xFD 停止令牌。

写入后必须读回卡的 data response:

uint8_t resp; spi_dma_xfer(&resp, NULL, 1); if ((resp & 0x1F) != 0x05) return SD_ERR_WRITE; /* 0bxxx00101 = 数据被接受 */ return sd_wait_ready(500); /* 等卡内部编程完成,忙时 MISO 拉低 */

0x05这个值看着莫名其妙,其实低 5 位是固定编码,高 3 位是状态位(是否 CRC 错、是否写错)。很多示例代码直接把这个响应丢掉,结果就是「写看起来成功了,读出来是乱的」。

4. FatFS 移植:从 f_mount 到 f_sync 的完整闭环

4.1 ffconf.h 里真正会绊人的几个宏

FatFS 的配置项有几十个,日常只需要动这几个:

宏建议值影响与坑
FF_FS_READONLY0置 1 之后f_write直接编译不过
FF_USE_MKFS0只在需要现场格式化时开,开了大概多占 6~10KB Flash
FF_USE_LFN0 或 1开长文件名要额外栈/静态区,栈给 512 字节时开 3 会直接 HardFault
FF_MAX_SS512别改成 4096,SD 卡扇区就是 512
FF_FS_TINY0置 1 省 512 字节 RAM,但每次读写都直接打到 diskio,速度掉一半以上
FF_FS_REENTRANT0单任务就关掉,开了还得实现ff_mutex_*
FF_VOLUMES1一卡一盘,够用

FF_USE_LFN是新手最常踩的雷。开着长文件名、又用f_open("0:/my_data_log_2024.txt"),编译没问题,一运行就进 HardFault——原因是 LFN 的工作缓冲区被放在栈上,默认栈只有 1KB。要么把栈调到 2KB 以上,要么把FF_USE_LFN改成 1 并把缓冲区指到静态区。

4.2 挂载、追加写、读回校验的三段代码

完整闭环其实就三段:挂载卷、打开文件追加写、关掉之后读回来比对。

FATFS fs; FIL fil; FRESULT fr; UINT bw = 0, br = 0; static uint8_t wbuf[4096] __attribute__((aligned(4))); /* 4KB 缓冲,对齐给 DMA 用 */ static uint8_t rbuf[4096] __attribute__((aligned(4))); /* 第一段:挂载。第三个参数 1 表示立刻挂载,0 表示延迟到第一次访问 */ fr = f_mount(&fs, "0:", 1); if (fr != FR_OK) { printf("mount fail: %d\r\n", fr); return; } /* 第二段:打开 + 定位到末尾 + 写 */ fr = f_open(&fil, "0:/data.bin", FA_OPEN_ALWAYS | FA_WRITE | FA_READ); if (fr != FR_OK) { printf("open fail: %d\r\n", fr); return; } fr = f_lseek(&fil, f_size(&fil)); /* 追加模式:光标挪到文件末尾 */ fr = f_write(&fil, wbuf, sizeof(wbuf), &bw); if (fr != FR_OK || bw != sizeof(wbuf)) { printf("write fail: %d, bw=%u\r\n", fr, bw); } f_sync(&fil); /* 关键:把 FAT 表和目录项刷到卡上 */ f_close(&fil); /* 第三段:重新打开读回来,逐字节比对 */ fr = f_open(&fil, "0:/data.bin", FA_READ); fr = f_read(&fil, rbuf, sizeof(rbuf), &br); if (br == sizeof(rbuf) && memcmp(wbuf, rbuf, sizeof(rbuf)) == 0) { printf("verify ok\r\n"); } f_close(&fil);

几个参数的含义值得逐条说清:

  • f_mount(&fs, "0:", 1)里的"0:"是逻辑盘符,和diskio.c的pdrv = 0对应,写错成"0"会返回FR_INVALID_DRIVE。
  • FA_OPEN_ALWAYS是「有就打开、没有就创建」,比FA_CREATE_ALWAYS安全——后者会把已有文件截断成 0 字节,调试时不小心就把数据清了。
  • f_lseek(&fil, f_size(&fil))用来做追加。直接f_lseek(&fil, 0)会从头覆盖。
  • f_write返回FR_OK不代表字节数写满,第二个判断bw != sizeof(wbuf)才是「卡满了」的真正信号。
  • f_sync是这套东西里最值钱的一行。不调它,数据全躺在 FatFS 的窗口缓冲和 MCU 内部,掉电就没了。

写入失败最常见的三个返回值:FR_DISK_ERR(底层 diskio 返回错误,多半是卡忙超时或 SPI 出错)、FR_DENIED(卡写保护或文件只读打开)、FR_NO_FILE(路径不存在,FatFS 不会自动建目录)。

4.3 吞吐量:4KB 缓冲为什么只跑出 40KB/s

很多人第一次测出来的速度惨不忍睹,然后怀疑 SPI 时钟不够。实际瓶颈往往不在总线上。常见量级大致是这样:

SPI 时钟单块写(512B/次)4KB 缓冲多块写备注
5.25MHz80~150 KB/s200~300 KB/s稳定,适合长排线
10.5MHz150~250 KB/s300~450 KB/s工程默认值
21MHz200~300 KB/s400~600 KB/s接近规范上限,杜邦线易出错

同一张卡在不同板子上差一倍是常事,别拿一个数字当基准。真正影响速度的是这四件事:

单块写 vs 多块写。CMD24 写一个扇区,卡内部要做一次编程,耗时 1~2ms 起步,这部分时间跟 SPI 时钟没关系。用 CMD25 一次写 8 个扇区,编程时间会被卡内部合并,实测能快一倍以上。所以f_write的缓冲至少给到 4KB,最好 8KB。

FF_FS_TINY的隐形代价。置 1 省了 512 字节 RAM,但 FatFS 就没了自己的扇区缓冲,每次读写都要下沉到diskio。一个 4KB 的文件写入可能触发十几次单块写,速度直接对半砍。

f_sync的频率。每次f_sync都要更新 FAT 表和目录项,是好几倍的额外写。每秒 sync 一次和每写 64KB sync 一次,速度能差 30% 以上。折中做法是攒够一定字节数或者超过一定时间(比如 2 秒)再 sync。

卡的本身。便宜卡在写满之后会触发内部垃圾回收,速度掉到几 KB/s 都正常。这就是为什么实测要写满整张卡,而不是只测前 100MB。

5. 排查清单:SPI 模式下最容易出问题的 5 个现场

5.1 现象:f_mount一直返回FR_NOT_READY,串口打印卡初始化超时

原因:初始化阶段的 SPI 时钟太快。SD 卡上电后需要一个稳定期,规范要求这段时间内时钟在 100~400kHz,并且要先发至少 74 个时钟让卡进入 SPI 模式。直接用 10MHz 发 CMD0,卡根本来不及响应。

解决:sd_init()开头强制切低速分频(/256),74 个时钟发完再发 CMD0,整个 ACMD41 循环都保持在低速,等sd_init返回成功再调sd_spi_high_speed()。另外确认 CS 在上电时是拉高的,CS 一直低电平卡会以为你在跟它通信,直接不进 SPI 模式。

5.2 现象:DMA 传输完成中断一次都不进,或者跑到一半直接 HardFault

原因:缓冲区放在了 CCM RAM 里。F407 有 64KB CCM RAM,地址在0x10000000段,这块内存DMA 访问不到。如果你的链接脚本或者__attribute__((section(".ccmram")))把大数组放那儿了,DMA 会一直等一个永远不会来的传输完成。

解决:把 DMA 缓冲区显式放到普通 SRAM。用__attribute__((aligned(4)))加普通全局数组就够了,别随手加 section 属性。同理,tx_dummy这类哑元缓冲也必须在 DMA 可达区。换到 F7/H7 的话还要多一步 cache 维护,F4 上没有 D-Cache,这一点倒是省心。

5.3 现象:读数据时波形上 SCK 一动不动,卡返回 0xFF

原因:只调了HAL_SPI_Receive_DMA。SPI 是全双工,接收模式下 MCU 不发数据,时钟就不会产生,卡自然也不会吐字节出来。

解决:所有接收统一走HAL_SPI_TransmitReceive_DMA,TX 侧传一个填满0xFF的哑元缓冲。这也是spi_dma_xfer里src == NULL时memset(tx_dummy, 0xFF, len)的原因。这个坑在调试时特别隐蔽,因为代码不报错、函数也返回 HAL_OK,就是收不到数据。

5.4 现象:小文件写正常,一写大文件就FR_DISK_ERR,之后整卡失联

原因:多块写用 CMD25 时少发了停止令牌0xFD。卡在收完最后一块之后还在等下一块的数据起始令牌,此时你发任何命令它都不理,表现为「卡死了」。

解决:多块写循环结束后必须先发0xFD(Stop Tran Token),再补一个字节的时钟,然后sd_wait_ready(500)等卡内部编程完成。多块读同理,收完要发 CMD12。另外每一块写完都要判 data response 的低 5 位是不是0x05,出错立刻中止并重新走一遍sd_init,别硬着头皮往下写。

5.5 现象:运行几天后拔电,文件长度是 0,或者最后几十 KB 没了

原因:f_write只把数据写进了 FatFS 的窗口缓冲和 MCU 内存,没调f_sync就不会落盘;即使落了数据区,FAT 表和目录项里的文件长度也是延迟更新的。

解决:按「写够 N 字节」或「距上次 sync 超过 T 秒」两个条件之一触发f_sync,重要数据先 sync 再置状态标志。如果对可靠性要求高,还可以维护一个双备份的元数据扇区,启动时比对两份的头尾魔数决定用哪一份。这套机制在工业现场是标配,省了它迟早要面对一次现场数据全丢。

注意:每次f_sync都会带来额外写入,别放在最内层的循环里。按 2 秒或者 64KB 触发一次是比较稳妥的折中。

6. 二次开发:把「能写」改成「敢长时间写」

工程跑通之后,真正要做的是让它能在现场连续跑几个月。我的做法是把裸f_write换成「环形缓冲 + 单写任务」:采集中断只往一个无锁环里塞定长记录,主循环里有个写任务按 4KB 粒度批量刷盘,攒够 64KB 或超过 2 秒就f_sync一次。这样写卡的抖动不会反压到采集中断,也不会出现某次卡内部垃圾回收把整个系统拖慢的情况。

/* 生产端:中断里只做 memcpy,绝不碰 FatFS */ void log_put(const void *rec, uint16_t len) { uint32_t next = (ring_head + 1) % RING_NUM; if (next == ring_tail) { overflow_cnt++; return; } /* 环满就丢,绝不阻塞采集 */ memcpy(&ring[ring_head], rec, len); ring_head = next; } /* 消费端:攒够 4KB 或超时 2 秒就落盘 */ void log_flush(FIL *f) { UINT bw; uint32_t n = ring_count(); if (n < 4096 && (HAL_GetTick() - last_sync) < 2000) return; f_write(f, ring_peek(), n, &bw); ring_drop(bw); /* 按实际写出的字节数出队 */ if (bw >= 65536 || (HAL_GetTick() - last_sync) > 2000) { f_sync(f); last_sync = HAL_GetTick(); /* f_sync 之后立刻刷新时间戳 */ } }

验证这套东西有三个必做动作:一是把卡写满,看写满之后f_write是不是干净地返回短写而不是报错;二是随机拔电二十次,每次

本文还有配套的精品资源,点击获取