【Linux系统编程】(十八)手搓 Linux Shell 命令行解释器:从 0 到 1 打造属于你的终端神器
本文详细介绍了如何从零开始实现一个简易的Linux Shell命令行解释器。文章首先解释了Shell的本质和工作原理,将其比作用户与内核之间的"翻译官"。然后分步骤实现了Shell的核心功能:通过主循环框架实现命令读取与解析,利用fork/exec/wait机制执行外部命令,并添加了对内置命令(cd/exit/pwd)的支持。进阶部分实现了后台执行功能(&符号)和SIG
目录
1.2 Shell 的核心工作流程:一个无限循环的 “命令处理机器”
二、从零开始:实现一个最基础的 Shell(支持外部命令执行)
2.3 步骤 3:实现外部命令执行(fork+exec+wait)
3.2 实战代码:实现 cd、exit、pwd 内置命令(my_shell_v4.c)
4.1 实战代码:添加后台执行支持(my_shell_v5.c)
前言
在 Linux 的日常使用中,我们每天都在和 bash、zsh 这些 Shell 命令行解释器打交道。当你在终端输入
ls -l、pwd、git clone这些命令时,是否曾好奇过:背后的 Shell 是如何 “读懂” 你的输入,又是如何将命令转化为实际的系统操作的?其实,Shell 命令行解释器并非什么神秘的 “黑盒”,它的核心工作流程清晰易懂,我们完全可以凭借对 Linux 进程控制(fork/exec/wait)、字符串处理、终端交互的理解,亲手打造一个属于自己的简易 Shell。
本文将从 Shell 的核心工作原理入手,一步步拆解命令行解释器的实现细节,从最基础的命令读取与解析,到支持内置命令、后台执行、重定向等高级功能,最终完成一个可实际运行的自主 Shell 命令行解释器。全程结合 C 语言实战代码,语言生动易懂,逻辑层层递进,即使你是 Linux 编程新手,也能跟着本文一步步实现属于自己的终端神器。下面就让我们正式开始吧!
一、前置认知:什么是 Shell?它到底在做什么?
1.1 Shell 的本质:用户与内核之间的 “翻译官”
Linux 内核是操作系统的核心,负责管理硬件资源、进程调度、内存管理等底层工作,但内核并不直接与用户交互 —— 用户无法直接向内核发送命令,而 Shell 就是连接用户与内核的 “桥梁” 和 “翻译官”。
简单来说,Shell 是一个命令行解释器,它接收用户输入的命令行,对命令进行解析和处理,最终通过系统调用或启动子进程的方式,让内核执行对应的操作,并将执行结果反馈给用户。
我们可以用一个生动的类比来理解:
- 内核就像一家公司的 CEO,掌握着公司的所有核心资源和决策权,不直接对接普通员工(用户);
- Shell 就像 CEO 的秘书,负责接收普通员工的请求(用户输入的命令),将请求整理、翻译为 CEO 能理解的语言(系统调用、子进程执行),再将 CEO 的处理结果(命令执行结果)反馈给员工。
1.2 Shell 的核心工作流程:一个无限循环的 “命令处理机器”
无论是 bash 还是我们将要实现的简易 Shell,其核心工作流程都是一个无限循环,这个循环可以概括为 “读取→解析→执行→反馈” 四个步骤,直到用户输入退出命令(如exit)才会终止循环。
完整工作流程拆解
- 读取(Read):打印命令行提示符(如
[my_shell]$),接收用户从终端输入的命令行字符串(如ls -l /home);- 解析(Parse):对读取到的命令行字符串进行处理,包括去除前后空格、拆分命令与参数、识别特殊符号(如
&后台执行、>重定向、|管道);- 执行(Execute):根据解析结果执行命令,分为两种情况:
- 内置命令(如
cd、exit、pwd):直接在 Shell 进程自身中执行,无需创建子进程;- 外部命令(如
ls、cat、gcc):通过fork创建子进程,再通过exec函数族替换子进程程序,执行对应的外部命令,最后通过wait/waitpid等待子进程执行完成(后台执行除外);- 反馈(Feedback):将命令执行结果(正常输出或错误信息)打印到终端,然后回到循环开头,等待用户输入下一条命令。
可视化流程示意图
1.3 实现自主 Shell 的核心技术栈
要完成一个可运行的 Shell 命令行解释器,我们需要用到以下几方面的技术知识,这些都是 Linux C 编程的核心内容:
- 终端 I/O 操作:使用
fgets、printf等函数读取用户输入、打印输出,处理终端缓冲区、换行符等问题;- 字符串处理:使用
strtok、strcpy、strcmp、memmove等函数实现命令行的拆分、修剪、匹配;- 进程控制:
fork创建子进程、exec函数族实现程序替换、wait/waitpid实现进程等待,处理僵尸进程;- 内置命令实现:直接在 Shell 进程中实现
cd、exit、pwd等命令,调用对应的系统调用(如chdir实现cd);- 特殊功能支持(进阶):处理
&后台执行、>/<重定向、|管道等特殊符号,涉及文件描述符操作(open、dup2、close)。
二、从零开始:实现一个最基础的 Shell(支持外部命令执行)
我们先从最简单的版本入手,实现一个具备 “读取→解析→执行外部命令” 核心功能的基础 Shell,暂不考虑内置命令、后台执行等高级功能,先搭建起 Shell 的整体框架。
2.1 步骤 1:搭建 Shell 的主循环框架
Shell 的核心是一个无限循环,我们先实现这个循环,包括打印提示符、读取用户输入、处理空命令等基础功能。
实战代码(my_shell_v1.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MAX_CMD_LEN 1024 // 定义命令行的最大长度,防止缓冲区溢出
// 函数声明:修剪字符串前后的空格和换行符
void trim_cmd(char *cmd);
int main(void)
{
char cmd_buf[MAX_CMD_LEN]; // 存储用户输入的命令行缓冲区
// Shell主循环:无限循环,直到用户输入exit退出
while (1)
{
// 1. 打印命令行提示符,格式类似 [my_shell] $
printf("[my_shell] $ ");
fflush(stdout); // 强制刷新标准输出缓冲区,确保提示符立即显示
// 2. 读取用户输入的命令行
if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
{
// 读取失败(如Ctrl+D),直接退出循环
perror("fgets read cmd failed");
break;
}
// 3. 处理读取到的命令行:修剪前后空格和换行符
trim_cmd(cmd_buf);
// 4. 处理空命令(用户仅输入空格或回车)
if (strlen(cmd_buf) == 0)
{
continue;
}
// 5. 后续步骤:解析命令、执行命令(后续补充)
printf("你输入的命令是:%s\n", cmd_buf);
}
printf("my_shell exit successfully\n");
return 0;
}
// 辅助函数:修剪字符串前后的空格、制表符、换行符
void trim_cmd(char *cmd)
{
if (cmd == NULL || strlen(cmd) == 0)
{
return;
}
// 步骤1:修剪字符串开头的空白字符(空格、制表符)
int start = 0;
while (cmd[start] == ' ' || cmd[start] == '\t' || cmd[start] == '\n')
{
start++;
}
// 步骤2:修剪字符串结尾的空白字符(空格、制表符、换行符)
int end = strlen(cmd) - 1;
while (end >= start && (cmd[end] == ' ' || cmd[end] == '\t' || cmd[end] == '\n'))
{
end--;
}
// 步骤3:将修剪后的字符串重新拷贝到原缓冲区,添加字符串结束符
if (start > end)
{
// 全是空白字符,置为空字符串
cmd[0] = '\0';
}
else
{
memmove(cmd, cmd + start, end - start + 1);
cmd[end - start + 1] = '\0';
}
}
代码编译与执行
# 编译C语言代码
gcc my_shell_v1.c -o my_shell_v1
# 执行编译后的Shell程序
./my_shell_v1
执行效果
[my_shell] $
你输入的命令是:
[my_shell] $ ls -l
你输入的命令是:ls -l
[my_shell] $ pwd
你输入的命令是:pwd
[my_shell] $ ^C
核心知识点解析
fflush(stdout)的作用:标准输出(stdout)是行缓冲的,只有遇到换行符\n或缓冲区满时才会刷新输出。我们的提示符没有换行符,因此需要用fflush(stdout)强制刷新缓冲区,确保提示符能立即显示在终端上。fgets的注意事项:fgets会读取用户输入的换行符\n并存储在缓冲区中,因此需要通过trim_cmd函数去除换行符,否则后续解析命令会出现问题。- 缓冲区大小限制:定义
MAX_CMD_LEN限制命令行长度,防止用户输入过长命令导致缓冲区溢出,提高程序安全性。
2.2 步骤 2:实现命令行解析功能(拆分命令与参数)
用户输入的命令行是一个完整的字符串(如ls -l /home),我们需要将其拆分为命令名和参数列表(如{"ls", "-l", "/home", NULL}),以便后续传递给exec函数族执行。
这里我们使用strtok函数实现字符串拆分,strtok可以按照指定的分隔符(这里是空格、制表符)拆分字符串,返回拆分后的子字符串指针。
实战代码(补充解析功能,my_shell_v2.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MAX_CMD_LEN 1024 // 命令行最大长度
#define MAX_ARG_NUM 64 // 命令参数的最大个数
// 函数声明
void trim_cmd(char *cmd);
int parse_cmd(char *cmd, char *argv[]);
int main(void)
{
char cmd_buf[MAX_CMD_LEN];
char *argv[MAX_ARG_NUM]; // 存储命令参数的数组,argv[0]是命令名
while (1)
{
// 1. 打印提示符
printf("[my_shell] $ ");
fflush(stdout);
// 2. 读取用户输入
if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
{
perror("fgets read cmd failed");
break;
}
// 3. 修剪命令行
trim_cmd(cmd_buf);
if (strlen(cmd_buf) == 0)
{
continue;
}
// 4. 解析命令行,拆分命令与参数
int arg_count = parse_cmd(cmd_buf, argv);
if (arg_count == 0)
{
continue;
}
// 5. 打印解析结果(测试用,后续替换为执行逻辑)
printf("解析结果:命令名=%s,参数个数=%d\n", argv[0], arg_count);
printf("参数列表:");
for (int i = 0; i < arg_count; i++)
{
printf("%s ", argv[i]);
}
printf("\n");
}
printf("my_shell exit successfully\n");
return 0;
}
// 辅助函数:修剪字符串前后的空白字符
void trim_cmd(char *cmd)
{
if (cmd == NULL || strlen(cmd) == 0)
{
return;
}
int start = 0;
while (cmd[start] == ' ' || cmd[start] == '\t' || cmd[start] == '\n')
{
start++;
}
int end = strlen(cmd) - 1;
while (end >= start && (cmd[end] == ' ' || cmd[end] == '\t' || cmd[end] == '\n'))
{
end--;
}
if (start > end)
{
cmd[0] = '\0';
}
else
{
memmove(cmd, cmd + start, end - start + 1);
cmd[end - start + 1] = '\0';
}
}
// 核心函数:解析命令行,拆分命令与参数
// 参数:cmd-修剪后的命令行字符串,argv-存储参数的数组
// 返回值:参数的个数
int parse_cmd(char *cmd, char *argv[])
{
if (cmd == NULL || argv == NULL || strlen(cmd) == 0)
{
return 0;
}
int arg_count = 0; // 记录参数个数
// 第一次调用strtok,传入命令行字符串和分隔符
char *token = strtok(cmd, " \t"); // 以空格和制表符作为分隔符
while (token != NULL && arg_count < MAX_ARG_NUM - 1)
{
argv[arg_count++] = token; // 将拆分后的子字符串存入argv数组
token = strtok(NULL, " \t"); // 后续调用strtok,第一个参数传NULL
}
argv[arg_count] = NULL; // 关键:argv数组必须以NULL结尾,供exec函数使用
return arg_count;
}
代码编译与执行
gcc my_shell_v2.c -o my_shell_v2
./my_shell_v2
执行效果
[my_shell] $ ls -l /home
解析结果:命令名=ls,参数个数=3
参数列表:ls -l /home
[my_shell] $ pwd
解析结果:命令名=pwd,参数个数=1
参数列表:pwd
核心知识点解析
strtok函数的使用:strtok是字符串拆分的核心函数,第一次调用时传入要拆分的字符串和分隔符,后续调用时第一个参数传NULL,表示继续拆分上一次的字符串。需要注意的是,strtok会修改原字符串,将分隔符替换为\0。argv数组的格式要求:exec函数族要求参数数组argv必须以NULL结尾,这是因为exec函数需要通过NULL来判断参数列表的结束,否则会出现未知错误。- 参数个数限制:定义
MAX_ARG_NUM限制参数个数,防止用户输入过多参数导致argv数组溢出,提高程序的健壮性。
2.3 步骤 3:实现外部命令执行(fork+exec+wait)
解析完命令与参数后,我们需要实现外部命令的执行逻辑。核心思路是:通过fork创建子进程,在子进程中调用execvp函数执行外部命令,在父进程中调用waitpid等待子进程执行完成,回收子进程资源,避免僵尸进程。

实战代码(补充执行功能,my_shell_v3.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#define MAX_CMD_LEN 1024 // 命令行最大长度
#define MAX_ARG_NUM 64 // 命令参数的最大个数
// 函数声明
void trim_cmd(char *cmd);
int parse_cmd(char *cmd, char *argv[]);
int main(void)
{
char cmd_buf[MAX_CMD_LEN];
char *argv[MAX_ARG_NUM];
while (1)
{
// 1. 打印提示符
printf("[my_shell] $ ");
fflush(stdout);
// 2. 读取用户输入
if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
{
perror("fgets read cmd failed");
break;
}
// 3. 修剪命令行
trim_cmd(cmd_buf);
if (strlen(cmd_buf) == 0)
{
continue;
}
// 4. 解析命令行
int arg_count = parse_cmd(cmd_buf, argv);
if (arg_count == 0)
{
continue;
}
// 5. 执行外部命令(fork+exec+wait)
pid_t pid = fork(); // 创建子进程
if (pid == -1)
{
// fork创建子进程失败
perror("fork create child process failed");
continue;
}
else if (pid == 0)
{
// 子进程:执行外部命令
execvp(argv[0], argv);
// 若execvp返回,说明执行失败(命令不存在、权限不足等)
perror("execvp execute command failed");
exit(EXIT_FAILURE); // 子进程执行失败,退出并返回非0状态码
}
else
{
// 父进程(Shell进程):等待子进程执行完成
int status;
pid_t ret_pid = waitpid(pid, &status, 0); // 阻塞等待子进程
if (ret_pid == -1)
{
perror("waitpid wait child process failed");
continue;
}
// 可选:打印子进程的退出状态(调试用)
printf("子进程(PID:%d)执行完成,退出状态码:%d\n", pid, WEXITSTATUS(status));
}
}
printf("my_shell exit successfully\n");
return 0;
}
// 辅助函数:修剪字符串前后的空白字符
void trim_cmd(char *cmd)
{
if (cmd == NULL || strlen(cmd) == 0)
{
return;
}
int start = 0;
while (cmd[start] == ' ' || cmd[start] == '\t' || cmd[start] == '\n')
{
start++;
}
int end = strlen(cmd) - 1;
while (end >= start && (cmd[end] == ' ' || cmd[end] == '\t' || cmd[end] == '\n'))
{
end--;
}
if (start > end)
{
cmd[0] = '\0';
}
else
{
memmove(cmd, cmd + start, end - start + 1);
cmd[end - start + 1] = '\0';
}
}
// 核心函数:解析命令行,拆分命令与参数
int parse_cmd(char *cmd, char *argv[])
{
if (cmd == NULL || argv == NULL || strlen(cmd) == 0)
{
return 0;
}
int arg_count = 0;
char *token = strtok(cmd, " \t");
while (token != NULL && arg_count < MAX_ARG_NUM - 1)
{
argv[arg_count++] = token;
token = strtok(NULL, " \t");
}
argv[arg_count] = NULL;
return arg_count;
}
代码编译与执行
gcc my_shell_v3.c -o my_shell_v3
./my_shell_v3
执行效果
[my_shell] $ ls -l
总用量 24
-rwxr-xr-x 1 root root 8944 1月 17 15:30 my_shell_v3
-rw-r--r-- 1 root root 2568 1月 17 15:29 my_shell_v3.c
-rwxr-xr-x 1 root root 8832 1月 17 14:50 my_shell_v2
-rw-r--r-- 1 root root 1876 1月 17 14:49 my_shell_v2.c
-rwxr-xr-x 1 root root 8704 1月 17 14:20 my_shell_v1
-rw-r--r-- 1 root root 1024 1月 17 14:19 my_shell_v1.c
子进程(PID:12345)执行完成,退出状态码:0
[my_shell] $ pwd
/root/my_shell
子进程(PID:12346)执行完成,退出状态码:0
[my_shell] $ whoami
root
子进程(PID:12347)执行完成,退出状态码:0
核心知识点解析
execvp函数的优势:我们选择execvp函数执行外部命令,因为它支持通过PATH环境变量查找命令,无需用户输入命令的完整路径(如ls而不是/bin/ls),这与 bash 的行为一致,提升了 Shell 的易用性。- 进程等待的必要性:父进程(Shell)通过
waitpid阻塞等待子进程执行完成,不仅可以回收子进程资源,避免僵尸进程,还可以获取子进程的退出状态码,了解命令的执行结果(0 表示成功,非 0 表示失败)。WEXITSTATUS宏的使用:waitpid的status参数是一个位图,WEXITSTATUS宏用于提取子进程的正常退出状态码,只有当子进程正常退出时(WIFEXITED(status)为真),该宏的返回值才有效。
三、功能升级:添加内置命令支持(cd、exit、pwd)
在基础 Shell 中,我们可以执行ls、pwd等外部命令,但当我们尝试执行cd命令时,会发现无法正常工作:
[my_shell] $ cd /home
子进程(PID:12348)执行完成,退出状态码:0
[my_shell] $ pwd
/root/my_shell
这是因为cd命令是内置命令,它需要修改 Shell 进程自身的当前工作目录,而如果通过fork创建子进程执行cd,只会修改子进程的工作目录,子进程退出后,父进程(Shell)的工作目录不会发生任何变化。
因此,对于内置命令,我们需要在 Shell 进程自身中直接执行,无需创建子进程。
3.1 内置命令的特点与实现思路
内置命令的核心特点
- 直接在 Shell 进程中执行,不创建子进程;
- 通常用于修改 Shell 的自身状态(如
cd修改工作目录、alias设置别名)或执行简单的辅助功能(如exit退出 Shell、echo打印输出);- 执行速度快,无需进行进程创建和程序替换的开销。
常见内置命令与对应的系统调用
| 内置命令 | 功能描述 | 对应的系统调用 / 实现方法 |
|---|---|---|
exit |
退出 Shell | 直接终止主循环,调用exit函数 |
cd |
切换工作目录 | 调用chdir系统调用 |
pwd |
打印当前工作目录 | 调用getcwd系统调用 |
echo |
打印指定内容 | 直接使用printf函数 |
实现思路
- 在解析命令后,先判断命令是否为内置命令;
- 如果是内置命令,调用对应的实现函数执行,执行完成后回到主循环;
- 如果不是内置命令,再执行
fork+exec+wait的外部命令执行逻辑。
3.2 实战代码:实现 cd、exit、pwd 内置命令(my_shell_v4.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <limits.h>
#define MAX_CMD_LEN 1024 // 命令行最大长度
#define MAX_ARG_NUM 64 // 命令参数的最大个数
// 函数声明
void trim_cmd(char *cmd);
int parse_cmd(char *cmd, char *argv[]);
int execute_builtin_cmd(char *argv[]); // 执行内置命令
int main(void)
{
char cmd_buf[MAX_CMD_LEN];
char *argv[MAX_ARG_NUM];
while (1)
{
// 1. 打印提示符
printf("[my_shell] $ ");
fflush(stdout);
// 2. 读取用户输入
if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
{
perror("fgets read cmd failed");
break;
}
// 3. 修剪命令行
trim_cmd(cmd_buf);
if (strlen(cmd_buf) == 0)
{
continue;
}
// 4. 解析命令行
int arg_count = parse_cmd(cmd_buf, argv);
if (arg_count == 0)
{
continue;
}
// 5. 先判断是否为内置命令,若是则直接执行
if (execute_builtin_cmd(argv))
{
continue;
}
// 6. 非内置命令,执行外部命令(fork+exec+wait)
pid_t pid = fork();
if (pid == -1)
{
perror("fork create child process failed");
continue;
}
else if (pid == 0)
{
execvp(argv[0], argv);
perror("execvp execute command failed");
exit(EXIT_FAILURE);
}
else
{
int status;
pid_t ret_pid = waitpid(pid, &status, 0);
if (ret_pid == -1)
{
perror("waitpid wait child process failed");
continue;
}
}
}
printf("my_shell exit successfully\n");
return 0;
}
// 辅助函数:修剪字符串前后的空白字符
void trim_cmd(char *cmd)
{
if (cmd == NULL || strlen(cmd) == 0)
{
return;
}
int start = 0;
while (cmd[start] == ' ' || cmd[start] == '\t' || cmd[start] == '\n')
{
start++;
}
int end = strlen(cmd) - 1;
while (end >= start && (cmd[end] == ' ' || cmd[end] == '\t' || cmd[end] == '\n'))
{
end--;
}
if (start > end)
{
cmd[0] = '\0';
}
else
{
memmove(cmd, cmd + start, end - start + 1);
cmd[end - start + 1] = '\0';
}
}
// 核心函数:解析命令行,拆分命令与参数
int parse_cmd(char *cmd, char *argv[])
{
if (cmd == NULL || argv == NULL || strlen(cmd) == 0)
{
return 0;
}
int arg_count = 0;
char *token = strtok(cmd, " \t");
while (token != NULL && arg_count < MAX_ARG_NUM - 1)
{
argv[arg_count++] = token;
token = strtok(NULL, " \t");
}
argv[arg_count] = NULL;
return arg_count;
}
// 核心函数:执行内置命令
// 返回值:1-是内置命令并执行完成,0-不是内置命令
int execute_builtin_cmd(char *argv[])
{
if (argv == NULL || argv[0] == NULL)
{
return 0;
}
// 1. 实现exit命令:退出Shell
if (strcmp(argv[0], "exit") == 0)
{
printf("Goodbye! my_shell exit now...\n");
exit(EXIT_SUCCESS); // 终止Shell进程
}
// 2. 实现pwd命令:打印当前工作目录
if (strcmp(argv[0], "pwd") == 0)
{
char cwd[PATH_MAX]; // PATH_MAX定义在limits.h中,是系统最大路径长度
if (getcwd(cwd, sizeof(cwd)) != NULL)
{
printf("%s\n", cwd);
}
else
{
perror("getcwd get current working directory failed");
}
return 1;
}
// 3. 实现cd命令:切换工作目录
if (strcmp(argv[0], "cd") == 0)
{
// 获取目标目录:cd后无参数,默认切换到用户主目录(HOME环境变量)
char *target_dir = argv[1];
if (target_dir == NULL)
{
target_dir = getenv("HOME"); // 获取HOME环境变量
if (target_dir == NULL)
{
fprintf(stderr, "cd: no HOME environment variable found\n");
return 1;
}
}
// 调用chdir系统调用切换工作目录
if (chdir(target_dir) == -1)
{
perror("cd: change directory failed");
}
return 1;
}
// 4. 后续可扩展其他内置命令:echo、alias等
// if (strcmp(argv[0], "echo") == 0) {...}
// 不是内置命令,返回0
return 0;
}
代码编译与执行
gcc my_shell_v4.c -o my_shell_v4
./my_shell_v4
执行效果
[my_shell] $ pwd
/root/my_shell
[my_shell] $ cd /home
[my_shell] $ pwd
/home
[my_shell] $ cd
[my_shell] $ pwd
/root
[my_shell] $ exit
Goodbye! my_shell exit now...
my_shell exit successfully
核心知识点解析
cd命令的实现:
- 调用
chdir系统调用切换工作目录,chdir的参数是目标目录的路径,成功返回 0,失败返回 - 1;- 处理
cd无参数的情况:默认切换到用户的主目录,通过getenv("HOME")获取HOME环境变量的值,这与 bash 的行为一致。pwd命令的实现:调用getcwd系统调用获取当前工作目录,getcwd会将当前工作目录的路径存入指定的缓冲区,成功返回缓冲区指针,失败返回 NULL。exit命令的实现:直接调用exit(EXIT_SUCCESS)终止 Shell 进程,结束主循环,实现 Shell 的正常退出。- 内置命令的判断逻辑:在执行外部命令之前,先调用
execute_builtin_cmd函数判断是否为内置命令,若是则直接执行并返回 1,跳过外部命令的执行逻辑;若不是则返回 0,继续执行外部命令。
四、进阶功能:支持后台执行(& 符号)
在 bash 中,我们可以在命令末尾添加&符号,让命令在后台执行,此时 Shell 不会阻塞等待命令执行完成,而是立即返回提示符,允许用户输入下一条命令。例如:
[bash] $ sleep 10 &
[1] 12349
我们的简易 Shell 也可以实现这个功能,核心思路是:
- 解析命令行时,判断命令末尾是否有
&符号;- 若有
&符号,将其从参数列表中移除,并标记为后台执行;- 执行外部命令时,后台执行无需调用
waitpid阻塞等待,直接返回主循环;- 为了避免后台进程退出后成为僵尸进程,需要在 Shell 中处理子进程的退出信号(
SIGCHLD)。
4.1 实战代码:添加后台执行支持(my_shell_v5.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <limits.h>
#include <signal.h>
#define MAX_CMD_LEN 1024 // 命令行最大长度
#define MAX_ARG_NUM 64 // 命令参数的最大个数
// 函数声明
void trim_cmd(char *cmd);
int parse_cmd(char *cmd, char *argv[], int *is_background);
int execute_builtin_cmd(char *argv[]);
void sigchld_handler(int signo); // 处理SIGCHLD信号,回收后台子进程
int main(void)
{
char cmd_buf[MAX_CMD_LEN];
char *argv[MAX_ARG_NUM];
int is_background; // 标记是否为后台执行:1-后台执行,0-前台执行
// 注册SIGCHLD信号处理函数,回收后台子进程,避免僵尸进程
if (signal(SIGCHLD, sigchld_handler) == SIG_ERR)
{
perror("signal register SIGCHLD handler failed");
exit(EXIT_FAILURE);
}
while (1)
{
// 1. 打印提示符
printf("[my_shell] $ ");
fflush(stdout);
// 2. 读取用户输入
if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
{
perror("fgets read cmd failed");
break;
}
// 3. 修剪命令行
trim_cmd(cmd_buf);
if (strlen(cmd_buf) == 0)
{
continue;
}
// 4. 解析命令行(新增:判断是否为后台执行)
is_background = 0;
int arg_count = parse_cmd(cmd_buf, argv, &is_background);
if (arg_count == 0)
{
continue;
}
// 5. 执行内置命令
if (execute_builtin_cmd(argv))
{
continue;
}
// 6. 执行外部命令(支持前台/后台执行)
pid_t pid = fork();
if (pid == -1)
{
perror("fork create child process failed");
continue;
}
else if (pid == 0)
{
// 子进程:执行外部命令
execvp(argv[0], argv);
perror("execvp execute command failed");
exit(EXIT_FAILURE);
}
else
{
// 父进程(Shell进程):根据是否后台执行,决定是否等待
if (is_background)
{
// 后台执行:打印后台进程信息,不阻塞等待
printf("后台进程启动,PID:%d\n", pid);
}
else
{
// 前台执行:阻塞等待子进程执行完成
int status;
pid_t ret_pid = waitpid(pid, &status, 0);
if (ret_pid == -1)
{
perror("waitpid wait child process failed");
continue;
}
}
}
}
printf("my_shell exit successfully\n");
return 0;
}
// 辅助函数:修剪字符串前后的空白字符
void trim_cmd(char *cmd)
{
if (cmd == NULL || strlen(cmd) == 0)
{
return;
}
int start = 0;
while (cmd[start] == ' ' || cmd[start] == '\t' || cmd[start] == '\n')
{
start++;
}
int end = strlen(cmd) - 1;
while (end >= start && (cmd[end] == ' ' || cmd[end] == '\t' || cmd[end] == '\n'))
{
end--;
}
if (start > end)
{
cmd[0] = '\0';
}
else
{
memmove(cmd, cmd + start, end - start + 1);
cmd[end - start + 1] = '\0';
}
}
// 核心函数:解析命令行,拆分命令与参数(新增:判断后台执行)
// 参数:is_background-输出型参数,1-后台执行,0-前台执行
int parse_cmd(char *cmd, char *argv[], int *is_background)
{
if (cmd == NULL || argv == NULL || is_background == NULL || strlen(cmd) == 0)
{
return 0;
}
int arg_count = 0;
char *token = strtok(cmd, " \t");
while (token != NULL && arg_count < MAX_ARG_NUM - 1)
{
argv[arg_count++] = token;
token = strtok(NULL, " \t");
}
argv[arg_count] = NULL;
// 判断是否为后台执行:最后一个参数是否为&
if (arg_count > 0 && strcmp(argv[arg_count - 1], "&") == 0)
{
*is_background = 1;
argv[arg_count - 1] = NULL; // 移除&符号,避免传递给exec函数
arg_count--; // 参数个数减1
}
else
{
*is_background = 0;
}
return arg_count;
}
// 核心函数:执行内置命令
int execute_builtin_cmd(char *argv[])
{
if (argv == NULL || argv[0] == NULL)
{
return 0;
}
// 1. 实现exit命令
if (strcmp(argv[0], "exit") == 0)
{
printf("Goodbye! my_shell exit now...\n");
exit(EXIT_SUCCESS);
}
// 2. 实现pwd命令
if (strcmp(argv[0], "pwd") == 0)
{
char cwd[PATH_MAX];
if (getcwd(cwd, sizeof(cwd)) != NULL)
{
printf("%s\n", cwd);
}
else
{
perror("getcwd get current working directory failed");
}
return 1;
}
// 3. 实现cd命令
if (strcmp(argv[0], "cd") == 0)
{
char *target_dir = argv[1];
if (target_dir == NULL)
{
target_dir = getenv("HOME");
if (target_dir == NULL)
{
fprintf(stderr, "cd: no HOME environment variable found\n");
return 1;
}
}
if (chdir(target_dir) == -1)
{
perror("cd: change directory failed");
}
return 1;
}
// 不是内置命令,返回0
return 0;
}
// 信号处理函数:处理SIGCHLD信号,回收后台子进程(避免僵尸进程)
void sigchld_handler(int signo)
{
(void)signo; // 消除未使用参数的警告
// 循环调用waitpid,回收所有已退出的子进程(非阻塞模式)
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
{
printf("后台进程(PID:%d)执行完成,已自动回收\n", pid);
}
}
代码编译与执行
gcc my_shell_v5.c -o my_shell_v5
./my_shell_v5
执行效果
[my_shell] $ sleep 10 &
后台进程启动,PID:12350
[my_shell] $ pwd
/root/my_shell
[my_shell] $ ls -l
总用量 32
-rwxr-xr-x 1 root root 9152 1月 17 16:30 my_shell_v5
-rw-r--r-- 1 root root 3876 1月 17 16:29 my_shell_v5.c
...(其他文件列表)
后台进程(PID:12350)执行完成,已自动回收
[my_shell] $ exit
Goodbye! my_shell exit now...
核心知识点解析
- 后台执行的判断逻辑:在
parse_cmd函数中,判断最后一个参数是否为&,若是则标记is_background为 1,并将&从参数列表中移除(避免传递给exec函数导致命令执行失败)。SIGCHLD信号处理:当子进程退出时,内核会向父进程发送SIGCHLD信号。我们注册sigchld_handler信号处理函数,在函数中通过waitpid(-1, &status, WNOHANG)非阻塞地回收所有已退出的子进程,避免后台进程成为僵尸进程。waitpid的非阻塞模式:waitpid的第三个参数设为WNOHANG时,若没有已退出的子进程,会立即返回 0,不会阻塞父进程。使用循环调用waitpid,可以确保回收所有已退出的子进程。- 后台进程的提示信息:前台执行时,父进程阻塞等待子进程;后台执行时,父进程不阻塞,直接打印后台进程的 PID,让用户了解后台进程的状态。
总结
这个 Shell 虽然简单,但已经具备了 bash 的核心工作流程,能够满足基本的终端使用需求,让我们深刻理解了 Shell 命令行解释器的底层工作原理。
打造自主 Shell 的过程,是一个将 Linux C 编程知识融会贯通的过程。通过这个过程,我们不仅掌握了 Shell 的工作原理,还加深了对进程控制、字符串处理、信号处理等核心知识点的理解,为后续开发更复杂的 Linux 应用程序打下了坚实的基础。
如果你在实现过程中遇到了问题,或者有更好的功能扩展思路,欢迎在评论区留言讨论!
更多推荐




所有评论(0)