0x00 序

这里照例是不知道写些什么的

不过放个蛮不错的思维导图,看书的时候可以用来辅助,找找自己的遗漏

https://zhuanlan.zhihu.com/p/111682188

https://zhuanlan.zhihu.com/p/138345701

0x01 简介

第一章 温故而知新

A.杂记

1.早期的计算机结构图:

image-20210308010516828

这时的计算机CPU频率不高,和内存一样,所以它们可以直接连接在一个总线(Bus)上。其它I/O设备与内存和CPU相比还是差很多,为了协调速度,每个设备会有一个相应的I/O控制器。

这些硬件结构虽然看起来复杂,但实际上还是最初的CPU、内存和I/O设备。

image-20210308011406962

后来由于CPU核心频率的提升,导致内存跟不上CPU的速度,于是产生了与内存频率一致的系统总线。接着随着图形化操作系统的普及,使得图形芯片需要跟CPU和内存大量交换数据,所以人们设计了一个高速的北桥芯片。
又由于北桥运行速度非常高,相对低速的设备如果连接在北桥上,北桥既需处理高速设备,又需处理低速设备,设计就会变得十分复杂,于是人们又设计了专门处理低速设备的南桥芯片,由南桥芯片把低速设备汇总后,连接到北桥上。20世纪90年代的PC机在系统总线上采用的是PCI结构,而在低速设备上用的是ISA总线。

2.计算机体系结构

image-20210308012450449

这个就是上层用下层提供的接口api,来完成对下层的操作。例如此图的操作系统内核层,对于硬件层来说就是硬件接口的使用者,而硬件是接口的定义者,这种接口被称作硬件规格。

3.我们想要充分利用CPU,所以编写了一个监控程序,当计算机中某程序不需要使用CPU时,监控程序就把另外等待CPU资源的程序启动,让CPU能充分利用起来。这种被称为多道程序,虽然能提高CPU的利用率,但是程序之间不分轻重缓急,例如如果我们点击鼠标,程序等了10分钟才有反应。
经过改进,程序运行模式变成了每个程序都运行一小段时间,这对于一些交互式任务尤为重要,例如点击一下鼠标或按一下键盘。这种模式叫做分时系统。windows3.1中,程序会判断是否有其他程序正在等待CPU,如果有,则可能暂停当前的程序,把CPU让出来给其他的程序。但是如果运行一个很耗时的程序时,操作系统没办法,其他程序只能等待。
大多数的现代操作系统用的是多任务系统,所有的应用程序以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,相互隔离。CPU由操作系统统一分配,根据优先级的高低都有机会得到CPU。当运行时间超出了一定的时间,操作系统会暂停进程,把CPU资源分配给别的进程,这种CPU的分配方式就是所谓的抢占式,操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。

4.操作系统出现后,硬件逐渐被抽象成了一系列概念。程序员可以从硬件细节中解放出来,更多关注应用程序本身的开发,繁琐的硬件细节交给了操作系统中的硬件驱动程序来完成。这些驱动通常由硬件厂商完成,操作系统为硬件生产厂商提供了接口和框架。

B.虚拟内存

早期的计算机中,程序是直接运行在物理内存上的。这样会出现几个问题:

  • 地址空间不隔离 恶意的程序可以很容易改写其他程序的内存数据,一个任务崩溃,可能会影响到其他任务;
  • 内存使用效率低 因为程序需要的空间是连续的,如果程序A正在运行,我们运行程序B时内存不够,需要把程序A换到磁盘,再读取B到内存开始运行。
  • 程序运行的地址不确定 因为程序在编写时,它访问数据和指令跳转时的目标地址都是固定的。

虚拟地址空间是指虚拟的,人们想象出来的地址空间,其实它并不存在。每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样有效地做到了进程的隔离。

分段

