天天看点

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

每天十五分钟,熟读一个技术点,水滴石穿,一切只为渴望更优秀的你!

————零声学院

内存地址

在任何一台计算机上,都存在一个程序能产生的内存地址的集合。当程序执行这样一条

指令时:

MOVE REG,ADDR

它把地址为 ADDR(假设为 10000)的内存单元的内容复制到 REG 中,地址 ADDR 可以通

过索引、基址寄存器、段寄存器和其他方式产生。

在 8086 的实模式下,把某一段寄存器左移 4 位,然后与地址 ADDR 相加后被直接送到内

存总线上,这个相加后的地址就是内存单元的物理地址,而程序中的这个地址就叫逻辑地址

(或叫虚地址)。在 80386 的保护模式下,这个逻辑地址不是被直接送到内存总线,而是被送

到内存管理单元(MMU)。MMU 由一个或一组芯片组成,其功能是把逻辑地址映射为物理地址,

即进行地址转换,如图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

当使用 80386 时,我们必须区分以下 3 种不同的地址。

1.逻辑地址

机器语言指令仍用这种地址指定一个操作数的地址或一条指令的地址。这种寻址方式在

Intel 的分段结构中表现得尤为具体,它使得 MS-DOS 或 Windows 程序员把程序分为若干段。

每个逻辑地址都由一个段和偏移量组成。

2.线性地址

线性地址是一个 32 位的无符号整数,可以表达高达 232(4GB)的地址。通常用 16 进制

表示线性地址,其取值范围为 0x00000000~0xffffffff。

3.物理地址

物理地址是内存单元的实际地址,用于芯片级内存单元寻址。物理地址也由 32 位无符

号整数表示。

从图可以看出,MMU 是一种硬件电路,它包含两个部件,一个是分段部件,一个是

分页部件,在本书中,我们把它们分别叫做分段机制和分页机制,以利于从逻辑的角度来理

解硬件的实现机制。分段机制把一个逻辑地址转换为线性地址;接着,分页机制把一个线性

地址转换为物理地址,如图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

段机制和描述符

1 段机制

在 80386 的段机制中,逻辑地址由两部分组成,即段部分(选择符)及偏移部分。

段是形成逻辑地址到线性地址转换的基础。如果我们把段看成一个对象的话,那么对它

的描述如下。

(1)段的基地址(Base Address):在线性地址空间中段的起始地址。

(2)段的界限(Limit):表示在逻辑地址中,段内可以使用的最大偏移量。

(3)段的属性(Attribute): 表示段的特性。例如,该段是否可被读出或写入,或者

该段是否作为一个程序来执行,以及段的特权级等。

段的界限定义逻辑地址空间中段的大小。段内在偏移量从 0 到 limit 范围内的逻辑地址,

对应于从 Base 到 Base+Limit 范围内的线性地址。在一个段内,偏移量大于段界限的逻辑地

址将没有意义,使用这样的逻辑地址,系统将产生异常。另外,如果要对一个段进行访问,

系统会根据段的属性检查访问者是否具有访问权限,如果没有,则产生异常。例如,在 80386

中,如果要在只读段中进行写入,80386 将根据该段的属性检测到这是一种违规操作,则产

生异常。

图表示一个段如何从逻辑地址空间,重新定位到线性地址空间。图的左侧表示逻辑

地址空间,定义了 A、B 及 C 三个段,段容量分别为 LimitA、LimitB及 LimitC。图中虚线把逻

辑地址空间中的段 A、B 及 C 与线性地址空间区域连接起来表示了这种转换。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

段的基地址、界限及保护属性,存储在段的描述符表中,在逻辑—线性地址转换过程中

要对描述符进行访问。段描述符又存储在存储器的段描述符表中,该描述符表是段描述符的

一个数组,关于这些内容,我们将在后面详细介绍。

2 描述符的概念

所谓描述符(Descriptor),就是描述段的属性的一个 8 字节存储单元。在实模式下,

段的属性不外乎是代码段、堆栈段、数据段、段的起始地址、段的长度等,而在保护模式下

则复杂一些。80386 将它们结合在一起用一个 8 字节的数表示,称为描述符。80386 的一个通

用的段描述符的结构如图 2.10 所示。

从图可以看出,一个段描述符指出了段的 32 位基地址和 20 位段界限(即段长)。

第 6 个字节的 G 位是粒度位,当 G=0 时,段长表示段格式的字节长度,即一个段最长可

达 1M 字节。当 G=1 时,段长表示段的以 4K 字节为一页的页的数目,即一个段最长可达

