Linux:实现简易的shell

整体逻辑:shell本质就是一个死循环:打印提示符 → 读取一行输入 → 分割参数 → 别名替换 → 内置命令直接执行 / 外部命令fork+exec执行。

一、头文件、宏、全局变量

①

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>         // Linux系统调用:fork,chdir,getcwd,execvp
#include <sys/types.h>      // pid_t类型定义
#include <sys/wait.h>       // waitpid,等待子进程
#include <string>
#include <unordered_map>    // 哈希表,存放alias别名
#include <algorithm>
  • #include <unistd.h> Linux系统调用必备头文件,只有Linux才有,Windows不能跑这套代码
  • <sys/wait.h>:waitpid、WEXITSTATUS 用来获取子进程退出码
  • unordered_map:C++哈希容器,key是别名,value是真实命令,alias ll="ls -l" 就存在这里

②

cpp 复制代码
#define COMMAND_SIZE 1024    // 命令缓冲区最大长度,一行输入最多1024字符
#define FORMAT "[%s@%s %s]# "//提示符模板:[用户名@主机名 当前文件夹]#

宏定义,预处理直接文本替换。

③

cpp 复制代码
#define MAXARGC 128
char *g_argv[MAXARGC];      // 全局参数数组,和main(int argc,char* argv[])的argv完全一样
int g_argc = 0;             // 参数个数,例如 ls -l 则g_argc=2

g_argv 存放分割后的命令字符串指针数组。

g_argv[0] = 命令名,g_argv[1] = 第一个参数,末尾必须NULL,execvp要求。

④

cpp 复制代码
#define MAX_ENVS 100
char *g_env[MAX_ENVS];      // 自己维护的环境变量数组
int g_envs = 0;             // 环境变量计数

我们拷贝系统环境变量到这个数组,自己管理环境变量。

⑤

cpp 复制代码
std::unordered_map<std::string, std::string> alias_list; //别名表

key:别名,value:真实命令。alias ll='ls -l' → alias_list"ll" = "ls -l"

⑥

cpp 复制代码
char cwd[1024];             // 存放当前目录字符串,getcwd写入
char cwdenv[1024];          // 构造PWD=xxx字符串,给putenv更新环境变量
char oldcwd[1024];          // 保存上一次目录,用于 cd -
int lastcode = 0;           // 上一条命令退出码,echo $?读取

全局变量:在整个shell生命周期一直保存状态,每次循环不会重置。


二、工具函数,获取用户、主机、目录

① 获取USER环境变量:用户名

cpp 复制代码
const char *GetUserName()
{
    const char *name = getenv("USER");
    return name == NULL ? "None" : name;
}

getenv("环境变量名"):读取环境变量。

USER 环境变量保存当前登录用户名;取不到返回None。

② 获取主机名 HOSTNAME

cpp 复制代码
const char *GetHostName()
{
    const char *hostname = getenv("HOSTNAME");
    return hostname == NULL ? "None" : hostname;
}

读取主机名,终端里hostname命令查看。

③ 获取当前工作目录,同时更新环境变量PWD

cpp 复制代码
const char *GetPwd()
{
    const char *pwd = getcwd(cwd, sizeof(cwd));
    if(pwd != NULL)
    {
        snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
        putenv(cwdenv); //把PWD写入进程环境变量
    }
    return pwd == NULL ? "None" : pwd;
}
  1. getcwd(buf,size) 系统调用,获取当前进程工作目录存入cwd数组。
  2. snprintf 拼接字符串,生成PWD=/home/xxx
  3. putenv() 修改当前进程的环境变量,更新PWD。>

很多程序依赖PWD环境变量,cd切换目录后必须同步更新PWD。

④ 获取家目录HOME

cpp 复制代码
const char *GetHome()
{
    const char *home = getenv("HOME");
    return home == NULL ? "" : home;
}

读取HOME环境变量,家目录,cd ~就跳转到这个路径。

⑤ 截取路径最后一段(提示符只显示文件夹名,不显示全路径)

cpp 复制代码
std::string DirName(const char *pwd)
{
#define SLASH "/"
    std::string dir = pwd;
    if(dir == SLASH) return SLASH;
    auto pos = dir.rfind(SLASH); //从后向前查找最后一个 '/'
    if(pos == std::string::npos) return "BUG?";
    return dir.substr(pos+1);
}

作用:只取路径最后一段文件夹名字 。

例:/home/student,最后一个/在索引5,substr(6)得到student。

根目录/特殊处理,直接返回/。

提示符不想展示一长串全路径,只显示文件夹名。


三、InitEnv() 初始化环境变量

cpp 复制代码
void InitEnv()
{
    extern char **environ; //系统全局环境变量数组
    memset(g_env, 0, sizeof(g_env));
    g_envs = 0;
    //拷贝系统环境变量到我们自己的g_env数组
    for(int i = 0; environ[i] && i < MAX_ENVS - 2; i++)
    {
        g_env[i] = (char*)malloc(strlen(environ[i])+1);
        strcpy(g_env[i], environ[i]);
        g_envs++;
    }
    g_env[g_envs++] = (char*)"HAHA=for_test"; //自定义测试环境变量
    g_env[g_envs] = NULL;

    // 将我们的环境变量设置到进程
    for(int i = 0; g_env[i]; i++)
    {
        putenv(g_env[i]);
    }
    environ = g_env; //替换全局环境变量指针

    getcwd(oldcwd, sizeof(oldcwd)); //初始化上一次目录
}
  1. extern char** environ Linux内置全局变量,存放父进程传给我们的全部环境变量。
  2. memset清空g_env数组。
  3. 循环读取每一条环境变量,malloc开辟内存,strcpy复制字符串到我们自己数组。

为什么malloc:environ是父进程的内存,我们需要自己拷贝一份,方便后续修改。

  1. 添加自定义环境变量HAHA=for_test
  2. environ = g_env:把系统环境指针指向我们自己维护的数组,整个进程环境变量由我们接管。
  3. 初始化oldcwd,保存启动时的目录。

四、提示符、读取用户输入

① 拼接提示符字符串

cpp 复制代码
void MakeCommandLine(char cmd_prompt[], int size)
{
    snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}

snprintf拼接提示符字符串。把用户名、主机名、文件夹填入FORMAT模板,生成[user@pc folder]#。

② 打印提示符

cpp 复制代码
void PrintCommandPrompt()
{
    char prompt[COMMAND_SIZE];
    MakeCommandLine(prompt, sizeof(prompt));
    printf("%s", prompt);
    fflush(stdout);
}
  1. 调用MakeCommandLine生成提示符字符串
  2. printf打印
  3. fflush(stdout):C标准输出是行缓冲,不加fflush,提示符可能卡在缓冲区,不立刻显示,等换行才输出。

③ 读取一行用户输入

cpp 复制代码
bool GetCommandLine(char *out, int size)
{
    char *c = fgets(out, size, stdin); //读取一行输入,包含末尾回车'\n'
    if(c == NULL) return false;
    out[strlen(out)-1] = 0; // 把换行符替换成'\0'字符串结束符
    if(strlen(out) == 0) return false; //用户直接回车,空输入返回false
    return true;
}

fgets(buf,maxlen,stdin):读取控制台一行输入。

输入ls -l按下回车,缓冲区得到字符串ls -l\n。

strlen(out)-1定位到\n,赋值0('\0'),删除换行。


五、命令解析 CommandParse,字符串分割

① 字符串分割

cpp 复制代码
bool CommandParse(char *commandline)
{
#define SEP " "
    g_argc = 0;
    // strtok分割字符串,以空格切割
    g_argv[g_argc++] = strtok(commandline, SEP);
    while( (g_argv[g_argc] = strtok(nullptr, SEP)) != nullptr && g_argc < MAXARGC-1 )
    {
        g_argc++;
    }
    return g_argc > 0 ? true:false;
}

strtok 字符串分割函数,会原地修改原字符串 ,把分隔符替换成\0。

  • 第一次调用:strtok(字符串,分隔符),返回第一个单词指针
  • 后续调用:strtok(NULL,分隔符),继续读取下一个单词,返回NULL代表分割结束

示例:输入ls -l -a

  1. g_argv0 = "ls",g_argc=1
  2. 循环取出 "-l" g_argv1, g_argc=2
  3. 取出 "-a" g_argv2, g_argc=3
  4. strtok返回NULL,循环结束
    返回true,代表解析成功。

② 打印分割后的参数

cpp 复制代码
void PrintArgv()
{
    for(int i = 0; g_argv[i]; i++)
    {
        printf("argv[%d]->%s\n", i, g_argv[i]);
    }
    printf("argc: %d\n", g_argc);
}

调试函数,打印分割后的参数,写代码的时候打开看分割是否正确,成品可以注释掉。

③ 别名替换 AliasReplace

cpp 复制代码
bool AliasReplace(char *cmdline)
{
    std::string cmd(g_argv[0]);
    if(alias_list.find(cmd) != alias_list.end())
    {
        strcpy(cmdline, alias_list[cmd].c_str());
        return true;
    }
    return false;
}

