操作系统真象还原实验记录之实验三十:fork的实现,增加read、putchar、clear系统调用

操作系统真象还原实验记录之实验三十: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 实验结果

操作系统真象还原实验记录之实验三十:fork的实现,增加read、putchar、clear系统调用
首先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中验证。

上一篇:Linux内核启动流程分析


下一篇:ADG搭建备库过程中备库预警日志报错ORA-00313 ORA-00312 ORA-27037