ucore lab1学习笔记整理

前置知识

ucore是运行在80386这一32位x86架构的CPU,汇编采用的格式是AT&T,

lab1

ucore开始执行makefile会生成磁盘映像,这个磁盘映像就是对应着现实计算机中的硬盘。

这个磁盘映像中的第一个扇区为bootloader,其余部分即为ucore的kernel部分。

80386在上电后,一般不会直接执行操作系统,而是会执行系统初始化软件并完成基本的IO初始化和引导加载功能,而我们所常说的BIOS和磁盘的引导扇区上的BootLoader就是系统初始化软件,BIOS是固化在ROM中的,是负责访问和控制硬件。CPU从物理地址0xFFFFFFF0开始执行,而在这个地址只是存放了一条跳转指令,跳到BIOS例行程序的起始点。随后开始执行BIOS,BIOS会进行计算机硬件的自检和初始化,然后选择一个启动设备(软盘、磁盘、光盘等),随后会读取其第一个扇区(启动扇区)到一个特定的内存地址0x7c00处,随后跳转到该地址处执行。此时BootLoader已经加载到内存中,后续便开始执行BootLoader的程序。

BootLoader需要做的工作有:1、切换到保护模式,启用分段机制;2、读取磁盘中的ELF执行文件格式的ucore操作系统到内存;3、把控制权交给ucore操作系统。

实模式

在BootLoader开始后,系统还是处于实模式即16位模式,此时可访问的内存空间大小不超过1MB,就是我们平时在学习8086的汇编语言时,我们会使用段寄存器和偏移寄存器,CS和IP寄存器均只有16位,但是可以表示20位的地址。实模式将整个物理内存看成分段的,操作系统和用户程序并没有区别对待。因此,如果用户程序的指针如果指向了操作系统区域,并修改了其中的信息,将会造成严重的后果。通过A20地址线就可以完成从实模式转换到保护模式。

为了进入32位保护模式,必须先开启A20(第21位内存访问线),否则在32位寻址模式下给出的内存地址第21位始终为0,造成错误。

为什么需要特意开启A20总线?

在早期的8086CPU中,内存总线是20位的,由高16位的段基址和低16位的段内偏移共同构成一个20位的内存地址。但事实上在段基址和段内偏移比较大的情况下,其实际得出的结果是超过了20位的(例如0xFFFF段基址 <<< 4 + 0xFFFF段内偏移 > 0xFFFFF),出现了溢出,而8086中对这种溢出是兼容的,这种溢出在8086上会体现为绕回0x00000低端。“程序员,你是知道的,他们喜欢钻研,更喜欢利用硬件的某些特性来展示自己的技术,很难说在当年有多少程序在依赖这个回绕特性工作着”,摘自《X86汇编语言 从实模式到保护模式》 11.5 关于第21条地址线A20的问题。到了更新版的80286时代,24位的内存总线,如果不默认关闭A20总线,那么就无法兼容使用回绕特性的8086程序了。而80386作为80286的后一代,也继承了80286这一特性。

保护模式

在保护模式下,80386的32根地址总线均有效,可以寻址4GB的线性地址空间和物理空间。将会采用分段存储管理机制和分页存储管理机制。这为存储共享和保护提供了硬件支持,而且为实现虚拟存储提供了硬件支持。此时操作系统中存在4个特权级以及完善的特权检查机制。保护模式下,我们就会看到GDT和LDT,但是在ucore中并没有LDT,这两张表可以理解为段表。在保护模式下,逻辑地址通过GDT或者LDT转换为线性地址,再通过页表将线性地址转化为物理地址。

GDT和LDT

这两个分别是全局描述符和局部描述符。这两个表中的项叫做段描述符。每个段描述符都包含一个段的基地址、访问权限、类型和用法信息,同时会有一个与之相关的段选择子,这个段选择子就是对GDT或者LDT中的一个描述符的索引,注意:选择子中包含一个全局、局部标志决定是指向GDT还是LDT的,同时还有访问权限DPL。我们的GDT的线性地址通常是存放在GDTR,而LDT的线性地址则是存放在LDTR。

系统段、段描述符和门

