从「静态程序」到「动态进程」

一个躺在磁盘上的 a.out 文件,到你看到的运行的进程,中间到底发生了什么?本页用 7 个图把每一次内核/运行库的动作拆给你看。

① 全景时间线
② 静态程序 = ELF
③ 谁来生它:fork+execve
④ 建虚拟空间 & 段装载
⑤ 动态链接 PLT/GOT
⑥ 按需分页 & 栈初始化
⑦ 跳入 main & 运行时对比

① 全景:一条命令如何变成一个进程

你在 shell 里敲下 ./demo,回车。接下来发生的事被拆成 8 个阶段。点任意一个阶段看它在哪一层、由谁执行。

1
shell 读取命令
用户态 · bash
识别到 ./demo 是可执行文件,准备启动它。
2
fork():克隆一个子进程
用户态→内核 · fork
复制 PCB/页表(COW),得到子进程,但跑的仍是 bash 代码。
3
execve():请求变身
用户态→内核 · execve
传入路径、参数、环境变量,请求把当前进程替换成 demo。
4
内核校验 & 读 ELF
内核态
验证格式合法,确定这是个 64 位 ELF 可执行文件。
5
建立虚拟地址空间(VMA)
内核态
画好『地图』,但地还是空的,先不搬家。
6
装入 ld.so & 初始化栈
内核态 → 用户态交界
准备好运行库与启动参数,进程即将被『第一次点亮』。
7
ld.so 做动态链接
用户态 · ld-linux.so
加载共享库、解析符号(PLT/GOT 延迟绑定)。
8
进入 main()
用户态 · 你的代码
main 执行完毕 → exit → 内核回收资源。
在 bash 里敲回车,shell 准备替你把它跑起来。此时 demo 还只是磁盘上的一个文件,没有任何地址空间。

一句话抓住本质

程序是「死的」文件,进程是「活的」实例。 文件只是按 ELF 格式排好的一串字节(指令 + 数据),没有地址空间、没有 PID、没有寄存器、没有栈。

中间的桥梁是 fork()+execve() 这两个系统调用 + 内核的装载逻辑 + 动态链接器。它们把「字节」变成「有地址空间、有状态、能执行」的进程。

② 静态程序是什么:解剖一个 ELF 文件

编译链接后的 demo 是一个 ELF(Executable and Linkable Format) 文件,躺在磁盘上。它的结构像下面这样(点任一区块看说明)。注意:它只是数据,CPU 此刻并没有在跑它。

ELF 头
魔数 + 架构 + 入口点 + 各表偏移(文件的目录)
Program Headers
段视图:告诉内核/链接器『怎么装进内存』(装载依据)
.text / .rodata
代码与只读数据 → 将来映射成 r-x 段
.data / .bss
已初始化 / 零初始化数据 → rw- 段(bss 不占磁盘)
.symtab/.dynamic/.got
符号与动态链接相关(运行时由 ld.so 使用)
Section Headers
节视图:链接器用的目录(链接依据)
ELF 头:7 个字节魔数 0x7F 'E''L''F',加上类别(32/64位)、字节序、类型、入口点地址、Program Header 偏移、Section Header 偏移。它是整个文件的『目录』。
元数据(头/表)代码/只读数据链接相关

为什么强调「静态程序是死的」

  • 没有地址空间:文件里的『虚拟地址』只是编译时假设的占位,并未被 MMU 翻译过。
  • 没有 PID、没有页表:它不属于任何进程。
  • 没有栈和堆:栈是内核在 execve 时铺的,堆是运行时 brk 长出来的。
  • bss 不占磁盘:只记大小,装载时才分配并清零——所以同一个文件多次运行,每次栈/堆内容都是从零开始。
想通这一点,你就明白了:「内存空间模型」并不是文件自带的,是内核在 execve 时替进程建好的框架。(这正是上一个页面讲的主题。)

③ 谁来生它:fork() 与 execve()

