C++ 的动态链接库 DLL 编程认识
本文讲解使用 C++ 构建非托管 DLL 的过程,以及静态调用、动态调用的区别,注意函数入口的限制
什么是 DLL?
- DLL (Dynamic Link Library, 动态链接库), 就是把一些经常会共享的代码制作成 DLL,DLL 不是可执行文件
- DLL 包含一个或多个已被编译、链接并与使用它们的进程分开存储的函数
- 多个应用程序可同时访问内存中单个 DLL 副本的内容
为什么使用 DLL 打包代码?
- 节省内存,同一个软件模块,若是以源代码的形式重用,则会被编译到不同的可执行程序中,同时运行这些 exe 时这些模块的二进制码会被重复加载到内存中。如果使用 dll,则只在内存中加载一次,所有使用该 dll 的进程会共享此块内存(当然,像 dll 中的全局变量这种东西是会被每个进程复制一份的)
- 增量式编程,不需编译的软件系统升级,若一个软件系统使用了 dll,则该 dll 被改变(函数名不变)时,系统升级只需要更换此 dll 即可,不需要重新编译整个系统。事实上,很多软件都是以这种方式升级的
- 扩展性强,Dll 库可以供多种编程语言使用,例如用 c 编写的 dll 可以在 vb 中调用。这一点上 DLL 还做得很不够,因此在 dll 的基础上发明了 COM 技术,更好的解决了一系列问题
DLL 的类型?
- 一般而言,DLL 使用 C 进行编写,但是 DLL 的本质是调用相同的系统 API 函数创建出来的,因此只要 windows 支持的语言都能用于创建 dll,VB,delphi,C,C# 等都是可以的
- 非托管的 DLL: 在 Dotnet 环境应用时,通过 DllImport 调用
- 托管的 DLL: 在 Dotnet 环境生成的 DLL 文件,可以在 Dotnet 环境通过 “添加引用” 的方式,直接把托管 DLL 文件添加到项目中。然后通过 UsingDLL 命名空间,来调用相应的 DLL 对象
什么是 DLL 地狱?
- 指在 Microsoft Windows 系统中,因为动态链接库(DLL)的版本或兼容性的问题而造成软件无法正常执行
- Windows 早期并没有很严谨的 DLL 版本管理机制,以致经常发生安装了某软件后,因为其覆盖了系统上原有的同一个 DLL 文件,而导致原有可运行的程序无法运行
- 这样的冲突可以通过将不同版本的问题 DLL 放到应用程序所在的文件夹而不是放到系统文件夹来解决 ,但这抵消 DLL 带来的降低存储的优势
- 目前,由于现代计算机有充足的存储空间和内存,Microsoft .NET 将解决 DLL hell 问题当作自己的目标,它允许同一个共享库的不同版本并列共存(WinSxS)
什么是 DLL 入口?
- 创建 DLL 时,可以选择指定入口点函数。 当进程或线程将自己附加到 DLL 或将自己与 DLL 分离时,将调用入口点函数
- 使用入口点函数初始化数据结构或销毁 DLL 所需的数据结构
- 如果应用程序是多线程的,可以使用线程本地存储 (TLS) 分配入口点函数中每个线程专用的内存
- 当入口点函数返回 FALSE 值时,如果使用加载时动态链接,应用程序将不会启动。 如果使用运行时动态链接,将不会加载单个 DLL
- 入口点函数只应执行简单的初始化任务,不应调用任何其他 DLL 加载或终止函数。 例如,在入口点函数中,不应直接或间接调用 LoadLibrary 函数 LoadLibraryEx 或 函数。 此外,当进程终止 FreeLibrary 时,不应调用 函数
- 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18BOOL APIENTRY DllMain(
HANDLE hModule,// Handle to DLL module
DWORD ul_reason_for_call,// Reason for calling function
LPVOID lpReserved ) // Reserved
{
switch ( ul_reason_for_call )
{
case DLL_PROCESS_ATTACHED: // A process is loading the DLL.
break;
case DLL_THREAD_ATTACHED: // A process is creating a new thread.
break;
case DLL_THREAD_DETACH: // A thread exits normally.
break;
case DLL_PROCESS_DETACH: // A process unloads the DLL.
break;
}
return TRUE;
}
静态库和动态库区别?
- 静态链接库与动态链接库都是共享代码的方式
- 动态库
- DLL 不必被包含在最终 EXE 文件中,使用动态库的时候,往往提供两个文件:一个引入库(后缀为.lib, 但是它与静态库有本质上的区别)和一个 DLL,引入库包含被 DLL 导出的函数和变量的符号名,DLL 包含实际的函数和数据
- 在编译链接可执行文件时,只需要链接引入库,DLL 中的函数代码和数据并不复制到可执行文件中,而是在运行的时候,再去加载 DLL,访问 DLL 中导出的函数
- 在动态链接库中还可以再包含其他的动态或静态链接库
- DLL 的编制与具体的编程语言及编译器无关。只要遵循约定的 DLL 接口规范和调用方式,用各种语言编写的 DLL 都可以相互调用。譬如 Windows 提供的系统 DLL (其中包括了 Windows 的 API),在任何开发环境中都能被调用,不在乎其是 Visual Basic、 Visual C++ 还是 Delphi
- 静态库
- 静态库中的函数和数据被编译进一个二进制文件 (通常扩展名为.LIB)
- 在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件 (.EXE 文件)
- 静态链接库中不能再包含其他的动态链接库或者静态库
应用程序和 DLL 之间的区别?
- 不能直接运行 DLL,而应用程序可以
- 应用程序可以在系统中同时运行其自身的多个实例。 而 DLL 只能有一个实例
- 应用程序可以作为进程进行加载。 它可以管理诸如堆栈、执行线程、全局内存、文件句柄和消息队列之类的资源。 而 DLL 不能管理这些资源
C++ 调用 DLL 的方式?
- 静态调用:在进行隐式调用的时候需要在客户端引入头文件,并在链接时指明 dll 对应的 lib 文件位置和名称
1
- 动态调用:LoadLibrary 将指定的可执行模块映射到调用进程的地址空间,不仅可以加载 DLL,还可以加载可执行模块 (Exe),如果调用成功,LoadLibrary 函数将返回所加载的那个模块的句柄
1
HMODULE LoadLibrary(LPCTSTR lpFileName);
C++ 的动态调用和静态调用 DLL 的区别?
- 动态加载: 比较灵活,可以在需要的时候才加载进来,如果没有,才会报错,并且此错误时可控的
- 静态加载: 则在一个工程编译时链接加载的,即在进程产生之前加载的,但相对不灵活,只能满足一般的需求。如果 exe 所在的文件夹没有 dll 文件及其 lib 文件,则运行 exe 会报错。及 exe 根本就运行不起来,系统提示没有找到 dll 文件等信息。错误信息是不可控的
DLL 如何进行内存管理?
- 加载 DLL 时的内存占用规则
- 在 Win32 中,DLL 文件按照片段(sections)进行组织。每个片段有它自己的属性,如可写或是只读、可执行(代码)或者不可执行(数据)等等
- 这些 section 可分为两种,一个是与绝对地址寻址无关的,所以能被多进程公用;另一个是与绝对地址寻址有关的,这个就必须由每个进程有自己的副本专用
- sections 的这种二分类,在编译 DLL 时就已经由编译器、链接器给标注好了。所以在装入 DLL 时,装入器知道哪些 sections 在内存物理地址空间只需要有一份,供多个进程共享
- DLL 代码段和数据端是否被进程共享?
- DLL 代码段通常被使用这个 DLL 的所有进程所共享。如果代码段所占据的物理内存被收回,它的内容就会被放弃,后面如果需要的话就直接从 DLL 文件重新加载
- 与代码段不同,DLL 的数据段通常是私有的;也就是说,每个使用 DLL 的进程都有自己的 DLL 数据副本
- 作为选择,数据段可以设置为共享,允许通过这个共享内存区域进行进程间通信。但是,因为用户权限不能应用到这个共享 DLL 内存,这将产生一个安全漏洞;也就是一个进程能够破坏共享数据,这将导致其它的共享进程异常。例如,一个使用访客账号的进程将可能通过这种方式破坏其它运行在特权账号的进程。这是在 DLL 中避免使用共享片段的一个重要原因
加载 DLL 时,不同类型数据的内存占用
- 局部变量:每个线程都有自己的栈,DLL 内部的局部变量随所在函数被执行而在各自线程的调用栈上开辟存储空间
- DLL 内部定义的全局变量
- const 全局变量 —— 放入 const 节中,但不是各个进程共享;因为进程加载 DLL 时会初始化只读全局变量的值,这个值由可能是依赖于所在的进程,如 DLL 的函数在该进程中的逻辑地址
- 非 const 全局变量 —— 放入各个进程各自专用的 data 节中。即 DLL 装入时各个进程复制一份自己专用的 DLL 的 data 节
- DLL 以外定义的全局变量
- 使用间址技术,在 DLL 的 data 节中用一个指针数据类型的内存空间来保存一个外部全局变量的地址
DLL 如何进行符号解析与绑定?
- DLL 输出的每个函数都由一个数字序号唯一标识,也可以由可选的名字标识。同样,DLL 引入的函数也可以由序号或者名字标识
- 按照序号引用函数并不一定比按照名字引用函数性能更好
- 绑定的可执行文件如果运行在与它们编译所用的环境一样,函数调用将会较快,如果是在一个不同的环境它们就等同于正常的调用,所以绑定输入函数没有任何的缺点
DLL 的如何进行资源加载?
- EXE 和 DLL 都有其自己的资源(如对话框资源),而且这些资源的 ID 可能重复,默认使用 EXE 的资源
- 如果需要加载、使用 DLL 中的资源,需要通过 DLL 加载后的实例句柄(HINSTANCE)来找到 DLL 的资源
- 应用程序进程本身及其调用的每个 DLL 模块都具有一个全局唯一的 HINSTANCE 句柄,它们代表了 EXE 或 DLL 模块在进程逻辑地址空间中的起始地址
- 进程本身的模块句柄一般为 0x400000,而 DLL 模块默认加载地址为 0x10000000。
- 如果程序同时加载了多个 DLL,则每个 DLL 模块都会有不同的 HINSTANCE
VC 动态链接库有哪 3 种?
- 非 MFCDLL 不采用 MFC 类库结构,其导出函数为标准的 C 接口,能被非 MFC 或 MFC 编写的应用程序所调用
- MFC 规则 DLL 包含一个继承自 CWinApp 的类,但其无消息循环
- MFC 扩展 DLL 采用 MFC 的动态链接版本创建,它只能被用 MFC 类库所编写的应用程序所调用
C++ 如何导出 DLL 的变量、函数、类?
- ** 使用关键字__declspec (dllimport) 或__declspec (dllimport)** 若要在应用程序中使用导出的 DLL 变量、函数、类,您必须使用以下关键字声明使用该关键字:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33// SampleDLL.cpp
// File: SampleDLL.h
// 导出函数
extern __declspec(dllexport) void HelloWorld();
extern __declspec(dllimport) void HelloWorld();
// 导出类
extern class __declspec(dllexport) CTest
{
public:
CTest(){}
int Max(int a, int b);
};
extern class __declspec(dllimport) CTest
{
public:
CTest(){}
int Max(int a, int b);
};
// 导出变量
__declspec(dllexport)int n = 0; - 或创建一个列出导出 DLL 函数的 (.def) 文件
1
2
3
4// SampleDLL.def
//
LIBRARY "sampleDLL"
EXPORTS HelloWorld
如何认识 DLL 关键字__declspec (dllimport)?
- 正确编译代码不需要使用 __declspec (dllimport),但是这样做可以让编译器生成更优质的代码。
- 编译器能够生成更好的代码,因为它可以确定某个函数是否存在于 DLL 中,这使编译器生成的代码可以跳过在跨越 DLL 边界的函数调用中通常存在的间接级别。
- 不过,必须使用 __declspec (dllimport) 来导入 DLL 中使用的变量
DLL 函数的调用约定?
- _cdecl visual studio 默认的调用方式。将只能被 C/C++ 调用,输出函数名前会有下划线,比如_funtionName
- _stdcall windows api 默认的调用式方式,_stdcall 调用约定在输出函数名前加上一个下划线前缀,后面加上一个 “@” 符号和其参数的字节数,格式为_functionname@number。如函数 int func (int a, double b) 的修饰名是_func@12
- _fastcall __fastcall 调用约定在输出函数名前加上一个 “@” 符号,后面也是一个 “@” 符号和其参数的字节数,格式为 @functionname@number
- _pascal 这种规则从左向右传递参数,通过 EAX 返回,堆栈由被调用者清除。并不常见
- _thiscall 仅仅应用于 "C++" 成员函数,并不常见
- extern “C” 输出函数名与原函数名一致
1
2
3
4
5extern "C"
{
_declspec(dllexport) char _great_function(const char* a, int b);
_declspec(dllexport) void _startLedCheck(byte* ImageBuffer, int imageWedth, int imageHeight);
}
WindowsWindows API 中所有的函数都包含在 dll 中,3 个最重要的 DLL?
- Kernel32.dll 包含那些用于管理内存、进程和线程的函数,例如 CreateThread 函数
- User32.dll 包含那些用于执行用户界面任务 (如窗口的创建和消息的传送) 的函数,例如 CreateWindow 函数
- GDI32.dll 包含那些用于画图和显示文本的函数
Windows 动态链接库的搜索顺序?―
- Windows 系统加载动态链接库规则
- 如果内存中已经有同 module 名的 DLL,除非是 DLL redirection 或 manifest,否则直接就用内存中这个 DLL 而不再搜索
- 如果 DLL 名字属于当前 Windows 版本的 Known DLL,则必须用 Known DLL。清单见 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs
- 如果 DLL 有依赖 DLL,操作系统按缺省标准规则根据 module 名字搜索依赖 DLL。即使第一个 DLL 指定了全路径
- Windows Desktop 应用程序的 DLL 标准搜索顺序应用程序所在目录
- 系统目录。GetSystemDirectory 函数返回该目录
- 16 比特系统目录
- Windows 目录。使用 GetWindowsDirectory 函数返回该目录
- 当前(工作)目录
- 环境变量 PATH 中列出的目录
VS2019 开发非托管的动态链接库步骤?
- (1) 头文件定义开放接口并实现
1
2
3
4
5
6
7
8# DDL.h
__declspec(dllexport) int ADD(int a,int b);
# DDL.cpp
int ADD(int a, int b)
{
return a + b;
} - (2) 项目生成改为 DLL,并生成 dll 与 lib
- (3) 新建 C++ 控制台项目,将 DLL 添加到工程上:(1) 直接通过绝对路径添加;(2) 通过在解决方案管理面板中添加头文件和资源文件
1
2 - (4) 调用
1
2
3
4void main()
{
std::cout << ADD(3, 4) << std::endl;
}
如何配置 C++ 开发与 dll 的关系?
方法 | 好处 | 坏处 | 分发方式 | 总结 |
---|---|---|---|---|
手动安装 cuda、cudnn、tensorrt | - | 需要安装环境、设置环境变量 | Dll、exe 单独分发,运行 exe 前,需要先安装环境、配置环境 | 原始安装方式 |
直接拷贝到 exe 所在目录 | u 不用安装环境不用设置环境变量 | 同名 dll 只能放一份,如果对同名不同版本的 dll 有依赖,该方法无效 | Dll 随 exe 分发,现场直接运行 exe 即可使用 | 部软件署快、安装包大 |
定制安装到固定目录,然后设置到环境变量 | 可以将多份 dll 安装到不同的目录下 | u 需要安装、设置环境变量程序真正使用版本可能出错 | Dll、exe 单独分发,运行 exe 前,需要先安装环境、配置环境 | 先装环境,再装软件 |
定制安装到固定目录,然后通过代码动态加载 dll | u 可以将多份 dll 安装到不同的目录下指明程序真正使用的 dll | u 代码绑定了固定路径的 dll 不明确是否需要将所有 dll 进行动态加载 | Dll、exe 单独分发,运行 exe 前,需要先安装环境 | 先装环境,再装软件,且需要改动代码 |
- 方法 2 最近简单,改动少,部署简单,兼容设置环境变量的方式,缺点是不能使用不同版本的 dll,推荐使用
- 方法 3、4 需要先安装环境,设置环境,再运行软件,设置需要改动代码,不建议使用