可执行文件的装载与进程
学习《程序员的自我修养》一书,进行记录总结。本文为第六章的主要内容。第四章静态链接,近期才看过,准备过一段时间回头写再加深印象。第五章是windows的PE格式,不准备看了。
进程虚拟地址空间
这部分内容较为容易理解,只记录一下程序和进程的区别。程序是一个静态的概念,是一些预先编译好的指令和数据集合的一个文件,进程则是一个动态概念,是程序运行的一个过程。当然上面这句话在单线程进程中比较适合,而在多线程环境中,进程其实是一个资源概念,标志了一个运行中程序占有的文件描述符、物理内存空间等,而线程才是一个运行的概念。
在32位系统下,虚拟地址空间只有4G大小,但现代操作系统都将地址空间的后面的一部分划分给内核,前面的部分才可供用户进程使用。在物理内存这个尺度上,进程在物理内存上是散乱分布的,通过内存地址映射机制将物理地址映射成虚拟地址以达到进程空间的互斥。在所有进程的虚拟地址空间中默认情况下,windows将后2G分给了系统内核,而linux将后1G分给了内核,当然这些都可以进行配置。

不管怎么分,总共4G的内存空间显然是不够用的,所以在32位系统的时代,intel推出了Pentium Pro CPU,采用36位物理地址,进而可以使用64GB的内存空间。而访问方式采用窗口映射策略,即将一段大小的内存空间,映射到多个高于32位的物理内存上,当需要时进行切换,从而达到使用32位虚拟地址空间访问大于4G物理内存的目的。windows下这个操作叫AWE,linux下叫mmap。
装载的方式
程序执行时所需要的指令和数据必须在内存中才能够正常运行,但是一般情况下程序所需内存远大于物理内存。为了解决这个问题,有两种装载方式,覆盖装入(Overlay)和页映射(Paging)。
覆盖装入
核心思想是将程序的模块进行分割,如果两个模块不会相互调用,那么两个模块可以共享同一段空间,用谁就加载谁。在编程时程序员需要手动将各模块间的依赖关系展开成树形结构。

- 从根到叶子的路径叫调用路径
- 禁止跨树间调用
不想写了,没用。
页映射
主要策略是将物理内存划分成页,而虚拟地址也被分成页,这两个页间可以定义一种映射关系,虚存与物存的映射方式、数据存储、替换方法都由内存管理单元MMU完成,显然需要一个页表存储在进程相关的空间中。至于映射方式,有本科计组中学到的全相联、组相联、直接映射等方式。
可执行文件的装载
进程的建立
这个过程分三步
-
创建虚拟地址空间
-
创建虚拟地址空间其实不是真的把一段空间抹成什么值,而是仅仅分配一个数据结构,在linux下仅仅时分配一个页表
(Page Directory) -
读取可执行文件头,建立虚拟空间与可执行文件的映射关系
-
为何不直接将文件读进内存?这是由于分页机制所带来的天然便利所决定的。由于分页有页表的存在,我们可以知道什么页被正确地映射到了物理内存上,什么页还没有被设置。装载的第二步,仅仅需要建立从虚存到可执行文件的映射关系,这样当程序运行到未被映射的虚拟空间时,将发生
缺页中断,这个机制可以使得系统能够根据映射关系从文件直接把数据加载到物理空间的页上,同时建立好物虚的映射关系。当然物虚关系还是有MMU完成的

