透过汇编另眼看世界之DLL导出函数调用

来源:互联网 发布:linux挂载硬盘 编辑:程序博客网 时间:2024/04/29 23:10
前言:我一直对DLL技术充满好奇,一方面是因为我对DLL的导入/导出机制还不是特别的了解,另一面是因为我发现:DLL技术在Windows平台下占有重要的地位,几乎所有的Win32 API都是以导出函数的形式存放于不同的DLL文件中,在DLL方面的学习是任何一个想深入研究Windows内部机制的Windows程序员都不可能回避的事实。我在查阅了大量的文章后,对DLL技术有了一定的了解,所以我写了这篇文章来总结和整理我的思路,也为以后深入的学习提供宝贵的资料。

对于如何制作DLL,网上有很多资料,我这里就不多罗嗦了。现在假设我们已经成功生成了一个Win32 DLL,这个DLL的名字是DllInDepth.dll,它只导出了一个简单的C++函数:
__declspec(dllexport) void foobar(int iValue) {
    printf(
"The value is %d.",iValue);
}

接下来我建立了一个Win32 Console Application。我想把这个应用程序(TestApp)作为客户程序,使用DLL导出的函数。首先我尝试在代码中直接使用这个函数:
int _tmain(int argc, _TCHAR* argv[]) {
    foobar(
5);
    
return 0;
}

编译器不高兴了,给出了这样的错误提示:
error C2065: “foobar” : 未声明的标识符

哦,编译器不知道符号"foobar"是什么东东,当然会报错了。这个好办,我就在TestApp程序中引用声明了foobar函数的头文件:
#include "DllInDepth.h"
int _tmain(int argc, _TCHAR* argv[]) {
    foobar(
5);
    
return 0;
}

这下编译器是满意了,但是连接器又不高兴了,连接器报错:
error LNK2019: 无法解析的外部符号 "__declspec(dllimport) void __cdecl foobar(int)" (__imp_?foobar@@YAXH@Z)

仔细想想,连接器不高兴是有道理的。连接器的主要任务就是将处于不同编译单元(Compilation Unit)的汇编代码"组合/加工"成一个可执行文件,找不到目标文件,连接器拿什么来"加工"呀。这个也好办,在编译DLL的时候,不是生成了一个导出库么?就让连接器连接这个库就可以了。这下问题都解决了,TestApp也成功生成了,怀着激动的心情迫不及待的运行TestApp,一个对话框和"嘣"的一声给了我当头一棒:无法找到需要的DLL

哦,应用程序在装载所需要的DLL时,会按照一定的顺序搜寻指定的目录来寻找需要的DLL。如果搜寻过程结束了也没有找到需要的DLL,应用程序就会停止运行。这个也好办,将DLLDllInDepth.dll复制到TestApp所在的目录不就可以了。

到了现在,TestApp总算能够正确的运行起来了。从以上的"挫折"可以看出,一个DLL的正确运行,需要三个"职能部门"的通力合作:
1.编译器。只有正确引用了相应的头文件,编译器才能正确编译。
2.连接器。只有正确连接了相应的导出库,连接器才能正确连接。
3.装载器。只有正确设置了DLL的路径,当需要它的时候它才能正确被装载到内存中。

接下来我们将深入DLL的编译和连接,了解更多实隐藏在编译器和连接器背后的东西。这次我们使用的"利器"仍然是汇编代码,希望能从汇编代码中找到一些"蛛丝马迹"。下面是TestApp中关键的代码和相应的汇编代码:
; foobar(5);
mov    esi, esp
push    
5
call    DWORD PTR __imp_
?foobar@@YAXH@Z
add    esp, 
4
cmp    esi, esp
call    __RTC_CheckEsp


除了看到奇怪的符号"__imp_?foobar@@YAXH@Z",我们并没有看到太多"新鲜"的东西,不过这里有两点内容引起了我的思考:
1."__imp_?foobar@@YAXH@Z"应该是foobar函数的外部引用,如果将这个符号拆成两个部分:"__imp_"和"?foobar@@YAXH@Z",对于"?foobar@@YAXH@Z"我们并不陌生,它是被C++编译器修饰后的函数名引用。这个符号经过"反修饰"后的结果是:

void __cdecl foobar(int);