逻辑:拿到解析后的命令名g_argv0,去哈希表alias_list查找。

  • 如果找到:把真实命令拷贝到cmdline缓冲区,返回true,main函数拿到true会重新调用CommandParse解析新命令 。
    例:alias ll='ls -l',输入ll,命中别名,替换成ls -l,再重新分割。
  • 找不到别名,返回false,不做处理。

六、内置命令实现

内置命令不能fork子进程!因为内置命令修改shell本身状态(当前目录、环境变量),子进程的修改无法传回父进程。

① cd 内置命令

cpp 复制代码
bool Cd()
{
    if(g_argc == 1)
    {
        //不带参数 cd → cd ~ 回到家目录
        std::string home = GetHome();
        if(home.empty()) return true;
        getcwd(oldcwd, sizeof(oldcwd));
        chdir(home.c_str());
    }
    else
    {
        std::string where = g_argv[1];
        if(where == "-")
        {
            // cd - 回到上一次目录
            char tmp[1024];
            strcpy(tmp, oldcwd);
            getcwd(oldcwd, sizeof(oldcwd));
            chdir(tmp);
            std::cout << tmp << std::endl;
        }
        else if(where == "~")
        {
            // cd ~
            std::string home = GetHome();
            getcwd(oldcwd, sizeof(oldcwd));
            chdir(home.c_str());
        }
        else
        {
            getcwd(oldcwd, sizeof(oldcwd));
            if(chdir(where.c_str()) == -1)
            {
                perror("cd");
                lastcode = 1; //失败退出码1
            }
        }
    }
    return true;
}

chdir(path)系统调用:修改当前进程 工作目录。

每次切换目录前,先把当前目录保存进oldcwd,用于cd -。

  • cd不带参数:跳HOME
  • cd ~:跳HOME
  • cd -:oldcwd保存上次目录,切换回去并打印路径
  • cd xxx:切换到xxx目录,失败perror打印错误,设置lastcode=1。

② echo 内置命令

cpp 复制代码
void Echo()
{
    if(g_argc >= 2)
    {
        for(int i = 1; i < g_argc; i++)
        {
            std::string opt = g_argv[i];
            if(opt == "$?")
            {
                std::cout << lastcode;
                lastcode = 0;
            }
            else if(opt[0] == '$')
            {
                std::string env_name = opt.substr(1);
                const char *env_value = getenv(env_name.c_str());
                if(env_value)
                    std::cout << env_value;
            }
            else
            {
                std::cout << opt;
            }
            if(i != g_argc - 1) std::cout << " ";
        }
        std::cout << std::endl;
    }
}

