自主实现一个简单的shell命令行

自主shell

自主实现一个简单的shell命令行,可以将我们之前学习过的知识串联起来。其核心思想就是:

  • 创建一个父进程(bash),完成基本的命令行操作;
  • 对于用户输入的指令,
    • 如果是普通命令,父进程将会创建子进程,让子进程执行;
    • 如果是内建命令,父进程将会自己执行。

1、搭建架构

c 复制代码
// myshell.h
#pragma once

#include <stdio.h>
c 复制代码
// myshell.c
#include "myshell.h"

void Bash()
{
	while (1)
	{
		// ...
	}
}
c 复制代码
// main.c
#include "myshell.h"

int main()
{
	Bash();
	
	return 0;
}

这里我们要明确一点:命令行本质上就是一个死循环的进程,只不过需要用户输入指令,命令行才会执行下一步操作。

2、打印命令行

vscode上的命令行是长这样的:

简单分析,我们就需要以下三个要素:

  • 用户名
  • 主机名
  • 当前的工作目录

这三个要素对应的信息,其实都在环境变量里面。所以我们可以使用getenv获取。

c 复制代码
// myshell.c
#include "myshell.h"

#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static char username[32];
static char hostname[64];
static char pwd[256];

static void GetUserName()
{
	char *_user_name = getenv("USER");
	strcpy(username, _user_name ? _user_name : "None");
}

static void GetHostName()
{
	char *_host_name = getenv("HOSTNAME");
	strcpy(hostname, _host_name ? _host_name : "None");
}

static void GetPwd()
{
	char *_pwd = getenv("PWD");
	strcpy(pwd, _pwd ? _pwd : "None");
}

static void PrintPrompt()
{
	GetUserName();
	GetHostName();
	GetPwd();
	
	printf("%s@%s:%s# ", username, hostname, pwd);
	fflush(stdout); // 冲涮缓冲区
}

void Bash()
{
	while (1)
	{
		PrintPrompt();
		sleep(1); // 方便观察,避免一下出现一大堆
	}
}
c 复制代码
// myshell.h
#pragma once

#include <stdio.h>

void Bash();

这里有四个细节:

  • 声明与定义分离的多文件项目中,一定要在.h文件中加上声明!!!
  • 本人曾经习惯将所有头文件声明一股脑地放入.h文件中,但这其实是不太好的习惯。建议对于某个文件需要某些头文件声明,而其它文件不需要,我们就把这些头文件声明放在这个文件当中。
  • printf不使用\n时,要使用fflush冲涮缓冲区,否则直到进程结束之前,什么也看不到。
  • 不想外部使用myshell.c里的函数,就加上static。

3、等待用户进行输入

用户输入可能包含空格,我们可以使用库方法fgets。

同时,我们可以用一个static字符数组接收用户输入的字符串。

c 复制代码
// myshell.c
#include "myshell.h"

#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static char username[32];
static char hostname[64];
static char pwd[256];

static char commandline[256];

// ...

static void GetCommandLine()
{
	memset(commandline, 0, sizeof(commandline)); // 清除历史记录
	if (NULL == fgets(commandline, sizeof(commandline), stdin))
	{
		perror("fgets failed!");
		exit(10);
	}
}

void Bash()
{
	while (1)
	{
		PrintPrompt();
		
		GetCommandLine();
	}
}

这里有三个细节:

用 sizeof 而不用 strlen

对于这句代码if (NULL == fgets(commandline, sizeof(commandline), stdin)),sizeof()可不能改成strlen(),因为用户的指令还没有输入进来,commandline肯定是一堆\0。

库函数strlen统计字符串的字符个数时,遇到\0就停止,所以strlen(commandline)的结果为0,导致fgets获取输入失败。

而操作符sizeof统计的是变量所占的字节数,sizeof(commandline)的返回结果就是256,因为一个char类型占一个字节,256个就占256个字节。

多了一个空格?

事实上,我们输入内容时,最后还输入了一个回车键,那么commandline末尾最终会多一个\n。

我们可以将这个转义字符覆盖掉:

