本文旨在记录完成操作系统实验(Mit Jos) Lab1的过程.

实验共有7个lab.分别为:

  • Lab 1: Booting a PC
  • Lab 2: Memory Management
  • Lab 3: User Environments
  • Lab 4: Preemptive Multitasking
  • Lab 5: File system, Spawn and Shell
  • Lab 6: Network Driver (default final project)
  • Lab 7: Final JOS project

介绍

本实验分为三个部分.第一部分集中于熟悉x86汇编语言,QEMU x86仿真器和PC的开机引导程序.第二部分检查了6.828内核的引导加载程序,该加载程序位于lab目录中的boot目录中.最后,第三部分深入研究了6.828内核本身的初始模板,名为JOS,它位于kernel目录中.

PC引导

第一个练习的目的是向您介绍x86汇编语言和PC引导过程,并使您开始进行QEMU和QEMU/GDB调试.您不必为此练习的任何部分编写任何代码,但是无论如何您都应该仔细阅读一下该代码以获取自己的理解,并准备回答下面提出的问题.

x86汇编入门

练习1. 熟悉6.828参考页上提供的汇编语言材料.您现在不必阅读它们,但是几乎可以肯定的是,在读写x86程序集时,您会希望参考其中的一些内容.我们建议阅读Brennan的《内联汇编指南》中的"语法"部分.它很好地(并且非常简短地)描述了我们将与JOS中的GNU汇编器一起使用的AT&T汇编语法.

当然,x86汇编语言编程的权威参考是英特尔的指令集体系结构参考,您可以在6.828参考页面上找到两种版本:旧版80386程序员参考手册的HTML版本,它比更多版本更短,更易于浏览.最近的手册,但描述了我们将在6.828中使用的所有x86处理器功能;以及英特尔提供的完整,最新和最出色的IA-32英特尔架构软件开发人员手册,其中涵盖了我们在课堂上不需要的最新处理器的所有功能,但您可能有兴趣了解. AMD可提供一组等效的(且通常更为友好的)手册.要查找特定处理器功能或指令的明确解释,请保存英特尔/ AMD架构手册以供以后使用,或将其用作参考.

x86模拟

我们不是在真实的物理个人计算机(PC)上开发操作系统,而是使用忠实地模拟完整PC的程序:您为模拟器编写的代码也将在真实PC上引导.使用仿真器可简化调试;例如,您可以在仿真的x86内设置断点,这对于x86的物理机器来说很难做到.

在6.828中,我们将使用QEMU仿真器,这是一种现代且相对较快的仿真器.虽然QEMU的内置监视器仅提供有限的调试支持,但QEMU可以充当GNU调试器(GDB)的远程调试目标,我们将在本实验中使用它来逐步完成早期引导过程.

首先,按照上面"软件设置"中所述,将Lab 1文件解压缩到您在linux中的目录中,然后在lab目录中键入make以构建最小的6.828引导加载程序和内核.用. (将我们在此处运行的代码称为"内核"有点慷慨,但是我们会在整个学期中充实它们.)

# cd lab
# 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

(如果出现类似"未定义对’__udivdi3’的引用"之类的错误,则可能没有32位gcc multilib.如果运行的是Debian或Ubuntu,请尝试安装gcc-multilib软件包.)

现在,您可以运行QEMU,并提供上面创建的文件obj/kern/kernel.img,作为仿真PC的"虚拟硬盘"的内容.该硬盘映像包含我们的引导加载程序(obj/boot/boot)和我们的内核(obj/kernel).

make qemu
# 无GUI环境,推荐这种
make qemu-nox

上面的代码将使用设置硬盘并将串行端口直接输出到终端所需的选项执行QEMU.一些文本应出现在QEMU窗口中:

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>

"Booting from Hard Disk…"之后的所有内容均由我们的JOS内核打印;K>是由我们包含在内核中的交互式控制程序打印的提示.如果使用make qemu,内核打印的这些行将同时显示在运行QEMU的常规Shell窗口和QEMU显示窗口中.这是因为出于测试和实验室分级的目的,我们已将JOS内核设置为不仅将其控制台输出写入虚拟VGA显示屏(在QEMU窗口中看到),而且还写入模拟PC的虚拟串行端口(QEMU在其中).将输出转换为自己的标准输出.同样,JOS内核将同时从键盘和串行端口获取输入,因此您可以在VGA显示窗口或运行QEMU的终端上给它命令.或者,您可以通过运行make qemu-nox使用不带虚拟VGA的串行控制台.如果您通过SSH进入连接linux,这可能会很方便.要退出qemu,请按Ctrl + a x.

