第十一讲 FATFS + Cursor
这篇笔记想解决什么问题
这篇笔记不重点讲“现象”,而重点讲:
-
代码为什么这样写
-
代码为什么这样放
-
每个函数在整个 FATFS 链路里负责什么
-
为什么有些代码放在
flash_app.c,有些代码放在sdio.c,有些代码放在fatfs.c -
主测试函数为什么按现在这个顺序组织
也就是说,这篇笔记更关注“代码结构”和“职责划分”,希望把“能看懂代码”这件事讲清楚。
先建立整体理解
这节课真正要打通的是这一条链路:
SD卡硬件 -> SDIO驱动 -> FATFS移植层 -> 应用层测试代码
可以把它拆成 4 层:
-
硬件层
SD卡本体,卡座,数据线,时钟线,命令线 -
外设驱动层
SDIO外设初始化,比如总线模式、时钟分频这些配置 -
FATFS 适配层
负责把 FATFS 和底层 SD 驱动接起来 -
应用层
真正去调用f_mount()、f_open()、f_write()、f_read()的地方
这 4 层分开之后,代码就不应该乱塞在一个文件里。
如果把这 4 层混在一起,后面就会出现两个问题:
-
看代码时不知道“这段到底是底层配置,还是业务逻辑”
-
改动一个地方时,很容易误伤本来不该动的层
所以这节课最重要的不只是 API,而是:
让每一层只做自己该做的事。
为什么 SDIO 配置放在 sdio.c
这个文件负责什么
sdio.c 的职责只有一个:
初始化 STM32 的 SDIO 外设。
也就是说,这个文件应该只关心:
-
选哪一个外设实例
-
时钟沿怎么配
-
总线宽度怎么配
-
分频怎么配
-
GPIO 怎么复用到 SDIO
它不应该关心:
-
文件系统怎么挂载
-
文件怎么打开
-
文件内容写什么
这些都不是外设驱动层该管的事。
为什么 1位初始化 放在这里
课件里强调初始化阶段先用:
hsd.Init.BusWide = SDIO_BUS_WIDE_1B;

