前言:我一直对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导出的函数。首先我尝试在代码中直接使用这个函数:

;
}

编译器不高兴了,给出了这样的错误提示:

error C2065: “foobar” : 未声明的标识符

哦,编译器不知道符号"foobar"是什么东东,当然会报错了。这个好办,我就在TestApp程序中引用声明了foobar函数的头文件:

;
}

这下编译器是满意了,但是连接器又不高兴了,连接器报错:

error LNK2019: 无法解析的外部符号 "__declspec(DLLimport) void __cdecl foobar(int)" (__imp_?foobar@@YAXH@Z)

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

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

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

接下来我们将深入DLL的编译和连接,了解更多实隐藏在编译器和连接器背后的东西。这次我们使用的"利器"仍然是汇编代码,希望能从汇编代码中找到一些"蛛丝马迹"。下面是TestApp中关键的代码和相应的汇编代码:

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);"这一句的时候,我选择"转到反汇编"进行运行时的汇编分析。这时我又获得了这样的汇编代码:

(__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模块中的某个部分。当我继续查看这个内存是什么内容的时候,我却得到了这样的结果:

(?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导出函数的入口处。为什么要这样处理却不得而知了。

透过汇编另眼看世界之DLL导出函数调用的更多相关文章

  1. C# 遍历DLL导出函数

    C#如何去遍历一个由C++或E语言编写的本地DLL导出函数呢 不过在这里我建议对PE一无所知的人 你或许应先补补这方面的知识,我不知道为什么PE方面的 应用在C#中怎么这么少,我查阅过相关 C#的知识 ...

  2. C++ DLL导出类 知识大全

    在公司使用C++ 做开发,公司的大拿搭了一个C++的跨平台开发框架.在C++开发领域我还是个新手,有很多知识要学,比如Dll库的开发. 参考了很多这方面的资料,对DLL有一个基本全面的了解.有一个问题 ...

  3. DLL导出函数和类的定义区别 __declspec(dllexport)

    DLL导出函数和类的定义区别 __declspec(dllexport) 是有区别的, 请看 : //定义头文件的使用方,是导出还是导入 #if defined(_DLL_API) #ifndef D ...

  4. AFX_MANAGE_STATE(AfxGetStaticModuleState())DLL导出函数包含MFC资源

    AFX_MANAGE_STATE(AfxGetStaticModuleState()) 先看一个例子: .创建一个动态链接到MFC DLL的规则DLL,其内部包含一个对话框资源.指定该对话框ID如下: ...

  5. dll 导出函数名的那些事

    dll 导出函数名的那些事 关键字: VC++  DLL  导出函数 经常使用VC6的Dependency或者是Depends工具查看DLL导出函数的名字,会发现有DLL导出函数的名字有时大不相同,导 ...

  6. [百度空间] [原]DLL导出实例化的模板类

    因为模板是在编译的时候根据模板参数实例化的,实例化之后就像一个普通的类(函数),这样才有对应的二进制代码;否则,没有模板参数,那么编译器就不知道怎么生成代码,所以生成的DLL就没有办法导出模板了.但是 ...

  7. dll的概念 dll导出变量 函数 类

    1. DLL的概念 DLL(Dynamic Linkable Library),动态链接库,可以向程序提供一些函数.变量或类.这些可以直接拿来使用. 静态链接库与动态链接库的区别:   (1)静态链接 ...

  8. DLL导出与调用约定

    一般来说,从DLL导出函数有两种方法.一种是使用.def文件:另一种是使用__declspec(dllexport). 使用上面两种方法各有优缺点.使用.def文件就是需要额外维护,当导出函数更改名字 ...

  9. DLL 导出函数

    DLL的链接方式分为两种:隐式链接和显式链接 DLL导出的函数 和 导出类在调用时,有些区别,这里暂时不讲,直说简单的导出函数: 隐式链接: #include "stdafx.h" ...

随机推荐

  1. yii学习笔记(7),数据库操作,联表查询

    在实际开发中,联表查询是很常见的,yii提供联表查询的方式 关系型数据表:一对一关系,一对多关系 实例: 文章表和文章分类表 一个文章对应一个分类 一个分类可以对应多个文章 文章表:article 文 ...

  2. 全文检索引擎 sphinx-coreseek中文索引

    Sphinx是一个基于SQL的全文检索引擎,可以结合MySQL,PostgreSQL做全文搜索,它可以提供比数据库本身更专业的搜索功能,使得应用程序更容易实现专业化的全文检索. Sphinx特别为一些 ...

  3. 最完整的数据倾斜解决方案(spark)

    一.了解数据倾斜 数据倾斜的原理: 在执行shuffle操作的时候,按照key,来进行values的数据的输出,拉取和聚合.同一个key的values,一定是分配到一个Reduce task进行处理. ...

  4. 关于一个flask的服务接口实战(flask-migrate,flask-script,SQLAlchemy)

    前言 最近接到一个接收前端请求的需求,需要使用python编写,之前没有写过python,很多技术没有用过,在这里做一个学习记录,如有错误,请不了赐教. Flask Api文档管理 使用Falsk A ...

  5. Hive(8)-常用查询函数

    一. 空字段赋值 1. 函数说明 NVL:给值为NULL的数据赋值,它的格式是NVL( value,default_value).它的功能是如果value为NULL,则NVL函数返回default_v ...

  6. 西安Uber优步司机奖励政策(12月28日到1月3日)

    滴快车单单2.5倍,注册地址:http://www.udache.com/ 如何注册Uber司机(全国版最新最详细注册流程)/月入2万/不用抢单:http://www.cnblogs.com/mfry ...

  7. 4567: [Scoi2016]背单词

    4567: [Scoi2016]背单词 https://www.lydsy.com/JudgeOnline/problem.php?id=4567 题意: 题意看了好久,最后在其他人的博客里看懂了的. ...

  8. POM中常用依赖包

    <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven ...

  9. Linux命令应用大词典-第18章 磁盘分区

    18.1 fdisk:分区表管理 18.2 parted:分区维护程序 18.3 cfdisk:基于磁盘进行分区操作 18.4 partx:告诉内核关于磁盘上分区的号码 18.5 sfdisk:用于L ...

  10. Java开发工程师(Web方向) - 03.数据库开发 - 第4章.事务

    第4章--事务 事务原理与开发 事务Transaction: 什么是事务? 事务是并发控制的基本单位,指作为单个逻辑工作单元执行的一系列操作,且逻辑工作单元需满足ACID特性. i.e. 银行转账:开 ...