程序员的自我修养

我这里是根据hollk师傅的博客来的

可执行文件的装载与进程

这部分的内容主要是关于linux的,而不是windows.

一:进程虚拟地址空间

先说说程序和进程的区别:

  • 程序(可执行文件):是一个静态的概念,是一些预先编译好的指令和数据集合的一个文件
  • 进程:是一个动态的概念,是程序运行时的一个过程,很多时候把动态库叫做运行时(Runtime)
    当每一个程序运行起来之后,都会拥有自己的独立虚拟地址空间,而虚拟空间的大小是由CPU的位数决定的,比如,32位的就是 02^32
    也就是所谓的4G内存,但是程序不能完全掌控这4G的内存,因为进程只能使用操作系统分配给进程的地址,如果访问未经允许的空间,在Linux就会出现”Segmentation“的错误,并且会强制结束进程,未被操作系统分配的内存地址是不合法,不被允许访问的
    因此整个4G的内存被分为2部分,一部分是1G(0x00000000
    0xFFFFFFFF)的操作系统,另一部分是3G(0x00000000~0xBFFFFFFF)的程序,进程其实不能完全使用这3GB,其中有一部分是预留给其他用途的
    对于Windows操作系统来说,它的进程虚拟地址空间划分是操作系统占用2GB,剩下的2GB给进程,当留给程序的2G的内存不够时,Windows有个启动参数可以将操作系统占用的虚拟地址空间减少到1GB,就和Linux分布一样了

二、装载的方法

人们希望在不添加内存的情况下让更多的程序运行起来,尽可能有效的利用内存。程序运行时是有局部性原理的,所以可以将程序最常用的部分留在内存中,将一些不太常用的数据存放在磁盘里,这就是动态装入的基本原理。

覆盖装入(Overlay)和页映射(Paging)是两种典型的动态装载方法,都是利用了程序的局部性原理。其中,覆盖装入过时了,因此说明页映射,
页映射会将内存和所有磁盘中的数据和指令按照“页”为单位划分成若干个页,装载和操作的单位就是页。假设32位机器有16KB的内存,每个页大小为4096字节:
页编号 地址
F0 0x00000000——0x00000FFF
F1 0x00001000——0x00001FFF
F2 0x00002000——0x00002FFF
F3 0x00003000——0x00003FFF

假设程序的指令和数据总和为32KB,那么程序总共被分为8页,那么在动态装载的原理看来,假设程序的入口地址在P0,那么装载管理器发现程序P0不再内存中,于是就将内存F0分配给P0,需要用到P5,检查之后发现不在内存,就把F1分配给P5。同理用到哪一块就先检查,不在内存中就分配空间
![[Pasted image 20251027152802.png]]
如果程序只需要P0、P3、P5、P6这四个页,那么程序运行没有问题,如果程序此时需要访问P4,那么就需要让出一块内存空间来。那么让出来的哪块空间就需要考虑了:

可以选择F0,因为他是第一个被分配掉的内存页,这个算法称为FIFO,先进先出算法
假设装载管理器发现F2很少北方问道,那么就可以选择F2,这种算法可以称之为LUR,最少使用算法

三、从操作系统的角度看可执行文件的装载

一个进程的关键特征是拥有独立的虚拟地址空间。创建一个进程,然后装载相应的可执行文件并且执行,在有虚拟存储的情况下,最开始只需要做三件事:

  • 创建一个独立的虚拟地址空间
  • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
  • 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
创建虚拟地址空间

一个虚拟地址由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录就可以了,这一步不需要设置页映射关系,等到后面程序发生页错误的时候再进行设置

读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系

这一步做的是虚拟空间与可执行文件的映射关系。当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才能运行。当操作系统捕获到缺页错误时,应该知道程序当前所需要的页在可执行文件中的哪个位置。这就是虚拟空间与可执行文件之间的映射关系,这是整个装载过程中最重要的一步。

