深入理解计算机基础第七章

时间:2024-03-05 07:44:37

前言

本章主要学习链接器
主要学习分离式编译、c/c++中链接导致的错误、动态库

讲的比较好的PIC实现

文件头

windows的文件头称为PE头
linux的文件头称为ELF头

.text 已编译的程序的机器代码
.rodata 只读数据
.data 已初始化的全局和静态C变量
.bss(better save space) 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。仅仅是个占位符,运行时在内存中分配这些变量,初始化为0
.symtab 一个符号表,存放程序中定义和引用的函数和全局变量的信息,每个可重定位目标都有该表,除非用STRIP指令去除
.rel.text 当链接器把该目标问价和别的可执行文件合并的时候,需要修改该表(一般来说不需要)
.rel.data 被模块引用或者定义的所有全局变量的重定位信息
.debug -g选项调用编译器驱动程序时,才能得到该表
.line 行号到.text指令的映射
.strtab 一个字符串表,包括.symtab和.debug.中的符号表,以及节头部中的节名字(C-style的字符串)
.common 没有初始化的全局变量
.und 没有定义的全局函数
und、common是伪节,仅在重定位文件中,执行文件无

链接器的主要任务

1.符号解析

强符号 : 函数,已经初始化的全局变量
弱符号 : 未初始化的全局变量

约定

1.不允许多个强符号
2.一强多弱选一强
3.多弱任选一个

当弱符号的声明不匹配时,链接器并不会报错

这很容易导致某个头文件中的变量被莫名其妙的修改了...特别是这还可以导致const变量被修改
当你和别人合作时,如果有弱符号相撞...很容易导出非常难查的错误
建议设置链接器,在遇到多重定义的弱符号时,触发错误

2.重定位

符号解析完成后(将每个引用和一个符号定义关联起来),链接器就知道输入的代码节和数据节的确切大小,然后就可以开始重定位

1.步骤一 : 重定位节和符号定义

首先将所有.o文件的相同类型的节合并成一个节
然后链接器将运行时内存地址赋给新的聚合节(中的每个节、符号)
这一步完成时,每一个全局变量和指令都有唯一的运行时地址了

2.步骤二 : 重定位节中的符号引用

修改代码节和数据节中对每个符号的引用,使之指向正确的运行时地址 (这一步依赖于重定位条目)
当汇编器遇到位置未知的引用,就会生成一个重定位条目(重定位条目放在.rel.date和.rel.text中)

重定位条目表
typedef struct
  {
    long offset;      //需要被修改的引用的节偏移
    long type : 32;   //重定位类型
    long symbol : 32; //被修改引用应该指向的符号
    long addend;      //一个有符号常数,一些重定位类型需要它对被修改引用的值做偏移调整
   } Elf64_Rela;
重定位算法

ELF中一共有32种重定位条目类型,我们只关心两种
因为内存对齐的原因,磁盘上的符号地址和运行时符号地址有出入
从磁盘文件到装载到内存,节的起始地址会变,但是节内偏移不变
用ADDR(s)代表符号s的运行时地址,假设下面算法运行时,链接器已经为每个节和符号安排了运行时地址

for (s : section)                                 //枚举每个节
  {
  	for (r : relocation_entry in s)           //枚举每个节中的重定位条目
  	  {
  	  	refptr = s + r.offset;            //需要修改的地方
  	  	if (r.type == R_X86_64_PC32)      //相对地址引用,pc + 偏移量
  	  	  {
                        //*refptr = 目标符号的运行时地址 - 引用的运行时地址 + r.addend
  	  	  	refaddr = ADDR(S) + r.offset; //这里求出了运行时的地址
  	  	  	*refptr = ADDR(r.symbol) - refaddr;
                        *refptr += r.addend;
			}
		else if (r.type == R_X86_64_32)  //绝对地址引用
		  {
		  	*refptr = ADDR(r.symbol) + r.addend;
		  }
		}
  }

如何加载可执行文件

每个Linux程序都有一个运行时内存映像:

在Linux X86-64系统中,代码总是从地址0x400000开始,后面是数据段,运行时堆在数据段之后,通过调用malloc库往上增长,堆后面的区域是为共享模块保留的。

加载器实际是如何工作的?咕咕咕

静态库

https://www.cnblogs.com/skynet/p/3372855.html
简单来说,就是提供一种打包机制,简化链接。Linux下使用ar工具、Windows下vs使用lib.exe
优点是运行时无需进一步的链接,缺点是比较浪费空间,并且在更新时要重新编译整个文件

链接器如何使用静态库解析引用

比较绕,先来捋捋概念
1.目标文件 : .o文件
2.存档文件 : 也就是静态库
3.可重定位目标文件的集合 E
4.一个未解析的符号集合 U
5.一个在前面输入文件中已定义的符号集合 D
开始时,EDU都为空

注意事项

1.可重定位文件a,b某些部分可能会相互引用,这导致了命令行中的文件名可能需要多次出现消除依赖
2.解析过程和写c/c++的直觉相反,c/c++先写头文件,再写main,链接过程是类似main.o在前,lib_std.o在后面(一般把库.o写到最后面)
3.有依赖关系的库一定要做拓扑排序,并可能多次出现以消除依赖

解析步骤
1 对于命令行上的每个文件 f ,链接器会判断 f 是一个目标文件还是存档文件
1.1 如果是目标文件

链接器将会把这个文件添加到集合E,并根据符号引用情况修改集合U和D的状态。然后处理下一个文件。

1.2 如果是存档文件

