第十一讲 FATFS + Cursor

第十一讲 FATFS + Cursor

这篇笔记想解决什么问题

这篇笔记不重点讲“现象”,而重点讲:

  • 代码为什么这样写

  • 代码为什么这样放

  • 每个函数在整个 FATFS 链路里负责什么

  • 为什么有些代码放在 flash_app.c,有些代码放在 sdio.c,有些代码放在 fatfs.c

  • 主测试函数为什么按现在这个顺序组织

也就是说,这篇笔记更关注“代码结构”和“职责划分”,希望把“能看懂代码”这件事讲清楚。


先建立整体理解

这节课真正要打通的是这一条链路:

SD卡硬件 -> SDIO驱动 -> FATFS移植层 -> 应用层测试代码

可以把它拆成 4 层:

  1. 硬件层
    SD卡 本体,卡座,数据线,时钟线,命令线

  2. 外设驱动层
    SDIO 外设初始化,比如总线模式、时钟分频这些配置

  3. FATFS 适配层
    负责把 FATFS 和底层 SD 驱动接起来

  4. 应用层
    真正去调用 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;

![代码修改截图](file:///C:/Users/24423/AppData/Roaming/Typora/typora-user-images/image-20260523194059862.png?lastModify=1779719797)

这句代码放在 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.cfatfs.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 层的公共信息。

这样放的好处

如果把 SDFatFSSDFileSDPath 都塞进 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 lfscfg

 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_writtenbytes_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()

如果你把这一段完整代码和上面的职责分析对起来看,就能更清楚地理解“为什么代码应该这样放”。