您只能向内核监视器提供两个命令:obj/kernelkerninfo.

K> help
help - display this list of commands
kerninfo - display information about the kernel
K> kerninfo
Special kernel symbols:
  entry  f010000c (virt)  0010000c (phys)
  etext  f0101a75 (virt)  00101a75 (phys)
  edata  f0112300 (virt)  00112300 (phys)
  end    f0112960 (virt)  00112960 (phys)
Kernel executable memory footprint: 75KB
K>

help命令是显而易见的,我们简单地讨论命令打印内容的含义.尽管很简单,但必须注意,此内核监视器在仿真PC的"原始(虚拟)硬件"上"直接"运行.这意味着您应该能够将obj/kern/kernel.img的内容复制到真实硬盘的前几个扇区中,将该硬盘插入真实PC中,打开它,然后在上面看到完全相同的内容.就像您在QEMU窗口中所做的那样,显示PC的真实屏幕. (不过,我们不建议您在具有硬盘信息的真实计算机上执行此操作,因为将kernel.img复制到其硬盘的开头会有效地破坏主引导记录和第一个分区的开头导致之前硬盘上的所有内容丢失!)

PC的物理地址空间

现在,我们将详细介绍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

首批基于16位Intel 8088处理器的PC只能处理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或网络)加载操作系统,并将计算机的控制权传递给操作系统.

当英特尔最终通过分别支持16MB和4GB物理地址空间的80286和80386处理器"打破1兆字节障碍"时,PC架构师仍然保留了低1MB物理地址空间的原始布局,以确保向后兼容.现有软件.因此,现代PC在从0x000A0000到0x00100000的物理内存中有一个"空洞",将RAM分为"低"或"常规内存"(前640KB)和"扩展内存"(其他所有东西).此外,BIOS现在通常将PC的32位物理地址空间中最顶层的某些空间(高于所有物理RAM)保留给BIOS,以供32位PCI设备使用.

最新的x86处理器可以支持超过4GB的物理RAM,因此RAM可以扩展到0xFFFFFFFF以上.在这种情况下,BIOS必须安排在32位可寻址区域顶部的系统RAM中留下第二个孔,以便为要映射的32位设备留出空间.由于设计上的限制,JOS仍将仅使用PC的前256MB物理内存,因此,到目前为止,我们将假设所有PC都"仅"具有32位物理地址空间.但是,处理复杂的物理地址空间以及经过多年发展的硬件组织的其他方面,是OS开发面临的重要实际挑战之一.

基本输入输出系统(ROM BIOS)

在本部分的实验中,您将使用QEMU的调试工具来研究IA-32兼容计算机的启动方式.

打开两个终端窗口,然后将两个shell都cd入您的实验目录.在其中之一中,输入make qemu-nox-gdb.这将启动QEMU,但是QEMU会在处理器执行第一条指令之前停止并等待来自GDB的调试连接.在第二个终端中,从您运行make的目录开始,运行make gdb.你应该看到这样的东西,

$ 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为什么这样开始?这就是英特尔设计IBM在其原始PC中使用的8088处理器的方式.由于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   # in hex multiplication by 16 is
   = 0xf0000 + 0xfff0     # easy--just append a 0.
   = 0xffff0 

0xffff0是BIOS(0x100000)结束之前的16个字节.因此,BIOS要做的第一件事就是将jmp倒退到BIOS中的较早位置,这个是合理的.毕竟仅16个字节能完成多少工作?

练习2.使用GDB的si(步骤指令)命令跟踪ROM BIOS,以获取更多说明,并尝试猜测它可能在做什么.您可能想要在6.828参考资料页面上查看Phil Storrs I/O端口说明以及其他资料.无需弄清楚所有细节-只需先了解BIOS的总体思路即可.

BIOS运行时,它将建立一个中断描述符表并初始化各种设备,例如VGA显示.这是您在QEMU窗口中看到的"正在启动SeaBIOS"消息的来源.

