首先在Ubuntu里面运行lab1_result,我们可以看到在启动操作系统之前,电脑对一系列文件进行了编译,通过make qemu我们可以看到,电脑首先针对kern文件下的.c和.S文件进行了编译,然后通过ld把kern文档下的所有程序和bin文件连接起来,并重定位他们的数据,然后生成kernel程序。
其次电脑针对boot文件夹下面的.c和.S文件进行了编译,并且将tools文件夹下面的sign.c也进行了编译,然后通过ld生成bootblock程序。
因此通过make qemu我们可以了解到在生成ucore.img之前,需要先通过编译链接,生成kernel和bootblock的ELF文件。
接下来我们可以看到通过dd指令,将上面我们生成的kernel和bootblock的ELF文件拷贝到ucore.img当中,根据拷贝的顺序我们可以看到,首先是将bootblock拷贝进了ucore.img,然后才是将kernel拷贝进ucore.img,所以可以得出bootblock是引导区,kernel是操作系统内核。
除此之外,在上文我们看到电脑还对sign.c进行了编译,然而在接下来的链接中并没有对其进行进一步操作,所以我们打开sign.c文件进行查看。
在这里我们看到给sign.c传进去了3个参数,其中通过输出,我们可以了解到argv[1]传入的是obj/bootblock.out:
紧接着在下面定义了一个长度为512的字符串,然后从obj/bootblock.out中读取500个字符存到buf里面,接下来在buf的结尾加上0x55AA,并且将其写到argv[2]里面,由于在运行中看不出来argv[2]是哪里,所以针对sign.c文件进行了修改,输出了argv[2]:
可以看到我们将更改后的buf存入了bin/bootblock当中,即我们的sign.c文件,在引导区内做了个0x55AA的标记,然后将其存入了bin/bootblock当中,然后拷贝到ucore.img里面。
自此,我们的ucore.img文件正式生成。
由此可知,我们的sign.c文件是用来给主引导扇区做标记的,因此我们可以通过sign.c的报错信息可以得到主引导扇区的两个特征,首先主引导扇区的大小必须是512个字节,其次主引导扇区的最后两个字节必须是0x55AA,否则就会报错。
从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
首先在CPU加电之后,CPU里面的ROM存储器会将其里面保存的初始值传给各个寄存器,其中CS:IP = 0Xf000 : fff0(CS:代码段寄存器;IP:指令寄存器),这个值决定了我们从内存中读数据的位置,PC = 16*CS + IP。
此时系统处于实模式,并且截止到目前为止系统的总线还不是我们平常的32位,这时的地址总线只有20位,所以地址空间的总大小只有1M,而我们的BIOS启动固件就在这个1M的空间里面。
BIOS启动固件需要提供以下的一些功能:
☆基本输入输出的程序
☆系统设置信息
☆开机后自检程序
☆系统自启动程序
在此我们需要找到CPU加电之后的第一条指令的位置,然后在这里break,单步跟踪BIOS的执行,根据PC = 16*CS + IP,我们可以得到PC = 0xffff0,所以BIOS的第一条指令的位置为0xffff0(在这里因为此时我们的地址空间只有20位,所以是0xffff0)。
在这里我们利用make debug来观察BIOS的单步执行,所以我们首先通过Makefile文件来查看make debug的相关操作:
通过debug的定义,我们可以看到,在debug里首先是对qemu进行的操作,然后等待一段时间之后,再进行针对gdbinit文件进行的调试,所以,我们继续观察gdbinit文件:
在gdbinit文件里我们可以看到电脑在运行到kern_init是会触发break,然后又紧接着在下一步continue,所以会继续执行,再根据第一问的编译顺序:
我们可以看到电脑CPU在加电之后第一步要执行的就是对kern_init进行编译,同时这也是BIOS的第一条指令,所以我们需要在这里break,然后通过gdb一步一步针对BIOS进行运行,因此,我们将gdbinit中的continue删除,从而使得电脑停在这里。
然后进行make debug:
在这里我们通过查看0xffff0地址内的信息可以看到,BIOS的第一条指令是一条跳转指令,然后电脑会跳转到0xf000e05b,开始进行一系列的操作。在截图中我们看到pc:0xfff0,这是因为在x86的机器里面并没有pc这个寄存器,所谓的pc值是通过CS:IP而得到的,因此这里的PC所代表的是eip寄存器里面的值。
在初始化位置0x7c00设置实地址断点,测试断点正常。
由于我们需要观察电脑在0x7c00处电脑的运行情况,所以首先需要在地址为0x7c00的地方设置断点,使得电脑在此停住:
然后通过continue指令,使得程序继续运行,直到运行到0x7c00之后再次停住。
再次通过x/10i $pc查看相近的指令:
通过查看bootasm.S文件我们可以看到,此时电脑已经进入bootasm.S文件,开始执行相应的代码。
然后再次输入c我们看到qemu工作正常,所以断点正常。
通过比较我们可以发现bootasm.S和bootblock.asm的汇编代码几乎相同。
通过对比我们可以看到,其汇编代码相同,然后输入continue,qemu继续正常工作,所以断点正常。
关于BootLoader进入保护模式的过程,我们通过说明文档可以了解到,这个练习我们最重要的是要理解三个问题:
1、为何要开启A20,以及如何开启A20;
2、如何初始化GDT表;
3、如何使能和进入保护模式。
首先关于A20,我们通过查询资料以及说明文档可以知道早期的8086CPU所提供的地址线只有20位,所以可寻址空间为0~2^20(1MB),但是8086的数据处理位宽16位,无法直接访问1M的地址空间,所以8086提供了段地址加偏移地址的转换机制。PC的寻址结构是segment:offset,segment和offset都是16位寄存器,最大值是0ffffh,所以换算成物理地址的计算方法是吧segment左移4位,再加上offset,所以segment:offset所能表示的最大为10ffefh,而这个地址超过了1M,但是超过1M会发生“回卷”的现象不会报错,但是从下一代的80286开始,地址线成为了24位,所能访问的地址空间超过了1M,此时寻址超过1M时会报错,出现了向下不兼容,所以为了解决这个问题采用了A20机制。
A20 Gate,将A20地址线控制和键盘控制器的一个输出进行AND操作,这样来控制A20地址线的打开与关闭,所以在实模式下,需要确保我的A20开关处于关闭状态,这样可以防止访问大于1M的地址空间,但是在保护模式下,我们需要访问更大的内存空间,所以需要将A20的开关打开,如果在保护模式下,A20的开关未打开的话,此时我们只能访问奇数兆的内存,即只能访问0—1M,2—3M,4—5M……,所以如果我们要进入保护模式,首先就需要把A20开关给打开。
接下来我们需要了解下GDT表(全局描述符表),在整个操作系统中我们只有一张GDT表,GDT可以放在内存的任意位置,但是CPU必须知道GDT的入口,在Intel里面有一个专门的寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存的某个位置之后,可以通过LGDT指令将GDT的入口地址加载到该寄存器里面,以后CPU就可以通过GDTR来访问GDT了。
最后我们需要了解如何使能和进入保护模式,关于这一点我们需要了解一个寄存器CR0,首先我们来看下CR0寄存器的各个位代表什么:
在这里由于我们需要进入保护模式,所以暂时可以先不用管其他的位,只需关注最低位的PE即可,PE是启用保护位(protection enable),当设置该位的时候即开启了保护模式,系统上电复位的时候该位默认为0,于是便是实模式;当PE置1的时候,进入保护模式,实质上是开启了段级保护,只是进行了分段,没有开启分页机制,如果要开启分页机制的话我们需要同时置位PE和PG。
有了初步了解之后我们便知道的开启保护模式的相关操作,首先开启A20 Gate,其次加载全局描述符表GDT,最后只需要将CR0寄存器的最低位置为1即可。
接下来我们通过观察代码来查看UCore具体是如何实现相应的操作的:
首先是开启A20,根据上文我们知道需要将第20位为1即可,但是我们需要知道在UCore里是如何将A20置为1的。根据说明书我们可以知道,A20地址线由键盘控制器8042进行控制,我们的A20所对应的是8042里面的P21引脚,所以问题就变成了我们需要将P21引脚置1。
对于8042芯片来说,有两个端口地址60h和64h。对于这两个端口来说,0x64用来发送一个键盘控制命令,0x60用来传递参数,所以将P21引脚置1的操作就变成了,我们首先利用0x64输入一个写入的指令,然后由0x60读进去相应的参数来将P21置1。
由以下的资料我们可以知道,我们首先要先向64h发送0xd1的指令,然后向60h发送0xdf的指令。
在这里可能有人会有疑问,既然我们只需要将P21置为1就可以了,那么我们是不是可以传入多种不同的参数,只需要对应的位为1就好了,答案是不行的。我们传入的0xdf参数在这里也相当于一条指令,通过这条指令我们可以将A20的开关打开。
在这里我们还需要注意一个问题就是当前端口(60h或者64h)是否空闲,只有当这两个端口空闲的时候我们才可以向其传入数据,等待其空闲的代码为:
在这里testb $0x2, %al是用来检测64h端口是否为空闲,当输入缓存区为空,即我们可以向其传入数据时,64h端口中的状态寄存器的值为0x2,所以我们可以通过这条指令来等待64h端口空闲。在等到64h空闲之后我们会写入0xd1,表明我们要向60h里面写入数据。
接下来我们需要继续通过64h端口来判断8042芯片是否空闲,在等到空闲之后,我们将0xdf写入60h端口,至此来打开A20开关。
然后我们需要加载GDT全局描述符。在代码里我们看到只用了一句指令便实现了加载GDT的操作:
所以,我们在这里继续跟踪这条指令,来看下这条指令具体是怎么加载GDT全局描述符的。
我们首先追踪gdtdesc可以看到,它里面有两个参数,首先是word 0x17表示的是我们GDT表的大小,其次是long gdt表示的是我们GDT表的入口地址,然后我们可以看到上面便是gdt的定义。根据资料的查询我们可以知道,GDT全局描述符表由三个全局描述符组成,根据规定,第一个均为空描述符,第二个为代码段描述符,第三个为数据段描述符,所以在这里会相应的对应的三个内联汇编用来实现相应的操作,至此,我们的GDT表顺利加载完成。
接下来我们来继续观察对于寄存器CR0的操作:
一共包括了三个操作,首先将cr0寄存器里面的内容取出来,然后进行一个或操作,最后将得到的结果再写入cr0中,由上文我们知道,在这里需要将cr0的最低位设置为1,所以我们的或操作是用来使得cr0的最低位为1的操作,也就是说我们的CR0_PE_ON的值必须为1,这样才可以达成目的,然后通过查询CR0_PE_ON的定义我们发现的确为1,所以顺利开启PE位。
最后通过一个长跳转指令正式进入保护模式。
bootloader如何读取硬盘扇区的?
在上一个联系中我们的BootLoader已经成功的进入了保护模式,接下来我们要做的就是从硬盘读取并运行我们的OS。对于硬盘来说,我们知道是分成许多扇区的其中每个扇区的大小为512字节。读取扇区的流程我们通过查询指导书可以看到:
1、等待磁盘准备好;
2、发出读取扇区的命令;
3、等待磁盘准备好;
4、把磁盘扇区数据读到指定内存。
接下来我们需要了解下如何具体的从硬盘读取数据,因为我们所要读取的操作系统文件是存在0号硬盘上的,所以,我们来看一下关于0号硬盘的I/O端口:
在这里我们可以看到,对于0号硬盘的读取操作是通过一系列的寄存器完成的,所以在读取硬盘时我们也是通过对这些硬盘进行操作从而得到相应的数据。
通过上面对硬盘知识的一些了解之后,我们开始观察具体的实现过程:
在这里我们需要通过观察readdseg函数,来了解所传进去的各个参数的用途:
在这里我们可以看到传进去的第一个参数是一个虚拟地址va,第二个是count(我们所要读取的数据的大小),第三个是offset(偏移量),关于SECTSIZE的定义我们通过追踪可以看到是512,即一个扇区的大小,所以在这里调用readseg函数从ELFHDR处读取8个扇区的大小。
接下来我们会跳转到readseg函数中:
在这里我们的offset是用来定位的参数,通过va减去offset模SECTSIZE我们可以得到va所在的块的首地址,cecno用来存储我们需要读取的磁盘的位置,然后通过一个for循环一次从磁盘中读取一个整块,并存到相应的虚存va中,然后继续对虚存va和secno进行自加操作,直到读完所需读的东西为止。
接下来是真正的对于磁盘的操作:
正如我们所讲,这里首先等待磁盘准备就绪:
根据我们上面的了解知道关于检查磁盘是否准备就绪需要检查0x1F7的最高两位,如果是01,那么证明磁盘准备就绪,跳出循环,否则继续等待。
然后0x1F2,0x1F3,0x1F4,0x1F5,0x1F6,0x1F7分别对应不同功能的寄存器,然后完成相应的操作,即读取相应的内容到寄存器里面,然后再次等待磁盘准备就绪后,将刚才存入寄存器的数据读入我们的dst中,对应的是我们传入的虚拟地址va。
bootloader是如何加载ELF格式的OS?
接下来我们需要读取ELF格式的OS,在读取ELF格式的OS之前我们需要了解ELF格式的文件在UCore里面是如何进行存储的,首先我们来观察一下用来读取ELF的结构体elfhdr。
在这里我们只需要关注其中的几个参数,e_magic,是用来判断读出来的ELF格式的文件是否为正确的格式;e_phoff,是program header表的位置偏移;e_phnum,是program header表中的入口数目;e_entry,是程序入口所对应的虚拟地址。
由于我们需要把ELF格式的OS加载到内存中的程序块中,所以我们需要了解下在内存中进程块是如何存储的:
在这里我们需要了解一些参数,p_va,一个对应当前段的虚拟地址;p_memsz,当前段的内存大小;p_offset,段相对于文件头的偏移。
了解了程序在磁盘和内存中分别的存储方式之后我们就需要开始从内存中读取数据加载到内存中来。由于上问的操作,我们将一些OS的ELF文件读到ELFHDR里面,所以在加载操作开始之前我们需要对ELFHDR进行判断,观察是否是一个合法的ELF头:
接下来我们需要开始从磁盘中加载OS,首先定义了两个程序头表段,ph,eph,其中ph表示ELF段表首地址,eph表示ELF段表末地址:
接下来通过循环读取每个段,并且将每个段读入相应的虚存p_va中。
最后调用头表中的内核入口地址实现内核链接地址转化为加载地址,无返回值。
在练习五中我们需要实现函数调用堆栈,因此我们需要首先针对函数堆栈的操作做一些相关的了解,对于函数堆栈来说可以分为以下三部分操作,首先保存原相关寄存器的状态,即将相关参数以及寄存器的当前状态压入栈;其次在栈中进行函数操作,即完成函数的相关功能;最后释放栈空间,回复原寄存器状态。
要实现以上的相关操作我们就需要对函数栈的结构有相关的了解:
在每次进行函数调用的时候,首先会将函数的参数自右向左压入栈中,所以从图中我们可以看到参数的顺序是从参数3到参数1,然后将返回地址压入栈,即下条指令的地址压入栈,接着把原ebp的值压入栈,便于稍后的恢复。
对于返回地址和上一层ebp的压入是通过两句汇编实现的:
当我们传完参数时,我们进行push操作,将原ebp的值压入栈,此时我们的ebp寄存器所指的位置是上一层ebp的位置,然后通过一个movl操作将返回地址压入对应的栈,便实现了对函数栈的搭建。
所以一般而言,ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用4字节内存),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层ebp值。由于ebp中的地址处总是“上一层函数调用时的ebp值”,而在每一层函数调用中,都能通过当时的ebp值“向上(栈底方向)”能获取返回地址、参数值,“向下(栈顶方向)”能获取函数局部变量值。
最后在函数调用结束后我们只需要将ebp还原,并且跳转到返回地址即可。
接下来我们来观察具体实现的代码:
首先通过两个函数得到寄存器ebp和eip的值,并存到变量里。
接下来通过一个for循环来循环输出栈内的相关参数,首先获取栈传入的参数,根据上面的分析我们可以知道第一个参数存在ebp+8的位置,在这里是通过ebp+2来实现的,因为在这里2是int型,所以可以得到第一个参数,然后我们需要得到原ebp以及返回地址的值,根据分析我们知道原ebp的值就存在ebp的位置,eip的值存在ebp+4的位置,所以在这里通过数组的操作实现具体功能。
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
中断描述符表一个表项占8字节。其中0~15位和48~63位分别为offset的低16位和高16位。16~31位为段选择子。通过段选择子获得段基址,加上段内偏移量即可得到中断处理代码的入口。
请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的 vectors数组即可。
在第二问中我们需要对所有的中断入口进行初始化,在这里我们首先需要对中断有一个大概的了解。
在操作系统中,有三种特殊的中断事件。由CPU外部设备引起的外部事件如I/O中断、时钟中断、控制台中断等是异步产生的(即产生的时刻不确定),与CPU的执行无关,我们称之为异步中断(asynchronous interrupt)也称外部中断,简称中断 (interrupt)。而把在CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件称作同步中断(synchronous interrupt),也称内部中断,简称异常(exception)。把在程序中使用请求系统服务的系统调用而引发的事件, 称作陷入中断(trap interrupt),也称软中断(soft interrupt),系统调用(system call)简称trap。在后续试验中会进一步讲解系统调用。
而对于中断描述符表idt来说把每个中断或异常编号和一个指向中断服务例程的描述符联系起来。同GDT一样,IDT是一个8字节的描述符数组,但IDT的第一项可以包含一个描述符。CPU把中断(异常)号乘以8做为 IDT的索引。IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址。指令LIDT和SIDT用来操作IDTR。两条指令都有一个显示的操作数:一个6字节表示的内存地址。
根据中断的分类我们可以了解到,我们在进行初始化时是需要对终端进行分类处理的,针对不同的权限进行不同的初始化,因此我们在编写代码时需要注意内核权限和用户权限的区分,通过指导书我们可以了解到,对于我们的UCore来说只有从用户态转化为内核态时权限是用户权限,所以我们在进行初始化时只需要将这一点拿出来单独初始化即可。
首先,我们会定义一个__vectors[]数组用来对应中断描述符表中的256个中断符,然后通过for循环运用SETGATE函数进行中断门的初始化,接下来我们来追踪SETGATE函数进行继续观察:
我们可以看到在这里,通过对一些参数的设置完成初始化过程,关注下我们传入的参数,首先是idt[i],在这里就是对应的门的编号;0,在这里是我们传入的istrap,传入0表示是一个中断门,而不是陷阱;GD_KTEXT
我们可以看到是一个全局变量,可以读到内核的text,在这里相当于段选择子,可以选择需要的数据;__vectors[],读取0~15低16位的数据;DPL_KERNEL,权限级设置,标明拥有内核权限。
所以在这里通过循环,完成了对于所有中断情况的初始化,那么接下来,就需要对用户态转内核态的中断表进行初始化了,和上面的不同之处只是在于特权值的不同,所以我们的操作如下:
最后加载idt全局描述符表。
请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
由于在上一问我们已经将idt中断向量符表完成了初始化的操作,所以我们在这里可以直接对其进行调用即可,在这里我们需要了解下调用中断的一个大体流程。
中断描述符表是一个表项占用8字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成位移,两者联合便是中断处理程序的入口地址。
我们可以看到当出发了中断之后,我们可以通过IDTR寄存器来查找到相应的中断号,我们可以通过IDT.base + 8*n可以找到相应中断的地址,然后跳转到具体中断的执行程序中就可以完成中断处理。
所以在第三问我们需要调用时钟中断,并且完成对于时钟中断的相关操作。
TICK_NUM通过追踪我们可以知道是个全局变量100,所以我们通过ticks来计数,每当ticks计数达到100时,即出发了100次时钟中断后,时钟中断会print“100 ticks”。