ucore lab1 实验报告

之前分析了一波内联汇编的语法,然而并没有什么卯月……主要还是靠着自己写了一波 bootloader (虽然内核都还没 load 上)于是这就开始实验了,参照官方的实验指导书做的练习。

/labcodes_answer/lab1_result/ 目录下的代码,在用较新版本(好像是 GCC 5.x 开始)就会出现生成的 bootloader 二进制文件过大无法塞入第一个扇区的问题,这种情况只需要将 /labcodes_answer/lab1_result/boot/bootmain.c 中两个全局变量的声明改为 #define 即可编译通过。

没错,就是这么骚。

最终结果我已经扔我的 GitHub 上了:https://github.com/xr1s/ucore_os_lab

  1. ucore lab1 实验报告
  2. ucore lab2 实验报告
  3. ucore lab3 实验报告

练习 1

Makefile 的默认目标在第 207 行被显式指定为 205 行的 TARGETS ,而 TARGETS 的依赖为 $(TARGETS) ,这个变量在 Makefile 只是空的,但是会在 tools/function.mk 中的 do_create_target 宏中被修改, do_create_target 被函数 create_target 直接调用。因此在 Makefile 中只要调用了 create_target 就会为 $(TARGETS) 增添新的一项。

经过一系列的 create_target$(TARGETS) 最终值为 bin/kernel bin/bootblock bin/sign bin/ucore.img

具体到各个目标的依赖就不详细分析下去了,因为实在是太复杂了(之前写了一波,因为占实验报告内容太长所以被我删了)。下面只画出最终的依赖树,由于 .h 的引用太乱了因此没有列出来(不然这图就彻底毁了)。

make2graph 真难用,最后还是我手撸的 graphviz 。

lab1 Makefile 依赖图

至于提示的 make V= ,我一开始以为这个 V= 是什么 make 的骚参数,没想到只是作者写在 Makefile 里的一个变量,默认是 @ ,是加在每个命令前用来抑制输出的。如果命令行调用 make 的时候设置了 V= ,也就是设置变量为空,这样每个命令执行的时候就会输出命令本身是什么。

好吧,还是按照要求来,两个问题的解答:

Question 1.

操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中每一条相关命令和命令参数的含义,以及说明命令导致的结果)

依赖图在上面,可以看出一点编译流程的端倪。

  1. 第一条线索是针对每个 .c 文件编译出对应的 .o 文件,然后大家一起链接成 bin/kernel 文件。
  2. 第二条线索是对于 boot 部分的编译,几个 boot/ 目录下的文件汇编链接成 obj/bootblock.o 之后 objecopy 出 raw binary 文件 obj/bootblock.out (这一段写在 Makefile 里,没有用 $(V) 直接用了 @ 所以不会打印出来)。
  3. 最后两步是最骚的,通过编译执行一个预先写好的 tools/sign.c 文件,读取整个 obj/bootblock.out ,判断文件大小是不是小于等于 510 ,如果不是说明构建失败,退出。如果成功则填充 magic number 0xAA55 ,输出到 bin/bootblock 中(这个也是写在 Makefile 中但是没打印出来)。
  4. 最后使用 dd 命令从 /dev/zero/ 创建空文件 bin/ucore.img,将两个编译出的文件 bin/bootblockbin/kernel 逐字节拷贝到其中。

