0%

67:链接(3)

介绍完了目标文件是怎么链接到可执行程序的,我们不妨进一步学习可执行程序是怎么被加载到内存中并运行的。以及动态链接库是怎么和程序一起被加载的。

可执行目标文件

我们已经学习了链接器是怎么将多个目标文件合并成一个可执行目标文件的。我们的C程序,从一开始的一组ASCII文本文件,被转换成了一个二进制文件。这个二进制文件包含加载程序到内存并运行它所需的所有信息。一个典型的ELF可执行文件有以下内容:

image.png

和可重定位目标文件还是有较大的区别。ELF头描述文件的总体格式。它还包括程序的入口点(entryPoint),也就是当程序运行时要执行的第一条指令的地址。.text .rodata .data节和可重定位目标文件中的节是相似的。此外,还有一个.init节,这个节中定义了一个小函数,叫做_init,程序初始化代码时会调用它。同时,因为可执行文件是完全链接的,所以不再需要rel

ELF可执行文件被设计的很容易加载到内存中,可执行文件的连续的片被映射到连续的内存段。程序头部表则描述了这种映射关系。我们使用objdump -p来查看:

1
2
3
4
5
6
# 只读代码段
LOAD off 0x0000000000001000 vaddr 0x0000000000401000 paddr 0x0000000000401000 align 2**12
filesz 0x000000000007d80d memsz 0x000000000007d80d flags r-x
# 读/写数据段
LOAD off 0x00000000000a4f50 vaddr 0x00000000004a5f50 paddr 0x00000000004a5f50 align 2**12
filesz 0x0000000000005b60 memsz 0x000000000000b2d8 flags rw-

其中:

  • off:目标文件中的段的第一个节的偏移
  • vaddr/paddr:虚拟地址/物理地址(物理地址在现代操作系统中无意义)
  • align:指定的对齐要求,使得段能够有效率的传送到内存中
  • filesz:目标文件中的段大小
  • memsz:内存中的段大小
  • flags:运行时的访问权限

我们以读写数据段的加载为例。开始于内存地址0xa4f50处,总的内存大小为0xb2d8,于是从目标文件中偏移0xa4f50处开始的.data节中的0x5b60个字节初始化。该段剩下的字节对应于运行时将被初始化为0的.bss数据。

对于任何段s,链接器必须选择一个起始地址vaddr,使得:

1
vaddr mod align = off mod align

off是段在可执行文件本身的起始位置。根据对齐要求对齐,是为了更好的优化加载的效率。会在虚拟内存中进一步学习。

加载可执行文件

当我们运行一个程序时:

1
ylin@Ylin:~/Program/test$ ./prog

由于prog不是一个内置的shell命令,所以shell会将它视作一个可执行目标文件,通过调用驻留在存储器中称为加载器的操作系统代码来运行它。任何Linux程序,都可以通过调用exevce函数来调用加载器。

记载其将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序到内存并运行的过程叫加载

为了解释加载器的运行,我们还需要认识以下每个Linux程序的内存映像:

image.png

在LInux x86_64中,代码段总是从0x400000处开始的,后面是数据段。运行时堆在数据段之后,通过调用用malloc库向上增长。堆之后的区域则是为共享库模块保留的。用户栈是从最大合法与用户地址(248-1)开始的,向低地址处生长。栈上的地址,从248处开始,是为内核中的代码和数据保留的。

不过这只是简图,实际上的内存空间分布略有不同,由于对齐有要求,段之间会有一定的间隙。而且现代编译器使用地址空间布局随机化,使得每次程序运行时这些区域的地址都会改变,但他们的相对位置是不会改变的

当加载器运行时,它会一个内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据毒啊。接下来加载器跳转到程序的入口,也就是_start函数的地址。这个函数在系统目标问及那ctrl.o中定义。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中。它负责初始化执行环境,调用用户层的main函数,处理main函数的返回值,并在需要时将控制返回给内核。

加载器的具体工作原理实际上涉及到多个方面:进程、虚拟内存、内存映射。我们之后会回头重新理解分析这个过程。

动态链接共享库

