程序是怎么运行的

程序是怎么运行的

•约 10.4 千字

程序是怎么运行的?

这个问题听上去像是计算机组成原理课的第一道题,答案通常也很像课本:编译器把源代码翻译成机器码,操作系统把程序加载到内存,CPU 执行它。

没错,但这句话把最有意思的部分全部压扁了。

操作系统为什么能同时运行浏览器、音乐播放器和终端?CPU 明明一次只能执行一条指令,所谓「同时」是怎么来的?一个普通程序为什么不能随便读取别的程序的内存?printf 最后又是怎么把字符送到屏幕上的?当我们在终端输入 ./hello 时,操作系统究竟做了什么?

说到底,计算机并没有我们想象中那么聪明。CPU 只是在内存里找到下一条指令,执行它,再找到下一条。 进程、用户态、系统调用、虚拟内存、ELF、动态链接,这些听起来宏大的概念,大部分都是操作系统为了把这件极其朴素的事情组织好而搭出来的抽象。

本文以 Linux x86-64 为主要例子,同时补充一些容易被初学者卡住的背景,并对已经变化的实现做必要说明。文中的「程序怎么运行」主要指 Linux、macOS 这一类 Unix-like 系统;Windows 在 CPU 基础上相似,但进程创建、系统调用和可执行文件加载流程并不完全相同。

先给出全文地图:

否是定时器中断写下源代码编译与链接得到可执行文件Shell 调用 execve内核识别文件格式加载 ELF 与共享库建立虚拟地址空间切到用户态CPU 从入口点执行需要资源?系统调用进入内核调度器切换进程

这不是一条只走一遍的流水线。程序运行以后,CPU 会在用户态指令、系统调用、硬件中断和调度器之间反复切换,直到程序退出。下面从最底层开始,一层一层把这件事拆开。

一、先把 CPU 想得简单一点

1.1 指令其实就是二进制数据

CPU 的核心工作是执行指令。这里的「指令」不是 C 语言的 if,也不是 JavaScript 的 forEach,而是特定 CPU 架构定义的一串二进制编码。

一条机器指令通常包含两类信息:

  • 操作码(opcode):要做什么,例如加法、加载、存储、跳转。
  • 操作数或附加数据:对谁做、数字是多少、从哪个地址读写。

比如在 x86 指令集中,某种形式的 add eax, 512 可以编码为:

x86-machine-code
text
05 00 02 00 00

第一个字节 05 是操作码,表示把一个 32 位立即数加到 EAX;后面的 00 02 00 00 是数字 512 的小端序表示。这里有一个很容易误会的地方:汇编指令和机器码并不一定一字节对一字节对应。 一条汇编语句可能编码成 2 个字节,也可能是 10 个字节;具体取决于操作码、寄存器、立即数大小和寻址方式。

汇编只是机器码的可读写记法。CPU 不认识 add eax, 512 这几个字母,它只认识对应的二进制;汇编器会把前者转换成后者。反过来,反汇编器则尝试把二进制还原成人类比较能读的形式。

1.2 RAM 保存的不只是数据

内存(RAM)可以看作一大片带地址的字节空间。正在运行的程序需要的数据在那里,操作系统的代码在那里,程序自己的机器指令也在那里。

CPU 不会直接从硬盘上一条一条执行指令。代码必须先被放进内存,CPU 才能取出来执行。硬盘上的可执行文件更像是「待装配的材料」,内存里的机器码才是 CPU 当前能使用的东西。

CPU 内部有一组很小、很快的存储位置,叫作寄存器(register)。寄存器保存临时计算结果、地址、状态和处理器控制信息。不同架构的寄存器名称和数量不同,x86-64 有 RAX、RBX、RSP、RIP 等,AArch64 则有 X0 到 X30 等另一套命名。

其中最关键的寄存器之一是指令指针(instruction pointer)。在 x86-64 里通常叫 RIP,在其他架构中叫法可能不同。它保存 CPU 接下来要取指令的位置。

1.3 取指、执行,再取指

程序之所以「跑起来」,核心原因就是下面这个循环:

否是指令指针指向某个地址从内存取出指令CPU 解码并执行指令是否改变控制流?指令指针移到下一条指令指令指针跳到目标地址

