含大量图文解析及例程 | Linux下的ELF文件、链接、加载与库
2022-8-18 11:2:33 Author: Linux学习(查看原文) 阅读量:40 收藏

来自公众号:人人极客社区

常用工具

我们首先列出一些在接下来的介绍过程中会频繁使用的分析工具,如果从事操作系统相关的较底层的工作,那这些工具应该再熟悉不过了。不熟悉的读者可以先看一下这里的简单的功能介绍,我们会在后文中介绍一些详细的参数选项和使用场景。

另外,建议大家在遇到自己不熟悉的命令时,通过 man 命令来查看手册,这是最权威的、第一手的资料。

ELF文件详解

ELF文件的三种形式

在Linux下,可执行文件/动态库文件/目标文件(可重定向文件)都是同一种文件格式,我们把它称之为ELF文件格式。虽然它们三个都是ELF文件格式但都各有不同。以下文件的格式信息可以通过 file 命令来查看。

  1. 可重定位(relocatable)目标文件:通常是.o文件。包含二进制代码和数据,其形式可以再编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。

  2. 可执行(executable)目标文件:是完全链接的可执行文件,即静态链接的可执行文件。包含二进制代码和数据,其形式可以被直接复制到内存并执行。

  3. 共享(shared)目标文件:通常是.so动态链接库文件或者动态链接生成的可执行文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。注意动态库文件和动态链接生成的可执行文件都属于这一类。会在最后一节辨析时详细区分。

因为我们知道ELF的全称:Executable and Linkable Format,即 ”可执行、可链接格式“,很显然这里的三个ELF文件形式要么是可执行的、要么是可链接的。

其实还有一种core文件,也属于ELF文件,在core dumped时可以得到。我们这里暂且不提。

注意:在Linux中并不以后缀名作为区分文件格式的绝对标准。

节头部表和程序头表和ELF头

在我们的ELF文件中,有两张重要的表:节头部表(Section Tables)和程序头表(Program Headers)。可以通过readelf -l [fileName]和readelf -S [fileName]来查看。

但并不是所有以上三种ELF的形式都有这两张表,

  • 如果用于编译和链接(可重定位目标文件),则编译器和链接器将把elf文件看作是节头表描述的节的集合,程序头表可选。
  • 如果用于加载执行(可执行目标文件),则加载器则将把elf文件看作是程序头表描述的段的集合,一个段可能包含多个节,节头部表可选。
  • 如果是共享目标文件,则两者都含有。因为链接器在链接的时候需要节头部表来查看目标文件各个 section 的信息然后对各个目标文件进行链接;而加载器在加载可执行程序的时候需要程序头表 ,它需要根据这个表把相应的段加载到进程自己的的虚拟内存(虚拟地址空间)中。

我们在后面的还会详细介绍这两张表。

此外,整个ELF文件的前64个字节,成为ELF头,可以通过readelf -h [fileName]来查看。我们也会在后面详细介绍。

可重定位ELF文件的内容分析

#include <elf.h>,该头文件通常在/usr/include/elf.h,可以自己vim查看。

首先有一个64字节的ELF头Elf64_Ehdr,其中包含了很多重要的信息(可通过readelf -h [fileName]来查看),这些信息中有一个很关键的信息叫做Start of section headers,它指明了节头部表,Section Headers Elf64_Shdr的位置。段表中储存了ELF文件中各个的偏移量以记录其位置。ELF中的各个段可以通过readelf -S [fileName]来查看。

其中各个节的含义如下:

这样我们就把一个可重定位的ELF文件中的每一个字节都搞清楚了。

静态链接

编译、链接的需求

为了节省空间和时间,不将所有的代码都写在同一个文件中是一个很基本的需求。

为此,我们的C语言需要实现这样的需求:允许引用其他文件(C标准成为编译单元,Compilation Unit)里定义的符号。C语言中不禁止你随便声明符号的类型,但是类型不匹配是Undefined Behavior。

假如我们有三个c文件,分别是a.c,b.c,main.c:

// a.c
int foo(int a, int b){
 return a + b;
}
// b.c
int x = 100, y = 200;
// main.c
extern int x, y;
int foo(int a, int b);
int main(){
 printf("%d + %d = %d\n", x, y, foo(x, y));
}

我们在main.c中声明了外部变量x,y和函数foo,C语言并不禁止我们这么做,并且在声明时,C也不会做什么类型检查。当然,在编译main.c的时候,我们看不到这些外部变量和函数的定义,也不知道它们在哪里。

我们编译链接这些代码,Makfile如下:

CFLAGS := -Os

a.out: a.o b.o main.o
 gcc -static -Wl,--verbose a.o b.o main.o

a.o: a.c
 gcc $(CFLAGS) -c a.c

b.o: b.c
 gcc $(CFLAGS) -c b.c

