打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
Linux系统学习笔记:链接

链接就是将不同部分的代码和数据收集组合为一个单一文件,该文件可以被加载到存储器执行。链接可以在编译时、加载时或运行时执行。本篇总结链接相关的内容,包括静态链接、加载时和运行时的动态链接。

现代系统中链接由链接器自动执行。链接使程序可以分离编译,程序可以分解为很多模块来单独修改和编译,修改一个模块只需重新编译它再链接到应用上,而不必重新编译其他模块。链接有助于理解程序的模块管理、程序的加载运行和共享库的编写方面的知识。

目标文件

目标文件是字节块的集合。链接器将这些块连接起来,确定被链接块的运行时位置,并修改代码和数据块中的各种位置。

目标文件有三种形式:

  • 可重定位目标文件:包含二进制代码和数据,可以在编译时和其他可重定位目标文件合并,创建可执行目标文件。
  • 可执行目标文件:包含二进制代码和数据,可以被直接复制到存储器并执行。
  • 共享目标文件:特殊类型的可重定位目标文件,可在加载或运行时被动态地加载到存储器并链接。

不同系统的目标文件的格式不同。System V Unix早期版本使用COFF格式,Windows使用COFF变种的PE格式,现代Unix使用ELF格式。

可重定位目标文件

ELF可重定位目标文件如下图组成:

ELF可重定位目标文件

  • ELF头:开头为16字节的序列,描述字的大小和生成该文件的系统的字节顺序。剩下的部分为帮助链接器解析和解释目标文件的信息,包括ELF头大小、目标文件类型、机器类型、节头部表的文件偏移、节头部表中表目大小和数量。

  • 节头部表:描述不同节的位置和大小,目标文件中每个节都有一个固定大小的表目。

  • 节:ELF头和节头部表之间的是节。

    • .text:已编译程序的机器代码。

    • .rodata:只读数据。

    • .data:已初始化的全局C变量。

    • .bss:未初始化的全局C变量。在目标文件中该节只是占位符,不占用实际空间。

      局部C变量在运行时保存在栈中,不出现在.data和.bss节中。

    • .symtab:符号表,存放程序中被定义和引用的函数和全局变量的信息。.symtab不包含局部变量的表目。

    • .rel.text:和其他文件链接时,.text节中任何调用外部函数或引用全局变量的指令都需要修改(调用本地函数的指令不需要修改)。

    • .rel.data:被模块定义或引用的任何全局变量的信息,.data节中任何已初始化全局变量的初始值是全局变量或外部定义函数的地址都需要修改。

      可执行目标文件不需要重定位信息,通常省略.rel.text和.rel.data。

    • .debug:调试符号表,以 -g 选项编译时得到。包含如定义的局部变量和类型定义,定义和引用的全局变量,原始的C源文件。

    • .line:原始的C源文件中的行号和.text节中机器指令之间的映射,以 -g 选项编译时得到。

    • .strtab:字符串表,是以 null 结尾的字符串序列,包含.symtab和.debug节中的符号表,节头部中的节名字。

可以用 readelf 工具查看ELF可重定位目标文件。

符号表

有三种符号:

  • 当前模块定义并能被其他模块引用的全局符号,对应于无 static 属性的函数和全局变量。
  • 其他模块定义并被当前模块引用的全局符号,称为外部符号,对应于定义在其他模块的函数和变量。
  • 只被当前模块定义和引用的本地符号,对应于有 static 属性的函数和全局变量,有 static 属性的本地变量。

符号表不包含非静态本地变量,它们在运行时由栈管理。多个静态本地变量重名时(如不同函数中),独立处理,每个有自己的空间和符号。

符号表由汇编器构造,使用汇编文件 .s 中的符号。符号表包含一个表目的数组,表目格式为:

typedef struct {    int name;       /* 字符串表中的字节偏移,指向符号的名字(以null结尾) */    int value;      /* 符号地址,可重定位为从节起始位置的偏移,可执行为运行时地址 */    int size;       /* 目标的字节大小 */    char type:4,    /* 数据、函数、节、源文件 */         binding:4; /* 本地、全局 */    char reserved;  /* 未用 */    char section;   /* 到节头表的索引、ABS(不该重定位的符号)、UNDEF(未定义只引用的符号)、COMMON(还未分配位置的未初始化的数据目标,这时value给出对齐请求,size给出最小大小) */} Elf_Symbol;

