Lab0: 拆炸弹
总结
感觉做下来还是蛮有意思,做得最难受的是phase4,这加密过程看得我脑壳痛……
第一次接触ARM架构,还是蛮新奇的。
stp x29, x30, [sp, #-16]!
将寄存器 x29(帧指针, EBP)和 x30(链接寄存器, RA)压入栈中,同时将栈指针sp减去16字节。
[sp, #-16]表示的是sp-16的位置,然后!表示把这个值写回sp寄存器
还有
ldp x29, x30, [sp], #16
也是同理。感觉这两个指令还蛮有意思的,节省了部分开销(可能有点点类似什么超标量的思想(当然还是完全不一样)),而且确实用的地方很多(保护&恢复现场),节省了很多开销。
1
2
3
4
5
6
7
8# adrp 指令获取目标地址的高 12 位,并将其放入 x1 寄存器
# GOT 是一个在程序的可执行文件中或在程序加载到内存时由动态链接器创建的表。
# 它包含了程序中所有被引用的全局变量和外部(其他共享库中的)符号的地址。
# PLT 是一个用于支持动态链接的跳转表,它包含了对外部函数的引用。
# 当程序调用一个外部函数时,它首先跳转到 PLT 中相应的条目。
# 然后,PLT 条目会将控制权传递给动态链接器,链接器解析函数的实际地址,
# 并将其存储在 GOT 中。下一次调用同一个函数时,程序会直接跳转到 GOT 中存储的地址,而不再通过 PLT。
adrp x1, 4a0000 <.got.plt+0x18>x0/w0 x1/w1
需要下载
aarch64-linux-gnu-objdump
,貌似不能用通用的objdump
(加了-m选项也还是不行)。
具体内容
通关!
phase0
只需要控制w1和w0的值相等就即可。用GDB调试一下就行。
phase1
这次是字符串
1
2
3
440076c: f9402c21 ldr x1, [x1, #88]
400770: 94008504 bl 421b80 <strcmp>
# 就是说如果w0不为零就爆炸,也就是说输入的字符串要跟x1所在字符串一样
400774: 35000060 cbnz w0, 400780 <phase_1+0x20>phase2
需要了解其栈结构,并且看懂它这个循环代码。迭代计算斐波那契数列。
phase3
phase_3是一个类似分支条件的结构。
第一个参数为3(w1),第二个参数x需满足 : (x^(x>>3)) & 0x07(取后三位) == 3(w1)。3 3即可满足要求。
第一个参数为6,这段计算太复杂了我有点没看懂,略……不过看其他俩的套路应该是6 6。
第一个参数为2(w1),第二个参数x需满足 : (x & 0x7) == 2。2 2即可满足要求。
phase4
一个简单的对输入字符串加密的程序,给定一个经过两次编码变换的结果字符串,要求逆向求出其原始字符串。
helloworlc->isggstsvke
观察encrypt_method2可知其大概是一个字母变换的函数,并且要求输入只能是仅含小写字母(不能含a)的序列
1
24009b4: 51018400 sub w0, w0, #0x61
4009bc: 7100641f cmp w0, #0x19在phase_4中可打印出目标字符串
1
2# 输入字符串和某静态字符串应该相等
400a24: 94008457 bl 421b80 <strcmp>打印x0和x1即可得到两个字符串的地址,访存可知目标字符串为isggstsvke。
测试调试几遍可知,大概思路是encrypt_method1负责乱序,encrypt_method2负责字母映射编码
观察encrypt_method2可获取字母映射表的静态存储地址,打印获得映射表,对2中的字符串反编码,得到原始字母序列
字母映射表在地址0x400998处,查询[x0-97]附近值即可。
encrypt_method1的变换我没太看懂(也有点懒研究哈哈哈),我最终从字母里感觉有点像helloworld(再加上还是10个字哈哈哈)猜出来的。
phase5
最后一个phase是一个递归函数,使用x0和x1交替传递返回值和参数。
观察可知phase_5要求func_5这个递归函数最终返回的是3。查看func_5的逻辑,可以将其转化为类似的伪代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23if (x1 == 0) return 0;
if (x0 == *x1) explode();
if (*x1 <= x0) goto END;
x1 = *(x1 + 8);
x0 = func(x0, x1);
x0 *= 2;
return;
END:
x1 = *(x1 + 16)
x0 = func(x0, x1);
x0 = x0 * 2 + 1
return
// 更高级语言一点
int func (int a, int *b) {
if (*b == 0) return 0
if (a == *b) GG.
if (a < *b) {
return 2 * func(a, (b + 8));
} else {
return 2 * func(a, (b + 16)) + 1;
}
}其中x1指向一个数组。
所以我们可以看出来,他大概就是一直将输入a和数组b中的元素进行对比。如果a大于等于该元素,那么就移动一个步长,继续比较,并且返回一个偶数;否则,移动两个步长,返回一个奇数。
由于phase_5中要求我们得到3,故而可能的结果序列只能是 3 1 0,也即前两次递归都进入分支2(输入必须小于),其他次递归都进入分支1,最后一次递归是由
*b == 0
终止。于是我们可以看看x1所指向的数组的内容:
[49] 0 [20] 0 [92] 0 20 0 [3] 0 [37] 0 92 0 [55] 0 [94] 0 3 0 0 0 0 0 37等等等
当输入为93的时候轨迹如上,很完美实现它的要求。
Lab1: 内核启动
感觉最大的收获还是亲身体验了一把启动流程,以前只简单看过XV6的C代码和听过课的讲解,这还是第一次直接钻到汇编底下看。。。感觉OS作为软硬件接口,非常好的一个地方就是他不会太hw,有需要就设一下寄存器让硬件自己猜就行了,这点我很喜欢哈哈。
实验内容
之前也简单探究过OS启动的流程,这里放上站内文章链接:
kernel boot【BIOS->GRUB】
Code: starting xv6 and the first process【GRUB将操作权给到OS之后的启动】
感觉此处的bootloader就是类似于grub的结构,负责切换异常级别、初始化串口和内存映射并且最终跳转到真.OS。
总体流程及关键函数大概是:
内核镜像构建
本实验代码包含了基础的ChCore 微内核操作系统,除了练习题相关的源码以外,其余部分通过二进制格式提供,最终编译构建成为一个ELF格式的内核映像文件。
可以使用readelf工具或者什么objdump来查看
kernel.img
。通过
kernel/arch/aarch64/boot/linker.tpl.ld
精细设计了.init等段的大小和位置。在cmake文件可看到bootloader大概包含这些东西:
1
2
3
4
5
6
7
8
9
10
11list(
APPEND
_init_sources
init/start.S
init/mmu.c
init/tools.S
init/init_c.c
peripherals/uart.c)
chcore_target_sources_out_objects(${kernel_target} PRIVATE init_objects
${_init_sources})然后具体通过ld配置文件控制每个段的具体位置和装载位置。
_start
(在start.S
中)primary CPU执行主要的初始化流程,其他CPU等待至完成。
arm64_elX_to_el1
(在tools.S
中)负责特权级别的切换,启动时为EL3->EL1。
AArch64 架构中,特权级被称为异常级别(Exception Level,EL),四个异常级别分别为 EL0、EL1、EL2、EL3,其中 EL3 为最高异常级别,常用于安全监控器(Secure Monitor),EL2 其次,常用于虚拟机监控器(Hypervisor),EL1 是内核常用的异常级别,也就是通常所说的内核态,EL0 是最低异常级别,也就是通常所说的用户态。
切换特权级要做的不多,只需修改相关寄存器即可。对于aarch64来说,它假定的特权级切换流程如下:
1
2
3
4
5
6
7
8
9
10假设处理器当前在EL1(异常级别1)运行,并且发生了一个异常导致处理器切换到EL3(异常级别3)来处理该异常。
处理器在进入EL3时:
1. 保存当前状态到SPSR_EL3
2. 保存返回地址到ELR_EL3
在处理完异常后,执行`ERET`指令,处理器会:
1. 从SPSR_EL3获取原有处理器状态。
2. 从ELR_EL3获取原有返回地址。
3. 切换目标特权级别(从SPSR获取),跳转回原先的程序执行。所以在这里,我们相当于需要手动填写一下EL3相关寄存器就行。
原所有代码感觉还是非常优美地封装了(EL3、EL2、EL1)->EL1这几种case的。
如果是EL2切回EL1,看起来其切换逻辑大概是,还是经典eret,只不过把ELR_EL2换成指向一个ret指令的label。也即先进行一个eret,再进行一个ret,那就是类似emm,应该是叫VM Exit之类的。
如果是EL1切回EL1,那就只需要进行一个普通的ret就行,那就是函数调用。
_start
(在start.S
中)准备好C语言环境需要用的栈,然后去执行C代码。
思考题:为什么要在进入 C 函数之前设置启动栈。如果不设置,会发生什么?
调试可知,不设置sp=0,会覆盖未知地址
init_c
(在init_c.c
中)clear bss
思考题 5:在实验 1 中,其实不调用
clear_bss
也不影响内核的执行,请思考不清理.bss
段在之后的何种情况下会导致内核无法工作。感觉多核(此时别的CPU会一直spin),或者说直接用到这些未初始化的全局变量的时候
初始化串口
我之前也写过简单的串口,aarch64也是属于外设和内存统一编址。
内存映射相关
init_kernel_pt
初始化内核页表操作系统内核通常运行在虚拟内存的高地址。在内核运行时,访问内核代码和数据,对任意物理内存和外设内存(MMIO)进行读写,都使用高地址。
因此,在内核启动时,首先需要对内核自身、其余可用物理内存和外设内存进行虚拟地址映射,最简单的映射方式是一对一的映射,即将虚拟地址
0xffff_0000_0000_0000 + addr
映射到addr
。需要注意的是,在 ChCore 实验中我们使用了0xffff_ff00_0000_0000
作为内核虚拟地址的开始(注意开头f
数量的区别),不过这不影响我们对知识点的理解。物理地址范围 | 对应设备| 映射粒度 | 类别
— | — | — | —
`0x00000000`~`0x3f000000` | 物理内存(SDRAM)| 2MB | normal
0x3f000000
~`0x40000000` | 共享外设内存| 2MB | device`0x40000000`~`0xffffffff` | 本地(每个 CPU 核独立)外设内存| 1GB | device > 我们需要在 `init_kernel_pt` 为内核配置从 `0x00000000` 到 `0x80000000`(`0x40000000` 后的 1G,ChCore 只需使用这部分地址中的本地外设)的映射,其中 `0x00000000` 到 `0x3f000000` 映射为 normal memory,`0x3f000000` 到 `0x80000000`映射为 device memory,其中 `0x00000000` 到 `0x40000000` 以 2MB 块粒度映射,`0x40000000` 到 `0x80000000` 以 1GB 块粒度映射。 在kernel中看它意思,我们貌似只采取到L2,故而最终也是以2MB块的形式访存。然后这边的映射关系也是很线性,相当于直接把低地址这些摁抬上高地址了,简单粗暴。
el1_mmu_activate
开启MMU开启MMU映射其实就是把页表地址填入相关寄存器,然后再设置一下控制寄存器就OK了。1
2
3
4
5
6
7
8
9
10/* Write ttbr with phys addr of the translation table */
adrp x8, boot_ttbr0_l0
msr ttbr0_el1, x8
adrp x8, boot_ttbr1_l0
msr ttbr1_el1, x8
isb
mrs x8, sctlr_el1
/* Enable MMU */
orr x8, x8, #SCTLR_EL1_M
思考题 11:请思考在
init_kernel_pt
函数中为什么还要为低地址配置页表,并尝试验证自己的解释。因为在开启完MMU、跳转到高地址(还得再过几步)之前,还需要使用原有的栈和驱动内存。
如果我们未初始化的话,开启MMU的时候会是0:
然后sp保存的又是低地址,所以需要前往低地址页表寄存器,导致之后栈访存指令GG:
ldp x29, x30, [sp], #16
驱动内存也应该是同理可得,大概。
start_kernel
可以看到看起来也是先构造了一个高地址的sp:
1
2
3
4
5
6
7
8ffffff000008e000 <start_kernel>:
# 造出来之后sp=ffffff00003b93f0
ffffff000008e000: 58000302 ldr x2, ffffff000008e060 <secondary_cpu_boot+0x38>
ffffff000008e004: 91400442 add x2, x2, #0x1, lsl #12 # 自高地址向下增长
ffffff000008e008: 9100005f mov sp, x2
ffffff000009470c: a9bf07e0 stp x0, x1, [sp, #-16]!
ffffff0000094710: f0002aa2 adrp x2, ffffff00005eb000 <empty_page>
ffffff0000094714: d5182002 msr ttbr0_el1, x2然后将高地址的新页表地址覆盖
ttbr0_el1
,作用应该是清零低地址页表,这样一来之后访问地址就相当于非法访问内存越界了,于是乎就开启了全高地址映射。然后就跳到main那边了。
小品环节
环境配置
这里记录一下我一个很幽默的无脑行为,最后发现是一场乌龙,给大伙笑一笑算了哈哈哈。
具体是这样,我在做实验之前先开始配环境,然后实验内容做啥都还一眼没看。再加上以前做lab的经历,还有看了错误版本的指导书,我就误以为配完环境直接make qemu
就可以简单跑起来(事实上还需要先写完lab1),然后看到欢迎信息了。
然后,我这docker pull又一直失败,我不得不尝试多种方法来曲线救国,但总归还是不大安心,所以后面我压根没想到这可能是代码问题,一直觉得是环境问题,最后折腾了两个小时才发现原来是还得写Lab1才能启动……
我这很容易不知不觉就陷进细节开始钻牛角尖的毛病是时候该改改了。不过这长达两个小时的折腾过程也让我学了挺多工具(包括我也是第一次使用docker、第一次更细致地了解qemu的用法),所以这里暂且先记录下来。
概述
本次环境配置的大概思路是这样的。docker仅仅是负责提供一个交叉编译环境,最后输出一个kernel.img
文件。然后我们用qemu启动整个ChCore。qemu命令如下:
1 | qemu-system-aarch64 -machine raspi3 -nographic -serial mon:stdio -m size=1G -kernel ./build/kernel.img |
前置环节:根据指导书配置。
网络问题探究
首先尝试发现docker硬是pull不下来,配置阿里云镜像无果,配置pull时代理(采用了方法1)无果。
不大清楚为什么……
直接编译
不得已,选择直接用gcc-aarch64编译。寻找一番可知修改chbuild
脚本中的选项即可:
1 | _main() { |
编译成功通过,然而,make qemu
不知道为什么卡住,但Ctrl+AX依然可以响应:
联想到当初做XV6也有类似问题,是因为qemu版本不匹配。我一开始qemu是6.2.0,但更换qemu版本(试了5.1.0、3.1.0)依然还是不行,百思不得其解,想着会不会是确实编译器版本也有影响,所以准备另寻他路。
自建容器
上个方法不大行得通,所以我换了个思路。docker pull不下来,我就开始自己创建一个新容器。
在github搜索,找到了往年的ChCore-Lab,提供了Dockerfile。然后可能因为docker在普通用户模式下运行,所以我需要给容器加一个跟当前用户id一致的用户。我这边是1000:
1 | # 在该文件基础上增加 |
总之成功把容器整出来了。根据其修改chbuild
脚本容器名即可。
1 | $ docker image ls |
此处还有一个小插曲,make build
之后还爆了奇奇怪怪的错。还好很快发现是我在尝试方法2的时候忘了clean了,不然又得排查老半天。。。
然后总之,也是成功编译出了kernel img,然而依然make qemu
卡住。。。。。
我感觉应该不是qemu版本问题,估计是编译出的kernel img有啥问题,所以我准备具体看看qemu究竟卡在哪了。
探究qemu
qemu_wrapper.sh
的逻辑还蛮经典。
我加了句打印get到了qemu具体的运行命令,然后给它加了个-d
选项用来调试。
1 | $qemu --version |
【其实这里可以用GDB的。我那时因为不知道具体到底什么问题,所以用了-d参数。】
然后我发现有几个CPU(Trace编号不同)都卡在了PC=000000000008000c
。我感觉这长得很像一个很特殊的数字,于是查到了往年sjtu学生的讨论,以及这个,得知这是一段内核启动常见操作,【当然现在貌似没有这个函数了】也即有一个primary CPU负责初始化流程,其他CPU就等待,而这正是等待的逻辑。
从帖子中我了解到主CPU存储在X8寄存器,所以我就去重点关注X8=0
的Trace,最终发现主CPU在执行完PC=0000000000088080
的代码之后不知道怎么回事可能就出错了,之后PC的值变成了200,并且qemu提示Taking exception 1 [Undefined Instruction]
。
1 | Trace 0: 0x7f68840012c0 [0000000000000000/0000000000088080/0xb0000000] arm64_elX_to_el1 |
为什么这里会变成200?指导书中有提示:
1
2
3
4
5 内核会发生地址翻译错误(Translation Fault),进而尝试跳转到异常处理函数(Exception Handler),
该异常处理函数的地址为异常向量表基地址(`vbar_el1` 寄存器)加上 `0x200`。
此时我们没有设置异常向量表(`vbar_el1` 寄存器的值是0),因此执行流会来到 `0x200` 地址,此处的代码为非法指令,会再次触发异常并跳转到 `0x200` 地址。
使用 GDB 调试,在 GDB 中输入 `continue` 后,按 Ctrl-C,可以观察到内核在 `0x200` 处无限循环。
然后,我注意到出错大概是在arm64_elX_to_el1
这个符号邻近。我一看我去这不切换特权级吗?于是赶紧去把arm64_elX_to_el1
这个函数搜出来了,并且一行行对比定位到出错的地方,然后就发现:
绷不住了!原来这是还没实现的内容呀:laughing:
总结
总之make qemu
实现完lab1就跑起来了,是我看错指导书版本误会误大发了(建议银杏书官网和实验官网update一下……不过github那边也没指路让我去参考这两个就是了,哈哈……)。不过一下尝试了三四五种配环境思路,还学习了怎么用docker,也是一个不可多得的体验了。
还有高手
自那之后我就一直都使用的老版本builder容器(看了看那都是v1.0了)或者直接不run在docker来做实验,安然无事到了lab2。lab3中有要求一个read_procmgr_elf_tool
,这个就是纯纯的sjtu提供的容器中自带的了,所以我也不得已继续花半天继续回到docker的问题……
Recall,之前是docker pull不下来,设了pull的代理和阿里云镜像仓库代理都没用。所以我主要还是先在尝试自建代理服务这一路,相关文章:
自建Docker镜像加速服务,免费且简单,服务器VPS、NAS皆可用
这个貌似确实比以前快了一些,但依旧还是会失败。焦头烂额了许久,最后终于找到了一个虽然略曲折但能行的方法:
相当于是白嫖了下github的workflow,让它打包上传到github然后再从github下载。不得不说也是思路清奇,有一种所有网络问题都可以这么解的感觉。
然后从这下下来的会打两层包,一层zip一层tar.gz,解压完这两层之后再用docker load -i filename
即可。
Lab2: 内存管理
本实验主要目的在于让同学们熟悉内核启动过程中对内存的初始化和内核启动后对物理内存和页表的管理,包括三个部分:物理内存管理、页表管理、缺页异常处理。
ChCore采用了buddy进行大内存分配和在此基础上的slab进行小内存分配。同时,采用了四级页表进行地址的映射。
物理内存管理
Buddy system
伙伴系统大概就是按照2的幂次进行order的划分,每个order对应2^order个页。分配的时候拆解,释放的时候合并。具体结构如下图所示:
1 | / * The layout of each physmem: |
此为buddy内存分配的一个内存池的典型结构,每页物理内存页对应一个struct page
结构体对象。
1 | struct page { |
我们将这些page对象以链表形式组织,对这些meta data的操作管理,来实现对buddy整个内存系统的管理。
我本来最直观的想法是迭代实现这个向上合并or向下分裂的过程,不过代码注释要求了使用递归(确实让我豁然开朗了一下),我于是也尽量将递归包括主体和对外接口都写得更优美一点了。
这也是我第一次实现buddy内存分配,以往都是简单知道概念。当时了解到这个算法就觉得非常惊艳,现在真正地去实现它,更感受到它的优美,包括但不限于对伙伴块的识别、合并分裂的实现和相关思想等。总的来说还是干货满满收获巨大,虽然还是遇到了一些曲折(见小品环节)。
slab
slab用于管理小内存对象。它为多个阶级的对象大小定义了多个内存池。具体结构可参考下图:
感觉最复杂的部分还是它已经帮我们写好的数据结构。
1 | // 对应 kmem_cache |
其中,每个slab的第一个obj被分配存储free_list_head
,接下来链接free_list_head
。free_list_head
没有元数据结构,而是直接在obj首部加一个地址指向下一个空闲的obj。并且这个链表不一定是连续的,每次申请释放的时候直接头插尾插就行了,毕竟整个地址空间其实都算知道,就是slab的地址。
然后,要获取partial_slab_list
的结点对应的slab_header
结构体,只需要使用类似这样的宏就可以:
1 |
另,还用了个这样的结构:
1 | /* Each free slot in one slab is regarded as slab_slot_list. */ |
它不同于buddy的每页都有一个对应的page_t对象,是采用了一个指针直接链接的形式。
虚拟内存管理
页表翻译的过程由MMU自动完成,我们只需把页表的物理地址存储在页表寄存器ttbr1_el1或ttbr0_el1即可。
在 AArch64 架构的 EL1 异常级别存在两个页表基址寄存器:
ttbr0_el1
1 和ttbr1_el1
2,分别用作虚拟地址空间低地址和高地址的翻译。那么什么地址范围称为“低地址”,什么地址范围称为“高地址”呢?这由tcr_el1
翻译控制寄存器3控制,该寄存器提供了丰富的可配置性,可决定 64 位虚拟地址的高多少位为0
时,使用ttbr0_el1
指向的页表进行翻译,高多少位为1
时,使用ttbr1_el1
指向的页表进行翻译4。0xffff_ff00_0000_0000为ChCore的虚拟地址开头,而0xffff_0000_0000_0000开始为高地址,所以是ttbr1_el1控制内核地址空间,ttbr0_el1控制用户地址空间。
ChCore采用了如下图所示的四级页表:
其中,TTBRx_EL1表示EL1下的页表地址寄存器,根据虚拟地址的后16位可知是需要用到高位还是低位。
页表项相关字段如下:
【L0-L2】
【L3】
“Output address”在这里即物理地址,一些地方称为物理页帧号(Page Frame Number,PFN)
值得注意的是,ChCore在内核空间(0xffff_ff00_0000_0000
之后的地址)中,为了简单起见,虚拟地址和物理地址都是线性映射的,也即转为vaddr + KBASE
,KBASE
表示了虚拟地址空间的起始地址,并且我们已经在内核启动时正确填写了内核页表,故而除了可以通过MMU地址翻译访问内核内存,我们可以直接通过virt_to_phys
将虚拟地址转化为物理地址。
具体实现记得在mappages的时候需要在第三层就退出,然后手动映射,不然会导致在get_next_ptp
中它帮我们申请一页物理地址不是我们想要的内存,导致内存泄漏。
这边可以回顾一下xv6的接口,是控制最后一次不alloc,然后返回下一次的pte。
缺页中断
当处理器发生缺页异常时,它会将发生错误的虚拟地址存储于
FAR_ELx
寄存器中,并触发相应的异常处理流程。ChCore 对该异常的处理最终实现在kernel/arch/aarch64/irq/pgfault.c
中的do_page_fault
函数。本次实验暂时不涉及前面的异常初步处理及转发相关内容,我们仅需要关注操作系统是如何处缺页异常的。
这部分填空也是比较简单,在此不提具体的实现细节,不过可以了解一下这边中断相关和vmr相关的整体框架。
ChCore是多线程微内核实现,一个进程拥有一个vmspace,为进程虚拟地址空间的抽象,每个进程的vmspace指针都存在其对应的per-CPU字段中。一个vmspace被切分为多个vmregion,代表一段逻辑连续、权限相同的内存,然后vmr记录了这段region对应的物理对象(PMO),PMO里又记录了相应的物理地址。
因此,想要处理缺页异常,首先需要找到当前进程发生页错误的虚拟地址所处的 VMR,进而才能得知其对应的物理地址,从而在页表中完成映射。
缺页处理主要针对 PMO_SHM
和 PMO_ANONYM
类型的 PMO,这两种 PMO 的物理页是在访问时按需分配的。
缺页处理逻辑为首先尝试检查 PMO 中当前 fault 地址对应的物理页是否存在(通过 get_page_from_pmo
函数尝试获取 PMO 中 offset 对应的物理页)。若对应物理页未分配,则需要分配一个新的物理页,再将页记录到 PMO 中,并增加页表映射。若对应物理页已分配,则只需要修改页表映射即可。
小品环节
Buddy system
【详情见buddy相关初次commit】
本来是打算仅有低地址的buddy(或者说第偶数个?)真正代表了本块的order数。这样的话可以减少一次order赋值,增加些微的效率,如下图所示(最左那个内部省略,4121,跟右边差不多)。
然而之后发现这样其实算是不对称的。。。因为这隐形规定了你split的时候需要先split最右,然后merge的时候也需要从右向左merge(也即必须前一个块就位了你这个块才能merge),也即split的时候是先割地址更高的块,而merge要求先回收低地址的块。
所以,当先释放高地址的块,再释放低地址的块的时候,会出问题,导致只能merge递归一次而不能继续向上递归:
1 | order[0] free = 0 |
PM
本来学了个类似这样的可变参数宏的新活想用上去:
1 |
|
奈何用完之后才想起来别的文件是预编译为链接文件的,而宏作用在更早的阶段,所以这样是不行的,会引发链接错误。
Lab3: 进程与线程
在实验 1 和实验 2 中,已经完成了内核的启动和物理内存的管理,以及一个可供用户进程使用的页表实现。现在,我们将一步一步支持用户态程序的运行。
实验 3 相较于实验 1 和实验 2 开放了部分用户态程序的代码,user
文件夹下提供了 chcore-libc
及 system-services
文件夹,并在根目录下添加了 ramdisk
文件夹。
ramdisk
。所有在ramdisk
目录下的文件将被放入内核镜像的文件系统中。chcore-libc
。基于musl-libc
进行修改以配合内核进行管理及系统调用,所有对musl-libc
的修改均在chcore-libc/libchcore
中,实际编译时将使用脚本进行override。system-services
。存放一些基本的系统服务,除了在ramdisk
中已经包含的,还有tmpfs
和procmgr
。tmpfs
是 ChCore 基本的内存文件系统,后续实验中将会有所涉及。procmgr
是 ChCore 的进程管理器,所有代码均以源代码形式给出,其中包含了创建进程、加载 elf 文件等操作,感兴趣的同学可以阅读。
访问控制
背景
在 ChCore 中,内核提供给用户的一切资源均采用 Capability 机制进行管理。ChCore 微内核采用“Everything is an object”的设计理念,它将用户态能够进行操作的资源统一抽象成内核对象(kernel object)。这和UNIX操作系统中经典抽象“Everything is a file”类似,都是旨在提供简洁而统一的资源抽象。
ChCore微内核中共提供了7种类型的内核对象,分别是 cap 组对象(cap_group)、线程对象(thread)、物理内存对象(pmo)、地址空间对象(vmspace)、通信对象(connection 和 notification)、中断对象(irq)。每种内核对象定义了若干可以被用户态调用的操作方法,比如为线程对象设置优先级等调度信息、将一个物理内存对象映射到一个地址空间对象中等。
为了能够使用户态调用内核对象定义的方法,ChCore 微内核需要提供内核对象命名机制,即为一个内核对象提供在用户态相应的标识符。类似地,宏内核操作系统中的文件对象(file)在用户态的识别符是 fd,这就是一种命名机制。ChCore 采用 Capability 作为内核对象在用户态的标识符。
在 ChCore 微内核操作系统上运行的应用程序在内核态对应一个 cap 组对象,该对象中记录着该应用程序能够操作的全部内核对象。也即,ChCore 的一个进程是一些对特定内核对象享有相同的 Capability 的线程的集合,通过 capability 的设计,每个进程拥有独立的内核对象命名空间。ChCore 中的每个进程至少包含一个主线程,也可能有多个子线程,而每个线程则从属且仅从属于一个进程。同时,子进程也视为父进程的cap group中的一个slot。
它这类比还蛮有意思的。在Linux中,包括内存管理、文件系统、进程本身、IO外设等的Metadata,都可以是文件(或者也不能这么说,我感觉文件形式更多时候其实还是只作为一个面向用户态的接口的……);而在ChCore中,这些Metadata则以对象的形式表现。文件描述符fd是用于访问一个文件,实际上就代表了当前上下文对该文件具有一种可以访问的“Capability”。
而且,进程的含义其实是对整个系统的一个小型抽象,有自己的逻辑和数据。这个cap_group的角度也是非常精准。
在实现中,一个 cap_group
作为一个进程的抽象,是指针数组,存储指向内核对象的指针。
Capability 在用户态看来是 cap 组的索引。当授权某个进程访问一个内核对象时,操作系统内核首先在这个进程对应的 cap 组中分配一个空闲的索引,然后把内核对象的指针填写到该索引位置,最后把索引值作为 capability 返回给用户态即可。【这就是非常FD的操作了】
用户态进程中的所有线程隶属于同一个 cap 组,而该 cap 组中非空闲的索引值即为该进程拥有的所有 capability,也就是该进程中所有线程能够操作的内核对象、即所有能够访问的资源。【这里其实也体现了线程和进程的概念之差,拥有共通共享的一些资源】
每个进程获取 capability 的方式有三种。
- 在被创建时由父进程赋予;(fork)
- 在运行时向微内核申请获得;(grant)
- 由其他进程授予。(grant传播)
Capability还能实现进程协作。比如说,若该内核对象是一个物理内存对象,则两个进程可以通过把它映射到各自的地址空间中从而建立共享内存;若该内核对象是一个通信对象,则两个进程可以通过调用其提供的方法进行IPC交互。【66666这个角度很有意思】
数据结构
一个内核对象由struct object
代表,其具体数据存储在opaque
中。而一个内核对象对应在每个cap group的代表是struct object_slot
,存储了对struct object
的指针以及其他相关信息。
1 | // 内核对象 |
特权与异常
AArch64采用“异常级别”这一概念定义程序执行时所拥有的特权级别,从低到高分别是 EL0、EL1、EL2 和 EL3。ChCore 中仅使用了其中的两个异常级别:EL0 和 EL1。其中,EL1 是内核模式,kernel
目录下的内核代码运行于此异常级别。EL0 是用户模式,user
目录下的用户库与用户程序代码运行在用户模式下。
在 AArch64 架构中,异常是指低特权级软件(如用户程序)请求高特权软件(例如内核中的异常处理程序)采取某些措施以确保程序平稳运行的系统事件【这个概括还挺精确的】,包含同步异常和异步异常:
- 同步异常:通过直接执行指令产生的异常。同步异常的来源包括同步中止(synchronous abort)和一些特殊指令。当直接执行一条指令时,若取指令或数据访问过程失败,则会产生同步中止。此外,部分指令(包括
svc
等)通常被用户程序用于主动制造异常以请求高特权级别软件提供服务(如系统调用)。 - 异步异常:与正在执行的指令无关的异常。异步异常的来源包括普通中 IRQ、快速中断 FIQ 和系统错误 SError。IRQ 和 FIQ 是由其他与处理器连接的硬件产生的中断,系统错误则包含多种可能的原因。本实验不涉及此部分。
发生异常后,处理器需要找到与发生的异常相对应的异常处理程序代码并执行。在 AArch64 中,存储于内存之中的异常处理程序代码被叫做异常向量(exception vector),而所有的异常向量被存储在一张异常向量表(exception vector table)中。AArch64 中的每个异常级别都有其自己独立的异常向量表,其虚拟地址由该异常级别下的异常向量基地址寄存器(VBAR_EL3
,VBAR_EL2
和 VBAR_EL1
)决定。每个异常向量表中包含 16 个条目,每个条目里存储着发生对应异常时所需执行的异常处理程序代码。如下图所示:
FIQ(Fast Interrupt Request)是一种 ARM 架构中的中断类型,是特权模式中的一种,同时也属于异常模式一类。旨在提供比标准中断(IRQ)更高的优先级和更快的响应时间,用于高速数据传输或通道处理。
FIQ和IRQ是两种不同类型的中断,ARM为了支持这两种不同的中断,提供了对应的叫做FIQ和IRQ处理器模式(ARM有7种处理模式)。
为使FIQ模式响应更快,FIQ模式具有更多的影子(Shadow)寄存器。
FIQ 的优先级高于常规的 IRQ(Interrupt Request)。
实验
1.用户进程和线程
在 ChCore 中,第一个被创建的进程是 procmgr
,是 ChCore 核心的系统服务。本实验将以创建 procmgr
为例探索在 ChCore 中如何创建进程,以及成功创建第一个进程后如何实现内核态向用户态的切换。
在内核完成必要的初始化之后,内核将会跳转到创建第一个用户程序的操作中,该操作通过调用 create_root_thread
函数完成,本函数完成第一个用户进程的创建。
创建用户程序至少需要包括创建对应的 cap_group
、加载用户程序镜像并且切换到程序。
create_root_thread
的操作包括:
- 从
procmgr
镜像中读取程序信息 - 调用
create_root_cap_group
创建第一个cap_group
进程 - 创建第一个线程,加载着
procmgr
系统服务
此外,用户程序也可以通过 sys_create_cap_group
系统调用创建一个全新的 cap_group
。
由于 cap_group
也是一个内核对象,因此在创建 cap_group
时,需要通过 obj_alloc
分配全新的 cap_group
和 vmspace
对象(TYPE_CAP_GROUP
与 TYPE_VMSPACE
)。对分配得到的 cap_group
对象,需要通过 cap_group_init
函数初始化并且设置必要的参数(Tip: size 参数已定义好 BASE_OBJECT_NUM
)。对分配得到的 vmspace
对象则需要调用 cap_alloc
分配对应的槽(slot)。
这部分的描述,可以看
docs/capability.md
,里面描述了cap的一般步骤:
obj_alloc
allocates an object and returns a data area with user-defined length.- Init the object within the data area.
cap_alloc
allocate a cap for the inited object.
然而,完成 cap_group
的分配之后,用户程序并没有办法直接运行,因为cap_group
只是一个资源集合的概念。线程才是内核中的调度执行单位,因此还需要进行线程的创建,将用户程序 ELF 的各程序段加载到内存中。
练习 2: 在
kernel/object/thread.c
中完成create_root_thread
函数,将用户程序 ELF 加载到刚刚创建的进程地址空间中。
此处大概是这样的逻辑,先在内核地址空间虚拟地址kva映射对应的一块物理内存pa,然后在内核中将elf程序copy到pa中(procmgr
已经包含在了kernel image中,所以只需一个memcpy
即可),最后再把这个pa映射到用户虚拟地址空间中(每个section的具体地址在elf程序头表的ph_vaddr字段指定)。
值得注意的是,貌似在ChCore的建立第一个进程这一阶段用的是一个魔改版的elf格式?详情见小品环节1。
不过这里还是简单放一下经典elf文件格式吧。
ELF文件结构主要包括三个部分:ELF头、程序头表(可选,用于可执行文件和共享库)、节头表(用于目标文件和可执行文件)。
ELF头(ELF Header):包含文件类型、架构、入口点、程序头表和节头表的偏移、表大小等基本信息。
程序头表(Program Header Table):描述程序执行所需的段(segments),如代码段、数据段等。
节头表(Section Header Table):描述文件中的各个节(sections),如符号表、字符串表等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT]; /* 魔数和相关信息 */
Elf32_Half e_type; /* 目标文件类型 */
Elf32_Half e_machine; /* 硬件体系 */
Elf32_Word e_version; /* 目标文件版本 */
Elf32_Addr e_entry; /* 程序进入点 */
Elf32_Off e_phoff; /* 程序头部偏移量 */
Elf32_Off e_shoff; /* 节头部偏移量 */
Elf32_Word e_flags; /* 处理器特定标志 */
Elf32_Half e_ehsize; /* ELF头部长度 */
Elf32_Half e_phentsize; /* 程序头部中一个条目的长度 */
Elf32_Half e_phnum; /* 程序头部条目个数 */
Elf32_Half e_shentsize; /* 节头部中一个条目的长度 */
Elf32_Half e_shnum; /* 节头部条目个数 */
Elf32_Half e_shstrndx; /* 节头部字符表索引 */
} Elf32_Ehdr;
1
2
3
4
5
6
7
8
9
10 typedef struct elf32_phdr{
Elf32_Word p_type; /* 段类型 */
Elf32_Off p_offset; /* 段位置相对于文件开始处的偏移量 */
Elf32_Addr p_vaddr; /* 段在内存中的地址 */
Elf32_Addr p_paddr; /* 段的物理地址 */
Elf32_Word p_filesz; /* 段在文件中的长度 */
Elf32_Word p_memsz; /* 段在内存中的长度 */
Elf32_Word p_flags; /* 段的标记 */
Elf32_Word p_align; /* 段在内存中对齐标记 */
} Elf32_Phdr;
再之后调用init_thread_ctx
进行上下文初始化,然后通过sched
选取到第一个程序进行运行,最后再一系列辗转调用eret回到用户态,从而开始运行。
然而,此时 ChCore 尚未配置从用户模式(EL0)切换到内核模式(EL1)的相关内容,在尝试执行 svc
指令时会出错,故而接下来完成对异常处理的配置。
2.异常向量表
在 ChCore 中,仅使用了 EL0 和 EL1 两个异常级别,因此仅需要对 EL1 异常向量表进行初始化即可。
在本实验中,ChCore 内除系统调用外所有的同步异常均交由 handle_entry_c
函数进行处理。遇到异常时,硬件将根据 ChCore 的配置执行对应的汇编代码,将异常类型和当前异常处理程序条目类型作为参数传递,对于 sync_el1h 类型的异常,跳转 handle_entry_c
使用 C 代码处理异常。对于 irq_el1t、fiq_el1t、fiq_el1h、error_el1t、error_el1h、sync_el1t 则跳转 unexpected_handler
处理异常。
由注释:
1 | * The selected stack pointer can be indicated by a suffix to the Exception Level: |
可知依次需要跳转的标签名的后缀顺序为t-h-64-32,并且每个跳转指令之间需要按照0x80对齐,所以需要间隔31个nop指令:
1 | b sync_el1t |
然后我也是之后看别人的代码才发现,原来还可以这么写,学到了:
1 | .macro exception_entry label |
3.系统调用
通过异常进入到内核后,ChCore在exception_enter
宏进行现场保存,在exception_exit
宏进行现场恢复。保存时在栈中应准备ARCH_EXEC_CONT_SIZE
大小的空间。
完成保存后,需要进行内核栈切换,首先从TPIDR_EL1
寄存器中读取到当前核的per_cpu_info
(参考kernel/include/arch/aarch64/arch/machine/smp.h
),从而拿到其中的cpu_stack
地址。
在本实验中新加入了 libc
文件,用户态程序可以链接其编译生成的libc.so
,并通过 libc
进行系统调用从而进行向内核态的异常切换。实验接下来将对 printf
函数(user/chcore-libc/musl-libc/src/stdio/printf.c
)的调用链进行分析与探索。
printf
函数调用了 vfprintf
,其中文件描述符参数为 stdout
。这说明在 vfprintf
中将使用 stdout
的某些操作函数。
在 user/chcore-libc/musl-libc/src/stdio/stdout.c
中可以看到 stdout
的 write
操作被定义为 __stdout_write
,之后调用到 __stdio_write
函数。
最终 printf
函数将调用到 chcore_stdout_write
。
思考 7: 尝试描述
printf
如何调用到chcore_stdout_write
函数。提示:
chcore_write
中使用了文件描述符,stdout
描述符的设置在user/chcore-libc/musl-libc/src/chcore-port/syscall_dispatcher.c
中。可以看到在
user/chcore-libc/musl-libc/src/chcore-port/syscall_dispatcher.c
中设置了stdout的默认fdop:
1
2
3
4
5
6
7
8 struct fd_ops stdout_ops = {
.read = chcore_stdio_read,
.write = chcore_stdout_write,
.close = chcore_stdout_close,
.poll = chcore_stdio_poll,
.ioctl = chcore_stdio_ioctl,
.fcntl = chcore_stdio_fcntl,
};
chcore_stdout_write
中的核心函数为 put
,此函数的作用是向终端输出一个字符串。
从 printf
的例子我们也可以看到从通用 api 向系统相关 abi 的调用过程,并最终通过系统调用完成从用户态向内核态的异常切换。
小品环节
ELF加载
不知道为什么,procmgr
被设计为不是正规的ELF文件格式,而是魔改版ELF……
具体来说,我们可以看看编译生成procmgr
镜像的cmake代码:
1 | add_custom_target( |
其中,procmgr.srv
为正统的ELF格式,通过readelf
工具可读。它只包含elf header和程序头表。
可以通过
readelf -h build/user/system-services/system-servers/procmgr/procmgr.srv
和readelf -l build/user/system-services/system-servers/procmgr/procmgr.srv
来读取其elf头和程序头表。
然后可以看到,他这里大概是做了这么个操作,首先是使用了他们docker环境自带的一个工具read_procmgr_elf_tool
将这个.srv文件转化为了elf_info.temp
,再把这个.tmp和.bin文件合二为一成为最终的procmgr
映像。
我们可以来看看这个.tmp文件里到底存了啥,跟正宗elf有什么不一样。
首先这个是elf_info.temp
:
这个是procmgr.srv
:
可以看到,蓝色部分其实就跟.tmp文件的内容差不多,.tmp文件大概是对ELF格式进行了一定的魔改。具体来说,它照搬了程序头表基本不变(自.tmp文件的0x40开始的位置,小端),然后又对标准的ELF Header进行了增删魔改(而且还变成了大端形式),最终形成了这么一个大小为48(elf header)+56*3(phdr size)=216大小的首部。
这还不是最幽默的,还有一点,就是他还改了程序头表的ph_offset
字段!
ph_offset
字段的原意是该程序头对应的segment离文件开始的偏移量,并且会按照其ph_align
字段进行地址对齐。在.srv中,每个程序头表项的align都为0x1000(PAGE_SIZE),故而,其offset字段情况如下图所示:
然而,生成的elf_info.temp
的第一个offset字段被置为了0,也就是说一个segment的起始地址应该为 文件起始地址+216(首部大小)+offset。
以上的这几种细节,以及全然未提及相关信息的指导书(只说加载ELF程序……但我寻思这也不是ELF啊),使我在这部分struggle了许久,对offset改来改去毫无思路,不清楚为什么读出来会是0,也不清楚是否需要按页对齐(反正对不对齐其实试出来都是错的hhh)。最终发现错误也比较偶然,大概是盯着那堆ELF相关宏抓耳挠腮时,开始好奇这个#define ROOT_BIN_HDR_SIZE 216
的216是怎么来的,算了一下发现标准的ELF格式其实应该是232(64+56*3)才对,然后又想到之前配环境一通折腾时就看不懂的那段cmake指令,以及细看才发现前面读elf header时更是古怪(原谅我写的时候前面直接跳过没仔细看了……),才意识到原来用的不是标准ELF,而是魔改版的……
不过,我不大懂这里为啥要用魔改版这么复杂曲折,我翻了翻往年的实验版本包括隔壁xv6也都是用的标准ELF格式。难道是为了读取方便统一用8字节,或者节省image大小……但指导书怎么没提及这一点呢,莫非是我孤陋寡闻了……
后续:
procmgr负责进程生态的管理,如果要新建进程,也是向procmgr发送相关消息即可,它会先读入elf,再调用
elf_so_loader_launch_process
,最终调用launch_process_with_pmos_caps
函数进行和内核差不多的操作,如创建内存空间创建主线程等。这里就是一个非常纯粹的微内核了,创建进程这种大活都被放到了userspace。可能就是因为这样,内核就仅保留一个小的魔改版elf了。
Lab4: 多核调度与IPC
在本实验中,ChCore将支持在多核处理器上启动;实现多核调度器以调度执行多个线程;最后实现进程间通信IPC。代码部分都相对简单,更多还是需要理解IPC通信流程。
多核启动
为了让ChCore支持多核,我们需要考虑如下问题:
如何启动多核,让每个核心执行初始化代码并开始执行用户代码?
wait_until_smp_enabled
→secondary_init_c
→如何区分不同核心在内核中保存的数据结构(比如状态,配置,内核对象等)?
ChCore对于内核中需要每个CPU核心单独存一份的内核对象,利用一个数组来保存,CID作为数组的索引。
ChCore支持的核心数量为
PLAT_CPU_NUM
。smp_get_cpu_id函数通过访问系统寄存器tpidr_el1来获取调用它的CPU核心的ID。
如何保证内核中对象并发正确性,确保不会由于多个核心同时访问内核对象导致竞争条件?
思考题 2:阅读汇编代码kernel/arch/aarch64/boot/raspi3/init/start.S, init_c.c以及kernel/arch/aarch64/main.c,解释用于阻塞其他CPU核心的secondary_boot_flag是物理地址还是虚拟地址?是如何传入函数enable_smp_cores中,又是如何赋值的(考虑虚拟地址/物理地址)?
真无敌了我刚刚就在研究这个,结果他居然正好也问了。
enable_smp_cores
中的secondary_boot_flag
为虚拟地址,此时已经开启了MMU,所以直接写入然后再保障缓存一致性就没什么问题。
然后在main中传入的这个boot_flag
是物理地址,是通过main函数参数传递进来的:
1 | void main(paddr_t boot_flag, void *info) |
然后感觉前面的就都是地址了,应该都是靠的参数传。
多核调度
【要来到我的老本行了,激动激动】
ChCore对于调度策略的抽象可以看到也是经典的调度类模式。
1 | /* Provided Scheduling Policies */ |
其他的就没啥好说了,因为毕竟调度这块还是比较经典的宏内核做法,这部分主要就是跟着写了个rr和初始化了一下时钟,大部分还是偏了解为主。这里就记录一点关于FPU的东西吧。
内核线程没有FPU状态?
- 内核线程通常不使用FPU:在许多操作系统中,内核线程主要负责执行内核态的任务,这些任务通常不涉及浮点运算,因此没有必要为这些线程保存和恢复FPU状态。
- 性能考虑:保存和恢复FPU状态会增加线程切换的开销。如果内核线程不使用FPU,则不需要在切换时处理FPU状态,这可以提升系统性能。
软件浮点运算
在没有FPU的情况下,浮点运算是通过一系列的整数运算和逻辑操作实现的。这通常涉及调用特定的库函数或由编译器生成相应的代码来模拟浮点运算。软件浮点运算的特点包括:
- 慢速:因为每一个浮点操作都需要多个机器指令来完成。
- 复杂性:实现浮点运算的函数比较复杂,涉及到多步操作。
硬件浮点运算(使用FPU)
FPU是专门用于执行浮点运算的硬件单元。使用FPU的特点包括:
- 快速:浮点运算可以在一个或几个时钟周期内完成,极大地提高了计算速度。
- 简便:编译器可以直接生成使用FPU指令的代码,简化了浮点运算的实现。
如果需要在内核线程中执行浮点运算,可以通过以下方式实现:
- 启用FPU使用:允许内核线程使用FPU,并在上下文切换时保存和恢复FPU状态。
- 特定处理:在需要时,特定的内核线程可以启用FPU,进行计算,然后禁用FPU,并将结果返回。
1
2
3
4
5
6 kernel_fpu_begin(); // 开始使用FPU
b = b * 5 - 1.5;
a = a * 100 / 6;
kernel_fpu_end(); // 结束使用FPU
进程IPC通信
【激动!】
在本部分,我们将实现ChCore的进程间通信,从而允许跨地址空间的两个进程可以使用IPC进行信息交换。
ChCore的IPC接口不是传统的send/recv接口,而是采用了客户端/服务器模型,并且采取了简单的Thread Migration机制。接下来,将以一次IPC通信为例,介绍ChCore的IPC机制。
在具体的流程之前,我们可以先简单回想一下基于线程迁移的IPC是什么个形式。
大概是这样,Server那端会提供一个特殊的线程,仅在Client发送IPC请求时才会被调度执行。然后如果需要通信,双方需要先建立连接,从而对通信过程中需要的例如函数、参数以及共享内存等进行一个申请和初始化。连接建立后,Client中的线程A如果想发起IPC调用Server的某些函数,那么A就会把自己直接上下文切换到Server端,在Server端的进程上下文中执行完再迁移回到Client进程的上下文中。与此同时可以通过共享内存来实现信息的交流。
在ChCore中,Server那端特殊的线程即被称为TYPE_SHADOW
。这些线程没有调度的上下文(也即时间片之类),会直接继承Client发起请求线程的时间片。与此同时,ChCore还有另一种特殊的shadow线程,类型被标记为TYPE_REGISTER
,来实现兼容线程迁移的连接建立(而非传统send/recv)。
接下来,将介绍ChCore的具体实现。
主要流程
Server init
Server调用register_server
将自己注册为服务端。具体来说,该函数会通过系统调用sys_register_server
注册三个回调函数,写入内核的meta data中(struct thread
的general_config
字段)。
server_destructor
:析构器,仅记录其函数指针client_register_handler
:创建连接回调,会为其创建对应的shadow线程(TYPE_REGISTER
,下称register_cb thread)server_handler
:Server的具体处理逻辑,暂时仅记录其函数指针是最体现service本身具体功能的部分了。对于procmgr,其服务功能就是了解进程的状态并且进行管理;而对于pipe,其服务功能就是响应对管道的读写请求。
至此,Server即处于监听状态,shadow线程register cb静默,直到Client端发来连接请求。
连接建立
Client端初始化
Client调用
register_client
来主动建立IPC connection。具体来说,该函数及其相关系统调用流程如下:
申请一块物理内存pmo作为共享内存SHM,并且在自己的vmspace中映射
在双方的cap group创建SHM对象
PS: 相信大家看到这应该也会和我有同样的疑惑,此处在Client上下文中是怎么获取Server的呢?
分析请见下一模块
相关细节-server cap
在双方的cap group创建IPC connection对象
1
2
3
4
5
6
7
8
9
10
11
12
13struct ipc_connection {
// Note that all threads in the client process can use this connection.
struct thread *current_client_thread;
struct thread *server_handler_thread;
badge_t client_badge; // pid
// SHM相关信息,如在双方的cap和vaddr,以及SHM大小
struct shm_for_ipc_connection shm;
/* For resource recycle */
struct lock ownership;
cap_t conn_cap_in_client;
cap_t conn_cap_in_server;
int state;
};重置register_cb thread
重置入口、栈地址等,并且设置参数为server handler。
上下文切换到register_cb thread,去完成Server端的初始化
Server端初始化
Server端通过
register_cb thread
来进行连接的初始化。具体来说,该函数及其相关系统调用
sys_ipc_register_cb_return
流程如下:- 创建shadow线程server handler
- 映射SHM到自己的vmspace
- 重置server handler thread
- 上下文切换回Client端线程,回到userspace
至此,连接成功建立,shadow线程server handler静默,直到Client端发送ipc请求。
通信
- Client端在共享内存中写入msg,包含请求函数和相关参数
- 调用系统调用
ipc_call
,上下文切换到Server端执行server handler - server handler执行结束后调用
ipc_return
(带返回结果),回到Client端上下文继续执行
相关细节
server cap
register_client
通过系统调用参数的server cap从而获取到其pmo的Server thread。然而既然用到了server cap那就说明server thread处在client的cap group中,那么Client是如何将想要连接的server加入到自己的cap group从而获取server cap的?
之前的实验告诉我们这两点:
- ChCore的第一个进程是procmgr
- 所有进程的创建都需要经过procmgr
- 子进程可以继承父进程的cap group
- 父进程的cap group会记录其所有子进程的cap
所以,答案显而易见,世界是一个巨大的ipc:
- 所有进程都是procmgr的子孙,故而procmgr拥有所有进程的cap,所有进程也都拥有procmgr的cap(cap[0])。创建顺序是procmgr->system service->user process。
- 所有进程都是procmgr的子孙,故而procmgr拥有所有进程的cap信息。client可以通过IPC向procmgr请求它所需要获取的服务的cap,procmgr会帮你填入
thread->cap_buffer
(<int, int>映射)。
thread migration
可以看到,ChCore这边就是一个非常纯粹的Thread Migration实现逻辑。相比于Linux等还有些把进程线程概念稍微等同的倾向(虽然实际实现不怎么等同了),ChCore直接用cap group和thread来彻底切割了,process仅仅提供逻辑和一些资源环境,所以真正执行逻辑的线程就可以进程之间到处乱窜了。
只不过这个迁移实现确实还是没我之前想得那么高大上()其实说白话,就相当于client thread主动放弃自己的CPU,然后定向把自己CPU丢给server thread,然后为了确保公平性就共享时间片这样。我原来还觉得可能是实体性的迁移,还觉得太牛逼了怎么实现的,不过你说这是线程迁移吧也确实,所以可能高度抽象的理论和具体实现之间总会有这种期望的落差哈哈……
不过可以看出,这样的线程迁移确实性能上会有些忧虑。虽然它也算是减少了原有IPC设计上的开销,
thread migration相比传统的IPC可以减少两次调度开销。
正统的线程间通信大概是这样的流程,有一个notification对象,然后server handler初始化建立完之后就block在这个对象上。client调用一个send,就相当于对这个notification进行一个唤醒操作并且block在上面,server handler醒来然后读共享内存完成任务,之后再notify client就可以。
值得注意的是唤醒完之后,需要加入调度队列并且再进行上下文切换,所以这样一来一共其实就是两次调度开销+两次上下文切换,相比于thread migration=两次上下文切换。
但其带来的页表切换、特权级切换、Cache/TLB刷新、更少的编译优化等性能问题还是会让人担忧。
除此之外,值得注意的是,Server端只有一个register cb,但是每个连接都有对应的server handler。故而register的时候是需要retry的。
信息交互
关于信息交互,感觉它还算是提供了比较丰富的接口,可以通过共享内存交互,也可以有常规的返回值,还可以通过cap group,在sys_ipc_call
的时候会把client的cap copy给server,然后return的时候会把server的cap copy给client。
常规返回值
这个就比较典了,在
ipc_return
的时候设一下返回寄存器就差不多了。capability
在server handler中或者client中可以设定想要拷贝给client的cap们:
1
2ipc_set_msg_return_cap_num(ipc_msg, 1);
ipc_set_msg_cap(ipc_msg, 0, mpinfo->fs_cap);然后在
ipc_return
或者ipc_call
中会调用ipc_send_cap
,从而能够进行cap的传播授权。共享内存
用来传递ipc msg。值得注意的是,很多地方在跟system service进行交互的时候,都是使用了这样的宏来作为创建消息时的icb(ipc control block,只用于client端):
1
2
3这其实是为了性能起见,让每个thread都内置一个一对一的connection icb,而非像其他service一样支持多thread对一connection,从而提高系统服务的可扩展性(这点在鸿蒙论文也提到了)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22/* ipc_struct for invoking system servers.
* fsm_ipc_struct and lwip_ipc_struct are two addresses.
* They can be used like **const** pointers.
*
* If a system server is related to the scalability (multi-threads) of
* applications, we should use the following way to make the connection with it
* as per-thread.
*
* For other system servers (e.g., process manager), it is OK to let multiple
* threads share a same connection.
*/
ipc_struct_t *__fsm_ipc_struct_location(void);
ipc_struct_t *__net_ipc_struct_location(void);
ipc_struct_t *__procmgr_ipc_struct_location(void);
/*
* **fsm_ipc_struct** is an address that points to the per-thread
* system_ipc_fsm in the pthread_t struct.
*/
ipc_struct_t *__fsm_ipc_struct_location(void)
{
return &__pthread_self()->system_ipc_fsm;
}也因此,在写这类system service的消息结构体的时候需要尤其注意初始化问题……(本人后面在写lab5 FSM的时候就被坑得巨惨)它是直接的覆写(比如在
ipc_create_msg
中),不会帮你在覆写前memset一下。
Lab5: 虚拟文件系统
虚拟文件系统(Virtual File System,VFS)提供了一个抽象层,使得不同类型的文件系统可以在应用程序层面以统一的方式进行访问。当应用程序发出文件操作请求时(如打开文件、读取文件等),这些请求首先会经过 VFS 层。VFS 根据文件路径解析出具体的文件系统,然后将操作请求委托给相应的文件系统驱动程序执行。
在 ChCore 中,我们通过 FSM 系统服务以及 FS_Base 文件系统 wrapper 将不同的文件系统整合起来,给运行在 ChCore 上的应用提供了统一的抽象。只要实现了 FSBase 和 FSWrapper 的接口的 IPC 服务,都可以成为一个文件系统示例。【这就是一种多态了】
FSM
FSM是一个system service,用来管理运行在ChCore上的多个文件系统,可以处理如下类型的请求:
1 | /* Client send fsm_req to FSM */ |
练习1:阅读
user/chcore-libc/libchcore/porting/overrides/src/chcore-port/file.c
的chcore_openat
函数,分析 ChCore 是如何处理openat
系统调用的,关注 IPC 的调用过程以及 IPC 请求的内容。
准备:alloc一个fd、形成完整路径名
chcore使用的是一个全局的fd table,以无锁map形式实现,也是经典的微内核特色。
1
2 /* Global fd desc table */
struct fd_desc *fd_dic[MAX_FD] = {0};向fsm发送一个
FSM_REQ_PARSE_PATH
请求获取mount id向fs发送
FS_REQ_OPEN
请求
从openat
的调用也可以看出FSM和FS的解耦。FSM只负责挂载/卸载和把FS的cap交给用户,获取完cap之后用户就直接访问FS service了。
mount
在看如何实现mount之前,可以先探究一下fsm的启动流程。
procmgr启动tmpfs和fsm两个service process:
1 | void boot_default_servers(void) { |
然后,FSM启动后会在main函数挂载tmpfs(这也是procmgr里面那个初始化顺序的原因),挂载的大概操作是先向procmgr发请求拿tmpfs的cap,然后再初始化mount info结构体。
1 | int init_fsm(void) { |
最后,FSM会打开一个跟FS的connection,用以后续跟FS service进行交互。
以此类推,其它FS的挂载操作其实也差不多,只不过稍显复杂一些。
发起挂载请求之后,会首先调用mount_storage_device
获取FS对应的cap。而在初次调用该函数时,该函数会先读取MBR引导扇区,获知本机的磁盘分区信息,获取对应的FS类型。
1 | for (i = 0; i < MBR_MAX_PARTS_CNT; i++) { |
然后,它也依然会发送GET_SERVER_CAP请求给procmgr。procmgr会返回对应的FS service cap,如果对应的FS service尚未启动则procmgr会立刻启动它。
1 | case SERVER_FAT32_FS: |
最后也是依然会打开一个connection用于后续交互。
可见mount大致流程大概就是:
- 让procmgr启动FS service process,然后获取server cap
- 填写mount info插入链表
- 打开一个connection用于后续交互
实验最主要让我们完成的还是比较简单的第三步。
此部分也属于是一种service间交互应用的体现吧,FS Client FSM这三者关系还算是比较交错。Client是FS、FSM的客户端,FSM是FS的客户端(发送FS_INIT等让它自助初始化),FSM也算是FS他半个爸()
parse path
Client端并非干脆使用server cap同FS service进行交互,而是使用mount id来表示一个FS。故而,为了实现该封装,我们就需要在Client端维护一个<mount id, server cap(Client的cap)>的映射和一个<connection(ipc_struct
), server cap>的映射。
因此,我们需要在FSM端:
- 集中存储每个Client对应的<mount id, server cap(FSM的cap)>映射,这对应本任务的
fsm_client_cap
- 收到parse path请求的时候,根据路径最大匹配找到对应的挂载点再找到对应的文件系统
- 根据文件系统的server cap找到mount id(调用1中接口)
- 将server cap通过IPC的cap拷贝机制copy给Client端(
ipc_return_with_cap
),然后将mount id、挂载路径写回msg中的response就行了1
FS_base
数据结构
在 ChCore 中,FS_Base 是文件系统的一层 wrapper,IPC 请求首先被 FS_Base 接收,再由 FS_Base 调用实际的文件系统进行处理。
【其实或者说叫“基类”什么的也挺好的()】
在 FS_Base wrapper 中,ChCore 实现了适用于VFS的vnode抽象,用来代表文件系统中所有的对象,包括文件、目录、链接等。它有一个private指针指向具体的各个FS的数据,比如说对于EXT4就是inode,算是多态的一种体现。
ChCore 中 vnode 的定义为:
1 | struct fs_vnode { |
再然后,为了兼容POSIX接口,外界用户态通过chcore-libc访问文件是以fd的形式,(fd是一个非负整数,它指向一个内核中的文件表项,包含了文件的状态信息和操作方法)然后每个进程的fd都集中存储在chcore-libc中。
1 | /* Global fd desc table */ |
而在FS内部,使用了一个server_entry
结构体,用来作为POSIX中fd指向的那个文件表项,其表索引为fid。
1 | // per-fd |
因此,FS_Base 的 IPC handler 在处理 IPC 请求时,会先把 IPC 消息中包含的文件 fd 转换为 fid,所以我们文件系统内部需要维护一个(client badge, fd) -> (fid)
的映射:
1 | /* (client_badge, fd) -> fid(server_entry) */ |
fs_wrapper_ops
当我们拥有了文件表项和VNode抽象后,我们便可以实现真正的文件系统操作了。
我们可以将 FS_Base 以及 FS_Wrapper 的所有逻辑看成一个 VFS 的通用接口,其暴露出的接口定义为 strcut fs_server_ops
。FS只需要实现该结构体的操作即可,FS wrapper会调用它。
我们可以来看看tmpfs具体是什么样的架构。
首先tmpfs以fs_server_dispatch
为handler注册为了一个IPC server:
1 | info("register server value = %u\n", |
fs_server_dispatch
定义在FS wrapper中,是对所有FS通用的一个server handler。它会先把fd转化为fid,然后根据请求类型调用相应处理函数:
1 | DEFINE_SERVER_HANDLER(fs_server_dispatch) { |
以mount
为例,可以看到它最终会调用server ops的函数:
1 | int fs_wrapper_mount(ipc_msg_t *ipc_msg, struct fs_request *fr) { |
也即tmpfs定义的具体server ops:
1 | struct fs_server_ops server_ops = { |
对于本 Lab 你只需要实现最基本的 Posix 文件操作即可,即 Open,Close,Read, Write 以及 LSeek 操作。具体的没啥好说的,就是常规的FS,体力活。
其它
TODO 之后有时间可以研究一下file page fault和cache的代码是怎么写的。
还有libc,recycle等等等