Linux进程控制(下):实现简易 Shell 命令行解释器

1. 什么是 Shell

简单来说,Shell 是系统的外壳

它是一个特殊的程序,充当了用户操作系统内核之间的 "桥梁" 角色。鉴于内核(Kernel)的特殊与敏感性,不便直接接触用户,所以 Shell 负责接收用户输入的字符串命令,将其解释为操作系统能听懂的语言,并将结果反馈给用户

我们在终端里敲下的 ls、cd、mkdir,本质上都是在和 Shell 进行交互

2. Shell 的基本工作原理

要实现一个 Shell,首先要理解它的底层逻辑。其实,Shell 的生命周期就是一个死循环。其工作流程遵循着固定的模式:

核心执行流程

一个标准的命令行解释器,其运行逻辑可以拆解为以下五个步骤:

  1. 打印提示符:显示类似 [user@hostname dir]$ 的字符串,告知用户输入字符串指令

  2. 读取命令: 等待用户输入一行指令(例如 ls -l -a),并将其存入缓冲区

  3. 解析命令 : 将用户输入的长字符串拆解成命令和选项(例如将 "ls -l -a" 拆分为 "ls"、"-l"、

    "-a" 三个独立的子串)

  4. 执行命令: 最核心的一步。对于绝大多数外部命令,Shell 会通过 fork 创建一个子进程,然后子进程利用 exec 族函数进行程序替换,去运行真正的指令程序

  5. 等待下一次输入: 父进程(Shell)调用 waitpid 等待子进程结束,回收资源。子进程退出后,Shell 重新回到第一步,继续循环

为什么 Shell 永不退出

你会发现,无论你执行多少次命令,Shell 依然在那里。除非你输入 exit 或按下 Ctrl+D,否则它不会消失

这是因为 Shell 本身就是一个 while(1) 的死循环。它并不直接执行 ls 或 pwd,而是 fork 子进程去执行。子进程崩了或者退出了,并不影响主进程 Shell。 这种结构极大地保证了交互界面的稳定性

3. 简易 Shell 的整体框架

在技术实现层面,简易 Shell 的整体框架通常基于以下 C 语言结构。该结构定义了程序从启动到进入常驻内存状态的完整路径:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

#define MAX_CMD 1024    // 用户输入命令的最大长度
#define MAX_ARG 64      // 命令拆分后的最大参数个数

char command_line[MAX_CMD]; // 存储原始命令字符串的缓冲区
char *command_args[MAX_ARG]; // 存储解析后的参数指针数组

int lastcode = 0;       // 用于读取上一次退出的退出码

int main() {
    // 核心交互循环
    while (1) {
        // 1: 打印命令行提示符
        PrintPrompt();

        // 2: 获取用户输入的指令字符串
        if (!GetCommand(command_line, sizeof(command_line))) {
            continue;
        }

        // 3: 将长字符串拆分为参数数组
        ParseCommand(command_line, command_args);

        // 4: 判断是否为内建命令并执行
        if (BuiltInCommandExec(command_args)) 
            continue;

        // 5: fork 子进程并执行指令
        ExecuteExternal(command_args);
    }
    return 0;
}

关键模块

该框架的设计遵循了模块化原则,各部分界定如下:

  1. PrintPrompt: 该模块负责获取当前系统的环境信息(如当前用户名、主机名及工作目录),并将其格式化输出至标准输出流。这是用户交互的起始点

  2. GetCommand:从标准输入读取一行数据。此阶段需要处理用户直接输入换行符(空输入)的情况,以防止程序进入无效的逻辑分支

  3. ParseCommand: 该过程涉及字符串的词法分析。程序需扫描 command_line 缓冲区,识别空格等分隔符,并将各个指令片段提取出来存入 command_args 指针数组。此步骤是后续调用 execvp 等函数的基础

  4. 进程控制流

    • 子进程阶段:调用 fork() 产生副本,并在子进程空间内使用 exec 函数族进行程序替换

    • 父进程阶段:Shell 主进程通过 waitpid() 进入阻塞等待状态,直到子进程执行完毕并返回退出状态信息。

在运行期间,Shell 进程通过 while(1) 维持其生命周期。除 exec 调用失败或捕获到特定的终止信号(如 exit 命令)外,主进程将始终常驻于系统进程表中,确保交互的连续性

4. 提示符的设计与显示

命令行提示符(Command Prompt)是 Shell 提供给用户的第一个交互界面。功能完整的提示符通常包含当前用户名主机名 以及当前工作目录。在 Linux 系统中,获取这些信息主要有两种途径:读取环境变量和执行系统调用

获取系统信息的途径

1 用户名与环境变量