构成程序运行环境的除了代码、数据、堆栈段之外,还有两个系统段:任务状态段TSS和LDT(没错,LDT是GDT中的一个描述符项,一个程序可以对应一个LDT,将其信息装载到LDTR中,后续可以去LDT中寻找对应的代码、数据、堆栈段)。

IA-32系统架构还定义了一套称为门的特殊描述符,这提供一种不同于应用程序特权级的系统过程和处理程序的保护性访问途径。例如:通过对调用门的调用可以访问与当前代码段特权相同或者更低的代码段。这个过程中,调用程序必须提供调用门的段选择子,然后处理器会比较CPL和调用门以及调用门所指向的目的代码的特权级来检查访问特权。如果特权允许,后续再进行根据段选择子找到代码的段地址,后续再根据IDT(中断描述符表)中的偏移地址找到ISR(终端服务例程)的地址。注意如果目标代码的DPL<CPL则需要切换到堆栈,进程会在TSS中找到对应级别的堆栈段选择子,并根据这个选择子进行堆栈的切换。

任务状态段和任务门

TSS这个段中包含了一个进程执行环境,包括的通用寄存器、段寄存器、EFLAGS寄存器,EIP寄存器,段选择子、三个级别的堆栈段,以及任务相关的LDT选择子和页表的基地址。

保护模式下程序执行的所有状态都放置在context中。

当前任务的TSS选择子保存在任务寄存器(TR)中。我们在切换任务时,处理器需要执行下列操作:1、保存当前任务的状态到TSS中。2、装载新的任务的段描述符到TR中;3、通过GDT来找到对应的描述符;4、将找的的TSS中的信息装载到CPU中。5、执行新的任务。

任务门和调用门比较相似,只不过任务门是通过提供TSS的选择子而不是一个门描述符。

linux不使用任务门(转载):

  Intel的这种设计确实很周到,也为任务切换提供了一个非常简洁的机制。但是,由于i386的系统结构基本上是CISC的,通过JMP指令或CALL(或中断)完成任务的过程实际上是“复杂指令”的执行过程,其执行过程长达300多个CPU周期(一个POP指令占12个CPU周期),因此,Linux内核并不完全使用i386CPU提供的任务切换机制。

  由于i386CPU要求软件设置TR及TSS,Linux内核只不过“走过场”地设置TR及TSS,以满足CPU的要求。但是,内核并不使用任务门,也不使用JMP或CALL指令实施任务切换。内核只是在初始化阶段设置TR,使之指向一个TSS,从此以后再不改变TR的内容了。也就是说,每个CPU(如果有多个CPU)在初始化以后的全部运行过程中永远使用那个初始的TSS。同时,内核也不完全依靠TSS保存每个进程切换时的寄存器副本,而是将这些寄存器副本保存在各个进程自己的内核栈中。

  这样一来,TSS中的绝大部分内容就失去了原来的意义。那么,当进行任务切换时,怎样自动更换堆栈?我们知道,新任务的内核栈指针(SS0和ESP0)应当取自当前任务的TSS,可是,Linux中并不是每个任务就有一个TSS,而是每个CPU只有一个TSS。Intel原来的意图是让TR的内容(即TSS)随着任务的切换而走马灯似地换,而在Linux内核中却成了只更换TSS中的SS0和ESP0,而不更换TSS本身,也就是根本不更换TR的内容。这是因为,改变TSS中SS0和ESP0所花费的开销比通过装入TR以更换一个TSS要小得多。因此,在Linux内核中,TSS并不是属于某个进程的资源,而是全局性的公共资源。在多处理机的情况下,尽管内核中确实有多个TSS,但是每个CPU仍旧只有一个TSS。

ucore在设计上大量参考了早期32位linux内核的设计,因此和Linux一样也没有完全利用硬件提供的任务切换机制。整个OS周期只在内核初始化时设置了TR寄存器和TSS段的内容(gdt_init函数中),之后便不再对其进行大的修改,而是仅仅在线程上下文切换时,令TSS段中的esp0指向当前线程的内核栈顶(proc_run)。

中断和异常处理

