写点什么

【精通内核】计算机内存地址原理深度解析

  • 2022 年 8 月 13 日
    上海
  • 本文字数:5420 字

    阅读完需:约 18 分钟

前言

📫作者简介小明java问道之路,专注于研究计算机底层/Java/Liunx 内核,就职于大型金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的架构设计📫 

🏆CSDN 专家博主/Java 领域优质创作者、阿里云专家博主、华为云享专家、51CTO 专家博主🏆

🔥如果此文还不错的话,还请👍关注、点赞、收藏三连支持👍一下博主~


本文导读

内存在程序、Linux 已经计算机中占有重要地位,本文深度解析计算机内存地址的原理,通过编译时的内存原理,深入浅出逐步讲解物理地址、虚拟内存、分段分页原理、线性地址,以及 intel 对内存操作和原理解析。

一、内存寻址

1、内存在编译链路的作用

计算机内存,这里面我们说的是 main memory(DRAM)主内存,语言通过 IDE(Integrated Development Environment )中编写,通过编译器变成 CPU 能工作执行的格式来执行。这些编译好的数据就保存在磁盘上(local disks),然后我们用过系统调用告诉操作系统(OS),由操作系统来获取编译好的数据并进行解析,将这些数据从磁盘加载到内存(DRAM)。编译器就是一个具有前端和后端的整套,将一个语言(源语言)编译为目标语言的工具。

计算机组成原理的一些知识,总线控制总线(CB)、地址总线(AB)、数据总线(DB)CPU 位数:CB 控制的种类的 2^nAB 地址数量的 2^nBD 一次传输的位数,CPU 通过总线来操作内存

2、内存地址

内存要有地址内存的最小寻址单元 1 byte,采用 16 进制编码地址信息为了避免表示二进制的位数过多。由于对地址进行了编码,所以内存地址存在高低之分(高地址、低地址)。使用 16 进制给内存中的每个字节编号,这个编号是内存地址。

内存地址流转

编译器将代码翻译为目标语言(汇编语言),此时汇编语言的指令,保存在内存上,内存地址是 16 进制的,每个汇编指令都对应一个 16 进制的内存地址编号,汇编指令操作外部内存(例如 OS 内存中的栈内存、堆内存、数据内存、代码段内存,其中数据、代码段内存是静态分配的,堆栈内存是动态分配的,如下图所示,指令流存在于代码段内存中,数据就存在代码段内存中),这个时候 CPU 控制操作系统分配内存,将指令流通过静态分配保存在内存中。

OS 内存原理

二、物理地址的缺陷

读者如果对于地址空间的概念感到模糊,可以假定地址空间是 Java 语言中的一个数组,数组的每一项大小都是一个 byte,即 8 位。用 Java 语言来描述的话,就是定义

byte memory[] = new byte[4*1024*1024]。
复制代码

前面我们看到了在 8086 时代的内存寻址,其实就是通过段寄存器生成 20 位的地址进行访问,此时访问的地址就是物理地址,这是真实的地址。但是随着时间的推移,到后面我们可以使用的真实物理地址范围从 20 位到了 32 位,即 4GB 的地址空间,再往后面的 48 位(intel 在 64 位机上的地址总线最大只有 48 位),我们来看 4GB 的内存空间。

假设有 A、B、C,3 个程序,各需要占用内存 512MB,所以总占用内存就是 3*512=1546MB。同时,操作系统还需要一定占用内存,假定要占用 1GB,那么问题来了,如果我们要直接操作物理内存且开发 A、B、C 程序的是不同开发人员,我们该怎么做?首先,需要清楚 A、B、C 各占用了哪一段地址空间。假设 A 占用 0~512MB,B 占用 512MB~1024MB,C 占用 1024MB~1546MB,操作系统占用最高的 3GB~4GB,好像直接操作物理地址就可以完成程序的执行了。但是,这样会有很大的问题,具体如下。

1、A、B、C 程序和操作系统占用的地址空间不可能一开始就商量好;而且这只是 3 个程序,如果有更多程序呢,显然这个设想不现实。

