extern vs __declspec(dllimport)

本文最后更新于:2024年12月29日 下午

前情提要

众所周知,C/C++extern表明外部链接,说…说明可以在多个文件共享巴拉…的(小声)

记者:那外部链接是什么意思,与之相对的是什么关键字?

记者:extern用于声明还是定义?

记者:对于函数、变量、类以及dll分别有什么作用?

:别说了别说了(小声)

extern

该关键字意味着:外部链接(External Linkage),具有 外部链接 的符号是可以在多个源文件中访问的。也就是说,这些符号在程序中是全局可见的,可以在一个源文件中定义,在其他源文件中引用。通过链接器,程序的多个源文件可以共享这些符号

当编译器看到一个外部链接的符号(如函数或全局变量)时,它会知道该符号在当前翻译单元(.cpp文件)中没有定义,但不会立即报错

而是将该符号的引用推迟到链接阶段,链接器负责在链接时查找其他翻译单元中的定义,并将这些引用解析为实际的地址

注意:如果链接器在链接时找不到外部链接符号的定义,通常会报错(如 LNK1120 错误,”无法解析外部符号”)

例如:

a.cpp

1
2
3
4
5
6
#include <iostream>

extern int func();
int main() {
std::cout << func() << std::endl;
}

b.cpp

1
2
3
int func() {
return 42;
}

大家可能会奇怪:诶,怎么没有.h头文件也可以使用其他文件定义的函数了

别急,听我细嗦

默认可见性

其实,函数和全局变量默认就是外部链接的,所以一定程度上,extern是可以省略的

为什么是一定程度呢,这个有点子复杂

extern 用于声明还是定义

这确实是个值得商榷的问题,从外部链接的作用来看,是为了在使用时可以不去寻找定义,那么用在声明是最合理的:extern int func();

不过其实定义也可以加,但是没什么用

1
2
3
4
extern int var = -1;
extern int func() {
return 42;
}

什么是声明,什么是定义

是不是有同学笑了一下(盯——)

这个问题对于函数没有什么异议,有花括号(函数体)就是定义

那么变量呢?

1
2
3
4
int x;
int x = 1;
extern int x;
extern int x = 1;

其实只有extern int x;是声明,其余都是定义

int x;虽然没有显式初始化,但是作为全局变量,默认初始化为0;

其实对于变量来说,定义意味着分配存储空间,而extern意味着在别处定义

所以有显示初始化的一定是定义(分配空间),而有extern且无初始化的才是纯声明

所以刚刚说:“一定程度上,extern是可以省略的”,指的是:

  • 函数、全局变量定义时的extern可以省略(默认外部可见)
  • 在另一个文件声明时,函数的extern可以省略,变量不能(否则变成了定义)(😾)

a.cpp(声明)

1
2
extern int var; // 不能省略extern,否则触发"重定义"错误
int func(); // 可以省略extern