假设一个ELF可执行文件中只有一个代码段“.text”,它的虚拟地址为0x08048000,在文件中的大小为0x000e1,对齐为0x1000,由于虚拟存储的页映射都是以页为单位的,在32位的Intel IA32下一般为4096字节,所以该.text段大小不到一个页 ,考虑到对齐该段占用一个段,所以一旦该可执行文件被装在,可执行文件与执行该可执行文件进程的虚拟空间的映射关系如下:![[Pasted image 20251027153432.png]]
ELF可执行文件引入一个概念叫“Segment”,在ELF中把属性相似的、又连在一起的段叫做一个“segment”,系统按照“segment”来映射可执行文件的。一个“segment”包含一个或多个属性类似的“Section”。如果将“.text”段和“.init”段一起看做一个“segment”,那么装载的时候就可以看做一个整体一起映射,映射以后进程空间中只有一个相对应的VMA,可以减少页面内部碎片,进而可以节省内存空间

“Segment”和“Section”是从不同角度来划分同一个ELF文件。从“Section”的角度来看ELF文件就是链接视图(Linking View),从“Segment”的角度来看就是执行视图(Execution View),当在谈到ELF装载时,“段”专门指“Segment”,其他情况下都是指“Section”
![[Pasted image 20251027155523.png]]
ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table),用来保存“Segment”的信息,因为ELF目标文件不需要被装在,所以他没有程序头表,但ELF的可执行文件和共享库文件都有,和段表结构一样,程序头表也是一个结构体数组:

1
2
3
4
5
6
7
8
9
10
11
typedef struct {
ELF32_Word p_type;
ELF32_Off p_offset;
ELF32_Addr p_vaddr;
ELF32_Addr p_paddr;
ELF32_Word p_filesz;
ELF32_Word p_memsz;
ELF32_Word p_flags;
ELF32_Word p_align;
} Elf32_Phdr

堆栈

接下来是比较重要的内容:内存的堆栈,
操作系统通过使用VMA来对进程的地址空间进行管理,例如栈(Stack)和堆(Heap),他们在进程的虚拟空间中的表现也是以VMA的形式存在的,并且一个进程中的栈和堆分别都有一个对应的VMA
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA,一个进程基本上可以分为如下VNA区域:

  • 代码VMA,权限只读、可执行;有映像文件
  • 数据VMA,权限可读写、可执行;有映像文件
  • 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展
  • 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展
    ![[Pasted image 20251027155823.png]]

段地址对齐

可执行文件需要被操作系统装载运行,装载过程一般是通过虚拟内存的页映射完成的,要映射一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是页大小的整数倍。举个栗子,Intel 80x86系列处理器默认页大小为4096字节,假设有一个ELF可执行文件,有三个段(Segment)需要装载,分别命名为SEG0、SEG1和SEG2.每个段的长度、在文件中的偏移如下:

长度(字节) 偏移(字节) 权限
SEG0 127 34 可读可执行
SEG1 9899 164 可读可写
SEG2 1988 只读

每个段的长度都不是页长度的整数倍,最简单的映射办法就是每个段分开映射,对于长度不足一个页的部分则占一页。通常ELF可执行文件的起始虚拟地址为0x08048000,那么该ELF文件中的各个段的虚拟地址和长度如下:

起始虚拟地址 大小 有效字节 偏移 权限
SEG0 0x08048000 0x1000 127 34 可读可执行
SEG1 0x08049000 0x1000 9899 164 可读可写
SEG2 0x0804C000 0x1000 1988 只读

可以看到这种对齐方式在文件段的内部会有很多内部碎片,浪费空间,三个段加起来就12014字节,但是却占了5个页。为了优化,一些UNIX系统让各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次:

neicun2

比如对于SEG0和SEG1的接壤部分的物理页,系统将他们映射两份到虚拟地址空间中,一份为SEG0,另一份为SEG1,其他页都按照正常的页粒度进行映射。而且UNIX系统将ELF的文件头看做系统的一个段,将其映射到进程的地址空间,进程中某一段区域就是整个ELF文件的映像,对于一些需要访问ELF文件头的操作(比如动态链接器读ELF文件头)可直接通过读写内存地址空间进行。好像整个ELF文件从文件最开始到某个点结束,被逻辑上分成了以4096字节为单位的若干个块,每个块都被装载到物理内存中,对于位于两段中间的块,会被映射两次,上面例子中ELF文件的映射方式如下

