0%

静态链接

一些基础知识:

在一个多CPU电脑中,对称多处理器(SMP):每个CPU在系统中所处的地位和所发挥的功能都是一样的,是相互对称的。

理论上增加CPU的数量可以提高运行速度。但是对于那种不能分成多条不相干的分支,只能一步一步执行的程序,却并不能带来什么运行上多大的帮助,

多核处理器实际上共享了缓存部分,留下多个核心。只要没有把CPU榨干的想法,可以把SMP和多核看作同一个概念。

计算机系统软件体系结构采用一种层的结构。因为有了中间层,应用程序才能和硬件之间保持相对独立。层与层之间要相互通讯,通信的协议我们一般称之为接口。

操作系统提供的系统调用接口往往以软件中断的方式的方式提供。

操作系统的功能:提高抽象的接口。管理硬件资源。

多任务系统:CPU由操作系统统一分配。按照进程优先级分配时间。当时间超出一定时间时,操作系统暂停该进程,CPU把资源分配给另外进程。

CPU提供‘in’和“out”指令来实现硬件端口的读和写。

内存不够的解决办法:把程序给出的地址看作虚拟地址。然后通过某种映射,把虚拟地址转换成物理地址。比如每个进程都有自己独立的虚拟空间,每个进程只能访问自己的地址空间。

分页:

把地址空间分成固定大小。每页的大小由硬件决定。比如:4KB

我们把虚拟空间的页叫做虚拟页。物理内存的页叫物理页。磁盘中的页叫做磁盘页。

当程序开始执行这一页时。由于该页还未放入内存,就会触发页错误。然后将磁盘中该地方的内容映射入内存。然后将执行流返回该地址,再次执行

我们可以为每一个页设置权限,谁可以访问,谁可以读写或者执行。这样操作系统就可以做到保护进程的作用。

几乎所有的硬件都采用一个叫MMU的部件来进行页映射。

线程:

也称轻量级进程,是程序执行的最小单元。各个线程之间可以共享进程资源。但线程也有独立的栈和寄存器和线程局部存储(TLS)。

使用多线程原因:

可以在一个线程等待是执行其他操作。

可以不同线程之间执行不同操作。

多线程在数据共享方面效率要高很多。

线程的几种状态:

新建状态(New):

用new语句创建的线程处于新建状态,此时它和其他Java对象一样,仅仅在堆区中被分配了内存。

就绪状态(Runnable):

当一个线程对象创建后,其他线程调用它的start()方法,该线程就进入就绪状态,Java虚拟机会为它创建方法调用栈和程序计数器。处于这个状态的线程位于可运行池中,等待获得CPU的使用权。

运行状态(Running):

处于这个状态的线程占用CPU,执行程序代码。只有处于就绪状态的线程才有机会转到运行状态。

阻塞状态(Blocked):

阻塞状态是指线程因为某些原因放弃CPU,暂时停止运行。当线程处于阻塞状态时,Java虚拟机不会给线程分配CPU。直到线程重新进入就绪状态,它才有机会转到运行状态。

阻塞状态可分为以下3种:

位于对象等待池中的阻塞状态(Blocked in object’s wait pool):
当线程处于运行状态时,如果执行了某个对象的wait()方法,Java虚拟机就会把线程放到这个对象的等待池中,这涉及到“线程通信”的内容。
位于对象锁池中的阻塞状态(Blocked in object’s lock pool):
当线程处于运行状态时,试图获得某个对象的同步锁时,如果该对象的同步锁已经被其他线程占用,Java虚拟机就会把这个线程放到这个对象的锁池中,这涉及到“线程同步”的内容。
其他阻塞状态(Otherwise Blocked):
当前线程执行了sleep()方法,或者调用了其他线程的join()方法,或者发出了I/O请求时,就会进入这个状态。

死亡状态(Dead):

当线程退出run()方法时,就进入死亡状态,该线程结束生命周期。

如下图:

线程优先级:

高优先级线程先执行

在windows中使用:

1
BOOL WINAPI SetThreadPriority(HANDLE hThread,int nPriority);

来设置线程优先级。与Linux有关的线程操作可以通过调用pthread库。

线程优先级改变的三种方式:

用户指定优先级

根据进入等待状态的频繁状态提升或降低优先级。

长时间得不到执行而提升优先级。

频繁等待的线程我们称之为IO密集型线程,把很少等待的线程称为CPU密集型线程。IO密集型线程比CPU密集型线程更容易得到线程的提升。

可抢占式线程和不可抢占式线程:

线程在用尽时间后会被强制剥夺继续执行权力,而进入就绪状态,过程叫做抢占。

