1. 什么是 Shell
简单来说,Shell 是系统的外壳
它是一个特殊的程序,充当了用户 与操作系统内核之间的 "桥梁" 角色。鉴于内核(Kernel)的特殊与敏感性,不便直接接触用户,所以 Shell 负责接收用户输入的字符串命令,将其解释为操作系统能听懂的语言,并将结果反馈给用户
我们在终端里敲下的 ls、cd、mkdir,本质上都是在和 Shell 进行交互
2. Shell 的基本工作原理
要实现一个 Shell,首先要理解它的底层逻辑。其实,Shell 的生命周期就是一个死循环。其工作流程遵循着固定的模式:
核心执行流程
一个标准的命令行解释器,其运行逻辑可以拆解为以下五个步骤:
-
打印提示符:显示类似 [user@hostname dir]$ 的字符串,告知用户输入字符串指令
-
读取命令: 等待用户输入一行指令(例如 ls -l -a),并将其存入缓冲区
-
解析命令 : 将用户输入的长字符串拆解成命令和选项(例如将 "ls -l -a" 拆分为 "ls"、"-l"、
"-a" 三个独立的子串)
-
执行命令: 最核心的一步。对于绝大多数外部命令,Shell 会通过 fork 创建一个子进程,然后子进程利用 exec 族函数进行程序替换,去运行真正的指令程序
-
等待下一次输入: 父进程(Shell)调用 waitpid 等待子进程结束,回收资源。子进程退出后,Shell 重新回到第一步,继续循环
为什么 Shell 永不退出
你会发现,无论你执行多少次命令,Shell 依然在那里。除非你输入 exit 或按下 Ctrl+D,否则它不会消失
这是因为 Shell 本身就是一个 while(1) 的死循环。它并不直接执行 ls 或 pwd,而是 fork 子进程去执行。子进程崩了或者退出了,并不影响主进程 Shell。 这种结构极大地保证了交互界面的稳定性
3. 简易 Shell 的整体框架
在技术实现层面,简易 Shell 的整体框架通常基于以下 C 语言结构。该结构定义了程序从启动到进入常驻内存状态的完整路径:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#define MAX_CMD 1024 // 用户输入命令的最大长度
#define MAX_ARG 64 // 命令拆分后的最大参数个数
char command_line[MAX_CMD]; // 存储原始命令字符串的缓冲区
char *command_args[MAX_ARG]; // 存储解析后的参数指针数组
int lastcode = 0; // 用于读取上一次退出的退出码
int main() {
// 核心交互循环
while (1) {
// 1: 打印命令行提示符
PrintPrompt();
// 2: 获取用户输入的指令字符串
if (!GetCommand(command_line, sizeof(command_line))) {
continue;
}
// 3: 将长字符串拆分为参数数组
ParseCommand(command_line, command_args);
// 4: 判断是否为内建命令并执行
if (BuiltInCommandExec(command_args))
continue;
// 5: fork 子进程并执行指令
ExecuteExternal(command_args);
}
return 0;
}
关键模块
该框架的设计遵循了模块化原则,各部分界定如下:
-
PrintPrompt: 该模块负责获取当前系统的环境信息(如当前用户名、主机名及工作目录),并将其格式化输出至标准输出流。这是用户交互的起始点
-
GetCommand:从标准输入读取一行数据。此阶段需要处理用户直接输入换行符(空输入)的情况,以防止程序进入无效的逻辑分支
-
ParseCommand: 该过程涉及字符串的词法分析。程序需扫描 command_line 缓冲区,识别空格等分隔符,并将各个指令片段提取出来存入 command_args 指针数组。此步骤是后续调用 execvp 等函数的基础
-
进程控制流:
-
子进程阶段:调用 fork() 产生副本,并在子进程空间内使用 exec 函数族进行程序替换
-
父进程阶段:Shell 主进程通过 waitpid() 进入阻塞等待状态,直到子进程执行完毕并返回退出状态信息。
-
在运行期间,Shell 进程通过 while(1) 维持其生命周期。除 exec 调用失败或捕获到特定的终止信号(如 exit 命令)外,主进程将始终常驻于系统进程表中,确保交互的连续性
4. 提示符的设计与显示
命令行提示符(Command Prompt)是 Shell 提供给用户的第一个交互界面。功能完整的提示符通常包含当前用户名 、主机名 以及当前工作目录。在 Linux 系统中,获取这些信息主要有两种途径:读取环境变量和执行系统调用
获取系统信息的途径
1 用户名与环境变量
在进程启动时,内核会为其分配一套环境变量。其中,变量 USER 存储了当前登录用户的名称。通过 C 标准库提供的 getenv() 函数,程序可以访问这些环境信息
-
实现方式:char *user = getenv("USER")
-
也可以通过系统调用 getuid() 获取用户 ID,再利用 getpwuid() 函数从系统用户数据库中检索对应的用户名
2 主机名与系统调用
主机名通常不存储在常规的环境变量中,而是通过专门的系统调用 gethostname() 获取
-
函数原型:int gethostname(char *name, size_t len)
-
机制:调用该接口后,内核会将当前系统的主机名字符串拷贝至指定的缓冲区 name 中
3 当前工作目录
获取当前进程的工作路径有两种常用方法:
-
getcwd()系统调用:直接从内核获取进程当前关联的绝对路径
-
getenv("PWD"):读取由 Shell 维护的路径环境变量
需要注意的是,为了模拟真实 Shell 的行为,通常会将路径中的家目录部分(如 /home/username)替换为波浪号 ~
实现代码
cpp
// 1. 获取用户名
const char* GetUserName() {
const char *user = getenv("USER");
return (user != NULL) ? user : "unknown";
}
// 2. 获取主机名
void GetHostName(char *buffer, size_t len) {
if (gethostname(buffer, len) != 0) {
snprintf(buffer, len, "unknown");
}
}
// 3. 获取当前目录
void GetCurrentDir(char *buffer, size_t len) {
if (getcwd(buffer, len) == NULL) {
snprintf(buffer, len, "unknown");
}
}
// 4. 统一调用
void PrintPrompt() {
char hostname[HOST_NAME_MAX];
char cwd[PATH_MAX];
// 调用功能函数
const char *user = GetUserName();
GetHostName(hostname, sizeof(hostname));
GetCurrentDir(cwd, sizeof(cwd));
printf("[%s@%s %s]$ ", user, hostname, cwd);
// 强制刷新标准输出
fflush(stdout);
}
在输出提示符时,必须显式调用 fflush(stdout)。由于标准输出在连接至终端时通常采用行缓冲策略,而提示符末尾通常不包含换行符 \n,因此数据可能会滞留在用户态缓冲区中。调用 fflush 可确保在程序进入阻塞等待输入状态前,提示符已完整渲染至屏幕
5. 命令读取与解析
在提示符显示完成后,Shell 必须进入阻塞状态以等待用户输入。这一阶段涉及两个核心步骤:从标准输入流捕获原始字符串,以及将该字符串规约为可执行的参数序列。
关键函数简介
在 C 语言系统编程中,fgets 和 strtok 是处理命令行输入的常用工具
fgets 函数
fgets 用于从指定的流中读取一行数据,直到遇到换行符、文件结束符或读取了特定长度的字符
-
原型:char *fgets(char *s, int size, FILE *stream)
-
特性:与 scanf 不同,fgets 会读取空格并将其视为字符串的一部分。需要注意的是,如果缓冲区空间足够,fgets 会将输入末尾的换行符 \n 一并读入缓冲区
strtok 函数
strtok 用于根据指定的分隔符集将字符串拆分为一系列子串
-
原型:char *strtok(char *str, const char *delim)
-
机制:在首次调用时,需传入待解析的字符串 str;在随后的调用中,首参数需传入 NULL,以便函数从上一次拆分停止的位置继续扫描。该函数会修改原始字符串,将匹配到的分隔符替换为字符串结束符 \0
命令读取的实现
读取命令时,除了调用 fgets,还必须处理用户直接输入回车产生的换行符。如果不移除该换行符,后续执行程序替换时,文件名将包含不可见的 \n,导致执行失败
cpp
int GetCommand(char *buf, int size) {
// 从标准输入读取一行
if (fgets(buf, size, stdin) == NULL) {
return 0; // 读取失败或遇到 EOF
}
// 处理换行符:将 \n 替换为 \0
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') {
buf[len - 1] = '\0';
}
// 检查是否为空输入
if (strlen(buf) == 0) return 0;
return 1;
}
命令解析的实现
解析过程的核心是将连续的命令字符串拆分为参数指针数组。例如,将字符串 "ls -l -a" 转化为 {"ls", "-l", "-a", NULL}。这种格式是 execvp 等系统调用所要求的标准形式
cpp
#define DELIM " " // 定义分隔符为空格
void ParseCommand(char *buf, char **args) {
int i = 0;
// 第一次调用 strtok
args[i++] = strtok(buf, DELIM);
// 循环调用以获取后续参数
while ((args[i++] = strtok(NULL, DELIM))) {
// 如果达到最大参数限制则停止
if (i >= MAX_ARG - 1) break;
}
// 数组末尾必须以 NULL 结尾,符合 exec 族函数的要求
args[i] = NULL;
}
-
安全性:在解析过程中,必须确保指针数组 args 不会发生越界。并且将解析后的数组最后一个元素强制设为 NULL
-
多空格处理:strtok 会自动跳过连续的多个分隔符,因此用户在输入命令时多打几个空格不会影响解析结果的准确性
6. 命令执行
在 Shell 的解析阶段完成后,我们需要将解析出的参数数组交付给内核执行。对于外部命令(如 ls、top、pwd 等),Shell 并不是直接调用函数执行,而是通过 fork()、exec() 和 waitpid() 来协同完成
为什么需要 fork()
Shell 本身是一个持续运行的进程。如果直接在当前进程中执行程序换,那么新程序的代码将覆盖 Shell 自身的代码,导致 Shell 在执行完一条指令后就彻底终止。
-
隔离性:通过 fork() 产生子进程,Shell 可以将执行任务的职责下放给子进程
-
安全性:子进程即使因为执行非法操作(如段错误)而崩溃,也不会影响到作为父进程的 Shell 交互界面
为什么需要 exec()
子进程被创建后,其代码和数据与父进程基本一致。为了执行磁盘上的可执行程序,必须使用 exec 函数族进行程序替换
为什么需要 waitpid()
当子进程去执行指令时,Shell 进程必须进入等待状态
-
同步机制:确保 Shell 在子进程完成任务前不会抢先打印新的提示符,从而维持交互的有序性
-
资源回收:收集子进程的退出状态,防止其退化为僵尸进程,确保系统资源的循环利用
在实现该功能时,我们通常选择 execvp 函数,因为它能够根据 PATH 环境变量自动搜索可执行文件,且接收数组形式的参数
cpp
void ExecuteExternal(char **args) {
// 1. 创建子进程
pid_t id = fork();
if (id < 0) {
perror("fork error");
exit(1);
}
else if (id == 0) {
// 2. 子进程程序替换
// 第一个参数是文件名,第二个是参数数组
execvp(args[0], args);
// 如果 execvp 返回,说明替换失败
perror("command not found");
exit(127);
}
else {
// 3. 父进程阻塞等待
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0) {
if (WIFEXITED(status)) lastcode = WEXITSTATUS(status));
else lastcode = 1; // 异常退出设置为 1
}
}
}
7. 内建命令
内建命令是 Shell 解释器程序内部的一个功能模块。当 Shell 解析到用户输入的命令属于内建命令时,它不会创建子进程,而是直接在当前的 Shell 进程空间内调用相应的函数或系统调用
1. 为什么有些命令必须内建
在 Shell 的设计架构中,内建命令并非为了优化执行效率而存在的加速方案,而是基于进程独立性原则下的一种技术必然
1. 外部命令执行的局限性
前文提到,执行外部命令的标准流程是 fork 子进程。在 Linux 内核的实现逻辑中,子进程虽然在初始时刻通过写时拷贝 机制共享父进程的物理内存,但它拥有独立的虚拟地址空间 和进程上下文
这意味着,子进程对自身运行环境(如工作目录、环境变量等)所做的任何修改,均被严格限制在子进程的 task_struct 结构体及其关联的内存映射中。一旦子进程任务完成并退出,这些修改将随着进程空间的销毁而消失,无法对父进程产生任何持久性影响
2. cd 命令
cd指令的核心职能是修改进程的当前工作目录。我们可以通过以下逻辑理解 cd 无法作为外部命令存在:
-
子进程派生:当用户输入 cd /home/user 时,如果 Shell 调用 fork 产生子进程
-
环境修改:子进程在其内部执行系统调用 chdir("/home/user")。此时,子进程的 CWD 属性成功更新为目标路径
-
进程退出:子进程执行完毕并退出
-
状态:父进程从 waitpid 中唤醒。由于父进程的 task_struct 及工作目录信息从未被修改,Shell 依然停留在执行命令前的原目录
结论 :如果 cd 是外部命令,它只能改变子进程的路径,而无法改变作为用户交互界面的 Shell 本身的路径
基于上述逻辑,所有在改变 Shell 自身状态 或配置进程上下文的指令,都必须在 Shell 进程内部直接调用系统调用,而不经过 fork 流程
除了 cd 之外,以下场景同样要求指令必须内建:
-
环境变量操作(如 export, unset):环境变量存储在进程的地址空间中。子进程设置的环境变量无法逆向作用于父进程
-
作业控制(如 fg, bg, jobs):这些操作涉及对 Shell 内部维护的任务列表进行增删改查
-
进程终止(如 exit):该指令必须直接触发 Shell 进程的退出逻辑
2. 常见内建命令的实现
在 Shell 的主循环中,在执行 fork 之前,需要先对解析出的参数进行匹配检查。如果匹配到内建命令,则直接跳转执行相关逻辑,并跳过后续的流程
以下是三个典型内建命令的具体实现方案:
cd 指令实现
cd 的核心是调用封装了内核操作的 chdir 系统调用
cpp
int DoCd(char **args) {
char *target_path = NULL;
char current_path[1024];
// 获取当前目录
getcwd(current_path, sizeof(current_path));
// 情况 A: cd 或 cd ~ -> 去家目录
if (args[1] == NULL || strcmp(args[1], "~") == 0) {
target_path = getenv("HOME");
}
// 情况 B: cd - -> 回到上一次目录
else if (strcmp(args[1], "-") == 0) {
target_path = getenv("OLDPWD");
}
// 情况 C: 普通路径
else {
target_path = args[1];
}
// 执行跳转
if (chdir(target_path) == 0) {
// 跳转成功后更新环境变量 OLDPWD
// 将跳转前的路径存入 OLDPWD
setenv("OLDPWD", current_path, 1);
} else {
perror("cd");
}
return 1;
}
echo 指令实现
虽然系统中通常存在 /bin/echo 外部程序,但为了效率和处理特定的环境变量(如 ?,HOME),Shell 往往会内置 echo
cpp
void DoEcho(char **args) {
if (args[1] == NULL) {
printf("\n");
return;
}
for (int i = 1; args[i] != NULL; i++) {
char *item = args[i];
if (item[0] == '$') {
if (strcmp(item, "$?") == 0) {
// 打印退出码
printf("%d ", lastcode);
} else {
// 打印环境变量
char *env = getenv(item + 1); // 跳过 $
printf("%s ", env);
}
} else {
printf("%s ", item);
}
}
printf("\n");
lastcode = 0;
}
exit 指令实现
exit 用于终止当前 Shell 的生命周期
cpp
int DoExit(char **args) {
// 可以在此处进行资源清理或保存历史记录
printf("Shell exited.\n");
exit(0);
return 1;
}
统一调用
将内建命令实现后我们在处理函数中统一调用即可
cpp
int BuiltInCommandExec(char **args) {
if (args[0] == NULL) return 0;
if (strcmp(args[0], "cd") == 0) {
DoCd(args);
return 1;
}
else if (strcmp(args[0], "echo") == 0) {
DoEcho(args);
return 1;
}
else if (strcmp(args[0], "exit") == 0) {
DoExit(args);
return 1;
}
return 0;
}
8. 总结
综上所述,Shell 本质上是一个基于进程控制机制构建的命令解释器。从提示符的输出、命令的读取与解析,到 fork 创建子进程、exec 完成程序替换,再到使用 waitpid 进行进程回收,这一完整流程将前面所学习的进程控制知识真正串联起来,转化为一个可运行的实际系统
与此同时,内建命令的实现进一步说明,并非所有命令都可以通过外部程序完成,一些涉及 Shell 自身状态的操作必须由其内部直接处理
通过这一简单 Shell 的实现,我们验证了进程控制相关接口的作用,为后续实现更复杂的功能(重定向、管道等)打下了基础
