本文探讨了C++中静态链接库的主要缺点,并系统介绍了动态链接库(.dll)的优势、创建方法与调用机制,同时揭示了跨平台兼容性与潜在安全问题。
静态链接库虽然常见,但也存在一些固有缺陷:
- 体积膨胀:生成的可执行文件体积较大,造成磁盘和内存空间的浪费。我们常用的
printf、memcpy等函数就来自静态库。
- 维护困难:库中实现的修改,需要所有使用该库的项目重新链接。
- 代码冗余:如果多个程序使用同一套静态库,这些程序中将包含大量重复的库代码。
动态链接库(Dynamic Link Library, DLL)则提供了另一种思路:先让程序运行起来,再将库的代码链接并加载到内存中。这样,可执行文件中不再包含库的完整代码。因此,修改动态库后,依赖它的软件通常无需重新链接,修复问题更高效。Windows平台下的动态链接库通常以.dll为扩展名。
当然,动态链接也有其缺点:程序启动可能稍慢,并且如果缺失必要的DLL文件,软件将无法正常运行,这与静态链接的特性恰好相反。
1. 创建DLL项目
在Visual Studio中创建动态链接库项目非常简单。

选择“动态链接库(DLL)”项目模板即可。生成的项目中会包含一个dllmain.cpp文件,它并非DLL所必需的,可以删除。dllmain的主要作用是在DLL被加载或卸载时执行初始化和清理代码。DLL的核心功能是作为函数库供其他程序调用。
我们可以创建一个简洁的项目结构,仅保留MyDll.h和MyDll.cpp两个核心文件。

MyDll.h 头文件:
#pragma once
// 使用 _declspec(dllexport) 声明导出函数
_declspec(dllexport) int Plus(int x, int y);
_declspec(dllexport) int Sub(int x, int y);
_declspec(dllexport) int Mul(int x, int y);
_declspec(dllexport) int Div(int x, int y);
_declspec(dllexport)关键字用于将函数声明为导出函数,编译器会将其信息放入DLL的导出表中,供其他模块使用。
MyDll.cpp 源文件:
#include "MyDll.h"
int Plus(int x, int y)
{
return x + y;
}
int Sub(int x, int y)
{
return x - y;
}
int Mul(int x, int y)
{
return x * y;
}
int Div(int x, int y)
{
return x / y;
}
点击生成后,项目虽然无法直接运行(因为DLL不是可执行程序),但会在输出目录(如Debug或Release)下成功生成.dll文件。同时,还会生成一个配套的.lib文件,这个文件在后续的隐式调用中会用到。
2. DLL的调用方式
DLL的调用主要分为两种:动态使用(显式调用)和静态使用(隐式调用)。
2.1 动态使用(显式调用)
显式调用允许开发者在代码中任意位置手动加载DLL,灵活性高。只有当代码执行到加载逻辑时,DLL才会被载入内存。
新建一个控制台测试项目,并将生成的MyDll.dll文件拷贝到项目目录下。无需头文件(.h)和库文件(.lib)。
测试代码示例:
#include <stdio.h>
#include <windows.h>
int main()
{
// ********** DLL显式调用测试 ***********
// 注意:若编译报错“无法将参数1从`const char *`转换为`LPCWSTR`”,
// 请检查:项目属性 -> 常规 -> 字符集,设置为“使用多字节字符集”;
// 或检查:C/C++ -> 预处理器 -> 预处理定义,确保没有`UNICODE`。
// 1. 定义与DLL函数签名一致的函数指针
typedef int(*lpPlus)(int, int);
lpPlus myPlus;
// 2. 动态加载DLL到内存
HMODULE hMod = LoadLibrary("MyDll.dll");
if (hMod == nullptr) {
printf("加载DLL失败!\n");
return -1;
}
// 3. 获取DLL中函数的地址
// 注意:C++函数名会经过“名称粉碎”,这里需要使用粉碎后的名称
myPlus = (lpPlus)GetProcAddress(hMod, "?Plus@@YAHHH@Z");
if (myPlus == nullptr) {
printf("获取函数地址失败!\n");
FreeLibrary(hMod);
return -1;
}
// 4. 调用函数
int result = myPlus(10, 2);
printf("10 + 2 = %d\n", result);
// 5. 卸载DLL(可选,进程退出时会自动卸载)
FreeLibrary(hMod);
return 0;
}
显式调用的核心步骤:
- 使用
LoadLibrary加载DLL并获取模块句柄。
- 使用
GetProcAddress,通过模块句柄和函数名(或函数序号)获取函数的内存地址。
- 将返回的地址转换为正确的函数指针类型后调用。
- 可选地使用
FreeLibrary卸载DLL。
关于“名称粉碎(Name-Mangling)”:
C++编译器为了实现函数重载和命名空间管理,会对函数名进行修饰,生成一个唯一的内部名称,这个过程称为名称粉碎。因此,在导出表中看到的函数名(如?Plus@@YAHHH@Z)并非源代码中的原名。可以使用Dependency Walker等工具查看粉碎后的名称。显式调用GetProcAddress时,必须使用这个粉碎后的名称。
2.2 静态使用(隐式调用)
隐式调用通过在编译链接期进行设置,使得程序一启动就自动加载所需的DLL。调用代码中看不到直接的加载语句,因此称为“隐式”。
准备工作:
- 将DLL项目生成的
MyDll.dll和MyDll.lib拷贝到测试项目目录。
- 将
MyDll.h头文件也拷贝到测试项目目录。
测试代码示例:
#include <stdio.h>
#include <windows.h>
// ********** DLL隐式调用测试 ***********
#include "MyDll.h" // 包含头文件,声明了函数
#pragma comment(lib, "MyDll.lib") // 告诉链接器需要链接此导入库
// 或者:在项目属性 -> 链接器 -> 输入 -> 附加依赖项 中添加`MyDll.lib`
int main()
{
printf("2 + 3 = %d\n", Plus(2, 3));
return 0;
}
隐式调用的原理是:链接器在编译时通过.lib文件(这里是导入库,仅包含函数名和DLL信息,没有实际代码)解析函数引用;程序运行时,系统加载器会自动查找并加载对应的DLL。
3. 进阶主题与问题解析
3.1 解决名称粉碎问题:extern “C” 与 .def 文件
为了使函数导出名保持可读性,便于跨语言(如C语言)调用,有两种常用方法。
方法一:使用 extern "C"
在函数声明和定义前添加extern "C",指示编译器使用C语言的命名约定,从而避免C++的名称粉碎。
// 声明
extern "C" _declspec(dllexport) int Plus(int x, int y);
// 定义
extern "C" int Plus(int x, int y)
{
return x + y;
}
注意:如果函数使用了__stdcall等调用约定,C语言规则下仍会产生名称修饰。此时函数名可能变为_Plus@8等形式。
方法二:使用模块定义文件 (.def)
.def文件可以精确定义DLL的导出函数名和序号,完全绕过编译器的名称粉碎规则。
首先,在DLL项目中添加一个.def文件(例如MyDll.def):
EXPORTS
Plus @12 // 将Plus函数导出,并指定序号为12
Sub @15 NONAME // 将Sub函数以序号15导出,且不暴露名称
Mul @13
Div @16
然后,需要在项目属性中指定该文件:

