☰
从shell到内核:程序执行与进程管理的完整脉络解析
2026/10/21 3:40:14 网站建设 项目流程

很多人初学操作系统,卡住的第一关往往不是调度算法,也不是死锁原理,而是一个非常朴素的问题:程序到底是怎么“跑起来”的?我在啃 UNIX V6++ 源码的时候也有同样的困惑,明明每个模块都能看懂大概,但把“磁盘上的可执行文件”和“CPU 里正在跑的进程”这两者联系起来却花了很长时间。后来我把进程管理里“程序的执行”这条线单独拎出来,从 shell 敲下一条命令开始,一路追到 exec、fork、wait、上下文切换,才终于把整条脉络串通。这篇就是当时的学习笔记记录,适合正在学操作系统、准备做进程实验,或者想彻底搞懂 shell 为什么能“分身”的读者。下面全部基于 UNIX V6++ 教学模拟环境来聊,遇到和真实 UNIX V6 有出入的地方我会说明。

1. 一条命令背后隐藏的程序执行主线

1.1 从 shell 敲下 ls 开始

我学习时的切入场景很简单:在 shell 里敲一个ls,回车,目录内容列出来,shell 提示符再次出现。这个过程中 shell 自己并没有去读目录,也没有变成 ls,而是先“复制”出一个和自己几乎一样的子进程,接着让子进程把手上的程序整体换掉,换成 ls,子进程执行完毕退出后,shell 再把它的状态收回来。这里一共牵涉到三个系统调用:fork、exec、wait。

把这条链路拆开看,shell 的逻辑大致是:

  1. 调用fork创建子进程。
  2. 子进程里调用exec,把 ls 这个可执行文件装入内存。
  3. exec成功后,子进程的地址空间被 ls 的映像完整替换。
  4. ls 运行结束后调用exit退出。
  5. shell 这边调用wait,获取子进程的退出状态。

我后来发现,只要把这条主线吃透,进程管理的一大半核心概念都能顺带掌握,包括进程表项的变化、地址空间的分配与释放、系统调用从用户态进入核心态的路径、进程睡眠与唤醒的时机。它几乎是一个把文件系统、内存管理、进程表、中断机制全部串起来的集成实验。

1.2 一个程序的三副面孔

学习过程中我习惯把一个“程序”分成三种形态来看,否则会经常被绕晕。

第一种是磁盘上的可执行文件。它是一堆静态的二进制数据,有一个文件头,后面跟着代码段和数据段。这个形态只和文件系统打交道,进程还没有诞生。

第二种是进程的地址空间。可执行文件被加载到内存之后,形成文本段、数据段、bss 段、用户栈这一整套布局。这个形态和内存管理打交道,进程表里会记录这块空间的起始位置和大小。

第三种是 CPU 执行中的现场。程序真正运行时,就是 PC 指向某条指令,寄存器里保存着中间结果,栈里压着函数调用的返回地址。这个形态和处理机状态相关,也是上下文切换时保存和恢复的对象。

在学习 UNIX V6++ 时,这三种形态的切换都可以在模拟器和源码层面直接观察到,这是我特别喜欢它的原因。

1.3 为什么用 UNIX V6++ 抓这条主线

说实话,如果一上来就用现代 Linux 学“程序的执行”,很容易被 ELF 解析、页表映射、VMA、PLT/GOT、cgroup 这些外围机制带偏。UNIX V6++ 的好处是把内核逻辑简化到了一个非常干净的程度:进程还是那批 proc 表项,内存管理就是一块连续物理内存的分配和释放,可执行文件头也就那么几个字段。即便教学版本为了可读性做了不少简化,但核心骨架和真实 UNIX V6 高度一致,非常适合用来理解主干。

2. 可执行文件的内存蓝图:文件头里到底写了什么

2.1 文件头字段逐个拆解

UNIX V6++ 里的可执行文件,开头会有一个结构固定的头部。不同教学版本字段命名可能略有差异,但含义基本一致,我按自己看源码时常用的方式整理如下:

