Linux 进程控制 --- 从 fork 到手写 Shell
前言
在 Linux 的世界里,进程是一切的核心。我们启动一个程序、执行一条命令、运行一个脚本,背后都是进程在运作。但进程不是凭空出现的 --- 它由一个已存在的进程创建,执行特定的代码,最终退出并被回收。这四个环节 --- 创建、执行、等待、终止 --- 构成了 Linux 进程控制的完整闭环。
实际上,理解了这四个环节,你就理解了操作系统是如何管理用户程序的。更深一层说,Linux 将函数调用的思想 --- 调用、传参、执行、返回 --- 扩展到了进程之间:fork+exec 类比 call,exit 类比 return,wait 类比获取返回值。这种设计哲学贯穿整个 Linux 系统编程,我们在文章最后还会回到这个视角。
这篇文章会从 fork 的底层原理讲起,一路深入到写时拷贝、僵尸进程、exec 函数族,最后带你手写一个微型 Shell。读完你会发现,原来 Shell 的工作原理如此简单。
1. 进程创建:fork 深度解析
1.1 fork 函数初识
在 Linux 中,fork 是创建新进程的唯一方式。它从当前进程"分叉"出一个子进程,子进程几乎是父进程的完整副本。
c
#include <unistd.h>
pid_t fork(void);
// 返回值:子进程中返回 0,父进程返回子进程 pid,出错返回 -1
看到这个函数签名,你可能会疑惑:一个函数为什么会有两个返回值? 而且为什么子进程返回 0,父进程返回子进程的 pid?
实际上,当我们调用 fork() 时,控制权转移到内核的 fork 代码中。内核做的事情大致如下:
- 为子进程分配新的
task_struct(PCB)和内核栈 - 将父进程的 PCB 部分内容拷贝到子进程(包括文件描述符表、信号处理方式等)
- 为子进程构建页表,让它"看起来"拥有和父进程一样的地址空间(这里先记住是"看起来",后面 COW 会详细讲)
- 将子进程加入系统进程列表,纳入调度器的管理范围
- fork 返回,开始调度 --- 此时父子进程都从 fork 的返回点继续执行
关键就在第 5 步:内核在 fork 返回时,分别给父子进程设置了不同的返回值。对于父进程,fork 返回子进程的 pid(一个大于 0 的整数);对于子进程,fork 返回 0。于是,父子进程虽然共享同一份代码,但通过 if-else 判断返回值就能走向不同的分支。
说白了,fork 在"返回"这个动作上做了手脚 --- 它让同一个函数调用在两个进程中产生了不同的返回值。这其实也是为什么我们写代码时 if (pid == 0) 和 else 分支分别对应子进程和父进程。
那为什么子进程返回 0,而不是别的数字?实际上,一个进程可以有多个子进程,但子进程只有一个父进程。子进程返回 0 意味着"我是子进程",而父进程拿到的是子进程的具体 pid,方便后续用 waitpid 精确管理某个子进程。如果子进程也返回自己的 pid,那它还得再调一次 getpid() 才知道自己是谁,不够优雅。
来看一个简单的例子:
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
pid_t pid;
printf("Before: pid is %d\n", getpid());
if ((pid = fork()) == -1) {
perror("fork()");
exit(1);
}
printf("After: pid is %d, fork return %d\n", getpid(), pid);
sleep(1);
return 0;
}
运行结果:
[tys@localhost linux]$ ./a.out
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0
这里输出了三行 --- 一行 Before,两行 After。进程 43676(父进程)先打印了 Before,然后打印了 After,fork 返回了 43677(子进程的 pid)。进程 43677(子进程)只打印了 After,fork 返回了 0。子进程没有打印 Before,因为子进程从 fork 返回处开始执行,不会重新执行 fork 之前的代码。

