上交-现代操作系统原理与实现 实验一:机器启动

上海交通大学开发的操作系统课程《现代操作系统原理与实现》实验一:机器启动

在线章节(实验手册从这里下载):

https://ipads.se.sjtu.edu.cn/mospi/

好大学在线(实验材料从这里下载):

https://www.cnmooc.org/portal/course/5610/14956.mooc

练习1 熟悉ARM指令集

练习1

实验手册要求阅读的是《Arm Instruction Set Reference Guide》的A1、A3、D部分,分别是ARM指令集概述、AArch64概述、AArch64指令集参考,这里我个人建议只阅读本书册的A1、A3部分,至于指令集,去阅读《ARM Developer Suite Assembler Guide》效率会更高些,主要阅读4.1 Conditonal execution、4.2 ARM memory access instructions、4.3 ARM general data processing instructions、4.6 ARM branch instructions、4.9 ARM pseudo-instructions即可初步了解ARM指令集的主要指令。

编译ChCore内核

本课程实验采用的是Arm架构,并提供了一个上交开发的ChCore内核(裁剪过)作为实验内核,因此,实验前需要先从源码编译此内核。

由于目标架构是Arm架构,而我们使用的个人电脑一般都是x86架构,因此内核作者封装好了一个docker镜像并提供了脚本,运行提供的脚本即可在docker中交叉编译出一个Arm架构的ChCore内核。这些运行脚本的命令已经进一步封装在了Makefile中,只需按照实验指导书的指示make对应的目标即可。

build chcore

本实验采用运行qemu来作为虚拟机(模拟器),运行make qemu即可启动内核。阅读Makefile可知实际运行的命令如下,可以看到,使用的虚拟机器是树莓派raspi3。

1
qemu-system-aarch64 -machine raspi3 -serial null -serial mon:stdio -m size=1G -kernel $(BUILD_DIR)/kernel.img -gdb tcp::1234

另外,本实验采用gdb-multiarch代替了普通的gdb来进行跨平台的调试。chcore目录下运行make qemu-gdb即可启动内核并等待gdb接入调试,实际运行的命令只是比上面的增加了-nographic以及-S选项。

1
qemu-system-aarch64 -nographic -machine raspi3 -serial null -serial mon:stdio -m size=1G -kernel $(BUILD_DIR)/kernel.img -gdb tcp::1234 -S

运行make gdb即可启动gdb调试器接入远程调试,实际运行的命令如下:

1
gdb-multiarch -n -x .gdbinit
1
2
3
4
sunxiaokong@ubuntu:~/Desktop/chcore-lab$ cat ./.gdbinit 
set architecture aarch64
target remote localhost:1234
file ./build/kernel.img

练习2 跟踪bootloader入口

练习2

分别运行make qemu-gdb以及make gdb,在gdb中运行where命令即可看到入口函数是in_start()及其地址0x80000

lab1-practice2

练习3

练习3

kernel.image入口的定义

使用readelf -S build/kernel.img -W查看build/kernel.img的section信息,这里加的-W (--wide)选项是为了能够让输出的信息展开便于阅读。

lab1-practice2-step1

结合实验指导书可以知道,这里的init段即为bootloader所在的段,其地址0x8000正是在练习2中使用gdb看到的入口代码地址。这个入口及其地址定义在scripts/linker-aarch64.lds.in以及boot/image.h文件中。lds链接脚本的阅读可以参考GNU Linker Script(.lds文件)的学习

那么又是哪里定义了image的代码入口为init段中的_start()函数呢?答案是在源码根目录下的CMakeList.txt中,其指定了链接器ld的flags,ld的-e参数是“entry”的意思,指定了二进制文件的代码入口。参考GCC - Link Options以及CMake - set_propertyCMake - LINK_FLAGS

多核处理器的挂起

https://developer.arm.com/documentation/ddi0601/2020-12/AArch64-Registers/MPIDR-EL1--Multiprocessor-Affinity-Register?lang=en

