操作系统真象还原实验记录之实验三十:fork的实现,增加read、putchar、clear系统调用
1. fork原理与实现
fork是叉子,作用是将父进程克隆给子进程,并返回父进程pid。
父进程先调用fork系统调用中断进入内核态,完成了拷贝父进程给子进程,并将子进程设置为就绪态后,中断返回父进程pid,执行fork之后的代码,打印父进程pid,父进程结束。
调度执行子进程,设置好thread_stack,利用switch_to进入intr_exit,利用中断返回,从而跳转到子进程并返回子进程pid,直接执行子进程的fork后的代码,也就是打印子进程pid。
从而有了父进程调用fork系统调用,打印两个pid的效果。
注意:这里子进程和父进程代码一模一样,但是子进程只执行了fork的中断返回,没有执行fork的复制进程功能,这里跳转到子进程fork后的代码的方式,和之前进程调度切换第一次上处理机的进程的方式是一致的。
memory.c之get_a_page_without_opvaddrbitmap函数
/* 安装1页大小的vaddr,专门针对fork时虚拟地址位图无须操作的情况 */
void* get_a_page_without_opvaddrbitmap(enum pool_flags pf, uint32_t vaddr) {
struct pool* mem_pool = pf & PF_KERNEL ? &kernel_pool : &user_pool;
lock_acquire(&mem_pool->lock);
void* page_phyaddr = palloc(mem_pool);
if (page_phyaddr == NULL) {
lock_release(&mem_pool->lock);
return NULL;
}
page_table_add((void*)vaddr, page_phyaddr);
lock_release(&mem_pool->lock);
return (void*)vaddr;
}
功能是为vaddr分配一页物理页,但无需从虚拟内存地址中设置。
thread.h与thread.c增加
首先,在thread.h的task_struct中增加了成员变量“int16_t parent_pid”,它位于cwd_inode_nr之后,
然后,thread.c中的init_thread函数中增加一句“pthread->parent_pid = -1”。
最后,在thread.c中还为fork专门增加了分配pid的函数 fork_pid
就是allocate_pid的封装,但是allocate_pid是静态函数,不能供外部调用。
fork.c之copy_pcb_vaddrbitmap_stack0函数
extern void intr_exit(void);
/* 将父进程的pcb、虚拟地址位图拷贝给子进程 */
static int32_t copy_pcb_vaddrbitmap_stack0(struct task_struct* child_thread, struct task_struct* parent_thread) {
/* a 复制pcb所在的整个页,里面包含进程pcb信息及特级0极的栈,里面包含了返回地址, 然后再单独修改个别部分 */
memcpy(child_thread, parent_thread, PG_SIZE);
child_thread->pid = fork_pid();
child_thread->elapsed_ticks = 0;
child_thread->status = TASK_READY;
child_thread->ticks = child_thread->priority; // 为新进程把时间片充满
child_thread->parent_pid = parent_thread->pid;
child_thread->general_tag.prev = child_thread->general_tag.next = NULL;
child_thread->all_list_tag.prev = child_thread->all_list_tag.next = NULL;
block_desc_init(child_thread->u_block_desc);
/* b 复制父进程的虚拟地址池的位图 */
uint32_t bitmap_pg_cnt = DIV_ROUND_UP((0xc0000000 - USER_VADDR_START) / PG_SIZE / 8 , PG_SIZE);
void* vaddr_btmp = get_kernel_pages(bitmap_pg_cnt);
if (vaddr_btmp == NULL) return -1;
/* 此时child_thread->userprog_vaddr.vaddr_bitmap.bits还是指向父进程虚拟地址的位图地址
* 下面将child_thread->userprog_vaddr.vaddr_bitmap.bits指向自己的位图vaddr_btmp */
memcpy(vaddr_btmp, child_thread->userprog_vaddr.vaddr_bitmap.bits, bitmap_pg_cnt * PG_SIZE);
child_thread->userprog_vaddr.vaddr_bitmap.bits = vaddr_btmp;
/* 调试用 */
// ASSERT(strlen(child_thread->name) < 11); // pcb.name的长度是16,为避免下面strcat越界
// strcat(child_thread->name,"_fork");
return 0;
}
将父进程pcb那一页以及父进程的虚拟地址位图复制给子进程,单独修改子进程pcb的一些属性,比如,子进程的状态改为就绪态、时间片充满、虚拟地址位图拷贝后设置、子进程pcb的parent_pid。
fork.c之copy_body_stack3函数
/* 复制子进程的进程体(代码和数据)及用户栈 */
static void copy_body_stack3(struct task_struct* child_thread, struct task_struct* parent_thread, void* buf_page) {
uint8_t* vaddr_btmp = parent_thread->userprog_vaddr.vaddr_bitmap.bits;
uint32_t btmp_bytes_len = parent_thread->userprog_vaddr.vaddr_bitmap.btmp_bytes_len;
uint32_t vaddr_start = parent_thread->userprog_vaddr.vaddr_start;
uint32_t idx_byte = 0;
uint32_t idx_bit = 0;
uint32_t prog_vaddr = 0;
/* 在父进程的用户空间中查找已有数据的页 */
while (idx_byte < btmp_bytes_len) {
if (vaddr_btmp[idx_byte]) {
idx_bit = 0;
while (idx_bit < 8) {
if ((BITMAP_MASK << idx_bit) & vaddr_btmp[idx_byte]) {
prog_vaddr = (idx_byte * 8 + idx_bit) * PG_SIZE + vaddr_start;
/* 下面的操作是将父进程用户空间中的数据通过内核空间做中转,最终复制到子进程的用户空间 */
/* a 将父进程在用户空间中的数据复制到内核缓冲区buf_page,
目的是下面切换到子进程的页表后,还能访问到父进程的数据*/
memcpy(buf_page, (void*)prog_vaddr, PG_SIZE);
/* b 将页表切换到子进程,目的是避免下面申请内存的函数将pte及pde安装在父进程的页表中 */
page_dir_activate(child_thread);
/* c 申请虚拟地址prog_vaddr */
get_a_page_without_opvaddrbitmap(PF_USER, prog_vaddr);
/* d 从内核缓冲区中将父进程数据复制到子进程的用户空间 */
memcpy((void*)prog_vaddr, buf_page, PG_SIZE);
/* e 恢复父进程页表 */
page_dir_activate(parent_thread);
}
idx_bit++;
}
}
idx_byte++;
}
}
函数功能是遍历父进程的虚拟地址位图每一位,将父进程的代码、数据资源复制给子进程。
流程如下:
buf_page是所有进程共享内存区,位于所有进程3GB以上的内核空间。
1.每当发现父进程虚拟地址位图某位为1,就将此页复制到buf_page中
2.然后切换成子进程页表,调用 为子进程分配一页,无需设置虚拟位图,因为父进程的位图复制给了子进程。
3.将buf_page的一页数据复制给子进程。
4.切换回父进程的页表,继续循环遍历父进程虚拟位图下一位。
(3特权级栈位于进程3GB一下空间中,记录在虚拟位图)
注意:按道理这里子父进程的页表是要独立的,
copy_pcb_vaddrbitmap_stack0这个函数已经完成了内核内存池中pcb(0特权级栈位于pcb中)、虚拟位图的拷贝,未完成页表的拷贝。
页表的拷贝留在其他地方。
fork.c之build_child_stack函数
/* 为子进程构建thread_stack和修改返回值 */
static int32_t build_child_stack(struct task_struct* child_thread) {
/* a 使子进程pid返回值为0 */
/* 获取子进程0级栈栈顶 */
struct intr_stack* intr_0_stack = (struct intr_stack*)((uint32_t)child_thread + PG_SIZE - sizeof(struct intr_stack));
/* 修改子进程的返回值为0 */
intr_0_stack->eax = 0;
/* b 为switch_to 构建 struct thread_stack,将其构建在紧临intr_stack之下的空间*/
uint32_t* ret_addr_in_thread_stack = (uint32_t*)intr_0_stack - 1;
/*** 这三行不是必要的,只是为了梳理thread_stack中的关系 ***/
uint32_t* esi_ptr_in_thread_stack = (uint32_t*)intr_0_stack - 2;
uint32_t* edi_ptr_in_thread_stack = (uint32_t*)intr_0_stack - 3;
uint32_t* ebx_ptr_in_thread_stack = (uint32_t*)intr_0_stack - 4;
/**********************************************************/
/* ebp在thread_stack中的地址便是当时的esp(0级栈的栈顶),
即esp为"(uint32_t*)intr_0_stack - 5" */
uint32_t* ebp_ptr_in_thread_stack = (uint32_t*)intr_0_stack - 5;
/* switch_to的返回地址更新为intr_exit,直接从中断返回 */
*ret_addr_in_thread_stack = (uint32_t)intr_exit;
/* 下面这两行赋值只是为了使构建的thread_stack更加清晰,其实也不需要,
* 因为在进入intr_exit后一系列的pop会把寄存器中的数据覆盖 */
*ebp_ptr_in_thread_stack = *ebx_ptr_in_thread_stack =\
*edi_ptr_in_thread_stack = *esi_ptr_in_thread_stack = 0;
/*********************************************************/
/* 把构建的thread_stack的栈顶做为switch_to恢复数据时的栈顶 */
child_thread->self_kstack = ebp_ptr_in_thread_stack;
return 0;
}
此函数为switch_to构建了thread_stack,保证schdule、switch_to可以进入intr_exit,由于copy_pcb_vaddrbitmap_stack0已经将父进程的中断栈复制给了子进程,intr_exit后的中断返回eip,便会将程序跳转到子进程的fork后的代码处。
注意:子父进程代码fork处的虚拟地址是一样的,但是页表里与之对应的物理地址不一致。
函数流程如下:
1.先获得子进程的中断栈,将eax置0,这样子进程执行完fork系统调用后,返回的pid为0。
2.然后,将子进程中断栈下面的线程栈栈底的前5个32位地址保存。
3.将第一个32位的内容即eip设置为intr_exit,将第五个32位的地址保存于子进程的self_thread中,这样switch_to下半部的esp就能在正确位置执行pop ebp,最后执行ret跳转到中断返回程序。
fork.c之update_inode_open_cnts
/* 更新inode打开数 */
static void update_inode_open_cnts(struct task_struct* thread) {
int32_t local_fd = 3, global_fd = 0;
while (local_fd < MAX_FILES_OPEN_PER_PROC) {
global_fd = thread->fd_table[local_fd];
ASSERT(global_fd < MAX_FILE_OPEN);
if (global_fd != -1) {
file_table[global_fd].fd_inode->i_open_cnts++;
}
local_fd++;
}
}
父子线程的pcb一致,所以它们pcb里的fd_table数组也是一致的,这表示父进程打开的文件,子进程同样打开,所以需要遍历fd_table,把fd_table中对应的打开文件表表项的inode打开数加一,表示父进程打开了的所有文件,子进程也要打开一遍。
fork.c之copy_process
/* 拷贝父进程本身所占资源给子进程 */
static int32_t copy_process(struct task_struct* child_thread, struct task_struct* parent_thread) {
/* 内核缓冲区,作为父进程用户空间的数据复制到子进程用户空间的中转 */
void* buf_page = get_kernel_pages(1);
if (buf_page == NULL) {
return -1;
}
/* a 复制父进程的pcb、虚拟地址位图、内核栈到子进程 */
if (copy_pcb_vaddrbitmap_stack0(child_thread, parent_thread) == -1) {
return -1;
}
/* b 为子进程创建页表,此页表仅包括内核空间 */
child_thread->pgdir = create_page_dir();
if(child_thread->pgdir == NULL) {
return -1;
}
/* c 复制父进程进程体及用户栈给子进程 */
copy_body_stack3(child_thread, parent_thread, buf_page);
/* d 构建子进程thread_stack和修改返回值pid */
build_child_stack(child_thread);
/* e 更新文件inode的打开数 */
update_inode_open_cnts(child_thread);
mfree_page(PF_KERNEL, buf_page, 1);
return 0;
}
上述函数的封装,申请了一个内核页用作子进程的页表。
fork.c之sys_fork
/* fork子进程,内核线程不可直接调用 */
pid_t sys_fork(void) {
struct task_struct* parent_thread = running_thread();
struct task_struct* child_thread = get_kernel_pages(1); // 为子进程创建pcb(task_struct结构)
if (child_thread == NULL) {
return -1;
}
ASSERT(INTR_OFF == intr_get_status() && parent_thread->pgdir != NULL);
if (copy_process(child_thread, parent_thread) == -1) {
return -1;
}
/* 添加到就绪线程队列和所有线程队列,子进程由调试器安排运行 */
ASSERT(!elem_find(&thread_ready_list, &child_thread->general_tag));
list_append(&thread_ready_list, &child_thread->general_tag);
ASSERT(!elem_find(&thread_all_list, &child_thread->all_list_tag));
list_append(&thread_all_list, &child_thread->all_list_tag);
return child_thread->pid; // 父进程返回子进程的pid
}
是copy_process的封装,在内核申请了一页用作子进程的PCB,然后调用copy_process,然后将子进程加入就绪队列和全局队列。
main.c之init函数
void init(void);
/* init进程 */
void init(void) {
uint32_t ret_pid = fork();
if(ret_pid) { // 父进程
printf("i am father, my pid is %d, child pid is %d\n", getpid(), ret_pid);
} else { // 子进程
printf("i am child, my pid is %d, ret pid is %d\n", getpid(), ret_pid);
}
while(1);
}
init进程会调用init函数来不断调用fork,生成更多的子进程
init进程是第一个进程,pid为1
目前的init进程作用是调用fork产生更多子进程并打印它们的pid。
thread.c之thread_init增加
/* 初始化线程环境 */
void thread_init(void) {
put_str("thread_init start\n");
list_init(&thread_ready_list);
list_init(&thread_all_list);
/* 先创建第一个用户进程:init */
process_execute(init, "init"); // 放在第一个初始化,这是第一个进程,init进程的pid为1
/* 将当前main函数创建为线程 */
make_main_thread();
/* 创建idle线程 */
idle_thread = thread_start("idle", 10, idle, NULL);
put_str("thread_init done\n");
}
为了保证init进程的pid为1,所以在主线程,idle线程之前创建了init进程。
增加fork系统调用
fork的内核实现完毕,
接下来增加fork系统调用
syscall.h增加
syscall.h中的enum SYSCALL_NR结构中添加SYS_FORK。
syscall.c增加
syscall.c中添加fork(),原型是pid_t fork(void),
实现是"return _syscall0(SYS_FORK);"
syscall-init.c增加
syscall_init函数中,添加代码"syscall_table[SYS_FORK] = sys_fork"。
1.2 实验结果
首先main.c调init_all,init_all调thread_init,thread_init首先创建了init进程,pid为1;之后创建了主线程pid=2;最后创建了idel线程pid=3。
主线程走的是main函数,init进程走的是init函数,它们都以死循环结尾,时间片用完就会相互调度。
init进程执行fork系统调用,由于中断处理程序涉及拷贝,消耗了大量时间片,当调用fork_pid为子进程分配pid的时候,主线程的main函数已经执行到了死循环,主线程获得了pid=2,idel线程获得了pid=3。因此,init进程的子进程只能获得pid=4。sys_fork最后返回了子进程的pid=4。同时,拷贝好了pid=4的子进程于就绪队列。
当切换到pid=4的子进程后,他会执行fork返回的中断返回代码,所以sys_fork会返回0,然后执行fork后的代码,即打印pid。
2. 添加read系统调用,获取键盘输入
之前的旧版本sys_read调用的是file_read,读文件,返回读出的字节个数,将读出的内容放入buf缓冲区。并未实现对键盘的读入。
fs.c之sys_read改进
/* 从文件描述符fd指向的文件中读取count个字节到buf,若成功则返回读出的字节数,到文件尾则返回-1 */
int32_t sys_read(int32_t fd, void* buf, uint32_t count) {
ASSERT(buf != NULL);
int32_t ret = -1;
uint32_t global_fd = 0;
if (fd < 0 || fd == stdout_no || fd == stderr_no) {
printk("sys_read: fd error\n");
} else if (fd == stdin_no) {
char* buffer = buf;
uint32_t bytes_read = 0;
while (bytes_read < count) {
*buffer = ioq_getchar(&kbd_buf);
bytes_read++;
buffer++;
}
ret = (bytes_read == 0 ? -1 : (int32_t)bytes_read);
} else {
global_fd = fd_local2global(fd);
ret = file_read(&file_table[global_fd], buf, count);
}
return ret;
}
如果fd为标准输入,就从键盘环形缓冲区kbd_buf里读。kbd_buf定义在keyboard.c中。
增加read系统调用
1.syscall.c里增加read系统调用 ,调用宏_syscall3(xxx,xxx,xxx)
2.syscall_init.c中添加代码"syscall_table[SYS_READ] = sys_read"。
3.syscall.h中enum SYSCALL_NR中添加SYS_READ。
3.添加putchar、clear系统调用
sys_putchar是console_put_char的封装,
clear的内核实现是print.S中cls_screen。
syscall.c增加
/* 从文件描述符fd中读取count个字节到buf */
int32_t read(int32_t fd, void* buf, uint32_t count) {
return _syscall3(SYS_READ, fd, buf, count);
}
/* 输出一个字符 */
void putchar(char char_asci) {
_syscall1(SYS_PUTCHAR, char_asci);
}
/* 清空屏幕 */
void clear(void) {
_syscall0(SYS_CLEAR);
}
syscall.h
增加enum SYSCALL_NR结构中添加SYS_PUTCHAR和SYS_CLEAR。
syscall-init.c增加
syscall_table[SYS_PUTCHAR] = sys_putchar;
syscall_table[SYS_CLEAR] = cls_screen;
实验结果在shell中验证。