0%

xv6-lab1-系统启动过程

原lab的地址:https://pdos.csail.mit.edu/6.828/2018/labs/lab1/

2020年以及之后的教程,用了全新的一套模拟器,教学顺序也有变化;但是这篇博文介绍的是系统启动的顺序,仍然具有很高的价值。

PC Bootstrap

这部分原文是介绍了x86的汇编语言和计算机的引导过程。

Getting Started with x86 assembly

注意区分AT&T的汇编语法和Intel的语法之间的区别。

练习一:了解一下基本的汇编知识。

当然如果有充足的时间,不妨可以阅读一下Intel提供的指令手册。

Simulating the x86

为了能够模拟真正的引导,该lab使用qemu来进行模拟,而不需要真实的机器。

第一步,先对代码进行make操作,毕竟目前都是一堆.c的C语言代码和.S的汇编代码,是给人看的,机器可看不懂。make输出的结果:

image-20201119163349076

可以看到首先是汇编器把entry.S转成二进制代码,然后是一堆编译的c文件,主要集中在kern文件夹下面和lib文件夹下面。然后使用了链接器来连接kernel。之后是分别对boot.S和main.c进行处理使之转化成二进制文件。最后一行发现创建了obj/kern/kernel.img,这个就是我们的bootloader和内核(虽然很丑陋)。

你可以把这个生成的kernel.img当成是真实机器的物理磁盘上的东西,包含了bootloader(obj/boot/boot)和kernel(obj/kernel)。

好了一切准备就绪,现在就可以启动这台“机器”了。运行make qemu-nox即可。输出的结果如下:

image-20201119165506105

屏幕上的每一句都是内核打印出来的。这个系统只支持两个命令,分别是helpkerninfo。所以如果你把这个img文件给复制到硬盘指定的位置(前几个扇区),那么到真正的物理机器上也可以运行这个简陋的内核(记得保存自己的数据)。

The PC’s Physical Address Space

下面这个字符画表示了PC的物理内存布局:

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

+------------------+ <- 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

第一台PC,基于Intel-8088处理器,这台机器很奇怪,寄存器是16位的,使用20条地址线,所以寻址范围是1MB。因为20条地址线在16位寄存器里面放不下,所以采用了一种很奇怪的寻址方式,就是把CS里面的值左移四位(等价于乘16),然后加上IP的值得到的就是地址。

可以看到BIOS映射到了内存的960KB~1MB处,它负责执行基本的系统初始化(硬件自检+中断向量表建立),然后从某个位置(软盘、硬盘、网络)开始加载操作系统,并把控制权转移给对应的操作系统。

随着时间的发展,寻址空间从1M到了4G,但是为了保持向后兼容,所以现在物理内存的640KB——1MB都有一个洞。而且如果是更多的寻址位置,会带来更多的挑战,但是我们这个只是简单的操作系统,只使用256MB的物理内存就够了。

The ROM BIOS

在这一章,开始学习利用GDB来观察操作系统的启动流程。

可以看到的第一句指令是:

image-20201119172047954

由此我们由以下两个结论:

  • 第一条指令的CS:IP是f000:fff0,也即0xffff0,而这个地址显然是在BIOS映射的内存空间中的。
  • 第一条指令是jmp,跳转到了一个地方。(原lab中的地址和上图中的地址有出入,暂时想不通是什么原因)

练习2:使用si命令来一条一条执行接下来的指令,不需要完全看懂。

然后BIOS创建中断向量表和初始化硬件,接下来就是把控制权交给bootloader了。

The Boot Loader

扇区(sector)是一个软盘和磁盘的最小读取单位。如果磁盘中第一个扇区是可以引导的(有一个特殊的魔数),BIOS如果找到了这么一个扇区,就会把它从软盘或者磁盘中读取出来,并且放到内存0x7c00处,然后通过修改IP寄存器跳转到0x7c00。别去纠结为什么是这么一个地址,反正对于所有的操作系统都是这样的。

从CD-ROM里进行引导的过程非常不一样,但是没有了解的必要。

在6.828这里,我们使用了传统的磁盘引导,并且boot loader包含了一个汇编语言boot/boot.S和一个C语言的boot/main.c文件。boot loader必须实现以下两个重要的功能:

  • 必须从实模式(real-mode)切换到32位的保护模式(protected-mode)。我们只需要了解,在实模式下和保护模式下地址计算有所出入(实模式就是cs*16+ip这种计算方式,保护模式的寻址方式这里暂不提),并且所支持的地址扩大到了32位。
  • boot loader通过使用x86特殊的IO指令来访问磁盘,并且直接从磁盘里把内核读出来。

obj/boot/boot.asm这个文件就是对bootloader的反汇编出来的,可以看到确实是从0x7c00开始,并且确实是包含了boot.Smain.c的代码。obj/kern/kernel.asm则是对内核的反汇编。