普通指令执行完以后,指令指针前进到下一条指令。如果遇到 jmp、函数调用、返回或条件分支,指令就会把指令指针改到别的地方。循环、函数、if 判断,最终都可以还原成「修改下一条指令在哪里」这件事。

这就是取指—执行循环(fetch-execute cycle)。现代 CPU 当然有缓存、流水线、乱序执行、分支预测和多核等大量优化,但这些优化不会改变最基本的编程模型:某个执行上下文总要有一个「下一条指令」的概念。

1.4 CPU 不知道什么叫进程

现在可以回答一个反直觉的问题:CPU 知道自己正在运行「浏览器」吗?

不知道。CPU 只知道当前的指令指针、寄存器、状态位以及一大堆硬件内部状态。它不会在寄存器里存一个标签,写着「这是 Chrome 进程」;也不会自然地知道某一段指令来自终端,另一段来自音乐播放器。

进程是操作系统的抽象,不是 CPU 原生理解的对象。 对 CPU 来说,所谓启动一个程序,最终不过是:操作系统准备好内存和寄存器,然后让指令指针指向这段程序的入口。

这句话同时带来了三个问题:

  1. CPU 只会顺序执行,为什么多个程序看起来能同时运行?
  2. CPU 可以访问内存,为什么一个程序不能随便读另一个程序的内存?
  3. 程序需要打开文件、申请内存、联网时,为什么不能自己直接操作硬件?

后面的章节分别回答它们。为了让第一步不至于同时塞进太多概念,我们先假设程序可以直接看见全部内存,而且机器一次只运行一个程序。

二、用户态、内核态和系统调用

2.1 内核到底是什么

操作系统这个词有时很宽泛。桌面环境、文件管理器、字体、终端,甚至预装应用,是否算操作系统的一部分,不同语境下可以有不同答案。

内核(kernel) 则比较明确:它是操作系统中负责管理硬件和基础资源的核心软件。内核管理内存、CPU、文件系统、网络设备、键盘、磁盘以及进程,并为上层程序提供统一的接口。

Linux 严格来说只是一个内核。一个可用的 Linux 系统还需要 Shell、C 标准库、服务管理器、图形服务器和各种用户空间程序。macOS 使用的是 XNU 内核,Windows 使用的是 NT 内核;它们的用户体验差异很大,但「内核管理资源,普通程序通过受控接口请求服务」这个大方向是一致的。

2.2 两种权限等级

如果任何程序都能执行任意指令,电脑很快就会变成灾难现场:浏览器可以覆盖内核,小游戏可以读取密码管理器,某个数组越界可以把整台机器的状态改掉。

所以现代处理器至少提供两种重要的运行权限:

  • 内核态(kernel mode / supervisor mode):可以执行受保护的指令,访问受保护的内存和设备。
  • 用户态(user mode):普通应用运行在这里,只能执行允许的指令,访问被允许的地址,不能直接控制设备或修改关键处理器设置。

在 x86-64 的术语里,处理器有 ring 0 到 ring 3 等权限环。通常操作系统内核运行在 ring 0,应用运行在 ring 3。硬件虽然保留了更多环的设计,但现代通用系统最常用的是这两层。

启动早期,处理器处于高权限状态,内核借此完成初始化;准备运行普通应用前,内核会把处理器切到用户态。应用不能凭自己的意愿把自己改回内核态,否则权限隔离就没有意义了。

2.3 系统调用:向内核递一张申请表

用户态程序仍然需要做很多只有内核能做的事:打开文件、读取网络数据、创建线程、分配映射、退出进程。解决方案不是把所有权限都开放,而是提供系统调用(system call,syscall)。

系统调用可以理解为一条受硬件保护的「请内核帮我做某件事」的通道。程序准备好系统调用编号和参数,然后执行特定的陷入指令;CPU 根据预先配置好的入口转入内核态,内核检查参数和权限,执行对应操作,最后再返回用户态。

早期或教学示例中常见的路径是 x86 的 int 0x80:

内核态代码CPU用户态程序内核态代码CPU用户态程序把 syscall 编号和参数放入寄存器执行 int 0x80保存返回所需的状态切换到内核态并跳到预设入口校验参数,执行 open/read/exit 等操作准备返回值恢复状态,回到用户态

