Unikernel内核模式下支持Linux多应用的前期准备

题目链接:https://raw.githubusercontent.com/arceos-org/oscamp/refs/heads/main/tour_books/prepare_unikernel_for_linux_apps/README.md

代码实现仓库(包含实验五的复现方式):https://github.com/yumu20030130/arceos

通过这个实验,主要学习了ArceOS的Unikernel模式下如何加载用户程序并执行。之前是内核直接启动一个应用(该应用不可被加载,是和内核一起编译的),现在把这个应用变成一个loader程序,然后通过这个程序再去加载一个或多个外部的用户程序,且加载的是bin文件而非elf文件,直接省去了elf加载的细节,只需把整个文件都load到内存中,并设置正确的执行开始入口,即可开始运行。

这样是最基础且最简单的加载方式,通过这些实验,可以很好地理解,Unikernel下加载一个最简单应用需要做什么。

Debug方式

查看应用的elf文件

1
riscv64-linux-musl-readelf -lw ./payload/hello_c/hello

主要看text段,看该程序的汇编执行代码,以及都有哪些段,是怎么布局的

查看应用的bin文件

1
xxd -l 6 ./payload/apps.bin

和elf文件对照着看,大致了解elf文件到bin文件是怎么转换的。(可以发现,在有了字符串字面量之后,除了text段还会有数据段,放在bin文件的最开始,此时程序开始执行位置就不是bin文件的起始了,loader开始执行应用的起始地址需要修改)

查看qemu.log

可以看到实际运行了哪些汇编命令,具体设置看https://raw.githubusercontent.com/arceos-org/oscamp/refs/heads/main/tour_books/prepare_unikernel_for_linux_apps/README.md

练习1

练习一:之前是分配一个固定大小的虚拟地址空间(32字节)来load应用,现在要求在load应用时动态获取应用大小;

简单做法:设计一个Image头,在执行APP编译及入磁盘脚本时,把各个应用的大小放在PFLASH的最前面(在所有应用之前),loader程序一开始就先加载这部分内容,初始化一个ImageHeader对象。

1
2
3
4
pub struct ImageHeader {
files_size: [u8; APP_NUM],
}
由于入磁盘脚本使用的是dd命令,不支持多个dd命令连续写(默认覆盖写),但可以指定开始写的块(块大小设置为1M)。所以,此处将第一个块用于放置所有应用的大小,剩下一个块放一个应用(则一个应用不得超过1M)。

练习2

练习二:支持连续加载两个应用(每个应用只有一条指令)并拼接起来运行。

简单做法:可以观察到,最简单的应用(仅含一条指令),编译出来的bin文件也就只有一条指令,理论上加载到连续的虚拟地址空间即可拼接运行,但是由于应用中有option(noreturn),会导致多两个00字节在bin文件最后,所以实际上,最后两个字节不要加载。

练习3

练习三:批处理方式执行两个单行代码应用,第一个应用的单行代码是nop,第二个的是wfi

简单做法:在最外面套一层for循环就行了,每次只加载一个应用

练习4

练习四:支持一个新的系统调用

简单做法:仿照示例拓展一下就行了

练习5

练习五:应用hello_app通过ABI获取loader测(OS侧)提供的服务。

简单做法:
loader程序在跳转到用户程序之前,将ABI_TABLE(一个函数数组地址,数组元素是对应索引的服务函数地址)放在a7寄存器中,此处可以在一开始将a7寄存器保存下来,之后通过直接写rust代码来让编译器去规范寄存器使用,以逃避一些难以理解的寄存器误改。(注释处是之前存在问题的汇编代码)

效果:
可以多次调用hello、puts、terminal函数,即多次通过ABI方式调用OS提供的服务。此处,调用OS提供的服务实际上就是普通的function_call,而不是syscall,这也正是Unikernel需要的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#![feature(asm_const)]
#![no_std]
#![no_main]

use core::mem;
const SYS_HELLO: usize = 1;
const SYS_PUTCHAR: usize = 2;
const SYS_TERMINATE: usize = 3;

static mut ABI_TABLE: usize = 0;

unsafe fn syscall(abi_num: usize, arg0: usize) -> isize {
type FunctionType = fn(usize);
unsafe {
// 将 usize 转换为函数指针并调用
// 记得做取值操作: 对应 ld t1, (t1)
let func_ptr: FunctionType = mem::transmute(*((ABI_TABLE + abi_num * 8) as *const usize));
func_ptr(arg0)
}
// core::arch::asm!("
// slli t0, t0, 3
// add t1, a7, t0
// ld t1, (t1)
// jalr t1
// ",
// in("a7") ABI_TABLE,
// in("t0") abi_num,
// in("a0") arg0,
// );
0
}

fn hello() -> isize {
unsafe { syscall(SYS_HELLO, 0) }
}

fn putchar(c: char) -> isize {
unsafe { syscall(SYS_PUTCHAR, c as usize) }
}

fn terminate() -> isize {
unsafe { syscall(SYS_TERMINATE, 0) }
}

fn puts(s: &str) -> isize {
for c in s.chars() {
putchar(c);
}
putchar('\n');
0
}

#[no_mangle]
unsafe extern "C" fn _start() -> ! {
unsafe {
core::arch::asm!(
"mv {0}, a7", // 将 a7 寄存器的值移到ABI_TABLE
out(reg) ABI_TABLE,
);
}
hello();
puts("puts");
terminate();
loop { }
}

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

在上面的代码中,如果使用汇编代码,会发现,puts("puts")在输出第二个字符时会出错,而如果换成多个单字符输出,而不是使用for循环,就不会有问题。

如果直接使用多次putchar,实际没有需要维护的寄存器上下文,直接写这种调用函数前没有保存寄存器的汇编是没问题的,而for循环使用了a1寄存器,此时没有保存a1寄存器,而callee不负责保存a1寄存器,可能直接更改(观察qemu.log,可以发现确实如此),从而导致之后for循环出问题。

在riscv标准中,caller负责保存及恢复a0a7、t0t6、ra寄存器,之后才是将参数放到对应的a0等寄存器。callee负责保存及恢复s0 ~ s11寄存器,即其他寄存器,callee都是默认已经保存,可以直接改的。

所以,如果使用rust编写的函数调用,编译器会根据当前的上下文,决定哪些寄存器需要保存,所以直接使用rust调用就没问题。

如果硬要自己写汇编,跳转到对应的ABI函数,最好就是按照riscv标准,在跳转之前保存好所有负责保存的寄存器,这样就也不会出错。

此外,可以将a7寄存器传递ABI_TABLE改为a0寄存器传递,这样给_start增加一个参数,就可以直接免去所有直接汇编书写,完全避免寄存器操作不当。