实验一:系统软件启动过程
一、 实验目的
- 了解操作系统开发实验环境
- 熟悉命令行方式的编译、调试工程
- 了解计算机启动的相关原理,包括CPU的编址与寻址、CPU的中断机制以及外设
- 理解Bootloader软件的重要作用,了解编译运行和调试bootloader的过程、PC启动 bootloader的过程以及ELF执行文件的格式与加载
- 掌握ucore OS软件,知晓编译运行和启动ucore OS的过程,在汇编级了解函数调用栈的 结构和处理过程
二、 实验任务
- 理解通过make生成执行文件的过程
- 使用qemu执行并调试lab1中的软件
- 分析bootloader进入保护模式的过程
- 分析bootloader加载ELF格式的OS的过程
- 实现函数调用堆栈跟踪函数
- 完善中断初始化和处理
- 扩展proj4,增加syscall功能
三、 实验内容
(一) 练习1:理解通过make生成执行文件的过程
1. 操作系统镜像文件 ucore.img 是如何一步一步生成的?
分析makefile文件,得到如下过程:
1.1. 生成ucore.img需要kernel和bootblock
生成ucore.img的代码如下:
首先先创建一个大小为10000的块,然后再将bootblock拷贝过去。
生成ucore.img需要先生成kernel和bootblock
1.2. 生成kernel
然后根据其中可以看到,要生成kernel,需要用GCC编译器将kern目录下所有的.c文件全部编译生成的.o文件的支持,make V=可以看到细节;
通过ld语句将所有的.o文件连接为可执行文件,即为kernel。
1.3. 生成bootblock
得到要生成bootblock,首先需要生成bootasm.o、bootmain.o、sign,make V=得到细节:
最后链接为bootblock
1.4. 生成ucore.img
根据make V=得到的详细情况:
创建大小为10000个块的ucore.img,初始化为0
把bootblock中的内容写到第一个块
从第二个块开始写kernel中的内容
对应着:
生成ucore.img的整个的过程为:
①编译所有生成bin/kernel所需的文件
②链接生成bin/kernel
③编译bootasm.S bootmain.c sign.c
④根据sign规范生成obj/bootblock.o
⑤生成ucore.img
2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
观察sign.c文件中的信息:
磁盘主引导扇区只有512字节
磁盘最后两个字节为0x55AA
初始时空间填0
(二) 练习2:使用qemu执行并调试lab1中的软件
1. 从 CPU加电后执行的第一条指令开始,单步跟踪 BIOS的执行。
1.1. 修改lab1/tools/gdbinit,
set architecture i8086
target remote :1234
1.2. 执行make debug,进入调试:
停在第一条指令:
0xffff0: ljmp $0xf000,$0xe05b
经过si单步运行后(过长,因此跳过中间部分)
得到中端输出的这样的信息:
2. 在初始化位置 0x7c00 设置实地址断点,测试断点正常
在gdb中设置断点后运行c:
可以看到成功停止,断点正常;
3. 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较
此时我们查看它的十条反汇编代码,与bootasm.S和 bootblock.asm进行比较:
bootasm.S中代码:
bootblock.asm中代码:
可以看出代码是一致的;
4. 自己找一个bootloader或内核中的代码位置,设置断点并进行测试
设置断点在0x7c0a处,如下:
0x7c0a处的bootblock.asm的汇编代码如下:
成功达到断点,并且后面的汇编指令与bootblock.asm的一致:
(三) 练习3:分析bootloader进入保护模式的过程
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。
查看lab1/boot/bootasm.S源码,得到以下步骤:
-
首先关闭中断,将各个段寄存器重置清零:
-
开启A20
开启A20地址线之后,用来表示内存地址的位数变多了。开启前20位,开启后是32位。如果不开启A20地址线内存寻址最大只能找到1M,对于1M以上的地址访问会变成对address mod 1M地址的访问。通过将键盘控制器上的A20线置于高电位,全部32条地址线可用,可以访问4G的内存空间。
打开A20地址线为了兼容早期的PC机,第20根地址线在实模式下不能使用所以超过1MB的地址,默认就会返回到地址0,重新从0循环计数,下面的代码打开A20地址线:
流程:
①等待8042 Input buffer为空;
②发送Write 8042 Output Port (P2)命令到8042 Input buffer;
③等待8042 Input buffer为空;
④将8042 Output Port(P2)得到字节的第2位置1(第二位控制A20信号线的开启与否),然后⑤写入8042 Input buffer;
等待的过程就是前面三行不断测试的过程,如果从0x64端口读出的Control Register的第二位(标识input register是否有数据)为1(通过test语句),那么继续执行这一过程;
写Output Port:向64h发送0d1h命令,然后向60h写入Output Port的数据;
系统向输入缓冲(端口0x64)写入一个字节,即发送一个键盘控制器命令。可以带一个参数。参数是通过0x60端口发送的。 命令的返回值也从端口 0x60去读。
-
初始化GDT表
保护模式下,有两个段表:GDT(Global Descriptor Table)和LDT(Local Descriptor Table),每一张段表可以包含8192 (2^13)个描述符[1],因而最多可以同时存在2 * 2^13 = 2^14个段。
在ucore lab中只用到了GDT,没有用LDT。
一个简单的GDT表和其描述符已经静态存储在引导区中,载入即可:
-
进入保护模式
通过将cr0寄存器PE位置1便开启了保护模式,即cro的第0位为1表示处于保护模式
-
通过长跳转到32位代码段,重装CS、EIP以及DS、ES等寄存器
上面已经打开了保护模式,所以这里需要用到逻辑地址。$PROT_MODE_CSEG的值为0x80
寄存器更新:
- 转到保护模式完成,进入bootmain主函数
(四) 练习4:分析bootloader加载ELF格式的OS的过程
bootloader让CPU进入保护模式后,下一步的工作就是从硬盘上加载并运行OS。考虑到实现的简单性,bootloader的访问硬盘都是LBA模式的PIO(Program IO)方式,即所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。
首先观察bootmain.c中的代码,之后回答问题;
- bootloader如何获取硬盘扇区?
根据bootmain函数分析,首先是由readseg函数读取硬盘扇区,而readseg函数则循环调用了真正读取硬盘扇区的函数readsect来每次读出一个扇区。
readsect从设备的第secno扇区读取数据到dst位置
I/O地址 功能如下:
0x1f0 读数据,当0x1f7不为忙状态时,可以读。
0x1f2 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区
0x1f3 如果是LBA模式,就是LBA参数的0-7位
0x1f4 如果是LBA模式,就是LBA参数的8-15位
0x1f5 如果是LBA模式,就是LBA参数的16-23位
0x1f6 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘
0x1f7 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据
大致流程如下:
(1)等待磁盘准备好
(2)发出读取扇区的命令
(3)等待磁盘准备好
(4)把磁盘扇区数据读取到指定内存
- bootloader是如何加载ELF格式的OS?
本实验的ELF的文件格式:用于执行的可执行文件(executable file),用于提供程序的进程映像,加载的内存执行。
上面从硬盘中读取完OS数据后,bootloader加载ELF格式的文件
ELF头文件的定义在libs/elf.h文件中,这里只需要关心几个数据:
e_magic,是用来判断读出来的ELF格式的文件是否为正确的格式;
e_phoff,是program header表的位置偏移;
e_phnum,是program header表中的入口数目;
e_entry,是程序入口所对应的虚拟地址。
program header描述与程序执行直接相关的目标文件结构信息,用来在文件中定位各个段的映像,同时包含其他一些用来为程序创建进程映像所必需的信息。可执行文件的程序头部是一个program header结构的数组, 每个结构描述了一个段或者系统准备程序执行所必需的其它信息。
uint type; // 段类型
uint offset; // 段相对文件头的偏移值
uint va; // 段的第一个字节将被放到内存中的虚拟地址
uint memsz; // 段在内存映像中占用的字节数
过程为:
从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用;
校验e_magic字段;根据偏移量分别把程序段的数据读取到内存中。
(五) 练习5:使用函数调用堆栈跟踪函数
我们需要在lab1中完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址。
-
理解函数堆栈最重要的两点是:栈的结构,以及EBP寄存器的作用。
一个函数调用动作可分解为零到多个 PUSH指令(用于参数入栈)和一个 CALL 指令。CALL 指令内部其实还暗含了一个将返回地址压栈的动作,这是由硬件完成的。几乎所有本地编译器都会在每个函数体之前插入类似如下的汇编指令:
pushl %ebp
movl %esp,%ebp
而由此我们可以直接根据ebp就能读取到各个栈帧的地址和值,一般而言,ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用 4 字节内存,对应32位系统),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层 ebp 值。 -
print_stackframe实现
根据其中的注释其实可以简单地得到思路;
①可以通过read_ebp()和read_eip()函数来获取当前ebp寄存器和eip 寄存器的信息。
②然后通过ebp+12,ebp+16,ebp+20,ebp+24来输出4个参数的值,最后更新ebp:ebp=ebp[0],③更新eip:eip=ebp[1]
转换得到如下代码:
结果如图:
结果与实验指导书中大致一致;
最后一行的解释:
其对应的是第一次调用堆栈的函数,bootmain.c中的bootmain。
bootloader设置的堆栈从0x7c00开始,使用”call bootmain”转入bootmain函数。
call指令压栈,所以bootmain中ebp为0x7bf8。eip表示当前的PC寄存器的值,由于代码不同,所以可能各不相同。
(六) 练习6:完善中断初始化和处理
- 中断向量表中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
中断描述符表(Interrupt Descriptor Table) 中断描述符表把每个中断或异常编号和一个指向中断服务例程的描述符联系起来。同GDT一样,IDT是一个8字节的描述符数组,但IDT的第一项可以包含一个描述符。CPU把中断(异常)号乘以8做为IDT的索引。IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址。
中断向量与中断服务例程起始地址关系:
其中015位和4863位分别为offset的低16位和高16位。16~31位为段选择子。使用段选择符中的偏移值在GDT(全局描述符表) 或 LDT(局部描述符表)中定位相应的段描述符。利用段描述符校验段的访问权限和范围,以确保该段是可以访问的并且偏移量位于段界限内。利用段描述符中取得的段基地址加上偏移量,形成一个线性地址。
- 请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init
该段注释的重点:
第一步,声明__vertors[],其中存放着中断服务程序的入口地址。这个数组生成于vertor.S中。
第二步,填充中断描述符表IDT(其中T_SWITCH_TOK是在用户态调用的因此需要特权级设置为用户级,其余全是内核级)。
第三部,加载中断描述符表。
根据注释写出整个代码:
SETGATE的参数含义:
传入的第一个参数gate是中断的描述符
传入的第二个参数istrap用来判断是中断
传入的第三个参数sel的作用是进行段的选择( GD_KTEXT 定义在memlayout.h中,是表示全局描述符表中的内核代码段选择子 )
传入的第四个参数off表示偏移
传入的第五个参数dpl表示这个中断的优先级
- 请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
在trap_dispatch中,如果次数ticks达到了该中断的次数,那么就调用函数进行打印并且清零。
测试得到如下结果:
运行正确;
(七) 扩展练习1
扩展proj4,增加syscall功能,即增加一用户态函数(可执行一特定系统调用:获得时钟计数值),当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务。
在kernel_init函数中:
去掉注释;
swith_test测试函数以及它的两个状态转换函数都已经给出;
switchto* 函数建议通过中断处理的方式实现。主要要完成的代码是在 trap 里面处理 T_SWITCH_TO* 中断,并设置好返回的状态。
思路:
-
我们需要知道:int指令和iret指令到底做了什么、cpu是如何表示特权级状态的
int n指令:
CPU根据中断向量,从 IDT 中获得第 n 个中断描述符,中断描述符里保存着中断服务例程的段选择子
CPU使用IDT查到的中断服务例程的段选择子从GDT中取得相应的段描述符,段描述符里保存了中断服务例程的段基址和属性信息,此时CPU就得到了中断服务例程的起始地址,并跳转到该地址
CPU会根据CPL和中断服务例程的段描述符的DPL(DPL 是描述符中记录的特权级)信息确认是否发生了特权级的转换。
若发生则立即将系统当前使用的栈切换成新的内核栈。这个栈就是即将运行的中断服务程序要使用的栈;紧接着就将当前程序使用的用户态的ss和esp压到新的内核栈中保存起来依次将%eflags %cs %eip errorCode压栈,置 %cs 和 %eip 为描述符中的值,开始执行中断程序
iret指令:动作也是类似的,因为int指令和iret指令是一对的,其指令的步骤如下:
①首先会从内核栈里弹出先前保存的被打断的程序的现场信息,即eflags,cs,eip重新 开始执行;
②若发生特权级转换,则还需要从内核栈中弹出用户态栈的ss和esp,这样也意味着栈 也被切换回原先使用的用户态的栈了; -
中断处理过程:
(1)产生中断后,CPU 跳转到相应的中断处理入口 (vectors)。如果特权级发生变化,必须将当前的ss和esp压栈;然后是EFLAGS;清除标志触发器TF和IF;CS和EIP也跟着压进去;接着在栈中压入相应的error_code(是否存在与异常号相关) 以及 trap_no,然后跳转到 alltraps 函数入口;
(2)在栈中保存当前被打断程序的 trapframe结构(参见过程trapasm.S)。设置 kernel的数据段寄存器,最后压入 esp,作为 trap 函数参数(struct trapframe* tf)并跳转到中断处理函数 trap 处;
(3)根据中断号对不同的中断进行处理。
(4)结束 trap 函数的执行后,通过 ret 指令返回到 alltraps 执行过程。从栈中恢复所有寄存器的值。调整 esp 的值:跳过栈中的 trap_no 与error_code,使esp指向中断返回 eip,通过 iret 调用恢复 cs、eflag以及 eip,继续执行。
根据中断处理过程可以写出以下代码:
push两次预留了用户栈ss、esp的空间,int $T_SWITCH_TOU进入相应中断完成内核态到用户态转换的过程,
movl %%ebp, %%esp为转换到用户态后将lab1_switch_to_user函数处于刚进入函数执行的状态(esp = ebp)
make grade测试得到结果:
测试正确!
四、 实验总结
通过本次实验,我了解到了很多书本中没有提及到的操作系统方面的细节。这里涉及到了计算机系统中学习过的ELF文件,代码实现ELF使得我对这一格式文件有了更深入的理解;同样的知识点代码实现还有CPU编址寻址、CPU的中断机制等等,通过对代码的理解,书上很多不是很清楚或是浅层的知识得以理解与延申巩固。