程序如何变成进程?UNIX V6++源码拆解exec完整执行链与进程映像 先问一个问题当你敲下./hello然后回车到屏幕上出现hello, world中间究竟发生了什么如果你只能答出“CPU执行了程序”那这门课还没学透。我在啃某高校系统能力课程的 UNIX V6 教学模拟器时对“程序的执行”这一章感触最深。教材里进程三状态背得滚瓜烂熟可一旦问到“hello 这个可执行文件是怎么从磁盘上变成进程映像的”“exec 系统调用在核心态里面到底干了哪几件事”“fork 出来的子进程第一次被调度时CPU 是怎么跳到用户态入口的”很多人就卡住了。UNIX V6 最大的价值在于它是真实 UNIX V6 的教学复刻代码量不大但一个程序从文件到进程的全部执行路径你都能在源码里一路追下来。这篇文章不抄概念就按我实际调试源码的顺序把程序执行这条链路拆开讲一遍顺便把踩过的坑都标出来。1. 程序到进程先建立整体执行路径1.1 “程序”和“进程”差在哪先说定义。程序是一个静态的、躺在磁盘上的可执行文件它什么也干不了进程是一个动态的执行实体有生命期、有资源、有状态。把“程序”变成“进程”要经历两件大事创建进程的骨架也就是分配 proc 结构、分配内存、初始化 PCB把可执行文件的内容读进内存替换新进程的映像。在 UNIX V6 里前者靠fork后者靠exec。这两个系统调用合在一起才构成一次完整的“程序执行”。你在 shell 里每敲一条命令shell 都先 fork 一个子进程然后子进程去 exec 对应的可执行文件父进程则 wait 等待子进程结束。一开始我总觉得 fork 和 exec 是两件独立的事后来才想明白exec 本身不创建进程它只是为“当前这个进程”换一副全新的面貌。也就是说exec 的工作重点是“装载”和“替换”而不是“创建”。1.2 UNIX V6的进程映像代码、数据、栈和u区进程映像指的是一个进程在内存中的所有内容它不只是一段代码而是分区域的。UNIX V6 的进程映像大致由四块组成。部分内容特性代码段text可执行文件的指令部分只读可共享数据段data全局变量、静态变量、堆区读写动态增长用户栈stack函数调用帧、局部变量、参数向下增长u区user structure进程的用户态寄存器现场、系统调用参数、文件描述符表等仅内核可见随进程切换UNIX V6 的进程地址空间不是像现代 Linux 那样把代码、数据、堆、栈都放在一个虚拟地址空间里它继承了 V6 的 PDP-11 硬件特征。PDP-11 的指令空间和数据空间是分离的所以代码段可以使用独立的指令空间数据段和栈共享数据空间。这一设计让进程不但能突破 64KB 的寻址限制还让代码段的共享变得非常自然。我当时看源码时的第一个顿悟就是程序执行的核心不是“CPU 跑起来”那个动作而是“把进程映像准备到可以被调度器接手”的整个过程。映像不准备好调度器想调度也无从下手。1.3 为什么 UNIX V6 适合研究执行机制UNIX V6 是教学用途的复现版本核心算法基本保留 V6 原貌但简化了硬件细节。它比现代 Linux 的 execve 实现简单得多没有复杂的 ELF 解析、没有文件系统缓存层、没有内存映射 mmap、没有写时复制机制成熟版本才有。正因为简单exec 的核心逻辑可以一个函数一个函数地读完。我花了一个下午把exec.c从头到尾过了一遍再把trap.c和slp.c里的调度切换流程串起来“程序执行”这幅拼图就完整了。2. 可执行文件与装载exec的前半程2.1 a.out文件头magic number 决定进程的内存布局UNIX V6 的可执行文件叫a.out它有自己的固定格式。文件头最重要的字段是魔数magic number它决定了内核用什么样的方式装载这个文件。V6 常见的三种魔数魔数含义进程空间0407不分 I/D 空间代码和数据在同一空间0410分离 I/D 空间指令与数据分离0411分离 I/D 且共享代码段代码段可共享UNIX V6 里链接器生成可执行文件时通常使用 0410 或 0411。内核在读文件头后第一步就是检查魔数如果不对直接返回错误。我在调试时犯过一个低级错误手工改了一处字节结果 exec 失败进程没有变成新程序而是回到原来的代码里执行。这个后面在故障排查章节细说。文件头之后是代码段、数据段。数据段之后可能还有符号表和字符串表用于调试和动态链接。V6 的加载方式很简单内核根据文件头里记录的 tsize 和 dsize把代码段读入指令空间把数据段读入数据空间。2.2 exec系统调用怎么进入核心trap与陷入机制用户态的程序不能直接调用系统调用必须通过陷入指令进入核心态。在 PDP-11 上这条指令是trap。用户程序里其实没有显式的 trap 指令C 库函数和汇编入口替你包了一层。整体流程是用户程序调用execve之类的库函数库函数把系统调用号放入某个寄存器然后执行trap指令CPU 切换到核心态保存用户态现场跳转到内核的陷入入口内核根据系统调用号查sysent表找到对应的处理函数处理完返回值执行rtt指令恢复用户态现场回到用户程序继续运行。在 UNIX V6 的源码中这一套逻辑集中在trap.c。系统调用号是硬编码的约定例如 fork 是 2exec 是 11。/* sysent.c 中的一部分表项示意 */ int fork(), exec(), read(), write(), open(), close(); int wait(), creat(), link(), unlink(), exit(), time(); struct sysent { int (*call)(); } sysent[] { { nosys }, /* 0 */ { rexit }, /* 1 */ { fork }, /* 2 */ { read }, /* 3 */ { write }, /* 4 */ { open }, /* 5 */ { close }, /* 6 */ { wait }, /* 7 */ { creat }, /* 8 */ { link }, /* 9 */ { unlink },/* 10 */ { exec }, /* 11 */ };真正进入核心后CPU 自动把用户态的 PC 和 PS 压入核心栈形成一个“中断帧”。内核在处理完系统调用后会通过这个帧恢复用户态的 PC 和 PS继续用户程序的执行。这里有第一个容易忽略的细节进程必须有两套栈。用户态用用户栈核心态用核心栈。核心栈通常分配在 u 区下方或者与 u 区共享一块固定大小的区域。正是靠核心栈内核才能在一个进程被切换出去后还能在下次切回来时准确恢复现场。2.3 exec的首要工作校验文件与释放旧映像进入exec系统调用的核心函数后第一件事不是读文件内容而是校验。V6 的 exec 源码大致要经过这几个检查参数里的文件路径是否有效能否找到对应 inode获取文件后检查这个文件是不是普通文件目录文件不能 exec读文件头检查魔数是否合法检查文件权限是否允许执行。校验通过之后exec 才真正动手改造当前进程。改造的第一步是“腾地方”。因为新程序的大小可能和当前进程完全不同所以内核要先把旧的进程映像释放掉。UNIX V6 里调用expand(0)把进程缩小到最小然后再根据新程序需要的空间重新分配内存。这个顺序很重要如果先把旧空间释放了再判断新程序能不能装入一旦新空间分配失败进程就处在“半死不活”的状态。UNIX V6 的 exec 通过把参数传给malloc后检查失败同时配合栈上的临时区尽量保证失败时还能恢复。后面专门有一节讲这个现场恢复的经典技巧。3. 新映像的组装exec的后半程3.1 计算进程大小并分配物理内存exec 后半程的核心工作可以用一个公式概括进程需要的内存总量 tsize dsize ssizetsize 是代码段大小dsize 是数据段大小ssize 是栈段大小。UNIX V6 里每个进程的栈不是随用随长的而是预先分配一个固定大小例如 6KB 到 8KB。所以 exec 在分配内存时必须给栈留出空间。分配内核内存用的是malloc按“首次适配”从内存区里找一个足够大的连续区域。分配成功后内核会更新 proc 结构和 u 区中记录进程大小的字段。这一步干完进程映像的新骨架就有了但里面还是空的接下来才把文件内容填进去。为什么数据段后面要清零这是新手最容易忽略的。C 语言里未初始化的全局变量默认是 0但磁盘上的可执行文件不会为这些变量留具体数据它在文件头中记录的 dsize 就已经包含了这些 BSS 区域的大小。内核装载时要把数据段整个复制进内存包括那些文件里没有实际数据的部分这些部分保持为零。3.2 加载代码段和数据段UNIX V6 的 exec 装载过程很直接用malloc拿到新内存后把原来进程占用的旧内存释放根据文件头的信息把代码段从磁盘读入指令空间把数据段读入数据空间设置页表寄存器让逻辑地址映射到新分配的内存清空数据段中未初始化部分。在分离 I/D 空间的情况下代码段被映射到指令空间数据段和栈被映射到数据空间。这里的“空间”指的是 PDP-11 的两套地址映射机制互不干扰。UNIX V6 模拟器在实现页表时做了简化但逻辑仍然是每个进程有一组 APRActive Page Register通过它把逻辑页映射到物理页。exec 的重点工作之一就是重置这些寄存器让新映像的逻辑地址能正确映射到刚分配的内存区域。我调试时发现UNIX V6 的模拟器在 exec 后如果出现“程序入口跳飞”的情况十有八九是页表没有重新加载。页表更新完毕后还要刷新模拟器内部的高速缓存这个细节在源码注释里不明显但排错时非常重要。3.3 参数栈的构造argv和环境变量是怎么“压”进栈的这是“程序的执行”这一章我最想展开的部分。很多人学完 exec知道它装了代码段和数据段却不知道命令行参数argv和环境变量是怎么传进去的。UNIX V6 的做法是在 exec 返回用户态之前内核在用户栈上手工构造一个参数块。假设你执行echo hello那么 exec 拿到的是参数指针数组argv[0] echo argv[1] hello argv[2] NULL另外还有环境变量数组假设只是简单的一两个envp[0] PATH/bin envp[1] HOME/root envp[2] NULL内核要做的是把这些字符串和指针数组按某种布局放到用户栈上。UNIX V6 的实际布局大致是栈的高地址放argc然后是argv[0]到argv[n]的指针接着是一个 NULL再是环境变量指针数组最后也放一个 NULL字符串本体则放在这些数组下方更低的地址区域。高地址 -------------------------- | argc 2 | | argv[0] 指针 | | argv[1] 指针 | | argv[2] NULL | | envp[0] 指针 | | envp[1] 指针 | | envp[2] NULL | | ... | | echo\0 | | hello\0 | | PATH/bin\0 | | HOME/root\0 | 低地址exec 返回用户态后用户程序入口处通常会有一段 crt0 启动代码汇编写的它要做的事情就是从这个栈上取出 argc 和 argv然后调用main(argc, argv, envp)。这个参数栈是谁构造的不是用户程序而是内核 exec 函数。我第一次看到 exec.c 里对参数栈的处理时最大的困惑是为什么它不用常规的 copyin / copyout而是直接用copyout往用户栈里写数据。这部分源码在 UNIX V6 里写得比较绕UNIX V6 则更直观一些。核心思路是先把参数块整体计算好然后从栈顶向低地址依次写入。内核需要绕过用户程序的数据段直接操作对应地址的用户空间所以它操作的是模拟内存不依赖用户页表是否已经映射。栈上还有一个关键点exec 返回后用户态看到的新栈的栈顶SP不是指向argc的正上方而是指向argc所在的位置。大多数启动代码会先读一部(SP)得到 argc再把 SP 加 4 得到 argv 数组的起点。这也是为什么返回用户态后不能简单地把 SP 恢复到任意值——必须严格指向参数块。3.4 exec如果是“换脸”u区里哪些字段被改写exec 完成后进程的 PCB 和 u 区有很多字段需要更新。这一块容易被忽略但它直接影响进程后续能否正确运行。主要改写的字段有进程大小u_tsize、u_dsize、u_ssize 分别记录新的代码段、数据段、栈段大小段寄存器重置指向新内存区域的页表用户态寄存器现场核心栈上的中断帧中的 PC 被改成新程序的入口地址计时字段清零进程的 CPU 累计时间重置信号处理从用户态安装的信号处理函数要复位为默认动作。为什么信号处理要复位因为 exec 执行成功后之前的用户代码已经被替换原先安装的信号处理函数地址在新程序里没有任何意义了。如果保留旧地址后续某信号到来时内核跳到一个不存在的地址进程必崩。需要保留的也有进程的打开文件描述符、当前目录、文件权限掩码等。这些属于进程的资源不随程序映像改变。shell 正是靠这一点才能让子进程 exec 换成新程序后仍然能访问父进程打开的文件和继承的环境。4. 进程真正跑起来上下文、栈与处理器调度4.1 用户栈和核心栈的双轨切换程序 img 装好了参数栈也准备好了CPU 怎么跳过去执行这就要回到中断/陷入和进程切换机制。PDP-11 上用户态执行 trap 指令时CPU 会自动把用户态的 PSW处理器状态字和 PC 压入核心栈切换到核心栈指针跳转到陷阱处理入口内核在核心栈上继续压入用户态的 R0-R5。于是核心栈上出现了一个“中断帧”它保存着用户态的完整执行现场。内核处理完系统调用或中断后再次执行rtt指令时CPU 从核心栈弹出 PC 和 PSW回到用户态并且核心栈指针又切换回用户栈指针。UNIX V6 在模拟器里实现这套切换时核心栈和用户栈是两块不同的内存区域。用户栈出现在进程数据空间的高地址端核心栈则固定在 u 区下方。我建议你把“核心栈上保存用户现场”这个画面刻在脑子里。理解进程切换时所有的 spirit 都在这套栈切换里。4.2 进程切换switch从当前进程到下一个进程先看一段简化伪代码描述 UNIX V6 的switch过程switch() { // 保存当前进程寄存器 u.u_rsav save_sp; // 保存当前 SP if (u.u_rsav 0) { // 处理没有可保存现场的情况 } // 从就绪队列中找优先级最高的进程 p sched(); // 切换到新进程 retu(p-p_addr); // 切换栈底进入新进程的 u 区 // 恢复新进程的现场 aretu(u.u_rsav); // 恢复到新进程被切换时的 SP 和 PC }这段逻辑有两个关键动作retu切换核心栈底把内核栈指针调整到新进程的 u 区对应位置aretu从新进程的 u_rsav 里恢复它的 SP 和 PC让它接着上次被打断的地方继续执行。所以一个进程被换下去时它的现场保存在自己的核心栈里通过 u_rsav 指示位置被换上来时从自己的核心栈里恢复。这保证了进程“想停就能停想走就能走”。进程切换和系统调用返回的区别要分清楚。系统调用返回是回到同一个进程的用户态只需要恢复中断帧即可而进程切换是换到另一个进程的上下文中需要完整保存和恢复现场。UNIX V6 里做进程切换时会把用户态寄存器和用户态 PC 都保存起来。时钟中断驱动的调度就是基于这个机制实现的——每次时钟中断都触发一次重新调度于是多个进程看起来在“同时”运行。4.3 时钟中断与抢占程序执行不是一条道跑到黑程序在执行过程中并不只是顺序执行指令。它还会响应中断、异常、系统调用。时钟中断是其中最特殊的一种它是操作系统“抢占”当前进程的唯一通道。在 PDP-11 上时钟硬件会周期性产生中断。内核在时钟中断处理中保存用户态现场累计当前进程的p_cpu时间根据优先级和累计时间计算新的调度优先级如果当前进程的时间片用完置runrun标志唤醒调度器退出中断前发现有runrun就调用switch切换到下一个进程。所以即使一个用户程序里完全没有系统调用它也不可能独占 CPU 一整辈子。时钟中断会强制它交还处理器。这里有一个需要体会的点“程序的执行”从用户视角看是线性推进的但从内核视角看是一次次“建立现场—执行—打断—保存现场—恢复现场”的循环。UNIX V6 模拟器的时钟中断周期是可以调的我建议你把周期调小一点再跑一个死循环程序观察进程切换的频次变化。4.4 系统调用是程序执行的一部分以read/write为例一个程序要访问文件、读写终端、创建子进程都必须经过系统调用。以最简单的write(1, hello, 5)为例C 库函数把参数压栈系统调用号放入 R0执行trap指令内核在trap中根据 R0 里的系统调用号查sysent表进入write系统调用函数从用户栈取参数内核根据文件描述符找到对应文件表项完成数据复制返回时把写入字节数放到 R0 中rtt回到用户态C 库函数把 R0 当作返回值处理。UNIX V6 里可以打开 trace 功能看每一个系统调用的进入与退出。我第一次完整跑通这个流程时才对“用户态与核心态交替执行”有了真正的体感。阶段运行环境使用栈典型动作用户态执行普通指令用户态用户栈计算、访问数据段用户态发起系统调用陷入核心态核心栈保存现场、查表内核执行系统调用核心态核心栈读写文件等内核返回用户态用户态用户栈恢复现场继续执行注意用户态代码和核心态代码用的是两个不同的栈。如果某天你调试时发现“栈被冲掉了”第一反应要分清是用户栈溢出还是核心栈溢出这两类问题的排查方向完全不同。5. fork与exec的精妙组合5.1 为什么程序执行必须配一个“创建者”前面说过exec 只是给当前进程换映像。但没有 fork 的话你执行一个命令就会把 shell 自己给替换掉shell 岂不是执行完一条命令就没了所以 UNIX 的设计是先 fork 出一个几乎一模一样的子进程再在子进程里 exec。父进程保留在自己原来的代码里继续读用户输入子进程 exec 后变成目标程序。两者相互独立互不干扰。shell 进程 | |--- fork() --------------- 子进程 | | | |--- exec(echo hello) | | | v | echo 程序执行 | |--- wait(status) --------- 子进程结束后通知父进程为什么 UNIX V6 不提供一个像 WindowsCreateProcess那样的“创建进程并装入程序”的原子操作历史原因是一方面但更关键的是forkexec 的组合给了中间层巨大的自由度。你可以先 fork再修改子进程的某些属性重定向、改权限、清环境最后 exec完全由你掌控。5.2 shell里执行一条命令的完整路径我把最经典的场景完整捋一遍。假设你在 shell 里输入ls -l整个执行路径是这样的shell 先解析命令得到可执行文件路径/bin/ls和参数列表[ls, -l]shell 调用fork创建子进程子进程返回 0执行第 3 步父进程返回子进程 PID执行第 5 步子进程调用exec(/bin/ls, [ls, -l], envp)exec 校验、释放旧映像、分配新内存、加载/bin/ls的代码和数据、构造参数栈、返回用户态后跳转到/bin/ls的入口父进程shell调用wait(4)阻塞等待子进程结束/bin/ls运行完调用exit进入僵尸态核心态的 exit 处理回收子进程资源唤醒等待的父进程shell 的wait返回重新显示提示符继续读下一条命令。每一步都能在 UNIX V6 的源码里找到对应函数。5.3 exec 失败如何恢复现场一个被低估的经典设计新手理解的 exec 失败往往是“execve 返回 -1调用者继续处理”。但这里有个矛盾exec 执行到一半的时候旧进程映像可能已经被部分释放了。如果这时候发现新程序无法加载怎么保证调用者还能活下来继续执行UNIX V6 的方案很巧妙它在核心栈上保留了一份关键信息并且在 exec 的可能失败点上设置恢复标签。如果中途失败内核会跳回一个恢复流程把旧映像拼回来。我在看 UNIX V6 的 exec.c 时第一次看到noaddrexpdone这类标签完全不懂其用意后来才明白这是一种“手动异常处理”。它把可能出错的步骤用标签切分成阶段任何一步失败就退回到上一次安全点尽量保持进程可继续运行。这个设计的代价是exec 失败后旧映像不一定能完全恢复。UNIX 的保证是只要 exec 函数还没有真正开始破坏旧映像失败就可以完全恢复一旦开始破坏映像失败就可能导致进程进入一个不稳定的中间状态但多数情况下仍能返回用户态。所以编码规范要求在 exec 调用之后不要依赖旧代码继续执行。这也是为什么你永远不会在 C 代码里写execl(/bin/ls, ls, NULL); printf(如果 exec 失败);如果 exec 真的失败这个 printf 可能以不可预期的方式出现在屏幕上甚至不出现。6. 常见问题与调试技巧6.1 程序刚exec完就段错误查栈指针症状exec 成功返回用户态但在 crt0 启动阶段就崩溃。排查思路先看用户栈是否被正确构造。最常见的原因有两个参数总数太多导致参数块超出预分配的栈空间字符串本体复制位置不对导致 argv 指针指向非法地址。UNIX V6 的模拟器提供了内存查看功能可以逐字节检查栈区是否写入了预期的字符串。6.2 a.out明明在exec却失败看magic症状文件存在、权限也正确但 exec 返回错误errno不明确。排查思路用二进制查看工具读文件头。如果魔数不对exec 会直接拒绝。UNIX V6 自带一个小工具可以 dump a.out 头部我在移植代码时经常因为链接器默认参数不同导致魔数变成 0411 而不是 0407从而在加载时走错分支。6.3 想跟踪exec但不知道断点放哪里在 UNIX V6 模拟器里跟踪 exec推荐几个关键断点断点位置观察目标exec 入口验证系统调用参数是否正确传入expand(0) 调用处确认旧映像释放时机malloc 调用处确认新内存分配是否成功参数栈组装结束处逐字节检查用户栈布局rtt 指令执行前检查中断帧中的入口 PC 和 PSW我在调试时通常在最后一个断点前暂停先手工推一遍用户栈里的 argc 和 argv 应该长什么样再和模拟器实际状态对比。对不上时十有八九是地址偏移算错了。6.4 容易混淆的文本段、栈增长、环境变量块最后给一张速查表这几件事我至今觉得初学者容易弄混概念正解易错点代码段共享exec 后如果魔数是 0411多个进程可共享代码段以为所有 exec 都会共享栈增长方向用户栈向下增长地址减少参数块和字符串位置搞反环境变量位置环境变量存于用户栈低地址侧指针数组在高地址侧以为环境变量在内核堆上EXPAND 时机exec 先缩小旧进程再分配新内存以为直接覆盖即可记录一次完整调试过程写到这里我忍不住想分享一次具体调试场景。当时我修改了 UNIX V6 的 exec 函数想在参数栈基础上多加一个自定义字段。改完之后系统能启动但一执行任何命令shell 就报段错误。排查过程是这样的先确认 fork 没问题因为 shell 本身活着再用模拟器的反汇编功能看 exec 返回后的用户指令发现地址跳到了错误位置最后检查核心栈上的中断帧发现 PC 确实被改成了新入口但 SP 指向的地址和文件头解析出的 ssize 不符。最终定位我在组装参数栈时用错了偏移把环境变量字符串写进了栈保留区破坏了 crt0 的启动帧。修正偏移后一切恢复正常。这个经历让我更加确信程序执行不是“加载完文件就完事”而是“机制链上每个环节都要严丝合缝”。UNIX V6 的 exec 代码只有几百行但每一行的存在都有原因。如果你正在学操作系统千万不要只看教材上那句“exec 系统调用将新程序装入内存并开始执行”一定要亲手把从exec入口到rtt返回之间的每一步源码都读一遍。读完之后你会对整个操作系统的运转方式产生不一样的直觉——因为你能真正说清楚一个程序是怎么变成进程又是怎么开始跑的。