链接器与链接
链接器介绍
链接器在编译流程中的作用
① 编写的 .c/.s 源文件,到最终能跑的程序,要经过 4 步:
- 预处理:处理 #include、#define 等宏指令(比如 gcc -E)
- 编译:把高级语言翻译成汇编 / 翻译成机器码(比如 gcc -c 或 as),生成目标文件(.o)
- 汇编:把汇编指令翻译成机器码,同样生成目标文件
- 链接:把多个 .o 文件、库文件(.a/.so)合并成一个完整的可执行文件,这一步就是链接器的工作。
② 目标文件(.o)里有什么?
- 代码段(.text):你的指令机器码
- 数据段(.data/.bss):全局变量、未初始化变量
- 符号表(Symbol Table):函数名、变量名和它们的地址(比如 printf 这个符号在哪个文件里)
③ 链接器的核心任务,就是把这些分散的 .o 文件“拼起来”:
- 合并所有 .text/.data/.bss 段
- 解决符号引用:比如 main.c 里调用了 string.c 的 strlen,链接器要找到 strlen 的地址,填到调用指令里
- 生成符合操作系统要求的格式(比如 Linux 的 ELF、Windows 的 PE)
④ 早期操作系统没有链接器,加载器(Loader,后来 ld 就成了链接器的代名词)直接处理所有工作。但随着程序越来越复杂:
- 一个程序有上百个源文件,不可能一次性编译
- 标准库、第三方库需要复用
- 不同文件的地址需要统一规划
于是链接器就成了必备工具,专门负责“把零件拼成完整程序”。
ld 命令详解
ld 是 GNU 工具链里的链接器,也是你开发 BenOS 用的 aarch64-linux-gnu-ld 的基础版本。
① 最简用法
ld -o mytest test1.o test2.o -lc
- -o mytest:输出文件叫 mytest
- test1.o test2.o:要链接的目标文件
- -lc:链接 C 标准库(libc.a),这样你用 printf 才不会报错
② 常用选项
| 选项 | 作用 | 开发 OS 时的用途 |
|---|---|---|
| -T | 指定链接脚本 | 必须使用,自定义内核内存分段布局、加载地址 |
| -Map | 输出符号表映射文件 | 查看内核函数、全局变量实际内存地址,方便调试排错 |
| -o | 指定输出可执行文件 | 生成内核 ELF 镜像文件,用于虚拟机 / 开发板运行 |
| -b | 指定目标文件格式 | 统一汇编、C 文件编译后的目标文件格式,适配架构 |
| -e | 指定程序入口执行点 | 设定内核启动入口函数,替代默认入口 |
| -l | 链接指定库文件 | 链接自研底层静态库,引入基础驱动、工具函数 |
| -L | 添加库文件搜索路径 | 自定义库存放目录,让链接器找到自制内核库 |
| -S | 剔除调试符号信息 | 精简内核镜像体积,制作正式发布内核 |
| -s | 清除所有符号信息 | 极致压缩内核大小,用于资源极小的嵌入式内核 |
| -t | 显示处理的输入文件 | 排查链接顺序错误,确认目标文件是否正常参与链接 |
| -Ttext | 指定代码段起始地址 | 快速设定内核代码段加载基址,简易场景替代链接脚本 |
| -Tdata | 指定数据段起始地址 | 单独划定全局初始化变量内存区域 |
| -Tbss | 指定未初始化段起始地址 | 划定栈、全局零初始化变量内存区间 |
| -Bstatic | 仅使用静态库 | 内核强制静态链接,不依赖系统动态库,独立运行 |
| -Bdynamic | 仅使用动态库 | 内核开发几乎不用,无动态链接运行环境 |
| -defsym | 自定义全局符号 | 链接阶段定义内存地址、硬件参数等全局宏地址 |
链接脚本
链接脚本概述
定义:链接脚本是控制链接器行为的配置文件,核心作用是定义目标文件如何合并为可执行文件、各段如何在内存中布局。
默认行为:不通过 -T 指定脚本时,链接器会使用内置脚本;但操作系统 / 裸机开发必须自定义脚本,手动控制内存地址与段排布。
一个简单的链接程序
① 核心:SECTIONS 命令
SECTIONS {} 是链接脚本的根命令,描述输出文件的内存布局,告诉链接器:
- 如何把输入文件的段映射到输出文件的段
- 如何将输入段合并为输出段
- 如何将输出段放入程序 / 进程地址空间
SECTIONS 内支持三种命令:
- ENTRY 命令:设置程序入口符号
- 赋值语句:给符号赋值(定义地址标记)
- 输出段描述语句:定义输出段的规则
② 最简示例脚本
SECTIONS
{
. = 0x10000; // 位置计数器(LC)设为0x10000
.text : { *(.text) } // 代码段:合并所有输入文件的.text段
. = 0x8000000; // 位置计数器强制跳转到0x8000000
.data : { *(.data) } // 数据段:合并所有输入文件的.data段
.bss : { *(.bss) } // BSS段:合并所有输入文件的.bss段
}
逐行解析:
- . = 0x10000:. 是当前位置计数器(LC),代表当前正在处理的内存地址,这里把它设为 0x10000。
- .text : { *(.text) }:定义输出段 .text,内容是所有输入文件(* 通配所有 .o 文件)的 .text 段,合并后从 0x10000 开始存放。
- . = 0x8000000:强制将位置计数器跳转到 0x8000000,跳过中间地址。
- .data/.bss:同理,分别存放数据段和未初始化数据段。
设置入口点
定义:程序执行的第一条指令地址,称为入口点(entry point)。
设置方式:在链接脚本中使用 ENTRY(symbol) 命令,指定 symbol 为入口函数。
入口点优先级(链接器按以下顺序尝试,直到成功):
- ld 命令行 -e 参数指定的入口点
- 链接脚本中 ENTRY() 命令指定的符号
- 特定符号(如默认的 _start)
- 代码段的起始地址
- 地址 0(通常会崩溃,不推荐)
操作系统开发必须显式指定入口点,否则程序启动时会跑飞。
基本概念
① 输入段 vs 输出段
- 输入段(input section):每个目标文件(.o)自带的段,如 main.o 中的 .text/.data,是链接的“零件”。
- 输出段(output section):链接后生成的可执行文件中的段,如内核的 .text/.data,是拼好的“成品”。
② 段的属性
- 可加载(loadable):运行时需要加载到内存的段,如 .text/.data。
- 可分配(allocatable):需要在内存中预留空间的段,如 .bss(不加载内容,但需要预留内存并清 0)。
③ 加载地址(LMA) vs 虚拟地址(VA)
- 虚拟地址(VA / 运行地址):程序运行时,段所在的内存地址。
- 加载地址(LMA / 存储地址):程序加载时,段被存放的地址(如 Flash/ROM 中)。
关系:通常两者相同,段直接在加载地址运行。嵌入式 / ROM 场景下两者可能不同:段存储在 ROM(LMA),运行时复制到 RAM(VA)执行。
符号赋值与引用
① 链接脚本中的符号赋值
链接脚本中可以像 C 语言一样对符号进行赋值和运算,支持的操作:
- symbol = expression;
- symbol += expression;
- symbol -= expression;
- symbol *= expression;
- symbol /= expression;
- symbol <<= expression;
- symbol >>= expression;
- symbol &= expression;
- symbol |= expression;
② 链接脚本符号与 C 语言变量的区别
C 语言变量:编译器会为变量分配内存空间,符号表中存储变量的内存地址,访问变量时会读写该地址的内容。
链接脚本符号:仅在符号表中创建符号,不分配内存空间,仅代表一个地址,没有实际存储内容。
// C语言定义:
int foo = 100;
/*编译后:
编译器在 RAM 中分配 4 字节空间
这 4 字节里存着值 100
符号表里 foo → 指向这 4 字节的地址(比如 0x20001000)
*/
当你写:
foo = 1;
/*程序做的事情:
查符号表,找到 foo 的地址(0x20001000)
往 0x20001000 写入 1
✅ foo 有自己专属的存储空间
*/
// 链接脚本定义:
foo = 0x100;
/*链接后:
没有分配任何内存
符号表里 foo → 直接是数字 0x100(它就是个常量地址)
*/
当你写:
extern int foo; // 声明链接脚本里的符号
int *p = &foo; // p = 0x100
/*foo 本身不代表"存着某个值"
它只代表地址 0x100 这个地方
0x100 这个地址里有什么?不知道,可能是代码、数据、或者空的
*/
| 区别 | C 语言定义的符号 | 链接脚本定义的符号 |
|---|---|---|
| 有内存空间吗? | ✅ 有(编译器分配) | ❌ 没有(只是地址标签) |
| 能存储值吗? | ✅ 能(比如 foo=100) | ❌ 不能(它本身就是一个地址) |
| 符号表里存什么? | 存变量的地址 | 存一个纯地址值 |
③ C 语言引用链接脚本符号
可以通过 extern 声明,在 C 语言中直接引用链接脚本定义的符号,常用于访问段的起止地址。
例 9-3:复制 ROM 内容到 Flash
start_of_ROM = .ROM;
end_of_ROM = .ROM + sizeof(.ROM);
start_of_FLASH = .FLASH;
C 语言中引用:
extern char start_of_ROM[], end_of_ROM[], start_of_FLASH[];
memcpy(start_of_FLASH, start_of_ROM, end_of_ROM - start_of_ROM);
例 9-4:定义段的起止地址
SECTIONS
{
start_of_text = .;
.text : { *(.text) }
end_of_text = .;
start_of_data = .;
.data : { *(.data) }
end_of_data = .;
}
C 语言中引用:
extern char start_of_text[], end_of_text[], start_of_data[], end_of_data[];
// 有两种引用方法:
// 方法1:声明为 char 数组(推荐)
extern char _etext[];
char *src = _etext; // ✅ 清晰:_etext 本身就是地址
// 方法2:声明为 char 变量(历史写法)
extern char _etext;
char *src = &_etext; // ⚠️ 别扭:明明是地址还要取地址
当前位置计数器(.)符号
. 代表当前位置计数器(location counter, LC),是链接脚本中最核心的特殊符号。
作用:记录当前正在处理的内存地址,会随着段的放置自动向后偏移,也可以手动赋值修改。
例 9-5:使用位置计数器实现地址对齐
SECTIONS
{
.text : {
*(.text)
_etext = .; // _etext = 代码段结束地址
}
_bdata = (. + 3) & ~3; // 当前地址+3后,向下对齐到4字节边界
.data : { *(.data) }
}
(. + 3) & ~3 是常用的 4 字节对齐写法,确保 _bdata 是 4 的倍数。
SECTIONS 命令详解
SECTIONS 命令的核心是输出段和输入段的定义。
① 输出段的完整格式
section [address] [(type)] : [AT(lma)] [ALIGN(align)]
{
output-section-command
...
} [>region] [:phdr ...]
各部分含义:
- section:段的名字,如 .text/.data。
- address:段的虚拟地址(VA)。
- type:输出段的属性
- AT(lma):段的加载地址(LMA),不写则与 VA 相同。
- ALIGN(align):段的对齐要求(如 ALIGN(4096) 表示 4KB 对齐)。
- output-section-command:输入段的映射规则。
- region:特定的程序段
- phdr:特定的程序段
② 输入段的映射规则
输入段命令告诉链接器如何将输入文件的段映射到输出段,常用语法:
- *(.text):通配所有输入文件的 .text 段
- EXCLUDE_FILE(*crtend.o) .ctors:排除 crtend.o 文件的 .ctors 段,其余文件的 .ctors 段保留
- foo.o(.input1):仅将 foo.o 文件的 .input1 段放入输出段
例 9-6:输入段过滤示例
SECTIONS {
outputa 0x10000 : {
all.o // 包含all.o的所有段
foo.o(.input1) // 仅包含foo.o的.input1段
}
outputb : {
foo.o(.input2) // 仅包含foo.o的.input2段
fool.o(.input1) // 仅包含fool.o的.input1段
}
outputc : {
*(.input1) // 所有文件的.input1段
*(.input2) // 所有文件的.input2段
}
}
③ 加载地址与虚拟地址分离示例
SECTIONS
{
.text 0x1000 : { *(.text) _etext = .; }
.mdata 0x2000 :
AT (ADDR(.text) + SIZEOF(.text))
{ _data = .; *(.data); _edata = .; }
.bss 0x3000 :
{ _bstart = .; *(.bss) *(COMMON); _bend = .; }
}
.text 段的 VA 和 LMA 均为 0x1000,直接在 ROM 中运行。.mdata 段的 VA 为 0x2000(RAM 地址),LMA 为 .text 段结束后的地址(ROM 中),运行时需要从 ROM 复制到 RAM。
extern char _etext, _data, _edata, _bstart, _bend;
char *src = &_etext; // src = 0x1028(Flash 中的初始值)
char *dst = &_data; // dst = 0x2000(RAM 中的目标地址)
// 复制数据段从ROM到RAM
while (dst < &_edata)
*dst++ = *src++;
// 清BSS段
for (dst = &_bstart; dst < &_bend; dst++)
*dst = 0;
常用内建函数
链接脚本提供了多个内建函数,方便获取段的信息:
① ABSOLUTE(exp)
作用:返回表达式的绝对地址,不受当前段的位置影响。
SECTIONS
{
. = 0xb0000;
.my_offset : {
my_offset1 = ABSOLUTE(0x100); // 直接赋值0x100,与当前段无关
my_offset2 = (0x100); // 等价于. + 0x100 = 0xb0100
}
}
② ADDR(section)
作用:返回指定段的虚拟地址(VA)。
③ ALIGN(align)
作用:返回下一个与 align 字节对齐的地址,基于当前位置计算。
示例:
.data ALIGN(0x2000) : { *(.data) } // .data段按0x2000字节对齐
variable = ALIGN(0x8000); // variable指向下一个0x8000对齐的地址
④ SIZEOF(section)
作用:返回指定段的大小。
例 9-10:
SECTIONS {
.output {
.start = .;
...
.end = .;
}
symbol_1 = .end - .start; // 方法1:通过起止地址差计算段大小
symbol_2 = SIZEOF(.output); // 方法2:使用内建函数获取段大小
}
⑤ 其他常用函数
- LOADADDR(section):返回指定段的加载地址(LMA)。
- MAX(exp1, exp2):返回两个表达式中的最大值。
- MIN(exp1, exp2):返回两个表达式中的最小值。
重定位
① 三个核心地址概念
在操作系统和嵌入式开发中,链接地址、运行地址(VMA)和加载地址(LMA)是三个最容易混淆的概念。理解它们的区别,是掌握重定位和内存布局的关键。
| 概念 | 一句话解释 | 谁决定的 | 什么时候用 |
|---|---|---|---|
| 链接地址 | 编译时假设程序“应该”在的地址 | 链接脚本 | 编译链接阶段 |
| 运行地址(VMA) | 程序实际运行时所在的地址 | 启动代码 + MMU | 运行阶段 |
| 加载地址(LMA) | 程序烧录时被存放的物理地址 | 烧录器 | 烧录阶段 |
两个物理地址类型:VMA 和 LMA。两个不同的命名视角:VMA 在编译时叫“链接地址”,在运行时叫“运行地址”。
② 三种典型场景
场景 1:三个地址完全相同(BenOS 基础场景)
条件:链接脚本中不使用 AT 关键字,也不开启 MMU。
- 链接地址:脚本中指定的地址(如 0x80000)
- 运行地址:BenOS 被 Bootloader 加载到 0x80000,直接执行,运行地址 = 链接地址
- 加载地址:同样是 0x80000,Bootloader 直接从该地址加载并跳转
SECTIONS
{
. = 0x80000; // 链接地址=0x80000
.text.boot : { *(.text.boot) }
.text : { *(.text) }
.rodata : { *(.rodata) }
.data : { *(.data) }
. = ALIGN(0x8);
bss_begin = .;
.bss : { *(.bss*) }
bss_end = .;
}
此时三个地址完全重合,程序直接在加载地址运行,不需要重定位。
场景 2:加载地址 ≠ 链接地址 = 运行地址(ROM/Flash 场景)
条件:代码存放在 Flash/ROM 中,运行时需要复制到 RAM 执行,使用 AT 关键字指定 LMA。
- 链接地址:脚本中指定的运行地址(如 0x80000)
- 运行地址:复制到 RAM 后执行的地址(和链接地址相同)
- 加载地址:Flash 中的存储地址(如 0x90000,通过 AT 指定)
TEXT_ROM = 0x90000; // 加载地址(LMA),代码在Flash中的真实地址
SECTIONS
{
. = 0x80000; // 链接地址=运行地址(VMA)
_text_boot = .;
.text.boot : { *(.text.boot) }
_etext_boot = .;
_text = .;
.text : AT(TEXT_ROM) // 加载地址=TEXT_ROM(0x90000),链接地址=当前位置(0x80088)
{
*(.text)
}
_etext = .;
}
此时的问题:代码存在 Flash 的 0x90000,但链接地址是 0x80088,直接跳转执行会导致 CPU 按链接地址取指,取到垃圾数据。
解决方法:启动时手动复制(重定位操作)。启动汇编中把 Flash 中的代码段复制到 RAM 的链接地址:
master:
adr x0, TEXT_ROM // LMA开始地址
adr x1, _text // VMA开始地址
adr x2, _etext // VMA结束地址
1:
ldr x4, [x0], #8 // 从Flash读数据
str x4, [x1], #8 // 写入RAM
cmp x1, x2
b.cc 1b // 循环复制直到完成
复制完成后,跳转链接地址执行,此时运行地址 = 链接地址,重定位完成。
场景 3:运行地址 ≠ 链接地址(MMU 开启后)
条件:开启 MMU,内核运行在虚拟地址空间,链接地址为虚拟地址,运行地址为物理地址。
- 链接地址:内核虚拟地址(如 0xFFFF00010080000)
- 运行地址:物理地址(如 0x80000,DDR 内存地址)
- 加载地址:物理地址(和运行地址相同,Bootloader 加载到 0x80000)
OUTPUT_ARCH(aarch64)
ENTRY(_start)
SECTIONS
{
. = 0xFFFF00010080000; // 链接地址=虚拟地址(内核空间)
_text_boot = .;
.text.boot : { *(.text.boot) }
_etext_boot = .;
_text = .;
.text : { *(.text) }
_etext = .;
}
此时的问题:链接地址是虚拟地址,但 MMU 未开启时 CPU 只能访问物理地址,直接按链接地址取指会触发异常。
解决方法:先初始化 MMU,再重定位
- 先在物理地址(
0x80000)执行启动汇编,初始化页表、开启 MMU。 - MMU 开启后,虚拟地址映射到物理地址,再通过跳转指令跳转到链接地址(虚拟地址)执行。
- 此时 CPU 访问虚拟地址时,MMU 自动转换为物理地址,运行地址 = 链接地址,重定位完成。
③ 重定位的底层原理:位置无关 vs 位置相关代码
a. 位置无关代码(PIC)
执行和内存地址无关,无论运行地址在哪,都能正常运行。
- 常见指令:B(相对跳转)、BL(相对调用)、MOV(立即数赋值)、ADR(小范围相对寻址)
- 原理:基于当前 PC 值计算目标地址,和链接地址无关。
- 应用:重定位前的启动代码、Bootloader 前 4KB 代码。
b. 位置相关代码
执行和链接地址强相关,必须在链接地址运行才能正确执行。
- 常见指令:LDR(绝对地址加载)、BR(绝对跳转)、访问全局变量的指令
- 原理:指令中直接编码了链接地址,运行地址 ≠ 链接地址时会访问错误地址。
- 应用:内核主代码、用户态程序。
U-Boot/Linux 内核重定位
Nor Flash 的完整内容布局
| 地址范围(举例) | 存放内容 | 大小 |
|---|---|---|
0x00000000 | U-Boot SPL(第一阶段) | ~64KB |
0x00010000 | U-Boot 完整版 | ~500KB |
0x00100000 | Linux 内核镜像 | ~10MB |
0x01A00000 | 设备树(DTB) | ~100KB |
0x01B00000 | 根文件系统(rootfs) | ~30MB |
ARM 芯片的内存映射
| 地址范围 | 硬件 |
|---|---|
0x00000000 | 异常向量表 + 片上 SRAM |
0x40000000 | DDR 内存 |
0x80000000 | 外设寄存器 |
0xFFFFFFFF | 其他 |
低地址放 SRAM,是因为上电时 DDR 还没初始化,需要一个"上电就能用、速度快、容量小"的内存做启动跳板。
问题:
- UBoot 镜像太大,放不进 SRAM(
0x0空间很小) - 必须先运行一小段代码,把 DDR 初始化好
- 再把 UBoot 从 Flash 复制到 DDR(
0x40000000) - 然后跳转到 DDR 执行
U-Boot 重定位过程
_start:
bl init_ddr // 相对跳转,OK
bl copy_uboot_to_ddr // 把 UBoot 从 Flash 复制到 0x40000000
ldr pc, =on_ddr // 绝对跳转!切换到 DDR
阶段 1:片内 SRAM 执行 (U-Boot SPL)
-
芯片 BOOT ROM 把 Flash 中的 U-Boot 前 4KB 加载到片内 SRAM(地址
0x0)。 -
此时这部分代码的运行地址=SRAM 地址
0x0,链接地址=DDR 地址0x40000000,因此只能执行位置无关代码 -
上电后实际运行情况:
阶段 运行地址 (VMA) 链接地址 是否相等 1. SRAM 中启动 0x00x40000000❌ 不等 2. DDR 中运行 0x400000000x40000000✅ 相等
阶段 2:复制代码到 DDR
- 初始化 DDR 内存,把整个 U-Boot 镜像从 Flash 复制到 DDR 的链接地址
0x40000000。
阶段 3:重定位跳转
-
通过
ldr pc, =on_sdram指令跳转到链接地址,此时运行地址 = 链接地址,重定位完成。 -
on_ddr: bl main // 现在运行地址 = 链接地址,一切正常
Linux 内核重定位过程
adrp x1, init_pg_dir
bl __enable_mmu // 开启 MMU,此时仍是物理地址
ldr x8, =__primary_switched // x8 = 链接地址(虚拟地址)
adrp x0, __PHYS_OFFSET
br x8 // 跳转到虚拟地址空间
- 阶段 1:物理地址执行
- U-Boot 把内核镜像加载到 DDR 物理地址
0x40080000,此时运行地址 = 物理地址,链接地址 = 虚拟地址0xFFFF00010080000。 - 执行
__primary_switch汇编函数,初始化页表、开启 MMU。
- U-Boot 把内核镜像加载到 DDR 物理地址
- 阶段 2:虚拟地址跳转
- 通过
br x8指令跳转到虚拟地址的内核主代码,此时运行地址 = 链接地址,重定位完成。
- 通过
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)