2、需要把程序中所有的代码和数据都加载到内存中,比如 ELF 文件的所有段内容都会被加载(即使应用程序可能根本不需要这些数据,或者写了一个方法不被调用,也需要被加载)

3、如何保证 A、B、C 程序和操作系统中的程序、数据不会彼此踩踏呢?假设 A 是个黑客程序,它要修改 OS 或者其他程序的数据和代码,该如何保证系统安全呢?

正是因为以上问题的存在,人们发现,直接操作物理地址根本是不现实的。

所以,只有在计算机启动时,为了兼容 8086,OS 会直接操作物理地址。这种开机直接操作物理地址的模式,被称为实模式,即直接操作 8086 的那几个段寄存器来生成 20 位真实物理地址,以完成访存操作。OS 完成初始化后,就会进入保护模式下。在保护模式下,引入了虚拟地址的概念。

三、虚拟内存

1、虚拟内存映射关系

读者可以尝试通过 gcc(GNU Compiler Collection)编译后的 ELF 文件的虚拟地址都是一样的,这里面有个问题,这些地址都一样,难道不会导致多个程序的地址冲突吗,答案肯定是不会的,本节就会解析虚拟地址、线程地址、物理地址之间的关系和区别。程序员面向的是虚拟地址编程,而线性地址和物理地址是接触不到。

仍然以 4GB 内存,以及 A、B、C,3 个需要 512MB 的进程和操作系统 OS 为例,地址空间上面说就是一个 byte 数组,大小是 4GB,需要访问这个数组时怎么做?

很明显,通过索引下标 i 即可。如果要访问一个区间呢?可以给出两个索引,即 start 和 end。程序 A、B、C 都拥有这样一个 start=0GB、end=4GB,的索引下标,都认为自己拥有了整个 4GB 内存。它们可以随便使用 start、end 下标,但当它们真实访问物理内存时,CPU 会将 start、end 转变为空闲的物理内存区间。比如,程序要访问 start=0MB-end=512MB 的地址空间,CPU 可把它们映射为物理地址,即 start=512MB-end=1024MB。程序自己用的这一段 start 和 end 的索引下标空间,被称为虚拟地址空间;而真实的 start 和 end 的索引下标空间,就是物理地址空间。

将虚拟地址空间映射到物理地址空间的工作由 CPU 完成,它是怎么做的呢?读者可以想想,什么结构能满足映射关系呢?当然是表结构。毕竟程序员常说查表。我们称保存这种映射关系的表叫作段描述符表

2、内存内碎片

问题又来了,查表需要一个 key,没有 key 怎么查 value 呢?再来看看这个 key 是怎么保存的。在实模式操作物理地址时,我们有几个段寄存器,可以通过 CS:IP 来获取需要执行的指令。进入保护模式后,就不再使用 CS 左移 4 位+IP 地址 来获取真实物理地址了,CPU 会改变寻址方式。怎么做呢?以 CS 寄存器作为段选择子,也就是上述的 key 查段描述符表,这时找到的表项里就包含了这个映射地址和范围了。

寄存器类型

现在我们知道,之前实模式使用的段寄存器,由于保护模式的引入,变成了用于查表的段选择子。现在又出现一个问题,key 是有了,表在哪里呢?很明显表保存在内存中,因此需要一个寄存器来保存表的首地址和长度,这个寄存器被称为 GDTR,即 全局段描述符表寄存器

实模式寻址原理

现在,有了 key 和表,就可以查表了。好像也没解决什么问题?其实问题已经解决了。

试想,如果把程序分成一段一段的,同样映射到的物理地址也是一段一段的,不需要的不映射,是不是可以节约很多空间?由于建立段描述符表是 OS 来操作的,因此也会保证程序之间的段不会发生踩踏,由 OS 来保证。

又有了一个新问题,这个段多大合适?1MB?10MB?

假如分得太大了,如 10MB,将会发生这样一种情况。例如在当前地址空间 0~50MB 中包含了 4 个占用 10MB 内存的程序,地址被划分为 0~10MB(A 程序)、10MB~20MB(B 程序)、20MB~30MB(C 程序)、30MB~40MB(D 程序)、40MB~50MB(空闲)。