在进程启动时,内核会为其分配一套环境变量。其中,变量 USER 存储了当前登录用户的名称。通过 C 标准库提供的 getenv() 函数,程序可以访问这些环境信息

  • 实现方式:char *user = getenv("USER")

  • 也可以通过系统调用 getuid() 获取用户 ID,再利用 getpwuid() 函数从系统用户数据库中检索对应的用户名

2 主机名与系统调用

主机名通常不存储在常规的环境变量中,而是通过专门的系统调用 gethostname() 获取

  • 函数原型:int gethostname(char *name, size_t len)

  • 机制:调用该接口后,内核会将当前系统的主机名字符串拷贝至指定的缓冲区 name 中

3 当前工作目录

获取当前进程的工作路径有两种常用方法:

  1. getcwd()系统调用:直接从内核获取进程当前关联的绝对路径

  2. getenv("PWD"):读取由 Shell 维护的路径环境变量

需要注意的是,为了模拟真实 Shell 的行为,通常会将路径中的家目录部分(如 /home/username)替换为波浪号 ~

实现代码

cpp 复制代码
// 1. 获取用户名
const char* GetUserName() {
    const char *user = getenv("USER");
    return (user != NULL) ? user : "unknown";
}

// 2. 获取主机名
void GetHostName(char *buffer, size_t len) {
    if (gethostname(buffer, len) != 0) {
        snprintf(buffer, len, "unknown");
    }
}

// 3. 获取当前目录
void GetCurrentDir(char *buffer, size_t len) {
    if (getcwd(buffer, len) == NULL) {
        snprintf(buffer, len, "unknown");
    }
}

// 4. 统一调用
void PrintPrompt() {
    char hostname[HOST_NAME_MAX];
    char cwd[PATH_MAX];

    // 调用功能函数
    const char *user = GetUserName();
    GetHostName(hostname, sizeof(hostname));
    GetCurrentDir(cwd, sizeof(cwd));

    printf("[%s@%s %s]$ ", user, hostname, cwd);

    // 强制刷新标准输出
    fflush(stdout);
}

在输出提示符时,必须显式调用 fflush(stdout)。由于标准输出在连接至终端时通常采用行缓冲策略,而提示符末尾通常不包含换行符 \n,因此数据可能会滞留在用户态缓冲区中。调用 fflush 可确保在程序进入阻塞等待输入状态前,提示符已完整渲染至屏幕

5. 命令读取与解析

在提示符显示完成后,Shell 必须进入阻塞状态以等待用户输入。这一阶段涉及两个核心步骤:从标准输入流捕获原始字符串,以及将该字符串规约为可执行的参数序列。

关键函数简介

在 C 语言系统编程中,fgets 和 strtok 是处理命令行输入的常用工具

fgets 函数

fgets 用于从指定的流中读取一行数据,直到遇到换行符、文件结束符或读取了特定长度的字符

  • 原型:char *fgets(char *s, int size, FILE *stream)

  • 特性:与 scanf 不同,fgets 会读取空格并将其视为字符串的一部分。需要注意的是,如果缓冲区空间足够,fgets 会将输入末尾的换行符 \n 一并读入缓冲区

strtok 函数

strtok 用于根据指定的分隔符集将字符串拆分为一系列子串

  • 原型:char *strtok(char *str, const char *delim)

  • 机制:在首次调用时,需传入待解析的字符串 str;在随后的调用中,首参数需传入 NULL,以便函数从上一次拆分停止的位置继续扫描。该函数会修改原始字符串,将匹配到的分隔符替换为字符串结束符 \0

命令读取的实现

读取命令时,除了调用 fgets,还必须处理用户直接输入回车产生的换行符。如果不移除该换行符,后续执行程序替换时,文件名将包含不可见的 \n,导致执行失败

cpp 复制代码
int GetCommand(char *buf, int size) {
    // 从标准输入读取一行
    if (fgets(buf, size, stdin) == NULL) {
        return 0; // 读取失败或遇到 EOF
    }

    // 处理换行符:将 \n 替换为 \0
    size_t len = strlen(buf);
    if (len > 0 && buf[len - 1] == '\n') {
        buf[len - 1] = '\0';
    }

    // 检查是否为空输入
    if (strlen(buf) == 0) return 0;

    return 1;
}

命令解析的实现

解析过程的核心是将连续的命令字符串拆分为参数指针数组。例如,将字符串 "ls -l -a" 转化为 {"ls", "-l", "-a", NULL}。这种格式是 execvp 等系统调用所要求的标准形式

cpp 复制代码
#define DELIM " " // 定义分隔符为空格