main.o: main.c
 gcc $(CFLAGS) -c main.c

clean:
 rm -f *.o a.out

结果生成的可执行文件可以正常地输出我们想要的内容。

make
./a.out
# 输出:
100 + 200 = 300

我们知道foo这个符号是一个函数名,在代码区。但这时,如果我们将main.c中的foo声明为一个整型,并且直接打印出这个整型,然后尝试对其加一。即我们将main.c改写为下面这样,会发生什么事呢?

// main.c (changed)
#include <stdio.h>
extern int x, y;
// int foo(int a, int b);
extern int foo;
int main(){
        printf("%x\n", foo);
        foo += 1;
        // printf("%d + %d = %d\n", x, y, foo(x, y));
}

输出:

c337048d

Segmentation fault (core dumped)

我们发现,其实是能够打印出四个字节(整型为4个字节),但这四个字节是什么东西呢?

C语言中的类型:C语言中的其实是可以理解为没有类型的,在C语言的眼中只有内存和指针,也就是内存地址,而所谓的C语言中的类型,其实就是对这个地址的一个解读。比如有符号整型,就按照补码解读接下来的4个字节地址;又比如浮点型,就是按照IEEE754的浮点数规定来解读接下来的4字节地址。

那我们这里将符号foo定义为了整型,那编译器也会按照整型4个自己来解读它,而这个地址指针指向的其实还是函数foo的地址。那这四个字节应该就是函数foo在代码段的前四个字节。我们不妨用objdump反汇编来验证我们的想法:

objdump -d a.out

输出(节选):

我们看到,foo函数在代码段的前四个字节的地址确是就是我们上面打印输出的c3 37 04 8d(注意字节序为小端法)。

那我们接下来试图对foo进行加一操作相当于是对代码段的写操作,而我们知道内存中的代码段是 可读可执行不可写 的,这就对应了上面输出的Segmentation fault (core dumped)。

总结一下,通过这个例子,我们应当理解:

  1. 编译链接的需求:允许引用其他文件(C标准成为编译单元,Compilation Unit)里定义的符号。C语言中不禁止你随便声明符号的类型,但是类型不匹配是Undefined Behavior。
  2. C语言中类型的概念:C语言中的其实是可以理解为没有类型的,在C语言的眼中只有内存和指针,也就是内存地址,而所谓的C语言中的类型,其实就是对这个地址的一个解读。

程序的编译 - 可重定向文件

我们先用file命令来查看main.c编译生成的main.o文件的属性:

file main.o

输出:

main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

我们看到这里的main.o文件是可重定向( relocatable) 的ELF文件,这里的重定向指的就是我们链接过程中对外部符号的引用。也就是说,编译过的main.o文件对于其中声明的外部符号如foo,x,y,是不知道的。

既然外部的符号是在链接时才会被main程序知道,那在编译main程序,生成可重定向文件时这些外部的符号是怎么处理的呢?我们同样通过objdump工具来查看编译出的main.o文件(未修改的原版本):

objdump -d main.o

输出:

main在编译的时候,引用的外部符号就只能 ”留空(0)“ 了。

我们看到,在编译但还未链接的main.o文件中,对于引用的外界符号的部分是用留空的方式用0暂时填充的。即上图中红框框出来的位置。注意图中的最后一列是笔者添加的注释,指明了本行中留空的地方对应那个外部符号。

另外注意这里的%rip相对寻址的偏移量都是0,一会儿我们会讲到,在静态链接完成之后,它们的偏移量会被填上正确的数值。

我们已经知道在编译时生成的文件中外部符号的部分使用0暂时留空的,这些外部符号是待链接时再填充的。那么,我们在链接时究竟需要填充哪些位置呢?我们可以使用readelf工具来查看ELF文件的重定位信息:

readelf -r main.o

这个图中上方是readelf的结果,下面是objdump的结果,笔者在这里已经将前两个外部符号的偏移量的对应关系用红色箭头指了出来,其他的以此类推。这种对应也可以证明我们上面的分析是正确的的。

应当讲,可重定向ELF文件(如main.o)已经告诉了我们足够多的信息,指示我们应该将相应的外部符号填充到哪个位置。

另外,注意%rip寄存器指向了当前指令的末尾,也就是下一条指令的开头,所以上图中最后的偏移量要减4(如 y - 4)。

程序的静态链接

简单讲,程序的静态链接是会把所需要的文件链接起来生成可执行的二进制文件,将相应的外部符号,填入正确的位置(就像我们上面查看的那样)。

  1. 段的合并

首先会做一个段的合并。即把相同的段(比如代码段 .text)识别出来并放在一起。

  1. 重定位

重定位表,可用objdump -r [fileName] 查看。