分段的基本思路是虚拟出一块地址空间,然后我们把物理地址映射到这段虚拟地址空间中,这个映射由软件来设置,比如操作系统来设置这个映射函数,实际的地址转换则由硬件完成。
分段这种方法解决了隔离的问题,如果程序访问虚拟空间的地址超过了范围,硬件就会判断这是一个非法请求,拒绝请求并报告给操作系统或者监控程序,由它处理。
同时也解决了地址不确定的问题,因为程序只需要按照虚拟地址空间来进行编写程序即可。
但是这种方法还是没解决第二个问题。但实际上,程序在运行时,在某个时间段,只是频繁的用到了一小部分数据。所以人们想到了进行内存分割提高内存使用率,这种方法就是分页。

分页

分页的基本方法就是把地址空间人为的分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小页,由操作系统选择决定页的大小。几乎所有32位PC上的操作系统都用4KB大小的页。物理空间也是同样的分法。

举个栗子:
我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘上,用到的时候把它从磁盘中取出来即可。
以下图为例,我们假设有两个进程Process1和Process2,它们进程中的部分虚拟页面被映射到了物理页面,比如VP0、VP1和VP7映射到PP0、PP2和PP3;而VP2和VP3位于磁盘的DP0和DP1中;另外VP4、VP5、VP6可能尚未使用或访问,暂时处于未使用的状态。在这里,我们把虚拟空间的页就叫**虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page)**。虚拟空间的有些页被映射到了同一个物理页,这样就可以实现内存共享。

图中Process1的VP2和VP3不在内存中,当进程需要使用这两个页时,硬件会捕获这个消息,这就是所谓的**页错误(Page Fault)**,然后操作系统接管进程,把VP2和VP3从磁盘中读出来装入内存。

image-20210311235942347

每个页可以设置权限属性,只有操作系统有权限修改这些属性,这可以做到保护自己和进程。

几乎所有的硬件都采用MMU(Memory Management Unit)来进行页映射。
CPU发出虚拟地址,在CPU转换后(MMU一般集成在CPU内部)变成物理地址。

image-20210312002436967

C.线程

线程(Thread),有时被称为轻量级进程,是程序执行流的最小单元。
一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。
通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间和一些进程级的资源(比如打开文件和信号)。

大多数软件应用中,线程的数量都不止一个,多个线程可以互不干扰地并发执行,使用多线程的优势如下:

  • 某个操作可能会陷入长时间等待,等待的线程会无法继续执行,多线程可以利用等待时间。
  • 某个操作会消耗大量时间,如果只有一个线程,程序和用户之间的交互会中断,多线程可以让一个线程负责交互,另一个线程负责计算。
  • 程序逻辑本身要求并发操作
  • 多CPU或多核计算机,本身具有执行多个线程的能力,单线程无法全面发挥计算机的计算能力。
  • 多线程在数据共享方面效率会更高。

线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈。在实际运用中线程也有自己的私有存储空间,包括以下几方面

  • 栈(尽管也可以被其他线程访问,但一般情况下认为是私有数据)
  • 线程局部存储(Thread Local Storage,TLS)。操作系统为线程提供的很有限的私有空间。
  • 寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有

image-20210312005414382

线程是并发执行的,在单处理器时用多线程,并发是一种模拟出来的状态,操作系统会让多线程程序轮流执行一小段时间,这样每个线程就看起来同时在执行。这样一个不断在处理器上切换不同线程的行为称之为**线程调度(Thread Schedule)**。

在优先级调度的环境下,线程的优先级改变一般有三种方式:

  • 用户指定优先级
  • 根据进入等待状态的频繁程度提升或降低优先级(频繁需要等待的线程优先级更容易提升,因为只需要占用很少时间)
  • 长时间得不到执行而被提升优先级

Linux把线程和进程都称为任务,每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过Linux下不同的任务之间可以选择共享内存空间,因此在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。

同步与锁

为了避免多个线程同时读写同一个数据,我们需要使用同步,指在一个线程访问数据未结束时,其他线程不得对同一个数据进行访问。
同步最常见的方法是使用锁(Lock)。这是一种非强制机制,每一个线程在访问数据或资源之前首先获取锁,并在访问结束之后释放锁。在锁已经被占用的时候,线程会等待到锁重新可用。二元信号量就是最简单的一种锁,它只有占用与非占用两种状态。

