写一个Shell

455 阅读6分钟

github:github.com/SuyuZhuang/…

版本功能

下一次迭代准备把并发问题解决一下

2022.11.16 更新

  • 用空格来区分参数,不对引号和\做处理
  • 不支持pipes |
  • 不支持 I/O 重定向 < and >
  • 不支持job control
  • 内置方法:
    • exit、quit 或者 Ctrl-C 可以退出
    • help 会打印帮助
  • 命令结尾加上 & ,可以后台运行

当然,如果你对真实的shell的实现标准感兴趣,可以参考 POSIX Specification 第11章的内容。

image.png

image.png

心路历程

起源是学习CSAPP第8章Exceptional Control Flow 的时候,看到书中的例子以及课后实验都是写shell lab,所以想着以这个为目标来学习本章节的内容。

我在c语言方面比较小白,只是知道大致的语法,以及遇到疑问会查一下文档资料,平时只用来写过一两百行的小项目,对于指针、地址、malloc、free方面也还在逐渐熟悉的过程中。

先是照着书中Figure 8.23、8.24和8.25,抄写了一份无Signal版的shell,能够执行成功,但是需要include csapp.h。

这个版本的执行需要的语句:

gcc -Og -o prog myshell.c csapp.c -lpthread

然后偶然看到有大牛推荐Stephen Brennan的手写shell的教程,教程写得很详细,也是无signal版本的,所以结合了一下csapp中的内容,改了一版。

到目前为止,用到的知识就是读取一行数据,fork() 进程,以及搭配使用execve 或者execvp来在当前进程中加载和运行新的程序,使用waitpid来回收子进程。

再然后,虽然看完了书中signal章节,也跟完了老师的视频课程,但是下笔写信号这块的内容还是觉得有点模糊不清似懂非懂,所以去把csapp的shell lab内容看了一下,简单跟着实现了对Ctrl-C以及子进程SIGCHILD信号的处理。

核心步骤

  • 初始化
    • 读取和执行配置文件
  • 解析和执行
    • 循环从文件或者stdin中读取命令并执行
    • 打印 > 提示符
    • 从stdin读取命令
      • 采用循环读取,动态分配的方式(reallocate)
    • parse
    • execute
      • fork() 调用1次,返回2次。会复制一个子进程,上下文也完全复制。子进程返回0,父进程返回子进程的PID
      • execve(args[0], args + 1, environ) 调用1次,无返回(error: -1)。会在当前进程中加载和运行一个新的程序
      • execvp(args[0], args + 1) 是系统调用 execve() 的变种,不用传环境参数,并且不用写命令全称(如 在execve中查看当前目录文件要使用/bin/ls -al ,而在execvp 中只需要使用ls -al)
      • waitpid(pid, &status, WUNTRACED) 等待
  • terminate
    • 命令执行结束后,释放空间

csapp 无Signal版

课本中直接抄下来的实现方式,有一些缺陷,比如创建的background进程无法reap(调用waitpid),会有很多僵尸进程产生,僵尸进程仍然会占有系统内存资源。

执行方式:

gcc -Og -o prog shellex.c csapp.c -lpthread
#include "csapp.h"
#define MAXARGS 128

void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);

int main() {
    printf("start\n");
    char cmdline[MAXLINE];

    while(1) {
        /* read */
        printf("> ");
        Fgets(cmdline, MAXLINE, stdin);
        if (feof(stdin)) {
            exit(0);
        }
    
        /* Evaluate */
        eval(cmdline);
    }
    return 0;
}


