操作系统 / 计算机专业课

[Linux] C语言实现一个 Shell (持续更新)

Be_invisible · 3月29日 · 2021年 ·

[Linux] C语言实现一个Shell (持续更新)

shellLinux等系统中的一个命令解释器, 它接受输入的命令, 解释之后与操作系统进行zcxblog.cn版权所有, 转载请说明交互. 在Linux终端Terminal输入的指令就是被shell接收的。

通过C语言手动实现shell, 不仅可以锻炼程序设计的能力, 还可以帮助理解操作系统的系统调用, 文件系统, 进程等重要知识.

shell可以实现:

  • cd, history, exitshell内置指令
  • ls, pwd, vi, grepprogram指令
  • 输入输出重定向
  • 管道
  • 后台运行程序

前置知识:

  • 基础C语言知识, 尤其是指针部分

  • unix系统编程, 如fork, dup2, chdir, getcwd, execvp, open, freopen, pipe函数须熟悉, 重定向, 管道指令须知晓.

    splitLine

0. 运行环境

云主机: CentOS 8.1 64位

物理机: Windows 10 version 2004

使用vscode remote ssh插件远程连接Linux云主机

splitLine

1. Shell框架

shell的大致流程为接受命令行 → 解释命令行 → 与操作系统交互, 如果shell没有退出, 则会重复前面的流程, 所以需要有一个while循环包裹三步流程. 我们用一个全局字符变量buf[BUFFSIZE]来保存输入, BUFFSIZE为宏定义, 这里设置为 255. 同时因为部分系统调用( 我们需要系统调用来实现输入的命令行) 函数需要其他命令行信息, 如命令行参数数量argc, 命令行指针数组argv, 命令行二维数组command.

/* 宏定义 */
#define MAX_CMD 10        // 最大命令数量
#define BUFFSIZE 255      // 输入最大命令字符数
#define MAX_CMD_LEN 100   // 每条命令的最大长度

/* 全局变量 */
int argc;                 // 命令行的有效参数个数
char* argv[MAX_CMD];      // 参数指针数组
char command[MAX_CMD][MAX_CMD_LEN]; // 参数二维数组
char buf[BUFFSIZE];       // 接受键盘输入的参数数组
char backupBuf[BUFFSIZE]; // buf数组的备份.

/* 函数声明 */
/** get_input函数
 * @brief 接受输入的字符
 * @param buf 存放命令输入的数组
 * @return int 即输入字符的数量
 */
int get_input(char buf[]);
/** parse函数
 * @brief 解析输入的buf字符串
 * @param buf
 * @return void
 */
void parse(char* buf);
/** do_cmd函数, 整个shell程序的核心
 * @brief 判断并执行命令
 * @param argc 
 * @param argv 
 */
void do_cmd(int argc, char* argv[]);

int main() {
    while(1) {
        // 前置输出提示这是一个shell
        printf("[myshell]$");
        // 接受来自键盘的输入, 如果输入字符(不包括回车)为0, 则跳过开始下一次循环
        if (get_input(buf) == 0)
            continue;
        strcpy(backupBuf, buf);
        parse(buf);
        do_cmd(argc, argv);
        argc = 0;
        // 后面在do_cmd部分会解释为什么无循环结束条件
    }
}

splitLine

2.输入并解析命令字符串


2.1get_input函数

/* 函数定义 */
/* get_input接受输入的字符并存入buf数组中 
 * 效果: 如果输入字符为"ls"后接回车, 则
 * buf[0] == 'l' && buf[1] == 's'
 */

int get_input(char buf[]) {
    // buf和backBuf数组初始化
    memset(buf, 0x00, BUFFSIZE);
    memset(backupBuf, 0x00, BUFFSIZE);        

    fgets(buf, BUFFSIZE, stdin);
    // 去除fgets带来的末尾\n字符
    buf[strlen(buf) - 1] = '\0';
    return strlen(buf);
}

2.2 parse函数

// 下方IN, OUT的宏定义
#define IN 1
#define OUT 0

