Administrator
发布于 2026-09-25 / 0 阅读
0
0

掉电不丢参数:手写一个带 CRC、双备份与磨损均衡的片内 Flash 键值存储

一句话结论:片内 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 的全部字节

三个设计要点:

  1. seq 必须在整个存储生命期内单调递增,不能只在单个 bank 内递增。

因为双 bank 轮换时,靠 seq 才能判断到底哪份更新。

  1. CRC 放在尾部:顺序写入时,只有整条记录都写完了 CRC 才有效。

写一半掉电 ⇒ CRC 必然对不上 ⇒ 自动丢弃。这比"先写长度再写数据"可靠得多。

  1. 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。

关键顺序(顺序错了掉电就丢数据):

  1. 擦除备用 bank(此时活动 bank 完好)
  2. 把有效记录写进备用 bank
  3. 切换活动 bank 指针
  4. 最后才擦除旧 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、不存在的键全部被正确拒绝

七、几个必须知道的坑

  1. 记录里的 key 不带结束符,所以扫描时必须用 klen 做定长拷贝,

不能用 strlen(key)。本文第一版就栽在这里:"pw" 被读成了 "pwd" (把 value 的第一个字节当成了 key 的一部分)。它不崩、不报错, 只是 remount 之后永远读不到这个键——这种 bug 只能靠测试用例抓。

  1. 不要手抄 CRC 表。256 项的表极容易抄错一位,而且错了不会崩,

只会在某些数据上校验失败——排查起来极其痛苦。运行时用 build_table() 生成一次即可(本文 crc16.c 就是这么做的)。

  1. seq 要断电不丢。挂载时把最大 seq 读出来,之后从这个值继续递增;

如果每次复位都从 0 开始,新旧记录就分不清了。

  1. 活动 bank 不能靠"谁写得更长"判断。压缩完成后新 bank 一定更短,

必须用「哪个 bank 含有 seq 更大的记录」来判断。

  1. Flash 写入前必须对齐半字(STM32F1 是 16 位编程)。

本文的仿真器按字节写,实际移植时要在 flash_kv 里做 2 字节缓冲。

  1. 擦除时间很长(STM32F103 擦 1KB 约 20~40 ms)。

擦除期间取指会停顿,中断响应变差——不要在高速控制循环里做压缩, 放到开机自检或空闲任务里。

  1. 别把 KV 区和程序区放同一个扇区。压缩时要擦整个扇区,

一不小心就把代码擦了,直接变砖。

  1. 省寿命的小技巧:把频繁变化的量(比如运行时长)先放在 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 clean

include/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 实编译实运行 =====

评论