Linux进程控制:从fork到exec,手写一个简易Shell

🌈 个人主页: 小小、码农的 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创建子进程时,这份尚未输出的缓冲数据也会被复制。

如果父子进程后来都通过returnexit结束,它们会各自刷新自己手中的那份缓冲区,于是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 进程退出码
谁设置 失败的库函数或系统调用 程序通过returnexit_exit决定
说明什么 某一次操作为什么失败 整个程序最终成功还是失败
谁读取 当前进程用perrorstrerror等读取 父进程通过wait读取,Shell通过$?查看
有效范围 只应在明确失败并规定会设置errno的调用之后检查 进程终止后作为最终结果保留

程序当然可以主动建立映射,例如系统调用失败后执行exit(errno)。但这是程序员自己的选择,不是操作系统自动把errno变成了退出码。而且父进程通常只能取得退出码的低8位,errno还可能被后续函数改写,因此实际程序更适合定义稳定的退出码,再把具体失败原因打印出来或记录到日志中。

所以,不能把一个任意的子进程退出码直接交给strerror,然后期待得到可靠解释。两个数值即使碰巧相同,语义也可能完全不同。

3.3 returnexit_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、退出原因和资源使用统计等少量信息。已经退出但尚未被父进程读取结果的子进程,就是僵尸进程。

下图中的STATZ+,命令后带有<defunct>,说明子进程已经退出,但父进程还没有读取它的终止状态。

解决它的接口,就是进程等待。

4 进程等待:读取结果并回收子进程

4.1 为什么父进程必须等待

进程等待有两个直接目的:

text 复制代码
取得子进程的终止结果
回收内核为子进程保留的进程表项

如果父进程长期不等待已经退出的子进程,僵尸记录就会不断积累。它们不会继续占用原来的代码和数据,但仍占用PID和进程表项,数量足够多时会影响系统继续创建进程。

4.2 waitwaitpid

两个常用接口的原型如下:

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)最清楚。

options0时,如果目标子进程仍在运行,父进程会阻塞等待。返回值大于0时,表示成功收到了哪个子进程的状态;返回-1表示调用失败。

等待可能被信号中断并设置errno == EINTR,稳妥的代码应该重试:

c 复制代码
pid_t ret;
do
{
    ret = waitpid(pid, &status, 0);
} while (ret == -1 && errno == EINTR);

父进程已经能够等到子进程,接下来还要回答"子进程怎样结束"这个问题,这就需要单独理解status参数。

4.3 status保存的不是一个普通退出码

status是输出型参数。调用waitwaitpid时,父进程传入它的地址;函数返回以后,内核把子进程的终止方式、退出码或终止信号编码在这个整数中。

在Linux常见布局中,正常退出时可以从高8位取得退出码,被信号终止时可以从低7位取得信号编号:

c 复制代码
exitcode   = (status >> 8) & 0xFF;
exitsignal = status & 0x7F;

这张图适合帮助理解数据怎样存放,但代码不应该到处手写移位和掩码。waitwaitpid返回的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也能得到正确结果,是因为它们没有遗留缓冲数据,或者终端中的换行已经完成刷新。

可以这样记:普通程序正常结束时使用returnexitfork以后准备调用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的接口会沿用当前进程环境;execveexecle则允许调用者传入新的环境。execvpe同时接受参数数组、搜索PATH并显式接收环境变量,它属于GNU扩展,不是POSIX标准接口。

5.3 forkexecwaitpid怎样配合

下面让子进程执行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之前要先识别并处理cdexit

6.4 外部命令的完整路径

普通外部命令进入下面这条路径:

c 复制代码
pid_t pid = fork();

if (pid == 0)
{
    execvp(argv[0], argv);
    _exit(127);
}

waitpid(pid, &status, 0);

实际代码还需要处理fork失败、execvp失败、等待被信号打断,以及子进程被信号终止等情况。

Shell自身忽略SIGINTSIGQUIT,子进程在执行外部程序前恢复这两个信号的默认处理方式。这样按下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+Csleep应该被终止,而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自身的工作目录,普通失败和命令不存在分别得到1127,最后exit 3又被外层环境正确取得。

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

6.7 这个Shell还缺什么

当前版本故意只实现进程控制主线,还不是完整的命令语言。它暂时不支持:

text 复制代码
单引号、双引号与反斜杠转义
输入输出重定向:>、>>、<
管道:|
后台任务与作业控制:&、jobs、fg、bg
通配符展开:*、?
变量展开与命令替换:$HOME、$(command)
除cd、exit以外的更多内建命令

这些功能并不是简单地多写几个if。管道需要在多个子进程之间组织文件描述符,重定向需要opendup2,后台任务还会涉及进程组、会话与终端控制。

但作为进程控制的总结,当前版本已经完整走通了最关键的一条路径:

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时,眼前只是一行命令,背后走过的却是一条完整的进程生命周期。

相关推荐
不会就选b1 小时前
Linux之进程间通信(二)---模拟进程池
linux·运维·服务器
NeilYuen2 小时前
用UDP实现向DNS服务器请求IP地址
服务器·tcp/ip·udp
沫璃染墨3 小时前
《从零入门Linux系统篇(十五):系统工具篇·六——GDB调试器详解:从程序执行控制到高级调试技巧》
linux·运维·服务器·c语言·c++
AIgorithmGEEK4 小时前
Linux 环境变量完全指南:从入门到理解底层原理
linux·运维·服务器
修罗王4 小时前
Linux Docker 服务管理与排查实战指南(新手速查版)
运维·docker
迪康Defender4 小时前
从静态存储到动态流转:终端透明加密两种模式实战解析
运维·服务器·网络·数据库·其他
酷可达拉斯5 小时前
Linux操作系统-升级OpenSSH修复漏洞
linux·运维·服务器·云计算
振浩微433射频芯片5 小时前
用TU2303B双向无线模块打通标准化智能家居接入:433MHz方案的落地优势指南
服务器·网络·人工智能
Molesidy5 小时前
【Linux】基于busybox的根文件系统的完善
linux·服务器·嵌入式linux