初始化PCI总线和BIOS知道的所有重要设备后,它将搜索可引导设备,例如软盘,硬盘驱动器或CD-ROM.最终,BIOS在找到可引导磁盘时,会从磁盘读取引导加载程序并将控制权转移给该引导加载程序.

引导加载程序

PC的软盘和硬盘分为512个字节的区域,称为扇区.扇区是磁盘的最小传输粒度:每个读或写操作必须是一个或多个扇区,并且必须在扇区边界上对齐.如果磁盘是可引导的,则第一个扇区称为引导扇区,因为这是引导加载程序代码所在的位置.当BIOS找到可引导的软盘或硬盘时,它将512字节的引导扇区加载到物理地址0x7c00至0x7dff的内存中,然后使用jmp指令将CS:IP设置为0000:7c00,将控制权传递给引导程序装载机.像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以上的所有内存.在Intel体系结构手册中对此进行了详细说明.此时,您只需要了解在保护模式下将分段地址(段:偏移量对)转换为物理地址的方式会有所不同,并且过渡偏移量之后是32位而不是16位.
  • 其次,引导加载程序通过x86的特殊I/O指令直接访问IDE磁盘设备寄存器,从而从硬盘读取内核.如果您想更好地理解此处特定I/O指令的含义,请查看6.828参考页上的IDE硬盘控制器部分.您无需在此类中学习很多有关对特定设备进行编程的知识:实际上,编写设备驱动程序是OS开发中非常重要的一部分,但是从概念或体系结构的角度来看,这也是最不有趣的事情之一.

了解了引导加载程序的源代码之后,请查看文件obj/boot/boot.asm.该文件是我们的GNUmakefile在编译引导加载程序后创建的引导加载程序的反汇编.通过该反汇编文件,可以轻松地准确地查看所有引导加载程序代码在物理内存中的位置,并且可以更轻松地跟踪在GDB中逐步引导加载程序时发生的情况.同样,obj/kern/kernel.asm包含一个对JOS内核的反汇编,通常对于调试很有用.

您可以使用b命令在GDB中设置地址断点.例如,b* 0x7c00在地址0x7C00处设置一个断点.到达断点后,可以使用c和si命令继续执行:c使QEMU继续执行直到下一个断点(或直到您在GDB中按Ctrl-C)为止,并且si N一次遍历指令N.

要检查内存中的指令(除了即将执行的下一条指令,GDB会自动打印出该指令),请使用x /i命令.该命令的语法为x /Ni ADDR,其中N是要反汇编的连续指令数,而ADDR是要开始反汇编的内存地址.

练习3.
查看实验工具指南,尤其是有关GDB命令的部分.即使您熟悉GDB,它也包含一些对OS工作有用的深奥的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循环的开始和结束.找出循环结束后将运行的代码,在其中设置一个断点,然后继续该断点.然后逐步完成引导加载程序的其余部分.

回答以下问题:

  1. 处理器从什么时候开始执行32位代码?究竟是什么原因导致从16位模式切换到32位模式?
  2. 引导加载程序执行的最后一条指令是什么,刚加载的内核的第一条指令是什么?
  3. 内核的第一条指令在哪里?
  4. 引导加载程序如何确定必须读取多少个扇区才能从磁盘获取整个内核?它在哪里找到此信息?

练习三答案

  1. ljmp $PROT_MODE_CSEG, $protcseg这条指令开始执行32位代码;movl %cr0, %eax这条指令把cr0寄存器的最后一位,即PE位打开。也就是开启了保护模式。CPU也就从这里开始进入到了32位模式。
  2. 最后一条指令为((void (*)(void)) (ELFHDR->e_entry))();,刚加载内核的第一条指令为movw $0x1234,0x472 # warm boot
  3. 内核的第一条指令位于kern/entry.S
  4. 详细见bootmain中的注释,会涉及到较多的ELF头文件格式信息.
加载内核

现在,我们将在boot/main.c中进一步详细介绍引导加载程序的C语言部分.但是在这样做之前,现在是停止并回顾C编程的一些基础知识的好时机.

