进程管理机制的设计实现¶
本节导读¶
本节将介绍如何基于上一节设计的内核数据结构来实现进程管理:
初始进程
initproc
的创建;进程调度机制:当进程主动调用
sys_yield
交出 CPU 使用权,或者内核本轮分配的时间片用尽之后如何切换到下一个进程;进程生成机制:介绍进程相关的两个重要系统调用
sys_fork/sys_exec
的实现;字符输入机制:介绍
sys_read
系统调用的实现;进程资源回收机制:当进程调用
sys_exit
正常退出或者出错被内核终止后,如何保存其退出码,其父进程又是如何通过sys_waitpid
收集该进程的信息并回收其资源。
初始进程的创建¶
内核初始化完毕之后,即会调用 task
子模块提供的 add_initproc
函数来将初始进程 initproc
加入任务管理器,但在这之前,我们需要初始进程的进程控制块 INITPROC
,这基于 lazy_static
在运行时完成。
// os/src/task/mod.rs
lazy_static! {
pub static ref INITPROC: Arc<TaskControlBlock> = Arc::new(TaskControlBlock::new(
get_app_data_by_name("initproc").unwrap()
));
}
pub fn add_initproc() {
add_task(INITPROC.clone());
}
我们调用 TaskControlBlock::new
来创建一个进程控制块,它需要传入 ELF 可执行文件的数据切片作为参数,
这可以通过加载器 loader
子模块提供的 get_app_data_by_name
接口查找 initproc
的 ELF 数据来获得。在初始化
INITPROC
之后,则在 add_initproc
中可以调用 task
的任务管理器 manager
子模块提供的 add_task
接口将其加入到任务管理器。
接下来介绍 TaskControlBlock::new
是如何实现的:
1// os/src/task/task.rs
2
3use super::{PidHandle, pid_alloc, KernelStack};
4use super::TaskContext;
5use crate::config::TRAP_CONTEXT;
6use crate::trap::TrapContext;
7
8// impl TaskControlBlock
9pub fn new(elf_data: &[u8]) -> Self {
10 // memory_set with elf program headers/trampoline/trap context/user stack
11 let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data);
12 let trap_cx_ppn = memory_set
13 .translate(VirtAddr::from(TRAP_CONTEXT).into())
14 .unwrap()
15 .ppn();
16 // alloc a pid and a kernel stack in kernel space
17 let pid_handle = pid_alloc();
18 let kernel_stack = KernelStack::new(&pid_handle);
19 let kernel_stack_top = kernel_stack.get_top();
20 // push a task context which goes to trap_return to the top of kernel stack
21 let task_cx_ptr = kernel_stack.push_on_top(TaskContext::goto_trap_return());
22 let task_control_block = Self {
23 pid: pid_handle,
24 kernel_stack,
25 inner: unsafe { UPSafeCell::new(TaskControlBlockInner {
26 trap_cx_ppn,
27 base_size: user_sp,
28 task_cx: TaskContext::goto_trap_return(kernel_stack_top),
29 task_status: TaskStatus::Ready,
30 memory_set,
31 parent: None,
32 children: Vec::new(),
33 exit_code: 0,
34 })
35 },
36 };
37 // prepare TrapContext in user space
38 let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx();
39 *trap_cx = TrapContext::app_init_context(
40 entry_point,
41 user_sp,
42 KERNEL_SPACE.exclusive_access().token(),
43 kernel_stack_top,
44 trap_handler as usize,
45 );
46 task_control_block
47}
第 11 行,解析 ELF 得到应用地址空间
memory_set
,用户栈在应用地址空间中的位置user_sp
以及应用的入口点entry_point
。第 12 行,手动查页表找到应用地址空间中的 Trap 上下文实际所在的物理页帧。
第 17~19 行,为新进程分配 PID 以及内核栈,并记录下内核栈在内核地址空间的位置
kernel_stack_top
。第 21 行,在该进程的内核栈上压入初始化的任务上下文,使得第一次任务切换到它的时候可以跳转到
trap_return
并进入用户态开始执行。第 22 行,整合之前的部分信息创建进程控制块
task_control_block
。第 39 行,初始化位于该进程应用地址空间中的 Trap 上下文,使得第一次进入用户态时,能正确跳转到应用入口点并设置好用户栈, 同时也保证在 Trap 的时候用户态能正确进入内核态。
进程调度机制¶
调用 task
子模块提供的 suspend_current_and_run_next
函数可以暂停当前任务,并切换到下一个任务,下面给出了两种典型的使用场景:
// os/src/syscall/process.rs
pub fn sys_yield() -> isize {
suspend_current_and_run_next();
0
}
// os/src/trap/mod.rs
#[no_mangle]
pub fn trap_handler() -> ! {
set_kernel_trap_entry();
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
Trap::Interrupt(Interrupt::SupervisorTimer) => {
set_next_trigger();
suspend_current_and_run_next();
}
...
}
trap_return();
}
随着进程概念的引入, suspend_current_and_run_next
的实现也需要发生变化:
1// os/src/task/mod.rs
2
3use processor::{task_current_task, schedule};
4use manager::add_task;
5
6pub fn suspend_current_and_run_next() {
7 // There must be an application running.
8 let task = take_current_task().unwrap();
9
10 // ---- access current TCB exclusively
11 let mut task_inner = task.inner_exclusive_access();
12 let task_cx_ptr = &mut task_inner.task_cx as *mut TaskContext;
13 // Change status to Ready
14 task_inner.task_status = TaskStatus::Ready;
15 drop(task_inner);
16 // ---- release current PCB
17
18 // push back to ready queue.
19 add_task(task);
20 // jump to scheduling cycle
21 schedule(task_cx_ptr);
22}
首先通过 take_current_task
来取出当前正在执行的任务,修改其进程控制块内的状态,随后将这个任务放入任务管理器的队尾。接着调用
schedule
函数来触发调度并切换任务。当仅有一个任务的时候, suspend_current_and_run_next
的效果是会继续执行这个任务。
进程的生成机制¶
fork 系统调用的实现¶
实现 fork 时,最为关键且困难一点的是为子进程创建一个和父进程几乎完全相同的地址空间。我们的实现如下:
1// os/src/mm/memory_set.rs
2
3impl MapArea {
4 pub fn from_another(another: &MapArea) -> Self {
5 Self {
6 vpn_range: VPNRange::new(
7 another.vpn_range.get_start(),
8 another.vpn_range.get_end()
9 ),
10 data_frames: BTreeMap::new(),
11 map_type: another.map_type,
12 map_perm: another.map_perm,
13 }
14 }
15}
16
17impl MemorySet {
18 pub fn from_existed_user(user_space: &MemorySet) -> MemorySet {
19 let mut memory_set = Self::new_bare();
20 // map trampoline
21 memory_set.map_trampoline();
22 // copy data sections/trap_context/user_stack
23 for area in user_space.areas.iter() {
24 let new_area = MapArea::from_another(area);
25 memory_set.push(new_area, None);
26 // copy data from another space
27 for vpn in area.vpn_range {
28 let src_ppn = user_space.translate(vpn).unwrap().ppn();
29 let dst_ppn = memory_set.translate(vpn).unwrap().ppn();
30 dst_ppn.get_bytes_array().copy_from_slice(src_ppn.get_bytes_array());
31 }
32 }
33 memory_set
34 }
35}
这需要对内存管理子模块 mm
做一些拓展:
第 4 行的
MapArea::from_another
可以从一个逻辑段复制得到一个虚拟地址区间、映射方式和权限控制均相同的逻辑段, 不同的是由于它还没有真正被映射到物理页帧上,所以data_frames
字段为空。第 18 行的
MemorySet::from_existed_user
可以复制一个完全相同的地址空间。首先在第 19 行,我们通过new_bare
新创建一个空的地址空间,并在第 21 行通过map_trampoline
为这个地址空间映射上跳板页面,这是因为我们解析 ELF 创建地址空间的时候,并没有将跳板页作为一个单独的逻辑段插入到地址空间的逻辑段向量areas
中,所以这里需要单独映射上。剩下的逻辑段都包含在
areas
中。我们遍历原地址空间中的所有逻辑段,将复制之后的逻辑段插入新的地址空间, 在插入的时候就已经实际分配了物理页帧了。接着我们遍历逻辑段中的每个虚拟页面,对应完成数据复制, 这只需要找出两个地址空间中的虚拟页面各被映射到哪个物理页帧,就可转化为将数据从物理内存中的一个位置复制到另一个位置,使用copy_from_slice
即可轻松实现。
接着,我们实现 TaskControlBlock::fork
来从父进程的进程控制块创建一份子进程的控制块:
1// os/src/task/task.rs
2
3impl TaskControlBlock {
4 pub fn fork(self: &Arc<TaskControlBlock>) -> Arc<TaskControlBlock> {
5 // ---- access parent PCB exclusively
6 let mut parent_inner = self.inner_exclusive_access();
7 // copy user space(include trap context)
8 let memory_set = MemorySet::from_existed_user(&parent_inner.memory_set);
9 let trap_cx_ppn = memory_set
10 .translate(VirtAddr::from(TRAP_CONTEXT).into())
11 .unwrap()
12 .ppn();
13 // alloc a pid and a kernel stack in kernel space
14 let pid_handle = pid_alloc();
15 let kernel_stack = KernelStack::new(&pid_handle);
16 let kernel_stack_top = kernel_stack.get_top();
17 let task_control_block = Arc::new(TaskControlBlock {
18 pid: pid_handle,
19 kernel_stack,
20 inner: unsafe {
21 UPSafeCell::new(TaskControlBlockInner {
22 trap_cx_ppn,
23 base_size: parent_inner.base_size,
24 task_cx: TaskContext::goto_trap_return(kernel_stack_top),
25 task_status: TaskStatus::Ready,
26 memory_set,
27 parent: Some(Arc::downgrade(self)),
28 children: Vec::new(),
29 exit_code: 0,
30 })
31 },
32 });
33 // add child
34 parent_inner.children.push(task_control_block.clone());
35 // modify kernel_sp in trap_cx
36 // **** access children PCB exclusively
37 let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx();
38 trap_cx.kernel_sp = kernel_stack_top;
39 // return
40 task_control_block
41 // ---- release parent PCB automatically
42 // **** release children PCB automatically
43 }
44}
它基本上和新建进程控制块的 TaskControlBlock::new
是相同的,但要注意以下几点:
子进程的地址空间不是通过解析 ELF,而是通过在第 8 行调用
MemorySet::from_existed_user
复制父进程地址空间得到的;在 fork 的时候需要注意父子进程关系的维护。既要将父进程的弱引用计数放到子进程的进程控制块中,又要将子进程插入到父进程的孩子向量
children
中。
实现 sys_fork
时,我们需要特别注意如何体现父子进程的差异:
1// os/src/syscall/process.rs
2
3pub fn sys_fork() -> isize {
4 let current_task = current_task().unwrap();
5 let new_task = current_task.fork();
6 let new_pid = new_task.pid.0;
7 // modify trap context of new_task, because it returns immediately after switching
8 let trap_cx = new_task.inner_exclusive_access().get_trap_cx();
9 // we do not have to move to next instruction since we have done it before
10 // for child process, fork returns 0
11 trap_cx.x[10] = 0;
12 // add new task to scheduler
13 add_task(new_task);
14 new_pid as isize
15}
在调用 sys_fork
之前,我们已经将当前进程 Trap 上下文中的 sepc 向后移动了 4 字节,使得它回到用户态之后会从 ecall
的下一条指令开始执行。之后,当我们复制地址空间时,子进程地址空间 Trap 上下文的 sepc 也是移动之后的值,我们无需再进行修改。
父子进程回到用户态的瞬间都处于刚刚从一次系统调用返回的状态,但二者返回值不同。第 8~11 行我们将子进程的 Trap
上下文中用来存放系统调用返回值的 a0 寄存器修改为 0 ,而父进程系统调用的返回值会在 syscall
返回之后再设置为 sys_fork
的返回值。这就做到了父进程 fork
的返回值为子进程的 PID ,而子进程的返回值为 0。
exec 系统调用的实现¶
exec
系统调用使得一个进程能够加载一个新的 ELF 可执行文件替换原有的应用地址空间并开始执行。我们先从进程控制块的层面进行修改:
1// os/src/task/task.rs
2
3impl TaskControlBlock {
4 pub fn exec(&self, elf_data: &[u8]) {
5 // memory_set with elf program headers/trampoline/trap context/user stack
6 let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data);
7 let trap_cx_ppn = memory_set
8 .translate(VirtAddr::from(TRAP_CONTEXT).into())
9 .unwrap()
10 .ppn();
11
12 // **** access inner exclusively
13 let mut inner = self.inner_exclusive_access();
14 // substitute memory_set
15 inner.memory_set = memory_set;
16 // update trap_cx ppn
17 inner.trap_cx_ppn = trap_cx_ppn;
18 // initialize trap_cx
19 let trap_cx = inner.get_trap_cx();
20 *trap_cx = TrapContext::app_init_context(
21 entry_point,
22 user_sp,
23 KERNEL_SPACE.exclusive_access().token(),
24 self.kernel_stack.get_top(),
25 trap_handler as usize,
26 );
27 // **** release inner automatically
28 }
29}
它在解析传入的 ELF 格式数据之后只做了两件事情:
首先从 ELF 生成一个全新的地址空间并直接替换进来(第 15 行),这将导致原有地址空间生命周期结束,里面包含的全部物理页帧都会被回收;
然后修改新的地址空间中的 Trap 上下文,将解析得到的应用入口点、用户栈位置以及一些内核的信息进行初始化,这样才能正常实现 Trap 机制。
sys_exec
的实现如下,它调用 translated_str
找到要执行的应用名,并试图从应用加载器提供的 get_app_data_by_name
接口中获取对应的 ELF 数据,如果找到的话就调用 TaskControlBlock::exec
替换地址空间。
// os/src/syscall/process.rs
pub fn sys_exec(path: *const u8) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
if let Some(data) = get_app_data_by_name(path.as_str()) {
let task = current_task().unwrap();
task.exec(data);
0
} else {
-1
}
}
应用在 sys_exec
系统调用中传递给内核的只有一个应用名字符串在用户地址空间中的首地址,内核必限手动查页表来获得字符串的值。
translated_str
用来从用户地址空间中查找字符串,其原理就是逐字节查页表直到发现一个 \0
为止。为什么要逐字节查页表?
因为内核不知道字符串的长度,且字符串可能是跨物理页的。
// os/src/mm/page_table.rs
pub fn translated_str(token: usize, ptr: *const u8) -> String {
let page_table = PageTable::from_token(token);
let mut string = String::new();
let mut va = ptr as usize;
loop {
let ch: u8 = *(page_table.translate_va(VirtAddr::from(va)).unwrap().get_mut());
if ch == 0 {
break;
} else {
string.push(ch as char);
va += 1;
}
}
string
}
系统调用后重新获取 Trap 上下文¶
原来在 trap_handler
中我们是这样处理系统调用的:
// os/src/trap/mod.rs
#[no_mangle]
pub fn trap_handler() -> ! {
set_kernel_trap_entry();
let cx = current_trap_cx();
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
Trap::Exception(Exception::UserEnvCall) => {
cx.sepc += 4;
cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
}
...
}
trap_return();
}
这里的 cx
是当前应用的 Trap 上下文的可变引用,我们需要通过查页表找到它具体被放在哪个物理页帧上,
并构造相同的虚拟地址来在内核中访问它。对于系统调用 sys_exec
来说,调用它之后, trap_handler
原来上下文中的 cx
失效了,因为它是就原来的地址空间而言的。为了能够处理类似的这种情况,我们在 syscall
返回之后需要重新获取 cx
,目前的实现如下:
// os/src/trap/mod.rs
#[no_mangle]
pub fn trap_handler() -> ! {
set_kernel_trap_entry();
let scause = scause::read();
let stval = stval::read();
match scause.cause() {
Trap::Exception(Exception::UserEnvCall) => {
// jump to next instruction anyway
let mut cx = current_trap_cx();
cx.sepc += 4;
// get system call return value
let result = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]);
// cx is changed during sys_exec, so we have to call it again
cx = current_trap_cx();
cx.x[10] = result as usize;
}
...
}
trap_return();
}
sys_read 获取输入¶
我们需要实现 sys_read
系统调用,使应用能够取得用户的键盘输入。
// os/src/syscall/fs.rs
use crate::sbi::console_getchar;
const FD_STDIN: usize = 0;
pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize {
match fd {
FD_STDIN => {
assert_eq!(len, 1, "Only support len = 1 in sys_read!");
let mut c: usize;
loop {
c = console_getchar();
if c == 0 {
suspend_current_and_run_next();
continue;
} else {
break;
}
}
let ch = c as u8;
let mut buffers = translated_byte_buffer(current_user_token(), buf, len);
unsafe { buffers[0].as_mut_ptr().write_volatile(ch); }
1
}
_ => {
panic!("Unsupported fd in sys_read!");
}
}
}
目前我们仅支持从标准输入 FD_STDIN
即文件描述符 0 读入,且每次只能读入一个字符,这是利用 sbi
提供的接口 console_getchar
实现的。如果还没有输入,我们就切换到其他进程,等下次切换回来时再看看是否有输入了。
获取到输入后就退出循环,并手动查页表将输入字符正确写入到应用地址空间。
进程资源回收机制¶
进程的退出¶
当应用调用 sys_exit
系统调用主动退出,或者出错由内核终止之后,会在内核中调用 exit_current_and_run_next
函数:
1// os/src/syscall/process.rs
2
3pub fn sys_exit(exit_code: i32) -> ! {
4 exit_current_and_run_next(exit_code);
5 panic!("Unreachable in sys_exit!");
6}
7
8// os/src/trap/mod.rs
9
10#[no_mangle]
11pub fn trap_handler() -> ! {
12 set_kernel_trap_entry();
13 let scause = scause::read();
14 let stval = stval::read();
15 match scause.cause() {
16 Trap::Exception(Exception::StoreFault) |
17 Trap::Exception(Exception::StorePageFault) |
18 Trap::Exception(Exception::InstructionFault) |
19 Trap::Exception(Exception::InstructionPageFault) |
20 Trap::Exception(Exception::LoadFault) |
21 Trap::Exception(Exception::LoadPageFault) => {
22 println!(
23 "[kernel] {:?} in application, bad addr = {:#x}, bad instruction = {:#x}, core dumped.",
24 scause.cause(),
25 stval,
26 current_trap_cx().sepc,
27 );
28 // page fault exit code
29 exit_current_and_run_next(-2);
30 }
31 Trap::Exception(Exception::IllegalInstruction) => {
32 println!("[kernel] IllegalInstruction in application, core dumped.");
33 // illegal instruction exit code
34 exit_current_and_run_next(-3);
35 }
36 ...
37 }
38 trap_return();
39}
相比前面的章节, exit_current_and_run_next
带有一个退出码作为参数,这个退出码会在
exit_current_and_run_next
写入当前进程的进程控制块:
1// os/src/mm/memory_set.rs
2
3impl MemorySet {
4 pub fn recycle_data_pages(&mut self) {
5 self.areas.clear();
6 }
7}
8
9// os/src/task/mod.rs
10
11pub fn exit_current_and_run_next(exit_code: i32) {
12 // take from Processor
13 let task = take_current_task().unwrap();
14 // **** access current TCB exclusively
15 let mut inner = task.inner_exclusive_access();
16 // Change status to Zombie
17 inner.task_status = TaskStatus::Zombie;
18 // Record exit code
19 inner.exit_code = exit_code;
20 // do not move to its parent but under initproc
21
22 // ++++++ access initproc TCB exclusively
23 {
24 let mut initproc_inner = INITPROC.inner_exclusive_access();
25 for child in inner.children.iter() {
26 child.inner_exclusive_access().parent = Some(Arc::downgrade(&INITPROC));
27 initproc_inner.children.push(child.clone());
28 }
29 }
30 // ++++++ release parent PCB
31
32 inner.children.clear();
33 // deallocate user space
34 inner.memory_set.recycle_data_pages();
35 drop(inner);
36 // **** release current PCB
37 // drop task manually to maintain rc correctly
38 drop(task);
39 // we do not have to save task context
40 let mut _unused = TaskContext::zero_init();
41 schedule(&mut _unused as *mut _);
42}
第 13 行,调用
take_current_task
来将当前进程控制块从处理器监控PROCESSOR
中取出,而不只是得到一份拷贝,这是为了正确维护进程控制块的引用计数;第 17 行将进程控制块中的状态修改为
TaskStatus::Zombie
即僵尸进程;第 19 行将传入的退出码
exit_code
写入进程控制块中,后续父进程在waitpid
的时候可以收集;第 24~26 行所做的事情是,将当前进程的所有子进程挂在初始进程
initproc
下面。第 32 行将当前进程的孩子向量清空。第 34 行,对于当前进程占用的资源进行早期回收。
MemorySet::recycle_data_pages
只是将地址空间中的逻辑段列表areas
清空,这将导致应用地址空间的所有数据被存放在的物理页帧被回收,而用来存放页表的那些物理页帧此时则不会被回收。最后在第 41 行我们调用
schedule
触发调度及任务切换,我们再也不会回到该进程的执行过程,因此无需关心任务上下文的保存。
父进程回收子进程资源¶
1// os/src/syscall/process.rs
2
3/// If there is not a child process whose pid is same as given, return -1.
4/// Else if there is a child process but it is still running, return -2.
5pub fn sys_waitpid(pid: isize, exit_code_ptr: *mut i32) -> isize {
6 let task = current_task().unwrap();
7 // find a child process
8
9 // ---- access current TCB exclusively
10 let mut inner = task.inner_exclusive_access();
11 if !inner
12 .children
13 .iter()
14 .any(|p| pid == -1 || pid as usize == p.getpid())
15 {
16 return -1;
17 // ---- release current PCB
18 }
19 let pair = inner.children.iter().enumerate().find(|(_, p)| {
20 // ++++ temporarily access child PCB lock exclusively
21 p.inner_exclusive_access().is_zombie() && (pid == -1 || pid as usize == p.getpid())
22 // ++++ release child PCB
23 });
24 if let Some((idx, _)) = pair {
25 let child = inner.children.remove(idx);
26 // confirm that child will be deallocated after removing from children list
27 assert_eq!(Arc::strong_count(&child), 1);
28 let found_pid = child.getpid();
29 // ++++ temporarily access child TCB exclusively
30 let exit_code = child.inner_exclusive_access().exit_code;
31 // ++++ release child PCB
32 *translated_refmut(inner.memory_set.token(), exit_code_ptr) = exit_code;
33 found_pid as isize
34 } else {
35 -2
36 }
37 // ---- release current PCB lock automatically
38}
sys_waitpid
是一个立即返回的系统调用,它的返回值语义是:如果当前的进程不存在一个符合要求的子进程,则返回
-1;如果至少存在一个,但是其中没有僵尸进程(也即仍未退出)则返回 -2;如果都不是的话则可以正常回收并返回回收子进程的
pid 。但在编写应用的开发者看来, wait/waitpid
两个辅助函数都必定能够返回一个有意义的结果,要么是 -1,要么是一个正数
PID ,是不存在 -2 这种通过等待即可消除的中间结果的。等待的过程由用户库 user_lib
完成。
首先判断 sys_waitpid
是否会返回 -1 ,这取决于当前进程是否有一个符合要求的子进程。当传入的 pid
为 -1
的时候,任何一个子进程都算是符合要求;但 pid
不为 -1 的时候,则只有 PID 恰好与 pid
相同的子进程才算符合条件。我们简单通过迭代器即可完成判断。
再判断符合要求的子进程中是否有僵尸进程。如果找不到的话直接返回 -2
,否则进行下一步处理:
我们将子进程从向量中移除并置于当前上下文中,当它所在的代码块结束,这次引用变量的生命周期结束,子进程进程控制块的引用计数将变为 0 ,内核将彻底回收掉它占用的所有资源,包括内核栈、它的 PID 、存放页表的那些物理页帧等等。
获得子进程退出码后,考虑到应用传入的指针指向应用地址空间,我们还需要手动查页表找到对应物理内存中的位置。
translated_refmut
的实现可以在 os/src/mm/page_table.rs
中找到。