起始虚拟地址 大小 偏移 权限
SEG0 0x08048022 127 34 可读可执行
SEG1 0x080490A4 9899 164 可读可写
SEG2 0x0804C74F 1988 可读可写

四、进程栈初始化

在进程刚开始启动时,须知道一些进程运行的环境,基本的是系统环境变量和进程的运行参数。操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中,假设系统中有两个环境变量:

  • HOME = /home/user
  • PATH = /usr/bin

比如运行命令为:

1
prog 123

假设栈底地址为0xBF802000,那么进程初始化后的堆栈为:

neicun3

栈顶寄存器esp指向的位置是初始化以后栈的顶部,最前面4个字节表示命令行参数的数量,即“prog”和“123”,紧接的就是分布指向这两个参数字符串的指针。0表示结束。接着是两个指向环境变量字符串的指针,分别指向字符串“HOME=/home/user”和“PATH=/usr/bin”,最后以0结束

进程在启动以后,程序的库部分会把栈中的初始化信息中的参数传递给main()函数,就是main()函数的两个argc和argv两个参数,这两个参数分别对应这里命令行参数数量和命令行参数字符串指针数组

5、Linux内核装载ELF过程简介

这块讲一下Linux系统中通过bash中输入命令执行ELF程序时,Linux系统是怎么装载整个ELF文件并执行的

首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。execve()系统调用被定义在unistd.h,原型如下:

1
1int execve(const char *filename, char *const argv[], char *const envp[]);

它的三个参数分别是执行文件的文件名、执行参数和环境变量。Glibc对execvp()系统调用进行了包装,提供了execl()、execlp()、execle()、execv()和execvp()等五个不同形式的exec系列API,只是在调用的参数形式上有所区别,但最终都会调用到execve()这个系统中

在进入execve()系统调用后,Linux内核就开始进行真正的装在工作。

1
2
3
4
在内核中execve()系统调用相应的入口是sys_execve(),sys_execve()进行一些参数的检查复制后,调用do_execve()
do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节判断文件的格式(后面解释为什么读取128个字节)
然后调用search_binary_handle()通过判断文件头部的魔数搜索和匹配适合可执行文件装载处理过程,比如ELF可执行文件的装载处理过程叫做load_elf_binary()(load_elf_binary()函数步骤往下翻)
当load_elf_binary()执行完毕,返回至do_execve()再返回至sys_execve()时,下面的第5部中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址,所以当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,然后新的程序开始执行,ELF可执行文件装载完成

为什么do_execve()要读取文件的前128个字节

Linux支持的可执行文件不止ELF,还有a.out、java程序和以“#!”开始的脚本程序。do_execve()读取文件前128个字节的目的是判断文件的格式,每种可执行文件的格式的抬头几个字节都是特殊的,特别是开头4个字节,这部分就是前面说的魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型,比如ELF的可执行文件格式的头4个字节为0x7F、‘e’、‘l’、‘f’;而Java的可执行文件格式的头4个字节为‘c’、‘a’、‘f’、‘e’;如果被执行的是shell脚本或perl、Python等解释型语言的脚本,那么他的第一行是“#!/bin/sh”或“#! /usr/bin/perl”或“#! /usr/bin/python”,这时候前两个字节‘#’和‘!’就构成了魔数,系统一旦判断到这两个字街,就会后面字符串进行解析,已确定具体的解释程序的路径
load_elf_binary()函数指令步骤

(1)检查ELF可执行文件格式的有效性,比如魔数、程序头表(Segment)的数量
(2)寻找动态链接的“.interp”段,设置动态链接器路径
(3)根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据
(4)初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址
(5)将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于金泰链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址,对于动态链接的ELF可执行文件件,程序入口点是动态链接器