IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 系统运维 -> Linux下的ELF文件、链接、加载与库(含大量图文解析及例程) -> 正文阅读

[系统运维]Linux下的ELF文件、链接、加载与库(含大量图文解析及例程)

Linux下的ELF文件、链接、加载与库

链接是将将各种代码和数据片段收集并组合为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行与编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是被加载器加载到内存执行时;甚至执行于运行时,也就是由应用程序来执行。

本文主要参考[原创] Linux环境下:程序的链接, 装载和库[完结] 2020 南京大学 “操作系统:设计与实现” (蒋炎岩)两个视频课程,并有CSAPP中介绍的一些内容。

常用工具

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

工具功能
strace跟踪程序执行过程中产生的系统调用及接收到的信号
readelf用于查看ELF格式的文件信息
file用于辨识文件类型
objdump以一种可阅读的格式让你更多地了解二进制文件可能带有的附加信息
ldd列出一个程序所需要得动态链接库
hexdumphexdump主要用来查看“二进制”文件的十六进制编码

ELF文件详解

ELF文件的四种形式

  1. 可执行性文件,excutable,静态链接生成
  2. 可重定位文件,.o文件
  3. 共享目标文件,.so文件,注意共享库文件和动态链接生成的可执行文件都属于这一类
  4. 还有一种core文件

可重定位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]来查看。下表是up主@fengzimu2003总结的ELF文件的内容:
在这里插入图片描述

其中各个节的含义如下:

名称意义
.text已编译程序的机器代码
.rodata只读数据
.data已初始化的全局变量和静态变量
.bss未初始化的全局变量和静态变量
.symtab一个符号表,存放在程序中定义和引用的函数和全局变量的信息
.rel.text一个.text节中位置的列表,当链接器把其他文件和目标文件组合时,需要修改这些位置
.rel.data被模块引用或定义的所有全局变量的重定位信息
.debug一个调试符号表,其条目是程序中定义的局部变量和类型定义,需要-g才有
.line原始C源程序中的行号和.text节中机器指令的映射,需要-g才有
.strtab一个字符串表

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

静态链接

编译、链接的需求

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

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

假如我们有三个c文件,分别是a.cb.cmain.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文件对于其中声明的外部符号如foox,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)识别出来并放在一起。

2 重定位

重定位表,可用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.cmultvec.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] = {1, 2};
int y[2] = {3, 4};
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的程序为例,画出静态链接的过程。

在这里插入图片描述

可执行文件的装载

进程和装载的基本概念的介绍

程序(可执行文件)和进程的区别

  • 程序是静态的概念,它就是躺在磁盘里的一个文件。
  • 进程是动态的概念,是动态运行起来的程序。

现代操作系统如何装载可执行文件

  1. 给进程分配独立的虚拟地址空间
  2. 将可执行文件映射到进程的虚拟地址空间(mmap)
  3. 将CPU指令寄存器设置到程序的入口地址,开始执行

可执行文件在装载的过程中实际上如我们所说的那样是映射的虚拟地址空间,所以可执行文件通常被叫做映像文件(或者Image文件)。

可执行ELF文件的两种视角

可执行ELF格式具有不寻常的双重特性,编译器、汇编器和链接器将这个文件看作是被区段(section)头部表描述的一系列逻辑区段的集合,而系统加载器将文件看成是由程序头部表描述的一系列段(segment)的集合。一个段(segment)通常会由多个区段(section)组成。例如,一个“可加载只读”段可以由可执行代码区段、只读数据区段和动态链接器需要的符号区段组成。

区段(section)是从链接器的视角来看ELF文件,对应段表 Section Headers,而段(segment)是从执行的视角来看ELF文件,也就是它会被映射到内存中,对应程序头表 Program Headers。
在这里插入图片描述

我们用命令readelf -a [fileName] 中的Section to Segment mapping部分来看一下可执行文件中段的映射关系。

可执行文件的程序头表

