练习1
在终端输入make V=,分别生成了bin/kernel和bin/bootblock,两者一同构成了ucore.img。首先是生成bin/kernel,需要数个源文件,将各个源文件编译,如下图所示:
部分用到的编译参数解释如下:
-c:只编译不链接,产生.o文件
-ggdb:生成可供gdb使用的调试信息
-m32: 生成适用于32位环境的代码。
-gstabs: 生成stabs格式的调试信息。这样要ucore的monitor可以显示出便于开发者阅读的函数调用栈信息。至于什么是stabs格式…
-nostdinc: 不使用标准库,标准库是给应用程序用的。
-fno-stack-protector 不生成用于检测缓冲区溢出的代码
Os 为减小代码大小而进行优化。根据硬件spec,主引导扇区只有512字节,我们写的简单bootloader的最终大小不能大于510字节
-I 添加搜索头文件的路径 //好像不需要空格?编译信息里都是-Ilibs/这种。。
-Wall: 显示编译后所有警告
-fno-builtin:不使用C语言的内建函数,建议好好看一下什么是内建函数。
-nostdinc, 编译器不在系统缺省的头文档目录里面找头文档,一般和-I 联合使用,明确限定头文档的位置
然后这些目标文件链接生成bin/kernel:
这里用到的参数的说明:
-m 这里后接参数elf_i386,模拟为i386上的连接器
-T 让连接器使用指定的脚本
同样的,生成bin/bootblock,经历相同的过程:
一些参数说明:
-N: 设置代码段和数据段均可读写
-e <entry>: 指定入口
-Ttext: 制定代码段开始位置
最后,这两个文件共同构成ucore.img
首先生成一个有10000个块的文件,每个块默认512字节,用0填充,然后,把bootblock写到第一个块,而从第二个块开始写kernel中的内容。
2.
一个被系统认为是符合规范的硬盘主引导扇区其大小不能超过512KB,且最后两个字节是0x55和0XAA
练习2
1
首先一个终端打开qemu
参数说明:
-s: qemu在端口1234监听gdb的调试连接
-S: 让qemu启动后暂停,等待gdb的连接
然后可以通过终端操作qemu,输入x/10i $pc,查看近十条指令:注意到第一条指令是一个long jump
这里有一个问题:
Q:为什么第一条指令是在0Xfffffff0: 刚开机不是实模式16位运行吗,怎么就寻址到这么高了
A:实模式确实是16*CS Segment Selector + EIP,但这种方式启用是在CS值第一次被改变后,刚开机时还没有改变CS值,此时寻址是Base Address+ EIP,而Base Address就是0Xffff0000,加上EIP的0xfff0便是开机后的第一条指令地址了。
为了调试ucore,新开一个终端打开gdb连接上这个虚拟机
我这里反汇编显示是错误的,还没找到解决办法,但是单步走的话其实程序运行是正确的
2 在初始化位置0x7c00设置实地址断点,测试断点正常
注意到练习1中bootblock的编译过程:
这里的-Ttext 0x7c00,表示代码段偏移了0x7c00,所以为了直接定位到这里,需要在这里加一个断点:b* 0x7c00。为了方便调试,可以在gdb里输入layout split
修改gdbinit脚本如下:
同样的,在一个终端打开QEMU,输入
再打开一个终端,输入make debug,得到如下界面:
断点正常,over。
3 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较
bootasm.S的部分截图如下:
bootblock.asm的部分截图如下:
结合之前2中的反汇编指令,三者基本一样
4 自己找一个bootloader或内核中的代码位置,设置断点并进行测试
自己随便设一个,略
练习3
首先介绍一些前置知识
对于Intel 80386的体系结构而言,PC机中的系统初始化软件由BIOS和BOOTLOADER(在ucore中位bootasm.S和bootmain.c)组成。
计算机加电后,CPU从物理地址0xFFFFFFF0开始执行,在0xFFFFFFF0这里只是存放了一条跳转指令,通过跳转指令跳到BIOS例行程序起始点,BIOS做完计算机硬件自检和初始化后,会选择一个启动设备(例如软盘、硬盘、光盘等),并且读取该设备的第一扇区(即主引导扇区或启动扇区)到内存一个特定的地址0x7c00处,然后CPU控制权会转移到那个地址继续执行,即开始执行bootloader。
bootloader完成的工作主要包括:
1 切换到保护模式,启用分段机制
2 读磁盘中ELF执行文件格式的ucore操作系统到内存
3 显示字符串信息
4 把控制权交给ucore操作系统
对应其工作的实现文件在lab1中的boot目录下的三个文件asm.h、bootasm.S和bootmain.c
这里还要讲下实模式和保护模式,在bootloader接手BIOS的工作后,当前的PC系统处于实模式(16位模式)运行状态。8086时,地址线是16位的但是要寻址20位空间。实模式寻址是由CS:IP来寻址,CS和IP值都来自16位寄存器,于是最大寻址能力是2^20+2^16=1088KB。所以当寻址到超过1MB的内存时,会发生“回卷”(不会发生异常)。后面,地址线宽度变宽,为了保持完全的向下兼容性,IBM决定在PC AT计算机系统上加个硬件逻辑,来模仿以上的回绕特征,于是出现了A20 Gate。他们的方法就是把A20地址线控制和键盘控制器的一个输出进行AND操作,这样来控制A20地址线的打开(使能)和关闭(屏蔽\禁止)。
一开始时A20地址线控制是被屏蔽的(总为0),直到系统软件通过一定的IO操作去打开它(参看bootasm.S)。很显然,在实模式下要访问高端内存区,这个开关必须打开,在保护模式下,由于使用32位地址线,如果A20恒等于0,那么系统只能访问奇数兆的内存,即只能访问0--1M、2-3M、4-5M......,这显然是不行的,所以在保护模式下,这个开关也必须打开。
只有在保护模式下,80386的全部32根地址线有效,可寻址高达4G字节的线性地址空间和物理地址空间,可访问64TB(有2^14个段,每个段最大空间为2^32字节)的逻辑地址空间,可采用分段存储管理机制和分页存储管理机制。
保护模式下,有两个段表:GDT(Global Descriptor Table)和LDT(Local Descriptor Table),每一张段表可以包含8192(2^13)个描述符,因而最多可以同时存在 2^14个段。虽然保护模式下可以有这么多段,逻辑地址空间看起来很大,但实际上段并不能扩展物理地址空间,很大程度上各个段的地址空间是相互重叠的。
编译器把源程序编译成执行程序时用到的代码段、数据段、堆和栈等概念在这里可以与段联系起来,二者在含义上是一致的
分段机制涉及4个关键内容:逻辑地址、段描述符(描述段的属性)、段描述符表(包含多个段描述符的“数组”)、段选择子(段寄存器,用于定位段描述符表中表项的索引)
转换逻辑地址(Logical Address,应用程序员看到的地址)到物理地址(Physical Address, 实际的物理内存地址)分以下两步:
[1] 分段地址转换:CPU把逻辑地址(由段选择子selector和段偏移offset组成)中的段选择子的内容作为段描述符表的索引,找到表中对应的段描述符,然后把段描述符中保存的段基址加上段偏移值,形成线性地址(Linear Address)。如果不启动分页存储管理机制,则线性地址等于物理地址。
[2] 分页地址转换,这一步中把线性地址转换为物理地址。(注意:这一步是可选的,由操作系统决定是否需要。在后续试验中会涉及。
每个段由如下三个参数进行定义:段基地址(Base Address)、段界限(Limit)和段属性(Attributes)
全局描述符表 全局描述符表的是一个保存多个段描述符的“数组”,其起始地址保存在全局描述符表寄存器GDTR中。GDTR长48位,其中高32位为基地址,低16位为段界限。全局描述符表中第一个段描述符设定为空段描述符
由于GDT 不能有GDT本身之内的描述符进行描述定义,所以处理器采用GDTR为GDT这一特殊的系统段(不能我记我自己)
段选择子(段寄存器)结构如下:
1 索引(Index):在描述符表中从8192个描述符中选择一个描述符。处理器自动将这个索引值乘以8(描述符的长度),再加上描述符表的基址来索引描述符表,从而选出一个合适的描述符。
2 表指示位(Table Indicator,TI):选择应该访问哪一个描述符表。0代表应该访问全局描述符表(GDT),1代表应该访问局部描述符表(LDT)。
3 请求特权级(Requested Privilege Level,RPL):保护机制
数据段选择子的整个内容可由程序直接加载(如MOV)到各个段寄存器(如SS或DS等)当中。这些内容里包含了RPL字段,然而,代码段寄存器(CS)的内容不能由装载指令(如MOV)直接设置,而只能被那些会改变程序执行顺序的指令(如JMP、INT、CALL)间接地设置。而且CS拥有一个由CPU维护的当前特权级字段(Current Privilege Level,简称CPL)。二者结构如下图所示:
CPU会在两个关键点上保护内存:当一个段选择符被加载时,以及,当通过线性地址访问一个内存页时。因此,保护也反映在内存地址转换的过程之中,既包括分段又包括分页。当一个数据段选择符被加载时,就会发生下述的检测过程:
CPL:当前特权级(Current Privilege Level) 保存在CS段寄存器(选择子)的最低两位,CPL就是当前活动代码段的特权级,并且它定义了当前所执行程序的特权级别)
DPL:描述符特权(Descriptor Privilege Level) 存储在段描述符中的权限位,用于描述对应段所属的特权等级,也就是段本身真正的特权级。
RPL:请求特权级RPL(Request Privilege Level) RPL保存在选择子的最低两位。RPL说明的是进程对段访问的请求权限,意思是当前进程想要的请求权限。RPL的值由程序员自己来自由的设置,并不一定RPL>=CPL,但是当RPL<CPL时,实际起作用的就是CPL了,因为访问时的特权检查是判断:max(RPL,CPL)<=DPL是否成立,所以RPL可以看成是每次访问时的附加限制,RPL=0时附加限制最小,RPL=3时附加限制最大。
这三个特权级,其实就是分别代表当前进程的,段描述符里的,段选择子里申请的特权级,确保当前进程不会越级。
接下来结束实验部分
TASK1 为何开启A20,以及如何开启A20
为何已经说了,现在说如何开启
首先在0x7c00处断点,进入调试:
cli和cld指令,它们的作用分别是:
cli:清除标志寄存器的中断允许标志位IF,这样CPU无法响应外部中断
cld:清除标志寄存器的方向标志位DF,在字串操作中使变址寄存器SI或DI的地址指针自动增加,字串处理由前往后,反之std就是置其为1,使字串处理由后往前
接下里是对各个段寄存器置0,然后是打开A20 GATE,其打开需要向键盘控制器8042发送一个命令。键盘控制器8042将会将它的的某个输出引脚的输出置高电平,作为 A20 地址线控制的输入。
8042芯片,其有两个端口地址,分别是0x60和0x64。0x64用来发送一个键盘控制命令,0x60用来传递参数
在传命令前需要确认这个port是否是busy的,而测试其是否空闲的代码便是如下
若非busy,便可顺序执行下去
这里对0x64 port发送了0xd1指令,表示要write这个port,然后又测试了一次是否busy,我们单步走,还是没有跳转,这代表8042仍然不busy,很nice,然后将0xdf通过0x60 port发送过去,也就打开了A20
TASK2 如何初始化GDT表
其实就是一条指令:
这里就是加载GDT表,但是我的显示和网上帖子的不一样,它们都是这种更直观的形式:
但是无所谓,接下来需要进入保护模式
TASK3 如何使能和进入保护模式
这三步的意思就是把CR0第一位置1。CR0寄存器是控制寄存器,其图如下:
将PE置1后便是开启了保护模式,至此保护模式已经开启。上图中第四条指令是一个long jump,它将我们带到这里:
各个段寄存器得到设立,栈被建立起。这里call 0x7d0c代表call bootmain
PS:初始化GDT表还是有点模糊,就只有一条指令,详细还是另找资料吧
练习4 分析bootloader加载ELF格式的OS的过程
TASK1 bootloader如何读取硬盘扇区的?
对于bootloader,之前的是bootasm.S里的工作,下面讲bootmain.c的工作。下面先贴出bootmain.c的代码:
bootloader需要将我们的kernel从硬盘读到内存中,
由指导书可知,其访问硬盘一般都是LBA的PIO方式。一般主板有2个IDE通道,每个通道可以接2个IDE硬盘。访问第一个硬盘的扇区可设置IO地址寄存器0x1f0-0x1f7实现的。一般第一个IDE通道通过访问IO地址0x1f0-0x1f7来实现,第二个IDE通道通过访问0x170-0x17f实现
读一个扇区的流程(可参看boot/bootmain.c中的readsect函数实现)大致如下:
1 等待磁盘准备好
2 发出读取扇区的命令
3 等待磁盘准备好
4 把磁盘扇区数据读到指定内存
下面对源码分析,首先:
这个函数的意思是从kernel的0地址处开始的SECTSIZE*8个字节读到虚拟地址ELFHDR处,ELFHDR这里是地址0x10000,而SECTSIZE是512,可能386定义的page size是512字节吧,现在一般是4KB。
这个readseg函数的具体如下
由于每次只能read一个SECTSIZE,所以在这里分开数次传送,每次调用了readsect()函数。kernel初始是sector1(sector 0放的就是我们正在讨论的bootloader),从sector1开始依次读入。readsect()函数的介绍略。
TASK2 bootloader是如何加载ELF格式的OS?
如之前所说,bootloder首先将kernel的前4096字节加载到了虚拟地址0x10000处
首先检查这个ELF格式的kernel是否是个合法的ELF文件,即检查其magic number是否正确。
ELF头的文件格式,如下:
而对于之后的代码,这里涉及到了program header:
program header描述与程序执行直接相关的目标文件结构信息,用来在文件中定位各个段的映像,同时包含其他一些用来为程序创建进程映像所必需的信息。
这里ph是program header表的首项,而eph是末项,由此通过循环,将各个段加载。
最后调用头表中的内核入口地址实现内核链接地址转化为加载地址,无返回值(这里不太懂)
练习5 实现函数调用堆栈跟踪函数 (需要编程)
练习五能增强对函数调用和栈的了解。函数调用是通过栈实现的,其参数入栈顺序有多种规定,这里以最常用的CDECL来示例:
esp寄存器指向栈顶,ebp寄存器指向栈底,栈是从高地址往低地址增加。
这里举例,比如fun1调用了fun2,首先入栈的是fun2需要用到的参数,即fun1传给他的参数,然后接着就是fun1的返回地址,fun2完成他的任务后,PC需要回到fun1原来的地方,需要注意,一直到这里都是fun1的栈帧,这些东西是属于fun1的。然后创建fun2的栈帧,首先栈底保存fun1的栈底地址,因为我们后面要更新ebp为fun2的栈底,即现在fun2的栈底处保存的值是fun1的栈底值,稍微有点绕但影响不大,再往后就是fun2中自己创建的局部变量等了,fun2开始进行他的工作。这就是函数调用的大概的流程
print_stackframe()函数的官方实现如下:
需要注意,这里read_ebp()必须是inline的,不然每次都是打印自己的栈底了
执行make qemu后终端显示的信息如下:
对于最后一项信息
此时ebp为kernel的入口,即kern_init的栈底,由练习3:
最开始esp设为0x7c00,是bootmain的栈顶,这里kern_init的栈底为0x7bf8,两者相差8字节,说明bootmain在调用kern_init时没有任何传参,因为为了创建kern_init的栈帧需要在bootmain的栈顶push 返回地址和push ebp。而打印的4个参数,其实bootmain()在调用kern_init时没有用任何参数,这四个其实没用。
练习6 完善中断初始化和处理
还是先对中断作一些简要介绍,这里说的中断是interrupt, exception, trap的合称。
中断描述符表中的entry又叫作门描述符(Gate Descriptor),有多种Gate Descriptor,但在这个lab主要介绍和处理Interrupt Gate Descriptor,Trap Gate Descriptor。
IDT和IDTR寄存器的结构和关系如下图所示:
Interrupts/Exceptions应该使用Interrupt Gate和Trap Gate,它们之间的唯一区别就是:当调用Interrupt Gate时,Interrupt会被CPU自动禁止;而调用Trap Gate时,CPU则不会去禁止或打开中断,而是保留它原来的样子。
这里所谓的自动禁止意思是,当调用Interrupt Gate时,会将标志寄存器(EFLAGS)压栈,然后清除(clear)其的IF位以避免重复中断。当然我们也可以在interrupt handler中再set IF位以达到嵌套中断,但需要注意保存好需要的context。此外,Trap Gate不会clear IF也很好理解,因为用户进程理应不能禁止中断。
下图显示了80386的中断门描述符、陷阱门描述符的格式
中断处理中硬件负责完成的工作:
下面介绍中断处理的流程,其中有2个过程是由硬件完成的
(起始)从CPU收到中断事件后,打断当前程序或任务的执行,根据某种机制跳转到中断服务例程去执行的过程。其具体流程如下:
1. CPU执行完每次指令后,都会去确认是否有中断请求到达,若有,CPU便会在总线上读取中该请求对应的中断向量。
2. CPU通过中断向量在IDT中找到对应的中断描述符,中断描述符里保存着中断服务例程的段选择子
3. CPU通过得到的段选择子在GDT中取得相应的段描述符,段描述符里保存了中断服务例程的段基址和属性信息,此时CPU就得到了中断服务例程的起始地址,并跳转到该地址
4. CPU会根据CPL (当前进程的权限级别Current Privilege Level) 和中断服务例程的段描述符的DPL确认是否发生了特权级的转换,若发生了特权级的转换,CPU会从当前进程的TSS信息(该信息在内存中的起始地址存在TR寄存器中)里取得该程序的内核栈位置,并将进程使用的栈切换到内核栈,这个栈就是即将运行的中断服务程序要使用的栈。紧接着就将当前程序使用的用户态的ss和esp压到新的内核栈中保存起来
5. CPU利用内核栈保存上下文,即依次压入当前被打断程序使用的eflags,cs,eip,errorCode(如果是有错误码的异常)信息
6. CPU利用中断服务例程的段描述符将其第一条指令的地址加载到cs和eip寄存器中,开始执行中断服务例程,中断服务程序正式开始工作
(结束):每个中断服务例程在有中断处理工作完成后需要通过iret(或iretd)指令恢复被打断的程序的执行。CPU执行IRET指令的具体过程如下:
1. 首先pop出上下文信息,
2. 若发生特权级转换,切换回用户栈
3. 如果此次处理的是带有错误码(errorCode)的异常,CPU在恢复先前程序的现场时,并不会弹出errorCode。这一步需要通过软件完成,即要求相关的中断服务例程在调用iret返回之前添加出栈代码主动弹出errorCode(这里不是很懂)
下图显示了从中断向量到GDT中相应中断服务程序起始位置的定位方式:
中断处理的特权级转换:
特权级转换是通过门描述符(gate descriptor)和相关指令来完成的。中断门描述符和陷阱门描述符几乎是一样的。中断发生时实施特权检查的过程如下图所示:
门中的DPL和段选择符一起控制着访问,同时,段选择符结合偏移量(Offset)指出了中断处理例程的入口点。内核一般在门描述符中填入内核代码段的段选择子。产生中断后,CPU一定不会将运行控制从高特权环转向低特权环,特权级必须要么保持不变(当操作系统内核自己被中断的时候),或被提升(当用户态程序被中断的时候)。无论哪一种情况,作为结果的CPL必须等于目的代码段的DPL。如果CPL发生了改变,一个堆栈切换操作(通过TSS完成)就会发生。如果中断是被用户态程序中的指令所触发的(比如软件执行INT n生产的中断),还会增加一个额外的检查:门的DPL必须具有与CPL相同或更低的特权。这就防止了用户代码随意触发中断。如果这些检查失败,会产生一个一般保护异常(general-protection exception)。
TASK1 中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
一个表项占8字节,由之前的中断门描述结构:
16-31位是段选择子,而offset由0-15和48-63共同构成,分别代表低16位和高16位。
TASK2
直接给官方答案吧,task3也可以看官方答案