首页
论坛
课程
招聘
[原创]编译与链接学习笔记(1)——静态链接
2021-7-28 23:54 5359

[原创]编译与链接学习笔记(1)——静态链接

2021-7-28 23:54
5359

0x00. 前言

最初接触编程时,我遇到过这样一个问题:将一个源代码文件编译成一个可执行文件,编译器只需要将源代码转化成可执行的机器代码(表面上);将多个源代码文件编译成一个可执行文件,编译器又是如何合并多个文件中的代码的呢?

 

学习了逆向后我知道这个过程叫做“链接”。然而链接这个概念又困扰了我很久:什么是动态链接?什么是静态链接?为什么动态链接的程序到另一台机器上就跑不起来了?等等,包括搭建环境的过程中也没少遇到过与链接相关的错误。

 

最近发现正好有那么一本书《程序员的自我修养——链接、装载与库》可以解决我的疑惑,在此将我总结的内容与大家分享。

 

环境:

  • Ubuntu 18.04
  • GCC version 7.5.0

0x01. GCC编译流程

当我们用GCC编译源代码时,可以分为4个步骤:预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。大致过程如下图所示:
图片描述
我们通过一个HelloWorld程序进行说明:

1
2
3
4
5
6
7
// hello.h
#ifndef _HELLO_H_
#define _HELLO_H_
 
void hello();
 
#endif
1
2
3
4
5
6
7
8
9
10
11
// hello.c
#include <stdio.h>
#include "hello.h"
 
void hello(){
    printf("Hello World.\n");
}
 
int main(){
    hello();
}

第一步:预处理

由预处理器cpp(C Pre-Processor)完成,主要工作是合并源文件和头文件,以及对以“#”开头的预编译指令和注释进行处理。

1
cpp hello.c > hello.i

第二步:编译

编译过程就是把预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后生成相应的汇编代码文件。这个过程相当复杂,但不是本文的重点。

1
gcc -S hello.i -o hello.s

第三步:汇编

汇编器将汇编代码转化成相应平台上的机器代码,得到.o目标文件。注意,目标文件还需要经过链接后才能执行。

1
as hello.s -o hello.o

第四步:链接

链接器将多个目标文件链接起来,输出一个可执行文件。链接是最复杂的一个过程,也是本文的重点。
使用以下命令来查看.o文件链接成可执行文件的过程:

1
gcc hello.o -o hello --verbose

输出:

1
2
/usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccpffkdK.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro -o hello /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. hello.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o
COLLECT_GCC_OPTIONS='-o' 'hello' '-v' '-mtune=generic' '-march=x86-64'

collect2是对链接器ld的一个封装,最终还是要调用ld。
可以看到除了hello.o文件,链接器还引用了大量.o后缀的目标文件,最后输出可执行文件。

0x02. 目标文件格式

由汇编器汇编后产生的.o后缀的文件叫做目标文件。目标文件从结构上讲已经是可执行文件(PE/ELF)的格式,只是还没有经过链接的过程,其中有些符号和地址还没有被调整。
用010 Editor打开一个.o文件,并使用ELF模板解析:
图片描述
PE文件和ELF文件的格式在很多篇文章里已经有讲解了,这里就不再赘述了。接下来主要讲解一下符号在链接中的作用以及ELF的符号表。

链接的接口——符号

在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名称为符号名(Symbol Name),每一个目标文件都会有一个相应的符号表(Symbol Table),这个表中记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。
符号主要有以下几种分类:

  • 定义在本目标文件的全局符号,可以被其他目标文件引用。
  • 在本目标文件中引用的全局符号,却没有定义在本目标文件,叫做外部符号(External Symbol)
  • 段名,通常由编译器产生,符号值就是段的起始地址。如代码段“.text”、数据段“.data”等。
  • 局部符号,由函数内部的局部变量产生,这些局部符号对链接过程没有作用,链接器也往往忽视他们。这也是为什么我们在用IDA反编译时看不到局部变量的名字。
  • 行号信息,通常用于调试。

