开始这篇文章之前,先问一个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)。其实我对这两个模式没有太多深入的了解,姑且说我的一点理解吧。它们的一点主要区别表现在如何使用物理内存上。实模式下,程序直接访问物理内存,而保护模式下,系统启用虚拟内存,程序访问的是虚拟内存,再由系统帮它找到实际的物理内存地址(通过页表)。
按照我的理解内存模型和内存模式之间存在如下关系:
- 8086/8088时代的段模式其实就是直接指向物理地址。一些相关细节如下:
- segment是16-bit的,每个段开始的物理地址都是16的整数倍,segment就是这个地址的高16位,比如从地址0x00000开始的段,segment是0x0000,从0xF0AA0开始的段,segment是0xF0AA
- Offset也是16-bit的,就是说当时每个程序的地址空间只有2^16,即64K。而且可以看出段与段之间可能是有重叠的,比如头两个段,0x00000-0x0FFFF,0x00010-0x1000F
- 这种构架下,允许安装最大的物理内存地址是0xFFFF0+0xFFFF
- 给出逻辑地址segment:offset,可以非常容易地计算出物理地址segment×0x10+offset
- 80286时代,分段内存模型使用了保护模式。这种模式下,segment不再只是一个简单的段开始地址,而是一个数据结构。
- Segment仍然是16-bit,它的高13位保存了一个段索引子(Segment descriptor index)
- 系统维护一个段描述符表(Segment descriptor table),里面每一条段描述符(Segment descriptor)记录了一个段的开始地址,跨度等信息。Segment中的段索引子就指向段描述符表中的一条段描述符。
- 保护模式启用了虚拟地址,就是说segment:offset实际指向的是一个虚拟地址,而不是直接映射到物理地址
- 通常使用保护模式的系统在启动时需要先使用实模式,然后建立起段描述符表,再切换到保护模式运行
- 从80386时代,intel添加了32-bit的分段内存模式。这个模型的改进是把原来16-bit的offset变成了32-bit的offset——所以平坦内存模式就是一个特殊的32-bit的分段内存模式。因为现在的应用程序对地址空间的要求越来越大,所以现在的win32的应用程序都使用平坦内存模型了。上面已经提到过,这个模型的实现也得益于虚拟内存技术。
程序内存布局
我的答案
cout << &a << endl << &b << endl << &c <<endl;
这个语句输出的是这些变量的虚拟地址。(如果是在分段内存模式下,实际输出的是segment:offset中的offset值)。所以,看起来,确实应该每次运行的输出结果都是一样的才对。