🌈 个人主页: 小小、码农的 CSDN 博客
🔥 系列方向: linux系统
💪 学习宣言: 行动胜于空谈,实践出真知
在上一篇进程概念中,我们已经知道:程序加载到内存后成为进程,Bash执行外部命令时会创建子进程,子进程退出后如果一直没有被回收,就会变成僵尸进程。
这些现象背后有一条完整的控制链没有展开:进程怎样创建,怎样结束,父进程怎样拿到子进程的结果,一个进程又怎样运行另一个程序。
Linux用四组接口完成这件事:
text
fork:创建子进程
exec:替换进程正在执行的程序
exit/_exit:结束进程
wait/waitpid:等待并回收子进程
把这四部分连起来,正好就是一个命令行解释器最核心的工作流程。本文最后会用它们实现一个简易Shell,让前面的知识真正闭环。
1 一条命令是怎样被执行的
先看最普通的一条命令:
bash
ls -l
Bash不能直接把自己替换成ls。否则ls一结束,Bash也就不存在了,后面自然无法继续输入命令。
它通常会完成下面几步:
text
读取命令
│
├── 内建命令:由Shell自己执行
│
└── 外部命令
│
fork
│
┌────┴────┐
│ │
父进程 子进程
waitpid execvp(进程替换)
│ │
│ 运行ls
│ │
└────等待退出结果
│
打印下一次提示符
这里有两个关键点。
第一,fork只负责创建子进程,并不负责运行ls。
第二,exec只负责替换程序,并不会再创建一个新进程。真正运行ls的,仍然是刚才那个子进程。
下面沿着这条链逐步展开。
2 fork:创建子进程
2.1 一个接口,两个返回值
fork的函数原型如下:
c
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
调用成功以后,父子进程都会从fork返回的位置继续执行,但拿到的返回值不同:
text
返回值 < 0:创建失败,没有子进程
返回值 = 0:当前执行流属于子进程
返回值 > 0:当前执行流属于父进程,返回值是子进程PID
一个最小示例如下:
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("before fork: pid=%d\n", getpid());
fflush(stdout);
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
return 1;
}
if (pid == 0)
{
printf("child : pid=%d, ppid=%d, fork_ret=%d\n",
getpid(), getppid(), pid);
}
else
{
printf("parent: pid=%d, child_pid=%d, fork_ret=%d\n",
getpid(), pid, pid);
}
return 0;
}
编译运行:
bash
gcc -std=c11 -Wall -Wextra -Wpedantic fork_demo.c -o fork_demo
./fork_demo
这里的几个编译选项只负责规定语言模式和打开警告:
| 选项 | 作用 |
|---|---|
-std=c11 |
按C11标准解析C语言代码 |
-Wall |
打开一批最常见、最有价值的编译警告,并不是字面意义上的"全部警告" |
-Wextra |
在-Wall基础上再增加一些检查,例如未使用参数等 |
-Wpedantic |
对不符合当前C标准的语言扩展给出警告 |
-o fork_demo |
指定生成的可执行文件名 |
警告不等于错误。只要没有真正的编译错误,GCC通常仍会生成程序;如果希望出现警告就停止编译,还需要另外加入-Werror。
一次运行结果如下:
text
before fork: pid=485
parent: pid=485, child_pid=486, fork_ret=486
child : pid=486, ppid=485, fork_ret=0

