Intel 80x86 的程序内存模型解析:从一道面试题说起

开始这篇文章之前,先问一个IT公司在面试的时候可能会问到的问题,以下程序

#include <iostream>
using namespace std;

int main(){
	int a, b, c;

	cout << &a << endl << &b << endl << &c <<endl;

	return 0;
}
编译之后,多次运行,输出的结果是否是一样的?重新编译一次再运行呢,是否还和前几次一样?

这个问题,官方的标准的答案是:是的,一样,重新编译之后还是一样。但是其实这个答案并不严谨。想彻彻底底地了解这个问题到底是想考你哪方面的知识,怎样回答才是最标准的答案,且让我们从头说起。


关于内存模型

那么进入正题。现在绝大多数的操作系统需要同时运行多个进程,从内存管理的角度,我们自然而然可以想到的第一个问题就是,如何让多个进程可以和平的共用有限的物理内存呢?x86构架的解决方案就是这两种内存模型。

分段内存模型(Segmented memory model)

这种内存模型起源于8086/8088 CPU,那个时候用的还是16位的地址。这个模式下,操作系统把内存分做一个一个的段segment,每个段的大小是确定的。每个程序在开始运行时,系统给它分配一个段,程序可用的内存就在这个段的范围内。(其实,更准确地说,系统可能是给一个程序分配多个段,比如,代码区分配一个段,栈在另一个段。)

这样,系统就可以通过适当的段分配方法来保证各个程序和平共处。而从程序的观点来看,系统给它分配的段就是它全部的“活动范围”,它不用去关心这个段究竟落在整个内存布局的哪里,它只要记得“我应该怎样使用系统给我的内存”就可以了。比如,程序里面可能写着,我把一个全局int常量i放在系统给我的段的0x00C2位置,这个位置其实是一个偏移量offset,在同一个程序的多次运行中,这个量总是不变的。

另外这里提一个概念,逻辑地址(Logical address),形式就是segment:offset。

平坦内存模型(Flat memory model)

分段的内存模型很巧妙,但是在现在的Win32程序中却被抛弃了。本着向下兼容的原则,80386的CPU仍然拥有各种段寄存器(用来保存段开始地址),地址寄存器变成32位。运行在平坦内存模式下时,段寄存器的值都被置零,这就是说,系统把4G可用的内存空间直接发给每一个进程使用,每个进程都有4G的地址空间。

这样一来,很明显不同进程之间的内存可能会冲突——比如Win32下我的A进程使用地址0x004000C2来存放一个值,B进程使用地址0x004000C2来存放另一个值,那这不就冲突了吗?因为这种模式下所有进程都共用这个4G大小、起点为0x00000000的段。

这个问题和虚拟内存(Virtual memory)技术有关。其实,前面提到的4G可用的内存并不是真正的物理内存,而是虚拟内存。虚拟内存通过页表(Page table)映射到真正的物理内存上。在平坦内存模型下,系统为各进程维护页表,对于不同的进程,同一个虚拟内存位置可能映射到不同的物理内存上,这样一来各个进程也就可以和平共用有限的物理内存了。CPU突然从进程A切换到进程B时,页表缓存区(Translation lookaside buffer)也会相应刷新,这其中的具体实现还和CR3寄存器有关,有兴趣可以自己查看相关内容,此处不赘述。

两种内存模式

提到内存管理时还经常出现的两个名词是实模式(Real mode)保护模式(Protected mode)。其实我对这两个模式没有太多深入的了解,姑且说我的一点理解吧。它们的一点主要区别表现在如何使用物理内存上。实模式下,程序直接访问物理内存,而保护模式下,系统启用虚拟内存,程序访问的是虚拟内存,再由系统帮它找到实际的物理内存地址(通过页表)。

