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

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

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

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

常用工具

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

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

工具功能
strace跟踪程序执行过程中产生的系统调用及接收到的信号
readelf用于查看ELF格式的文件信息
file用于辨识文件类型
objdump以一种可阅读的格式让你更多地了解二进制文件可能带有的附加信息
ldd列出一个程序所需要得动态链接库
hexdumphexdump主要用来查看“二进制”文件的十六进制编码
ar创建静态库,插入/删除/列出/提取 成员函数
strings列出目标文件中所有可打印的字符串
nm列出目标文件中符号表所定义的符号
strip从目标文件中删除符号表的信息
size列出目标文件中各个段的大小

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]来查看。下表是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 := -Osa.out: a.o b.o main.ogcc -static -Wl,--verbose a.o b.o main.oa.o: a.cgcc $(CFLAGS) -c a.cb.o: b.cgcc $(CFLAGS) -c b.cmain.o: main.cgcc $(CFLAGS) -c main.cclean: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 中的代码

下图中的 hello-dyhello-st 是同一个hello源文件hello.c分别动态 / 静态链接后生成的可执行文件的大小,大家可以感受一下,查了一百倍。而且这只是链接了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, 0x90ret

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

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

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

LIB := /tmp/libhuge.soall: $(LIB) a.out$(LIB): huge.Sgcc -fPIC -shared huge.S -o $@a.out: huge.c $(LIB)gcc -o $@ huge.c -L/tmp -lhugeclean: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`
doLD_LIBRARY_PATH=/tmp ./a.out &
donewait
# 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, %eaxret

比如上面这段代码,它很简单,就是返回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), %eaxret

这相当于这样一段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), %rdicall *%rdiret

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

总结

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

  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函数之前执行以及在程序终止后完成一些任务

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

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

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

我们之前提到过,静态链接的可执行文件的其实地址就是本文件的_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个函数,则打包库后,只会链接用到那个函数)。另一个好处就是使用静态库只需写一个库文件名,而不需要写一长串目标文件名。

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

https://blog.csdn.net/xuehuafeiwu123/article/details/72963229

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/532355.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

java 按钮 监听_Button的四种监听方式

Button按钮设置点击的四种监听方式注&#xff1a;加粗放大的都是改变的代码1.使用匿名内部类的形式进行设置使用匿名内部类的形式&#xff0c;直接将需要设置的onClickListener接口对象初始化&#xff0c;内部的onClick方法会在按钮被点击的时候执行第一个活动的java代码&#…

linux查看java虚拟机内存_深入理解java虚拟机(linux与jvm内存关系)

本文转载自美团技术团队发表的同名文章https://tech.meituan.com/linux-jvm-memory.html一, linux与进程内存模型要理解jvm最重要的一点是要知道jvm只是linux的一个进程,把jvm的视野放大,就能很好的理解JVM细分的一些概念下图给出了硬件系统进程三个层面内存之间的关系.从硬件上…

java function void_Java8中你可能不知道的一些地方之函数式接口实战

什么时候可以使用 Lambda&#xff1f;通常 Lambda 表达式是用在函数式接口上使用的。从 Java8 开始引入了函数式接口&#xff0c;其说明比较简单&#xff1a;函数式接口(Functional Interface)就是一个有且仅有一个抽象方法&#xff0c;但是可以有多个非抽象方法的接口。 java8…

java jvm内存地址_JVM--Java内存区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域&#xff0c;如图&#xff1a;1.程序计数器可以看作是当前线程所执行的字节码的行号指示器&#xff0c;通俗的讲就是用来指示执行哪条指令的。为了线程切换后能恢复到正确的执行位置Java多线程是…

java情人节_情人节写给女朋友Java Swing代码程序

马上又要到情人节了&#xff0c;再不解风情的人也得向女友表示表示。作为一个程序员&#xff0c;示爱的时候自然也要用我们自己的方式。这里给大家上传一段我在今年情人节的时候写给女朋友的一段简单的Java Swing代码&#xff0c;主要定义了一个对话框&#xff0c;让女友选择是…

java web filter链_filter过滤链:Filter链是如何构建的?

在一个Web应用程序中可以注册多个Filter程序&#xff0c;每个Filter程序都可以针对某一个URL进行拦截。如果多个Filter程序都对同一个URL进行拦截&#xff0c;那么这些Filter就会组成一个Filter链(也叫过滤器链)。Filter链用FilterChain对象来表示&#xff0c;FilterChain对象中…

java final static_Java基础之final、static关键字

一、前言关于这两个关键字&#xff0c;应该是在开发工作中比较常见的&#xff0c;使用频率上来说也比较高。接口中、常量、静态方法等等。但是&#xff0c;使用频繁却不代表一定是能够清晰明白的了解&#xff0c;能说出个子丑演卯来。下面&#xff0c;对这两个关键字的常见用法…

java语言错误的是解释运行的_Java基础知识测试__A卷_答案

考试宣言:同学们, 考试考多少分不是我们的目的! 排在班级多少的名次也不是我们的初衷!我的考试的目的是要通过考试中的题目,检查大家在这段时间的学习中,是否已经把需要掌握的知识掌握住了,如果哪道题目你不会做,又或者做错了, 那么不用怕, 考完试后, 导师讲解的时候你要注意听…

java 持续集成工具_Jenkins-Jenkins(持续集成工具)下载 v2.249.2官方版--pc6下载站

Jenkins是一款基于java开发的持续集成工具&#xff0c;是一款开源软件&#xff0c;主要用于监控持续重复的工作&#xff0c;为开发者提供一个开发易用的软件平台&#xff0c;使软件的持续集成变成可能。。相关软件软件大小版本说明下载地址Jenkins是一款基于java开发的持续集成…

java中线程调度遵循的原则_深入理解Java多线程核心知识:跳槽面试必备

多线程相对于其他 Java 知识点来讲&#xff0c;有一定的学习门槛&#xff0c;并且了解起来比较费劲。在平时工作中如若使用不当会出现数据错乱、执行效率低(还不如单线程去运行)或者死锁程序挂掉等等问题&#xff0c;所以掌握了解多线程至关重要。本文从基础概念开始到最后的并…

java类构造方法成员方法练习_面向对象方法论总结 练习(一)

原标题&#xff1a;面向对象方法论总结 & 练习(一)学习目标1.面向对象与面向过程2.类与对象的概念3.类的定义&#xff0c;对象的创建和使用4.封装5.构造方法6.方法的重载内容1.面向对象与面向过程为什么会出现面向对象反分析方法&#xff1f;因为现实世界太复杂多变&#x…

mysql 统计查询不充电_MySql查询语句介绍,单表查询,来充电吧

mysql在网站开发中&#xff0c;越来越多人使用了&#xff0c;方便部署&#xff0c;方便使用。我们要掌握mysql,首先要学习查询语句。查询单个表的数据&#xff0c;和多个表的联合查询。下面以一些例子来先简单介绍下单表查询。操作方法01首先看下我们例子用到的数据表&#xff…

MySQL线上优化_线上MySQL千万级大表,如何优化?

前段时间应急群有客服反馈&#xff0c;会员管理功能无法按到店时间、到店次数、消费金额进行排序。经过排查发现是 SQL 执行效率低&#xff0c;并且索引效率低下。图片来自 Pexels应急问题商户反馈会员管理功能无法按到店时间、到店次数、消费金额进行排序&#xff0c;一直转圈…

php创建表设置编码,教您在Zend Framework里如何设置数据库编码以及怎样给数据表设定前缀!...

当我们在开发项目时..大家都会遇到一个问题就是:数据库的编码问题.当然我们不用Zend Framework做为项目开发的框架时..我们可以很快,很容易搞定这个小问题..但是当我们要使用Zend Framewok开发项目时..我们可能一时会不知道如何解决这个小问题..比如我就是这样的人..在开发这个…

python 怎么将数组转为列表_怎么将视频转为GIF动态图 表情包怎么制作

说到GIF&#xff0c;大家应该都不陌生了吧&#xff01;尤其是在聊天中使用较多&#xff0c;似乎一言不合就开启了斗图模式&#xff0c;但是我们平时使用的GIF一般都是软件中自带的&#xff0c;其实自己制作也是很方便的&#xff0c;而且会发现很有趣&#xff0c;不但可以直接录…

proteus里面没有stm32怎么办_嵌入式单片机之stm32串口你懂了多少!!

stm32作为现在嵌入式物联网单片机行业中经常要用多的技术&#xff0c;相信大家都有所接触&#xff0c;今天这篇就给大家详细的分析下有关于stm32的出口&#xff0c;还不是很清楚的朋友要注意看看了哦&#xff0c;在最后还会为大家分享有些关于stm32的视频资料便于学习参考。点击…

tomcat不能解析php,tomcat不支持php怎么办

tomcat不支持php的解决办法&#xff1a;首先将“PHP/Java Bridge”下的相关文件复制到tomcat的lib目录下&#xff1b;然后修改tomcat安装目录下conf文件夹里的“web.xml”文件&#xff1b;最后重启tomcat即可。java开发者都知道&#xff0c;tomcat是用来部署java web项目的。这…

c++ dicom图像切割_【高训智造】原创专业课堂第225期--定位滑座的线切割加工

原标题&#xff1a;【高训智造】原创专业课堂第225期--定位滑座的线切割加工欢迎来到【高训智造】原创专业课堂第225期&#xff0c;本期由郭沃沛老师给大家带来线切割小课堂。定位滑座的线切割加工郭沃沛1零件图如图1所示为定位滑座零件图&#xff0c;其材料为45钢&#xff0c;…

c iostream.源码_通达信指标公式源码精准买卖主图指标公式免费分享

V0:EMA(C,5),COLOR00FF66;V1:EMA(C,10),COLOR00FF66;V2:EMA(C,15),LINETHICK2,COLORFFFFFF;V3:EMA(C,30);V4:EMA(C,60),COLOR3366FF;年线:EMA(C,90),COLORBLUE;M1:1000*V1/V4<1015 AND 1000*V1/V4>975;M2:1000*V2/V4<1020 AND 1000*V2/V4>980;M3:1000*V3/V4<101…

4am永远 鼠标按键设置_4AM称霸PCL和PEL 绝地求生与和平精英的双端冠军 | 电玩巴士...

在《绝地求生》PCL秋季赛&#xff0c;4AM高分碾压全场斩获冠军&#xff1b;在手游和平精英PEL联赛上4AM战队再度重拳出击荣获S3总冠军。在同一时间&#xff0c;4AM战队实现了端游与手游双冠王的神迹&#xff01;要说国内第一大逃杀电竞俱乐部&#xff0c;4am自认第二&#xff0…