extern vs __declspec(dllimport)
本文最后更新于:2024年12月29日 下午
前情提要
众所周知,C/C++
中extern
表明外部链接,说…说明可以在多个文件共享巴拉…的(小声)
记者:那外部链接是什么意思,与之相对的是什么关键字?
记者:extern
用于声明还是定义?
记者:对于函数、变量、类以及dll
分别有什么作用?
:别说了别说了(小声)
extern
该关键字意味着:外部链接(External Linkage),具有 外部链接 的符号是可以在多个源文件中访问的。也就是说,这些符号在程序中是全局可见的,可以在一个源文件中定义,在其他源文件中引用。通过链接器,程序的多个源文件可以共享这些符号
当编译器看到一个外部链接的符号(如函数或全局变量)时,它会知道该符号在当前翻译单元(.cpp
文件)中没有定义,但不会立即报错
而是将该符号的引用推迟到链接阶段,链接器负责在链接时查找其他翻译单元中的定义,并将这些引用解析为实际的地址
注意:如果链接器在链接时找不到外部链接符号的定义,通常会报错(如 LNK1120 错误,”无法解析外部符号”)
例如:
a.cpp
1 |
|
b.cpp
1 |
|
大家可能会奇怪:诶,怎么没有.h
头文件也可以使用其他文件定义的函数了
别急,听我细嗦
默认可见性
其实,函数和全局变量默认就是外部链接的,所以一定程度上,extern
是可以省略的
为什么是一定程度呢,这个有点子复杂
extern 用于声明还是定义
这确实是个值得商榷的问题,从外部链接的作用来看,是为了在使用时可以不去寻找定义,那么用在声明是最合理的:extern int func();
不过其实定义也可以加,但是没什么用
1 |
|
什么是声明,什么是定义
是不是有同学笑了一下(盯——)
这个问题对于函数没有什么异议,有花括号(函数体)就是定义
那么变量呢?
1 |
|
其实只有extern int x;
是声明,其余都是定义
int x;
虽然没有显式初始化,但是作为全局变量,默认初始化为0;
其实对于变量来说,定义意味着分配存储空间,而extern
意味着在别处定义
所以有显示初始化的一定是定义(分配空间),而有extern
且无初始化的才是纯声明
所以刚刚说:“一定程度上,extern
是可以省略的”,指的是:
- 函数、全局变量定义时的
extern
可以省略(默认外部可见) - 在另一个文件声明时,函数的
extern
可以省略,变量不能(否则变成了定义)(😾)
a.cpp(声明)
1 |
|
b.cpp(定义)(可以省略extern
)
1 |
|
谨记:声明可以多次,定义只能一次
#include 头文件的本质(😾)
#include
的本质其实是文本替换:
当预处理器处理到#include
指令时,它会将这一行替换为头文件的全部内容,其实就是复制了一份.h
到.cpp
中,但是可以少写点重复代码(预处理器帮忙复制)
例如,我们平时最常见的写法是这样的:
test.h
1 |
|
test.cpp
1 |
|
main.cpp
1 |
|
其本质上就是:
main.cpp
1 |
|
是不是看出了什么端倪,这不就是上述a.cpp省略extern
的函数声明嘛(抖包袱)
所以我们平时一直在利用函数默认是外部链接这个特性,而不自知
那么代价是什么
由于 #include
是无脑复制,所以就很容易出现重定义问题(复制了太多份定义)
这时候有同学要说了,怎么可能,谁会 #include
多个一样的头文件啊
直接包含可能不太可能,那么间接包含呢?
可能#include "b.h"
,而b.h
中#include "a.h"
这种情况就很难发现了,但是最终都会复制展开到一个翻译单元(.cpp)中,造成重复定义错误
肿么办
一般有两种解决方案:
- 宏定义法
1 |
|
- 第一次包含
a.h
时,#ifndef A_H
判断条件为false
,因此定义了A_H
,并开始包含文件内容 - 第二次再包含
a.h
时,#ifndef A_H
条件为true
,直接跳过,从而避免了重复定义
#pragma once
(编译器指令)
1 |
|
#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 |
|
只有被__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 |
|
看起来很合理对吧,但是在运行时,程序怎么知道test_cpp
函数在哪个dll
文件的哪个位置(偏移量)呢?
所以虽然编译能过,但是链接阶段会直接报错:
1 |
|
因为链接器根本不知道去哪里找这个函数
这不纯纯抓瞎嘛,只要代入机器视角就能发现端倪了
.dll only
我们先来看看假如只有.dll
的情况下,应该如何使用其中的函数吧
main.cpp
1 |
|
这样应该能比较清晰地看明白调用一个dll
中函数的过程
- 加载
dll
进内存(同一个dll
只会加载一份) - 获取函数在
dll
中的地址(指针) - 将地址转换为对应的函数指针就可以调用了
这种方法不需要依赖.h
和.lib
,但是调用起来稍微复杂一点
通常,这种方式被称为:显式链接(Explicit linking),或 dynamic load or run-time dynamic linking
一般用在插件系统(运行时加载新插件,而无需重新编译和分发主程序)或者未公开API
例如:SetWindowCompositionAttribute
1 |
|
这个函数可以为窗口实现毛玻璃效果,但是可能由于性能原因,并没有公开
微软文档:
该函数没有关联的导入库或头文件;您必须使用LoadLibrary和GetProcAddress函数来调用它。该 API 是从 user32.dll 导出的
Why extern “C”
相信大家已经注意到了,刚刚是通过GetProcAddress(hMouse, "test")
函数获取函数地址的
但是为什么”test”就能定位到test
函数呢(虽然听起来有点脱裤子放屁)
但是你仔细想想,C++
的函数签名真的是可以通过函数名唯一确定吗?视重载为何物
1 |
|
所以,其实上文的test
函数其实并不是以C++
函数的形式导出的
dllmain.cpp
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 |
|
比如上述test_cpp
函数在dll
中表示为:?test_cpp@@YAHH@Z
,而不是test_cpp
- 以
?
标识函数名的开始,后跟函数名,函数名后面以@@YA
(对于__cdecl
调用方式)标识参数表的开始,后跟参数表 H
代表int
,D--char
,X--void
等等- 第一个
H
代表返回值,第二个H
代表参数 - 最后的
@Z
标记参数表结尾
ref: C++ 编译器的函数名修饰规则 - yxysuanfa - 博客园
所以我们可以这样获取一个C++
函数地址
1 |
|
看起来有点鬼畜,但是没办法
使用extern "C"
可以避免进行名称修饰,让dll
函数调用简单很多
当然,简单并不是最关键的理由
这样可以兼容C程序
可以跨编译器调用
dll
因为不同编译器的名称修饰规则不同,例如
MinGW
是以_Z
开头,而MSVC
是以?
开头,所以无法互相识别
要注意一个显而易见的道理,使用extern "C"
之后会被视为C
函数,所以函数重载不可用
1 |
|
当然,如果只是为了自己使用,或者条件允许(相同编译器)的情况下,可以不使用extern "C"
,例如:Qt
.dll with .lib
好的,通过上述的例子,我们已经知道了直接调用裸dll
中函数的复杂性
不仅要考虑dll
的加载释放,还要考虑修饰后的不可读名称,晕头转向
此时就轮到.lib
出场了
本质上,.lib
文件就是一个中介,帮助程序找到函数所在dll
和地址
无非就是这两行:
1 |
|
用dumpbin /all
命令查看.lib
文件内部保存的详细符号信息
1 |
|
有了这些信息,就可以大大简化我们对.dll
的使用
use .lib
使用.lib
也很简单
- 对于
MSVC
编译器,加上一行编译器指令即可
main.cpp
1 |
|
- 对于
cmake
,更加通用的做法是:
CMakeLists.txt
1 |
|
如此一来,链接器在根据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 |
|
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 |
|
qsystemerror.h
1 |
|
玄机就在Q_CORE_EXPORT
中:
1 |
|
如果定义了QT_BUILD_CORE_LIB
,Q_CORE_EXPORT
就是__declspec(dllexport)
,否则为__declspec(dllimport)
既然导入导出的声明代码是类似的,就可以通过宏开关的方式实现复用,妙哉
编译 dll
我们唯一要做的就是在编译dll
时启动这个宏QT_BUILD_CORE_LIB
例如在CMakelists.txt
中开启这个宏,或者直接在.cpp
文件中#define
dll.h
1 |
|
dllmain.cpp
1 |
|
使用 dll
然后,在客户端使用dll
时,也就可以复用同一套.h
进行dllimport
了,perfect
main.cpp
1 |
|
此时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 |
|
观察这两行声明,同时用到了extern
和__declspec(dllimport)
,非常令人头大
不慌,我们先来看看第二行:test_cpp
- 首先,我们知道函数默认是外部链接的,意味着
extern
可以省略
1 |
|
- 其次,对于函数而言,
__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 |
|
不过对于以C
格式进行导出的test
函数,还是需要保留extern "C"
的,否则无法找到对应的符号
1 |
|
根据文档,变量不能省略__declspec(dllimport)
所以需要:
1 |
|
So, 他俩的区别是什么
当然,__declspec(dllimport)
只能用于dll
的处理,这就不多说了
单从导入dll
的函数和变量来看,他俩还是相辅相成、相爱相杀,不分伯仲的
Class 的导出
说完了函数、变量,我们再来聊聊dll
中类的导出吧
其实也很简单,只要加上API_EXPORT
,其他的都没有变化:
dll.h
1 |
|
dllmain.cpp
1 |
|
使用时,也只需要包含dll.h
即可
main.cpp
1 |
|
类的声明
不过要注意,我们包含了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++博客
what does __declspec(dllimport) really mean? - Stack Overflow