1 引言
前段时间做了下C++生成Dll的总结,但是有些地方还是没有弄特别清楚(比如调用约定的区别,extern "C"的意义),所以这里再重新总结一遍。
2 Dll的导出
先创建一个空项目,空项目意味着我们可以从零开始一步一步配置环境,了解生成dll的整个步骤。(熟悉之后可以创建一个动态链接库(DLL)的项目,这样VS背后会帮我配置好环境,我们只需写代码就行了)
2.1 设置项目属性
-
右键项目》属性》常规 设置目标文件扩展名为.dll,配置类型为动态库(.dll),字符集设置为使用Unicode字符集。(字符集一般选用Unicode字符集,对中文更加友好。) 点击应用,然后就可以开始写代码了。 -
创建include文件夹 我们创建两个文件夹include和src,include文件夹新建mathHelper.h文件,src文件夹中新建mathHelper.cpp文件。 这里创建的include文件夹用于存放我们dll所有的对外的声明。我们后面发布dll时,不仅需要提供.dll文件,还需提供.lib文件以及头文件。有了include文件夹,头文件这部分我们就可以直接将include文件夹整个拷贝出去。 然后在项目属性》C/C++》常规》附加包含目录项 中添加$(ProjectDir)include(即我们上面创建的include文件夹路径)。 设置附加包含目录的作用是为了方便代码中查找头文件 ,可以直接#include “mathHelper.h”,而不用#include “…/include/mathTool.h”,见mathHelper.cpp。 -
C/C++》常规》预处理器定义 添加DLL_EXPORT宏。此宏的作用,我们下面会说。
2.2 写代码
头文件:mathHelper.h
#pragma once
#ifdef DLL_EXPORT
#define DLL_API __declspec(dllexport)
#else
#define DLL_API __declspec(dllimport)
#endif
namespace MathHelper
{
DLL_API int add(int a, int b);
}
函数签名前我们需要添加__declspec(dllexport)关键字,其指定从 DLL 中导出的数据、函数、类或类成员函数。 __declspec关键字晦涩难懂,它其实是declaration specification(声明规范)的缩写。 这里我们还添加了命名空间namespace MathHelper,防止其他人使用此Dll时出现命名冲突的问题。
首先,dll的导出和导入的关键字区分在于__declspec()的括号里面是dllexport(导出)还是dllimport(导入)。 在本项目中我需要告诉VS这个函数是导出的,在其他项目中需要告诉VS这个函数是从别的dll里面导入的。 但是不管哪个项目,使用的都是mathHelper.h这个头文件,这时宏定义作为编译开关的作用就体现出来了。 在本项目中,由于我们定义了DLL_EXPORT的宏定义,所以DLL_API是被定义为__declspec(dllexport)。 而在其他项目中引用头文件mathHelper.h时,其他项目中并没有定义DLL_EXPORT的宏定义,所以DLL_API被定义为__declspec(dllimport)。 这样就做到了同一份header不同的声明。
mathHelper.cpp
#include "mathHelper.h"
int MathHelper::add(int a, int b)
{
return a + b;
}
我们点击生成,就能生成dll了。生成的文件中,.dll和.lib文件是需要发布出去的。 需要注意生成的平台,x86的dll只能在32位程序上使用,x64只能在64位程序上使用,两者不能混用。
2.3 C++项目调用dll
新建一个空项目。然后创建3rd、include、lib三个文件夹,并将我们上面生成的DllExport.dll、DllExport.lib分别拷贝到3rd和lib文件夹,同时将2.1中include文件夹的所有文件拷贝到此项目的include文件中。 创建入口函数。 main.cpp
int main()
{
return 0;
}
2.3.1 设置项目属性
然后设置项目的属性,包括附加包含目录、附加库目录、附加依赖项、生成后事件。
- C/C++》常规》附加包含目录
添加$(ProjectDir)include 作用:为了方便代码中查找头文件 ,可以直接#include "mathTool.h"而不用#include “…/include/mathTool.h” - 链接器》常规》附加库目录
添加$(ProjectDir)lib 作用:为了链接器链接时去搜索我们创建的lib文件夹 - 链接器》输入》附加依赖项
添加DllExport.lib 作用:上面的附加库目录只是定义了搜索lib文件夹,但具体哪个文件由附加依赖项来确定 - 项目》属性》生成事件》生成后事件》命令行
添加如下代码。 作用:为了生成成功后,将3rd文件夹中的DllExport.dll自动拷贝到程序生成目录,而不用人为手动拷贝。
xcopy "$(ProjectDir)3rd\DllExport.dll" "$(SolutionDir)$(Platform)\$(Configuration)\" /y
2.3.2 调用dll中的方法
调用dll中的方法。 main.cpp
#include <iostream>
#include "mathHelper.h"
int main()
{
int res = MathHelper::add(4, 3);
std::cout << res;
return 0;
}
ps:我们这里完全不用修改mathHelper.h,因为此项目中我们的预处理器中没有定义宏DLL_EXPORT,因此mathHelper.h中的DLL_API被定义为__declspec(dllimport)。DLL_EXPORT宏的作用也就体现出来了。
2.4 C#项目调用dll
创建一个控制台项目。
Program.cs
using System;
using System.Runtime.InteropServices;
namespace CSharpDllImport01
{
class Program
{
[DllImport("DllExport.dll",
EntryPoint = "?add@MathHelper@@YAHHH@Z",
CallingConvention = CallingConvention.Cdecl,
SetLastError = true,
CharSet = CharSet.Unicode)]
static extern int Add(int a, int b);
static void Main(string[] args)
{
int res = Add(4, 6);
Console.WriteLine(res);
Console.ReadKey();
}
}
}
同时把2.1中生成的DllExport.dll拷贝到C#项目中的bin\Debug和bin\Release目录下。
2.4.1 试图加载格式不正确的程序
然后点击运行,可能会报错,提示“试图加载格式不正确的程序”。 这是dll的版本与C#程序版本不匹配导致的。 比如我们编译的是x64平台的dll,但是我们C#项目默认是32位的。这时就需要手动更改目标平台,或者将项目》属性》生成 中的首选 32位给取消勾选掉。 设置完毕后,程序就可以运行了。
2.4.2 DllImport
2.4.2.1 static extern、C# C++参数匹配
从上面的例子可以看到,C#调用C++的核心代码就是DllImport特性。
[DllImport("DllExport.dll",
EntryPoint = "?add@MathHelper@@YAHHH@Z",
CallingConvention = CallingConvention.Cdecl,
SetLastError = true,
CharSet = CharSet.Unicode)]
static extern int Add(int a, int b);
首先,C#的方法签名必须加上static和extern关键字,同时参数列表和返回值必须和C++端的签名相匹配。这里就涉及到C#的数据类型与C++的数据类型对应的问题。见这篇文章。 下面说说DllImport的几个属性。
2.4.2.2 dll路径
第一个是dll的文件路径,可以写绝对路径,也可以直接dll文件名。 只写dll文件名时,dll文件必须位于程序当前目录或系统定义的查询路径中(即:系统环境变量中Path所设置的路径),DllImport会按照顺序去查找dll文件(程序当前目录>System32目录>环境变量Path所设置路径)。
2.4.2.3 EntryPoint
EntryPoint用于指定入口,如果不写此属性,默认与下面的C#方法名相同。 需要注意的是EntryPoint对应的是编译后的dll中的函数名,注意这里是编译后的。编译时,编译器根据调用约定(下面会说)将函数名重新生成另外的函数名,而且不同的编译器生成编译后的函数名的规则不同。 比如我们的mathHelper.h定义的函数名为add,但是编译之后,在dll中的函数名却变为了?add@MathHelper@@YAHHH@Z。
DLL_API int add(int a, int b);
如果我们改为EntryPoint = “add”,就会提示无法在DLL“DllExport.dll”中找到名为"add"的入口点。 那么问题来了,我们看编译后的函数名是什么呢?可以通过dumpbin或Dependency Walker来查看。
2.4.2.4 dumpbin
先说dumpbin。
dumpbin.exe是微软二进制文件转储器。显示有关通用对象文件格式 (COFF) 的二进制文件的信息。 可以使用 DUMPBIN 检查 COFF 对象文件、 COFF 对象、 可执行文件和动态链接库 (Dll) 的标准库。
使用dumpbin查看dll内容的步骤如下:
- 1.找到环境
开始->所有程序->Visual Studio 2017->适用于VS 2017的x64本机工具命令提示。 就进入与cmd一样的命令行环境,然后就可以正常使用VS的一些工具,其中就包括dumpbin。 - 2.进入.dll文件的所在地
先输入我们程序所在的盘符,然后输入cd dll所在路径。 - 3.输入dumpbin –exports DllExport.dll,列出导出函数
可以看到咱们导出的dll中的函数名为?add@MathHelper@@YAHHH@Z。 但是为什么vs的编译器会生成这么奇怪的函数名,又是?又是@符号的,其实这是有一套规则的(下面我们数调用约定的时候会看到)。
2.4.2.5 Dependency Walker
Dependency Walker的下载地址。 下载完成后直接将DllExport.dll拖拽进Dependency Walker中,但是Dependency Walker卡死。 Dependency Walker卡死的原因有2:①是Dependency Walker只开了一个线程,所有事情都是在主线程上处理 ②是Dependency Walker会去搜索系统环境变量中的Path路径,导致搜索时间过长。 为了我们更加愉快的使用,最好把搜索路径给去掉。 此时再将dll拖拽到Dependency Walker中,立马就分析出结果了。 上图中展示的函数名是Dependency Walker解码过后的,想看原始的函数需要点击下工具栏的C++项。 可以看到,与dumpbin的分析结果是一样的。
2.4.2.6 CallingConvention
调用约定(Calling Convention)是规定子过程如何获取参数以及如何返回的方案,其通常与架构、编译器等相关。具体来说,调用约定一般规定了:
- 参数、返回值、返回地址等放置的位置(寄存器、栈或存储器等)
- 如何将调用子过程的准备工作与恢复现场的工作划分到调用者(Caller)与被调用者(Callee)身上
__cdecl __cdecl 是 C Declaration 的缩写,表示 C 语言默认的函数调用方法:所有参数从右到左依次入栈,这些参数由调用者清除,称为手动清栈。被调用函数不会要求调用者传递多少参数,调用者传递过多或者过少的参数,甚至完全不同的参数都不会产生编译阶段的错误。 __stdcall __stdcall 是 Standard Call 的缩写,是 C++ 的标准调用方式:所有参数从右到左依次入栈,如果是调用类成员的话,最后一个入栈的是 this 指针。这些堆栈中的参数由被调用的函数在返回后清除,使用的指令是 retnX,X 表示参数占用的字节数,CPU 在 ret 之后自动弹出 X 个字节的堆栈空间,称为自动清栈。函数在编译的时候就必须确定参数个数,并且调用者必须严格的控制参数的生成,不能多,不能少,否则返回后会出错。 __pascal __pascal 是 Pascal 语言(Delphi)的函数调用方式,也可以在 C/C++ 中使用,参数压栈顺序与前两者相反。返回时的清栈方式与 __stdcall 相同。 __fastcall __fastcall 是编译器指定的快速调用方式。由于大多数的函数参数个数很少,使用堆栈传递比较费时。因此 __fastcall 通常规定将前两个(或若干个)参数由寄存器传递,其余参数还是通过堆栈传递。不同编译器编译的程序规定的寄存器不同,返回方式和 __stdcall 相当。 __thiscall __thiscall 是为了解决类成员调用中 this 指针传递而规定的。__thiscall 要求把 this 指针放在特定寄存器中,该寄存器由编译器决定。VC 使用 ecx,Borland 的 C++ 编译器使用 eax。返回方式和 __stdcall 相当。
__fastcall 和 __thiscall 涉及的寄存器由编译器决定,因此不能用作跨编译器的接口。所以 Windows 上的 COM 对象接口(所有的Windows API)都定义为 __stdcall 调用方式。 C 语言中不加说明默认函数为 __cdecl 方式(C中也只能用这种方式),C++ 也一样,但是默认的调用方式可以在VS(项目》属性》C/C++》高级》调用约定)中设置。 我们上面说过,编译器会根据不同的调用约定来生成不同的的函数名,其生成规则如下。 其中的X表示几个,如XH,可以为HH,HHH或者HHHH等等。
在C#中调用C++的dll时,测试发现32位的dll的调用约定是一定需要匹配的,否则会报错。 比如导出的dll的调用约定用的是__cdecl,但是DllImport中指定的是CallingConvention = CallingConvention.StdCall,会提示托管调试助手 “PInvokeStackImbalance”:“对 PInvoke 函数“CSharpDllImport01!CSharpDllImport01.Program::Add”的调用导致堆栈不对称。原因可能是托管的 PInvoke 签名与非托管的目标签名不匹配。请检查 PInvoke 签名的调用约定和参数与非托管的目标签名是否匹配。”的错误。 但是64位的dll,就算调用约定没有匹配上也不会报错,不知是什么原因。
2.4.2.7 SetLastError
SetLastError 指示方法是否保留 Win32"上一错误",默认值为false。
SetLastError 错误处理非常重要,但在编程时经常被遗忘。当您进行 P/Invoke 调用时, 也会面临其他的挑战 — 处理托管代码中 Windows API 错误处理和异常之间的区别。我可以给您一点建议。 如果您正在使用 P/Invoke 调用 Windows API 函数,而对于该函数,您使用 GetLastError 来查找扩展的错误信息, 则应该在外部方法的 DllImportAttribute 中将 SetLastError 属性设置为 true。这适用于大多数外部方法。 这会导致 CLR 在每次调用外部方法之后缓存由 API 函数设置的错误。然后,在包装方法中, 可以通过调用类库的 System.Runtime.InteropServices.Marshal 类型中定义的 Marshal.GetLastWin32Error 方法来获取缓存的错误值。 我的建议是检查这些期望来自 API 函数的错误值,并为这些值引发一个可感知的异常。对于其他所有失败情况(包括根本就没意料到的失败情况), 则引发在 System.ComponentModel 命名空间中定义的 Win32Exception,并将 Marshal.GetLastWin32Error 返回的值传递给它。
2.4.2.8 CharSet
字符集,默认值为Auto。在调用WindowsAPI时,如果传递的字符串内有中文,一定要指定为Unicode,否则很可能出现乱码。
2.5 extern “C”
上面我们看到,C++编译器编译时会重新命名我们的函数名,C++也是使用这种方式来实现函数的重载的。 比如我们重载一下add方法。 mathHelper.h
#pragma once
#ifdef DLL_EXPORT
#define DLL_API __declspec(dllexport)
#else
#define DLL_API __declspec(dllimport)
#endif
namespace MathHelper
{
DLL_API int add(int a, int b);
DLL_API int add(int a, int b, int c);
}
mathHelper.cpp
#include "mathHelper.h"
int MathHelper::add(int a, int b)
{
return a + b;
}
int MathHelper::add(int a, int b, int c)
{
return a + b + c;
}
生成的dll中的函数名如下,可以看到C++编译器实际把重载的函数编译成了两个不同的函数名。 有什么方法可以不让函数名被编译器修改呢?这时extern "C"就登场了。 extern "C"的功能是让编译器以处理 C 语言代码的方式来处理修饰的 C++ 代码。简单点说就是不让编译器修改函数名。 由于C语言不支持函数的重载,所以上面的add方法咱们得删除一个,否则不能编译通过。 添加extern "C"后的mathHelper.h文件。
#pragma once
#ifdef DLL_EXPORT
#define DLL_API __declspec(dllexport)
#else
#define DLL_API __declspec(dllimport)
#endif
#ifdef __cplusplus
extern "C"
{
#endif
namespace MathHelper
{
DLL_API int add(int a, int b);
}
#ifdef __cplusplus
}
#endif
mathHelper.cpp
#include "mathHelper.h"
int MathHelper::add(int a, int b)
{
return a + b;
}
编译后dll中的函数名为add。 注意,上面的头文件中,我们加了#ifdef __cplusplus,因为C语言是不支持extern "C"的,这样我们的dll在C语言也可以使用。 可查看stack overflow了解更多细节。
3 遗留问题
TODO ①def文件的使用 ②导出类(导出接口),以及C#端的使用
4 测试项目
博主本文博客链接。 链接:https://pan.baidu.com/s/1rbsvaOF5-r95FRalDR7sxA 提取码:4iry
5 参考文章
|