我们用readelf -h [fileName]命令查看一个可执行ELF文件的ELF头时,会发现与可重定位ELF文件的ELF头有一个重大不同:可重定位文件ELF头中 Start of program headers 为0,因为它是没有程序头表,Program Headers,Elf64_Phdr的;而在可执行ELF文件中,Start of program headers 是有值的,为64,也就是说,在可执行ELF文件中程序头表会紧接着ELF头(因为ELF头的大小即为64字节)。

我们通过readelf -l [fileName]可以直接查看到程序头表。

可执行ELF文件个进程虚拟地址空间的映射关系

我们可以通过 cat /proc/[pid]/maps 来查看某个进程的虚拟地址空间。

该虚拟文件有6列,分别为:

含义
地址虚拟内存区域的起始和终止地址
权限虚拟内存的权限,r=读,w=写,x=执行,s=共享,p=私有
偏移量虚拟内存区域在被映射文件中的偏移量
设备映像文件的主设备号和次设备号;
节点映像文件的节点号;
路径映像文件的路径

vdso的全称是虚拟动态共享库(virtual dynamic shared library),而vsyscall的全称是虚拟系统调用(virtual system call),关于这部分内容有兴趣的读者可以看看https://0xax.gitbooks.io/linux-insides/content/SysCall/syscall-3.html。

总体来说,在程序加载过程中,磁盘上的可执行文件,进程的虚拟地址空间,还有机器的物理内存的映射关系如下:

在这里插入图片描述

Linux下的装载过程

接下来我们进一步探究一下Linux是怎么识别和装载ELF文件的,我们需要深入Linux内核去寻找答案 (内核实际处理过程涉及更多的过程,我们这里主要关注和ELF文件处理相关的代码)。

当我们在bash下输入命令执行某一个ELF文件的时候,首先bash进程调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件 ,内核开始真正的装载工作。

下图是Linux内核代码中与ELF文件的装载相关的一些代码:

在这里插入图片描述

/fs/binfmt_elf.cLoad_elf_binary的代码走读:

  1. 检查ELF文件头部信息(一致性检查)
  2. 加载程序头表(可以看到一个可执行程序必须至少有一个段(segment),而所有段的大小之和不能超过64K(65536u))
  3. 寻找和处理解释器段(动态链接部分会介绍)
  4. 装入目标程序的段(elf_map)
  5. 填写目标程序的入口地址
  6. 填写目标程序的参数,环境变量等信息(create_elf_tables)
  7. start_thread会将 eip 和 esp 改成新的地址,就使得CPU在返回用户空间时就进入新的程序入口

例子:静态ELF加载器,加载 a.out 执行

我们同样以刚才介绍静态链接时的a.cb.cmain.c的例子来看一下静态链接的可执行文件的加载。

静态ELF文件的加载:将磁盘上静态链接的可执行文件按照ELF program header,正确地搬运到内存中执行。

操作系统在execve时完成:

  • 操作系统在内核态调用mmap
    • 进程还未准备好时,由内核直接执行 ”系统调用“
    • 映射好 a.out 的代码、数据、堆区、堆栈、vvar、vdso、vsyscall
  • 更简单的实现:直接读入进程的地址空间

加载完成之后,静态链接的程序就开始从ELF entry开始执行,之后就变成我们熟悉的状态机,唯一的行为就是取指执行。

我们通过readelf来查看a.out文件的信息:

readelf -h a.out

输出:

在这里插入图片描述

我们这里看到,程序的入口地址是:Entry point address: 0x400a80。我们接着用gdb来调试:

在这里插入图片描述

上图是笔者在gdb中调试的一些内容:

  1. 我们用starti来使得程序在第一条指令就停下,可以看到,程序确实是从0x400180开始的,与我们上面查到的入口地址一致。
  2. 而我们用cat /proc/[PID]/maps 来查看这个程序中内存的内容,看到我们之前提到的代码、数据、堆区、堆栈、vvar、vdso、vsyscall都已经被映射进了内存中。

调试的结果符合我们对静态程序加载时操作系统的行为的预期。

动态链接

什么是动态链接以及为什么需要动态链接

实际上,链接程序在链接时一般是优先链接动态库的,除非我们显式地使用-static参数指定链接静态库,像这样:

gcc -static hello.c

