跳到主要内容Linux 系统编程实践:手动实现 Shell | 极客日志C
Linux 系统编程实践:手动实现 Shell
综述由AI生成通过 C 语言从零实现一个简易 Shell,涵盖命令行交互、字符串解析、内建命令(cd/export/echo)及普通命令执行(fork/exec/wait)。详细讲解了进程模型、环境变量管理、程序替换机制及内存清理,帮助深入理解 Linux 系统编程核心原理。
DebugKing8K 浏览 Linux 系统编程实践:手动实现 Shell
在学习 Linux 系统编程的过程中,Shell 是一个无法绕开的核心组件。它既是我们日常与操作系统交互最频繁的工具,也是理解 Linux 体系结构、进程模型、环境变量管理、程序加载与替换机制等关键知识点的窗口。
只有亲手实现一个 Shell,才能真正理解命令识别原理、普通命令与内建命令的区别、输入解析方式、exec 替换机制以及环境变量传递等核心概念。本文将带你从零开始,使用 C 语言与系统调用,手动实现一个具备基本功能的小型 Shell。
一、Shell 基础架构与 Makefile 工程
1. 创建工作目录与测试 Makefile 文件
项目结构如下,所有文件处于同一级目录。
Makefile 内容示例:
myshell:myshell.c
gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
rm -f myshell
单文件版本的 Makefile 极其简单,输入 make 指令,myShell 正确生成。
2. Shell 基础架构
Shell 程序本质是一个死循环,工作的基本流程如下:
- 打印提示符并读取输入
- 解析命令,对命令进行分割解析,并返回参数的个数
- 判断是否是内建命令:内建命令直接在父进程 Shell 内部执行
- 如果不是,fork 创建子进程执行外部命令
- 重复直到 quit 被置为 true
基础架构代码如下:
int main() {
while (!quit) {
}
return 0;
}
二、交互问题
1. 命令行格式定制化
命令行格式为:用户名@主机名:当前路径 命令行提示符 ($/#)
需要在终端中打印出相应的命令行格式。代码如下:
#define LEFT "{"
#define RIGHT "}"
#define LABEL_ROOT "#"
# LABEL_USER
quit = ;
pwd[LINE_SIZE];
* {
getenv();
}
* {
getenv();
}
{
getcwd(pwd, (pwd));
}
{
(!quit) {
getPwd();
((getUserName(), ) == )
(LEFT RIGHT LABEL_ROOT, getUserName(), getHostName(), pwd);
(LEFT RIGHT LABEL_USER, getUserName(), getHostName(), pwd);
}
;
}
define
"$"
int
0
#define LINE_SIZE 1024
char
const
char
getUserName
()
return
"USER"
const
char
getHostName
()
return
"HOSTNAME"
void
getPwd
()
sizeof
int
main
()
while
if
strcmp
"root"
0
printf
"%s@%s:%s"
else
printf
"%s@%s:%s"
return
0
PS:printf("aaa""bbb"); 输出的结果为 "aaabbb",原因是 C 语言中相邻的字符串具有自动连接特性。因此我们可以使用宏定义拼接多个字符串实现更灵活的格式化输出。
注意:如果操作系统环境变量表中没有 HOSTNAME 字段,可能需要硬编码。建议根据实际环境调整。
获取当前路径后将其存在全局变量 pwd 中,这是为后文实现内建命令 cd 做的准备。
2. 读取输入的命令
尝试使用 scanf() 函数对输入的指令进行读取,但无法实现。原因是 scanf 的输入结束规则由格式控制符决定,不是统一由空格或回车控制。Linux 指令以空格作为分隔符,而 scanf() 读取输入的字符串时,以空格、回车、Tab 作为单次输入的结束,因此不能使用 scanf() 函数读取命令。这里介绍使用 fgets 解决 scanf 的问题。
char* s:获取的内容暂存的缓冲区
int size:缓冲区的大小
FILE* stream:文件对象
C 语言程序中,会默认为我们打开三个输入输出流,我们直接从 stdin 读取键盘输入。
#define LINE_SIZE 1024
int quit = 0;
char commandLine[LINE_SIZE];
int main() {
while (!quit) {
getPwd();
char* str = fgets(commandLine, sizeof(commandLine), stdin);
assert(str);
printf("test:: %s", str);
}
return 0;
}
由于我们在输入时,最后总是会键入回车(换行符),而我们仅需要对字符串进行解析,无需对回车(换行符)进行解析,因此需要将换行符处理掉。
在 assert(str) 后添加一行代码:将末尾的换行符修改为 \0。
cmdLine[strlen(cmdLine)-1] = '\0';
3. 将以上逻辑抽象为 interact 交互函数
我们可以将以上逻辑抽象为交互 interact 函数,仅用于完成交互逻辑,这样有助于让 main 函数中的逻辑更加清晰。
void interact(char* cLine, int size) {
getPwd();
if (strcmp(getUserName(), "root") == 0)
printf(LEFT "%s@%s:%s" RIGHT LABEL_ROOT, getUserName(), getHostName(), pwd);
else
printf(LEFT "%s@%s:%s" RIGHT LABEL_USER, getUserName(), getHostName(), pwd);
char* str = fgets(cLine, size, stdin);
assert(str);
cLine[strlen(cLine)-1] = '\0';
}
int main() {
while (!quit) {
interact(commandLine, sizeof(commandLine));
}
return 0;
}
三、字符串解析问题
核心目标:将输入的字符串解析为多个子字符串,即解析为命令行参数表。如将 "ls -a -l" 解析为 "ls" "-a" "-l" "NULL"。
实现分割的方案有很多种,这里考虑使用 strtok 函数分割字符串。
strtok 简介:
char *strtok(char *str, const char *delim);
char *str:要分割的字符串
const char *delim:分隔符
- 返回分割出来的子串
- 该函数调用一次,只会截取出一个子串
如果想对同一个字符串连续做切割,第一次需要传入字符串,后面需要传入 NULL。
1. 字符串解析函数
字符串分割逻辑实现代码如下:实现一个函数,仅用于分割字符串,并返回分割出的字符串个数。
#define ARGC_SIZE 32
#define DELIM " \t"
int splitString(char* cLine, char* _argv[], int _max_args) {
if (_max_args <= 0) return 0;
int i = 0;
char* tok = strtok(cLine, DELIM);
while (tok != NULL && i < _max_args - 1) {
_argv[i++] = tok;
tok = strtok(NULL, DELIM);
}
_argv[i] = NULL;
return i;
}
2. 解析字符串后的执行逻辑
交互读取字符串后,分割后将其存储在自定义的命令行参数表中。
interact(commandLine, sizeof(commandLine));
int argc = splitString(commandLine, argv, ARGC_SIZE);
if (argc == 0) continue;
for (int i = 0; argv[i]; ++i)
printf("[%d]->: %s\n", i, argv[i]);
四、内建命令的执行
通过前面进程和变量的学习,我们知道,Linux 中的命令分为普通命令和内建命令。
- 普通命令:由 shell 执行 fork 创建一个子进程,通过进程程序替换,由子进程单独执行。
- 内建命令:执行时不创建子进程,而是在 shell 进程内部直接执行。
1. 命令执行总体框架
交互读取命令行输入后,分割出的字符串个数不为 0 时,才执行命令:
- 优先判断是否是内建命令:
int isBuild = buildExecute(argc, argv);
- 如果是内建命令:会在函数
buildExecute(argc, argv) 执行内建命令的逻辑
- 如果不内建命令:fork 出子进程,执行普通命令的逻辑
int isBuild = buildExecute(argc, argv);
if (!isBuild)
normalExecute(argv);
- 如果是内建命令:逻辑执行完毕后 return 1
- 如果不是内建命令:不执行内建命令,直接 return 0
这里关于内建命令的执行,仅实现以下三个内建命令:cd、export、echo。
其他内建命令读者可自行实现。
关于内建命令:内建命令的执行不能让子进程去执行,原因在于(以 cd 命令举例)子进程执行 cd,只会改变子进程的当前所处路径,cd 的效果是要改变父进程的当前所处路径,应该让父进程去执行 cd,因此不能用程序替换。
2. 内建命令 cd 的执行
char pwd[LINE_SIZE];
int buildExecute(int _argc, char* _argv[]) {
if (_argc <= 2 && strcmp(_argv[0], "cd") == 0) {
if (_argc == 1)
chdir(getenv("HOME"));
else
chdir(_argv[1]);
getPwd();
setenv("PWD", pwd, 1);
return 1;
}
return 0;
}
cd:只输入 cd 代表返回到当前用户的 home 目录,此时 argc 的值为 1
cd <dir>:cd 后面跟指定路径,代表切换到指定路径,此时 argc 的值为 2
综上:内建命令 cd 的 argc 个数 <= 2。
3. 内建命令 export 的执行
char* myenv[LINE_SIZE];
int buildExecute(int _argc, char* _argv[]) {
else if (_argc == 2 && strcmp(_argv[0], "export") == 0) {
for (int i = 0; i < LINE_SIZE; ++i) {
if (myenv[i] == NULL) {
myenv[i] = (char*)malloc(strlen(_argv[1]) + 1);
if (myenv[i] == NULL) {
perror("malloc fail");
return 1;
}
strcpy(myenv[i], _argv[1]);
if (putenv(myenv[i]) != 0)
perror("putenv fail\n");
break;
}
}
return 1;
}
return 0;
}
不能直接使用 putenv 的原因:putenv 只是把环境变量字符串的地址填入到系统环境变量表中。我们的每次通过 export 输入的环境变量暂存在 char commandLine[LINE_SIZE] 命令行中的,和其他输入的命令共用一块空间。输入其他命令时,会把之前的命令覆盖掉,环境变量也就消失了,因此要再单独存储一份环境变量,不能和命令公用一块空间。
4. 内建命令 echo 的执行
echo $?:获取上个子进程的退出码
echo $valKey:通过该操作获取环境变量的相应值
echo anyContent:原样输出字符串,去掉双引号
int lastCode = 0;
int buildExecute(int _argc, char* _argv[]) {
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0) {
if (strcmp(_argv[1], "?$") == 0) {
if (strcmp(_argv[1], "?$") == 0) { }
}
if (strcmp(_argv[1], "?$") == 0) {
printf("%d\n", lastCode);
lastCode = 0;
} else if (_argv[1][0] == '$') {
char* val = getenv(_argv[1] + 1);
if (val) printf("%s\n", val);
} else {
char* s = _argv[1];
int len = strlen(s);
if (len >= 2 && s[0] == '"' && s[len - 1] == '"') {
s[len - 1] = '\0';
s++;
}
printf("%s\n", s);
}
return 1;
}
return 0;
}
五、普通命令的执行
普通命令的执行由 shell 执行 fork 创建一个子进程,通过进程程序替换,由子进程单独执行普通命令。
int lastCode = 0;
void normalExecute(char* _argv[]) {
pid_t id = fork();
if (id < 0) {
perror("fork failed\n");
return;
}
else if (id == 0) {
execvp(_argv[0], _argv);
perror("execvp");
exit(EXIT_CODE);
}
else {
int status = 0;
pid_t retPid = waitpid(id, &status, 0);
if (retPid == id) {
lastCode = WEXITSTATUS(status);
}
}
}
- fork 创建子进程,判断子进程是否创建成功,id < 0 时子进程创建失败,进行错误处理。
- fork 函数在子进程中返回 0,通过 execvp 函数进行程序替换,传参执行普通命令。注意程序替换有可能失败,exec 系列函数没有成功返回值,只有失败返回值。
- fork 函数在父进程中返回子进程的 pid,父进程等待子进程执行完毕,执行完毕后获取子进程的退出状态以及退出码。
六、改进事项
-
进程替换出错时提示错误信息
exec 没有成功返回值,只有失败返回值,失败时使用 perror 提示错误信息。exit(EXIT_CODE) 使用指定的退出码退出。
-
父进程等待结束后保存子进程的退出码
保存子进程的退出码,方便通过 echo $? 获取执行上一次操作的退出码。
-
为 ls 命令加上颜色高亮显示
当输入的命令为 ls 时,向 ls 的参数表中添加一个选项 "--color",之后再将下一个位置置为 NULL 即可。
-
shell 进程结束后释放环境变量表避免内存泄漏
void destroyEnv() {
for (int i = 0; i < LINE_SIZE; ++i) {
if (myenv[i]) {
free(myenv[i]);
myenv[i] = NULL;
}
}
}
七、完整代码实现
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define LEFT "{"
#define RIGHT "}"
#define LABEL_ROOT "#"
#define LABEL_USER "$"
int quit = 0;
#define DELIM " \t"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define EXIT_CODE 16
extern char** environ;
char commandLine[LINE_SIZE];
int lastCode = 0;
char pwd[LINE_SIZE];
char* myenv[LINE_SIZE];
char myVal[LINE_SIZE];
const char* getUserName() { return getenv("USER"); }
const char* getHostName() { return getenv("HOSTNAME"); }
void getPwd() { getcwd(pwd, sizeof(pwd)); }
void interact(char* cLine, int size) {
getPwd();
if (strcmp(getUserName(), "root") == 0)
printf(LEFT "%s@%s:%s" RIGHT LABEL_ROOT, getUserName(), getHostName(), pwd);
else
printf(LEFT "%s@%s:%s" RIGHT LABEL_USER, getUserName(), getHostName(), pwd);
char* str = fgets(cLine, size, stdin);
assert(str);
cLine[strlen(cLine)-1] = '\0';
}
int splitString(char* cLine, char* _argv[], int _max_args) {
if (_max_args <= 0) return 0;
int i = 0;
char* tok = strtok(cLine, DELIM);
while (tok != NULL && i < _max_args - 1) {
_argv[i++] = tok;
tok = strtok(NULL, DELIM);
}
_argv[i] = NULL;
return i;
}
void normalExecute(char* _argv[]) {
pid_t id = fork();
if (id < 0) {
perror("fork failed\n");
return;
}
else if (id == 0) {
execvp(_argv[0], _argv);
perror("execvp");
exit(EXIT_CODE);
}
else {
int status = 0;
pid_t retPid = waitpid(id, &status, 0);
if (retPid == id) {
lastCode = WEXITSTATUS(status);
}
}
}
int buildExecute(int _argc, char* _argv[]) {
if (_argc <= 2 && strcmp(_argv[0], "cd") == 0) {
if (_argc == 1) chdir(getenv("HOME"));
else chdir(_argv[1]);
getPwd();
setenv("PWD", pwd, 1);
return 1;
}
else if (_argc == 2 && strcmp(_argv[0], "export") == 0) {
for (int i = 0; i < LINE_SIZE; ++i) {
if (myenv[i] == NULL) {
myenv[i] = (char*)malloc(strlen(_argv[1]) + 1);
if (myenv[i] == NULL) {
perror("malloc fail");
return 1;
}
strcpy(myenv[i], _argv[1]);
if (putenv(myenv[i]) != 0)
perror("putenv fail\n");
break;
}
}
return 1;
}
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0) {
if (strcmp(_argv[1], "?$") == 0) {
printf("%d\n", lastCode);
lastCode = 0;
} else if (_argv[1][0] == '$') {
char* val = getenv(_argv[1] + 1);
if (val) printf("%s\n", val);
} else {
char* s = _argv[1];
int len = strlen(s);
if (len >= 2 && s[0] == '"' && s[len - 1] == '"') {
s[len - 1] = '\0';
s++;
}
printf("%s\n", s);
}
return 1;
}
if (strcmp(_argv[0], "ls") == 0) {
_argv[_argc++] = "--color";
_argv[_argc] = NULL;
}
return 0;
}
void destroyEnv() {
for (int i = 0; i < LINE_SIZE; ++i) {
if (myenv[i]) {
free(myenv[i]);
myenv[i] = NULL;
}
}
}
int main() {
char* argv[ARGC_SIZE];
while (!quit) {
interact(commandLine, sizeof(commandLine));
int argc = splitString(commandLine, argv, ARGC_SIZE);
if (argc == 0) continue;
int isBuild = buildExecute(argc, argv);
if (!isBuild)
normalExecute(argv);
}
destroyEnv();
return 0;
}
结语
通过从零实现一个简单 Shell,我们不仅复现了 Linux 用户日常最熟悉的工具之一,也逐层揭开了其背后涉及的进程控制、程序替换、环境变量、文件系统与字符串解析机制。可以说,一个小小的 Shell,几乎串联起了 Linux 系统编程的整个知识体系。
在这次实践中,我们清晰地看到了 fork + exec 如何赋予 Linux 强大的进程模型,waitpid 如何保证父子进程协作与资源回收,环境变量表如何被继承、修改、扩展,内建命令为什么必须在父进程执行,字符串解析与参数传递如何影响命令的执行逻辑。
这些实现不仅让我们拥有了一个能真实工作的 Shell,更重要的是,它让整个 Linux 运行机制不再抽象,而是变得可触摸、可理解、可实验。
这仅仅是起点。你可以在这些基础上继续扩展:支持管道 |,实现重定向 <>>>,添加后台运行 &,支持环境变量展开、命令历史等,甚至构建自己的 mini bash。每一步都将让你更接近一个真正的系统开发者。
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online