本章总算是开始我之前最关心的问题:用户进程的虚拟地址空间如何实现。 实际上在读前几章的时候就大概知道了,但还是对其具体的实现和细节方面抱有疑问,既然现在看完这章了,趁着还记得的时候留些笔记好了。
首先是关于TSS(Task Status Segment)的作用和开始时存在的疑问:
TSS早期是由Intel设计出来,并建议操作系统厂商在实现多任务时使用的结构。 支持多任务的操作系统往往是通过中断来实现任务切换的,Intel的目的是希望通过TSS保存任务中断前的状态(寄存器、栈、位图、上一个TSS结构),然后由操作系统加载新的TSS到该寄存器中并记录中断前TSS到新TSS中,以实现任务嵌套。
但从结论上说,由于操作系统维护TSS的开销巨大,于是各个操作系统厂商都拒绝了这套方案,转而用自己的实现去替代,而TSS只起到特权级切换时对栈的切换而已。
Linux选择了更加简单的维护寄存器方案:
直接在任务自己的栈中push寄存器,同时之维护TSS中与栈相关的内容。且让所有任务共用一个TSS。
不过我们也知道,Linux只用了R0和R3两个特权级,所以对只需要维护TSS中SS0和ESP0即可。
接下来概述一下建立用户进程的流程:
首先需要为用户进程建立TSS,但只需要为其SS0选址。
同时还要为用户进程添加GDT(还未建立LDT),分别是其代码段还数据段。
然后为用户进程建立PCB(流程同之前加载线程相同)
为用户进程建立用户空间虚拟内存池,初始化其位图
为用户进程创建新页表
将用户进程加入到调度队列
尽管流程如上,但有几个需要注意的细节点:
首先是关于如何切换到用户进程。 用户进程毕竟是运行在R3权限下的进程,但目前我们却在做R0才能做的事,且处理器是不允许我们能够普通地从高特权级往低特权级转移的,类似jmp和call指令在特权检查时会被阻止。
一般的想法应该是中断返回,这是处理器唯一容许的由高权级往低权级转移的方法。所以我们的目的是在内核栈中伪造数据,然后通过iret指令返回到用户进程中。由此往后再通过普通的线程调度来回切换即可。任务切换走的是时钟中断,和线程调度并无区别,只是任务切换涉及到了页表切换这一过程。
调度器会在进行线程/进程调度时进行页表切换。对于用户进程,其页表地址的PCB中记录;对于内核线程,其页表地址为NULL,将会默认切换回内核页表。至于用户线程,则可和用户进程一样,只是其PCB中记录进程本身的页表地址。
创建进程:
1 2 3 4 5 6 7 8 9 10 11 12 13 void process_execute(void* filename, char* name) { struct task_struct* thread = get_kernel_pages(1); init_thread(thread, name, default_prio); create_user_vaddr_bitmap(thread); thread_create(thread, start_process, filename); thread->pgdir = create_page_dir(); enum intr_status old_status = intr_disable(); ASSERT(!elem_find(&thread_ready_list, &thread->general_tag)); list_append(&thread_ready_list, &thread->general_tag); ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag)); list_append(&thread_all_list, &thread->all_list_tag); intr_set_status(old_status); }
注意第五行thread_create函数,该线程将调用start_process函数,而filename则是我们输入的文件(假设它是一个函数吧,因为笔者目前还没看到第12章)
start_process实例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 void start_process(void* filename_) { void* function = filename_; struct task_struct* cur = running_thread(); cur->self_kstack += sizeof(struct thread_stack); struct intr_stack* proc_stack = (struct intr_stack*)cur->self_kstack; proc_stack->edi = proc_stack->esi = proc_stack->ebp = proc_stack->esp_dummy = 0; proc_stack->ebx = proc_stack->edx = proc_stack->ecx = proc_stack->eax = 0; proc_stack->gs = 0; // 用户态用不上,直接初始为0 proc_stack->ds = proc_stack->es = proc_stack->fs = SELECTOR_U_DATA; proc_stack->eip = function; // 待执行的用户程序地址 proc_stack->cs = SELECTOR_U_CODE; proc_stack->eflags = (EFLAGS_IOPL_0 EFLAGS_MBS EFLAGS_IF_1); proc_stack->esp = (void*)((uint32_t)get_a_page(PF_USER, USER_STACK3_VADDR) + PG_SIZE) ; proc_stack->ss = SELECTOR_U_DATA; asm volatile ("movl %0, %%esp; jmp intr_exit" : : "g" (proc_stack) : "memory"); }
该函数将获取用户进程的intr_stack结构体,该结构体是在进入中断时用户储存返回信息的,现在只需要篡改这些返回信息,比如将eip初始化为我们的”文件”入口,也就是function,然后再返回到intr_exit就能像普通的中断一样正常退出了。
然后是调度器在遇到本进程的时候会主动尝试激活进程/线程:调用process_activate
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void process_activate(struct task_struct* p_thread) { ASSERT(p_thread != NULL); page_dir_activate(p_thread); if (p_thread->pgdir) { /* 更新该进程的esp0,用于此进程被中断时保留上下文 */ update_tss_esp(p_thread); } } void page_dir_activate(struct task_struct* p_thread) { uint32_t pagedir_phy_addr = 0x100000; // 默认为内核的页目录物理地址,也就是内核线程所用的页目录表 if (p_thread->pgdir != NULL) { // 用户态进程有自己的页目录表 pagedir_phy_addr = addr_v2p((uint32_t)p_thread->pgdir); } asm volatile ("movl %0, %%cr3" : : "r" (pagedir_phy_addr) : "memory"); } void update_tss_esp(struct task_struct* pthread) { tss.esp0 = (uint32_t*)((uint32_t)pthread + PG_SIZE); }
其中page_dir_activate函数会将记录在PCB中的页表加载到CR3寄存器中。
但上述过程有一个小细节,在不清楚代码全貌的情况下可能会产生一个困惑:
cr3寄存器加载以后,为什么接下来的操作还能够进行,寻址不会出现问题吗?
事实上确实如此,但在为用户进程建立页表的时候,为防止此问题出现做了些微操。代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 uint32_t* create_page_dir(void) { uint32_t* page_dir_vaddr = get_kernel_pages(1); if (page_dir_vaddr == NULL) { console_put_str("create_page_dir: get_kernel_page failed!"); return NULL; } memcpy((uint32_t*)((uint32_t)page_dir_vaddr + 0x300*4), (uint32_t*)(0xfffff000+0x300*4), 1024); uint32_t new_page_dir_phy_addr = addr_v2p((uint32_t)page_dir_vaddr); page_dir_vaddr[1023] = new_page_dir_phy_addr PG_US_U PG_RW_W PG_P_1; return page_dir_vaddr; }
memcpy函数将内核页表的768项及以后都拷贝到了用户页表中。相当于我们在用户的地址空间中嵌入了内核入口点,768正对应着0xc0000000,也就是一般规定的内核空间地址。
所以即便加载了用户页表到cr3,也会因为其有着相同的页表内容而不会出现地址错位的情况。因为内核是一个进程,它也只有一个自己的页表,所以只要把自己的页表嵌入到其他进程里,所有的进程就都能够访问内核空间了(权限允许的情况下)。
最后来梳理一下过程吧:
首先为进程创建一个PCB,这个PCB里包含了进程运行的必要参数,同时还提供了进程R0权级下的栈空间。 更新进程状态为就绪,并为其建立用户虚拟地址空create_user_vaddr_bitmap 然后照常用thread_create将其初始化为线程(只做初始化操作)thread_create 再为该进程建立页表create_page_dir 最后将进程的PCB加入到调度就绪队列和总队列中即可。
在调度器选中该进程时,将会因为thread_create时设置的eip为start_process转而执行该函数。最后在该函数中完成最后的操作:
首先在其内核栈中布置iret时需要的寄存器数据。 其中,因为esp将会是用户级的栈,所以另外为其开辟一页内存(get_a_page ),然后在SS中赋予栈权级为R3 最后将esp转到布置好的内核栈位置,然后跳转到intr_exit正常返回
但不知道是本书作者的遗漏还是没注意到,总觉得start_process 函数有些问题。
该函数最后一行直接将esp切换到了用户内核栈,但是,应该如何恢复自己的esp呢?直接mov的操作不会导致自己的esp值丢失吗?
这才发现之前中断中使用的switch_to函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 switch_to: push esi push edi push ebx push ebp mov eax, [esp + 20] ; 得到栈中的参数cur, cur = [esp+20] mov [eax], esp ; 保存栈顶指针esp. task_struct的self_kstack字段, mov eax, [esp + 24] ; 得到栈中的参数next, next = [esp+24] mov esp, [eax] ; pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针, pop ebp pop ebx pop edi pop esi ret ; 返回到上面switch_to下面的那句注释的返回地址, ; 未由中断进入,第一次执行时会返回到kernel_thread
在执行start_process函数之前,会先把当前esp保存到PCB中,然后再进行切换。
之后,在start_process函数中所做的“遗弃”似的操作就成了无关紧要的事情了。
保存当前esp以后,esp已经切换为了用户进程的内核栈,然后在start_process中进行任何操作对esp有任何影响都无关紧要了,因为这里面的数据从此以后都不再需要了。之后需要用到内核栈的时候从TSS里加载即可。
另外还有这么一个事实:
用户进程毕竟是用户态的程序,它大多的事情应该是在用户态中进行的。那么从R3到R0再到R3的过程以后,R0级的栈里应该仍然是空的,因为所有编译器都会保证push和pop的数量相等,相当于从R3通过call进到R0一样,回来的时候同样会把R0的栈中数据释放。所以每次加载TSS的ESP都会有相同的结果,即栈底。
(不过就我个人来说,对这个事实还是有点难以释然,但这毕竟是说得通的,如果以后有更好的答案了再来补充吧)
插画ID:74657806