真实的现代 x86-64 Linux 通常使用更快的 syscall/sysret 路径;32 位环境可能使用 sysenter/sysexit。AArch64 则使用 svc 指令。它们的细节不同,但安全关键点相同:用户态不能随便指定内核代码地址,入口由内核预先配置;用户态也不能把任意内核指针伪装成合法参数,内核需要重新检查。

在启动时,内核会设置中断描述符表(IDT,Interrupt Descriptor Table)之类的结构,把中断编号映射到内核处理函数。硬件只允许具有足够权限的代码配置这些入口,因此应用只能通过规定好的门进入内核。

2.4 为什么我们几乎没写过汇编

不同操作系统、不同 CPU 架构的系统调用约定并不一样。哪一个寄存器放系统调用编号?参数按什么顺序传?返回值放哪里?触发内核的指令是什么?如果每个应用都自己处理这些细节,程序将很难跨平台,内核也不敢轻易改进实现。

因此我们使用库函数:Unix-like 系统中常见的是 libc,Windows 用户空间里则有 ntdll.dll 等系统组件。你调用的 open、read、fork、exit 往往只是普通的用户态函数;函数内部才会把参数整理到寄存器里,再执行架构相关的系统调用指令。

例如 C 代码调用 exit(1) 时,库函数最终会选择正确的系统调用编号,把 1 放到约定位置,再进入内核。库函数不是魔法,只是替我们把「这台机器的这套约定」包装起来了。

有一个常见的细节:不是每个看起来像系统 API 的函数都会真的进入内核。例如 Linux 的 vDSO 会让某些只读时间信息查询在用户态完成。判断一个函数是否系统调用,最好看实现和文档,不要只看函数名字。

三、CPU 一次只做一件事,为什么能同时运行多个程序

3.1 软件中断与硬件中断

前面讲的系统调用,是程序主动执行指令进入内核,可以叫作软件中断或软件触发的陷入。

键盘按下一个键、网卡收到数据、磁盘完成一次读操作,则是硬件设备主动向 CPU 发信号。CPU 会暂停当前正常的取指流程,保存必要状态,跳到内核预先登记的中断处理函数。处理完成后,再根据保存的状态回到原来的位置。

定时器中断尤其重要。操作系统可以设置硬件定时器,让它在经过一段时间后发起中断:

进程 B调度器进程 ACPU硬件定时器进程 B调度器进程 ACPU硬件定时器设置下一次定时器中断恢复进程 A 的寄存器和页表在用户态执行时间到,发硬件中断保存进程 A 的上下文,进入内核态选择下一个可运行任务设置下一次中断恢复进程 B 的上下文在用户态执行

这叫抢占式多任务(preemptive multitasking)。浏览器没有主动说「我玩够了,你让音乐播放器执行一会儿」;定时器到点后,硬件中断把控制权交回内核,内核保存浏览器的现场,再恢复音乐播放器的现场。

3.2 时间片和上下文切换

调度器允许某个任务运行的一小段时间,叫时间片(timeslice,也叫 quantum)。最简单的方案是轮转调度:进程 A 运行几毫秒,换 B,再换 C,最后回到 A。

timeline
text
时间 ─────────────────────────────────────────────>
       | 进程 A | 调度 | 进程 B | 调度 | 进程 C | 调度 | 进程 A |
       <--- 时间片 --->
       <---------------- 目标延迟 ---------------->

如果一台机器上有 10 个可运行任务,可以设一个目标延迟,例如 15 ms,让每个任务获得大约 15 / 10 = 1.5 ms。如果只有 3 个任务,每个任务就可以获得大约 5 ms。这样任务较少时不会浪费太多时间在切换上,任务较多时又能尽量保证响应速度。

但时间片不能无限缩短。每次切换都要保存当前寄存器、指令指针、栈指针和调度相关状态,还可能切换地址空间、刷新或更新缓存。切换本身不产出用户程序的工作;如果每个任务只跑几十条指令就切换,CPU 可能大部分时间都在做调度。

因此调度器通常设有最小粒度,并综合考虑优先级、交互性、实时期限和 CPU 亲和性。Linux 曾长期使用 CFS(Completely Fair Scheduler)作为通用调度器;从 Linux 6.6 起,公平调度类逐步采用 EEVDF(Earliest Eligible Virtual Deadline First)等机制来改善延迟和公平性。这里不必把调度器理解成一个永远固定的算法:它是内核里持续演进的工程实现,但「定时器中断夺回控制权,再保存和恢复执行上下文」这条主线保持不变。