c 复制代码
static void GetCommandLine()
{
	memset(commandline, 0, sizeof(commandline));
	if (NULL == fgets(commandline, sizeof(commandline), stdin))
	{
		perror("fgets failed!");
		exit(10);
	}
	else
	{
		commandline[strlen(commandline) - 1] = 0;
	}
}

输入了空串

如果用户什么也没输入,只是敲了一下"enter",那么我们也就没必要进行下面的操作了,以免浪费CPU资源:

c 复制代码
void Bash()
{
	while (1)
	{
		PrintPrompt();
		
		GetCommandLine();
		if (0 == strlen(commandline))
			continue;
	}
}

清除历史记录

向commandline写入前,先清空commandline里的内容。

4、解析字符串

用户输入了一个字符串,我们需要解析字符串:以空格为分界拆成一个个"小串",然后传入指针数组argv中做成一个环境变量表。

c 复制代码
static int argc = 0;
static char *argv[64];

这里我们要借助一个C语言处理字符串的库函数strtok:

例如,这里有一个字符串char *string = "ls -a -l",要求将空格作为分界delim,将字符串拆分:

对于strtok中的str参数:

  • 如果输入的是string,那么返回字符串(字符指针)"ls"。
    • 我们可以理解为strtok在返回的时候,内部有一个"指针"从头开始,读到空格就返回了。
    • 以后如果输入的都是string,那么返回的结果都是一样的。
  • 如果输入的是NULL,
    • strtok就会往后读取。比如第二次调用,返回"-a";第三次调用,返回"-l";第四次调用,由于后面没有内容了,就返回NULL。
    • 我们可以理解为,strtok内部存在static全局变量,能够保存最近一次传入字符串的信息。

然后我们就可以完善解析字符串的函数:

c 复制代码
static void ParseCommandLine()
{
	// 清除原有的记录
	argc = 0;
	memset(argv, 0, sizeof(argv));
	
	// 填表
	argv[argc] = strtok(commandline, sep);
	while ((argv[++argc] = strtok(NULL, sep)));
}

首先,我们要清除上一次输入指令留下的记录;其次,我们借助strtok,只用了两行代码,就完成了argc argv的设置,甚至还能在argv的末尾加上NULL。

值得注意的是,while ((argv[++argc] = strtok(NULL, sep)));中我们多加了一个括号。这是因为括号里面进行的是赋值操作,一般赋值操作的返回值都为真,很容易导致死循环;编译器会认为我们在这里进行了误操作,即把 == 错写成了 = ,所以这里触发了编译器的警告。我们加上括号,意在告诉编译器,我们确实在做赋值操作,并且将赋值作为 while 循环的条件。

5、执行命令

执行命令,我们可以让父进程创建子进程,然后让子进程通过程序替换完成命令的执行。

c 复制代码
static void Execute()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork failed!\n");
        exit(11);
    }
    else if (0 == id)
    { // child
        execvp(argv[0], argv); // 程序替换
        exit(12);
    }
    else
    { // parent
        pid_t rid = waitpid(id, NULL, 0);
        (void)rid; // 接收但不处理
    }
} 

这样一来,我们就做好了一个功能简单的shell命令行。

6、进一步完善

接下来,我们将进一步完善自主实现的shell命令行解释器,以帮助我们加深理解这些概念:

  • shell命令行的运行原理
  • 普通命令 vs 内建命令
  • 命令行参数 & 环境变量

6.1、路径无法切换?

子进程做完路径的切换,修改的只是子进程的副本;子进程直到退出的时候,也没能修改父进程(bash)的路径信息。所以路径切换的工作只能由父进程来做。

实际上,不仅路径切换指令cd,查看环境变量指令env、导入环境变量指令export、以及echo......这些命令,都必须由父进程执行,都是内建命令。

所以我们得到一个解析完的字符串,还应该先判断一下是否是内建命令:

c 复制代码
if (CheckBuildinAndExecute())
	continue;

我们执行内建命令的大致思路是:进入CheckBuildinAndExecute,

  • 如果是内建命令,执行内建命令,返回"1",此时命中 if 条件,就转而去等待用户的下一次输入;
  • 如果不是内建命令,返回"0",就不会命中 if 条件,而是继续往下,执行普通命令。

