在之前的ch4中,实现了虚拟地址功能,不用通过汇编直接给每个APP分配物理地址然后运行了。但是对每个APP而言,系统并不能很好的控制他们,因此我们需要实现让内核能够控制每个APP。这就是进程的作用,这里的系统还没有实现线程,因此一个进程只能运行一个APP。
pub struct TaskControlBlock {
// Immutable
/// Process identifier
pub pid: PidHandle,
/// Kernel stack corresponding to PID
pub kernel_stack: KernelStack,
/// Mutable
inner: UPSafeCell<TaskControlBlockInner>,
}这里提供了进程的pid,内核栈还有进程的信息,使用inner来实现,因为一些信息会被修改,比如上下文,状态,所以需要线程安全(名词就叫这个,这里没有线程)。
pub struct TaskControlBlockInner {
/// The physical page number of the frame where the trap context is placed
pub trap_cx_ppn: PhysPageNum,
/// Application data can only appear in areas
/// where the application address space is lower than base_size
pub base_size: usize,
/// Save task context
pub task_cx: TaskContext,
/// Maintain the execution status of the current process
pub task_status: TaskStatus,
/// Application address space
pub memory_set: MemorySet,
/// Parent process of the current process.
/// Weak will not affect the reference count of the parent
pub parent: Option<Weak<TaskControlBlock>>,
/// A vector containing TCBs of all child processes of the current process
pub children: Vec<Arc<TaskControlBlock>>,
/// It is set when active exit or execution error occurs
pub exit_code: i32,
/// Heap bottom
pub heap_bottom: usize,
/// Program break
pub program_brk: usize,
/// Current stride of this process, used by the stride scheduler.
pub stride: usize,
/// Scheduling pass: `BIG_STRIDE / priority`.
pub pass: usize,
/// Priority of this process, always `>= 2`.
pub priority: isize,
}inner里面主要有页表(memory_set),trap时保存上下文的页表,内核上下文内容,任务状态等信息。这些信息在进程切换的时候会改变。TaskContext里面主要是ra,sp,s0~s11这些寄存器,在恢复现场的时候,这些寄存器里面的东西都要被保存。trap_cx_ppn这个是内核的地址,由于切换的时候是在内核的页表里面,所以不能用用户的地址,也不能当变量直接存在用户的属性中。
pub enum TaskStatus {
/// uninitialized
UnInit,
/// ready to run
Ready,
/// running
Running,
/// exited
Zombie,
}进程的四个状态的作用如下:
进程切换实际在内核发生,具体有
这里进入内核实际是走的trap的方式,sscause寄存器会保存trap的原因,然后trap的时候就会运行调度的程序,这里内核会让出这个CPU,修改进程的状态,然后放入队列中,最后调度器调度。整体过程并不复杂,但是需要注意实现的步骤,而且这里需要进入内核来实现,内核中要保证修改的原子性。
时序如下:
用户态 P 运行
│ 时钟中断
▼
__alltraps(跳板页): 存 TrapContext → satp 切内核 → sp 切 P 的内核栈
▼
trap_handler(内核页表,P 的栈): suspend_current_and_run_next()
├─ take_current_task() P 离开 PROCESSOR
├─ P.status = Ready
├─ add_task(P) 回就绪队列
└─ schedule(&mut P.task_cx)
└─ __switch(P.task_cx, idle_task_cx) 存 P 的内核现场
▼
run_tasks(boot stack): fetch_task() 选 stride 最小的 Q,Q.status = Running
└─ __switch(idle_task_cx, Q.task_cx) 存调度器现场,载入 Q
▼
Q 的内核栈: ra = trap_return(首次)或 schedule() 的返回点(恢复)
▼
trap_return → jr 跳板页 __restore → satp 切 Q 的页表 → 恢复 TrapContext → sret
▼
用户态 Q 运行这里把相应的函数实现后,后面实现睡眠锁等需要阻塞方式实现的函数的时候只需要修改进程的状态,将进程放入队列中,然后运行下一个进程就可以了。
其实我感觉这里实现的进程的部分与freertos的任务非常相似,只不过freertos的资源是需要我们写入参数来分配的,而进程是内核分配资源,freertos就像是个小内核一样,只不过它管理的部分并不多,只是管理任务的切换,内存等资源分配。想到这里我才彻底明白为什么说进程是操作系统分配资源的最小单位,而freertos中创建任务又为什么要分配栈的大小等部分。
这里新加的函数如下:
为了实现waitpid功能,rCore把会使用的命令的名字和相应的地址保存了起来,这样我们就可以使用相应的名字来调用相应的程序。程序是应用的 ELF 格式数据,这里通过elf格式的文件保存了起来。
fork的作用就是复制一个进程,父子从同一点分叉新建 TCB + 深拷贝 MemorySet + 复制 trap_cx
exec是用来替换自己的程序映像,不新建进程原地换 MemorySet + 重置 trap_cx
waitpid回收子进程,拿它的退出码找僵尸子进程。
这里fork实现比较有意思,它修改了保存的上下文,实现如下:
pub fn sys_fork() -> isize {
let current_task = current_task().unwrap();
let new_task = current_task.fork();
let new_pid = new_task.pid.0;
// modify trap context of new_task, because it returns immediately after switching
let trap_cx = new_task.inner_exclusive_access().get_trap_cx();
// we do not have to move to next instruction since we have done it before
// for child process, fork returns 0
trap_cx.x[10] = 0;
// add new task to scheduler
add_task(new_task);
new_pid as isize修改后,这样可以让父进程返回子进程的pid,子进程会获取0,这样可以分辨父子进程。
调度器的全部状态就是一个Vec:
pub struct TaskManager {
ready_queue: Vec<Arc<TaskControlBlock>>,
}
lazy_static! {
pub static ref TASK_MANAGER: UPSafeCell<TaskManager> =
unsafe { UPSafeCell::new(TaskManager::new()) };
}Ready 的进程。 Running 的进程在 PROCESSOR.current 里,不在这个 Vec 里。Vec 是无序的。 顺序由 push 决定,没有任何排序——排序的责任完全落在 fetch 的扫描上。插入进程就是直接在尾部插入
pub fn add(&mut self, task: Arc<TaskControlBlock>) {
self.ready_queue.push(task);
}fetch则是扫描一遍然后获取最低的stride,然后将该任务拿出来运行
pub fn fetch(&mut self) -> Option<Arc<TaskControlBlock>> {
let mut min_stride = usize::MAX;
let mut min_idx = None;
for (idx, task) in self.ready_queue.iter().enumerate() {
let stride = task.inner_exclusive_access().stride;
if stride < min_stride {
min_stride = stride;
min_idx = Some(idx);
}
}
let idx = min_idx?;
let task = self.ready_queue.remove(idx);
{
let mut inner = task.inner_exclusive_access();
inner.stride += inner.pass;
}
Some(task)
}拆开看有四个决策点:
<,不是 <=。 后果是平局时第一个被遍历到的胜出,也就是 Vec 里靠前的那个。而 add 是 push 到尾部,所以"让出 CPU 后被重新排到队尾"天然构成 FIFO 轮转。如果写成 <=,平局时永远选最后一个,队列靠前的进程会长期挨饿。suspend_current_and_run_next 或 add_task 里,那么走 exec、走 exit、被 waitpid 回收的进程永远不经过放回路径,这次调度就白嫖了。放在取出时,每一次调度恰好记一次账,与进程后来怎么结束无关。remove(idx) 是 O(n)。 它要把后面的元素整体前移。加上扫描本身也是 O(n),fetch 整体是 O(n)。同时 remove 会保持剩余元素的相对顺序,这一点对 ① 里的平局规则很重要——如果用 swap_remove,队列顺序会被打乱,FIFO 语义就没了。第五章新增的其实不多:一个 TaskControlBlock 的分层结构、一个四状态的状态机、一个 stride 调度器、一个 Processor 来管"谁在跑"。但把这些东西拼在一起之后,fork/exec/waitpid 这三件看起来玄乎的事情就都落地了
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。