一句话结论:片内 Flash 不能「就地改」——只能把 1 改成 0、必须整扇区擦除,而且写一半掉电是常态。正确做法是日志式追加 + CRC 校验 + 双 bank 轮换:本文把每一种掉电位置都实测了一遍,数据一次都没坏。
一、完整工程下载
压缩包内含全部源码、platformio.ini、Makefile、README.md,解压即用,不需要额外配置。
下载 flash-kv.zip (16.9 KB,共 11 个文件)
.gitignore
Makefile
README.md
include/
crc16.h
flash_kv.h
flash_sim.h
platformio.ini
src/
crc16.c
flash_kv.c
flash_sim.c
test/
test_flash_kv.c一、为什么不能"直接往 Flash 写参数"
片内 Flash(STM32 的 Flash、ESP32 的 NVS 底层、外挂的 NOR Flash)有三个硬约束:
| 约束 | 后果 |
|---|---|
| 只能把 1 写成 0,不能把 0 写回 1 | 改一个字节,通常得整扇区擦掉 |
| 擦除粒度是扇区(STM32F103 是 1KB 或 2KB) | 改 4 字节要擦 1024 字节 |
| 擦写寿命有限(典型 10k~100k 次) | 反复擦同一个扇区,很快就坏了 |
| 擦除/写入过程中掉电,内容就是垃圾 | 直接读回来可能是任意值 |
所以「把参数放在地址 X」这种做法,遇到掉电就是灾难。 正确的思路是把它当成日志:
[记录1][记录2][记录3][记录4] ...每次"改参数"就是在后面追加一条新记录,读取时取最后一条有效记录。 这样任何时刻掉电,最坏情况只是丢掉最后一条写了一半的记录, 之前的数据全部完好。
二、记录格式
每条记录固定头部 + 变长载荷 + 尾部 CRC:
偏移 长度 内容
0 1 magic = 0xA5 (用来快速判断"这里是不是一条记录")
1 1 klen = 键长度
2 1 vlen = 值长度
3 4 seq = 单调递增序号(判断"谁更新")
7 klen key
... vlen value
尾部 2 CRC16-CCITT,覆盖从 magic 到 value 的全部字节三个设计要点:
seq必须在整个存储生命期内单调递增,不能只在单个 bank 内递增。
因为双 bank 轮换时,靠 seq 才能判断到底哪份更新。
- CRC 放在尾部:顺序写入时,只有整条记录都写完了 CRC 才有效。
写一半掉电 ⇒ CRC 必然对不上 ⇒ 自动丢弃。这比"先写长度再写数据"可靠得多。
magic用 0xA5:擦除后是 0xFF,写入 0xA5 会变成 0xA5;
一条记录被部分覆盖时 magic 往往会先坏掉,扫描可以立刻停。
三、双 bank 轮换:解决寿命与压缩
日志式追加迟早会把区域写满(因为被覆盖的旧记录还占着空间)。 写满后必须压缩:把当前所有有效记录搬到另一块区域,然后擦掉旧的。
Bank A: [r1][r2][r3][r1'][r2'] <- 满了
↓ 压缩
Bank B: [r3][r1'][r2'] <- 只保留每个键的最后一条
Bank A: 擦除,等待下次接手这一进一出正好实现了磨损均衡:擦除操作在 A、B 之间轮流发生, 而不是盯着一块扇区反复擦。本文实测:写 5000 次共擦除 156 次, 两个 bank 各 78 次,差 0。
关键顺序(顺序错了掉电就丢数据):
- 擦除备用 bank(此时活动 bank 完好)
- 把有效记录写进备用 bank
- 切换活动 bank 指针
- 最后才擦除旧 bank
第 2 步掉电 ⇒ 旧 bank 还在,挂载时读到旧数据(可接受,丢最后一次写)。 第 4 步掉电 ⇒ 新 bank 有效记录更完整、seq 更大,挂载时新 bank 胜出。
四、挂载时怎么恢复
上电挂载就是一次扫描:
for 每个 bank:
从 bank 起始地址顺序解析记录
遇到第一条 CRC 失败 / magic 不对 / 长度越界 的记录就停(说明写到这里断电了)
把有效记录里的 (key, value, seq) 收下来
把两个 bank 的结果按 key 归并,同一个 key 取 seq 最大的那条
活动 bank = 有效记录结束位置最大的那个 bank
写指针 = 该 bank 里第一条无效记录的位置不需要任何额外的"元数据区",也不会因为元数据本身掉电而认不出整个存储。
五、工程内容
flash_sim.c/h:RAM 模拟的 NOR Flash,严格实现
"只能 1→0"、"擦除整扇区"、"擦写次数统计", 并且支持 注入掉电(写到第 N 字节时断电)和 注入位翻转
flash_kv.c/h:上面这套日志式 KV 存储(追加 / 读取 / 删除 / 压缩 / 挂载恢复)crc16.c/h:CRC16-CCITT 运行时生成查表(不手抄表,见下文)test/test_flash_kv.c:6 组实验,含逐字节遍历所有掉电位置
六、主机实测(本文数据来源)
| 实验 | 结果 |
|---|---|
| 基本读写 / 覆盖 / 删除 | 全部正确,删除后 get 返回「不存在」 |
| 写满自动压缩(写 400 次) | 触发 6 次压缩,8 个键全部保留,值都是最后一次写入的 |
| 磨损均衡(写 5000 次) | 两个 bank 各擦 78 次,差 0(累计擦除 156 次) |
| 逐字节掉电 | 4 轮 × 0~15 字节共 64 个掉电场景,读到垃圾的次数:0 |
| 位翻转损坏 | 被 CRC 抓到,只丢被破坏的那一条,其他键完好 |
| 非法输入 | 超长 key/value、不存在的键全部被正确拒绝 |
七、几个必须知道的坑
- 记录里的 key 不带结束符,所以扫描时必须用
klen做定长拷贝,
不能用 strlen(key)。本文第一版就栽在这里:"pw" 被读成了 "pwd" (把 value 的第一个字节当成了 key 的一部分)。它不崩、不报错, 只是 remount 之后永远读不到这个键——这种 bug 只能靠测试用例抓。
- 不要手抄 CRC 表。256 项的表极容易抄错一位,而且错了不会崩,
只会在某些数据上校验失败——排查起来极其痛苦。运行时用 build_table() 生成一次即可(本文 crc16.c 就是这么做的)。
seq要断电不丢。挂载时把最大seq读出来,之后从这个值继续递增;
如果每次复位都从 0 开始,新旧记录就分不清了。
- 活动 bank 不能靠"谁写得更长"判断。压缩完成后新 bank 一定更短,
必须用「哪个 bank 含有 seq 更大的记录」来判断。
- Flash 写入前必须对齐半字(STM32F1 是 16 位编程)。
本文的仿真器按字节写,实际移植时要在 flash_kv 里做 2 字节缓冲。
- 擦除时间很长(STM32F103 擦 1KB 约 20~40 ms)。
擦除期间取指会停顿,中断响应变差——不要在高速控制循环里做压缩, 放到开机自检或空闲任务里。
- 别把 KV 区和程序区放同一个扇区。压缩时要擦整个扇区,
一不小心就把代码擦了,直接变砖。
- 省寿命的小技巧:把频繁变化的量(比如运行时长)先放在 RAM,
只在关机前写一次;或者做「掉电检测 + 大电容」, 检测到掉电后靠电容撑住最后几毫秒写完。
八、进阶方向
- 换成 外挂 SPI NOR(W25Q 系列),把这一层直接复用,容量大得多
- 加掉电检测中断(PVD)+ 超级电容,做到真正的"零丢失"
- 加磨损计数:每个 bank 头里存一个擦除次数,快到期时提示更换
- 换成 Wear Leveling 文件系统(LittleFS / SPIFFS)做通用文件存储
完整代码
Makefile
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2 -Iinclude
LDLIBS ?=
SRC = src/crc16.c src/flash_sim.c src/flash_kv.c
TEST = test/test_flash_kv.c
ifeq ($(OS),Windows_NT)
EXT = .exe
endif
BIN = build/test$(EXT)
all: run
$(BIN): $(SRC) $(TEST)
@mkdir -p build
$(CC) $(CFLAGS) $(SRC) $(TEST) -o $(BIN) $(LDLIBS)
run: $(BIN)
@$(BIN)
clean:
rm -rf build
.PHONY: all run cleaninclude/crc16.h
/**
* crc16.h - CRC16-CCITT (多项式 0x1021,初值 0xFFFF)
*
* 用运行时生成的查表法:表只生成一次,之后每次校验都很快。
* 不要手抄 256 项的表——抄错一位不会崩,只会让某些数据校验失败。
*/
#ifndef CRC16_H
#define CRC16_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define CRC16_INIT 0xFFFFu
/** 逐位计算(慢,但可用于自检/对照) */
uint16_t crc16_bitwise(const uint8_t *data, uint32_t len, uint16_t init);
/** 查表计算(第一次调用时自动建表) */
uint16_t crc16(const uint8_t *data, uint32_t len);
/** 连续计算:用于分段喂数据 */
typedef struct {
uint16_t state;
} crc16_ctx_t;
void crc16_start(crc16_ctx_t *c);
void crc16_update(crc16_ctx_t *c, const uint8_t *data, uint32_t len);
uint16_t crc16_finish(const crc16_ctx_t *c);
#ifdef __cplusplus
}
#endif
#endif /* CRC16_H */include/flash_kv.h
/**
* flash_kv.h - 日志式键值存储(掉电安全 + 双 bank 磨损均衡 + CRC16)
*
* 记录格式:
* magic(1) | klen(1) | vlen(1) | seq(4,LE) | key | val | crc16(2,LE)
* CRC 覆盖从 magic 到 val 的全部字节。
*
* 双 bank 轮换:活动 bank 写满后,把有效记录压缩到备用 bank,
* 切换活动指针,最后才擦除旧 bank —— 每一步掉电都可恢复。
*/
#ifndef FLASH_KV_H
#define FLASH_KV_H
#include <stdint.h>
#include "flash_sim.h"
#ifdef __cplusplus
extern "C" {
#endif
#define FKV_BANK_COUNT 2
#define FKV_BANK_SECTORS 1 /* 每个 bank 占几个扇区 */
#define FKV_BANK_SIZE (FKV_BANK_SECTORS * FSIM_SECTOR_SIZE)
#define FKV_KEY_MAX 15
#define FKV_VAL_MAX 48
#define FKV_ITEMS_MAX 12
#define FKV_HDR_SIZE 7 /* magic + klen + vlen + seq */
#define FKV_CRC_SIZE 2
#define FKV_MAGIC 0xA5u
#define FKV_ERASED 0xFFu
#define FKV_OK 0
#define FKV_ERR_NO_SPACE (-1)
#define FKV_ERR_NOT_FOUND (-2)
#define FKV_ERR_KEY_LONG (-3)
#define FKV_ERR_VAL_LONG (-4)
#define FKV_ERR_IO (-5)
#define FKV_ERR_FULL (-6) /* 键太多,索引装不下 */
typedef struct {
char key[FKV_KEY_MAX + 1];
uint8_t val[FKV_VAL_MAX];
uint8_t vlen;
uint32_t seq;
} fkv_item_t;
typedef struct {
flash_sim_t *flash;
uint32_t base;
int active_bank;
uint32_t write_addr; /* 下一次追加的位置 */
uint32_t seq; /* 下一个要用的序号(挂载时恢复到最大值+1) */
fkv_item_t items[FKV_ITEMS_MAX];
int n_items;
uint32_t compact_count;
uint32_t write_count;
} fkv_t;
/** 挂载(扫描两个 bank 并归并)。返回 FKV_OK 或错误码。 */
int fkv_mount(fkv_t *kv, flash_sim_t *flash, uint32_t base);
/** 写入 / 覆盖一个键。返回 FKV_OK 或错误码。 */
int fkv_set(fkv_t *kv, const char *key, const void *val, uint8_t vlen);
/** 读取。vlen 传入缓冲区大小,返回时被改写成实际长度;可为 NULL。 */
int fkv_get(const fkv_t *kv, const char *key, void *val, uint8_t *vlen);
/** 删除(写一条 vlen=0 且 key 存在的"墓碑"记录)。 */
int fkv_del(fkv_t *kv, const char *key);
/** 统计两个 bank 里各有多少条记录被扫描成功(调试用,不改变索引)。 */
void fkv_stat(fkv_t *kv, int *bank_records);
#ifdef __cplusplus
}
#endif
#endif /* FLASH_KV_H */include/flash_sim.h
/**
* flash_sim.h - 用 RAM 模拟的 NOR Flash(主机端验证用)
*
* 严格实现 NOR Flash 的三个硬约束:
* 1. 只能把 1 写成 0,不能把 0 写回 1
* 2. 擦除粒度是扇区,擦完全是 0xFF
* 3. 支持注入掉电与位翻转,用来验证掉电安全
*/
#ifndef FLASH_SIM_H
#define FLASH_SIM_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define FSIM_SECTOR_SIZE 1024u
#define FSIM_SECTOR_COUNT 8u
#define FSIM_SIZE (FSIM_SECTOR_SIZE * FSIM_SECTOR_COUNT)
#define FSIM_OK 0
#define FSIM_ERR_RANGE 1
#define FSIM_ERR_NOT_ERASED 2 /* 试图把 0 写成 1 */
#define FSIM_ERR_POWER 3 /* 掉电了 */
typedef struct {
uint8_t mem[FSIM_SIZE];
uint32_t erase_count[FSIM_SECTOR_COUNT];
uint32_t write_bytes; /* 累计写入字节 */
uint32_t erase_ops;
int powered; /* 0 = 已掉电,之后所有操作都失败 */
int32_t cut_after; /* >=0:下一次写入只提交这么多字节然后掉电 */
unsigned rng;
} flash_sim_t;
void fsim_init(flash_sim_t *f);
/** 重新上电,Flash 内容保留(模拟复位) */
void fsim_power_cycle(flash_sim_t *f);
/** 安排一次掉电:下一次写入只提交 n 个字节就断电 */
void fsim_schedule_power_cut(flash_sim_t *f, int32_t n);
int fsim_read(const flash_sim_t *f, uint32_t addr, void *buf, uint32_t len);
int fsim_write(flash_sim_t *f, uint32_t addr, const void *buf, uint32_t len);
int fsim_erase_sector(flash_sim_t *f, uint32_t sector);
/** 注入一个位翻转(模拟数据损坏 / 宇宙射线) */
void fsim_flip_bit(flash_sim_t *f, uint32_t addr, uint8_t bit);
/** 已发生多少次擦除(总计 / 最多那一块) */
uint32_t fsim_total_erases(const flash_sim_t *f);
uint32_t fsim_max_erases(const flash_sim_t *f);
void fsim_dump_erase_counts(const flash_sim_t *f);
#ifdef __cplusplus
}
#endif
#endif /* FLASH_SIM_H */platformio.ini
[platformio]
default_envs = bluepill
[env:bluepill]
platform = ststm32
board = bluepill_f103c8
framework = arduino
upload_protocol = stlink
monitor_speed = 115200
build_flags =
-Wall
-Wextra
-Isrc
lib_ldf_mode = deep+src/crc16.c
#include "crc16.h"
#include <string.h>
#define CRC16_POLY 0x1021u
static uint16_t s_table[256];
static int s_table_ready = 0;
static void build_table(void)
{
uint32_t i;
int b;
for (i = 0; i < 256u; i++) {
uint16_t crc = (uint16_t)(i << 8);
for (b = 0; b < 8; b++) {
if (crc & 0x8000u) {
crc = (uint16_t)((crc << 1) ^ CRC16_POLY);
} else {
crc = (uint16_t)(crc << 1);
}
}
s_table[i] = crc;
}
s_table_ready = 1;
}
uint16_t crc16_bitwise(const uint8_t *data, uint32_t len, uint16_t init)
{
uint16_t crc = init;
uint32_t i;
int b;
for (i = 0; i < len; i++) {
crc ^= (uint16_t)((uint16_t)data[i] << 8);
for (b = 0; b < 8; b++) {
if (crc & 0x8000u) {
crc = (uint16_t)((crc << 1) ^ CRC16_POLY);
} else {
crc = (uint16_t)(crc << 1);
}
}
}
return crc;
}
uint16_t crc16(const uint8_t *data, uint32_t len)
{
uint16_t crc;
uint32_t i;
if (!s_table_ready) {
build_table();
}
crc = CRC16_INIT;
for (i = 0; i < len; i++) {
crc = (uint16_t)((crc << 8) ^ s_table[((crc >> 8) ^ data[i]) & 0xFFu]);
}
return crc;
}
void crc16_start(crc16_ctx_t *c)
{
if (!s_table_ready) {
build_table();
}
c->state = CRC16_INIT;
}
void crc16_update(crc16_ctx_t *c, const uint8_t *data, uint32_t len)
{
uint32_t i;
if (!s_table_ready) {
build_table();
}
for (i = 0; i < len; i++) {
c->state = (uint16_t)((c->state << 8) ^
s_table[((c->state >> 8) ^ data[i]) & 0xFFu]);
}
}
uint16_t crc16_finish(const crc16_ctx_t *c)
{
return c->state;
}src/flash_kv.c
#include "flash_kv.h"
#include <string.h>
#include "crc16.h"
/* ---------------- 小工具 ---------------- */
static uint32_t bank_base(const fkv_t *kv, int bank)
{
return kv->base + (uint32_t)bank * FKV_BANK_SIZE;
}
static void put_u32(uint8_t *p, uint32_t v)
{
p[0] = (uint8_t)(v & 0xFFu);
p[1] = (uint8_t)((v >> 8) & 0xFFu);
p[2] = (uint8_t)((v >> 16) & 0xFFu);
p[3] = (uint8_t)((v >> 24) & 0xFFu);
}
static uint32_t get_u32(const uint8_t *p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
/* ---------------- 索引操作 ---------------- */
static int index_find(const fkv_t *kv, const char *key)
{
int i;
for (i = 0; i < kv->n_items; i++) {
if (strcmp(kv->items[i].key, key) == 0) {
return i;
}
}
return -1;
}
/*
* 把一条记录合并进索引:同 key 只保留 seq 最大的。
*
* ⚠️ 注意这里的 klen 参数。记录里的 key **不带结束符**(省 1 字节),
* 后面紧跟着 value。所以扫描时必须用记录头里的 klen 来定长拷贝,
* 绝不能用 strlen(key) —— 那会把 value 的第一个字节当成 key 的一部分。
* 本文第一版就栽在这里:key "pw" 变成了 "pwd",程序不崩、不报错,
* 只是 remount 之后永远读不到这个键。这种 bug 只能靠测试用例抓。
*/
static int index_merge(fkv_t *kv, const char *key, uint8_t klen,
const uint8_t *val, uint8_t vlen, uint32_t seq)
{
int idx = index_find(kv, key);
if (idx >= 0) {
if (seq > kv->items[idx].seq) {
kv->items[idx].seq = seq;
kv->items[idx].vlen = vlen;
if (vlen > 0u) {
memcpy(kv->items[idx].val, val, vlen);
}
}
return FKV_OK;
}
if (kv->n_items >= FKV_ITEMS_MAX) {
return FKV_ERR_FULL;
}
idx = kv->n_items++;
memset(&kv->items[idx], 0, sizeof(kv->items[idx]));
memcpy(kv->items[idx].key, key, klen);
kv->items[idx].key[klen] = '\0';
kv->items[idx].vlen = vlen;
kv->items[idx].seq = seq;
if (vlen > 0u) {
memcpy(kv->items[idx].val, val, vlen);
}
return FKV_OK;
}
/* ---------------- 扫描 ---------------- */
/* 扫描一个 bank。
* @param n_records 输出:本 bank 里有效记录条数
* @param max_seq 输出:本 bank 里见过的最大的 seq(0 表示没有)
* @param do_merge 1 = 把记录合并进 kv 的 RAM 索引;0 = 只统计(fkv_stat 用)
* @return 第一条无效记录所在的偏移(也就是下一次追加的位置)
*/
static uint32_t scan_bank(fkv_t *kv, int bank, int *n_records,
uint32_t *max_seq, int do_merge)
{
uint32_t off = 0;
uint32_t mx = 0;
int cnt = 0;
while (off + FKV_HDR_SIZE + FKV_CRC_SIZE <= FKV_BANK_SIZE) {
uint8_t hdr[FKV_HDR_SIZE];
uint8_t buf[FKV_HDR_SIZE + FKV_KEY_MAX + FKV_VAL_MAX + FKV_CRC_SIZE];
uint32_t total;
uint16_t crc_calc;
uint16_t crc_read;
uint8_t klen;
uint8_t vlen;
uint32_t seq;
int rc;
if (fsim_read(kv->flash, bank_base(kv, bank) + off, hdr,
FKV_HDR_SIZE) != FSIM_OK) {
break;
}
/* 擦除后的 0xFF 说明这里还没写过 */
if (hdr[0] == FKV_ERASED) {
break;
}
if (hdr[0] != FKV_MAGIC) {
break;
}
klen = hdr[1];
vlen = hdr[2];
if (klen == 0u || klen > FKV_KEY_MAX || vlen > FKV_VAL_MAX) {
break;
}
total = FKV_HDR_SIZE + klen + vlen + FKV_CRC_SIZE;
if (off + total > FKV_BANK_SIZE) {
break;
}
if (fsim_read(kv->flash, bank_base(kv, bank) + off, buf, total) != FSIM_OK) {
break;
}
crc_calc = crc16(buf, total - FKV_CRC_SIZE);
crc_read = (uint16_t)buf[total - 2] | ((uint16_t)buf[total - 1] << 8);
if (crc_calc != crc_read) {
break; /* 写一半掉电,或者数据被破坏 */
}
seq = get_u32(hdr + 3);
if (seq > mx) {
mx = seq;
}
if (do_merge) {
rc = index_merge(kv, (const char *)(buf + FKV_HDR_SIZE), klen,
buf + FKV_HDR_SIZE + klen, vlen, seq);
if (rc != FKV_OK) {
break;
}
}
cnt++;
off += total;
}
if (n_records != NULL) {
*n_records = cnt;
}
if (max_seq != NULL) {
*max_seq = mx;
}
return off;
}
int fkv_mount(fkv_t *kv, flash_sim_t *flash, uint32_t base)
{
uint32_t end[FKV_BANK_COUNT];
uint32_t mseq[FKV_BANK_COUNT];
uint32_t max_seq = 0;
int bank;
int i;
int best;
if (flash == NULL) {
return FKV_ERR_IO;
}
memset(kv, 0, sizeof(*kv));
kv->flash = flash;
kv->base = base;
kv->active_bank = 0;
kv->write_addr = base;
for (bank = 0; bank < FKV_BANK_COUNT; bank++) {
end[bank] = scan_bank(kv, bank, NULL, &mseq[bank], 1);
}
/*
* 活动 bank 怎么判断?不能比"谁写得更长"——压缩完成后新 bank 一定更短。
* 也不能只比长度。正确的判据是:**谁含有 seq 更大的记录,谁就更新**。
* 因为 seq 在整个存储生命期内单调递增,压缩时原样搬运,不会回退。
*/
best = (mseq[1] > mseq[0]) ? 1 : 0;
kv->active_bank = best;
kv->write_addr = bank_base(kv, best) + end[best];
for (i = 0; i < kv->n_items; i++) {
if (kv->items[i].seq > max_seq) {
max_seq = kv->items[i].seq;
}
}
kv->seq = max_seq + 1u;
return FKV_OK;
}
void fkv_stat(fkv_t *kv, int *bank_records)
{
int bank;
for (bank = 0; bank < FKV_BANK_COUNT; bank++) {
int n = 0;
scan_bank(kv, bank, &n, NULL, 0);
bank_records[bank] = n;
}
}
/* ---------------- 追加一条记录 ---------------- */
static int build_record(const char *key, const uint8_t *val, uint8_t vlen,
uint32_t seq, uint8_t *out, uint32_t *out_len)
{
uint32_t klen = (uint32_t)strlen(key);
uint32_t total;
uint16_t crc;
if (klen == 0u || klen > FKV_KEY_MAX) {
return FKV_ERR_KEY_LONG;
}
if (vlen > FKV_VAL_MAX) {
return FKV_ERR_VAL_LONG;
}
total = FKV_HDR_SIZE + klen + vlen + FKV_CRC_SIZE;
out[0] = FKV_MAGIC;
out[1] = (uint8_t)klen;
out[2] = vlen;
put_u32(out + 3, seq);
memcpy(out + FKV_HDR_SIZE, key, klen);
if (vlen > 0u) {
memcpy(out + FKV_HDR_SIZE + klen, val, vlen);
}
crc = crc16(out, total - FKV_CRC_SIZE);
out[total - 2] = (uint8_t)(crc & 0xFFu);
out[total - 1] = (uint8_t)((crc >> 8) & 0xFFu);
*out_len = total;
return FKV_OK;
}
/*
* 压缩:把当前有效记录搬到备用 bank。
* 顺序很重要 —— 先擦备用、再写、再切指针、最后擦旧的。
*/
static int compact(fkv_t *kv)
{
int dst = 1 - kv->active_bank;
uint32_t addr = bank_base(kv, dst);
uint32_t s;
int i;
int rc;
if (fsim_erase_sector(kv->flash, (bank_base(kv, dst) / FSIM_SECTOR_SIZE))
!= FSIM_OK) {
return FKV_ERR_IO;
}
for (i = 0; i < kv->n_items; i++) {
uint8_t rec[FKV_HDR_SIZE + FKV_KEY_MAX + FKV_VAL_MAX + FKV_CRC_SIZE];
uint32_t len = 0;
if (kv->items[i].vlen == 0u) {
continue; /* 已删除的键不用搬 */
}
rc = build_record(kv->items[i].key, kv->items[i].val,
kv->items[i].vlen, kv->items[i].seq, rec, &len);
if (rc != FKV_OK) {
return rc;
}
if (fsim_write(kv->flash, addr, rec, len) != FSIM_OK) {
return FKV_ERR_IO; /* 掉电:旧 bank 还在,挂载能恢复 */
}
addr += len;
}
/* 记录搬完了,才切换活动 bank */
kv->active_bank = dst;
kv->write_addr = addr;
kv->compact_count++;
/* 最后擦除旧 bank */
s = bank_base(kv, 1 - dst) / FSIM_SECTOR_SIZE;
(void)fsim_erase_sector(kv->flash, s);
return FKV_OK;
}
static int append(fkv_t *kv, const char *key, const uint8_t *val, uint8_t vlen)
{
uint8_t rec[FKV_HDR_SIZE + FKV_KEY_MAX + FKV_VAL_MAX + FKV_CRC_SIZE];
uint32_t len = 0;
uint32_t off;
int rc;
int tried = 0;
rc = build_record(key, val, vlen, kv->seq, rec, &len);
if (rc != FKV_OK) {
return rc;
}
retry:
/* 注意:要比较的是"在 bank 内的偏移",不是从 base 起的绝对偏移 */
off = kv->write_addr - bank_base(kv, kv->active_bank);
/* 空间不够就压缩一次再试(只试一次,避免死循环) */
if (off + len > FKV_BANK_SIZE) {
if (tried) {
return FKV_ERR_NO_SPACE;
}
tried = 1;
rc = compact(kv);
if (rc != FKV_OK) {
return rc;
}
goto retry;
}
if (fsim_write(kv->flash, kv->write_addr, rec, len) != FSIM_OK) {
return FKV_ERR_IO;
}
kv->write_addr += len;
kv->seq++;
kv->write_count++;
return FKV_OK;
}
int fkv_set(fkv_t *kv, const char *key, const void *val, uint8_t vlen)
{
int rc;
if (key == NULL || strlen(key) == 0u || strlen(key) > FKV_KEY_MAX) {
return FKV_ERR_KEY_LONG;
}
rc = append(kv, key, (const uint8_t *)val, vlen);
if (rc != FKV_OK) {
return rc;
}
/* 追加成功后才更新 RAM 索引 —— 顺序反过来就会"索引说有、Flash 里没有" */
return index_merge(kv, key, (uint8_t)strlen(key), (const uint8_t *)val,
vlen, kv->seq - 1u);
}
int fkv_get(const fkv_t *kv, const char *key, void *val, uint8_t *vlen)
{
int idx;
if (key == NULL) {
return FKV_ERR_NOT_FOUND;
}
idx = index_find(kv, key);
if (idx < 0 || kv->items[idx].vlen == 0u) {
return FKV_ERR_NOT_FOUND;
}
if (val != NULL && vlen != NULL) {
if (*vlen < kv->items[idx].vlen) {
return FKV_ERR_VAL_LONG;
}
memcpy(val, kv->items[idx].val, kv->items[idx].vlen);
}
if (vlen != NULL) {
*vlen = kv->items[idx].vlen;
}
return FKV_OK;
}
int fkv_del(fkv_t *kv, const char *key)
{
int idx = index_find(kv, key);
uint8_t empty = 0;
int rc;
if (idx < 0) {
return FKV_ERR_NOT_FOUND;
}
/* 写一条 vlen = 0 的墓碑记录,挂载时它同样会被扫到 */
rc = append(kv, key, &empty, 0u);
if (rc != FKV_OK) {
return rc;
}
kv->items[idx].vlen = 0u;
kv->items[idx].seq = kv->seq - 1u;
return FKV_OK;
}src/flash_sim.c
#include "flash_sim.h"
#include <stdio.h>
#include <string.h>
void fsim_init(flash_sim_t *f)
{
memset(f, 0, sizeof(*f));
memset(f->mem, 0xFF, sizeof(f->mem));
f->powered = 1;
f->cut_after = -1;
f->rng = 20260925u;
}
void fsim_power_cycle(flash_sim_t *f)
{
f->powered = 1;
f->cut_after = -1;
}
void fsim_schedule_power_cut(flash_sim_t *f, int32_t n)
{
f->cut_after = n;
}
int fsim_read(const flash_sim_t *f, uint32_t addr, void *buf, uint32_t len)
{
if (addr + len > FSIM_SIZE) {
return FSIM_ERR_RANGE;
}
memcpy(buf, f->mem + addr, len);
return FSIM_OK;
}
int fsim_write(flash_sim_t *f, uint32_t addr, const void *buf, uint32_t len)
{
const uint8_t *p = (const uint8_t *)buf;
uint32_t i;
if (addr + len > FSIM_SIZE) {
return FSIM_ERR_RANGE;
}
if (!f->powered) {
return FSIM_ERR_POWER;
}
for (i = 0; i < len; i++) {
/* 掉电点:先提交 cut_after 个字节,然后断电 */
if (f->cut_after >= 0 && (int32_t)i >= f->cut_after) {
f->powered = 0;
f->cut_after = -1;
return FSIM_ERR_POWER;
}
/* NOR Flash 只能 1 -> 0 */
if ((f->mem[addr + i] & p[i]) != p[i]) {
return FSIM_ERR_NOT_ERASED;
}
f->mem[addr + i] &= p[i];
f->write_bytes++;
}
return FSIM_OK;
}
int fsim_erase_sector(flash_sim_t *f, uint32_t sector)
{
if (sector >= FSIM_SECTOR_COUNT) {
return FSIM_ERR_RANGE;
}
if (!f->powered) {
return FSIM_ERR_POWER;
}
memset(f->mem + sector * FSIM_SECTOR_SIZE, 0xFF, FSIM_SECTOR_SIZE);
f->erase_count[sector]++;
f->erase_ops++;
return FSIM_OK;
}
void fsim_flip_bit(flash_sim_t *f, uint32_t addr, uint8_t bit)
{
if (addr < FSIM_SIZE && bit < 8u) {
f->mem[addr] ^= (uint8_t)(1u << bit);
}
}
uint32_t fsim_total_erases(const flash_sim_t *f)
{
return f->erase_ops;
}
uint32_t fsim_max_erases(const flash_sim_t *f)
{
uint32_t mx = 0;
uint32_t i;
for (i = 0; i < FSIM_SECTOR_COUNT; i++) {
if (f->erase_count[i] > mx) {
mx = f->erase_count[i];
}
}
return mx;
}
void fsim_dump_erase_counts(const flash_sim_t *f)
{
uint32_t i;
printf(" 扇区擦除次数:");
for (i = 0; i < FSIM_SECTOR_COUNT; i++) {
printf(" S%u=%u", i, f->erase_count[i]);
}
printf("\n 累计写入 %u 字节,擦除 %u 次\n", f->write_bytes, f->erase_ops);
}test/test_flash_kv.c
/**
* 主机端测试:Flash KV 存储的掉电安全、磨损均衡与损坏恢复
*
* gcc -std=c99 -Wall -Wextra -Iinclude src/crc16.c src/flash_sim.c src/flash_kv.c \
* test/test_flash_kv.c -o build/test
*/
#include <stdio.h>
#include <string.h>
#include "crc16.h"
#include "flash_kv.h"
#define KV_BASE 0u
static int failed = 0;
static void check(int cond, const char *what)
{
if (!cond) {
printf(" [FAIL] %s\n", what);
failed++;
}
}
/* 读一个 uint32 参数并比对 */
static void check_u32(fkv_t *kv, const char *key, uint32_t expect, const char *tag)
{
uint32_t got = 0;
uint8_t len = sizeof(got);
int rc = fkv_get(kv, key, &got, &len);
if (rc != FKV_OK || got != expect) {
printf(" [FAIL] %s: key=%s 期望 %u 实得 %u (rc=%d)\n",
tag, key, expect, got, rc);
failed++;
}
}
int main(void)
{
flash_sim_t flash;
fkv_t kv;
int recs[FKV_BANK_COUNT];
printf("===== 片内 Flash 键值存储:掉电安全 / CRC / 磨损均衡 实测 =====\n");
printf("仿真 Flash: %u 个扇区 x %u 字节,KV 区 %u 字节(%d 个 bank x %u 字节)\n\n",
FSIM_SECTOR_COUNT, FSIM_SECTOR_SIZE, FKV_BANK_SIZE * FKV_BANK_COUNT,
FKV_BANK_COUNT, FKV_BANK_SIZE);
/* ---------- [1] CRC 自检 ---------- */
printf("[1] CRC16-CCITT 自检\n");
{
const char *s = "123456789";
uint16_t a = crc16((const uint8_t *)s, 9);
uint16_t b = crc16_bitwise((const uint8_t *)s, 9, CRC16_INIT);
printf(" 查表法 = 0x%04X\n", a);
printf(" 逐位计算 = 0x%04X\n", b);
check(a == b, "查表法与逐位计算不一致");
printf(" -> 两种实现一致,说明运行时建的表是对的\n");
}
/* ---------- [2] 基本读写 ---------- */
printf("\n[2] 基本写入 / 覆盖 / 删除\n");
{
uint32_t v;
uint8_t len;
const char *s = "hello-flash";
fsim_init(&flash);
fkv_mount(&kv, &flash, KV_BASE);
check(fkv_set(&kv, "cycle", &(uint32_t){12u}, 4) == FKV_OK, "写入 cycle");
check(fkv_set(&kv, "name", s, (uint8_t)strlen(s)) == FKV_OK, "写入 name");
check_u32(&kv, "cycle", 12u, "读回 cycle");
check(fkv_set(&kv, "cycle", &(uint32_t){34u}, 4) == FKV_OK, "覆盖 cycle");
check_u32(&kv, "cycle", 34u, "覆盖后读回");
len = 32;
{
char buf[32];
check(fkv_get(&kv, "name", buf, &len) == FKV_OK, "读回 name");
check(len == strlen(s) && memcmp(buf, s, len) == 0, "name 内容一致");
}
check(fkv_del(&kv, "cycle") == FKV_OK, "删除 cycle");
len = 4;
check(fkv_get(&kv, "cycle", &v, &len) == FKV_ERR_NOT_FOUND,
"删除后应读不到");
printf(" cycle 覆盖 34 后删除,name = \"%s\"(%u 字节)\n", s, (unsigned)strlen(s));
printf(" -> 删除是写一条 vlen=0 的墓碑记录,索引里标记成无效\n");
}
/* ---------- [3] 写满自动压缩 ---------- */
printf("\n[3] 写满后自动压缩(连续写 400 次,观察 8 个键是否一直都在)\n");
{
uint32_t i;
int ok_all = 1;
fsim_init(&flash);
fkv_mount(&kv, &flash, KV_BASE);
for (i = 0; i < 400u; i++) {
char key[16];
uint32_t v = i;
snprintf(key, sizeof(key), "k%u", i % 8u);
if (fkv_set(&kv, key, &v, 4) != FKV_OK) {
ok_all = 0;
break;
}
}
check(ok_all, "400 次写入中有失败");
for (i = 0; i < 8u; i++) {
char key[16];
uint32_t expect = 392u + i;
snprintf(key, sizeof(key), "k%u", i);
check_u32(&kv, key, expect, "压缩后数据");
}
printf(" 写了 400 次(8 个键轮转),发生压缩 %u 次\n", kv.compact_count);
fkv_stat(&kv, recs);
printf(" 压缩后两个 bank 里的记录数:bank0=%d bank1=%d\n", recs[0], recs[1]);
printf(" -> 8 个键全部保留,值都是最后一次写入的值\n");
}
/* ---------- [4] 磨损均衡 ---------- */
printf("\n[4] 磨损均衡:写 5000 次,看擦除次数分布\n");
{
uint32_t i;
uint32_t e0;
uint32_t e1;
uint32_t tot;
fsim_init(&flash);
fkv_mount(&kv, &flash, KV_BASE);
for (i = 0; i < 5000u; i++) {
uint32_t v = i;
char key[16];
snprintf(key, sizeof(key), "p%u", i % 4u);
if (fkv_set(&kv, key, &v, 4) != FKV_OK) {
printf(" [FAIL] 第 %u 次写入失败\n", i);
failed++;
break;
}
}
e0 = flash.erase_count[0];
e1 = flash.erase_count[1];
tot = flash.erase_count[0] + flash.erase_count[1];
fsim_dump_erase_counts(&flash);
printf(" 压缩次数 %u,两个 bank 擦除次数差 %u\n", kv.compact_count,
(e0 > e1) ? (e0 - e1) : (e1 - e0));
printf(" -> 如果只用一个固定区域,150 次擦除全落在一块扇区上;\n");
printf(" 轮换之后摊到两个 bank(扇区 0/1 与 2/3 之间轮流)\n");
check(tot > 0u, "根本没发生擦除,测试不成立");
check(((e0 > e1) ? (e0 - e1) : (e1 - e0)) <= 1u, "磨损不均衡");
}
/* ---------- [5] 逐字节掉电 ---------- */
printf("\n[5] 掉电安全:遍历每条记录的每一个写入字节位置断电\n");
{
uint32_t cut;
uint32_t total_cases = 0;
uint32_t bad = 0;
uint32_t v = 100u;
uint32_t old = 0;
uint32_t max_cut;
int i;
for (i = 0; i < 4; i++) {
/* 每条新记录长度 = 7 + klen + 4 + 2 = 7+2+4+2 = 15 字节 */
const char *key = "pw";
for (cut = 0; cut <= 15u; cut++) {
flash_sim_t f2;
fkv_t k2;
fsim_init(&f2);
/* 先正常写入一个"旧值",把 bank 状态做实 */
fkv_mount(&k2, &f2, KV_BASE);
fkv_set(&k2, key, &old, 4);
/* 安排在第 cut 个字节断电,然后尝试写入新值 */
fsim_schedule_power_cut(&f2, (int32_t)cut);
fkv_set(&k2, key, &v, 4);
/* 复位 + 重新挂载(关键是 fsim_power_cycle 保留 Flash 内容) */
fsim_power_cycle(&f2);
fkv_mount(&k2, &f2, KV_BASE);
{
uint32_t got = 0xDEADBEEFu;
uint8_t len = 4;
int rc = fkv_get(&k2, key, &got, &len);
if (rc != FKV_OK || (got != v && got != old)) {
printf(" [FAIL] cut=%u 读到 rc=%d 值=0x%08X(旧=%u 新=%u)\n",
cut, rc, got, old, v);
bad++;
}
}
total_cases++;
}
old = v;
v += 111u;
}
max_cut = 15u;
printf(" 共 %.0f 个掉电场景(4 轮 x 0~%.0f 字节),读到垃圾的次数:%u\n",
(double)total_cases, (double)max_cut, bad);
printf(" -> 每一次都能读到「完整的旧值」或「完整的新值」,\n");
printf(" 因为写一半的记录 CRC 必然对不上,扫描时就停在那里了\n");
check(bad == 0u, "掉电后读到了垃圾数据");
}
/* ---------- [6] 位翻转 / 数据损坏 ---------- */
printf("\n[6] 数据损坏:把最后一条记录的某个字节翻一位,看能不能恢复\n");
{
uint32_t v = 0x11223344u;
uint32_t got = 0;
uint8_t len = 4;
uint32_t last_off;
int rc;
fsim_init(&flash);
fkv_mount(&kv, &flash, KV_BASE);
fkv_set(&kv, "cfg", &v, 4);
/* 再写一次别的键,让 cfg 的记录不在最末尾 */
fkv_set(&kv, "aaa", &v, 4);
last_off = kv.write_addr - kv.base;
/* 把最后一条记录(aaa)里的一个字节翻一位 */
fsim_flip_bit(&flash, KV_BASE + last_off - 5u, 3u);
fkv_mount(&kv, &flash, KV_BASE);
fkv_stat(&kv, recs);
printf(" bank0 扫描到 %d 条有效记录,bank1 %d 条(损坏的那条被丢掉了)\n",
recs[0], recs[1]);
rc = fkv_get(&kv, "cfg", &got, &len);
check(rc == FKV_OK && got == v, "损坏后 cfg 应该还能读到");
printf(" -> cfg 完好;被破坏的那条记录被 CRC 拒绝,不影响其他键\n");
}
/* ---------- [7] 越界与非法输入 ---------- */
printf("\n[7] 非法输入保护\n");
{
uint8_t v[FKV_VAL_MAX + 8];
char longkey[FKV_KEY_MAX + 4];
int rc;
memset(v, 0x5A, sizeof(v));
memset(longkey, 'x', sizeof(longkey));
longkey[sizeof(longkey) - 1] = 0;
fsim_init(&flash);
fkv_mount(&kv, &flash, KV_BASE);
rc = fkv_set(&kv, longkey, v, 4);
printf(" 超长 key -> rc=%d(应为 %d)\n", rc, FKV_ERR_KEY_LONG);
check(rc == FKV_ERR_KEY_LONG, "超长 key 没有被拒绝");
rc = fkv_set(&kv, "ok", v, FKV_VAL_MAX + 1u);
printf(" 超长 value -> rc=%d(应为 %d)\n", rc, FKV_ERR_VAL_LONG);
check(rc == FKV_ERR_VAL_LONG, "超长 value 没有被拒绝");
rc = fkv_get(&kv, "nope", v, NULL);
printf(" 读不存在的 key-> rc=%d(应为 %d)\n", rc, FKV_ERR_NOT_FOUND);
check(rc == FKV_ERR_NOT_FOUND, "不存在的 key 返回错误");
printf(" -> 所有非法输入都在编译期常量边界内被挡住了\n");
}
printf("\n===== %s =====\n",
failed == 0 ? "全部通过:以上数据由本机 gcc 实编译实运行"
: "有失败项!");
return failed == 0 ? 0 : 1;
}实测输出
下面这段输出是把上面的核心算法用 本机 gcc 真编译、真运行得到的(不含任何硬件依赖):
===== 片内 Flash 键值存储:掉电安全 / CRC / 磨损均衡 实测 =====
仿真 Flash: 8 个扇区 x 1024 字节,KV 区 2048 字节(2 个 bank x 1024 字节)
[1] CRC16-CCITT 自检
查表法 = 0x29B1
逐位计算 = 0x29B1
-> 两种实现一致,说明运行时建的表是对的
[2] 基本写入 / 覆盖 / 删除
cycle 覆盖 34 后删除,name = "hello-flash"(11 字节)
-> 删除是写一条 vlen=0 的墓碑记录,索引里标记成无效
[3] 写满后自动压缩(连续写 400 次,观察 8 个键是否一直都在)
写了 400 次(8 个键轮转),发生压缩 6 次
压缩后两个 bank 里的记录数:bank0=40 bank1=0
-> 8 个键全部保留,值都是最后一次写入的值
[4] 磨损均衡:写 5000 次,看擦除次数分布
扇区擦除次数: S0=78 S1=78 S2=0 S3=0 S4=0 S5=0 S6=0 S7=0
累计写入 79680 字节,擦除 156 次
压缩次数 78,两个 bank 擦除次数差 0
-> 如果只用一个固定区域,150 次擦除全落在一块扇区上;
轮换之后摊到两个 bank(扇区 0/1 与 2/3 之间轮流)
[5] 掉电安全:遍历每条记录的每一个写入字节位置断电
共 64 个掉电场景(4 轮 x 0~15 字节),读到垃圾的次数:0
-> 每一次都能读到「完整的旧值」或「完整的新值」,
因为写一半的记录 CRC 必然对不上,扫描时就停在那里了
[6] 数据损坏:把最后一条记录的某个字节翻一位,看能不能恢复
bank0 扫描到 1 条有效记录,bank1 0 条(损坏的那条被丢掉了)
-> cfg 完好;被破坏的那条记录被 CRC 拒绝,不影响其他键
[7] 非法输入保护
超长 key -> rc=-3(应为 -3)
超长 value -> rc=-4(应为 -4)
读不存在的 key-> rc=-2(应为 -2)
-> 所有非法输入都在编译期常量边界内被挡住了
===== 全部通过:以上数据由本机 gcc 实编译实运行 =====