Unikernel模式尝试劫持ecall以支持Linux应用运行

前言

在前面的博客中,提到了ArceOS宏内核模式是如何在libc层和OS层之间实现对Linux应用运行的支持的。
而这些Linux应用,都是默认其运行于用户态,然后通过ecall陷入内核态,进行系统调用来访问内核功能。而Unikernel中,应用也运行于内核态,则其实只需要函数调用即可。
如果想要纠正这种偏差,一种可以预见的方法是,通过对编译出来的elf文件里的所有ecall指令进行重写,将其变成一个跳转指令,以逃避ecall带来的各种特权级变换,同时满足函数调用的需求。

需要做的改动

这个功能其实可以由m_3_0(宏内核模式支持Linux应用)“退化”而来,基本思路是加载elf的可执行段时(含E标识)二进制重写其中的ecall,同时通过一些手段让进入用户空间时仍保持在内核态。

保持内核态

前面【最简宏内核模式内核构建】介绍过,一个进程首次从内核态进入用户态,是通过设置sstatus、sepc等寄存器,并利用sret的特性完成的。其中,sstatus寄存器中的SPP位就决定了sret之后应该是什么状态。

如下所示,将SPP位设置为1,表明“进入内核态之前”是U态,那么之后sret就会返回U态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// oscamp/arceos/modules/axhal/src/arch/riscv/context.rs
pub fn new(entry: usize, ustack_top: VirtAddr) -> Self {
const SPIE: usize = 1 << 5;
const SUM: usize = 1 << 18;
const SPP: usize = 1 << 8;
Self(TrapFrame {
regs: GeneralRegisters {
sp: ustack_top.as_usize(),
..Default::default()
},
sepc: entry,
sstatus: SPIE | SUM | SPP,
})
}

注意,此处其实只影响了sret之后的状态,用户应用起始地址照常放在sepc寄存器,栈也可以照常转换为用户栈。
值得提醒的是,ecall会由硬件设置sepc和sstatus,sret会读取sepc和sstatus的值,但sscratch其实是不会被这两个指令影响的。它目前是常规用法:在进程进入用户态后保存着当前进程的内核栈地址,但这都是可以看你代码怎么写的,不是规定的。哪怕进程一直都只有内核态,它也可以分内核栈和用户栈。

而且,有意思的是,在trap起始时,目前是通过观察sscratch寄存器是否为0来判断当前是否处于用户态的:
如果sscratch不为0,则认为目前处于用户态,sscratch中存着内核栈指针,则把sscratch和sp交换,开始使用内核栈。在这样的逻辑下,即使我们的应用实际处于内核态,但只要我们有用户栈,就也把我们当作用户态。

说白了,某种意义上,之前的状态切换都没有消失,只是特权级始终不切换,仅仅切换栈。(可能仅有内核态之后用户栈是无意义的,但此处仅考虑这样能不能正常运行)

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
.section .text
.balign 4
.global trap_vector_base
trap_vector_base:
// sscratch == 0: trap from S mode
// sscratch != 0: trap from U mode
csrrw sp, sscratch, sp // swap sscratch and sp
bnez sp, .Ltrap_entry_u

csrr sp, sscratch // put supervisor sp back
j .Ltrap_entry_s

.Ltrap_entry_s:
SAVE_REGS 0
mv a0, sp
li a1, 0
call riscv_trap_handler
RESTORE_REGS 0
sret

.Ltrap_entry_u:
SAVE_REGS 1
mv a0, sp
li a1, 1
call riscv_trap_handler
RESTORE_REGS 1
sret

增加函数调用处理者

效仿上面的trap_vector_base,可以加一个func_call_base,然后之后改写ecall(如果U态调用ecall,就会跳转到stvec寄存器存的地址,此处存的就是上面trap_vector_base的地址),使其跳转到这个函数就行了,使用extern "C"的方式可以在rust中获取func_call_base所在的虚拟地址。

1
2
3
4
5
6
7
8
.global func_call_base
func_call_base:
csrrw sp, sscratch, sp // swap sscratch and sp
SAVE_REGS 1
mv a0, sp
call func_call_handler
RESTORE_REGS 1
ret

此处改写,只是想逃避ecall带来的特权级转换,所以之后,func_call_handler的设计,直接使用riscv_trap_handler里的系统调用相关分支就行了。

至此,一切看起来都很美好,应用始终保持在内核态,trap触发时能像之前一样该换栈时换栈,ecall单独处理,换成一个函数调用。

存在的问题

要保证改写复杂度低,就要保证跳转到func_call_base在一条仅有64位的指令中完成,如果想通过立即数直接跳到该函数,则需要保证这个函数的地址在地址空间的最低或者最高的一小个范围,而这些范围通常都有其他数据(ELF可执行的LOAD段一般就会在虚拟地址最低处,而最高的一段地址一般也不归应用用),如果用个寄存器一直存着这个函数的地址,感觉也很浪费。

或者通过写链接脚本等方式,强制musl_libc编译出来的ELF文件把最开始的一段空间空出来,是可以做到的,但这个劫持方式的初衷,就是像musl_libc之前编译出来的应用直接可以运行,这样又要重新编译的话,似乎违背初衷了。

上面说了这么多,但我甚至还没能简单运行出一个简单的hello,所以“坑”可能比我想象的还要多得多。不管怎么样,算是一个尝试吧。