进程管理
branch ch5
关键系统调用
pub fn sys_fork() -> isize;
由当前进程 fork 出一个子进程。
pub fn sys_exec(path: &str) -> isize;
将当前进程的地址空间清空并加载一个特定的可执行文件,返回用户态后开始它的执行。
字符串 path 给出了要加载的可执行文件的名字;
fork 和 exec 的组合,我们能让创建一个子进程
令其执行特定的可执行文件
pub fn sys_waitpid(pid: isize, exit_code: *mut i32) -> isize;
当前进程等待一个子进程变为僵尸进程,回收其全部资源并收集其返回值。
pid 表示要等待的子进程的进程 ID,如果为 -1 的话表示等待任意一个子进程
封装成俩API
wait(exit_code: &mut i32)
4 loop {
5 match sys_waitpid(-1, exit_code as *mut _) {
6 -2 => { sys_yield(); }
7 n => { return n; }
8 }
9 }
等待任意子进程
如果子进程没结束
yield让出时间片
waitpid(pid: usize, exit_code: &mut i32) 等待特定子进程
应用示例
用户初始程序-init
内核初始化完成第一个process,initial Process
通过ford+exec创建user_shell子进程
也用于回收僵尸进程
fork()出子进程,子进程也进入同样的main()函数,==0的时候进入的是子进程分支,
exec执行user_shell
else是父进程main进loop
在这里循环等待并回收所有的僵尸进程
成功打印
-1就yield继续循环
shell程序-user_shell
捕获用户输入并进行解析处理
添加一个能获取用户输入的系统调用
pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize;
syscall(SYSCALL_READ, [fd, buffer.as_mut_ptr() as usize, buffer.len()])
文件中读取一段内容到缓冲区。
fd 是待读取文件的file descriptor,切片 buffer 是缓冲区。
如果出现了错误则返回 -1,否则返回实际读到的字节数。
实际调用时,我们必须要同时向内核提供缓冲区的起始地址及长度:
用户库中将其进一步封装成每次能够从 标准输入 中获取一个字符的 getchar 函数。
loop
调用getchar获取一个user字符
如果回车键
fork一个子进程,line是名字
试图exec调用执行一个应用
exec返回-1找不到应用,打印错误退出
user_shell自己会wait_pid并回收资源
顺带收集子进程退出状态并打印出来
退格键
将屏幕上当前行的最后一个字符用空格替换掉
这可以通过输入一个特殊的退格字节 BS 来实现
user_shell 进程内维护的 line 也需要弹出最后一个字符。
其他字符
将它打印在屏幕上,并加入到 line 中
Ctrl+A x 退出qemu模拟器
TODO
qemu相关
基于应用名的应用链接/加载器
实现 exec 系统调用的时候
根据应用的名字而不仅仅是一个编号来获取应用的 ELF 格式数据
链接器 os/build.rs 中按顺序保存链接进来的每个应用的名字
应用的名字通过 .string 伪指令放到数据段中
链接器会自动在每个字符串的结尾加入分隔符 \0
位置由全局符号 _app_names 指出
writeln!(f, r#".global _app_names _app_names:"#)?;
for app in apps.iter() {
writeln!(f, r#" .string "{}""#, app)?;
}
loader.rs
用一个全局可见的 只读 向量 APP_NAMES 来按照顺序将所有应用的名字保存在内存中:
static ref APP_NAMES: Vec<&'static str> = {
get_app_data_by_name
按照应用的名字来查找获得应用的 ELF 数据
list_apps
内核初始化时被调用,打印出所有可用应用的名字
进程标识符pid和内核栈
所有进程都有一个自己的pid
互不相同的整数
pub struct PidHandle(pub usize);
类似之前的物理页帧分配器 FrameAllocator
栈式分配策略的进程标识符分配器 PidAllocator
全局实例化为 PID_ALLOCATOR
pid_alloc包装了一下alloc
static ref PID_ALLOCATOR: UPSafeCell =
pub fn pid_alloc() -> PidHandle {
内核栈
将应用编号替换为pid来决定每个进程内核栈在address space中的位置
在内核栈 KernelStack 中保存着它所属进程的 PID
RAII,drop会自动释放这段pfn
pub struct KernelStack {
pid: usize,
}
pub fn kernel_stack_position(app_id: usize) -> (usize, usize) {
老样子,通过TRAMPOLINE和app_id获得对应app的os stack位置
impl KernelStack
pub fn new(pid_handle: &PidHandle) -> Self {
new 方法可以从一个 PidHandle中对应生成一个内核栈 KernelStack
insert_framed_area跟之前一样
保存到KernelStack里
pub fn push_on_top(&self, value: T) -> *mut T where
将类型T压入os stack top返回T裸指针
使用下面get_top获取当前os stack top在os address space的地址
pub fn get_top(&self) -> usize {
fn drop(&mut self) {
.remove_area_with_start_vpn(kernel_stack_bottom_va.into());
进程控制块
PCB-TCB
PCB, Process Control Block内核对进程进行管理的单位
等价一个进程process
对TCB做些改动,直接变成PCB
进程调用 exit 系统调用主动退出或者执行出错由内核终止的退出码被内核保存在它的TCB中
等待它的父进程通过 waitpid 回收它的资源的同时也收集它的 PID 以及退出码
3pub struct TaskControlBlock {
4 // immutable
5 pub pid: PidHandle,
6 pub kernel_stack: KernelStack,
7 // mutable
8 inner: UPSafeCell,
9}
10
11pub struct TaskControlBlockInner {
12 pub trap_cx_ppn: PhysPageNum, app address space的 TrapContext被放在的ppn
13 pub base_size: usize, app data仅有可能出现在应用地址空间低于 base_size 字节的区域中。借助它我们可以清楚的知道应用有多少数据驻留在内存中。
14 pub task_cx: TaskContext, 保存TaskContext,用于任务切换
15 pub task_status: TaskStatus, 当前进程的执行状态
16 pub memory_set: MemorySet, app address space
17 pub parent: Option>, 当前进程的父进程,Weak不影响父进程引用计数
18 pub children: Vec>, 子进程,Arc包裹
19 pub exit_code: i32,
20}
impl TCB
没啥东西
任务管理器
TaskManager
TaskManager
需要将taskmanager对于 CPU 的监控职能拆分到处理器管理结构 Processor 中去
taskmanager自身仅负责管理所有task
这里的task就是process
pub struct TaskManager {
ready_queue: VecDeque>,
}
双端队列+Arch TCB经常需要放入/取出
直接移动会带来大量数据拷贝开销
pub fn add_task(task: Arc) {
pub fn fetch_task() -> Option> {
用于RR算法
处理器管理结构
Processor
Processor
维护从任务管理器 TaskManager 分离出去的那部分 CPU 状态
pub struct Processor {
current: Option>, 当前处理器上正在执行的任务
idle_task_cx: TaskContext, 当前处理器上的 idle 控制流的TaskContext的address
}
单核环境下,我们仅创建单个 Processor 的全局实例 PROCESSOR
pub static ref PROCESSOR: UPSafeCell = unsafe { UPSafeCell::new(Processor::new()) };
一些current相关的接口
每个 Processor 都有一个 idle 控制流
运行在每个核各自的启动栈上
尝试从taskmanager中选出一个任务来在当前核上执行
os初始化完毕之后,核通过调用 run_tasks 函数来进入 idle 控制流
当一个应用交出 CPU 使用权时,
比如suspend_current_and_run_next或exit....
进入内核后它会调用 schedule 函数来切换到 idle 控制流并开启新一轮的任务调度。
pub fn run_tasks() {
循环调用 fetch_task 直到顺利从taskmanager中取出一个任务
获得 __switch 两个参数进行任务切换。
整个过程要严格控制临界区,多个drop
要严格控制临界区
pub fn schedule(switched_task_cx_ptr: *mut TaskContext) {
__switch(switched_task_cx_ptr, idle_task_cx_ptr);
切换回去之后,我们将跳转到 Processor::run 中 __switch 返回之后的位置,开启下一轮循环。
TODO
这块还没太理解
初始进程的创建
INITPROC
os初始化完毕
lazy_stakic初始进程的进程控制块 INITPROC
调用add_initproc 来将初始进程 initproc 加入taskmanager
pub static ref INITPROC: Arc = Arc::new(TaskControlBlock::new(
get_app_data_by_name("initproc").unwrap()
));
pub fn add_initproc() {
add_task(INITPROC.clone());
}
TCB::new
解析 ELF 得到
应用地址空间 memory_set
user stack在app address space中的位置 user_sp
app的入口点 entry_point 。
手动查page table找到应用address space中的 TrapContext实际所在的pfn。
为新process分配 PID 以及process os stack,并记录下process os stack在os address space的位置 kernel_stack_top
该进程的os stack上压入INITPROC的taskContext,使得第一次任务切换switch到它的时候可以跳转到 trap_return 并进入U-Mode开始执行。
整合信息创建tcb
初始化app address space中的TrapContext
使得第一次进入U-Mode能正确跳转到app entry point并设置好app stack
保证Trap的时候U-Mode能正确进入S-Mode
进程调度机制
suspend_current_and_run_next 暂停当前任务切换到下一个任务
典型场景
pub fn sys_yield() -> isize {
suspend_current_and_run_next();
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();
重新实现
通过 take_current_task 来取出当前正在执行的任务
修改其tcb内的状态-Ready
将这个task放入taskmanager的队尾
调用 schedule 切换任务
仅有一个任务的时候, suspend_current_and_run_next 的效果是继续执行这个任务。
进程的生成机制
fork
最为关键且困难一点的是为子进程创建一个和父进程几乎完全相同的地址空间
MapArea::from_another
从一个逻辑段MemorySet复制得到一个虚拟地址区间virtual range、映射方式type和权限控制flag均相同的逻辑段
由于它还没有真正被映射到物理页帧pfn上,所以 data_frames 字段为空。
MapArea::from_existed_user
复制一个完全相同的地址空间
new_bare 新创建一个空的地址空间
map_trampoline 为这个地址空间映射上跳板页面
因为我们解析 ELF 创建address space的时候,
并没有将跳板页作为一个单独的逻辑段MemorySet插入到地址空间address space的逻辑段向量 areas 中
遍历原address space中的所有MemorySet,
将复制之后的逻辑段MemorySet插入新的address space, 在插入的时候就已经实际分配了物理页帧pfn了。
遍历逻辑段中的每个虚拟页面virtual page,对应完成数据data复制
只需要找出两个地址空间中的virtual page各被映射到哪个物理页帧pfn,就可转化为将数据data从物理内存中的一个位置复制到另一个位置,
使用 copy_from_slice 即可轻松实现。
TaskControlBlock::fork
从父进程的进程控制块tcb创建一份子进程的控制块tcb
基本上和新建进程控制块tcb的 TaskControlBlock::new 相同
子进程的地址空间address space不是通过解析 ELF,
MemorySet::from_existed_user 复制父parent进程process地址空间address space得到的;
在 fork 的时候需要注意父子进程关系的维护。。
父进程的弱引用计数放到子进程的进程控制块中,
子进程插入到父进程的孩子向量 children 中
pub fn sys_fork() -> isize {
需要特别注意如何体现父子进程的差异
调用 sys_fork 之前,
我们已经将当前process TrapContext中的 sepc 向后移动了 4 字节
它回到U-Mode之后会从 ecall 的下一条指令开始执行
当我们复制address space时
child process address space的 TrapContext的 sepc 也是移动之后的值
无需再进行修改
父子进程回到用户态的瞬间都处于刚刚从一次系统调用返回的状态
二者返回值不同
child process的 TrapContext中用来存放syscall返回值的 a0 寄存器修改为 0
parent process的syscall的返回值会在 syscall 返回之后再设置为 sys_fork 的返回值
做到了parent process fork 的返回值为child process的 PID ,而子进程的返回值为 0。
exec
使得一个进程能够加载一个新的 ELF 可执行文件替换原有的app address space并开始执行
impl tcb
pub fn exec(&self, elf_data: &[u8]) {
解析传入的 ELF 格式数据之后做两件事情
从 ELF 生成一个全新的address space并直接替换进来(第 15 行),
这将导致原有address space生命周期结束,
里面包含的全部pfn都会被回收;
然后修改新的address space中的 TrapContext,
将解析得到的app entry、app stack位置以及一些内核的信息进行初始化,
这样才能正常实现 Trap 机制。
pub fn sys_exec(path: *const u8) -> isize {
调用 translated_str 找到要执行的应用名
试图从应用加载器提供的 get_app_data_by_name 接口中获取对应的 ELF 数据
找到的话就调用 TaskControlBlock::exec 替换address space
app在 sys_exec syscall中传递给os的只有一个应用名字符串在app address space中的首地址
需要手动查页表来获得字符串的值
pub fn translated_str(token: usize, ptr: *const u8) -> String {
从用户地址空间app address space中查找字符串
原理就是逐字节查页表page table直到发现一个 \0 为止
因为内核不知道字符串的长度,且字符串可能是跨物理页pfn的
系统调用后重新获取 Trap 上下文
pub fn trap_handler() -> ! {
原来的cx是当前app的TrapContext的可变引用
通过查页表找到它具体被放在哪个物理页帧pfn上
构造相同的虚拟地址virtual address来在内核os中访问它
对于系统调用 sys_exec 来说,调用它之后, trap_handler 原来上下文Context中的 cx 失效了
因为它是就原来的地址空间address space而言
因此在在 syscall 返回之后需要重新获取 cx
sys_read pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize {
仅支持从标准输入 FD_STDIN 即文件描述符 0 读入
每次只能读入一个字符
利用 sbi 提供的接口 console_getchar 实现
如果还没有输入,我们就切换到其他进程process,等下次切换回来时再看看是否有输入了。
获取到输入后就退出循环,
并手动查页表将输入字符正确写入到应用地址空间app address space。
进程资源回收机制
进程的退出
都是使用exit_current_and...
pub fn sys_exit(exit_code: i32) -> ! {
pub fn trap_handler() -> ! {
exit_current_and_run_next(exit_code);
带有一个退出码作为参数
退出码会在 exit_current_and_run_next 写入当前进程的tcb
调用 take_current_task 来将当前tcb从处理器监控 PROCESSOR 中取出
将进程tcb中的status修改为 TaskStatus::Zombie 即僵尸进程;
传入的退出码 exit_code 写入tcb中,后续父进程在 waitpid 的时候可以收集;
将当前进程的所有子进程child process挂在初始进程 initproc 下面。将当前进程的child vec清空
对于当前进程占用的资源进行早期回收。
MemorySet::recycle_data_pages 只是将address space中的逻辑段MemorySet列表 areas 清空,
这将导致应用地址空间app address space的所有数据data被存放在的物理页帧pfn被回收,
而用来存放页表page table的那些物理页帧此时则不会被回收。
调用 schedule 触发调度及任务切换,
我们再也不会回到该进程的执行过程,
无需关心任务上下文的保存。
父进程回收子进程资源 pub fn sys_waitpid(pid: isize, exit_code_ptr: *mut i32) -> isize {
立即返回的系统调用
如果当前的process不存在一个符合要求的child process,则返回 -1;
至少存在一个,但是其中没有zombie process(也即仍未退出)则返回 -2;
如果都不是的话则可以正常回收并返回回收child process的 pid
在编写应用的开发者看来,
wait/waitpid 两个辅助函数都必定能够返回一个有意义的结果,
要么是 -1,要么是一个正数 PID ,
不存在 -2 这种通过等待即可消除的中间结果的。
等待的过程由用户库 user_lib 完成
首先判断 sys_waitpid 是否会返回 -1 ,这取决于current process是否有一个符合要求的子进程。
传入的 pid 为 -1 的时候,任何一个子进程都算是符合要求;
pid 不为 -1 的时候,则只有 PID 恰好与 pid 相同的子进程才算符合条件。
再判断符合要求的child process中是否有zombie process
找不到的话直接返回 -2 ,否则进行下一步处理:
将child process从向量中移除并置于当前Context中
TODO
啥是置于当前Context
当它所在的代码块结束,这次引用变量的生命周期结束,
child process tcb子进程进程控制块的引用计数将变为 0 ,
内核将彻底回收掉它占用的所有资源,
包括内核栈os stack、它的 PID 、存放页表page table的那些物理页帧pfn等等。
获得child process退出码exit code后,
考虑到应用app传入的指针指向应用地址空间app address space,
我们还需要手动查页表page table找到对应物理内存中的位置。
translated_refmut 的实现可以在 os/src/mm/page_table.rs 中找到。

Generated 2025-05-05 04:17:35 +0000
25bcfca-dirty 2025-05-04 14:47:56 +0000