资讯详情

深入理解链接:符号解析、重定位、静态/动态链接与操作系统

📅 2026/9/30 12:05:14 | 华诺云谱 👁 阅读
深入理解链接:符号解析、重定位、静态/动态链接与操作系统
操作系统里有一类东西平时你几乎感觉不到它的存在但一旦出问题报错信息能让你盯着屏幕发半天呆——链接就是其中之一。很多人上手操作系统第一反应是去啃进程调度、内存管理、文件系统这些大块头这没错但真正把源码和能跑起来的程序连起来的那根线其实是链接。这篇【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位置无关代码要么换-mcmodellarge。这类错误很少见但一旦出现靠搜索引擎很难直接找到答案理解重定位原理才能自己推出来。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.onm math_util.o # 0000000000000000 T add # 0000000000000000 D global_counter # 0000000000000004 B bufferadd是Tglobal_counter是Dbuffer是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生成位置无关代码和-sharedgcc -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_PATHDT_RUNPATH指定的目录/etc/ld.so.cache缓存由 ldconfig 生成默认目录/lib、/usr/lib64 位系统还有 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_DEBUGlibs ./app_dyn看过动态链接器的搜索全过程它会一行行打印去哪儿找、找到没、为什么放弃。这个调试开关比任何文档都直观建议你自己跑一遍看看你的程序到底经历了哪些路径才把库找到的。踩过一次坑你就永远不会忘。注意RPATH和RUNPATH差别在于搜索顺序——RPATH 优先于 LD_LIBRARY_PATHRUNPATH 则在其后面。新编译器默认用 RUNPATH。这个细节在做安全加固时很重要因为 RPATH 优先级太高容易被利用来加载恶意库。如果只是本地调试用 LD_LIBRARY_PATH 最省事别急着改系统配置。4. 常见报错与排查技巧实录4.1 报错速查表链接和加载相关的报错就那么几类我把最常见的整理成表方便你对号入座报错信息出现阶段根因处理方向undefined reference to xxx链接符号没定义或库顺序错检查 -l 顺序、确认函数名C 注意 extern Cmultiple definition of xxx链接强符号重复定义改为 extern 声明或把定义放进 .ccannot open shared object file运行时动态链接器找不到 .so配 LD_LIBRARY_PATH、rpath 或 ldconfigversion GLIBC_2.xx not found运行时运行环境 glibc 版本低于编译环境在低版本环境编译或静态链接relocation truncated to fit链接代码模型/内存模型不匹配改用 -fPIC 或 -mcmodellargePermission denied执行时运行时没执行权限或挂载了 noexecchmod x检查挂载选项Text file busy运行时文件正被运行/写入先停进程再替换或原子替换4.2 几个我踩过的坑第一个坑是C 的名字修饰。你在 C 里直接调用一个 C 写的库不改头文件就会报 undefined reference。原因是 C 为了支持重载会把参数类型编进符号名name manglingadd会变成_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 节的实验敲一遍十分钟收益比看一整天视频都大。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