如果拿这个和foobar函数的声明进行比较:
__declspec(dllimport) void foobar(int iValue);

这样就不难想像,"__imp_"应该是编译器看到"__declspec(dllimport)"后特殊处理的结果。

2.我们在前面提到,如果要将连接器正确的连接,我们需要给连接器提供导入库。连接器连接导入库或者静态库最主要的目的就是解析(resolution)外部引用。而导入库和静态库先天上的差别,又决定了连接器在处理他们的过程中需要区别对待。静态库可以看作是集中存放许多目标文件(.obj)的仓库,它里面存放的都是"货真价实"的经过编译器”处理“后的汇编代码。当连接器连接静态库的时候,连接器会将相应的代码拷贝到应用程序的代码段中,任何使用到这部分代码的引用被具体的代码所替换。而对于导入库,它并没有存放"货真价实"的代码,具体的代码存放在DLL中,所以连接器并不能将相应的代码拷贝到应用程序的代码段中,而只能提供一种"间接使用"方式,这种"间接使用"的方式首先应该将连接器满足,最重要的是要让应用程序在运行时能够获得他们需要的代码。分析到这里,我越来越糊涂了,这后面到底隐藏了什么秘密?

对于这一部分的内容,我并没有完整的资料,强烈的好奇心驱使我在博客,杂志,论坛,MSDN中寻找各种各样的"蛛丝马迹",当我试图将这些"蛛丝马迹"整理并将他们串联起来的时候,一个相对模糊的轮廓浮现在我的脑海里。我已略微能看到胜利的曙光了。

以上的代码是经过编译器处理后的汇编代码,当连接器连接导入库的时候,所以的外部连接将会被解析。那经过连接后的汇编代码是什么样子的呢?这时候,我们就需要使用VS2002提供的"反汇编"功能。我将TestApp在调试状态下运行起来,当进行到"foobar(5);"这一句的时候,我选择"转到反汇编"进行运行时的汇编分析。这时我又获得了这样的汇编代码:
    foobar(5);
00411A1E  mov         esi,esp
00411A20  push        
5   
00411A22  call        dword ptr [__imp_foobar (42A1BCh)]
00411A28  add         esp,
4
00411A2B  cmp         esi,esp
00411A2D  call        @ILT
+920(__RTC_CheckEsp) (41139Dh) 

看到这个反汇编,再比较一下前面的编译器生成的汇编代码,我一下懵了。请注意这两次函数调用:
call    DWORD PTR __imp_?foobar@@YAXH@Z

00411A22  call        dword ptr [__imp_foobar (42A1BCh)]

在前一此函数调用中,__imp_?foobar@@YAXH@Z扮演的是函数名的角色,表示的是函数的"绝对地址"。而在后一次中,__imp_foobar却扮演着函数指针的角色:先找到__imp_foobar(它位于0x42A1BCH的位置),然后取它的值,作为函数的地址,然后转而调用那个函数。虽然这段代码使用陷入了更深的迷糊,但是我也从中找到一些细小的线索。我们可以看到,__imp_foobar在内存中位于0x0042A1BCH的位置,而我们再观察"call dword ptr [__imp_foobar (42A1BCh)]"这段代码所处的位置,发现它位于0x00411A22H的位置,我们就会意识到他们具有相同的内存段,再联想到模块的"基地址",我们就可以大胆猜测,__imp_foobar应该是TestApp的PE文件格式中的导入表的一项,而它的值应该是被导入的函数的地址。喔!"柳暗花明又一村"呀。