void ParseCommand(char *buf, char **args) {
    int i = 0;
    // 第一次调用 strtok
    args[i++] = strtok(buf, DELIM);

    // 循环调用以获取后续参数
    while ((args[i++] = strtok(NULL, DELIM))) {
        // 如果达到最大参数限制则停止
        if (i >= MAX_ARG - 1) break;
    }
    
    // 数组末尾必须以 NULL 结尾,符合 exec 族函数的要求
    args[i] = NULL;
}
  • 安全性:在解析过程中,必须确保指针数组 args 不会发生越界。并且将解析后的数组最后一个元素强制设为 NULL

  • 多空格处理:strtok 会自动跳过连续的多个分隔符,因此用户在输入命令时多打几个空格不会影响解析结果的准确性

6. 命令执行

在 Shell 的解析阶段完成后,我们需要将解析出的参数数组交付给内核执行。对于外部命令(如 ls、top、pwd 等),Shell 并不是直接调用函数执行,而是通过 fork()、exec() 和 waitpid() 来协同完成

为什么需要 fork()

Shell 本身是一个持续运行的进程。如果直接在当前进程中执行程序换,那么新程序的代码将覆盖 Shell 自身的代码,导致 Shell 在执行完一条指令后就彻底终止。

  • 隔离性:通过 fork() 产生子进程,Shell 可以将执行任务的职责下放给子进程

  • 安全性:子进程即使因为执行非法操作(如段错误)而崩溃,也不会影响到作为父进程的 Shell 交互界面

为什么需要 exec()

子进程被创建后,其代码和数据与父进程基本一致。为了执行磁盘上的可执行程序,必须使用 exec 函数族进行程序替换

为什么需要 waitpid()

当子进程去执行指令时,Shell 进程必须进入等待状态

  • 同步机制:确保 Shell 在子进程完成任务前不会抢先打印新的提示符,从而维持交互的有序性

  • 资源回收:收集子进程的退出状态,防止其退化为僵尸进程,确保系统资源的循环利用

在实现该功能时,我们通常选择 execvp 函数,因为它能够根据 PATH 环境变量自动搜索可执行文件,且接收数组形式的参数

cpp 复制代码
void ExecuteExternal(char **args) {
    // 1. 创建子进程
    pid_t id = fork();

    if (id < 0) {
        perror("fork error");
        exit(1);
    } 
    else if (id == 0) {
        // 2. 子进程程序替换
        // 第一个参数是文件名,第二个是参数数组
        execvp(args[0], args);

        // 如果 execvp 返回,说明替换失败
        perror("command not found");
        exit(127);
    } 
    else {
        // 3. 父进程阻塞等待
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);

        if (ret > 0) {
            if (WIFEXITED(status)) lastcode = WEXITSTATUS(status));
            else lastcode = 1; // 异常退出设置为 1
        }
    }
}

7. 内建命令

内建命令是 Shell 解释器程序内部的一个功能模块。当 Shell 解析到用户输入的命令属于内建命令时,它不会创建子进程,而是直接在当前的 Shell 进程空间内调用相应的函数或系统调用


1. 为什么有些命令必须内建

在 Shell 的设计架构中,内建命令并非为了优化执行效率而存在的加速方案,而是基于进程独立性原则下的一种技术必然

1. 外部命令执行的局限性

前文提到,执行外部命令的标准流程是 fork 子进程。在 Linux 内核的实现逻辑中,子进程虽然在初始时刻通过写时拷贝 机制共享父进程的物理内存,但它拥有独立的虚拟地址空间进程上下文

这意味着,子进程对自身运行环境(如工作目录、环境变量等)所做的任何修改,均被严格限制在子进程的 task_struct 结构体及其关联的内存映射中。一旦子进程任务完成并退出,这些修改将随着进程空间的销毁而消失,无法对父进程产生任何持久性影响

2. cd 命令

cd指令的核心职能是修改进程的当前工作目录。我们可以通过以下逻辑理解 cd 无法作为外部命令存在:

  1. 子进程派生:当用户输入 cd /home/user 时,如果 Shell 调用 fork 产生子进程

  2. 环境修改:子进程在其内部执行系统调用 chdir("/home/user")。此时,子进程的 CWD 属性成功更新为目标路径

  3. 进程退出:子进程执行完毕并退出

  4. 状态:父进程从 waitpid 中唤醒。由于父进程的 task_struct 及工作目录信息从未被修改,Shell 依然停留在执行命令前的原目录

结论 :如果 cd 是外部命令,它只能改变子进程的路径,而无法改变作为用户交互界面的 Shell 本身的路径

基于上述逻辑,所有在改变 Shell 自身状态配置进程上下文的指令,都必须在 Shell 进程内部直接调用系统调用,而不经过 fork 流程

除了 cd 之外,以下场景同样要求指令必须内建:

  • 环境变量操作(如 export, unset):环境变量存储在进程的地址空间中。子进程设置的环境变量无法逆向作用于父进程

  • 作业控制(如 fg, bg, jobs):这些操作涉及对 Shell 内部维护的任务列表进行增删改查

  • 进程终止(如 exit):该指令必须直接触发 Shell 进程的退出逻辑