如果 B 程序用完这段内存,释放了它,则 10MB~20MB 处于空闲状态。如果这时有一个 20M 的 E 程序 申请内存,虽然内存中包含 10MB~20MB(空闲)和 40MB~50MB(空闲),共 20MB 的空闲空间,但由于它们不连续,因此不能进行分配。这种两个程序之间造成的内存空洞,叫作内存外碎片。继续分析,如果新申请内存的 F 程序 数据不满 10MB,也只能一次性申请 10MB 内存。例如需要用 5MB,但却分了 10MB,其中的 5MB 就浪费了。这种程序内申请的内存和没有用完的空间,叫作内存内碎片

四、intel 分段寄存器原理

1、intel 分段描述

接下来一起看一个图,intel 给出的分段详解图

CS、SS 等段寄存器被称为段选择子。在段描述符中查找段寄存器对应的段描述符,可发现包含有访问权限位(access)和段基址(base address),同时还需要段界限(limit)来指明这个段的段长是多少。读者可能会看到对时应物理地址(physical memory)有一个 or 关键字,居然出现了我们要讲的线性地址空间(linear addre ess space)。因为涉及下面要讲的分页,所以读者直接把通过段描述符查询出来的地址当作物理地址就好。

intel 分段描述

2、intel 分段生成物理地址

这种查段表最终生成物理地址的原理,如下图

我们通过 16 位段选择子在段表中获取到了段描述符和待访问地址(如 IP 寄存器中的地址)相加,便得到了物理地址。读者一定要掌握,通过段表得到的基地址和段界限,仅仅用于描述一个内存段;我们需要访问其中的数据,就需要偏移量参与。

intel 分段生成物理地址原理图

五、intel 分段寄存器原理

1、16 位段寄存器信息原理

1.1、16 位段寄存器解析

一起来看看这些 16 位段寄存器中保存了哪些位,以及分别起到了什么作用。图 1-13 描述了用于查找段描述符表中对应项的 key,即段选择子子内容。

它包含 3 个 get 部分:

1、index 索引(13 位):段描述符表中的下标。

2、table indicator 表项类型(1 位):GDT 表明当前程序段就是全局描述符表(global descripes table);LDT 表明当前 GDT 中的表项指向的程序段是局部描述符表(localdescriptor table)。 

3、requested privilege level(2 位):即 RPL,特权位。用于校验当前程序的执行权限,正是由于这个权限位的保护,因此应用程序和 OS 才不会发生踩踏。

16位段寄存器信息描述

对于 index,这里把它当成数组索引下标即可。可以看到它的大小为 13 位,所以查询的表项最大只能拥有 2^13=8192 个,局限了系统的最大进程数。对于 table indicator 来说,可以这样理解,由于 GDT 的表项有限,即多个程序共享,而每个程序都拥有自己的数据段、代码段、堆栈段等,所以可以利用 LDT 把它们封装在一起进行访问,这样一个程序只需要占用一部分 GDT 表项。那么问题又来了,引入的 LDT 也是一个表。怎么查询这个表呢?同样需要 key。这时的 key 由谁来提供呢?仍然是段选择子。如果使用 GDT,可用 CS、DS 等寄存器获取到的段选择子查 GDT 表项。引入了 LDT 后,我们的段选择子可用于查 LDT 的表项。LDT 的基地址在哪?在 GDT 中,谁来查?这时需要引入一个新的寄存器 LDTR,用它作为段选择子,并前往 GDT 中查询,就可以得到 LDT 的基地址了。

1.2、查询 GDT(全局描述符表)

1、获取 ES、DS、SS、CS 等寄存器的高 13 位作为段选择子。

2、根据 GDTR 寄存器获取到全局段描述符的基地址。

3、根据获取的段选择子到 GDTR 所指的全局段描述符中查找表项。

4、根据获取的表项获取到程序段的基地址。

5、根据获取的程序基地址+IP 或者其他寄存器的偏移地址就可以获取到物理地址。

