前言
本篇博文是操作系统基础系列之一;本文为作者的原创作品,转载需注明出处;
本文笔者将深入虚拟地址,去探明虚拟地址是如何产生的?以下的操作均是在 Ubuntu 操作系统上进行测试的;
产生
如何产生
编译器完成编译后,在生成的目标文件中产生程序的逻辑地址(Logic Address),也就是将程序划分为段,然后根据机器指令自动生成相应的偏移地址;段的基址和其它相关信息在程序运行期间自动的保存到段表 GDT 中;这样,程序的逻辑地址便产生了;
以 C 语言为例,GCC 通过编译将源文件生成目标文件(二进制文件,文件名以 .o 结尾);该文件中便将程序划分为段,以及每个指令的偏移地址;然后通过链接(linker),将各个目标文件链接起来,统一生成最终的段基址和相应的偏移地址;
在 X86 指令架构下,一个机器指令的长度在 1 到 13 Bytes 之间;
概念图
如图,这是一张有关 Program 理想中的逻辑地址段划分示意图,
从概念上而言,程序段的基址从 0 开始,然后其它段的基址相应的根据各个段的大小进行偏移;但是实际情况却并非如此,实际段基址的分布情况参考实际分布情况小节;
32 位操作系统,内核占用 1G 的虚拟内存;
逻辑地址分析
下面的测试过程是在 Ubuntu 64 位操作系统中完成的,
静态内存段
代码段
写一个 C 程序,命名为 main.c
,
1 |
|
编译,生成目标程序,
1 | $ gcc -Wall -c main.c |
在当前目录中生成目标文件 main.o
,接下来,我们通过 size 工具检查下目标文件中各个段的大小;
1 | $ size -A main.o |
首先,我们来看一下各个段的含义
- .text 段
该段既是操作系统基础 - 内存管理(一) 虚拟内存 → 物理内存中所提到的代码段; - .data 段
数据段,因为我们的程序中只有一个 main 函数,因此数据段中没有任何数据,长度为 0 - .bss 段
存放未初始化数据段;比如某个参数只申明却未被初始化; - .comment 段
故名思议,也就是注释段;
可见,目前主要是程序段(.text)和注释段有内容;下面我们来看看目标文件 main.o 中代码段的内容是什么,
1 | $ objdump -M intel -j .text -d main.o |
我们通过objdum
命令将 main.o 中代码段的内容打印了出来;从第 8 行开始,
0000000000000000 <main>
:
表示段的起始地址,既段基址是 0;0: 55 push rbp
第一条指令,偏移地址是 0,该指令占用 1 个字节,内容是01010101
;1: 48 89 e5 mov rbp,rsp
第二条指令,偏移地址是 1,该指令占用 3 个字节;4: b8 00 00 00 00 mov eax,0x0
第三条指令,偏移地址是 4,该指令占用 5 个字节;
要注意的是,下一条指令的(起始)偏移地址既是上一个指令结束的地方;由此,只需要某个芯片能够对累加已执行指令的长度,该结果便是下一条指令的(起始)偏移地址,因此,无需额外的存储设备来存放每条指令的偏移地址,只需要动态计算出来即可;而该芯片就是我们常说的 PC 寄存器,它永远指向下一条指令的起始地址;
数据段
修改 main.c,添加三个常量,一个字符串类型,两个 int 类型;
1 |
|
编译,
1 | $ gcc -c main.c |
备注,这次可以不再使用-Wall
命令,这样可以不输出 warnings;通过 size 工具检查 main.o 中各个段的大小,
1 | $ size -A main.o |
可以看到,data 段的大小发生了变化;使用 objdump 来查看 data 段的内容,
1 | $ objdump -M intel -j .data -d main.o |
从输出结果中可以看到,int 类型的值都保存在偏移地址 0 和 4,但奇怪的是字符数据中却没有值,只有一个偏移地址 8,数据并没有保存在 .text 段中; 为了验证到底发生了什么,笔者往 main.c 中继续添加一个 char 字符串,
1 |
|
编译后,通过 objdump 来看看到底发生了什么?
1 | $ objdump -M intel -j .data -d main.o |
可以看到,数据段只为char*
类型分配了地址空间,但它的数据却并没有被放在数据段中;那字符串的内容是保存在什么地方的呢?是不是存放在链接之后的可执行文件中的呢?抱着这个疑问,我们来试着完成链接操作,
1 | gcc main.o -o main |
这样我们生成了可执行文件 main,这个时候,我们再通过 objdump 来检查一下 main 中的数据段,
1 | $ objdump -M intel -j .data -d main |
过不其然,char *
类型的数据是在链接的过程中填充的;从这里可以看到,连接后,数据段的基址已经发生了变更,有关这部分内容将在GCC 链接小节详细阐述;
BSS 段
BSS 是未初始化数据段;修改 main.c,这次我们只添加一个未被初始化的静态变量,
1 |
|
编译后,使用 size 工具检查各个段的大小,
1 | $ size -A main.o |
可以看到,bss 段中有东西了,使用 objdump 来检查一下 main.o,
1 | $ objdump -M intel -j .bss -d main.o |
链接后,检查链接后输出文件 main,
1 | $ objdump -M intel -j .bss -d main |
可见 bss 是一个空段,未初始化常量 char*
正好是 bss 段的第一个元素(相对于 bss 段基址偏移 8 位);
链接后生成新的段地址
GCC 通过编译后,为每个 .c 文件生成一个单个的中间二进制 .o 文件,但是这些文件是孤立的,并不能直接运行;这里的孤立主要是指,每个 .o 文件都有自己独立的段基址,偏移地址,但问题是,这些段基址都是从 0 开始,导致各个 .o 文件的逻辑地址是重叠的,因此,我们需要把这些各自孤立的中间 .o 文件合并到一起生成一个新的文件,使他们拥有同一个段地址,这样才能得到一个可执行的文件,这个过程在 GCC 中就称作链接
;
由此,我们我可以知道,链接
完成后,必定会生成新的段基址和偏移地址,这点可以从数据段的分析例子中清晰可见;
动态内存段
上述的地址都可以在编译器进行编译的时候,确定出机器指令、数据的逻辑地址(段基址+偏移地址),但是,有一些是在程序运行过程中动态创建的内存,而这些虚拟内存地址又是如何分配的呢?
继续修改 main.c,这次,笔者通过malloc
在程序运行过程中动态申请内存空间,
1 |
|
例子中,通过无限循环避免程序退出;编译
1 | $ gcc -c main.o |
链接,
1 | $ gcc main.o -o main |
增加可执行权限,
1 | $ chmod +x main |
执行 main
1 | $ ./main |
main 程序进入无线循环,下面笔者通过 process file 来检查当前堆和栈的分配情况,打开一个新的窗口,
1 | $ pgrep main |
可见,main 进程 PID=17766,下面通过 process file 来检查虚拟内存的分配情况,
1 | $ cat /proc/17766/maps |
可见,堆
(heap)使用的逻辑地址是 024d1000-024f2000,栈
(stack)所使用的逻辑地址是 7fff48c16000-7fff48c37000;由此可知,main.c 中所动态分配的内存就在堆中,也就是由逻辑地址 024d1000-024f2000 所表示的虚拟内存中;
在支持虚拟内存的 os 的环境下,malloc 首先是去物理内存中查找是否有可用内存,若有,则开辟一块物理内存返回给应用程序,只是要注意的是,因为应用程序是基于虚拟内存的,所以,需要通过页表、段表的映射,将物理地址 → 线性地址 → 逻辑地址,然后将逻辑地址返回给应用程序;
实际分布情况
通过 objdump 工具分析链接后可执行文件 main 的逻辑地址分布,
1 | $ objdump -M intel -j .text -d main |
程序段的起始地址 4003e0;
1 | $ objdump -M intel -j .data -d main |
数据段的起始地址 601020;
1 | $ objdump -M intel -j .bss -d main |
bss 段(未初始化段)的起始地址 601040;
最后,结合动态内存段中的堆段和栈段,我们就可以非常准确的绘制出逻辑地址中的各个段的划分情况了,如下图所示,