before fork只出现一次,说明调用前只有父进程。后面父子分支各执行一次,说明调用完成后已经有了两个执行流。
父进程先打印还是子进程先打印并不确定。两个进程都要接受调度,程序不能依赖这里的输出顺序。
这里为什么要在fork前调用fflush(stdout)?
printf并不保证每次都立即调用write把内容交给内核。为了减少系统调用次数,C库会先把数据放进进程用户空间中的stdout缓冲区。fork创建子进程时,这份尚未输出的缓冲数据也会被复制。
如果父子进程后来都通过return或exit结束,它们会各自刷新自己手中的那份缓冲区,于是fork前的一段内容可能被输出两次。fflush(stdout)的作用,就是在创建子进程前先把旧数据交给内核,让父子进程继承到的都是空缓冲区。
直接输出到终端时,stdout通常采用行缓冲,遇到换行符就会刷新,所以这个问题不一定明显。重定向到普通文件后,stdout通常改为全缓冲,重复输出就很容易出现。需要强调的是,fflush清空的是C库缓冲区,并不会阻止子进程继承标准输出文件描述符。
下面的实测故意让before fork不带换行:未刷新时,11字节的内容被父子进程各输出一次,文件共22字节;在fork前刷新后,文件只有11字节。

2.2 父子进程不是共享所有变量
fork以后,父子进程拥有各自独立的虚拟地址空间。它们刚开始看到的数据相同,但任何一方修改普通变量,都不会直接改掉另一方的数据。
Linux通常不会在fork发生时立刻复制所有物理页,而是采用写时拷贝:
text
刚完成fork:父子进程的页表可以映射同一批只读物理页
发生写入时:内核再复制将要修改的页,让父子进程各写各的
因此,fork既保留了进程之间的独立性,也避免了无意义的整块内存复制。尤其当子进程马上调用exec时,大量原有数据根本不需要真的复制。

图中页表项除了保存物理页位置,还带有读写权限。fork完成后,父子进程原本可写的私有页面会暂时以只读方式映射,并继续指向同一物理页。
当CPU执行写指令时,发现写操作与页表中的只读权限不匹配,便产生页故障并进入内核。内核接着检查这段虚拟地址原本是否允许写,以及它是否属于写时拷贝页面:
text
逻辑上允许写 + 当前是COW共享页:复制物理页,修改当前进程页表,再继续执行写指令
逻辑上不允许写,或者地址本身非法:这是真正的错误,进程通常收到SIGSEGV
所以,"写只读页触发异常"只是入口,并不代表所有写保护异常都会执行写时拷贝。最终怎样处理,要由内核结合虚拟内存区域的权限和页面状态判断。
2.3 fork失败时发生了什么
fork返回负数时,系统中没有产生子进程,后面的父子分流也就不存在。
常见原因包括进程数量达到限制,或者内核无法分配创建进程所需的资源。此时errno会记录具体原因,应该使用perror输出,而不是继续把负数当作PID使用。
c
pid_t pid = fork();
if (pid == -1)
{
perror("fork");
return 1;
}
到这里,进程已经创建出来。下一个问题是:进程完成任务后,怎样把结果交还给父进程?
3 进程退出:把执行结果留给父进程
3.1 进程结束以后,父进程想知道什么
进程完成任务后不能一直留在系统中,但也不能悄无声息地消失。父进程至少要知道两个问题:子进程是怎样结束的,以及它交代的任务有没有完成。
先回答第一个问题。从操作系统观察,进程主要有两种终止方式:
text
正常终止:从main返回,或者调用exit、_exit等接口
信号终止:进程收到具有终止作用的信号
"正常终止"只说明进程主动走完了退出流程,不代表它完成了任务。程序执行失败后调用exit(1),仍属于正常终止;程序因为段错误收到SIGSEGV,才属于被信号终止。
只有确定进程正常终止以后,退出码才有意义。它回答的是第二个问题:程序最终把任务做得怎么样。
3.2 退出码与errno解决的是两个问题
正常终止的进程会留下一个退出码。按照Shell和大多数程序的约定,0表示任务成功,非0表示任务失败,具体数值由程序自己定义。
c
return 0; // 成功
return 1; // 失败,具体含义由程序定义
Shell使用$?保存上一条命令的状态:
bash
./my_program
echo $?
需要注意两点。
第一,$?只代表上一条命令。执行echo $?以后,再查看$?,看到的已经是echo自己的状态。
第二,父进程通过等待接口取得的普通退出码只有低8位,也就是0~255。例如程序返回300,Shell最终看到的是44。
退出码和errno原本没有自动关系:
| 对比项 | errno |
进程退出码 |
|---|---|---|
| 谁设置 | 失败的库函数或系统调用 | 程序通过return、exit或_exit决定 |
| 说明什么 | 某一次操作为什么失败 | 整个程序最终成功还是失败 |
| 谁读取 | 当前进程用perror、strerror等读取 |
父进程通过wait读取,Shell通过$?查看 |
| 有效范围 | 只应在明确失败并规定会设置errno的调用之后检查 |
进程终止后作为最终结果保留 |
程序当然可以主动建立映射,例如系统调用失败后执行exit(errno)。但这是程序员自己的选择,不是操作系统自动把errno变成了退出码。而且父进程通常只能取得退出码的低8位,errno还可能被后续函数改写,因此实际程序更适合定义稳定的退出码,再把具体失败原因打印出来或记录到日志中。
所以,不能把一个任意的子进程退出码直接交给strerror,然后期待得到可靠解释。两个数值即使碰巧相同,语义也可能完全不同。
3.3 return、exit和_exit
三种常见退出方式的区别如下:
| 方式 | 作用 | 是否刷新C标准IO缓冲区 | 是否执行atexit注册函数 |
|---|---|---|---|
return离开main |
正常结束当前进程 | 是 | 是 |
exit(status) |
由C库完成正常退出 | 是 | 是 |
_exit(status) |
直接进入内核终止进程 | 否 | 否 |
从main函数返回,效果等价于把返回值交给exit。但从普通函数return,只是回到调用者,并不会结束整个进程。
exit还会调用通过atexit注册的退出函数。glibc另外提供on_exit,但它属于GNU扩展;需要可移植性时,优先使用标准C提供的atexit。
_exit不会刷新stdio缓冲区,也不会执行用户注册的退出函数。但这不等于内核什么都不回收:进程终止后,内核仍会关闭文件描述符,释放地址空间等资源,并保存少量退出信息等待父进程读取。
下面用同一段缓冲数据观察差别:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
if (argc != 2)
{
fprintf(stderr, "usage: %s exit|_exit\n", argv[0]);
return 2;
}
printf("buffered message");
if (strcmp(argv[1], "exit") == 0)
{
exit(0);
}
if (strcmp(argv[1], "_exit") == 0)
{
_exit(0);
}
fprintf(stderr, "unknown mode: %s\n", argv[1]);
return 2;
}
printf没有换行,数据暂时停留在用户态缓冲区中。把输出接到wc -c统计字节数:
bash
./exit_buffer_demo exit | wc -c
./exit_buffer_demo _exit | wc -c
结果如下:
text
16
0

