前言
对于我们平时写代码运行,我们很少去关注编译和链接的过程,因为现在的开发环境都是集成(IDE)的,这些IDE一般都会将编译和链接的过程一步搞定,这一过程又被称为构建。但若经常写代码,经常会有很多莫名其妙的错误让我们不知所措,对于这些错误若我们能知其原因,那是再好不过了。因此本系列就是带你了解这些编译器和链接器在背后的工作
梦开始的地方
让我们先来看一个最最最经典的例子
//hello.c
#include <stdio.h>
int main()
{
printf("hello world");
return 0;
}
事实上,运行以上过程,可以被分解为四步:预处理、编译、汇编、链接
预编译
预处理器在预编译阶段会将源代码文件.c和相关的头文件编译为一个.i文件,其中主要处理“#”开头的预编译指令
预编译过程主要处理规则:
- 删除所有注释 "//" 和 "/**/"
- 处理所有条件预编译指令 "#if"、"#ifdef"等
- 删除所有 "#define",且展开所有宏定义
- 处理预编译指令"#include",将被包含的文件插入到相应的预编译指令位置。当然可能插入的文件还包含其他文件
- 添加行号和文件名标识,以便于编译器在编译时产生调试所用的行号信息和产生编译错误或警告时可以显示行号
- 保留所有#pragma指令,便于编译器使用
编译
所谓编译,就是将上一个预处理阶段处理完的文件进行词法分析、语法分析、语义分析和优化后生成的汇编代码文件。其过程最为关键且复杂。但是现在版本的GCC已经把预编译和编译合并为一个步骤,使用cc1来完成
现有如下片段:
array[index] = (index + 4) * ( 2 + 6 )
词法分析
首先,源代码程序被输入到扫描器,运用类似有限状态机的算法将源代码的字符序列分割为一系列记号(token)
记号 | 类型 |
---|---|
array | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
4 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
- 词法分析的记号可分为:关键字、标识符、字面量(数字、字符串等)、特殊符号(加号等)
- 识别记号时,扫描器也会将标识符放入符号表,将字面量常量放入文字表,以备往后的步骤使用
语法分析
接下来时语法分析。语法分析器对扫描器产生的记号进行语法分析,再产生语法树,语法树时以表达式为节点的树。语法分析过程会采用上下文无关语法
- 语法分析过程,会确定运算符号的优先级和含义。此时若出现表达式不合法,编译器则会报告语法分析阶段的错误
语义分析
语法分析会对完成了表达式的语法层面的分析,但其并不知道这个语句是否有意义,因此需要语义分析器进行语义分析,从而对整个语法树的表达式标识类型;若有类型需要做隐式转换,会在语法树中插入相应的转换节点
语义分析分为两类,静态语义是编译器再编译器可以确定的语义;动态语义则是在运行期才能确定的语义
- 静态语义包括声明、类型的匹配和类型的转换。比如浮点类型赋值给整型,需要进行类型转换
生成中间语言
编译器有很多不同的优化,其中一种便是在源代码级别用源码级优化器进行优化。直接在语法树上优化较为困难,因此源代码优化器将语法树转换为中间代码
-
虽然中间代码看上去已经十分接近目标代码了,但中间代码和机器以及运行时环境无关
-
中间代码类型:三地址码,P-代码
//三地址码 //表示将y z进行op操作后赋值给x x = y op z t1 = 2 + 6 t2 = index + 4 t3 = t2 * t1 array[index] = t3; //优化 //优化程序会计算2 + 6 t2 = index + 4 t2 = t2 * 8 array[index] = t2;
-
中间代码可以将编译器分为前后端。前端由编译器产生机器无关的中间代码;而后端由编译器将中间代码转换为目标机器代码。前端关注的是正确反映代码含义的静态结构,而后端关注让代码良好运行的动态结构。好处是对于跨平台的编译器,它们可以针对不同平台使用同一个前端和针对不同机器的数个后端
目标代码生成于优化
编译器后端包括代码生成器和目标代码优化器。代码生成器将中间代码转换为目标机器代码,目标代码优化器会将目标代码进行优化
革命尚未结束
也许你觉得到这里我们已经万事俱备,已经形成可执行文件。但其实之前的步骤只是将源代码文件编译为目标文件,但在目标文件中我们还未确定index和array的地址,若index和array的地址在另一个程序模块,便没法确定地址,我们还需其他手段
这个问题由链接解决。事实上,定义在其他模块的全局变量和函数最终运行时的绝对地址都需要在链接时才能确定。编译器将一个源代码文件编译为一个未链接的目标文件,随后由链接器最终将目标文件链接未可执行文件。编译器只是暂时搁置调用地址的指令,最后等到链接时由链接器去修正地址
汇编
汇编器将汇编代码转变成机器指令(机器可以执行的指令)
- 由于每个汇编语句几乎都对应一条机器指令,因此汇编过程较为简单,没有复杂语法、语义、指令优化,只需根据汇编指令和机器指令的对照表一一翻译便好
深挖中间目标文件
中间目标文件又简称目标文件(object文件):编译器编译源代码后生成的文件。从结构上来说,目标文件是已经编译后的可执行文件格式,只是没有经过链接,其中某些符号和地址还没有调整,但其本身是按照可执行文件格式存储的
往后我们将深入分析目标文件格式,介绍ELF文件的重要段及文件头、段表、重定位表、字符串表、符号表、调试信息等相关结构;我们会了解到可执行文件、目标文件、库都是以段为基础的文件,不仅是数据和代码存放在相应段中,编译器也会将一些辅助信息按照表的方式存储
目标文件格式
现如今的pc平台流行的可执行文件格式为windows的PE和Linux的ELF,都是COFF格式的变种
目标文件格式和编译器和操作系统有关,不同平台下的格式各有不同
ELF格式的文件类型分为四类:
ELF文件类型 | 说明 | 实例 |
---|---|---|
可重定位文件(relocatable file) | 包含数据和代码,可被用来链接成可执行文件或共享目标文件,静态链接库也属于此类 | Linux .o windows .obj |
可执行文件(executable file) | 包含可直接执行的程序,一般没有扩展名 | /bin/bash文件 windows .exe |
共享目标文件(shared object file) | 包含数据和代码,可在以下两种情况使用。一是链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接,生成新的目标文件;二是动态链接器可以将几个这样的共享目标文件与可执行文件结合,作为进程映像的一部分运行 | Linux .so,如/lib/glibc-2.5.so windows的DLL |
核心转储文件(core dump file) | 当进程意外终止时,系统可将该进程的地址空间的内容及终止时的其他信息转储到核心转储文件 | Lindex的core dump |
目标文件的重要段
目标文件包含机器指令代码、数据、链接时所需要的信息(符号表、调试信息、字符串);以"字"和"段"进行存储,都表示一定长度的区域,基本不加以区别,以后的都统一为"段"
机器指令被放于代码段,常见的名称有".code"或".text";
全局变量和局部静态变量数据放于数据段,常见的名称有".data";
未初始化的全局变量和局部变量放于".bss"段
文件头(file header)描述整个文件的属性,其中包含文件是否可执行、静态链接还是动态链接及入口地址、目标硬件、目标操作系统等信息,ELF文件还包括一个段表(描述文件中各个段的数组),其描述了文件中各个段在文件中的偏移量、段名、长度、读写权限等
ELF文件布局会随着讨论不断深入而扩大
总的来说,源代码编译后主要分为两种段:指令和数据
那么,我们为什么要对这些指令和数据进行分类呢?这有什么好处呢?
- 程序被装载后,数据和指令分别被映射到两个虚拟存储区域。数据区域对进程来说是可读写,而指令区域是只读的。如此可以防止指令被恶意改写
- cpu在当下拥有十分强大的缓存,因此程序需要想尽一切办法提高缓存的命中率;而指令和数据进行分类后有利于提高程序的局部性,就可以提高命中率了
- 当系统中运行多个同一个程序的副本且它们的指令也是相同的,内存中只需要保存一份此程序的指令部分,当然其他只读数据也是同样的道理;不过数据区域是进程私有,因此每个副本进程的数据是不同的
纸上谈来终觉浅
如果只是对目标文件了解概念上的知识,而不深入其具体细节,我认为这并不可能真正了解他,因此接下来我将以一个具有代表性的例子撩开这层神秘的面纱
现有一个instance.c程序:
int globalInitVar = 1;
int globalUninitVar;
int printf( const char* format, ... );
void func1( int i )
{
printf( "%d\n", i );
}
int main()
{
static int staticVar = 2;
static int staticVar2;
int a = 1;
int b;
func1( staticVar + staticVar2 + a + b );
return a;
}
用gcc编译(-c)此文件,再通过binutils的工具objdump(-h将基本信息打印出来,-x信息更多)查看object内部结构:
我们先来看几个重要的段属性,Size表示是段的长度;File off表示段的偏移量,也就是所在的位置;CONTENTS表示该段在文件中存在。我们可以看到.bss并没有CONTENTS,说明它在ELF中没有内容
- size 查看ELF文件的代码段、数据段、BSS的长度和(dec十进制,hex十六进制)
接下来,我们将细探这几个段所包含的内容
用objdump的-s将所有段的内容以十六进制方式打印出来,-d将所有包含指令的段反汇编
- 首先是代码段:
contents of section.text是.text的数据用十六进制打印出来的。其中最左边的是offset偏移量;中间四列是十六进制内容,也就是段长度,这里是0x5b和之前看到的是一致的;最后一列是当前段的ASCII码形式。下面的是反汇编结果,很显然两个函数func1和main正是本例程序里的
- 其次是数据段和只读数据段:
.rodata段存放只读数据,如字符串常量和const修饰的。在这里调用printf时用到的字符串常量"%d\n"就是只读数据,因此将其放在.rodata段。但有些编译器会把字符串常量放到.data段
设立.rodata段的好处:
- 支持c++关键字
- 操作系统在加载时可以将.rodata段属性映射为只读,保证安全性
objdump -x -s -d instance.o查看data情况:
contents of section .data的中间部分前四个字节为0x01、0x00、0x00、0x00这个值也就是globalInitVar.后四个字节为0x02、0x00、0x00、0x00,这个值也就是staticInitVar
在这里你可能会有疑惑,为什么globalInitVar的次序不是0x00、0x00、0x00、0x01呢?这与CPU的字节序有关,现在有请我们的嘉宾大端和小端登场!
在不同的计算机结构中,对于数据的存储和传输机制有所不同,这导致了一个问题——通信双方的信息单元应该以怎样的顺序传送?目前计算机体系中最常用的字节存储机制有两种:大端和小端
在了解大端和小端前,我们还需了解两个概念——MSB(most significant bit/byte)和LSB(least significant bit/byte)。MSB表示在一个bit序列或一个byte序列中对整个序列取值影响最大的那个bit/byte;LSB表示在在一个bit序列或一个byte序列中对整个序列取值影响最小的那个bit/byte
比如0x32857233,0x32则是MSB,0x33时LSB
大端规定了存储时MSB放在低地址,传输时MSB放在流开始处,存储时LSB放在高地址,传输时LSB放在流的末尾处;小端与大端相反
pc的CPU的兼容机中经常用小端,而mac机器以及TCP/IP、java虚拟机用大端.至于是大端好还是小端好,这个问题已经争论很久了但也没有结论
- 然后是.bss段:
.bss段为globalUninitVar和staticVar2预留了空间,但很奇怪的是这个段的长度只有四个字节,而globalUninitVar和staticVar2的大小总和是8。通过符号表查找,原来只有static_var2存放在.bss段,而globalUninitVar却没有存放在任何段,其表现形式是一个未定义的COM符号。关于这个现象与编译器实现有关,有些编译器会将全局静态未初始化的变量存放在.bss段,只是预留未定义的全局变量符号,等到最终链接成可执行文件时再为.bss分配空间
当然除了.text、.data、.bss这三个常用的段外,ELF文件也很有可能包含其他种类的段
常用段名 | 说明 |
---|---|
.rodata1 | 只读数据 |
.comment | 编译器版本信息 |
.debug | 调试信息 |
.dynamic | 动态链接信息 |
.hash | 符号哈希表 |
.line | 调试时的行号表,也就是源代码行号和编译后指令的对应表 |
.note | 额外的编译器信息,如程序的公司名、发布版本 |
.strtab | 字符串表,存储ELF文件中用到的字符串 |
.symtab | 符号表 |
.shstrtab | 段名表 |
.plt .got |
动态链接的跳转表和全局入口表 |
.init .fini |
程序初始化和终结代码段 |
- 以上这些段都是系统保留的,我们也可以使用一些非系统保留的名字作为自定义段的名字
ELF文件总体结构
ELF文件将要了解的重要结构:
ELF文件头
文件头作为ELF目标文件的最前部包含描述整个文件的属性,如ELF文件版本、目标机器型号、程序入口地址、包含段描述符的段表等等
我们通过readelf -h instance.o来查看ELF文件内容:
可以看到ELF文件中包含了ELF魔数(Magic)、机器字节长度(Class)、数据存储方式(Data)、版本(Version)、操作系统运行平台(OS/ABI)、ABI版本、ELF文件类型(Type)、硬件平台(Machine)、硬件平台版本(Version)、入口地址(Entry point address)、程序和段表的起始位置(Start of program headers、Start of section headers)、当前ELF文件头大小(Size of this header)、程序头大小(Size of program headers)、程序头数量(Number of program headers)、段表描述符大小(Size of section headers)、段表描述符数量(Size of section headers)、段表字符串表所在段在段表中的下标(section header string table index)
ELF文件有32位和64位版本,文件头亦是如此,分别是"Elf32_Ehdr"和"Elf64_Ehdr",定义在"/usr/include/elf.h"两个版本文件头内容都一样,只是部分成员大小不同。以下是文件头"elf.h"使用typredef定义的变量体系
自定义类型 | 描述 | 原始类型 | 长度(字节) |
---|---|---|---|
Elf32_Addr | 32位版本程序地址 | uint32_t | 4 |
Elf32_Half | 32位版本的无符号短整型 | uint16_t | 2 |
Elf32_Off | 32位版本的偏移地址 | uint32_t | 4 |
Elf32_Sword | 32位版本有符号整型 | int32_t | 4 |
Elf32_Word | 32位版本无符号整型 | uint32_t | 4 |
Elf64_Addr | 64位版本程序地址 | uint64_t | 8 |
Elf64_Half | 64位版本的无符号短整型 | uint16_t | 2 |
Elf64_Off | 64位版本的偏移地址 | uint64_t | 8 |
Elf64_Sword | 64位版本有符号整型 | int32_t | 4 |
Elf64_Word | 64位版本无符号整型 | uint32_t | 4 |
举例Elf32_Ehdr:
typedef struct
{
//ELF魔数(Magic)、机器字节长度(Class)、数据存储方式(Data)、版本(Version)、操作系统运行平台(OS/ABI)、ABI版本
unsigned char e_ident[16];
//ELF文件类型
Elf32_Half e_type;
//ELF文件的CPU属性
Elf32_Half e_machine;
//ELF版本号
Elf32_Word e_version;
//入口地址
Elf32_Addr e_entry;
//start of program headers
Elf32_Off e_phoff;
//start of section headers 段表起始位置
Elf32_Off e_shoff;
//ELF标志位,标识ELF相关属性
Elf32_Word e_flags;
//size of this header ELF文件头大小
Elf32_Half e_ehsize;
//将在后面的系列动态链接讲解
Elf32_Half e_phentsize;
//将在后面的系列动态链接讲解
Elf32_Half e_phnum;
//段表描述符大小
Elf32_Half e_shentsize;
//段表描述符数量
Elf32_Half e_shnum;
//段表字符串表在所在段表的下标
Elf32_Half e_shstrndx;
}Elf32_Ehdr;
Magic 从前面readelf输出的内容可以得知,Magic后面16个字节对应于e_ident,用来标识ELF文件的平台属性,如ELF字长是32位还是64位下的、字节序是大端还是小端、ELF文件版本;其中前四个字节也就是这里所说的ELF魔数,这四个字节的魔数用来确认文件类型。操作系统在加载可执行文件时会确认魔数是否正确,若不对则拒绝加载;
上面这个例子,前面四个字节是标识码,所有ELF文件都相同,也就是ELF魔数,这四个字节的魔数用来确认文件类型。操作系统在加载可执行文件时会确认魔数是否正确,若不对则拒绝加载;第一个字节0x7f对应ASCII字符里的DEL控制符,后面三个字节0x45、0x4c、0x46是ELF这三个字母的ASCII标识码;第五个字节表示系统位数,也就是Class,64位是0x02,32位是0x01;第六个字节表示字节序,也就是Data,0x01是小端,0x02是大端;第七个字节表示ELF文件的主版本号,也就是Version,一般为1,因为ELF标准在1.2往后就没更新了;最后九位字节没有定义,填0。但有些平台会使用这九个字节作为扩展标志
Type e_type表示ELF文件类型,在之前我们提到过4种ELF文件类型,其中三种文件类型对应一个常量,系统通过这个常量来判断ELF文件类型,而不是文件扩展名
常量 | 值 | 含义 |
---|---|---|
ET_REL | 1 | 可重定位文件,.o |
ET_EXEC | 2 | 可执行文件 |
ET_DYN | 3 | 共享目标文件,.so |
Machine ELF文件格式在不同平台可以使用,但这并不代表一个ELF文件可以在不同的平台使用,只是不同平台遵守同一套ELF文件标准
e_machine表示该ELF文件的平台属性
常量 | 值 | 定义 |
---|---|---|
EM_M32 | 1 | AT&T WE 32100 |
EM_SPARC | 2 | SPARC |
EM_386 | 3 | Intel x86 |
EM_68k | 4 | Motorola 680000 |
EM_88K | 5 | Motorola 880000 |
EM_860 | 6 | Intel 80860 |
段表
我们使用段表来包含各式各样的段,它描述各个段的信息,比如名字、长度等;编译器、链接器和装载器都是依靠段表来定位访问各个段
在ELF文件中,段表的位置由ELF文件头的"e_shoff"成员记录
段表是一个以"Elf32_Shdr"结构体为元素的数组,每个元素对应一个段,也可以称这个结构体为段描述符.Elf32_Shdr定义在"/usr/include/elf.h"
我们用readelf -S instance.o来查看完整的段表信息
可以看到,段表数组中的第一个元素是个无效的段描述符,类型为NULL,除此以外都是有效的
在这里我们解释Elf32_Shar各个成员的含义
sh_name | Section name 段名 一个字符串,位于一个叫做".shstrtab"的字符串表 |
---|---|
sh_type | section type 段的类型 |
sh_flags | section flag 段的标志位 |
sh_addr | section address 段的虚拟地址 |
sh_offset | section offset 段偏移 |
sh_size | section size 段长 |
sh_link 和 sh_info | section link and section information 段链接信息 |
sh_addralign | section address alignment 段地址对齐 |
sd_entsize | section entry size 项长 包含一些固定大小的项,如符号表,其包含的每个符号大小相同 |
- sh_type sh_flag 段名只在编译链接过程有意义,但它并不真正表示段的类型;而对于编译器来说,主要决定段属性的是段类型和段标志位.其中段标志位表示该段在进程虚拟地址中的属性,如是否可写,是否可执行等
sh_type相关常量
常量 | 值 | 含义 |
---|---|---|
SHT_NULL | 0 | 无效段 |
SHT_PROGBITS | 1 | 程序段 |
SHT_SYMTAB | 2 | 表示该段内容为符号表 |
SHT_STRTAB | 3 | 表示该段内容为字符串表 |
SHT_RELA | 4 | 重定位表。包含重定位信息 |
SHT_HASH | 5 | 符号表的哈希表 |
SHT_DYNAMIC | 6 | 动态链接信息 |
SHT_NOTE | 7 | 提示信息 |
SHT_NOBITS | 8 | 表示该在文件中无内容 |
SHT_REL | 9 | 包含重定位信息 |
SHT_SHLIB | 10 | 保留 |
SHT_DNYSYM | 11 | 动态链接的符号表 |
sh_flag相关常量
常量 | 值 | 含义 |
---|---|---|
SHF_WRITE | 1 | 表示该段在进程空间可写 |
SHF_ALLOC | 2 | 表示该段在进程空间中需要分配空间 |
SHF_EXECINSTR | 4 | 表示该段在进程空间中可被执行 |
- 段链接信息
sh_link 和 sh_info所包含的意义
sh_type | sh_link | sh_info |
---|---|---|
SHT_DYNAMEIC | 该段所使用的字符串表在段表中的下标 | 0 |
SHT_HASH | 该段所使用的符号表在段表中的下标 | 0 |
SHT_REL SHT_RELA |
该段所使用的相应符号表在段表中的下标 | 该重定位表所作用的段在段表中的下标 |
SHT_SYMTAB SHT_DYNSYM |
与操作系统有关 | 与操作系统有关 |
other | SHN_UNDEF | 0 |
重定位表
以上例子,在段表中有一个称为".rela.text"的段,其类型为"SHT_REL",就是重定位表,也就是说重定位表也是一个段;该表是针对.text段的重定位表,其"sh_link"表示符号表下标,"sh_info"表示其作用于哪个段。比如当前这个例子,".rela.text"作用于".text"段,因为".text"段下标为1,"sh_info"就为1
字符串表
ELF文件中会经常用到字符串,比如段名、变量名之类的;但因为字符串的长度大多数情况是不确定的,所以用固定大小的结构去表示它行不通。一种常用的方法是将字符串存放在一个表中,用字符串的偏移量来引用字符串,也就是字符串表;这样引用字符串只需一个数字下标即可搞定而不用考虑长度的问题
如以下字符串表
字符串表在ELF文件中同样以段的形式存储。如"strtab"为字符串表,保存普通的字符串;"shstrtab"为段表字符串表,保存段表中用到的字符串
"e_shstrndx"是Elf32_Ehdr中的成员,表示"shstrtab"在段表中的下标,也就是段表字符串表在段表中的下标
符号表
链接(具体请查看2.5链接)的本质就是其实就是将一个复杂的系统逐步分割成小系统,把每个源代码模块独立编译,再按需将它们组装。其原理便是将指令对其他符号地址的引用加以修正。而链接过程非常关键的一部分就是对符号的管理,也就是符号表;每个目标文件都含有一个符号表,其中包含了当前目标文件所用的所有符号
每个符号都有值,对于变量函数来说,这个值就是它们的地址,被称为符号值
下面,我们对符号分一下类:
- 在目标文件内定义的全局符号,可以被其他目标文件引用。如,之前的例子中的"glovalInitVar"、"func1"
- 在目标文件内引用其他目标文件的全局符号,并没有定义在当前目标文件,称为外部符号。如,"printf"
- 局部符号。只在编译单元内可见,对链接无用,因此链接器会忽视这类符号。作用是调试器使用这类符号分析核心转储文件
- 段名
- 行号
使用nm instance.o查看符号表;readelf -s instance.o查看符号表中的符号
符号表结构 ELF文件中符号表往往是一个段,叫做".symtab";结构上是一个包含Elf32_Sym结构体的数组,其中Elf32_Sym对应一个符号。从上表我们可以得知,符号表的第一个元素是未定义符号,也就是无效的
以下是Elf32_Sym的结构体定义
typedef struct
{
Elf32_Word st_name; /* Symbol name (string tbl index) 符号名。这个成员是该符号名的下标,因为我们是通过偏移量得出符号名 */
Elf32_Addr st_value; /* Symbol value 符号值 */
Elf32_Word st_size; /* Symbol size 符号类型的大小 */
unsigned char st_info; /* Symbol type and binding 符号类型和绑定信息 */
unsigned char st_other; /* Symbol visibility 目前为0,暂时没用 */
Elf32_Section st_shndx; /* Section index 符号所在的段 */
} Elf32_Sym;
st_info符号类型和绑定信息 该类型低4位标识符号类型,高28位标识符号绑定信息
符号绑定信息
宏定义名 | 值 | 说明 |
---|---|---|
STB_LOCAL | 0 | 局部符号 |
STB_GLOBAL | 1 | 全局符号 |
STB_WEAK | 2 | 弱引用 |
符号类型
宏定义名 | 值 | 说明 |
---|---|---|
STT_NOTYPE | 0 | 未知类型符号 |
STT_OBJECT | 1 | 数据对象,如变量 |
STT_FUNC | 2 | 函数或其他可执行代码 |
STT_SECTION | 3 | 段,此类符号必须为STB_LOCAL |
STT_FILE | 4 | 文件名,一般为目标文件的源文件名,必须是STB_LOCAL,其st_shndx必须为SHB_ABS |
st_shndx符号所在段 若定义在本目标文件内,表示符号所在段在段表中的下标;若不是,则较为特殊
宏定义名 | 值 | 说明 |
---|---|---|
SHN_ABS | 0xfff1 | 表示一个绝对的值,如意为文件名的符号 |
SHN_COMMON | 0xfff2 | 表示一个"COMMON块"类型,一般来说是未初始化的全局符号 |
SHN_UNDEF | 0 | 表示未定义。在本文件引用,定义在其他目标文件 |
st_value符号值 按之前说的符号值是函数或变量的地址,但需分情况
- 目标文件内,若表示的是符号的定义且不为"COMMON"(即st_shndx不为SHN_COMMON),则st_value表示该符号在段内偏移量
- 目标文件内,若符号位"COMMON",则st_value表示符号的对齐属性
- 可执行文件内,st_value表示符号的虚拟地址
下面我们来分析实际的符号表内容
Num对应st_name表示数组下标;Value对应st_value表示符号值;Size对应st_size表示符号大小;Type和Bind对应st_info表示符号类型和绑定信息;Vis对应st_other在c/c++内并未使用,忽略。
我们可以看到符号类型Type存在SECTION类型的,这些类型表示下标为Ndx的段的段名,它们并没有显示符号名,因为段名就是它们的符号名.如Num为2的Ndx为1,说明它是".text"段的段名
特殊符号 链接器进行链接产生可执行文件时,会定义许多特殊符号,这些符号并不是在我们的程序中定义,但我们可以引用它。如:
- _executable_start,表示程序最开始的地址
- _end或end,程序结束地址
- __etext或_etext或etext,表示代码为结束地址
- _edata或edata,表示数据段结束地址
符号修饰 在以前,编译后产生的目标文件,符号名与对应的变量函数的名字是一样的,没有变化;后来演化出相当多由汇编编写的库和目标文件,这时就产生了一个问题,程序若要使用这些库,就不能使用库中定义的变量函数的名字作为符号名,否则会发生冲突。为了防止符号名发生冲突,会对原本定义的名字加一些符号,如"_",也就是所谓的符号修饰,这并没有从根本上解决问题,因此后来c++推出了名称空间来解决这类问题
函数签名 函数签名包含一个函数的函数名、参数类型、所处类、名称空间及其他信息。函数签名用于识别不同函数,在编译器及链接器处理符号时,会使用某种名称修饰方法,使得每个函数签名对应一个修饰后的名称;这种方法不仅对函数有效,全局变量和静态变量也在使用
弱符号和强符号 若我们在多个目标文件中定义了相同名字的全局符号,链接时将会出现名称重复的错误
编译器默认函数和已初始化的全局变量为强符号;未初始化的全局变量为弱符号。当然,也可以通过"__attribute__((weak))"定义任何一个强符号为弱符号
判断为强化号还是弱符号,是通过定义,而非引用
下面我们来分析一个例子
extern int exa;
int strong = 2;
int weak;
__attribute__((weak)) weak2 = 3;
int main()
{
//...
}
上述片段中,强符号有strong、main,弱符号有weak、weak2,exa都不是
链接器会按如下规则处理和选择被多次定义的全局符号:
- 不允许强符号被多次定义,否则链接器报符号重复定义错误
- 若一个符号在一个目标文件内为强符号,在其他文件中都为弱符号,则选择强符号
- 若一个符号在所用目标文件内都为弱符号,则选择占用空间最大的一个为强符号
尽量不要使用多个不同类型的弱符号,容易导致难以发现的错误
弱符号的优点:库中定义的弱符号可以被用户定义的强符号覆盖,使得程序可以使用定义的库函数
调试信息
目标文件内可能包含调试信息,且调试信息占用很大的空间,比代码和数据还大几倍
在gcc编译时用"-g"参数,编译器就会在目标文件里加上调试信息
链接
也许你会疑惑已经转变成机器指令了,为什么还需要链接?为什么不直接输出可执行文件反而输出目标文件?链接过程到底包含了什么?
在计算机最早的时候,编写并不像现在如此快捷轻松,当时的程序员使用机器语言在纸上写好程序,当程序要被执行时,便手动地将程序写入存储设备纸带(在纸带上会打相应的孔)。若一条指令需要执行的内容是跳转到目的地的绝对地址;此时会面临一个问题,程序并不是一层不变,大概率会经常被修改;若目的地的绝对地址发生变动,程序员又需要手动修改之前执行跳转的指令,这种来来回回修改会使得开发效率十分低下。这个时候汇编语言如同救世主一般降临,汇编语言因其使用接近人类的各种符号和标记帮助记忆,开发效率提高了很多。但问题到这里并没有戛然而止,随着软件规模的日渐庞大,代码里也快速地膨胀,这导致我们需要考虑将不同功能的代码以一定方式组织起来,使得更加容易阅读理解,对于以后维护十分有用。于是,逐渐地人们开始将代码按功能或性质进行划分,形成不同的功能模块,不同模块间按照层次结构组织;这些模块相互依赖且相对独立。问题接踵而至,当一个程序被分割为多个模块后,这些模块间如何形成一个单一程序?这就很像模块间如何通信?
模块间通信有两种,都需要知道地址,也就是模块间符号的引用:
- 模块间函数调用
- 模块间变量访问
综上,我们便可得出链接其实就是将一个复杂的系统逐步分割成小系统,把每个源代码模块独立编译,再按需将它们组装。其原理便是将指令对其他符号地址的引用加以修正
链接器所要做的便是将各个模块之间相互作用的部分处理好,使得各个模块间可以正确衔接。使用链接器可以直接引用其他模块的函数和全局变量而无需知其地址,因为链接时链接器会根据引用的符号去自动查找符号的地址,再将引用符号地址的指令进行修正
链接的过程主要包括:地址空间分配、符号决议、重定位等
以下便是最基本的静态链接过程:每个模块的源代码文件经编译器编译为目标文件,目标文件和库一起链接形成最终的可执行文件
静态链接
在这之前我们了解了基本的预编译、编译、汇编、链接的过程,并分析了ELF文件轮廓和内容,接下来我们将进入本文的核心内容静态链接
既然我们知道链接会将目标文件合并为一个可执行文件,那么这个过程要经历哪些步骤?
现在有这样两个源文件,main.c文件会引用sub.c内的swap函数和变量,接下来我们要做的是将它们链接形成一个可执行文件
//m.c
extern int shared;
void swap( int*, int* );
int main()
{
int x = 100;
swap( &shared, &x );
return 0;
}
//sub.c
int shared = 1;
void swap( int* a, int* b )
{
*a ^= *b ^= *a ^= *b;
}
空间与地址分配
我们知道,可执行文件的代码段和数据段是通过合并多个目标文件得来的,那么对于这些目标文件,链接器是怎样重新分配空间给这些目标文件呢?
按序叠加 将各个目标文件按照顺序叠加
这种方法非常简单,但会造成空间上的浪费,因为对每个段都有地址和空间上的对齐要求,这会导致存在许多零散的段,合并的目标文件越多,问题愈加明显
注:这里的地址和空间有两个含义。对于".text"这种有实际数据的段,在文件中和虚拟地址中都要分配空间;而".bss"段装载前并不占用文件空间,因此对他只是分配虚拟地址空间
- 输出的可执行文件中的空间
- 装在后的虚拟地址中的虚拟地址空间
相似段合并 将相同性质的段合并在一起。这种方法是现在链接器采用的
相似段合并链接分为两步:
- 空间与地址分配 获得所有目标文件各自的各个段的长度、属性、位置,计算出合并后的长度、位置并建立映射关系,将他们的符号表中所有的符号定义和符号引用统一放在一个全局符号表中
- 符号解析与重定位 获得信息后再进行符号解析、重定位、调整代码中的地址等
下面,我们先编译 m.c、sub.c,再用ld链接器进行链接
其中, 编译源码到目标文件时,若没有加“-fno-stack-protector”,默认会调用函数“__stack_chk_fail”进行栈相关检查,且若是手动ld去链接,没有链接“__stack_chk_fail”所在库文件,链接时必然会报此项错误,因此在编译时加上“-fno-stack-protector”,强制gcc不做栈检查。-e main表示将main函数作为程序入口,ld默认入口为_start;-o result表示输出文件名,默认为a.out
在用objdump查看链接前后地址的分配情况
m
sub
result
其中,VMA(virtual memory address)是虚拟地址,LMA(load memory address)是加载地址;大部分情况两个值相同,但在某些嵌入式系统中可能不同。可以看到,链接前目标文件中的所有VMA都为0,因此此时虚拟空间还没有被分配,等到链接后,各个段都会被分配相应的虚拟地址;并且可以看到生成的可执行代码result中的".text"Size大小是目标文件m.c和sub.c的".text"之和。聪明的你也许发现了,可执行文件中".text"段被分配到了0x4000e8,"data"段被分配到了0x400160,为什么虚拟地址不是从0开始呢?这与操作系统的进程虚拟地址空间分配规则有关,要将可执行文件中的".text"和".data"段加载到一个新创建的进程中,Linux加载器会分配虚拟页的一个连续的片(chunk),对于32位系统来说,地址是从0x08048000处开始的;对于64位系统来说,地址是从0x400000处开始的。然后把这些虚拟页标记为无效的,也就是未被缓存的,将页表条目指向目标文件中适当的位置(会详细的内容会在以后的装载部分讨论)
确定符号地址 当完成前面段的虚拟地址确定后,链接器就会开始计算各个符号的虚拟地址,因为符号在段内的相对位置是固定的,不会发生变化,因此我们只需给符号加上基础地址,也就是它所处的段的虚拟地址
符号解析和重定位
空间和地址分配完成后,链接器将进行符号解析和重定位,在进入这个主题前,我们先来看看链接前对两个外部符号进行了什么操作,也就是说编译m.c时,如何访问外部符号
我们用objdump -d m.o查看反汇编结果
我们知道程序里代码都用的虚拟地址,在未进行空间分配前,目标文件代码段的起始地址为0x00000000.最左边那一列表示每条指令的偏移量,每一行表示一条指令,其中1d那一条指令表示引用"swap"的位置,第一个字节表示操作码,后四节表示被调用函数相对于调用指令的下一条指令的偏移量,相对偏移量也就是0,所以这条callq指令的实际调用地址时mov地址 + 0,也就是0x22;而16那一条指令lea表示对shared的引用,将shared地址赋值给rdi寄存器,前三个字节为指令码,后四个字节时shared地址;其实两个对象都是临时地址,在编译时并不知道外部符号的真正地址,等到链接时将真正的地址计算工作交给链接器
通过先前的学习,我们知道地址和空间分配后就可确定所有符号的虚拟地址,随后链接器就可根据符号的地址对每个需要重定位的指令进行地址修正
重定位 链接器如何知道哪些指令是要被调整的呢?指令的哪些部分需要调整?如何调整?事实上是由重定位表告诉他,重定位表保存了重定位相关的信息 ,描述如何修改相应的段的内容,每个要被重定位的段都有一个对应的重定位表
通过objdump -r m.o查看目标文件的重定位表
每个需要被重定位的地方叫做重定位入口,这里有四个;重定位入口的偏移量表示该入口在要被重定位的段中的位置
重定位表的结构
typedef struct
{
Elf32_Addr r_offset; /* Address 重定位入口的偏移.对于可重定位文件来说,此值表示该重定位入口所要修正的位置的第一个字节相对于段起始的偏移量;对于可执行文件或共享对象文件来说,此值表示该重定位入口所要修正的位置的第一个字节的虚拟地址 */
Elf32_Word r_info; /* Relocation type and symbol index 重定位入口的类型和符号.低八位表示重定位入口的类型,高24位标识重定位入口的符号在符号表中的下标.各处理器的指令格式不同,因此重定位所修正的指令地址格式也不一样*/
} Elf32_Rel;
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
} Elf64_Rel;
符号解析 重定位时,每个重定位入口都是对一个符号的引用,那么当链接器要对某符号的引用进行重定位时,他需要确定这个符号的地址,因此链接器就去全局符号表查找相应的符号,再进行重定位
readelf -s m.o 查看符号表
意料之中,我们可以看到shared和swap为UND类型,也就是未定义类型.在链接器扫描完所有目标文件后,必须能从全局符号表中查找到UND类型的符号,否则会报符号未定义的错误
静态库链接
我们知道,在我们平常使用的编程语言中有输入输出方式,这些输入输出肯定调用了系统提供的API,否则我们不可能仅凭一行代码就能实现输入输出,而这些语言都会将API包装成一个语言库。例如,c++的cout需要调用<iostream>语言库。当然,语言库中有些函数并不会调用操作系统的API,如strlen()
在一个语言如c++的运行库中,包含许多和系统功能有关的代码,如输入输出、时间等,这其中包含了许许多多的目标文件,若是将这些文件直接给程序员使用,那恐怕程序员得秃顶了;因此会将这些目标文件打包压缩在一起,且标注编号和索引,便于查找和检索,这也就是我们常说的静态库
根据上面的内容,我们可以定义静态库就是一组目标文件的集合,很多目标文件经过压缩打包后形成的一个文件
那静态库链接是什么呢?显而易见答案已经呼之欲出了,将我们写的源文件编译成目标文件和需要的静态库里的目标文件链接;当然很有可能,静态库里的目标文件里的符号也依赖于其他目标文件,相当于会递归很多很多次;很显然,我们链接时不可能将一个完整的目标文件全部包含,由于之前说的会递归很多次,这样会造成运行库中有许多许多的符号,会造成极大的空间浪费,因此只会包含目标文件中需要的符号
BFD库
现在的硬件和软件平台种类五花八门,这导致了不同平台都有它自己独特的目标文件格式,这些差异导致编译器和链接器很难处理不同平台间的目标文件,因此对于这种问题,我们需要一种统一的接口处理不同平台格式间的差异。BFD库就做到了这一点
BFD库将目标文件抽象为一个统一的模型,这个模型会抽象目标文件的文件头、段、符号表等等,这样使得BFD库的程序只需使用这个抽象的模型就可以操作所有BFD支持的目标文件格式
GCC这种可跨平台的工具就是使用BFD库处理目标文件,而不是直接使用目标文件。好处是编译器和链接器处理的目标文件分隔开来,一旦需要支持一种新的目标文件格式,只须在BFD库中添加对应的格式即可,无需修改编译器和链接器
总结
在本章中,我们讲到编译器和链接器为我们服务时究竟做了哪些幕后工作。我们了解到从源文件到最终可执行文件的预编译、编译、汇编、链接整个过程是怎样的,分析每个步骤的作用和前后间的关系;我们还深入分析了各种目标文件格式及其包含的内容,主要介绍有代码段、数据段、BSS段、文件头、段表、重定位表、字符串表、符号表等,发现原来可执行文件、目标文件、库也不过如此,都是基于段的文件的集合;最后我们介绍了静态链接这类型的奥秘,目标文件在呗链接成可执行文件时,目标文件中的段是如何合并的,链接器是如何为他们分配地址和空间的,最终地址确定后,链接器会将各个目标文件中对外部符号的引用进行解析,将段中重定位指令和数据进行指正,使他们指向正确的位置
请慢慢地咀嚼消化这些知识,接下来我们将进入装载的世界,学习关于可执行文件装载到内存的知识以及这一过程究竟是怎样的,它的本质是什么