1. Flash 存储器的本质与工程定位

Flash 是嵌入式系统中一种非易失性存储器(NVM),其核心价值在于断电后数据持久保存。在 STM32F103ZET6 这类主流 MCU 中,512 KB 的 Flash 空间是程序代码、常量数据及少量用户配置信息的物理载体。它并非通用数据存储介质,而是一个与 CPU 指令执行流深度耦合的硬件模块。理解这一点至关重要:Flash 的读写操作不是简单的内存访问,而是涉及时序控制、状态机管理与安全保护的底层硬件行为。

与 SRAM 的本质区别在于访问语义。SRAM 是运行时工作内存,所有变量、堆栈、函数调用帧均驻留于此;而 Flash 是只读执行域(Read-Only Execution Domain),CPU 的取指总线直接连接到 Flash 地址空间。当我们在调试器中修改某个寄存器值并观察到程序行为变化时,这种“即时生效”并非修改了 Flash 中的代码,而是将整个程序镜像加载到了 SRAM 中运行——此时 Flash 中的原始二进制文件纹丝未动。退出调试后,MCU 仍从 Flash 的起始地址 0x08000000 复位启动,加载并执行原始固件。这个现象清晰地划定了 Flash 作为“固件仓库”的边界:它存储的是经过编译链接后的最终可执行映像,而非运行时动态数据。

因此,在工程实践中,对 Flash 的操作必须遵循严格的生命周期管理。任何写入操作都意味着对固件映像的永久性修改,其风险远高于 SRAM 操作。一个未经充分验证的 Flash 写入错误,可能导致整个系统无法启动(bricked)。这解释了为何 ST 官方文档将 Flash 编程列为“高级功能”,并要求开发者必须透彻理解其内部结构与操作协议。

2. Flash 地址空间与启动机制

STM32 的地址空间是一个统一的 32 位线性映射。Flash 存储器被映射到 0x08000000 开始的连续区域。对于 512 KB 的 Flash,其结束地址为 0x0807FFFF (计算过程:512 × 1024 = 524,288 字节; 0x08000000 + 524,288 - 1 = 0x0807FFFF )。这一地址范围是固定的硬件特性,由芯片的物理设计决定,与软件无关。

该地址空间并非全部用于用户程序。在 0x08000000 起始处,存放的是中断向量表(Interrupt Vector Table)。复位后,CPU 首先从 0x08000000 读取初始堆栈指针(MSP)值,再从 0x08000004 读取复位中断服务程序(Reset Handler)的入口地址,并跳转执行。这个设计是 ARM Cortex-M 架构的标准,确保了启动过程的确定性。

启动模式由 BOOT0 和 BOOT1 引脚的电平组合决定,这是一个硬件级的多路选择器:
- BOOT0 = 0, BOOT1 = x :从主 Flash 存储器启动( 0x08000000 ),这是绝大多数应用的默认模式。
- BOOT0 = 1, BOOT1 = 0 :从系统存储器(System Memory)启动,该区域固化了 ST 官方的 Bootloader。
- BOOT0 = 1, BOOT1 = 1 :从内置 SRAM 启动,通常用于调试或特殊恢复场景。

系统存储器中的 Bootloader 是一个关键的安全与维护通道。它通过 USART1(硬件上固定连接)提供串口 ISP(In-System Programming)功能。普中科技等开发板利用此特性,通过 CH340 芯片将 USB 信号转换为 TTL 电平的 UART 信号,使用户无需 J-Link 即可完成程序烧录。这种设计深刻影响了硬件原理图:USB 接口必然硬连线至 USART1 的 TX/RX 引脚,这是 PCB 布局的刚性约束,而非软件可配置项。

3. Flash 内部结构与页擦除机制

Flash 的物理组织以“页”(Page)为基本擦除单位。STM32F103ZET6 的 512 KB Flash 被划分为 256 个页,每页大小为 2 KB(2048 字节)。这一结构是硬件固有的,无法通过软件更改。理解页的概念是进行可靠 Flash 操作的前提。

擦除操作具有不可逆性与原子性。一次擦除操作只能针对整页进行,无法擦除单个字节或单个字。这意味着,若需更新存储在 Flash 中的某个 4 字节 PID 参数,工程师必须:
1. 读取整个目标页(2 KB)的数据到 SRAM 缓冲区;
2. 在 SRAM 中修改目标参数;
3. 擦除该页;
4. 将修改后的 2 KB 数据重新写入该页。

这个流程凸显了 Flash 作为存储介质的“低效”特性,但它换来了极高的数据保持可靠性(典型值为 10,000 次擦写循环)。相比之下,EEPROM 支持字节级擦写,但其容量小、成本高、速度慢。在资源受限的嵌入式系统中,工程师必须根据数据更新频率、容量需求与可靠性要求,在 Flash 与外部 EEPROM 之间做出权衡。对于机器人云台 PID 参数这类更新频率极低(可能仅在出厂标定或固件升级时发生)、数据量小(几十字节)的应用场景,利用 Flash 的页擦除机制是完全可行且经济的方案。