练习4. 阅读有关使用C进行指针编程的信息.最好的参考语言是Brian Kernighan和Dennis Ritchie编写的C语言(称为"K&R").我们建议学生购买本书(这里是Amazon Link)或查找MIT的7本书之一.阅读K&R中的5.1(指针和地址)至5.5(字符指针和功能).然后下载pointers.c的代码,运行它,并确保您了解所有打印值的来源.特别是,请确保您了解打印线1和6中的指针地址来自何处,打印线2至4中的所有值如何到达那里,以及为什么看似在第5行中打印的值被破坏.尽管没有强烈建议,但在C语言中还有其他关于指针的参考(例如Ted Jensen的教程大量引用了K&R).警告:除非您已经完全精通C语言,否则请勿跳过甚至略读此阅读练习.如果您真的不了解C中的指针,那么您将在以后的实验中遭受难以言喻的痛苦和痛苦,然后最终会发现这是一条艰难的道路.相信我们;您不想发现"艰难的道路"是什么.

为了理解boot/main.c,您需要知道什么是ELF二进制文件.当您编译并链接诸如JOS内核的C程序时,编译器会将每个C源文件(.c)转换为对象文件(.o),该文件包含以硬件期望的二进制格式编码的汇编语言指令. .然后,链接器将所有已编译的目标文件组合成单个二进制映像,例如obj/kern/kernel,在这种情况下,该映像是ELF格式的二进制文件,代表"可执行和可链接格式".

有关此格式的完整信息,请参见我们的参考页上的ELF规范,但是您无需在此类中深入研究此格式的详细信息。尽管总体上来说,格式非常强大且复杂,但是大多数复杂的部分都是用于支持共享库的动态加载的,而我们在此类中不会这样做。 Wikipedia页面上有简短描述。

对于6.828,可以将ELF可执行文件视为带有加载信息的标头,然后是几个程序段,每个程序段都是要在指定地址加载到内存中的连续代码或数据块。引导加载程序不会修改代码或数据;它将其加载到内存中并开始执行它。

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

  • .text: 程序的可执行指令。
  • .rodata: 只读数据,例如C编译器生成的ASCII字符串常量。 (但是,我们不会麻烦设置硬件来禁止写入。)
  • .data: 数据部分保存程序的初始化数据,例如,使用int x = 5的初始化程序声明的全局变量;

链接器计算程序的内存布局时,会在名为.bss的节中紧随内存中…data的部分中为未初始化的全局变量(例如int x;)保留空间。 C要求"未初始化的"全局变量以零值开头。因此,无需在ELF二进制文件中存储.bss的内容。而是,链接器仅记录.bss节的地址和大小。加载程序或程序本身必须将.bss节清零。

通过键入下列代码,检查内核可执行文件中所有部分的名称,大小和链接地址的完整列表:

objdump -h obj/kern/kernel

执行结果为:

您会看到比上面列出的部分更多的部分,但是其他部分对于我们的目的并不重要。其他大多数将保留调试信息,该信息通常包含在程序的可执行文件中,但不会由程序加载器加载到内存中。
请特别注意.text部分的"VMA"(或链接地址)和"LMA"(或加载地址)。段的加载地址是该段应加载到内存中的内存地址。

节的链接地址是该节期望从中执行的内存地址。链接器以各种方式对二进制文件中的链接地址进行编码,例如当代码需要全局变量的地址时,结果是,如果二进制文件是从未为其链接的地址执行的,则二进制文件通常不起作用。 (有可能生成不包含任何此类绝对地址的与位置无关的代码。现代共享库已广泛使用此代码,但是它具有性能和复杂性成本,因此我们不会在6.828中使用它。)

通常,链接和加载地址是相同的。例如,查看引导加载程序的.text部分:

objdump -h obj/boot/boot.out

引导加载程序使用ELF程序标头来决定如何加载这些部分。程序标头指定要加载到内存中的ELF对象的哪些部分以及每个目标地址应占据的位置。您可以通过键入以下命令检查程序头:

objdump -x obj/kern/kernel

结果如下图:

然后,程序头在objdump的输出中的"程序头"下列出。 ELF对象的需要加载到内存中的区域是标记为"LOAD"的区域。给出了每个程序头的其他信息,例如虚拟地址(“vaddr”),物理地址(“paddr”)和加载区域的大小(“memsz"和"filesz”)。

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

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