void parse(char* buf) {
    // 初始化argv和command数组
    for (i = 0; i < MAX_CMD; i++) {
        argv[i] = NULL;
        for (j = 0; j < MAX_CMD_LEN; j++)
            command[i][j] = '\0';
    }
    argc = 0;
    // 下列操作改变了buf数组, 为buf数组做个备份
    strcpy(backupBuf, buf);
    /** 构建command数组
     *  即若输入为"ls -a"
     *  strcmp(command[0], "ls") == 0 成立且
     *  strcmp(command[1], "-a") == 0 成立
     */  
    int len = strlen(buf);
    for (i = 0, j = 0; i < len; ++i) {
        if (buf[i] != ' ') {
            command[argc][j++] = buf[i];
        } else {
            if (j != 0) {
                command[argc][j] = '\0';
                ++argc;
                j = 0;
            }
        }
    }
    if (j != 0) {
        command[argc][j] = '\0';
    }

    /** 构建argv数组
     *  即若输入buf为"ls -a"
     *  strcmp(argv[0], "ls") == 0 成立且
     *  strcmp(argv[1], "-a") == 0 成立*/
    argc = 0;
    int flg = OUT;
    for (i = 0; buf[i] != '\0'; i++) {
        if (flg == OUT && !isspace(buf[i])) {
            flg = IN;
            argv[argc++] = buf + i;
        } else if (flg == IN && isspace(buf[i])) {
            flg = OUT;
            buf[i] = '\0';
        }
    }
    argv[argc] = NULL;
}

splitLine

3. 识别并执行命令

do_cmd函数

一开始, 需要判断指令包含是否包含重定向与管道指令, 如果包含, 就进入对应的函数执行命令, 之后判断指令是否包含cd, exitshell内置指令, 如果均不满足, 则可以用execvp函数执行输入的命令. 我们这里fork了一个子进程执行命令, 父进程在子进程结束后返回.

此时, 该shell已经可以实现诸如ls, ls -a -l, pwd, vi等命令. 同时, 在判断为exit命令后, shell调用exit函数退出进程, 因为执行到这里时程序尚未创建任何进程, 所以调用该命令即在do_cmd, 而不是main函数中退出程序. 所以在main函数的while循环中可以无循环结束条件.

void do_cmd(int argc, char* argv[]) {
    pid_t pid;
    /* 识别program命令 */
    // 识别重定向输出命令
    for (j = 0; j < MAX_CMD; j++) {
        if (strcmp(command[j], ">") == 0) {
            strcpy(buf, backupBuf);
            int sample = commandWithOutputRedi(buf);
            return;
        }
    }

    // 识别输入重定向
    for (j = 0; j < MAX_CMD; j++) {
        if (strcmp(command[i], "<") == 0) {
            strcpy(buf, backupBuf);
            int sample = commandWithInputRedi(buf);
            return;
        }
    }

    // 识别追加写重定向
    for (j = 0; j < MAX_CMD; j++) {
        if (strcmp(command[j], ">>") == 0) {
            strcpy(buf, backupBuf);
            int sample = commandWithReOutputRedi(buf);
            return;
        }
    }

    // 识别管道命zcxblog.cn版权所有, 转载请说明令
    for (j = 0; j < MAX_CMD; j++) {
        if (strcmp(command[j], "|") == 0) {
            strcpy(buf, backupBuf);
            int sample = commandWithPipe(buf);
            return;
        }
    }

    // 识别后台运行命令
    for (j = 0; j < MAX_CMD; j++) {
        if (strcmp(command[j], "&") == 0) {
            strcpy(buf, backupBuf);
            int sample = commandInBackground(buf);
            return;
        }
    }

    /* 识别shell内置命令 */
    if (strcmp(command[0], "cd") == 0) {
        int res = callCd(argc);
        if (!res) printf("cd指令输入错误!");
    } else if (strcmp(command[0], "history") == 0) {
        printHistory(command);
    } else if (strcmp(command[0], "exit") == 0) {
        exit(0);
    } else {
        switch(pid = fork()) {
            // fork子进程失败
            case -1:
                printf("创建子进程未成功");
                return;
            // 处理子进程
            case 0:
                {   /* 函数说明:execvp()会从PATH 环境变量所指的目录中查找符合参数file 的文件名, 找到后便执行该文件, 
                     * 然后将第二个参数argv 传给该欲执行的文件。
                     * 返回值:如果执行成功则函数不会返回, 执行失败则直接返回-1, 失败原因存于errno 中. 
                     * */
                    execvp(argv[0], argv);
                    // 代码健壮性: 如果子进程未被成功执行, 则报错
                    printf("%s: 命令输入错误\n", argv[0]);
                    // exit函数终止当前进程, 括号内参数为1时, 会像操作系统报告该进程因异常而终止
                    exit(1);
                }
            default: {
                    int status;
                    waitpid(pid, &status, 0);      // 等待子进程返回
                    int err = WEXITSTATUS(status); // 读取子进程的返回码

                    if (err) { 
                        printf("Error: %s\n", strerror(err));
                    }                    
            }
        }
    }
}

