Shell 的简单实现

环境

选项 参数
系统 Windows 10 下 VMware Workstation 虚拟机 ubuntu 14.04.6 desktop i386
GCC gcc version 4.8.4 (Ubuntu 4.8.4-2ubuntu1~14.04.4)

功能

  1. 解析并执行用户提交的命令行
  2. 提供 lsmkdirrmdirpwdps 等内部命令(多进程并使用 execvp 函数调用)
  3. 最后一个参数为 & 时,命令后台执行,同时父进程应当等待子进程
  4. 提供历史查询功能:当用户按下 Ctrl+C 时输出最近的 10 个命令行
  5. 执行历史命令行:当用户输入 r x 时,执行首字母为 x 的最近的命令行,如果没有参数则执行最近的命令
  6. 用户输入 Ctrl+D 时退出程序

步骤

Ctrl+C

实验要求在用户按下 Ctrl+C 时,不退出程序,而改为输出最近输入的 10 条命令,首先需要捕获,然后重新定义它的功能。

void my_handle(int sig);

int main()
{
    signal(SIGINT, my_handle);
    /* ... */
}

void my_handle(int sig)
{
    int i, j;
    printf("\nCaught Control C\n");
    if (idx == -1)
        printf("Never entered a command.\n");
    else
        for (i = 0; i < MAX_HISTORY; ++i)
            if (!history[j = (idx - i) < 0 ? idx - i + MAX_HISTORY : idx - i][0])
                break;
            else
                printf("%2d: %s\n", i + 1, history[j]);
}

发现 Ctrl+C 的确变成了输出最近输入的 10 条命令,但是在输出完之后依旧会退出,如下图

最后发现 Ctrl+C 每次发出 SIGINT 信号,但是在 SIGINT 信号处理完之后,其处理函数会重置,不再是 my_handle,所以在每次调用 my_handle 时都需要重新设置,作如下更改:

void my_handle(int sig)
{
    signal(SIGINT, my_handle);
    /* ... */
}

更改完之后如下

发现缺少 COMMAND-> 提示,重新把它加上。

Ctrl+D

实验要求在用户按下 Ctrl+D 时退出程序,由于 Ctrl+D 并非如 Ctrl+C 一般发送一个信号,而是作为一个特殊的字符。在键盘缓冲区内有数据时, Ctrl+D 会刷新缓冲区,使程序能读取到这些字符;在键盘缓冲区内没有字符时,Ctrl+D 会向程序发送 EOF,使用 getc 等函数读取时会返回 -1,所以需要自行实现 readline 类的输入函数并判断是否键入 Ctrl+D

输入并解析

由于存储的历史记录为完整的命令,除了在输入之后需要解析之外,在使用 r x 命令时也需要再次解析,所以选择将解析的功能剥离出 setup

首先使用 getc 函数逐个读取字符,直接使用 scanf 等函数不易判断 EOF

/**
 * 读取命令并调用 parse_command 解析命令
 *
 * @param buffer 输入的命令
 * @param args 存放解析完的参数
 * @param background 为 1 时表示后台执行
 **/
void setup(char buffer[], char* args[], int* background)
{
    /**
     * i 表示命令中当前字符的下标
     * j 表示参数个数
     * k 用于查找历史记录
     **/
    int i, j, k;
    // 读取命令
    i = 0;
    do {
        // 读取一字符
        buffer[i] = getc(stdin);
        // 捕获 Ctrl+D
        if (buffer[i] == EOF) {
            printf("\nCaught Control D\n");
            exit(0);
        }
    } while (buffer[i++] != '\n');
    // 将 '\n' 结尾的字符串替换为 '\0' 结尾的标准字符串
    buffer[--i] = '\0';
    // 解析命令
    j = parse_command(buffer, args);
    /* 后续处理 */
}

对命令行进行解析,以空白符为分隔:

/**
 * 解析命令
 *
 * @param buffer 输入的命令
 * @param args 存放解析完的参数
 *
 * @return 参数个数
 **/
