STM32 零基础可移植教程 26:SPI Flash 保存参数,做一个掉电不丢的配置结构体
上一篇我们已经能往 SPI Flash 里写一段字符串,再读回来校验。
这说明最基础的链路已经通了:
bash
擦除 -> 写入 -> 读回 -> 校验
但真实项目里,我们很少只是写一段固定字符串。
更常见的是保存一些参数,比如:
bash
串口波特率
采样周期
设备地址
LED 是否默认开启
校准系数
运行次数
这些参数有一个共同特点:
bash
掉电后不能丢
这一篇就把前面的 SPI Flash 擦写能力,整理成一个更像工程里会用的东西:
bash
保存一个配置结构体,上电后自动读回来;如果 Flash 里没有有效配置,就使用默认值
零基础读者必读:
如果你之前没接触过"把参数存到 Flash 里"这件事,这里先用一句话建立直觉:
你现在写的程序,每次断电后所有变量都会消失。想让参数(比如串口波特率、设备地址)断电后还在,就需要把它们存到 SPI Flash 里。这篇文章就是教你做一个"不会丢的配置本"------上电时自动从 Flash 读回来,Flash 里没有就先用默认值。
本篇先做入门版。
不做磨损均衡,不做双备份,不做文件系统。
先把"参数结构体怎么落到 Flash 里"讲清楚。
零基础热身:几个必须提前理解的概念
在动手写代码之前,先花 5 分钟理解几个关键词。它们会在整篇文章里反复出现。
1. 什么是"配置结构体"
C 语言里,struct(结构体)就是把几个相关的变量打包在一起:
bash
// 没有结构体时,参数散落各处:
uint32_t
boot_count;
uint32_t
uart_baudrate;
uint16_t
sample_interval_ms;
uint8_t
led_enable;
// 有了结构体,打包成一个"配置本":
typedef
struct{
uint32_t
boot_count;
uint32_t
uart_baudrate;
uint16_t
sample_interval_ms;
uint8_t
led_enable;
uint8_t
reserved;
} App_Config;
打包的好处:你可以把这个结构体整体读、整体写、整体校验,不用一个一个字段处理。
2. 为什么"掉电不丢"不等于"不用管"
SPI Flash 确实是掉电不丢的存储器------你写进去的数据,断电再上电还在。
但是:
bash
第一次上电时,Flash 里什么都没有(全是 0xFF)
如果你直接把 0xFF 当成配置参数读进来:
boot_count = 0xFFFFFFFF = 4294967295
uart_baudrate = 0xFFFFFFFF = 4294967295
...
这显然不能用。所以你需要一套判断机制:Flash 里的数据到底是不是我们之前写进去的有效配置。
3. 什么是 magic number(魔数)
magic number 就是一个特殊的标记值,用来回答一个问题:
bash
"这块 Flash 区域里的数据,是我们之前存进去的配置吗?"
你可以把它理解成"暗号"------就像两个人接头时说一句暗号确认身份。本篇用的 magic 是 0x31474643。
为什么选这个值?因为它足够"特别",Flash 出厂时全是 0xFF,几乎不可能碰巧等于这个值。
4. 什么是 checksum(校验和)
checksum 是一种最简单的数据完整性检查。
原理很简单:
bash
把一段数据的所有字节加加减减(或做某种运算),得到一个"校验值"。
保存时把校验值一起存进去。
读取时重新算一遍------如果算出来的和存的不一样,说明数据坏了。
类比:就像快递包裹上的防拆贴纸。你收到包裹时看一眼贴纸是否完好,就知道有没有被拆过。
本篇用的 checksum 不是 CRC32(那个更复杂也更可靠),而是一个简化的 32 位累加+旋转算法。对于入门级别的参数保存来说够用了。
5. 为什么要一个"记录"而不是只存结构体
如果你只把 App_Config 的十几个字节直接写到 Flash,上电读回来后你只看到一串数字:
bash
01 00 00 00 00 C2 01 00 E8 03 01 00
你怎么知道这串数字是"我们存的有效配置",还是"Flash 出厂时的随机状态"?
所以我们在结构体外面包一层"信封":
bash
┌──────────────────────────────────────────┐
│ magic(暗号) version(版本) length(长度)│
├──────────────────────────────────────────┤
│ config(真正的参数) │
├──────────────────────────────────────────┤
│ checksum(校验值) │
└──────────────────────────────────────────┘
这一层信封就是本篇的核心设计。
本篇目标
最终现象:
第一次运行时,Flash 里还没有有效参数,串口输出类似:
bash
SPI Flash config store demo
No valid config, use default.
boot_count=1
uart_baudrate=115200
sample_interval_ms=1000
led_enable=1
Save config OK.
断电或复位后再次运行,串口输出类似:
bash
SPI Flash config store demo
Load config from SPI Flash.
boot_count=2
uart_baudrate=115200
sample_interval_ms=1000
led_enable=1
Save config OK.
如果你继续复位,boot_count 会继续增加。
这就说明:
bash
参数确实保存到了 SPI Flash 里,而且掉电后能正确读回来
本篇用到的外设:
bash
SPI
GPIO Output
USART printf
本篇跑通标准:
-
第一次运行能使用默认参数;
-
保存后复位,能从 SPI Flash 读回参数;
-
boot_count能随复位次数增加; -
能说清楚
magic/version/length/checksum的作用; -
知道本篇只使用一个固定扇区保存参数;
-
知道不要在主循环里高频保存参数。
准备工作
本篇建议从第 25 篇工程继续做。
你需要已经完成:
|
项目
|
说明
|
| --- | --- |
|
SPI Flash 读 ID
|
确认通信正常
|
|
SPI Flash 读数据
|
能从指定地址读数据
|
|
SPI Flash 写数据
|
能擦除、写入、读回校验
|
|
串口 printf
|
用来观察参数加载和保存结果
|
动手前的检查清单:
在开始写本篇代码之前,请确认以下每一项:
-
烧录第 25 篇的工程,串口能打印
Verify OK -
擦除、写入、读回三个操作都稳定(重复上电 3 次都正常)
-
串口输出稳定,不是乱码
-
SPI Flash 的 CS 引脚 User Label 设置为
SPI_FLASH_CS -
知道自己的 SPI 用的是 SPI1 还是 SPI2
-
知道 Flash 的页大小(通常 256 字节)和扇区大小(通常 4KB)
-
确认 Flash 上没有存其他重要数据(本篇会擦除一个扇区)
本篇使用的参数保存地址是:
bash
0x002000
它是一个 4KB 扇区边界。
换算成十进制:0x002000 = 8192,也就是 Flash 的第 8KB 位置。
如果你的 Flash 里已经放了其他数据,比如字体、图片、日志,记得换一个不会冲突的扇区。
这一点很重要。
本篇的 App_ConfigStore_Save() 会擦除整个扇区。
如果这个扇区里还有别的数据,会一起被擦掉。
如何确认扇区是空的?
你可以用第 24 篇的读数据功能,读一下
0x002000开始的 256 字节。如果全是FF,说明这片区域是空的,可以放心用。如果有非FF的数据,说明这片区域可能被用过,换一个地址。
先看一遍:第一次上电和第二次上电,Flash 里发生了什么
在写代码之前,先用两张图看懂整个过程。这对后面理解代码很有帮助。
第一次上电(Flash 里什么都没有)
bash
上电
│
▼
读取 Flash 地址 0x002000
│
▼
读到的数据:FF FF FF FF FF FF FF FF ...(全是出厂状态)
│
▼
检查 magic:0xFFFFFFFF ≠ 0x31474643 → 不是有效配置
│
▼
使用默认值:
boot_count = 0
uart_baudrate = 115200
sample_interval_ms = 1000
led_enable = 1
│
▼
boot_count 加 1 → boot_count = 1
│
▼
保存到 Flash:
擦除 0x002000 扇区
写入完整记录(magic + version + length + config + checksum)
读回校验
│
▼
串口输出:boot_count=1, Save config OK.
第二次上电(Flash 里已经有上次保存的配置)
bash
上电
│
▼
读取 Flash 地址 0x002000
│
▼
读到的数据:43 46 47 31 01 00 0C 00 01 00 00 00 ...
\_________/ \___/ \___/ \___________/
magic ver length config...
│
▼
检查 magic:0x31474643 ✓ 匹配!
检查 version:1 ✓ 匹配!
检查 length:12 ✓ 匹配(sizeof(App_Config) = 12)
检查 checksum:✓ 匹配!
│
▼
全部通过 → 使用 Flash 里的配置
boot_count = 1(上次保存的值)
uart_baudrate = 115200
...
│
▼
boot_count 加 1 → boot_count = 2
│
▼
保存到 Flash(覆盖旧值)
│
▼
串口输出:boot_count=2, Save config OK.