演示:

do_cmdexit


3.1 Callcd函数

该函数判断cd指令输入的正确性并在正确的前提下执行cd指令. 理解该函数需要学习chdirgetcwd系统调用函数.

int callCd(int argc) {
    // result为1代表执行成功, 为0代表执行失败
    int result = 1;
    if (argc != 2) {
        printf("指令数目错误!");
    } else {
        int ret = chdir(command[1]);
        if (ret) return 0;
    }

    if (result) {
        char* res = getcwd(curPath, BUFFSIZE);
        if (res == NULL) {
            printf("文件路径不存在!");
        }
        return result;
    }
    return 0;
}

执行callCd函数后, 输入pwd即可看到改变后的地址.

演示:

callCd


3.2 printHistory函数

history指令的功能为打印前面输入的指令, 后面加打印的指令数目, 如history 3表示打印前面输入的3条指令, 包括history指令本身.

/* 全局变量 */
int commandNum;                                 // 已经输入指令数目
char history[MAX_CMD][BUFFSIZE];                // 存放历史命令

int main() {
    // 省略
    if (get_input(buf) == 0)
            continue;
    strcpy(history[commandNum++], buf);
    // 省略
}

int printHistory(char command[MAX_CMD][MAX_CMD_LEN]) {
    int n = atoi(command[1]);           // 将数目从字符串转为int

    for (i = n; i > 0 && commandNum - i >= 0; i--) {
        printf("%d\t%s\n", n - i + 1, history[commandNum - i]);
    }
    return 0;
}

演示:

printHistory


3.3 commandWithOutputRedi输入重定向函数

commandWithOutputRedi 判断并处理输出重定向指令. 他能够处理诸如ls -a -l > result.txt等指令, 即将ls -a -l指令的输入写到result.txt文件中, 如果不存在则创建该文件. 理解该函数需要学习open, dup2函数.

Linux为每个进程赋予键盘输入和控制台输出的文件描述符默认为01, 在宏定义中中分别为STDIN_FILENOSTDOUT_FILENO。输入重定向的思路为: 子进程装载程序前,调用dup2(fd, STDOUT_FILENO)将某个打开文件的文件描述fd映射到标准输出。

