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[] = {"USR=linux", "PASSWD=123456", 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[] = {"USR=linux", "PASSWD=123456", 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]); } // 可以看到 ';', ':', '/' 都被替换成了 '\0'(ASCII 码 0) return 0; }三、进程退出(结束)
1. 进程终止的 8 种情况
分为正常终止和异常终止两大类:
正常终止(3~5 种)
| 编号 | 方式 | 说明 |
|---|---|---|
| 1 | main函数中return | 最常见的方式 |
| 2 | exit(status) | 库函数,会刷新 I/O 缓冲区,关闭所有流和打开的文件,并调用 atexit 注册的清理函数 |
| 3 | _exit(status)/_Exit(status) | 系统调用,会关闭所有文件,但不刷新缓冲区,不执行清理函数 |
| 4 | 主线程退出 | (多线程场景) |
| 5 | 主线程调用pthread_exit | (多线程场景) |
异常终止(6~8 种)
| 编号 | 方式 | 说明 |
|---|---|---|
| 6 | abort() | 发送 SIGABRT 信号终止进程 |
| 7 | signal/kill | 发信号结束进程,如kill -9 PID |
| 8 | 最后一个线程被pthread_cancel | (多线程场景) |
2. exit 函数详解
#include <stdlib.h> void exit(int status);- 功能:让进程退出,并刷新缓冲区。
- 参数:
status为进程退出状态。 - 常用宏:
EXIT_SUCCESS(0)、EXIT_FAILURE(1)。 - 退出状态只取低 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 |
| 失败 | 返回 -1 |
waitpid 三种行为
- 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 返回 -1)4. wait vs waitpid 对比
| 对比项 | wait | waitpid |
|---|---|---|
| 阻塞/非阻塞 | 只有阻塞版本 | 可以非阻塞(WNOHANG) |
| 选择性回收 | 只能回收任意一个子进程 | 可以指定 PID回收 |
| 等价关系 | wait(&status) | waitpid(-1, &status, 0) |
注意:waitpid 配合 WNOHANG 实现非阻塞回收时,要想真正完成"收尸"操作,必须使用轮询(while 循环不断检查)。后面还可以学习异步回收的方式——通过信号来通知父进程。
五、fork 之后文件描述符的共享
fork 之后,父子进程共享:
- 1.子进程会继承父进程中已经打开的文件描述符。
- 2.父子进程使用的是同一个文件表。
- 3.相互之间的文件操作会受到影响。
解决方案:如果不想相互间受到影响,可以在 fork之后再打开文件,这样父子进程各自独立操作各自的文件。
六、简易 Shell(myshell)实现
综合 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 / execvpe |
| l vs v 区别 | l 逐个罗列参数,v 用数组传参 |
| p 的含义 | 在 PATH 环境变量中查找,不需要完整路径 |
| e 的含义 | 传递自定义环境变量 |
| strtok | 字符串拆分函数,首次传原始串,后续传 NULL |
| exit vs _exit | exit 刷新缓冲区并执行 atexit 清理;_exit 不刷新不清理 |
| atexit | 注册退出清理函数,倒序调用,仅在正常退出时执行 |
| wait | 阻塞等待任意子进程退出并回收资源 |
| waitpid | 更灵活,支持指定 PID、支持非阻塞(WNOHANG) |
| WIFEXITED / WEXITSTATUS | 判断正常结束并获取退出值 |
| WIFSIGNALED / WTERMSIG | 判断信号结束并获取信号编号 |
| 简易 shell | fork + execvp + strtok + wait 组合实现 |
exec 函数族是 Linux 进程编程的核心,配合 fork 就能实现"创建新进程并执行新程序"的能力。wait / waitpid 则是避免僵尸进程、回收子进程资源的必备工具。建议结合代码动手实践,尤其是简易 shell 的实现,它综合了本篇和上一篇的大部分知识点。
