4.1 分段
在上一篇已经介绍了交换和分区,不管是交换还是分区,都有提到,我们需要把进程加载到内存中,然后再运行,那进程是怎么加载进内存的呢?是整个进程都一起加载么?
看过上一篇的都了解,按照上一篇的技术是很有可能整个进程一起加载,不过现在操作系统应该不这样做了,现在操作系统引入的是分段,如果看过编译链接的朋友,应该就会恍然大悟,原来我们说的各个程序段是这个意思。
4.1.1 程序段介绍
我们写程序时,我们会认为程序由主程序加上一组方法、函数所构成并且程序还包含各种数据结构:对象、数组、堆栈等。每个模块或函数之间都是通过名字来引用的。
我们经常会说堆栈、变量、C库等,但我们并不知道这些东西存在哪里,也不知道这些东西是怎么存储的。
正因为我们程序有这么多元素,并且各种元素属性不一样,有的只读,有的可读可写,有的可以增长,如果像上一节课一个进程就分配一块内存,这样做缺点很大,所以引进了我们今天的主题,分段。
分段就是支持这种用户视图的内存管理方案。逻辑地址空间是由一组段构成。每个段都有名称和长度。
怎么确定段内的地址?其实只需要<段号, 偏移>就能确定位置,这个下一节详细介绍。
下面我们就来先认识一下,现代计算机代码编译后的各个段,当然现代计算用的是虚拟内存,这个我们后面介绍,先忽略,我们只是来看看各个段的介绍:
root@ubuntu:~/knowledge_systeam/4.os/3.Memory/1.principle/01# objdump -h test
test: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 0000001c 0000000000400238 0000000000400238 00000238 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .note.ABI-tag 00000020 0000000000400254 0000000000400254 00000254 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .note.gnu.build-id 00000024 0000000000400274 0000000000400274 00000274 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .gnu.hash 0000001c 0000000000400298 0000000000400298 00000298 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .dynsym 00000048 00000000004002b8 00000000004002b8 000002b8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
5 .dynstr 00000038 0000000000400300 0000000000400300 00000300 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .gnu.version 00000006 0000000000400338 0000000000400338 00000338 2**1
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .gnu.version_r 00000020 0000000000400340 0000000000400340 00000340 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
8 .rela.dyn 00000018 0000000000400360 0000000000400360 00000360 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
9 .rela.plt 00000018 0000000000400378 0000000000400378 00000378 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
10 .init 0000001a 0000000000400390 0000000000400390 00000390 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
11 .plt 00000020 00000000004003b0 00000000004003b0 000003b0 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
12 .plt.got 00000008 00000000004003d0 00000000004003d0 000003d0 2**3
CONTENTS, ALLOC, LOAD, READONLY, CODE
13 .text 00000192 00000000004003e0 00000000004003e0 000003e0 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
14 .fini 00000009 0000000000400574 0000000000400574 00000574 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
15 .rodata 00000004 0000000000400580 0000000000400580 00000580 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
16 .eh_frame_hdr 00000034 0000000000400584 0000000000400584 00000584 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
17 .eh_frame 000000f4 00000000004005b8 00000000004005b8 000005b8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
18 .init_array 00000008 0000000000600e10 0000000000600e10 00000e10 2**3
CONTENTS, ALLOC, LOAD, DATA
19 .fini_array 00000008 0000000000600e18 0000000000600e18 00000e18 2**3
CONTENTS, ALLOC, LOAD, DATA
20 .jcr 00000008 0000000000600e20 0000000000600e20 00000e20 2**3
CONTENTS, ALLOC, LOAD, DATA
21 .dynamic 000001d0 0000000000600e28 0000000000600e28 00000e28 2**3
CONTENTS, ALLOC, LOAD, DATA
22 .got 00000008 0000000000600ff8 0000000000600ff8 00000ff8 2**3
CONTENTS, ALLOC, LOAD, DATA
23 .got.plt 00000020 0000000000601000 0000000000601000 00001000 2**3
CONTENTS, ALLOC, LOAD, DATA
24 .data 00000011 0000000000601020 0000000000601020 00001020 2**3
CONTENTS, ALLOC, LOAD, DATA
25 .bss 00000007 0000000000601031 0000000000601031 00001031 2**0
ALLOC
26 .comment 00000035 0000000000000000 0000000000000000 00001031 2**0
CONTENTS, READONLY
现代操作系统也是把程序分了这么多个段,前面是段号,Size是段的大小。
其中我们最熟悉的13 .text 代码段;24 .data 数据段。
这两个段,以后讲虚拟内存还会回来介绍的。
4.1.2 各段放入内存信息
上面我们见识到了,一个程序分了这么多个段,每个段都各自加载到内存中,内存是怎么申请的呢?
就是上一篇写的分区,通过连续内存分区分配的方式给程序中各个段分配内存。(虽然我们上一节课说了分区分配很多缺点,但是也是需要了解一下,并且在下面会提出解决分区分配缺点的方案)
这个图就是分段后,进程加载进内存的部署,当然现在操作系统使用的虚拟内存并不是这样(不过也差不多了),不过我们暂时先看看之前是怎么处理的,怎么演变过来的。
我们也随便看看现在操作系统进程加载进内存的分布:
root@ubuntu:/proc/1604# cat maps
00400000-00401000 r-xp 00000000 08:01 12460229 /root/knowledge_systeam/4.os/3.Memory/1.principle/01/test
00600000-00601000 r--p 00000000 08:01 12460229 /root/knowledge_systeam/4.os/3.Memory/1.principle/01/test
00601000-00602000 rw-p 00001000 08:01 12460229 /root/knowledge_systeam/4.os/3.Memory/1.principle/01/test
7f9c3cb34000-7f9c3ccf4000 r-xp 00000000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f9c3ccf4000-7f9c3cef4000 ---p 001c0000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f9c3cef4000-7f9c3cef8000 r--p 001c0000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f9c3cef8000-7f9c3cefa000 rw-p 001c4000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f9c3cefa000-7f9c3cefe000 rw-p 00000000 00:00 0
7f9c3cefe000-7f9c3cf24000 r-xp 00000000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f9c3d116000-7f9c3d119000 rw-p 00000000 00:00 0
7f9c3d123000-7f9c3d124000 r--p 00025000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f9c3d124000-7f9c3d125000 rw-p 00026000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f9c3d125000-7f9c3d126000 rw-p 00000000 00:00 0
7ffe32b26000-7ffe32b47000 rw-p 00000000 00:00 0 [stack]
7ffe32ba9000-7ffe32bac000 r--p 00000000 00:00 0 [vvar]
7ffe32bac000-7ffe32bae000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
也是一个段占一块内存,不过因为有虚拟内存的存在,每个段的地址都知道了,不过之前的系统在没有虚拟内存的情况下,每个段的地址和段号是根据段表来决定的,并且如果程序的运行也离不开段表。
下面我们就来了解了解段表。
4.1.3 段表
因为我们分了很多个段,并且每个段都有不同的基地址,这样的结构让我们想起了表。
计算机也引入了一张这样的表,叫段表。段表的每个条目都有段号、段基地址、段界限。
段基地址包含该段在内存中开始物理地址,逻辑地址映射成物理地址有用。
段界限指该段的长度,一般做保护处理,怕访问溢出。(这个可以看前面章节)
接下来我们就按哈工大老师说的例子,我们来过一边重定位:
mov [DS:100], %eax
jmpi 100, CS
段号 |
基地址 |
长度 |
保护 |
0 |
180K |
150K |
R |
1 |
360K |
60K |
R/W |
2 |
70K |
110K |
R/W |
3 |
460K |
40K |
R |
问题:假设DS=1,CS=0,上面两条指令运行时重定位成什么?
DS=1,说明这个数据段在1号段,1号段的基地址为360K,段内偏移是100,所以我们要把这个内存里的数据:360K+100,赋值到eax寄存器中。
CS=0,说明这个指令段在0号段,0号段的基地址为180K,段内便宜也是100,所以我们要跳到180K+100的指令上,然后继续执行。
4.1.4 总结
我们引入分段之后,跟之前执行程序的最大区别是,之前只有一个重定位寄存器即可,因为都是一个段,只有一个基地址加偏移就可以了,现在分了很多个段,自然就需要段表了,所以执行程序的时候多了一步查找过程。
现代操作系统其实有一个GDT和LDT表,跟上面的段表比较相似了,GDT表示操作系统标记每个进程的地址的,LDT表示每个进程中各个段的地址,有点跟段表相似了,之后再分析这两个。
4.2 分页
4.1介绍的分段是对程序的划分,并且也是对逻辑地址的划分,并且通过分区分配的方式加载进程的各个段,上一篇也介绍了分区方式的缺点,所以分区的方式是操作系统早期对物理内存的划分。那现在操作系统对内存是如何划分的???
没错,现代操作系统对物理内存的划分是分页。那啥是分页呢???
上一篇我们用了分面包来介绍分区,那大家想想现在分面包是怎么分的???
现在的面包是分片,每个人的胃口都不一样,胃口大的就吃多几片,胃口小的就吃小就吃一片,这样浪费就不会那么多,内存的分页也是这种思想,没页大小是4KB,当然也有其他的大小。
下面我们就来看看是怎么使用分页的。
4.2.1 概念介绍
分页是将物理内存分为固定大小的块,称为帧、页帧或页框。每个页框(帧、页帧)都有一个编号,叫做页框号(帧号、页帧号),也框号是从0开始的。
将逻辑内存(是否想起之前说的地址空间的抽象)也分为跟页框大小一样的块,称为页或页面。每个页面也有一个编号,叫页号,页号也是从0开始。
4.2.2 各段加载进内存
前面感受了分段的时候,一整块加入内存,那现在物理内存引入了分页,我们就来感受一下分页吧。
这样为了画少一点,我把一页设为512K,其中代码段为1M,刚好占了两页,数据段512k也刚好一页,然后堆段只有612k,但是也需要占两也,其中剩下的就是内部碎片,因为我这里分的页大,所以内部碎片也很大,其实实际系统大部分都只分4k作为一页,即使有内部碎片也影响不大,栈段也是1M,刚好2页。
最后存在物理内存中,也是离散的,这就是分页的好处,不用一起申请那么大的内存。所以可以离散存储。但是也因为这种离散存储,导致我们指令寻找不到物理地址,那这里也再次引入页表的概念。
页表其实跟上面说的段表差不多,也是通过页号了找到对应的物理地址。
4.2.3 例子分析
那逻辑地址是怎么找到物理地址的,我们来研究一下。
例子还是看哈工大老师举的例子:
mov [0x2240], %eax // 逻辑地址
页号 |
页框号 |
保护 |
0 |
5 |
R |
1 |
1 |
R/W |
2 |
3 |
R/W |
3 |
6 |
R |
我们的页就按4K来计算。
逻辑地址为0x2240,那它的
页号= 0x2240 / (4 *1024) = 0x02
页偏移 = 0x2240 % (4 *1024) = 0x240
通过查找页表,页号为2的,页框号为3,页框号为3的话,基地址为0x03 * 4 * 1024 = 0x3000
最后再加上偏移,最终物理地址 = 0x3000 + 0x240 = 0x3240
4.2.4 总结
我们这一篇学习了程序分段,物理内存分页,也就是段页式初步学习,一个程序的执行,需要先查段表,查出段表中的基地址,然后计算出当初指令或数据的逻辑地址,然后再根据逻辑地址计算出在哪一页,然后通过页号去查页表,最后才找到具体的物理地址,这样确实麻烦了不少。不过后期虚拟内存的引进就少了一层,下一篇我们主要学习页表,快接近真想了,加油。