练习3:追踪所有的boot loader的代码,并且回答以下问题:

  • 什么时候从实模式切换到了保护模式?是什么促使了从16位到32位的变化? 应该是打开第21根地址线(A20)的时候
  • boot loader 执行的最后一条指令是什么?那么kernel的第一条指令是什么?通过gdb在0x7c00设置断点,可以发现bootloader的第一条指令是cli,最后一条指令是call *0x10018,相当于把控制权交给了kernel,然后执行kernel的代码。
  • kernel的第一条指令在哪里?是0xf0100000开始的,因为一开始说了只占用256M内存嘛
  • boot loader是怎么知道要从磁盘中读取多少个sector的?它是从哪里获取这些信息的?

总结一下bootloader到底做了什么:关中断、确定字符串方向、初始化段寄存器、打开地址线、初始化GDT、启动保护模式、把操作系统的内核的ELF头部读取到内存0x10000开始的位置中。

Loading the Kernel

现在我们把目光放到boot/main.c,在这之前你需要详细了解C语言的指针相关知识。

练习4:这段代码会输出什么?

为了能够理解这段C代码,需要首先理解ELF。ELF大部分复杂的内容是为了动态链接库,在这个简单的操作系统上是不被需要的。所以可以这么理解,ELF就是一个固定长度的程序头,带几个程序段的东西。我们感兴趣的程序段就下面三个:

  • .text:可执行的指令
  • .rodata:只读数据,是配合硬件实现的,但是我们这里不实现,所以其实并不是只读的
  • .data:保存初始化的

其实还有一个.bss的代码段,但是因为程序本身没必要存储,只有进程才需要,所以ELF文件里面.bss不占据空间。可以利用objdump -h kernel查看这个二进制文件。当然除了上面的这些程序段,还有一些别的不重要的程序段(下面的截图只截取了部分):

image-20201119194907229

可以看到有两个地址,分别是VMA(虚拟地址)和LMA(加载地址),一般情况下两者是相同的(比如boot里面就是相同的),但是图中是不同的。LMA是应该被加载到内存的地址,而VMA则是被执行的位置。

这里额外提一句,在指令里面涉及的地址,叫逻辑地址(也叫虚拟地址),然后地址会经过分段硬件,如果是代码就计算CS*16+offset(对应的硬件叫分段硬件),得到的地址就是线性地址。最后通过分页硬件得到内存的物理地址。如果没有这个分页硬件,那么线性地址就直接是物理地址(显然BIOS的时候是没有这个硬件的,所以BIOS使用的地址就是物理内存)。而到了xv6的话,是只有分页硬件而没有分段硬件了,所以在xv6里面逻辑地址和线性地址是一样的。而且不论是现在的x86,还是这个简单的xv6,就只有两种地址了,一种是程序里看到的逻辑地址,还有一个地址是经过分页硬件的物理内存地址。

在头部中指定了如何放置ELF的每一个部分,通过-x可以查看。在C语言的代码里面,ph->p_pa这个字段就包含了真实的段目标物理地址。

练习5:把初始地址从0x7c00改成别的一个地址并且重新运行,观察结果。记得改回来。结果是卡在启动过程中。

再回过头看看这两个地址,可以发现内核其实是想加载到内存的低地址处,但是确实希望在高地址处执行,接下来一节会解释这是为什么。

在ELF里面还有一个比较重要的区域,叫e_entry。可以通过objdump -f obj/kern/kernel,所以现在应该很好理解main.c所做的事情了,它就是把kernel的每一段从磁盘中放到LMA指定的位置那里,然后跳转到入口点(即把控制权转交给内核)

练习6:我们可以通过x命令来查看当前的内存内容。那么问题来了,0x100000在BIOS把控制权交给boot loader的时候,和当boot loader把控制权交给kernel的时候有什么区别?别实验,仅仅靠想想出答案。显然当BIOS把控制权给boot loader的时候,0x100000后面全是0,因为是boot loader把内核放过来的。

The Kernel

从这里开始就是真正的内核部分了。内核一开始使用的是汇编语言,为了接下来的C语言做准备。

Using virtual memory to work around position dependence

上面也提到了,在boot loader中的LMA和VMA是相同的,但是到了kernel里面却发现有巨大的差距。如果画过内存图的话,我们都知道在4G的内存中,内核是在(虚拟)内存的最高处,也就是0xC00000000xFFFFFFFF是内核的地址,而显然xv6更加的夸张,在更加上面的地方,更加具体的原因可以参考下一个lab。

显然有的机器肯定没有4G的内存,所以我们使用处理器的内存管理单元来把虚拟地址(就是上面的VMA)转化成物理地址(就是LMA,也就是boot loader把内核放到的物理空间的1M的上面)。看上去内核被存放在了内存的高地址处,但是实际上真正的物理地址却是1M的上面。当然你可能会问要是物理内存它只有1M呢?emmm 那就花钱买新的内存条吧。

