Linux进程控制与程序替换:看懂fork、exec与写时拷贝

发布时间:2026/9/8 1:23:01
Linux进程控制与程序替换:看懂fork、exec与写时拷贝 干这个活儿这么多年接触过不少从应用层转到服务端底层开发的同事也带过一些刚入门Linux的新人。我发现大家最容易卡住的地方往往不是malloc和free也不是多线程加锁而是不知道一个进程到底是怎么生、怎么死、怎么变性成为另一个程序的。这里面最核心的玩意就是进程控制和进程程序替换。每次有新人问我为什么fork完要配一个exec使用直接在子进程里写逻辑不就行了我就知道这位同学离Windows的CreateProcess思维不远了。这篇文章我就把自己对Linux进程控制和进程程序替换的理解包括踩过的坑、调过的bug、手写迷你shell的过程一次性整理出来。内容尽量给足细节既有原理也有可直接抄的代码。适合正在学Linux系统编程的学生刚接触服务端Linux开发的从业者以及准备面试、想把这些概念讲清楚的运维同学。1. 先搞清楚进程控制到底在控制什么1.1 进程不只是运行中的程序这么简单教科书上说进程是运行中的程序这句话对但不完整。我习惯把进程拆成两层看代码和数据可执行文件里那一堆指令、全局变量初始值属于磁盘上的静态资产。运行上下文当前执行到哪一行PC寄存器、函数栈长什么样、打开的文件描述符、环境变量、挂的信号处理函数……这些才是进程运行时的状态快照。Linux里面的进程控制本质上就是对上面第二层做管理创建进程、切换进程状态、回收进程资源。而这里最出名的两个操作一个是fork()一个就是exec族函数。前者用来复制一个进程后者用来置换一个进程的代码和数据。两者配合才是Unix/Linux创建一个全新程序的标准动作。这个设计和Windows的创建进程方式有本质区别。Windows的CreateProcess直接一步到位把创建进程加载新程序合在一起。而Unix把这两件事拆开了拆开的最大好处是灵活——你可以在fork之后、exec之前干很多事情重设文件描述符、改信号屏蔽字、动一下进程的资源限制。换句话说fork先给你一个停下来的机会exec再让你变成别人。不理解这个顺序后面手写minishell就没法下手。1.2 fork返回两次这背后是写时拷贝先看一段最入门也最让人困惑的代码#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } else if (pid 0) { printf(我是子进程, 我的pid是%d, fork返回了0\n, getpid()); } else { printf(我是父进程, 我的pid是%d, fork返回了子进程的pid %d\n, getpid(), pid); } return 0; }运行之后你会看到两行输出一个是父进程的一个是子进程的。很多人会问怎么printf只调用了一次却打印了两行原因在于fork()调用一次返回两次而且返回之后父进程和子进程会各自执行fork之后的代码。传统理解是fork做了一次完全复制父进程的内存整个拷贝一份给子进程。但是现代Linux早就不这么干了用的是写时拷贝。初始时刻父子进程共享同一份物理内存页并且这些页被标记为只读。只有某一方真的去写这块内存时才会触发缺页异常内核复制出新的页再重新映射。这样fork复制的开销从O(整个进程内存)降到了O(页表项数)大部分场合下甚至接近O(1)。你从结果上看父子进程的内存内容确实一样但它们是两份独立的物理页互不影响。这里我特别建议新人做一次验证在fork之后子进程里改一个全局变量父进程里打印这个变量你会发现父进程看到的依然是原来那个值。这个实验能让你真正感受到独立地址空间这几个字的分量。1.3 fork之后的执行顺序谁先跑还真不一定还有一个让新手很懵的地方fork之后父进程和子进程谁先执行答案是没准。内核调度器会根据当前CPU负载、进程优先级等因素决定谁先抢到CPU。你看到父进程先输出或者子进程先输出都正常。有些同学为了观察行为会在fork之后写一个while(1)让进程保持存活然后用ps命令查看。这个思路是对的也是排查进程状态最常用的手段#include unistd.h #include stdio.h int main() { pid_t pid fork(); if (pid 0) { while (1) { sleep(1); } } else { while (1) { sleep(1); } } return 0; }编译运行到后台然后执行ps -ef或者pstree -p就能看到父子进程的PPID关系。说实话真正上手操作之后理解速度比看十篇文章都快。做服务端开发的人fork的另起炉灶思维一定要刻进脑子里父进程和子进程是两个独立的执行流代码可以看起来一样但状态、资源、信誉都分家了。2. exec家族进程程序替换的完整地图2.1 为什么需要程序替换fork复制完还得换芯刚才说fork能复制出一个几乎一样的进程但如果只是复制那所有的子进程不就都干一样的事情了吗现实场景里我们fork子进程通常是想让它去跑一个新的程序比如Shell里你输入lsfork出来的子进程得变成ls进程而这个变身过程就是进程程序替换。很多人对exec的理解有偏差以为exec是加载一个新进程。其实不是。exec只是把当前进程的代码段、数据段、堆、栈全部换掉换成新的可执行文件的内容但进程的PID没变它还是原来的那个进程只是内核里的进程描述符task_struct重新初始化了部分内容。说得直白点你不是换了一辆车你是给这辆车换了发动机、变速箱和外壳车牌号PID还是原来的但你进去一看司机和内饰全变了。程序替换会覆盖原有进程的代码段、数据段、堆、栈进程PID、父进程关系、文件描述符表除非设置了FD_CLOEXEC等内核层面的资源不会变已经打开的文件描述符在exec之后通常还是打开的信号处理方式里被捕获的信号会重置为默认行为阻塞的信号集不变所以exec具有永不成功的成功这种说法如果exec成功了它不会返回如果它返回了那一定是出错了返回-1。2.2 exec族的六个兄弟l、v、p、e 都是什么意思Linux提供了一族exec函数名字特别像容易劝退新人。其实只要拆开看后缀就明白了函数名后缀含义程序路径如何指定参数怎么传环境变量怎么处理execll list必须给完整路径可变参数列表以NULL结尾继承当前环境变量execlpl p只给文件名搜PATH可变参数列表以NULL结尾继承当前环境变量execlel e必须给完整路径可变参数列表以NULL结尾由调用者传入环境变量数组execvv vector必须给完整路径参数放char*数组继承当前环境变量execvpv p只给文件名搜PATH参数放char*数组继承当前环境变量execvpev p e只给文件名搜PATH参数放char*数组由调用者传入环境变量数组里面的含义我用一句话总结l代表参数用可变参数列表一个个列出来v代表参数放进数组一次性传p代表自动到PATH环境变量指定的目录里找程序e代表可以指定全新的环境变量。写代码时我自己的选择习惯是需要直接执行一个已知路径的二进制用execl想偷懒让系统去PATH里找用execlp或execvp需要给子进程自定义环境变量比如改PATH、改HOME用execle或execvpe参数多且本来就是数组存储用execv或execvp代码更整洁2.3 一个例子看明白execl和execlp的差别写一段最常见的演示代码#include stdio.h #include unistd.h #include sys/wait.h #include stdlib.h int main() { pid_t pid fork(); if (pid 0) { // 子进程用execlp去找ls因为ls在/usr/bin下只要PATH里有就能找到 execlp(ls, ls, -l, /tmp, NULL); perror(execlp); exit(1); } wait(NULL); printf(父进程回收完毕\n); return 0; }这里用execlp就不用写完整路径了。如果换成execl你得写成/usr/bin/lsexecl(/usr/bin/ls, ls, -l, /tmp, NULL);注意execl里第一个参数是路径第二个参数是argv[0]也就是程序自身名字。后面NULL作为参数列表的结束标志漏掉的话可能让子进程加载出莫名其妙的崩溃这在面试里也是常被翻出来的考点。我一直强调exec失败后要加perror并exit。原因很简单如果你忘了检查父进程还以为子进程在跑新程序实际上子进程可能早就因为找不到路径退出了这种bug特别隐蔽。子进程里不能依赖返回值来区分我还能继续跑和我exec失败——只要能看到返回值就一定失败了。3. 实操环节手写一个迷你Shell3.1 阶段一先让fork exec wait转起来理解了fork和exec最有价值的落地练习就是写一个简化版Shell。我经常跟同事说别一上来就写什么高性能网络框架先把Shell这个东西的骨架用20行代码搭出来你对进程模型的理解能上一个台阶。第一步我们先做一个最简单版本读取用户输入的一行命令然后fork一个子进程去执行父进程wait回收。这样至少能实现输入ls就列出文件的效果。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #define MAX_CMD 1024 int main() { char cmd[MAX_CMD]; while (1) { printf(minish ); fflush(stdout); if (fgets(cmd, sizeof(cmd), stdin) NULL) { break; } // 去掉末尾换行 cmd[strcspn(cmd, \n)] \0; if (strcmp(cmd, exit) 0) { break; } if (strlen(cmd) 0) { continue; } pid_t pid fork(); if (pid 0) { // 子进程把这条命令交给sh -c执行最简单的做法 execl(/bin/sh, sh, -c, cmd, NULL); perror(execl); exit(1); } else if (pid 0) { wait(NULL); } else { perror(fork); } } return 0; }这里偷了个懒让/bin/sh -c去解析用户输入。这个版本已经能跑通大部分命令了包括带管道和重定向的。原理上也完全符合一个Shell的标准工作方式fork出子进程在子进程里替换成另一个程序父进程等待回收。你没看错Shell执行命令这个动作的本质就是进程控制加程序替换没有魔法。3.2 阶段二手动解析命令不再依赖sh -c直接甩给sh -c的方式教学上不够清晰因为真正的Shell是自己解析命令行、拆参数、再exec的。我们升级一下把用户输入的命令字符串拆分成参数数组然后用execvp去PATH里找程序执行。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #define MAX_CMD 1024 #define MAX_ARG 64 char *parse_args(char *cmd) { static char *args[MAX_ARG]; int idx 0; args[idx] strtok(cmd, \t); while ((args[idx] strtok(NULL, \t)) ! NULL) { idx; if (idx MAX_ARG - 1) { break; } } args[idx] NULL; return args[0], args; }但是这个函数的接口有问题不能在子进程里暴露。严格写的时候我建议维护一个全局的argv指针数组或者在一个函数里完成解析和fork。下面这个例子更完整#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #define MAX_CMD 1024 #define MAX_ARG 64 int main() { char cmd[MAX_CMD]; char *args[MAX_ARG]; while (1) { printf(minish ); fflush(stdout); if (fgets(cmd, sizeof(cmd), stdin) NULL) { break; } cmd[strcspn(cmd, \n)] \0; if (strcmp(cmd, exit) 0) { break; } // 解析参数 int idx 0; char *token strtok(cmd, \t); while (token ! NULL idx MAX_ARG - 1) { args[idx] token; token strtok(NULL, \t); } args[idx] NULL; if (idx 0) { continue; } // 如果输入的是cd单独处理 if (strcmp(args[0], cd) 0) { if (args[1] NULL) { chdir(getenv(HOME)); } else { chdir(args[1]); } continue; } pid_t pid fork(); if (pid 0) { // 子进程执行用户输入的命令 execvp(args[0], args); perror(args[0]); exit(127); } else if (pid 0) { int status; wait(status); } else { perror(fork); } } return 0; }这个版本是不是就非常像一个真正Shell的骨架了execvp会自动去PATH指定的几个目录里找命令。如果用户输入了/usr/bin/top这种带路径的execvp也能正确执行。这段代码我建议你亲手敲一遍不要复制粘贴。敲完你会发现几个之前没注意的细节比如strtok会修改原字符串、execvp失败后要返回127代表命令不存在、cd这类命令必须在子进程里执行不然只改了子进程的工作目录对父进程Shell根本没作用。3.3 阶段三看懂Shell内置命令为什么特殊既然聊到cd就多说一句。普通的ls、grep这些都是外部命令需要forkexec。但cd不一样它是Shell内置命令因为工作目录是进程级别的属性如果cd也用子进程去跑那么子进程改了目录就退出了父进程的目录根本没变化。所以Shell遇到cd这类内置命令会自己直接处理不走fork。类似的还有export、unset、exit等。凡是需要影响当前Shell进程自身状态的命令都必须是内置的。这个设计逻辑是你理解Shell体系的关键。面试官如果问为什么cd是内置命令你应该能给出这个层面上的回答。你也可以顺手给自己的minishell加一个内置命令列表cd、export、echo。实现上不复杂但能让你把父进程自己处理和子进程去跑新程序这两个路线彻底分清楚。3.4 观察子进程如何变身用getpid验证PID不变很多同学会怀疑程序替换不换进程这个说法。我建议这样验证在子进程exec之前打印getpid()再看新程序里打印自己的getpid()两个PID是一样的。举个例子写一个被替换的小程序#include stdio.h #include unistd.h int main() { printf(被替换后的程序PID是%d\n, getpid()); return 0; }编译成/path/to/newprog。然后在主程序里forkexec它exec前也打印一个PID。你会看到两个PID一致。这个实验既直观又能加深记忆强烈建议跑一遍。程序替换的本质是操作系统加载新的可执行文件解析其格式现在一般是ELF把新程序的段映射到当前进程的地址空间然后重置入口点。旧程序的代码段、数据段、堆、栈全部作废被新程序占用。但PCB里记录的PID、打开的文件表、当前目录等大部分保持原样。所以进程程序替换这个叫法非常准确进程还活着程序换了。4. 常见问题与排查技巧实录4.1 僵尸进程进程控制最容易翻车的点写服务端程序最经典的进程控制问题就是僵尸进程。子进程退出之后如果父进程没有调用wait或waitpid去回收它的退出状态这个子进程就会变成一个僵尸进程Zombie。僵尸进程的PCB不能被释放会一直占着PID和内核资源如果你的服务不停fork子进程又不管它们系统上僵尸进程会越积越多最终可能导致无法创建新进程。我得说一个容易忽略的点wait只能回收直接子进程且是阻塞的。如果你在父进程里写了一个长循环期间子进程退出了等到父进程调用wait那一刻子进程早就变成僵尸了。而且wait一次只能回收一个如果你连续fork了三五个子进程累加回收就要调用对应次数。更关键的是wait没有轮询能力父进程如果自己还有别的事要做wait就会把父进程卡住。这时候要用waitpid#include sys/wait.h pid_t waitpid(pid_t pid, int *status, int options);options传WNOHANG时如果当前没有子进程退出waitpid立即返回0父进程可以继续干自己的事后面再回头来回收。这是服务端主循环里非常标准的一个用法。4.2 fork之后printf输出重复了先检查缓冲区有一种场景大家会很困惑fork之前代码里用一个printf打印了一行内容fork后父子进程各打印一条结果屏幕上出现了三行甚至四行输出。这是因为printf默认是行缓冲或全缓冲如果输出到终端通常是行缓冲遇到换行就刷。但如果输出被重定向到文件就会变成全缓冲数据还留在缓冲区里没写出去。fork会复制进程地址空间连同缓冲区一起复制于是父进程缓冲区和子进程缓冲区里都有一份未刷出的数据最终两个进程退出时各自刷一次输出就重复了。解决办法也很简单fork之前别让缓冲区里残留数据该fflush(stdout)的地方提前flush或者用setvbuf设置成无缓冲。这个坑在生产环境出日志时经常出现加一句fflush就能救回来。4.3 exec后文件描述符到底还在不在exec之后已打开的文件描述符默认是保持打开状态的。这意味着一个进程打开了一个文件然后exec变成另一个程序新程序依然能通过原来的fd操作这个文件不需要重新打开。这个特性在服务端非常有用先监听端口、接好连接、把connfd传给子进程子进程exec后依然能read/write这个fd。前提是你没有给fd设置FD_CLOEXEC标志。设置了这个标志的fd在exec成功时会自动关闭。为什么需要这个机制为了安全防止新程序继承一些你不希望它接触的fd。我写服务端时在打开fd之后如果不希望它被子进程继承就会加上这么一句int flags fcntl(fd, F_GETFD, 0); fcntl(fd, F_SETFD, flags | FD_CLOEXEC);同理如果父子进程之间想共享某些传递通道而避开exec的影响就不能加这个标志。平时不显眼一旦遇到子进程怎么打不开这个管道的问题先检查是不是fd被exec关掉了。4.4 子进程退出了怎么拿到退出码wait和waitpid的第二个参数status里面编码了子进程的退出信息。但千万不要直接用status 0判断成功因为status的高位低位各有含义。查退出码的标准动作是int status; pid_t ret waitpid(pid, status, 0); if (ret pid) { if (WIFEXITED(status)) { printf(子进程正常退出, 退出码%d\n, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf(子进程被信号%d杀死\n, WTERMSIG(status)); } }我自己排查问题时最常用的就是WIFEXITED和WIFSIGNALED这两个宏。如果子进程是被信号杀死的直接看WTERMSIG就能定位到是哪一种异常。很多人反馈子进程退出码是13怎么查其实13就是SIGPIPE的信号编号说明子进程往一个读端已关闭的管道里写了数据典型的网络或管道通信问题。5. 容易被忽略的深水区环境变量、进程调度与面试应答5.1 exec和环境的交互子进程的环境变量哪来的当使用execl、execlp、execv、execvp时新程序的环境变量继承自当前进程也就是说子进程会接收到父进程环境变量的拷贝。但如果使用了execle或execvpe我们可以手动指定一个全新的环境变量数组新程序就不会继承父进程的环境变量了。实际上execve系统调用内部会为新程序构建初始环境这些环境变量存放于新程序启动时的栈上。main函数里的第三个参数(char *envp[])就是从这里取到的。环境变量不是无限复制进程地址空间和参数、环境字符串有长度限制但一般用到上限的情况很少主要注意的是如果你手动构造环境变量数组别忘了最后的NULL结束标志。面试中常问execlp和execl的区别直接答一个是可执行文件名自动搜PATH一个必须写绝对路径追问环境变量还能提到execle可以自己传envp。能把这一套说完整基本就过了。5.2 fork失败原因不只有内存不足很多人提到fork失败就想到内存不够不全面。Linux默认对每个用户能创建的进程数有上限限制尤其是系统里跑了很多子进程或者个别进程疯狂fork忘回收触发了RLIMIT_NPROC上限再fork就会返回EAGAIN。还有一种可能是系统进程数总量达到上限直接fork不出新进程。排查手段也很直接先看当前用户进程数ulimit -u如果上限很小那就是资源限制的事。再看僵尸进程数量ps aux | awk $8 ~ /Z/ {count} END {print count}如果僵尸进程一大堆说明某个父进程一直没有回收子进程这时候优先修回收逻辑。我见过一个挺离谱的生产事故就是一部分常驻进程长期fork完不wait几个月后把PID空间耗尽所有服务都不能fork新进程了重启才缓过来。5.3 进程程序替换和fork一定是绑定的吗有人以为exec族函数的调用前提是必须先fork。其实不是exec可以从任何进程里调用甚至可以在一个没有fork过的单进程程序里调用。比如你写个程序跑着跑着执行到某一行时调用execl把自己替换成另一个程序这是在同一个进程里完成的根本不需要子进程。日常里我们习惯fork exec一起出现是因为Shell这类程序想同时做到两件事父进程要继续存在以便等用户输入而子进程去跑新程序。这是业务需求决定了要先fork不是exec本身的要求。有时候在单进程程序里也可以直接exec比如引导程序、系统初始化进程的切换。搞懂这层对进程被替换成另一个程序但PID不变会有更立体的认知。5.4 调度优先级和后台运行进程控制的扩展玩法进程控制家族里还有一对常用的函数nice和setpriority用来调整进程优先级加上信号里常用的kill、raise整个进程控制就完整了。Shell里你在命令行末尾加个本质上是让Shell不调用等待回收函数直接把子进程放到后台去然后打印子进程PID。如果我们想在代码里模拟后台运行的效果就是fork之后父进程不wait而是返回shell界面继续等待下一条命令。但这样会产生僵尸进程隐患所以真正的Shell会采取措施要么在合适时机统一收尸要么用信号SIGCHLD来通知父进程有孩子死了快来收尸。SIGCHLD的默认动作是忽略但你可以在父进程里注册一个信号处理函数里面调用waitpid(-1, status, WNOHANG)循环回收。这是服务端守护进程最常用的养孩子方式也是把进程控制理解进到深处后自然能想到的方案。这就引出了一个面试高发题如何正确使用SIGCHLD回收多个子进程基本答案是这样在父进程里signal(SIGCHLD, handler)handler里用while循环不断waitpid(-1, NULL, WNOHANG)直到返回0或-1为止。这样能回收掉所有已经退出的子进程而且不阻塞父进程。如果你以后再看到系统里积累大量僵尸进程至少马上能想到是SIGCHLD回收机制没有做对或者父进程压根就没管孩子。6. 几个可以直接抄的调试工具与最后的小建议6.1 ps、pstree、strace三件套排查进程问题时我的三板斧是ps查状态、pstree查父子关系、strace查系统调用。比如怀疑程序替换失败直接strace -f可以追到子进程的execve系统调用返回了什么错误怀疑某个进程一直变僵尸ps显示状态为Z马上就能定位到它的PPID是哪个进程再去看那个父进程有没有调用wait。如果真的需要确认exec后的环境可以在被替换的程序里用getenv打印几个关键环境变量快速区分传参有没有问题。加上gdb下断点到exec函数上还能看到exec之前进程的完整状态。这套组合拳打下来进程控制相关的疑难杂症基本都能揪出来。6.2 给新人的三条实操建议第一不要把fork和exec背成八股一定要亲手写代码。这个minishell项目花一个下午就能做完但是带来的理解收获非常大。写完后再看看系统里真的/bin/bash是怎么做的源码太大可以挑注释读很多设计理念就通了。第二进程程序替换记得检查返回值。exec返回就说明失败必须在子进程里处理错误并退出否则子进程会继续执行当前程序后面的代码产生非常隐蔽的逻辑错误。这个坑我见人踩过不下十次。第三多利用waitpid的WNOHANG参数而不是裸wait。长期运行的服务端程序里用WNOHANG配合SIGCHLD信号处理来回收子进程是更健壮的方案。裸wait在子进程退出时间不固定时很容易让主流程被拖住。说实话进程控制和进程程序替换是Linux系统编程里最值得反复咀嚼的一块内容。它不复杂代码量也不大但它背后牵扯的fork、写时拷贝、exec、环境变量、僵尸进程、wait族函数几乎覆盖了日常开发里会遇到的进程生命周期问题。把这块啃下来你看很多服务端框架的进程管理逻辑时会豁然开朗比如Nginx的worker进程为什么用fork复制出那么多子进程比如shell在执行一条命令时为什么有四五个进程同时存在。理解了这些模型写代码和排查问题的底气都会完全不一样。