exit刷新了16字节的buffered message,_exit则没有。
这个区别在fork之后尤其重要。子进程调用exec失败时,通常应该使用_exit退出,避免把从父进程复制来的标准IO缓冲区再次刷新。

3.4 进程退出以后为什么还不能立刻消失
子进程退出时,内核可以释放它的代码、数据和地址空间,但不能把所有记录都直接删除。因为父进程可能还需要知道:
text
子进程是正常结束,还是被信号终止?
如果正常结束,退出码是多少?
如果被信号终止,是哪个信号?
因此,内核会暂时保留PID、退出原因和资源使用统计等少量信息。已经退出但尚未被父进程读取结果的子进程,就是僵尸进程。
下图中的STAT为Z+,命令后带有<defunct>,说明子进程已经退出,但父进程还没有读取它的终止状态。

解决它的接口,就是进程等待。
4 进程等待:读取结果并回收子进程
4.1 为什么父进程必须等待
进程等待有两个直接目的:
text
取得子进程的终止结果
回收内核为子进程保留的进程表项
如果父进程长期不等待已经退出的子进程,僵尸记录就会不断积累。它们不会继续占用原来的代码和数据,但仍占用PID和进程表项,数量足够多时会影响系统继续创建进程。
4.2 wait和waitpid
两个常用接口的原型如下:
c
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait等待任意一个子进程,基本等价于:
c
waitpid(-1, &status, 0);
waitpid的控制更精确:
pid取值 |
等待目标 |
|---|---|
pid > 0 |
等待指定PID的子进程 |
pid == -1 |
等待任意一个子进程 |
pid == 0 |
等待与调用进程同进程组的任意子进程 |
pid < -1 |
等待进程组ID等于-pid的任意子进程 |
目前最常用的是前两种。父进程刚通过fork拿到子进程PID时,直接调用waitpid(child_pid, &status, 0)最清楚。
当options为0时,如果目标子进程仍在运行,父进程会阻塞等待。返回值大于0时,表示成功收到了哪个子进程的状态;返回-1表示调用失败。
等待可能被信号中断并设置errno == EINTR,稳妥的代码应该重试:
c
pid_t ret;
do
{
ret = waitpid(pid, &status, 0);
} while (ret == -1 && errno == EINTR);
父进程已经能够等到子进程,接下来还要回答"子进程怎样结束"这个问题,这就需要单独理解status参数。
4.3 status保存的不是一个普通退出码
status是输出型参数。调用wait或waitpid时,父进程传入它的地址;函数返回以后,内核把子进程的终止方式、退出码或终止信号编码在这个整数中。
在Linux常见布局中,正常退出时可以从高8位取得退出码,被信号终止时可以从低7位取得信号编号:
c
exitcode = (status >> 8) & 0xFF;
exitsignal = status & 0x7F;