/* eval - Evaluate a command line */
void eval(char *cmdline) {
    char *argv[MAXARGS];
    char buf[MAXLINE];
    int bg;
    pid_t pid;

    strcpy(buf, cmdline);
    bg =  parseline(buf, argv);
    if (argv[0] == NULL) {
        return;
    }
    
    if (!builtin_command(argv)) {
        if ((pid = Fork()) == 0) {
            if (execve(argv[0], argv, environ) <0 ){
                printf("%s : Command not found.\n", argv[0]);
                exit(0);
            }
        }

        /* Parent waits for foreground job to terminate */
        if (!bg) {
            int status;
            if (waitpid(pid, &status, 0) < 0) {
                unix_error("waitfg: waitpid error");
            } else {
                printf("%d %s", pid, cmdline);
            }
        }
    }
    return;
}

/* If first arg is a builtin command, run it and return true */
int builtin_command(char **argv) {
    if (!strcmp(argv[0], "quit")) {  /* 只识别quit */
        exit(0);
    }
    if (!strcmp(argv[0], "&")) {   /* 忽略& */
        return 1;
    }
    return 0;
}


/* parseline - Parse the command line and build the argv array*/
int parseline(char *buf, char **argv) {
    char *delim;
    int argc;
    int bg;

    buf[strlen(buf)-1] = ' ';
    while (*buf && (*buf == ' ')) {
        buf++;
    }

    argc = 0;
    while ((delim = strchr(buf, ' '))) {
        argv[argc++] = buf;
        *delim = '\0';
        buf = delim + 1;
        while (*buf && (*buf == ' ')) {
            buf++;
        }
    }
    argv[argc] = NULL;

    if (argc == 0) {
        return 1;
    }

    if ((bg = (*argv[argc-1] == '&')) != 0) {
        argv[--argc] = NULL;
    }

    return bg;
}

主程序

主程序大致的架构,这里是学习的Brennan的代码

#include <stdio.h>
#include <stdlib.h>

void init();
void interpret();
void terminate();
char *read_line();
char **parse_args(char *line);
void execute(char **args);

int main(int argc, char *argv[]) {
    // 1.初始化
    init();

    // 2.解释和执行
    interpret();

    // 3.收尾
    terminate();
    return 0;
}

读取一行数据

这里结合几个教程,写了几种读取的方法,最后是采用了getline的方式

fgets()

csapp中采用的是这个方法,限制长度地读取一行数据 feof用来检查是否读到了eof 局限在于其实我们并不知道命令行会写多长,超出了以后就处理不了了 最后读到\n时仍然存入的是\n

c.biancheng.net/view/235.ht…

char* read_line_limit() {
    char str[128];
    char *rptr;
    // gets 会导致越界,所以用fgets
    rptr = fgets(str, 128, stdin);
    printf("in str=%s rptr=%s\n", str, rptr);
    if (rptr == NULL) {
        fprintf(stderr, "sush: read error\n");
        exit(EXIT_FAILURE);
    }
    if (feof(stdin)) {
        exit(EXIT_SUCCESS);
    }
    return rptr;
}

getchar()

使用reallocate动态分配,不限制长度地读取一行数据,这里是1个字符1个字符读取然后处理的。 最后读到\n时会会在这里就转换成\0

这个方法用到了realloc ,是动态分配内存常用的策略

image.png

/**
 * 循环读取,动态分配,reallocate
 *
 * @return 读取的行
 */
#define BUF_SIZE 1024
char *read_line() {
    int bufsize = BUF_SIZE;
    int position = 0;
    char *buf = malloc(sizeof(char) * bufsize);
    int c;

    if (!buf) {
        fprintf(stderr, "sush: allocation error\n");
        exit(EXIT_FAILURE);
    }

    while (1) {
        c = getchar();
        if (c == EOF || c == '\n') {
            buf[position] = '\0';
            return buf;
        } else {
            buf[position++] = c;
        }

        // 检测buffer是否需要扩容
        if (position >= bufsize) {
            bufsize += BUF_SIZE;
            buf = realloc(buf, bufsize);
            if (!buf) {
                fprintf(stderr, "sush: allocation error\n");
                exit(EXIT_FAILURE);
            }
        }
    }
}

getline()

使用reallocate动态分配,这里是直接按行读取 feof用来检查是否读到了eof 最后读到\n时仍然存入的是\n

