绪论
大家好,欢迎来到【程序员的自我修养】专栏。正如其专栏名,本专栏主要分享学习《程序员的自我修养——链接、装载与库》的知识点以及结合自己的工作经验以及思考。编译原理相关知识本身就比较有难度,我会尽自己最大的努力,争取深入浅出。若你希望与一群志同道合的朋友一起学习,也希望加入到我们的学习群中。文末有加入方式。
介绍
通过上一篇【程序员的自我修养08】精华!!!动态库的由来及其实现原理,大致介绍了动态链接的历史背景,以及通过示例进一步了解当今动态库的实现过程。由于篇幅有限,所以针对一些特殊场景和动态库链接的过程没有进一步展开描述。本文主要作为上文内容的补充。
共享模块的全局变量问题
针对下面的示例,我们进行思考:
//main.c
#include<stdio.h>
extern int g_shared_num;
int main()
{
g_shared_num = 1;
printf("g_shared_num = %d\n",g_shared_num);
return 0;
}
//shared.c
int g_shared_num = 0;
编译&执行:
yihua@ubuntu:~/test/globaldata$ gcc -shared -fPIC shared.c -o libshared.so
yihua@ubuntu:~/test/globaldata$ gcc main.c -lshared -L. -o main
yihua@ubuntu:~/test/globaldata$ export LD_LIBRARY_PATH=./
yihua@ubuntu:~/test/globaldata$ ./main
g_shared_num = 1
yihua@ubuntu:~/test/globaldata$
分析:
在动态库libshared.so
中,定义了一个全局变量g_shared_num
,主程序并对其进行引用。由于main
在运行阶段不会进行动态重定位的操作,因此在编译阶段就需要确认g_shared_num
,于是乎,在main
程序的.bss
段创建一个副本。如下:
000000000000075a <main>:
75a: 55 push %rbp
75b: 48 89 e5 mov %rsp,%rbp
75e: 8b 05 ac 08 20 00 mov 0x2008ac(%rip),%eax # 201010 <g_shared_num>
764: 89 c6 mov %eax,%esi
766: 48 8d 3d 97 00 00 00 lea 0x97(%rip),%rdi # 804 <_IO_stdin_used+0x4>
76d: b8 00 00 00 00 mov $0x0,%eax
772: e8 b9 fe ff ff callq 630 <printf@plt>
777: b8 00 00 00 00 mov $0x0,%eax
77c: 5d pop %rbp
77d: c3 retq
77e: 66 90 xchg %ax,%ax
yihua@ubuntu:~/test/globaldata$ nm main | grep g_shared_num
0000000000201010 B g_shared_num
由上可知,main
的汇编语句中,对g_shared_num
的引用为相对地址引用。这就存在一个问题,main
和libshared.so
中都有g_shared_num
的副本,那么在运行时,采用哪一个呢?
当前的解决方式为:ELF共享库在编译时,默认把定义在模块内部的全局变量当作定义在其它模块的全局变量,也就是通过GOT来实现变量的访问。
- 如果全局变量在可执行程序中拥有副本,那么动态链接器会在动态重定位时,修改GOT表项。
- 如果变量在共享模块中被初始化,那么动态链接器还会将该初始值复制到程序主模块中的变量副本。
- 如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。
思考:如下代码,最终输出什么呢?
//main.c
extern int shared1();
extern int shared2();
int main()
{
shared1();
shared2();
return 0;
}
//shared-1.c
#include<stdio.h>
int g_shared_num = 1;
int shared1()
{
printf("shared-1 g_shared_num = %d\n",g_shared_num);
return 0;
}
//shared-2.c
#include<stdio.h>
int g_shared_num = 2;
int shared2()
{
printf("shared-2 g_shared_num = %d\n",g_shared_num);
return 0;
}
编译:
yihua@ubuntu:~/test/globaldata$ gcc -fPIC -shared shared-1.c -o libshared1.so
yihua@ubuntu:~/test/globaldata$ gcc -fPIC -shared shared-2.c -o libshared2.so
yihua@ubuntu:~/test/globaldata$ gcc main.c -o main -L. -lshared1 -lshared2
yihua@ubuntu:~/test/globaldata$ ./main
??????????????????
可以思考一下为什么?或更换一下编译指令gcc main.c -o main -L. -lshared2 -lshared1
。再看看输出。
数据段如何实现地址无关性
如下代码:
//test.c
static int a;
static int* p = &a
分析:
我们知道上述代码中,指针p
的地址是一个绝对地址,它指向变量a
。而a
会随着加载地址的不同而导致其虚拟地址变化。这就导致对指针p
引用的代码段也需要发生变化。这就会导致共享库中代码段无法在多个进程中共享。
当前的处理方式:对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,在动态链接时,再重定位修复。如下:
如图可知,动态库中明确描述了符号p
在动态链接时,重定位为a
的地址。
动态链接的详细步骤
其实动态链接的过程可以分为三步骤:启动动态链接器本身、装载所需要的共享对象、重定位和初始化。
- 动态链接器启动。动态链接器是一个特殊的存在,虽然它是一个动态库,但是因为它的职责问题,必须要具备以下特点。
- 动态链接器本身不可以依赖其他任何共享对象。
- 动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。
至于动态链接器具体如何完成这一过程,不再赘述。
- 装载共享对象。完成链接器启动后,动态链接器将可执行文件和链接器本身的符号表合并到全局符号表中。
- 寻找可执行程序所依赖的共享对象,并将其放入到一个装载集合中。
- 链接器从集合里去一个所需要的共享对象的名字,找到相应的文件后,将其相应的代码段和数据段映射到进程空间(即创建程序与虚拟空间的映射)。若该共享对象,依赖其他共享对象,那么将依赖的共享对象名字加入到装载集合中。
- 如此循环,直到所有依赖的共享对象都被装载进来为止。当一个新的共享对象被装载进来时,它的符号表会被合并到全局符号表中。
这里面有一个问题:若可执行程序依赖liba.so
,libb.so
,若两个共享库中具备相同的符号,按照上述的加载流程,岂不是会造成全局符号表中有两个相同的符号。该场景即为上面的思考问题。
在linux下的动态链接器处理规则:当一个符号需要被加入到全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。这样一个共享对象里面的全局符号被另一个共享对象的全局符号覆盖的现象又被称为共享对象全局符号介入。
- 重定位和初始化。当完成上面的步骤后,程序依赖的所有动态库已经完成了映射并拥有了全局符号表,此时需要根据可执行文件和共享对象的重定位表,对需要重定位的位置进行修订。完成重定位后,如果共享对象有
.init
段,那么动态链接器会执行.init
中的代码,用以实现共享对象特有的初始化过程。
注:若进程的可执行文件也有.init
段,那么动态链接器不会执行它,因为可执行文件的.init
和.finit
由程序初始化代码负责执行。
- 进入程序的入口并开始执行。当上述流程完成后,动态链接器已完成全部工作,接下来就是进入可执行成的
Entry point address
并开始执行。
总结
本文是对上篇文章的补充,讲解了共享模块中全局变量的处理方式,以及如何实现共享模块中数据段的地址无关性。
再进一步讲解了动态链接的过程,其中可能会遇到的问题:共享对象全局符号介入。实际上,我在实际工作中已经遇到过了,并做过分享:坑惨啦!!!——符号冲突案例分析
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途。