迷你版Shell:源码详解与行为解析
前言
本文逐模块、逐函数解析你提交的 Shell 源码(包含提示符、命令读取、解析、内建命令、环境变量、重定向与执行流程),用行为级说明帮助读者完全理解每一行代码在运行时做了什么。
目录(快速导航)
概览
全局变量与常量
辅助函数(用户名、主机名、PWD、Home、目录名)
提示符生成与打印
获取命令行与解析(
GetCommandLine
、CommandPrase
)内建命令(
cd
、echo
、export
)环境变量初始化(
Initenv
)重定向解析(
RedirCheck
与TrimSpace
)外部命令执行(
Excute
:fork
、dup2
、execvp
、waitpid
)主循环(
main
)的执行流程与行为运行示例(典型交互)
小结:运行时画面与全局状态
1. 概览
Shell 是一个简单的命令行解释器,核心功能包括:
显示格式化 Prompt(
[user@host dirname]#
)读取一行用户输入
解析重定向(
<
、>
、>>
)将命令按空格切分为
argv
(strtok
)执行内建命令:
cd
、echo
、export
对外部命令使用
fork()
→子进程dup2
重定向 →execvp()
执行 → 父进程waitpid()
回收并记录退出码
下面按模块逐一解析。
2. 全局变量与常量(代码片段含义)
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]#"#define MAXARGC 128
char cwd[1024];
char cwdenv[2024];
char* g_argv[MAXARGC];
int g_argc = 0;int lastcode = 0; // 记录最后一次命令退出码#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
int redir = NONE_REDIR;
std::string filename;
cwd
/cwdenv
:存放当前工作目录和构造出的PWD=...
字符串(后续用putenv
)。g_argv
/g_argc
:全局保存当前命令的 argv/argc,便于CheckAndExcBuiltin
、Excute
使用。lastcode
:保存上一次执行外部命令的退出码(供echo $?
查询)。g_env
:用来保存从父进程继承的环境变量字符串副本,并在初始化时putenv
到当前进程环境中。redir
/filename
:记录当前命令的重定向类型与目标文件名。
3. 辅助函数(用户名、主机名、PWD、Home、目录名)
这些函数为提示符和环境变量准备信息。
Getusername()
const char* Getusername(){const char* name = getenv("USER");return name == NULL ? "None" : name;
}
从环境变量
USER
获取用户名;若缺失返回"None"
。
Gethostname()
const char* Gethostname() {static char hostname[256];if (gethostname(hostname, sizeof(hostname)) == 0) return hostname;else return "None";
}
使用
gethostname()
系统调用获得主机名(比从环境变量取更可靠)。
GetPwd()
const char* GetPwd(){const char* pwd = getcwd(cwd, sizeof(cwd));if (pwd != NULL) {snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);putenv(cwdenv);}return pwd == NULL ? "None" : pwd;
}
通过
getcwd()
获取当前目录到cwd
,并把PWD=...
字符串写入cwdenv
,再putenv(cwdenv)
把PWD
导出到当前进程环境。
GetHome()
const char* GetHome(){const char* home = getenv("HOME");return home == NULL ? "" : home;
}
返回
HOME
环境变量的值(如果存在)。
DirName(const char* pwd)
std::string DirName(const char* pwd){#define SLASH "/"std::string dir = pwd;if (dir == SLASH) return SLASH;auto pos = dir.rfind(SLASH);if (pos == std::string::npos) return "BUG?";return dir.substr(pos+1);
}
取出路径的最后一段(
/home/hu/test
→test
),用于提示符显示当前目录名。
4. 提示符生成与打印
函数 MakeCommandLine
与 PrintCommandPrompt
:
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);
}
FORMAT
="[ %s@%s %s ]#"
,生成类似[hu@host dir]#
的提示符并打印到标准输出(立即 flush)。
5. 获取命令行与解析
GetCommandLine
bool GetCommandLine(char* out, int size) {char* c = fgets(out, size, stdin);if (c == NULL) return false;out[strlen(out)-1] = 0; // 去掉末尾换行if (strlen(out) == 0) return false;return true;
}
使用
fgets()
读取一行(包含换行),去掉换行并忽略空行。返回
false
表示没有有效输入(例如按 Ctrl+D 或空行)。
CommandPrase
bool CommandPrase(char* commandline){g_argc = 0;g_argv[g_argc++] = strtok(commandline, " ");while ((bool)(g_argv[g_argc++] = strtok(nullptr, " ")));g_argc--;return g_argc > 0 ? true : false;
}
用
strtok
以单个空格" "
为分隔符依次拆分 token 并把指针放入g_argv
。g_argv
最后会以NULL
结尾。(g_argc
最后被修正为实际个数)行为说明:
仅按空格分隔(不处理引号或制表符)。
连续空格会被
strtok
忽略。g_argv[0]
是命令名。
PrintArgv
调试函数,打印当前 g_argv
列表与 g_argc
。
6. 内建命令(cd
、echo
、export
)
这些命令在父进程中执行(非 fork 执行),因为它们需要影响 Shell 本身的状态或环境。
Cd()
行为:
cd
没参数或cd ~
:切换到HOME
(通过GetHome()
)。cd -
:尝试读取OLDPWD
并切换到上一次目录(函数中会打印并chdir(old_pwd)
)。cd path
:调用chdir(path)
。
在代码中,
Cd()
基于g_argv[1]
的值决定行为并调用chdir()
执行切换。
Echo()
支持:
echo $?
:打印lastcode
(上一次外部命令退出码)。echo $VAR
:打印环境变量VAR
的值(通过getenv
)。echo
普通字符串:逐参数输出并在参数间加入空格,最后换行。
g_argc
为 1 时不输出任何内容(你的代码先判g_argc > 1
)。
Export()
(导出环境变量)
无参数时:列出当前
environ
中的所有已导出环境变量(declare -x ...
格式)。有参数时:处理每个参数:
若有
=
(如VAR=val
),用setenv(key,val,1)
设定并导出;若无
=
(如export VAR
),取当前getenv(VAR)
的值(若存在)并用setenv(VAR, val? val: "", 1)
导出(若不存在则导出空值)。
注意:使用
setenv
来确保变量加入当前进程环境。
7. 环境变量初始化:Initenv()
void Initenv(){extern char** environ;memset(g_env, 0, sizeof(g_env));g_envs = 0;for (int i = 0; environ[i]; i++){g_env[i] = (char*)malloc(strlen(environ[i]) + 1);strcpy(g_env[i], environ[i]);g_envs++;}g_env[g_envs++] = (char*)"TEST=test";g_env[g_envs] = NULL;for (int i = 0; g_env[i]; i++){putenv(g_env[i]);}
}
从
environ
(父进程环境)复制每一项到g_env
(通过malloc
分配内存并strcpy
内容)。追加了
"TEST=test"
这一项。最后逐项
putenv(g_env[i])
,把这些字符串(格式KEY=VAL
)加入到当前进程的环境表中。
运行结果/行为:
初始化时,当前进程获得与父 shell 相同的环境变量(以字符串副本形式),并增加了
TEST=test
。
8. 重定向解析:RedirCheck
与 TrimSpace
这部分处理命令行中的重定向符号 <
、>
、>>
,并从命令字符串中分离出目标文件名。
TrimSpace
void TrimSpace(char cmd[], int &end){while(isspace(cmd[end])){end++;}
}
把指针
end
前移,跳过空白字符,直到遇到第一个非空白字符。注意它改变的是索引end
,并期望后续由filename=commandline+end
使用。
RedirCheck
核心流程(按代码逻辑):
初始化:
redir=NONE_REDIR; filename.clear();
start = 0; end = strlen(commandline)-1;
从字符串尾向前扫描
while(end > start)
:若遇到
<
:将commandline[end++] = 0;
(在该位置写\0
截断命令),调用TrimSpace
,设置redir=INPUT_REDIR
,filename = commandline + end
,break
。若遇到
>
:检测commandline[end-1] == '>'
判断是>>
(追加)还是单>
(覆盖)。相应地在命令串中写入\0
来截断,调整end
位置,TrimSpace,设置redir
为APPEND_REDIR
或OUTPUT_REDIR
,把filename
指向commandline+end
。否则
end--
继续向左扫描。
行为说明:
以从右向左扫描方式寻找最后出现的重定向符号,找到后把原命令字符串从该位置截断(写
'\0'
),并把文件名部分(跳过空格后的内容)记录到filename
。在后续
CommandPrase
时,命令字符串已被截断,g_argv
不会包含重定向与文件名(因为这些字符已被置\0
)。
9. 外部命令执行:Excute()
int Excute(){pid_t id = fork();if (id == 0) {// child: 处理重定向if (redir == INPUT_REDIR) {int fd = open(filename.c_str(), O_RDONLY);if (fd < 0) exit(1);dup2(fd, 0);close(fd);} else if (redir == OUTPUT_REDIR) {int fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0666);if (fd < 0) exit(2);dup2(fd, 1);close(fd);} else if (redir == APPEND_REDIR) {int fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);if (fd < 0) exit(2);dup2(fd, 1);close(fd);}// 执行命令替换(execvp)execvp(g_argv[0], g_argv);exit(1); // exec 失败才会到这里}int status = 0;pid_t rid = waitpid(id, &status, 0);if (rid > 0) {lastcode = WEXITSTATUS(status);}return 0;
}
行为详解:
fork()
:父子复制地址空间(写时复制),父进程继续;子进程负责把自己设置好并exec
外部程序。子进程处理重定向:
输入重定向:
open
目标文件(只读)→dup2(fd,0)
把stdin
重定向到该文件描述符 →close(fd)
。输出(覆盖):
open(..., O_CREAT|O_WRONLY|O_TRUNC,0666)
→dup2(fd,1)
把stdout
指向文件。追加:用
O_APPEND
打开并dup2
到1
。
execvp(g_argv[0], g_argv)
:用 PATH 搜索并执行命令;若成功,当前进程映像被替换,不再返回到这段代码;若失败,execvp
返回并子进程执行exit(1)
.父进程使用
waitpid(id, &status, 0)
等待子进程结束;若waitpid
返回成功,用WEXITSTATUS(status)
取出子进程退出码写入lastcode
。
退出码含义(按你实现的约定):
当子进程因
open
失败而exit(1)
或exit(2)
,父进程会把对应的退出码收集到lastcode
。若
execvp
找不到命令,子进程exit(1)
(或更复杂的失败场景),父进程的lastcode
反映这个退出码。
10. 主循环(main
)的执行流程与行为
主函数核心逻辑(精简版):
Initenv();
while(true) {PrintCommandPrompt();if (!GetCommandLine(commandline, sizeof(commandline))) continue;RedirCheck(commandline);printf("redir:%d,filename:%s\n", redir, filename.c_str());if (!CommandPrase(commandline)) continue;if (CheckAndExcBuiltin()) continue;Excute();
}
逐步行为说明:
启动时调用
Initenv()
,把继承的环境变量放到g_env
并putenv
。每次循环:
打印提示符
[user@host dir]#
。读取一行命令(
fgets
),去掉换行,忽略空行。调用
RedirCheck
分离重定向并记录redir
、filename
,并在主循环中打印redir:..., filename:...
便于观察命令解析结果。再用
CommandPrase
将截断后的命令行分割成g_argv
参数列表。如果
g_argv[0]
是cd
/echo
/export
等内建命令,通过CheckAndExcBuiltin
在父进程直接执行并回到循环(不fork
)。否则调用
Excute()
:fork
出子进程,子进程处理重定向并execvp
外部命令,父进程等待子结束并记录退出码到lastcode
。
注意(代码行为观察):
主循环里有两段
RedirCheck/CommandPrase
的重复调用(在你提交代码里这一对出现了两次)。因此在实际运行中会执行这些解析两次,且会打印两次redir:...,filename:...
。这是代码的实际行为——解释器会把同一输入按相同顺序处理两次(打印两次解析信息),然后继续后续逻辑。
11. 运行示例(典型交互)
下面给出几个典型的交互示例,展示命令从输入到执行的行为:
示例 A:执行内建命令 cd
、echo
输入:
[hu@host test]# cd ..
行为:
GetCommandLine
读取"cd .."
.RedirCheck
没有找到重定向,redir = NONE_REDIR
。CommandPrase
解析为g_argv[0] = "cd", g_argv[1] = ".."
.CheckAndExcBuiltin()
识别cd
,调用Cd()
在父进程chdir("..")
,并返回到主循环(不fork
)。
输入:
[hu@host test]# echo $HOME
行为:
g_argv[0] = "echo", g_argv[1] = "$HOME"
Echo()
分析$HOME
,调用getenv("HOME")
并输出结果。
示例 B:外部命令 + 输出重定向
输入:
[hu@host test]# ls -l > out.txt
行为:
RedirCheck
从字符串尾部发现>
,截断命令,把filename = "out.txt"
,并把redir = OUTPUT_REDIR
。主循环会打印(两次)
redir:2,filename:out.txt
(如代码所示会打印两遍)。CommandPrase
得到g_argv = {"ls","-l",NULL}
。CheckAndExcBuiltin()
返回 false →Excute()
:fork()
子进程子进程打开
out.txt
(O_CREAT|O_WRONLY|O_TRUNC,0666
),dup2(fd,1)
把标准输出重定向为文件execvp("ls", g_argv)
执行ls -l
,输出写入out.txt
父进程
waitpid
等待子结束并取出退出码到lastcode
示例 C:输入重定向
输入:
[hu@host test]# ./myfile log1.txt
(若 myfile 实现 open argv[1] 并 dup2(fd,0) 的那类)
行为:
g_argv[0] = "./myfile", g_argv[1] = "log1.txt"
Excute()
中子进程会执行open("log1.txt",O_RDONLY)
、dup2(fd,0)
、execvp("./myfile", g_argv)
。注意这里你的 Shell 也支持cmd < file
形式:若用户输入cmd < file
,RedirCheck
会把 filename 置为file
,子进程在Excute
中执行dup2(fd,0)
,然后execvp(g_argv[0], g_argv)
。
12. 小结:运行时画面与全局状态
Prompt:当显示
[user@host dir]#
时,GetPwd()
已经把PWD
写入到环境中(putenv(cwdenv)
)。命令分离:你的解析流程先做重定向分离、再 token 化;命令参数保存在
g_argv
,以便外部命令execvp
使用。内建 vs 外部:内建命令在父进程执行,直接影响 Shell(如
cd
、export
),外部命令通过fork/exec
执行并通过waitpid
获取退出码以供$?
查询。重定向行为:重定向只在子进程中生效(通过
dup2
),不会污染父进程的stdin/stdout
,这保证了 Shell 的持续可用性。环境变量:Shell 启动时从
environ
继承变量并放到g_env
,通过putenv
与setenv
建立在当前进程中的环境视图。
附:代码里值得注意的运行行为(事实陈述)
RedirCheck
与CommandPrase
在主循环中出现了重复调用语句(会导致两次打印redir
信息)。这是代码在运行时实际表现出来的行为。Echo()
、Export()
、Cd()
都通过读取/修改环境或调用chdir()
在父进程完成(内建命令的标准做法)。Initenv()
将父进程环境复制到g_env
并通过putenv
导入当前进程,程序增加了一个TEST=test
条目。Excute()
对重定向失败使用硬编码退出状态(如exit(1)
或exit(2)
),再由父进程的WEXITSTATUS
读取并放入lastcode
。
结语
这份源码实现了一个结构清晰、功能完整的迷你 Shell:提示符、行读取、解析、内建命令、环境处理、重定向与 fork/exec
执行都具备。通过 g_argv
全局数组、redir/filename
状态和 lastcode
的设计,shell 在父进程与子进程职责分工上实现了典型行为:内建命令直接生效、外部命令在子进程受重定向影响并通过 execvp
执行。