字段作用备注
魔数标识该文件是否是可执行文件校验失败时 exec 直接报错
文本段长度共享只读代码区域的大小通常用块或字节表示
数据段长度已初始化数据区域的大小需要从文件里读入内存
bss 段长度未初始化数据区域的大小不占文件空间,但内存里要清零
入口点程序第一条指令的地址教学版本也可能默认从文本段起点开始
标志位标记文本段是否允许共享影响加载策略

我第一次看这个结构时觉得太简单了,但真正有用的恰恰是这种简单。这个头部就是“内存蓝图”,内核要照着这张蓝图去分配内存、拷贝数据、设栈顶、跳入口。

2.2 文本段、数据段、bss 段的分工

我常用一个生活类比来解释这三个段的区别:文本段像一本只读教材,多个学生可以同时看同一本;数据段像每个人自己带的笔记本,初始内容已经写好了;bss 段像草稿纸,一开始是空白的,但必须给每个人发几张才能开工。

在地址空间布局上,数据段和 bss 段是进程私有的,文本段则是“可以共享的代码区域”。加载器根据文件头里的三种长度,先算出总共需要多少内存,再决定文本段是新建还是复用已有的一份。数据段从文件里读入,bss 段直接清零,用户栈从这块私有内存的顶端向下生长。

2.3 共享文本段的引用计数:多进程执行同一程序的关键

这里有个非常值得展开的细节:当多个进程同时执行同一个可执行文件时,内核不会给每个进程都复制一份代码。UNIX V6++ 沿用了真实 UNIX V6 的 text 表,每个共享文本段占用一个表项,表项里维护着一个引用计数。新进程 exec 同一文件时,先查一下是否已经有对应的 text 表项,有就把引用计数加一,地址空间映射到同一份文本段;没有才真正从磁盘读取代码并分配内存。

为什么要关注这个?因为现代系统里的共享库、同时开多个相同进程的场景,底层思路都是一样的。而且学习时经常会碰到一些“奇怪现象”,比如修改了正在执行的文件,旧代码还在跑;或者删掉了文件,进程照样运行。理解了共享文本段和引用计数之后,这些现象就一点也不奇怪了——文件内容一旦被读入内存,就和磁盘上的文件本身分离了,进程只依赖内存里的映像。

3. exec 的完整内核路径:旧外衣怎么换成新外衣

3.1 从权限检查到释放旧领地

exec 这个系统调用在用户程序里看起来就是一个普通函数调用,但内核那边做的事情相当多。按我梳理的执行流程,第一步是合法性检查:先看文件是否存在、当前用户是否有执行权限,然后读取文件头并校验魔数。魔数不对的话,exec 直接失败返回,调用者的地址空间原封不动。

校验通过后,内核要开始“腾地方”。进程原来的地址空间包括它自己引用的文本段、私有的数据段和用户栈,这些都要被释放。如果是共享文本段,就把它在 text 表里的引用计数减一;如果减到零,说明没有其他进程在用了,这块内存也要回收。私有的数据段和栈内存直接归还给内存管理器。

这里有个很容易被忽略但很关键的点:真实实现中通常先释放旧映像,再分配新映像,因为如果反过来,可能因为内存不足导致分配失败,而这时旧映像已经被释放,进程就没有回滚的余地了。这个取舍背后是“程序执行”这种不可逆操作的特点,学习时值得想一想。

3.2 分配新地址空间:文本段和数据段分别处理

释放旧空间之后,内核开始照新文件头分配内存。分配策略大致如下:

  • 如果文件头标记文本段可共享,先查 text 表里是否已有同文件的表项。有则直接建立引用,引用计数加一;没有则分配一块新的文本段内存,把文件里的代码部分读进去。
  • 按“数据段长度 + bss 段长度 + 栈空间”计算私有内存的总大小,分配一块连续内存。
  • 把文件数据段部分拷贝到这块内存的起始位置,bss 部分用零填充。
  • 把这块内存的高地址端作为用户栈的栈顶起始位置。

在模拟器里,此时观察进程表项的p_addr、p_size以及引用的文本表项,能非常直观地看到“这个进程的地址空间从哪开始、占多大、共享了哪段代码”。我当时就是在这一步把内存分配和进程管理的联系建立起来的。

