创建子进程fork与执行新程序execve时页表复制的问题
在os内核中,我通过fork系统调用创建新进程,在fork调用中,创建了新进程的tcb结构和内核态堆栈,通过mm_copy为新进程创建了独立的页目录和页表,这些页目录和页表映射到父进程相同的物理页上。
/**
* the one just the return address for sys_call0_for()
* int do_fork(int nr,unsigned long stack_start)
*/
int do_fork(unsigned long stack_start)
{
struct task_struct *p;
struct regs *childreg;
int pid;
/**
* 分配大小为KERNEL_STACK_PAGES的空间作为内核栈,并在栈底建立TCB结构
* 新的TCP结构复制了父进程几乎所有的内容,除了EAX, 内核栈指针ESP
* 将父进程的堆栈空间设置为只读状态,以便触发COW机制.
* 为子进程创建独立的页目录和页表,并映射到和父进程相同的物理地址.
* 实现父子进程共享页表.
*/
p = (struct task_struct *)PA2VA(get_free_pages(KERNEL_STACK_PAGES));
*p = *current;
pid = get_last_pid();
printk("task[%d] do_fork(): new struct_tcb[%d]=0x%08x, \n", current->pid, pid, (uint32)p);
p->pid = pid;
p->state = TASK_INTERRUPTABLE;
p->delay = 0;
p->u_time = 0;
p->k_time = 0;
p->last_fd = 0;//current->last_fd;
for ( int i = 0; i < FSAL_MAX_OPENED_FILE; i++){
p->file[i] = current->file[i];
}
/* alloc memory block to new task kernel stack */
p->thread.esp0 = (uint32)p + KERNEL_STACK_PAGES*PAGE_SIZE -4;
p->thread.ss0 = SELECTOR_KERNEL_DATA;
p->thread.eip = (unsigned long)ret_from_fork;
p->thread.ss = SELECTOR_USER_DATA;
p->thread.trace_bitmap = 0x80000000;
/* copy current task kernel stack to new task kernel stack */
childreg = (struct regs *)((p->thread.esp0) - (sizeof(struct regs)));
reg_copy(childreg, (void*)(stack_start+4), sizeof(struct regs));
// child process diffrent with parent
p->thread.esp = (unsigned long)childreg;
childreg->eax = 0; // for child process !!!!!!
p->parent = current;
p->mm.stack_start = current->mm.stack_start; // 和父进程使用相同地址的栈空间.
// 将父进程用户栈设置为只读,并将页面计数+1,当父子进程任何一个进行写入操作时,将page_default,从而重新分配页面.
printk("task[%d] user_stack[0x%08x] set to wp[0x%02x]\n",current->pid, current->mm.stack_start->vaddr, P_P|P_US);
for ( int i = 0; i < USER_STACK_PAGES; i++ )
{
unsigned long vaddr = current->mm.stack_start->vaddr+i*PAGE_SIZE;
if (page_attrs_set(current->pg_dir,vaddr,P_P|P_US) == -1 )
{
kernel_die(" user'space [0x%08x] wp failed\n", vaddr);
}else{
printk(" user'space [0x%08x] to wp 0x%02x\n", vaddr, P_P|P_US);
}
#if 1
if ( vaddr >= low_memory_start ){
mem_map[MAP_NR(vaddr)]++;
}
#endif
}
/* 为子进程分配页目录,并复制父进程的页表项 */
p->pg_dir = get_free_page();
if ( !p->pg_dir ){
kernel_die("fork() get_free_page failed\n");
}
mm_copy(current, p);
通过mm_copy来复制父进程的页表
void mm_copy(const struct task_struct *old, struct task_struct *new)
{
uint32 *pgd_old, *pgd_new, page_copied = 0;
pgd_old = (uint32 *)old->pg_dir;
pgd_new = (uint32 *)new->pg_dir;
printk("mm_copy() parent[%d] pgd %08x to child[%d] pgd %08x\n", old->pid, (uint32)pgd_old,new->pid,(uint32)pgd_new);
for ( int i = 0; i < 1024; i++ ) // copy 4MB kernel space page table
{
pgd_new[i] = pgd_old[i];
if ( pgd_new[i] != 0x0 ) // 二级页表
{
uint32 new_page;
new_page = get_free_page();
if ( new_page == 0x0 ){
kernel_die("mm_copy() no page\n");
}
printk("copy %d[0x%08x],0x%08x to 0x%08x\n", i, pgd_old[i], pgd_old[i]&0xFFFFF000, new_page);
memcpy( (char*)new_page, (char*)(pgd_old[i] & 0xFFFFF000), PAGE_SIZE );
uint32 *pte = (uint32 *)new_page;
for ( int k = 0; k < 1024; k++ ){
if ( pte[k] & 0x1 ){
page_copied++;
}
}
}
}
printk("mm_copy(): %d page(s) done\n", page_copied);
}
通过mm_copy之后,父子进程拥有自己独立的页目录和页表,但是这些页表的页表项都是相同的,即它们映射的物理地址是相同的,并且这些页面目前来说都是只读状态,为后面的写时复制作了铺垫。最后将新进程加入调度列表:
p->state = TASK_RUNNING;
task[pid] = p;
return pid;
新进程创建出来后,先关闭父进程继承过来的文件描述符,并重新打开tty(目前这些操作只是一个空的系统调用,进入内核之后又原路返回,没有实际作用,仅仅是一个形式)然后执行execve调用,载入新进程sh的镜像,而父进程则通过wait等待子进程运行结束:
for (;;)
{
if ((pid=fork()) < 0)
{
printf("init: fork() failed\n\r");
continue;
}
if (!pid) {
close(0);close(1);close(2);
(void)open("/dev/tty0",O_RDWR,0);
(void)dup(0);
(void)dup(0);
_exit(execve("/bin/sh")); // exit if execve failed
}
while(1){
if (pid==wait(&i)){
break;
}
}
}
execve实现如下:
int syscall_execve( uint32 *esp, uint32 *eip,const int8 *file_name)
{
// 进程在fork()时就已经具备独立的内核栈与用户栈(页表独立,实际的物理页还是共享父进程的)
// 调用do_execve()时将两个堆栈指针恢复到栈顶.
// 在调用execve时,压栈的地址为返回地址即EIP,当进入_syscall_execve后,EIP在
// 内核栈的偏移位置为56字节.因此在_syscall_execve中,通过指令lea EIP(%esp),%eax 也即lea 56(%esp),%eax
// 将返回地址存放在%eax寄存器中,然后将其压入syscall_execve()的栈帧内,得到的参数便是eip
// 通过指针eip可以直接修改内核栈上对应EIP位置上的值.
// 我们正是通过修改内核栈保存的EIP值达到切换系统调用返回后,直接执行新进程指令的目的.
// 使用新程序的入口entry填入eip
// 从RAM_DISK内存段中载入新进程的代码.先这样做吧,后续实现文件系统以后,改为从文件系统中读取可执行文件.
// 此处新进程由loader加载在主内存临时区8M附近.
// 改为COW 2019.07.11
#if 1
if ( !strncmp(file_name,"/bin/sh",16)){
current->exe_inode.i_size = PAGE_SIZE * 2;
current->exe_inode.i_start = 0x0;
}else if (!strncmp(file_name,"/bin/ls",16)){
current->exe_inode.i_size = PAGE_SIZE * 1;
current->exe_inode.i_start = 0x4000;
}else{
kernel_die("task[%d] execve file not found %s\n", current->pid, file_name);
return (-1);
}
#endif
#if 1
printk("task[%d] execve(): file_name=%s, inode_start: 0x%08x, size: 0x%08x\n",
current->pid,
file_name,current->exe_inode.i_start,
current->exe_inode.i_size);
#endif
// 清空'用户空间'code、data、stack及其他mmap的页面映射关系
// 内核空间映射关系保留、一级也二级页表,它们在fork()时已经建立好.
// 如此一来,该子进程的进程空间实际上的物理页面已全部释放,与父进程完全没有关系了.
// 缺页中断触发时,只需申请物理页面,填到对应的表项即可重新完成页面映射关系.
mm_free(current->pg_dir);
*eip = 0x02000000; // 新进程的入口点,所有由execve加载的二进制程序入口点统一
*esp = 0x08000000/*+PAGE_SIZE*/-4; // 堆栈
printk("task[%d] execve call ready\n", current->pid);
return (0);
}
若子进程执行execve失败返回了,那么它将调用_exit(),在_exit()中将自己移出调度队列,向父进程发送一个信号,唤醒父进程,最后执行shedule()函数,让出CPU使用权(此时子进程就成了传说中的僵尸进程):
int do_exit(int exit_code)
{
struct task_struct *p;
p = current->parent;
current->state = TASK_STOP;
current->exit_code = exit_code;
if (!p){
p = task[0];
}
printk("send signal to parent [%d]\n", p->pid);
p->signal |= (0x1<<0); // signal to parent task.
if ( p->state == TASK_WAIT ){
p->state = TASK_RUNNING;
}
if_return:
schedule();
goto if_return;
return (0);
}
int syscall_exit ( int exit_code )
{
return do_exit(exit_code);
}
之后父进程被唤醒,在wait中找到子进程,并释放它的资源(收尸),页表,堆栈, 最后父进程继续进行下一轮fork()调用,周而复始。
在此过程中, mm_free()是关键,fork()中已经为子进程建立了独立的一级、二级页表结构,并且这些页表项与父进程相同,即共享父进程的物理页面。execve执行的是新的二进制文件,子进程实际上的.text,.data.bss、堆栈都需要不同于父进程的物理页面来存放,因此我先把子进程页表的用户空间映射关系全部解除,标记为页面不存在,达到子进程不再共享父进程的物理页面的目的。随后子进程sh执行时,由于缺页异常触发COW机制从磁盘(RAM_DISK)加载真正的物理页面。
void mm_free(uint32 pg_dir )
{
uint32 *p_pg_dir, *pte;
uint32 page_freed = 0;
p_pg_dir = (uint32*)(pg_dir);
uint32 cr3;
__asm__ __volatile__("movl %%cr3, %%eax":"=g"(cr3)::"memory");
printk("cr3=0x%08x\n", cr3);
printk("mm_free(): free task user's space pg_dir=0x%08x\n", p_pg_dir);
for ( int i = 0; i < 768; i++ )
{
if ( !(p_pg_dir[i] & 0x1) ){ // 全局页目录项不为空,则存在页表
continue;
}
printk(" p_pg_dir[%d] 0x%08x\n",i,p_pg_dir[i] & 0xFFFFF000);
pte = (uint32*)(p_pg_dir[i] & 0xFFFFF000);
for ( int k = 0; k < 1024; k++ )
{
// 若页面存在,页表项设置为不存在标志,表示该物理页面不存在,触发缺页default.
// 在fork()阶段,已经为进程分配了独立的页目录项和页表项,这些已分配的页目录和页表项
// 指向了父进程的物理页,且堆栈标记为不可写.
if ( pte[k] & 0x1){ // 页表项存在
page_freed++;
// printk("pte[%d]=0x%08x\n", k,pte[k]);
}
pte[k] = 0;
}
}
printk("mm_free(): %d page(s) done\n", page_freed);
}
但是这个流程出现了一点问题,就是mm_free()解除用户空间的页表映射关系时,导致bochs CPU重启,并报错:
physical address not available
而且页表是在释放的过程中,前768项页目录仅仅释放了2项时就报错,而且我释放的是用户空间的地址,此时执行的代码是处于内核态,使用的是内核空间,内核空间的256项页目录项并没有释放,不明白为什么会报错。为了验证释放的页目录地址是否正确,mm_free()还读取了cr3寄存器的值,也确定了是当前进程的tcb->pg_dir。 看来对页表的理解还是不够透彻。。。