一、进程的装载方式
1.1 覆盖装载
覆盖装载 现在可能被淘汰了,这是一种非常久远的装载方式,它在计算机发展初期提供了内存受限下,程序正常运行的解决方式。
程序员需要编译 辅助代码 来管理代码模块的装载和卸载,这个 辅助代码 也称为 覆盖管理器。
假设有一个程序,分为 3 个模块:main(1024Byte)、A模块(512Byte) 和 B模块(256Byte),其中 A模块 和 B模块 不会相互调用。理论上需要有 1792Byte 的内存空间才能完整装载程序。
内存图为了解决这个问题,程序需要带有 覆盖管理器,且其中 A模块 和 B模块 共享一块内存。在 main 需要调用 A模块 时,将 B模块 换出内存,反之亦然。这样我们仅需要 1536Byte 的内存,比理论上所需要的内存减轻不少。
覆盖管理器 在组织内存时,需要 将模块按照它们之间的调用依赖关系组织成树状结构,如下图所示:
覆盖管理器 需要保证
- 调用路径 上的必须都在内存中。(树状结构中的 任何一个模块 到 根模块 都叫 调用路径)
- 禁止跨树调用
1.2 页映射
在操作系统中,将 内存 按 页(Page) 进行组织,详情可以查看我前面的文章 ARM体系架构——MMU
现在假设 32bit 的机器,每页为 4KB。其内存有 16KB 并分成 4页,即F0-F3。程序一共 32KB 分成 8页,即 P0-P7。我们可以假定程序有个 装载管理器 来管理页的换入换出。
那么在程序启动后假设有下图的装载关系:
如果程序需要访问 P4 时,那么 装载管理器 需要选择一个 内存页 来换入换出,目前有多种 选择算法。其余同理,同理不断的换入换出 内存页,就可以实现动态装载。这个 *装载管理器 也就是我们现在说的 操作系统或者 存储管理器。
常用的选择算法有:
- FIFO算法(先进先出算法)
- LUR算法(最少使用算法)
二、装载可执行程序
前文讲了 2种 装载方式,如果它们在使用 物理地址 的情况,在这种情况下每次换入换出都需要进行 重定位。现代计算器都拥有 MMU,其提供了 地址转换功能。有了 MMU ,操作系统 动态加载 可执行文件的方式就有了很大的改变。
2.1 进程的建立
- 建立虚拟空间:创建一个 独立的虚拟地址空间。虚拟空间由一组 页映射函数 将虚拟空间中的各个页 映射 到对应的物理空间。创建虚拟空间并不是创建 实际的内存空间,而是创建 映射关系 所需要的 数据结构。在 Linux 下,创建虚拟空间实际上是分配一个 页目录。
- 建立映射关系:读取 可执行文件头,并建立 虚拟空间 与可执行文件的 映射关系。在代码上,这种 映射关系 表现为一种数据结构,在 Linux c称为 虚拟内存区域(VMA,Virtual Memory Area),VMA 记录了 页 在可执行文件中的 位置。当程序发生 缺页异常 时,操作系统从 物理内存 中分配一个 物理页,然后从 存储介质 中读取对应的 *程序页 到 物理内存页中,再设置 虚拟页 到 物理页 的映射关系。 VMA 记录了发生异常的 虚拟地址页 在 可执行文件 中的位置,从而可以读取对应的 页。
- 设置入口地址:将 CPU 的 指令寄存器 设置成可执行文件的 入口地址,启动运行。ELF文件头 保存了程序的 入口地址。
需要注意的是,虚拟内存 的映射是以 页 为单位,一般是 4KB。如果一个段小于该大小,由于对齐原因,该段会占用一个 内存页。比如一个 .txt段 小于 4KB,那么在内存中该段会占用一个 4KB 大小的 内存页。
2.2 VMA
VMA 即 虚拟内存区域(Virtual Memory Area),可以使用下面的查看进程的 虚拟空间分布
cat /proc/pid/maps
其中 pid 为对应的进程号,其输出可以看笔者的文章 进程内存优化。
一个进程一般有以下几种 VMA:
- 代码VMA:可读可执行,映射到程序文件
- 代码VMA:可读可写,映射到程序文件
- 堆VMA:可读可写可执行,不映射到程序文件中,匿名,向上扩展
- 代码VMA:可读写,不映射到程序文件,匿名,向下扩展
2.3 页错误
- 程序访问没有映射到物理页的虚拟地址
- 查找该虚拟地址所在的虚拟页对应的VMA
- 根据VMA计算出对应页面在可执行文件中的偏移
- 物理内存分配物理页
- 建立物理页到虚拟页的映射
- 将可执行文件中的页读取到内存中
- 返回程序继续执行
三、链接视图和执行视图
ELF文件 在被映射时,是以 页长度(4KB) 为单位的。如果每个 段(section) 的长度不是 页长度 的整数倍,那么将浪费内存。一个 ELF文件 往往有很多个 段,那么这种浪费将进一步的扩大。
由于操作系统在装载可执行文件时并不关心 段(section) 的实际内容,而是只关心装载的相关属性,主要就是 段权限(可读、可写、可执行)。权限一般有以下几种组合:
- 可读可执行,比如 代码段
- 可读可写,比如 数据段
- 只读,比如 只读数据段
对于相同权限的段,把它们合并到一起当做一个段进行映射,这个段也称为 segment。一个 segment 包含一个或多个属性类似的 Section。操作系统将每个 segment 映射到进程虚拟空间后就对应一个 VMA。由此减少内存浪费
Segment 和 Section 是从不同角度来看到 ELF文件。从 Segment 看就是 执行视图,从 Section 看就是 链接视图。
用以下这段代码作为示例:
/* SectionMapping.c */
#include <stdlib.h>
#include <unistd.h>
int main()
{
while(1)
{
sleep(1000);
}
return 0;
}
使用以下命令进行 编译:
arm-linux-gnueabihf-gcc -static SectionMapping.c -o SectionMapping.elf
使用以下命令查看 section:
sectionarm-linux-gnueabihf-readelf -S SectionMapping.elf
使用以下命令查看 segment:
segmentarm-linux-gnueabihf-readelf -l SectionMapping.elf
根据上面的结果我们就可以看出一个程序中 section 和 segment 的关系。
3.1 程序头表
ELFke可执行文件 有个专门的数据结构用于描述 segment ,称为 程序头表(Program Header Table)。由于 目标文件 不需要装载到内存,所以没有 程序头表。
typedef struct
{
Elf32_Word p_type; /* Segment type */
Elf32_Off p_offset; /* Segment file offset */
Elf32_Addr p_vaddr; /* Segment virtual address */
Elf32_Addr p_paddr; /* Segment physical address */
Elf32_Word p_filesz; /* Segment size in file */
Elf32_Word p_memsz; /* Segment size in memory */
Elf32_Word p_flags; /* Segment flags */
Elf32_Word p_align; /* Segment alignment */
} Elf32_Phdr;
- p_type:segment 类型,一般有 LOAD、DYNAMIC 和 INTERP
- p_offset:segment 在文件中的偏移
- p_vaddr:Segment 在虚拟空间中的 起始地址
- p_paddr:Segment 的 物理装载地址,一般与 p_vaddr 的值一致。
- p_filez:Segment 在 文件 所占的空间长度
- p_memse:Segment 在 虚拟空间 中所占长度。由于内存对齐,该值一般 不小于p_filez,而有些 Segment 扩充的多余部分可以用于建立 BSS段。
- p_flags:Segment 的权限属性,一般有 R(可读)、W(可写) 和 X(可执行)
- p_align:Segment 的对齐属性
3.2 段地址对齐
在一段物理内牧场呢和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是 页长度(4096) 的整数倍,并且其其实地址也需要为 页长度(4096) 的整数倍。由于该限制,可执行文件应该尽量优化空间和地址的安排,以节省空间。不然将会造成空间上的浪费。
下面结合书中例子进行讲解:
操作系统为了解决 内存浪费 的问题,将各个段 接壤的部分 共享一个 物理页面,然后将该物理页面分别映射 2次 。
段 | 长度(字节) | 偏移 | 权限 |
---|---|---|---|
SEG0 | 127 | 34 | 可读可执行 |
SEG1 | 9899 | 164 | 可读可写 |
SEG2 | 1988 | 只读 |
每个 段 的长度都不是 页长度 的整数倍。由于对齐要求,长度不足一个页的部分则占据一个页。按照这种做法,则 ELF文件 中各个段的信息如下:
段 | 起始虚拟地址 | 大小 | 有效字节 | 偏移 | 权限 |
---|---|---|---|---|---|
SEG0 | 0x08048000 | 0x1000 | 127 | 34 | 可读可执行 |
SEG0 | 0x08049000 | 0x3000 | 9899 | 164 | 可读可写 |
SEG0 | 0x0804C000 | 0x1000 | 1988 | 0 | 只读 |
从上表可知,总长度只有 12014字节,但占据了 5 个页,即 20480字节。空间利用率为 58.6%。
为了解决上面的问题,操作系统让各个 段的接壤部分 共享一个物理页面,然后将该物理页面分别映射 2次。如下图所示:
操作系统可以通过访问 VMA1 来访问与 SEG0 共享的物理页,同理通过访问 VMA0 也可以实现对 SEG0 的访问。
通过这种方式,ELF文件 从占据 5个 页面优化为占用 3个 页面,从而减少了内存浪费。
通过下面的指令来查看例程代码的 segment信息:
segment信息arm-linux-gnueabihf-readelf -l SectionMapping.elf
注意:这里的例子与书中不是很相符,笔者下面仅按照自己的理解进行讲述
- 代码段:起始地址为 0x00010000,偏移为 0x000000
- 数据段:起始地址为 0x00077F70,偏移为 0x057F70
根据上面的信息,可以大概得出 数据段 占据了 0x57F70 的长度。代码段 和 数据段 相邻,代码段 的 虚拟起始地址 为 0x10000,那么数据段的 虚拟起始地址 应该为 0x67F70。由于对齐是按照 0x10000 进行的,且将 VMA0(代码段) 和 VMA1(数据段) 共享页面映射了 2 次,所以地址就修改为 0x77F70。也就是说 0x67F70 到 0x77F70 之间的页面由 代码段 进行访问,而这段空间就是共享的页面。
网友评论