3.3 内核自己也可能被抢占

如果进程通过系统调用进入内核,而内核处理一个慢操作时完全不能被打断,那么某个程序就可能因为磁盘、驱动或复杂内核路径而拖住所有其他程序。

现代 Linux 是可抢占内核:在满足锁和上下文安全条件的地方,内核代码也可以被调度。它并不是「任何一条内核指令都能随时打断」,因为内核需要保护临界区和共享数据,但总体目标是避免长时间占用 CPU。

历史上也有协作式多任务(cooperative multitasking)。程序自己运行一会儿,主动调用 yield 或软件中断,把控制权还给操作系统。它实现简单,却把整个系统的稳定性寄托在每个程序都足够善良上:恶意程序或死循环程序不主动让出 CPU,其他任务就可能一起卡死。抢占式调度正是为了解决这个问题。

四、运行一个文件:execve 做了什么

到这里,我们知道 CPU 如何执行,也知道内核如何调度。但用户在 Shell 里输入 ./app 后,内核究竟如何把磁盘上的文件变成正在执行的程序?

4.1 exec 不是创建进程

Unix-like 系统里最重要的接口之一是 execve:

execve.c
c
int execve(
    const char *filename,
    char *const argv[],
    char *const envp[]
);

它接收文件路径、参数数组和环境变量数组。argv、envp 都以空指针结尾;我们常见的 argc 通常是程序启动代码根据 argv 算出来的。

execve 成功以后,当前进程会被新程序替换。它不是「再创建一个进程」。调用它的进程的 PID、很多资源和父子关系仍然存在,但原来的用户空间代码、地址空间和入口程序被换掉了。成功时 execve 通常不会返回;只有加载失败时,调用它的代码才会收到错误返回。

argv[0] 按惯例写成程序名,但这只是约定,不是 execve 强制规定的。调用者完全可以传入一个与真实文件名无关的字符串。这种「调用者可以传任意值,但后续代码又默认它正常」的组合,是 Unix 世界里许多奇怪行为的来源之一。

4.2 从用户态走进内核态

Shell 找到程序后会调用类似 execve("./app", argv, envp) 的接口。C 库把它包装成系统调用指令,CPU 进入内核。Linux 内核中,这条路径会继续经过 do_execve、do_execveat_common 等函数,准备一个 linux_binprm 结构。

可以把这一步想成验货台。内核需要保存:

  • 要运行的文件名;
  • 参数和环境变量的数量;
  • 新进程将要使用的内存结构;
  • 文件开头的一小段内容,用来识别文件格式;
  • 真正的解释器(如果这是脚本或动态链接程序)。

Linux 会读取执行文件开头的一段缓冲区。当前内核中的 BINPRM_BUF_SIZE 通常是 256 字节,这个数字是实现细节,不应该被当成所有系统永远不变的标准。

4.3 binfmt:文件格式识别器

内核并不只支持一种可执行文件。它会依次尝试一组 binfmt(binary format)处理器:

不是脚本是脚本ELF 魔数匹配不匹配否是execve 文件读取文件头部尝试 binfmt_script尝试 binfmt_elf找到 shebang 解释器把解释器加入 argv对解释器再次执行 binfmt加载 ELF尝试其他 binfmt找到处理器?返回 Exec format error

处理器通常查看魔数(文件开头的固定字节)、文件头和少量格式信息。Linux 还支持 binfmt_misc,管理员可以注册额外规则,例如遇到某种魔数时交给某个解释器,或者把特定扩展名的文件交给某个程序处理。

4.4 Shebang 原来是内核功能

很多脚本第一行写着:

script
bash
#!/usr/bin/env python3

我们很容易以为是 Shell 看到这行以后自己启动 Python。通常情况下,真正识别 shebang 的是内核的 binfmt_script 处理器。

内核检查文件开头是否为 #!,然后读取后面的解释器路径和参数。假设文件叫 hello.py,那么执行它时,内核可能把原来的参数改造成类似:

argv
text
["/usr/bin/python3", "hello.py", "arg1", "arg2"]

