资讯详情

资讯详情

FLASH分区管理全拆解:从物理特性到OTA实战,避坑指南

做了这么多年嵌入式开发我见过太多新手项目栽在FLASH分区管理这个坎上。不少朋友拿着开发板把整颗Flash当成一块大数组随便用编译出来能跑就算完事结果一上OTA就翻车一断电配置就丢甚至Bootloader和App互相踩踏板子直接变砖。FLASH分区管理这件事核心就两件事Code区和Data区怎么划以及划分之后怎么管。这篇文章我把里里外外全部拆开讲从Flash的物理特性讲到链接脚本从OTA双分区讲到磨损均衡全程带实操代码和避坑经验新手照着做就能少走半年弯路。1. FLASH分区的本质先看清手里的Flash到底是什么1.1 NOR Flash的三个基础操作决定了分区的玩法嵌入式里绝大多数MCUSTM32、GD32、ESP32这些用的是NOR Flash不是电脑里的SSD那种NAND。NOR Flash的物理特性决定了你能对它做什么以及做这些操作的代价有多大。理解不了这一点后面所有分区设计都是空中楼阁。第一个操作是读Read。Flash支持随机读芯片可以像从内存里取指令一样直接读Flash里的代码来执行这叫XIPExecute 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分开分区因为要解决固件升级失败后设备还能不能活的问题。如果没有BootloaderApp里的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区0x080000008KB启动代码、跳转逻辑、Flash下载驱动App区0x0800200044KB应用程序、只读数据配置参数区0x0800D0004KB设备参数、校准值槽位轮换升级暂存区0x0800E0008KBOTA固件下载缓冲区注意看每个分区的大小都是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_BOOTApp只认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 bytesApp代码太大超出了44KB分区。这不是紧急事故但也说明你当初的分区评估太乐观要么精简代码要么下次布局时把App区扩大从配置区或OTA区匀空间。undefined symbol __VECTOR_TABLEApp工程少了中断向量表定义多半是启动文件没加进工程或链接脚本里少了向量表符号导出。L6221E: Execution region ... has no spaceKeil环境和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、做量产固件管理都会顺很多。如果你正在设计自己的第一块分区表建议拿一块开发板从BootloaderApp参数区三个分区开始一步一步把跳转、读写、掉电保护都跑通这个功夫花得绝对值。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →