一.Lab 1
目的:熟悉x86汇编语言,使用QEMU软件来仿真xv6操作系统,并且配合GDB对操作系统的运行进行调试。
有关x86:汇编语言是二进制文件中机器指令的标准表示形式(汇编语言用文本符号代表二进制指令)。汇编程序由4种类型的组件组成:指令(instruction)、伪指令(directive)、标号(label)及注释(comment)。x86机器指令的语法格式主要有两种:Intel语法和AT&T语法。AT&T语法显式地在每个寄存器名称的前面加上%符号,每个常量前面加上$符号,而Intel语法没有这些符号。在AT&T中,源操作数在目的操作数前面,Intel语法相反。
1.PC机物理地址空间
第一代PC处理器是16位字长的Intel 8088处理器,这类处理器只能访问1MB的地址空间,即0x00000000~0x000FFFFF。但是用户只能利用低640KB(0x00000000~0x000A0000)的地址空间。剩下的384KB的高地址空间则被保留用作其他的目的,比如(0x000A0000~0x000C0000)被用作屏幕显示内容缓冲区,其他的则被非易失性存储器(ROM)所使用,里面会存放一些固件,其中最重要的一部分就是BIOS,占据了0x000F0000~0x00100000的地址空间。BIOS负责进行一些基本的系统初始化任务,比如开启显卡,检测该系统的内存大小等等工作。在初始化完成后,BIOS就会从某个合适的地方加载操作系统。
Intel处理器突破了1MB内存空间,在80286和80386上已经实现了16MB,4GB的地址空间,但是PC的架构必须仍旧把原来的1MB的地址空间的结构保留下来,这样才能实现向后兼容性。所以现代计算机的地址 0x000A0000~0x00100000区间是一个空洞,不会被使用。因此这个空洞就把地址空间划分成了两个部分,第一部分就是从0x00000000~0x000A0000,叫做传统内存。剩下的不包括空洞的其他部分叫做扩展内存。而对于这种32位字长处理器通常把BIOS存放到整个存储空间的顶端处。由于xv6操作系统设计的一些限制,它只利用256MB的物理地址空间,即它假设用户的主机只有256MB的内存。
PC启动后的运行顺序为 BIOS --> boot loader --> 操作系统内核
BIOS的操作就是在控制,初始化,检测各种底层的设备,比如时钟,GDTR寄存器。以及设置中断向量表。但是作为PC启动后运行的第一段程序,它最重要的功能是把操作系统从磁盘中导入内存,然后再把控制权转交给操作系统。所以BIOS在运行的最后会去检测可以从当前系统的哪个设备中找到操作系统,通常来说是我们的磁盘。也有可能是U盘等等。当BIOS确定了,操作系统位于磁盘中,那么它就会把这个磁盘的第一个扇区,通常把它叫做启动区(boot sector)先加载到内存中,这个启动区中包括一个非常重要的程序--boot loader,它会负责完成整个操作系统从磁盘导入内存的工作,以及一些其他的非常重要的配置工作。最后操作系统才会开始运行。
2.ROM BIOS
[f000:fff0] 0xffff0: ljmp $0xf3630, $0xe05b 这条指令就是整个PC启动后,执行BIOS的第一条指令。这里的停在的地址是oxfff0这是通过$0xf000 << 4 + $0xfff0得到(实模式下的寻址方式)。
接下来,BIOS执行。首先bios会初始化一些中断向量表,然后会初始化一些重要设备比如vga等等,然后开机提示信息就回现实(如windows常见的loading图),在初始化PCI总线和一些重要设备之后,它搜索可引导设备,如软盘,硬盘驱动器或CD-ROM。 最终,当它找到可启动磁盘时,BIOS将引导加载程序从磁盘读取。随后转移到引导启动程序上去。
两种模式:这两种模式都是CPU的工作模式,实模式是早先CPU的工作模式,保护模式是现代CPU的工作模式,项目中选择先进入实模式再切换成保护模式是为了实现软件的后向兼容。
实模式是在8088CPU时期出现的,当时的CPU总共有20个地址线(地址空间只有1MB),以及8个16位的通用寄存器,和4个16位的段寄存器,所以为了通过16位的寄存器去构成20位的主存地址,必须采用特殊的方式——段基址:段偏移量,段基址由4种段寄存器提供(%cs,%ds,%ss,%es),段内偏移量代表要访问的内存地址距离这个段基址的偏移,他的值是用通用寄存器提供的,所以也是16位,所以物理地址 = 段基址<< 4 + 段内偏移。
保护模式是随着CPU的发展,CPU的地址线个数从原来的20变为32根可以访问的空间也从1MB变为4GB,寄存器的位数也变为32位。程序员通常只需要指明段内偏移量(segment:offset)。然后分段管理机构(segmentation hardware)将会把这个逻辑地址转换为线性地址(linear address)。如果该机器没有采用分页机制(paging hardware)的话,此时linear address就是最后的主存物理地址。但是如果机器中还有分页设备的话,那就要另外计算,若有分页设备,段偏移只是作为一个索引去搜索GDT/LDT表。在保护模式下,分段机制是利用一个称作段选择子的偏移量到全局描述符表中找到需要的段描述符,而这个段描述符中就存放着真正的段的物理首地址,然后再加上偏移地址量便得到了最后的物理地址。
GDT/LDT表(全局段描述符表/本地段描述符表,两个表相似都包含了三个字段BASE:32位代表段基地址,LIMIT:20位代表这个段的大小,FLAGS:12位代表这个段的访问权限),程序员访问时先根据索引找到对应的字段然后根据FLAGS判断数据是否可以访问,接着再用BASE直接和偏移量相加得到最终的地址。所以保护模式会比实模式多一层保护并且长度也更长。
CR0:0位PE,是否进入保护模式(1进入);31位PG,是否启动分页(1启动)
3.The Boot Loader
对于PC来说,软盘,硬盘都可以被划分为一个个大小为512字节的区域,叫做扇区。一个扇区是一次磁盘操作的最小粒度。每一次读取或者写入操作都必须是一个或多个扇区。如果一个磁盘是可以被用来启动操作系统的,就把这个磁盘的第一个扇区叫做启动扇区。当BIOS找到一个可以启动的软盘或硬盘后,它就会把这512字节的启动扇区加载到内存地址0x7c00~0x7dff这个区域内。
对于6.828,我们将采用传统的硬盘启动机制,这就意味着我们的boot loader程序的大小必须小于512字节。整个boot loader是由一个汇编文件,boot/boot.S(当BIOS运行完成之后,CPU的控制权就会转移到boot.S文件上),以及一个C语言文件,boot/main.c组成。Boot loader必须完成两个主要的功能:
1.首先,boot loader要把处理器从16bit实模式转换为32bit的保护模式(有一条语句),因为只有在这种模式下软件可以访问超过1MB空间的内容。
2.然后,boot loader可以通过使用x86特定的IO指令,直接访问IDE磁盘设备寄存器,从磁盘中读取内核。
boot loader执行的最后一条语句是bootmain子程序中的最后一条语句 " ((void (*)(void)) (ELFHDR->e_entry))(); ",即跳转到操作系统内核程序的起始指令处。内核被加载到内存后执行的第一条指令位于/kern/entry.S文件中。
问:boot loader是如何知道它要读取多少个扇区才能把整个内核都送入内存的呢?在哪里找到这些信息?
答:首先关于操作系统一共有多少个段,每个段又有多少个扇区的信息位于操作系统文件中的Program Header Table中。这个表中的每个表项分别对应操作系统的一个段。并且每个表项的内容包括这个段的大小,段起始地址偏移等等信息。所以如果我们能够找到这个表,那么就能够通过表项所提供的信息来确定内核占用多少个扇区。这个表存放在操作系统内核映像文件的ELF头部信息中。
boot.S文件具体任务:(1)cli:CPU在实模式下,关闭所有中断(在BIOS运行期间有可能打开了中断),cld: 指定之后发生的串处理操作的指针移动方向 (2)三个段寄存器ds,es,ss全部清零(为进入保护模式做准备)(3)准备把CPU的工作模式从实模式转换为保护模式(先检查输入缓冲区是否满,即CPU传给控制器的数据是否被取走,取走后把0xd1这条数据写入到0x64端口(0x64端口属于键盘控制器804x),代表下一次写入0x60端口的数据将被写入给804x控制器的输出端口,是一个控制指令,然后写入新指令0xdf,使能A20)(4)lgdt gdtdesc,把gdtdesc这个标识符的值送入全局映射描述符表寄存器GDTR中(gdtdesc是一个标识符,标识着一个内存地址,该地址后的六个字节中存放着GDT表的内存起始地址以及GDT表的长度,gdtdesc存放在文件最后)。(5)修改CR0寄存器的内容,把CR0寄存器的bit0置1,CR0寄存器的bit0是保护模式启动位,把这一位值1代表保护模式启动。CR0~CR3寄存器都是80x86的控制寄存器(6)跳转指令,把当前的运行模式切换成32位地址模式(7)修改段寄存器(因为加载完GDTR寄存器之后必须要重新加载所有的段寄存器,CS需长跳转指令更新,其他段寄存器根据设置好的段选择子更新)(8)call bootmain 跳转到main.c文件中的bootmain函数处
main.c文件具体任务:(1)readseg:把内核的第一个页(4MB)读取到内存地址0x10000处,相当于把操作系统映像文件的elf头部读取出来放入内存中(2)把操作系统内核的各个段从外存读入内存中(首先找到elf头部中包含的Program Header Table,这个表格存放着程序中所有段(代码段、数据段等)的信息)(3)e_entry字段指向的是这个文件的执行入口地址0x0010000C(.text段),即开始运行内核文件。 自此就把控制权从boot loader转交给操作系统的内核。
一个ELF文件,开始处是一个固定长度的ELF文件头(带有加载信息),后面紧跟着一个程序段表,这个段表中列出了要加载到内存中的所有段。关于ELF文件头的格式在inc/elf.h文件中有声明。例如: .text段(存放所有程序的可执行代码), .rodata段:(存放所有只读数据的数据段,比如字符串常量), .data段(存放所有被初始化过的数据段,比如有初始值的全局变量)
Exercise 1.4: 通过objdump -h obj/kern/kernel指令获取JOS内核中所有段的名字,大小和地址(ELF)。通过objdump -x obj/kern/kernel指令获取kernel的Program Headers Table(ELF中,指明ELF文件中哪些部分被加载到内存,以及被加载到内存中的地址)的信息。
Exercise 1.5: 修改boot loader的链接地址,找到出现错误的指令(在boot/Makefrag文件中修改它的链接地址,修改完成后运行 make clean, 然后通过make指令重新编译内核)。除了各个段的信息,在ELF头部中,还有一个非常重要的信息就是e_entry字段。这个字段存放的是这个可执行程序的执行入口处的链接地址。通过objdump -f obj/kern/kernel指令查看内核程序入口处(0x0010000C)。
Exercise 1.6: 使用GDB的x命令,查看在BIOS进入boot loader之前,内存地址0x00100000处8个字的内容,然后进入boot loader运行到内核开始处停止该地址处的值。地址变化原因:bootmain函数在最后会把内核的各个程序段送入到内存地址0x00100000处,所以这里现在存放的就是内核的某一个段的内容,由于程序入口地址是0x0010000C,正好位于这个段中,所以可以推测,这里面存放的应该是指令段,即.text段的内容。
Exercise 1.7: movl %eax, %cr0指令前后,内存地址0xf0100000处存放数据改为0x00100000处数据,原因是完成了从物理地址到虚拟地址到映射。
Exercise 1.8: print.c的调用链cprintf -> vcprintf -> vprintfmt -> putch -> cputchar
,补充了处理显示八进制的格式的时候的代码。
4.The Kernel
在运行boot loader时,boot loader中的链接地址(虚拟地址)和加载地址(物理地址)是一样的。但是当进入到内核程序后,这两种地址就不再相同了。操作系统内核程序在虚拟地址空间会被链接到一个很高的虚拟地址空间比如0xf0100000,但是实际计算机没有地址那么大的物理内存,所以就将这个虚拟地址实际映射到一个真实低位的物理地址上,这个映射常常是用分页管理的方式实现的。本实验中,设计者手写程序lab\kern\entrygdir.c用于进行映射,但功能受限只能把虚拟地址空间的地址范围:0xf0000000~0xf0400000,映射到物理地址范围:0x000000~0x400000上面。或虚拟地址范围:0x000000~0x400000,同样映射到物理地址范围:0x000000~0x400000上面。任何不再这两个虚拟地址范围内的地址都会引起一个硬件异常。但这两块很小的空间足够刚启动程序的时候来使用。
继续上面程序运行的流程当main.c文件中的bootmain函数运行到最后时,它执行的最后一条指令就是跳转到entry.S文件中的entry地址处。此时控制权已经被转交给了entry.S(汇编)。在这里有对内核堆栈进行初始化设置,内核声明了一块32Kb的空间作为堆栈使用,而堆栈指针指向的是最高地址,因为堆栈是向下生长的,堆栈有两个重要的寄存器分别是esp和ebp,ebp(i)所指向的内存单元处存放着上一层程序的ebp寄存器的值,即ebp(i-1),在esp(i)所指向的内存单元处存放着对下一层子程序调用时传入的参数,即i+1。
虚拟内存:每个进程创建加载的时候,会被分配一个大小为4G的连续的虚拟地址空间,虚拟的意思就是,其实这个地址空间时不存在的,仅仅是每个进程“认为”自己拥有4G的内存,而实际上,它用了多少空间,操作系统就在磁盘上划出多少空间给它,等到进程真正运行的时候,需要某些数据并且数据不在物理内存中,才会触发缺页异常,进行数据拷贝。操作系统向进程描述了一个完整的连续的虚拟地址空间供进程使用,但是在物理内存中进程数据的存储采用离散式存储(提高内存利用率),还需要使用分段管理或分页管理(页表)映射虚拟地址与物理地址的映射关系,并且通过页表实现内存访问控制。
Exercise 1.9: 内核从entry.S的跳转i386_init之前的两句开始初始化堆栈空间。在entry.S中的数据段里面声明一块大小为32Kb的空间作为堆栈使用,从而为内核保留了一块空间。因为堆栈是向下生长的,堆栈指针指向这块被保留的区域的最高地址(bootstacktop)
把操作系统的代码的虚拟地址设置为从0xf0100000开始。需要一种机制使得将高虚拟地址转换为低物理地址。机制实现方法:(1)建立C语言页表entry_pgdir,可以自动的把[000000-400000]这4MB的虚拟地址空间映射为[000000-400000]的物理地址空间。但这个页表的映射能力有限,只能映射一个区域。因为当前运行的是内核程序,他们的虚拟空间地址范围在[000000-400000]之内,该映射空间足够。但是当操作系统真正运行起来的时候,这个映射就不够用了。必须采用更全面的,也就是在lab 2中要介绍的页表机制。(2)把entry_pgdir这个页表的起始物理地址送给%cr2。控制寄存器cr2和cr3都是和分页机制相关的寄存器。其中cr3寄存器存放页表的物理起始地址。(3)把cr0的PE位、PG位、WP位都置位1。PE位是启用保护标识位,=1代表将会运行在保护模式下。PG位是分页标识位,=1代表开启了分页机制。WP位是写保护标识,=1处理器会禁止超级用户程序向用户级只读页面执行写操作。这条指令过后,就开始工作在具有分页机制的模式之下了。然后通过指令把当前运行程序的地址空间提高到[000000-400000]范围内。(4)设置%ebp,%esp两个寄存器的值。%ebp被修改为0。%esp则被修改为bootstacktop的值。
Exercise 1.10: 对于这个循环嵌套调用的程序test_backtrace,了解压入的堆栈信息。
Exercise 1.11: 实现显示当前正在执行的程序的栈帧信息的子程序。包括当前的ebp寄存器的值,这个寄存器的值代表该子程序的栈帧的最高地址。eip则指的是这个子程序执行完成之后要返回调用它的子程序时,下一个要执行的指令地址。后面的值就是这个子程序接受的来自调用它的子程序传递给它的输入参数。
ebp是基址指针寄存器;esp是堆栈指针寄存器;c语言在进行编译时,会将各个函数的局部变量压入堆栈中;栈一直随着函数调用的深入,一直想栈顶方向压下去。每次调用函数时候,先压函数参数(从右往左顺序压),再压入函数调用下条指令的地址(由call完成)。接着进入调用函数体中先执行PUSH EBP; MOV EBP ESP;(一般已经由编译器加入到函数头中了),接着就是把函数体中的局部变量压入栈中。再遇到函数的调用的嵌套则依此类推。
“PUSH EBP”“MOV EBP ESP”:首先将EBP入栈,然后将栈顶指针ESP赋值给EBP。
“MOV EBP ESP”:给EBP赋值之前,原EBP值已被压栈(位于栈顶),而新的EBP又恰恰指向栈顶。从该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的EBP值。在函数执行过程中esp随着压栈和出栈操作随时变化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问
资料来源:MIT6.828 Lab1_饮水小思源的博客-优快云博客
x86汇编快速入门 - 知乎 (zhihu.com)周小伦 - 博客园 (cnblogs.com)
周小伦 - 博客园 (cnblogs.com)MIT 6.828 JOS 操作系统学习笔记 - 随笔分类 - fatsheep9146 - 博客园 (cnblogs.com)