如果 shebang 本身是一个脚本,内核还可以递归地继续识别,最后找到真正的 ELF 解释器。这个过程也带来一个很实在的限制:shebang 只从开头缓冲区读取,超过当前内核缓冲区长度的路径内容会被截断。路径太长时,报错可能看起来十分玄学,因为消失的字符并不在 Shell,而是在内核读取文件头时就被舍弃了。

五、ELF:可执行文件的地图

5.1 ELF 解决什么问题

内核不能只把文件从第一个字节复制到内存,然后从文件开头执行。一个程序有代码、只读常量、已经初始化的全局变量、应该清零的全局变量、动态库信息和入口地址;这些内容需要不同的权限、不同的内存布局和不同的处理方式。

因此可执行文件需要一种格式,告诉加载器:

  • 这是什么架构的程序;
  • 它是可执行文件还是共享库;
  • 哪些文件区域需要装入内存;
  • 应该放到什么虚拟地址;
  • 每块区域允许读、写还是执行;
  • 程序从哪里开始;
  • 如果动态链接,需要哪个运行时加载器。

Linux 最常见的格式是 ELF(Executable and Linkable Format)。macOS 主要使用 Mach-O,Windows 的 .exe 使用 PE(Portable Executable)。三者的结构不同,但「可执行文件是一份给加载器看的内存布局说明书」这个思想相同。

5.2 ELF 的四张地图

一个 ELF 文件通常可以从四个角度看:ELF header、program header table、section header table 和实际数据。

ELF Header架构、类型、入口、表的位置Program Header Table运行时如何加载Section Header Table链接与调试时如何理解Data代码、常量、变量、字符串

ELF header 在文件开头,包含魔数、位数、字节序、目标架构、文件类型、入口地址,以及程序头表和节头表的位置。

Program header table(程序头表) 面向加载器。每个条目称为一个 segment,说明文件中的哪一段数据要映射到内存、映射到什么虚拟地址、文件中有多长、内存中需要多长,以及权限是什么。PT_LOAD 表示要加载的段,PT_INTERP 指定动态链接器,PT_DYNAMIC 保存动态链接信息,PT_NOTE 则存放说明性信息。

一个段的文件大小可能小于它在内存中需要的大小。多出来的部分要填零,这正好可以表示 .bss:程序需要一大片初始值为零的全局变量,但没有必要在磁盘文件里真的存一大片零。

常见权限是 R(读)、W(写)和 X(执行)。把代码段设为可执行但不可写,把数据段设为可写但不可执行,是最基本的权限隔离之一。

Section header table(节头表) 更像给链接器、调试器和反汇编器看的地图。它描述 .text、.data、.bss、.rodata、.symtab 等节的名字、类型、偏移和用途。程序运行时,加载器主要依赖程序头表;节头表甚至可以被剥掉,程序仍然能够运行,只是调试和分析会困难很多。

最常见的节可以这样理解:

节里面放什么常见权限或用途
.text机器指令可读、可执行
.rodata字符串、只读常量可读,不应写入
.data已初始化的全局变量可读、可写
.bss初始为零的全局变量只在内存中分配
.shstrtab各个节的名字帮助工具理解节结构

这里有一个很重要的区分:section 是链接和分析视角,segment 是加载和运行视角。 多个 section 可以被合并到同一个 PT_LOAD segment 中。CPU 并不需要知道「这是 .text 的第 17 个字节」,它只需要从入口地址取到正确的机器码。

5.3 静态链接与动态链接

程序通常会使用库。例如 C 程序调用 printf,它并没有在源文件中重新实现一套格式化输出;链接器需要把这个符号和某个库中的实现对应起来。

静态链接会把所需的库代码复制进最终可执行文件。优点是运行时依赖少,缺点是每个程序都携带一份相似代码,文件和内存可能更大。

动态链接只在程序里留下「我需要哪个库的哪个符号」这样的信息,运行时再由动态链接器找到共享库并解析这些符号。Linux 上共享库通常是 .so 文件,Windows 是 .dll,macOS 是 .dylib。共享库本身也通常是某种可加载的目标文件。

动态链接的好处不只是节省磁盘空间。多个进程可以把同一个共享库的只读代码页映射到同一组物理内存中,代码只保留一份;每个进程的可写数据仍然分开。这样既方便集中修复库,也能节省内存。

