《程序员的自我修养笔记之静态连接》

若是想完全弄懂Android代码保护的基本原理,《Unix环境高级编程》和《程序员的自我修养》是必读书目。在此做读书笔记git

第二章

程序源代码到最终可执行文件的4个步骤:程序员

  • 预编译

主要处理那些源代码文件中以"#"开始的预编译指令github

gcc -E hello.c -o hello.i
复制代码
  • 编译

对预编译生成的文件进行词法分析,语法分析,语义分析,中间语言生成,目标代码生成及优化生成汇编代码文件编程

gcc -S hello.i -o hello.s
复制代码
  • 汇编

汇编器将汇编代码转换成可执行指令,输出目标文件bash

as hello.s -o hello.o`或者`gcc -c hello.s -o hello.o`或者`gcc -c hello.c -o hello.o
复制代码
  • 连接
ld -static crt1.o crti.o crtbeginT.o -start-gruoup -lgcc -lgcc_eh -lc-end-group crtend.o crtn.o
复制代码

这里省略了各文件的路径函数

连接过程主要有以下步骤:工具

  • 地址和空间分配
  • 符号决议
  • 重定位

第三章

目标文件格式

  • 可重定位文件(Relocatable File)
  • 可执行文件(Executable File)
  • 共享目标文件(Shared Object File)
  • 核心转储文件(Core Dump File)

在Linux下可以使用file命令显示文件格式优化

目标文件与程序之间的关系

SimpleSection.c代码以下:ui

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;
}
复制代码

1545926648771

  • 程序源代码编译后的机器指令被放在代码段(.code或者.text)里面
  • 全局变量和局部静态变量放在数据段(.data)
  • 未初始化全局变量和未初始化局部静态变量,或者有些编译器也会将初始化为0的变量也放置在.bss段

查看目标文件内部的结构可使用objdump工具,可看到对应的各个段大体结构(-h),各个段详细内容(-x),代码段内容(翻译成了汇编语言)(-s -d)spa

其余段内容

1545927294296

  • 将某个二进制文件做为目标文件的一个段

    objcopy -I binary -o elf32-i386 -B i386 image.jpg image.o

  • 将某个变量放在特定段

    __attribute__((section("FOO"))) int global = 42

    __attribute__((section("BAR"))) void foo()

第四章静态连接

空间与地址分配

  • 类似段合并

    • 空间与地址分配

      扫描全部输入目标文件,并得到他们各个段的长度、属性和位置,并将输入目标文件中的符号表全部的符号定义和符号引用收集起来,放到全局符号表中。因而,连接器将获取全部输入目标文件的段长度,并将他们合并,计算输出文件中各个段合并后的长度和位置,创建映射关系。

    • 符号解析与重定位

      利用上一步的信息进行段的数据,读取段数据,重定位信息,进行符号解析与重定位、调整代码中的地址等。

    以下图:

    1546439014195

编写程序:

/*a.c*/

extern int shared;

int main(){
	int a = 100;
	swap(&a, &shared);
	return 0;
}
/* b.c */
int shared = 1;

