手搓一个 Shell:从命令行原理到自主解释器
在myshell.c中定义全局变量,用static// 提示符相关// 与命令行相关每次解析前需要清空,因为上次解析的痕迹会残留(比如上次argc = 3,下次解析新命令如果不重新清零,argc就从 3 开始计数了)。命令行提示符:用getenv从环境变量获取用户名、主机名、当前路径,用强制刷新输出缓冲用户输入:用fgets(而非scanf)获取整行字符串,去掉末尾换行符字符串解析:用strtok

.
专栏:数据结构|Linux|C语言
路漫漫其修远兮,吾将上下而求索
文章目录

手搓一个 Shell:从命令行原理到自主解释器
一、前情回顾:从指令到进程控制
在正式编写自主 Shell 之前,我们先快速回顾一下之前推进的核心话题。
1.1 基础指令与权限
指令:经过约 20 节课的训练,同学们对 Linux 常见命令(ls、cd、pwd、touch、mkdir、rm 等)已经比较熟悉,后续还会不断使用,同时也会新增 make、gcc、gdb、yum、apt 等开发工具类指令。
权限:权限的本质是"人 + 事物属性"。人分为两类——角色和真实的人:
- 角色:拥有者(owner)、所属组(group)、other
- 真实的人:root 用户、你自己的账号。一个真实的账号(如
whb)对于某个文件来说,可能是拥有者,也可能是所属组,也可能什么都不是
设置权限时,一方面要把拥有者、所属组和 other 对应的权限与角色对应起来,另一方面要明确这个角色由哪种人承担(root 还是普通用户)。配套工具有 chown、chgrp、chmod。
三个子话题:
-
umask(权限掩码):普通文件创建时起始权限从 666 开始(无可执行),目录创建时起始权限从 777 开始。系统通过 umask 过滤掉不想要的权限,最终权限 = 起始权限 & (~umask)。所以我们看到新建文件的默认权限是 664、662 等,目录是 775、774 等。
-
目录权限三句话:
- 进入目录需要 x 权限
- 读目录内容需要 r 权限
- 新建/删除文件需要 w 权限
-
粘滞位(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 / Makefile:make 是个命令,Makefile 是个文件,里面写的是依赖关系和依赖方法。核心要求是能处理多文件编译。
版本控制(git):三板斧——git clone、git add、git commit、git push,附加命令有 git log、git status 等。
调试器(gdb/cgdb):推荐用 cgdb,至少可以可视化代码执行位置。三个实用技巧:
watch:监视变量,变量被修改时自动触发断点set var:在 debug 时直接修改变量值来验证结果- 条件断点:在打断点之前或对已有断点添加条件
1.3 进程概念
冯·诺依曼体系:
三句话概括:
- 存储器指的是内存。
- CPU 只跟内存打交道,不能直接访问外设(在数据层面上)。因为外设速度太慢,CPU 太快,直接与外设做数据沟通会拖慢整机速度。
- 程序运行前必须加载到内存——因为可执行程序是磁盘上的二进制文件(属于外设),CPU 要读取指令和数据只能从内存读,所以必须把数据从外设搬到内存。这是体系结构规定的,不是谁拍脑袋想的。
跨主机通信的数据流向:比如你在直播间看到老师的画面——老师的摄像头和话筒采集数据 → 经过打包/加密 → 写入内存 → 通过网卡发出 → 你的网卡接收 → 先搬到内存 → 软件解密/渲染 → 刷新到显示器。无论发代码、发图片、发音频,逻辑完全一样,因为体系结构这么规定。
操作系统:
狭义操作系统指 Linux 内核,广义包括内核 + 预装软件(shell、图形化界面、GCC、各种指令等)。操作系统是一款进行软硬件资源管理的软件。
深刻理解"管理":管理的本质不是管你这个人,而是管你身上的数据。比如教务系统采集你的学习数据(看了多少课、作业完成情况),助教老师根据这些数据对你做管理——这个过程他连你面都没见过。
面对大量被管理对象时,思维方式高度提炼为六个字:先描述,再组织。用 struct 结构体描述每个对象,然后用特定数据结构(链表、哈希表等)组织起来,最后把对人的管理转化成对数据结构的增删查改。这就是现实问题到计算机建模的过程。
操作系统管理进程也是一样:每个进程用一个 task_struct 结构体描述,所有进程节点用双链表连接起来,对进程的管理就变成对链表的操作。这种思维方式在后面学文件、学内存时同样适用。
系统调用和库函数:操作系统为了自身安全,不允许用户随便访问内核代码和数据,所以提供了系统调用接口。
进程 PCB:task_struct 结构体包含进程的所有管理信息。相关话题:查看进程、fork 创建进程、进程状态、优先级、进程切换与调度、环境变量、命令行参数、程序地址空间。
O(1) 调度算法:每个 CPU 有一个运行队列,内部维护两组队列——活跃队列(active queue)和过期队列(expired queue)。以活跃队列为例,内有 140 个优先级子队列(0-99 实时进程,100-139 分时系统,共 40 个优先级)。采用时间片轮转方式:进程从活跃队列调度,时间片用完放入过期队列,等活跃队列全部消耗完,交换两个指针(active ↔ expired),活跃变过期、过期变活跃,继续调度。算法设计非常精巧,值得学习。
命令行参数和环境变量:
- 命令行参数:
main函数的argv参数支持命令行选项功能。Linux 命令的选项功能本质上就是通过命令行参数完成的。 - 环境变量:系统预先创建的一组系统级变量,记录程序搜索路径(
PATH)、当前用户(USER)、当前工作路径(PWD)、家目录(HOME)、LS配色方案、历史命令上限、主机名(HOSTNAME)等信息。
程序地址空间(虚拟地址空间):
操作系统给每个进程画了一张"大饼"——32 位下每个进程都有 4GB 空间。每个进程都认为自己有 4GB 内存,可以统一规划代码区、数据区、堆区、栈区的位置。内核中用 mm_struct 结构体描述这个虚拟空间。
为什么要有虚拟地址空间?三个理由:
- 保护合法数据,防止进程越界访问
- 进程和内存管理模块完全解耦
- 在进程视角下内存分布整体有序
1.4 进程控制
进程终止:三种情况——
- 代码跑完,结果对(
main返回值 0) - 代码跑完,结果不对(
main返回非 0 退出码) - 出异常(收到异常信号,进程崩溃)
main 函数的返回值最终返回给父进程(bash),告诉 shell 子进程的执行结果对不对。以前写 return 0 但不清楚为什么——现在知道不仅仅是写 0,可以通过返回不同值区分执行结果。
进程等待:
是什么:父进程通过 wait 或 waitpid 回收子进程的僵尸状态,获取子进程的退出信息。
为什么必须等:
- 子进程退出时处于僵尸状态,要把退出信息维护起来(告诉父进程"我执行得怎么样")
- 不回收僵尸就会一直存在,造成内存泄漏——这是刚需
- 可选的收益:通过退出码和退出信号两个整数,辨别子进程的退出原因(退出码为 0 且信号为 0 = 正常;退出码非 0 但信号为 0 = 结果不对;信号不为 0 = 异常)
怎么办:通过 wait / waitpid 的参数读取退出信息。本质上,子进程退出时会把退出信息写在 PCB 里,获取退出信息就是访问 PCB——和 getpid()、getppid() 一样。
程序替换:
是什么:通过特定的系统调用接口,让当前进程加载磁盘上的全新程序,在不新创建进程、不大面积更改进程属性的前提下,用新程序的代码和数据覆盖当前进程的代码段和数据段,然后重新从新程序的 main 函数开始执行。进程 PID 不变。
为什么:需求驱动的。创建子进程有两种用途——要么执行父进程代码的一部分,要么执行一个全新的程序(如 ls、任务管理器等)。操作系统必须提供程序替换的接口。
怎么办:七个系统调用——execl、execlp、execle、execv、execvp、execve(实际是六个封装 + 一个真正的系统调用 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 步,继续等待输入
你在命令行输入 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 系统中都有对应的系统调用(获取用户名、gethostname、getcwd 等),但今天有一个更重要的工作要练习——环境变量。
用户名、主机名、当前工作路径这些信息,不就在环境变量里吗?USER、HOSTNAME、PWD 明晃晃地放在那里。所以今天不用系统调用(除非后面被逼急了),我们用 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 为什么要解析?
程序替换函数(如 execl、execv)的参数要么是一个个传,要么是以数组形式传。没人见过把命令和选项揉在一起作为一个字符串传的。
所以必须把 "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"
}
这段代码的精妙之处:
- 第一次
strtok(commandline, sep)切出"ls",argc变为 1 while循环中,先++argc(变为 2),strtok(NULL, sep)切出"-a",赋值给argv[2],地址非空,条件为真,继续循环- 再次
++argc(变为 3),strtok(NULL, sep)切出"-l",赋值给argv[3] - 再次
++argc(变为 4),strtok(NULL, sep)切完,返回NULL,赋值给argv[4],NULL为假,循环退出
结果:argv 以 NULL 结尾(argv[3] = NULL),完美满足 execv 系列函数对参数表的要求。
一个关于 argc 的小问题:按上面的逻辑,argc 最后是 4,而实际上我们只有 3 个参数(ls、-a、-l)。因为最后一次赋值 NULL 时 argc 也被加了一次。
解决方法:把 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)的方式共享。命令行解析工作由父进程完成,子进程不做修改,所以子进程能直接看到解析好的 argv 和 argc。
八、内建命令:让 bash 自己执行
8.1 问题发现:cd 命令失效
运行我们的 shell:pwd 正常,ls 正常,但 cd .. 之后再 pwd,路径没有变化。
原因:每个进程都有自己的当前工作路径。cd 命令是让子进程去执行的——子进程切换了自己的路径,然后子进程退出。父进程(bash)的路径根本没受影响!
所以 cd 这种命令不应该让子进程执行,而应该让 bash 自己执行。这类命令称为内建命令(built-in command)。
内建命令本质上是 bash 内部的一个函数。常见的内建命令包括 cd、echo、env、export 等。
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;
}
有了这份环境变量表,env、export、echo $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,我们把之前学到的知识全部串了起来:
- 命令行提示符:用
getenv从环境变量获取用户名、主机名、当前路径,用fflush(stdout)强制刷新输出缓冲 - 用户输入:用
fgets(而非scanf)获取整行字符串,去掉末尾换行符 - 字符串解析:用
strtok按空格切割,形成argv向量表。关键技巧——先自增再赋值,利用strtok返回NULL作为循环终止条件,同时自然地为argv表提供NULL结尾 - 命令执行:
fork创建子进程 → 子进程调用execvp进行程序替换 → 父进程通过waitpid回收子进程并提取退出码 - 内建命令:
cd用chdir让 bash 自己切换路径;echo $?由 bash 自己打印内部记录的lastcode;env和export操作 bash 自己维护的环境变量表 - 环境变量:从系统
environ拷贝一份到自己的_environ数组,所有环境变量操作(env、export、echo $PATH)都变成对这张表的管理 - 重定向:通过
dup2将标准输入/输出重定向到指定文件,结合open的不同 flag 支持输入重定向(<)、输出重定向(>)、追加重定向(>>)
命令行解释器本质上就是一个进程——启动了就是进程,进程是个死循环,命令行字符串就是一个字符串,解析就是把字符串打散成 argv/argc,然后通过 fork + exec 完成命令执行。理解了这些,就能从编码和系统角度重新看待 shell 命令行原理。
更多推荐

所有评论(0)