int commandWithOutputRedi(char buf[BUFFSIZE]) {
    strcpy(buf, backupBuf);
    char outFile[BUFFSIZE];
    memset(outFile, 0x00, BUFFSIZE);
    /* 1. 判断重定向指令输入的正确性 */
    int RediNum = 0;                        // 重定向符号数量
    for ( i = 0; i + 1 < strlen(buf); i++) {
        if (buf[i] == '>' && buf[i + 1] == ' ') {
            RediNum++;
            break;
        }
    }
    if (RediNum != 1) {
        printf("输出重定向指令输入有误!");
        return 0;
    }

    for (i = 0; i < argc; i++) {
        if (strcmp(command[i], ">") == 0) {
            if (i + 1 < argc) {
                strcpy(outFile, command[i + 1]);
            } else {
                printf("缺少输出文件!");
                return 0;
            }
        }
    }

    // 指令分割, outFile为输出文件, buf为重定向符号前的命令
    for (j = 0; j < strlen(buf); j++) {
        if (buf[j] == '>') {
            break;
        }
    }
    buf[j - 1] = '\0';
    buf[j] = '\0';
    // 解析指令
    parse(buf);
    pid_t pid;
    switch(pid = fork()) {
        case -1: {
            printf("创建子进程未成功");
            return 0;
        }
        case 0: {
            int fd;
            // fd设置为OutFile的文件描述符
            fd = open(outFile, O_WRONLY|O_CREAT|O_TRUNC, 7777);
            // 文件打开失败
            if (fd < 0) {
                exit(1);
            }
            /* 2. 关键指令 */
            dup2(fd, STDOUT_FILENO);        // 将fd重定向到标准输出
            execvp(argv[0], argv);
            if (fd != STDOUT_FILENO) {      // 关闭fd, 还原标准输出
                czcxblog.cn版权所有, 转载请说明lose(fd);
            }
            printf("%s: 命令输入错误\n", argv[0]);
            // exit函数终止当前进程, 括号内参数为1时, 会像操作系统报告该进程因异常而终止
            exit(1);
        }
        default: {
            int status;
            waitpid(pid, &status, 0);       // 等待子进程返回
            int err = WEXITSTATUS(status);  // 读取子进程的返回码
            if (err) { 
                printf("Error: %s\n", strerror(err));
            } 
        }                        
    }
}

演示

commandWithOutputRedi


3.4 commandWithInputRedi输入重定向函数

输入重定向函数与输出重定向函数高度一致, 唯一的不同点就是文件描述符fd映射到标准输出. <表示文件输入.

int commandWithInputRedi(char buf[BUFFSIZE]) {
    strcpy(buf, backupBuf);
    char inFile[BUFFSIZE];
    memset(inFile, 0x00, BUFFSIZE);
    int RediNum = 0;
    for ( i = 0; i + 1< strlen(buf); i++) {
        if (buf[i] == '<' && buf[i + 1] == ' ') {
            RediNum++;
            break;
        }
    }
    if (RediNum != 1) {
        printf("输入重定向指令输入有误!");
        return 0;
    }

    for (i = 0; i < argc; i++) {
        if (strcmp(command[i], "<") == 0) {
            if (i + 1 < argc) {
                strcpy(inFile, command[i + 1]);
            } else {
                printf("缺少输入指令!");
                return 0;
            }
        }
    }

    // 指令分割, InFile为输出文件, buf为重定向符号前的命令
    for (j = 0; j < strlen(buf); j++) {
        if (buf[j] == '<') {
            break;
        }
    }
    buf[j - 1] = '\0';
    buf[j] = '\0';
    parse(buf);
    pid_t pid;
    switch(pid = fork()) {
        case -1: {
            printf("创建子进程未成功");
            return 0;
        }
        case 0: {
            // 完成输入重定向
            int fd;
            fd = open(inFile, O_RDONLY, 7777);
            // 文件打开失败
            if (fd < 0) {
                exit(1);
            }
            /* 关键代码 */
            // 将fd映射到标准输入
            dup2(fd, STDIN_FILENO);  
            execvp(argv[0], argv);
            if (fd != STDIN_FILENO) {
                close(fd);
            }
            // 代码健壮性: 如果子进程未被成功执行, 则报错
            printf("%s: 命令输入错误\n", argv[0]);
            // exit函数终止当前进程, 括号内参数为1时, 会像操作系统报告该进程因异常而终止
            exit(1);
        }
        default: {
            int status;
            waitpid(pid, &status, 0);       // 等待子进程返回
            int err = WEXITSTATUS(status);  // 读取子进程的返回码
            if (err) { 
                printf("Error: %s\n", strerror(err));
            } 
        }                        
    }
}

3.5 commandWithReOutputRedi重定向追加输出函数

该函数与2.3 重定向输出函数类似, 不同点处在于: 重定向追加输出函数在输出文件存在且有内容的基础上往后追加写内容, 而2.3重定向输出函数会把文件原内容清空再写内容.

要实现这个效果, 我们只需要在open函数中添加O_APPEND这个选项就可以了, 当然判断命令正确性代码也要变化一下.

