MIT6.828_Lab1

Part 1: PC Bootstrap

第一个练习的目的。它旨在向你介绍 x86 汇编语言、PC 引导过程,并让你开始使用 QEMU 和 QEMU/GDB 进行调试。在这部分实验中,你不需要编写任何代码,但你应该仔细阅读并理解其中的内容,并准备回答下面提出的问题,以便加深对相关主题的理解。

Getting Started with x86 assembly

在课程中将会迅速熟悉 x86 汇编语言。《PC Assembly Language Book》是一个很好的起点。希望这本书对你来说既有新的内容也包含一些旧的知识。

需要注意的是,该书中的示例是为 NASM 汇编器编写的,而我们将使用 GNU 汇编器。NASM 使用所谓的 Intel 语法,而 GNU 使用 AT&T 语法。虽然在语义上是等效的,但至少在表面上,根据使用的语法不同,汇编文件会有相当大的差异。幸运的是,两者之间的转换相当简单,并且在 Brennan's Guide to Inline Assembly 中有所涉及。

Exercise 1

熟悉 6.828 参考页面提供的汇编语言资料。现在你不必全部阅读,但在阅读和编写 x86 汇编时,几乎肯定会需要参考其中的一些资料。

Brennan's Guide to Inline Assembl

我们建议阅读Brennan的内联汇编指南中的"The Syntax"部分。它对我们在JOS中使用的GNU汇编器的AT&T汇编语法提供了一个很好(而且相当简洁)的描述。

当然,x86汇编语言编程的权威参考资料是英特尔的指令集架构参考手册,你可以在6.828参考页面上找到两种版本:一种是旧的80386程序员参考手册的HTML版本,比较简短且易于浏览,描述了我们在6.828课程中将使用的所有x86处理器功能;另一种是英特尔最新的IA-32英特尔体系结构软件开发手册,涵盖了我们在课堂上不需要的最新处理器的所有功能,但你可能有兴趣了解。同样,AMD也提供了类似的(并且通常更友好的)手册。暂时将英特尔/AMD体系结构手册留待以后,或者当你想查找特定处理器功能或指令的准确解释时,可以作为参考。

Simulating the x86

在6.828课程中,我们使用一个模拟完整PC的程序,而不是在实际的物理个人电脑(PC)上开发操作系统:你为模拟器编写的代码也可以在实际PC上启动。使用模拟器简化了调试过程;例如,你可以在模拟的x86内部设置断点,而在真正的x86芯片版本中这很难做到。

在6.828中,我们将使用QEMU模拟器,这是一个现代化且相对快速的模拟器。尽管QEMU的内置监视器仅提供有限的调试支持,但QEMU可以充当GNU调试器(GDB)的远程调试目标,在本实验中我们将使用它来逐步执行早期的启动过程。

要开始实验,请按照“软件设置”中的说明将Lab 1文件提取到Athena上你自己的目录中,然后在实验目录中键入make(或在BSD系统上键入gmake)以构建你将使用的最小化6.828引导加载程序和内核。(称我们在这里运行的代码为“内核”有点慷慨,但我们将在整个学期中逐步完善它。)

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

