lagrange's blog

what's dead may never die

0%

Lab5 实验报告

Lab5

中断

根据 8086 Intel 的官方说明书有:

When the processor performs a call to the exception- or interrupt-handler procedure(P198):

  • If the handler procedure is going to be executed at a numerically lower privilege level, a stack switch occurs.
    When the stack switch occurs: 1. The segment selector and stack pointer for the stack to be used by the handler are obtained from the TSS for the currently executing task. On this new stack, the processor pushes the stack segment selector and stack pointer of the interrupted procedure. 2. The processor then saves the current state of the EFLAGS, CS, and EIP registers on the new stack (see Figures 6-4). 3. If an exception causes an error code to be saved, it is pushed on the new stack after the EIP value.
  • If the handler procedure is going to be executed at the same privilege level as the interrupted procedure:
    1. The processor saves the current state of the EFLAGS, CS, and EIP registers on the current stack (see Figures 6-4).
    2. If an exception causes an error code to be saved, it is pushed on the current stack after the EIP value.

Figures 6-4

也就是说只有跨 ring 的时候,CPU 才会从 TR 寄存器里找 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
void
trap(struct trapframe *tf) {
// dispatch based on what type of trap occurred
// 向前面的实验兼容,基本用不到
if (current == NULL) {
trap_dispatch(tf);
}
else {
// keep a trapframe chain in stack
struct trapframe *otf = current->tf;
current->tf = tf;
bool in_kernel = trap_in_kernel(tf);
trap_dispatch(tf);
current->tf = otf;
if (!in_kernel) {
// 该进程需要退出的话就退出
if (current->flags & PF_EXITING) {
do_exit(-E_KILLED);
}
// 需要调度的话就调度
if (current->need_resched) {
schedule();
}
}
}
}

ucore允许中断嵌套。如果是用户态,有可能会出现第一次中断从用户态蹦到内核态,第二次在内核态又引起了一次中断,第一次因为跨了 ring 被切换到内核栈里,第二次没有跨 ring 所以又在原来内核栈运行到的地方再压了一层中断帧,所以可以形成两个栈帧,trap_in_kernel 通过判断当前的被保存的CS段是否是 KERNEL_CS 来判断中断是否发生在内核。

其次这应该是一个递归结构,在第 n 层处理完中断后,把当前的中断设置为 n-1 层的中断,再判断一下是否需要调度,不需要的话就返回上面一层 trap 接着处理(这种判断只发生在用户态蹦到内核态的那次中断,因为只有那个时候CS段是用户的代码段,防止在内核的时候也被抢占,导致内核线程的竞争,内核态发生的中断直接蹦到上一层中断处理)。

这个结构同时也实现了进程的抢占,因为在 trap_dispatch 中,若引发时钟中断到100次,则会把 current->need_resched 修改为 1 ,当函数返回的时候就知道需要进行 schedule 让出时间片。

总结一下,其实下面 if 判断的执行条件很苛刻,大部分的中断处理只是不停递归的完成 trap 函数。只有当引发的是时钟中断,且次数到了 100 次,且是从内核返回用户态的时候。这个时候 ucore 可以清晰的知道该进程运行了较久时间,需要切时间片给其他进程。

1
2
3
4
5
6
7
if (!in_kernel) {
……

if (current->need_resched) {
schedule();
}
}

这里表明了只有当进程在用户态执行到“任意”某处用户代码位置时发生了中断,且当前进程控制块成员变量 need_resched 为1(表示需要调度了)时,才会执行 shedule 函数。这实际上体现了对用户进程的可抢占性。如果没有第一行的 if 语句,那么就可以体现对内核代码的可抢占性。但如果要把这一行 if 语句去掉,我们就不得不实现对 ucore 中的所有全局变量的互斥访问操作,以防止所谓的 racecondition 现象,这样 ucore 的实现复杂度会增加不少。

首先在执行某进程A的用户代码时,出现了一个 trap (例如是一个外设产生的中断),这个时候就会从进程A的用户态切换到内核态(过程(1)),并且保存好进程A的trapframe;当内核态处理中断时发现需要进行进程切换时,ucore要通过schedule函数选择下一个将占用CPU执行的进程(即进程B),然后会调用proc_run函数,proc_run函数进一步调用switch_to函数,切换到进程B的内核态(过程(2)),继续进程B上一次在内核态的操作,并通过iret指令,最终将执行权转交给进程B的用户空间(过程(3))。