静态库仍然面临着几个问题:

  • 需要要更新和维护:这导致一个程序员如果想更新它的程序使用的库的最新版本。那他需要重新显式的链接一遍。
  • 占用较多的空间资源:我们需要明确计算机中的空间资源始终是稀缺的,我们要尽可能的利用好计算机中的磁盘空间。但是静态链接则不符合这个问题。静态链接将目标模块复制到每一个使用它的目标文件,这就导致一个系统里可能有上百个这个目标模块。

为了解决这个问题,我们就要引入共享库的概念。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,由一个叫做动态链接器的程序来执行,共享库也被称为共享目标,在LInux中以.so表示,在Windows中以.dll来表示

我们可以尝试构建一个动态链接库并使用它:

1
2
3
ylin@Ylin:~/Program/test$ gcc -o libtest.so -fpic -shared addvec.c multvec.c
# -fpic指示编译器生成位置无关代码 -shared指示链接器创建一个共享的目标文件
ylin@Ylin:~/Program/test$ gcc -o prog main.c ./libtest.so

共享库的共享方式和静态库不同。在任何给定的文件系统中,对于一个库只有一个.so文件,所有引用这个库的可执行程序,共享这个文件中的数据和代码。在内存中,一个共享库的.text节的副本,可以被不同的正在要运行的进程共享。我们会在之后详细的研究这个过程。动态链接的过程如下:

image.png

当加载器加载和运行可执行文件prog时,加载器注意到prog中包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是有一个共享目标(ld-linux.so)。加载器不会直接将控制传递给应用程序,而是加载和运行这个动态链接器。然后动态链接器通过执行下面的重定位完成链接任务:

  • 重定位libc.so的文本和数据到某个内存段
  • 重定位libtest.so的文本和数据到另一个内存段
  • 重定位prog中所有堆由libc.solibtest.so定义的符号的引用

最后动态链接器将控制传递给应用程序。此时共享库的位置就固定了,在程序执行的过程中不再改变。

从应用程序中加载和链接共享库

正常情况下,我们的动态链接是在程序加载之后,执行之前进行的。可是在一些特殊的情况下,我们需要在运行的过程中(比如热更新、插件拓展…)动态链接共享库。此时我们可以用到LInux系统为动态链接器提供的接口——允许应用程序在运行是加载和链接共享库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<dlfnc.h>

void* dlopen(const char* filename, int flag);
// 成功则返回指向句柄的指针,失败则返回NULL
// dlopen函数用来将共享库映射到内存,如果在编译时设置了 -rdynamic 那么当前可执行文件里的全局符号也可以被共享库使用。此外,RTLD_GLOBAL 设置可以让此次打开的库里的符号能被后续加载的库使用。RTLD_NOW和RTLD_LAZY分别时立即解析和延迟解析。他们可以通过|和RTLD_GLOBAL拼接

void* dlsym(void* handle, char* symbol);
// 若成功则返回指向符号的指针,失败则返回NULL
// dlsym的输入是一个指向前面已经打开了的共享库的句柄和一个symbol的名字

int dlclose(void* handle);
// 如果成功返回0,失败则返回-1
// 如果没有其他共享库还在使用这个共享库,就关闭它

const char* dlerror(void);
// 如果前面的几个函数的调用失败,则为错误信息。如果调用成功,则为NULL
// dlerror会返回有一个字符串,它描述调用时发生的最近的错误

我们可与尝试编写一个程序来在运行过程中加载我们的共享库,然后调用它的addvec例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>

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

int main(){
void* handle;
void (*addvec)(int*,int*,int*,int);
char* error;

handle = dlopen("./libtest.so",RTLD_LAZY);
if(handle==NULL){
fprintf(stderr,"%s\n",dlerror());
exit(1);
}

addvec = dlsym(handle,"addvec");
if((error=dlerror())!=NULL){
fprintf(stderr,"%s\n",error);
exit(1);
}

addvec(x,y,z,5);
printf("z = [%d %d %d %d %d]",z[0],z[1],z[2],z[3],z[4]);

if(dlclose(handle)<0){
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
return 0;
}

编译后可以正常运行!我们简单的实现了运行过程中动态库的加载和更新。