0%

2023开源操作系统训练营第三阶段项目一基本任务总结报告

Arceos unikernel summary

Exercise 1

实验中 payload 中的 app.bin 文件是通过一系列工具组合编译处理打包而来的,在实验一中,只是用 objdump 工具将 elf 文件中的一些调试信息和元信息删除掉,并通过 dd 命令生成一个32M 的空文件并将应用放在空文件的开始位置,在 arceos 中 loader app 运行的时候,将镜像写入给 qemu 的 pflash 供 loader 访问。参考练习提示:“可以为镜像设置一个头结构”,并考虑到后面的练习会有在一个镜像中写入多个 app 的操作,可以想到这个头结构是在 “镜像制作” 过程中被结构的,也就是可以在 dd 命令生成的空 32M 镜像上做文章,而不需要深入到 hello_app 的编译和链接过程。所以可以考虑写一个脚本,计算编译处理过后的 bin 文件的大小,并将其写入到 32M 的空文件头部,作为这个镜像的头结构,并在后续镜像内存中写入 app 的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import struct

# 定义头部结构
header_format = '>Q'
header_size = struct.calcsize(header_format)

# 读取原始应用程序二进制文件
with open('test.bin', 'rb') as f:
app_data = f.read()
app_size = len(app_data)

# 计算应用程序大小
print(app_size)

# 创建新的二进制文件
with open('test_app.bin', 'wb') as f:
# 写入头部(包含应用程序大小)
f.write(struct.pack(header_format, app_size))

# 附加原始应用程序数据
f.write(app_data)

这是一个简单的 py 镜像处理脚本,对于一个单应用而言,头结构只需要存储一个整数,注意这里的头结构的数据存储的大小端顺序要和 loader 中解析 头结构 的代码要一致。这里选择大端序存储的 8 字节无符号整数,这样生成的镜像开头就会先存储一个 64 位的数据作为这个 app 的长度,可以用 xxd -ps 命令查看:

1
2
root@08e03dc057f5:~/phease3/test_app/test4# xxd -ps test_app.bin
000000000000000e411106e422e00008730050100000

可以看到前 8 字节保存的数据为 app 的大小:14,在 loader 里面首先根据大端序读取镜像头,就可以知道 app 的实际大小了。

Exercise 2

练习二需要在镜像中存在两个应用,完成练习一后思路便明朗了起来,直接在原先的 py 脚本上处理即可:

1
2
3
4
5
6
7
8
9
10
11
12
- header_format = '>Q'
+ header_format = '>QQ' #表示头结构存储了两个64位无符号整数(大端序)

+ with open('nop.bin', 'rb') as f:
+ app0_data = f.read()
+ app0_size = len(app0_data)

- f.write(struct.pack(header_format, app_size))
+ f.write(struct.pack(header_format, app_size, app0_size))

f.write(app_data)
+ f.write(app0_data)

在 loader app 中,解析出两个 app 的长度之后,即可计算出每个 app 的起始地址和长度,并打印相关数据即可。

Exercise 3

按照练习二的思路,制作一个包含两个 app 的镜像,loader 复制解析每个 app 的长度和将 pflash 上的 app data 拷贝到内存。并分批次的将 app 的数据拷贝到内核可执行地址空间,来逐个运行 app。hello_app 通过编译器处理后,在 arceos unikernel 中,运行 app 等价于函数调用,而编译器在编译 app 的时候已经处理好了调用栈以及 ra 寄存器等,所以只需要 jalr 到 app 的开头,等待程序运行无误后便可将控制权转交给 loader ,运行在实验二的基础上,需要修改内联汇编的代码,将最后一行 j . 删掉以保证程序正常运行。大致运行过程为从 loader app 将 hello_app 的内存载入到可读可写可执行的内存区域并跳转到该 app,运行完成后回到 loader app 接着这一流程载入并运行下一应用。

Exercise 4

根据 arceos 的框架设计

arceos arch

