武汉大学21级网安操作系统大作业 任务三
1. 问题介绍与思路摘要
1.1 问题介绍
本任务是OS期末实验的第三个基本任务,主要目标是改造任务二的shell,使其能够在同一个shell中支持多任务执行。几点需要注意的地方如下:
? 注意现有内存管理可能不支持多程序支持
? 可执行程序的装入和内存定位问题需要仔细考虑
1.2 思路摘要
在本次实验中,我们利用"&"符号分割输入的多条命令,拓展 kernel/main.c中的shabby_shell函数使其能够同时执行多条命令。核心思路如下:
? 创建二维字符串数组
char* multi_argv[MAX_SHELL_PROC][MAX_SHELL_PROC_STACK]
? 在argv中保存输入的所有字符串后,对argv进行扫描,把用&分割得到的命令分别保存在multi_argv中。
? 父进程借助for循环fork出所有的子进程,子进程被fork出来后主动将自己阻塞,等待父进程解除阻塞后,调用execv执行。
同时我们需要注意,父进程利用for循环进行fork时,得到的子进程同样在该循环中,以及如果子进程抢占了父进程,则父进程可能无法完成fork出所有子进程的过程,导致无法同时运行多条指令。上述问题我们实验具体思路及实现中详细介绍并解决。
2. 具体思路及其实现
在本次实验中,我们需要对shell进行改造使之可以同时解析和执行多条命令。这一部分的实现原理较为简单,书本给出的原始代码已经在第十章实现了fork、exit、wait、exec等系统调用函数,并实现了一个简单的shell,也已经具备了将 command文件夹中的指令文件编译链接并装载入操作系统的能力。所以我们要扩展shell,只需要修改shell的函数即可。
任务三基于任务二的代码,shell的代码在kernel/main.c中,由Init()进程fork出来。要完成这部分的改造,我们首先需要了解原始shabby_shell函数、wait函数和execv函数。
2.1 shabby_shell函数
shell目前的功能很简单,只实现了读取命令并执行之(如果命令存在的话)。代码如下:
void shabby_shell(const char * tty_name) { int fd_stdin = open(tty_name, O_RDWR); assert(fd_stdin == 0); int fd_stdout = open(tty_name, O_RDWR); assert(fd_stdout == 1); char rdbuf[128]; while (1) { write(1, "$ ", 2); int r = read(0, rdbuf, 70); rdbuf[r] = 0; int argc = 0; char * argv[PROC_ORIGIN_STACK]; char * p = rdbuf; char * s; int word = 0; char ch; do { ch = *p; if (*p != ' ' && *p != 0 && !word) { s = p; word = 1; } if ((*p == ' ' || *p == 0) && word) { word = 0; argv[argc++] = s; *p = 0; } p++; } while(ch); argv[argc] = 0; int fd = open(argv[0], O_RDWR); if (fd == -1) { if (rdbuf[0]) { write(1, "{", 1); write(1, rdbuf, r); write(1, "} ", 2); } } else { close(fd); int pid = fork(); if (pid != 0) { /* parent */ int s; wait(&s); } else { /* child */ execv(argv[0], argv); } } } close(1); close(0); }
可以分析出,shabby_shell用write()打印一个简单的提示符 $ 到标准输出,之后用read()读取用户输入,然后用open尝试打开与输入命令同名的文件。如果文件存在,即fd!=-1,就认为这是一个可执行文件,然后关闭文件描述符fd,通过fork()创建一个新的进程,在父进程中,使用wait(&s)等待子进程完成;在子进程中,使用execv()执行用户输入的命令。如果文件不存在,就将输入的命令直接输出给用户。跟任务二中的流程图是一样的,这里再放一张:
2.2 wait函数 & exit函数
在shabby_shell中有一个wait函数,而wait与exit是一对函数。exit()执行后杀死进程,wait()执行后挂起程序,与fork()相同,这两个函数工作时将会返回EXIT和WAIT消息给MM。在MM中,收到的消息分别由do_exit()和do_wait()来处理。
下面是wait.c中的wait()、exit.c中的exit()、与forkexit.c中的do_wait()和do_exit()的代码,之后我会将其结合起来理解。
PUBLIC int wait(int * status) { MESSAGE msg; msg.type = WAIT; send_recv(BOTH, TASK_MM, &msg); *status = msg.STATUS; return (msg.PID == NO_TASK ? -1 : msg.PID); }
PUBLIC void exit(int status) { MESSAGE msg; msg.type = EXIT; msg.STATUS = status; send_recv(BOTH, TASK_MM, &msg); assert(msg.type == SYSCALL_RET); }
PUBLIC void do_wait() { int pid = mm_msg.source; int i; int children = 0; struct proc* p_proc = proc_table; for (i = 0; i < NR_TASKS + NR_PROCS; i++,p_proc++) { if (p_proc->p_parent == pid) { children++; if (p_proc->p_flags & HANGING) { cleanup(p_proc); return; } } } if (children) { /* has children, but no child is HANGING */ proc_table[pid].p_flags |= WAITING; } else { /* no child at all */ MESSAGE msg; msg.type = SYSCALL_RET; msg.PID = NO_TASK; send_recv(SEND, pid, &msg); } }
PUBLIC void do_exit(int status) { int i; int pid = mm_msg.source; /* PID of caller */ int parent_pid = proc_table[pid].p_parent; struct proc * p = &proc_table[pid]; /* tell FS, see fs_exit() */ MESSAGE msg2fs; msg2fs.type = EXIT; msg2fs.PID = pid; send_recv(BOTH, TASK_FS, &msg2fs); free_mem(pid); p->exit_status = status; if (proc_table[parent_pid].p_flags & WAITING) { /* parent is waiting */ proc_table[parent_pid].p_flags &= ~WAITING; cleanup(&proc_table[pid]); } else { /* parent is not waiting */ proc_table[pid].p_flags |= HANGING; } /* if the proc has any child, make INIT the new parent */ for (i = 0; i < NR_TASKS + NR_PROCS; i++) { if (proc_table[i].p_parent == pid) { /* is a child */ proc_table[i].p_parent = INIT; if ((proc_table[INIT].p_flags & WAITING) && (proc_table[i].p_flags & HANGING)) { proc_table[INIT].p_flags &= ~WAITING; cleanup(&proc_table[i]); } } } }
? exit()
这是用户空间的函数,由进程调用以结束自己。它发送一个包含退出状态的消息给内存管理任务(TASK_MM),告知系统进程希望退出。
? do_exit()
当内存管理任务接收到退出请求时,do_exit 函数被调用。它的主要工作包括:
? 通知文件系统(为了可能的资源释放,如关闭打开的文件)
? 释放该进程占用的内存
? 设置进程的退出状态
? 如果父进程正在等待(即处于 WAITING 状态),则清理退出进程的资源并发送信号给父进程;如果父进程不在等待,则设置该进程为 HANGING(即成为僵尸进程)。
? 遍历进程表,将所有该进程的子进程的父进程设置为 INIT 进程,这样如果子进程退出,它们将被 INIT 进程清理。
? wait()
这个函数也在用户空间调用,通常由父进程调用以等待其子进程的退出。它向内存管理任务发送等待消息,并阻塞直到有子进程退出。
? do_wait()
当内存管理任务收到等待请求时,do_wait 函数被调用。它检查是否有任何子进程已经退出(即处于 HANGING 状态):
? 如果有,则清理这些子进程,并将它们的退出状态发送给父进程。
? 如果没有,且该进程确实有子进程,则设置该进程为 WAITING 状态,等待其子进程退出。
? 如果没有子进程,返回错误。
具体的,举个例子。
假设进程P有子进程A。而A调用exit(),那么内存管理模块 (MM) 将会:
1.告诉文件系统 (FS):A退出,请做相应处理。
2.释放A占用的内存。
3.判断P是否正在等待(WAITING):
(1)如果是:
? 清除P的WAITING位;
? 向P发送消息以解除阻塞(到此P的wait()函数结束);
? 释放A的进程表项(到此A的exit()函数结束)。
(2)如果否:
? 设置A的HANGING位。
4.遍历proc_table[],寻找A的子进程(如果有的话):
(1)将Init进程设置为A的子进程的父进程(换言之,将A的子进程过继给Init)。
(2)判断是否满足Init正在WAITING且A的子进程正在HANGING:
? 如果是:
i.清除Init的WAITING位;
ii.向Init发送消息以解除阻塞(到此Init的wait()函数结束);
iii.释放A的子进程的进程表项(到此A的子进程的exit()函数结束)。
? 如果否:
i.如果Init正在WAITING但A的子进程没有HANGING,那么“握手”会在将来A的子进程调用exit()时发生;
ii.如果A的子进程正在HANGING但Init没有WAITING,那么“握手”会在将来Init调用wait()时发生。
如果P调用wait(),那么MM将会:
1.遍历proc_table[],寻找P的子进程A:
(1)如果A正在HANGING,那么:
? 向P发送消息以解除阻塞(到此P的wait()函数结束);
? 释放A的进程表项(到此A的exit()函数结束)。
(2)如果A不在HANGING,那么:
? 设P的WAITING位。
2.如果P没有子进程:
向P发送消息,消息携带一个表示出错的返回值(到此P的wait()函数结束)。
2.3 execv函数
在shabby_shell中还有一个execv函数,而execv()所做的其实只是一件事,即向MM提供最终供调用exec的进程使用的堆栈。
下面是exec.c中的execv()和mm/do_exec()的代码。
PUBLIC int execv(const char *path, char * argv[]) { char **p = argv; char arg_stack[PROC_ORIGIN_STACK]; int stack_len = 0; while(*p++) { assert(stack_len + 2 * sizeof(char*) < PROC_ORIGIN_STACK); stack_len += sizeof(char*); } *((int*)(&arg_stack[stack_len])) = 0; stack_len += sizeof(char*); char ** q = (char**)arg_stack; for (p = argv; *p != 0; p++) { *q++ = &arg_stack[stack_len]; assert(stack_len + strlen(*p) + 1 < PROC_ORIGIN_STACK); strcpy(&arg_stack[stack_len], *p); stack_len += strlen(*p); arg_stack[stack_len] = 0; stack_len++; } MESSAGE msg; msg.type = EXEC; msg.PATHNAME = (void*)path; msg.NAME_LEN = strlen(path); msg.BUF = (void*)arg_stack; msg.BUF_LEN = stack_len; send_recv(BOTH, TASK_MM, &msg); assert(msg.type == SYSCALL_RET); return msg.RETVAL; }
PUBLIC int do_exec() { /* get parameters from the message */ int name_len = mm_msg.NAME_LEN; /* length of filename */ int src = mm_msg.source; /* caller proc nr. */ assert(name_len < MAX_PATH); char pathname[MAX_PATH]; phys_copy((void*)va2la(TASK_MM, pathname), (void*)va2la(src, mm_msg.PATHNAME), name_len); pathname[name_len] = 0; /* terminate the string */ /* get the file size */ struct stat s; int ret = stat(pathname, &s); if (ret != 0) { printl("{MM} MM::do_exec()::stat() returns error. %s", pathname); return -1; } /* read the file */ int fd = open(pathname, O_RDWR); if (fd == -1) return -1; assert(s.st_size < MMBUF_SIZE); read(fd, mmbuf, s.st_size); close(fd); /* overwrite the current proc image with the new one */ Elf32_Ehdr* elf_hdr = (Elf32_Ehdr*)(mmbuf); int i; for (i = 0; i < elf_hdr->e_phnum; i++) { Elf32_Phdr* prog_hdr = (Elf32_Phdr*)(mmbuf + elf_hdr->e_phoff + (i * elf_hdr->e_phentsize)); if (prog_hdr->p_type == PT_LOAD) { assert(prog_hdr->p_vaddr + prog_hdr->p_memsz < PROC_IMAGE_SIZE_DEFAULT); phys_copy((void*)va2la(src, (void*)prog_hdr->p_vaddr), (void*)va2la(TASK_MM, mmbuf + prog_hdr->p_offset), prog_hdr->p_filesz); } } /* setup the arg stack */ int orig_stack_len = mm_msg.BUF_LEN; char stackcopy[PROC_ORIGIN_STACK]; phys_copy((void*)va2la(TASK_MM, stackcopy), (void*)va2la(src, mm_msg.BUF), orig_stack_len); u8 * orig_stack = (u8*)(PROC_IMAGE_SIZE_DEFAULT - PROC_ORIGIN_STACK); int delta = (int)orig_stack - (int)mm_msg.BUF; int argc = 0; if (orig_stack_len) { /* has args */ char **q = (char**)stackcopy; for (; *q != 0; q++,argc++) *q += delta; } phys_copy((void*)va2la(src, orig_stack), (void*)va2la(TASK_MM, stackcopy), orig_stack_len); proc_table[src].regs.ecx = argc; /* argc */ proc_table[src].regs.eax = (u32)orig_stack; /* argv */ /* setup eip & esp */ proc_table[src].regs.eip = elf_hdr->e_entry; /* @see _start.asm */ proc_table[src].regs.esp = PROC_IMAGE_SIZE_DEFAULT - PROC_ORIGIN_STACK; strcpy(proc_table[src].name, pathname); return 0; }
? execv()
execv接收两个参数:要执行的文件路径和一个字符串数组,其中包含传递给新程序的参数。在您的代码中,execv 通过以下步骤实现:
1.复制参数到一个栈 (arg_stack):
(1)argv中的每个字符串都被复制到一个临时的栈arg_stack中。
(2)每个字符串的指针(在arg_stack中的位置)也被存储在arg_stack中。
2.构造MESSAGE结构:
(1)这个结构用于与内存管理模块(MM)进行通信,告知它要执行新的程序。
(2)消息包含了程序路径、路径长度、参数栈的地址和参数栈的长度。
3.发送消息给MM:
(1)通过send_recv调用,将消息发送给MM任务。
(2)assert确保调用成功。
? do_exec()
它处理从execv发来的执行请求。步骤如下:
1.获取并验证程序路径:
从消息中复制程序路径,并检查路径的合法性。
2.获取程序文件的大小并读取内容:
(1)使用 stat 系统调用获取文件大小。
(2)读取文件内容到mmbuf,这是一个临时缓冲区。
3.解析 ELF 格式的可执行文件:
(1)ELF (Executable and Linkable Format) 是一种常见的二进制文件格式。
(2)解析ELF头部,加载程序段到目标进程的地址空间。
4.设置参数栈:
将参数从execv发送的消息中复制到目标进程的栈空间。
5.设置新的指令指针 (eip) 和栈指针 (esp):
这些寄存器被设置为新程序的入口点和新栈的顶部。
6.更改进程名:
进程表中的名称被更新为新程序的路径。
总之,execv函数负责准备执行新程序所需的所有信息,并将其发送给内存管理模块。MM任务接收这些信息,加载新程序到内存,并更新进程表以反映这些更改。
需要注意的是,execv将arg_stack[]的首地址以及其中有效内容的长度等通过消息发送给了MM。
2.4 修改shell支持多任务执行
了解了上述基础知识后,我们就可以开始修改shell以实现支持多任务运行,我们采取的方法是令父进程fork出多个子进程,然后子进程去执行那些命令。
我们首先定义两个变量MAX_SHELL_PROC和MAX_SHELL_PROC_STACK,分别表示同时可执行的最多指令条数和一次输入的最大长度。
#define MAX_SHELL_PROC 5 //最多指令条数 echo pwd ls cat cp touch rm #define MAX_SHELL_PROC_STACK 128 //一次输入的最大长度
shabby_shell函数代码的前半部分不做修改,和源码是一样的,我们用0标志一条命令的结束。我们用&指令分割不同命令后,argv数组的内容可能是这样的:
argv = {test, &, ls, &, echo, dp'os, dp.txt, &, ls, &, cat, dp.txt}
利用 multi_argv 保存二维字符串数组,对命令进行处理后,它的内容可能是这样的:
multi_argv = {{test, 0}, {ls, 0}, {echo, dp'os, dp.txt, 0}, {ls, 0}, {cat, dp.txt, 0} }
定义int类型变量num_proc表示有多少个命令,sec_count的作用与上面argc的作用类似。定义字符串数组multi_argv。error标记命令是否出错,初始值赋为0。
char* multi_argv[MAX_SHELL_PROC][MAX_SHELL_PROC_STACK]; //multi_argv保存二维字符串数组 int num_proc = 1; // 表示有多少个命令 int sec_count = 0; // 和上面argc的作用类似 int error = 0; // 标记命令是否出错
接着使用for循环顺序扫描argv数组。如果argv[i]处遇到的不是&,则把字符串放入multi_argv[num_proc - 1][sec_count++],否则,用0标记该命令结束,并将表示任务数量的变量num_proc加一,最后将sec_count重新置为0。
如果任务数量大于定义中的最大任务数MAX_SHELL_PROC,我们将error置为1。
int i; for (i = 0; i < argc; i++) { if (strcmp(argv[i],"&")) { multi_argv[num_proc - 1][sec_count++] = argv[i]; } else { multi_argv[num_proc - 1][sec_count] = 0; num_proc++; sec_count = 0; if (num_proc > MAX_SHELL_PROC) { error = 1; printf("Too many commands! "); } } }
下面的代码只有在error为0,即没有错误时才会执行,出错则直接跳过。父进程fork出子进程后,父子进程均还在循环中,所以进行判断:如果当前进程是父进程,则执行wait(),是子进程,则调用execv执行。
if (!error) { for (i = 0; i < num_proc; i++) { int fd = open(multi_argv[i][0], O_RDWR); if (fd == -1) { if (rdbuf[0]) { write(1, "{", 1); write(1, rdbuf, r); write(1, "} ", 2); } } else { close(fd); int pid = fork(); if (pid != 0) { /* parent */ int s; wait(&s); } else { /* child */ execv(multi_argv[i][0], multi_argv[i]); } } } }
至此对shell的一个最基本的改造就完成了。我们首先将这段新修改的代码放入kernel/main.c中,并编译连接,查看一下执行效果。
我们用之前在任务二中实现的算法指令进行测试,结果如下图。可以看到,改
造后的shell已经可以实现多任务的执行了。
3. 本部分小结
在本部分实验中,我们实现了对shell的改造,使它可以在同一个shell中支持多任务并行。我们首先对同时可执行的指令条数和一次输入的最大长度进行限制,然后修改源码中的shabby_shell函数,用"&"符号分割不同的指令,并定义multi_argv保存二维字符串数组。在for循环中顺序扫描argv数组对输入的指令字符串进行处理,然后父进程fork出所有的子进程以实现多任务的执行。