在chcore目录下运行make qemu-gdb以及make gdb启动gdb调试,在gdb中使用disass命令查看汇编代码。

disass _start

第一行指令是使用mrs指令将系统寄存器mpidr_el1中的值读入x8寄存器中,然后and x8, x8, #0xff指令只保留x8寄存器中的低8位其余清零,紧接就是一个cbz指令,该指令为“Compare and Branch on Zero”指令,意思是当x8中的值为0时跳转到_start+16,否则继续执行。而_start+12处的指令为无限循环原地跳转,_start_16处的指令为跳转到arm64_elx_to_el1处继续执行。

要理解这几行代码的意图首先要知道MPIDR_EL1的作用,可参考MPIDR_EL1, Multiprocessor Affinity Register以及Zircon - Fuchsia 内核分析 - 启动(平台初始化)MPIDR_EL1是多核标记处理器,其字段如下:

Affinity level 0. This is the affinity level that is most significant for determining PE behavior. Higher affinity levels are increasingly less significant in determining PE behavior. The assigned value of the MPIDR.{Aff2, Aff1, Aff0} or MPIDR_EL1.{Aff3, Aff2, Aff1, Aff0} set of fields of each PE must be unique within the system as a whole.

个人理解是,这四个AFFx字段共同描述了当前的PE(Processor Element?) behavior,其中AFF0是最重要的,如果这个值为0,即表示当前处理器为prime processor主处理器的意思。这样的话就能理解上面的代码了,意思是如果mpidr_el1寄存器表明当前处理器不是主处理器,则跳转至_start+12处执行原地跳转指令,相当于挂起当前处理器,不继续往下执行,而如果mpidr_el1寄存器表明当前处理器是主处理器,则跳转至_start+16处继续bootloader的执行。

通过单步调试可以证实这个想法。

第一次调试:

gdb-1

第二次调试:

gdb-2

通过这两次调试可以看到,如果mpidr_el1的低8位为0,才能执行到_start+12处。比如说第一次调试中,x8中的值为0x1,然后单步执行就执行到了_start+12处,此时看x8的值为0x2,再单步一次,就执行到了arm64_elx_to_el1,此时再看x8的值,是0x0。因为在不同的处理器中,其mpidr_el1的值必定是不同的(上面参考资料提到过{Aff3, Aff2, Aff1, Aff0} set of fields of each PE must be unique within the system as a whole.),所以出现这种情况的原因就是gdb在这个过程中切换了处理器来读取寄存器的值,所以每次读取x8(mpidr_el1)都有可能是不同的结果。

练习4 LMA与VMA

练习4

使用objdump -h查看各个段信息:

objdump -h build/kernel.img

scripts/linker-aarch64.lds.in中,使用了AT()指令给.text段指定了LMA,而前面的init段没有指定LMA,按照ld scripts的规则,没有指定LMA的话,默认是使之等于VMA的。在/boot/image.h中指定了#define KERNEL_VADDR 0xffffff0000000000.因此可以看到上图中objdump出来的.text开始的段,其VMA和LMA是不同的。

而我认为这个问题的重点是为什么要将内核各个段的LMA设置得与VMA不同?而LMA与VMA分别是在什么情况下使用的?如何转换?

以下是我一开始的理解,纯粹从虚拟地址和物理地址的角度出发,应该是不正确的,LMA和VMA应是一个完全不同的情况,完全搞懂后再更新…

目前的一些参考:

https://www.embeddedrelated.com/showthread/comp.arch.embedded/77071-1.php

https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_chapter/ld_3.html

https://www.crifan.com/detailed_lma_load_memory_address_and_vma_virtual_memory_address/