1M×4K=4G 字节。D 位表示缺省操作数的大小,如果 D=0,操作数为 16 位,如果 D=1,操作数

为 32 位。第 6 个字节的其余两位为 0,这是为了与将来的处理器兼容而必须设置为 0 的位。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

第 5 个字节是存取权字节,它的一般格式如图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

第 7 位 P 位(Present) 是存在位,表示段描述符描述的这个段是否在内存中,如果在

内存中。P=1;如果不在内存中,P=0。

DPL(Descriptor Privilege Level),就是描述符特权级,它占两位,其值为 0~3,

用来确定这个段的特权级即保护等级。

S 位(System)表示这个段是系统段还是用户段。如果 S=0,则为系统段,如果 S=1,则

为用户程序的代码段、数据段或堆栈段。系统段与用户段有很大的不同,后面会具体介绍。

类型占 3 位,第 3 位为 E 位,表示段是否可执行。当 E=0 时,为数据段描述符,这时的

第 2 位 ED 表示扩展方向。当 ED=0 时,为向地址增大的方向扩展,这时存取数据段中的数据

的偏移量必须小于或等于段界限,当 ED=1 时,表示向地址减少的方向扩展,这时偏移量必须

大于界限。当表示数据段时,第 1 位(W)是可写位,当 W=0 时,数据段不能写,W=1 时,数

据段可写入。在 80386 中,堆栈段也被看成数据段,因为它本质上就是特殊的数据段。当描

述堆栈段时,ED=0,W=1,即堆栈段朝地址增大的方向扩展。

也就是说,当段为数据段时,存取权字节的格式如图所示。

当段为代码段时,第 3 位 E=1,这时第 2 位为一致位(C)。当 C=1 时,如果当前特权级

低于描述符特权级,并且当前特权级保持不变,那么代码段只能执行。所谓当前特权级

(Current Privilege Level),就是当前正在执行的任务的特权级。第 1 位为可读位 R,当

R=0 时,代码段不能读,当 R=1 时可读。也就是说,当段为代码段时,存取权字节的格式如

图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)
深入分析Linux内核源代码-Linux 运行的硬件基础(下)

存取权字节的第 0 位 A 位是访问位,用于请求分段不分页的系统中,每当该段被访问时,

将 A 置 1。对于分页系统,则 A 被忽略未用。

3 系统段描述符

以上介绍了用户段描述符。系统段描述符的一般格式如图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

可以看出,系统段描述符的第 5 个字节的第 4 位为 0,说明它是系统段描述符,类型占

4 位,没有 A 位。第 6 个字节的第 6 位为 0,说明系统段的长度是字节粒度,所以,一个系统

段的最大长度为 1M 字节。

系统段的类型为 16 种,如图 2.15 所示。

在这 16 种类型中,保留类型和有关 286 的类型不予考虑。

门也是一种描述符,有调用门、任务门、中断门和陷阱门 4 种门描述符。有关门描述符

的内容将在第四章中进行具体讨论。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

4 描述符表

各种各样的用户描述符和系统描述符,都放在对应的全局描述符表、局部描述符表和中

断描述符表中。

描述符表(即段表)定义了 386 系统的所有段的情况。所有的描述符表本身都占据一个

字节为 8 的倍数的存储器空间,空间大小在 8 个字节(至少含一个描述符)到 64K 字节(至

多含 8K)个描述符之间。

1.全局描述符表(GDT)

全局描述符表 GDT(Global Descriptor Table),除了任务门,中断门和陷阱门描述符

外,包含着系统中所有任务都共用的那些段的描述符。它的第一个 8 字节位置没有使用。

2.中断描述符表(IDT)

中断描述符表 IDT(Interrupt Descriptor Table),包含 256 个门描述符。IDT 中只

能包含任务门、中断门和陷阱门描述符,虽然 IDT 表最长也可以为 64K 字节,但只能存取 2K

字节以内的描述符,即 256 个描述符,这个数字是为了和 8086 保持兼容。

3.局部描述符表(LDT)

局部描述符表 LDT(Local Descriptor Table),包含了与一个给定任务有关的描述符,

每个任务各自有一个的 LDT。有了 LDT,就可以使给定任务的代码、数据与别的任务相隔离。

每一个任务的局部描述符表 LDT 本身也用一个描述符来表示,称为 LDT 描述符,它包含

了有关局部描述符表的信息,被放在全局描述符表 GDT 中。

5 选择符与描述符表寄存器

