【操作系统实验】ChCore lab3 - 用户进程与异常处理
ChCore lab3 - 用户进程与异常处理
0. 进程与线程
操作系统为了管理程序的运行,提出进程(process)的概念:每个进程都对应一个运行中的程序。进程运行时仿佛独占了整个 CPU 和内存资源,而进程管理与协调的工作则交由操作系统,不再由用户(或者说进程自己)负责。
进程
进程的状态
进程的状态有五种:
- 创建状态(new):刚刚创建的进程,还未初始化,不能够被调度执行;
- 就绪状态(ready):初始化完成,可以被调度运行,但是还没有被处理器调度;
- 执行状态(running):处于就绪状态的进程被处理器调度后,进入执行状态。当进程执行完时间片后,则会停止执行,回到就绪态;如果在执行中需要请求外部资源(例如 I/O 请求),则会进入阻塞状态;
- 阻塞状态(blocked):进程需要等待外部事件或者外部资源,暂时无法被调度。当外部事件或资源条件满足时,则迁移至预备状态;
- 终止状态(terminated):进程已经完成了执行,资源被释放,不会再被调度了。
进程控制块与上下文切换
在内核中,每个进程都有一个数据结构来保存这个进程的相关状态,包括进程标识符(pid)等。该数据结构称为进程控制块。
进程的上下文(context)指的是进程运行时需要的寄存器状态,能够用于保存和恢复一个进程在处理器上运行的状态。
Linux 进程操作
进程创建——fork
Linux 中,进程一般是通过 fork 接口从已有的进程中分裂出来的。
需要注意的是,fork 得到的子进程从父进程得到了完全相同的拷贝,不仅包括寄存器和内存状态,也包含打开的文件等 PCB 中包含的内容。
在每个进程运行中都会维护一张已打开文件的文件描述符表。文件描述符会使用偏移量记录单签进程读取到某一文件的某一位置。所以 fork 的时候,相当于子进程和父进程都打开了相同的文件,且文件指针的偏移也相同。
因此,尽管 fork 接口的使用非常简单,但是背后的实现逻辑仍然非常复杂,因为子进程和父进程会存在大量的共享,所以会产生大量不确定行为。POSIX 标准列举出了调用 fork 时 25 种特殊情况的处理方法,包括进程标识符,文件,锁,计时器,消息队列等内容。
进程执行——exec
1 | int execve(const char *pathname, char *const argv[], char *const envp[]); |
execve 接口可以启动一个新的可执行文件。当 execve 被调用时,操作系统至少完成以下几个步骤:
- 根据 pathname 指出的路径,将可执行文件的数据段和代码段导入当前进程的地址空间;
- 重新初始化堆栈,依照操作系统设置可能进行地址空间虽计划,改变堆栈位置;
- 将 pc 设置到可执行文件定义的入口点,开始执行。
进程管理
进程的 PCB(或者称为 task_struct
)都会记录自己的父进程和子进程。因此进程之间会构成进程树结构。
处于进程树根部的是 init 进程,它是操作系统创建的第一个进程。kthreadd 是第二个进程所有内核进程,所有内核进程都是它 fork 出来的。
父进程可以调用 waitpid 来对其子进程进行监控。子进程结束时,waitpid 也会返回,并设置 status 来表示子进程状态。子进程没有中止时,waitpid 会阻塞父进程。此外,wait 操作还会起到回收结束子进程和释放资源的作用,避免终止的子进程成为僵尸进程。
线程
多线程地址空间布局
进程内部添加的可独立执行的单元称为线程。线程之间共享进程的地址空间,但是又各自保存运行时的上下文。
多线程地址空间有两个主要特征:
- 分离的内核栈和用户栈:每个线程的执行相对独立,线程切换到内核中时,栈帧也会切换到内核对应的栈中。
- 共享其他区域:线程之间共享代码段,数据段等内容。例如当同一个进程的多个线程使用 malloc 分配时,都是在同一个堆上实现的。(可能在不同的分配区)
这里我不太明白,内核态线程和用户态线程可以形成对应,那为什么内核态进程和用户态进程没有对应关系呢?
线程控制块
在主流的一对一线程模型中,内核态线程和用户态线程各自保存自己的 TCB。
1. 实现用户进程
1.1 加载用户 ELF 文件
kernel/process/thread.c 中实现 load_binary()
函数:
Part1 - 获得 segment 的大小和加载地址
为
pmo_init()
和vmspace_map_range()
函数的调用准备好参数。pmo_init()
函数用于分配 PMO 的物理内存地址;vmspace_map_range()
将 pmo 映射到某个虚拟地址上。你需要获取当前 segment 的大小,以及该 segment 需要映射到的虚拟地址的位置。提示:我们建议你使用
seg_sz
和p_vaddr
变量来处理 elf 头部的原始数据,并使用seg_map_sz
来保存段对齐后的大小。注意分配和映射物理内存的时候,处理页面尺寸的对齐。
ELF program header 数据结构如下:
1 | struct elf_program_header { |
所以第一部分就非常简单了,直接从 elf program header 中获取虚拟地址和 size 即可。提示要求对齐,不满一页按照一页分配,但其实后续调用的那两个函数做了处理,所以就不必对齐了。
1 | // part1: |
Part2 - 将 elf segment 的数据拷贝到 pmo
更简单了:
1 | // part2 |
1.2 初始化线程上下文
kernel/sched/context.c 中实现 init_thread_ctx()
函数:
你需要在这里初始化线程的上下文,目的是让后续的
eret_to_thread(context)
能够正常工作。在这个函数中,你需要初始化寄存器和线程上下文的类型,包括:
- 设置 SP_EL0 指向栈;
- 设置 ELR_EL1 的值为线程的代码入口点;
- 设置 SPSR_EL1 的值为 SPSR_EL1_EL0t 作为正确的 PSTATE。最重要的字段是 SPSR_EL1[3:0] ,将其设置为 0 表示 eret 返回到 EL0.
你可以查看 register.h 中的宏定义获取帮助。
1 | // 1. 将 SP_EL0 设置为 stack 的位置 |
1.3 切换到当前线程的上下文
kernel/sched/sched.c 中实现 switch_context()
函数。
切换虚拟地址和架构相关的内容。
Return the context pointer which should be set to stack pointer register
返回线程结构的指针,应该被设置为 SP 寄存器的值。
在 main.c 中是这样调用的:
1 | eret_to_thread(switch_context()); |
eret_to_thread()
函数定义于 exception_table.S:
1 | /* void eret_to_thread(u64 sp) */ |
exception_exit
是一个汇编宏,具体定义如下:
1 | .macro exception_exit |
显然,这段宏指令是将 sp 处的数据取出,保存到寄存器内,然后调用 eret
进行返回。
Mnemonic | Instruction | See |
---|---|---|
ERET | Exception return using current ELR and SPSR | ERET on page C6-1010 |
Exception Return using the ELR and SPSR for the current Exception level. When executed, the PE restores PSTATE from the SPSR, and branches to the address held in the ELR.
The PE checks the SPSR for the current Exception level for an illegal return event. See Illegal return events from AArch64 state on page D1-2468.
因此,eret_to_thread(switch_context());
中 switch_context()
返回的结果应当是寄存器结果保存的地址,即 target_thread->thread_ctx->ec.reg
。
1 | /* |
1.4 分析 process_create_root() 函数
在 main.c 中初始化了 uart,内存管理,初始化并启用了异常向量之后,就可以创建进程/线程相关的内容了。Chcore 通过调用 process_create_root()
函数来初始化进程/线程,代码如下:
1 | // in process_create_root(): |
调用 process_create()
创建了第一个进程,关于资源与 capability 的关系见 【操作系统实验】Linux Capability - 能力机制 。总体上来说,process_create()
函数创建了一个进程对象(object,资源类型是 TYPE_PROCESS
)。特殊的地方在于,这个进程 obj 被放置在它自己的 slot_table
中(进程自身是自身的资源?)。此外,还为该进程申请了 vmspace
对象,用于页表等虚拟地址转换操作。具体细节参考 process_create()
函数即可。
下面分析 thread_create_main()
函数:
1 | // 先获取进程的 vmspace 变量,后面用于虚拟地址映射。 |