使用 readelf -s 可以查看符号表。

/* sym.c - 编译为sym.o */#include <stdio.h>int x;                              /* 全局符号:全局变量 */extern int y;                       /* 外部符号:外部变量 */int f()                             /* 全局符号:函数 */{    static int x = 1;               /* 本地符号:静态本地变量 */    return x;}static int g()                      /* 本地符号:静态函数 */{    static int x = 2;               /* 本地符号:静态本地变量 */    return x;}int main()                          /* 全局符号:函数 */{    x = y = 0;    printf("%d %d\n", f(), g());    /* 外部符号:函数 */    return 0;}
Symbol table '.symtab' contains 15 entries:   Num:    Value  Size Type    Bind   Vis      Ndx Name     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS sym.c     2: 00000000     0 SECTION LOCAL  DEFAULT    1     3: 00000000     0 SECTION LOCAL  DEFAULT    3     4: 00000000     0 SECTION LOCAL  DEFAULT    4     5: 00000004     4 OBJECT  LOCAL  DEFAULT    3 x.1703   .data节,静态本地变量     6: 0000000a    10 FUNC    LOCAL  DEFAULT    1 g        .text节,本地函数     7: 00000000     4 OBJECT  LOCAL  DEFAULT    3 x.1707   .data节,静态本地变量     8: 00000000     0 SECTION LOCAL  DEFAULT    5     9: 00000000     0 SECTION LOCAL  DEFAULT    7    10: 00000000     0 SECTION LOCAL  DEFAULT    6    11: 00000004     4 OBJECT  GLOBAL DEFAULT  COM x        未初始化全局变量    12: 00000000    10 FUNC    GLOBAL DEFAULT    1 f        .text节,全局函数    13: 00000014    76 FUNC    GLOBAL DEFAULT    1 main     .text节,全局函数    14: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND y        未定义符号    15: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND printf   未定义符号

可执行目标文件

可重定位目标文件链接后生成可执行目标文件。下图是典型ELF可执行文件中的信息:

ELF可执行目标文件

ELF头描述文件的总体格式,还包括程序的入口点,即运行时执行的第一条指令的地址。.text、.rodata和.data节和可重定位目标文件中的类似,但已经被重定位。.init节定义了一个_init函数,程序初始化代码会调用它。

加载时,连续的可执行文件的组块被映射到连续的存储器段,段头表描述这种映射关系。使用 objdump -preadelf -l 查看段头表信息:

off:         文件偏移vaddr/paddr: 虚拟/物理地址align:       段对齐filesz:      目标文件中的段大小memsz:       存储器中的段大小flags:       运行时许可Program Header:    PHDR off    0x00000034 vaddr 0x08048034 paddr 0x08048034 align 2**2         filesz 0x000000e0 memsz 0x000000e0 flags r-x  INTERP off    0x00000114 vaddr 0x08048114 paddr 0x08048114 align 2**0         filesz 0x00000013 memsz 0x00000013 flags r--    LOAD off    0x00000000 vaddr 0x08048000 paddr 0x08048000 align 2**12         filesz 0x000004e0 memsz 0x000004e0 flags r-x    LOAD off    0x000004e0 vaddr 0x080494e0 paddr 0x080494e0 align 2**12         filesz 0x00000110 memsz 0x00000118 flags rw- DYNAMIC off    0x000004f4 vaddr 0x080494f4 paddr 0x080494f4 align 2**2         filesz 0x000000d0 memsz 0x000000d0 flags rw-    NOTE off    0x00000128 vaddr 0x08048128 paddr 0x08048128 align 2**2         filesz 0x00000044 memsz 0x00000044 flags r--   STACK off    0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**2         filesz 0x00000000 memsz 0x00000000 flags rw-

可以看到加载( LOAD )的两个段:

  • 代码段:开始于存储器地址 0x08048000 ,大小 0x4e0 字节,对齐到4KB(0x1000B)边界,有读/执行许可,初始化为可执行文件前 0x4e0 字节。
  • 数据段:开始于存储器地址 0x080494e0 ,大小 0x110 字节,对齐到4KB(0x1000B)边界,有读/写许可,初始化为可执行文件 0x4e0 后的 0x110 字节。

运行可执行目标文件时,加载器将它的代码和数据从磁盘复制到存储器中,然后跳转到入口点运行程序,这个过程称为加载。

