Refer to <<linux
内核源代码情景分析
>> and <<Linux kernel Version:2.4.0>>
Having any problems, send mails to viloner@163.com
Intel X86 CPU
系列的寻址方式与段式内存管理机制
在
X86
系列中,
8086
和
8088
是
16
位处理器,而从
80386
开始为
32
位处理器,
80286
则是系列从
8088
到
80386,
也就是从
16
位到
32
位过渡的一个中间步骤。
80286
虽然仍是
16
位处理器,但是在寻址方式上开始了从“初地址模式”到“保护模式”的过渡。
当我们说一个
CPU
是“
16
位”或“
32
位”时,指的是处理器中“自述逻辑单元”
(ALU)
的宽度。系统总线中的数据线部分,称为“数据总线”,通常与
ALU
具有相同的宽度
(
但有例外
)
。那么“地址总线”的宽度呢?最自然的地址总线宽度是与数据总线一致。这是因为从程序设计的角度来说,一个地址,也就是一个指针,最好是与一个整数的长度一致。但是如果从
8
位
CPU
寻址能力的角度来考虑,则实际上是不现实的,因为一个
8
位的地址只能用来寻访
256
个不同的地址单元,这显然太小了。所以,一般
8
位
CPU
的地址总线都是
16
位的。但
16
位还是太小。
Intel
决定在其
16
位
CPU
,即
8086
中采用
1M
字节的内存地址空间,地址总线的宽度也就相应地确定了,那就是
20
位。但这样就出现了一个问题,虽然地址总线的宽度是
20
位,但
CPU
中
ALU
的宽度却只有
16
位,也就是说直接加以运算的指针长度是
16
位的。如何来填补这个空隙呢?
Intel
设计了一种在当时看来不失巧妙的方法,即分段的方法。
Intel
在
8086CPU
中设置了四个“段寄存器”:
CS
、
DS
、
SS
和
ES
,分别用于可执行代码即指令、数据、堆栈和其他。每个段寄存器都是
16
位,对应于地址总线中的高
16
位。每条“访内”指令中的“内部地址”都是
16
位的,但是在送上地址总线之前在
CPU
内部自动地与某个段寄存器中的内容相加,形成一个
20
位的实际地址。这样,就实现了从
16
位内部地址到
20
位实际地址的转换,或者“映射”。这里要注意段寄存器中的内容对应于
20
位地址总线中的高
16
位,所以在相加时实际上是拿内部地址中的高
12
位与段寄存器中的
16
位相加,而内部地址中的低
4
位保持不变。但这种方法是有缺陷的,主要是没有地址空间保护机制。对于每一个由段寄存器的内容确定的“基地址”,一个进程总是能够访问从此开始的
64K
字节的连续地址空间,而无法加以限制。同时,可以用来改变段寄存器内容的指令也不是什么“特权指令”,也就是说,通过改变段寄存器的内容,一个进程可以随心所欲地访问内存中的任何一个单元,而丝毫不受限制。不能对一个进程的内存访问加以限制,也就谈不上对其他进程以及系统本身的保护。与此相应,一个
CPU
如果缺乏对内存访问的限制,或者说保护,就谈不上什么内存管理,也就谈不上是现代意义上的中央处理器。由于
8086
的这种内存寻址方式缺乏对内存空间的保护,所以为了区别于后来出现的“保护模式”,就称为“实地址模式”。
针对
8086
的这种缺陷,
Intel
从
80286
开始实现其“保护模式”。同时不久后
32
位的
80386CPU
也开发成功了。这样,从
8088/8086
到
80386
就完成了一次从比较原始的
16
位
CPU
到现代的
32
位
CPU
的飞跃,而
80286
则变成这次飞跃的一个中间步骤。
80386
是个
32
位
CPU
,也就是说它的
ALU
数据总线是
32
位的,则最自然的地址总线宽度也应是与数据总线一致的。当地址总线的宽度达到
32
位时,其寻址能力达到了
4G
,对于内存来说似乎是足够了。所以,如果新设计一个
32
位
CPU
的话,其结构应该是可以做到很简洁,很自然的。但是,
80386
却无法做到这一点。作为一个产品系列中的一员,
80386
必须维持那些段寄存器,还必须支持实地址模式,在此同时又要支持保护模式。因此,
Intel
决定在段寄存器的基础上构筑保护模式,并且保留段寄存器为
16
位
(
这样才可以利用原有的四个段寄存器
)
,但是却又增添了两个段寄存器
FS
和
GS
。为了实现保护模式,光是用段寄存器来确定一个基地址是不够的,至少还要有一个地址段的长度,并且还需要一些其他信息,如访问权限之类。所以,这里需要的是一个数据结构,而并非一个单纯的基地址。对此,
Intel
设计人员的基本思路是:在保护模式下改变段寄存器的功能,使其从一个单纯的基地址变成指向这样一个数据结构的指针。因此,当一个访存指令发出一个内存地址时,
CPU
按照下面过程实现从指令中的
32
位逻辑地址到
32
位物理地址的转换:
1.
首先根据指令的性质来确定该使用哪一个段寄存器,例如转移指令中的地址在代码段,而数据指令中的地址在数据段。这一点与实地址模式相同。
2.
根据段寄存器的内容,找到相应的
“
段描述结构
”
。
3.
从
“
段描述结构
”
中得到基地址。
4.
将指令中的地址作为位移,与段描述结构中规定的段长度相比,看是否越界;
5.
根据指令的性质和段描述符中的访问权限来确定是否越权;
6.
最后才将指令中的地址作为位移,与段基地址相加,得到物理地址。
虽然段描述结构存储在内存中,在实际使用时却将其装载入
CPU
中的一组“影子”结构,而
CPU
在运行时则使用其在
CPU
中的“影子”。从保护的角度考虑,在由
(
指令给出的
)
内部地址
(
或者说“逻辑地址”
)
转换成物理地址的过程中,必须要在某个环节上对访问权限时行比对,以访止不具有特权的用户程序通过玩弄某些诡计
(
例如修改段寄存器的内容,修改段描述结构的内容等
)
,得以非法访问其他进程的空间或系统空间,从而实现了保护。
明白了这个思路,
80386
的段式内存管理机制就比较容易理解了,下面就是此机制的实际实现。
首先,在
80386CPU
中增设了两个寄存器:一个是全局性段描述表寄存器
GDTR
,另外一个是局部性段描述表寄存器
LDTR
,分别可以用来指向存储在内存中的一个段描述结构数组,或者称为段描述表。由于这两个寄存器是新增设的,不存在与原有的指令是否兼容的问题,访问这两个寄存器的专用指令便设计成“特权指令”。
在此基础上,段寄存器的高
13
位用作访问段描述表中具体描述结构的下标
(index)
,如下图所示
段寄存器定义
RPL
:请求特权级,
2
位二进制数字,求特权级是将要访问的段的特权级。
TI
:表指示符。为
0
时,从
GDT
中选择描述符;为
1
时,从
LDT
中选择描述符。
Index
:索引。指出要访问描述符在段描述符表中的顺序号。总共有
213=8192
个。
GDTR
或
LDTR
中的段描述表指针和段寄存器中给出的下标结合在一起,才决定了具体的段描述表项在内存中的什么地方,也可以理解成,将段寄存器内容的低
3
位屏蔽掉以后与
GDTR
或
LDTR
中的基地址相加得到描述表项的起始地址。因此就无法通过修改描述表项的内容来玩弄诡计,从而起到保护的作用。每个段描述表项的大小是
8
个字节,每个描述表项含有段的基地址和段的大小,再
加上其他一些信息,其结构如下图所示:
8
字节段描述表项的含义
结构中的
B31-B24
和
B23-B16
分别为基地址的
bit16~bit23
和
bit24~bit31.
而
L19~L16
和
L15~L0
则为段长度
(limit)
的
bit0~bit15
和
bit16~bit19.
G
:粒度位。
G=1
时,限长以页为单位;
G=0
时,限长以字节为单位。
D
:默认操作数宽度。
D=1
时,为
32
位数据操作段;
D=1
时,为
16
位数据操作段。
AVL
:可用位。
这一位保留给操作系统或应用程序来使用
DPL
是个
2
位的位段,而
TYPE
是一个
4
位的位段。它们的定义如下:
P
:存在位
等于
1
时表示该段己装入内存;
等于
0
时表示该段没有在内存中,访问这个段会产生段异常。
n
DPL
:描述符特权级,说明这个段的特权级
S
:描述符类型位
为
1
时,这个段为代码段、数据段或堆栈段;
为
0
时,为系统段描述符。
E
:可执行位,区分代码段和数据段
S=0
且
E=1
时,这是一个代码段,可执行。
S=0
且
E=0
时,这是一个数据段或堆栈段,不可执行。
E=0
时,后面的两位为
ED
和
W
;
若
E=1
时,后面的两位为
C
和
R
。
ED
:扩展方向位
为
0
时,段从低地址向高地址扩展,偏移量小于等于限长。
为
1
时,段从高地址向低地址扩展,偏移量必须大于限长。
W
:写允许位
为
0
时,不允许对这个数据段写入;
为
1
时,允许对这个数据段写入。
C
:一致位
为
0
时,这个段不是一致代码段
为
1
时,这个段是一致代码段
R
:读允许位
为
0
时,不允许读这个段的内容
为
1
时,允许读这个段的内容
A
:访问位
为
1
表示段已被访问过
为
0
表示段未被访问过。
也可以用一段“伪代码”来说明整个段描述结构:
段描述结构
:
typedef struct {
unsigned int base_24_31:8; //
基地址最高
8
位
unsigned int g:1; //granularity
表段长度单位
[0]
字节
[1]4KB
unsigned int d_b:1; //default operation size
存取方式
[0]16
位
[1]32
位
unsigned int unused:1; //
固定设置成
0
unsigned int avl:1 //avaliable,
可供系统软件使用
unsigned int seg_limit_16_19:4; //
段长度的最高
4
位
unsigned int p:1; //segment present, [0]
该段的内容不在内存中
unsigned int dp1:2; //Descriptor privilege level,
访问本段所需权限
unsigned int s:1; //
描述项类型
[1]
系统
[0]
代码
/
数据
unsigned int type:4 //
段的类型
,
与
S
标志位一起使用
unsigned int base_0_23:24; //
基地址的低
24
位
unsigned int seg_limit_0_15:16; //
段长度的低
16
位
}descriptor;
以这里的位段
type
为例,“:
4
”表示其宽度为
4
位。整个数据结构的大小为
64
位,即
8
个字节。
在读写内存单元时,
CPU
需要检查段描述符的内容是否和当前操作相一致,
CPU
的运行效率极大地降低。为解决这个问题,
CPU
在内部设置了段描述符高速缓存,可以看作是对段寄存器的扩充。扩充后的段寄存器分成两部分,一部分是可见的
(
对程序而言
)
,还与原来的段寄存器一样,另一部分是不可见的,就是用来放影子描述项的空间,这一部分是专供
CPU
内部使用的。在指令执行过程中,只有段寄存器的值发生改变时,才需要到
GDT
或
LDT
中装入段描述符。如果段寄存器的值不改变,高速缓存
(
即对段寄存器扩充的那部分
)
中的段描述符可以被直接引用,这样就避免了到主存中频繁读取段描述符。提高了
CPU
的效率。
在
80386
的段式内存管理的基础上,如果把每个段寄存器都指向同一个描述项,而在该描述项中则将基地址设成
0,
并将段长度设成最大,这样便形成一个从
0
开始覆盖整个
32
位地址空间的一个整段。由于基地址为
0,
此时的物理地址与逻辑地址相同,
CPU
放到地址总线上去的地址就是在指令中给出的地址。这样的地址有别于由“段寄存器
/
位移量”构成的“层次式”地址,所以
Intel
称其为“平面
(Flat)
”地址。
Linux
内核的源代码
(
更确切地应该是
gcc)
采用平面地址。这里要指出,平面地址的使用并不意味着绕过了段描述表、段寄存器这一整套段式内存管理的机制,而只是段式内存管理的一种使用特例。
利用
80386
对段式内存管理的硬件支持,可以实现段式虚存管理。如前所述,当一个段寄存器内容改变时,
CPU
要根据新的段寄存器内容以及
GDTR
或
LDTR
的内容找到相应的段描述项并将其装入
CPU
中。在些过程中,
CPU
会检查该描述项中的
p
标志位
(
表示“
present
”
)
,如果
p
标志位为
0,
就表示该描述项所指向的那一段内容不在内存中
(
也就是说,在磁盘上的某个地方
)
,此时
CPU
会产生一次异常
(exception
,类似于中断
)
,而相应的服务程序便可以从磁盘交换区将这一段的内容读入内存中的某个地方,并据此设置描述项中的基地址,再将
p
标志位设置成
1.
相应地,内存中暂时不用的存储段则可以写入磁盘,并将其描述项中的
p
标志位改成
0.
对段式内存管理的支持只是
i386
保护模式的一个组成部分。如果没有系统状态和用户状态的分离,以及特权指令
(
只允许在系统状态下使用的
)
的设立,那么尽管有了前述的段式内存管理,也还不能起到保护的效果。前面已提到特权指令的设置,如果来装入和存储
GDTR
和
LDTR
的指令
LGDT/LLDT
和
SGDT/SLDT
等就都是特权指令。正是由于这些特权指令都只能在系统状态
(
也就是在操作系统的内核中
)
使用,才使得用户程序不但不能改变
GDTR
和
LDTR
的内容,还因为既无法确知其段描述表在内存中的位置,又无法访问其段描述表所在的空间
(
只能在系统状态下才能访问
)
,从而无法通过修改段描述项来打破系统的保护机制。那么,
80386
怎么来分隔系统状态和用户状态,并且提供在两种状态之间切换的机制呢?
80386
并不只是像一般
CPU
通常所做的那样,划分出系统状态和用户状态,而是划分成四个特权级别,其中
0
级为最高,
3
级为最低。每一条指令也都有其适用的级别,如前所述的
LGDT
,就只有在
0
级的状态下才能使用,而一般的输入
/
输出指令
(IN
,
OUT)
则规定为
0
级或
1
级。通常,用户的应用程序都是
3
级。一般程序的当前运行级别由其代码段的局部描述项
(
即由段寄存器
CS
所指向的局部段描述项
)
中的
dpl
字段决定
(dpl
表示“
descriptor privilege level
”
)
。当然,每个描述项的
dpl
字段都是从
0
级状态下由内核设定的。而全局段描述的
dpl
字段,则又有所不同,它是表示所需的级别。
前面讲过,
16
位的段寄存器中的高
13
位用作下标来访问段描述表,而低
3
位是干什么的呢?下面通过一段伪代码来说明:
typedef struct {
unsignedshort seg_idx: 13; /*13
位的段描述项的下标
*/
unsignedshort ti: 1; /*
段描述表指示位,
0
表示
GDT
,
1
表示
LDT*/
unsignedshort rpl: 2; /*Requested Privilege Level,
要求的优先级别
*/
}
段寄存器
;
当段寄存器
CS
中的
ti
位为
1
时,表示要使用全局段描述表,为
0
时,则表示要使用局部段描述表而
rpl
则表示所要求的权限。当改变一个段寄存器的内容时,
CPU
会加以检查,以确保该段程序的当前执行权限和段寄存器所指定要求的权限均不低于所要访问的那一段内存的权限
dpl
。
至于怎样在不同的执行权限之间切换,将在进程高度、系统调用和中断处理中讨论。此外,除了全局段描述表指针
GDTR
和局部段描述表指针
LDTR
两个寄存器外,其实
i386CPU
中还有个中断向量表指针寄存器
IDTR
、与进程
(
在
Intel
术语中称为“任务”,
Task)
有关的寄存器
TR
以及描述任务状态的“任务状态段”
TSS
等。