char *read_line_getline() {
    char *line = NULL;
    ssize_t bufsize = 0;

    if (getline(&line, &bufsize, stdin) == -1) {
        if (feof(stdin)) {
            // 到达EOF, 文件末尾,或者Ctrl-D
            exit(EXIT_SUCCESS);
        } else {
            perror("readline");
            exit(EXIT_FAILURE);
        }
    }
    return line;
}

解析

使用空格作为分隔符

纯手工

csapp中是采用这个方法去tokenizer

char **parse_args_basic(char *line) {
    printf("parse_args_basic line=%s\n", line);
    char **tokens;
    size_t lineLength = strlen(line);
    char *buf = malloc(lineLength);

    strcpy(buf, line);

    buf[strlen(buf) -1] = ' ';
    while (*buf && (*buf == ' ')) {
        buf++;
    }

    int argc = 0;
    char *delim;
    char *argv[128];
    while ((delim = strchr(buf, ' '))) {
        argv[argc++] = buf;
        *delim = '\0';
        buf = delim + 1;
        while (*buf && (*buf == ' ')) {
            buf++;
        }
    }
    argv[argc] = NULL;

    if (argc == 0) {
        return 1;
    }
    return argv;
}

strtok

#include <string.h>
token = strtok(line, TOK_DELIM);

strtok 方法用来对line进行分割,分割会至少使用TOK_DELIM中的1个字符 strok第一次调用的时候,需要指定line这个参数,之后的调用中如果想要同样获取line中的其他token,需要传NULL 注意,该方法的调用会对原来传入的line字符串造成伤害

#define TOK_BUFSIZE 64
#define TOK_DELIM " \r\t\n\a"
char **parse_args_strtok(char *line) {
    printf("parse_args_strtok line=%s\n", line);
    int bufsize = TOK_BUFSIZE;
    int position = 0;
    char **tokens = malloc(sizeof(char *) *bufsize);
    char *token;

    if (!tokens) {
        fprintf(stderr, "sush: allocation error\n");
        exit(EXIT_FAILURE);
    }

    token = strtok(line, TOK_DELIM);
    while (token != NULL) {
        tokens[position++] = token;

        if (position >= bufsize) {
            bufsize += TOK_BUFSIZE;
            tokens = realloc(tokens, sizeof(char*) *bufsize);
            if (!tokens) {
                fprintf(stderr, "sush: allocation error\n");
                exit(EXIT_FAILURE);
            }
        }
        token = strtok(NULL, TOK_DELIM);
    }
    tokens[position] = NULL;
    return tokens;
}

内置方法处理

目标是exit和quit直接退出 help会打印帮助内容 而cd在这里比较特殊,参考Stephen Brennan大神的教程,直接在当前进程使用cd方法,确实可以在fork()的子进程中change directory,然后直接terminate。但是我们想要的是整个shell的目录结构都变化,所以需要让父进程也就是咱们这个shell的进程执行chdir()

/*
  List of builtin commands, followed by their corresponding functions.
 */
char *builtin_str[] = {
        "exit",
        "help",
        "cd"
};

int num_builtins() {
    return sizeof(builtin_str) / sizeof(char *);
}

int isBuiltinCommand(char **args) {
    // quit 退出
    if (strcmp(args[0], "quit") == 0) {
        exit(0);
    }
    // help 帮助
    if (strcmp(args[0], "help") == 0) {
        int i;
        printf("SUSH help:\n");
        printf("Type program names and arguments, and hit enter.\n");
        printf("The following are built in:\n");

        for (i = 0; i < num_builtins(); i++) {
            printf("  %s\n", builtin_str[i]);
        }

        printf("Use the man command for information on other programs.\n");
        return 1;
    }
    // cd change directory
    if (strcmp(args[0], "cd") == 0) {
        if (args[1] == NULL) {
            fprintf(stderr, "sush: expected argument to "cd"\n");
        } else {
            if (chdir(args[1]) != 0) {
                perror("sush");
            }
        }
        return 1;
    }
    return 0;
}

