在这里插入图片描述

.

个人主页:晓风飞
专栏:数据结构|Linux|C语言
路漫漫其修远兮,吾将上下而求索



在这里插入图片描述

手搓一个 Shell——你手里已经有了所有积木


你已经有 fork、wait、exec 了

快速过一遍——只看和今天直接相关的结论。

创建子进程

pid_t id = fork();

fork 之后有两个执行流。父子代码共享,数据写时拷贝。返回值:父进程拿到子进程的 PID,子进程拿到 0。

创建子进程的目的只有两个:要么让子进程执行父进程代码的一部分(靠 if (id == 0) 分流),要么让子进程执行一个全新的程序。今天要用的主要是后者。

终止进程

进程终止三种情况:代码跑完结果对(退出码 0)、代码跑完结果不对(退出码非 0)、出异常(收到信号)。

退出码和退出信号塞在一个 32 位整数的低 16 位里。次低 8 位是退出码,低 7 位是退出信号。exit(1) 写进次低 8 位就是 1 << 8 = 256

系统给了宏:WIFEXITED(status) 判断是否正常退出,WEXITSTATUS(status) 提取退出码。

等待子进程

pid_t rid = waitpid(id, &status, 0);  // 阻塞等
pid_t rid = waitpid(id, &status, WNOHANG);  // 非阻塞等

为什么必须等?因为不等会有僵尸——子进程退出后 PCB 保留着退出信息,父进程不来收就永远挂在那,内存泄漏。这是刚需。

waitpid 做了两件事:回收僵尸(释放 PCB),获取退出信息(把 PCB 里的 exit_code 拷贝到用户空间的 status 变量里)。原理上,它遍历子进程列表,检查 task_struct 的状态是不是 Z(僵尸),是 Z 就把退出信息拷出来,不是 Z 就根据第三个参数决定阻塞还是立刻返回。

程序替换

execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
execvp("ls", argv);  // 带 p:自动搜 PATH;带 v:数组传参

exec 系列函数让当前进程加载磁盘上一个全新的程序,覆盖自己的代码段和数据段。PCB 不变,PID 不变,只换内容。

调用成功就不返回——新程序从 main 开始执行,原先 exec 后面的代码被覆盖了,永远不会执行。只有失败才返回 -1。所以正确写法是 execvp(...); exit(1);——能走到 exit 说明 exec 失败了。

七个 exec 函数,命名规则就四个字母:l(list,参数一个个传)、v(vector,数组传)、p(path,自动搜 PATH)、e(environment,自定义环境变量)。只有 execve 是真正的系统调用,其他六个是库函数封装,底层全部调 execve

fork + exec = Bash 的骨架

创建子进程,让子进程 exec 新程序,父进程 waitpid 等子进程结束。这就叫"让子进程去干别的活,干完了告诉我干得怎么样"。进程独立性保证了子进程的 exec 不影响父进程——写时拷贝在 exec 触发时全面生效,代码段数据段全部分离。

命令行参数和环境变量是怎么传的

你程序的 main(int argc, char *argv[], char *env[]) 的三个参数不是凭空来的。argv 是父进程通过 exec 传进来的——你在 execl 里写的 "ls", "-a", "-l" 最终进了 argv。env 是父进程的 environ 表传进来的——不带 e 的 exec 函数自动把 extern char **environ 传给了 execve。


把这些积木拼起来

上面每一块你都见过。单独拆开看都不复杂。但拼在一起——一个等用户输入、解析命令、fork 子进程、exec 替换、等子进程退出的东西——就是一个 shell。

int main() {
    Bash();  // 入口
    return 0;
}

void Bash() {
    while (1) {
        // 1. 打印提示符
        // 2. 读取用户输入
        // 3. 解析字符串
        // 4. 执行命令
    }
}

真正的软件都是死循环。排序、查找这些程序跑完就退了——那是练习。QQ、微信、浏览器、终端、shell——你一开它就不退,除非你关掉。四步,循环到你 kill 它为止。


第一步:打印提示符

[dzh@centos code]#

三个字符串拼起来:用户名、主机名、当前目录。

#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>

static char username[32];
static char hostname[64];
static char cwd[256];

static void GetUserName() {
    char *name = getenv("USER");
    strcpy(username, name ? name : "None");
}

