第一讲 内存寻址

引子

一段代码:

#include <stdio.h>
int foo;
void main()
{
    foo = 100;
    printf("%d\n",foo);
}

问题:变量foo存放在内存的什么位置,printf又在什么位置,CPU如何访问(修改)它们?
答:在不同操作系统上、不同的编译器编译结果、不同硬件平台上、不同时间运行它、同一时间运行的不同进程中……变量位置都不同。

确定一个变量的位置有两个步骤,一是编译链接时期由工具链确定的虚拟地址空间的地址,二是运行时操作系统将虚拟地址映射到一个物理地址。CPU执行指令访问虚拟地址又要经过一系列过程。
从这个意义上,内存寻址涉及到处理器、编程语言、操作系统三个主题,并且三者之间是密不可分的。本讲中将首先从这三个角度依次分析,然后重点讲解Linux内存寻址机制和过程。
处理器、编程语言与操作系统

内容摘要:

编译、链接、静态链接、动态链接、重定位。
逻辑地址到线性地址转化、线性地址到物理地址转化、页目录表、页表、页框。
CRn和gdtr/ldtr等寄存器、分段单元、分页单元、缓存、主存、磁盘。

我们还省略了可执行文件的格式、操作系统加载可执行文件的过程等主题,作为进程管理中的一部分在以后分析。

生成可执行文件

从编程语言的发展角度上,确定变量地址是一个不断延迟配置的过程。
最早用机器语言开发时使用的都是绝对地址,地址值都是直接写死在代码中,如果插入一条指令那么后续指令全部需要修改。
后来发明了汇编语言,使用符号表示一个地址和指令,用汇编器计算符号地址,修正指令,但仍然是绝对地址。
再后来有模块化开发概念后,某个符号可能是另一个模块的,因此需要延迟到链接时期才能确定其地址。
有了动态链接库的概念后,某些符号可能根本不存在可执行文件内,需要运行时期在操作系统的帮助下才能确定。

我们先来关注截止到生成可执行文件时的过程。从源码生成可执行文件有四个步骤:预处理、编译、汇编、链接。

编译器经过扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化等六步,生成目标文件。目标文件中涉及其他模块符号的地址都被设置为0,等待链接过程中确定。
多个目标文件经过链接生成可执行文件。

链接过程分为静态链接和动态链接两种,在链接控制命令中确定。

静态链接过程中,每个目标文件中的各个段被提取出来合并。这个过程分两步。
第一步,空间与地址分配。扫描所有输入目标文件,获得各段长度、属性和位置,并且将输入目标文件的符号定义和引用收集起来放到一个全局符号表。
在这一步中每个段在链接后的虚拟地址已经确定,例如Linux下的可执行文件的.text段起始地址为0x08048000等。因为各个符号在段内的位置固定,因此给每个符号加上一个偏移量即可确定符号的虚拟地址。
第二步,符号解析与重定位。读取输入文件中段的数据、重定位信息,并进行符号解析与重定位、调整代码中的地址等,这是链接过程的核心。
具体是这样的:先扫描重定位表,确定所有需要重定位的符号引用位置和指令调整方式,然后利用前面确定的符号虚拟地址修正代码中的未确定地址。重定位表在ELF中是一个或多个段,一般以.rel开头,如代码段.text的重定位表存在.rel.text段。
第二步中如果发现某个引用符号在全局符号表中不存在,则会出现符号未定义的错误,这也是编译过程中最常见的错误之一。

动态链接。
静态链接原理简单,但是实现困难,原因之一是大量公共库函数在内存中将重复存在。例如Linux系统中一个普通程序会用到1MB以上C语言静态库,多个进程将造成巨大的空间浪费。
另一个原因是更新、部署和发布困难。一个细小的改动需要重新发布整个程序,浪费传输资源。
使用动态链接时,对于输入动态链接库中的符号定义,链接器将只标记,不进行重定位,将重定位过程延迟到装载时期或更晚的符号第一次被使用时。
这是一个宏大的主题,具体可以参考《链接、装载与库》一书。

最终链接完成的可执行文件格式如下图所示。
ELF文件格式

可执行文件中已经包含了静态链接后变量的虚拟地址值,以及需要动态确定地址的共享库符号,以备让操作系统为其提供地址。操作系统加载可执行文件的过程中,将解析这些符号,在加载期或运行期确定所用符号的虚拟地址,分配在虚拟地址空间上。

在Linux加载运行后的虚拟地址空间如下所示。
Linux进程虚拟地址空间

x86的寻址机制