静态链接和动态链接的可执行文件的大小差距还是很显著的, 因为静态库被链接后库就直接嵌入可执行文件中了。

这样就带来了两个弊端:

  1. 首先就是系统空间被浪费了。这是显而易见的,想象一下,如果多个程序链接了同一个库,则每一个生成的可执行文件就都会有一个库的副本,必然会浪费系统空间。
  2. 再者,一旦发现了库中有bug或者是需要升级,必须把链接该库的程序找出来,然后全部需要重新编译。

libc.so中有300K 条指令,2 MiB 大小,每个程序如果都静态链接,浪费的空间很大,最好是整个系统里只有一个 libc 的副本,而每个用到 libc 的程序在运行时都可以用到 libc 中的代码

动态库的出现正是为了弥补静态库的弊端。因为动态库是在程序运行时被链接的,所以磁盘上和内存中只要保留一份副本,因此节约了磁盘空间。如果发现了bug或要升级也很简单,只要用新的库把原来的替换掉就行了。

Linux环境下的动态链接对象都是以.so为扩展名的共享对象(Shared Object).

真的是动态链接的吗?

我们常说gcc默认的链接类型就是动态链接,而且我们及其中运行的大部分进程也都是动态链接的,真的是这样的吗?我们不妨来做个实验验证一下。

我们通过创建一个动态链接库 libhuge.so, 然后创建1000个进程去调用这个库中的foo函数,该函数是128M 个 nop。如果程序不是动态链接的话,1000 * 128MB的内存占用足以撑爆大多数个人电脑的内存。而如果程序确实是动态链接的,即内存中只有一份代码,那么只会有很小的内存占用。我们是这样做的:

首先我们有huge.S

.global foo
foo:
        # 128MiB of nop
        .fill 1024 * 1024 * 128, 1, 0x90
        ret

这就是我们刚才说的一个动态链接库的源代码。我们一会儿会把他编译成 libhuge.so供我们的huge.c调用,我们的huge.c是这样的:

#include <unistd.h>
#include <stdio.h>
int main(){
	foo(); // huge code, dynamic linked
	printf("pid = %d\n", getpid());
	while (1) sleep(1);
}

它会调用foo函数,并在结束后打印自己的PID,然后睡眠。Makefile如下:

LIB := /tmp/libhuge.so

all: $(LIB) a.out

$(LIB): huge.S
	gcc -fPIC -shared huge.S -o $@

a.out: huge.c $(LIB)
	gcc -o $@ huge.c -L/tmp -lhuge

clean:
	rm -f *.so *.out $(LIB)

正如我们刚才所介绍的,我们会先将huge.S编译成动态链接库libhuge.so放在/tmp下,然后我们的huge.c回去动态链接这个库,并完成自己的代码。这还不够,我们要创建1000个进程来执行上述行为。这样才能验证我们的动态链接是不是在内存中真的只有一份代码,我们用下面的脚本来完成:

#!/bin/bash

# for i in {1...1000}
for i in `seq 1 100`
do
	LD_LIBRARY_PATH=/tmp ./a.out &
done

wait
# ps | grep "a.out" | grep -Po "^(\d)*" | xargs kill -9  用于清空生成的进程

实验证明,我们的操作系统能够很好地运行这1000个进程,并且内存只多占用了 400MB。也就是说,库中的foo函数确实是动态链接的,内存中只有一份foo的副本。

这在操作系统内核不难实现:所有以只读方式映射同一个文件的部分(如代码部分)时,都指向同一个副本,这个过程中会创建引用计数。

动态链接的例子

假如我们要制作一个关于向量的动态链接库libvector.so,它包含两个源代码addvec.cmultvec.c如下:我们只需要这样来进行编译:

gcc -shared -fpic -o libvector.so addvec.c multvec.c

其中-fpic选项告诉编译器生成位置无关代码(PIC),而-shared选项告诉编译器生成共享库。

我们现在拿一个使用到这个共享库的可执行文件来看一下,其源代码main.c

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

int addvec(int*, int*, int*, int);

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

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

注意我们在最后加了一个死循环是为了让进程保持运行,然后去查看进程的虚拟地址空间。