static void GetHostName() {
    char *name = getenv("HOSTNAME");
    strcpy(hostname, name ? name : "None");
}

static void PrintPrompt() {
    GetUserName();
    GetHostName();
    GetCwdName();
    printf("[%s@%s %s]# ", username, hostname, cwd);
    fflush(stdout);  // 没有 \n,必须手动刷新缓冲区
}

为什么用 getenv 而不是更底层的系统调用?因为这个阶段的目标是练习环境变量的使用——系统已经给你准备好了 USERHOSTNAMEPWD,直接用 getenv 拿就行。不传 \n 的原因是提示符后面紧跟用户输入,不能换行。但没有 \n 意味着 stdio 缓冲区不会自动刷新——fflush(stdout) 强制刷出来。

路径截短:从绝对路径到目录名

系统 shell 只显示当前目录名,不显示完整路径。两个原因让我们一开始保留了完整路径:第一,让我们的 shell 和系统 shell 看起来不一样——后面演示内建命令的时候一眼能区分;第二,后面演示 cd 效果时完整路径更直观。

后来为了长得更像,还是截短了:

static void GetCwdName() {
    char _cwd[256];
    getcwd(_cwd, sizeof(_cwd));  // 系统调用,从 PCB 拿实时路径
    if (strcmp(_cwd, "/") == 0) {
        strcpy(cwd, _cwd);       // 根目录不截
    } else {
        int end = strlen(_cwd) - 1;
        while (end >= 0) {
            if (_cwd[end] == '/') {
                strcpy(cwd, &_cwd[end + 1]);  // 从最后一个 '/' 之后截
                break;
            }
            end--;
        }
    }
}

为什么后来换成 getcwd 而不是继续用 getenv("PWD")?因为 cd 切完路径之后,PWD 环境变量不会自动更新——它是 shell 自己维护的,不是内核维护的。真正的 Bash 在 cd 时会同步更新 PWD 环境变量。我们暂时不搞这个,直接用 getcwd 系统调用从 PCB 拿实时路径,一劳永逸。


第二步:读取用户输入

你敲 ls -a -l 回车。shell 读到的是 "ls -a -l\n"——一个字符串。

不能用 scanf

scanf("%s", buf) 以空白字符作为分隔符。"ls -a -l" 输进去,scanf 读到第一个空格就停了——只拿到 "ls""-a -l" 留在输入缓冲区里。你不知道用户输入了多少个词,没法提前准备够多的 %s 占位符。

fgets 拿整行

static char commandline[256];

static void GetCommandLine() {
    if (fgets(commandline, sizeof(commandline), stdin) != NULL) {
        commandline[strlen(commandline) - 1] = 0;  // 干掉末尾的 \n
    }
}

fgets 无论多少空格全读进来,包括回车键。最后一行把 \n 替换成 \0

具体推导:输入 ls -a -l 再敲回车,fgets 读到的完整内容是 l s - a - l \n \0(六个字符)。strlen 返回 5(碰到 \0 之前)。commandline[5-1]commandline[4] 就是 \n,把它改成 \0 之后字符串变成 "ls -a -l"——干净了。

如果用户只敲了回车没敲任何东西?fgets 读到的是 "\n\0"strlen 等于 1(只有一个 \n)。commandline[1-1]commandline[0],设成 \0——空字符串。后面解析代码判断为空直接 continue,不浪费 CPU。

退格键不管用

输入打错了想退格删掉——没反应。退格键在终端里的行为需要程序自己做键盘输入处理(读取每个按键,判断是不是退格键,手动从缓冲区移除)。我们没做。想删?按住 Ctrl 再按退格(Ctrl+Shift+Delete,取决于你的终端模拟器)。


第三步:拆字符串

"ls -a -l" 是一个字符串。但 execvp 要的是:

char *argv[] = {"ls", "-a", "-l", NULL};

需要拆成三个子字符串,地址放进指针数组,NULL 收尾。

strtok:带静态记忆的切割函数

#include <string.h>

static char *argv[64];
static int argc = 0;
static const char *sep = " ";

static void ParseCommandLine() {
    // 清空上一轮解析的痕迹
    argc = 0;
    memset(argv, 0, sizeof(argv));

    if (strlen(commandline) == 0)
        return;

    // 第一次调用:传字符串本身
    argv[argc] = strtok(commandline, sep);

    // 后续调用:传 NULL,继续切上一个字符串
    while ((argv[++argc] = strtok(NULL, sep)));
}