练习5。再次查找引导加载程序的前几条指令,并确定如果要弄错引导加载程序的链接地址,则会"中断"或执​​行其他错误操作的第一条指令。然后将boot/Makefrag中的链接地址更改为错误的地址,运行make clean,使用make重新编译实验室,然后再次跟踪到引导加载程序以了解发生了什么。别忘了改回链接地址,然后再次清理!

回顾一下内核的负载和链接地址。与引导加载程序不同,这两个地址不同:内核告诉引导加载程序以低地址(1兆字节)将其加载到内存中,但是希望从高地址执行。在下一节中,我们将深入探讨如何进行这项工作。

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

objdump -f obj/kern/kernel

执行结果如下图:
现在,您应该能够在boot/main.c中了解最小的ELF加载器。它从磁盘读取内核的每个部分到该部分的加载地址处的内存中,然后跳转到内核的入口点。

练习6。我们可以使用GDB的x命令检查内存。 GDB手册有完整的细节,但是就目前而言,足以知道命令x/Nx ADDR在ADDR上打印N个字的内存。 (请注意,命令中的两个x均为小写。)警告:单词的大小不是通用标准。在GNU汇编中,一个单词是两个字节(xorw中的"w"代表单词,表示2个字节)。重置机器(退出QEMU/GDB并重新启动它们)。在BIOS进入引导加载程序时,检查0x00100000处的8个内存字,然后在引导加载程序进入内核时再次检查。他们为什么不同?第二个断点有什么? (您实际上并不需要使用QEMU来回答这个问题。只需考虑一下即可。)

练习6答案
第一次全为0,因为内核未加载;第二次的值如下所示

内核

现在,我们将开始更详细地研究最小的JOS内核。 (您最终将可以编写一些代码!)。像引导加载程序一样,内核从一些汇编语言代码开始,这些代码对代码进行设置,以便C语言代码可以正确执行。

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

当您检查上面的引导加载程序的链接和加载地址时,它们完美匹配,但是内核的链接地址(由objdump打印)与加载地址之间存在(相当大的)差异。返回并检查两者,确保您可以看到我们在说什么。 (链接内核比引导加载程序更为复杂,因此链接和加载地址位于kern/kernel.ld的顶部。)

操作系统内核通常喜欢链接并在很高的虚拟地址(例如0xf0100000,为了将处理器的虚拟地址空间的下部留给用户程序使用。这种安排的原因在下一个实验中将变得更加清楚。

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

实际上,在下一个实验中,我们将映射从物理地址0x00000000到0x0fffffff到虚拟地址0xf0000000到0xffffffff,PC的整个底部256MB物理地址空间。

现在,您应该明白为什么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版本)。

练习7.使用QEMU和GDB跟踪到JOS内核,并在movl %eax, %cr0处停止。检查0x00100000和0xf0100000的内存。现在,使用stepi GDB命令单步执行该指令。同样,检查内存为0x00100000和0xf0100000。确保您了解刚刚发生的事情。建立新映射后的第一条指令是什么,如果没有正确的映射将无法正常工作?注释掉kern/entry.S中的%eax动画,%cr0,跟踪它,然后看您是否正确。

格式化打印到控制台

大多数人都将诸如printf()之类的功能视为理所当然,有时甚至将它们视为C语言的"原始"。但是在OS内核中,我们必须自己实现所有I/O。

通读kern/printf.c,lib/printfmt.c和kern/console.c,并确保您了解它们之间的关系。在以后的实验中将清楚为什么printfmt.c位于单独的lib目录中。

练习8.我们省略了一小段代码-使用"%o"形式的模式打印八进制数字所必需的代码。查找并填写此代码片段。

回答以下问题:

  1. 解释printf.c和console.c之间的接口。具体来说,console.c导出什么功能? printf.c如何使用此功能?

答: printf里的调用链为:cprintf -> vcprintf -> vprintfmt -> putch -> cputchar,cputchar位于console.c中.

  1. 从console.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_pos -= CRT_COLS;
}

答: 这段代码的作用就是在当写满一个屏幕的时候,把整个字符串往上滚动一行。具体如注释.

  1. 对于以下问题,您可能希望查阅第2讲的注释。这些注释涵盖了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,列出其两个参数的值。