在不可抢占线程中,线程主动放弃的两种情况:

当线程试图等待某线程时(I/O等)

线程主动放弃时间片。

linux多线程:

linux所有执行实体叫做任务。每个任务都类似单线程的进程。Linux在不同任务间可以选择执行共享内存空间。

fork()函数:

产生一个新任务。两个任务可以自由读取内存。但只要有一个任务要对其内存进行修改,内存就会复制一份提供给修改方使用。

fork只能产生本任务映像。exec可以用新的可执行映像替换当前可执行映像。。

clone函数实际效果相当于产生一个新线程。

线程安全:

单条指令的操作称为原子的。

锁:

锁是一种非强制机制,每一个线程访问数据或资源之前首先试图获取锁,并在访问结束时释放锁。

一个线程访问数据未结束时,其他线程不得对同一数据进行访问,我们称之为同步。

我们一般采用一种叫锁的方式实现同步,一般分下面几种:

二元信号量:他由于能被任意线程获取和释放,所以只适用于一个线程独占访问的资源。

多元信号量,简称信号量,允许多个线程并发访问的资源。

互斥量:要求哪个线程获取则哪个线程释放。

临界量:临界区的作用范围仅限于本进程。其他进程无法获取锁

读写锁:我们在程序偶尔写入时使用它。两种获取方式分别为共享的和独占的。

条件变量:

也是同步的一种手段。当满足条件时,唤醒线程。

可重入:

一个函数被重入的两种情况:

多个线程同时执行这个函数。

函数自身调用自身。

函数可重入的特点:

不使用任何静态或全局的非const变量

不返回任何静态或全局的非const变量的指针

仅依赖于调用方提供参数

不依赖任何单个资源的锁

不调用任何不可重入的函数。

过度优化:

过度优化可能会调换执行顺序。

用volatile关键字阻止过度优化。

该关键字作用:

阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。

阻止编译器调整操作volatile变量的指令顺序。

C++中new的两个步骤:

分配内存

调用构造函数

调用CPU提供的barrier指令会阻止CPU将该指令交换到barrier之后。

三种线程模型:

一对一模型:一个用户使用的线程对应一个内核使用的线程。

多对一模型:多个用户线程映射到一个内核线程中

多对多模型:结合多对一和一对一模型的特点。

静态链接:

编译与链接:

.c文件生成可执行文件的四个步骤:

预编译:

删除所有“#define”宏定义

处理所有条件编译指令

预编译过程主要是处理那些源代码文件在以“#”开始的预编译文件。

删除所有的注释

添加行号和文件名标识

保留所有的#pragma编译器指令。

编译:

分为六个步骤:

扫描,语法分析,语义分析,源代码分析,代码生成和目标代码优化。

以下面代码为例子分析:

1
2
array[index] = (index+4)*(2+6)
CompilerExpression.c
词法分析:

源代码输入扫描器,进行简单的词法分析。

利用一种类似有限状态机将源代码字符序列分割成一系列记号。

记号分类:关键字,标识符,字面量(数字,字符串)和特殊符号。

语法分析:

语法分析器将对扫描器产生的记号进行语法分析,产生语法树,整个分析采用了上下文无关语法。

在这一过程中,很多运算符号的优先级和含义被确定下来了。

语义分析:

由语义分析器来完成。

静态语义:在编译期间可以确定的语义。

动态语义:只有在运行期才能被确定的语义。(0做除数)

有些类型需要做隐式转换,与语义分析程序会在语法树中插入相应的转换节点。

中间语言生成:

使用源码级优化器。把能计算的计算出来,如2+6 = 8。

把整个语法数转换成中间代码,一般常见形式:三地址吗和P-代码。

中间代码使得编译器分为前端和后端,前端负责与机器无关的中间代码,后端将中间代码转换成目标机器代码。

目标代码生成与优化:

编译器后端包括代码生成器和目标代码优化器。

1
2
3
4
5
movl index,%ecx
addl $4,%ecx
mull $8,%ecx
movl index,%eax
movl %ecx,array(,eax,4)

目标代码优化器对目标代码进行优化。比如选择合适的寻址方式,使用位移代替乘法,删除多余指令。优化后:

1
2
3
movl index,%edx
leal 32(,%edx,8),%eax
movl %eax,array(,edx,4)

乘法采用了基址变址寻址。

经过这些操作,源代码变成了目标代码,但是index和array的地址没有确定。接下来会讲到链接器的内容。

静态链接:

人们把每一个源代码模块独立的编译,按照需要将他们“组装”起来,这个组装模块的过程叫做链接。

链接过程主要包括地址空间分配,符号决议,重定位。

