在这里插入图片描述

.

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


文章目录


在这里插入图片描述

手搓一个 Shell:从命令行原理到自主解释器

一、前情回顾:从指令到进程控制

在正式编写自主 Shell 之前,我们先快速回顾一下之前推进的核心话题。

1.1 基础指令与权限

指令:经过约 20 节课的训练,同学们对 Linux 常见命令(lscdpwdtouchmkdirrm 等)已经比较熟悉,后续还会不断使用,同时也会新增 makegccgdbyumapt 等开发工具类指令。

权限:权限的本质是"人 + 事物属性"。人分为两类——角色和真实的人:

  • 角色:拥有者(owner)、所属组(group)、other
  • 真实的人:root 用户、你自己的账号。一个真实的账号(如 whb)对于某个文件来说,可能是拥有者,也可能是所属组,也可能什么都不是

设置权限时,一方面要把拥有者、所属组和 other 对应的权限与角色对应起来,另一方面要明确这个角色由哪种人承担(root 还是普通用户)。配套工具有 chownchgrpchmod

三个子话题

  1. umask(权限掩码):普通文件创建时起始权限从 666 开始(无可执行),目录创建时起始权限从 777 开始。系统通过 umask 过滤掉不想要的权限,最终权限 = 起始权限 & (~umask)。所以我们看到新建文件的默认权限是 664、662 等,目录是 775、774 等。

  2. 目录权限三句话

    • 进入目录需要 x 权限
    • 读目录内容需要 r 权限
    • 新建/删除文件需要 w 权限
  3. 粘滞位(Sticky Bit):在共享目录场景下,每个人都可以新建、删除文件。虽然可以通过权限阻止别人访问你的文件,但因为删除文件取决于目录的 w 权限(而非文件本身权限),所以别人仍然可以删你的文件。给目录设置粘滞位(chmod +t)后,不属于你的文件你就删不掉了。

1.2 基础开发工具

软件安装(apt / yum)

两条核心认知:

第一,安装/卸载的本质是从网络下载文件到系统级目录(如 /usr/bin/usr/lib64),卸载就是把对应目录下的文件 rm 删掉。因此必须通过 sudo 提权到 root。

第二,软件包生态。好的操作系统必须有完整的社区论坛、官方文档、软件体系、更新机制、客户群体,形成闭环。CentOS 或 Ubuntu 背后当年写完操作系统的开发者,也必然会花大量时间去开发应用软件、搭建包服务器。生态思维的例子:华为纯血鸿蒙必须让微信、抖音、淘宝等应用愿意在上面开发客户端,因为有客户端才有用户。

包依赖:软件之间彼此依赖——你写的代码用 C 标准库,C 标准库可能还用其他库。以前 yum/apt 生态不够好时,搭环境可能要花一两个礼拜去解决依赖问题,现在 apt 会自动解决。

国内镜像源:由于特殊国情,很多国外包服务器访问缓慢或无法访问。国内高校和云服务厂商(163、清华、阿里、腾讯等)会把国外软件包镜像到国内,云服务器厂商安装系统时也会配置好国内镜像源。

写代码(VIM):VIM 是多模式的,课堂上讲了五种——插入模式、命令模式、底行模式、替换模式、视图模式(批量化注释/操作),用熟已经足够。

编译(gcc/g++):预处理、编译、汇编、链接四阶段。gcc 命令选项:-E(预处理)、-S(编译到汇编)、-c(汇编到目标文件)。

动静态链接

  • 动态链接:与 .so 动态库链接,不会把库拷贝到可执行程序里。执行时需要把可执行程序和动态库都加载到内存,代码调用库方法时跳转到库方法执行。
  • 静态链接:把 .a 文件中用到的代码拷贝到可执行程序里,生成的程序体积较大。

make / Makefilemake 是个命令,Makefile 是个文件,里面写的是依赖关系和依赖方法。核心要求是能处理多文件编译。

版本控制(git):三板斧——git clonegit addgit commitgit push,附加命令有 git loggit status 等。

