设计思路
模块划分¶
依据模块化内核的设计思想,我们期望我们的内核能够在尽可能保留原有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。