欢迎光临
我们一直在努力

手写自己的shell-从字符串解析到内建命令

手写一个自己的 shell:从字符串解析到内建命令

进程控制系列的收官篇(2026-06-25 ~ 06-26)。
前几篇我们攒齐了零件:fork、exec、waitpid、chdir……
这一篇终于把它们组装成一台能跑的机器——myshell。
而且过程中踩的两个坑(strtok 的静态指针、chdir 不更新的环境变量),恰好把前面的底层知识全部串了一遍。


一、shell 的本质:一个死循环

剥掉所有包装,shell 干的事朴素得惊人:

while (1) {
打印提示符
读一行输入 ← fgets
切割成命令 + 参数 ← strtok
fork + exec 执行 ← fork/execvp/waitpid
记录退出码
}

输入若干字符串 → 切割 → 执行 → 循环,如此而已。所谓"命令行解释器",解释的就是这一串字符串。


二、两个必须搞清的函数

2.1 fgets():会连 \\n 一起读进来

char buf[1024];
fgets(buf, sizeof(buf), stdin);

两个关键行为:

  • 末尾自动补 \\0(字符串结束符);
  • 你在终端敲的 Enter 是 \\n,它也会被一起读进来——所以 buf 里常常带着一个多余的换行。
  • 这就是为什么 shell 命令解析时总要先处理掉行尾的 \\n。

    2.2 strtok():快慢指针版本的切割

    char *token = strtok(buf, " \\t\\n"); // 第一次:传源字符串
    while (token) {
    argv[i++] = token;
    token = strtok(NULL, " \\t\\n"); // 后续:传 NULL 继续切
    }

    机理:strtok 内部用快慢指针扫描——快指针识别到分隔符,就把它就地改成 \\0,于是慢指针到快指针之间就成了一个 token;然后重复。

    两个大坑,务必记牢:

    坑说明
    一次只切一个 token 它不会一次把整串切完,每次调用只切出一个
    内部有静态指针 Linux glibc 下:只要传入相同的虚拟地址,它就不会重新开始;Windows/VS 的实现:第一次必须传指针,之后必须传 NULL

    为什么会有这种差异?因为 strtok 把"上次切到哪儿了"记在一个静态变量里。这也是它线程不安全的根源(多线程应改用 strtok_r)。

    顺便,这也解释了一个"变量作用域"的老问题:普通变量(栈帧里)只是"虚拟地址空间里加了个数字 + 页表挂个标记",作用域结束就销毁;而 static 变量在 mm_struct 的静态区占一个位置,页表标记代表它与进程同生命周期——所以 strtok 的静态指针能跨调用"记住"位置。


    三、核心执行逻辑:fork + exec + waitpid

    pid_t id = fork();
    if (id == 0) {
    /* 子进程:去执行命令 */
    execvp(argv[0], argv);
    exit(0); // ← 关键!exec 失败时兜底,绝不能让"假 shell"继续跑
    } else {
    int status;
    waitpid(id, &status, 0); // 父进程等待并回收
    }

    exec 之后必跟 exit(0) 是新手最容易忘的一步:

    exec 成功后不返回;但一旦 exec 失败(例如命令不存在),代码会继续往下执行——那时子进程就会继续跑"后面的逻辑",甚至把 -bash 的行为搞乱。所以必须用 exit(0) 立刻结束。

    exec 的三要素,在我们的参数里一一对应:

    要素在函数参数里的体现
    1. 搜索路径 用带 p 的版本(execvp)就查 PATH,否则给全路径
    2. 怎么启动 l 形式(逐个列出)或 v 形式(char*[] 数组),末尾必须是 NULL
    3. 环境变量表 带 e 的版本可自己传,否则继承当前 environ

    至此,ls、pwd、cat 这些外部命令都能跑了。


    四、坑一:cd 为什么不能交给子进程?

    问题来了:cd 用上面的套路实现不了。为什么?

    因为 cd 要改的是"当前 shell 自己"的目录,而我们把命令交给子进程去跑——子进程改了自己的 cwd,父进程(shell)毫不知情,改了等于没改。

    由此引出内建命令(built-in command) 的概念:

    必须由 shell 进程自己亲自执行、而不能委托给子进程的命令,就叫内建命令。

    cd、echo $?、export 都是典型的内建命令。

    4.1 cd 的本质:改 PCB 里的 cwd

    chdir("/home/user"); // 系统调用,支持绝对路径和相对路径

    • chdir 改的是调用它的那个进程的 PCB 里的 cwd(当前工作目录);
    • 子进程的 PCB 是从父进程拷贝来的(含 cwd),所以子进程里的 chdir 影响不到父进程。

    4.2 坑二:chdir 之后,printf("%s", getenv("PWD")) 为什么没变?

    这是个经典现象,原因值得记牢:

    环境变量 env 是"用户级数据",存在进程自己的内存里;操作系统不会因为你调用了 chdir 就去主动更新它。

    具体拆开看:

    • getenv() 虽然是系统调用,但它读的是内存里的那串字符串,不是内核的 cwd;
    • 换句话说:PWD 这个环境变量与内核的 cwd 是两套东西,cwd 变了,内存里的 PWD 字符串不会自己跟着变。

    解决方式(两个系统接口配合):

    char cwd[1024];
    getcwd(cwd, sizeof(cwd)); // ① 读当前进程 PCB 里的真实 cwd
    putenv("PWD=…"); // ② 自己动手把环境变量更新掉

    接口读/写对象
    chdir(path) PCB 的 cwd
    getcwd(buf, size) PCB 的 cwd
    getenv(name) 进程内存里的环境变量字符串
    putenv(str) 进程内存里的环境变量字符串

    一句话对比:cwd 是内核态的事实,PWD 是用户态的副本——副本要自己同步。


    五、坑三:echo $? 怎么实现?

    $? 要显示上一条命令的退出码。要点:

  • 每执行完一条命令就要记录它的退出码——因为这是内建命令,echo 必须由一个全局变量来保存值;
  • 这个全局变量"进程不死它不死",所以每次循环结束都会被最新一次的结果覆盖 —— 这正是我们要的语义:只保留最近一条命令的退出码;
  • 如果命令是通过 exec 出的子进程执行的,父进程必须 waitpid 拿到 status,再用位图运算取出退出码:
  • exit_code = (status >> 8) & 0xFF; // 退出码
    exit_sig = status & 0x7F; // 信号编号

    一个闭环细节:exec 出来的子进程在自己退出时,退出码先写进它自己的 PCB;waitpid 把它取回父进程,父进程再存进全局变量——$? 的信息就这么一层层"接力"过来的。


    六、变量与作用域:再回望一次底层

    写 shell 时对"变量"的体感会突然清晰起来:

    变量类型内存里的本质生命周期
    普通局部变量 栈帧里一个虚拟地址 + 页表标记 随函数/作用域结束而销毁
    static 变量 mm_struct 静态区里占一个位置,页表标记"全局" 与进程同生命周期——进程结束、PCB 回收,页表才消失

    也因此,strtok 能靠一个静态指针"记住上次切到哪"——它是进程级的长寿变量。

    C 语言的字符串约定也顺带复习:"p" 实际是 'p' + '\\0',比字符 'p' 多一个 \\0——所有字符串函数(fgets、strtok、getcwd)都建立在这个约定上。


    七、小结:myshell 用到的系统接口清单

    环节接口
    读取输入 fgets
    切割解析 strtok
    创建子进程 fork
    执行程序 execvp / execlp / execve
    回收子进程 & 拿退出码 waitpid
    内建 cd chdir
    读/写环境变量 getenv / putenv
    读当前目录 getcwd

    三条最值钱的收获:

  • 外部命令 = fork + exec + waitpid;内建命令 = shell 自己动手(cd、echo $?);
  • PWD 环境变量不会自动跟随 cwd 变化——内核事实与用户态副本要靠 getcwd + putenv 手动同步;
  • strtok 靠静态变量记忆状态,所以一次只切一个 token、并且是线程不安全的。
  • 写到这里,从 fork 的第一个 PCB 副本,到终端里那个等待你输入的提示符,整条链路终于完全由自己实现了。


    本文基于 2026-06-25《自定义 shell1》、2026-06-26《自定义 shell2》两份笔记整理。
    我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=ffv239lb5al

    赞(0)
    未经允许不得转载:171主机测评 » 手写自己的shell-从字符串解析到内建命令
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址