调试器(gdb/cgdb):推荐用 cgdb,至少可以可视化代码执行位置。三个实用技巧:

  • watch:监视变量,变量被修改时自动触发断点
  • set var:在 debug 时直接修改变量值来验证结果
  • 条件断点:在打断点之前或对已有断点添加条件

1.3 进程概念

冯·诺依曼体系

三句话概括:

  1. 存储器指的是内存。
  2. CPU 只跟内存打交道,不能直接访问外设(在数据层面上)。因为外设速度太慢,CPU 太快,直接与外设做数据沟通会拖慢整机速度。
  3. 程序运行前必须加载到内存——因为可执行程序是磁盘上的二进制文件(属于外设),CPU 要读取指令和数据只能从内存读,所以必须把数据从外设搬到内存。这是体系结构规定的,不是谁拍脑袋想的。

跨主机通信的数据流向:比如你在直播间看到老师的画面——老师的摄像头和话筒采集数据 → 经过打包/加密 → 写入内存 → 通过网卡发出 → 你的网卡接收 → 先搬到内存 → 软件解密/渲染 → 刷新到显示器。无论发代码、发图片、发音频,逻辑完全一样,因为体系结构这么规定。

操作系统

狭义操作系统指 Linux 内核,广义包括内核 + 预装软件(shell、图形化界面、GCC、各种指令等)。操作系统是一款进行软硬件资源管理的软件。

深刻理解"管理":管理的本质不是管你这个人,而是管你身上的数据。比如教务系统采集你的学习数据(看了多少课、作业完成情况),助教老师根据这些数据对你做管理——这个过程他连你面都没见过。

面对大量被管理对象时,思维方式高度提炼为六个字:先描述,再组织。用 struct 结构体描述每个对象,然后用特定数据结构(链表、哈希表等)组织起来,最后把对人的管理转化成对数据结构的增删查改。这就是现实问题到计算机建模的过程。

操作系统管理进程也是一样:每个进程用一个 task_struct 结构体描述,所有进程节点用双链表连接起来,对进程的管理就变成对链表的操作。这种思维方式在后面学文件、学内存时同样适用。

系统调用和库函数:操作系统为了自身安全,不允许用户随便访问内核代码和数据,所以提供了系统调用接口。

进程 PCBtask_struct 结构体包含进程的所有管理信息。相关话题:查看进程、fork 创建进程、进程状态、优先级、进程切换与调度、环境变量、命令行参数、程序地址空间。

O(1) 调度算法:每个 CPU 有一个运行队列,内部维护两组队列——活跃队列(active queue)和过期队列(expired queue)。以活跃队列为例,内有 140 个优先级子队列(0-99 实时进程,100-139 分时系统,共 40 个优先级)。采用时间片轮转方式:进程从活跃队列调度,时间片用完放入过期队列,等活跃队列全部消耗完,交换两个指针(activeexpired),活跃变过期、过期变活跃,继续调度。算法设计非常精巧,值得学习。

命令行参数和环境变量

  • 命令行参数:main 函数的 argv 参数支持命令行选项功能。Linux 命令的选项功能本质上就是通过命令行参数完成的。
  • 环境变量:系统预先创建的一组系统级变量,记录程序搜索路径(PATH)、当前用户(USER)、当前工作路径(PWD)、家目录(HOME)、LS 配色方案、历史命令上限、主机名(HOSTNAME)等信息。

程序地址空间(虚拟地址空间)

操作系统给每个进程画了一张"大饼"——32 位下每个进程都有 4GB 空间。每个进程都认为自己有 4GB 内存,可以统一规划代码区、数据区、堆区、栈区的位置。内核中用 mm_struct 结构体描述这个虚拟空间。

为什么要有虚拟地址空间?三个理由:

  1. 保护合法数据,防止进程越界访问
  2. 进程和内存管理模块完全解耦
  3. 在进程视角下内存分布整体有序

1.4 进程控制

进程终止:三种情况——

  • 代码跑完,结果对(main 返回值 0)
  • 代码跑完,结果不对(main 返回非 0 退出码)
  • 出异常(收到异常信号,进程崩溃)