3.3 用户栈的构造:参数和环境变量为什么要这样压

exec 成功之后,程序并不是直接跳到 main 函数,而是先跳到 C 运行库的启动代码,由启动代码从栈上取参数,再调用 main。所以 exec 里有一个非常重要的操作:构造用户栈。

栈从高地址向下生长。内核往栈里压入的内容顺序大概是这样:先压参数和环境变量字符串,然后压这两个指针数组,再压一个 argc,最后再留一个“返回地址”的位置。为什么这个顺序很重要?因为压栈是“先压的在更高地址”,而启动代码拿到的是栈顶指针,它必须从栈顶先取到 argc,再顺着指针数组去访问字符串。如果字符串没先压好,argv 指针数组里的指针就没法指向确定的位置。

教学环境里你可以做一个实验:在 exec 返回用户态之前,用调试器打印用户栈顶往下几十个字的内容,你会清楚地看到argc、各个指针、以\0结尾的字符串,这个结构比任何教材描述都直观。

3.4 回到用户态的那一刻:exec 其实从不“返回”

这是我在学习过程中觉得最反直觉的一点:exec 这个系统调用,对调用者来说,其实永远没有“返回”这回事。它最后一步是伪造一个“系统调用正常返回”的现场,把这个现场设置成:PC指向新程序的入口点,SP指向构造好的用户栈顶,状态字切回用户态,通用寄存器被设成约定的初始值。然后做一次普通的系统调用返回操作,处理器就从内核态弹回用户态,开始执行新程序的第一条指令。

也就是说,exec 的返回值 0,只有在新程序的角度看才存在。对于原来的调用者来说,它已经被彻底替换掉了。这就好比一个人换了身份、换了地址,他并不是从原来的地方重新出发,而是从新的起点开始过新的生活。理解这一点之后,再看 fork 返回两次的问题就顺理成章了。

下面给一个简化版的 exec 核心流程伪代码,方便对照源码看:

// 简化版 exec 核心流程,教学版本往往在此基础上调整 static int do_exec(ip, args, envp) { // 1. 权限检查与读取文件头 if (!(ip->i_mode & IEXEC)) return -1; read_header(ip, &hdr); if (!valid_magic(hdr)) return -1; // 2. 释放旧进程地址空间 free_data_segment(curproc); release_text_ref(curproc->p_textp); // 3. 分配新空间 if (hdr.flags & TEXTSHARE) { curproc->p_textp = attach_text(ip, hdr.text_size); } curproc->p_addr = allocate(hdr.data_size + hdr.bss_size + STACK_SIZE); copy_from_file(ip, curproc->p_addr, hdr.data_size); clear_bss(curproc->p_addr + hdr.data_size, hdr.bss_size); // 4. 构造用户栈 setup_user_stack(curproc, hdr.entry, args, envp); // 5. 伪造系统调用返回现场 set_pc(hdr.entry); set_sp(user_stack_top); set_psw(USER_MODE); return 0; }

4. fork 和 wait:程序执行的分身与收尸

4.1 fork 返回两次的机关在哪里

程序的执行很少是孤立的,shell 执行 ls 的过程里,必须先 fork 出一个子进程。fork 最神奇的地方在于:调用一次,返回两次,父进程拿到子进程的 PID,子进程拿到 0。这个“机关”的底层原理,其实就是寄存器和栈的复制。

内核在 fork 时,会把父进程的完整执行现场复制给子进程,包括用户栈、数据段、寄存器现场。关键在于:这个“寄存器现场”里有一份是系统调用处理时保存的返回寄存器位置。内核在子进程的内存里,把那个位置的值直接改成 0,而父进程保留原来的返回值(子进程 PID)。于是两个进程各自继续执行时,看起来就同一个 fork 调用返回了不同的值。

我不要小看这个细节。理解了 fork 的返回机制,你就理解了进程切换的本质是现场保存与恢复,也就理解了为什么子进程能从头继续执行“fork 之后的代码”。

4.2 exec 之后,子进程为什么不再执行 shell 的后续逻辑

