目录
前言
该自定义shell并没有任何实用价值,一是因为其功能并不完善,二是因为其是建立在bash的基础之上进行的编写的成果(初始环境变量取自bash),仅具有学习价值。
为了贴近真实的 shell 程序开发,我们的代码完全用C语言完成。
本文通过完成一个自定义shell小项目的方式,对前面学习的知识做一个简单的总结,帮助读者深入理解有关概念(子进程,进程调度,环境变量……)。
前置参考文章:https://blog.csdn.net/2302_80372340/category_12833896.html
1. 程序框架
在开始之前,我们要先明确我们要做什么,我们的目标是:(1)不断显示当前用户名、主机和工作目录,并要求用户输入指令;(2)解析用户输入的指令,执行对应的操作或启动对应的进程。
当然,在正式开始之前,我们需要初始化我们自己的 shell 程序(后文统称myshell)的环境变量。
// myshell的环境变量
#define MAX_ENV_SIZE 100
char *my_env[MAX_ENV_SIZE];
int env_count = 0;
据此,我们可以大致拟出 myshell 的框架:
int main()
{
// 初始化环境变量
InitEnv();
// PrintEnv();
while(true)
{
// 打印命令行提示符
PrintCommandPrompt();
// 获取命令行输入
char commandline[MAX_COMMAND_LEN];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
// 判断是否有重定向
RedirCheck(commandline);
// 命令行解析
if(!ParseCommandLine(commandline))
continue;
// PrintArgv();
// 判断是否有为内建命令,如果是就执行
// 后文中再解释何为内建命令
if(CheckAndExecBuiltin())
continue;
// 执行命令
Execute();
}
return 0;
}
bash 会在启动时,通过解析配置文件,将环境变量加载到内存中并维护起来。
我们并不了解其配置文件的解析方式,为了简单起见,我们直接抄袭 bash 的环境变量以初始化的环境变量。
在拷贝完成之后,需要让environ指向我们自己的环境变量表,这样一来,各种与环境变量有关的系统调用就会对我们自己的环境变量表进行操作。
实际上,就算直接使用 environ 指向的环境变量表也是完全一样的效果,因为其本来就是从 bash 继承下来的一个拷贝。但就像我们之前说的,我们只是用拷贝 bash 环境变量的方式代替了从配置文件中读取的过程,真正的 shell 程序肯定是要自己维护一张环境变量表的。虽然将环境变量拷贝到自己的环境变量表中略显多余,但是为了帮助我们更好地理解 shell 程序的运作方式,还是自己维护一张环境变量表的方式更好。
// 初始化环境变量
void InitEnv()
{
memset(my_env, 0, sizeof(my_env));
extern char** environ;
// 本来应该从配置文件中读取,但为了方便就直接拷贝bash的环境变量
for(env_count = 0; environ[env_count]; env_count++)
{
my_env[env_count] = (char*)malloc(strlen(environ[env_count]) + 1);
strcpy(my_env[env_count], environ[env_count]);
}
my_env[env_count++] = NULL;
// 让environ指向我们自己的环境变量表
environ = my_env;
}
2. 打印命令行提示符
所谓命令行提示符,就是:用户名 + 主机 + 工作目录。
上图是Ubuntu环境下的命令行提示符,它将工作目录完整显示出来了,当工作目录层次较深时看起来很冗余。
相比之下,我更喜欢CentOS的格式,下图是myshell运行起来的提示符:
只显示了最后一级工作目录,看起来要简洁一些。
void PrintCommandPrompt()
{
// CentOs的格式,感觉更简洁清晰一些
printf("[%s@%s %s]# ", GetUserName(), GetHostName(), GetBaseName());
}
2.1 获取用户名(GetUserName)
直接到环境变量中找到 "USER" 环境变量即可。
// 默认字符串
const char* default_str = "None";
// 获取用户名
const char* GetUserName()
{
const char* username = getenv("USER");
return username == NULL ? default_str : username;
}
2.2 获取主机名(GetHostName)
在Unbuntu中没有 "HOSTNAME" 环境变量,我们可以通过系统调用 gethostname 来获取。
// 主机名hostname
#define MAX_HOST_SIZE 256
char hostname[MAX_HOST_SIZE];
// 获取主机名
const char* GetHostName()
{
// Ubuntu中没有HOSTNAME环境变量
// const char* hostname = getenv("HOSTNAME");
return gethostname(hostname, sizeof(hostname)) != 0 ? default_str : hostname;
}
2.3 获取工作目录(GetPwd)
同样,在找到环境变量 "PWD" 即可,但是我们只需要最后一级目录,所以对GetPwd进行了一层封装。
这里要解释一下 cwd 是什么。cwd 是进程的一个属性而不是环境变量,用于表示进程的当前工作目录。使用 getcwd 系统调用可以获得 cwd。至于为什么要用 cwd 来更新 PWD ,我们在后文中讲到 cd 命令的时候在详细解释。
// cwd---当前用户工作目录
#define MAX_CWD_SIZE 1024
char cwd[MAX_CWD_SIZE];
char pwdOfcwd[MAX_CWD_SIZE + 6];
// 获取当前工作目录
const char* GetPwd()
{
const char* pwd = getcwd(cwd, sizeof(cwd));
// 更新pwd
if(pwd != NULL)
{
snprintf(pwdOfcwd, sizeof(pwdOfcwd), "PWD=%s", cwd);
putenv(pwdOfcwd);
}
return pwd == NULL ? default_str : pwd;
}
// 最后一级路径,也叫"基本名称"
const char* GetBaseName()
{
const char* pwd = GetPwd();
int pos = strlen(pwd) - 1;
while(pos > 0 && pwd[pos] != '/')
pos--;
if(pwd[pos] == '/') pos++;
return (pwd + pos);
}
3. 获取命令行输入
第一个参数是存放命令行输入的字符数组,第二个参数是字符数组的大小。
这个函数只负责将用户的输入照搬下来,放到一个一维数组,包括空格、重定向符号等。
为了读取一整行的字符,这里用 fgets 函数来进行读取。
// 命令行输入的最大值
#define MAX_COMMAND_LEN 1024
// 在主循环中定义的数组
char commandline[MAX_COMMAND_LEN];
bool GetCommandLine(char* commandline, int n)
{
char* str = fgets(commandline, n, stdin);
if(str == NULL) return false;
// 清理'\n',顺便在结尾加上'\0'
commandline[strlen(commandline) - 1] = '\0';
if(strlen(commandline) == 0) return false;
return true;
}
4. 判断是否有重定向
在解析命令行之前,需要先判断是否存在重定向,并将重定向类型与相关文件记录下来。接着,将命令行输入中的重定向符号与文件名删除。
这样一来,解析命令行部分要做的就只有将命令行参数按空格分开,而不需要再检查某个参数中是否含有重定向符号了。
// 重定向状态
enum redir_status
{
NONE_REDIR,
INPUT_REDIR,
OUTPUT_REDIR,
APPEND_REDIR
};
enum redir_status redir = NONE_REDIR;
#define MAX_FILENAME 1024
char filename[MAX_FILENAME];
void RedirCheck(char* commandline)
{
redir = NONE_REDIR;
int len = strlen(commandline);
int pos = 0;
for(pos = 0; pos < len; pos++)
{
// 输入重定向
if(commandline[pos] == '<')
{
commandline[pos] = '\0';
redir = INPUT_REDIR;
break;
}
else if(commandline[pos] == '>')
{
commandline[pos] = '\0';
// 追加重定向
if(pos < len - 1 && commandline[pos + 1] == '>')
{
redir = APPEND_REDIR;
pos++;
}
// 输出重定向
else
{
redir = OUTPUT_REDIR;
}
break;
}
}
pos++;
// 发生了重定向,提取文件名
if(pos != len)
{
while(commandline[pos] == ' '){pos++;}
int i = 0;
for(i = 0; pos + i < len; i++)
{
filename[i] = commandline[pos + i];
}
filename[i] = '\0';
}
}
5. 解析命令行
利用 strtok 函数将命令行的输入按空格拆分成一个个的命令行参数,并存放到命令行参数向量 my_argv 中。
// 命令行参数
#define MAX_ARGC 128
char* my_argv[MAX_ARGC];
int my_argc = 0;
bool ParseCommandLine(char* commandline)
{
my_argc = 0;
static const char sep[] = {" "};
for(my_argv[my_argc] = strtok(commandline, sep); my_argv[my_argc++]; my_argv[my_argc] = strtok(NULL, sep)){;}
my_argc--;
return my_argc > 0 ? true : false;
}
6. 内建命令
在最后一个部分中,我们可以通过启动子程序的方式来执行大多数命令(就像 bash 做的那样),但是某些命令却不能用这样的方式来实现,因为它们是 shell 本身的一种功能,而非外部命令。
在 Linux 和类 Unix 系统的 Shell 中,内建命令(Built-in Command) 是直接集成在 Shell 解释器内部的命令,不需要调用外部的可执行文件。它们由 Shell 自身直接执行,因此具有更高的执行效率和特殊的功能特性。
6.1 内建命令的特点
直接由 Shell 解释器处理:内建命令的代码是 Shell 程序的一部分,执行时不会创建新的进程(例如 cd、echo、export)。
与 Shell 环境紧密相关:内建命令通常用于修改 Shell 自身的状态(例如修改当前目录 cd、设置环境变量 export)。
执行速度快:因为不需要启动外部进程或搜索磁盘上的可执行文件。
无法通过 PATH 环境变量查找:内建命令的名称是固定的,不能通过路径调用(例如 /bin/cd 并不存在,但 cd 是 Shell 的内建命令)。
6.2 常见内建命令
- cd:切换当前工作目录。
- echo:输出文本。
- export:设置环境变量。
- source(或 .):加载并执行脚本(不创建子 Shell)。
- exit:退出 Shell 或脚本。
- alias/unalias:设置或取消命令别名。
- type:查看命令类型(内建、外部或别名)。
6.3 内建命令 vs 外部命令
特性 | 内建命令 | 外部命令 |
存储位置 | 集成在 Shell 解释器中 | 存储在磁盘上的可执行文件(如 /bin/ls) |
执行方式 | 由 Shell 直接执行 | 需要启动新进程(通过 fork + exec) |
依赖环境 | 直接影响当前 Shell 环境 | 在子进程中运行,不影响父 Shell 环境 |
执行速度 | 快(无进程创建开销) | 慢(需启动进程和加载可执行文件) |
6.4 为什么需要内建命令
- 修改 Shell 自身状态:例如 cd 必须内建,因为外部命令无法修改父 Shell 的当前目录。
- 提高效率:高频操作(如 echo)内建可减少进程创建开销。
- 提供特殊功能:例如 source 命令用于在当前 Shell 中执行脚本,而非新建子进程。
某些命令可能同时有内建和外部版本(例如 echo、printf),默认优先使用内建版本。可以通过路径强制调用外部版本(例如 /bin/echo)。
不同 Shell(如 Bash、Zsh)的内建命令可能略有差异。
6.5 处理内建命令
下面的代码仅实现了内建命令 cd 和 echo,对于其他的内建命令,各位可以自行尝试实现。
bool CheckAndExecBuiltin()
{
if(strcmp(my_argv[0], "cd") == 0)
{
Cd();
return true;
}
else if(strcmp(my_argv[0], "echo") == 0)
{
Echo();
return true;
}
else if(strcmp(my_argv[0], "export") == 0)
{
// Todo
return true;
}
else if(strcmp(my_argv[0], "alias") == 0)
{
// Todo
return true;
}
else
{
return false;
}
}
6.5.1 cd 命令
cd 命令的作用是修改 shell 的工作目录,即修改 shell 进程的 cwd 属性,在代码层面,我们可以使用 chdir 系统调用来实现这一点。
// 内建命令cd
void Cd()
{
if(my_argc == 1 || strcmp(my_argv[1], "~") == 0 || strcmp(my_argv[1], "-") == 0)
{
const char* home = GetHome();
if(strcmp(home, default_str) != 0)
chdir(home);
if(strcmp(my_argv[1], "-") == 0)
printf("%s\n", home);
}
else
{
chdir(my_argv[1]);
}
}
cwd(Current Working Directory)是进程的一个属性,表示进程当前的工作目录。
PWD(Print Working Directory)是进程环境变量表中的一个环境变量,用于打印当前工作目录。
在 bash 中,我们使用 cd 修改的是 cwd,程序关注的也是 cwd (cwd + 相对路径 = 绝对路径),但是 bash 会自动更新 PWD 。
当我们自己管理环境变量表时,一定要记得更新 PWD ,这一点我们放到 GetPwd 中完成。
6.5.2 echo 命令
// 最近的一次指令执行的退出码
int last_exit_code = 0;
// 内建命令echo
void Echo()
{
FILE* file = stdout;
if(redir == OUTPUT_REDIR)
{
file = fopen(filename, "w");
}
else if(redir == APPEND_REDIR)
{
file = fopen(filename, "a");
}
if(strcmp(my_argv[1], "$?") == 0)
{
fprintf(file, "%d\n", last_exit_code);
}
else if(my_argv[1][0] == '$')
{
const char* env = getenv(my_argv[1] + 1);
if(env)
fprintf(file, "%s\n", env);
}
else
{
fprintf(file, "%s\n", my_argv[1]);
}
if(file != stdout)
fclose(file);
}
7. 执行命令
就像我们在Linux笔记---进程:进程替换-CSDN博客和Linux笔记---系统文件I/O-CSDN博客中讲到的,启动子程序,重定向,程序替换,三步即可完成。
看上去是核心,实际上却是全篇最简单的部分(因为巨人主要站在这部分)。
// 执行
void Execute()
{
pid_t id = fork();
if(id == 0)
{
int fd = 0;
if(redir == INPUT_REDIR)
{
fd = open(filename, O_RDONLY);
if(fd < 0) exit(1);
dup2(fd, 0);
}
else if(redir == OUTPUT_REDIR)
{
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0) exit(1);
dup2(fd, 1);
}
else if(redir == APPEND_REDIR)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0) exit(1);
dup2(fd, 1);
}
execvp(my_argv[0], my_argv);
exit(1);
}
redir = NONE_REDIR;
int status;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
last_exit_code = WEXITSTATUS(status);
}
8. 完整代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/stat.h>
// myshell的环境变量
#define MAX_ENV_SIZE 100
char *my_env[MAX_ENV_SIZE];
int env_count = 0;
// cwd---当前用户工作目录
#define MAX_CWD_SIZE 1024
char cwd[MAX_CWD_SIZE];
char pwdOfcwd[MAX_CWD_SIZE + 6];
// 主机名hostname
#define MAX_HOST_SIZE 256
char hostname[MAX_HOST_SIZE];
// 命令行参数
#define MAX_ARGC 128
char* my_argv[MAX_ARGC];
int my_argc = 0;
// 命令行输入的最大值
#define MAX_COMMAND_LEN 1024
// 最近的一次指令执行的退出码
int last_exit_code = 0;
// 默认字符串
const char* default_str = "None";
// 重定向状态
enum redir_status
{
NONE_REDIR,
INPUT_REDIR,
OUTPUT_REDIR,
APPEND_REDIR
};
enum redir_status redir = NONE_REDIR;
#define MAX_FILENAME 1024
char filename[MAX_FILENAME];
// 初始化环境变量
void InitEnv()
{
memset(my_env, 0, sizeof(my_env));
extern char** environ;
// 本来应该从配置文件中读取,但为了方便就直接拷贝bash的环境变量
for(env_count = 0; environ[env_count]; env_count++)
{
my_env[env_count] = (char*)malloc(strlen(environ[env_count]) + 1);
strcpy(my_env[env_count], environ[env_count]);
}
my_env[env_count++] = NULL;
// 让environ指向环境变量表
environ = my_env;
}
// 打印环境变量,用于调试
void PrintEnv()
{
extern char** environ;
for(int i = 0; environ[i]; i++)
{
printf("%s\n", environ[i]);
}
}
// 获取用户名
const char* GetUserName()
{
const char* username = getenv("USER");
return username == NULL ? default_str : username;
}
// 获取主机名
const char* GetHostName()
{
// Ubuntu中没有HOSTNAME环境变量
// const char* hostname = getenv("HOSTNAME");
return gethostname(hostname, sizeof(hostname)) != 0 ? default_str : hostname;
}
// 获取当前工作目录
const char* GetPwd()
{
const char* pwd = getcwd(cwd, sizeof(cwd));
// 更新pwd
if(pwd != NULL)
{
snprintf(pwdOfcwd, sizeof(pwdOfcwd), "PWD=%s", cwd);
putenv(pwdOfcwd);
}
return pwd == NULL ? default_str : pwd;
}
// 最后一级路径,也叫"基本名称"
const char* GetBaseName()
{
const char* pwd = GetPwd();
int pos = strlen(pwd) - 1;
while(pos > 0 && pwd[pos] != '/')
pos--;
if(pwd[pos] == '/') pos++;
return (pwd + pos);
}
// 获取家目录
const char* GetHome()
{
const char* home = getenv("HOME");
return home == NULL ? default_str : home;
}
void PrintCommandPrompt()
{
// CentOs的格式,感觉更简洁清晰一些
printf("[%s@%s %s]# ", GetUserName(), GetHostName(), GetBaseName());
}
bool GetCommandLine(char* commandline, int n)
{
char* str = fgets(commandline, n, stdin);
if(str == NULL) return false;
// 清理'\n',顺便在结尾加上'\0'
commandline[strlen(commandline) - 1] = '\0';
if(strlen(commandline) == 0) return false;
return true;
}
bool ParseCommandLine(char* commandline)
{
my_argc = 0;
static const char sep[] = {" "};
for(my_argv[my_argc] = strtok(commandline, sep); my_argv[my_argc++]; my_argv[my_argc] = strtok(NULL, sep)){;}
my_argc--;
return my_argc > 0 ? true : false;
}
// 打印命令行参数,用于调试
void PrintArgv()
{
for(int i = 0; i < my_argc; i++)
{
printf("%s ", my_argv[i]);
}
printf("\n");
}
// 内建命令cd
void Cd()
{
if(my_argc == 1 || strcmp(my_argv[1], "~") == 0 || strcmp(my_argv[1], "-") == 0)
{
const char* home = GetHome();
if(strcmp(home, default_str) != 0)
chdir(home);
if(strcmp(my_argv[1], "-") == 0)
printf("%s\n", home);
}
else
{
chdir(my_argv[1]);
}
}
// 内建命令echo
void Echo()
{
FILE* file = stdout;
if(redir == OUTPUT_REDIR)
{
file = fopen(filename, "w");
}
else if(redir == APPEND_REDIR)
{
file = fopen(filename, "a");
}
if(strcmp(my_argv[1], "$?") == 0)
{
fprintf(file, "%d\n", last_exit_code);
}
else if(my_argv[1][0] == '$')
{
const char* env = getenv(my_argv[1] + 1);
if(env)
fprintf(file, "%s\n", env);
}
else
{
fprintf(file, "%s\n", my_argv[1]);
}
if(file != stdout)
fclose(file);
}
bool CheckAndExecBuiltin()
{
if(strcmp(my_argv[0], "cd") == 0)
{
Cd();
return true;
}
else if(strcmp(my_argv[0], "echo") == 0)
{
Echo();
return true;
}
else if(strcmp(my_argv[0], "export") == 0)
{
// Todo
return true;
}
else if(strcmp(my_argv[0], "alias") == 0)
{
// Todo
return true;
}
else
{
return false;
}
}
// 执行
void Execute()
{
pid_t id = fork();
if(id == 0)
{
int fd = 0;
if(redir == INPUT_REDIR)
{
fd = open(filename, O_RDONLY);
if(fd < 0) exit(1);
dup2(fd, 0);
}
else if(redir == OUTPUT_REDIR)
{
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0) exit(1);
dup2(fd, 1);
}
else if(redir == APPEND_REDIR)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0) exit(1);
dup2(fd, 1);
}
execvp(my_argv[0], my_argv);
exit(1);
}
redir = NONE_REDIR;
int status;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
last_exit_code = WEXITSTATUS(status);
}
void RedirCheck(char* commandline)
{
redir = NONE_REDIR;
int len = strlen(commandline);
int pos = 0;
for(pos = 0; pos < len; pos++)
{
// 输入重定向
if(commandline[pos] == '<')
{
commandline[pos] = '\0';
redir = INPUT_REDIR;
break;
}
else if(commandline[pos] == '>')
{
commandline[pos] = '\0';
// 追加重定向
if(pos < len - 1 && commandline[pos + 1] == '>')
{
redir = APPEND_REDIR;
pos++;
}
// 输出重定向
else
{
redir = OUTPUT_REDIR;
}
break;
}
}
pos++;
// 发生了重定向,提取文件名
if(pos != len)
{
while(commandline[pos] == ' '){pos++;}
int i = 0;
for(i = 0; pos + i < len; i++)
{
filename[i] = commandline[pos + i];
}
filename[i] = '\0';
}
}
int main()
{
// 初始化环境变量
InitEnv();
// PrintEnv();
while(true)
{
// 打印命令行提示符
PrintCommandPrompt();
// 获取命令行输入
char commandline[MAX_COMMAND_LEN];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
// 判断是否有重定向
RedirCheck(commandline);
// 命令行解析
if(!ParseCommandLine(commandline))
continue;
// PrintArgv();
// 判断是否有为内建命令,如果是就执行
if(CheckAndExecBuiltin())
continue;
// 执行命令
Execute();
}
return 0;
}
9. 缺陷
除了之前提到的内建命令不完整的问题以外,myshell 与真正的 bash 还有一定的区别:
- 输入时会将方向键,删除键等识别为字符,而不会发挥其原本的文本编辑功能。
- ls 不会根据文件类型显示对应的颜色。
尝试过解决,但是放弃了。先这样吧,说不定以后学的知识多了就知道怎么解决了。