STM32未用FLASH区域填充:链接脚本配置与固件校验优化
1. 为什么要给未用FLASH区域做填充
先聊个实际场景。你在做产品量产的时候,固件需要做CRC校验或者签名校验,用来保证程序在传输、烧录过程中没有被篡改或者损坏。这时候你写了一个上位机工具,把编译出来的hex或者bin文件读进来,对整个FLASH区域做校验和计算。结果呢?烧进去的芯片第一次上电自检,校验失败。
排查半天发现,问题出在那些“没用到”的FLASH区域上。编译生成的bin文件只包含实际代码和数据占用的空间,而芯片FLASH里剩余的部分,出厂时可能是0xFF,也可能因为之前烧过其他程序残留了其他数据,完全不可控。你的校验算法是对整个FLASH区间算的,这部分随机数据就会导致校验结果对不上。
另一种常见场景是调试阶段。你用调试器反复下载程序,FLASH里残留的旧数据和新代码混在一起,某些未初始化区域被意外执行,程序跑飞了还不好定位。如果能把未用区域统一填充成固定值(一般是0xFF或者0x00),至少能让行为可预期,排查问题会省很多事。
LAT1306这个应用笔记讲的就是怎么在STM32CubeIDE里做这件事。它的核心思路并不复杂:通过修改链接脚本,在编译阶段就把未用区域填充成指定值。这样生成的bin文件天然是“完整”的,不需要额外写脚本去处理,也不用在烧录后再去擦除改写。
适合谁看呢?主要三类人:
- 做量产固件、需要做校验和或签名的嵌入式工程师。
- 调试阶段被FLASH残留数据坑过,想让行为更可控的人。
- 对STM32CubeIDE链接脚本、启动文件机制感兴趣,想弄明白编译链接背后逻辑的爱好者。
这篇文章我会把原理、配置步骤、常见坑一次讲透,按我实际验证过的流程来写。
2. 填充方案的原理与选型分析
2.1 为什么直接改启动文件不行
很多人第一反应是:在启动文件里定义一个很大的数组,初始化为0xFF,不就把区域占住了吗?思路方向对,但实现上有问题。
C语言里定义一个const uint8_t fill_area[0x10000] = {0xFF}这样的数组,编译后确实会在FLASH里占空间。但问题是,这个数组通常会被放在.rodata段里,具体位置由链接脚本决定,不能精确控制它落在哪个地址区间。你希望填充的是“代码段之后到FLASH末尾”的区域,而不是随便一个地方。
另外,大数组的初始化会导致编译时间变长、生成的中间文件变大,而且如果链接脚本里FLASH总容量和实际芯片型号不匹配,数组越界都不知道。这种方式只适合“恰好够用”的场景,一旦工程代码长大,很容易出问题。
2.2 链接脚本方案:从源头解决
正确的做法是改链接脚本(.ld文件),利用链接器的填充机制。GNU ld 支持一个特性:当输出段(output section)的地址空间大于输入段实际内容的大小时,可以用一个填充值来填补空隙。
具体来说,链接脚本里可以这样写:
.flash_fill : { . = ORIGIN(FLASH) + LENGTH(FLASH) - 1; BYTE(0xFF) } > FLASH这段脚本的意思是:定义一个名为.flash_fill的输出段,把它放在FLASH区域的最后一个字节,并且这个字节的值强制为0xFF。链接器在布局时发现这个段的起始地址(当前.的值)和前面代码段结束地址之间存在空洞,就会自动用填充值去填补。
这个方案的优点:
- 编译产物(hex/bin)里天然包含完整的FLASH区域数据,不需要额外工具处理。
- 填充值可以任意指定,0xFF、0x00或者别的值都行,只要芯片支持。
- 不占RAM,不增加运行时开销。
- 填充逻辑集中在链接脚本里,工程结构清晰。
缺点也有:需要对链接脚本有一定了解,第一次配置有点门槛;而且如果FLASH区域中间有多个不连续的空洞,可能需要写多段脚本去覆盖。
2.3 填充值怎么选:0xFF还是0x00
这是实际项目里很多人纠结的点。我的建议是优先用0xFF,除非有明确理由不用。
原因很简单:STM32的FLASH在擦除后所有位都是1,也就是0xFF。如果把未用区域填充成0xFF,那么这部分区域和“刚擦除的芯片”状态完全一致。这样生产流程里,即使你不在固件里做填充,烧录器直接擦除再烧录,效果也是一样的——校验算法拿到的是一个确定性的全FF区域。
反过来,如果你填0x00,芯片擦除后是0xFF,烧录器烧完代码后还要再把未用区域写成0x00。大部分烧录器是支持“擦除后写入完整镜像”的,所以也能做,但多了一道操作,而且对烧录器有额外要求。
从调试和排错角度,0xFF还有个好处:如果你不小心跳到了未初始化区域(PC指针跑飞了),执行到全0xFF的指令,在ARM Cortex-M上会触发HardFault,方便你立刻发现程序跑飞了。如果全是0x00,0x00在ARM里是lsls r0, r0, #0,是一句空操作,程序会在填充区里一直“滑行”,直到遇到有效代码或其他异常,排查起来更费劲。
提示:如果你的产品对功耗有极致要求,或者FLASH里有特殊保护逻辑,选填充值前先确认芯片型号的数据手册,个别芯片对FLASH编程有特殊限制。
2.4 直接影响范围:hex文件、bin文件和烧录器
配置填充之后,最直观的变化是编译产物体积变大。原本你的代码只有32KB,但芯片FLASH是256KB,填充后bin文件会变成256KB。hex文件也会相应变大。这会影响:
- 编译时间:链接阶段生成大文件会多花一点时间,但通常可接受。
- 烧录时间:烧录器要写入的数据量变大,量产时每片芯片的烧录时间会增加。如果产线对节拍很敏感,需要评估这个成本。
- 版本管理:bin文件变得很大,放在Git里每次提交的diff会不好看。建议在CI流程里用release构建做填充,日常开发构建不做。
另外一个容易被忽略的点:填充后,如果后续代码空间不够用了怎么办?这是我用这个方案时最担心的问题。FLASH区域被“占满”之后,链接器一旦发现代码段增长导致和.flash_fill段重叠,会直接报LMA区域溢出错误。这时候你会被迫重新调整填充区域的边界——这是好事,因为它强制你关注FLASH占用率。
我实际的做法是:不把整个FLASH全填满,往下留一点安全余量。比如芯片有256KB,我只填充到最后一个扇区之前,预留几KB空间给后续小改动。这样既保证了校验区域的确定性,又给自己留了缓冲。后面在“实操过程”里会详细讲怎么界定边界。
3. 实操过程:完整配置步骤与核心环节实现
3.1 环境准备
本文示例基于以下环境:
- 硬件:STM32F103C8T6(64KB FLASH,项目里LAT1306的对应芯片)
- IDE:STM32CubeIDE 1.13.0以上版本
- 调试器:ST-Link V2 / J-Link均可
- 固件库:STM32CubeF1(其他系列同理)
在开始之前,确保你的工程能在默认配置下正常编译、烧录、运行。这个前提很重要,因为后续改动只涉及链接脚本和构建配置,如果基础工程都不稳,很难判断是填充配置引入的问题还是环境本身的问题。
STM32CubeIDE新建工程这里就不赘述了,重点讲怎么改。如果你用的是STM32CubeMX生成的基础工程,改完链接脚本注意别在CubeMX里重新生成配置时不小心覆盖掉,最好把修改记录下来,或者直接把链接脚本加入版本管理。
3.2 修改链接脚本:核心配置
打开工程里的链接脚本,默认路径在工程根目录下,文件名一般是STM32F103C8TX_FLASH.ld。用文本编辑器打开,重点关注MEMORY和SECTIONS两个部分。
先看一眼默认的MEMORY定义:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K }这里明确指定了FLASH的起始地址和长度。我们需要在SECTIONS里新增一个输出段,用它来触发填充。
在SECTIONS的末尾(}之前)加上如下内容:
.flash_fill : { . = ORIGIN(FLASH) + LENGTH(FLASH) - 1; BYTE(0xFF) } > FLASH我来逐行解释这段脚本在做什么。
第一行的.flash_fill是段名,你可以随便起,只要不跟已有的段重名就行。:后面的大括号里是这个段的布局规则。
. = ORIGIN(FLASH) + LENGTH(FLASH) - 1;这行是核心技巧。.在链接脚本里表示当前虚拟地址游标。把这个游标直接设置到FLASH区域最后一个字节的地址,也就是0x08000000 + 64K - 1 = 0x0800FFFF。
BYTE(0xFF)在这个地址上放置一个单字节,值为0xFF。
那么关键问题来了:链接器在布局时发现,上一段(通常是.isr_vector、.text、.rodata这些实际代码数据段)结束地址距离0x0800FFFF之间还有一段空洞,这个空洞没有对应的输入段内容。链接器会用“填充值”来填充这段空洞。默认的填充值是0x00,但我们用BYTE(0xFF)设置了段的最后一个字节为0xFF后,链接器会把这个作为整个输出段的填充值模式,也就是说,空洞部分会用0xFF来填。
这里要注意一个细节:如果空洞大小不是1字节的整数倍(在FLASH上一定是的),填充值按字节来填充。使用0xFF时没问题,因为0xFF是对齐的。
3.3 修改启动文件:确保入口最简
实际上,只改链接脚本就能让生成的bin文件包含填充区域。但有一个环节必须检查:启动文件里是否定义了堆栈和中断向量表以外的东西,会不会占用额外空间。
多数场景下,STM32CubeIDE生成的启动文件不用动。但如果你用的是自研启动文件,注意确保FLASH区域里除了代码段、只读数据段之外,没有其他需要运行时初始化的数据段被错误地放进了FLASH。否则填充区域的边界会和你预期的不一样。
这里给一个判断方法:编译后打开Project.map文件(在Debug/目录下),搜索Memory Configuration,查看FLASH区域每个段的起始和结束地址,确认_estack外的保留段没有异常跳动。
3.4 构建并检查产物
配置完成后,执行一次完整构建(Project -> Build,或直接点锤子图标)。构建成功后,在Debug目录下检查.bin和.hex文件。
如果你用的是Windows,可以用HxD这类十六进制编辑器打开bin文件,直接看文件尾部是否为连续的FF。如果你用Linux/macOS,用xxd或hexdump查看:
xxd firmware.bin | tail -20正常情况下,bin文件的末尾应该是从代码段结束到文件结尾全是ff。文件的整体大小应该等于ORIGIN(FLASH) + LENGTH(FLASH) - ORIGIN(FLASH),也就是64KB(本例)。
到这里,你可能会问:我烧录到芯片里后怎么验证呢?很简单,用调试器在线读FLASH,或者用CubeProgrammer把内部FLASH读出来存成文件,再对比填充区域。实际操作中我会用STM32CubeProgrammer的“Read”功能,把整个FLASH读出来,然后检查数据一致性,这是最直观的验证方式。
3.5 把填充值改成0x00或其他值
有时候你可能确实需要填充成0x00。改法很简单,把链接脚本里的BYTE(0xFF)改成BYTE(0x00)。但这里有一个安全提醒:STM32的FLASH擦除后是0xFF,写入0x00相当于真正“编程”了这些位。如果后续你重新修改代码,想擦除整个FLASH时,这些0x00的位会被正常擦除回0xFF,不会有副作用。但在线调试时,如果填充区域正好被写保护了,烧录某些页可能会报错。具体看芯片的保护配置。
实际上,在部分STM32型号上,全0x00填充还有一个大坑:如果程序计数器跑飞到全0填充区域,这些指令会滑行很久,而且可能触发总线错误或进入不可预期的状态。所以除非有特殊需求(比如用0x00来表示“未使用”语义、配合外部校验算法要求全0基线),我仍然建议保持0xFF。
3.6 给FLASH区域预留安全余量
前面提到过,把FLASH完全填满会让后续代码空间变得敏感。一个实用的改进方法是:不填满整个FLASH,而是留出一段不参与填充的余量。
具体做法是,不全用LENGTH(FLASH)来定位最后一个字节,而是手工指定一个地址:
.flash_fill : { . = ORIGIN(FLASH) + 56K - 1; BYTE(0xFF) } > FLASH这个写法把填充段锚定在距离FLASH末尾还差8KB的位置。这样代码段 + 填充段只会占掉56KB,剩下8KB处于“未填充”状态,作为代码增长的缓冲。如果你的产品后续需要加功能,这个缓冲就能派上用场,不用每次改FLASH边界。
但要注意,这种方式下bin文件的体积变成56KB,而不是64KB。如果你的校验算法是固定按“整个FLASH”来算的,那这8KB的“自由区域”依然会导致校验不稳定。所以这个方案只适用于“校验区域固定为填充后的bin文件”的场景,比如上位机直接把bin文件作为整体来计算哈希。实际项目中,我更倾向于先用满FLASH做填充,等代码接近满时再手动按扇区调整。
3.7 多Bank芯片的特殊处理
一些大容量STM32(比如STM32H7系列)有双Bank结构,FLASH区域被分成两个独立的区间,中间可能存在系统存储区或选项字节区。链接脚本里的MEMORY定义可能包含多个区域,比如FLASH和FLASH1。
这时需要分别对每个FLASH区域做填充,脚本类似:
.flash_fill : { . = ORIGIN(FLASH) + LENGTH(FLASH) - 1; BYTE(0xFF) } > FLASH .flash1_fill : { . = ORIGIN(FLASH1) + LENGTH(FLASH1) - 1; BYTE(0xFF) } > FLASH1两个段的名字不能重复。而且要注意,如果两个FLASH区域之间隔着系统区域,bin文件导出时可能会包含不连续的区域。这时候通常建议用hex格式,因为hex本身就支持不连续地址区间,而bin是线性连续镜像,导出的bin会包含中间所有空洞,文件体积会虚增。
我之前的项目里就遇到这个问题,H743的1MB FLASH分两个Bank,中间有保留区。用bin做OTA升级包时,中间那段保留区数据全是0xFF,导致升级包体积大了不少。后来改成只在代码段所在Bank做填充,另一个Bank的填充交给上位机脚本在打包时处理,才平衡了体积和确定性。
4. 常见问题与排查技巧实录
4.1 生成的bin文件大小不符合预期
现象:配置了填充后,bin文件大小没有变成64KB,还是只有代码实际大小。
原因:最常见的原因是链接脚本里MEMORY区域的LENGTH定义和实际芯片不匹配。比如芯片是64KB,但链接脚本写的是128KB,填充段被定位到128KB末尾,导致bin只有前64KB代码,填充区域在64KB之外的部分没有生效。
排查方法:打开.map文件,搜索.flash_fill,看看这个段的起始地址是哪个。正常情况它应该落在实际FLASH容量的最后一个字节地址上。如果对不上,修改LENGTH或手工指定的锚点地址。
另一个原因:你用的是自带Bootloader的工程,链接脚本里FLASH的ORIGIN被改过,起始地址不是0x08000000,比如偏移到了0x08008000。此时用ORIGIN(FLASH) + LENGTH(FLASH) - 1计算出的还是正确的末尾地址,没问题。但如果你手工指定了锚点,记得加上偏移。
4.2 链接报错“region ‘FLASH’ overflowed”
现象:代码实际占用 + 填充段占用的空间总和超过了FLASH容量,链接阶段直接报错。
原因:FLASH空间不够用了,代码膨胀到填充区域。
处理办法:
- 确认代码占用情况,看看是不是优化等级太低导致代码膨胀。在Project Properties -> C/C++ Build -> Settings -> Tool Settings -> MCU GCC Compiler -> Optimization里把优化等级调到
-Os(优化尺寸),很多时候可以腾出空间。 - 如果确实代码太大,只能换更大FLASH的芯片,或者精简功能。
- 临时方案:把填充段锚点往前挪,让填充区域缩小,但这样你的校验区域就不是全FLASH了,后续校验逻辑要同步调整。
注意:这个报错其实是个保护机制。它防止你“无意识”地覆盖掉填充段,导致产物重复定义同一地址。我建议把它当成一次设计审查机会,而不是强行绕过去。
4.3 烧录后填充区域不是0xFF
现象:烧录完成后,用调试器或CubeProgrammer读FLASH,发现填充区域有部分不是0xFF。
原因:这个现象通常不是链接脚本的问题,而是烧录器的烧录策略。很多烧录器在下载程序时,会先擦除整个FLASH,然后只写入hex/bin里实际包含的数据。如果你的bin文件已经包含填充区域(0xFF),理论上写进去就是0xFF,没有问题。但如果你用的是“编程后自动校验”模式,某些烧录器只校验它“实际写入的扇区”,而填充区域如果全是0xFF,还会被跳过扇区擦除,导致校验跳过。
还有一个隐蔽原因:FLASH的写保护。某些芯片出厂时使能了读保护或写保护,烧录器写入时静默失败,读出来还是旧数据。如果这个排除不掉,用CubeProgrammer检查一下选项字节。
我的建议:量产烧录时,统一在烧录器里配置“Erase All Sectors”,再编程完整镜像(包含填充区域),确保FLASH最终状态和bin文件完全一致。不要依赖“只擦除有变化的扇区”这种增量模式,因为增量模式在量产现场会因为芯片状态不可控而埋雷。
4.4 链接脚本在CubeMX重新生成后被覆盖
现象:用CubeMX改了Pin配置或时钟树,重新生成代码后,链接脚本里自定义的.flash_fill段不见了。
原因:CubeMX在重新生成项目时,会对链接脚本做一部分刷新操作,尤其是当芯片型号变更时,MEMORY部分会重写。SECTIONS部分不一定每次都被覆盖,但有一定概率被重置。
处理办法:把链接脚本纳入版本管理,每次CubeMX重新生成后检查diff,发现丢了就手动加回来。更“自动”一点的做法:把填充逻辑单独放到一个新的.ld文件里,然后在主链接脚本的SECTIONS里用INCLUDE xxx.ld引入。这样CubeMX重写主脚本时,只要不删除INCLUDE行,你的自定义段就能保留。不过实测CubeMX对不同版本的IDE行为不一致,最稳妥的还是版本管理 + 改动记录。
4.5 启动文件里增加自定义段导致启动失败
现象:有人想直接把填充段写进启动文件,用汇编.space指令预留空间并初始化,结果程序无法启动。
原因:这种方式本质上是让FLASH空间被占用了,但可能影响中断向量表的排列,或者你的.space加到了一个不可执行/不可读的段里。
处理办法:回到链接脚本的方案。启动文件动得越少越好,链接脚本是修改FLASH布局的最标准方式。
4.6 不同编译优化等级下,产物不一致
现象:在Debug(-O0)下编译,填充正常;切到Release(-Os)后,生成的文件大小变化,甚至bin文件末尾填充区域长度变化。
原因:这是正常现象。不同优化等级下代码段长度不同,填充区域的起点会跟着变化,但填充区域的终点是固定的(FLASH末尾),所以bin文件的总长度是不变的。如果你发现总长度变了,那一定是你链接脚本里锚点没有写死,或者手工用了类似. = ALIGN(4)导致游标移动后计算出来不同。
排查方法:确认.flash_fill段的起始地址只依赖ORIGIN(FLASH) + LENGTH(FLASH) - 1,不要在其他地方修改这个地址。
5. 进阶用法:把填充与校验流程串在一起
到这里,基础填充已经能正常工作了。但实际项目里,填充往往只是链条的一环。我再分享两个实际工程里的进阶用法,帮你把这个能力发挥到最大。
5.1 生成带校验值的完整镜像
一个常见需求是:烧录前自动计算固件的CRC32/SHA256,把校验值附加到固定位置(通常是FLASH末尾几个字节),用于Bootloader在上电时自校验。
做法是把填充段和“放校验值的区域”结合在一起。思路如下:
- 在FLASH末尾预留一段固定区域,专门存放校验值。这个区域在链接脚本中定义为一个独立段,初始值可以用0xFF占位。
- 编译出bin文件后,用上位机脚本(Python)计算前面所有区域的校验值,填入预留区域。
- 产线烧录时直接烧录这个“已经填好校验值”的完整镜像。
这样做的好处是,Bootloader不需要在运行时动态计算并烧写FLASH(那会很慢,而且需要谨慎处理掉电问题),而是直接读取固定地址的校验值来比对。因为填充区域是确定性的0xFF,校验结果也是确定性的,不会出现“上次烧录的数据和这次不一样”的情况。
5.2 与CE/OTA升级配合的注意事项
如果你的产品支持OTA升级,升级包通常是从FLASH某个偏移地址开始的一段数据。这时候未用FLASH的填充值会影响升级包的制作逻辑。
比如你的App区是0x08008000到0x0801FFFF,共96KB。实际代码只有60KB,剩余36KB是填充区域。OTA升级包是只打包那60KB,还是打包完整的96KB?两种方式各有优劣:
- 只打包60KB:升级包小,传输快,但接收方烧录时只能做“部分擦除”,未覆盖区域保持旧状态。如果旧固件和新固件在未用区域的填充值不一致,整体校验会失败。
- 打包96KB完整镜像:升级包大,但接收方可以整片擦除后写入,镜像完整性和校验都简单可靠。
显然,如果你的升级流程里有“整体校验”的环节,第二种方式更稳妥。而填充区域让第二种方式成为可能——因为bin文件本来就是96KB的完整镜像,不依赖现场芯片的擦除状态。这也是“填充未用FLASH区域”在生产环境中最大的价值。
5.3 使用脚本批量生成不同填充值的镜像
在某些产线场景下,不同产品型号可能要求填充不同的值(比如用0xAA表示“禁用功能”,0x55表示“启用功能”)。手动改链接脚本再编译效率太低了。
我的做法是写一个Python脚本,直接读取链接脚本模板里的锚点配置,生成多个版本:
import re def modify_fill_value(ld_path, fill_byte): with open(ld_path, 'r', encoding='utf-8') as f: content = f.read() # 替换BYTE(0xFF)为BYTE(0x..) content = re.sub(r'BYTE\(0x[0-9A-Fa-f]+\)', f'BYTE(0x{fill_byte:02X})', content) with open(ld_path, 'w', encoding='utf-8') as f: f.write(content)这样每次构建时,脚本自动生成不同填充值和镜像我就能区分不同配置的产品,不需要维护多套代码分支。
提示:填充值不能随意指定,必须是FLASH可编程的位模式。比如从0xFF只能向0x00方向编程单个位,小端模式下字节值可以是0xFE、0xFC等,但不是所有值都能从任意初始状态转换过来。如果你的校验算法不依赖特定填充值,建议只用0xFF或0x00两个值。
6. 一个完整的自动化流程参考
最后给一个我在实际生产项目中使用的完整流程,从编译到烧录全链路覆盖。
第一步:编译生成带填充的固件
链接脚本里配置好填充段,使用Release配置编译,产物为firmware.hex(或bin)。
第二步:校验产物
用脚本检查bin文件最后一个非0xFF的字节是否存在,并且文件大小是否符合预期:
python3 -c " data = open('firmware.bin', 'rb').read() print(f'Size: {len(data)} bytes') last_non_ff = max(idx for idx, b in enumerate(data) if b != 0xFF) print(f'Last non-0xFF byte at: {hex(last_non_ff)}') "如果最后一段全是0xFF,说明填充生效。
第三步:计算并写入校验值
用Python的zlib.crc32或hashlib.sha256计算镜像的哈希值,写入预留区域(比如FLASH末尾某个固定偏移)。写完后再次生成最终的烧录包。
第四步:产线烧录
烧录器软件里配置“全片擦除 + 编程完整镜像 + 校验”。确保烧录器能识别firmware_with_crc.hex里的不连续地址区间(如果用了多段填充)。
第五步:产线抽检
用CubeProgrammer随机抽取烧录好的芯片,读取整个FLASH区域,和烧录包逐字节比对。这一步能有效发现填充不到位、烧录策略配置错误等问题。
这套流程在多个项目中跑下来,校验不稳定导致的上线返工问题基本被根除了。唯一需要注意的是,产线烧录速度会受bin文件体积影响,但相比排查随机校验失败的成本,这点时间投资非常划算。
从我个人的经验看,填充未用FLASH区域这个操作,初看只是链接脚本里画蛇添足的一两行,但它带来的核心收益是让固件镜像的每一个字节都可预测。无论是做校验、做签名、做差分升级,还是单纯想排查神秘问题,可控的镜像都是基础中的基础。如果你在量产或调试中也被FLASH随机数据坑过,不妨花半小时试试这篇文章里的配置,大概率能省下后面几天的排查时间。
