2、链接脚本深度解析:GNU LD语法、内存区域定义、段布局
链接脚本,说白了就是告诉链接器:你的芯片内存长什么样,代码该放哪,数据该放哪。很多做应用开发的同事可能几年都碰不到这东西,但做Bootloader和底层固件,这是绕不过去的坎。
我记得刚入行那会儿,接手一个老项目,编译出来的固件死活跑不起来。折腾了两天,最后发现是链接脚本里RAM的起始地址写错了。嗯,从那以后,我养成了一个习惯——拿到一个新平台,第一件事就是把链接脚本从头到尾读一遍。
2.1 GNU LD语法速览
GNU LD的链接脚本,语法其实不复杂。核心就几个关键字:ENTRY、MEMORY、SECTIONS。我个人习惯把链接脚本分成三大块来写:
- 入口点声明:告诉CPU第一条指令从哪开始执行
- 内存区域定义:描述芯片的物理内存布局
- 段布局规则:把各种段(.text、.data、.bss)安排到对应的内存区域
一个最简的链接脚本骨架长这样:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : { *(.text*) } > FLASH
.data : { *(.data*) } > RAM AT> FLASH
.bss : { *(.bss*) } > RAM
}
你可能会问:为什么.data段后面有个AT> FLASH?这个问题我后面会细讲,先记住一个结论——初始化数据在运行时必须待在RAM里,但它的初始值得从Flash拷贝过来。
2.2 内存区域定义:MEMORY命令
MEMORY命令用来描述目标芯片的物理内存。每个区域需要指定:名字、属性、起始地址、长度。
属性这块,常见的有:
| 属性 | 含义 | 典型用途 |
|---|---|---|
| r | 只读 | Flash、ROM |
| w | 可写 | RAM |
| x | 可执行 | 存放代码的区域 |
| rwx | 可读可写可执行 | 内部SRAM(某些场景) |
我曾经在一个项目里踩过坑:把RAM的属性写成了rw,忘了加x。结果代码里有个函数被放到了RAM里执行,一运行就进HardFault。查了半天才发现是属性没给对。
实际项目中,内存区域往往不止两个。比如有些芯片有TCM、SRAM、DDR等多个区域:
MEMORY
{
ITCM (rwx) : ORIGIN = 0x00000000, LENGTH = 16K
DTCM (rw) : ORIGIN = 0x20000000, LENGTH = 16K
SRAM (rw) : ORIGIN = 0x20004000, LENGTH = 112K
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
}
2.3 段布局:SECTIONS命令
SECTIONS是链接脚本的核心。它定义了每个段放在哪个内存区域,以及段与段之间的顺序。
2.3.1 .text段:代码和只读数据
.text段存放的是程序代码、字符串常量、跳转表等只读数据。它通常被放在Flash里。
.text :
{
. = ALIGN(4);
_stext = .;
*(.isr_vector) /* 中断向量表 */
*(.text*) /* 程序代码 */
*(.rodata*) /* 只读数据 */
. = ALIGN(4);
_etext = .;
} > FLASH
这里有几个细节要注意:
. = ALIGN(4):保证段起始地址4字节对齐。ARM Cortex-M要求中断向量表必须对齐到256字节边界,但一般段内对齐4字节就够了。_stext和_etext:这两个符号是给启动代码用的。启动代码需要知道.text段的起始和结束地址,用来做校验或者拷贝。*(.isr_vector):中断向量表必须放在最前面,这是CPU硬件决定的。
2.3.2 .data段:已初始化的全局变量
.data段存放的是有初始值的全局变量和静态变量。这些变量在运行时必须在RAM里,但初始值得从Flash拷贝过来。
.data : ALIGN(4)
{
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT> FLASH
注意这个AT> FLASH,它表示:这个段的运行时地址在RAM,但加载地址在Flash。链接器会在Flash里给.data的初始值预留空间,启动代码再把这些值从Flash拷贝到RAM。
我曾经见过一个同事,写链接脚本时忘了加AT> FLASH。结果编译出来的固件,.data段的初始值全丢了,全局变量上电全是随机值。排查了整整一天。
2.3.3 .bss段:未初始化的全局变量
.bss段存放的是没有初始值或者初始值为0的全局变量。它只需要在RAM里占空间,不需要在Flash里存初始值。
.bss (NOLOAD) : ALIGN(4)
{
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
(NOLOAD)告诉链接器:这个段不需要加载到目标文件里,运行时直接清零就行。启动代码里会用_sbss和_ebss来遍历清零。
.bss段不占用Flash空间,但会占用RAM空间。如果你的芯片RAM很小,注意控制全局变量的数量。
2.4 段布局全景图
下面这张图展示了典型的嵌入式固件在Flash和RAM中的布局关系:
2.5 链接脚本中的符号导出
链接脚本里定义的符号(比如_stext、_sdata),在C代码里可以直接用extern声明后访问。这是启动代码和链接脚本之间的通信桥梁。
/* 在C代码中访问链接脚本导出的符号 */
extern uint32_t _stext;
extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
void SystemInit(void)
{
/* 拷贝.data段 */
uint32_t *src = &_sidata; /* 注意:这个符号需要在链接脚本里额外定义 */
uint32_t *dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
/* 清零.bss段 */
dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
}
&_sdata直接当成地址值来用。记住,链接脚本里定义的符号,在C语言里取地址才能拿到它的值。直接使用_sdata得到的是地址本身,不是地址里的内容。
2.6 进阶技巧:自定义段
有时候我们需要把特定的函数或变量放到指定的地址。比如Bootloader里的升级标志位,必须放在Flash的固定位置。
在C代码里用__attribute__指定段:
/* 强制放到Flash的0x0800FF00位置 */
const upgrade_flag_t __attribute__((section(".upgrade_flag"))) upgrade_flag = {
.magic = 0xA5A5A5A5,
.version = 0x0100
};
然后在链接脚本里安排这个段:
.upgrade_flag : ALIGN(4)
{
KEEP(*(.upgrade_flag))
} > FLASH
/* 或者指定绝对地址 */
.upgrade_flag 0x0800FF00 :
{
KEEP(*(.upgrade_flag))
} > FLASH
KEEP关键字很重要。链接器默认会优化掉没有被引用的段,但升级标志位这种数据,没有代码直接引用它,不加KEEP就会被丢掉。
2.7 避坑指南
做链接脚本这些年,我总结了几条血泪教训:
- 对齐问题:ARM芯片要求中断向量表256字节对齐,否则上电直接跑飞。我建议所有段都至少4字节对齐。
- 段顺序:中断向量表必须放在Flash的最开头。如果前面插了别的段,CPU会从错误的地方开始执行。
- NOLOAD别漏:
.bss段一定要加(NOLOAD),否则链接器会在生成的二进制文件里给.bss段填充0,白白增加固件体积。 - 栈和堆的位置:栈通常放在RAM的末尾(向下生长),堆放在
.bss之后(向上生长)。两者之间留够余量,防止碰撞。
嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑——链接器只是按照你的规则,把各种段安排到内存里。规则写对了,固件就能跑;规则写错了,调试能让你怀疑人生。