你的可执行程序,在碰到内存之前,就已经知道该怎么跑了

跑一下这个命令:

readelf -h a.out | grep Entry

你会看到类似这样的输出:

Entry point address: 0x4010c0

0x4010c0。不是 main 的地址。不是你以为的"程序从 main 开始"。

停。先别往下读。你刚才脑子里是不是闪过一个念头——“哦,入口地址,就是程序开始执行的地方”?好,我问你:不用"入口地址"这四个字,你能说清楚程序到底是从哪开始跑的吗?

换个问法:你双击一个程序,CPU 执行的第一条指令,到底长什么样?为什么是那条指令?操作系统怎么知道把那条指令放在内存的什么位置?

如果你只能回答"入口地址就是 entry point address"——你只是记住了名字。你还没搞清楚。

命名不等于理解。我们从头来。


一段代码,一个 call,一堆零

先看一段 C 代码:

// main.c
void foo() { /* ... */ }
int main() {
    foo();
    return 0;
}

把它编译成目标文件:

gcc -c main.c -o main.o

然后用 objdump -d main.o 看一下 main 的汇编。你会发现一件诡异的事:

call   0x0

调用 foo 的那条指令,后面跟的地址是 0

不是说调用 foo 吗?foo 的地址呢?

foo 在这个 .o 文件里是存在的。但当你只编译这一个 .o 时,编译器并不知道最终 foo 会被放在整个可执行程序的哪个位置。所以它先写个 0,占个坑。等链接的时候再来填。

.o 文件里,所有不属于当前文件内部的函数调用,地址全部是 0。

这就是所谓的"未定义符号"。翻译成人话:我知道我要调谁(名字我知道),但我不知道它在哪(地址我不知道)。


链接的本质:合起来,统一编号

当你把所有的 .o 链接在一起:

gcc main.o foo.o -o a.out

链接器做了两件事,不多不少:

第一,把相同的数据节合并。

main.o 里的代码叫 .textfoo.o 里的代码也叫 .text。链接器把它们拼成一块更大的 .text。数据节也是一样,.data.data.rodata.rodata

第二,统一编址。

合并完之后的这个大 .text,从全零开始,给每一行指令编一个地址。四个字节一条指令?好,第一行地址 0,第二行 4,第三行 8。就这么一直编下去。

等一下——“从全零开始”?第一个地址真的是 0 吗?

不一定。说的是从逻辑上认为起始地址是 0,然后加偏移量。实际上第一个有效地址可能是 0x400000 之类的——前面的空间留给别的用了。但思路没变:从某个基地址开始,每一行顺序编号。基地址 + 偏移量 = 逻辑地址。