在操作系统中,有三种特殊的中断事件。由CPU外部设备引起的外部事件如I/O中断、时钟中断、控制台中断等是异步产生的,与CPU的执行无关,称之为异步中断也称为外部中断,简称中断。而在CPU执行执行期间检测到不正常的或者非法的条件所引起的内部事件称作同步中断,也称为内部中断,简称异常。把在程序内部使用请求系统服务的系统调用所引起的事件,称为陷入中断trap,也称为软中断,系统调用简称trap。

Interrupt Gate和Trap Gate的区别在于,调用前者时,eflag中IF位修改,关中断,而在调用后者的时候并不会关中断。

外部中断、软件中断和异常都是通过IDT中断描述符表处理的。IDT包含了中断和异常处理程序的门描述符的集合。IDT的线性地址是保存在IDTR寄存器中。CPU都是通过中断向量在IDT中找。

下面描述一下硬件中断的开始处理过程:CPU在执行完每一条指令都会检查是否有中断信号,如果有的话就在相应的时钟脉冲到来时从总线上读取对应的中断向量,根据这个中断向量可以去IDT中进行索引找到指定的中断描述符,其中包含了ISR中断服务例程的段选择子,然后根据这个段选择去GDT中查找指定的门描述符,在门描述符中即可找到段基址,而在中断描述符中会有偏移地址,两者结合即可得到ISR的地址。其中还需要注意,CPU在找到门描述符后,会查看DPL,查看CPL和DPL是否不同,如果发生了特权级的变化,那么当前使用的堆栈也需要进行切换,例如用户态需要切换到内核态去执行,那么CPU可以从进程的TSS中找到esp0和ss0就是内核栈的地址,在内核栈中需要保存现场,压入用户态的SS和ESP保存起来,还需要将一些寄存器的值压入,依次压入eflags,cs,eip,errorCode信息。后续CPU会把ISR的段地址和偏移地址加载到CS:IP中,正式开始服务。

结束处理过程:每个ISR在工作完成后都需要通过iret指令来恢复被打断程序的执行。这个指令的执行过程如下:

首先从内核栈中弹出现场的信息,如果存在特权级转换,那么还需要从内核栈中弹出用户态的SS和ESP,切换回原来的用户态的栈。而在恢复过程中并不会弹出errorCode,如果有errorCode,ISR会在调用iret之前添加出栈代码主动弹出errorCode。

注意不同进程有不同的内核栈,而这个内核栈是和task_struct定义时一块分配内存的,两者是一个union数据结构。还有就是iret指令能够让进程转换特权级,这个特点很重要,在后续的用户进程的创建中会iret从内核态变到用户态去。

CPL、DPL、RPL之间的关系

CPL:是当前进程的特权级别,存在于CS寄存器的低两位。

RPL:是进程对段访问的请求权限,是对于段选择子而言的,每个段选择子都有自己的RPL,说明的是进程对段访问的请求权限。

DPL:是规定访问该段所需要的特权级别,存在于描述符项中。

记住一些“定律”

所有程序的跳转,CPU都不会把段选择子的RPL赋予跳转后的CPL。

跳转后的CPL只可能是以下两种情况:

1、跳转后的CPL=跳转前的CPL。如果跳转后的代码是一致代码段。

2、跳转后的CPL=跳转指定段的描述符的DPL。如果跳转后的代码是非一致代码段。

对于特权级高的进程RPL是作用是防止自己不小心访问到一些资料段。比方说,如果进程A的CPL=0,它知道它的委托进程B的DPL=3,也知道数据段C的DPL=2,而这数据段是不能让CPL>2的进程访问的。

那么如果你是进程A的程序员根本不需要RPL的帮助,也不会试图让进程A访问数据段C的数据,因为这样做只会浪费时间。当然如果你一定要访问数据段C的数据然后把数据传给委托进程B,这就是你的选择,你真的可以这样做,但后果自负。只是有时候要访问的数据段我们不知道它的DPL是怎么,也不知道能不能让进程B访问,其中的一个解决方法就是把委托进程B的DPL以RPL的方法告诉数据段C让它决定接受或不接受。(我想应该是通过程序把B的DPL装入到A的选择子中,然后再由A去访问数据段C)

函数堆栈

SS寄存器存放的堆栈的段地址。

EBP基数指针寄存器BP(base pointer)是一个寄存器,它的用途有点特殊,是和堆栈指针SP联合使用的,作为SP校准使用的,只有在寻找堆栈里的数据和使用个别的寻址方式时候才能用到