简单讲,就是当某个文件中引用了外部符号,在编译时编译器是不会阻止你这样做的,因为它相信你会在链接时告诉它这些外部符号是什么东西。但在编译时,它也不知到这些符号具体在什么地址,因此这些符号的地址会在编译时被留空为0。此时的重定位,就是链接器将这些留空为0的外部符号填上正确的地址。

具体的链接过程,可以通过ld --verbose来查看默认的链接脚本,并在需要的时候修改链接脚本。

我们可以通过使用gcc的 -Wl,--verbose将--verbose传递给链接器ld,从而直接观察到整个静态链接的过程,包括:

  • ldscript里面各个section是按照何种顺序 “粘贴”
  • ctors / dtors (constructors / destructores) 的实现,( 我们用过__attribute__((contructor)) )
  • 只读数据和读写数据之间的padding,. = DATA_SEGMENT_ALIGN …

我们可以通过objdump来查看静态链接完成以后生成的可执行文件a.out的内容:

objdump -d a.out

注意,这个a.out的objdump结果图要与我们之前看到的main.o的objdump输出对比着来看。

我们可以看到,之前填0留空的地方都被填充上了正确的数值,%rip相对寻址的偏移量以被填上了正确的数值,而且objdump也能够正确地解析出我们的外部符号名(最后一列)的框。

静态链接库的构建与使用

假如我们要制作一个关于向量的静态链接库libvector.a,它包含两个源代码addvec.c和multvec.c如下:

// addvec.c
int addcnt = 0;

void addvec(int *x, int *y, int*z, int n){
 int i;
 addcnt++;

 for (i=0; i<n; i++) z[i] = x[i] + y[i];
}

// multvec.v
int multcnt = 0;

void multvec(int *x, int *y, int*z, int n){
 int i;
 multcnt++;

 for (i=0; i<n; i++) z[i] = x[i] *  y[i];
}

我们只需要这样来进行编译:

gcc -c addvec.c multvec.c

ar rcs libvector.a addvec.o multvec.o

假如我们有个程序main.c要调用这个静态库libvector.a:

// main.c
#include <stdio.h>
#include "vector.h"

int x[2] = {12};
int y[2] = {34};
int z[2];

int main(){
 addvec(x, y, z, 2);
 printf("z = [%d %d]\n", z[0], z[1]);
 return 0;
}

// vector.h
void addvec(int*, int*, int*, int);
void multvec(int*, int*, int*, int);

只需要在这样编译链接即可:

gcc -c main.c

gcc -static main.o ./libvector.a

静态链接过程图示

我们以使用刚才构建的静态库libvector.a的程序为例,画出静态链接的过程。

入口函数和运行库

入口函数

初学者可能一直以来都认为C程序的第一条指令就是从我们的main函数开始的,实际上并不是这样,在main开始前和结束后,系统其实帮我们做了很多准备工作和扫尾工作,下面这个例子可以证明:

我们有两个C代码:

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

__attribute((constructor)) void before_main()
printf("%s\n",__FUNCTION__); }

int main() {
    printf("%s\n",__FUNCTION__);
}

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

void post(void)
{
    printf("goodbye!\n");
}

int main()
{
    atexit(&post);
    printf("exiting from main\n");
}

分别编译运行这两个程序,输出结果分别为:

# entry.c
before_main
main
# atexit.c
exiting from main
goodbye!

可见,在main开始前和结束后,其实还有一部分程序在运行。

事实上操作系统装载程序之后首先运行的代码并不是我们编写的main函数的第一行,而是某些运行库的代码,它们负责初始化main函数正常执行所需要的环境,并负责调用main函数,并且在main返回之后,记录main函数的返回值,调用atexit注册的函数,最后结束进程。以Linux的运行库glibc为例,所谓的入口函数,其实 就是指ld 默认的链接脚本所指定的程序入口_start (默认情况下)。

运行库

glibc = GNU C library

Linux环境下的C语言运行库glibc包括:

  • 启动和退出相关的函数
  • C标准库函数的实现 (标准输入输出,字符处理,数学函数等等)

事实上运行库是和平台相关的,和操作系统联系的非常紧密,我们可以把运行库理解成我们的C语言(包括c++)程序和操作系统之间的抽象层,使得大部分时候我们写的程序不用直接和操作系统的API和系统调用直接打交道,运行库把不同的操作系统API抽象成相同的库函数,方便应用程序的使用和移植。

Glibc有几个重要的辅助程序运行的库 /usr/lib64/crt1.o, /usr/lib64/crti.o, /usr/lib64/crtn.o。

其中crt1包含了基本的启动退出代码, ctri和crtn包含了关于.init段及.finit段相关处理的代码(实际上是_init()和_finit()的开始和结尾部分)

Glibc是运行库,它对语言的实现并不太了解,真正实现C++语言特性的是gcc编译器,所以gcc提供了两个目标文件crtbeginT.o和crtend.o来实现C++的全局构造和析构 – 实际上以上两个高亮出来的函数就是gcc提供的,有兴趣的读者可以自己翻阅gcc源代码进一步深入学习。