程序都有一个运行时存储器映像。在Linux系统中,代码段总是从地址 0x08048000 开始,数据段在下一个4KB对齐的地址处,运行时堆又在下一个4KB对齐的地址处,并通过 malloc 调用向上增长。地址 0x40000000 开始的段是为共享库保留的。用户栈从地址 0xbfffffff 开始向下增长。地址 0xc0000000 开始的段为内核的代码和数据保留。

Linux运行时存储器映像

加载器运行时,创建存储器映像,根据段头表复制代码和数据段,然后跳转到程序的入口点,即符号 _start 的地址,根据启动代码进行一系列的调用,最后将控制返回给操作系统(如使用 _exit )。

静态链接

静态链接器(如 ld )以一组可重定位目标文件为输入,生成一个完全链接的可加载和运行的可执行目标文件。

可重定位目标文件由各种不同的代码和数据节组成,指令、初始化的全局变量和未初始化的变量在不同的节。

链接器具体地会进行符号解析和重定位:

  • 符号解析。目标文件定义和引用符号,符号解析将符号引用和符号定义联系起来。
  • 重定位。编译器和汇编器生成从地址0开始的代码和数据节。链接器把符号定义和存储器位置联系起来,然后修改符号引用,使指向存储器位置。

符号解析

编译器只允许每个模块中的每个本地符号只有一个定义。编译器确保静态本地变量有本地链接器符号。对于全局符号,如果编译器遇到非当前模块中定义的符号,就假设它在其他模块中定义,生成一个链接器符号表表目,交给链接器处理。

如果链接器在输入的模块中找不到被引用的符号,输出错误信息并终止。如果相同的符号在多个目标文件中定义,链接器标志错误或选用其中一个定义。

C++和Java中允许重载方法。编译器将方法和参数列表组合编码成对链接器唯一的名字,编码过程称为毁坏,相反称为恢复。毁坏类名由类名字符数加原始名字组成。毁坏方法名由原始方法名加 __ ,加毁坏类名,加每个参数的一个字母组成。如 Foo::bar(int, long) 编码为 bar__3Fooil 。全局变量和模板名字的毁坏策略类似。

编译器输出时,函数和已初始化的全局变量为强符号,未初始化的全局变量为弱符号。链接器以下面的规则处理多处定义的符号:

  1. 不允许有多个强符号。
  2. 如果有一个强符号和多个弱符号,选择强符号。
  3. 如果有多个弱符号,从它们中任选一个。

gcc 使用 --warn-common 选项调用链接器能够在解析多定义的全局符号时输出警告。