符号决议也叫做符号绑定,名称绑定,名称决议。“决议”倾向于静态链接,“绑定”倾向于动态链接。

每个模块经过编译器生成目标文件,目标文件和库一起链接形成最终可执行文件。

最常见的库就是运行时库。它支持程序运行的基本集合。

一个模块main函数中调用了另外模块foo函数,在编译的时候,由于每个模块单独编译,我们无法知道foo函数地址,所以就先将foo的地址搁置,等到最后链接的时候再把这些指令的目标地址修正。全局变量也是类似。

假设两个文件链接后,变量地址确定下来为0x1000,链接·器会把这个指令的地址部分修改为0x10000,

这个修正的地址也叫做重定位,每个要被修正的地方叫做重定位入口。

目标文件:

PC平台上的可执行文件格式包括:windows下的PE,linux下的ELF。

目标文件就是源代码编译后但未进行链接的中间文件。

可执行文件:

windows的exe和linux下ELF

动态链接库:windows下dll(linux下.so)

静态链接库:windows下lib(linux下.a)

目标文件与可执行文件格式跟操作系统密切相关。

目标文件包括编译后的机器指令和代码,数据。链接时所需要的一些信息,如符号表,调试信息,字符串等。

按照不同的属性,我们按’节‘来区分,也可以叫’段‘。

源代码编译后机器指令放在代码段(.code和.text),全局变量和局部静态变量放在数据段(.data).

ELF文件:

开头是一个文件头,描述了文件属性,如,是否可执行,静态还是动态链接,入口地址等。文件头还包括一个段表。

段表是一个描述文件中各个段的数组。段表描述了各个段在文件中的偏移位置及段的属性,从段表中可以得到每个段的所有信息。

已初始化的全局变量和局部静态变量存放在.data段,未初始化的全局变量和局部静态变量一般放在.bss段(只预留位置,不占据空间)。

文件头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ELF header:

Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS / ABI: UNIX - System V
.version: 0
Type:
Machine:
Version: 0x1
Entry point address: 0x0
Start of program headers: ( bytesintofile)
Start of header :
Flags:
Size of this header:
Size of program headers:
Number of program headers :
Size of section headers:
Number of Section headers:
Section header string table index:

elf.h中定义了一套自己的体系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define EI_NIDENT 16
  typedef struct{
  unsigned char e_ident[EI_NIDENT];
  Elf32_Half e_type; //它标识的是该文件的类型。
  Elf32_Half e_machine; //表明运行该程序需要的体系结构。
  Elf32_Word e_version; // 表示文件的版本。
  Elf32_Addr e_entry; //程序的入口地址。
  Elf32_Off e_phoff; //表示Program header table 在文件中的偏移量
  Elf32_Off e_shoff; //表示Section header table 在文件中的偏移量
  Elf32_Word e_flags; // 对IA32而言,此项为0。
  Elf32_Half e_ehsize; //表示ELF header大小
  Elf32_Half e_phentsize; //表示Program header table中每一个条目的大小。
  Elf32_Half e_phnum; //表示Program header table中有多少个条目。
  Elf32_Half e_shentsize; // 表示Section header table中的每一个条目的大小
  Elf32_Half e_shnum; //表示Section header table中有多少个条目。
  Elf32_Half e_shstrndx; //包含节名称的字符串是第几个节(从零开始计数
  }Elf32_Ehdr;

e_ident对应了Magic,Class,Data,Version,OS/ABI,和ABI Version六个参数。

段表:

编译器,链接器和装载器都是利用段表来定位和访问各个段的属性的。

“Elf32_Shdr”被称为段描述符。每个这样的结构体对应一个段。

Elf32_Shdr各成员定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sh_name:section 的名字。这里其实是一个索引,指出 section 的名字存储在 .shstrtab 的什么位置。.shstrtab是一个存储所有 section 名字的字符串表。

sh_type:section 的类型

sh_flags:Section type标志位。

sh_addr:如果此 section 需要映射到进程空间,此成员指定映射的起始地址。如不需映射,此值为 0。

sh_offset: 此 section 相对于文件开头的字节偏移量。如果 section 类型为 SHT_NOBITS,表明该 section 在文件中不占空间,这时 sh_offset 没什么用。

sh_size:此 section 的字节大小。如果 section 类型为 SHT_NOBITS,就不用管 sh_size 了。

sh_link 和 sh_info :基本上就是针对不同的 section 类型,分别给出字符串表、符号表等所在section 在section header table 中的索引。

sh_addralign:指明此 section 的 sh_addr 向几字节对齐,sh_addralign 应该是 2 的正整数倍。如果为 0 或 1,表明此 section 没有字节对齐约束。

