6.828-Lab1

6.828 的 Lab1 之前用英文洋洋洒洒写了七万多字,稍显冗杂。这次使用 Linux 环境重新走一遍,并且改用中文记录。

Lab 1:Booting a PC

Part 1: PC Bootstrap

Getting Started with x86 assembly

本课程的汇编代码使用Intel 语法,请同学们自行阅读该小节给出的资料。

Simulating the x86

在这个课程中,我们使用 QEMU 作为模拟器。首先,编译 Boot Loader 和 Kernel:

1
2
3
4
5
~/6.828/lab lab1 > make
+ as kern/entry.S
+ cc kern/entrypgdir.c
# .. lots of outputs
+ mk obj/kern/kernel.img

使用make qemu启动编译好的 Boot Loader 和 Kernel,如果实验机器没有 GUI,请使用make qemu-nox以串口方式连接虚拟机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
~/6.828/lab lab1 > make qemu-nox
sed "s/localhost:1234/localhost:26000/" < .gdbinit.tmpl > .gdbinit
***
*** Use Ctrl-a x to exit qemu
***
qemu-system-i386 -nographic -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::26000 -D qemu.log
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>

我们已经成功启动了编译好的 Kernel,现在你可以使用Ctrl+a x退出 QEMU。

The PC’s Physical Address Space

最初的 PC 是基于 16 位的 8088 处理器,它有 1MB 的物理内存寻址空间,但是用户可以用的只有 640KB 的 Low Memory。

1
2
3
4
5
6
7
8
9
10
11
12
+------------------+  <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000

一个 32 位 PC 的内存寻址空间如下所示,它保留了 1MB 以下的内存空间布局来确保兼容性。

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

出于简化设计的考虑,JOS 仅使用内存空间的前 256MB。

课外话:在 Windows NT 和 Linux Kernel 5.13 的 x86 版本中,前 1MB 内存将无条件保留,以确保内存安全。

The ROM BIOS

接下来尝试使用 GDB 调试:

  1. 使用make qemu-nox-gdb启动虚拟机,这表明接下来需要 GDB 附加到 QEMU。
  2. 使用make gdb启动 GDB,并自动附加到启动的 QEMU 上。

我们可以看到接下来要执行的指令,这是 BIOS 的第一条指令:

1
[f000:fff0] 0xffff0:	ljmp   $0xf000,$0xe05b

我们可以通过这条提示得到以下几点:

  • IBM PC 从 0x000ffff0 开始执行,该地址位于 ROM BIOS 保留的 64KB 区域的最顶部。
  • PC 从 CS = 0xf000 和 IP = 0xfff0 开始执行。
  • 第一条指令是长跳转指令,它通过设置寄存器 CS = 0xf000 和 IP = 0xe05b 来实现跳转。

问题来了,什么是 CS 和 IP?

这是两个寄存器的名字,CS 是Code Segment,存放某个程序的开始地址,而 IP 是Instruction Pointer,存放下一条指令的偏移。这两个寄存器的值通过如下的公式,即可转换为一个真实的物理内存地址:

physical address = 16 * segment + offset

使用这种方式表达内存地址是因为,单个寄存器不足以表达 1MB 大小的寻址空间,具体可以参考我另一篇文章里的详解部分。

0xffff0距离 BIOS 内存空间结束只有 16 字节,所以 BIOS 启动后第一件事情是向前跳跃并不奇怪,毕竟 16 个字节也做不了什么。

早期的 PC 设计中,BIOS 是硬连线到 0x000f0000-0x000fffff 这段空间的,这确保 BIOS 在每次加电后能够获得 PC 的控制权。

BIOS 的工作是进行一系列的初始化,如设置中断向量表和初始化 VGA 显示器等一系列设备。在初始化完毕后,BIOS 就会开始寻找可启动设备并转交控制权,在本节课中,他寻找到的正是下一小节中的 Boot Loader。

Part 2: The Boot Loader

PC 的软硬盘被分为了 512 字节为单位的区域,这种区域叫做扇区。扇区是磁盘的最小传输粒度,每次读写操作都是一个或多个扇区为界限的。

如果说一个磁盘是可启动的,那他的第一个扇区被称为启动扇区,这里存放了 Boot Loader 的代码。当 BIOS 找到启动扇区后,它会把这 512 字节载入内存的0x7c000x7dff这段空间,并通过jmp设置 CS:IP 为0000:7c00,这样就把 PC 控制权转交给了 Boot Loader。

Why 0x7C00 here?