做路径切换,我们借助库函数chdir:

c 复制代码
static int CheckBuildinAndExecute()
{
    int ret = 0;

    if (0 == strcmp(argv[0], "cd"))
    {
        ret = 1;
        if (2 == argc)
            chdir(argv[1]);
    }

    return ret;
}

6.2、完善提示符

路径切换确实能做了,但是又出现一个问题:

命令行提示符中的路径,看起来没有改变?

实际上,我们是从环境变量中获取的当前工作路径。chdir并没有改变环境变量的PWD。

在这里,我们获取当前工作路径的时候,不从环境变量中获取,而是借助库函数getcwd:

c 复制代码
void GetPwd()
{
	// char *_pwd = getenv("PWD");
	// strcpy(pwd, _pwd ? _pwd : "None");
    getcwd(pwd, sizeof(pwd));
}

只给出最近的目录名?

有的平台下的命令行解释器,对于当前工作目录,只会给出最近的目录名,例如10_5。

这时我们就可以实现一个简单的算法,提取最近的目录名:

c 复制代码
void GetPwd()
{
	// char *_pwd = getenv("PWD");
	// strcpy(pwd, _pwd ? _pwd : "None");
    //getcwd(pwd, sizeof(pwd));

    char _cwd[256];
    getcwd(_cwd, sizeof(_cwd));
    if (0 == strlen(_cwd))
    {
        strcpy(pwd, "None");
    }
    else if (0 == strcmp(_cwd, "/"))
    {
        strcpy(pwd, _cwd);
    }
    else
    {
        int end = strlen(_cwd) - 1;
        while (end >= 0)
        {
            if ('/' == _cwd[end])
            {
                strcpy(pwd, &_cwd[end + 1]);
                break;
            }

            --end;
        }
    }
}

注意这里使用的 while 循环的判断条件,不能用 while(_cwd[end] != '/') ,否则遇到'/'就会结束循环,永远也提取不到目录名。

6.3、echo $?提取退出码

(当前我们只针对普通命令的退出码的提取)

首先我们要完善Execute的进程等待,以及添加一个static全局变量记录退出码:

c 复制代码
static exit_code = 0;
// ...

static void Execute()
{
	// ...
		// ...
		
    else
    { // parent
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        (void)rid; // 接收但不处理
        exit_code = WEXITSTATUS(status);
    }
}

我们统一将echo设置为内建命令。echo包括两种功能;

  1. 打印字符串(会换行)
  2. 打印退出码
    ......
c 复制代码
static int CheckBuildinAndExecute()
{
    int ret = 0;

    if (0 == strcmp(argv[0], "cd"))
    {
        ret = 1;
        if (2 == argc)
            chdir(argv[1]);
    }
    else if (0 == strcmp(argv[0], "echo"))
    {
        ret = 1;
        if (1 == argc)
        {
            printf("Nothing to do.\n");
            return 1;
        }
        else if ('$' == argv[1][0])
        {
            if (0 == strcmp(argv[1], "$?"))
            {
                printf("%d\n", exit_code);
                exit_code = 0;
            }
        }
        else
        {
            printf("%s\n", argv[1]);
        }
    }

    return ret;
}

6.4、环境变量的获取

对于环境变量,我们有两个问题需要回答:

  • 环境变量由谁维护?bash
  • 环境变量在哪获取?配置文件

但是从配置文件中读取,我们做不到,就只能从真正的bash中获取环境变量:

c 复制代码
// 环境变量
static int envc;
static char *env[64];

// ...

static void LoadEnv() // 从真正的bash中读取环境变量
{
    extern char **environ;
    for (envc = 0; environ[envc]; ++envc)
        env[envc] = environ[envc];
    env[envc] = NULL;
}

值得注意的是,LoadEnv可不能放到循环内部。因为下一次等待命令的时候,由于又执行了一次LoadEnv,之前对环境变量表的修改就会被覆盖。

至于执行env查看环境变量表,我们可以修改检查内建命令的函数CheckBuildinAndExecute,添加env分支。

