内存梳理1. linux 内存寻址

来源:互联网 发布:常用的mac论文软件 编辑:程序博客网 时间:2024/06/03 20:32

引子

一段代码:

#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(Arithmetic Logic Unit,算术逻辑单元)等宽。而地址总线一般也与数据总线一致。这是因为从程序设计角度,一个地址也就是一个指针,最好与一个整型长度一致。

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的结构详见《内存梳理0. 实模式和保护模式区别及寻址方式》。

页式寻址

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

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

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

x86的页式寻址

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

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

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

页目录项和页表项的结构
页目录项和页表项

P: Present标志, 用于指明表项对地址转换是否有效,P=1表示有效;P=0表示无效。

R/W: 是读/写(Read/Write)标志。R/W=1,表示页面可以被读、写或执行。如果为0,表示页面只读或可执行。当处理器运行在超级用户特权级(级别0、1或2)时,则R/W位不起作用。页目录项中的R/W位对其所映射的所有页面起作用。

U/S: 是用户/超级用户(User/Supervisor)标志。如果为1,那么运行在任何特权级上的程序都可以访问该页面。如果为0,那么页面只能被运行在超级用户特权级(0、1或2)上的程序访问。页目录项中的U/S位对其所映射的所有页面起作用。

A: 是已访问(Accessed)标志。当处理器访问页表项映射的页面时,页表表项的这个标志就会被置为1。当处理器访问页目录表项映射的任何页面时,页目录表项的这个标志就会被置为1。处理器只负责设置该标志,操作系统可通过定期地复位该标志来统计页面的使用情况。

D: 是页面已被修改(Dirty)标志。当处理器对一个页面执行写操作时,就会设置对应页表表项的D标志。处理器并不会修改页目录项中的D标志。
AVL: 该字段保留专供程序使用。处理器不会修改这几位,以后的升级处理器也不会。


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

页面命中
缺页

扩展分页

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

PAE分页机制

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

Linux寻址机制

从操作系统发展历史上,地址访问也是逐渐严格限制的过程。早期操作系统包括当今的多数嵌入式操作系统没有权限等概念,每个任务都可以访问任意地址,改写其他任务的地址空间,从而导致其他任务异常甚至系统崩溃。
举例,两个任务栈紧邻分配,一个栈溢出导致另一个任务运行不正常。可能造成系统崩溃,或更糟糕的是运行结果不正确。

现在的Linux和Windows等操作系统都对每个进程划分地址空间,每个进程不能随意访问其他进程的地址空间,甚至本进程空间内的地址也有明确的访问权限。这样一个进程的编程错误也不会影响其他进程,用户空间的进程错误也不会影响内核,大大增强了系统稳定性。
32位 Linux的线性地址空间共4GB,分为内核空间与进程空间,内核空间占据3GB以上地址,内容对于所有进程都一样,进程空间是低3GB,每个进程各不相同。
Linux地址空间划分
进程地址空间的引入需要一套对应的寻址机制。这将是前三讲的内容。我们以x86为例对其进行详细讲述。

Linux的分段

分段是x86首创,而ARM和MIPS并不支持分段Linux要照顾所有平台,因此仅在x86上使用了分段,且使用方式非常有限,即只使用了有限几个段如用户代码和数据段、内核代码和数据段,并将所有这四个段的段描述符的基址都设置为零这意味着在用户态和内核态进程都可以使用相同的逻辑地址并且逻辑地址与线性地址是永远相等的。用代码来说明。

=================== arch/x86/kernel/process_32.c 247 263 =====================voidstart_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp){    set_user_gs(regs, 0);    regs->fs        = 0;    set_fs(USER_DS);    regs->ds        = __USER_DS;    regs->es        = __USER_DS;    regs->ss        = __USER_DS;    regs->cs        = __USER_CS;    regs->ip        = new_ip;    regs->sp        = new_sp;    /*     * Free the old FP and other extended state     */    free_thread_xstate(current);}

在内核代码中,四个段选择符分别被定义为常数值:
选择符定义
TI都是0,说明全部使用GDT,在GDT中这四个段选择符对应的段描述符被初始化为如下值,且永远不变。
段描述符
可见每个段都是从0地址开始覆盖了全部4GB地址空间
另外Linux在GDT中还使用了其他几个专门的段:任务状态段、缺省局部描述符表段、局部线程存储段、高级电源管理相关段、支持即插即用功能的BIOS服务程序相关段、双重错误异常的特殊TSS段。此处略去两千字分析。
Linux全局描述符表

Intel设计段式寻址意图是让每个进程使用各自的LDT,且让每个段描述符对应不同的地址空间,Linux的做法显然与之不符。实际上Linux只在有限情况下进程需要创建自己的局部描述符表,如Wine程序。Linux提供了modify_ldt()系统调用供这样的程序修改自己的局部描述符表。解释Wine:在posix兼容系统上执行面向段的微软windows应用的兼容层。如Wine qq。
Linux不使用局部描述符表,因此内核定义了一个缺省的LDT供大多数进程共享,在default_ldt数组中,内核只使用了其中两个调用门相关项。

问:是否可以通过恶意设置CS、DS绕过Intel的段式保护机制?
答:可以,但Linux真正重要的是分页机制里的保护机制。

Linux的分页

32位的x86使用两级映射,而64位系统使用四级映射,为了兼容,2.6.10版本采用了三级分页模型, 从2.6.11版本开始使用了一种通用的四级分页模型来匹配所有支持的硬件。这四种页表分别是页全局目录页上级目录页中间目录页表。

  • 对于没有启用PAE的32位系统,两级页表足够,因此使页上级目录和页中间目录全部为0,取消了这两个字段;
  • 对于启用了PAE的32位系统,启用了三级页表;
  • 对于64位系统,使用三级还是四级页表取决于硬件。具体做法在编译期间配置宏实现;

Linux四级页表
Linux高度依赖分页,所以了解内存页表的建立过程是很有必要的,下一篇将梳理内存页表的建立过程。