代价是启动时多了一层工作:动态链接器要加载依赖库,完成符号解析和重定位。ELF 的 PT_INTERP 会告诉内核应该使用哪个 ELF 解释器,例如某些 x86-64 Linux 系统上的 /lib64/ld-linux-x86-64.so.2。

5.4 内核如何真正启动 ELF

以一个动态链接的 Linux ELF 为例,execve 的后半段大致是:

  1. 内核读取 ELF header,检查目标架构和文件类型。
  2. 遍历 program header table,找到所有 PT_LOAD 段。
  3. 按照每个段的偏移、大小和权限建立新的虚拟内存映射。
  4. 把需要的代码和静态数据准备好,给 .bss 等区域分配清零的内存。
  5. 读取 PT_INTERP,加载动态链接器本身。
  6. 把 argc、argv、环境变量和辅助信息放到新程序的用户栈上。
  7. 设定返回用户态时应恢复的指令指针。
  8. 系统调用返回用户态,CPU 开始执行动态链接器。
  9. 动态链接器加载共享库并完成重定位,然后跳转到应用真正的入口。

如果是静态链接程序,内核可以直接把指令指针设到程序入口;如果是动态链接程序,第一次进入用户态时,执行的往往是动态链接器,而不是 main。

main 也不是 ELF 入口。入口通常会先经过 C 运行时启动代码,处理栈上的参数、初始化 TLS 和全局对象,最后才调用 main;main 返回以后,还会经过退出清理并进入 exit 系统调用。我们平时写的那几行 C 代码,只是整个启动和退出链条中间的一小段。

六、内存是「假的」:虚拟地址与分页

6.1 每个进程为什么都有自己的地址

前面说 ELF 可以要求把数据加载到某个虚拟地址。那多个进程都使用相似的地址,不会互相覆盖吗?

答案是:程序看到的地址通常不是 RAM 芯片上的物理地址。

CPU 发出的地址先交给 MMU(Memory Management Unit,内存管理单元)。MMU 根据当前进程的页表,把虚拟地址翻译成物理地址。进程 A 的虚拟地址 0x400000 可以映射到一块物理内存,进程 B 的同一个虚拟地址则可以映射到另一块物理内存。

进程 A 看到的虚拟地址0x400000进程 A 的页表物理页 0x123000进程 B 看到的虚拟地址0x400000进程 B 的页表物理页 0x9AB000RAM

内存被切成固定大小的 page,物理内存对应 page frame。页表记录「虚拟页到物理页框」的映射以及读写执行权限。当程序访问一个虚拟地址时,MMU 查页表并完成转换;如果没有合法映射,CPU 触发 page fault,把控制权交给内核。

分页同时解决了隔离和灵活布局:进程不需要知道自己在 RAM 的哪个位置,操作系统也可以把页放在不连续的物理位置。页表通常是多级的,虚拟地址被拆成多个索引,层层找到最终页表项;CPU 还会用 TLB 缓存最近的翻译结果,避免每次访问都从头查表。

6.2 权限不只在用户态这一层

页表项可以标记某个页面是否存在、是否可写、是否可执行、是否属于用户空间。即使程序运行在用户态,它也只能访问当前页表中标记为用户可访问的页。

这就是为什么一个进程不能直接读取另一个进程的秘密:不是因为 CPU 认识「别人的内存」,而是因为当前页表根本没有把那个物理页面映射成可访问的虚拟地址。内核切换到另一个进程时,也会切换页表上下文。

现代系统还会使用 W^X、NX 等策略,尽量不让同一个页面同时可写和可执行,以降低代码注入风险。实际保护策略还会受到 JIT、共享内存、内核配置和架构特性的影响,不能把它们简化成一句绝对规则。

6.3 缺页与按需分配

虚拟地址空间可以比当前实际占用的物理内存大得多。内核可以先建立一个映射,等程序真正访问某一页时再分配物理页;这叫按需分页(demand paging)。

所以「加载 ELF」也不一定意味着把整个文件一次性复制到 RAM。很多映射会与文件建立关联,第一次访问时触发缺页,内核再把对应数据读入物理页。空闲的页面还可能被换出,之后重新加载。程序看到的是连续、稳定的虚拟地址空间,背后却一直在动态管理物理页。

七、fork:一个进程如何生出另一个进程

7.1 创建第一个用户程序的问题