fork 之后谁先执行?完全由调度器决定,没有任何保证。所以如果你的代码依赖父子进程的执行顺序,那是不可靠的。
1.2 写时拷贝(Copy-On-Write)
前面说 fork 后子进程"看起来"拥有和父进程一样的地址空间,为什么要加"看起来"?
际上,现代 Linux 在 fork 时并不会真的把父进程的整个物理内存拷贝一份给子进程。那样做太慢了,也太浪费内存 --- 想想看,如果子进程 fork 后立刻 exec 加载新程序,那辛苦拷贝的几百 MB 数据岂不是全部白费?
于是有了 写时拷贝(Copy-On-Write, COW) 这个技术。fork 刚完成时,父子进程的页表指向同一块物理内存 ,但页表项被标记为只读。当任意一方试图写入时,CPU 触发缺页中断,内核这时才真正复制物理页,并修改页表让双方各自拥有独立的可写页面(其实就是运用修改时的报错,当做写实拷贝的触发条件)。
| 阶段 | 页表映射 | 物理内存 | 页表项权限 |
|---|---|---|---|
| fork 刚完成 | 父子指向同一物理页 | 共享一份 | 只读(R/O) |
| 任一进程写入前 | 共享映射 | 共享 | 只读 |
| 写入触发缺页中断 | 内核复制物理页,各自映射 | 分离为两份 | 读写(R/W) |
COW 有两个核心价值:
- 加速 fork 执行:fork 时只需复制父进程的页表(而非整个物理内存),数据结构小、速度快
- 节省内存:父子共享的只读页面(如代码段)永远不需要复制;即使对于数据段,也只有真正被写入的页面才会触发复制
这其实也是为什么 fork 后父子进程得以彻底分离 --- COW 在必要时才完成物理内存的分离,是一种延迟申请技术,可以提高整机内存的使用率。有了 COW 的存在,父子进程的独立性才真正有了技术保证。
1.3 fork 的常规用法
fork 主要有两种典型使用场景:
场景一:父子执行不同代码分支。 父进程希望复制自己,然后父子各自执行不同的代码段。典型的例子是网络服务器 --- 父进程负责监听客户端请求,收到请求后 fork 一个子进程来处理请求,父进程继续监听。这种模式下,父子进程共享代码段但通过返回值走不同的分支。
场景二:子进程执行另一个程序。 子进程从 fork 返回后,立刻调用 exec 函数加载新程序。这是 Shell 执行命令的方式 --- 我们后面会详细讲。这种模式下,COW 的价值体现得最明显:fork 时几乎不复制数据,子进程 exec 时直接替换整个地址空间,之前共享的页面不需要复制。
fork 也有可能调用失败,主要原因是:
- 系统中进程数量太多,达到了上限
- 当前用户的实际进程数超过了限制
1.4 进程地址空间回顾
由之前的学习我们可以知道,每个进程都有自己的虚拟地址空间。典型布局如下:

fork 时,子进程会获得这份地址空间的"副本" --- 在 COW 的加持下,实际上只是页表级别的复制,物理内存仍然共享,直到某一方写入了数据。
2. 进程与终止
2.1 进程退出方式
进程退出的本质是释放系统资源 --- 释放进程申请的内核数据结构、代码和数据。
退出方式分成以下三种:
1.代码运行完毕,结果正确
2.代码运行完毕,结果不正确
3.异常退出
一般情况下,我们可以通过echo $? 用来查看最近的一个进程退出时的退出码
正常终止
正常终止可以通过 echo $? 查看退出码。有三种方式:
1. 从 main 函数 return
c
int main()
{
return 0; // 等同于 exit(0)
}
执行 return n 等同于执行 exit(n),因为调用 main 的运行时函数会将 main 的返回值当作 exit 的参数。
2. 调用 exit 函数
c
#include <unistd.h>
void exit(int status);
exit:括号中可以填入自己设定的错误码,调用以后会直接退出当前进程,相当于 main 函数中的 return 0
exit 最后也会调用 _exit,但在那之前还做了额外工作:
- 执行用户通过
atexit或on_exit注册的清理函数 - 关闭所有打开的流,将所有缓冲数据写入
- 调用
_exit
3. 调用 _exit 函数
c
#include <unistd.h>
void _exit(int status);
// 参数:status 定义了进程的终止状态,但仅有低 8 位可以被父进程获取
// 所以 _exit(-1) 时,$? 显示的是 255
_exit 直接进入内核,不做任何用户态清理。来看看 exit 和 _exit 的关键区别:
c
// 使用 exit --- 缓冲区会被刷新
int main()
{
printf("hello");
exit(0);
}
// 运行结果:
// [tys@localhost linux]$ ./a.out
// hello[tys@localhost linux]$
// 使用 _exit --- 缓冲区不会被刷新
int main()
{
printf("hello");
_exit(0);
}
// 运行结果:
// [tys@localhost linux]$ ./a.out
// [tys@localhost linux]$
第一段代码,printf("hello") 没有换行符,数据留在了 stdio 缓冲区。exit(0) 会关闭所有流,缓冲区被刷新,所以 "hello" 被打印了。第二段代码,_exit(0) 直接进入内核,缓冲区数据直接丢弃,所以什么都没输出。
这个实验告诉我们:exit 退出的时候会刷新缓冲区,_exit 不会。一般情况下用 exit,只有在 fork 后的子进程中,如果 exec 失败了,才会用 _exit 避免重复清理。
实际上exit属于是C语言提供的接口,而_exit是系统提供的接口,exit 的底层实际上就是_exit关系如下图所示:

所以所谓的缓冲区是由 C 语言提供的缓冲区,而不是操作系统提供的
异常终止
异常终止通常由信号触发,比如 ctrl + c(SIGINT)、kill -9(SIGKILL)、段错误(SIGSEGV),野指针等。
要注意异常终止的时候,退出码是没有意义的
退出码机制
退出码可以告诉我们最后一次执行的命令的状态。基本规则是:程序返回 0 表示执行成功,返回非 0 表示失败,不同的退出码表明了不同的错误原因,当进程结束的时候,退出码会写入到task_struct中,以方便父进程查看子进程退出时的错误信息。当进程因为错误信息退出的时候,操作系统会把进程中对应的errno设置成相应的号码,于是我们便可以使用strerror()函数查看,具体每一个错误码对应什么含义?。
Linux Shell 中的主要退出码:
| 退出码 | 含义 |
|---|---|
| 0 | 命令执行成功 |
| 1 | 一般性错误,"不被允许的操作" |
| 2 | 命令使用错误(如参数错误) |
| 130 | 被 SIGINT(ctrl+c)终止,130 = 128 + 2 |
| 143 | 被 SIGTERM 终止,143 = 128 + 15 |
被信号终止时,退出码 = 128 + 信号编号。可以用 strerror 函数根据退出码获取描述信息。
3. 进程等待:wait 与 waitpid
3.1 为什么需要进程等待
由之前的僵尸进程分析我们可以知道,子进程退出后如果父进程不管不顾,就会造成僵尸进程,进而造成内存泄漏。僵尸进程占着 PCB 槽位不释放,而且一旦变成僵尸状态,就刀枪不入 --- kill -9 也无能为力,因为谁也杀不死一个已经死去的进程。
另外,父进程派给子进程的任务完成得怎么样,也需要知道。子进程是正常退出还是异常退出?退出码是什么?这些信息都需要父进程通过进程等待的方式获取。
于是,父进程通过 wait/waitpid 来做三件事:
- 回收子进程资源(释放 PCB,这个是主要目的)
- 获取子进程退出信息(正常/异常、退出码)
- 保持一定程度的同步(子进程在 wait 之前退出,wait 立即返回;子进程还在运行,wait 就阻塞等待有子进程结束时就返回)
3.2 wait 方法
c
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
// 返回值:成功返回被等待子进程的 pid,失败返回 -1
// 参数:输出型参数,获取子进程退出状态,不关心可以设为 NULL
wait 是最简单的等待方式 --- 阻塞等待任意一个子进程退出。如果当前没有子进程,wait 立即返回 -1(出错),wait的作用是,当它检测到有任何一个子进程返回的时候,它就会返回这个进程的PID,。
3.3 waitpid 方法
waitpid 提供了更精细的控制:
c
pid_t waitpid(pid_t pid, int *status, int options);
// 返回值:
// 正常返回时,返回收集到的子进程 pid
// 设置了 WNOHANG 且没有子进程退出时,返回 0
// 出错返回 -1
// 参数 pid:
// pid = -1:等待任意子进程,与 wait 等效
// pid > 0:等待指定 pid 的子进程
// 参数 options:
// 0:阻塞等待(默认)
// WNOHANG:非阻塞等待
3.4 status 的位图解析
进程的退出码只有在进程代码执行完正常退出时才有意义,当代码遇到异常退出时,进程的退出码是没有任何意义的。
wait 和 waitpid 的 status 参数是一个输出型参数,由操作系统填充。但status 不能简单当成一个整数来看,它其实是一个位图,只研究低 16 位:
- 低 8 位(bit 0-7):终止信号编号。正常退出时为 0,被信号杀死时为信号编号
- 高 8 位(bit 8-15):退出码。只有正常退出时有效
这也就是为什么 _exit(-1) 时 $? 显示 255 --- 因为 -1 的低 8 位就是 255。
系统提供了几个宏来方便地解析 status:
| 宏 | 作用 |
|---|---|
WIFEXITED(status) |
若子进程正常终止,返回真 |
WEXITSTATUS(status) |
若 WIFEXITED 为真,提取退出码(高 8 位) |
WIFSIGNALED(status) |
若子进程被信号终止,返回真 |
WTERMSIG(status) |
若 WIFSIGNALED 为真,提取信号编号 |
来看一个完整的测试代码:
c
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
pid_t pid;
if ((pid = fork()) == -1) {
perror("fork");
exit(1);
}
if (pid == 0) {
// 子进程
sleep(20);
exit(10);
} else {
// 父进程
int st;
int ret = wait(&st);
// 正常退出:低 7 位为 0
if (ret > 0 && (st & 0x7F) == 0) {
printf("child exit code: %d\n", (st >> 8) & 0xFF);
} else if (ret > 0) {
// 异常退出:低 7 位是信号编号
printf("sig code: %d\n", st & 0x7F);
}
}
return 0;
}
运行结果:
# 等 20 秒正常退出
[tys@localhost linux]$ ./a.out
child exit code: 10
# 在另一个终端 kill 掉子进程
[tys@localhost linux]$ ./a.out &
[tys@localhost linux]$ kill -9 <子进程pid>
sig code: 9
第一个结果,子进程正常退出,低 7 位为 0,高 8 位提取出退出码 10。第二个结果,子进程被 SIGKILL(信号 9)杀死,低 7 位是 9。