为什么不能只把结构体裸写进去
你可能会想:
bash
我定义一个结构体
然后直接写到 Flash
上电再读回来
这当然可以作为第一步。
但马上会遇到几个问题:
1. 怎么知道 Flash 里有没有有效参数
第一次上电时,Flash 对应区域可能是空的:
bash
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ...
如果你直接把这些字节当成结构体,就会得到一堆奇怪参数。
比如------假设 App_Config 在内存中的布局如下:
bash
偏移 0:boot_count (4 字节)
偏移 4:uart_baudrate (4 字节)
偏移 8:sample_interval_ms (2 字节)
偏移 10:led_enable (1 字节)
偏移 11:reserved (1 字节)
从空白 Flash 读回来就是:
bash
FF FF FF FF FF FF FF FF FF FF FF FF
当成结构体解析:
-
boot_count = 0xFFFFFFFF = 4294967295(明显不对) -
uart_baudrate = 0xFFFFFFFF = 4294967295(串口根本不能用) -
sample_interval_ms = 0xFFFF = 65535(采样间隔 65 秒?) -
led_enable = 0xFF = 255(这个值只有 0/1 有意义)
这显然不能直接用。
2. 后面结构体升级怎么办
今天你的参数结构体可能只有:
bash
波特率
采样周期
后面你可能又加:
bash
设备地址
校准系数
运行模式
举例:现在的结构体是 12 字节,以后你加一个 device_id 字段变成 16 字节。
Flash 里存的是 12 字节的旧版本,你的新代码期望 16 字节。直接读进来:
-
少了的 4 字节会读到旧数据后面的
FF,导致device_id = 0xFFFFFFFF -
更糟的是,
checksum的位置也变了,校验一定失败
这就需要版本号。
3. 数据写坏了怎么办
如果写入过程中掉电,或者 SPI 通信异常,Flash 里的数据可能不完整。
假设写入到一半断电了:
bash
本应写入:43 46 47 31 01 00 0C 00 01 00 00 00 00 C2 01 00 E8 03 01 00 [checksum]
实际写入:43 46 47 31 01 00 0C 00 01 00 00 00 00 C2 FF FF FF FF FF FF ...
\___/ \_______________/
前半段 后半段还是 FF(没写成)
如果上电后不检查,程序可能直接使用一份坏参数。
所以至少要有一个简单校验。
本篇用的是:
bash
checksum
它不是最强的校验方式,但对入门足够直观。

本篇使用的数据格式
我们不只保存配置结构体本身,而是保存一个记录:
bash
magic
version
length
config
checksum
可以理解成:
bash
头部 + 真实参数 + 校验
|
字段
|
类型
|
字节数
|
作用
|
| --- | --- | --- | --- |
|
magic
|
uint32_t
|
4
|
判断这里是不是我们保存过的配置
|
|
version
|
uint16_t
|
2
|
判断配置格式版本是否匹配
|
|
length
|
uint16_t
|
2
|
判断结构体长度是否匹配
|
|
config
|
App_Config
|
12
|
真正的配置参数
|
|
checksum
|
uint32_t
|
4
|
判断数据有没有损坏
|
一条记录总共:4 + 2 + 2 + 12 + 4 = 24 字节。
远小于一个扇区(4096 字节),所以整个记录可以轻松放进一个扇区里。
为什么选择这些字段
每个字段都有具体的防御目的:
bash
magic: 防止把"不是配置的数据"当成配置
version:防止新代码读旧格式的配置
length: 防止结构体大小变了但版本号忘了改
checksum:防止数据损坏(掉电、通信异常、Flash 老化)
四个检查全部通过,才能说"这份配置是有效的"。
如果任何一个检查失败,程序就回退到默认值------这是最安全的选择。
本篇的配置结构体
bash
typedef
struct{
uint32_t
boot_count;
uint32_t
uart_baudrate;
uint16_t
sample_interval_ms;
uint8_t
led_enable;
uint8_t
reserved;
} App_Config;
各字段含义:
|
字段
|
含义
|
| --- | --- |
| boot_count |
运行次数,每次上电或复位加 1
|
| uart_baudrate |
示例保存一个串口波特率
|
| sample_interval_ms |
示例保存一个采样周期
|
| led_enable |
示例保存一个开关参数
|
| reserved |
预留字节,方便以后扩展
|
这里的 boot_count 很适合做验证。
因为你每复位一次,它都应该增加一次。如果复位后 boot_count 没有增加,说明保存或加载出了问题------这是最直观的验证方式。

整体流程
上电后的流程是:
bash
初始化 SPI Flash
读取配置记录
检查 magic/version/length/checksum
如果有效:使用 Flash 里的配置
如果无效:加载默认配置
boot_count 加 1
保存配置
打印当前配置
也就是:
bash
读 -> 判断 -> 默认值或旧值 -> 修改 -> 保存
整个流程在 main.c 里只调用了一次,不会重复擦写 Flash。
第一次运行时,Flash 里没有有效记录,所以会走默认值。
保存成功后,下一次运行就能读到有效记录。
CubeMX 配置步骤
本篇不新增外设,继续沿用 SPI Flash 工程。
1. SPI 配置
保持前面几篇的配置:
|
配置项
|
推荐值
|
为什么这样设
|
| --- | --- | --- |
|
Mode
|
Full-Duplex Master
|
STM32 是主机,控制整个通信
|
|
Data Size
|
8 Bits
|
Flash 命令和数据都按字节
|
|
First Bit
|
MSB First
|
Flash 数据手册要求高位在前
|
|
CPOL
|
Low
|
Mode 0,空闲时时钟低
|
|
CPHA
|
1 Edge
|
Mode 0,第一个边沿采样
|
|
Prescaler
|
先慢一点,比如 64 或 128
|
稳定优先,通了再提速
|
|
NSS
|
Software / Disable Hardware NSS
|
CS 用 GPIO 手动控制
|
【重要】如果前面几篇已经能稳定读写 Flash,这里不要乱改 SPI 参数。参数保存失败几乎从来不是 SPI Mode 的问题。

