STM32 零基础可移植教程 26:SPI Flash 保存参数,做一个掉电不丢的配置结构体

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 添加文件的步骤(零基础版):

  1. 在 Keil 左侧工程树里找到 Core/Src 分组

  2. 右键点击 Core/SrcAdd Existing Files to Group

  3. 浏览到工程目录下的 Core/Src/app_config_store.c,选中添加

  4. 确认两个 .c 文件(app_spi_flash.capp_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 给 Checksum32length 参数,意思是"只计算前 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;

}

这个函数的逻辑:

  1. 从 Flash 地址 0x002000 读取 24 字节

  2. 把读到的 24 字节当成一个 App_ConfigStore_Record 来解析

  3. 调用 IsRecordValid() 检查四道关卡

  4. 如果有效 → 把里面的 config 部分拷贝给调用者

  5. 如果无效 → 返回 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 BEGINUSER 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)

这是最关键的一步。它做了:

  1. 从 Flash 读 24 字节

  2. 检查 magic / version / length / checksum

  3. 如果全通过 → config 里就是上次保存的值,used_default = 0

  4. 如果没通过 → 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。每次都能稳定增加。

如果看到的不是这样,下面是分步骤的诊断方法:

  1. 如果第一行 "SPI Flash config store demo" 都没出来 → 串口没配好,回头看第 07 篇

  2. 如果每次都是 "No valid config" → 保存失败,看下文常见问题排查第 1 条

  3. 如果 Save config OK. 但复位后 boot_count 不变 → Flash 写入可能没生效,看下文第 2 条

  4. 如果程序卡住不动 → 擦除或写入超时,看下文第 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 → 不通过 ✗

  → 使用默认值

所以 lengthversion 的一个补充防线。

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 |

移植时改参数结构体的步骤:

  1. 修改 app_config_store.h 中的 App_Config 结构体------增删你的参数字段

  2. 修改 app_config_store.c 中的 App_ConfigStore_LoadDefault()------填上你的默认值

  3. 修改 app_config_store.c 中的 App_ConfigStore_Print()------打印你的新字段

  4. APP_CONFIG_STORE_VERSION 加 1(因为结构体变了)

  5. 重新编译,烧录,验证

如果你换了 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 也会不一样 → 第四关不通过

入门阶段可以直接使用默认值------旧参数丢了,但不影响程序正常运行。

真实项目里可以做"版本迁移",比如:

  1. 读旧版本记录时,发现 version = 1

  2. 把 version 1 的字段映射到 version 2 的结构体(能复用的复用,新字段用默认值)

  3. 保存成 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 更接近工业现场一点。下一篇我们先把收发器、终端电阻、波特率、过滤器这些容易卡住新手的点讲清楚。

相关推荐
Szime1 小时前
深智微40Gsps高速数据采集系统进入工程化阶段
科技·单片机·嵌入式硬件·fpga开发
fffzd2 小时前
STM32:OLED原理
stm32·单片机·嵌入式硬件·iic·oled·嵌入式软件
清风66666612 小时前
基于单片机与DAC0832的双路波形信号发生系统设计
单片机·嵌入式硬件·毕业设计·课程设计·期末大作业
azwsm13 小时前
电路元器件和GPIO控制器
单片机·嵌入式硬件
kebidaixu16 小时前
FreeRTOS 移植到 STM32F407VETX 记录(一)
stm32·单片机·嵌入式硬件
CSDN官方博客17 小时前
「谁说嵌入式只是调包和焊板子?」—— 2026嵌入式全栈技术征锋令
嵌入式硬件·物联网·embedding
半条-咸鱼17 小时前
【INACCESSIBLE_BOOT_DEVICE】安装 Config Tool 后 Windows 蓝屏,最终通过 VMware 虚拟机解决
windows·stm32·vmware·芯片
点灯小铭17 小时前
基于单片机的数码管定时插座设计与定时开关功能实现
单片机·嵌入式硬件·毕业设计·课程设计·期末大作业
云栖梦泽18 小时前
玩转RK3506SDK
linux·嵌入式硬件