按照我的理解内存模型和内存模式之间存在如下关系:


  1. 8086/8088时代的段模式其实就是直接指向物理地址。一些相关细节如下:
    1. segment是16-bit的,每个段开始的物理地址都是16的整数倍,segment就是这个地址的高16位,比如从地址0x00000开始的段,segment是0x0000,从0xF0AA0开始的段,segment是0xF0AA
    2. Offset也是16-bit的,就是说当时每个程序的地址空间只有2^16,即64K。而且可以看出段与段之间可能是有重叠的,比如头两个段,0x00000-0x0FFFF,0x00010-0x1000F
    3. 这种构架下,允许安装最大的物理内存地址是0xFFFF0+0xFFFF
    4. 给出逻辑地址segment:offset,可以非常容易地计算出物理地址segment×0x10+offset
  2. 80286时代,分段内存模型使用了保护模式。这种模式下,segment不再只是一个简单的段开始地址,而是一个数据结构。
    1. Segment仍然是16-bit,它的高13位保存了一个段索引子(Segment descriptor index)
    2. 系统维护一个段描述符表(Segment descriptor table),里面每一条段描述符(Segment descriptor)记录了一个段的开始地址,跨度等信息。Segment中的段索引子就指向段描述符表中的一条段描述符。
    3. 保护模式启用了虚拟地址,就是说segment:offset实际指向的是一个虚拟地址,而不是直接映射到物理地址
    4. 通常使用保护模式的系统在启动时需要先使用实模式,然后建立起段描述符表,再切换到保护模式运行
  3. 从80386时代,intel添加了32-bit的分段内存模式。这个模型的改进是把原来16-bit的offset变成了32-bit的offset——所以平坦内存模式就是一个特殊的32-bit的分段内存模式。因为现在的应用程序对地址空间的要求越来越大,所以现在的win32的应用程序都使用平坦内存模型了。上面已经提到过,这个模型的实现也得益于虚拟内存技术。

程序内存布局

说完上面的内容,再来说说程序的内存布局。这个其实很多书上都说得很详细了,我只补充一点东西。

有一个问题是,很多书上是这样画一个程序的内存布局的,从下往上:代码区——全局/静态/文本等数据——堆——栈,一个区紧挨着一个区。其实真的是这样子吗?也许使用分段模型写出来的程序是这样的,因为它的地址空间十分有限,要尽量把这些区域安排得紧凑一些才好。但是在Win32平坦内存模式下,一个程序就可以独占4G地址空间,显然没有必要这样做,让各个区域离远一点,留下足够的生长空间,反而更好。在Win7下用VC编译器编译出来的程序,通常代码区和数据区是挨在一起的,而堆和栈则离得比较远。

另外一个问题,既然一个Win32程序有4G地址空间可以用,而实际用到的可能只有1M,甚至更小,编译器在编译一个小程序的时候会怎样安排它的各个区的位置呢?这里有一个概念,基址(Base address)——即是一个区域的开始位置。通常,编译器针对一种代码量级的程序设计好了一些固定的基址,比如说VC编译器,通常编译小程序时,代码区是从0x00401000开始的,栈从0x00130000开始向下生长。用同一个编译器分别编译两份相同代码,得到的程序里面,这些个基址应该都是一样的。
说到这里你可能会犯嘀咕:那这样编译器也太不负责任了吧,这等于说用这个编译器编译出来的很多程序的布局都差不多是一样的啊?对于这个,我们可以这样理解,既然系统能够合理的进行虚地址→物理地址映射来避免冲突问题了,那么编译器似乎也没有必要硬要在各个程序使用的虚拟内存区域的互相避让上大做文章了。

我的答案

现在可以来回答开头提出的那个问题了。
cout << &a << endl << &b << endl << &c <<endl;
这个语句输出的是这些变量的虚拟地址。(如果是在分段内存模式下,实际输出的是segment:offset中的offset值)。所以,看起来,确实应该每次运行的输出结果都是一样的才对。
而且同一份代码,如果使用同一个编译器来编译运行,结果应该还是一样的,就是因为栈区的基址都已经由编译器确定好了的。
当然如果你换了编译器编译,结果就不一定相同了。
不过我们可以想到平坦内存模式下的一个问题是,既然同一个程序使用的内存地址都是一样的,那么这个特点很有可能被攻击者利用,成为程序的软肋。当然操作系统的设计者也考虑到了这个问题,所以又有了一个随机重设基址(Address sapce layout randomization)技术。简单地说,就是应用了这个技术编译的程序,每次运行时,基址都会被重设为某个范围内的随机数,这样一来,代码地址,栈区地址等等再也不是固定在虚拟内存中的了。Windows系列从Vista开始支持了这种ASLR技术,而且VS系列IDE的编译器中,编译代码时候会有一个“随机基址”的选项,具体设置的方法是在项目属性中,配置属性>链接器>高级>随机基址,默认为是(/DYNAMICBASE)。

所以最后对这个问题的一个补充是:如果编译该程序时,编译器支持并且开启了随机基址选项编译,那么输出结果不一定相同。

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

打赏
文章很值,打赏犒劳作者一下
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页

打赏

Geterns

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者