使用 LDD、Readelf 和 Objdump 的 GCC 链接过程
链接是 gcc 编译过程的最后阶段。
在链接过程中,目标文件被链接在一起,所有对外部符号的引用都被解析,最终地址被分配给函数调用等。
在本文中,我们将主要关注 gcc 链接过程的以下几个方面:
- 目标文件以及它们如何链接在一起
- 代码重定位
在阅读本文之前,请确保您了解 C 程序在成为可执行文件之前必须经过的所有 4 个阶段(预处理、编译、汇编和链接)。
链接对象文件
让我们通过一个例子来理解这第一步。首先创建以下 main.c 程序。
$ vi main.c
#include <stdio.h>
extern void func(void);
int main(void)
{
printf("\n Inside main()\n");
func();
return 0;
}
接下来创建以下 func.c 程序。在 main.c 文件中,我们通过关键字“extern”声明了一个函数 func(),并在一个单独的文件 func.c 中定义了这个函数
$ vi func.c
void func(void)
{
printf("\n inside func()\n");
}
为 func.c 创建目标文件,如下所示。这将在当前目录中创建文件 func.o。
$ gcc -c func.c
同样为 main.c 创建目标文件,如下所示。这将在当前目录中创建文件 main.o。
$ gcc -c main.c
现在执行以下命令来链接这两个目标文件以生成最终的可执行文件。这将在当前目录中创建文件“main”。
$ gcc func.o main.o -o main
当您执行此“主”程序时,您将看到以下输出。
$ ./main
Inside main()
Inside func()
从上面的输出中可以清楚地看出,我们能够成功地将两个目标文件链接到最终的可执行文件中。
当我们将函数 func() 从 main.c 中分离出来并写在 func.c 中时,我们得到了什么?
答案是如果我们也将函数 func() 写在同一个文件中,这可能无关紧要,但想想我们可能有数千行代码的非常大的程序。对一行代码的更改可能会导致重新编译整个源代码,这在大多数情况下是不可接受的。因此,非常大的程序有时会分成小块,这些小块最终链接在一起以生成可执行文件。
在大多数情况下,适用于 makefile的make 实用程序都会发挥作用,因为该实用程序知道哪些源文件已被更改以及哪些目标文件需要重新编译。相应源文件未被更改的目标文件按原样链接。这使得编译过程非常容易和易于管理。
所以,现在我们明白了,当我们链接两个目标文件 func.o 和 main.o 时,gcc 链接器能够解析对 func() 的函数调用,并且当最终的可执行文件 main 执行时,我们会看到 printf()在正在执行的函数 func() 内部。
链接器在哪里找到函数 printf() 的定义?由于链接器没有给出任何错误,这肯定意味着链接器找到了 printf() 的定义。printf() 是在 stdio.h 中声明并定义为标准“C”共享库 (libc.so) 的一部分的函数
我们没有将此共享对象文件链接到我们的程序。那么,这是如何工作的呢?使用ldd工具一探究竟,它会打印出每个程序所需的共享库或命令行指定的共享库。
在“main”可执行文件上执行 ldd,它将显示以下输出。
$ ldd main
linux-vdso.so.1 => (0x00007fff1c1ff000)
libc.so.6 => /lib/libc.so.6 (0x00007f32fa6ad000)
/lib64/ld-linux-x86-64.so.2 (0x00007f32faa4f000)
上面的输出表明主可执行文件依赖于三个库。上述输出中的第二行是“libc.so.6”(标准“C”库)。这就是 gcc 链接器能够解析对 printf() 的函数调用的方式。
第一个库是进行系统调用所必需的,而第三个共享库是加载可执行文件所需的所有其他共享库的库。该库将出现在每个依赖于任何其他共享库执行的可执行文件中。
在链接过程中,gcc 内部使用的命令很长,但对于用户而言,我们只需要编写即可。
$ gcc <object files> -o <output file name>
代码重定位
重定位是二进制文件中的条目,它们在链接时或运行时被填充。一个典型的重定位条目说:找到'z'的值并将该值放入偏移量'x'的最终可执行文件中
为此示例创建以下 reloc.c。
$ vi reloc.c
extern void func(void);
void func1(void)
{
func();
}
在上面的 reloc.c 中,我们声明了一个尚未提供定义的函数 func(),但我们在 func1() 中调用了该函数。
从 reloc.c 创建一个目标文件 reloc.o,如下所示。
$ gcc -c reloc.c -o reloc.o
使用 readelf 实用程序查看此目标文件中的重定位,如下所示。
$ readelf --relocs reloc.o
Relocation section '.rela.text' at offset 0x510 contains 1 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000005 000900000002 R_X86_64_PC32 0000000000000000 func - 4
...
在我们制作 reloc.o 时,func() 的地址是未知的,因此编译器会留下 R_X86_64_PC32 类型的重定位。这种重定位间接表示“在最终可执行文件中填充函数 func() 的地址,偏移量为 000000000005”。
上述重定位对应于目标文件 reloc.o 中的 .text 部分(再次需要了解 ELF 文件的结构才能理解各个部分),因此让我们使用 objdump 实用程序反汇编 .text 部分:
$ objdump --disassemble reloc.o
reloc.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <func1>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: e8 00 00 00 00 callq 9 <func1+0x9>
9: c9 leaveq
a: c3 retq
在上面的输出中,偏移量“5”(相对于起始地址 0000000000000000 的值为“4”的条目)有 4 个字节等待写入函数 func() 的地址。
因此,函数 func() 有一个未决的重定位,当我们将 reloc.o 与包含函数 func() 定义的目标文件或库链接时,它将得到解决。
让我们试试看这个重定位是否得到解决。这是另一个提供 func() 定义的 main.c 文件:
$ vi main.c
#include<stdio.h>
void func(void) // Provides the defination
{
printf("\n Inside func()\n");
}
int main(void)
{
printf("\n Inside main()\n");
func1();
return 0;
}
从 main.c 创建 main.o 目标文件,如下所示。
$ gcc -c main.c -o main.o
将 reloc.o 与 main.o 链接并尝试生成如下所示的可执行文件。
$ gcc reloc.o main.o -o reloc
再次执行objdump,看看重定位是否已经解决:
$ objdump --disassemble reloc > output.txt
我们重定向了输出,因为可执行文件包含大量信息,我们不想迷失在标准输出上。
查看 output.txt 文件的内容。
$ vi output.txt
...
0000000000400524 <func1>:
400524: 55 push %rbp
400525: 48 89 e5 mov %rsp,%rbp
400528: e8 03 00 00 00 callq 400530 <func>
40052d: c9 leaveq
40052e: c3 retq
40052f: 90 nop
...
在第 4 行中,我们可以清楚地看到我们之前看到的空地址字节现在填充了函数 func() 的地址。
总而言之,gcc 编译器链接是一片广阔的海洋,无法在一篇文章中涵盖。尽管如此,本文还是尝试剥离链接过程的第一层,让您了解在承诺链接不同目标文件以生成可执行文件的 gcc 命令下会发生什么。
- 点赞
- 收藏
- 关注作者
评论(0)