b.cpp(定义)(可以省略extern

1
2
3
4
int var = -1;
int func() {
return 42;
}

谨记:声明可以多次,定义只能一次

#include 头文件的本质(😾)

#include 的本质其实是文本替换:

当预处理器处理到#include 指令时,它会将这一行替换为头文件的全部内容,其实就是复制了一份.h.cpp中,但是可以少写点重复代码(预处理器帮忙复制)

例如,我们平时最常见的写法是这样的:

test.h

1
int func();

test.cpp

1
2
3
int func() {
return 42;
}

main.cpp

1
2
3
4
5
#include <iostream>
#include "test.h"
int main() {
std::cout << func() << std::endl;
}

其本质上就是:

main.cpp

1
2
3
4
5
#include <iostream>
int func(); // 拷贝了 test.h 中的内容
int main() {
std::cout << func() << std::endl;
}

是不是看出了什么端倪,这不就是上述a.cpp省略extern的函数声明嘛(抖包袱)

所以我们平时一直在利用函数默认是外部链接这个特性,而不自知

那么代价是什么

由于 #include是无脑复制,所以就很容易出现重定义问题(复制了太多份定义)

这时候有同学要说了,怎么可能,谁会 #include多个一样的头文件啊

直接包含可能不太可能,那么间接包含呢?

可能#include "b.h",而b.h#include "a.h"

这种情况就很难发现了,但是最终都会复制展开到一个翻译单元(.cpp)中,造成重复定义错误

肿么办

一般有两种解决方案:

  1. 宏定义法
1
2
3
4
5
6
7
// a.h
#ifndef A_H // 注意宏不要太短,防止冲突
#define A_H

// 头文件内容

#endif // A_H
  • 第一次包含 a.h 时,#ifndef A_H 判断条件为 false,因此定义了 A_H,并开始包含文件内容
  • 第二次再包含 a.h 时,#ifndef A_H 条件为 true,直接跳过,从而避免了重复定义
  1. #pragma once(编译器指令)
1
2
3
// a.h
#pragma once
// 头文件内容

#pragma once更简洁,也是更现代的做法,支持的编译器很多,但某些老旧的编译器(或者特定的工具链)可能不支持

所以如果考虑兼容性的话,可以采用第一种做法(貌似Clion默认生成的是第一种)

__declspec(dllimport)

__declspec实际上是微软对于C/C++特定拓展,并不是语言本身的标准,只在Windows平台使用

// 更搞的是,甚至还有_declspec(单下划线版本),其实是同义词,为了兼容旧版编译器

显然,从名字来看,__declspec(dllimport)是用于从dll中导入符号(函数、变量等),与之相对应的是__declspec(dllexport),用于导出

所以很多同学可能没有接触过,因为只在编写dll(Dynamic-Link Library)库时需要用到

太抽象了,多说无益,还是 Show me the code

一般用Visual Studio来编写(新建dll工程就有模板)ref : Windows Hook 技术浅析 - MrBeanC-Blog

dllmain.cpp

1
2
3
4
__declspec(dllexport)
int test_cpp(int a) {
return a - 1;
}

只有被__declspec(dllexport)标记的符号才会被导出(这和linux.so(Shared Object file)正好相反,.so是默认导出所有符号)

直接编译(生成解决方案)就可以得到产物:test.dll test.lib

.dll & .lib

为什么Windows下的动态库有两个文件构成,而Linux只有一个.so

这个说起来就比较复杂了

.lib

.lib实际上有两种用途

  • 静态库(Static Library):包含所有代码实现,直接编译到可执行文件中
  • 导入库(Import Library):此时需要和dll配合使用,此时的.lib文件内并不包含具体的代码实现,而是一个符号表,用于让静态链接器知道如何连接到dll中的符号,起到中介的作用

没有简单的方法可以区分它们,除了作为 dll 导入库的 lib 通常比匹配的静态 lib 小(通常小得多)
Dynamic-link library - Wikipedia

试想一下,我们如何使用刚刚编译出来的dll库,也许是这样:

main.cpp

1
2
3
4
5
__declspec(dllimport) int test_cpp(int a);
int main() {
test_cpp(2);
return 0;
}

看起来很合理对吧,但是在运行时,程序怎么知道test_cpp函数在哪个dll文件的哪个位置(偏移量)呢?

所以虽然编译能过,但是链接阶段会直接报错:

1
main.cpp.obj : error LNK2019: 无法解析的外部符号 "int __cdecl test_cpp(int)" (?test_cpp@@YAHH@Z),函数 main 中引用了该符号

因为链接器根本不知道去哪里找这个函数

这不纯纯抓瞎嘛,只要代入机器视角就能发现端倪了

.dll only

我们先来看看假如只有.dll的情况下,应该如何使用其中的函数吧

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <windows.h>

int main() {
typedef int (* pfnTestC)(int); // 定义函数指针类型
HMODULE hMouse = LoadLibrary("mouseHook.dll"); // 加载dll (Only once for each process)
if (hMouse) {
auto testC = (pfnTestC) GetProcAddress(hMouse, "test"); // 获取test函数在dll中的地址
if (testC)
std::cout << "dynamic load: " << testC(5) << std::endl;
}
}

这样应该能比较清晰地看明白调用一个dll中函数的过程

  1. 加载dll进内存(同一个dll只会加载一份)
  2. 获取函数在dll中的地址(指针)
  3. 将地址转换为对应的函数指针就可以调用了

这种方法不需要依赖.h.lib,但是调用起来稍微复杂一点

通常,这种方式被称为:显式链接Explicit linking),或 dynamic load or run-time dynamic linking

