目录
1、编译过程简介
从源代码从可执行文件的生成过程
编译器的组成
预处理器进行预编译生成预处理文件
-处理所有的注释,以空格代替
-将所有的#define删除,并且展开所有的宏定义
-处理条件编译指令#if, #ifdef, #elif, #else, #endif
-处理#include, 展开被包含的文件
-保留编译器需要使用的#pragma指令
-添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号
-预处理指令示例: gcc -E file.c -o file.i
// a.h
const char* p = "wss";
int i = 0;
// test.c
#include "a.h"
#define GREETING "Hello world!"
#define INC(x) x++
int main()
{
p = GREETING;
INC(i);
return 0;
}
编译器的编译
-对预处理文件进行词法分析,语法分析和语义分析
词法分析:分析关键字,标示符,立即数等是否合法
语法分析:分析表达式是否遵循语法规则
语义分析:在语法分析的基础上进—步分析表达式是否合法
-分析结束后进行代码优化生成相应的汇编代码文件
-编译指令示例: gcc -S file.i -o file.s
对于C语言预编译和编译的程序是cc1,对于C++来说,是cc1plus;所以gcc这个命令只是大量后台程序的包装,它会根据不同的参数要求去调用预编译编译程序cc1、汇编器as、链接器ld
汇编器的汇编
-汇编器将汇编代码转变为机器的可以执行指令 ,每条汇编语句几乎都对应—条机器指令
-汇编指令示例: gcc -c file.s -o file.o
链接器的链接
-目标文件:各个段没有具体的起始地址,只有段大小信息,各个标识符没有实际地址,只有段中的相对地址,段和标识符的实际地址需要链接器具体确定
-链接器的主要作用是把各个模块之间相互引用(全局符号的互相引用,链接器往往会忽视局部符号)的部分处理好, 使得各个模块之间能够正确的衔接。
2、编译原理简介
编译过程一般可以分为6步:扫描(词法分析)、语法分析、语义分析、源代码优化、代码生成和目标代码优化
词法分析
-首先源代码程序被输入到扫描器(Scanner),扫描器简单地进行词法分析,将源代码的字符序列分割成一系列的记号(Token)
-记号类型:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号)
-扫描器也将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用
-lex程序可以按照用户之前描述好的词法规则可以实现词法扫描
-示例代码:array[index] = (index + 4) * (2 + 6)
语法分析
-语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)→ 以表达式为节点的树
-例如:array[index] = (index + 4) * (2 + 6) 是由 赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句
-yacc程序可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建出一棵语法树
语义分析
-语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的
-经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点
中间语言生成
-源代码级优化器会在源代码级别进行优化,在上例中,(2 + 6)这个表达式可以被优化掉,因为它的值在编译期就可以被确定
在语法树上作优化比较困难,源代码优化器往往将整个语法树转换成中间代码 如使用三地址码描述
三地址码形式:x = y op z
array[index] = (index + 4) * (2 + 6)
t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
array[index] = t3
在三地址码的基础上进行优化时,优化程序会将2+6的结果计算出来,得到t1 = 8。然后将后面代码中的t1替换成数字8。还可以省去一个临时变量t3,因为t2可以重复利用。经过优化以后的代码如下:
t2 = index + 4
t2 = t2 * 8
array[index] = t2
目标代码生成与优化
-代码生成器将中间代码转换成目标机器代码
-目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等
3、ELF文件结构简述
链接视图与执行视图
-ELF文件标准里面把系统中采用ELF格式的文件归为4类
/* Legal values for e_type (object file type). */
#define ET_REL 1 /* 可重定位文件(Relocatable file) 例如:Linux下的.o文件 .a静态库 */
#define ET_EXEC 2 /* 可执行文件(Executable file) */
#define ET_DYN 3 /* 共享目标文件(Shared object file) 例如:.so共享库 */
#define ET_CORE 4 /* 核心转储文件(Core file) 例如:Linux下的core dump */
// ...
-ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Sections)和 节头表(Section header table)
-程序头表是程序头的结构体数组,程序头描述了可执行文件中Segment的类型,偏移位置,文件中大小,加载到内存大小,加载地址等各种属性信息
-用来构造进程映像的可执行文件和共享库需要装载,所以必须有程序头表,而重定位文件不需要装载,所以不需要这个表
-于是就有了链接视图(Linking View)和 执行视图(Execution View)
◆ 链接视图:从链接的角度ELF文件是按Section存储的
◆ 执行视图:从装载的角度ELF文件是按Segment划分的(后文中的“.text”会随意称呼为代码段或代码节,不区分Section和Segment)
-目标文件链接时会合并相似段(例如可读可执行的节都放在一起作代码段,可读可写的节都放在一起作数据段), 一个“Segment”包含一个或多个属性类似的“Section”
-使用 readelf 可以显示关于 ELF 格式文件内容的信息。(使用readelf -H 查看各参数选项说明)
段加载到内存后大小比在文件中大,多余的填充0(.bss 合并导致的结果?)
ELF header
-ELF header实际是一个Elf32_Ehdr结构体
/* The ELF file header. This appears at the start of every ELF file. */
#define EI_NIDENT (16)
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;
Section header table 在文件中的偏移为0x2b0(688B),有13个Section header,每个Section header占40字节
Section header
-Section header table是一个Section header 结构体数组
-每一个Section都对应一个Section header,Section header不一定在文件中有对应的Section(例如:.bss节不占用文件空间,占内存空间)
-Section header描述了Section的名字、类型,执行时的虚拟地址,文件中的偏移、大小、读写权限及其他属性
-查看所有的Section header
-Section类型:
-字符串表:用来保存普通的字符串,比如符号的名字,节名一般为“.strtab”;
-节表字符串表:用来保存节头表中用到的字符串(例如节名),节名一般为“.shstrtab”
-ELF header的最后一个字段记录了节表字符串表在节头表中的索引
4、符号与符号表
符号
-在链接中,将函数和变量统称为符号(Symbol),函数名或变量名就是符号名
-每一个目标文件都会有一个相应的符号表(Symbol Table),表里记录了目标文件中所用到的所有符号
-每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址
符号分类
-定义在本目标文件的全局符号,可以被其他目标文件引用。如:全局变量,函数
-在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号。如:printf
-局部符号,这类符号只在编译单元内部可见,对于其他目标文件来说是“不可见”的。如:静态局部变量,静态函数
-段名,这种符号往往由编译器产生,它的值就是该段的起始地址。如:“.text”、“.data”等
ELF符号表结构
-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; // 该成员目前为0,没用
Elf32_Half st_shndx; // 符号所在的段
} Elf32_Sym;
符号类型和绑定信息(st_info)
-该成员低4位表示符号的类型,高28位表示符号绑定信息
符号所在段(st_shndx)
-如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标;
-如果符号不是定义在本目标文件中,或者对于有些特殊符号,st_shndx为特殊值,下面为它们的宏定义
★ SHN_ ABS : 表示该符号包含了一个绝对的值。如: 文件名的符号
★ SHN_COMMON : 表示该符号是一个"COMMON块"类型的符号,一般来说, 未初始化的全局符号就是这种类型的,
★ SHN_UNDEF : 表示该符号未定义。这个符号表示该符号在本目标文件被引用到,但是定义在其他目标文件中
符号值(st_value)
-st_shndx不为SHN_COMMON时,符号所对应的函数或变量位于由st_shndx指定的段,偏移st_value的位置
-st_shndx为SHN_COMMON时,st_value表示该符号的对齐属性
-st_value表示符号的虚拟地址(可执行文件)
特殊符号
-ld链接器的链接脚本中定义了一些特殊符号,可以在代码中直接声明使用它们
#include <stdio.h>
extern char __executable_start[]; // 该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址
extern char etext[], _etext[], __etext[]; // 该符号为代码段结束地址,即代码段最末尾的地址
extern char edata[], _edata[]; // 该符号为数据段结束地址,即数据段最末尾的地址
extern char end[], _end[]; // 该符号为程序结束地址
int main()
{
printf("Executable Start %X\n", __executable_start);
printf("Text End %X %X %X\n", etext, _etext, __etext);
printf("Data End %X %X\n", edata, _edata);
printf("Executable End %X %X\n", end, _end);
return 0;
}
符号修饰
int func(int)
{
static int static_i;
return 0;
}
float func(float) { return 0; }
class A
{
public:
int func(int) { return 0; }
class B
{
public:
int func(int) { return 0; }
};
};
namespace MY
{
int func(int) { return 0; }
class C
{
public:
int func(int) { return 0; }
};
}
int main()
{
static int static_i;
A a;
a.func(1);
A::B b;
b.func(1);
MY::C c;
c.func(1);
return 0;
}
GCC的基本C++名称修饰方法如下:
-所有的符号都以“_Z”开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟“N”,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以“E”结尾。
-对于一个函数来说,它的参数列表紧跟在“E”后面,对于int类型来说,就是字母“i”。
-名称修饰机制也被用来防止静态变量的名字冲突
不同的编译器厂商的名称修饰方法可能不同。
例如:VC++编译器修饰后名称如下
弱符号与强符号
重定义错误:目标文件A和B都定义了全局变量global并初始化,链接时就会出现符号重复定义错误
对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号,弱符号重复定义不会出现重复定义错误
可以通过GCC的“__attribute__((weak))”来定义任何一个强符号为弱符号
extern int ext; // 针对外部符号的引用,非强非弱
int weak; // 弱符号
int strong = 1; // 强符号
__attribute__((weak)) weak2 = 2; // 弱符号 , __attribute__((weak))只要在分号之前即可
int main() // 强符号
{
return 0;
}
链接器处理全局符号的规则:
-强符号被多次定义将报符号重复定义错误
-如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号
-如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个
弱引用和强引用
-链接时如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用。若不报错,这种被称为弱引用
-弱引用和弱符号主要用于库的链接过程,比如库中定义的弱符号可以被用户定义的强符号所覆盖
-在GCC中,使用“__attribute__((weakref))”这个扩展关键字来声明对一个外部函数的引用为弱引用
__attribute__ ((weakref)) void foo();
int main()
{
if(foo) foo();
}
通过弱符号的方法来判断当前的程序是链接到了单线程的Glibc库还是多线程的Glibc库(是否在编译时有-lpthread选项),从而执行单线程版本的程序或多线程版本的程序
#include <stdio.h>
#include <pthread.h>
__attribute__((weak))
extern int pthread_create(pthread_t*, const pthread_attr_t*, void* (*)(void*), void*) ;
int main()
{
if(pthread_create) {
printf("This is multi-thread version!\n");
// run the multi-thread version
// main_multi_thread()
} else {
printf("This is single-thread version!\n");
// run the single-thread version
// main_single_thread()
}
}
5、静态链接与链接时重定位
静态链接 :由链接器在链接时将库的内容直接加入到可执行程序中
Linux下静态库的创建和使用
-编译静态库源码: gcc -c lib.c -o lib.o
-生成静态库文件: ar -q lib.a lib.o (ar是gnu归档工具, 常用q选项,rcs选项)
-使用静态库编译: gcc main.c lib.a -o mian.out
//slib.c
char* name()
{
return "Static Lib";
}
int add(int a, int b)
{
return a + b;
}
//20-1.c
#include <stdio.h>
extern char* name();
extern int add(int a, int b);
int main()
{
printf("Name: %s\n", name());
printf("Result: %d\n", add(2, 3));
return 0;
}
静态链接器的工作:将目标文件和库文件整合为最终的可执行程序
链接通常分为两步:
-空间与地址分配 :合并各个目标文件中的相似段(.text,.data,.bss),它们的符号表统一放到一个全局符号表
-符号解析与重定位: 确定各个段和段中标识符的最终地址(重定位)
//func.c
int* g_pointer; // 弱符号,COMMON类型, 链接后才确定具体段
void func()
{
g_pointer = (int*)"DT";
return;
}
//test.c
#include <stdio.h>
int g_global = 0; // bss段, 偏移0处
int g_var1 = 10; // data段,偏移0处
int g_var2 = 20; // data段,偏移4处
int g_var3 = 30; // data段,偏移8处
int g_u1; // 弱符号或公共符号, Common类型,链接后才确定具体段
int g_u2;
int g_u3;
extern int* g_pointer; // 未定义标识符
extern void func(); // 未定义标识符
void print(){} // 代码段
int main(int argc, char *argv[]) // 代码段
{
int a = 0x10; // 一条指令,将0x10放到栈内
int b = 0x20;
static int s = 0xAABB;
g_global = 0x1234; // 编译本模块时g_global的地址用 0 代替, 需要重定位
g_var1 = 0x1234; // 编译本模块时g_var1的地址用 0 代替, 需要重定位
g_var2 = 0x5678; // 编译本模块时g_var2的地址用 0 代替, 需要重定位
print(); // 编译本模块时print的地址用 -4 代替, 需要重定位
printf("%p\n", &g_global); // 编译本模块时printf的地址用 -4 代替, 需要重定位
func(); // 编译本模块时func的地址用 -4 代替, 需要重定位
return 0;
}
先单独编译各个模块
可以看到编译各个模块文件时,对于全局符号(包括外部符号)的引用其地址值都使用数字(0、 -4)代替,所谓的重定位就是校正这些值
因此编译器同时生成重定位表,用于记录这些对全局符号的引用的位置(如: 0x44),以备链接时能找到这些位置进行重定位操作
每个需要被重定位的节(Section) 都有一个对应的重定位表,而一个重定位表也是一个Section。(比如“.text”有要被重定位的地方,那么会有一个相对应叫“.rel.text”的节保存了代码节的重定位表)
每个重定位表由多个重定位表项组成,每个重定位表项都对应对一个符号的引用,描述了需要重定位地址位置等的信息
定位表项结构如下:
/* Relocation table entry without addend (in section of type SHT_REL). */
typedef struct
{
Elf32_Addr r_offset; /* Address(需要修正地址的偏移地址) */
Elf32_Word r_info; /* Relocation type and symbol index(低8位表示重定位表项的类型,高24位表示重定位表项的符号在符号表中的下标,如下图的第一项的0xa) */
} Elf32_Rel;
有了重定位表,当链接器须要对某个符号的引用进行重定位时,链接器会查找全局符号表,找到相应的符号后进行重定位
例如:
g_global链接后虚拟地址是0x804a030, 需要修正位置的值是0, 重定位后校正值就是 S + A = 0x804a030 + 0 = 0x804a030
func函数链接后虚拟地址是0x804840b, 需要修正位置的值是-4, 被修正的位置是(main + 0x5e), 重定位后校正值就是 S + A - P = 0x804840b - 4 - (0x5e + 0x804841b) = 0xffff ff8e
再链接
相对地址调用:call函数调用的返回地址(即下一条指令的地址)+ 偏移
例如:0x8048463 - 0x48 = 0x804841b,0x804847d - 0x72 = 0x804840b。不论是print函数还是func在链接时都找到了定义,不论程序加载到哪里,相对位置不会发生改变,call就不会出错
上图发现符号表中的printf依然处于未定义状态,说明还没有完成链接,call 80481e0 <printf@plt> 又是神马鬼?,只能接着分析了
6、动态链接
6.1 动态链接的基本用法
-可执行程序在运行时才动态加载库进行链接 ,库的内容不会进入可执行程序当中
-在Linux中,常用的C语言库的运行库glibc,它的动态链接形式的版本保存在 /lib目录下,文件名叫做 libc.so
-当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作
-动态链接器与普通共享对象一样被映射到了进程的地址空间,执行可执行文件前,会控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给可执行程序
-在Linux下,动态链接器实际上是一个共享对象(ld.so),操作系统通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
-“.interp”段保存着动态链接器的路径:/lib/ld-2.6.1.so ;
-“.dynamic”段保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等
-“.dynsym”段保存了与动态链接相关的符号(动态符号表),“.symtab”中往往保存了所有符号,包括“.dynsym”中的符号
-符号字符串表“.strtab”,对应于动态符号字符串表“.dynstr”
-符号哈希表“.hash” 可以加快符号的查找过程; 动态链接相关的重定位表:.rel.dyn和“.rel.plt
Linux下动态库的创建和使用
-编译动态库源码: gcc -shared dlib.c -o dlib.so (gcc -shared -fPIC dlib.c -o dlib.so )
-使用动态库编译: gcc main.c -ldl -o main.out (当使用下面系统调用时需要加上-ldl 选项)
-关键系统调用 (依赖于libdl.so库)
dlopen : 打开动态库文件
dlsym : 查找动态库中的函数并返回调用地址 (查找符号)
dlclose: 关闭动态库文件
示例1:这种方式是动态链接器在运行时将共享模块装载进内存并且可以进行重定位等操作
//dlib.c
char* name()
{
return "Dynamic Lib";
}
int add(int a, int b)
{
return a + b;
}
//main.c
#include <stdio.h>
#include <dlfcn.h>
#include <unistd.h>
int main()
{
void* pdlib = dlopen("./dlib.so", RTLD_LAZY); //加载共享库内存,之前都未加载, 常量RTLD_LAZY表示使用延迟绑定,后续有介绍
printf("pdlib: %p\n", (void*)pdlib);
char* (*pname)();
int (*padd)(int, int);
if( pdlib != NULL ) //动态库加载成功
{
pname = dlsym(pdlib, "name"); // 到动态库查找name函数,返回函数入口地址
padd = dlsym(pdlib, "add");
if( (pname != NULL) && (padd != NULL) ) //都找到了,调用
{
printf("Name: %p\n", (void*)pname);
printf("Result: %p\n", (void*)padd);
printf("Name: %s\n", pname());
printf("Result: %d\n", padd(2, 3));
}
sleep(-1); // 停在这里方便观察分析
dlclose(pdlib); //关闭动态库
}
else
{
printf("Cannot open lib ...\n");
}
return 0;
}
示例2:这种方式会在执行可执行程序前先加载共享库,执行动态链接器代码进行链接工作,再将控制权交给可执行程序
// wlib.c
int add(int a, int b)
{
return a + b;
}
int minux(int a, int b)
{
return a - b;
}
// slib.c
void print()
{
}
// test_1.c
#include <stdio.h>
extern int add();
extern int minux();
extern void print();
int main()
{
printf("%d\n", add(1, 2));
printf("%d\n", minux(1, 2));
print();
return 0;
}
6.2 装载时重定位(或基址重置)
共享库的装载位置
-可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的装载地址,比如Linux下一般都是0x08040000,Windows下一般都是0x0040000
-然而动态库文件肯定不能指定加载到固定的地址,否则极易出现冲突。使用readelf -l 可以发现默认装载地址是0即未指定(Windows的DLL指定为0x10000000),
-需要在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象
装载时重定位
-在之前的静态链接中,链接时经过重定位就能确定已定义全局符号的虚拟地址,其中包括绝对地址的引用或相对偏移,为了能够使共享对象在任意地址装载,可以将重定位推迟到装载时,根据实际装载地址重定位
装载时重定位带来的问题
-共享库在物理内存中只存在一份,被映射到到不同的进程虚拟地址空间,所以映射的基地址可能不同
-动态链接模块被装载映射到虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位需要修改指令,所以没办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的,
-当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本(copy on write ???),所以它们可以采用装载时重定位的方法来解决
缺点:指令部分无法在多个进程之间共享
需求:希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变
解决办法:把指令中需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。即生成地址无关代码(gcc 加上-fPIC选项)
6.3 地址无关代码(PIC)
根据模块中各种类型的地址引用方式。把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样就得到了4种情况。
① 模块内部调用或跳转
-调用者与被调者都是位于同一个模块,所以调用者与被调者之间的相对位置是固定的,因此,采用 相对地址调用代替绝对地址,对于这种指令是不需要装载时重定位的(前面的静态链接的结尾已说明原因)
② 模块内部数据访问
-在之前的静态链接示例中,链接后访问全局变量都是通过绝对地址,访问函数通过相对偏移地址,要产生地址无关代码,指令中肯定不能包含绝对地址
-任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了(PIC的核心原理,看下面的紫色图就理解了)
-例如下面示例中变量a的真实地址计算过程
③ 模块间数据访问
-模块间的数据访问,目标地址要等到装载时才决定
-在数据段建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用
-例如下面示例中变量b的访问过程
④ 模块间调用、跳转
-与模块间的数据访问一样,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转
地址无关代码技术除了可以用在共享对象上面,它也可以用于可执行文件,一个以地址无关方式编译的可执行文件被称作地址无关可执行文件(PIE)(编译选项-fPIE)
共享模块的全局变量问题
当一个模块引用了一个定义在共享对象的全局变量的时
// test.c
extern int g_global; // from wlib.c
void func()
{
g_global = 0; // 一个模块引用了一个定义在共享对象的全局变量
}
// 1. 若这个模块是程序主模块(可执行文件),程序主模块的代码并不是地址无关代码,
// 所以编译生成目标文件时用0或-4代替地址, 在静态链接就要确认变量的地址,链接器会在可执行文件中的.bss段创建这个全局变量的副本,所有的使用这个变量的指令都指向这个副本
// 2. 若这个模块是共享库模块,编译时无法判断它定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中。
// GCC编译器在-fPIC的情况下,就会把对这个全局变量的调用按照跨模块模式产生代码。因为这个全局变量可能被可执行文件引用,最终指向副本
// wlib.c -> wlib.so
int g_global = 10;
// ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量(即模块间数据访问,通过GOT来实现变量的访问)
// 当共享模块被装载时,如果这个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。
// 如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;
// 如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本
如果一个共享对象lib.so中定义了一个全局变量G,而进程A和进程B都使用了lib.so,那么当进程A改变这个全局变量G的值时,进程B中的G不会受到影响。因为当lib.so被两个进程加载时,它的数据段部分在每个进程中都有独立的副本
对于同一个进程的两个线程来说,它们访问的是同一个进程地址空间,也就是同一个lib.so的副本,所以它们对G的修改,对方都是看得到的。
多个进程可以共享同一个全局变量就可以用来实现进程间通信;而多个线程访问全局变量的不同副本可以防止不同线程之间对全局变量的干扰,比如C语言运行库的erron全局变量。
多进程共享全局变量又被叫做“共享数据段”
多个线程访问不同的全局变量副本又被叫做“线程私有存储”(Thread Local Storage)
数据段地址无关性
共享模块的数据段也可能存在绝对地址的引用,如 static int a; static int* p = &a; 指针p的地址就是一个绝对地址,它指向变量a,而变量a的地址会随着共享对象的装载地址改变而改变
如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含“R_386_RELATIVE”类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位
6.4 延迟绑定(PLT)
动态链接比静态链接慢的主要原因
-动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转
-程序开始执行时,动态链接器都要进行一次链接工作,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度
在动态链接下,程序模块之间包含了大量的函数引用,所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位
在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,
基本的思想就是当函数第一次被用到时才由动态链接器进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序
当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项,比如bar()函数在PLT中的项的地址我们称之为bar@plt。
ELF将GOT拆分成了两个表叫做“.got”和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址
bar@plt的基本实现原理:
bar@plt:
jmp *(bar@GOT) ; 如果在GOT 中找到bar()的地址,直接实现函数正确调用
push n ; bar这个符号引用在重定位表“.rel.plt”中的下标
push moduleID ; 模块的ID(_dl_runtime_resolve 需要知道哪个模块,哪个函数)
jump _dl_runtime_resolve ; 调用动态链接器的_dl_runtime_resolve()函数完成符号解析和重定位工作。_dl_runtime_resolve()在进行一系列工作以后将bar()的真正地址填入到bar@GOT中。
; 实际的PLT代码比这复杂,它们存放在一个段名叫做“.plt”的段,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的“Segment”被装载入内存
7、程序启动过程的函数调用
默认情况下(gcc)
-程序加载后,_start() 是第一个被调用执行的函数,_start() 函数准备好参数后立即调用 _libc_start_main() 函数,_libc_start_main()初始化运行环境后调用 main() 函数执行
-代码段的起始地址就是_start()函数的入口地址,执行程序时将PC指针指向代码段的起始地址
分析program.txt文件
_libc_start_main()函数的作用
-调用_libc_csu_init()函数(完成必要的初始化操作),启动程序的第一个线程(主线程),main()为线程入口
-注册_libc_csu_fini()函数(程序运行终止时被调用)
程序的启动过程
自定义程序入口函数
-gcc提供 -e 选项用于在链接时指定入口函数(ld的-e选项),自定义入口函数时必须使用 -nostartfiles 选项进行链接
ld链接器根据什么原则完成具体的工作?
8、链接脚本
链接脚本的概念和意义
-链接脚本用于描述链接器处理目标文件和库文件的方式
★ 合并各个目标文件中的段,重定位各个段的起始地址, 重定位各个符号的最终地址
//test.c
#include <stdio.h>
int s1; // s1 编译时属于公共符号还没确定放入哪个段
extern int s2; // s2 是在外部定义的,即未定义标识符
int main()
{
printf("&s1 = %p\n", &s1);
printf("&s2 = %p\n", &s2);
return 0;
}
test.lds 在链接脚本里定义s2
SECTIONS /* 关键字,描述各个段在内存中的布局 */
{
/* 各个段的链接地址必须符合具体平台的规范,在Linux中,进程代码段( .text )的合法起始地址为[0x08048000,0x08049000] */
.text 0x08048400: /* 代码段的起始地址 */
{
*(.text) /* 所有目标文件中的代码段合并进入可执行文件 */
}
. = 0x01000000; /* 当前地址 */
s1 = .; /* 链接脚本中能够指定源代码中标识符的存储地址 */
. += 4;
s2 = .; /* 链接脚本中能够直接定义标识符并指定存储地址 */
.data 0x0804a800:
{
*(.data)
}
.bss :
{
*(.bss)
}
}
默认情况下:链接器认为程序应该加载进入同一个存储空间
嵌入式系统中:如果存在多个存储空间,必须使用MEMORY进行存储区域定义
MEMORY 命令的使用
ENTRY 命令指定入口
ENTRY(program) /* program变成代码段第一个函数 */
SECTIONS
{
.text 0x08048400:
{
*(.text)
}
}
默认情况下gcc提供一个链接脚本,指定了使用_start函数作为应用程序入口函数
实验(模拟嵌入式开发)
-编写一个“体积受限”的可执行程序,通过makefile 完成代码编译,运行后在屏幕上打印"D.T.Software"
解决方案设计
-通过内嵌汇编自定义打印函数和退出函数(INT 80H)
-通过链接脚本自定义入口函数(不依赖任何库和GCC内置功能)
-删除可执行程序中的无用信息(无用段信息,调试信息,等)
关键命令
-ld命令:GNU的链接器,将目标文件链接为可执行程序
-ld -static:-static表示Id使用静态链接的方式来产生最终程序,而不是默认的动态链接方式
-gcc -fno -builtin:-fno-builtin参数用于关闭GCC内置函数的功能(GCC提供了很多内置函数,它会把一些常用的C库函数替换成编译器的内置函数,以达到优化的目的)
program.c
void print(const char* s, int l);
void exit(int code);
void program()
{
print("D.T.Software\n", 13);
exit(0);
}
// 打印函数 sys_write
void print(const char* s, int l)
{
asm volatile (
"movl $4, %%eax\n"
"movl $1, %%ebx\n"
"movl %0, %%ecx\n"
"movl %1, %%edx\n"
"int $0x80 \n"
:
: "r"(s), "r"(l)
: "eax", "ebx", "ecx", "edx"
);
}
// 退出函数 sys_exit
void exit(int code)
{
asm volatile (
"movl $1, %%eax\n"
"movl %0, %%ebx\n"
"int $0x80 \n"
:
: "r"(code)
: "eax", "ebx"
);
}
program.lds
ENTRY(program)
SECTIONS
{
.text 0x08048000 + SIZEOF_HEADERS :
{
*(.text)
*(.rodata)
}
/DISCARD/ :
{
*(*) /*放弃其它段*/
}
}
makefile
CC := gcc
LD := ld
RM := rm -fr
TARGET := program.out
SRC := $(TARGET:.out=.c)
OBJ := $(TARGET:.out=.o)
LDS := $(TARGET:.out=.lds)
.PHONY : rebuild clean all
$(TARGET) : $(OBJ) $(LDS)
$(LD) -static -T $(LDS) -o $@ $<
@echo "Target File ==> $@"
$(OBJ) : $(SRC)
$(CC) -fno-builtin -o $@ -c $^
rebuild : clean all
all : $(TARGET)
clean :
$(RM) $(TARGET) $(OBJ)
更多细节内容查阅《程序员的自我修养》