1. 链接脚本概述:程序布局的精确控制
在嵌入式开发和系统级编程中,链接脚本(Linker Scripts)是控制程序内存布局的核心工具。它就像建筑师的蓝图,决定了代码和数据在内存中的精确位置。理解链接脚本的工作原理,对于开发Bootloader、操作系统内核以及资源受限的嵌入式系统至关重要。
ELF(Executable and Linkable Format)文件是链接器处理的基本单元。编译产生的.o文件、共享库以及最终的可执行文件都属于ELF文件范畴。这些文件内部以section(节)为组织单位,常见的如.text(代码)、.data(初始化数据)、.bss(未初始化数据)等。每个section都有属性标志,例如SHF_ALLOC表示需要加载到内存,SHF_EXECINSTR表示包含可执行指令。
链接过程中,链接器会将多个输入文件的同名section合并,并根据链接脚本的指示重新布局。最终生成的可执行文件中,这些section会被进一步组织成segment(段),这是程序运行时加载的基本单位。例如,一个PT_LOAD段可能同时包含.text和.rodata section,因为它们都具有只读属性。
内存布局中有两个关键概念需要区分:
- VMA(Virtual Memory Address):程序运行时访问该内容使用的虚拟地址
- LMA(Load Memory Address):该内容在存储介质中的加载地址
在大多数桌面系统中,VMA和LMA是相同的。但在嵌入式系统中,它们经常不同。例如,代码可能存储在Flash中(LMA),但需要在RAM中执行(VMA)。这种情况下,启动代码需要负责将数据从LMA复制到VMA。
c复制SECTIONS {
.text 0x08000000 : { *(.text) } /* VMA=LMA=Flash地址 */
.data 0x20000000 : AT(0x08010000) { *(.data) } /* VMA=RAM, LMA=Flash */
}
这个简单示例展示了如何将.data段设置为在RAM中运行,但实际存储在Flash中。系统启动后,需要将.data从Flash的0x08010000复制到RAM的0x20000000。
提示:理解VMA和LMA的区别是掌握链接脚本的关键。在嵌入式开发中,这种分离设计非常常见,特别是对于需要从慢速存储器加载到快速存储器执行的情况。
2. 链接脚本语法基础与简单示例
链接脚本使用一种专门设计的领域特定语言(DSL),其语法简洁但表达能力强大。脚本由一系列命令组成,每条命令要么是控制语句(如SECTIONS、MEMORY),要么是符号赋值表达式。语句之间用分号分隔,空白字符仅用于分隔作用。
脚本支持C风格的注释/* ... */,这在复杂脚本中非常有用。良好的注释习惯可以大大提高脚本的可维护性,特别是在团队协作项目中。建议在修改位置计数器或定义关键符号时都添加注释,说明设计意图。
让我们看一个完整的简单示例:
c复制/* 简单链接脚本示例 */
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS {
/* 代码段放在FLASH开头 */
.text : {
*(.text) /* 所有输入文件的.text段 */
*(.text.*) /* 编译器生成的.text.*段 */
. = ALIGN(4); /* 4字节对齐 */
_etext = .; /* 定义代码段结束符号 */
} > FLASH
/* 初始化数据段 */
.data : {
_sdata = .; /* 数据段开始 */
*(.data)
*(.data.*)
. = ALIGN(4);
_edata = .; /* 数据段结束 */
} > RAM AT > FLASH /* VMA在RAM,LMA在FLASH */
/* 未初始化数据段 */
.bss : {
_sbss = .;
*(.bss)
*(.bss.*)
*(COMMON) /* 未初始化的全局变量 */
. = ALIGN(4);
_ebss = .;
} > RAM
}
这个脚本展示了几个关键概念:
- MEMORY命令定义了系统的物理内存布局
- SECTIONS命令控制输出段的组织
- 位置计数器(.)用于跟踪当前地址
- ALIGN用于地址对齐
- 符号定义(_etext等)用于在代码中引用
链接器处理脚本的基本流程是:
- 解析MEMORY定义,建立内存区域模型
- 按SECTIONS顺序处理输入段
- 根据位置计数器分配地址
- 生成最终的ELF文件
注意:通配符*(.text)会收集所有输入文件的.text段。如果需要对特定文件的段特殊处理,可以单独列出,如startup.o(.text)。
3. 核心命令详解:从基础到高级
3.1 基础控制命令
ENTRY命令用于指定程序的入口点,即ELF头中的e_entry字段。如果没有指定,链接器会尝试使用_start符号作为默认入口。在裸机编程中,明确指定入口点是个好习惯:
c复制ENTRY(Reset_Handler)
这个命令告诉链接器,程序启动后应该首先执行Reset_Handler函数。在ARM Cortex-M系统中,这个函数通常位于启动文件中,负责初始化硬件和调用main()。
INPUT和GROUP命令控制输入文件的处理方式。INPUT相当于在命令行直接指定目标文件,而GROUP用于处理库文件,特别是存在循环依赖时:
c复制INPUT(startup.o main.o)
GROUP(libgcc.a libc.a)
SEARCH_DIR命令可以添加库搜索路径,类似于编译器的-L选项:
c复制SEARCH_DIR("/opt/arm-none-eabi/lib")
OUTPUT_FORMAT和OUTPUT_ARCH命令指定输出文件的格式和目标架构:
c复制OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
这些命令在交叉编译时特别重要,确保生成的文件符合目标平台要求。
3.2 内存区域定义
MEMORY命令提供了对物理内存的精确描述。在嵌入式系统中,不同存储器(Flash、RAM、外部SDRAM等)通常有不同的属性和地址范围:
c复制MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
CCRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
}
每个内存区域可以指定属性:
- r:可读
- w:可写
- x:可执行
- a:可分配
- i:初始化
- l:与i相同
- !:反转后面所有属性
REGION_ALIAS命令可以为内存区域创建别名,提高脚本的可读性和可移植性:
c复制REGION_ALIAS("CODE_REGION", FLASH)
REGION_ALIAS("DATA_REGION", RAM)
3.3 段到内存区域的映射
在SECTIONS命令中,可以使用>region语法将输出段分配到特定内存区域:
c复制SECTIONS {
.text : { *(.text) } > FLASH
.data : { *(.data) } > RAM AT > FLASH
}
这里的.data段比较特殊:它的VMA(运行时地址)在RAM中,但LMA(加载地址)在FLASH中。这意味着:
- 程序映像中的.data段内容存储在Flash中
- 运行时需要将.data从Flash复制到RAM
- 程序访问.data时使用的是RAM地址
这种技术常用于嵌入式系统,因为RAM访问速度更快,但Flash容量更大且非易失。
技巧:使用AT>语法时,确保LMA区域有足够空间存储数据。链接器不会自动检查LMA区域的溢出,这可能导致隐蔽的错误。
4. 符号赋值与地址计算
链接脚本中的符号赋值本质上是地址标签的定义。这些符号不会占用任何存储空间,它们只是地址的别名。语法上支持C风格的赋值运算符(=、+=、-=等)。
位置计数器(.)是最常用的特殊符号,表示当前的输出地址。可以通过修改.来跳过或保留特定地址区域:
c复制. = 0x1000; /* 跳转到绝对地址0x1000 */
. += 0x100; /* 保留256字节空间 */
ALIGN函数用于地址对齐,这对于某些需要特定对齐的硬件功能(如MMU页表)非常重要:
c复制. = ALIGN(4K); /* 对齐到4K边界 */
定义符号时,可以使用简单的表达式:
c复制_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶地址 */
_heap_start = .; /* 堆起始地址 */
_heap_end = ORIGIN(RAM) + LENGTH(RAM) - 8; /* 堆结束地址 */
在C代码中,可以通过extern声明访问这些符号:
c复制extern uint32_t _estack;
extern uint8_t _heap_start[];
PROVIDE命令可以创建弱符号,只有在符号未被其他目标文件定义时才会生效:
c复制PROVIDE(_stack_size = 0x400);
这在提供默认实现同时允许用户覆盖的场景中非常有用。
5. SECTIONS命令深度解析
SECTIONS是链接脚本的核心,它定义了如何将输入段映射到输出段,以及这些输出段在内存中的布局。完整的SECTIONS语法非常灵活,可以满足各种复杂需求。
5.1 基本段定义
最简单的段定义只包含输入段描述:
c复制.text : { *(.text) }
这会收集所有输入文件的.text段,合并到输出文件的.text段中。更精细的控制可以指定特定文件的段:
c复制.text : {
startup.o(.text) /* 启动代码放在最前面 */
*(.text) /* 其他代码 */
}
KEEP命令可以防止链接器优化掉未被引用的段,这对于中断向量表等特殊段很重要:
c复制.isr_vector : {
KEEP(*(.isr_vector))
} > FLASH
5.2 段属性控制
可以为输出段指定属性,这些属性会影响生成的程序头:
c复制.text : { *(.text) } > FLASH AT > FLASH = 0xFFFF /* 填充未初始化的空间 */
段属性包括:
- CONTENTS:段有实际内容(默认)
- READONLY:只读
- CODE:代码
- DATA:数据
- LOAD:需要加载
- NOLOAD:不需要加载(如调试信息)
5.3 复杂段布局
对于需要精确控制布局的场景,可以使用位置计数器进行微调:
c复制SECTIONS {
. = 0x8000000;
.text : { *(.text) }
. = 0x20000000;
.data : { *(.data) }
.bss : { *(.bss) }
}
这个例子将代码段和数据段分别放在不同的地址空间,这在哈佛架构的系统中很常见。
5.4 特殊段处理
有些特殊段需要特别注意:
- .rodata:只读数据
- .preinit_array、.init_array、.fini_array:全局构造/析构函数表
- .ARM.exidx:ARM异常处理表
- .data.rel.ro:重定位只读数据
一个处理这些段的完整示例:
c复制SECTIONS {
.text : { *(.text) } > FLASH
.rodata : { *(.rodata) } > FLASH
.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(.init_array.*)))
KEEP(*(.init_array*))
PROVIDE_HIDDEN(__init_array_end = .);
} > FLASH
.fini_array : {
PROVIDE_HIDDEN(__fini_array_start = .);
KEEP(*(SORT(.fini_array.*)))
KEEP(*(.fini_array*))
PROVIDE_HIDDEN(__fini_array_end = .);
} > FLASH
}
6. 高级主题:PHDRS与符号版本
6.1 PHDRS命令详解
PHDRS命令用于精确控制程序头(Program Header)的生成。程序头告诉加载器如何将文件映射到内存。默认情况下,链接器会自动生成合理的程序头,但在某些特殊场景下需要手动控制。
基本语法:
c复制PHDRS {
headers PT_PHDR PHDRS;
text PT_LOAD FILEHDR PHDRS;
data PT_LOAD;
}
然后可以在SECTIONS中指定段属于哪个程序头:
c复制SECTIONS {
.text : { *(.text) } :text
.data : { *(.data) } :data
}
常见的程序头类型包括:
- PT_LOAD:可加载段
- PT_DYNAMIC:动态链接信息
- PT_INTERP:程序解释器路径
- PT_NOTE:辅助信息
6.2 符号版本控制
符号版本控制主要用于共享库,确保ABI兼容性。它通过版本脚本实现:
c复制VERS_1.0 {
global:
foo;
bar;
local:
*;
};
VERS_2.0 {
global:
baz;
} VERS_1.0;
这个例子中:
- VERS_1.0导出了foo和bar,其他符号都是local的
- VERS_2.0继承了VERS_1.0,并新增了baz
在链接时使用--version-script选项指定版本脚本:
bash复制gcc -shared -o libfoo.so foo.o --version-script=foo.map
7. 链接脚本中的表达式与内置函数
链接脚本支持丰富的表达式和内置函数,用于复杂的地址计算和布局控制。
7.1 表达式语法
链接脚本表达式与C语言类似,支持:
- 算术运算:+ - * / %
- 位运算:| & ~ ^ << >>
- 比较运算:== != < <= > >=
- 逻辑运算:&& || !
- 条件表达式:? :
表达式示例:
c复制__stack_size = DEFINED(__stack_size) ? __stack_size : 0x400;
7.2 常用内置函数
-
ALIGN/ALIGNOF:
c复制. = ALIGN(8); /* 对齐到8字节 */ -
SIZEOF/ADDR/LOADADDR:
c复制
_data_size = SIZEOF(.data); _data_vma = ADDR(.data); _data_lma = LOADADDR(.data); -
DEFINED/PROVIDE:
c复制
PROVIDE(__heap_start = .); -
MAX/MIN:
c复制
__stack_end = MIN(__stack_start + __stack_size, ORIGIN(RAM) + LENGTH(RAM)); -
SECTIONPROLOG/SECTIONEPILOG:
用于在段前后插入特定内容
8. 实战技巧与常见问题
8.1 嵌入式系统启动代码配合
链接脚本通常需要与启动代码紧密配合。典型的启动流程包括:
- 初始化栈指针
- 从Flash复制.data到RAM
- 清零.bss段
- 调用全局构造函数
- 进入main()
链接脚本需要提供必要的符号:
c复制SECTIONS {
.text : {
*(.isr_vector)
*(.text)
_etext = .;
} > FLASH
.data : {
_sdata = .;
*(.data)
_edata = .;
} > RAM AT > FLASH
.bss : {
_sbss = .;
*(.bss)
_ebss = .;
} > RAM
_stack_top = ORIGIN(RAM) + LENGTH(RAM);
}
启动代码中可以使用这些符号:
c复制extern uint32_t _sdata, _edata, _data_lma;
extern uint32_t _sbss, _ebss;
/* 复制.data段 */
uint32_t *src = &_data_lma;
uint32_t *dst = &_sdata;
while (dst < &_edata) *dst++ = *src++;
/* 清零.bss段 */
dst = &_sbss;
while (dst < &_ebss) *dst++ = 0;
8.2 常见问题排查
- 段重叠错误:检查MEMORY定义是否有足够空间,确认VMA/LMA没有冲突
- 未定义符号:确保所有必要的目标文件都参与链接,检查拼写错误
- 数据未正确初始化:确认.data的复制代码正确执行,LMA设置正确
- 性能问题:关键代码段考虑放到快速存储器(如CCRAM)
- 对齐问题:确保关键数据结构有正确的对齐约束
8.3 调试技巧
-
使用objdump查看段布局:
bash复制
arm-none-eabi-objdump -h firmware.elf -
查看符号地址:
bash复制
arm-none-eabi-nm -n firmware.elf -
生成映射文件:
bash复制
arm-none-eabi-ld -T script.ld -Map=firmware.map ... -
检查程序头:
bash复制
arm-none-eabi-readelf -l firmware.elf
9. 进阶应用场景
9.1 多核系统的内存布局
在多核系统中,链接脚本需要为每个核心定义独立的内存区域:
c复制MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
RAM0 (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
RAM1 (rwx) : ORIGIN = 0x20020000, LENGTH = 128K
}
SECTIONS {
.core0.text : { core0/*.o(.text) } > FLASH
.core0.data : { core0/*.o(.data) } > RAM0
.core1.text : { core1/*.o(.text) } > FLASH
.core1.data : { core1/*.o(.data) } > RAM1
}
9.2 内存保护单元(MPU)配置
对于使用MPU的系统,链接脚本需要配合定义保护区域:
c复制SECTIONS {
.privileged_code : {
*(.privileged_code)
} > FLASH
.privileged_data : {
*(.privileged_data)
} > RAM
.unprivileged_code : {
*(.unprivileged_code)
} > FLASH
.unprivileged_data : {
*(.unprivileged_data)
} > RAM
}
9.3 固件升级设计
对于支持固件升级的系统,链接脚本需要考虑A/B分区:
c复制MEMORY {
FLASH_A (rx) : ORIGIN = 0x08000000, LENGTH = 512K
FLASH_B (rx) : ORIGIN = 0x08080000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS {
.text : { *(.text) } > FLASH_A
.update_handler : {
KEEP(*(.update_handler))
} > FLASH_B
}
10. 性能优化技巧
10.1 关键代码段优化
将性能敏感的代码放到快速存储器:
c复制MEMORY {
ITCM (rx) : ORIGIN = 0x00000000, LENGTH = 16K
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
}
SECTIONS {
.critical_code : {
*(.critical_code)
} > ITCM
.text : {
*(.text)
} > FLASH
}
10.2 数据缓存优化
将频繁访问的数据放到紧耦合内存(TCM):
c复制MEMORY {
DTCM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20010000, LENGTH = 64K
}
SECTIONS {
.hot_data : {
*(.hot_data)
} > DTCM
.data : {
*(.data)
} > RAM
}
10.3 链接时优化(LTO)
结合LTO可以获得更好的代码布局:
bash复制gcc -flto -ffunction-sections -fdata-sections -Wl,--gc-sections -T script.ld
链接脚本可以配合LTO进行更精细的控制:
c复制SECTIONS {
.text : {
KEEP(*(.text.startup))
*(.text.hot.*)
*(.text.*)
} > FLASH
}
11. 跨平台兼容性设计
11.1 条件链接脚本
使用预处理器指令创建可配置的链接脚本:
c复制#ifdef STM32F4
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
#elif defined(STM32H7)
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 512K
}
#endif
11.2 通用符号定义
定义与平台无关的符号名称:
c复制PROVIDE(__flash_start = ORIGIN(FLASH));
PROVIDE(__flash_size = LENGTH(FLASH));
PROVIDE(__ram_start = ORIGIN(RAM));
PROVIDE(__ram_size = LENGTH(RAM));
这样应用程序代码可以统一使用这些符号,而不需要关心具体平台。
12. 工具链集成
12.1 与构建系统集成
在Makefile中使用链接脚本:
makefile复制LDFLAGS += -T$(LINKER_SCRIPT) -Wl,-Map=$@.map
在CMake中:
cmake复制target_link_options(firmware PRIVATE
-T${CMAKE_SOURCE_DIR}/scripts/linker.ld
-Wl,-Map=${PROJECT_BINARY_DIR}/firmware.map)
12.2 自动化测试
编写脚本验证关键约束:
bash复制# 检查.text段不超过Flash大小
text_size=$(arm-none-eabi-size -A firmware.elf | awk '/.text/ {print $2}')
flash_size=0x80000
if (( text_size > flash_size )); then
echo "Error: .text section too large"
exit 1
fi
13. 安全考虑
13.1 关键段保护
防止关键段被意外覆盖:
c复制SECTIONS {
.secure_code : {
KEEP(*(.secure_code))
} > FLASH
.secure_data : {
KEEP(*(.secure_data))
} > RAM
}
13.2 栈保护
添加栈溢出检测区域:
c复制SECTIONS {
.stack (NOLOAD) : {
. = ALIGN(8);
_stack_start = .;
. += __stack_size;
_stack_end = .;
. += 256; /* 保护区域 */
_stack_guard = .;
} > RAM
}
14. 维护与文档
14.1 注释规范
良好的注释应该解释:
- 内存区域的用途
- 关键符号的意义
- 特殊布局的设计原因
- 平台特定的考虑
c复制/*
* 内存布局定义
* FLASH: 主程序存储,支持XIP
* RAM: 运行时数据,分为.data/.bss/.heap/.stack
*/
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
14.2 版本控制
将链接脚本与代码一起版本控制,并在修改时:
- 添加有意义的提交信息
- 考虑向后兼容性
- 必要时提供迁移指南
15. 未来发展趋势
随着技术的发展,链接脚本也在不断演进:
- 更强大的表达式支持
- 更好的调试信息集成
- 对新型存储器(如MRAM、ReRAM)的支持
- 与高级语言特性的更好配合(如Rust的链接器脚本需求)
理解这些基础概念将帮助开发者适应未来的变化,并能够根据项目需求定制最适合的内存布局方案。