4、链接脚本(Linker Script)深入理解

说实话,很多做嵌入式或者内核开发的朋友,一开始都会被链接脚本搞得一头雾水。我当年第一次接触它的时候,心里想的是:这不就是个编译器的附属品吗?随便写写不就行了?结果呢?内核死活起不来,调试了整整两天,最后发现是链接脚本里一个段地址写错了。

嗯,从那以后,我再也不敢小看这个 .lds 文件了。

什么是链接脚本?

链接脚本,说白了就是给链接器(ld)看的一张「地图」。它告诉链接器:

  • 你的代码应该放在内存的哪个位置
  • 数据段、BSS段、栈分别放在哪里
  • 各个段的排列顺序是什么
  • 入口地址在哪里

你想想看,我们写 C 代码的时候,编译器只管生成目标文件(.o),它不知道最终这个程序要跑在哪个地址上。链接器拿到一堆 .o 文件,如果没有链接脚本,它就用默认的规则——把代码从 0x0 开始放。这在用户态程序里没问题,因为操作系统会帮你做地址翻译。但内核呢?内核是直接跑在物理内存上的,没有 MMU 帮你转,你必须自己告诉链接器:我的代码要放在 0x100000 这个位置。

核心理解:链接脚本就是内核的「内存布局说明书」。没有它,内核连自己该待在哪里都不知道。

为什么内核需要它?

我见过不少做应用层开发的同事,他们写程序从来不管链接脚本。为什么?因为操作系统替他们管了。但内核开发不一样,原因有三:

  1. 物理地址固定:内核必须运行在特定的物理地址上。比如 x86 上,传统 BIOS 会把内核加载到 0x100000(1MB 处)。如果你不指定这个地址,链接器可能把代码放在 0x0,那 CPU 跳转过去就执行了错误的代码。
  2. 段布局精细控制:内核需要把只读数据(.rodata)和可写数据(.data)分开。为什么?因为 .rodata 可以放在 ROM 里,或者被标记为只读页,防止被意外修改。我在项目中遇到过,有个同事没分开这两个段,结果一个野指针把内核的字符串表给改了,系统直接崩溃。
  3. BSS 段清零:全局未初始化变量(BSS 段)在启动时必须清零。链接脚本需要告诉链接器 BSS 段的起始和结束地址,这样内核启动代码才能找到它并执行 memset。

个人经验:我曾经在移植一个 RTOS 到新平台时,忘了在链接脚本里定义 BSS 段的结束符号。结果启动代码只清零了一半的 BSS,另一半全是随机值。全局变量初始值不对,调度器直接跑飞。查了三天才找到原因。

编写一个最简单的链接脚本

好,理论说完了,我们来写一个真正能用的链接脚本。这个脚本是为一个运行在 QEMU 上的最小内核准备的。

/* kernel.lds - 最简单的内核链接脚本 */

OUTPUT_FORMAT("elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)

SECTIONS
{
    /* 内核加载地址:1MB 处 */
    . = 0x100000;

    /* 代码段 */
    .text : {
        *(.text)
        *(.text.*)
    }

    /* 只读数据段 */
    .rodata : {
        *(.rodata)
        *(.rodata.*)
    }

    /* 数据段 */
    .data : {
        *(.data)
        *(.data.*)
    }

    /* BSS 段:未初始化全局变量 */
    .bss : {
        __bss_start = .;
        *(.bss)
        *(.bss.*)
        *(COMMON)
        __bss_end = .;
    }
}

这个脚本看起来简单,但每个部分都有讲究。我来拆开讲讲:

指令/符号 作用 为什么重要
OUTPUT_FORMAT 指定输出文件格式 QEMU 模拟的是 32 位 x86,所以用 elf32-i386
OUTPUT_ARCH 指定目标架构 告诉链接器生成 i386 指令
ENTRY(_start) 指定入口点 CPU 复位后第一条指令的地址
. = 0x100000 设置当前位置计数器 告诉链接器从 1MB 处开始放代码
__bss_start / __bss_end 定义 BSS 边界符号 启动代码用这两个符号来清零 BSS

注意:BSS 段的两个符号 __bss_start 和 __bss_end 必须定义。我曾经见过有人忘了定义 __bss_end,结果启动代码把整个内存都清零了,包括内核自己的栈空间。嗯,那画面太美我不敢看。

链接脚本的核心逻辑

为了让你更直观地理解链接脚本做了什么,我画了一张图:

链接脚本内存布局示意图 0x000000 0x100000 0x100xxx 0x200xxx 0x300xxx 0x400xxx 0x500xxx 保留区(BIOS、实模式代码等) .text(代码段) .rodata(只读数据) .data(已初始化数据) .bss(未初始化数据) 栈空间 / 堆空间 . = 0x100000

这张图展示了一个典型内核在内存中的布局。从 0x100000 开始,依次是代码段、只读数据段、数据段、BSS 段,最后是栈空间。链接脚本就是这张图的「施工图纸」。

如何编译和验证

写好了链接脚本,怎么用呢?很简单,编译的时候加上 -T 参数:

# 编译汇编启动代码
as -o start.o start.s

# 编译 C 代码
gcc -c -o kernel.o kernel.c -ffreestanding -nostdlib

# 链接,使用我们的链接脚本
ld -T kernel.lds -o kernel.bin start.o kernel.o

# 检查生成的二进制文件
objdump -h kernel.bin

用 objdump -h 查看段信息,你会看到:

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000123  00100000  00100000  00001000  2**4
  1 .rodata       00000045  00100123  00100123  00001123  2**4
  2 .data         00000067  00100168  00100168  00001168  2**4
  3 .bss          00000089  001001cf  001001cf  000011cf  2**4

看到 VMA 列了吗?全是 0x100000 开头的。这就是链接脚本的功劳。

避坑指南:我曾经在调试一个内核时,发现代码明明编译成功了,但 QEMU 就是跑不起来。用 objdump 一看,VMA 是 0x0。原来是我忘了在链接脚本里写 . = 0x100000。嗯,从那以后我每次写完链接脚本,第一件事就是 objdump 检查地址。

总结

链接脚本不是什么高深的东西,它就是一张内存布局图。你告诉链接器:代码放这里,数据放那里,BSS 从这里开始到那里结束。没有它,内核就像没有地图的探险家,在内存的荒野里乱撞。

写链接脚本的时候,记住三个要点:

  • 设置正确的起始地址(. = 0x100000)
  • 定义 BSS 段的边界符号(__bss_start / __bss_end)
  • 用 objdump 验证生成的地址是否正确

做到这三点,你的内核就能在 QEMU 上顺利启动了。


公众号:蓝海资料掘金营,微信deep3321