2. CS 引脚配置
CS 仍然作为普通 GPIO:
|
配置项
|
推荐值
|
| --- | --- |
|
GPIO mode
|
Output Push Pull
|
|
Output Level
|
High
|
|
User Label
|
SPI_FLASH_CS
|

3. USART 配置
复用第 07 篇 printf():
bash
USART1
115200
8-N-1

Keil 工程生成和编译
本篇配套文件:
bash
Core/Inc/app_spi_flash.h
Core/Src/app_spi_flash.c
Core/Inc/app_config_store.h
Core/Src/app_config_store.c
如果你从第 25 篇继续做,可以保留 app_spi_flash.h/.c,再新增:
bash
app_config_store.h
app_config_store.c
Keil 工程里要添加:
bash
Core/Src/app_spi_flash.c
Core/Src/app_config_store.c
Keil 添加文件的步骤(零基础版):
-
在 Keil 左侧工程树里找到
Core/Src分组 -
右键点击
Core/Src→ Add Existing Files to Group -
浏览到工程目录下的
Core/Src/app_config_store.c,选中添加 -
确认两个
.c文件(app_spi_flash.c和app_config_store.c)都出现在工程树里
如果编译报 undefined symbol App_ConfigStore_LoadOrDefault,99% 是因为 app_config_store.c 没加入工程。