当进程B由于某种原因发生中断之后(过程(4)),会从进程B的用户态切换到内核态,并且保存好进程B的trapframe;当内核态处理中断时发现需要进行进程切换时,即需要切换到进程A,ucore再次切换到进程A(过程(5)),会执行进程A上一次在内核调用schedule (具体还要跟踪到 switch_to 函数)函数返回后的下一行代码,这行代码当然还是在进程A的上一次中断处理流程中。最后当进程A的中断处理完毕的时候,执行权又会反交给进程A的用户代码(过程(6))。这就是在只有两个进程的情况下,进程切换间的大体流程。

GCC 内联汇编

GCC 序号占位符介绍: GCC 规定,一个内联汇编语句中最多只能有 10 个 Input/Output 操作表达式,这些操作表达式按照他们被列出来的顺序依次赋予编号 0 到 9;对于占位符中的数字而言,与这些编号是对应的;比如:占位符%0 对应编号为 0 的操作表达式,占位符%1 对应编号为 1 的操作表达式,依次类推;GCC 对占位符进行编译的时候,会将每一个占位符替换为对应的 Input/Output 操作表达式所指定的寄存器/内存/立即数;

例如:

1
__asm__("addl %1,%0\n\t":"=a"(__out):"m"(__in1),"a"(__in2));

这个语句中,%0对应 Output 操作表达式"=a"(__out),而"=a"(__out)指定的寄存器是%eax,所以,占位符%0被替换为%eax;占位符%1对应 Input 操作表达式"m"(__in1),而"m"(__in1)被指定为内存,所以,占位符%1被替换位__in1的内存地址;
用一句话描述:序号占位符就是前面描述的%0、%1、%2、%3、%4、%5、%6、%7、%8、%9;其中,每一个占位符对应一个 Input/Output 的 C/C++表达式;

根据上面的背景知识,可以知道下面 uCore 用来进行系统调用的内联汇编的具体含义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// kernel_execve - do SYS_exec syscall to exec a user program called by user_main kernel_thread
static int
kernel_execve(const char *name, unsigned char *binary, size_t size)
{
int ret, len = strlen(name);
asm volatile(
"int %1;" // %1对应第2个Input/Output操作表达式(从0开始数),第一个是输出,第二个就是第一个输入(使用立即数的T_SYSCALL)
: "=a"(ret) //等号(=)说明圆括号中的表达式是一个只写的表达式,只能被用作当前内联汇编语句的输出,而不能作为输入。中断结束后返回值放在eax 转交给ret
: "i"(T_SYSCALL), "0"(SYS_exec), "d"(name), "c"(len), "b"(binary), "D"(size)
//i:表示使用一个整数类型的立即数 edx ecx ebx edi
//0表示第一个表达式(output)用的寄存器就是eax
: "memory"); //对内存做了改动
return ret;
}

通过引发 int 0x80 中断,并将中断调用号存入%eax,从而在 trap_dispatch 中的 switch 语句中进入 syscall() 调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void
syscall(void) {
struct trapframe *tf = current->tf;
uint32_t arg[5];
int num = tf->tf_regs.reg_eax;
if (num >= 0 && num < NUM_SYSCALLS) {
if (syscalls[num] != NULL) {
arg[0] = tf->tf_regs.reg_edx;
arg[1] = tf->tf_regs.reg_ecx;
arg[2] = tf->tf_regs.reg_ebx;
arg[3] = tf->tf_regs.reg_edi;
arg[4] = tf->tf_regs.reg_esi;
//传入参数调用,并把返回值放在 reg_eax 中
tf->tf_regs.reg_eax = syscalls[num](arg);
return ;
}
}
}

exec / fork

shell 原理

这是一份 shell 的简化版代码(CSAPP:524):

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
int main()
{
char cmdline[MAXLINE]; /* Command line */

while (1)
{
/* Read */
printf("> ");
//获取命令行输入
Fgets(cmdline, MAXLINE, stdin);
if (feof(stdin))
exit(0);

/* Evaluate */
eval(cmdline);
}
}
/* $end shellmain */