main 函数的返回值最终返回给父进程(bash),告诉 shell 子进程的执行结果对不对。以前写 return 0 但不清楚为什么——现在知道不仅仅是写 0,可以通过返回不同值区分执行结果。

进程等待

是什么:父进程通过 waitwaitpid 回收子进程的僵尸状态,获取子进程的退出信息。

为什么必须等:

  • 子进程退出时处于僵尸状态,要把退出信息维护起来(告诉父进程"我执行得怎么样")
  • 不回收僵尸就会一直存在,造成内存泄漏——这是刚需
  • 可选的收益:通过退出码和退出信号两个整数,辨别子进程的退出原因(退出码为 0 且信号为 0 = 正常;退出码非 0 但信号为 0 = 结果不对;信号不为 0 = 异常)

怎么办:通过 wait / waitpid 的参数读取退出信息。本质上,子进程退出时会把退出信息写在 PCB 里,获取退出信息就是访问 PCB——和 getpid()getppid() 一样。

程序替换

是什么:通过特定的系统调用接口,让当前进程加载磁盘上的全新程序,在不新创建进程、不大面积更改进程属性的前提下,用新程序的代码和数据覆盖当前进程的代码段和数据段,然后重新从新程序的 main 函数开始执行。进程 PID 不变

为什么:需求驱动的。创建子进程有两种用途——要么执行父进程代码的一部分,要么执行一个全新的程序(如 ls、任务管理器等)。操作系统必须提供程序替换的接口。

怎么办:七个系统调用——execlexeclpexecleexecvexecvpexecve(实际是六个封装 + 一个真正的系统调用 execve)。命名规则:

  • l(list):列表传参
  • v(vector):数组/vector 传参
  • p(path):自动搜索 PATH 环境变量
  • e(env):使用自定义环境变量

这些接口不需要死记硬背,用到了查就行,让 AI 生成一个最简单的 Demo 样例,一看就懂。


二、自主 Shell:把知识全部串起来

2.1 这份代码的价值

写一个自主 Shell,从工程价值上说没有意义——现成的 shell 已经在用了。但从教学角度说,只有手搓一个,才能真正理解命令行上一个命令是怎么被执行的。之前讲的"王婆"例子、"大学实习生"例子,从编码和系统角度重新看待时才能真正理解。

真正的 shell 是万行级别的,我们写一个 120 行左右的 Demo,把之前学到的知识全部串起来——进程创建、进程等待、程序替换、环境变量、命令行参数,同时通过代码解释三个之前"大概听懂但并没有深刻理解"的概念:

  • 一个命令是怎么跑的
  • 什么叫做内建命令(普通命令 vs 内建命令的区别)
  • 本地变量和环境变量有什么区别

2.2 真正的软件都是死循环

你会发现一个现象:登录 QQ,不退出它就一直运行;打开画图板,不关它就一直开着。真正的软件永远都在运行,除非用户关闭它。你们以前写的排序、查找代码跑一次就结束了——那是因为你们在练习,真正的软件是死循环。

所以 Shell 的核心结构就是一个 while(1) 死循环。

2.3 命令行解释器的工作流程

Shell 的工作流程非常清晰:

  1. 打印提示符
  2. 等待用户输入(此时"卡住")
  3. 获取用户输入的字符串
  4. 解析字符串(拆出命令和选项)
  5. 执行命令
  6. 返回第 1 步,继续等待输入

你在命令行输入 ls -a -l,实际上输入的是一个字符串 "ls -a -l"(注意中间是空格,不是三个独立的字符串)。Shell 拿到这个字符串后,在内部分析——拆出命令 ls,拆出选项 -a-l,然后交给指定程序运行。运行完毕再重新返回命令行。


三、工程搭建:多文件结构

mkdir lesson22
cd lesson22
touch myshell.c myshell.h main.c Makefile

myshell.h

#pragma once
#include <stdio.h>

void Bash();

main.c

#include "myshell.h"

int main()
{
    Bash();
    return 0;
}

Makefile

