内部 Flash 编程:参数持久化与页擦除
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 17 篇。上一篇:ADC 采样:单次、扫描与内部温度传感器,下一篇:独立看门狗:复位诊断与可靠性设计,目录见文末。
内部 Flash 编程:参数持久化与页擦除
导读
芯片自己的 512 KB Flash 除了装代码,还能装"参数":用户设置、校准系数、累计计数。不外加任何器件就把数据存过断电,是内部 Flash(IAP,In-Application Programming)的独门价值。但它也是全系列最"裸"的一篇:microzig 未提供 Flash HAL,本节寄存器直写——没有 hal.flash 模块(第 01 篇的 HAL 能力边界表里第一条缺席者就是它),解锁、擦除、编程、加锁四个动作全部手写,位定义依据 RM0008 §3。这一篇还要直面一个别的教程常绕开的真相:F103 是单 bank 芯片,从 Flash 执行期间擦写 Flash,CPU 取指会停顿——2 KB 页擦除的 20–40 ms 里整个 CPU 是冻结的,你的串口、你的时基、你的看门狗都在这段"时间真空"里静止。理解并围绕这个代价做设计(数据页放 Flash 末尾、双页轮换磨损均衡),是本篇的主线。
为什么需要内部 Flash 与它的硬规则
需求很朴素:产品要在断电后记住用户的设置。RAM 做不到(掉电即失);EEPROM 外挂器件要占 I2C 总线与 PCB 面积;内部 Flash 就在你已经付费的芯片里,F103ZET6 的 512 KB 对 64–128 KB 的固件而言有巨大富余——把最后一页让给参数,零 BOM 成本。
代价是 Flash 的三条物理规则,全部来自浮栅存储单元的物理特性(第 15 篇讲过 SPI NOR 的同款规则,内部 Flash 只是"挂在 AHB 上的同族亲戚"):
- 编程只能 1 → 0。擦除后整页是 0xFFFF(全 1),编程把选定位清 0。想改一个位,必须先整页擦除。
- 擦除按页。高密度 F103 的页是 2 KB(512 KB / 256 页;中低密度是 1 KB——跨型号移植时第一件要查的事)。改 1 字节的代价是整页 2 KB 的擦除 + 重写。
- 擦写期间 Flash 不可读。编程/擦除用的是片内高压泵,期间 Flash 读总线被占用——CPU 从 Flash 取指的每一条指令都要等待,等效于整个 CPU 停机。这是与外挂 SPI Flash 的本质区别(第 15 篇擦 W25Q64 时 CPU 照常跑)。
寄存器层(RM0008 §3.3,FLASH 接口基址 0x4002_2000):
| 偏移 | 寄存器 | 关键位 |
|---|---|---|
| 0x00 | ACR | LATENCY、预取使能(第 07 篇配过) |
| 0x04 | KEYR | 解锁键序列入口 |
| 0x0C | SR | BSY=0、PGERR=2、WRPRTERR=4、EOP=5 |
| 0x10 | CR | PG=0、PER=1、AR=2、MER=3、OPTPG=4、OPTER=5、STRT=6、LOCK=7 |
| 0x14 | AR | 页擦除的目标地址 |
写保护机制:复位后 CR 的 LOCK 位为 1,任何写 CR 的擦写位都被忽略;向 KEYR 依次写入 0x45670123 → 0xCDEF89AB(两个写死魔数,写错或乱序都会把总线锁死直到复位)后解锁,CR.LOCK 清零。用完立刻置回 LOCK——参数存储代码不该让 Flash 长期裸奔。
代码案例:配置结构体存到最后一页
设计:Config 结构体(魔数 + 版本 + 用户参数 + CRC32)存放在 0x0807F800——512 KB Flash 的最后一页(页 255:0x08000000 + 255 × 0x800 = 0x0807F800)。上电读出校验,失败则写默认值。
const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const time = stm32.time;
pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{ .logFn = stm32.uart.log });
comptime {
_ = microzig.export_startup();
}
// ---------- FLASH 寄存器层(microzig 无 Flash HAL,寄存器直写) ----------
// 位定义与偏移依据 RM0008 §3.3(FLASH 接口,基址 0x40022000)
// regz 对 FLASH 外设的字段名本地不可核对,统一用"基址 + 偏移"的 volatile 指针
const FLASH_BASE: usize = 0x4002_2000;
const flash_keyr: *volatile u32 = @ptrFromInt(FLASH_BASE + 0x04); // KEYR
const flash_sr: *volatile u32 = @ptrFromInt(FLASH_BASE + 0x0C); // SR
const flash_cr: *volatile u32 = @ptrFromInt(FLASH_BASE + 0x10); // CR
const flash_ar: *volatile u32 = @ptrFromInt(FLASH_BASE + 0x14); // AR
const KEY1: u32 = 0x45670123;
const KEY2: u32 = 0xCDEF89AB;
const CR_PG: u32 = 1 << 0; // 编程(半字)
const CR_PER: u32 = 1 << 1; // 页擦除选择
const CR_AR: u32 = 1 << 2; // 页擦除地址选择
const CR_STRT: u32 = 1 << 6; // 启动擦除
const CR_LOCK: u32 = 1 << 7; // 锁定
const SR_BSY: u32 = 1 << 0;
const SR_PGERR: u32 = 1 << 2;
const SR_WRPRTERR: u32 = 1 << 4;
const PAGE_SIZE: usize = 2048; // 高密度 2KB 页
const CFG_ADDR: usize = 0x0807F800; // 页 255 = 最后一页
const MAGIC: u32 = 0xC0FFEE01;
const Config = extern struct {
magic: u32,
version: u16,
brightness: u16,
reserved: u16, // 凑齐偶数个半字
crc: u32, // 前 4 个半字(12 字节)的硬件 CRC
};
fn flash_wait_busy() u32 {
while (flash_sr.* & SR_BSY != 0) {}
return flash_sr.*;
}
fn flash_unlock() void {
if (flash_cr.* & CR_LOCK != 0) {
flash_keyr.* = KEY1;
flash_keyr.* = KEY2;
}
}
fn flash_lock() void {
flash_cr.* |= CR_LOCK;
}
/// 擦除一页。注意:2KB 页擦除典型 20-40ms,期间 CPU 取指停顿。
fn flash_erase_page(page_addr: usize) void {
flash_unlock();
flash_cr.* |= CR_PER | CR_AR; // 选择页擦除 + 地址模式
flash_ar.* = @intCast(page_addr);
flash_cr.* |= CR_STRT;
const sr = flash_wait_busy();
// 清错误标志(PGERR/WRPRTERR 是写 1 清零,用整体赋值而非读改写)
flash_sr.* = SR_PGERR | SR_WRPRTERR;
flash_cr.* &= ~(CR_PER | CR_AR | CR_STRT);
if (sr & SR_PGERR != 0) @panic("flash: pgerr");
flash_lock();
}
/// 把一段内存按半字编程到 Flash(地址必须半字对齐,目标必须已擦除)。
fn flash_program(addr: usize, data: []const u8) void {
std.debug.assert(addr % 2 == 0 and data.len % 2 == 0);
flash_unlock();
flash_cr.* |= CR_PG;
var i: usize = 0;
while (i < data.len) : (i += 2) {
const hw: u16 = @as(u16, data[i]) | (@as(u16, data[i + 1]) << 8);
const dst: *volatile u16 = @ptrFromInt(addr + i);
dst.* = hw; // 半字写即触发编程
const sr = flash_wait_busy(); // 每半字典型 20-40us
if (sr & (SR_PGERR | SR_WRPRTERR) != 0) @panic("flash: program error");
}
flash_cr.* &= ~CR_PG;
flash_lock();
}
// ---------- 配置读写 ----------
fn config_crc(cfg: *const Config) u32 {
stm32.crc.reset();
const words: [*]const u32 = @ptrCast(@alignCast(&cfg.magic));
// CRC 覆盖 magic/version/brightness/reserved 共 3 个字(12 字节,不含 crc 自身)
var i: usize = 0;
while (i < 3) : (i += 1) {
stm32.crc.load(words[i]);
}
return stm32.crc.read();
}
fn load_config() Config {
const cfg: *volatile const Config = @ptrFromInt(CFG_ADDR);
const c = cfg.*;
if (c.magic == MAGIC and c.crc == config_crc(&c)) {
return c;
}
// 空白(0xFFFFFFFF)或损坏:给默认值并保存
var def = Config{ .magic = MAGIC, .version = 1, .brightness = 500, .reserved = 0, .crc = 0 };
def.crc = config_crc(&def);
save_config(&def);
return def;
}
fn save_config(cfg: *const Config) void {
flash_erase_page(CFG_ADDR);
const bytes = std.mem.asBytes(cfg); // extern struct 无填充问题(16 字节)
flash_program(CFG_ADDR, bytes);
}
pub fn main() !void {
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.RTCClockSelection = .LSI,
.flags = .{ .HSEOscillator = true, .RTCUsed_ForRCC = true },
});
time.init_timer(.TIM4);
rcc.enable_clock(.CRC); // 硬件 CRC 单元在 AHB 上
stm32.crc.reset();
var cfg = load_config();
std.log.info("boot: magic={X} ver={d} brightness={d}", .{ cfg.magic, cfg.version, cfg.brightness });
// 模拟"用户改设置":每次上电亮度 +50
cfg.brightness += 50;
cfg.crc = config_crc(&cfg);
save_config(&cfg);
std.log.info("saved, next boot brightness = {d}", .{cfg.brightness});
while (true) {
time.sleep_ms(1000);
}
}
逐段解析
解锁与加锁:两条魔数的仪式感
flash_unlock 的三行代码浓缩了 F1 的写保护设计:KEYR 是只写的序列入口,先 0x45670123、再 0xCDEF89AB,顺序与值都不能错——任何偏差不仅解锁失败,还会把 Flash 总线锁死到下次复位(防暴力尝试的保护设计)。CR.LOCK 读回 0 表示已解锁,函数先判断避免重复发序列(重复写 KEYR 同样算错误序列)。flash_lock 只是简单置回 LOCK 位——它在 erase 与 program 的收尾各调用一次,保证异常路径之外 Flash 总是加锁的。
寄存器访问选了"基址 + 偏移"的 *volatile u32 裸指针写法(@ptrFromInt(0x4002_2000 + 偏移)),而不是 microzig.chip.peripherals.FLASH.CR.modify(...) 的具名字段写法——原因与第 12 篇 USART 同款:regz 生成的字段名依赖本地拉取的 stm32-data-generated,仓库内核对不了;而 RM0008 的偏移(KEYR=0x04、SR=0x0C、CR=0x10、AR=0x14)与位号是印刷品级的事实。若你的生成物里字段名齐全(CR.PG、SR.BSY),用具名 modify 完全等价,只是每个掩码常量旁边那句"RM0008 位号"注释依然要留着——那是排错时的字典。*volatile u32 的语义与 mmio.Mmio.raw 完全一致(第 05 篇的老朋友),volatile 保证轮询 BSY 时每次真读。
页擦除:PER + AR + STRT 的三连拍
擦除序列严格按 RM0008 §3.3.2 的顺序:
- 解锁;
- CR 置 PER(页擦除选择)与 AR(地址选择)——两者必须同时有效,只置 PER 不置 AR 是无效组合;
- AR 写目标页地址(必须 2 KB 对齐);
- CR 置 STRT 启动;
- 轮询 SR.BSY(典型 20–40 ms);
- 清标志(PGERR/WRPRTERR 是写 1 清零语义,用整体赋值而不是读改写——
modify的读改写会把读到的错误标志原样写回,永远清不掉,第 05 篇 W1C 寄存器的老话题); - 清 CR 的 PER/AR、加锁。
擦除完成后该页全部为 0xFFFF。load_config 里 c.magic == MAGIC 的判断隐含利用了这一点:新芯片的最后一页是 0xFFFF,读出的 magic 必然不等于 MAGIC,走默认值分支——"第一次上电"与"数据损坏"共用同一条恢复路径,CRC 校验兜底区分"半途掉电的半新半旧数据"(magic 对但 crc 错)。
半字编程:PG 置位后的第一次写就是命令
编程序列比擦除简单:解锁 → CR 置 PG → 向目标地址写一个 u16 → 等 BUSY → 清 PG。硬件把"PG 有效的写入"解释为编程命令,所以代码里 dst.* = hw 这一句就是完整的编程动作。三个约束:
- 必须半字(u16)对齐、按半字写。F1 的编程粒度是 16 位,用 u8/u32 指针写都是非法操作(u32 写会报错或行为未定义,依型号)。
- 每个半字之间等 BUSY。编程典型 20–40 µs/半字,连续写不等就是 PGERR。
- 目标必须是擦除过的(全 0xFFFF)。对已编程位再编程会触发 PGERR(RM0008 的说法),示例在每次半字后检查 SR 并 panic——裸机 demo 里 panic 可以接受,生产代码应返回错误并走重试/恢复。
extern struct 在这里不是装饰:Config 要被 std.mem.asBytes 压成字节流写入 Flash,必须保证内存布局确定(无 Zig 自动填充)。16 字节的布局恰好是偶数半字——奇数长度要手动补 reserved 字段,flash_program 的 assert 会拦截。
读 Flash:指针、volatile 与"读自己"
0x0807F800 是普通的内存映射地址,读它就是读 Flash,@ptrFromInt 造指针即可——第 05 篇的 MMIO 手法原样适用。两处细节:*volatile const Config 的 volatile 是为了防止编译器把"读两次 Flash"优化成读一次(load_config 读结构体、config_crc 再读字段,中间没有写操作,激进优化可能合并访问);而擦写之后重新读,volatile 同样保证真的重新访存。
config_crc 用了片上 CRC 单元(stm32.crc,28 行的极简模块:load/read/reset)——它算的是 CRC-32/MPEG-2 风格(多项式 0x04C11DB7、初值 0xFFFFFFFF、大端逐字),比软件 CRC 快一个量级且不占代码体积。注意它按 u32 字消费数据,所以 CRC 覆盖范围要凑成 4 的倍数、且指针 4 字节对齐(@alignCast)。不想依赖外设的话,换成查表软件 CRC 也完全成立——存校验值的目的是检测"半途掉电的撕裂数据",算法选择是自由的。
单 bank 的代价:擦写期间 CPU 冻结与三条规避
现在谈本篇最重要的架构事实。F103 只有一个 Flash bank,擦写期间 Flash 读端口被高压泵占用——CPU 的下一条取指请求只能等待。于是 2 KB 页擦除的 20–40 ms 内:主循环停了、串口接收停了、TIM4 时基的中断来了也进不去(ISR 代码在 Flash 里)、如果你已经在用看门狗,1 秒超时的 IWDG 会直接把芯片复位。这不是 bug,是架构,必须设计绕开:
- 数据页放 Flash 末尾(本篇 0x0807F800 的选址逻辑)。代码与数据物理隔离,擦数据页绝不碰代码页;链接器把代码放在 Flash 头部(BANK_1 起始),中间的 200 多页是天然缓冲带。
- 接受停顿但让它可预测:只在"用户点保存"的路径上擦写,不在实时路径上;擦写前把串口接收缓冲的内容处理完(第 12 篇的环形缓冲恰好能扛 40 ms——115200 下 40 ms 会积压约 460 字节,256 字节缓冲不够,要么加大要么先关 RXNEIE 挂起接收)。有看门狗时,超时时间必须大于最坏擦写时间加裕量,或在擦写前喂狗。
- 双页轮换磨损均衡。
save_config每次擦同一页,10 万次擦写寿命 / 每天保存 100 次 = 2.7 年磨穿。改进:用相邻两页 A/B 轮换——保存时只写"另一页"(追加式写,利用 1→0 特性带序号),读时找序号最大的有效记录,两页都满了才擦一次旧的。磨损分摊到两页,寿命 ×2;推广到 N 页就是简单的日志式存储。第 15 篇的 SPI Flash 版本(2048 个扇区)在空间上奢侈得多,但思想同源。
运行现象
烧录后第一次上电串口输出:
boot: magic=C0FFEE01 ver=1 brightness=500
saved, next boot brightness = 550
- 断电重启:
boot: ... brightness=550,然后saved, next boot = 600——每上电一次 +50,数据穿过断电持久累积。 - 首次烧录前用 probe-rs 全片擦除(
probe-rs erase --chip STM32F103ZE):再上电走默认值分支,确认"空白 Flash → 默认配置"路径正常。 - 停顿实测:在
save_config前后各翻转一次 GPIO、示波器量间隔——约 21–45 ms(页擦除 + 8 个半字编程)。同一窗口里挂 PB5 的呼吸灯会"卡帧"一次,这是 CPU 冻结的可见证据。 - 故意破坏 CRC(把 save 后手动改写一个半字、或临时把 MAGIC 改错值烧录再恢复):上电走默认值分支——CRC 拦截撕裂数据的能力得到验证。
- 擦写寿命:写个循环每 100 ms 保存一次,理论 10 万次 ≈ 2.8 小时就能验证"页磨穿"后的 PGERR 行为——当然,真实产品请上双页轮换。
常见问题
Q1:解锁后写 CR 没反应,或整个程序跑飞。
KEYR 序列错了:值必须是 0x45670123 → 0xCDEF89AB 的精确顺序,写错(包括"顺手纠正"字节序)会锁死总线直到复位。另一个常被忽略的规则:OPTKEYR 的选项字节解锁是另一套键(0x00A5A5A5 ×2 次序不同),别混用。还有一条环境性陷阱:调试器断点挂在 Flash 里时,某些擦写操作会被调试口干扰——擦写代码的调试要靠串口日志而不是断点单步。
Q2:编程后读回来不是写进去的值。
按概率:① 目标没擦除——对非 0xFFFF 的位编程触发 PGERR,SR 检查(示例每半字后查)能立刻暴露;② 用了 u32 指针写——F1 编程粒度是 16 位,必须 *volatile u16;③ 地址没对齐半字;④ 页边界跨了——擦的是 A 页、写到 B 页,B 页没擦;⑤ WRPRTERR——写保护没关(选项字节的 WRP 位,通常出厂全关,但二手板可能被设过)。诊断入口永远是 SR:PGERR(位 2)与 WRPRTERR(位 4)会把失败原因写在你脸上。
Q3:擦除期间串口丢数据 / 时基乱了 / 看门狗复位了。
这就是单 bank 停顿。三条对策(见正文):数据页隔离 + 可预测的擦写时机(避开实时路径);擦写前处理完或暂停接收(加大第 12 篇的缓冲到 512+ 字节可扛 40 ms);看门狗超时 > 最坏停顿 + 裕量,或擦写前喂狗。如果应用不能容忍 40 ms 的黑洞,答案不是优化代码,而是换外挂 SPI Flash(第 15 篇)或换双 bank 芯片(F4/F7 系列)——架构问题要在架构层解决。
Q4:为什么数据页选 0x0807F800?代码会不会长到这一页?
0x0807F800 = 0x08000000 + 255 × 0x800,是 256 页中的最后一页(页 255)。链接器把代码从 0x08000000 向上生长;只要你的固件 < 510 KB,就碰不到最后一页。固件超大的风险可以用链接脚本检查(第 20 篇会讲如何在 map/section 尺寸里确认 text 的结束地址),或者干脆在 build 里加一步断言。另一层保护:probe-rs/OpenOCD 烧录只擦写被固件占用的扇区(按 ELF 段),不会动最后一页——但也意味着你烧新固件时参数页数据会保留,这对"升级固件不丢配置"恰好是想要的行为。
Q5:F103ZET6 的 2 KB 页与 F103C8 的 1 KB 页有区别吗?代码能移植吗?
页大小是密度的函数:小/中密度 1 KB、高密度(ZET6 属于)2 KB。PAGE_SIZE 写成常量的意义就在这:跨型号移植时只改这一个数,其余逻辑(解锁/擦/编/锁)完全一致。两个隐含差异要跟着改:页数(512 KB/2 KB = 256 vs 128 KB/1 KB = 128)与"最后一页地址"。用芯片 ID 读出容量(0x1FFFF7E0 的 u16,单位 KB)动态计算页数与末页地址,是让同一份驱动覆盖整个 F1 家族的标准做法。
Q6:内部 Flash 与外挂 SPI Flash(第 15 篇)怎么选?给出一张决策表。
参考答案:维度对比——容量:512 KB vs 8 MB(外置赢一个数量级);擦写停顿:内部 20–40 ms CPU 冻结 vs 外置零停顿(外置赢);读速度:内部零拷贝指针直读 vs 外置要过 SPI 协议(内部赢);擦写粒度:2 KB 页 vs 4 KB 扇区(内部略优);寿命:同量级 10 万次,但外置容量大、磨损均衡空间大;成本:内部零 BOM vs 外置 +4 线 + 器件。决策:小配置参数、读写不频繁 → 内部 Flash(本篇);日志流、字库、固件备份、高频记录 → 外置 SPI Flash;两者都要的常见架构是"配置进内部、数据进外部"。
面试考点
Q1:写出 F103 内部 Flash 的解锁—擦除—编程—加锁完整寄存器序列(位定义依据 RM0008)。
参考答案:解锁:KEYR 写 0x45670123 后写 0xCDEF89AB(顺序错误或值错误会锁死到复位),CR.LOCK 清零。页擦除:CR 置 PER+AR → AR 写 2KB 对齐的页地址 → CR 置 STRT → 轮询 SR.BSY 清零 → 清错误标志(PGERR/WRPRTERR,写 1 清零)→ 清 PER/AR。编程:CR 置 PG → 按半字(u16、对齐)写目标地址 → 每半字后等 BSY → 检查 PGERR → 清 PG。加锁:CR 置 LOCK。全程约束:目标必须先擦除(0xFFFF)、编程粒度半字、擦除期间 Flash 不可读。
Q2:为什么 F103 擦写内部 Flash 时 CPU 会停顿?有哪几种工程规避手段?
参考答案:F103 单 bank,编程/擦除使用片内电荷泵,期间 Flash 读端口不可用,CPU 取指被迫等待——2 KB 页擦除典型 20–40 ms 内整个系统(主循环、中断、时基)冻结。规避:① 数据页与代码页物理隔离(数据放 Flash 末尾);② 把擦写限制在可停顿的路径(用户显式保存),实时路径不碰 Flash;③ 接收缓冲按"最坏停顿 × 波特率"预算,或暂停接收;④ 看门狗超时覆盖最坏停顿;⑤ 不能容忍停顿的应用改用外挂 SPI Flash 或双 bank 芯片。理解"停顿是架构属性"比试图"优化掉停顿"更接近正确答案。
Q3:配置数据为什么要有魔数 + 版本 + CRC 三件套?分别拦截什么故障?
参考答案:魔数(magic)区分"从未写过"(空白 Flash 全 0xFFFF)与"写过"——首次上电走默认值分支;版本(version)支撑结构体演化的向后兼容——新固件读到旧版本数据时按版本号迁移字段;CRC 拦截"半途掉电"——擦除后只写了一半、页内数据撕裂,magic 可能恰好有效但 CRC 必然不匹配,走默认值而不是用损坏数据。三者共同保证存储层在任何异常下都收敛到"安全默认值",这是配置持久化的最低可靠性门槛。擦写寿命紧张时再加序号与双页轮换(A/B),用空间换磨损与掉电安全的双重保险。
Q4:extern struct 在本篇的作用是什么?换成普通 struct 会有什么风险?
参考答案:Config 要经 std.mem.asBytes 压成字节写入 Flash、再从 Flash 按同样布局读回,两端布局必须逐字节一致。extern struct 保证 C ABI 布局(字段按声明顺序、无 Zig 自由重排),是确定布局的最低要求。普通(auto layout)struct 的布局由编译器自由决定——理论上两次编译都一致,但没有任何承诺:字段重排、填充插入都可能发生,写进 Flash 的字节布局变成"本次编译的偶然结果"。风险场景:固件升级后 auto 布局变化,旧数据按新布局解读 → 全部字段错位且 magic 校验可能侥幸通过。配 CRC 的三件套能兜住大部分,但布局确定性应从类型层面锁死。另外对齐(@alignCast)与长度(偶数半字)也要显式保证。
Q5:*volatile const Config 指向 Flash 的读为什么需要 volatile?什么情况下可以省?
参考答案:volatile 强制每次访问真实访存、禁止编译器合并/缓存/删除"冗余"读。需要它的场景:读 Flash 的前后发生了"编译器看不见的写入"(如本程序的 flash_program 直写地址,或调试器/DMA 改了内容),volatile 保证重新读到新值;多次读取同一配置也不允许被优化合并(可能有其他执行流改写)。可以省的场景:数据从加载后确实无人修改、且只读一次——那时普通 const 指针即可。原则同 MMIO:只要"这内存会被编译器之外的力量改变",就 volatile;否则是多余的悲观。本例中 save 后重新 load 的路径必须有 volatile,纯上电首次读则可以省。
Q6:设计一个"每天保存 10 次、产品寿命 10 年"的配置存储,F103 内部 Flash 怎么做才够?
参考答案:需求:10 年 × 365 × 10 = 36500 次擦写周期,单页 10 万次寿命看似够,但还要算上调试期与异常重试,按 3 倍裕量 = 11 万次,单页不够。方案:4 页轮换(8 KB,占 512 KB 的 1.5%)——追加式记录(每条含序号 + CRC),写满一页擦最旧的一页,磨损分摊 4 倍 ≈ 40 万次容量,裕量 10 倍以上。读侧:扫描 4 页找最大有效序号。若参数频度再高一个数量级(每分钟保存),答案变成"换存储介质":外挂 SPI NOR(2048 扇区,寿命余量三个数量级)或 FRAM/EEPROM(无擦除粒度限制)。方法论:先按"寿命 × 裕量 = 所需擦写次数"做预算,再决定"单页重写 / 多页轮换 / 换介质"的层级,而不是上来就写代码。
小结
- microzig 无 Flash HAL,寄存器直写:KEYR 双魔数解锁(0x45670123 → 0xCDEF89AB)、CR.PG/PER/AR/STRT、SR.BSY 轮询、CR.LOCK 加锁;位定义依据 RM0008 §3(SR: BSY=0/PGERR=2/WRPRTERR=4,CR: PG=0/PER=1/AR=2/STRT=6/LOCK=7)。
- 高密度 F103 页 = 2 KB,512 KB 共 256 页;数据页放末页 0x0807F800,与代码天然隔离。
- 编程粒度半字(
*volatile u16),目标必须先擦除(0xFFFF),PGERR/WRPRTERR 是失败的自白书;W1C 标志用整体赋值清。 - 单 bank 擦写 = CPU 冻结 20–40 ms:串口、时基、看门狗全部静止——预算缓冲、选对时机、喂狗,或换外挂 Flash。
- 配置三件套:魔数(区分空白)+ 版本(演化兼容)+ CRC(拦截撕裂);
extern struct锁死布局,片上 CRC 单元按 u32 消费。 - volatile 读 Flash 的条件是"有编译器外的力量会改它"。
- 双页/多页轮换把 10 万次寿命摊成 N 倍,思想与第 15 篇 SPI Flash 的磨损均衡同源。
- 下一篇 IWDG:既然 flash 擦写能卡死看门狗,就该认真讲讲看门狗本身了——复位诊断与可靠性设计。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与平台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号