Lab4 数据结构 进程控制块 PCB 在 ucore 中,并不显式的区分进程与线程,都使用同样的数据结构 proc_struct 进程/线程管理块进行管理。当不同的线程控制块对应的页表(cr3)相同时,ucore 认为是同一进程下的不同线程。
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 32 33 34 35 36 37 38 39 40 41 42 43 static list_entry_t hash_list[HASH_LIST_SIZE];list_entry_t proc_list;enum proc_state { PROC_UNINIT = 0 , PROC_SLEEPING, PROC_RUNNABLE, PROC_ZOMBIE, }; struct context { uint32_t eip; uint32_t esp; uint32_t ebx; uint32_t ecx; uint32_t edx; uint32_t esi; uint32_t edi; uint32_t ebp; }; struct proc_struct { enum proc_state state ; int pid; int runs; uintptr_t kstack; volatile bool need_resched; struct proc_struct *parent ; struct mm_struct *mm ; struct context context ; struct trapframe *tf ; uintptr_t cr3; uint32_t flags; char name[PROC_NAME_LEN + 1 ]; list_entry_t list_link; list_entry_t hash_link; };
idleproc idleproc 作为 ucore 的第一个进程,其目的就是会执行 cpu_idle 函数,并从中调用 schedule 函数,准备开始调度进程。作为第一个内核进程,可以说在它之前 ucore 所有执行的内容是没有进程的概念的,但 idleproc 出现后 ucore 后面的初始化代码都是以 idleproc 进程的名义执行。
下面是对 ucore 进行的初始化操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 if ((idleproc = alloc_proc()) == NULL ) panic("cannot alloc idleproc.\n" ); idleproc->pid = 0 ; idleproc->state = PROC_RUNNABLE; idleproc->kstack = (uintptr_t )bootstack; idleproc->need_resched = 1 ; set_proc_name(idleproc, "idle" ); nr_process++; current = idleproc;
这段初始化将 ucore 在 entry.S 设置的新的栈赋给了 idleproc->kstack,ucore 一直到现在只用过两个栈,第一个是在启动块设置段表的时候顺手把栈改成 0x7c00 处,后面是在 entry.S 中声明了两页大小的内核栈,一直用到现在。这也表明了剩下的函数以 idleproc 的名义在执行,至于其 context 上下文,在 switch_to(from, to)会被换到其 PCB 的 context 中。
TSS TSS 是一个特殊的段。在 Linux 中,CPU 从系统态切换到用户态时会用到 TSS 里面的 ss0 和 esp0。每个 CPU 只维护一个 TSS。TR 寄存器指向这个 TSS,切换时里面的 ss0 和 esp0 会有改变。相应有一个 TSS 段放在 GDT 中,是 GDT 的一个表项。
在 CPU 的中断被触发的时候,CPU 会通过 TR 寄存器中的值找到位于 GDT 表中的 TSS 段,该段指向了一个 TSS 结构体,在这个结构体中 CPU 取出里面的 ss0 和 esp0,将新的栈地址设置为 ss0 和 esp0,并将之前旧的 ss,espeflags,cs,eip 全部压到新的栈里面去。所以观察一个栈帧的结构可以发现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 struct trapframe { struct pushregs tf_regs ; uint16_t tf_gs; uint16_t tf_padding0; uint16_t tf_fs; uint16_t tf_padding1; uint16_t tf_es; uint16_t tf_padding2; uint16_t tf_ds; uint16_t tf_padding3; uint32_t tf_trapno; 这些东西是CPU切换到内核栈的时候自动压进去的 uint32_t tf_err; uintptr_t tf_eip; uint16_t tf_cs; uint16_t tf_padding4; uint32_t tf_eflags; uintptr_t tf_esp; uint16_t tf_ss; uint16_t tf_padding5; }
__alltraps 执行完成 pushal 后
1 2 3 4 5 6 7 8 9 10 # load GD_KDATA into %ds and %es to set up data segments for kernel movl $GD_KDATA, %eax movw %ax, %ds 设置新的ds、es,旧的已经被压到trapframe里了 movw %ax, %es # push %esp to pass a pointer to the trapframe as an argument to trap() pushl %esp 现在的栈顶值esp指向了一个完整的栈帧,当作参数传给trap就能够根据栈帧进行中断处理 # call trap(tf), where tf=%esp call trap
TSS 的设置 首先是在最后一次设置 GDT 表的时候
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 32 33 34 35 36 static struct segdesc gdt [] = { SEG_NULL, [SEG_KTEXT] = SEG(STA_X | STA_R, 0x0 , 0xFFFFFFFF , DPL_KERNEL), [SEG_KDATA] = SEG(STA_W, 0x0 , 0xFFFFFFFF , DPL_KERNEL), [SEG_UTEXT] = SEG(STA_X | STA_R, 0x0 , 0xFFFFFFFF , DPL_USER), [SEG_UDATA] = SEG(STA_W, 0x0 , 0xFFFFFFFF , DPL_USER), [SEG_TSS] = SEG_NULL, }; struct taskstate { ....... uintptr_t ts_esp0; uint16_t ts_ss0; uint16_t ts_iomb; ....... } static struct taskstate ts = {0 };static void gdt_init(void ) { ts.ts_esp0 = (uintptr_t )bootstacktop; ts.ts_ss0 = KERNEL_DS; gdt[SEG_TSS] = SEGTSS(STS_T32A, (uintptr_t )&ts, sizeof (ts), DPL_KERNEL); lgdt(&gdt_pd); ltr(GD_TSS); }
首先是把当前的 TSS 设置为了之前 entry.S 申请的栈底(栈向低地址处生长,那段汇编先写的 bootstack 再写的 bootstacktop,所以 bootstacktop 是高地址,作为栈底),再将 TSS 中的 ss0 换成内核栈的段选择子,最后是将 GDT 的最后一项 TSS 填充完成,在 load TR 寄存器,完成当前的 TSS 设置(就当现在执行的是 idleproc 进程的话,TSS 里面确实也是存的这个进程的内核栈地址)。
然后再每次程序切换的时候
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 static int setup_kstack(struct proc_struct *proc) { struct Page *page = alloc_pages(KSTACKPAGE); if (page != NULL ) { proc->kstack = (uintptr_t )page2kva(page); return 0 ; } return -E_NO_MEM; } void proc_run (struct proc_struct *proc) { if (proc != current) { bool intr_flag; struct proc_struct *prev = current, *next = proc; local_intr_save(intr_flag); { current = proc; load_esp0(next->kstack + KSTACKSIZE); lcr3(next->cr3); switch_to(&(prev->context), &(next->context)); } local_intr_restore(intr_flag); } } .globl switch_to switch_to: # switch_to(from, to) # save from's registers movl 4 (%esp), %eax # 获取当前进程的context结构地址 popl 0 (%eax) # 将eip保存至当前进程的context结构 movl %esp, 4 (%eax) # 将esp保存至当前进程的context结构 movl %ebx, 8 (%eax) # 将ebx保存至当前进程的context结构 movl %ecx, 12 (%eax) # 将ecx保存至当前进程的context结构 movl %edx, 16 (%eax) # 将edx保存至当前进程的context结构 movl %esi, 20 (%eax) # 将esi保存至当前进程的context结构 movl %edi, 24 (%eax) # 将edi保存至当前进程的context结构 movl %ebp, 28 (%eax) # 将ebp保存至当前进程的context结构 # restore to's registers movl 4 (%esp), %eax # 获取下一个进程的context结构地址 # 需要注意的是,其地址不是8 (%esp),因为之前已经pop过一次栈。 movl 28 (%eax), %ebp # 恢复ebp至下一个进程的context结构 movl 24 (%eax), %edi # 恢复edi至下一个进程的context结构 movl 20 (%eax), %esi # 恢复esi至下一个进程的context结构 movl 16 (%eax), %edx # 恢复edx至下一个进程的context结构 movl 12 (%eax), %ecx # 恢复ecx至下一个进程的context结构 movl 8 (%eax), %ebx # 恢复ebx至下一个进程的context结构 movl 4 (%eax), %esp # 恢复esp至下一个进程的context结构 pushl 0 (%eax) # 插入下一个进程的eip,以便于ret到下个进程的代码位置。 ret
把当前的内核栈地址换成当前执行进程的内核栈底,之所以加 KSTACKSIZE 和上面一样,proc->kstack 实际上存的是低位地址,加了 KSTACKSIZE 才是栈底,这样可以保证在每一个进程中断的时候,该进程对应的内核栈的栈底都是中断帧。
第一个内核进程的创建 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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 int pid = kernel_thread(init_main, "Hello world!!" , 0 );if (pid <= 0 ) { panic("create init_main failed.\n" ); } initproc = find_proc(pid); set_proc_name(initproc, "init" ); int kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) { struct trapframe tf ; memset (&tf, 0 , sizeof (struct trapframe)); tf.tf_cs = KERNEL_CS; tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS; tf.tf_regs.reg_ebx = (uint32_t )fn; tf.tf_regs.reg_edx = (uint32_t )arg; tf.tf_eip = (uint32_t )kernel_thread_entry; return do_fork(clone_flags | CLONE_VM, 0 , &tf); } int do_fork(uint32_t clone_flags, uintptr_t stack , struct trapframe *tf) { int ret = -E_NO_FREE_PROC; struct proc_struct *proc ; if (nr_process >= MAX_PROCESS) goto fork_out; ret = -E_NO_MEM; if ((proc = alloc_proc()) == NULL ) goto fork_out; proc->parent = current; if (setup_kstack(proc) != 0 ) goto bad_fork_cleanup_proc; if (copy_mm(clone_flags, proc) != 0 ) goto bad_fork_cleanup_kstack; copy_thread(proc, stack , tf); bool intr_flag; local_intr_save(intr_flag); { proc->pid = get_pid(); hash_proc(proc); list_add(&proc_list, &(proc->list_link)); nr_process ++; } local_intr_restore(intr_flag); wakeup_proc(proc); ret = proc->pid; fork_out: return ret; bad_fork_cleanup_kstack: put_kstack(proc); bad_fork_cleanup_proc: kfree(proc); goto fork_out; } static void copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) { proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1 ; *(proc->tf) = *tf; proc->tf->tf_regs.reg_eax = 0 ; proc->tf->tf_esp = esp; proc->tf->tf_eflags |= FL_IF; proc->context.eip = (uintptr_t )forkret; proc->context.esp = (uintptr_t )(proc->tf); }
注意,debug 可以发现:
(proc->kstack + KSTACKSIZE) - 1)
is 0xc0333fff(struct trapframe *)(proc->kstack + KSTACKSIZE) - 1)
is 0xc0333fb4
两个地址的差刚好为一个 sizeof(struct trapframe)
,这表明指针强制类型转换将存储 tf 的指针往栈底抬了一个sizeof(struct trapframe)
的大小,所以该进程对应的中断帧放在栈底是没有问题的,并没有越界。
经历完这一系列的初始化后,在schedule
函数里会选中该进程去 run,调用proc_run
中的switch_to
,保存当前的寄存器内容到 idleproc 中的 context 中,又换 initmain 对应 context 中的 eip 来执行,即执行forkret
函数(copy_thread 里设置的)
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 static void forkret(void ) { forkrets(current->tf); } .globl forkrets forkrets: # set stack to this new process's trapframe movl 4 (%esp), %esp jmp __trapret .globl __trapret __trapret: # 从中断帧中恢复所有的寄存器值 popal # restore %ds, %es, %fs and %gs popl %gs popl %fs popl %es popl %ds # get rid of the trap number and error code addl $0x8 , %esp iret
这句 iret 应该会把 tf->tf_eip 当作返回地址弹出跳转到执行 kernel_thread_entry。
1 2 3 4 5 6 7 8 9 .text. .globl kernel_thread_entry kernel_thread_entry: # void kernel_thread(void) pushl %edx # push arg call *%ebx # call fn pushl %eax # save the return value of fn(arg) call do_exit # call do_exit to terminate current thread
ebx 存了要执行的函数地址 edx 存了函数参数,调用完对应的函数后取返回值 eax,最后执行 do_exit 释放进程占用资源并退出。
tf and context struct context context:储存进程当前状态,用于进程切换中上下文的保存与恢复。
需要注意的是,与 trapframe 所保存的用户态上下文不同,context 保存的是线程的当前上下文。这个上下文可能是执行用户代码时的上下文,也可能是执行内核代码时的上下文。
struct trapframe* tf
:无论是用户程序在用户态通过系统调用进入内核态,还是线程在内核态中被创建,内核态中的线程返回用户态所加载的上下文就是struct trapframe* tf
。 所以当一个线程在内核态中建立,则该新线程就必须伪造一个 trapframe 来返回用户态。
思考一下,从用户态进入内核态会压入当时的用户态上下文 trapframe。
两者关系:以 kernel_thread 函数为例,尽管该函数设置了 proc->trapframe,但在 fork 函数中的 copy_thread 函数里,程序还会设置 proc->context。两个上下文看上去好像冗余,但实际上两者所分的工是不一样的。
进程之间通过进程调度来切换控制权,当某个 fork 出的新进程获取到了控制流后,首当其中执行的代码是 current->context->eip 所指向的代码,此时新进程仍处于内核态,但实际上我们想在用户态中执行代码,所以我们需要从内核态切换回用户态,也就是中断返回。此时会遇上两个问题:
新进程如何执行中断返回? 这就是 proc->context.eip = (uintptr_t)forkret 的用处。forkret 会使新进程正确的从中断处理例程中返回。
新进程中断返回至用户代码时的上下文为? 这就是 proc_struct->tf 的用处。中断返回时,新进程会恢复保存的 trapframe 信息至各个寄存器中,然后开始执行用户代码。
local_intr_save and local_intr_restore 语句 local_intr_save(intr_flag);….local_intr_restore(intr_flag);在这里有何作用?请说明理由。
这两句代码的作用分别是阻塞中断和解除中断的阻塞。 这两句的配合,使得这两句代码之间的代码块形成原子操作,可以使得某些关键的代码不会被打断,从而避免引起一些未预料到的错误,避免条件竞争。 以进程切换为例,在 proc_run 中,当刚设置好 current 指针为下一个进程,但还未完全将控制权转移时,如果该过程突然被一个中断所打断,则中断处理例程的执行可能会引发异常,因为 current 指针指向的进程与实际使用的进程资源不一致。