strtok 按分隔符切字符串。分隔符可以是一个集合——传 " " 按空格切,传 " |&" 按空格、竖线、and 符号一起切。我们只需要空格。

第一次 strtok(commandline, " ") 切出 "ls",内部把 ls 后面的空格改成 \0,返回 "ls" 的起始地址,赋给 argv[0]

然后 while 循环:strtok(NULL, " ")。参数为 NULL 的意思是"继续切上一个字符串剩下的部分"。切出 "-a",赋给 argv[1]argc 从 0 自增到 1)。再切 "-l",赋给 argv[2]argc 变成 2)。再切一次——没东西了,返回 NULL

strtok 怎么记得上一个字符串?

一个函数调完了——栈帧销毁,局部变量都没了——下一次再调的时候它怎么还记得上次切到哪了?

strtok 内部用了 static 变量。 第一次调用时,它把字符串的起始地址和当前位置(快指针指向哪里、慢指针指向哪里)存在 static 局部变量里。Static 变量只初始化一次,函数退出后不释放,下次进来还能读。第二次调用发现参数是 NULL,就读取 static 变量里保存的"上次切到哪了",从那个位置继续往后找分隔符。

这也是为什么 strtok 不是线程安全的——多个线程同时调 strtok 会踩同一块 static 内存。

一行代码干了三件事

while ((argv[++argc] = strtok(NULL, sep)));

这一行同时完成:调 strtok、把返回值赋给 argv[++argc]、用赋值表达式的结果作为 while 条件。strtok 切完所有 token 返回 NULL——作为 while 条件为假,循环退出。而且 argv 最后一个有效元素后的下一个位置恰好是 NULL——不是巧合,是 strtok 的设计决定的。exec 系列函数恰好要求 argv 必须以 NULL 结尾。

小坑:argc 多计了一个

argv[argc] = strtok(...) 第一次的时候 argc 是 0。但 while 里用了 ++argc——先自增再赋值。切 "-a"argc 变成 1,切 "-l" 时变成 2,切出 NULL 时变成 3。

输入是 ls -a -l 三个词,argc 变成了 3——包含尾部 NULL。遍历 argv 的时候用 for (; argv[i]; i++) 遇 NULL 就停,所以功能不受影响。如果你想让 argc 精确等于有效参数个数,把第一次改成 argv[argc++] = strtok(...) 然后 while 里维持 ++argc

debug:验证解析

printf("argc: %d\n", argc);
for (int i = 0; argv[i]; i++)
    printf("argv[%d]: %s\n", i, argv[i]);

输入 ls -a -l -b -c

argc: 5
argv[0]: ls
argv[1]: -a
argv[2]: -l
argv[3]: -b
argv[4]: -c

对了。不管用户输入了多少选项,全部切出来。这就是为什么不用 scanf——你不知道有几个。


第四步:执行命令

解析完了。argv 表就绪。执行。

选哪个 exec?

  • 用户输的是 ls,不是 /usr/bin/ls——没带路径 → 带 p(自动搜 PATH)
  • 参数在数组里 → 带 v(数组传参)
  • 环境变量用默认的 → 不带 e

结论:execvp

#include <sys/wait.h>

void Execute() {
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return;
    } else if (id == 0) {
        // 子进程:替换成用户要跑的程序
        execvp(argv[0], argv);
        exit(1);  // execvp 成功不返回,能走到这里说明失败了
    } else {
        // 父进程:等子进程跑完,记录退出码
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        lastcode = WEXITSTATUS(status);
    }
}

execvp(argv[0], argv)——第一个参数是"执行谁"("ls"),第二个参数是"怎么执行"(整个 argv 数组)。之前讲 exec 时说过:命令行怎么写,参数就怎么传。argv 恰好就是"命令行怎么写"的数组形式。

为什么不能让 shell 自己去 exec?

shell 自己调 execvp("ls", argv)——shell 自己的代码段和数据段就被 ls 覆盖了。while 循环、提示符、输入解析——全没了。ls 跑完,进程退出。shell 死了。