代码文件总览
本篇涉及 3 个文件(app_spi_flash.h/.c 沿用第 25 篇):
|
文件
|
作用
|
| --- | --- |
| app_spi_flash.h |
Flash 驱动头文件(沿用第 25 篇)
|
| app_spi_flash.c |
Flash 驱动实现(沿用第 25 篇)
|
| app_config_store.h |
配置存储头文件:定义 App_Config、声明存取函数
|
| app_config_store.c |
配置存储实现:magic/version/length/checksum 判断、读写、校验
|
分层关系:
bash
main.c
└─ App_ConfigStore_LoadOrDefault() ← 配置层:判断有效性、默认值
└─ App_ConfigStore_Save() ← 配置层:打包、擦写、校验
└─ App_SPIFlash_ReadData() ← 驱动层:只管通信
└─ App_SPIFlash_WriteData() ← 驱动层:只管通信
└─ App_SPIFlash_SectorErase() ← 驱动层:只管通信
配置层不关心 SPI 时序,驱动层不关心配置结构体长什么样。各司其职。
完整代码
1. 新建或更新 Core/Inc/app_spi_flash.h
bash
#ifndef APP_SPI_FLASH_H
#define APP_SPI_FLASH_H
#include "main.h"
#include <stdint.h>
#define APP_SPI_FLASH_PAGE_SIZE 256u
#define APP_SPI_FLASH_SECTOR_SIZE 4096u
typedef
struct{
uint8_t
manufacturer_id;
uint8_t
memory_type;
uint8_t
capacity;
} App_SPIFlash_JedecID;
void App_SPIFlash_Init(void)
;
HAL_StatusTypeDef App_SPIFlash_ReadJedecID(App_SPIFlash_JedecID *id)
;
HAL_StatusTypeDef App_SPIFlash_ReadStatusReg1(uint8_t *status_reg)
;
HAL_StatusTypeDef App_SPIFlash_WaitUntilReady(uint32_t timeout_ms)
;
HAL_StatusTypeDef App_SPIFlash_WriteEnable(void)
;
HAL_StatusTypeDef App_SPIFlash_SectorErase(uint32_t address)
;
HAL_StatusTypeDef App_SPIFlash_ReadData(uint32_t address, uint8_t *buffer, uint16_t length)
;
HAL_StatusTypeDef App_SPIFlash_PageProgram(uint32_t address, const uint8_t *data, uint16_t length)
;
HAL_StatusTypeDef App_SPIFlash_WriteData(uint32_t address, const uint8_t *data, uint16_t length)
;
#endif
app_spi_flash.c 使用第 25 篇版本即可(完整代码见第 25 篇)。
2. 新建 Core/Inc/app_config_store.h
bash
#ifndef APP_CONFIG_STORE_H
#define APP_CONFIG_STORE_H
#include "main.h"
#include <stdint.h>
typedef
struct{
uint32_t
boot_count;
uint32_t
uart_baudrate;
uint16_t
sample_interval_ms;
uint8_t
led_enable;
uint8_t
reserved;
} App_Config;
void App_ConfigStore_LoadDefault(App_Config *config)
;
HAL_StatusTypeDef App_ConfigStore_Load(App_Config *config, uint8_t *is_valid)
;
HAL_StatusTypeDef App_ConfigStore_LoadOrDefault(App_Config *config, uint8_t *used_default)
;
HAL_StatusTypeDef App_ConfigStore_Save(const App_Config *config)
;
void App_ConfigStore_Print(const App_Config *config)
;
#endif
头文件里声明了什么(零基础解释):
-
App_Config结构体:你的"配置本",包含 boot_count、波特率、采样周期、LED 开关 -
App_ConfigStore_LoadDefault():给配置本填上默认值(就像新本子的出厂设置) -
App_ConfigStore_Load():从 Flash 读取配置,同时告诉你读到的有没有效 -
App_ConfigStore_LoadOrDefault():最常用的函数------先试着从 Flash 读,读不到就用默认值 -
App_ConfigStore_Save():把当前配置存到 Flash -
App_ConfigStore_Print():通过串口打印当前配置内容
3. 新建 Core/Src/app_config_store.c
bash
#include "app_config_store.h"
#include "app_spi_flash.h"
#include <stdio.h>
#include <stddef.h>
#include <string.h>
#ifndef APP_CONFIG_STORE_ADDRESS
#define APP_CONFIG_STORE_ADDRESS 0x002000u
#endif
#define APP_CONFIG_STORE_MAGIC 0x31474643u
#define APP_CONFIG_STORE_VERSION 1u
typedef
struct{
uint32_t
magic;
uint16_t
version;
uint16_t
length;
App_Config config;
uint32_t
checksum;
} App_ConfigStore_Record;
static uint32_t App_ConfigStore_Checksum32(const uint8_t *data, uint16_t length)
{
uint16_t
i;
uint32_t
checksum =
0xA5A5A5A5
u;
for
(i =
0u
; i < length; i++)
{
checksum += data[i];
checksum = (checksum <<
5
) | (checksum >>
27
);
}
return
checksum;
}
static uint32_t App_ConfigStore_RecordChecksum(App_ConfigStore_Record *record)
{
record->checksum =
0u
;
return
App_ConfigStore_Checksum32((
const
uint8_t
*)record,
(
uint16_t
)offsetof(App_ConfigStore_Record, checksum));
}
static uint8_t App_ConfigStore_IsRecordValid(App_ConfigStore_Record *record)
{
uint32_t
saved_checksum;
uint32_t
calculated_checksum;
if
(record->magic != APP_CONFIG_STORE_MAGIC)
{
return
0u
;
}
if
(record->version != APP_CONFIG_STORE_VERSION)
{
return
0u
;
}
if
(record->length !=
sizeof
(App_Config))
{
return
0u
;
}
saved_checksum = record->checksum;
calculated_checksum = App_ConfigStore_RecordChecksum(record);
record->checksum = saved_checksum;
if
(saved_checksum != calculated_checksum)
{
return
0u
;
}
return
1u
;
}
void App_ConfigStore_LoadDefault(App_Config *config)
{
if
(config ==
0
)
{
return
;
}
config->boot_count =
0u
;
config->uart_baudrate =
115200u
;
config->sample_interval_ms =
1000u
;
config->led_enable =
1u
;
config->reserved =
0u
;
}
HAL_StatusTypeDef App_ConfigStore_Load(App_Config *config, uint8_t *is_valid)
{
App_ConfigStore_Record record;
HAL_StatusTypeDef status;
if
((config ==
0
) || (is_valid ==
0
))
{
return
HAL_ERROR;
}
*is_valid =
0u
;
memset
(&record,
0
,
sizeof
(record));
status = App_SPIFlash_ReadData(APP_CONFIG_STORE_ADDRESS,
(
uint8_t
*)&record,
(
uint16_t
)
sizeof
(record));
if
(status != HAL_OK)
{
return
status;
}
if
(App_ConfigStore_IsRecordValid(&record) ==
0u
)
{
return
HAL_OK;
}
*config = record.config;
*is_valid =
1u
;
return
HAL_OK;
}
HAL_StatusTypeDef App_ConfigStore_LoadOrDefault(App_Config *config, uint8_t *used_default)
{
uint8_t
is_valid =
0u
;
HAL_StatusTypeDef status;
if
((config ==
0
) || (used_default ==
0
))
{
return
HAL_ERROR;
}
status = App_ConfigStore_Load(config, &is_valid);
if
(status != HAL_OK)
{
return
status;
}
if
(is_valid ==
0u
)
{
App_ConfigStore_LoadDefault(config);
*used_default =
1u
;
}
else
{
*used_default =
0u
;
}
return
HAL_OK;
}
HAL_StatusTypeDef App_ConfigStore_Save(const App_Config *config)
{
App_ConfigStore_Record record;
App_ConfigStore_Record verify_record;
HAL_StatusTypeDef status;
if
(config ==
0
)
{
return
HAL_ERROR;
}
memset
(&record,
0
,
sizeof
(record));
memset
(&verify_record,
0
,
sizeof
(verify_record));
record.magic = APP_CONFIG_STORE_MAGIC;
record.version = APP_CONFIG_STORE_VERSION;
record.length =
sizeof
(App_Config);
record.config = *config;
record.checksum = App_ConfigStore_RecordChecksum(&record);
status = App_SPIFlash_SectorErase(APP_CONFIG_STORE_ADDRESS);
if
(status != HAL_OK)
{
return
status;
}
status = App_SPIFlash_WriteData(APP_CONFIG_STORE_ADDRESS,
(
const
uint8_t
*)&record,
(
uint16_t
)
sizeof
(record));
if
(status != HAL_OK)
{
return
status;
}
status = App_SPIFlash_ReadData(APP_CONFIG_STORE_ADDRESS,
(
uint8_t
*)&verify_record,
(
uint16_t
)
sizeof
(verify_record));
if
(status != HAL_OK)
{
return
status;
}
if
(
memcmp
(&record, &verify_record,
sizeof
(record)) !=
0
)
{
return
HAL_ERROR;
}
return
HAL_OK;
}
void App_ConfigStore_Print(const App_Config *config)
{
if
(config ==
0
)
{
return
;
}
printf
(
"boot_count=%lu\r\n"
, (
unsigned
long
)config->boot_count);
printf
(
"uart_baudrate=%lu\r\n"
, (
unsigned
long
)config->uart_baudrate);
printf
(
"sample_interval_ms=%u\r\n"
, (
unsigned
int
)config->sample_interval_ms);
printf
(
"led_enable=%u\r\n"
, (
unsigned
int
)config->led_enable);
}
app_config_store.c 逐块解释
上面代码虽然不长,但每一段都有明确的目的。下面按功能块逐一解释。
第一部分:可配置的宏定义
bash
#ifndef APP_CONFIG_STORE_ADDRESS
#define APP_CONFIG_STORE_ADDRESS 0x002000u
#endif
配置保存的 Flash 地址。如果你需要换地址,在包含 app_config_store.h 之前定义它:
bash
#define APP_CONFIG_STORE_ADDRESS 0x003000u
bash
#define APP_CONFIG_STORE_MAGIC 0x31474643u
#define APP_CONFIG_STORE_VERSION 1u
MAGIC 是"暗号",VERSION 是格式版本。后面会详细解释。
第二部分:打包用的内部结构体
bash
typedef
struct{
uint32_t
magic;
// 4 字节:暗号
uint16_t
version;
// 2 字节:版本号
uint16_t
length;
// 2 字节:config 大小
App_Config config;
// 12 字节:真正的参数
uint32_t
checksum;
// 4 字节:校验值
} App_ConfigStore_Record;
这就是前面说的"信封"。总共 4+2+2+12+4 = 24 字节。
注意这个结构体只在 app_config_store.c 内部使用------main.c 不需要知道这个"信封"长什么样,只需要知道 App_Config。
第三部分:checksum 计算
bash
static uint32_t App_ConfigStore_Checksum32(const uint8_t *data, uint16_t length)
{
uint16_t
i;
uint32_t
checksum =
0xA5A5A5A5
u;
// 初始值不是 0,这是故意的
for
(i =
0u
; i < length; i++)
{
checksum += data[i];
// 累加当前字节
checksum = (checksum <<
5
) | (checksum >>
27
);
// 左移 5 位,右移 27 位(旋转)
}
return
checksum;
}
逐行解释:
uint32_t checksum = 0xA5A5A5A5u;
初始值不是 0。为什么?如果初始值是 0,那么全 0 的数据算出来的 checksum 也是 0------这会降低校验的有效性。用一个非零初始值可以避免这个问题。
checksum += data[i];
把当前字节加到 checksum 上。如果数据里有一个字节变了,累加结果就会不同。
checksum = (checksum << 5) | (checksum >> 27);
这是一次"旋转":把 checksum 左移 5 位,同时把高 5 位移到低 5 位。这能让每个字节的差异"扩散"到 checksum 的各个位,提高检出率。
为什么要用旋转?
如果只累加不旋转,checksum 本质上就是所有字节的和。两个字节交换位置(比如 01 02 变成 02 01),累加和一样,checksum 就检测不出来。加上旋转后,字节的顺序也会影响最终结果。
bash
static uint32_t App_ConfigStore_RecordChecksum(App_ConfigStore_Record *record)
{
record->checksum =
0u
;
// 先把 checksum 字段清零
return
App_ConfigStore_Checksum32((
const
uint8_t
*)record,
(
uint16_t
)offsetof(App_ConfigStore_Record, checksum));
}
这个函数计算"记录中除 checksum 字段本身以外"所有字节的 checksum。
offsetof 是什么?
offsetof(App_ConfigStore_Record, checksum) 返回 checksum 字段在结构体中的偏移量(字节数)。
在本篇的结构体中:
bash
magic: 偏移 0,占 4 字节
version: 偏移 4,占 2 字节
length: 偏移 6,占 2 字节
config: 偏移 8,占 12 字节
checksum: 偏移 20,占 4 字节
所以 offsetof(App_ConfigStore_Record, checksum) = 20。
传 20 给 Checksum32 的 length 参数,意思是"只计算前 20 个字节(magic + version + length + config),不包含 checksum 字段本身"。
为什么传入之前要把 checksum 清零?
因为要保证"写之前的 checksum 计算"和"读之后的 checksum 验证"用的是同一套逻辑。如果 checksum 字段里还有旧值,算出来的校验值就不对了。
第四部分:有效性判断
bash
static uint8_t App_ConfigStore_IsRecordValid(App_ConfigStore_Record *record)
{
uint32_t
saved_checksum;
uint32_t
calculated_checksum;
// 第一关:暗号对不对
if
(record->magic != APP_CONFIG_STORE_MAGIC)
{
return
0u
;
// 暗号不对 → 不是我们的配置
}
// 第二关:版本对不对
if
(record->version != APP_CONFIG_STORE_VERSION)
{
return
0u
;
// 版本不匹配 → 格式不同
}
// 第三关:长度对不对
if
(record->length !=
sizeof
(App_Config))
{
return
0u
;
// 长度不匹配 → 结构体变了
}
// 第四关:校验值对不对
saved_checksum = record->checksum;
// 先记下读回来的 checksum
calculated_checksum = App_ConfigStore_RecordChecksum(record);
// 重新算一遍
record->checksum = saved_checksum;
// 再把原值写回去
if
(saved_checksum != calculated_checksum)
{
return
0u
;
// 校验不通过 → 数据坏了
}
return
1u
;
// 四关全过 → 有效配置
}
四道关卡,逐个检查:
bash
magic 检查 → "这是我们的配置吗?"
version 检查 → "格式版本对吗?"
length 检查 → "结构体大小对吗?"
checksum 检查 → "数据完整吗?"
任何一个不通过,都返回 0(无效)。
为什么 saved_checksum 要先记下来再写回去?
因为 App_ConfigStore_RecordChecksum() 内部会先把 record->checksum 清零再计算。如果不清零,checksum 字段里的旧值会参与计算,算出来的一定不对。
但清零后 record->checksum 就变成 0 了。如果下面还要用这个记录(虽然本篇不会),原值就丢了。所以先保存原值,算完再恢复。
第五部分:加载默认值
bash
void App_ConfigStore_LoadDefault(App_Config *config)
{
if
(config ==
0
)
// 空指针保护
{
return
;
}
config->boot_count =
0u
;
// 从 0 开始计数
config->uart_baudrate =
115200u
;
// 最常用的波特率
config->sample_interval_ms =
1000u
;
// 1 秒采样一次
config->led_enable =
1u
;
// 默认开灯
config->reserved =
0u
;
// 预留位置零
}
这就是"出厂设置"。如果你的项目有不同的默认值,改这个函数就行。
注意 boot_count 的默认值是 0。在 main.c 里加载后会立刻 boot_count++,所以第一次打印出来是 1。
第六部分:从 Flash 加载
bash
HAL_StatusTypeDef App_ConfigStore_Load(App_Config *config, uint8_t *is_valid)
{
App_ConfigStore_Record record;
HAL_StatusTypeDef status;
if
((config ==
0
) || (is_valid ==
0
))
// 空指针保护
{
return
HAL_ERROR;
}
*is_valid =
0u
;
// 先假设无效
memset
(&record,
0
,
sizeof
(record));
// 清空 record
status = App_SPIFlash_ReadData(APP_CONFIG_STORE_ADDRESS,
(
uint8_t
*)&record,
(
uint16_t
)
sizeof
(record));
if
(status != HAL_OK)
{
return
status;
// SPI 通信失败
}
if
(App_ConfigStore_IsRecordValid(&record) ==
0u
)
{
return
HAL_OK;
// 读成功了,但数据无效(比如第一次上电)
// 这时 is_valid = 0,调用者会用默认值
}
*config = record.config;
// 把有效配置拷贝出来
*is_valid =
1u
;
// 标记有效
return
HAL_OK;
}
这个函数的逻辑:
-
从 Flash 地址
0x002000读取 24 字节 -
把读到的 24 字节当成一个
App_ConfigStore_Record来解析 -
调用
IsRecordValid()检查四道关卡 -
如果有效 → 把里面的
config部分拷贝给调用者 -
如果无效 → 返回 HAL_OK 但
*is_valid = 0(注意:这不是错误,只是"数据无效")
为什么无效也返回 HAL_OK?
因为"数据无效"不是通信失败------SPI 通信可能完全正常,只是那片 Flash 区域没写过配置。区分"通信失败"和"数据无效"很重要:前者要排查硬件,后者只需要用默认值。
第七部分:加载或使用默认值(最常用的函数)
bash
HAL_StatusTypeDef App_ConfigStore_LoadOrDefault(App_Config *config, uint8_t *used_default)
{
uint8_t
is_valid =
0u
;
HAL_StatusTypeDef status;
if
((config ==
0
) || (used_default ==
0
))
{
return
HAL_ERROR;
}
status = App_ConfigStore_Load(config, &is_valid);
if
(status != HAL_OK)
{
return
status;
// SPI 通信失败,往上报告
}
if
(is_valid ==
0u
)
{
App_ConfigStore_LoadDefault(config);
// Flash 里没有有效配置,用默认值
*used_default =
1u
;
// 告诉调用者"我用了默认值"
}
else
{
*used_default =
0u
;
// Flash 里的配置有效
}
return
HAL_OK;
}
这是 main.c 里直接调用的函数。逻辑很简单:
bash
试着从 Flash 加载
├─ SPI 通信失败 → 返回错误
├─ 数据无效 → 填默认值,used_default = 1
└─ 数据有效 → 直接用,used_default = 0
第八部分:保存配置------本篇最关键的函数
bash
HAL_StatusTypeDef App_ConfigStore_Save(const App_Config *config)
{
App_ConfigStore_Record record;
App_ConfigStore_Record verify_record;
HAL_StatusTypeDef status;
if
(config ==
0
)
{
return
HAL_ERROR;
}
// 1. 清空两个结构体
memset
(&record,
0
,
sizeof
(record));
memset
(&verify_record,
0
,
sizeof
(verify_record));
// 2. 打包:往"信封"里填内容
record.magic = APP_CONFIG_STORE_MAGIC;
record.version = APP_CONFIG_STORE_VERSION;
record.length =
sizeof
(App_Config);
record.config = *config;
// 拷贝参数
record.checksum = App_ConfigStore_RecordChecksum(&record);
// 计算校验值
// 3. 擦除整个扇区
status = App_SPIFlash_SectorErase(APP_CONFIG_STORE_ADDRESS);
if
(status != HAL_OK)
{
return
status;
}
// 4. 写入记录
status = App_SPIFlash_WriteData(APP_CONFIG_STORE_ADDRESS,
(
const
uint8_t
*)&record,
(
uint16_t
)
sizeof
(record));
if
(status != HAL_OK)
{
return
status;
}
// 5. 读回校验
status = App_SPIFlash_ReadData(APP_CONFIG_STORE_ADDRESS,
(
uint8_t
*)&verify_record,
(
uint16_t
)
sizeof
(verify_record));
if
(status != HAL_OK)
{
return
status;
}
// 6. 逐字节比较
if
(
memcmp
(&record, &verify_record,
sizeof
(record)) !=
0
)
{
return
HAL_ERROR;
// 写进去的和读回来的不一样!
}
return
HAL_OK;
}
保存流程每一步在做什么(新手版):
步骤 1:清空结构体
用
memset把 record 和 verify_record 全部填 0。为什么要清空?因为结构体里可能有"空洞"(编译器会在字段之间加填充字节),不清空的话那些填充字节的值是不确定的,会影响 checksum 计算。
步骤 2:打包
把 magic、version、length、config、checksum 逐一填入 record。注意 checksum 是最后算的------它必须基于前面四个字段的最终值。
步骤 3:擦除扇区
调用第 25 篇的
App_SPIFlash_SectorErase(),把0x002000所在的整个 4KB 扇区擦成0xFF。擦除可能需要几十到几百毫秒,函数内部会自动等待 BUSY 清零。
步骤 4:写入记录
调用
App_SPIFlash_WriteData(),把 24 字节的 record 写入 Flash。这个函数内部会自动处理写使能和跨页拆分。
步骤 5:读回校验
从同一个地址读回 24 字节,放到
verify_record里。
步骤 6:逐字节比较
memcmp()比较两个 24 字节数组。如果完全一样,返回 HAL_OK;如果有任何字节不同,返回 HAL_ERROR。
为什么写入后还要读回校验?
因为 SPI 通信可能出错,Flash 写入可能失败,中间可能掉电。你在串口里看到 Save config OK.,说明数据不仅发出去了,而且读回来确认一致------这才是真正的"保存成功"。
main.c 调用方式
1. Includes 区域
bash
/* USER CODE BEGIN Includes */
#include "app_spi_flash.h"
#include "app_config_store.h"
#include <stdio.h>
/* USER CODE END Includes */
为什么写在
USER CODE BEGIN/END之间?CubeMX 重新生成代码时,只保留
USER CODE BEGIN和USER CODE END之间的内容。写在别处,下次生成代码就被覆盖了。
2. USER CODE BEGIN 2
确认这些初始化已经执行:
bash
MX_GPIO_Init();
MX_SPI1_Init();
MX_USART1_UART_Init();
然后添加:
bash
/* USER CODE BEGIN 2 */
App_Config config;
uint8_t
used_default =
0u
;
App_SPIFlash_Init();
printf
(
"\r\nSPI Flash config store demo\r\n"
);
if
(App_ConfigStore_LoadOrDefault(&config, &used_default) == HAL_OK)
{
if
(used_default !=
0u
)
{
printf
(
"No valid config, use default.\r\n"
);
}
else
{
printf
(
"Load config from SPI Flash.\r\n"
);
}
config.boot_count++;
App_ConfigStore_Print(&config);
if
(App_ConfigStore_Save(&config) == HAL_OK)
{
printf
(
"Save config OK.\r\n"
);
}
else
{
printf
(
"Save config failed.\r\n"
);
}
}
else
{
printf
(
"Load config failed.\r\n"
);
}
/* USER CODE END 2 */
main.c 代码做了什么(逐段解释)
变量声明:
bash
App_Config config;
// 你的"配置本"
uint8_t
used_default =
0u
;
// 标记:是否使用了默认值
初始化:
bash
App_SPIFlash_Init();
// 把 CS 拉高,Flash 进入待机
printf
(
"...SPI Flash config store demo\r\n"
);
// 打印标题
加载配置:
bash
App_ConfigStore_LoadOrDefault(&config, &used_default)
这是最关键的一步。它做了:
-
从 Flash 读 24 字节
-
检查 magic / version / length / checksum
-
如果全通过 → config 里就是上次保存的值,used_default = 0
-
如果没通过 → config 里是默认值,used_default = 1
判断并打印:
bash
if
(used_default !=
0u
)
printf
(
"No valid config, use default.\r\n"
);
else
printf
(
"Load config from SPI Flash.\r\n"
);
boot_count 加 1:
bash
config.boot_count++;
无论是从 Flash 加载还是用默认值,boot_count 都加 1。
打印当前参数:
bash
App_ConfigStore_Print(&config);
输出 boot_count、uart_baudrate、sample_interval_ms、led_enable。
保存到 Flash:
bash
App_ConfigStore_Save(&config)
擦除 → 写入 → 读回校验。成功打印 Save config OK.,失败打印 Save config failed.。
3. while 循环
不要在 while 里反复保存:
bash
while
(
1
)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
HAL_Delay(
1000
);
/* USER CODE END 3 */
}
第一次上电运行:你会看到什么
烧录程序后打开串口(115200 波特率),按一下复位,你应该看到:
bash
SPI Flash config store demo
No valid config, use default.
boot_count=1
uart_baudrate=115200
sample_interval_ms=1000
led_enable=1
Save config OK.
逐行解读:
-
第 1 行:标题
-
第 2 行:
No valid config, use default.→ Flash 里没有有效配置(因为是第一次运行),所以用了默认值 -
第 3~6 行:默认参数。注意
boot_count=1,因为默认值是 0,然后 +1 -
第 7 行:
Save config OK.→ 当前参数已经保存到 Flash
现在按一下复位键(或断电再上电),你应该看到:
bash
SPI Flash config store demo
Load config from SPI Flash.
boot_count=2
uart_baudrate=115200
sample_interval_ms=1000
led_enable=1
Save config OK.
关键变化:
-
第 2 行变成了
Load config from SPI Flash.→ 说明从 Flash 读到了有效配置 -
boot_count=2→ 比上次增加了 1,说明上次保存的值被正确读回了 -
其他参数和第一次一样
再复位一次,boot_count 会变成 3。再复位变 4。每次都能稳定增加。
如果看到的不是这样,下面是分步骤的诊断方法:
-
如果第一行 "SPI Flash config store demo" 都没出来 → 串口没配好,回头看第 07 篇
-
如果每次都是 "No valid config" → 保存失败,看下文常见问题排查第 1 条
-
如果
Save config OK.但复位后boot_count不变 → Flash 写入可能没生效,看下文第 2 条 -
如果程序卡住不动 → 擦除或写入超时,看下文第 3 条
验证通过的标志: 连续复位 3 次,每次 boot_count 都增加 1,且每次都打印 Save config OK.。
关键代码解释
1. magic 是干什么的
magic 用来判断:
bash
这块 Flash 区域是不是我们写过的配置
本篇使用:
bash
#define APP_CONFIG_STORE_MAGIC 0x31474643u
这个数字不是随便选的。把它拆成 4 个字节:
bash
0x31 = '1'
0x47 = 'G'
0x46 = 'F'
0x43 = 'C'
在 Flash 里(小端序)存储为:
bash
43 46 47 31
你可以理解成 CFG1 的某种编码。
如果第一次上电,Flash 里大概率是:
bash
FF FF FF FF
就不会匹配这个 magic:
bash
0xFFFFFFFF ≠ 0x31474643
这时程序就知道:
bash
没有有效配置,使用默认值
magic 会不会碰巧对上了?
概率极低。Flash 出厂是 0xFF,即使你之前写过其他数据,要碰巧等于 0x31474643 几乎不可能。如果真的担心,可以选择更长的 magic 或者用两个 magic 字段。
2. version 是干什么的
version 用来处理后续升级。
比如现在版本 1 的参数是:
bash
boot_count
uart_baudrate
sample_interval_ms
led_enable
以后你新增一个:
bash
device_id
结构体格式就变了。
这时可以把版本号改成 2。
程序发现 Flash 里是版本 1,而当前代码要版本 2,就可以选择:
bash
使用默认值
或者做一次参数迁移(把旧版本的字段读出来,填到新版本结构体里)
本篇先不做迁移,只做版本匹配。
一个具体的版本升级场景:
bash
版本 1:4 个字段,12 字节
版本 2:5 个字段,16 字节(新增 device_id)
Flash 里存的是版本 1 的记录(24 字节)
新代码的 version = 2, sizeof(App_Config) = 16
Load 时:
magic 通过 ✓
version: 1 ≠ 2 → 不通过 ✗
→ 使用默认值
3. length 是干什么的
length 记录的是:
bash
sizeof(App_Config)
它能进一步确认结构体长度是否匹配。
如果版本号忘了改,但结构体长度变了,length 还能帮你挡一次。
bash
场景:你加了一个字段,忘了改 version
sizeof(App_Config) 变了(比如 12 → 16)
Flash 里存的 length = 12
当前 sizeof(App_Config) = 16
12 ≠ 16 → 不通过 ✗
→ 使用默认值
所以 length 是 version 的一个补充防线。
4. checksum 是干什么的
checksum 用来检查数据有没有损坏。
比如:
-
写入中途掉电------写入只完成了一半,checksum 对不上
-
SPI 通信异常------某些位传错了
-
Flash 某个字节异常------Flash 老化或外部干扰导致数据翻转
这些都可能导致 checksum 对不上。
一旦 checksum 不对,本篇代码就会认为:
bash
这份配置无效
然后使用默认值。
checksum 的局限性(需要知道):
-
它不是 CRC32,不能检测所有类型的错误
-
它不能纠正错误,只能检测
-
如果两个字节同时变化且"恰好"相互抵消,可能检测不出来(概率极低)
对于入门级别的参数保存,这个简化版 checksum 够用了。真实产品里可以换成 CRC32 或加 ECC。

