进程实践:手动实现 Shell
前言
在学习 Linux 系统编程的过程中,Shell 是一个无法绕开的核心组件。它既是我们日常与操作系统交互最频繁的工具,也是理解 Linux 体系结构、进程模型、环境变量管理、程序加载与替换机制等关键知识点的窗口。
通过 C 语言从零实现一个简易 Shell,涵盖命令行提示符定制、输入读取、字符串解析、内建命令(cd/export/echo)及普通命令执行(fork/exec/wait)。重点讲解进程控制、环境变量管理、内存分配及错误处理机制,帮助深入理解 Linux 系统编程核心概念。

在学习 Linux 系统编程的过程中,Shell 是一个无法绕开的核心组件。它既是我们日常与操作系统交互最频繁的工具,也是理解 Linux 体系结构、进程模型、环境变量管理、程序加载与替换机制等关键知识点的窗口。
只有亲手实现一个 Shell,才能真正理解命令识别原理、普通命令与内建命令的本质区别、输入解析方式、参数传递机制以及 exec 替换过程。本文从零开始,不依赖任何复杂库,仅使用 C 语言与系统调用,手动实现一个具备基本功能的小型 Shell。
项目结构如下(所有文件处于同一级目录):
myshell: myshell.c
@gcc -o $@ $^ -std=c99
.PHONY: clean
clean:
@rm -f myshell
单文件版本的 Makefile 极其简单,输入 make 指令,myShell 正确生成。
基础架构代码如下:
int main() {
// shell 本质是一个死循环
while (!quit) {
// 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
// 2. 对输入的命令进行命令行解析,拆分为 指令名 + 选项
// 3. 判断是否是内建命令,内建命令调用 Shell 内部的函数执行
// 4. 非内建命令,fork 出子进程,执行命令
}
return 0;
}
Shell 程序本质是一个死循环,工作的基本流程如下:
命令行格式为:用户名@主机名:当前路径 命令行提示符 ($/#)
因此需要在终端中打印出相应的命令行格式。
#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 语言中相邻的字符串具有自动连接特性。因此我们可以使用宏定义拼接多个字符串实现更灵活的格式化输出。
根据命令行格式,我们使用 printf 函数进行格式化打印。宏定义用于防止代码硬编码,打印命令行时加上括号,与系统默认的命令行作区分。
工具函数:用户名、主机名、当前工作路径都从环境变量中获取。注意:如果操作系统环境变量表中没有 HOSTNAME 字段,可能需要硬编码或适配。
为什么获取当前路径后要将其存在全局变量 pwd 中?这是为后文实现内建命令 cd 做的准备。
尝试使用 scanf() 函数对输入的指令进行读取,但无法实现。原因是 scanf 的输入结束规则由格式控制符决定,不是统一由空格或回车控制。Linux 指令以空格作为分隔符,而 scanf() 读取输入的字符串时,以空格、回车、Tab 作为单次输入的结束,因此不能使用 scanf() 函数读取命令。这里介绍使用 fgets 解决 scanf 的问题。
fgets 从特定的文件流中获取内容:
C 语言程序中,会默认为我们打开三个输入输出流。我们直接从 stdin 读取键盘输入。
#define LINE_SIZE 1024
int quit = 0;
char commandLine[LINE_SIZE]; // 存储读入的命令
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);
char* str = fgets(commandLine, sizeof(commandLine), stdin);
assert(str); // 即使直接输入回车,也输入了一个换行符,因此 str 不可能为空
printf("test:: %s", str);
}
return 0;
}
由于我们在输入时,最后总是会键入回车(换行符),而我们仅需要对字符串进行解析,无需对回车(换行符)进行解析,因此需要将换行符处理掉。 在 assert(str) 后添加一行代码:将末尾的换行符修改为 '\0'。
cmdLine[strlen(cmdLine)-1] = '\0';
我们可以将以上逻辑抽象为交互 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);
如果想对同一个字符串连续做切割,第一次需要传入字符串,后面需要传入 NULL。
字符串分割逻辑实现代码如下:实现一个函数,仅用于分割字符串,并返回分割出的字符串个数。
#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 个数
}
交互读取字符串后,分割后将其存储在自定义的命令行参数表中。
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]);
接收返回的字符串的个数:如果分割出的字符串个数为 0,代表什么都没输入,继续循环交互即可;如果不为 0,代表输入了相关的命令,对命令进行判断并执行。
通过前面进程和变量的学习,我们知道,Linux 中的命令分为普通命令和内建命令。
交互读取命令行输入后,分割出的字符串个数不为 0 时,才执行命令:
if (!isBuild) nomalExecute(argv);
以下为命令执行代码的总体框架:
int isBuild = buildExecute(argc, argv);
if (!isBuild) nomalExecute(argv);
buildExecute 总体逻辑如下:
int buildExecute(int _argc, char* _argv[]) {
// 内建命令 1
if (...) return 1;
// 内建命令 2
else if (...) return 1;
// ... 其他内建命令
return 0;
}
关于内建命令的执行,仅实现以下三个内建命令:cd、export、echo。
关于内建命令:内建命令的执行不能让子进程去执行,原因在于(以 cd 命令举例)子进程执行 cd,只会改变子进程的当前所处路径,cd 的效果是要改变父进程的当前所处路径,应该让父进程去执行 cd,因此不能用程序替换。
char pwd[LINE_SIZE]; // 全局变量 pwd, 存储当前路径
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 的 argc 个数 <= 2。
实现 cd 的执行逻辑:通过系统调用 chdir() 切换 shell 进程的当前工作目录,随后刷新 pwd。
char* myenv[LINE_SIZE]; // 全局变量,初始时自动置为 NULL
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;
}
不能直接使用 putenv 的原因:putenv 只是把环境变量字符串的地址填入到系统环境变量表中。我们的每次通过 export 输入的环境变量暂存在 char commandLine[LINE_SIZE] 命令行中的,和其他输入的命令共用一块空间。输入其他命令时,会把之前的命令覆盖掉,环境变量也就消失了,因此要再单独存储一份环境变量,不能和命令公用一块空间。
这里使用全局变量存储我们的环境变量:char* myenv[LINE_SIZE]; 全局变量初始时自动置为 NULL。
实现 export 的执行逻辑:将命令行中输入的环境变量拷贝到环境变量表的非空位置。
echo 命令需要实现的功能如下:
int lastCode = 0; // 全局变量,保存每个子进程退出时的退出码
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;
}
实现 echo 的执行逻辑:实现 echo 的多个功能。
通过前面进程和变量的学习:普通命令的执行由 shell 执行 fork 创建一个子进程,通过进程程序替换,由子进程单独执行 普通命令。
int lastCode = 0;
void nomalExecute(char* _argv[]) {
pid_t id = fork();
if (id < 0) {
perror("fork failed\n"); return;
}
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);
}
}
}
解释:
进程替换出错时提示错误信息
父进程等待结束后保存子进程的退出码
为 ls 命令加上颜色高亮显示
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 nomalExecute(char* _argv[]) {
pid_t id = fork();
if (id < 0) { perror("fork failed\n"); return; }
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) nomalExecute(argv);
}
destroyEnv();
return 0;
}
通过从零实现一个简单 Shell,我们不仅复现了 Linux 用户日常最熟悉的工具之一,也逐层揭开了其背后涉及的进程控制、程序替换、环境变量、文件系统与字符串解析机制。可以说,一个小小的 Shell,几乎串联起了 Linux 系统编程的整个知识体系。
在这次实践中,我们清晰地看到了 fork + exec 如何赋予 Linux 强大的进程模型,waitpid 如何保证父子进程协作与资源回收,环境变量表如何被继承、修改、扩展,内建命令为什么必须在父进程执行,字符串解析与参数传递如何影响命令的执行逻辑。
这些实现不仅让我们拥有了一个能真实工作的 Shell,更重要的是,它让整个 Linux 运行机制不再抽象,而是变得可触摸、可理解、可实验。
你可以在这些基础上继续扩展:支持管道 |,实现重定向 <>>>,添加后台运行 &,支持环境变量展开、命令历史等,甚至构建自己的 mini bash。每一步都将让你更接近一个真正的系统开发者。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online