操作系统基础 - 内存管理(二) 虚拟地址是如何生成的

前言

本篇博文是操作系统基础系列之一;本文为作者的原创作品,转载需注明出处;

本文笔者将深入虚拟地址,去探明虚拟地址是如何产生的?以下的操作均是在 Ubuntu 操作系统上进行测试的;

产生

如何产生

编译器完成编译后,在生成的目标文件中产生程序的逻辑地址(Logic Address),也就是将程序划分为段,然后根据机器指令自动生成相应的偏移地址;段的基址和其它相关信息在程序运行期间自动的保存到段表 GDT 中;这样,程序的逻辑地址便产生了;

以 C 语言为例,GCC 通过编译将源文件生成目标文件(二进制文件,文件名以 .o 结尾);该文件中便将程序划分为段,以及每个指令的偏移地址;然后通过链接(linker),将各个目标文件链接起来,统一生成最终的段基址和相应的偏移地址;

在 X86 指令架构下,一个机器指令的长度在 1 到 13 Bytes 之间;

概念图

如图,这是一张有关 Program 理想中的逻辑地址段划分示意图,

从概念上而言,程序段的基址从 0 开始,然后其它段的基址相应的根据各个段的大小进行偏移;但是实际情况却并非如此,实际段基址的分布情况参考实际分布情况小节;

32 位操作系统,内核占用 1G 的虚拟内存;

逻辑地址分析

下面的测试过程是在 Ubuntu 64 位操作系统中完成的,

静态内存段

代码段

写一个 C 程序,命名为 main.c

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
/**
* main - Entry point of the program
*
* Return: 0 every time.
*/
int main(void)
{
return (0);
}

编译,生成目标程序,

1
2
3
$ gcc -Wall -c main.c
$ ls
main.c main.o

在当前目录中生成目标文件 main.o,接下来,我们通过 size 工具检查下目标文件中各个段的大小;

1
2
3
4
5
6
7
8
9
10
$ size -A main.o
main.o :
section size addr
.text 11 0
.data 0 0
.bss 0 0
.comment 53 0
.note.GNU-stack 0 0
.eh_frame 56 0
Total 120

首先,我们来看一下各个段的含义

  • .text 段
    该段既是操作系统基础 - 内存管理(一) 虚拟内存 $\to$ 物理内存中所提到的代码段;
  • .data 段
    数据段,因为我们的程序中只有一个 main 函数,因此数据段中没有任何数据,长度为 0
  • .bss 段
    存放未初始化数据段;比如某个参数只申明却未被初始化;
  • .comment 段
    故名思议,也就是注释段;

可见,目前主要是程序段(.text)和注释段有内容;下面我们来看看目标文件 main.o 中代码段的内容是什么,

1
2
3
4
5
6
7
8
9
10
11
12
13
$ objdump -M intel -j .text -d main.o

main.o: file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: b8 00 00 00 00 mov eax,0x0
9: 5d pop rbp
a: c3 ret

我们通过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
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
/**
* main - Entry point of the program
*
* Return: 0 every time.
*/
int main(void)
{
static char* str = "C is fun";
static int x = 10;
static int y = 10000;
return (0);
}

编译,

1
$ gcc -c main.c

备注,这次可以不再使用-Wall命令,这样可以不输出 warnings;通过 size 工具检查 main.o 中各个段的大小,

1
2
3
4
5
6
7
8
9
10
11
$ size -A main.o
main.o :
section size addr
.text 11 0
.data 16 0
.bss 0 0
.rodata 9 0
.comment 53 0
.note.GNU-stack 0 0
.eh_frame 56 0
Total 145

可以看到,data 段的大小发生了变化;使用 objdump 来查看 data 段的内容,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ objdump -M intel -j .data -d main.o

main.o: file format elf64-x86-64


Disassembly of section .data:

0000000000000000 <y.2288>:
0: 10 27 00 00 ....

0000000000000004 <x.2287>:
4: 0a 00 00 00 ....

0000000000000008 <str.2286>:
...

从输出结果中可以看到,int 类型的值都保存在偏移地址 0 和 4,但奇怪的是字符数据中却没有值,只有一个偏移地址 8,数据并没有保存在 .text 段中; 为了验证到底发生了什么,笔者往 main.c 中继续添加一个 char 字符串,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
/**
* main - Entry point of the program
*
* Return: 0 every time.
*/
int main(void)
{
static char* str = "C is fun";
static char* str2 = "Hello world";
static int x = 10;
static int y = 10000;
return (0);
}

编译后,通过 objdump 来看看到底发生了什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ objdump -M intel -j .data -d main.o

main.o: file format elf64-x86-64


Disassembly of section .data:

0000000000000000 <y.2289>:
0: 10 27 00 00 ....

0000000000000004 <x.2288>:
4: 0a 00 00 00 ....

0000000000000008 <str2.2287>:
...

0000000000000010 <str.2286>:
...

