6.828操作系统lab1
Posted rookie0080
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了6.828操作系统lab1相关的知识,希望对你有一定的参考价值。
概述
本实验分为三部分。第一部分集中在熟悉x86汇编语言,QEMU x86模拟器,以及PC的开机启动过程。第二部分考察6.828内核(即JOS)的启动加载器,它位于目录boot中。最后,第三部分深入研究JOS自身的初始模板,它位于目录kern中。我的实验环境是Ubuntu 18.04,安装课程所需要的软件工具很方便。
软件安装
- Git及JOS初始代码仓库
- 安装QEMU(使用课程提供的补丁版本)和GCC,详见课程网站
Part 1: PC启动
熟悉X86汇编语言
课程主页提供了很多x86汇编语言的资料,本人是在读完《x86汇编语言,从实模式到保护模式》[1]一书后再学习此课程的,该书面向使用Intel语法的NASM汇编器,而本课程面向使用AT&A语法的GNU汇编器,二者有一定的区别。但熟悉其中一种语法之后,再阅读另外一种也不成问题,详见文档提供的介绍二者语法差异的材料。在此,也强烈推荐对x86汇编和操作系统基础知识(我指的是感性认识,仅读过操作系统的理论书籍并不算)不熟悉的朋友将这本书作为6.828的先导资料仔细阅读。本实验中老师向学生强调C指针的重要性时有一段话,就像是对没读这本书之前学习6.828时饱受煎熬的我说的,我想把它不加修改地记录在这里:
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.
模拟x86
我们使用QEMU模拟器与GDB调试器的组合,具体安装过程见课程网站。JOS初始模板的代码文件根目录是lab,我们先进入该目录,使用make命令编译JOS,编译完成后还会另外创建文件obj/kern/kernel.img,它是安装有JOS内核和启动加载器的虚拟硬盘映像。接下来我们在lab目录下执行命令make qemu
,让QEMU从该虚拟硬盘启动JOS,效果如图1-1所示。目前我们的JOS内核只能执行help和kerninfo两个命令。
物理地址空间
读过材料[1]后,这一节(以及后面多处)的知识就显得很简单了,也没必要翻译一遍,遂不赘述。
ROM Bios
我们先启用调试功能。在lab目录下,打开两个shell,先在其中一个输入make qemu-gdb
,这会启动虚拟机,但机器在执行第一条指令前停下,等待来自GDB的调试连接。然后,我们在第二个shell中输入make gdb
,开始对QEMU进行调试,效果如图1-2所示。lab目录下有课程提供的.gdbinit文件("."开头的文件默认是不可见的),它设置了GDB调试器的工作方式,我们不深究,总而言之就是可以在图1-2所示的环境下对QEMU虚拟机进行调试了。
图中[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
是GDB对将要执行的下一条机器指令反汇编的结果,该语句的意义和所涉及的相关知识在材料[1]中均有详细讲解,不再赘述。QEMU从BIOS ROM加载BIOS程序,初始化各个硬件设备,并搜寻第一个可启动的存储设备。在找到我们的虚拟硬盘后,QEMU从硬盘中加载启动加载程序,然后把控制权移交给它。
Part 2: 启动加载器
JOS使用传统的硬盘启动机制,所以我们的启动加载程序位于硬盘的第一个扇区内(512字节),它的源代码包括一个汇编语言文件boot/boot.S和一个C语言文件boot/main.c。启动加载程序需要完成两个任务,一是将处理器的工作模式由实模式转为32位保护模式,二是从硬盘加载JOS内核。obj/boot/boot.asm和obj/kern/kernel.asm是在JOS编译时根据编译完成的二进制文件反汇编得到的两个汇编程序,我们在调试时可以结合源文件与这两个汇编文件观察指令执行的情况。相关的GDB调试指令和练习参见课程网页和实验文档。
加载内核(以及ELF格式)
接下来,我们详细考察启动加载程序的C语言部分,即boot/main.c文件。要弄明白这个文件,我们需要了解一部分关于ELF格式的知识。我们只需要大致读一读关于ELF的标准规定[2]就够用了。
ELF是Linux以及其它类Unix系统的一种标准二进制文件格式,用于可执行文件、目标文件(object file)、共享库等(有的资料对object file的定义更广泛,包括以上三种文件[3])。当我们在Linux上编译C程序时,编译器将它们转换成符合硬件要求的二进制目标文件;接着链接器将这些目标文件组合成单个的二进制文件,如obj/kern/kernel,它就是一个具有ELF格式的可执行文件。ELF格式重要且复杂,但我们在课程中只需要用到很少的一部分。图2-1中分别给出了典型的可重定位ELF目标文件与可执行ELF文件的内部组织[2]。在6.828中我们只关注可执行的ELF文件。如图中所示,一个典型的可执行ELF文件最上面是ELF header(固定长度),它标识ELF文件的类型,指示文件内部是如何组织的,包括program header table和section header table(如有的话)的位置和大小等信息;紧随其后的是一个program header table,它对于可执行ELF文件是必须的,该表说明各个segment的相关属性,并指示文件应该如何加载到内存中;对于可执行文件来说,section header table是可选的。我们需要注意,图中所示仅仅是一个可执行ELF文件组织的典型案例。实际上,除了ELF header外,其余部分的分布并不是固定不变的,只要在ELF header中给出文件组织的相关信息即可。另外,一个segment可以包含多个section,二者只存在逻辑上的区别,加载程序以segment为单位。JOS的ELF格式比标准精简了很多,相关格式定义在一个C语言文件inc/elf.h中,我们可以对照材料[2]进行阅读。
我们使用命令objdump -h obj/kern/kernel
来查看内核的ELF可执行文件中各个section的信息,结果如图2-2所示。我们比较关心的是.text,.rodata(read only data)和.data。另外,.bss并不占可执行文件的空间,链接器只将它的地址和大小等信息保存在文件内(未求证是否为section header中),因为该section中保存的数据全是0(比如未初始化和初始化为0的全局变量),所以安排让加载器将文件加载到内存后再分配相应的空间并清零。图中其它的section我们不用关心,它们中的大部分用于调试,并不会加载到内存中。
我们留意图2-2中所示的“VMA”和"LMA"两个字段。LMA(load address),即加载地址,是section加载到内存中的线性地址(当处理器未启用页机制时就是物理地址);VMA(link address),即链接地址,是进程映像在内存中执行时section的线性地址。大部分时候,它们两的值是相同的。材料[4]中给出了一个二者不相同的例子(材料[5]中也给出了一个类似的关于flash存储的例子):在一个基于ROM的系统中经常会出现这样的情况,某个数据section被加载到ROM中,但当程序开始执行时,这个section又会被加载到RAM中。在该情境下,section在ROM中的地址就是加载地址LMA,在RAM中的地址就是链接地址VMA。回到我们的JOS上来,由于操作系统会被加载到低址段,但是通过页机制运行在高址端(这又涉及不少知识,此处不便展开,参见材料[1]),所以此处.text的LMA为0x00100000,VMA为0xf0100000。
注意:所谓内核运行在高址端,是指在内核开始运行时,内核中的指令和数据就已经具有高端的线性地址了;所以内核在开启页机制前只使用物理地址而一定不能使用标号,因为加载到内存后,标号对应的地址是线性地址;而此时页硬件并不会将高端的线性地址映射到内核实际所在的物理地址的低址区域。因此,千万不能以为在开启页机制前,内核的线性地址就是低址区域的。图2-3很好地展示了这一点,在kernel.asm中,每一条指令的前面显示的都是指令加载到内存后的线性地址,可以看到它们位于VMA指示的0xf0100000之上。 Exersice 5就在引导我们思考这个问题。
进入练习5之前,实验文档告诉我们:在/boot/Makefrag文件中,通过传递给链接器-TExt 0x7c00
这个参数,使得链接器能够在生成的代码中产生正确的内存地址,练习让我们试图修改这个参数,看看boot loader运行是否会出错。这里所说的“内存地址”是指线性地址,因为BIOS默认会把boot loader加载到物理地址0x7c00并从该处开始执行;如果参数指示的是物理地址,那么进入boot loader后第一条指令就会出错,然而并没有。我将该参数修改为0x8c00,并通过make clean
和make
重新编译,发现反汇编生成的obj/boot/boot.asm文件中,线性地址被均被改动了,如图2-4所示。经过调试,发现boot loader执行到ljmp $PROT_MODE_CSEG, $protcseg
这条指令时发生错误。之所以会这样,是因为boot loader在这里使用了标号protcseg,而标号使用的是线性地址!也就是说ljmp指令试图跳转到0x8c32处,但事实上那里可能根本不是一条合法的指令,它真正期望的目标指令位于0x7c32处。
最后,命令objdump -x obj/kern/kernel
可以查看到ELF文件的program header相关信息,如图2-5所示(命令还显示section和符号表信息,未予截图)。一个program header条目对应一个segment,所以内核可执行文件分为了三个segment;其中前两个标记为LOAD,它们将会被加载到内存。图中还显示了segment的线性地址vaddr和物理地址paddr,以及加载区域的大小、对齐、权限等信息,我们可以再一次认识到是program header table决定了程序如何被加载到内存中。对照图2-2我们不难发现,这里的第一个segment是从.text这个section开始的,第二个则是从.data开始的。segment的vaddr和.text的VMA值相同,我并不确定到底是哪一个真正决定了指令和数据在内存中的线性地址。
Part 3: 内核
在这一部分中,我们需要写一些代码。
使用虚拟内存解决位置依赖
JOS使用PC物理地址空间的低端256MB,通过页机制将线性地址区间0xf0000000~0xfffffff映射到物理地址区间0x00000000~0x0fffffff(文档中仅使用map一词描述从物理地址到线性地址的方向,反方向称为translate,我认为问题不大)。出于某些原因(将在xv6 book Ch.2中介绍),我们在内核的入口代码entry.S只先创建两个映射关系,将线性地址区间0xf0000000~0xf0400000和0x00000000~0x00400000均映射到物理地址区间0x00000000~0x00400000。实际上这里只创建页目录,不会创建页表,页目录项会直接指向4MB的物理页。
格式化输出到控制台
在C语言中,很多人把printf视为是理所当然的,但事实上,它并不属于C语言本身,而是C语言标准库的一部分。printf是提供给用户程序员使用的,而非内核程序员,因为printf本身使用了操作系统内核提供的系统调用。绝大部分的标准库函数都使用了系统调用,所以开发内核时如果需要对应的功能只有自己去设计和实现。
我们需要稍微研究一下kern/printf.c、lib/printfmt.c和kern/console.c三个文件,练习8补充关于八进制打印的几行代码,参考十进制和十六进制的代码照葫芦画瓢很简单。现在我们来回答一下实验给出的几个问题。第2题中的代码是当信息在控制台窗口一屏显示不下时,将当前内容整体向上移一行,同时把最后一行清空,并把光标移到最后一行的起始位置。后面的几个问题需要参考lecture 2 note中关于GCC在x86上函数调用约定的相关知识。
- 第3题让我们跟踪下面两行代码的执行情况:
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d
", x, y, z);
从实验文档下一小节我们可以知道,只需要把上面的代码放在monitor.c的monitor函数内就可以执行到了。monitor函数用于在JOS的shell窗口显示欢迎信息并准备执行用户输入的命令。我们通过b monitor.c:119
(具体的行数当然不是固定的,调试方法亦然)设置断点并执行到cprintf调用之前,然后再设置断点b printf.c:32
并单步执行到vcprintf调用之前。在这个位置,我们可以观察到在上面代码执行时,cprintf函数调用过程中参数fmt与ap指向的内容(经调试验证,在va_start函数执行后,无法马上通过p指令查看ap的值,暂不深究),调试信息如下图所示:
显然,fmt指向字符串"x %d, y %x, z %d "。而参数ap指向的内容是"