MIT 6.828 实验记录
大二学习操作系统的时候,老师给我们介绍了MIT6.828的实验,由于课程安排的原因,我们并没有完成MIT6.828太多的实验,记忆中应该是只看了和xv6相关的内容辅助理解操作系统,以至于我现在几乎忘记了当时做了哪些实验。趁大三上课不是很多,想重新自己完整完成这7个实验。
Part 0: 6.828 实验环境的搭建
- 虚拟机环境:Ubuntu 18.04(64位)
- 仿真器(qemu):
git clone https://github.com/mit-pdos/6.828-qemu.git qemu
- 实验代码(lab):
git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
()
虚拟机环境32位
,因为JOS
就是32位的操作系统。
仿真器使用MIT进行patched过的(见上链接)。原因是实验中分页机制是有意修改过的,使用patched version
的话在后面Exercise
中不需要手动转换地址。
关于实验代码,默认熟悉Git
和MakeFile
。每做完一个Exercise
可以使用make grade
进行测试。
./configure
时候可能会出现库缺失导致无法完成配置,可以根据报错提示将缺失的库重新安装补全,Google一下。详细的搭建过程见Tools Guide。
关于Tool Guide中给出的配置指令,如果[--prefix=PFX]
参数没有指定的话,默认会安装在/usr/local/share/qemu
中,这个目录需要管理员权限才能修改,所以安装时需要使用sudo make install
Part 1: PC Bootstrap
如果您还不熟悉 x86 汇编语言,那么在本课程中您将很快熟悉它!PC 汇编语言手册是一个很好的起点。希望这本书包含新旧材料的混合供你参考。
*警告:*不幸的是,书中的例子是为 NASM 汇编器编写的,而我们将使用 GNU 汇编器。NASM 使用所谓的 Intel 语法,而 GNU 使用 AT&T 语法。虽然在语义上是等效的,但程序集文件将有很大差异,至少在表面上是这样,具体取决于使用的语法。幸运的是,两者之间的转换非常简单,Brennan’s Guide to Inline Assembly 中对此进行了介绍。
Exercise 1. Familiarize yourself with the assembly language materials available on the 6.828 reference page. You don’t have to read them now, but you’ll almost certainly want to refer to some of this material when reading and writing x86 assembly.
We do recommend reading the section “The Syntax” in Brennan’s Guide to Inline Assembly. It gives a good (and quite brief) description of the AT&T assembly syntax we’ll be using with the GNU assembler in JOS.
Simulating the x86
我们不是在真实的物理个人计算机 (PC) 上开发操作系统,而是使用忠实模拟完整 PC 的程序:您为仿真器编写的代码也可以在真实 PC 上启动。使用仿真器可以简化调试;例如,您可以在模拟的 x86 中设置断点,这对于 x86 的 Silicon 版本来说很难做到。
在 6.828 中,我们将使用 QEMU Emulator,这是一种现代且相对较快的仿真器。虽然 QEMU 的内置监视器仅提供有限的调试支持,但 QEMU 可以充当 GNU 调试器 (GDB) 的远程调试目标,我们将在本实验中使用它来逐步完成早期启动过程。
接下来我们就可以编译并尝试在QEMU上运行JOS了,进入之前clone的lab文件夹,执行make指令,可以看到下面的输出
tommygong@TommyGong:~/lab$ make |
这就表示已经成功编译出了镜像文件。
现在可以运行qemu,将上面创建的obj/kern/kernel.img
作为模拟PC的“虚拟硬盘”的内容提供。这个硬盘映像包含我们的引导加载程序 ( obj/boot/boot
) 和内核 ( obj/kernel
)。
tommygong@TommyGong:~/lab$ make qemu |
要退出QEMU,请键入 Ctrl+a x
The PC’s Physical Address Space
PC 的物理地址空间是硬连线的,具有以下常规布局:
+------------------+ <- 0xFFFFFFFF (4GB) |
第一台基于 16 位 Intel 8088 处理器的 PC 只能寻址 1MB 的物理内存。因此,早期 PC 的物理地址空间将从 0x00000000 开始,但以 0x000FFFFF 结束,而不是 0xFFFFFFFF。标记为“Low Memory”的 640KB 区域是早期 PC 唯一可以使用的随机存取存储器 (RAM);事实上,最早的 PC 只能配置 16KB、32KB 或 64KB 的 RAM!
从 0x000A0000 到 0x000FFFFF 的 384KB 区域由硬件保留用于特殊用途,例如视频显示缓冲区和非易失性存储器中保存的固件。此保留区域最重要的部分是基本输入/输出系统 (BIOS),它占据了从 0x000F0000 到 0x000FFFFF 的 64KB 区域。在早期的 PC 中,BIOS 保存在真正的只读存储器 (ROM) 中,但当前的 PC 将 BIOS 存储在可更新的闪存中。BIOS 负责执行基本的系统初始化,例如激活视频卡和检查安装的内存量。执行此初始化后,BIOS 会从某个适当的位置(如软盘、硬盘、CD-ROM 或网络)加载操作系统,并将计算机的控制权传递给操作系统。
当英特尔最终用分别支持 16MB 和 4GB 物理地址空间的 80286 和 80386 处理器“打破 1MB 的障碍”时,PC 架构师仍然保留了 1MB 物理地址空间的原始布局,以确保与现有软件的向后兼容性。因此,现代 PC 在物理内存上有一个从 0x000A0000 到 0x00100000 的“漏洞”,将 RAM 分为“低内存”或“传统内存”(前 640KB)和“扩展内存”(其他所有内存)。此外,PC 的 32 位物理地址空间最顶部的一些空间(尤其是物理 RAM)现在通常由 BIOS 保留供 32 位 PCI 设备使用。
最新的 x86 处理器可以支持超过 4GB 的物理 RAM,因此 RAM 可以进一步扩展到 0xFFFFFFFF 以上。在这种情况下,BIOS 必须在系统 RAM 的 32 位可寻址区域顶部留出第二个孔,以便为这些 32 位设备留出空间进行映射。由于设计限制,JOS 无论如何都会只使用 PC 物理内存的前 256MB,所以现在我们假设所有 PC 都“只有”一个 32 位的物理地址空间。但是,处理复杂的物理地址空间和多年来发展起来的硬件组织的其他方面是操作系统开发的重要实际挑战之一。
The ROM BIOS
打开两个终端窗口和 cd 两个 shell 进入您的实验室目录。在一个版本中,输入 make qemu-gdb
。这将启动 QEMU,但 QEMU 在处理器执行第一条指令并等待来自 GDB 的调试连接之前停止。在第二个终端中,从您运行的同一目录中运行 make gdb
。然后应该就能看到下面的内容
tommygong@TommyGong:~/lab$ make gdb |
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
是GDB对QEMU执行的第一条指令的反汇编。
-
IBM PC 从物理地址
0x000ffff0
开始执行,该地址位于为 ROM BIOS 保留的 64KB 区域的最顶部。 -
PC 以
CS(Code Segment) = 0xf000
和IP(Instruction Pointer) = 0xfff0
开始执行。 -
要执行的第一条指令是一条
jmp
指令,该指令跳转到分段地址CS = 0xf000
和IP = 0xe05b
。
这条指令 0xffff0: ljmp $0xf000,$0xe05b
是一个远跳转(ljmp
)指令,它用于将程序的执行流跳转到特定的段和偏移地址。
0xffff0
: 这是指令在内存中的地址,意味着当前指令位于内存的 0xFFFF0
地址处。ljmp $0xf000, $0xe05b
: 这是一条远跳转指令(long jump,简称 ljmp
),它包含两个部分:
- 段选择符
$0xf000
: 段寄存器的值,即代码段的基地址。 - 偏移地址
$0xe05b
: 相对于段基地址的偏移量。
在 x86 保护模式之前的实模式下,内存地址是通过段和偏移组合的形式访问的:
物理地址 = 段选择符 × 16 + 偏移地址
因此,执行这条指令后,CPU 会跳转到段 $0xf000
和偏移 $0xe05b
组合形成的物理地址:
- 物理地址 =
0xf000 * 16 + 0xe05b
- 物理地址 =
0xf0000 + 0xe05b = 0xfe05b
这种指令通常出现在系统启动时(例如,BIOS 启动阶段)。当 CPU 加电或者复位时,它会从 0xFFFF0
这个地址开始执行,通常这是一条跳转指令,将 CPU 引导到系统 BIOS 的实际启动代码处。
实模式 (Real Mode)
实模式是x86处理器上电或重置后默认的工作模式,最早用于8086处理器,并且向后兼容现代处理器。
- 内存寻址:处理器只能访问 1MB 的内存空间。这是由于实模式只能使用20位地址(段寄存器16位+偏移量16位,实际结果为20位地址线)。
- 段寄存器:内存寻址采用分段机制,内存地址是通过段寄存器和偏移量相加来计算的。例如,物理地址 =
段基址 × 16 + 偏移量
。 - 没有内存保护:在实模式下,程序可以直接访问任何内存地址,导致多个程序之间可能会互相覆盖内存,容易出现系统崩溃。
- 多任务处理:没有内建的硬件支持多任务处理,处理器无法有效地管理多个程序的并行执行。
- 应用:实模式主要用于早期的操作系统(如DOS),以及一些简单的嵌入式系统。
保护模式 (Protected Mode)
保护模式是现代x86处理器的主要工作模式,最早引入于80286处理器,后来在80386及以后的处理器中得到了大幅改进。
- 内存寻址:使用32位地址总线,最多可以寻址 4GB 的内存。并且支持更复杂的内存管理机制,如分页(Paging)和虚拟内存(Virtual Memory)。
- 段管理:保护模式中的段寄存器不再简单地提供段基址,而是与**全局描述符表(GDT)和局部描述符表(LDT)**关联,提供段保护。每个段都有自己的权限、大小等信息。
- 内存保护:每个程序运行在自己的地址空间内,处理器能够检测非法的内存访问。通过段和分页机制,操作系统可以防止不同程序互相干扰,增强系统的稳定性和安全性。
- 多任务处理:硬件支持多任务处理,处理器能够通过任务状态段(TSS)快速切换任务。内存保护机制使得每个任务在自己的地址空间中运行,确保系统的稳定性。
- 虚拟内存:保护模式支持虚拟内存,通过分页机制将虚拟地址映射到物理地址,允许程序使用比实际物理内存更大的地址空间。
- 应用:所有现代操作系统(如Windows、Linux、macOS)都运行在保护模式下。
实模式和保护模式的对比
特性 | 实模式 | 保护模式 |
---|---|---|
内存寻址 | 最大 1MB | 最大 4GB(支持分页) |
段寄存器 | 简单的段+偏移 | 与 GDT/LDT 关联,支持权限 |
内存保护 | 无内存保护 | 内存保护,防止进程冲突 |
多任务处理 | 不支持 | 支持,硬件层面支持 |
虚拟内存 | 不支持 | 支持(通过分页实现) |
应用场景 | 早期操作系统、嵌入式系统 | 现代操作系统和应用 |
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.
当 BIOS 运行时,它会设置中断描述符表并初始化各种设备,例如 VGA 显示器。这就是您在 QEMU 窗口中看到的 “ Starting SeaBIOS
” 消息的来源。
在初始化 PCI 总线和 BIOS 知道的所有重要设备后,它会搜索可启动设备,例如软盘、硬盘驱动器或 CD-ROM。最终,当它找到可启动磁盘时,BIOS 会从磁盘中读取 boot loader 并将控制权转移给它。
Part 2: The Boot Loader
用于 PC 的软盘和硬盘被划分为 512 字节的区域,称为扇区。扇区是磁盘的最小传输粒度:每个读取或写入操作的大小必须是一个或多个扇区,并在扇区边界上对齐。如果磁盘是可引导的,则第一个扇区称为引导扇区,因为这是引导加载程序代码所在的位置。当 BIOS 找到可启动的软盘或硬盘时,它会将 512 字节的引导扇区加载到物理地址的内存中,0x7c00 到 0x7dff,然后使用 jmp
指令将 CS:IP 设置为 0000:7c00
,将控制权传递给引导加载程序。与 BIOS 加载地址一样,这些地址相当任意 - 但它们对于 PC 来说是固定和标准化的。
在 PC 的发展过程中,从 CD-ROM 启动的能力出现得要晚得多,因此 PC 架构师借此机会稍微重新考虑了启动过程。因此,现代 BIOS 从 CD-ROM 启动的方式稍微复杂一些(也更强大)。CD-ROM 使用的扇区大小为 2048 字节而不是 512 字节,并且 BIOS 可以在将控制权转移到磁盘之前将更大的引导映像从磁盘加载到内存中(而不仅仅是一个扇区)。有关更多信息,请参见“El Torito”可启动 CD-ROM 格式规范。
然而,对于 6.828,我们将使用传统的硬盘驱动器启动机制,这意味着我们的启动加载程序必须适合区区 512 字节。引导加载程序由一个汇编语言源文件 boot/boot.S
和一个 C 源文件组成, boot/main.c
请仔细查看这些源文件,并确保您了解发生了什么。引导加载程序必须执行两个主要功能:
- 首先,boot loader 将处理器从实模式切换到 32 位保护模式,因为只有在这种模式下,软件才能访问处理器物理地址空间中 1MB 以上的所有内存。保护模式在 PC 汇编语言的 1.2.7 和 1.2.8 节中简要描述,在 Intel 架构手册中也有非常详细的描述。此时,您只需要了解分段地址 (segment:offset pairs) 到物理地址的转换在保护模式下的发生方式不同,并且在转换后偏移量是 32 位而不是 16 位。
- 其次,引导加载程序通过 x86 的特殊 I/O 指令直接访问 IDE 磁盘设备寄存器,从硬盘读取内核。如果您想更好地理解此处的特定 I/O 指令的含义,请查看 6.828 参考页面上的“IDE 硬盘驱动器控制器”部分。在本课程中,您不需要学习太多有关特定设备编程的知识:编写设备驱动程序实际上是操作系统开发中非常重要的部分,但从概念或体系结构的角度来看,它也是最不有趣的部分之一。
了解引导加载程序源代码后,请查看文件 obj/boot/boot.asm
.这个文件是我们的 GNUmakefile 在编译 boot loader 后创建的 boot loader 的反汇编。这个反汇编文件可以很容易地看到所有 boot loader 代码在物理内存中的确切位置,并且更容易跟踪在 GDB 中单步执行 boot loader 时发生的情况。同样, obj/kern/kernel.asm
包含 JOS 内核的反汇编,这通常对调试很有用。
您可以使用该 b
命令在 GDB 中设置地址断点。例如, b *0x7c00
在地址 0x7C00
处设置断点。到达断点后,您可以使用 c and si 命令继续执行: c 使 QEMU 继续执行,直到下一个断点(或直到您按下 Ctrl-C
),并 si N
一次单步执行 N
指令。
要检查内存中的指令(除了 GDB 自动打印的下一个要执行的指令),请使用命令 x/i
。此命令的语法 x/Ni ADDR
为 ,其中 N 是要反汇编的连续指令数,ADDR 是开始反汇编的内存地址。
Exercise 3. Take a look at the lab tools guide, especially the section on GDB commands. Even if you’re familiar with GDB, this includes some esoteric GDB commands that are useful for OS work.
Set a breakpoint at address 0x7c00, which is where the boot sector will be loaded. Continue execution until that breakpoint. Trace through the code inboot/boot.S
, using the source code and the disassembly fileobj/boot/boot.asm
to keep track of where you are. Also use thex/i
command in GDB to disassemble sequences of instructions in the boot loader, and compare the original boot loader source code with both the disassembly inobj/boot/boot.asm
and GDB.
Trace intobootmain()
inboot/main.c
, and then intoreadsect()
. Identify the exact assembly instructions that correspond to each of the statements inreadsect()
. Trace through the rest ofreadsect()
and back out intobootmain()
, and identify the begin and end of thefor
loop that reads the remaining sectors of the kernel from the disk. Find out what code will run when the loop is finished, set a breakpoint there, and continue to that breakpoint. Then step through the remainder of the boot loader.
Quesiton:
-
At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
7c1e: 0f 01 16 lgdtl (%esi)
7c21: 64 7c 0f fs jl 7c33 <protcseg+0x1>
movl %cr0, %eax
7c24: 20 c0 and %al,%al
orl $CR0_PE_ON, %eax
7c26: 66 83 c8 01 or $0x1,%ax
movl %eax, %cr0
7c2a: 0f 22 c0 mov %eax,%cr0
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
7c2d: ea .byte 0xea
7c2e: 32 7c 08 00 xor 0x0(%eax,%ecx,1),%bh在这里,引导程序从实模式切换到保护模式,支持更大的内存访问,在GDT(Global Descriptor Table)加载完成之后,处理器就可以开始处理32位指令了,
-
What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?
-
Where is the first instruction of the kernel?
-
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?
Loading the Kernel
现在,我们将更详细地查看引导加载程序的 C 语言部分。 boot/main.c
但在此之前,现在是停下来回顾一下 C 编程的一些基础知识的好时机。
Exercise 4. Read about programming with pointers in C. The best reference for the C language is The C Programming Language by Brian Kernighan and Dennis Ritchie (known as ‘K&R’). We recommend that students purchase this book (here is an Amazon Link) or find one of MIT’s 7 copies.
Read 5.1 (Pointers and Addresses) through 5.5 (Character Pointers and Functions) in K&R. Then download the code for pointers.c, run it, and make sure you understand where all of the printed values come from. In particular, make sure you understand where the pointer addresses in printed lines 1 and 6 come from, how all the values in printed lines 2 through 4 get there, and why the values printed in line 5 are seemingly corrupted.
There are other references on pointers in C (e.g., A tutorial by Ted Jensen that cites K&R heavily), though not as strongly recommended.
Warning: Unless you are already thoroughly versed in C, do not skip or even skim this reading exercise. If you do not really understand pointers in C, you will suffer untold pain and misery in subsequent labs, and then eventually come to understand them the hard way. Trust us; you don’t want to find out what “the hard way” is.
要弄清楚这一点, boot/main.c
您需要知道什么是 ELF 二进制文件。编译和链接 C 程序(如 JOS 内核)时,编译器会将每个 C 源 (’ .c
‘) 文件转换为一个对象 (’ .o
') 文件,其中包含以硬件所需的二进制格式编码的汇编语言指令。然后,链接器将所有已编译的目标文件组合成一个二进制映像,例如 obj/kern/kernel
,在本例中是 ELF 格式的二进制文件,代表“可执行和可链接格式”。
有关此格式的完整信息可在我们的参考页面上的 ELF 规范中找到,但您无需在本课程中深入研究此格式的详细信息。尽管整体格式非常强大和复杂,但大多数复杂的部分都是为了支持共享库的动态加载,我们不会在本课程中这样做。维基百科页面有一个简短的描述。
对于 6.828,您可以将 ELF 可执行文件视为具有加载信息的标头,后跟几个程序部分,每个部分都是要加载到指定地址的内存中的连续代码块或数据。引导加载程序不会修改代码或数据;它会将其加载到内存中并开始执行它。
ELF 二进制文件以固定长度的 ELF 标头开头,后跟一个可变长度的程序标头,其中列出了要加载的每个程序部分。这些 ELF 标头的 C 定义位于 inc/elf.h
中。我们感兴趣的节目部分是:
.text
:程序的可执行指令。.rodata
:只读数据,例如 C 编译器生成的 ASCII 字符串常量。(但是,我们不会费心设置硬件来禁止写入。).data
:data 部分保存程序的初始化数据,例如使用初始化器声明的全局变量,如int x = 5;
.
当链接器计算程序的内存布局时,它会为未初始化的全局变量保留空间,例如 int x;
,在内存中紧随其后的 .data
名为 section .bss
called 中。C 要求“未初始化”的全局变量以零值开头。因此,无需在 ELF 二进制文件中存储内容 .bss
;相反,链接器仅记录 .bss
节的地址和大小。加载器或程序本身必须将 .bss
部分归零。
通过键入以下内容,检查内核可执行文件中所有部分的名称、大小和链接地址的完整列表:
tommygong@TommyGong:~/lab$ objdump -h obj/kern/kernel |
这些信息通常包含在程序的可执行文件中,但不会由程序加载器加载到内存中。
请特别注意该 .text
部分的 “VMA” (或链接地址) 和 “LMA” (或加载地址)。节的加载地址是该节应加载到内存中的内存地址。
节的 link address 是 section 预期执行的内存地址。链接器以各种方式对二进制文件中的链接地址进行编码,例如,当代码需要全局变量的地址时,结果是如果二进制文件从未链接的地址执行,则二进制文件通常不起作用。(可以生成不包含任何此类绝对地址的与位置无关的代码。这被现代共享库广泛使用,但它有性能和复杂性成本,因此我们不会在 6.828 中使用它。
通常,链路地址和加载地址相同。例如,查看 boot loader .text
的部分:
tommygong@TommyGong:~/lab$ objdump -h obj/boot/boot.out |
引导加载程序使用 ELF 程序头文件来决定如何加载这些部分。程序头文件指定要加载到内存中的 ELF 对象的哪些部分,以及每个部分应占用的目标地址。您可以通过键入以下内容来检查程序头文件:
tommygong@TommyGong:~/lab$ objdump -x obj/kern/kernel |
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!