1.实验目的
操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。通过分析和实现这个bootloader和ucore OS,读者可以了解到:
(1)计算机原理
- CPU的编址与寻址: 基于分段机制的内存管理
- CPU的中断机制
- 外设:串口/并口/CGA,时钟,硬盘
(2)Bootloader软件
编译运行bootloader的过程
- 调试bootloader的方法
- PC启动bootloader的过程
- ELF执行文件的格式和加载
- 外设访问:读硬盘,在CGA上显示字符串
(3)ucore OS软件
- 编译运行ucore OS的过程
- ucore OS的启动过程
- 调试ucore OS的方法
- 函数调用关系:在汇编级了解函数调用栈的结构和处理过程
- 中断管理:与软件相关的中断处理
- 外设管理:时钟
2.实验内容
lab1中包含一个bootloader和一个OS。这个bootloader可以切换到X86保护模式,能够读磁盘并加载ELF执行文件格式,并显示字符。而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。
练习1:理解通过make生成执行文件的过程。
在此练习中,大家需要通过静态分析代码来了解:
1. 操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
首先执行指令make "V=" 可得到如下结果:
从上图看出,Makedile的过程主要是三部分:
(1)
- 调用gcc,将C的源代码编译成.o目标文件
- 调用ld,将一系列目标文件链接成可执行程序
- 调用dd,将bootblock和kernel内容放入到虚拟硬盘ucore.img中
(4) 生成bootblock,如下图。可以看到要生成bootblock,首先需要生成bootasm.o、bootmain.o、
2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
练习2 理解通过make生成执行文件的过程
[1]从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
[2] 在初始化位置0x7c00设置实地址断点,测试断点正常
[3]从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和bootblock.asm进行比较
[4]自己找一个bootblock或内核中的代码位置,设置断点并进行测试
使用命令 b *0x7c14 在0x00007c14处设置断点,输入命令 c 使其运行至断点处停止:
在0x7c14处使用si和x/i $pc 进行单步调试并反汇编:
bootblock.S 中的代码为:
bootblock.asm 中的代码为:
对比发现,上述反汇编指令与两个汇编文件相同
练习3:分析bootloader进入保护模式的过程
1.为何开启A20,以及如何开启A20?
【1】A20的存在是为了保持向下兼容性,一开始A20地址线控制是被屏蔽的,直到系统软件通过一 定的IO操作打开它,开启之后才能访问高端内存,即保护模式下开关必须开启。换句话说,未开启 A20时,此时A20为0,软件可访问的物理内存空间不能超过1MB,并且无法发挥Intel 80386以上级 别的32位CPU的4GB内存管理能力,而开启A20,将其置为1,才可访问4G内存。
【2】A20开启方式如下:
等待8042 Input buffer为空 发送Write 8042 Output Port命令到8042 Input buffer 等待8042 Input buffer为空 将8042 Output Port得到的字节的第2位置为1,然后写入8042 Input buffer
【3】由bootasm.S中的Enable A20部分代码可查看具体关于开启A20的实现
2. 如何初始化GDT表
【1】初始化GDT表直接通过加载全局描述符的lgdt指令来直接完成,如下面第一个图所示; gdtdesc对应一个内存地址,由于GDTR寄存器6个字节,存放GDT表的内存起始地址和表长度,所 以此条指令是将gdtdesc指向的内存地址开始的6个字节存到GDTR中。
3. 如何使能并进入保护模式
【1】将CR0中的PE位置1,即可使能并从实模式切换到保护模式;
【2】执行一系列指令,重置段寄存器,分配堆栈空间,完成后调用bootmain将kernel加载进内存 之中
练习4:分析bootloader加载EIF格式的OS过程
1. bootloader如何读取硬盘扇区的
【1】EIF文件格式是Linux系统下的一种常用目标文件格式,本实验中的EIF类型为用于执行的可执 行文件(executable file),用于提供程序的进程映像,加载到内存执行,读取硬盘扇区关联的函数是 bootmain.c中的readsect函数,整个加载过程即为对应的bootmain函数;
【2】指导书中已经给出了磁盘IO地址和对应功能以及一个扇区的流程,如下图所示;
【3】查看bootmain.c中的readsect函数,开启过程如上图所示,对0x1F2的操作指定了每次读取1 个扇区,对0x1F3-0x1F6的操作共同指定了读取的扇区号,对0x1F7的操作中0x20的指令用来读取 扇区。
2. bootloader是如何加载EIF格式的OS
【1】EIF header在文件开始处描述了整个文件的组织,EIF的文件头包含整个执行文件的控制结 构,定义在elf.h中,由上面的分析可得:加载整个kernel的过程本质上就是循环读取扇区的过程, 涉及radseg函数和bootmain函数;
【2】下图为readseg函数,secno从1开始,0扇区对应的是主引导区,从1扇区开始是kernel部分;
【3】bootmain主函数如下图所示,首先读取EIF的头部,然后由EIF文件头格式要求,要先对比 e_magic是否等于EIF_MAGIC来确认EIF的合法性,确认后按照头格式中加载位置、入口信息等值 将EIF加载进内存并找到内核入口,完成后即加载过程结束,进入内核执行。
练习5:实现函数调用堆栈跟踪函数
【1】通过下图所示的函数print_stackframe来跟踪函数调用堆栈中记录的返回地址,即从0-当前深度将 调用的所有函数全部打印出来,包括ebp、eip和参数列表args;
【2】执行make qemu,得到关于堆栈调用部分的代码,每一层调用均会输出ebp、eip和args的值,并 且指明调用函数的位置和关系,结果如下;
【3】最后一行实际上对应的是最开始的调用call bootmain,由于初始化后堆栈为空,栈顶在0x7c00位 置,所以调用后压入栈,栈顶指针变为0x7BF8,即ebp的值,如下图,eip指向0x7d64,即调用 kern_init后的指令地址,由于kern_init()无参数传入,故此处输出bootloader的二进制代码。
练习6:完善中断初始化和处理
1. 中断描述符表(保护模式下的中断向量表)中一个表项占多少字节,其中哪几位代表中断处理代码 的入口
【1】由指导书可知,中断描述符表IDT是一个8字节的描述符数组,所以一个表项占8个字节, CPU把中断异常号乘以8作为IDT的索引;
【2】由指导书给出的三种描述符知,除Task-gate未使用外,另外两个均可写成统一形式:
- 2-3字节作为段选择子
- 6-7字节和0-1字节拼接成4字节的偏移量
- 4-5字节决定描述符类型
- 除去4-5字节外剩余的6个字节联合给出中断处理代码的入口
2. 请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init,在idt_init函数中,依次对所 有中断入口进行初始化,使用mmu.h中的SETGATE宏,填充idt数组内容,每个中断入口由 tools/vectors.c生成,使用trap.c中声明的vectors数组即可
【1】按照要求编写代码如下图,循环对idt内所有的中断入口进行初始化,完成后即可通过LIDT指 令来加载IDT;
3. 请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分的填写trap函数中处理时钟 中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字 “100 ticks”
【1】编写代码如下图,当面对中断时对中断类型进行判断,实验中时钟中断和键盘中断易出现, 串口中断由于无相关设备连接所以未触发;
【2】利用make qemu得到结果如下图,当没有键盘输入时,由于时钟中断,每隔100 ticks会触发 中断并打印出100 ticks,当有键盘输入时会立刻触发键盘中断。
扩展练习 Challenge 1:
扩展proj4,增加syscall功能,即增加一用户态函数(可执行一特定系统调用:获得时钟计数值),当内 核初始完毕后,从内核态返回用户态的函数,而用户态的函数又通过系统调用得到内核态的服务
【1】内核初始化完毕后执行lab1_switch_test函数,发生两次切换,首先输出当前状态,内核态,然后 切换到用户态,输出状态后再切换回内核态并输出当前状态,内核态,编码及结果如下;
【2】上图中lab1_switch_to_kernel和lab1_switch_to_user函数采用内嵌汇编的写法,加入了volatile限 定符确保汇编指令不被优化修改,‘:’用来分隔不同部分,本处分隔汇编语句和输入部分,%0用作占位 符,输入部分用‘i’代表立即数,将中断号填入INT触发中断;
【3】下图为中断处理切换状态的代码,起始判断是否处在目的状态,若不是,则修改存储状态的相应 寄存器的值,据trapframe,需改CS/DS/ES/ESP等,并且根据trapframe中padding可验证前方的55AA 的小尾存储问题;
【4】EFLAGS寄存器的第12、13位为IOPL,指示当前运行任务的IO特权级,仅当运行任务的CPL不大 于IOPL时,才可访问IO地址,代码中| =0x3000和& =~0x3000分别置11(最小)和00(最大)。实验的 评测结果如下:
扩展练习 Challenge 2:
用键盘实现用户模式内核模式切换,具体目标:键盘输入3时切换到用户模式,输入0时切换到内核模式 【1】代码实现如下图,考虑键盘中断的分支,先输出当前键入字符,若输入为0\3且需要发生模式转 换,则按照T_SWITCH_TOU/TOK的方式来切换;
【2】实验结果如下图,可看出不需要状态切换时只是单纯的键盘中断,需要状态切换时调用 print_trapframe打印寄存器状态,可通过EFLAGS寄存器的值看状态切换结果,并且多次切换时仍可保 证正常工作。
实验感想
【1】本实验与以往的实验不同,实验总体难度大,需要大量的时间来详细阅读ucore指导书,关键对实 验代码的含义以及实验结果的理解,此外,本实验要求对Linux系统有一定的了解,并且会应用指令运 行调试代码;
【2】为了完成实验的相关要求,除了对实验本身有一定的理解外,还要有自己在网上能够搜索相关知 识并应用到实验上的整合能力;
【3】通过本次实验,收获了OS的很多相关知识,初步了解了make生成执行文件的过程以及如何查看 make具体执行的命令;知道了硬盘主引导扇区的作用和具体特征;了解了系统软件启动过程以及相关 代码的编写,掌握了利用make qemu设断点调试查看的方法;明白了与进入保护模式相关的A20、GDT 表等的设置方法;通过函数调用堆栈跟踪函数观察了函数调用时出栈、入栈的情况并且通过中断代码的 简单编写,实现了中断的初始化和处理,另外,通过对中断初始化和处理过程的直接观察,对中断的内 在执行原理有了更深的了解。
【4】本实验中的两个challenge均是针对OS中重要的状态转换细节而设计的。challenge1增加syscall, 使得可以从用户态转换到内核态,采用中断来处理状态转换的方法,在switch语句对应的case中填写相 应转换状态所需的代码来完成;challenge2更进一步地实现“人机交互”,通过键盘键入来完成指定的状 态转换,编写的代码需要对应到键盘中断对应的case之中,再根据输入的字符来判断具体的状态转换方 向,要注意硬件中断执行的时候本身就是内核态的事实!