Skip to content

设计思路

模块划分

依据模块化内核的设计思想,我们期望我们的内核能够在尽可能保留原有unikernel的基础上同时兼容宏内核功能,因此我们在arceos的基础上新增了如下模块:

  • axmem:引入地址空间
  • axtask/monolithic_task:修改原有task模块,添加了更多任务状态,从而使得任务可以作为线程的形式被进程调度
  • axsignal:信号模块
  • axprocess:引入进程概念,支持多进程多线程运行,添加了很多与Linux系统调用相关的内容
  • starry_libax:Linux系统调用用户库,包装了作为Linux兼容层的众多对外接口。

进程引入

为了运行Linux相关的应用,我们需要让不同任务之间存在父子等关系,因此我们引入了进程的概念。在标准的Linux中,进程和线程统一用pthread结构体代替,但我们为了保证原有arceos的任务调度结构不受过大影响,因此选择将进程和线程进行分离,进程保存在独立的结构体Process中。

依据模块化的思想,我们可以将进程视为一个容器,存储了各类运行时资源,包括虚存、文件描述符、 线程、信号等。 在该种设计理念下,进程仅是对上述资源的一个统一与包装。因此可以通过添加 feature 等方式将进程作为一个可插拔模块,使得内核在宏内核架构与微内核架构中随时进行切换。

进程结构设计如下:

pub struct ProcessInner {
    /// 父进程的进程号
    pub parent: u64,
    /// 子进程
    pub children: Vec<Arc<Process>>,
    /// 子任务
    pub tasks: Vec<AxTaskRef>,
    /// 地址空间,由于存在地址空间共享,因此设计为Arc类型
    pub memory_set: Arc<SpinNoIrq<MemorySet>>,
    /// 用户堆基址,任何时候堆顶都不能比这个值小,理论上讲是一个常量
    pub heap_bottom: usize,
    /// 当前用户堆的堆顶,不能小于基址,不能大于基址加堆的最大大小
    pub heap_top: usize,
    /// 进程状态
    pub is_zombie: bool,
    /// 退出状态码
    pub exit_code: i32,
    /// 文件管理器,存储如文件描述符等内容
    #[cfg(feature = "fs")]
    pub fd_manager: FdManager,
    /// 进程工作目录
    pub cwd: String,
    #[cfg(feature = "signal")]
    /// 信号处理模块    
    /// 第一维代表线程号,第二维代表线程对应的信号处理模块
    pub signal_module: BTreeMap<u64, SignalModule>,

    /// robust list存储模块
    /// 用来存储线程对共享变量的使用地址
    /// 具体使用交给了用户空间
    pub robust_list: BTreeMap<u64, FutexRobustList>,
}

任务结构如下:

/// The inner task structure.
pub struct TaskInner {
    id: TaskId,
    name: String,
    is_idle: bool,
    is_init: bool,
    /// 任务的入口函数,仅在内核态下有效
    entry: Option<*mut dyn FnOnce()>,
    state: AtomicU8,

    in_wait_queue: AtomicBool,
    #[cfg(feature = "irq")]
    in_timer_list: AtomicBool,

    #[cfg(feature = "preempt")]
    need_resched: AtomicBool,
    #[cfg(feature = "preempt")]
    pub preempt_disable_count: AtomicUsize,

    exit_code: AtomicI32,
    wait_for_exit: WaitQueue,
    /// 内核栈,对于unikernel不需要
    #[cfg(feature = "monolithic")]
    kstack: Option<TaskStack>,

    ctx: UnsafeCell<TaskContext>,

    #[cfg(feature = "monolithic")]
    // 对应进程ID
    process_id: AtomicU64,

    #[cfg(feature = "monolithic")]
    /// 是否是所属进程下的主线程
    is_leader: AtomicBool,

    #[cfg(feature = "monolithic")]
    // 所属页表ID,在宏内核下默认会开启分页,是只读的所以不用原子量
    page_table_token: usize,

    #[cfg(feature = "monolithic")]
    /// 初始化的trap上下文
    pub trap_frame: UnsafeCell<TrapFrame>,
    // 时间统计
    #[cfg(feature = "monolithic")]
    time: UnsafeCell<TimeStat>,

    #[allow(unused)]
    #[cfg(feature = "monolithic")]
    /// 子线程初始化的时候,存放tid的地址
    set_child_tid: AtomicU64,

    #[cfg(feature = "monolithic")]
    /// 子线程初始化时,将这个地址清空;子线程退出时,触发这里的 futex。
    /// 在创建时包含 CLONE_CHILD_SETTID 时才非0,但可以被 sys_set_tid_address 修改
    clear_child_tid: AtomicU64,

    #[cfg(feature = "monolithic")]
    /// 退出时是否向父进程发送SIG_CHILD
    pub send_sigchld_when_exit: bool,
}

该种设计的优势如下:

  • 保留了 ArceOS 的结构,可以较为方便地与其他同学开发结果进行结合
  • 耦合度低,因此可以使内核较为方便地在不同模式间进行切换

