Mamba spirit

Gra55

愿背井离乡、追寻梦想的你归来仍是少年

【MIT 6.828】3. PC 启动流程

本节学习 6.828 lab 1,PC 启动的详细流程

gra55

8-Minute Read

lab 1 实验地址:https://pdos.csail.mit.edu/6.828/2018/labs/lab1/

0x00 非官方解读

6.828 实验使用的是 386 CPU,但是 386 CPU 一上电以后,处于 16 位的实模式,与 8086 CPU 很相似,不清楚 8086 的可以学习文章操作系统基础知识概览

CPU 一上电,CS=0xF000、IP=0xFFF0,地址为 0xFFFF0,这个地址刚好在 BIOS ROM 中,所以先执行 BIOS 中的代码。

BIOS 执行流程:

  1. 上电自检(Power On Self Test)
  2. 加载磁盘的第一个可引导扇区,一个扇区大小为 512 字节,如何判断该扇区时可引导的?判断最后两个字节为 0xAA55 则表示该扇区可引导
  3. BIOS 会把该扇区加载到段地址为 0x0000,偏移地址为 0x7c00 内存处
  4. 然后使用跳转指令跳到 CS=0x0000、IP=0x7c00 处开始执行
  5. 至此,BIOS 完成了自己的使命,把执行流交给了 0x7c00 处的代码,所以我们需要做的就是在磁盘的第一个扇区存放我们的代码。

0x01 官方解读(Lab 1: Booting a PC

实验分为三部分:

  • Part 1:主要是熟悉 x86 汇编语言、QEMU x86 仿真器和 PC 的开机引导过程
  • Part 2:主要是学习 6.828 内核的 boot 引导过程,代码的 boot 目录下
  • Part 3:主要学习 6.828 中使用的 JOS 的内核初始化模型,代码在 kern 目录下

下载代码:

athena% mkdir ~/6.828
athena% cd ~/6.828
athena% add git
athena% git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
Cloning into lab...
athena% cd lab
athena% 

Part 1: PC Bootstrap

x86 汇编

本实验使用的是 GNU 使用的 AT&T 语法的汇编语言,但是你如果实习 Intel语法的汇编语言,那更利于你学习本课程。

推荐学习书籍《PC 汇编语言

模拟 x86 运行

我们不需要在真实的机器上运行&调试我们的程序,那样是在太麻烦了。在 6.828 课程中,我们使用 QEMU 模拟器来模拟 x86 操作系统的运行,另外还可以使用 GDB 来调试。

执行一下命令,可以模拟 JOS 系统的启动:

athena% cd lab
athena% 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
athena% make qemu-nox
Booting from Hard Disk...
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>

PC 的物理地址空间

PC 的物理地址空间是 hard-wired 的,如下所示:

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

早期,基于 16 位 Intel 8088 处理器的电脑,最大只能寻址 1M 的物理内存。所以地址空间是从 0x000000000x000FFFFF(20-bit,2^20=1M),而不是 0xFFFFFFFF

标记为 Low Memory 的 640K 内存区域,是早期 PC 可以使用的 RAM;实际上,跟早期的 PC,只能配置 16KB、32KB、64KB 的 RAM。

0x000A00000x000FFFFF 的 384K 内存区域是有硬件为视频显示和外部设备固件保留的。

0x000F00000x000FFFFF 的 64K 内存是为 BIOS(Basic Input/Output System,基本输入输出系统)保留的,这个是非常重要的一部分内存区域。早期的 PC 中,BIOS 保存在 ROM(read-only memory)中,但是现在保存在闪存(译者注:其实也就是内存)中。BIOS 的任务是执行基础的系统初始化工作,例如:激活 video card、检查内存等。初始化检查完成以后,BIOS 会从磁盘、CD-ROM 等位置加载操作系统,然后将机器的控制权交给操作系统。

Intel 最后使用 80286(支持 16M)和 80386(支持 4G)处理器突破了 1M 的内存限制,但是整体 PC 的架构还是保留了低 1M 物理地址空间的原始布局,以确保与现有软件的兼容。所以现代的 PC 中存在一个从 0x000A0000 到 0x00100000 的特殊内存区域,所以 RAM 也被区分为 low memory 和 extended memory,low memory 指的就是“特殊区域”的前 640K 内存,extended memory 指的就是其他内存 RAM 内存区域。此外,32-bit 物理内存空间的最顶部,首先就是物理 RAM,他由 BIOS 保留,专为 32-bit PCI 设备使用。

最新的 x86 处理器已经突破了 4G RAM 内存,因此地址空间可以进一步扩展到 0xFFFFFFFF 以上,因此,和 16-bit 一样,64-bit 处理器也需要为 32-bit 处理器留出一部分“特殊区域”的 RAM,以此来兼容 32-bit 程序。

这里就解答了:为什么很多程序都需要编译成 32 位版本和 64 位版本?

就是因为 32-bit 处理器和 64-bit 处理器的物理内存空间不一样。

在本课程中,为了简单,其实也是 JOS 本身的设计局限性,JOS 只使用 PC 物理内存的前 256M,所以我们假设所有的 PC 都只有 32 位物理地址空间。

ROM BIOS

本节将学习使用 QEMU 的调试工具来研究 IA-32 计算机如何启动

首先,打开两个终端,切换到 lab 目录下,终端 A 输入 make qemu-gdb (或者 make qemu-nox-gdb),此时启动了 QEMU,但是在处理器执行第一条指令之前停止了,此时在等待 GDB 的连接。在终端 B 中,执行 make gdb,将看到下面的输出:

athena% make gdb
GNU gdb (GDB) 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
+ target remote localhost:26000
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0:	ljmp   $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb) 