int parse_command(char buffer[], char* args[])
{
    /**
     * i 表示命令中当前字符的下标
     * j 表示命令中当前参数的下标
     * k 表示命令中当前参数的长度
     * c 用于读取的字符并处理
     **/
    int i, j, k;
    char c;
    // 指针置 NULL 但不释放
    i = 0;
    while (args[i])
        args[i++] = NULL;
    // 分割参数
    j = k = 0;
    for (i = 0; i < MAX_SIZE; ++i)
        if (isspace(buffer[i]) || !buffer[i]) {
            if (i && !isspace(buffer[i - 1])) {
                /**
                 * 分配空间并拷贝字符串
                 * k + 1 保留 '\0' 的空间
                 **/
                args[j] = malloc(sizeof(char) * (k + 1));
                // 标识字符串末尾
                c = buffer[i];
                buffer[i] = '\0';
                // 拷贝参数
                strcpy(args[j++], buffer + i - k);
                buffer[i] = c;
                k = 0;
            }
            if (!buffer[i]) break;
        } else ++k;
    // 返回参数个数
    return j;
}

由于 r x 命令出现在历史记录中可能导致产生命令死循环,所以禁止其加入历史记录:

void setup(char buffer[], char* args[], int* background)
{
    /* 输入命令 */
    // 解析命令
    j = parse_command(buffer, args);
    // 如果输入了一条非空命令
    if (j) {
        if (strcmp("r", args[0]))
            // 保存命令,FIFO
            strcpy(history[idx = (idx + 1) % MAX_HISTORY], buffer);
        else if (idx == -1)
            // 没有历史记录
            printf("Never entered a command.\n");
        else {
            k = idx;
            // 执行历史中记录的命令
            if (args[1] && args[1][0])
                // 查找命令
                for (i = 0; i < MAX_HISTORY; ++i)
                    if (args[1][0] == history[k = (idx - i) < 0 ? idx - i + MAX_HISTORY : idx - i][0])
                        break;
            // 释放空间
            i = 0;
            while (args[i])
                free(args[i++]);
            //解析命令
            j = 0;
            if (k == MAX_HISTORY)
                printf("Command not found.\n");
            else
                j = parse_command(history[k], args);
        }
        // 后台执行
        if (j && !strcmp("&", args[j - 1])) {
            // 设置 background 值
            *background = 1;
            --j;
        }
    }
    // 以 NULL 标识参数的结束
    args[j] = NULL;
}

main 函数,实现整体功能:

int main()
{
    char buffer[MAX_SIZE];
    /**
     * 存储参数
     * 
     * (MAX_SIZE + 1) / 2 保证能存储所有参数
     * 1 保证有多余的空位存储 NULL 以标识参数的结尾
     **/
    char* args[(MAX_SIZE + 1) / 2 + 1];
    int background, i;
    pid_t pid;
    // 捕获 Ctrl+C
    signal(SIGINT, my_handle);
    memset(history, 0, sizeof(history));
    while (1) {
        background = 0;
        printf("COMMAND-> ");
        // 读取并解析命令
        setup(buffer, args, &background);
        pid = fork();
        if (pid == -1) {
            fprintf(stderr, "fork error\n");
            // 释放空间
            i = 0;
            while (args[i])
                free(args[i++]);
        } else if (pid == 0) {
            execvp(args[0], args);
        } else if (background) {
            pid = wait(NULL);
            printf("[parent] child process %d done\n", pid);
        }
    }
}

父进程等待

测试中发现功能基本已经完善,但是在输入后台命令时,只有执行第一条后台命令时父进程会等待子进程,之后的命令父进程都不会等待,如下图

分析 wait 函数发现只要有一个子进程完成,wait 函数就会返回,所以父进程不会等待之后的子进程。

wait 函数更改为 waitpid,等待指定的子进程:

pid = waitpid(pid, NULL, 0);

最终结果如下

至此完成所有功能。

实验总结

本次实验主要学习了如何使用多进程来完成工作,因为 execvp 会替换当前进程,所以如果不使用多进程将会导致每次执行只能解析运行一条指令。同时了解了如何设置专门的信号处理函数,信号在编程中如何使用,也了解到了 Ctrl+CCtrl+D 的区别。

个人博客:https://wilfredshen.cn/

你可能感兴趣的:(Shell 的简单实现)