简介:面向STM32F103单片机开发者的FATFS文件系统管理实验例程,基于HAL库和KEIL环境编写,适合正在学习嵌入式文件系统应用、或需要快速搭建存储管理功能的读者。压缩包内含226个文件,以C源文件与H头文件为主,覆盖FATFS核心驱动和STM32F1xx系列HAL库外设驱动,同时附有PNG接线示意图、TXT说明文档、工程配置文件及实用脚本,整体大小仅2.43MB,下载后即可对照使用。目前已有121人学习下载,例程经过精心编写,关键代码带有详细注释,并明确给出了单片机与所接模块的引脚定义和接线方式,方便在硬件上快速验证。除此之外,工程中还提供烧录文件与清理脚本,可帮助提升调试效率,适合物联网实验开发中直接复用或在此基础上做功能扩展,对于理解文件系统读写、文件管理等底层逻辑也有较强的参考价值。
1. 把 FATFS 从“例程能跑”改成“自己会改”
很多拿到 STM32F103 FATFS 实验例程的人,第一步往往是把 .rar 解压出一个工程,烧进去能读卡就认为完成了。但真正接手项目时,卡住你的通常不是 FatFs 本身,而是 HAL 库生成代码里的底层回调没接对、SDIO 和 SPI 选错了引脚、或者 ffconf.h 里一个宏把 RAM 吃掉大半。这个实验讲的是用 STM32 HAL 库把 FATFS 文件系统管理工作做完整:建目录、写日志、列文件、删旧文件,并在 CubeMX 配置和 FRESULT 排错层面留出可复现的步骤。适合刚学完 HAL 库中断和 DMA、想往数据记录方向走的开发者,也适合做量产固件前想把这套文件栈摸透的工程师。
2. 先用 STM32CubeMX 把 F103 的 FATFS 底子生成出来
解压例程包以后,不急着看 main.c。先明确一个原则:文件系统代码只有很小一部分是 FatFs 中间件,真正和板子强相关的是diskio.c里的disk_initialize、disk_read、disk_write、disk_ioctl四个函数。HAL 库工程里,这四个函数通常由 CubeMX 自动生成,但接口模式选错,后面写的所有文件操作都会在莫名其妙的地方失败。
2.1 为什么老例程值得重生成而不是直接改
网上流传的 STM32F103 FATFS 例程,很多是用标准外设库写的,底层是SD_Init()、SD_ReadBlock()这类函数,拿过来和自己的 HAL 工程混用,会出现两套外设状态机互相覆盖的问题。常见做法是直接在 STM32CubeMX 里新建工程,只保留 FatFs 中间件和底层生成代码,再把自己的应用逻辑加进去。
这样做的另一个好处是,CubeMX 会把 FATFS 的磁盘接口和 STM32 的 SDIO 外设绑在一起,生成FATFS_Target和User文件夹。后续升级 HAL 库版本时,重新生成不会覆盖手写的业务函数,排错的边界也很清楚:应用层只调用 FatFs 的 API,底层只面对扇区。
2.2 SDIO 还是 SPI:先看板子再动 CubeMX
F103 上跑 FATFS,最常见的介质是 SD 卡,而 STM32 访问 SD 卡有两条路:SDIO 四线模式和 SPI 模式。这里没有绝对的好坏,只有适不适合你手上的板子。
| 对比项 | SDIO 四线模式 | SPI 模式 |
|---|---|---|
| 典型引脚 | PC8-PC12、PD2 | SPI 的 MOSI/MISO/SCK,另加一根 CS |
| 理论速度 | 高,适合连续数据记录 | 低,但日志频率不高时够用 |
| 接线难度 | 线多,杜邦线容易松动 | 线少,面包板也能跑 |
| HAL 生成复杂度 | CubeMX 直接配置 SDIO | 需要在 diskio.c 里自己桥接 SPI |
| 卡兼容性 | 对卡时序要求稍高 | 许多老卡反而更听话 |
对于 STM32F103C8T6 最小系统板和普通 SD 卡模块,我一般建议先用 SPI 模式把 FATFS 跑通,因为四线 SDIO 一旦遇到接触不良,排查起来比写代码还费时间。等代码逻辑完整了,再改成 SDIO 模式去追求吞吐量,这时候排错的变量就少了。
CubeMX 里的配置步骤分为四步:第一,时钟树先把 HCLK 配到 72MHz;第二,如果有 SDIO 外设,在 Connectivity 里勾选 SDIO,并根据硬件选择 1-bit 或 4-bit 总线;第三,在 Middleware 里启用 FATFS,接口选择 SDIO;第四,生成工程到 Keil 或 Makefile 工程。
2.3 生成后最先改的 ffconf.h 参数
FATFS 的配置集中在ffconf.h,这个文件决定了代码体积、RAM 占用和中文文件名的处理方式。实验里至少要把下面几个宏确认一遍:
#define FF_USE_LFN 2 // 2 表示长文件名工作缓冲在栈上分配 #define FF_MAX_LFN 64 // 最大文件名长度,按实际需要调整 #define FF_CODE_PAGE 936 // 936 对应 GBK,方便中文文件名 #define FF_FS_READONLY 0 // 0 表示可写 #define FF_USE_STRFUNC 2 // 允许 f_printf 等格式化输出 #define FF_VOLUMES 1 // 卷数量,实验通常只用 0 号卷这段配置里最容易踩坑的是FF_USE_LFN。如果设为 1,工作缓冲是全局静态数组,多个函数共用时要注意重入;如果设为 2,FatFs 在解析路径时会在栈上分配一块大小为FF_MAX_LFN + 2的缓冲,F103 的栈如果只给了 0x400 字节,长文件名一出现就会 HardFault。遇到这个现象,先看 Startup 文件里的 Stack_Size,再决定要不要把FF_MAX_LFN调小。
提示:
FF_CODE_PAGE改成 936 以后,FatFs 内部会带一个较大的 Unicode 转换表,F103 的 Flash 占用会明显增加。如果只是记录 ASCII 日志,可以保持 437,避免 Flash 容量不够。
3. 写一个按目录管理的 FATFS 日志读写例程
把文件系统跑通和把文件系统用起来是两回事。在实验里,建议不要把所有代码堆在 main.c 里,而是把 FatFs 的调用封装成几个带有错误返回的接口,这样后面加 DMA、加掉电保护时才不会改一处崩三处。
3.1 挂载卷的封装,要覆盖“新卡未格式化”的情况
挂载是第一个容易产生错觉的 API。f_mount的第一个参数是 FATFS 结构体指针,第二次挂载同一张卡时传入 NULL 可以只注销;第二个参数是路径前缀,F103 上通常传""或"0:";第三个参数 1 表示立即挂载,0 表示只登记工作区。
static FATFS g_fs; FRESULT file_system_init(void) { FRESULT res = f_mount(&g_fs, "", 1); if (res == FR_NO_FILESYSTEM) { BYTE work[FF_MAX_SS]; // f_mkfs 需要的工作缓冲区 res = f_mkfs("", NULL, work, sizeof(work)); if (res == FR_OK) { res = f_mount(&g_fs, "", 1); // 格式化完成后重新挂载 } } return res; }这段代码里,FF_MAX_SS是扇区大小宏,常取 512 或 4096。f_mkfs的第三个参数必须是 4 字节对齐的缓冲区,否则部分底层接口会报对齐错误。格式化是破坏性操作,代码里加上FR_NO_FILESYSTEM条件,能避免每次上电都把正常卡格式化一遍。
3.2 创建目录并按天追加日志
文件系统管理实验里,日志一般按日期分目录或分文件。先写一个确保目录存在的函数:
FRESULT ensure_dir(const char *path) { FILINFO fno; if (f_stat(path, &fno) == FR_OK) { if (fno.fattrib & AM_DIR) { return FR_OK; } return FR_EXIST; } return f_mkdir(path); }这里用f_stat先判断路径是否存在,避免了f_mkdir返回FR_EXIST被误当成错误处理。FATFS 的f_mkdir一次只能建一层目录,要建多级路径时得逐层调用。
追加日志的典型写法是:
FRESULT log_append(const char *fmt, ...) { FRESULT res; va_list ap; res = f_open(&g_file, "0:/LOG/RUNTIME.LOG", FA_WRITE | FA_OPEN_APPEND); if (res != FR_OK) return res; f_printf(&g_file, "%lu,", (unsigned long)HAL_GetTick()); va_start(ap, fmt); f_vprintf(&g_file, fmt, ap); va_end(ap); f_puts("\r\n", &g_file); res = f_close(&g_file); return res; }FA_OPEN_APPEND每次打开文件都会把读写指针移到文件末尾,适合追加记录。每次写一行就f_close,会损失一部分性能,但换来了掉电时丢数据概率更低的保证。如果日志写入频繁,可以改成保留文件句柄,攒满 512 字节再f_sync一次,但这个优化会放大掉电窗口,项目中要衡量。
3.3 列目录时注意 FILINFO 的长文件名缓冲
遍历目录是实验里展示“管理”能力的重要部分。FATFS 的f_readdir会把结果填进FILINFO,其中fname是短文件名,长文件名需要调用方自己准备缓冲。
| FILINFO 字段 | 用途 | 注意事项 |
|---|---|---|
| fname | 短文件名,固定 13 字节 | 始终有效 |
| lfname | 长文件名缓冲指针 | 调用前必须自己赋值 |
| lfsize | 上面缓冲的大小 | 填 0 会禁用长文件名 |
| fattrib | 文件属性 | 用 AM_DIR 判断目录 |
| fsize | 文件大小,单位字节 | 目录项此值为 0 |
| fdate | 文件修改日期 | 位域格式,可直接比较 |
void list_dir(const char *path) { DIR dj; FILINFO fno; char lfn[FF_MAX_LFN + 1]; fno.lfname = lfn; fno.lfsize = sizeof(lfn); if (f_opendir(&dj, path) != FR_OK) return; for (;;) { if (f_readdir(&dj, &fno) != FR_OK) break; if (fno.fname[0] == 0) break; // 读到目录末尾 if (fno.fname[0] == '.') continue; // 跳过 . 和 .. if (lfn[0]) { printf("%s, %lu\r\n", lfn, fno.fsize); } else { printf("%s, %lu\r\n", fno.fname, fno.fsize); } } f_closedir(&dj); }这段代码里最容易错的是忘记给lfname赋值。如果fno.lfname保持 NULL,f_readdir就只填短文件名,显示出来的日志名会被截断成 8.3 格式。另一个坑是lfn缓冲每次循环都会被复用,所以要先打印再用,不要积累引用。
如果要做“删除三天前的日志”,不要在这个循环里直接调f_unlink。删除文件会改变目录项,虽然 FATFS 在多数情况下允许,但不安全的写法容易漏项。正确做法是先把要删的文件名存到数组里,循环结束后再统一删除。
4. 从 FRESULT 到 HAL 底层读函数一级一级定位问题
文件系统实验报告里最难写的是“排错”部分,因为 FATFS 返回的错误码覆盖了从物理层到逻辑层的所有问题。遇到FR_NOT_READY时先怀疑文件系统代码,是最常见的误区。
4.1 先用底层读函数确认扇区是否可读
FATFS 的f_open走的是disk_read,所以最直接的排查方式是绕过 FATFS,直接调用 HAL 的 SDIO 读接口,读第 0 个扇区。SDIO 模式下,CubeMX 会生成SD_HandleTypeDef hsd,实验代码可以这样验证:
uint8_t buf[512]; HAL_StatusTypeDef st; __ALIGN_BEGIN uint8_t aligned_buf[512] __ALIGN_END; st = HAL_SD_ReadBlocks(&hsd, aligned_buf, 0, 1, 1000); if (st != HAL_OK) { printf("HAL_SD_ReadBlocks fail: %d\r\n", st); }参数说明:第一个参数是 SDIO 句柄;第二个参数要求 4 字节对齐,普通局部数组在编译器默认对齐下通常可以,但开启 DMA 后不对齐会直接卡死;第三个参数 0 表示逻辑扇区号;第四个参数 1 表示读 1 个扇区;最后一个参数是超时毫秒数。
读回来的 512 字节里,偏移 510 和 511 必须是0x55和0xAA,这是 MBR 或 FAT 卷的结束标志。如果这个都读不对,问题在硬件,不在 FATFS。
4.2 把 FRESULT 返回值和硬件表现对应起来
| FRESULT | 典型硬件/配置原因 | 排查动作 |
|---|---|---|
| FR_NOT_READY | 卡未插好、供电不足、SDIO 无时钟 | 先读 CID/CSD,确认卡进入传输态 |
| FR_DISK_ERR | 扇区读写超时,响应 CRC 错误 | 降低 SDIO 时钟分频,换短杜邦线 |
| FR_NO_FILESYSTEM | 新卡没有格式化,或分区表格式不支持 | 用 f_mkfs 或电脑格式化为 FAT32 |
| FR_NOT_ENABLED | f_mount没执行,或 FATFS 工作区被清空 | 检查初始化调用顺序 |
| FR_EXIST | 文件已存在,而打开模式要求新建 | 改用 FA_OPEN_ALWAYS 或先删除 |
| FR_DENIED | 卡写保护或文件属性只读 | 查 disk_ioctl 的写保护返回 |
这些错误里,FR_DISK_ERR 的迷惑性最强。它在写入大量数据后出现,多半不是文件系统逻辑错误,而是 F103 的 SDIO 在高速下采样不稳定。先把卡时钟降下来,用HAL_SD_Init里的 Init.ClockDiv 参数调整,确认能连续写 100 个文件后再逐步提频。
4.3 加一个统一的返回值检查宏
工程里的每次f_open、f_read、f_write都不能只看一眼返回值。推荐在调试阶段做一个快速断言:
static FRESULT f_chk(FRESULT res) { if (res != FR_OK) { printf("FRESULT=%d at %s:%d\r\n", res, __FILE__, __LINE__); while (1) { // 停在错误现场,方便在调试器里看调用栈 } } return res; }调用处改成f_chk(f_open(...)),一旦出现异常,程序会停在出错那一行。这个写法不是给最终交付固件用的,而是帮你在实验初期把问题暴露在第一时间。
4.4 新卡格式化时的工作缓冲和分区选项
如果读到了FR_NO_FILESYSTEM,不要急着用电脑格式化。FATFS 自己可以完成格式化:
MKFS_PARM opt; BYTE work[FF_MAX_SS]; opt.fmt = FM_FAT32 | FM_SFD; opt.au_size = 0; opt.n_fat = 1; opt.align = 0; res = f_mkfs("", &opt, work, sizeof(work));FM_SFD表示不创建 MBR 分区表,整个卡当作一个超级软盘。实验用的 SD 卡容量不大,这种方式更简单,也减少了分区表偏移带来的对齐问题。n_fat设为 1 能减少 FAT 表写入次数,但对容错不利;如果实验里经常直接拔电,建议保留默认值 2。
5. 交付前我会做掉的三个验证动作
实验做到能读写文件还不够,最后要留几个可以反复执行的验证手段,让“文件系统管理”这件事可被测量。
5.1 用 f_getfree 回读容量,防止容量算错
格式化完成后,不要相信卡面上写的 8GB。用 FATFS 的卷信息回读,能同时验证 FAT 表是否被正确解析。
DWORD fre_clust = 0; FATFS *pfs = NULL; DWORD total_kb; if (f_getfree("", &fre_clust, &pfs) == FR_OK) { total_kb = (pfs->n_fatent - 2) * pfs->csize * pfs->ssize / 1024; printf("total=%lu KB, free=%lu KB\r\n", total_kb, fre_clust * pfs->csize * pfs->ssize / 1024); }这里的n_fatent是 FAT 表的表项数,减 2 是因为前两个表项保留。不同 FatFs 分支的 FATFS 结构体字段略有差异,如果是老版本移植过来的,编译报错时先回ff.h里确认一下结构体名。
5.2 用串口脚本做连续写测试
建议在实验板上写一个测试命令,循环追加 1000 条记录,每条记录带序号。上位机用脚本把日志拉回来检查序号是否连续。
#!/bin/bash expected=1 while IFS=, read -r seq rest; do if (( seq != expected )); then echo "hole at $expected, got $seq" exit 1 fi ((expected++)) done < log.txt echo "all sequence ok"这个验证能同时暴露两个问题:写多了缓冲区溢出导致丢数据,以及f_sync位置不对导致重启后日志断裂。如果测试中发现序号空洞,优先看任务栈大小,再看写文件时是不是跨了扇区边界。
5.3 掉电恢复时用临时文件再加 f_rename
对数据记录类设备,直接写正式文件名在掉电时容易留下半截文件。一个稳妥技巧是先写入同目录下的临时文件,写完后用f_rename改成正式名字。虽然 FATFS 的 rename 不是严格原子操作,但它比在正式文件中间覆盖写入更容易恢复。
具体的做法是:文件名带序号,例如REC_0001.TMP和REC_0001.DAT,每次启动先扫描.DAT文件,如果最后一个.DAT的文件头校验不对,再检查同序号的.TMP,把能用的数据补齐。这比单纯依赖 FATFS 的掉电保护更可控,也是实验中体现“管理”二字的最佳落点。
本文还有配套的精品资源,点击获取