ax_terminate 功能在 axhal 模块被定义并通过 arceos_api 的 arceos_api::sys::ax_terminate 函数暴露给 app,loader app 要实现 terminate 调用需要引入 arceos_api crate,定义一个 abi_terminate 函数,在函数内部调用 arceos_api::sys::ax_terminate ,并在 abi_table 注册这个调用即可。这个实验实现较为简单,但是在这一步的时候 loader app 内部的 main 文件涵盖的内容太多了,所以就想到了模块化,新建了 parse 和 abi 文件,将处理镜像头结构的代码转移到 parse,abi 相关调用转移到 abi 文件。

Exercise 5

在做练习五的时候,首先先尝试多次 abi 调用,将实验五的 main 函数的代码独立出来成为 putchar 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn putchar(c: char) {
let arg0 = c as u8;

unsafe { core::arch::asm!("
li t0, {abi_num}
slli t0, t0, 3
add t1, t0, a7
ld t1, (t1)
jalr t1",
abi_num = const SYS_PUTCHAR,
in("a0") arg0,
) }
}

接着在 main 函数中多次调用 putchar 函数,制作成镜像后在 arceos 中运行,会报 LoadPageFault 错误。在进行了艰难的 debug 后,发现 a7 寄存器在第一次调用后,其值就失效了,猜测也许是在 print 调用中等过程导致这个寄存器的值发生了更改,而内联汇编编译过后 app 也并没有自动保存这个调用者寄存器,最终在尝试下,发现可以先将 abi_table 的地址先取出来作为一个全局的静态变量 ABI_TABLE_ADDRESS 中,然后在 putchar 函数中,先 ld 这个值的符号, 将这个值存储在一个临时寄存器中,再进行后续计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn putchar(c: char) {
let arg0 = c as u8;

unsafe { core::arch::asm!("
li t0, {abi_num}
slli t0, t0, 3
ld t2, {abi_table}
add t1, t0, t2
ld t1, (t1)
jalr t1",
abi_num = const SYS_PUTCHAR,
abi_table = sym ABI_TABLE_ADDRESS,
in("a0") arg0,
) }
}

通过反汇编命令:

1
cargo objdump --release --target riscv64gc-unknown-none-elf --bin hello_app -- -d

反汇编发现 rust 若按照第一种方式,编译器会将代码编译为两次在一个寄存器中取值,然而这个值会在 abi 调用后失效。在交流群里询问才得知需要在内联汇编上加上一行 clobber_abi(“C”),这时编译器会帮你自动加上某个 abi 的 function call 的 caller-saved 的寄存器会在内联汇编被使用的提示,从而会保证程序上下文寄存器的值在函数调用前后都有效。具体的 ref: https://doc.rust-lang.org/reference/inline-assembly.html.

在测试完多次 abi 调用没有问题后,封装了一个 puts 函数:

1
2
3
fn puts(s: &str) {
s.bytes().for_each(|c| putchar(c as char))
}

Exercise 6

我们看一下练习六的要求

  1. 仿照 hello_app 再实现一个应用,唯一功能是打印字符 ‘D’。
  2. 现在有两个应用,让它们分别有自己的地址空间。
  3. 让 loader 顺序加载、执行这两个应用。这里有个问题,第一个应用打印后,不能进行无限循环之类的阻塞,想办法让控制权回到 loader,再由 loader 执行下一个应用。

对于第一个要求:我们可以在练习二的基础上制作一个包含两个 app 的镜像,并可以在 loader 中读取 pfalsh 中的 app data。对于第三个要求顺序加载执行,我们的 parse 模块会解析每个 app 的起始地址和长度,按照练习三中提到的执行流程即可,且我们删掉了汇编中的 j . ,以及在练习六种我们加上了 clobber_abi("C") 来保证寄存器在调用前后都有效,以保证程序正常返回到 loader,对于第二条要求,在第三条顺序执行加载的情况下可以得到保证。