对于这节课,我们的 Boot Loader 由两部分组成:boot/boot.Sboot/main.c。仔细阅读这两个文件,确保你知道他们在做什么。

  1. 首先,Boot Loader 将处理器从实模式切换到 32 位保护模式,因为我们需要突破 1MB 的寻址空间限制。
  2. 其次,Boot Loader 通过特定的 x86 指令,从硬盘中读取 Kernel 并加载到内存。

在你理解了 Boot Loader 源码之后,看一下obj/boot/boot/asm,这是汇编后的代码,它储存了一些重要的地址信息,方便你进行调试,相似地,obj/kern/kernel.asm储存了 Kernel 相关的信息。

Loading the Kernel

想要搞明白boot/main.c,你得知道什么是 ELF 二进制文件。

如需深入了解,请查阅给出资料。对于本节课程,你可以认为 ELF 文件是如下结构:

  • 一个定长的文件头 ELF header。
  • 一个变长的 Program header table ,指明了所有 Program sections 的长度与要加载到的对应地址。

有一些段是需要我们关注的:

  • .text: 程序的可执行命令
  • .rodata: 只读数据,如字符串常量等
  • .data: 所有初始化的数据,如全局初始化变量(int x = 5)
  • .bss: 储存未初始化全局量(int x),位于.data之后,仅保存大小和地址,由装载器或者程序本身赋值

你可以使用objdump来查看 Kernel 所有段的名字大小和链接地址:

1
2
3
4
5
6
7
8
9
10
11
 ~/6.828/lab │ lab1  objdump -h ./obj/kern/kernel    

./obj/kern/kernel: file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 000019e1 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 000006bc f0101a00 00101a00 00002a00 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
# ..

注意.text段的VMA(链接地址)和LMA(装载地址)时不一样的,装载地址是这个段加载到内存后所在的物理内存地址,链接地址是这个段执行时的期望内存地址。

一般来说,链接地址和装载地址是一样的,比如 Boot Loader 的 ELF 文件中的.text段:

1
2
3
4
5
6
7
8
~/6.828/lab lab1 > objdump -h obj/boot/boot.out

obj/boot/boot.out: file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000018c 00007c00 00007c00 00000074 2**2
CONTENTS, ALLOC, LOAD, CODE

Boot loader 会根据 ELF 中的 Program Headers 来决定如何载入以及载入到何处。

可以通过下面的指令获取 kernel 的 Program Headers Table 的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
~/6.828/lab lab1 > objdump -x obj/kern/kernel

obj/kern/kernel: file format elf32-i386
obj/kern/kernel
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c

Program Header:
LOAD off 0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
filesz 0x00006d1e memsz 0x00006d1e flags r-x
LOAD off 0x00008000 vaddr 0xf0107000 paddr 0x00107000 align 2**12
filesz 0x0000b6c1 memsz 0x0000b6c1 flags rw-
STACK off 0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
filesz 0x00000000 memsz 0x00000000 flags rwx

回到boot/main.c,它做的事情简单来说就是读取 Kernel 到内存,然后转交控制权。

它首先把 Kernel 的前 8 个扇区读取到内存中,这次读取的内容被认为足以包含 ELF 头(注意阅读函数体,虽然这里参数 sector 是 0,但是计算后操作的扇区其实是 1)。

1
2
3
// read 1st page off disk
// 其实到这里为止,page 这个概念还不存在。可能作者只是这么表达,4KB的空间,也就是8个扇区。
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

随后开始读取 ELF header 中的每一个 Program header,并且根据其中信息加载所有的 Segment 到内存中,最后调用 ELF 文件的入口,进入 Kernel。

回看 Kernel 的装载地址和链接地址,不像 Boot Loader, 这两个地址是不一样的:Kernel 告诉 Boot Loader 把它装载到内存的低位地址(0x10000),但是它希望运行在一个高位地址,在下一节中我们将深入讨论。

Part 3: The Kernel

现在我们将开始稍微深入地讨论 JOS kernel。

Using virtual memory to work around position dependence

当你观察 Boot Loader 的链接地址和装载地址时,你会发现它们一模一样,然而对于 Kernel 来说却完全不同。

操作系统更加偏好于被链接和运行到非常高的虚拟地址,比如 0xf0100000,这样是为了腾出低位空间给用户使用。下一次的 Lab 中将会详细说明。

0xf0100000 ? 许多机器都没有这么大的内存,所以我们不可能指望把 Kernel 真的放在这么高的位置。作为替代,我们使用处理器的内存管理单元去映射虚拟地址 0xf0100000 (Kernel 希望运行在的地方) 到物理地址 0x00100000 (Boot Loader 将 Kernel 加载到物理内存中的地址)。这样的话,虽然看样子 Kernel 腾出了足够多的空间给用户使用,但是它仍然处在低位,就正好在 BIOS 的空间上面。