int commandWithReOutputRedi(char buf[BUFFSIZE]) {
    strcpy(buf, backupBuf);
    char reOutFile[BUFFSIZE];
    memset(reOutFile, 0x00, BUFFSIZE);
    int RediNum = 0;
    for ( i = 0; i + 2 < strlen(buf); i++) {
        if (buf[i] == '>' && buf[i + 1] == '>' && buf[i + 2] == ' ') {
            RediNum++;
            break;
        }
    }
    if (RediNum != 1) {
        printf("追加输出重定向指令输入有误!");
        return 0;
    }

    for (i = 0; i < argc; i++) {
        if (strcmp(command[i], ">>") == 0) {
            if (i + 1 < argc) {
                strcpy(reOutFile, command[i + 1]);
            } else {
                printf("缺少输出文件!");
                return 0;
            }
        }
    }

    // 指令分割, outFile为输出文件, buf为重定向符号前的命令
    for (j = 0; j + 2 < strlen(buf); j++) {
        if (buf[j] == '>' && buf[j + 1] == '>' 
            && buf[j + 2] == ' ') {
            break;
        }
    }
    buf[j - 1] = '\0';
    buf[j] = '\0';
    // 解析指令
    parse(buf);
    pid_t pid;
    switch(pid = fork()) {
        case -1: {
            printf("创建子进程未成功");
            return 0;
        }
        case 0: {
            // 完成输出重定向
            int fd;
            // open函数添加了O_APPEND这个选项.
            fd = open(reOutFile, O_WRONLY|O_APPEND|O_CREAT|O_APPEND, 7777); 
            // 文件打开失败
            if (fd < 0) {
                exit(1);
            }
            /* 关键代码 */
            dup2(fd, STDOUT_FILENO);  
            execvp(argv[0], argv);
            if (fd != STDOUT_FILENO) {
                close(fd);
            }
            // 代码健壮性: 如果子进程未被成功执行, 则报错
            printf("%s: 命令输入错误\n", argv[0]);
            // exit函数终止当前进程, 括号内参数为1时, 会像操作系统报告该进程因异常而终止
            exit(1);
        }
        default: {
            int status;
            waitpid(pid, &status, 0);       // 等待子进程返回
            int err = WEXITSTATUS(status);  // 读取子进程的返回码
            if (err) { 
                printf("Error: %s\n", strerror(err));
            } 
        }                        
    }   
}

演示

commandWithReOutputRedi


3.6 commandWithPipe执行管道指令函数

管道, 用|表示, 作用为将前面一个进程(指令)的输出(stdout)直接作为下一个进程的输入(stdin)。实现管道需要用到pipe函数创建一个管道, 函数原型为int pipe(int filedes[2]) , 返回值为0代表创建成功, 为-1代表创建失败. pipe()会建立管道,并将文件描述词由参数filedes数组返回。 filedes[0]为管道里的读取端, filedes[1]则为管道的写入端。

该函数思路为: 在命令输入正确的基础上, 以|为分zcxblog.cn版权所有, 转载请说明隔符分隔命令. |前面的命令作为输出命令, |后面的命令作为输入命令, 使用pipe()函数创建管道, 使用fork创建子进程, 关闭子进程管道的读端, 子进程写管道, 使管道写端写入的内容重定向到标准输出; 关闭父进程的写端, 父进程读管道, 使管道读端读到的内容重定向到标准输入.

需要注意的是: pipe命令实现一个半双工管道, 意思就是说读管道和写管道这两个行为同一时间内只能进行一个. 在这里, 我们需要保证的是, 子进程写完并返回后再进行父进程的读操作, 所以需要在父进程开头进行一个等待子进程返回的操作( 关键代码处 ).

