做了这么多年嵌入式开发,我见过太多新手项目栽在FLASH分区管理这个坎上。不少朋友拿着开发板,把整颗Flash当成一块大数组随便用,编译出来能跑就算完事,结果一上OTA就翻车,一断电配置就丢,甚至Bootloader和App互相踩踏,板子直接变砖。FLASH分区管理这件事,核心就两件事:Code区和Data区怎么划,以及划分之后怎么管。这篇文章我把里里外外全部拆开讲,从Flash的物理特性讲到链接脚本,从OTA双分区讲到磨损均衡,全程带实操代码和避坑经验,新手照着做就能少走半年弯路。
1. FLASH分区的本质:先看清手里的Flash到底是什么
1.1 NOR Flash的三个基础操作,决定了分区的玩法
嵌入式里绝大多数MCU(STM32、GD32、ESP32这些)用的是NOR Flash,不是电脑里的SSD那种NAND。NOR Flash的物理特性决定了你能对它做什么,以及做这些操作的代价有多大。理解不了这一点,后面所有分区设计都是空中楼阁。
第一个操作是读(Read)。Flash支持随机读,芯片可以像从内存里取指令一样直接读Flash里的代码来执行,这叫XIP(Execute In Place,原地执行)。也就是说,代码放在Flash里,CPU可以直接跑,不用先拷到RAM里。这也是为什么Code区可以放在Flash上长期运行的前提。
第二个操作是写(Program)。NOR Flash的写操作有一个非常反常识的限制:它只能把位从1变成0,不能把0变成1。所以往一个已经写过数据的地址上再写新数据,结果会跟你的预期完全不一样,大概率得到一堆垃圾值。要把0变回1,必须执行擦除操作。
第三个操作是擦除(Erase)。擦除不是按字节、按字来的,而是按扇区(Sector)或块(Block)为单位整片进行的。不同芯片规格不同,有的扇区是4KB,有的是8KB、16KB甚至128KB,擦除一块就是一整块全部变成0xFF。这就好比你要改一张纸上的一个字,不能涂改,只能把整页纸撕掉换一张新的,然后重新写上所有内容。
这三个特性串起来的结论就是:你想更新Flash里的任何一小块数据,都需要先在更大的扇区粒度上做擦除,再整区重写。这对分区设计的影响是决定性的,后面讲擦除对齐、磨损均衡、掉电保护的时候,全都是从这里推导出来的。
1.2 Code区和Data区:概念上的第一次澄清
很多新手会把"Code/Data分区"理解成"代码放一边,数据放一边",这个理解方向没错,但不够精确。在嵌入式开发里,Code和Data的关系要分两个层面看。
第一个层面是编译器视角。一个C工程编译出来,代码段在内存布局上会分成好几个区域:
.text:机器指令,就是你的函数编译后的代码,还有一些常量字符串也可能跟它放在一起。.rodata:只读数据,比如const修饰的查表数组、协议帧格式、版本号、出厂序列号等。.data:有初值的全局变量/静态变量。注意,变量的初值在Flash里保存,但运行的时候变量本身要被复制到RAM里,因为变量要可写。.bss:没有初值或者初值为0的全局变量,运行时全部在RAM里,Flash里不占空间。
所以你把一个工程下载到芯片之后,Flash上真正存的东西是:.text、.rodata、.data的初始值拷贝,以及你自己在用户区存放的参数、日志、OTA固件等数据。严格说,Code区至少涵盖.text和.rodata,而Data区在Flash这个语境下,通常指那些运行期可读写的用户数据存储区。
第二个层面是"Data"的词义陷阱。新手经常把"Data"跟RAM划等号,这也没错,但放到FLASH分区管理这个话题下,Data区更常指的是"放在Flash里的可变数据区",比如设备参数、校准值、OTA下载的新固件、运行日志。这些数据的特点是:掉电不能丢、运行时要能改、改了要能读回。它们是Flash里最考验设计功力的部分,后面详细讲。
一句话总结:Code区管的是"程序能不能正常跑起来",Data区管的是"设备的数据能不能可靠存下来"。两者物理上都住在这颗Flash里,但生命周期、访问频率、容错要求完全不同,所以必须分区管理,不能混用。
2. 分区设计的幕后逻辑:好的分区是设计出来的,不是碰出来的
2.1 Bootloader与App分区:启动链路背后的安全考量
为什么几乎所有正经产品都要求Bootloader和App分开分区?因为要解决"固件升级失败后设备还能不能活"的问题。如果没有Bootloader,App里的Flash擦写代码万一在升级中途断电挂掉,整颗Flash可能就是一块废料,设备变砖只能寄回原厂用烧录器救。有了独立的Bootloader,升级失败最坏情况下还能回到Bootloader重新下载。
Bootloader分区通常放在Flash的最开头(比如0x08000000),因为芯片复位后就是从起始地址取向量表的。App分区跟在Bootloader后面,比如STM32F103这样起始地址在0x08000000的芯片,可以把前16KB或32KB划给Bootloader,后面的空间全部给App。
这里有一个新手必踩的坑:App的向量表偏移必须重新定位。芯片默认的中断向量表在Flash起始位置,也就是Bootloader的位置。如果App直接裸奔,一旦发生中断,CPU会跳到Bootloader的向量表去取中断处理函数地址,结果就是跑飞或者进HardFault。解决方法是:
- 在App代码里设置SCB->VTOR寄存器为App分区的起始地址。
- 在链接脚本里把App的起始地址设到分区起始处。
- 如果芯片不支持VTOR(老款M0内核部分型号),就得在App启动代码里手动做中断向量重映射,用跳转表转发中断。
Bootloader跳到App也不是简单跳过去就行。正确的跳转姿势是:先关掉全局中断,把栈指针(MSP)设置为App向量表第一个字,然后从App向量表第二个字取复位处理函数地址,再跳转执行。很多移植失败是因为只跳了地址,没有重新设置栈指针,App一运行就崩溃。
2.2 OTA升级:下载区与备份区的博弈
OTA(空中升级,Over-The-Air)是分区设计的核心考点。不管是通过WiFi、蓝牙还是4G收到新固件,你总要先把固件完整地接住,检验没问题,再把它写进App区。问题是:新固件放哪?最朴素的做法是下载区直接覆盖App区,逐包写入。但这样有个致命风险:如果写了一半断电,或者下载的文件本身损坏,App区就残了。相当于你开着车在路上换轮胎,车直接就停在路中间了。
靠谱的做法有两种:
一种是"下载暂存区"方案。Flash里专门留一块区域(通常比App区大一些)当作下载缓冲区,收到完整固件后校验CRC,校验通过再整体搬移到App区。搬移过程本身也要考虑掉电,所以成熟方案会加一个"升级状态标志",Bootloader启动时如果检测到状态是"搬移未完成",会选择重新搬移或者直接放弃。
另一种是"A/B双分区"方案。把Flash里划分出两个App区(A槽和B槽),当前运行在A槽,新固件直接写入B槽,写完后置位"启动标志"。下次复位后Bootloader看到启动标志,从B槽启动。如果B槽启动失败或自检不通过,自动回滚到A槽。这样全程不需要原地搬移,安全性最高,代价是Flash容量要多付出将近一倍。
对于新手,我建议从"下载暂存区 + 状态标志"做起,逻辑清晰、资源占用小,足够应付绝大多数场景。但无论选哪种方案,有几个原则是通用的:
- 所有分区的地址和大小必须对扇区边界对齐,否则擦除时会牵连到别的区。
- 升级过程中任何时候都禁止重启后出现"两个分区状态都不明确"的状态。
- 新固件写入前,务必校验固件的长度、魔数、CRC和版本号,缺一项都不允许覆盖。
2.3 配置参数区:磨损均衡与掉电保护
Data区最常见的内容是配置参数:用户设置的WiFi密码、传感器校准值、设备工作模式等。这些数据的特点是频繁更新,但每次数据量不大。而Flash的擦写寿命是有限的,NOR Flash典型擦写次数在一万次到十万次之间(具体看芯片数据手册),如果你每次都往同一个地址擦写,设备正常使用几个月就废了。
磨损均衡(Wear Leveling)的思路很简单:参数不要只存一份,而是存很多份,分散在多个扇区里,每次更新写到一个新的位置,旧的置为无效,用完一圈再整体擦除。这就好比笔记本记东西,每页写满一行,而不是在同一行反复涂改,这样整本笔记本才能用得久。
最常见的实现是"槽位轮换"(Slot Rotation)。假设配置区划了8KB,分成16个512字节的槽位,每个槽位头部有个序列号或版本号。写入时找到一个空槽写入并自增序列号;读取时扫描所有槽位,找序列号最大且CRC校验通过的那个。槽位用完后,把最新的那份拷贝到第一个槽位,然后把整块区域擦除重来一遍。
掉电保护也是Data区的重点。写入参数时如果恰好断电,Flash里可能出现半写状态——数据一半是新的,一半是旧的。解决办法有两个层级:
- 单记录级别:先写完整数据,再写"数据有效"标志。读取时如果标志不合法,就丢弃这条数据。因为标志总是放在最后一个写,所以只要标志合法,前面的数据就一定完整。
- 多记录级别:保留上一份有效记录,新记录写入成功后再把旧记录标记为失效。这样任何时刻都至少有一份完整数据可以读取。
2.4 日志区:循环覆盖的讲究
很多设备需要记录运行日志,比如故障码、开关机时间、传感器采样值。日志的特点是数据量大、价值密度低、允许覆盖旧的。日志区通常设计成环形缓冲:一个写指针,写满一个扇区就跳到下一个扇区,全部写满就从头覆盖最旧的扇区。实现时注意两个细节:每个日志条目要带时间戳和长度字段,方便解析;写指针的位置要存在Flash里,否则每次断电重启都不知道该从哪里接着写,这本身又是一次"参数存储"设计。
3. 手把手实操:基于STM32的分区落地实例
3.1 查手册,先摸清扇区资源和容量
明确一个思想:任何分区的起点必须是芯片数据手册,不能是拍脑袋。以我常用的STM32G030为例,这颗芯片自带64KB Flash,扇区结构是每页1KB,共64页,擦除粒度就是1KB。为什么强调这个?因为如果我像有些教程那样把Bootloader划成16KB、App划成48KB,这在G030上是合法的(都是1KB的整数倍),但如果你用的是F103,它的高密度版本Flash大扇区从16KB起步,你按1KB粒度设计就完全行不通。
实操第一步,打开手册找到Memory Map章节,确认三件事:
- Flash的起始地址和总容量。
- 扇区/页/块的大小分布表。
- 每块扇区的擦除次数寿命。
然后按住址从低到高,按功能划分区域。下面是一个典型的64KB Flash分区表:
| 分区名称 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader区 | 0x08000000 | 8KB | 启动代码、跳转逻辑、Flash下载驱动 |
| App区 | 0x08002000 | 44KB | 应用程序、只读数据 |
| 配置参数区 | 0x0800D000 | 4KB | 设备参数、校准值(槽位轮换) |
| 升级暂存区 | 0x0800E000 | 8KB | OTA固件下载缓冲区 |
注意看,每个分区的大小都是1KB的整数倍,和扇区对齐。配置参数区只有4KB,但配合磨损均衡,实际可用的擦写次数等于4个扇区擦写寿命的总和,而不是1个扇区的寿命。
3.2 用链接脚本把Code区钉死在Flash上
芯片的Flash布局最终要落到链接脚本(Linker Script),也就是.ld文件上。很多新手不敢动这个文件,但理解了它其实很简单。链接脚本的核心就是告诉编译器"哪段代码放哪个地址"。下面这段是G030的链接脚本精简版:
MEMORY { FLASH_BOOT (rx) : ORIGIN = 0x08000000, LENGTH = 8K FLASH_APP (rx) : ORIGIN = 0x08002000, LENGTH = 44K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 8K }Bootloader工程和App工程各自用到不同的MEMORY定义。Bootloader只认FLASH_BOOT,App只认FLASH_APP,这样编译出来的App固件,向量表和代码天然就在0x08002000这个起始地址上。
App链接脚本里还要砍掉一个默认行为:STM32的启动文件startup_xxx.s会定义两个符号_sidata、_sdata、_edata,用来把Flash里的.data初值拷贝到RAM里,并把.bss清零。如果你动了Flash起始地址,这些符号是自动跟链接脚本走的,一般不用管,但你要检查链接脚本里有没有正确生成__VECTOR_TABLE符号。App的向量表偏移设置示例:
// 设置中断向量表偏移到App起始地址 #define APP_FLASH_BASE 0x08002000U void app_jump_to_main(void) { // 检查栈顶地址是否合法 uint32_t app_sp = *(volatile uint32_t *)APP_FLASH_BASE; // 检查复位向量是否在合法范围 uint32_t app_pc = *(volatile uint32_t *)(APP_FLASH_BASE + 4U); if ((app_sp & 0x2FFE0000U) != 0x20000000U) { return; // 栈顶非法,拒绝跳转 } if ((app_pc & 0xFFFF0000U) == 0x08000000U) { // 关闭全局中断、设置MSP、跳转 __disable_irq(); SCB->VTOR = APP_FLASH_BASE; __set_MSP(app_sp); ((void (*)(void))app_pc)(); } }这段代码里有两个关键的防御性检查:栈顶地址必须落在RAM范围内,复位向量必须指向Flash的App区。这两个条件不满足就直接返回,绝不能强行跳转。我见过不少人在跳转前没做检查,结果从临时变量地址取复位向量,跳过去直接HardFault。
3.3 统一封装Flash读写接口
分区管理落到代码层面,最重要的习惯是:不要让业务代码直接调用HAL_FLASH_Program这种底层API。业务代码应该只跟"分区逻辑地址"打交道,底层的扇区换算、擦除、保护全部封装起来。接口设计如下:
typedef enum { PART_BOOT = 0, PART_APP, PART_CONFIG, PART_OTA, PART_MAX } part_id_t; typedef struct { uint32_t base_addr; uint32_t size; uint32_t sector_size; } part_info_t; static const part_info_t part_table[PART_MAX] = { { 0x08000000, 8 * 1024, 1024 }, // Bootloader { 0x08002000, 44 * 1024, 1024 }, // App { 0x0800D000, 4 * 1024, 1024 }, // Config { 0x0800E000, 8 * 1024, 1024 }, // OTA };有了这个表,上层接口就非常干净:
// 读取分区数据 int part_read(part_id_t id, uint32_t offset, uint8_t *buf, uint32_t len); // 写入分区数据(内部处理跨扇区边界) int part_write(part_id_t id, uint32_t offset, const uint8_t *buf, uint32_t len); // 擦除指定扇区数量 int part_erase_sectors(part_id_t id, uint32_t sector_index, uint32_t count);封装层的核心逻辑是边界检查。任何请求的offset + len都不能超过分区大小,否则直接返回错误码。这个简单的检查能挡住90%的越界Bug。另一个细节是part_write内部要把数据按扇区大小切片,逐段调用芯片的编程接口,遇到跨扇区的写入还要确保目标扇区事先擦除过。很多人忘了"写之前先擦",最后读出来的数据全是0xFF和半截新数据,调三天三夜发现是没擦扇区。
3.4 配置参数区的槽位轮换实现思路
配置参数区的4KB,我分成4个1KB槽位(正好一个扇区一个槽)。每个槽的头部是16字节的结构体:
typedef struct { uint32_t magic; // 魔数,固定0xA5A5A5A5 uint32_t seq; // 序列号,单调递增 uint32_t crc32; // 数据区CRC校验值 uint32_t flags; // 状态标志(有效/无效/待擦除) } slot_header_t;写参数时,流程是:
- 扫描四个槽位,找到当前序列号最大且CRC校验通过的槽,记下它的序列号。
- 把新参数打包成数据块,计算CRC后,找一个空的或标记为失效的槽位写入。
- 新槽写入成功后,把旧槽的flags标记为"失效",整个区域始终至少保留一份有效数据。
- 如果四个槽位都写满了,就把最新数据写入0号槽,然后把1到3号槽统一擦除,重新开始。
读取参数时也简单:遍历所有槽位,过滤掉magic不对、CRC不对、flags不是有效的槽,然后找序列号最大的那个返回。
这套逻辑跑在MCU上毫无压力,但对掉电保护极其有效。不管在哪一步断电,重启后扫描一遍,至少能找到上一次完整写入的数据。我实际调试的时候还故意用继电器不停地通断电源来做压力测试,跑了上千次,没有一次把参数区搞坏。
4. 分区运行阶段的常见问题与排查技巧实录
4.1 编译时报错:区域溢出和地址冲突
链接脚本里MEMORY定义好之后,最常见的错误是:
region 'FLASH_APP' overflowed by xxx bytes:App代码太大,超出了44KB分区。这不是紧急事故,但也说明你当初的分区评估太乐观,要么精简代码,要么下次布局时把App区扩大,从配置区或OTA区匀空间。undefined symbol __VECTOR_TABLE:App工程少了中断向量表定义,多半是启动文件没加进工程,或链接脚本里少了向量表符号导出。L6221E: Execution region ... has no space(Keil环境):和GCC的overflowed是一回事,改分散加载文件的执行区长度。
排查这类问题,最直接的办法是看编译生成的.map文件,搜索每个区域的占用情况,明确是哪个函数或者数据占了大头。另外,务必定期用size命令确认App镜像大小,做好版本演进的空间预算。我的习惯是分区利用率超过80%就要开始紧张了,留出20%余量给后续功能迭代。
4.2 跳转App后不进main,或一进就HardFault
这是Bootloader开发中最经典的问题。原因基本集中在三处:
- 没有设置SCB->VTOR:中断发生后CPU按默认向量表走,跳到Bootloader的向量表去了,App的中断处理函数根本找不到。
- MSP设置错误:App的栈顶指针必须在RAM范围内,如果这个值被App的启动文件正确生成,一般不会错;就怕你跳转时用了当前正在调用的栈,而不是重新加载App的栈顶。
- 优化器把跳转函数尾调用优化了:跳转函数里做了重置MSP和VTOR,但编译器为了优化把函数栈帧破坏了,跳过去后异常上下文错乱。解决方法是把跳转函数声明成
__attribute__((noreturn))并且禁止优化,或者直接内嵌汇编保证执行顺序。
我的排查思路是先看硬件异常:在HardFault_Handler里打断点,查看LR寄存器和压栈的PC值,就能知道是断在哪一次函数调用。如果PC落在Bootloader里,说明是向量表问题;如果PC落在奇怪地址,先怀疑栈顶设置。
4.3 反复擦写导致数据丢失或者读到全0xFF
出现这个现象,先不要怀疑芯片坏了,大概率是下面几个原因:
- 写入前没有擦除扇区,数据没有真正写进去。用过STM32 HAL库的朋友都知道,
HAL_FLASH_Program只是编程,不会自动擦除,你需要先调用HAL_FLASHEx_Erase。 - 掉电保护没做,写了一半断电,参数区处于半新半旧状态。按之前讲的双槽位/槽位轮换方案做,就能避免。
- 读地址越界。有些人用指针直接读Flash地址,比如
*(uint32_t *)0x0800D200,一旦偏移算错,读到的就是其他分区的数据或者空洞区(0xFFFFFFFF),表现出来就是"数据丢了"。 - 擦除超过芯片支持的次数,扇区进入坏块或寿命耗尽。正规做法是读芯片的
FLASH_SR状态寄存器,看有没有编程错误(PGAERR)或写保护错误(WRPERR)。
最后这点我特别提醒:擦写寿命问题在开发阶段根本测不出来,因为开发阶段的擦写次数远小于一万次。等产品用半年后批量出问题,再改就晚了。所以方案设计阶段就要把磨损均衡做进去,并且要估算最坏情况下的擦写频率。比如日志每10分钟写一次,一天144次,一年5万多次,一个1KB扇区根本扛不住,至少得上环形轮换。
4.4 OTA升级后设备变砖,怎么救回来
这个问题是所有分区设计的终极考验。如果你做了冷静的Bootloader设计,这题一点都不难。救砖的核心思想是:Bootloader区永远不被App升级覆盖,它是安全的。
标准流程是:设备上电进Bootloader,检查"升级标志"。如果有完整的新固件在OTA暂存区且校验通过,就执行搬移覆盖App区,完成后清除升级标志再重启;如果没有升级标志,就直接跳转App。那么升级失败的情况,无非几种:
- OTA数据没下载完:Bootloader发现暂存区数据不完整,直接忽略,跳旧App,设备照常跑。
- 搬移过程中断电:App区被擦了但没有写完,下次Bootloader检测到App区数据CRC不对,但OTA暂存区还有完整固件,就继续搬移,等于把升级补完了。
- 新App启动自检失败:Bootloader跳转后App能跑,但跑起来自检发现硬件异常,可以在App里主动跳回Bootloader并请求回滚到旧固件(前提是A/B分区,或者OTA暂存区里还留着之前那版旧固件)。
所以,给Bootloader留足空间和状态标志位,是救砖的关键。千万别把"硬件复位只跳一次App"当成不可变的铁律,真正可靠的产品都是带着一套状态机和多级校验的。
5. 从实战里提炼的几条分区管理经验
5.1 地址对齐是分区管理的底线
这条我再强调一遍:所有分区的起始地址、大小,必须是你所用芯片的扇区大小的整数倍。否则当你擦除一个扇区时,会把隔壁分区的数据一并抹掉。检查方法很简单,定义一个编译期断言:
STATIC_ASSERT(PART_APP_BASE % SECTOR_SIZE == 0, "App base must align"); STATIC_ASSERT(PART_OTA_BASE % SECTOR_SIZE == 0, "OTA base must align");编译不过,说明你的分区表设计有问题,别硬跑。
5.2 写Flash期间关闭中断和看门狗
Flash编程操作是需要时间的,特别擦除一个4KB扇区可能要几十毫秒甚至上百毫秒。在这期间:
- 中断服务函数里如果也有Flash操作,会导致重入,轻则互相覆盖,重则触发Flash编程错误。
- 看门狗如果不喂,长时间擦除会直接触发复位,而复位发生在擦除中间,数据就毁了。
标准做法是在擦除和写入之前__disable_irq(),并且如果是最后一包数据,擦写完成后立刻喂狗。但注意,关中断时间不能太长,如果Flash很大,可以在每个扇区擦除结束后短暂开中断,让系统喘口气。
5.3 给每个分区设计明确的"身份标识"
我另外一个习惯是:在每个分区的开头放一个固定格式的头部,包含魔数、版本号、镜像长度、CRC。不管是Bootloader跳App、OTA搬移还是配置区读取,第一件事都是校验这个头。魔数的作用是和随机数据区分开,CRC的作用是校验数据完整性。这个习惯帮我避免过无数个"看起来正常但其实是旧数据残留"的诡异问题。
5.4 分区管理一定要有调试手段
最后分享一个小技巧,也是我后来专门补的一课:做Flash分区的人,一定要给自己留一个调试后门。比如在Bootloader里埋一个串口交互命令,可以查看各分区的头部信息、擦除指定区、跳转指定区。遇到设备变砖时,不用每次都用烧录器救,串口命令就能把现场状态dump出来,排查效率提升一个档次。
我个人在实际项目中的体会是,FLASH分区管理不是那种能靠背几个API解决的事,它是对Flash物理特性的理解、对产品可靠性要求的权衡、对启动和升级流程的深思熟虑。新手阶段把这个模块练扎实了,后面做OTA、做量产固件管理都会顺很多。如果你正在设计自己的第一块分区表,建议拿一块开发板,从Bootloader+App+参数区三个分区开始,一步一步把跳转、读写、掉电保护都跑通,这个功夫花得绝对值。