实际上,下一次的 Lab 中我们将映射整整 256MB 内存:

  • 物理地址:0x00000000 through 0x0fffffff
  • 虚拟地址:0xf0000000 through 0xffffffff

这就是为什么 JOS 只能使用 256MB 内存,因为它就映射了这么点。

不过现在,我们只映射了前 4MB 物理内存,这足够我们运行起来 Kernel 了。

我们通过在kern/entrypgdir.c中手动编写页目录和页表实现。现在你不用理解其中的细节,只需要了解它的效果。在kern/entry.S设置CRO_PG标志之前,所有内存引用都会被视作物理地址,设置之后则为虚拟地址,会被硬件翻译为物理地址。

entry_pgdir将:

  • 虚拟地址 0xf0000000 through 0xf0400000 翻译到 物理地址 0x00000000 through 0x00400000
  • 虚拟地址 0x00000000 through 0x00400000 翻译到 物理地址 0x00000000 through 0x00400000

任何不处在上述范围内的虚拟地址将硬件异常,因为我们还没有做中断处理,所以它会直接导致 QEMU 异常并 dump 后退出。

Formatted Printing to the Console

许多人把printf()当作理所当然的事情,甚至认为这是 C 的原语。但是在 Kernel 中,我们必须自己实现所有的 IO。

阅读 kern/printf.c, lib/printfmt.c, and kern/console.c,确保你了解他们之间的关系。在后续的 Lab 中,你会清楚为什么printfmt.c放在单独的lib文件夹。

The Stack

几乎只有题目

Exercises

2

Exercise 2. Use GDB’s si (Step Instruction) command to trace into the ROM BIOS for a few more instructions, and try to guess what it might be doing. You might want to look at Phil Storrs I/O Ports Description, as well as other materials on the 6.828 reference materials page. No need to figure out all the details - just the general idea of what the BIOS is doing first.

参考SeaBIOS 实现简单分析 - gnuemacs - 博客园 (cnblogs.com)

首先,通过ljmp重新设置 CS:IP 寄存器,跳跃到低位空间

1
1 0xffff0: ljmp $0xf000, $0xe05b  

随后的两句指令是 QEMU 用于检查系统是否是被恢复/重启的,详见这个StackOverflow 问答

1
2
2 0xfe05b: cmpl $0x0, $cs:0x6ac8   
3 0xfe062: jne 0xfd2e1

由于这是正常启动,所以接下来会跳转到正常的处理流程。

  1. 设置ss为 0,设置esp为 0x7000(其实就是设置sp为 0x7000,正好是esp的低 16 位)

    这里的设计就像 CS:IP 一样,表明当前栈顶位于 0*16+7000=0x7000

  2. 把要执行的 32 位 C 函数存到edx

  3. 跳转到下一个函数,切换到 32 位保护模式

1
2
3
4
5
4 0xfe066: xor %dx, %dx               
5 0xfe068: mov %dx, %ss
6 0xfe06a: mov $0x7000, %esp
7 0xfe070: mov $0xf34d2,%edx
8 0xfe076: jmp 0xfd15c # 跳转到切换模式的函数

后续的切换过程中,暂时屏蔽了中断

  • cli将 FLAG 寄存器的第 9 位,IF (Interrupt flag),设置为 0,用于屏蔽可屏蔽中断

  • 通过对 0x70 号端口的操作,屏蔽了不可屏蔽中断。。。

    详细说明可以看我之前的英文笔记

1
2
3
4
5
6
7
8
9
9 0xfd15c: mov %eax, %ecx
10 0xfd15f: cli
11 0xfd160: cld
# Here to disable Maskable Hardware interrupts

12 0xfd161: mov $0x8f, %eax
13 0xfd167: out %al, $0x70
14 0xfd169: in $0x71, %al
# And disable Non-maskable Hardware interrupts

接着开启 A20 总线,关于 A20 的介绍可以参考A20:历史的妥协 - BakaFT’s blog

在 BIOS 开启 A20 总线的原因,似乎是为了计算内存大小,并且对内存进行测试

1
2
3
15 0xfd16b: in $0x92, %al
16 0xfd16d: or $0x2, %al
17 0xfd16f: out %al, $0x92

接下来加载中断描述符表 IDT

1
18 0xfd171: lidtw %cs:0x6ab8       

加载全局描述符表 GDT

1
19 0xfd177: lgdtw %cs:0x6a74       

开启保护模式

  • CR0 is a 32-bit control register, whose first bit (bit 0) is the Protection Enable bit.
  • 这里开启保护模式是因为edx中储存的 32 位 C 函数需要运行在保护模式,由于 Boot Loader 还会再开启一次保护模式,所以 SeaBIOS 应该在后续某处关闭了保护模式
