第十讲 Flash + 文件系统 + Shell

第十讲 Flash + 文件系统 + Shell

以西门子 V2 开发板为基础。

image-20260519201523395

1. 为什么要学这一讲

单片机内部 Flash 容量有限,适合存放程序,但不太适合长期保存大量运行数据。
当我们想保存下面这些内容时,就经常需要外接 SPI Flash:

  • 配置参数

  • 日志数据

  • 历史记录

  • 波形数据

  • 用户文件

这节课的主线不是单独学一个器件,而是理解一条完整链路:

SPI Flash 硬件驱动 -> 文件系统 LittleFS -> Shell 命令交互

也就是说,先让 MCU 能直接读写 Flash,再让 Flash 具备“文件”和“目录”的管理能力,最后通过串口 Shell 用命令去操作这些文件。


2. SPI Flash 基础

2.1 主要特性概览

  • 接口类型: 标准 SPI、Dual SPI、Quad SPI。我们这次主要使用标准 SPI。

  • 工作电压: 常见为 2.7V ~ 3.6V

  • 容量范围: GD25Q 系列从几 Mbit 到几百 Mbit 不等。

  • 存储结构: 一般按 Block(块)Sector(扇区)Page(页) 管理。

  • 擦除单位: 通常以扇区或块为单位擦除。

  • 写入单位: 通常按页写入。

  • 指令集: 通过固定命令字完成读、写、擦除、读状态寄存器等操作。

对于本工程,后面会反复接触两个关键参数:

  • Sector = 4KB

  • Page = 256B

这两个值不仅影响裸 Flash 驱动,也会直接影响 LittleFS 的移植配置。

2.2 SPI 的四根常见信号线

标准 SPI 通信一般包含 4 根线:

  • CS / SS: 片选信号,低电平有效。

  • SCK: 时钟线,由主机提供。

  • MOSI: 主机发给从机的数据线。

  • MISO: 从机发给主机的数据线。

其中最容易忽略的是 CS。很多 SPI 外设不是“发一串字节就行”,而是要求:

  1. 先拉低 CS

  2. 发送命令

  3. 发送地址

  4. 读或写数据

  5. 拉高 CS

如果 CS 时序不对,器件就可能不响应。

2.3 什么叫全双工

SPI 是一种同步、全双工通信方式。

“全双工”的意思是:

  • 主机发送 1 个字节的同时

  • 从机也会回送 1 个字节

也就是说,SPI 每打一拍时钟,主从双方都在交换 1 bit 数据。

所以很多读操作本质上其实是:

  • 主机先发命令和地址

  • 然后继续发送“无意义字节”

  • 用这些时钟把从机里的数据读出来

在代码里,这个“无意义字节”通常叫 dummy byte

2.4 SPI 工作模式

SPI 一共有 4 种模式,由 CPOLCPHA 组合决定:

  • 模式 0: CPOL=0, CPHA=0

  • 模式 1: CPOL=0, CPHA=1

  • 模式 2: CPOL=1, CPHA=0

  • 模式 3: CPOL=1, CPHA=1

可以先把它理解成两个问题:

  • 时钟空闲时是高还是低

  • 数据在哪个边沿采样

关键点:主机模式必须和从机模式匹配。
如果 Flash 芯片要求模式 0,而你把 SPI 配成模式 3,就会出现读 ID 错误、读写失败等问题。

GD25QXX 系列常见支持模式 0 和模式 3,但实际使用时还是要以芯片手册为准。

image-20260520202758659

image-20260520202702548

image-20260519213908033


3. 在工程里如何驱动 GD25QXX

本工程里,SPI Flash 驱动的核心接口在:

  • Components/GD25QXX/gd25qxx.h

  • Components/GD25QXX/gd25qxx.c

头文件中已经定义了一组比较典型的底层接口:

 void spi_flash_init(void);
 void spi_flash_sector_erase(uint32_t sector_addr);
 void spi_flash_bulk_erase(void);
 void spi_flash_page_write(uint8_t *pbuffer, uint32_t write_addr, uint16_t num_byte_to_write);
 void spi_flash_buffer_write(uint8_t *pbuffer, uint32_t write_addr, uint16_t num_byte_to_write);
 void spi_flash_buffer_read(uint8_t *pbuffer, uint32_t read_addr, uint16_t num_byte_to_read);
 uint32_t spi_flash_read_id(void);
 void spi_flash_write_enable(void);
 void spi_flash_wait_for_write_end(void);