(如果出现“undefined reference to `__udivdi3'”等错误消息,则可能是因为您没有安装32位的gcc multilib。如果您使用的是Debian或Ubuntu,请尝试安装gcc-multilib软件包。)

现在您已经准备好运行QEMU了,将上面创建的文件obj/kern/kernel.img作为模拟PC的“虚拟硬盘”的内容。这个硬盘镜像包含我们的引导加载程序(obj/boot/boot)和内核(obj/kernel)。

在Athena命令行中键入:

make qemu

或者

make qemu-nox

这将使用必要的选项执行QEMU,并设置硬盘,并将直接的串行端口输出到终端。在QEMU窗口中应该会出现一些文本:

vbnetCopy code
Booting from Hard Disk...
6828 decimal is XXX octal!
entering test_backtrace 5
...
leaving test_backtrace 5
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>

“Booting from Hard Disk...”之后的所有文本都是我们基本的JOS内核打印的;而“K>”是我们内核中包含的小型监视器或交互式控制程序打印的提示符。如果您使用了**make qemu,内核打印的这些行将同时出现在您运行QEMU的常规终端窗口和QEMU显示窗口中。这是因为为了测试和实验评分目的,我们设置了JOS内核将其控制台输出写入模拟PC的虚拟VGA显示器(在QEMU窗口中可见),同时也写入模拟PC的虚拟串行端口,而QEMU将其输出到自己的标准输出。同样,JOS内核将从键盘和串行端口接收输入,因此您可以在VGA显示窗口或运行QEMU的终端中给它发送命令。或者,您可以使用没有虚拟VGA的串行控制台,方法是运行make qemu-nox**。如果您通过SSH连接到Athena的拨号终端,这可能更方便。要退出QEMU,请键入Ctrl+a x。

内核监视器只能接受两个命令,help和kerninfo。

shellCopy code
K> help
help - 显示命令列表
kerninfo - 显示有关内核的信息
K> kerninfo
特殊的内核符号:
  entry  f010000c(虚拟)  0010000c(物理)
  etext  f0101a75(虚拟)  00101a75(物理)
  edata  f0112300(虚拟)  00112300(物理)
  end    f0112960(虚拟)  00112960(物理)
内核可执行内存占用:75KB
K>

help命令很明显,我们稍后会讨论kerninfo命令的含义。虽然简单,但重要的是要注意,这个内核监视器是直接运行在模拟PC的“原始(虚拟)硬件”上的。这意味着您应该能够将obj/kern/kernel.img的内容复制到真实硬盘的前几个扇区中,将该硬盘插入真实PC中,然后打开它,就可以在PC的真实屏幕上看到与上面在QEMU窗口中看到的完全相同的东西。(尽管如此,我们不建议您在具有重要信息的实际硬盘上这样做,因为将kernel.img复制到硬盘的开头将破坏主引导记录和第一个分区的开头,从而有效地使硬盘上之前的所有内容丢失!)

The PC's Physical Address Space

现在我们将更详细地了解PC的启动过程。PC的物理地址空间硬件布局通常如下:

+------------------+  <- 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基于16位Intel 8088处理器,寻址内存大小主要由其地址总线宽度决定,Intel 8088处理器的地址总线宽度为20位,只能寻址1MB的物理内存。因此,早期PC的物理地址空间从0x00000000开始,但结束地址为0x000FFFFF,而不是0xFFFFFFFF。

标记为“低内存”的640KB区域是早期PC可用的唯一随机存取内存(RAM)。事实上,最早期的PC只能配置16KB、32KB或64KB的RAM!

从0x000A0000到0x000FFFFF的384KB区域由硬件保留,用于视频显示缓冲区和存储在非易失性存储器中的固件等特殊用途。这个保留区域中最重要的部分是基本输入/输出系统(BIOS),占据了0x000F0000到0x000FFFFF的64KB区域。在早期的PC上,BIOS存储在真正的只读存储器(ROM)中,但当前的PC将BIOS存储在可更新的闪存中。BIOS负责执行基本的系统初始化,例如激活视频卡并检查安装的内存量。在执行完此初始化之后,BIOS会从软盘、硬盘、CD-ROM或网络等适当位置加载操作系统,并将机器的控制权移交给操作系统。

当Intel最终通过80286和80386处理器“突破了一兆字节障碍”,支持了16MB和4GB物理地址空间时,PC架构师仍然保留了原始布局,以确保向后兼容现有软件。因此,现代PC在物理内存的低1MB中有一个“空洞”,从0x000A0000到0x00100000,将RAM分为“低”或“传统内存”(前640KB)和“扩展内存”(其他所有)。此外,一些空间通常保留在PC的32位物理地址空间的顶部,即所有物理RAM之上,用于32位PCI设备。

上面那些的意思是,为了旧系统的兼容,将RAM分成了好几段,好几段之间的部分被称为空洞,一部分未使用的物理地址空间通常被保留,以便用于32位PCI设备。这些设备需要分配物理地址空间。

最近的x86处理器可以支持超过4GB的物理RAM,因此RAM可以扩展到0xFFFFFFFF以上。在这种情况下,BIOS必须安排在系统的RAM顶部留下第二个空洞,以为这些32位设备留出空间。由于设计限制,JOS将仅使用PC物理内存的前256MB,因此目前我们假装所有PC都“只有”32位物理地址空间。但处理复杂的物理地址空间和多年来演变的硬件组织的其他方面,是操作系统开发中重要的实际挑战之一。

The ROM BIOS

在实验的这个部分,你将使用 QEMU 的调试工具来研究如何启动兼容 IA-32 架构的计算机。

打开两个终端窗口,并将两个 shell 都切换到你的实验目录。在其中一个窗口中输入 make qemu-gdb(或者 make qemu-nox-gdb)。这会启动 QEMU,但 QEMU 会在处理器执行第一条指令之前停止,并等待来自 GDB 的调试连接。在第二个终端中,从与你运行 make 命令相同的目录中运行 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)

我们提供了一个名为 .gdbinit 的文件,它设置了 GDB 以调试在早期引导过程中使用的 16 位代码,并指示它连接到正在等待的 QEMU 调试实例。(如果它不起作用,你可能需要在你的主目录下的 .gdbinit 文件中添加 add-auto-load-safe-path 来说服 gdb 处理我们提供的 .gdbinit。如果需要这样做,gdb 会告诉你。)

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

GDB对即将执行的第一条指令进行了反汇编。从这个输出你可以得出一些结论:

  • IBM PC在物理地址0x000ffff0开始执行,该地址位于保留给ROM BIOS的64KB区域的最顶部。
  • PC开始执行时,CS = 0xf000,IP = 0xfff0。
  • 即将执行的第一条指令是一个jmp指令,它跳转到分段地址CS = 0xf000和IP = 0xe05b。

QEMU为什么会这样启动呢?这是因为英特尔设计了8088处理器,IBM在他们的原始个人电脑中使用了这种设计。因为PC中的BIOS是“硬连线”到物理地址范围0x000f0000-0x000fffff的,这个设计确保BIOS在上电或任何系统重新启动后始终首先控制机器——这是至关重要的,因为在上电时,机器的RAM中没有其他软件可供处理器执行。QEMU仿真器带有自己的BIOS,它将其放置在处理器模拟物理地址空间的这个位置。在处理器复位时,(模拟的)处理器进入实模式,并将CS设置为0xf000,IP设置为0xfff0,因此执行从那个(CS:IP)段地址开始。那么,段地址0xf000:fff0如何转换为物理地址呢?

要回答这个问题,我们需要了解一些关于实模式寻址的知识。在实模式(PC启动时的模式)中,地址转换遵循以下公式:物理地址 = 16 * 段 + 偏移量。因此,当PC将CS设置为0xf000,IP设置为0xfff0时,引用的物理地址是:

16 * 0xf000 + 0xfff0 # 在十六进制中,乘以16很简单——只需在末尾添加一个0。 = 0xf0000 + 0xfff0 = 0xffff 0xffff0是BIOS结束位置(0x100000)前的16个字节。因此,我们不应该感到惊讶,BIOS要做的第一件事是向后跳转到BIOS中的一个较早位置;毕竟,在仅有16个字节的情况下,它能完成多少工作呢?

Exercise 2

使用GDB的si(Step Instruction,逐条指令执行)命令来跟踪ROM BIOS中的更多指令,并尝试猜测它可能在做什么。你可能想要查看Phil Storrs的I/O端口描述,以及6.828参考资料页面上的其他资料。无需弄清所有细节,只需先了解BIOS正在做什么的一般想法。

当BIOS运行时,它会设置中断描述符表并初始化各种设备,比如VGA显示器。这就是你在QEMU窗口中看到的“Starting SeaBIOS”消息来自的地方。

在初始化PCI总线和BIOS已知的所有重要设备后,BIOS会搜索可引导的设备,比如软盘、硬盘或CD-ROM。最终,当它找到一个可引导的磁盘时,BIOS会从磁盘中读取引导加载程序(boot loader),并将控制权转移给它。

Part 2: The Boot Loader

软盘和PC上的硬盘被分为512字节大小的区域,称为扇区(sector)。扇区是磁盘的最小传输粒度:每个读取或写入操作必须至少是一个扇区的大小,并且在扇区边界上对齐。如果磁盘是可引导的,第一个扇区被称为引导扇区(boot sector),因为引导加载程序代码就驻留在这里。当BIOS找到一个可引导的软盘或硬盘时,它会将大小为512字节的引导扇区加载到内存中的物理地址0x7c00至0x7dff,并使用jmp指令将CS:IP设置为0000:7c 00,从而将控制权传递给引导加载程序。就像BIOS的加载地址一样,这些地址相当随意,但对于PC来说是固定和标准化的。

能够从CD-ROM引导的能力在PC演变过程中出现得较晚,因此PC架构师们有机会稍微重新考虑引导流程。因此,现代BIOS从CD-ROM引导的方式稍微复杂一些(也更强大)。CD-ROM使用的扇区大小为2048字节而不是512字节,BIOS在将控制权转移给引导加载程序之前可以从磁盘中加载一个更大的引导镜像到内存中(不仅仅是一个扇区)。有关更多信息,请参阅“El Torito”可引导CD-ROM格式规范。

然而,在6.828中,我们将使用传统的硬盘引导机制,这意味着我们的引导加载程序必须适合于区区512字节。引导加载程序由一个汇编语言源文件boot/boot.S和一个C源文件boot/main.c组成。仔细查看这些源文件,确保你理解其中的内容。引导加载程序必须执行两个主要功能:

首先,引导加载程序将处理器从实模式切换到32位保护模式,因为只有在这种模式下,软件才能访问处理器物理地址空间中1MB以上的所有内存。保护模式在PC汇编语言的1.2.7和1.2.8节中简要描述,在英特尔架构手册中有更详细的介绍。在这一点上,你只需要理解在保护模式下,分段地址(段:偏移地址对)到物理地址的转换方式不同,并且过渡后,偏移量是32位而不是16位。 其次,引导加载程序通过直接访问x86的特殊I/O指令,通过读取IDE硬盘设备寄存器来从硬盘读取内核。如果你想更好地理解这里的特定I/O指令的含义,请查看6.828参考页面上的“IDE硬盘控制器”部分。在这门课程中,你不需要深入学习有关特定设备的编程:编写设备驱动程序在操作系统开发中实际上是一个非常重要的部分,但从概念或架构的角度来看,这也是最不有趣的部分之一。 在你理解了引导加载程序的源代码之后,看一下obj/boot/boot.asm文件。这个文件是我们的GNUmakefile在编译引导加载程序后生成的引导加载程序的反汇编文件。这个反汇编文件可以很容易地查看引导加载程序代码在物理内存中的确切位置,并且可以更容易地跟踪在GDB中逐步执行引导加载程序时发生的情况。同样,obj/kern/kernel.asm包含了JOS内核的反汇编代码,这在调试时通常是有用的。

你可以在GDB中使用b命令设置地址断点。例如,b *0x7c00会在地址0x7C00处设置一个断点。一旦到达断点,你可以使用c和si命令继续执行:c使QEMU继续执行直到下一个断点(或者直到你在GDB中按下Ctrl-C),si N可以逐步执行N个指令。

要查看内存中的指令(除了即将执行的下一条指令,GDB会自动打印),你可以使用x/i命令。这个命令的语法是x/Ni ADDR,其中N是要反汇编的连续指令数量,ADDR是开始反汇编的内存地址。

Exercise 3.

看一下实验工具指南,特别是关于GDB命令的部分。即使你熟悉GDB,这部分也包括对操作系统工作有用的一些神秘GDB命令。

在地址0x7c00处设置一个断点,这是引导扇区将被加载的位置。继续执行,直到达到断点。通过使用源代码和反汇编文件obj/boot/boot.asm来跟踪boot/boot.S中的代码。还可以使用GDB中的x/i命令来反汇编引导加载程序中的指令序列,并将原始引导加载程序源代码与obj/boot/boot.asm和GDB中的反汇编进行比较。

跟踪到boot/main.c中的bootmain()函数,然后进入readsect()函数。识别与readsect()中每个语句对应的确切汇编指令。跟踪readsect()的其余部分,然后返回到bootmain(),并确定从磁盘读取内核的剩余扇区的for循环的开始和结束。找出当循环结束时将运行的代码,设置一个断点,然后继续到该断点。然后逐步执行剩余的引导加载程序。

能够回答下面的问题

  • 处理器何时开始执行32位代码?是什么确切地导致了从16位切换到32位模式?

    boot/boot.S中,我们可以看到这样一行代码

      # Jump to next instruction, but in 32-bit code segment.
      # Switches processor into 32-bit mode.
      ljmp    $PROT_MODE_CSEG, $protcseg    
    

    在这一步,执行了一个段间跳转指令,格式为ljmp $SECTION, $OFFSET,并且从此开始执行32位代码。

    ljmp $PROT_MODE_CSEG, $protcseg 是一个汇编指令,用于在8086处理器中从实模式切换到保护模式。

    在实模式下,处理器以物理地址的方式访问内存,程序使用的地址是直接的物理地址。而在保护模式下,内存访问是通过段描述符进行管理的,这样可以提供更高级的内存保护和特权级别管理。

    这条指令实际上是一种长跳转指令(long jump),用于在保护模式中切换代码执行的位置。它接受两个参数:

    • $PROT_MODE_CSEG 是目标代码段选择子,它指定了在保护模式下要执行的代码段。
    • $protcseg 是目标代码段内的偏移地址,表示从选择的代码段开始执行的位置。

    在实际操作中,这条指令会在切换到保护模式之后,开始执行位于 $protcseg 中的代码段,从而完成从实模式到保护模式的转换。这样一来,处理器就能够利用保护模式提供的更多功能和更高级别的内存管理来执行代码。

    (gdb)
    [   0:7c23] => 0x7c23:	mov    %cr0,%eax
    0x00007c23 in ?? ()
    (gdb)
    [   0:7c26] => 0x7c26:	or     $0x1,%ax
    0x00007c26 in ?? ()
    (gdb)
    [   0:7c2a] => 0x7c2a:	mov    %eax,%cr0
    0x00007c2a in ?? ()
    (gdb)
    [   0:7c2d] => 0x7c2d:	ljmp   $0xb866,$0x87c32
    0x00007c2d in ?? ()
    
  • 引导加载程序执行的最后一条指令是什么?它加载的内核的第一条指令是什么?

    boot/main.c中可以到这一行代码

    	// call the entry point from the ELF header
    	// note: does not return!
    	((void (*)(void)) (ELFHDR->e_entry))();
    

    这段代码的作用是执行位于 ELF 文件头 (ELFHDR) 中指定的入口点 (e_entry) 处的函数。

    让我们逐步解释这段代码:

    • ELFHDR->e_entry:这是从 ELF 文件头结构体 (ELFHDR) 中获取入口点地址。在 ELF 文件中,入口点地址 (e_entry) 指定了程序的执行应该从哪里开始。
    • (void (*)(void)) (ELFHDR->e_entry):这一部分将入口点地址强制转换为一个函数指针,该函数接受无参数并返回 void 类型。因为通常程序的入口点是一个函数,所以将入口点地址转换为函数指针是为了执行该函数。
    • ((void (*)(void)) (ELFHDR->e_entry))():最后一步是将获取的函数指针调用为函数。这样就会执行位于入口点地址处的代码,从而启动程序的执行。

    总体来说,这段代码是为了启动程序,执行位于 ELF 文件头中指定的入口点地址处的代码。

    这就是引导加载程序执行的最后一条指令,我们可以在gdb找出来,地址为0x7d6b

    (gdb) b *0x7d6b
    Breakpoint 3 at 0x7d6b
    (gdb) c
    Continuing.
    => 0x7d6b:	call   *0x10018
    
    

    接下去执行的就是内核执行的第一条指令:

    (gdb) si
    => 0x10000c:	movw   $0x1234,0x472
    0x0010000c in ?? ()
    
    

    内核的第一条指令在哪里?

  • 内核的第一条指令在哪里?

    由上一问可以知道就是地址0x10000c

  • 引导加载程序如何决定必须读取多少扇区才能从磁盘中获取整个内核?它在哪里找到这些信息?

    根据对main.c的分析,显然是通过 ELF 文件头获取所有program header table

    通过 objdump 命令可以查看:

    $ objdump -p obj/kern/kernel
    
    obj/kern/kernel:     file format elf32-i386
    
    Program Header:
        LOAD off    0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
             filesz 0x0000759d memsz 0x0000759d flags r-x
        LOAD off    0x00009000 vaddr 0xf0108000 paddr 0x00108000 align 2**12
             filesz 0x0000b044 memsz 0x0000b6a4 flags rw-
       STACK off    0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
             filesz 0x00000000 memsz 0x00000000 flags rwx
    

Loading the Kernel

现在我们将更详细地查看引导加载程序中的 C 语言部分,即在 boot/main.c 文件中。但在继续之前,现在是停下来复习 C 编程的基础知识的好时机。

Exercise 4.

阅读关于 C 语言中指针的编程知识。C 语言的最佳参考书是 Brian Kernighan 和 Dennis Ritchie 编写的《The C Programming Language》(通常称为 'K&R')。我们建议学生购买这本书(这里是亚马逊链接),或找到麻省理工学院的其中一本 7 本副本。

在 K&R 的第 5.1 节(指针和地址)到第 5.5 节(字符指针和函数)之间进行阅读。然后下载 pointers.c 的代码,运行它,并确保你理解所有打印出的值的来源。特别要确保你理解打印行 1 和 6 中指针地址的来源,打印行 2 到 4 中所有值的来源,以及为什么打印行 5 中的值似乎是损坏的。

还有其他关于 C 语言指针的参考资料(例如 Ted Jensen 的教程,其中大量引用了 K&R),尽管没有被强烈推荐。

警告:除非你已经完全熟悉 C 语言,否则不要跳过或浏览这个阅读练习。如果你对 C 语言中的指针没有真正的理解,那么在接下来的实验中你将遭受不可言喻的痛苦和困扰,然后最终会用艰难的方式去理解它们。相信我们,你不想以艰难的方式学习。

  • 熟悉c中地址和指针

    #include <stdio.h>
    #include <stdlib.h>
     
    void f(void)
    {
        int a[4];
        int *b = malloc(16);
        int *c;
        int I;
        // a是一个int类型的数组,那么在代码中要理解的是如下三个概念:
        // 1. a是指a指针指向内存所代表的地址
        // 2. &a是指a指针的内存地址
        // 3. *a是指a指针指向内存代表的地址中的内容
        // 因此,在下面一行代码执行结果出现的是a,b,c三个指针所在的内存地址,并不是他们指向的地址
        // 于是内存地址按照 &a>&b>&c 分配,并且三个地址连续
        printf("1: a = %p, b = %p, c = %p\\n", &a, &b, &c);
        // 下面把c的指针的内存地址指向a
        // 那么也就意味着c+1变成了a[1]的地址,c+2变成了a[2]的地址,等等
        c = a;
        for (i = 0; i < 4; i++)
    	    a[i] = 100 + I;
        // c[0]为200,那么也就意味着c的指针指向内存代表的地址中的内容为200,也就意味着a[0]=200
        c[0] = 200;
        printf("2: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\\n",
    	   a[0], a[1], a[2], a[3]);
        // 下面主要是替换指针指向内存代表的地址中的内容,较为简单的三种形式
        // 按照如下模版都是一个意思:
        //c[1]=*(c+1)=1[c]
        c[1] = 300;
        *(c + 2) = 301;
        3[c] = 302;
        printf("3: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\\n",
    	   a[0], a[1], a[2], a[3]);
        // 这里如果不清楚指针地址是如何变换的,那么打印一下指针地址就知道了
        // 从输出就可以看出,a和c指针指向内存代表的地址是相同的
        printf("3.1: a = %d, a+1 = %d,a+2 = %d,a+3 = %d, b = %d, c = %d, c+1=%d\\n", a,a+1,a+2,a+3, b, c, c+1);
        // 这时,将c指针向后挪动一个位置,这里的一个位置代表的是四个bit(32位)
        // 也就是说c指针指向内存的地址变成了原来c+1指针指向内存的地址
        // c+1指针指向内存的地址变成了原来c+2指针指向内存的地址,等等
        // 这时,变化的仅仅是c指针吗?并不是,之前的c=a依然作数
        // 也就是说现在的c指针指向的内存地址变化了,那么以前的关系也要发生变化
        // 原来c指针指向的内存地址也是a指针指向的内存地址
        // 现在c指针指向了原来c+1的内存地址,原来c+1指向的是a+1的内存地址
        // 那么意味着,c指针现在指针也指向a+1的内存地址
        c = c + 1;
        // 所以可以输出一下内存地址看一下,可以看出现在c的指向内存地址和a+1的内存地址是完全一致的
        // 而在+1之前,c的指向内存地址和a的内存地址是完全一致的
        printf("3.2: a = %d, a+1 = %d,a+2 = %d,a+3 = %d, b = %d, c = %d, c+1=%d\\n", a,a+1,a+2,a+3, b, c, c+1);
        *c = 400;
        // 这里把c的指向内存地址的内容换成了400,那么意味着,同一地址的a+1的内容也发生了改变
        printf("4: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\\n",
    	   a[0], a[1], a[2], a[3]);
        // 这里输出一下各个部分的指向内存地址
        printf("4.1: a = %d, a+1 = %d,a+2 = %d,a+3 = %d, b = %d, c = %d, c+1=%d\\n", a,a+1,a+2,a+3, b, c, c+1);
        // 这里继续变化c的指向内存的地址,和上次不一样的是,上次顺延了4个bit(一个int的长度)
        // 现在是1个bit(一个char的长度)
        c = (int *) ((char *) c + 1);
        printf("4.2: a = %d, a+1 = %d,a+2 = %d,a+3 = %d, b = %d, c = %d, c+1=%d\\n", a,a+1,a+2,a+3, b, c, c+1);
        // 为了更便于理解,在这里呢,我分析的更加具体一些:
        // 现在a[2]的值为301
        // 用八个bit表示就是:0000 0000 0000 0000 0000 0001 0010 1101
        // 现在a[1]的值为400
        // 用八个bit表示就是:0000 0000 0000 0000 0000 0001 1101 0000
        // 我先写a[2] 再写a[1] 的原因是因为a[2]地址大,a[1]地址小,大的在上方比较符合规律
        // 现在要替换一个数500
        // 用八个bit表示就是:0000 0000 0000 0000 0000 0001 1111 0100
        // 开始替换的位置是a[1]的地址上顺延1个bit,那么替换后,可以知道
        // 现在a[2]的值为256
        // 用八个bit表示就是:0000 0000 0000 0000 0000 0001 0000 0000
        // 现在a[1]的值为128144
        // 用八个bit表示就是:0000 0000 0000 0001 1111 0100 1001 0000
        *c = 500;
        printf("5: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\\n",
    	   a[0], a[1], a[2], a[3]);
        // 理解到上面,下面的不必解释了
        b = (int *) a + 1;
        c = (int *) ((char *) a + 1);
        printf("6: a = %p, b = %p, c = %p\\n", a, b, c);
    }
     
    int main(int ac, char **av){
        f();
        return 0;
    }
    

    一个int占32位,一个char占8位,存储的时候a[2]的地址比a[1]的地址大

    1: a = 0x7ffeee515570, b = 0x7ffeee515568, c = 0x7ffeee515560
    2: a[0] = 200, a[1] = 101, a[2] = 102, a[3] = 103
    3: a[0] = 200, a[1] = 300, a[2] = 301, a[3] = 302
    3.1: a = -296659600, a+1 = -296659596,a+2 = -296659592,a+3 = -296659588, b = 1464861216, c = -296659600, c+1=-296659596
    3.2: a = -296659600, a+1 = -296659596,a+2 = -296659592,a+3 = -296659588, b = 1464861216, c = -296659596, c+1=-296659592
    4: a[0] = 200, a[1] = 400, a[2] = 301, a[3] = 302
    4.1: a = -296659600, a+1 = -296659596,a+2 = -296659592,a+3 = -296659588, b = 1464861216, c = -296659596, c+1=-296659592
    4.2: a = -296659600, a+1 = -296659596,a+2 = -296659592,a+3 = -296659588, b = 1464861216, c = -296659595, c+1=-296659591
    5: a[0] = 200, a[1] = 128144, a[2] = 256, a[3] = 302
    6: a = 0x7ffeee515570, b = 0x7ffeee515574, c = 0x7ffeee515571
    

当你阅读boot/main.c时,你需要了解什么是ELF二进制文件。当你编译和链接像JOS内核这样的C程序时,编译器将每个C源代码('.c')文件转换为一个包含用硬件期望的二进制格式编码的汇编语言指令的目标('.o')文件。链接器然后将所有已编译的目标文件合并成一个单独的二进制图像,比如obj/kern/kernel,这在本例中是一个ELF格式的二进制文件,ELF代表"可执行和可链接格式"。

有关该格式的完整信息可以在我们的参考页面上找到ELF规范,但在这门课程中,你不需要深入了解这个格式的细节。尽管整体上这个格式非常强大且复杂,但大部分复杂部分是用于支持共享库的动态加载,而在这门课程中我们不会做这些。Wikipedia页面上有一个简短的描述。

对于6.828课程来说,你可以将ELF可执行文件视为一个包含加载信息的头部,后跟多个程序段,每个段都是一块连续的代码或数据,旨在被加载到指定地址的内存中。引导加载程序不会修改代码或数据;它会将其加载到内存中并开始执行。

一个ELF二进制文件以固定长度的ELF头部开始,后面是可变长度的程序头部,列出了要加载的每个程序段。这些ELF头部的C语言定义在inc/elf.h中。我们感兴趣的程序段包括:

.text:程序的可执行指令。 .rodata:只读数据,例如由C编译器生成的ASCII字符串常量。(但我们不会设置硬件来禁止写入。) .data:数据段保存程序的初始化数据,例如使用初始化程序声明的全局变量,比如int x = 5;。 当链接器计算程序的内存布局时,它会为未初始化的全局变量(比如int x;)在内存中的.data紧随之后的一个名为.bss的段中预留空间。C要求“未初始化”的全局变量以零值开始。因此,在ELF二进制文件中不需要存储.bss的内容;相反,链接器只记录了.bss段的地址和大小。加载器或程序本身必须安排将.bss段清零。

检查内核可执行文件中所有部分的名称、大小和链接地址的完整列表,方法是输入以下命令:

athena% objdump -h obj/kern/kernel(在一开始的目录下,最开始没有进到任何其他目录下,objdump -h 6.828/lab/obj/kern/kernel) (如果您编译了自己的工具链,则可能需要使用i386-jos-elf-objdump) 你会看到比我们上面列出的部分更多的部分,但其他部分对我们的目的并不重要。大多数其他部分用于保存调试信息,这些信息通常包含在程序的可执行文件中,但不会被程序加载器加载到内存中。-和

特别注意“.text”段的“VMA”(或链接地址)和“LMA”(或加载地址)。一个段的加载地址是该段应该加载到内存中的内存地址。

一个段的链接地址是该段期望执行的内存地址。链接器以各种方式在二进制文件中编码链接地址,比如当代码需要全局变量的地址时,结果是,如果二进制文件正在执行一个它没有链接的地址,它通常不会工作。(可以生成不包含任何这种绝对地址的位置无关代码。这在现代共享库中得到广泛应用,但它具有性能和复杂性成本,所以我们在6.828课程中不会使用它。)

通常情况下,链接地址和加载地址是相同的。例如,看看引导加载程序(boot loader)的 .text 段:

athena% **objdump -h obj/boot/boot.out**

引导加载程序使用 ELF 程序头来决定如何加载各个段。这些程序头指定了 ELF 对象中需要加载到内存中的部分以及它们应该占据的目标地址。你可以通过输入以下命令来检查程序头:

athena% **objdump -x obj/kern/kernel**

程序头随后会在 objdump 输出中的“Program Headers”部分列出。需要加载到内存中的 ELF 对象区域会标记为“LOAD”。每个程序头还提供了其他信息,如虚拟地址("vaddr")、物理地址("paddr")、加载区域的大小("memsz" 和 "filesz")等。

在 boot/main.c 中,每个程序头的 ph->p_pa 字段包含了段的目标物理地址(在这种情况下,它确实是一个物理地址,尽管 ELF 规范对该字段的实际含义并不清楚)。

BIOS将引导扇区加载到内存中,起始地址为0x7c00,因此这是引导扇区的加载地址。这也是引导扇区执行的地址,因此也是其链接地址。我们通过在 boot/Makefrag 中传递 -Ttext 0x7C00 给链接器来设置链接地址,这样链接器会在生成的代码中生成正确的内存地址。

Exercise 5.

再次跟踪引导加载程序的前几条指令,并确定如果引导加载程序的链接地址错误,将会“中断”或产生其他错误的第一条指令。然后,将 boot/Makefrag 中的链接地址更改为错误的值,运行 make clean,使用 make 重新编译实验,再次跟踪进入引导加载程序,查看会发生什么。不要忘记随后将链接地址更改回来,并再次运行 make clean!

boot/Makefrag中的-Ttext 0x7c00改为-Ttext 0x7c20,仍然将断点设置在0x7c00

(gdb)
[   0:7c2d] => 0x7c2d:	ljmp   $0xb866,$0x87c52
0x00007c2d in ?? ()

与之前对比可以发现,相差了0x20,也就是修改增加的值。然而由于BIOS会把引导加载程序固定加载在0x7c00,于是导致了错误。

回顾一下内核的加载地址和链接地址。与引导加载程序不同,这两个地址并不相同:内核告诉引导加载程序将其加载到一个低地址(1兆字节),但它期望从一个高地址执行。我们将在下一节详细了解如何使这种情况发挥作用。

除了节信息之外,ELF 头中还有一个对我们很重要的字段,名为 e_entry。该字段保存着程序的入口点的链接地址:程序的文本部分中应该开始执行的内存地址。您可以查看入口点:

athena% objdump -f obj/kern/kernel

现在,您应该能够理解 boot/main.c 中的最小 ELF 加载程序。它会将内核的每个节从磁盘读取到相应节的加载地址中,然后跳转到内核的入口点。

Exercise 6.

我们可以使用GDB的x命令来检查内存。GDB手册提供了详细的信息,但现在只需知道命令x/Nx ADDR会打印地址ADDR处的N个字的内存内容。(请注意,命令中的两个“x”都是小写。)警告:字的大小不是一个通用标准。在GNU汇编中,一个字是两个字节(xorw中的“w”代表字,意味着2个字节)。

重置机器(退出QEMU/GDB并重新启动它们)。检查BIOS进入引导加载程序时地址0x00100000处的8个字的内存内容,然后在引导加载程序进入内核时再次检查。为什么它们不同?在第二个断点处有什么?(实际上,您不需要使用QEMU来回答这个问题。只需思考即可。)

ELF头中有一个很重要的字段,名为e_entry。该字段保存程序中入口点的链接地址:程序应该开始执行的程序文本部分中的内存地址。你可以看到入口点:

$ 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

这和练习3的内核的第一条指令的位置相符。 可以看出,boot/main.c的作用就是从硬盘读取内核的每个段,然后跳转到内核的e_entry

首先在BIOS进入引导加载程序时检查一次(还未读取内核至内存),再在从引导加载程序进入内核时检查一次(此时已经将内核读入内存)。

(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) c
Continuing.
[   0:7c00] => 0x7c00:	cli

Breakpoint 1, 0x00007c00 in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x00000000	0x00000000	0x00000000	0x00000000
0x100010:	0x00000000	0x00000000	0x00000000	0x00000000
(gdb) b *0x7d6b
Breakpoint 2 at 0x7d6b
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x7d6b:	call   *0x10018

Breakpoint 2, 0x00007d6b in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8

Part 3: The Kernel

我们现在将稍微详细地开始研究最小化的 JOS 内核。(最终您将有机会编写一些代码!)与引导加载程序一样,内核以一些汇编语言代码开始,这些代码设置了一些东西,以便 C 语言代码可以正常执行。

Using virtual memory to work around position dependence

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

当您检查引导加载程序的链接和加载地址时,它们完全匹配,但是内核的链接地址(由 objdump 打印)和加载地址之间存在(相当大的)差异。请返回检查两者,并确保您能理解我们的讨论。(链接内核比引导加载程序更复杂,所以链接和加载地址位于 kern/kernel.ld 的顶部。)

操作系统内核通常喜欢链接和运行在非常高的虚拟地址上,比如 0xf0100000,以便将处理器的虚拟地址空间的较低部分留给用户程序使用。这种安排的原因将在下一个实验中变得更清晰。

许多计算机在地址 0xf0100000 处没有任何物理内存,因此我们不能保证能够将内核存储在那里。相反,我们将使用处理器的内存管理硬件将虚拟地址 0xf0100000(内核代码期望运行的链接地址)映射到物理地址 0x00100000(引导加载程序将内核加载到物理内存的位置)。这样,尽管内核的虚拟地址足够高,以便留出足够的地址空间给用户进程,但它将被加载到物理内存中的 PC 内存 RAM 中的 1MB 点之上,即 BIOS ROM 的上方。这种方法要求 PC 至少有几兆字节的物理内存(以使物理地址 0x00100000 生效),但这对于大约 1990 年后建造的任何 PC 都是可能的。

实际上,在下一个实验中,我们将把计算机的物理地址空间底部的 256MB,从物理地址 0x00000000 到 0x0fffffff,分别映射到虚拟地址 0xf0000000 到 0xffffffff。现在您应该能够看到为什么 JOS 只能使用前 256MB 的物理内存。

目前,我们将仅映射前 4MB 的物理内存,这将足以让我们启动和运行。我们使用 kern/entrypgdir.c 中手动编写的静态初始化页目录和页表来实现这一点。目前,您不必理解其工作原理的细节,只需理解其实现的效果。在 kern/entry.S 设置 CR0_PG 标志之前,内存引用被视为物理地址(严格来说,它们是线性地址,但是 boot/boot.S 设置了从线性地址到物理地址的恒等映射,我们将永远不会更改它)。一旦设置了 CR0_PG,内存引用将成为由虚拟内存硬件转换为物理地址的虚拟地址。entry_pgdir 将虚拟地址范围从 0xf0000000 到 0xf0400000 转换为物理地址从 0x00000000 到 0x00400000,同时将虚拟地址从 0x00000000 到 0x00400000 转换为物理地址从 0x00000000 到 0x00400000。任何不在这两个范围内的虚拟地址将引发硬件异常,由于我们尚未设置中断处理,这将导致 QEMU 转储机器状态并退出(如果您未使用经过 6.828 补丁的 QEMU 版本,则会无限重新启动)。

  • 对上面那段话的解释

    这段话描述了在操作系统开发中常见的一种内存管理策略,特别是在涉及到如何将操作系统内核的虚拟地址映射到物理地址的问题。我会逐步解释这段话的含义:

    1. 内核的链接地址和加载地址
      • 链接地址:这是操作系统内核在编译时设置的虚拟地址。例如,内核可能被设计为在虚拟地址 0xf0100000 上运行。
      • 加载地址:这是内核实际被引导加载程序加载到物理内存中的地址。例如,内核可能被加载到物理地址 0x00100000。
    2. 为什么使用高虚拟地址
      • 使用高虚拟地址(如 0xf0100000)运行内核,可以把较低的虚拟地址空间留给用户程序使用。这有助于隔离内核空间和用户空间,提高安全性和稳定性。
    3. 物理内存的限制
      • 许多计算机的物理内存并不会一直延伸到高虚拟地址(如 0xf0100000)对应的物理地址。因此,内核虽然链接到这个高地址,但实际上不能直接被加载到对应的物理地址上。
    4. 内存管理硬件的映射
      • 通过设置分页机制,操作系统将内核的虚拟地址(例如 0xf0100000)映射到一个有效的物理地址(例如 0x00100000)。这意味着当内核访问虚拟地址 0xf0100000 时,分页硬件会自动将其转换为物理地址 0x00100000。
    5. 实验中的映射策略
      • 实验中,内存的前 256MB(从物理地址 0x00000000 到 0x0fffffff)被映射到虚拟地址 0xf0000000 到 0xffffffff。这种设置使得内核能够访问前 256MB 的物理内存。
      • 初始阶段,只有前 4MB 的物理内存(从 0x00000000 到 0x00400000)被映射,足以启动和运行操作系统。
    6. 分页和地址转换
      • 在设置 CR0_PG 标志(启用分页)之前,内存引用被视为物理地址(实际上是线性地址,但因为设置了恒等映射,所以它们与物理地址相同)。
      • 启用分页后,内存引用变为虚拟地址,由分页硬件转换为物理地址。

    总之,这段描述涉及到如何在操作系统内核中使用分页机制来设置虚拟内存,使得内核虽然链接到一个高虚拟地址,但实际上运行在一个较低的物理地址上。这样做的目的是为了内存隔离、安全性以及对有限物理内存的有效利用。

Exercise 7.

使用QEMU和GDB跟踪到JOS内核并停在movl %eax, %cr0。检查内存为0x001000000xf0100000。现在,使用stepiGDB命令单步执行该指令。再次,检查内存为0x001000000xf0100000。确保你了解刚刚发生的事情。

先将断点设置到加载内核的前一句

(gdb) b *0x7d6b
Breakpoint 1 at 0x7d6b
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x7d6b:	call   *0x10018

然后进入内核并停在movl %eax, %cr0,查看地址0x1000000xf0100000的内容,可以发现内容不一样

(gdb)
=> 0x100025:	mov    %eax,%cr0
0x00100025 in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>:	0x00000000	0x00000000	0x00000000	0x00000000
0xf0100010 <entry+4>:	0x00000000	0x00000000	0x00000000	0x00000000

继续执行,执行完movl %eax, %cr0后,可以发现,VMALMA现在具有同样的内容。这是因为0x00100000被映射到了0xf0100000

(gdb) si
=> 0x100028:	mov    $0xf010002f,%eax
0x00100028 in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0xf0100010 <entry+4>:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8

建立新映射后,如果映射不到位,将无法正常工作的第一条指令是什么?movl %eax, %cr0在 kern/entry.S中注释掉,跟踪它,看看你是否正确。

程序会直接崩溃,这是在执行完jmp *%eax后崩溃的

(gdb)
=> 0x10002a:	jmp    *%eax
0x0010002a in ?? ()
(gdb)
=> 0xf010002c <relocated>:	add    %al,(%eax)
relocated () at kern/entry.S:74
74		movl	$0x0,%ebp			# nuke frame pointer
(gdb)
Remote connection closed

Formatted Printing to the Console

大多数人认为像 printf() 这样的函数理所当然,有时甚至认为它们是 C 语言的“原语”。但在操作系统内核中,我们必须自己实现所有的 I/O。

请阅读 kern/printf.c、lib/printfmt.c 和 kern/console.c,并确保您理解它们之间的关系。在后续的实验中,printfmt.c 为何位于单独的 lib 目录下将变得清晰明了。

Exercise 8.

我们省略了一小段代码——使用“%o”形式的模式打印八进制数所需的代码。查找并填写此代码片段。

能够回答下面的问题

  • 解释一下 printf.c 和 console.c 之间的接口。具体来说,console.c 导出了什么函数?这个函数是如何被 printf.c 使用的?

  • 解释一下 console.c 中的以下内容:

    1      if (crt_pos >= CRT_SIZE) {
    2              int i;
    3              memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
    4              for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
    5                      crt_buf[i] = 0x0700 | ' ';
    6              crt_pos -= CRT_COLS;
    7      }
    
    

    看完这个函数大概可以猜出来这个函数应该是在屏幕上输出字符,而显示屏幕是有大小的,crt_pos是当前光标的位置,而CRT_SIZE是显示屏幕的大小,超过这个大小,则要向下移动一行

  • 对于以下问题,您可能希望参考第二讲的笔记。这些笔记涵盖了 x86 平台上 GCC 的调用约定。 逐步跟踪以下代码的执行:

    int x = 1, y = 3, z = 4;
    cprintf("x %d, y %x, z %d\\\\n", x, y, z);
    

    在对 cprintf() 的调用中,fmt 指向什么?ap 指向什么?

    按照执行顺序,列出每个 cons_putc、va_arg 和 vcprintf 的调用。对于 cons_putc,请列出其参数。对于 va_arg,请列出调用前后 ap 指向的内容。对于 vcprintf,请列出其两个参数的值。

    ap指向第二个参数的地址。注意ap中存放的是第二个参数的地址,而非第二个参数。 + 列出(按执行顺序)每次调用 cons_putcva_argvcprintf。对于cons_putc,也列出其论点。对于va_arg,列出ap呼叫之前和之后的点。对于vcprintf名单的两个参数的值。 调用关系为cprintf -> vcprintf -> vprintfmt -> putch -> cputchar -> cons_putc

  • 运行以下代码

    unsigned int i = 0x00646c72;
    cprintf("H%x Wo%s", 57616, &i);
    

    什么是输出?解释如何以前一个练习的逐步方式获得此输出。 输出是He110 World。 57616的16进制形式为 e110,这个很好理解。 输出字符串时,从给定字符串的第一个字符地址开始,按字节读取字符,直到遇到 ‘\0’ 结束。

    于是,Wo%s, &i 的意义是把 i 作为字符串输出。查阅 ASCII 码表可知,0x00 对应 ‘\0’,0x64 对应 ’d’,0x6c 对应 ‘l’,0x72 对应 ‘r’。

  • 在下面的代码中,将要打印的是什么 ‘y=‘?(注意:答案不是特定值。)为什么会发生这种情况?

    输出为:x = 3, y = -267321588。 由于第二个参数尚未指定,输出3以后无法确定ap的值应该变化多少,更无法根据ap的值获取参数。va_arg取当前栈地址,并将指针移动到下个“参数”所在位置简单的栈内移动,没有任何标志或者条件能够让你确定可变参函数的参数个数,也不能判断当前栈指针的合法性。

  • 假设GCC更改了它的调用约定,以便它按声明顺序在堆栈上推送参数,以便最后推送最后一个参数。您将如何更改cprintf或其界面,以便仍然可以传递可变数量的参数?

    需要更改va_start以及va_arg两个宏的实现。

The Stack

在这个实验的最后一个练习中,我们将更详细地探讨 C 语言在 x86 上如何使用堆栈,并在此过程中编写一个有用的新内核监视器功能,即打印堆栈的回溯:从导致当前执行点的嵌套调用指令中保存的指令指针(IP)值列表。

Exercise 9.

确定内核初始化其堆栈的位置,以及堆栈所在内存的确切位置。内核如何为其堆栈保留空间?并且在这个保留区域的“结束”是堆栈指针初始化为指向?

kern/entry.S中找到初始化ebpesp的语句:

# Clear the frame pointer register (EBP)
# so that once we get into debugging C code,
# stack backtraces will be terminated properly.
movl	$0x0,%ebp			# nuke frame pointer
# Set the stack pointer
movl	$(bootstacktop),%esp

用gdb查看地址:

(gdb) b kern/entry.S:74
Breakpoint 1 at 0xf010002f: file kern/entry.S, line 74.
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0xf010002f <relocated>:	mov    $0x0,%ebp

Breakpoint 1, relocated () at kern/entry.S:74
74		movl	$0x0,%ebp			# nuke frame pointer
(gdb) si
=> 0xf0100034 <relocated+5>:	mov    $0xf0110000,%esp
relocated () at kern/entry.S:77
77		movl	$(bootstacktop),%esp

可以看出,栈顶在0xf0110000,然后在kern/entry.S中找到:

bootstack:
	.space		KSTKSIZE

inc/memlayout.h中找到以下定义:

// Kernel stack.
#define KSTACKTOP	KERNBASE
#define KSTKSIZE	(8*PGSIZE)   		// size of a kernel stack#define KSTKGAP		(8*PGSIZE)   		// size of a kernel stack guard

inc/mmu.h中找到以下定义:

#define PGSIZE		4096		// bytes mapped by a page

可以看出,栈大小为32kB。

由于栈是从内存高位向低位生长,所以堆栈指针指向的是高位。

x86 堆栈指针 (esp 寄存器) 指向当前正在使用的堆栈中最低的位置。在堆栈保留区域中,低于该位置的所有内容都是自由的。将一个值推送(push)到堆栈上涉及减小堆栈指针,然后将值写入堆栈指针所指向的位置。从堆栈中弹出(pop)一个值涉及读取堆栈指针所指向的值,然后增加堆栈指针。在 32 位模式下,堆栈只能容纳 32 位的值,并且 esp 寄存器始终是 4 的倍数。各种 x86 指令(例如 call)被硬编码为使用堆栈指针寄存器。

与此相反,ebp(基址指针)寄存器主要通过软件约定与堆栈相关联。进入 C 函数时,函数的序言代码通常会将先前函数的基址指针保存到堆栈上,然后将当前 esp 值复制到 ebp 中,以供函数使用。如果程序中的所有函数都遵循这个约定,那么在程序执行过程中的任何时刻,可以通过跟踪保存的 ebp 指针的链条,准确地追踪堆栈,以确定导致程序达到特定点的嵌套函数调用序列。例如,当特定函数因为传递了错误的参数而导致断言失败或发生 panic 时,但不确定是谁传递了错误的参数,堆栈回溯可以帮助找到问题函数。

Exercise 10.

要熟悉x86上的C调用约定,test_backtraceobj/kern/kernel.asm中找到函数的地址,在那里设置断点,并检查每次在内核启动后调用它时会发生什么。每个递归嵌套级别test_backtrace的堆栈中有多少32位字,这些字是什么?

test_backtrace函数在kern/init.c里定义和使用

// Test the stack backtrace function (lab 1 only)
void
test_backtrace(int x)
{
	cprintf("entering test_backtrace %d\\n", x);
	if (x > 0)
		test_backtrace(x-1);
	else
		mon_backtrace(0, 0, 0);
	cprintf("leaving test_backtrace %d\\n", x);
}

// Test the stack backtrace function (lab 1 only)
test_backtrace(5);

开始调试:

(gdb) b *0xf0100076
Breakpoint 1 at 0xf0100076: file kern/init.c, line 18.
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0xf0100076 <test_backtrace+54>:	call   0xf010076e <mon_backtrace>
Breakpoint 1, 0xf0100076 in test_backtrace (x=0) at kern/init.c:18
18			mon_backtrace(0, 0, 0);
(gdb) x/52x $esp
0xf010ff20:	0x00000000	0x00000000	0x00000000	0x00000000
0xf010ff30:	0xf01008ef	0x00000001	0xf010ff58	0xf0100068
0xf010ff40:	0x00000000	0x00000001	0xf010ff78	0x00000000
0xf010ff50:	0xf01008ef	0x00000002	0xf010ff78	0xf0100068
0xf010ff60:	0x00000001	0x00000002	0xf010ff98	0x00000000
0xf010ff70:	0xf01008ef	0x00000003	0xf010ff98	0xf0100068
0xf010ff80:	0x00000002	0x00000003	0xf010ffb8	0x00000000
0xf010ff90:	0xf01008ef	0x00000004	0xf010ffb8	0xf0100068
0xf010ffa0:	0x00000003	0x00000004	0x00000000	0x00000000
0xf010ffb0:	0x00000000	0x00000005	0xf010ffd8	0xf0100068
0xf010ffc0:	0x00000004	0x00000005	0x00000000	0x00010094
0xf010ffd0:	0x00010094	0x00010094	0xf010fff8	0xf01000d4
0xf010ffe0:	0x00000005	0x00001aac	0x00000644	0x00000000
0xf010fff0:	0x00000000	0x00000000	0x00000000	0xf010003e

因为栈向下生长,从后往前看即为执行顺序。 在调用函数时,对栈需要进行以下操作: 1. 将参数由右向左压入栈 2. 将返回地址 (eip中的内容) 入栈,在 call 指令执行 3. 将上一个函数的 ebp 入栈 4. 将 ebx 入栈,保护寄存器状态 5. 在栈上开辟一个空间存储局部变量 可以看出,第二列出现的0x000000050x00000000都是参数。 在参数前一个存储的是返回地址,0xf0100068出现了多次,是test_backtrace递归过程中的返回地址。而0xf01000d4出现仅一次,是i386_init函数中的返回地址。可以通过查看obj/kern/kernel.asm证明。

上述练习应该给了你实现栈回溯函数所需的信息,你应该调用mon_backtrace()。在kern/monitor.c中,已经为这个函数提供了原型。你可以完全使用C语言来实现,但你可能会发现inc/x86.h中的read_ebp()函数有用。你还需要将这个新函数连接到内核监视器的命令列表中,以便用户可以交互式地调用它。

回溯函数应以以下格式显示函数调用帧的列表:

Stack backtrace: ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031 ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061 ... 每一行包含一个ebp、eip和args。ebp值表示该函数在堆栈中的基指针:即,在函数被输入后,并且函数序言代码设置了基指针后,堆栈指针的位置。列出的eip值是函数的返回指令指针:即函数返回时控制将返回的指令地址。返回指令指针通常指向调用指令之后的指令(为什么?)。最后,args之后列出的五个十六进制值是问题函数的前五个参数,在调用函数之前,这些参数会被推入堆栈。当然,如果函数调用的参数少于五个,这些值中不是所有的五个值都有用(为什么回溯代码不能检测到实际有多少个参数?如何修复这个限制?)。

第一行打印的是当前执行的函数,即mon_backtrace函数本身,第二行反映了调用mon_backtrace的函数,第三行反映了调用该函数的函数,以此类推。你应该打印出所有未完成的堆栈帧。通过研究kern/entry.S,你会发现有一个简单的方法来确定何时停止。

以下是关于K&R第5章中值得记住的一些特定要点,对于下面的练习和以后的实验都很有用:

如果int p = (int)100,那么(int)p + 1和(int)(p + 1)是不同的数字:第一个是101,但第二个是104。当将整数添加到指针时,如第二种情况,整数隐式乘以指针指向的对象的大小。 p[i]被定义为*(p+i),指的是指针p所指向的内存中的第i个对象。上述添加规则有助于在对象大于一个字节时使该定义生效。 &p[i]与(p+i)相同,给出指针p所指向的内存中的第i个对象的地址。 虽然大多数C程序从不需要在指针和整数之间进行转换,但操作系统经常需要。每当看到涉及内存地址的加法时,都要问问自己,这是整数加法还是指针加法,并确保要添加的值是否适当地乘以或不乘以对象的大小。

Exercise 11.

实现上面指定的回溯函数。使用与示例中相同的格式,否则将使评分脚本混淆。如果您认为它正常工作,请运行make grade以查看其输出是否符合我们的评分脚本所期望的内容,如果不符合则修复它。 在您交付Lab 1代码后,欢迎您以任何方式更改回溯功能的输出格式。

输出格式为:

Stack backtrace:
  ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
  ebp f0109ed8  eip f01000d6  args 00000000 00000000 f0100058 f0109f28 00000061
  ...

主要是根据提示来改写kern/monitor.c,要点: 1. 利用read_ebp() 函数获取当前ebp值 2. 利用 ebp 的初始值0判断是否停止 3. 利用数组指针运算来获取 eip 以及 args

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	uint32_t ebp, *p;
	ebp = read_ebp();
	while(ebp != 0)
	{
		p = (uint32_t*)ebp;
		cprintf("ebp %x eip %x args %08x %08x %08x %08x %08x\\n", ebp, p[1], p[2], p[3], p[4], p[5], p[6]);
		ebp = *p;
	}
	return 0;
}

此时,你的回溯函数应该给出了在调用mon_backtrace()时位于堆栈上的函数调用者的地址。然而,在实践中,你通常希望知道这些地址对应的函数名称。例如,你可能想知道哪些函数可能包含导致内核崩溃的错误。

为了帮助你实现这个功能,我们提供了debuginfo_eip()函数,它在符号表中查找eip,并返回该地址的调试信息。该函数定义在kern/kdebug.c中。

Exercise 12.

修改堆栈回溯功能,为每个eip显示与该eip对应的函数名称,源文件名和行号。

输出格式为:

K> backtrace
Stack backtrace:
  ebp f010ff78  eip f01008ae  args 00000001 f010ff8c 00000000 f0110580 00000000
         kern/monitor.c:143: monitor+106
  ebp f010ffd8  eip f0100193  args 00000000 00001aac 00000660 00000000 00000000
         kern/init.c:49: i386_init+59
  ebp f010fff8  eip f010003d  args 00000000 00000000 0000ffff 10cf9a00 0000ffff
         kern/entry.S:70: <unknown>+0
K>

首先是完成二分查找stab表确定行号的函数,在kern/kdebug.c的173行处 根据注释的提示基本就能完成:

// Search within [lline, rline] for the line number stab.
// If found, set info->eip_line to the right line number.
// If not found, return -1.
//
// Hint:
//	There's a particular stabs type used for line numbers.
//	Look at the STABS documentation and <inc/stab.h> to find
//	which one.
// Your code here.
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if(lline <= rline)
{
	info->eip_line = stabs[lline].n_desc;
}
else
{
	return -1;
}

此后是添加命令,在 kern/monitor.c 的第27行:

static struct Command commands[] = {
	{ "help", "Display this list of commands", mon_help },
	{ "kerninfo", "Display information about the kernel", mon_kerninfo },
	{ "backtrace", "Display information about the backtrace", mon_backtrace },
};

最后是添加backtrace的输出信息,将kern/monitor.cmon_backtrace函数改为:

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	uint32_t ebp, *p;
	struct Eipdebuginfo eip_info;
	ebp = read_ebp();
	while(ebp != 0)
	{
		p = (uint32_t*)ebp;
		cprintf("ebp %x eip %x args %08x %08x %08x %08x %08x\\n", ebp, p[1], p[2], p[3], p[4], p[5], p[6]);
		if(debuginfo_eip(p[1], &eip_info) == 0)
		{
			uint32_t offset = p[1] - eip_info.eip_fn_addr;
			cprintf("\\t\\t%s:%d: %.*s+%d\\n", eip_info.eip_file, eip_info.eip_line, eip_info.eip_fn_namelen,  eip_info.eip_fn_name, offset);
		}
		ebp = *p;
	}
	return 0;
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值