IA-32保护模式下的内存寻址方式(一):分段

版权声明:本文为博主原创文章,转载请注明出处。 https://blog.csdn.net/Apollon_krj/article/details/79504452

1、简介:

在基于Intel 80x86微处理器的平台上,内存寻址是内存管理最重要的一部分内容。而关于内存地址在实际的软件及硬件实现上,也出现了不同的表示方式:逻辑地址(logical address)、线性地址/虚拟地址(linear address/virtual address)以及物理地址(physical address)。其中:

①逻辑地址为“16位段地址:偏移地址”的表示方式;
②线性地址是一个“32位无符号整数”,可表示4GB大小内存;
③物理地址则是内存芯片(主存)“RAM的实际地址”,由CPU地址总线的高低电平来寻址。

(1)实模式与保护模式:
在8086处理器上,地址总线有20位,而寄存器只有16位,无法直接存放20位内存地址。所以采用段地址*16+偏移地址(段地址存放在段寄存器中,则2^16 × 16 = 2^20)的方式来表示内存地址。这种对内存进行寻址的模式称为实模式

从80286微处理器开始导入了保护模式,保护模式下内存寻址采用32位段和偏移量,最大寻址空间4GB,最大分段4GB (Pentium Pre及以后为64GB)。80286虽然引进了保护模式,但是也存在实模式,即向前兼容:电脑开机后处于实模式,BIOS加载主引导记录以及进行一些寄存器的设置之后就进入保护模式(CR0寄存器,PE=0实模式;PE=1保护模式)。保护模式相对于实模式最大的变化就是内存寻址方式的改变。从80386开始在保护模式下CPU可以进入虚拟8086方式,这是在保护模式下的实模式程序运行环境。而IA-32为Intel Architecture 32-bit简称,即英特尔32位体系架构,也是在386中首先采用。今天我们就来学习IA-32保护模式下的内存寻址方式之分段。

更多关于实模式、保护模式以及虚拟8086模式的介绍,可参考:CPU 实模式 保护模式 和虚拟8086模式

(2)分段与分页:
许多人都听说过内存分段内存分页,那么二者区别在哪,又有什么联系?举个例子:DS:[0X12345678]中DS即所谓段寄存器,其中存放着段地址,但是DS只是某一个数据段,并不代表所有段。内存寻址对指令、数据等进行寻址,则需要寻找不同段(代码段、数据段等),而分段的段就可以认为是这个段。分段指的是逻辑地址到线性地址的转化,分页则是指线性地址到物理内存的转化映射。另外,内存管理单元MMU(Memory management unit)也是名声在外,而MMU分为分段单元(Segmentation Uint)和分页单元(Paging Unit),且内存地址进行转换时先进行分段转换,再进行分页转换。可以理解:分段单元是将逻辑地址转换为线性地址,而分页单元将线性地址转换为物理地址。 MMU如下图所示(图片来自《Understanding the Linux Kernel》):

这里写图片描述

2、GDT与段描述符:

上面说到在虚拟内存中,虚拟内存共有4GB大小,这些内存分为许多不同的段,用以存放不同的信息,比如:存放用户程序指令的用户代码段、存放内核程序指令的内核代码段、存放用户数据的用户数据段、存放内核数据的内核数据段、存放进程上下文中处理器寄存器内容的任务状态段等,而这些不同的段是如何组织在内存中的,在需要使用时又是如何找到的?

首先,不同的段要想找到,必须得有描述这个段的一些具体信息,如:段的基址、大小、属性、操作权限等。存放这些信息的结构叫做:段描述符(Segment Descriptor),一个段描述符有8个Byte;而将所有段的段描述符组织起来的数据结构称为全局描述符表(Global Descriptor Table,GDT)或局部描述符表(Local Descriptor Table,LDT);那么GDT和LDT就相当于一个数组了。其中GDT只定义一个,每个处理器有其一个副本,副本中只有少部分内容不同;LDT则是用来存放每个进程的附加段的信息,至于GDT副本与LDT暂时先不用管,不影响对于分段的理解。

