
前阵子在一个OTA升级项目里被Flash自检的边界问题折腾得够呛。BootLoader要校验App区的完整性CRC算法本身没什么难度网上代码一抓一大把真正让人头疼的是“从哪开始、到哪结束”校验范围的起点终点怎么定义CRC值又该存在哪个位置最开始大家习惯用宏定义或者直接写死0x08000000这种地址结果就是Boot和App两边各维护一份地址清单改一次分区就要全工程翻一遍稍不留神就漏改。后来翻到一份LAT1471应用笔记核心思路很简单这些边界地址本来就在链接脚本里写得明明白白为什么不直接在链接脚本里定义成变量让C代码自动引用呢今天就把这个思路展开聊聊——Flash全片自检过程中巧用Linker自定义变量到底能解决什么问题具体怎么写以及我在实际项目里踩过的那些坑。这个方案特别适合正在做BootLoader、OTA升级、固件完整性校验或者功能安全相关需求的嵌入式工程师。无论你用STM32CubeIDE、Keil MDK还是IAR都有对应的写法。读完你会明白真正靠谱的Flash自检地址边界不应该是人肉维护的而是应该让链接器把最准确的值直接交到代码手里。1. 先把场景讲清楚Flash全片自检到底在查什么1.1 校验的本质不只是算一个CRCFlash自检本身是一个很朴素的需求确认当前Flash里的固件镜像和出厂时烧录的内容完全一致。实际场景通常有三种上电自检设备启动时先做一次完整性检查不通过就拒绝进入正常运行流程。OTA升级后的校验从网络或外部存储下载了新固件写进Flash之后要先验证再跳转防止跑到一半的程序变成砖。运行中后台巡检周期性或者随机抽检用于捕获偶发的Flash内容异常比如坏块导致的读取错误。不管哪种场景本质上都是“对一块指定范围内的Flash数据算出一个特征值和预先存储的期望值比对”。这里的核心词不是“算法”而是“指定范围”。边界选错了CRC算法再正确也没用。很多工程师会把“全片自检”理解成“把整个Flash从地址0到最大地址全部扫描一遍”。这个理解在实际量产中不完整。芯片的Flash并不是所有区域都允许普通用户态访问比如部分芯片有独立的System Memory、选项字节区、安全保护区另外未编程的区域通常保持0xFF但假如上一版固件留下的数据不是0xFF而烧录工具又不会每次都全片擦除那么对整颗芯片做校验会得到完全不可预期的结果。所以实际项目中更严谨的理解是对“当前固件实际占用的用户Flash区域”做完整性检查。这个区域的范围并不是随便划的它取决于链接器怎么布局代码段、只读数据段、CRC存储段。这时候链接脚本里的信息就是个金矿而Linker自定义变量就是那把铲子。1.2 边界问题第一个坑举个例子一颗Cortex-M0芯片Flash从0x08000000开始总共512KB。BootLoader占了前32KBApp区占了后面的某个固定区间再往后可能还有一小块用户参数存储区。这个参数区通常不能在CRC校验范围内因为它的内容运行时会被改写。用宏硬编码的写法大概长这样#define APP_START_ADDR 0x08008000 #define APP_END_ADDR 0x0807F000 #define CRC_STORE_ADDR 0x0807F800第一眼看没什么问题但时间长了就有意思了BootLoader里维护了一份App工程里又维护了一份如果还有工厂测试固件还要再维护一份。等到某一天调整了App分区大小Boot里的宏忘改了线上设备OTA之后自检一直报错你就得在凌晨两点对着这两个地址发愁。硬编码地址的本质问题在于信息源不唯一。Flash的布局实际是由链接器脚本决定的而代码里却用一套人肉维护的宏去描述这个布局两边必然会产生漂移。Linker自定义变量做的就是把这个信息源统一起来链接脚本说Flash从哪里开始、到哪里结束、CRC段在哪C代码就从链接器符号里取这些值不再自己猜测。1.3 三种常见做法对比把常见方案放在一起看就很直观了。方案优点缺点适用场景宏定义/硬编码地址直观新手也能改信息源不唯一布局调整时容易漏改快速原型项目生命周期很短运行时通过固定向量表或栈顶推算少一个头文件可读性差依赖特定启动方式边界不透明极简BootLoader不想动链接脚本链接脚本自定义变量边界由链接器生成永远与实际布局一致需要对链接脚本有基本认识Boot/App分离、OTA、功能安全项目第三个方案就是LAT1471笔记里强调的思路。它不改变链接脚本对Flash布局的约束力只是把布局的关键参数“暴露”给C语言使用。你不需要魔法数字也不需要额外的配置头文件链接器天然就是最权威的信息来源。2. Linker自定义变量原理和C语言使用要点2.1 链接脚本里的变量和C变量根本不是一回事理解这个技巧之前得先搞清楚一个关键概念链接脚本里定义的符号和C语言里的变量是完全不同的东西。在编译器眼里C语言变量是在“某个段里占一块空间”的实体它有地址也有存储空间。比如定义一个uint32_t flash_start 0x08000000;编译器会在Flash或者RAM里分配4个字节把这4字节内容初始化为0x08000000同时生成一个符号flash_start指向这块内存的起始地址。而链接脚本里的写法是这样的__FLASH_START ORIGIN(FLASH);这条赋值语句不会生成任何存储空间。它只是向最终ELF文件的符号表里塞了一个“绝对符号”这个符号的值就是ORIGIN(FLASH)也就是0x08000000。这个符号“本身就是地址”它不指向某个存着地址的变量它就等于那个地址值。可以打个比方C语言变量是一个箱子里面放着地址你打开箱子才能拿到地址链接器符号是一张贴在门上的门牌号门牌号本身就是地址不需要再打开任何箱子。很多刚接触这个技巧的同事会栽在同一个地方在C语言里写extern uint32_t __FLASH_START;然后直接访问__FLASH_START这个变量名试图拿到地址值。结果发现拿到的不是0x08000000而是Flash地址0x08000000处的前4个字节数据。在Cortex-M平台上那通常是初始栈指针值往往是0x20020000这种RAM地址。看着不报错结果却完全不对。2.2 C语言里的正确声明方式有两种靠谱的声明方式任选其一。第一种声明成变量然后取地址extern uint32_t __FLASH_START; uint32_t addr (uint32_t)__FLASH_START;这里要绕一个弯因为链接器符号没有存储空间你不能把它当成普通变量去“取值”但你仍然可以取它的“地址”。而链接器符号的“地址”就是符号本身的值。所以__FLASH_START得到的就是0x08000000把它强转成uint32_t就是地址值。第二种声明成数组直接拿数组名当地址extern char __FLASH_START[]; uint32_t addr (uint32_t)__FLASH_START;这种方法我个人更推荐因为从语义上就杜绝了“取链接器符号的值”这个错误。数组名在表达式里会退化为首元素地址而链接器符号本身没有存储空间所以这个“首元素地址”恰好就是链接器符号的值。再说了拿char[]声明也暗示了它不占存储空间、只代表一个地址的事实。错误示范必须单独拎出来看一眼extern uint32_t __FLASH_START; uint32_t addr (uint32_t)__FLASH_START; // 错误读的是地址0x08000000处的数据这种写法编译能过但运行时拿到的数据完全取决于Flash里那个地址放了什么排查起来特别迷惑。2.3 三大主流工具链的等价写法GNU ld的写法最直白但很多嵌入式项目用的是Keil MDK或者IAR所以每种工具链都值得列一下。工具链链接脚本形式C代码中的取法注意事项GNU ldSTM32CubeIDE、GCC ARM链接脚本中直接写__FLASH_START ORIGIN(FLASH);extern char __FLASH_START[];然后(uint32_t)__FLASH_START符号名任意但要避免和C变量重名Keil MDKARMCC/ARMClang分散加载文件.sct中定义执行域或显式写FLASH_START 0x08000000使用自动生成的Image$$ER_FLASH$$Base等符号取地址执行域名要和分散加载文件中一致建议从map文件里确认准确拼写IAR.icf文件中用define symbol __FLASH_START 0x08000000;extern uint32_t __FLASH_START;取地址或用__section_begin取地址方式可以但别直接访问“值”Keil MDK如果使用分散加载文件最常见的是直接用Image$$符号。比如一个简单工程里执行域叫ER_FLASH那么链接器会自动生成extern uint32_t Image$$ER_FLASH$$Base; extern uint32_t Image$$ER_FLASH$$Length;用的时候这样拿uint32_t flash_start (uint32_t)Image$$ER_FLASH$$Base; uint32_t flash_size (uint32_t)Image$$ER_FLASH$$Length;注意Image$$ER_FLASH$$Base里的大小写和$$分隔符完全不能写错。写错一点链接器就报undefined symbol。最可靠的办法是编译完之后打开map文件直接搜Image$$ER_FLASH看到什么就抄什么。IAR的写法相对自由一些。如果只想要一个常量地址可以在.icf里定义define symbol __FLASH_START 0x08000000;然后C代码extern uint32_t __FLASH_START; uint32_t flash_start (uint32_t)__FLASH_START;如果要用段地址IAR也有类似__section_begin(FLASH_CRC)的拓展这跟GNU ld里在SECTIONS内定义符号是一个思路。三种工具链的实现路径不太一样但思想完全一致链接脚本是Flash布局的唯一事实来源C代码通过链接器符号来获取关键地址。3. 核心实操在链接脚本里布置自检边界和CRC存储区3.1 设计目标与LAT1471场景还原我在实际项目里的需求是这样的MCU拥有512KB Flash需要区分Boot区和App区Boot在每次上电时校验App区完整性App区末尾单独保留一个4KB扇区存放CRC值、版本号、固件长度等元数据。元数据区不能参与CRC计算因为CRC值本身要存在里面。如果不用Linker自定义变量这个需求会产生一个很别扭的问题Boot需要知道App区在哪App也需要知道自己区有多大更麻烦的是CRC存储区地址要固定下来两边各维护一份地址就会重复造轮子。用链接脚本做这件事思路就清晰了在链接脚本里把Flash的起始地址、结束地址、当前固件实际占用的结束地址全部定义成符号同时为CRC元数据单独设置一个section并把这个section的起止地址也暴露出来。C代码只负责消费这些符号不负责记忆地址。3.2 链接脚本定义Flash边界与CRC段下面是一份GNU ld链接脚本的关键片段基于STM32G0B1这种Cortex-M0芯片512KB Flash/* memory regions */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 144K } /* Linker custom variables */ __FLASH_START ORIGIN(FLASH); __FLASH_END ORIGIN(FLASH) LENGTH(FLASH); __FLASH_SIZE LENGTH(FLASH); SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) . ALIGN(4); } FLASH .flash_crc : { . ALIGN(4); __CRC_SECTION_START__ .; KEEP(*(.flash_crc)) __CRC_SECTION_END__ .; } FLASH . ALIGN(4); __FLASH_USED_END__ .; .data : { __DATA_START__ .; *(.data*) __DATA_END__ .; } RAM AT FLASH .bss : { __BSS_START__ .; *(.bss*) __BSS_END__ .; } RAM /DISCARD/ : { *(.comment) } }这段脚本里几个符号的意思要分清楚。__FLASH_START是Flash物理起始地址由ORIGIN(FLASH)直接得出。__FLASH_END是Flash物理结束地址或者更准确说是“最后一个字节之后的地址”。__FLASH_USED_END__是当前固件实际占用的Flash结束地址它是在.flash_crc段结束之后通过.当前位置定义的表示“当前固件映像到此为止”。.flash_crc段是专门留给CRC元数据的。KEEP(*(.flash_crc))非常关键它告诉链接器不管这个段有没有被引用都必须保留。否则如果代码里没有显式引用CRC存储变量链接器的--gc-sections垃圾回收会把这个段丢掉烧录进去的固件里就根本没有CRC值的位置。还有一个细节__CRC_SECTION_START__和__CRC_SECTION_END__这两个符号是在SECTIONS内部用.当前位置定义的和__FLASH_START这种在SECTIONS外部用ORIGIN计算出来的符号地位一样都是链接器符号只是值的来源不同。C代码在遍历Flash做CRC计算时可以精确跳过[__CRC_SECTION_START__, __CRC_SECTION_END__)这个区间。如果想让CRC存储区固定在物理Flash的末尾而不是跟着固件长度跑做法会稍微复杂一些。一种方式是把.flash_crc段用固定地址定义.flash_crc 0x0807F800 : { KEEP(*(.flash_crc)) } FLASH但这样等于又在脚本里引入了硬编码失去了动态自适应能力我不太推荐。实际项目里更常见的做法是让.flash_crc跟随整个镜像排在最后然后Boot和App通过__CRC_SECTION_START__动态定位它。这样不管固件怎么膨胀收缩只要Flash放得下边界永远是对的。3.3 C代码怎么引用这些边界有了链接脚本C代码就清爽很多。下面是一个完整的CRC校验函数用来校验__FLASH_USED_END__之前的区域并跳过CRC存储区。#include stdbool.h #include stdint.h /* Linker-defined symbols */ extern char __FLASH_START[]; extern char __FLASH_USED_END__[]; extern char __CRC_SECTION_START__[]; extern char __CRC_SECTION_END__[]; /* CRC storage, placed in .flash_crc section */ __attribute__((used, section(.flash_crc))) static const uint32_t crc_storage 0xFFFFFFFF; static uint32_t crc32_update(uint32_t crc, uint8_t data) { crc ^ data; for (int i 0; i 8; i) { if (crc 1u) { crc (crc 1) ^ 0xEDB88320u; } else { crc 1; } } return crc; } bool flash_integrity_check(void) { const uint8_t *p (const uint8_t *)__FLASH_START; const uint8_t *end (const uint8_t *)__FLASH_USED_END__; const uint8_t *crc_start (const uint8_t *)__CRC_SECTION_START__; const uint8_t *crc_end (const uint8_t *)__CRC_SECTION_END__; uint32_t crc 0xFFFFFFFFu; while (p end) { if (p crc_start p crc_end) { p crc_end; continue; } crc crc32_update(crc, *p); p; } return crc crc_storage; }有几个点需要解释。C代码里用extern char xxx[];声明链接器符号这是上一节提到的推荐做法。(uint8_t *)__FLASH_START把符号值转换成一个可遍历的指针范围是__FLASH_USED_END__之前这就是“当前固件实际占用区域”。遍历到CRC段时直接把指针跳到crc_end。原因很简单CRC校验值自己不能参与自己的计算。你如果把这个区域也算了进去那么写入CRC之后CRC值本身会被当成数据改变计算结果下次校验无论怎么算都对不上。crc_storage变量的定义也值得展开说一说。它被显式放在.flash_crc段加上了__attribute__((used))确保编译器不会因为它是static const就把它优化掉或者内联成常数。在最终ELF文件里这个变量会坐落在Flash靠近末尾的位置它的内容在编译阶段是占位值0xFFFFFFFF烧录前由构建工具回填真实CRC值。3.4 烧录前CRC值是怎么来的这是整个方案里最容易被忽略的环节。你写了一个很漂亮的CRC校验函数但是烧录进去的时候crc_storage里还是0xFFFFFFFF两个CRC比对不上启动就会拒绝运行。解决思路通常是在构建流程里加一个“回填CRC”的步骤编译器生成包含占位CRC值的ELF文件。脚本读取ELF找到__FLASH_START、__FLASH_USED_END__、__CRC_SECTION_START__、__CRC_SECTION_END__这四个符号。按照和C代码相同的CRC算法对__FLASH_USED_END__之前的区域跳过CRC段计算CRC。把计算出的值写回ELF中crc_storage的位置生成新的HEX/BIN用于烧录。Python里可以用pyelftools来做这件事思路不复杂。这里不贴完整工具代码但在Makefile里会看到类似这样的命令arm-none-eabi-objcopy -O ihex firmware.elf firmware_before.hex python3 tools/fill_crc.py firmware.elf arm-none-eabi-objcopy -O ihex firmware.elf firmware_final.hexfill_crc.py的核心逻辑就是读ELF符号表拿边界算CRC再改文件。把这一步骤加进CI或本地构建脚本就能保证每次烧录出去固件里的CRC值都是正确的。4. 常见问题与排查技巧实录4.1 链接符号未定义或被优化掉症状编译时报undefined reference to __FLASH_START。通常是几个原因。第一链接脚本里符号名拼错了或者符号定义在了SECTIONS内部但被局部作用域限制了可见性。第二在C工程里链接器符号不会自动做名字修饰但C引用的符号会有修饰需要在引用处加extern C包裹或者直接用C文件。第三--gc-sections垃圾回收把引用这些符号的段回收了符号在最终镜像里不存在。排查方法很简单编译后打开生成的ELF用nm工具查一下arm-none-eabi-nm firmware.elf | grep __FLASH_START如果符号不存在优先检查链接脚本里是否误加进了LOCAL或者/DISCARD/。如果符号存在再看类型是不是Aabsolute symbol。这个字母A很关键它代表这是一个绝对地址符号而不是某个段的符号。4.2 CRC计算范围不对把CRC存储区也算进去了症状烧录完成后第一次上电自检就失败。先说一个排查技巧先不写回填工具把crc_storage固定设置成一个手工计算好的值烧进去再在C代码里把计算出的CRC通过串口打印出来对比两者是否一致。如果不一致最大的嫌疑就是遍历范围里混入了CRC存储区。调试时直接看__FLASH_USED_END__、__CRC_SECTION_START__、__CRC_SECTION_END__三个符号的值确认跳过逻辑是否在正常执行。一个很容易被忽略的问题是如果你在链接脚本里用的section名字拼写和C代码里section(.flash_crc)不一致比如C代码写成了.flashcrc那么CRC段实际没有进到.flash_crc里__CRC_SECTION_START__和__CRC_SECTION_END__会被链接器自动分配到一个空段两个值可能相同跳过逻辑等于没生效。检查这种问题最直接的办法是打开map文件搜.