对于允许多个线程并发访问的资源,多元信号量是一个很好的选择。一个初始值为N的信号量允许N个线程并发访问,当一个线程访问资源获取信号量时,信号量的值就会减一。当访问完资源后,线程释放信号量,信号量的值就会加一。如果信号量的值小于0,则进入等待状态。

**互斥量(Mutex)**和二元信号量很相似,不过信号量的一个任务申请成功后,可以由另一个任务释放。互斥量则必须由同一个任务释放。

**临界区(Critical Section)**是比互斥量更严格的同步手段,临界区和互斥量的区别在于,在一个进程获取临界区后,其他进程无法获取该锁。

**读写锁(Read-Write Lock)**:

image-20210313011915853

条件变量可以被多个线程等待,线程也可以唤醒条件变量。也就是说,使用条件变量可以让线程一起等待某个事件的发生,当事件发生时,所有的线程一起恢复执行。

CPU有可能因为过度优化(例如CPU的换序执行)导致代码出问题。为了保证线程安全,阻止CPU换序是必须的。通常方法是调用CPU提供的指令barrier拦住该指令之前的指令交换到barrier的后面去。

线程的并发执行是由多处理器或操作系统调度来实现的。但是用户使用的并不是内核线程,而是用户态的用户线程。用户态线程并不一定在操作系统内核里对应同等数量的内核线程,例如某些轻量级的线程库,对用户来说有三个线程在同时执行,对内核来说只有一个。

用户态多线程库的实现方法:

1.一对一模型,一个用户态的线程对应一个内核的线程

image-20210313014551589

这样用户线程就具有了和内核线程一致的优点,实现了真正的并发。但这也同时限制了线程数量,以及操作系统内核线程调度时,切换开销大导致的执行效率下降。

2.多对一模型,多个用户态映射到一个内核线程

image-20210313014855513

多对一相比一对一,在线程的切换上要快速许多以及几乎无限制的线程数量。但是如果一个用户线程阻塞,那么所有的线程都无法执行。

3.多对多模型,多个用户态映射到多个内核线程

image-20210313015147020

一个用户线程阻塞不会让所有用户线程阻塞,同时对线程数量没有限制,性能也能得到一定提升,不过不如一对一模型高。

0x02 静态链接

第二章 编译和链接

当我们使用gcc编译程序时,编译器进行了如下步骤

  1. 预编译主要处理那些源代码中以”#”开始的预编译指令。
  • 将所有的#define删除,展开宏定义
  • 处理条件预编译指令
  • 处理#include,将被包含的文件插入到该预编译指令的位置。
  • 删除注释
  • 添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
  • 保留所有#pragma编译器指令,因为编译器要使用它们。
  1. 编译就是将预处理完的文件进行了一系列的语法分析,优化后生产一个汇编代码文件

  2. 汇编是将汇编代码转换成机器指令

  3. 链接把库文件进行了链接

编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。

image-20210316183219598

词法分析(扫描)

扫描器的任务是简单进行词法分析,把源代码的字符序列分割成一系列记号。记号一般分为几类:关键字、 标识符、字面量(包括数字和字符串等)和特殊符号(如加、等号)。

语法分析

语法分析器对扫描器产生的记号进行语法分析,从而产生语法树(以表达式为节点的树)。如果出现了表达式不合法,编译器就会报告语法分析阶段的错误。

语义分析

编译器能分析的语义是静态语义(指在编译期可以确定的语义,与之对应的动态语义就是只有在运行期才能确定的语义)。

例如将一个浮点型的表达式转换成整型,语义分析过程中会完成这个步骤。但是将浮点型赋值给指针时,语义分析程序会发现类型不匹配,编译器将会报错。

经过语义分析后,整个语法树的表达式都被标识了类型(整形,浮点型等等),如果有些类型需要做隐式转换,语义分析程序会在语法树上插入相应的转换节点。

另外像2+6这种表达式是可以被直接优化成8的,因为它的值在编译器就可以被确定。

中间语言生成

源代码优化器将整个语法树转换成中间代码,它是语法树的顺序表示,且非常接近目标代码。中间代码有很多类型,在不同编译器中有不同的形状。

中间代码使编译器可以被分为前端和后端,编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。
这样的好处是,对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的多个后端。