/* $begin eval */
/* eval - Evaluate a command line */
void eval(char *cmdline)
{
char *argv[MAXARGS]; /* Argument list execve() */
char buf[MAXLINE]; /* Holds modified command line */
// 是否以 & 结尾
int bg; /* Should the job run in bg or fg? */
pid_t pid; /* Process id */

strcpy(buf, cmdline);
bg = parseline(buf, argv);

if (!builtin_command(argv))
{
if ((pid = Fork()) == 0)
{ /* Child runs user job */
if (execve(argv[0], argv, environ) < 0)
{
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}

/* Parent waits for foreground job to terminate */
if (!bg)
{
int status;
if (waitpid(pid, &status, 0) < 0)
unix_error("waitfg: waitpid error");
}
else
printf("%d %s", pid, cmdline);
}
return;
}

它的首要任务是调用 parseline 函数,这个函数解析了以空格分隔的命令行参数,并构造最终会传递给 execve 的 argv 向量。

第一个参数被假设为要么是一个内置的 shell 命令名,马上就会解释这个命令,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
如果最后一个参数是一个“&”字符,那么 parseline 返回 1,表示应该在后台执行
该程序(shell 不会等待它完成)。否则,它返回 0,表示应该在前台执行这个程序(shell 会等待它完成)。

在解析了命令行之后,eval 函数调用 builtin_command 函数,该函数检查第一个命令行参数是否是一个内置的 shell 命令。如果是,它就立即解释这个命令,并返回值 1。否则返回 0。

shell 有大量的命令,比如 pwd、jobs 和 fg。如果 builtin_command 返回 0, 那么 shell 创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么 shell 返回到循环的顶部,等待下一个命令行。否则,shell 使用 waitpid 函数等待作业终止。当作业终止时,shell 就开始下一轮迭代。

简而言之就是先使用 fork 调用,fork 出一个子进程,再让子进程执行 exec 调用运行程序,根据是否有 & 来决定是否显式的去调用 wait 等待该进程终止。

fork

根据上面的调用顺序,首先看 fork 调用时都执行了哪些内容:

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
static int
sys_fork(uint32_t arg[]) {
struct trapframe *tf = current->tf;
uintptr_t stack = tf->tf_esp;
return do_fork(0, stack, 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) //为进程结构分配空间(slab)
goto fork_out;
proc->parent = current; //设置其父进程为调用进程
if (setup_kstack(proc) != 0) //新分配一个大小为两页的内核栈
goto bad_fork_cleanup_proc;

//设置新进程的页表,如果clone_flags
//CLONE_VM : 父子进程共享页表 直接设置子进程的页表指向父进程
//否则调用dup_mmap进行页表的复制
if (copy_mm(clone_flags, proc) != 0)
goto bad_fork_cleanup_kstack;

//参考Lab4的分析,修改contxt的eip在后面wakeup这个进程后去执行forkret
//之前内核进程强改中断帧eip,最后去执行了内核进程被要求执行的函数
//这没改eip 可认为最后该fork进程会返回到原来调用进程相同的地方
copy_thread(proc, stack, tf);

bool intr_flag;
local_intr_save(intr_flag);
{
// 把新进程链到表上
proc->pid = get_pid();
hash_proc(proc);
set_links(proc);
}
local_intr_restore(intr_flag);

// 修改进程状态设置为可执行,不是直接就调用这进程,只是到就绪态等待os调度
wakeup_proc(proc);

// 返回fork进程的 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; // 注意到这的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);
}

fork 函数的定义是父进程返回创建子进程的 pid,子进程中 fork 返回 0。这个逻辑在上面的函数中也得到了印证,因为中断结束的时候返回了 proc->pid,在设置 copy_thread 中的中断帧的时候吧 reg_eax 设置为 0。

exec

在 fork 调用结束后,子进程会调用 exec 执行新的进程,简要来说 exec 的执行过程就是把原来子进程的除了 pid,内核栈等属于子进程自己私有的东西保留。其他从父进程复制过来的的东西全部释放,并读取 ELF 格式的文件建立新的页表,vma。

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
static int
sys_exec(uint32_t arg[]) {
const char *name = (const char *)arg[0];
size_t len = (size_t)arg[1];
unsigned char *binary = (unsigned char *)arg[2];
size_t size = (size_t)arg[3];
return do_execve(name, len, binary, size);
}

int do_execve(const char *name, size_t len, unsigned char *binary, size_t size)
{
struct mm_struct *mm = current->mm;
// 检查载入的程序是否是在用户空间里
if (!user_mem_check(mm, (uintptr_t)name, len, 0))
return -E_INVAL;
if (len > PROC_NAME_LEN)
len = PROC_NAME_LEN;
// 处理进程名称字符串
char local_name[PROC_NAME_LEN + 1];
memset(local_name, 0, sizeof(local_name));
memcpy(local_name, name, len);

// 把页表换成内核空间的,并释放之前的页表
//(因为可能有父子进程共享页表的情况,所以先设置一下mm的引用计数,确定没人用再删)
if (mm != NULL)
{
lcr3(boot_cr3);
if (mm_count_dec(mm) == 0)
{
exit_mmap(mm);
put_pgdir(mm);
mm_destroy(mm);
}
current->mm = NULL;
}
int ret;
//载入ELF文件,设置好mm、vma、用户栈、页表
if ((ret = load_icode(binary, size)) != 0)
{
goto execve_exit;
}
set_proc_name(current, local_name);
return 0;

execve_exit:
do_exit(ret);
panic("already exit: %e.\n", ret);
}

tatic int
load_icode(unsigned char *binary, size_t size)
{
if (current->mm != NULL)
panic("load_icode: current->mm must be empty.\n");
int ret = -E_NO_MEM;
struct mm_struct *mm;
//(1) create a new mm for current process
if ((mm = mm_create()) == NULL)
goto bad_mm;
//建个新的PDT, 并把内核上3G地址页目录表拷过去
if (setup_pgdir(mm) != 0)
goto bad_pgdir_cleanup_mm;
//(3) copy TEXT/DATA section, build BSS parts in binary to memory space of process
struct Page *page;
//(3.1) get the file header of the bianry program (ELF format)
struct elfhdr *elf = (struct elfhdr *)binary;
//(3.2) get the entry of the program section headers of the bianry program (ELF format)
struct proghdr *ph = (struct proghdr *)(binary + elf->e_phoff);
//(3.3) This program is valid?
if (elf->e_magic != ELF_MAGIC)
{
ret = -E_INVAL_ELF;
goto bad_elf_cleanup_pgdir;
}

uint32_t vm_flags, perm;
struct proghdr *ph_end = ph + elf->e_phnum;
for (; ph < ph_end; ph++)
{
//(3.4) find every program section headers
//ELF_PT_LOAD表示一个可加载的段,段的大小由 p_filesz 和 p_memsz 描述。
if (ph->p_type != ELF_PT_LOAD)
continue;
if (ph->p_filesz > ph->p_memsz)
{
ret = -E_INVAL_ELF;
goto bad_cleanup_mmap;
}
if (ph->p_filesz == 0)
continue;
//(3.5) call mm_map fun to setup the new vma ( ph->p_va, ph->p_memsz)
// vma 应该是一个段一个(一并记录该段的读写等权限) 并且页表权限一开始就与了PTE_U,让用户有权限看到自己的页表
vm_flags = 0, perm = PTE_U;
if (ph->p_flags & ELF_PF_X)
vm_flags |= VM_EXEC;
if (ph->p_flags & ELF_PF_W)
vm_flags |= VM_WRITE;
if (ph->p_flags & ELF_PF_R)
vm_flags |= VM_READ;
if (vm_flags & VM_WRITE)
perm |= PTE_W;
//p_vaddr 段在内存中的虚拟地址
//查看这段在vma中是否存在,不存在新建一个vma并插到mm里
if ((ret = mm_map(mm, ph->p_va, ph->p_memsz, vm_flags, NULL)) != 0)
goto bad_cleanup_mmap;

//下面开始向OS申请空间,复制ELF的内容到虚拟空间中,并填好对应的页表
unsigned char *from = binary + ph->p_offset;
size_t off, size;
uintptr_t start = ph->p_va, end, la = ROUNDDOWN(start, PGSIZE);
ret = -E_NO_MEM;
//(3.6) alloc memory, and copy the contents of every program section (from, from+end) to process's memory (la, la+end)
end = ph->p_va + ph->p_filesz;
//(3.6.1) copy TEXT/DATA section of bianry program
while (start < end)
{
//根据la的地址,获取对应的一页内存,不存在申请一页并挂到页目录上
if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL)
{
goto bad_cleanup_mmap;
}
off = start - la, size = PGSIZE - off, la += PGSIZE;
if (end < la)
{
size -= la - end;
}
memcpy(page2kva(page) + off, from, size);
start += size, from += size;
}

//(3.6.2) build BSS section of binary program
end = ph->p_va + ph->p_memsz;
if (start < la)
{
/* ph->p_memsz == ph->p_filesz */
if (start == end)
{
continue;
}
off = start + PGSIZE - la, size = PGSIZE - off;
if (end < la)
{
size -= la - end;
}
memset(page2kva(page) + off, 0, size);
start += size;
assert((end < la && start == end) || (end >= la && start == la));
}
while (start < end)
{
if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL)
{
goto bad_cleanup_mmap;
}
off = start - la, size = PGSIZE - off, la += PGSIZE;
if (end < la)
{
size -= la - end;
}
memset(page2kva(page) + off, 0, size);
start += size;
}
}

