进程管理

进程是OS资源分配的最小单位,NUDT-OS中使用Process结构体表示进程。每个用户进程拥有独立的地址空间,进程拥有的资源包括:文件、互斥锁、信号量。

一、数据结构

// kernel/src/task/process.rs
pub struct Process {
    /// 进程id
    pub pid: usize,
    /// 此进程已退出但还没被回收
    pub zombie: bool,
    /// 退出码
    pub exit_code: i32,
    /// 进程地址空间
    pub vm: Option<MemorySet>,
    /// 父进程
    pub parent: Option<ProcPtr>,
    /// 子进程队列
    pub children: Vec<ProcPtr>,
    /// 线程队列
    pub threads: Vec<Box<Thread>>,
    /// 打开的文件表
    pub files: Vec<Option<Rc<dyn File>>>,
    /// 持有的互斥锁
    pub mutexes: Vec<Box<dyn Mutex>>,
    /// 持有的信号量
    pub sems: Vec<Sem>,
}

由于目前内核尚不支持多核,任意时刻只有单线程运行,故未添加Arc或Mutex处理,后续将会进行改进。

这里并没有为进程区分自己的状态,这是因为进程的状态是由其包含的线程状态决定的。只要进程中有运行的线程,则进程就应该处于运行态,在Process结构体中,我们只需要添加一个zombie标志,当线程中的主线程(0号线程)退出时,进程的zombie标志置为true,表示进程已退出但还未被回收,而父进程使用waitpid方法时,若子进程的zombie标记为true,则回收进程资源。

二、关键方法

下面给出进程的重要方法:即fork、exec、waitpid三个经典的方法,将他们结合使用就可以实现多进程例如用户态的shell程序。

2.1 fork()

// impl Process
/// 复制当前进程
///
/// 目前只支持单线程进程
pub fn fork(&mut self) -> ProcPtr {
    assert_eq!(self.threads.len(), 1);
    let child = Box::leak(Box::new(Process {
        pid: new_id(),
        // 复制父进程的地址空间
        vm: self.vm.clone(),
        // 复制父进程的文件表
        files: self.files.clone(),
        ..Process::default()
    }));
    // 为子进程创建自己的主线程
    let t = unsafe {
        let child = child as *mut Process;
        PID2PROC.get().insert((*child).pid, &mut *child);
        self.add_child(&mut *child);
        Thread::new(&mut *child, user_task_entry, 0).0
    };
    let f = t.syscall_frame();
    // 复制父进程根线程的syscall_frame
    *f = *self.threads[0].syscall_frame();
    // 设置子进程的返回值为0
    f.caller.rax = 0;
    child
}

fork函数将会复制当前进程的一些重要资源如地址空间、文件表等。另外,子进程创建自己的主线程,子进程主线程将会复制父进程主线程的syscall_frame,这里的syscall_frame中存放的是线程在用户态中的上下文,子进程在进入用户态之前会从syscall_frame中取出通用寄存器的值和rip的值(详细说明可以参考系统调用)。

这样当子进程的主线程被调度执行时将会从父进程主线程进行系统调用前的上下文开始执行,下面给出了一个用户程序使用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);
}

2.2 exec()

/// 将当前进程的进程入口设置为elf文件入口
///
/// 将命令行参数放在用户栈上(用户程序的main函数不是使用call指令
/// 调用的,而是手动修改rip跳转的,因此需要手动设置rdi和rsi参数)
///
/// 目前只支持单线程的进程
pub fn exec(&mut self, path: &str, args: Vec<String>) -> isize {
    assert_eq!(self.threads.len(), 1);
    if let Some(file) = open_file(path, OpenFlags::RDONLY) {
        let elf_data = file.read_all();
        let (entry, vm) = mm::load_app(&elf_data);
        vm.activate();
        let mut top = (USTACK_TOP - (args.len() + 1) * size_of::<usize>()) as *mut u8;
        let argv = top as *mut usize;
        unsafe {
            for (i, arg) in args.iter().enumerate() {
                top = top.sub(arg.len() + 1);
                core::ptr::copy_nonoverlapping(arg.as_ptr(), top, arg.len());
                // '\0'
                *top.add(arg.len()) = 0;
                *argv.add(i) = top as _;
            }
            // Set argv[argc] = NULL
            *argv.add(args.len()) = 0;
        }
        self.vm = Some(vm);
        let f = self.threads[0].syscall_frame();
        // 进入用户态的sysretq指令会从rcx中恢复ip
        // 从r11中恢复rflags
        f.caller.rcx = entry;
        f.caller.r11 = my_x86_64::RFLAGS_IF;
        f.callee.rsp = top as usize & !0xF;
        f.caller.rdi = args.len();
        f.caller.rsi = argv as _;
        0
    } else {
        -1
    }
}

exec()从文件系统中加载一个elf文件,将其装载到进程的地址空间中。可以将这个过程分成三个部分:

  • 读取elf文件,并为进程创建地址空间
  • 命令行参数处理,将其放在用户栈上
  • 初始化进程的syscall_frame结构,将程序入口点,栈顶,参数等写入其用户态上下文中,进程进入用户态之前就会取出这些上下文恢复寄存器

结合使用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);
}

2.3 waitpid()

///  回收一个子进程,返回(子进程号,子进程退出码)
    ///
    /// 未找到子进程-> -1; 找到未回收-> -2;
    pub fn waitpid(&mut self, pid: isize) -> (isize, i32) {
        let mut found_pid = false;
        for (idx, p) in self.children.iter().enumerate() {
            // 若pid为-1,表示回收任一子进程
            if pid == -1 || p.pid == pid as usize {
                found_pid = true;
                if p.zombie {
                    let child = self.children.remove(idx);
                    let ret = (child.pid as _, child.exit_code);
                    unsafe {
                        // drop it
                        drop(Box::from_raw(child));
                    }
                    return ret;
                }
            }
        }
        (if found_pid { -2 } else { -1 }, 0)
    }

waitpid()由父进程调用,回收其子进程,可以指定子进程的pid,也可以令pid为-1,将会回收第一个僵死的子进程。僵死子进程在上面已说明,表示已经退出但是还没有被回收的进程,父进程使用waitpid时将回收僵死子进程的资源并释放内存。

这里以内核主线程的线程函数为例进行说明:

Thread::new(
    root,
    |_| {
        let cur = current();
        // 回收已退出子进程
        loop {
            my_x86_64::disable_interrupts();
            cur.proc.waitpid(-1);
            my_x86_64::enable_interrupts_and_hlt();
        }
    },
    0,
);

内核主线程无限循环回收僵死进程。