先问一个最基础的问题:你在终端里敲下./hello,按下回车,屏幕上出现几个字符的问候语。这个过程中间隔了多少毫秒?几毫秒。但就是这几毫秒里,系统完成了一整条链路——编译器把源码变成目标文件,链接器把目标文件变成可执行文件,内核再把可执行文件载入内存并启动为进程。目标文件、ELF 格式、程序加载,这三个词基本可以概括 Linux 底层开发的半壁江山。这篇文章就把这条链路从头到尾拆开,结合常见的排查案例,看看每个环节究竟做了什么,以及它为什么非这么做不可。
文章面向的是已经能写 C、会用 gcc 编译、但还没仔细琢磨过“编译和链接到底干了什么”的开发者,也适合那些被“undefined reference”“Segmentation fault”折磨过、想彻底弄懂根因的人。不需要太多前置知识,只要你能对着 readelf 的输出看懂个大概,就够用了。
1. 从源码到.o:目标文件到底解决什么问题
1.1 为什么不能一步编译出可执行文件
很多新手觉得,gcc hello.c -o hello是一条完整的命令,编译器应该直接把源码变成可执行文件。实际上这条命令内部是分阶段走的:预处理、编译、汇编、链接。前三个阶段做完,产物就是目标文件,也就是常说的.o文件(Windows 上是.obj)。第四个阶段“链接”才是把多个目标文件拼成可执行文件的过程。
为什么要停在半路?因为大型项目根本不可能一次性把全部源码编译成一个文件。你有一个 100 个源文件的项目,改一行util.c,难道要把所有文件都重新编译一遍?合理做法是:只把util.c重新编译成util.o,然后和之前已经编译好的 99 个.o一起链接。这就是增量编译的思想,也是Makefile这类构建工具存在的基础。
用gcc -c util.c就能得到util.o。你可以打开任意一个.o文件看一看,Windows 上没法直接看,但在 Linux 上,file util.o会告诉你它其实是 ELF 格式的一种,类型是 ET_REL(可重定位文件)。这就引出了第一个关键点:目标文件和可执行文件都是 ELF,只是 ELF 有不同子类型。
1.2 目标文件里的地址为什么全是 0
你可以做个实验:
gcc -c hello.c -o hello.o readelf -h hello.o输出里有一个字段非常显眼:Entry point address: 0x0。再执行readelf -S hello.o,你会看到各种节区(section)的Address列基本也都是 0。这说明目标文件里的代码和数据还没有拿到真正的虚拟内存地址,它们只是躺在文件里的“半成品”。
这背后的原因是:单个.o文件不知道自己最终会被链接到什么地址,也不知道其他.o文件里的符号会落在哪里。比如hello.o里调用了printf,但这个函数的地址是谁?现在没人知道。所以编译器只能先把调用指令留一个空的相对偏移,等链接器来填。目标文件的核心价值,就是提供一个“可重定位”的中间产物,让链接器统一分配地址、统一填充这些空缺。
1.3 ELF 的三种主要子类型
用 ELF 格式可以承载多种产物,最常用的是这三种:
| e_type 值 | 宏名称 | 典型产物 | 特点 |
|---|---|---|---|
| 1 | ET_REL | .o目标文件 | 地址未分配,必须经过链接 |
| 2 | ET_EXEC | 传统可执行文件 | 加载地址固定,代码段不可偏移 |
| 3 | ET_DYN | 共享库.so、PIE 可执行文件 | 可加载到任意地址,靠基址计算 |
值得强调的是,现代 Linux 发行版里,绝大多说可执行文件都是 ET_DYN,而不是 ET_EXEC。为什么?因为 ET_DYN 是位置无关的,加载器可以把它映射到随机化的地址,配合 ASLR(地址空间布局随机化)增加安全性。你用file /bin/ls看到的通常是ELF 64-bit ... dynamically linked ...,这就说明它是动态链接且支持随机化加载的 PIE 文件。这个细节在第 5 章加载环节还会再提。
2. 解剖 ELF:文件头、节区、符号表
2.1 从魔数到关键文件头字段
ELF 文件的最开头 4 个字节是0x7f 45 4c 46,也就是\x7fELF。这既是给内核识别用的魔数,也是给file这类工具做文件类型判断的依据。你可以用hexdump -C看一眼任意 ELF 文件的开头,格式几乎一模一样。
hexdump -C hello | head -4用readelf -h能读到完整的 ELF 文件头。几个核心字段要能看懂:
Class:32 位还是 64 位,通常是 ELF64。Data:小端序还是大端序,x86/ARM 主流都是小端。Type:就是第 1.3 节说的ET_REL、ET_EXEC、ET_DYN。Machine:目标架构,比如EM_X86_64或EM_AARCH64。Entry point address:程序启动后执行的第一条用户态指令的虚拟地址。如果是动态链接的可执行文件,这个地址通常指向动态链接器,而不是你写的main。Start of program headers和Start of section headers:分别指向程序头表和节区头表在文件中的偏移。
文件头相当于 ELF 的“总目录”,后面所有的节区、段、符号表都靠这些偏移去找。
2.2 节区(Section):程序数据的大仓库
用readelf -S hello.o可以看到目标文件里所有节区。我挑几个最重要的说明:
.text:编译后的机器指令。.data:已初始化的全局变量和静态变量。比如int x = 42;。.rodata:只读数据,字符串常量、const修饰的变量都放这里。.bss:未初始化或初始化为 0 的全局/静态变量,比如int y;。这个节比较特殊,它在文件里不占空间,却要在运行时占一大块内存。.symtab:符号表,记录函数名、全局变量名、文件内局部符号等。.strtab:符号名的字符串池,.symtab里只存偏移,不直接存字符串,省空间。.rela.text:针对.text的重定位表,链接器靠它修正指令里的地址。.eh_frame:异常处理和栈展开信息,调试和回溯栈时会用到。
.bss这个概念值得多说一句。很多初学者不理解:为什么int arr[100000]在全局定义,编译出来的 .o 文件并不大?因为.bss节里的内容全是 0,存进文件毫无意义。链接器只需要在运行时为它分配内存,并且把这部分内存清零就行。这也是“文件里面的体积”和“运行时的体积”经常不一致的原因。
2.3 符号表与字符串表:链接用的“通讯录”
目标文件之间要互相引用函数和变量,靠的是符号。每个.o文件都维护一张符号表,说明自己“定义了哪些符号、引用了哪些外部符号”。符号表的结构是Elf64_Sym,每个字段可以用readelf -s hello.o查看:
Num:符号编号Value:符号的值,目标文件里通常是相对所在节的偏移;链接完成后是虚拟地址Size:符号占用的大小Type:FUNC、OBJECT、NOTYPE等Bind:GLOBAL、LOCAL、WEAKNdx:符号所在的节索引,UND表示未定义
nm命令也可以看符号表,输出更简短。比如:
nm hello.o看到U printf,表示hello.o引用了printf,但这个符号在本文件里未定义(U 就是 undefined),以后链接时需要别的地方提供。看到T main,表示main是本文件定义的文本段符号(T 就是 text section 里的全局符号)。看到t小写就是局部符号,比如某个static函数或编译器生成的内部标签。
符号表是第 3 章链接过程的输入,没有它,链接器根本不知道哪些符号待解析、哪些符号已定义。
3. 静态链接:符号解析与重定位的完整过程
3.1 符号解析:把每个“引用”都找到一个“定义”
链接器做的第一件事是在所有输入的目标文件和库文件里建立一张完整的符号表,然后逐一处理每个“未定义符号”,看能不能找到对应的定义。这个阶段最常见的错误就是:
undefined reference to 'foo'出现这个错误,通常有几种情况:你根本没有实现foo;你忘了把实现foo的那个.o或库参与链接;或者foo是static的,只在某个文件内部可见,外部引用当然找不到。
符号解析还涉及强符号和弱符号的概念。简单说:多个目标文件都定义同一个全局函数时,链接器会报“multiple definition”错误,因为两个强符号冲突了;但如果是两个同名弱符号,链接器会选择其中一个。实际情况里最常见的还是重复定义问题。想控制符号的可见性,用static限定函数、变量是最简单的手段。
3.2 重定位:把指令里的占位符改成真实地址
符号解析完成后,每个符号都有了最终虚拟地址。接下来链接器要根据重定位表,把指令里那些临时的值改成真实地址。重定位表里的每条记录一般包含三个关键信息:
r_offset:需要修改的位置,在可执行文件里是一个虚拟地址,在目标文件里是节内偏移。r_info:包含符号编号和重定位类型。r_addend:一个附加修正值,不同的重定位类型用法不同。
以常见的 x86_64 跳转指令重定位R_X86_64_PC32为例,它的计算公式是:
result = S + A - P其中S是目标符号的最终地址,A是 addend,P是重定位位置所在的地址。为什么要减P?因为 x86 的call指令后面的操作数是一个相对偏移,CPU 算跳转目标时,是用下一条指令的地址加上这个偏移。链接器必须先算好“目标地址 - 当前位置”,才能把这个差值写进指令。
我用一个简化例子演示一下。假设main.o里有个call foo指令,在.text偏移 0x15 处,跳转指令的操作数位置在 0x15+1,但是 addend 通常是 -4(因为相对偏移是从下一条指令算起的)。链接后foo被分配到0x401000,当前指令位置P = 0x400515,于是:
result = 0x401000 + (-4) - 0x400515 = 0xAE7链接器把0xAE7写进指令的 4 字节操作数里,这条调用指令就变成可执行状态了。
3.3 链接脚本与输出布局
链接器在合并目标文件时,不是简单把内容拼起来,而是按照一个“链接脚本”来决定各节区的摆放位置。传统非 PIE 可执行文件默认从0x400000开始放代码段,然后依次是.text、.rodata、.data、.bss。你可以用readelf -l hello查看程序头表,里面LOAD段的Vaddr就是从这些基址开始算的。
嵌入式开发的同学对这个应该很熟:写完了交叉编译的.o,还要自己写链接脚本,把代码段放在片内 Flash 的起始地址,把数据段放在 RAM 的起始地址。PC 上开发通常感觉不到链接脚本的存在,因为编译器给了默认版本。但如果你想搞懂程序最终的内存分布,花点时间读一读/usr/bin/ld配套的默认链接脚本,会很有帮助。
3.4 静态链接的代价
把所有.o和三方库一次性打包成可执行文件,优点是运行时不需要额外的库,拷走就能跑;缺点是文件体积大、内存占用高(多个进程共享同一份代码的收益就没有了),而且一旦库函数更新,你必须重新链接整个程序。所以现代 Linux 默认走的是动态链接,这也是第 4 章要讲的内容。
4. 动态链接:PLT/GOT、延迟绑定与共享库搜索
4.1 共享库的本质是“喂给链接器的半成品”
共享库.so本身也是 ELF,类型是ET_DYN。它不是独立可执行文件,而是“可以和其他程序合并加载的代码仓库”。编译时,链接器只需要确认符号在库里有定义,然后把对库函数的调用做成“之后到库里找”的跳转,运行时再由动态链接器完成最终的绑定。
但这里有个问题:多个进程共享同一份.so的物理内存页时,如果库的加载地址是固定的,那还好办。可我们为了安全还想给库随机分配地址,这样一来,库内部的函数地址每次都不确定。解决办法是让库的代码变成位置无关代码(PIC),编译共享库时必须加-fPIC编译选项。
PIC 的原理简单讲,就是代码内部不直接访问绝对地址,而是通过一个叫 GOT(Global Offset Table,全局偏移表)的跳板表来访问。GOT 在进程自己的数据段里,库被加载到哪个基址,动态链接器就把 GOT 里的值改成实际地址。指令访问符号时先通过 GOT,不需要因为加载地址变化而改写指令本身。这也是为什么共享库的.text段可以不修改就映射到多个进程里,物理内存同一份代码页,谁都能用。
4.2 PLT 与延迟绑定:第一次调用才真正解析
链接器在生成可执行文件时,对每个动态库函数都会生成一小段“蹦床代码”,放在.plt节区里。以printf为例,你源码里调用printf,编译结果是把printf符号解释为跳转到.plt里的一个地址。
第一次调用printf@plt时,PLT 条目先跳转到 GOT 里对应项,而这一项初始内容被链接器填成 PLT 的下一条指令地址,也就是“下一步该走解析流程”的地址。接着程序会跳转到 PLT 的公共入口,由动态链接器的dl_runtime_resolve根据函数索引找到真正地址,写入 GOT,再转而执行真正的printf。第二次调用时,GOT 已经是真实地址,直接跳过去,不需要再次解析。
这套机制叫延迟绑定(lazy binding)。好处是启动的时候不用解析成百上千个动态符号,哪个函数被调用了才去解析哪个,启动明显更快。坏处是第一次调用会多一点开销。如果你需要尽早暴露所有符号解析问题,可以设置环境变量LD_BIND_NOW=1,或者在编译时加链接选项让 GOT 一次性填充。现在很多发行版默认使用了部分 RELRO 加 now binding 的组合,安全性和性能之间取了个平衡。
用readelf -r能看到动态重定位表,里面有JUMP_SLOT类型的项,这就是 GOT 里会被动态填写的那些槽位。
4.3 动态链接器怎么找到库
运行一个动态链接的程序时,内核会先加载动态链接器本身(通常是ld-linux-x86-64.so.2),由它负责加载程序依赖的所有.so。这个加载器是个特殊的共享库,没有它,程序根本起不来。你可以用readelf -l prog看到程序头里有一个INTERP段,里面就写着动态链接器的路径。
动态链接器查找依赖库时,大致顺序是:
LD_PRELOAD指定的库(会优先劫持同名符号,但 setuid/setgid 程序会忽略此环境变量)LD_LIBRARY_PATH环境变量指定的目录- 可执行文件里的
DT_RUNPATH字段指定的目录 /etc/ld.so.cache里的缓存(对应/etc/ld.so.conf.d/配置)- 默认系统库目录,如
/lib、/usr/lib
我排查“共享库找不到”的问题时,第一反应都是:
readelf -d prog | grep NEEDED看它到底依赖哪些库。第二步再用readelf -r prog | grep func确认是哪个符号出了问题。ldd命令功能更强,但有些场景会有误报,建议结合readelf -d一起看,而不是盲信ldd。
4.4 动态链接对可执行文件的影响
现在回到第 1.3 节的问题:为什么现代可执行文件大部分是ET_DYN。因为程序本身也要 PIC 化,才能支持加载地址随机化。如果程序是ET_EXEC的传统可执行文件,内核只能把它映射到固定地址(比如0x400000),攻击者就能轻易预测关键地址,ASLR 对代码段就形同虚设。把所有可执行文件改成 PIE 并动态链接,让程序也需要做一次“运行时基址修正”,再加上地址随机化,安全基线就高了一个档次。
代价是:程序启动时多一个动态链接器的参与阶段,首次运行要解析符号;程序必须依赖系统里有正确的共享库,换了一个环境可能跑不起来。这就是“动态链接”的经典取舍。
5. 程序加载:内核把 ELF 变成进程的那一瞬间
5.1 execve 之后,内核做了什么
你在终端输入./hello,shell 会调用execve,然后进入内核的加载流程。execve是一道分水岭,它不创建新进程(那是fork的事),而是把当前进程的内存现场整个换掉,换成新的可执行文件对应的映像。
在内核代码里,这一步走的是load_elf_binary。它先检查 ELF 魔数和文件头,确认这是一个合法的 ET_EXEC 或 ET_DYN 文件;然后读取程序头表,逐个处理PT_LOAD段;最后把入口地址设好、构造初始栈,再从内核态回到用户态执行。你真的可以认为“加载”就是一次大规模的mmap。
5.2 段映射:从文件偏移到虚拟地址
让程序能跑起来,内核需要把文件的代码和数据放进虚拟地址空间。这不是“读文件内容到内存”这么简单,而是映射。映射的核心是程序头表(Program Header),与目标文件的节区表不同,程序头表描述的是“运行时如何把这个文件的内存段放到地址空间”。
每个PT_LOAD段里有几个关键字段:
p_offset:段在文件中的起始偏移p_vaddr:段应该放到的虚拟地址p_filesz:段在文件里占用的字节数p_memsz:段在内存里占用的字节数
注意p_memsz可以大于p_filesz。多出来的这部分,就是.bss段。内核只把p_filesz对应部分从文件映射到内存,余下部分用匿名零页补齐。这也是.bss不占磁盘空间却占内存空间的原因。
由于内存映射是按页对齐的,而 ELF 段往往不是恰好从页边界开始,所以内核还要处理一个偏移修正,在文件偏移和虚拟地址之间保留一个“页内偏移”。内核里这些逻辑和mmap系统调用完全一致,你甚至可以自己写个程序,手动mmap映射一个 ELF 文件里的代码段,然后发明一个自己版本的加载器。
映射之后还有一个细节:执行时页面不是一次性全部读进物理内存,而是按需分页。进程访问到没有加载的页时,CPU 触发缺页异常,内核再从文件里读那一页。这就是为什么一个几百 MB 的大程序,启动速度不一定比几十 MB 的程序慢很多——系统只需要加载实际用到的页。
5.3 动态链接程序的“双入口”
前面说过,动态链接的可执行文件在文件头里写着的入口地址,并不直接指向你的main,而是指向动态链接器。内核把 ELF 文件映射好之后,还会检查有没有PT_INTERP段,如果有,就先把动态链接器映射到进程里,然后把入口地址改为动态链接器的入口。
动态链接器拿到控制权后,会继续加载依赖库、完成重定位和符号解析,最后才跳到真正的程序入口。所以一个动态链接程序从启动到执行main,经历的是:
内核加载 ELF 文件 -> 动态链接器加载依赖库 -> 动态链接器解析符号 -> 跳转到程序入口 -> libc 启动代码 -> mainreadelf -l hello里会在INTERP之后看到若干LOAD段,其中.text所在的LOAD段,Align通常是 0x1000(4KB 页大小),就是为了配合内存页映射。
5.4 ASLR、PIE 和运行时基址
现代系统里,PIE 程序加载的基址是随机的。比如每次运行./hello,都能用 gdb 看到入口地址在变化——这就是 ASLR 的效果。共享库也一样,每次加载基址都不同。内核和动态链接器通过“基址 + 偏移”的方式计算最终地址,这也是ET_DYN类型的核心意义:代码不会假设自己必须从某个固定地址开始执行。
ASLR 还随机化了栈地址、堆地址和 mmap 区域的地址。你在调试一些依赖固定地址的程序时会很痛苦,以前我们用 gdb 打断点、看反汇编,地址每次都在变。这也是为什么很多调试指南会先建议你关闭 ASLR 或者打开 PIE 支持,再进行分析。
关于辅助向量(Auxiliary Vector),它是内核构造初始栈时塞进去的一批键值对,动态链接器和 libc 启动代码会从里面读取关键信息,比如程序入口地址、程序头表地址、随机种子(AT_RANDOM)、vDSO 地址(AT_SYSINFO_EHDR)等。这也是为什么程序员的main函数接收参数之前,底下的_start和 libc 要完成这么多铺垫。
6. 排查与调试:从 readelf 到内存布局的实操
6.1 一套顺手的检查链
遇到任何“程序跑不起来”“一启动就崩”的问题,我一般按下面这个顺序查:
file 程序名,确认文件类型,是不是 ELF,是不是动态链接的,架构对不对。readelf -h 程序名,看入口地址和程序头表偏移是否正常。readelf -l 程序名,看有没有INTERP,LOAD段的地址和权限对不对。readelf -d 程序名 | grep NEEDED,看依赖哪些库。readelf -s 程序名 | grep 符号名,确认符号是导入还是导出。objdump -d 程序名 | grep -A20 '<foo@plt>',查看 PLT 蹦床代码。
这套组合拳能解决 80% 的“符号找不到”“库加载失败”问题。举个例子,我以前排查过一个怪情况:程序在开发环境跑得好好的,放到生产环境一启动就报cannot open shared object file。ldd显示的依赖都能找到,但LD_DEBUG=libs一打开,发现它加载了一个同名但是旧版本的.so。问题就出在发布镜像里某个依赖路径下有一个旧的同名库,干扰了搜索顺序。用readelf -d直接看NEEDED和RPATH/RUNPATH,一眼就锁定问题。
6.2 典型问题速查表
整理一份我在实际工作中反复用到的排查对照表,碰到类似问题可以直接对号入座:
| 现象 | 常见原因 | 排查命令/手段 |
|---|---|---|
undefined reference to 'foo' | 链接时没加对应库;库顺序错误;符号被static隐藏;未实现该函数 | nm 目标文件.o | grep foo;确认链接命令里库文件从左到右的顺序 |
cannot open shared object file | 动态链接器找不到依赖库 | readelf -d 程序 | grep NEEDED;LD_DEBUG=libs 程序 |
multiple definition of 'foo' | 两个目标文件都定义了同名强符号 | 检查 .o 和 .a 的符号表;用static限制符号可见性 |
启动就Segmentation fault | 栈随机化导致某些非法假设;空指针;入口被破坏 | 先用gdb跑start加bt看栈回溯;必要时关 ASLR 对比 |
.bss太大导致运行内存爆掉 | 全局定义了超大数组 | readelf -s 程序看OBJECT类型符号的Size;考虑改用动态分配 |
| 反汇编时发现地址对不上 | 程序是 PIE,加载基址随机 | 关闭 ASLR 或在 gdb 里set disable-randomization on |
| 动态链接器符号找不到 | 某个.so的符号导出不完整 | nm -D 库.so | grep 符号看动态符号表 |
6.3 一个小实验:看完整加载链路
为了让你把前面所有知识串起来,我建议做这个实验。准备一个hello.c:
#include <stdio.h> int global_var = 10; int main(void) { printf("hello, ELF\n"); return 0; }编译并查看文件类型:
gcc -g -O0 hello.c -o hello file hello你会看到ELF 64-bit ... dynamically linked ...。然后:
readelf -h hello | head -20 readelf -l hello在readelf -l的输出里找INTERP和LOAD。观察一下.text段的Vaddr,再对比.bss对应的LOAD段里Memsz和Filesz的差。用:
readelf -S hello | grep -E '\.text|\.data|\.bss|\.rela.plt'能看到文件里各节的大小,跟程序头里的Filesz/Memsz对应起来。
接下来用strace看执行轨迹:
strace -e execve ./hello 2>&1 | head -20再打开动态链接器调试输出:
LD_DEBUG=bindings ./hello 2>&1 | grep -E 'printf|libc'你会亲眼看到动态链接器解析printf符号的时刻。如果再加一行:
LD_DEBUG=files ./hello 2>&1 | head -30能看到它依次打开哪些库、映射了哪些地址。这套输出的信息量和直接“背概念”完全不是一个量级。
7. 几条值得长期保留的实操习惯
最后分享几个我自己的习惯,都是我踩过坑之后总结出来的。
第一个习惯:遇到底层问题先别急着开 gdb,先把 ELF 的“结构信息”读出来。很多问题在符号层面就能定位,用readelf、nm、objdump三件套,通常比调试器更快。尤其是“程序太大、地址太乱”的时候,看结构比跑调试器更清爽。
第二个习惯:链接错误里的库顺序问题,几乎所有新手都会踩。静态库在命令行里的顺序是有讲究的:链接器从左到右扫描,遇到未定义符号就去后面的库找定义。如果你把库写在依赖它的目标文件之前,就会出现undefined reference。解决方法要么调整顺序,要么用--start-group和--end-group让链接器反复扫描,但性能会有一点损耗。
第三个习惯:动态链接问题不要迷信ldd,直接看readelf -d。ldd会把LD_PRELOAD和环境变量影响都算进去,反而容易误导你去查一些根本不相干的库。readelf -d说的是文件真实的需求,环境变量带来的额外库另说。
最后一个习惯,与符号表有关:如果你发布的是共享库,注意用nm -D检查导出符号。忘记加-fPIC可能导致奇怪的运行时错误,而导出符号过多则会把内部实现细节曝露给链接器。需要精确控制导出范围时,可以用链接器版本脚本或者-fvisibility=hidden配合显式导出宏,而不是靠运气。
目标文件、ELF 格式、程序加载这三层,说到底是同一个问题的三个视角:内容怎么组织、地址怎么确定、进程怎么诞生。把这三层打通之后,再回头看那些编译警告、链接报错、段错误,就不再是一堆随机变量,而是一张有清晰因果的图。多数时候,你需要的不是更多工具,而是更准确的心智模型。