// 设置用户栈
vm_flags = VM_READ | VM_WRITE | VM_STACK;
// 同样建一个vma,插到 mm 里(vma只是记录每段的起始位置(虚拟地址)和权限,真正找到物理地址还得看段表)
if ((ret = mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)) != 0)
goto bad_cleanup_mmap;

//(5) set current process's mm, sr3, and set CR3 reg = physical addr of Page Directory
mm_count_inc(mm);
current->mm = mm;
current->cr3 = PADDR(mm->pgdir);
lcr3(PADDR(mm->pgdir));

//(6) setup trapframe for user environment
struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));
//在这一步设置用户权限,使得返回后能够回到用户状态
tf->tf_cs = USER_CS;
tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
tf->tf_esp = USTACKTOP;
tf->tf_eip = elf->e_entry;
tf->tf_eflags = FL_IF;
ret = 0;
out:
return ret;
bad_cleanup_mmap:
exit_mmap(mm);
bad_elf_cleanup_pgdir:
put_pgdir(mm);
bad_pgdir_cleanup_mm:
mm_destroy(mm);
bad_mm:
goto out;
}

解释一下 p_filesz 与 p_memsz 的含义:p_filesz 代表这个段在 ELF 文件中的大小 p_memsz 代表这个段在内存中的大小。p_filesz<=p_memsz,因为存在 BSS 段。BSS 段里面放置的都是未初始化的全局变量,这些东西如果全部放在 ELF 中既不能记录数据(因为没初始化),还会浪费存储,就设置了这两个长度,p_memsz-p_filesz 就是 BSS 段的长度,把这块区域也在内存中放出来,然后初始化为 0 就行。