-
物虚映射靠的是
页表,而文虚映射靠的是虚拟内存区域(VMA, Virtual Memory Area),windows中叫做虚拟段(Virtual Section)。注意这个数据结构是进程级的。如上图,操作系统创建进程后,会在进程的数据结构中设置一个属于.text段的VMA,从0x8048000到0x8049000,以及其他的一些附属信息。当指令寄存器跑到这部分区域时,由于未建立物虚映射,发生缺页中断,系统选一个物存页,进行物虚映射,将文件读取到虚拟地址空间里。 -
缺页中断与页映射构成了一门比较深的学问,暂时就不展开了
-
将CPU指令寄存器设置成可执行文件入口,启动运行
-
从进程角度看,只是简单的将IP(指令寄存器)设置到入口处,但是在操作系统层级牵扯到内核堆栈和用户堆栈的切换、CPU运行权限的切换等。在可自行文件中,程序入口默认被链接器指定到了main函数哪里,当然可以通过参数进行设置。
进程虚存空间分布
实际的ELF文件包含了多个段,比如在目标文件与可执行文件中,有13个段。这些段的权限一般的组合比较有限。可读可执行、可读可写、只读等。对于权限相同的段,直接把它们合并到一起即可。ELF在这里引入了Segment的概念,一个Segment由一个或多个Section合并得到,而Section是前面提到的段。Segment从装载角度上重新划分了ELF的各个段,Segment信息存储在ELF中,直接由ELF头进行索引。使用readelf -h可以看到ELF中Segment的个数,输出中使用Number of program headers表示。使用readelf -h可以看到Segment的详细信息。
由于只有可执行程序才有Segment所以本文章代码相较于目标文件与可执行文件有所调整。
#include <stdio.h>
int g_uninit;
int g_init = 2;
int main()
{
int l_int = 1;
static int s_int;
printf("g_unint %d, g_init %d, l_int %d, s_int %d\n", g_uninit, g_init, l_int, s_int);
return 0;
}
依次执行如下命令。
$gcc -o a.out a.c
$readelf -l a.out
Elf file type is EXEC (Executable file)
Entry point 0x400430
There are 9 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
0x00000000000001f8 0x00000000000001f8 R E 8
INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238
0x000000000000001c 0x000000000000001c R 1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x000000000000074c 0x000000000000074c R E 200000
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x000000000000022c 0x0000000000000238 RW 200000
DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
0x00000000000001d0 0x00000000000001d0 RW 8
NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254
0x0000000000000044 0x0000000000000044 R 4
GNU_EH_FRAME 0x0000000000000624 0x0000000000400624 0x0000000000400624
0x0000000000000034 0x0000000000000034 R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x00000000000001f0 0x00000000000001f0 R 1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .jcr .dynamic .got
可以看到共有8个Segment。在进行装载时将被映射到8个VMA。
- 为什么用
Segment?为了防止Section导致的内存碎片,如果一个页为4096,一个Section为4097,另一个为512,单独加载将用到3个页,而使用Segment策略只需要2个页。
堆和栈
进程执行过程中需要用到堆和栈,它们在操作系统中也是以VMA的形式存在的。
在执行程序后,执行shell命令cat /proc/
如将上面a.c代码后加一个死循环,会导致程序始终运行从而可以观察其内存空间。
$cat /proc/xxxx/maps
00400000-00401000 r-xp 00000000 08:06 2648398 /home/libaoyu/tmp/a
00600000-00601000 r--p 00000000 08:06 2648398 /home/libaoyu/tmp/a
00601000-00602000 rw-p 00001000 08:06 2648398 /home/libaoyu/tmp/a
7fdf0dd70000-7fdf0df30000 r-xp 00000000 08:06 7209055 /lib/x86_64-linux-gnu/libc-2.23.so
7fdf0df30000-7fdf0e130000 ---p 001c0000 08:06 7209055 /lib/x86_64-linux-gnu/libc-2.23.so
7fdf0e130000-7fdf0e134000 r--p 001c0000 08:06 7209055 /lib/x86_64-linux-gnu/libc-2.23.so
7fdf0e134000-7fdf0e136000 rw-p 001c4000 08:06 7209055 /lib/x86_64-linux-gnu/libc-2.23.so
7fdf0e136000-7fdf0e13a000 rw-p 00000000 00:00 0
7fdf0e13a000-7fdf0e160000 r-xp 00000000 08:06 7209053 /lib/x86_64-linux-gnu/ld-2.23.so
7fdf0e32d000-7fdf0e330000 rw-p 00000000 00:00 0
7fdf0e35f000-7fdf0e360000 r--p 00025000 08:06 7209053 /lib/x86_64-linux-gnu/ld-2.23.so
7fdf0e360000-7fdf0e361000 rw-p 00026000 08:06 7209053 /lib/x86_64-linux-gnu/ld-2.23.so
7fdf0e361000-7fdf0e362000 rw-p 00000000 00:00 0
7fff1f483000-7fff1f4a4000 rw-p 00000000 00:00 0 [stack]
7fff1f4ce000-7fff1f4d1000 r--p 00000000 00:00 0 [vvar]
7fff1f4d1000-7fff1f4d3000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
第一列为地址范围,第二列为权限,p为私有有(COW,写时复制),s为共享,第三列为VMA在文件中的偏移,第四列为文件的设备号,第五列为映像文件的节点号,最后为映像文件的路径。
前面说相同的权限的Section被合并为Segment,所以在ELF文件中,段是按照Segment顺序存的,这样可以直接映射。
Linux还加了一些trick,将包含bss段和一个libcfreeres_ptrs段的VMA进行部分映射,将这两个段归到了堆空间中。所以实际计算下来和之前的模型可能有些出入。
堆的最大申请数量
这个根据系统实际情况可能会有不同。
段地址对齐
进程空间起始地址为4096对齐,直接将各个段映射到物理内存上,如果要对齐,会导致很多页被用一半。UNIX给出的解决方法就是将接壤的物存进行共享,在物虚映射时映射两次,前一次跟着前面的段的虚存,后一次另起一页虚存。以下为一个样例。

对于这个例子,如果直接进行4096对齐然后进行映射需要五个物理页。而采用UNIX系统映射策略可以只用三个物理页即可。注意ELF头也被映射到了虚存中,起始地址为0X08048000,0X22和0XA4分别为十进制34和164的十六进制表示。具体过程通过下图即可理解。

进程栈初始化
栈是从内核的高地址向低地址扩展的。而栈与内核之间保存了系统变量、命令行参数、命令行命令等。书上例子当运行
$prog 123
时,栈的初始化情况如下:

从上往下看起来,最上面(高位地址)全部是环境变量字符串拼出来的内存空间。接下来时命令行参数,再下来是命令。接着,是指向上面的指针,先是环境变量指针,再是参数和命令的指针,再是命令行参数的个数,main里面的argc,argv其实就是这么实现的!
Linux内核装载ELF过程简介
用户层面,当在bash内输入一条命令,bash首先调用fork创建新的进程,之后新的进程调用execve执行指定的elf文件,execve具体细节不再展开。进入execve系统调用后,Linux内核开始进行真正的装载工作。execve的系统调用入口为sys_execve(),其进行参数的检查后调用do_execve。do_execve的过程如下:
-
查找、读取文件前128字节,根据
魔术来决定加载程序的具体方法,这是因为除了ELF外,还有Java可执行程序、脚本语言等,都需要不同的处理方式。ELF的魔术为0X7F,'e','l','f'。而elf的处理过程叫做load_elf_binary。 -
检查ELF的格式的有效性。
-
寻找动态链接的
.interp段,设置动态链接器路径。 -
根据ELF的头表的描述,进行文虚内存映射
-
初始化ELF进程环境,比如EDX寄存器该指向DT_FINI,(参照动态链接)。
-
将系统调用的返回地址修改为ELF可执行文件的入口点,对于静态ELF可执行文件,入口点为e_entry,对于动态链接的ELF可执行文件,入口点为动态链接器,于是程序继续从入口点执行。(参照动态链接)

浙公网安备 33010602011771号