操作系统的镜像、引导和内核调试
最近看完了《现代操作系统》一书,想着顺便找个lab做做看,体验一下写操作系统的快乐,同时加深一下对操作系统的理解。然后就看到了清华大学的ucore lab,这个lab是清华大学操作系统课程的lab,是一个很好的入门lab,所以就决定做做这个lab玩一玩。
下面给出这个lab对应的视频、github仓库和实验指导书
- 视频 : 清华 操作系统原理
- Github : ucore_lab
- 实验指导书 : ucore_lab_guide
另外,需要注意的一点就是,这个仓库的代码是存在一些问题的,如果在比较新版本的GCC下运行,容易出现以下两个问题:
- 生成的bootloader超过一个扇区,导致无法生成镜像
- 内核初始化在lgdt函数中无限循环
原因是-fPIC在 GCC 的较新版本 (>=7) 中默认启用,会生成位置无关的代码,导致出错。
解决方法是:修改每个Lab中的MakeFile,为两处CFLAG添加-fno-PIC
选项
0X00 环境准备
先把ucore的代码拉下来,装好对应的环境。这里要注意的,不带GUI的linux似乎是不可以的(得用X11转发,我觉得挺麻烦的,没弄)。我一开始打算直接vscode+remote ssh来做,然后在make debug的时候报错。猜测是使用QEMU硬件模拟器的问题。
ucore就是一个简单的操作系统,真实操作系统的部分它一个不少,但是它的目的是让我们学习操作系统的原理,所以它的代码量是很少的。同时呢,它也避开了各种先进CPU的奇奇怪怪的特性,针对的是i386这种比较简单的CPU。
但是很明显,我们很难找到一台i386的机器,所以我们需要一个模拟器来模拟这个CPU。这里我们使用的是QEMU,它是一个开源的模拟器,可以模拟很多CPU,包括i386。但是,QEMU是一个硬件模拟器,它模拟的是CPU、内存、硬盘、网卡等等,而不是一个操作系统。
因此,想要运行ucore这个操作系统,就只需要把它制作成一个镜像,然后使用QEMU来运行这个镜像就可以了。
0X01 ucore的镜像
其实,ucore的镜像制作并不需要我们去实现,进入到lab1文件夹中,执行make
就会在./bin/
中编译出多个程序,其中就包括ucore.img
这个镜像文件。我们主要就是来分析一下这个镜像文件是怎么做的,这也是lab1中练习1的内容。
其实只要执行make V=
就能更清楚的看到编译的过程,这里就不贴出来了。
我画了一个流程图来表示生成过程
上图中的蓝色的表示为源文件,黄色的为最终bin
目录中存在的文件。
制作一个镜像,首先需要编译出整个系统,因此需要把kern/*
的文件全部编译出来,这个目录下的文件涉及到了操作系统的内核,包括进程管理、内存管理、中断处理、调试和驱动等等。其中中断处理用到的中断向量表是由tools/vector.c
编译的程序动态生成的,因为中断向量表重复性很高。最终,就能得到一个操作系统的内核,也就是bin/kernel
这个文件。
但是仅有一个操作系统内核文件是不够的。系统启动时,BIOS会调用bootloader来负责将内核加载到内存中,然后跳转到内核的入口地址执行。因此,我们还需要一个bootloader,目录boot/
下的文件就是用于生成这个bootloader的。有一个硬性要求是这个bootloader程序被编译后的大小不能超过512-2个字节,其中512个字节是一个扇区的大小(也就是扇区0,主引导扇区),剩下两个字节是一个符合规范的硬盘主引导扇区的固定标志位,还有一个要求是代码的起始段地址为0x7c00,这个要求在编译时就能满足。
由于主引导扇区存在上述限制,而我们直接编译出来的bootloader显然不一定符合,我们需要一个程序来检查这个bootloader大小是否满足要求,同时再将固定位置的两个字节填上。这个程序就是tools/sign.c
编译得到的。
但是们编译的可执行文件是ELF格式,存在各个段,这些段会显著增加大小,而且我们实际上也用不到这些段,所以要去掉这些段,去除的指令为objcopy -S -O binary obj/bootblock.o obj/bootblock.out
。我们来看一下去除前后文件大小的比对
显然,去除前足足由4.1Kb,远远超过了限制。在去除之后就小于512B了,可以放到一个扇区内了。
再使用sign处理之后,就得到了一个512B的符合规范的主引导扇区了,这个二进制文件就是bin/bootblock
。
现在我们得到了存有bootloader的bootblock
,同时也得到了一个操作系统内核kernel
,然后就可以开始正式组装镜像了。首先使用dd
命令创建一个含有1000个块的文件,每个块大小512字节。然后把bootblock
放到第一块里面,从第二个块开始,放入kernel
。最后就得到了需要的镜像ucore.img
了。
0X02 ucore的引导
接下来,我们使用QEMU来模拟一个硬件,并在模拟的硬件平台上使用镜像来运行操作系统,同时通过GDB来监测运行情况。
首先先改写MakeFile,让QEMU将运行的汇编指令保存
1 |
|
修改tools/gdbinit
如下,使得GDB进去后立马进入调试,不需要其默认运行到0x7c00,我们从上电后的代码开始看。
1 |
|
然后执行make debug
来进行调试
执行b *0x7c00
来设置一个断点,然后执行c
来继续执行到断点,执行layout asm
来显示汇编,再执行若干条代码后退出即可。
我们来看一下记录的汇编指令
可以看到,CPU上电后,指令指针寄存器的值为0xffff fff0,且对应位置是一条长跳转指令。然后剩下的这些代码是BIOS,负责检查硬件、初始化一类的,在我们的模拟中占据了绝大篇幅,我们不必细究,只需要知道BIOS在执行完后会固定跳转到0x7c00处,这个位置就是我们的bootloader。
我们可以对比一下日志中记录的0x7c00处的汇编和boot/asm.s
文件中的代码段,两者其实是一样的,这更加说明了0x7c00处的代码就是我们的bootloader。
在调试过程中,可以执行layout asm
来显示汇编。但是可以发现BIOS部分的日志中执行的汇编和GDB调试的时候显示的汇编是对不上的。
这是因为BIOS首先运行在16位实模式下,而gdb 默认是32bit线性地址模式,调试BIOS的16bit代码(段地址)需要手动计算地址,计算公式如下:
Linear Addr = ( cs << 4 ) + ip
如果 CS=0x0000,EIP=0xffff,则 Linear Address=0xffff0
可以使用info register
查看寄存器
另外,为了正确反汇编16bit指令,可能需要在GDB中执行一下set architecture i8086
下面给出CPU上电后对镜像的引导过程:
- 首先,CPU上电后处于实模式下,也就是直接引用物理内存,执行的第一条指令位置为0xFFFFFFF0,是一条长跳转指令,会继续执行BIOS中的代码
- BIOS会做相当长的自检之类的功能,最后会跳转到0x7c00处,这个位置就是我们的bootloader,也就是主引导扇区的代码
- bootloader会进入到保护模式(下面会分析源码),然后加载内核镜像到内存中,然后跳转到内核镜像的入口处,开始执行内核代码
实模式就是采用CS:IP这样寻址,寻得是物理地址,共20位(就是8086那种寻址)
保护模式是使用GDT表,寻得是32位地址,可以寻址4G内存(本质上也是寻物理地址)
保护二字体现在CPU会在硬件上自动根据GDT、段选择子等信息,来判断访问的内存是否存在、是否合法
0X03 Bootloader的工作
因此,系统加载的关键就是这个512字节的Bootloader,它从BIOS手上接下控制权,并转入保护模式运行;它将内核加载到内存中,并将控制权交给内核。这个过程我们称之为引导。
我们的Bootloader主要就是boot/bootasm.s
和boot/bootmain.c
两个文件,下面我们来分析一下这两个文件。首先,先概述一下这两个文件的功能:
- boot/bootasm.s 是汇编代码,主要是切换到保护模式
- boot/bootmain.c 是C代码,主要是加载内核镜像到内存中,负责解析ELF格式的内核镜像
A20开关
地址线的第21位,也就是A20比较特殊。因为早期的8086只有20根地址线,寻址1Mb空间(也就是CS:IP,只有IP时只能寻址64k)。
后续的CPU,比如80396,有32根地址线,可以寻址4G空间。但是为了兼容早期的CPU,所以在80386中,引入了A20地址线,用来控制是否开启高位地址。
更具体的说,A20只是一个开关,当它打开的时候,A20线既可以为0,也可以为1,也就是正常工作;而当它关闭时,A20线只能为0。
所以,为了能让地址线全部正常工作,需要打开A20这个开关
但是由于历史原因,A20地址位由键盘控制器芯片8042管理。所以要给8042发命令激活A20。
给8042发命令时,先发送要写哪里,再发送要写什么。所以要两次写入,同时为了避免总线冲突,所以需要在汇编中存在一些循环重复测试
保护模式与A20
CR0是控制寄存器,其中包含了6个预定义标志,0位是保护允许位PE(Protedted Enable),用于启动保护模式。如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
也就是说,保护模式和A20开关没有必然的关系,即使不打开A20开关也是可以开启保护模式的,只是此时无法寻址一部分空间而已。
打开A20开关只是为了在保护模式时能够"满血"寻址所有的地址位。
1 | # boot/bootasm.s |
1 |
|
可以看到,两个文件做的事情都相当简单,无非就是打开保护,解析内核的ELF文件并进入内核。
0X04 内核调试之函数调用堆栈跟踪函数
这个其实是lab1的练习5,就是实现一下kern/debug/kdebug.c
中的print_stackframe
函数,这个函数的作用是打印函数调用堆栈的信息,包括函数名,函数参数,函数返回地址等等。
在实现之前,文档还贴心的介绍了一下CPU相关的寄存器:
- esp 栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
- ebp 基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
- eip 指令指针寄存器(extended instruction pointer),永远指向当前执行的指令的内存地址。
ebp指向的堆栈位置储存着caller的ebp,以此为线索可以得到所有使用堆栈的函数ebp。而ebp+4指向的是调用时的eip,也就是返回地址。ebp+8、+12这些则是可能的参数。
虽然它要求编程,但是lab中的文件不是已经编好了嘛...所以就分析一下叭。
1 |
|
下面是我的一次调用结果
下面解释一下最后一行
此时ebp的值是kern_init函数的栈顶地址,从前面练习我们知道,整个栈的栈顶地址为0x00007c00,ebp指向的栈位置存放调用者的ebp寄存器的值,ebp+4指向的栈位置存放返回地址的值,这意味着kern_init函数的调用者(也就是bootmain函数)没有传递任何输入参数给它!因为单是存放旧的ebp、返回地址已经占用8字节了。
eip的值是kern_init函数的返回地址,也就是bootmain函数调用kern_init对应的指令的下一条指令的地址,反汇编bootmain函数证实了这个判断。
args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8:一般来说,args存放的4个元素是对应4个输入参数的值。但这里比较特殊,由于bootmain函数调用kern_init并没传递任何输入参数,并且栈顶的位置恰好在bootloader第一条指令存放的地址的上面,而args恰好是kern_int的ebp寄存器指向的栈顶往上第2~5个单元,因此args存放的就是bootloader指令的前16个字节。
(需要注意下图中的数据都是小端存储的,因此每个字节需要反过来看)
0X05 内核调试之中断初始化和处理
众所周知,中断信号发生后,需要传递中断号,然后内核根据中断号来在中断向量表中查找的对应的中断处理函数,然后执行中断处理函数。
BIOS中断、DOS中断、Linux中断的区别
- BIOS和DOS都存在于实模式下,由它们建立的中断调用都是建立在中断向量表(Interrupt Vector Table,IVT)中的,都是通过软中断指令 int 中断号来调用。
- BIOS 中断调用的主要功能是提供了硬件访问的方法,该方法使对硬件的操作变得简单易行。
- DOS 是运行在实模式下的,故其建立的中断调用也建立在中断向量表中,只不过其中断向量号和BIOS的不能冲突。
- Linux 内核是在进入保护模式后才建立中断例程的,不过在保护模式下,中断向量表已经不存在了,取而代之的是中断描述符表(Interrupt Descriptor Table,IDT)。Linux 的系统调用和DOS中断调用类似,不过Linux是通过int 0x80指令进入一个中断程序后再根据eax寄存器的值来调用不同的子功能函数的。
而中断向量表是在内核初始化的时候建立的。在ucore中,对于中断描述符表IDT的初始化,是在kern_init总控函数中通过idt_init函数进行的。
中断描述符IDT表示一个系统表,它与中断或异常向量相联系。每一个中断或异常向量在这个系统表中有对应的中断或异常处理程序入口地址。中断描述符的表每一项对应一个中断或异常向量,每个向量由8个字节组成。因此,最多需要256*8=2048 字节来存放IDT。
当中断发生时,cpu会得到一个中断向量号,作为IDT(中断描述符表)的索引,IDT表起始地址由IDTR寄存器存储,cpu会从IDT表中找到该中断向量号相应的中断服务程序入口地址,跳转到中断处理程序处执行,并保存当前现场;当中断程序执行完毕,恢复现场,跳转到原中断点处继续执行。而IDT的表项为中断描述符,主要类型有中断门、陷阱门、任务门。
中断门:包含段选择符和中断或异常处理程序的段内偏移量,当控制权转移到一个适当的段时,处理器清IF标志,从而关闭将来会发生的可屏蔽中断
陷阱门:陷阱门是一种特殊的中断门,与中断门相似,只是控制权传递到一个适当的段时处理器不修改IF标志
任务门:当中断信号发生时,必须取代当前进程的那个进程的TSS选择符存放在任务门中。
以上内容来自百度
这些门的描述符格式比较固定,在kern/mm/mmh.c
中有一些宏可以直接设置:
- SETGATE(gate, istrap, sel, off, dpl) : 设置中断、陷阱门描述符
- SETCALLGATE(gate, ss, off, dpl) : 设置任务门描述符
而且内核所有需要用到的中断函数都被tools/vector.c
程序动态生成了,所以只需要把这些写入到IDT表中就可以了。
我们先来看一下生成的kern/trap/vector.s
中断表
1 |
|
可以看到,前面是中断号对应的处理程序,后面是一个__vectors数组,数组中给出了对应中断号的中断处理程序的位置。而中断处理程序只是把中断号压栈,然后跳转到__alltraps
函数,这个函数在kern/trap/trapentry.S
中定义
1 |
|
可以看到,这个函数就是构造一个trapframe结构体,然后调用trap函数,trap函数在kern/trap/trap.c
中定义
1 |
|
显然,ucore的所有中断都会由trap_dispatch进行实际上的处理,这个函数在kern/trap/trap.c
中定义。如果我们需要修改对某个中断的处理函数,实际上也只需要在trap_dispatch函数中进行修改即可。
0x06 通过中断进行权限切换
这个实验是一个challenge的实验,就是完成通过中断从用户态(ring
3)切换到内核态(ring
0)的功能。需要先把kern/init:kern_init()
中的switch_test()
解注释,然后完成kern/init:lab1_switch_to_user()
和kern/init:lab1_switch_to_kernel()
这两个函数。
CPL、DPL、RPL与IOPL
DPL存储于段描述符中,规定访问该段的权限级别(Descriptor
Privilege Level),每个段的DPL固定。
当进程访问一个段时,需要进程特权级检查。
CPL存在于CS寄存器的低两位,即CPL是CS段描述符的DPL,是当前代码的权限级别(Current Privilege Level)。
RPL存在于段选择子中,说明的是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样它对该段仍然只有特权为3的访问权限。
IOPL(I/O Privilege Level)即I/O特权标志,位于eflag寄存器中,用两位二进制位来表示,也称为I/O特权级字段。该字段指定了要求执行I/O指令的特权级。如 果当前的特权级别在数值上小于等于IOPL的值,那么,该I/O指令可执行,否则将发生一个保护异常。
只有当CPL=0时,可以改变IOPL的值,当CPL<=IOPL时,可以改变IF标志位。
特权级检查
在下述的特权级比较中,需要注意特权级越低,其ring值越大。
访问门时(中断、陷入、异常),要求DPL[目标代码段] <= CPL <= DPL[门]
访问门的代码权限比门的特权级要高,因为这样才能访问门。
但访问门的代码权限比被访问的段的权限要低,因为通过门的目的是访问特权级更高的段,这样就可以达到低权限应用程序使用高权限内核服务的目的。
访问段时,要求DPL[段] >= max {CPL, RPL}
只能使用CPL, RPL中最低的权限来访问段数据
在之前提到的中断描述符中,有一个DPL位,表示门权限,或者说,只有不低于门的权限的代码才能通过门。要实现从r3到r0的提权,需要先保证有一个中断门在r3就能执行,且该中断门的目标代码段在r0,这样就实现了提权。
1 | // kern/trap/trap.c |
另外还需要一个TSS(Task State Segment) ,它是操作系统在进行进程切换时保存进程现场信息的段,CPU能够理解TSS段,是硬件结构,需要用软件来初始化,负责保留ring0、ring1、ring2的栈(ss、esp寄存器值)。当用户程序从ring3跳至ring0时(例如执行中断),此时的栈就会从用户栈切换到内核栈。切换栈的操作从开始中断的那一瞬间(例如:从int 0x78到中断处理例程之间)就已完成。切换栈的操作为修改esp和ss寄存器。CPU会自动从TSS读取并修改而同时TSS段的段描述符保存在GDT中,其ring0的栈会在初始化GDT时被一起设置。TR寄存器会保存当前TSS的段描述符,以提高索引速度。
那么切换栈是由谁来完成的呢?这个程序我们之前也见过,只是没有具体分析代码含义。
1 |
|
优先级切换中堆栈处理步骤:
- 准备调用门
- 加载TSS,因为总共有4个优先级(ring0~3),所以每个优先级下面都对应一个堆栈。加载了TSS,就能够根据目标优先级而从TSS中获取想对应的堆栈地址
- 临时性保存当前,未切换前的SS_old和ESP_old的值
- 根据目标优先级从TSS中获取对应的对应得堆栈地址SS_new和ESP_new,并加载。(因为CPU只有一个SS和一个ESP寄存器,所以,所谓的加载,只是把新的SS_new和ESP_new的地址赋给CPU的SS和ESP寄存器)
- 将已经临时保存的SS_old和ESP_old的值保存到新堆栈中
- 将当前的CS_old和EIP_old保存入新堆栈
- 加载调用门中指定的新的CS和EIP,开始执行被调用者过程
优先级切换返回过程堆栈处理步骤:
- 加载被调用者堆栈上的CS_old和EIP_old
- 加载SS_old和ESP_old,切换到调用者堆栈,被调用者的SS_new和ESP_new被丢弃
特权级的转变,就是改段寄存器的selecter,还有进行堆栈的变化。CPU认为只有之前发生特权级变化时才会额外压入ss、esp,所以中断返回时如果发现弹出的cs与当前cs不一致时,除了恢复之前栈上的cs(也恢复了CPL),同时会额外的弹出esp、ss。通过这个机制我们也能欺骗机器进行堆栈的转换
当通过陷入门从ring3切换至ring0(特权提升)时:
- 在陷入的一瞬间,CPU会因为特权级的改变,索引TSS,切换ss和esp为内核栈,并按顺序自动压入user_ss、user_esp、user_eflags、user_cs、old_eip以及err。(需要注意的是,CPU先切换到内核栈,此时的esp与ss不再指向用户栈。但此时CPU却可以再将用户栈地址存入内核栈。这种操作可能是依赖硬件来完成的。)
- 之后CPU会在中断处理例程入口处,先将剩余的段寄存器以及所有的通用寄存器压栈,构成一个trapframe。然后将该trapframe传入给真正的中断处理例程并执行
- 该处理例程会判断传入的中断数(trapno)并执行特定的代码。在提升特权级的代码中,程序会处理传入的trapframe信息中的CS、DS、eflags寄存器,修改上面的DPL、CPL与IOPL以达到提升特权的目的
- 将修改后的trapframe压入用户栈(这一步没有修改user_esp寄存器),并设置中断处理例程结束后将要弹出esp寄存器的值为用户栈的新地址(与刚刚不同,这一步修改了将要恢复的user_esp寄存器)。(这样在退出中断处理程序,准备恢复上下文的时候,首先弹出的栈寄存器值是修改后的用户栈地址,其次弹出的通用寄存器、段寄存器等等都是存储于用户栈中的trapframe。)
通过陷入门从ring0切换至ring3(特权降低) 的过程与特权提升的操作基本一样,不过有几个不同点需要注意一下:
- 与ring3调用中断不同,当ring0调用中断时,进入中断前和进入中断后的这个过程,栈不发生改变
- 因为在调用中断前的权限已经处于ring0了,而中断处理程序里的权限也是ring0,所以这一步陷入操作的特权级没有发生改变,故不需要访问TSS并重新设置ss 、esp寄存器。
- 修改后的trapFrame不需要像上面那样保存至将要使用的栈,因为当前环境下iret前后特权级会发生改变,执行该命令会弹出ss和esp,所以可以通过iret来设置返回时的栈地址。
1 |
|
0X07 总结
通过这个lab1,我们学习了以下内容:
- 操作系统的镜像是如何制作的:第一个扇区是什么、剩下的扇区是什么、bootloader去段的处理、bootloader的大小限制
- 操作系统是如何启动的:BIOS什么模式、BIOS结束时如何进入bootloader、bootloader两个功能(保护模式、ELF)、bootloader如何进入内核
- 内核的中断、中断描述符、各种门、在ucore中断函数的实现方法
- 段和门权限的表示方法,通过中断提权