目录
什么是shell?

Shell 是一个命令行解释器,它为用户提供了一个与操作系统交互的界面。用户通过 Shell 输入命令,Shell 负责解析这些命令并将其传递给操作系统执行。
Shell 的主要功能:
-
命令执行 :Shell 可以直接执行用户输入的命令。例如,
ls
用于列出当前目录下的文件和文件夹。 -
脚本编写:Shell 支持编写脚本,这些脚本是由一系列命令组成的文件,能够自动化重复的任务。例如,可以编写一个 Shell 脚本来备份文件、安装软件等。
-
管道和重定向 :Shell 支持管道 (
|
) 和重定向 (>
,<
),使得用户可以将一个命令的输出作为另一个命令的输入,或者将输出重定向到文件中。 -
环境管理 :Shell 允许用户设置和管理环境变量,这些变量可以影响 Shell 的行为和程序的运行方式。例如,
PATH
环境变量用于指定系统查找可执行文件的路径。
Shell 的工作原理:
-
用户输入:用户在终端中输入命令,Shell 接收这些命令并将其解析为一系列的指令。
-
解析命令:Shell 解析用户输入的命令,并将其分解成可执行的指令和参数。
-
执行命令:Shell 使用系统调用来创建进程,并在子进程中执行用户输入的命令。
-
处理输出:命令执行完成后,Shell 将命令的输出显示在终端上,并返回到用户的命令提示符,等待下一个命令。
本文将对shell进行简单对模拟
所用头文件、宏、全局变量:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h> //错误码
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#define SIZE 512
#define ZERO '\0'
#define SEP " "
#define NUM 32
#define SkipPath(p) \
do \
{ \
p += strlen(p) - 1; \
while (*p != '/') \
p--; \
} while (0) // 宏函数
// 缓冲区
char *gArgv[NUM];
char cwd[SIZE * 2];
int lastcode=0;
输出命令行提示符

根据上图命令提示符:
-
获取用户名
-
获取主机名
-
获取当前所处路径
1.获取用户名
介绍getenv: <stdlib.h>
getenv
函数用于获取环境变量的值。
cppchar *getenv(const char *name);
参数 :
name
是环境变量的名称(以 C 字符串形式传递)。返回值 :如果环境变量存在,则返回其对应的值(也是 C 字符串形式)。如果环境变量不存在,则返回
NULL
。
用env
查看环境变量:

环境变量USER=(当前用户名)
cpp
const char *GetUserName()
{
const char *name = getenv("USER");
if (name == NULL)//没找到用户
return "None";
return name;
}
2.获取主机名

cpp
const char *GetHostName()
{
const char *hostname = getenv("HOSTNAME");
if (hostname == NULL)
return "None";
return hostname;
}
3.获取当前所处路径

cpp
const char *Getcwd()
{
const char *cwd = getenv("PWD");
if (cwd == NULL)
return "None";
return cwd;
}
输出命令行提示符

定义相关宏函数:
cpp
#define SkipPath(p) \
do \
{ \
p += strlen(p) - 1; \
while (*p != '/') \
p--; \
} while (0) // 宏函数
主代码:
cpp
void MakeCommandLinePrint()
{
char line[SIZE];
const char *username = GetUserName();
const char *hostname = GetHostName();
const char *cwd = Getcwd();
SkipPath(cwd);
snprintf(line, SIZE, "[%s@%s %s]>", username, hostname, strlen(cwd) == 1 ? "/" : cwd + 1); //+1不打印'/'
printf("%s", line);
fflush(stdout);
}
snprintf:
cppint snprintf(char *str, size_t size, const char *format, ...);
char \*str
:这是一个字符数组,用于存储格式化后的字符串。
size_t size
:指定str
数组的大小(即最大写入长度)。snprintf
会确保不会写入超过这个长度的字符,以防止缓冲区溢出。
const char \*format
:格式字符串,类似于printf
的格式字符串,用于定义输出的格式。
...
:格式字符串中的格式说明符所对应的变量。返回值
snprintf
返回实际写入的字符数(不包括终止的空字符'\0'
)。如果返回值大于或等于
size
,说明输出被截断了(即实际需要的字符数超出了提供的缓冲区大小)。在这种情况下,str
数组将会以空字符'\0'
结尾,确保字符串是以正确的格式终止的。优点
安全性 :
snprintf
提供了对缓冲区溢出的保护,通过指定缓冲区的大小来避免写入超过缓冲区限制的数据。灵活性 :可以格式化各种类型的数据,类似于
printf
函数,但输出到字符串中而不是直接到标准输出。
fflush:
功能 :
fflush
用于刷新指定文件流的缓冲区,确保缓冲区中的数据被立即写入目标流(如终端或文件)。
cppint fflush(FILE *stream);
FILE \*stream
:指向FILE
结构的指针,表示要刷新的流。可以是标准输入 (stdin
)、标准输出 (stdout
)、标准错误 (stderr
),或者其他打开的文件流。如果stream
为NULL
,则fflush
刷新所有输出流。返回值
成功 :返回
0
。失败 :返回
EOF
,并且设置errno
以指示错误类型。
效果展示:

