前言

《程序员的自我修养》中提到“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,其中e8call的指令码,而其后的e8 ff ff ff为-24的补码(0xFFFFFFE8),则实际调用地址为0x804835c + (-24) = 0x8048344,即bar()的地址。

但是数据的相对寻址往往没有相对于当前指令地址(PC)的寻址方式,也就是说虽然同一模块内变量的相对位置也是固定的,但我们不能像函数调用那样指定一个相对偏移就得到变量的值,因为没有这样的指令。这是至关重要的。

ELF的PIC实现

别着急,我们先看看ELF格式文件的PIC实现,有对比才能说明DLL的地址无关性。

一般来说,要实现PIC,我们要考虑4种类型的地址引用方式:

  1. 模块内部调用或转跳
  2. 模块内部数据访问
  3. 模块间数据访问
  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下的动态链接的主要问题都解决了,程序可以正常运行了。我们再回头看几个小问题:

  1. 为什么之前举例是DLL调用DLL,而不是EXE调用DLL?很简单,你仔细看装载时重定位,需要重定位是因为默认基地址被占用,EXE作为第一个被装载的PE文件,0x400000上一片空白,用不着变换基地址,例子里也就不需要重定位,直接就能找到IAT。
  2. 既然0x10000000很容易被占用,可不可以编译时就指定其他基地址,省去重定位?当然可以,微软还提供工具这么干了,而且确实会有效果。
  3. 是不是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.