文章

链接脚本:内存布局的规划图(以 Cortex-M4 为例)

启动文件篇说过,每个 STM32 工程里都有一个没人打开过的文件。其实有两个。比 startup_stm32f407xx.s 更惨的是 STM32F407VGTX.ld——大多数人根本不知道它存在,直到出事。

一个真实场景:固件 504K 逼近 Flash 512K 的上限,网上搜到一招——编译加 -ffunction-sections -fdata-sections,链接加 -Wl,--gc-sections,让链接器裁掉没人用的代码。加上,重新构建:链接通过,bin 还真小了。烧录,上电——板子死了。一个灯都不闪。

代码一行没改。死因:链接器把 .isr_vector——那张中断电话簿——当垃圾扫走了。--gc-sections 的算法是从入口(Reset_Handler)出发标记所有”可达”的代码和数据,没被够到的统统扔掉。而向量表是数据:你代码里没有任何一行引用 gfnVectors 这个数组本身,它对链接器来说就是一份”没人看的表”。修复只需一个词:KEEP(*(.isr_vector))。

为什么链接器有这么大的权力、又为什么 KEEP 这一个词就能救回来?答案全在那个 .ld 文件里。启动文件篇结尾埋过一句伏笔——”启动文件是契约的执行者,链接脚本才是契约的起草者”——本篇兑现它。读完你会发现,启动文件里那些神秘的下划线符号(_estack、_sdata、_sidata)到底是谁起的草、怎么发的。

L1:先让它读得懂——把 .ld 读薄成三块

你在第一层。目标:打开工程里的链接脚本,不再当成”生成的东西”,能认出它的骨架。

不管 ST、NXP 还是 TI 的芯片,GCC 工具链的 .ld 剥掉细节都是同样三块:

块干什么一句话比喻
MEMORY划出 Flash 和 RAM 的预算仓库平面图
符号定义给地址发门牌号门牌发放处
SECTIONS决定每段住哪分房方案