2. 常见内建命令的实现

在 Shell 的主循环中,在执行 fork 之前,需要先对解析出的参数进行匹配检查。如果匹配到内建命令,则直接跳转执行相关逻辑,并跳过后续的流程

以下是三个典型内建命令的具体实现方案:

cd 指令实现

cd 的核心是调用封装了内核操作的 chdir 系统调用

cpp 复制代码
int DoCd(char **args) {
    char *target_path = NULL;
    char current_path[1024];

    // 获取当前目录
    getcwd(current_path, sizeof(current_path));

    // 情况 A: cd 或 cd ~ -> 去家目录
    if (args[1] == NULL || strcmp(args[1], "~") == 0) {
        target_path = getenv("HOME");
    }
    // 情况 B: cd - -> 回到上一次目录
    else if (strcmp(args[1], "-") == 0) {
        target_path = getenv("OLDPWD");
    }
    // 情况 C: 普通路径
    else {
        target_path = args[1];
    }

    // 执行跳转
    if (chdir(target_path) == 0) {
        // 跳转成功后更新环境变量 OLDPWD
        // 将跳转前的路径存入 OLDPWD
        setenv("OLDPWD", current_path, 1);
    } else {
        perror("cd");
    }

    return 1;
}

echo 指令实现

虽然系统中通常存在 /bin/echo 外部程序,但为了效率和处理特定的环境变量(如 ?,HOME),Shell 往往会内置 echo

cpp 复制代码
void DoEcho(char **args) {
    if (args[1] == NULL) {
        printf("\n");
        return;
    }

    for (int i = 1; args[i] != NULL; i++) {
        char *item = args[i];
        if (item[0] == '$') {
            if (strcmp(item, "$?") == 0) {
                // 打印退出码
                printf("%d ", lastcode);
            } else {
                // 打印环境变量
                char *env = getenv(item + 1); // 跳过 $
                printf("%s ", env);
            }
        } else {
            printf("%s ", item);
        }
    }
    printf("\n");
    lastcode = 0;
}

exit 指令实现

exit 用于终止当前 Shell 的生命周期

cpp 复制代码
int DoExit(char **args) {
    // 可以在此处进行资源清理或保存历史记录
    printf("Shell exited.\n");
    exit(0);
    return 1;
}

统一调用

将内建命令实现后我们在处理函数中统一调用即可

cpp 复制代码
int BuiltInCommandExec(char **args) {
    if (args[0] == NULL) return 0;

    if (strcmp(args[0], "cd") == 0) {
        DoCd(args);
        return 1;
    }
    else if (strcmp(args[0], "echo") == 0) {
        DoEcho(args);
        return 1;
    }
    else if (strcmp(args[0], "exit") == 0) {
        DoExit(args);
        return 1;
    }
    return 0;
}

8. 总结

综上所述,Shell 本质上是一个基于进程控制机制构建的命令解释器。从提示符的输出、命令的读取与解析,到 fork 创建子进程、exec 完成程序替换,再到使用 waitpid 进行进程回收,这一完整流程将前面所学习的进程控制知识真正串联起来,转化为一个可运行的实际系统

与此同时,内建命令的实现进一步说明,并非所有命令都可以通过外部程序完成,一些涉及 Shell 自身状态的操作必须由其内部直接处理

通过这一简单 Shell 的实现,我们验证了进程控制相关接口的作用,为后续实现更复杂的功能(重定向、管道等)打下了基础

相关推荐
薛定谔的悦2 小时前
BMS Modbus RTU实现:从帧结构到寄存器映射的完整工程
linux·数据库·bms
Smile_2542204182 小时前
clickhouse日志疯涨问题
linux·运维·服务器·clickhouse
2301_旺仔2 小时前
【Nginx进程管理】
linux·服务器·网络
SPC的存折2 小时前
(自用)LNMP-Redis-Discuz5.0部署指南-openEuler24.03-测试环境
linux·运维·服务器·数据库·redis·缓存
舒一笑2 小时前
Docker Compose 挂载 Nginx 配置的正确姿势(90%的人都踩过这个坑)
运维·docker·容器
W.W.H.2 小时前
嵌入式常见面试题——操作系统与RTOS篇
linux·经验分享·操作系统·rtos
云飞云共享云桌面3 小时前
共享云主机告别传统电脑——制造工厂研发部门2台三维设计云主共享给20个设计师并发用
大数据·运维·服务器·自动化·电脑·制造
航Hang*3 小时前
Windows Server 配置与管理——第10章:配置FTP服务器
运维·服务器·网络·windows·学习·vmware
此刻觐神3 小时前
IMX6ULL开发板学习-05(Linux之Vi/Vim编辑器的使用)
linux·学习·编辑器