链接器与链接器脚本:从原理到实践¶
引言¶
如果你读过笔者的《深入理解C/C++编译原理》系列博客,相信对链接器已经有了初步的认识。简单回顾一下:编译器负责将源代码转换为目标文件,而链接器则是构建流程中的最后一环,它将这些目标文件组合成最终的可执行程序。
相关阅读:
在嵌入式开发中,链接器的重要性往往被低估。实际上,链接器的配置和优化策略直接影响着程序的代码大小、运行性能,甚至决定了程序能否正常启动。本文将带你深入理解链接器的工作原理,并重点讲解链接脚本的编写与启动代码的实现,帮助你构建更小、更快、更可靠的嵌入式程序。
一、链接器的基本工作原理¶
在深入链接脚本之前,让我们先明确链接器到底做了什么。理解这些基本概念,将帮助我们更好地编写和调试链接脚本。
1.1 链接器的四大核心任务¶
链接器的工作看似神秘,实际上可以归纳为以下四个核心任务:
(1)符号解析(Symbol Resolution)
当你在一个文件中调用另一个文件定义的函数时,编译器只知道这个函数的名字,但不知道它的实际地址。链接器的职责就是找到这个函数的实际定义,并建立连接:
// file1.cpp
void printMessage() {
// 函数实现
}
// file2.cpp
extern void printMessage(); // 这只是一个声明
void main() {
printMessage(); // 链接器负责找到实际的函数地址
}
(2)地址分配(Address Assignment)
链接器为程序中的所有代码和数据分配最终的内存地址。这个过程看似简单,但在嵌入式系统中却至关重要——因为不同类型的内存(FLASH、RAM)有不同的物理地址和访问特性。
(3)段合并(Section Merging)
编译器生成的每个目标文件都包含多个段(section),比如 .text(代码)、.data(初始化数据)、.bss(未初始化数据)等。链接器会将所有文件中相同类型的段合并在一起,形成最终可执行文件的统一布局。
(4)库链接(Library Linking)
程序通常会使用标准库或第三方库。链接器负责从这些库中提取需要的代码,并将它们整合到最终的可执行文件中。
二、为什么嵌入式系统需要自定义链接脚本?¶
理解了链接器的基本工作后,你可能会问:编译器和链接器不是自动完成这些工作吗?为什么还需要我们手动编写链接脚本?这是因为——嵌入式系统是多样性的,有时还要批量生产,需要我们顾虑这些细节来进行成本调优。
2.1 嵌入式系统的内存约束¶
在嵌入式系统中,内存是稀缺且分散的资源,与通用计算机有着本质的不同:
- 启动向量必须放在特定地址:处理器复位后会从固定地址读取中断向量表
- 程序代码必须存放在 FLASH:FLASH 是非易失性存储,断电后代码不会丢失
- 只读常量应该驻留 FLASH:充分利用 FLASH 空间,节省宝贵的 RAM
- 运行时变量需要放在 RAM:RAM 可读可写,但断电后数据会丢失
- C++ 全局对象需要正确构造:构造函数的调用需要专门的启动代码支持
- 堆栈与堆也要正确配置:确保程序有足够的栈空间和堆空间
编译器和链接器的默认策略是为通用系统设计的,根本无法满足这些硬件约束。这就是为什么我们需要链接脚本(linker script)——它是我们告诉链接器"在这个特殊硬件上应该如何组织内存"的配置文件。
2.2 链接脚本的核心概念¶
在编写链接脚本之前,让我们先理解几个最重要的概念:
MEMORY 区域定义 定义物理内存区域的名称、起始地址和长度。例如:
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS 输出节定义 告诉链接器把各个输入节(来自目标文件)如何组织成输出节,并放到哪个 MEMORY 区域:
符号(Symbols)导出 链接脚本可以定义符号,这些符号在启动代码中会被使用,例如:
_sdata/_edata:.data段的起始和结束地址_sbss/_ebss:.bss段的起始和结束地址_estack:栈顶地址
常用控制指令
KEEP():防止某些节被优化掉(如中断向量表)PROVIDE():提供一个符号的默认值ASSERT():在链接时进行约束检查
2.3 不同段的作用¶
理解不同段的作用,对于正确编写链接脚本至关重要:
.text— 可执行代码段,通常放在 FLASH 中.rodata— 只读常量段(如字符串字面量),也放在 FLASH 中.data— 已初始化的全局/静态变量。这个段很特殊:它的内容在链接时位于 FLASH(因为需要保存初始值),但在运行时必须被复制到 RAM(因为变量需要可写).bss— 未初始化的全局/静态变量,只存在于 RAM 中,启动时需要被清零。由于不需要保存初始值,.bss不占用 FLASH 空间
三、实战:编写一个完整的链接脚本¶
理论讲完了,让我们动手编写一个真实可用的链接脚本。这个例子针对 ARM Cortex-M 微控制器,但原理适用于所有嵌入式平台。
3.1 最小可用链接脚本¶
/* minimal-arm.ld - ARM Cortex-M 最小链接脚本 */
/* 指定程序入口点 */
ENTRY(Reset_Handler)
/* 定义物理内存布局 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 计算栈顶地址(RAM 的末尾) */
_estack = ORIGIN(RAM) + LENGTH(RAM);
/* 定义输出节的布局 */
SECTIONS
{
/* 中断向量表必须放在 FLASH 起始处 */
.isr_vector :
{
KEEP(*(.isr_vector)) /* 防止被优化掉 */
} > FLASH
/* 程序代码和只读数据 */
.text :
{
*(.text*) /* 所有代码 */
*(.rodata*) /* 只读常量 */
*(.gcc_except_table) /* 异常处理表 */
*(.eh_frame) /* 栈展开信息 */
/* 保留初始化和析构函数指针 */
KEEP(*(.init))
KEEP(*(.fini))
KEEP(*(.init_array*))
KEEP(*(.fini_array*))
} > FLASH
/* 已初始化数据段(需要从 FLASH 拷贝到 RAM) */
.data : AT(ADDR(.text) + SIZEOF(.text))
{
_sdata = .; /* 标记 RAM 中的起始地址 */
*(.data*)
_edata = .; /* 标记 RAM 中的结束地址 */
} > RAM
/* 记录 FLASH 中数据段的位置(用于拷贝) */
_sidata = LOADADDR(.data);
/* 未初始化数据段(需要清零) */
.bss :
{
_sbss = .; /* 标记起始地址 */
*(.bss*)
*(COMMON)
_ebss = .; /* 标记结束地址 */
} > RAM
/* 导出堆的起始位置 */
_end = .;
PROVIDE(end = _end);
}
/* 导出栈顶符号,供启动文件使用 */
PROVIDE(_estack = _estack);
3.2 脚本解析¶
这个脚本的关键点:
- 中断向量表(
.isr_vector)必须放在 FLASH 的最开始,因为处理器复位后会从固定地址读取它 - 代码段(
.text)紧随其后,包含所有可执行代码和只读常量 .data段的双重地址:AT(ADDR(.text) + SIZEOF(.text))指定加载地址(LMA),即数据在 FLASH 中的位置> RAM指定运行地址(VMA),即数据运行时应该在 RAM 中的位置- 启动代码需要将数据从 LMA 复制到 VMA
- 符号导出:
_sdata、_edata、_sbss、_ebss等符号会被启动代码使用
四、启动代码:让链接脚本活起来¶
有了链接脚本,程序的内存布局就确定了。但这还不够——我们需要启动代码来完成关键的初始化工作,让程序能够正确运行。
4.1 启动代码的完整流程¶
当处理器复位后,会跳转到 Reset_Handler 执行。这是整个程序的第一段代码,它的职责是:
- 禁用中断(可选,取决于平台)
- 拷贝
.data段:将已初始化数据从 FLASH 复制到 RAM - 清零
.bss段:将未初始化数据区域清零 - 调用 C++ 全局构造函数(如果使用 C++)
- 设置堆栈指针
- 跳转到
main()函数
4.2 启动代码实现示例¶
/* startup.c - ARM Cortex-M 启动代码 */
#include <stdint.h>
/* 链接脚本导出的符号(外部符号) */
extern uint32_t _sidata; /* .data 在 FLASH 中的起始地址 */
extern uint32_t _sdata; /* .data 在 RAM 中的起始地址 */
extern uint32_t _edata; /* .data 在 RAM 中的结束地址 */
extern uint32_t _sbss; /* .bss 的起始地址 */
extern uint32_t _ebss; /* .bss 的结束地址 */
/* C++ 构造函数数组(由链接脚本填充) */
extern void (*__init_array_start[])(void);
extern void (*__init_array_end[])(void);
/* main 函数声明 */
extern int main(void);
/**
* 复位处理函数 - 程序的真正入口
*/
void Reset_Handler(void) {
uint32_t *src, *dst;
/* 1. 拷贝 .data 段从 FLASH 到 RAM */
src = &_sidata;
dst = &_sdata;
while (dst < &_edata) {
*dst++ = *src++;
}
/* 2. 清零 .bss 段 */
dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
/* 3. 调用 C++ 全局对象的构造函数 */
for (void (**p)() = __init_array_start; p < __init_array_end; ++p) {
(*p)();
}
/* 4. 跳转到 main 函数 */
main();
/* 如果 main 返回,进入无限循环 */
while (1);
}
4.3 为什么需要这些步骤?¶
为什么要拷贝 .data? 已初始化的全局变量需要保存初始值,这些值存储在 FLASH 中(非易失)。但程序运行时需要修改这些变量,而 FLASH 通常是只读的,所以必须将数据复制到 RAM 中。
为什么要清零 .bss? 根据 C/C++ 标准,未初始化的全局变量应该初始化为 0。但为了节省 FLASH 空间,编译器不会在镜像中为这些变量存储 0 值,而是在启动时由程序负责清零。
为什么要调用构造函数? C++ 的全局对象需要在 main() 之前被构造。编译器会将这些构造函数的地址放在 .init_array 数组中,启动代码负责逐一调用它们。
五、C++ 开发的特殊考虑¶
如果你使用 C++ 进行嵌入式开发,还需要注意一些额外的问题。C++ 的高级特性(如全局对象、异常、RTTI)会给链接和启动过程带来额外的复杂性。
5.1 全局对象构造顺序¶
C++ 有一个著名的"静态初始化顺序问题"(Static Initialization Order Fiasco):
- 同一翻译单元内:对象的初始化顺序与它们在代码中的出现顺序一致
- 不同翻译单元之间:初始化顺序是未定义的!
这可能导致一个对象的构造函数中使用了另一个尚未构造的对象。解决方案:
- 避免全局对象之间的依赖(最推荐)
- 使用 Meyers 单例模式(函数局部静态变量)
- 使用
__attribute__((init_priority(N)))(GCC 扩展,谨慎使用)
// 使用 Meyers 单例避免初始化顺序问题
class Logger {
public:
static Logger& getInstance() {
static Logger instance; // 第一次调用时才构造
return instance;
}
private:
Logger() = default;
};
5.2 链接脚本中的 C++ 支持¶
确保链接脚本正确处理 C++ 相关的节:
.text : {
/* ... */
KEEP(*(.init_array*)) /* 构造函数指针数组 */
KEEP(*(.fini_array*)) /* 析构函数指针数组 */
*(.eh_frame) /* 异常处理信息 */
*(.gcc_except_table) /* 异常处理表 */
}
如果这些节被错误地丢弃,构造函数将不会被调用,或者异常处理将失败。
5.3 优化建议¶
嵌入式 C++ 开发的黄金法则:能不用的高级特性就不用。
- 禁用异常:使用
-fno-exceptions编译选项(异常处理会显著增加代码体积) - 禁用 RTTI:使用
-fno-rtti编译选项(运行时类型信息很少用到) - 避免动态内存分配:嵌入式系统通常没有完整的堆管理
- 将常量放入 FLASH:使用
const和constexpr,让数据进入.rodata段
六、链接优化技巧与最佳实践¶
掌握了基础知识后,让我们看看如何进一步优化链接过程,减小代码体积,提高启动速度。
6.1 函数级链接优化¶
使用编译器的分段选项和链接器的垃圾回收功能:
# 编译时:将每个函数和数据放入独立的段
arm-none-eabi-gcc -ffunction-sections -fdata-sections ...
# 链接时:移除未使用的段
arm-none-eabi-gcc -Wl,--gc-sections ...
这样,如果程序没有调用某个函数,链接器会自动将其从最终镜像中移除。
6.2 内存使用优化¶
技巧 1:把常量放入 FLASH
技巧 2:避免大数组的非零初始化
// 不好:占用 10KB FLASH 空间(在 .data 段)
uint8_t buffer[10240] = {1, 2, 3, ...};
// 好:不占用 FLASH 空间(在 .bss 段),启动时在 main() 中初始化
uint8_t buffer[10240];
技巧 3:使用 ASSERT 进行约束检查
6.3 启动性能优化¶
测量构造函数开销 C++ 全局对象的构造可能非常耗时。你可以:
- 使用 DWT 性能计数器测量启动时间
- 检查
.map文件,查看哪些函数占用了大量空间 - 在构造函数中避免复杂操作(文件 I/O、动态分配、外设初始化)
延迟初始化 将不紧急的初始化推迟到 main() 或首次使用时: