做嵌入式的人,早晚会碰上一个硬需求:把某个变量钉在固定的内存地址上,让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区域 | 只能靠.sct | at()主要针对数据对象,函数定位需要执行区 |
| 需要让一段代码在RAM里运行 | 只能靠.sct | 涉及加载地址和执行地址分离 |
3. 分散加载文件要和地址指到哪才落得下
3.1 加载区与执行区:仓库和柜台的差别
.sct分散加载文件描述两类信息:一是数据烧录时放在哪,也就是加载区LR;二是运行起来后,各段要在哪个地址执行,也就是执行区ER。
最常见的STM32 Flash启动场景,这两者地址一致,都在0x08000000附近。做Bootloader加载、或者把代码搬到RAM执行时,才会分开。可以简单理解为:加载区是出厂包装箱,执行区是柜台上打开的展品。普通情况下二者在同一位置,所以不需要额外搬代码。
3.2 一个可用的.sct长什么样
以某型号STM32为例,Flash从0x08000000开始共512KB,SRAM从0x20000000开始共64KB。我打算这样安排内存:
- 普通代码和只读数据放在Flash低区,只允许使用到0x0807DFFF。
- CRC自检函数放在0x0807E000开始的4KB区域。
- App版本信息放在Flash最后128字节,也就是0x0807FF80。
- RAM区主数据放在0x20000000开始的16KB。
- Bootloader与App共享缓冲区放在0x20004000,大小16KB,且不进行启动清零。
对应的.sct:
LR_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_shared,Keil会按结构体成员展开显示。如果变量被优化掉了,或者只是想按地址强制查看,可以输入:
*(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执行区上写UNINIT:
SHARED_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里少走弯路。