这里真正使我迷惑的是:连接器如果将函数的直接调用变成函数的间接调用? 我们知道连接器是不能修改编译器生成的结果,连接器只能解析外部引用符号,难道是连接器在解析外部引用符号的时候做了些"手脚"? 幸运的是,我找到了一篇文章,其中正好涉及到这方面的内容:
  "The compiler would generate a normal call instruction, leaving the linker to resolve the external. The linker then sees that the external is really an imported function, and, uh-oh, the direct call needs to be converted to an indirect call. But the linker can't rewrite the code generated by the compiler. What's a linker to do?
  The solution is to insert another level of indirection. (Warning: The information below is not literally true, but it's "true enough". We'll dig into the finer details later in this series.)
  For each exported function in an import library, two external symbols are generated. The first is for the entry in the imported functions table, which takes the name __imp__FunctionName. Of course, the naive compiler doesn't know about this fancy __imp__ prefix. It merely generates the code for the instruction call FunctionName and expects the linker to produce a resolution.
  That's what the second symbol is for. The second symbol is the longed-for FunctionName, a one-line function that consists merely of a jmp [__imp__FunctionName] instruction. This tiny stub of a function satisfies the external reference and in turn generates an external reference to __imp__FunctionName, which is resolved by the same import library to an entry in the imported function table.
  When the module is loaded, then, the import is resolved to a function pointer and stored in __imp__FunctionName, and when the compiler-generated code calls the FunctionName function, it calls the stub which trampolines (via the indirect call) to the real function entry point in the destination DLL.
  Note that with a naive compiler, if your code tries to take the address of an imported function, it gets the address of the FunctionName stub, since a naive compiler simply asks for the address of the FunctionName symbol, unaware that it's really coming from an import library."

这篇文章的作者是一名微软的资深工程师,应该具有很高的可靠性。老实说,这篇文章我看的不是太明白,不过从字面上来看,具体的过程应该是这样的:
1.当编译器看到"foobar(5)"的时候,生成这样的代码:
    call ?foobar@@YAXH@Z

2.在连接阶段,外部引用?foobar@@YAXH@Z被解析成一个简单的存根函数:
    jmp [__imp_?foobar@@YAXH@Z]

3.在DLL被装载的时候,DLL导出函数的地址将被填充到应用程序的导入表(Import Table)中。

当我用这个说法和本文的汇编代码进行比对的时候,发现本文中编译器看到"foobar(5)"的时候,却生成这样的代码:
    call __imp_?foobar@@YAXH@Z

仔细想想,是不是"__declspec(dllimport)"在"捣鬼"?如果我把它从导出函数声明处删掉,会是什么结果呢?当我这样做了以后,生成的结果就完全和文章中的分析完全吻合。我在网上找到的一篇文章正好印证了我的猜想:
  "Let's examine what the call to an imported API looks like. There are two cases to consider: the efficient way and inefficient way. In the best case, a call to an imported API looks like this:
CALL DWORD PTR [0x00405030]
  If you're not familiar with x86 assembly language, this is a call through a function pointer. Whatever DWORD-sized value is at 0x405030 is where the CALL instruction will send control. In the previous example, address 0x405030 lies within the IAT.
The less efficient call to an imported API looks like this:
CALL 0x0040100C
•••
0x0040100C:
JMP DWORD PTR [0x00405030]
   In this situation, the CALL transfers control to a small stub. The stub is a JMP to the address whose value is at 0x405030. Again, remember that 0x405030 is an entry within the IAT. In a nutshell, the less efficient imported API call uses five bytes of additional code, and takes longer to execute because of the extra JMP.
You're probably wondering why the less efficient method would ever be used. There's a good explanation. Left to its own devices, the compiler can't distinguish between imported API calls and ordinary functions within the same module. As such, the compiler emits a CALL instruction of the form
CALL XXXXXXXX
where XXXXXXXX is an actual code address that will be filled in by the linker later. Note that this last CALL instruction isn't through a function pointer. Rather, it's an actual code address. To keep the cosmic karma in balance, the linker needs to have a chunk of code to substitute for XXXXXXXX. The simplest way to do this is to make the call point to a JMP stub, like you just saw.
Where does the JMP stub come from? Surprisingly, it comes from the import library for the imported function. If you were to examine an import library, and examine the code associated with the imported API name, you'd see that it's a JMP stub like the one just shown. What this means is that by default, in the absence of any intervention, imported API calls will use the less efficient form.
Logically, the next question to ask is how to get the optimized form. The answer comes in the form of a hint you give to the compiler. The __declspec(dllimport) function modifier tells the compiler that the function resides in another DLL and that the compiler should generate this instruction
CALL DWORD PTR [XXXXXXXX]
rather than this one:
CALL XXXXXXXX
In addition, the compiler emits information telling the linker to resolve the function pointer portion of the instruction to a symbol named __imp_functionname. For instance, if you were calling MyFunction, the symbol name would be __imp_MyFunction. Looking in an import library, you'll see that in addition to the regular symbol name, there's also a symbol with the __imp__ prefix on it. This __imp__ symbol resolves directly to the IAT entry, rather than to the JMP stub.
So what does this mean in your everyday life? If you're writing exported functions and providing a .H file for them, remember to use the __declspec(dllimport) modifier with the function:
__declspec(dllimport) void Foo(void);
   If you look at the Windows system header files, you'll find that they use __declspec(dllimport) for the Windows APIs. It's not easy to see this, but if you search for the DECLSPEC_IMPORT macro defined in WINNT.H, and which is used in files such as WinBase.H, you'll see how __declspec(dllimport) is prepended to the system API declarations."