我们可以根据GDT中不同的段描述符的信息找到相应的段,但是GDT(LDT)又是如何找到的?这个不用担心,GDT的线性地址存放在gdtr寄存器中(LDT地址在ldtr寄存器中),只要用相应指令读取gdtr寄存器就可获取到GDT在内存中的位置(注意:GDT和LDT存在于物理内存中,而gdtr中的线性地址只不过是映射后的地址;另外一点其实基本大多数运行中所需的数据、数据结构都在内存中,否则就在其它真实存在的存储器中,而不会说是在虚拟内存中,因为虚拟内存本身不拥有空间)。gdtr共48bit,分为32bit的基址和16bit的界限,如下所示:

这里写图片描述

处理器一上电或复位,基地址就被设为缺省值 0,表界限设为FFFFH。作为处理器进入保护模式初始化过程的一部分,一个新的基地址必须装入 GDTR。计算得知GDT的界限为2^16=64K,而一个段描述符大小为8Byte,所以GDT表最多可以存放64KB/8B=8192个段描述符(但实际最多只能存放8191个,因为GDT第一个项总是设为0,并且在不同系统的实现中,表界限都不同)。

LGDT 和 SGDT 指令分别用来装载和保存 GDTR 寄存器。虽然SGDT是为系统软件提供的,但是可以在用户态使用,将读出的寄存器内容写入缓冲区。

我们可以在Windows XP上进行测试,结果如下:
这里写图片描述

或在Linux(Redhat6.4;2.6.32-358.el6.i386)上读取GDTR结果如下:

这里写图片描述
关于Windows的资料有限,但是在Linux2.6内核中,比如linux-2.6.11.10内核。我们在/linux-2.6.11.10/arch/i386/kernel/ 目录下,可以查看head.S文件,其中对于GDT的定义(ENTRY(cpu_gdt_table)),其中有32个项(32*8=256=0XFF+1),正好与我们测试的Limit结果相同(0~0XFF)。

接下来,我们对GDT中的元素:段描述符8字节的结构进行具体的分析,分析完毕后我们就能知道一个段是如何被找到的。格式如下:
这里写图片描述

段描述符共8字节,图中所示分高32位和低32位。具体字段,我们一一来分析:

①Limit:段的大小。共32bit,我们从上图却只能找到20bit(低4字节的0~15位和高4字节的16~19位),这是因为另外12bit信息由G字段来决定。

②G:粒度(granularity)。只占用1bit,当G=0,则段以1Byte为单位;当G=1,则段以4K为单位。结合:Limit字段,当G=0,以字节位单位,则Limit的20bit信息之前再补12个0(即(DWORD)Limit & 0X000FFFFF);当G=1,以4KB为单位,则Limit的20bit信息之后补12个1(即((DWORD)Limit << 12) | 0XFFFFFFFF),这样段的大小就是32bit来表示(当G=1,测试界限是否超出时,就无需测试低12位)。