答: 在对cprintf()的调用中,fmt指向左边第一个字符串, ap指向x,y,z参数.

  1. 运行下列代码会输出什么?
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);

答:57616的16进制为e110,i转为char[4]数组为{‘r’,‘l’,‘d’,0},所以输出为He110 World.这个程序跟系统的大端模式和小端模式是相关的.大端模式即高字节在低地址,低字节在高地址,这里即小端模式,因为低字节在低地址,高字节在高地址.

  1. 下列代码输出啥?
cprintf("x=%d y=%d", 3);

答:不清楚,越界了.

练习9. 确定内核在哪里初始化其堆栈,以及确切地在其内存中位于堆栈的位置。内核如何为其堆栈保留空间?并在此保留区的哪个“末端”初始化堆栈指针指向?

答: 初始化栈即执行bootmain函数调用的前一句movl $start, %esp,栈位于内存中的位置如memlayout.h所示,有关esp和ebp的知识可参见链接


/*
 * Virtual memory map:                                Permissions
 *                                                    kernel/user
 *
 *    4 Gig -------->  +------------------------------+
 *                     |                              | RW/--
 *                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 *                     :              .               :
 *                     :              .               :
 *                     :              .               :
 *                     |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
 *                     |                              | RW/--
 *                     |   Remapped Physical Memory   | RW/--
 *                     |                              | RW/--
 *    KERNBASE, ---->  +------------------------------+ 0xf0000000      --+
 *    KSTACKTOP        |     CPU0's Kernel Stack      | RW/--  KSTKSIZE   |
 *                     | - - - - - - - - - - - - - - -|                   |
 *                     |      Invalid Memory (*)      | --/--  KSTKGAP    |
 *                     +------------------------------+                   |
 *                     |     CPU1's Kernel Stack      | RW/--  KSTKSIZE   |
 *                     | - - - - - - - - - - - - - - -|                 PTSIZE
 *                     |      Invalid Memory (*)      | --/--  KSTKGAP    |
 *                     +------------------------------+                   |
 *                     :              .               :                   |
 *                     :              .               :                   |
 *    MMIOLIM ------>  +------------------------------+ 0xefc00000      --+
 *                     |       Memory-mapped I/O      | RW/--  PTSIZE
 * ULIM, MMIOBASE -->  +------------------------------+ 0xef800000
 *                     |  Cur. Page Table (User R-)   | R-/R-  PTSIZE
 *    UVPT      ---->  +------------------------------+ 0xef400000
 *                     |          RO PAGES            | R-/R-  PTSIZE
 *    UPAGES    ---->  +------------------------------+ 0xef000000
 *                     |           RO ENVS            | R-/R-  PTSIZE
 * UTOP,UENVS ------>  +------------------------------+ 0xeec00000
 * UXSTACKTOP -/       |     User Exception Stack     | RW/RW  PGSIZE
 *                     +------------------------------+ 0xeebff000
 *                     |       Empty Memory (*)       | --/--  PGSIZE
 *    USTACKTOP  --->  +------------------------------+ 0xeebfe000
 *                     |      Normal User Stack       | RW/RW  PGSIZE
 *                     +------------------------------+ 0xeebfd000
 *                     |                              |
 *                     |                              |
 *                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 *                     .                              .
 *                     .                              .
 *                     .                              .
 *                     |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
 *                     |     Program Data & Heap      |
 *    UTEXT -------->  +------------------------------+ 0x00800000
 *    PFTEMP ------->  |       Empty Memory (*)       |        PTSIZE
 *                     |                              |
 *    UTEMP -------->  +------------------------------+ 0x00400000      --+
 *                     |       Empty Memory (*)       |                   |
 *                     | - - - - - - - - - - - - - - -|                   |
 *                     |  User STAB Data (optional)   |                 PTSIZE
 *    USTABDATA ---->  +------------------------------+ 0x00200000        |
 *                     |       Empty Memory (*)       |                   |
 *    0 ------------>  +------------------------------+                 --+
 *
 * (*) Note: The kernel ensures that "Invalid Memory" is *never* mapped.
 *     "Empty Memory" is normally unmapped, but user programs may map pages
 *     there if desired.  JOS user programs map pages temporarily at UTEMP.
 */

