ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式Flash分区管理实战:从Code/Data分离到OTA与数据可靠存储

嵌入式Flash分区管理实战:从Code/Data分离到OTA与数据可靠存储 1. 为什么FLASH分区管理是新手躲不开的门槛1.1 标题里的Code和Data究竟是什么先解开标题里的两个关键词。嵌入式开发里经常说“Code and Data”Code就是程序代码也就是编译后生成的机器指令跑起来只读、不修改Data这里不是指内存里的变量而是存放在Flash里的数据内容比如设备序列号、校准参数、用户配置、日志记录、OTA升级包之类需要掉电保存的东西。很多新手写单片机程序用的芯片内部Flash只有64KB、128KB甚至1MB总觉得空间不够用或者觉得反正Flash那么大程序放前面、数据随便找个地址写就行。这种想法在简单DEMO阶段没问题但一旦产品要量产、要OTA升级、要保存参数问题就全冒出来了程序莫名跑飞、参数写几次就丢、升级一次变砖、Flash提前写坏。到最后排查下来八成都是分区没规划好。FLASH分区管理说白了就是回答三个问题代码放哪、数据放哪、两者怎么互不干扰。这个能力是嵌入式从“能点灯”到“能交付产品”之间必须精进的一步。这篇内容不绕弯子从原理到实操把我这些年踩过的坑和验证过的方案全部掰开讲。1.2 不分区的后果一次真实的“翻车”经历有次做一款物联网设备主控用的芯片内部Flash 256KB程序大约80KB我当时想多出来的空间随便存个校准数据绰绰有余。于是直接在代码里写了个固定地址0x08020000对应偏移128KB处把校准参数写进去。刚开始一切正常。后来产品要支持OTA升级升级固件比原来大了30KBBootloader把新固件往Flash里写的时候正好覆盖到了我存参数的那个区域。升级完成、设备重启程序新版本倒是跑起来了但所有设备的校准数据全部清零用户设备精度全偏售后炸了。后来排插才发现我写参数时根本没看Flash整体映射也没考虑升级程序会占用哪个区域。问题的根源不是某一个Bug而是整个Flash空间没有明确的管理方案哪个范围是代码区、哪个范围是数据区、代码区的边界会不会变化、数据区能不能被误操作覆盖全部靠“感觉”。那次之后我把分区管理彻底重做划分独立的参数存储区在链接脚本里约束代码最大范围数据区放在Flash尾部专门划分的扇区升级程序之前先检查目标地址是否落在数据区。这套方案沿用至今再没出过同类事故。所以开篇先把这个故事放出来是想让大家明白分区管理不是学术概念是每个做产品的人都要面对的工程底线。2. 分区规划的核心思路与设计原则2.1 分区设计要回答的五个问题无论你用哪家芯片做分区规划前都要先理清五个问题第一整个Flash容量有多大最小擦除单位是什么。这个数据在芯片手册里一定有比如STM32F103系列一页是1KB或2KB擦除按页ESP32的Flash擦除扇区大小通常是4KBNXP的部分芯片按扇区8KB或32KB。最小擦除单位直接决定了分区粒度你不可能做到按字节擦除所以分区边界最好对齐到擦除单位否则会出现跨扇区操作的麻烦。我见过有人非要在1KB页的芯片上做一个512字节的独立分区最后不得不忍受每次修改都擦掉两页的浪费逻辑复杂还容易出错。第二代码区最大可能有多大。代码区的大小不能以当前程序体积为准要以“未来两三年内可能的增长上限”为准。产品生命周期里功能只会越来越多OTA升级包通常比当前固件大预留不足就意味着后期要重做分区而重做分区往往涉及Bootloader和升级策略大改代价极大。第三数据区需要多少空间、有哪些类型的数据。参数、日志、升级暂存三个场景对空间和可靠性的要求完全不一样。设备参数一般几KB就够但需要极高的写入可靠性日志数据可能几天就写满一圈需要磨损均衡OTA暂存区需要一个完整的固件包空间可能几十KB甚至数百KB。把这三类数据混在一个区域后面维护会非常痛苦。第四是否需要OTA升级。有OTA就必然有BootloaderBootloader本身也要占用一段代码区而且还需要考虑是原地升级还是A/B双备份原地升级需要预留一个下载暂存区A/B升级则需要两个完整的App区。这是分区方案里影响最大的决策项。第五芯片是否支持运行时修改自身Flash。有些芯片特别是内部Flash很小或者某些专用型号不允许程序在运行中擦写自己的代码区只允许通过专用接口烧录。这种芯片如果要做参数存储必须外挂EEPROM或SPI Flash。这个约束在设计初期就要确认否则方案做了一半发现硬件不支持非常被动。把这五个问题列成一张表每个分区方案在设计之初就对照填写一遍基本能把大部分隐患排除掉。很多新手一上来就翻手册找地址跳过需求直接画布局往往做完一遍又推翻效率很低。2.2 一个可落地的典型布局以一颗常见的512KB内部Flash芯片为例分区布局通常长这样地址范围偏移大小用途说明0x00000 - 0x0FFFF64KBBootloader启动引导、OTA接收入口0x10000 - 0x3FFFF192KBApp代码区应用程序主体可升级0x40000 - 0x43FFF16KB参数存储区设备配置、校准数据双份备份0x44000 - 0x47FFF16KB日志/掉电记录区环形写入按页擦除0x48000 - 0x7BFFF208KBOTA下载暂存区存放升级包原始数据0x7C000 - 0x7FFFF16KB系统状态区升级标志、启动计数、恢复标记注意几个细节第一Bootloader、App、数据区的边界全都在4KB对齐的位置这是为了兼容大多数Flash扇区擦除大小也让地址判断更简单。第二参数区留了16KB但实际参数可能只需要4KB多出来的空间用于双份冗余存储和未来字段扩展。双份的意思是把参数块A存在前半区、参数块B存在后半区写的时候轮流写读的时候先校验CRCA坏了读BB也坏了才恢复默认值。这样大幅度降低单次写入失败导致参数清空的风险。第三OTA暂存区放在了Flash尾部原因是它最大、最灵活而且不参与启动过程即使写了一半坏掉也不会影响系统启动。App区的更新逻辑通常是Bootloader把暂存区的数据搬到App区或者App直接从暂存区校验后跳转具体看方案设计。第四系统状态区很小但非常重要它记录“本次启动是否需要进入升级模式”。写这个标志位之前必须先确保整个数据链路可靠否则一旦升级中途掉电重启后可能进不了Bootloader设备直接变砖。这套布局不一定适合所有项目但它的设计逻辑是通用的启动引导独立、应用代码独立、易变数据和易丢失数据各自独立、大块临时数据放最尾部。按照这个思路去映射你自己的芯片容量基本不会出大错。2.3 对齐、容量与寿命三个绕不开的数学题分区规划说起来是画格子实际上有三个数字必须心里有数。对齐计算。假设芯片Flash总容量是1MB0x100000字节最小擦除扇区是4KB0x1000字节。那么所有分区的起始地址都应该是0x1000的整数倍。这么做的好处有两点第一任何分区都在完整的扇区内不存在“这个扇区一半属于代码区、一半属于数据区”的情况否则擦除数据区扇区时会误伤代码区第二地址判断可以用位运算简化比如判断地址是否为4KB对齐直接addr 0xFFF 0即可不用做除法取模。代码和数据的地址计算方式要统一。在代码里定义地址宏时我习惯用“基地址 偏移”的方式基地址由链接脚本或平台头文件统一定义偏移量对应分区表格里的行。这样以后调分区只改一处宏不会出现某个模块硬编码地址忘改的情况。容量预算。App代码区分配多少最简单的办法是当前代码体积乘以1.5再向上取整到扇区整数倍。比如当前编译出来是96KB乘以1.5得144KB向上取整到4KB的整数倍就是144KB如果保险起见就是160KB。注意OTA升级包如果用压缩算法体积可能小于实际代码占用但分区大小要以解压后实际写入的大小为准不能拿压缩包体积来算。我见过有人按压缩后体积预留App区结果解压后装不下升级的时候才发现非常狼狈。寿命估算。内置Flash的擦写寿命一般在1万次到10万次之间数据手册有明确值比如“Erase/Suspend cycles min 10,000”。假设你的设备每天校准一次参数按双份参数区磨损均衡的设计每写一次只磨损一个扇区两个扇区轮流写每天平均磨损0.5次那么16KB的参数区假设分成两个4KB扇区理论上可以用1万天约27年。但如果设计成每次都擦同一个扇区同样1万次寿命27天就写坏了。这个差别就是分区和磨损均衡的价值。日志区的寿命算法也类似假设日志区16KB分4个扇区每个扇区4KB记录一条日志写入256字节一个扇区约16条日志写满就要擦除4个扇区轮流写擦写1万次总共可写64万条日志。如果日志量是每天100条可以用17年左右。这些计算要在设计阶段就估算好不要在量产几个月后才发现Flash寿命不够。3. 实操从零搭建Code和Data分区3.1 基于链接脚本的代码区划分代码区到底从哪里开始、到哪里结束这件事不是写在C代码里而是通过链接脚本Linker Script来定义的。以常见的ARM GCC工具链为例链接脚本.ld文件中有类似下面这样的关键段落MEMORY { FLASH (rx) : ORIGIN 0x08010000, LENGTH 192K RAM (rwx) : ORIGIN 0x20000000, LENGTH 64K } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) _etext .; } FLASH .data : { _sdata .; *(.data*) _edata .; } RAM AT FLASH }这段脚本把Flash的起始地址设定为0x08010000即偏移64KB处Bootloader占用0x08000000到0x0800FFFF长度为192KB。链接时编译器生成的所有代码和只读数据都会被放在这个范围内。关键点在于链接脚本和分区设计表必须一一对应。App分区起点是0x08010000对应上表里App代码区的起始地址长度192KB对应App代码区的容量。链接脚本一旦写错比如起始地址错位生成的固件烧进去大概率直接跑飞或者覆盖到Bootloader区域。Keil环境下对应的概念叫分散加载文件.sct写法类似LR_IROM1 0x08010000 0x00030000 { ER_IROM1 0x08010000 0x00030000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }无论用哪种IDE检查App代码区划分是否成功的方法都是一样的编译后查看生成的map文件确认首个段的地址是否等于App区起始地址最后一个代码段的结束地址是否小于App区结束地址有没有任何段落到FLASH范围之外。我之前遇到过一种情况链接脚本明明把FLASH范围设置为192KB但编译时有个大数组直接写到Flash末尾之外GCC在链接阶段会直接报错而Keil的某些版本只给Warning导致生成的固件实际非法。所以养成看map文件的习惯比什么都重要。3.2 基于Flash驱动的Data区读写封装Data区的读写不能像访问内存那样直接赋值必须通过芯片的Flash操作接口。这里给出一套基于HAL库的典型封装思路可用在任何有标准Flash驱动库的芯片上#define DATA_PARTITION_BASE 0x08040000 /* 参数存储区起始地址 */ #define DATA_PARTITION_SIZE 0x00004000 /* 参数存储区大小16KB */ #define PARAM_BLOCK_A_ADDR (DATA_PARTITION_BASE) #define PARAM_BLOCK_B_ADDR (DATA_PARTITION_BASE 0x2000) #define PARAM_BLOCK_SIZE 0x2000 /* 单个参数块 8KB实际有效数据4KB */ /* 擦除一个扇区 */ static int flash_erase_sector(uint32_t addr) { FLASH_EraseInitTypeDef erase_cfg; uint32_t sector_error 0; erase_cfg.TypeErase FLASH_TYPEERASE_SECTORS; erase_cfg.Sector get_sector_num(addr); erase_cfg.NbSectors 1; erase_cfg.VoltageRange FLASH_VOLTAGE_RANGE_3; if (HAL_FLASHEx_Erase(erase_cfg, sector_error) ! HAL_OK) { return -1; } return 0; } /* 写参数 */ int param_save(const param_t *param) { uint32_t target_addr; uint32_t crc_val; uint8_t *p; crc_val crc32((const uint8_t *)param, sizeof(param_t)); /* 先写入B块成功后切换标识再考虑是否清理A块 */ target_addr PARAM_BLOCK_B_ADDR; HAL_FLASH_Unlock(); /* 按32位字写入保证对齐 */ p (uint8_t *)param_snapshot; memcpy(param_snapshot, param, sizeof(param_t)); param_snapshot.crc crc_val; flash_erase_sector(target_addr); for (uint32_t i 0; i sizeof(param_t); i 4) { uint32_t word; memcpy(word, p i, 4); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, target_addr i, word) ! HAL_OK) { HAL_FLASH_Lock(); return -1; } } HAL_FLASH_Lock(); return 0; }这里有几个细节值得强调第一写Flash之前必须先擦除而且擦除单位是扇区。你不能只改参数里一个字节就只擦一个字节Flash没有这么细粒度的操作。第二写操作要按芯片要求的对齐方式。很多芯片要求至少按32位字写如果你传一个字节数组进去驱动层会报错或者校验失败。所以上面的代码里用了一个param_snapshot中转变量确保内存对齐再以4字节为单位逐字写入。第三写参数区域之前必须保证目标地址属于Data区不能是代码区。这个判断是一个必要的安全检查if (target_addr DATA_PARTITION_BASE || target_addr sizeof(param_t) DATA_PARTITION_BASE DATA_PARTITION_SIZE) { return -2; /* 地址越界保护 */ }有人觉得多此一举但实际工作中计划外的代码改动比如有人把代码区扩大、把Data区顶上去很容易让这类地址宏悄悄失效。有了运行时边界检查出问题时至少能在日志里定位到原因。3.3 Bootloader与App之间的分区约定Bootloader和App之间必须有一套统一的地址约定否则升级过程根本没法协同。我强烈建议把分区信息定义成一个独立的公共头文件Bootloader和App工程都引用同一份定义。/* partition_map.h —— 两个工程共享 */ #define PART_BOOT_ADDR 0x08000000 #define PART_BOOT_SIZE 0x00010000 #define PART_APP_ADDR 0x08010000 #define PART_APP_SIZE 0x00030000 #define PART_APP_MAX_SIZE 0x00030000 #define PART_PARAM_ADDR 0x08040000 #define PART_PARAM_SIZE 0x00004000 #define PART_LOG_ADDR 0x08044000 #define PART_LOG_SIZE 0x00004000 #define PART_OTA_ADDR 0x08048000 #define PART_OTA_SIZE 0x00034000 #define PART_STATUS_ADDR 0x0807C000 #define PART_STATUS_SIZE 0x00004000Bootloader启动时做三件事顺序不能乱读系统状态区里的升级标志。如果标志是“需要升级”则进入升级流程从OTA暂存区读取固件校验CRC写入App区完成后清标志跳转到App如果标志是“正常启动”则直接跳转App。跳转前校验App区的有效性。最简单的校验是看栈顶地址是否在RAM范围内以及复位向量是否为合法的Thumb指令地址。这个校验能挡住很大一部分“升级一半断电”造成的变砖。如果App校验失败Bootloader应该停留在原地等待重新下载固件而不是盲目跳转。App侧的工作也很关键。当App收到升级指令后它不能立刻把自己正在运行的区域覆盖掉而是先把升级包写入OTA暂存区写完后设置系统状态区标志再软复位。Bootloader在复位后接管后续动作。这种“App只写暂存区Bootloader负责搬运”的模式天然降低了覆盖正在运行代码的风险。分区约定的核心是两个程序对地址的理解完全一致。任何一边改了地址宏另一边就必须同步修改否则升级时Bootloader可能把固件写到参数区、或者App跳转地址算错。这也是为什么共享头文件比在每个工程里各自定义一份重要得多——前者从物理上避免了两边不一致的可能。4. 常见问题与排查技巧实录4.1 程序跑飞、启动异常的排查清单分区管理不当导致的“诡异问题”很多都有规律。我总结了一份排查清单遇到程序跑飞、启动异常时按顺序过一遍现象优先怀疑方向排查方法上电直接进HardFault栈顶地址不对、链接脚本起始地址错偏查看map文件中首个段地址是否等于分区设定起点用调试器读MSP初始值程序能跑但某些功能异常代码区与数据区重叠运行中被擦除/改写对比链接脚本Flash范围与分区表检查是否有模块直接操作Flash地址OTA升级后无法启动App区覆盖不完整、Bootloader校验太弱检查升级包CRC、App区是否有合法的复位向量在Bootloader里加跳转前合法校验参数区写完之后程序跑飞写入地址越界到代码区在Flash写入函数里增加分区边界检查输出日志定位触发地址其中“程序跑飞但调试器显示PC指针在一个Flash地址”的情况十有八九是代码区某个字节被改写成了非法指令。这类问题最难查因为现场往往已经过去了。所以我后来在所有涉及Flash写入的地方都强制加了边界检查宏宁可多写几行代码也不要让一次越界写入把整个系统干掉。4.2 数据写坏后如何自救Data区写坏的情况分两种一种是写入过程中掉电参数块写了一半另一种是Flash本身损坏超出擦写寿命或坏块。针对掉电写坏双份备份方案是最有效的。原理很简单任何时候都存在一份完整有效的参数副本。写入逻辑要做成“先写备份块确认成功后再更新主块”而不是图省事总是写同一个地址。读的时候先读主块校验CRC失败就自动读备份块两个都失败才恢复默认。针对Flash寿命耗尽只能靠提前检测。比较务实的做法是每次擦写前先读一下该扇区的擦写计数如果驱动支持或者每隔一段时间统计擦写次数在接近寿命极限比如达到标称值80%时上报告警。对于量产设备日志区还可以记录Flash错误事件方便事后分析。还有一个容易忽略的点不要把重要的唯一性参数放在日志区同一个扇区里循环写。日志区的特性就是写满就擦如果把设备唯一ID放进去某次日志轮转可能把ID抹掉。这种低级错误我见过不止一次设计分区时就要明确“永久数据区”和“循环数据区”两者物理隔离。4.3 掉电丢失与写入原子性嵌入式系统掉电是常态。掉电最危险的窗口发生在“擦除完成、但新数据还没写进去”的瞬间此时该扇区全0xFF旧数据已经不存在了。要命的是这个窗口虽然短但一旦掉电结果就是该扇区数据彻底丢失。如果设计成参数块A和B轮流写且永远是先完整写完一个块再擦另一个块那么在掉电瞬间最坏的情况也只是丢失正在写的那块另一块还是好的。下次上电时读备份块即可恢复。另外写入流程里有一个细节先写入CRC值再写入业务数据还是先写入业务数据、再写入CRC很多教程不讲究这个顺序但实际上应按以下流程在内存中组装完整的参数结构体计算CRC填充到结构体尾部字段一次性按顺序写入Flash。关键是读取判断时如果CRC校验失败整块数据视为无效。而写入时如果中途掉电Flash里残留的数据大概率CRC校验不过自然被自动丢弃。顺序本身不是核心核心是整个块在单次写入周期内是完整可校验的。4.4 排查工具和调试建议排查分区问题最有效的工具其实不是什么高端仪器而是调试器查看Flash内存窗口。把要检查的地址输入内存窗口直接看该区域的内容是不是预期的数据模式代码区应该能看到指令字节流开头是栈顶地址和复位向量参数区如果全部是0xFF说明被擦除了还没写入或者从未写入过参数区如果出现大量0x00、乱码或者部分正确部分0xFF多半是写入中断或地址越界造成的。还有一个小技巧在Bootloader和App的入口处各放一个版本号常量把它们编译到不同的固定地址升级后读这两个地址就能快速确认Bootloader和App版本是否匹配、有没有出现跑飞导致跳到错误段的情况。我自己的项目中还会在系统状态区记录“上次启动原因”上电正常启动、软件复位、看门狗复位、升级后启动。通过这个字段能快速判断设备是升级后起不来还是运行中被看门狗反复复位。这个信息对远程定位问题特别有价值。5. 最后一层心得分区管理是一种工程习惯分区规划看着是技术活实际上更像一种工程习惯。我在几个不同项目里验证过凡是分区管理做得清晰的固件后续加功能、做升级、修bug都相对顺凡是分区稀里糊涂的项目越改越乱最后不得不重构。这套习惯总结下来就几句话画一张总容量表确定Bootloader、App、参数、日志、OTA的边界把边界写进一个共享头文件代码和链接脚本都从那一个源里取数所有Flash写操作都带边界检查和CRC校验Bootloader永远保留一份能救命的跳转前校验逻辑。还有一个永远不会过时的小建议给每个分区写清楚注释注释里带上日期和当时的设计考虑。半年后你自己回来看代码会庆幸当初留下了这些记录。分区管理这种事最怕的不是方案不够先进而是方案在一个人脑子里别人不敢动、自己也想不起来当初为什么这么切。我翻了之前的项目笔记发现当年那次参数被OTA覆盖的事故其实有很多前置信号可以提前发现比如编译出来的map文件里代码区已经悄悄超过了预留范围、升级包体积比预期增长明显。只是当时没有养成看map、对分区的习惯。把这件事写出来就是希望正在入门的朋友不要用一次生产事故去换这个教训。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进