程序员的自我修养 pdf_程序员的自我修养读书笔记(20191030)

ea715521c6c9f5c09173b39c8635f825.png

第一部分 简介

计算机主要分为硬件和软件,计算机中最核心的三个硬件是CPU,内存,I/O控制芯片。这三个部件的速度不同,计算机的发展一方面为了提升三者各自的速度,同时又更好的加强三者之间协助。发展历程大致分为早期 -- 北桥 -- 南桥 -- 多核。软件分为应用软件和系统软件。硬件和软件的布局如图1所示。应用软件还是很好理解的,系统软件一般指的是的一些开发工具,它们都是调用的操作系统应用程序编程借口(API),API的提供者是运行库(简单理解就是一些大佬写好的代码给我等小白使用),什么样的运行库提供什么样的API,比如Linux下的Glibc库提供POSIX的API。接着是系统调用,内核,硬件规格(告知如何与硬件进行通信的接口)。关于系统调用以及系统调用与API的区别,会在书的第12章详细讲解。

636f51fe104fad3b0840c03d294824ec.png
图1 (来自原书截图)

那么操作系统主要做些什么呢?就是尽可能的发挥CPU Memory IO的潜力,对于CPU而言,由分时操作系统转向实时操作系统的发展;对于内存管理(要解决的问题是如何将计算机上有限的物理资源分配给多个应用程序来使用)而言,由直接使用物理内存再到虚拟地址空间的出现,再到分段--分页--段页式等一系列的发展;对于IO而言,设备驱动的发展,应用程序员是不需要与硬件直接打交道的,在如今的操作系统中,硬件被抽象成一系列概念,在Unix中,硬件也被抽象成文件,所有繁琐的硬件交互都是由硬件驱动程序来完成的。

关于线程的问题。线程本身的特点;线程调度与优先级(优先级调度和轮转法);线程安全问题,主要包括:竞争与原子操作;可重入函数与线程安全(一个可重入函数在多线程环境下是可放心使用的);过度优化(寄存器中的数据暂时不放回,指令乱序执行(可以使用屏障来解决这个问题));多线程的内部情况。

第二部分 静态链接

  1. 编译和链接
#include <stdio.h>

int main(void)
{

	printf("hello worldn");
	return 0;
}

对于上面一个简单打印helloworld的代码,你对它到底了解多少呢?

一个高级语言写的代码文本文件到可执行文件大致经历了下面四个步骤。

gcc -E helloworld.c -o helloworld.i  //1. 预处理
➜  test ls
helloworld.c helloworld.i
➜  test gcc -S helloworld.i -o helloworld.s //2. 编译
➜  test ls
helloworld.c helloworld.i helloworld.s
➜  test gcc -c helloworld.s -o helloworld.o //3. 汇编
➜  test ls
helloworld.c helloworld.i helloworld.o helloworld.s
➜  test gcc helloworld.o
➜  test ls
a.out        helloworld.c helloworld.i helloworld.o helloworld.s
➜  test gcc helloworld.o -o helloworld //4. 由目标文件生成可执行文件  链接
➜  test ls
a.out        helloworld   helloworld.c helloworld.i helloworld.o helloworld.s
  1. 预处理:预处理期间做的事情有,将所有的头文件展开 #include;替换到所有的宏定义#define;处理所有的条件预编译指令#if #endif;去掉注解;加上行号;保留所有的编译器指令#pragma。
  2. 编译:词法分析,语法分析,语义分析,中间语言生成,目标代码生成与优化。
  3. 汇编:汇编器将汇编语言源代码翻译成二进制文件的过程。
  4. 链接:这是一个比较复杂的过程,后文会详细介绍。

2. 目标文件里有什么

编译器 编译源代码后生成的问价叫做目标文件(个人觉得,应该是已经发生了汇编的过程,这里写的有点不是很严谨)。目标文件从结构上讲,它是编译后的可执行文件格式,只是还没有经历过链接的过程,其中可能有些符号或者地址还没有被调整。可执行文件格式涵盖了程序的编译、链接、装载和执行的各个方面(好像汇编过程简略没有说了)。

1. 目标文件的格式 (我主要关注Linux环境,其它环境类似)

在Linux环境中目标文件叫作ELF文件,不仅如此,还有其它相似的文件也叫作ELF文件,如下。

可重定位文件、可执行文件、共享目标文件(个人理解包含动态库和静态库文件)、Core dump file。