好吧然后分析调用 gcc 的每个参数是什么意思(其实看 man 就够了,更何况大部分参数我都记得住……)。

  • -I 系列: GCC 的 -I dir 编译选项,将 dir 加入到搜索头文件的目录列表中,可以用 #include <...> 来包含。
  • -fno-builtin :嘛,废柴选项。对所有 GNU C Compiler 内建函数,必须以 __builtin_ 开头才能被编译器识别,否则作为未定义标识符(或者用户定义标识符)。
  • -Wall :生成尽可能多警告。为什么叫尽可能多呢……因为还有 -Wextra -Wpedantic ,甚至 -Wpedantic 也不是最多的,有更多警告需要传参手动打开。
  • -ggdb :生成 gdb 兼容的调试信息,其实这个指令 -g 就可以了。
  • -m32 :可以说是最重要的选项了,生成 32 位环境( x86 架构)兼容指令。
  • -gstabs :生成 stabs 格式的调试信息,在内核源码中有一步输出栈帧信息利用了 stabs 格式的调试信息,和上面的 -ggdb 并不冲突。
  • -nostdinc :不搜索系统标准头文件,只使用 -I 选项指明的目录。
  • -fno-stack-protector : 关闭 canary 保护,这是针对栈溢出的保护,对于内核来说没啥用,毕竟内核本身就是乱写内存的( sorry ,内核就是可以为所欲为),打开反而可能会导致莫名的 crash 。
  • -c :仅仅编译+汇编,不链接。也就是产生可重定位 ELF 文件(好吧,也叫 object 文件)。
  • -o :指明输出文件名。

然后是 ld 命令……

  • -m :指明生成文件格式,此处为 elf_i386 ,则生成 x86 架构的 ELF 格式可执行文件。
  • -nostdlib :字面意思,不链接标准库。
  • -T :指明链接脚本,链接脚本是用来指明各个段的位置还有入口之类的链接信息的。
  • -o :指明输出文件名。
  • -N :设置 text 和 data 段可写,不要按页对其代码,不和共享库链接。这个命令就是专为编译裸机程序打造的……
  • -e :指明 entry point ,默认是 _start ,这里设置为入口函数 kern_init 以便随后 boot 进入保护模式后可以直接 jump 到 entry point 函数。
  • -Ttext 指明 text 段在运行时的内存地址,以便于符号重定位。

最后是 dd 命令……

  • if : input file 。
  • of : output file 。
  • count :读取并写入的 block size 数。
  • bs : block size ,默认 512 。
  • seek :跳过写入文件的 block size 数。
  • conv : conv 符号处理的东西有点多,就这里的 notrunc 是不设置 write syscall 的参数 O_TRUNCdd 默认会设置),这就使得 dd 输出的文件已存在时,如果现在输出的比原来的要小,则文件大小保持被 dd 之前一样不变。

Question 2

一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

必须恰好 512 字节,且最后两个字节分别是 0x55 和 0xaa , BIOS 只检查这两个字节。

练习 2

嘛,我自己写的时候调了不知道多少遍了…… gdbinit 稍微改了一下。

顺便一提 gdb-tui 是一个已经废弃了不知道多少年的工程,充满了 bug ,我是打死都不会用它的。

图裂了,传不上来,自己意会吧。

练习 3

设置 A20 总线,使用 lgdt 指令加载 GDT 表,修改 %cr0 的最低位为 1 ,一个长跳刷新 cache 。

Question 1

为何开启A20,以及如何开启A20

兼容原因,早期 8086 CPU 只支持 20 位寻址 (1MB) ,而“段:偏移”寻址方式支持略高于 20 位的寻址能力,大于等于部分会“回卷”而访问低地址。在新的 24 位总线 CPU 出现之后,为了兼容回卷特性而加入了 A20 地址总线,若关闭则保留回卷特性,否则禁用回卷,允许访问高地址。

由于 A20 的实现是通过逻辑与进行,如果不打开则只能访问奇数位内存,为了充分利用内存当然需要打开。

Question 2

如何初始化GDT表

虽然写过一遍……下面是 GDT 表的格式:

GDT 表格式

总计有 64 个位。其中,除了身首异处的 Base 和 Limit 块,另外两块 Access Byte 和 Flags 块的内容是这样的:

GDT 表 Flag 字段

以上图片取自 OSDev.org