此处的None是应为我个人使用的Unix系统进行执行,Unix系统获取主机名的环境变量并不叫
HOSTNAME
获取用户输入的命令
cpp
char usercommand[SIZE];//定义数组获得用户输入的命令
int n = GetUserCommand(usercommand, sizeof(usercommand));
if (n <= 0)
return 1; // 获取失败,重新获取
cpp
int GetUserCommand(char command[], size_t n)
{
char *s = fgets(command, n, stdin);
if (s == NULL)
return 1;
command[strlen(command) - 1] = ZERO; // 移除最后的换行符
return strlen(command);
}
//#define ZERO '\0'
fgets:
cppchar *fgets(char *str, int n, FILE *stream);
参数
char \*str
:指向用于存储读取内容的字符数组。fgets
将从文件流中读取的字符存储到这个数组中。
int n
:指定要读取的最大字符数(包括终止的空字符'\0'
)。实际读取的字符数最多为n-1
。
FILE \*stream
:指向FILE
结构的指针,表示要读取的文件流。可以是标准输入 (stdin
)、标准输出 (stdout
)、标准错误 (stderr
),或其他打开的文件流。返回值
成功 :返回
str
(即指向字符数组的指针),并将读取的内容存储在这个数组中。遇到文件结束符 (EOF) 或错误 :返回
NULL
,并且可能设置errno
以指示错误。特点
缓冲区管理 :
fgets
会在读取到换行符、文件结束符或达到最大字符数(n-1
)时停止,自动添加空字符'\0'
作为字符串结束符。换行符处理 :如果读取的行包含换行符,
fgets
会将换行符包括在返回的字符串中,直到换行符之前的所有字符(最多n-1
个字符)。安全性 :相比于
gets
,fgets
是更安全的,因为它允许指定缓冲区大小,防止缓冲区溢出。
分割命令
获取到用户输入的命令,要对用户对命令进行拆解
cpp
void SplitCommand(char command[], size_t n)
{
gArgv[0] = strtok(command, SEP); // 第一个参数
int index = 1;
while ((gArgv[index++] = strtok(NULL, SEP)))
;
}
//#define SEP " "
strtok:
cppchar *strtok(char *str, const char *delim);
参数
char \*str
:待分割的字符串。如果这是第一次调用strtok
,则传入待分割的字符串。如果是后续调用,应传入NULL
,以便继续分割上次的字符串。
const char \*delim
:包含所有分隔符字符的字符串。strtok
将根据这些字符分割输入字符串。返回值
成功:返回指向当前分割出的子串的指针。
结束 :当没有更多的子串时,返回
NULL
。功能描述
首次调用 :在第一次调用
strtok
时,传入要分割的字符串str
和分隔符delim
。strtok
会找到第一个分隔符,将它替换为'\0'
(字符串结束符),并返回指向第一个子串的指针。后续调用 :在后续调用中,传入
NULL
作为str
参数,strtok
会继续使用上次传入的字符串,并返回下一个分隔符之间的子串,直到没有更多子串为止。
检查命令是否是内建命令
cpp
n = CheckBuildin();
if (n)
continue;
cpp
int CheckBuildin()
{
int yes = 0; // 标记是否识别到内建命令,初始值为0(表示未识别)
const char *enter_cmd = gArgv[0]; // 获取输入命令的第一个参数,即命令名称
// 判断是否为内建命令
// 检查是否为 "cd" 命令
if (strcmp(enter_cmd, "cd") == 0)
{
yes = 1; // 识别到内建命令,设置标记为1
cd(); // 调用处理 "cd" 命令的函数
}
// 检查是否为 "echo" 命令,并且第二个参数是否为 "$?"
else if (strcmp(enter_cmd, "echo") == 0 && strcmp(gArgv[1], "$?") == 0)
{
yes = 1; // 识别到内建命令,设置标记为1
printf("%d\n", lastcode); // 打印上一个命令的返回状态码
lastcode = 0; // 重置返回状态码为0,准备下一次使用
}
return yes; // 返回识别标记,1表示识别到内建命令,0表示没有识别到
}
cd
函数的主要功能是:
-
更改当前进程的工作目录。
-
更新
PWD
环境变量,以确保环境变量反映新的工作目录路径。这对于命令行提示符的显示(如果该程序是一个命令行工具的一部分)以及子进程继承环境变量是重要的。
cpp
void cd()
{
const char *path = gArgv[1]; // 获取命令行参数中的路径(即 'cd' 命令后的路径)
if (path == NULL)
path = GetHome(); // 如果路径参数为空,则设置路径为用户主目录
// 使用 chdir 函数更改当前工作目录
// chdir(path) 将当前进程的工作目录更改为 path 指定的路径
chdir(path);
// 刷新环境变量以更新命令行提示符的路径
char temp[SIZE * 2]; // 临时缓冲区,用于存储当前工作目录的路径
// 使用 getcwd 函数获取当前工作目录的路径,并将其存储在 temp 中
// getcwd(temp, sizeof(temp)) 将当前工作目录路径存储在 temp 中
getcwd(temp, sizeof(temp));
// 使用 snprintf 函数格式化路径为 "PWD=当前路径",并存储在 cwd 中
// 这样可以更新环境变量 PWD 以反映当前工作目录
snprintf(cwd, sizeof(cwd), "PWD=%s", temp);
// 使用 putenv 函数更新环境变量,将 cwd 变量的内容写入环境变量
// 这会影响到环境变量的值,使得命令行提示符显示正确的路径
putenv(cwd);
}
执行命令
cpp
// 退出码
void Die()
{
exit(1);
}
void ExecuteCommand()
{
pid_t id = fork(); // 创建一个新进程,返回值存储在 id 中
if (id < 0)
{
// 如果 fork 返回负值,则表示进程创建失败,调用 Die() 函数处理错误
Die();
}
else if (id == 0)
{
// 子进程部分
// 使用 execvp 函数替换当前进程的映像为新命令的映像
// gArgv[0] 是要执行的命令,gArgv 是命令及其参数的数组
execvp(gArgv[0], gArgv);
// execvp 如果成功,则不会返回;如果失败,返回到这里并退出进程
// 使用 errno 作为退出状态码
exit(errno);
}
else
{
// 父进程部分
int status = 0; // 用于存储子进程的退出状态
pid_t rid = waitpid(id, &status, 0); // 等待子进程结束,并获取其退出状态
if (rid > 0)
{
// 使用 WEXITSTATUS 宏获取子进程的退出状态码
lastcode = WEXITSTATUS(status);
// 如果退出状态码不为0,则打印错误信息
if (lastcode != 0)
{
// 打印命令名、错误描述和错误代码
// strerror(lastcode) 将状态码转换为可读的错误描述
printf("%s:%s:%d\n", gArgv[0], strerror(lastcode), lastcode);
}
}
}
}
execvp:
cppint execvp(const char *file, char *const argv[]);
参数说明
file
:要执行的程序的名称或路径。可以是一个可执行文件的名称(当前路径下的文件)或者是绝对路径/相对路径。
argv
:一个字符串数组,包含要传递给程序的参数。数组的第一个元素argv[0]
通常是程序的名称,数组的最后一个元素必须是NULL
,以标识参数的结束。主要功能
替换当前进程 :当调用
execvp
成功时,当前进程的上下文(包括代码、数据、堆栈等)会被新程序的上下文所替代,因此execvp
之后的代码不会被执行。换句话说,调用execvp
后,执行的程序将成为当前进程。参数传递 :通过
argv
数组传递给新程序的参数,可以让新程序在执行时获得所需的命令行参数。搜索路径 :
execvp
会在系统的 PATH 环境变量中查找指定的可执行文件,因此,你可以直接传递程序名称,而无需提供完整路径。此外,如果只给出文件名,execvp
将自动在 PATH 中的目录中搜索该文件。返回值
成功 :
execvp
成功时不会返回(因为当前进程已经被新程序替换)。失败 :如果失败,则返回 -1,并设置
errno
以指示错误的类型。在许多情况下,特别是在创建子进程的场景下,
execvp
是调用新程序的常用方式。例如,用户输入命令后,可以使用fork
创建一个子进程,并在子进程中调用execvp
来执行用户指定的命令,从而使得 shell 能够运行各种程序。
完整代码及最终效果
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h> //错误码
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#define SIZE 512
#define ZERO '\0'
#define SEP " "
#define NUM 32
#define SkipPath(p) \
do \
{ \
p += strlen(p) - 1; \
while (*p != '/') \
p--; \
} while (0) // 宏函数
// 缓冲区
char *gArgv[NUM];
char cwd[SIZE * 2];
int lastcode=0;
// 退出码
void Die()
{
exit(1);
}
const char *GetHome()
{
const char *home = getenv("HOME");
if (home == NULL)
return "/r";
return home;
}
// 获取用户名字,失败返回空
const char *GetUserName()
{
const char *name = getenv("USER");
if (name == NULL)
return "None";
return name;
}
// 获取主机名
const char *GetHostName()
{
const char *hostname = getenv("HOSTNAME");
if (hostname == NULL)
return "None";
return hostname;
}
// 获取当前所处路径
const char *Getcwd()
{
const char *cwd = getenv("PWD");
if (cwd == NULL)
return "None";
return cwd;
}
// 输出命令行提示符
void MakeCommandLinePrint()
{
char line[SIZE];
const char *username = GetUserName();
const char *hostname = GetHostName();
const char *cwd = Getcwd();
SkipPath(cwd);
snprintf(line, SIZE, "[%s@%s %s]>", username, hostname, strlen(cwd) == 1 ? "/" : cwd + 1); //+1不打印'/'
printf("%s", line);
fflush(stdout);
}
// 获取用户输入命令
int GetUserCommand(char command[], size_t n)
{
char *s = fgets(command, n, stdin);
if (s == NULL)
return 1;
command[strlen(command) - 1] = ZERO; // 移除最后的换行符
return strlen(command);
}
// 分割用户命令
void SplitCommand(char command[], size_t n)
{
gArgv[0] = strtok(command, SEP); // 第一个参数
int index = 1;
while ((gArgv[index++] = strtok(NULL, SEP)))
;
}
void ExecuteCommand()
{
pid_t id = fork();
if (id < 0)
Die();
else if (id == 0)
{
// child
execvp(gArgv[0], gArgv);
exit(errno);
}
else
{
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
lastcode=WEXITSTATUS(status);//获取错误代码
if(lastcode!=0)printf("%s:%s:%d\n",gArgv[0],strerror(lastcode),lastcode);//打印出对应的错误信息
}
}
}
void cd()
{
const char *path = gArgv[1];
if (path == NULL)
path = GetHome();
// path一定存在
// chdir:更改当前工作路径
chdir(path);
// 刷新环境变量
char temp[SIZE * 2];
getcwd(temp, sizeof(temp)); // getcwd:获取当前工作目录的路径,返回当前工作目录的路径名
// 更新当前环境变量(不更新导致命令行提示符path不更新)
snprintf(cwd, sizeof(cwd), "PWD=%s", temp);
putenv(cwd);
}
int CheckBuildin()
{
int yes = 0;
const char *enter_cmd = gArgv[0];
// 判断是不是内建命令
if (strcmp(enter_cmd, "cd") == 0)
{
yes = 1;
cd();
}
else if(strcmp(enter_cmd,"echo")==0 && strcmp(gArgv[1],"$?")==0)
{
yes=1;
printf("%d\n",lastcode);
lastcode=0;
}
return yes;
}
int main()
{
int quit = 0;
while (!quit)
{
// 1. 输出命令行提示符
MakeCommandLinePrint();
// 2. 获取用户输入的命令
char usercommand[SIZE];
int n = GetUserCommand(usercommand, sizeof(usercommand));
if (n <= 0)
return 1; // 获取失败,重新获取
// 3. 分割命令
SplitCommand(usercommand, sizeof(usercommand));
// 4.检查命令是否是内建命令
n = CheckBuildin();
if (n)
continue;
// n.执行命令
ExecuteCommand();
}
return 0;
}
// ls -l --color

本篇讲解就到这啦,感谢翻阅!