这句代码放在 sdio.c 是合理的,因为它属于:
-
SDIO 外设初始化策略
-
硬件通信参数配置
它不属于 FATFS,也不属于应用逻辑。
换句话说:
-
“先用 1 位还是 4 位”是硬件初始化问题
-
“挂载哪个盘符、创建什么文件”才是应用层问题
这就是它应该放在 sdio.c,而不该放在 flash_app.c 的原因。
为什么课件图上选了 4 位,代码里还要先写 1 位
这个地方最容易让人迷糊。
图形界面里的 SD 4 bits Wide bus 更像是在表达“这个工程最终希望运行在 4 位模式下”。
但初始化过程本身是一个逐步建立通信的过程:
-
一开始先保守一点,让卡先识别成功
-
识别成功以后,再切到更高性能的总线模式
所以“配置目标是 4 位”和“初始化起步先 1 位”并不冲突。
一个是最终运行模式,一个是初始化策略。
为什么 FATFS 配置放在 fatfs.c / fatfs.h
这一层负责什么
fatfs.c 和 fatfs.h 的职责是:
把 FATFS 中间件和底层 SD 驱动连接起来。
这里不是做文件读写业务,而是在做“文件系统模块的公共准备工作”。
典型对象有:
-
FATFS SDFatFS -
FIL SDFile -
char SDPath[4]
这些对象为什么放在这里,而不是放在 flash_app.c?
因为它们不属于某一个测试函数,而属于整个 FATFS 模块的公共资源。
每个对象为什么放在这一层
FATFS SDFatFS
这个对象代表“文件系统对象本身”。
谁想挂载 FATFS,谁就需要它。
所以它是 FATFS 层的核心资源,不应该放到某个测试函数内部。
FIL SDFile
这个对象代表“文件对象”。
它描述当前打开文件的状态。
虽然应用层会使用它,但它的类型和意义来自 FATFS,所以统一由 FATFS 模块提供更合理。
char SDPath[4]
这个对象代表逻辑驱动器路径,比如 0:/。
应用层挂载和打开文件时都要用到它。
所以它不是某个单独函数的局部信息,而是 FATFS 层的公共信息。
这样放的好处
如果把 SDFatFS、SDFile、SDPath 都塞进 flash_app.c:
-
这个应用文件就会变得过重
-
其他地方如果也想用 FATFS,就没法方便复用
-
模块边界会很乱
放在 fatfs.c / fatfs.h 的好处是:
-
FATFS 资源集中管理
-
应用层只负责“使用”,不负责“定义整套中间件对象”
-
后面如果别的模块也要访问 SD 卡,更容易复用
这就是典型的“模块归模块,应用归应用”。
为什么应用逻辑放在 flash_app.c
这个文件现在的角色是什么
flash_app.c 现在不再只是“Flash 测试”,而是承担了一个更偏应用层的角色:
把课件里的 FATFS 操作流程写成一个可以直接跑的测试函数。
也就是说,这个文件不是在定义 FATFS 中间件本身,而是在“使用 FATFS”。
所以像下面这些代码,就适合放在这里:
-
f_mount() -
f_mkdir() -
f_open() -
f_write() -
f_read() -
f_readdir()
因为这些都是“业务动作”,不是“底层驱动定义”。
为什么不是把这些都写到 main.c
理论上也能写进 main.c,但那样会让 main.c 越来越重。
main.c 更适合放系统初始化和主循环,不适合承载具体功能测试细节。
把 FATFS 流程单独放到 flash_app.c 的好处是:
-
main.c更干净 -
存储相关逻辑集中在一个模块里
-
以后要替换测试流程或扩展功能时更好改
所以这里不是“能不能写到 main 里”的问题,而是“写在哪里更清楚”的问题。
flash_app.h 为什么只放函数声明
头文件现在很轻,只保留了:
void lfs_basic_test(void);
void fatfs_basic_test(void);
void test_spi_flash(void);
这样放是合理的,因为头文件的职责应该是:
-
向外暴露接口
-
告诉别的
.c文件“这个模块能做什么”
而不应该在头文件里塞很多实现细节。
如果把大量 FATFS 逻辑、局部实现、复杂变量定义全塞进头文件,会带来几个问题:
-
依赖关系变乱
-
编译耦合变重
-
以后改实现时容易牵连更多文件
所以这里让 .h 只暴露函数,让 .c 负责具体实现,是标准做法。
flash_app.c 里的代码是怎么分层的
1. 头文件引用
#include "flash_app.h"
#include "mydefine.h"
#include "fatfs.h"
这三行可以这样理解:
-
flash_app.h:当前模块自己的对外接口 -
mydefine.h:项目里公共依赖的汇总入口 -
fatfs.h:当前模块要用到 FATFS 的对象和接口
这里的放置逻辑是“谁被使用,就包含谁”。
flash_app.c 要调用 FATFS,所以它必须显式依赖 fatfs.h。
这样做的好处是依赖关系清楚,别人一看开头就知道:这个文件在和 FATFS 打交道。
2. 保留 lfs_t lfs 和 cfg
lfs_t lfs;
struct lfs_config cfg;
这两个对象还保留着,不是因为现在要继续用 LittleFS 做主测试,而是因为项目里其他部分还可能依赖它们。
也就是说,这里的思路不是“立刻把旧系统全删光”,而是:
-
先让 FATFS 跑起来
-
同时不把项目里其他还没迁移的依赖打断
这是一个很典型的渐进式改法。
这种做法比“为了干净一次性全删”更稳,因为当前阶段你的目标是先把课件这条线接通。
这里还能看出一个工程习惯:
新逻辑接入时,先保证兼容,再考虑清理历史包袱。
为什么单独写 fatfs_list_dir()
static void fatfs_list_dir(const char *path)
这个函数被单独拆出来,而不是直接塞进 fatfs_basic_test() 里,原因主要有三个。
第一,它是一个独立功能
列目录和读写文件虽然都属于 FATFS,但职责不一样:
-
fatfs_basic_test()负责整体测试流程 -
fatfs_list_dir()负责目录遍历
这两个职责拆开以后,代码会更清楚。
第二,它以后可以复用
如果以后别的地方也想列目录,比如:
-
shell 命令
-
文件浏览菜单
-
调试输出
那么 fatfs_list_dir() 可以直接复用,不需要再从测试函数里剪代码出来。
第三,主流程更清晰
如果把目录遍历细节全塞到 fatfs_basic_test() 里,这个函数会变得很长,主线会被打散。
拆出来以后,主函数保留的是:
-
挂载
-
建目录
-
打开文件
-
写入
-
读取
-
校验
-
调用列目录函数
-
卸载
这样读起来更像“流程图”。
为什么 fatfs_list_dir() 要定义成 static
这里写成:
static void fatfs_list_dir(const char *path)
static 的意思是:这个函数只在当前 .c 文件内部使用。
它不需要对外暴露,因为它只是 flash_app.c 里服务主测试流程的一个内部工具。
这样做的好处是:
-
限定作用域
-
减少外部模块误用
-
让头文件保持简洁
这是一种很好的习惯:
只把真正需要给别人用的函数放到头文件里,内部辅助函数尽量留在
.c文件内部。
为什么 fatfs_basic_test() 这样写
1. 它是流程函数,不是工具函数
void fatfs_basic_test(void)
这个函数本质上是一个“演示 FATFS 全流程”的入口函数,所以它写成顺序执行是合理的。
它不是那种需要高度抽象的底层库函数,因此没必要一开始就拆成很多碎片化小函数。
这和课堂代码的目标一致:
-
可读
-
能跑
-
能看懂 FATFS 顺序
所以这里采用“顺着课件流程往下写”的方式,其实是教学友好的写法。
2. 为什么变量定义放在函数开头
FRESULT res;
UINT bytes_written = 0;
UINT bytes_read = 0;
char read_buffer[256];
const char write_buffer[] = "MiCu FATFS SD test message";
const char *test_dir = "0:/fatfs_demo";
const char *test_file = "0:/SD_TEST.TXT";
这些变量放在函数开头,是为了让“这次测试要用到哪些资源”一眼看清楚。
每个变量的作用也不一样:
-
res:接收每一步 FATFS API 的返回状态 -
bytes_written:记录实际写入了多少字节 -
bytes_read:记录实际读取了多少字节 -
read_buffer:读文件时放数据的缓冲区 -
write_buffer:准备写入文件的测试内容 -
test_dir:测试目录路径 -
test_file:测试文件路径
这里最值得注意的是:
FATFS 不只是告诉你“函数成功还是失败”,还常常需要你额外检查“实际读写了多少字节”。
所以 bytes_written 和 bytes_read 不是可有可无,而是 FATFS 文件操作里很重要的配套信息。
3. 为什么先打印测试开始信息
my_printf(&huart1, "\r\n--- SD FATFS Test Start ---\r\n");
这行虽然看起来只是打印,但它的作用其实很明确:
-
标记流程入口
-
让串口输出更好读
-
方便你知道后面所有输出属于哪一个测试阶段
对于嵌入式调试来说,这种“流程边界打印”是很有价值的,不只是为了好看。
4. 为什么先 f_mount()
res = f_mount(&SDFatFS, (TCHAR const *)SDPath, 1);
它放在最前面,因为整个 FATFS 流程必须先有“文件系统已经挂载成功”这个前提。
也就是说,后面的 f_open()、f_write()、f_read() 都依赖它。
这不是单纯的代码顺序问题,而是依赖关系问题。
这里的 1 表示立即挂载,不是延迟到后面第一次访问时再挂载。
这样做更适合测试函数,因为你希望尽早知道挂载是否成功。
5. 为什么挂载失败就直接 return
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: mount failed (%d)\r\n", res);
return;
}
因为挂载失败以后,后面的所有文件操作都失去前提了。
继续往下执行没有意义,反而会让调试信息更乱。
这其实体现的是一个很重要的流程控制思想:
前置条件没成立,就立刻退出,不做无意义的后续动作。
6. 为什么 f_mkdir() 放在前面
res = f_mkdir(test_dir);
这个动作放在文件读写前面,是因为目录结构本身也是文件系统的一部分。
先建目录,意味着测试不只验证“能不能写文件”,还验证“目录操作有没有通”。
这比只做一个 open + write 更完整。
同时它还有一个课堂上的好处:
让你意识到 FATFS 不只是“文件 API”,它也包含“目录 API”。
7. 为什么 FR_EXIST 要单独判断
else if (res == FR_EXIST)
因为“目录已存在”不等于“出错不能继续”。
如果每次运行程序都把已存在目录当成失败,测试就不够友好。
单独分出这个分支,体现的是:
-
不是所有非
FR_OK都是同一类问题 -
有些返回值代表异常
-
有些返回值代表“当前状态可接受”
这也是 FATFS 代码里一个很重要的阅读点:
要学会区分“错误”和“特殊但可接受的状态”。
8. 为什么 f_open() 用 FA_CREATE_ALWAYS | FA_WRITE
res = f_open(&SDFile, test_file, FA_CREATE_ALWAYS | FA_WRITE);
这个模式组合的意思是:
-
以写方式打开文件
-
如果文件不存在,就创建
-
如果文件已经存在,就覆盖
这很适合做测试代码,因为测试希望每次都从一个确定状态开始。
如果不用覆盖模式,可能会因为旧文件内容残留而影响理解。
9. 为什么 f_write() 后不仅检查 res,还检查字节数
if (res != FR_OK || bytes_written != strlen(write_buffer))
这是一个很值得学的点。
因为 FATFS 写文件时,函数返回成功不一定就代表你想写的全部内容都真的写进去了。
所以这里要检查两层:
-
API 状态是否成功
-
实际写入字节数是否等于预期字节数
这体现的是一种更稳的写法,而不是只图“能编译能跑”。
10. 为什么写完后先 f_close() 再读
res = f_close(&SDFile);
写入完成后先关闭文件,再重新打开读,是一个很有意义的安排。
因为这样验证的是:
-
写入过程本身没问题
-
关闭后数据已经真正回到文件系统
-
重新打开后还能正常读出
如果不关闭,直接在同一个打开状态下读,有时不够直观,也不利于理解完整流程。
11. 为什么读之前要先 memset()
memset(read_buffer, 0, sizeof(read_buffer));
这一步是为了把读缓冲区先清空。
这样后面如果读取到的字符串比较短,也不会被旧残留数据干扰显示。
这属于“让结果更干净、便于观察”的处理。
12. 为什么读完后补 \0
read_buffer[bytes_read] = '\0';
因为 FATFS 读出来的是字节数据,不会自动帮你补上 C 字符串结束符。
如果你后面想把它当字符串打印,就必须自己补 \0。
这一步特别值得记住,因为它体现了一个基础事实:
文件读写 API 处理的是字节,不是天然字符串。
13. 为什么要比较读写内容
if (strcmp(read_buffer, write_buffer) == 0)
这一步不是多余的。
它的作用是把“写成功了”和“真正写对了”区分开。
因为:
-
API 返回成功,不等于内容一定正确
-
真正可靠的测试应该包含校验
所以这一步是在做“结果验证”,而不只是“动作调用”。
14. 为什么最后调用 fatfs_list_dir()
fatfs_list_dir("0:/");
因为前面已经完成了目录和文件操作,这时候列目录能够进一步验证:
-
目录是否真的存在
-
文件是否真的创建成功
-
文件大小是否符合预期
它让这次测试从“只验证一个文件”扩展成“验证文件系统结构能不能被正确看到”。
15. 为什么使用 goto unmount_fs
代码里多次出现:
goto unmount_fs;
在很多普通程序里,大家会尽量少用 goto。
但在这种“多步流程 + 统一收尾动作”的代码里,goto 反而是比较实用的。
因为这里的目标很明确:
-
中间任何一步失败,都跳到统一的卸载位置
-
避免在每个错误分支里重复写一遍收尾逻辑
所以这里的 goto 不是乱跳,而是在做“统一出口管理”。
这类写法在嵌入式和底层 C 代码里其实很常见。
16. 为什么最后卸载
res = f_mount(NULL, (TCHAR const *)SDPath, 0);
把卸载放在最后,是为了让整套测试形成闭环。
这体现的是一个完整资源生命周期:
-
挂载
-
使用
-
结束
-
卸载
从学习角度看,这样更容易形成正确的文件系统使用习惯。
从代码组织角度看,这也让这个测试函数有一个明确的“结尾动作”。
为什么保留 lfs_basic_test() 这个名字
现在代码里是:
void lfs_basic_test(void)
{
fatfs_basic_test();
}
这种写法的核心目的不是“语义完美”,而是“减少联动修改”。
因为当前项目里,别的地方已经在调用 lfs_basic_test()。
如果为了名字完全准确,立刻把所有调用点一起改掉,就会把这次任务从“切应用层到 FATFS”扩大成“全项目接口重构”。
这在当前阶段没有必要。
所以这里保留旧入口名,本质上是在做接口兼容。
这是很实用的工程思路:
-
先保证新逻辑能接入
-
再考虑后续是否统一改名
为什么这份代码没有把所有逻辑都抽得很碎
从工程角度看,这份代码没有继续拆成很多更小的函数,是因为当前目标更偏:
-
课堂练习
-
流程演示
-
快速验证
如果后面项目继续扩大,才更适合再做这些拆分:
-
单独提炼
mount逻辑 -
单独提炼
read/write工具函数 -
单独提炼错误码打印函数
-
单独提炼路径和文件名配置
也就是说,当前代码结构是“够清楚、够能跑、够贴近课件”的版本,而不是“最终抽象版”。
这其实很符合当前学习阶段。
这节课我应该学会的代码思维
这节课最值得学的,不只是几个 API 名字,而是这种分层思维:
-
sdio.c管硬件初始化 -
fatfs.c/.h管文件系统对象和驱动连接 -
flash_app.c管应用层测试流程 -
flash_app.h只暴露接口
这说明一个好习惯:
不同层只做自己该做的事。
如果这个习惯建立起来,以后无论是写 FATFS、LittleFS、串口、SPI、UI,代码都会更清楚。
这节笔记的结论
这节课真正重要的不是“把 SD 卡跑起来”这一件事,而是理解:
为什么文件系统代码不能和底层初始化代码混在一起,为什么应用层测试又不能和 FATFS 对象定义混在一起。
这次代码的放置逻辑可以总结成一句话:
底层配置放底层,模块资源放模块,流程测试放应用层,头文件只暴露接口。
如果把这句话真正理解了,这节课就不只是学会几个 FATFS API,而是开始形成“代码应该怎么组织”的感觉。
完整代码
下面把这次笔记里最核心的代码完整放出来,方便直接对照看。
flash_app.h
#ifndef __FLASH_APP_H
#define __FLASH_APP_H
void lfs_basic_test(void);
void fatfs_basic_test(void);
void test_spi_flash(void);
#endif // __FLASH_APP_H
这个头文件很简单,重点就是“只暴露接口,不放实现细节”。
你从这里一眼就能看出这个模块对外提供了什么能力,而不用先钻进 .c 文件。
flash_app.c
#include "flash_app.h"
#include "mydefine.h"
#include "fatfs.h"
/* Keep these globals for the existing LittleFS-based callers in main/shell. */
lfs_t lfs;
struct lfs_config cfg;
static void fatfs_list_dir(const char *path)
{
DIR dir;
FILINFO file_info;
FRESULT res;
res = f_opendir(&dir, path);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: open dir failed (%d): %s\r\n", res, path);
return;
}
my_printf(&huart1, "FATFS: listing %s\r\n", path);
while (1)
{
res = f_readdir(&dir, &file_info);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: read dir failed (%d)\r\n", res);
break;
}
if (file_info.fname[0] == '\0')
{
break;
}
if (file_info.fattrib & AM_DIR)
{
my_printf(&huart1, " [DIR ] %s\r\n", file_info.fname);
}
else
{
my_printf(&huart1,
" [FILE] %s (%lu bytes)\r\n",
file_info.fname,
(unsigned long)file_info.fsize);
}
}
f_closedir(&dir);
}
void fatfs_basic_test(void)
{
FRESULT res;
UINT bytes_written = 0;
UINT bytes_read = 0;
char read_buffer[256];
const char write_buffer[] = "MiCu FATFS SD test message";
const char *test_dir = "0:/fatfs_demo";
const char *test_file = "0:/SD_TEST.TXT";
my_printf(&huart1, "\r\n--- SD FATFS Test Start ---\r\n");
res = f_mount(&SDFatFS, (TCHAR const *)SDPath, 1);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: mount failed (%d)\r\n", res);
return;
}
my_printf(&huart1, "FATFS: mount success, path=%s\r\n", SDPath);
res = f_mkdir(test_dir);
if (res == FR_OK)
{
my_printf(&huart1, "FATFS: mkdir success: %s\r\n", test_dir);
}
else if (res == FR_EXIST)
{
my_printf(&huart1, "FATFS: dir already exists: %s\r\n", test_dir);
}
else
{
my_printf(&huart1, "FATFS: mkdir failed (%d): %s\r\n", res, test_dir);
}
res = f_open(&SDFile, test_file, FA_CREATE_ALWAYS | FA_WRITE);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: open for write failed (%d): %s\r\n", res, test_file);
goto unmount_fs;
}
res = f_write(&SDFile, write_buffer, strlen(write_buffer), &bytes_written);
if (res != FR_OK || bytes_written != strlen(write_buffer))
{
my_printf(&huart1,
"FATFS: write failed (%d), written=%u expected=%u\r\n",
res,
(unsigned int)bytes_written,
(unsigned int)strlen(write_buffer));
f_close(&SDFile);
goto unmount_fs;
}
my_printf(&huart1, "FATFS: write success, bytes=%u\r\n", (unsigned int)bytes_written);
res = f_close(&SDFile);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: close after write failed (%d)\r\n", res);
goto unmount_fs;
}
memset(read_buffer, 0, sizeof(read_buffer));
res = f_open(&SDFile, test_file, FA_READ);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: open for read failed (%d): %s\r\n", res, test_file);
goto unmount_fs;
}
res = f_read(&SDFile, read_buffer, sizeof(read_buffer) - 1U, &bytes_read);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: read failed (%d)\r\n", res);
f_close(&SDFile);
goto unmount_fs;
}
read_buffer[bytes_read] = '\0';
my_printf(&huart1, "FATFS: read success, bytes=%u\r\n", (unsigned int)bytes_read);
my_printf(&huart1, "FATFS: file content: %s\r\n", read_buffer);
if (strcmp(read_buffer, write_buffer) == 0)
{
my_printf(&huart1, "FATFS: data verify success\r\n");
}
else
{
my_printf(&huart1, "FATFS: data verify failed\r\n");
}
f_close(&SDFile);
fatfs_list_dir("0:/");
unmount_fs:
res = f_mount(NULL, (TCHAR const *)SDPath, 0);
if (res != FR_OK)
{
my_printf(&huart1, "FATFS: unmount failed (%d)\r\n", res);
}
else
{
my_printf(&huart1, "FATFS: unmount success\r\n");
}
my_printf(&huart1, "--- SD FATFS Test End ---\r\n");
}
void lfs_basic_test(void)
{
fatfs_basic_test();
}
void test_spi_flash(void)
{
my_printf(&huart1, "SPI Flash test is not used in FATFS mode.\r\n");
}
这里最值得反复看的不是某一行 API,而是这份代码的组织方式:
-
头部
#include先明确依赖来源 -
全局对象只保留当前项目还需要兼容的内容
-
目录遍历拆成单独函数
-
主测试流程集中放在
fatfs_basic_test() -
对外兼容入口继续保留
lfs_basic_test()
如果你把这一段完整代码和上面的职责分析对起来看,就能更清楚地理解“为什么代码应该这样放”。