这张图适合帮助理解数据怎样存放,但代码不应该到处手写移位和掩码。wait和waitpid返回的status是一组编码后的终止信息,系统已经提供了含义更清楚、可移植性也更好的宏:
| 宏 | 含义 |
|---|---|
WIFEXITED(status) |
子进程是否正常终止 |
WEXITSTATUS(status) |
正常终止时的退出码 |
WIFSIGNALED(status) |
子进程是否被信号终止 |
WTERMSIG(status) |
导致子进程终止的信号编号 |
必须先判断WIFEXITED,再读取WEXITSTATUS;同样,必须先判断WIFSIGNALED,再读取WTERMSIG。
完整示例如下:
c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
return 1;
}
if (pid == 0)
{
printf("child: pid=%d, exit code=7\n", getpid());
fflush(stdout);
_exit(7);
}
int status = 0;
pid_t ret;
do
{
ret = waitpid(pid, &status, 0);
} while (ret == -1 && errno == EINTR);
if (ret == -1)
{
perror("waitpid");
return 2;
}
if (WIFEXITED(status))
{
printf("parent: child %d exited normally, code=%d\n",
ret, WEXITSTATUS(status));
}
else if (WIFSIGNALED(status))
{
printf("parent: child %d was killed by signal %d\n",
ret, WTERMSIG(status));
}
return 0;
}
运行结果:
text
child: pid=488, exit code=7
parent: child 488 exited normally, code=7