sh_entsize:有些节的内容是一张表,其中每个表项的大小固定,比如符号表。对于这种表,本成员指定其每个表项的大小。如果此值为 0,表明该段不包括固定大小项。


重定位表:

重定位表记录了代码段和数据段中那些对绝对地址引用的位置。

字符串表:

ELF文件引用字符串只需给出数字下标。

字符串一般段名:.strtab和.shstrtab。一个是字符串表,一个是段表字符串表。

字符串表用来保存普通字符串,如,符号名字。

段表字符串表保存段表中用到的字符串,如,段名。

指令与代码分开的好处:

程序被装载时,数据和指令分别被映射到两个虚拟空间。

有利于提高程序的局部性。

当运行多个程序副本时,只保留一份该程序指令。

数据段和只读代码段:

.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。

.rodata存放只读数据,存放只读变量(const修饰)和字符串。

有时候字符串会放在.data段。

BSS段:

.bss段存放未初始化全局变量和局部静态变量。

编译器会将全局的未初始化变量存放在.bss段,有些则不存放,只是预留一个未定义的全局符号,链接成可执行文件再分配空间。

除了最常见的为 .text, .data, .bss 这 3 个段之外,还有其它的一些常见段,如下所示:

.strtab : String Table 字符串表,用于存储 ELF 文件中用到的各种字符串。

.symtab : Symbol Table 符号表,从这里可以所以文件中的各个符号。

.shstrtab : 是各个段的名称表,实际上是由各个段的名字组成的一个字符串数组。

.hash : 符号哈希表。

.line : 调试时的行号表,即源代码行号与编译后指令的对应表。

.dynamic : 动态链接信息。

.debug : 调试信息。

.comment : 存放编译器版本信息,比如 “GCC:(GNU)4.2.0”。

.plt 和 .got : 动态链接的跳转表和全局入口表。

.init 和 .fini : 程序初始化和终结代码段。

应用程序自定义的段不能使用’.’作为前缀,否则容易与系统段名发生冲突。

自定义段:

实现特定功能,如,满足某些硬件的内存和I/O的地址布局,linux操作内核来完成一些初始化和用户空间复制时出现页错误异常。

我们可以指定变量所处段:

1
__attribute__((section("name")))

链接接口——符号:

在链接中,将函数和变量统称为符号。函数名和变量名称为符号名。

每个目标文件都有一个符号表。

每个符号都有一个对应的值,叫符号值。符号值就是他的地址。

符号分类:

本目标文件的全局符号

外部符号

段名

局部符号:调试器可以使用这些符号来分析程序或崩溃时的核心转储文件。

行号信息

符号表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
st_name
符号名。这个成员包含了该符号名在字符串
表中的下标(还记得字符串表吧?)

st_value
符号相对应的值。这个值跟符号有关,可能
是一个绝对值,也可能是一个地址等

st_size
符号大小。对于包含数据的符号,这个值是该数据
类型的大小。比如一个double型的符号它占用8个字节。
如果该值为0,则表示该符号大小为0或未知

st_info
符号类型和绑定信息

st_other
该成员目前为0,没用

st_shndx
符号所在的段

符号表结构

特殊符号:

链接器会在程序最终链接时将其解析成正确的值,只有使用ld链接生产最终可执行文件的时候符号才会存在。

符号修饰与函数签名:

C++符号修饰:

函数签名包含一个函数的信息,包括函数名称,参数类型,所在的类和名称空间及其他信息。我们这里介绍Visual C++的名称修饰规则:

首先以“?”开头,接着函数名由@结尾,后面跟由@结尾的类名和名称空间,再用@表示函数名称结束,第一个A表示函数调用类型:__cdecl,接着就是函数参数类型及返回值,由@结束,最后以Z结束。如:int C::C2::func(int)修饰后名称:?func@C2@C@@AAEHH@Z

UnDecorateSymbolName()API可将修饰后的名称转换为函数签名。

extern “C”:

1
2
3
4
5
6
7
#ifdef __cplusplus
extern "c"{
#endif
void *memset(void*,int,size_t);
#ifdef __cplusplus
}
#endif

C++的宏“__cplusplus”,C++编译器在编译C++的程序时默认定义这个宏,我们可以使用条件宏来判断是不是C++代码。如果是,就把memset在extern “c”申明。如果是C,就直接申明。

强符号与弱符号:

编译器默认函数和初始化的全局变量为强符号,未初始化的全局变量为弱符号。

我们可以通过GCC的”_ attribute_((weak))”将强符号转换成弱符号。