4. Flash 保护机制与安全编程模型

Flash 模块受 FPEC(Flash Program/Erase Controller)控制,其寄存器组(如 FLASH_CR, FLASH_SR)默认处于写保护状态。这是一种硬件级的安全设计,防止意外的软件错误导致 Flash 内容被破坏。要执行任何擦除或编程操作,必须首先解除保护。

解锁过程是一个精确的“钥匙序列”协议:
1. 向 FLASH_KEYR 寄存器写入第一个密钥 0x45670123
2. 向 FLASH_KEYR 寄存器写入第二个密钥 0xCDEF89AB

此序列必须严格按顺序、无中断地执行。任何错误(如写入错误的密钥、顺序颠倒、中间插入其他 Flash 操作)都会触发总线错误(BusFault)并永久锁定 FPEC,直至下一次系统复位。这体现了嵌入式开发中“防御性编程”的重要性:所有 Flash 操作函数必须包含完整的错误检查与状态轮询逻辑。

解锁后,操作完成必须立即执行加锁操作(设置 FLASH_CR 寄存器的 LOCK 位)。这类似于进入一个保险库后必须随手关门,确保后续代码不会无意中修改 Flash 控制寄存器。整个 Flash 编程模型是一种典型的“临界区”操作:解锁 → 执行擦除/编程 → 加锁。任何中断服务程序(ISR)若尝试访问 Flash,都必须被禁止或确保其不执行 Flash 操作,否则将引发不可预测的冲突。

5. Flash 读写 API 的底层实现与工程实践

Flash 的读操作是纯粹的内存映射访问,无任何特殊协议。其本质是 CPU 对 0x08000000 起始地址空间的普通读取指令。因此,读取一个 16 位无符号整数( uint16_t )只需进行类型转换:

uint16_t data = *(volatile uint16_t*)0x08004000;

此处 volatile 关键字至关重要,它强制编译器每次访问都生成实际的内存读取指令,而非优化为缓存值。

写操作则复杂得多,其标准流程如下:
1. 状态检查 :轮询 FLASH_SR 寄存器的 BSY (Busy)位,确保前一操作已完成。
2. 解锁 :执行上述两步密钥序列。
3. 页擦除 :设置 FLASH_CR PER 位(Page Erase),写入目标页地址到 FLASH_AR ,置位 FLASH_CR STRT 位,再次轮询 BSY 直至完成。
4. 编程 :清除 PER 位,设置 PG 位(Programming),使用半字(16-bit)写入指令向目标地址写入数据,轮询 BSY
5. 加锁 :设置 FLASH_CR LOCK 位。

HAL 库提供的 HAL_FLASHEx_Erase() HAL_FLASH_Program() 函数封装了上述全部细节。但工程师必须理解其内部逻辑,因为 HAL 函数的返回值( HAL_StatusTypeDef )是唯一的错误反馈渠道。在实际项目中,我曾因忽略对 HAL_FLASHEx_Erase() 返回值的检查,导致在 Flash 擦除失败(如电压不稳)后继续执行编程,最终将无效数据写入,造成设备批量失效。自此,所有 Flash 操作都强制加入 if (HAL_OK != status) { /* 错误处理 */ } 判断。

6. 工程内存布局分析与地址规划

在将用户数据写入 Flash 前,首要任务是精确划定“安全区”。这要求工程师深入分析编译器生成的 .map 文件。该文件是链接器输出的权威内存布局报告,位于工程输出目录(如 Objects/xxx.map )。

.map 文件末尾的 Memory Map of the image 部分,可找到关键信息:

ER_IROM1 0x08000000 0x00080000
...
LOAD_REGION_1 0x08000000 0x00003F7C
...

其中 0x00003F7C 是当前固件的十六进制大小(约 16.2 KB)。因此,用户数据的安全起始地址应为 0x08000000 + 0x00003F7C = 0x08003F7C 。但考虑到页对齐要求(2 KB = 0x00000800 ),实际应向上取整到下一个页首地址: 0x08004000

Keil MDK 的 Options for Target -> Utilities -> Settings 中的 Flash 下载选项,正是基于此原理:
- Erase Full Chip :擦除整个 512 KB,适用于固件全量升级。
- Erase Sectors :仅擦除固件映像所占用的页(本例中为 0x08000000 0x08003FFF ),这是最常用、最安全的选项。
- Do not Erase :此选项在 Keil 中仅对特定调试场景有效,实际 Flash 编程前必须擦除,故不应在生产代码中依赖。