解释一下 Access Byte 中 8 个位的含义:

  • Ac 是访问位,只要设置为 0 即可。 CPU 会在第一次访问这个段之后设置为 1 。
  • RW 是读写位:
    • 对于数据段: 0 表示不可写(只读), 1 表示可写。数据段永远可读。
    • 对于代码段: 0 表示不可读(无访问权限), 1 表示可读。代码段永远不可写。
  • DC 位, Direction/Conforming 位:
    • 对于数据段: 0 表示访问该段的偏移必须要比 Limit 要大(向下增长), 1 则是要小(向上增长)。
    • 对于代码段: 1 表示可以被更低运行权限(更大 Privl )执行, 0 表示只能被同权或更高权限执行。
  • Ex 是可执行位: 0 表示该段为数据段, 1 表示该段为代码段。
  • Privl 是权限位,占据两个比特。权限位从 0~3 ,越小表示权限越高,最高为 0 。
  • Pr 为 Present 位,对于所有可用段必须为 1 。

解释一下 Flags 中的 4 个比特含义:

  • Sz 是大小位: 0 表示该段运行于 16-bit 保护模式下, 1 表示运行于 32-bit 保护模式下。用于向后兼容。
  • Gr 是粒度位: 0 表示 Limit 的单位是 1 Byte , 1 表示 Limit 的单位是 4 KB (一个页)。
  • Flags 中的第 2 个 bit 在 x86-64 中指明为 64 位描述符,此时 Sz 位必须为 0 。

然后……按照需要设置即可,现在 ucore 只有代码段和数据段,所以只设置了这两个。

具体格式略有点复杂, GDT 中第一项必须全空,随后才是设置上面图中的数据结构。在内存中构建好 GDT 之后,我们需要告知 CPU 其位置。需要再在内存中构建一个特殊的(简单许多的)结构,并使用 lgdt 指令来将其从内存中加载出来。

该结构体很简单,总共 3 个字长,第一个字是 GDT 的大小 – 1 ,后两个字长为 GDT 在物理内存中的地址。

Question 3

如何使能和进入保护模式

x86 引入了几个新的控制寄存器 (Control Registers) cr0 cr1 … cr7 ,每个长 32 位。这其中的某些寄存器的某些位被用来控制 CPU 的工作模式,其中 cr0 的最低位,就是用来控制 CPU 是否处于保护模式的。

因为控制寄存器不能直接拿来运算,所以需要通过通用寄存器来进行一次存取,设置 cr0 最低位为 1 之后就已经进入保护模式。

但是最后,由于一些现代 CPU 特性 (乱序执行和分支预测等),在转到保护模式之后 CPU 可能仍然在跑着实模式下的代码,这显然会造成一些问题。因此必须强迫 CPU 清空一次缓冲。对此,最有效的方法就是进行一次 long jump 。

练习 4

利用 in out 指令读取外设。 ELF 格式的内核置于第二个扇区以及之后,读入后程序控制流跳转至 ELF 格式中指定的 entry point 以达成进入内核代码的目的。

Question 1

bootloader如何读取硬盘扇区的?

具体的代码实现在 boot/bootmain.c 中,流程跳转到 bootmain 函数后,开始读取硬盘流程。

readseg 负责多个扇区的读取,将字节偏移转化为扇区编号,处理对齐等问题。

readsect 负责具体扇区的读取,利用 libs/x86.h 中定义的具体指令对硬盘进行操作。

具体的端口作用如下:

  • 0x1f0 :当硬盘不忙时,可以在此读写数据。
  • 0x1f1 :错误寄存器。 0x1f7 端口返回错误时由在该寄存器设置错误码,可供读取。
  • 0x1f2 :向该端口传递读取的扇区数。
  • 0x1f3 : LBA 模式下传递扇区编号的 0~7 比特。
  • 0x1f4 : LBA 模式下传递扇区编号的 8~15 比特。
  • 0x1f5 : LBA 模式下传递扇区编号的 16~23 比特。
  • 0x1f6 : LBA 模式下低 4 比特为扇区编号的 24~27 比特 ,第 4 比特为 0 表示主盘,否则为从盘。
  • 0x1f7 :状态和命令寄存器。读取时,它第三位为 1 时表示硬盘做好数据交换准备,最高位为 1 时表示忙。写入时,写入 0x20 表示请求硬盘读。