lab 提供了一个 .gdbinit 文件,该文件设置 GDB 来调试早期的 16-bit 引导程序,并连接侦听 QEMU。(如果不起作用,则需要手动添加 add-auto-load-safe-path.gdbinit 文件中)

[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b 这行是 GDB 对将被执行的指令的反汇编,从这行我们可以得到如下信息:

  • PC 从 0x000ffff0 物理地址开始执行,该地址位于为 BIOS ROM 保留的 64K 地址的最顶端。
  • PC 开始执行 CS=0xf000:IP=0xfff0 位置的代码(不清楚寄存器的可以学习下这篇文章:操作系统基础知识概览
  • 要执行的第一条指令是 jmp 指令,它跳转到段地址为 CS=0xf000:IP=0xe05b

为什么 QEMU 是这样开始的?

因为 8088 处理器就是这样设计的。为什么要这样设计?因为不这样设计的话,机器通电以后,CPU 不知道该执行哪块的指令。所以 PC 中的 BIOS 是 hard-wired(硬连线) 到地址 0x000f0000-0x000fffff 的,所以机器在上电以后,BIOS 会先拿到机器的控制权(因为此时 RAM 中没有处理器可以执行的程序),执行初始化检查,检查完以后,将控制权再交给操作系统。

QEMU 模拟器自带了 BIOS,该 BIOS 被放置在处理器模拟物理地址空间的位置上,当处理器被 reset,模拟的处理器则会进入 real mode(实模式),并且设置 CS=0xf000:IP=0xfff0,所以将从 CS:IP 代码段开始执行。

段地址 CS=0xf000:IP=0xfff0 如何转变为物理地址?

电脑刚通电启动,会进入 real mode,该模式下地址转换通过公式(physical address = 16 * segment + offset)进行。当 CS=0xf000:IP=0xfff0 时,物理地址为:16 * 0xf000 + 0xfff0 = 0xf0000 + 0xfff0 = 0xffff0(2 进制乘以 16 相当于左移四位,16 进制下相当于左移一位)。

0xffff0 是 BIOS(0x100000)内存区域最顶部的 16 个字节。BIOS 做的第一件事就是将 jmp 跳转到 BIOS 中最顶部的位置开始执行指令。

当 BIOS 开始运行时,它会设置一个中断描述符表(interrupt descriptor table),并且初始化各种设备,例如 VGA 等,这时 QEMU 终端会打出 “Starting SeaBIOS” 信息。

初始化 PCI 总线和 BIOS 必须的设备后,将会查找可引导设备,例如软盘、硬盘、CD_ROM 等。最后,BIOS 会引导加载程序(boot loader),并将控制权移交给它。

Part 2: The Boot Loader

PC 的软盘和硬盘被划分为多个 512 字节的区域,该 512 字节区域被称为扇区(sectors)。扇区是磁盘的最小传输单位:每一次读写必须是一个或多个扇区,并且在扇区边界对齐。如果一个磁盘是可引导的,那么它的第一个扇区必须是引导扇区(boot sector),因为这是引导程序代码所在的位置。当 BIOS 找到可引导的软盘或者硬盘时,他将位于第一个扇区的 512-byte 的引导扇区加载到物理地址为 0x7c00-0x7dff 的内存中,然后使用 jmp 指令将 CS:IP 设置为 0000:7c00,将控制权传递给引导加载程序。就像 加载 BIOS 的地址一样,这些地址看起来是任意地址,但是它们对于 PC 而言,是标准化的,也是固定的。

在 PC 的发展过程中,从 CD-ROM 引导的能力出现得晚得多,因此 PC 架构师借此机会稍微重新思考了引导过程。因此,现代的 BIOS 从 CD-ROM 引导的方式更复杂(也更强大)。CD-ROMs 使用 2048 字节的扇区代替 512 字节,这样 BIOS 就可以在控制权移交之前,从磁盘加载更大的引导镜像到内存中。

对于 6.828 实验来说,我们使用传统的硬盘引导机制,这意味着引导加载程序必须容纳在 512 字节以内。引导加载程序(boot loader)包含一个汇编源文件(boot/boot.S)和一个 C 源文件(boot/main.c),可以详细看下这两个文件。引导加载程序(boot loader)必须执行两个 main functions:

  • 首先引导加载程序(boot loader)将处理器从实模式(real mode)切换到 32 位保护模式(32-bit protected mode),因为只有在 32 位保护模式(32-bit protected mode)下,软件才能访问处理器物理地址空间中 1M 以上的所有内存。保护模式(protected mode)在《PC 汇编语言》 PDF 版本的 1.2.7 和 1.2.8 节有详细的介绍。这个阶段,你只需要了解段地址到物理地址的转换在在保护模式(protected mode)下是不同的,转换偏移量(offset)是 32 位,而不是 16 位的。
  • 其次,引导加载程序(boot loader)通过 x86 特殊的 I/O 指令直接访问 IDE 磁盘设备寄存器,来从磁盘读取内核(kernel)。如果想更好地理解此处的特定的 I/O 指令的含义,请参考“IDE硬盘驱动器控制器”部分。本课程中,不需要深入了解特殊设备编程的知识:编写设备驱动程序其实是操作系统开发的一个非常重要的部分,也是最无趣的部分之一。

理解完引导加载程序(boot loader)源码后,接下来看下 obj/boot/boot.asm 文件,这个文件是使用 GNU Makefile 编译引导加载程序(boot loader)生成的引导加载程序(boot loader)的反汇编。这个反汇编文件可以很容易的看到所有引导加载程序(boot loader)的代码在物理内存中的确切位置,并且在 GDB 调试时跟容易的跟踪发生的事情。

可以使用 b 命令在 GDB 中设置地址断点,例如:命令 b *0x7c00 会在地址 0x7c00 处设置一个断点。到达断点后,你可以使用 csi 命令继续执行(c 使 QEMU 继续执行,直到遇到下一个断点或者在 QEMU 终端中按下 Ctrl-c;si N 可以一次执行 N 个指令)。

如果要检查内存中要执行的指令(除了 GDB 自动打印的下一个要执行的指令之外),可以使用 x/i 命令。此命令的语法为 x/Ni ADDR,其中 N 是要反汇编的连续指令数,ADDR 是要开始反汇编的内存地址。

简单画了个图,如下所示:

cpu 上电流程

加载内核

接下来,将根据 boot/main.c 文件来详细学习引导加载程序(boot loader)的 C 语言部分。(需要温习下 C 语言编程指针部分内容)

什么是 ELF 二进制文件?

当编译并链接一个 C 程序(比如 JOS 内核程序)时,编译器会将每个 C 源文件(.c)转换成一个 object 文件(.o),object 文件包含汇编语言指令,这些指令以硬件所期望的二进制格式编码。

然后,链接器将所有编译的 object 文件组合成一个二进制镜像(binary image),例如:obj/kern/kernel,在这里他就是一个 ELF 格式的二进制文件,表示“可执行和可链接格式

我们可以简单的认为,ELF 可执行文件带有加载信息的头,信息头后面紧跟的是多个程序段,每个程序段都是连续的一组代码或者数据,会在指定的地址加载到内存中。引导加载程序(boot loader)不会修改代码或数据,他将其加载到内存中并开始执行。

一个 ELF 二进制文件以一个固定长度的 ELF 头(fixed-length ELF header)开始,然后是一个可变长度的程序头(variable-length program header),程序头列出了要加载的每个程序段。inc/elf.h 文件中定义了这些 ELF 头,我们需要关注的程序段如下所示:

  • .text:程序的可执行指令
  • .rodata:只读数据。比如编译器生成的 ASCII 字符串常量。(不会限制硬件写入)
  • .data:数据部分保存程序的初始化数据,例如使用初始化器声明的全局变量(int x = 5

当链接器开始计算程序的内存分布时,它会为未初始化的全局变量(例如:int x)保留空间,该空间位于名叫 .bss 的段(section)中,它紧跟在 .data 段(section)之后。在 C 语言中,“未初始化”的全局变量默认为 0。因此,不需要将 .bss 的内容存储在 ELF 二进制文件中,只需要链接器记录.bss 段(section)的地址和大小即可。加载程序或者程序自身必须将 .bss 段(section)内容设置为 0。

输入命令 objdump -h obj/boot/boot.out 来查看 ELF 二进制文件内部的结构:

[root@centos ~/6.828/lab]# 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         0000017e  00007c00  00007c00  00000054  2**2
                  CONTENTS, ALLOC, LOAD, CODE
  1 .eh_frame     000000cc  00007d80  00007d80  000001d4  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .stab         000006d8  00000000  00000000  000002a0  2**2
                  CONTENTS, READONLY, DEBUGGING
  3 .stabstr      000007df  00000000  00000000  00000978  2**0
                  CONTENTS, READONLY, DEBUGGING
  4 .comment      00000011  00000000  00000000  00001157  2**0
                  CONTENTS, READONLY

可以看到,这里 dump 出来的内容比我们上面提到的要多,但是其他的段(section)在本课程中不需要过多关注,它们大多数是保存着调试信息,这些信息通常包含在程序的可执行文件中,但是不会由程序加载器加载到内存中。

需要特别关注一下 .text 部分的 “VMA”(链接地址)和 “LMA”(加载地址):

  • 加载地址(LMA)表示 section 内容应该加载到的内存地址
  • 链接地址(VMA)表示 section 希望执行代码的内存地址

链接器(linker)以各种方式在二进制文件中编码链接地址,例如当代码需要一个全局变量的地址,如果一个二进制文件从一个没有被链接的地址开始执行,那么这个二进制文件通常是无法正常工作的。(其实,也可以生成一个位置独立(position-independent)的代码,这类代码不包含绝对地址(absolute addresses)。这种方式在现代的共享库(shared libraries)中广泛使用,但他有性能和复杂性成本,在 6.828 中不使用。)

如上面 terminal 输出所示,一般情况下,链接地址和加载地址相同。

引导加载程序(boot loader)根据 ELF 程序头(program headers)决定如何去加载 sections。程序头(program headers)标记了 ELF 对象的每个部分应该加载到内存中的哪个目标地址。输入命令 objdump -x obj/kern/kernel 可以查看程序头(program headers):

[root@centos ~/6.828/lab]# 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 0x0000ee71 memsz 0x0000ee71 flags r-x
    LOAD off    0x00010000 vaddr 0xf010f000 paddr 0x0010f000 align 2**12
         filesz 0x0000a948 memsz 0x0000a948 flags rw-

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         0000178e  f0100000  00100000  00001000  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       00000704  f01017a0  001017a0  000027a0  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .stab         000044d1  f0101ea4  00101ea4  00002ea4  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .stabstr      00008afc  f0106375  00106375  00007375  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .data         0000a300  f010f000  0010f000  00010000  2**12
                  CONTENTS, ALLOC, LOAD, DATA
  5 .bss          00000648  f0119300  00119300  0001a300  2**5
                  CONTENTS, ALLOC, LOAD, DATA
  6 .comment      00000011  00000000  00000000  0001a948  2**0
                  CONTENTS, READONLY
SYMBOL TABLE:
f0100000 l    d  .text  00000000 .text
f01017a0 l    d  .rodata        00000000 .rodata
f0101ea4 l    d  .stab  00000000 .stab
# 省略......
f0101ea4 g       .stab  00000000 __STAB_BEGIN__
f01011e8 g     F .text  00000016 strlen
f0101315 g     F .text  0000001d strchr
f0100648 g     F .text  000000cb mon_kerninfo
f0100762 g     F .text  00000136 monitor
f0101459 g     F .text  0000001b memfind
f0100713 g     F .text  00000045 mon_help

如上所示,可以在 “Program Header” 关键字下看到程序头(program headers)。需要被加载到内存中的 ELF 对象区域被 “LOAD” 关键字标记。程序头(program headers)中还可以看到其他信息,比如:虚拟地址 vaddr、物理地址 paddr、加载区域的大小 memsz & filesz。

回看 boot/main.c 文件,每个程序头(program headers)的 ph->p_pa 字段包含了段的目标物理地址。(注意:在本例中,它真的是一个物理地址,尽管 ELF 规范中对该字段没有明确说明。)

BIOS 加载 boot sector 到内存中的起始地址为 0x7c00,因此该地址也是 boot sector 的加载地址。这个地址也是 boot sector 开始执行的位置,所以它也是一个链接地址。我们通过在 boot/Makefrag 文件中将 -Ttext 0x7c00 传递给链接器来设置链接地址。这样的话,链接器将在生成的代码中生成正确的内存地址。

回顾下 kernel 的加载地址和链接地址,它们是不一样的,引导加载程序(boot loader)是一样的(00007c00)。kernel 告诉引导加载程序(boot loader)以较低的地址(1M 字节)将其加载到内存中,但是 kernel 想以更高的地址来执行代码。下一节中重点讨论这部分的实现。

除了 section 信息之外,ELF 头中还有一个对我们很重要的字段,名为e_entry。该字段保存程序入口点(entry point)的链接地址:程序的 text 段(section)的内存地址,就是程序开始执行的位置。命令 objdump -f obj/kern/kernel 可以看到程序入口:

[root@centos ~/6.828/lab]# objdump -f obj/kern/kernel
obj/kern/kernel:     file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c

至此,我们应该了解了 boot/main.c 中的小型 ELF 加载器的大致功能:它会从磁盘读取 kernel 中的每个 section,然后加载到对应 section 加载地址所在的内存区域中,然后跳转到 kernel 程序入口(entry point)处开始执行。

Part 3: The Kernel

本节详细学习 JOS 的内核。

虚拟内存

使用虚拟内存解决位置依赖问题

如上文所说,引导加载程序(boot loader)的链接地址和加载地址是一样的,但是 kernel 的两个地址相差很大(加载地址是低地址内存区域,链接地址是高地址内存区域)。链接 kernel 比引导加载程序(boot loader)更复杂,因此加载地址和链接地址位于 kern/kernel.ld 的最顶部。

操作系统内核通常被链接和运行在一个非常高的虚拟地址(virtual address),比如 0xf0100000,以便把处理器的虚拟地址空间较低一部分区域留给用户进程使用。这么做的原因会在下一个 lab 中详细讨论。

很多机器在地址 0xf0100000 处已经没有物理内存,因此我们不能指望在那个地址存储内核。所以,我们会使用处理器的内存管理硬件(processor's memory management hardware)将虚拟地址 0xf0100000(内核代码预期运行的链接地址)映射到物理地址 0x00100000(引导加载程序(boot loader)将内核加载到的物理内存地址)。尽管内核的虚拟地址足够高,可以为用户进程留下大量地址空间,但是它还是会被加载到 0x00100000 这个物理内存上(译者注:32 位预留的 RAM,最底下 640K 是 16 位预留的 RAM),这个位置刚好位于 BIOS ROM 的上面。这种方法要求 PC 至少有几兆的物理内存(大于 1M 内存,0x100000 内核代码才能正常运行), 1990 年以前的 PC 估计不行。

下一个实验,我们会映射 PC 物理地址空间底部的 256M 空间,从物理地址 0x00000000-0x0fffffff(256M)到虚拟地址 0xf0000000-0xffffffff。现在我们就明白了,为什么 JOS 只能使用前 256M 的物理内存!

现在我们只映射前 4M 物理内存,这些内存足够我们启动和运行。我们在 kern/entrypgdir.c 文件中手动修改代码,来静态初始化页目录(page directory)和页表(page table)。现在不用了解它的工作机制,只需要了解它的效果就行。在 kern/entry.S 文件设置 CR0_PG 标志之前,内存引用被视为物理地址(严格来说,是线性地址(linear addresses),但是 boot/boot.S 设置了一个线性地址到物理地址的映射)。一旦 CR0_PG 标志被设置,内存引用就是虚拟地址了,该虚拟地址是由虚拟内存硬件(virtual memory hardware)从物理地址转换而来。entry_pgdir 将虚拟地址(0xf0000000-0xf0400000)转换到物理地址(0x00000000-0x00400000),并将虚拟地址(0x00000000-0x00400000)转换为物理地址(0x00000000-0x00400000)。任何不在这两个范围内的虚拟地址都将导致硬件异常,因为我们还没有设置中断处理,这会导致 QEMU 退出并 dumo 机器状态信息。(如果没有使用 6.828 补丁版本的 QEMU,则将无休止地重启)

格式化控制台输出

大多数人认为直接使用 printf() 函数就行了,可能还认为它是 C 语言的 “primitives”(原语),但是在操作系统内核中,我们必须自己实现所有 I/O。

大家需要学习一下 kern/printf.clib/printfmt.ckern/console.c 这三个文件,搞清楚他们之间的关联。后面自然就知道为什么 printfmt.c 文件要单独放在 lib 目录下?

栈(Stack)

栈是先进后出的数据结构,内存中栈顶在低地址、栈底在高地址。可以想象成一个帽子🎩

本实验的最后,我们学习 C 语言在 x86 上使用栈的方式,并在此过程中,编写一个新的内核监控函数,用于打印栈的 backtrace:也就是从嵌套的 call 指令所保存的指令指针到当前执行指针的列表信息。

       # High Address
       +------------+   |
       |    arg 2   |   \
       +------------+    >- 前一个函数的栈帧(stack frame)
       |    arg 1   |   /
       +------------+   |
       |  ret %eip  |   /
       +============+   
       | saved %ebp |   \
%ebp-> +------------+   |
       |            |   |
       |   local    |   \
       | variables, |    >- 当前函数的栈帧(stack frame)
       |    etc.    |   /
       |            |   |
       |            |   |
%esp-> +------------+   /
       # Low Address

x86 栈指针(esp 寄存器,extended stack pointer)指向栈上当前正在使用的最低位置(栈顶位置指针)。在为栈保留的区域中,该位置下方的所有内容都是空的(free)。push 一个值入栈时,栈指针(esp)需要减小(也就是向下移动),然后将值写入到栈指针(esp)指向的内存区域。相反的,pop 一个值出栈时,会先读取栈指针(esp)指向的内存值,然后栈指针(esp)增加(向上移动)。在 32-bit 模式下,栈只能保存 32 位的值,esp 寄存器事中可以被 4 整除。各种 x86 指令都是“硬连线”(hard-wired)来使用栈指针寄存器(esp)的。

ebp 寄存器(栈基址寄存器,extended base pointer,指向系统栈最上面一个栈帧「最新执行的一个函数」的底部)主要通过软件约定来与栈关联在一起的。进入一个 C 函数时,函数的底层代码通常会将上一个函数的基指针 push 到栈中,然后在函数操作期间将当前 esp 值复制给 ebpebp 不是必须的,主要是为了方便操作局部变量)。如果程序中的所有函数都遵守此约定,那么在程序执行中的任何 point,都可以通过栈去追溯:通过保存的 ebp 指针的链路,确定函数通过怎样的调用嵌套顺序来到达这个 point。这个功能还是特别有用的,例如:当某个特定函数由于传递了错误参数导致 assert 失败或 panic,但是不确定是谁传递了错误参数时,栈的 backtrace 可以轻松找到有问题的函数。

0xff 参考

MIT 6.828 参考资料

Recent Posts

Categories

About

Ordinary but not mediocre, fighting