
⭐️博主: 此生决int-@CSDN博客
** 速胜派就是最大的投降派!!!**
** 🔥热门专栏🔥**
文章目录
- [4. 进程控制](#4. 进程控制)
-
- [1. 进程创建](#1. 进程创建)
-
- [1.1 `fork` 的接口与失败返回](#1.1
fork的接口与失败返回) - [1.2 `fork` 内部:先建立管理对象,再让子进程运行](#1.2
fork内部:先建立管理对象,再让子进程运行) - [1.3 `fork` 的两种常规用法](#1.3
fork的两种常规用法) - [1.4 `fork` 为什么会失败](#1.4
fork为什么会失败)
- [1.1 `fork` 的接口与失败返回](#1.1
- [2. 进程终止](#2. 进程终止)
-
- [2-1 进程退出场景](#2-1 进程退出场景)
- [2-2 常见退出方法](#2-2 常见退出方法)
- [2-2-1 退出码](#2-2-1 退出码)
-
- [1. 数字怎样表达成功和失败](#1. 数字怎样表达成功和失败)
- [2. `130`、`143` 为什么与信号有关](#2.
130、143为什么与信号有关) - [3. `strerror()` 不是退出码翻译器](#3.
strerror()不是退出码翻译器) - [4. 退出信息存在哪里,低 7 位又是什么](#4. 退出信息存在哪里,低 7 位又是什么)
- [2-3-2 _exit 函数](#2-3-2 _exit 函数)
- [2-3-3 exit 函数](#2-3-3 exit 函数)
- [2-3-4 return 退出](#2-3-4 return 退出)
- [3. 进程等待](#3. 进程等待)
-
- [3-1 进程等待必要性](#3-1 进程等待必要性)
- [3-2 进程等待的方法](#3-2 进程等待的方法)
-
- [3-2-1 wait 方法](#3-2-1 wait 方法)
- [3-2-2 waitpid 方法](#3-2-2 waitpid 方法)
- [3-2-3 获取子进程 status](#3-2-3 获取子进程 status)
- [3-2-4 阻塞与非阻塞等待](#3-2-4 阻塞与非阻塞等待)
- [4. 进程程序替换](#4. 进程程序替换)
-
- [4-1 替换原理](#4-1 替换原理)
-
- [为什么有了 `fork`,还需要 `exec`?](#为什么有了
fork,还需要exec?) - 换的是程序,不是进程
- 为什么成功后没有返回值?
- [为什么有了 `fork`,还需要 `exec`?](#为什么有了
- [4-2 替换函数](#4-2 替换函数)
-
- 六种函数:先看名字,再选用法
- 语法:参数应当放在哪里?
- [`execl` / `execlp`:逐个列出参数](#
execl/execlp:逐个列出参数) - [`execv` / `execvp`:把参数放进数组](#
execv/execvp:把参数放进数组) - [`execle` / `execve`:决定新程序看到什么环境](#
execle/execve:决定新程序看到什么环境) - 完整示例:六种函数执行同一个程序
- [为什么只有 `execve` 是系统调用?](#为什么只有
execve是系统调用?) - 为什么子进程替换不会影响父进程?
- 能不能替换成自己写的程序?
- [5. 自主 Shell 命令行解释器](#5. 自主 Shell 命令行解释器)
-
- [5-1 目标](#5-1 目标)
- [5-2 实现原理](#5-2 实现原理)
-
- [1. 自主 shell 的循环过程](#1. 自主 shell 的循环过程)
- [2. 内建命令 vs 普通命令](#2. 内建命令 vs 普通命令)
- [5-3 源码关键结构](#5-3 源码关键结构)
-
- [1. 全局数据:一轮命令需要哪些信息](#1. 全局数据:一轮命令需要哪些信息)
- [2. 提示符生成与命令解析](#2. 提示符生成与命令解析)
- [3. 普通命令的执行位置与结果保存](#3. 普通命令的执行位置与结果保存)
- [4. 环境变量初始化](#4. 环境变量初始化)
- [5. 内建命令实现](#5. 内建命令实现)
- [5-4 总结](#5-4 总结)
- 下期预告
- 基础IO
- 结语
上期回顾
大家好,这里是此生弃int(财哥) ,欢迎来到我的博客。上一篇我们主要学习了进程的基础概念,以及环境变量和虚拟地址空间 ,我们对操作系统和进程有了更深入的了解,今天我们就继续来看看进程控制相关知识,来看看进程的整个生命过程!
4. 进程控制
1. 进程创建
1.1 fork 的接口与失败返回
这个函数前已经讲过了,这里再复习一下
程序想创建子进程,不仅要考虑成功后的父子分流,还必须考虑"系统没有创建成功"的情况。fork 的成功返回值和分流方式已学过,这里补充接口与失败处理。
语法:
cpp
#include <unistd.h> // 提供 fork 的函数声明
pid_t fork(void); // 接口原型:不接收参数,返回进程编号类型的值
返回值:子进程:0,父进程:子进程的pid,创建失败:-1
pid_t 是用于表示进程编号的整数类型,也能表示 -1 这样的错误值。
示例:下面只保留创建失败时必须处理的路径,不重复父子分流。
cpp
#include <stdio.h> // 提供 perror
#include <unistd.h> // 提供 fork 和 pid_t
int main(void) { // 程序入口
pid_t result = fork(); // 尝试创建子进程
if (result == -1) { // 创建失败时,只有原进程继续执行
perror("fork"); // 输出 fork 失败的具体错误信息
return 1; // 以非零状态结束,表示运行失败
}
return 0; // 创建成功后,父子分别正常结束
}
perror 会把给定文字与系统记录的错误原因一起输出,方便判断失败原因。注意:失败时并不存在一个"拿到 -1 的子进程"。
1.2 fork 内部:先建立管理对象,再让子进程运行
只让父进程返回一个编号,系统并不会凭空多出一个可运行的进程。因为系统还需要单独记录和管理子进程,所以必须先为它准备内核中的管理对象。
可以把内部过程理解为以下顺序:
- 分配必要的内存和内核数据结构。为子进程准备独立的 PCB 等管理对象;这里的"分配新内存块"不表示立即复制父进程的全部代码和数据。
- 复制父进程的部分信息,并调整子进程专属信息。复制能够继承的内容,同时设置新的 PID 等,不能把父进程的管理记录原封不动照搬。
- 将子进程加入系统的管理范围,并使其可以被调度。不仅要让系统"查得到它",还要让它具备获得 CPU 的条件。
- 父子分别继续执行 。这里沿用已学过的
fork执行位置与调度规则,不保证父子谁先运行。
创建子进程必须建立独立的管理信息,但不必立即复制父进程的全部内存内容。
1.3 fork 的两种常规用法
程序需要帮手时,有两种不同需求:要么让帮手按照现有程序继续分工,要么让帮手改去运行另一套程序。
| 需求场景 | 做法 | 子进程运行的内容 |
|---|---|---|
| 程序既要接待新请求,又要处理已经收到的请求 | 使用已学过的父子分流:父进程继续等待客户端请求,子进程处理当前请求 | 原程序中的不同代码段 |
| 创建帮手后,希望它运行另一个程序 | 子进程在 fork 返回后调用 exec |
新程序的代码 |
客户端就是向服务程序发出请求的程序;exec 是一组用于把当前进程正在运行的程序替换成另一个程序的函数。
第二种需求不能只靠 fork 完成:fork 创建出来的子进程仍然延续原程序,所以还需要 exec 来更换它运行的程序。
fork负责"创建进程",exec负责"替换当前进程运行的程序",后者不会再创建一个进程。
1.4 fork 为什么会失败
创建进程要消耗系统资源。如果允许无限创建,单个程序或用户就可能挤占整台机器,因此系统会设置数量限制。
| 失败原因 | 限制范围 | 直观理解 |
|---|---|---|
| 系统中的进程太多 | 整个系统 | 整台机器的名额用完了 |
| 实际用户的进程数超过限制 | 某个用户 | 机器可能还有名额,但该用户的配额已用完 |
这里的"实际用户"指进程实际用户 ID 所标识的用户,系统据此判断相应的用户额度。提要列出的是两类常见原因,并非全部;内存等资源不足也可能造成创建失败,因此应通过 perror 查看实际错误,而不是看到 -1 就认定进程数量超限。
2. 进程终止
2-1 进程退出场景
一个程序不再运行,只能说明"它结束了",不能说明"它把事情办好了"。判断结束情况,要把两个问题分开:程序是否按自己的安排结束?任务是否完成成功?
| 场景 | 含义 | 如何判断 |
|---|---|---|
| 代码运行完毕,结果正确 | 程序主动结束,并报告成功 | 通常返回退出码 0 |
| 代码运行完毕,结果不正确 | 程序发现任务做不成,但仍能主动结束 | 通常返回非零退出码 |
| 代码异常终止 | 程序没有走正常结束流程,而是被信号终止 | 查看终止信号 |
信号:操作系统向进程发送的一种事件通知,其中一些信号默认会让进程结束。
这里的"正常终止"说的是结束方式正常 ,不是"计算结果一定正确"。即使程序算错了,只要它仍然返回 0,外部看到的也只是它报告的"成功";操作系统不会替它检查业务结果。
2-2 常见退出方法
程序完成任务后,需要主动通知系统"我不再运行了";如果程序被中断,也可能由信号让它结束。
| 结束方式 | 方法 | 特点 |
|---|---|---|
| 正常终止 | 从 main 返回 |
用返回值报告结果 |
| 正常终止 | 调用 exit |
先做 C 库退出清理,再结束进程 |
| 正常终止 | 调用 _exit |
不做这些 C 库退出清理,直接结束进程 |
| 异常终止 | 由 SIGINT 等信号终止 |
不依赖程序主动走到退出位置 |
ctrl+c 通常向前台运行的进程发送 SIGINT,即编号为 2 的中断信号;它的默认动作是终止进程。程序也可能处理或忽略它,因此按下按键并不保证程序一定结束。
程序结束后,屏幕上的输出未必能说明成败,于是需要一个专门报告结果的数字------退出码。
语法: echo $?。其中,$? 表示 shell 保存的上一条命令的状态,echo 把它显示出来。
bash
ls / # 执行一条通常能成功的命令
echo $? # 立即查看上一条命令的状态,通常输出 0
查看退出结果时,要紧接着执行
echo $?;中间执行其他命令,就会覆盖你要查看的状态。
注意:它查看的是当前 shell 中上一条命令的状态,不是"系统里最近结束的任意进程"。
2-2-1 退出码
1. 数字怎样表达成功和失败
程序可能因不同原因失败。如果只报告"失败",调用它的人不知道该怎么处理,因此程序可以约定不同数字代表不同原因。
通常,0 表示成功,非零表示失败或其他需要区分的结果。例如,一份程序可以约定 1 表示输入不合法、2 表示目标文件不存在,但其他程序不必采用这套约定。
不同退出码可以表示不同原因,但具体含义由程序约定,不存在所有程序通用的错误码对照表。
提要中的"退出码 1 表示不被允许的操作"不能当作通用规则:
- 权限不足可能让某个工具返回
1,但要以工具自身的约定为准。 - 整数除以零不是"必然返回退出码
1"。在 C 中,这是未定义行为,即语言不保证会发生什么;常见 Linux 环境中可能触发SIGFPE,一种算术错误信号。
2. 130、143 为什么与信号有关
进程被信号终止时,没有正常返回一个退出码,但 shell 仍需要用一个数字向用户报告结果。Bash 通常采用 128 + 信号编号 的方式表示。
| 终止原因 | 信号编号 | Bash 中常见的 $? |
|---|---|---|
被 SIGINT 终止 |
2 |
130 = 128 + 2 |
被 SIGTERM 终止 |
15 |
143 = 128 + 15 |
SIGTERM 是请求进程终止的信号,默认动作也是结束进程。
这里的 130、143 是 shell 转换后的报告值 。程序也能主动返回 130,所以仅凭这个数字,不能百分之百断言它被 SIGINT 终止。
3. strerror() 不是退出码翻译器
看到非零数字后,很自然会想"有没有函数能把它翻译成人话?"但退出码由各程序自行约定,标准库不可能知道所有约定。
strerror() 实际翻译的是 错误编号 :系统调用或库函数报告失败原因时使用的一套编号;它们常由 errno 保存,errno 是记录这类错误编号的位置。
语法: char *strerror(int errnum),声明在 <string.h> 中。它接收错误编号,返回对应的描述字符串。
cpp
#include <stdio.h> // 声明 puts
#include <string.h> // 声明 strerror
#include <errno.h> // 提供 EACCES 等错误编号
int main(void) { // 演示翻译错误编号,而非退出码
puts(strerror(EACCES)); // 显示"权限不足"的描述,文字随系统环境变化
return 0; // 报告本演示程序正常完成
}
不能用 strerror(130) 判断程序为什么退出;即使它给出了文字,也是在解释错误编号 130,不是解释 Bash 的终止状态 130。
4. 退出信息存在哪里,低 7 位又是什么
子进程结束后,父进程不一定立刻来读取结果,因此内核必须先保存退出信息,供父进程稍后获取。
Linux 会把退出信息记录在进程的
task_struct中;其中保存的是编码后的终止状态,不能简单等同于$?显示的数字。
等待状态 :父进程通过 wait 取得的一份终止信息,其中既能记录正常退出码,也能记录终止信号。wait 是让父进程等待子进程结束并取得其结果的接口,具体使用留到进程等待部分。
仅针对 Linux 中"子进程已经终止"的常见编码,可以这样理解:
| 等待状态中的部分 | 正常终止 | 被信号终止 |
|---|---|---|
| 低 7 位 | 为 0 |
保存终止信号编号 |
| 第 8 位 | 通常为 0 |
可标记是否产生核心转储,即用于排查崩溃的现场记录 |
| 再往上的 8 位 | 保存正常退出码 | 不能当作正常退出码解释 |
应先判断进程是否正常终止,再读取退出码;如果终止状态的低 7 位非零,应查看信号原因,而不是解释正常退出码。
"低 7 位"说的是 wait 得到的等待状态 ,不是 $?。实际编程通常使用 WIFEXITED、WEXITSTATUS、WIFSIGNALED、WTERMSIG 这些现成判断工具,分别判断正常结束、提取退出码、判断信号终止和提取信号编号,不必自己拆位。
2-3-2 _exit 函数
程序有时需要直接结束,不再执行 C 库安排的退出清理。这时可以使用 _exit。
语法:
cpp
#include <unistd.h> // 提供 _exit 的声明
void _exit(int status); // 接收终止状态;调用后不会返回
功能: 结束整个进程,status 用来报告结果。父进程通过 wait 取得等待状态后,再从中提取正常退出码。
status 虽然是 int,但父进程能取得的正常退出码只有它的低 8 位 ,因此范围是 0~255。
示例: 将以下程序保存为 exit_low8.c,用已学过的编译方法生成可执行文件 exit_low8。
cpp
#include <unistd.h> // 声明 _exit
int main(void) { // 程序入口
_exit(-1); // Linux 上 -1 的低 8 位全为 1,因此报告值为 255
}
bash
./exit_low8 # 执行程序
echo $? # 显示 255,而不是 -1
_exit走直接终止进程的路径,不刷新 C 库的输出缓冲区;尚未交出去的缓冲数据可能丢失。
注意:课程常把 _exit 归为系统调用。严格说,程序调用的是 C 库提供的系统调用封装;现代 Linux 的常见实现通过 exit_group 结束整个进程。它不做 C 库清理,并不意味着内核不回收进程资源。
2-3-3 exit 函数
直接结束可能丢掉尚未送出的输出,也可能跳过程序事先安排的收尾工作。因此,更常用的 exit 会先执行 C 库退出清理,再终止进程。
语法:
cpp
#include <stdlib.h> // exit 的正确头文件,不是 unistd.h
void exit(int status); // 接收终止状态;调用后不会返回
功能: 报告终止状态,并按顺序完成以下工作:
- 执行通过
atexit或on_exit登记的清理函数,即程序提前约好的"退出前要做的事"。其中on_exit不是标准 C 接口。 - 刷新输出流的缓冲数据,再关闭打开的流;流 就是 C 库管理输入输出时使用的对象,如
stdout。 - 进入
_exit这一类底层终止路径,最终结束进程。
| 对比项 | exit |
_exit |
|---|---|---|
| 所在层次 | 提供退出清理的库函数 | 封装底层终止操作的接口 |
| 执行已登记的退出清理函数 | 是 | 否 |
| 刷新 C 库输出缓冲区 | 是 | 否 |
| 是否结束整个进程 | 是 | 是 |
示例: 保存为 exit_buffer.c,编译为 exit_buffer。
cpp
#include <stdio.h> // 提供 printf、stdout 和 setvbuf
#include <stdlib.h> // 声明 exit
#include <unistd.h> // 声明 _exit
int main(int argc, char *argv[]) { // 根据是否提供参数选择退出方式
static char buffer[4096]; // 为本次实验提供持续有效的缓冲空间
if (setvbuf(stdout, buffer, _IOFBF, sizeof buffer) != 0) { // 设置全缓冲
return 1; // 设置失败时不继续实验
}
printf("hello"); // 先把短文本留在 C 库缓冲区中
if (argc > 1) { // 提供任意额外参数时走直接退出路径
_exit(0); // 不刷新,因此这段文本不会输出
}
exit(0); // 先刷新,因此会输出 hello
}
bash
./exit_buffer # 使用 exit,输出 hello
./exit_buffer direct # 使用 _exit,不输出 hello
这里沿用已学过的缓冲区知识;实验特意设置全缓冲,避免输出行为依赖终端环境。
本节最关键的区别是:
exit刷新的是 C 语言库提供的缓冲区,_exit不刷新;这里不是在比较是否刷新操作系统内部的缓冲区。
刷新 C 库缓冲区表示把数据交给操作系统,不等于保证数据已经写到磁盘硬件上。
2-3-4 return 退出
return 本身负责结束当前函数调用。它能否结束进程,取决于从哪个函数返回。
普通函数有调用者,返回后调用者还能继续执行;main 返回时,接手的是 C 运行时 ------负责在 main 前后完成启动和收尾工作的一组支持代码。正常启动的 C 程序中,运行时会把 main 的返回值作为 exit 的参数。
| 写法及位置 | 实际效果 |
|---|---|
在 main 中执行 return n |
正常结束进程,效果相当于 exit(n) |
在其他函数中执行 return n |
只结束当前函数,把结果交回调用者 |
在任意函数中调用 exit(n) |
结束整个进程,不再回到调用位置继续执行 |
示例:
cpp
#include <stdio.h> // 声明 puts
int check(void) { // 普通函数,不是进程入口
return 2; // 只结束 check,向 main 返回 2
}
int main(void) { // 程序入口
int result = check(); // check 返回后,main 继续执行
puts("main is still running"); // 证明普通函数返回没有结束进程
return result; // 从 main 返回,最终以退出码 2 正常终止
}
main中的return结束进程,普通函数中的return只结束该次调用;任何函数中调用exit都会结束整个进程。
退出状态由父进程读取,并非固定"返回给 Bash";对于 Bash 直接启动的普通前台程序,父进程是 Bash,它再把结果提供给 $?。
3. 进程等待
3-1 进程等待必要性
父进程安排子进程完成任务后,会遇到两个问题:子进程退出后留下的资源由谁回收?父进程怎样知道任务完成得如何? 前者对应已学过的僵尸进程问题,后者则需要读取子进程的退出信息。
因此需要进程等待:父进程通过操作系统提供的接口,收取子进程的退出信息,并完成退出后的资源回收。这里的"等待"不一定意味着停下来等;如果子进程已经退出,父进程可以直接收取结果。
进程等待的首要任务是回收子进程资源;获取退出信息是可选的,不关心结果也应该回收。
退出信息需要区分两件事:
- 是否正常退出:是程序自己结束,还是被信号终止。
- 任务是否成功 :程序自己结束时,用退出码 ------交给父进程的一个小整数------表达结果。通常约定
0表示成功,非0表示失败,具体含义由程序约定。
所以,"正常退出"不等于"任务成功":程序发现输入错误后返回 1,仍然属于正常退出。
3-2 进程等待的方法
3-2-1 wait 方法
如果父进程只想收取一个子进程的退出结果,不在乎具体是哪一个,可以使用 wait。
语法
cpp
#include <sys/types.h> // 提供 pid_t 等类型
#include <sys/wait.h> // 提供进程等待接口
pid_t wait(int *status); // 等待一个子进程,并通过 status 接收退出信息
pid_t 是保存进程 PID 的整数类型。status 是输出型参数:父进程给出变量地址,由操作系统向该变量写入结果,而不是把它作为输入条件。
功能
| 项目 | 含义 |
|---|---|
| 成功返回值 | 本次收取结果的子进程 PID |
| 失败返回值 | -1 |
status |
接收退出信息的变量地址 |
status 为 NULL |
不读取退出信息,但仍然回收资源 |
这里有两个不同的结果:返回值回答"收到了谁",status 回答"它怎么结束的"。
示例:只回收,不读取退出信息
cpp
#include <sys/types.h> // 提供 pid_t
#include <sys/wait.h> // 提供 wait
#include <unistd.h> // 提供 fork 和 sleep
#include <stdio.h> // 提供 printf 和 perror
int main() { // 父进程创建子进程后等待回收
pid_t child = fork(); // 创建子进程
if (child == -1) { // 创建失败时不进入等待
perror("fork"); // 输出失败原因
return 1; // 报告失败
}
if (child == 0) { // 子进程执行任务
sleep(2); // 用暂停两秒模拟任务耗时
return 0; // 正常结束
}
pid_t ret = wait(NULL); // 等待并回收一个子进程,不接收退出信息
if (ret == -1) { // 判断等待是否失败
perror("wait"); // 输出失败原因
return 1; // 报告失败
}
printf("已回收子进程 PID=%ld\n", (long)ret); // 显示被回收者
return 0; // 父进程结束
}
注意事项
有可等待的子进程但它尚未退出时,父进程会阻塞在
wait调用处;子进程已经退出时,则立即收取结果。
阻塞期间,父进程不会继续执行 wait 后面的语句。没有可等待的子进程时,wait 返回 -1,不是永远等下去。
3-2-2 waitpid 方法
如果有多个子进程,父进程可能只想等待其中一个;也可能只想问一下"结束了吗",没结束就先做别的事。wait 不能直接表达这些要求,因此需要参数更灵活的 waitpid。
语法
cpp
#include <sys/types.h> // 提供 pid_t
#include <sys/wait.h> // 提供 waitpid 和等待选项
pid_t waitpid(pid_t pid, int *status, int options); // 指定等待对象、结果地址和等待方式
功能:三个参数分别管什么
① pid:选择等待对象
为了能一次选择一批相关进程,系统提供了进程组:给一组进程分配同一个组号。这里仅用组号筛选自己的子进程。
pid 的取值 |
等待对象 |
|---|---|
< -1 |
进程组 ID 等于 pid 绝对值的任意子进程 |
-1 |
任意子进程 |
0 |
与调用者属于同一进程组的任意子进程 |
> 0 |
PID 等于 pid 的那个子进程 |
pid不总是一个具体 PID:正数选一个子进程,-1选任意子进程,0和小于-1的值按进程组筛选。
这些选择都受"自己的子进程"限制,不是填写任意进程 PID 就能等待它。waitpid(-1, &status, 0) 与 wait(&status) 等效。
② status:接收退出信息
与 wait 相同:传变量地址让操作系统填写,不关心信息可以传 NULL。
③ options:决定是否停下来等待
options 是必须传入的参数,没有可以省略的默认实参。最基本的取值为:
options |
没有子进程退出时的行为 |
|---|---|
0 |
阻塞等待 |
WNOHANG |
立即返回,不停下来等 |
WNOHANG 的含义是:return immediately if no child has exited,即没有符合条件的子进程退出时立即返回。
返回值与子进程状态
以下只讨论 0 和 WNOHANG 这两种选项:
| 符合选择条件的子进程情况 | options = 0 |
options = WNOHANG |
|---|---|---|
| 已有子进程退出,尚未回收 | 返回其 PID,并回收 | 返回其 PID,并回收 |
| 有子进程,但都尚未退出 | 阻塞 | 返回 0 |
| 没有可等待的子进程 | 返回 -1 |
返回 -1 |
注意:返回 PID 不代表任务成功,也不只发生在正常退出时;子进程被信号终止后,同样可以返回其 PID。
示例安排
下一节的完整程序使用 waitpid(child, &st, 0),演示"等待指定子进程";3-2-4 使用 WNOHANG,演示"不停下来等"。
注意事项
只有成功收取结果、返回正数时,才读取本次得到的 status。返回 0 表示还没收取到结果;返回 -1 则需要检查错误原因。
3-2-3 获取子进程 status
父进程既想知道退出码,又想知道是否被信号终止,一个整数怎样装下这些信息?办法是把整数的不同二进制位分给不同用途。
这种表示叫位图 :不是把整个整数看成一个数,而是分别读取其中几位代表的信息。这里的 status 在 C 类型上仍是 int,只是不能把它直接当作退出码。
低 16 位的结构
一个比特位 就是只能取 0 或 1 的二进制位置。位号从最右侧的 0 开始数;本节只讨论 Linux 下正常终止和被信号终止时的低 16 位。
| 位置 | 正常终止时 | 被信号终止时 |
|---|---|---|
| 高 8 位:第 15~8 位 | 退出码 | 不作为有效退出码读取 |
| 第 7 位 | 0 |
core dump 标志 |
| 低 7 位:第 6~0 位 | 全部为 0 |
终止信号编号 |
程序异常结束后,有时需要留下现场供排查;core dump 就是记录进程出错现场的机制。这里的标志与这种终止情况有关,不保证当前目录一定能找到现场文件。
先看终止方式,再决定怎样解释其他位:正常终止才读退出码,被信号终止则读终止信号。
这也意味着:低 7 位为 0 时,退出码仍然可能非 0;"没有被信号终止"和"任务成功"不是一回事。
语法与功能:优先使用系统提供的判断工具
为了不让程序依赖手写的位布局,系统提供了以下宏,可以把它们当作已经写好的判断或提取表达式:
| 写法 | 功能 | 使用前提 |
|---|---|---|
WIFEXITED(status) |
判断是否正常终止 | 已成功获得退出信息 |
WEXITSTATUS(status) |
提取退出码 | WIFEXITED(status) 为真 |
为解释信号终止,示例还会使用 WIFSIGNALED(status) 判断是否被信号终止,以及 WTERMSIG(status) 提取终止信号编号。
课堂手动提取的两个表达式对应:
(st >> 8) & 0xFF:取高 8 位,0xFF是用于保留 8 位的十六进制数。st & 0x7F:取低 7 位,0x7F用于保留 7 位。
示例:同一个程序测试两种终止方式
不带额外参数时,子进程返回 7;带任意额外参数时,父进程向子进程发送 SIGKILL,即 9 号强制终止信号。
cpp
#include <sys/types.h> // 提供 pid_t
#include <sys/wait.h> // 提供 waitpid 和状态解析宏
#include <unistd.h> // 提供 fork 和 pause
#include <signal.h> // 提供 kill 和 SIGKILL
#include <stdio.h> // 提供 printf 和 perror
#include <errno.h> // 提供错误编号 errno 和 EINTR
int main(int argc, char **) { // 用是否有额外参数选择测试方式
bool signal_test = argc > 1; // 有额外参数时测试信号终止
pid_t child = fork(); // 创建被等待的子进程
if (child == -1) { // 检查创建结果
perror("fork"); // 输出失败原因
return 1; // 报告失败
}
if (child == 0) { // 子进程分支
if (signal_test) { // 信号测试时等待父进程终止自己
for (;;) pause(); // 暂停运行,等待信号
}
return 7; // 正常终止,退出码为 7
}
if (signal_test) { // 父进程执行信号测试
if (kill(child, SIGKILL) == -1) { // 向子进程发送强制终止信号
perror("kill"); // 输出发送失败原因
}
}
int st = 0; // 接收系统填写的退出信息
pid_t ret; // 保存本次等待结果
do { // 等待被信号打断时重新尝试
ret = waitpid(child, &st, 0); // 阻塞等待指定子进程并回收
} while (ret == -1 && errno == EINTR); // EINTR 表示调用被信号打断
if (ret == -1) { // 其他等待错误不继续解析状态
perror("waitpid"); // 输出失败原因
return 1; // 报告失败
}
if (WIFEXITED(st)) { // 先确认正常终止
printf("退出码=%d,手动提取=%d\n",
WEXITSTATUS(st), (st >> 8) & 0xFF); // 对比宏与高 8 位提取结果
} else if (WIFSIGNALED(st)) { // 确认由信号终止
printf("终止信号=%d,手动提取=%d\n",
WTERMSIG(st), st & 0x7F); // 对比宏与低 7 位提取结果
}
return 0; // 父进程结束
}
注意事项
手工移位用于理解本节的 Linux 位布局;实际程序优先使用系统宏。本节结论不扩展到子进程暂停、恢复等其他等待事件。
退出信息为什么能在子进程结束后读取?
如果子进程一结束就把全部记录删掉,父进程稍后调用等待接口,就无从得知结果。因此内核会先保留退出信息,直到被收取。
内核先把子进程的退出信息保存在
task_struct等相关记录中;父进程调用wait/waitpid时,内核将其整理写入status,并完成回收。
提要中的 exit_code、exit_signal 可以用来区分"退出码"和"终止信号"这两类信息,但不能直接当作 Linux 内核中两个同名字段的含义:实际 task_struct.exit_signal 通常表示子进程退出时通知父进程的信号,并非杀死子进程的信号。
3-2-4 阻塞与非阻塞等待
父进程如果只负责收取结果,停下来等很方便;但如果它还要处理其他任务,停在等待调用处就会耽误自己的工作。因此需要根据任务安排选择等待方式。
语法与功能
| 对比项 | 阻塞等待 | 非阻塞等待 |
|---|---|---|
| 调用 | waitpid(-1, &status, 0) |
waitpid(-1, &status, WNOHANG) |
| 有子进程,但都未退出 | 停在调用处等待 | 立即返回 0 |
| 父进程能否继续往下执行 | 等待期间不能 | 返回后可以 |
| 电话类比 | 李四不接,你一直拿着电话等 | 李四不接,你挂断去做别的事,过会儿再打 |
这里课堂所说的"父进程挂起",是泛指暂停执行、阻塞等待,不是专指已学过的换出到 swap。
非阻塞轮询:问一次,做些事,再问一次
一次非阻塞调用只能告诉父进程"现在有没有结果"。为了最终收取结果,需要反复检查,这叫轮询:隔一段时间再问一次,而不是一直停在一次调用里。
WNOHANG只保证本次等待不因子进程尚未退出而停住;"边等边做自己的事"需要父进程在调用返回后主动安排。
如果要安排多个任务,可以把它们的函数地址存入数组,再依次调用。这就是这里的回调机制 :先登记要做的函数,到合适的时候再执行。handler_t 用来给函数指针类型起名字,不是系统提供的固定类型。
示例:通过函数指针数组处理其他任务
cpp
#include <sys/types.h> // 提供 pid_t
#include <sys/wait.h> // 提供 waitpid 和 WNOHANG
#include <unistd.h> // 提供 fork 和 sleep
#include <stdio.h> // 提供 printf 和 perror
#include <errno.h> // 提供 errno 和 EINTR
typedef void (*handler_t)(); // 给"无参数、无返回值的函数指针"起名
void handle_messages() { // 一个可以登记的任务
printf("父进程:处理消息\n"); // 模拟处理消息
}
void update_progress() { // 另一个可以登记的任务
printf("父进程:更新进度\n"); // 模拟更新进度
}
int main() { // 演示轮询期间执行其他任务
pid_t child = fork(); // 创建子进程
if (child == -1) { // 检查创建结果
perror("fork"); // 输出失败原因
return 1; // 报告失败
}
if (child == 0) { // 子进程分支
sleep(3); // 模拟持续三秒的任务
return 0; // 正常结束
}
handler_t handlers[] = {handle_messages, update_progress}; // 登记回调函数
int status = 0; // 接收退出信息
for (;;) { // 持续轮询,直到回收成功或遇到错误
pid_t ret = waitpid(-1, &status, WNOHANG); // 查询是否有子进程退出
if (ret > 0) { // 已成功回收一个子进程
if (WIFEXITED(status)) { // 正常终止时读取退出码
printf("子进程退出码=%d\n", WEXITSTATUS(status)); // 显示任务结果
} else if (WIFSIGNALED(status)) { // 信号终止时读取信号编号
printf("终止信号=%d\n", WTERMSIG(status)); // 显示终止原因
}
break; // 本例只有一个子进程,回收后结束轮询
}
if (ret == -1) { // 查询失败
if (errno == EINTR) continue; // 调用被信号打断时重试
perror("waitpid"); // 输出其他失败原因
return 1; // 结束程序,避免无效轮询
}
printf("child is running\n"); // ret 为 0:尚无可收取的退出结果
for (handler_t handler : handlers) { // 依次取出登记的函数
handler(); // 调用函数,处理父进程自己的任务
}
sleep(1); // 控制查询频率,避免空转占用 CPU
}
return 0; // 父进程结束
}
注意事项
这里的 sleep(1) 是主动安排的轮询间隔,不是 waitpid 导致的阻塞;间隔越长,发现子进程退出的延迟可能越大。若其他任务本身耗时很长,也会推迟下一次检查。
当登记的任务不只是普通函数,还需要携带自己的数据时,可以使用 std::function :C++ 标准库提供的通用可调用对象容器。这里对应的类型是 std::function<void()>,表示存放"无参数、无返回值"的任务;本例只有普通函数,使用 handler_t 数组已经足够。
4. 进程程序替换
4-1 替换原理
为什么有了 fork,还需要 exec?
如果父进程想让子进程执行另一个程序,仅仅调用 fork 不够:创建出来的子进程仍然执行原来的程序。于是需要一种操作,让这个子进程"保留身份,换掉正在执行的程序",这就是进程程序替换。
exec 是一组替换函数的统称,不是这里某个具体函数的名字;exec* 中的 * 表示这一组函数的不同名字。
进程程序替换:把磁盘上的新程序加载到调用进程的地址空间中,用新程序的代码和数据替换旧程序的代码和数据。
替换成功后,执行位置也换了:进程从新程序的启动例程 开始执行。启动例程就是进入 main 之前负责准备运行环境的那段代码,而不是旧程序调用位置后面的代码。
换的是程序,不是进程
要执行另一个程序,不一定要再增加一个进程。exec 直接改换调用者正在运行的程序,因此:
| 对比项 | fork |
exec |
|---|---|---|
| 是否创建新进程 | 是 | 否 |
| PID | 子进程有自己的 PID | 调用进程的 PID 不变 |
| 执行的程序 | 起初仍是原程序 | 成功后执行新程序 |
| 原代码是否继续执行 | 按父子分支执行 | 调用进程的原代码不再继续 |
结合已学的进程组成,可以把替换理解为:保留 task_struct 所代表的进程身份,换掉用户空间的程序内容 。这里的用户空间就是进程中运行普通程序、存放其代码和数据的部分。
"task_struct 不变"指它没有被另一个新进程的 task_struct 取代,不代表其中所有字段都不更新;程序替换时,相关管理信息仍然会调整。
调用
exec不创建新进程:程序换了,调用进程的 PID 不变。
为什么成功后没有返回值?
普通函数完成工作后,会回到调用位置继续执行。但程序替换成功时,旧程序的代码已经被换掉,原来准备接收返回值、执行下一条语句的位置也不再保留。
因此,exec* 的行为是:
| 结果 | 接下来执行什么 |
|---|---|
| 替换成功 | 执行新程序,不回到原调用位置 |
| 替换失败 | 返回 -1,继续执行原程序后面的语句 |
失败可能是因为目标不存在、没有执行权限,或者不是可执行的程序。此时可用 perror 输出失败原因;perror 就是把最近一次错误转换成可读文字的函数。
exec*没有"成功返回值";只要调用返回了,就说明替换失败。
新程序以后正常结束,是新程序退出 ,不是 exec 返回成功。
4-2 替换函数
六种函数:先看名字,再选用法
执行新程序时,需要回答三个问题:程序在哪里、启动参数怎么传、环境变量从哪里来。六种函数做的是同一种替换,只是这三件事的交代方式不同。
这些函数的声明都在 <unistd.h> 中。
| 字母 | 含义 | 解决什么问题 |
|---|---|---|
l:list |
参数逐个列出来 | 参数少且固定时,直接一个一个写 |
v:vector |
参数放在数组里 | 参数已保存在数组中时,直接传数组 |
p:path |
可以根据 PATH 搜索程序 |
只知道程序名,不想指定所在目录 |
e:env |
显式传入环境变量数组 | 想为新程序指定一套环境变量 |
l 和 v 是两种参数组织方式;p 和 e 则描述额外能力。
l用列表,v用数组,p搜索PATH,e自己传环境变量。
语法:参数应当放在哪里?
下面的 path 表示程序路径,file 表示程序名或路径,arg0 表示新程序收到的 argv[0]。
| 函数 | 调用形式 | 程序定位 | 新程序环境 |
|---|---|---|---|
execl |
execl(path, arg0, ..., (char *)NULL) |
指定路径 | 继承当前环境 |
execlp |
execlp(file, arg0, ..., (char *)NULL) |
可搜索 PATH |
继承当前环境 |
execle |
execle(path, arg0, ..., (char *)NULL, envp) |
指定路径 | 使用 envp |
execv |
execv(path, argv) |
指定路径 | 继承当前环境 |
execvp |
execvp(file, argv) |
可搜索 PATH |
继承当前环境 |
execve |
execve(path, argv, envp) |
指定路径 | 使用 envp |
注意两处容易混淆的地方:
- 无
p不等于必须写绝对路径 :/usr/bin/ls和./other都可以,只是不会按PATH搜索。 - 有
p也可以传路径 :当传入的名字包含/时,就按该路径执行,不再搜索PATH。
execl / execlp:逐个列出参数
以执行 ls -l -a 为例,execl 的典型写法是 execl("/usr/bin/ls", "ls", "-l", "-a", (char *)NULL)。
为什么前面出现两次 ls?因为"找到哪个程序"和"给这个程序传什么参数"是两件事:
| 参数 | 含义 |
|---|---|
"/usr/bin/ls" |
用来寻找并加载目标程序 |
"ls" |
传给新程序的 argv[0],按惯例写程序名 |
"-l" |
新程序的 argv[1] |
"-a" |
新程序的 argv[2] |
(char *)NULL |
表示参数列表结束,不是字符串参数 |
第二个参数并不负责寻找程序,也不强制与文件名完全一致;初学时按命令中的程序名填写即可。每个命令行参数分别传递,不要把 "ls -l -a" 当作一个整体传进去。
execlp 只改变寻找方式,对应调用为 execlp("ls", "ls", "-l", "-a", (char *)NULL),由 PATH 帮忙寻找 ls。
注意:提要中的 NULL 在可变参数调用中写成 (char *)NULL 更稳妥,明确表示这是一个空指针,而不是普通整数。
execv / execvp:把参数放进数组
如果启动参数已经在数组里,就不必再逐项写到函数调用中。argv 数组应包含 "ls"、"-l"、"-a",并以空指针结尾,然后调用:
execv("/usr/bin/ls", argv):自己指定路径。execvp("ls", argv):通过PATH找程序。
声明中的 char *const argv[] 是这些函数接收参数数组的形式;调用时可准备普通的 char *argv[]。
程序路径负责"找程序";参数列表或数组负责"给新程序传参数",其中第一项是新程序的
argv[0]。
execle / execve:决定新程序看到什么环境
有时,新程序不仅要接收命令行参数,还要读取某个环境变量。此时需要分清:是修改当前环境再继承,还是直接交给新程序一份环境清单。
envp 是环境变量字符串数组,每项写成 "名字=值",最后用空指针结束。
| 方式 | 做法 | 影响范围 |
|---|---|---|
| 默认继承 | 使用不带 e 的函数 |
新程序使用调用进程当时的环境 |
| 先修改,再继承 | 用 putenv 新增或修改变量,再调用不带 e 的函数 |
先改变调用进程的环境,新程序随后继承 |
| 显式指定 | 用 execle / execve 传入 envp |
新程序使用这份环境,不会自动合并旧环境 |
putenv 是把一条 "名字=值" 字符串加入当前进程环境的函数。例如,准备可写数组 char setting[] = "COURSE=process",再调用 putenv(setting)。
注意:putenv 直接使用传入字符串的存储空间,不能在它仍被环境使用时释放或使其失效。
execle 要先用空指针结束启动参数列表,再传 envp ;execve 则直接接收 argv 和 envp 两个数组。
提要中"只影响被替换的子进程"有一个前提:这些操作是在子进程中进行的 。函数本身并不限定只能由子进程调用;如果父进程自己调用 exec,被替换的就是父进程。
完整示例:六种函数执行同一个程序
为了直接比较差异,下面用一个选择编号的程序演示六种调用。保存为 replace.c:
cpp
#include <unistd.h> // 声明六种 exec 函数
#include <stdio.h> // 声明 perror 和 fprintf
int main(int argc, char *argv[]) {
if (argc != 2 || argv[1][1] != '\0') { // 要求传入一个单字符编号
fprintf(stderr, "Usage: %s 1-6\n", argv[0]); // 输出使用方式
return 1; // 参数不合要求时退出
}
char name[] = "ls"; // 新程序的 argv[0]
char option1[] = "-l"; // 新程序的第一个选项
char option2[] = "-a"; // 新程序的第二个选项
char *args[] = {name, option1, option2, NULL}; // 参数数组必须以空指针结束
char setting[] = "COURSE=process"; // 自定义环境变量
char *envp[] = {setting, NULL}; // 环境数组也必须以空指针结束
switch (argv[1][0]) { // 按编号选择一种替换方式
case '1':
execl("/usr/bin/ls", "ls", "-l", "-a", (char *)NULL); // 路径加参数列表
break;
case '2':
execlp("ls", "ls", "-l", "-a", (char *)NULL); // 搜索 PATH,加参数列表
break;
case '3':
execle("/usr/bin/ls", "ls", "-l", "-a", (char *)NULL, envp); // 列表加自定义环境
break;
case '4':
execv("/usr/bin/ls", args); // 路径加参数数组
break;
case '5':
execvp("ls", args); // 搜索 PATH,加参数数组
break;
case '6':
execve("/usr/bin/ls", args, envp); // 路径、参数数组和自定义环境
break;
default:
fprintf(stderr, "Choose 1-6\n"); // 拒绝不支持的编号
return 1;
}
perror("exec"); // 只有替换失败,才会执行到这里
return 1; // 向外报告失败
}
bash
gcc replace.c -o replace # 编译示例
./replace 1 # 使用 execl;可把编号改为 2 到 6 选择其他函数
注意:示例假定系统上存在 /usr/bin/ls;带 e 的两个分支只给新程序传入 COURSE,并未保留原来的其他环境变量。
为什么只有 execve 是系统调用?
六种接口只是方便我们用不同方式提供信息,真正更换进程程序内容,需要内核完成。其他五个函数先整理参数、处理路径搜索等事情,最后交给 execve。
这里的封装就是替调用者先做好准备工作,再调用底层接口。
| 接口 | 身份 | man 手册位置 |
|---|---|---|
execl、execlp、execle、execv、execvp |
对 execve 的库函数封装 |
第 3 节 |
execve |
系统调用接口 | 第 2 节 |
关系可以写成:execl / execlp / execle / execv / execvp → 库函数整理参数或搜索路径 → execve → 内核完成替换。
这六种接口中,只有
execve对应真正的系统调用,其余五个最终都通过它完成替换。
为什么子进程替换不会影响父进程?
父子进程具有独立的地址空间,所以子进程调用 exec 时,只更换自己的程序内容和地址映射,父进程继续执行原程序。
这与已学的写时拷贝相衔接:初始共享并不破坏独立性;但 exec 并不是先把父进程的整个程序复制一份,再覆盖它,而是为调用进程建立新程序所需的地址空间内容。
能不能替换成自己写的程序?
exec 关心的是目标能否执行,并不关心它是系统自带还是自己编译的。因此,可以直接替换成 ./other。
下面观察同一个进程在替换前后是否保持 PID。先保存 other.c:
cpp
#include <stdio.h> // 声明 printf
#include <unistd.h> // 声明 getpid
int main(void) {
printf("other: PID=%ld\n", (long)getpid()); // 输出新程序运行时的 PID
return 0; // 结束新程序
}
再保存 launcher.c:
cpp
#include <stdio.h> // 声明 printf、fflush 和 perror
#include <unistd.h> // 声明 getpid 和 execl
int main(void) {
printf("launcher: PID=%ld\n", (long)getpid()); // 输出替换前的 PID
fflush(stdout); // 在旧程序被替换前送出输出内容
execl("./other", "other", (char *)NULL); // 用自己的可执行程序替换当前程序
perror("execl"); // 只有失败时才有机会报告错误
return 1; // 替换失败时退出
}
bash
gcc other.c -o other # 生成目标程序
gcc launcher.c -o launcher # 生成发起替换的程序
./launcher # 观察两行输出中的 PID 相同
注意:./other 相对于进程的当前工作目录寻找目标,不是相对于 launcher 文件所在目录。这里没有调用 fork,所以被替换的就是运行 launcher 的进程。
5. 自主 Shell 命令行解释器
5-1 目标
现成的 shell 已经能执行命令,但要自己写一个,就必须回答两个问题:用户输入的文字怎样变成程序参数?哪些操作应该交给其他程序,哪些必须留在 shell 内完成?
本节实现的是一个简化版命令行解释器:读取一行文字,判断要做什么,再安排执行。
| 目标 | 在自主 shell 中的落实方式 |
|---|---|
| 处理普通命令 | 用 fork、execvp、waitpid 组织执行 |
| 处理内建命令 | 在 shell 内部调用对应函数 |
| 联系变量概念 | 让环境变量表真正参与后续程序的执行 |
| 理解运行结构 | 把提示、输入、解析、执行连接成循环 |
本节只处理简单的、按空格分开的命令,不实现引号、管道、重定向等复杂输入。也不实现完整的本地变量赋值系统,变量部分聚焦于环境变量和 echo 的读取。
5-2 实现原理
1. 自主 shell 的循环过程
用户执行完一条命令后,还希望继续输入,而不是整个解释器随之结束。因此,shell 的主体必须是一个循环;每轮处理一行输入。
第一步:打印命令行提示符。 提示符就是输入命令前显示的那段文字,让用户知道"现在可以输入了"。本节使用 [用户名@主机名 当前目录]# 这样的格式。
第二步:获取用户命令。 用 fgets 把输入保存进字符数组。它可能保留末尾的换行符,因此解析前应先去掉换行;输入太长时,一次调用不一定能读完一整行。
第三步:解析命令行。 用户输入的是一整段文字,程序执行接口需要的却是"命令名、参数一、参数二......"这样的参数表。解析就是把前者整理成后者,本节用 strtok 按空格拆分,结果放入 gargv。
第四步:检查内建命令。 内建命令就是直接由 shell 自己的代码实现的命令。匹配成功后,调用对应函数,然后进入下一轮,不再走普通命令分支。
第五步:执行普通命令。 这里的普通命令指要启动外部程序的命令。沿用已学过的 fork 创建子进程,再由子进程调用 execvp 装入目标程序;shell 用 waitpid 等待这次执行结束,记录结果后继续读取输入。
execvp 的作用是让当前进程改为运行指定程序,命令名不含 / 时会按 PATH 查找。waitpid 的作用是等待指定子进程结束,并取得它的结束情况。
每轮循环先判断内建命令;只有不是内建命令时,才进入普通命令的执行分支。
2. 内建命令 vs 普通命令
为什么不能把所有命令都丢给子进程?
考虑 cd:用户想改变的是接下来继续接收命令的那个 shell 所在的目录。如果只让子进程改变目录,子进程结束后,shell 自己的位置没有改变,后续命令仍从原目录开始执行。
| 比较项 | 普通命令 | 内建命令 |
|---|---|---|
| 本节由谁执行 | 子进程中的外部程序 | shell 自己的函数 |
| 是否进入创建子进程的分支 | 是 | 否 |
| 典型命令 | ls、ps、cat |
cd、export、env、echo |
| 为什么这样安排 | 运行外部程序,同时保留 shell 接收下一条命令 | 直接完成操作,必要时修改 shell 自己的状态 |
这里需要修正提要中的一个绝对说法:并不是所有内建命令都"不能在子进程执行"。 cd、export 要影响当前 shell,才必须在当前 shell 中执行;env、echo 只是显示内容,也可以由外部程序完成。本节把它们统一做成内建命令,是为了简化实现。
同样,"普通命令必须由子进程执行"指的是本节这种还要继续交互的 shell 设计,不是所有程序都必须遵守的限制。
本节内建命令的本质:shell 自己调用函数或系统调用完成操作,不为它另外创建子进程。
5-3 源码关键结构
1. 全局数据:一轮命令需要哪些信息
命令读取、解析、执行分别由不同函数完成,它们需要共享本轮参数、当前环境以及上次执行结果。因此,课件把这些信息放在全局位置。
| 定义 | 保存什么 | 关键约束 |
|---|---|---|
char *gargv[64] |
本轮命令名及参数的地址 | 最后一项必须是 NULL |
int gargc = 0 |
本轮有效参数数量,包含命令名 | 每次重新解析前清零 |
char *genv[64] |
环境变量字符串的地址 | 也必须以 NULL 结尾 |
char pwd[1024] |
当前工作路径 | 获取路径时检查空间是否足够 |
char pwdenv[1024] |
形如 PWD=路径 的字符串 |
比单独的路径多出 PWD= 的长度 |
int lastcode = 0 |
上一条命令的退出码 | 供 echo $? 使用 |
两个容易混淆的名字:
pwd是本程序中的字符数组,不是在这里执行pwd命令。pwdenv是准备交给环境管理函数的字符串,不会因为变量名含有env就自动进入环境。
gargv[64] 最多放 63 个有效指针 ,另外一个位置留给 NULL;genv[64] 也一样。
2. 提示符生成与命令解析
提示符包含用户名、主机名和目录,不能把它们写死。因此使用 snprintf:它按格式拼接文字,同时限制写入大小,避免超出数组边界。
语法: snprintf(command_line, size, "[%s@%s %s]# ", 用户名, 主机名, 当前目录名)。
读取后,则使用 strtok 拆分文字:
| 调用形式 | 功能 |
|---|---|
strtok(command_buffer, " ") |
开始拆分这一个字符串 |
strtok(NULL, " ") |
继续获取同一字符串中的下一项 |
返回 NULL |
已经没有下一项 |
下面是一个可独立编译运行的 C 程序,只展示提示、读取、解析 这部分;保存为 parse_line.c。
c
#include <stdio.h> // 使用输入输出和格式化函数
#include <string.h> // 使用字符串拆分和查找函数
char *gargv[64]; // 保存本轮命令名和参数
int gargc = 0; // 记录有效项数量
int main(void) {
char command_line[128]; // 保存提示符
char command_buffer[1024]; // 保存用户输入
snprintf(command_line, sizeof command_line,
"[%s@%s %s]# ", "student", "host", "demo"); // 用示意信息生成提示符
fputs(command_line, stdout); // 输出提示符
fflush(stdout); // 提示符没有换行,主动让它显示出来
if (fgets(command_buffer, sizeof command_buffer, stdin) == NULL) {
return 0; // 没有读到输入时结束
}
if (strchr(command_buffer, '\n') == NULL && !feof(stdin)) {
fputs("输入过长,本次不解析\n", stderr); // 不执行可能被截断的命令
return 1; // 结束这个单次解析示例
}
command_buffer[strcspn(command_buffer, "\n")] = '\0'; // 去掉末尾换行
gargc = 0; // 从空参数表开始
char *token = strtok(command_buffer, " "); // 获取第一项
while (token != NULL) {
if (gargc == 63) {
fputs("参数过多\n", stderr); // 保留最后一个位置给 NULL
return 1; // 拒绝超出容量的输入
}
gargv[gargc++] = token; // 保存当前项的地址
token = strtok(NULL, " "); // 继续获取下一项
}
gargv[gargc] = NULL; // 标记参数表结束
for (int i = 0; i < gargc; ++i) {
printf("gargv[%d] = <%s>\n", i, gargv[i]); // 显示解析结果
}
return 0; // 单次演示结束
}
注意:strtok 会修改原始输入数组,gargv 指向的仍是该数组中的内容。它也不理解引号;只使用 " " 作分隔符时,制表符不会被当作分隔符。
实际循环中,空行或全空格输入会得到 gargc == 0,应直接读取下一行,不访问 gargv[0]。
3. 普通命令的执行位置与结果保存
参数表构建好以后,不能直接在 shell 自己身上调用 execvp:成功后,原来的 shell 程序就被替换,无法再执行自己的输入循环。
因此,普通命令分支按以下位置安排:
| 位置 | 调用或操作 | 本节作用 |
|---|---|---|
| 子进程 | execvp(gargv[0], gargv) |
用解析得到的命令名和参数运行程序 |
| 父进程 | waitpid(id, &status, 0) |
等待本次命令结束 |
| 等待成功后 | 检查 WIFEXITED(status) |
判断是否正常退出 |
| 正常退出时 | lastcode = WEXITSTATUS(status) |
保存退出码 |
status 是"结束情况的打包记录",不只是退出码。WIFEXITED 用来判断能否按正常退出解读,WEXITSTATUS 才负责取出其中的退出码。
如果 execvp 返回,说明执行失败;子进程应报错并结束,不能继续落入 shell 的读命令循环。若子进程被信号终止,也不能直接套用 WEXITSTATUS。
4. 环境变量初始化
程序启动时已经有继承来的环境,但课件希望通过自己的 genv 管理它。因此,先从 extern char **environ 逐项复制,最后补上 NULL。
这里最容易出现的问题是:打印用的是 genv,执行外部程序时用的却仍是另一张环境表。 这样 env 看起来已经出现新变量,后续程序却拿不到它。
| 数据 | 在本程序中的角色 |
|---|---|
genv |
课件自己维护的环境变量表 |
environ |
当前进程交给环境相关库函数和后续执行使用的环境表入口 |
因此,课件式实现可以在完成初始化后让 environ = genv,把它们接到同一份数据上;但后续维护必须保持一致。
仅把字符串放进
genv不等于设置了实际环境;必须让后续程序使用的环境表同步改变。
有三个实现边界:
- 容量: 继承来的环境变量可能超过 63 项,不能假定
genv[64]一定够用。 - 字符串寿命: 只复制地址时,没有复制字符串本身;新增变量尤其不能直接引用下一轮会被覆盖的输入数组。
- 混用接口:
putenv等函数可能调整environ指向的表,不能一直假定它仍然等于原来的genv。
这些约束决定了 AddEnv 不能只是简单地执行一次数组赋值。
5. 内建命令实现
cd:先改变真实目录,再更新记录
语法是 cd 目录,核心调用是 chdir(gargv[1])。chdir 是改变调用进程当前工作目录的系统调用。
要让提示符和环境信息跟上变化,处理顺序应是:
- 检查是否提供了目录参数。
- 调用
chdir,失败则报错,不修改路径记录。 - 成功后用
getcwd获取实际路径;getcwd就是读取当前进程所在目录的函数。 - 更新
pwd,再拼出PWD=实际路径,更新环境中的PWD。
真实工作目录与 PWD 字符串不是同一件事: 修改 PWD 只是改记录,不会代替 chdir。
课件中的 putenv(pwdenv) 用来把 NAME=value 形式的字符串接入环境。它会使用传入字符串本身,因此这里需要寿命足够长的存储;固定的全局数组符合这一点,但仍要检查拼接是否被截断。
export:新增或替换环境表中的一项
本节支持的简化语法是 export NAME=value,由 AddEnv(gargv[1]) 处理。
AddEnv 不是标准库函数,而是本项目自己写的辅助函数。它应当完成:
- 检查参数中是否有合法的变量名和
=。 - 找到同名项时替换,而不是不断追加重复项。
- 没有同名项时检查容量,再增加一项。
- 保存独立、有效的字符串,并维持表尾
NULL。 - 保证实际环境表与显示用的表一致。
这一步与已学过的继承规则相接:修改的是当前自主 shell 的环境,供它以后启动的程序使用。
env:显示正在使用的环境表
本节语法为不带参数的 env,功能是从 genv[0] 开始逐项打印,直到遇到 NULL。
它是否准确,取决于上一小节的数据一致性:如果 genv 与实际环境已经脱节,打印结果就不能代表后续程序真正得到的环境。
echo:按输入形式选择输出来源
为了观察变量与命令结果,本节让 echo 识别三类输入:
| 输入形式 | 本节处理方式 |
|---|---|
echo $? |
打印执行本次 echo 之前 的 lastcode |
echo $PATH |
去掉 $ 后,查询对应环境变量 |
echo 普通文字 |
原样输出文字,多个参数之间补空格 |
这里的 $? 是"上一条命令结果"的特殊写法,不是环境表里名为 ? 的变量。
实现时应先判断 $?,再处理其他以 $ 开头的参数;查询不到环境变量时不能把空指针直接交给 %s,可以按空字符串输出。
lastcode 也应覆盖内建命令的结果:本节约定成功记为 0,失败记为非零。对于 echo $?,要先打印旧值,再记录这次 echo 的结果。
TrimSpace:只跳过开头的空白
有些处理路径拿到的字符串前面会残留空格,例如 " file.txt"。如果直接使用,开头的空格可能被误当成名称的一部分,因此课件使用 TrimSpace 宏让指针跳过前导空白。
它与 strtok 的用途不同:
| 工具 | 解决的问题 |
|---|---|
TrimSpace |
找到字符串真正开始的位置 |
strtok |
把整段输入拆成多个参数 |
如果已经使用 strtok(command_buffer, " "),前导空格会被跳过,不必再为同一段输入重复处理。宏本质上是编译前展开的代码替换,用作参数的指针表达式应保持简单。
5-4 总结
课件用 call/return 类比进程间的启动与结果回收,是为了强调"发起任务,再取得结果"这个联系,不表示两者完全相同。
| 类比位置 | 函数调用 | 程序执行 |
|---|---|---|
| 发起工作 | 调用函数并传参 | 通过 fork、exec 启动程序并传参 |
| 给出结果 | return |
exit(n) |
| 取得结果 | 接收返回值 | 等待结束并解读结束状态 |
exec不是会返回原调用位置的普通函数调用;wait(&ret)得到的也不是直接可用的退出码,而是需要解读的结束状态。
下期预告
基础IO
结语
** 本文到此结束,感谢大家的阅读!如果觉得本文对你有所帮助,欢迎点赞、收藏、关注,也欢迎在评论区一起交流讨论。**
** 也欢迎订阅我的**
深入理解 C++系列:从语法入门到底层原理,系统掌握现代 C++
算法系列:从入门到精通,蓝桥杯、ACM、LeetCode 与面试算法全路线
快速复习系列:知识梳理、查漏补缺,考前冲刺必备
Linux系列:从零带你系统学习Linux系统,深入理解计算机组成原理!
** 愿每一次敲下键盘,都比昨天更进一步!** ** 愿每一行代码落下,都让未来多一种可能!**