mybash:myshell.c main.c
	gcc -o $@ $^
.PHONY:clean
clean:
	rm -f mybash

不需要在 Makefile 里写 -I 指定头文件路径,因为头文件就在当前目录下。回顾一下:头文件包含中,<> 直接去系统目录找,"" 先在当前目录找,找不到再去系统目录找。


四、第一步:打印命令行提示符

4.1 需要哪些信息

命令行提示符的格式:

[用户名@主机名 当前工作目录]#

我们需要获取三样东西:用户名、主机名、当前工作路径。Linux 系统中都有对应的系统调用(获取用户名、gethostnamegetcwd 等),但今天有一个更重要的工作要练习——环境变量

用户名、主机名、当前工作路径这些信息,不就在环境变量里吗?USERHOSTNAMEPWD 明晃晃地放在那里。所以今天不用系统调用(除非后面被逼急了),我们用 getenv 从环境变量拿。

4.2 全局变量定义

myshell.c 中定义全局变量,用 static 限制作用域:

// 提示符相关
static char username[32];
static char hostname[64];
static char cwd[256];

4.3 获取环境变量:getenv

getenv 的声明在 stdlib.h,通过环境变量名获取对应字符串内容。获取成功返回字符串指针,失败返回 NULL

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

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

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

static void GetCwdName()
{
    char *_cwd = getenv("PWD");
    strcpy(cwd, (_cwd ? _cwd : "None"));
}

4.4 打印提示符

static void PrintPrompt()
{
    GetUserName();
    GetHostName();
    GetCwdName();
    printf("[%s@%s %s]# ", username, hostname, cwd);
    fflush(stdout);
}

注意:提示符不能带 \n,否则用户输入会跑到下一行。但如果不带 \n,由于标准输出的行缓冲机制,提示符可能不会立即显示。所以需要用 fflush(stdout) 强制刷新输出缓冲区,这和写进度条时的 fflush 是同一个道理。


五、第二步:获取用户输入

5.1 为什么不用 scanf?

scanf 默认以空格作为分隔符。当用户输入 ls -a -l 时,这是一个完整的字符串,你不知道用户输入了多少个以空格分隔的部分。所以不能用 scanf

5.2 使用 fgets

fgets 从标准输入(键盘)获取数据到指定缓冲区,成功返回字符串首地址,失败返回 NULL

static char commandline[256];

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

5.3 为什么要去掉末尾换行符?

当你输入 ls -a -l 然后按回车,fgets 会把回车键(\n)也当作字符串的一部分读进来。所以实际上你输入了 ls -a -l\n 这整串字符。

strlen(commandline) - 1 取到最后一个字符(即 \n)的下标,把它设为 \0,相当于把换行符干掉了。

边界情况strlen(commandline) 会不会等于 0?不会。因为你至少会输入一个回车,回车让 strlen 等于 1,1 - 1 = 0,相当于第一个字符就被清掉,结果为空字符串。这在直接按回车时是符合预期的。


六、第三步:解析命令行字符串

6.1 为什么要解析?

程序替换函数(如 execlexecv)的参数要么是一个个传,要么是以数组形式传。没人见过把命令和选项揉在一起作为一个字符串传的。

所以必须把 "ls -a -l" 拆成:"ls""-a""-l"——形成一张 argv 表,概念上就是命令行参数表。

这就是 main 函数的 argv 参数的来源:父进程(bash)对字符串做分析,拆成若干子字符串形成 argv 表,然后通过 exec 系列函数传递给子进程。

6.2 定义 argv 表

// 与命令行相关
static char *argv[64];
static int argc = 0;

每次解析前需要清空,因为上次解析的痕迹会残留(比如上次 argc = 3,下次解析新命令如果不重新清零,argc 就从 3 开始计数了)。

6.3 使用 strtok 切割字符串

strtok 是 C 语言字符串切割函数。它把你传入的字符串按指定的分隔符集进行分割,每次返回一个子串(称为一个 token)。