子进程把7交给内核,父进程通过waitpid取回7。当waitpid成功返回时,子进程保留的僵尸记录也被回收。
这里的子进程为什么使用_exit(7),而许多入门示例写的是exit(7)?
对父进程来说,两者都会让子进程正常终止,WEXITSTATUS(status)也都能得到7。区别发生在子进程退出前的用户态清理:exit会刷新C标准IO缓冲区并执行atexit函数,_exit则直接进入内核。
这个示例位于fork之后。子进程可能继承了父进程尚未刷新的缓冲数据,使用exit就有重复刷新的风险。因此代码先用fflush(stdout)输出子进程自己打印的内容,再调用_exit(7)。很多博客里的简单示例直接使用exit也能得到正确结果,是因为它们没有遗留缓冲数据,或者终端中的换行已经完成刷新。
可以这样记:普通程序正常结束时使用return或exit;fork以后准备调用exec的子进程,尤其是exec失败的路径,优先使用_exit更稳妥。
4.4 阻塞等待与非阻塞等待
前面的options == 0属于阻塞等待。如果父进程还想要在等待子进程的同时处理其他任务,可以加上WNOHANG:
c
pid_t ret = waitpid(pid, &status, WNOHANG);
if (ret > 0)
{
// 子进程状态已经改变
}
else if (ret == 0)
{
// 子进程仍在运行,父进程可以先做其他事情
}
else
{
// 调用失败,注意单独处理EINTR
}
WNOHANG返回0不是错误,只表示目标子进程还没有可读取的状态。如果用循环轮询,循环中应该安排其他工作或适当休眠,不能无休止空转占满CPU。
本文实现的是前台命令执行:Shell发起一个外部命令后,本来就应该等它结束再打印下一次提示符,因此使用阻塞式waitpid更符合需求。
5 exec:让当前进程运行另一个程序
5.1 进程替换不是进程创建
fork产生新进程,exec没有这个作用。
exec会把当前进程原来的程序映像替换成新程序,包括代码、数据、堆、栈和入口地址。替换成功以后,CPU开始执行新程序,但进程仍然是原来的进程,PID不会改变。
text
替换前:子进程PID = 1001,执行myshell中的代码
│
exec()
│
替换后:子进程PID = 1001,执行ls中的代码

工作目录、PID和PPID等进程属性不会因为一次普通exec凭空改变。已经打开的文件描述符默认也会保留,除非描述符设置了FD_CLOEXEC。创建文件描述符时传入O_CLOEXEC,就是设置这一关闭属性的常见方式。这里我们在讲文件的时候会详细谈到。
最重要的判断规则是:
text
exec成功:新程序已经接管当前进程,原程序后面的代码不会再执行
exec失败:返回-1,并设置errno
所以,写在exec后面的代码只负责处理失败。
5.2 exec函数族怎样区分
常用接口如下:
c
execl execlp execle
execv execvp execve
execvpe
名字中的字母不是随意组合:
| 字母 | 含义 |
|---|---|
l:list |
参数逐个列出,最后必须以NULL结束 |
v:vector |
参数放进argv数组,数组最后必须是NULL |
p:PATH |
文件名中不含/时,按照PATH搜索程序 |
e:environment |
由调用者显式传入新的环境变量数组 |

例如:
c
execl("/usr/bin/ls", "ls", "-l", NULL);
char *const argv[] = {"ls", "-l", NULL};
execvp(argv[0], argv);
简易Shell已经把命令拆成了argv数组,又需要像Bash一样搜索PATH,因此execvp最合适。
在Linux中,execve是实际的系统调用入口,其他常见exec接口通常由C库在它的基础上封装。没有显式e的接口会沿用当前进程环境;execve、execle则允许调用者传入新的环境。execvpe同时接受参数数组、搜索PATH并显式接收环境变量,它属于GNU扩展,不是POSIX标准接口。
5.3 fork、exec与waitpid怎样配合
下面让子进程执行ls -l .,父进程等待它结束:
c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0)
{
char *const argv[] = {"ls", "-l", ".", NULL};
execvp(argv[0], argv);
int saved_errno = errno;
perror("execvp");
_exit(saved_errno == EACCES ? 126 : 127);
}
int status = 0;
pid_t ret;
do
{
ret = waitpid(pid, &status, 0);
} while (ret == -1 && errno == EINTR);
if (ret == -1)
{
perror("waitpid");
return EXIT_FAILURE;
}
if (WIFEXITED(status))
{
printf("ls exit code: %d\n", WEXITSTATUS(status));
}
else if (WIFSIGNALED(status))
{
printf("ls was killed by signal %d\n", WTERMSIG(status));
}
return EXIT_SUCCESS;
}
子进程中的execvp如果成功,后面的perror和_exit永远不会执行。
如果替换失败,代码先保存errno,再输出错误信息,最后使用_exit终止子进程。这里沿用Shell常见约定:找到命令但没有执行权限时返回126,找不到命令等其他简单失败返回127。