一般用在插件系统(运行时加载新插件,而无需重新编译和分发主程序)或者未公开API

例如:SetWindowCompositionAttribute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void setWindowBlur(HWND hWnd) {
typedef BOOL(WINAPI* pfnSetWindowCompositionAttribute)(HWND, WINDOWCOMPOSITIONATTRIBDATA*);

HMODULE hUser = GetModuleHandle(L"user32.dll"); // 在确定dll已经被加载的情况下,可以用 GetModuleHandle 替代 LoadLibrary
if (hUser) {
auto setWindowCompositionAttribute = (pfnSetWindowCompositionAttribute) GetProcAddress(hUser, "SetWindowCompositionAttribute");
if (setWindowCompositionAttribute) {
ACCENT_POLICY accent = {ACCENT_ENABLE_BLURBEHIND, 0, 0, 0};
WINDOWCOMPOSITIONATTRIBDATA data;
data.Attrib = WCA_ACCENT_POLICY;
data.pvData = &accent;
data.cbData = sizeof(accent);
setWindowCompositionAttribute(hWnd, &data);
}
}
}

这个函数可以为窗口实现毛玻璃效果,但是可能由于性能原因,并没有公开

微软文档:
该函数没有关联的导入库或头文件;您必须使用LoadLibraryGetProcAddress函数来调用它。该 API 是从 user32.dll 导出的

Why extern “C”

相信大家已经注意到了,刚刚是通过GetProcAddress(hMouse, "test")函数获取函数地址的

但是为什么”test”就能定位到test函数呢(虽然听起来有点脱裤子放屁)

但是你仔细想想,C++的函数签名真的是可以通过函数名唯一确定吗?视重载为何物

1
2
int test(int a);
int test(float a);

所以,其实上文的test函数其实并不是以C++函数的形式导出的

dllmain.cpp

1
2
3
4
5
6
7
8
9
extern "C" __declspec(dllexport)
int test(int a) {
return a + 1;
}

__declspec(dllexport)
int test_cpp(int a) {
return a - 1;
}

没错,和test_cpp相比,test函数多了extern "C"

Name Mangling