本节我们关注CPU如何用虚拟地址确定真实的物理地址,将其放到总线上完成寻址的过程。我们以Intel的80x86系列CPU为例讲述。

处理器最早是没有虚拟地址和保护模式等概念,CPU访问内存使用是实地址、可无保护访问任意地址。这就导致了程序员必须了解平台的物理特性、谨慎的编写代码。
举例:C6678需要统计应用的空间占用需求。
最早的8086处理器使用的就是实地址。后来Intel为了解决地址宽度不足的问题引入分段机制,再后来为进一步保护数据又引入分页机制,相应衍生出MMU、CRn等寄存器和物理单元,演变为现今的分段加分页的寻址系统。如图所示。
x86地址转换过程

段式寻址

引入段式寻址的直接原因是处理器位宽的增加。
一般讲处理器的位宽,是指ALU的宽度,数据总线一般与ALU等宽。而地址总线一般也与数据总线一致。这是因为从程序设计角度,一个地址也就是一个指针,最好与一个整型长度一致。

8086和8088是16位CPU,从80386开始为32位。当初8086寻址范围64K太小,于是Intel决定将其扩展到1MB,即20位地址宽度。为此Intel发明了一种巧妙的方法,即分段。在CPU中设置了四个段寄存器:CS、DS、SS、ES,用于访问指令、数据、堆栈和其他。将内存对应划分为多段,用段寄存器配合偏移量来完成寻址。
回忆微机原理课程。在8086处理器上写汇编语言时,访问一个内存需要两步,将段地址写入DS寄存器,将偏移量写入BX,然后使用[DS:BX]组合完成寻址。这就是段式寻址。这时DS:BX的组合称为逻辑地址,寻址前经过一个分段单元的硬件电路转化成线性地址。但这种寻址模式存在安全风险,即缺少权限管理,任意进程都能访问所有地址空间。这种寻址方式称为实地址模式。

从80286开始,Intel开始实现保护模式。这种模式下写入段寄存器的不再是段地址,而是一个段描述符,多了一步转化过程。
段式寻址的进化
详细的转化过程是这样的:逻辑地址由16位段选择符和32位偏移量组成,段选择符存放段寄存器里。有六个段寄存器,分别是cs,ss,ds,es,fs和gs。每个段选择符有一个TI位表示是哪个描述符表,有13位索引号字段表示是段描述符表中的哪一个,还有RPL位表示访问权限。
段选择符

有两类段描述符表:全局描述符表GDT或局部描述符表LDT,每个描述符表有多个段描述符。GDT的地址和大小在gdtr控制寄存器定义,当前使用的LDT地址和大小在ldtr控制寄存器中定义。
每个段描述符8字节,表示一个段。有几种不同类型的段和描述符,在Linux中广泛采用的有代码段描述符、数据段描述符、任务状态段描述符、局部描述符表描述符,其格式有所不同。之后会讲到。

Linux中用到的几种段选择符

这样要访问一个地址,先将该地址所在段的段选择符放入段寄存器,由此按索引字段找到段描述符,找到段基址,再加上偏移量,就转化成了线性地址。在没有分页单元的情况下,线性地址等于物理地址,可以直接放到地址总线上。
在这个过程中,通过段长和段访问权限,就可以控制进程无法访问到非法地址。
更详细的x86的结构不再详述。

页式寻址

页式地址管理从80年代中期在Unix等操作系统上实现,它比段式管理更为先进。因此Intel不得不在80386上开始实现页式管理。
这时,线性地址不能直接放到总线上,而是要再经过一个分页单元的硬件电路,将线性地址转化成物理地址。在这个过程中,很关键的一个任务是请求的访问类型与线性地址的访问权限相比较,如果这次内存访问是无效的(如线性地址还未映射到物理地址上),就产生一个缺页异常。缺页异常处理程序在第三讲中详述。

插入异常分类。(在讲Linux中断和异常时详述)
异常可以分为四类:中断、陷阱、故障、终止。
异常的分类
举例:硬件引脚变化引起中断;系统调用使用陷阱;缺页异常是故障;SRAM数据错误引起终止。

为了效率起见,线性地址被分为以固定长度为单位的页,一般为4KB。这组连续的线性地址,会被映射到连续的物理地址中。名词解释:页和页框,不要将页与物理存储器的页框混淆,更不要与Flash的页混淆。
使用页是为了减少映射表的数量和减少地址权限描述符的数量。后面将Linux的页管理时会讲到该描述符,存放该描述符的数据结构称为页表,在启用分页单元前需要由内核对该结构进行初始化。
从80386开始,所有x86处理器都支持分页,通过将CR0寄存器的PG标志置位实现。其页大小为4KB。启用分页后,32位的线性地址分为三个域:高10位的目录、中间10位的页表和低10位的偏移量。

