【Linux系统】从零开始构建简易 Shell:从输入处理到命令执行的深度剖析

在这里插入图片描述

文章目录

  • 前言
    • 一、打印命令行提示符
      • 代码功能概述
    • 二、读取键盘输入的指令
      • 2.1 为什么不继续使用`scanf()`而换成了`fgets()`?
      • 2.2 调试输出的意义
      • 2.3 为什么需要去掉换行符?
    • 三、指令切割
      • 补充知识: `strtok` 的函数原型
    • 四、普通命令的执行
      • 代码功能概述
    • 五、内建指令执行
      • 代码功能概述
  • 结语


前言

在操作系统的世界里,Shell 作为用户与系统交互的桥梁,扮演着至关重要的角色。无论是资深的开发者,还是对系统运维感兴趣的新手,了解 Shell 的工作原理和实现机制都能极大地加深对操作系统底层运行逻辑的理解。

本文将带领大家深入探索简易 Shell 的实现过程,从最基础的打印命令行提示符开始,逐步实现读取用户输入、切割指令、执行普通命令以及处理内建指令等核心功能。每一步都配有详细的代码解析和关键知识点说明,不仅让你知其然,更能知其所以然。通过阅读本文,你将掌握 Shell 实现的关键技术,理解其中涉及的编程技巧和系统调用原理,同时也能体会到从无到有构建一个实用工具的乐趣与成就感。


为了方便理解我把源代码发给大家。(单击蓝色字体)

一、打印命令行提示符

#define MARK ":"
#define LABLE "$"
#define LINE_SIZE 1024char commandline[LINE_SIZE]; // 用于存储输入的命令行
char pwd[LINE_SIZE]; // 当前工作目录// 获取用户名
const char* getusername()
{return getenv("USER");
}// 获取主机名
const char* gethostname()
{return getenv("HOSTNAME");
}// 获取当前工作目录并进行格式化
void getpwd()
{getcwd(pwd, sizeof(pwd)); // 获取当前工作目录const char* home = getenv("HOME"); // 获取用户主目录if (home != NULL && strcmp(pwd, home) == 0){// 如果当前目录等于用户主目录,则返回 "~"strcpy(pwd, "~");}else if (home != NULL && strncmp(pwd, home, strlen(home)) == 0){// 如果当前目录是用户主目录的子目录,则替换主目录部分为 "~"static char relative_path[LINE_SIZE];snprintf(relative_path, sizeof(relative_path), "~%s", pwd + strlen(home));strcpy(pwd, relative_path);}
}int main()
{getpwd();printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd);scanf("%s", commandline);                                                                               return 0;
}

代码功能概述

  1. 定义宏和变量:

    • MARKLABLE 定义了提示符中的分隔符 :$,用于提示符的格式化输出。
    • LINE_SIZE 设置了缓冲区大小(1024),用于存储命令行输入和当前工作目录。
    • 全局变量 commandlinepwd
      • commandline: 存储从用户输入读取的命令行。
      • pwd: 存储当前工作目录。
  2. 获取环境变量:

    • 函数 getusername()gethostname() 分别通过调用 getenv() 获取环境变量 USERHOSTNAME,用于表示用户名和主机名。
  3. 格式化当前工作目录:

    • 函数 getpwd()

      获取当前工作目录并将其格式化:

      • 如果当前目录是用户的主目录(HOME),用 ~ 替代完整路径。
      • 如果当前目录是主目录的子目录,则用 ~ 替代主目录部分。
      • 否则显示完整路径。
  4. 打印命令行提示符:

    • main() 函数中调用 getpwd() 获取当前工作目录,然后通过 printf()

      按以下格式打印提示符:

      在这里插入图片描述

  5. 等待用户输入:

    • 程序通过 scanf() 等待用户输入命令,将输入存储到 commandline 中。

二、读取键盘输入的指令

