第二部分 静态链接
第二章 链接和编译
2.1 gcc过程
- 通常将编译和链接合并到一起的过程称为构建。
- gcc
- 使用gcc编译程序:gcc hello.c
- 上述过程包括4个步骤:预处理(prepressing)、编译(compilation)、汇编(assembly)和链接(linking)
- 预编译
- 过程:将源代码文件hello.c和相关头文件预编译为一个.i文件
- 命令:gcc -E hello.c -o hello.i (-E表示只进行预编译)
- 作用:预编译过程主要处理那些源代码文件中以 # 开始的预编译指令
- 处理规则:
- 将所有 #define 删除,并展开所有的宏定义
- 处理所有条件预编译指令,如 #if #ifdef #elif #else #endif
- 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。这个过程是递归的。
- 删除所有的注释:// 和 /* */
- 添加行号和文件名标识,例如 #2 “hello.c" 2 ,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的 #pragma 编译器指令,因为编译器需要使用它们
- 经过预编译后的.i文件不包含任何宏定义,因为宏已经展开,并且包含的文件也插入到.i文件中了。所以当我们无法判断宏定义/头文件是否正确时,可以查看预编译后的文件来确定问题。
- 编译
- 定义:编译过程是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成相应的汇编代码文件,是整个程序构建的核心部分。
- 指令:gcc -S hello.i -o hello.s
- 汇编
- 定义:汇编是将汇编代码->机器可以执行的指令,每一个汇编语句几乎对应一条机器指令。所以汇编过程比较简单。
- 指令:as hello.s -o hello.o 或 gcc -c hello.s -o hello.o
- 链接
- 指令:ld -static crt1.o crt2.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -lc-end-group crtend.o crtn.o
- 第二章就是在介绍链接是什么
2.2 编译器
- 定义:编译器是将高级语言编译为机器语言的工具。
- 编译过程:扫描(词法分析)、语法分析、语义分析、源代码优化、代码生成和目标代码优化
例如:array[index] = {index+4}*(2+6)
- 词法分析
- 扫描:源代码程序被输入到扫描器,扫描器简单地进行词法分析,运用一种类似于有限状态机的算法可以将源代码的字符序列分割成一系列记号。
- 记号的分类:关键字、标识符、字面量(数字、字符串)和特殊符号(加号、等号等)
- 扫描器在识别记号的同时,将标识符存到符号表,将数字、字符串常量存到文字表等。
- 扫描:源代码程序被输入到扫描器,扫描器简单地进行词法分析,运用一种类似于有限状态机的算法可以将源代码的字符序列分割成一系列记号。
- 语法分析
- 定义:语法分析器将对扫描器产生的记号进行语法分析,从而产生语法树。
- 语法分析采用上下文无关语法的分析手段。
- 语义分析
- 语义分类:
- 静态语义:在编译期可以确定的语义
- 动态语义:在运行期才能确定的语义
- 定义:编译器能确定的语义是静态语义,通常包括生命和类型的匹配,类型的转换。
- 经过语义分析后,整个语法树的表达式都被标识了类型。
- 语义分类:
- 中间语言生成
- 定义:源代码优化器往往将整个语法树转换为中间代码,它是语法树的顺序表示,已经非常解决目标代码了。
- 编译器的前后端:
- 前端:负责产生机器无关的中间代码(可跨平台)
- 后端:将中间代码转换为目标机器代码,主要包括代码生成器和目标代码优化器
- 目标代码生成与优化
- 代码生成器:将中间代码转换为目标机器代码,非常依赖目标机器,因为不同的目标机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。
- 目标代码优化器:对上述目标代码进行优化,比如选择合适的寻址方式、使用位移代替乘法运算、删除多余指令等。
- 目标代码中,index和array的地址没有确定。
- 代码生成器:将中间代码转换为目标机器代码,非常依赖目标机器,因为不同的目标机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。
- 链接:定义在其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接时才能确定,所以编译器可以将源代码文件编译成未经链接的目标文件,由链接器将这些目标文件链接起来形成可执行文件。
2.3 链接器
- 重定位:重新计算各个目标的地址的过程
- C/C++模块之间的通信方式——模块间符号的引用:模块间依靠符号来通信类似于拼图,定义符号的模块多出一块区域,引用该符号的模块刚好少那一块区域。
2.4 静态链接——模块拼接
- 静态链接器 —— ld
- 链接定义:把一些指令对其他符号地址的引用加以修正
- 链接过程:包括地址和空间分配、符号决议和重定位。
- 静态链接过程:每个模块的源代码文件(.c)通过编译器编译成目标文件(.o),目标文件和库一起链接成最终可执行文件。
- 库
- 最常见的库是运行时库(Runtime Library),它是支持程序运行的基本函数的集合。
- 库是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。
- 重定位:如果在编译目标文件B时,编译器不知道var的目标地址,就先将目标地址置0,等A和B链接后确定了var的地址为0x1000,则链接器会将该指令的目标地址修改为0x1000,整个地址修正的过程称为重定位。每个要被修改的地方叫一个重定位入口。
第三章 目标文件里有什么
3.1 目标文件
- 目标文件的本质:目标文件从格式上讲,是经过编译后,没有链接的可执行文件格式。虽然有些符号和地址还没有经过调整,但是它本身就是按照可执行文件格式存储的,和最终的可执行文件只是稍有不同。
- 目标文件的格式
- 可执行文件格式:Windows下的是PE格式,Linux下的是ELF格式。
- PE 和 ELF 格式都来源于可执行文件格式COFF,COFF的主要贡献是在目标文件中引入了段的概念。
- 可执行文件、动态链接库和静态链接库都是按照可执行文件的格式进行存储。静态链接库稍有不同,它是把许多目标文件捆绑成一个文件,再加上一些索引,可以理解为包含很多文件的文件包。
- 本质:无论是目标文件、可执行文件还是库,本质上都是基于段的文件集合。程序源代码经过编译后,按照代码和数据分别存入相应段中,编译器还会将一些辅助性的信息(符号、重定位信息等)也按照表的方式存入段中。通常情况下,一个表就是一个段。
3.2 ELF文件
- 在linux下可以用file命令来查看相应的文件格式:
- section和segment
- 目标文件按照代码和数据的不同属性,存储在段/节中(segment/section),它们都表示一定长度的区域。在本书中同一称之为”段“。
- 代码段(.code/.text):源代码编译后的机器指令存放在代码段
数据段(.data):已初始化的全局变量和局部静态变量数据存放在.data段
BSS 段:未初始化的全局变量和局部静态变量存放在.bss段因为.bss段内数据都是0,因此把它们存放在.data中并分配空间是没有意义的。.bss段不给该段的数据分配空间,只是记录数据所需空间的大小。
- ELF文件格式:
- 文件头:首先是文件头,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址、目标硬件、目标OS等信息。
- 段表:文件头包括一个段表,是用来描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性。
- 文件头后面是各个段的内容,源代码被编译后主要分成两个段,程序指令和程序数据。代码段属于程序指令,数据段和.bss段属于程序数据。
- 程序分段的优点:
- 保障指令安全:当程序被装载后,代码段和数据段可以被映射到两个虚拟内存区域,因为代码段是只读的,数据段是可读写的,所以可以给两个段不同的权限,可以保障程序指令不被改写。
- 提高缓存命中率:因为当代CPU有着强大的缓存体系,如果设计成将代码段和数据段分开缓存,可以利用局部性原理提高缓存的命中率。
- 共享指令(最重要的原因):当系统中运行多个同程序的副本时,由于指令是只读的,因此可以共享,节省内存。但数据段是读写的,因此每个副本都有自己私人的数据段。
3.3 挖掘SimpleSection.o
/*
SimpleSection.c
Linux:gcc -c SimpleSection.c
Windows:cl SimpleSection.c /c /Za
*/
int printf(const char* format,...);
int global_init_var = 84;
int global_uninit_var;
void func1(int i){
printf("%d\n",i);
}
int main(void){
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1(static_var + static_var2 + a + b);
return a;
}
- 编译文件
- 指令:gcc -c SimpleSection.c
- -c表示只编译
- 使用objdump来查看object内部结构
- 指令:objdump -h SimpleSection.o
- -h表示打印各段基本信息,-x可以把更多信息打印出来
- CONTENTS表示该段在文件中存在。.bss段没有CONTENTS,表示它实际上在ELF文件中不存在内容。所以ELF文件实际存在的就是.text .data .rodata 和 .comment四个段
- 使用size来查看ELF文件各段的长度
- 指令:size SimpleSection.o
- 指令:size SimpleSection.o
- 查看代码段
- 指令:-s 表示将所有段的内容以16进制方式打印出来,-d表示将所有包含指令的段反汇编
- 指令格式:objdump -s -d SimpleSection.o
- "Contents of section.text"就是将.text的数据以16进制的方式打印出来,最左边一列是偏移量,中间4列是16进制的内容,最右边一列是.text段的ASCII码形式。下面是反汇编的结果。
- 查看数据段和只读数据段
- .data段保持的是已初始化的全局静态变量和局部静态变量。
- .rodata段保持的是只读数据,一般是程序里面的只读数据(如const修饰的变量)和字符串常量(如%d\n)。
- .rodata段的优点:
- 保障程序安全:OS在加载时可以将.rodata设置为只读格式,保障程序的安全性。
- ROM:有些存储区域采用ROM只读存储器,可以将.rodata放在ROM中来保障程序的安全性。
- 指令格式:objdump -x -s -d SimpleSection.o
- 查看.bss段
- .bss段存放的是未初始化的全局变量和局部静态变量。
- 有些编译器会将全局未初始化变量放入.bss段中,有些则不放入,只预留一个未定义的全局变量符号,等最终链接成可执行文件时再在.bss段分配空间(见COMMON块)。
- 指令格式:objdump -x -s -d SimpleSection.o
- 例:x1和x2分别存放在哪个段?
static int x1 = 0; static int x2 = 1;
- x1存放在.bss段中,x2存放在.data段中。
- 因为x1 = 0,可以认为是未初始化的,所以被优化掉放在.bss中,节省磁盘空间。
- 其他段
- 自定义段
- 在全局变量/函数前加上 attribute((section(“name”))) 属性就可以把相应的变量/函数放入以"name"命名的段中。
_attribute_((section("FOO"))) int global = 42;
_attribute_((section("BAR"))) void foo(){}
3.4 ELF文件格式描述
1. 文件头
- ELF头文件结果成员含义
- elf魔数:最开始是4个字节为elf的魔数,即0x7F 0x45 0x4C 0x46 对应ASCII里的del和E L F字母。魔数用来确认文件类型,OS在加载文件时会确认魔数是否正确,如果不正确会拒绝加载。
- 文件类型:e_type成员表示ELF文件类型,每个文件类型对应一个常量,系统通过该常量判断ELF文件的类型。
- 机器类型:e_machine成员表示ELF文件的平台属性,可以在哪个平台下运行。
- elf魔数:最开始是4个字节为elf的魔数,即0x7F 0x45 0x4C 0x46 对应ASCII里的del和E L F字母。魔数用来确认文件类型,OS在加载文件时会确认魔数是否正确,如果不正确会拒绝加载。
- 段表
- 段表用来保存段的基本属性
- ELF文件的段结构由段表决定,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。
- e_shoff 成员决定段表在ELF文件中的位置
- 用readelf查看文件的段:它是以Elf32_Shdr结构体为元素的数组。
- 段描述符
- Elf32_Shdr结构体:段描述符,每个Elf32_Shdr对应一个段
- SectionTable长度为0x1b8,440字节,包含了11个段描述符,每个段描述符长度为40字节,刚好为sizeof(Elf32_Shdr)。
rel.text和Section Table都因为对齐的原因,与前面的段之间分别有一个字节和两个字节的间隔。
- 段名只在链接和编译过程中有意义,但并不能真正表示段的类型。对应编译器和链接器而言,主要决定段的属性的是段的类型(sh_type)和段的标志位(sh_flags)。
- 段的类型(sh_type):
- 段的标志位(sh_flags):段的标志位表示该段在进程虚拟地址空间中的属性,比如是否可写、可执行等。
- 段的链接信息(sh_link、sh_info):如果段的类型与链接有关,该成员才有意义。
- Elf32_Shdr结构体:段描述符,每个Elf32_Shdr对应一个段
2. 重定位表
- sh_type = SHT_REL 为重定位表,之前SimpleSection.o中的 .rel.text 段就是重定位表。
- 重定位的信息记录在ELF文件的重定位表里,对于每个需要重定位的代码段或数据段,都会有一个相应的重定位表。
.rel.text是针对.text段的重定位,因为.text段至少有一个绝对地址的引用(printf函数的调用)
- 它的 sh_link 表示符号表的下标,sh_info 表示它作用于哪个段。
3. 字符串表
- ELF文件中需要用到很多字符串,因为其长度不同,所以将字符串放入字符串表中,然后通过偏移量来引用字符串。
- 字符串表在ELF文件中也以段的形式保存。
- 字符串表:.strtab段(string table),保存普通的字符串,例如符号名。
- 段表字符串表:.shstrtab段(section header string table),保存段表中用到的字符串,例如段名。
5.链接的接口——符号
1. 符号
- 在链接中,目标文件之间相互拼接实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。
- 在链接中,我们将函数和变量称为符号,函数名和变量名称为符号名。
- 符号表:每个目标文件会有一个对应的符号表,里面记录了目标文件中所用到的所有符号。
- 符号值:每个定义的符号有一个对应的符号值,符号值就是符号的地址。
- 符号分类:
- 全局符号:定义在本目标文件中的全局符号,可以被其他目标文件引用。
- 外部符号:在本目标文件中引用的全局符号,却没有定义在本目标文件中。
- 段名:由编译器产生,它的值就是该段的起始地址。例如.text .data等
- 局部符号:只在编译单元内部可见,例如static_var,调试器可以使用这些符号来分析程序或崩溃时的核心存储文件。这些局部符号对于链接过程没有作用,链接器往往忽略它们。
- 行号信息:目标文件指令和源代码中代码行的对应关系,是可选的。
- 使用nm查看SimpleSection.o的符号结果:
2. 符号表结构
- ELF文件的符号表往往是文件的一个段,段名为.symtab
- 符号表是一个Elf32_Sym结构的数组,每个Elf32_Sym对应一个符号。数组的第一个元素无效。
typedef struct {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
}Elf32_Sym;
- st_info:符号类型和绑定信息,该成员低4位表示符号类型,高28位表示绑定信息:
- st_shndx:符号所在段,如果符号定义在本目标文件中,则st_shndx表示所在段在段表中的下标;如果不在本目标文件中,则sh_shndx的值有些特殊:
- st_value:符号值,如果符号是一个函数/变量的定义,则符号的值就是整个函数/变量的地址。
- 在目标文件中,如果st_value是符号的定义且不是COMMON块类型(st_shndx不是SHN_COMMON),则st_value表示符号在段中的偏移。即符号地位为st_shndx段,偏移st_value个位置。
- 在目标文件中,如果st_value是COMMON块类型(st_shndx是SHN_COMMON),则st_value表示该符号的对其属性。
- 在可执行文件中,st_value表示符号的虚拟地址,对于动态链接器非常有用。
- 查看符号表:
3. 特殊符号
- 特殊符号:当我们使用ld作为链接器生成可执行文件时,它会为我们定义很多特殊符号,这些符号并没有在你程序中定义,但是我们可以之间声明并引用它。
-
只有用ld作为链接器时这些特殊符号才会存在。
- 有代表性的特殊符号:
- __executable_start:程序起始地址,不是入口地址,而是最开始的地址。
- _etext:代码段结束地址,即代码段最末尾地址。
- _edata:数据段结束地址,即数据段最末尾地址。
- _end:程序结束地址。
- 我们可以直接在程序中使用这些符号:
#include<stdio.h>
extern char _executable_start[];
extern char _etext[];
extern char _edata[];
extern cahr _end[];
int main(){
printf("Executable Start %X\n",__executable_start);
printf("Text End %X\n",_etext);
printf("Data End %X\n",_edata);
printf("Executable End %X\n",_end);
//%X:以无符号十六进制整数形式(大写)输出,不输出前导符0X
return 0;
}
4. 符号修饰与函数签名
- C语言中,为了避免函数与链接库中的符号名冲突,在Windows下,C源代码中的所有全局变量和函数经过编译后,符号名前加下划线"_"。(Linux下不变)但不能解决C++中函数重载等问题,因此C++中使用名称修饰。
- 函数签名:函数签名用来识别不同的函数,它包含了一个函数的基本信息,包括函数名、参数类型、所在类和名称空间等信息。
- C++名称修饰:编译器和链接器在处理符号时,采用名称修饰的方法,使每一个函数签名对应一个修饰后的名称。
- C++源代码编译后的目标文件中所用的符号名是相应函数和变量的修饰后名称。所以对于不同数字签名的函数,即使函数名相同,编译器和链接器也认为它们是不同的函数。
- 名称修饰的方法:
- 所有符号以_Z开头。
- 对于嵌套的名字(在名称空间/类中),后面紧跟N。然后是各个名称空间和类的名字,每个名字前是字符串长度,然后以E结尾。
例如:N::C::func经过名称修饰后为_ZN1N1C4funcE。 - 对于函数而言,它的参数列表紧跟在E后面,对于int类型来说,就是字母i。
例如:N::C::func(int)经过名称修饰后为_ZN1N1C4funcEi。 - 不同的编译器厂商的名称修饰的方法可能不同,所以不同编译器对于同一个函数签名可能有不同的修饰方法。
- 可以使用binutils查看修饰后名称:
- 签名和名称修饰不光用在函数上,C++中的全局变量和静态变量也有一样的机制。因为它们和函数一样是个全局可见的名称。但是名称中没有变量类型,所以不管什么类型都是一样的名称。
namespace foo{
int bar; //修饰后名称:_ZN3foo3barE
}
- 名称修饰的意义:
- 函数重载
- C++名称空间:运行不同的名称空间中有多个相同名字的符号
- 防止静态变量的名称冲突:例如main和func函数中都有一个静态变量foo,则它们可以分别被修饰为_ZZ4mainE3foo和_ZZ4funcE3foo (Z为静态变量)
5. extern “C”
- C++为了兼容C,在符号管理上,有一个用来声明/定义一个C的符号的extern "C"关键字的用法:
extern "C"{
int func(int);
int var;
}
//单独声明:
extern "C" int func(int);
extern "C" int var;
- C++的宏:
- 若在C++中引用了C语言的函数,编译器会将其符号修饰,链接器就无法与C语言库中的函数链接。所以对于C++来说必须使用extern "C"声明该函数;但C语言中并不支持extern "C"的使用。
- 为了兼容C和C++的头文件,我们使用C++的宏——__cplusplus,C++编译器会在编译C++的程序时默认定义这个宏,我们可以使用条件宏来判断当前编译单元是不是C++代码。如果是C++代码,则该函数会在extern "C"中被声明;如果不是C代码,则直接声明。
#ifdef __cplusplus
extern "C"{
#endif
void *memset(void *,int,size_t);
#ifdef __cplusplus
}
#endif
6. 强符号与弱符号
- 强符号和弱符号
- 对于C/C++来说,编译器默认函数和已初始化的全局变量为强符号;未初始化的全局变量为弱符号。
- 强弱符号是针对定义来说的,不是针对符号的引用的。
- 可以通过gcc的__attribute__((weak))来定义一个强符号为弱符号。
extern int ext; int weak; //弱符号 int strong = 1; //强符号 __attribute__((weak)) weak2 = 2; //弱符号 int main(){ //强符号 return 0; }
- 链接器对强弱符号的处理:
- 不允许强符号被多次定义(不同的目标文件中不能出现同名的强符号)
- 如果一个符号在某目标文件中为强符号,其他文件中为弱符号,则选择强符号。
- 如果一个符号在所有目标文件中都是弱符号,则选择占用空间最大的那个。
- 强引用和弱引用
- 强引用:如果链接器没有找到该符号的定义,则报符号未定义错误。
- 弱引用:如果该符号未被定义,则链接器不报错。一般对于未定义的弱引用,链接器默认其为0,或一个特殊值以便代码能够识别。
- 在gcc中,可以使用__attribute__((weakref))来声明一个外部函数的引用为弱引用。
__attribute_((weakref)) void foo(); int main(){ foo(); //在链接时不报错,但运行时main调用foo函数,发现其地址为0,非法地址访问错误。 } /*---------进行改进------------*/ __attribute_((weakref)) void foo(); int main(){ if(foo) //当foo != 0 时 foo(); }
- 强弱引用的意义:
- 用户可以定义强符号来覆盖掉库中定义的弱符号,使程序可以使用自定义版本的弱符号。
- 程序可以将某些扩展功能模块定义为弱引用,当扩展模块与程序链接后可正常使用;如果去掉这些模块,程序也可以正常链接,只是少了某个功能。使程序的功能更容易裁剪和组合。
- 调试信息
- gcc中加上"-g"参数,编译器就会在产生的目标文件里面加上调试信息,可以通过readelf工具看到里面多了很多debug的段:
第四章 静态链接
- 总结:先确定输入段的最终地址,然后进行服啊后的解析与重定位。
- 例子:源代码 a.c 和 b.c:
/*a.c*/
extern int shared;
int main(){
int a = 100;
swap(&a,&shared);
}
/*b.c*/
int shared = 1;
void swap(int *a,int *b){
*a ^= *b ^= *a ^= *b;
}
4.1 空间与地址分配
- 相似段合并
- 链接器为目标文件分配地址和空间:一是在输出的可执行文件上分配空间;二是在装载后的虚拟地址中的虚拟空间中分配空间。
- 对于有实际数据的段,如.data和.text,在文件和虚拟地址中都要分配空间;对于.bss段来说,它文件中无内容,地址分配值局限于虚拟地址空间。
- 链接中的空间分配只关注虚拟地址空间即可,因为这个关系到链接器后面的地址计算过程;而可执行文件本身的空间分配和链接过程关系不大。
- 两步链接:
- 地址与空间分配:链接器扫描所有目标文件,获取各个段的长度、属性和位置,并将所有符号表中的符号定义和符号引用收集到一个全局符号表中。这一步中,链接器可以获取所有输入目标文件的段长度,将它们合并,计算出输出文件中各个段合并后的长度和位置,并建立映射关系。
- 符号解析与重定位:使用第一步收集到的信息,读取输入文件中段的数据、重定位信息并进行符号解析与重定位、调整代码中的地址。(核心步骤)
- 使用 ld 链接器将 a.o 和 b.o 链接起来:
- ld a.o b.o -e main -o ab
- -e main表示将main函数作为程序入口,ld链接器默认的程序入口为_start
-o ab表示链接输出文件名为ab,默认为a.out
-
VMA为Virtual Memory Address虚拟地址;LMA为Load Memory Address加载地址。正常情况下两个地址是一样的,但在有些嵌入式系统中是不同的,我们只关注VMA即可。
- 在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没分配,所有默认0;等链接后,可执行文件ab中各个段都被分配到了相应的虚拟地址。
- 符号地址的确定
- 在扫描和空间分配后,输入文件中各个段在链接后的虚拟地址已经确定了。只不过链接器要给段中的每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。
- 例:a.o中的main函数相对于.text段的偏移量是X。若链接后a.o的.text段的虚拟地址为0x0804_8094,则main的地址为0x0804_8094+X。
- a.o和b.o中符号的虚拟地址为:
4.2 符号解析与重定位(静态链接的核心)
- 重定位表
- 重定位:编译器会将外部符号的地址用假地址代替进行编译,链接器在完成地址和空间分配后已经可以确定所有符号的虚拟地址了,所以链接器可以根据符号的地址对每个需要重定位的指令进行地址修正。
- 查看a.o的反汇编结果:
- 偏移为0x18(最左边列)的指令是对shared的引用,编译器将0x0看作是shared地址,是绝对地址。
- 偏移为0x26(最左边列)的指令是对swap函数的调用,这条指令是一条近址相对位移调用指令,后面4字节是被调用函数的相对于调用指令的下一条指令的偏移量。在重定位前,相对偏移量为0xFFFF_FFFC,是常量 -4 的补码。因为下一条指令add的指令地址为0x2b,偏移量为-4,所以最终相对地址为 0x2b-4=0x27。
- 这两个地址都是假地址,编译器把这两条指令的地址先用0x0000_0000和0xFFFF_FFFC代替着,把真正的地址计算工作留给了链接器。
- 重定位表:ELF文件中用来与记录重定位相关信息的结构,它在ELF文件中往往是一/多个段。
- 如果代码段.text中有需要重定位的地方,那么一定会有一个rel.text段来保存代码段的重定位表。
- 查看a.o中引用的外部符号的地址:
- 重定位入口:每个要被重定位的地方叫做一个重定位入口,a.o中有两个重定位入口。
- 偏移(offset):重定位入口的偏移表示该符号在该段(要被重定位的段中,即引用该符号的段中)的位置。
- 重定位表的结构:一个Elf32_Rel结构的数组,每个元素对应一个重定位入口。
typedef struct{ Elf32_Addr r_offset; //重定位入口的偏移 Elf32_Word r_info; //重定位入口的类型和符号 }Elf32_Rel;
-
符号解析
- 重定位的过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。
- 在重定位的过程中,每个重定位入口都是一个对外部符号的引用。当链接器要对某个符号进行重定位时,就要确定它的目标地址。这时链接器就要去查找由所有输入的目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
- 全局符号表:用来记录整个编译过程中产生的所有符号的。(编译时产生的,链接不产生新东西,它只是重定位符号)
- 查看a.o的符号表:
- UND为 undefined 未定义类型,这种UND符号都是因为该目标文件中有关于它们的重定位项。所以编译器在扫描完所有输入的目标文件后,这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。
-
指令修正方式
- 32位x86平台下ELF文件的重定位入口所修正的指令寻址方式只有两种:
- 绝对近址32位寻址
- 相对近址32位寻址
- 重定位入口的r_info成员的低8位表示重定位入口类型:
- 我们将a.o和b.o链接为可执行文件后,main函数的虚拟地址为0x1000,swap函数的虚拟地址为0x2000,shared变量的虚拟地址为0x3000。那么链接器如何将a.o的重定位入口修正呢?
- 绝对(近址32位)寻址修正:
- 偏移为0x18的mov指令的修正,S+A。
- S为shared实际地址——0x3000,A为修正位置的值——0x0000_0000,最终修正地址为0x0000_3000。即:
- 相对(近址32位)寻址修正:
- 偏移为0x26的call指令的修正,S+A-P。
- S为swap的实际地址——0x2000,A为被修正位置的值——0xFFFF_FFFC (-4),P为被修正的位置,当链接成可执行文件时,这个值应该是被修正位置的虚拟地址,即0x1000+0x27。修正后的地址为0xFD5。
- (P-A) [0x1027-(-4) = 0x102b] 为下一条指令的地址,S-(P-A)[ 0x2000 - 0x102b = 0xfd5] 为swap的实际地址与下一条指令地址之差(偏移量)
- 相对位置的调用指令调用的是下一条指令的起始地址加上偏移量,即:0x102b+0xfd5 = 0x2000。
- 区别:绝对地址修正后的地址为符号的实际地址;相对地址修正后的地址为符号地址与下一条指令之间的符号差。
- 绝对(近址32位)寻址修正:
- 32位x86平台下ELF文件的重定位入口所修正的指令寻址方式只有两种:
4.3 COMMON块
- 问题:弱符号机制允许同一个符号的定义存在于多个文件中,所以如果一个弱符号定义在多个目标文件中,但类型不同怎么办?目前链接器并不知道符号类型,它只知道名字。
- 符号类型不一致的几种情况:
- 两个/以上的强符号类型不一致:定义多个强符号本身就是非法的。
- 一个强符号,其他都是弱符号,类型不一致:以强符号为准。
- 两个/以上的弱符号类型不一致:以所需空间最大的为准。
- COMMON块:弱符号编译后在符号表中的所在段类型。弱符号编译后并不分配空间,只是把它符号表的所在段类型st_shndx标记为SHN——COMMON。
- 查看符号global_uninit_var在符号表中的值:st_shndx = SHN_COMMON
- 当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。现代编译器和链接器都支持COMMON块机制。
- 例:弱符号变量a占4字节,另一个a占8字节,最后链接的时候,a的大小以输入文件中最大的a为准,所以是8字节空间。
- COMMON类型的链接规则是针对符号都是弱符号的情况,若其中一个为强符号则最终输出结果中符号所占空间与强符号相同。但如果链接中有弱符号>强符号的话,ld链接器会报如下警告:
ld:warning: alignment 4 of symbol 'global' in a.o is smaller than 8 in b.o
- COMMON块机制的直接原因是编译器和链接器允许不同类型的弱符号存在;根本原因是链接器不支持符号类型,即链接器无法判断各个符号的类型是否一致。
- 问题:在目标文件中,为什么编译器不把未初始化的全局变量放入.bss段中,而是为它分配一个COMMON块?
答:因为未初始化的全局变量为弱符号,所以有可能存在其他类型不同的相同变量。编译器不能知道它最终占用的空间大小,无法为它在.bss段分配空间,所以先放给它分配一个COMMON块。链接时可以确定该弱符号的最终大小,所以可以在最终输出文件的.bss段为其分配空间。未初始化的全局变量最终还是被放入.bss段中。 - 历史遗留问题:关于多个文件出现同一变量的原因,有种说法是早期C语言程序员经常忘记在变量前加extern关键字,使得编译器会在多个目标文件中产生同一个变量的定义。为解决这个问题,编译器和链接器干脆把未初始化的全局变量都当作COMMON类型处理。
- 自己的思考:因为全局变量是未定义的,所以不管是不是extern都是0,不影响程序的运行,所以人们选择优化编译器而不是强制让程序员写extern关键字。
- gcc的“-fno-common”也允许我们把未初始化的全局变量不以COMMON块形式处理,或者使用__attribute__扩展
int global __attribute__((nocommon)); //当弱符号不以COMMON形式存在时,000000000000000000000000000000它就相当于一个强符号。
4.4 C++相关问题
- C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。最主要的是C++的重复代码消除和全局构造与析构。
- C++的一些语言特性,如虚拟函数、函数重载、继承、异常等,使得它背后的数据结构异常复杂,这些数据结构往往在不同编译器和链接器之间相互不能通用,使得C++程序的二进制兼容性册成为一个很大的问题。
1. 重复代码消除
-
重复代码消除
- C++编译器产生重复代码:模板、外部内联函数(可以被其他源文件调用的内联函数)和虚函数表等都有可能在不同的编译单元里生成相同的代码。
- 编译单元:一个.c或.cpp文件作为一个编译单元,生成.o。
模板函数可能在不同编译单元被实例化成相同的类型;编译器会在用到虚函数类的多个编译单元生成虚函数表;
- 重复代码的影响:
- 空间浪费。
- 地址较易出错:可能两个指向同一函数的指针不相等。
- 指令运行效率低:现代CPU对指令和数据有缓存功能,如果同一份指令有多个副本,则Cache命中率降低。
- 方法:
- 将每个模板的示例代码都单独放入一个段中,每个段只包含一个模板实例。
- 例如模板函数add(),某个编译单元以int和float实例化了该模板函数,则编译单元的目标文件中就包含了这两个该模板实例的段。假设起名为.temp.add和.temp.add,当别的编译单元也以int和float类型实例化该模板函数时,也会产生相同的名字。
- 链接器在最终链接时可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。
- gcc将这种需要在链接时合并的段命名为.gnu.linkonce.name(name为该模板函数实例的修饰后名称)。
- 方法的缺点:相同名称的段可能因为不同编译单元使用了不同版本的编译器/编译优化选项而拥有不同的内容,这时链接器会随意选择一个副本作为链接的输入,同时提出一个警告。
-
函数级别链接
- 由于现在的程序和库往往非常庞大,因此,当我们要用到某目标文件的任意一个函数/变量时,就需要把它们整个链接进来,那些没用到的函数也一起链接进来了。使得链接输出文件变得非常庞大。
- 函数级别链接:链接器提供的一个选项,把所有函数单独保存在一个段中。当链接器要用到某函数时,就把它合并到输出文件中,没用到的函数就丢弃。
- 缺点:减慢了编译和链接过程。因为链接器要计算各个函数之间的依赖关系,并且所有函数都保存到独立段中,段数大大增加,重定位过程也因为段数的增加而复杂,目标文件随着段数的增加也变大。
- gcc提供了类似的机制,“-ffunction-sections”和“-fdata-sections”为将每个函数/变量分别保存到独立的段中。
2. 全局构造与析构
- C/C++程序是从main开始执行,到main结束而终止。但在main函数执行前,为了程序能够顺利执行,要先初始化进程执行环境,比如堆分配初始化、线程子系统等。
- C++全局对象的构造函数在main之前执行,析构函数在main之后执行。
- Linux系统下一般程序入口为“_start”,该函数是Linux系统库(Glibc)的一部分。
- ELF文件定义了两种特殊的段:
- .init:段内保存的是可执行指令,在main调用之前,Glibc的初始化部分安排执行这个段中的代码;如果一个函数放入该段中,则在main函数执行前系统会执行它。
- .fini:段内保存着进程终止代码指令,当一个程序的main函数正常退出时,Glibc安排执行这个段中的代码;如果一个函数放入该段中,则在main函数执行后系统会执行它。
3. C++与ABI
- 如果不同编译器编译出的目标文件能够相互链接,则这几个目标文件必须具有相同的ABI。
- ABI:符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容(Application Binary Interface)。
- API与ABI:
- API:Application Programming Interface,应用程序编程接口。
- ABI:Application Binary Interface,应用程序二进制接口。
- 它们都是应用程序接口,API是源代码级别的接口,比如规定 printf() 的原型;ABI是二进制层面的接口,比如规定 printf() 在运行过程中的参数压栈顺序,参数在堆栈中如何分配等。
4.5 静态库链接
- 语言库:库是对OS的API的包装。
- 静态库可以简单看作是一组目标文件的集合。但如果把零散的目标文件(printf.o malloc.o等)直接提供给库的使用者的话,会造成文件传输、阻止和管理上的不便。于是,人们通常用“ar”压缩程序把这些目标文件压缩到一起,并对其进行编号和索引,以便于查找和检索。
- Linux中最常用的C语言静态库libc位于user/lib/libc.a。
- 使用 ar 工具来查看libc.a包含了哪些目标文件:
- 为什么静态链接库中一个目标文件只包含一个函数,如printf.o只有printf()函数?
答:因为链接器在链接静态库时是以目标文件为单位的。比如我们引用了printf()函数,则链接器就会把库中包含printf()的目标文件链接进来。如果一个目标文件中有多个函数,则很多没有的函数就会一起被链接进来。
4.6 链接过程控制
- 大多数情况下,我们使用链接器提供的默认链接规则对目标文件进行链接是没问题的;但对于一些特殊文件(如OS内核、BIOS或一些没有OS情况下运行的程序等),它们往往受限于一些特殊条件(特别是对某些硬件条件的限制),往往对程序的各个段的地址有着特殊的要求。
- 链接控制脚本
- 链接器一般有三种链接方法:
- 使用命令行给链接器指定参数:ld的 -o -e 参数等。
- 将链接指令存放到目标文件中,编译器通常会通过这种方法向链接器传递指令。
- 使用链接控制脚本:最灵活、最强大的链接控制方法。
- 如果我们在使用ld时没有指定链接脚本,则它会使用默认链接脚本。为了更精确地控制连接过程,我们自己写一个脚本,然后指定该脚本为连接控制脚本,使用参数-T。
ld -T link.script
- 链接器一般有三种链接方法:
- 最“小”的程序
- 经典的C语言hello world小程序:
- 使用了printf()函数,该函数是系统C语言库的一部分;我们希望小程序能摆脱C语言库,成为一个独立于任何库的小程序。
- 使用了库,所以必须有main函数。因为程序入口在库的_start,由库负责初始化后调用main函数来执行程序的主体部分。为了不适用main,小程序使用nomain作为入口。
- 使用了多个段,.text段、.data段等。为了演示ld连接过程,我们将小程序的所有段合并到一个“tinytext”段中。(我们自己命名的段,由链接脚本控制链接过程生成的)
- TinyHelloWorld.c 源代码:
char *str = "Hello World!\n"; void print(){ //使用WRITE调用向文件句柄写入数据 int write(int filedesc,char* buffer,int size); //asm("...") 是使用汇编语言嵌入在C语言中的方式。 asm("mov1 $13,%%edx \n\t" //size,要写入的字节数,使用edx传递。 //$表示立即数,%%表示寄存器名 "mov1 %0,%%ecx \n\t" //buffer表示要写入的缓冲区地址,使用ecx传递,ecx = str "mov1 $0,%%ebx \n\t" //filedesc表示被写入的文件句柄,使用ebx传递。我们默认向终端输出,文件句柄为0 "mov1 $4,%%eax \n\t" //WRITE调用的调用号为4,则eax = 4 "int $0x80 \n\t" //系统调用,向屏幕输出 ::"r"(str):"edx","ecx","ebx"); // "r"(str) 表示将变量 str 放入一个通用寄存器中 } void exit(){ //调用exit()函数结束进程。使用EXIT系统调用 asm("mov1 $42,%ebx \n\t" //ebx表示进程的退出码类似于return 0 "mov1 $1,%eax \n\t" //EXIT系统调用号为1 "int $0x80 \n\t"); } void nomain(){ print(); exit(); //调用EXIT结束进程是因为普通程序中main执行结束后控制权会返回给系统库,由系统库调用EXIT。 //nomain()结束后系统控制权不会返回,可能会执行到nomain()后面不正常的指令。 }
- 经典的C语言hello world小程序:
- 使用ld链接脚本
- 如果把连接过程比作一台计算机,那么ld链接器就是CPU,链接脚本就是程序来控制CPU的运行。所有的目标文件和库函数是输入,链接结果输出的可执行文件是输出。
- 无论是输入文件还是输出文件,它们的主要数据就是文件中的各种段。我们把输入文件中的段称为输入段;输出文件中的称为输出段。控制链接过程就是如何控制输入段如何变成输出段。
- 链接脚本TinyHelloWorld.lds (lds为ld script)
ENTRY(nomain) //指定入口地址 SECTIONS{ //链接的主体:指定输入段到输出段的变换 . = 0x08048000 + SIZEOF_HEADERS; //将当前虚拟地址设置为0x08048000 + SIZEOF_HEADERS //SIZEOF_HEADERS为输出文件文件头的大小,这样设置可以便于装载时页映射更方便。 tinytext : {*(.text)*(.data)*(.rodata)} //将输入文件中名字为.text .data .rodata的段依次合并到输出文件的 .tinytext中 /DISCARD/:{*(.common)} //将名字为.common的段丢弃 }
- 启用该链接控制脚本:
gcc -c -fno-builtin TinyHelloWorld.c ld -static -T TinyHelloWorld.lds -o TinyHelloWorld TinyHelloWorld.o