几乎所有程序都是由另一个进程生出来的。你双击、命令行、点图标,归根结底都是某进程调用了这两个系统调用。

bash (父进程)
↓ fork()
子进程(仍是 bash 副本)
↓ execve("./demo")
demo(被替换后的新镜像)
点左侧节点看说明。fork() 克隆出子进程(COW 复制页表),子进程里跑的还是 bash 代码;紧接着子进程调用 execve(),内核把它的整个地址空间替换成 demo 的镜像——这就是「变身」。父 bash 不受影响,继续等待子进程结束。

fork vs execve 各自干了啥

fork() — 克隆

内核:复制 PCB(进程控制块)
内核:复制页表,**写时复制(COW)**
返回两次:父得子 PID,子得 0
子进程此时仍是 bash 的代码/数据
关键:还没碰 demo,只是造了个『空白壳』

execve() — 变身

内核:丢弃原地址空间(bash 的)
内核:读 ELF、建 VMA、装 ld.so
内核:铺初始栈(argv/envp/auxv)
内核:PC 指向入口,返回用户态
关键:同一进程『换脑子』,PID 不变
为什么是 fork 再 exec 两步,而不是直接「运行 demo」? 这是 Unix 的经典设计:fork 让子进程可以先改环境(重定向 stdin/out、设环境变量、设优先级),再 exec 替换成目标程序。这种「先克隆再变身」的组合极其灵活,shell 的重定向、管道、后台任务全靠它。

④ 建立虚拟地址空间 & 段装载

execve 时内核并不直接把文件拷进内存,而是先做两件关键事:① 规划虚拟地址空间(建 VMA);② 按 段(Segment) 而非 section 来映射。

4.1 虚拟内存区域(VMA):先画地图,不搬家

内核建立 mm_struct,里面挂一串 vm_area_struct。每个 VMA 描述「[起,止] 地址 + 权限 + 背后文件偏移」。此时物理内存一页都没分配——这叫按需分页

text (r-x)
ELF .text
data/bss (rw-)
ELF .data
heap (rw-)
brk 起点
mmap (rw-)
库/匿名映射
stack (rw-)
初始栈
内核空间
不可访问
↑ 这些是「虚拟范围」声明。物理页在真正访问时才分配(见 Tab ⑥)。
VMA 是「预约」不是「入住」。 内核告诉 MMU:「这段地址将来属于 text,那段属于堆」。但只有当 CPU 真正读/写某个地址、触发缺页时,内核才从磁盘 ELF 读一页进来、分配物理帧、填好页表。

4.2 为什么装载按「段」而不是「节」

链接视图:很多 section
.text .rodata .data .bss .got ...
↓ 权限相同的被合并
执行视图:少量 Segment
PT_LOAD(r-x) + PT_LOAD(rw-) + PT_INTERP...
↓ 装载器只读 Program Header
内核按 Segment 建 VMA 并映射
section 有几十上百个(链接器细粒度组织用),但权限相同的 section 会被合并成一个 Segment。装载器只按 Segment 映射:

.text + .rodata → 一个 r-x
.data + .bss → 一个 rw-

这样页表项(Page Table Entry) 的权限位才统一,映射高效。所以你在文件里看到的「节」和实际装进内存的「段」是两个不同视图。

另外 PT_INTERP 段指向动态链接器路径,内核读到它就顺带把 ld-linux.so 也映射进来——这是下一节动态链接的起点。

⑤ 动态链接:PLT / GOT 延迟绑定

现代程序几乎都依赖共享库(libc 等)。这些库的确切地址在运行时才确定(位置无关 + ASLR)。于是调用 printf 这样的外部函数时,不能写死地址,要靠 PLT(过程链接表)+ GOT(全局偏移表) 在运行时解析。下面演示第一次调用 printf 的完整跳转。

