如何理解DLL不是地址无关的?DLL与ELF的对比分析
前言
《程序员的自我修养》中提到“PE DLL的代码段并不是地址无关的”,恐怕好多人看完都是一知半解。本文将DLL与Linux下ELF格式的动态共享对象(.so文件)对比分析,详细解释为何DLL不是地址无关的;并简单分析这样做的出发点和利弊。
这里提到的“地址无关(Position-Independent Code,PIC)”是指对于动态链接库(Windows下的.dll,Linxu下的.so)的代码段(.text段)的地址无关,即是否可以多个进程共用同一个动态链接库的代码段。
Update:关于x64下的PIC
本文介绍的是32位下的PIC实现,x64下的PIC有重大的改进,参考x64下PIC的新寻址方式:RIP相对寻址。
关于相对寻址
想要弄清楚PIC,应当首先理解相对寻址的一些特点,这也是很多人忽略的。
我们知道,对于同一个模块来说,模块内部的函数之间的相对位置都是固定的,所以一般模块内的函数调用都是采用的相对地址调用。准确地来说,模块内部的跳转、函数调用都可以是相对地址调用,或是基于寄存器的相对调用。例如:
8048344 <bar>:
8048344: 55 push %ebp
8048345: 89 e5 mov %esp, %ebp
8048347: 5d pop %ebp
8048348: c3 ret
8048349 <foo>:
...
8048357: e8 e8 ff ff ff call 8048344 <bar>
804835c: b8 00 00 00 00 mov $0x0, %eax
...
8048344对应函数bar()
,8048349对应函数foo()
;foo()
中有对bar()
的调用,即8048357反汇编得到的call 8048344 <bar>
。实际上这就是一个相对地址调用,8048357的机器码为e8 e8 ff ff ff
,其中e8
即call
的指令码,而其后的e8 ff ff ff
为-24的补码(0xFFFFFFE8),则实际调用地址为0x804835c + (-24) = 0x8048344,即bar()
的地址。
但是数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,也就是说虽然同一模块内变量的相对位置也是固定的,但我们不能像函数调用那样指定一个相对偏移就得到变量的值,因为没有这样的指令。这是至关重要的。
ELF的PIC实现
别着急,我们先看看ELF格式文件的PIC实现,有对比才能说明DLL的地址无关性。
一般来说,要实现PIC,我们要考虑4种类型的地址引用方式:
- 模块内部调用或转跳
- 模块内部数据访问
- 模块间数据访问
- 模块间调用或跳转
对于第1种我们在上一节已经讲到了,这种情况不需做任何处理。下面来关注ELF对后面3种情况的PIC处理。
注意:“模块内部调用或转跳”实际没这么简单,还涉及到全局符号介入的问题。
模块内部数据访问
数据的相对寻址没有相对于当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的方法来得到当前PC值,然后再加上一个偏移量就可以达到访问相应变量的目的了。
例如:
static int a;
void bar(){
a = 1;
}
由于a
是静态变量,所以a
在.data
段;而bar()
则在.text
段中,bar()
对a
进行了赋值。需要注意虽然两者不在同一个段内,但同属一个模块,相对位置是固定的。设想如果系统支持数据相对寻址,那么a = 1
只需要像函数调用那样指定一个偏移量然后赋值就好了,但很可惜现代的系统都不支持。
来看看ELF怎么做的:
0000044c <bar>:
...
44f: e8 40 00 00 00 call 494 <__i686.get_pc_thunk.cx>
454: 81 c1 8c 11 00 00 add $0x118c, %ecx
45a: c7 81 28 00 00 00 01 movl $0x1, 0x28(%ecx) // a =1
461: c3 ret
00000494 <__i686.get_pc_thunk.cx>:
494: 8b 0c 24 mov (%esp), %ecx
497: c3 ret
44f是一个相对地址调用,454 + 40 = 494,调用__i686.get_pc_thunk.cx
,这个函数直接将返回地址(下一条指令地址,就是PC啦)放到ecx寄存器(执行call指令函数调用时,下一指令地址会被压到栈顶,而esp寄存器始终指向栈顶,(%esp)
即获取栈顶值,494即将此值交给ecx),然后454给PC加上一个偏移量0x118c,45a则继续添加偏移量0x28,然后将1赋值到此偏移地址,即a = 1
。0x118c和0x28从哪来?这就是预先知道的偏移量啦,和函数调用类似的,这个偏移量就是当前地址与变量a
的偏移。
注意为了找到这个数据,我们花了一次函数调用,一次加法,和另一次隐含的加法。
注意:“模块内部数据访问”实际没这么简单,还涉及到全局符号介入的问题。
模块间数据访问
由于动态链接时,只有在模块装载后才能知道其他模块中数据的地址,所以模块间数据访问就用到大名鼎鼎的全局偏移表GOT(Global Offset Table)了。
简单来说,GOT位于数据段(.data段),保存本模块用到的外部符号(变量或函数)的实际地址;因为装载前不知道外部符号的实际地址,所以在装载时由动态链接器对每个模块的GOT进行更新设置真实地址。GOT在数据段中的位置是不变的。
在编译时,本模块的所有对外部变量的访问(即需要知道外部变量地址的地方),都间接引用到GOT中的对应项;因为GOT的相对位置不变,所以可以像“模块内部数据访问”那样,编译时确定GOT的位置。由于GOT在数据段中,因此每个进程都可以自定义GOT内部的值,这样实现PIC。
举个例子,本模块需要访问另一模块的变量b
,那么获取b
的地址的指令就变成了以相对寻址获取GOT中对应地址的值;这个值在装载时由动态链接器更新为b
的实际地址,所以最终通过GOT获得了b
的实际地址。由于改动只存在于数据段的GOT中,而代码段从编译到装载都没有发生变化,因此实现了地址无关,多个进程可以共用同一代码段。
注意“模块间数据访问”需要用到“模块内部数据访问”。
模块间调用或跳转
“模块间调用或跳转”和“模块间数据访问”很类似,“模块间数据访问”得到的是外部变量的地址,用来读取或赋值;“模块间调用或跳转”则得到的是外部函数的地址,直接通过call
进行跳转就好了。
例如:
call 494 <__i686.get_pc_thunk.cx>
add $0x118c, %ecx
movl 0xfffffffc(%ecx), %eax
call *(%eax)
第一行得到PC值,第二和第三行添加偏移量,得到外部函数实际地址放到eax,最后间接调用eax所对应的函数。
注意“模块间调用或跳转”需要用到“模块内部数据访问”。
小结
注意看模块间的数据访问和调用的代价,引入GOT后实现了PIC,但每次模块间的数据访问和调用都要首先计算出GOT的位置,然后才能通过GOT获得真实地址。
DLL
现在轮到主角DLL了,准确来说是PE DLL。
DLL也用到了类似GOT的方法,称为导入地址数组(Import Address Table,IAT)。IAT和GOT非常类似,IAT中表项对应本模块中用到的外部符号的真实地址,初始为空(也不算为空),在装载后由动态链接器更新为真实地址。
对于熟悉ELF的PIC的人来说,看到IAT了就很容易对应到GOT,然后认为PE里也是使用的和ELF类似的技术来实现PIC,这就犯了先入为主的错误啦!实际微软并没有采用ELF的那套PIC机制,就像最开始说的,DLL根本不是PIC。
那么PE是怎么处理模块间调用或转跳的呢?这里只为了说明DLL不是地址无关,就只拿模块间函数调用举例了。
模块间函数调用
假设LibA是一个动态链接库,其源码中有对另一动态链接库LibB中函数add()
的调用
/* LibA.c */
#define DllExport __declspec(dllexport)
#define DllImport __declspec(dllimport)
DllImport int add(int a, int b);
DllExport int callLibBAdd(int a, int b){
return add(a, b);
}
LibB中的add()
就是
/* LibB.c */
#define DllExport __declspec(dllexport)
DllExport void add(int a, int b){
return a + b;
}
现在将LibA.c编译为LibA.dll,将LibB.c编译为LibB.dll。那么LibA.c中的add(a, b)
就被编译为了
/* LibA.c add(a, b) */
CALL DWORD PTR [0x1000D11C]
这个CALL DWORD PTR [0x1000D11C]
意思就是间接调用0x1000D11C这个地址中保存的地址。而对于上面例子来说,其间接调用的地址就是此模块IAT中的某一项,即需要调用的外部函数在IAT中所对应的元素。如LibA.dll中,需要调用LibB.dll中的add
函数,那么0x1000D11C正好对应add
在LibA.dll的IAT中的位置。
现在问题就来了:IAT的地址直接写死在代码段中,还能够实现地址无关吗?怎么动态链接呢?
我们仔细对比DLL和ELF的“模块间函数调用”,ELF写死了当前地址与GOT的偏移值,然后通过一个小技巧获取到了当前地址(PC),与偏移值相加获得GOT的地址,再call
调用外部函数,代码段不需要作任何修改;而DLL直接从一个认为是IAT的指定的地址(0x1000D11C)拿到一个值,以这个值作为外部函数地址进行CALL
函数调用。
我们很明显知道LibA.dll的位置是装载时确定的,也就是说LibA.dll中IAT的地址也是装载时才能确定的,那代码段里写个0x1000D11C是几个意思?除非LibA.dll就是这个DLL预先期望的位置,否则0x1000D11C不可能是IAT的位置!
到这里我们就可以负责任地说DLL不是地址无关的了。理由很简单:如果装载后动态链接时0x1000D11C没有被修改为真正的IAT地址,那么程序是不能正常运行的;所以0x1000D11C会被修改,那么代码段就被修改了,不能再多个进程共享这个代码段了。
那么是不是DLL不可能PIC了?根据上面的分析可以明确地说,只要寻找IAT地址还是用的绝对地址,就不可能实现PIC。
到现在已经解释完了“DLL不是地址无关的”这句话,希望你也看明白了。如果不着急的话,下面继续介绍PE是怎么实现动态链接的,以及微软这么干的原因。
装载时重定位
上面只是说到了DLL动态装载时会找不到IAT,那么解决办法是什么呢?毕竟程序还是要运行的。
这里要谈到PE里的另一个概念:基地址(Base Address)和相对地址(Relative Vitual Address,RVA)。PE文件在编译时,其模块内地址空间以预设的基地址为起始地址,而内部的相对地址都是相对于基地址的地址。一般来说,EXE文件基地址默认值为0x400000,DLL文件默认值为0x10000000。以上述例子中的0x1000D11C为例,其基地址为0x10000000,RVA为0xD11C。
默认情况下,PE文件将被装载到预设的基地址,如上例默认会被装载到0x10000000。想想如果真的0x10000000这片区域是空闲的,LibA.dll被装载到了这里,那么0x1000D11C就真的对应到IAT了!装载后运行就不会出问题了!但是大家都知道,现实是这个默认地址很可能已经被其他的DLL占用了,现在冲突了,该怎么办呢?
PE采用的就是装载时重定位的方法。在DLL装载是,如果目标地址被占用,那么操作系统就会为它分配一块新的空间,并且将DLL装载到该地址。这时基地址修改为新的装载地址,将模块内每个绝对地址引用都进行重定位。看着不可思议?虽然这个过程很浩大,但方法倒是挺简单的,就是简单的算术加减法。举个例子,对于一条编译时的指令
MOV DWORD PTR [0x10001000], 0x100
假设0x1000100是模块中变量foo
的地址,则其RAV为0x1000。如果装载时默认基地址0x10000000被占用,则操作系统分配其一个新的基地址,假设为0x20000000。因为现在0x10001000已经不是正确的地址了,我们需要对这条指令重定位。由于绝对地址变化的只是基地址,RVA不变,因此只需要将新旧基地址的差值加上就好了;这里基地址差值为0x20000000 - 0x10000000 = 0x10000000,所以新的绝对地址为0x10001000 + 0x10000000 = 0x20001000,就是foo
的地址。重定位后的指令为:
MOV DWORD PTR [0x20001000], 0x100
我们也能够确信,现在这个DLL能够正常运行了。事实上,由于DLL内部的地址都是基于基地址的,或者是相对于基地址的RVA,那么所有需要重定位的地方只需要加上一个固定差值。这样的重定位过程又叫做重定基地址(Rebasing)。
怎么重定位也讲完了,至此windows下的动态链接的主要问题都解决了,程序可以正常运行了。我们再回头看几个小问题:
- 为什么之前举例是DLL调用DLL,而不是EXE调用DLL?很简单,你仔细看装载时重定位,需要重定位是因为默认基地址被占用,EXE作为第一个被装载的PE文件,0x400000上一片空白,用不着变换基地址,例子里也就不需要重定位,直接就能找到IAT。
- 既然0x10000000很容易被占用,可不可以编译时就指定其他基地址,省去重定位?当然可以,微软还提供工具这么干了,而且确实会有效果。
- 是不是DLL代码段完全不能共享?我们说DLL不是地址无关,可从来没说DLL的代码段是一定不能共享的。想想修改代码段只是因为基地址变了,因此需要重定位所有的绝对地址,如果两个进程使用同一个DLL,如果基地址都是一样的是不是就可以公用一个了?当然可以!典型的例子是windows系统本身常用的DLL基地址都是精心调整过的,其装载时不需要进行重定位,系统内的所有进程都是共享的一份这些DLL代码段。
微软为什么这么做
我实在是没有找到官方对这方面的解释,下面都是结合《程序员的自我修养》和我自己猜测的。
这么做缺点是显而易见的:浪费内存,每个进程都有一份DLL代码段的副本。而且还会有连锁反应,如页面交换,cache缓存等。
但也不得不说这么做也有着难以比拟的优点。记得前面谈到ELF的PIC实现中经常说的注意点吗?就是想强调,为了实现PIC实际花了一些计算代价,最明显的,每次模块内部数据访问、模块间数据访问和模块间调用或跳转都要首先计算GOT的地址,这个计算涉及到一次函数调用和两次加法,注意是每次。反观DLL,在经过装载时重定位后,对这些访问或转跳都不需要任何计算,直接间接寻址就完了;换一种说法,一个DLL模块使用的次数越多,这个优点越明显。还有,DLL的装载时重定位所花的代价也是比较小的,就是简单的一次加法。
同时我们也考虑windows本身的原因。众所周知,windows就是建立在DLL上的,windows内核实际上相当小;这样DLL就免不了频繁使用,也免不了经常的模块间数据访问和调用,采用装载时重定位是不是也有道理?另一方面就是一个忧伤的故事了,微软不能放弃的windows二进制兼容性,就连现在的windows 10也还能完全兼容95,这是微软说什么都不会放弃的,所以就算微软想把DLL改成PIC,恐怕也是力不从心,算是历史的包袱了。
总结
上面说了这么多也只是把PE和ELF比较有特点的地方拎出来讲,目的就是分清楚两者动态链接时的不同处理策略,以及出发点。两者的实现也各有优缺点,还希望不要一棒子打死,认为微软的设计跟不上时代。
时间仓促,文中难免会有纰漏,还望不吝赐教。
参考
- 俞甲子, 潘爱民. 程序员的自我修养: 链接, 装载与库[M]. 电子工业出版社, 2009.
这个网站的模板是什么?结构挺清晰的。求推荐
网站是基于Typecho的,主题是我自己写的呢~因为写的仓促所以还没正式放出来