
做嵌入式的人早晚会碰上一个硬需求把某个变量钉在固定的内存地址上让Bootloader和App都能访问到它。可能是升级状态、版本号、序列号也可能是一块外部RAM里的采集缓冲区。我第一次认真研究这个问题是在调IAP升级的时候。Bootloader和App各写各的“升级状态”变量调了半天发现两块内存根本不在同一个地方逻辑上完全对不上。后来才算彻底明白在Keil MDK里“变量会放在哪”这件事不是由你在代码里写在哪一行决定的而是由编译器的段划分和链接脚本共同决定的。这篇文章想讲清楚三件事怎么通过变量和函数的属性控制段归属怎么用绝对地址定位和分散加载文件把段放到指定地址以及在这个过程中我踩过的编译优化、清零初始化和链接器相关的坑。适合正在用Keil MDK做STM32这类ARM Cortex-M项目又想彻底弄懂内存布局和地址控制的开发者。内容不玄乎全是实际能落地的写法。1. 变量和函数属性编译器眼中的修饰与段归属1.1 一个变量最终去哪里先看它属于哪一段C语言层面看不到“段”这个概念但编译器会把每个变量、函数放进不同的输出段。函数进.text大字符串常量进.rodata带初值的全局变量进.data没初值的变量进.bss。链接器再把段落到实际的物理地址上。理解这一层你才会明白“给变量指定地址”的本质不是魔术而是两步先用属性告诉编译器“这个对象归哪个段”再用链接脚本告诉链接器“这个段放哪里”。很多教程只教你抄一条__attribute__((at(0x...)))却不讲背后的规则等到优化级别一变、或者换到AC6编译器代码就出各种诡异问题。1.2 static、const、volatile这些基础属性工程上怎么理解static局部变量改成static后从栈里挪到了全局RAM区生命周期贯穿整个程序。如果它被中断和主循环同时访问你就要考虑访问竞争和缓存一致性的问题。const不一定就进Flash只读区。如果定义了const uint8_t version[] {...}但代码里没用到编译器可能整体优化掉即便保留放哪里也要看段选择器。所以想长期保存一份常量并固定到地址必须配合自定义段和used属性。volatile告诉编译器“这个变量可能在你看不到的地方变化”比如中断、DMA、另一颗MCU。映射寄存器时几乎标配。但要注意volatile只管访问顺序和优化访问不做同步也不是锁。1.3attribute控制段归属的“主开关”Keil MDK的ARMCC和AC6环境里最常用的扩展属性是这几个__attribute__((section(.my_buf))) __attribute__((aligned(4))) __attribute__((packed)) __attribute__((used))section(name)将变量或函数放进一个名叫.my_buf的输入段之后链接脚本可以通过*(.my_buf)把这个段里的所有内容整体抓走。aligned(4)指定变量按4字节对齐。DMA缓冲区往往要求更高具体看外设。packed压缩结构体成员之间的填充一般用于通信协议帧。代价是访问效率下降能不用尽量不用。used告诉编译器“即使看起来没人引用也必须保留”。AC6高优化下这个属性非常关键后面会在踩坑章节里细说。2. 绝对地址还是段地址at()的便利与局限2.1attribute((at()))最快把变量钉死在地址上Keil很早就支持__attribute__((at(地址)))。典型的写法是__attribute__((at(0x20004000), used)) volatile uint32_t shared_flag;编译后shared_flag一定位于0x20004000。调试时打开Memory窗口直接输入地址就能看到它。这个方式适合单个标志、寄存器映射、简单的Bootloader/App握手变量。例子里的shared_flag是4字节足够存一个magic或者状态。但局限也很明显编译器不检查你设的地址附近有没有别的变量数组越界或地址写错容易悄悄覆盖别的数据。如果一组相关变量都要固定地址逐个用at()定义可读性和维护成本都很差。AC6下如果不配合used这个变量可能整个被优化掉。2.2 为什么需要“段地址”这种更抽象的方式单个变量用at()可以批量变量和函数定位更适合用“自定义段”配合分散加载文件。具体做法是先把变量或函数打上同一个段名标签然后在.sct文件里新建一个执行区指定起始地址和大小再用*(段名)把这一组东西全部放进去。地址的管理权就从“每个变量分散写死”变成了“链接脚本统一管理”。以后想整体挪位置只需要改一个数字不用动源码。举一个函数定位的例子void crc32_self_check(void) __attribute__((section(.check_funcs))); void crc32_self_check(void) { // 校验逻辑 }把函数放进.check_funcs段后链接脚本可以把该段放到Flash特定区域Bootloader在固定地址就能找到它并调用。2.3 什么情况下只需at()什么情况下该写.sct场景推荐做法原因单个寄存器地址映射at()简单直接不需要额外脚本Bootloader和App共用两三个变量at()或自定义段变量少时at()够用一组缓冲区/协议池集中管理自定义段.sct方便整体搬移可加边界检查把函数放到固定Flash区域只能靠.sctat()主要针对数据对象函数定位需要执行区需要让一段代码在RAM里运行只能靠.sct涉及加载地址和执行地址分离3. 分散加载文件要和地址指到哪才落得下3.1 加载区与执行区仓库和柜台的差别.sct分散加载文件描述两类信息一是数据烧录时放在哪也就是加载区LR二是运行起来后各段要在哪个地址执行也就是执行区ER。最常见的STM32 Flash启动场景这两者地址一致都在0x08000000附近。做Bootloader加载、或者把代码搬到RAM执行时才会分开。可以简单理解为加载区是出厂包装箱执行区是柜台上打开的展品。普通情况下二者在同一位置所以不需要额外搬代码。3.2 一个可用的.sct长什么样以某型号STM32为例Flash从0x08000000开始共512KBSRAM从0x20000000开始共64KB。我打算这样安排内存普通代码和只读数据放在Flash低区只允许使用到0x0807DFFF。CRC自检函数放在0x0807E000开始的4KB区域。App版本信息放在Flash最后128字节也就是0x0807FF80。RAM区主数据放在0x20000000开始的16KB。Bootloader与App共享缓冲区放在0x20004000大小16KB且不进行启动清零。对应的.sctLR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 FIXED 0x0007E000 { *(.text*) *(.rodata*) *(.ARM.extab*) *(SHT) } CHECK_FUNC_REGION 0x0807E000 FIXED 0x00001000 { *(check_funcs) } APP_VER_REGION 0x0807FF80 FIXED 0x00000080 { *(app_version) } RW_IRAM1 0x20000000 0x00004000 { *(.data*) *(.bss*) } SHARED_BUF 0x20004000 UNINIT 0x00004000 { *(shared_boot_param) } }这份脚本里刻意没有写*(RO)这种宽泛选择器而是用了精确的.rodata*因为如果写*(RO)编译器可能把自定义的.app_version段也当成普通只读段抓走导致版本信息放错地址。这是很多人容易忽略的细节。3.3 如何启用自定义.sct在Keil工程中打开魔术棒 - Linker默认勾选“Use Memory Layout from Target Dialog”。要改成自定义取消这个勾选然后在“Scatter File”一栏选你写好的.sct文件。实操上有个建议先在勾选状态下点一下“Edit”Keil会生成一份默认.sct复制一份再改成自用。这样标准段的抓取规则都有兜底不容易漏掉.ARM.exidx这些冷门段。4. STM32实战把共享缓冲区和版本信息钉在指定地址4.1 需求还原以一个Bootloader升级项目为例典型需求Bootloader和App共用一块0x400字节的握手缓冲区存放magic、包长度、CRC、升级结果。地址必须固定两边才能访问同一份数据。App启动后Bootloader需要到Flash固定位置读取App版本信息所以版本结构体要放在Flash最后128字节。有一段CRC自检函数Bootloader要跳到一个固定地址去执行函数需要放到独立Flash区域。4.2 在C代码里声明段、变量和函数先定义共享结构体并放到自定义段typedef struct { uint32_t magic; uint32_t app_size; uint32_t package_crc; uint8_t upgrade_result; uint8_t reserved[3]; } UpgradeShared_t; __attribute__((section(.shared_boot_param), used, aligned(4))) UpgradeShared_t g_upgrade_shared;版本信息是只读常量放到Flash里__attribute__((section(.app_version), used)) struct { uint8_t major; uint8_t minor; uint16_t patch; uint32_t build; } g_app_version {1, 2, 0, 20240517};最后声明函数定位void crc32_self_check(void) __attribute__((section(.check_funcs))); void crc32_self_check(void) { // 这里写CRC校验实现 }建议变量都加上aligned(4)因为结构体里有uint32_t字段如果地址不对齐Cortex-M在读写时可能出额外等待周期某些外设场景下会直接HardFault。4.3 把.sct添加进工程把上一节写的.sct文件放到工程目录然后在Linker选项卡取消自动布局选择手动Scatter File。编译后如果普通代码量超过了预留的低区容量链接器会报Region ER_IROM1 overflowed by ...这说明代码超过0x0807DFFF需要调整保留区域的划分或者优化代码体积。链接器的这个保护机制是好事它能在下载前就暴露问题。4.4 验证map文件、调试器与结构体变量查看编译完打开Project.map文件搜索g_upgrade_shared能看到地址落在0x20004000搜索g_app_version地址在0x0807FF80附近crc32_self_check的地址也会落在0x0807E000之后的区域里。调试模式下Watch窗口直接输入g_upgrade_sharedKeil会按结构体成员展开显示。如果变量被优化掉了或者只是想按地址强制查看可以输入*(UpgradeShared_t*)0x20004000Keil会把0x20004000的地址解析成结构体类型成员一目了然。这个技巧在排查共享数据问题时非常实用相当于用指针变量做了一次强制转换不依赖源码里的变量名。5. 那些编译优化、初始化与链接器引发的隐蔽问题5.1 AC6下at()变量被优化掉现在MDK新工程默认用ARM Compiler 6行为跟老AC5不太一样。AC6在-O2甚至更高优化等级下如果发现一个全局变量没有“可见的引用”可能直接把变量丢掉就算加了at()也一样。我亲身踩过这个坑升级工具链之后Bootloader握手变量一直读不出来。排查到最后发现App这边定义的握手变量在汇编输出里根本不存在整个变量被当成死代码优化掉了。解决办法很简单给变量加__attribute__((used))__attribute__((at(0x20004000), used)) volatile uint32_t shared_flag;如果这个变量会被中断、DMA或另一段程序访问还建议加上volatile防止编译器把它在循环里优化成只在寄存器上操作导致外部写入看不见。5.2 UNINIT指定地址的RAM变量到底会不会被清零这是整个主题里最绕人的地方。普通有初值的RW变量启动代码会从Flash拷到RAM没初值的ZI变量启动代码会统一清零。可问题是Bootloader写好的共享缓冲区跳转到App之后App的启动代码有没有资格给它清零如果共享缓冲区被当成普通RW或ZI段处理它会在一上电时被清零Bootloader写入的magic直接消失握手必然失败。解决办法是在.sct执行区上写UNINITSHARED_BUF 0x20004000 UNINIT 0x00004000 { *(shared_boot_param) }UNINIT表示这个区域不需要初始化。链接器会把它标记出来C启动代码不会对它做清零动作。你需要在业务代码里自行判断是否需要初始化比如通过magic值判断是冷启动还是热启动if (g_upgrade_shared.magic ! SHARED_MAGIC) { memset(g_upgrade_shared, 0, sizeof(g_upgrade_shared)); g_upgrade_shared.magic SHARED_MAGIC; }这个问题很多人不知道直接导致共享数据“一重启就消失”。其实不是BSP有问题是启动代码把RAM清掉了。5.3 地址重叠、对齐问题与map文件排查用.sct管理地址时两个执行区如果重叠链接器会直接报错。比如Execution region CHECK_FUNC_REGION overlaps with Execution region ER_IROM1这种错误反而是好事说明链接脚本帮你做了一次地址审查。但如果用at()编译器并不知道你指定的地址附近还有没有别的变量也就不会报警。所以用at()定位时一定要养成查.map文件的习惯。对齐是另一个隐蔽点。假设你把一个结构体指针指向0x20004001里面有一个uint32_t字段访问时可能产生总线错误或者性能下降。解决就是在变量定义上写aligned(4)或aligned(8)__attribute__((section(.shared_boot_param), used, aligned(8))) UpgradeShared_t g_upgrade_shared;DMA场景尤其要注意。很多DMA控制器对缓冲区地址有对齐要求比如必须按16字节对齐否则传输直接异常。这类约束只有外设手册和寄存器的描述能告诉你编译器不会提醒。排查两个程序内存布局时最好把Bootloader和App的.sct放在一起对比用同一张地址表管理避免两边各写各的最后对不上。5.4 调试阶段观察指定地址变量的小经验如果源码里的变量名被优化得没法直接看用指针表达式强制转换是最快的。比如在Keil Watch窗口订阅*(UpgradeShared_t*)0x20004000就能随时展开结构体。这种方法适用于各种调试器因为本质是“按地址访问内存”。另一个经验是要确认某个输入段到底有没有被链接脚本抓到最好直接在.map文件里搜索段名或执行区名而不是只搜变量名。变量名可能因为优化而消失但段名和执行区名是链接器生成的只要脚本里有就一定会出现。打开.map文件看Symbol Table部分搜索g_app_version或者check_funcs比在源码里反复看靠谱得多。从我个人经验来看凡是跟“固定地址”相关的坑七成不是语法问题而是没搞懂链接器和启动代码怎么配合。把.sct文件彻底读透一次后面再做Bootloader、再做内存优化都会顺很多。上面这个共享缓冲区和版本信息的案例可以直接当模板拿去改地址换成自己的内存规划就行。祝大家在MDK里少走弯路。