一个常见困惑是:shell fork 之后,子进程也站在“fork 返回处”,它接下来凭什么能跳出 shell 的主流程去执行用户输入的命令?答案就是子进程调用了 exec。exec 把子进程的整个地址空间替换成了新程序的映像,包括那部分“fork 之后判断自己是父还是子”的代码。所以子进程永远不会回到 shell 的解析逻辑里继续执行,因为那些代码已经不存在了。

这个理解上的突破非常重要:fork 和 exec 是两个独立的系统调用,一个负责“分身”,一个负责“变身”,合在一起才是 shell 执行命令的完整姿势。如果只有 fork 没有 exec,子进程只能复制一个和自己一模一样的进程;如果只有 exec 没有 fork,shell 自己就被替换成 ls 了,哪还有机会等你输入下一条命令。

4.3 wait 的睡眠、唤醒与僵尸状态

程序执行完之后,父进程还要收场。父进程调用wait,内核会先检查有没有已经是僵尸状态的子进程。如果有,直接收集退出状态并释放进程表项;如果没有,父进程就在 proc 表里标记自己正在等待子进程,然后睡眠。

子进程这边的执行终点是exit。它要做的事情包括:关闭所有打开的文件、释放数据段和文本段引用、把进程状态改成僵尸,然后唤醒父进程。这里需要注意:子进程退出时进程表项不会立即释放,因为内核要保留一份退出状态等着父进程来取。这就是僵尸状态存在的原因。

我在实验里见过不少这种场景:父进程忘了调用 wait,子进程退出后成了僵尸,进程表项迟迟不回收。在现代系统里你能用ps看到僵尸进程,原理一模一样。所以我说 wait 是程序的执行这条链路里必不可少的“收尸环节”,少了它,整个执行生命周期就不完整。

5. 上下文切换与系统调用:执行现场是怎么保存和恢复的

5.1 一个“执行现场”包含什么

前面讲的都是进程怎么从无到有、从旧程序变成新程序,但程序运行时还有一个绕不开的机制:它跑着跑着也会被切走,过一会儿又被切回来。这就涉及执行现场的保存和恢复。

我把执行现场拆成两块。一块是处理机现场:程序计数器、栈指针、状态字、通用寄存器。另一块是进程内核现场:进程的核心栈、u 区、以及 proc 表项里的状态信息。UNIX V6++ 在这点上和真实 V6 一脉相承,切换进程时会先把当前处理机现场压入旧进程的核心栈,然后切换栈指针到新进程的内核栈,再把新进程之前保存的现场弹出来。

5.2 switch 的移交过程:睡一觉醒来已经在别的进程

V6++ 里的进程切换代码,我建议直接对着源码一段段看,逻辑非常清晰。大致过程是:

  1. 把当前进程的寄存器现场保存到核心栈上,并把新的栈指针值记录到 proc 表项或 u 区的对应字段。
  2. 根据调度策略选择下一个要运行的进程,更新全局的当前进程指针。
  3. 从新进程的记录中取出它上次保存的栈指针,切换过去。
  4. 执行弹栈操作,把之前保存的 PC 和寄存器恢复,新进程就在“上次让出 CPU 的地方”继续往下走。

这里最值得体会的一点是:一个进程是被切走再切回来的,它自己完全感知不到中间发生了什么,它只觉得自己在那个系统调用里短暂停顿了一下,接着就继续执行了。这个假象之所以成立,就是因为现场的保存和恢复是精确的、完整的。

5.3 用户态到核心态的 trap 通道

程序的执行过程中,系统调用怎么进入内核?答案是 trap。用户程序执行到一条 trap 指令时,处理器切换到核心态,把当前 PC 和状态字保存好,跳转到内核的 trap 入口。内核再根据 trap 类型和系统调用号,派发到对应的系统调用处理函数。

这条通道极其重要,因为 exec、fork、wait、read、write 全都要走它。V6++ 里每个进程都有自己的核心栈,trap 处理过程中就用这块栈来保存用户态寄存器。回到用户态时,再从核心栈的保存区恢复所有寄存器,然后切回用户态继续执行。理解了这条通道,再回头看 exec 的“伪造现场”,就明白它其实是对这套返回机制的一个巧妙利用。