这篇文章的内容正好解释了我发现的事实和我的疑惑:
1。如果导出函数的声明没有用
__declspec(dllimport) 修饰的话,编译器并不知道这个函数是由DLL导出的,所以编译器就把这个函数当作普通的外部引用来对待,产生一个外部引用的符号等着连接器解析。当连接器工作的时候,就会将导入库中的存根函数拷贝到应用程序的代码段中,并将外部引用解析成那个存根函数。
2。如果导出函数的声明用__declspec(dllimport) 修饰的话,编译器就知道这个函数是DLL导出函数,就将这个函数调用直接编译成对IAT中对应项的调用,而在连接阶段,连接器对IAT中对应项的符号解析成一个函数,这种情况下就没有使用存根函数的必要了。

到了现在,可以说所有的谜底已经"大白于天下"了,可是我突发其想,想看看__imp_foobar到底存的是不是函数地址,还是在调试状态下,我打开观察内存的窗口,在里面输入"0x0042A1BCH",发现位于此内存下的值是:”7b 17 01 10 00“,由于一个指针占有4个字节,而且Window是小头排列(Little Endian),所以正确的值应该是:0x1001177BH。难道这就是函数地址?从这个内存值来看,它应该不位于TestApp所在的内存段,它应该是DLL模块中的某个部分。当我继续查看这个内存是什么内容的时候,我却得到了这样的结果:
@ILT+1910(?foobar@@YAXH@Z):
1001177B  jmp         foobar (10011F80h) 

这里只是一个简单的跳转指令,最终的秘密应该在0x10011F80H被揭开。我继续查看0x10011F80H的内容的时候,我很吃了一惊:
DLLINDEPTH_API void foobar(int iValue) {
10011F80  push        ebp 
10011F81  mov         ebp,esp
10011F83  sub         esp,0C0h
10011F89  push        ebx 
.....


看我发现了什么!我终于找到了最终的函数调用的位置。直到现在,我才看到了导出函数的"庐山真面目"。应用程序的IAT中填充的并不是DLL中导出函数的真实地址,而是在处于DLL中的又一个"存根函数"的地址,而这个"存根函数"仅仅是一个简单的jmp指令跳转到DLL导出函数的入口处。为什么要这样处理却不得而知了。

参考文献:
1。强烈建议大家看看这位老兄的blog: http://blogs.msdn.com/oldnewthing/
这位老兄就是本文提到的那位微软的资深工程师,他的blog几乎每天都有更新,其中的内容包含很多Windows内部实现的内容,极具参考价值。其中他写了一系列的关于DLL导入/导出内容的文章,给了我很大的启发:
http://blogs.msdn.com/oldnewthing/archive/2006/07/27/680250.aspx

2。Matt Pietrek发表于MSDN专栏中的"Under The Hood"系列

也揭示了许多Windows内部的秘密,具有极高的参考价值:http://www.wheaty.net/Columns.htm
他的"Under The Hood"系列专栏中的一篇关于Linker的工作原理的文章给我很大的启发:
http://www.microsoft.com/msj/0498/hood0498.aspx

3。MSDN Magazine中的两篇好文章,内容涉及PE文件格式,作者依然是Matt Pietrek
http://msdn.microsoft.com/msdnmag/issues/02/02/PE/default.aspx
http://msdn.microsoft.com/msdnmag/issues/02/03/PE2/

历史:
11/19/2006 
v1.0
原文的第一个正式版

11/21/2006  v1.1
1。原文中对__declspec(dllimport)这一块的理解还不是太清楚,本次修改添加了一篇文章中对这部分的解释,使所有的疑惑找到了答案。
2。添加了
Matt Pietrek的两篇关于PE文件格式的文章的引用。
原创粉丝点击