关键特性

  • 第一次调用:传入目标字符串,strtok 切出第一个 token 并返回其起始地址,同时自动把 token 后面的分隔符替换为 \0
  • 后续调用:第一个参数必须传 NULL,它才会继续切割上一个字符串的剩余部分。如果继续传字符串本身,它每次都只会切第一个 token
  • 切割完毕:返回 NULL

为什么第二次要传 NULL? 因为 strtok 内部使用了 static 变量来记录切割的位置(相当于快指针和慢指针的状态),函数调用结束后这些状态不会丢失,下次再调时还能继续切。

使用示例

static const char *sep = " ";  // 分隔符集,这里只有一个空格

static void ParseCommandLine()
{
    // 清空
    argc = 0;
    memset(argv, 0, sizeof(argv));

    // 判空
    if (strlen(commandline) == 0)
        return;

    // 解析
    argv[argc] = strtok(commandline, sep);        // 第一次:切出 "ls"
    while ((argv[++argc] = strtok(NULL, sep)));   // 后续:切出 "-a", "-l"
}

这段代码的精妙之处

  1. 第一次 strtok(commandline, sep) 切出 "ls"argc 变为 1
  2. while 循环中,先 ++argc(变为 2),strtok(NULL, sep) 切出 "-a",赋值给 argv[2],地址非空,条件为真,继续循环
  3. 再次 ++argc(变为 3),strtok(NULL, sep) 切出 "-l",赋值给 argv[3]
  4. 再次 ++argc(变为 4),strtok(NULL, sep) 切完,返回 NULL,赋值给 argv[4]NULL 为假,循环退出

结果:argvNULL 结尾(argv[3] = NULL),完美满足 execv 系列函数对参数表的要求。

一个关于 argc 的小问题:按上面的逻辑,argc 最后是 4,而实际上我们只有 3 个参数(ls-a-l)。因为最后一次赋值 NULLargc 也被加了一次。

解决方法:把 argc++ 放在赋值之前而不是之后——先自增,再赋值。这样最后一次 NULL 赋值时虽然也加了,但 NULL 不计入有效参数。


七、第四步:执行命令

7.1 为什么不能自己执行?

Shell 本身是一个进程,如果它自己调用 exec 系列函数替换了自己,那 while(1) 循环就没了,shell 就退出了。所以必须创建子进程来执行命令。

7.2 完整的 Execute 函数

#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.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);
        (void)rid;
        lastcode = WEXITSTATUS(status);
    }
}

7.3 为什么选 execvp?

  • 需要带 p:用户输入命令时没有带路径(如直接输入 ls 而不是 /usr/bin/ls),需要系统自动去 PATH 环境变量里搜索
  • 需要带 v:已经解析好了 argv 向量表

注意:函数名中的 p 意思是"自动搜索 PATH",而不是"需要带路径"。这一点在讲课时容易说反!

7.4 子进程能看到 argv 表吗?

能。因为 fork 之后父子进程代码共享,数据以写时拷贝(Copy-On-Write)的方式共享。命令行解析工作由父进程完成,子进程不做修改,所以子进程能直接看到解析好的 argvargc


八、内建命令:让 bash 自己执行

8.1 问题发现:cd 命令失效

运行我们的 shell:pwd 正常,ls 正常,但 cd .. 之后再 pwd,路径没有变化。

原因:每个进程都有自己的当前工作路径。cd 命令是让子进程去执行的——子进程切换了自己的路径,然后子进程退出。父进程(bash)的路径根本没受影响!

所以 cd 这种命令不应该让子进程执行,而应该让 bash 自己执行。这类命令称为内建命令(built-in command)

内建命令本质上是 bash 内部的一个函数。常见的内建命令包括 cdechoenvexport 等。

8.2 判断并执行内建命令

Bash() 主循环中加入判断:

// 第4步:检查是否为内建命令
if (CheckBuiltinAndExecute())
{
    continue;  // 内建命令已执行,跳过 fork/exec
}
// 第5步:普通命令,fork + exec
Execute();

CheckBuiltinAndExecute() 返回 1 表示是内建命令且已执行,返回 0 表示是普通命令,交给后续的 Execute()