void swap(int *a, int *b){
	*a ^= *b  ^= *a ^=*b;
}
复制代码
  • objdump -h a.o

    1546439221303

  • objdump -h b.o

    1546439234732

  • 连接两个文件ld a.o b.o -e main -o ab -lc

    在个人机器上面须要加上-lc参数才能连接成功,否则报a.c:(.text+0x4b): undefined reference to `__stack_chk_fail错误,具体缘由不明,

    -c: 从指定的命令文件读取命令

    -l: 把指定的存档文件添加到要链接的文件清单

    获得的可执行文件不仅是简单的连接过程,跟书中的内容有差别,有大神知道麻烦赐教

  • objdump -h ab

    1546439338068

    能够看出,合并后获得的ab文件的.text段和.data段的长度分别是9c和4,正好等于两个.o文件相应段的长度之和。

符号解析与重定位

重定位

重定位是静态连接的核心内容,首先看a.o里面是如何访问调用外部符号(shared变量和swap函数)

  • 使用objdump -d 命令查看a.o反编译代码

    1548947287398

  • objdump -d ab

    1548951798049

其中main起始地址为0x0,共占用0x50个字节,最左边那列表明偏移量偏移量为22和31的地方即是分别引用sharead变量和swap函数的位置。

  • a.o中引用shared代码为lea 0x0(%rip),%rsi,是将rip寄存器的值+0直接传递给rsi寄存器,这是由于还没法查找符号shared的位置,使用0x0代替,后面连接完成以后,ab文件就将0x0替换为0x200d47,加上rsi寄存器的值,计算后也就是shared的地址0x0601020,可以使用objdump -s abdata段内看到该变量的值。

  • 引用swap函数的代码为callq 36 <main+0x36>,既下一条指令的地址,ab文件则会直接将swap地址0x400301填入,变成callq 400301 <swap>

  • 可是第二次试验,是在公司电脑,我获得的是以下结果

    1549882203163

    1549882230042

    也就是说,没有相对寻址了,这让我有点纳闷,swap函数地址也是,不一样于家里电脑生成的指令。

总之,就是当文件并无连接以前,遇到了不认得的符号时,编译器把地址0x0和下一条指令的地址做为代替,等连接完成地址和空间的分配后,就已经能够肯定全部符号的虚拟地址了,此时连接器再将全部须要重定位的指令进行地址修复。

书中的环境及解释是这样子的:

1549942799298

1549942845177

  • 绝对寻址修正

    a.o第一个重定位入口,即偏移为18的mov指令修正,修正方式是R_386_32,即绝对地址修正。这个重定位入口,修正后应该是S+A

    • S是符号shared的实际地址,即0x3000
    • A是被修正位置的值,即0x00000000

    因此重定位入口修正后地址为:0x3000+0x00000000=0x3000,指令修正后应该是:

    1549942583409

  • 相对寻址修正

    a.o的第二个重定位入口,即偏移为0x26这条call指令的修正,修正方式为R_386_PC32,也就是相对地址修正。这个重定位入口,修正后结果应为S+A-P

    • S是swap的实际地址,即0x2000
    • A是被修正的未知的值,即e8 fc ff ff ff中操做数0xfc ff ff ff(小端:-4)
    • P为被修正的未知,当连接成可执行文件时,这个值应该是被修正位置的虚拟地址,也就是0x1000+0x27

最后重定位入口修正后地址为0x2000 + (-4) - (0x1000 + 0x27) = 0xFD5,即:

1549943316258

重定位表

重定位表专门用于保存与重定位相关的信息,它在ELF文件中每每是一个或者多个段。对于可重定位ELF文件来讲,一个重定位表每每就是ELF文件中的一个段,因此重定位表也能够叫作重定位段。好比,代码段“.text”若是有要被重定位的地方,那么就会有一个相对应的“.rel.text”的段保存代码段的重定位表,可以使用objdump来查看目标文件的重定位表:

objdump -r a.o
复制代码

1549001595525

  • 每一个要被重定位的地方叫作重定位入口,咱们能够看到”a.o“有两个重定位入口(Relocation Entry)
  • 偏移:表示该入口在段中的位置
  • RELOCATION RECORDS FOR [.text]表示这个重定位表是代码段的重定位表

重定位表的结构是一个Elf64_Rel or Elf32_Rel结构,以下

struct Elf32_Rel
{
  Elf32_Addr  r_offset;  /* Address */
  Elf32_Word  r_info;    /* Relocation type and symbol index */
};
struct Elf64_Rel
{
  Elf64_Addr  r_offset;  /* Address */
  Elf64_Xword r_info;    /* Relocation type and symbol index */
};
复制代码

1549002681392

符号解析

连接是由于咱们的目标文件中用到的符号被定义在其余目标文件当中,若是咱们直接使用ld来连接“a.o”,而不将“b.o”做为输入,则会出现sharedswap两个符号未定义的状况:

1549878517301

在开发过程当中,发生这种状况的缘由有不少,最多见的状况通常都是连接时缺乏某个库文件或者输入目标文件路径不正确或者符号的声明和定义不同。所以,从普通程序员的角度看,符号的解析占据了连接过程的主要内容

其实,重定位过程也伴随着符号的解析过程,每个目标文件均可能定义一些符号,也可能引用到定义在其余目标文件的符号。重定位过程当中,每一个重定位的入口都是对一个符号的引用,当链接器须要对某个符号的引用进行重定位时,就要肯定这个符号的目标地址。此时,连接器就会去查找全部输入目标文件的符号表组成的全局符号表,找到对应的符号后进行重定位。

好比查看“a.o”的符号表

1549879012254

其中UND表示undefined未定义类型。这种未定义的符号是由于该目标文件中有关于他们的重定位项。因此连接器扫描完全部输入目标文件以后,这些未定义的符号都应该能够在全局符号表中找到,不然就会报符号未定义错误。

指令修正方式

不一样的处理器指令对与地址的格式和方式都不同。但总的来讲寻址方式有以下几个方面:

  • 近址寻址或远址寻址

  • 绝对寻址或相对寻址

  • 寻址长度为8位、16位、32位或64位

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

  • 绝对近址32位寻址

  • 相对近址32位寻址

书中的寻址方式是这个:

1549880971583

可是在公司,我机器是64位的,寻址方式是这个:

1549881132972

也就是R_X86_64_32R_X86_64_PC32,网上也没找到对应的资料,哪位大佬若是知道恳请指导,或者在git上面提issue。

不过我研究了一下,R_X86_64_32的寻址方式,彷佛是直接将地址直接写入修复便可:

1549934038967

家里机器确定会不同的结果,所以这里留一个坑待填。

COMMON块(涉及弱符号的理解)

在C语言中,函数和初始化的全局变量(包括显示初始化为0)是强符号,未初始化的全局变量是弱符号。咱们也能够经过GCC的"__attribute__((weak))"来定义任何一个强符号为弱符号。

对于它们,下列三条规则使用:

① 同名的强符号只能有一个,不然编译器报"重复定义"错误。

② 容许一个强符号和多个弱符号,但定义会选择强符号的。

③ 当有多个弱符号相同时,连接器选择占用内存空间最大的那个。

若是一个弱符号定义在多个目标文件中,它们的类型又不一样,而连接器自己有不支持符号类型,即变量类型对于连接器来讲是透明的,此时若是类型不一致应该如何处理呢?主要分如下集中状况:

  • 两个或者两个以上强符号类型不一致
  • 一个强符号,其余都是弱符号,出现类型不一致
  • 两个或者两个以上弱符号不一致

第一种状况是无需额外处理的,多强符号定义自己便是非法,连接器将报多重定义错误,连接器要处理后两种状况。

此时,COMMOM块机制出现。以SimpleSection.c为例子,符号表以下:

1549936124323

这里能够看到符号global_uninit_varGLOBAL数据对象,大小为4,类型为COM,而实际上该变量为弱类型 int 类型变量

另外编写一个Common.c文件内容以下:

double global_uninit_var = 24;
复制代码
  • gcc -c Common.c SimpleSection.c
  • gcc -o Common Common.o SimpleSection.o
  • readelf -s Common

获得以下结果:

1549937921207

能够看到,size变成了8

但若是将SimpleSample.c里面的global_uninit_var改成double类型,把Common.c里面的global_uninit_var改成int类型,再执行可得以下警告:

1549938109105

这是由于弱符号大小大于强符号大小所致,此时结果以下,大小是4:

1549938231992

若是Common.c里面的global_uninit_var也改成弱符号,则获得的符号大小为8

1549938461555

最后.bbs段大小为8,即最终为初始化全局变量仍是放在了bbs段。

1549938857040

这个时候咱们能够得出以下结论:

  • 当强符号与弱符号同时存在时,最后获得的符号大小取决于强符号
  • 多个弱符号时,大小取决于比较大的那个
  • 最后读取完全部输入目标文件之后,弱符号最终仍是放在了BBS段

咱们能够想到,当编译器将一个编译单元编译成目标文件的时候,若是该编译单元包含了弱符号(未初始化的全局变量就是典型的弱符号),那么该弱符号最终所占空间的大小此时是未知的,由于有可能其余编译单元中同符号名称的弱符号所占的空间比本编译单元该符号所占的空间要大。因此编译器此时没法为该弱符号在BSS段分配空间,由于所须要的空间大小此时是未知的。可是连接器在连接过程当中能够肯定弱符号的大小,由于当连接器读取全部输入目标文件后,任何一个弱符号的最终大小均可以肯定了,因此它能够在最终的输出文件的BSS段为其分配空间。因此整体来看,未初始化的全局变量仍是被放在BSS段

GCC的-fno-common选项容许咱们把全部为初始化的全局变量不以COMMON块的形式处理,或者使用__attribute__扩展:int global ____attribute__((nocommon));

C++相关问题

主要有两个:

  • 重复代码消除
  • 全局构造与析构
重复代码消除

C++编译器会产生重复代码,如模板(Templates),外部内联函数(ExternInline Function)和虚函数表(Virtual Function Table)均可能在不一样的编译单元中生成相同代码。

有效作法是将每一个模板实例单独放在一个段里面,每一个段包含一个模板实例。好比add<T>(),某个编译单元以int类型和float类型实例化该模板函数,那么目标文件就包含了该模板实例的段,如.tmp.add<int>.tmp.add<float>,当其余编译单元也须要相同的方式实例化该模板函数后,也会使用相同的名字,这样在连接器最终连接的时候能够区分这些相同的模板实例段,而后将它们并入最后的代码段。

GCC把相似最终连接时合并的段叫作Link Once,将这种类型的段命名为.gnu.linkonce.name,其中name是该模板函数实例的修饰后名称。

而VISUAL C++则将该类型的段叫作“COMDAT”,连接器会根据这个标记,在链接时将重复的段丢弃。

可是,当相同名称的段可能有不一样的内容,这多是不一样编译单元使用的编译器版本或编译优化选项不一样。这时连接器颇有可能随意选择其中一个副本做为连接的输入,而后提供一个警告信息,一般状况下,这种信息是不能随意忽略的。

函数级别连接

这是VISUAL C++提供的编译选项“函数级别连接”,可将函数象上述方式那样把函数放在单独的段中,能够作到没有用到的函数则将它抛弃。

GCC 也提供相似的机制

  • -ffunction-sections:将函数分别保持到独立的段中
  • -fdata-sections:将变量分别保持到独立的段中
全局构造和析构

Linux系统下通常程序入口为_start,这个函数是Linux系统库(Glibc)的一部分。当程序和Glibc库连接到一块儿造成最终可执行文件之后,这个函数就是程序的初始化部分入口。

ELF文件定义以下两个特殊段

  • .init

    该段保存可执行指令,构成进程的初始化代码,main函数被调用前,Glibc的初始化部分安排执行这个段中的代码。

  • .fini

    该段保存着进程终止代码指令。当main函数正常退出时,Glibc会安排执行这个段中的代码。

利用这个特性,C++全局构造和析构函数便由此实现。

静态连接库

静态连接库实际上能够当作是一组目标文件的集合。使用ar压缩程序可将这些目标文件压缩在一块儿,并对其进行编号和索引,以便于查找和检索。

  • ar -t libc.a

    查看文件包含哪些目标文件

  • objdump -t libc.a grep xxx

    查找某个函数所在目标文件

  • ar -x libc.a

    解压出目标文件

连接过程能够十分复杂,以printf函数为例:

1549941968960
相关文章
相关标签/搜索