ESP寄存器是栈顶指针寄存器,指向栈顶,存放的是栈的偏移地址。

ESP和EBP都是配合SS寄存器进行使用,SS:(EBP+4)是返回地址,+8是第一个参数的地址。一般EBP寄存器是用在函数调用,而SS:ESP就是栈顶的地址。

一个函数调用可以分解为:多个PUSH指令将参数入栈,一个CALL指令。CALL指令内部还暗含了一个将返回指令压栈的操作。后续会有以下两条指令push ebp; movl esp,ebp 就是先将ebp寄存器入栈,这个是保存调用函数前的栈基址,然后将esp赋予ebp表示此时的栈顶就是栈底,栈为空,因为函数调用执行开始的时候栈都是空的。但是我们需要注意到十分重要的一点,此时ebp指向的栈中信息是调用函数前的栈的基址。因此当该函数调用结束后,仅需将ebp指向的地址中的数据重新赋予给ebp即可恢复栈基址。

最开始提到的多个push指令将参数入栈,我们需要注意的是参数的压栈顺序是反的,先压入参数3,再压入参数2,再压入参数1,如果有3个参数的话。后续便是压入返回地址,再后面便是ebp被压入,可见此时的ebp+4的地址存储的是返回地址,即底下一个单位的地址处,再往下一个单位就是参数1,再往下就是参数2.而在后续函数执行的过程中生成的局面变量在压入栈后,就是在ebp的顶上,就是ebp-4,ebp-8的地址。我们需要注意的就是栈的往低地址方向增长,所以地址低处是栈顶。

代码部分

在BootLoader将ucore的完整内核加载到内存之后,并通过elf文件头中指定的入口跳转到内核入口处,即/kern/init.c中的kern_init函数。kern_init是总控函数,在这个函数中将会完成内核中各个组成部分的初始化。

在lab1中我们需要完成idt表的建立,而这个过程就是通过kern_init函数中的idt_init函数进行的。​​​​​​​

通过SETGATE宏可以给idt[i]中的每一项都赋予一个中断描述符

#define SETGATE(gate, istrap, sel, off, dpl)可见,_vectors[i]数组对应的就是这个中断处理过程在目标代码段的偏移量。

我们在查看/kern/trap/vector.S可以看到里面的每一项中断服务例程的代码都十分相似,有的会先push $0 再push $i i为中断号。后续便统一跳转到_alltraps处,这个定义在/kern/trap/trapentry.S

_alltraps会按照顺序将寄存器的值压栈,随后更新DS和ES寄存器,将数据段更新为内核态,因为原先的可能是处于用户态的。后续将当前栈顶地址esp的值压入栈内作为调用trap的参数。

/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
    extern uintptr_t __vectors[];
    int i;
                                                           
    for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
    // 遍历idt数组,将其中的内容(中断描述符)设置进IDT中断描述符表中(默认的DPL特权级都是内核态DPL_KERNEL=0)
    SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
		}
		// set for switch from user to kernel
		// 用户态与内核态的互相转化是通过中断实现的,单独为其一个中断描述符
		// 由于需要允许用户态的程序访问使用该中断,DPL特权级为用户态DPL_USER=3
		SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
    // load the IDT 令IDTR中断描述符表寄存器指向idt_pd,加载IDT
    // idt_pd结构体中的前16位为描述符表的界限,pd_base指向之前完成了赋值操作的idt数组的起始位置
    lidt(&idt_pd);
}

我们需要注意的一点就是栈是往低地址生长的,因此我们看到trapframe结构体中的数据段定义顺序和在_alltraps中的压栈顺序是反的,因为最后压栈的字段的地址最小,因此根据结构体的定义来看,地址越小的字段,定义越靠前。

在vertor.S中对于没有错误号的中断请求默认加上了pushl $0,压入一个默认的错误号0;对于CPU硬件会压入错误号的中断向量则没有进行默认处理,例如vertor8、vertor9等等。

后续在trap_dispatch函数中就会根据trapframe中的tf_trapno来执行对应的中断服务例程。

这里应该还和上面的中断和异常处理模块结合起来看。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值