概述
现象
- 有个
DLL
是给JavaScript
调用的,发现它加载了这个库后,给Init
函数传入了两个参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const module = await lib.LoadNativeLibrary("utils/defrag.dll", imports); if (!module) { return; } this.module_ = module; this.engine_id_ = this.module_.CreateDefrag(); if (this.engine_id_ == 0) { return; } await this.module_.Init.promise(this.engine_id_, this.progress_c_callback_) |
- 实际上,该动态库导出的函数中,
Init
是别名,为InitDefrag
1 2 3 4 5 6 7 8 9 10 |
EXPORTS CreateDefrag DestroyDefrag Init = InitDefrag GetDiskInfoList StartAnalyse StopAnalyse StartDefragment StopDefragment IsRunning = IsDefragRunning |
- 而项目中名为
InitDefrag
的函数只有一个,且只有一个参数
1 |
bool WINAPI InitDefrag(DEFRAG_CALLBACK callback); |
结论
- 是正确的用法
- 因为上述的
InitDefrag
是类的public
成员函数 - 传入的第一个参数作为了隐式的
this
指针 (DefragTest*
) - 这种调用方式是有效的标准技术
- 因为上述的
可能存在的问题
- 内存管理陷阱
- 既然这个接口需要传入一个隐式的
this
,那就需要使用者最后要手动对这个this
进行一定的管理,比如可能需要使用者通过这个this
调用类的一些Uninit
或者Destroy
的逻辑
- 既然这个接口需要传入一个隐式的
1 2 3 4 5 6 7 8 |
if (m_pDefrag) { pFNDestroyDefrag pDestroyDefrag = (pFNDestroyDefrag)GetProcAddress(m_hModule, "DestroyDefrag"); if (pDestroyDefrag) { pDestroyDefrag(); } } |
- 二进制接口(
ABI
)稳定
改动类型 | 影响 |
添加虚函数 | 虚表偏移改变→崩溃 |
调整成员顺序 | 成员偏移改变→数据错位 |
修改调用约定 | 堆栈管理不一致 |
- 多线程安全
1 2 3 4 5 6 |
// InitDefrag 需要保证线程安全 class DefragTest { std::mutex mtx; // 危险!不同模块STL实现可能不同 // 应使用操作系统原始同步对象 CRITICAL_SECTION cs; }; |
问题详解
添加虚函数 虚表偏移改变→崩溃
- 概述
- 添加虚函数会导致虚函数表(
vtable
)偏移量改变,这是C++
二进制兼容性中最危险的问题之一
- 添加虚函数会导致虚函数表(
- 虚函数表结构
1 2 3 4 5 6 |
class DefragTest { public: virtual ~DefragTest(); // vtable 条目 0 virtual bool InitDefrag(); // vtable 条目 1 // 添加前 vtable 大小 = 2 }; |
1 2 3 4 5 |
+----------------+ | vptr | --> 指向 vtable +----------------+ | 成员变量... | +----------------+ |
1 2 3 |
; 调用 InitDefrag mov eax, [ecx] ; 获取vtable地址 (ecx=this指针) call [eax+4] ; 调用vtable第2个条目(索引1) |
- 现在升级版本添加新虚函数:
1 2 3 4 5 6 7 |
// DLL 2.0 版本 class DefragTest { public: virtual ~DefragTest(); // vtable[0] virtual bool CheckCompatibility(); // 新增 vtable[1] 🔴 virtual bool InitDefrag(); // 下移为 vtable[2] ⚠️ }; |
- 奔溃场景模拟:
EXE
使用旧版本头文件编译,如1
- 实际加载新版本
DLL
对象,如2
EXE
调用[eax+4]
→ 实际调用了CheckCompatibility
1 2 3 4 5 6 7 |
// 1 // EXE以为InitDefrag仍在vtable[1] m_pDefrag->InitDefrag(); // 实际生成: // mov eax, [ecx] // call [eax+4] // 期望调用InitDefrag |
1 2 3 4 5 6 |
// 2 对象vtable: [0]: ~DefragTest [1]: CheckCompatibility // 实际被调用! [2]: InitDefrag |
其他操作也会导致上述类似崩溃
- 调整成员顺序
- 改变继承关系
- 修改调用约定
- 添加新成员
RTTI
改动
解决方案
概述
- 安全的跨模块
C++
类设计
接口继承法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 共享头文件 test_interface.h struct IDefrag { virtual bool InitDefrag() = 0; // 纯虚接口 virtual ~IDefrag() = default; }; // DLL实现 class DefragImpl : public IDefrag { public: bool InitDefrag() override {...} // 实现 }; // 工厂函数 extern "C" __declspec(dllexport) IDefrag* CreateDefrag(); |
PIMPL
模式(零虚表依赖)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 共享头文件 class DefragWrapper { public: DefragWrapper(); ~DefragWrapper(); bool InitDefrag(); private: struct Impl; Impl* pImpl; // 不透明指针 }; // DLL实现 struct DefragWrapper::Impl { // 自由修改内部实现 bool InitDefragImpl() {...} }; bool DefragWrapper::InitDefrag() { return pImpl->InitDefragImpl(); } |
COM
组件(工业级方案)
1 2 3 4 5 6 7 8 |
// 声明接口 struct __declspec(uuid("XXXX-XXXX")) IDefrag : IUnknown { STDMETHOD(InitDefrag)() = 0; }; // 调用方 CoCreateInstance(CLSID_Defrag, IID_IDefrag, (void**)&pDefrag); pDefrag->InitDefrag(); |
其他
C++
的this
指针传递规则
平台 | this指针传递方式 |
x86 (32位) | 通过ECX寄存器传递(默认thiscall) |
x64 (64位) | 通过RCX寄存器传递 |
当使用WINAPI | 强制改为栈传递(类似stdcall) |
关于GetProcAddress
- 概述
- 在
Windows
动态库中,普通成员函数(非静态)是无法直接通过GetProcAddress
按名称获取的,因为编译器会对成员函数名进行修饰,并且调用时隐含this
指针,导致函数签名与普通C
函数不同
- 在
- 为什么之前的方式能够工作呢?
- 案例中,获取的是包装函数地址,不是成员函数本身
- 为什么?
- 当在
DEF
文件中使用别名导出时:
编译器实际生成了一个thunk
(跳板)函数
1 |
pFNInitDefrag pInitDefrag = (pFNInitDefrag)GetProcAddress(m_hModule, "Init"); |
1 |
Init = InitDefrag |
关于跳板函数背后,编译器幕后工作机制
- 原始类布局 (版本
1
)
1 2 3 4 5 6 7 |
class DefragTest { public: virtual ~DefragTest(); // vtable 项0 bool InitDefrag(); // 非虚函数 // 内存布局: // [0-3/7]: vptr }; |
DEF
导出后产生的代码- 直接调用
1 2 3 4 5 6 |
// 编译器生成的thunk函数 (真实导出为"Init") extern "C" __declspec(dllexport) bool WINAPI _thunk_InitDefrag(DefragTest* pThis) { // 隐藏参数: ECX = pThis (32位) / RCX = pThis (64位) return pThis->InitDefrag(); } |
GetProcAddress
获得的地址
1 2 |
// 实际获取的是_thunk_InitDefrag的地址 // NOT InitDefrag成员函数的地址! |
- 添加虚函数后发生了什么 (版本
2
)InitDefrag
本身仍是普通成员函数- 函数签名没变
DEF
文件映射关系没变
1 2 3 4 5 6 7 |
class DefragTest { public: virtual ~DefragTest(); // vtable 项0 virtual bool CheckCompatibility(); // vtable 项1 (新虚函数) bool InitDefrag(); // 仍是普通成员函数 // 内存布局不变: vptr位置固定 }; |
- 致命问题:编译器生成的新
thunk
函数this
指针处理不同- 间接调用
1 2 3 4 5 6 |
// 新版本DLL生成的thunk函数 (同名导出"Init") extern "C" __declspec(dllexport) bool WINAPI _thunk_InitDefrag(DefragTest* pThis) { // 实现变了! return pThis->InitDefrag(); // 但底层成员访问方式相同 } |
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!