ELF符号表结构

ELF文件中的符号表往往是文件中的一个段——“.symtab”。符号表的结构很简单,它是一个Elf64_Sym结构(64位ELF文件)的数组。符号表及Elf64_Sym的结构如下:

1
2
3
4
5
6
7
8
9
typedef struct
{
  Elf64_Word    st_name;        /* Symbol name (string tbl index) */
  unsigned char    st_info;        /* Symbol type and binding */
  unsigned char st_other;        /* Symbol visibility */
  Elf64_Section    st_shndx;        /* Section index */
  Elf64_Addr    st_value;        /* Symbol value */
  Elf64_Xword    st_size;        /* Symbol size */
} Elf64_Sym;

几个成员的定义如下表所示:
图片描述
st_info的低四位表示符号类型,高4位表示符号绑定信息,定义如下表:
图片描述
在010 Editor中查看main函数的符号类型和绑定信息:
图片描述
st_shndx定义符号所在段的下标:
图片描述
图片描述
main函数对应下标为1的段,即.text段。

0x03. 静态链接

当我们有两个目标文件时,如何将它们链接起来形成一个可执行文件呢?这基本上就是静态链接的核心问题。
静态链接的过程大致分为以下两个步骤:

  • 空间与地址分配
  • 符号解析与重定位

接下来通过以下两个程序进行讲解:

1
2
3
4
5
6
7
8
9
10
// a.c
#include <stdio.h>
 
int shared = 1;
 
void swap(int *a, int *b){
    int t = *a;
    *a = *b;
    *b = t;
}
1
2
3
4
5
6
7
8
9
10
// b.c
#include <stdio.h>
 
extern int shared;
extern void swap(int*, int*);
 
int main(){
    int a = 2;
    swap(&a, &shared);
}

其中b.c引用了a.c中的shared变量和print函数。

空间与地址分配

第一步:物理空间分配

物理空间分配指的是输出的可执行文件中如何为每个输入文件中的段分配空间。

 

可执行文件中的代码段和数据段都是由输入的目标文件合并而来的。对于多个输入目标文件,链接器按照相似段合并的规则将相同性质的段合并到一起,比如将所有输入文件的.text段合并到输出文件的.text段等等。
图片描述
首先GCC编译得到a.o和b.o目标文件:

1
2
gcc -c a.c -fno-stack-protector -o a.o
gcc -c b.c -fno-stack-protector -o b.o

注意这里一定要加上-fno-stack-protector选项,否则之后用ld链接会出现

