ELF文件格式概述(三):静态链接

October 13, 2016 at 8:58 pm

最近忙着搞毕设和玩ED6,所以好像再一次好久没有更新了。本来今天也是应该全心全意搞毕设的,不过开发环境的搭建似乎异常地耗时,所以干脆就先利用这部分时间来继续ELF的篇章了。事实上之前的loader并不需要链接这样高大上的内容,不过从全面了解ELF文件的角度来看,讨论链接相关的问题仍然是相当有必要的。不出意外的话本篇只讨论静态链接的过程。

本文分为如下几个部分:
1. 链接时的符号解析
2. 重定位表
3. 相似段合并
4. 静态库链接

链接时的符号解析

首先我们假设有这样两个文件a.c和b.c,内容如下所示:

/* a. c */
extern int shared;

int main() {
    int a = 100;
    swap(&a, &shared);
}

/* b.c */
int shared = 1;

void swap(int *a, int *b) {
    *a ^= *b ^= *a ^= *b;
}

a中声明了在外部定义的全局变量shared,而b中定义了这个变量;a中使用了b中定义的函数swap。将a.c, b.c编译成目标文件a.o, b.o时并没有解决这个变量到底在哪里定义的问题,因为两者是互不可见的。

链接的时候,我们就要解决swap和变量shared何去何从的问题。我们先从编译后的汇编代码查看一下a.c在未进行链接时如何获取shared变量的,我们使用objdump指令查看结果:

objdump -d a.o
0000000000000000 
: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 83 ec 10 sub $0x10,%rsp 8: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax f: 00 00 11: 48 89 45 f8 mov %rax,-0x8(%rbp) 15: 31 c0 xor %eax,%eax 17: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp) 1e: 48 8d 45 f4 lea -0xc(%rbp),%rax 22: be 00 00 00 00 mov $0x0,%esi 27: 48 89 c7 mov %rax,%rdi 2a: b8 00 00 00 00 mov $0x0,%eax 2f: e8 00 00 00 00 callq 34 34: 48 8b 55 f8 mov -0x8(%rbp),%rdx 38: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx 3f: 00 00 41: 74 05 je 48 43: e8 00 00 00 00 callq 48 48: c9 leaveq 49: c3 retq

高亮的两行对应shared的取值和swap的调用。可以看到在目标文件中,它们的地址都被直接使用了0,所以实际上在生成目标文件时,这些不知道的变量都用假的地址进行填充。

接下来我们看看进行链接之后的可执行文件的结果,这里我们直接使用ld进行链接:

ld -lc -e main -s a.o b.o -o ab

其中-lc表示链接上libc库,虽然我们实际上并不需要这样的库,但默认情况下我们的程序中使用了部分libc提供的堆栈保护方面的调用。-e main指定程序的入口函数,如果不指定,则是libc通用的入口函数_start。

我们可以看到我们的变量、函数都得到了正确的填充:

0000000000400270 
: 400270: 55 push %rbp 400271: 48 89 e5 mov %rsp,%rbp 400274: 48 83 ec 10 sub $0x10,%rsp 400278: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax 40027f: 00 00 400281: 48 89 45 f8 mov %rax,-0x8(%rbp) 400285: 31 c0 xor %eax,%eax 400287: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp) 40028e: 48 8d 45 f4 lea -0xc(%rbp),%rax 400292: be e8 04 60 00 mov $0x6004e8,%esi 400297: 48 89 c7 mov %rax,%rdi 40029a: b8 00 00 00 00 mov $0x0,%eax 40029f: e8 16 00 00 00 callq 4002ba 4002a4: 48 8b 55 f8 mov -0x8(%rbp),%rdx 4002a8: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx 4002af: 00 00 4002b1: 74 05 je 4002b8 4002b3: e8 a8 ff ff ff callq 400260 <__stack_chk_fail@plt> 4002b8: c9 leaveq 4002b9: c3 retq 00000000004002ba <swap>:

重定位表

上一篇我们查看SimpleSection.o的段时以及在介绍string table时,实际上都提到了rela.text这样的段,我们同样使用readelf命令查看一下a.o:

[ 2] .rela.text        RELA             0000000000000000  00000290
       0000000000000048  0000000000000018   I      10     1     8

这个段就是所谓的重定位表。每一个需要进行重定位的段xxx都会有一个和它相对应的rela.xxx重定位表,这里,我们主要只有.text代码段需要进行重定位。我们可以使用objdump指令的-r指令更清晰地查看这个表:

objdump -r a.o
a.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE 
0000000000000023 R_X86_64_32       shared
0000000000000030 R_X86_64_PC32     swap-0x0000000000000004
0000000000000044 R_X86_64_PC32     __stack_chk_fail-0x0000000000000004

其中最后一个函数是我们提到的堆栈保护函数,这里我们忽略它,可以看到,offset对应了这些变量在.text段中的偏移。我们也可以看一下重定位表项的结构,非常简单,就是一个偏移和一个info,这里的info包含该项的类型和在symbol table中的下标:

typedef struct
{
  Elf64_Addr	r_offset;		/* Address */
  Elf64_Xword	r_info;			/* Relocation type and symbol index */
} Elf64_Rel;

相似段合并

a.o和b.o都带有各自的.text段以及.data段,那么生成的ab如何将这些段合并呢?最直接的方式是按顺序放置,a之后接着放b,但这样子,由于内存对齐的问题,在要链接的文件非常多时就会浪费大量的内存。现今的处理策略通常是进行相似段合并,即把两者的.text段合并在一起,如我们之前看到的反汇编代码一样。
所以连接器这个时候进行的工作有两个,我们把它称为两步链接(two-pass linking)。
1. 连接器首先扫描所有的输入目标文件,然后根据section table等信息获取每个段的长度,再使用symbol table将所有的符号整合到一个全局的符号表,最后按照之前的计算对所有的段的位置进行重新编排。
2. 第二个步骤就是我们之前看到的变量的实际地址的替换过程,这依赖于第一步中得到的全局符号表,基于这个表和每个目标文件本身的重定位表,就可以对之前未知的变量地址进行替换。

静态库链接

最后我们简单讨论一下静态链接的应用。我们的第一个程序helloworld通常都会调用printf函数,但这个函数并没有在我们的程序中定义,我们只是通过stdio.h对它进行了声明,根据前面链接的原理,printf的定义所在的文件必定通过某种方式链接到了我们的程序中。事实上,在使用gcc编译的时候,编译器会默认给我们链接上/usr/lib/x86_64-linux-gnu/libc.so(不同的系统的位置可能不同)动态库,不过linux也同样提供了静态库/usr/lib/x86_64-linux-gnu/libc.a供有需要的时候使用。我们通过ar -t指令可以查看到里面包含的目标文件:

ar -t /usr/lib/x86_64-linux-gnu/libc.a

我们可以看到printf.o这个目标文件,它实际上就包含了printf函数。通常情况下静态库就是一系列目标文件的打包,而每一个目标文件通常都只包含一个函数,以保证ld进行链接选择时不会把无用的函数链接到程序当中。

关于动态链接的知识这里就暂时不讨论了,可能以后会加在这里,或者直接放在第5篇中,至于第4篇,我们用来讨论系统具体如何对ELF文件进行加载,把它load到内存中的。不过一旦毕设的环境搭好了可能最多也只是先发一些和毕设相关的技术文章,所以第4篇的坑什么时候填也是未知数了。