2 . 目标问价是什么样的

目标文件中除了编译后的机器指令代码和数据,还包含链接时需要的符号表、调试信息、字符串(不懂)等。目标文件将这些信息按照不同的属性,以“节“(Section)的形式存储,有时也叫作”段”(Segment)

程序源代码编译后的机器指令放在代码段“.text", 局部变量和全局变量放在数据段“.data"。

1cc7ca276b3af7d9ef7750e46ddf2947.png

这里对bss段再进行一下补充,未初始化的全局变量和局部静态变量默认值都是0,本来它们也可以放在.data 段里,但是因为它们都是0,所以为它们在.data段中分配空间并存放数据0是没必要的。程序运行的时候,它们确实是要占用空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和,记为.bss段(不理解)。所以.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占用空间。

总体来说,程序源代码编译后被分为两段:程序指令和程序数据

关于疑问,为什么要把代码和数据分开呢?

0187cb853316ec816826beecbc17d2cd.png

3.3 挖掘SimpleSection.o

objdump -h SimpleSection.o 查看文件内部结构

45f096addc91b4fe641d92062070fc22.png

除了最基本三个段之外多了3个段,ELF

ee50af5dd1c75f81316d9c3d39473ffc.png

除了上述常见的一些段,还有一些其它段,如下表所示。

7ed2cd3996c13e8404440e813d9768a4.png

3.4 ELF文件结构描述

下面是ELF文件的基本结构,ELF目标文件格式的最前部分是ELF文件头,它包含了描述整个文件的基本属性,比如文件版本号,目标机器型号,程序入口地址等。紧接着是文件中的各个段。其中ELF文件中段有关的重要结构是段表,它表述了ELF文件包含的所有段的信息,比如段名、段的长度、在文件中的偏移、读写权限等。

e77a3df9ff27722236f0d15f8179b7e2.png

下面是readelf显示的ELF文件的结构

b23e766ed662e16985a46c3bf8fa6be0.png

重定位表

注意到ELF文件中有一个叫作“re l.txt"的段,这是一个重定位表。连接器在处理目标文件时,需要对目标文件中的某些部位进行重定位,即代码段和数据段中那些绝对地址的引用的位置。这些重定位的信息都记录在ELF文件的重定位表里。

3.5 链接的接口 --- 符号

链接过程的本质就是要把多个不同的目标文件互相“粘”在一起。在链接中,目标文件之间互相拼合实际上是目标文件之间对地址的引用,即对函数和变量地址的引用。在链接中,将函数和变量统称为符号,函数名和变量名就是符号名。所以,可以将符号看作是链接中的粘和剂。每一个目标文件都会有一个相应的符号表,这里面记录了目标文件中所用到的所有符号,每一个定义的符号有一个对应的值,叫作符号值,对于函数和变量来说,符号值就是它们的地址,此外,还有几种不常见的符号,后文讲。

下面是符号的分类:

  • 定义在目标文件中,可以被其它目标文件引用的符号称为全局符号
  • 在目标文件中引用的全局符号,但是没有在目标文件中定义的符号,一般叫作外部符号。
  • 局部符号,这类符号只是在编译单元内部可见。
  • 段名,这种符号往往由编译器产生,它的值就是改段的起始地址
  • 行号信息,即目标文件指令和源代码中代码行的对应关系
    上述符号分类中,最值得关注的是第一类和第二类,因为链接过程只关心全局符号互相“粘和”,后面三种都是次要的,它们对于其它目标文件都是不可见的。

符号表的结构

符号表是ELF文件中的一个段,名字是“.symtab",符号表的结构是一个struct 数组,struct如下:

d4d02ee04559009113bb4a5655485351.png

特殊符号

当使用ld作为链接器生产可执行问价时,它会为我们定义很多特殊饿符号,这些符号并没有在你的程序中定义,但是你可以直接申明并且引用它,这些符号称为特殊符号。

b6a851d9b709c18159644f40d41399ff.png

符号修饰和符号签名

符号修饰是为了减少符号命名冲突的问题,为了解决符号命名冲突的问题,C++引入了命名空间。下面说下C++中的符号修饰,强大而又复杂的C++拥有类、继承、虚机制、重载等特性,这些都使得符号管理更为复杂。为了解决这些问题,发明了符号修饰和符号改改编机制

