进程管理

进程是OS资源分配的最小单位,NUDT-OS中使用Process结构体表示进程。

在设计进程时,我们基本上采用和宏内核相同的设计,每个用户进程有独立的地址空间,并维护独立的内核资源,进程中包含的多个用户线程共享进程的所有资源。

数据结构

// kernel/src/task/process.rs
/// 进程抽象
#[derive(Default)]
pub struct Process {
    /// 进程id
    pid: usize,
    /// 进程名称,可重复
    name: String,
    /// 进程地址空间
    memory_set: Arc<MemorySet>,
    /// 父进程
    parent: RwLock<Weak<Process>>,
    /// 子进程队列
    children: Cell<Vec<Arc<Process>>>,
    /// 进程包含的线程集合,TID到线程对象的映射
    threads: Cell<HashMap<usize, Arc<Thread>>>,
    /// 线程ID,创建新线程时分配
    thread_id: AtomicUsize,
    /// 文件表
    file_table: Cell<Vec<Option<Arc<dyn File>>>>,
    /// 互斥锁
    mutexes: Cell<Vec<Arc<MutexBlocking>>>,
    /// 信号量
    sems: Cell<Vec<Arc<Sem>>>,
    /// 条件变量
    condvars: Cell<Vec<Arc<Condvar>>>,
}

每个进程持有一个虚拟地址空间的指针Arc<MemorySet,这是进程间地址隔离的关键。每个虚拟地址空间都采用独立的一张页表,所以多个进程中的数据可能有同样的虚拟地址但是映射到不同的物理地址。

创建和使用进程

Unix的简单设计哲学将创建一个进程分成两个简单的步骤:即forkexec两个经典的方法。fork()复制当前进程的地址空间和所有资源,而exec()为新进程加载一个用户程序执行。

我们遵循了Unix的使用方式,fork()exec()结合使用来实现进程的创建和使用

fork()

fork()复制当前进程并创建一个新线程,若当前进程有多个线程,只复制当前线程。


/// 复制当前进程
///
/// 若当前进程有多个线程,只复制当前线程
///
/// 返回(子进程指针, 子进程id)
pub fn fork(&mut self) -> (Arc<Self>, usize) {
    let current_thread = CURRENT_THREAD.lock();
    // 复制当前进程的地址空间(栈空间没复制)
    let child_memory_set = self.vms().self_clone();

    // 复制栈空间
    ...

    // 创建子进程
    ...

    // 创建子进程的根线程
    ...
    // 复制当前线程的UserContext
    let root_thread_user_context = &mut root_thread.user_context();
    *root_thread_user_context = current_ctx;
    // 子线程的返回值为0
    root_thread.user_context().general.rax = 0;

    child_proc.insert_thread(root_thread);
    self.add_child(child_proc.clone());

    (child_proc, child_pid)
}
  • 复制当前进程的所有资源包括地址空间、文件表、互斥锁等。
  • 子进程创建自己的主线程
  • 子进程主线程将会复制当前线程的UserContext,这里的UserContext中存放的是线程在用户态中的上下文,子进程在进入用户态之前会从其中恢复用户态现场,在用户线程中有更详细的说明

这样当子进程的主线程被调度执行时将会从当前线程进行系统调用前的上下文开始执行,下面给出了一个用户程序使用fork()的简单示例

// An example in user program
let pid = fork();
// 子进程的根线程进入用户态后从这里开始运行
if pid == 0 {
    // 子进程
    println!("I am child {}", i);
    exit(0);
} else {
    // 父进程
    println!("I am parent, forked child pid = {}", pid);
}

exec()

exec()从文件系统中加载一个elf文件,将其装载到进程的地址空间中。

/// 使用新的elf替换当前进程的elf
pub fn exec(&self, new_name: String, elf_data: &[u8], args: Option<Vec<String>>) -> usize {
    trace!("[Kernel] start exec {}", new_name);

    // 清理运行exec之外的其他所有子线程
    ...

    // 清理原ELF类型的虚存块
    ...

    // 载入elf到虚存空间
    let elf = ElfFile::new(elf_data).unwrap();
    parse_elf(Arc::downgrade(&self.vms), &elf);
    let entry = elf.header.pt2.entry_point() as usize;

    // 准备根线程的栈虚存块
    ...

    // 参数压到用户栈
    let sp_offset = push_to_stack(thread_stack_pfg, args);
    let root_sp = sp_offset + USER_STACK_BASE;
    let root_ip = entry;
    // 设置线程的用户态上下文
    current_thread.test_set_ip(root_ip);
    current_thread.test_set_sp(root_sp);

    threads_lock.insert(current_thread.tid(), current_thread);

    root_sp
}
  • 读取elf文件,创建虚拟地址空间,将elf文件中的各个section加载到内存空间中
  • 命令行参数处理,将其放在用户栈上
  • 初始化进程的UserContext结构,将程序入口点,栈顶,参数等写入其用户态上下文中,进程进入用户态之前就会取出这些上下文恢复寄存器

结合使用fork和exec就可以实现用户态的shell程序,下面是一个简易的结构示意:

// An example of shell
let pid = fork();
// 子进程
if pid == 0 {
    // 执行应用程序
    if exec(args_copy[0].as_str(), args_addr.as_slice()) == -1 {
        println!("Error when executing!");
        return -4;
    }
    unreachable!();
// 父进程
} else {
    children.push(pid);
}