必须 fork 子进程,子进程 exec,父进程 wait。fork 之后子进程和父进程共享数据(argv/argc 是全局变量,只读不写就不触发写时拷贝),子进程能直接拿到父进程解析好的命令行参数。

防御:没命令就跳过

if (argc == 0)
    continue;  // 用户只敲了回车

cd 为什么不干活?

代码写到这里,shell 能跑。lspwdtoppython3 test.py 都正常。但 cd .. 不生效。

[/home/whb/code]# cd ..
[/home/whb/code]# pwd
/home/whb/code       ← 没变!
[/home/whb/code]# ls
main.c  myshell.c  myshell.h  Makefile   ← ls 明明是好的

ls 能跑是因为子进程去跑的——子进程拿到 ls 的代码,执行,打印输出,退出。父进程不受影响。

cd 不生效也是因为子进程去跑的——子进程调了 cd,子进程把自己的当前工作目录切了。然后子进程退出。父进程(你的 shell)的路径纹丝不动。每个进程的当前工作目录记录在自己的 PCB 里,子进程改了子进程的,父进程的 PCB 不受影响。

cd 不能让子进程执行。它必须让 shell 自己执行。

内建命令 = shell 内部的函数

// 返回 1:是内建命令,已处理
// 返回 0:不是内建命令,继续走 fork+exec
int CheckBuiltinAndExecute() {
    // cd
    if (strcmp(argv[0], "cd") == 0) {
        if (argc == 2)
            chdir(argv[1]);  // shell 自己切路径
        return 1;
    }
    // 未来会有更多内建命令...
    return 0;
}

chdir 是一个系统调用。谁调它,谁的当前工作目录就切。shell 自己调,shell 的路径就变了。你甚至找不到叫 cd 的二进制文件——/usr/bin/ 下面根本没有它。它只在 shell 的代码里。

主循环的处理逻辑:

while (1) {
    PrintPrompt();
    GetCommandLine();
    ParseCommandLine();
    if (argc == 0) continue;

    if (CheckBuiltinAndExecute())  // 是内建命令 → 已处理,下一轮
        continue;

    Execute();  // 不是内建命令 → fork + execvp
}

判断一个命令是内建命令还是普通命令的标准:这个命令需不需要修改 shell 自己的状态。 需要——shell 自己调函数。不需要——fork 个子进程跑。

cd 改路径 → 自己调 chdirecho $? 读 shell 内部的 lastcode → 自己打印。env 读 shell 维护的环境变量表 → 自己遍历。export 往环境变量表追加 → 自己追加。

系统 shell 的 cd 还支持不带参数(回 home)、cd -(回上次目录,需维护 OLDPWD)、cd ~(波浪号展开,需读 HOME)。这些都需要维护额外的环境变量。demo 不处理。


echo 和 $?

$ ls
$ echo $?
0

$? 是上一个命令的退出码,存在 shell 的全局变量里:

static int lastcode = 0;

父进程在 waitpid 之后更新它。现在把 echo 也做成内建命令:

else if (strcmp(argv[0], "echo") == 0) {
    if (argc == 2) {
        if (argv[1][0] == '$') {
            // 以 $ 开头 → 查变量
            if (strcmp(argv[1], "$?") == 0) {
                printf("%d\n", lastcode);
                lastcode = 0;  // 查完清零
            }
        } else {
            // 普通字符串,原样输出
            printf("%s\n", argv[1]);
        }
    }
    return 1;
}

lastcode = 0 在打印之后立即执行。因为 echo $? 本身也是一条命令——它执行完之后也有退出状态。不把 lastcode 清零,下一次 echo $? 就会打出"echo 自己的退出状态"而不是"上一条命令的退出状态"。系统 shell 也是这个行为。

echo 在系统里既有内建版本(shell 自带),也有二进制版本(/usr/bin/echo)。我们只做了最常见的内建逻辑。双引号包裹、转义字符、echo -n 这些选项——那是词法分析的工作,demo 不做。


环境变量:从系统 Bash 继承

环境变量表是一个 char **——字符串指针数组,以 NULL 结尾:

environ[0] → "USER=whb"
environ[1] → "HOME=/home/whb"
environ[2] → "PATH=/usr/bin:/bin"
environ[3] → NULL

