操作系统里有一类东西,平时你几乎感觉不到它的存在,但一旦出问题,报错信息能让你盯着屏幕发半天呆——链接就是其中之一。很多人上手操作系统,第一反应是去啃进程调度、内存管理、文件系统这些大块头,这没错,但真正把"源码"和"能跑起来的程序"连起来的那根线,其实是链接。这篇【00】号文章我打算先把链接这件事讲透,因为它是后面所有内容的地基:不管你是准备操作系统期末复习、刷王道那套书,还是打算从零开始手搓一个操作系统,链接这块知识都绕不过去。
这篇文章适合三类人看:一是正在学操作系统原理、对"程序怎么从磁盘跑到内存里执行"感到模糊的同学;二是写过 C/C++,但一遇到 undefined reference、cannot open shared object file 就只会复制粘贴报错去搜的开发者;三是对底层感兴趣、想搞清楚 ELF、动态链接器、加载地址这些名词到底指什么的人。我会从"为什么要有链接"讲到"链接器具体干了哪几件事",再到"你自己怎么动手把链接过程扒开看",最后聊聊从零写操作系统时链接脚本和加载器的那点事。全程不堆概念,能动手的地方都给你命令和步骤。
1. 为什么我把"链接"放在操作系统系列的第 00 篇
1.1 链接到底解决了一个什么问题
先说结论:链接解决的是"分而治之之后如何拼回去"的问题。你写程序不可能把所有代码塞进一个 .c 文件,那样几万行下来没人维护得了。于是我们按功能拆成几十上百个源文件,每个文件单独编译成目标文件,最后再把它们拼成一个完整的可执行程序——这个"拼"的过程就是链接。
这个思路听起来平平无奇,但它带来的三个麻烦才是真正的重点。第一,一个文件里用到了另一个文件里定义的函数,编译器单独编译某个文件时根本不知道那个函数在哪、有多大、地址是多少,只能先留个"空位";第二,那个空位最后要填什么值,得等所有目标文件都到齐了才能算出来;第三,如果库是多个程序共享的,那这个地址就不能写死,得留到程序真正运行时再确定。前两个问题对应静态链接,第三个问题对应动态链接。你把这三点想明白,链接的整个知识框架就立起来了。
换个生活化的类比:盖一栋楼,每个施工队只负责一个房间,工人砌墙的时候要在墙上预留管线洞口,但具体从哪个洞口接哪根管子,得等所有房间的图纸汇总到总工那里才能定。链接器就是那个总工。它不管墙怎么砌,只管把预留的洞按图纸接起来,接错了就报"undefined reference",接重了就报"multiple definition"。
1.2 这个系列的排布思路与取舍
把链接当成第 00 篇,是我自己的一个习惯。很多人学操作系统喜欢从"定义"开始——先讲什么是操作系统,再讲发展历史,再讲体系结构。这套路子应付考试还行,但真正动手的时候你会发现,你连"hello world 是怎么被操作系统加载起来的"都说不清楚。
我的排布逻辑是"自底向上 + 痛点驱动":第 00 篇讲链接和构建,让你先弄明白一个可执行文件长什么样、它是怎么被组装出来的;后面才讲进程、内存、中断这些。为什么这么排?因为操作系统内核本身就是一个被链接出来的大程序,你在写引导代码、写链接脚本、处理加载地址的时候,如果不理解重定位和符号,会寸步难行。像 z220sff 那种"能不能通过 PCIe 接口的 NVMe 硬盘直接引导启动操作系统"的问题,往上追一层,就是固件怎么找到引导扇区、怎么把内核镜像搬到内存某个地址、那个地址又是谁在链接阶段写死的。
这里我做一个取舍:不铺开讲编译原理的词法语法分析,那跟操作系统主线关系不大。我聚焦在编译流程的后半段——汇编输出目标文件、链接器处理符号和重定位、加载器把程序搬进内存。这条线足够短,也足够有用。工具上我统一用 Linux 下最常见的 GNU 工具链(gcc、ld、binutils),你在 Ubuntu、CentOS、RHEL 上都能直接复现,麒麟、统信这些国产发行版底层也是同一套机制,命令完全通用。
2. 拆开看:链接器在幕后干的四件事
2.1 从 .c 到可执行文件,中间到底经过了几道手
很多人以为 gcc 是一个"编译器",其实它是个驱动程序,真正干活的是一串工具。你敲下gcc hello.c -o hello,背后至少发生了四步:
- 预处理:处理
#include、#define、条件编译,把宏展开、把头文件内容原地插进来,输出.i文件。 - 编译:把
.i翻译成汇编代码.s,这一步才是狭义上的"编译",做词法语法语义分析和优化。 - 汇编:把
.s翻译成机器码,产出目标文件.o,但此时地址还是不确定的。 - 链接:把若干
.o和库文件拼成最终可执行文件。
这四步你可以用-E、-S、-c三个开关分别停下来看。我强烈建议你至少手动做一遍,因为"知道每一步产出什么"和"只会一句 gcc 指令"是两个层次。目标文件.o是理解链接的关键中间态——它里面已经有机器的指令和数据,但所有涉及外部符号的地方都是占位符,等着链接器来填。
注意:不要把"汇编"理解成"把汇编语言写的东西翻译成机器码"这么简单。现代编译器输出的汇编可能经过多轮优化,和源码结构已经对不上了。你看到的
.s文件里有很多编译器自动生成的标记和伪指令,这是正常的。
2.2 符号解析:谁定义了谁,谁又引用了谁
链接器的第一件事是符号解析,说白了就是建立一张"符号 → 地址"的对应表,把每个引用都指到唯一的定义上。这里面有两条规则你必须记住。
第一条:强符号和弱符号。函数名和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。规则是:强符号不能重复定义(否则 multiple definition 报错);一强一弱以强为准;两个弱符号则任选一个。这个机制在 C 里有个经典用途——你可以在头文件里声明一个弱符号作为"默认实现",让使用方按需覆盖。
第二条:静态库的链接是按需提取的。这点极其重要,也是无数人踩坑的地方。链接器从左到右扫描命令行,遇到.a静态库时,只会把"当前还没解决、且库里正好有定义"的目标文件抽出来链进去。这就导致一个经典现象:库 A 依赖库 B,你把-lA -lB写成-lB -lA就报未定义符号。解决办法是把被依赖的库放在后面,或者直接上-Wl,--start-group ... -Wl,--end-group让它循环扫描。
实操心得:C++ 里链接顺序的坑比 C 更狠,因为模板和 inline 函数会产生大量弱符号。遇到莫名其妙的 undefined reference 时,先别怀疑代码,先检查
-l的顺序,十有八九是它。
2.3 重定位:把占位符换成真地址
符号解析完成之后,链接器知道每个符号该落在哪了,接下来就是把目标文件里那些"空位"填上真值,这一步叫重定位。目标文件里有专门的重定位表,记录了"第几节、第几个偏移处、需要按什么方式修正"。
为什么需要"按什么方式修正"?因为地址填进去的方式不止一种。一条call指令后面跟的是相对偏移(目标地址减去下一条指令地址),而一个全局变量的地址可能是绝对地址。链接器要按重定位表里标注的类型(比如 R_X86_64_PC32 表示相对寻址、R_X86_64_32 表示绝对寻址)分别处理。这就是为什么同一个目标文件在不同链接方式下能产生不同代码。
我在实际操作中总结出一个判断技巧:如果链接报错里出现 "relocation truncated to fit",基本可以断定是代码模型或内存模型选错了——比如 32 位绝对重定位塞不进有限的偏移范围,这时候要么改用 PIC(位置无关代码),要么换-mcmodel=large。这类错误很少见,但一旦出现,靠搜索引擎很难直接找到答案,理解重定位原理才能自己推出来。
2.4 静态链接与动态链接的分岔路
静态链接把库代码直接复制进可执行文件,产物自包含、体积大、每次改库都要重新链接;动态链接只记录"我依赖 libxxx.so 的哪个符号",运行时由动态链接器去加载对应的共享库。两者没有绝对的优劣,只有场景取舍。
| 对比维度 | 静态链接 | 动态链接 |
|---|---|---|
| 可执行文件体积 | 大,库代码被复制进来 | 小,只留引用信息 |
| 运行时依赖 | 无,拷贝到哪都能跑 | 依赖系统里的 .so 文件 |
| 内存占用 | 每个进程各一份 | 多进程共享同一份物理内存 |
| 升级维护 | 需重新链接整个程序 | 换 .so 即可,程序不动 |
| 启动速度 | 略快,无加载开销 | 略慢,要做符号绑定 |
| 典型场景 | 容器镜像、嵌入式、急救工具 | 桌面发行版、服务端常规部署 |
这里有个认知升级点:动态链接的"共享"不只是省磁盘,更省内存。十个进程跑同一个 libc,物理内存里只有一份代码页,大家通过页表映射到各自的地址空间。这正是操作系统虚拟内存机制的价值体现,也是为什么链接和内存管理必须放在一起学。理解不到这一层,你就只会背"动态链接节省空间",但说不出省在哪、怎么省的。
3. 动手实操:把链接过程扒开来看
3.1 环境与工具链准备
先确认手头工具齐全。任何主流 Linux 发行版装好 build-essential 或 Development Tools 就够了:
# Debian/Ubuntu 系 sudo apt install build-essential binutils # RHEL/CentOS/麒麟/统信系 sudo yum groupinstall "Development Tools"需要用到的主要是gcc、ld、nm、readelf、objdump、ldd、ldconfig。建议再装一个file命令,用来快速识别文件类型,file对 ELF 的判断非常准。如果你的环境里有多个版本的 gcc 或者交叉编译器,先gcc -v看清楚默认用的哪一个,避免后面实验结论对不上。
提示:实验尽量在虚拟机或容器里做,尤其涉及 ldconfig、LD_LIBRARY_PATH 这类全局配置的操作,改错了可能让系统里的程序找不到库,影响面比想象中大。
3.2 用 -c 和 -S 分步走一遍编译流程
先准备两个文件,模拟"多文件链接"的典型场景:
// math_util.c int add(int a, int b) { return a + b; } int global_counter = 0; // 强符号 int buffer[1024]; // 弱符号(未初始化) // main.c #include <stdio.h> extern int add(int, int); extern int global_counter; extern int buffer[]; int main(void) { global_counter = add(1, 2); printf("result=%d, buf_size=%zu\n", global_counter, sizeof(buffer)); return 0; }然后分步执行,观察每一步的产出:
gcc -E main.c -o main.i # 预处理,看头文件被展开了多少行 gcc -S main.i -o main.s # 编译,产出汇编 gcc -c main.s -o main.o # 汇编,产出目标文件 gcc -c math_util.c -o math_util.o gcc main.o math_util.o -o app # 链接做到main.o这一步时,用file main.o看看,它会告诉你这是一个 "ELF 64-bit LSB relocatable"。关键词是 relocatable(可重定位),意思就是"地址还没定,可以往任何地方放"。而最终链接出来的app是 "ELF 64-bit LSB executable",已经是定好地址的可执行文件了。这个差别,就是链接器的工作成果,一目了然。
3.3 nm、readelf、objdump 三件套解剖目标文件
目标文件里到底有什么,光看是看不出来的,得用工具扒。三个命令各有分工:nm看符号,readelf看结构,objdump看指令和数据。
先看符号:
nm main.o # U add # 0000000000000000 T main # U printf # U global_counter第一列是地址,第二列是符号类型,第三列是名字。这里有个识读技巧:U表示 undefined(未定义,需要外部提供),T表示在 .text 段中定义的全局符号,D是已初始化数据段,B是 .bss 段,t是小写的局部符号。你看add和printf都是U,说明 main.o 自己不知道它们在哪,必须靠链接器解决。再看math_util.o:
nm math_util.o # 0000000000000000 T add # 0000000000000000 D global_counter # 0000000000000004 B bufferadd是T,global_counter是D,buffer是B——正好和前面讲的强符号、弱符号对应上了。链接器就是把左边的U和右边的T/D/B对上号。
接着看重定位表,这才是链接器的"施工图纸":
readelf -r main.o你会看到类似R_X86_64_PLT32、R_X86_64_PC32这样的条目,每条都写着"在 .text 段的某个偏移处,需要按某类型填入某个符号的地址"。有了这张表,链接器才知道该改文件的哪个字节。
再看整体结构:
readelf -h main.o # ELF 头,看类型、入口点、节头表偏移 readelf -S main.o # 节头表,看有哪些节、各自多大 objdump -d main.o # 反汇编 .text,看指令长什么样 objdump -s -j .data main.o # 以十六进制看数据段内容关于.bss我要专门说一句,因为它特别容易被误解:buffer有 1024 个 int,占 4096 字节,但它不占目标文件的磁盘空间。你用readelf -S看.bss的 size 是 4096,但看文件本身大小根本没这么大。原理是.bss只记录"我要多大、从哪开始",内容全是零,没必要真存 4096 个零字节。程序加载时由操作系统把这段内存清零。这是"文件里的表示"和"内存里的表示"不一样的经典案例,也是理解可执行文件格式的一个重要节点。
3.4 动态链接器搜索路径实验与 ldconfig
现在换个玩法,把 math_util 编译成共享库,观察动态链接。动态库的编译要加-fPIC(生成位置无关代码)和-shared:
gcc -fPIC -c math_util.c -o math_util.o gcc -shared -o libmathutil.so math_util.o gcc main.o -L. -lmathutil -o app_dyn ./app_dyn # ./app_dyn: error while loading shared libraries: libmathutil.so: cannot open shared object file报错了,而且注意——这个错误不是链接阶段报的,是运行时报的。这说明链接时它找到了库(因为在-L.指定的当前目录),但运行时动态链接器不知道去哪找。看它依赖了什么:
readelf -d app_dyn | head -20 # 关注 NEEDED、RPATH、RUNPATH 三个字段NEEDED列出这个程序需要哪些共享库,RPATH和RUNPATH则是编译时写死的搜索目录。运行时动态链接器(glibc 里叫 ld-linux-x86-64.so.2)按这个顺序找库:
- 可执行文件里
DT_RPATH指定的目录(如果没设 RUNPATH) - 环境变量
LD_LIBRARY_PATH DT_RUNPATH指定的目录/etc/ld.so.cache缓存(由 ldconfig 生成)- 默认目录
/lib、/usr/lib(64 位系统还有 lib64 变体)
想让上面的程序跑起来,有几种做法,各有适用场景:
# 方案一:临时用环境变量(调试时最方便) LD_LIBRARY_PATH=. ./app_dyn # 方案二:编译时把 rpath 写进去,注意 $ORIGIN 表示可执行文件所在目录 gcc main.o -L. -lmathutil -Wl,-rpath,'$ORIGIN' -o app_dyn # 方案三:把库装到系统目录并刷新缓存(正式部署常用) sudo cp libmathutil.so /usr/local/lib/ sudo sh -c 'echo /usr/local/lib > /etc/ld.so.conf.d/mymath.conf' sudo ldconfig我用LD_DEBUG=libs ./app_dyn看过动态链接器的搜索全过程,它会一行行打印"去哪儿找、找到没、为什么放弃"。这个调试开关比任何文档都直观,建议你自己跑一遍,看看你的程序到底经历了哪些路径才把库找到的。踩过一次坑你就永远不会忘。
注意:
RPATH和RUNPATH差别在于搜索顺序——RPATH 优先于 LD_LIBRARY_PATH,RUNPATH 则在其后面。新编译器默认用 RUNPATH。这个细节在做安全加固时很重要,因为 RPATH 优先级太高,容易被利用来加载恶意库。如果只是本地调试,用 LD_LIBRARY_PATH 最省事,别急着改系统配置。
4. 常见报错与排查技巧实录
4.1 报错速查表
链接和加载相关的报错就那么几类,我把最常见的整理成表,方便你对号入座:
| 报错信息 | 出现阶段 | 根因 | 处理方向 |
|---|---|---|---|
| undefined reference to `xxx' | 链接 | 符号没定义或库顺序错 | 检查 -l 顺序、确认函数名(C++ 注意 extern "C") |
| multiple definition of `xxx' | 链接 | 强符号重复定义 | 改为 extern 声明,或把定义放进 .c |
| cannot open shared object file | 运行时 | 动态链接器找不到 .so | 配 LD_LIBRARY_PATH、rpath 或 ldconfig |
| version `GLIBC_2.xx' not found | 运行时 | 运行环境 glibc 版本低于编译环境 | 在低版本环境编译,或静态链接 |
| relocation truncated to fit | 链接 | 代码模型/内存模型不匹配 | 改用 -fPIC 或 -mcmodel=large |
| Permission denied(执行时) | 运行时 | 没执行权限或挂载了 noexec | chmod +x,检查挂载选项 |
| Text file busy | 运行时 | 文件正被运行/写入 | 先停进程再替换,或原子替换 |
4.2 几个我踩过的坑
第一个坑是C++ 的名字修饰。你在 C++ 里直接调用一个 C 写的库,不改头文件就会报 undefined reference。原因是 C++ 为了支持重载,会把参数类型编进符号名(name mangling),add会变成_Z3addii这种东西,而 C 库里存的符号就叫add。解决办法是在头文件里用extern "C" { ... }包起来。用nm看符号名就能立刻判断是不是这个问题。
第二个坑是静态链接和 -static 的误区。有人以为加了-static就一定万事大吉,但如果某个库系统里只有.so没有.a,链接器会报错或警告。反过来,你在容器里用-static编出来的程序,虽然可移植性好,但 glibc 静态链接会有 NSS(网络服务切换)相关的问题,比如getaddrinfo在某些场景下不工作。所以纯静态不是银弹,很多正式项目选择用 musl 或者只静态链接部分库。
第三个坑是编译机和运行机 glibc 版本不一致。你在比较新的系统上编译,依赖了 GLIBC_2.34 的符号,拿到老服务器上跑就报 version not found。这个问题的本质是"链接时绑定了带版本号的符号",处理方式要么在低版本环境里编译,要么用容器固定工具链。这也是为什么很多团队用构建镜像来统一编译环境——版本兼容问题靠人肉是治不好的。
第四个坑是strip 掉符号后再想调试。有人为了减小体积把可执行文件 strip 了,结果线上出问题连调用栈都还原不出来。我的习惯是:保留一份带符号的文件用objcopy --only-keep-debug抽出来,主程序 strip 后加一个 debug link 指过去,需要的时候 gdb 依然能用。
5. 从零手搓操作系统时,链接为什么更关键
5.1 链接脚本与内存布局
前面讲的都是"用户态程序"的链接,等你开始自己写操作系统内核,链接的分量会陡然上升。原因很简单:内核跑在对内存布局极其敏感的环境里,它自己还没建立起完整的运行环境,很多东西必须提前写死。这个"提前写好"的工具就是链接脚本(linker script),通常是个.ld文件。
一个最典型的内核链接脚本长这样:
ENTRY(_start) SECTIONS { . = 0x100000; /* 内核加载地址,通常从 1MB 开始 */ .text : { *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(.bss) *(COMMON) } }里面有几点值得掰开说。ENTRY(_start)告诉链接器可执行文件的入口点符号是谁,这个符号就是引导代码的第一条指令。.是位置计数器,0x100000是约定俗成的内核加载地址,为什么从 1MB 开始?因为低 1MB 内存有历史遗留的 BIOS、显存、中断向量表等占位,从 1MB 往上相对干净。.bss后面跟*(COMMON)是为了把未初始化的公共符号也收进来。
如果你手搓内核时没指定链接地址,或者地址写错了,症状通常是"引导后直接重启"或者"跳进内核就跑飞"。排查思路是写一个打印字符的极简版本,先让它在屏幕上打出一个字母,再逐步加复杂度。链接地址、栈指针初值、页表基址这三样对不上,是手搓 OS 最常见的翻车点。
5.2 内核加载程序时动态链接器做了什么
最后我们从操作系统的视角回看一下动态链接。一个动态链接的可执行文件里有一个.interp节,里面存着动态链接器的路径,比如/lib64/ld-linux-x86-64.so.2。内核在execve加载这个程序时,读到.interp就知道"这不是个能直接跑的程序,得先请动态链接器来",于是把动态链接器的代码映射进地址空间,把控制权交给它。
动态链接器接手之后要做几件大事:读取程序的.dynamic节,拿到所有NEEDED的库;按前面说的搜索路径把每个.so映射进来;处理符号解析和重定位,把 GOT 表里的条目填上真实地址;最后跳转到程序的入口点。对于函数调用,现代系统普遍用 PLT + GOT 加延迟绑定,第一次调用某个外部函数时才真正去解析地址,之后走缓存,这就是为什么你能用LD_BIND_NOW强制提前绑定来做调试。
把这条链路串起来看:源码 → 编译 → 目标文件 → 链接器 → 可执行文件 → 内核 execve → 动态链接器 → 进程。每一步都在前面一步的基础上做"地址确定"这件事,只是确定的时机不同。静态链接在构建时确定,动态链接推迟到加载时确定,而操作系统内核本身则在链接脚本的控制下,把地址确定在编写时。顺着这条线,你会发现操作系统里很多看似独立的机制其实是同一件事在不同阶段的展开。
我个人在折腾这些的体会是:链接是个"用进废退"的知识点。你光看书上画的重定位示意图,过两天就忘了;但如果你真的用一个最小例子做过nm、readelf、ldd三连,遇到过一两次 cannot open shared object file,再亲手把 rpath 写进去修好,那套理解就会跟着你走很久。后面我们聊进程地址空间和内存映射的时候,你会再次遇到今天这些概念——那时候它们会变得更亲切一些。如果你手边正好有个 Ubuntu 20.04.6 之类的环境,我建议你现在就把上面第 3 节的实验敲一遍,十分钟,收益比看一整天视频都大。