在接下来的lab里面,我们将会映射整个物理内存低地址的256M内存空间。当然这一章我们只映射了4M的物理内存,我们通过手写的页目录和页表(在 kern/entrypgdir.c这个文件里面)来完成,之所以选择4M是因为够用,且刚好一个页表能完成。在这里你还不需要了解页目录和页表。在kern/entry.S里面设置了CR0_PG,这个一旦启用,就说明开启了虚拟地址转化成物理地址的功能。我们手写的这个entrypgdir.c,能够把0xf0000000到0xf0400000映射到内存的物理内存的前4M,同时把0x00000000到0x00400000也映射到物理内存的前4M。如果你使用了别的虚拟地址,那么就会导致QEMU无限重启。

练习7:利用GDB进行调试,在 movl %eax, %cr0这条指令停下,观察 0x00100000 0xf0100000。然后执行一条指令,并且再次观察这两个内存的值。

建立新的映射后的第一条指令是什么?如果映射失败了会怎么样?

Formatted Printing to the Console

在C语言里面我们使用printf来进行标准输出,甚至以为是理所当然的。但是在操作系统中,我们需要自己来实现IO。

熟读kern/printf.clib/printfmt.ckern/console.c这三个c文件,接下来的实验会让你明白为什么printfmt.c是在lib这个目录下面的。

我个人打算是从printf.c开始分析的,首先是第一个函数:

1
2
3
4
5
6
7
static void
putch(int ch, int *cnt)
{
cputchar(ch);
*cnt++;
}
// 可以看到是调用了cpuchar,而且显然ch就是要输出的字符。

继续追踪,发现其实是在console.c里的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
cputchar(int c)
{
cons_putc(c);
}
// 没什么好说的

static void
cons_putc(int c)
{
serial_putc(c);
lpt_putc(c);
cga_putc(c);
}

// 可以看到是三个函数的集合
// 本质上其实就是在用outb进行输出

练习8:我们故意没有实现%o来打印八进制的功能,请你实现。

请回答下面的问题:

  • 解释下printf.cconsole.c的接口关系。console.c输出了什么函数可以被printf.c引用,是怎么引用的?

printf.c使用了console.ccputchar()这个函数,并将其封装成putch()函数

  • ```c
    if (crt_pos >= CRT_SIZE){

    int i;
    memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
    for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
        crt_buf[i] = 0x0700 | ' ';
    crt_pos -= CRT_COLS;
    

    }
    // 解释下这段代码
    // 来自网络:是屏幕代码太长了放不下了,需要刷新重新搞

    1
    2
    3
    4
    5
    6

    - ```c
    int x = 1, y = 3, z = 4;
    cprintf("x %d, y %x, z %d\n", x, y, z);
    // 跟踪这段代码,fmt和ap各自指向了什么?
    // 来自网络:fmt指向了字符串,ap则是指向了栈顶
  • 说真的是完全看不懂了….先跳过吧,等以后什么时候想要详细了解C的printf的时候再来。

The Stack

最后的练习,来做一个堆栈的练习。

练习9:内核在什么时候初始化了堆栈,它把堆栈放哪里了?内核是如何为其保存地址的?

首先是初始化esp指针,是在entry.S文件中,有一段是:

1
2
# Set the stack pointer
movl $(bootstacktop),%esp

栈的位置则是在0xf0108000-0xf0110000

在x86中,我们使用esp来指向栈顶,并且比esp低的地址处是free的空间,push操作是先将栈指针进行减少的操作,然后把数字写入到对应的栈指针所指向的位置处。而pop操作则是先进行读取,然后对指针进行增加操作。

很多x86的指令,都是通过硬连线的方式来使用栈的。相反esp则大多是通过软件来进行修改的。

一般在函数调用的时候,被调用的那个函数会首先把前一个函数的ebp入栈,然后在自己运行的时候,让ebp为之前的esp,就像这个样子:

1
2
3
4
5
push   %rbp
mov %rsp,%rbp
函数逻辑......
pop %rbp
retq

显然如果所有的函数调用都遵循这个规则,那么就可以构成一条回溯的链,来找到bug。

练习10:找到 test_backtrace所在的位置,并且使用断点进行调试。

不用打断点直接就能看呀….在f0100040开始的

根据上面的练习,你应该能够编写一个函数自己来实现栈追踪功能了吧。函数执行开始之前,就已经把参数入栈了。并且函数执行完毕之后会跳转到call后面的那条指令继续运行。那么为什么无法得知有多少个参数呢?怎么修复这个问题呢?

下面是一个C语言中指针的小tip,假设int *p = (int *)100;,那么此时p是100(因为100不能用取地址符号)。

练习11:完成上面所述的函数,并且上传得分。