在实模式下,段寄存器存储的是真实的段地址,在保护模式下,16 位的段寄存器无法放

下 32 位的段地址,因此,它们被称为选择符,即段寄存器的作用是用来选择描述符。选择符

的结构如图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

可以看出,选择符有 3 个域:第 15~3 位这 13 位是索引域,表示的数据为 0~8129,用于

指向全局描述符表中相应的描述符。第 2 位为选择域,如果 TI=1,就从局部描述符表中选择

相应的描述符,如果 TI=0,就从全局描述符表中选择描述符。第 1、0 位是特权级,表示选

择符的特权级,被称为请求者特权级 RPL(Requestor Privilege Level)。只有请求者特权

级 RPL 高于(数字低于)或等于相应的描述符特权级 DPL,描述符才能被存取,这就可以实

现一定程度的保护。

我们知道,实模式下是直接在段寄存器中放置段基地址,现在则是通过它来存取相应的

描述符来获得段基地址和其他信息,这样以来,存取速度会不会变慢呢?为了解决这个问题,

386 的每一个段选择符都有一个程序员不可见(也就是说程序员不能直接操纵)的 88 位宽的

段描述符高速缓冲寄存器与之对应。无论什么时候改变了段寄存器的内容,只要特权级合理,

描述符表中的相应的 8 字节描述符就会自动从描述符表中取出来,装入高速缓冲寄存器中(还

有 24 位其他内容)。一旦装入,以后对那个段的访问就都使用高速缓冲寄存器的描述符信息,

而不会再重新从表中去取,这就大大加快了执行的时间,如图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

由于段描述符高速缓冲寄存器的内容只有在重新设置选择符时才被重新装入,所以,当

你修改了选择符所选择的描述符后,必须对相应的选择符重新装入,这样,88 位描述符高速

缓冲寄存器的内容才会发生变化。无论如何,当选择符的值改变时,处理器自动装载不可见

部分。

下面讲一下在没有分页操作时,寻址一个存储器操作数的步骤。

(1)在段选择符中装入 16 位数,同时给出 32 位地址偏移量(比如在 ESI、EDI 中等)。

(2)根据段选择符中的索引值、TI 及 RPL 值,再根据相应描述符表寄存器中的段地址和

段界限,进行一系列合法性检查(如特权级检查、界限检查),该段无问题,就取出相应的

描述符放入段描述符高速缓冲寄存器中。

(4)将描述符中的 32 位段基地址和放在 ESI、EDI 等中的 32 位有效地址相加,就形成

了 32 位物理地址。

注意:在保护模式下,32 位段基地址不必向左移 4 位,而是直接和偏移量相加形成 32

位物理地址(只要不溢出)。这样做的好处是:段不必再定位在被 16 整除的地址上,也不必

左移 4 位再相加。

寻址过程如图所示。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

6 描述符投影寄存器

为了避免在每次存储器访问时,都要访问描述符表,读出描述符并对段进行译码以得到

描述符本身的各种信息,每个段寄存器都有与之相联系的描述符投影寄存器。在这些寄存器

中,容纳有由段寄存器中的选择符确定的段的描述符信息。段寄存器对编程人员是可见的,

而与之相联系的容纳描述符的寄存器,则对编程人员是不可见的,故称之为投影寄存器。图

中所示的是 6 个寄存器及其投影寄存器。用实线画出的寄存器是段寄存器,用以表示这

些寄存器对编程人员可见;用虚线画出的寄存器是投影寄存器,表示对编程人员不可见。

投影寄存器容纳有相应段寄存器寻址的段的基地址、界限及属性。每当用选择符装入段

寄存器时,CPU 硬件便自动地把描述符的全部内容装入对应的投影寄存器。因此,在多次访

问同一段时,就可以用投影寄存器中的基地址来访问存储器。投影寄存器存储在 80386 的芯

片上,因而可以由段基址硬件进行快速访问。因为多数指令访问的数据是在其选择符已经装

入到段寄存器之后进行的,所以使用投影寄存器可以得到很好的执行性能。

7 Linux 中的段

Intel 微处理器的段机制是从 8086 开始提出的, 那时引入的段机制解决了从 CPU 内部

16 位地址到 20 位实地址的转换。为了保持这种兼容性,386 仍然使用段机制,但比以前复杂

得多。因此,Linux 内核的设计并没有全部采用 Intel 所提供的段方案,仅仅有限度地使用

了一下分段机制。这不仅简化了 Linux 内核的设计,而且为把 Linux 移植到其他平台创造了

