前言
本篇博文是操作系统基础系列之一;本文为作者的原创作品,转载需注明出处;
本文笔者将深入虚拟地址,去探明虚拟地址是如何产生的?以下的操作均是在 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
执行 main1
$ ./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 个主要的步骤,
- 通过编译器的编译和链接后,生成可执行文件(设为 $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 指令,然后载入物理内存中去,然后再将物理页写会页表;
上述详细的地址与地址之间的转换过程参考笔者的另外一篇文章《操作系统基础 - 内存管理(一) 虚拟内存 $\to$ 物理内存》;