在机器人项目中,我们将 PID 参数存储于 0x08004000 。通过 #define PID_PARAM_ADDR 0x08004000 宏定义,所有相关读写函数均以此为基准,彻底避免了硬编码地址带来的维护噩梦。

7. 多应用程序跳转与 Bootloader 设计

Flash 的页擦除特性使其天然支持多应用程序共存。其核心思想是将 Flash 空间划分为多个逻辑分区,每个分区存放一个独立的、可自启动的应用程序。主程序(Bootloader)负责根据用户输入(如按键)或通信指令,跳转到指定分区的复位向量地址。

跳转的底层实现是 C 语言函数指针的强制类型转换:

typedef void (*pFunction)(void);
pFunction Jump_To_Application;
uint32_t JumpAddress = *(__IO uint32_t*)(0x08004000 + 4); // 读取目标APP的复位向量(地址+4)
Jump_To_Application = (pFunction)JumpAddress;
__set_MSP(*(__IO uint32_t*)0x08004000); // 设置目标APP的主堆栈指针(地址+0)
Jump_To_Application(); // 执行跳转

此代码的关键在于两点:
1. 向量表偏移 0x08004000 是目标 APP 的起始地址,其 0x08004000 处存放 MSP, 0x08004004 处存放 Reset Handler 入口。
2. 堆栈重置 __set_MSP() 是 CMSIS 内联函数,用于在跳转前将 CPU 的主堆栈指针(MSP)切换到目标 APP 的初始值,确保其拥有独立的运行栈空间。

在实际部署中,三个应用程序( app_main , app_printf1 , app_printf2 )分别被链接到 0x08000000 , 0x08001600 , 0x08004000 。其 .map 文件明确显示了各自的大小与分配。下载时,Keil 的 Flash 下载器会智能识别各程序的起始地址与大小,仅擦除并烧录对应页,互不干扰。这种设计为 OTA(Over-The-Air)固件升级提供了坚实基础:主 Bootloader 可通过 UART 或 BLE 接收新固件,将其写入空闲页(如 0x08004000 ),校验无误后,修改一个标志位并复位,新固件即接管系统。

8. Flash 使用的工程经验与避坑指南

在多年嵌入式开发中,我踩过几个与 Flash 相关的深坑,这些经验比任何理论都更宝贵:

坑一:HSI 时钟依赖陷阱
Flash 编程操作需要稳定的内部时钟源(HSI)。虽然 RCC->CR 中 HSI 默认使能,但在某些低功耗模式或异常复位后,HSI 可能被意外关闭。若此时执行 Flash 擦除,操作将失败或超时。解决方案是在所有 Flash 操作函数的开头,强制添加 __HAL_RCC_HSI_ENABLE() 并等待 __HAL_RCC_GET_FLAG(RCC_FLAG_HSIRDY) ,确保时钟就绪。

坑二:中断禁用的粒度
许多教程建议在 Flash 操作期间全局禁用中断( __disable_irq() )。这虽简单,却可能引发严重问题:若禁用时间过长,看门狗(IWDG)可能超时复位,或实时任务严重超期。更优策略是仅禁用那些可能触发 Flash 访问的中断(如某些 DMA 传输完成中断),并对关键 Flash 操作段使用 __disable_irq() / __enable_irq() 进行最小化包裹。

坑三:页擦除的“幽灵”数据
当擦除一页时,该页所有字节被置为 0xFF 。若新写入的数据未覆盖整个页,残留的 0xFF 字节会成为“幽灵”数据。例如,一个结构体有 100 字节,写入后页内剩余 1948 字节均为 0xFF 。若后续代码逻辑错误地读取了这些 0xFF 字节并当作有效数据解析,将导致不可预知的行为。因此,在写入结构体前,务必先将整个 SRAM 缓冲区清零,再填充有效数据,最后写入 Flash。

坑四:调试器的“幻影”写入
在 Keil 调试时,若在 Watch 窗口中直接修改 Flash 地址的值(如 *(uint32_t*)0x08004000 = 0x12345678 ),调试器会自动调用其内置的 Flash 编程算法。这看似方便,但极易掩盖真实代码中的 Bug。我曾因此误以为自己的 Flash 写入函数工作正常,直到脱离调试器独立运行才发现其根本未被调用。因此,调试阶段务必关闭调试器的自动 Flash 写入功能,只通过自己编写的函数进行操作。

这些教训共同指向一个核心原则:Flash 操作不是普通的内存读写,而是一次对硬件状态机的精密操控。每一次 HAL_FLASH_Program() 调用,背后都是对 FLASH_CR , FLASH_SR , FLASH_AR 等寄存器的一系列读-改-写操作。唯有将 API 调用与寄存器手册的描述一一对照,才能真正掌控它。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