我们先编译源码,注意在同目录下可以直接按以下命令编译,之后我们会介绍将动态链接库放到环境目录后的编译命令。

gcc  main.c ./libvector.so 

然后先用file命令查看生成的可执行文件a.out的文件信息,再用ldd命令查看其需要的动态库,最后查看其虚拟地址空间。

file a.out

输出:

在这里插入图片描述

我们看到,该可执行文件是共享对象,并且是动态链接的。

ldd a.out

输出:

在这里插入图片描述

ldd命令就是用来查看该文件所依赖的动态链接库。

./a.out & 
cat /proc/12002/maps

输出:

在这里插入图片描述

我们看到,除了像静态链接时,进程地址空间中的堆、栈、vvarvdsovsyscall等之外,还有了许多动态链接库.so

动态链接的实现机制

程序头表

我们同样用readelf -l [fileName]来查看动态链接的可执行ELF文件的程序头表:

readelf -l a.out

在这里插入图片描述

可以看到编译完成之后地址是从 0x00000000 开始的,即编译完成之后最终的装载地址是不确定的。

关键技术

之前在静态链接的过程中我们提到过重定位的过程,那个时候其实属于链接时的重定位,现在我们需要装载时的重定位 ,主要使用了以下关键技术:

  1. PIC位置无关代码
  2. GOT全局偏移表
  3. GOT配合PLT实现的延迟绑定技术

引入动态链接之后,实际上在操作系统开始运行我们的应用程序之前,首先会把控制权交给动态链接器,它完成了动态链接的工作之后再把控制权交给应用程序。

可以看到动态链接器的路径在.interp这个段中体现,并且通常它是个软链接,最终链接在像ld-2.27.so这样的共享库上。

.dynamic段

我们来看一下和动态链接相关的.dynamic段和它的结构,.dynamic段其实就是全局偏移表的第一项,即GOT[0]。

可以通过readelf -d [fileName]来查看。

它对应的是elf.h中的Elf64_Dyn这个结构体。

动态链接器ld

对于动态链接的可执行文件,内核会分析它的动态链接器地址,把动态链接器映射到进程的地址空间,把控制权交给动态链接器。动态链接器本身也是.so文件,但是它比较特殊,它是静态链接的。本身不依赖任何其他的共享对象也不能使用全局和静态变量。这是合理的,试想,如果动态链接器都是动态链接的话,那么由谁来完成它的动态链接呢?

在这里插入图片描述

Linux的动态链接器是glibc的一部分,入口地址是sysdeps/x86_64/dl-machine.h中的_start,然后调用 elf/rtld.c 的_dl_start函数,最终调用 dl_main(动态链接器的主函数)。

动态链接过程图示

在这里插入图片描述

动态链接库的构建与使用

创建自己的动态链接库

创建号一个动态链接库(如我们的libvector.so)之后,我们肯定不可能只在当前目录下使用它,那样他就不能被叫做 ”库“了。

为了在全局使用动态链接库,我们可以将我们自己的动态链接库移动到/usr/lib下:

sudo mv libvector.so /usr/lib

之后我们只要在需要使用到相关库时加上-l[linName]选项即可,如:

gcc main.c -lvector

大家也注意到了,上面的命令要用到管理员权限sudo。适应为/usr/lib/lib是系统级的动态链接目录,我们要创建自己的第三方库最好不要直接放在这个目录中,而是创建一个自己的动态链接库目录,并将这个目录添加到环境变量 LD_LIBRARY_PATH 中:

mkdir /home/song/dynlib
mv libvector.so /home/song/dynlib
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/song/dynlib

命名规范

动态链接库要命名为:lib[libName].so 的形式。

实现动态链接及实际ELF的动态链接

想必大家看了上面一节对动态链接的介绍,已经明白动态链接以及动态链接库的大体过程和用法,但是对其中具体的实现细节还是比较迷惑。本节是笔者在听南大蒋炎岩老师的录播课程时做的笔记。蒋老师从分析实现一个简易的动态链接的三个等级的需求讲起,逐步引出了上面笔者提到的动态链接的三个关键技术:PIC、GOT并加以介绍,最后通过介绍实际中ELF的动态加载过程,介绍GOT、PLT配合实现的lazy symbol resolve。想要更加深入地理解动态链接的实现过程的朋友可以读一下本节。如果笔者的笔记有令人疑惑的地方,也可以去看蒋老师在B站的录播课程