既然如此,父进程具体是怎么拿到子进程的exit_code,以及exit_signal的呢?

实际上是父亲成将输出型参数status交给由操作系统提供的接口 waitpid(),然后由于当进程结束的时候,它会将自己的退出码以及退出信号存在自己的 PCB 中,此时操作系统就可以将子进程 PCB 中的退出码以及退出信号和 status做相反的位运算以及或运算,此时成功的就把子进程的退出码和退出信号给到了父进程。
3.5 阻塞等待 vs 非阻塞等待
阻塞等待:父进程调用 waitpid 时,如果子进程还在运行,父进程就卡住不动,直到子进程退出。这是最简单的同步方式。
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
pid_t pid = fork();
if (pid < 0) {
printf("fork error\n");
return 1;
} else if (pid == 0) {
// 子进程
printf("child is run, pid is : %d\n", getpid());
sleep(5);
exit(257); // 退出码 257,低 8 位是 1
} else {
// 父进程
int status = 0;
pid_t ret = waitpid(-1, &status, 0); // 阻塞等待
printf("this is test for wait\n");
if (WIFEXITED(status) && ret == pid) {
printf("wait child 5s success, child return code is :%d.\n",
WEXITSTATUS(status));
} else {
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
运行结果:
[tys@localhost linux]$ ./a.out
child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.
父进程阻塞等待了 5 秒,子进程退出码虽然是 257,但 WEXITSTATUS 提取的是低 8 位,所以拿到的是 1。
非阻塞等待:父进程调用 waitpid 时,如果子进程还没退出,waitpid 立即返回 0,父进程可以继续做自己的事情,然后再次轮询。这种模式下,父进程在等待期间可以执行其他任务。
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
typedef void (*handler_t)();
std::vector<handler_t> handlers;
void fun_one() { printf("这是一个临时任务1\n"); }
void fun_two() { printf("这是一个临时任务2\n"); }
void Load() {
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
void handler() {
if (handlers.empty())
Load();
for (auto iter : handlers)
iter();
}
int main()
{
pid_t pid = fork();
if (pid < 0) {
printf("fork error\n");
return 1;
} else if (pid == 0) {
// 子进程
printf("child is run, pid is : %d\n", getpid());
sleep(5);
exit(1);
} else {
// 父进程
int status = 0;
pid_t ret = 0;
do {
ret = waitpid(-1, &status, WNOHANG); // 非阻塞轮询
if (ret == 0) {
printf("child is running\n");
}
handler(); // 等待期间执行其他任务
} while (ret == 0);
if (WIFEXITED(status) && ret == pid) {
printf("wait child 5s success, child return code is :%d.\n",
WEXITSTATUS(status));
} else {
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
这个例子在轮询等待子进程的同时,周期性地执行 handler() 中的临时任务。非阻塞等待的好处是父进程不会闲着,可以利用等待时间做有意义的事情。
4. 进程程序替换:exec 函数族
4.1 见识一下程序替换
运行如下代码:
c
#include<stdio.h>
2 #include<stdlib.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5
6 int main()
7 {
8 printf("程序开始!\n");
9 execl("/usr/bin/ls","ls","-al",NULL);
10 printf("程序结束!\n");
11 }
运行结果如下:

由图片中的结果可以看出来,当程序运行到 excel 函数的时候程序被替换了,接下来是执行ls指令的内容,这种exec*类函数的作用就是进行程序替换接下来我们详细讲述一下程序替换
4.2 替换原理
fork 之后,父子执行的是同一份代码的不同分支。但如果子进程想执行一个全新的程序呢?比如你在 Shell 里敲 ls,Shell 需要启动 ls 这个程序,而不是执行 Shell 自己的代码。
这时候就需要进程程序替换 --- 通过 exec 函数族,将磁盘上的一个全新程序(代码和数据)加载到当前进程的地址空间中,替换掉原来的内容。
exce类函数的工作原理就是当程序调用这个函数的时候,他就会把这个子进程的代码和数据替换掉成exec 函数中要执行的代码和数据如下图所示:

并且exec*类函数是没有返回值的,因为当程序调用这个函数之后,进程的代码和数据会被新程序完全替代,所以根本不会有返回值。如果遇到有返回值,那么就说明程序替换失败了。
关键点:exec 不创建新进程。调用 exec 前后,进程的 PID 不变,但地址空间里的代码和数据完全被新程序替换。exec 调用成功后,从新程序的启动例程开始执行,原来的代码不再执行,所以 exec 函数没有成功的返回值 --- 只有出错时返回 -1。
4.3 exec 函数族命名规则
exec 一共有六个变体,看起来容易混,但掌握了命名规律就很好记:
c
#include <unistd.h>
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 execve(const char *path, char *const argv[], char *const envp[]);
命名规律:
| 后缀 | 含义 | 说明 |
|---|---|---|
l (list) |
参数以列表形式传递 | execl("/bin/ls", "ls", "-l", NULL) |
v (vector) |
参数以数组形式传递 | execv("/bin/ls", argv) |
p (PATH) |
自动在 PATH 环境变量中搜索 | 可以写 "ls" 而不是 "/bin/ls" |
e (env) |
自定义环境变量 | 传递 envp[] 数组 |
六个函数之间的关系:只有 execve 是真正的系统调用,其他五个函数最终都调用 execve。所以 execve 在 man 手册第 2 节,其他在第 3 节。
来看一个完整的例子:
c
#include <unistd.h>
#include <stdlib.h>
int main()
{
char *const argv[] = {"ps", "-ef", NULL};
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
// 带 p 的,可以使用环境变量 PATH,无需写全路径
execlp("ps", "ps", "-ef", NULL);
// 不带 p 的,需要写全路径
execl("/bin/ps", "ps", "-ef", NULL);
// 带 e 的,需要自己组装环境变量
execle("ps", "ps", "-ef", NULL, envp);
// 参数用数组传递
execv("/bin/ps", argv);
// 带 p + 数组
execvp("ps", argv);
// 带 e + 数组,真正的系统调用
execve("/bin/ps", argv, envp);
exit(0);
}
我们以execvpe举个例子,详细讲解一下。
首先我们可以执行以下代码:
c
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
//char *newnew = (char*)"myVAL=66666666";
char *const addenv[] = {
(char *const)"MYVAL=123456789",
(char *const)"MYVAL1=123456789",
(char *const)"MYVAL2=123456789",
NULL
};
int main()
{
printf("我的程序要运行了!\n");
if(fork() == 0)
{
printf("I am Child, My Pid Is: %d\n", getpid());
sleep(1);
char *const argv[] = {
(char*const)"other",
(char*const)"-a",
(char*const)"-b",
(char*const)"-c",
(char*const)"-d",
NULL
};
for(int i = 0; addenv[i]; i++)
{
putenv(addenv[i]);
}
extern char **environ;
execvpe("./other", argv, environ);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
//return 0;
}
cpp
#include <iostream>
#include <cstdio>
#include <unistd.h>
int main(int argc, char *argv[], char *env[])
{
std::cout << "hello C++, My Pid Is: " << getpid() << std::endl;
for(int i = 0; i < argc; i++)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
printf("\n");
for(int i = 0; env[i]; i++)
{
printf("env[%d]: %s\n", i, env[i]);
}
return 0;
}
程序运行结果如下:

又如代码中所示,我们在程序替换的时候需要传入命令行参数、命令行参数表以及环境变量表,从这里我们终于知道了,Bash 是如何将命令行参数表和环境变量表传给子进程的,实际上就是父进程通过调用exec类函数,将命令行参数表和环境变量表传给对应的子进程。
以上代码中还有一个接口学要介绍一下名为putenv()起作用就是将新的环境变量导入到调用这个函数的进程中
4.4 fork + exec 经典模式
为什么 Shell 执行命令时一定是 fork 然后再 exec,而不是直接 exec?如果直接 exec,Shell 进程本身就会被 ls 替换掉,执行完 ls 退出了,Shell 也就没了。所以必须先 fork 一个子进程,让子进程去 exec,父进程(Shell)继续等待下一条命令。
这就是 Linux 进程控制中最经典的组合:fork 创建新进程,exec 赋予新程序,wait 回收资源。三者配合,构成了 Shell 执行命令的完整流程。
5. 实战:手写一个微型 Shell
5.1 Shell 的工作原理
考虑下面这个与 Shell 典型的互动:
[tys@localhost epoll]$ ls
client.cpp readme.md server.cpp utility.h
[tys@localhost epoll]$ ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps
Shell 的工作流程用时间轴来表示就是:Shell 从用户读入字符串 "ls",然后建立一个新的子进程,在子进程中运行 ls 程序,同时 Shell 自己等待那个进程结束。然后 Shell 读取新的一行输入,再建立新的子进程,运行 ps 程序,再等待它结束。
于是,写一个 Shell 就是循环执行以下过程:
- 获取命令行输入
- 解析命令行(分割为命令名和参数)
- 创建子进程(fork)
- 子进程替换程序(execvp)
- 父进程等待子进程退出(wait)
5.2 完整实现代码
下面是一个完整的微型 Shell 实现,支持外部命令执行、内建命令(cd、export、env、echo $?)、命令行提示符:
c
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
using namespace std;
const int basesize = 1024;
const int argvnum = 64;
const int envnum = 64;
// 全局的命令行参数表
char *gargv[argvnum];
int gargc = 0;
// 全局变量:记录上一条命令的退出码
int lastcode = 0;
// 我的系统的环境变量
char *genv[envnum];
// 全局的当前 shell 工作路径
char pwd[basesize];
char pwdenv[basesize];
// 跳过空格宏
#define TrimSpace(pos) do { \
while (isspace(*pos)) { \
pos++; \
} \
} while (0)
// 获取用户名
string GetUserName()
{
string name = getenv("USER");
return name.empty() ? "None" : name;
}
// 获取主机名
string GetHostName()
{
string hostname = getenv("HOSTNAME");
return hostname.empty() ? "None" : hostname;
}
// 获取当前工作目录
string GetPwd()
{
if (nullptr == getcwd(pwd, sizeof(pwd)))
return "None";
// 同步更新环境变量 PWD
snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
putenv(pwdenv);
return pwd;
}
// 获取当前路径的最后一级目录名
string LastDir()
{
string curr = GetPwd();
if (curr == "/" || curr == "None")
return curr;
// /home/tys/XXX → XXX
size_t pos = curr.rfind("/");
if (pos == std::string::npos)
return curr;
return curr.substr(pos + 1);
}
// 构造命令行提示符:[tys@localhost myshell]#
string MakeCommandLine()
{
char command_line[basesize];
snprintf(command_line, basesize, "[%s@%s %s]# ",
GetUserName().c_str(), GetHostName().c_str(), LastDir().c_str());
return command_line;
}
// 1. 打印命令行提示符
void PrintCommandLine()
{
printf("%s", MakeCommandLine().c_str());
fflush(stdout); // 确保提示符立即显示
}
// 2. 获取用户输入的命令行
bool GetCommandLine(char command_buffer[], int size)
{
// fgets 读取一行,包含换行符
char *result = fgets(command_buffer, size, stdin);
if (!result) {
return false;
}
// 去掉末尾的换行符
command_buffer[strlen(command_buffer) - 1] = 0;
if (strlen(command_buffer) == 0)
return false;
return true;
}
// 3. 解析命令行:用空格分割,存入 gargv 数组
void ParseCommandLine(char command_buffer[], int len)
{
(void)len;
memset(gargv, 0, sizeof(gargv));
gargc = 0;
// "ls -a -l -n" → gargv[0]="ls", gargv[1]="-a", ...
const char *sep = " ";
gargv[gargc++] = strtok(command_buffer, sep);
// 注意这里是 = 赋值,不是 == 比较
while ((bool)(gargv[gargc++] = strtok(nullptr, sep)));
gargc--; // 回退多出来的一个 NULL
}
// 4. 执行外部命令:fork 子进程,子进程 execvp,父进程 waitpid
bool ExecuteCommand()
{
pid_t id = fork();
if (id < 0)
return false;
if (id == 0) {
// 子进程:加载并执行新程序
execvpe(gargv[0], gargv, genv);
// execvp 成功不会返回,走到这里说明执行失败
exit(1);
}
// 父进程:等待子进程结束
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0) {
if (WIFEXITED(status)) {
lastcode = WEXITSTATUS(status); // 记录退出码,供 echo $? 使用
} else {
lastcode = 100; // 异常退出
}
return true;
}
return false;
}
// 添加环境变量到 genv 数组
void AddEnv(const char *item)
{
int index = 0;
while (genv[index]) {
index++;
}
genv[index] = (char *)malloc(strlen(item) + 1);
strncpy(genv[index], item, strlen(item) + 1);
genv[++index] = nullptr;
}
// 检查和执行内建命令:由 shell 自己执行,不 fork 子进程
bool CheckAndExecBuiltCommand()
{
// cd 命令:改变当前工作目录
if (strcmp(gargv[0], "cd") == 0) {
if (gargc == 2) {
chdir(gargv[1]);
lastcode = 0;
} else {
lastcode = 1;
}
return true;
}
// export 命令:添加环境变量
else if (strcmp(gargv[0], "export") == 0) {
if (gargc == 2) {
AddEnv(gargv[1]);
lastcode = 0;
} else {
lastcode = 2;
}
return true;
}
// env 命令:显示所有环境变量
else if (strcmp(gargv[0], "env") == 0) {
for (int i = 0; genv[i]; i++) {
printf("%s\n", genv[i]);
}
lastcode = 0;
return true;
}
// echo 命令:输出文本或 $?
else if (strcmp(gargv[0], "echo") == 0) {
if (gargc == 2) {
if (gargv[1][0] == '$') {
if (gargv[1][1] == '?') {
printf("%d\n", lastcode);
lastcode = 0;
}
} else {
printf("%s\n", gargv[1]);
lastcode = 0;
}
} else {
lastcode = 3;
}
return true;
}
return false; // 不是内建命令
}
// 初始化环境变量:从父 shell 继承
void InitEnv()
{
extern char **environ;
int index = 0;
while (environ[index]) {
genv[index] = (char *)malloc(strlen(environ[index]) + 1);
strncpy(genv[index], environ[index], strlen(environ[index]) + 1);
index++;
}
genv[index] = nullptr;
}
int main()
{
InitEnv();
char command_buffer[basesize];
while (true) {
// 1. 打印提示符
PrintCommandLine();
// 2. 获取用户输入
if (!GetCommandLine(command_buffer, basesize)) {
continue;
}
// 3. 解析命令行
ParseCommandLine(command_buffer, strlen(command_buffer));
// 4. 先检查是否是内建命令
if (CheckAndExecBuiltCommand()) {
continue;
}
// 5. 不是内建命令,fork + exec 执行外部命令
ExecuteCommand();
}
return 0;
}
5.3 代码走读
这个微型 Shell 虽然只有 200 多行,但完整实现了 Shell 的核心逻辑。来看看几个关键设计:
命令行提示符 :MakeCommandLine() 构造了 [tys@localhost myshell]# 这样的提示符,通过 GetUserName()、GetHostName()、LastDir() 动态获取用户名、主机名和当前目录。
命令行解析 :ParseCommandLine() 用 strtok 按空格分割命令行字符串,存入全局的 gargv 数组。注意 while 条件里的 = 是刻意写的赋值语句,不是比较 --- strtok 返回 NULL 时赋值结果转 bool 为 false,循环自然结束。
内建命令 vs 外部命令 :cd 之所以不能由子进程执行,是因为它要改变的是 Shell 自身的工作目录。如果 fork 子进程去 chdir,改变的只是子进程的目录,Shell 自己的目录不变。同样,export 添加环境变量、env 显示环境变量,都需要在 Shell 进程中直接操作。所以 CheckAndExecBuiltCommand() 在 fork 之前就拦截了这些命令。
环境变量继承 :InitEnv() 通过 extern char **environ 获取父进程(真实 Shell)的环境变量,拷贝到自己的 genv 数组中。子进程 execvp 时通过 genv 传递环境变量。
exit 码跟踪 :lastcode 记录了最后一条命令的退出码,echo $? 时输出。如果命令异常退出(被信号杀死),lastcode 设为 100 作为统一标记。
6. 延伸:exec/exit 与 call/return 的类比
在继续学习新知识之前,我们来思考一个有趣的类比 --- 函数和进程之间的相似性。
一个 C 程序由很多函数组成。一个函数调用另一个函数,传递参数,被调用的函数执行操作,然后返回一个值。每个函数有自己私有的局部变量,不同的函数通过 call/return 机制进行通信。这种通过参数和返回值在拥有私有数据的函数间通信的模式,是结构化程序设计的基石。
Linux 把这种思想扩展到了进程之间:
| 函数调用 | 进程控制 |
|---|---|
| call 调用函数 | fork + exec 创建并执行新程序 |
| 传参 | 通过 argv / envp 传递参数 |
| 函数执行 | 进程执行 |
| return 返回值 | exit(n) 退出并返回状态码 |
| 调用者获取返回值 | wait(&status) 获取退出码 |
一个 C 程序可以 fork/exec 另一个程序,传给它参数。这个被调用的程序执行操作,然后通过 exit(n) 返回值。调用它的进程通过 wait(&status) 获取 exit 的返回值。
这其实也是为什么 Linux 系统编程的学习路径如此自然 --- 本质上,你只是在用进程间通信的思维重演你早已熟悉的函数调用模式。
总结
回到文章开头提出的核心理念:Linux 进程控制的四个环节 --- 创建、执行、等待、终止 --- 构成了一个完整的闭环。
- fork 创建:唯一创建新进程的方式,通过写时拷贝实现高效的父子分离
- exec 替换:不创建新进程,而是赋予进程全新的代码和数据
- wait 回收:父进程获取子进程退出状态,防止僵尸进程,回收系统资源
- exit 退出:释放资源,返回退出码,通知父进程
这四个环节通过 fork+exec → wait → exit 的经典组合,完成了进程从生到死的完整生命周期。而写时拷贝作为贯穿始终的设计哲学,在 fork 阶段以"延迟复制"的方式保证了效率,在有 exec 的场景下价值尤为突出。
最后,手写 Shell 是检验进程控制知识的最佳实践。当你把 fork、exec、wait 组合成一个可交互的命令行解释器时,你对进程控制的理解会从"知道"变成"会用"。下一步,你可以在这基础上继续深入:信号处理、进程间通信(管道、共享内存、消息队列)、多线程编程。每个主题都会让你对 Linux 的理解更上一层楼。