github:github.com/SuyuZhuang/…
版本功能
下一次迭代准备把并发问题解决一下
2022.11.16 更新
- 用空格来区分参数,不对引号和\做处理
- 不支持pipes |
- 不支持 I/O 重定向 < and >
- 不支持job control
- 内置方法:
- exit、quit 或者 Ctrl-C 可以退出
- help 会打印帮助
- 命令结尾加上 & ,可以后台运行
当然,如果你对真实的shell的实现标准感兴趣,可以参考 POSIX Specification 第11章的内容。
心路历程
起源是学习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
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 ,是动态分配内存常用的策略
/**
* 循环读取,动态分配,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
目标:
- 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;
}