首先为了解决函数重载问题,引用了函数签名,一个函数签名包含了一个函数的信息,包括函数名、它的参数类型、所在的类、命名空间等信息。在编译器和链接器处理符号时,使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称。编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名。函数签名 ---- 》 符号名转换例子如下表。此外,C++中的全局变量和静态变量也有同样的机制。

还需要注意一个问题,不同编译器采用不通的名字修饰方法,必然会导致由不同编译器产生的目标文件无法正常互相链接。

0b7555c86063b26d64665fd37f5fa015.png

extern “C”

extern “C” int var 会申明一个C语言的变量var,加上extern “C”后,C++的名称修饰机制将不会起作用。

若符号和强符号

对于CC++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号,关于强弱符号,有一些定义规则

  • 规则一:不允许重复定义强符号
  • 规则二:如果一个符号在某个目标文件中是强符号,在其它文件中是弱符号,那么选择强符号
  • 规则三:如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个

弱引用和强引用 将外部目标文件中的符号引用到目标文件最终形成可执行文件时,它们需要被正确决议,如果没有找到该符号的定义,那么连接器就会报符号未定义错误。这种被称为强引用,与之对应的还有一种弱引用,对于弱引用,如果改符号有定义,则连接器直接引用,如果该符号没有被定义,则连接器也不会报错。简单说,连接器处理强引用和弱引用的过程几乎一样的,只是对于没有定义的弱引用,连接器不认为它是一个错误,而是认为它是一个0,或者是一个特殊的值。 弱引用和这个概念和链接器的COMMON概念联系的很紧。

这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用了,如果去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能。

第四章 静态链接

4.1 空间和地址的分配

对于连接器来说,整个链接的过程,就是将几个输入的目标文件加工后合并成一个输出文件。

这里着重介绍相似段合并,如下图所示。

d42b312867c305a0c1c65ea4930cbd63.png

这里需要注意一点的事,“.bss"段在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间,所以连接器在合并各个段的同时,也将“.bss"合并,并且分配虚拟空间。

“连接器为目标文件分配地址和空间“这句话中的”地址和空间“其实有两个含义。第一个是在输出的可执行文件中的空间,第二个是在装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段,比如“.text“和”.data"来说,它们在文件只能够和虚拟地址中都要分配空间,因为它们在这两者中都存在,对于“.bss“这样的段来说,分配的空间的意义只是局限于虚拟地址空间,因为它在文件中并没有内容。 实际上,我们在这里谈到的空间分配只是关注于虚拟地址空间的分配,这个关系到连接器后面的关于地址计算的步骤。

链接分为两步链接:

第一步 空间与地址的分配 扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中符号表中的所有的符号定义和符号引用搜集起来,统一放到一个全局符号表中。这一步中,连接器能够将能够获得所有输入文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度和位置,并且建立映射关系

第二步 符号解析和重定位 使用上一步中搜集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析和重定位,调整代码中的地址等。这一步是链接中的核心(这一部分在深入理解计算机系统这一本书中写的更加清楚)

在链接前,目标文件中所有段的VMA都是0,因为虚拟空间还没有被分配,所以它们默认都是0。等到链接之后,可执行文件中国各个段都被分配到了相应的虚拟地址

符号地址的确定

4.2 符号解析和重定位

重定位

在完成空间空间和丢置的分配步骤以后,连接器就进入到了符号解析和重定位的步骤,这也是静态链接的核心内容。

重定位表

那么连接器是怎么知道那么指令是需要被调整的呢,这些指令的那些部分要被调整呢,实际上在ELF文件中有一个重定位表,其实也是叫作重定位段“rel.txt"

每一个要被重定位的地方叫作重定位入口,重定位入口的偏移表示该入口在要被重定位的段中的位置。对于32位的intel x86系统处理器来说,重定位的结构也很简单,也是一个struct数组,如下图,每个数组元素对应一个重定位入口。

f36fc3ef0f394724119696bbb035d20c.png

符号解析

我们平时在编写程序时最长碰到的问题之一,就是链接时符号未定义,导致这个问题的原因很多,最常见的一般都是链接是缺少了某个库,或者输入目标文件路径不正确或符号的申明与定义不一样。所以,从普通程序员的角度看,符号的解析占据了链接过程的主要内容。

通过前面指令重定义的介绍,我们可以更加深层次的理解为什么缺少符号的定义会导致链接错误,其实重定位过程页伴随着符号解析的过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当连接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候连接器就会去查找 ️所有输入目标文件的符号表组成的全局符号表,找到相应的符号进行重定位。