3.1 这些函数分别在干什么

  • spi_flash_init():初始化 Flash 通信相关状态,重点是把 CS 先恢复到空闲电平。

  • spi_flash_read_id():读取芯片 ID,用来确认 SPI 通信是否正常。

  • spi_flash_sector_erase(addr):擦除一个扇区。

  • spi_flash_page_write(...):按页写入,适合页内连续写。

  • spi_flash_buffer_write(...):更高一层,能处理跨页写入。

  • spi_flash_buffer_read(...):从指定地址读出一段数据。

  • spi_flash_write_enable():写入前先开写使能。

  • spi_flash_wait_for_write_end():等待写或擦除真正完成。

3.2 为什么写入前要先擦除

NOR Flash 有一个很重要的特点:

  • 擦除后数据通常是 0xFF

  • 写入时只能把位从 1 改成 0

  • 不能直接把 0 改回 1

所以如果某个区域已经写过数据,想重新写,通常要先擦除。

这也是为什么测试流程基本都是:

  1. 读 ID

  2. 擦除扇区

  3. 写数据

  4. 读回来验证

例如底层驱动里,擦除和等待写结束是这样配合的:

 void spi_flash_sector_erase(uint32_t sector_addr)
 {
     spi_flash_write_enable();
 ​
     SPI_FLASH_CS_LOW();
     spi_flash_send_byte(SE);
     spi_flash_send_byte((sector_addr & 0xFF0000) >> 16);
     spi_flash_send_byte((sector_addr & 0xFF00) >> 8);
     spi_flash_send_byte(sector_addr & 0xFF);
     SPI_FLASH_CS_HIGH();
 ​
     spi_flash_wait_for_write_end();
 }

这里可以这样理解:

  • spi_flash_write_enable():告诉 Flash,后面要进行写/擦操作

  • SE:发送扇区擦除命令

  • 后面 3 个字节:发送 24 位地址

  • spi_flash_wait_for_write_end():不断查询忙标志,确保擦除真的结束

3.3 本工程里的裸 Flash 测试流程

APP/flash_app.c 里有一个 test_spi_flash(),它把最基础的 SPI Flash 读写过程串起来了。

先看它前半段最关键的测试骨架:

 spi_flash_init();
 flash_id = spi_flash_read_id();
 ​
 spi_flash_sector_erase(test_addr);
 spi_flash_buffer_read(read_buffer, test_addr, SPI_FLASH_PAGE_SIZE);

这一小段对应的就是:

  • 初始化 SPI Flash

  • 读取芯片 ID,确认通信正常

  • 擦除测试区域

  • 再读一遍,确认擦除后是不是 0xFF

它做的事情非常适合作为理解模板:

  1. spi_flash_init() 初始化驱动

  2. spi_flash_read_id() 读取芯片 ID

  3. spi_flash_sector_erase(test_addr) 擦除测试扇区

  4. spi_flash_buffer_read(...) 检查是否全为 0xFF

  5. 准备一段字符串写入缓冲区

  6. spi_flash_buffer_write(...) 写入一页数据

  7. spi_flash_buffer_read(...) 再读回来

  8. memcmp(...) 比较写入和读出的结果是否一致

这个测试的意义不是“做完就结束”,而是它证明了:

  • SPI 时序是通的

  • Flash 命令发送是通的

  • 擦除逻辑是通的

  • 写入逻辑是通的

  • 读回校验是通的

只有这一步稳定了,后面上文件系统才有基础。

后半段写入和校验也很典型:

 spi_flash_buffer_write(write_buffer, test_addr, SPI_FLASH_PAGE_SIZE);
 ​
 memset(read_buffer, 0, SPI_FLASH_PAGE_SIZE);
 spi_flash_buffer_read(read_buffer, test_addr, SPI_FLASH_PAGE_SIZE);
 ​
 if (memcmp(write_buffer, read_buffer, SPI_FLASH_PAGE_SIZE) == 0)
 {
     my_printf(&huart1, "Data VERIFIED! Write and Read successful.\r\n");
 }