在该种设计架构下,接受外来系统调用时,需要将部分对线程进行操作的系统调用转发给进程。进程收 到该系统调用之后,再对当前进程下正在运行的线程进行相应的操作。实例为 yield , exit 等。

在生成新的任务时,由于是通过Linux的clone调用生成新的任务,因此可以根据clone的参数判断生成的是新的进程还是线程,从而确定线程所属的进程是哪一个,进程与线程之间形成父子关系,而同一进程下的线程形成兄弟关系,从而可以更加方便地进行管理。

地址空间引入

任务切换

引入了进程之后,由于进程是资源容器集合,因此地址空间相关的存储结构也存放在这里,不同进程之间可以共享或者独享地址空间,因此在切换任务时,只需要额外判断当前所属进程的地址空间的token是否发生改变,就可以完成多地址空间的引入。

特权级切换

目前内核和用户态使用的是同一个地址空间,可以避免trap时更改页表,减少时空损耗。

特权级切换

在Starry中,各种测例运行在用户态下,从内核态进入到用户态的方式有两个:用户程序初始化进入和trap返回。

初始化进入

对于用户程序初始化进入部分,即是在原有ArceOS基础上添加了额外的判断:

判断的原则如下:若要执行的任务的入口函数在内核态,则直接调用即可。否则需要通过手写汇编代码保存寄存器,以类似trap返回的机制调用sret进入用户态执行对应的函数。

extern "C" fn task_entry() {
    // release the lock that was implicitly held across the reschedule
    unsafe { crate::RUN_QUEUE.force_unlock() };
    axhal::arch::enable_irqs();
    let task: CurrentTask = crate::current();
    if let Some(entry) = task.entry {
        if task.get_process_id() == KERNEL_PROCESS_ID {
            // 对于unikernel,这里是应用程序的入口,由于都在内核态所以可以直接调用函数
            // 对于宏内核,这是初始调度进程,也在内核态,直接执行即可
            unsafe { Box::from_raw(entry)() };
        } else {
            // 需要通过切换特权级进入到对应的应用程序
            let kernel_sp = task.get_kernel_stack_top().unwrap();

            let frame_address = task.get_first_trap_frame();
            // 切换页表已经在switch实现了
            first_into_user(kernel_sp, frame_address as usize);
        }
    }
    // only for kernel task
    crate::exit(0);
}

/// 初始化主进程的trap上下文
#[no_mangle]
fn first_into_user(kernel_sp: usize, frame_base: usize) -> ! {
    let trap_frame_size = core::mem::size_of::<TrapFrame>();
    let kernel_base = kernel_sp - trap_frame_size;
    // 在保证将寄存器都存储好之后,再开启中断
    // 否则此时会因为写入csr寄存器过程中出现中断,导致出现异常
    axhal::arch::disable_irqs();
    // 在内核态中,tp寄存器存储的是当前任务的CPU ID
    // 而当从内核态进入到用户态时,会将tp寄存器的值先存储在内核栈上,即把该任务对应的CPU ID存储在内核栈上
    // 然后将tp寄存器的值改为对应线程的tls指针的值
    // 因此在用户态中,tp寄存器存储的值是线程的tls指针的值
    // 而当从用户态进入到内核态时,会先将内核栈上的值读取到某一个中间寄存器t0中,然后将tp的值存入内核栈
    // 然后再将t0的值赋给tp,因此此时tp的值是当前任务的CPU ID
    // 对应实现在axhal/src/arch/riscv/trap.S中
    unsafe {
        asm::sfence_vma_all();
        core::arch::asm!(
            r"
            mv      sp, {frame_base}
            .short  0x2432                      // fld fs0,264(sp)
            .short  0x24d2                      // fld fs1,272(sp)
            LDR     gp, sp, 2                   // load user gp and tp
            LDR     t0, sp, 3
            mv      t1, {kernel_base}
            STR     tp, t1, 3                   // save supervisor tp,注意是存储到内核栈上而不是sp中,此时存储的应该是当前运行的CPU的ID
            mv      tp, t0                      // tp:本来存储的是CPU ID,在这个时候变成了对应线程的TLS 指针
            csrw    sscratch, {kernel_sp}       // put supervisor sp to scratch
            LDR     t0, sp, 31
            LDR     t1, sp, 32
            csrw    sepc, t0
            csrw    sstatus, t1
            POP_GENERAL_REGS
            LDR     sp, sp, 1
            sret
        ",
            frame_base = in(reg) frame_base,
            kernel_sp = in(reg) kernel_sp,
            kernel_base = in(reg) kernel_base,
        );
    };
    core::panic!("already in user mode!")
}

trap切换

trap切换对应的汇编代码在axhal/src/arch/riscv,值得关注的是其嵌套trap的处理。在第一次进入trap时,是从用户态进入到内核态,此时会将内核栈的地址赋给sp,将用户栈的地址存在内核栈上,并将sscratch清零。若发生内核嵌套trap,则此时sscratch的值为0,与sp交换之后,sp为0,即发生了内核嵌套trap。

因此可以通过交换之后sp是否为0来判断是否发生了内核嵌套trap。