当前位置: 首页 > article >正文

linux链接、目标文件全解析

内容目录

内容目录

链接

  • 1. 静态链接
  • 2. 目标文件
  • 3. 可重定位目标文件
  • 4. 符号和符号表
  • 5. 符号解析
    • 5.1 链接器如何解析多重定义的符号
    • 5.2 与静态库链接
    • 5.3 链接器如何使用静态库来解析引用
  • 6. 重定位
    • 6.1 重定位条目
      - 6.2 重定位符号引用
      • 6.2.1 重定位PC相对引用
      • 6.2.2 重定位绝对引用
  • 7. 可执行目标文件
  • 8. 加载可执行目标文件
  • 9. 动态链接共享库
  • 10. 运行时从应用程序中加载和链接共享库
  • 11. 位置无关代码
    • 11.1 PIC数据引用
    • 11.2 PIC函数调用
  • 12. 处理目标文件的工具

链接

链接的定义: 链接(linking)是将各种代码和数据片段收集并组合为一个单一文件的过程。链接既可被执行于编译时(静态链接),也可被执行于加载时(load time),还可以被执行于运行时(动态链接)。

​ ——《深入理解计算机系统》

1. 静态链接

定义: 静态链接以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。

链接
可重定位目标文件
可执行目标文件

链接器主要完成两个任务:

  • **符号解析:**目标文件中定义和引用了符号,每个符号对应于一个函数、全局变量或者静态变量。符号解析就是将符号引用和符号定义关联起来。
  • **重定位:**编译器和汇编器总是生成从地址0开始的代码和数据,链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使它们指向这个内存位置。

2. 目标文件

定义: 目标文件是编译器生成的中间文件,包含编译后的机器代码和数据,还没有被链接为最终的可执行文件。

目标文件有三种形式:

  • 可重定位目标文件。不能直接运行,必须与其它可重定位目标文件合并起来才能执行。
  • 可执行目标文件。可以被直接复制到内存中运行。
  • 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
静态链接
加载
动态链接
可重定位目标文件
可执行目标文件
运行
共享目标文件

目标文件地格式:

  • Linux: ELF格式 (Executable and Linkable File)
  • Windows: PE格式(Portable Executable)
  • MAC OS-X: Mach-O格式

3. 可重定位目标文件

image-20241012213836939
  • ELF头: 包含生成该目标文件的系统的字的大小和字节序,以及ELF头大小、目标文件类型、机器类型(如x86-64)、节头部表地文件偏移、节头部表中条目地数量和大小。夹在ELF头和节头部表之间的都是节:
  • .text: 已编译程序的机器代码。
  • .rodata: 只读数据。例如字符串字面值。
  • .data:已初始化的全局和静态变量。
  • .bss: 未初始化的全局和静态变量,以及所有被初始化为0的全局或者静态变量。该节的存在主要是为了节省磁盘空间 ,提高加载速度,因为未初始化的变量只需要描述其类型和大小即可,不需要预留存储空间。
  • .symtab: 符号表,存放在程序中定义和引用的函数全局变量信息。
  • .rel.text: 存访.text节中引用或定义的符号的重定位信息。对外部函数全局变量的调用的位置都需要进行修改,对本地函数的调用则不需要修改位置。
  • .rel.data: 存放.data节中引用或定义的所有全局变量的重定位信息。
  • .debug: 调试符号表,里面保存了程序中定义的局部变量和类型定义,程序中定义和引用的全局变量以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会生成这张表。
  • .line: 原始C源文件中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会生成这张表。
  • .strtab: 一个字符串表,其内容包括.systab和.debug节中的符号表,以及节头部中的节名字。

4. 符号和符号表

每个可重定位目标模块都有一个符号表,其中有三种不同的符号:

  • 在本目标文件中定义并能够被其它模块引用的全局符号。
  • 有其它模块定义,并在本目标文件中引用的全局符号。
  • 只被本目标文件定义和引用的局部符号,即局部静态变量。
image-20241012233138820

上图是符号表中条目的结构。其中需要着重解释两个字段:

  1. section: 表示该符号所在的节,其值为其在节头部表中的索引。除了[3]中图中显示的节外,还有三个特殊的伪节,在节头部表中没有条目,分别是ABS(不该被重定位的符号),UNDEF(被引用但并未被定义的符号),COMMON(还未被分配位置的未初始化的数据条目)。
  2. value: 表示该符号在所在节中的偏移。

5. 符号解析

定义:链接器解析符号引用的方法是将每个符号引用与输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。

对于本地局部的符号(static函数、static全局变量、局部静态变量),编译器会确保它们有且只有一个定义。但是对于全局符号,编译器无法确定它们在别的目标文件中是否有定义,这个工作就交给了链接器。