可以看到,数据段只为char*类型分配了地址空间,但它的数据却并没有被放在数据段中;那字符串的内容是保存在什么地方的呢?是不是存放在链接之后的可执行文件中的呢?抱着这个疑问,我们来试着完成链接操作,

1
gcc main.o -o main

这样我们生成了可执行文件 main,这个时候,我们再通过 objdump 来检查一下 main 中的数据段,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ objdump -M intel -j .data -d main

main: file format elf64-x86-64


Disassembly of section .data:

0000000000601020 <__data_start>:
...

0000000000601028 <__dso_handle>:
...

0000000000601030 <y.2289>:
601030: 10 27 00 00 ....

0000000000601034 <x.2288>:
601034: 0a 00 00 00 ....

0000000000601038 <str2.2287>:
601038: 74 05 40 00 00 00 00 00 t.@.....

0000000000601040 <str.2286>:
601040: 80 05 40 00 00 00 00 00 ..@.....

过不其然,char *类型的数据是在链接的过程中填充的;从这里可以看到,连接后,数据段的基址已经发生了变更,有关这部分内容将在GCC 链接小节详细阐述;

BSS 段

BSS 是未初始化数据段;修改 main.c,这次我们只添加一个未被初始化的静态变量,

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
/**
* main - Entry point of the program
*
* Return: 0 every time.
*/
int main(void)
{
static char* str;
return (0);
}

编译后,使用 size 工具检查各个段的大小,

1
2
3
4
5
6
7
8
9
10
$ size -A main.o
main.o :
section size addr
.text 11 0
.data 0 0
.bss 8 0
.comment 53 0
.note.GNU-stack 0 0
.eh_frame 56 0
Total 128

可以看到,bss 段中有东西了,使用 objdump 来检查一下 main.o,

1
2
3
4
5
6
7
8
9
$ objdump -M intel -j .bss -d main.o

main.o: file format elf64-x86-64


Disassembly of section .bss:

0000000000000000 <str.2286>:
...

链接后,检查链接后输出文件 main,

1
2
3
4
5
6
7
8
9
10
11
12
$ objdump -M intel -j .bss -d main

main: file format elf64-x86-64


Disassembly of section .bss:

0000000000601030 <__bss_start>:
...

0000000000601038 <str.2286>:
...

可见 bss 是一个空段,未初始化常量 char* 正好是 bss 段的第一个元素(相对于 bss 段基址偏移 8 位);

链接后生成新的段地址

GCC 通过编译后,为每个 .c 文件生成一个单个的中间二进制 .o 文件,但是这些文件是孤立的,并不能直接运行;这里的孤立主要是指,每个 .o 文件都有自己独立的段基址,偏移地址,但问题是,这些段基址都是从 0 开始,导致各个 .o 文件的逻辑地址是重叠的,因此,我们需要把这些各自孤立的中间 .o 文件合并到一起生成一个新的文件,使他们拥有同一个段地址,这样才能得到一个可执行的文件,这个过程在 GCC 中就称作链接

由此,我们我可以知道,链接完成后,必定会生成新的段基址和偏移地址,这点可以从数据段的分析例子中清晰可见;

动态内存段

上述的地址都可以在编译器进行编译的时候,确定出机器指令、数据的逻辑地址(段基址+偏移地址),但是,有一些是在程序运行过程中动态创建的内存,而这些虚拟内存地址又是如何分配的呢?

继续修改 main.c,这次,笔者通过malloc在程序运行过程中动态申请内存空间,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
/**
* main - Entry point of the program
*
* Return: 0 every time.
*/
int main(void)
{
char *p = (char*)malloc(sizeof(char));
while (1)
{
sleep(1);
}
return (0);
}

例子中,通过无限循环避免程序退出;编译

1
$ gcc -c main.o

链接,

1
$ gcc main.o -o main

增加可执行权限,

1
$ chmod +x main

执行 main

1
$ ./main

main 程序进入无线循环,下面笔者通过 process file 来检查当前堆和栈的分配情况,打开一个新的窗口,

1
2
$ pgrep main
17766