如图,这便是真实的,由 GCC 编译器编译后所生成的逻辑地址以及段空间;程序段并非概念图那样,从 0 地址开始;为什么程序段之前还留有一个比较大的空位呢?笔者猜想,或许是为将来可能新增的段留出足够的空间吧;
全局观
本小节从编译开始,讲解虚拟地址是如何产生的,并且它是如何一步一步转换成物理地址的;由此形成一个全局的概念,
如图所示,整个过程分为 4 个主要的步骤,
- 通过编译器的编译和链接后,生成可执行文件(设为 F),该文件中包含了由编译器所生成的虚拟地址,其中包含了段基址(Base)和段内偏移地址(Offset);
- 当操作系统载入 Program 生成 Process 的时候,会做如下相关初始化的工作,
- OS 将 Program 的第一条指令的偏移地址赋值给 PC 寄存器
- 另外为了加速对段描述(Segment Descriptor)的读取速度,OS 会做如下两件事情,
- OS 首先把 F 中的段地址映射为 Segment Selector,为什么能够得到加速呢?是因为 Segment Selector 由多个寄存器保存,这些寄存器分别是 cs、ds、ss 等,分别用来存储 code segment selector,data segment selector,stack selector 等内容,以便加速对每个 segment 的访问速度;
- OS 然后把 F 中的段地址保存到段表(GDT)中去;
备注,GTDR 是段表寄存器,用来保存段表在物理内存中的偏移地址;
- 通过段内偏移地址(Offset)和段基址相加,得到线性地址;
- 通过目录表、页表转换后得到
物理页框
,通过物理页框
可以计算出页框的物理地址
,使之加上Offset
后,便得到了最终的物理内存地址;另外,虚线部分表述的是缺页的情况,当通过页表查找物理页框的时候,如果发现没有对应的物理页,这个时候,便会抛出缺页中断,通过 I/O 总线从磁盘中读取 Program 指令,然后载入物理内存中去,然后再将物理页写会页表;
上述详细的地址与地址之间的转换过程参考笔者的另外一篇文章《操作系统基础 - 内存管理(一) 虚拟内存 → 物理内存》;