《程序员的自我修养》学习心得——第六章 可执行文件的装载与进程 (下)

引言

个人认为接下来的部分对做题很有帮助,所以就分了上和下。接下来的堆栈的介绍会让你做题的时候更加通透。

2、堆和栈

操作系统通过使用VMA来对进程的地址空间进行管理,例如栈(Stack)和堆(Heap),他们在进程的虚拟空间中的表现也是以VMA的形式存在的,并且一个进程中的栈和堆分别都有一个对应的VMA

操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA,一个进程基本上可以分为如下VNA区域:

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

常见进程的虚拟空间:
在这里插入图片描述

3、段地址对齐

可执行文件需要被操作系统装载运行,装载过程一般是通过虚拟内存的页映射完成的,要映射一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是页大小的整数倍。举个栗子,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 0x3000 9899 164 可读可写
SEG2 0x0804C000 0x1000 1988 只读

可以看到这种对齐方式在文件段的内部会有很多内部碎片,浪费空间,三个段加起来就12014字节,但是却占了5个页。为了优化,一些UNIX系统让各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次:
在这里插入图片描述比如对于SEG0和SEG1的接壤部分的物理页,系统将他们映射两份到虚拟地址空间中,一份为SEG0,另一份为SEG1,其他页都按照正常的页粒度进行映射。而且UNIX系统将ELF的文件头看做系统的一个段,将其映射到进程的地址空间,进程中某一段区域就是整个ELF文件的映像,对于一些需要访问ELF文件头的操作(比如动态链接器读ELF文件头)可直接通过读写内存地址空间进行。好像整个ELF文件从文件最开始到某个点结束,被逻辑上分成了以4096字节为单位的若干个块,每个块都被装载到物理内存中,对于位于两段中间的块,会被映射两次,上面例子中ELF文件的映射方式如下

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

4、进程栈初始化

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

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

比如运行命令为:

prog 123

假设栈底地址为0xBF802000,那么进程初始化后的堆栈为:
在这里插入图片描述栈顶寄存器esp指向的位置是初始化以后栈的顶部,最前面4个字节表示命令行参数的数量,即“prog”和“123”,紧接的就是分布指向这两个参数字符串的指针。0表示结束。接着是两个指向环境变量字符串的指针,分别指向字符串“HOME=/home/user”和“PATH=/usr/bin”,最后以0结束

扫描二维码关注公众号,回复: 11403522 查看本文章

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

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

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

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

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

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

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

  • 在内核中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可执行文件件,程序入口点是动态链接器

这章内容就到此为止了,关于栈、堆在后面的章节里有具体讲解,如果对你有帮助,记得点赞呦!
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_41202237/article/details/106936929