5. 为什么保存前要擦除整个扇区
这一点和第 25 篇一样。
SPI Flash 写入只能把 1 写成 0。
如果你想让某些 0 变回 1,必须擦除。
本篇的保存逻辑是:
bash
擦除配置扇区
写入配置记录
读回校验
这是最简单的入门方式。
缺点也很明显:
bash
每保存一次,就擦写一次扇区
所以真实项目里不要高频保存。
具体来说,一次保存发生了什么:
bash
1. Sector Erase 0x002000
→ 0x002000 ~ 0x002FFF 全部变成 FF
→ 耗时 ~45ms(典型值)
2. Write Enable + Page Program 24 字节到 0x002000
→ 0x002000 开始的 24 字节被写入
→ 耗时 ~0.7ms
3. Read Data 24 字节
→ 读回来和内存中的 record 逐字节对比
→ 耗时忽略不计
整个过程不到 50ms,但每次都会擦写 Flash。如果放在 while 循环里每秒执行一次,一天就是 86400 次擦写。大多数 SPI Flash 的擦写寿命是 10 万次级别,不到两天就耗尽了。
6. 为什么写入后还要读回校验
即使 SPI 通信正常,写入也可能出现意外:
-
写入中途掉电------Flash 里的数据不完整
-
SPI 总线受到干扰------某个位写错了
-
Flash 内部故障------写入缓冲区出现问题
memcmp() 把"我想写的"和"实际写进去的"逐字节对比。如果有任何一个字节不同,返回 HAL_ERROR。
如果不做读回校验会怎样?
假如写入到一半掉电了,Flash 里可能是:
bash
43 46 47 31 01 00 0C 00 01 00 00 00 FF FF FF FF FF FF FF FF FF FF FF FF
\_________/ \___/ \___/ \___________________/ \___________________________/
magic OK ver OK len OK config 前半 OK config 后半 + checksum = FF
没有读回校验,程序不知道数据是坏的。下次上电时,checksum 检查会发现不对(因为后半是 FF),然后使用默认值------这还好。但如果碰巧 checksum 也算对了(概率极低),程序就会用一份半好半坏的配置,导致难以排查的 bug。
有了读回校验,写入失败当场就能发现,不会留下隐患。