int commandWithPipe(char buf[BUFFSIZE]) {
    // 获取管道符号的位置索引
    for(j = 0; buf[j] != '\0'; j++) {
        if (buf[j] == '|')
            break;
    }

    // 分离指令, 将管道符号前后的指令存放在两个数组中
    // outputBuf存放管道前的命令, inputBuf存放管道后的命令
    char outputBuf[j];
    memset(outputBuf, 0x00, j);
    char inputBuf[strlen(buf) - j];
    memset(inputBuf, 0x00, strlen(buf) - j);
    for (i = 0; i < j - 1; i++) {
        outputBuf[i] = buf[i];
    }
    for (i = 0; i < strlen(buf) - j - 1; i++) {
        inputBuf[i] = buf[j + 2 + i];
    }

    int pd[2];
    pid_t pid;
    if (pipe(pd) < 0) {
        perror("pipe()");
        exit(1);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {                     // 子进程写管道
        close(pd[0]);                   // 关闭子进程的读端
        dup2(pd[1], STDOUT_FILENO);     // 将子进程的写端作为标准输出
        parse(outputBuf);
        execvp(argv[0], argv);
        if (pd[1] != STDOUT_FILENO) {
            clozcxblog.cn版权所有, 转载请说明se(pd[1]);
        }
    }else {                              // 父进程读管道
        /** 关键代码
         *  子进程写管道完毕后再执行父进程读管道, 
         *  所以需要用wait函数等待子进程返回后再操作
         */
        int status;
        waitpid(pid, &status, 0);       // 等待子进程返回
        int err = WEXITSTATUS(status);  // 读取子进程的返回码
        if (err) { 
            printf("Error: %s\n", strerror(err));
        }

        close(pd[1]);                    // 关闭父进程管道的写端
        dup2(pd[0], STDIN_FILENO);       // 管道读端读到的重定向为标准输入
        parse(inputBuf);
        execvp(argv[0], argv);
        if (pd[0] != STDIN_FILENO) {
            close(pd[0]);
        }       
    }
    return 1;
}

 演示:

commandWithPipe


3.7 commandInBackground 后台运行指令

为了屏蔽键盘和控制台,子进程的标准输入、输出映射成 /dev/null。子进程调用signal(SIGCHLD,SIG_IGN),使得Linux接管此进程。 因此Shell可以避免调用wait/waitpid直接运行下一条命令。

int commandInBackground(char buf[BUFFSIZE]) {
    char backgroundBuf[strlen(buf)];
    memset(backgroundBuf, 0x00, strlen(buf));
    // 将&前面的命令提取出来
    for (i = 0; i < strlen(buf); i++) {
        backgroundBuf[i] = buf[i];
        if (buf[i] == '&') {
            backgroundBuf[i] = '\0';
            backgroundBuf[i - 1] = '\0';
            break;
        }
    }

    pid_t pid;
    pid = fork();
    if (pid < 0) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
        // 将stdin、stdout、stderr重定向到/dev/null
        freopen( "/dev/null", "w", stdout );
        freopen( "/dev/null", "r", stdin ); 
        signal(SIGCHLD,SIG_IGN);
        // 子进程调用signal(SIGCHLD,SIG_IGN),使得Linux接管此进程。
        parse(backgroundBuf);
        execvp(argv[0], argv);
        printf("%s: 命令输入错误\n", argv[0]);
        // exit函数终止当前进程, 括号内参数为1时, 会像操作系统报告该进程因异常而终止
        exit(1);
    }else {
        // 父进程不等待子进程结束就返回
        exit(0);
    }
}

splitLine

4. 完整代码

完整代码见

https://github.com/ZhangChunXian/myLinux/tree/main/00shellLab

splitLine

5. 总结

学习Linux操作系统的一大方法为手动实现各种命令, 从最接近操作系统的一层编程. 用一些基本函数甚至原子操作, 从操作系统层面分析, 来实现复杂的命令, 真正实现深入理解计算机系统. 我想, 这就是为什么同名书籍, 即CSAPP, 有一个lab就是实现一个shell.当初在寒假布置的这个lab被我水过去了16流泪哭泣, 现在就让我好好的补补课吧!

光说不写代码可不行, 仅仅实现上面的指令是不够的, 未来我还将继续更新shell.

talk is cheap show me the code. 屁话少说,放码过来51欢呼加油高兴开心成功

showMeTheCode

相关文章
暂无相关文章!
0 条回应