2020年以及之后的教程,用了全新的一套模拟器,教学顺序也有变化;但是这篇博文介绍的是系统启动的顺序,仍然具有很高的价值。
PC Bootstrap
这部分原文是介绍了x86的汇编语言和计算机的引导过程。
Getting Started with x86 assembly
注意区分AT&T的汇编语法和Intel的语法之间的区别。
练习一:了解一下基本的汇编知识。
当然如果有充足的时间,不妨可以阅读一下Intel提供的指令手册。
Simulating the x86
为了能够模拟真正的引导,该lab使用qemu来进行模拟,而不需要真实的机器。
第一步,先对代码进行make操作,毕竟目前都是一堆.c的C语言代码和.S的汇编代码,是给人看的,机器可看不懂。make输出的结果:

可以看到首先是汇编器把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即可。输出的结果如下:
屏幕上的每一句都是内核打印出来的。这个系统只支持两个命令,分别是help和kerninfo。所以如果你把这个img文件给复制到硬盘指定的位置(前几个扇区),那么到真正的物理机器上也可以运行这个简陋的内核(记得保存自己的数据)。
The PC’s Physical Address Space
下面这个字符画表示了PC的物理内存布局:
1 |
|
第一台PC,基于Intel-8088处理器,这台机器很奇怪,寄存器是16位的,使用20条地址线,所以寻址范围是1MB。因为20条地址线在16位寄存器里面放不下,所以采用了一种很奇怪的寻址方式,就是把CS里面的值左移四位(等价于乘16),然后加上IP的值得到的就是地址。
可以看到BIOS映射到了内存的960KB~1MB处,它负责执行基本的系统初始化(硬件自检+中断向量表建立),然后从某个位置(软盘、硬盘、网络)开始加载操作系统,并把控制权转移给对应的操作系统。
随着时间的发展,寻址空间从1M到了4G,但是为了保持向后兼容,所以现在物理内存的640KB——1MB都有一个洞。而且如果是更多的寻址位置,会带来更多的挑战,但是我们这个只是简单的操作系统,只使用256MB的物理内存就够了。
The ROM BIOS
在这一章,开始学习利用GDB来观察操作系统的启动流程。
可以看到的第一句指令是:

由此我们由以下两个结论:
- 第一条指令的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.S和main.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查看这个二进制文件。当然除了上面的这些程序段,还有一些别的不重要的程序段(下面的截图只截取了部分):
可以看到有两个地址,分别是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的内存中,内核是在(虚拟)内存的最高处,也就是0xC0000000到0xFFFFFFFF是内核的地址,而显然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.c、lib/printfmt.c和kern/console.c这三个c文件,接下来的实验会让你明白为什么printfmt.c是在lib这个目录下面的。
我个人打算是从printf.c开始分析的,首先是第一个函数:
1 | static void |
继续追踪,发现其实是在console.c里的:
1 | void |
练习8:我们故意没有实现%o来打印八进制的功能,请你实现。
请回答下面的问题:
- 解释下
printf.c和console.c的接口关系。console.c输出了什么函数可以被printf.c引用,是怎么引用的?
printf.c使用了console.c的cputchar()这个函数,并将其封装成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 | # Set the stack pointer |
栈的位置则是在0xf0108000-0xf0110000。
在x86中,我们使用esp来指向栈顶,并且比esp低的地址处是free的空间,push操作是先将栈指针进行减少的操作,然后把数字写入到对应的栈指针所指向的位置处。而pop操作则是先进行读取,然后对指针进行增加操作。
很多x86的指令,都是通过硬连线的方式来使用栈的。相反esp则大多是通过软件来进行修改的。
一般在函数调用的时候,被调用的那个函数会首先把前一个函数的ebp入栈,然后在自己运行的时候,让ebp为之前的esp,就像这个样子:
1 | push %rbp |
显然如果所有的函数调用都遵循这个规则,那么就可以构成一条回溯的链,来找到bug。
练习10:找到
test_backtrace所在的位置,并且使用断点进行调试。不用打断点直接就能看呀….在
f0100040开始的
根据上面的练习,你应该能够编写一个函数自己来实现栈追踪功能了吧。函数执行开始之前,就已经把参数入栈了。并且函数执行完毕之后会跳转到call后面的那条指令继续运行。那么为什么无法得知有多少个参数呢?怎么修复这个问题呢?
下面是一个C语言中指针的小tip,假设int *p = (int *)100;,那么此时p是100(因为100不能用取地址符号)。
练习11:完成上面所述的函数,并且上传得分。