从零手搓实现 Linux 简易 Shell:内建命令 + 环境变量 + 程序替换全解析
🔥草莓熊Lotso:个人主页
❄️个人专栏: 《C++知识分享》《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!
🎬 博主简介:
文章目录
前言:
Shell 是 Linux 系统的核心交互工具,本质是一个命令行解释器 —— 接收用户输入、解析命令、创建子进程执行程序(或直接执行内建命令),最后返回执行结果。看似复杂的交互逻辑,核心依赖 进程创建(fork)、程序替换(exec)、进程等待(wait) 三大技术,再配合内建命令处理和环境变量管理,就能实现一个基础可用的 Shell。本文带你从零理解 Shell 的运行原理。一. Shell核心工作流程
一个简易 Shell 的核心逻辑是 “循环 + 五大步骤”,无论是复杂程度如何,底层流程是始终差不多的:
- 打印命令提示符:显示
[用户名@主机名 当前目录]#格式的提示符; - 读取用户命令:通过
fgets获取用户输入的命令行(如ls -l、cd /home); - 解析命令行:将输入字符串分割为命令名和参数(如
ls -l分割为argv[0]="ls"、argv[1]="-l"); - 执行内建命令:对
cd、export等需 Shell 自身执行的命令,直接调用内置函数; - 执行外部命令:对
ls、ps等外部命令,创建子进程,通过程序替换(exec)加载执行,父进程等待子进程退出。

