【Linux】Linux进程控制(三)自主实现简易shell命令行解释器

目录

[1. 关于两种头文件包含方式:<>与""的区别](#1. 关于两种头文件包含方式:<>与""的区别)

[2. 自主实现shell命令原理](#2. 自主实现shell命令原理)

[2.1 shell的一个典型的互动](#2.1 shell的一个典型的互动)

[2.2 Shell的工作循环](#2.2 Shell的工作循环)

[2.2.1 详细工作日志](#2.2.1 详细工作日志)

2.2.2核心要点

[2.2.3 连续执行命令](#2.2.3 连续执行命令)

[2.2.4 对Shell这一典型互动的总结](#2.2.4 对Shell这一典型互动的总结)

[3. 自主实现shell命令行细节](#3. 自主实现shell命令行细节)

[3.1 提示符(字符串)](#3.1 提示符(字符串))

[3.2 static](#3.2 static)

[3.2.1 fgets](#3.2.1 fgets)

[3.2.2 strtok:切割](#3.2.2 strtok:切割)

[1、strtok() 基本用法](#1、strtok() 基本用法)

[2、示例步骤(输入 "ls -a -l")](#2、示例步骤(输入 “ls -a -l”))

3、代码关键行解析

[4、strtok() 特性与注意事项](#4、strtok() 特性与注意事项)

5、调试输出结果示例

流程演示

总结

[3.3 五步走](#3.3 五步走)

[3.3.1 env](#3.3.1 env)

[3.3.2 export](#3.3.2 export)

[3.3.3 获取环境变量:LoadEnv](#3.3.3 获取环境变量:LoadEnv)

[3.3.4 本地变量](#3.3.4 本地变量)

[3.4 内建命令和普通命令](#3.4 内建命令和普通命令)

[3.4.1 对比](#3.4.1 对比)

[3.4.2 为什么要区分内建命令](#3.4.2 为什么要区分内建命令)

[3.4.3 内建命令检查函数](#3.4.3 内建命令检查函数)

[3.4.4 which命令没查到的就是典型的内建命令------cd、echo、env也是内建,为什么能够在磁盘上用which指令查到?](#3.4.4 which命令没查到的就是典型的内建命令——cd、echo、env也是内建,为什么能够在磁盘上用which指令查到?)

[3.5 指令别名支持、管道、重定向问题](#3.5 指令别名支持、管道、重定向问题)

[3.6 shell总结](#3.6 shell总结)

[3.8 演示](#3.8 演示)


1. 关于两种头文件包含方式:<>与""的区别

在C和C++编程中,头文件包含有两种形式:使用尖括号 < > 和双引号 " " 。它们的主要区别在于编译器搜索头文件的路径顺序不同。

尖括号 < >:

复制代码
#include <stdio.h>
#include <iostream>

双引号 " ":

复制代码
#include "myheader.h"
#include "../utils/helper.h"

简单记忆:

  • <> = 去系统那里找;

  • "" = 先在附近找,找不到再去系统那里找。

2. 自主实现shell命令原理

我们都知道,真正的软件是 只要不退出就会一直运行,始终不结束的------死循环

我们要实现自主Shell命令行编辑器,先要来考虑其实现原理。

2.1 shell的一个典型的互动

考虑下面这个与shell典型的互动:

复制代码
[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
 PID TTY TIME CMD
 3451 pts/0 00:00:00 bash
 3514 pts/0 00:00:00 ps

用下图的时间轴表示事件发生的顺序,时间从左向右推移。代表 shell 的方块标识为"sh",并随时间从左向右移动。shell 读取用户输入的字符串"ls",接着创建一个新进程,在该进程中运行 ls 程序,并等待该进程结束。

我们将这个时间轴展开描述一下,即描述Shell的生命周期

这就是Shell(媒婆)作为"命令调度中心"的完整生命周期:

我们可以打个相对比较恰当的比方:假设有这样一个餐厅后厨,我们"媒婆"Shell这里摇身一变,它的的生命周期就可以比喻成一位餐厅经理。

想象一下,Shell就是一个永不休息的餐厅前厅经理,它的工作就是循环处理顾客(用户)的点单(命令)。

2.2 Shell的工作循环

[等待点单] → [安排后厨] → [等待上菜] → [清理餐桌] → [回到等待]...

Shell工作循环这里的特点在于:

2.2.1 详细工作日志

  1. 待命状态 | 显示提示符 $

经理行为:站在前台,微笑,等待顾客。

Shell 实质:运行主循环,打印提示符,阻塞在 readline() 等待输入。

  1. 接收订单 | 读取命令 ls

经理行为:听到顾客说"一份意大利面"。

Shell 实质:从标准输入读取字符串 "ls\n",并解析其含义。

  1. 安排后厨 | 调用 fork()

经理行为:复制自己,生成一个和自己一模一样的"后厨经理分身"。

Shell 实质:创建子进程。此时父子进程几乎完全相同,都准备执行"做意大利面"这个任务。

  1. 执行烹饪 | 调用 exec()

经理行为:分身经理瞬间变身为意大利面主厨,并开始按菜谱烹饪。真身经理则回到前台。

Shell 实质:在子进程中,将进程内存映像替换为 /bin/ls 的程序代码和数据,并开始执行。父进程 Shell 原样保留。

  1. 待上菜 | 调用 wait()

经理行为:真身经理在前台等待,直到后厨通知"菜好了"。

Shell 实质:父进程进入睡眠(阻塞)状态,等待子进程终止的信号。

  1. 菜品完成 | 子进程退出

经理行为:主厨做完菜,消失(下班)。

Shell 实质:ls 程序运行结束,调用 exit(),内核释放其大部分资源,留下一个"死亡凭证"(退出状态码)。

  1. 清理与复盘 | 回收状态

经理行为:真身经理收到通知,查看菜品是否合格(退出状态),然后清理餐桌,准备迎接下一位顾客。

Shell 实质:父进程被唤醒,从 wait() 返回,读取子进程的退出状态。之后继续循环,回到步骤 1。

2.2.2核心要点

  • Shell 永存:真身经理(父进程)永不亲自下厨,只负责调度和等待。
  • 分身术fork() 创造执行机会。
  • 变身术exec() 让机会变成具体的行动。
  • 等待的艺术wait() 保证了秩序,让 Shell 可以同步地处理任务。

2.2.3 连续执行命令

复制代码
时间轴: t0 → t1 → t2 → t3 → t4 → t5 → t6 → t7 → t8

t0-t1: Shell 读取 "ls" 命令
t1-t2: 创建子进程,执行 ls
t2-t3: Shell 等待 ls 结束
t3-t4: ls 执行完毕,Shell 恢复
t4-t5: Shell 读取 "ps" 命令
t5-t6: 创建子进程,执行 ps
t6-t7: Shell 等待 ps 结束
t7-t8: ps 执行完毕,Shell 恢复

2.2.4 对Shell这一典型互动的总结

然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序并等待这个进程结束。

所以要写一个shell,需要循环以下过程:

根据这些思路,和我们前面的学的技术,就可以自己来实现一个shell了。

3. 自主实现shell命令行细节

3.1 提示符(字符串)

这一串提示符其实就是字符串:

我们要做的就是:获取用户名 + 主机名 + 工作目录

3.2 static

加上static之后,这些变量就是全局变量啦,父子进程共享数据。

在这段代码中,static 关键字用于修饰全局变量,它的作用主要有以下几个方面:

限制作用域:

  • static 用于全局变量时,它会将变量的作用域限制在当前源文件(myshell.c)内;

  • 其他源文件即使使用 extern 声明也无法访问这些变量;

  • 这相当于创建了文件作用域的私有变量。

    // 这些变量只能在 myshell.c 文件中访问
    static char username[32]; // 私有:存储用户名
    static char hostname[64]; // 私有:存储主机名
    static char cwd[256]; // 私有:存储当前工作目录
    static char commandline[256]; // 私有:存储命令行输入

    static char *argv[64]; // 私有:命令行参数数组
    static int argc = 0; // 私有:参数个数
    static const char *sep = " "; // 私有:分隔符

    static int lastcode; // 私有:上一次命令的退出码
    static char *env[64]; // 私有:环境变量数组

为什么使用 static?

|-------------|-------------------------------------------------------------------------------------------|
| 概念 | 说明 |
| 信息隐藏/封装 | 这些变量是 shell 程序的内部状态,不希望其他文件(如 myshell.h 中声明的函数)直接修改这些内部变量,只有 myshell.c 中的函数可以访问和修改它们。 |
| 避免命名冲突 | 如果其他文件也定义了同名的全局变量,不会有冲突,因为 static 变量是文件私有的。 |
| 代码组织 | 明确哪些变量是本模块的"内部状态",提高代码的可维护性和可读性。 |

3.2.1 fgets

复制代码
fgets(commandline, sizeof(commandline), stdin)
  • 从键盘读取输入到 commandline 数组中;

  • sizeof(commandline) = 256(根据之前定义);

  • 最多读取 255 个字符(留一个位置给 \0)。

这里fgets的优点有两点:

  • 限制读取长度,避免缓冲区溢出
  • 如果读取到换行符 \n,会把它存入字符串

3.2.2 strtok:切割

1、strtok() 基本用法

|----------|-----------------------------------------------|
| 项目 | 说明 |
| 函数原型 | char *strtok(char *str, const char *delim) |
| 首次调用 | strtok(commandline, " ") - 传入待分割字符串 |
| 后续调用 | strtok(NULL, " ") - 传入 NULL 继续分割 |
| 返回 | 分割出的子字符串,无更多时返回 NULL |
| 分隔符 | " "(空格),可定义其他字符如 "\t\n" |
| 原字符串 | 会被修改(分隔符替换为 \0) |

2、示例步骤(输入 "ls -a -l")

结果

argc = 3argv = ["ls", "-a", "-l", NULL]

3、代码关键行解析

|-------------------------------------|----------|--------------|
| 代码行 | 功能 | 说明 |
| argc=0 | 重置参数计数器 | 每次解析前清零 |
| memset(argv,0,...) | 清空参数数组 | 所有指针设为 NULL |
| strlen(commandline)==0 | 检查空输入 | 用户只按回车时返回 |
| argv[argc]=strtok(...) | 首次分割 | 获取第一个参数 |
| while((argv[++argc]=strtok(...))) | 循环获取剩余参数 | 一行完成递增、分割、赋值 |

4、strtok() 特性与注意事项

|-----------|-------------|--------------------|
| 特性 | 说明 | 注意 |
| 修改原串 | 分隔符处改为 \0 | 如需保留原串,先拷贝 |
| 静态变量 | 内部保存位置信息 | 非线程安全,用 strtok_r |
| 连续分隔符 | 多个分隔符视为一个 | 自动跳过 |
| 结尾处理 | 最后返回 NULL | 循环条件自然终止 |

5、调试输出结果示例

|-----------|------|
| 变量 | 值 |
| argc | 3 |
| argv[0] | "ls" |
| argv[1] | "-a" |
| argv[2] | "-l" |
| argv[3] | NULL |

流程演示

我们以表格的形式再理解一下:

总结

一句话总结就是:strtok() 按空格分割命令行,将 "命令 参数1 参数2" 变成数组 ["命令","参数1","参数2",NULL],并计数参数个数。

3.3 五步走

3.3.1 env

演示结果如下所示:

3.3.2 export

export的变量可以被添加到环境变量表里面。

如下图所示:

3.3.3 获取环境变量:LoadEnv

我们之前定义了计数器,这里直接调用一下即可。

这样我们就获取到了环境变量。

3.3.4 本地变量

如何理解本地变量?其实都是bash数据,都被子进程继承了,只不过我们看不到,也就是说,不带export的就到本地变量表了。

我们可以在这个shell里面再形成一张表,即本地变量表:

一句话概括两张表关系:

  • 默认不带export,写到这个local本地变量表里面;
  • 带了export,写到env环境变量表里面

3.4 内建命令和普通命令

内建命令就是让Shell自己去维护自己解析的命令。我们之前说过:可以把内建命令当做bash内部的一个函数。

3.4.1 对比

内建命令(Builtin Command)普通命令(External Command) 的表格对比如下:

|----------|-------------------------------------|--------------------------------|
| 对比维度 | 内建命令 (Builtin Command) | 普通命令 (External Command) |
| 执行方式 | 由 Shell 进程自身执行(不创建子进程) | 由于进程执行(通过 fork() + exec()) |
| 功能实现 | Shell 程序内部实现的功能函数 | 外部可执行文件 |
| 目的 | 影响 Shell 自身状态(如改变当前目录、设置环境变量等) | 执行独立程序 |
| 特点 | 执行速度快,无需创建新进程 | 需要创建新进程,有进程切换开销 |
| 示例 | cdechoenvexportexit 等 | lsgrepcatpython 等 |

3.4.2 为什么要区分内建命令

我们就以路径相关的指令:cd命令为例,存在cd路径无法回退的问题

复制代码
# 如果是外部命令(错误):
bash$ cd /home/user  # 子进程改变目录
bash$ pwd            # 父进程目录没变,还在原地
# → /old/path

# 如果是内建命令(正确):
bash$ cd /home/user  # 父进程改变目录
bash$ pwd            # 父进程目录已变
# → /home/user

其它还有一些必须使用内建的情况,艾莉丝这里举一些例子:

  • export VAR=value : 设置环境变量(必须影响当前 Shell);

  • exit : 退出 Shell 自身;

  • source script.sh : 在当前 Shell 环境中执行脚本。

3.4.3 内建命令检查函数

上图中 CheckBuiltinAndExecute() 这个函数实现了两个内建命令:

cd 命令:

复制代码
if(strcmp(argv[0],"cd") == 0) {
    ret = 1;  // 标记为内建命令
    if(argc == 2) {
        chdir(argv[1]);  // 改变当前目录
    }
}
  • 使用 chdir() 系统调用改变当前工作目录;

  • 必须在父进程中执行,因为子进程的目录改变不影响父进程。

echo 命令:

复制代码
else if(strcmp(argv[0],"echo") == 0) {
    ret = 1;  // 标记为内建命令
    if(argc == 2) {
        if(argv[1][0] == '$') {
            if(strcmp(argv[1],"$?") == 0) {
                printf("%d\n",lastcode);  // 打印上一次命令的退出码
                lastcode = 0;
            }
            // 其他环境变量处理(未实现)
        } else {
            printf("%s\n",argv[1]);  // 直接打印字符串
        }
    }
}
  • 特殊处理 $?:打印上一个命令的退出状态码;

  • 其他情况直接输出参数。

3.4.4 which命令没查到的就是典型的内建命令------cd、echo、env也是内建,为什么能够在磁盘上用which指令查到?

which命令没查到的就是典型的内建命令

其它的内建命令,像export,用which是查不到的,但是,cd、echo、env也是内建,为什么能够在磁盘上用which指令查到?

我们前面说内建命令是Shell自己实现的一个相当于成员函数的功能函数,由 Shell 进程自身执行(不创建子进程),那么就不可能能够用which指令查到,查到说明在磁盘上面存在这个文件。

这是因为Shell(比如bash)不仅是一个命令行解释器,同时也是一门语言------脚本语言。

之所以能够用which查到,是因为它们有两份,而且其中一份在磁盘上面真实存在!

3.5 指令别名支持、管道、重定向问题

别名、管道、重定向 这些都是我们现在的Shell没办法实现的,比如别名 ,举个例子,"ll"是别名,但是我们的Shell是识别不出来ll的.

别名 其实就是一个变量。

管道涉及到进程间通信,我们后面讲到了再介绍;重定向要麻烦一些,我们后面在文件部分再讨论,预计在下一篇博客就能够讲到啦。

其实在实现自主Shell命令行解释器中其它还能够解决,都没有特别吃力,唯独有一个------非常费劲:就是检查配置文件,这个不是我们现在能够办到的。

3.6 shell总结

在继续学习新知识前,我们来思考函数和进程之间的相似性:

exec / exit就像call / return。

一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call / return系统进行通信。

这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图所示:

一个C程序可以fork / exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值。

3.8 演示

myshell.h

复制代码
  1 #pragma once
  2 
  3 #include <stdio.h>                                                                                                                               
  4 
  5 void Bash();

myshell.c

复制代码
#include "myshell.h"

#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>

// 提示符(真的是字符串)相关
static char username[32];
static char hostname[64];
static char cwd[256];
static char commandline[256];

// 命令行相关
static char *argv[64];  // 一般最多也就十几个
static int argc = 0;    
static const char *sep = " ";

// 与退出码有关
static int lastcode;

// 与环境变量有关,应该由bash来维护,从系统配置文件读,直接从系统bash拷贝即可
static char *env[64];

static void GetUserName()
{
    char *_username = getenv("USER");   // 获取环境变量
    strcpy(username,(_username ? _username : "None"));
}

static void GetHostName()
{
    char *_hostname = getenv("HOSTNAME");   // 获取环境变量
    strcpy(hostname,(_hostname ? _hostname : "None"));
}

//static void GetCwdName()    // 要详细处理,包括只保留"/"之后的路径
//{
//    char *_cwd = getenv("PWD");   // 获取环境变量
//    strcpy(cwd,(_cwd ? _cwd : "None"));
//}


static void GetCwdName()    // 要详细处理,包括只保留"/"之后的路径
{
    char _cwd[256];
    getcwd(_cwd,sizeof(_cwd));
    if(strcmp(_cwd,"/") == 0)
    {
        strcpy(cwd,_cwd);
    }
    else
    {
        int end = strlen(_cwd) - 1;
        while(end >= 0)
        {
            if(_cwd[end] == '/')
            {
                // 如 /home/Alice
                strcpy(cwd,&_cwd[end + 1]);
                break;
            }
            end--;
        }
    }
}

void PrintPrompt()
{
    GetUserName();
    GetHostName();
    GetCwdName();
    printf("[%s@%s %s]# ",username,hostname,cwd);
    fflush(stdout); // 直接刷新缓冲区
}

static void GetCommandLine()
{
    if(fgets(commandline,sizeof(commandline),stdin) != NULL)
    {
        // 比如"abcd\n" -> "abcd"
        commandline[strlen(commandline) - 1] = 0;
        // printf("debug:%s\n",commandline);
    }
}

static void ParseCommandLine()
{
    // 清空
    argc = 0;
    memset(argv,0,sizeof(argv));
    // 判空
    if(strlen(commandline) == 0)
        return;
    // 解析 ls -a -l
    argv[argc] = strtok(commandline,sep);   // strtok分割字符串,变成几个子串
    while((argv[++argc] = strtok(NULL,sep)));   // argc的大小即子串个数

// // 调试信息注释掉
//    printf("argc:%d\n",argc);
//    int i = 0;
//    for(;argv[i];i++)
//    {
//        printf("argv[%d]:%s\n",i,argv[i]);
//    }
}

// 创建子进程
void Execute()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return;
    }
    else if(id == 0)
    {
        // printf("child running..\n");
        // 子进程
        // 程序替换
        execvp(argv[0],argv);
        exit(1);
    }
    else{
        // 父进程
        int status = 0;
        pid_t rid = waitpid(id,&status,0);  // 得到状态
        (void)rid;
        lastcode = WEXITSTATUS(status);
    }
}

// 1:yes
// 0:no
int CheckBuiltinAndExcute()
{
    int ret = 0;
    if(strcmp(argv[0],"cd") == 0)
    {
        // 内建命令
        ret = 1;
        if(argc == 2)   // 有选项
        {
            chdir(argv[1]);
        }
    }
    else if(strcmp(argv[0],"echo") == 0)
    {
        ret = 1;
        if(argc == 2)
        {
            // echo $?
            // echo $PATH
            // echo "helloworld"
            // echo helloworld
            if(argv[1][0] == '$')
            {
                if(strcmp(argv[1],"$?") == 0)
                {
                    printf("%d\n",lastcode);
                    lastcode = 0;   // 返回上一次的退出码
                }
                else
                {
                    // env:环境变量
                }
            }
            else
            {
                printf("%s\n",argv[1]); // 打印字符串,不细分什么""或者没有""了
            }
        }
    }

    return ret;
}

void Bash()
{
    while(1)    // 软件都是死循环的
    {
        // 第1步:输出命令行
        PrintPrompt();

        // 第2步:等待用户输入,获取用户输入
        // char commandline[256]; -> scanf()
        // sleep(1);   // 等待一会儿
        GetCommandLine();

        // 第3步:解析字符串,"ls -a -l" -> "ls" "-a" "-l"
        ParseCommandLine();
        if(argc == 0)
            continue;

        // 第4步:有些命令,cd、ench、env、export等命令,不应该让子进程执行,而应该让父进程自己执行,内建命令。bash内部的函数
        if(CheckBuiltinAndExcute())
        {
            continue;
        }

        // 第5步:执行命令
        Execute();
    }
}

main.c

复制代码
  1 #include "myshell.h"                                                                                                                             
  2 
  3 int main()
  4 {
  5     Bash();
  6     return 0;
  7 }

Makefile

复制代码
  1 mybash:myshell.c main.c
  2     gcc -o $@ $^
  3 .PHONY:clean
  4 clean:
  5     rm -f mybash  
相关推荐
HIT_Weston2 小时前
119、【Ubuntu】【Hugo】首页板块配置:Template Lookup Order
linux·运维·ubuntu
wangt59522 小时前
Ubuntu22.04.5的网络配置在重启后被重置的问题
linux·运维·服务器
不被定义的程序猿2 小时前
如何使用docker搭建一个 aarch-linux-gun-gcc的交叉编译环境
linux·运维·服务器
学历真的很重要2 小时前
【系统架构师】第一章 计算机系统基础知识(详解版)
学习·职场和发展·系统架构·系统架构师
RisunJan2 小时前
Linux命令-logrotate(自动轮转、压缩、删除和邮件发送日志文件)
linux·运维·服务器
浅念-2 小时前
C语言——自定义类型:结构体、联合体、枚举
c语言·开发语言·数据结构·c++·笔记·学习·html
AI视觉网奇2 小时前
ue slot 插槽用法笔记
笔记·学习·ue5
Jack___Xue2 小时前
LangGraph学习笔记(二)---核心组件与工作流人机交互
笔记·学习·人机交互
Marry Andy2 小时前
Atlas 300l Duo部署qwen3_32b_light
linux·人工智能·经验分享·语言模型·自然语言处理