神说、内核要有自己的数据、使用户不可访问.事就这样成了。
神称高地址为内核空间、称低地址为用户空间. 神看着是好的。
神说、用户要有自己的进程、和自己的页表、并可以进行系统调用.事就这样成了。
有晚上、有早晨、是第三日。
1、lab3概述
lab3大体分为两部分,第一部分包括执行环境(可以简单的理解为进程,下文也用进程代替执行环境)的建立和运行第一个进程,第二个部分初始化并完成中断和异常以及系统调用相关机制,本文只描述第一部分的做法。
2、原理
在建立进程之前首先要建立进程的管理机制,包括分配进程描述符的空间、建立空闲进程列表、把进程描述符数组相应内存进行映射等。这是进程描述符管理的一些工作。
其次初始化进程的虚拟地址空间,也就是给其页目录赋值过程,主要是将内核空间映射到虚拟地址的高位上。
然后加载进程的代码,代码以二进制形式和内核编译到一起了,所谓“加载”就是将这段代码从内存(内核区往上一点的位置)复制到某个物理位置,接着将这个位置和相应虚拟地址进行映射。
最后将进程的页目录加载进cr3,然后将各寄存器压栈,通过iret跳到用户态执行。
大体上讲就是这样。
3、具体实现与代码
(1)Env数据结构
Env数据结构代表一个进程描述符,定义在env.h中,包括进程的id,父进程的id,执行状态,该进程的寄存器状态,执行的次数等,并使用env_link指向下一个空闲的Env。
所有Env对象存储在envs数组中,该数组定义在env.c的开头。
除此之外curenv代表当前正在执行的进程,env_free_list指向空闲的进程描述符,组成链表,链表的添加与删除均在表头执行。
(2)进程描述符空间的分配
首先在pmap.c里给数组envs分配空间,然后将分配的页面从free_page_list里剔除掉,最后将UENVS映射到envs,整个过程跟Page数组的处理完全一样,不再赘述。通过检查函数即代表完成。
(3)进程描述符数组初始化
完成env_init函数,将数组中所有的进程id置0,同时将各元素串起来,并把数组地址赋给env_free_list,代码如下:
- void
- env_init(void)
- {
-
-
- int i=0;
- for(i=0;i<=NENV-1;i++)
- {
- envs[i].env_id=0;
- if(i!=NENV-1)
- {
- envs[i].env_link=&envs[i+1];
- }
- }
- env_free_list=envs;
-
- env_init_percpu();
- }
(4)初始化进程虚拟地址空间
完成env_setup_vm函数。不同的进程有不同的虚拟地址空间,进而就必须有自己的页目录和页表,该函数的任务就是初始化页目录。
首先分配一个空闲页当做页目录,然后将这个页目录映射内核地址空间(UTOP之上的部分),不需要映射页表因为可以和内核共用页表。
接着增加分配的物理页的引用(JOS设计的一个很不美的地方),将此页的虚拟地址赋值给进程的pgdir,然后使进程有权限操作自己的pgdir。
- static int
- env_setup_vm(struct Env *e)
- {
- int i;
- struct Page *p = NULL;
- cprintf("env_setup_vm\r\n");
-
- if (!(p = page_alloc(ALLOC_ZERO)))
- return -E_NO_MEM;
-
- e->env_pgdir=page2kva(p);
- for(i=PDX(UTOP);i<1024;i++)
- {
- e->env_pgdir[i]=kern_pgdir[i];
- }
- p->pp_ref++;
-
-
- e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;
-
- return 0;
- }
(5)辅助映射函数
当进行加载二进制代码的时候,需要一个辅助函数region_alloc,其作用是映射虚拟地址va及之后的len字节到进程e的虚拟地址空间里。
实现比较简单,首先将va向下4K对齐,va+len向上4k对齐,以保证分配的是整数页面。之后一个页面一个页面分配即可。
为了使页面用户态可写,需要权限PTE_W|PTE_U。
- static void
- region_alloc(struct Env *e, void *va, size_t len)
- {
-
- void* i;
- cprintf("region_alloc %x,%d\r\n",e->env_pgdir,len);
- for(i=ROUNDDOWN(va,PGSIZE);i<ROUNDUP(va+len,PGSIZE);i+=PGSIZE)
- {
- struct Page* p=(struct Page*)page_alloc(1);
- if(p==NULL)
- panic("Memory out!");
- page_insert(e->env_pgdir,p,i,PTE_W|PTE_U);
- }
-
- }
(6)进程代码加载
通过load_icode给相应的进程加载可执行代码。可执行代码的格式是elf格式,因此使用类似加载kernel的方式将代码加载到相应内存中。
因为当前JOS没有文件系统,所以用户态的程序是编译在kernel里面的,通过编译器的导出符号来进行访问,通过追踪init.c里的相关代码也可以说明这一点。当然在这里我们无需更多的关注这个可执行代码目前在哪里,只需要完成其加载机制即可。
为了往进程对应的虚拟空间映射到的物理内存中写数据,首先必须要加载进程相应的页目录,当然必须使用此函数外的逻辑来保证在调用此函数之前页目录是已经初始化过的。接着仿照kernel的方式去分析elf文件,加载类型为ELF_PROG_LOAD的段到其要求的虚拟地址中,使用之前完成的辅助函数来方便的进行地址的映射。最后将程序入口放入进程的eip中(后面会有解释),并映射进程堆栈(个人认为堆栈映射应该放在env_setup_vm会更合乎逻辑一些),然后重新加载kern_pgdir,函数返回。
- static void
- load_icode(struct Env *e, uint8_t *binary, size_t size)
- {
-
- lcr3(PADDR(e->env_pgdir));
- cprintf("load_icode\r\n");
- struct Elf * ELFHDR=(struct Elf *)binary;
- struct Proghdr *ph, *eph;
- int i;
- if (ELFHDR->e_magic != ELF_MAGIC)
- panic("Not a elf binary");
-
-
- ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
- eph = ph + ELFHDR->e_phnum;
- for (; ph < eph; ph++)
- {
-
-
- if(ph->p_type==ELF_PROG_LOAD)
- {
- cprintf("load_prog %d\r\n",ph->p_filesz);
- region_alloc(e,(void*)ph->p_va,ph->p_filesz);
- char* va=(char*)ph->p_va;
- for(i=0;i<ph->p_filesz;i++)
- {
-
- va[i]=binary[ph->p_offset+i];
- }
-
- }
- }
- e->env_tf.tf_eip=ELFHDR->e_entry;
-
-
-
-
-
- struct Page* p=(struct Page*)page_alloc(1);
- if(p==NULL)
- panic("Not enough mem for user stack!");
- page_insert(e->env_pgdir,p,(void*)(USTACKTOP-PGSIZE),PTE_W|PTE_U);
- cprintf("load_icode finish!\r\n");
- lcr3(PADDR(kern_pgdir));
- }
(7)进程建立
使用上述完成的函数来建立进程。
完成env_create函数,首先分配一个进程描述符,然后加载可执行代码,逻辑很简单。
- void
- env_create(uint8_t *binary, size_t size, enum EnvType type)
- {
-
-
- struct Env* env;
-
- if(env_alloc(&env,0)==0)
- {
- env->env_type=type;
- load_icode(env, binary,size);
- }
-
- }
(8)进程执行
完成env_run函数来运行进程。
首先设置当前进程的一些信息,然后更改当前进程指针指向要运行的进程,之后加载进程页目录跳转到使用env_pop_tf真正使进程执行。
- void
- env_run(struct Env *e)
- {
-
-
- cprintf("Run env!\r\n");
- if(curenv!=NULL)
- {
- if(curenv->env_status==ENV_RUNNING)
- {
- curenv->env_status=ENV_RUNNABLE;
- }
- }
- curenv=e;
- e->env_status=ENV_RUNNING;
- e->env_runs++;
- lcr3(PADDR(e->env_pgdir));
- env_pop_tf(&e->env_tf);
- }
接着分析env_pop_tf。
env_pop_tf首先将传入的trapframe,包含所有的寄存器信息压栈,然后使用iret,即中断返回来执行。
- void
- env_pop_tf(struct Trapframe *tf)
- {
- __asm __volatile("movl %0,%%esp\n"
- "\tpopal\n"
- "\tpopl %%es\n"
- "\tpopl %%ds\n"
- "\taddl $0x8,%%esp\n"
- "\tiret"
- : : "g" (tf) : "memory");
- panic("iret failed");
- }
iret到底是什么,这里引用一段IA-32手册上的话:
the IRET instruction pops the return instruction pointer, return code segment selector, and EFLAGS image from the stack to the EIP, CS, and EFLAGS registers, respectively, and then resumes execution of the interrupted program or procedure. If the return is to another privilege level, the IRET instruction also pops the stack pointer and SS from the stack, before resuming program execution。
为什么要使用这种方式来运行第一个进程,而不是直接根据该进程入口执行,主要是因为当前运行在内核态(CPL,也就是CS等寄存器的后两位,见图),要使进程运行在用户态必须改变各段寄存器的CPL,但又不能直接给诸如CS等寄存器赋值,所以必须使用iret从堆栈里弹出相应寄存器的值,这也是需要事先将tf的eip放入进程代码入口地址的原因。
做完这些之后,第一个进程就能运行起来了,但立刻又崩溃了,因为没有处理系统调用,而系统调用相关的实现就是下篇日志的内容了。