实验简述
- 了解第一个用户进程创建过程
- 了解系统调用框架的实现机制
- 了解ucore如何实现系统调用 sys_fork,sys_exec,sys_exit,sys_wait 来进行进程管理
ucore在lab4中实现了进程/线程机制,能够创建并进行内核线程的调度,通过上下文的切换令线程分时的获得CPU,使得不同线程能够并发的运行
在lab5中需要更进一步,实现我们平常开发接触到的、运行在用户态的进程/线程机制
- 用户线程通常用于承载和运行应用程序,为了保护操作系统内核,避免其被不够鲁棒的应用程序破坏,应用程序都运行在 低特权级 中,无法直接访问 高特权级 的内核数据结构,也无法通过程序指令直接的访问各种外设
- 但应用程序访问高特权级数据、外设的需求是不可避免的,因此ucore在lab5中也实现了 系统调用机制 ,应用程序平常运行在用户态,在有需要时可以通过系统调用的方式间接的访问外设等受到保护的资源
在ucore lab5中,提供了一些用户态的demo应用程序,并在内核实现了诸如 fork、exit、kill、yield、wait 等等系统调用功能以及C实现的应用程序系统调用库
通过lab5的学习,可以更深入的了解操作系统中用户态应用程序的加载、运行和退出机制,以及系统调用的工作原理
系统调用
- 系统调用是操作系统提供的一种特殊api接口,底层是通过中断实现的,应用程序调用系统中断时,其CPL特权级会被暂时的提升到ring0,因此便获得了访问外设、内核数据的能力
- 这一提升CPL特权级从外层用户态到里层内核态的过程,也被称为陷入内核,系统调用会陷入内核,但是陷入内核的方式除了系统调用外,还包括触发保护异常等
- 由于系统调用是操作系统的开发人员精心设计的,且对传入的参数等等有着很严格的控制,确保了系统调用不会对内核造成破坏,同时,在系统调用中断返回时,也会将其CPL特权级对应用程序透明的还原到用户态
ELF文件结构
因为后续要分析ELF文件加载到进程的过程,所以我们先了解一下ELF文件的结构(这里我们主要关注一下执行视图)
值得注意的这一点是:
- ELF头部 和 程序头部表 是两个不同的东西
- 前者用于描述整个 ELF 文件的信息
- 后者是一个结构体数组,数组中的每个结构体元素是一个段表头(program header),每个程序头描述一个段(segment)
进程&文件&段
描述进程
proc_struct 结构体专门用于描述进程的状态:
1 | struct proc_struct { |
描述文件
当文件加载到内存时,CPU必须要执行 load_icode 函数来把该文件加载到进程,因此,load_icode 函数必须要获取文件的相关信息
elfhdr 结构体(也被称为ELF文件头,或者文件头)专门用于描述文件的信息:
1 | struct elfhdr { |
描述段
ELF文件有多个段表,每个段表都有一个用于描述信息的段表头(program header),而各个段表头又组织在程序头表中
- proghdr:专门用来描述段信息的结构体
1 | struct proghdr { |
练习0-把 lab4 的内容复制粘贴到 lab5
不过相比 lab4,lab5 新添&修改了一些内容:
proc_struct:进程控制块结构体(上文已经提过)
idt_init:对中断描述符表进行初始化
1 | static inline void |
- trap_dispatch:实现中断处理分发逻辑,也实现了对应的中断服务例程(用于处理T_SYSCALL系统调用中断)
1 |
|
- alloc_proc:分配一个 proc_struct,用于描述进程的信息
1 | static struct proc_struct * |
- do_fork:创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同,在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态
1 | int |
练习1-加载应用程序并执行
do_execv 函数调用 load_icode 来加载并解析一个处于内存中的ELF执行文件格式的应用程序
load_icode 函数主要用来将执行程序加载到进程空间(执行程序本身已从磁盘读取到内存中),这涉及到修改页表、分配用户栈等工作
load_icode 已知代码如下:
1 |
|
其他函数的代码:
- mm_create:创建一片虚拟内存,完成各个条目的初始化
1 | struct mm_struct { |
- mm_destroy:遍历并释放 vma 链表中的所有 vma,最后释放 mm
1 | void |
- setup_pgdir:申请一个页目录表
1 |
|
- mm_count_inc:设置并返回“共享该虚拟内存空间mva的进程数”
1 | static inline int |
本实验的目的就是叫我们完善 load_icode 函数,代码很长很复杂,不过我都解析完了,可以参考上面的注释
其实 load_icode 函数就只有一个地方没有完成了:为用户环境设置 trapframe
下面是实现代码:(根据提示初始化几个数值就可以了)
1 | struct trapframe *tf = current->tf; /* 构建中断帧 */ |
练习2-父进程复制自己的内存空间给子进程
创建子进程的函数 do_fork 在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制,具体是通过 copy_range 函数实现的,请补充 copy_range 的实现,确保能够正确执行
1 | int /* 将一个进程A的内存内容(start,end)复制到另一个进程B */ |
- copy_range 函数的目的是把A进程(from,父进程)的内存资源,拷贝到B进程(to,子进程)
- 它选择在循环中,以页为单位对进程内容进行复制(和 load_icode 建立段映射的逻辑很像)
- 复制需要使用 memcpy ,而这个函数需要虚拟地址,程序已经获取了对应的物理页地址,所以我们只需要调用 page2kva 即可
- 最后需要把已经复制完成的物理页添加到对应的页目录表项中,需要调用 page_insert ,而它的参数我们都已知
具体实现:
1 | int /* 将一个进程A的内存内容(start,end)复制到另一个进程B */ |
练习3-理解 fork/exec/wait/exit/syscall 的实现
- do_fork:创建当前内核线程的一个副本
- 它们的执行上下文、代码、数据都一样,但是存储位置不同,在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态(已经实现)
- 这个函数我们已经实现了,详情可以看前面代码
- do_execve:可执行程序的加载和运行
- 检查当前进程所分配的内存区域是否存在异常
- 回收当前进程的所有资源,包括已分配的内存空间/页目录表等等
- 读取可执行文件,并根据
ELFheader
分配特定位置的虚拟内存,并加载代码与数据至特定的内存地址,最后分配堆栈并设置trapframe
属性 - 设置新进程名称
- do_execve 本身完成的这4步操作都是一些简单的“边角料”,真正核心且复杂的 load_icode 在前面已经分析过了
1 | int |
- do_wait:程序会使某个进程一直等待,直到(特定)子进程退出后,该进程才会回收该子进程的资源并函数返回,该函数的具体操作如下:
- 检查当前进程所分配的内存区域是否存在异常
- 查找特定/所有子进程中是否存在某个等待父进程回收的子进程(PROC_ZOMBIE)
- 如果有,则回收该进程并函数返回
- 如果没有,则设置当前进程状态为
PROC_SLEEPING
并执行schedule
调度其他进程 - 当该进程的某个子进程结束运行后,当前进程会被唤醒,并在
do_wait
函数中回收子进程的PCB内存资源
1 | int |
- do_exit:退出操作
- 回收所有内存(除了PCB,该结构只能由父进程回收)
- 设置当前的进程状态为
PROC_ZOMBIE
- 设置当前进程的退出值
current->exit_code
- 如果有父进程,则唤醒父进程,使其准备回收该进程的PCB
- 正常情况下,除了
initproc
和idleproc
以外,其他进程一定存在父进程
- 正常情况下,除了
- 如果当前进程存在子进程,则设置所有子进程的父进程为
initproc
- 这样倘若这些子进程进入结束状态,则
initproc
可以代为回收资源
- 这样倘若这些子进程进入结束状态,则
- 执行进程调度,一旦调度到当前进程的父进程,则可以马上回收该终止进程的
PCB
1 | int |
- syscall:系统调用
- syscall 是内核程序为用户程序提供内核服务的一种方式
- 在用户程序中,若需用到内核服务,则需要执行
sys_xxxx
函数(例如sys_kill
)
1 | static inline int |
- 该函数会设置
%eax, %edx, %ecx, %ebx, %edi, %esi
五个寄存器的值 - 分别为 syscall调用号、参数1、参数2、参数3、参数4、参数5
- 然后执行int中断进入中断处理例程
- 在中断处理例程中:程序会根据中断号执行 syscall 函数
1 | void /* 和前面那个syscall同名,但不是同一个函数 */ |