引言
人类总是不断进步,在此过程中呢,不断遇到问题,解决问题。内存的寻址过程在计算机技术衍进的浪潮中,亦不断遇到问题,不断改进,为了以前的金主爸爸,小心谨慎的兼容着过去的设计,导致现在的寻址复杂性,一两句话还真说不清楚。当然,如果你都明白了,那大神完全可忽略以下内容。
直入主题,今天我们从不同视角来看内存的寻址。我会尽量避免晦涩术语,转而用白话描述。
在讨论寻址之前,假定我们都有这样一个常识:
内存中的最小存储单元是 位(bit)。8个 bit 组成了一个 字节(byte),内存中,所有的字节都是连续(线性)增长的,给每个字节一个编号,这个编号可以唯一定位到这个字节,我们称 ”编号” 为 “地址”。
程序中的地址
我们知道,要运行一个程序,需要先把程序加载到内存中,包括程序的指令和数据。
程序中的某一句指令,可能需要访问其他指令和数据的地址,比如 goto 跳转到另一个地址开始执行指令,比如访问一个全局变量所在的地址,获取全局变量的值。
那在程序的指令中,如何表达要访问的地址呢?
硬编码地址
最简单能够想到的方式是,直接写死内存中某个字节存储区域的地址值。由于是物理硬件内存的绝对地址,我们称之为物理地址。这种直接在代码中硬编码物理地址的方式,可以吗?
从实现的可行性上看,这显然是可以的。
但是这样做的后果是,你要访问的代码和数据,必须在程序加载的时候,加载到你硬编码地址的对应位置,稍有偏差,你代码要访问的地址,便不是你心里的那个 “它” 了。
程序只能安装到指定的地方,可以吗?
从实现的可行性上看,这显然也是可以的。
但是这样做,你会发现,假设你要安装 程序A,A程序员要加载到固定地址3,B程序员也需要加载在固定地址3? 3是物理地址,3只有1个,不能一3侍俩猿。
简单总结:采用硬编码地址,我们会发现它有俩个问题,其中问题2是致命的
1. 程序只能加载到指定地址的地方
2. 加载多个程序的时候,会存在地址冲突,那同一时间加载到内存的,可能只有一个程序,这意味着同一时间,内存中只能运行一个程序,这肯定不可接受,必须改进
来张图感受下:
偏移地址
既然程序加载到固定的地址会有致命问题,那只能考虑让程序加载到随机的地方。作为程序员来看,如果程序加载到随机的地方,代码和数据的地址都是在运行时动态随机决定的,那代码中的地址表达便不能在采取硬编码,那怎么办呢?
以最简单的方式来思考,我们把程序中所有的代码、数据按照一个个字节连续,顺序的排开,这样,不管程序加载到什么地方,所有代码和数据的相对于程序最开始的偏移字节数总是固定不变的。因此可以将要防伪的地址表达为
访问程序内的代码或数据的地址 = 程序加载的起始位置 + 相对于程序开始处的第几个字节
可能我们写代码的时候没有关注过这个偏移地址,那是因为这不是让我们干的事情,这是编译器帮我们干的事情,编译器对程序进行编译,对代码和数据进行了“排序”, 确定了程序中代码和数据的相对位置。
因此 相对于程序开始处的第几个字节 这个在编译完之后就能确定, 程序加载的起始位置在程序加载的时候就可以确定。
简单总结:程序中指令或数据的物理地址可以由一个简单公式来表明:
物理地址 = 程序开始处加载到内存的地址 + 编译后的偏移量
来张图感受下:
加载多道程序
采用了偏移地址后,程序从内存哪个地方开始加载,可以用一段控制程序来调度 (操作系统),那么现在分工明确了
编译器只管将程序编译后将程序的所有指令和数据按字节 “顺序” “连续” 排列好
控制程序在加载的时候,在内存中找到一块连续足够存放程序的地方进行加载,并且 “记录“ 好程序加载的开始位置
简单总结:只要先加载控制程序到内存,再由控制程序寻找空白内存给不同程序,就可以在内存中加载多道程序
来张图感受下:
内存分段
我们可以从上图所示发现,当多个程序都加载到内存中,每个程序都占用了一段连续的内存空间,而程序的指令和数据,是提供给 CPU 进行计算和处理的,因此,在 CPU 眼中,内存是被分为一段段的。
所以, CPU 在执行程序时,对程序内的指令和数据的内存的访问方式,可以考虑以一种内存段的方式,采用这种方式需要俩个要素:
- 段的起始地址
- 段的偏移地址
这样的方式理论上是行得通的。于是 CPU 决定这么干,CPU 需要自身来支持这种工作方式,如何支持呢,且往下看。
我们需要知道,CPU 最终访问内存的地址,是由 CPU 提供最终地址并输出到地址总线上的。
因此,CPU 自己就要先知道 “段的起始地址” 以 “及段的偏移地址”,否则,拿什么输出呢?
CPU 厂家给 CPU 内制造了一些寄存器,其中某个寄存器就用来存放段的开始地址,另外某个寄存器用来提供偏移的地址,这样,就和起始地址和偏移地址形成了一一对应,在输出的时候,只需根据固定公式计算输出就好。
截止到此处,都是讨论的将一个程序当成了一个连续的段,实际上,以 大名鼎鼎的 Inter 公司生产的早期名为 “8086” 的CPU 中,就将程序分成了多个段,包括代码段、数据段等。我们这里不必过于纠结多个段对于内存访问的复杂影响,在此时只用关注 CPU 是按照 “段起始地址 + 偏移” 这种方式来工作就好了。
来张图感受下:
这里还有俩个问题:
- 段起始地址从哪里来,谁提供
- 偏移地址由谁提供
其实这俩个问题上问已经涉及,现在来详细说明
第一,段的起始地址是由控制程序即操作系统在加载程序的时候进行保存,将多个程序的段起始地址都保存在一个表中。
以控制程序 linux 为例,linux 将多程序的段的起始地址信息记录在名为 “段描述符” 表中,这块区域位于 linux 的专属内存区(内核区),在程序执行前,linux 将读取段描述符表,将要执行的程序的段开始地址设置到 CPU 的 CS 寄存器中。
如图
第二,设置好段的开始地址了,接下来就是解决偏移地址的问题,在执行程序段的指令时,指令中要访问的地址信息就是携带偏移地址的地方,也就是说,程序代码中自带了偏移地址,这个是由编译器在编译的时候指定的。当程序的指令加载到CPU中时,会将偏移信息解析出来,并设置到 IP 寄存器中,当然,只有一些特殊指令如跳转指令等会改变 IP 寄存器,其他情况下,这个 IP 会在CPU执行完一条指令后 “自增” 来指香下一条指令。
你可能会好奇,这个段描述符长什么样,这不是本文主要目的,这里就简单通过一张图来一窥表象
内存分页
有了内存分段,虽然可以加载多道程序,但是如果一个特大型程序的段太长,会产生俩个问题。
- 现有空闲内存区域不足以放下整个段
- 有必要一次加载全部段内容那内存中吗?
看来,程序的加载以段为单位还是过大了,当然,你可以分成更多更多的段,分成足够小的段,且我们希望,加载的时候,这些”小段“不必连续,且用完了可以回收再利用。
在实践中,这里有多重考虑,一是逻辑上更简单,二是基于性能的考虑。
基于以上等因素的考虑,linux 采用等长的段大小 (计算更简单,逻辑更简单),且将这种细化的段的默认值设置为 4KB 大小。
这种等长且如此细粒度的段,linux将其称之为 “页“
linux 认为,程序不需要将所有的页全部加载到内存,需要访问某页的时候,再去加载对应的页就好了,这样一来,操作系统中可以加载很多很多很多程序。
现在,我们来看看页的好处:
- 可回收,可复用
- 粒度足够小,足够灵活
- 可离散、不连续
- 提供了一种”缺页异常处理“机制--- 可以实现延迟加载对应的页
分页带来的好处我们已经可以清楚的看到,那么分页是如何来实现的呢?
我们最开始说过,物理内存空间是被硬件统一编址为一块从0开始增长的线性地址,现在,linux 需要做的,就是将这些逻辑上的“页” 映射到物理的线性空间里。可以这样去解决这个问题:
- 操作系统将物理内存划分为 4kb 为一页,这样,整个 4GB内存,总共有 1k 页,可以用一张表记录这1k页,是否被使用。
- 程序中的访问的地址,若是4个字节来表示,4个字节最大值为4G,因此,采用4个字节来表示地址的时候,程序最大可访问的空间就是4GB,将地址0到程序可以访问的最大地址值这段空间理解为虚拟的地址空间,那么要做的就是,将程序的虚拟地址空间映射到一个个物理页上。
- 程序记录自己的页使用情况,用一张表记录,最终,记录的是,程序的虚拟地址空间的虚拟页和物理内存空间的物理页。
图中,进程P1的虚拟 的A页 映射到 物理页A上,P2 的虚拟A页,在映射的时候,发现物理内存页A已经被使用,则继续找,找到了空间的物业页B,占用并记录为已使用状态。
分页寻址
进程将自己存的页面地址设知道CPU的控制寄存器 CR3中,由之前讲到的段映射出来的地址,作为分页部件的输入,用来查询段表目录和段表的索引偏移位置,最终通过多级表查询计算出的地址,就是该虚拟页对应的物理页中的地址。
这里有一个多级页表(页目录【一级页表】 + 二级页表)的概念,为什么这么设计呢,其实是为了减少内存,因为我们知道虚拟内存的页其实可能最终也不会映射到物理内存页中,因为用不着,所以用一张表就是浪费,我们可以理解成一维数据中,如果大量用不着,就很浪费空间,但是二维数组,一维中某些元素的第二维可以不存在,这样节省了大量空间。
地址转换总览
最后,在整体上看看这个过程吧