execve 只能替换当前进程。那第一个 Shell 是谁创建的?Shell 又如何启动我们输入的程序?

Unix-like 系统的经典答案是 fork + exec:先复制一个进程,再让子进程调用 exec 换成目标程序。

Shell 进程fork父进程继续运行 Shell子进程拥有父进程的副本设置重定向、权限、工作目录等execve 目标程序当前子进程被 ELF 程序替换等待子进程或继续处理命令

调用 fork 后,父子两个执行流都会从 fork 返回位置继续,但返回值不同:父进程得到子进程 PID,子进程得到 0。程序通常通过这个差异判断自己是哪一个,然后由子进程设置标准输入输出、关闭不需要的文件描述符,再执行 execve。

7.2 复制全部内存?没那么笨

如果 fork 真把父进程几 GB 的内存全部复制一份,启动一个命令会非常昂贵。实际系统使用写时复制(Copy-on-Write,COW)。

fork 初始时,父子进程的页表都指向同一批物理页;这些页面暂时标记为只读。只要双方都只读,完全可以共享。某一方尝试写入时,CPU 发现页面没有写权限,触发 page fault;内核确认这是合法的 COW 写入后,才复制该物理页,把新页映射给写入者,并将其改回可写。

否是fork父子页表指向同一物理页页面暂时只读某一方写入?继续共享,同一份物理页触发 page fault内核分配新物理页并复制内容写入者指向新页并设为可写父子进程拥有各自版本

这正好解释了为什么 fork 后立刻 exec 很高效:子进程几乎没有机会修改旧地址空间,旧页面不需要真的复制;exec 很快就会丢弃旧页表,建立新程序的地址空间。

在现代 Linux 中,fork 的内部实现比「复制一个结构体」复杂得多,还要处理文件描述符、信号、线程、锁、内存映射和各种资源。但 COW 仍然是这个模式能够工作的关键。

7.3 init:进程树的根

Unix-like 系统中,几乎每个用户空间进程都可以沿着父进程关系追溯回一个特殊的 PID 1 进程:init。它是内核启动的第一个用户空间程序,也是系统关机时最后处理善后的进程之一。

机器启动时大致经历:

主板固件找到并加载 bootloaderbootloader 加载内核内核初始化中断、驱动和内存内核创建 PID 1PID 1 exec /sbin/init 或systemd启动服务、登录管理器、ShellShell fork + exec 用户程序

在内核还没有启动 PID 1 以前,并不存在通常意义上的用户态进程树、系统调用和调度环境。bootloader 的工作方式更接近「把下一段机器码放入内存,然后跳过去执行」,没有一个普通程序可以在它结束后等待返回。

Linux 内核初始化完成后会创建一个内核线程,进一步寻找配置的 init 程序,常见位置包括 /sbin/init,也可能是指向 systemd、OpenRC 或其他实现的符号链接。找不到可执行的 init,内核会 panic,因为没有进程树的根,后面的用户空间无从开始。

PID 1 随后通过 fork 和 exec 启动服务。服务再启动自己的子进程,Shell 再启动命令。于是你看到的桌面程序、终端和后台服务,都是这棵进程树上的节点。

不要真的在生产机器上执行 sudo kill 1 来验证这个过程。PID 1 被杀死通常意味着整个用户空间会被迫停止,最好的结果也是系统立即重启,最坏的结果是你开始认真思考备份为什么总是应该昨天做。

八、把完整旅程串起来

现在回答开头的问题:在 Linux x86-64 上,我们从终端运行一个动态链接的 ELF 程序,大致发生了什么?

新程序CPUMMU / 页表Linux 内核Shell(用户态)新程序CPUMMU / 页表Linux 内核Shell(用户态)fork()创建子进程,复制页表并设置 COW父子进程分别返回子进程 execve("./app", argv, envp)读取文件头,尝试各个 binfmt识别 ELF,解析 program header建立新的虚拟地址空间和页表映射 PT_LOAD、栈、共享库和动态链接器设置用户态入口指令指针从入口开始取指执行read/write/mmap 等系统调用返回结果定时器中断保存当前上下文,选择另一个任务恢复另一个任务的上下文

