5.3 加载内核
5.3.1 用 C 语言写内核
第一个 C 语言代码:
1 | int main(void) { |
这个内核文件什么都没做,通过while(1)
这个死循环一直使用 CPU,目的是停在这里。这个死循环仅仅是为了演示 elf 文件解析以及加载内核的作用。
用 gcc 编译该程序:
1 | gcc -m32 -c -o kernel/main.o kernel/main.c |
-c
的作用是编译、汇编到目标代码,不进行链接,也就是直接生成目标文件。
-o
的作用是将输出的文件以指定文件名来存储,有同名文件存在时直接覆盖。
main.o
文件还是个“半成品”,因为它只是个目标文件,也称为待重定位文件,重定位指的是文件里面所用的符号还没有安排地址,这些符号的地址需要将来与其他目标文件“组成”一个可执行文件时再重新定位(编排地址)。这里的符号就是指该目标文件中所调用的函数或使用的变量,这里的“组成”就是指链接。由于不知道可执行文件由几个目标文件组成,所以一律在链接阶段对符号重新定位(编排地址)。
用file kernel/main.o
来检查一下文件状态,可以看到目标文件是有relocatable
属性的:
用nm kernel/main.o
列出符号信息,发现由于 main 函数地址未被链接,所以其值为00000000
:
在 Linux 下用 ld 链接程序:
1 | ld -m elf_i386 kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin |
-Ttext
指定起始虚拟地址为0xc0001500
,这个地址是设计好的;
-o
指定输出的文件;
-e
指定程序的入口地址,参数可以是数字形式的地址,也可以是符号名。默认入口地址是_start
;
我们用 gcc 直接生成可执行文件:
1 | gcc -m32 -o kernel/test.bin kernel/main.c |
可以发现直接编译生成的文件比手动链接生成的文件大得多,在 test.bin 中出现了很多不是咱们代码中存在的符号,说明编译器在编译过程中为咱们引用了别的代码,这就是 C 运行库的功劳,目的是在调用 main 函数前做初始化环境等工作。
其实这段 C 语言代码换成汇编代码的话就是个jmp $
,其大小不过是 2 字节的机器码ebfe
。除了编译器自动添加的代码外,一般情况下 C 语言编译出来的程序也比汇编语言生成的程序体积大。可见人们常说的汇编语言比 C 语言快,并不是汇编语言本身有多快(它也要变成机器指令后才能上 CPU 运行),而是汇编语言对应的机器指令是一对一的、简单、直接、可依赖,而 C 语言生成的机器指令是一对多的、复杂、间接、略冗余。
5.3.2 二进制程序的运行方法
BIOS 调用 MBR,MBR 的地址是0x7c00
,MBR 调用 loader,loader 的地址是0x900
,这两个地址是固定的,也就是说我们目前的方法是很不灵活的,调用方需要提前和被调用方约定调用地址。
在程序中,程序头(也就是文件头〉用来描述程序的布局等信息,它属于信息的信息,也就是元数据。包含程序头的程序文件示意如图 5-32 所示。
以 header.S 为例:
1 | header: |
调用方程序知道 header.S 的文件格式,于是:
- 将 header.bin 前 8 字节的内容读到内存,前 4 字节是程序体长度,后 4 字节是程序的入口地址;
- 将 header.bin 开头偏移 8 字节的地方作为起始,将 header.bin 文件尾即开头偏移(8+程序体长度)个字节的地方作为终止;
- 将起始至终止之间的程序体复制到入口地址;
- 转到入口地址处执行。
5.3.3 elf 格式的二进制文件
Windows 下的可执行文件格式是 PE,即 Protable Executable,Linux 下可执行文件格式是 ELF,即 Executable and Linkable Format,可执行链接格式。
ELF Header 数据结构
有关 elf 的任何定义,包括变量、常量及取值范围,都可以在 Linux 系统的/usr/include/elf.h
中找到。
https://llvm.org/doxygen/BinaryFormat_2ELF_8h_source.html
1 | struct Elf32_Ehdr { |
先介绍 elf 的结构:
字段 | 含义 |
---|---|
e_ident[16] |
16 字节的数组,用来表示 elf 字符等信息,开头的 4 个字节是固定不变的,是 elf 文件的魔数,它们分别是0x7f 、字符串 ELF 的 asc 码:0x45 、0x4c 、0x46 。详见表 5-9 |
e_type |
用来指定 elf 目标文件的类型,详见表 5-10,后两种是与硬件相关的参数,故取值很“怪异” |
e_machine |
用来描述 elf 目标文件的体系结构类型,也就是说该文件要在哪种硬件平台(哪种机器)上才能运行,详见表 5-11 |
e_version |
用来表示版本信息 |
e_entry |
用来指明操作系统运行该程序时,将控制权转交到的虚拟地址 |
e_phoff |
用来指明程序头表(program header table)在文件内的字节偏移量。如果没有程序头表,该值为 0 |
e_shoff |
用来指明节头表(section header table)在文件内的字节偏移量。若没有节头表,该值为 0 |
e_flags |
用来指明与处理器相关的标志 |
e_ehsize |
用来指明 elfheader 的宇节大小 |
e_phentsize |
用来指明程序头表(program header table)中每个条目(entry )的字节大小,即每个用来描述段信息的数据结构的字节大小 |
e_phnum |
用来指明程序头表中条目的数量,实际就是段的个数 |
e_shentsize |
用来指明节头表(section header table)中每个条目(entry)的字节大小,即 每个用来描述节信息的数据结构的字节大小 |
shnum |
用来指明节头表中条目的数量。实际上就是节的个数 |
e_shstrndx |
用来指明 string name table 在节头表中的索引 index |
程序头表的数据结构
struct Elf32_Phdr
结构的功能类似 GDT 中段描述符的作用,段描述符用来描述物理内存中的一个内存段,而struct Elf32 Phdr
是用来描述位于磁盘上的程序中的一个段,它被加载到内存后才属于 GDT 中段描述符所指向的内存段的子集。
1 | // Program header for ELF32. |
字段 | 含义 |
---|---|
p_type |
用来指明程序中该段的类型,详见表 5-12 |
p_offset |
用来指明本段在文件内的起始偏移字节 |
p_vaddr |
用来指明本段在文件内的起始偏移字节 |
p_paddr |
用来指明本段在内存中的起始虚拟地址 |
p_filesz |
用来指明本段在文件中的大小 |
p_memsz |
用来指明本段在内存中的大小 |
p_flags |
用来指明与本段相关的标志,详见表 5-13 |
p_align |
用来指明本段在文件和内存中的对齐方式;如果值为 0 或 1 ,则表示不对齐。否则应该是 2 的幂次数; |
5.3.4 elf 文件实例分析
1 | xxd -u a -g I -s $2 -l $3 $1 |
使用./xxd.sh kernel/kernel.bin 0 300
分析 kernel.bin:
脚本的输出大概分了三部分:
- 最左边的一列是十六进制的地址,或者称为偏移量最为恰当。
- 中间这一大块矩阵似的十六进制数字是文件中的内容,每两位十六进制数字为一字节,每行共 16 个字节。
- 最右边那一列,含有点点的、偶尔伴有可读字符的部分是字符显示区,这部分将内容按照字符编码显示,不是可显示的字符便显示为“.”。
中间部分:
-
elf header 部分:
行数 占位(单位:字节) HEX 属性 含义 第一行 16 7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00 e_indent 数组 7F 45 4C 46
为魔数,01
代表 32 位 elf 文件,01
小端字节序,01
当前版本第二行 2 02 00 e_type 值为 0x02
,类型为ET_EXEC
可执行文件2 01 00 e_machine 值为 1,表示为 EM_386
4 01 00 00 00 e_version 版本信息 4 00 15 00 C0 e_entry 值为 0xc0001500
,程序的虚拟入口地址4 34 00 00 00 e_phoff 值为 0x34
,表示程序头表在文件中的偏移量第三行 4 BC 21 00 00 e_shoff 值为 0x21BC
,表示节头表在文件中的偏移量4 00 00 00 00 e_flags 指明与处理器相关的标志 2 34 00 e_ehsize 值为 0x34
,表示 elf header 大小(这与 e_phoff 大小一致,说明程序头表紧跟 elf header 后)2 20 00 e_phentsize 值为 0x20
,32 字节,即struct Elf64_Phdr
的大小2 07 00 e_phnum 值为 7,表示程序头表中有 7 个段 2 28 00 e_shentsize 值为 0x28
,表示节头表中节的大小第四行 2 09 00 e_shnum 值为 9,表示有 9 个节 2 08 00 e_shstrndx 值为 7,表示 string name table
在节头中的索引为 8 -
program header 部分:
| 行数 | 占位
单位:字节 | HEX | 属性 | 含义 |
| — | — | — | — | — |
| 第四行 | 4 | 01 00 00 00 | p_type | 值为 1,指明程序中该段的类型为可加载程序段
|
| | 4 | 00 00 00 00 | p_offset | 值为 0,表示本段在文件内的偏移量(存疑) |
| | 4 | 00 80 04 08 | p_vaddr | 值为0x08048000
,表示本段被加载到内存后的起始虚拟地址 |
| 第五行 | 4 | 00 80 04 08 | p_paddr | 它通常和 p_vaddr 值一致,但该属性是保留项 |
| | 4 | 30 01 00 00 | p_filesz | 值为0x0130
,304 字节,表示本段在文件中的字节大小 |
| | 4 | 30 01 00 00 | p_memsz | 值为0x0130
,表示本段在内存中的大小,由于段无论在哪里,逻辑大小是不变的,故该值等于p_filesz
|
| | 4 | 04 00 00 00 | p_flags | 值为 4,表示PF_R
,为可读 |
| 第六行 | 4 | 00 10 00 00 | p_align | 值为0x1000
,4096,表示本段对齐的方式 |
使用readelf -e kernel/kernel.bin
查看信息:
-e 相当于 -hls
5.3.5 将内核载入内存
回顾一下计算机启动过程:
- 计算机启动后,运行的第一个程序是 BIOS,BIOS 检查、初始化硬件之后,将 0 盘 0 柱面 1 扇区的 MBR 加载到内存 0x7C00 的位置并跳转过去;
- MBR 选择指定的操作系统的内核加载器程序 loader,从硬盘中读取出 loader 程序到内存中并跳转过去;
- loader 将内核 kernel 程序从硬盘上读出并加载到内存中。
目前为止硬盘使用情况:
扇区 | 内容 |
---|---|
第 0 扇区 | MBR |
第 1 扇区 | 空 |
第 2 扇区 | loader,由于 loader.bin 目前的大小是 1342 字节,占用 3 个扇区 |
第 3 扇区 | loader |
第 4 扇区 | loader |
第 5 扇区 | 空 |
内存使用情况:
内存范围 | 大小 | 内容 |
---|---|---|
0x000~0x3FF | 1k | IVT,中断向量表 |
0x400~0x4FF | 256B | BIOS |
0x500~0x7BFF | 30K | 可用 |
0x7C00~0x7DFF | 512B | MBR |
0x7E00~0x9FBFF | 608K | 可用 |
0x9FC00~0x9FFFF | 1k | EBDA(Extended BIOS Data Area)扩展 bios 数据区 |
我们将 kernel 写入硬盘的第 9 扇区:
1 | dd if=boot/kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc |
我们将来的内核大小不会超过 100KB,所以直接把 count 改为 200 扇区。而且 dd 命令会自己判断写入的数据量,如果参数 if 指定的文件大小小于 count*bs,只按照实际文件大小写入。
内核是由 loader 加载的,loader 需要完成:
- 加载内核:需要把内核文件加载到内存缓冲区
程序执行到 loader 后,MBR 的使命就结束了,所以 MBR 占用的内存区可以被覆盖掉了。
内核被加载到内存后,loader 还要通过分析其 elf 结构将其展开到新的位置,内核在内存中有两份拷贝,一份是 elf 格式的原文件,另一份是 loader 解析 elf 格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段 segment 复制到内存后的程序体),这个映像才是真正运行的内核。
我们可以将内核文件 kernel.bin 加载到地址较高的空间,而内核映像要放置到较低的地址。内核文件经过 loader 解析后就没用了,这样内核印象将来往搞地质处扩展时可以覆盖原来的内核文件 kernel.bin。
这里我们选的 kernel 地址为0x70000
。
1 | ;----加载 kernel---- |
rd_disk_m_32
是从硬盘上读取文件的函数,与 mbr 中的rd_disk_m_16
函数只是版本由 16 位变成了 32 位的。
- 初始化内核:需要在分页后,将加载进来的 elf 内核文件安置到相应的虚拟内存地址,然后跳过去执行
初始化内核就是根据 elf 规范将内核文件中的段(segment)展开到(复制到)内存中的相应位置。
1 | ;开启分页后,用gdt新的地址重新加载 |
函数 kernel_init
的作用是将 kernel.bin 中的段( segment)拷贝到各段自己被编译的虚拟地址处,将这些段单独提取到内存中,这就是平时所说的内存中的程序映像。kernel init 的原理是分析程序中的每个段(segment),如果段类型不是 PTNULL(空程序类型),就将该段拷贝到编译的地址中。
函数mem_cpy
的作用是将 src 指向的地址空间处的连续 n 个字节拷贝到 dest 指向的地址空间,此函数的原型相当于 mem_cpy(void dest, _void* src, int size),所以此处需要提供三个参数,这三个参数都在程序头中,所以他们都可以基于 ebx+偏移量来得到。怎么传递参数呢?在汇编语言中传递参数的方法太多了,可以把参数放入寄存器中,也可以放入栈中,亦或直接放入某块内存中,类似共享内存的方式来传递参数。我们这里把参数放入栈中保存,而且参数的入栈顺序是从右往左。
说点题外话:
关于栈
栈是从上往下发展的,而且栈底是用不上的。
由于栈指针 esp 已经在 loader 中被加上了0xc0000000
,所以栈中地址都是内核所在的高地址。用 call 指令进行函数调用时,CPU 会自动在栈中压入返回地址。
字符串搬运指令族
movsb
、movsw
、movsd
,其中movs
代表 move string,后面的 b 代表 byte,w 代表 word,d 代表 dword。所以 movsb 代表复制 1 字节,movsw 代表复制 2 字节,movsd 代表复制 4 字节。
这三条指令是将DS : [E]SI
指向地址处的相应字节大小搬到ES: [E]DI
指向的地址处。16 位环境下用 SI 和 DI 寄存器,32 位环境下用 ESI 和 EDI 寄存器。复制字符串是表象,本质上是复制字节,所以它被用于复制数据。
重复运行指令
rep
指令,该指令是按照 ecx 寄存器中指令的次数重复执行后面指定的指令,每执行一次 ecx 自减 1,直到 ecx 等于 0 为止。
重复复制数据的话需要有指令来更新数据的来源和目的地,这时cld
和std
就派上用场了,这两个指令本质上是控制重复执行字符串指令时的 esi 和 edi 的递增方式,递增方式是指他们的地址是往高地址变化还是低地址变化。
cld
是指 clean direction,作用是将 eflags 寄存器中的方向标志位 DF 置为 0,这样 rep 循环执行后面的指令时,esi 和 edi 会根据使用的字符串搬运指令自动加上搬运数据的字节大小,这是由 CPU 自动完成的,不用人工干预。
std
是指 set direction,该指令是将方向标志位置为 1,req 循环执行后 esi 和 edi 会自动减去所搬运数据的大小。
字符串操作还有 ins[bwd]、outs[bwd]、lods[bwd]和 stos[bwd]。
进入内核后栈地址改为0xc009f000
,目前的内存布局如图所示:
5.4 特权级深入浅出
所谓保护模式下的“保护”,主要体现在特权级上,以后随着后面工作的展开,会越来越多地和它们打交道。
5.4.1 特权级那点事
整个计算机世界可以分为两部分,访问者和受访者。访问者的特权级是可以改变的,受访者的特权不能变。
程序特权级按照权力从大到小分为 0、1、2、3 级,数字越小,权力越大。0 级特权是操作系统内核所在的特权级,计算机在启动之初就以 0 级特权运行,例如 MBR 就是以 0 级特权运行的。
- 操作系统位于最内环的 0 级特权,它要直接控制硬件,掌控各种核心数据,所以它的权利必须最大。
- 系统程序分别位于 1 级特权和 2 级特权,运行在这两层的程序一般是虚拟机、驱动程序等系统服务。
- 在最外层的是 3 级特权,用户程序就运行在此层。
5.4.2 TSS 简介(了解)
TSS,即 Task State Segment,意为任务状态段, 它是处理器在硬件上原生支持多任务的一种实现方式,也就是说处理器原本是想让操作系统开发厂商利用此结构实现多任务的,人家处理器厂商已经提供了多任务管理的解决方案,只是后来操作系统不买账。TSS 是一种数据结构,它用于存储任务的环境。
TSS 是每个任务都有的结构, 它用于一个任务的标识,相当于任务的身份证,程序拥有此结构才能运行,这是处理器硬件上用于任务管理的系统结构,处理器能够识别其中每一个字段。该结构看上去也有点复杂,里面众多寄存器都囊括到这 104 字节中啦,其实这 104 字节只是 TSS 的最小尺寸,根据需要,还可以再接上个 IO 位图,这些内容将在后面章节用到时补充。这里目前只需要关注 28 字节之下的部分,这里包括了 3 个栈指针。
在没有操作系统的情况下,可以认为进程就是任务,任务就是一段在处理器上运行的程序,相当于某个计算机高手在脱离操作系统的情况下所写的代码,它能让计算机很好地运行。在有了操作系统之后,程序可分为用户程序和操作系统内核程序,故之前完整的一个任务也因此被分为用户部分和内核部分。由于内核程序位于 0 特权级,用户程序位于 3 特权级,所以, 一个任务按特权级来划分的话,实质上是被分成了 3 特权级的用户程序和 0 特权级的内核程序,这两部分加在一起才是能让处理器完整运行的程序,也就是说完整的任务要历经这两种特权的变换。所以我们平时在 Linux 下所写的程序只是个半成品,咱们只负责完成用户态下的部分,内核态的部分由操作系统提供。
任务是由处理器执行的,任务在特权级变换时,本质上是处理器的当前特权级在变换,由一个特权级变成了另外一个特权级。这就开始涉及栈的问题了,处理器固定,处理器在不同特权级下,应该用不同特权级的栈,原因是如果在同一个栈中容纳所有特权级的数据时,这种交叉引用会使栈变得非常混乱,并且用一个栈容纳多个特权级下的数据,栈容量有限很容易溢出。
每个任务的每个特权级下只能有一个栈,不存在一个任务的某个特权级下存在多个同特权级栈的情况。也就是说, 一共 4 个特权级, 一个任务“最多”有 4 个栈。既然一个 TSS 代表一个任务,每个任务 又有 4 个栈,那为什么 TSS 中只有 3 个栈: ss0 和 esp0、ss1 和 esp1、ss2 和 esp2?它们分别代表 0 级栈的段选择子和偏移量、1 级栈的段选择子和偏移量、2 级栈的段选择子和偏移量。
前面说的一个任务最多拥有 4 个栈,并不是所有的任务都是这样的。特权级在变换时,需要用到不同特权级下的栈,当处理器进入不同的特权级时,它自动在 TSS 中找同特权级的栈。由于 TSS 是处理器硬件原生的系统级数据结构,处理器知道 TSS 中哪些字段是目标栈的选择子及偏移量。
特权级转移
特权级转移分为两类, 一类是由中断门、调用门等手段实现低特权级转向高特权级,另一类则相反,是由调用返回指令从高特权级返回到低特权级,这是唯一一种能让处理器降低特权级的情况。
对于第 1 种一一特权级由低到高的情况,由于不知道目标特权级对应的栈地址在哪里,所以要提前把目标栈的地址记录在某个地方,当处理器向高特权级转移时再从中取出来加载到 SS 和 ESP 中以更新栈, 这个保存的地方就是 TSS。处理器会自动地从 TSS 中找到对应的高特权级栈地址,这一点对开发人员是透明的,咱们只需要在 TSS 中记录好高特权级的栈地址便可。
也就是说,除了调用返回外,处理器只能由低特权级向高特权级转移, TSS 中所记录的栈是转移后的高特权级目标栈,所以它一定比当前使用的栈特权级要高,只用于向更高特权级转移时提供相应特权的栈地址。 进一步说, TSS 中不需要记录 3 特权级的栈,因为 3 特权级是最低的,没有更低的特权级会向它转移。
不是每个任务都有 4 个栈, 一个任务可有拥有的栈的数量取决于当前特权级是否还有进一步提高的可能,即取决于它最低的特权级别。比如 3 特权级的程序,它是最低的特权级,还能提升三级,所以可额外拥有 2、1、0 特权级栈,用于将特权分别转移到 2、1 、0 级时使用。 2 特权级的程序,它还可以提升两级,所以可额外拥有 1、0 特权级栈,用于将特权级分别转移到 1、0 级时使用。以此类推, 1 特权级的程序, 它可以额外拥有 0 特权级栈。特权级已经是至高无上了,只有这一个 0 级栈。
对于第 2 种一一由高特权返回到低特权级的情况,处理器是不需要在 TSS 中去寻找低特权级目标栈的。正常情况下,特权级由低向高转移在先,由高向低返回在后,即只有先向更高特权级转移,才能谈得上再从高特权级回到低特权级。当处理器由低向高特权级转移时,它自动地把当时低特权级的栈地址(SS 和 ESP)压入了转移后的高特权级所在的栈中,所以当用返回指令如 retf 或 iret 从高特权级向低特权级返回时,处理器可以用当前使用的搞特权级的栈中获取低特权级的栈段选择子及偏移量。由高特权级返回低特权级的过程称为“向外层转移”。
处理器如何找到 TSS
TSS 是硬件支持的系统数据结构,它和 GDT 等一样,由软件填写其内容,由硬件使用。 GDT 也要加载到寄存器 GDTR 中才能被处理器找到, TSS 也是一样,它是由 TR ( Task Register)寄存器加载的,每次处理器执行不同任务时,将 TR 寄存器加载不同任务的 TSS。
正是由于处理器提供了硬件方面的框架,所以很多工作都是“自动”完成的,虽然操作系统看上去是底层的技术,但其实也属于“应用型”开发。
5.4.3 CPL 和 DPL 入门
计算机特权级的标签体现在 DPL 、 CPL 和 RPL。
指令“请求”、“访问”其他资源的能力等级便称之为请求特权级,指令存放在代码段中,所以,就用代码段寄存器 CS 中选择子的 RPL 位表示代码请求别人资源能力的等级。代码段寄存器 CS 和指令指针寄存器 EIP 中指向的指令便是当前在处理器中正在运行的代码,所以位于 CS 寄存器中选择子低 2 位的值不仅称为请求特权级,又称为处理器的当前特权级,也就是说处理器的当前特权级是 CS.RPL。
在 CPU 中运行的是指令,其运行过程中的指令总会属于某个代码段,该代码段的特权级,也就是代码段描述符中的 DPL,便是当前 CPU 所处的特权级,这个特权级称为当前特权级,即 CPL( Current Privilege Level),它表示处理器正在执行的代码的特权级别。
指令最终是用处理器执行的,执行到不同特权的代码,处理器的特权级就换到不同的等级。所以,当前特权级实际上是指处理器当前所处的特权级,是指处理器的特权角色,更形象一点地说,是指 CPU 当前在计算机世界中的特权地位。
当前正在运行的代码所在的代码段的特权级 DPL 就是处理器的当前特权级。当处理器从一个特权级的代码段转移到另一个特权级的代码段上执行时,由于两个代码段的特权级不一样,处理器当前的特权身份起了变化,这就是当前特权级 CPL 改变的原因。其实就是使用了那些能够改变程序执行流的指令,如 int、call 等,这样就使 CS 和 EIP 的值改变,从而使处理器执行到了不同特权级的代码。
受访者的特权标签
在段描述符中有一个属性还为该内存标明了特权等级,这就是段描述符中的 DPL 字段的作用,它就是受访者的特权标签。不仅段描述符中有 DPL 字段,以后所介绍的所有描述符都有 DPL。DPL,即 Descriptor Privilege Level,描述符特权级。
- 对于受访者为数据段(段描述符中 type 字段中没有 X 可执行属性)来说:只有访问者权限大于等于该 DPL 表示的最低权限才能够继续访问
- 对于受访者为代码段(段描述符中 type 字段中有 X 可执行属性)来说:只有访问者权限等于该 DPL 表示的最低权限才能够继续访问,即只能平级访问。任何权限大于或小于它的访问者都将被 CPU 拒之门外。
唯一一种处理器会从高特权降到低特权运行的情况:处理器从中断处理程序中返回到用户态的时候。在中断的处理过程中需要具备访问硬件的能力,在大多数情况下只 有 CPU 处于 O 特权级才能访问硬件,这是因为 eflags 寄存器中的 IOPL 位的值通常被设置为 0,并且 TSS 中不存在 IO 位图。而且有些中断处理中需要的指令只能在 0 特权级下使用,这部分指令称为特权指令,所以中断发生后其处理的过程必须在 0 特权级下进行。
不提升特权级执行执行高特权级代码段指令
使用一致性代码段。在段描述符中如果该段为非系统段(段描述符的 S 字段为 0),可以用 type 字段中的 C 位来表示该段是否为一致性代码段。C 为 1 时则表示该段是一致性代码段,C 为 0 时则表示该段为非一致性代码段。一致性代码段是指若自己是转移后的代码单,自己的特权级 DPL 一定要大于等于转移前的 CPL,而且转移后 CPL 不会用该目标段的 DPL 替换,即转移后 CPL 不变。
代码段有一致性和非一致性之分,但所有的数据段都是非一致的。
5.4.4 门、调用门与 RPL 序
处理器只有通过“门结构”才能由低特权级转移到高特权级。而门结构就是记录一段程序起始地址的描述符。门描述符用来描述一段程序。进入门处理器就能转移到更高的特权级上。门描述符同段描述符类似,都是 8 字节大小的数据结构,用来描述门中通向的代码。
一共有四种门结构:
- 任务门:任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时, 如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务门的选择子或任务 TSS 的选择子。
- 中断门:以 int 指令主动发中断的形式实现从低特权向高特权转移;
- 陷阱门:以 int3 指令主动发中断的形式实现从低特权向高特权转移,一般是编译器在调试时用;
- 调用门:call 和 jmp 指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。 call 指令使用调用门可以实现向高特权代码转移, jmp 指令使用调用门只能实现向平级代码转移。
任务门是用任务 TSS 的描述符选择子来描述一个任务,除了任务门外,其他三种门都是对应到一段例程,即对应一段函数,故需要用“代码段选择子+段内偏移量”来确定程序的确切地址。无论是哪种门描述符,他们中所记录的信息都已经可以确定所描述的对象(例程或任务)了,所以在被调用时,CPU 都会忽略指令中的偏移量。
- 任务门描述符位于 GDT、 LDT 或 IDT(中断描述符表)中
- 调用门位于 GDT 或 LDT 中
- 中断门和陷阱门仅位于 IDT 中
任务门、调用门都可以用 call 和 jmp 指令直接调用,原因是这两个门描述符都位于 GDT 或 LDT,访问它们同普通的段描述符是一样的,必须通过选择子,因此只要在 call 或 jmp 指令后接任务门或调用门的选择子便可调用它们。而陷阱门和中断门只存在于 IDT 中,因此不能主动调用,只能由中断信号来触发调用。
现代操作系统很少用到调用门和任务门,在咱们的系统中也只用到了中断门,而陆阱门是供调试器用的,咱们并未打算支持应用程序的调试,一方面工作量较大,另一方面违背咱们的初衷,就是想通过更少的代码了解操作系统原理。
门描述符的 DPL 要低于等于 CPL,否则访问者连门都进不去,门中包含的目标程序所在的段的 DPL 要高于或等于 CPL,不然还使用门干嘛。
操作系统可以利用调用门实现一些系统功能(但现代操作系统用调用门实现系统调用并不是主流, 一 般是用中断门实现系统调用),用户程序需要系统服务时可以调用该调用门以获得内核帮助。
不同特权级下处理器用不同的栈,处理器处于 3 特权级下时要用 3 特权级下的栈,处理器处于 0 特权级下要用 0 特权级下的栈。所以用户传入参数是在 3 特权级下做的,但内核服务程序是在 0 特权级下,他需要在 0 特权级栈中获取参数。处理器的设计者也看到了这一点,故为了方便软件开发人员,处理器在固件上实现参数的自动复制,即,将用户进程压在 3 特权级栈中的参数自动复制到 0 特权级栈中。所以在调用门描述符中的高 32 位有个“参数个数”,这是专门给处理器准备的。
5.4.5 调用门的过程保护
通过调用门也不能由高特权级转向低特权级,只有通过返回指令如 retf 或 iret 才能够做到由高特权级转移到低特权级。
通过 call 指令调用“调用门”的过程:
假设在 32 位模式下用户进程要调用某个调用门,该门描述符中参数的个数是 2,调用前的当前特权级是 3,调用后的新特权级为 0。
- 为调用门提供 2 个参数。向当前特权级栈,即 3 特权级栈压入两个参数。
- 确定新特权级栈的栈。根据门描述符中选择子对应的目标代码段的 DPL,这里 DPL 为 0,处理器自动在 TSS 中找到合适的栈段选择子 SS 和栈指针 ESP,为了方便叙述,这里记作 SS_new 和 ESP_new。
- 检查新栈段选择子对应的描述符的 DPL 和 TYPE,如果未通过检查则处理器引发异常。
- 特权级栈转换。这里将旧栈段记为 SS_old 和 ESP_old,处理器先找个地方临时保存 SS_old 和 ESP_old,之后将 SS_new 加载到栈段寄存器 SS,将 ESP_new 加载到栈指针寄存器 esp,这样便启动了新栈。
- 将 SS_old 和 ESP_old 压入当前新栈中(即 0 特权级栈中),由于栈操作数是 32 位,SS_old 是 16 位数据,故将其高 16 位用 0 填充后入栈保存。如图 5-56A。
- 复制 3 特权级栈的参数到 0 特权级栈。如图 5-56B。
- 备份 CS 和 EIP 寄存器。由于调用门描述符中记录的是目标程序所在代码段的选择子及偏移地址,这都意味着代码段寄存器 CS 要用该选择子重新加载,只要段寄存器被加载,段描述符缓冲寄存器就会被刷新,所以需要将当前代码段 CS 和 EIP 都备份在栈中,这两个值记为 CS_old 和 EIP_old,由于 CS_old 是 16 位数据故高位补 0 后再入栈。如图 5-56C。
- 运行调用门指向的程序。将门描述符中国你的代码段选择子和偏移量分别装载到 CS 和 EIP 寄存器中。
若在第 2 步处理器发现是平级转移,处理器并不会更新当前栈,也就是说不会从 TSS 中在此选择同级的栈载入 SS 和 ESP,处理器只是把此转移当成直接远转移,直接跳到第 7 步。
用 retf 指令将返回地址从栈中弹出到 CS 和 EIP,将低特权级地址弹出到 SS 和 ESP。
用 retf 指令从调用门返回的过程:
- 特权级检查。检查栈中 CS 选择子,根据其 RPL 位判断返回过程中是否要改变特权级。
- 获取栈中 CS_old 和 EIP_old,根据 CS_old 选择子对应的代码段的 DPL 及选择子中的 RPL 做特权级检查。检查通过将 EIP_old 和 CS_old 处理后弹出到 EIP 和 CS 寄存器。
- 跳过
参数个数*参数大小
。此时栈指针 ESP_new 指向 ESP_old。 - 若第 1 步中判断出需改变优先级,则栈中弹出 SS_old 和 ESP_old 处理后存到 SS 和 ESP 寄存器中。
注意:处理器执行 retf 指令从内核态返回时,处理器顶多是把栈中低特权级的 CS、EIP、SS 以及 ESP 的值重新加载到寄存器中,像 DS 等数据段寄存器是不会被更新的。若某个寄存器中选择子指向的数据段描述符的 DPL 比返回后的 CPL 高,但用户进程依然可以直接访问该数据段数据,这就出问题了。
一个可行解决办法是操作系统代码在使用任何一个数据段寄存器时,先将其入栈,然后再更新选择子,在使用完毕后,操作系统再将压入栈的选择子出栈回复到该段寄存器。但这只是用软件来避免此问题的办法,处理器不相信第三方的软件都会处理好此问题,故当出现这种情况时,处理器会把 0 填充到相应的段寄存器,从而让处理器引发异常(访问 0 号段描述符异常)。
5.4.6 RPL 的前世今生
目前资源访问的两个必须保证的客观条件:
- 用户不能访问系统资源
- 处理器必须陷入内核才能帮助用户程序做事
在只有 CPL 和 DPL 情况下,用户程序可以借助内核程序来获得内核数据,这是万万不可的。故此时引入 RPL,用 RPL 代表真正资源需求者的 CPL,以后在请求某特权级为 DPL 的资源时,要求 CPL 和 RPL 的特权级都大于等于 DPL,即数值上 CPL≤DPL 且 RPL≤DPL。
修改 RPL 可以使用arpl
指令。
5.4.7 IO 特权级
eflags 寄存器中第 12 ~ 13 位是 IOPL CI/O Privilege Level),即 IO 特权级,它除了限制当前任务进行 IO 敏感指令的最低特权级外,还用来决定任务是否允许操作全部 IO 端口,这里是全部 IO 端口, IOPL 位是打开所有 IO 端口的开关(用来单独设置端口访问的方式是 IO 位图)。每个任务(内核进程或用户进程)都有自己的 eflags 寄存器,所以每个任务都有自己的 IOPL,它表示当前任务要想执行所有 IO 指令的最低特权级。
可以用 popf 和 iretd 指令改变 IOPL 。
即使特权级数值上 CPL > IOPL,还可以通过 IO 位图来设置部分端口的访问权限,处理器这么做是为了提速。
如果所有 IO 端口访问都要经过内核的话,由低特权级转向高特权级时是需要保存任务上下文环境的,这个过程也是要消耗处理器时间的,随着端口访问变多,时间成本也会变多。典型应用就是硬件的驱动程序,它位于特权级 1.
驱动程序是通过 in、out 等 IO 指令直接访问硬件的程序,它为上层程序提供对硬件的控制访问,相当于硬件的代理。
Intel 处理器最大支持 65536 个端口,它允许任务通过 I/O 位图来开启特定端口,65536 个端口号占用的位图大小是 65536/8 = 8192 字节,即 8KB。若 I/O 位图中相应 bit 被置为 0,表示可以访问,否则为 1 时则禁止访问。
注意,I/O 位图只在 CPL > IOPL 时有效, CPL ≤ IOPL 时任何接口都可直接访问。
I/O 位图是位于 TSS 中的,若 TSS 中没有 I/O 位图便默认为禁止访问所有端口。
若 TSS 中有 I/O 位图,其大小为“I/O 位图偏移地址”+ 8192 + 1 字节,其中 1 字节是 I/O 位图中最后的 0xff。
若没有 I/O 位图,TSS 最小尺寸为 104 字节。
I/O 位图最后一个字节为 0xff 的原因
计算机系统硬件中,IO 端口是按字节编址的,意思是一个端口只能读写 1 个字节的数据,如果对一个端口连续读写多个字节,实际上是从以改端口号为起始的多个端口一起读进来的。
例如: in 指令可以读取 16 位端口数据,即一次读取 2 字节,假设端口 0x234 为 16 位端口:
1 | in ax, 0x234 |
连续的多个 bit 也许会跨字节,这样处理器必须将这两个字节都读进来处理。但当第 1 个 bit 为在位图的最后一个字节就会出问题,处理器要读进 2 个字节,但在第 2 个字节时越界了,该字节不属于位图范围。因此位图最后 1 字节设置为 0xFF,可以有两个作用:
- 字节为 1 表示禁止访问此字节表示的端口,还能禁止访问位图范围外的端口;
- 用作位图的边界标记。