1
代码执行 call printf@PLT,跳到 PLT 桩
2
PLT 查 GOT[printf],发现是『待解析』桩
3
ld.so 解析符号,找到 printf 实际地址
4
回填 GOT[printf] = 真地址
5
执行 printf;第二次起直达,不再解析
GOT[printf] = 0x...plt_resolver (待解析)
GOT[scanf] = 0x...plt_resolver (还没调用过)
call printf → CPU 跳到 PLT[printf] 这个桩代码。此时 GOT 里还没有 printf 的真地址。

为什么这叫「动态」链接

  • 静态链接:编译期就把 libc 代码拷进你的文件,「程序」自带所有指令 → 文件大、但启动快、不依赖外部。
  • 动态链接:文件里只留「占位 + 线索」,运行时由 ld.so 把共享库 mmap 进来、解析地址。→ 多个进程共用同一份 .so 物理页,省内存;可单独升级库。
这也是为什么你在文件里看到 .dynamic / .got / .plt:它们就是为「运行时才确定地址」准备的机关。没有动态链接器这一步,现代 Linux 程序根本起不来。

⑥ 按需分页 & 栈初始化

6.1 第一次执行触发缺页(demand paging)

内核建好 VMA、PC 指向入口后返回用户态。CPU 去取第一条指令地址 → MMU 查页表 → 没有对应的物理页 → 触发 缺页异常(page fault) → 陷入内核。

虚拟地址空间(VMA 已规划)

v0
v1
v2
v3
v4
v5
v6
v7

物理内存(访问时才分配)

点按钮:CPU 访问 v0 这一虚拟页,发现还没映射物理页,内核从磁盘 ELF 读入该页、分配物理帧、填好页表,之后 v0 才『入住』。这就是按需分页——只有用到才搬,省下启动时海量 IO。

6.2 内核铺好的初始栈(启动清单)

execve 时内核在进程最高地址往下铺好初始栈,作为进入 __libc_start_main 之前的「交接清单」。点任意一行看内容。

argc = 3参数计数
argv[]命令行参数指针
envp[]环境变量
auxv[]辅助向量(关键)
栈顶 / 边界
↑ 点任意一行查看其内容
命令行参数个数,比如 ./demo a b → argc=3。
注意:main 的参数 (argc, argv, envp) 不是凭空来的,是内核在栈上摆好、由 __libc_start_main 取出来再传给 main 的。auxv 里的 AT_ENTRY 更是指定了「第一条该跑哪」。

⑦ 跳入 main & 运行中 vs 静态对比

7.1 控制流:从内核态到你的 main

内核返回用户态
PC = 入口点
ld.so (_dl_start)
加载 .so、重定位
_start(crt 启动代码)
__libc_start_main
初始化 libc、跑构造函数
main(argc, argv, envp)
你的代码终于运行
入口点不是 main,而是链接时塞进来的启动代码 _start。它准备好参数后调用 __libc_start_main,后者完成 libc 初始化、执行全局对象的构造函数,最后才调用你的 main。main return 后由它调用 exit,最终陷入内核回收资源。

到此,从「磁盘文件」到「运行进程」的变身彻底完成。

7.2 静态程序 vs 运行中进程:逐项对照

维度静态程序(磁盘 ELF)动态进程(运行中)
存在形式一串字节(指令+数据)有 PCB、地址空间、寄存器状态的活实例
PID有(fork 时分配)
地址空间只有编译期假设的虚拟地址内核建好的 VMA + 真实页表
物理内存0(按需分页,访问才搬)已分配在用到的页
栈 / 堆栈由内核铺、堆靠 brk 长出来
外部函数地址GOT 里是占位桩运行时由 ld.so 解析回填
共享库只是「依赖线索」已 mmap 进地址空间、多进程共用
CPU 在跑吗是(时间片轮转)
收尾一句话: 程序是「图纸」,进程是「按图纸盖起来并住人的楼」。fork+execve 是开工令,内核装载 + 按需分页是打地基搬砖,ld.so 是现场把共享部件拼装好,最后门一开(main),楼就活了。