指令修正方式(简单说就是寻址方式)

不同的处理器指令对于地址的格式和方式都不一样,比如对于32位intel x86处理器 来说,跳转指令jmp,子程序调用call指令,数据传送指令mov指令等都是千差万别。直到2006年为止,Intel x86系统CPU的jmp指令有11种寻址模式,call指令有10种,mov指令则有多达34种寻址模式。这些寻址方式有如下几个方面的区别:

  1. 近址寻址或远址寻址。
  2. 绝对寻址或相对寻址
  3. 寻址长度为8位、16位、32位或64位

但是对于32位x86平台下的的ELF文件的重定位入口所修正的指令寻址方式只有两种

  1. 绝对近址32位寻址
  2. 相对近址32位寻址 (近址的意思是不用考虑intel的段间远址寻址)

下面看下绝对寻址和相对寻址的区别,重定位入口的r_info成员低8位表示重定位入口类型,如下表:

2bab24e5cee00f39b67370f4d1099ade.png

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

COMMON块

由于若符号机制允许一个符号的定义存在与多个文件中,所以可能导致的一个问题是:如果一个弱符号定义在多个目标文件中,而且它们的类型又不同,怎么办??

编译器为什么不直接把未初始化的全局变量页当作为初始化的局部静态变量一样处理,为他在BSS段分配空间呢,而是将它标记为一个COMMON类型的变量?

ddd863ca509a71baeda2663cdcba7640.png

C++相关的问题

消除重复代码

C++编译器在很多时候都会产生重复代码,比如模版、外部内联函数和虚函数都有可能在不同的编译单元里产生相同的代码。最简单的做法就是将这些重复代码都保存下来,不过这样做的主要问题是如下几个方面。

  • 空间浪费
  • 地址较易出错,有可能两个指向同一个函数的指针会不相同
  • 指令运行效率低。因为现代的CPU都会对指令和数据进行缓存,如果同样一份指令有多份副本,那么指令Cache的命中率就会降低

一个解决方案如下(GUN GCC Visual C++ 都是采用类似的方法):

0ab6062375e9feb4157f4388068b7cd2.png

全局构造和析构

一般的C/C++程序是从main开始执行的,随着main函数的结束而结束。然而,其实在main函数被调用之前,为了程序能够顺利执行,要先初始化进程的执行环境,比如堆分配初始化、线程子系统等。我们要知道C++的全局对象的构造在main之前执行,C++全局对象的析构函数在main之后被执行。

Linux系统下一般程序的入口是“_start“,这个函数是Linux系统库的一部分,当我们的程序与Glibc库链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分的入口,程序初始化部分完成一些列初始化过程之后名,会调用main函数来执行程序的主体。对于有场合程序的一些特定的操作必须是在main函数之前被执行,还有一些操作必须是在main函数之后被执行。其中最具代表性的就是C++的全局对象的构造和析构函数。因此,ELF文件还定义了两种特殊的段。

  • init 该段里保存的是可执行指令,它构成了进程的初始化代码。
  • fini 改段保存着进程终止代码指令。

C++与ABI

既然每个编译器都能将源代码编译成目标文件,那么有没有不同编译器编译出来的目标文件是不能够互相链接的呢?有没有可能将MSVC编译出来的目标文件和GCC编译出来的目标文件链接到一起呢,形成一个可执行文件呢?

面对上述问题,首先可以想到的是,如果将两个不同编译器的编译结果链接到一起,那么首先连接器必须要支持这两个编译器产生的目标文件的格式,此外,这两个目标文件必须满足下面这些条件:采用同样的目标文件格式,拥有同样的符号修饰标准,变量的内存分布方式相同,函数的调用方式相同。

我们把符号修饰标准、变量内存布局、函数调用方式等这些和可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)

ABI的概念其实从开始至今一直存在,因为人们总是希望程序能够在不经任何修改的情况下得到重用。人们始终在朝着这个方向努力,但是由于现实的因素,二进制级别的重用还是很难实现的。其中最大的问题就是各种硬件平台、编程语言、编译器、连接器和操作系统之间的ABI互相不兼容,由于ABI的不兼容,各个目标文件之间无法互相链接,二进制兼容性更加无从谈起。

e8f40f6d8fc2fcb7e68c1be3b4d09026.png