5.4 系统调用参数是从哪儿取出来的

用户程序调用 write 这类系统调用时,参数放在哪里?不同教学版本的约定不太一样,常见做法是把参数压到用户栈上,再把栈指针或参数指针放在寄存器里传给内核。内核进入系统调用处理函数后,需要用用户栈指针从用户空间取出参数。

这里有一个必须强调的要点:内核不能无条件相信用户传进来的指针。地址是否落在当前进程的地址空间内、长度是否越界,都需要检查。在 V6++ 里这一层逻辑做得比较直接,但它是理解“用户态与核心态之间如何安全传参”的关键入口,也是程序执行过程中防止越权的核心防线。

6. 调试与踩坑:我学习过程中的实操笔记

6.1 观察 exec 前后进程表结构的变化

我在实验时最常用的手段,是在 exec 实现的入口和出口分别加打印语句,输出当前进程的地址空间起始位置、大小、文本段引用计数。对比这两组输出,你会直接看到:入口时还是旧的 p_addr,出口时已经指向新的内存区域;旧文本段的引用计数减了一,新文本段的引用计数有可能加了一(如果之前已经有进程在跑同一个文件)。

这里一个容易踩的坑是:V6++ 教学版的字段命名在不同分支里可能不一样,比如有时叫p_textp,有时叫p_text。我建议先看结构体定义,再搜引用位置,确认清楚再动手加日志,否则会浪费很多时间在编译错误上。

6.2 参数太多导致栈溢出的表现

exec 构造用户栈时,如果参数太多、环境变量太长,栈空间可能不够用。实际表现不一定报错,可能只是启动代码读到错乱的参数,变量值变成奇怪的值,甚至程序直接崩溃。

这其实是一个很好的实验题目:人为往 exec 传几十个很长的环境变量,观察用户栈顶附近的数据。你会在源码里看到用户栈的可用空间是有限的,因为数据段、bss、栈共用一块私有内存。学习的时候主动制造这种越界,比单纯读代码印象深得多。

6.3 建议从“加载”入手,而不是一上来就啃调度

我在学习中走过弯路:一开始直接扎进调度算法,研究哪个进程什么时候被选中,结果越看越晕。后来换了个顺序,先看 exec 如何加载程序,再看 fork 如何复制现场,然后看 wait 如何回收,最后才回头看调度,发现一切都顺了。

顺序很重要。程序的执行首先是构建进程的地址空间,调度只是把 CPU 让给谁的问题。没有正确的地址空间,调度做得再精细也白搭。如果你也卡在这个上面,我建议先放下调度那一章。

6.4 一个扩展实验:自己写一个迷你程序加载器

如果你想把“程序的执行”彻底变成自己的东西,我强烈推荐做这个扩展实验:在内核里新增一个自定义系统调用,让它手动完成一次简化版 exec。

实验步骤可以这样设计:

  1. 自己定义一个最简单的二进制文件格式,文件头只有魔数、代码长度、入口地址。
  2. 系统调用读取该文件,把文件内容复制到内核缓冲区。
  3. 释放当前进程的数据段和栈空间,分配一块新内存。
  4. 把文件里的代码复制到新内存,构造最小的用户栈,把入口地址写进现场。
  5. 返回用户态,让进程从你指定的代码开始执行。

这个实验做通之后,再回头看 exec 源码,你会觉得每一个字段、每一次分配都是理所应当的,因为你自己就实现过一遍精简版。

6.5 我的一个学习顺序建议

最后分享一点我个人的实操体会。学“程序的执行”这条线,我认为比较高效的主线顺序是:先读可执行文件头部的定义,再顺着 exec 的代码追一遍加载流程,中间在模拟器里设几个断点观察进程表字段变化;之后再看 fork 和 wait,搞清楚父子进程的配合;最后再看一次 switch 切换现场。如果时间允许,把 6.4 的迷你加载器实验做出来。

我按照这个顺序学完之后,再回头做进程管理的实验题,明显感觉思路清楚了很多,至少在看到“进程状态变化”的时候,能立刻联想到它背后对应的地址空间和现场保存动作。希望这份笔记也能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询