extern "C"意味着把test作为一个C函数进行导出,不要进行名称修饰(Name Mangling

ref: How to use extern “C” in C++ - CodersLegacy

简单来说,名称修饰是由于C++函数存在重载,所以函数名具有二义性,需要在符号中加入其他信息来区分彼此

我们可以使用VS自带的dumpbin.exe工具来查看dll中的符号

// 路径可能是:C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.42.34433\bin\Hostx86\x86\dumpbin.exe

// 打开Developer Command Prompt for VS 2022可以自动加载到环境变量

1
2
3
4
5
6
7
8
9
10
// Developer Command Prompt for VS 2022
dumpbin /exports mouseHook.dll

ordinal hint RVA name
1 0 000112F3 ?test_cpp@@YAHH@Z = @ILT+750(?test_cpp@@YAHH@Z)
2 1 000112D5 ?test_macro@@YAHH@Z = @ILT+720(?test_macro@@YAHH@Z)
3 2 0001D000 ?test_var@@3HA = ?test_var@@3HA (int test_var)
4 3 000112F8 clearHook = @ILT+755(clearHook)
5 4 000110C3 setMouseHook = @ILT+190(setMouseHook)
6 5 0001114A test = @ILT+325(test)

比如上述test_cpp函数在dll中表示为:?test_cpp@@YAHH@Z,而不是test_cpp

  • ?标识函数名的开始,后跟函数名,函数名后面以@@YA(对于__cdecl调用方式)标识参数表的开始,后跟参数表
  • H代表intD--charX--void等等
  • 第一个H代表返回值,第二个H代表参数
  • 最后的@Z标记参数表结尾

ref: C++ 编译器的函数名修饰规则 - yxysuanfa - 博客园

所以我们可以这样获取一个C++函数地址

1
GetProcAddress(hMouse, "?test_cpp@@YAHH@Z");

看起来有点鬼畜,但是没办法

使用extern "C"可以避免进行名称修饰,让dll函数调用简单很多

当然,简单并不是最关键的理由

  1. 这样可以兼容C程序

  2. 可以跨编译器调用dll

    因为不同编译器的名称修饰规则不同,例如MinGW是以_Z开头,而MSVC是以?开头,所以无法互相识别

要注意一个显而易见的道理,使用extern "C"之后会被视为C函数,所以函数重载不可用

1
C2733 “test”: 无法重载具有外部 "C" 链接的函数

当然,如果只是为了自己使用,或者条件允许(相同编译器)的情况下,可以不使用extern "C",例如:Qt

.dll with .lib

好的,通过上述的例子,我们已经知道了直接调用裸dll中函数的复杂性

不仅要考虑dll的加载释放,还要考虑修饰后的不可读名称,晕头转向

此时就轮到.lib出场了

本质上,.lib文件就是一个中介,帮助程序找到函数所在dll和地址

无非就是这两行:

1
2
LoadLibrary("mouseHook.dll");
GetProcAddress(hMouse, "?test_cpp@@YAHH@Z");

dumpbin /all命令查看.lib文件内部保存的详细符号信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dumpbin /all mouseHook.lib

Archive member name at 7E0: mouseHook.dll/
...

Version : 0
Machine : 8664 (x64)
TimeDateStamp: FB36C7D7
SizeOfData : 00000020
DLL name : mouseHook.dll
Symbol name : ?test_cpp@@YAHH@Z (int __cdecl test_cpp(int))
Type : code
Name type : name
Hint : 0
Name : ?test_cpp@@YAHH@Z
...

有了这些信息,就可以大大简化我们对.dll的使用

use .lib

使用.lib也很简单

  1. 对于MSVC编译器,加上一行编译器指令即可

main.cpp

1
2
3
4
5
6
#pragma comment(lib, "E:\\Projects\\cpp\\tests\\dllimport\\mouseHook.lib") // 如果是系统库 一般只需要相对路径
__declspec(dllimport) int test_cpp(int a);
int main() {
test_cpp(2);
return 0;
}
  1. 对于cmake,更加通用的做法是:

CMakeLists.txt

1
2
add_executable(${PROJECT_NAME} main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/mouseHook.lib)

如此一来,链接器在根据int test_cpp(int a)得到符号?test_cpp@@YAHH@Z

就能通过.lib找到对应的dll名称mouseHook.dll,以及相应的函数地址了

在程序启动时,会自动寻找对应的dll并加载

这被称为:隐式链接Implicit linking),或 static load or load-time dynamic linking

微软文档

隐式链接到许多 DLL 的应用程序启动速度可能会很慢,因为 Windows 在加载应用程序时会加载所有 DLL
为了提高启动性能,应用程序可能只对加载后立即需要的 DLL 使用隐式链接。它可能仅在需要时使用显式链接来加载其他 DLL

默认库

其实,我们调用Windows API时调用的就是动态库,但是为什么好像一般不需要指定.lib

这是因为编译器帮我们链接了常用的系统默认库

Visual Studio,“配置属性”>“链接器”>“命令行”属性页中可以看到:

1
"kernel32.lib" "user32.lib" "gdi32.lib" "winspool.lib" "comdlg32.lib" "advapi32.lib" "shell32.lib" "ole32.lib" "oleaut32.lib" "uuid.lib" "odbc32.lib" "odbccp32.lib"

dll 搜索目录

ref: Dynamic-link library search order - Win32 apps | Microsoft Learn

那么程序启动后会在哪里搜索dll

文档看起来有点复杂,不过一般来说,只需要考虑:

  • exe所在目录
  • 系统文件夹
  • 当前目录
  • PATH环境变量

dll - 统一头文件

我们都知道,Qt基本上是动态链接的

但是平时是不是没有特别在意过这个事实

我指的是,使用起来相当无缝,以至于并不需要在意是动态/静态库

为什么呢,为什么Qt可以有这样的体验?

问题还是出在头文件上,我们之前提到了.lib .dll,唯独没有提到.h

例如:

对于QString qt_error_string(int code)函数,我并不需显式地__declspec(dllimport)或者extern

只需要#include<qlogging.h>或者#include<QDebug>即可使用该函数

qlogging.h

1
Q_CORE_EXPORT QString qt_error_string(int errorCode = -1);

