lagrange's blog

what's dead may never die

0%

Lab4 实验报告

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
//由于进程数量可能较大,倘若从头向后遍历查找符合某个状态的PCB,则效率会十分低下
//因此使用了哈希表作为遍历所用的数据结构。
static list_entry_t hash_list[HASH_LIST_SIZE];

//进程调度时用这个链表顺着索引
list_entry_t proc_list;

enum proc_state {
PROC_UNINIT = 0, // 未初始化的 -- alloc_proc
PROC_SLEEPING, // 等待状态 -- try_free_pages, do_wait, do_sleep
PROC_RUNNABLE, // 就绪/运行状态 -- proc_init, wakeup_proc,
PROC_ZOMBIE, // 僵死状态 -- do_exit
};
struct context { // 保存的上下文寄存器,注意没有eax寄存器和段寄存器
uint32_t eip;
uint32_t esp;
uint32_t ebx;
uint32_t ecx;
uint32_t edx;
uint32_t esi;
uint32_t edi;
uint32_t ebp;
};

/**
* 进程控制块结构(ucore进程和线程都使用proc_struct进行管理)
* */
struct proc_struct {
enum proc_state state; // 当前进程的状态
int pid; // 进程ID
int runs; // 当前进程被调度的次数
uintptr_t kstack; // 内核栈
volatile bool need_resched; // 是否需要被调度
struct proc_struct *parent; // 父进程ID
struct mm_struct *mm; // 当前进程所管理的虚拟内存页,包括其所属的页目录项PDT
struct context context; // 保存的上下文
struct trapframe *tf; // 中断所保存的上下文
uintptr_t cr3; // 页目录表的地址
uint32_t flags; // 当前进程的相关标志
char name[PROC_NAME_LEN + 1]; // 进程名称(可执行文件名)
list_entry_t list_link; // 用于连接list 切进程用的那个
list_entry_t hash_link; // 用于连接hash list 链在hash表上的那个
};

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
// 分配一个proc_struct结构
if ((idleproc = alloc_proc()) == NULL)
panic("cannot alloc idleproc.\n");
// 该空闲进程作为第一个进程,pid为0
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; //那大堆push后的pushal压进去的
uint16_t tf_gs; //跳转到__alltraps后那一大堆push压进去的
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;
/* below here defined by x86 hardware */这些东西是CPU切换到内核栈的时候自动压进去的
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding4;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
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; // stack pointers and segment
uint16_t ts_ss0; // after an increase in
uint16_t ts_iomb; // i/o map base address
.......
}

static struct taskstate ts = {0};

/* gdt_init - initialize the default GDT and TSS */
static void
gdt_init(void) {
// set boot kernel stack and default SS0
ts.ts_esp0 = (uintptr_t)bootstacktop;
ts.ts_ss0 = KERNEL_DS;//段基址寄存器 13位索引+1位T1+2位CPL

// initialize the TSS filed of the gdt
gdt[SEG_TSS] = SEGTSS(STS_T32A, (uintptr_t)&ts, sizeof(ts), DPL_KERNEL);

// reload all segment registers
lgdt(&gdt_pd);

// load the TSS
//一个gdt表项八个字节,所以GD_TSS = ((SEG_TSS) << 3) = SEG_TSS*8 TR寄存器里面存的是偏移
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
// setup_kstack - alloc pages with size KSTACKPAGE as process kernel stack
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;
// 设置ring0的内核栈地址
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
// 创建init的主线程
int pid = kernel_thread(init_main, "Hello world!!", 0);
if (pid <= 0) {
panic("create init_main failed.\n");
}
// 通过pid 查找proc_struct
initproc = find_proc(pid);
set_proc_name(initproc, "init");


int
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
struct trapframe tf;
//在当前栈上临时分配一个tf,随后这个值会被存到新建的内核栈里
memset(&tf, 0, sizeof(struct trapframe));
tf.tf_cs = KERNEL_CS;
tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS;
// ebx = fn
tf.tf_regs.reg_ebx = (uint32_t)fn;
// edx = arg
tf.tf_regs.reg_edx = (uint32_t)arg;
// eip = kernel_thread_entry
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;

// 首先分配一个PCB
if ((proc = alloc_proc()) == NULL)
goto fork_out;
// fork肯定存在父进程,所以设置子进程的父进程
proc->parent = current;
// 分配内核栈(2页大小)
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);
// 将子进程的PCB添加进hash list或者list
// 需要注意的是,不能让中断处理程序打断这一步操作
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);
// 返回子进程的pid
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 指针指向的进程与实际使用的进程资源不一致。