多次定义全局符号规则:

不允许强符号被多次定义

如果一个符号在某个目标文件是强符号,其他地方弱符号,选择强符号。

一个符号在所有目标文件都是弱符号,选择占用空间最大的。

弱引用和强引用:

如果没有找到符号定义,链接器就会报未定义错,这种称为强引用。

弱引用:如果符号有定义,链接器就将该符号引用决议,如果符号未定义,链接器对符号不报错。

程序可以对某些扩展功能模块的引用定义为弱符号,当我们扩展模块与程序链接在一起时,功能模块就可以正常使用。

如果一格程序被设置为支持单线程和多线程模式,就可以通过弱引用方法来判断当前程序是链接了单线程Glibc库还是多线程Glibc库。

调试信息:

ELF文件采用一个叫DWARF的标准的调试信息格式。microsoft也有自己的调试信息格式(Codeview).

linux下用strip命令可以去掉ELF文件中调试信息。

静态链接:

空间地址分配:

链接时,我们会采用相似段合并的方式。对于.text和.data段来说,分配空间是文件和虚拟内存都分配。而.bss段只是虚拟空间分配。

链接器一般采用两步链接的方法:

空间与地址分配

将输入目标文件中的符号表中所有符号定义和符号引用收集起来,统一放到全局符号表

符号解析与重定位

使用收集到的信息,读取输入文件中段的数据,重定位信息,进行符号解析与重定位,调整代码地址。

链接前后程序中所使用的地址已经是程序在进程中的虚拟地址,我们只需要关心各个段中的VMA和Size。

链接器需要为每一个符号添加一个偏移量,是他们能够调整到正确的地址上。

符号解析与重定位:

重定位:

每个段中如果需要有重定位的地方,就会有一个对应的重定位段,如.text重定位段rel.text。

重定位表的结构,是一个Elf32_Rel结构的数组,每个数组元素对应一个重定位入口。定义如下:

1
2
3
4
5
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;

r_offset
本数据成员给出重定位所作用的位置。对于重定位文件来说,此值是受重定位作用的存储单元在节中的字节偏移量;对于可执行文件或共享ELF文件来说,此值是受重定位作用的存储单元的虚拟地址。

r_info
本数据成员既给出了重定位所作用的符号表索引,也给出了重定位的类型。以下是应用于 r_info 的宏定义。

符号解析:

重定位的过程也是符号解析的过程,每个重定位入口都对应一个符号的引用。链接器需要对某个符号的引用进行重定位时,要确定这个符号的目标地址,链接器就要去查找所有输入目标文件的符号表组成的全局符号表。

指令修正方式:

绝对近址寻址 R_386_32 S+A

相对近址寻址R_386_PC32 S+A-P

A:保存被修正位置的值

p:被修正的位置(相对于段开始的偏移量和虚拟地址)

S:符号的实际地址。(r_info的高24位)

COMMON块:

编译器和链接器支持一种叫COMMON块的机制。

未初始化的全局变量标记为COMMON类型的变量。

链接器链接过程可以确定弱符号的大小。未初始化的全局变量还是被放在了BSS段。

我们可以使用:

1
int global __attribute__((nocommon))

来将未初始化的全局变量不以COMMON块的形式处理。

C++全局构造与析构:

.init:

该段保存可执行指令,构成进程初始化代码。

.fini:

该段保存进程终止代码指令。

ABI:

二进制层面接口

决定目标文件二进制接口是否兼容的方法:

内置类型的大小和存储器中的存放方式。

组合类型的存储方式和内存分布。

外部符号与用户定义的符号之间的命名方式和解析方式。

函数调用方式

堆栈的分布方式

寄存器使用约定

继承类体系的内存分布

指向成员函数的指针的内存分布

如何调用虚函数,vtable的内容和分布形式

template如何实例化。

外部符号的修饰

全局对象的构造何析构

异常的产生和捕获机制

标准库的细节问题

内嵌函数使用细节

链接过程控制:

链接控制脚本:

链接器控制脚本三种方法:

使用命令行给链接器指定参数

将链接指令存放在目标文件里面。

使用链接控制脚本

VC++允许使用链接脚本控制整个链接过程。vc++把这种控制脚本叫做模块定义文件,扩展名为def

ld在用户没有指定链接脚本时使用默认链接脚本。

ld链接脚本语法简介

BFD库:

BFD库是一个GUN项目,他的目标就是希望通过一种统一的接口来处理不同的文件格式。

BFD把目标文件抽象成一个统一的模型,使得它的程序只要通过操作这个抽象的目标文件模型就可以实现操作所有BFD支持的目标文件格式。