至此,命令行解释器需要的四个动作已经齐全。下面把它们组装起来。
6 自主实现一个简易Shell
6.1 先确定实现边界
我们要实现的不是完整Bash,而是一个用来串联进程控制知识的最小Shell。它具备下面几项能力:
text
显示用户名、主机名和当前目录
读取一行命令并按空白字符切分参数
在Shell进程中执行cd和exit
使用fork、execvp执行外部命令
使用waitpid等待并取得命令结果
让Ctrl+C终止前台子进程,而不是终止Shell本身
主循环可以概括为:
text
打印提示符
│
读取一行命令
│
切分为argv数组
│
├── 空命令:继续下一轮
│
├── cd/exit:Shell自己执行
│
└── 外部命令
│
fork
┌──┴──┐
parent child
waitpid execvp
└──┬──┘
记录退出状态
│
回到循环开头

6.2 读取并切分命令
固定长度数组容易截断输入,所以这里使用getline读取整行。它会按需分配或扩展缓冲区,程序结束前再调用free释放。
c
char *line = NULL;
size_t line_capacity = 0;
ssize_t length = getline(&line, &line_capacity, stdin);
拿到一行字符串后,使用strtok_r按照空格、制表符和换行切分:
text
输入:ls -l /tmp
argv[0] -> "ls"
argv[1] -> "-l"
argv[2] -> "/tmp"
argv[3] -> NULL
execvp要求参数数组以NULL结束,因此这一步不能遗漏。
这里只做空白切分,暂时不支持引号和转义。也就是说,echo "hello world"会被错误地拆成多个片段。这个限制会在文章末尾统一说明。
6.3 为什么cd必须是内建命令
外部命令可以交给子进程执行,cd却不行。
进程的当前工作目录属于进程自身。如果让子进程调用chdir,改变的只是子进程目录;子进程退出以后,父Shell仍停留在原来的位置。
text
错误做法:Shell fork -> 子进程chdir -> 子进程退出 -> Shell目录没变
正确做法:Shell自己chdir -> Shell目录改变 -> 后续子进程继承新目录
exit同样必须由Shell自己执行。放进子进程只会结束子进程,命令行解释器还会继续运行。
因此,在fork之前要先识别并处理cd和exit。
6.4 外部命令的完整路径
普通外部命令进入下面这条路径:
c
pid_t pid = fork();
if (pid == 0)
{
execvp(argv[0], argv);
_exit(127);
}
waitpid(pid, &status, 0);
实际代码还需要处理fork失败、execvp失败、等待被信号打断,以及子进程被信号终止等情况。
Shell自身忽略SIGINT和SIGQUIT,子进程在执行外部程序前恢复这两个信号的默认处理方式。这样按下Ctrl+C时,当前前台命令可以结束,Shell仍能回到下一轮输入。
6.5 完整代码
c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <pwd.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define COMMAND_SIZE 1024
#define MAX_ARGS 32
#define HOST_SIZE 64
#define PATH_SIZE 1024
int main(void)
{
char command[COMMAND_SIZE]; // 保存一整行命令
char *argv[MAX_ARGS]; // 保存拆分后的命令和参数
char hostname[HOST_SIZE]; // 主机名
char current_path[PATH_SIZE]; // 当前工作目录
int last_status = 0;
int interactive = isatty(STDIN_FILENO);
/*
* Shell忽略Ctrl+C和Ctrl+\。
* 子进程创建后会恢复默认处理方式。
*/
if (signal(SIGINT, SIG_IGN) == SIG_ERR ||
signal(SIGQUIT, SIG_IGN) == SIG_ERR)
{
perror("myshell: signal");
return 1;
}
while (1)
{
/* 1. 交互运行时打印命令提示符 */
if (interactive)
{
struct passwd *user = getpwuid(getuid());
const char *username =
user == NULL ? "unknown" : user->pw_name;
if (gethostname(hostname, sizeof(hostname)) == -1)
{
strcpy(hostname, "unknown");
}
hostname[sizeof(hostname) - 1] = '\0';
if (getcwd(current_path, sizeof(current_path)) == NULL)
{
strcpy(current_path, "?");
}
/*
* 只显示当前目录的最后一部分。
* 例如 /home/lenovo/test 显示为 test。
*/
char *directory = strrchr(current_path, '/');
if (directory == NULL)
{
directory = current_path;
}
else if (directory[1] != '\0')
{
directory++;
}
printf("[%s@%s %s]$ ",
username, hostname, directory);
fflush(stdout);
}
/* 2. 读取一行命令 */
if (fgets(command, sizeof(command), stdin) == NULL)
{
/* Ctrl+D表示输入结束 */
if (interactive)
{
putchar('\n');
}
break;
}
char *newline = strchr(command, '\n');
if (newline != NULL)
{
*newline = '\0';
}
else if (!feof(stdin))
{
/*
* 输入超过缓冲区后,丢弃这一行剩余内容,
* 避免把一条长命令当成多条命令执行。
*/
int ch;
while ((ch = getchar()) != '\n' && ch != EOF)
{
}
fprintf(stderr, "myshell: command is too long\n");
last_status = 2;
continue;
}
/* 3. 按空格和制表符拆分命令 */
int argc = 0;
char *token = strtok(command, " \t");
while (token != NULL && argc < MAX_ARGS - 1)
{
argv[argc++] = token;
token = strtok(NULL, " \t");
}
/* execvp要求参数数组以NULL结尾 */
argv[argc] = NULL;
if (token != NULL)
{
fprintf(stderr, "myshell: too many arguments\n");
last_status = 2;
continue;
}
/* 用户只输入空格时,不创建子进程 */
if (argc == 0)
{
continue;
}
/*
* 4. 处理内建命令cd
*
* cd必须由Shell自己执行。
* 如果让子进程执行,改变的只是子进程的工作目录。
*/
if (strcmp(argv[0], "cd") == 0)
{
if (argc > 2)
{
fprintf(stderr,
"myshell: cd: too many arguments\n");
last_status = 2;
continue;
}
const char *path =
argc == 1 ? getenv("HOME") : argv[1];
if (path == NULL)
{
fprintf(stderr,
"myshell: cd: HOME is not set\n");
last_status = 1;
}
else if (chdir(path) == -1)
{
perror("myshell: cd");
last_status = 1;
}
else
{
last_status = 0;
}
continue;
}
/*
* 5. 处理内建命令exit
*
* exit同样必须由Shell自己执行。
* 否则结束的只是子进程。
*/
if (strcmp(argv[0], "exit") == 0)
{
int exit_status = last_status;
if (argc > 2)
{
fprintf(stderr,
"myshell: exit: too many arguments\n");
last_status = 2;
continue;
}
if (argc == 2)
{
char *end = NULL;
errno = 0;
long value = strtol(argv[1], &end, 10);
if (errno != 0 ||
*argv[1] == '\0' ||
*end != '\0' ||
value < 0 ||
value > 255)
{
fprintf(
stderr,
"myshell: exit: status must be "
"between 0 and 255\n");
exit_status = 2;
}
else
{
exit_status = (int)value;
}
}
return exit_status;
}
/* 6. 普通命令交给子进程执行 */
/*
* 先刷新C标准IO缓冲区,
* 避免尚未输出的数据被子进程复制。
*/
fflush(NULL);
pid_t child_pid = fork();
if (child_pid == -1)
{
perror("myshell: fork");
last_status = 1;
continue;
}
if (child_pid == 0)
{
/*
* 子进程恢复信号的默认处理方式。
* 因此Ctrl+C可以终止前台命令,但不会终止Shell。
*/
if (signal(SIGINT, SIG_DFL) == SIG_ERR ||
signal(SIGQUIT, SIG_DFL) == SIG_ERR)
{
perror("myshell: signal");
_exit(1);
}
execvp(argv[0], argv);
/*
* execvp成功后不会返回。
* 能执行到这里,说明进程替换失败。
*/
int saved_errno = errno;
fprintf(stderr, "myshell: %s: %s\n",
argv[0], strerror(saved_errno));
/*
* 127表示没有找到命令;
* 126表示找到了命令,但无法执行。
*/
_exit(saved_errno == ENOENT ? 127 : 126);
}
/* 7. Shell等待并回收子进程 */
int status = 0;
pid_t result;
do
{
result = waitpid(child_pid, &status, 0);
} while (result == -1 && errno == EINTR);
if (result == -1)
{
perror("myshell: waitpid");
last_status = 1;
}
else if (WIFEXITED(status))
{
last_status = WEXITSTATUS(status);
}
else if (WIFSIGNALED(status))
{
/*
* Shell通常使用128加信号编号,
* 表示命令被信号终止。
*/
last_status = 128 + WTERMSIG(status);
}
}
return last_status;
}
6.6 编译与测试
编译时打开常用警告:
bash
gcc -std=c11 -Wall -Wextra -Wpedantic myshell.c -o myshell
./myshell
先验证外部命令和PATH搜索:
text
[lenovo@host ~/process]$ pwd
/home/lenovo/process
[lenovo@host ~/process]$ ls -l
...
再验证cd。提示符和pwd都应该显示新的目录:
text
[lenovo@host ~/process]$ cd /tmp
[lenovo@host /tmp]$ pwd
/tmp
验证普通失败和命令不存在:
text
[lenovo@host /tmp]$ false
myshell: exit status 1
[lenovo@host /tmp]$ command_that_does_not_exist
myshell: execvp: No such file or directory
myshell: exit status 127
运行sleep 30后按下Ctrl+C,sleep应该被终止,而Shell继续显示下一次提示符:
text
[lenovo@host /tmp]$ sleep 30
^Cmyshell: terminated by signal 2
[lenovo@host /tmp]$
最后测试Shell自己的退出码:
bash
./myshell
exit 3
echo $?
外层Bash应该输出:
text
3
下面是在WSL Ubuntu中的完整实测。cd改变了Shell自身的工作目录,普通失败和命令不存在分别得到1与127,最后exit 3又被外层环境正确取得。