一个能跑的最小骨架(比 ST 官方的短一半,注释标出了对应零件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
ENTRY(Reset_Handler)                        /* 入口:gc-sections 从这开始标记 */

MEMORY                                       /* ← 第一块:预算 */
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

_estack = ORIGIN(RAM) + LENGTH(RAM);         /* ← 第二块:门牌号之一,栈顶 */

SECTIONS                                     /* ← 第三块:分房 */
{
  .isr_vector :
  {
    KEEP(*(.isr_vector))                     /* 电话簿不许当垃圾扫走 */
  } > FLASH

  .text :
  {
    *(.text) *(.text.*)                      /* 代码 */
    *(.rodata) *(.rodata.*)                  /* const、字符串字面量 */
    . = ALIGN(4);
  } > FLASH

  _sidata = LOADADDR(.data);                 /* 货在仓库的货架号 */

  .data :
  {
    _sdata = .;                              /* RAM 里的住址 */
    *(.data) *(.data.*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT> FLASH                          /* 双重生活:L2 再细说 */

  .bss :
  {
    _sbss = .;
    *(.bss) *(.bss.*) *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM

  . = ALIGN(8);
  _end = .;
}

ASSERT(_end <= _estack, "RAM overflowed")    /* 预算检查,链接期报警 */

三块按顺序过一遍。

第一块:仓库平面图。 MEMORY 里那两行数字不是谁发明的,是从芯片手册的 memory map 一章逐字抄下来的——F407 的 Flash 起始 0x08000000、1MB;SRAM 起始 0x20000000、128KB。反过来说:换芯片型号时 .ld 必须跟着换,本质上是把新芯片手册的这张表重抄一遍。rx、rwx 是属性声明,链接器只拿它做粗查(往只读区放可写段会报错),硬件并不执行它——真正管”写 Flash 会 HardFault”的是硅片。

第二块:门牌发放处。 _estack = ORIGIN(RAM) + LENGTH(RAM) 这一行的意思是:造一个名叫 _estack 的符号,值等于 RAM 顶端。启动文件里向量表第 0 项 .word _estack 引用的就是它——链接器算出具体数字填进去。同样的手法发放了 _sdata、_sidata、_sbss……启动文件那一堆下划线符号,全是在这里定义、在启动文件里消费的。这就是两文件”握手”的具体形态。

第三块:分房方案。 SECTIONS 里每个块是一条分房规则:.text : { *(.text) *(.text.*) } > FLASH 读作”所有叫 .text 和 .text. 开头的输入段,合并成一个大 .text 输出段,放进 FLASH 区”。.data 那条多了个 AT> FLASH,_sdata 们插在规则中间当界碑——这里先记住,L2 展开。

到这里骨架清楚了。但一堆”为什么”悬着:. 这个光秃秃的点是什么?KEEP 凭什么能对抗垃圾回收?AT> 这个奇怪箭头的两端到底是什么关系?

L2:懂原理——每个符号底下发生了什么

到这一层,问题变了:不再问”哪块干什么”,而是问”每个决定为什么这样下”。

.:链接器的笔尖

.ld 里满屏的 . 不是一个”当前目录”,也不是省略号——它是位置计数器(location counter):链接器正在安置的地址。你可以把它想象成仓库管理员手里那支笔的笔尖:每安置一段货,笔尖往后挪。

  • *(.text) 把所有代码碎片接进来,笔尖随之一路前移;
  • . = ALIGN(4) 把笔尖抬到下一个 4 字节边界(Cortex-M4 的单条 LDR/STR 虽能容忍非对齐,但 LDM/STM 这类多字访问遇到非对齐地址直接 HardFault,对齐访问也更快——所以脚本的默认动作是把笔尖抬齐);
  • _sdata = . 在笔尖当前处贴一张门牌号。

于是那些下划线符号有了一个统一的本质:门牌号,贴纸而已。它们不是变量,不占任何一个字节的内存,只是”某个地址”的名字。这一认知马上引出一个高频翻车点,放在动手环节验证。

链接器符号只是地址——没有类型,没有大小

想算 .bss 有多大,C 里直觉写法是 sizeof——编译都过不了。因为 _sbss 在链接器那里只是个数字,没有类型没有尺寸,C 编译器根本不认识它。正确姿势是先声明”它是个地址”:

1
2
3
extern unsigned char _sbss, _ebss;           /* 借个类型,只为取地址 */

unsigned long bss_size = (unsigned long)&_ebss - (unsigned long)&_sbss;

(这个减法 _ebss - _sbss 在链接脚本里是合法表达式,在 C 里必须先取地址绕开指针运算的缩放。)FreeRTOS 堆的边界、启动文件篇的搬运循环边界,底层全是这套”门牌号减门牌号”。下次看到 extern char _heap_start;,你应该条件反射:门牌号,取地址用。

KEEP 与垃圾回收的攻防

现在能讲清开头的案子了。--gc-sections 把链接器从”搬运工”升级成了”搬运工 + 清洁工”:它从 ENTRY(Reset_Handler) 出发,沿着符号引用一路标记,够不到的输入段一律扔掉——-ffunction-sections 又恰好把每个函数拆成了独立小段,于是”没人调用的函数”和”没人引用的表”一起进了垃圾袋。

.text 为什么安然无恙?因为从 Reset_Handler 出发能走到 main、走到你的每个被调用的函数——代码天然长在引用链上。.isr_vector 为什么遇难?因为引用它的不是代码,是硬件——上电那两步取 SP 取 PC 是硅片行为,链接器看不见。软件世界的可达性分析,覆盖不了硬件世界的强制引用。KEEP() 的语义就是给一个段挂上”免检”牌子:别分析了,这人我保。

同样思路还能反过来用:想裁掉没用的 libc 函数?让代码别引用它就行。--gc-sections 和 KEEP 是同一套可达性规则的攻与守。

AT>:一张房契,两个地址

.data : { ... } > RAM AT> FLASH 这行 启动文件篇已经拆过:运行时住 RAM(VMA),初值存 Flash(LMA)。这里补三个当时没展开的细节:

其一,Flash 里的地址不用你指定。AT> FLASH 的意思是”接在 FLASH 区已有内容后面排”,所以 .text 涨了 100 字节,.data 的 LMA 自动后挪 100 字节——两边的界碑 _sidata = LOADADDR(.data) 由 LOADADDR 关键字实时取值,永远同步。你在 .ld 里从未写过 0x08000430 这样的具体 LMA,就是这个机制在替你维护。

其二,objcopy -O binary 生成烧录文件时按 LMA 排布。所以 .bin 里 .data 的初值紧贴在 .text 后面——它就是”仓库货架”的物理形态,烧录器把它原样写进 Flash。

其三,.bss 不需要 AT>:没有初值要存,也就不需要仓库房产。有些脚本给它标 (NOLOAD),明示”这个段别出现在加载镜像里”——对纯裸机 .bin 没差别,但能让 .elf 语义更干净。

第三条握手通道:__init_array

启动文件篇讲过 Reset_Handler 会调 __libc_init_array 来跑 C++ 全局构造。它怎么知道有哪些构造函数要跑?又是链接脚本发的门牌号。ST 官方 .ld 的 .text 段里藏着这些行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.preinit_array :
{
  PROVIDE_HIDDEN(__preinit_array_start = .);
  KEEP(*(.preinit_array*))
  PROVIDE_HIDDEN(__preinit_array_end = .);
} > FLASH

.init_array :
{
  PROVIDE_HIDDEN(__init_array_start = .);
  KEEP(*(SORT_BY_NAME(.init_array.*)))
  KEEP(*(.init_array))
  PROVIDE_HIDDEN(__init_array_end = .);
} > FLASH

编译器把每个全局对象的构造函数指针放进 .init_array.xxx 小段,链接脚本把它们按名字排序收集起来(排序保证单个 .o 内的构造顺序),两端贴上 __init_array_start / __init_array_end 门牌号,__libc_init_array 拿着这两个地址区间逐个调用。所以启动文件和链接脚本的握手通道其实有三条:.data 搬运(_sdata 一族)、栈顶(_estack)、构造函数表(__init_array 一族)——漏了第三条的症状很隐蔽:纯 C 工程毫无异常,C++ 工程构造函数静默不执行。注意这里每个 KEEP 都有必要:构造函数和向量表一样,是”只有运行时才被走到”的引用。

顺带认三个常见段:.ARM.exidx 是栈回溯表(C++ 异常、断言打印 backtrace 用它),纯 C 工程常直接 /DISCARD/ 扔掉省几百字节;*(COMMON) 收的是”未初始化全局变量”的历史遗留形态(见下);/DISCARD/ : { *(.comment) } 扔掉的是编译器塞的注释信息。

预算检查:把”板子上的玄学”变成”链接器的一行报错”

还记得 MEMORY 里那个 LENGTH 吗?它不只是画地图,还是预算。链接器安置完所有段会做算术:总占用超出 ORIGIN + LENGTH,当场报错——

1
region `RAM' overflowed by 140 bytes

这就是人人都见过的 overflowed 报错的出处:不是编译器的检查,是 .ld 里你抄的那两个数字在把关。但注意一个盲区:栈不占预算。_estack 只是门牌号,栈用的内存不在任何段里,链接器不知道你要用多少。ST 官方脚本的补法是显式预留一段空段:

1
2
3
4
5
._user_heap_stack (NOLOAD) :
{
  . = . + _Min_Heap_Size + _Min_Stack_Size;   /* 预留堆+栈的预算 */
  . = ALIGN(8);
} > RAM

一个只挪笔尖、不放任何东西的段——纯粹占位,让”.bss 长太大把栈的地界挤掉”也在链接期暴露。配合结尾的 ASSERT(ASSERT(_end <= _estack, ...)),整套思路是一个词:把错误往左移——能挪到链接期的故障,绝不留给板子用玄学死法告诉你。这和 启动文件篇 的”宁可明确死,不要随便跑”是同一条设计哲学的两半。

*(COMMON) 的历史顺带一句:远古时代未初始化全局变量会编译成”公共符号”(FORTRAN commons 的遗产),同名符号还能被链接器合并。GCC 10 起默认 -fno-common,它们直接进 .bss,*(COMMON) 这行成了保险——留着无害,删掉后老代码会报”no memory region specified”之类的怪错。

到这里,.ld 的每一块都能对应到启动文件的某个零件了。但退一步看还有几个”设计层”的问题:为什么 PC 上写了一辈子 C 从没碰过链接脚本?为什么偏要”编译器出碎片、链接器分房”这种两段式?

L3:想得透——为什么非得是这副骨架

到这一层,问题变成:换个设计行不行?代价是什么?

本质:链接脚本到底是什么

去掉所有术语,链接脚本是一份把无名碎片变成有址地图的分房方案。

编译器的输出(.o 文件)是一堆碎片:每段代码和数据都打包成标准集装箱(输入段),但没有一个是定了地址的——.text 只知道”我是代码”,不知道自己该在 0x08000000 还是 0x20000000。链接器的工作是拼图加分配:把所有工程的 .o 摸一遍,按 .ld 的规则给每个集装箱分配住址,并在最终图纸(.map 文件)上登记每个符号的门牌号。

所以那个光秃秃的 .、MEMORY 的预算、AT> 的两址、KEEP 的免检牌,全是这张规划图上的标注工具,各自回答规划问题:笔尖在哪、地块多大、要不要两处房产、拆迁名单豁免谁。启动文件按图施工,链接脚本画图。 两者的分工,从 Reset_Handler 里第一个 ldr r0, =_sdata 就咬合上了。

为什么 PC 上你从没见过它

写 Linux 程序十年,没写过一个字节的链接脚本——为什么到了 Cortex-M 上它成了必需品?

因为 PC 上有一整套机构替你把图画好了:操作系统定义了内存布局规范(ABI),内核的 ELF 加载器负责运行时分房。可执行文件里每个段的 VMA/LMA 映射,是加载器在 execve 时按 program header 逐段 mmap 进内存完成的。你现在能对上号了:PC 上干”搬运 .data”这活的,是操作系统的加载器;裸机上没有加载器,干这活的就是你自己那 20 行 Reset_Handler 搬运循环。AT> 这个语法的存在意义,就是把”加载器”这个角色折叠进固件——让链接器在生成镜像时就按”将来有人会把我从仓库搬到货架”来排布。

而裸机上为什么没人能替你画图?因为知道内存地图的只有芯片手册。Cortex-M 内核卖几百家芯片厂,Flash 多大、RAM 起始在哪、有没有 CCM,每家每型都不同——工具链不可能预置。于是这份”人肉翻译芯片手册”的工作落到了每个工程头上:你的 .ld,本质是把 datasheet memory map 一章翻译成链接器认识的语法。不信可以跑 arm-none-eabi-ld --verbose,看看工具链自带的默认脚本长什么样——它存在,但对你的板子毫无意义,因为它假设的地址和你的芯片对不上。

为什么是”碎片 + 分房”的两段式

另一个备选设计听着更简单:编译器直接给每个函数、每个变量定死绝对地址,要什么链接器分配?早年系统真这么干过,死得很惨:任何一处插入一行代码,后面所有地址全体位移,于是牵一发动全身——改一个 .c,全工程重编译;两个 .o 想拼接,地址得提前手工协调好;更别说按需裁剪(--gc-sections)和搬运(AT>)这些进阶玩法,全都建立在”地址是链接期才定的”之上。

碎片化的真正威力在解耦:.o 里的代码对自己的最终住址一无所知,所以同一批 .o 可以链成 Flash 布局的裸机固件,也可以链成 RAM 布局的调试镜像,甚至链进 bootloader 的 app 槽——换的只是图,不动的是货。你在 启动文件篇 见过同一思想的硬件版(”把不变量做进硬件,把策略留在软件”),这是软件版:把内容做进 .o,把地址留在链接期。

旁证是方言的存在:ARMCC(Keil)管这份图叫 scatter 文件,IAR 叫 .icf,语法天差地别,概念一模一样——MEMORY 对应执行域,AT> 对应执行地址/加载地址分离,KEEP 对应不能被裁掉的根段。一个问题长出三种方言,说明挖到的是问题的本质形状,不是某个工具的脾气。

动手环节:亲手摸到这些机制

光看不算数,来两件事。

实验一(有交叉编译环境就能做):复现开头的死法,再用一个词救活。 找一个能构建的 STM32 工程(或基于 GPIO 篇 的裸机工程),先把链接脚本里 .isr_vector 的 KEEP() 临时删掉,然后给构建加上裁剪参数:

1
arm-none-eabi-gcc ... -ffunction-sections -fdata-sections -Wl,--gc-sections

先猜:arm-none-eabi-size 显示的 text 会小多少?(提示:F407 的向量表是 98 项 × 4 字节。)

猜完执行:

1
2
arm-none-eabi-size firmware.elf
arm-none-eabi-objdump -h firmware.elf | grep isr_vector

预期:size 的 text 少了 392 字节;第二条命令什么都搜不到——电话簿从地图上消失了。烧到板上(或跑仿真)就是开头那个”一个灯都不闪”。然后把 KEEP() 加回去,重新构建,一切复活。花十分钟主动踩一次,比将来在产线上追三天值。

实验二:查账——谁吃了我的 Flash。 加上这两参数重新构建:

1
-Wl,-Map=firmware.map --print-memory-usage

--print-memory-usage 会在链接完成时打印每个区的一行账:

1
2
3
Memory region         Used Size  Region Size  %age Used
           FLASH:       98340 B         1 MB      9.38%
             RAM:        1512 B       128 KB      1.15%

再找”大户”:firmware.map 里每个段、每个符号的地址和大小都在,但更快的姿势是让 nm 按大小排序:

1
arm-none-eabi-nm --size-sort -S firmware.elf | tail -10

输出按尺寸升序,最后十行就是最大的十个符号(通常是 printf 家族和某些数学库函数)。”程序为什么超了”这类问题从此有据可查——顺手把 RAM 的 LENGTH 改成 2K 再链接一次,亲眼看一眼 region RAM overflowed 的报错长什么样,下次线上见它就不慌了。

自测三题,能答出来说明真懂了:一是 --gc-sections 下 .text 为什么不需要 KEEP 而 .isr_vector 必须?(提示:想想”可达”的定义从哪里出发,答案在 L2 攻防那节。)二是 C 代码里想拿到 _ebss - _sbss,为什么必须先 extern 再取地址?(门牌号的本质,L2 第二节。)三是为什么 PC 上写 C 十年可以不碰链接脚本,裸机上非写不可?(谁在 PC 上扮演了 Reset_Handler 搬运循环的角色?L3 第二节。)

收尾地图

把整篇收进一张速查表——出问题时去哪看:

症状 / 问题去哪看一句话理由
region FLASH overflowed by N bytes.map、nm --size-sort预算超了,找大户
region RAM overflowed.bss 大数组、._user_heap_stack堆栈预算被挤占
上电完全无反应,bin 无故变小.isr_vector 的 KEEP()gc-sections 扫走了电话簿
变量初值是随机数AT>、LOADADDR、_sidata搬运握手失败
C++ 全局构造静默不执行.init_array 段和它的 KEEP第三条握手通道断了
想知道变量到底住哪objdump -h、.map 文件VMA 是住处,LMA 是仓库
换了芯片型号链接就乱MEMORY 的 ORIGIN/LENGTH.ld 是芯片手册的翻译
bootloader 跳 app 后中断全失灵app 的 .ld ORIGIN + VTOR图和电话簿都搬了家

再留三张”知道就好、用到再来”的小抄:一是不想被复位的变量,自定义一个 .noinit 段(.noinit (NOLOAD) : { *(.noinit) } > RAM)放软复位后想留住的现场——.bss 每次上电清零,NOLOAD 段谁也不碰;二是想让某个函数跑得快,__attribute__((section(".ramfunc"))) 加一条 RAM 分房规则(记得配搬运——.ramfunc 是 .data 的代码版,同样 > RAM AT> FLASH);三是函数指针表想固化在 Flash 固定偏移处(放出厂标定、序列号),自定义段加 . 赋值锁死地址。

下一站,两条路任选。往工程的深处走:精读 .map 文件——本文所有概念(分房、门牌号、LMA/VMA)在里面逐行对号,从此”程序为什么这么大”“这个变量链接到哪了”变成查表题;再往后是 bootloader 的双槽设计(A/B 区、ORIGIN 改到 0x08010000、配套 VTOR),那是把本文的分房方案用到多份固件共存的进阶棋局。往系统的深处走:回去重看 启动文件篇 的 Reset_Handler——这次每行 ldr r0, =_sdata 背后你看到的不再是神秘符号,而是一张你亲手能画出来的规划图。图看清了,这块板子的内存,才真正从”工具链说了算”变成”你说了算”。

本文由作者按照 CC BY 4.0 进行授权