非内置方法的执行

关于execve和execvp的区别可见下图。csapp的实现中使用的是最底层的system call execve,这个方法不仅需要手动传入环境参数,而且执行命令的时候需要写全路径,不能写别名。比如如果我们想直接ls -al ,会报错说Command not found, 需要使用/bin/ls -al 才可以。这个问题execvp帮忙包装了。

void execute(char **args) {
    pid_t pid;
    pid_t wpid;
    int isBackground;

    if (args[0] == NULL) {
        return;
    }

    isBackground = checkIsBg(args);

    // 不是内置的命令,则调用system call
    if (!isBuiltinCommand(args)) {
        pid = fork();
        if (pid == 0) {
            // 子进程
            if (execvp(args[0], args + 1) < 0) {
                printf("sush: %s Command not found.\n", args[0]);
                exit(EXIT_FAILURE);
            }
        } else if (pid < 0) {
            printf("sush: %s System error.\n", args[0]);
        } else {
            // 父进程
            if (!isBackground) {
                // 创建的不是bg运行的子进程,需要父进程等待
                int status;
                waitpid(pid, &status, 0);

                if (status < 0) {
                    printf("sush: %s System error.\n", args[0]);
                } else {
                    printf("sush: pid=%d cmd=%s \n", pid,  args[0]);
                }
            }
        }


    }
    return;
}

int checkIsBg(char **args) {
    int i = 0;
    while (args[i] != NULL) {
        i++;
    }
    return *args[i - 1] == '&';
}

添加 Signal 功能

可以参考shell lab

csapp.cs.cmu.edu/3e/shlab.pd…

目标:

  • SIGINT
  • SIGCHILD

使用书中的方法封装了注册方法

/**
 * 包装signal方法,用来给某种信号注册对应的handler
 * 这个方法csapp书中有
 *
 * @param signum 信号对应的数字
 * @param handler 信号处理的handler
 */
handler_t* Signal(int signum, handler_t *handler)
{
    struct sigaction action, old_action;

    action.sa_handler = handler;
    sigemptyset(&action.sa_mask); /* block sigs of type being handled */
    action.sa_flags = SA_RESTART; /* restart syscalls if possible */

    if (sigaction(signum, &action, &old_action) < 0)
        printf("Signal error");
    return (old_action.sa_handler);
}


在init() 中对SIGINT 和 SIGCHILD 两种信号分别注册

/*****************
 * 初始化和 Signal handlers
 *****************/

void init() {
    printf("Welcome, this is sush, pid=%d\n", getpid());
    // 将stderr重定向到stdout
    dup2(1, 2);
    // 注册信号Handlers
    Signal(SIGINT, sigint_handler); /* ctrl-c */
    Signal(SIGCHLD, sigchld_handler);  /* Terminated or stopped child */
}

具体的handler方法


/**
 * 接收到kernel 传来的SIGINT信号
 * 直接退出shell
 *
 * @param sig SIGINT信号
 */
void sigint_handler(int sig)
{
    printf("  caught! sigint_handler  sig=%d\n", sig);
    exit(0);
}

/**
 * 接收到kernel 传来的SIGCHLD信号
 * 处理所有变成僵尸进程的子进程
 *
 * @param sig SIGCHLD信号
 */
void sigchld_handler(int sig)
{
    printf("caught! sigchld_handler prepare to reap children who became zombie   sig=%d\n", sig);
    // 因为在waitpid过程中也可能覆盖errno,所有先保存下来,退出handler时再恢复
    int olderrno = errno;
    while (waitpid(-1, NULL, 0) > 0) {
        printf("Handler reaped child\n");
    }

    if (errno != ECHILD) {
        printf("waitpid error");
    }
    // 简单处理
    Sleep(1);
    errno = olderrno;
}