这段代码的重点不是语法,而是流程:

  • 先把准备好的数据写进去

  • 再完整读回来

  • 最后用 memcmp() 做整页比较

这就是最基础的“写入后回读校验”。


4. 为什么还要引入文件系统

如果只使用裸 Flash 地址读写,会遇到几个问题:

  • 需要自己规划每段地址存什么

  • 需要自己处理跨页、跨扇区问题

  • 需要自己管理文件长度

  • 需要自己避免数据覆盖

  • 需要自己考虑掉电可靠性和磨损均衡

这时就会发现:

裸 Flash 更像“原始存储介质”,而不是“可以方便使用的文件空间”。

文件系统的作用,就是把底层的块设备封装成更好用的逻辑结构,比如:

  • 目录

  • 文件

  • 文件读写

  • 重命名

  • 删除

  • 空间管理

这样一来,我们就不需要天天手算地址 0x0000000x0010000x002000 存什么,而是可以直接操作:

  • /boot/boot_cnt.txt

  • /config/sys.cfg

  • /log/data.txt


5. 为什么选 LittleFS

本工程里使用的是 LittleFS

LittleFS 比较适合单片机场景,原因可以先记住这几个:

  • 面向小型嵌入式设备

  • 支持掉电恢复

  • 适合 NOR Flash / SPI Flash

  • 自带一定的磨损均衡思想

  • 比直接自己写“伪文件系统”更可靠

对我们来说,最重要的不是把 LittleFS 的所有内部原理都吃透,而是先明白:

LittleFS 并不直接操作硬件,它需要你提供一层“底层块设备接口”。

也就是:

  • 怎么读一个块

  • 怎么写一个块

  • 怎么擦一个块

  • 怎么同步

只要你把这层接口接到自己的 Flash 驱动上,LittleFS 就能跑起来。


6. 本工程里的 LittleFS 移植

这一层你现在不用把源码细节背下来,先抓住作用就够了。

LittleFS 要想跑起来,必须先有一层“底层适配”,把文件系统操作翻译成 Flash 的读、写、擦除。

你可以把这层理解成一句话:

LittleFS 不直接操作硬件,它是通过一层适配,把文件系统请求交给 SPI Flash 驱动完成。

这里当前阶段最值得记的只有两个点:

  • Flash 的块大小、页大小、总容量这些参数,要和实际芯片一致

  • LittleFS 的底层读写擦,最终还是会落到 spi_flash_buffer_read()spi_flash_buffer_write()spi_flash_sector_erase() 这些函数上

所以这一层的学习目标不是“把 lfs_port.c 默写下来”,而是知道:

  • 它是文件系统和硬件之间的桥梁

  • 没有这层,LittleFS 就不知道怎么访问你的外部 Flash


7. 本工程里的 LittleFS 基本测试

APP/flash_app.c 中,除了裸 Flash 测试函数,还有一个 lfs_basic_test()

这个函数展示了文件系统真正开始“可用”之前,要经历的几个关键步骤。

7.1 挂载失败就格式化

核心逻辑是:

 err = lfs_mount(&lfs, &cfg);
 if (err) {
     lfs_format(&lfs, &cfg);
     lfs_mount(&lfs, &cfg);
 }

意思是:

  • 如果文件系统已经存在,就直接挂载

  • 如果是第一次上电,或者原有数据无效,就先格式化,再重新挂载

这就是很多嵌入式存储系统的常见套路:

先 mount,不行再 format,然后重新 mount。

你工程里的代码就是这样写的:

 int err = lfs_mount(&lfs, &cfg);
 if (err)
 {
     my_printf(&huart1, "LFS: Mount failed(%d), formatting...\n", err);
     if (lfs_format(&lfs, &cfg) || (err = lfs_mount(&lfs, &cfg)))
     {
         my_printf(&huart1, "LFS: Format/Mount failed(%d)!\n", err);
         return;
     }
 }

可以把它记成一句话:

  • 有文件系统就挂载

  • 没有就先格式化

  • 格式化完再挂载一次

7.2 创建目录和文件

测试函数里接着做了两件事:

  1. 创建目录 boot

  2. 打开文件 boot/boot_cnt.txt

这里用到的思路很典型:

  • 目录不存在就创建

  • 文件不存在就创建

  • 文件存在就继续读写