条件,因为很多 RISC 处理器并不支持段机制。但是,对段机制相关知识的了解是进入 Linux

内核的必经之路。

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

从 2.2 版开始,Linux 让所有的进程(或叫任务)都使用相同的逻辑地址空间,因此就

没有必要使用局部描述符表 LDT。但内核中也用到 LDT,那只是在 VM86 模式中运行 Wine 时,

即在 Linux 上模拟运行 Windows 软件或 DOS 软件的程序时才使用。

Linux 在启动的过程中设置了段寄存器的值和全局描述符表 GDT 的内容,段的定义在

include/asm-i386/segment.h 中:

#define __KERNEL_CS0x10 /*内核代码段,index=2,TI=0,RPL=0*/

#define __KERNEL_DS0x18 /*内核数据段, index=3,TI=0,RPL=0*/

#define __USER_CS 0x23 /*用户代码段, index=4,TI=0,RPL=3*/

#define __USER_DS 0x2B /*用户数据段, index=5,TI=0,RPL=3*/

从定义看出,没有定义堆栈段,实际上,Linux 内核不区分数据段和堆栈段,这也体现

了 Linux 内核尽量减少段的使用。因为没有使用 LDT,因此,TI=0,并把这 4 个段都放在 GDT

中, index 就是某个段在 GDT 表中的下标。内核代码段和数据段具有最高特权,因此其 RPL

为 0,而用户代码段和数据段具有最低特权,因此其 RPL 为 3。可以看出,Linux 内核再次简

化了特权级的使用,使用了两个特权级而不是 4 个。

全局描述符表的定义在 arch/i386/kernel/head.S 中:

ENTRY(gdt_table)

.quad 0x0000000000000000

从代码可以看出,GDT 放在数组变量 gdt_table 中。按 Intel 规定,GDT 中的第一项为

空,这是为了防止加电后段寄存器未经初始化就进入保护模式而使用 GDT 的。第二项也没用。

从下标 2~5 共 4 项对应于前面的 4 种段描述符值。对照图 2.10,从描述符的数值可以得出:

• 段的基地址全部为 0x00000000;

• 段的上限全部为 0xffff;

• 段的粒度 G 为 1,即段长单位为 4KB;

• 段的 D 位为 1,即对这 4 个段的访问都为 32 位指令;

• 段的 P 位为 1,即 4 个段都在内存。

由此可以得出,每个段的逻辑地址空间范围为 0~4GB。读者可能对此不太理解,但只要

对照图 2.9 就可以发现,这种设置既简单又巧妙。因为每个段的基地址为 0,因此,逻辑地

址到线性地址映射保持不变,也就是说,偏移量就是线性地址,我们以后所提到的逻辑地址

(或虚拟地址)和线性地址指的也就是同一地址。看来,Linux 巧妙地把段机制给绕过去了,

而完全利用了分页机制。

从逻辑上说,Linux 巧妙地绕过了逻辑地址到线性地址的映射,但实质上还得应付 Intel

所提供的段机制。只不过,Linux 把段机制变得相当简单,它只把段分为两种:用户态(RPL

=3)的段和内核态(RPL=0)的段,因此,描述符投影寄存器的内容很少发生变化,只在进

程从用户态切换到内核态或者反之时才发生变化。另外,用户段和内核段的区别也仅仅在其

RPL 不同,因此内核根本无需访问描述符投影寄存器,当然也无需访问 GDT,而仅从段寄存器

的最低两位就可以获取 RPL 的信息。Linux 这样设计所带来的好处是显而易见的,Intel 的分

段部件对 Linux 性能造成的影响可以忽略不计。

在上面描述的 GDT 表中,紧接着那 4 个段描述的两个描述符被保留,然后是 4 个高级电

源管理(APM)特征描述符,对此不进行详细讨论。

按 Intel 的规定,每个进程有一个任务状态段(TSS)和局部描述符表 LDT,但 Linux 也

没有完全遵循 Intel 的设计思路。如前所述,Linux 的进程没有使用 LDT,而对 TSS 的使用也

非常有限,每个 CPU 仅使用一个 TSS。

通过上面的介绍可以看出,Intel 的设计可谓周全细致,但 Linux 的设计者并没有完全

陷入这种沼泽,而是选择了简洁而有效的途径,以完成所需功能并达到较好的性能为目标。

每日分享15分钟技术摘要选读,关注一波,一起保持学习动力!

深入分析Linux内核源代码-Linux 运行的硬件基础(下)

继续阅读