5d03f6500ec844a3b2628a5f968f4441.png

因此,C++一直为人诟病的一大原因是它的二进制兼容性不好,或者说比起C语言来说更为不易,不仅不同编译器的二进制代码之间无法兼容,有时候连同一个编译器的不同版本之前的兼容性也不好。

4.5 静态库链接

最简单的办法是使用操作系统提供的应用程序编程接口(API),在一般的情况下,一种语言的开发环境往往会附带有语言库,这些库就是对操作系统API的包装。其实静态库可以简单地地看作是一些目标文件的集合,很多目标文件经过压缩打包后形成一个文件。比如我们在Linux中最常用的C语言静态库libc位于/usr/lib/libc.a,它属于glibc的一部分。libc.a里面总共包含了1400个文件。

为什么静态库里面一个目标文件中只包含一个函数?比如libc.c 里面printf.o只有printf函数?

我们知道,连接器在链接静态库的时候是以目标文件为单位的,比如我们引用了静态库中的printf()函数,那么连接器就会把库中包含printf函数的那么目标文件链接进来。如果很多函数都放在一个目标文件中,很有可能很多没用的函数都被链接进了输出文件中。

第三部分 装载与动态链接 (可执行文件的装载与进程)

6.1 进程的虚拟地址空间

32位的CPU下,程序的使用空间能不能超过4GB呢,这个问题其实应该从两个角度来看。

首先,问题里的“空间”如果是指虚拟地址空间,那么答案是“否”,因为32位的CPU只能使用32位的指针,它的最大寻址范围是0到4GB;

如果问题里面的”空间“指的是计算机的内存空间,那么答案是“是”。Intel自从1995年的Pentium Pro CPU开始采用了36位的物理内存,也就是可以访问高达64GB的物理内存。

从硬件层面上来讲,原先的32位地址线只能最多访问4GB的物理内存,但是自从扩展至36位的地址线之后,Intel修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存,Intel把这个地址扩展方式叫作PAE。

当然扩展的物理地址空间,对于普通的应用程序来说正常情况下感觉不到它的存在,遮住腰部是操作系统的事,在应用程序里,只有32位的虚拟地址空间。

那么应用程序如何使用这些大于常规的内存空间呢?

一个很常见的方法是操作系统提供一个窗口映射的方法,把这些额外的内存映射到进程地址空间中来,应用程序可以根据需要来选择申请和映射。(Linux中有mmap()系统 调用来实现)

6.2 装载的方式

程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序所需要的指令和数据全部装入到内存中,这就是最简单的静态装入的办法。但是很多程序需要的内存数量大于物理内存的数量。根据程序的局部性原理,可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理

覆盖装入和页映射(重点)是两种很典型的动态装载方法。

(在这里我的理解就是,现在操作系统的内存管理采用的就是段页式内存管理,关于分段,前面已经讲过了)

关于页映射有一个很重要的问题需要解决就是,关于页的置换算法。常见的有FIFO,LRU等。

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

进程的建立

从操作系统的角度来看,一个进程最关键的是它拥有独立的虚拟地址空间,使它有别于其他进程,很多时候,一个程序被执行同时伴随着一个新的进程的创建,在有虚拟存储的情况下,上述过程最开始只需要做三件事:

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

首先是创建虚拟地址空间(建立虚拟空间到物理空间的映射关系【这个映射关系也可能不会建立,只是在第一次发生缺页异常的时候建立】,这里容易与第二步混淆,简单的记法就是,这里仅仅是建立虚拟虚拟地址空间,所有的进程都是公用的,而与特定进程没有关系)。我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间。那么创建一个虚拟空间实际上并不是创建空间,而是创建映射函数所需要的相应的数据结构。在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录就可以来,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。

再读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系上面那一步的页映射关系是虚拟空间到物理空间的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘读取到内存中,再设置虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的是,当操作系统捕获到页错误时,它应该知道程序当前所需要的页在可执行文件的一个位置。这就是虚拟空间和可执行文件之间的映射关系。(这一步是整个装载过程中最重要的一步,这也是传统意义上“装载“的过程)

将CPU指令寄存器设置成可执行文件入口,启动运行。操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。看似简单,实际上在操作系统层面比较复杂,它涉及到内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单的认为操作系统执行来译写跳转指令,直接跳转到可执行文件的入口地址。还记得ELF文件中保存有入口地址名??

页错误