对应代码片段如下:

 err = lfs_mkdir(&lfs, "boot");
 ​
 lfs_file_t file;
 const char *filename = "boot/boot_cnt.txt";
 err = lfs_file_open(&lfs, &file, filename, LFS_O_RDWR | LFS_O_CREAT);

这段代码很适合帮助你建立“文件系统思维”:

  • lfs_mkdir() 是在创建目录

  • lfs_file_open(..., LFS_O_CREAT) 是文件不存在时自动创建

  • 这里已经不是在操作某个固定地址,而是在操作一个路径

7.3 用文件保存启动次数

这部分很适合作为“文件系统为什么比裸地址操作更好用”的例子。

逻辑是:

  1. 读取 boot_cnt.txt

  2. 如果文件为空或读取失败,就把计数从 0 开始

  3. boot_count++

  4. 重新写回文件

这样每次系统启动,都会把启动次数保存在 Flash 中。

最核心的读写片段是:

 lfs_ssize_t r_sz = lfs_file_read(&lfs, &file, &boot_count, sizeof(boot_count));
 boot_count++;
 lfs_file_rewind(&lfs, &file);
 lfs_file_write(&lfs, &file, &boot_count, sizeof(boot_count));

这一小段要读懂的是:

  • 先从文件里把原来的启动次数读出来

  • 让计数 +1

  • lfs_file_rewind() 把文件读写位置移回开头

  • 再把新计数写回去

这个例子说明了文件系统的价值:

  • 你不再关心数据放在第几个扇区

  • 你只关心某个文件里保存了什么

7.4 打印文件系统结构

lfs_basic_test() 里还调用了 list_dir_recursive("/", 0),递归列出目录树。

它背后的核心结构大概是:

 int res = lfs_dir_read(&lfs, &dir, &info);
 if (info.type == LFS_TYPE_DIR)
 {
     list_dir_recursive(full_path, level + 1);
 }
 else
 {
     my_printf(&huart1, "+-- [FILE] %s (%lu bytes)\r\n", info.name, (unsigned long)info.size);
 }

也就是说:

  • 如果读到的是目录,就继续递归进去

  • 如果读到的是文件,就直接打印文件名和大小

这和电脑里的“树状目录遍历”思路是一样的。

这一步的作用主要有两个:

  • 验证目录和文件是否真的创建成功

  • 让我们更直观地看到当前文件系统结构


8. Shell 是怎么接到文件系统上的

到这里为止,文件系统已经能工作了,但如果每次想测试一个文件操作都要重新改代码、重新下载程序,使用起来还是不够方便。

所以本工程又在 LittleFS 之上封装了一层串口 Shell,让这些文件操作可以直接通过命令来完成。

相关文件:

  • APP/shell_app.h

  • APP/shell_app.c

8.1 Shell 的本质

Shell 可以理解成一个“命令解释器”。

它负责做三件事:

  1. 从串口接收用户输入的字符

  2. 把一整行命令解析成 argc/argv

  3. 调用对应的命令处理函数

例如输入:

 mkdir boot

Shell 会识别出:

  • 命令名:mkdir

  • 参数:boot

然后进一步调用内部的 cmd_mkdir()

8.2 本工程已经实现的命令

当前这份 Shell 已经支持这些命令:

  • help

  • ls

  • cd

  • pwd

  • cat

  • mkdir

  • rm

  • touch

  • mv

  • cp

  • echo

  • clear

  • write

这说明你的工程已经不是“只有一个测试接口”,而是已经能像一个小型命令行文件系统一样工作。

这些命令在代码里就是一个命令表:

 static const shell_command_t commands[] = {
     {"help", "Display help information", cmd_help},
     {"ls", "List directory contents", cmd_ls},
     {"cd", "Change current directory", cmd_cd},
     {"pwd", "Print working directory", cmd_pwd},
     {"cat", "Display file contents", cmd_cat},
     {"mkdir", "Create directory", cmd_mkdir},
     {"write", "Write text to file", cmd_write},
 };

这个表的作用很直观:

  • 第 1 列是命令名字

  • 第 2 列是帮助信息

  • 第 3 列是这个命令真正对应的处理函数

8.3 这些命令最终调用的是谁