8.3 实现 cd:chdir 系统调用

int CheckBuiltinAndExecute()
{
    int ret = 0;

    if (strcmp(argv[0], "cd") == 0)
    {
        ret = 1;
        if (argc == 2)
        {
            chdir(argv[1]);  // 切换当前进程的工作路径
        }
    }
    // ... 其他内建命令
    return ret;
}

chdir 是更改当前工作路径的系统调用。谁调用它,谁的路径就切换。由 bash 自己调用 chdir,切换的就是 bash 自己的工作路径,这样 pwd 就能正确反映了。

所以什么叫做内建命令? 所谓的内建命令,其实就是 shell 内部的一个函数,要么是 shell 自己定义的函数,要么直接调用系统调用。

8.4 路径显示优化:只显示当前目录名

真实的 shell 只显示当前目录名(如 code),而不显示完整的绝对路径(如 /home/whb/code)。我们需要从完整路径中提取最后一段:

static void GetCwdName()
{
    char _cwd[256];
    getcwd(_cwd, sizeof(_cwd));  // 获取当前进程的真实工作路径

    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")。因为当路径切换时,环境变量 PWD 不会自动更新(这是 shell 自己维护的),而 getcwd() 直接从内核 PCB 中查询,获取的是实时路径。


九、退出码:记录命令执行结果

9.1 echo $? 的原理

在真实的 shell 中:

ls -a -l
echo $?     # 0(上一个命令执行成功)
ls xxx      # 报错:文件不存在
echo $?     # 2(非 0 表示失败)

echo $? 是 shell 内部的一个特殊变量,记录上一次命令的退出码。注意echo 本身也是一个命令,但它不应该创建子进程去执行,而是由 bash 自己解释执行——它也是一个内建命令。

9.2 维护 lastcode

// 与退出码有关
static int lastcode = 0;

在父进程回收子进程时更新:

// 父进程中
int status = 0;
pid_t rid = waitpid(id, &status, 0);
lastcode = WEXITSTATUS(status);  // 提取退出码

9.3 实现 echo 内建命令

else if (strcmp(argv[0], "echo") == 0)
{
    ret = 1;
    if (argc == 2)
    {
        if (argv[1][0] == '$')
        {
            if (strcmp(argv[1], "$?") == 0)
            {
                printf("%d\n", lastcode);
                lastcode = 0;  // 查完后清零,和真实 shell 行为一致
            }
            else
            {
                // $PATH 等环境变量,后面处理
            }
        }
        else
        {
            printf("%s\n", argv[1]);  // 普通字符串直接打印
        }
    }
}

$? 中的 $ 是 shell 内部的特殊符号,表示要解析一个变量的内容。echo 命令看到 $?,不是去查子进程的什么信息,而是直接把自己内部记录的 lastcode 打印出来。因此 echo 是内建命令——它要访问 bash 内部的私有数据。


十、环境变量:bash 自己维护

10.1 环境变量由谁维护?

启动程序时,bash 会读取环境变量信息,在内部维护一张 char*[] 类型的表。我们的自主 shell 怎么获得环境变量?直接从系统 bash 拷贝一份就行了。

extern char **environ;  // 系统提供的环境变量表

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

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

有了这份环境变量表,envexportecho $PATH 等操作就都是对这张表的增删查改了——它们都是内建命令。

10.2 实现 env 命令

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

10.3 实现 export 命令

else if (strcmp(argv[0], "export") == 0)
{
    ret = 1;
    if (argc == 2)
    {
        // export myval=100
        char *mem = (char*)malloc(strlen(argv[1]) + 1);
        strcpy(mem, argv[1]);
        _environ[envc++] = mem;
        _environ[envc] = NULL;
    }
}

十一、重定向:增强鲁棒性

11.1 重定向类型

#define NONE_REDIR 0
#define IN_REDIR   1   // <
#define OUT_REDIR  2   // >
#define APP_REDIR  3   // >>

11.2 解析重定向符号

在原始命令行字符串中扫描 >>><,记录重定向类型和文件名,并将重定向符号替换为 \0,使得前面的命令部分和后面的文件名部分分离。

void Redir()
{
    char *start = commandline;
    char *end = commandline + strlen(commandline);

    while (start < end)
    {
        if (*start == '>')
        {
            if (*(start + 1) == '>')
            {
                redir_type = APP_REDIR;
                *start = '\0';
                start += 2;
                CLEAR_LEFT_SPACE(start);
                redir_filename = start;
                break;
            }
            else
            {
                redir_type = OUT_REDIR;
                *start = '\0';
                start++;
                CLEAR_LEFT_SPACE(start);
                redir_filename = start;
                break;
            }
        }
        else if (*start == '<')
        {
            redir_type = IN_REDIR;
            *start = '\0';
            start++;
            CLEAR_LEFT_SPACE(start);
            redir_filename = start;
            break;
        }
        else
        {
            start++;
        }
    }
}

其中 CLEAR_LEFT_SPACE 宏用于跳过文件名前的空格:

#define CLEAR_LEFT_SPACE(pos) do { \
    while (isspace(*pos)) \
        pos++; \
} while(0)

