从零打造一个简易 Linux Shell:核心原理解析与代码实战

你是否好奇,每次在终端敲下 ls -l 并回车时,系统背后究竟发生了什么?Shell 作为用户与操作系统内核之间的桥梁,承担着命令解析、程序启动、环境管理等一系列复杂任务。本文将通过分析一个真实的 C++ 简易 Shell 实现,带你深入理解 Shell 的工作原理,并在代码解读的过程中指出常见陷阱和优化方向。


1. 项目总览:我们想实现什么?

这个简易 Shell 支持以下功能:

  • 显示包含用户名、主机名、当前目录的命令行提示符;
  • 读取用户输入的命令行;
  • 解析命令行为独立的参数数组;
  • 识别并执行内建命令(cd, echo, export, alias 等);
  • 通过 fork + execvp 执行外部命令,并获取退出状态;
  • 基本的环境变量管理,支持从父 Shell 继承环境;
  • (片段性)支持输入/输出重定向。

主程序结构清晰,采用经典的 “读取-解析-执行”循环,没有依赖任何第三方库。


2. 环境变量与工作目录:Shell 的“上下文”

Shell 之所以知道“你是谁”、“你在哪儿”,全靠环境变量。我们可以实现这几个基础辅助函数来告诉操作系统:

const char *GetUserName() {
    const char *name = getenv("USER");
    return name == NULL ? "None" : hostname;
}

const char *GetHostName() {
    const char *hostname = getenv("HOSTNAME");
    return name == NULL ? "None" : hostname;
}

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;
}

我们可以利用DirName 函数尝试从完整路径中提取当前目录名

const std::string *DirName(const char *pwd) {
    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);
}

3. 命令行提示符:打造个性化的见面

提示符格式定义为 [用户名@主机名 目录]# ,通过 MakeCommandLine 拼装:

void MakeCommandLine(char cmd_prompt[], int size) {
    snprintf(cmd_prompt, size, FORMAT,
             GetUserName(), GetHostName(),
             DirName(GetPwd()).c_str());    // 只打印当前目录
    // 第二行被注释的代码为打印全路径,可按需选择
}

PrintCommandPrompt 负责将提示符输出并刷新缓冲区,保证在没有换行的情况下也能立即显示。

void PrintCommandPrompt()
{
	char prompt[COMMAND_SIZE];
	MakeCommandLine(prompt, sizeof(prompt));
	printf("%s", prompt);
	fflush(stdout);
}

4. 命令读取与解析:拆解用户意图

4.1 读取一行

GetCommandLine 使用 fgets 读取用户输入,然后去掉末尾的 \n

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;
}

4.2 拆分为参数数组

CommandParse 使用 strtok 以空格为分隔符进行分割:

bool CommandParse(char *commandline) {
    g_argc = 0;
    g_argv[g_argc++] = strtok(commandline, SEP);
    while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));
    g_argc--;
    g_argv[g_argc] = NULL;
    return g_argc > 0;
}

注意这里 strtok 会修改原字符串,所以传入的 commandline 不能是常量。


5. 内建命令:不需要 fork 的“自家事”

内建命令直接影响 Shell 进程自身状态,因此不能在子进程中执行。本实现主要支持两种内建命令。

5.1 cd 改变工作目录

bool Cd() {
    if(g_argc == 1) {
        std::string home = GetHome();
        if(home.empty()) return true;
        chdir(home.c_str());
    } else {
        std::string where = g_argv[1];
        if(where == "~") { ... }
        else { chdir(where.c_str()); }
    }
    return true;
}

cd - 返回上一级目录的功能还未实现,有兴趣的读者可以通过维护一个历史目录栈来完善。

5.2 echo 与退出码

echo $? 输出上一条命令的退出码,echo $VAR 输出环境变量值:

bool Echo() {
    if(g_argc == 2) {
        std::string opt = g_argv[1];
        if(opt == "$?") {
            std::cout << lastcode << std::endl;
            lastcode = 0;
            return true;
        } else if(opt[0] == '$') {
            std::string env_name = opt.substr(1);
            const char *env_value = getenv(env_name.c_str());
            if(env_value) std::cout << env_value << std::endl;
        } else {
            std::cout << opt << std::endl;
        }
        return true;
    }
    return true;
}

6. 执行外部命令:fork 与 exec 的经典配合

当命令不是内建命令时,Execute 函数登场:

int Execute()
{
	pid_t id = fork();
	if(id == 0)
	{
		int fd = -1;
		// 子进程检测重定向情况
		if(redir == INPUR_REDIR)
		{
			fd = open(filename.c_str(), O_RDONLY);
			if(fd < 0)exit(1);
			up2(fd, 0);
			close(fd);
		}
		else if(redir == OUTPUT_REDIR)
		{
			fd = open(filename,c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0666);
			if(fd < 0)exit(2);
			up2(fd, 1);
			close(fd);
		}
		else if(redir == APPEND_REDIR)
		{
			fd = open(filename,c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);
			if(fd < 0)exit(3);
			up2(fd, 2);
			close(fd);
		}
		else
		{}
		
		// child
		execvp(g_argv[0], g_argv);
		exit(1);
	}
	int status = 0;// 保存退出信息
	// father
	pid_t rid = waitpid(id, &status, 0);
	if(rid > 0)
	{
		lastcode = WEXITSTATUS(status);
	}
	return 0;
}

进程替换 (execvp) 会用新程序镜像完全替代子进程的地址空间,因此重定向等操作必须execvp 之前完成,因为一旦替换成功,后续代码都不会执行。

重定向的解析可以放在 CommandParse 中,将 g_argv 中的重定向符号和文件名单独记录下来,并从 g_argv 中移除,确保后续 execvp 只看到真正的命令和参数。


7. 环境变量初始化:继承与本地管理

InitEnv 尝试从外部环境 environ 全局变量复制一份:

void InitEnv() {
    extern char **environ;
    memset(g_env, 0, sizeof(g_env));
    // 错误:g_env = 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] = NULL;
    for(int i = 0; g_env[i]; i++)
        putenv(g_env[i]);
}

一个健壮的 Shell 不应只复制父进程环境,还应支持 export 修改并重新同步给子进程。


8. 总结:

通过这份“充满教学点”的代码,我们可以提炼出 Shell 的核心骨架:

  1. 提示生成 → 读取输入 → 解析令牌判断内建/外部 → 执行。
  2. 内建命令修改 Shell 自身状态,不 fork;外部命令需要 fork+exec,并妥善回收子进程。
  3. 环境变量表是进程间信息传递的重要途径。
  4. 重定向的本质是操控文件描述符表,且在 exec 之前完成。

动手实现一个 Shell 是理解操作系统进程管理、文件描述符以及 Unix 哲学的最佳途径。希望你也能基于这份原型,修复其中的 bug,并添加自己梦寐以求的功能。完整可跑的 Shell 就在你的键盘之下,祝 Coding 愉快!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