15. SPI Flash 高阶应用
📝本节在第 14 章 SPI 基础驱动的基础上,深入探讨 Flash 存储的工程化使用——分区管理、磨损均衡、掉电安全写入、环形日志、KV 键值存储、字库管理和性能优化。
🏆本章目标
1️⃣ 掌握 Flash 分区规划和地址映射设计
2️⃣ 实现磨损均衡策略,延长 Flash 使用寿命
3️⃣ 实现掉电安全的数据写入机制
4️⃣ 构建环形日志系统和轻量级 KV 存储引擎
前置知识
本章假设你已掌握第 13 章的 SPI 通信和 W25Q64 基础驱动(Read/Write/Erase)。本章不重复驱动代码,直接调用以下 API:
W25Q_Read(addr, buf, len)— 读取数据W25Q_Write(addr, buf, len)— 写入数据(自动分页)W25Q_EraseSector(addr)— 扇区擦除(4KB)W25Q_ReadID()— 读取芯片 ID
15.1 Flash 存储的工程约束
在设计 Flash 存储方案之前,必须深刻理解 NOR Flash 的物理特性带来的工程约束:
| 约束 | 影响 | 应对策略 |
|---|---|---|
| 写入前必须擦除 | 不能原地修改单个字节 | 追加写入 / 双缓冲切换 |
| 最小擦除单位 4KB | 改 1 字节也要擦 4KB | 缓存整扇区再回写 / 日志追加 |
| 擦除寿命 10 万次 | 高频写入会耗尽扇区 | 磨损均衡 / 多扇区轮转 |
| 擦除耗时 45ms | 阻塞时间长 | 异步擦除 / 后台整理 |
| 写入不可跨页 | 256B 页边界限制 | 驱动层自动分页处理 |
| 掉电丢失当前操作 | 写到一半断电数据损坏 | 原子提交 / CRC 校验 |
理解这些约束后,才能设计出可靠的存储方案。下面逐一解决这些工程问题。
15.2 分区管理
8MB 的 Flash 空间足够大,但如果不做规划,随意读写会导致数据互相覆盖。合理的做法是将 Flash 划分为多个功能区域,每个区域有明确的用途和边界。
15.2.1 天巧星分区方案
出厂固件将 Flash 划分为以下分区:
地址空间 (8MB = 0x000000 ~ 0x7FFFFF)
┌──────────────────────────────────────────┐
│ 0x000000 ~ 0x03FFFF │ HZK16 字库 (256KB) │
├──────────────────────────────────────────┤
│ 0x040000 ~ 0x04A3FF │ Unicode 映射 (42KB) │
├──────────────────────────────────────────┤
│ 0x050000 ~ 0x07FFFF │ HZK12 字库 (192KB) │
├──────────────────────────────────────────┤
│ 0x080000 ~ 0x0F7FFF │ HZK20 字库 (480KB) │
├──────────────────────────────────────────┤
│ 0x0F8000 ~ 0x7FEFFF │ 用户数据区 (7MB) │
├──────────────────────────────────────────┤
│ 0x7FF000 ~ 0x7FFFFF │ 系统配置 (4KB) │
└──────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15.2.2 分区表实现
将分区信息定义为结构化常量,便于代码引用和维护:
#ifndef FLASH_PARTITION_H
#define FLASH_PARTITION_H
#include <stdint.h>
// 分区定义
typedef struct {
const char *name; // 分区名称
uint32_t start; // 起始地址
uint32_t size; // 分区大小(字节)
uint8_t read_only; // 是否只读
} FlashPartition_t;
// 天巧星分区表
static const FlashPartition_t flash_partitions[] = {
{"HZK16", 0x000000, 0x040000, 1}, // 256KB, 只读
{"UNICODE_MAP", 0x040000, 0x00A400, 1}, // 42KB, 只读
{"HZK12", 0x050000, 0x030000, 1}, // 192KB, 只读
{"HZK20", 0x080000, 0x078000, 1}, // 480KB, 只读
{"USER_DATA", 0x0F8000, 0x707000, 0}, // ~7MB, 可读写
{"SETTINGS", 0x7FF000, 0x001000, 0}, // 4KB, 可读写
};
#define PARTITION_COUNT (sizeof(flash_partitions) / sizeof(flash_partitions[0]))
// 分区索引(便于代码引用)
#define PART_HZK16 0
#define PART_UNICODE_MAP 1
#define PART_HZK12 2
#define PART_HZK20 3
#define PART_USER_DATA 4
#define PART_SETTINGS 5
// 地址合法性检查
static inline uint8_t flash_addr_in_partition(uint8_t part_idx, uint32_t addr, uint32_t len)
{
const FlashPartition_t *p = &flash_partitions[part_idx];
return (addr >= p->start) && ((addr + len) <= (p->start + p->size));
}
#endif2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
15.2.3 用户数据区二次划分
7MB 的用户数据区可以根据应用需求进一步划分:
// 用户数据区内部划分(基于 PART_USER_DATA 偏移)
#define USER_BASE 0x0F8000
#define LOG_AREA_ADDR (USER_BASE + 0x000000) // 日志区 512KB
#define LOG_AREA_SIZE 0x080000
#define RECORD_AREA_ADDR (USER_BASE + 0x080000) // 记录区 1MB
#define RECORD_AREA_SIZE 0x100000
#define CUSTOM_AREA_ADDR (USER_BASE + 0x180000) // 自定义区(剩余)
#define CUSTOM_AREA_SIZE (0x707000 - 0x180000)2
3
4
5
6
7
8
9
10
11
⚠️分区保护
操作 Flash 前务必检查地址是否在目标分区范围内。误写字库分区将导致 OLED 中文显示功能失效,恢复需通过串口重新烧录字库(耗时数分钟)。
建议在驱动封装层加入地址校验:
uint8_t safe_write(uint8_t part_idx, uint32_t offset, uint8_t *data, uint32_t len)
{
const FlashPartition_t *p = &flash_partitions[part_idx];
if (p->read_only) return 1; // 拒绝写只读分区
if (offset + len > p->size) return 2; // 越界
W25Q_Write(p->start + offset, data, len);
return 0;
}2
3
4
5
6
7
8
15.3 磨损均衡
Flash 每个扇区的擦除次数有限(约 10 万次)。如果每次保存设置都擦除同一个扇区,频繁操作会很快耗尽该扇区的寿命。磨损均衡的目标是将写入操作分散到更多物理位置,延长整体可用寿命。
15.3.1 方案一:追加写入法
核心思想:每次写入不擦除,追加到扇区中下一个空闲位置。只有当扇区写满时才擦除一次。
扇区状态(4KB,每条记录 16 字节 → 可存 256 条):
写入第 1 次:[Record 0][ 空 ][ 空 ]...[ 空 ]
写入第 2 次:[Record 0][Record 1][ 空 ]...[ 空 ]
写入第 3 次:[Record 0][Record 1][Record 2]...[ 空 ]
...
写入第 256 次:[Record 0][Record 1]...[Record 255] ← 写满
写入第 257 次:擦除 → [Record 256][ 空 ]...[ 空 ] ← 重新开始2
3
4
5
6
7
8
寿命提升:4KB / 16B = 256 条,填满才擦一次。擦除寿命 10 万 × 256 = 2560 万次有效写入。
#include <string.h>
#define SETTINGS_SECTOR 0x7FF000 // 配置扇区地址
#define RECORD_SIZE 16 // 每条记录大小
#define RECORD_MAGIC 0xA5 // 有效记录标识
#define MAX_RECORDS (4096 / RECORD_SIZE) // 256 条
// 查找最新记录的位置
// 返回值: 最新记录的扇区内偏移,-1 表示扇区为空
static int32_t settings_find_latest(void)
{
uint8_t magic;
int32_t last_valid = -1;
for (uint16_t i = 0; i < MAX_RECORDS; i++) {
uint32_t addr = SETTINGS_SECTOR + i * RECORD_SIZE;
W25Q_Read(addr, &magic, 1);
if (magic == 0xFF) {
// 遇到空位置,前一条即为最新
break;
} else if (magic == RECORD_MAGIC) {
last_valid = i * RECORD_SIZE;
}
}
return last_valid;
}
// 查找下一个可写入位置
// 返回值: 可写偏移,-1 表示扇区已满
static int32_t settings_find_free_slot(void)
{
uint8_t magic;
for (uint16_t i = 0; i < MAX_RECORDS; i++) {
uint32_t addr = SETTINGS_SECTOR + i * RECORD_SIZE;
W25Q_Read(addr, &magic, 1);
if (magic == 0xFF) {
return i * RECORD_SIZE; // 找到空位
}
}
return -1; // 扇区已满
}
// 读取最新配置
uint8_t settings_load(uint8_t *data, uint8_t len)
{
int32_t offset = settings_find_latest();
if (offset < 0) return 1; // 无有效数据
// 跳过 magic 字节读取数据
W25Q_Read(SETTINGS_SECTOR + offset + 1, data, len);
return 0;
}
// 保存配置(追加写入,自动磨损均衡)
uint8_t settings_save(uint8_t *data, uint8_t len)
{
if (len > RECORD_SIZE - 1) return 1; // 数据超长
int32_t free_slot = settings_find_free_slot();
if (free_slot < 0) {
// 扇区已满,擦除后从头写
W25Q_EraseSector(SETTINGS_SECTOR);
free_slot = 0;
}
// 组装记录:[magic][data...]
uint8_t record[RECORD_SIZE];
memset(record, 0xFF, RECORD_SIZE);
record[0] = RECORD_MAGIC;
memcpy(&record[1], data, len);
// 写入
W25Q_Write(SETTINGS_SECTOR + free_slot, record, RECORD_SIZE);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
15.3.2 方案二:双扇区交替
对于更高可靠性要求的场景,使用两个扇区交替写入。一个扇区写满后切换到另一个,前一个在后台擦除备用:
#define SETTINGS_SECTOR_A 0x7FE000 // 扇区 A
#define SETTINGS_SECTOR_B 0x7FF000 // 扇区 B
typedef struct {
uint32_t active_sector; // 当前活动扇区地址
uint32_t standby_sector; // 备用扇区地址
uint16_t write_offset; // 当前写入偏移
} DualSectorCtx_t;
static DualSectorCtx_t ds_ctx;
// 初始化:确定哪个扇区是活动的
void dual_sector_init(void)
{
uint8_t magic_a, magic_b;
W25Q_Read(SETTINGS_SECTOR_A, &magic_a, 1);
W25Q_Read(SETTINGS_SECTOR_B, &magic_b, 1);
// 两个扇区都为空 → A 为活动
if (magic_a == 0xFF && magic_b == 0xFF) {
ds_ctx.active_sector = SETTINGS_SECTOR_A;
ds_ctx.standby_sector = SETTINGS_SECTOR_B;
}
// A 有数据、B 为空 → A 活动
else if (magic_a != 0xFF && magic_b == 0xFF) {
ds_ctx.active_sector = SETTINGS_SECTOR_A;
ds_ctx.standby_sector = SETTINGS_SECTOR_B;
}
// B 有数据、A 为空 → B 活动
else {
ds_ctx.active_sector = SETTINGS_SECTOR_B;
ds_ctx.standby_sector = SETTINGS_SECTOR_A;
}
// 找到活动扇区中的写入位置
ds_ctx.write_offset = 0;
uint8_t byte;
while (ds_ctx.write_offset < 4096) {
W25Q_Read(ds_ctx.active_sector + ds_ctx.write_offset, &byte, 1);
if (byte == 0xFF) break;
ds_ctx.write_offset += RECORD_SIZE;
}
}
// 写入(活动扇区满时自动切换)
void dual_sector_write(uint8_t *record, uint8_t len)
{
if (ds_ctx.write_offset + RECORD_SIZE > 4096) {
// 活动扇区满,切换
uint32_t temp = ds_ctx.active_sector;
ds_ctx.active_sector = ds_ctx.standby_sector;
ds_ctx.standby_sector = temp;
ds_ctx.write_offset = 0;
// 擦除新的备用扇区(原来的活动扇区)
W25Q_EraseSector(ds_ctx.standby_sector);
}
uint8_t buf[RECORD_SIZE];
memset(buf, 0xFF, RECORD_SIZE);
buf[0] = RECORD_MAGIC;
memcpy(&buf[1], record, len);
W25Q_Write(ds_ctx.active_sector + ds_ctx.write_offset, buf, RECORD_SIZE);
ds_ctx.write_offset += RECORD_SIZE;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
双扇区 vs 单扇区:
| 方案 | 寿命 | 安全性 | 空间占用 |
|---|---|---|---|
| 单扇区追加 | 2560 万次 | 擦除瞬间断电可能丢数据 | 4KB |
| 双扇区交替 | 5120 万次 | 切换前旧数据仍在备用扇区 | 8KB |
15.3.3 写入计数监控
在生产环境中,建议记录每个扇区的擦除次数,用于监控 Flash 剩余寿命:
// 擦除计数器(存储在扇区最后 4 字节)
uint32_t get_erase_count(uint32_t sector_addr)
{
uint32_t count;
W25Q_Read(sector_addr + 4096 - 4, (uint8_t *)&count, 4);
if (count == 0xFFFFFFFF) return 0; // 从未记录
return count;
}
void increment_erase_count(uint32_t sector_addr)
{
uint32_t count = get_erase_count(sector_addr) + 1;
// 注意:此函数应在擦除后立即调用
// 计数器本身写在扇区末尾,不影响数据区
W25Q_Write(sector_addr + 4096 - 4, (uint8_t *)&count, 4);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
15.4 掉电安全写入
嵌入式设备随时可能断电——电池耗尽、用户拔线、意外掉电。如果恰好在 Flash 写入或擦除过程中断电,数据可能处于不完整状态(部分字节写入、擦除到一半)。掉电安全设计确保即使发生中断,系统重启后也能恢复到一个一致的状态。
15.4.1 CRC 校验
每条记录附加 CRC 校验码。读取时验证 CRC,不通过则视为无效数据:
#include <stdint.h>
// CRC-8 计算(多项式 0x07)
static uint8_t crc8(const uint8_t *data, uint8_t len)
{
uint8_t crc = 0x00;
while (len--) {
crc ^= *data++;
for (uint8_t i = 0; i < 8; i++) {
crc = (crc & 0x80) ? (crc << 1) ^ 0x07 : (crc << 1);
}
}
return crc;
}
// 带校验的记录结构(共 16 字节)
typedef struct __attribute__((packed)) {
uint8_t magic; // 0xA5 = 有效
uint8_t payload[13]; // 用户数据
uint8_t crc; // CRC-8(覆盖 magic + payload)
uint8_t reserved; // 保留(对齐)
} SafeRecord_t;
// 写入带校验的记录
void safe_record_write(uint32_t addr, uint8_t *data, uint8_t len)
{
SafeRecord_t rec;
memset(&rec, 0xFF, sizeof(rec));
rec.magic = RECORD_MAGIC;
memcpy(rec.payload, data, (len > 13) ? 13 : len);
rec.crc = crc8((uint8_t *)&rec, 14); // 校验 magic + payload
W25Q_Write(addr, (uint8_t *)&rec, sizeof(rec));
}
// 读取并校验记录(返回 0=成功,1=无效/损坏)
uint8_t safe_record_read(uint32_t addr, uint8_t *data, uint8_t len)
{
SafeRecord_t rec;
W25Q_Read(addr, (uint8_t *)&rec, sizeof(rec));
// 检查 magic
if (rec.magic != RECORD_MAGIC) return 1;
// 验证 CRC
uint8_t calc_crc = crc8((uint8_t *)&rec, 14);
if (calc_crc != rec.crc) return 1; // 数据损坏
memcpy(data, rec.payload, (len > 13) ? 13 : len);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
15.4.2 双备份策略
对关键数据(如系统配置),维护两份副本。写入时先写副本 B,确认成功后再写副本 A。无论何时断电,至少有一份完整数据可用:
#define CONFIG_COPY_A 0x7FE000 // 主副本
#define CONFIG_COPY_B 0x7FF000 // 备份副本
typedef struct __attribute__((packed)) {
uint32_t magic; // 0xDEADBEEF
uint32_t sequence; // 递增序列号(判断哪份更新)
uint8_t data[48]; // 配置数据
uint32_t crc32; // CRC-32 校验
} ConfigBlock_t;
// CRC-32 计算(简化版)
static uint32_t crc32_calc(const uint8_t *data, uint32_t len)
{
uint32_t crc = 0xFFFFFFFF;
while (len--) {
crc ^= *data++;
for (uint8_t i = 0; i < 8; i++) {
crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320 : 0);
}
}
return ~crc;
}
// 验证一个配置块是否有效
static uint8_t config_block_valid(uint32_t addr, ConfigBlock_t *block)
{
W25Q_Read(addr, (uint8_t *)block, sizeof(ConfigBlock_t));
if (block->magic != 0xDEADBEEF) return 0;
uint32_t crc = crc32_calc((uint8_t *)block,
sizeof(ConfigBlock_t) - sizeof(uint32_t));
return (crc == block->crc32) ? 1 : 0;
}
// 加载配置(选择序列号更大且有效的副本)
uint8_t config_load(uint8_t *data, uint8_t len)
{
ConfigBlock_t block_a, block_b;
uint8_t a_valid = config_block_valid(CONFIG_COPY_A, &block_a);
uint8_t b_valid = config_block_valid(CONFIG_COPY_B, &block_b);
ConfigBlock_t *latest = NULL;
if (a_valid && b_valid) {
latest = (block_a.sequence >= block_b.sequence) ? &block_a : &block_b;
} else if (a_valid) {
latest = &block_a;
} else if (b_valid) {
latest = &block_b;
} else {
return 1; // 两份都无效
}
memcpy(data, latest->data, (len > 48) ? 48 : len);
return 0;
}
// 保存配置(双备份写入)
void config_save(uint8_t *data, uint8_t len)
{
// 读取当前序列号
ConfigBlock_t current;
uint32_t seq = 0;
if (config_block_valid(CONFIG_COPY_A, ¤t)) {
seq = current.sequence;
}
if (config_block_valid(CONFIG_COPY_B, ¤t)) {
if (current.sequence > seq) seq = current.sequence;
}
seq++; // 新序列号
// 组装新配置块
ConfigBlock_t new_block;
new_block.magic = 0xDEADBEEF;
new_block.sequence = seq;
memset(new_block.data, 0xFF, 48);
memcpy(new_block.data, data, (len > 48) ? 48 : len);
new_block.crc32 = crc32_calc((uint8_t *)&new_block,
sizeof(ConfigBlock_t) - sizeof(uint32_t));
// 先写 B(备份),再写 A(主)
// 即使 A 写入时断电,B 仍有完整的最新数据
W25Q_EraseSector(CONFIG_COPY_B);
W25Q_Write(CONFIG_COPY_B, (uint8_t *)&new_block, sizeof(new_block));
W25Q_EraseSector(CONFIG_COPY_A);
W25Q_Write(CONFIG_COPY_A, (uint8_t *)&new_block, sizeof(new_block));
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
断电场景分析
| 断电时刻 | 数据状态 | 恢复结果 |
|---|---|---|
| 写 B 之前 | A=旧数据(有效), B=旧数据(有效) | 正常加载旧数据 |
| 写 B 过程中 | A=旧数据(有效), B=损坏(CRC 不过) | 从 A 加载旧数据 |
| 擦除 A 过程中 | A=损坏, B=新数据(有效) | 从 B 加载新数据 ✓ |
| 写 A 过程中 | A=损坏(CRC 不过), B=新数据(有效) | 从 B 加载新数据 ✓ |
| 全部完成 | A=新数据(有效), B=新数据(有效) | 正常加载新数据 |
无论在哪个环节断电,都能恢复到一个有效状态。
15.5 环形日志系统
传感器数据、运行日志、错误记录等需要长期连续存储。环形日志(Ring Log)利用多个扇区组成环形缓冲区,写满后自动擦除最旧的扇区继续写入,实现"无限"记录。
15.5.1 设计原理
扇区0 扇区1 扇区2 扇区3 扇区4 ... 扇区N
[旧数据] [旧数据] [当前写入] [空闲] [空闲] [空闲]
↑ write_ptr
写满扇区2后 → 移动到扇区3
全部写满后 → 擦除扇区0,从扇区0重新开始(环形)2
3
4
5
6
15.5.2 实现
#define LOG_BASE_ADDR 0x0F8000 // 日志区起始地址
#define LOG_SECTOR_COUNT 128 // 使用 128 个扇区 = 512KB
#define LOG_SECTOR_SIZE 4096
#define LOG_ENTRY_SIZE 32 // 每条日志 32 字节
// 日志条目结构
typedef struct __attribute__((packed)) {
uint32_t timestamp; // 系统时间(ms)
uint8_t type; // 日志类型
uint8_t data[25]; // 日志数据
uint16_t crc; // CRC-16 校验
} LogEntry_t;
// 日志管理上下文
typedef struct {
uint16_t current_sector; // 当前写入的扇区索引 (0 ~ LOG_SECTOR_COUNT-1)
uint16_t sector_offset; // 当前扇区内的写入偏移
uint32_t total_entries; // 累计写入条目数
} LogCtx_t;
static LogCtx_t log_ctx;
// CRC-16 (CCITT)
static uint16_t crc16(const uint8_t *data, uint8_t len)
{
uint16_t crc = 0xFFFF;
while (len--) {
crc ^= (uint16_t)*data++ << 8;
for (uint8_t i = 0; i < 8; i++) {
crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : (crc << 1);
}
}
return crc;
}
// 初始化:扫描找到当前写入位置
void log_init(void)
{
log_ctx.current_sector = 0;
log_ctx.sector_offset = 0;
log_ctx.total_entries = 0;
// 扫描每个扇区,找到第一个非满扇区
for (uint16_t s = 0; s < LOG_SECTOR_COUNT; s++) {
uint32_t addr = LOG_BASE_ADDR + s * LOG_SECTOR_SIZE;
uint8_t first_byte;
W25Q_Read(addr, &first_byte, 1);
if (first_byte == 0xFF) {
// 空扇区:如果前一个扇区有数据,当前扇区就是写入点
log_ctx.current_sector = s;
// 如果此扇区不是完全空的(可能上次写了一部分)
// 找到扇区内的精确写入位置
for (uint16_t off = 0; off < LOG_SECTOR_SIZE; off += LOG_ENTRY_SIZE) {
uint8_t byte;
W25Q_Read(addr + off, &byte, 1);
if (byte == 0xFF) {
log_ctx.sector_offset = off;
return;
}
}
log_ctx.sector_offset = 0;
return;
}
}
// 所有扇区都有数据 → 从头开始(擦除最旧扇区)
log_ctx.current_sector = 0;
log_ctx.sector_offset = 0;
W25Q_EraseSector(LOG_BASE_ADDR);
}
// 写入一条日志
void log_write(uint8_t type, uint8_t *data, uint8_t data_len)
{
// 检查当前扇区是否写满
if (log_ctx.sector_offset + LOG_ENTRY_SIZE > LOG_SECTOR_SIZE) {
// 切换到下一个扇区
log_ctx.current_sector = (log_ctx.current_sector + 1) % LOG_SECTOR_COUNT;
log_ctx.sector_offset = 0;
// 擦除目标扇区(覆盖最旧数据)
uint32_t erase_addr = LOG_BASE_ADDR + log_ctx.current_sector * LOG_SECTOR_SIZE;
W25Q_EraseSector(erase_addr);
}
// 组装日志条目
LogEntry_t entry;
memset(&entry, 0xFF, sizeof(entry));
entry.timestamp = get_sys_tick_ms(); // 系统时间
entry.type = type;
memcpy(entry.data, data, (data_len > 25) ? 25 : data_len);
entry.crc = crc16((uint8_t *)&entry, sizeof(entry) - 2);
// 写入
uint32_t write_addr = LOG_BASE_ADDR
+ log_ctx.current_sector * LOG_SECTOR_SIZE
+ log_ctx.sector_offset;
W25Q_Write(write_addr, (uint8_t *)&entry, LOG_ENTRY_SIZE);
log_ctx.sector_offset += LOG_ENTRY_SIZE;
log_ctx.total_entries++;
}
// 读取最近 N 条日志(从最新到最旧)
uint16_t log_read_recent(LogEntry_t *buf, uint16_t max_count)
{
uint16_t count = 0;
int32_t sector = log_ctx.current_sector;
int32_t offset = log_ctx.sector_offset - LOG_ENTRY_SIZE;
while (count < max_count) {
if (offset < 0) {
// 回退到上一个扇区的末尾
sector = (sector - 1 + LOG_SECTOR_COUNT) % LOG_SECTOR_COUNT;
offset = LOG_SECTOR_SIZE - LOG_ENTRY_SIZE;
// 检查是否回到当前扇区(绕了一圈)
if (sector == log_ctx.current_sector) break;
}
uint32_t addr = LOG_BASE_ADDR + sector * LOG_SECTOR_SIZE + offset;
LogEntry_t entry;
W25Q_Read(addr, (uint8_t *)&entry, sizeof(entry));
// 验证条目有效性
if (entry.timestamp == 0xFFFFFFFF) break; // 空条目
uint16_t calc_crc = crc16((uint8_t *)&entry, sizeof(entry) - 2);
if (calc_crc != entry.crc) break; // 损坏条目
buf[count++] = entry;
offset -= LOG_ENTRY_SIZE;
}
return count;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
15.5.3 日志容量计算
| 参数 | 值 |
|---|---|
| 日志区总大小 | 512KB(128 扇区) |
| 每条日志大小 | 32 字节 |
| 每扇区条目数 | 4096 / 32 = 128 条 |
| 总容量 | 128 × 128 = 16384 条 |
| 1 秒 1 条时可存 | 约 4.5 小时 |
| 1 分钟 1 条时可存 | 约 11 天 |
超出容量后自动覆盖最旧数据,无需人工干预。
15.6 轻量级 KV 键值存储
当系统需要存储多个独立参数(亮度、音量、校准值、用户偏好等),用固定偏移的方式管理会很脆弱——增删参数时所有偏移都要改。KV(Key-Value)存储引擎通过"键名→值"的映射关系管理数据,增删参数不影响已有数据。
15.6.1 设计思路
每条 KV 记录的格式:
[key_len:1B][val_len:1B][key:NB][value:MB][crc:1B]多条记录依次追加写入扇区。读取时从头扫描,后写入的同名 key 覆盖先前的值(以最后一条为准)。
15.6.2 实现
#define KV_SECTOR_ADDR 0x7FD000 // KV 存储扇区
#define KV_SECTOR_SIZE 4096
#define KV_MAX_KEY_LEN 15
#define KV_MAX_VAL_LEN 48
#define KV_HEADER_SIZE 3 // key_len + val_len + crc
// KV 存储上下文
static uint16_t kv_write_offset = 0;
// 初始化:找到写入位置
void kv_init(void)
{
kv_write_offset = 0;
uint8_t byte;
while (kv_write_offset < KV_SECTOR_SIZE) {
W25Q_Read(KV_SECTOR_ADDR + kv_write_offset, &byte, 1);
if (byte == 0xFF) break; // 找到空位
// 跳过当前记录
uint8_t header[2];
W25Q_Read(KV_SECTOR_ADDR + kv_write_offset, header, 2);
uint16_t record_len = KV_HEADER_SIZE + header[0] + header[1];
kv_write_offset += record_len;
}
}
// 写入 KV 对
uint8_t kv_set(const char *key, const uint8_t *value, uint8_t val_len)
{
uint8_t key_len = strlen(key);
if (key_len > KV_MAX_KEY_LEN || val_len > KV_MAX_VAL_LEN) return 1;
uint16_t record_len = KV_HEADER_SIZE + key_len + val_len;
// 检查空间是否足够
if (kv_write_offset + record_len > KV_SECTOR_SIZE) {
// 空间不足,需要整理(compact)
kv_compact();
}
// 组装记录
uint8_t record[KV_MAX_KEY_LEN + KV_MAX_VAL_LEN + KV_HEADER_SIZE];
record[0] = key_len;
record[1] = val_len;
memcpy(&record[2], key, key_len);
memcpy(&record[2 + key_len], value, val_len);
record[2 + key_len + val_len] = crc8(record, 2 + key_len + val_len);
W25Q_Write(KV_SECTOR_ADDR + kv_write_offset, record, record_len);
kv_write_offset += record_len;
return 0;
}
// 读取 KV 值(扫描找到最后一条匹配 key 的记录)
uint8_t kv_get(const char *key, uint8_t *value, uint8_t *val_len)
{
uint8_t target_key_len = strlen(key);
uint16_t offset = 0;
uint8_t found = 0;
uint16_t found_offset = 0;
uint8_t found_val_len = 0;
while (offset < kv_write_offset) {
uint8_t header[2];
W25Q_Read(KV_SECTOR_ADDR + offset, header, 2);
uint8_t k_len = header[0];
uint8_t v_len = header[1];
if (k_len == 0xFF) break; // 空区域
// 比较 key
if (k_len == target_key_len) {
uint8_t read_key[KV_MAX_KEY_LEN];
W25Q_Read(KV_SECTOR_ADDR + offset + 2, read_key, k_len);
if (memcmp(read_key, key, k_len) == 0) {
found = 1;
found_offset = offset + 2 + k_len;
found_val_len = v_len;
}
}
offset += KV_HEADER_SIZE + k_len + v_len;
}
if (!found) return 1;
W25Q_Read(KV_SECTOR_ADDR + found_offset, value, found_val_len);
*val_len = found_val_len;
return 0;
}
// 整理:将所有有效 KV 对重新写入(去重,只保留最新值)
void kv_compact(void)
{
// 1. 将所有有效数据读到 RAM 临时缓冲区
// (简化实现:逐条扫描,同 key 只保留最后一条)
static uint8_t temp_buf[KV_SECTOR_SIZE];
uint16_t temp_offset = 0;
// 收集所有唯一 key 的最新值
// ... (遍历逻辑,此处省略完整实现)
// 2. 擦除扇区
W25Q_EraseSector(KV_SECTOR_ADDR);
// 3. 将去重后的数据写回
if (temp_offset > 0) {
W25Q_Write(KV_SECTOR_ADDR, temp_buf, temp_offset);
}
kv_write_offset = temp_offset;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
15.6.3 使用示例
// 保存参数
uint8_t brightness = 80;
uint8_t volume = 5;
int16_t cal_x = -15;
kv_set("brightness", &brightness, 1);
kv_set("volume", &volume, 1);
kv_set("cal_x", (uint8_t *)&cal_x, 2);
// 读取参数
uint8_t val;
uint8_t len;
if (kv_get("brightness", &val, &len) == 0) {
// val = 80, len = 1
}
// 修改参数(直接 set,新值覆盖旧值)
brightness = 120;
kv_set("brightness", &brightness, 1);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
KV 存储 vs 固定结构体
| 方式 | 优点 | 缺点 |
|---|---|---|
| 固定结构体 | 简单直接,读写快 | 增删字段需改结构,版本兼容麻烦 |
| KV 存储 | 灵活,增删参数不影响已有数据 | 读取需扫描,空间利用率略低 |
小型项目(参数少、不会变)用结构体即可。参数较多或需要 OTA 升级兼容时推荐 KV。
15.7 外部字库管理
天巧星的 OLED 中文显示依赖存储在 SPI Flash 中的点阵字库。理解字库的存储格式和加载机制,有助于自定义字体或排查显示异常。
15.7.1 字库存储格式
| 字库 | 地址 | 点阵大小 | 每字符字节数 | 编码 |
|---|---|---|---|---|
| HZK16 | 0x000000 | 16×16 | 32B | GB2312 |
| HZK12 | 0x050000 | 12×12 | 24B | GB2312 |
| HZK20 | 0x080000 | 20×20 | 60B | GB2312 |
| Unicode 映射 | 0x040000 | — | 2B/条目 | UTF-16 → GB2312 |
字库按 GB2312 编码的区位码顺序线性排列。给定一个汉字的 GB2312 编码,可直接计算其在 Flash 中的偏移地址:
// 计算 HZK16 字库中某汉字的地址偏移
uint32_t hzk16_get_offset(uint16_t gb2312_code)
{
uint8_t qh = (gb2312_code >> 8) - 0xA1; // 区码(高字节)
uint8_t wh = (gb2312_code & 0xFF) - 0xA1; // 位码(低字节)
return (uint32_t)(qh * 94 + wh) * 32; // 每字符 32 字节
}2
3
4
5
6
7
15.7.2 UTF-8 到 GB2312 转换
现代系统普遍使用 UTF-8 编码,但字库按 GB2312 索引。需要一个映射表完成转换:
#define UNICODE_MAP_ADDR 0x040000 // Unicode 映射表在 Flash 中的地址
#define UNICODE_MAP_SIZE 21248 // 映射表条目数
// UTF-8 解码为 Unicode 码点
static uint32_t utf8_to_unicode(const uint8_t **ptr)
{
uint8_t c = **ptr;
uint32_t code;
if (c < 0x80) {
code = c;
(*ptr)++;
} else if ((c & 0xE0) == 0xC0) {
code = (c & 0x1F) << 6 | ((*ptr)[1] & 0x3F);
(*ptr) += 2;
} else if ((c & 0xF0) == 0xE0) {
code = (c & 0x0F) << 12 | ((*ptr)[1] & 0x3F) << 6 | ((*ptr)[2] & 0x3F);
(*ptr) += 3;
} else {
(*ptr)++;
return 0; // 不支持 4 字节 UTF-8
}
return code;
}
// Unicode → GB2312(二分查找映射表)
uint16_t unicode_to_gb2312(uint32_t unicode)
{
// 映射表格式:每 4 字节一条 [unicode_le:2B][gb2312:2B],按 unicode 升序
uint16_t left = 0, right = UNICODE_MAP_SIZE - 1;
while (left <= right) {
uint16_t mid = (left + right) / 2;
uint8_t entry[4];
W25Q_Read(UNICODE_MAP_ADDR + mid * 4, entry, 4);
uint16_t map_unicode = entry[0] | (entry[1] << 8);
if (map_unicode == (uint16_t)unicode) {
return entry[2] | (entry[3] << 8); // 返回 GB2312 编码
} else if (map_unicode < (uint16_t)unicode) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return 0; // 未找到
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
15.7.3 按需加载与缓存
每次显示一个汉字需要从 Flash 读取 32~60 字节。如果逐字读取,频繁的 SPI 通信会降低刷屏效率。简单的缓存策略可以显著提升性能:
#define FONT_CACHE_SIZE 8 // 缓存 8 个最近使用的字符
typedef struct {
uint16_t gb_code; // GB2312 编码(0 = 空槽位)
uint8_t font_data[32]; // 点阵数据(HZK16)
} FontCacheEntry_t;
static FontCacheEntry_t font_cache[FONT_CACHE_SIZE];
static uint8_t cache_idx = 0; // 轮转替换指针
// 带缓存的字库读取
const uint8_t *font_get_hzk16(uint16_t gb_code)
{
// 查缓存
for (uint8_t i = 0; i < FONT_CACHE_SIZE; i++) {
if (font_cache[i].gb_code == gb_code) {
return font_cache[i].font_data; // 缓存命中
}
}
// 缓存未命中,从 Flash 读取
uint32_t offset = hzk16_get_offset(gb_code);
FontCacheEntry_t *slot = &font_cache[cache_idx];
slot->gb_code = gb_code;
W25Q_Read(0x000000 + offset, slot->font_data, 32);
cache_idx = (cache_idx + 1) % FONT_CACHE_SIZE;
return slot->font_data;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
缓存命中率
中文文本中常用字的重复率很高。8 个缓存槽位在显示连续文本时命中率可达 30%~50%,显著减少 SPI 读取次数。对于菜单界面(文字固定不变),命中率接近 100%。
15.7.4 字库烧录
出厂已烧录字库。如需重新烧录(Flash 被误擦除,或更换自定义字体),通过 UART 接收字库数据并逐页写入:
// 字库烧录流程(简化)
// 1. PC 端工具将字库文件按 256B 页拆分
// 2. 每页数据通过 UART 发送:[页地址:4B][数据:256B][CRC:2B]
// 3. MCU 接收后验证 CRC,写入对应 Flash 地址
// 4. 写入前自动擦除目标扇区(按需)
void font_burn_page(uint32_t addr, uint8_t *data, uint16_t crc_received)
{
// 验证 CRC
uint16_t crc_calc = crc16(data, 256);
if (crc_calc != crc_received) {
uart_send_string("CRC ERR\r\n");
return;
}
// 如果是扇区的第一页,先擦除
if ((addr % 4096) == 0) {
W25Q_EraseSector(addr);
}
// 写入
W25Q_Write(addr, data, 256);
uart_send_string("OK\r\n");
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
出厂固件中的 mid_font_burner.c 提供了完整的烧录协议实现,配合 PC 端 Python 工具(fonts/tools/)使用。
15.8 性能优化
15.8.1 Fast Read 命令
标准 Read(0x03)在高时钟频率下可能不稳定。W25Q64 支持 Fast Read(0x0B),在命令和地址之后插入一个 Dummy 字节,允许更高的 SPI 时钟:
// Fast Read(支持更高 SPI 时钟频率)
void W25Q_FastRead(uint32_t addr, uint8_t *buf, uint32_t len)
{
W25Q_CS_LOW();
spi_transfer(0x0B); // Fast Read 命令
spi_transfer((addr >> 16) & 0xFF);
spi_transfer((addr >> 8) & 0xFF);
spi_transfer(addr & 0xFF);
spi_transfer(0xFF); // Dummy 字节
for (uint32_t i = 0; i < len; i++) {
buf[i] = spi_transfer(0xFF);
}
W25Q_CS_HIGH();
}2
3
4
5
6
7
8
9
10
11
12
13
14
| 命令 | 最高时钟 | 适用场景 |
|---|---|---|
| Read (0x03) | 50MHz | 通用读取 |
| Fast Read (0x0B) | 104MHz | 高速批量读取 |
天巧星 SPI1 配置为 20MHz,两种命令都能正常工作。如果将 SPI 时钟提升到 50MHz 以上,必须使用 Fast Read。
15.8.2 DMA 批量读取
大块数据读取(如字库加载、日志导出)可结合 DMA 提升效率,避免 CPU 逐字节搬运:
// DMA 加速的 Flash 读取(伪代码思路)
void W25Q_DMA_Read(uint32_t addr, uint8_t *buf, uint32_t len)
{
W25Q_CS_LOW();
// 手动发送命令 + 地址(4 字节,量少,CPU 发)
spi_transfer(0x03);
spi_transfer((addr >> 16) & 0xFF);
spi_transfer((addr >> 8) & 0xFF);
spi_transfer(addr & 0xFF);
// 数据部分用 DMA 接收
// 配置 DMA:SPI RX → buf,长度 len
// 同时 SPI TX 发送 0xFF 驱动时钟(也用 DMA)
// 等待 DMA 完成
W25Q_CS_HIGH();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
实际效果:读取 4KB 数据时,DMA 方式比逐字节轮询快 3~5 倍(CPU 可在传输期间做其他事)。
15.8.3 页对齐写入
Flash 写入在页边界(256B 对齐)时效率最高。跨页写入需要驱动层拆分为多次 Page Program 操作。设计数据结构时尽量让记录大小为 256 的因数(如 16B、32B、64B),避免频繁跨页。
15.8.4 批量擦除
连续擦除多个扇区时,使用 Block Erase(64KB 整块擦除)比逐个 Sector Erase 快得多:
| 操作 | 擦除大小 | 典型耗时 |
|---|---|---|
| Sector Erase (0x20) | 4KB | 45ms |
| Block Erase (0xD8) | 64KB | 120ms |
| Chip Erase (0xC7) | 全片 | 20~100s |
擦除 64KB:用 Sector Erase 需 16 × 45ms = 720ms;用 Block Erase 只需 120ms。
15.9 知识总结
| 主题 | 要点 |
|---|---|
| 分区管理 | 明确边界、地址表常量化、只读保护 |
| 磨损均衡 | 追加写入延长寿命 256 倍;双扇区更安全 |
| 掉电安全 | CRC 校验 + 双备份 + 序列号 → 任意时刻断电可恢复 |
| 环形日志 | 多扇区轮转、自动覆盖最旧、CRC 校验每条记录 |
| KV 存储 | 键值对追加写入、同 key 后值覆盖、满时 compact |
| 字库管理 | GB2312 线性寻址、Unicode 映射表、LRU 缓存 |
| 性能优化 | Fast Read、DMA 批量传输、页对齐、Block Erase |
实际应用建议
- 存几个配置参数 → 追加写入法(15.3.1),最简单
- 存很多参数且可能增减 → KV 存(15.6)
- 记录传感器数据 → 环形日志(15.5)
- 关键数据不能丢 → 双备份 + CRC(15.4)
- 显示中文 → 字库缓存加载(15.7)
根据实际需求组合使用,不必全部实现。
本章覆盖了 Flash 存储在实际工程中最常见的使用模式。这些技术不仅适用于 W25Q64,对所有 NOR Flash(W25Q128、GD25Q、MX25L 等)都通用——接口相同,只是容量和地址范围不同。