目标代码生成与优化

编译器后端主要包括代码生成器和目标代码优化器。代码生成器把中间代码转换成目标机器代码。代码优化器对目标代码进行优化,比如选择合适的寻址方式,使用位移来代替乘法运算等等。

链接

当以上所有操作做完以后,我们会发现某些函数的地址没有确定(在编译时如果有不知道的函数地址,编译器会将地址搁置,等待链接进行修正)。如果这些函数跟源代码在用一个编译单元里,那么编译器可以分配空间确定地址,但是如果定义在其他程序模块里,我们就需要链接器将目标文件链接成可执行文件。

最基本的静态链接就是将目标文件和库链接成可执行文件。

当有不确定的函数出现时,程序会将地址置为0,链接后将修正地址,这个修正的过程被称为重定位

第三章 目标文件里面有什么

1.现在PC平台流行的可执行文件格式主要是Windows下的PE和Linux下的ELF。它们都是COFF格式的变种。

2.目标文件(重定向文件)就是源代码编译后未进行链接的中间文件(Windows下的.obj和Linux下的.o)。它跟可执行文件的格式几乎是一样的。我们可以广义地将目标文件和可执行文件看成一种类型的文件。

3.动态链接库(Windows的.dll和Linux的.so)和静态链接库(Windows的.lib和Linux的.a)文件也按照同一种格式存储。

4.静态链接库是把很多目标文件捆绑成一个文件,再加上一些索引。可以把它理解为一个包含很多目标文件的文件包。

5.可执行文件,Windows的demo.exe和Linux的demo。

A.段

1.程序源代码编译后的机器指令经常放在代码段(code section)里,代码段常见的名字有”.code”或”.text”;全局变量和局部静态变量数据经常被放在数据段(data section),数据段常见的名字是”.data”。

2.未初始化的全局变量和局部静态变量存放在”.bss”段里。未初始化的全局变量和局部静态变量的默认值都为0,本身可以放到data段,但是因为这种值无意义。bss段只是为未初始化的全局变量和局部静态变量预留位置,它没有内容,所以在文件中不占空间,执行时生成虚拟地址空间。

总体来说,程序源代码被编译后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。

3.指令、数据分段的好处:

a.数据可读写,指令只读,所以分段可以防止指令被有意或无意的改写。

b.现代CPU的缓存一般设计为数据缓存和指令缓存分离,所以程序的指令和数据分开存放对CPU的缓存命中率提高有好处。

c.共享指令:当系统中运行着多个该程序的副本时,它们的指令部分都是一样的,所以内存只需要保存一份指令部分即可。当然每个副本进程的数据区域是不一样的,是进程私有的。

ELF常见段

image-20210410214059957

B.ELF文件结构

image-20210410215140178

  • ELF Header描述整个文件的基本属性,Section Header Table描述了ELF文件包含的所有段的信息。

  • 我们可以用readelf命令查看ELF文件

  • elf文件头结构及相关常数被定义在/usr/include/elf.h里,有32位和64位两个版本

elf的自定义宽度:

image-20210410221150057

32位的ELF Header的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct{
unsigned char e_ident[16];
Elf32_Half e_type; //ELF文件类型
Elf32_Half e_machine; //CPU属性
Elf32_Word e_version; //ELF版本,一般为常数1
Elf32_Addr e_entry; //入口地址,规定ELF程序的入口虚拟地址,操作系统从这个地址开始执行指令。可重定位文件一般没有入口地址,值为0
Elf32_Off e_phoff;
Elf32_Off e_shoff; //节表在文件中的偏移
Elf32_Word e_flags; //ELF标志位
Elf32_Half e_ehsize; //ELF文件头的大小
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize; //节表描述符大小
Elf32_Half e_shnum; //节表数量
Elf32_Half e_shstrndx; //字符串表所在的段,在节表中的下标
} Elf32_Ehdr;
1
2
3
4
5
6
7
8
e_ident{
Magic: 7f 45 4c 46
Class: 标识ELF文件类型,0x0132位,0x0264
Data: 规定大端序还是小端序
Version: 规定ELF版本号,一般是1
OS/ABI: 没有意义一般为0
ABI Version: 没有意义一般为0
}