讲解的总体思路如下:

我们通过逐步把需求进行分解,从加载的视角理解链接:

  1. 需要加载一段代码(foo):PIC(通过使用PC相对寻址)+ mmap
  2. 代码需要伴随数据(bar):数据也使用PC相对寻址 + mmap
  3. 需要解析动态符号(baz):查表(GOT)、优化:PLT,lazy symbol resolve

实现动态链接与加载

我们要实现动态链接,需要具体做到哪些事情呢?我们希望有一个库函数,其中包含一些代码,所有的进程链接这一段代码,这段代码在内存中只有一份拷贝。

实现动态加载(1)

需求1:加载纯粹的代码

编译成位置无关代码(Position Independent Code, PIC)即可,即引用代码(跳转)全部使用PC相对寻址。x86已经是这样了。直接把代码mmap进地址空间就行了。

# foo.S
.global fool
foo:
	movl $1, %eax
	ret

比如上面这段代码,它很简单,就是返回1。

实现动态加载(2)

需求2:动态链接库只有纯粹的代码没有数据可不行,我们要能加载代码,并且代码有附带的数据。

这也好办,将代码和数据放在一起,然后都使用PC相对寻址就好了。

对于x86不支持rip相对寻址,我们可以通过 call __i686. get_pc_thunk.bx 来得到下条指令的地址。

我们有这样一段代码:

# bar.S
x:      # 数据不能共享 (MAP_PRIVATE 方式映射)
        .int 0

.global bar
bar:
        addl $1, x(%rip)
        movl x(%rip), %eax
        ret

这相当于这样一段C代码:

int bar(){
	static int x = 0;
  return ++x;
}

即在静态区定义一个变量x,然后每次调用bar函数时都会将x加一并返回。这也是一段位置无关代码,也可以直接mmap到内存中去执行。

实现动态加载(3)

需求3:比较难的是,一个文件或者一个动态链接库想要访问另外一个动态链接库导出的符号。因为我们想要知道的符号(比如bar)也是动态加载的,也就是说,符号的地址是运行(加载)的时候才能确定的。而我们在编译(比如编译baz时)的时候无法知道动态加载的符号bar的地址。即允许访问其他动态链接库导出的符号(代码 / 数据)。

解决方法是我们用一张表,编译时编译成:call *table[bar]bar.o会先被映射到进程的地址空间中,然后,我们要将baz.o映射到地址空间时,我们会给baz所保有的这张表中bar所对应的表项填上正确的数值,即此时已知的bar的地址。即我们为每个动态加载的符号(代码 / 数据)创建一张变,在运行时每次用到这些动态符号时,才解析符号的地址。

.global ..bar 
..bat: bar:
	.quad 0

.global baz
baz:
	movq baz(%rip), %rdi
	call *%rdi
	ret

重填(相当于在运行时重做静态链接),这样行吗?不行,因为这样违背了我们动态链接的初衷:希望整个内存中只有一份代码的副本,而每次重填会导致每次都在内存中多一份代码的副本。而上面的解决方案,只有这张表,是需要复制的,这大幅减少了系统中冗余的内存。

总结

总结一下,实现动态链接和加载就是两个关键点:

  1. PIC位置无关代码,不管是代码还是数据,我们全部都要通过PC相对寻址,来使得它们是位置无关代码。
  2. 要引用动态链接库中的符号(编译时不知道)时,我们创建一张表,在运行(加载)时将其填上正确的地址。

例子

假如我们是十几种有这样一个动态链接(共享代码)的需求:

  • main需要调用libc中的printf
  • printf需要调用libfoo中的foo