// 交互式模式,显示提示符并读取用户输入
void interact(char* cline, int size)
{getpwd(); // 获取当前工作目录printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd); // 显示提示符char* s = fgets(cline, size, stdin); // 读取用户输入assert(s); // 确保输入不为空printf("echo: %s\n", s); // 测试能否读取成功cline[strlen(cline)-1] = '\0'; // 去掉末尾的换行符
}int main()
{while(1) // 循环运行直到用户退出{// 1. 获取用户输入的命令行interact(commandline, sizeof(commandline));}return 0; 
}

2.1 为什么不继续使用scanf()而换成了fgets()

scanf 通常用于格式化输入,但它在处理用户输入时存在一些显著的缺点:

  • scanf 默认以空格、制表符或换行符作为输入的分隔符,因此只能读取一个单词(或无空格的字符串)。
  • 在交互式 Shell 中,用户输入的命令往往由多个部分组成(如命令和参数),scanf 无法正确读取整行命令。
  • scanf 不会自动限制输入长度,如果用户输入超出缓冲区大小,就会导致缓冲区溢出,进而引发未定义行为甚至安全漏洞。

2.2 调试输出的意义

加调试输出 printf("echo: %s\n", s) 是为了测试程序的输入功能是否正常运行,具体验证以下几点:

  1. 提示符显示后,用户是否能输入数据
    • 如果 s == NULL,则说明 fgets() 未能成功读取输入(可能是因为输入错误或用户直接按下 Ctrl+D)。
  2. 输入是否被正确存储到缓冲区 cline
    • 打印输入内容以确保其正确性。
  3. 缓冲区大小是否足够
    • 如果输入过长,可能会导致缓冲区溢出或截断。

调试结果
在这里插入图片描述

2.3 为什么需要去掉换行符?

  • 用户通过键盘输入时,输入内容会带有换行符(\n),这是因为按下回车键会在输入的末尾自动添加一个换行符。

  • 例如,用户输入 ls -l 后,缓冲区中的数据实际是:

    ls -l\n\0
    
    • \n 是换行符。
    • \0 是字符串的终止符。
  • 在处理命令时,换行符通常是多余的:

    • 它会影响字符串的比较。例如,strcmp(command, "exit") 会返回不匹配,因为字符串实际是 "exit\n"
    • 如果直接打印字符串,换行符会造成多余的空行。
    • 某些函数(如文件名或路径相关函数)可能会因为换行符导致逻辑错误。

三、指令切割

#define DELIM " "	// 分隔符
#define ARGC_SIZE 32char *argv[ARGC_SIZE]; // 用于存储命令行参数// 将输入的命令行分割为参数数组
int splitstring(char* cline, char* _argv[])
{int i = 0;argv[i++] = strtok(cline, DELIM); // 分割第一个参数while(_argv[i++] = strtok(NULL, DELIM)); // 分割剩余的参数return i - 1; // 返回参数个数
}int main()
{while(1) // 循环运行直到用户退出{// 1. 获取用户输入的命令行interact(commandline, sizeof(commandline));// 2. 分割命令行字符串为指令和参数int argc = splitstring(commandline, argv);// 调试代码for(int i = 0; argv[i]; i++){printf("[%d]->%s\n", i, argv[i]);}// 3. 如果没有输入指令(空行),跳过本次循环if(argc == 0) continue;}return 0; 
}

补充知识: strtok 的函数原型

char *strtok(char *str, const char *delim);
  • 参数:
    1. char *str:
      • 第一次调用时传入需要分割的字符串。
      • 后续调用传入 NULL 表示继续上一次的分割。
    2. const char *delim:
      • 一个以 \0 结尾的字符串,表示分割的分隔符集合(例如,空格、逗号等)。
  • 返回值:
    • 成功:返回一个指向分割后的子字符串(token)的指针。
    • 失败:如果没有更多的子字符串可以返回,则返回 NULL

注意点:循环结束时,i 的值比实际的参数个数多 1(因为最后一次分割返回 NULL)。