参考:
ELF 文件格式解析 > BSS 段解析

wait

调用完 fork,exec 后,一个新的进程就跑了起来,该进程的父进程是依赖于启动他的 shell,是否有&则决定了是否 wait。下面来看 wait 的实现。

在这个 lab 里 proc 的关系变得更加丰富:

1
2
3
4
5
6
7
8
9
10
11
                     +----------------+
| parent process |
+----------------+
parent ^ \ ^ parent
/ \ \
/ \ cptr \
/ yptr V \ yptr
+-------------+ --> +-------------+ --> NULL
| old process | | New Process |
NULL <-- +-------------+ <-- +-------------+
optr optr

下面是 linux 中对 wait 的定义

wait_pid meaning
< -1 meaning wait for any child process whose process group ID is equal to the absolute value of pid.
-1 meaning wait for any child process.
0 meaning wait for any child process whose process group ID is equal to that of the calling process
> 0 meaning wait for the child whose process ID is equal to the value of pid.

do_wait 程序会使某个进程一直等待,直到(特定)子进程退出后,该进程才会回收该子进程的资源并函数返回。该函数的具体操作如下:

检查当前进程所分配的内存区域是否存在异常。查找特定/所有子进程中是否存在某个等待父进程回收的子进程(PROC_ZOMBIE)。

如果有,则回收该进程并函数返回。

如果没有,则设置当前进程状态为 PROC_SLEEPING 并执行 schedule 调度其他进程运行。当该进程的某个子进程结束运行后,当前进程会被唤醒,并在 do_wait 函数中回收子进程的 PCB 内存资源。

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
static int
sys_wait(uint32_t arg[]) {
int pid = (int)arg[0];
int *store = (int *)arg[1];
return do_wait(pid, store);
}