x86的页式寻址

线性地址的转化分两步完成,每一步都基于一种转换表,第一种称为页目录表,第二种称为页表。从图中解释这个转换过程。

考虑一个问题:能否只用一个页表,一步完成寻址?
看起来二级模式是多此一举的,并且还多占用了系统内存来存放这些表。实际上使用二级模式正是为了减少所需内存数量。如果只使用一级页表,那么每个进程需要高达2^20个表项(每个4字节则4MB)来描述其地址空间。因为页表内不能有空洞。使用二级模式,则那些不使用的地址不需要分配页表。可以大大减少所需内存数量。
举例计算:一个4GB线性空间需要4MB页表空间来描述。如果有100个进程,全部描述其线性空间需要400MB,这是不太实现的。在第三讲会提到,每个进程理论可访问的空间是4GB,但其生命周期中有可能大多数线性空间都永远不会访问,因此大量的描述符是没有必要的,这样可以节省巨大内存。

每个活动进程需要一个页目录和多个页表,页目录的物理地址存放在CR3中。各个字段依次叠加可以完成最终的寻址。

讲述页目录项和页表项的结构:
页目录项和页表项

Present标志、包含页框物理地址的最高20位的字段、Accessed标志、Dirty标志、Read/Write标志、User/Supervisor标志、。其中User/Supervisor标志表示页和页表的特权级,Read/Write标志表示其存取权限。PCD和PWT标志、PageSize标志、AVL。

用下图描述分页机制下CPU访问一个内存的硬件操作步骤。说明正常分页与缺页、权限或访问方式错误。
页面命中
缺页

扩展分页:从奔腾开始,x86处理器引入的扩展分页(与后续的物理地址扩展PAE区分),使用20位偏移和10位目录来寻址,每个页可以达到4MB,通过设置CR4的PSE标志使扩展分页与正常分页共存。随着内存容量和磁盘容量的增加,以及磁盘访问速度的显著提高,以及对图像处理要求的日益增加,4MB字节的页面大小有可能会成为主流,在这点上说明了Intel的远见。然而Linux目前没有采用这种机制。
扩展分页

PAE分页机制。由于32位地址管脚只能寻址4GB地址空间,而当今的服务器中需要同时运行数以千计的进程,对Intel造成了压力,因此其从Pentium Pro开始管脚数提升到36,寻址空间达到64GB,这也需要一种新的分页机制。
另外对于64的x86_64系统,需要两级以上分页。稍后会讲Linux如何解决不同硬件平台需要不同级数分页的问题。

这里省去了多核处理器的讲解,可以参考C6678培训材料自行学习。

总例

某次进程切换过程中的寻址的整个过程。
1. schedule()在内核空间,需要获取到切换到的进程的结构体task_struct以及mm_struct,假设已经拿到其地址值。由于这个结构体在内核空间,因此全局描述符表寄存器gdtr不需要改变,内核向段寄存器中写入段选择符,向某个通用寄存器写入偏移量,调用一条内存加载汇编指令加载该值,这时分段单元先从内存的全局段描述符表中加载段描述符,找到段基址,加上偏移量构成线性地址。
2. 接下来分页单元对该线性地址分为三部分:页目录、页表、偏移量。通过CR3寄存器的值读一次内存加载页目录表,找到页目录项,再读一次内存加载页表,找到页表项,最后读一次内存把真正需要的值返回给CPU。
3. 此时读到的值仅是为了写入CR3寄存器的该进程页目录的物理地址。内核还需要读取其他很多项数据,每次读取都重复上述1、2步。
4. 最终内核完成准备过程后将页目录的物理地址加载入CR3寄存器,然后进入该进程,此时才能进入到该进程的地址空间后才能访问该进程的数据。
5. 假如进程开始执行后需要获取数据,则要用新的段描述符表、页目录表,全部都要从内存中重新加载一遍。
6. 以上过程中省略了读取段描述符、权限检查的过程,简化了有可能存在的修改gdtr寄存器的过程、缺页时的处理等步骤,否则真实的过程将更加复杂。

关于效率

一次寻址操作有可能要访问多次内存,这样复杂的设计为的是获得更好的可移植性、更方便的管理和更高的安全性。表面上看效率极其低下,但实际上硬件已经为这样的寻址方式提供了巨大的支持。

为了加速段式寻址转换速度ÿ

  • 0
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值