几组概念的辨析

动态链接的可执行文件和共享库文件的区别

问题: 可执行文件和动态库之间的区别?我们在第一节中提到过动态链接的可执行文件和动态库文件file命令的查看结果是类似的,都是shared object,一个不同之处在于可执行文件指明了解释器intepreter:

可执行文件和动态库之间的区别,简单来说:可执行文件中有main函数,动态库中没有main函数,可执行文件可以被程序执行,动态库需要依赖程序调用者。

在可执行文件的所有符号中,main函数是一个很特别的函数,对C/C++程序开发人员来说,main函数是整个程序的起点;但是,main函数却不是程序启动后真正首先执行的代码。

除了由程序员编写的源代码编译成目标文件进而链接到程序内存映射,还有一部分机器指令代码是在链接过程中添加到程序内存映射中。比如,程序的启动代码,放在内存映射的起始处,在执行main函数之前执行以及在程序终止后完成一些任务编译动态库时,链接器没有添加这部分代码。这是可执行文件和动态库之间的区别。

静态链接 / 动态链接的可执行文件的第一条指令地址

  • 静态链接可执行文件的第一条指令地址

我们之前提到过,静态链接的可执行文件的其实地址就是本文件的_strat,即readelf -h所得到的的起始地址。对于一个hello程序:

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

int main(){
    printf("Hellow World.\n");
    return 0;
}

我们先用选项-static来静态链接它,得到hello-st:

gcc -static hello.c -o hello-st

我们先用file命令看一下:

它是静态链接的可执行文件。

我们用readelf -h查看其入口地址,并在gdb中starti查看它实际的第一条指令的地址:

可以看到,与我们的预期是一致的,确是是从文件本身真正的入口地址entry point0x400a50开始执行第一条指令。而在动态链接的可执行文件中,我们将看到不同。

  • 动态链接的可执行文件的第一条指令地址

我们现在动态链接(默认)编译hello程序得到hello-dy:

gcc hello.c -o hello-dy

还是先来file一下:

我们看到hello-dy是一个动态链接的共享目标文件,当然它也是可执行的,共享库文件和可执行的共享目标文件的区别我们上面已经介绍过了。大家注意,这里还多了一个奇怪的家伙:解释器,interpreter /lib64/ld-linux-x86-64.so.2。

实际上,它就是动态链接文件的链接加载器。我们之前已经介绍过,在动态链接的可执行文件中,外部符号的地址在程序加载、运行的过程中才被确定下来。这个链接加载器 ld 就是负责完成这个工作的。当 ld 将外部符号的地址都确定好之后,才将指令指针执行程序本身的_start。也就是说,在动态链接的可执行文件中,第一条指令应该在链接加载器 ld 中。我们接下来还是通过readelf -h和gdb来验证一下。

可以看到,我们的动态链接的可执行程序的第一条指令的地址并不是本文件的entry point 0x530,而是链接加载器 ld 的第一条指令_start的地址 0x7ffff7dd4090。

这就验证了我们上面的说法:动态链接的可执行文件的第一条指令是链接加载器的程序入口,它会完成外部符号地址的绑定,然后将控制权交还给程序本身,开始执行。

静态库和共享库

库:有时候需要把一组代码编译成一个库,这个库在很多项目中都要用到,例如libc就是这样一个库,我们在不同的程序中都会用到libc中的库函数(例如printf)。

共享库和静态库的区别:在链接libc共享库时只是指定了动态链接器和该程序所需要的库文件,并没有真的做链接,可执行文件调用的libc库函数仍然是未定义符号,要在运行时做动态链接。而在链接静态库时,链接器会把静态库中的目标文件取出来和可执行文件真正链接在一起。

  • 静态库链接后,指令由相对地址变为绝对地址,各段的加载地址定死了。
  • 共享库链接后,指令仍是相对地址,共享库各段的加载地址并没有定死,可以加载到任意位置。

静态库好处:静态库中存在很多部分,链接器可以从静态库中只取出需要的部分来做链接 (比如main.c需要stach.c其中的一个函数,而stach.c中有4个函数,则打包库后,只会链接用到那个函数)。另一个好处就是使用静态库只需写一个库文件名,而不需要写一长串目标文件名。

--- EOF ---
推荐↓↓↓

文章来源: http://mp.weixin.qq.com/s?__biz=MzI4MDEwNzAzNg==&mid=2649458165&idx=2&sn=e6b9dbe4c9a1b693adb7fd2edc577669&chksm=f3a2a486c4d52d9068cb93786eb66f50f612b7278761f2be3f63fc3ec9c506813cc1fc03175b#rd
如有侵权请联系:admin#unsafe.sh