qsystemerror.h

1
2
3
4
5
#include<xx.h>
QString qt_error_string(int code)
{
return windowsErrorString(code == -1 ? GetLastError() : code);
}

玄机就在Q_CORE_EXPORT中:

1
2
3
4
5
6
7
8
9
10
11
12
#  define Q_DECL_EXPORT __declspec(dllexport)
# define Q_DECL_IMPORT __declspec(dllimport)

#if defined(QT_SHARED) || !defined(QT_STATIC)
# if defined(QT_BUILD_CORE_LIB)
# define Q_CORE_EXPORT Q_DECL_EXPORT
# else
# define Q_CORE_EXPORT Q_DECL_IMPORT
# endif
#else
# define Q_CORE_EXPORT
#endif

如果定义了QT_BUILD_CORE_LIBQ_CORE_EXPORT就是__declspec(dllexport),否则为__declspec(dllimport)

既然导入导出的声明代码是类似的,就可以通过宏开关的方式实现复用,妙哉

编译 dll

我们唯一要做的就是在编译dll时启动这个宏QT_BUILD_CORE_LIB

例如在CMakelists.txt中开启这个宏,或者直接在.cpp文件中#define

dll.h

1
2
3
4
5
6
7
8
9
#pragma once

#if defined(BUILDING_DLL)
# define API_EXPORT __declspec(dllexport)
#else
# define API_EXPORT __declspec(dllimport)
#endif

API_EXPORT int test_macro(int a);

dllmain.cpp

1
2
3
4
5
6
#define BUILDING_DLL
#include "dll.h"

int test_macro(int a) {
return std::pow(a, 2);
}
使用 dll

然后,在客户端使用dll时,也就可以复用同一套.h进行dllimport了,perfect

main.cpp

1
2
3
4
5
6
#pragma comment(lib, "E:\\Projects\\cpp\\tests\\dllimport\\mouseHook.lib") // 推荐 CMakeLists.txt 统一控制
#include "dll.h" // 本质是拷贝
int main() {
test_macro(2);
return 0;
}

此时API_EXPORT会自动变成__declspec(dllimport),进行导入,十分无感

甚至都不需要手动写什么函数声明了😄

Why dll

说了这么多,话说我们到底为什么要用dll呢?

如果不是使用dll动态库,其实我们就只剩下了两种选择:.lib静态库 & 拷贝源码

1.静态库

静态库会在编译链接时被打包进最终的可执行文件中,运行时不再依赖额外的dll文件,更方便

但带来的是exe文件体积膨胀,启动速度变慢,以及代码复用性变差等问题

同时,如果想要升级外部库代码,就必须重新编译链接,不便于软件升级

Qt貌似不太允许静态编译,因为这样就隐藏了Qt的文件标识,属于白嫖行为

2.源码

如果你既不想用静态库,又不想用动态库,那么你只能拷贝第三方库源代码了对吧

这个,很难评😄

dll 的优势

Okay,那么最后再总结一下dll的优点,坚定一下打工人使用动态库的决心

  • 内存共享:多个进程可以共享同一个 DLL 文件,操作系统只需要在内存中加载一次,节省了大量的内存资源
    • 现代的程序一般都自带第三库,例如Qt5Core.dll,假如目录/路径不同,可能会加载多份(?)
  • 可扩展性:可以将核心功能和插件分离,在运行时动态加载不同的模块,方便地进行功能扩展而无需修改主程序
  • 简化更新:如果需要更新某个 DLL 文件的功能或修复 Bug,只需要替换 DLL 文件,无需重新编译整个应用程序
    • 好像破解也会替换dll hhh

extern vs __declspec(dllimport)

就是为了这点醋,才包了这顿饺子

我相信大家一定有这样的疑惑:extern__declspec(dllimport)都是用来声明外部符号,他们之间有什么区别呢?

1
2
extern "C" __declspec(dllimport) int test(int a);
extern __declspec(dllimport) int test_cpp(int a);

观察这两行声明,同时用到了extern__declspec(dllimport),非常令人头大

不慌,我们先来看看第二行:test_cpp

  • 首先,我们知道函数默认是外部链接的,意味着extern可以省略
