简介面向嵌入式开发学习者与中级工程师的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 控制器StreamChannelSPI2_RXDMA1Stream 3Channel 0SPI2_TXDMA1Stream 4Channel 0SPI1_RXDMA2Stream 0 或 2Channel 3SPI1_TXDMA2Stream 3 或 5Channel 3SPI3_RXDMA1Stream 0 或 2Channel 0SPI3_TXDMA1Stream 5 或 7Channel 0选 SPI2 而不是 SPI1理由很朴素SPI1 在 APB2 上跑 84MHz分频到 21MHz 需要 4 分频看着更好但 SPI1 的 DMA 全在 DMA2 上而这个工程另外还有一路 ADC 连续采样也在用 DMA2两路抢同一个控制器优先级调度会变得很难预测。SPI2 走 DMA1跟 ADC 彻底分开中断里少一层互相等待。速率上SPI 模式的 SD 卡规范上限是 25MHz21MHz 是安全区。注意别贪心去开 42MHz——卡能认出来但连续写几十兆之后会开始偶发 CRC 错这种错误在 FAT 层表现为莫名其妙的FR_DISK_ERR查起来极其难受。初始化阶段的时钟必须单独处理。SD 卡上电后头几十条命令要求 SCK 不超过 400kHz21MHz 下去发 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 个字节才吐响应所以接收循环必须有重试上限。/* 返回 R10xFF 表示无响应超时 * 注意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 bit0, tx bit1 */ 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 填 0x95CMD8 填 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 字节 OCRR7 是 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); /* HCS1声明支持高容量 */ 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 收到 R10x00 之后卡会先吐一个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 收到 R10x00 之后要先发一个0xFE起始令牌再发 512 字节数据最后发 2 字节伪 CRCSPI 模式下卡不校验但字节数必须给。数据发完卡会回一个数据响应字节格式是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_TINY1单扇区缓冲文件对象不再各带一份省 RAM_FS_READONLY0要写日志就不能只读_USE_MKFS1卡没格式化时能现场f_mkfs调试期很有用_USE_LFN1 或 2支持长文件名2 用栈1 用堆_MAX_SS512SD 卡扇区固定 512别设 4096_VOLUMES1一个盘符够用_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收到count8这种参数驱动层如果没做多块拼接就直接算错地址。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_NOINIT0x01表示失败。这个跟一般函数返回 0 表示成功的约定是一致的但跟RES_OK那种枚举又不一样混着用很容易写反。disk_status在 FatFS 内部会被频繁调用别在里面做任何耗时操作读一个标志位就返回。这里还有一个隐藏的时序问题disk_initialize有可能被调用多次。比如用户拔卡再插卡或者f_mount时传了force1。所以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 里不一样老版本是DWORDR0.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_SYNCdisk_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.ModeSCK 和 MOSI 是GPIO_MODE_AF_PPMISO 必须是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的count8走多块写配合 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 的参数要按0x40000000HCS 位传别传 0虽然有些卡传 0 也能过但那样就无法识别高容量卡。6. 二次开发从能读写到敢长期跑的三步改造工程跑通只是起点真正上设备还要过三关。第一关是把阻塞改掉。当前的spi_dma_transfer里那个while (hspi2.State ! HAL_SPI_STATE_READY)是忙等CPU 在 DMA 搬 512 字节的 200 微秒里空转。如果系统里还有别的实时任务直接把它换成 FreeRTOS本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?