1
2
3
20 0xfd17d: mov %cr0, %eax
21 0xfd180: or $0x1, %eax
22 0xfd184: mov %eax, %cr0

后续开始执行 32 位初始化

From x86 Assembly/Global Descriptor Table - Wikibooks, open books for an open world

Note that to complete the process of loading a new GDT, the segment registers need to be reloaded. The CS register must be loaded using a far jump

这是原地 ljmp 的原因

From Jump (jmp, ljmp) (IA-32 Assembly Language Reference Manual) (oracle.com)

In Real Address Mode or Virtual 8086 mode, the long pointer provides 16 bits for the CS register and 16 or 32 bits for the EIP register. This is how jmp and ljmp work.

1
2
3
4
5
6
7
8
23 0xfd187: ljmpl $0x8, $0xfd18f
24 0xfd18f: mov $0x10, %eax
25 0xfd194: mov %eax, %ds
26 0xfd196: mov %eax, %es
27 0xfd198: mov %eax, %ss
28 0xfd19a: mov %eax, %fs
29 0xfd19c: mov %eax, %gs
30 0xfd19e: mov %ecx, %eax

最后跳转到之前储存的函数地址

1
31 0xfd1a0: jmp *%edx

后续将在这个函数内进行 POST 初始化,后续还要初始化中断向量表,初始化设备等,到最后寻找 Boot Loader 并转交控制权。

感觉写的够多了,有兴趣深入可以看看答案开头的参考文章。

3

At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?

boot.S中, the ljmp $PROT_MODE_CSEG, $protcseg导致了模式的切换

What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?

Boot Loader 的最后一句是在main.c中的,这句将控制权转交给了 Kernel

1
((void (*)(void)) (ELFHDR->e_entry))();

Kernel 的第一句是在entry.S中的

1
movw    $0x1234,0x472           # warm boot

Where is the first instruction of the kernel?

这道题的意思是,解引用指针 0x10018,获取入口点真正的地址

1
2
3
4
5
6
7
8
9
10
(gdb) b *0x7d71
Breakpoint 1 at 0x7d71
(gdb) c
Continuing.
The target architecture is set to "i386".
=> 0x7d71: call *0x10018

Breakpoint 1, 0x00007d71 in ?? ()
(gdb) x/1x 0x10018
0x10018: 0x0010000c

How does the boot loader decide how many sectors it must read in order to fetch the entire kernel from disk? Where does it find this information?

Boot Loader 会从 ELF 头中获取所有的 Program Header,每个 Header 都指明了要加载的段,并挨个加载到内存中,等加载完了,内核也就全部载入了。

5

Exercise 5. Trace through the first few instructions of the boot loader again and identify the first instruction that would “break” or otherwise do the wrong thing if you were to get the boot loader’s link address wrong. Then change the link address in boot/Makefrag to something wrong, run make clean, recompile the lab with make, and trace into the boot loader again to see what happens. Don’t forget to change the link address back and make clean again afterward!

这里很诡异,我把 0x7c00 改成 0x8c00,重新 make 之后,好像一切都没改过。

Boot Loader 的代码甚至还在 0x7c00

1
2
3
4
5
6
7
8
9
(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) c
Continuing.
[ 0:7c00] => 0x7c00: cli
Breakpoint 1, 0x00007c00 in ?? ()
(gdb) si
[ 0:7c01] => 0x7c01: cld
0x00007c01 in ?? ()

但是不久之后你会察觉到不对劲,怎么 GDT 内存地址都变负数了?到最后甚至引发了 QEMU 的 Triple fault

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
(gdb) si
[ 0:7c1e] => 0x7c1e: lgdtw -0x739c
0x00007c1e in ?? ()
...
(gdb) si
[ 0:7c2a] => 0x7c2a: mov %eax,%cr0
0x00007c2a in ?? ()
(gdb) si
[ 0:7c2d] => 0x7c2d: ljmp $0x8,$0x8c32
0x00007c2d in ?? ()
(gdb) si
Program received signal SIGTRAP, Trace/breakpoint trap.
[ 0:7c2d] => 0x7c2d: ljmp $0x8,$0x8c32
0x00007c2d in ?? ()

GDT 仍然在原来的 0x7c64,而-0x739c 更是不知道飞到哪里了(应该是溢出了)

1
2
3
4
(gdb)  x/6xb -0x739c
0xffff8c64: 0x04 0x26 0x67 0x66 0x89 0x07
(gdb) x/6xb 0x7c64
0x7c64: 0x17 0x00 0x4c 0x8c 0x00 0x00
Author

BakaFT

Posted on

2022-11-10

Updated on

2023-12-28

Licensed under

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×