MIT 6.828 Lab实验记录 —— lab1 Booting PC
阅读原文时间:2023年08月31日阅读:1
  1. MIT 6.828 lab1 讲义地址
  2. MIT 6.828 课程 Schedule
  3. MIT 6.828 lab 环境搭建参考
  4. MIT 6.828 lab 工具guide
  5. Brennan's Guide to Inline Assembly

笔者实验环境:ubuntu 20.02

本实验的实验环境主要包括两部分:

  1. QEMU:x86模拟器
  2. 一整套编译环境

由于实验环境搭建网上已经有很多详尽的资料,这里引用一位大佬的博客作为参考。

实验环境搭建参考链接

该实验主要分为3个部分:

  1. 由于我们的实验是基于一个x86模拟器QEMU做的,因此首先要先熟悉一下这个工具,并且借此研究一下PC的开机程序
  2. 在这部分,我们会探究6.828内核的加载过程,探究开机后,是如何将内核加载到内存并运行的。
  3. 最后这部分我们会探究一下6.828内核的基本结构

6.828的实验代码存储在https://pdos.csail.mit.edu/6.828/2018/jos.git代码仓库中,可以通过如下代码拉取到本地:

git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab

6.828一共有6个实验,每个实验对应一个分支,因此需要切换到对应分支,即:

cd lab # lab code 被克隆到了lab目录下
git checkout lab1 # 切换到lab1分支

由于操作系统的主要编程语言是C和汇编,因此需要有一定的汇编基础,为了保证可以顺畅进行后面的实验,可以先阅读一下Brennan's Guide to Inline Assembly

做好这些准备工作后,让我们开始实验内容。

1. PC Bootstrap(初探QEMU)

这里我们会尝试利用QEMU模拟PC的启动过程。首先,我们尝试先将QEMU跑起来,依次执行如下命令:

cd lab
make

运行结果如下:

+ as kern/entry.S
+ cc kern/entrypgdir.c
+ cc kern/init.c
+ cc kern/console.c
+ cc kern/monitor.c
+ cc kern/printf.c
+ cc kern/kdebug.c
+ cc lib/printfmt.c
+ cc lib/readline.c
+ cc lib/string.c
+ ld obj/kern/kernel
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 380 bytes (max 510)
+ mk obj/kern/kernel.img

到此为止,我们已经得到了一个镜像文件(obj/kern/kernel.img),该文件包括了两个部分:

  1. boot loader(启动加载器):obj/boot/boot

  2. kernel(内核):obj/kernel

    这两个部分后面都会分别介绍,拥有了这个镜像文件,我们就可以运行QEMU了。

    make qemu-nox # 或者 make qemu,建议使用make qemu-nox,否则在虚拟机环境下还是有一点小麻烦的

然后如下内容将被显示,如果想要退出qemu请依次按下Ctrl+ax

Booting from Hard Disk...
# 下面部分是习题用的print出来的内容
6828 decimal is XXX octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
# 到此为止
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>

我们可以看到,QEMU模拟的操作系统打印出了许多类似于debug的信息,这些都是后面的习题要使用的内容,为了我们能够很好的对操作系统debug,以便后面处理上面的信息,我们需要学会使用GDB(调试工具),使用方式也很简单。

  1. 在lab目录下打开两个终端
  2. 第一个终端(终端1)输入命令make qemu-nox-gdb
  3. 第二个终端(终端2)输入命令make gdb

结果如下:

终端1是预览窗口,终端2是debug窗口。我们可以在终端2中输入一些命令来控制程序的运行,或者获取当前计算机中的信息,详细使用方式可以查看MIT 6.828 lab 工具guide,本实验我们只需要使用到3条命令,第一条为si即单句运行。

通过上图我们可以看到,我们对6.828提供的操作系统debug,运行的第一行代码是在内存0xffff0位置的代码ljmp $0xf000,$0xe05b,并且在这行代码上还有一句提示The target architecture is assumed to be i8086。这里有一个问题:

  1. 为何是从0xffff0这个位置开始运行?这里是什么?

我们考察MIT 6.828 lab1 讲义中给出的地址空间布局图:

+------------------+  <- 0xFFFFFFFF (4GB)
|      32-bit      |
|  memory mapped   |
|     devices      |
|                  |
/\/\/\/\/\/\/\/\/\/\

/\/\/\/\/\/\/\/\/\/\
|                  |
|      Unused      |
|                  |
+------------------+  <- depends on amount of RAM
|                  |
|                  |
| Extended Memory  |
|                  |
|                  |
+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000

可以看到0xffff0对应BIOS ROM的最后16个字节。继续查看讲义,我们发现,原来这里是沿用了早起8088的设计,PC中的BIOS是烧录进入0x000f0000-0x000fffff位置的,这使得在PC启动时BIOS总能第一时间控制PC,毕竟此时,内存中除了BIOS的部分,都是随机的数据。因此,设计者将入口设置到了0xffff0,即CS=0xf000,IP=0xfff0。注意,BIOS只能运行在实模式下,实模式的物理地址计算方式为:

physical address = 16 * segment + offset

根据公式可以看出实模式只能访问前1MB的内存(0x00000-0xfffff)。然而16个字节的内存并不能存储多少代码,因此,真实的处理逻辑被存储在其他地方,这里只负责jump到对应的地点而已。这部分代码主要用于进行上电自检等设备检验和初始化操作,当一切硬件设备都处理好了,就要开始引导并加载操作系统内核了。

2. Boot Loader

传统意义上,操作系统存储在硬盘空间中,而硬盘又被划分为一个个的扇区,每个扇区512bytes。根据冯诺依曼体系结构,操作系统的内核映像需要被装载到内存中才能运行,因此,Boot Loader的职责就是将操作系统内核映像加载到内存中,并且将控制权限交给内核。

然而这里存在一个问题,回顾物理内存的布局结构,可以看到前1MB的内存基本已经被占用满了。

+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000

这里的Low Memory也需要做保留用于他用,可见实模式的寻址已经不足以满足当前的需求,我们需要一种方式能够访问更多的内存,以便可以将内核加载到其中。因此,就需要将实模式切换到32-bit的保护模式,在这个模式下,可以访问4GB内存(32bit 30=1GB 2=4)。处理这部分的代码在boot/boot.S中。

了解了内存寻址不够的处理方案,那么还有一个问题,我们需要从哪里加载操作系统内核?怎么让机器了解这个位置?

事实上,为了从硬盘或者软盘上启动,必须把它们用于启动的第一个扇区中所存放的指令(boot/boot.S)装载到内存中执行,而这些指令再把包含内核映像的其他所有扇区拷贝到内存中。处理这部分的代码在boot/main.c中。第一个扇区的装载位置同PC的最初启动位置一样,是一个固定值,在 0x7c00 到 0x7dff中。这里我们学习GDB的第二条和第三条命令:

  1. b *ADDR 在ADDR位置设置断点
  2. c 将程序运行到断点处

这里我们尝试在0x7c00位置设置断点,即b *0x7c00,然后使用c命令,将程序运行到断点处。

对比右侧和左侧的代码可以发现,boot/boot.S被加载到了0x7c00中,我们也可以通过obj/boot/boot.asm查看代码和它的内存空间中的物理地址分布。

考察boot.S的第44行(boot.asm的第61行):

通过注释,可以看到,ljmp $PROT_MODE_CSEG, $protcseg指令后,跳转到了32-bit保护模式,如果我们将断点打在这里,并跳过这一行代码可以发现:

然而,真正造成实模式到保护模式转变的包括两个部分:

  1. 使能A20总线

  2. 设置保护模式flag

完成这两步之后,还需要对保护模式下的各大段寄存器进行初始化,并为C语言运行设置esp,保证C语言运行有栈可用:

最后调用call bootmain进入到boot/main.c中,在boot/main.c中将会读取整个内核镜像到内存中。编译后的内核镜像是一个ELF格式的文件,整个装载过程就是装载该ELF文件的过程(这个文件很复杂,我们只简单看一下装载过程),查看boot/main.c文件:

#define SECTSIZE    512
#define ELFHDR        ((struct Elf *) 0x10000) // scratch space

void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);

void
bootmain(void)
{
    struct Proghdr *ph, *eph;

    // 读取磁盘中的第一页(4096 bytes)
    readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

    // is this a valid ELF?
    if (ELFHDR->e_magic != ELF_MAGIC)
        goto bad;

    // load each program segment (ignores ph flags)
    ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
    // 根据ELFHeader中的信息,每次读取一个segment,直到读取完毕为止。
    eph = ph + ELFHDR->e_phnum;
    for (; ph < eph; ph++)
        // p_pa is the load address of this segment (as well
        // as the physical address)
        readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

    // call the entry point from the ELF header
    // note: does not return!
    // 进入内核
    ((void (*)(void)) (ELFHDR->e_entry))();

bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);
    while (1)
        /* do nothing */;
}

可以看到,ELF文件的Header中存放了各个segment的信息,boot/main.c根据这些信息将整个内核加载入内存中,最后进入内核。

3. The kernel

通过本次实验,我们了解了6.828的使用的QEMU的整个BOOT流程,真实的Linux是如何启动的呢?笔者考察了《深入理解linux内核》这一著作,其附录一《系统启动》描述了该问题:

  1. BIOS:在开始启动时,有一个特殊的硬件电路在CPU的一个引脚上产生一个RESET逻辑值,在RESET产生以后,就把处理器的一些寄存器(包括cs和eip)设置成固定的值,并执行在物理地址0xfffffff0处找到的代码。硬件把这个地址映射到某个只读、持久的存储芯片(ROM),ROM中存放的程序集在80x86体系中通常叫做基本输入/输出系统(BIOS),因为它包括几个终端驱动的低级过程。所有的操作系统在启动时,都要通过这些过程对计算机硬件进行设备初始化。BIOS的启动过程主要包括4个操作:

  2. 引导装入程序:

  3. 进入内核进行初始化。