Magic前4个字节是所有ELF文件都必须相同的标识码

e_type:

image-20210410232756674

e_machine:

image-20210410232908693

<————————————分割线————————————>

Section Header Table就是描述节的基本属性的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
Elf32_Word sh_name; //段名
Elf32_Word sh_type; //段的类型
Elf32_Word sh_flags; //段的标志位
Elf32_Addr sh_addr; //段的虚拟地址
Elf32_Off sh_offset; //段的文件偏移
Elf32_Word sh_size; //段的长度
Elf32_Word sh_link; //段链接信息
Elf32_Word sh_info; //段链接信息
Elf32_Word sh_addralign; //段地址对齐
Elf32_Word sh_entsize; //项的长度
}Elf32_Shdr;

段的类型(sh_type)

image-20210411000535505

image-20210411000541281

段的标志位(sh_flags)

image-20210411000612666

系统保留段

image-20210411000721838

image-20210411000728065

段的链接信息(sh_linksh_info)

image-20210411000848895

<————————————分割线————————————>

ELF文件把字符串集中存放在一个表中,然后使用字符串在表中的偏移引用字符串。常见段名为.strtab.shstrtab,分别为字符串表和段表字符串表,一个用来保存普通字符串,一个用来保存段表中用到的字符串,最常见的如段名。

C.链接的接口-符号

每个函数或变量都有自己独特的名字,这样能避免链接过程中不同变量和函数之间的混淆。我们将函数和变量统称为符号,函数名或变量名就是符号名。

每个目标文件有一个符号表,里面记录了目标文件中用到的符号。每个函数和变量的符号值都是它们的地址。

还有几个其它的符号:

  • 全局符号,可以被其他目标文件引用
  • 在本目标文件引用的全局符号,被称为外部符号
  • 段名,由编译器产生,值是该段的起始地址
  • 局部符号,对编译过程没有作用,编译器往往忽略。
  • 行号信息,目标文件指令和源代码中代码行的对应关系

我们值得关注的是全局符号,其它对链接是无关紧要的,因为它们对其他目标文件来说是不可见的。

<————————————分割线————————————>

符号表结构

1
2
3
4
5
6
7
8
typedef struct{
Elf32_Word st_name; //符号名
Elf32_Addr st_value; //符号值
Elf32_Word st_size; //符号的数据类型大小
unsigned char st_info; //符号类型和绑定信息
unsigned char st_other; //没用,默认0
Elf32_Half st_shndx; //符号所在段
}Elf32_Sym;

符号类型和绑定信息st_info,该成员低4位表示符号类型,高28位表示符号绑定信息

image-20210411153704495

符号所在段st_shndx

image-20210411153836320

符号值st_value,根据符号确定符号值,如果符号是个函数或者变量,符号值则是它的地址。

  • 在目标文件中,如果符号不是COMMON块的(st_shndx不为SHN_COMMON),则符号值表示该符号在段中的偏移。
  • 在目标文件中,如果符号是COMMON类型的,则符号值表示该符号的对齐属性
  • 在可执行文件中,符号值表示符号的虚拟地址。

<————————————分割线————————————>

特殊符号:

1
2
3
__executable_start	程序起始地址
__etext或_etext或etext 代码段结束地址
_end或end 程序结束地址

我们可以在程序中直接使用这些符号

1
2
3
4
5
extern char etext[];
int main()
{
printf("etext:%d",etext);
}

<————————————分割线————————————>

为了防止符号名冲突,不同语言的符号名规格也不一样,例如C语言的符号会在名字前面加上下划线,而Fortran语言会在前后都加上下划线。但是仍然会有符号名重复的事情,所以C++增加了namespace来解决冲突问题。

随着时间推移,Linux下的GCC编译器已经去掉了在C语言前加下划线的方式,但是Windows平台下的版本还会加下划线。

C++为了区分函数重载,发明了符号修饰的机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int func(int);
float func(float);
class C{
int func(int);
class C2{
int func(int);
};
};
namespace N{
int func(int);
class C{
int func(int);
}
}