当链接器无法找到一个符号的定义时,会直接抛出错误信息,如"undefined reference xxx"。那当链接器找到了一个符号的多个定义时,会发生什么呢?

5.1 链接器如何解析多重定义的符号

在编译时,编译器向汇编器输出每个全局符号,要么是强,要么是弱,汇编器会把强弱信息隐含在符号表中。所谓强符号,是指函数和已初始化的全局符号,而弱符号就是未初始化的全局变量。请牢记定义。

根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:

  • 规则1:不允许有多个同名的强符号。
  • 规则2:如果有一个强符号和多个弱符号同名,那么选择强符号。
  • 规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。

例子:

// foo1.c
#include <stdio.h>

int x = 12;     // 强符号
int y = 12;     // 强符号

void f(void);
int main()
{
        f();
        printf("x: 0llx%x, y: 0llx%x\n", (long long)&x, (long long)&y);
        printf("x = 0x%x, 0x%x\n", x, y);
        return 0;
}

// foo2.c
double x;	// 弱符号
void f()
{
	x = 0.0;
}

上面的代码会引起什么错误?

显然,x这个符号存在多个定义,一强一弱的情况下链接器会使用强符号,但是在main函数中,由于首先调用了f函数,执行"x = 0.0",而在foo.c中x是double类型,占64位,而链接后x实际是int类型,占32位。当x和y位置相邻且y在x的后面时,x和y的值就都会被改变,引起非常难以定位的错误。

幸运的是,当我们怀疑有此类错误时,可以用GCC的 -fno-common标志这样的选项调用链接器,这样在遇到多重定义时会触发错误,或者-Werror,它会把所有警告都变为错误。

5.2 与静态库链接

静态库:将所有相关的目标模块打包位一个单独的文件,成为静态库(static library)。它可以用作链接器的输入。

在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存访在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。

linux平台GNU C编译器在编译时会自动链接libc.a这个C语言标准库。

使用ar工具创建静态库

> gcc -c addvec.c mulvec.c
> ar rcs libaddvec.a addvec.o mulvec.o

将main函数文件与库文件链接创建一个可执行文件:

> gcc -static -o prog2c main2.c -L. -laddrvec
# 或者
> gcc -static -o prog2c main2.c ./libaddrvec.a
image-20241013123751147
5.3 链接器如何使用静态库来解析引用

符合解析阶段,链接器从左到右按照在命令行中出现的顺序来扫描可重定位目标文件存档文件。在扫描过程中链接器维护着一个可重定位目标文件的集合E,一个未解析的符号的集合U, 一个在前面的输入文件中已经定义的符号集合D。初始时E、U、D均为空。

链接器处理扫描对象的规则如下:

  • 判断文件f是目标文件还是静态库文件(存档文件)
  • 如果f是目标文件,则将其加入E,同时修改U和D反应f中的符号定义和引用。
  • 如果f是归档文件,链接器会尝试匹配U中未解析的符号和由归档文件成员定义的符号,如果匹配成功,就将对应目标文件加入E,并修改U和D。最后未被加入的目标文件被简单丢弃。
  • 最后,链接器完成对输入文件的扫描后,如果U是非空的,链接器会输出错误并终止。否则它会合并和重定位E中的目标文件,构建输出的可执行文件。

根据这个规则可知:命令行中目标文件和静态库文件的顺序非常重要,如果定义一个符号的归档文件出现在引用这个符号的目标文件或归档文件之前,那么该引用就无法被解析

> gcc -static ./libvector.a main2.c
> /tmp/cc9XH6Rp.o: In function 'main': /tmp/cc9XH6Rp.o(.text+0xl8): undefined reference to 'addvec

6. 重定位

完成符号解析之后,链接器就知道了哪些目标文件会被加入到输出文件,以及每个符号对应的定义的位置,于是可以开始进行符号的重定位了。重定位就是为每个符号分配运行时地址。重定位由两步组成:

  • 重定位节和符号定义。在这一步,链接器将所有相同的节合并在一起,并且赋予它们运行时地址,然后赋予所有符号运行时地址。
  • 重定位节中的符号引用。在这一步,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标文件中的重定位条目。
6.1 重定位条目

编译器在生成目标代码时并不知道静态变量和函数最终会被放在什么位置,更不知道在外部定义的符号的最终位置,因此编译器每遇到一个对最终位置未知的目标引用,都需要生成一个重定位条目,来告诉链接器将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,数据的重定位条目放在.rel.data中。

一起看下上图的重定位条目的结构:

  • offset: 需要被修改的引用的节内偏移。
  • type: 重定位类型,告知链接器如何修改引用。
  • symbol: 指向被引用的符号,在符号表中的索引。
  • addend: 一些类型的重定位需要使用它对被修改的引用值做偏移调整。

