Linux系列(08)——进程控制

⭐️博主: 此生决int-@CSDN博客
** 速胜派就是最大的投降派!!!**

**         🔥热门专栏🔥**

      深入理解 C++ 系列 | 算法系列

      快速复习系列 | Linux系列

Gitee地址:财哥 | GitHub地址:财哥


文章目录

  • [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 为什么会失败)
    • [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. 进程程序替换)
    • [5. 自主 Shell 命令行解释器](#5. 自主 Shell 命令行解释器)
    • 下期预告
    • 基础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 内部:先建立管理对象,再让子进程运行

只让父进程返回一个编号,系统并不会凭空多出一个可运行的进程。因为系统还需要单独记录和管理子进程,所以必须先为它准备内核中的管理对象。

可以把内部过程理解为以下顺序:

  1. 分配必要的内存和内核数据结构。为子进程准备独立的 PCB 等管理对象;这里的"分配新内存块"不表示立即复制父进程的全部代码和数据。
  2. 复制父进程的部分信息,并调整子进程专属信息。复制能够继承的内容,同时设置新的 PID 等,不能把父进程的管理记录原封不动照搬。
  3. 将子进程加入系统的管理范围,并使其可以被调度。不仅要让系统"查得到它",还要让它具备获得 CPU 的条件。
  4. 父子分别继续执行 。这里沿用已学过的 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);   // 接收终止状态;调用后不会返回

功能: 报告终止状态,并按顺序完成以下工作:

  1. 执行通过 atexit 或 on_exit 登记的清理函数,即程序提前约好的"退出前要做的事"。其中 on_exit 不是标准 C 接口。
  2. 刷新输出流的缓冲数据,再关闭打开的流;流 就是 C 库管理输入输出时使用的对象,如 stdout。
  3. 进入 _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 是改变调用进程当前工作目录的系统调用。

要让提示符和环境信息跟上变化,处理顺序应是:

  1. 检查是否提供了目录参数。
  2. 调用 chdir,失败则报错,不修改路径记录。
  3. 成功后用 getcwd 获取实际路径;getcwd 就是读取当前进程所在目录的函数。
  4. 更新 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系统,深入理解计算机组成原理!


**  愿每一次敲下键盘,都比昨天更进一步!** **  愿每一行代码落下,都让未来多一种可能!**

相关推荐
BioTender27 分钟前
Xaira 发布 X-Automate:将物理 AI 与智能体 AI 引入实验室自动化
运维·人工智能·自动化
Percep_gan1 小时前
Linux中安装gitea,很详细
linux·elasticsearch·gitea
Z21-X2 小时前
云原生运维学习笔记:容器技术的演进、底层原理与 Docker 全生命周期管理
运维·笔记·学习·云原生
Rabitebla10 小时前
【Linux 底层原理】进程(十):进程等待详解---从僵尸进程到 wait、waitpid 与 status
linux·c++·笔记·学习·visual studio
Decay__10 小时前
18_MySQL 智能巡检方案 —— 巡检项清单 / 一键巡检脚本 / 健康评分
运维·数据库·sql·mysql·架构
天远API14 小时前
零信任架构实战:基于天远运营商三要素详版V即时版构建自动化消费电子大促电商平台高价值权益秒杀与优惠券防羊毛高并发实名核验网关
运维·人工智能·架构·自动化
Android系统攻城狮15 小时前
Linux FFmpeg深度解析之avcodec_default_get_encode_buffer调用流程与实战(二十八)
linux·运维·服务器·ffmpeg·车载音视频·ffmpeg音视频
程序员初一15 小时前
8080 打不开的四层定位:监听地址、ufw、DOCKER 链、安全组
linux·ssh
程序开发阿元15 小时前
swappiness 60 和 10 差在哪:一台 1C1G 的三轮对比
linux·ssh