如何用 CubeMX 让 FreeRTOS 和 SD 卡“和平共处”?
在做嵌入式项目时,你有没有遇到过这种情况:系统要实时采集传感器数据,同时还得把日志写进 SD 卡。结果一调f_write(),整个程序卡住几十毫秒——LED 不闪了,串口收不到命令,仿佛时间静止了一样。
这其实是典型的I/O 阻塞问题。裸机程序里一旦开始文件操作,CPU 就得乖乖等 SD 卡慢慢响应。而现代嵌入式系统早已不是单打独斗的时代,我们需要的是:既能准时读传感器,又能后台默默存数据。
解决办法?上FreeRTOS。
但光上 OS 还不够。如果多个任务争着写 SD 卡,轻则数据错乱,重则文件系统崩溃。怎么让操作系统和外设驱动真正“联动”起来,而不是互相拖后腿?本文就带你从零开始,用 STM32CubeMX 搭出一个稳定可靠的多任务存储系统。
为什么必须把 SD 卡操作放进独立任务?
先说结论:SD 卡是慢设备,FreeRTOS 是快调度器,不隔离就会打架。
我们来看一组真实数据:
- 一次
f_write(512字节)平均耗时:8~20ms - FAT 表更新或簇分配时可能长达:40ms+
- 而你的高优先级任务(比如电机控制)周期可能是:1ms
这意味着什么?如果你在一个 1ms 周期的任务中直接调用写卡函数,等于每毫秒都可能被“冻结”十几毫秒——实时性荡然无存。
FreeRTOS 的价值就在于:它允许我们将这类耗时但非紧急的操作放到低优先级任务中执行,就像安排了一个“后勤员”,专门负责搬运数据到 SD 卡,不影响前线“战士”(如中断服务、通信协议处理)的工作节奏。
CubeMX 快速搭建基础框架
打开 STM32CubeMX,选好芯片(比如 STM32H743),接下来几步关键配置:
1. 启用 SDMMC1 接口
- 工作模式选SD 4-bit Wide bus
- 时钟分频设置为
sysclk / 2 = 100MHz → 50MHz(支持高速模式) - 开启 DMA 请求(DMA2 Stream 3)
⚠️ 注意:SDMMC 要求严格遵循上电时序,HAL 库已封装好初始化流程,无需手动模拟 CMD0/CMD8/ACMD41 等命令。
2. 添加 FatFs 中间件
- 在 Middleware 栏选择FatFs
- 物理层选SD / SDMMC
- 默认配置即可生成
user_diskio.c和ffconf.h
3. 配置 FreeRTOS
- 创建默认任务(
StartDefaultTask) - 设置堆栈大小、启用互斥量和队列功能
- 自动生成
osMutexNew()、osQueueNew()等 API 支持
点击 “Generate Code”,几秒钟就拿到了带 RTOS + SDMMC + FatFs 的工程骨架。
FatFs 多任务安全:别让两个任务同时“开门”
FatFs 本身是非线程安全的。你可以把它想象成一个老式保险柜,一次只能有一个人输入密码打开。如果两个任务同时尝试操作文件系统,轻则返回FR_TIMEOUT,重则导致目录项损坏。
怎么办?加锁。
STM32 官方 BSP 提供了现成的解决方案:通过ff_mutex_take()和ff_mutex_give()实现线程保护。
第一步:开启重入支持
修改ffconf.h:
#define _FS_REENTRANT 1 // 启用多任务访问保护 #define _USE_MUTEX 1 // 使用外部互斥量机制第二步:绑定 FreeRTOS 互斥量
创建一个全局互斥量,并在 FatFs 回调中使用它:
// global variables static osMutexId_t sd_mutex; // 在 main() 或 MX_FATFS_Init() 中初始化 void MX_FATFS_Init(void) { sd_mutex = osMutexNew(NULL); if (sd_mutex == NULL) { Error_Handler(); } } // user_diskio.c 中实现 int ff_mutex_take(BYTE vol) { return osMutexAcquire(sd_mutex, 1000) == osOK ? 1 : 0; } int ff_mutex_give(BYTE vol) { return osMutexRelease(sd_mutex) == osOK ? 1 : 0; }从此以后,任何调用f_open、f_write的任务都会自动排队进入临界区,不会出现“两人抢钥匙”的尴尬局面。
数据怎么传?用队列做“中转站”
设想这样一个场景:
- 任务 A 每 10ms 采集一次 ADC 数据
- 任务 B 负责把这些数据写入 CSV 文件
如果 A 直接调用写文件函数,又回到了阻塞的老路。正确的做法是:A 只管发数据,B 自己去取。
这就需要用到消息队列。
创建日志队列
osMessageQueueId_t log_queue; void StartDefaultTask(void *argument) { // 创建容量为 32 条、每条 64 字节的消息队列 log_queue = osMessageQueueNew(32, 64, NULL); // 启动日志写入任务 osThreadAttr_t attr = { .name = "Logger" }; osThreadNew(LogWriterTask, NULL, &attr); for(;;) { char buf[64]; float v = read_adc(); // 假设这是你的采样函数 snprintf(buf, sizeof(buf), "%.2f\r\n", v); // 非阻塞发送 if (osMessageQueuePut(log_queue, buf, 0U, 0) != osOK) { // 队列满,说明写卡太慢,可考虑丢弃或告警 } osDelay(10); // 10ms 采样周期 } }后台写卡任务:稳扎稳打不慌张
void LogWriterTask(void *argument) { FRESULT fr; FIL file; // 等待 SD 卡插入或电源稳定(可根据需要添加检测逻辑) osDelay(1000); fr = f_mount(&fs, "", 1); if (fr != FR_OK) { // 错误处理:重试 or 报警 return; } while (1) { char data[64]; osStatus_t status = osMessageQueueGet(log_queue, data, NULL, 100); if (status == osOK) { // 打开文件追加写入 fr = f_open(&file, "data.csv", FA_OPEN_ALWAYS | FA_WRITE); if (fr == FR_OK) { f_lseek(&file, f_size(&file)); // 移动到末尾 f_puts(data, &file); f_close(&file); } else { // 处理文件打开失败 } } else { // 超时也没关系,继续循环 } } }你看,这个写卡任务可以慢慢悠悠地工作,哪怕每次f_open花了 20ms,也不会影响其他任务运行。
性能优化实战技巧
✅ 技巧一:合并小包,减少系统调用
频繁调用f_write开销很大,因为每次都要查找 FAT 表、定位扇区。更好的方式是批量写入:
#define BATCH_SIZE 16 char batch_buf[BATCH_SIZE][64]; // 改为累积 16 条再写一次 for (int i = 0; i < BATCH_SIZE; i++) { osMessageQueueGet(log_queue, batch_buf[i], NULL, osWaitForever); } // 一次性写入所有数据 write_lines_to_file((const char**)batch_buf, BATCH_SIZE);这样可以把 I/O 次数降低 16 倍,显著提升吞吐效率。
✅ 技巧二:保持挂载状态,避免反复初始化
很多人习惯每次写完就f_unmount(""),以为这样更安全。其实不然。
频繁挂载/卸载会增加 SD 卡磨损,还可能导致重新识别失败。建议:
- 系统启动时挂载一次
- 日志任务全程保持挂载状态
- 只有在明确拔卡或低功耗休眠前才卸载
✅ 技巧三:合理设置任务优先级与栈空间
| 任务类型 | 优先级 | 推荐栈大小 |
|---|---|---|
| 紧急中断处理 | osPriorityRealtime | 128~256 B |
| 传感器采集 | osPriorityAboveNormal | 256 B |
| 通信任务(UART/WiFi) | osPriorityNormal | 512 B |
| SD 卡写入 | osPriorityBelowNormal | 256~512 B |
FatFs 内部函数调用层级深,尤其是涉及长文件名或 LFN 缓冲区时,栈小于 256 字节容易溢出。
✅ 技巧四:防患于未然——磁盘满怎么办?
最怕的不是写得慢,而是某天 SD 卡满了,程序直接崩掉。
加入简单的容量检查:
FATFS *fs; DWORD fre_clust; fr = f_getfree("", &fre_clust, &fs); if (fr == FR_OK && (fre_clust * fs->csize) < 10) { // 剩余不足 10 cluster // 触发清理策略:删除旧文件 or 报警 LED 闪烁 }或者采用环形日志(ring buffer)策略,自动覆盖最早的数据。
常见坑点与避坑指南
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
f_mount返回FR_DISK_ERR | SD 卡未插稳 / 供电不足 | 检查 VDD/VSS 是否完整,加 10μF 旁路电容 |
| 写入速度忽快忽慢 | 文件碎片化 | 定期格式化 or 使用固定长度预分配文件 |
| 程序跑着跑着死锁 | 忘记释放互斥量 or 队列死等 | 所有osMessageQueueGet加超时,避免永久等待 |
| 低功耗唤醒后无法再次挂载 | SDMMC 时钟未正确恢复 | 进入睡眠前关闭 SDMMC 电源,在唤醒后重新初始化 |
特别是最后一点,很多开发者忽略了外设电源域管理。进入 Stop Mode 前记得调用:
HAL_SD_DeInit(&hsd1); __HAL_RCC_SDMMC1_CLK_DISABLE();唤醒后再反向操作重新使能。
这套架构适合哪些产品?
这套“FreeRTOS + 队列 + SD 卡后台写入”的模式,已经在多个实际项目中验证有效:
- 📊数据记录仪:连续记录温度、振动、GPS 轨迹,断电不丢数据
- 🏥便携医疗设备:动态心电图(ECG)长时间存储
- 🌍环境监测站:PM2.5、噪声、光照强度定时采集并导出报表
- 🎓教学实验箱:学生可通过 TF 卡导出 ADC/DAC 实验波形进行分析
它的核心思想很简单:让专业的人做专业的事。
RTOS 负责统筹调度,FatFs 负责文件管理,SDMMC 负责高速传输,各司其职,系统自然流畅。
最后一句真心话
别再让你的日志操作拖垮整个系统了。
下一次当你想在主循环里加一句fprintf(fp, "%f\n", sensor_val);的时候,请停下来问问自己:能不能交给一个后台任务去做?
利用 CubeMX 几分钟就能配好的 FreeRTOS,配上一点点队列和互斥量的知识,换来的是系统稳定性质的飞跃。
而这,正是嵌入式工程师从“会点亮灯”走向“能做出产品”的关键一步。
如果你正在做一个需要本地存储的项目,不妨试试这个架构。欢迎在评论区分享你的实践心得或踩过的坑。