链接器介绍

链接器在编译流程中的作用

① 编写的 .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 的完整内容布局

地址范围(举例)存放内容大小
0x00000000U-Boot SPL(第一阶段)~64KB
0x00010000U-Boot 完整版~500KB
0x00100000Linux 内核镜像~10MB
0x01A00000设备树(DTB)~100KB
0x01B00000根文件系统(rootfs)~30MB

ARM 芯片的内存映射

地址范围硬件
0x00000000异常向量表 + 片上 SRAM
0x40000000DDR 内存
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。
  • 阶段 2:虚拟地址跳转
    • 通过br x8指令跳转到虚拟地址的内核主代码,此时运行地址 = 链接地址,重定位完成。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