《操作系统真象还原》chapter1-5笔记与总结
Last Update:
引题:操作系统是如何被启动的?
主板接电以后,内嵌在主板上的ROM中的BIOS会将 0盘0道1扇区 中的MBR(Main Boot Record)读取到内存中一个固定的位置,然后自动跳转到该位置(0x7c00),之后由MBR取代BIOS接管系统。此时,系统处于“实模式”,此时只能使用寄存器的低16位。
但MBR最大只能有一个扇区(512字节),可做的事情极其有限,因此MBR只从硬盘读取Loader到内存(读取位置也是约定好的),同时再跳转到Loader,由其取代MBR接管系统。
(至于读到哪里,实际上无所谓,只要最开始做好约定,让其能够跳转达到即可)
Loader则能够做到所有初始化工作。进入保护模式、加载内核、启动分页等工作。
MBR主引导记录:
1 |
|
Loader:
1 |
|
实模式下的地址拓展于今日而言似乎并没有太大意义了,随着寄存器和总线位数拓宽,不再需要像DOS时代那样仅使用1MB的内存了,因此这里不做记录,只需要记住其寻址最大值是0xffff:0xffff(0x10ffef)即可。
但内存的寻址方式和进入保护模式以后的段寄存器的用途却十分耐人寻味,一言蔽之即为“描述符–>>内存”
GDT(Global Descriptor Table):全局描述符表(段描述符表)
该表用于储存一系列逻辑门、内存段的地址。
其第一项默认留空,称之为哑描述符。之所以这样规定,似是为了防止在未初始化选择子时违规访问到该描述符,于是索性就对其留空,让无意的访问直接错误。
而专门有一个寄存器GDT Register(48bit)用于加载该表的地址,使用R0专用的指令 lgdt 加载,通过 sgdt 保存。(我有点怀疑,之所以只有48bit是因为Intel芯片只有48根总线,支持内存只有2^48 BYTE,不过目前64位系统中,这个寄存器又达到79bit了,但目前没有确信)
其结构如下:
1 |
|
除此之外,我暂时不对GDT做过多深入
A20(A20GATE):特殊总线的控制端口
实则对应第21根总线的控制端口。在80286时代,被使用的总线为0~19,但内存只有1M-0x100000,大于该部分是地址会被回绕。但打通A20(第21根总线)之后,硬件就知道应该拓展地址了,而不应该继续回绕。对应到32位的现代芯片,当A20被开启(置1)以后,处理器将不再把16位以上的地址回绕。(体现为:将0x92端口低位置1)
1 |
|
CR0 Register:
处理器控制位图。不过多深究,仅记录PE(Protection Enable):置1则标识进入保护模式。之后,CPU将以4字节为单位读取指令。
分页机制:
分页总的能够概况成一句话:“32位地址能够表示2^32空间”。
似乎没什么特殊的,但当时读完整章之后,我最大的感想就是这句。
保护模式下,可寻址范围扩大到 4G ,共32位,但出于安全考虑,我们不应该让内存能够被 “平坦地访问” 。所谓平坦,指的是整个内存的地址空间连续,从0~4G可以直接通过地址的加减来访问对应内存;但只要系统会被用户使用,就应该避免内核数据能够被用户直接读写。显然,“平坦模式”下(也就是从加电直到分页之前),我们没办法直接实现这个功能。
因此需要引入“内存分段(页)”,对于权限低的人,只允许他访问限定好的页,而对于最高权限的内核,则允许它访问整个内存。对于“操作系统占用内存高地址的1GB,用户占用低地址3GB”的设想也是因此得以实现的。可以看出,这个1GB和3GB已经指的是“虚拟地址”了,但这里的虚拟地址又和每个进程都有的“虚拟地址空间”不是同一个东西,后者是进程独立的,而前者则属于操作系统自身。
概念如此,具体表现在:对32位地址的分割
首先,按照每页4K来划分内存,4G/4K=2^20,意味着如果对每一页都使用一个索引去表示,需要 1MB * 4=4MB 的内存。但这个肯定是不允许的,因为占用实在太大了,因此我们可以再做一份二级页表(此处称之为“页目录表”(PDE:Page Directory Entry)),该表也按照4K分页,则4MB/4K=2^10,则页目录表只占用 1KB * 4=4KB 大小(1024条目)。
假设现在,我们容许为此耗费4KB,那么就不需要再继续分页了,过度的分页对导致效率降低。PDE的目的是为了索引页表,共1024个条目(Entry),每个PDE的条目都会指向一个页表,而一张页表对应1K * 4KB = 4MB 内存。
因此,只需要划出页目录表的后256个条目供内核使用,就能够界定这1GB空间,而只要禁止用户去访问这部分页目录,那么用户程序自然就没办法直接访问内核数据了。
显然的是,索引1024个条目不需要32位,10位足够了,所以我们完全可以留出一些内容提供额外的信息(上文所述的属性),比如访问权限等(但这是后话,并不是本章的重点,以下仅给出结构而不详细说明)。(另外一个事实是,如果您读到这里都没觉得奇怪,就说明您已经接受了一个条目占用32位的事实了,不过事实确实如此,也说明这并没有反直觉)
1 |
|
第768(内核空间的第一个)个页目录项之所以要和第一项相同,是为了保证分页之后的Loader程序的虚拟地址和物理地址一致。因为Loader也算是内核程序,应该保证它在虚拟地址的高1GB内,所以要把自己这一页放到768项(0xc000000对应的就是768页目录所标识的页表)
在设置完内核页表以后,调整esp到虚拟地址空间,修改GDT指向虚拟地址的对应位置,将页目录表的地址存入cr3 Register(该寄存器专门用于此功能),置cr0 Register的PG位为1。加载 GDT,从此开启了内核的分页,往后的地址将全都使用虚拟地址替代物理地址。
显然,当下的“虚拟地址”是由硬件和操作系统共同完成的,它不同于多进程下每个进程独立的“虚拟地址空间”,后者理应是由操作系统单独提供的能力(暂时还没看到后面,但就目前我个人猜测,认为理应如此)。
然后只需要从硬盘读取内核到0xc0000000,然后跳转到_start函数即可由内核接管以后的工作。且因为自此之后,MBR,Loader等均不再工作,直接在内存中覆盖掉也完全无妨。另外,开启虚拟地址功能以后,所有的思考都应该直接通过虚拟地址完成,不再需要进行物理地址的计算和转换,因为处理器会完成这一切。
此时的内核已经加载到内存,接下来根据ELF的文件头来获取相关信息以后,将内核按照节区(Section)划分复制到虚拟地址对应的位置后直接用长跳转刷新流水线后达到内核的第一条指令。
如上内容的流程图:
特权级:
首先需要涉及TSS(Task State Segment)结构:
这是一个针对任务的结构体,每个任务都会拥有一个TSS结构体(这个“任务”将在以后成为进程)。
SS代表栈段寄存器,用以储存不同等级下的栈基址,分别有R0,R1,R2这三个等级;至于R3,因为R3向高权限区域访问时会将自己的SS入栈;而高权限区域从来不需要向R3“主动跳转”,因此不需要SS3(“主动”指的是类似中断、call等,ret等指令我称之为被动跳转)。
不过就要提到上述的GDT以及下文涉及的门描述符了。其结构如下图。
门
type值
存在位置
用法
任务门
0101
GDT、LDT、IDT
与TSS配合实现任务切换,不过大多数操作系统都不这么玩
中断门
1110
IDT
进入中断后屏蔽中断(eflags的IF位置0),linux利用此实现系统调用,int 0x80
陷阱门
1111
IDT
进入中断后不屏蔽中断
调用门
1100
GDT、LDT
用户用call或jmp指令从用户进程进入0特权级
GDT(Global Descriptor Table)除了一般的段描述符外,还储存各类门描述符。
(但也可能从LDT(局部描述符)中寻找,但原理是一样的)
一个门描述符中包含了对应的例程选择子和例程偏移量,像该门跳转的过程同一般的jmp相近,但特别的是,需要经过处理器的权限检查。
必须明确的是:
描述符 (Descriptor):用以描述一个段的属性
选择子(Selector):用以访问内存
因此描述符规定了访问该内存所需的权限,而选择子都表明了访问者拥有的权限。
每个描述符中的DPL(Descriptor Privilege Level)标识该描述符所拥有的权级,03对应R0R3的权限。接下来分为两个情况:
请求数据:
处理器对比当前CPL(Current Privilege Level)和目标段选择子中的DPL(Descriptor Privilege Level),若CPL<=DPL,则允许访问。
因此对于R3下的程序,如果尝试读取内核数据,就会因为CPL大于DPL而被阻止。
CPL即为当前CS和SS寄存器选择子中的RPL(Request Privilege Level),意味请求特权级。
- 检查时机:特权级检查会发生在往 数据段寄存器 中加载 段选择子 的时候,数据段寄存器包括 DS 和附加段寄存器 ES、FS、GS,如
mov ds,ax
- 检查条件:CPL <= 目标数据段DPL && RPL <= 目标数据段DPL (只能高特权级的指令访问地特权级的数据)
跳转执行:
倘若程序企图直接从R3跳转到R0权限执行,就需要通过门进行了。但情况还是要分为两种,跳转目标是/非一致代码段。
call 内核选择子
- 检查条件
- 无门结构且目标为非一致代码段:CPL = RPL = 目标代码段DPL
- 无门结构且目标为一致代码段:CPL >= 目标数据段DPL && RPL >= 目标数据段DPL
- 有门结构:DPL_GATE >= CPL >= DPL_CODE && RPL <= DPL_GATE(从低特权级跳到高特权级需要通过门)
转移前的栈结构如下:
同时,当SS切换到高权级的时,会自动将这些内容复制到新的栈中。最后会通过iret或retf指令返回到R3
RPL意味着“请求者”的权限等级,而非“承包商”的。
思考这样一个情况:
- 用户程序发出读取硬盘调用请求,操作系统接收,进入内核(CPL=0/RPL=3)
- 操作系统执行调用,将数据写入缓冲区(CPL=0/RPL=3)
倘若缓冲区位于R3段,那么DPL=3,能够正常写;但倘若缓冲区位于R0,那么DPL=0,应该被阻止。RPL相当于发出调用请求的用户程序,而CPL则相当于执行请求的“承包商”,不能因为“当前权限允许就去执行”,还需要判断“发起人是否有足够的权力这样做”。
至于RPL是在什么时候被写换的,内核态时,RPL为什么不会是0?
首先,Selector是由操作系统提供的,在提供该Selector时会将RPL改为用户的CPL,因此用户手中的Selector对应的RPL必定会是3。
然后,CPL是在用户程序加载时由操作系统设定的,并且操作系统规定,处于R3的权限下不能将CS段中的低两位降低,所以用户的CPL必定为3,且不由用户控制。
最后,在用户需要提交自己的选择子时,不论用户能否伪造它,只要用户无法伪造CS 寄存器,它就无法修改自己提交的选择子。因为只要用户提交选择子,操作系统就会用CPL来替换选择子中的RPL,而该选择子的RPL意味着请求以后的RPL。
这两个耦合的键值加上操作系统的强权,硬是把权限限死了……
另外,当调用门完成调用之后,需要从R0切换回R3,只需要把栈中的数据恢复即可。但值得注意的是,DS、ES、FS、GS等寄存器的值如果不属于返回目标对应的DPL区域,会直接被置0,以防止数据泄露。在之后的运行过程中,如果需要调用该寄存器,就会触发处理器异常,然后调用到对应的处理函数去。
至于本章最后的I/O特权级,我个人认为作为了解性知识即可,便不再过多赘述了。
.
.
.
插画ID:93302401