Linux-自定义shell
- [一、 几个基本使用](#一、 几个基本使用)
- 二、自制shell
一、 几个基本使用
1、强制刷新输出缓冲区
在stdout流中,如果输出缓冲区没有满 、没有在输出行换行 或者进程没有终止运行(终止运行强制刷新缓冲区),输出缓冲区上的内容,是不会打印到屏幕上的。
例如:
c
#include <stdio.h>
void PrintCommandline()
{
printf("[%s@%s %s]",getenv("USER"),getenv("HOSTNAME"),getenv("PWD"));
fflush(stdout);
// 因为上面的 printf 没有以 \n 结尾,所以必须手动 fflush 才能看到输出
// 强制刷新输出缓冲区
}
int main()
{
while(1)
{
PrintCommandline();
sleep(1);
}
return 0;
}
2、fgets函数使用
c
#include <stdio.h>
char *fgets(char *str, int n, FILE *stream);
| 参数 | 含义 | 说明 |
|---|---|---|
| str | 字符数组(缓冲区) | 用于存储读取到的字符串。函数会返回这个指针。 |
| n | 最大读取字符数 | 关键参数。函数最多读取 n-1 个字符(留 1 个给 \0)。 |
| stream | 文件流指针 | 指定数据来源,如 stdin(键盘)、fp(文件)。 |
fgets 的读取逻辑非常严谨,它会在以下三种情况发生时停止读取:
读到换行符 (\n):这是最常见的情况(读取一行)。注意:换行符\n会被包含在读取的结果字符串中。
读取了 n-1 个字符:为了防止缓冲区溢出,当读取到 n-1 个字符时,函数会停止,并自动在末尾添加 \0。
遇到文件结尾 (EOF):如果在读取任何字符之前遇到文件结尾,函数返回 NULL。
成功:返回指向字符数组 str 的指针。
失败或到达文件末尾:返回 NULL。
注意:如果在读取任何字符之前就遇到 EOF,返回 NULL;如果读取了部分字符后遇到 EOF,会先返回字符串,下一次调用才会返回 NULL。
c
#include <stdio.h>
#include <string.h>
int main()
{
char buffer[100];
printf("请输入字符串: ");
fgets(buffer, sizeof(buffer), stdin); // 读取一行
// 去除可能存在的换行符
int len = strlen(buffer);
if (len > 0 && buffer[len-1] == '\n') {
buffer[len-1] = '\0'; // 将换行符替换为字符串结束符
}
printf("处理后的字符串: %s", buffer); // 这里输出不会换行
return 0;
}

这张图解释了2个关键点:
1、空格不作为结束符:
fgets 会读取包括空格在内的所有字符,直到遇到换行符 \n 或达到缓冲区限制。
这与 scanf("%s", ...) 不同,scanf 会在遇到空格时停止读取。
2、\n 会被记录在字符串中:
这是 fgets 最显著的特征。它会将换行符作为字符串的一部分存储。而且它在 \n 之后,自动添加一个空字符 \0 作为 C 语言字符串的结束标记。
因此,字符串的结尾是 \n\0,而不是 \0。
3、字符串解析
之前我们通过fgets读取到的是一行字符串,这一行字符串大大小取决于缓冲区的大小,然后我们得到了一个中间带有各种符号的字符串。
假如现在我们需要解析"ls -a -l",需要将其解析为"ls""-a""-l",换句话说就是需要用空格分割字符串,我们可以使用 strtok进行分割。
c
char *strtok(char *str, const char *delim);
// 返回值是字符串指针
// 第一个参数是需要分割的字符串
// 第二个参数是分隔符

strtok 的工作方式非常巧妙,但也容易让人误解。它利用了静态局部变量来保存"当前解析位置"。
第一次调用:传入需要分割的字符串 str。
函数会跳过开头的分隔符。
从第一个非分隔符开始,直到遇到下一个分隔符为止,这一段就是第一个 Token。
函数会修改原字符串,将找到的分隔符替换为 \0(字符串结束符)。
返回指向第一个 Token 的指针。
c
char Command_line[MAX_SIZE] = {0};
char* token;
token = strtok(Command_line," ");
// 当第一次调用的时候,token是返回的字符串首地址,即图中第一个token,结尾添加了\0
// 形成了第一个字符串
printf("Token: %s\n", token);
这样我们获得了第一个token:hello\0
再后续调用的时候,不需要再传入字符串首地址,只需要传入NULL和字符串分隔符即可。
因为如果字符串全部被遍历一遍,后续没有字符串了以后,strtok的返回值为NULL
所以我们使用循环对字符串进行遍历。
c
while(token != NULL)
{
printf("Token: %s\n", token);
token = strtok(NULL," ");
}
这样我们就获得了第二个token:wo\0
第三个token:name\0。
第四个token:le\n\0。
例子:
c
#include <string.h>
#include <stdio.h>
#define MAX_SIZE 100
int argc = 0;
char* argv[100];
int main()
{
char Command_line[MAX_SIZE] = {0};
char* token;
int i = 0;
fgets(Command_line,sizeof(Command_line),stdin);
printf("原始字符串为:%s\n",Command_line);
// 首次调用strtok,传入分隔符
token = strtok(Command_line," ");
// 循环调用strtok,传入NULL,直到返回NULL
while(token != NULL)
{
argv[i] = token;
token = strtok(NULL," "); // 若token为空则退出,token不为空,argv记录地址
i++;
}
argc = i;
for(int i = 0; argv[i];i++)
{
printf("%s\n",argv[i]);
}
return 0;
}
这样一个很简单的代码实现了字符串的分割和命令行参数的模拟
ls -a -l
原始字符串为:ls -a -l 再存入argv中。
ls
-a
-l
这样我们就实现了字符串的分割,和模拟命令行参数存储。

二、自制shell
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <string.h>
#include <string>
#define MAX_SIZE 128
#define MAXARGS 32
// shell 内部维护的第一张表:命令行参数表
char* gargv[100];
int gargc = 0;
const char* gsep = " "; // 注意此处必须设定为字符串,而不是字符
// 环境变量表
char* genv[MAX_SIZE];
int gennv = 0;
// 我们自己shell所处的工作路径
char cwd[MAX_SIZE];
// 最近一次命令执行完毕的退出码
int lastcode = 0;
void LoadEnv() // 正常情况环境变量表内部是从配置文件来的
{
// 现在我们从父进程拷贝
extern char** environ;
for(int i = 0; environ[i];i++)
{
genv[i] = (char*)malloc(sizeof(char)*4096); // 每一个genv[i],都有100个字节的空间
strcpy(genv[i],environ[i]); // 拷贝全局环境变量到genv中
}
}
static std::string rfindDir(const std::string &p)
{
if(p == "/")
return p;
const std::string psep = "/";
auto pos = p.rfind(psep);
if(pos == std::string::npos)
return std::string();
return p.substr(pos+1);// /home/whb
}
const char* GetUserName()
{
char* name = getenv("USER");// 使用getenv()函数从环境变量中获取USER用户名
if (name == NULL)
return "None";
return name;
}
const char* GetHostName()
{
char* hostname = getenv("HOSTNAME");
if (hostname == NULL)
return "None";
return hostname;
}
const char* GetPwd()
{
// 1、通过系统变量修改
char* pwd = getenv("PWD");
// 这样直接获取系统变量PWD的值,因为我们写的myshell是bash的子进程,继承至父进程bash
// 我们通过系统调用chdir修改了myshell这个进程的环境变量中的PWD,所以不影响父进程的PWD
// 换句话说,在cd .. 以后,bash中的PWD不能跟随修改
// 也就是bash进程中的环境变量没有改变,而怎么修改bash的环境变量呢?只能bash自己修改
// 2、通过系统调用getcwd获取当前路径
// getcwd 函数用于获取当前工作目录的绝对路径
// 使用系统调用 char* getpwd(char* buf,size_t size),return 值是当前pwd的字符串首地址
// char *buf:这是一个指向缓冲区的指针,用于存放获取到的当前工作目录路径字符串。
// size_t size:指定缓冲区 buf 的大小(以字节为单位),用于防止缓冲区溢出。
// pwd 其实是cwd的首地址
// char* pwd = getcwd(cwd,sizeof(cwd));
// 截取绝对路径 /home/benjiangliu/lesson23/shell 只想要最后的那个shell
//
if (pwd == NULL)
return "None";
return pwd;
}
void PrintCommandline()
{
printf("[%s@%s %s]",GetUserName(),GetHostName(),rfindDir(GetPwd()).c_str());// 用户名@主机名 当前路径
fflush(stdout);// 强制刷新输出缓冲区
}
int GetCommand(char* commandline,int size)
{
// 这个 if 的意义是:确保 fgets 成功读取了一行输入,
// 否则立即返回错误,避免后续操作出错。它是处理用户输入时必不可少的安全检查。
if(NULL == fgets(commandline,size,stdin))
return 0;
// 3、用户在输入的时候,至少会按下一个回车\n
// fgets 会收集回车键\n,打印的时候,会将\n输出
// command_line = "hello\n\0",strlen(command_line) = 6,因为strlen不包括\0,
// command_line[5] = '\n',所以将command_line[5]改为\0
commandline[strlen(commandline)-1] = '\0'; // 如果只按下空格,fgets记录的只有一个\n,并会被替换为\0
return strlen(commandline); // 若缓冲区只有一个\0,则会自动返回0
}
int ParseCommand(char* commandline)
{
// 每次进入函数,都会清空一次命令行参数表
gargc = 0;
memset(gargv,0,sizeof(gargv));
char* token;
token = strtok(commandline,gsep);
while(token != NULL) // 这么写就算commandline 中只有一个字符串,也会进入while循环,进行记录
{
gargv[gargc++] = token; // 将分割好的命令传入命令行参数表
token = strtok(NULL," ");
}
// printf("argc = %d\n",gargc);
// for(int i = 0;i<gargc;i++)
// {
// printf("gargv[%d] = %s\n",i,gargv[i]);
// }
gargv[gargc] = NULL;// 命令行参数表最后一个参数为NULL
return gargc;
}
int ExecuteCommand()
{
pid_t id = fork(); // 创建子进程执行命令
if(id == 0) // child
{
// 因为需要从环境变量搜索可执行文件
execvp(gargv[0],gargv);// 命令行第一个参数是命令的名字
perror("execvp fault!");
exit(-1); // 防止exec出问题1
}
else if(id > 0)
{
int status = 0;
waitpid(id,&status,0);
if(WIFEXITED(status) && WEXITSTATUS(status) != 0)
{
printf("child process is exit ,exit code is %d\n",WEXITSTATUS(status));
}
lastcode = WEXITSTATUS(status);
// else if(WIFSIGNALED(status))
// {
// printf("child process is exit ,ignel code is %d\n",WTERMSIG(status));
// }
}
else
{
return -1;
}
}
// 0:不是内建命令
// 1: 是内建命令 && 执行内建命令
// 内建命令其实就是bash执行了一个函数
int CheckBuiltinExecute()
{
if(strcmp(gargv[0],"cd") == 0 )
{
// 是内建命令,准备执行
if(gargc == 2) // 说明是由参数的
{
// 1、先修改当前myshell进程的工作路径
// cd 时候新的目标路径 在gargv[1]中
// int chdir(const char* path) // 更改当前进程的工作路径
chdir(gargv[1]);
// 2、更改刷新环境变量,让命令行直接可以调用PWD显示
// 将当前的工作路径加上PWD,构建一个自定义环境变量:PWD=/home/benjiangliu/Bite这样的结构
// 然后修改子进程自定义环境变量,替换继承自父进程bash的环境变量
char pwd[1024];
getcwd(pwd,sizeof(pwd));// 获取当前工作路径
snprintf(cwd,sizeof(cwd),"PWD=%s",pwd); // 将cwd传输到PWD中,修改的是当前进程
putenv(cwd);
lastcode = 0;//因为内建命令也有退出码
}
return 1;
}
else if(strcmp(gargv[0],"echo") == 0)
{
if(gargc == 2)
{
if(gargv[1][0] == '$')
{
if(strcmp(gargv[1]+1,"?") == 0)
{
printf("%d\n",lastcode);
}
else if(strcmp(gargv[1]+1,"PATH") == 0)
{
printf("%s\n",getenv("PATH"));// putenv 和 getenv 是什么??
}
}
lastcode = 0;
}
return 1;
}
return 0;
}
int main()
{
// 0、从配置文件中获取环境变量填充环境变量表
LoadEnv();
char Command_line[MAX_SIZE] = {0};
while(1)
{
// 1、打印用户名 主机名 当前路径
PrintCommandline();
// 2、从键盘获取输入命令行
if(0 == GetCommand(Command_line,sizeof(Command_line)))
continue;
// printf("%s\n",Command_line);// 打印输入从命令行,测试使用因为在之前就过滤了\n
// 3、解析字符串:"ls -a -l",-> "ls" "-a" "-l"
// 命令行解释器,就要对命令行字符串进行解析
ParseCommand(Command_line);
// 4、这个命令,到底让父进程执行(内建命令),还是让子进程执行?
// 更改了父进程的路径,所有的子进程都会继承父进程已经改变的路径
// 但是改变子进程的路径,父进程路径不会改变
if(CheckBuiltinExecute()) // > 0
{
continue; // 不执行第五步
}
// 5、让子进程执行命令
ExecuteCommand();
}
return 0;
}