前言
📫作者简介:小明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控制操作系统分配内存,将指令流通过静态分配保存在内存中。
二、物理地址的缺陷
读者如果对于地址空间的概念感到模糊,可以假定地址空间是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)。因为涉及下面要讲的分页,所以读者直接把通过段描述符查询出来的地址当作物理地址就好。
2、intel分段生成物理地址
这种查段表最终生成物理地址的原理,如下图
我们通过16位段选择子在段表中获取到了段描述符和待访问地址(如IP寄存器中的地址)相加,便得到了物理地址。读者一定要掌握,通过段表得到的基地址和段界限,仅仅用于描述一个内存段;我们需要访问其中的数据,就需要偏移量参与。
五、intel分段寄存器原理
1、16位段寄存器信息原理
1.1、16位段寄存器解析
一起来看看这些16位段寄存器中保存了哪些位,以及分别起到了什么作用。如下图描述了用于查找段描述符表中对应项的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 才不会发生踩踏。
对于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位段寄存器信息原理
段描述符中存在那些信息
六、分页原理
1、Intel分段分页原理
在了解了内碎片、外碎片、段选择子、段描述符后,我们可以开始进一步讨论。回到刚才的问题,段应该分多大才算合适?不同的程序,情况不一样。如果分得太小,就减少了内碎片,如果分得大了,就增加了外碎片,并且程序的段都是连续的,这意味着需要分配的内存是连续的。这明显不太合理。有没有进一步的解决方案呢?当然有,那就是分页。
不确定分多大,那就直接把4GB的内存空间划分为4KB一格,称为页框,同样程序的段信息(代码段、数据段)也分为4KB一个页框。然后程序的段信息和物理内存的页框一一对应即可,这样极大地减少内碎片的发生。
由于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 大小的表项吗?显然不可能因为程序在运行中可能由于一直没有访问某些指令和数据,导致它们的那几页没有映射到物理内存。所以根本没有必要为这些不用的页保存空的页表项。进程的这种行为,我们称之为稀疏存储。正由于这种稀疏存储的性质,我们采取了分级的页表。
如段描述符表的起始地址被保存在GDTR中一样,页目录表的地址也被保存在CR3 寄存中。线性地址的查询页表基地址的 DIR 部分为10位,Table用于查询页表项的部分为10位,用于查找物理页框偏移量部分为12位。
这样的存储,必然极大地节约空间。程序没有必要建立所有页表,使用时再在页目录和页表中创建页表项即可,没必要一开始就创建 4MB的连续空间。
总结
本文的主题虚拟地址、线性地址、物理地址到底是什么,通过intel开发手册了解其原理,顺着这个思路,已足够让读者了解计算机的内存空间了,相信读者对于更深层次的内容也能自己探究完成。