💡关键区别:内建命令(如 cd)必须由 Shell 进程自身执行(修改 Shell 的工作目录),无法通过子进程执行;外部命令(如 ls)需创建子进程执行,避免影响 Shell 主进程。
二. 完整实现源代码
2.1 Makefile文件
mybash:myshell.c main.c gcc -o $@ $^ .PHONY:clean clean: rm -rf mybash 2.2 头文件(myshell.h)和 主函数(main.c)
#pragmaonce#include<stdio.h>// Shell主循环函数voidbash();#include"myshell.h"intmain(){bash();// 启动Shellreturn0;}2.3 核心实现(myshell.c 优化版)
#include"myshell.h"#include<stdlib.h>#include<unistd.h>#include<string.h>#include<sys/stat.h>#include<sys/wait.h>#include<ctype.h>// 提示符相关:用户名、主机名、当前工作目录staticchar username[32];staticchar hostname[64];staticchar cwd[256];staticchar commandLine[256];// 命令行解析相关:argv存储命令参数,argc为参数个数,sep为分割符staticchar* argv[64];staticint argc =0;staticconstchar* sep =" ";// 退出码:记录上一条命令的执行结果(0成功,非0失败)staticint lastCode =0;// 环境变量相关:存储Shell的环境变量(从系统bash拷贝+用户export添加)char** _environ;//方便实现通过声明(_environ)就能直接用环境变量staticint envc =0;// 环境变量计数// -------------------------- 环境变量相关函数 --------------------------// 初始化环境变量:从系统bash拷贝环境变量(如PATH、HOME等)staticvoidInitEnv(){externchar** environ;// 系统环境变量数组(以NULL结尾)for(envc =0; environ[envc]; envc++){ _environ[envc]= environ[envc];}}// 查找环境变量:返回环境变量值(如查找PATH返回"/bin:/usr/bin")staticchar*GetEnv(constchar* key){if(key ==NULL)returnNULL;int key_len =strlen(key);for(int i =0; env[i]!=NULL; i++){// 环境变量格式:"KEY=VALUE",找到"KEY="开头的条目if(strncmp(env[i], key, key_len)==0&& env[i][key_len]=='='){return env[i]+ key_len +1;// 返回"VALUE"部分}}returnNULL;}// 添加环境变量:用于export命令(如export my_value=100)staticvoidAddEnv(constchar* val)// argv[1];{ _environ[envc]=(char*)malloc(strlen(val)+1);strcpy(_environ[envc], val); _environ[++envc]=NULL;}// 打印所有环境变量:用于env命令staticvoidPrintAllEnv(){int i =0;for(; _environ[i]; i++){printf("%s\n", _environ[i]);}}// -------------------------- 提示符相关函数 --------------------------// 获取用户名(从环境变量USER读取)staticvoidGetUserName(){char* _username =getenv("USER");strcpy(username,(_username ? _username :"None"));}// 获取主机名(从环境变量HOSTNAME读取)staticvoidGetHostName(){char* _hostname =getenv("HOSTNAME");strcpy(hostname,(_hostname ? _hostname :"None"));}// 获取当前工作目录(简化显示:只显示最后一级目录)staticvoidGetCmd(){// char* _cwd = getenv("PWD");// strcpy(cwd, (_cwd ? _cwd : "None"));char _cwd[256];getcwd(_cwd,sizeof(_cwd));if(strcmp(_cwd,"/")==0){strcpy(cwd, _cwd);}else{int end =strlen(_cwd)-1;//while(end >= 0)//{// if(_cwd[end] == '/')// {// strcpy(cwd, &_cwd[end + 1]);// break;//}//end--;//}// 从后往前找 '/',截取最后一级目录while(end >=0&& _cwd[end]!='/') end--;strcpy(cwd,&_cwd[end +1]);}}// 打印命令提示符:[用户名@主机名 目录]#staticvoidPrintPromt(){GetUserName();GetHostName();GetCmd();printf("[%s@%s %s]# ", username, hostname, cwd);fflush(stdout);// 刷新缓冲区,确保提示符及时显示}// -------------------------- 命令读取与解析 --------------------------// 读取用户输入的命令行staticvoidGetCommandLine(){memset(commandLine,0,sizeof(commandLine));if(fgets(commandLine,sizeof(commandLine),stdin)!=NULL){// 去除换行符(fgets会读取回车符'\n') commandLine[strlen(commandLine)-1]='\0';}}// 解析命令行:将输入字符串分割为argv数组staticvoidParseCommandLine(){ argc =0;memset(argv,0,sizeof(argv));// 清空argvif(strlen(commandLine)==0)return;// 判空// 分割命令和参数(以空格为分隔符) argv[argc]=strtok(commandLine, sep);while((argv[++argc]=strtok(NULL, sep)));// argv数组必须以NULL结尾(exec函数要求)}// -------------------------- 内建命令处理 --------------------------// 检查并执行内建命令:返回1表示内建命令,0表示外部命令staticintCheckBuiltinAndExcute(){int ret =0;// 1. cd命令:切换工作目录(必须内建,子进程切换不影响Shell)if(strcmp(argv[0],"cd")==0){ ret =1;if(argc ==2){chdir(argv[1]);}}// 2. echo命令:打印字符串或环境变量(如echo $PATH、echo $?)elseif(strcmp(argv[0],"echo")==0){ ret =1;if(argc ==2){if(argv[1][0]=='$'){if(strcmp(argv[1],"$?")==0){printf("%d\n", lastCode); lastCode =0;}else{// envchar* val =GetEnv(argv[1]+1);// 跳过'$',获取变量值if(val !=NULL){printf("%s\n", val);}else{printf("\n");// 变量不存在,输出空行}}}// 打印普通字符串else{printf("%s\n", argv[1]);}}}// 3. export命令:添加环境变量(如export MY_VAR=123)elseif(strcmp(argv[0],"export")==0){ ret =1;if(argc ==2){AddEnv(argv[1]);}}// 4. env命令:打印所有环境变量elseif(strcmp(argv[0],"env")==0){ ret =1;PrintAllEnv();}// 5. exit命令:退出Shellelseif(strcmp(argv[0],"exit")==0){exit(0);}// 非内建命令,返回0return ret;}// -------------------------- 外部命令执行 --------------------------// 执行外部命令:创建子进程+程序替换staticvoidExcute(){ pid_t id =fork();// 创建子进程if(id <0){perror("fork error");return;}elseif(id ==0){// 子进程:程序替换(execvp自动搜索PATH环境变量)execvp(argv[0], argv);exit(1);// 子进程退出,退出码1}else{// 父进程:等待子进程退出,获取退出码int status =0; pid_t rid =waitpid(id,&status,0);(void)rid; lastCode =WEXITSTATUS(status);}}// -------------------------- Shell主循环 --------------------------voidbash(){// 环境变量相关,方便实现通过声明(_environ)就能直接用环境变量staticchar* env[64]; _environ = env;// 除此以外我们还可以通过一个数组存储本地变量// 以及可以通过一个来存储别名…// 初始化环境变量(从系统拷贝)InitEnv();while(1){// 第一步: 输出提示命令行PrintPromt();// 第二步: 等待用户输入, 获取用户输入GetCommandLine();// 第三步: 解析字符串,"ls -a -l" -> "ls" "-a" "-l"ParseCommandLine();if(argc ==0)continue;// 第四步: 有些命令, cd echo env等等不应该让子进程执行// 而是让父进程自己执行,这些是内建命令. bash内部的函数if(CheckBuiltinAndExcute())continue;// 第五步: 执行命令Excute();}}- 原版代码:lesson22/myshell.c
- 注意:大家可以自己下去测试一下使用这个简易版的Shell去进行一些指令操作。
补充:Excute里面进行执行命令的权限设置相关操作,大家可以自己去试一下。