ELF定义了32中不同的重定位类型,我们只关心最基本的两种:

  • R_X86_64_PC32: 重定位一个使用32位PC(程序计数器)相对地址的引用。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC(程序计数器)的当前运行时值,得到目标地址。
  • R_X86_64_32, 重定位一个使用32位绝对地址的引用。

当程序规模较大,例如数据段与代码段的偏移超过32地址范围,那么就需要在编译时设置更大的代码模型

-mcmodle medium|large
6.2 重定位符号引用

看一下链接器重定位算法的伪代码:

其中refptr表示引用在节内的位置,ADDR(s)表示节s的运行时地址,ADDR(r.symbol)表示重定位条目r表示的符号的运行时地址。

6.2.1 重定位PC相对引用

从第7,8行可以看到,在重定位相对地址的引用时,链接器需要首先算出引用本身的运行时地址refaddr, 然后用符号定义的运行时地址ADDR(r.symbol),加上偏移调整,然后减去refaddr

6.2.2 重定位绝对引用

从第 13行可以看到,重定位绝对引用只需要,在符号定义的运行时地址上加上偏移调整即可。

7. 可执行目标文件

下图是一个典型的ELF可执行目标文件的结构:

image-20241013165649651

可以看到,它于ELF格式的可重定位目标文件结构类似又有所不同。其中.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件时完全链接的,所以不再需要.rel节。

段(segment)与节(section)的关系

节section是编译时的概念,编译器将代码、数据等划分为不同的节。

段segment是执行时的概念,操作系统的加载器会根据段头部表将文件中的多个节合并到同一个段中加载到内存中。

可执行文件的连续的的片/段(chunk)会被映射到连续的内存区域。程序头部表描述了这种映射关系:

image-20241013194712551

8. 加载可执行目标文件

当在shell中运行可执行目标文件时:

> ./prog

shell进程会通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。加载器将可执行目标文件中的代码和数据从磁盘复制到内存,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载

每个linux程序都有一个运行时内存映像,请看下图:

image-20241015130730474

实际上,由于虚拟内存和内存映射的存在,加载器并不是简单的把程序的各个段复制到内存中。实际上,除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射到虚拟页时才会进行复制。

9. 动态链接共享库

定义共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。

创建一个共享库:

> gcc -shared -fpic -o 	libvector.so addvec.c multvec.c

链接一个共享库

# 在加载时链接共享库
> gcc -o prog21 main2.c ./libvector.so

这里libvector.so会在加载时进行链接。

当加载器加载prog21这个可执行目标文件时,它注意到里面包含一个**.interp节,这一节包含动态链接器**的路径名,动态链接器本身就是一个共享目标,然后加载器会加载和运行这个动态链接器。动态链接器通过执行下面的重定位完成链接任务:

  • 重定位libc.so的文本和数据到某个内存段。
  • 重定位libvector.so的文本和数据到某个内存段。
  • 重定位prog21中所有对libc.so和libvector.so定义的符号的引用。

最后,动态链接器将控制转交给应用程序。

10. 运行时从应用程序中加载和链接共享库

linux系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库。

#include <dlfcn.h>
// 返回值:若成功则为指向句柄的指针,若失败则为NULL
void *dlopen(const char *filename, int flag);

// dlsym用来获取一个符号的地址
// 如果该符号不存在,就返回NULL
void *dlsym(void *handle, char *symbol);

// 如果没有其他共享库还在使用这个共享库,dlclose函数就卸载该共享库
void dlclose(void *handle);

// 返回前面的函数最近出现的错误,如果没有错误发生,就返回NULL
const char *dlerror(void);

下面给出一个使用这些函数在运行时连接一个共享库的例子:

// dll.c
#include <stdio.h>
#include <stdlib.h>
#include <dlfnc.h>

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main()
{
    void *handle;
    void (*addvec)(int *, int *, int *, int);
    char *error;
    
    handle = dlopen("./libvector.so", RTLD_LAZY);
    if (!handle)
    {
        fprintf(stderr, "%s\n", dlerror());
        exit(1);
    }
    
    addvec = dlsym(handle, "addvec");
    if ((error = dlerror()) != NULL)
    {
        fprintf(stderr, "%s\n", error);
        exit(1);
    }
    
    addvec(x, y, z, 2);
    
    if (dlclose(handle) < 0)
    {
        fprintf(stderr, "%s\n", dlerror());
    	exit(1);
    }
    return 0;
}

编译这个程序

gcc -rdynamic -o prog2r dll.c -ldl	# 链接dl库

-rdynamic参数用于控制在生成可执行文件时,再动态符号表中保留全局符号,而非只保留需要在外部解析的符号。默认情况下,可执行文件不保存静态符号表。