通过这几个端口读取了总计 8 个扇区。

Question 2

bootloader是如何加载ELF格式的OS?

通过 libs/elf.h 定义的 ELF 文件头结构体来访问具体内容。

简单地检查 magic number 是否正确 (“\x7fELF”) ,随后通过头中地信息再从硬盘中读取相应的段到内存中,随后跳转到 e_entry ,也就是指定的入口点。

练习 5

哎,注释里写得一清二楚,跟着走就好了。

uint32_t ebp = read_ebp();
uint32_t eip = read_eip();
while (ebp) {
  cprintf("ebp:0x%08x eip:0x%08x\nargs:", ebp, eip);
  uint32_t *args = (uint32_t *)ebp + 2;
  for (size_t i = 0; i != 4; ++i)
    cprintf(" 0x%08x", args[i]);
  cprintf("\n");
  print_debuginfo(eip - 1);
  eip = ((uint32_t *)ebp)[1];
  ebp = ((uint32_t *)ebp)[0];
}

x86 中,栈向低地址增长,因此栈底在高地址,栈顶在低地址。

x86 中被调用函数的返回地址储存在调用函数栈帧的顶部,也就是最低的几个字节。对于当前(被调用)函数栈帧来说,就是栈帧底部以上的部分。调用函数的栈底信息则储存在当前函数栈帧的底部几个字节。因此在折叠栈的时候 pop 到 ebp 即可,再 pop 一个到 eip 就返回了(当然这一步是由 ret 指令实现的)。

x86 中的传参约定为,调用函数传入的参数应当从右至左压入栈中,因此参数地址可以直接由 ebp 推导出。

最后一行?是指 unknow 那行吗?那就是最初进入 kernel 前的内存地址呀。

练习 6

要求在题目里说得非常清楚了,只要初始化就好了,甚至具体哪些字节如何初始化都不需要我们去担心,为我们提供了宏函数。

这一部分一个比较坑的地方是使用 GCC 6 以上的编译器编译似乎会有 bug ,应该是 kern/mm/pmm.c 的编译出现了问题,纠结了我很久,反编译也没看出来有什么问题, GCC 6 以上编译出来的结构混乱很多。

Question 1

8 字节。

中断表结构还是很简单的,这个读源码就可以看出来,具体来说还是参考 OSDev.org 上的表格吧,这里只需要跟着走就好了。

Question 2

extern uintptr_t __vectors[];
for (size_t i = 0; i != sizeof idt / sizeof *idt; ++i)
  SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