所以,链接之后:

  1. foo 函数有了一个确定的地址(比如 0x4010c0
  2. main 里那条 call 0x0 被链接器改成了 call 0x4010c0

这个过程叫静态地址重定位。说白了就是:把以前写的 0,替换成现在知道的真实地址。

.o 文件叫"可重定位目标文件",意思就是——我里面的地址可以改。

顺便说一句,.o 文件的 ELF 头部里,entry point address0。没有入口。这就是为什么你不能直接 ./main.o 运行一个目标文件——操作系统不知道从哪开始执行。

还有一个概念要校准一下:静态库是什么?静态库就是一堆 .o 文件的集合。 .a 文件本质上就是个 .o 的打包。所以 .o.o 怎么链接,静态库和静态库就怎么链接。

就这么回事。


一个你可能没想到的事实

问一个问题:a.out 这个可执行程序,现在还躺在磁盘上,没有加载到内存。它有地址吗?

你的第一反应可能是:没有吧?地址不是内存里的概念吗?

错了。可执行程序在磁盘上就已经有地址了。

换句话说,一名演员还没上台,他就已经把自己在台上该怎么演,设计好了。一名老师在还没上课之前,就已经把自己该怎么讲,提前规划好了。当他真正上了讲台,他按预设的往下走就行。

静态链接的可执行程序,所有的函数调用链,在加载之前就已经完整了。谁调谁,跳到哪,对齐了。

那这套"磁盘上的地址"到底是什么?

我们的可执行程序从全零开始统一编址,采用基地址 + 偏移量的方式。因为基地址统一为 0(逻辑上),所以每一行代码的地址其实就是它在文件里的偏移量。在磁盘上,这套地址叫逻辑地址。加载到内存之后,同样的这套数值,Linux 里叫线性地址或者虚拟地址——同一个东西,不同场景下的不同名字。

就像你过年回家叫"小张",在学校叫"张同学",上班叫"Zhang"。同一个人,不同语境,不同称呼。

地址这件事,现实生活中到处都是。 你在学校里有学号,宿舍里有床位号,上课有教室编号(1 教 101、2 教 203)。公司里有工号。本质上,这些都是在"编址"——给每个东西分配一个唯一编号。所以可执行程序在磁盘上有地址,不奇怪。它不过就是把编址这个思路用在了代码和数据上而已。

另外,上节课观察过一个具体数据:一个 ELF 可执行程序里可能有 30 个 section。经过 program header table 的指示合并之后,就剩 6 到 9 个 segment。合并是怎么决定的?靠 ELF 里的 program header table——它本质上是一张映射表,告诉你哪些 section 该合并成哪一个 segment。


加载:从磁盘文件到活着的进程

可执行程序有了地址。现在把它加载到内存。发生了什么?

分四步:

Step 1:创建 PCB

操作系统先给你分配一个 task_struct——进程控制块。里面包含进程 ID、状态、优先级、文件描述符表……还有虚拟地址空间

地址空间长什么样?内核里有个结构体叫 mm_struct,里面记录着:

start_code, end_code     → 代码段从哪开始,到哪结束
start_data, end_data     → 数据段从哪开始,到哪结束
start_stack              → 栈从哪开始
……

问题是:这些起始地址和结束地址的数据,从哪来?

从 ELF 来。

ELF 文件里有 section header table,记录了每个数据节在哪、多大。加载的时候,把多个 section 按 program header table 的指示合并成 segment,然后就有了代码段的起始地址和长度、数据段的起始地址和长度。用这些数据来填充 mm_struct 里的字段。

数据结构本身怎么写,我们清楚。但参数从哪来?从 ELF 的管理信息里来。

这就像你定义一个 C++ 类,构造函数写得再漂亮,参数也是外部传进来的。

另外注意:堆和栈在 ELF 文件里不存在。ELF 只管代码段和数据段的起始地址、长度——这些是从 section→segment 合并出来的。堆和栈的位置是内核在创建进程时动态分配的,跟 ELF 没关系。所以 mm_struct 里关于堆栈的字段,数据来源是内核的缺省设置,不是 ELF。

Step 2:加载到物理内存

把 ELF 里的代码段、数据段,实实在在地拷贝到物理内存的某个区域。此时每一行代码都有了一个物理地址——它在物理内存条上的真实位置。

Step 3:构建页表

现在你两边都有了:

  • 从 ELF 来的虚拟地址(逻辑地址)
  • 从物理内存来的物理地址

页表就是一张映射表,左边填虚拟地址,右边填物理地址:

虚拟地址        物理地址
0x40108f    →   0x111111
0x401060    →   0xffffff
0x401064    →   0xffffe1
……

页表初始化好了。但此时进程还没开始跑——它处于"加载完成,等待调度"的状态。

Step 4:填充硬件上下文

CPU 内部有一组寄存器,统称为当前进程的硬件上下文。其中最重要的是两个:

  • CR3:指向当前进程的页表基地址(物理地址)
  • EIP(PC 指针):保存当前正在执行指令的下一条指令的地址

当操作系统调度到这个进程时,把页表基地址写入 CR3,把 ELF 头部的 entry point address 写入 EIP。

也就是:

CR3  ←  页表的物理基地址
EIP  ←  0x401060(程序入口地址)

现在,CPU 准备好了。


CPU 怎么跑第一行代码

EIP 里现在存的是 0x401060。这是一个虚拟地址

CPU 和物理内存之间只有总线连着。你拿着 0x401060 直接去物理内存上找?找不到。真正的数据在物理地址 0xffffff 之类的地方。

所以 CPU 内部有一个硬件电路,叫 MMU(内存管理单元)。它做的事很简单:

  1. 从 EIP 拿到虚拟地址 0x401060
  2. 从 CR3 拿到当前进程的页表基地址
  3. 查页表:0x4010600xffffff
  4. 把物理地址 0xffffff 送到地址总线上
  5. 物理内存返回那行指令

整个过程在 CPU 内部自动完成。进来的是虚拟地址,出去的是物理地址。 总线上跑的永远是物理地址——操作系统、物理内存,压根不知道什么"虚拟地址"。

这就好比一个航海家:他知道自己要去好望角(虚拟地址),手里有导航图(页表,通过 CR3 拿到),MMU 就是帮他查图的那个人——看图、换算、给出实际航向(物理地址)。整个过程在驾驶舱(CPU)内部完成,船外面的水手(总线、物理内存)只认最终航向。

拿到指令后,指令本身被装入另一个寄存器叫 IR(指令寄存器)


EIP 怎么知道自己该跳到下一行

指令拿到了。执行完之后,EIP 需要更新成下一条指令的地址。怎么更新?

当前地址 + 当前指令的长度 = 下一条指令的地址。

你把指令读进 IR 了,这条指令有多长你总知道吧?指令本身的二进制编码里就包含了长度信息。有的指令 2 字节,有的 4 字节,有的 6 字节。

0x401060  +  4  =  0x401064
0x401064  +  4  =  0x401068
0x401068  +  6  =  0x40106e
……

所以当初编址的时候,只要你给了入口地址 0x401060,后面所有的地址全部自动确定了。每行指令的长度我们是能动态算出来的,EIP 只要把自己加上当前指令长度就行。

然后新的虚拟地址再次交给 MMU,再次查页表,再次转成物理地址,再取指令。周而复始。

还有一件事没说完。

EIP 在不断往上更新:0x401060 → 0x401064 → 0x401068 → ... 0x40108e → 0x40108f → 0x401090。当你读到 0x401090 的时候,MMU 去查页表——发现这个虚拟地址不在页表里。这时候怎么办?

触发缺页中断。操作系统介入:把对应的代码页从磁盘加载到物理内存(可能被放到 0xbcbcb 之类的全新物理地址),然后更新页表,补上 0x401090 → 0xbcbcb 这条映射。中断返回之后,CPU 重新执行刚才那条指令——这次 MMU 查到了,指令顺利取出。

所以整个过程中,左边(虚拟地址)是连续有序增长的,右边(物理地址)随便怎么放都行。 页表负责把有序的虚拟地址映射到乱七八糟的物理页框上。这不就是把"无序变有序"吗?当年学地址空间的时候就说过这件事——有了页表,物理内存再怎么碎片化,进程看到的永远是整整齐齐一排地址。

另外两个关于指令的小细节:

第一,IR 寄存器装不下长指令怎么办? 有些指令很长——5 字节、7 字节、十几字节。IR 寄存器是有限的,一次读不完。没关系——读多少执行多少,分多次读。就跟一个结构体你一次 memcpy 不完,分几次拷一样。

第二,CPU 怎么知道每条指令多长? 有些指令自己二进制编码里就带了长度字段。相当于每条指令进 CPU 之前,自己举个牌子:“我是 2 字节的”“我是 5 字节的”“我是 7 字节的”。也有的 CPU 靠指令本身的操作码就能推算长度——这跟具体芯片的设计有关。x86 属于复杂指令集(CISC),指令长度不固定;ARM 属于精简指令集(RISC),大部分指令长度固定。不过这都是底层细节了——有兴趣的自己翻芯片手册。

整个循环转起来了。


三个东西凑在一起,虚拟内存才成立

到这里你应该感觉到了——虚拟地址空间不是操作系统一个人搞定的。

它是三方协作的结果:

角色 干了什么
编译器 统一编址,赋予每一行代码虚拟地址,填好 entry point
操作系统 创建 PCB、构建页表、做虚拟→物理映射
CPU 硬件 CR3 存页表指针、MMU 做地址转换、EIP 驱动指令流

缺一个,虚拟内存就转不起来。

就像一部手机——不是只要有个操作系统就能工作。你得有主板、存储、摄像头、ARM 芯片、半导体工艺,整个产业链全部就位。


前面还有代码——编译器对你动了手脚

再跑一个命令:

objdump -d a.out | head -50

找到 _start

0000000000401040 <_start>:
    401040:    xor    ebp,ebp
    401042:    mov    rdx,rsi
    401045:    push   rsi
    ...

你的 main 函数在哪?还在后面。

可执行程序的入口地址指向的不是 main,是 _start main 是被 _start 调用的。

编译器在你写的代码前面偷偷加了一段代码。它不是只加了 _start——在 Windows 上叫 CRTStartup,在 Linux 上叫 _start。内容大同小异,都是帮你做一堆准备工作。

顺便校准一个概念:平时说的"GCC"不只是编译器。它是个工具链——里面包含预处理器、编译器、汇编器、链接器,合起来叫"编译器"。你能 gcc main.c -o a.out 一步到位,就是因为 GCC 内部帮你串了整个流程。别把它当纯编译器看。

编译器的确能对你的代码动手脚——它能做宏替换、条件编译,为什么不能给你的代码前头和结尾各塞一段代码?编译器内部有两个固定的代码片段:一个贴在可执行程序开头,一个贴在结尾。把你的代码夹在中间。链接的时候三个部分拼在一起,形成最终的可执行程序。

这也是为什么你写 C 的时候入口函数必须叫 main编译器自己的代码最后要调用 main,你不写这个名字,它找不到你。


动态库来了:两个文件,不是合并

如果你的程序是静态链接的,上面那些就够了。库的代码都合并进你的可执行程序了,你一个文件走天下。

但动态链接不一样。

ldd a.out
linux-vdso.so.1
libc.so.6 => /lib64/libc-2.17.so
/lib64/ld-linux-x86-64.so.2

动态链接的可执行程序,用到了库,但不会把库的代码拷进自己身体里。 运行时需要两个(或更多)文件一起配合——你自己的 ELF,外加你依赖的动态库。

第一个问题:进程怎么"看到"库

你自己的进程代码和数据在物理内存里,库也被加载到了物理内存里。但你的代码怎么跳过去调用库里的函数?

把库映射到进程的虚拟地址空间的共享区里。 在栈和堆之间,有一块区域叫"共享区"。操作系统在当前进程的页表里新增映射条目,把库所在的物理内存映射到这个共享区。映射完之后,你的进程就可以用自己的虚拟地址访问到库里的代码了。

库本身也是一个 ELF 文件。 它也是从零开始统一编址的,库里的每一个函数也都有自己的偏移量。

你可以自己验证一下:

objdump -d /lib64/libc-2.17.so | less

随便找一个函数——比如 printf。你会看到类似这样的地址编排:

910:   ...(6 字节指令)
916:   ...(6 字节指令)
91c:   ...(4 字节指令)
920:   ...(6 字节指令)
926:   ...(5 字节指令)
92b:   ...(4 字节指令)

910 + 6 = 916916 + 6 = 91c91c + 4 = 920920 + 6 = 926926 + 5 = 92b……和可执行程序一模一样的编址逻辑。每一个函数的入口地址本质上就是从库文件起始位置算起的偏移量。这个偏移量是固定的——不管库被映射到地址空间的哪个角落,偏移量不变。

所以,要找到库里的任意一个函数,公式是:

库函数真实虚拟地址 = 库映射起始虚拟地址 + 函数在库中的偏移量

库可以被映射到共享区的任意位置。 这个叫"与位置无关"——不管你把我映射到哪,只要你告诉我基地址,我偏移量一加就能找到。

好问一句:换个问法,不用"位置无关代码"这个词,还能说清楚吗? 能——“不管你把库塞到地址空间的哪个角落,只要告诉我起点在哪,我就能算出来我要找的函数在哪。”

第二个问题:100 个进程用同一个库呢

系统里有 100 个进程,全都要用 libc.so。怎么办?

一个方案:把 libc 的代码拷 100 份,每个进程各一份。浪费多少物理内存?

更好的方案:libc 在物理内存里只存一份,然后映射到 100 个进程各自的虚拟地址空间里。

动态库就是共享库。 所有进程共享同一份物理内存里的代码和数据。省磁盘空间,更省内存空间。

什么样的函数值得被打包进动态库?一定是所有程序都必须要用的基础操作——printfscanf、字符串操作。如果不放库里面,每个程序自己实现一份,物理内存里就会有几十份 printf 的副本。

顺便说一句——这也是为什么你下载的很多 Linux 软件,安装目录里除了可执行程序,还带一大堆 .so 文件。不是为了炫技。是为了局部升级:哪天某个功能模块要更新,只替换对应的 .so 就行,不需要整个软件重装。

第三个问题:操作系统怎么知道你要加载哪个库

你在终端输 ldd a.out,程序都还没运行呢,怎么就知道它依赖哪些库了?

因为 ELF 文件里记录了。

readelf -d a.out | grep NEEDED
0x0000000000000001 (NEEDED)    Shared library: [libc.so.6]

可执行程序的 .dynamic 节里,白纸黑字写着:我需要 libc.so.6

好,你有需求的声明。但真正加载库的,不是操作系统直接做的——是你的程序自己在跑 _start 的时候,调用了一个叫 ld-linux-x86-64.so.2 的动态链接器。这个链接器负责:

  • 解析 ELF 文件的依赖信息
  • 根据 LD_LIBRARY_PATH/etc/ld.so.conf 等配置找到库的路径
  • 为了提高查找效率,Linux 还维护了一个缓存文件 /etc/ld.so.cacheldconfig 命令会读取所有配置文件,把库路径信息汇总写入这个缓存。动态链接器优先查缓存——比每次去遍历配置文件快得多
  • 走文件系统:路径解析 → 目录项(dentry)→ inode → data block → 把库文件内容加载到物理内存
  • 映射到你的地址空间

所以 ld-linux.so 本身也是一个 .so 文件,它必须被最先加载。你加载其他库的代码,全靠它。

库文件本身也是一个文件——走文件系统加载

动态库文件,和普通文件一样:有权限、所属用户、创建时间、文件大小(你可以 ls -l /lib64/libc-2.17.so 看看),也有 inode、data block。

关键是:谁负责打开这个库文件?

不是你的进程。是操作系统。

库文件由系统自动打开,它的 struct file 对象被挂在系统的全局文件列表里——不在你的进程 fd 表里。然后进程地址空间的内核数据结构 vm_area_struct 里有一个指针叫 vm_file,直接指向这个 struct file。这样,进程不需要自己 open 这个库,就能通过 vm_file 找到文件的一切:inode、数据块、权限……全部能拿到。

这也是为什么动态库在 lsof/proc/pid/maps 里能看到——虽然你没 open 它,但系统帮你 open 了,还映射到了你的地址空间里。


动态链接本质也是链接——分成两阶段

静态链接是在编译时一次性做完的——合并 section、统一编址、把 call 后面的 0 全部改成真实地址。一个文件,加载就能跑。

动态链接不一样。它分两阶段

阶段一:磁盘级链接(编译时做的)。

编译器在形成可执行程序的时候,做的事情:

  • 帮你生成 GOT 表(放在数据段里),把你用到的所有外部函数条目都写进去
  • 把你代码里所有 call 0x0(对库函数的调用),替换成指向 GOT 表里对应条目的地址
  • 塞 PLT 桩代码

注意——此时 GOT 表里的条目还没有库函数的真实地址。库还没加载呢。条目里存的只是"标识信息":哪个库、什么函数名、偏移量多少。

阶段二:内存级链接(加载/运行时做的)。

程序加载到内存之后,动态链接器开始工作:加载依赖库、拿到库的基地址、把 GOT 表里的条目逐个填上真实地址(库基地址 + 函数偏移量)。可能用 PLT 延迟绑定,也可能一次全填。

这就跟 C 语言里两个概念的关系一样:全局变量是静态分配——编译时就定好空间了,加载的时候已经有了。new/malloc 是动态分配——运行期间才跟内存要空间。同样的道理,静态链接是编译时把地址都定好,动态链接是运行时才去修地址。

好,现在关键问题来了——


代码区只读,那怎么改 call 后面的地址?

这是整件事里最巧妙的地方,我们得慢慢说。

静态链接的时候,链接器直接修改 .text 段里 call 后面的地址——把 0 替换成真实地址。这在编译阶段做,没问题,因为是在改磁盘上的文件。

但动态链接不行。动态库的地址在加载之后才确定,你可能得在运行时去改 call 后面的地址。可是——代码段是只读的!页表映射的时候,代码段的权限就是 r-x(可读可执行,不可写)。你改不了。

那怎么办?

GOT 表:一个藏在数据区里的跳板

编译器在形成可执行程序的时候,除了代码段、数据段,还额外生成了一个叫 **GOT(Global Offset Table,全局偏移量表)**的数据节。这个表被合并到数据段(.data)里——数据段是可写的。

GOT 表长什么样?很简单,就是一个数组。每一项对应一个外部函数。里面存的是什么?

最开始,存的不是函数的真实地址,因为库还没加载。它存的是"标识信息"——我要调的是哪个库里的哪个函数,偏移量是多少。

然后,你的代码里所有对库函数的调用,call 后面的地址不再指向库函数本身——它指向 GOT 表里对应的那一项。

call → GOT[n] → 库函数的真实地址

两次跳转。

当程序运行时,动态链接器把库加载进来,拿到库的基地址,然后把 GOT 表里的每一项填上真实地址。修改的是 GOT 表(数据段),不是代码段。

这就是 GOT 的巧妙之处:代码段不变,只改数据段。通过二次跳转绕过了"代码段只读"的限制。


PLT:等你真的用了,我再给你改

还有一个小问题。

如果你用了非常多的库函数——几百个、几千个。一上来就把 GOT 表全部填满,需要遍历所有依赖的库,解析所有符号,每一行都写一遍。加载进程的时间就会拖得很长。

有没有更好的办法?

等你真正第一次调用某个函数的时候,我再帮你填 GOT 表。

这叫延迟绑定(Lazy Binding)。实现它的机制叫 PLT(Procedure Linkage Table,过程链接表)

编译器在你的代码段里,为每一个外部函数生成了一小段"桩代码"(stub),放在 PLT 里。你的代码 call puts,实际上跳到了 puts@plt

第一次调用 puts 时:

  1. call puts@plt → 跳到 PLT 里对应的桩代码
  2. 桩代码做的事:找到这个函数在哪个库、偏移量多少,算出真实地址
  3. 把真实地址填进 GOT 表里对应的那一项
  4. 然后跳转到 puts 的真实地址去执行

第二次再调用 puts

  1. call puts@plt → GOT 表里已经有真实地址了
  2. 直接跳过去

PLT 本质就是把"一次性全部重定位"变成了"用到哪个才重定位哪个"。

这也意味着,重定位的工作从操作系统手里交到了用户程序自己手上。PLT 的桩代码是你自己的进程在跑,是编译器给你塞进代码段里的——编译器的确对你的程序动了手脚。

这背后是一个操作系统设计的通用哲学:能推迟就推迟,用到再说。 PLT 是延迟绑定,写时拷贝(COW)是延迟复制,缺页中断是延迟加载——同一个思路,不同场景。目的都一样:别在启动时把所有活干完,尽可能把开销往后推,提高进程创建的响应速度。

你可以自己验证一下。用 objdump -d a.out 反汇编你的程序,搜索 @plt

objdump -d a.out | grep '@plt'

你会看到每个库函数都对应一个 PLT 条目:

0000000000401030 <puts@plt>:
    401030:    jmp    *0x2fca(%rip)    # 跳转到 GOT 表里的对应项
    401036:    push   $0x0
    40103b:    jmp    401020           # 跳转到 PLT[0](公共解析器)

0000000000401050 <printf@plt>:
    401050:    jmp    *0x2fca(%rip)
    ...

call puts → 跳到 puts@pltjmp 到 GOT 表。第一次调用时 GOT 表里存的不是 puts 的真实地址,而是 401036(桩代码的下一行),所以又跳回来,执行解析逻辑,填 GOT 表,再跳过去。第二次调用时 GOT 表里已经有真实地址了,jmp 一步到位。


_start 到底干了什么

我们看一眼反汇编:

0000000000401040 <_start>:
    401040:    xor    ebp,ebp
    401042:    mov    rdx,rsi
    401045:    push   rsi
    ...
    401060:    call   __libc_start_main@plt

_start 在执行时做了三件大事:

1. 设置堆栈

用汇编指令构建初始的栈帧。你进程地址空间里的栈,最开始是空的,就是 _start 帮你开辟的。什么 sub $0x100, %rsp 之类的,切一块出来。

2. 初始化数据段

  • 把 ELF 里已经初始化的全局变量(.data 段),从磁盘拷贝到内存对应的位置
  • 把未初始化的全局变量(.bss 段),批量清零

.bss 段这里有一个省空间的技巧。你在代码里写了 int a; int b; int c; 十个变量,ELF 文件里不会一条条记"int a=0, int b=0, int c=0……"。它只记一行:“有 10 个四字节变量,全清零”。这样在磁盘上只占几个字节的元信息,不是 40 字节。加载到内存的时候,_start 的初始化代码才按这个描述,切出 40 字节空间,全部写 0。

这就是为什么你定义全局变量不初始化,默认是 0——不是 C 语言规定的,是 _start 的初始化代码帮你清的。

3. 动态链接

_start 会调 __libc_start_main@plt,而这个函数内部会去调动态链接器 ld-linux.so 里的方法。链接器做的事情:

  • 读 ELF 文件的 .dynamic 段,解析依赖了哪些库
  • 根据配置文件(/etc/ld.so.confLD_LIBRARY_PATH)和缓存(/etc/ld.so.cache)找到库的路径
  • 走文件系统:路径解析 → 找到 inode → 找到数据块 → 加载到物理内存
  • 映射到进程地址空间的共享区
  • 做动态链接(可能用 PLT 延迟绑定)
  • 设置信号处理函数等运行时环境

全部搞定之后,__libc_start_main 才来调用你的 main

给你的函数搭好了台子,让你上去唱戏。

而你的 main 返回之后呢?控制权回到 _start,它调 _exit 终止进程。所以就算你不写 return 0,进程也能正常退出——_start 帮你兜底了。

但是注意:如果你 main 不写 return 语句,返回值是多少?不确定。 跟编译器、系统、甚至当天运气有关。有人测出来是 11,有人测出来是 0。C 标准没规定。所以别指望它。你可以在程序跑完之后用 echo $? 看一下——它会打印上一个进程的退出码,也就是 _exit() 收到的那个值。


如果想继续深挖——两本书

讲到这里,动态库和动态链接的原理在"够用"的层面上已经讲清楚了。如果你想进一步成为更专家型的人才,两本书可以接着看:

  • 《深入理解计算机系统》(CS:APP)——涉及到链接、虚拟内存、异常控制流等章节,和今天的内容高度吻合
  • 《程序员的自我修养——链接、装载与库》——专门讲 ELF、静态链接、动态链接、库加载的,更聚焦

再往下?就是读 glibc 源码、读 ld-linux.so 源码、读内核加载器的实现了。没兴趣的话,上面这些完全够用了。


别碰 GOT 表——库替换攻击

讲一个安全上的小插曲。

动态库是独立的文件,加载的时候才链接。这意味着什么?

如果你偷偷把我的 libc.so 替换成你改过的版本——比如在 printf 里加了网络功能,偷偷把数据传出去——我的程序调 printf 的时候,就是在跑你的代码。

这叫库替换攻击

防御也不复杂:对库文件做完整性校验(哈希、签名)。很多安全软件——比如 360、某些俄罗斯的安全产品——做的就是这件事:阻止未经授权的库替换,监测库文件的完整性。

这不是今天的主菜,但知道有这么回事就行。


所以到底发生了什么?就三件事

把整个链路串起来:

第一,磁盘阶段。

编译器把你的 .c 变成 .o,把 .o 拼成一个 ELF。统一编址,每一行代码都有虚拟地址。入口地址写进 ELF 头部。如果是动态链接的,依赖的库名写进 .dynamic 段,未解析的函数放进 GOT 表,PLT 桩代码塞进代码段。

演员在后台已经知道自己上台后每一步怎么走。

第二,加载阶段。

操作系统创建 PCB,开辟地址空间,把代码和数据加载到物理内存,构建页表做虚拟→物理映射。动态链接器加载依赖库,映射到地址空间的共享区,填充 GOT 表(或者等 PLT 延迟绑定)。_start 初始化栈、清零 BSS 段。

台子搭好了。

第三,执行阶段。

CPU 调度进程,CR3 指向页表,EIP 拿到入口地址。第一条指令进入 MMU:虚拟地址 → 查页表 → 物理地址 → 取指令到 IR → 执行。EIP 自动更新(当前地址 + 指令长度),下一条指令进入 MMU……周而复始。函数调用通过 GOT/PLT 跳转到动态库,执行完返回到 call 的下一条指令。

戏开演了。


你最好自己跑一下试试

光读没用。打开终端:

# 看入口地址
readelf -h /bin/ls | grep Entry

# 看反汇编,找到 _start
objdump -d /bin/ls | head -80

# 看依赖的动态库
ldd /bin/ls
readelf -d /bin/ls | grep NEEDED

# 看动态符号表
readelf -s /bin/ls | grep UND

你自己敲一遍,比读十遍这篇文章强。演示永远比论证有力。



附:预告——命名管道

进程间通信。第一个要讲的就是命名管道(FIFO)。

代码已经准备好了。来看一眼,感受一下这玩意儿有多直接:

两个进程通过一个"文件"对话:

comm.h——管道的名字:

#pragma once
#include <string>

const std::string fifoname = "fifo";

server.cc——等着读的一方:

int main()
{
    umask(0);
    int n = mkfifo(fifoname.c_str(), 0666);  // 创建一个管道文件
    if(n < 0) { perror("mkfifo"); return 1; }

    int rfd = open(fifoname.c_str(), O_RDONLY);  // 打开它,等着读
    if(rfd < 0) { perror("open"); return 2; }

    char inbuffer[1024];
    while(true)
    {
        ssize_t n = read(rfd, inbuffer, sizeof(inbuffer)-1);
        if(n > 0) {
            inbuffer[n] = 0;
            std::cout << "client say# " << inbuffer << std::endl;
        }
        else if(n == 0) { break; }  // 写端关了
        else { perror("read"); break; }
    }

    close(rfd);
    unlink(fifoname.c_str());  // 用完删掉
    return 0;
}

client.cc——往里写的一方:

int main()
{
    int wfd = open(fifoname.c_str(), O_WRONLY);
    if(wfd < 0) { perror("open"); return 1; }

    std::string outstring;
    while(true)
    {
        std::cout << "Please Enter@ ";
        std::cin >> outstring;
        write(wfd, outstring.c_str(), outstring.size());
    }

    close(wfd);
    return 0;
}

编译运行:

make
# 开两个终端
# 终端1: ./server
# 终端2: ./client
# 在 client 里敲字,server 那边实时显示

两个完全独立的进程,通过磁盘上一个"看起来像文件"的东西(实际上就是内核里的一个缓冲区),完成了通信。

进程间通信的第一个招式:管道。

Logo

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

更多推荐