所谓向上向下拓展的意义为:
在向上扩展段中,逻辑地址偏移的范围从 0到段界限。超过段界限的偏移会导致一般保护异常(#GP)。在向下扩展段,段界限的作用正好相反;偏移的范围从段界限到 FFFFFFFFH或者 FFFFH,这由标志位 B 的值决定。小于段界限的偏移也会导致一般保护异常。减少向下扩展段的段界限将会在段地址空间的底部而不是顶部为该段分配新的内存空间。IA-32 架构中的栈总是向下增长的,采用这种机制便于实现可扩展的栈 。(该段来自《IA-32卷3:系统编程指南》的中文翻译版)

③Base:段基址。共32bit(低四字节的高16位、高四字节的低8位和高8位)。段基址应当为16位对齐的,但并非必须的,因为16位对齐可以最大化处理器性能(对IA-32来说)。

④S:系统标志。S=0表示该段是一个系统段(局部描述符表段LDT、任务状态段TSS、调用门、陷阱门、中断门、任务门)。S=1表明该段是一个代码段或数据段,具体是代码段还是数据段要看Type域(Type最高位,也是段描述符第43位)。

当S=0,表示系统段。这些描述符又可以分为两类:系统段描述符和门描述符。系统段描述符指向系统段(LDT 和 TSS 段)。门描述符它们自身就是“门”,它们或者持有指向放置在代码段中的过程入口点的指针,或者持有 TSS(任务门)的段选择子。下表列举了该段为系统段时Type域的组合说明:
这里写图片描述

⑤Type:共4bit(高4字节的8~11位),指明段(或者门)的类型,确定段的访问权限和增长方向。其中第11位(总第43位)为0时表示该段为data段;为1时,表示该段为code段。对于8、9、10位分别有其代表属性,且因第11位不同而有差别:

如果是数据段(第十一位为0):

8:A,表示是否访问过(访问过被置为1,否则为0)
9:W,表示是否可写(1表示可读可写,0表示只可读不可写)
10:E,拓展位(0表示向上拓展,1表示向下拓展)

如果是代码段(第十一位为1):

8:A,表示是否访问过(访问过被置为1,否则为0)
9:R,表示该段是否可读(1表示可执行可读,0表示只可执行不可读)
10:C,一致位(0表示非一致代码段,1表示一直代码段)

⑥DPL:描述符特权级(Descriptor Privilege Level)字段,共2bit(总第45~46位) 。用于限制该段的存取,他表示访问这个段而要求的CPU的最小优先级。因此DPL=0时(00),只能在CPL=0才能访问;DPL=3时(11),CPL=0或3都可以访问。

关于访问级别
0级即内核态,3级即用户态。虽然IA-32处理器有0~3四个状态,但是无论是Windows还是Linux都知使用了0态和3态。即DPL字段只可能为00或11,不可能为01或10(目前)。
DPL(Descriptor Privilege Level):描述符特权级,即该段特权级别(如果你想访问我这个段,你应当具有什么样的级别);
CPL(Current Privilege Level):当前特权级,即当前执行的指令所在特权级别(CPU拥有的特权级别:用户态还是内核态),CPL存放在CS/SS的后两位,为什么是CS/SS而不是DS或ES等?因为CPU执行指令,必须要读指令,实际上就是访问CS段中EIP偏移量的内存。因此,CS选择子的特权就代表了“我有多大权力去运行这段代码”,也就是当前特权级(CPL),正因为执行的代码(CODE)的特权也就代表了当前任务(TASK)的特权。至于SS,因为堆栈的特殊性,代码的执行与堆栈密切相关,特别是诸如retn之类的指令,是有可能改变程序执行流程的,因此要求堆栈具有足够的特权。 该段摘自:RPL保存在选择子里,那么CPL是保存在哪里的
RPL(Request Privilege Level):请求特权级,针对段选择子而言,可以自己指定(段选择子后面会提到)(设定什么样的权限去访问,会削弱CPL的权限)。
对于数据段的权限检查:要求CPL<=DPL并且RPL<=DPL

⑦D/B:称为D或时B取决于是数据段还是代码段,系统段该位未使用。根据段描述符所指的是可执行代码段向下扩展的数据段还是堆栈段,这个标志有不同的功能。(对 32 位的代码和数据段,这个标志总是被置为 1,而 16 位的代码和数据段,这个标志总是被置为 0)。

具体描述(该段来自《IA-32卷3:系统编程指南》的中文翻译版):

可执行代码段:这个标志被称为 D 标志,它指明段中指令的有效地址和操作符的默认位数。如果该标志为 1,则默认 32 位的地址,32 位或者 8 位的操作符;若为 0,则默认 16 位的地址,16位或者 8 位的操作符。指令前缀 66H 可以指定操作符的长度而不使用缺省长度。前缀 67H 可用来指定地址值的长度(关乎这一点,可参考Intel硬编码(一):Opcode Map、定长指令与指令前缀)。

堆栈段(SS 寄存器所指的数据段):这个标志被称为 B(大的)标志,它为隐含的栈操作(如 push、pop 和 call)确定栈指针的大小。如果该标志为 1,则使用 32 位的栈指针,栈指针放在32 位的 ESP 寄存器中;若该标志为 0,则使用 16 位的栈指针,栈指针存放在 SP 寄存器中。如果堆栈段为一个向下扩展的数据段(见下一段的说明),则 B 标志还确定堆栈段的地址上界。

向下扩展的数据段:这个标志称为 B 标志,确定段的地址上界。如果该标志为 1,则段地址上界为 FFFFFFFFH(4GB);若该标志为 0,则段地址上界为 FFFFH(64KB)。

⑧AVL标志(Available and reserved bits):总第52位,被操作系统(系统软件)使用。总第 53 位被保留,并且应该设置为 0。

有了以上分析,我们就有了段基址,可以找到一个段的起始位置;有了段界限,可以清楚段的内存范围;有了段的访问权限限制级别等。(注意:堆栈段必须是可读写的数据段。将一个不可写的数据段的选择子置入 SS 寄存器会导致一般保护异常(#GP)。那么其它一些段寄存器也有该特点,根据属性不同,一些不合法的操作就会导致一般保护异常)

3、段选择子与段寄存器:

我们一直在说GDT中可以找到想要找的段信息,但是我们突然发现了另外一个问题:我们只知道GDT的首地址(gdtr寄存器中),却不知道一个段描述符在GDT中的具体偏移地址(即不知道要找的元素在数组中的索引)。其实这个索引存放在一个叫做段选择子(Segment Selector,或被称为段选择器、段选择符等)的地方。段选择子会被存放在处理器提供的段寄存器中(DS、ES、CS、SS、 GS、FS等)。一个段寄存器大小为96bit,其中分为80bit不可见部分和16bit可见部分。结构如下:

struct SegMent{
    WORD Selector;      //16bit Selector    (段选择子,可见部分)
    WORD Attributes;    //16bit Attributes  (属性)
    DWORD Base;         //32 Base           (段起始地址)
    DWORD Limit;        //32bit Limit       (段长度)
};

从其结果可知,不可见的后三个元素都是从段描述符中获取到的,只要我们指定了可见的段选择子,不可见的部分就是固定的了(上面说GDT第一项为0,因为段选择子为0时被视为无效,索引到的GDT第一项自然无效)。那么段选择子的结构式怎样的?如下所示:
这里写图片描述
段选择子16bit共分为3部分:

index:用来索引段在GDT中的偏移量(需要乘以8,因为GDT结构中元素size=8);
TI:表格指示器,用来表明该段选择子所索引的段描述符在GDT中还是LDT中。TI=0表示在GDT中,TI=1表示在LDT中。
RPL:请求特权级,上面已经介绍过(比如,当前特权级CPL为0,即内核态;但是请求特权级RPL为3,即用户态;CPL被降低为CPL=RPL=3。如果所index的段描述符的DPL=3则可以访问,如果DPL=0则不可以访问,这就是所谓RPL会削弱CPL权限)。

4、分段单元:

前面说过,分段单元是进行逻辑地址到虚拟地址转换的工作,并且我们已经了解了转换过程中有关的寄存器和字段的作用。那么接着我们来总结一下分段单元的工作,如下图:

这里写图片描述

对上图解析(仅说地址转换,不说权限判断):

①假设我们从汇编指令中获取到一个地址,如DS:[0X12345678],那么这个逻辑地址中包含了段选择子(存放在DS中)以及段偏移地址 12345678H两部分。至于DS或其他段寄存器在不同系统中的值是多少,我们可以写一个测试程序在Linux中用GDB调试器查看((gdb)info reg )/Windwos中用集成开发环境自带的调试工具查看,比如DS的值可能为0X70或0X73但是不可能为0X71或0X72,因为RPL不可能为1和2。这里就不具体分析了。

②对DS的内容进行解析,获取到TI、RPL以及index,对index乘以8,并根据TI确定的GDT/LDT来读取gdtr/ldtr获取描述符表的首地址,进行偏移8*index便得到该段描述符所在位置;

③将定位到的段描述符的Base字段和0X12345678相加就得到了指令中的线性地址。至于线性地址到物理地址的映射,我们将在分页机制中讲到。

参考书籍资料:
《Intel Architecture Software Developer’s Manual volumn 3:System Programming》(《IA-32卷3:系统编程指南》)
Daniel, P, Bovet, &, Marco, Cesati. Understanding the Linux Kernel[M]. California:O’Reilly Media, 2005.(《深入LINUX内核》(第三版))

猜你喜欢

转载自blog.csdn.net/Apollon_krj/article/details/79504452