背景
目前后台服务器都是由bin+so的方式构成,ServerFrame(bin)提供网络通信,内存管理,配置管理等基础的通用服务,so实现各个服务器的特有逻辑。bin文件和So公用一个全局变量G进行数据共享,在G在bin中读取相应的配置,so中根据G中的参数进行一系列的操作。bin和so还公用一些基础库的代码。
最近一件事情引起了后台同学的注意,服务器so更新后,同学执行bin文件,加载so后会发生错误,导致服务器会出现异常。
经过查看svn代码,发现了几个可疑的地方:
1.在bin和so公用的代码库里,有一个函数的定义发生了变化,增加了一个带有默认值的参数。这个函数会在so中被调用,由于带有默认参数,因此调用方式与从前一样,并没有发生改变。
2.最近的代码提交中,G的定义发生了变化,在其定义的中间添加了几个变量。
因为so编译时用的是最新代码,而bin文件并没有重新编译,因此其中的函数以及结构体的定义还都是老版本的定义,因此现在程序的结构图大致如下所示:
问题
- Q1:因为调用除的代码不会变,是否可能so中队函数的调用依旧会走到了bin中所定义的旧版本的函数呢?
A1:不会。程序中调用哪一个函数是在编译时决定的。默认参数一般只定义在头文件中,只有编译器看到了函数有默认参数的声明或定义之后,才会在函数调用处根据情况,添加默认的参数,否则可能会出现编译问题。
在so中编译时确定了调用名为foo且有两个int类型参数的函数后,是不会再调用到bin中定义的foo函数的。
关于带默认值的参数,还需要注意一点:Never redefine an inherited default parameter value
- Q2:现在两边看到的定义已经不同了,那么程序会如何运行。
A2:bin会按照老的偏移去给变量赋值,而so会依照新的偏移去获取数值并使用。因为中间添加了新的变量,导致取出的一部分变量没问题,一部分变量数值不正确。也正是因为这个问题,导致了更换so之后,服务器无法正常启动。
临时解决方案
因为问题的根本原因是由于数据结构的改变引起的不兼容,想要解决问题就必须更新bin文件,用最新的版本的数据结构定义编译后内存才能够匹配上。
延伸问题
这次问题的出现使一个没有注意到的问题浮出水面:
由于bin文件和so公用一些基础的代码库。而两者又是分开编译的,如果编译So时一个公用的函数已经更新,那么bin和so中的函数执行就可能出现问题。
实例:
橙色函数表示foo的新版本,期望的运行方式如图:
但实际可能出现的运行方式有可能是一下两种:
为了明白上面的问题,我们要搞清楚一下几个问题:
- 为什么在bin文件中和so中函数原型相同的两个函数有着不同的实现还能够正常的链接和运行。
我们的bin文件和so在编译时互相并不知道对方的存在。bin中通过dlopen打开so,除了在bin中调用的几个有限的接口之外,编译时不会知道so中其他的信息。因此在编译时,两者都可以编译通过。
那么为什么通过dlopen打开之后,bin和so中明明有相同函数原型的函数,却不会出现在编译时经常可以看到的错误信息呢?
symbol "x" redefined: first defined in "./main.obj"; redefined in ***.c
- 是什么决定了bin或者so调用到哪一个函数。
根据分析,既然运行时有多个定义,那么bin或者so又是如何决定选取哪一个定义,我们又是否有办法来控制bin和so的行为,让他们按照我们的希望选择相应的定义呢?
动态链接库相关知识
Position Independent Code
目前我们编译so时都会用到-fPIC选项,这表示生成的动态链接库(SO)是地址无关代码(Position Independent Code),那么地址有关无关到底有什么关系呢?
//libdep.c
int g = 1;
int foo(int a){
return g + a ;
}
由于动态链接库无法知道自己将会被加载到内存的哪一个位置,因此也就无法在编译时决定g的地址。对于非PIC的so,当libdep.so被不同的程序都用到的时候,g的地址也就不一样,导致ptr的赋值代码会不同。
如果so代码在不同的程序都不同,因此这种方式没有办法做到同一份指令被多个进程所共享。
而地址无关代码的so的内存结构如下所示:
地址无关的实现
PIC的实现基本想法是把指令中那些需要被修改的部分分离出来,跟数据放在一起,这样指令部分就可以保持不变,而数据部分可以在各进程中拥有一个副本。
这个想法之所以能够实现的前提是,在链接阶段,链接器可以知道数据段和程序段的相对偏移。
还是这段代码为例
//libdep.c
int g = 1;
int foo(int a){
return g + a ;
}
如果不是地址无关代码,那么生成的程序可能会是这么描述获取g值的过程。
将1234地址的内容放到ax寄存器。
而PIC生成的代码会是这样的
从数据段中用来实现PIC的数据结构中获取g的地址,并存到bx寄存器
将bx中地址的内容放到ax
图示如下:
数据段中用来实现PIC的数据结构有一个名字叫做全局偏移表(Global Offfset Table)。
GOT中包含哪些内容
我们可以把共享库中对地址的引用分为四类
- 模块内部的函数调用,跳转等
- 模块内部的数据访问,包括模块内定义的全局变量,静态变量
- 模块外部的函数调用,跳转等
- 模块外部的数据访问,比如定义在其他模块中定义的全局变量
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1; // Type2, 模块内部数据访问
b = 2; // Type4, 模块外部数据访问
}
void foo()
{
bar(); // Type1, 模块内部函数调用
ext(); // Type3, 模块外部函数调用
}
具体的分析可以看程序员的自我修养7.3.3,7.3.4节。这里我只说一下结论:
PIC代码中通过GOT访问的地址引用包括:
- 类型1中没有限制为static的函数
- 类型2中模块内部的全局变量
- 类型3,4的地址引用
GOT中的内容何时确定
GOT中的内容无法在编译时确定,当动态链接器将动态链接库加载到内存之后,会进行符号解析和重定位工作。当动态链接器发现了某个需要在GOT中保存的符号后,会将这个符号对应的地址填到GOT中。
通过GOT访问数据会增加一层间接的地址获取步骤。但是也带来了一定的好处。
- 在非PIC的代码中,每对一个符号进行引用都会产生一处重定位的地方。而GOT访问的方式使得重定位次数从每次引用一次变为每一个符号一次。
- 通过GOT可以使得代码段成为地址无关的,从而可以在多个进程中共享代码。
举一个例子:
$ cat test.c
extern int foo;
int function(void) {
return foo;
}
$ gcc -shared -fPIC -o libtest.so test.c
$ objdump --disassemble libtest.so
[...]
00000000000005ac <function>:
5ac: 55 push %rbp
5ad: 48 89 e5 mov %rsp,%rbp
5b0: 48 8b 05 71 02 20 00 mov 0x200271(%rip),%rax # 200828 <_DYNAMIC+0x1a0>
5b7: 8b 00 mov (%rax),%eax
5b9: 5d pop %rbp
5ba: c3 retq
$ readelf --sections libtest.so
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[...]
[20] .got PROGBITS 0000000000200818 00000818
0000000000000020 0000000000000008 WA 0 0 8
$ readelf --relocs libtest.so
Relocation section '.rela.dyn' at offset 0x418 contains 5 entries:
Offset Info Type Sym. Value Sym. Name + Addend
[...]
000000200828 000400000006 R_X86_64_GLOB_DAT 0000000000000000 foo + 0
延迟绑定
从上面的描述可以看出,为了保证程序的正常运行,GOT中的信息需要在动态链接库被程序加载后立刻填写正确。这就给采用动态链接库的程序在启动时带来了一定额外开销,从而减缓了启动速度。ELF采用了做延迟绑定的做法来解决这一问题。基本思想就是通过增加另外一个间接层,使得函数第一次被用到时才进行绑定,这就是PLT(Procedure Linkage Table)的作用。
通过PLT进行函数调用的过程如下图所示:
- 当func被调用时,编译器会生成相关代码func@plt,表示跳转到plt中表示func的那一项。
- 假设func在plt中为第n项,其内容如图,这时会继续跳转到GOT[n]所指向的地址。
- 而在第一次调用时,GOT[n]内的地址会指回PLT[n]中,这里会做一些初始化的工作,然后跳转到PLT[0],PLT[0]指向了动态链接器中解析符号的函数去,根据准备好的数据,解析func的地址并将其填写到GOT[n]中
在第一次调用func之后,再对func进行函数调用时的流程如下:
这时GOT[n]中的地址已经是正确的函数地址,因此会直接跳转到正确的地址去。
下面看一个例子:
$ cat test.c
int foo(void);
int function(void) {
return foo();
}
$ gcc -shared -fPIC -o libtest.so test.c
$ objdump --disassemble libtest.so
[...]
00000000000005bc <function>:
5bc: 55 push %rbp
5bd: 48 89 e5 mov %rsp,%rbp
5c0: e8 0b ff ff ff callq 4d0 <foo@plt>
5c5: 5d pop %rbp
$ objdump --disassemble-all libtest.so
00000000000004d0 <foo@plt>:
4d0: ff 25 82 03 20 00 jmpq *0x200382(%rip) # 200858 <_GLOBAL_OFFSET_TABLE_+0x18>
4d6: 68 00 00 00 00 pushq $0x0
4db: e9 e0 ff ff ff jmpq 4c0 <_init+0x18>
$ readelf --relocs libtest.so
Relocation section '.rela.plt' at offset 0x478 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000200858 000400000007 R_X86_64_JUMP_SLO 0000000000000000 foo + 0
动态符号表
前面解释了PIC的动态链接库的内部实现原理,那么动态链接库如何被外部的bin或者其他的so调用呢?
想要引用so中的函数或者获取so中的全局变量,那么bin或者其他的so就必须要知道so中有什么内容。无论是变量还是函数都可以看做是一个符号,符号有其对应的值,对于变量和函数来说,符号值是他们的地址。
ELF格式的so文件中会有一个段叫做动态符号表。动态符号表中包含了动态链接库需要的导入函数(本so没有定义的)和导出函数(本so定义可以给其他so或bin实用的)
// libso.c
int a;
extern int test();
void bar()
{
a = 1;
}
void foo()
{
test();
bar();
}
$ gcc -shared -fPIC -o libso.so libso.c
$ readelf --dyn-syms libso.so
Symbol table '.dynsym' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
[...]
8: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND test
9: 0000000000000735 19 FUNC GLOBAL DEFAULT 11 bar
10: 0000000000000748 26 FUNC GLOBAL DEFAULT 11 foo
11: 0000000000201044 4 OBJECT GLOBAL DEFAULT 22 a
[...]
可以看到默认所有的非static符号都会被导出a,bar,foo,而引用的test作为导入符号也在动态符号表中,其Ndx为UND表示未定义。
程序运行后,动态链接器会按照宽度优先的顺序加载其依赖的动态链接库以及动态链接库的依赖。如果存在多个相同的符号,那么最先被加载的符号将会干涉(interpose)后面的符号。也就是说,如果有多个相同的符号,那么最先加载的符号定义将会被采用,而后面的链接库中对该符号的引用都会指向最早被加载的那一个符号。
可执行文件中的符号默认并没有导出,因此不会参与到动态链接库的符号解析过程中去,但是如果在编译时添加了-rdynamic选项,会将bin文件中的符号导出,从而使得bin中的符号可以被so调用到。
下面看一个例子:
// main.c
#include <stdio.h>
#include <dlfcn.h>
typedef void (*func_ptr)();
short g = 1;
short x = 1;
int main()
{
printf("g in main:%d\n", g);
printf("x in main:%d\n",x );
void* handler = dlopen("./so",RTLD_NOW|RTLD_LOCAL);
func_ptr ptr = (func_ptr)dlsym(handler, "call_back");
(*ptr)();
printf("g in main:%d\n", g);
printf("x in main:%d\n", x);
return 0;
}
// libso.c
#include <stdio.h>
int g;
extern "C"{
void call_back()
{
printf("g in so:%d\n", g);}
g = 3;
}
$ g++ -shared -fPIC libso.c -o so -g
$ g++ -o main main.c -g -ldl
$ ./main
g in main:1
x in main:1
g int so:0
g in main:1
x in main:1
可以看到一开始编译main的时候没有加上-rdynamic,虽然main和so中都有一个符号叫做g,但是两者互不影响。
下面采用新的方式重新编译
$ g++ -shared -fPIC libso.c -o so -g
$ g++ -o main main.c -g -ldl -rdynamic
$ ./main
g in main:1
x in main:1
g int so:65537
g in main:3
x in main:0
这时候我们发现,so中g的初始值变成了65537也就是0x00010001,也就是short g short x的内存布局,bin中的符号g现在和so中的符号为同一个,且因为so中的类型为int,在编译时决定了so中对g的赋值时按照4个字节的,因此在so中对g赋值时会覆盖掉bin中x的内容。
符号查找顺序
当存在多个相同符号时,先被加载的符号会覆盖掉后面的符号,那么不同的加载顺序就会影响符号的解析内容,从而改变程序的行为。
默认的符号查找范围(lookup scope)是全局查找范围(global lookup scope),一开始包括bin中的符号,之后动态链接器会按照宽度优先的顺序遍历可执行文件所依赖的动态链接库以及动态链接库所依赖的库文件,并将其中的符号添加到全局查找范围中。
当动态链接库通过dlopen打开时so时,so以及其依赖的库文件形成另外一个局部查找范围(local lookup scope)。可以通过RTLD_GLOBAL改变这一行为,使其添加到全局查找范围中。
默认情况下,符号的查找先从全局查找范围开始,然后再查找局部查找范围。
有以下几个例外:
- 当时用dlsym查找so中的符号时则是从局部查找范围开始
- 当dlopen中有RTLD_DEEPBIND时so中的符号从局部范围开始查找
- 当编译so时添加了-Wl,-Bsymbolic参数,使得so具有DF_DYNAMIC flag时,在该so进行符号查找是,依然会按照先全局在局部的顺序查找,但是该so自己会被添加到全局符号中的第一个。
下面是一个例子:
app依赖于一个libone.so libdl.so libc.so, 并且通过dlopen打开了libdynamic.so,libdynamic.so又依赖于libtwo.so。现在的符号查找范围如图所示:
Global: app-->libone.so-->libdl.so-->libc.so
Local: libdynamic.so-->libtwo.so-->libc.so
如果libone.so 和libtwo.so中都定义了一个变量g,app和libone.so中都有对g的引用。
按照默认的顺序,先全局再局部,这时libdynamic.so中引用的变量将是libone.so中的。
如果dlopen时添加了RTLD_DEEPBIND则先从局部范围开始查找因此在libdnamic.so中查找到的g是libtwo.so中的,而app中g则是定义在libone.so中的。
假如libdynamic.so具有DT_DYNAMIC,那么这是的查找libdynamic.so中符号的顺序会变成如下所示:
libdynamic.so-->app-->libone.so-->libdl.so-->libc.so--> libtwo.so-->libc.so
libdynamic.so和app中所引用的g都会是libone.so中定义的。
遗留问题解答
- 为什么在bin文件中和so中函数原型相同的两个函数有着不同的实现还能够正常的链接和运行。
- 是什么决定了bin或者so调用到哪一个函数。
在存在多个相同符号的时候,动态链接器会选择最先加载的哪一个符号,PIC程序由于采用GOT的方式访问数据和函数因此可以在运行时决定对应符号加载的地址,从而实现全局符号介入(Global Symbol Interposition)。因此先被加载的符号会被采用,执行顺序会如下图所示:
如何防止出现这样的问题
数据结构定义不一致
在查阅一番资料后,对于数据结构定义不一致的问题,没有发现可以解决的办法,毕竟数据结构的定义不同直接导致内存布局不同。程序按照原来的内存结构去操作数据肯定会出现问题。
在bin和so公用的关键数据结构定义发生变化时,感觉比较好的做法就是禁止程序启动,要是像这一次导致程序无法启动还好,如果正常启动但是还有一些隐藏的问题直接会导致运行出现莫名奇怪的问题,甚至写错数据。
函数定义不一致
通过-Wl,-Bsymbolic的编译选项可以使函数调用到希望的函数,但是也会有一些问题。因为在bin和so之间会通过全局变量进行通信,Bsymbolic使得无法这样做,因为so会采用自己的全局变量内容,和bin中的全局变量内容不同。为了解决这一个问题我们可以使用编译选项(-Wl,-Bsymbolic-functions),这样只对函数的查找改变顺序,但是如果有函数指针的在bin和so中传递的情况出现,可能出现同一个函数的指针不同的情况。Bsymbolic引起的问题主要原因是采用这一个符号后会使得so和bin中本应该是只有一份的内容产生两个副本。是否使用这一标记需要待定。
RTLD_DEEPBIND标记和Bsymbolic的功能有一相似,在这里也可以解决我们的问题,但是RTLD_DEEPBIND同Bsymbolic一样,同样会有一些问题。
示例如下:
// main.c
#include <stdio.h>
#include <dlfcn.h>
void foo(int a){}
typedef void(*foo_ptr)(int);
typedef void (*func_ptr)(foo_ptr);
int main()
{
void* handler = dlopen("./so",RTLD_NOW|RTLD_LOCAL);
func_ptr ptr = (func_ptr)dlsym(handler, "call_back");
(*ptr)(&foo);
return 0;
}
// libso.c
#include <stdio.h>
int g = 1;
void foo(int a){}
typedef void(*foo_ptr)(int);
extern "C"{
void call_back(foo_ptr p)
{
printf("foo in main:%p\n", p);
printf("foo in so:%p\n", &foo);
if(p == &foo){
printf("ptr is equal \n");
}else{
printf("ptr not equal\n");
}
}
}
// Normal
$ g++ -shared -fPIC libso.c -o so -g
$ g++ -o main main.c -g -ldl -rdynamic
$ ./main
foo in main:0x4008bd
foo in so:0x4008bd
ptr is equal
// Bsymbolic
$ g++ -shared -fPIC libso.c -o so -g -Wl,-Bsymbolic
$ g++ -o main main.c -g -ldl -rdynamic
$ ./main
foo in main:0x4008bd
foo in so:0x7f1e57910755
ptr not equal
// dlopen添加 RTLD_DEEPBIND
$ g++ -shared -fPIC libso.c -o so -g
$ ++ -o main main.c -g -ldl -rdynamic
$ ./main
foo in main:0x4008bd
foo in so:0x7f0a2329a785
ptr not equal
可以看到,本来应该是相等的函数指针,在采用了Bsymbolic和RTLD_DEEPBIND之后都变得不相等了。
gcc还提供了可见性控制,gcc默认将符号全部都导出可见,如果我们仅导出需要使用的函数就可以防止so中的函数被bin中的定义所覆盖。在编译时我们可以通过添加-fvisibility=[default,hidden,internal,protected]来控制导出符号默认值,同时在程序中添加__attribute__((visibility ("hidden/default")));
来改变默认的函数或者变量的可见性。但这就需要对所有需要导出的函数去修改代码,添加控制相关的内容。尤其是GameSvr中判断是否支持某个新的函数时用的是dlsym是否存在来判断,如果游戏so代码没有即使正确的控制可见性,会导致即使游戏so中已经有了新的函数,但是dlsym依旧会查找不到的情况出现。
gcc还提供了version script可以定义符号的版本以及控制是否导出等,但是和可见性控制一样,会增加维护的成本,当维护不当时容易出现一些难以发现的问题。
目前我们在so和bin中都有版本控制,当检测到版本不匹配时会弹出提示。但是这种提示还不够明确,大部分情况下并不清楚版本不匹配到底会带来什么问题。我们可以引入主版本号次版本号的概念,当此版本号不同时弹出提示。当修改了so和bin中的关键数据后,修改主版本号,强制bin在加载so后启动失败。
遗留问题
目前我们使用PIC代码编译的so在dlopen时都会采用RTLD_NOW来加载so,不会用到PLT所提供的lazy binding功能。而为此,我们每次函数访问都会付出一次从plt转到got读取地址的过程,必然就带来了性能损失。
在新版本的gcc新版本(may be 6.0)为此添加了编译选项(-fno-plt)可以不通过plt来访问函数。
再进一步,如果我们根本不关心so是否为PIC的来节约内存,我们可以不使用PIC代码编译so,直接采用装载时重定位的方式加载so,将通过got访问的间接层也去掉。不过64位环境中默认so必须是PIC的,需要通过添加 -mcmodel=large选项来进行编译。
相关阅读:
How to write shared library
What exactly does -Bsymblic do?
What exactly does -Bsymblic do? -- update
Load-time relocation of shared libraries
Position Independent Code (PIC) in shared libraries
Position Independent Code (PIC) in shared libraries on x64
PLT and GOT - the key to code sharing and dynamic libraries
Redirecting functions in shared ELF libraries
Bsymbolic与plt
当使用Bsymbolic编译so后我们会发现原来通过plt访问的函数调用都变成了直接通过相对地址访问。这是因为linker在发现了Bsymbolic标记后知道so中的符号都不会被外部所调用,因此会将原本通过plt/got调用的函数改为直接通过相对地址调用。
Bsymbolic与Visibility
Bsymbolic会使得so内部的函数不会被外部的函数所干涉,但是so中的函数依旧会导出,给其他的模块使用。
而设置visibility hidden之后,内部函数不仅不会被外部函数干涉,而且也无法被其他的模块使用。