链接器将尝试匹配集合U中未解析的符号和存档文件成员定义的符号,如果存档文件的成员m定义了一个符号来解析U中的一个引用,
那么就将m加入到集合E中,然后修改U和D的状态。对存档文件中的每个成员都重复这个过程,直到U和D不再发生变化,然后简单地丢弃不包含在集合E中的成员目标文件。然后链接器继续处理下一个文件。

2 判断集合U是否为空

如果链接器扫描完命令行上的所以文件后,集合U仍不为空,则说明引用了未定义的符号,则链接器将会报错并终止程序。
如果链接器扫描完命令行上的所以文件后,集合U仍为空,则将合并和重定位E中的目标文件,并输出可执行文件。

动态库

特点

1.节省空间(一个动态库,在内存中只有一份拷贝)
2.动态库把对一些库函数的链接载入推迟到程序运行的时期
3.可以实现进程之间的资源共享。(因此动态库也称为共享库)
4.将一些程序升级变得简单
5.甚至可以真正做到链接载入完全由程序员在程序代码中控制(显示调用)
6.动态库在处理类型、虚函数,方面有一些很大的缺陷 : DLL Hell

动态链接共享库

使用动态库生成可执行文件的过程中,静态的执行一部分链接,然后在程序加载时,动态完成剩余部分的链接过程。没有任何的动态库代码和数据节真的被复制到可执行文件中,而是,复制了一些重定位和符号表信息,它们使得运行时可以解析对动态库中代码和数据的引用。

咕咕咕

位置无关代码 PIC

现代系统使用一种方法来编译共享模块的代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。

1.mov指令要求一个绝对地址,怎么转化成相对地址

重要特性

1.数据段和指令段之间的距离是一个常量
2.X86上指令相对偏移的计算

如何把数据绝对地址变为数据相对地址

思路 :
1.段与段之间的距离是固定的
2.通过拿到指令地址计算出指令段的地址
3.计算出数据段的地址
那么就可以计算出数据段的地址(x64上可以直接访问RIP,x86不行),取巧的办法

  call GET_ADDR // 将下一条指令的地址压栈
GET_ADDR : 
  pop ebx       // 弹栈得到ip寄存器的值

用全局偏移表GOT来实现数据位置无关

GOT是一张在date数据段中存储的表,里面记录了很多全局变量的段内绝对地址

假设一条指令想要引用一个变量,并不是直接去用绝对地址,而是去引用GOT里的一个entry。
通过计算GOT表的地址 + entry的偏移,即得到数据的绝对地址
PIC代码具有性能上的缺陷。现在每次全局变量引用都需要5条指令而不是1条,同时GOT还需要占用额外的内存空间。并且,PIC代码需要使用额外的寄存器来保存GOT表项的地址。在寄存器较多的机器上,这不是什么大问题。但是在寄存器较少的IA32系统中,缺少哪怕一个寄存器都可能会触发将寄存器内容暂存在堆栈里。

2.怎么延迟绑定函数地址

延迟绑定需要在两个数据结构之间进行密集而复杂的交互
GOT和过程连接表(procedure linkage table, PLT)。
如果一个目标模块调用了共享库中的任意函数,那么它就有它自己的GOT和PLT。
GOT是.data段的一部分。PLT是.text段的一部分。

库打桩机制

和windows下的hook类似,可以拦截动态库中的调用
下面是它的基本思路:给定一个需要打桩的目标函数,创建一个包装函数,它的原型与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是目标函数了。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返回值传递给调用者。
打桩可以发生在编译时、链接时或当程序被加载和执行的运行时。
库打桩最重要的就是实现mymalloca能调用malloca这样,自引用

1.编译时打桩(需要访问源代码)

int.c

#include <stdio.h>
#include <malloc.h>
int main()
  {
  	int *p = malloc(32);
  	free(p);
  	return 0;
   } 

malloc.h

#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)
void* mymalloc(size_t size);
void myfree(void *ptr);

mymalloc.c

#ifdef COMPILETIME
#include <stdio.h>
#inlcude <malloc.h>
void* mymalloc(size_t size)
  {
  	void *ptr = malloc(size);
  	printf("malloc(%d) = %p\n",int(size),ptr);
  	return ptr;
  }
void myfree(void *ptr)
  {
  	free(ptr);
  	printf("free(%p)\n",ptr);
  }
#endif COMPILETIME

-I.:指示C预处理器在搜索通常的系统目录前,先在当前目录中查找malloc.h

gcc -DCOMPILETIME-c mymalloc.c  //-DCOMPILETIME等效于#define DCOMPILETIME
gcc -I -o intc int.c mymalloc.o

待实操

2.链接时打桩(需要访问可重定位对象文件)

#ifdef LINKTIME
#include <stdio.h>
void *__real_malloc(size_t size);
void  __real_free(void *ptr);
void *__wrap_malloc(size_t size)
  {
  	void* ptr = __real_malloc(size); // 调用libc::malloc 
  	printf("malloc(%d) = %p\n",int(size),ptr);
  	return ptr;
  }
void __wrap_free(void *ptr)
  {
  	__real_free(ptr); // 调用 libc::free
	printf("free(%p)\n",ptr);
  }
#enif
gcc -DLINKTIME -c mymalloc.c
gcc -c int.c
gcc -W1,--wrap,malloc -W1,--wrap,free -o int1 int.o mymalloc.o

-W1,option标志把option传递给链接器,option中的每个逗号都要替换为一个空格,所以-w1,--wrap,malloc
就把--wrap malloc传递给链接器

3.运行时打桩(需要访问可执行文件)

这个很厉害的机制基于动态链接器的LD_PRELOAD环境变量
待填