第十讲 Flash + 文件系统 + Shell
以西门子 V2 开发板为基础。

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 外设不是“发一串字节就行”,而是要求:
-
先拉低 CS
-
发送命令
-
发送地址
-
读或写数据
-
拉高 CS
如果 CS 时序不对,器件就可能不响应。
2.3 什么叫全双工
SPI 是一种同步、全双工通信方式。
“全双工”的意思是:
-
主机发送 1 个字节的同时
-
从机也会回送 1 个字节
也就是说,SPI 每打一拍时钟,主从双方都在交换 1 bit 数据。
所以很多读操作本质上其实是:
-
主机先发命令和地址
-
然后继续发送“无意义字节”
-
用这些时钟把从机里的数据读出来
在代码里,这个“无意义字节”通常叫 dummy byte。
2.4 SPI 工作模式
SPI 一共有 4 种模式,由 CPOL 和 CPHA 组合决定:
-
模式 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,但实际使用时还是要以芯片手册为准。



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
所以如果某个区域已经写过数据,想重新写,通常要先擦除。
这也是为什么测试流程基本都是:
-
读 ID
-
擦除扇区
-
写数据
-
读回来验证
例如底层驱动里,擦除和等待写结束是这样配合的:
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
它做的事情非常适合作为理解模板:
-
spi_flash_init()初始化驱动 -
spi_flash_read_id()读取芯片 ID -
spi_flash_sector_erase(test_addr)擦除测试扇区 -
spi_flash_buffer_read(...)检查是否全为0xFF -
准备一段字符串写入缓冲区
-
spi_flash_buffer_write(...)写入一页数据 -
spi_flash_buffer_read(...)再读回来 -
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 更像“原始存储介质”,而不是“可以方便使用的文件空间”。
文件系统的作用,就是把底层的块设备封装成更好用的逻辑结构,比如:
-
目录
-
文件
-
文件读写
-
重命名
-
删除
-
空间管理
这样一来,我们就不需要天天手算地址 0x000000、0x001000、0x002000 存什么,而是可以直接操作:
-
/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 创建目录和文件
测试函数里接着做了两件事:
-
创建目录
boot -
打开文件
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 用文件保存启动次数
这部分很适合作为“文件系统为什么比裸地址操作更好用”的例子。
逻辑是:
-
读取
boot_cnt.txt -
如果文件为空或读取失败,就把计数从 0 开始
-
boot_count++ -
重新写回文件
这样每次系统启动,都会把启动次数保存在 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 可以理解成一个“命令解释器”。
它负责做三件事:
-
从串口接收用户输入的字符
-
把一整行命令解析成
argc/argv -
调用对应的命令处理函数
例如输入:
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
建议你按这个顺序复习:
-
先看
gd25qxx.c,理解最底层的 Flash 读写擦除 -
再看
flash_app.c,理解test_spi_flash()和lfs_basic_test()怎么把流程串起来 -
再看
shell_app.c,理解命令是怎么调用文件系统 API 的 -
最后看
main.c,理解整个系统上电后的初始化顺序
如果以后你需要,我还可以再单独帮你把某一个文件做成“逐函数讲解版”,那会比整段贴源码更适合学习。