int
do_wait(int pid, int *code_store) {
struct mm_struct *mm = current->mm;
// code_store 应该是 NULL
if (code_store != NULL) {
if (!user_mem_check(mm, (uintptr_t)code_store, sizeof(int), 1)) {
return -E_INVAL;
}
}

struct proc_struct *proc;
bool intr_flag, haskid;
repeat:
haskid = 0;
// 按照上面的参数定义 pid 不为 0(不实现<0的情况,默认pid>=0)
// 表示等待特定子进程终止
if (pid != 0) {
// 找到那个子进程,确定存在父子关系
// 若子进程的状态为 ZOMBIE 直接跳到found回收子进程资源
// ZOMBIE 按照exit的定义自己已经回收了mm,等待父进程回收PCB
proc = find_proc(pid);
if (proc != NULL && proc->parent == current) {
haskid = 1;
if (proc->state == PROC_ZOMBIE)
goto found;
}
}
//循环找到该父进程下的所有子进程
else {
proc = current->cptr;
for (; proc != NULL; proc = proc->optr) {
haskid = 1;
// 查到一个 ZOMBIE 子进程直接回收
if (proc->state == PROC_ZOMBIE)
goto found;
}
}
// 查到有要等待的的子进程,设置父进程的状态为等待,进行CPU调度
if (haskid) {
current->state = PROC_SLEEPING;
current->wait_state = WT_CHILD;
schedule();
// 一直陷在内核态,等待子进程将其唤醒,继续执行下面的函数释放子进程资源
// 重复直到没有进程资源需要释放
if (current->flags & PF_EXITING)
do_exit(-E_KILLED);
goto repeat;
}
return -E_BAD_PROC;

// 释放子进程资源,就是子进程exit时回收不了的PCB
found:
if (proc == idleproc || proc == initproc)
panic("wait idleproc or initproc.\n");
if (code_store != NULL)
*code_store = proc->exit_code;
local_intr_save(intr_flag);
{
unhash_proc(proc);
remove_links(proc);
}
local_intr_restore(intr_flag);
put_kstack(proc);
kfree(proc);
return 0;
}

exit

为什么上面的父进程可以在执行 schedule()切换到其他进程后还能拿到 CPU 的控制权从而释放子进程资源的原因就在 do_exit 函数

函数与 do_execve/do_wait 函数中的进程回收代码类似,但又有所不同。其具体操作如下:

  • 回收所有内存(除了 PCB,该结构只能由父进程回收)
  • 设置当前的进程状态为 PROC_ZOMBIE
  • 设置当前进程的退出值 current->exit_code。
  • 如果有父进程,则唤醒父进程,使其准备回收该进程的 PCB。
  • 正常情况下,除了 initproc 和 idleproc 以外,其他进程一定存在父进程。
  • 如果当前进程存在子进程,则设置所有子进程的父进程为 initproc。这样倘若这- 些子进程进入结束状态,则 initproc 可以代为回收资源。
  • 执行进程调度。一旦调度到当前进程的父进程,则可以马上回收该终止进程的 PCB。
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
// do_exit - called by sys_exit
// 1. call exit_mmap & put_pgdir & mm_destroy to free the almost all memory space of process
// 2. set process' state as PROC_ZOMBIE, then call wakeup_proc(parent) to ask parent reclaim itself.
// 3. call scheduler to switch to other process
int do_exit(int error_code)
{
if (current == idleproc)
{
panic("idleproc exit.\n");
}
if (current == initproc)
{
panic("initproc exit.\n");
}

struct mm_struct *mm = current->mm;
// 释放当前进程 mm
if (mm != NULL)
{
lcr3(boot_cr3);
if (mm_count_dec(mm) == 0)
{
exit_mmap(mm);
put_pgdir(mm);
mm_destroy(mm);
}
current->mm = NULL;
}

// 设置当前进程状态为 ZOMBIE 等待父进程回收
current->state = PROC_ZOMBIE;
current->exit_code = error_code;

bool intr_flag;
struct proc_struct *proc;
local_intr_save(intr_flag);
{
proc = current->parent;
// 在这步中会唤起等待子进程结束的父进程来回收资源
if (proc->wait_state == WT_CHILD)
wakeup_proc(proc);

//当前进程存在子进程,则设置所有子进程的父进程为 initproc。这样倘若这- 些子进程进入结束状态,则 initproc 可以代为回收资源。
while (current->cptr != NULL)
{
proc = current->cptr;
current->cptr = proc->optr;
proc->yptr = NULL;
if ((proc->optr = initproc->cptr) != NULL)
initproc->cptr->yptr = proc;
proc->parent = initproc;
initproc->cptr = proc;
if (proc->state == PROC_ZOMBIE)
if (initproc->wait_state == WT_CHILD)
wakeup_proc(initproc);
}
}
local_intr_restore(intr_flag);

schedule();
panic("do_exit will not return!! %d.\n", current->pid);
}

