CMake 构建完整插件项目 / Building a Complete Plugin Project with CMake
📅 创建时间:2026-07-13 🏷️ 标签:#CMake #插件构建 #项目结构 #RPATH 📚 前置知识:project structure thirdparty cmake, plugin interface design
📋 本章目标
- 掌握插件项目的标准 CMake 目录结构设计
- 学会用 INTERFACE 库管理插件公共接口
- 掌握插件的 CMake 配置:SHARED 库 / 输出目录 / RPATH 设置
- 实现插件自动发现:构建后插件 .so 统一输出到 plugins/ 目录
- 理解 install 规则:安装后宿主、接口头文件、插件的目录布局
第1部分:插件项目的标准目录结构
plugin_system/
├── CMakeLists.txt # 顶层 CMake
├── host/ # 宿主程序
│ ├── CMakeLists.txt
│ └── main.cpp
├── plugin_interface/ # 公共接口(最关键!)
│ ├── CMakeLists.txt # INTERFACE 库
│ ├── iplugin.h # 所有插件的基础接口
│ ├── icalculator.h # 计算器插件专用接口
│ └── plugin_loader.h # 跨平台加载工具(第06篇的封装)
├── plugins/ # 编译好的插件 .so/.dll 放这里
│ ├── CMakeLists.txt # 只是组织子目录
│ ├── plugin_math/
│ │ ├── CMakeLists.txt
│ │ └── math_plugin.cpp
│ └── plugin_text/
│ ├── CMakeLists.txt
│ └── text_plugin.cpp
└── cmake/ # CMake 工具模块
└── PluginUtils.cmake┌─────────────────────────────────────────────────────────────────────────────┐
│ 各目录的编译角色 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ plugin_interface/ → INTERFACE 库 │
│ 不生成二进制,只提供头文件和编译选项 │
│ 宿主和插件都依赖它 │
│ │
│ host/ → EXECUTABLE │
│ 链接到 plugin_interface(用头文件) │
│ 不链接到具体插件(插件是运行时加载的) │
│ │
│ plugins/*/ → SHARED 库 │
│ 每个插件编译为 .so/.dll │
│ 依赖 plugin_interface(用头文件) │
│ 输出到统一的 plugins/ 目录 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第2部分:CMake 实现详解
2.1 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(PluginSystem VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_VISIBILITY_PRESET hidden) # Linux 默认隐藏符号
set(CMAKE_VISIBILITY_INLINES_HIDDEN YES)
# 公共接口
add_subdirectory(plugin_interface)
# 宿主程序
add_subdirectory(host)
# 所有插件
add_subdirectory(plugins)2.2 plugin_interface/ —— INTERFACE 库
# plugin_interface/CMakeLists.txt
add_library(plugin_interface INTERFACE)
target_include_directories(plugin_interface
INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<INSTALL_INTERFACE:include/plugin_system>
)
# 插件接口需要的公共编译选项
target_compile_definitions(plugin_interface
INTERFACE
PLUGIN_API_VERSION=1
)INTERFACE 库不编译任何 .cpp 文件——它只传递头文件路径和编译选项给任何依赖它的 target。
2.3 plugins/plugin_math/ —— 一个具体插件
# plugins/plugin_math/CMakeLists.txt
add_library(plugin_math SHARED
math_plugin.cpp
)
# 依赖公共接口(获取头文件 + 编译选项)
target_link_libraries(plugin_math PRIVATE plugin_interface)
# 设置输出目录 —— 所有插件统一到 plugins/ 目录
set_target_properties(plugin_math PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)
# Linux: 设置 soname
set_target_properties(plugin_math PROPERTIES
VERSION 1.0.0
SOVERSION 1
)
# Windows: 编译 DLL 时定义导出宏(如果需要对外导出更多符号)
target_compile_definitions(plugin_math PRIVATE MATH_PLUGIN_EXPORTS)2.4 host/ —— 宿主程序
# host/CMakeLists.txt
add_executable(plugin_host
main.cpp
)
# 只需要公共接口头文件,不需要链接任何具体插件!
target_link_libraries(plugin_host PRIVATE plugin_interface)
# Linux: 需要 -ldl
if(UNIX)
target_link_libraries(plugin_host PRIVATE ${CMAKE_DL_LIBS})
endif()
# RPATH: 让可执行文件能从 ../plugins 找到 .so
set_target_properties(plugin_host PROPERTIES
BUILD_RPATH "${CMAKE_BINARY_DIR}/plugins"
INSTALL_RPATH "$ORIGIN/../lib/plugin_system/plugins"
)第3部分:插件自动收集 —— add_custom_command
构建完成后,所有插件 .so/.dll 已经在 build/plugins/ 下了。如果想要构建后自动生成一个插件列表文件或拷贝到统一位置:
# 在顶层 CMakeLists.txt 末尾
add_custom_target(collect_plugins ALL
COMMAND ${CMAKE_COMMAND} -E echo "Plugins built:"
COMMAND ${CMAKE_COMMAND} -E echo " ${CMAKE_BINARY_DIR}/plugins/"
COMMENT "All plugins collected in plugins/ directory"
)
# 如果有外部预编译的插件需要拷贝进来:
# add_custom_command(TARGET collect_plugins POST_BUILD
# COMMAND ${CMAKE_COMMAND} -E copy_if_different
# ${CMAKE_SOURCE_DIR}/external/prebuilt_plugin.so
# ${CMAKE_BINARY_DIR}/plugins/
# )3.1 Debug vs Release 插件子目录
# 更专业的做法:Debug 和 Release 插件分开放
set(PLUGIN_OUTPUT_DIR "${CMAKE_BINARY_DIR}/plugins/$<CONFIG>")
set_target_properties(plugin_math PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${PLUGIN_OUTPUT_DIR}"
RUNTIME_OUTPUT_DIRECTORY "${PLUGIN_OUTPUT_DIR}"
)
# 结果:build/plugins/Debug/plugin_math.so
# build/plugins/Release/plugin_math.so第4部分:Install 规则 —— 给别人用的时候
# 顶层 CMakeLists.txt 末尾的 install 规则
# 1. 宿主程序
install(TARGETS plugin_host
RUNTIME DESTINATION bin
)
# 2. 公共接口头文件
install(TARGETS plugin_interface
INCLUDES DESTINATION include
)
install(DIRECTORY plugin_interface/
DESTINATION include/plugin_system
FILES_MATCHING PATTERN "*.h"
)
# 3. 插件
install(TARGETS plugin_math plugin_text
LIBRARY DESTINATION lib/plugin_system/plugins
RUNTIME DESTINATION bin/plugin_system/plugins
)
# 安装后的目录结构:
# /usr/local/
# ├── bin/
# │ └── plugin_host
# ├── include/
# │ └── plugin_system/
# │ └── *.h
# └── lib/
# └── plugin_system/
# └── plugins/
# ├── libplugin_math.so
# └── libplugin_text.so第5部分:处理第三方插件的构建
当甲方给你一个预编译好的 .so/.dll(不是你的 CMake 子项目),如何融入构建流程?
# 方法1:创建 IMPORTED target
add_library(external_plugin SHARED IMPORTED)
set_target_properties(external_plugin PROPERTIES
IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/external/prebuilt_plugin.so"
)
# 方法2:复制到构建输出目录
add_custom_command(TARGET collect_plugins POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/external/prebuilt_plugin.so"
"${CMAKE_BINARY_DIR}/plugins/"
)
# 方法3:如果是 Windows 并需要 .lib + .dll
add_library(ext_win64_plugin SHARED IMPORTED)
set_target_properties(ext_win64_plugin PROPERTIES
IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/external/win64/plugin.dll"
IMPORTED_IMPLIB "${CMAKE_SOURCE_DIR}/external/win64/plugin.lib"
)第6部分:一个完整的构建和运行流程
# 1. 构建
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j$(nproc)
# 2. 查看产物
ls build/plugins/ # 所有 .so 插件
ls build/host/plugin_host # 宿主程序
# 3. 运行(build RPATH 已经设好,直接运行)
./build/host/plugin_host
# 4. 安装
cmake --install build --prefix /usr/local
# 5. 运行安装后的版本
/usr/local/bin/plugin_host # INSTALL_RPATH = $ORIGIN/../lib/plugin_system/plugins核心总结
┌─────────────────────────────────────────────────────────────────────────────┐
│ CMake 插件项目核心模式 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ INTERFACE 库 = 插件接口的载体 │
│ • 不编译代码,只传递头文件和编译选项 │
│ • 宿主和插件都 PRIVATE 依赖它 │
│ │
│ 插件 target 配置: │
│ • add_library(xxx SHARED ...) │
│ • LIBRARY_OUTPUT_DIRECTORY → 统一的 plugins/ 目录 │
│ • VERSION + SOVERSION → soname 正确设置 │
│ │
│ RPATH 配置: │
│ • BUILD_RPATH → 构建后能直接在 build 目录运行 │
│ • INSTALL_RPATH → 安装后能找到插件 │
│ │
│ 宿主不链接具体插件 → 依赖关系通过运行时 dlopen 管理 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘章节测试
测试1:INTERFACE 库
为什么插件公共接口要用 INTERFACE 库而不是 STATIC 库?INTERFACE 库传递了什么?
测试2:插件输出
如何让所有插件编译后自动放在同一个 plugins/ 目录下?用到了 CMake 的什么属性?
测试3:RPATH
BUILD_RPATH 和 INSTALL_RPATH 分别解决什么问题?为什么需要分别设置?
测试4:宿主依赖
宿主程序为什么不需要 target_link_libraries 到任何具体插件?
测试5:构建流程
从零开始构建这个插件项目,写出完整的命令序列(configure → build → run)。
参考答案
测试1答案
答案:INTERFACE 库不生成任何二进制文件,只传递:(1) include 路径(target_include_directories);(2) 编译选项(target_compile_definitions / target_compile_options);(3) 链接依赖。使用 INTERFACE 库的理由是插件接口是纯头文件的——只需要 #include,不需要链接任何 .lib/.a。如果用 STATIC 库,编译链接时会试图找 .cpp 文件来生成二进制。
测试2答案
答案:通过 set_target_properties 设置 LIBRARY_OUTPUT_DIRECTORY(Linux .so)、RUNTIME_OUTPUT_DIRECTORY(Windows .dll)、ARCHIVE_OUTPUT_DIRECTORY(Windows .lib 静态/导入库)。将所有插件的这些属性设为同一个 ${CMAKE_BINARY_DIR}/plugins 目录即可。
测试3答案
答案:
BUILD_RPATH:在 build 目录中运行时用的搜索路径。构建后./build/host/plugin_host能直接从./build/plugins/找到 .so。INSTALL_RPATH:安装后运行时用的搜索路径。cmake --install后程序被拷贝到系统路径(如/usr/local/bin),需要用$ORIGIN/../lib/...指明 .so 的相对位置。- 分别设置是因为 build 和 install 的目录结构不同。
测试4答案
答案:因为运行时动态加载(dlopen/LoadLibrary)完全绕过了编译时的链接检查。宿主通过 dlopen 打开 .so,通过 dlsym 获取函数指针,通过函数指针调用。这些操作对链接器完全不可见——链接器不知道宿主"将要"加载哪些 .so。宿主只需要链接提供 dlopen 的 libdl(Unix)和插件接口头文件。
测试5答案
答案:
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j$(nproc)
./build/host/plugin_host相关笔记
- project structure thirdparty cmake - CMake 基础与第三方库
- plugin interface design - 插件接口设计
- plugin architecture patterns - 插件架构设计模式
下一步学习
- [ ] 阅读 09 - 插件架构设计模式
学习状态:🟡 开始学习
工程深化:把本篇知识落到项目里
这一节不是为了凑篇幅,而是把《CMake 构建完整插件项目 / Building a Complete Plugin Project with CMake》从“知道概念”推进到“能在项目里稳定使用”。 阅读时可以把每个知识点都追问成四件事:它保护什么边界,失败时有什么现象,怎样最小复现,怎样写进团队流程。
concept -> boundary -> failure signal -> minimal proof -> project rule工程切片 1:最小可复现样例
**场景。**围绕 插件发现 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 插件发现、加载器 还是 契约 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 插件发现 -> 加载器 -> 契约 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 2:接口边界
**场景。**围绕 加载器 建一个很小的案例,不要一开始就放进完整业务系统。写清调用方能依赖什么、不能依赖什么,并把隐式假设转换成命名函数、配置项或测试。
**边界。**先判断这里讨论的是 加载器、契约 还是 生命周期 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 加载器 -> 契约 -> 生命周期 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 3:失败注入
**场景。**围绕 契约 建一个很小的案例,不要一开始就放进完整业务系统。故意制造一个常见错误,让日志、断言或测试先失败,再用修复后的证据说明规则生效。
**边界。**先判断这里讨论的是 契约、生命周期 还是 隔离 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 契约 -> 生命周期 -> 隔离 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 4:跨平台差异
**场景。**围绕 生命周期 建一个很小的案例,不要一开始就放进完整业务系统。至少比较 Windows、Linux 或不同编译器下的一个差异,记录差异属于标准、实现还是平台约定。
**边界。**先判断这里讨论的是 生命周期、隔离 还是 诊断 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 生命周期 -> 隔离 -> 诊断 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 5:性能观察
**场景。**围绕 隔离 建一个很小的案例,不要一开始就放进完整业务系统。用小规模和压力规模各跑一次,区分算法成本、同步成本、I/O 成本和工具链配置成本。
**边界。**先判断这里讨论的是 隔离、诊断 还是 插件发现 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 隔离 -> 诊断 -> 插件发现 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 6:生命周期
**场景。**围绕 诊断 建一个很小的案例,不要一开始就放进完整业务系统。标出资源创建、转移、共享、停止和销毁的顺序,特别关注错误返回和提前退出路径。
**边界。**先判断这里讨论的是 诊断、插件发现 还是 加载器 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 诊断 -> 插件发现 -> 加载器 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 7:诊断证据
**场景。**围绕 插件发现 建一个很小的案例,不要一开始就放进完整业务系统。保留命令、日志、符号、栈、测试输出或截图,让结论可以被另一个环境重新验证。
**边界。**先判断这里讨论的是 插件发现、加载器 还是 契约 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 插件发现 -> 加载器 -> 契约 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 8:维护策略
**场景。**围绕 加载器 建一个很小的案例,不要一开始就放进完整业务系统。把一次性经验沉淀到 README、CI、脚本、示例工程或检查表里,避免只存在个人记忆中。
**边界。**先判断这里讨论的是 加载器、契约 还是 生命周期 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 加载器 -> 契约 -> 生命周期 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 9:版本演进
**场景。**围绕 契约 建一个很小的案例,不要一开始就放进完整业务系统。为未来变更预留兼容策略:版本号、特性位、弃用窗口、迁移脚本和回滚入口。
**边界。**先判断这里讨论的是 契约、生命周期 还是 隔离 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 契约 -> 生命周期 -> 隔离 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 10:最小消费者
**场景。**围绕 生命周期 建一个很小的案例,不要一开始就放进完整业务系统。从使用者角度写一个小程序或小工程,只依赖公开接口,验证安装包、头文件和运行时行为。
**边界。**先判断这里讨论的是 生命周期、隔离 还是 诊断 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 生命周期 -> 隔离 -> 诊断 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
本篇收束
掌握《CMake 构建完整插件项目 / Building a Complete Plugin Project with CMake》的标志,不是记住所有名词,而是能把 插件发现、加载器、契约、生命周期、隔离、诊断 放进一条可验证的工程链路。 先用最小样例建立判断,再用测试和脚本固定判断,最后把失败证据留给未来的自己和团队。