uCore lab 1
本系列旨在完成uCore实验,通过实验了解操作系统的概念,从汇编,c语言编程的角度,以微观的视角了解操作系统的运行过程。
本文为本系列第一篇,介绍机器启动到启动os的过程。
从机器启动到操作系统运行
BIOS 启动
计算机加电后先完成基本IO初始化和引导加载功能。系统软件初始化由BIOS与OS Bootloader完成。
BIOS是固化在计算机ROM上的软件,为上层软件提供最为底层的硬件控制与支持。CPU从0XFFFFFFF0开始执行,存放一条跳转指令,跳转到BIOS例行程序起始点,完成硬件自检和初始化后,选择启动设备读取第一扇区到内存的特殊位置0x7c00处,CPU控制权交给该地址,开始执行。
Bootloader启动过程
Bootloader工作包括:
- 切换到保护模式,启用分段机制。
- 读取ELF执行文件的ucore操作系统到内存。
- 显示字符串信息。
- 把控制权交给操作系统。
保护模式与分段机制
当Intel 80386进入保护模式后才能充分发挥功能,提供更好的保护与更大的寻址空间。
实模式
当Bootloader接手工作后,PC处于实模式,可访问的物理内存空间不超过1MB,将内存看为分段的区域,代码和数据位于不同区域,此时指针指向的是实际的地址。可以通过修改A20地址线来完成转换。
保护模式
保护模式地址线才全部有效,可采用分段存储管理和分页存储管理,为存储管理保护提供了硬件支持。通过提供特权级和特权检查机制,实现资源共享又能保证安全。
在保护模式下有两个段表GDT和LDT可以有64TB逻辑空间,但实际空间还是只有4GB,ucore只用到了GDT。
分段存储管理机制
分段机制涉及4个关键内容:逻辑地址,段描述符,段描述符表,段选择子(段寄存器)。
分段地址转换:CPU将逻辑地址中段选择子作为段描述符号表的索引,找到段描述符,之后加上偏移值得到线性地址,后续可以将线性地址转换为物理地址。
段描述符内容如下:
- 段基地址:线性空间中段的起始地址,80386保护模式中任何一个段都可以从任何一个字节开始。
- 段界限:规定段的大小,80386保护模式下以20位表示,可以用字节为单位或者4K字节为单位。
- 段属性:
- 粒度位:即以字节为单位或4K字节为单位。
- 类型:代码段还是数据段。读写执行权限。段的扩展方向。
- 描述符特权级位:用于实现保护机制。
- 段存在位:如果非法则不能用来实现地址转换,产生异常。
- 已访问位:处理器访问该段时候自动设置。
全局描述符号表:保存多个段描述符的数组,但由于GDT不能有自己之内的描述定义,因此定义了特殊的段GDTR。
选择子:包含索引,表指示位(GDT还是LDT)和特权级。
保护模式下的特权级
保护模式下有四个,从0到3,主要保护内存,IO端口以及执行特殊机器指令的能力。对于ring 0之外运行这些指令会导致异常。
代码段寄存器的内容不能由mov等指令直接设置,只能被如JMP,INT,CALL指令间接设置。
代码段CPL字段的值同时等于当前的特权级别。
特权级比较:比较当前特权级与请求特权级的最大值,并与描述符特权级相比较。
地址空间
逻辑地址,物理地址,段描述符表,段描述符,段选择子。
逻辑地址->分段地址转换->线性地址->分页地址转换->物理地址。
硬盘访问概述
bootloader进入保护模式后下一步从硬盘加载并运行OS。IO操作通过CPU访问硬盘的IO地址寄存器完成。
访问硬盘流程:
- 等待硬盘准备好
- 发起读取扇区命令
- 等待硬盘准备好
- 扇区读到指定内存
ELF格式概述
ELF有三种主要类型,可执行文件,可重定位文件与共享目标文件(动态库)。
link address为链接器配置的内存地址,Load address为实际被加载到内存的位置,这俩不同会导致直接跳转位置错误,直接内存访问(只读数据或bss)错误,堆栈使用不受影响,但可能会覆盖程序数据段。动态链接库会存在不一样。
操作系统启动过程
加载到内存后,os接管控制权,实验所完成的工作包括:
- 初始化终端
- 显示字符串
- 显示堆栈中多层函数调用关系
- 切换到保护模式,启用分段机制
- 初始化中断控制器,初始化时钟中断,实现中断
- 执行死循环
函数堆栈
函数调用可分为push指令入参,call指令,call指令暗含将下一跳指令地址压栈动作。函数体之前会有类似如下:
1 | pushl %ebp |
函数调用前会有如下数据顺序入栈:参数,返回地址,ebp寄存器。
中断和异常
有三种特殊中断产生是异步产生,产生时间不确定,与CPU无关,称为异步中断也叫外部中断,CPU执行期间检测到违法指令称为内部终端,也叫trap。
在保护模式下,收到中断(8259A)时候会暂停,跳转到相关例程中,处理完成后再跳回,需要中断描述符号表IDT负责找到关系,而IDT地址存在idtr寄存器中。
IDT是八字节描述符数组,中断号乘以8为IDT索引,LIDT和SIDT可以操作IDTR。
保护模式下最多有256个中断,0到31被异常使用,32到255用于自定义。
IDT gate description用于标识中断或trap,当用中断gate时候,会禁止中断,避免重复中断,使用trap gate时候会保持原样,不去禁止或打开。
CPU收到中断事件,打断当前程序或者任务执行,跳转到中断例程序流程如下:
- 执行完每一条指令后,都会确认在执行刚才指令的过程中中断控制器是否发送请求过来,有则会在相应的时钟脉冲到来的时候从总线读取请求对应的中断向量。
- 根据中断向量在IDT找到对应中断描述符,符号内有中断服务例程的段选择子。
- 根据段选择子在GDT里面找到对应段描述符,其中保存有段基址和属性信息,得到起始地址,跳转到该地址。
- 根据CPL和DPL看是否发生了特权级转换,如果转换则从任务描述符(TSS)中获得该程序的内核栈地址,包含内核态的ss和sp(ss:sp地址形式),将系统当前使用的栈换成内核栈,换掉ss,sp,将用户态ss,sp压入内核栈保存。
- CPU保存寄存器值来保存现场。
- 将中断服务例程的段描述符的第一条指令加载到cs和eip寄存器,开始执行例程。
在中断服务例程处理完成后需要通过iret指令恢复被打断的程序的执行执行如下:
- 从内核栈弹出现场信息,即eflags,cs,eip等。
- 如果存在特权级转换,则弹出ss,sp,切换回用户态栈。
- 如果此次处理带错误码的异常,cpu恢复现场时候不会弹出错误码,需要软件编程弹出错误码。
中断处理的特权级转换通过门描述符来确定,产生中断后一定是向相等或更高特权级转换,结果的CPL必须等于目的代码段的DPL。如果中断由用户态指令触发,则目标DPL必须具有比CPL相同或者更低的特权,防止随意中断。
练习
1:makefile编写 理解make执行文件过程 了解主引导扇区
makefile基本原则
1 | targets : prerequisites |
示例如下
1 | test:test.c |
四种赋值:
1 | x:=foo |
一些基本函数
1 | $(call <expression>,<parm1>,<parm2>,<parm3>,...) |
一些常见的自动变量
1 | test:test.o test1.o test2.o |
实战分析
分析lab1中的makefile,可以分为一下几个部分:
1 | $(UCOREIMG): $(kernel) $(bootblock) |
上述为ucore.img部分,即依赖kernel和bootlock部分。命令中,dd用于读取,转换,输出文件,if
和of
即为输入文件和输出文件,count
代表拷贝块大小,seek
代表跳过blocks个块再开始复制,notrunc
即不截断输出文件。该行目的为生成10000块的文件,每块默认512字节,用0填充,bootblock写入第一块,kernel写入第二块。
再来观察kernel:
1 | KOBJS = $(call read_packet,kernel libs) |
kernel.ld
不必多说,关注下面的KOBJS
,即将obj文件都链接起来。obj文件来自于read_packet
,而packet
文件来自如下:
1 | $(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,$(KCFLAGS)) |
这其中add_files_cc
传入CC(编译器,clang还是gcc),CFLAGS编译参数。KCFLAGS即include的files,KSRCDIR即要编译的文件目录。实际执行命令如下:
1 | gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 \ |
回到kernel,下面一行为ld,即链接器,链接所有obj文件。
之后反汇编到asm文件,再处理一下得到sym文件。
回到img,除了kernel,下一个为bootblock,如下所示:
1 | bootfiles = $(call listf_cc,boot) |
简要分析,首先bootblock
需要boot文件夹下的files编译成obj,然后ld链接,然后需要反汇编到asm文件便于查看,再objcopy去掉符号和重定位,生成到.out文件中,最后使用sign工具处理.out,得到bootblock。
简要来分析编译时候关键参数:
1 | -ggdb 生成调试信息 |
练习1.2 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
由文档我们可以知道,sign.c是用来生成一个符合规范的硬盘主引导扇区的。观察sign.c代码可以看出,它接收os参数,检测参数对应文件长度,读入512的缓冲区,并且将最后两个字节,即buf[510] = 0x55;
以及buf[511] = 0xAA;
,从而得到正确的硬盘主引导扇区。
2:使用qemu执行并调试lab1中的软件
qemu基础
安装不必多言,可以直接sudo apt-get:
1 | sudo apt-get install qemu-system |
由于主要是i386平台,这里软链接一下方便使用。
qemu格式如下:
1 | qemu [options] [disk_image] |
下面是一些常见参数:
1 | -hda xxfile -hdb xxfile -hdc xxfile |
在lab 1中,我们可以这样启动设备
1 | qemu -S -s -hda ucore.img -monitor stdio # 用于与gdb配合进行源码调试 |
完成后用gdb打开kernel文件,执行如下连接到端口1234:
1 | (gdb) target remote:1234 |
也有更简便的方法来,就是将它配置进文件,在makefile里面启动时候加载gdb配置文件,开启qemu进行debug。简单来说可以在tools/gdbinit
修改,并且执行make debug
进行调试。
1 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行
更改gdbinit文件如下:
1 | set architecture i8086 |
构建运行系统并调试,进入gdb,执行以下指令:
1 | 0x0000fff0 in ?? () |
需要注意的是,我们现在处于实模式,直接看pc是看不出指令的,下一条指令地址位于CS:ip算出来的。于是可以观察cs寄存器,ip寄存器,如下:
1 | (gdb) x $cs |
可以看到cs在0xf000,eip在0xfff0,通过计算16*cs+ip
可以得到地址0xffff0
,观察0xffff0
,得到如下:
1 | (gdb) x /2i 0xffff0 |
最开始的指令是一个长跳转ljmp,跳转到e05b。对于BIOS代码,qemu默认是以32位模式解释代码的,这导致地址不正确,反汇编也不正确。参考这个链接我们可以得到解决办法,在gdb配置中加入以下指令:
1 | set tdesc filename target.xml |
target.xmlet.xml可以在上面链接中获取到,根据此我们可以以16位方法正确反汇编代码。
2 在初始化位置0x7c00设置实地址断点,测试断点正常
直接在gdb中设置断点就行,格式如下,可以写进gdb配置也可以直接在命令行输入。
1 | b *0x7c00 |
3 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较
在makefile里面把debug命令改成如下:
1 | debug: $(UCOREIMG) |
其中in_asm
代表以target asm形式表示。将执行了的汇编码保存到q.log里面,进行对比。
1 | IN: |
与之前的asm文件一致。
4 自己找断点测试
方法同上,不过多赘述。
3:分析bootloader进入保护模式的过程
阅读bootasm.S源码解决以下问题
- 为何开启A20,以及如何开启A20
- 如何初始化GDT表
- 如何使能和进入保护模式
观察源码,首先从第一个扇区读取内容到地址0x7c00
处,此时$cs=0x0
并且$ip=0x7c00
,得到cs:ip
。开始进入汇编,开始代码如下:
1 | start: |
cli指令关闭中断,cld指令使得方向标志位复位,从而按递增方向增长。
xor操作用于清零,mov操作用于将数据段,代码段,栈段寄存器也清零。初始化完成之后,开始打开A20线。
1 | seta20.1: |
使用8042控制器来控制A20 gate。操作简单来说如下:
- 读0x60端口,为读output buffer
- 写0x60端口,为写input buffer
- 读0x64端口,为读status register
- 操作control register:先向0x64写一个命令,根据命令从60读control register命令或者写60一个control register命令。
实际操作起来流程为:读0x64观察8042是否忙,忙则继续读直到不忙,然后向0x64写入0xd1,代表即将写入8042的输出端口。然后再次等待,等待完成后向0x60写入0xdf,代表打开A20地址线。
对于全局描述符表GDT而言,下面使用lgdt指令直接加载即可:
1 | lgdt gdtdesc |
然后将控制寄存器cr0的PE位置置为1,即可开启保护模式:
1 | movl %cr0, %eax |
然后跳转到32位代码区:
1 | ljmp $PROT_MODE_CSEG, $protcseg |
设置寄存器,建立堆栈,进入boot主方法:
1 | movw $PROT_MODE_DSEG, %ax # Our data segment selector |
4:分析bootloader加载ELF格式的OS的过程
bootloader如何读取扇区的
IO操作读取硬盘包含三种方式,chs方式(小于8GB),LBA28方式(小于137GB),LBA48方式(小于144000000GB)。CHS模式采用磁头,磁道,扇区的方式确定位置,读取三个参数,送到磁盘控制器去执行。LBA方式通过将三维寻址变成一维寻址,提高了效率。
bootloader采用LBA的PIO模式,所有操作是通过CPU访问硬盘的IO地址寄存器完成。读硬盘的流程大致如下,与键盘控制器类似:
- 等待磁盘准备好
- 发出读取扇区的命令
- 等待磁盘准备好
- 把磁盘扇区数据读取到指定内存。
下面我们来看等待硬盘和读取硬盘代码:
1 | static void |
可以看到,等待磁盘与键盘控制器类似,即死循环检查磁盘状态,观察磁盘是否忙碌。
1 | /* readsect - read a single sector at @secno into @dst */ |
先等待磁盘准备好,再向硬盘控制器写入指令,其中F3到F6表示LBA模式下地址,F2代表读取扇区数。F7代表读取指令状态寄存器。先发送指令,再从F0开始读取。
readseg
函数与之类似,即从目标磁盘位置开始,读取count数的磁盘字节。
bootloader如何加载elf格式OS的
代码如下:
1 | void |
先从磁盘中读取数据,读取到结构体指针p中。再通过指针操作检查p->e_magic
是否有效。再找到文件中代码段起始与代码段边界,存到ph和eph中,读取代码段到目标位置。最后根据指针p找到代码段的进入点,通过函数调用来调用该地址函数并且执行。
5:实现函数调用堆栈跟踪函数 (需要编程)
首先知道,在调用函数前,会将ebp压入栈,然后将栈顶指针的值赋值给ebp。由于ebp被压入后位置就是栈顶,那么*(ebp)
就是外层的ebp的值。
eip寄存器存储的是下一条指令地址。
在ebp被压入栈之前,从新向旧被压入栈的分别是返回地址,参数1,参数2……。返回地址是call指令的下一条指令的地址,那么找到eip-1我们就可以得到call指令的地址。
由于ebp可以知道,因此我们可以不断迭代这一过程,直到ebp为0或达到了迭代深度。
由此我们可以写出函数如下:
1 | void |
cprintf
是终端相关的printf函数,比如可以自定义打印颜色等,但缺少移植性。
在完成后的显示中,最后一行为:
1 | ebp:0x00007bf8 eip:0x00007d72 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 |
这是bootloader时候的代码,由于bootloader设置的堆栈esp值从0x7c00开始,调用call bootmain
,call指令压栈,7c00-0008=7bf8,所以bootmain中ebp为0x7bf8
6:完善中断初始化和处理 (需要编程)
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
我们可以在trap.c
找到idt数组,并在头文件中打开数组类型的声明:
1 | static struct gatedesc idt[256] = {{0}};//类型为gatedesc,在mmu.h中打开该类型 |
这里注意冒号的用法,冒号后面的数字代表占多少多少位,这里就是16+16+5+3+4+1+2+1+16=64
位,也就是8个字节。
请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化
观察SETGATE
宏:
1 |
|
在这里,gate
即为idt数组的数组项。除了syscall
是一个trap,其他我们都设置为0。sel
即为段基址,即OS的代码段位置,可以在memlayout.h
中看到。这里也就是GD_KTEXT
。off
则是偏移,在__vectors.s
中可以看到。dpl
是特权级,syscall
使用用户特权级,其他都使用内核特权级。这里使用宏而不要用数字。
我们可以实现idt_init
如下:
1 | void |
请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”
观察trap
函数,如下:
1 | void |
然后看trap_dispatch()
函数,这里直接写好了:
1 | /* trap_dispatch - dispatch based on what type of trap occurred */ |
看到IRQ_OFFSET + IRQ_TIMER
这一项,即每个中断计数一下,计数到了就print_ticks()
,这里都给你写好了,很简单。
扩展练习
需要特权转换详细知识,待到完成lab2后再做。
总结与感想
这一次主要完成的是从CPU加电到实际加载进入操作系统,完成各种初始化这一过程。主要熟悉了qemu的debug,反汇编,熟悉了bootloader的汇编码,熟悉了读取扇区以及文件的过程,熟悉了gdt全局描述表,idt中断描述表的初始化过程,熟悉了函数堆栈的构成。对操作系统最初的状态从顶层到底层有了一个大致的了解。
lab1确实是一个好的开始,没有非常劝退,对于代码编写要求并不高,但对于代码理解能力,调试能力,理论知识考验很高。实验的文档写的很详尽,有时候遇到卡顿的时候最好的办法是重新读一遍实验文档,往往就知道该怎么做了。实验的代码也包含了相当详尽的注释,但理解注释也需要理论知识相当扎实。
今天是5月12号,5月3号开始的本次实验,写了大概十天,如果考虑到中途的摸鱼,大概能在七天内写完。虽然东西不算多,但很细节,对知识积累的要求很大。而且也相当考验开发的基础水平。lab1主要还是考核基础,比如代码调试,你需要熟悉gdb的操作,熟悉qemu的通信,出现了与答案不一样的地方,比如qemu实模式时候显示32位汇编,为什么,你需要去探究。汇编代码不说精通,但对整个过程要有了解,知道每一行大致是在干什么。makefile是编译程序的基础,就算不要求编写,也要了解一下,编译参数有些什么,程序的编译是个什么过程。函数堆栈也是如此,平时使用的debug方法,究竟是如何debug的,如何看到函数的嵌套调用。在完成lab1后,提升最大的不仅仅是操作系统知识,还有实际的代码开发水平,一些开发的基础操作在完成实验的过程中得到了考验。而需要编程的模块,也仅仅没几行,封装也封装了很多,最考验的是理解而非编写。
理解是实际开发很重要的一环,开发人员必须保持高效的理解力,这样才能快速上手项目。同时也需要掌握工程化的代码调试能力,对基础了解,这样才能规范开发,不至于陷入手足无措的境地。
lab2后续看情况写,最近感觉自己的开发效率受外界影响很大,难以专注,对自己的上班状态很堪忧。希望自己能更加专注一些。