函数签名:函数签名包含了一个函数的信息,包括函数名,参数类型,所在的类和namespace及其他信息。

编译器将C++源代码编译成目标文件时,会将函数和变量名修饰后,再形成符号名。

image-20210411235604744

GCC的基本C++修饰方法如下:所有符号以_Z开头。然后嵌套的名字(在名称空间或在类里的)紧跟一个N,再以E结尾。每个名字前是名字字符串的长度,对于函数来说,它的参数列表紧跟在E后面,例如int类型就是i。

c++filt这个工具可以用来解析被修饰过的名称

全局变量和静态变量也会被修饰。不同编译器的修饰方法也是不同的。

第四章 静态链接

Q.对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?

  1. 第一种方法是将输入的目标文件按次序叠加,但是缺点是目标文件很多的情况下,输出文件会有很多零散的段。因为对齐的原因,所以会浪费大量空间。
  2. 第二种方法是将相同性质的段合并在一起,现在的链接器也一般使用这种方法。使用这个方法的链接器一般采用一种两步链接的方法:第一步,空间与地址分配。扫描输入的目标文件,获取它们各个段的属性,将它们合并。并将符号表的所有符号定义和符号引用收集起来,统一放到一个全局符号表;第二步,使用上一步收集到的信息,进行符号解析与重定位

重定位表:

1
2
3
4
typedef struct{
Elf32_Addr r_offset;
Elf32_Word r_info;
}Elf32_Rel;

image-20210412143117240

image-20210412145215215

绝对地址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。

A.COMMON块

强符号:编译器默认函数和已经初始化的全局变量

弱符号:未初始化的全局变量

现在的编译器和链接器支持COMMON块(Common Block)机制。COMMON类型的链接规则是针对符号都是弱符号的情况。如果其中有一个强符号,最终符号所占空间与强符号相同。如果有弱符号大小大于强符号,链接器报警告。

COMMON块解决了一个弱符号定义在多个目标文件类型不同的问题。如果出现多个弱符号,两个文件链接后以最大的弱符号为准,例如是double类型,所占空间就为8个字节。

因为弱符号最终所占空间大小是未知的,所以无法为弱符号在BSS段分配空间。但是在链接器读取所有输入文件后,弱符号的大小就可以确定,所以还是存放在BSS段。

B.C++相关问题

重复代码消除:C++编译器在很多时候会产生重复的代码,比如模板,外部内联函数和虚函数表等等,都有可能在不同的编译单元里生成相同的代码。为了防止空间浪费等等问题,我们把这些代码单独存放在一个段里,每个段只包含一个,最后合并代码段时去掉重复的。

函数级别链接:由于现在的库和程序都很大,我们把函数单独保存到一个段里面,当我们用到的时候,把函数所在的段链接到输出文件就可以了。

全局构造与析构:Linux系统下程序的入口是_start,这个函数是Glibc的一部分,当这个函数完成一系列初始化之后,才会调用main函数执行程序。main函数执行完后,又返回到_start,进行析构。除了这些以外,C++的全局构造和析构,也在main函数之前和之后执行。

  • .init该段保存可执行指令,构成进程的初始代码,在main函数调用之前,Glibc的初始化部分安排执行这个段的代码。
  • .fini该段保存进程终止代码指令,当main函数正常退出时,Glibc会执行这个段指令。

第五章 Windows PE/COFF

之前写过PE相关的:链接,所以这章快进着看了

VC++编译器产生的目标文件格式是COFF,可执行文件格式是PE

因为PE文件在装载时被直接映射到进程的虚拟空间中运行,它是进程的虚拟空间的映像,所以PE可执行文件很多时候被叫做Image File.

本章主要讲了讲COFF文件格式,目前对其没什么兴趣,等之后用到再回来翻书。

0x03 装载与动态链接

第六章 可执行文件的装载与进程

A.虚拟地址空间(Virtual Address Space)

1.程序和进程的区别:程序是一个静态的概念,它是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态的概念,它是程序运行时的过程。

2.虚拟地址空间的大小由CPU的位数决定,例如32位CPU有32位寻址能力,也就是4GB虚拟空间大小。