循环遍历所有参数:

  1. 参数是$?:打印lastcode(上一条命令退出码),重置lastcode
  2. 参数以$开头:截取后面字符串,getenv读取环境变量输出,例如'后面字符串,getenv读取环境变量输出,例如`后面字符串,getenv读取环境变量输出,例如'PATH`
  3. 普通字符串:直接打印
    参数之间空格分隔,最后换行。

③ export 设置环境变量

cpp 复制代码
void Export()
{
    if(g_argc != 2)
    {
        lastcode = 1;
        return;
    }
    putenv(g_argv[1]);
}

用法export TEST=123。

putenv("TEST=123")直接添加/修改当前进程环境变量。参数数量不对,退出码置1。

④ alias 别名命令

cpp 复制代码
void Alias()
{
    if(g_argc == 1)
    {
        //无参:打印全部别名
        for(auto &p : alias_list)
        {
            std::cout << "alias " << p.first << "='" << p.second << "'" << std::endl;
        }
    }
    else if(g_argc == 2)
    {
        std::string s = g_argv[1];
        size_t eq = s.find('=');
        if(eq == std::string::npos)
        {
            lastcode = 1;
            return;
        }
        std::string key = s.substr(0, eq);
        std::string val = s.substr(eq+1);
        alias_list[key] = val;
    }
}
  • alias不带参数:范围遍历哈希表,打印全部已定义别名
  • alias ll=ls -l:找到=,左边是别名key,右边是真实命令value,存入alias_list

⑤ 内置命令分发入口 CheckAndExecBuiltin

cpp 复制代码
bool CheckAndExecBuiltin()
{
    std::string cmd = g_argv[0];
    if(cmd == "cd")
    {
        Cd();
        return true;
    }
    else if(cmd == "echo")
    {
        Echo();
        return true;
    }
    else if(cmd == "export")
    {
        Export();
        return true;
    }
    else if(cmd == "alias")
    {
        Alias();
        return true;
    }
    else if(cmd == "exit")
    {
        exit(lastcode);
    }
    return false; //不是内置命令,返回false
}

读取命令名g_argv0,if匹配:

匹配成功:执行对应内置函数,return true,main就不会执行外部命令。

没有匹配:return false,交给Execute()执行外部程序。

exit(lastcode)直接结束shell进程,返回退出码。


七、 Execute() 外部命令执行 fork + execvp

cpp 复制代码
int Execute()
{
    pid_t id = fork(); //复制进程,创建子进程
    if(id == 0)
    {
        // =========子进程=========
        execvp(g_argv[0], g_argv); //加载外部程序替换子进程镜像
        perror("execvp fail");     //execvp成功就不会返回;失败才执行这行
        exit(1);
    }
    // =========父shell进程=========
    int status = 0;
    pid_t rid = waitpid(id, &status, 0); //阻塞等待子进程结束
    if(rid > 0)
    {
        lastcode = WEXITSTATUS(status); //拿到子进程退出码保存到lastcode
    }
    return 0;
}

核心进程知识点:

  1. fork():创建子进程。调用一次,返回两次:
    • 返回0:当前是子进程
    • 返回正数:父进程,返回值是子进程pid
    • 返回负数:fork失败
  2. execvp(程序名,argv数组):用新程序覆盖当前进程。成功之后,原来代码全部消失,不会回到这行后面。

execvp会自动搜索PATH环境变量,直接写ls不用写/bin/ls

  1. waitpid(pid,&status,0):父进程阻塞,卡住不动,等待子进程结束。
  2. WEXITSTATUS(status):从status提取子进程退出码,保存进全局lastcode,供echo $?读取。

八、 main函数,shell主循环

cpp 复制代码
int main()
{
    InitEnv(); //初始化环境变量
    char alias_buf[COMMAND_SIZE];
    while(true) //永久循环,直到exit退出
    {
        PrintCommandPrompt();        //1.打印提示符
        char commandline[COMMAND_SIZE];
        if(!GetCommandLine(commandline, sizeof(commandline)))
            continue;

        if(!CommandParse(commandline))//2.解析输入命令,分割参数
            continue;

        //别名替换,如果命中别名,重新解析
        if(AliasReplace(alias_buf))
        {
            CommandParse(alias_buf);
        }

        if(CheckAndExecBuiltin())    //3.内置命令,直接执行
            continue;

        Execute();                   //4.外部命令,fork+exec执行
    }
    return 0;
}

while(true)无限循环,shell一直运行。

流程一步一步:

  1. PrintCommandPrompt()打印提示符 [user@xxx folder]#
  2. GetCommandLine()读取一行输入,空输入continue跳过
  3. CommandParse()分割字符串,失败就continue
  4. AliasReplace()检测别名;如果命中别名,重新解析替换后的命令
  5. CheckAndExecBuiltin()判断内置命令,true代表已经执行完毕,continue回到循环开头
  6. 不是内置命令,调用Execute()fork子进程运行外部命令。
    子进程跑完,回到循环,再次打印提示符等待输入。

补充注意:代码是简易版,现存缺陷

  1. strtok用空格分割,多个连续空格会丢参数 ,不能识别引号(echo "hello world"会分割成echo,"hello,world")
  2. 不支持管道|、重定向>、后台&
  3. 没有信号处理,Ctrl+C直接杀死整个shell
  4. 内存泄漏:malloc分配的环境变量没有free
  5. 不支持分号连续命令 cmd1;cmd2

感兴趣的可以自行将其完善

相关推荐
2301_777998342 小时前
TCP连接断开:进程终止、主机断电与网络断开
linux·服务器·网络·tcp/ip
星恒随风2 小时前
Linux进程基础(二):进程调度、上下文切换、O(1)调度器、环境变量与虚拟地址空间
linux·笔记·学习·状态模式
xbzb2 小时前
一边装一边报错:CentOS 7 + LNMP 跑 ECShop 的常见故障与解法
linux·运维·centos
学linux的QQ蛋2 小时前
图解 Linux 驱动编译进内核的完整工作流程
linux·运维·服务器
承渊政道3 小时前
Linux网络学习【UDP Socket编程实战:网络命令与客户端访问Linux验证】
linux·网络·学习·ubuntu·编程实战·udp socket
程序员-Benothing3 小时前
正则表达式详解:BRE、ERE、PCRE语法与实战案例
linux·运维·服务器
软件架构师-叶秋3 小时前
V853方案之ubuntu开发环境搭建
linux·运维·服务器·全志·v853
lzx_0023 小时前
Linux 开发工具(二) 简单认识一下编辑器 —— vim
linux·编辑器·vim
AlfredZhao3 小时前
堡垒机里 `su - oracle` 被拦截,怎么绕过限制切换用户
linux