我个人对LMA和VMA的简单理解就是物理地址和虚拟地址的关系。LMA表示将该section装载到物理内存中的物理地址,而VMA则表示该section在进程虚拟地址空间中的虚拟地址。因此可以看到,kernel.img中,bootloader的VMA和LMA是相同的,因为bootloader是机器上电启动首先执行的代码,此时处理器还没有初始化页表也还没有开启分页模式(类似于x86-64中的实模式情况下),PC寄存器读取的地址直接就是物理地址了,不需要经过也表的转换,即VMA等于LMA。而其他属于kernel的段就不一样了,VMA和LMA是不同的,例如.text段,其VMA为0xffffff000008c000,还记得本书第二章中提到的,AArch64的虚拟地址空间么?AArch64的虚拟地址空间分为两部分,一部分是0x00000000_00000000 - 0x0000FFFF_FFFFFFFF,常用于应用程序地址空间,而另一部分是0xFFFF0000_00000000 - 0xFFFFFFFF_FFFFFFFF,常用于内核地址空间,因此,objdump看到的这些kernel段的VMA,就是它们在虚拟地址空间中的虚拟地址。

内核如何将该段的地址从LMA变为VMA?当然是通过页表的构建来完成的了。

练习5 printk功能的完善

练习5

练习5要求填充printk_write_num()函数,阅读一下源码可以知道,已经把格式化字符串的解析、传参都做好了,而且也做好了底层输出的函数,我只需要在printk_write_num()函数中实现不同进制转化成字符串,填充到print_buf[]这个缓冲区即可。

为方便代码的编写,在kernel/common/printk.c中我添加了一个my_memcpy()函数(copy自关于C函数memcpy的实现细节思考)实现简单的内存拷贝功能:

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
// Memory copy function
// From https://blog.csdn.net/yuanrxdu/article/details/23771459
void* my_memcpy(void *dst, const void* src, size_t size)
{
if(dst == NULL || src == NULL || size <= 0)
return dst;

char* dst_pos = (char *)dst;
char* src_pos = (char *)src;
if(dst_pos < src_pos + size && dst > src){ //DOWN COPY,向前拷贝
dst_pos = dst_pos + size;
src_pos = src_pos + size;

while(size > 0){
*dst_pos-- = *src_pos--;
size --;
}
}
else { //UP COPY,向后拷贝
while(size > 0){
*dst_pos++ = *src_pos++;
size --;
}
}

return dst;
}

然后在printk_write_num()中预留的To DO位置添加代码即可:

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
// 八进制 
if (base == 8) {
char octtable[8] = "01234567";
neg = 0;
u = i;
char tempoct[22];
int idx = 0;

while(u)
{
tempoct[idx++]=octtable[u%8];
u/=8;
}
for(i=0;i<idx;i++)
print_buf[i]=tempoct[idx-1-i];
print_buf[i] = '\0';
s = print_buf;
}

//十进制
if (base == 10) {
char dectable[10] = "0123456789";
neg = 0;
u = i;

// 负数
if(sign && i < 0) {
neg = 1;
u = -i;
}
char tempdec[20];
int idx = 0;

while(u)
{
tempdec[idx++]=dectable[u%10];
u/=10;
}
for(i=0;i<idx;i++)
print_buf[i]=tempdec[idx-1-i];
print_buf[i] = '\0';
s = print_buf;
}

// 十六进制
if (base == 16) {
char hextable[16];
neg = 0;
u = i;
char temphex[16];
int idx = 0;

if(letbase == 'A')
my_memcpy(hextable, "0123456789ABCDEF", 16); // 大写
else
my_memcpy(hextable, "0123456789abcdef", 16); // 小写

while(u)
{
temphex[idx++]=hextable[u%16];
u/=16;
}
for(i=0;i<idx;i++)
print_buf[i]=temphex[idx-1-i];
print_buf[i] = '\0';
s = print_buf;
}

修改一下main.c中的main函数以验证输出正常:

编译运行qemu,一切正常!

或者输入make grade也可以看到完成了print功能:

练习6 内核栈的初始化

练习6

kernel/head.S中定义的start_kernel()函数初始化了内核栈(SP):