这些 Shell 命令本质上并不是直接操作硬件,而是调用 LittleFS 的文件系统接口,例如:

  • ls 会用 lfs_dir_open()lfs_dir_read()

  • cat 会用 lfs_file_open()lfs_file_read()

  • mkdir 会用 lfs_mkdir()

  • rm 会用 lfs_remove()

  • mv 会用 lfs_rename()

  • cp 会组合 lfs_file_read()lfs_file_write()

  • write 会用 lfs_file_open()lfs_file_write()

所以这三层关系一定要分清:

  • 最底层: spi_flash_xxx 直接操作外部 Flash

  • 中间层: lfs_xxx 把 Flash 变成文件系统

  • 最上层: shell_xxx / cmd_xxx 让用户通过串口命令操作文件系统

Shell 接收到一行命令后,会先做命令分发:

 int shell_execute(const char *cmd_line)
 {
     char cmd_copy[SHELL_MAX_COMMAND_LENGTH];
     char *argv[SHELL_MAX_ARGS];
 ​
     strncpy(cmd_copy, cmd_line, sizeof(cmd_copy) - 1);
     cmd_copy[sizeof(cmd_copy) - 1] = '\0';
 ​
     int argc = shell_parse_args(cmd_copy, argv);
     if (argc == 0)
     {
         return 0;
     }
 ​
     for (int i = 0; i < NUM_COMMANDS; i++)
     {
         if (strcmp(argv[0], commands[i].name) == 0)
         {
             return commands[i].function(argc, argv);
         }
     }
 ​
     shell_printf("Unknown command: %s\r\n", argv[0]);
     return -1;
 }

这段代码的含义就是:

  • 先把一整行命令拆成参数

  • argv[0] 是命令名

  • 在命令表里逐个匹配

  • 找到后执行对应函数

比如 cat test.txt 最终就会走到 cmd_cat()

再看一个最典型的文件读取命令:

 static int cmd_cat(int argc, char *argv[])
 {
     char *file_path = shell_normalize_path(argv[1]);
     lfs_file_t file;
     int res = lfs_file_open(shell_state.fs, &file, file_path, LFS_O_RDONLY);
 ​
     while ((bytes_read = lfs_file_read(shell_state.fs, &file, buffer, SHELL_MAX_LINE_LEN)) > 0)
     {
         buffer[bytes_read] = '\0';
         shell_printf("%s", buffer);
     }
 ​
     lfs_file_close(shell_state.fs, &file);
     return (bytes_read >= 0) ? 0 : -1;
 }

这个命令的流程就是:

  • 先规范化路径

  • 用只读方式打开文件

  • 循环读取一小段一小段内容

  • 把内容打印到串口

写文件命令也很适合看:

 static int cmd_write(int argc, char *argv[])
 {
     char *file_path = shell_normalize_path(argv[1]);
     lfs_file_t file;
     int res = lfs_file_open(shell_state.fs, &file, file_path, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC);
 ​
     for (int i = 2; i < argc; i++)
     {
         lfs_file_write(shell_state.fs, &file, argv[i], strlen(argv[i]));
         if (i < argc - 1)
         {
             lfs_file_write(shell_state.fs, &file, " ", 1);
         }
     }
 ​
     lfs_file_close(shell_state.fs, &file);
     return 0;
 }

这里的关键点是:

  • argv[1] 是文件名

  • argv[2] 以及后面的是要写入的文本

  • LFS_O_TRUNC 打开,表示覆盖旧内容

  • 每个参数之间补一个空格,所以 write a.txt hello world 最后写进去的是 hello world


9. main 函数里的整体初始化流程

前面已经分别看过 Flash、文件系统和 Shell,这里要开始把它们放回同一个系统里看。

Core/Src/main.c 中,相关初始化顺序大致是:

 spi_flash_init();
 lfs_storage_init(&cfg);
 lfs_basic_test();
 shell_init(&lfs);
 shell_set_uart(&huart1);

所以这一段最适合按“从下往上接起来”的顺序理解:

第一步:初始化 SPI Flash

 spi_flash_init();

目的:让最底层硬件访问先可用。

第二步:初始化 LittleFS 的存储后端

 lfs_storage_init(&cfg);

目的:告诉 LittleFS,这块 Flash 的参数是多少,底层的读写擦除函数分别是谁。