/* func.c *//* int x = 0x100; */    /* 情况1 *//* int x; */            /* 情况2 */double x;               /* 情况3 */void f(){    x = 0.0;}/* main.c */#include <stdio.h>void f(void);int x = 0x100;int y = 0x101;int main(){    f();    printf("0x%x 0x%x\n", x, y);    return 0;}
  1. func.c 中定义 int y = 0x101; 时,由规则1,链接错误:

    $ gcc main.c func.c/tmp/cc52NZQ8.o:(.data+0x0): multiple definition of `x'/tmp/ccwTqTEp.o:(.data+0x0): first defined herecollect2: ld returned 1 exit status
  2. func.c 中定义 int x; 时,由规则2,选择 main.c 中定义的 x ,但可以看到函数 f 带来了不希望的结果:

    $ gcc main.c func.c$ ./a.out0x0 0x101$ gcc -Wl,--warn-common main.c func.c/tmp/ccnqS830.o: warning: common of `x' overridden by definition/tmp/cci8vWlo.o: warning: defined here
  3. func.c 中定义 double x; 时,和情况2相同,这时函数 f 甚至破坏了 y 的值:

    $ gcc main.c func.c/usr/bin/ld: Warning: alignment 4 of symbol `x' in /tmp/cciNcJD5.o is smaller than 8 in /tmp/ccZ0W8WM.o$ ./a.out0x0 0x0

静态库

编译系统支持将相关的目标模块打包为一个单独的文件,称为静态库,它可作为链接器的输入。链接器在构造可执行文件时,只复制静态库中被引用的目标模块,减少了可执行文件的大小。静态库简化了目标模块的管理和使用。

Unix系统上,静态库保存为一种存档格式 .a 。它是一组可重定位目标文件的集合,在文件头部描述各成员目标文件的大小和位置。

创建静态库文件。设有C源文件 addmatrix.cmultmatrix.c

$ gcc -c addmatrix.c multmatrix.c$ ar rcs libmatrix.a addmatrix.o multmatrix.o

使用静态库文件。有主程序文件 main.c ,包含头文件 matrix.h ,后者定义了 libmatrix.a 中函数的原型:

$ gcc -O2 -c main.c$ gcc -static -o prog main.o ./libmatrix.a

-static 选项使链接器构建完全链接的可执行目标文件。

gcc 默认会将 libc.a 作为链接器的输入。

和静态库链接

符号解析阶段,链接器按照编译命令输入的可重定位目标文件和存档文件的顺序进行扫描。设可重定位目标文件的集合为E,未解析符号的集合为U,已定义符号的集合为D。对每个输入文件f,若是目标文件,链接器将f添加到E,并根据f修改U和D;若是存档文件,链接器依次读取f中的成员m,用m中定义的符号去匹配U中未解析的符号,如有匹配,将m加入E,修改U和D,反复读取f中的成员直到U和D不再变化。完成扫描后,如果U非空,则链接出错并终止,否则合并和重定位E中的目标文件,构建可执行文件。

因此编译时输入文件的顺序很重要,一般将库放在命令行末尾。如果库之间还有依赖关系,那么还要对它们排序,必要时可以重复输入库解决相互依赖的问题。

$ gcc foo.c libx.a liby.a libx.a    # libx.a和liby.a相互依赖

重定位

重定位分两步:

  • 重定位节和符号定义。链接器将所有相同类型的节合并为同类型的一个节,然后将运行时存储器地址赋给每个节和每个符号,这样程序中每个指令和全局变量都有了唯一的运行时存储器地址。
  • 重定位节中的符号引用。链接器修改代码节和数据节中对每个符号的引用,使其指向正确的运行时地址。链接器依赖重定位表目来执行。

汇编器在遇到对最终位置未知的目标引用时会生成一个重定位表目,来告诉链接器重定位时如何修改引用。.rel.text中为代码的重定位表目,.rel.data中为已初始化数据的重定位表目。

如下是ELF重定位表目的格式:

typedef struct {    int offset;     /* 需要修改的引用的节偏移 */    int symbol:24,  /* 被修改引用应指向的符号 */        type:8;     /* 重定位类型,如何修改引用 */} Elf32_Rel;

ELF有11种重定位类型,这里只给出两种最基本的类型:

  • R_386_PC32:重定位使用32位PC相关地址的引用。PC相关地址是距PC的当前运行时值(通常是存储器中下一条指令的地址)的偏移量,CPU执行PC相关寻址的指令时,将指令中的值加上PC的当前运行时值,得到有效地址。(参考上一篇)
  • R_386_32:重定位使用32位绝对地址的引用。CPU直接使用指令中的值作为有效地址。

重定位算法如下:

foreach section s {    foreach relocation entry r {        refptr = s + r.offset;              /* 指向要重定位的引用的指针 */        /* PC相关引用 */        if (r.type == R_386_PC32) {            refaddr = ADDR(s) + r.offset;   /* 引用的运行时地址 */            *refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr);        }        /* 绝对引用 */        if (r.type == R_386_32)            *refptr = (unsigned) (ADDR(r.symbol) + *refptr);    }}

以前面的 main.c 为例,使用 objdump -dr main.o 反编译得到(部分):

9:   e8 fc ff ff ff          call   a <main+0xa>                     a: R_386_PC32   fe:   8b 0d 00 00 00 00       mov    0x0,%ecx                     10: R_386_32    y

重定位PC相关的引用

如果生成的可执行文件中 mainf 函数的运行时存储器地址为:

080483c4 <main>:080483fc <f>:

对于 call 指令:

s        = 0(.text)  mainr.offset = 0xar.symbol = fr.type   = R_386_PC32*refptr = (unsigned) (ADDR(f) + *(0 + 0xa) - (ADDR(main) + 0xa))        = (unsigned) (0x80483fc + 0xfffffffc - (0x80483c4 + 0xa))        = (unsigned) (0x80483fc - 4 - 0x80483ce)        = (unsigned) (0x2a)

因此可执行文件中重定位后的 call 指令为:

80483cd:       e8 2a 00 00 00          call   80483fc <f>

运行时CPU执行到 call 指令时,PC的值应为 0x80483d2 ,则跳转到 0x80483d2 + 0x2a = 0x80483fcf 函数的位置。

PC总是指向当前指令的下一条指令,因此汇编器生成的引用初始值为-4。

重定位绝对引用

如果生成的可执行文件中 y 变量的运行时存储器地址为:

080495ec <y>:

对于变量 y

s        = 0(.text)r.offset = 0x10r.symbol = yr.type   = R_386_32*refptr = (unsigned) (ADDR(y) + *(0 + 0x10))        = (unsigned) (0x80495ec + 0x00000000)        = (unsigned) (0x80495ec)

因此可执行文件重定位后的 y 变量读取指令为:

80483d2:       8b 0d ec 95 04 08       mov    0x80495ec,%ecx

可以使用 objdump -D 查看可执行文件来进行验证。

动态链接和共享库

静态库确实方便了目标模块的管理,但它仍有一些局限性:它需要定期维护和更新,程序员需要显式地用新版本重新链接生成程序;对于很多程序都使用的一些公共函数,每个进程都会在存储器中保存一份自己的副本,形成了极大的浪费。

共享库解决了静态库的缺点。共享库也是目标模块,在运行时可以加载到任意的存储器地址并和程序链接起来,这个过程称为动态链接,由动态链接器程序执行。在Unix中,共享库是 .so 文件,在Windows中是 .dll 文件。

在给定的文件系统中,一个共享库只有一个 .so 文件,所有引用该库的可执行目标文件共享它的代码和数据。在存储器中,一个共享库的.text节只有一个副本被不同的正在运行的进程共享。

创建共享库文件可以通过:

$ gcc -shared -fPIC -o libmatrix.so addmatrix.c multmatrix.c

-shared 选项使链接器创建共享目标文件, -fPIC 使编译器生成与位置无关的代码。

使用共享库文件生成程序:

$ gcc -o prog main.c ./libmatrix.so

注意这时 libmatrix.so 中的代码和数据节并没有复制到 prog 中,链接器只复制了一些重定位和符号表信息。

可执行目标文件的 .interp 节包含了动态链接器的路径名。加载器加载可执行目标文件时,加载和运行这个动态链接器(Linux下为 ld-linux.so ),后者执行重定位完成链接(包括加载共享库,重定位可执行目标文件对共享库定义的符号的引用),最后动态链接器将控制传递给应用程序。

用共享库来动态链接

可以用 ldd 列出一个可执行文件在运行时需要的共享库。

Linux为动态链接器提供了允许应用程序在运行时加载和链接共享库的接口:

#include <dlfcn.h>/** * 返回指向句柄的指针,出错返回null * @filename    共享库文件名 * @flag        标志:RTLD_GLOBAL, RTLD_NOW, RTLD_LAZY, ... */void *dlopen(const char *filename, int flag);/** * 返回符号的地址,不存在返回null * @handle      共享库句柄 * @symbol      符号名 */void *dlsym(void *handle, const char *symbol);/** * 若共享库未正在使用,卸载共享库,返回0,否则返回-1 * @handle      共享库句柄 */int dlclose(void *handle);/** * 返回描述前三个调用最近错误的字符串 */char *dlerror(void);

例:

/* RTLD_LAZY: 推迟符号解析直到执行库中的代码 */void *handle = dlopen("./libmatrix.so", RTLD_LAZY);if (!handle) {    fprintf(stderr, "%s\n", dlerror());    exit(1);}addma = dlsym(handle, "addma");char *error = dlerror();if (error) {    fprintf(stderr, "%s\n", error);    exit(1);}addma(m1, m2);/* ... */if (dlclose(handle) < 0) {    fprintf(stderr, "%s\n", dlerror());    exit(1);}

可以编译含这样的代码的程序如:

$ gcc -rdynamic -O2 -o prog main.c -ldl

Java本地接口(JNI)就是将C函数编译到共享库中,然后使用 dlopen 之类的接口来加载和调用它们。

本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
【热】打开小程序,算一算2024你的财运
计算机大佬让你彻底了解深入理解计算机系统
ELF文件格式与程序的编译链接
深入理解计算机系统:第7章
Linux下动态链接的步骤与实现详解
ELF的GOT和PLT以及PIC | Zhiwei.Li
C 语言 全局变量多处定义 (强符号与弱符号)
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服