三. 核心功能解析
3.1 环境变量管理(补充重点)
环境变量是 Shell 的重要特性,本文实现了完整的环境变量生命周期管理:
- 初始化:
InitEnv()从系统environ数组拷贝环境变量(如 PATH、HOME、USER),确保ls、ps等命令能通过 PATH 找到执行文件; - 添加 / 覆盖:
AddEnv()用于export命令,支持新增环境变量或覆盖已有变量(如export PATH=$PATH:/my/bin); - 查找:
GetEnv()用于echo $KEY场景,根据键名查找环境变量值; - 打印:
PrintAllEnv()用于env命令,打印所有环境变量。
3.2 内建命令实现
- cd:调用
chdir()系统调用切换工作目录,必须内建(子进程切换目录不影响 Shell 主进程); - echo:支持打印普通字符串、退出码(
$?)和环境变量($PATH); - export:调用
AddEnv()添加环境变量; - env:调用
PrintAllEnv()打印所有环境变量; - exit:调用
exit(0)退出 Shell。
3.3 外部命令执行
- 进程创建:
fork()创建子进程,避免外部命令执行影响 Shell 主进程; - 程序替换:
execvp()自动搜索 PATH 环境变量,无需写命令全路径(如ls无需/bin/ls); - 进程等待:
waitpid()等待子进程退出,获取退出码并存储到lastCode,供echo $?使用。
3.4 关键技术点总结
- 程序替换本质:
execvp会替换子进程的代码和数据段,加载新程序执行,进程 PID 不变; - 内建命令必要性:修改 Shell 自身状态的命令(如 cd、export)必须内建,子进程执行无法影响父进程;
- 环境变量格式:环境变量数组必须以 NULL 结尾,
exec系列函数依赖此格式解析参数; - 命令解析细节:需处理连续空格、开头空格等异常输入,确保解析后的
argv符合规范。
四. 思考:函数和进程之间的相似性
exec/exit 就像 call/return
- 一个 C 程序有很多函数组成。一个函数可以调用另一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过
call/return系统进行通信。 - 这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图:

- 一个 C 程序可以
fork/exec另一个程序,并传给它一些参数。这个调用的程序执行一定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值。
结尾:
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点: 👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长 ❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量 ⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用 💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑 🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解 技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标! 结语:本文实现的简易 Shell 虽不及 bash 功能强大,但覆盖了核心工作原理 —— 通过 fork 创建子进程、exec 替换程序、wait 等待结果,再配合内建命令和环境变量管理,就能完成与用户的交互。在此基础上,还可扩展更多功能:支持管道(ls | grep txt)、重定向(ls > log.txt)、后台运行(sleep 10 &)等。如果需要进一步优化,可重点关注命令解析的健壮性(如支持引号包含空格的参数)和信号处理(如 Ctrl+C 终止前台进程)。
✨把这些内容吃透超牛的!放松下吧✨ʕ˘ᴥ˘ʔづきらど