换成更朴素的语言就是:

  1. Shell 先 fork 出一个子进程。
  2. 子进程根据需要设置重定向、环境和文件描述符。
  3. 子进程调用 execve,请求内核用目标程序替换自己。
  4. 内核读取文件头,选择对应的 binfmt 处理器。
  5. 如果是脚本,内核找到 shebang 指定的解释器;如果是 ELF,内核开始解析 ELF。
  6. 内核按 program header 的要求建立新的虚拟内存映射,必要时准备动态链接器。
  7. 内核把参数和环境放到新栈上,把 CPU 的指令指针设到入口地址。
  8. CPU 在用户态取指、执行,程序真正开始运行。
  9. 程序要读文件、写终端或联网时,通过库函数触发系统调用,暂时进入内核态。
  10. 定时器中断不断让内核重新夺回控制权,保存一个进程的现场,恢复另一个进程的现场。
  11. 页表保证不同进程看到各自的地址空间,COW 则让 fork 在不立即复制全部内存的情况下完成克隆。

九、几个容易混淆的结论

9.1 「程序」不是一个总在运行的东西

磁盘上的 ELF 文件是静态数据,进程是内核管理的运行实例,线程是实际被调度的执行上下文。一个程序文件可以被启动很多次,产生很多进程;一个进程也可以通过 exec 先后运行不同的程序文件。

9.2 fork 和 exec 是两件事

fork 复制一个进程的运行环境,exec 用一个新程序替换当前进程。把两者合在一起说成「启动程序」很方便,但理解两者的区别非常重要:PID 通常在 fork 时产生,程序入口在 exec 后改变。

9.3 main 不是第一条指令

ELF 入口、动态链接器入口、C 运行时入口和 main 是不同层次的入口。main 之前还有参数准备、重定位、库初始化和运行时初始化;main 返回以后也还有清理和退出系统调用。

9.4 系统调用不是普通函数调用

普通函数调用只是在当前权限级别改变指令指针和栈。系统调用会经过 CPU 提供的受保护入口,切换到内核权限,并让内核验证参数。库函数可以长得像普通函数,但内部是否进入内核要看实现。

9.5 CPU 的「同时」是错觉,也是工程成果

单个 CPU 核心在一个时间点只执行一条指令,但定时器中断、上下文保存、调度器和多级缓存让多个任务轮流获得执行机会。多核机器能真正并行执行多个线程,但每个核心内部仍然需要同样的控制流、权限和内存保护机制。

十、最后再从头看一遍

最底层的 CPU 很朴素:它有一个指向内存的指令指针,取出指令、执行指令,通常前进到下一条;遇到跳转或中断时,去另一个位置继续。

操作系统利用硬件提供的权限级别,把内核和普通程序分开。应用运行在用户态,想接触文件、网络、设备和进程管理,就通过系统调用进入内核。操作系统利用定时器中断周期性地夺回 CPU,把当前进程的寄存器、栈和地址空间保存起来,再恢复另一个进程,于是一个核心也能让多个程序保持响应。

虚拟内存则在中间加了一层翻译:程序使用虚拟地址,MMU 根据当前页表找到物理页面。每个进程可以拥有看起来独立的地址空间,非法访问会触发异常而不是直接读到别人的数据。fork 通过 COW 共享尚未修改的页面,exec 再把当前进程换成新的地址空间。

所以,「运行一个程序」并不是把文件扔给 CPU 就结束了。它是一条由多个抽象共同完成的链:

text
源代码
  ↓ 编译、汇编、链接
可执行文件(通常是 ELF)
  ↓ fork 创建执行者
execve 请求内核替换进程
  ↓ binfmt 识别格式
解析 ELF,映射代码、数据、栈和共享库
  ↓ 设置页表与入口指令指针
用户态开始取指执行
  ↓ 系统调用 / 硬件中断 / 定时器
内核管理资源并调度回用户态

下一次看到浏览器同时播放音乐、渲染网页、等待网络数据时,可以把那层「应用程序正在做很多事情」拆成更具体的画面:CPU 在一段段机器码之间移动,定时器在合适的时候打断它,内核保存和恢复状态,MMU 翻译地址,系统调用替程序向硬件申请服务。

我们平时写的业务代码离这些细节很远,但它们从来没有消失。它们只是被编译器、运行时、标准库和操作系统包装得足够好,让我们可以暂时假装计算机真的懂「程序」是什么。

作者Aoang
发布于2022-02-04
更新于2022-02-04
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。