1
undefined reference to `__stack_chk_fail'

的报错。

 

接着将两个目标文件链接,注意这个命令只会链接a.o和b.o文件,不会链接libc等其他库。只链接这两个目标文件的目的是方便后续分析:

1
ld a.o b.o -e main -o ab

-e main是指定可执行文件的入口点为main函数,默认为_start函数,但我们链接的这两个文件中并没有_start函数,所以手动修改为main函数。链接后得到ab可执行文件(实际上并不能执行,要链接成真正的可执行文件还要链接多个目标文件,这里为例方便理解只链接两个目标文件)。

 

以.text段为例查看链接前后段长度的变化:
a.o文件中.text段的大小为45:
图片描述
b.o文件中.text段的大小为41:
图片描述
ab.o文件中.text段的大小为86,正好是a.o文件和b.o文件两个.text段大小之和:
图片描述
.data段大小的结果也符合相似段合并的策略。

第二步:虚拟地址空间分配

接下来要引入一个概念——虚拟地址(Virtual Memory Address, VMA),即程序在进程中使用的地址。
在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没有被分配。等到链接之后,“可执行”文件ab中的各个段都被分配到了相应的虚拟地址,如链接后.text段分配到的虚拟地址为0x4000E8:
图片描述

第三步:符号地址的确定

在虚拟地址空间分配后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内的相对位置是固定的,所以这时候其实“main”、“shared”和“swap”的地址已经确定了。如swap函数在链接后的虚拟地址为0x4000E8,与.text段的虚拟地址一致,所以swap函数在合并后的.text段中的相对偏移量为0:
图片描述
main函数的虚拟地址为0x400115,在.text段内的偏移量为45,正好为a.0文件中.text段的大小,再次印证了相似段合并:
图片描述
至此静态链接的第一步——空间和地址分配已经完成。

符号解析与重定位

在完成空间和地址的分配步骤后,链接器就进入了符号解析与重定位的步骤,这也是静态链接的核心内容。

重定位

IDA打开b.o文件,查看其实如何引用shared变量和swap函数的,对比010 Editor和IDA提供的十六进制表示后我发现了问题:
图片描述
且IDA中引入了一个根本不存在的段.extern:
图片描述
在010 Editor中call指令和lea指令的偏移量均为0,而在IDA中显示的偏移量为0x4E,估计是IDA为了方便分析做出的特殊处理。这里我们按以下的字节码处理:

1
2
3
48 8D 35 00 00 00 00    lea     rsi, shared
48 89 C7                mov     rdi, rax
E8 00 00 00 00          call    swap

当源代码b.c被编译为目标文件时,编译器并不知道“shared”和“swap”的地址,所以编译器就暂时把0看做这两个符号的偏移量。(这里的lea指令和call指令按偏移量寻址)。

 

真正的地址计算工作由链接器完成。我们通过前面的空间与地址分配可以得知,链接器在完成地址和空间分配之后就可以确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行修正。在可执行文件ab中我们可以看到被修正后的指令:
图片描述
swap函数的地址为0x4000E8,与0x400137的偏移量为-0x4F,补码为0xFFFFFFB1,正好是call指令的偏移量。

 

那么链接器是怎么知道哪些指令是要被调整的呢?在目标文件中有一个叫做重定位表(Relocation Table)的结构专门用来保存与重定位相关的信息。对于每个要被重定位的ELF段都有一个对应的重定位表,例如.text段如有要被重定位的地方,就会有一个相对应的.rel.text的段保存了代码段的重定位表,数据段.data同理:
图片描述
重定位表是一个Elf64_Rel的数组,Elf64_Rel的定义如下:

1
2
3
4
5
typedef struct
{
  Elf64_Addr    r_offset;        /* Address */
  Elf64_Xword    r_info;            /* Relocation type and symbol index */
} Elf64_Rel;

图片描述
至此链接器的重定位过程已经讲解完毕。

符号解析

如果我们单独对b.o文件进行链接,会出现以下的报错:

1
2
3
b.o: In function `main':
b.c:(.text+0x16): undefined reference to `shared'
b.c:(.text+0x1e): undefined reference to `swap'

这也是我们平时在编写程序的时候最常碰到的问题之一,就是链接时符号未定义,从普通程序员的角度看,符号解析占据了链接过程的主要内容。

 

现在我们可以更加深层次的理解为什么缺少符号的定义会导致链接错误。其实重定位的过程也伴随这符号的解析过程,每个目标文件都可能定义一些符号,也可能引用定义在其他目标文件中的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,在重定位时链接器会查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

0x04. 总结

以上就是有关静态链接的所有内容了,文章中的大部分内容提炼并扩充自《程序员的自我修养——链接、装载与库》,省略了一些我认为当前阶段不重要的内容。《程序员的自我修养——链接、装载与库》是一本很好的书,大家有兴趣可以去读读原著。

 

链接的另一个核心是动态链接,这部分内容比静态链接复杂得多,文章篇幅估计也不小,所以我打算下次另开一篇文章讲解动态链接了。欢迎大家交流学习!


[注意] 欢迎加入看雪团队!base上海,招聘CTF安全工程师,将兴趣和工作融合在一起!看雪20年安全圈的口碑,助你快速成长!

收藏
点赞3
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回