SETGATE(idt[T_SYSCALL], 0, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
lidt(&idt_pd);

Question 3

trapdispath 函数中这一项需要我们编写

if (++ticks == TICK_NUM) {
  print_ticks();
  ticks = 0;
}

扩展练习 Challenge 1

Challenge 不愧是 Challenge ,连注释都不给了,没有基础的就是不会做,你爸爸永远是你爸爸。

我们已经在 kern_init 中利用 gdt_init 函数初始化了用户态的 GDT ,切换的时候只需要设置一下几个段寄存器为用户态寄存器就好了。

在中断表中有两个中断, T_SWITCH_TOUT_SWITCH_TOK ,一个是切换到用户态,另一个是切换回内核态,显然是希望我们通过这两个中断来进行上下文切换。内核已经为我们提供了这两个中段号,我们只需要在 ISR 中设置一下段寄存器。

当然,从用户态切换到内核态需要另外设置中断号使其可以从用户态被中断。

稍微分析跟踪一下 ISR 的流程,首先在中断表中注册的 vectors 数组中存放着准备参数和跳转到 __alltraps 函数的几个指令,在 __alltraps (在 kern/trap/trapentry.S 中定义)函数中,将原来的段寄存器压栈后作为参数 struct trapframe *tf 传递给 trap_dispatch ,并在其中分别处理。

中断处理函数在退出的时候会把这些参数全部 pop 回寄存器中,于是我们可以乘他还在栈上的时候修改其值,在退出中断处理的时候相应的段寄存器就会被更新。

我们这里只需要在 case T_SWITCH_TOU:case T_SWITCH_TOK: 两个 case 处添加修改段寄存器的代码即可:

static void
switch_to_user(struct trapframe *tf) {
  if ((tf->tf_cs & 3) == 3) return;
  tf->tf_ds = tf->tf_es = tf->tf_fs = tf->tf_gs = tf->tf_ss = USER_DS;
  tf->tf_cs = USER_CS;
  tf->tf_eflags |= FL_IOPL_3;
}
​
static void
switch_to_kernel(struct trapframe *tf) {
  if ((tf->tf_cs & 3) == 0) return;
  tf->tf_ds = tf->tf_es = tf->tf_fs = tf->tf_gs = tf->tf_ss = KERNEL_DS;
  tf->tf_cs = KERNEL_CS;
  tf->tf_eflags &= ~FL_IOPL_3;
}

这样的话,只要触发 T_SWITCH_TOUT_SWITCH_TOK 编号的中断, CPU 指令流就会通过 ISR 执行到这里,并进行内核态和用户态的切换。

这里有一个坑,在输出的时候,由于 in out 是高权限指令,切换到用户态后跑到这两个指令 CPU 会抛出一般保护性错误(即第 13 号中断)。而源码中在切换至用户态之后还会有两次输出( lab1_print_cur_statuscprintf ),如果不作处理自然再次导致陷入中断,控制流再次进入 trap_dispatch 中。但是这次 T_GPLT 未被处理,所以会落到 default 中打印错误并退出……于是就递归了。

因此为了能正常地输出,需要修改 IO 权限位。在 EFLAGS 寄存器中的第 12/13 位控制着 IO 权限。这个域只有在 GDT 中的权限位为 0 (最高权限)时,通过 iretpopf 指令修改。只有在 IO 权限位大于等于 GDT 中的权限位才能正常使用 in out 指令。我们可以在 trap_dispatch 中通过 trap_frame 中对应位修改 EFLAGS 。

接下来只需要在 kern/init/init.c 中开启题目开关,然后实现题目要求的两个函数 lab1_switch_to_userlab1_switch_to_kernel 。需要另外注意保持栈平衡。

static void
lab1_switch_to_user(void) {
    asm volatile (
        "subl $0x08, %%esp\n"
        "int  %[switch_tou]\n"
        "movl %%ebp, %%esp\n"
        :
        : [switch_tou]"N"(T_SWITCH_TOU)
        : "%eax", "%esp", "memory", "cc"
    );
}
​
static void
lab1_switch_to_kernel(void) {
    asm volatile (
        "int  %[switch_tok]\n"
        "popl %%esp\n"
        :
        : [switch_tok]"N"(T_SWITCH_TOK)
        : "%eax", "%esp", "memory", "cc"
    );
}

到这一步应该是 make grade 满分了,不过还有最后一个 challenge 。

扩展练习 Challenge 2

主要是捕获击键,然后调用上面写的两个函数。好嘛。

击键也会触发一个中断,对其的处理在 trap_dispatchIRQ_KBD case 处,反正返回的就是 ASCII 码,直接判断是不是等于 ‘0’ 或者 ‘3’ 即可。

c = cons_getc();
switch (c) {
 case '0':
  switch_to_kernel(tf);
  print_trapframe(tf);
  break;
 case '3':
  switch_to_user(tf);
  print_trapframe(tf);
  break;
}
cprintf("kbd [%03d] %c\n", c, c);
break;

发表评论

电子邮件地址不会被公开。 必填项已用*标注