因此,用 i - 1 表示参数的实际个数。

调试结果

在这里插入图片描述

四、普通命令的执行

#define EXIT_CODE 44extern char **environ; // 环境变量
int lastcode = 0; // 上次命令的退出状态
int quit = 0; // 是否退出// 执行外部命令
void normalExcute(char* _argv[])
{pid_t id = fork(); // 创建子进程if(id < 0){perror("fork"); // 创建失败,打印错误信息return;}if(id == 0) // 子进程{execvpe(_argv[0], _argv, environ);exit(EXIT_CODE);}else // 父进程{int status = 0;waitpid(id, &status, 0); // 等待子进程完成if (WIFEXITED(status)) // 检查子进程是否正常退出{lastcode = WEXITSTATUS(status); // 获取子进程的退出状态}}
}int main()
{while(!quit) // 循环运行直到用户退出{// 1. 获取用户输入的命令行interact(commandline, sizeof(commandline));// 2. 分割命令行字符串为指令和参数int argc = splitstring(commandline, argv);// 3. 如果没有输入指令(空行),跳过本次循环if(argc == 0) continue;// 4.执行普通外部指令normalExcute(argv);}return 0; 
}

代码功能概述

功能:执行外部命令并处理子进程的退出状态。

关键点

  1. fork 创建子进程
  2. execvpe 替换子进程执行映像
  3. waitpid 等待子进程结束并处理退出状态

结果:命令执行结果的退出码存储在 lastcode 中供后续使用。

调试结果

在这里插入图片描述

五、内建指令执行

char myenv[LINE_SIZE]; // 用于存储导出的环境变量// 构建内置命令
int buildCommand(char* _argv[], int _argc)
{// 内置命令:cdif(_argc == 2 && strcmp(_argv[0], "cd") == 0){getpwd();chdir(_argv[1]); // 改变工作目录sprintf(getenv("PWD"), "%s", pwd); // 更新环境变量PWDreturn 1; // 返回已处理标志}// 内置命令:exportelse if(_argc == 2 && strcmp(_argv[0], "export") == 0){strcpy(myenv, _argv[1]); // 保存环境变量putenv(myenv); // 设置环境变量return 1; // 返回已处理标志}// 内置命令:echoelse if(_argc == 2 && strcmp(_argv[0], "echo") == 0){if(strcmp(_argv[1], "$?") == 0) // 显示上一个命令的退出状态{printf("%d\n", lastcode);lastcode = 0; // 重置退出状态}else if(*_argv[1] == '$') // 显示环境变量的值{char* val = getenv(_argv[1] + 1);if(val) printf("%s\n", val);}else printf("%s\n", _argv[1]); // 直接打印参数return 1; // 返回已处理标志}// 自动为 ls 添加 --color 参数if(strcmp(_argv[0], "ls") == 0){_argv[_argc++] = "--color";_argv[_argc] = NULL; // 确保参数数组以 NULL 结束}return 0; // 返回未处理标志
}int main()
{while(!quit) // 循环运行直到用户退出{// 1. 获取用户输入的命令行interact(commandline, sizeof(commandline));// 2. 分割命令行字符串为指令和参数int argc = splitstring(commandline, argv);// 3. 如果没有输入指令(空行),跳过本次循环if(argc == 0) continue;// 4. 尝试执行内置命令int n = buildCommand(argv, argc);// 5. 如果不是内置命令,执行普通外部指令if(!n) normalExcute(argv);}return 0; 
}

代码功能概述