6.5、环境变量的添加

即内建命令export的完善。

c 复制代码
else if (0 == strcmp(argv[0], "export")) // export
{
    if (argc == 2)
    {
        env[envc++] = argv[1];
        env[envc] = NULL;
    }
}

在CheckBuildinAndExecute中这样写,就可以了吗?

c 复制代码
else if (0 == strcmp(argv[0], "export")) // export
{
    if (argc == 2)
    {
        env[envc++] = argv[1];
        env[envc] = NULL;
    }
}

只查到一行空白,怎么回是?

实际上,这种添加环境变量的方式,我们也不陌生:浅拷贝 。env表的最后一个有数据的项是直接指向argv[1]指向的内容的,当Bash()等待用户的下一次输入时,argv会被清空,env表的最后一个有效项就指向了一堆\0,就出现了上面的情况。

其中一个解决的办法,就是深拷贝:

c 复制代码
else if (0 == strcmp(argv[0], "export")) // export
{
    if (argc == 2)
    {
        // env[envc++] = argv[1];
        // env[envc] = NULL;
        char *mem = (char*)malloc(sizeof(argv[1] + 1));
        strcpy(mem, argv[1]);
        env[envc++] = mem;
        env[envc] = NULL;
    }
}

7、其它知识点

7.1、进一步理解:环境变量 vs 本地变量

对于环境变量,其实就是bash内部隐藏了一个环境变量表,接着用一个二级指针指向环境变量表;用户定义一个同名指针,就可以通过这个同名指针,获取环境变量表中的所有信息。

至于本地变量,其实也是bash内部隐藏了一个本地变量表。只要bash没有识别到export,就会写入本地变量表中。

c 复制代码
void Bash()
{
	// ...
	static char *local[1024]; // 本地变量表
	// ...
}

7.2、一个命令,两种身份?

上面的学习中,我们默认将echo env等命令当作了内建命令,从而来完善自主shell。实际上,类似于echo env,Linux中也有相当一批命令,既有在bash中 实现的方法,又有在磁盘上可随时调用的程序。

原因是,shell不仅含有解释命令的解释器bash:

与此同时,shell还是一门语言。

意味着shell可以像python一样,既可以做交互式的命令执行工作,又可以对一批写好代码和数据的文件,做语法分析与一系列处理。

对于同一种功能,

  • 交互式工作场景下,一般都是bash自己的工作,就需要bash内部实现方法,即内建命令;
  • 批量化处理场景下,bash就可以创建子进程执行,就需要磁盘上已经实现好的方法,即普通命令。

7.3、当前自主shell不支持的功能

当前的自主shell不支持:

  • 权限的管理。例如cat打开文件的请求会不会被拦截
  • > < >>重定向操作
  • 管道操作
  • 取别名(实际上我们也可以创建一个别名表 ,然后别名实际上也是一个变量)

这些不支持的功能,未来大家有兴趣的话也可以尝试完善一下🤝。

代码演示

相关推荐
顺风尿一寸2 小时前
深入 Linux 6.18 挂载机制:从 VFS 到 ext4 的完整调用链
linux
她说彩礼65万2 小时前
C语言 assert
java·linux·c语言
lcj25113 小时前
【Linux】 Makefile 详解
linux·笔记·面试
天疆说3 小时前
腾讯会议 Linux 版在 NVIDIA 显卡上看不到画面:EGL 像素配置与窗口视觉不匹配的排查与修复
linux·数据库·腾讯会议
geats人山人海3 小时前
linux 2 基本操作与vim
linux·运维·vim
lisanmengmeng4 小时前
NRPE 添加命令(三)
java·linux·服务器
人工智能培训4 小时前
数字孪生驱动大模型工业知识库:为具身机器人植入领域专业经验
大数据·linux·服务器·前端·人工智能
江屿风4 小时前
【Linux系统】【从菜鸟驿站到操作系统:一节课打通Linux重定向与缓冲区真相】流食般投喂
linux·运维·服务器·开发语言·笔记
今天要早睡_4 小时前
Linux 基础指令速查:从 ls 到 tar 的常用命令全解析
linux·运维·chrome