11. 位置无关代码

定义:可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code, PIC)。共享库的编译必须总是使用该选项。

共享库的一个主要目的是允许多个进程共享内存中相同的代码,从而节省内存资源。那么这是如何实现的呢?

11.1 PIC数据引用

编译器在数据段开始的地方创建了一个表,叫做全局偏移量(Global Offset Table, GOT)。在GOT中,每个被这个目标模块引用的全局符号都有一个8字节的条目。编译器还为GOT中每个条目生成一个重定位记录。每个引用全局目标的目标模块都有自己的GOT。

image-20241015183418147

这里的关键是,最GOT[3]的PC相对引用中的偏移量是一个常数:0x2008b9。这是因为这样一个关键的事实:无论我们在内存中的何处加载一个目标模块(包括共享目标模块 ),数据段与代码段的距离总是保持不变。

这里指的是虚拟地址的上的距离,而不是物理内存上的距离,后者实际上是随机的。

如果addcnt是由 libvector.so 模块定义的 ,编译器可以利用代码段和数据段之间不变的距离,产生对addcnt的直接PC相对引用,并增加一个重定位,让链接器在构造这个共享模块时解析它。如果addcnt是由另一个共享模块定义的,那么就需要通过GOT进行间接访问。

11.2 PIC函数调用

编译器没有办法预测共享库中定义的函数的运行时地址,那么就需要在运行时进行某种重定位。一种做法是为该引用生成一条重定位记录,然后动态链接器在程序加载时解析它。但是,这种方法并不是PIC,因为它需要链接器修改调用模块的代码段。GNU编译器系统使用延迟绑定来解决这个问题。

延迟绑定通过两个数据结构来实现,分别是GOT和过程链接表(Procedure Linkage Table, PLT)。如果一个模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。GOT是数据段的一部分,而PLT是代码段的一部分。

linux的动态链接器默认采用延迟绑定机制,就是在函数首次被使用时才进行解析。但是它仍会在启动时就尝试加载所有共享库,以确保在需要时能够找到这些库并进行符号解析。

那么让我们看一下这两个表的内容:

  • 过程链接表(PLT):PLT是一个数组,其中每个条目是16字节。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。
  • 全局偏移量表(GOT):和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余每个条目对应一个被调用的函数,其地址需要在运行时被解析。初始时,每个GOT条目都指向对应PLT条目的第二条指令。
image-20241015191714102

上图展示了,GOT和PLT如何协同完成工作:

第一次调用addvec:

  1. 不直接调用addvec,程序进入PLT[2]。
  2. 间接跳转到GOT[4]所指的地址,即PLT[2]的下一条指令。
  3. 把addvec的ID(0x1)压入栈,跳转到PLT[0]。
  4. PLT[0]把GOT[1]压入栈(即把动态链接器的一个参数压入栈),然后跳转到GOT[2]。动态链接利用两个参数确定addvec的运行时位置,用这个地址重写GOT[4],再把控制传递给addvec。

后续调用addvec:

  1. 程序进入PLT[2]
  2. 此时GOT[4]已被填入addvec的运行时地址,因此直接执行addvec

12. 处理目标文件的工具

  • ar:创建静态库,插入、删除、列出和提取成员

  • strip: 从目标文件中删除符号表信息

  • nm: 列出一个目标文件的符号表中定义的符号

  • size: 列出目标文件中节的名字和大小

  • readelf: 显示一个目标文件的完整结构

  • objdump: 所有二进制工具之母。能够显示一个二进制文件的所有信息。

  • LDD: 列出一个可执行文件在运行时所需要的共享库。


http://www.kler.cn/news/361271.html

相关文章:

  • TWS充电盒:【电源管理芯片汇总】
  • 3184. 构成整天的下标对数目 I
  • Telegram mini app开发极简示例
  • 批量合并PDF 文件的 5 大解决方案
  • springboot+vue美食推荐商城的设计与实现+万字lw
  • 格姗知识圈博客网站开源了!
  • 苍穹外卖学习笔记(三十二最终篇)
  • 构建高效智慧社区:Spring Boot Web框架应用
  • Ubuntu配置FTP
  • 基于图像拼接开题报告
  • Python 正则
  • Prompt提示词设计:如何让你的AI对话更智能?
  • EasyExcel自定义下拉注解的三种实现方式
  • 容灾与云计算概念
  • 加密DNS有什么用?
  • 网络安全——防火墙技术
  • 在 Kylin Linux 上安装 PostgreSQL 以下是安装 PostgreSQL 的步骤:
  • linux命令基础
  • 边缘计算网关兼容多种通信协议实现不同设备和系统互联互通
  • python实战项目46:selenium爬取百度新闻