
观众老爷们大家好 这里是邪修KING的独家频道
本文属于系列Linux系统篇 ——操作指令
一起学Linux的小伙伴可订阅专栏: Linux系统篇
上一篇我们讲透了 exec 全家桶 6 个函数的用法和原理。而 fork + exec 最经典、最贴近我们日常使用的应用,就是 Shell 命令行解释器。 我们每天敲的 bash、zsh,底层核心逻辑其实非常朴素:打印提示符 → 读命令 → 拆参数 → fork 子进程 → exec 替换执行 → 父进程等待回收,循环往复。 本篇我们就通过一份完整的 C++ 迷你 Shell 代码,逐模块拆解每个函数的作用,重点讲清楚 exec 在其中的角色,彻底搞懂 Shell 到底是怎么跑起来的。
一、迷你 Shell 的整体工作流程
在看代码之前,先把核心流程印在脑子里,整个 Shell 就是一个无限循环,每一轮做 4 件事:
💡 核心本质: Shell 本身从来不执行具体命令,它只做 “解析 + 调度”。真正跑命令的,是 fork 出来的子进程,通过 exec 替换成系统里的真实程序(ls、cat、gcc 等)。
二、代码逐模块教学拆解
2.1 头文件与全局定义
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define COMMAND_SIZE 1024 // 命令行最大长度
#define FORMAT "[%s@%s %s]# " // 提示符格式
#define MAXARG 128 // 最大参数个数
char* g_argv[MAXARG]; // 全局:解析后的参数数组
int g_argc = 0; // 全局:参数个数
各部分作用
表格
| unistd.h | 提供 fork、execvp、getpid 等系统调用 |
| sys/wait.h | 提供 waitpid 进程回收函数 |
| sys/types.h | 提供 pid_t 等进程类型定义 |
- g_argv / g_argc:全局变量,用来存放解析后的命令参数,直接传给 exec 使用。
2.2 辅助信息函数:拼装提示符用
这一组函数都是为了生成和 bash 类似的提示符,从环境变量和系统信息里取出用户名、主机名、当前目录。
获取用户名
const char* GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
- 作用:从环境变量 USER 里取出当前登录用户名
- 对应知识点:getenv 获取环境变量,就是上一篇讲的标准获取方式
获取主机名
const char* GetHostName()
{
const char *hostname = getenv("HOSTNAME");
return hostname == NULL ? "None" : hostname;
}
- 作用:取出当前主机名,提示符里 @ 后面的部分
获取当前目录 + 截取最后一级
const char* GetPwd()
{
const char *pwd = getenv("PWD");
return pwd == NULL ? "None" : pwd;
}
std::string DirName(const char *pwd)
{
std::string dir = pwd;
auto pos = dir.rfind("/");
if(pos == std::string::npos) return "BUG";
return dir.substr(pos+1);
}
- 作用:取出当前工作目录,并且只截取最后一级目录名,提示符更简洁。
- 对应知识点:进程的工作目录记录在环境变量 PWD 中。
2.3 生成并打印提示符
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT,
GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout); // 强制刷新输出,确保提示符立刻显示
}
- 作用:按照格式组装成 [user@host dir]# 样式的提示符,打印到屏幕上。
- 细节:fflush(stdout) 很重要,否则提示符可能不会立刻显示出来。
2.4 读取用户输入
bool GetCommandLine(char *out, int size)
{
char *c = fgets(out, size, stdin);
if(c == NULL) return false;
// 去掉末尾的换行符
if(out[strlen(out)–1] == '\\n') out[strlen(out)–1] = 0;
if(strlen(out) == 0) return false;
return true;
}
- 作用:从标准输入读取一整行用户命令
- 细节处理:去掉 fgets 带进来的末尾换行符;空行直接返回不处理。
2.5 命令解析:把一行拆成参数数组
这一步是为 exec 做准备 ——exec 需要的是 argv[] 参数数组,所以要把一整行字符串按空格切开。
#define SEP " "
bool CommandParse(char *commandline)
{
g_argc = 0;
g_argv[g_argc++] = strtok(commandline, SEP);
while( (g_argv[g_argc++] = strtok(NULL, SEP)) );
g_argc—;
return true;
}
- 作用:用 strtok 按空格切割字符串,填充全局 g_argv 数组。
- 结果:比如输入 ls -l -a,会被切成 g_argv[0]="ls"、g_argv[1]="-l"、g_argv[2]="-a"、结尾 NULL。
- 为什么要拆成数组?因为 exec 系列的 v 版本(execvp、execv),就是接收数组形式的参数,刚好完美匹配。
2.6 核心执行函数:fork + execvp(重点)
这是整个 Shell 最核心的部分,直接对应我们上一篇讲的 fork 创建进程 + exec 程序替换 + wait 回收 完整流程。
int Execute()
{
pid_t id = fork();
if(id == 0)
{
// ========== 子进程:程序替换 ==========
execvp(g_argv[0], g_argv);
// 能走到这里,说明 exec 失败了
exit(1);
}
else
{
// ========== 父进程:等待子进程退出 ==========
pid_t rid = waitpid(id, nullptr, 0);
(void)rid;
}
return 0;
}
逐行讲透
① 为什么用 fork?
Shell 本身不能被替换。如果直接在主进程 exec,命令跑完 Shell 就没了。所以必须 fork 出子进程,让子进程去替换执行,父进程保留,继续等待下一条命令。
② 为什么选 execvp?对应上一篇的知识点
我们上一篇讲了 6 个 exec 函数,这里选 execvp 是最贴合 Shell 场景的:
- v 后缀:参数用数组传递。我们已经把命令解析成 g_argv 数组了,直接传进去就行,非常方便。
- p 后缀:自动在 PATH 环境变量里搜索可执行文件。用户输入 ls 不用写 /usr/bin/ls,自动能找到,和真实 Shell 行为完全一致。
③ exec 成功不返回的特性
execvp 下面的 exit(1),只有 exec 失败的时候才会执行。 只要替换成功,子进程就开始跑新程序的代码,原来的代码彻底消失,永远走不到这一行。这完全呼应上一篇讲的 exec 返回值特性。
④ 父进程 waitpid
父进程阻塞等待子进程执行完毕,回收子进程的 PCB,避免产生僵尸进程。对应我们之前讲的僵尸进程回收知识点。
2.7 主循环:Shell 的永生循环
int main()
{
while(true)
{
// 1. 打印命令提示符
PrintCommandPrompt();
// 2. 获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
// 3. 命令行解析
CommandParse(commandline);
// 4. 执行命令
Execute();
}
return 0;
}
- 整个 Shell 就是一个死循环,一轮接一轮地 “提示 → 读 → 解析 → 执行 → 等”。
- 这就是为什么 Shell 不会跑完一条命令就退出,它会一直等你输入下一条。
三、编译运行与实际效果
3.1 配套 Makefile
g++ -o myshell myshell.cc -std=c++11
.PHONY:clean
clean:
rm -f myshell
3.2 编译运行
make
./myshell
3.3 运行效果
启动后会显示提示符,输入命令和正常 bash 几乎一样:
[ubuntu@VM-0-16-ubuntu lesson17]# ls -l
总用量 xx
-rw-rw-r– ... Makefile
-rwxrwxr-x ... myshell
-rw-rw-r– ... myshell.cc
[ubuntu@VM-0-16-ubuntu lesson17]# pwd
/home/ubuntu/113/lesson17
[ubuntu@VM-0-16-ubuntu lesson17]# clear
所有系统命令都能正常执行,本质就是:子进程被替换成了系统里对应的真实程序。
四、原理总结与拓展说明
4.1 核心结论
4.2 这个迷你 Shell 的简化点
这是教学演示版本,重点突出进程替换原理,省略了很多高级功能:
- ❌ 不支持管道 |、重定向 >
- ❌ 没有内置命令(比如 cd、exit 这些需要 Shell 自己执行,不能 fork 执行)
- ❌ 没有信号处理、Tab 补全、历史命令
- ✅ 完整保留了 Shell 最核心的 fork + exec + wait 机制
4.3 对应前面的知识点串联
表格
| fork() 创建子进程 | 进程创建、写时拷贝 |
| execvp() 执行替换 | exec 全家桶、v 数组传参、p 自动搜 PATH |
| exec 成功不返回 | exec 返回值特性:失败才返回 |
| waitpid() 回收 | 僵尸进程、进程等待回收 |
| getenv("USER") | 环境变量获取 |
全文总结
手写迷你 Shell 是对「进程创建 + 程序替换 + 进程等待」三个核心知识点的最好综合实践。 看似复杂的命令行解释器,底层其实就是不断重复 fork → exec → wait 的循环。理解了这个,你就彻底懂了 Shell 的本质,也把前面三篇的进程知识点串成了完整的体系。
完整代码
完整可运行代码:myshell.cc(每接口带作用备注)
// ==============================================
// 头文件引入
// ==============================================
#include <iostream>
#include <cstdio> // C标准IO:printf、fgets、snprintf
#include <cstring> // C字符串:strtok、strlen
#include <cstdlib> // 标准库:getenv、exit
#include <unistd.h> // 系统调用:fork、execvp、getpid
#include <sys/types.h> // 系统类型:pid_t
#include <sys/wait.h> // 进程等待:waitpid
// ==============================================
// 宏定义
// ==============================================
#define COMMAND_SIZE 1024 // 命令行最大输入长度
#define FORMAT "[%s@%s %s]# " // 提示符格式:[用户名@主机名 目录]#
#define MAXARG 128 // 命令参数最大个数
#define SEP " " // 命令参数分隔符:空格
// ==============================================
// 全局变量
// 作用:存放解析后的命令参数数组,直接供 execvp 使用
// ==============================================
char* g_argv[MAXARG]; // 参数指针数组,结尾以NULL结束
int g_argc = 0; // 参数个数
// ==============================================
// 函数:GetUserName
// 作用:从环境变量中获取当前登录用户名
// 备注:使用标准库 getenv 读取 USER 环境变量
// ==============================================
const char* GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
// ==============================================
// 函数:GetHostName
// 作用:从环境变量中获取当前主机名
// 备注:读取 HOSTNAME 环境变量
// ==============================================
const char* GetHostName()
{
const char *hostname = getenv("HOSTNAME");
return hostname == NULL ? "None" : hostname;
}
// ==============================================
// 函数:GetPwd
// 作用:从环境变量中获取当前工作目录完整路径
// 备注:读取 PWD 环境变量,进程工作目录由内核维护并同步到环境变量
// ==============================================
const char* GetPwd()
{
const char *pwd = getenv("PWD");
return pwd == NULL ? "None" : pwd;
}
// ==============================================
// 函数:DirName
// 作用:截取完整路径的最后一级目录名
// 备注:让提示符更简洁,只显示当前所在目录名而非全路径
// ==============================================
std::string DirName(const char *pwd)
{
std::string dir = pwd;
// 从右往左找第一个斜杠
auto pos = dir.rfind("/");
if(pos == std::string::npos) return "BUG";
// 截取斜杠后面的部分
return dir.substr(pos+1);
}
// ==============================================
// 函数:MakeCommandLine
// 作用:按照格式拼装命令提示符字符串
// 备注:将用户名、主机名、当前目录填入格式模板
// ==============================================
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT,
GetUserName(),
GetHostName(),
DirName(GetPwd()).c_str());
}
// ==============================================
// 函数:PrintCommandPrompt
// 作用:打印命令提示符到屏幕
// 备注:必须 fflush 刷新输出缓冲区,否则提示符可能延迟显示
// ==============================================
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout); // 强制刷新标准输出
}
// ==============================================
// 函数:GetCommandLine
// 作用:从标准输入读取用户输入的一整行命令
// 返回:读取成功返回true,失败/空行返回false
// 备注:去掉fgets自带的末尾换行符
// ==============================================
bool GetCommandLine(char *out, int size)
{
char *c = fgets(out, size, stdin);
if(c == NULL) return false;
// 去掉末尾的换行符
if(out[strlen(out)–1] == '\\n')
out[strlen(out)–1] = '\\0';
// 空行不处理
if(strlen(out) == 0) return false;
return true;
}
// ==============================================
// 函数:CommandParse
// 作用:命令行解析,按空格拆分字符串,填充全局参数数组 g_argv
// 备注:拆分后的格式完全匹配 execvp 的参数要求,结尾自动带NULL
// ==============================================
bool CommandParse(char *commandline)
{
g_argc = 0;
// 第一次拆分,取出第一个参数(命令名)
g_argv[g_argc++] = strtok(commandline, SEP);
// 循环拆分后续所有参数,直到返回NULL
while( (g_argv[g_argc++] = strtok(NULL, SEP)) );
g_argc—; // 减去最后一次NULL导致的计数+1
return true;
}
// ==============================================
// 函数:Execute【核心函数】
// 作用:fork创建子进程 + execvp程序替换 + waitpid父进程回收
// 核心流程:
// 1. 父进程fork出子进程
// 2. 子进程调用execvp,把自己替换成用户输入的命令程序
// 3. 父进程阻塞等待子进程执行完毕,回收PCB避免僵尸进程
// ==============================================
int Execute()
{
// 创建子进程
pid_t id = fork();
if(id == 0)
{
// ========== 子进程分支 ==========
// 作用:执行程序替换,用目标命令程序覆盖当前子进程的代码数据
// 参数1:命令名(自动去PATH环境变量搜索)
// 参数2:解析好的参数数组
// 备注:execvp成功则永不返回,直接跑新程序;只有失败才会返回-1
execvp(g_argv[0], g_argv);
// ===== 能走到这里,说明exec执行失败 =====
perror("命令执行失败");
exit(1);
}
else if(id > 0)
{
// ========== 父进程分支 ==========
// 作用:阻塞等待指定子进程退出,回收子进程PCB,防止僵尸进程
// 参数1:等待的子进程PID
// 参数2:接收退出状态,这里传nullptr不关心
pid_t rid = waitpid(id, nullptr, 0);
(void)rid; // 避免未使用变量警告
}
else
{
// fork失败
perror("fork失败");
return –1;
}
return 0;
}
// ==============================================
// 主函数:Shell 主循环
// 整体流程:打印提示符 → 读命令 → 解析 → 执行 → 等待 → 循环
// ==============================================
int main()
{
// Shell 是死循环,不断等待用户输入命令
while(true)
{
// 1. 打印命令提示符
PrintCommandPrompt();
// 2. 读取用户输入的命令行
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue; // 空行跳过
// 3. 命令行解析,拆分出参数数组
CommandParse(commandline);
// 4. 执行命令(fork+exec+wait)
Execute();
}
return 0;
}
配套 Makefile
# 编译器
CC = g++
# 编译选项:开启警告、C++11标准
CFLAGS = -Wall -std=c++11
# 目标可执行文件
TARGET = myshell
# 源文件
SRC = myshell.cc
# 默认目标:编译生成
all: $(TARGET)
$(TARGET): $(SRC)
$(CC) $(CFLAGS) $< -o $@
# 清理编译产物
.PHONY: clean
clean:
rm -f $(TARGET)
编译运行
# 编译
make
# 启动迷你Shell
./myshell