我们知道,动态加载的程序最先并不是从main的入口地址开始执行的。而是需要先由加载器libld进行动态加载。libld由操作系统加载,按照相互依赖相反的方向加载:

  1. libld加载libfoo,一切顺利
  2. libld加载libc
    • libcfoo的调用在编译时,被编译为call *libc.tab[FOO]
    • libld调用dl_runtime_resolve解析符号,填入libc.tab[FOO],因为此时libfoo已经被加载到地址空间中了,foo地址是已知的
  3. libld完成main的初始化
    • a.outprintf的调用在编译时,被编译成call *a.out.tab[PRINTF]
    • libld机械printf的地址,填入call *a.out.tab[PRINTF],因为此时libc已经被加载到地址空间中了,printf地址是已知的

所有的填表都完成之后,就跳转到main的入口地址开始执行。

ELF 动态链接与加载

上面一种简化版的动态加载过程,实际的ELF动态加载比这要复杂一点。

GOT (Global Offset Table)

GOT

GOT:shared object用来存储动态符号的表格。库函数有,可执行文件也有。

所以用file命令查看a.outlibc.so时都是 ”shared object“ 。也就是说我们生成的可执行文件其实和库函数是同一种文件格式。它们都需要调用其他的动态链接库中的符号。

GOT中储存的数据
  • GOT[0]:.dynamic节的地址
  • GOT1:link map,用于遍历依赖的动态链接库
  • GOT2:dl_runtime_resolve 的地址,即call *GOT2 可以完成符号解析
  • GOT[i]:程序所需的动态符号的地址(printf, …)

新需求

新需求:能否降低实际没有调用到的符号的开销?

程序可能会引用很多符号,但执行时可能大部分符号都没用到,逐个dl_runtime_resolve的话会造成不必要的开销。

lazy symbol resolution

想法:加载时设置为NULL,加载时来判断 / 解析

使用一小段 ”trampoline code“ 跳板代码

  • 如果符号还未解析,就解析
  • 跳转到解析后的符号执行
int print_internal(const char *fmt, ...){
  if (GOT[PRINRF]){
    GOT[PRINTF] = call_dl_runtime_reslove("printf");
  }
  return GOT[PRINTF]{...};
}

需要编译器把向printf(动态链接库)的调用翻译成call printf_internal

坏处:fast path多做一次判断:call + load + 判断 + jump,会损失一定的性能。

黑科技:让printf@GOT指向trampoline的下一条指令。

在这里插入图片描述

  • 只有两条指令:call print@plt; jmp *a.out.GOT[PRINTF]
  • 对现代处理器非常友好,因为有branch-target-buffer(BTB),几乎不损失性能。

Takeaways and Wrap-up

我们通过逐步把需求进行分解,从加载的视角理解链接:

  1. 需要加载一段代码(foo):PIC(通过使用PC相对寻址)+ mmap
  2. 代码需要伴随数据(bar):数据也使用PC相对寻址 + mmap
  3. 需要解析动态符号(baz):查表(GOT)、优化:PLT,lazy symbol resolve

入口函数和运行库

入口函数

初学者可能一直以来都认为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函数之前执行以及在程序终止后完成一些任务

编译动态库时,链接器没有添加这部分代码。这是可执行文件和动态库之间的区别。

静态库和共享库

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

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

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

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

Ref

Computer Systems A Programmer’s Perspective - by Randal E. Bryant & David O’Hallaron

https://www.bilibili.com/video/BV1hv411s7ew

https://blog.csdn.net/weixin_44966641/article/details/120616894?spm=1001.2014.3001.5501

https://www.bilibili.com/video/BV1N741177F5?p=15

https://www.jianshu.com/p/7c609b70acbd

  系统运维 最新文章
配置小型公司网络WLAN基本业务(AC通过三层
如何在交付运维过程中建立风险底线意识,提
快速传输大文件,怎么通过网络传大文件给对
从游戏服务端角度分析移动同步(状态同步)
MySQL使用MyCat实现分库分表
如何用DWDM射频光纤技术实现200公里外的站点
国内顺畅下载k8s.gcr.io的镜像
自动化测试appium
ctfshow ssrf
Linux操作系统学习之实用指令(Centos7/8均
上一篇文章      下一篇文章      查看所有文章
加:2021-10-07 14:11:49  更:2021-10-07 14:12:43 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/15 18:39:36-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码