3.我们可以通过指针来判断虚拟空间位数,32位下的指针为32位,也就是4字节;64位下的指针为64位,8字节。

4.程序不能任意使用操作系统分配的虚拟空间,只能使用操作系统分配给进程的地址,如果访问未经允许的空间,操作系统会捕获访问并强制结束进程。
这里以32位举例,4GB会被分成两部分,从0xC00000000xFFFFFFFF这1GB由操作系统使用,剩下的从0x000000000xBFFFFFFF这3GB由进程使用。但其实这3GB进程也不能完全使用(太惨了)。

4.PAE(Physical Address Extension):物理地址扩展,Intel改进CPU,扩展到36位地址线,可以映射更多内存。

B.装载的方式

1.动态装入的基本原理:将程序常用的部分放在内存,不常用数据放在磁盘里面。

2.覆盖装入在早期没有虚拟存储时使用比较广泛,现在已经被淘汰了。覆盖装入的方法是让程序员将程序分割,然后写辅助代码来管理这些程序何时驻留内存何时被替换,这个辅助代码就是所谓的覆盖管理器。

3.页映射:程序把磁盘中的所有数据和指令按照页为单位划分成若干个页,以后所有的装载和操作的单位就是页。

4.页映射采用先进先出,最少使用算法。

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

进程的建立:

  1. 创建一个独立的虚拟地址空间
  2. 读取可执行文件头,建立虚拟空间和可执行文件的映射
  3. 将CPU的指令寄存器设置成可执行文件的入口地址

Linux将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area),在Windows里叫虚拟段(Virtual Section)

页错误:当程序试图访问已映射在虚拟地址空间中,但是目前未被加载到物理内存,由中央处理器的内存管理单元所发出的中断。操作系统通过页错误来把虚拟地址空间加载到物理内存去执行。

image-20210415184340491

理解:计算机把可执行程序拉伸到虚拟地址空间,然后分页,实际的内存需要哪页,就给它哪页。

D.进程的虚拟存储空间分布

1.操作系统不关心各个段的内容,只关心段的权限(可读可写可执行)。为了避免空间浪费,我们将相同权限的段合并到一起当作一个段进行映射,这种合成的段被看作是一个Segment,减少对齐导致的空间浪费(因为是先合并,然后再映射)。

举个例子,.text被单独映射到一个虚拟段,.data也被单独映射到一个虚拟段,但如果他们两个权限相同,可以先把他俩合并成一个段,也就是Segment,然后再映射到虚拟段,这样可以节省内存。

描述section属性的叫做段表,描述segment的程序叫程序头表。

ELF可执行文件中有一个专门的数据结构保存Segment的信息。

1
2
3
4
5
6
7
8
9
10
typedef struct{
Elf32_Word p_type; //Segment类型
Elf32_Off p_offset; //在文件中的偏移
Elf32_Addr p_vaddr; //第一个字节在进程虚拟地址空间的起始位置
Elf32_Addr p_paddr; //物理地址
Elf32_Word p_filesz; //在ELF文件中占空间的长度
Elf32_Word p_memsz; //在虚拟空间中占用的长度
Elf32_Word p_flags; //权限属性RWX
Elf32_Word p_align; //对齐属性,实际对其字节是2的p_align次,比如p_align等于10,那么实际对齐属性就是2的10次方
}Elf32_Phdr;

2.操作系统通过给进程空间划分出一个个的VMA来管理进程的虚拟空间,基本原则是将相同权限属性、有相同映像文件的映射成一个VMA。

image-20210415200652636

3.进程在启动时,需要知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是操作系统在进程启动前将这些信息保存到进程的虚拟空间的栈中。

E.Linux内核装载ELF过程

Q.当我们在bash下输入命令执行ELF程序时,Linux系统会怎么做呢?

A:首先在用户层面上,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程返回等待启动的进程结束。
在内核中,execve()系统调用sys_execve()对参数进行检查复制,然后调用do_execve()查找被执行的文件,如果找到文件就读取文件的前128字节(用以接下来判断文件的格式)。然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程,search_binary_handle()会判断文件格式,调用相应的装载处理过程。比如ELF就会调用load_elf_binary(),a.out就调用load_aout_binary()。