你的 shell 进程启动时,系统 Bash(shell 的父进程)已经准备好了这张表,存在全局变量 extern char **environ 里。不需要自己解析配置文件——配置文件是 shell 脚本,要解析它相当于写一个脚本解释器。直接把 environ 拷一份过来:

static char **_environ = NULL;
static int envc = 0;

static void LoadEnv() {
    extern char **environ;
    for (envc = 0; environ[envc]; envc++)
        _environ[envc] = environ[envc];
    _environ[envc] = NULL;
}

拷贝的存储空间提前分配好:

void Bash() {
    static char *env[64];      // 最多 64 个环境变量
    _environ = env;
    LoadEnv();

    while (1) { /* ... */ }
}

为什么不直接用 environ?因为后面要用 export 往表里追加新变量——需要修改这张表。environ 是系统维护的,直接追加可能踩到不该踩的内存。自己拷一份,想怎么改怎么改。

env 命令:遍历表

else if (strcmp(argv[0], "env") == 0) {
    for (int i = 0; i < envc; i++)
        printf("%s\n", _environ[i]);
    return 1;
}

env 说到底就是遍历那张字符串指针数组。和 ls 不一样——ls 是磁盘上的二进制文件,env 连文件都算不上。它只是 shell 读自己的数据。这就是它必须是内建命令的原因。

export 命令:追加环境变量

else if (strcmp(argv[0], "export") == 0) {
    if (argc == 2) {
        char *mem = (char *)malloc(strlen(argv[1]) + 1);
        strcpy(mem, argv[1]);
        _environ[envc++] = mem;
        _environ[envc] = NULL;  // 维持 NULL 结尾
    }
    return 1;
}

必须 malloc,不能直接 _environ[envc] = argv[1] argv[1] 指向的是 commandline 缓冲区里解析出来的临时子串——下次用户输入新命令,commandlinefgets 覆盖,argv[1] 指向的内容就变成新输入了。malloc 拷贝一份独立内存,环境变量表有自己的数据,不依赖临时缓冲区。


完整骨架

void Bash() {
    // 环境变量表初始化
    static char *env[64];
    _environ = env;
    LoadEnv();

    while (1) {
        // 第 1 步:打印提示符
        PrintPrompt();

        // 第 2 步:读取用户输入
        GetCommandLine();

        // 第 3 步:解析字符串 "ls -a -l" → argv[]
        ParseCommandLine();
        if (argc == 0)   // 空输入 → 下一轮
            continue;

        // 第 4 步:检查并执行内建命令(cd / echo / env / export)
        if (CheckBuiltinAndExecute())
            continue;     // 内建命令已处理 → 下一轮

        // 第 5 步:不是内建命令 → fork + execvp
        Execute();
    }
}

CheckBuiltinAndExecute() 目前处理四个内建命令:cd(调 chdir)、echo(打印字符串或 $?)、env(遍历环境变量表)、export(追加环境变量)。每增加一个内建命令,在里面加一个 else if 分支。


跑起来的模样:

[whb@centos code]# ls
main.c  myshell.c  myshell.h  Makefile
[whb@centos code]# cd ..
[whb@centos home]# pwd
/home/whb
[whb@centos home]# echo $?
0
[whb@centos home]# export myval=100
[whb@centos home]# env | grep myval
myval=100

一百多行,一个能跑的 shell。没有管道、重定向、信号处理、作业控制、别名、通配符、Tab 补全、历史命令、配置文件解析。但它的骨架——fork → exec → wait——和 Bash 完全一样。Bash 只是在这个骨架上长了更多的肉。

你敲 cd,shell 自己调 chdir。你敲 echo $?,shell 自己查 lastcode。你敲 env,shell 自己遍历 _environ 表。你敲 ls,shell fork 个子进程,子进程 execvp 换成 ls,父进程 waitpid 等着。

内建命令就是 shell 内部的函数。普通命令就是 fork + exec。 区分标准只有一个:需不需要动 shell 自己的状态。

exportenvecho $PATH——这三个"命令"本质上在操作同一张字符串指针数组。表在 shell 自己的内存里,除了 shell 自己谁也碰不到。所以它们全部是内建命令。

回到开头:为什么 ls 能用,cd 不能用?因为 cd 被子进程执行了。子进程 cut 了自己的路径,然后死了。父进程——你的 shell——站在原地,没动过。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