移植到其他板子的修改点
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
SPI 实例
|
不同板子可能是 SPI1/SPI2
| APP_SPI_FLASH_HANDLE |
|
CS 引脚
|
板载 Flash 或模块 CS 不同
|
CubeMX User Label = SPI_FLASH_CS
|
|
配置保存地址
|
避免和字体、日志、图片等数据冲突
| APP_CONFIG_STORE_ADDRESS |
|
扇区大小
|
不同 Flash 擦除粒度可能不同
| APP_SPI_FLASH_SECTOR_SIZE |
|
擦除命令
|
常见 4KB 扇区擦除是 0x20
| APP_SPI_FLASH_CMD_SECTOR_ERASE |
|
参数结构体
|
项目参数不同
| App_Config |
|
默认参数
|
不同项目默认值不同
| App_ConfigStore_LoadDefault() |
|
校验方式
|
可换成 CRC32
| App_ConfigStore_Checksum32() |
|
magic 值
|
不同项目可以用不同的 magic 区分
| APP_CONFIG_STORE_MAGIC |
移植时改参数结构体的步骤:
-
修改
app_config_store.h中的App_Config结构体------增删你的参数字段 -
修改
app_config_store.c中的App_ConfigStore_LoadDefault()------填上你的默认值 -
修改
app_config_store.c中的App_ConfigStore_Print()------打印你的新字段 -
把
APP_CONFIG_STORE_VERSION加 1(因为结构体变了) -
重新编译,烧录,验证
如果你换了 Flash 芯片,至少查这些内容:
bash
页大小
扇区大小
擦除命令
写入命令
忙状态位
最大擦除时间
最大写入时间
常见问题排查
1. 每次上电都显示 No valid config
这是最常见的问题。优先查:
|
检查项
|
说明
|
| --- | --- |
|
保存是否成功
|
看串口有没有 Save config OK.。如果有,说明写入+读回校验通过了
|
|
地址是否一致
|
保存和读取都必须用同一个 APP_CONFIG_STORE_ADDRESS
|
|
是否擦错扇区
|
地址必须落在你预留的配置扇区。比如 0x002000 在第二个 4KB 扇区
|
|
Flash 写保护
|
有些 Flash 状态寄存器可能开启了写保护。上电后读一下状态寄存器看看
|
|
复位方式
|
软件复位可能不清 Flash,试试完全断电再上电
|
最可能的原因:
如果你看到 Save config OK.,但下次上电还是 No valid config,很可能是"读的地址"和"写的地址"不一致。
检查 app_config_store.c 中的 APP_CONFIG_STORE_ADDRESS 是否被定义了多次(比如在 main.c 中定义了一个,在头文件中又有默认值)。
如何快速定位:
在 App_ConfigStore_Load() 里加一行调试打印:
bash
status = App_SPIFlash_ReadData(APP_CONFIG_STORE_ADDRESS,
(
uint8_t
*)&record,
(
uint16_t
)
sizeof
(record));
// 调试用:打印读回来的原始 24 字节
printf
(
"Raw data @ 0x%06lX: "
, (
unsigned
long
)APP_CONFIG_STORE_ADDRESS);
for
(
int
i =
0
; i <
24
; i++) {
printf
(
"%02X "
, ((
uint8_t
*)&record)[i]);
}
printf
(
"\r\n"
);
如果读回来全是 FF,说明这个地址上没有写过数据------要么写的时候用了不同的地址,要么擦除后写入失败但没报错。
如果保存时失败,先回到第 25 篇排查写入链路。
2. boot_count 不增加
常见原因:
-
没有调用
App_ConfigStore_Save(); -
保存失败但没有看串口输出;
-
复位后又加载了默认值(
boot_count被重置为 0 然后再 +1 = 1,所以每次都是 1); -
APP_CONFIG_STORE_ADDRESS改来改去; -
Flash 写入不稳定。
先确认串口中是否有:
bash
Save config OK.
如果没有,说明保存失败了。先排查保存失败的原因(看下文第 3 条)。
如果每次打印的都是 boot_count=1,说明每次都在使用默认值------Flash 里的配置没有被正确加载。回到第 1 条排查。
3. Save config failed. 或程序卡住
Save config failed. 说明保存过程中的某一步失败了。
优先查:
|
检查项
|
说明
|
| --- | --- |
|
擦除是否成功
|
擦除失败会导致后续写入无效
|
|
写入是否成功
|
WriteData 返回非 HAL_OK
|
|
读回校验是否通过
|
memcmp 不匹配
|
|
BUSY 是否一直不清零
|
擦除或写入等待超时
|
|
SPI 是否还能读状态寄存器
|
读状态失败会返回错误
|
|
超时时间是否太短
|
不同 Flash 擦除时间不同,擦除超时默认 5000ms
|
|
Flash 是否供电稳定
|
擦写时供电不稳容易出问题
|
程序卡住不动,大概率是 BUSY 超时:
App_SPIFlash_WaitUntilReady() 里有一个超时循环。如果 Flash 一直不把 BUSY 清零,程序就会卡在循环里直到超时(默认 5000ms)。
5 秒后超时,程序会继续执行但返回 HAL_TIMEOUT。如果 Save 没检查返回值就继续,后面可能出现奇怪现象。
4. 编译报 undefined symbol App_ConfigStore_LoadOrDefault
说明:
bash
app_config_store.c 没有加入 Keil 工程
右键 Application/User/Core,添加:
bash
Core/Src/app_config_store.c
5. 编译报 undefined reference to 'offsetof'
说明没有包含 <stddef.h>。检查 app_config_store.c 顶部是否有:
bash
#include <stddef.h>
6. 结构体加字段后,旧参数读不出来
这是正常的。
因为本篇会检查:
bash
version
length
checksum
结构体变了,旧记录就可能失效。
具体来说:
-
length变了 → 第三关不通过 → 使用默认值 -
即使
length碰巧一样(不太可能),checksum也会不一样 → 第四关不通过
入门阶段可以直接使用默认值------旧参数丢了,但不影响程序正常运行。
真实项目里可以做"版本迁移",比如:
-
读旧版本记录时,发现 version = 1
-
把 version 1 的字段映射到 version 2 的结构体(能复用的复用,新字段用默认值)
-
保存成 version 2 格式
这篇先不展开。
7. 可以把参数每秒保存一次吗
不建议。
Flash 有擦写寿命。典型 W25Qxx 的擦写寿命在 10 万次以上。
如果每秒保存一次:
bash
一天 = 86400 次
10 万次 ÷ 86400 ≈ 1.15 天
不到两天就达到芯片标称寿命了。
参数保存一般应该放在明确事件里,比如:
-
用户修改参数后保存;
-
收到保存命令后保存;
-
设备关机前保存(需要掉电检测电路);
-
参数变化累计到一定次数后保存;
-
参数变化后延迟一段时间(比如 5 秒)再保存,避免连续修改导致的频繁擦写。
不要在主循环里无条件保存。
8. 我换了 Flash 芯片,checksum 验证总是失败
不同 Flash 的擦除后状态可能不同(虽然绝大多数是 0xFF)。先确认:
-
擦除命令是否正确(常见
0x20但也有0xD7等) -
扇区大小是否匹配
-
页大小是否匹配(影响 WriteData 的跨页拆分)
如果这些都对,在 Save 函数中的读回校验前加一段调试打印,比较 "写入前" 和 "读回后" 的原始字节。
9. 参数结构体为什么要加 reserved 字段
bash
uint8_t
reserved;
这个字段目前没有实际用途,但它给以后扩展留下了空间。
比如以后你想加一个 uint8_t 的小字段,可以直接把 reserved 改名使用,结构体大小不变------这样 Flash 里的旧数据还能兼容(length 不变,version 可以不变)。
如果没有预留字段,每次加字段都会改变结构体大小,导致旧数据全部失效。
本篇小结
这一篇我们把 SPI Flash 写入能力,整理成了一个简单的参数保存模块。
你现在应该知道:
-
不能直接盲目使用 Flash 里的结构体------要先判断是不是有效配置;
-
magic用来判断"这块区域是不是我们写过的配置"; -
version用来处理格式版本------结构体升级时区分新旧格式; -
length用来检查结构体大小------作为 version 的补充防线; -
checksum用来判断数据有没有损坏------写入掉电、通信异常都能检测; -
第一次上电没有配置时,要使用默认值(出厂设置);
-
保存参数会擦写 Flash,不要高频调用------放在明确事件里保存;
-
写入后读回校验是必须的------确保数据真的写入了;
-
本篇只是单扇区入门版,还没有做双备份和磨损均衡。
本篇完成检查清单:
-
第一次运行显示
No valid config, use default. -
复位后显示
Load config from SPI Flash. -
boot_count每次复位都增加 1 -
每次都能看到
Save config OK. -
能用自己的话解释
magic/version/length/checksum四个字段的作用 -
能说出为什么不能每秒保存一次参数
-
知道
0x002000是配置保存地址,换算成十进制是 8192
下一篇我们进入 CAN:
STM32 CAN 入门:先让两块板子互相发一帧。
CAN 会比 I2C、SPI 更接近工业现场一点。下一篇我们先把收发器、终端电阻、波特率、过滤器这些容易卡住新手的点讲清楚。