练习10. 为了熟悉C函数调用的转换。可以在obj/kern/kernel.asm中找到test_backtrace函数。在那里设置一个断点。然后验证每次调用的时候发生了什么。进一步的,您应该调用mon_backtrace()。此功能的原型已经在kern / monitor.c中等待着您。您可以完全在C中完成此操作,但是您可能会发现inc / x86.h中的read_ebp()函数很有用。您还必须将此新函数挂接到内核监视器的命令列表中,以便用户可以交互地调用它。

每行包含一个ebp,eip和args。 ebp值指示该函数使用的堆栈中的基本指针:即,刚进入函数并设置基本指针的函数序言之后的堆栈指针的位置。列出的eip值是函数的返回指令指针:函数返回时控件将返回到的指令地址。返回指令指针通常指向调用指令之后的指令(为什么?)。最后,在args后面列出的五个十六进制值是所讨论函数的前五个参数,在调用该函数之前将其压入堆栈。当然,如果调用的函数少于五个参数,那么并非所有这五个值都有用。 (为什么回溯代码无法检测到实际有多少个参数?如何解决此限制?)

打印的第一行反映了当前正在执行的函数,即mon_backtrace本身,第二行反映了称为mon_backtrace的函数,第三行反映了调用该函数的函数,依此类推。您应该打印所有未完成的堆栈帧。通过研究内核/入口,您会发现有一种简单的方法可以告诉您何时停止。

练习11 完成mon_backtrace()函数

补充kern下的monitor.c文件中的mon_backtrace即可,如下所示

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	 uint32_t ebp = read_ebp();
  #define TO_INT(x)  *((uint32_t*)(x))
  cprintf("Stack backtrace:\n");
  while (ebp) {
    // ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
    cprintf("ebp %08x  eip %08x  args %08x %08x %08x %08x %08x\n",
            ebp,       /*ebp*/
            TO_INT((ebp+4)),   /*eip*/
            TO_INT((ebp+8)),   /*arg1*/
            TO_INT((ebp+12)),  /*arg2*/
            TO_INT((ebp+16)),  /*arg3*/
            TO_INT((ebp+20)),  /*arg4*/
            TO_INT((ebp+24)));  /*arg5*/
    ebp = TO_INT(ebp);
  }
	return 0;
}

练习12 完善mon_backtrace,使其可打印出与eip相关的文件名和行数.

如下所示

// kdebug.c中debuginfo_eip所需完成的代码
int olline = lline, orline = rline;
stab_binsearch(stabs, &olline, &orline, N_SOL, (!(lline == lfile && rline == rfile))*addr + info->eip_fn_addr);

if(olline>orline){
  stab_binsearch(stabs,&lline,&rline,N_SLINE,addr);
  // 如果在N_SLINE也没有找到
  if (lline>rline) {
    return -1;
  }
}
// 记录找到的行号
info->eip_line=stabs[lline].n_desc;

// monitor.h所需完成的代码
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	uint32_t ebp = read_ebp();
	uint32_t eip=0;
	struct Eipdebuginfo info;
  	#define TO_INT(x)  *((uint32_t*)(x))
	
  	cprintf("Stack backtrace:\n");
  	while (ebp) {
		  eip=TO_INT((ebp+4));
    // ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
    	cprintf("ebp %08x  eip %08x  args %08x %08x %08x %08x %08x\n",
            ebp,       /*ebp*/
            TO_INT((ebp+4)),   /*eip*/
            TO_INT((ebp+8)),   /*arg1*/
            TO_INT((ebp+12)),  /*arg2*/
            TO_INT((ebp+16)),  /*arg3*/
            TO_INT((ebp+20)),  /*arg4*/
            TO_INT((ebp+24)));  /*arg5*/
		
		if(!debuginfo_eip(eip,&info)) {
			cprintf("%s:%d: %.*s+%d\n",
				info.eip_file,
				info.eip_line,
				info.eip_fn_namelen,
				info.eip_fn_name,
				eip - info.eip_fn_addr);
		}
    	ebp = TO_INT(ebp);
  	}
	return 0;
}
结束

最后使用make grade命令即可对lab1所有要求代码进行评分.

评分满分为50,全通过后通过git提交.然后使用以下命令开始lab2的探索.

// 创建分支lab2
git checkout -b lab2

参考


我很好奇