1
__declspec(dllimport) int test_cpp(int a);
  • 其次,对于函数而言,__declspec(dllimport)也是可选的,这只是为了优化

微软文档
Using __declspec(dllimport) is optional on function declarations, but the compiler produces more efficient code if you use this keyword. However, you must use __declspec(dllimport) for the importing executable to access the DLL’s public data symbols and objects. Note that the users of your DLL still need to link with an import library.
在函数声明中使用**__declspec(dllimport)是可选的,但如果使用此关键字,编译器会生成更高效的代码。但是,您必须使用__declspec(dllimport)**作为导入可执行文件才能访问 DLL 的公共数据符号和对象。请注意,DLL 的用户仍然需要链接导入库

所以其实只要这样写就可以:

1
int test_cpp(int a);

不过对于以C格式进行导出的test函数,还是需要保留extern "C"的,否则无法找到对应的符号

1
extern "C" int test(int a);

根据文档,变量不能省略__declspec(dllimport)

所以需要:

1
2
3
__declspec(dllimport) int test_var;
// or
extern __declspec(dllimport) int test_var; // 这里 extern 可选,因为 dllimport 肯定表明了是外部链接

So, 他俩的区别是什么

当然,__declspec(dllimport)只能用于dll的处理,这就不多说了

单从导入dll的函数和变量来看,他俩还是相辅相成、相爱相杀,不分伯仲的

Class 的导出

说完了函数、变量,我们再来聊聊dll中类的导出吧

其实也很简单,只要加上API_EXPORT,其他的都没有变化:

dll.h

1
2
3
4
5
6
7
8
9
#pragma once
...

class API_EXPORT Test {
int a;
public:
Test(int a);
int get();
};

dllmain.cpp

1
2
3
4
5
6
7
Test::Test(int a) {
this->a = a;
}

int Test::get() {
return a;
}

使用时,也只需要包含dll.h即可

main.cpp

1
2
3
4
5
6
#pragma comment(lib, "mouseHook.lib")
#include "dll.h"
int main() {
Test t(1024);
std::cout << t.get() << std::endl;
}

类的声明

不过要注意,我们包含了dll.h,事实上就是包含了Test类的完整声明

如果直接写class API_EXPORT Test;,就会由于类信息不完整导致编译报错

其实仔细想想,类内包含成员变量和成员函数,导出类的本质就是导出变量函数

如果没有完整声明,只有类名,那属于是强人所难了

所以从这个角度来讲,extern也一样,对类没有什么帮助,还是需要依赖.h中的类声明

拓展性

上述直接导出Class的方法固然非常方便,也被广泛使用(Qt

但是存在一定的局限性,如果我们改动了类的声明(增添成员或更换顺序),会导致.lib也发生变化,需要重新编译主程序才能继续使用.dll

为了避免这一现象,我们可以使用抽象接口方式导出类,对类细节进行封装

具体可以参考:Dll导出C++类的3种方式_c++ 导出类-CSDN博客

Peace

Ref

extern关键字(声明和定义的区别)_外部函数和外部变量在声明时,都不能省略关键词extern-CSDN博客

How to use extern “C” in C++ - CodersLegacy

C++ 编译器的函数名修饰规则 - yxysuanfa - 博客园

Win10 查看 DLL 中的函数_查看dll函数及参数-CSDN博客

What is the effect of extern “C” in C++? - Stack Overflow

Dynamic-link library - Wikipedia

c++ - How does the Import Library work? Details? - Stack Overflow

Link an executable to a DLL | Microsoft Learn

Windows环境下,.lib导入库 详解 - suphgcm - 博客园

Import into an application using __declspec(dllimport) | Microsoft Learn

DLL入门浅析(3)——从DLL中导出变量 - C++ Programmer - C++博客

Dll导出C++类的3种方式_c++ 导出类-CSDN博客

浅谈Windows中DLL导出类 - JinJ - 博客园

what does __declspec(dllimport) really mean? - Stack Overflow

【重学C/C++系列(二)】:extern关键字用法全解析 - 知乎


extern vs __declspec(dllimport)
https://mrbeancpp.github.io/2024/12/25/extern-vs-declspec-dllimport/
作者
MrBeanC
发布于
2024年12月25日
许可协议