第三步:挂载并测试文件系统

 lfs_basic_test();

目的:

  • 挂载文件系统

  • 挂载失败时自动格式化

  • 创建目录和文件

  • 验证基本读写是否正常

第四步:初始化 Shell

 shell_init(&lfs);
 shell_set_uart(&huart1);

main.c 里这一段连在一起看会更清楚:

 spi_flash_init();
 ​
 if (lfs_storage_init(&cfg) != LFS_ERR_OK)
 {
     while (1);
 }
 ​
 lfs_basic_test();
 shell_init(&lfs);
 shell_set_uart(&huart1);

这一段初始化顺序特别值得记:

  • 先把底层 Flash 驱动准备好

  • 再把文件系统配置好

  • 再做一次文件系统测试

  • 最后才把 Shell 开放出来

这一步的目的,是把已经可用的文件系统交给 Shell,让用户通过串口直接操作。

所以从系统启动角度看,这一讲的依赖顺序非常明确:

Flash 驱动先通 -> 文件系统再挂 -> Shell 最后开放

如果前一层没通,后一层就不成立。


10. 这一讲你应该真正理解的几个点

10.1 SPI Flash 和文件系统不是一回事

  • SPI Flash 是硬件存储芯片

  • LittleFS 是跑在这块芯片上的文件系统软件

一个是“存储介质”,一个是“管理规则”。

10.2 先做裸驱动验证,再上文件系统

如果 SPI 读 ID 都不稳定,就不要急着调文件系统。
因为文件系统的问题有时表面复杂,根源却可能只是底层时序或擦写失败。

10.3 文件系统让“按地址存数据”变成“按文件存数据”

这正是这一讲最核心的工程价值。

原来你操作的是:

  • 地址

  • 扇区

引入 LittleFS 之后,你操作的是:

  • 文件

  • 目录

  • 路径

10.4 Shell 让调试和演示都更方便

有了 Shell,你不需要每次都重新烧录程序去验证一个文件操作。
只要串口连上,就可以直接输入命令测试:

 ls
 mkdir test
 write test/a.txt hello
 cat test/a.txt

这对于课堂演示、功能验证、后续扩展都很有帮助。


11. 可以怎么记这节课

可以用一句话概括:

先让 MCU 能按地址读写外部 Flash,再用 LittleFS 把它组织成文件系统,最后通过 Shell 把这些文件操作开放给用户。

如果再压缩成流程图,就是:

 SPI1 + GD25QXX
     ↓
 spi_flash_read / write / erase
     ↓
 LittleFS block device port
     ↓
 lfs_mount / lfs_file_open / lfs_file_write
     ↓
 Shell 命令:ls / cat / mkdir / write / rm

12. 现阶段适合继续补的内容

这份笔记后面当然还可以继续补,但按你现在这个学习阶段,建议先分两步掌握。

现在先掌握

  • SPI Flash 的基本读写流程

  • 为什么写前要擦除

  • LittleFS 需要底层移植接口

  • Shell 最终是在调文件系统 API

后面再深入

  • LittleFS 的磨损均衡思想

  • 掉电保护具体机制

  • Shell 的自动补全和历史命令实现

  • 如何把配置参数、日志、波形数据正式存成文件

这样学会更稳,不容易一下子把层次混在一起。


13. 代码复习索引

这一节不再整段贴完整源码了,避免笔记后半部分过重。

如果你后面要回到工程里对照代码,优先看这几个文件:

  • APP/flash_app.h

  • APP/flash_app.c

  • Components/GD25QXX/gd25qxx.h

  • Components/GD25QXX/gd25qxx.c

  • APP/shell_app.h

  • APP/shell_app.c

  • Core/Src/main.c

建议你按这个顺序复习:

  1. 先看 gd25qxx.c,理解最底层的 Flash 读写擦除

  2. 再看 flash_app.c,理解 test_spi_flash()lfs_basic_test() 怎么把流程串起来

  3. 再看 shell_app.c,理解命令是怎么调用文件系统 API 的

  4. 最后看 main.c,理解整个系统上电后的初始化顺序

如果以后你需要,我还可以再单独帮你把某一个文件做成“逐函数讲解版”,那会比整段贴源码更适合学习。

1 个赞