两者的重要区别:
extern "C"主要影响编译器的命名规则。
.def文件直接定义了导出表的内容。但需要注意,配套生成的.lib导入库中的函数名可能仍然是粉碎过的。这意味着,仅使用.def文件(不加extern "C")生成的DLL,C语言程序可能无法直接进行隐式调用(因为C链接器找不到lib中粉碎后的名字),但仍可通过显式调用(使用.def中定义的名称)成功使用。理解这一点,对于处理底层链接和跨模块调用问题非常有帮助。
3.2 确保头文件的跨(C/C++)平台兼容性
为了让同一个DLL头文件既能被C++编译器使用,也能被C编译器使用,可以采用如下惯用写法:
// MyDll.h
#ifdef __cplusplus
extern "C" { // 告诉C++编译器,以下函数按C语言规则链接
#endif
_declspec(dllexport) int Plus(int x, int y);
// 导出全局变量
extern _declspec(dllexport) int g_num;
#ifdef __cplusplus
}
#endif
这种写法是系统编程和创建通用库时的常见模式。
3.3 导出C++类
导出C++类相对复杂,因为需要处理类的成员函数。通常需要在头文件中使用条件编译宏来区分“导出”和“导入”场景:
// MyDll.h
#ifdef MYDLL_EXPORTS
#define MYDLL_API __declspec(dllexport)
#else
#define MYDLL_API __declspec(dllimport)
#endif
class MYDLL_API CMyClass
{
public:
CMyClass();
void DoSomething();
// ...
};
在DLL项目的预处理器定义中添加MYDLL_EXPORTS,这样在编译DLL时,MYDLL_API被展开为__declspec(dllexport);而其他项目包含此头文件时,MYDLL_API则被展开为__declspec(dllimport)。
3.4 安全问题:DLL搜索顺序与劫持
当程序隐式调用DLL时,系统会按照特定顺序搜索DLL文件,通常包括:应用程序所在目录、系统目录、环境变量PATH指定的目录等。这个机制可能导致DLL劫持攻击:攻击者将一个恶意的同名DLL放置在应用程序目录或其他更高优先级的搜索路径中,系统会优先加载恶意DLL,从而执行攻击代码。这是网络安全领域一个经典的安全问题。开发者应注意DLL的放置位置,或使用绝对路径等安全加载方式。