简单理解内存地址
简单整理内存地址的一些概念,方便后续深入了解,硬件平台是经典的 x86,其他平台可能存在出入。
物理地址
物理地址是加载到内存地址寄存器中的地址,是内存单元的真正地址,与和 CPU 相连接的地址总线相对应。在前端总线(连接 CPU 与北桥,很早之前就已经被淘汰了)上传输的内存地址都是物理地址,编号从 0 开始一直到可用的物理内存的最大值,这些值被北桥(现在也被淘汰了,北桥是以前的内存控制器,现在的内存控制器都集成到了 CPU 内部)映射到实际的内存条上。实际上我们可以将物理内存看作是一个从 0 字节一直到最大容量的逐个字节编号的大数组,虽然不准确但是容易理解。
虚拟内存
与物理地址对应的真实内存不同,虚拟内存并不是真实存在的,现代操作系统都提供了一种内存管理的抽象,进程使用的虚拟内存的地址会由操作系统与相关硬件协作,转换成真正的物理内存地址。有了这种抽象,一个程序才可以使用比真实物理地址大得多的地址空间。
逻辑地址
逻辑地址指的是机器语言执行中,用来指定一个操作数或者是一条指令的地址,比如说:mov 0x80495b0, %eax
这条指令,其中的 0x80495b0
就是一个逻辑地址。在段式内存管理中比较完整说法是,逻辑地址应该由一个段选择符加上一个段内偏移量,表示为段选择符:段内偏移量,比如上面的逻辑地址应该表示为 代码段选择符:0x80495b0
。
实模式下的逻辑地址与保护模式下的逻辑地址虽然都是使用段(segment):偏移量(offset)表示,但是在保护模式下,段的概念发生了根本性的变化。在实模式下,段值还是可以看作最终地址的一部分,比如段值 xxxxh 表示以 xxxx0h 开始的一段内存;而在保护模式下,虽然段值仍然使用原来的 cs、ds 等寄存器存储,但此时它的值仅仅变成了一个索引,这个索引指向了一个数据结构的表项,表项中详细定义了段的起始地址、界限、属性等内容,这个数据结构就是 GDT(全局描述符表),也有可能是 LDT(局部描述符表)。
线性地址
线性地址其实就是段基址加上段内偏移量。可能你会诧异,这不就是逻辑地址吗?我的理解是,其实逻辑地址真正的值是段内偏移量部分,段选择符:段内偏移量这种只是逻辑地址的表示形式。
逻辑地址是段式内存管理中进行转换前的地址,而线性地址是页式内存管理中转换前的地址。CPU 将一个虚拟内存空间中的地址转换为物理地址,需要经过两步:首先给定的一个逻辑地址(其实是段内偏移量),CPU 利用其段式内存管理单元将逻辑地址转换成一个线性地址,接下来需要再利用其页式内存管理单元,将线性地址转换成最终的物理地址。这样转换确实很麻烦,完全可以直接将线性地址交给进程,intel 之所以这么做,完全是为了兼容以前的段式内存管理方式。
需要说明的是,如果没有开启分页功能,那么线性地址其实就是最终的物理地址;如果开启了分页,则需要再将线性地址经过页式内存管理单元转换成最终的物理地址。
段式内存管理
逻辑地址转线性地址
严格意义上讲,实模式下不存在段式内存管理,段式内存管理出现在保护模式下。
一个逻辑地址由两部分组成,段选择符:段内偏移量。段选择符由一个 16 位长的字段组成,其中 13 位是一个索引号,后面 3 位包含一些硬件细节,其中 TI(Table Index)是表指示器,RPL(Requested Privilege Level)是请求特权级。
段选择符中的索引号指向的是段描述符表中的表项,这个段描述符表是一个简单的数组,数组元素长度为 8 个字节,每个元素描述一个段。
在这里我们可以只关心一个字段,那就是 Base 字段,它描述的是一个段的开始位置。Intel 设计的本意是,一些全局的段描述符,就放在 GDT 中,一些局部的,比如说每个进程自己的,就放在 LDT 中。这个可以通过段选择符中的 TI 字段表示。当 TI 为 0,表示用 GDT;当 TI 为 1,表示用 LDT。
GDT 在内存中的地址和大小存放在 CPU 的 GDTR 控制寄存器中,而 LDT 则保存在 LDTR 寄存器中。下面给出一张逻辑地址转线性地址的过程图解(只有 GDT 的过程,LDT 类似):
- 首先需要给定一个完整的逻辑地址,包含段选择符和段内偏移地址。
- 然后查看段选择符的 TI 字段的值,确定当前要转换的是 GDT 中的段还是 LDT 中的段。找到对应的寄存器,从寄存器中取出 GDT 或者 LDT 的内存地址和大小。
- 根据段选择符的索引值查找 GDT 或者 LDT 表,找到对应的段描述符,这样就拿到基地址了。
- 将 Base + offset,就得到了线性地址了。
Linux 的段式管理
Intel 要求进行两次转换,这样做虽然兼容,但是却很冗余。某些硬件平台并没有二次转换的概念,因此 Linux 需要提供一个高层的抽象来提供统一的界面。
按照 Intel 的本意,全局的用 GDT,每个进程自己自己的用 LDT。但是 Linux 则对所有的进程都使用了相同的段来对指令和数据寻址,即用户代码段、用户数据段,内核中的是内核代码段、内核数据段。查看 Linux 内核关于 i386 架构的段定义(include/asm-i386/segment.h):
1 |
将其中的值计算得出:
1 |
这样可以计算出索引值和 TI 字段的值:
1 | __USER_CS index = 14 TI = 0 |
TI 均为 0,表示都使用了 GDT,再查看初始化 GDT 的内容中对应的 12 到 15 项(arch/i386/head.S):
1 | .quad 0x00cf9a000000ffff /* 0x60 kernel 4GB code at 0x00000000 */ |
将它们的值展开,发现第 16 到 31 位全是 0,即这四个段的基地址都是 0。按照地址转换公式,0 + 段内偏移得到线性地址。因此可以得出一个重要的结论,在 Linux 下,逻辑地址与线性地址总是一致的(是一致,不是相同)。
在 x86 体系中,分段机制是必选的,而分页机制可由具体的操作系统选择,Linux 通过让段的基地址为 0 而巧妙的绕过了基地址,在 32 位平台上,线性地址的大小固定为 4GB,由于采用了保护机制,Linux 将这 4GB 分为了两部分,地址较高的 1GB(0xC0000000 到 0xFFFFFFFF)为共享的内核空间;而地址较低的 3GB(0x00000000 到 0xBFFFFFFF)为每个进程的用户空间。