简介
MIT6.828 Operating System Engineering
6.828的目标是:1. 理解操作系统设计和实现、2. 实现小型操作系统
OS的目的是:1. 支持应用程序运行、2. 对硬件进行抽象、3. 在多个应用程序之间复用硬件、4. 隔离应用程序防止bugs、5. 允许共用多个应用程序、6. 高性能
操作系统内核一般提供:进程、内存分配、file contents、目录和文件名、安全、以及其他的例如,用户,IPC,网络,计时,终端
操作系统的抽象让应用程序只看到系统调用。
我实验使用的是 ubuntu-16.04.6-desktop-i386
Lab1: Booting a PC
Introduction
这个实验分为三个部分。第一部分会专注于熟悉x86汇编语言、QEMU x86模拟器、PC的开机引导程序。第二部分检查了我们的6.828内核的引导程序,该引导程序位于lab树的boot文件夹中。最后,第三部分深入研究6.828内核的初始模板,我们把它叫做JOS,它位于kernel文件夹中。
Software Setup
1 | mkdir ~/6.828 |
然后配置一下git,生成ssh密钥
1 | ssh-keygen -t rsa -C "ris.qscf@gmail.com" |
然后登陆github打开Account settings -> SSH Keys 点击Add SSH Key填上Title,在Key文本框里粘贴id_rsa.pub的文件内容[需要全部复制进去]。(/home/lin/.ssh/id_rsa.pub.)
1 | git remote add origin https://github.com/risuxx/JOS.git |
完成配置之后成功上传到github,由于该实验需要切换分支,而将remote库与自己的库绑定后无法切换分支,所以可以先不要这么做,等最终实验完成时再进行提交。
Simulating the x86
在6.828中我们将使用 QEMU Emulator。QEMU内建监视器只提供有限的debug支持,QEMU可以作为GDB的远程调试目标,我们将在这个lab中一步步的实现一个简单的启动程序。
首先我们安装qemu
1 | sudo apt-get install qemu |
然后编译我们的lab1
1 | cd lab |
假如需要在没有虚拟VGA的情况下使用串行控制台则输入
1 | make qemu-nox |
如果需要退出qemu输入(注意需要在ubuntu的shell中输入)
1 | Ctrl+a x |
1 | help |
现在只有两条命令:help用来查看有哪些命令、kerninfo用来展示kernel的信息。
如果将obj/kern/kernel.img的内容复制到真实的硬盘的前几个扇区中,并且在真实的PC中打开它,会看到和QEMU中相同的内容(但是不建议进行这样的操作,因为复制kernel.img进入硬盘的开头将会破坏主引导记录和第一个分区的开头,从而导致硬盘上的所有内容丢失。)
ROM BIOS
在本部分的实验中,您将使用QEMU的调试工具来研究IA-32兼容计算机的启动方式。
打开两个terminal窗口并且这两个shell都进入lab目录。在其中一个窗口中输入make qemu-gdb(or make qemu-nox-gdb)
。这个操作会启动QEMU但是QEMU会在执行第一个指令之前停下来,并且等待与GDB的连接。在第二个terminal中,在同一个文件夹下需要执行make
命令,然后执行make gdb
。你应该会看到如下的样子。
1 | lin@lin-virtual-machine:~/6.828/lab$ make |
我们提供了一个.gdbinit
文件去设置GDB,让它在启动过程中去debug16位的代码,并且attach和监听QEMU。
下面的一行:
1 | [f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b |
是GDB反汇编的第一个要被执行的命令。从这个输出中你可以确信一些事情:
- IBM的PC从物理地址
0x000ffff0
开始执行指令,这个地址是保留给ROM BIOS的64KB区域的头部。 - PC开始执行的时候
CS = 0xf000
以及IP = 0xfff0
- 第一个要被执行的指令是一个
jmp
指令,将要跳转到的地址是CS = 0xf000
和IP = 0xe05b
为什么QEMU是像这样启动的?Intel是这样设计8088处理器的。因为在PC中BIOS是一个物理地址为0x000f000-0x000fffff
的hard-wired,这个设计确保了BIOS总是能在机器重启之后被控制,因为在起电后RAM中没有可以被执行的软件代码。
The PC's Physical Address Space
1 |
|
Exercise2
使用GDB的si
指令去追踪几个BIOS执行的指令,并且尝试猜测它们在做什么。你可能想要看 Phil Storrs I/O Ports Description或者其他的资料 6.828 reference materials page。不需要弄清楚所有的细节--只需要有一个大致的了解。
1 | [f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b |
cli
屏蔽中断,cld
控制字符流向,然后与IO设备交互进行一些初始化,打开A20,之后使用lidtw
和lgdtw
加载idtr gdtr寄存器用来管理中断向量表以及进程表。之后设置cr0寄存器进入实模式,长跳转到内核。
当BIOS运行的时候,它设置了一个中断描述表以及初始化了一些设备例如VGA显示器。此时你能在QEMU窗口看到"Starting SeaBIOS"的提示。
在初始化PCI总线和所有BIOS知道的核心设备之后,它会茶轴可以启动的设备例如floppy,硬盘驱动,CD-ROM。最终当它找到可启动的硬盘之后,BIOS会从硬盘中读取boot loader,并跳转到boot loader中。
Part 2: The Boot Loader
Floppy和硬盘被划分为512b的区域,被称为sectors(扇区)。一个扇区是磁盘的最小传输单元:每一次读写操作大小必须是一个或者更多扇区那么大,并且要按照扇区的边界对齐。假如磁盘是可启动的,第一个扇区被称为启动扇区,因为boot loader的代码位于这一区域。当BIOS寻找一个可启动的floppy或者硬盘,它会将512b的启动扇区装载到内存中放到0x7c00-0x7dff
上,之后使用jmp
指令设置CS:IP为0000:7c00
来将控制权交给boot loader。
从CD-ROM上启动的能力在PC发展到很晚的时候才出现,结果是PC结构拥有了一个能够略微重想启动过程的机会。因此现代BIOS从CD-ROM中启动由一些复杂。CD-ROMS使用2048b作为一个扇区大小而不是512b。BIOS能够在转移控制权之前,从磁盘中装载更大的boot image到内存中(而不仅仅是一个扇区)。
但是在6.828中,我们使用传统的硬盘驱动启动方式,也就意味这我们的boot loader必须小于512b。boot loader由一个汇编语言源文件boot/boot.S和一个C源文件boot/main.c组成。boot loader必须有两个主要功能。
- boot loader将处理器转换为32位保护模式,因为在实模式中软件只能访问1MB的物理空间。保护模式在PC Assembly Language的1.2.7和1.2.8节有简要描述。你必须理解在保护模式中段地址与物理地址的差异。
- boot loader使用x86的IO指令访问IDE磁盘设备寄存器,从硬盘上直接读取内核。
在你理解了boot loader的源码之后,看文件obj/boot/boot.asm。这个文件是boot loader的汇编形式。通过这个汇编文件可以更加情绪的看到所有的boot loader代码位于物理内存的哪个地方。另外obj/kern/kernel.asm包含了JOS内核的汇编代码,它在debugging的时候经常是很有用的。
Exercise3
看一下 lab tools guide,特别是GDB命令的章节。即使你很熟悉GDB,这中间也包含了一些在OS中有用但是不常见的命令。
GDB
在0x7c00
处打断点,这个地址是boot sector被装载的地址。跟踪调试boot/boot.S使用obj/boot/boot.asm的代码去查看你调试到哪里了。使用x/i
命令去比较反汇编的代码和原始的obj/boot/boot.asm中的代码的区别。
跟踪调试boot/main.c
中的bootmain()
,之后进入readsect()
。辨别与readsect()
中语句相关的汇编语句。之后跟踪剩下的readsect()
并且回到bootmain()
中,并且确定for循环的开始和结束,这个for循环会将内核剩余的部分从硬盘中读出。查看在loop循环结束之后什么代码将会执行,在那个位置打断点,继续到断点处。之后步出剩下的boot loader代码。
开启一个terminal执行make qemu-gdb
然后在同一个文件夹下(lab下)执行make gdb
。然后再gdb中设置断点b *0x7c00
。代码的详细分析参考https://www.cnblogs.com/fatsheep9146/p/5115086.html
需要能够回答下面的问题:
什么时候处理器开始执行32位代码?如何从16位跳转到的32位?
1
2
3
4
5
6
7
8
9
10
11
12# 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
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg将cr0寄存器最低位设置为1就能从16位转为32位模式,之后使用长跳转。
boot loader执行的最后一个命令是什么?内核被装载之后的第一个命令是什么?
1
((void (*)(void)) (ELFHDR->e_entry))(); // 最后一个命令,该命令用来调用内核的第一个指令
第一条指令位于/kern/entry.S文件中,第一句
movw $0x1234, 0x472
内核的第一个命令在哪?
第一条指令位于/kern/entry.S文件中。
boot loader如何知道要从硬盘中读取多少扇区才能将内核从磁盘中取出?从哪里找到这个信息?
查找Program Header Table
1 | byte inb(word port); // 从I/O端口读取一个字节 |
Loading the Kernel
Exercise4
关于C语言的指针编程,最好的参考是Brian Kernighan和Dennis Ritchie写的The C Programming Language(被称为“K&R”)。
从K&R中的5.1(指针和地址)读到5.5(字符指针和函数)。然后下载pointer.c,运行它,并确保您理解所有打印值的来源。特别是,确保您理解打印的第1行和第6行中的指针地址从哪里来,打印的第2行到第4行中的所有值是如何到达那里的,以及为什么在第5行中打印的值看起来是损坏的。
还有其他关于C中的指针的参考资料(例如,Ted Jensen的一个教程中大量引用了K&R),尽管不那么强烈推荐。
警告:除非你已经完全精通C语言,否则不要跳过甚至略读这篇阅读练习。如果您不能真正理解C中的指针,您将在随后的实验中遭受难以言表的痛苦和折磨,然后最终以艰难的方式理解它们。相信我们;你不会想知道“艰难之路”是什么。
1 | // pointers.c |
要理解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: 只读数据段,例如字符串常量。(但是,我们不会费心设置硬件来禁止写入。)
- .data : 存放已经初始化的数据,例如初始化的全局变量例如
int x = 5;
- .bss : 存放未初始化的变量, 但是在ELF中只需要记录.bss的起始地址和长度。
Loader
andprogram
必须自己将.bss段清零。
通过下面的命令来检查内核可执行文件中所有部分的名称、大小和链接地址的完整列表:
1 | objdump -h obj/kern/kernel |
BIOS从地址0x7c00开始将引导扇区加载到内存中,因此这是引导扇区的加载地址。这也是引导扇区执行的地方,所以这也是它的链接地址。我们通过将 -Ttext 0x7C00
传递给boot/Makefrag中的链接器来设置链接地址,因此链接器将在生成的代码中生成正确的内存地址。
Exercise5
再次跟踪引导加载程序的前几条指令,并确定第一条指令,如果引导加载程序的链接地址错误,它将“中断”或执行错误操作。然后将boot/Makefrag中的链接地址更改为错误,运行make clean,用make重新编译实验室,并再次跟踪引导加载程序,看看会发生什么。别忘了把链接地址改回来,然后make clean!
将0x7c00修改为0x7000,然后进行make clean
似乎没有发生什么事情,然后试着将链接地址改为0x7cc0之后启动的提示句子反复在屏幕中出现
回头看看内核的加载和链接地址。与引导加载程序不同,这两个地址并不相同:内核告诉引导加载程序以低地址(1兆字节)将其加载到内存中,但它期望从高地址执行。在下一节中,我们将深入研究如何使其工作。
除了片段信息之外,ELF头中还有一个对我们很重要的字段,即e_entry。这个字段保存程序入口点的链接地址:程序文本部分的内存地址,程序应该在这里开始执行。你可以看到入口点:
1 | objdump -f obj/kern/kernel |
您现在应该能够理解boot/main.c中的最小ELF加载程序了。它将内核的每个部分从磁盘读入内存,读入该部分的加载地址,然后跳到内核的入口点。
Exercise6
重置机器(退出QEMU/GDB并再次启动它们)。在BIOS进入引导加载程序时,然后在引导加载程序进入内核时,检查0x00100000处的8个单词的内存。为什么会有不同?第二个断点是什么?(您实际上并不需要使用QEMU来回答这个问题。只是觉得)。
在进入boot loader的时候都是0,因为此时还是在实模式,之后进入kernel的时候已经有值了。
Part 3: The Kernel
现在,我们将开始更详细地研究最小JOS内核。(您终于可以编写一些代码了!)与引导加载程序一样,内核从一些汇编语言代码开始,这些代码进行了设置,以便后面的C语言代码能够正确执行。
使用虚拟内存来解决位置依赖 当您检查上面的引导加载程序的链接和加载地址时,它们完全匹配,但是内核的链接地址(由objdump打印)和它的加载地址之间存在(相当大的)差异。回去检查一下,确保你能明白我们在说什么。(链接内核比引导加载程序更复杂,因此链接地址和加载地址位于kern/kernel.ld的顶部。)
操作系统内核通常喜欢在非常高的虚拟地址(比如0xf0100000)上链接和运行,以便将处理器虚拟地址空间的较低部分留给用户程序使用。这样安排的原因在下一个实验室会更清楚。
许多机器在地址0xf0100000处没有任何物理内存,因此我们不能指望能够在那里存储内核。相反,我们将使用处理器的内存管理硬件来将虚拟地址0xf0100000(内核代码预期运行的链接地址)映射到物理地址0x00100000(引导加载程序将内核加载到物理内存的位置)。这种方式,虽然内核虚拟地址高的足以让很多用户进程的地址空间,它将被加载到物理内存中1 mb的电脑的内存,略高于BIOS芯片。这种方法要求个人电脑至少有几兆字节的物理内存(物理地址0 x00100000作品),但是这可能是真正的电脑大约1990之后建立的。
实际上,在下一个实验室中,我们将分别映射PC的整个底层256MB物理地址空间,从物理地址0x00000000到0x0fffffff,到虚拟地址0xf0000000到0xffffffff。现在您应该看到为什么JOS只能使用前256MB的物理内存。
现在,我们只映射前4MB的物理内存,这将足以让我们启动并运行。我们使用kern/entrypgdir.c中手工编写的静态初始化页目录和页表来完成此操作。现在,您不需要了解它的工作细节,只需要了解它所实现的效果。直到kern/条目。当设置CR0_PG标志时,内存引用被视为物理地址(严格地说,它们是线性地址,但是是引导/引导)。我们建立了一个从线性地址到物理地址的身份映射,我们永远不会改变它)。一旦设置了CR0_PG,内存引用就是虚拟地址,虚拟内存硬件会将其转换为物理地址。entry_pgdir将范围0xf0000000到0xf0400000的虚拟地址转换为物理地址0x00000000到0x00400000,以及虚拟地址0x00000000到0x00400000的物理地址0x00000000。任何不在这两个范围内的虚拟地址都将导致硬件异常,因为我们还没有设置中断处理,这将导致QEMU转储机器状态并退出(或者,如果不使用补丁版本为6.828的QEMU,则会无休止地重新启动)(这里应该就是之前练习中修改链接地址导致不断重新启动的原因,因为我没有使用补丁版本的QEMU)。
Exercise7
使用QEMU和GDB跟踪jo内核,并在movl %eax, %cr0
处停止。检查0x00100000和0xf0100000处的内存。现在,使用stepi
GDB命令完成该指令的单步操作。同样,检查0x00100000和0xf0100000处的内存。确保你明白刚才发生了什么。
建立新映射后的第一条指令是什么?如果映射不到位,它将不能正常工作?注释掉内核/条目中的movl %eax, %cr0。追踪它,看看你是否猜对了。
首先启动qemu和gdb然后在0x100025
处下断点,该地址就是movl %eax, %cr0
在注释movl %eax, %cr0之后执行$relocated有关的指令会出错,因为这个地址是段地址+偏移,如果没有映射那么段地址就是0xf0100008,而实际地址并没有这个地址。
Formatted Printing to the Console
大多数人认为像printf()这样的函数是理所当然的,有时甚至认为它们是C语言的“原语”。但是在操作系统内核中,我们必须自己实现所有的I/O。
通读kern/printf.c,lib/printfmt.c和kern/console.c。确保你理解他们的关系。在以后的实验中,将会清楚为什么printfmt.c位于单独的lib目录中。
Exercise8
我们省略了一小段代码——使用“%o”形式的模式打印八进制数所必需的代码。查找并填充此代码片段。
1 | if (crt_pos >= CRT_SIZE) { |
crt_pos:当前输出位置指针,指向内存区中对应输出映射地址。
CRT_SIZE:是CRT_COLS和CRT_ROWS的乘积,即2000=80*25,是不翻页时一页屏幕最大能容纳的字数
crt_buf:输出缓冲区内存映射地址
CRT_COLS:默认输出格式下整个屏幕的列数,为80
CRT_ROWS:默认输出格式下整个屏幕的行数,为25
unit16_t:typedef unsigned short 正好两字节,可以分别用来表示当前要打印的字符ASCII码和打印格式属性。
函数:
memmove(): memmove(void dst, const void src, size_t n).意为将从src指向位置起的n字节数据送到dst指向位置,可以在两个区域重叠时复制。
该功能是向下滚动一行。
The Stack
在这个实验室的最后练习中,我们将更详细地探讨C语言的方式使用x86上的堆栈,并在这一过程中写一个有用的新内核监控功能,输出一个堆栈回溯:保存指令指针(IP)的列表值从嵌套调用指令导致的当前点执行。
Exercise9
确定内核初始化其堆栈的位置,以及它的堆栈在内存中的确切位置。内核如何为它的堆栈保留空间?并且在这个保留区域的“结束”是堆栈指针初始化指向?
https://www.cnblogs.com/fatsheep9146/p/5079177.html
ebp(基指针)寄存器主要根据软件约定与堆栈相关联。在进入C函数时,函数的序言代码通常通过将前一个函数的基指针压入堆栈来保存它,然后在函数运行期间将当前的esp值复制到ebp中。如果所有的功能在程序遵守本公约,在任何给定的点在程序的执行期间,可以追溯链后通过堆栈保存ebp指针和决定什么嵌套的函数调用序列使这个程序中的特定点。此功能可能特别有用,例如,当某个特定函数由于传入了错误的参数而导致断言失败或恐慌时,但您不确定是谁传递了错误的参数。堆栈回溯让您找到出错的函数。
Exercise10
要熟悉x86上的C调用约定,可以在obj/kern/kernel.asm中找到test_backtrace函数的地址。在那里设置一个断点,并检查在内核启动后每次调用断点时会发生什么。
https://blog.csdn.net/amgtgsh3150267/article/details/101834732
上面的练习应该为您提供实现堆栈回溯函数所需的信息,您应该调用mon_backtrace()。这个函数的原型已经在kern/monitor.c中等你了。您可以完全用C语言完成,但是您可能会发现inc/x86.h中的read_ebp()函数很有用。您还必须将这个新函数连接到内核监视器的命令列表中,以便用户可以交互地调用它。
backtrace函数应该以以下格式显示函数调用帧列表:
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值是函数的返回指令指针:当函数返回时,控件将返回的指令地址。rip通常指向call
之后的一行的地址。最后,在args后面列出的5个十六进制值是所讨论的函数的前5个参数,它们将在调用函数之前被压入堆栈。当然,如果调用函数时参数少于5个,那么这5个值并不都有用。(为什么回溯代码不能检测实际有多少个参数?<这个是因为在函数内部代码中需要使用ebp的值来定位参数的位置,假如传入更少的参数会使得难以定位>这个限制是如何被修正的呢?<可以在传递参数的时候同时传递参数的个数,例如main函数的声明>)
打印的第一行反映当前执行的函数,即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程序从不需要在指针和整数之间进行转换,但操作系统经常需要。每当您看到一个涉及到内存地址的加法时,问问自己它是整数加法还是指针加法,并确保所添加的值是否被适当地相乘。
Exercise11
借助x86提供的read_ebp()在kern/monitor.c的mon_backtrace中打印出函数调用的栈中的ebp和eip的信息,实现前面提到的打印ebp eip args的效果
1 | int |
此时,您的backtrace函数应该为您提供导致执行mon_backtrace()的堆栈上的函数调用者的地址。但是,在实践中,您通常希望知道与这些地址对应的函数名。例如,您可能想知道哪些函数可能包含导致内核崩溃的错误。
为了帮助您实现此功能,我们提供了函数debuginfo_eip(),它在符号表中查找eip并返回该地址的调试信息。这个函数在kern/kdebug.c中定义。
Exercise12
kern/kernel.ld中的__STAB_*
分别指定了stab和stabstr段的起始和结束地址
这里解释一下上面这个表的意思:
- Symnum是这个表的索引
- n_type是符号的类型,SO 表示主函数的文件名,SOL 表示包含进的文件名,SLINE 表示代码段的行号,FUN 表示函数的名称
- n_othr目前没被使用,其值固定为0
- n_value表示的是地址。FUN类型中是绝对地址,SLINE的地址表示偏移量它的实际地址等于函数入口地址加上偏移量。
查看init.s
查看符号表在加载内存的时候有没有加载,根据前面的查看的信息可以看到.stabstr加载到了0xf0105871
于是按照如下的方式查看
我们需要利用stab的信息,/lab/kern/kdebug.c/stab_binsearch来找到某个地址对应的行号。我们需要通过插入对stab_binsearch的调用完成/lab/kern/kdebug.c/debuginfo_eip的实现。
然后需要给内核添加backtrace命令,并且在mon_backtrace中添加打印文件名、函数名和行号的功能。
首先在kern/monitor.c中添加命令。
1 | mon_backtrace(int argc, char **argv, struct Trapframe *tf) |
实际效果:
printf("%.*s", length, string) prints at most length characters of string. 因此可以将函数名称后面的字符省略不打印。
lab2: Memory Management
参考资料
https://zhuanlan.zhihu.com/p/74028717