可见,main 进程 PID=17766,下面通过 process file 来检查虚拟内存的分配情况,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ cat /proc/17766/maps
00400000-00401000 r-xp 00000000 fd:01 1204897 /home/macooo/tmp/mmgt/main
00600000-00601000 r--p 00000000 fd:01 1204897 /home/macooo/tmp/mmgt/main
00601000-00602000 rw-p 00001000 fd:01 1204897 /home/macooo/tmp/mmgt/main
024d1000-024f2000 rw-p 00000000 00:00 0 [heap]
7f0dcb662000-7f0dcb822000 r-xp 00000000 fd:01 402248 /lib/x86_64-linux-gnu/libc-2.23.so
7f0dcb822000-7f0dcba22000 ---p 001c0000 fd:01 402248 /lib/x86_64-linux-gnu/libc-2.23.so
7f0dcba22000-7f0dcba26000 r--p 001c0000 fd:01 402248 /lib/x86_64-linux-gnu/libc-2.23.so
7f0dcba26000-7f0dcba28000 rw-p 001c4000 fd:01 402248 /lib/x86_64-linux-gnu/libc-2.23.so
7f0dcba28000-7f0dcba2c000 rw-p 00000000 00:00 0
7f0dcba2c000-7f0dcba52000 r-xp 00000000 fd:01 402246 /lib/x86_64-linux-gnu/ld-2.23.so
7f0dcbc46000-7f0dcbc49000 rw-p 00000000 00:00 0
7f0dcbc51000-7f0dcbc52000 r--p 00025000 fd:01 402246 /lib/x86_64-linux-gnu/ld-2.23.so
7f0dcbc52000-7f0dcbc53000 rw-p 00026000 fd:01 402246 /lib/x86_64-linux-gnu/ld-2.23.so
7f0dcbc53000-7f0dcbc54000 rw-p 00000000 00:00 0
7fff48c16000-7fff48c37000 rw-p 00000000 00:00 0 [stack]
7fff48d6e000-7fff48d71000 r--p 00000000 00:00 0 [vvar]
7fff48d71000-7fff48d73000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]

可见,(heap)使用的逻辑地址是 024d1000-024f2000,(stack)所使用的逻辑地址是 7fff48c16000-7fff48c37000;由此可知,main.c 中所动态分配的内存就在堆中,也就是由逻辑地址 024d1000-024f2000 所表示的虚拟内存中;

在支持虚拟内存的 os 的环境下,malloc 首先是去物理内存中查找是否有可用内存,若有,则开辟一块物理内存返回给应用程序,只是要注意的是,因为应用程序是基于虚拟内存的,所以,需要通过页表、段表的映射,将物理地址 $\to$ 线性地址 $\to$ 逻辑地址,然后将逻辑地址返回给应用程序;

实际分布情况

通过 objdump 工具分析链接后可执行文件 main 的逻辑地址分布,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ objdump -M intel -j .text -d main
main: file format elf64-x86-64


Disassembly of section .text:

00000000004003e0 <_start>:
4003e0: 31 ed xor ebp,ebp
4003e2: 49 89 d1 mov r9,rdx
4003e5: 5e pop rsi
4003e6: 48 89 e2 mov rdx,rsp
4003e9: 48 83 e4 f0 and rsp,0xfffffffffffffff0
4003ed: 50 push rax
4003ee: 54 push rsp
4003ef: 49 c7 c0 60 05 40 00 mov r8,0x400560
4003f6: 48 c7 c1 f0 04 40 00 mov rcx,0x4004f0
...

程序段的起始地址 4003e0;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ objdump -M intel -j .data -d main

main: file format elf64-x86-64


Disassembly of section .data:

0000000000601020 <__data_start>:
...

0000000000601028 <__dso_handle>:
...

0000000000601030 <y.2288>:
601030: 10 27 00 00 ....

0000000000601034 <x.2287>:
601034: 0a 00 00 00 ....

0000000000601038 <str.2286>:
601038: 74 05 40 00 00 00 00 00 t.@.....

数据段的起始地址 601020;

1
2
3
4
5
6
7
8
9
$ objdump -M intel -j .bss -d main

main: file format elf64-x86-64


Disassembly of section .bss:

0000000000601040 <__bss_start>:
...

bss 段(未初始化段)的起始地址 601040;

最后,结合动态内存段中的堆段和栈段,我们就可以非常准确的绘制出逻辑地址中的各个段的划分情况了,如下图所示,

如图,这便是真实的,由 GCC 编译器编译后所生成的逻辑地址以及段空间;程序段并非概念图那样,从 0 地址开始;为什么程序段之前还留有一个比较大的空位呢?笔者猜想,或许是为将来可能新增的段留出足够的空间吧;

全局观

本小节从编译开始,讲解虚拟地址是如何产生的,并且它是如何一步一步转换成物理地址的;由此形成一个全局的概念,

如图所示,整个过程分为 4 个主要的步骤,

  1. 通过编译器的编译和链接后,生成可执行文件(设为 $F$),该文件中包含了由编译器所生成的虚拟地址,其中包含了段基址(Base)和段内偏移地址(Offset);
  2. 当操作系统载入 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 是段表寄存器,用来保存段表在物理内存中的偏移地址;
  3. 通过段内偏移地址(Offset)和段基址相加,得到线性地址;
  4. 通过目录表、页表转换后得到物理页框,通过物理页框可以计算出页框的物理地址,使之加上Offset后,便得到了最终的物理内存地址;另外,虚线部分表述的是缺页的情况,当通过页表查找物理页框的时候,如果发现没有对应的物理页,这个时候,便会抛出缺页中断,通过 I/O 总线从磁盘中读取 Program 指令,然后载入物理内存中去,然后再将物理页写会页表;

上述详细的地址与地址之间的转换过程参考笔者的另外一篇文章《操作系统基础 - 内存管理(一) 虚拟内存 $\to$ 物理内存》;