load_elf_binary()的步骤是:

  1. 检查ELF可执行文件格式的有效性
  2. 寻找动态链接.interp段,设置动态链接器路径
  3. 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射
  4. 初始化ELF进程环境
  5. 将系统调用的返回地址修改成ELF可执行文件的入口点

当load_elf_binary()执行完毕,返回至do_execve()再返回至sys_execve(),系统调用的返回地址已经被修改成ELF的入口地址,这时EIP寄存器跳转到入口地址,新的程序开始执行。

F.Windows PE的装载

Windows不需要考虑ELF多段地址对齐之类的问题,虽然会浪费一些磁盘和内存,不过PE的段一般比较少,不像ELF中有很多,最后还要用Segment把它们合并到一起装载。

PE的装载过程:

  1. 先读取文件第一个页
  2. 检查进程地址空间中,目标地址是否可用。不可用就选另一个装载地址。这个问题对可执行文件基本上不存在,因为它往往是进程第一个装入的模块,主要针对于DLL文件的装载。
  3. 使用段表中提供的信息,将PE文件中的所有段一一映射到地址空间
  4. 如果装载地址不是目标地址,则进行重定位
  5. 装载所有PE文件所需要的DLL文件
  6. 对PE文件中的所有导入符号进行解析
  7. 根据PE头中指定的参数建立初始化栈和堆
  8. 建立主线程并启动

第七章 动态链接

1.静态链接的缺点是浪费内存和磁盘空间,模块更新困难。

2.动态链接的基本思想是把程序按照模块分成各个独立的部分,在程序运行时将它们链接在一起,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。

3.动态链接优点:能够节省内存,有更好的程序扩展性和兼容性。

4.动态链接的缺点:当程序依赖某个模块更新后,由于新的模块和旧的模块不兼容,导致程序无法运行,也被称为DLL Hell。

5.如果a函数是一个定义在其他静态模块的函数,那么链接器会将a函数的地址重定位;如果a函数是一个定义在动态共享对象的函数,那么链接器就会将这个符号标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。

6.可执行文件基本可以确定自己再进程虚拟空间的起始位置,Linux下一般是0x08040000,Windows下一般是0x0040000。

7.**地址无关代码(PIC)**:希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令的部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。

8.**全局偏移表GOT(Global Offset Table)**是链接器在执行链接时实际上要填充的部分, 保存了所有外部符号的地址信息。动态链接时,因为不知道模块加载位置,将地址相关代码抽出,放在数据段中就是got表。

9.延迟绑定(Lazy Binding):当函数第一次被用到时才进行绑定。能加快速度,因为有些函数不会用到。绑定需要进行符号查找和重定位,如果用延迟绑定就不需要把所有的函数进行绑定了。

10.**PLT(Procedure Linkage Table)**:ELF使用PLT的方法进行延迟绑定,PLT为了实现延迟绑定,当调用外部模块的函数时,不直接通过GOT表进行间接跳转,而是在这个过程中又加了一层间接跳转PLT表。

image-20210422005334405

PLT将GOT拆分成了两个表叫做”.got“和”.got.plt“,.got用来保存全局变量引用的地址,.got.plt用来保存函数引用的地址。

对应这篇文章一起理解,https://aidaip.github.io/binary/2019/10/25/%E5%8A%A8%E6%80%81%E9%93%BE%E6%8E%A5.html

11.生成动态链接库gcc -fPIC -shared demo.c demo.so

12.静态链接下,操作系统可以直接把控制权交给可执行文件的入口地址,然后程序开始执行。但是动态链接下,操作系统会启动动态链接器ld.so,当所有动态链接工作完成之后,才会把控制权交给可执行文件的入口。

13..inertp段保存了一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。

14..dynamic段保存了动态链接所需要的基本信息。

15..rel.dyn表示代码段(修正.got)的重定位表,.rel.data是数据段(修正.got.plt)的重定位表。

16.动态链接步骤:启动动态链接器,动态链接器自己把自己本身的函数进行重定位。接下来把符号表合并到一个里面,然后装载所有需要的共享对象,最后重定位和初始化。