该函数 buildCommand 的功能是处理内置命令,包括 cdexportecho,并对特定外部命令(如 ls)添加额外参数。如果输入的命令属于内置命令范围,函数会执行相应逻辑并返回已处理标志;否则返回未处理标志,交由其他部分(如外部命令执行器)处理。

  1. 处理内置命令:cd

    if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
    
    • 检查用户输入的命令是否是 cd,且参数个数为 2(命令名 + 目标目录)。
    • 功能:
      • 使用 chdir 改变当前工作目录为用户指定的路径(_argv[1])。
      • 更新环境变量 PWD,同步当前工作目录的变化。
    • 实现思路:要注意 cd 命令是由 bash 本身去做,而不是创建一个子进程去做,故而需要改变的是当前可执行程序的工作目录,并且需要将环境变量中的 PWD 改变。
    • 测试:
      在这里插入图片描述
  2. 处理内置命令:export

    else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
    
    • 检查用户输入的命令是否是 export,且参数个数为 2。
    • 功能:
      • 设置环境变量,将用户输入的 变量=值 格式字符串存储到全局变量 myenv
      • 调用 putenv 将该变量添加到环境变量中。
    • 实现思路:我们输入的环境变量实际上是保存在commandline当中,只要当下一次输入指令,上一次定义的环境变量就会被清空。putenv 添加环境变量,并不是把对应的字符串深拷贝到系统的环境变量表当中,而是把该字符串的地址保存在系统的环境变量表中(浅拷贝)。因此我们要确保保存环境变量字符串的那个地址里的环境变量不会被修改,所以我们需要为用户输入的环境变量,也就是那一串字符串单独开辟一块空间进行存储,保证在内次重新输入指令的时候,不会影响到之前用户添加的环境变量。所以我们需要定义一个二维数组用于存储导出的环境变量(这里只简单地分配了一维数组)。
    • 测试:
      在这里插入图片描述
      在这里插入图片描述
      注意看,连续两次的写入导致第一次的定义的环境变量被覆盖了。
  3. 处理内置命令:echo

    else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
    
    • 检查用户输入的命令是否是 echo,且参数个数为 2。
    • 功能:
      • 如果参数为 "$?",显示上一个命令的退出状态(从全局变量 lastcode 获取)。
      • 如果参数以 $ 开头,显示对应环境变量的值。
      • 否则,直接打印参数内容。
    • 测试:
      在这里插入图片描述
      在这里插入图片描述
      故意写成 ll (没有定义的),导致子进程退出,退出码刚好是44。
  4. 处理外部命令:自动为 ls 添加 --color 参数

    if (strcmp(_argv[0], "ls") == 0)c
    
    • 检查用户输入的命令是否是 ls
    • 功能:
      • 自动为命令添加 --color 参数,用于增强可读性(适用于 Linux 的 ls 命令)。
      • 确保参数数组以 NULL 结束。
    • 测试:
      在这里插入图片描述
      总结:说了这么久的环境变量,那么请问我们登录的时候,系统中的 shell 的环境变量又是从哪里来的呢?答案是 Bash。那么 Bash 的环境变量又是从何而来?当然是系统自带的目录文件中写入的。

结语

通过以上对简易 Shell 实现过程的详细讲解,相信大家对 Shell 的工作流程和实现细节已经有了较为全面的认识。从命令行提示符的设计,到输入指令的处理,再到不同类型命令的执行,每一个环节都凝聚着操作系统与编程的智慧。

虽然本文实现的 Shell 只是一个简化版本,但其中涉及的技术和思想为进一步探索更复杂、功能更强大的 Shell,乃至深入理解操作系统的运行机制奠定了坚实的基础。希望大家能将所学应用到实际开发或探索中,不断挖掘操作系统的奥秘。如果在阅读过程中有任何疑问或想法,欢迎在评论区交流分享,也别忘了点赞、收藏并持续关注后续更多精彩的技术内容!

天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/82452.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

湖仓一体架构在金融典型数据分析场景中的实践

在数字经济与金融科技深度融合的今天&#xff0c;数据已成为金融机构的核心战略资产。然而&#xff0c;传统数据架构面临着三大困局&#xff0c;制约着金融机构数据价值的充分释放。 一、需求驱动更多银行数据分析场景 金融机构&#xff0c;特别是银行业&#xff0c;面临着双重…

基于Llama3的开发应用(一):Llama模型的简单部署

