Linux 0.11启动过程分析(一)
Linux 0.11 fork 函数(二)
Linux0.11 缺页处理(三)
Linux0.11 根文件系统挂载(四)
Linux0.11 文件打开open函数(五)
Linux0.11 execve函数(六)
Linux0.11 80X86知识(七)
Linux0.11 内核体系结构(八)
函数调用操作包括从一块代码到另一块代码之间的双向数据传递和执行控制转移。数据传递通过函数参数和返回值来进行。另外,我们还需要在进入函数时为函数的局部变量分配存储空间,并且在退出函数时收回这部分空间。Intel 80x86 CPU 为控制传递提供了简单的指令,而数据的传递和局部变量存储空间的分配与回收则通过栈操作来实现。
大多数 CPU 上的程序实现使用栈来支持函数周用操作。栈被用来传递函数参数、存储返回信息、临时保存寄存器原有值以备恢复以及用来存储局部数据。单个函数调用操作所使用的栈部分被称为栈帧(Stack frame)结构,其通常结构见下图所示。栈帧结构的两端由两个指针来指定。寄存器 ebp 通常用作帧指针(frame pointer),而 esp 则用作栈指针(stack pointer)。在函数执行过程中,栈指针 esp 会随着数据的入栈和出栈而移动,因此函数中对大部分数据的访问都基于帧指针 ebp 进行。
对于函数 A 调用函数 B 的情况,传递给 B 的参数包含在 A 的栈帧中。当 A 调用 B 时,函数 A 的返回地址(调用返回后继续执行的指令地址)被压入栈中,栈中该位置也明确指明了 A 栈帧的结束处。而 B 的栈帧则从随后的栈部分开始,即图中 保存帧指针(ebp) 的地方开始。再随后则用于存放任何保存的寄存器值以及函数的临时值。
B 函数同样也使用栈来保存不能放在寄存器中的局部变量值。例如由于通常 CPU 的寄存器数量有限而不能够存放函数的所有局部数据,或者有些局部变量是数组或结构,因此必须使用数组或结构引用来访问。还有就是 C 语言的地址操作符 ‘&’ 被应用到一个局部变量上时,我们就需要为该变量生成一个地址,即为变量的地址指针分配一空间。最后,B 函数会使用栈来保存调用任何其它函数的参数。
栈是往低(小)地址方向扩展的,而 esp 指向当前栈顶处的元素。通过使用 push 和 pop 指令我们可以把数据压入栈中或从栈中弹出。对于没有指定初始值的数据所需要的存储空间,我们可以通过把栈指针递减适当的值来做到。类似地,通过增加栈指针值我了可以回收栈中已分配的空间。
指令 CALL 和 RET 用于处理函数调用和返回操作。调用指令 CALL 的作用是把 返回地址 压入栈中并且跳转到被调用函数开始处执行。返回地址 是程序中紧随调用指令 CALL 后面一条指令的地址。因此当被调函数返回时就会从该位置继续执行。返回指令 RET 用于弹出栈项处的地址并跳转到该地址处。在使用该指令之前,应该先正确处理栈中内容,使得当前栈指针所指位置内容正是先前 CALL 指令保存的返回地址。另外,若返回值是一个整数或一个指针,那么寄存器 eax 将被默认用来传递返回值。
尽管某一时刻只有一个函数在执行,但我们还是需要确定在一个函数(调用者)调用其他函数(被调用者)时,被调用者不会修改或覆盖掉调用者今后要用到的寄存器内容。因此 Intel CPU 采用了所有函数必须遵守的 寄存器用法统一惯例 。该惯例指明,寄存器 eax、edx 和 ecx 的内容必须由调用者自己负责保存当函数 B 被 A 调用时,函数 B 可以在不用保存这些寄存器内容的情况下任意使用它们而不会毁坏函数 A 所需要的任何数据。另外,寄存器 ebx、esi 和 edi 的内容则必须由被调用者 B 来保护。当被调用者需要使用这些寄存器中的任意一个时,必须首先在栈中保存其内容,并在退出时恢复这些寄存器的内容。因为调用者 A (或者一些更高层的函数)并不负责保存这些寄存器内容,但可能在以后的操作中还需要用到原先的值。还有寄存器 ebp 和 esp 也必须遵守第二个惯例用法。
作为一个例子,我们来观察下面 C 程序 exch.c 中函数调用的处理过程。该程序交换两个变量中的值,并返回它们的差值。
void swap(int* a, int* b) {
int c;
c = *a;
*a = *b;
*b = c;
}
int main() {
int a, b;
a = 16;
b = 32;
swap(&a, &b);
return (a - b);
}
其中函数 swap() 用于交换两个变量的值。C 程序中的主程序 main() 也是一个函数(将在下面说明),它在调用了 swap() 之后返回交换后的结果。这两个函数的栈帧结构如下图所示。可以看出,函数 swap() 从调用者( main() )的栈帧中获取其参数。图中的位置信息相对于寄存器 ebp 中的帧指针。栈帧左边的数字指出了相对于帧指针的地址偏移值。在像 gdb 这样的调试器中,这些数值都用 2 的补码表示。例如 ‘-4’ 被表示成 ‘0xFFFFFFFC’ ,‘-12’ 会被表示成’0xFFFFFFF4’。
调用者 main() 的栈帧结构中包括局部变量 a 和 b 的存储空间,相对于帧指针位于 -4 和 -8 偏移处。由于我们需要为这两个局部变量生成地址,因此它们必须保存在栈中而非简单地存放在寄存器中。
使用命令 “gcc -Wall -S -o exch.s exch.c” 可以生成该 C 语言程序的汇编程序 exch.s 代码,见如下所示。
1 _.text
2 _swap:
3 pushl %ebp #保存原 ebp 值,设置当前函数的帧指针。
4 movl %esp,%ebp
5 subl $4,%esp #为局部变量 c 在栈内分配空间。
6 movl 8(%ebp),%eax #取函数第 1 个参数,该参数是一个整数类型值的指针。
7 movl(%eax),%ecx #取该指针所指位置的内容,并保存到局部变量 c 中。
8 movl %ecx, -4(%ebp)
9 movl 8(%ebp),%eax # 再次取第 1 个参数,然后取第 2 个参数。
10 movl 12(%ebp),%edx
11 movl (%edx),%ecx # 把第 2 个参数所指内容放到第 1 个参数所指的位置。
12 movl %ecx, (%eax)
13 movl 12(%ebp),%eax # 再次取第 2 个参数。
14 movl -4(%ebp),%ecx #然后把局部变量 c 中的内容放到这个指针所指位置处。
15 movl %ecx,(%eax)
16 leave # 恢复原 ebp、esp 值(即 movl %ebp,%esp; popl %ebp;)。
17 ret
18 _main:
19 pushl %ebp # 保存原 ebp 值,设置当前函数的帧指针。
20 movl %esp,%ebp
21 subl $8,%esp #为整型局部变量 a 和 b 在栈中分配空间。
22 movl $16,-4(%ebp) #为局部变量赋初值(a=16,b=32)。
23 mov1 $32,-8(%ebp)
24 leal -8(%ebp),%eax # 为调用 swap ()函数作准备,取局部变量 b 的地址,
25 pushl %eax # 作为调用的参数并压入栈中。即先压入第 2 个参数。
26 leal -4(%ebp),%eax # 再取局部变量 a 的地址,作为第 1 个参数入栈。
27 push1 %eax
28 call _swap # 调用函数 swap()。
29 movl -4(%ebp),%eax #取第 1 个局部变量 a 的值,减去第 2 个变量 b 的值。
30 subl -8(%ebp),%eax
31 leave #恢复原 ebp、esp 值(即 ovl %ebp,%esp; popl %ebp;)。
32 ret
这两个函数均可以划分成三个部分:
对于 swap() 函数,其设置部分代码是 3–5 行。前两行用来设置保存调用者的帧指针和设置本函数的栈帧指针,第 5 行通过把栈指针 esp 下移 4 字节为局部变量 c 分配空间。行 6–15 是 swap 函数的主体部分。第 6–8 行用于取调用者的第 1 个参数&a,并以该参数作为地址取所存内容到 ecx 寄存器中,然后保存到为局部变量分配的空间中(-4(%ebp))。第 9–12 行用于取第 2 个参数&b,并以该参数值作为地址取其内容放到第 1 个参数指定的地址处。第 13–15 行把保存在临时局部变量 c 中的值存放到第 2 个参数指定的地址处。最后 16-17 行是函数结束部分。leave 指令用于处理栈内容以准备返回,它的作用等价于下面两个指令:
mov1 %ebp,%esp # 恢复原 esp 的值(指向栈帧开始处)-
popl %ebp # 恢复原 ebp 的值(通常是调用者的帧指针)。
这部分代码恢复了在进入 swap() 函数时寄存器 esp 和 ebp 的原有值,并执行返回指令 ret。
第 19–21 行是 main()函数的设置部分,在保存和重新设置帧指针之后,main()为局部变量 a 和 b 在栈中分配了空间。第 22–23 行为这两个局部变量赋值。从 24-28 行可以看出 main() 中是如何调用 swap() 函数的。其中首先使用 leal 指令(取有效地址)获得变量 b 和 a 的地址并分别压入栈中,然后调用 swap() 函数。变量地址压入栈中的顺序正好与函数申明的参数顺序相反。即函数最后一个参数首先压入栈中
,而函数的第 1 个参数则是最后一个在调用函数指令 call 之前压入栈中的。第 29–30 两行将两个已经交换过的数字相减,并放在 eax 寄存器中作为返回值。
从以上分析可知,C 语言在调用函数时是在堆栈上临时存放被调函数参数的值,即 C 语言是传值类语言,没有直接的方法可用来在被调用函数中修改调用者变量的值。因此为了达到修改的目的就需要向函数传递变量的指针(即变量的地址)。
上面这段汇编程序是使用 gcc 1.40 编译产生的,可以看出其中有几行多余的代码。可见当时的 gcc 编译器还不能产生最高效率的代码,这也是为什么某些关键代码需要直接使用汇编语言编制的原因之一。另外,上面提到 C 程序的主程序 main()也是一个函数。这是因为在编译链接时它将会作为 crt0.s 汇编程序的函数被调用。crt0.s 是一个桩(stub)程序,名称中的"crt"是"C run-time"的缩写。该程序的目标文件将被链接在每个用户执行程序的开始部分,主要用于设置一些初始化全局变量等。Linux 0.11 中 crt0.s 汇编程序见如下所示。其中建立并初始化全局变量 _environ 供程序中其它模块使用。
1 .text
2 .globl _environ #声明全局变量 _environ(对应 C 程序中的 environ 变量)。
3
4 _entry: #代码入口标号。
5 movl 8(%esp), %eax # 取程序的环境变量指针 envp 并保存在_environ 中。
6 movl %eax, _environ # envp 是 execveO函数在加载执行文件时设置的。
7 call _main # 调用我们的主程序。其返回状态值在 eax 寄存器中。
8 pushl %eax # 压入返回值作为 exit()函数的参数并调用该函数。
9 1: call _exit
10 _jmp 1b #控制应该不会到达这里。若到达这里则继续执行 exit()。
11 .data
12 _environ: # 定义变量_environ,为其分配一个长字空间。
13 .long 0
从汇编程序中调用 C 语言函数的方法实际上在上面已经给出。在上面 C 语言例子对应的汇编程序代码中,我们可以看出汇编程序语句是如何调用 swap() 函数的。现在我们对调用方法作一总结。
在汇编程序调用一个 C 函数时,程序需要首先按照逆向顺序把函数参数压入栈中,即函数最后(最右边的)一个参数先入栈,而最左边的第 1 个参数在最后调用指令之前入栈,见图 3-6 所示。然后执行 CALL指令去执行被调用的函数。在调用函数返回后,程序需要再把先前压入栈中的函数参数清除掉。
在执行 CALL 指令时,CPU 会把 CALL 指令下一条指令的地址压入栈中(见图中 EIP )。如果调用还涉及到代码特权级变化,那么 CPU 还会进行堆栈切换,并且把当前堆栈指针、段描述符和调用参数压入新堆栈中
。由于 Linux 内核中只使用中断门和陷阱门方式处理特权级变化时的调用情况,并没有使用 CALL指令来处理特权级变化的情况,因此这里对特权级变化时的 CALL 指令使用方式不再进行说明。
汇编中调用 C 函数比较"自由"。只要是在栈中适当位置的内容就都可以作为参数供 C 函数使用。这里仍然以图 3-6 中具有 3 个参数的函数调用为例,如果我们没有专门为调用函数 func()压入参数就直接调用它的话,那么 func()函数仍然会把存放 EIP 位置以上的栈中其他内容作为自己的参数使用。
如果我们为调用 func()而仅仅明确地压入了第 1、第 2 个参数,那么 func()函数的第 3 个参数 p3 就会直接使用 p2 前的栈中内容
。在 Linux 0.1x 内核代码中就有几处使用了这种方式。例如在 kernel/systemm_call.s 汇编程序中第 217 行上调用 copy_process()函数(kernel/fork.c 中第 68 行)的情况。在汇编程序函数_sys_fork中虽然只把 5 个参数压入了栈中,但是 copy_process()却共带有多达 17 个参数。
查看前面的_swap ,就是一个汇编函数:
1 _.text
2 _swap:
3 pushl %ebp #保存原 ebp 值,设置当前函数的帧指针。
4 movl %esp,%ebp
5 subl $4,%esp #为局部变量 c 在栈内分配空间。
6 movl 8(%ebp),%eax #取函数第 1 个参数,该参数是一个整数类型值的指针。
7 movl(%eax),%ecx #取该指针所指位置的内容,并保存到局部变量 c 中。
8 movl %ecx, -4(%ebp)
9 movl 8(%ebp),%eax # 再次取第 1 个参数,然后取第 2 个参数。
10 movl 12(%ebp),%edx
11 movl (%edx),%ecx # 把第 2 个参数所指内容放到第 1 个参数所指的位置。
12 movl %ecx, (%eax)
13 movl 12(%ebp),%eax # 再次取第 2 个参数。
14 movl -4(%ebp),%ecx #然后把局部变量 c 中的内容放到这个指针所指位置处。
15 movl %ecx,(%eax)
16 leave # 恢复原 ebp、esp 值(即 movl %ebp,%esp; popl %ebp;)。
17 ret
在 C 语言中调用示例如下:
int main() {
int a, b;
a = 16;
b = 32;
swap(&a, &b);
return (a - b);
}
linux查看C程序的汇编代码(可读懂)
vscode反汇编以及调试
方法 一:使用gcc -S a.c得到a.s,用cat a.s查看;
方法二:先gcc -c a.c得到a.o,用objdump -d a.o来反汇编查看里面的汇编代码;
方法三:在调试中进入gdb, 用disassemble命令查看。
objdump有多个命令选项,可根据需要选择:
-d:将代码段反汇编
-S:将代码段反汇编的同时,将反汇编代码和源代码交替显示,编译时需要给出-g,即需要调试信息。
-C:将C++符号名逆向解析。
-l:反汇编代码中插入源代码的文件名和行号。
-j section:仅反汇编指定的section。可以有多个-j参数来选择多个section。
打断点 F5调试后:
(1)查看汇编代码。在调试控制台输入
-exec disassemble /m
或者 -exec disassemble /m main
(2)查看寄存器的信息。在调试控制台输入
-exec info registers
#include
int add(int a, int b) {
auto sum = a + b;
return sum;
}
int main(int, char **) {
std::cout << "Hello, world!\n";
int sum1 = add(1, 2);
std::cout << "sum:" << sum1 << std::endl;
}
使用如下命令查看汇编代码:
objdump -d -S -C ./build/CMakeFiles/c6.dir/main.cpp.o
汇编代码:
0000000000000000 <add(int, int)>:
#include
int add(int a, int b) {
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d ec mov %edi,-0x14(%rbp)
7: 89 75 e8 mov %esi,-0x18(%rbp)
auto sum = a + b;
a: 8b 55 ec mov -0x14(%rbp),%edx
d: 8b 45 e8 mov -0x18(%rbp),%eax
10: 01 d0 add %edx,%eax
12: 89 45 fc mov %eax,-0x4(%rbp)
return sum;
15: 8b 45 fc mov -0x4(%rbp),%eax
}
18: 5d pop %rbp
19: c3 retq
000000000000001a <main>:
int main(int, char **) {
1a: 55 push %rbp
1b: 48 89 e5 mov %rsp,%rbp
1e: 48 83 ec 20 sub $0x20,%rsp
22: 89 7d ec mov %edi,-0x14(%rbp)
25: 48 89 75 e0 mov %rsi,-0x20(%rbp)
std::cout << "Hello, world!\n";
29: be 00 00 00 00 mov $0x0,%esi
2e: bf 00 00 00 00 mov $0x0,%edi
33: e8 00 00 00 00 callq 38 <main+0x1e>
int sum1 = add(1, 2);
38: be 02 00 00 00 mov $0x2,%esi
3d: bf 01 00 00 00 mov $0x1,%edi
42: e8 00 00 00 00 callq 47 <main+0x2d>
47: 89 45 fc mov %eax,-0x4(%rbp)
std::cout << "sum:" << sum1 << std::endl;
4a: be 00 00 00 00 mov $0x0,%esi
4f: bf 00 00 00 00 mov $0x0,%edi
54: e8 00 00 00 00 callq 59 <main+0x3f>
59: 48 89 c2 mov %rax,%rdx
5c: 8b 45 fc mov -0x4(%rbp),%eax
5f: 89 c6 mov %eax,%esi
61: 48 89 d7 mov %rdx,%rdi
64: e8 00 00 00 00 callq 69 <main+0x4f>
69: be 00 00 00 00 mov $0x0,%esi
6e: 48 89 c7 mov %rax,%rdi
71: e8 00 00 00 00 callq 76 <main+0x5c>
}
76: b8 00 00 00 00 mov $0x0,%eax
7b: c9 leaveq
7c: c3 retq
AT&T汇编
AT&T的汇编世界
8个32-bit寄存器 %eax,%ebx,%ecx,%edx,%edi,%esi,%ebp,%esp
8个16-bit寄存器 它们事实上是上面8个32-bit寄存器的低16位:
%ax,%bx,%cx,%dx,%di,%si,%bp,%sp
8个8-bit寄存器 %ah,%al,%bh,%bl,%ch,%cl,%dh,%dl
它们事实上是寄存器%ax,%bx,%cx,%dx的高8位和低8位
6个段寄存器 %cs(code),%ds(data),%ss(stack), %es,%fs,%gs
3个控制寄存器 %cr0,%cr2,%cr3;
6个debug寄存器 %db0,%db1,%db2,%db3,%db6,%db7;
2个测试寄存器 %tr6,%tr7;
8个浮点寄存器栈 %st(0),%st(1),%st(2),%st(3),%st(4),%st(5),%st(6),%st(7)
16个逻辑上的通用寄存器 rax, rbx, rcx, rdx, rbp, rsp, rdi, rsi, r8~r15
以E开头的寄存器为32位
EAX(累加器,是算术运算的主要寄存器)
EBX(基址寄存器,在内存中寻址时存放基址)
ECX(计数器)
EDX(数据寄存器)
ESI(源变址)
EDI(目标变址)
ESP(堆栈指针)
EBP(基址指针)
EIP(程序计数器,存储的是将要执行的下一条指令放在内存中的地址)
EFLAGS(保存的是根据运算得到的结果设置的条件码ZF,CF,SF,OF)
段寄存器:
CS:代码段寄存器
SS:堆栈段寄存器
DS:数据段寄存器
ES、FS、GS:附加数据段寄存器
寄存器 | 是否有约束 | 惯例/用途 |
---|---|---|
rax | 否 | 1,系统调用时,调用号; 2,函数返回值; 3,除法运算中,存放除数、以及运算结果的商; 4,乘法运算中,存放被乘数、以及运算结果; |
rbx | 是,被调用者保存 | 1,在32位模式下,用来存放GOT的地址; |
rcx | 否 | 1,函数调用时,第4个参数; 2,有时用作counter; |
rdx | 否 | 1,函数调用时,第3个参数; 2,除法运算中,存放运算结果的余数; 3,乘法运算中,存放运算结果溢出的部分; |
rbp | 是,被调用者保存 | frame pointer,存放当前函数调用时栈的基地址 |
rsp | 是,被调用者保存 | 时时刻刻指向栈顶 |
rdi | 否 | 1,函数调用时,第1个参数; 2, rep movsb中的目的寄存器; |
rsi | 否 | 1,函数调用时,第2个参数; 2, rep movsb中的源寄存器; |
r8 | 否 | 1,函数调用时,第5个参数 |
r9 | 否 | 1,函数调用时,第6个参数 |
r10 | 否 | |
r11 | 否 | |
r12 | 是,被调用者保存 | |
r13 | 是,被调用者保存 | |
r14 | 是,被调用者保存 | |
r15 | 是,被调用者保存 |
标志寄存器 EFLAGS 中的系统标志和 IOPL 字段用于控制 I/O 访问、可屏蔽硬件中断、调试、任务切换以及虚拟-8086 模式,见图 4-1 所示。通常只允许操作系统代码有权修改这些标志。EFLAGS 中的其他标志是一些通用标志(进位 CF、奇偶 PF、辅助进位 AF、零标志 ZF、负号 SF、方向 DF、溢出 OF)。这里我们仅队 EFLAGS 中的系统标志进行说明。
GDTR、LDTR、IDTR 和 TR 都是段基址寄存器,这些段中含有分段机制的重要信息表。GDTR、IDTR 和 LDTR 用于寻址存放描述符表的段。TR 用于寻址一个特殊的任务状态段 TSS(Task State Segment)。TSS段中包含着当前执行任务的重要信息。
控制寄存器(CR0、CR1、CR2 和 CR3)用于控制和确定处理器的操作模式以及当前执行任务的特性, 见图 4-3 所示。CR0 中含有控制处理器操作模式和状态的系统控制标志;CR1 保留不用;CR2 含有导致页。错误的线性地址。CR3 中含有页目录表物理内存基地址,因此该寄存器也被称为页目录基地址寄存器 PDBR(Page-Directory Base address Register)。
CR0 中协处理器控制位
CR0 的 4 个比特位:扩展类型位 ET、任务切换位 TS、仿真位 EM 和数学存在位 MP 用于控制 80X86 浮点(数学)协处理器的操作。有关协处理器的详细说明请参见第 11 章内容。CR0 的 ET 位(标志)用于选择与协处理器进行通信所使用的协议,即指明系统中使用的是 80387 还是 80287 协处理器。TS、MP 和 EM 位用于确定浮点指令或 WAIT 指令是否应该产生一个设备不存在 DNA(Device Not Available)异常。这个异常可用来仅为使用浮点运算的任务保存和恢复浮点寄存器。对于没有使用浮点运算的任务,这样做可以加快它们之间的切换操作。(P86)
启用保护模式 PE(Protected Enable) 位(位 0)和开启分页 PG(Paging) 位(位 31)分别用于控制分段和分页机制。PE 用于控制分段机制。如果 PE=1,处理器就工作在开启分段机制环境下,即运行在保护模式下。如果 PE=0,则处理器关闭了分段机制,并如同 8086 工作于实地址模式下。PG 用于控制分页机制。如果 PG=1,则开启了分页机制。如果 PG=0,分页机制被禁止,此时线性地址被直接作为物理地址使用。
如果 PE=0、PG=0,处理器工作在实地址模式下;如果 PG=0、PE=1,处理器工作在没有开启分页机制的保护模式下;如果 PG=1、PE=0,此时由于不在保护模式下不能启用分页机制,因此处理器会产生一个一般保护异常,即这种标志组合无效;如果 PG=1、PE=1,则处理器工作在开启了分页机制的保护模式下。
当改变 PE 和 PG 位时,我们必须小心。只有当执行程序起码有部分代码和数据在线性地址空间和物理地址空间中具有相同地址时,我们才能改变 PG 位的设置。此时这部分具有相同地址的代码在分页和未分页世界之间起着桥梁的作用。无论是否开启分页机制,这部分代码都具有相同的地址。另外,在开启分页(PG=1)之前必须先刷新 CPU 中的页高速缓冲(或称为转换查找缓冲区 TLB - Translation Lookaside Buffers)。
在修改该了 PE 位之后程序必须立刻使用一条跳转指令,以刷新处理器执行管道中已经获取的不同模式下的任何指令。在设置 PE 位之前,程序必须初始化几个系统段和控制寄存器。在系统刚上电时,处理器被复位成 PE=0、PG=0(即实模式状态),以允许引导代码在启用分段和分页机制之前能够初始化这些寄存器和数据结构。
CR2 和 CR3
CR2 和 CR3 用于分页机制。CR3 含有存放页目录表页面的物理地址,因此 CR3 也被称为 PDBR。因为页目录表页面是页对齐的,所以该寄存器只有高 20 位是有效的。而低 12 位保留供更高级处理器使用,因此在往 CR3 中加载一个新值时低 12 位必须设置为 0。
使用 MOV 指令加载 CR3 时具有让页高速缓冲无效的副作用。为了减少地址转换所要求的总线周期数量,最近访问的页目录和页表会被存放在处理器的页高速缓冲器件中,该缓冲器件被称为转换查找缓冲区 TLB(Translation Lookaside Buffer)。只有当 TLB 中不包含要求的页表项时才会使用额外的总线周期从内存中读取页表项。
即使 CR0 中的 PG 位处于复位状态(PG=0),我们也能先加载 CR3。以允许对分页机制进行初始化。当切换任务时,CR3 的内容也会随之改变。但是如果新任务的 CR3 值与原任务的一样,处理器就无需刷新页高速缓冲。这样共享页表的任务可以执行得更快。
CR2 用于出现页异常时报告出错信息。在报告页异常时,处理器会把引起异常的线性地址存放在 CR2 中。因此操作系统中的页异常处理程序可以通过检查 CR2 的内容来确定线性地址空间中哪一个页面引发了异常。
内存是指一组有序字节组成的数组,每个字节有唯一的内存地址。内存寻址则是指对存储在内存中的某个指定数据对象的地址进行定位。这里,数据对象是指存储在内存中的一个指定数据类型的数值或字符串。80X86 支持多种数据类型:1 字节、2 字节(1 个字)或 4 字节(双字或长字)的无符号整型数或带符号整型数,以及多字节字符串等。通常字节中某一比特位的定位或寻址可以基于字节来寻址,因此最小数据类型的寻址是对 1 字节数据(数值或字符)的定位。通常内存地址从 0 开始编址,对于 80X86 CPU 来说,其地址总线宽度为 32 位,因此一共有 2^32 个不同物理地址。即内存物理地址空间有 4G,总共可以寻址 4G 字节的物理内存。对于多字节数据类型(例如 2 字节整数数据类型),在内存中这些字节相邻存放。 80X86 首先存放低值字节,随后地址处存放高值字节。因此 80X86 CPU 是一种先存小值(Little Endium)的处理器。
对于 80X86 CPU,一条指令主要由操作码(Opcode)和操作对象即操作数(Oprand)构成。操作数可以位于一个寄存器中,也可以在内存中。若要定位内存中的操作数,就要进行内存寻址。80X86 有许多指令的操作数涉及内存寻址,并且针对所寻址对象数据类型的不同,也有很多不同的寻址方案可供选择。为了进行内存寻址, 80X86 使用了一种称为 段(Segment)的寻址 技术。这种寻址技术把内存空间分成一个或多个称为段的线性区域,从而对内存中一个数据对象的寻址就需要使用一个段的起始地址(即 段地址 )和一个 段内偏移地址 两部分构成。段地址部分使用 16 位的 段选择符 指定,其中 14 位可以选择 2^14 次方即 16384 个段。段内偏移地址部分使用 32 位的值来指定,因北段内地址可以是 0 到 4G。即一个段的最大长度可达 4G。程序中由 16 位的段和 32 位的偏移构成的 48 位地址或长指针称为一个逻辑地址(虚拟地址)。它唯一确定了一个数据对象的段地址和段内偏移地址。而仅由 32 位偏移地址或指针指定的地址是基于当前段的对象地址。
80X86 为段部分提供了 6 个存放段选择符的段寄存器:CS、DS、ES、SS、FS 和 GS。其中 CS 总是用于寻址代码段,而堆栈段则专门使用 SS 段寄存器。在任何指定时刻由 CS 寻址的段称为当前代码段。此时 EIP 寄存器中包含了当前代码段内下一条要执行指令的段内偏移地址。因此要执行指令的地址可表示成 CS:[EIP] 。后面将说明的段间控制转移指令可以被用来为 CS 和 EIP 赋予新值,从而可以把执行位置改变到其他的代码段中,这样就实现了在不同段中程序的控制传递。
由段寄存器 SS 寻址的段称为当前堆栈段。栈顶由 ESP 寄存器内容指定。因此堆栈顶处地址是 SS:[ESP] 。另外 4 个段寄存器是通用段寄存器。当指令中没有指定所操作数据的段时,那么 DS 将是默认的数据段寄存器。为了指定内存操作数的段内偏移地址,80X86 指令规定了计算偏移量的很多方式,称为指令寻址方式。指令的偏移量由三部分相加组成:基地址寄存器、变址寄存器和一个偏移常量。即:
偏移地址 = 基地址 + (变址 * 比例因子) + 偏移量
任何完整的内存管理系统都包含两个关键部分:保护和地址变换。提供保护措施是可以防止一个任务访问另一个任务或操作系统的内存区域。地址变换能够让操作系统在给任务分配内存时具有灵活性,并且因为我们可以让某些物理地址不被任何逻辑地址所映射,所以在地址变换过程中同时也提供了内存保护功能。
正如上面提到的,计算机中的物理内存是字节的线性数组,每个字节具有一个唯一的物理地址;程序中的地址是由两部分构成的逻辑地制。这种逻辑地址并不能直接用于访问物理内存,而需要使用地址变换机制将它变换或映射到物理内存地址上。内存管理机制即用于将这种逻辑地址转换成物理内存地址。
为了减少确定地址变换所需要的信息,变换或映射通常以内存块作为操作单位。分段机制和分页机制是两种广泛使用的地址变换技术。它们的不同之处在于逻辑地址是如何组织成被映射的内存块、变换信息如何指定以及编程人员如何进行操作。分段和分页操作都使用驻留在内存中的表来指定它们各自的变换信息。这些表只能由操作系统访问,以防止应用程序擅自修改。
80X86 在从逻辑地址到物理地址变换过程中使用了分段和分页两种机制,见图 4-4 所示。第一阶段使用分段机制把程序的逻辑地址变换成处理器可寻址内存空间(称为线性地址空间)中的地址。第二阶段使用分页机制把线性地址转换为物理地址。在地址变换过程中,第一阶段的分段变换机制总是使用的,而第二阶段的分页机制则是供选用的。如果没有启用分页机制,那么分段机制产生的线性地址空间就直接映射到处理器的物理地址空间上。物理地址空间定义为处理器在其地址总线上能够产生的地址范围。
80X86 支持两类保护。其一是通过给每个任务不同的虚拟地址(逻辑地址)空间来完全隔离各个任务。这是通过给每个任务逻辑地址到物理地址不同的变换映射来做到。另一个保护机制对任务进行操作,以保护操作系统内存段和处理器特殊系统寄存器不被应用程序访问。
任务之间的保护
保护的一个重要方面是提供应用程序各任务之间的保护能力。80X86 使用的方法是通过把每个任务放置在不同的虚拟地址空间中,并给予每个任务不同的逻辑地址到物理地址的变换映射。每个任务中的地址变换功能被定义成一个任务中的逻辑地址映射到物理内存的一部分区域,而另一个任务中的逻辑地址映射到物理内存中的不同区域中。这样,因为一个任务不可能生成能够映射到其他任务逻辑地址对应使用的物理内存部分,所以所有任务都被隔绝开了。只需给每个任务各自独立的映射表,每个任务就会有不同的地址变换函数。在 80X86 中,每个任务都有自己的段表和页表。当处理器切换去执行一个新任务时,任务切换的关键部分就是切换到新任务的变换表。
通过在所有任务中安排具有相同的虚拟到物理地址映射部分,并且把操作系统存储在这个公共的虚拟地址空间部分,操作系统可以被所有任务共享。这个所有任务都具有的相同虚拟地址空间部分被称为全局地址空间(Global address space)。这也正是现代 Linux 操作系统使用虚拟地址空间的方式。
每个任务唯一的虚拟地址空间部分被称为局部地址空间(Local address space)。局部地址空间含有需要与系统中其他任务区别开的私有的代码和数据。由于每个任务中具有不同的局部地址空间,因此两个不同任务中对相同虚拟地址处的引用将转换到不同的物理地址处。这使得操作系统可以给与每个任务的内存相同的虚拟地址,但仍然能隔绝每个任务。另一方面,所有任务在全局地址空间中对相同虚拟地址的引用将被转换到同一个物理地址处。这给公共代码和数据(例如操作系统)的共享提供了支持。
特权级保护
在一个任务中,定义了 4 个执行特权级(Privilege Levels),用于依据段中含有数据的敏感度以及任务中不同程序部分的受信程度,来限制对任务中各段的访问。最敏感的数据被赋予了最高特权级,它们只能被任务中最受信任的部分访问。不太敏感的数据被赋予较低的特权级,它们可以被任务中较低特权级的代码访问。
特权级用数字 0 到 3 表示,0 具有最高特权级,而 3 则是最低特权级。每个内存段都与一个特权级相关联。这个特权级限制具有足够特权级的程序来访问一个段。我们知道,处理器从 CS 寄存器指定的段中取得和执行指令,当前特权级(Current Privilege Level),即 CPL 就是当前活动代码段的特权级,并且它定义了当前所执行程序的特权级别。CPL 确定了哪些段能够被程序访问。
每当程序企图访问一个段时,当前特权级就会与段的特权级进行比较,以确定是否有访问许可。在给定 CPL 级别上执行的程序允许访问同级别或低级别的数据段。任何对高级别段的引用都是非法的,并且会引发一个异常来通知操作系统。
每个特权级都有自己的程序栈,以避免使用共享栈带来的保护问题。当程序从一个特权级切换到另一个特权级上执行时,堆栈段也随之改换到新级别的堆栈中。
分段机制可用于实现多种系统设计。这些设计范围从使用分段机制的最小功能来保护程序的平坦模型,到使用分段机制创建一个可同时可靠地运行多个程序(或任务)的具有稳固操作环境的多段模型。
多段模型能够利用分段机制全部功能提供由硬件增强的代码、数据结构、程序和任务的保护措施。通常,每个程序(或任务)都使用自己的段描述符表以及自己的段。对程序来说段能够完全是私有的,或者是程序之间共享的。对所有段以及系统上运行程序各自执行环境的访问都由硬件控制。
访问检查不仅能够用来保护对段界限以外地址的引用,而且也能用来在某些段中防止执行不允许的操作。例如,因为代码段被设计成是只读形式的段,因此可以用硬件来防止对代码段执行写操作。段中的访问权限信息也可以用来设置保护环或级别。保护级别可用于保护操作系统程序不受应程序非法访问。
在上一节概述中已经提到,保护模式中 80X86 提供了 4GB 的物理地址空间。这是处理器在其地址总线上可以寻址的地址空间。这个地址空间是平坦的,地址范围从 0 到 0xFFFFFFFF。这个物理地址空间可以映射到读写内存、只读内存以及内存映时 I/O 中。分段机制就是把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存块单元。80386 虚拟地址空间中的虚拟地址(逻辑地址)由一个段部分和一个偏移部分构成。段是虚拟地址到线性地址转换机制的基础。每个段由三个参数定义:
段的基地址、段限长以及段的保护属性存储在一个称为段描述符(Segment Descriptor)的结构项中。在逻辑地址到线性地址的转换映射过程中会使用这个段描述符。段描述符保存在内存中的段描述符表(Descriptor table)中。段描述符表是包含段描述符项的一个简单数组。前面介绍的段选择符即用于通过指定表中一个段描述符的位置来指定相应的段。
即使利用段的最小功能,使用逻辑地址也能访问处理器地址空间中的每个字节。逻辑地址由 16 位的段选择符和 32 位的偏移量组成,见图 4-7 所示。段选择符指定字节所在的段,而偏移量指定该字节在段中相对于段基地址的位置。处理器会把每个逻辑地址转换成线性地址。线性地址是处理器线性地址空间中的 32 位地址。与物理地址空间类似,线性地址空间也是平坦的 4GB 地址空间,地址范围从 0 到 0xFFFFFFFF。线性地址空间中含有为系统定义的所有段和系统表。
为了把逻辑地址转换成一个线性地址,处理器会执行以下操作:
如果没有开启分页,那么处理器直接把线性地址映射到物理地址(即线性地址被送到处理器地址总线上)。如果对线性地址空间进行了分页处理,那么就会使用二级地址转换把线性地址转换成物理地址。页转换将在稍后进行说明。
段描述符表是段描述符的一个数组,见图 4-8 所示。描述符表的长度可变,最多可以包含 8192 个 8 字节描述符。有两种描述符表:全局描述符表 GDT(Global descriptor table);局部描述符表 LDT(Localdescriptor table)。
描述符表存储在由操作系统维护着的特殊数据结构中,并且由处理器的内存管理硬件来引用。这些特殊结构应该保存在仅由操作系统软件访问的受保护的内存区域中,以防止应用程序修改其中的地址转换信息。虚拟地址空间被分割成大小相等的两半。一半由 GDT 来映射变换到线性地址,另一半则由 LDT 来映射。整个虚拟地址空间共含有 214 个段:一半空间(即 213 个段)是由 GDT 映射的全局虚拟地址空间,另一半是由 LDT 映射的局部虚拟地址空间。通过指定一个描述符表(GDT 或 LDT)以及表中描述符号,我们就可以定位一个描述符。
当发生任务切换时,LDT 会更换成新任务的 LDT,但是 GDT 并不会改变。因此,GDT 所映射的一半虚拟地址空间是系统中所有任务共有的,但是 LDT 所映射的另一半则在任务切换时被改变。系统中所有任务共享的段由 GDT 来映射。这样的段通常包括含有操作系统的段以及所有任务各自的包含 LDT 的特殊段。LDT 段可以想象成属于操作系统的数据。
图 4-9 示出一个任务中的段如何能在 GDT 和 LDT 之间分开。图中共有 6 个段,分别用于两个应用程序(A 和 B)以及操作系统。系统中每个应用程序对应一个任务,并且每个任务有自己的 LDT。应用程序 A 在任务 A 中运行,拥有 LDTA,用来映射段 CodeA 和 DataA。类似地,应用程序 B 在任务 B 中运行,使用 LDTB 来映射 CodeB 和 DataB 段。包含操作系统内核的两个段 Codeos 和 Dataos 使用 GDT 来映射,这样它们可以被两个任务所共享。两个 LDT 段:LDTA 和 LDTB 也使用 GDT 来映射。
当任务 A 在运行时,可访问的段包括 LDTA 映射的 CodeA 和 DataA 段,加上 GDT 映射的操作系统的段 Codeos 和 Dataos。当任务 B 在运行时,可访问的段包括 LDTB 映射的 CodeB 和 DataB 段,加上 GDT 映射的段。
这个例子通过让每个任务使用不同的 LDT,演示了虚拟地址空间如何能够被组织成隔离每个任务。当任务 A 在运行时,任务 B 的段不是虚拟地址空间的部分,因此任务 A 没有办法访问任务 B 的内存。同样地,当任务 B 运行时,任务 A 的段也不能被寻址。这种使用 LDT 来隔离每个应用程序任务的方法,正是关键保护需求之一。
每个系统必须定义一个 GDT,并可用于系统中所有程序或任务。另外,可选定义一个或多个 LDT。例如,可以为每个运行任务定义一个 LDT,或者某些或所有任务共享一个 LDT。
GDT 本身并不是一个段,而是线性地址空间中的一个数据结构。GDT 的基线性地址和长度值必须加载进 GDTR 寄存器中。GDT 的基地址应该进行内存 8 字节对齐,以得到最佳处理器性能。GDT 的限长以字节为单位。与段类似,限长值加上基地址可得到最后表中最后一个字节的有效地址。限长为 0 表示有 1 个有效字节。因为段描述符总是 8 字节长,因此 GDT 的限长值应该设置成总是 8 的倍数减 1(即 8N-1)。
处理器并不使用 GDT 中的第 1 个描述符。把这个"空描述符"的段选择符加载进一个数据段寄存器(DS、ES、FS 或 GS)并不会产生一个异常,但是若使用这些加载了空描述符的段选择符访问内存时就肯定会产生一般保护性异常。通过使用这个段选择符初始化段寄存器,那么意外引用未使用的段寄存器肯定会产生一个异常。
LDT 表存放在 LDT 类型的系统段中。此时 GDT 必须含有 LDT 的段描述符。如果系统支持多 LDT 的话,那么每个 LDT 都必须在 GDT 中有一个段描述符和段选择符。一个 LDT 的段描述符可以存放在 GDT 表的任何地方。
访问 LDT 需使用其段选择符。为了在访问 LDT 时减少地址转换次数,LDT 的段选择符、基地址、段限长以及访问权限需要存放在 LDTR 寄存器中。
当保存 GDTR 寄存器内容时(使用 SGDT 指令),一个 48 位的"伪描述符"被存储在内存中。为了在用户模式(特权级 3)避免对齐检查出错,伪描述符应该存放在一个奇字地址处(即 地址 MOD 4 = 2)。这会让处理器先存放一个对齐的字,随后是一个对齐的双字(4 字节对齐处)。用户模式程序通常不会保存伪描述符,但是可以通过使用这种对齐方式来避免产生一个对齐检查出错的可能性。当使用 SIDT 指令保存 IDTR 寄存器内容时 也需要使用同样的对齐方式。然而,当保存 LDTR 或任务寄存器(分别使用 SLTR 或 STR 指令)时,伪描述符应该存放在双字对齐的地址处(即 地址 MOD 4 = 0)。
段选择符(或称段选择子)是段的一个 16 位标识符,见图 4-10 所示。段选择符并不直接指向段,而是指向段描述符表中定义段的段描述符。段选择符 3 个字段内容:
请求特权级字段 RPL 提供了段保护信息,将在后面作详细说明。表索引字段 TI 用来指出包含指定段描述符的段描述符表 GDT 或 LDT。TI=0 表示描述符在 GDT 中;TI=1 表示描述符在 LDT 中。索引字段给出了描述符在 GDT 或 LDT 表中的索引项号。可见,选择符通过定位段表中的一个描述符来指定一个段,并且描述符中包含有访问一个段的所有信息,例如段的基地址、段长度和段属性。
例如:
实际上,图 4-11 中的前 4 个选择符:(a)、(b)、(c)和(d)分别就是 Linux 0.1x 内核的内核代码段、内核数据段、任务代码段和任务数据段的选择符。
另外,处理器不使用 GDT 表中的第 1 项。指向 GDT 该项的选择符(即索引值为 0,TI 标志为 0 的选择符)用作为"空选择符",见图 4-11(f)所示。当把空选择符加载到一个段寄存器(除了 CS 和 SS 以外)中时,处理器并不产生异常。但是当使用含有空选择符的段寄存器用于访问内存时就会产生异常。当把空选择符加载到 CS 或 SS 段寄存器中时将会导致一个异常。
对应用程序来说段选择符是作为指针变量的一部分而可见,但选择符的值通常是由链接编辑器或链接加载程序进行设置或修改,而非应用程序。
为减少地址转换时间和编程复杂性,处理器提供可存放最多 6 个段选择符的寄存器(见图 4-12 所示),即段寄存器。每个段寄存器支持特定类型的内存引用(代码、数据或堆栈)。原则上执行每个程序都起码需要把有效的段选择符加载到代码段(CS)、数据段(DS)和堆栈段(SS)寄存器中。处理器还另外提供三个辅助的数据段寄存器(ES、FS 和 GS),可被用于让当前执行程片(或任务)能够访问其他几个数据段。
对于访问某个段的程序,必须已经把段选择符加载到一个段寄存器中。因此,尽管一个系统可以定义很多的段,但同时只有 6 个段可供立即访问。若要访问其他段就需要加载这些段的选择符。
另外,为了避免每次访问内存时都去引用描述符表,去读和解码一个段描述符,每个段寄存器都有一个"可见"部分和一个"隐藏"部分(隐藏部分也被称为"描述符缓冲"或"影子寄存器")。当一个段选择符被加载到一个段寄存器可见部分中时,处理器也同时把段选择符指向的段描述符中的段地址、段限长以及访问控制信息加载到段寄存器的隐藏部分中。缓冲在段寄存器(可见和隐藏部分)中的信息使得处理器可以在进行地址转换时不再需要花费时间从段描述符中读取基地址和限长值。
由于影子寄存器含有描述符信息的一个拷贝,因此操作系统必须确保对描述符表的改动应反映在影子寄存器中。否则描述符表中一个段的基地址或限长被修改过,但改动却没有反映到影子寄存器中。处理这种问题最简捷的方法是在对描述符表中描述符作过任何改动之后就立刻重新加载 6 个段寄存器。这将把描述符表中的相应段信息重新加载到影子寄存器中。
为加载段寄存器,提供了两类加载指令:
MOV 指令当然也可以用于把段寄存器可见部分内容存储到一个通用寄存器中。
前面我们已经说明了使用段选择符来定位描述符表中的一个描述符。段描述符是 GDT 和 LDT 表中的一个数据结构项,用于向处理器提供有关一个段的位置和大小信息以及访问控制的状态信息。每个段描述符长度是 8 字节,含有三个主要字段:段基地址、段限长和段属性。段描述符通常由编译器、链接器、加载器或者操作系统来创建,但绝不是应用程序。图 4-13 示出了所有类型段描述符的一般格式。
一个段描述符中各字段和标志的含义如下:
段限长字段 LIMIT(Segment limit field)
段限长 Limit 字段用于指定段的长度。处理器会把段描述符中两个段限长字段组合成一个 20 位的值,并根据颖粒度标志 G 来指定段限长 Limit 值的实际含义。如果 G=0,则段长度 Limit 范围可从 1 字节到 1MB 字节,单位是字节。如果 G=1,则段长度 Limit 范围可从 4KB 到 4GB,单位是4KB。
根据段类型中的段扩展方向标志 E,处理器以两种不同方式使用段限长 Limit。对于向上扩展的段。(简称上扩段),逻辑地址中的偏移值范围可以从 0 到段限长值 Limit。大于段限长 Limit 的偏移。值将产生一般保护性异常。对于向下扩展的段(简称下扩段),段限长 Limit 的含义相反。根据默认栈指针大小标志 B 的设置,偏移值范围可从段限长 Limit 到 0xFFFFFFFF 或 0xFFFF。而小于段限长 Limit 的偏移值将产生一般保护性非常。对于下扩段,减小段限长字段中的值会在该段地址空间底部分配新的内存,而不是在顶部分配。80X86 的栈总是向下扩展的,因此这种实现方式很适合扩展堆栈。
基地址字段 BASE(Base address field)
该字段定义在 4GB 线性地址空间中一个段字节 0 所处的位置。处理器会把 3 个分立的基地址字段组合形成一个 32 位的值。段基地址应该对齐 16 字节边界。虽然这不是要求的,但通过把程序的代码和数据段对齐在 16 字节边界上,可以让程序具有最佳性能。
段类型字段 TYPE(Type field)
类型字段指定段或门(Gate)的类型、说明段的访问种类以及段的扩展方向。该字段的解释依赖于描述符类型标志 S 指明是一个应用(代码或数据)描述符还是一个系统描述符。TYPE 字段的
编码对代码、数据或系统描述符都不同,见图 4-14 所示。
描述符类型标志 S(Descriptor type flag)
描述符类型标志 S 指明一个段描述符是系统段描述符(当S=0)还是代码或数据段描述符(当 S=1)。
描述符特权级字段 DPL(Descriptor privilege level)
DPL 字段指明描述符的特权级。特权级范围从 0 到 3。0 级特权级最高,3 级最低。DPL 用于控制对段的访问。
段存在标志 P(Segment present)
段存在标志 P 指出一个段是在内存中(P=1)还是不在内存中(P=0)。当一个段描述符的 P 标志为 0 时,那么把指向这个段描述符的选择符加载进段寄存器将导致产生一个段不存在异常。内存管理软件可以使用这个标志来控制在某一给定时间实际需要把那个段加载进内存中。这个功能为虚拟存储提供了除分页机制以外的控制。图 4-15 给出了当 P=0 时的段描述符格式。当 P 标志为 0时,操作系统可以自由使用格式中标注为可用(Avaliable)的字段位置来保存自己的数据,例如有关不存在段实际在什么地方的信息。
D/B(默认操作大小/默认栈指针大小和/或上界限)标志(Default operation size/default stack pointer size and/or upper bound)
根据段描述符描述的是一个可执行代码段、下扩数据段还是一个堆栈段,这个标志具有不同的功能。(对于 32 位代码和数据段,这个标志应该总是设置为 1;对于 16 位代码和数据段,这个标志被设置为 0。)
可执行代码段。此时这个标志称为 D 标志并用于指出该段中的指令引用有效地址和操作数的默认长度。如果该标志置位,则默认值是 32 位地址和 32 位或 8 位的操作数;如果该标志为 0,则默认值是 16 位地址和 16 位或 8 位的操作数。指令前缀 Cx66 可以用来选择非默认值的操作数大小;前缀 0x67 可用来选择非默认值的地址大小。
栈段(由 SS 寄存器指向的数据段)。此时该标志称为 B(Big)标志,用于指明隐含堆栈操作(例如 PUSH、POP 或 CALL)时的栈指针大小。如果该标志置位,则使用 32 位栈指针并存放在 ESP 寄存器中;如果该标志为 0,则使用 16 位栈指针并存放在 SP 寄存器中。如果堆段被设置成一个下扩数据段,这个 B 标志也同时指定了堆栈段的上界限。
下扩数据段。此时该标志称为 B 标志,用于指明堆栈段的上界限。如果设置了该标志,则堆段的上界限是 0xFFFFFFFF(4GB);如果没有设置该标志,则堆栈段的上界限是 0xFFFF(64KB)。
颗粒度标志 G(Granularity)
该字段用于确定段限长字段 Limit 值的单位。如果颗粒度标志为 0,则段限长值的单位是字节;如果设置了颗粒度标志,则段限长值使用 4KB 单位。(这个标志不影响段基地址的颗粒度,基地址的颗粒度总是字节单位。)若设置了 G 标志,那么当使用段限长来检查偏移值时,并不会去检查偏移值的 12 位最低有效位。例如,当 G=1 时,段限长为 0 表明有效偏移值为 0 到 4095。
可用和保留比特位(Available and reserved bits)
段描述符第 2 个双字的位 20 可供系统软件使用;位 21 是保留位并应该总是设置为 0。
当段描述符中 S(描述符类型)标志被置位,则该描述符用于代码或数据段。此时类型字段中最高比特位(第 2 个双字的位 11)用于确定是数据段的描述符(复位)还是代码段的描述符(置位)。
对于数据段的描述符,类型字段的低 3 位(位 8、9、10)被分别用于表示已访问 A(Accessed)、可写 W(Write-enable)和扩展方向 E(Expansion-direction),参见表 4-3 中有关代码和数据段类型字段比特位的说明。根据可写比特位 W 的设置,一个数据段可以是只读的,也可以是可读可写的。
堆栈段必须是可读/写的数据段。若使用不可写数据段的选择符加载到 SS 寄存器中,将导致一个一般保护异常。如果堆栈段的长度需要动态地改变,那么堆栈段可以是一个向下扩展的数据段(扩展方向标志置位)。这里,动态改变段限长将导致栈空间被添加到栈底部。
已访问比特位指明自从上次操作系统复位该位之后一个段是否被访问过。每当处理器把一个段的段选择符加载进段寄存器,它就会设置该位。该位需要明确地清除,否则一直保持置位状态。该位可用于虚拟内存管理和调试。
对于代码段,类型字段的低 3 位被解释成已访问 A(Accessed)、可读 R(Read-enable)和一致的 C(Conforming)。根据可读 R 标志的设置,代码段可以是只能执行、可执行/可读。当常数或其他静态数据以及指令码被放在了一个 ROM 中时就可以使用一个可执行/可读代码段。这里,通过使用带 CS 前缀的指令或者把代码段选择符加载进一个数据段寄存器(DS、ES、FS 或 GS),我们可以读取代码段中的数据。在保护模式下,代码段是不可写的。
代码段可以是一致性的或非一致性的。向更高特权级一致性代码段的执行控制转移,允许程序以当前特权级继续执行。向一个不同特权级的非一致性代码段的转移将导致一般保护异常,除非使用了一个调用门或任务门(有关一致性和非一致性代码段的详细信息请参见"直接调用或跳转到代码段")。不访问保护设施的系统工具以及某些异常类型(例如除出错、溢出)的处理过程可以存放在一致性代码段中。需要防止低特权级程序或过程访问的工具应该存放在非一致性代码段中。
所有数据段都是非一致性的,即意味着它们不能被低特权级的程序或过程访问。然而,与代码段不同,数据段可以被更高特权级的程序或过程访问,而无须使用特殊的访问门。
如果 GDT 或 LDT 中一个段描述符被存放在 ROM 中,那么若软件或处理器试图更新(写)在 ROM中的段描述符时,处理器就会进入一个无限循环。为了防止这个问题,需要存放在 ROM 中的所有描述符的已访问位应该预先设置成置位状态。同时,删除操作系统中任何试图修改 ROM 中段描述符的代码。
当段描述符中的 S 标志(描述符类型)是复位状态(0)的话,那么该描述符是一个 系统描述符。处理器能够识别以下一些类型的系统段描述符:
这些描述符类型可分为两大类:系统段描述符 和 门描述符 。系统段描述符 指向系统段(如 LDT 和 TSS 段),门描述符 就是一个"门",对于调用、中断或陷阱门,其中含有代码段的选择符和段中程序入口点的指针;对于任务门,其中含有 TSS 的段选择符。表 4-4 给出了系统段描述符和门描述符类型字段的编码。
有关 TSS 状态段和任务门的使用方法将在任务管理一节中进行说明,调用门的使用方法将放在保护一节中说明,中断和陷阱门的使用方法将在中断和异常处理一节中给予说明。
分页机制是 80X86 内存管理机制的第二部分。它在分段机制的基础上完成虚拟(逻辑)地址到物理地址转换的过程。分段机制把逻辑地址转换成线性地址,而分页则把线性地址转换成物理地址。分页可以用于任何一种分段模型。处理器分页机制会把线性地址空间(段已映射到其中)划分成页面,然后这些线性地址空间页面被映射到物理地址空间的页面上。分页机制几种页面级保护措施,可和分段机制保护机制合用或替代分段机制的保护措施。例如,在基于页面的基础上可以加强读/写保护。另外,在页面单元上,分页机制还提供了用户 - 超级用户两级保护。
我们通过设置控制寄存器 CR0 的 PG 位可以启用分页机制。如果 PG=1,则启用分页操作,处理器会使用本节描述的机制将线性地址转换成物理地址。如果 PG=0,则禁用分页机制,此时分段机制产生的线性地址被直接用作物理地址。
前面介绍的分段机制在各种可变长度的内存区域上操作。与分段机制不同,分页机制对固定大小的内存块(称为页面)进行操作。分页机制把线性和物理地址空间都划分成页面。线性地址空间中的任何页面可以被映射到物理地址空间的任何页面上。图 4-16 示出了分页机制是如何把线性和物理地址空间都划分成各个页面,并在这两个空间之间提供了任意映射。图中的箭头把线性地址空间中的页面与物理地址空间中的页面对应了起来。
80X86 使用 4K(212)字节固定大小的页面。每个页面均是 4KB,并且对齐于 4K 地址边界处。这表示分页机制把 232 字节(4GB)的线性地址空间划分成 220(1M = 1048576)个页面。分页机制通过把线性地址空间中的页面重新定位到物理地址空间中进行操作。由于 4K 大小的页面作为一个单元进行映射,并且对齐于 4K 边界,因此线性地址的低 12 比特位可作为页内偏移量直接作为物理地址的低 12 位。分页机制执行的重定位功能可以看作是把线性地址的高 20 位转换到对应物理地址的高 20 位。
另外,线性到物理地址的转换功能被扩展成允许一个线性地址被标注为无效的,而非让其产生一个物理地址。在两种情况下一个页面可以被示注为无效的:1)操作系统不支持的线性地址;2)对应在虚拟内存系统中的页面在磁盘上而非在物理内存中。在第一种情况下,产生无效地址的程序必须被终止。在第二种情况下,该无效地址实际上是请求操作系统虚拟内存管理器把对应页面从磁盘上加载到物理内存中,以供程序访问。因为无效页面通常与虚拟存储系统相关,因此它们被称为不存在的页面,并且由页表中称为存在(present)的属性来确定。
在保护模式中,80X86 允许线性地址空间直接映射到大容量的物理内存(例如 4GB 的 RAM)上,或者(使用分页)间接地映射到较小容量的物理内存和磁盘存储空间中。这后一种映射线性地址空间的方法被称为虚拟存储或者需求页(Demand-paged)虚拟存储。
当使用分页时,处理器会把线性地址空间划分成固定大小的页面(长度 4KB),这些页面可以映射到物理内存中和/或磁盘存储空间中。当一个程序(或任务)引用内存中的逻辑地址时,处理器会把该逻辑地址转换成一个线性地址,然后使用分页机制把该线性地址转换成对应的物理地址。
如果包含线性地址的页面当前不在物理内存中,处理器就会产生一个页错误异常。页错误异常的处理程序通常就会让操作系统从磁盘中把相应页面加载到物理内存中(操作过程中可能还会把物理内存中不同的页面写到磁盘上)。当页面加载到物理内存中之后,从异常处理过程的返回操作会使得导致异常的指令被重新执行。处理器用于把线性地址转换成物理地址和用于产生页错误异常(若必要的话)的信息包含在存储于内存中的页目录和页表中。
分页与分段最大的不同之处在于分页使用了固定长度的页面。段的长度通常与存放在其中的代码或数据结构具有相同的长度。与段不同,页面有固定的长度。如果仅使用分段地址转换,那么储存在物理内存中的一个数据结构将包含其所有的部分。但如果使用了分页,那么一个数据结构就可以一部分存储于物理内存中,而另一部分保存在磁盘中。
正如前述,为了减少地址转换所要求的总线周期数量,最近访问的页目录和页表会被存放在处理器的缓冲器件中,该缓冲器件被称为转换查找缓中区 TLB(Translation Lookaside Buffer)。TLB 可以满足大多数读页目录和页表的请求而无需使用总线周期。只有当 TLB 中不包含要求的页表项时才会使用额外的总线周期从内存中读取页表项,这通常在一个页表项很长时间没有访问过时才会出现这种情况。
分页转换功能由驻留在内存中的表来描述,该表称为页表(page table),存放在物理地址空间中。页表可以看作是简单的 220 物理地址数组。线性到物理地址的映射功能可以简单地看作是进行数组查找。线性地址的高 20 位构成这个数组的索引值,用于选择对应页面的物理(基)地址。线性地址的低 12 位给出了页面中的偏移量,加上页面的基地址最终形成对应的物理地址。由于页面基地址对齐在 4K 边界上,因此页面基地址的低 12 位肯定是 0。这意味着高 20 位的页面基地址和 12 位偏移量连接组合在一起就能得到对应的物理地址。
页表中每个页表项大小为 32 位。由于只需要其中的 20 位来存放页面的物理基地址,因此剩下的 12 位可用于存放诸如页面是否存在等的属性信息。如果线性地址索引的页表项被标注为存在的,则表示该项即有效,我们可以从中取得页面的物理地址。如果项中表明不存在,那么当访问对应物理页面时就会产生一个异常。
页表含有 220(1M)个表项,而每项占用 4 字节。如果作为一个表来存放的话,它们最多将占用 4MB 的内存。因此为了减少内存占用量,80X86 使用了两级表。由此,高 20 位线性地址到物理地址的转换也被分成两步来进行,每步使用(转换)其中 10 个比特。
第一级表称为页目录(page directory)。它被存放在 1 页 4K 页面中,具有 210(1K)个 4 字节长度的表项。这些表项指向对应的二级表。线性地址的最高 10 位(位 31-22)用作一级表(页目录)中的索引值来选择 210 个二级表之一。
第二级表称为页表(page table),它的长度也是 1 个页面,最多含有 1K 个 4 字节的表项。每个 4 字节表项含有相关页面的 20 位物理基地址。二级页表使用线性地址中间 10 位(位 21-12)作为表项索引值,以获取含有页面 20 位物理基地址的表项。该 20 位页面物理基地址和线性地址中的低 12 位(页内偏移)组合在一起就得到了分页转换过程的输出值,即对应的最终物理地址。
图 4-17 示出了二级表的查找过程。其中 CR3 寄存器指定页目录表的基地址。线性地址的高 10 位用于索引这个页目录表,以获得指向相关第二级页表的指针。线性地址中间 10 位用于索引二级页表,以获得物理地址的高 20 位。线性地址的低 12 位直接作为物理地址低 12 位,从而组成一个完整的 32 位物理地址。
通过使用二级表结构,我们还没有解决需要使用 4MB 内存来存放页表的问题。实际上,我们把问题搞得有些复杂了。因为我们需要另增一个页面来存放目录表。然而,二级表结构允许页表被分散在内存各个页面中,而不需要保存在连续的 4MB 内存块中。另外,并不需要为不存在的或线性地址空间未使用部分分配二级页表。虽然目录表页面必须总是存在于物理内存中,但是二级页表可以在需要时再分配。这使得页表结构的大小对应于实际使用的线性地址空间大小。
页目录表中每个表项也有一个存在(present)属性,类似于页表中的表项。页目录表项中的存在属性指明对应的二级页表是否存在。如果目录表项指明对应的二级页表存在,那么通过访问二级表,表查找过程第 2 步将同如上描述继续下去。如果存在位表明对应的二级表不存在,那么处理器就会产生一个异常来通知操作系统。页目录表项中的存在属性使得操作系统可以根据实际使用的线性地址范围来分配二级页表页面。
目录表项中的存在位还可以用于在虚拟内存中存放二级页表。这意味着在任何时候只有部分二级页表需要存放在物理内存中,而其余的可保存在磁盘上。处于物理内存中页表对应的页目录项将被标注为存在,以表明可用它们进行分页转换。处于磁盘上的页表对应的页目录项将被标注为不存在。由于二级页表不存在而引发的异常会通知操作系统把缺少的页表从磁盘上加载进物理内存。把页表存储在虚拟内存中减少了保存分页转换表所需要的物理内存量。
页目录和页表的表项格式见图 4-18 所示。其中位 31–12 含有物理地址的高 20 位,用于定位物理地址空间中一个页面(也称为页帧)的物理基地址。表项的低 12 位含有页属性信息。我们已经讨论过存在属性,这里简要说明其余属性的功能和用途。
页目录和页表表项中的存在标志 P 为使用分页技术的虚拟存储提供了必要的支持。若线性地址空间中的页面存在于物理内存中,则对应表项中的标志 P=1,并且该表项中含有相应物理地址。页面不在物理内存中的表项其标志 P = 0。如果程序访问物理内存中不存在的页面,处理器就会产生一个缺页异常。此时操作系统就可以利用这个异常处理过程把缺少的页面从磁盘上调入物理内存中,并把相应物理地址存放在表项中。最后在返回程序重新执行引起异常的指令之前设置标志 P=1。
已访问标志 A 和已修改标志 D 可以用于有效地实现虚拟存储技术。通过周期性地检查和复位所有 A 标志,操作系统能够确定哪些页面最近没有访问过。这些页面可以成为移出到磁盘上的候选者。假设当一页面从磁盘上读入内存时,其脏标志 D=0,那么当页面再次被移出到磁盘上时,若 D 标志还是为 0,则该页面就无需被写入磁盘中。若此时 D=1,则说明页面内容已被修改过,于是就必须将该页面写到磁盘上。
对于将程序控制权从一个代码段转移到另一个代码段,目标代码段的段选择符必须加载代码段寄存器(CS)中。作为这个加载过程的一部分,处理器会检测目标代码段的段描述符并执行各种限长、类型和特权级检查。如果这些检查都通过了,则目标代码段选择符就会加载进 CS 寄存器,于是程序的控制权就被转移到 新代码段 中,程序将从 EIP 寄存器指向的指令处开始执行。
程序的控制转移使用指令 JMP、RET、INT 和 IRET 以及异常和中断机制来实现。异常和中断是一些特殊实现,将在后面描述,本节主要说明 JMP、CALL 和 RET 指令的实现方法。JMP 或 CALL 指令可以利用一下四种方法之一来引用另外一个代码段:
下面描述前两种引用类型,后两种将放在有关任务管理一节中进行说明。
JMP、CALL 和 RET 指令的近转移形式只是在当前代码段中执行程序控制转移,因此不会执行特权级检查。JMP、CALL 或 RET 指令的远转移形式会把控制转移到另外一个代码段中,因此处理器一定会执行特权级检查。
当不通过调用门把程序控制权转移到另一个代码段时,处理器会验证 4 种特权级和类型信息,见图 4-21所示:
处理器检查 CPL、RPL 和 DPL 的规则依赖于一致标志 C 的设置状态。当访问非一致代码段时(C=0),调用者(程序)的 CPL 必须等于目的代码段的 DPL,否则将会产生一般保护异常。指向非一致代码段的段选择符的 RPL 对检查所起的作用有限。RPL 在数值上必须小于或等于调用者的 CPL 才能使得控制转移成功完成。当非一致代码段的段选择符被加载进 CS 寄存器中时,特权级字段不会改变,即它仍然是调用者的 CPL。即使段选择符的 RPL 与 CPL 不同,这也是正确的。
当访问一致代码段时(C=1),调用者的 CPL 可以在数值上大于或等于目的代码段的 DPL。仅当 CPL < DPL 时,处理器才会产生一般保护异常。对于访问一致代码段,处理器忽略对 RPL 的检查。对于一致代码段,DPL 表示调用者对代码段进行成功调用可以处于的最低数值特权级。
当程序控制被转移到一个一致代码段中,CPL 并不改变,即使目的代码段的 DPL 在数值上小于 CPL。这是 CPL 与可能与当前代码段 DPL 不相同的唯一一种情况。同样,由于 CPL 没有改变,因此堆栈也不会切换。
大多数代码段都是非一致代码段。对于这些段,程序的控制权只能转移到具有相同特权级的代码段中,除非转移是通过一个调用门进行,见下面说明。
为了对具有不同特权级的代码段提供受控的访问,处理器提供了称为门描述符的特殊描述符集。共有 4 种门描述符:
任务门用于任务切换,将在后面任务管理一节说明。陷阱门 和 中断门 是 调用门 的 特殊类 ,专门用于 调用异常 和 中断的处理程序 ,这将在下一节进行说明。本节仅说明调用门的使用方法。
调用门用于在不同特权级之间实现受控的程序控制转移。它们通常仅用于使用特权级保护机制的操作系统中。图 4-22 给出了调用门描述符的格式。调用门描述符可以存放在 GDT 或 LDT 中,但是不能放在中断描述符表 IDT 中。一个调用门主要具有一下几个功能:
调用门中的段选择符字段指定要访问的代码段。偏移值字段指定段中入口点。这个入口点通常是指定过程的第一条指令。DPL 字段指定调用门的特权级,从而指定通过调用门访问特定过程所要求的特权级。标志 P 指明调用门描述符是否有效。参数个数字段(Param Count)指明在发生堆栈切换时从调用者堆栈复制到新堆栈中的参数个数。Linux 内核中并没有用到调用门。这里对调用门进行说明是为下一节介绍利用中断和异常门进行处理作准备。
为了访问调用门,我们需要为 CALL 或 JMP 指令的操作数提供一个远指针。该指针中的段选择符用于指定调用门,而指针的偏移值虽然需要但 CPU 并不会用它。该偏移值可以设置为任意值。见图 4-23 所示。
当处理器访问调用门时,它会使用调用门中的段选择符来定位目的代码段的段描述符。然后 CPU 会把代码段描述符的基地址与调用门中的偏移值进行组合,形成代码段中指定程序入口点的线性地址。
通过调用门进行程序控制转移时,CPU 会对 4 种不同的特权级进行检查,以确定控制转移的有效性,见图 4-24 所示。
另外,目的代码段描述符中的一致性标志 C 也将受到检查。
使用 CALL 指令和 JMP 指令分别具有不同的特权级检测规则,见表 4-5 所示。调用门描述符的 DPL 字段指明了调用程序能够访问调用门的数值最大的特权级(最小特权级),即为了访问调用门,调用者程序的特权级 CPL 必须小于或等于调用门的 DPL。调用门段选择符的 RPL 也需同调用这的 CPL 遵守同样的规则,即 RPL 也必须小于或等于调用门的 DPL。
如果调用这与调用门之间的特权级检查成功通过,CPU 就会接着把调用者的 CPL 与代码段描述符的 DPL 进行比较检查。在这方面,CALL 指令和 JMP 指令的检查规则就不同了。只有 CALL 指令可以通过调用门把程序控制转移到特权级更高的非一致性代码段中,即可以转移到 DPL 小于 CPL 的非一致性代码段中去执行。而 JMP 指令只能通过调用门把控制转移到 DPL 等于 CPL 的非一致性代码段中。但 CALL 指令和 JMP 指令都可以把控制转移到更高特权级的一致性代码段中,即转移到 DPL 小于或等于 CPL 的一致性代码段中。
如果一个调用把控制转移到了更高特权级的非一致性代码段中,那么 CPL 就会被设置为目的代码段的 DPL 值,并且会引起堆栈切换。但是如果一个调用或跳转把控制转移到更高级别的一致性代码段上,那么 CPL 并不会改变,并且也不会引起堆栈切换。
调用门可以让一个代码段中的过程被不同特权级的程序访问。例如,位于一个代码段中的操作系统代码可能含有操作系统自身和应用软件都允许访问的代码(比如处理字符 I/O 的代码)。因此可以为这些过程设置一个所有特权级代码都能访问的调用门。另外可以专门为仅用于操作系统的代码设置一些更高特权级的调用门。
每当 调用门 用于把程序控制转移到一个 更高级别 的 非一致性 代码段时,CPU 会自动切换到目的代码段特权级的堆栈去。执行栈切换操作的目的是为了防止高特权级程序由于栈空间不足而引起崩激,同时也为了防止低特权级程序通过共享的堆栈有意或无意地干扰高特权级的程序。
每个任务必须定义最多 4 个栈。一个用于运行在特权级 3 的应用程序代码,其他分别用于用到的特权级 2、1 和 0 。如果一个系统中只使用了 3 和 0 两个特权级,那么每个任务就只需设置两个栈。每个栈都位于不同的段中,并且使用段选择符和段中偏移值指定。
当特权级 3 的程序在执行时,特权级 3 的堆栈的段选择符和栈指针会被分别存放在 SS 和 ESP 中,并且在发生堆栈切换时被保存在被调用过程的堆栈上。
特权级 0、1 和 2 的 堆栈的初始指针值 都存放在当前运行任务的 TSS 段中,TSS 段中这些指针都是只读值。在任务运行时 CPU 并不会修改它们。当调用更高特权级程序时,CPU 才用它们来建立新堆栈。当从调用过程返回时,相应栈就不存在了。下一次再调用该过程时,就又会再次使用 TSS 中的初始指针值建立一个新栈。
操作系统需要负责为所有用到的特权级 建立堆栈 和 堆栈段描述符 ,并且在任务的 TSS 中设置初始指针值。每个栈必须可读可写,并且具有足够的空间来存放以下一些信息:
由于一个过程可调用其它过程,因此每个栈必须有足够大的空间来容纳多帧(多套)上述信息。
当通过调用门执行一个过程调用而造成特权级改变时,CPU就会执行以下步骤切换堆栈并开始在新的特权级上执行被调用过程(见图 4-25 所示):
指令 RET 用于执行近返回(near return)、同特权级远返回(far return)和不同特权级的远返回。该指令用于从使用 CALL 指令调用的过程中返回。近返回仅在当前代码段中转移程序控制权,因此 CPU 仅进行界限检查。对于相同特权级的远返回,CPU 同时从堆栈中弹出返回代码段的选择符和返回指令指针。由于通常情况下这两个指针是 CALL 指令压入栈中的,因此它们因该是有效的。但是 CPU 还是会执行特权级检查以应付当前过程可能修改指针值或者堆栈出现问题时的情况。
会发生特权级改变的远返回仅允许返回到低特权级程序中,即返回到的代码段 DPL 在数值上要大于 CPL。CPU 会使用 CS 寄存器中选择符的 RPL 字段来确定是否要求返回到低特权级。如果 RPL 的数值要比 CPL 大,就会执行特权级之间的返回操作。当执行远返回到一个调用过程时,CPU 会执行以下步骤:
中断(Interrupt)和异常(Exception)是指明系统、处理器或当前执行程序(或任务)的某处出现一个事件,该事件需要处理器进行处理。通常,这种事件会导致执行控制被强迫从当前运行程序转移到被称为中断处理程序(interrupt handler)或异常处理程序(exception handler)的特殊软件函数或任务中。处理器响应中断或异常所采取的行动被称为中断/异常服务(处理)。
通常,中断发生在程序执行的随机时刻,以响应硬件发出的信号。系统硬件使用中断来处理外部事件,例如要求为外部设备提供服务。当然,软件也能通过执行 INT n 指令产生中断。
异常发生在处理器执行一条指令时,检测到一个出错条件时发生,例如被 0 除出错条件。处理器可以检测到各种出错条件,包括违反保护机制、页错误以及机器内部错误。
对应用程序和操作系统来说,80X86 的中断和异常处理机制可以透明地处理发生的中断和异常事件。当收到一个中断或检测到一个异常时,处理器会自动地把当前正在执行的程序或任务挂起,并开始运行中断或异常处理程序。当处理程序执行完毕,处理器就会恢复并继续执行被中断的程序或任务。被中断程序的恢复过程并不会失去程序执行的连贯性,除非从异常中恢复是不可能的或者中断导致当前运行程序被终止。本节描述保护模式中处理器中断和异常的处理机制。
为了有助于处理异常和中断,每个需要被处理器进行特殊处理的处理器定义的异常和中断条件都被赋予了一个标识号,称为向量(vector)。处理器把赋予异常或中断的向量用作中断描述符表 IDT(Interrupt Descriptor Table)中的一个索引号,来定位一个异常或中断的处理程序入口点位置。
允许的向量号范围是 0 到 255。其中 0 到 31 保留用作 80X86 处理器定义的异常和中断,不过目前该范围内的向量号并非每个都已定义了功能,未定义功能的向量号将留作今后使用。
范围在 32 到 255 的向量号用于用户定义的中断。这些中断通常用于外部 I/O 设备,使得这些设备可以通过外部硬件中断机制向处理器发送中断。对于每个异常,该表给出了异常类型以及是否会产生一个错误码并保存在堆栈上。同时还给出了每个预先定义好的异常和 NMI 中断源。
处理器从两种地方接收中断:
外部中断通过处理器芯片上两个引脚(INTR 和 NMI)接收。当引脚 INTR 接收到外部发生的中断信号时,处理器就会从系统总线上读取外部中段控制器(例如 8259A)提供的中断向量号。当引脚 NMI 接收到信号时,就产生一个非屏蔽中断。它使用固定的中断向量号 2。在何通过处理器 INTR 引脚接收的外部中断都被称为可屏蔽硬件中断,包括中断向量号 0 到 255。标志寄存器 EFLAGS 中的 IF 标志可用来屏蔽所有这些硬件中断。
通过在指令操作数中提供中断向量号,INT n 指令可用于从软件中产生中断。例如,指令 INT 0x80 会执行 Linux 的系统中断调用中断 0x80。向量 0 到 255 中的任何一个都可以用作 INT 指令的中断号。然而,如果使用了处理器预先定义的 NMI 向量,那么处理器对它的响应将与普通方式产生的该 NMI 中断不同。如果 NMI 的向量号 2 用于该 INT 指令,就会调用 NMI 的中断处理器程序,但是此时并不会激活处理器的 NMI 处理硬件。
注意,EFLAGS 中的 IF 标志不能屏蔽使用 INT 指令从软件中产生的中断。
处理器接收的异常也有两个来源:
在应用程序或操作系统执行期间,如果处理器检测到程序错误,就会产生一个或多个异常。80X86 处理器为其检测到的每个异常定义了一个向量。异常可以被细分为故障(faults)、陷阱(traps)和中止(aborts),见后面说明。
指令 INTO、INT 3 和 BOUND 指令可以用来从软件中产生异常。这些指令可对指令流中指定点执行的特殊异常条件进行检查。例如,INT 3 指令会产生一个断点异常。
INT n 指令可用于在软件中模拟指定的异常,但有一个限制。如果 INT 指令中的操作数 n 是 80X86 异常的向量号之一,那么处理器将为该向量号产生一个中断,该中断就会去执行与该向量有关的异常处理程序。但是,因为这实际上是一个中断,因此处理器并不会把一个错误号压入堆栈,即使硬件产生的该向量相关的中断通常会产生一个错误码。对于那些会产生错误码的异常,异常的处理程序会试图从堆找上弹出错误码。因此,如果使用 INT 指令来模拟产生一个异常,处理程序则会把 EIP(正好处于缺少的错误码位置处)弹出堆栈,从而会造成返回位置错误。
根据异常被报告的方式以及导致异常的指令是否能够被重新执行,异常可被细分成故障(Fault)、陷阱(Trap)和中止(Abort)。
为了让程序或任务在一个异常或中断处理完之后能重新恢复执行,除了中止(Abort)之外的所有异常都能报告精确的指令位置,并且所有中断保证是在指令边界上发生。
对于故障类异常,处理器产生异常时保存的返回指针指向出错指令。因为,当程序或任务在故障处理程序返回后重新开始执行时,原出错指令会被重新执行。重新执行引发出错的指令通常用于处理访问指令操作数受阻的情况。Fault 最常见的一个例子是页面故障(Page-fault)异常。当程序引用不在内存中页面上的一个操作数时就会出现这种异常。当页故障异常发生时,异常处理程序可以把该页面加载到内存中并
通过重新执行出错指令来恢复程序执行。为了确保重新执行对于当前执行程序具有透明性,处理器会保存必要的寄存器和堆栈指针信息,以使得自己能够返回到执行出错指令之前的状态。
对于陷阱 Trap 类异常,处理器产生异常时保存的返回指针指向引起陷阱操作的后一条指令。如果在一条执行控制转移的指令执行期间检测到一个 Trap,则返回指令指针会反映出控制的转移情况。例如,如果在执行 JMP 指令时检测到一个 Trap 异常,那么返回指令指针会指向 JMP 指令的目标位置,而非指向 JMP 指令随后的一条指令。
中止 Abort 类异常不支持可靠地重新执行程序或任务。中止异常的处理程序通常用来收集异常发生时有关处理器状态的诊断信息,并且尽可能恰当地关闭程序和系统。
中断会严格地支持被中断程序的重新执行而不会丢失任何连贯性。中断所保存的返回指令指针指向处理器获取中断时将要执行的下一条指令边界处。如果刚执行的指令有一个重复前缀,则中断会在当前重复结束并且寄存器已为下一次重复操作设置好时发生。
中断描述符表 IDT(Interrupt Descriptor Table)将每个异常或中断向量分别与它们的处理过程联系起来。与 GDT 和 LDT 表类似,IDT 也是由 8 字节长描述符组成的一个数组。与 GDT 不同的是,表中第 1 项可以包含描述符。为了构成 IDT 表中的一个索引值,处理器把异常或中断的向量号*8。因为最多只有 256 个中断或异常向量,所以 IDT 无需包含多于 256 个描述符。IDT 中可以含有少于 256 个描述符,因为只有可能发生的异常或中断才需要描述符。不过 IDT 中所有空描述符项应该设置其存在位(标志)为 0。
IDT 表可以驻留在线性地址空间的任何地方,处理器使用 IDTR 寄存器来定位 IDT 表的位置。这个寄存器中含有 IDT 表 32 位的基地址和 16 位的长度(限长)值,见图 4-26 所示。IDT 表基地址应该对齐在 8 字节边界上以提高处理器的访问效率。限长值是以字节为单位的 IDT 表的长度。
指令 LIDT 和 SIDT 指令分别用于加载和保存 IDTR 寄存器的内容。LIDT 指令把在内存中的限长值和基地址操作数加载到 IDTR 寄存器中。该指令仅能由当前特权级 CPL 是 0 的代码执行,通常被用于创建 IDT 时的操作系统初始化代码中。SIDT 指令用于把 IDTR 中的基地址和限长内容复制到内存中。该指令可在任何特权级上执行。
如果中断或异常向量引用的描述符超过了 IDT 的界限,处理器会产生一个一般保护性异常。
IDT 表中可以存放三种类型的门描述符:
图 4-27 给出了这三种门描述符的格式。中断门和陷阱门含有一个长指针(即段选择符和偏移值),处理器使用这个长指针把程序执行权转移到代码段中异常或中断的处理过程中。这两个段的主要区别在于处理器操作 EFLAGS 寄存器 IF 标志上。IDT 中任务门描述符的格式与 GDT 和 LDT 中任务门的格式相同。任务门描述符中含有一个任务 TSS 段的选择符,该任务用于处理异常和/或中断。
处理器对异常和中断处理过程的调用操作方法与使用 CALL 指令调用程序过程和任务的方法类似。当响应一个异常或中断时,处理器使用异常或中断的向量作为 IDT 表中的索引。如果索引值指向中断门或陷阱门,则处理器使用与 CALL 指令操作调用门类似的方法调用异常或中断处理过程。如果索引值指向任务门,则处理器使用与 CALL 指令操作任务门类似的方法进行任务切换,执行异常或中断的处理任务。
异常或中断门引用运行在当前任务上下文中的异常或中断处理过程,见图 4-28 所示。门中的段选择符指向 GDT 或当前 LDT 中的可执行代码段描述符。门描述符中的偏移字段指向异常或中断处理过程的开始处。
当处理器执行异常或中断处理过程调用时会进行以下操作:
为了从中断处理过程中返回,处理过程必须使用 IRET 指令。IRET 指令与 RET 指令类似,但 IRET 还会把保存的寄存器内容恢复到 EFLAGS 中。不过只有当 CPL 是 0 时才会恢复 EFLAGS 中的 IOPL 字段,并且只有当 CPL<=IOPL 时,IF 标志才会被改变。如果当调用中断处理过程时发生了堆找切换,那么在返回时 IRET 指令会切换回到原来的堆栈。
因为异常和中断通常不会定期发生,因此这些有关特权级的规则有效地增强了异常和中断处理过程能够运行的特权级限制。我们可以利用以下技术之一来避免违反特权级保护:
当通过 IDT 中任务门来访问异常或中断处理过程时就会导致任务切换。使用单独的任务来处理异常或中断有如下好处:
使用独立任务处理异常或中断的不足之处是:在任务切换时必须对大量机器状态进行保存,使得它比使用中断门的响应速度要慢,导致中断延时增加。
IDT 中的任务门会引用 GDT 中的 TSS 描述符,图 4-30 所示。切换到句柄任务的过程与普通任务切换过程相同。到被中断任务的反向链接会被保存在句柄任务 TSS 的前一任务链接字段中。如果一个异常会产生一个出错码,则该出错码会被复制到新任务堆栈上。
当异常或中断句柄任务用于操作系统中时,实际上有两种分派调度任务的机制:操作系统软件调度和处理器中断机制的硬件调度。使用软件调度方法时需要考虑到中断开启时采用中断处理任务。
任务(Task)是处理器可以分配调度、执行和挂起的一个工作单元。它可用于执行程序、任务或进程、操作系统服务、中断或异常处理过程和内核代码。
80X86 提供了一种机制,这种机制可用来保存任务的状态、分派任务执行以及从一个任务切换到另一个任务。当工作在保护模式下,处理器所有运行都在任务中。即使是简单系统也必须起码定义一个任务。更为复杂的系统可以使用处理器的任务管理功能来支持多任务应用。
80X86 提供了多任务的硬件支持。任务是一个正在运行的程序,或者是一个等待准备运行的程序。通过中断、异常、跳转或调用,我们可以执行一个任务。当这些控制转移形式之一和某个描述符表中指定项的内容一起使用时,那么这个描述符是一类导致新任务开始执行的描述符。描述符表中与任务相关的描述符有两类:任务状态段描述符 和 任务门。当执行权传给这任何一类描述符时,都会造成任务切换。
任务切换很像过程调用,但任务切换会保存更多的处理器状态信息。任务切换会把控制权完全转移到一个新的执行环境,即新任务的执行环境。这种转移操作要求保存处理器中几乎所有寄存器的当前内容,包括标志寄存器 EFLAGS 和所有段寄存器。与过程不同,任务不可重入。任务切换不会把任何信息压入堆栈中,处理器的状态信息都被保存在内存中称为 任务状态段(Task state segment)的数据结构中。
一个任务由两部分构成:任务执行空间和任务状态段 TSS(Task-state segment)。任务执行空间包括代码段、堆栈段和一个或多个数据段,见图 4-33 所示。如果操作系统使用了处理器的特权级保护机制,那么任务执行空间就需要为每个特权级提供一个独立的堆栈空间。TSS 指定了构成任务执行空间的各个段,并且为任务状态信息提供存储空间。在多任务环境中,TSS 也为任务之间的链接提供了处理方法。
一个任务使用指向其 TSS 的段选择符来指定。 当一个任务被加载进处理器中执行时,那么该任务的段选择符、基地址、段限长以及 TSS 段描述符属性就会被加载进任务寄存器 TR(Task Register)中。如果使用了分页机制,那么任务使用的 页目录表基地址 就会被加载进控制寄存器 CR3 中。当前执行任务的状态由处理器中的以下所有内容组成:
软件或处理器可以使用以下方法之一来调度执行一个任务:
所有这些调度任务执行的方法都会使用一个指向 任务门 或任务 TSS 段的选择符来确定一个任务。当使用 CALL 或 JMP 指令调度一个任务时,指令中的选择符既可以直接选择任务的 TSS,也可以选择存放有 TSS 选择符的任务门。当调度一个任务来处理一个中断或异常时,那么 IDT 中该中断或异常表项必须是一个任务门,并且其中含有中断或异常处理任务的 TSS 选择符。
当调度一个任务执行时,当前正在运行任务和调度任务之间会自动地发生任务切换操作。在任务切换期间,当前运行任务的执行环境(称为任务的状态或上下文)会被保存到它的 TSS 中并且暂停该任务的执行。此后新调度任务的上下文会被加载进处理器中,并且从加载的 EIP 指向的指令处开始执行新任务。
如果当前执行任务(调用者)调用了被调度的新任务(被调用者),那么调用者的 TSS 段选择符会被保存在被调用者 TSS 中,从而提供了一个返回调用者的链接。对于所有 80X86 处理器,任务是不可递归调用的,即任务不能调用或跳转到自己。
中断或异常可以通过切换到一个任务来进行处理。在这种情况下,处理器不仅能够执行任务切换来处理中断或异常,而 且也会在中断或异常处理任务返回时自动地切换回被中断的任务中去。这种操作方式可以处理在中断任务执行时发生的中断。
作为任务切换操作的一部份,处理器也会切换到另一个 LDT,从而允许每个任务对基于 LDT 的段具有不同逻辑到物理地址的映射。同时,页目录寄存器 CR3 也会在切换时被重新加载,因此每个任务可以有自己的一套页表。这些保护措施能够用来隔绝各个任务并且防止它们相互干扰。
使用处理器的任务管理功能来处理多任务应用是任选的。我们也可以使用软件来实现多任务,使得每个软件定义的任务在一个 80X86 体系结构的任务上下文中执行。
处理器定义了一下一些支持多任务的寄存器和数据结构:
使用这些数据结构,处理器可以从一个任务切换到另一个任务,同时保存原任务的上下文,以允许任务重新执行。
用于恢复一个任务执行的处理器状态信息被保存在称为任务状态段 TSS(Task state segment)的段中。图 4-34 给出了 32 位 CPU 使用的 TSS 的格式。TSS 段中各字段可分成两大类:动态字段 和 静态字段。
如果使用了分页机制,那么在任务切换期间应该避免处理器操作的 TSS 段中(前 104 字节中,备注:104 正好是上面所列的 TSS 的大小 0x68
)含有内存页边界。如果 TSS 这部分包含内存页边界,那么该边界处两边的页面都必须同时并且连续存在于内存中。另外,如果使用了分页机制,那么与原任务 TSS 和新任务 TSS 相关的页面,以及对应的描述符表表项应该是可读写的。
与其他段一样,任务状态段 TSS 也是使用段描述符来定义。图 4-35 给出了 TSS 描述符的格式。TSS 描述符只能存放在 GDT 中。
类型字段 TYPE 中的忙标志 B 用于指明任务是否处于忙状态。忙状态的任务是当前正在执行的任务或等待执行(被挂起)的任务。值为 0b1001 的类型字段表明任务处于非活动状态;而值为 0b1011 的类型字段表示任务正忙。任务是不可以递归执行的,因此处理器使羽忙标志 B 来检测任何企图对被中断执行任务的调用。
其中基地址、段限长、描述符特权级 DPL、颗粒度 G 和存在位具有与数据段描述符中相应字段同样的功能。当 G=0 时,限长字段必须具有等于或大于 103(0x67)的值,即 TSS 段的最小长度不得小于 104 字节。如果 TSS 段中还包含 I/O 许可位图,那么 TSS 段长度需要大一些。另外,如果操作系统还想在 TSS 段中存放其他一些信息,那么 TSS 段就需要更大的长度。
使用调用或跳转指令,任何可以访问 TSS 描述符的程序都能够造成任务切换。可以访问 TSS 描述符的程序其 CPL 数值必须小于或等于 TSS 描述符的 DPL。在大多数系统中,TSS 描述符的 DPL 字段值应该设置成小于 3。这样,只有具有特权级的软件可以执行任务切换操作。然而在多任务应用中,某些 TSS 的 DPL 可以设置成 3,以使得在用户特权级上也能进行任务切换操作。
可访问一个 TSS 段描述符并没有给程序读写该描述符的能力。若想读或修改一个 TSS 段描述符,可以使用映射到内存相同位置的数据段描述符(即别名描述符)来操作。把 TSS 描述符加裁进任何段寄存器将导致一个异常。企图使用 TI 标志置位的选择符(即当前 LDT 中的选择符)来访问 TSS 段也将导致异常。
任务寄存器 TR(Task Register》中存放着 16 位的段选择符以及当前任务 TSS 段的整个描述符(不可见部分)。这些信息是从 GDT 中当前任务的 TSS 描述符中复制过来的。处理器使用任务寄存器 TR 的不可见部分来缓冲 TSS 段描述符内容。
指令 LTR 和 STR 分别用于加载和保存任务寄存器的可见部分,即 TSS 段的选择符。LTR 指令只能被特权级 0 的程序执行。LTR 指令通常用于系统初始化期间给 TR 寄存器加裁初值(例如,任务 0 的 TSS 段选择符),随后在系统运行期间,TR 的内容会在任务切换时自动地被改变。
任务门描述符(Task gate descriptor)提供对一个任务间接、受保护地的引用,其格式见图所示。任务门描述符可以被存放在 GDT、LDT 或 IDT 表中。
任务门描述符中的 TSS 选择符字段指向 GDT 中的一个 TSS 段描述符。这个 TSS 选择符字段中的 RPL 域不用。任务门描述符中的 DPL 用于在任务切换时控制对 TSS 段的访问。当程序通过任务门调用或跳转到一个任务时,程序的 CPL 以及指向任务门的门选择符的 RFL 值必须小于或等于任务门描述符中的 DPL 请注意,当使用任务门时,目标 TSS 段描述符的 DPL 忽略不用。
程序可以通过任务门描述符或者 TSS 段描述符来访问一个任务。图 4-36 示出了 LDT、GDT 和 IDT 表中的任务门如何都指向同一个任务。
处理器可使用一下 4 种方式之一执行任务切换操作:
JMP、CALL 和 IRET 指令以及中断和异常都是处理器的普通机制,可用于不发生任务切换的环境中。对于 TSS 描述符或任务门的引用(当调用或跳转到一个任务),或者 NT 标志的状态(当执行 IRET 指令时)确定了是否会发生任务切换。
为了进行任务切换,JMP 或 CALL 指令能够把控制转移到 TSS 描述符或任务门上。使用这两种方式的作用相同,都会导致处理器把控制转移到指定的任务中,见图 4-37 所示。
当中断或异常的向量索引的是 IDT 中的一个任务门时,一个中断或异常就会造成任务切换。如果向量索引的是 IDT 中的一个中断或陷阱门,则不会造成任务切换。
中断服务过程总是把执行权返回到被中断的过程中,被中断的过程可能在另一个任务中。如果 NT 标志处于复位状态,则执行一般返回处理。如果 NT 标志是置位状态,则返回操作会产生任务切换。切换到的新任务由中断服务过程 TSS 中的 TSS 选择符(前一任务链接字段)指定。
当切换到一个新任务时,处理器会执行一下操作:
当成功地进行了任务切换操作,当前执行任务的状态总是会被保存起来。当任务恢复执行时,任务将从保存的 EIP 指向的指令处开始执行,并且所有寄存器都恢复到任务挂起时的值。
当执行任务切换时,新任务的特权级与原任务的特权级没有任何关系。新任务在 CS 寄存器的 CPL 字段指定的特权级上开始运行。因为各个任务通过它们独立的地址空间和 TSS 段相互隔绝,并且特权级规则已经控制对 TSS 的访问,所以在任务切换时软件不需要再进行特权级检查。
每次任务切换都会设置控制寄存器 CR0 中的任务切换标志 TS。该标志对系统软件非常有用。系统软件可用 TS 标志来协调处理器和浮点协处理器之间的操作。TS 标志表明协处理器中的上下文内容可能与当前正在执行任务的不一致。
TSS 的前一任务连接(Backlink)字段以及 EFLAGS 中的 NT 标志用于返回到前一个任务操作中。NT 标志指出了当前执行的任务是否是嵌套在另一个任务中执行,并且当前任务的前一任务连接字段中存放着嵌套层中更高层任务的 TSS 选择符,若有的话(见图 4-38 所示)。
当 CALL 指令、中断或异常造成任务切换,处理器把当前 TSS 段的选择符复制到新任务 TSS 段的前一任务链接字段中,然后在 EFLAGS 中设置 NT 标志。NT 标志指明 TSS 的前一任务链接字段中存放有保存的 TSS 段选择符。如果软件使用 IRET 指令挂起新任务,处理器就会使用前一任务链接字段中值和 NT 标志返回到前一个任务。也即如果 NT 标志是置位的话,处理器会切换到前一任务链接字段指定的任务去执行。
注意,当任务切换是由 JMP 指令造成,那么新任务就不会是嵌套的。也即,NT 标志会被设置为 0,并且不使用前一任务链接字段。JMP 指令用于不希望出现嵌套的任务切换中。
表 4-10 总结了任务切换期间,忙标志 B(在 TSS 段描述符中)、NT 标志、前一任务链接字段和 TS 标志(在 CR0 中)的用法。注意,运行于任何特权级上的程序都可以修改 NT 标志,因此任何程序都可以设置 NT 标志并执行 IRET 指令。这种做法会让处理器去执行当前任务 TSS 的前一任务链接字段指定的任务。为了避免这种伪造的任务切换执行成功,操作系统应该把每个 TSS 的该字段初始化为 0。
任务的地址空间由任务能够访问的段构成。这些段包括代码段、数据段、堆栈段、TSS 中引用的系统段以及任务代码能够访问的任何其他段。这些段都被映射到处理器的线性地址空间中,并且随后被直接地或者通过分页机制映射到处理器的物理地址空间中。
TSS 中的 LDT 字段可以用于给出每个任务自己的 LDT。对于一个给定的任务,通过把与任务相关的所有段描述符放入 LDT 中,任务的地址空间就可以与其他任务的隔绝开来。
当然,几个任务也可以使用同一个 LDT。这是一种简单而有效的允许某些任务互相通信或控制的方法,而无须抛弃整个系统的保护屏障。
因为所有任务都可以访问 GDT,所以也同样可以创建通过此表访问的共享段。
如果开启了分页机制,则 TSS 中的 CR3 寄存器字段可可以让每个任务有它自己的页表。或者,几个任务能够共享相同页表集。
有两种方法可以把任务映射到线性地址空间和物理地址空间:
不同任务的线性地址空间可以映射到完全不同的物理地址上。如果不同页目录的条目(表项)指向不同的页表,而且页表也指向物理地址中不同的页面上,那么各个任务就不会共享任何物理地址。
对于映射任务线性地址空间的这两种方法,所有任务的 TSS 都必须存放在共享的物理地址空间区域中,并且所有任务都能访问这个区域。为了让处理器执行任务切换而读取或更新 TSS 时,TSS 地址的映射不会改变,就需要使用这种映射方式。GDT 所映射的线性地址空间也应该映射到共享的物理地址空间中。否则就丧失了 GDT 的作用。
为了在任务之间共享数据,可使用下列方法之一来为数据段建立共享的逻辑到物理地址空间的映射:
通过使用 GDT 中的段描述符。所有任务必须能够访问 GDT 中的段描述符。如果 GDT 中的某些段描述符指向线性地址空间中的一些段,并且这些段被映射到所有任务共享的物理地址空间中,那么所有任务都可以共享这些段中的代码和数据。
通过共享的 LDT。两个或多个任务可以使用相同的 LDT,如果它们 TSS 中 LDT 字段指向同一个 LDT。如果一个共享的 LDT 中某些段描述符指向映射到物理地址空间公共区域的段,那么共享 LDT 的所有任务可以共享这些段中的所有代码和数据。这种共享方式要比通过 GDT 来共享好,因为这样做可以把共享局限于指定的一些任务中。系统中有与此不同 LDT 的其他任务没有访问这些共享段的权利。
通过映射到线性地址空间公共地址区域的不同 LDT 中的段描述符。如果线性地址空间中的这个公共区域对每个任务都映射到物理地址空间的相同区域,那么这些段描述符就允许任务共享这些段。这样的段描述符通常称为别名段。这个共享方式要比上面给出的方式来得更好,因为 LDT 中的其他段描述符可以指向独立的未共享线性地址区域。