跨平台插件开发:Windows / Linux / macOS 统一策略
📅 创建时间:2026-07-13 🏷️ 标签:#跨平台 #Windows #Linux #macOS #条件编译 📚 前置知识:runtime dynamic loading, cmake plugin project
📋 本章目标
- 掌握三平台(Windows / Linux / macOS)动态库开发的差异速查
- 学会用条件编译和宏封装统一三平台的动态加载 API
- 理解 macOS 特有概念:Framework Bundle、@rpath、install_name_tool
- 掌握跨平台 CMake 配置的最佳实践
第1部分:三平台差异速查表
┌─────────────────────────────────────────────────────────────────────────────┐
│ 三平台核心差异 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 维度 │ Windows │ Linux │ macOS │
│ ────────────────┼─────────────────────┼───────────────────┼───────────────│
│ 动态库后缀 │ .dll │ .so │ .dylib │
│ 可执行后缀 │ .exe │ (无) │ (无) / .app │
│ 静态库后缀 │ .lib │ .a │ .a │
│ 导入库 │ .lib (必须有!) │ 不需要 │ .tbd (可选) │
│ 加载函数 │ LoadLibrary │ dlopen │ dlopen │
│ 取符号 │ GetProcAddress │ dlsym │ dlsym │
│ 卸载 │ FreeLibrary │ dlclose │ dlclose │
│ 错误获取 │ GetLastError │ dlerror │ dlerror │
│ 符号导出宏 │ __declspec( │ __attribute__ │ __attribute__ │
│ │ dllexport) │ ((visibility │ ((visibility │
│ │ │ ("default"))) │ ("default")))│
│ 符号导入宏 │ __declspec( │ 默认 │ 默认 │
│ │ dllimport) │ │ │
│ 默认符号可见性 │ 隐藏(不导出) │ 导出(全可见) │ 导出(全可见)│
│ 库搜索路径 │ .exe同级>PATH>系统 │ RPATH>LD_LIB... │ @rpath>DYLD..│
│ 环境变量 │ PATH │ LD_LIBRARY_PATH │ DYLD_LIBRARY │
│ │ │ │ _PATH │
│ 调试符号 │ .pdb(独立文件) │ 嵌入ELF或.debug │ .dSYM bundle │
│ 运行时库 │ vcruntime │ libstdc++/libc++ │ libc++ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第2部分:统一封装 —— 一套代码编译三平台
2.1 跨平台宏
// plugin_loader.h
#pragma once
// ====== 1. 平台检测 ======
#if defined(_WIN32) || defined(_WIN64)
#define PLATFORM_WINDOWS 1
#elif defined(__APPLE__)
#define PLATFORM_MACOS 1
#elif defined(__linux__)
#define PLATFORM_LINUX 1
#else
#error "Unsupported platform"
#endif
// ====== 2. 动态库句柄类型 ======
#if PLATFORM_WINDOWS
#include <windows.h>
#define PLUGIN_HANDLE HMODULE
#define PLUGIN_INVALID_HANDLE NULL
#else
#include <dlfcn.h>
#define PLUGIN_HANDLE void*
#define PLUGIN_INVALID_HANDLE nullptr
#endif
// ====== 3. 统一的 API ======
inline PLUGIN_HANDLE plugin_load(const char* path) {
#if PLATFORM_WINDOWS
return LoadLibraryA(path);
#else
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
#endif
}
inline void* plugin_getsym(PLUGIN_HANDLE h, const char* name) {
#if PLATFORM_WINDOWS
return (void*)GetProcAddress(h, name);
#else
return dlsym(h, name);
#endif
}
inline void plugin_close(PLUGIN_HANDLE h) {
#if PLATFORM_WINDOWS
FreeLibrary(h);
#else
dlclose(h);
#endif
}
inline const char* plugin_error() {
#if PLATFORM_WINDOWS
static char buf[256];
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM, NULL,
GetLastError(), 0, buf, sizeof(buf), NULL);
return buf;
#else
return dlerror();
#endif
}
// ====== 4. 动态库文件后缀 ======
inline const char* plugin_extension() {
#if PLATFORM_WINDOWS
return ".dll";
#elif PLATFORM_MACOS
return ".dylib";
#else
return ".so";
#endif
}2.2 符号导出/导入宏
// plugin_export.h
#pragma once
#if PLATFORM_WINDOWS
// Windows: dllexport 导出, dllimport 导入
#ifdef PLUGIN_EXPORTS
#define PLUGIN_API __declspec(dllexport)
#else
#define PLUGIN_API __declspec(dllimport)
#endif
#else
// Linux/macOS: 用 visibility 控制
#define PLUGIN_API __attribute__((visibility("default")))
#endif
// 用法:
// 编译插件时定义 PLUGIN_EXPORTS → dllexport / 显式 visible
// 使用插件时(宿主)不定义 → dllimport (Windows) / 默认 (Linux)第3部分:macOS 特殊处理
3.1 Framework vs dylib
macOS 有两种动态库形态:
┌─────────────────────────────────────────────────────────────────────────────┐
│ macOS Framework vs dylib │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ .dylib(类似 Linux .so) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 单一文件:libfoo.dylib │ │
│ │ 适合:命令行工具、后台服务、跨平台 C++ 库 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ .framework Bundle │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Foo.framework/ │ │
│ │ ├── Foo ← 实际的动态库(Mach-O) │ │
│ │ ├── Headers/ ← 头文件(可选) │ │
│ │ ├── Resources/ ← 图标、图片、nib 等 │ │
│ │ └── Versions/ ← 版本管理 │ │
│ │ │ │
│ │ 适合:GUI 应用框架、系统级 Framework │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 插件开发场景:用 .dylib 就够了,简单直接 │
│ Framework 主要用于系统 API 和 GUI 框架 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘3.2 @rpath 和 install_name_tool
macOS 的动态库有一个"内嵌路径"(install name),记录了运行时应该在哪个位置找到这个 .dylib:
# 查看 .dylib 的 install name
otool -D libfoo.dylib
# 输出:@rpath/libfoo.dylib
# @rpath = 可执行文件中记录的 runpath search paths
# 等价于 Linux 的 RPATH / RUNPATH
# 修改 install name
install_name_tool -id "@rpath/libfoo.dylib" libfoo.dylib
# 修改可执行文件对动态库的依赖路径
install_name_tool -change "/old/path/libfoo.dylib" \
"@rpath/libfoo.dylib" my_app
# 添加 @rpath 搜索路径
install_name_tool -add_rpath "@loader_path/../lib" my_app
# @loader_path = 加载者所在的目录(如果是可执行文件,就是 .app/Contents/MacOS)3.3 macOS 上的 CMake 配置
if(APPLE)
# 设置 install name
set_target_properties(plugin_math PROPERTIES
INSTALL_NAME_DIR "@rpath"
MACOSX_RPATH ON
)
# .dylib 的版本信息
set_target_properties(plugin_math PROPERTIES
VERSION 1.0.0
SOVERSION 1
)
endif()第4部分:跨平台 CMake 配置
# 平台检测
if(WIN32)
set(PLUGIN_SUFFIX ".dll")
set(EXE_SUFFIX ".exe")
add_compile_definitions(PLATFORM_WINDOWS)
elseif(APPLE)
set(PLUGIN_SUFFIX ".dylib")
set(EXE_SUFFIX "")
add_compile_definitions(PLATFORM_MACOS)
else()
set(PLUGIN_SUFFIX ".so")
set(EXE_SUFFIX "")
add_compile_definitions(PLATFORM_LINUX)
endif()
# 插件输出名称可以统一
set_target_properties(plugin_math PROPERTIES
OUTPUT_NAME "math_plugin"
SUFFIX "${PLUGIN_SUFFIX}"
)
# Windows: 链接 ws2_32, 设置 UNICODE
if(WIN32)
target_link_libraries(plugin_host PRIVATE ws2_32)
add_compile_definitions(UNICODE _UNICODE)
endif()
# Linux: 链接 dl, pthread
if(UNIX AND NOT APPLE)
target_link_libraries(plugin_host PRIVATE ${CMAKE_DL_LIBS} pthread)
endif()
# macOS: 需要 Foundation 等(如果用了 Objective-C)
if(APPLE)
target_link_libraries(plugin_host PRIVATE "-framework Foundation")
endif()第5部分:不同平台的插件搜索路径惯例
std::vector<std::string> get_default_plugin_paths() {
std::vector<std::string> paths;
#if PLATFORM_WINDOWS
// Windows: exe 同级目录下的 plugins/
paths.push_back(get_exe_dir() + "\\plugins\\");
// 用户 AppData
char* appdata = getenv("APPDATA");
if (appdata) paths.push_back(std::string(appdata) + "\\MyApp\\plugins\\");
// 程序安装目录(注册表读取)
paths.push_back("C:\\Program Files\\MyApp\\plugins\\");
#elif PLATFORM_MACOS
// macOS: .app bundle 内
paths.push_back(get_bundle_dir() + "/Contents/PlugIns/");
// 用户 Library
const char* home = getenv("HOME");
if (home) paths.push_back(std::string(home) + "/Library/Application Support/MyApp/plugins/");
// 系统 Library
paths.push_back("/Library/Application Support/MyApp/plugins/");
#else
// Linux: 可执行文件相对路径
paths.push_back(get_exe_dir() + "/../lib/myapp/plugins/");
// XDG 用户目录
const char* xdg = getenv("XDG_DATA_HOME");
if (!xdg) xdg = (getenv("HOME") + std::string("/.local/share")).c_str();
paths.push_back(std::string(xdg) + "/myapp/plugins/");
// 系统路径
paths.push_back("/usr/lib/myapp/plugins/");
paths.push_back("/usr/local/lib/myapp/plugins/");
#endif
// 通用:环境变量
const char* env = getenv("MYAPP_PLUGIN_PATH");
if (env) {
for (auto& p : split(env, ':')) paths.push_back(p);
}
return paths;
}核心总结
┌─────────────────────────────────────────────────────────────────────────────┐
│ 跨平台插件速查 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Windows: LoadLibrary / GetProcAddress / FreeLibrary │
│ .dll + .lib, dllexport/dllimport │
│ │
│ Linux: dlopen / dlsym / dlclose, .so, visibility("default") │
│ │
│ macOS: dlopen / dlsym / dlclose, .dylib, @rpath │
│ │
│ 统一封装:条件编译 #if PLATFORM_WINDOWS / #elif PLATFORM_LINUX │
│ │
│ macOS 特殊工具: │
│ otool -L = 查看依赖(类似 ldd) │
│ install_name_tool = 修改 install name(类似 patchelf) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘章节测试
测试1:三平台对比
在 Windows 上用 __declspec(dllexport),在 Linux 上用什么?为什么 Linux 默认导出所有符号?
测试2:macOS @rpath
Linux 的 $ORIGIN 和 macOS 的 @rpath 有什么异同?
测试3:跨平台 CMake
如何让同一个 CMakeLists.txt 在三个平台上都正确构建插件?写出关键的 if/else 分支逻辑。
参考答案
测试1答案
答案:Linux 上用 __attribute__((visibility("default")))。Linux(GCC)默认导出所有符号是因为历史原因——传统 Unix 链接器就默认所有符号对外可见。而 Windows 默认隐藏所有符号,只导出显式标记的。现代 Linux 开发推荐加 -fvisibility=hidden 让 Linux 表现得像 Windows 一样(默认隐藏 + 显式导出)。
测试2答案
答案:两者都是解决"程序怎么找到自己的动态库"的机制。$ORIGIN 是 Linux 的 RPATH 变量,指"可执行文件所在目录"。@rpath 是 macOS 的 install name 前缀,也需要配合可执行文件中的 rpath 条目使用。区别在于:(1) Linux 的 $ORIGIN 是加载器内置支持的变量;(2) macOS 的 @rpath 需要显式用 -rpath 链接选项或 install_name_tool -add_rpath 添加搜索路径;(3) 用法类似——都是为了让程序绑定的库位置是相对路径。
测试3答案
答案:
if(WIN32)
set(PLUGIN_EXT ".dll")
target_compile_definitions(plugin_math PRIVATE PLUGIN_EXPORTS)
elseif(APPLE)
set(PLUGIN_EXT ".dylib")
set_target_properties(plugin_math PROPERTIES
INSTALL_NAME_DIR "@rpath"
MACOSX_RPATH ON)
else()
set(PLUGIN_EXT ".so")
target_link_libraries(plugin_host PRIVATE ${CMAKE_DL_LIBS})
endif()
set_target_properties(plugin_math PROPERTIES SUFFIX "${PLUGIN_EXT}")相关笔记
- runtime dynamic loading - 运行时动态加载 API 详解
- cmake plugin project - CMake 构建插件项目
- hot reload versioning - 热加载与版本管理
下一步学习
- [ ] 阅读 11 - 插件热加载与版本管理
学习状态:🟡 开始学习
工程深化:把本篇知识落到项目里
这一节不是为了凑篇幅,而是把《跨平台插件开发:Windows / Linux / macOS 统一策略》从“知道概念”推进到“能在项目里稳定使用”。 阅读时可以把每个知识点都追问成四件事:它保护什么边界,失败时有什么现象,怎样最小复现,怎样写进团队流程。
concept -> boundary -> failure signal -> minimal proof -> project rule工程切片 1:最小可复现样例
**场景。**围绕 DLL 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 DLL、导入库 还是 PDB 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> DLL -> 导入库 -> PDB -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 2:接口边界
**场景。**围绕 导入库 建一个很小的案例,不要一开始就放进完整业务系统。写清调用方能依赖什么、不能依赖什么,并把隐式假设转换成命名函数、配置项或测试。
**边界。**先判断这里讨论的是 导入库、PDB 还是 运行库 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 导入库 -> PDB -> 运行库 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 3:失败注入
**场景。**围绕 PDB 建一个很小的案例,不要一开始就放进完整业务系统。故意制造一个常见错误,让日志、断言或测试先失败,再用修复后的证据说明规则生效。
**边界。**先判断这里讨论的是 PDB、运行库 还是 导出符号 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> PDB -> 运行库 -> 导出符号 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 4:跨平台差异
**场景。**围绕 运行库 建一个很小的案例,不要一开始就放进完整业务系统。至少比较 Windows、Linux 或不同编译器下的一个差异,记录差异属于标准、实现还是平台约定。
**边界。**先判断这里讨论的是 运行库、导出符号 还是 部署目录 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 运行库 -> 导出符号 -> 部署目录 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 5:性能观察
**场景。**围绕 导出符号 建一个很小的案例,不要一开始就放进完整业务系统。用小规模和压力规模各跑一次,区分算法成本、同步成本、I/O 成本和工具链配置成本。
**边界。**先判断这里讨论的是 导出符号、部署目录 还是 DLL 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 导出符号 -> 部署目录 -> DLL -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 6:生命周期
**场景。**围绕 部署目录 建一个很小的案例,不要一开始就放进完整业务系统。标出资源创建、转移、共享、停止和销毁的顺序,特别关注错误返回和提前退出路径。
**边界。**先判断这里讨论的是 部署目录、DLL 还是 导入库 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 部署目录 -> DLL -> 导入库 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 7:诊断证据
**场景。**围绕 DLL 建一个很小的案例,不要一开始就放进完整业务系统。保留命令、日志、符号、栈、测试输出或截图,让结论可以被另一个环境重新验证。
**边界。**先判断这里讨论的是 DLL、导入库 还是 PDB 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> DLL -> 导入库 -> PDB -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 8:维护策略
**场景。**围绕 导入库 建一个很小的案例,不要一开始就放进完整业务系统。把一次性经验沉淀到 README、CI、脚本、示例工程或检查表里,避免只存在个人记忆中。
**边界。**先判断这里讨论的是 导入库、PDB 还是 运行库 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 导入库 -> PDB -> 运行库 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
本篇收束
掌握《跨平台插件开发:Windows / Linux / macOS 统一策略》的标志,不是记住所有名词,而是能把 DLL、导入库、PDB、运行库、导出符号、部署目录 放进一条可验证的工程链路。 先用最小样例建立判断,再用测试和脚本固定判断,最后把失败证据留给未来的自己和团队。