start_kernel init stack

内核为栈保留空间的方法是在kernel/main.c定义一个全局字符数组(未初始化的全局变量位于.bss段)

栈的大小KERNEL_STACK_SIZE定义在kernel/common/vars.h中,为8192。
由于kernel_stack是定义在main.c文件的一开始,因此其地址应该就是.bss段的起始地址,可以使用objdump和gdb调试证实这一点。
使用objdump -h build/kernel.img查看.bss段的起始地址,注意,此时已经初始化了内存管理模块,所以处理器直接使用的已经是VMA了。

根据start_kernel()函数中的代码,进入main()函数时,sp的值应该为0xffffff0000090170 + 8192 = 0xffffff0000092170
进入gdb调试,在main函数下断点,运行到main函数时查看sp的值,符合我们的计算:

练习7 aarch64函数调用1

练习7

gdb调试过程如下

第一次进入stack_test()

第二次进入stack_test()

可以看到,每次进入stack_test()函数时,都会给栈开辟32字节的空间(sp-32),然后往里放三个64位值,分别是x29、x30、x19寄存器中的值,其中x29是FP(Frame Pointer)栈帧基地址寄存器,x30是bl stack_test()的下一条指令的地址,即返回地址,因此实际上每次进入stack_test()函数时,都会保存上一个函数的栈帧基地址,以及返回地址

通过阅读后续的汇编代码也可以知道,在stack_test()函数中把第一个参数(x0)的值放入了x19寄存器中,所以在函数的开始,也保存了x19寄存器的值。

练习8 aarch64函数调用2

练习8

首先在gdb中查看stack_start()的汇编代码,正如上一个练习所说的,可以看到函数开始时保存了LR、FP以及X19寄存器到栈中:

Procedure Call Standard for the Arm® 64-bit中可以找到aarch64的栈帧布局,结合stack_test()函数的实际情况我做了标注。

回溯函数所需的信息主要是保存在栈中的上一个函数的栈帧基地址。

另外,在Procedure Call Standard for the Arm® 64-bit中明确规定了,如果被调用函数需要使用x19 ~ x28寄存器,那么一定要将其原值保存在栈中,例如stack_test()函数中使用了x19寄存器,那么在使用前就将其原值压入了栈中,并且在函数结束时从栈中还原了其值。

练习9 实现stack_backtrace

image-20210223181552118

练习9要求我们完善栈回溯的功能,只需要根据栈上保存的FP的值不断索引回溯即可,需要注意的是stack_backtrace()函数本身并不需要展示。
这里我是根据stack_start()函数的情况,直接把栈上保存的x19的值作为上一个函数的被调用参数,但显然这种方法不具备通用型,暂未想出如何通用地索引调用参数,哪位大哥有想法恳请指教!

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
__attribute__ ((optimize("O1")))
int stack_backtrace()
{
typedef struct {
u64 fp;
u64 lr;
u64 arg;
} stackframe;

stackframe frame; // 上一个函数的栈帧
u64 current_fp, last_fp;

current_fp = read_fp(); // 当前函数的fp
last_fp = *(u64 *)current_fp; // 上一个函数的fp
printk("Stack backtrace:\n");

while(last_fp){
frame.fp = last_fp;
frame.lr = *((u64 *)frame.fp+1);
frame.arg = *((u64 *)current_fp+2);
printk("LR %lx FP %lx Args %lx\n", frame.lr, frame.fp, frame.arg);
current_fp = frame.fp;
last_fp = *(u64 *)frame.fp;
}
return 0;
}

编译运行:

实验指导书说完成该练习后make grade会显示满分💯

Done ~

总结

在本次实验中,初步了解了arm汇编,并独立阅读并理解了部分boot及内核代码,完成了实验要求的内核代码的补充实现,还是很有趣的!感谢交大提供的课程,期待下一节关于内存管理的实验!

End of article
感谢阅读 ♪(^∇^*)

相关文章

评论区