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() {
// shell 本质是一个死循环
while (!quit) {
// 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
// 2. 对输入的命令进行命令行解析,拆分为 指令名 + 选项
// 3. 判断是否是内建命令,内建命令调用 Shell 内部的函数执行
// 4. 非内建命令,fork 出子进程,执行命令
}
return 0;
}
二、交互问题
1. 命令行格式定制化
命令行格式为:用户名@主机名:当前路径 命令行提示符 ($/#)
需要在终端中打印出相应的命令行格式。代码如下:
#define LEFT "{"
#define RIGHT "}"
#define LABEL_ROOT "#"
#define LABEL_USER "$"
int quit = 0;
#define LINE_SIZE 1024
char pwd[LINE_SIZE]; // 存储当前路径
const char* getUserName() {
return getenv("USER");
}
const char* getHostName() {
return getenv("HOSTNAME");
}
void getPwd() {
getcwd(pwd, sizeof(pwd));
}
int main() {
while (!quit) {
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);
// ...
}
return 0;
}
PS:
printf("aaa""bbb");输出的结果为"aaabbb",原因是 C 语言中相邻的字符串具有自动连接特性。因此我们可以使用宏定义拼接多个字符串实现更灵活的格式化输出。
注意:如果操作系统环境变量表中没有 HOSTNAME 字段,可能需要硬编码。建议根据实际环境调整。
获取当前路径后将其存在全局变量 pwd 中,这是为后文实现内建命令 cd 做的准备。
2. 读取输入的命令
尝试使用 scanf() 函数对输入的指令进行读取,但无法实现。原因是 scanf 的输入结束规则由格式控制符决定,不是统一由空格或回车控制。Linux 指令以空格作为分隔符,而 scanf() 读取输入的字符串时,以空格、回车、Tab 作为单次输入的结束,因此不能使用 scanf() 函数读取命令。这里介绍使用 fgets 解决 scanf 的问题。
fgets 从特定的文件流中获取内容:
char* s:获取的内容暂存的缓冲区int size:缓冲区的大小FILE* stream:文件对象
C 语言程序中,会默认为我们打开三个输入输出流,我们直接从 stdin 读取键盘输入。
加上使用 fgets 读取输入的逻辑后如下:
#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); // 即使直接输入回车,也输入了一个换行符,因此 str 不可能为空
printf("test:: %s", str);
// 2. 对输入的命令进行命令行解析...
}
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; // 保证以 NULL 结尾
return i; // 返回实际的 token 个数
}
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);
buildExecute 总体逻辑如下:
- 如果是内建命令:逻辑执行完毕后 return 1
- 如果不是内建命令:不执行内建命令,直接 return 0
这里关于内建命令的执行,仅实现以下三个内建命令:cd、export、echo。 其他内建命令读者可自行实现。
关于内建命令:内建命令的执行不能让子进程去执行,原因在于(以 cd 命令举例)子进程执行 cd,只会改变子进程的当前所处路径,cd 的效果是要改变父进程的当前所处路径,应该让父进程去执行 cd,因此不能用程序替换。
2. 内建命令 cd 的执行
char pwd[LINE_SIZE]; // 全局变量 pwd,存储当前路径
int buildExecute(int _argc, char* _argv[]) {
// 处理内建命令 cd
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:只输入 cd 代表返回到当前用户的 home 目录,此时 argc 的值为 1cd <dir>:cd 后面跟指定路径,代表切换到指定路径,此时 argc 的值为 2
综上:内建命令 cd 的 argc 个数 <= 2。
3. 内建命令 export 的执行
char* myenv[LINE_SIZE]; // 全局变量,维护自定义环境变量表
int buildExecute(int _argc, char* _argv[]) {
// 处理内建命令 export
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 $?:获取上个子进程的退出码echo $valKey:通过该操作获取环境变量的相应值echo anyContent:原样输出字符串,去掉双引号
完整实现逻辑如下:
int lastCode = 0; // 全局变量,保存每个子进程退出时的退出码
int buildExecute(int _argc, char* _argv[]) {
// 处理内建命令 echo
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0) {
if (strcmp(_argv[1], "?$") == 0) { // 注意:原文是 $?,这里修正逻辑匹配
// 实际上 argv[1] 可能是 "?$" 如果解析有问题,通常直接比较 "?$" 或者 "$?"
// 根据上下文,应该是 "$?"
if (strcmp(_argv[1], "?$") == 0) { /* 修正 */ }
}
// 修正逻辑:
if (strcmp(_argv[1], "?$") == 0) { // 假设输入是 $?,解析后 argv[1] 为 "?$" 还是 "$?"?通常是 "$?"
// 原文逻辑: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);
} {
* s = _argv[];
len = (s);
(len >= && s[] == && s[len - ] == ) {
s[len - ] = ;
s++;
}
(, s);
}
;
}
;
}
五、普通命令的执行
普通命令的执行由 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 * { getenv(); }
{ getcwd(pwd, (pwd)); }
{
getPwd();
((getUserName(), ) == )
(LEFT RIGHT LABEL_ROOT, getUserName(), getHostName(), pwd);
(LEFT RIGHT LABEL_USER, getUserName(), getHostName(), pwd);
* str = fgets(cLine, size, );
assert(str);
cLine[(cLine)] = ;
}
{
(_max_args <= ) ;
i = ;
* tok = strtok(cLine, DELIM);
(tok != && i < _max_args - ) {
_argv[i++] = tok;
tok = strtok(, DELIM);
}
_argv[i] = ;
i;
}
{
id = fork();
(id < ) {
perror();
;
}
(id == ) {
execvp(_argv[], _argv);
perror();
(EXIT_CODE);
}
{
status = ;
retPid = waitpid(id, &status, );
(retPid == id) {
lastCode = WEXITSTATUS(status);
}
}
}
{
(_argc <= && (_argv[], ) == ) {
(_argc == ) chdir(getenv());
chdir(_argv[]);
getPwd();
setenv(, pwd, );
;
}
(_argc == && (_argv[], ) == ) {
( i = ; i < LINE_SIZE; ++i) {
(myenv[i] == ) {
myenv[i] = (*)((_argv[]) + );
(myenv[i] == ) {
perror();
;
}
(myenv[i], _argv[]);
(putenv(myenv[i]) != )
perror();
;
}
}
;
}
(_argc == && (_argv[], ) == ) {
((_argv[], ) == ) {
(, lastCode);
lastCode = ;
} (_argv[][] == ) {
* val = getenv(_argv[] + );
(val) (, val);
} {
* s = _argv[];
len = (s);
(len >= && s[] == && s[len - ] == ) {
s[len - ] = ;
s++;
}
(, s);
}
;
}
((_argv[], ) == ) {
_argv[_argc++] = ;
_argv[_argc] = ;
}
;
}
{
( i = ; i < LINE_SIZE; ++i) {
(myenv[i]) {
(myenv[i]);
myenv[i] = ;
}
}
}
{
* argv[ARGC_SIZE];
(!quit) {
interact(commandLine, (commandLine));
argc = splitString(commandLine, argv, ARGC_SIZE);
(argc == ) ;
isBuild = buildExecute(argc, argv);
(!isBuild)
normalExecute(argv);
}
destroyEnv();
;
}
结语
通过从零实现一个简单 Shell,我们不仅复现了 Linux 用户日常最熟悉的工具之一,也逐层揭开了其背后涉及的进程控制、程序替换、环境变量、文件系统与字符串解析机制。可以说,一个小小的 Shell,几乎串联起了 Linux 系统编程的整个知识体系。
在这次实践中,我们清晰地看到了 fork + exec 如何赋予 Linux 强大的进程模型,waitpid 如何保证父子进程协作与资源回收,环境变量表如何被继承、修改、扩展,内建命令为什么必须在父进程执行,字符串解析与参数传递如何影响命令的执行逻辑。
这些实现不仅让我们拥有了一个能真实工作的 Shell,更重要的是,它让整个 Linux 运行机制不再抽象,而是变得可触摸、可理解、可实验。
这仅仅是起点。你可以在这些基础上继续扩展:支持管道 |,实现重定向 <>>>,添加后台运行 &,支持环境变量展开、命令历史等,甚至构建自己的 mini bash。每一步都将让你更接近一个真正的系统开发者。