Llama模型的简单部署 0 前言1 环境准备1.1 硬件环境1.2 软件环境 2 Meta-Llama-3-8B-Instruct 模型简介2.1 Instruct含义2.2 模型下载 3 简单调用4 FastAPI 部署4.1 通过FastAPI简单部署4.2 测试 5 使用 streamlit 构建简易聊天界面6 总结 0 前言 本系列文章是基于Meta-Llama-…

模拟太阳系(C#编写的maui跨平台项目源码)

源码下载地址&#xff1a;https://download.csdn.net/download/wgxds/90789056 本资源为用C#编写的maui跨平台项目源码&#xff0c;使用Visual Studio 2022开发环境&#xff0c;基于.net8.0框架&#xff0c;生成的程序为“模拟太阳系运行”。经测试&#xff0c;生成的程序可运行…

基于人工智能的个性化 MySQL 学习路径推荐研究

基于人工智能的个性化 MySQL 学习路径推荐研究 摘要: 随着信息技术的飞速发展,数据库在各行业应用广泛,MySQL 作为主流数据库之一,学习需求庞大。然而,不同学习者在知识水平、学习进度和目标上存在差异,传统统一的学习路径难以满足个性化需求。本研究通过运用人工智能技…

OSPF综合应用

​ 要求&#xff1a; 1&#xff0c;R5为ISP&#xff0c;其上只能配置IP地址&#xff1b;R4作为企业边界路由器&#xff0c; 出口公网地址需要通过PPP协议获取&#xff0c;并进行chap认证 2&#xff0c;整个OSPF环境IP基于172.16.0.0/16划分&#xff1b; 3&#xff0c;所有设备…

中国古代史1

朝代歌 三皇五帝始&#xff0c;尧舜禹相传。 夏商与西周&#xff0c;东周分两段。 春秋和战国&#xff0c;一统秦两汉。 三分魏蜀吴&#xff0c;二晋前后延。 南北朝并立&#xff0c;隋唐五代传。 宋元明清后&#xff0c;皇朝至此完。 原始社会 元谋人&#xff0c;170万年前…

ensp的华为小实验

1.先进行子网划分 2.进行接口的IP地址配置和ospf的简易配置&#xff0c;先做到全网小通 3.进行ospf优化 对区域所有区域域间路由器进行一个汇总 对区域1进行优化 对区域2.3进行nssa设置 4.对ISP的路由进行协议配置 最后ping通5.5.5.5

华为OD机试真题——荒岛求生(2025A卷:200分)Java/python/JavaScript/C/C++/GO最佳实现

2025 A卷 200分 题型 本专栏内全部题目均提供Java、python、JavaScript、C、C、GO六种语言的最佳实现方式&#xff1b; 并且每种语言均涵盖详细的问题分析、解题思路、代码实现、代码详解、3个测试用例以及综合分析&#xff1b; 本文收录于专栏&#xff1a;《2025华为OD真题目录…

IOC和Bean

IOC IOC将对象的创建&#xff0c;依赖关系的管理和生命周期的控制从应用程序代码中解耦出来了 IOC容器的依赖注入(DI) 在程序运行过程中动态的向某个对象中注入他所需要的其他对象 依赖注入是基于反射实现的 Spring IOC 容器使用的是Map&#xff08;concorrentMap&#xff…

vue3: pdf.js 2.16.105 using typescript

npm create vite vuepdfpreview //创建项目npm install vue-pdf-embed npm install vue3-pdfjs npm install pdfjs-dist2.16.105 <!--* |~~~~~~~|* | |* | |…

Java面试全栈解析:Spring Boot、Kafka与Redis实战揭秘

《Java面试全栈解析&#xff1a;Spring Boot、Kafka与Redis实战揭秘》 【面试现场】 面试官&#xff1a;&#xff08;推了推眼镜&#xff09;小张&#xff0c;你简历里提到用Spring Boot开发过微服务系统&#xff0c;能说说自动配置的实现原理吗&#xff1f; 程序员&#xff1…

常见的提示词攻击方法 和防御手段——提示词注入(Prompt Injection)攻击解析

提示词注入&#xff08;Prompt Injection&#xff09;攻击解析 提示词注入是一种针对大型语言模型&#xff08;LLM&#xff09;的新型攻击手段&#xff0c;攻击者通过精心设计的输入文本&#xff08;提示词&#xff09;操控AI模型的输出&#xff0c;使其执行非预期行为或泄露敏…

基于NI-PXI的HIL系统开发

基于NI-PXI平台的汽车电控单元HIL系统开发全解析 引言&#xff1a;HIL系统如何成为汽车电控开发的“效率倍增器”&#xff1f; 某车企通过基于NI-PXI的HIL系统&#xff0c;将悬架控制器的测试周期从3个月压缩至2周&#xff0c;故障检出率提升65%。这背后是硬件在环技术对汽车电…

复合机器人案例启示:富唯智能如何以模块化创新引领工业自动化新标杆

在国产工业机器人加速突围的浪潮中&#xff0c;富唯智能复合机器人案例凭借其高精度焊接与智能控制技术&#xff0c;成为行业标杆。然而&#xff0c;随着制造业对柔性化、全场景协作需求的升级&#xff0c;复合机器人正从单一功能向多模态协同进化。作为这一领域的创新者&#…

如何使用极狐GitLab 软件包仓库功能托管 python?

极狐GitLab 是 GitLab 在中国的发行版&#xff0c;关于中文参考文档和资料有&#xff1a; 极狐GitLab 中文文档极狐GitLab 中文论坛极狐GitLab 官网 软件包库中的 PyPI 包 (BASIC ALL) 在项目的软件包库中发布 PyPI 包。然后在需要将它们用作依赖项时安装它们。 软件包库适用…

K8s中的containerPort与port、targetPort、nodePort的关系:

pod中的containerPort与service中的port、targetPort、nodePort的关系&#xff1a; 1、containerPort为pod的配置&#xff0c;对应pod内部服务监听的具体端口&#xff0c;例如nginx服务默认监听80端口&#xff0c;那么nginx的pod的containerPort应该配置为80&#xff0c;例如m…

面试题:QTableView和QTableWidget的异同

目录 1.QTableView简介 2.QTableWidget简介 3.QTableView和QTableWidget不同 4.总结 1.QTableView简介 QTableView是一个基于模型-视图架构的表格控件&#xff0c;用于展示表格形式的数据。同样需要关联一个QAbstractTableModel或其子类&#xff08;如QStandardItemModel&a…

smbd:快速拉取服務端SMB共享文件脚本工具

地址:https://github.com/MartinxMax/smbd smbd 是一款簡單的 Bash 腳本&#xff0c;用於自動化從共享目錄透過 SMB/CIFS 協議下載檔案的過程。此工具設計用來與遠端 SMB 分享進行互動&#xff0c;並將其內容下載到本地目錄中。 環境需求 $ apt install smbclient 使用方式 …

MiInsertVad函数分析之nt!MMVAD结构

MiInsertVad函数分析之nt!MMVAD结构 1: kd> dt nt!MMVAD 89520270 0x000 u1 : __unnamed 0x004 LeftChild : (null) 0x008 RightChild : (null) 0x00c StartingVpn : 0x2b0 0x010 EndingVpn : 0x2c5 0x014 u …

OSPF不规则区域划分

1、建立一条虚链路 vlink 可以被视为是⻣⼲区域的⼀段延伸。 这⼀条虚拟的链路&#xff0c;只能够跨域⼀个⾮⻣⼲区域。 [r2-ospf-1-area-0.0.0.1]vlink-peer 3.3.3.3 [r3-ospf-1-area-0.0.0.1]vlink-peer 2.2.2.2 在没有建立虚链路之前,r1是不能ping r4的。vlink建⽴的邻居关…