1.3、查询 LDT(局部描述符表)

1、根据 LDTR 的高 13 位作为段选择子到 GDTR 寄存器的 GDT 中查询表项。

2、根据查询的表项获取到 LDT 的基地址。

3、获取 ES、DS、SS、CS 等寄存器的高 13 位作为段选择子。

4、根据段选择子到 LDT 中查询对应的表项。

5、根据 LDT 的表项获取程序的段基地址。

6、根据获取的程序基地址+IP 或者其他寄存器的偏移地址就可以获取到物理地址。

2、64 位段寄存器信息原理

段描述符中存在那些信息

64位段描述符信息

六、分页原理

1、Intel 分段分页原理

在了解了内碎片、外碎片、段选择子、段描述符后,我们可以开始进一步讨论。回到刚才的问题,段应该分多大才算合适?不同的程序,情况不一样。如果分得太小,就减少了内碎片,如果分得大了,就增加了外碎片,并且程序的段都是连续的,这意味着需要分配的内存是连续的。这明显不太合理。有没有进一步的解决方案呢?当然有,那就是分页。

分页的基本信息表

不确定分多大,那就直接把 4GB 的内存空间划分为 4KB 一格,称为页框,同样程序的段信息(代码段、数据段)也分为 4KB 一个页框。然后程序的段信息和物理内存的页框一一对应即可,这样极大地减少内碎片的发生。

由于 intel 最开始只支持分段,后面引入的分页也需要先分段,再分页,

intel 分段分页原理

从上图中可以看到,开启分页后的 intel 寻址分为两步:

1、根据段选择子和偏移量生成线性地址(linear address)。

2、根据生成的线性地址分页,获取真正的物理地址(physical address space)。

同分段一样,用 SS、DS、CS、ES 段选择子和 GDTR 寄存器保存段表的地址。同理,也需要页表(page table)和 key 来查询程序中这一页对应于物理地址的哪一页。

如何查表?查表的 key 在哪里很明显,物理内存被分成了 4KB 一个页帧,那么 4GB 就共有 4*1024*1024/4=1MB 个表项。假设一个表项的大小为 4byte,即 32 位,那么最直接的做法就是为每个程序都保存在一个这样的页表中,需要用分段获得的线性地址作为 key,前往表中查询对应的物理页框。这时,一个程序拥有的页表大小为 1*1024*1024*4byte=4MB 大小。

2、Intel2 级分页原理

可见当程序变多后,内存几乎都被用于保存页表,这自然也不可取有没有什么办法优化呢?程序一定会用完所有的 1M 大小的表项吗?显然不可能因为程序在运行中可能由于一直没有访问某些指令和数据,导致它们的那几页没有映射到物理内存。所以根本没有必要为这些不用的页保存空的页表项。进程的这种行为,我们称之为稀疏存储。正由于这种稀疏存储的性质,我们采取了分级的页表。

intel 2级分页寻址原理

如段描述符表的起始地址被保存在 GDTR 中一样,页目录表的地址也被保存在 CR3 寄存中。线性地址的查询页表基地址的 DIR 部分为 10 位,Table 用于查询页表项的部分为 10 位,用于查找物理页框偏移量部分为 12 位。

这样的存储,必然极大地节约空间。程序没有必要建立所有页表,使用时再在页目录和页表中创建页表项即可,没必要一开始就创建 4MB 的连续空间。

总结

本文的主题虚拟地址、线性地址、物理地址到底是什么,通过 intel 开发手册了解其原理,顺着这个思路,已足够让读者了解计算机的内存空间了,相信读者对于更深层次的内容也能自己探究完成。

发布于: 刚刚阅读数: 6
用户头像

🏆博客专家 2020.03.20 加入

🏆 CSDN专家博主/Java领域优质创作者、阿里云专家博主、华为云享专家、51CTO专家 📫就职某大型金融互联网公司后端高级工程师 👍专注于研究计算机底层/Java/架构/设计模式/算法

评论

发布
暂无评论
【精通内核】计算机内存地址原理深度解析_内存_小明Java问道之路_InfoQ写作社区