11.3 在子进程中处理重定向

// 在子进程中,execvp 之前
if (redir_type == IN_REDIR)
{
    int fd = open(redir_filename, O_RDONLY);
    dup2(fd, 0);   // 将标准输入重定向到文件
}
else if (redir_type == OUT_REDIR)
{
    int fd = open(redir_filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
    dup2(fd, 1);   // 将标准输出重定向到文件
}
else if (redir_type == APP_REDIR)
{
    int fd = open(redir_filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
    dup2(fd, 1);   // 将标准输出追加重定向到文件
}

注意:在 ParseCommandLine 之前调用 Redir(),顺序不能错——先把重定向部分从字符串中剥离,剩下的部分再解析为 argv 表。


十二、完整的主循环

void Bash()
{
    static char *env[64];
    _environ = env;

    // 第0步:加载环境变量
    LoadEnv();

    while (1)
    {
        redir_type = NONE_REDIR;
        redir_filename = NULL;

        // 第1步:输出命令行提示符
        PrintPrompt();

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

        // 第2.1步:检查并处理重定向
        Redir();

        // 第3步:解析字符串
        ParseCommandLine();
        if (argc == 0)
            continue;  // 空输入,重新开始

        // 第4步:检查并执行内建命令
        if (CheckBuiltinAndExecute())
            continue;

        // 第5步:执行普通命令(fork + exec)
        Execute();
    }
}

十三、全景总结

通过这份 Demo,我们把之前学到的知识全部串了起来:

  1. 命令行提示符:用 getenv 从环境变量获取用户名、主机名、当前路径,用 fflush(stdout) 强制刷新输出缓冲
  2. 用户输入:用 fgets(而非 scanf)获取整行字符串,去掉末尾换行符
  3. 字符串解析:用 strtok 按空格切割,形成 argv 向量表。关键技巧——先自增再赋值,利用 strtok 返回 NULL 作为循环终止条件,同时自然地为 argv 表提供 NULL 结尾
  4. 命令执行fork 创建子进程 → 子进程调用 execvp 进行程序替换 → 父进程通过 waitpid 回收子进程并提取退出码
  5. 内建命令cdchdir 让 bash 自己切换路径;echo $? 由 bash 自己打印内部记录的 lastcodeenvexport 操作 bash 自己维护的环境变量表
  6. 环境变量:从系统 environ 拷贝一份到自己的 _environ 数组,所有环境变量操作(envexportecho $PATH)都变成对这张表的管理
  7. 重定向:通过 dup2 将标准输入/输出重定向到指定文件,结合 open 的不同 flag 支持输入重定向(<)、输出重定向(>)、追加重定向(>>

命令行解释器本质上就是一个进程——启动了就是进程,进程是个死循环,命令行字符串就是一个字符串,解析就是把字符串打散成 argv/argc,然后通过 fork + exec 完成命令执行。理解了这些,就能从编码和系统角度重新看待 shell 命令行原理。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