页级保护

页目录和页表表项中的读写标志 R/W 和用户/超级用户标识 U/S 提供了分段机制保护属性的一个子集。分页机制只识别两级权限。特权级 0、1 和 2 被归类为超级用户级,而特权级 3 被称为普通用户级。普通用户级的页面可以被标志成只读/可执行或可读/可写/可执行。超级用户级的页面 对于超级用户来说总是可读/可写/可执行的,但普通用户不可访问。 对于分段机制,在最外层用户级执行的程序只能访问用户级的页面,但是在任何超级用户层(0、1、2)执行的程序 不仅可以访问用户层的页面,也可以访问超级用户层的页面。与分段机制不同的是,在内层超级用户级执行的程序对任何 页面都具有可读/可写/可执行权限,包括那些在用户级标注为只读/可执行的页面。

P–位 0 是存在(Present)标志,用于指明表项对地址转换是否有效。P=1 表示有效;P=0 表示无效。在页转换过程中,如果说涉及的页目录或页表的表项无效,则会导致一个异常。如果 P=0,那么除表示表项无效外,其余位可供程序自由使用,如图 4-18b 所示。例如,操作系统可以使用这些位来保存已存储在磁盘上的页面的序号。

R/W–位 1 是读/写(Read/Write)标志。如果等于 1,表示页面可以被读、写或执行。如果为 0,表示页面只读或可执行。当处理器运行在超级用户特权级(级别 0、1 或 2)时,则 R/W 位不起作用。页目录项中的 R/W 位对其所映射的所有页面起作用。

U/S–位 2 是用户/超级用户(User/Supervisor)标志。如果为 1,那么运行在任何特权级上的程序都可以访问该页面。如果为 0,那么页面只能被运行在超级用户特权级(0、1 或 2)上的程序访问。页目录项中的 U/S 位对其所映射的所有页面起作用。

ucore 通过上述机制实现对内核的保护。注意在 pmm.c 函数 boot_map_segment 中映射内核地址的页表的时候。对每个页表项没有设置 PTE_U 这一位,因为这个时候还在操作系统内核里面,用的段选择子还是内核态的,所以不需要用户权限也能够访问页表。

1
2
3
4
5
6
7
8
9
10
11
12
13
boot_map_segment(boot_pgdir, KERNBASE, KMEMSIZE, 0, PTE_W);

boot_map_segment(pde_t *pgdir, uintptr_t la, size_t size, uintptr_t pa, uint32_t perm) {
assert(PGOFF(la) == PGOFF(pa));
size_t n = ROUNDUP(size + PGOFF(la), PGSIZE) / PGSIZE;
la = ROUNDDOWN(la, PGSIZE);
pa = ROUNDDOWN(pa, PGSIZE);
for (; n > 0; n --, la += PGSIZE, pa += PGSIZE) {
pte_t *ptep = get_pte(pgdir, la, 1);
assert(ptep != NULL);
*ptep = pa | PTE_P | perm;
}
}

但当用户态切到内核态执行 exec 调用时,setup_pgdir 会在该进程新建立的 mm 中复制内核的页表(do_execve->load_icode->setup_pgdir)

1
2
3
4
5
6
7
8
9
10
11
12
static int
setup_pgdir(struct mm_struct *mm)
{
struct Page *page;
if ((page = alloc_page()) == NULL)
return -E_NO_MEM;
pde_t *pgdir = page2kva(page);
memcpy(pgdir, boot_pgdir, PGSIZE);
pgdir[PDX(VPT)] = PADDR(pgdir) | PTE_P | PTE_W;
mm->pgdir = pgdir;
return 0;
}

在随后 load_icode 执行加载 ELF 文件时会不断的去扩充之前写好的页表(之前复制内核的页表,该页表只有上 3G 有地址对应,下面全是空的,根据 ELF 段的位置把他加载到下 3G 的虚拟内存中,并填好页表),这个时候会设置 PTE_U 这一位使用户态的程序可见。

像这样就完成了对内核空间的映射和保护,每个进程都复制了内核代码的位置,但是处在用户态的时候没有足够的权限看见,但当引发系统中断时。会把段子换成内核的段,就可以执行内核态的代码了。

1
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);