本章总算是开始我之前最关心的问题:用户进程的虚拟地址空间如何实现。
实际上在读前几章的时候就大概知道了,但还是对其具体的实现和细节方面抱有疑问,既然现在看完这章了,趁着还记得的时候留些笔记好了。

首先是关于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中记录进程本身的页表地址。

创建进程:

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实例如下:

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

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寄存器加载以后,为什么接下来的操作还能够进行,寻址不会出现问题吗?

事实上确实如此,但在为用户进程建立页表的时候,为防止此问题出现做了些微操。代码实现如下:

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函数:

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


"The unexamined life is not worth living."