STM32F4 SPI+DMA驱动SD卡与FatFS移植实战
简介:面向嵌入式开发学习者与中级工程师的STM32F4实战工程包,围绕SPI结合DMA访问MicroSD卡并移植FatFS文件系统,帮助理解高速数据传输、DMA减轻CPU负担以及文件创建、读取、写入、关闭等完整流程。压缩包共141个文件,约1.48MB,以63个h头文件和55个c源文件为主体,配有12个s汇编及启动文件、Keil工程文件、bat批处理脚本和axf可执行文件等,目录覆盖外设驱动、文件系统与工程配置,便于直接编译、对照移植和排查通信故障。已有1367人学习下载,适合作为外设综合实验、课程设计或嵌入式文件存储方案的参考;读者可从中梳理SPI与DMA参数配置、FatFS挂载及文件操作接口的使用思路,理解传输开始与结束阶段的CPU协作方式,并体会在资源受限环境中兼顾吞吐量、实时性与稳定性的工程实现。
1. 当 SDIO 的六根线被网口和 SRAM 占满:STM32F4 只能拿 SPI 去挂 SD 卡
去年拆一块四层板,SDIO 的 CLK/CMD/D0-D3 全被 RMII 和外部 SRAM 吃掉了,卡座上只剩一组 SPI2 的四个脚。项目要求按秒级频率往卡里写采集日志,一天几百兆。最早用轮询方式写,一个 512 字节块要花掉近 3 毫秒,采样中断被拖得东倒西歪。
这份 STM32F4_SPI_DMA_FATFS 工程要解决的就是这件事:在只有 SPI 可用的前提下,用 DMA 把数据搬运从 CPU 手里摘出去,再用 FatFS 把 SD 卡包装成一个能直接f_open/f_write的块设备。适合两类人——手上是 F407/F429 这类带 DMA 的 M4、卡座只引出了 SPI 的;以及想把 FatFS 的diskio.c那一层彻底搞明白的。下面按硬件对位、驱动拆解、文件系统移植、排障、二次开发的顺序,把它从头到尾过一遍。
2. 工程骨架与硬件对位:引脚、时钟、DMA 请求映射一次理清
拿到一个 STM32 工程,我第一件事不是编译,是先翻Core/Inc和链接脚本,看它把外设挂在哪个总线上、把缓冲区放在哪块 RAM。这个项目里藏着的坑,八成都能从这两处提前发现。
2.1 为什么锁定 SPI2 + DMA1,而不是随便挑一组
F4 系列的外设到 DMA 流的映射是硬编码的,选错流等于白写。SPI2 在 APB1 上(F407 主频 168MHz 时 APB1 为 42MHz),对应的 DMA 请求是固定的:
| 外设方向 | DMA 控制器 | Stream | Channel |
|---|---|---|---|
| SPI2_RX | DMA1 | Stream 3 | Channel 0 |
| SPI2_TX | DMA1 | Stream 4 | Channel 0 |
| SPI1_RX | DMA2 | Stream 0 或 2 | Channel 3 |
| SPI1_TX | DMA2 | Stream 3 或 5 | Channel 3 |
| SPI3_RX | DMA1 | Stream 0 或 2 | Channel 0 |
| SPI3_TX | DMA1 | Stream 5 或 7 | Channel 0 |
选 SPI2 而不是 SPI1,理由很朴素:SPI1 在 APB2 上跑 84MHz,分频到 21MHz 需要 4 分频,看着更好;但 SPI1 的 DMA 全在 DMA2 上,而这个工程另外还有一路 ADC 连续采样也在用 DMA2,两路抢同一个控制器,优先级调度会变得很难预测。SPI2 走 DMA1,跟 ADC 彻底分开,中断里少一层互相等待。
速率上,SPI 模式的 SD 卡规范上限是 25MHz,21MHz 是安全区。注意别贪心去开 42MHz——卡能认出来,但连续写几十兆之后会开始偶发 CRC 错,这种错误在 FAT 层表现为莫名其妙的FR_DISK_ERR,查起来极其难受。
初始化阶段的时钟必须单独处理。SD 卡上电后头几十条命令要求 SCK 不超过 400kHz,21MHz 下去发 CMD0,卡根本不会理你。所以sd_spi.c里要有两个速率档:
/* 初始化阶段:42MHz / 128 ≈ 328kHz,落在 100~400kHz 窗口内 */ void sd_spi_low_speed(void) { hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_128; HAL_SPI_Init(&hspi2); } /* 正常读写阶段:42MHz / 2 = 21MHz */ void sd_spi_high_speed(void) { hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; HAL_SPI_Init(&hspi2); }HAL_SPI_Init会重新写 CR1 寄存器,调用前要先确认 SPI 已经HAL_SPI_DeInit或者至少处于关闭状态,否则改分频位不会生效。我一般直接在函数里先__HAL_SPI_DISABLE(&hspi2),改完再__HAL_SPI_ENABLE,比走一遍完整 Init 更稳。
SPI 参数本身也有讲究:
hspi2.Instance = SPI2; hspi2.Init.Mode = SPI_MODE_MASTER; hspi2.Init.Direction = SPI_DIRECTION_2LINES; /* 全双工,收数据时必须发 dummy */ hspi2.Init.DataSize = SPI_DATASIZE_8BIT; hspi2.Init.CLKPolarity = SPI_POLARITY_LOW; /* 模式 0:空闲低、上升沿采样 */ hspi2.Init.CLKPhase = SPI_PHASE_1EDGE; hspi2.Init.NSS = SPI_NSS_SOFT; /* CS 必须软件控制 */ hspi2.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi2.Init.TIMode = SPI_TIMODE_DISABLE; hspi2.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; /* 硬件 CRC 跟 SD 卡 CRC 不是一回事 */NSS一定要设成软件模式。硬件 NSS 会在每个字节传输间隙自动抬高片选,而 SD 卡的一条命令加响应加数据块必须是连续的一整段低电平,中间被抬高一次卡就复位状态机了。这是新手最容易踩的一个坑,报错现象是命令能发出去,响应永远是 0xFF。
CRCCalculation关掉也别犹豫。SPI 外设自带的 CRC 是给 SPI 帧做校验的,跟 SD 协议里的 CRC7/CRC16 完全无关,开了只会让帧长多出两个字节,把卡的命令解析搞乱。SD 卡命令里的 CRC 字段,除了 CMD0 和 CMD8 必须给正确值,其余在 SPI 模式下卡是不校验的,直接填 0x01 或 0xFF 都行。
2.2 目录分层:从 BSP 到底层的一层一层看
这个工程的目录结构是典型的四层切法,拆开看很清楚:
STM32F4_SPI_DMA_FATFS/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f4xx_hal_conf.h │ │ └── stm32f4xx_it.h │ └── Src/ │ ├── main.c │ ├── stm32f4xx_hal_msp.c /* DMA 与时钟的底层初始化 */ │ ├── stm32f4xx_it.c │ └── dma.c / spi.c / gpio.c /* CubeMX 生成的底层配置 */ ├── BSP/ │ ├── Inc/ │ │ ├── sd_spi.h /* SD 卡协议层接口 */ │ │ └── spi_bus.h /* SPI 收发原语 */ │ └── Src/ │ ├── sd_spi.c /* CMD0/CMD8/ACMD41/CMD17/CMD24... */ │ └── spi_bus.c /* 阻塞与 DMA 两种收发 */ ├── Middlewares/ │ └── Third_Party/ │ └── FatFs/ │ ├── src/ │ │ ├── ff.c │ │ ├── ffconf.h │ │ └── diskio.c /* 移植层,五个函数 */ │ └── inc/ └── App/ ├── Inc/ │ └── data_logger.h └── Src/ └── data_logger.c /* f_mount / f_open / f_write 的业务封装 */分层的价值在排障时体现得特别明显:spi_bus.c里全是字节级收发,sd_spi.c只关心命令和响应,diskio.c只负责把扇区号翻译成命令,data_logger.c完全不知道底下是 SPI 还是 SDIO。有一次板子换了个 SPI 口,我只改了spi_bus.c和stm32f4xx_hal_msp.c两个文件,上层一行没动。
spi_bus.c里我会同时保留阻塞和 DMA 两套接口,因为 SD 卡初始化阶段用的是单字节往返,走 DMA 反而更慢——一次 DMA 启动加中断的开销,够轮询几十个字节了。只有在数据块读写这种 512 字节起步的场景,DMA 才划得来。
2.3 初始化顺序:时钟、GPIO、DMA、SPI 谁先谁后
CubeMX 生成的MX_SPI2_Init只负责 SPI 寄存器,NVIC 和 DMA 是分开配置的。正确的调用顺序是:
HAL_Init(); SystemClock_Config(); /* 先定主频,分频系数才有意义 */ MX_GPIO_Init(); /* CS、SCK、MISO、MOSI 全部初始化为复用/推挽 */ MX_DMA_Init(); /* DMA 控制器时钟必须先开 */ MX_SPI2_Init(); /* SPI 配置里会引用 hdma_spi2_rx/tx */MX_DMA_Init()里最关键的一句是__HAL_RCC_DMA1_CLK_ENABLE()。顺序反了,HAL_SPI_TransmitReceive_DMA会在启动传输时返回HAL_ERROR,因为 DMA 流寄存器写不进去,而 HAL 只返回一个笼统的错误码,不看寄存器根本定位不到。
另外HAL_SPI_MspInit里要把 DMA 句柄链接到 SPI 上:
/* 这一步在 CubeMX 里通常已经生成,手写工程千万别漏 */ __HAL_LINKDMA(hspi, hdmatx, hdma_spi2_tx); __HAL_LINKDMA(hspi, hdmarx, hdma_spi2_rx);漏了这一句,现象是HAL_SPI_TransmitReceive_DMA秒返回HAL_OK,但数据一个字节都不动,中断也不来。这个HAL_OK是假的——HAL 内部只是检查了句柄指针非空,并没有真的启动流。
还有一处 GPIOD 的 CS 引脚,一定要上电默认拉高。SD 卡在 CS 为低时会认为主机在跟它说话,你一上电就低电平,卡会进入一个未定义的中间状态,后面 CMD0 进去直接石沉大海。初始化 GPIO 时把 ODR 先置 1 再配成输出模式,能省掉一个玄学问题。
3. 驱动层拆解:SD 卡 SPI 命令帧、响应与 DMA 搬运
协议层是整个工程里最需要照着手册写的一段。SD 卡的 SPI 模式说白了就是一套固定的帧格式加一个状态机,写对了很稳,写错一个位序就全盘不认。
3.1 命令帧格式与 R1/R3/R7 响应
一条命令固定 6 个字节:起始位 + 传输位(合起来是0x40 | cmd)、4 字节大端参数、1 字节 CRC。发完命令之后,卡不是立刻回,最多会隔 8 个字节才吐响应,所以接收循环必须有重试上限。
/* 返回 R1;0xFF 表示无响应(超时) * 注意:CS 由调用者控制,命令+响应+数据必须是一整段低电平 */ static uint8_t sd_send_cmd(uint8_t cmd, uint32_t arg, uint8_t crc) { uint8_t frame[6]; uint8_t r1 = 0xFF; int i; frame[0] = 0x40 | cmd; /* start bit=0, tx bit=1 */ frame[1] = (uint8_t)(arg >> 24); frame[2] = (uint8_t)(arg >> 16); frame[3] = (uint8_t)(arg >> 8); frame[4] = (uint8_t)(arg); frame[5] = crc; /* CMD0 填 0x95,CMD8 填 0x87,其余无所谓 */ for (i = 0; i < 6; i++) { spi_xfer_byte(frame[i]); } /* CMD12 停止多块读时,会先回一个 stuff byte 再回 R1 */ if (cmd == 12) { spi_xfer_byte(0xFF); } /* 最多等 8 个字节,R1 的 bit7 一定为 0 */ for (i = 0; i < 8; i++) { r1 = spi_xfer_byte(0xFF); if ((r1 & 0x80) == 0) { break; } } return r1; }参数说明:cmd是 0~63 的命令号,arg是大端 32 位参数(对 CMD17 来说是扇区地址,对 ACMD41 来说是 OCR 位掩码),crc只在 CMD0/CMD8 时有用。
r1的位定义值得背下来:bit0 空闲态、bit1 擦除复位、bit2 非法命令、bit3 CRC 错、bit4 擦除序列错、bit5 地址错、bit6 参数错。调试时把r1原样打印出来,比任何断点都直接。曾经有个板子 CMD8 一直返回 0x05(空闲态 + 非法命令),最后发现是 MOSI 和 MISO 焊反了。
R3 是 CMD58 专有的,R1 之后再跟 4 字节 OCR;R7 是 CMD8 专有的,R1 之后再跟 4 字节回显。这两条命令必须读完整 5 个字节,少读一个字节,后面那条命令会从中间错位开始解析,整条链路直接废掉。
3.2 上电到 ACMD41 的初始化时序
初始化序列是这个工程的核心逻辑,顺序错一步卡就不认。完整流程是这样的:
int sd_init(void) { uint8_t r1, buf[4]; uint32_t ocr = 0; uint32_t timeout; sd_spi_low_speed(); /* 1. 时钟降到 400kHz 以下 */ /* 2. 上电延时:CS 拉高状态下至少发 74 个时钟,这里给 10 个 0xFF */ SD_CS_HIGH(); for (int i = 0; i < 10; i++) { spi_xfer_byte(0xFF); } /* 3. CMD0:进 SPI 模式,必须带正确 CRC */ SD_CS_LOW(); r1 = sd_send_cmd(0, 0, 0x95); SD_CS_HIGH(); if (r1 != 0x01) return -1; /* 只有"空闲态"是正确结果 */ /* 4. CMD8:探测 2.7~3.6V 电压和版本,V2 卡才会应答 */ SD_CS_LOW(); r1 = sd_send_cmd(8, 0x1AA, 0x87); if (r1 == 0x01) { for (int i = 0; i < 4; i++) buf[i] = spi_xfer_byte(0xFF); if (buf[2] != 0x01 || buf[3] != 0xAA) { SD_CS_HIGH(); return -2; } } SD_CS_HIGH(); /* 5. ACMD41 循环:CMD55 + CMD41,直到卡退出空闲态 */ timeout = HAL_GetTick(); do { SD_CS_LOW(); sd_send_cmd(55, 0, 0x01); /* ACMD 前缀 */ r1 = sd_send_cmd(41, 0x40000000, 0x01); /* HCS=1,声明支持高容量 */ SD_CS_HIGH(); if (HAL_GetTick() - timeout > 2000) return -3; } while (r1 != 0x00); /* 6. CMD58:读 OCR,看 bit30 判断是 SDHC/SDXC 还是标准容量 */ SD_CS_LOW(); r1 = sd_send_cmd(58, 0, 0x01); for (int i = 0; i < 4; i++) buf[i] = spi_xfer_byte(0xFF); SD_CS_HIGH(); ocr = ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) | ((uint32_t)buf[2] << 8) | buf[3]; sd_card_info.high_capacity = (ocr & 0x40000000) ? 1 : 0; /* 7. CMD16:标准容量卡必须显式设块长 512,高容量卡固定 512 可不发 */ if (!sd_card_info.high_capacity) { SD_CS_LOW(); r1 = sd_send_cmd(16, 512, 0x01); SD_CS_HIGH(); if (r1 != 0x00) return -4; } sd_spi_high_speed(); /* 8. 初始化完成,提到 21MHz */ /* 9. 读 CSD 算容量 */ return sd_read_csd(); }几个容易写错的点:
第 2 步的 74 个时钟,很多资料写"至少 74 个",实际给 10 个 0xFF 就是 80 个时钟,够用。但关键在于这段时间 CS 必须是高电平,而且 SPI 必须是主机模式在发空字节——如果你的 SPI 还没使能,这些字节发不出去,等于没做。
第 6 步的 OCR bit30 决定了扇区地址的寻址单位。标准容量 SD 卡(SDSC)的 CMD17 参数是字节地址,高容量卡(SDHC/SDXC)是块地址。这个差别如果搞错,读第一块能对(地址都是 0),读到后面全乱。工程里统一用块地址,遇到 SDSC 时在disk_read里左移 9 位:
/* 高容量卡直接用块号;标准卡要换成字节地址 */ static uint32_t sd_addr(uint32_t sector) { return sd_card_info.high_capacity ? sector : (sector << 9); }第 8 步提速之后,强烈建议立刻做一次 CMD13 读状态或者随便读一个块验证。有些劣质卡在提速瞬间会掉进错误状态,这时候不查,等到f_mount时才报错,波形已经抓不到了。
3.3 单块与多块读写:CMD17/CMD18/CMD24/CMD25
读写命令的时序差异集中在数据包的起始令牌上。
读操作:发完 CMD17 收到 R1=0x00 之后,卡会先吐一个0xFE数据起始令牌,然后是 512 字节数据,最后 2 字节 CRC。
/* 读单块,buf 必须 4 字节对齐且位于 DMA 可访问的 RAM */ int sd_read_block(uint8_t *buf, uint32_t sector) { uint8_t r1, token; int retry = 0; SD_CS_LOW(); r1 = sd_send_cmd(17, sd_addr(sector), 0x01); if (r1 != 0x00) { SD_CS_HIGH(); return -1; } /* 最多等 100ms 找 0xFE 令牌 */ uint32_t tick = HAL_GetTick(); do { token = spi_xfer_byte(0xFF); if (HAL_GetTick() - tick > 100) { SD_CS_HIGH(); return -2; } } while (token != 0xFE); /* 512 字节走 DMA,收发同时启动 */ if (spi_dma_transfer(NULL, buf, 512) != 0) { SD_CS_HIGH(); return -3; } spi_xfer_byte(0xFF); /* 吃掉 CRC 高字节 */ spi_xfer_byte(0xFF); /* 吃掉 CRC 低字节 */ SD_CS_HIGH(); spi_xfer_byte(0xFF); /* 释放总线,给卡一点收尾时间 */ return 0; }多块读是 CMD18,卡会连续吐多个0xFE + 512 字节 + CRC,直到主机发 CMD12 才停。这里的坑是 CMD12 发出去之后,卡会先回一个字节的"填充数据",再回 R1。漏读这个字节,后面那条命令的响应就会从一个字节的偏移处开始,怎么调都错位。
写操作多一步。发完 CMD24 收到 R1=0x00 之后,要先发一个0xFE起始令牌,再发 512 字节数据,最后发 2 字节伪 CRC(SPI 模式下卡不校验,但字节数必须给)。数据发完,卡会回一个数据响应字节,格式是xxx0sss1,低 5 位是状态,0x05表示写入成功。然后卡进入忙状态,MISO 被拉低,必须一直读 0xFF 直到它变回高电平才能发下一条命令。
/* 写单块,返回 0 成功 */ int sd_write_block(const uint8_t *buf, uint32_t sector) { uint8_t r1, resp; uint32_t tick; SD_CS_LOW(); r1 = sd_send_cmd(24, sd_addr(sector), 0x01); if (r1 != 0x00) { SD_CS_HIGH(); return -1; } spi_xfer_byte(0xFF); /* 一个字节的间隔 */ spi_xfer_byte(0xFE); /* 数据起始令牌 */ if (spi_dma_transfer(buf, NULL, 512) != 0) { SD_CS_HIGH(); return -2; } spi_xfer_byte(0xFF); /* 伪 CRC */ spi_xfer_byte(0xFF); resp = spi_xfer_byte(0xFF); if ((resp & 0x1F) != 0x05) { SD_CS_HIGH(); return -3; } /* 关键:等卡退出忙状态,MISO 回到 0xFF */ tick = HAL_GetTick(); while (spi_xfer_byte(0xFF) != 0xFF) { if (HAL_GetTick() - tick > 500) { SD_CS_HIGH(); return -4; } } SD_CS_HIGH(); spi_xfer_byte(0xFF); return 0; }那个忙等待循环是最容易被省掉的一段。省掉它,单块写测试跑一万次可能都不出错,因为主机发下一条命令的间隔足够卡忙完。一旦换成多块连续写,卡还没擦完你就发下一条命令,卡直接丢弃,返回 0xFF,上层判定超时。这种偶发性错误复现成本极高。
3.4 DMA 收发的封装与 BSY 收尾
全双工 SPI 做单向传输,另一方向必须喂 dummy。工程里预分配了一块对齐的填充缓冲区:
/* 必须放在 DMA 能访问的 SRAM 里,且 4 字节对齐 */ static uint8_t s_dummy[512] __attribute__((aligned(4))); static uint8_t s_txbuf[512] __attribute__((aligned(4))); /* tx 为 NULL 表示只收;rx 为 NULL 表示只发;返回 0 成功 */ int spi_dma_transfer(const uint8_t *tx, uint8_t *rx, uint16_t len) { HAL_StatusTypeDef st; if (len > sizeof(s_dummy)) return -1; if (tx == NULL && rx != NULL) { memset(s_txbuf, 0xFF, len); /* 收的时候发 0xFF */ st = HAL_SPI_TransmitReceive_DMA(&hspi2, s_txbuf, rx, len); } else if (tx != NULL && rx == NULL) { st = HAL_SPI_TransmitReceive_DMA(&hspi2, (uint8_t *)tx, s_dummy, len); } else { st = HAL_SPI_TransmitReceive_DMA(&hspi2, (uint8_t *)tx, rx, len); } if (st != HAL_OK) return -2; /* 等两个流都完成,超时按 100ms 算,21MHz 下 512 字节约 200us */ uint32_t tick = HAL_GetTick(); while (hspi2.State != HAL_SPI_STATE_READY) { if (HAL_GetTick() - tick > 100) { HAL_SPI_DMAStop(&hspi2); return -3; } } /* 血泪经验:DMA 传输完成中断只说明最后一个字节进了移位寄存器, SPI 的 BSY 位可能还没落。此时抬 CS 会把最后一位截断 */ tick = HAL_GetTick(); while (__HAL_SPI_GET_FLAG(&hspi2, SPI_FLAG_BSY)) { if (HAL_GetTick() - tick > 10) return -4; } return 0; }为什么用HAL_SPI_TransmitReceive_DMA而不是单独调Transmit_DMA再调Receive_DMA?因为 SPI 是全双工的,收和发共用同一组时钟,必须同时启动两个流。分开调的话,先启动的那个已经开始产生时钟了,另一个还没配好,前几个字节就丢了。
hspi2.State这个轮询看起来有点土,但比用信号量简单,而且裸机环境下本来就该这么写。要注意HAL_SPI_STATE_READY是在HAL_SPI_TxRxCpltCallback里由 HAL 改的,所以必须确保回调被正确调用——如果工程里重写了HAL_SPI_TxRxCpltCallback又没调HAL_SPI_TxCpltCallback,状态就永远停在 BUSY_TX_RX,死循环。
最后的 BSY 轮询是整个工程的"后悔药"。我在三个不同的板子上遇到过读回来的数据块最后一个字节偶尔是 0xFF,追了两天才定位到这里:HAL_SPI_TxRxCpltCallback触发的时候,数据已经从 DMA 搬到内存了,但 SPI 移位寄存器里还有一个字节正在往 MOSI 上送,此时 CS 拉高,那个字节就被掐掉了。对读操作来说,掐掉的是 dummy,问题不大;对写操作来说,掐掉的是真实数据,卡收到的块 CRC 校验失败,返回写错误。
4. FatFS 移植:diskio.c 五个函数与 ffconf.h 的组合
驱动层通了之后,FatFS 这层其实很薄,薄到很多人以为改几个宏就完事了。实际上宏配错一个,现象能诡异到让你去怀疑硬件。
4.1 ffconf.h 里必须改的六个宏
先看这几项,它们直接决定了功能边界和内存占用:
| 宏 | 建议值 | 说明 |
|---|---|---|
_FS_TINY | 1 | 单扇区缓冲,文件对象不再各带一份,省 RAM |
_FS_READONLY | 0 | 要写日志就不能只读 |
_USE_MKFS | 1 | 卡没格式化时能现场f_mkfs,调试期很有用 |
_USE_LFN | 1 或 2 | 支持长文件名;2 用栈,1 用堆 |
_MAX_SS | 512 | SD 卡扇区固定 512,别设 4096 |
_VOLUMES | 1 | 一个盘符够用 |
_FS_TINY设成 1 之后,FIL结构体里不再有 512 字节的buf,所有读写都借用FATFS对象里的那份窗口缓冲。代价是同一时刻只能有一个文件处于高效读写状态,好处是开十个文件对象也只占一份缓冲。日志场景通常只开一个文件,设 1 没有副作用。
_USE_LFN设 1 需要在ffconf.h里把ff_memalloc/ff_memfree指到自己的堆上。很多人编译过了但一调f_open就进 HardFault,原因就是这两个宏默认是空的,_USE_LFN == 1时 FatFS 会调用它们去分配长文件名缓冲。要么改_USE_LFN为 2(用栈上的局部数组),要么老老实实实现这两个函数:
/* 放在 diskio.c 或者任意一个 C 文件里 */ void *ff_memalloc(UINT size) { return pvPortMalloc(size); /* FreeRTOS 环境用这个 */ } void ff_memfree(void *p) { vPortFree(p); }裸机没有堆管理器的话,用_USE_LFN = 2更省事,长文件名缓冲放在栈上,代价是每个用到 LFN 的函数多吃 200 多字节栈空间。主栈设在 0x2000 以上就够。
_MAX_SS千万别设成 4096 想"兼容大扇区"。SD 卡的扇区就是 512 字节,设大了 FatFS 会按 4096 去组织读写,disk_read收到count=8这种参数,驱动层如果没做多块拼接就直接算错地址。
4.2 disk_initialize 与 disk_status 的返回值语义
这两个函数的返回值是位掩码,不是错误码,理解反了会让 FatFS 一直认为卡没就绪。
DSTATUS disk_status (BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return sd_card_info.ready ? 0 : STA_NOINIT; } DSTATUS disk_initialize (BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; if (sd_init() != 0) { sd_card_info.ready = 0; return STA_NOINIT; } sd_card_info.ready = 1; return 0; /* 0 表示初始化成功 */ }返回 0 才是成功,返回STA_NOINIT(0x01)表示失败。这个跟一般函数返回 0 表示成功的约定是一致的,但跟RES_OK那种枚举又不一样,混着用很容易写反。disk_status在 FatFS 内部会被频繁调用,别在里面做任何耗时操作,读一个标志位就返回。
这里还有一个隐藏的时序问题:disk_initialize有可能被调用多次。比如用户拔卡再插卡,或者f_mount时传了force=1。所以sd_init内部必须先做一次完整的复位流程(包括低速时钟、74 个时钟、CMD0),不能假设卡还在之前的状态。我在sd_init开头加了一句sd_card_info.ready = 0,防止中途失败后状态残留。
4.3 disk_read 与 disk_write:把 LBA 翻译成 CMD
这是移植的核心,代码不长但每一行都不能错。
DRESULT disk_read (BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0 || count == 0) return RES_PARERR; /* 单块走 CMD17,多块走 CMD18 效率差一倍以上 */ if (count == 1) { if (sd_read_block(buff, sector) != 0) return RES_ERROR; } else { if (sd_read_blocks(buff, sector, count) != 0) 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 (count == 1) { if (sd_write_block(buff, sector) != 0) return RES_ERROR; } else { if (sd_write_blocks(buff, sector, count) != 0) return RES_ERROR; } return RES_OK; }LBA_t这个类型名在不同版本的 FatFS 里不一样,老版本是DWORD,R0.12 之后引入了LBA_t别名。照着你手上那份ff.h里的定义来,别硬套。函数签名里的const也要注意,新版本disk_write的 buff 是const BYTE *,老版本没有 const,改的时候编译器会报类型不匹配。
多块写有个能显著提升速度的技巧:CMD25 之前先发 ACMD23 告诉卡"我要连着写 N 块",卡内部会提前做擦除,避免每块都现擦。这个预擦除数量宁小勿大,设成实际要写的块数就行,设大了反而会白擦一片。
/* 多块写:先 ACMD23 预擦除,再 CMD25 连续写 */ int sd_write_blocks(const uint8_t *buf, uint32_t sector, uint32_t count) { uint8_t r1; SD_CS_LOW(); sd_send_cmd(55, 0, 0x01); /* ACMD 前缀 */ r1 = sd_send_cmd(23, count, 0x01); /* 预擦除 count 块 */ if (r1 > 0x01) { SD_CS_HIGH(); return -1; } r1 = sd_send_cmd(25, sd_addr(sector), 0x01); if (r1 != 0x00) { SD_CS_HIGH(); return -2; } for (uint32_t i = 0; i < count; i++) { spi_xfer_byte(0xFF); spi_xfer_byte(0xFC); /* 多块写令牌,单块是 0xFE */ if (spi_dma_transfer(buf + i * 512, NULL, 512) != 0) { SD_CS_HIGH(); return -3; } spi_xfer_byte(0xFF); spi_xfer_byte(0xFF); /* 两字节伪 CRC */ if ((spi_xfer_byte(0xFF) & 0x1F) != 0x05) { SD_CS_HIGH(); return -4; } /* 每块之间都要等忙结束 */ uint32_t tick = HAL_GetTick(); while (spi_xfer_byte(0xFF) != 0xFF) { if (HAL_GetTick() - tick > 500) { SD_CS_HIGH(); return -5; } } } spi_xfer_byte(0xFD); /* 多块写停止令牌 */ spi_xfer_byte(0xFF); while (spi_xfer_byte(0xFF) == 0x00) { } /* 等内部编程完成 */ SD_CS_HIGH(); spi_xfer_byte(0xFF); return 0; }注意多块写的令牌是0xFC不是0xFE,停止令牌是0xFD。这三个值记混是常态,建议在头文件里定义成宏,别在代码里写裸数字。
4.4 disk_ioctl:容量、擦除块与 CTRL_SYNC
disk_ioctl里最常被写错的是GET_BLOCK_SIZE的返回值语义。它返回的不是"扇区大小",而是"擦除块大小,单位是扇区"。SD 卡内部的擦除块通常很大(几百 KB 到几 MB),但 FatFS 只拿它给f_mkfs做对齐参考,给 1 是完全合法的保守值。
DRESULT disk_ioctl (BYTE pdrv, BYTE cmd, void *buff) { if (pdrv != 0) return RES_PARERR; switch (cmd) { case CTRL_SYNC: /* 等待所有挂起的写操作落盘。有写缓存的工程要在这里 flush */ return RES_OK; case GET_SECTOR_COUNT: *(LBA_t *)buff = sd_card_info.block_count; return RES_OK; case GET_SECTOR_SIZE: *(WORD *)buff = 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff = 1; /* 擦除块 = 1 个扇区,保守但不出错 */ return RES_OK; default: return RES_PARERR; } }block_count是从 CSD 寄存器算出来的。CSD 有 V1 和 V2 两种格式,V2(高容量卡)的容量算法是(c_size + 1) * 1024块,V1 是(c_size + 1) * (2^(c_size_mult + 2)) * (2^read_bl_len)。工程里按 CSD 的CSD_STRUCTURE字段分支处理,读不对会导致f_mount之后显示容量是 0 或者几十 GB。
CTRL_SYNC在很多简化实现里直接return RES_OK,如果你的写操作是同步阻塞的(DMA 传输完才返回),这样确实没问题。但一旦以后改成异步写缓存,这里就必须真的等队列排空,否则f_sync就变成了假同步。
4.5 应用层写法:f_sync 比 f_close 更重要
日志场景的写入模式跟普通文件操作不太一样,核心诉求是"随时掉电,已写的不能丢"。
FATFS fs; FIL log_fil; int logger_init(void) { if (f_mount(&fs, "0:", 1) != FR_OK) return -1; FRESULT fr = f_open(&log_fil, "0:/log.csv", FA_OPEN_ALWAYS | FA_WRITE); if (fr != FR_OK && fr != FR_EXIST) return -2; f_lseek(&log_fil, f_size(&log_fil)); /* 追加模式:定位到末尾 */ return 0; } int logger_append(const void *data, UINT len) { UINT written = 0; if (f_write(&log_fil, data, len, &written) != FR_OK) return -1; if (written != len) return -2; /* 每攒够一批就落盘,别等 f_close */ if (f_sync(&log_fil) != FR_OK) return -3; return 0; }f_sync会把 FatFS 缓冲区的脏数据刷到卡上,同时更新目录项里的文件大小。不做这一步,数据只存在 RAM 里,一断电全没。但也别每写一个字节就f_sync一次,那是把吞吐量往死里砍。我的做法是攒 4KB 或者攒够 1 秒,哪个先到就同步一次。
路径前缀"0:"要跟ffconf.h里的_VOLUMES数量对上。_VOLUMES = 1时有效盘符是0:,写"1:"会直接返回FR_INVALID_DRIVE。分隔符用/不用\,虽然 FatFS 两个都认,但混用会让某些工具链的路径解析出偏差。
5. 避坑排查:五个让 SD 卡"时好时坏"的具体现场
下面这几条都是我在不同板子上真实撞过的,现象、原因、解决一并写出来,撞上时对着看能省不少时间。
5.1 初始化阶段:CMD0 永远返回 0xFF
现象:程序卡在sd_init第一步,sd_send_cmd(0, 0, 0x95)的返回值一直是 0xFF,示波器上看 MOSI 有波形,MISO 一直是高电平。
原因:有三个常见源头。其一,上电时 CS 默认拉低,卡进了未定义状态;其二,初始化时钟没降下来,还在 21MHz 跑 CMD0,卡来不及响应;其三,MOSI/MISO 的 GPIO 模式配成了推挽输出,MISO 被主机强行驱动成高电平,卡想拉低都拉不动。
解决:先把 CS 的 GPIO 初始化顺序改成"先写 ODR 再配模式",保证上电瞬间就是高电平;然后在sd_init第一行强制切低速档,用示波器量一下 SCK 频率确实在 400kHz 以下;最后检查GPIO_InitStruct.Mode,SCK 和 MOSI 是GPIO_MODE_AF_PP,MISO 必须是GPIO_MODE_AF_PP配上拉,或者GPIO_MODE_INPUT配上拉(有些 HAL 版本对 AF 输入的支持不一样,实测两种都能用,但一定要有上拉)。
5.2 传输阶段:DMA 报错与数据错位
现象:HAL_SPI_TransmitReceive_DMA返回HAL_BUSY,或者传输能完成但读回来的 512 字节里前几百字节正确、后面全是 0x00。
原因:前者九成是缓冲区地址问题——STM32F407 有 64KB 的 CCM RAM(起始地址 0x10000000),那块内存只有 CPU 能访问,DMA 控制器根本够不着。如果你的全局数组被链接脚本分到了 CCM 段,DMA 一启动就报错。后者通常是 DMA 数据宽度配错,外设侧是字节、内存侧配成了半字,512 个字节只搬了 256 次,后半段自然没数据。
解决:在链接脚本里确认.ccmram段没有被用来放 DMA 缓冲区,或者干脆给缓冲区加属性强制放到普通 SRAM:
/* 显式放到 SRAM1,避开 CCM */ static uint8_t sd_buf[512] __attribute__((section(".sram1"), aligned(4)));DMA 配置里PeriphDataAlignment和MemDataAlignment都必须是DMA_PDATAALIGN_BYTE/DMA_MDATAALIGN_BYTE,跟 SPI 的 8 位数据宽度对齐。另外MemInc必须是DMA_MINC_ENABLE,外设地址PeriphInc必须是DMA_PINC_DISABLE——SPI 的 DR 寄存器地址是固定的,递增就读到别的外设去了。
5.3 文件系统阶段:FR_DISK_ERR 与容量识别为 0
现象:f_mount偶尔返回FR_DISK_ERR,重试几次又能成功;或者挂载成功但f_getfree算出来的总容量是 0。
原因:FR_DISK_ERR的偶发性来自读块失败,最常见的触发点是抬 CS 太早(BSY 没清)和写块后没等忙结束。容量为 0 则是 CSD 解析写错,V1/V2 格式分支判断反了,或者block_count根本没赋上值。
解决:先按 3.4 节的办法加上 BSY 轮询和忙等待,这类偶发错误能消掉一大半。容量问题上,把 CSD 的 128 字节原样打印出来,对照手册的位域表手工算一遍c_size,再看代码算出来的对不对。我遇到过一次是CSD_STRUCTURE字段取的是高 2 位,代码里按低 2 位取了,结果 V2 卡被当成 V1 解析,算出来 3.7GB 的卡显示成 128MB。
5.4 性能与稳定性:写盘慢、跑久了死机
现象:写 1MB 数据要十几秒,平均只有几十 KB/s;或者设备连续跑两三个小时之后,f_write开始返回错误,重启才能恢复。
原因:慢的原因几乎都是单块写 + 每写一次就f_sync。SD 卡的单块写每次都要经历"命令-擦除-编程-忙等待"四步,擦除占了绝大部分时间;而连续多块写时卡内部能流水线处理,速度快五到十倍。长时间跑死机的根源是超时缺失——某次卡返回了一个异常响应,代码里的while循环没有退出条件,一路等到看门狗复位。
解决:应用层加一个 4KB 的环形缓冲,攒满一次用disk_write的count=8走多块写,配合 ACMD23 预擦除。实测同一张 Class10 卡,单块写大概 90KB/s,多块写能到 600KB/s 以上。所有等待循环一律带HAL_GetTick()超时,超时后执行一次完整的sd_init重新握手,比死等强得多。
5.5 换卡之后不认:兼容性问题
现象:同一块板子,A 卡能用,B 卡初始化失败;或者 8GB 的能用,32GB 的不认。
原因:不同厂商的卡在上电时间和 ACMD41 的响应速度上差异很大。有些卡要求上电后延时 10ms 以上才接受 CMD0,有些卡对 ACMD41 的轮询间隔敏感,主机连发太快它会漏响应。大容量卡不认,通常是 CMD8 的响应没读完整 4 个字节,导致后面的 ACMD41 参数错位。
解决:sd_init开头加上HAL_Delay(10);ACMD41 的轮询循环里每次重试之间插入 1ms 延时;CMD8 之后的 4 字节回显一定要读满并校验buf[2]==0x01 && buf[3]==0xAA。另外 ACMD41 的参数要按0x40000000(HCS 位)传,别传 0,虽然有些卡传 0 也能过,但那样就无法识别高容量卡。
6. 二次开发:从"能读写"到"敢长期跑"的三步改造
工程跑通只是起点,真正上设备还要过三关。
第一关是把阻塞改掉。当前的spi_dma_transfer里那个while (hspi2.State != HAL_SPI_STATE_READY)是忙等,CPU 在 DMA 搬 512 字节的 200 微秒里空转。如果系统里还有别的实时任务,直接把它换成 FreeRTOS
本文还有配套的精品资源,点击获取