还可以按Ctrl+D发送文件结束符,Shell会结束输入并退出。

6.7 这个Shell还缺什么
当前版本故意只实现进程控制主线,还不是完整的命令语言。它暂时不支持:
text
单引号、双引号与反斜杠转义
输入输出重定向:>、>>、<
管道:|
后台任务与作业控制:&、jobs、fg、bg
通配符展开:*、?
变量展开与命令替换:$HOME、$(command)
除cd、exit以外的更多内建命令
这些功能并不是简单地多写几个if。管道需要在多个子进程之间组织文件描述符,重定向需要open和dup2,后台任务还会涉及进程组、会话与终端控制。
但作为进程控制的总结,当前版本已经完整走通了最关键的一条路径:
text
读取命令 -> fork创建子进程 -> exec运行程序
-> exit留下结果 -> waitpid读取并回收
7 总结
Linux进程控制的四组接口分工非常明确:
| 接口 | 核心作用 | 最容易混淆的点 |
|---|---|---|
fork |
创建子进程 | 调用一次,父子进程各返回一次 |
exit/_exit |
结束进程并留下终止信息 | _exit不刷新C标准IO缓冲区 |
wait/waitpid |
取得子进程结果并完成回收 | status要用WIF*、W*STATUS宏解析 |
exec |
用新程序替换当前进程映像 | 成功不返回,也不会创建新进程 |
单独看,每个接口都不复杂。真正重要的是它们怎样协作。
Shell先用fork保住自己,再让子进程通过exec运行外部程序。程序结束时,内核保存退出信息;父Shell通过waitpid读取结果、回收子进程,然后重新打印提示符。
下一次再输入ls时,眼前只是一行命令,背后走过的却是一条完整的进程生命周期。