页错误就是“缺页异常“。此时,CPU会将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。这时候上述虚拟空间与可执行文件之间的映射关系就很重要了,操作系统将查询这个数据结构,然后找到空页面所在的VMA(虚拟内存区域),计算出相应的页面在可执行文件中的偏移。然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后再把控制权再还给进程

6.4 进程虚拟空间分布

ELF文件链接视图和执行视图

在一个正常的进程中,可执行文件包含代码段、数据段、BSS段等。当段的增多时,就会产生空间浪费问题,因为ELF文件被映射时,是以系统的页长度为单位的,那么每个段在映射时的长度应该时系统页长度的整数倍,如果不是呢,多余的页也会占用一个页,一个ELF文件中往往有十几个段,那么内存空间的浪费是可想而知的,那么有没有什么办法尽量减少这种浪费呢?

其实呢,我们站在操作系统装载可执行文件的角度看问题,可以发现实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些和装载相关的问题,最主要的是段的权限。ELF文件中往往只有为数不多的几种组合,如下:

  • 以代码段为代表的可读可执行的段
  • 以数据段和BSS段为代表的为可读可执行的段
  • 以只读数据段为代表的只读的段

因此,对于相同权限的段,把它们合并到一起当作一个段进行映射。ELF可执行文件引入了一个概念叫做“Segment”,一个“Segment “包含一个或者多个属性类似”Section“。

所以总的来说,“Segment“和”Section“是从两个角度来划分同一个ELF文件。这个在EL中被称为不同的视图,从“Section”角度来看ELF文件就是链接视图,而从“Segment”的角度来看就是执行视图。

ELF可执行文件中有一个专门的数据结构叫做程序头表用来保存“Segment”的信息,因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF可执行文件和共享库文件都有。

堆和栈

