Linux系统编程(5):进程进阶——exec 函数族、进程退出与资源回收
前言本文整理自 Linux 系统编程中关于exec 函数族、strtok 字符串解析、进程退出方式exit / _exit / atexit、wait / waitpid 进程回收的学习笔记与代码示例。所有代码均附有详细注释。一、exec 函数族——替换进程镜像1. 概述fork 之后子进程和父进程执行的是相同的代码。如果希望子进程去执行一个全新的程序就需要用到 exec 函数族。exec 函数族的核心功能用新进程的镜像替换原有进程的镜像。进程 [text | data | bss | 堆栈] PCB其中 [text | data | bss | 堆栈] 就是镜像部分。exec 调用成功后原有的代码、数据、堆栈全部被新程序替换但PID 不变——本质上还是同一个进程只是脱胎换骨了。2. exec 函数族总览int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg, ..., char *const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[], char *const envp[]);3. 命名规律与区分函数名由以下几个字母组合而成记住规律就很容易区分字母含义说明l(list)逐个罗列参数从第二个参数开始逐个写出来以 NULL 结尾v(vector)参数用数组传入从第二个参数开始组织成指针数组p(PATH)从环境变量 PATH 中查找不需要指定完整路径只需文件名e(environ)传递自定义环境变量通过 envp 数组传递环境变量给新程序l vs v 的区别// l —— list逐个罗列参数 execl(/bin/ls, ls, -l, /, NULL); // v —— vector用数组传入参数 char *const args[] {ls, -l, /, NULL}; execv(/bin/ls, args);两者功能完全相同只是参数传递方式不同。带 p —— 在 PATH 中查找// 不带 p需要指定完整路径 execl(/bin/ls, ls, -l, /, NULL); // 带 p系统会到 PATH 环境变量中查找不需要指定路径 execlp(ls, ls, -l, /, NULL);可以用which ls命令查看可执行文件的完整路径。带 e —— 传递自定义环境变量// 什么时候用 // 如果要执行的程序需要用到自定义的环境变量 // 方式一使用系统默认环境变量 extern char **environ; execle(/usr/bin/env, env, NULL, environ); // 方式二使用自定义环境变量 char *const envp[] {USRlinux, PASSWD123456, NULL}; execle(/usr/bin/env, env, NULL, envp);其中extern char **environ是系统环境变量的指针环境变量存放在一个char *的指针数组中。4. exec 调用成功后的特征exec 调用成功后原进程中 exec 之后的代码将不再被执行因为整个进程的镜像已经被替换了。只有调用失败时才会继续执行后续代码。5. 基本代码示例execl 示例#include stdio.h #include unistd.h int main(int argc, const char *argv[]) { printf(exec start\n); // execl逐个罗列参数第一个参数是完整路径 // /bin/ls —— 可执行文件的路径必须包含文件名 // ls —— argv[0]程序名 // -l, / —— 命令行参数 // NULL —— 参数列表结束标志 execl(/bin/ls, ls, -l, /, NULL); // 以下代码只有 execl 调用失败才会执行 // 因为成功后整个进程镜像已被替换为 ls printf(exec end\n); return 0; }execlp 示例#include stdio.h #include unistd.h int main(int argc, const char *argv[]) { printf(exec start\n); // execlp带 p系统会到 PATH 环境变量中查找 cp // 不需要写 /bin/cp只需写 cp execlp(cp, cp, 1.txt, 2.txt, NULL); printf(exec end\n); return 0; }execv 示例#include stdio.h #include unistd.h int main(int argc, const char *argv[]) { printf(exec start\n); // execv参数用数组方式传入 // args[0] ls → 程序名 // args[1] -l → 参数 // args[2] / → 参数 // args[3] NULL → 结束标志 char *const args[] {ls, -l, /, NULL}; execv(/bin/ls, args); printf(exec end\n); return 0; }execvp 示例#include stdio.h #include unistd.h int main(int argc, const char *argv[]) { printf(exec start\n); // execvp带 p v // p在 PATH 中查找不需要完整路径 // v参数用数组传入 char *const args[] {ls, -l, /, NULL}; execvp(ls, args); printf(exec end\n); return 0; }execle / execvpe 示例传递环境变量#include stdio.h #include unistd.h // 系统默认环境变量 extern char **environ; // 自定义环境变量数组 char *const envp[] {USRlinux, PASSWD123456, NULL}; int main(int argc, const char *argv[]) { // 方式一execle 系统默认环境变量 // execle(/usr/bin/env, env, NULL, environ); // 方式二execle 自定义环境变量 // execle(/usr/bin/env, env, NULL, envp); // 方式三execvpe 自定义环境变量 // 带 p在 PATH 中查找带 v参数数组带 e自定义环境变量 char *const args[] {env, NULL}; execvpe(env, args, envp); // 也可用于登录程序 // execle(/bin/login, login, NULL, envp); return 0; }二、strtok 字符串解析函数在实现后续的简易 shell之前需要先掌握strtok函数用于将用户输入的命令行字符串按空格拆分成各个参数。函数原型#include string.h char *strtok(char *str, const char *delim);功能提取字符串中的子字符串按分隔符拆分。参数str要解析的字符串连续拆分时传 NULL表示接着上次的位置继续拆分。delim分隔符字符串。返回值成功返回子字符串的首地址。失败或字符串解析完毕返回 NULL。注意strtok 会修改原始字符串将分隔符替换为\0。strtok 示例代码#include stdio.h #include string.h int main(int argc, const char *argv[]) { int i 0; char buf[] ls;-l:/; // 定义指针数组用于存放拆分后的各子串 char *ret[10] {NULL}; // 第一次调用传入原始字符串 buf分隔符为 :; ret[0] strtok(buf, :;); // 后续调用传入 NULL表示继续从上次位置拆分 // 直到返回 NULL 为止 while (ret[i] strtok(NULL, :;)) ; // 打印拆分结果 for (i 0; i 3; i) { printf(ret[%d] %s\n, i, ret[i]); } // 输出 // ret[0] ls // ret[1] -l // ret[2] / // 验证strtok 会修改原始字符串 // 原始的 ls;-l:/ 中的分隔符会被替换为 \0 for (i 0; i sizeof(buf); i) { printf(buf[%d] %d:%c\n, i, buf[i], buf[i]); } // 可以看到 ;, :, / 都被替换成了 \0ASCII 码 0 return 0; }三、进程退出结束1. 进程终止的 8 种情况分为正常终止和异常终止两大类正常终止3~5 种编号方式说明1main函数中return最常见的方式2exit(status)库函数会刷新 I/O 缓冲区关闭所有流和打开的文件并调用 atexit 注册的清理函数3_exit(status)/_Exit(status)系统调用会关闭所有文件但不刷新缓冲区不执行清理函数4主线程退出多线程场景5主线程调用pthread_exit多线程场景异常终止6~8 种编号方式说明6abort()发送 SIGABRT 信号终止进程7signal/kill发信号结束进程如kill -9 PID8最后一个线程被pthread_cancel多线程场景2. exit 函数详解#include stdlib.h void exit(int status);功能让进程退出并刷新缓冲区。参数status为进程退出状态。常用宏EXIT_SUCCESS0、EXIT_FAILURE1。退出状态只取低 8 位status 0377。exit 的执行流程exit → 刷新缓冲区 → 调用 atexit 注册的退出函数 → 调用 _exit3. _exit 函数详解#include unistd.h void _exit(int status);功能让进程退出不刷新缓冲区。参数status为进程退出状态。是系统调用比 exit 更底层。exit 与 _exit 的关键区别// exit 示例 printf(hello world!); // 注意没有 \n内容在缓冲区中 sleep(3); exit(0); // exit 会刷新缓冲区 → 屏幕上能看到 hello world! // _exit 示例 printf(hello world!); // 注意没有 \n内容在缓冲区中 sleep(3); _exit(0); // _exit 不刷新缓冲区 → 屏幕上看不到 hello world!4. atexit 注册退出清理函数#include stdlib.h int atexit(void (*function)(void));功能注册进程退出前要执行的清理函数。参数function为函数指针指向void返回值、void参数的函数。返回值成功返回 0失败返回非 0。调用顺序多个清理函数按照注册的倒序后注册的先调用被调用。重要注意事项exit调用时会执行 atexit 注册的函数_exit调用时不会执行。只有在进程正常结束时才会被调用main 中 return 或 exit。atexit 可以多次注册多个清理函数。atexit 示例代码#include stdio.h #include unistd.h #include stdlib.h // 清理函数 1 void cleanup1(void) { printf(1 cleanup free malloc\n); } // 清理函数 2 void cleanup2(void) { printf(2 cleanup free malloc\n); } int main(int argc, const char *argv[]) { // 按顺序注册两个清理函数 atexit(cleanup1); // 先注册 atexit(cleanup2); // 后注册 printf(hello world!); sleep(3); exit(0); // 调用 exit 正常退出 // 以下代码不会执行 while (1) sleep(1); return 0; }运行结果hello world!等待 3 秒后 2 cleanup free malloc ← 后注册的先调用 1 cleanup free malloc ← 先注册的后调用执行顺序先打印hello world!等待 3 秒后exit 触发 atexit 注册的清理函数后注册的 cleanup2 先执行先注册的 cleanup1 后执行。_exit 示例代码不执行 atexit 清理函数#include stdio.h #include unistd.h #include stdlib.h int main(int argc, const char *argv[]) { printf(hello world!); // 没有 \n内容在缓冲区中 sleep(3); _exit(0); // _exit 不刷新缓冲区不执行 atexit while (1) sleep(1); return 0; }运行结果等待 3 秒后直接退出屏幕上什么都没有缓冲区中的hello world!没有被刷新。即使注册了 atexit 清理函数也不会被调用。5. return 关键字当return出现在main函数中时效果等同于调用exit可以结束进程。当return出现在其他函数中时只是结束该函数的调用不会结束进程。四、wait 和 waitpid——回收子进程资源1. 为什么需要 wait当子进程结束时如果父进程不回收其资源子进程就会变成僵尸进程Z 状态占用系统资源。wait和waitpid就是用来回收子进程资源并获取退出状态的函数。2. wait 函数#include sys/types.h #include sys/wait.h pid_t wait(int *wstatus);功能阻塞等待任意子进程退出并回收该进程的资源和获取退出状态值。参数wstatus用于接收子进程的退出状态如果不关心退出状态传NULL。返回值成功返回回收到的子进程 PID失败返回 -1。注意wait 是一个阻塞操作——如果所有子进程都在运行父进程会一直卡在 wait 处等待。退出状态解析宏宏功能WIFEXITED(wstatus)判断子进程是否正常结束WEXITSTATUS(wstatus)获取正常结束时的退出状态值WIFSIGNALED(wstatus)判断子进程是否因信号而结束WTERMSIG(wstatus)获取导致结束的信号编号wait 示例代码#include stdio.h #include sys/types.h #include unistd.h #include sys/wait.h #include stdlib.h int main(int argc, const char *argv[]) { // 创建子进程 pid_t pid fork(); if (pid 0) { perror(fork fail); return -1; } // 父进程阻塞等待子进程结束 if (pid 0) { while (1) { printf(father hello world!\n); int status; // wait 阻塞等待任意子进程退出 // status 中保存子进程的退出状态 if (wait(status) 0) { perror(wait fail); return -1; } // 判断子进程是正常结束还是信号结束 if (WIFEXITED(status)) { // 正常结束打印退出状态值 printf(status %d\n, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { // 信号结束打印信号编号 printf(signo %d\n, WTERMSIG(status)); } } } // 子进程循环 10 次后退出退出状态为 99 else if (pid 0) { int n 10; while (1) { sleep(1); printf(child hello world!\n); n--; // 子进程循环打印 10 次后退出 // 注意n 变为 0 后还会再循环一次while(1) // 实际上会死循环除非 n 到 0 时 break 或用 while(n) } exit(99); // 以状态值 99 退出 } return 0; }3. waitpid 函数——更灵活的版本#include sys/types.h #include sys/wait.h pid_t waitpid(pid_t pid, int *wstatus, int options);参数说明pid 参数pid 的值含义 -1回收**进程组 ID 等于-1回收任意子进程不限进程组0回收与调用进程同组的任意子进程 0回收指定 PID的子进程waitpid(-1, status, 0) ← 等价于 wait(status)options 参数options 的值含义0阻塞模式——没有子进程退出就一直等待WNOHANG非阻塞模式——如果没有子进程退出立即返回 0返回值情况返回值成功回收到子进程返回该子进程的 PID非阻塞模式下没有子进程状态改变返回 0失败返回 -1waitpid 三种行为1.如果所有子进程都在运行阻塞等待。2.如果有子进程终止立即获得终止状态并返回。3.如果没有子进程立即出错退出返回 -1。waitpid WNOHANG 非阻塞示例代码#include stdio.h #include sys/types.h #include unistd.h #include sys/wait.h #include stdlib.h int main(int argc, const char *argv[]) { // 创建子进程 pid_t pid fork(); if (pid 0) { perror(fork fail); return -1; } // 父进程 if (pid 0) { while (1) { printf(father hello world!\n); int status; int ret -1; // waitpid 非阻塞回收 // -1回收任意子进程 // WNOHANG非阻塞模式 // 返回值 0成功回收到的子进程 PID // 返回值 0没有子进程状态改变还在运行 // 返回值 0出错如没有子进程了 if ((ret waitpid(-1, status, WNOHANG)) 0) { perror(wait fail); return -1; } // ret ! 0 表示成功回收到了一个子进程 if (ret ! 0) { printf(ret %d\n, ret); // 打印回收到的子进程 PID if (WIFEXITED(status)) { // 正常结束打印退出状态值 printf(status %d\n, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { // 信号结束打印信号编号 printf(signo %d\n, WTERMSIG(status)); } } sleep(1); // 每隔 1 秒检查一次 } } // 子进程循环 10 次后退出 else if (pid 0) { int n 10; while (n) { sleep(1); printf(child hello world!\n); n--; } exit(99); // 以状态值 99 退出 } return 0; }运行结果子进程运行期间父进程非阻塞地做自己的事子进程结束后立即回收father hello world! child hello world! father hello world! child hello world! ...重复 10 次 child hello world! ← 子进程最后一次打印 father hello world! ret 6038 ← 父进程成功回收到子进程 status 99 ← 子进程的退出状态值 father hello world! father hello world! ← 父进程继续运行但子进程已回收完毕后续 waitpid 返回 -14. wait vs waitpid 对比对比项waitwaitpid阻塞/非阻塞只有阻塞版本可以非阻塞WNOHANG选择性回收只能回收任意一个子进程可以指定 PID回收等价关系wait(status)waitpid(-1, status, 0)注意waitpid 配合 WNOHANG 实现非阻塞回收时要想真正完成收尸操作必须使用轮询while 循环不断检查。后面还可以学习异步回收的方式——通过信号来通知父进程。五、fork 之后文件描述符的共享fork 之后父子进程共享1.子进程会继承父进程中已经打开的文件描述符。2.父子进程使用的是同一个文件表。3.相互之间的文件操作会受到影响。解决方案如果不想相互间受到影响可以在 fork之后再打开文件这样父子进程各自独立操作各自的文件。六、简易 Shellmyshell实现综合 fork、exec、strtok、wait可以实现一个简易的 shell 程序。版本 1使用 sleep 等待不推荐#include stdio.h #include unistd.h #include string.h int main(int argc, const char *argv[]) { char buf[1024] {0}; while (1) { // 1. 打印提示符 printf(myshell$ ); // 2. 从键盘读取一行输入 fgets(buf, sizeof(buf), stdin); // 3. 判断是否退出 if (strncmp(buf, exit, 4) 0 || strncmp(buf, quit, 4) 0) { printf(exit myshell ......\n); return 0; } // 4. 用 strtok 将输入按空格和换行拆分存入 args 数组 // 例如输入 ls -l / → args[0]ls, args[1]-l, args[2]/, args[3]NULL int i 0; char *args[10] {NULL}; args[0] strtok(buf, \n); while (args[i] strtok(NULL, \n)) ; // 5. fork 创建子进程 pid_t pid fork(); if (pid 0) { perror(fork fail); return -1; } if (pid 0) { // 父进程用 sleep(1) 等待子进程不推荐无法精确等待 sleep(1); continue; } else if (pid 0) { // 子进程调用 execvp 执行用户输入的命令 // execvp 带 p在 PATH 中查找带 v参数数组 if (execvp(args[0], args) 0) { perror(execvp fail); return -1; } } } return 0; }问题父进程用sleep(1)等待子进程不够精确可能导致子进程还没执行完父进程就开始下一轮循环了。版本 2使用 wait 回收推荐#include stdio.h #include unistd.h #include string.h #include sys/wait.h int main(int argc, const char *argv[]) { char buf[1024] {0}; while (1) { // 1. 打印提示符 printf(myshell$ ); // 2. 读取用户输入 fgets(buf, sizeof(buf), stdin); // 3. 判断退出 if (strncmp(buf, exit, 4) 0 || strncmp(buf, quit, 4) 0) { printf(exit myshell ......\n); return 0; } // 4. 解析命令参数 int i 0; char *args[10] {NULL}; args[0] strtok(buf, \n); while (args[i] strtok(NULL, \n)) ; // 5. fork 创建子进程 pid_t pid fork(); if (pid 0) { perror(fork fail); return -1; } if (pid 0) { // 父进程阻塞等待子进程结束推荐方式 // 比 sleep(1) 更精确确保子进程执行完毕 wait(NULL); // 不关心退出状态传 NULL } else if (pid 0) { // 子进程执行命令 if (execvp(args[0], args) 0) { perror(execvp fail); return -1; } } } return 0; }改进点用wait(NULL)替代sleep(1)父进程会阻塞等待子进程执行完毕后再继续下一轮循环。myshell 流程图开始 ↓ 打印提示符 myshell$ ↓ fgets 从键盘读入字符串 ↓ 判断是否为 exit/quit ├── 是 → 程序结束 └── 否 ↓ strtok 分割字符串存入指针数组 ↓ fork 创建子进程 ├── 父进程 → wait 等待子进程结束 → 回到循环开头 └── 子进程 → execvp 执行命令 → 结束七、总结本文覆盖了以下核心知识点知识点关键内容exec 函数族execl / execlp / execle / execv / execvp / execvpel vs v 区别l 逐个罗列参数v 用数组传参p 的含义在 PATH 环境变量中查找不需要完整路径e 的含义传递自定义环境变量strtok字符串拆分函数首次传原始串后续传 NULLexit vs _exitexit 刷新缓冲区并执行 atexit 清理_exit 不刷新不清理atexit注册退出清理函数倒序调用仅在正常退出时执行wait阻塞等待任意子进程退出并回收资源waitpid更灵活支持指定 PID、支持非阻塞WNOHANGWIFEXITED / WEXITSTATUS判断正常结束并获取退出值WIFSIGNALED / WTERMSIG判断信号结束并获取信号编号简易 shellfork execvp strtok wait 组合实现exec 函数族是 Linux 进程编程的核心配合 fork 就能实现创建新进程并执行新程序的能力。wait / waitpid 则是避免僵尸进程、回收子进程资源的必备工具。建议结合代码动手实践尤其是简易 shell 的实现它综合了本篇和上一篇的大部分知识点。