在操作系统里面,VMA除了被用来映射可执行文件中的各个“Segment”以外,它还可以有其他作用,操作系统通过使用VMA来对进程的地址空间进行管理。堆和栈等空间,它们在虚拟空中的表现也是以VMA的形式存在,很多情况下,一个进程中的堆和栈分别都有一个对应的VMA。

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

  • 代码VMA,可读可执行,有映像文件
  • 数据VMA,可读写可执行,有映像文件
  • 堆VMA,可读写可执行,无映像文件,匿名,可向上扩展
  • 栈VMA,可读写不可执行,无映像文件,匿名,可向下扩展 (这里为啥是不可执行呢?

堆的最大申请数量

Linux下虚拟地址空间分给进程本身的3GB,那么程序真正可以用到有多少呢,一般的情况就是使用malloc可以申请多大的内存呢?那么malloc的最大申请数量会受到哪些因素的影响呢?具体数值会受到系统版本,程序本身大小,用到的动态/共享库数量和大小,程序栈数量和大小等。甚至有可能每次运行的结果都会不同的,因为有些操作系统使用了一种叫作随机的地址空间分布的技术。

进程栈初始化

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

6.5 Linux内核装载ELF过程简介

当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,Linux是怎样装载这个ELF文件,并且执行它的呢?

(这里与6.3有啥区别呢??)

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

4a17959ff31da63aead7afacf30ab5e9.png
  • 在进入execve()系统调用之后,Linux内核就开始真正的装载工作,
  • 在内核中,execve()系统调用相应的入口是sys_execve();
  • sys_execve()它进行一下参数的检查复制之后,调用do_execve();
  • do_execve()首先会检查被执行的文件,如果找到文件,就读取文件的前128字节
  • 当do_execve()读取这128字节的文件头部之后,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程,Linux中所有被支持的可执行文件格式都有相应的装载处理过程
  • search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程
  • 后面有点复杂!!!

动态链接

7.1 为什么要动态链接

静态链接具有很多缺点,比如浪费内存和磁盘空间、模块更新困难等

为了解决空间浪费和更新苦难这两个问题最简单的办法就是把程序的模块互相分割开来,形成独立的文件,而不再是将它们静态地链接在一起。简单讲,就是不对那些组成程序的目标文件进行链接,等到程序运行才进行链接。也就是说,把链接这个过程推迟到来运行时再进行,这就是动态链接的思想。

程序的可扩展性和兼容性

动态链接还有一个特点就是程序在运行是可以动态的选择加载各种程序模块,整个优点就是后来人用来制作程序的插件。

比如某一个公诉开发某个产品,它按照一定的规则制订好程序的接口,其他公司或开发者就可以按照这种接口来编写符合要求的动态链接的文件,该产品程序可以动态的载入各种第三方开大的模块,在程序运行时动态的链接,实现程序功能的扩展。

动态链接还可以增强程序的兼容性,一个程序在不同平台运行时。可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间依赖的差异性。比如,操作系统A和操作系统B对于printf的实现机制不同。如果程序是静态链接的,那么程序需要分别链接成能够在A运行和B运行的两个版本并且分开发布;但是如果是动态链接,只要操作系统A和操作系统B都能提供一个动态链接库包含printf(),并且这个printf()使用相同的接口,那么程序只需要一个版本,就可以在两个操作系统上运行,动态地选择相应的printf()的实现版本。(不是很理解

根据估算,动态链接与静态链相比较,性能损失大约5%以下。

在静态链接时,整个程序最终只是一个可执行文件,它是一个不可分割的整体,但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件,和程序所依赖的共享对象,很多时候也把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看成是一个模块。

如何区分动态链接和静态链接?

连接器是如何知道foobar的引用是一个静态链接符号,还是动态链接符号?这实际上就是用到Lib.so的原因。Lib.so中保存了完整的符号信息(运行时进行动态链接还需要使用符号信息)。把Lib.so也作为链接的输入文件之一,连接器在解析符号时就可以知道:foobar是定义在Lib.so的动态符号。

动态链接程序运行时地址空间分布

对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身之外,还有其它所依赖的共享目标文件,那么进程的地址空间是如何分布的呢?

动态链接器与普通共享对象一样被映射到了进程的地址空间,在开始运行程序之前,首先会把控制权交给动态连接器,由它完成所有的动态链接的工作以后再把控制权交给程序,然后开始执行。共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间额空闲情况,动态分配一块足够大大小的虚拟地址空间给相应的共享对象。

7.3 地址无关代码

固定装载地址的困扰

共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?

为了实现动态链接,首先会想到的就是共享对象地址冲突问题。

有一种做法叫作静态共享库。跟静态库有很明显的区别,静态共享库的做法是将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间。

静态共享库会出现很多问题,除了地址冲突问题,还有静态共享库的升级问题等。

为了解决这种模装载地址固定的问题,设想是否可以让共享对象在任意地址加载呢?这个问题的另一种表述方法就是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定自己在进程虚拟空间的开始位置,因为可执行文件往往是第一个被加载的的文件,它可以选择一个固定空闲的地址,比如Linux下一般都是0x08040000.

装载时重定位

为了使得共享对象在任意地址装载,首先想到的就是静态链接中的重定位,不过不同的是,在链接时,对所有绝对地址的引用不作重定位,而是把这一步推迟到装载时再完成。(这就是装载时重定位)

地址无关代码

装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点,就是指令部分无法在多个进程之间共享。这样就失去了动态链接节省内存的一大优势,因此,还需要一种更好的办法解决共享对象指令中绝对地址的重定位问题。即需要解决的问题是,程序模块中的共享指令部分在装载时不需要因为装载地址的改变而改变。所以实现的基本想法就是把指令中那些需要修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,这种方案就是目前被称为地址无关代码 (PIC)技术。

把共享对象模块中的地址引用按照是否跨模块分为两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问。

当编译器在编译pic.c时,实际上并不能确定变量b和函数ext()是模块外部的还是模块内部的,因为它们可能被定义在同一个共享对象的其他目标文件中(不能理解)。由于没法确定,编译器只能把它们当作模块外部函数和变量来处理。

类型一 模块内部调用或跳转

这种情况是最简单的,因为在模块内部调=调用,所以被调用的函数和调用者都处于同一个模块,它们之间的位置是相对固定的。

比如上面例子中foo对bar的调用可能产生如下代码。

dbe25d67b963ab5470e1aaafa5c82a14.png

cbe147c0f0da302aaeb15f5074ba1a75.png

foo中对bar的调用的那条指令实际上是一条相对地址调用指令。相对偏移调用指令下图所示。

cc089984bf06a5749f03fea304dc3eb7.png

这条指令中的后4个字节是目的地址相对于当前指令的下一条指令的偏移,即0xFFFFFFE8(小端),0xFFFFFFE8是-24的补码形式,即bar的地址为0x804835c+(-24) = ox8048344。只要bar和foo的相对位置不变,那么这条指令就是地址无关的。即无论这个模块被装载到哪个位置,这条指令都是有效的。这种相对地址的方式对于jmp指令也有效。

类型二 模块内部数据访问

很明显,这种情况指令不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。

我们知道一个模块前面一般是若干页的代码,后面也是紧跟若干页的数据,这些页之间的相对位置是固定的。也就是说,任何一条指令与它要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了(我很难理解这个和类型一有什么区别)。

然而现代的体系结构中,数据的相对寻址往往没有相对于与当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的办法来得到当前的PC值,然后再加上一个偏移量就可以达到访问相应变量的目的了。得到PC值的方法有很多,下面看下ELF的共享对象里面用的一种方法

...

这部分有点看不懂,后续再看。

类型三 模块间数据访问

类型四 模块间调用、跳转

7.3.4 共享模块的全局变量问题

8522c61769c6cd56fb486e99e6079ee3.png

6e82a00089e57d867dff3edb468c92ce.png

7.3.5 数据段地址无关性

通过上面的方法,我们能够保证共享对象中的代码部分地址无关,但是数据部分是不是也有绝对地址引用的问题呢。比如下面:

21495e18b63f71435f1f6ca22dd098e8.png

如果某个共享对象里面有这样的一段代码的话,那么指针p的地址就是一个绝对地址,它指向变量a,但是变量a的地址会随着共享对象的装载地址的改变而改变,有什么办法解决呢?

对于数据段来说,它的每个进程都有一份独立的副本,所以并不担心被进程改变,从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址的引用。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和连接器就会产生一个重定位表,这个重定位表里面包含了“R_386_RELATIVE"类型的重定位入口,用于解决上述问题。那么动态连接器装载共享对象时,如果发现了该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。

实际上,我们升至可以让代码段也适用这种转载时重定位的方法,而不是使用地址无关代码。在前面的例子中,我们在编译共享对象时使用了“-fPIC”参数,这个参数表示产生地址无关的代码段,如果我们不实用这个参数来产生共享对象会怎么样呢?

如果代码不是地址无关的,它就不能被多个进程之间共享,于是就失去了节约内存的优点。

但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PIC的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节约内存,所以我们可以看到,动态链接的可执行文件中存在“.got"这样的段。

7.4 延迟绑定(PLT)

de04e9e47fe6252c9c710c2ca55d5448.png

延迟绑定的实现

如果一开始就把所有函数都链接好实际上是一种浪费,所以ELF采用了一种叫作延迟绑定的做法,基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态连接器来负责绑定。这样的做法可以大大加快程序的启动速度。特别有利于一些有大量函数引用和大量模块的程序。

ELF使用了PLT的方法来实现(这里原文有很详细的介绍)

7.5 动态链接相关的结构

Linux共享库的组织

由于动态链接的诸多优点,大量的程序开始使用动态链接机制,导致系统里存在数量极为庞大的共享对象。如果没有很好的方法将这些共享对象组织起来,整个系统中的共享对象文件则会散落在各个目录下,给长期的维护和升级造成了很大的问题,这一章介绍Linux下共享库的管理问题。从文件结构上讲共享库和共享对象没什么区别。

8.1 共享库版本

共享库兼容性

共享库的更新被分为两类:

兼容更新:所有的更新只是在原有的共享库基础上添加一些内容,所有原有的接口都保持不变。

不兼容更新:共享库更新改变了原有的接口,使用该共享库原有接口的程序可能不能运行或者运行不正常。

这里讨论的“接口“是二进制接口,即ABI,共享库的ABI跟程序语言有着很大的关系,不同的语言对于接口的兼容性要求不同,ABI对于不同的语言来说,主要包含一些诸如函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等方面的规则,那么C语言写的共享库来说,什么样的更改会导致ABI的变化呢?

d0a45059b1bfdb479ed0e080f37f2078.png

c1afd8789b97d891485464872a6b9c14.png

注意,仅仅是绝大部分情况,要破坏一个共享库的ABI十分容易,要保持ABI的兼容却十分苦难,很多因素会导致ABI的不兼容,比如不同版本的编译器,操作系统和硬件平台等,使得ABI兼容尤为苦难,使用不同版本的编译器或者系统库可能会导致结构体成员对齐方式不一致,从而导致ABI的变化。

2f5dad02813972f58426c471aa71ff5c.png

c6d1f52a82c9589088c4351ec026b281.png

内存

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值