引言:从”独栋别墅”到”预制构件厂”
在上一节(10.1)中,我们的CMake”施工队长”带领团队盖了一栋”独栋别墅”——一个完整的命令行工具。那栋楼的住户是最终用户,直接拎包入住(运行可执行文件)即可。但这一次,我们要挑战一种更复杂的商业模式:开一家”预制构件厂”,专门生产标准化的建筑模块(共享库),卖给其他工地(其他CMake项目)使用。
作为供应商,你的构件必须满足严苛的标准:尺寸规格不能今天变明天变(ABI稳定)、出厂包装要适配不同国家的运输规范(跨平台符号导出)、产品要有清晰的型号标签(版本控制)、还要附带使用说明书和安装指南(CMake配置包与文档)。这一节,我们就来亲手打造这样一个专业的跨平台共享库。
要点1:库接口设计——ABI稳定性与版本兼容性
当你把库当作商品卖给其他开发者时,接口就是合同。合同一旦签订,就不能随意撕毁。在C++中,这涉及两个层面的稳定性:
- API(源代码接口):头文件里的类名、函数签名是否保持不变。
- ABI(二进制接口):编译后的符号布局、虚函数表结构、成员变量偏移是否保持不变。ABI一旦破坏,旧版本的客户端程序无需重新编译就会崩溃。
PIMPL惯用法:把实现藏起来
最稳妥的ABI稳定策略是不向用户暴露任何实现细节。PIMPL(Pointer to Implementation)惯用法正是为此而生:
// mathlib/include/mathlib/calculator.h
#pragma once
#include <memory>
#include "mathlib_export.h" // 由CMake自动生成,见要点2
namespace mathlib {
class MATHLIB_EXPORT Calculator {
public:
Calculator();
~Calculator();
// 用户可见的公开接口
double compute(int op, double a, double b);
// 禁止拷贝,避免实现细节泄漏
Calculator(const Calculator&) = delete;
Calculator& operator=(const Calculator&) = delete;
// 允许移动
Calculator(Calculator&&) noexcept;
Calculator& operator=(Calculator&&) noexcept;
private:
class Impl; // 仅前置声明,定义藏在.cpp里
std::unique_ptr<Impl> pImpl;
};
} // namespace mathlib
关键点在于:pImpl是一个不完整的指针类型,用户头文件里完全不包含Impl的定义。这意味着你可以随意修改Impl的成员变量、添加新的私有方法,只要公开接口不变,ABI就稳如磐石。
让用户选择静态还是动态链接
一个专业的库不会强迫用户必须用动态链接。CMake中可以通过BUILD_SHARED_LIBS选项把选择权交给下游:
# 顶层CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MathLib VERSION 1.2.3 LANGUAGES CXX)
option(BUILD_SHARED_LIBS "Build shared libraries" ON)
option(BUILD_TESTING "Build tests" ON)
add_subdirectory(src)
在src/CMakeLists.txt中,只需写add_library(mathlib ...),CMake会根据全局的BUILD_SHARED_LIBS自动决定生成STATIC还是SHARED库。当然,如果你希望显式控制,也可以:
add_library(mathlib ${MATHLIB_SOURCES})
# 或者强制双版本都编译:
# add_library(mathlib_shared SHARED ${SOURCES})
# add_library(mathlib_static STATIC ${SOURCES})
版本号的声明
在顶层CMakeLists.txt里用project(... VERSION 1.2.3)声明版本后,建议生成一个版本头文件供C++代码引用:
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/include/mathlib/version.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/include/mathlib/version.h"
@ONLY
)
其中version.h.in内容如下:
#pragma once
#define MATHLIB_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define MATHLIB_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define MATHLIB_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define MATHLIB_VERSION "@PROJECT_VERSION@"
要点2:Windows DLL导出符号管理
如果你只用过Linux/macOS,可能会对Windows的DLL符号导出机制感到困惑。在Linux下,共享库默认导出所有符号;而在Windows下,除非你显式标记,否则符号对外不可见。这就像是Windows的工厂默认把所有产品锁在仓库里,只有贴上了”允许出口”标签的箱子才能运出门。
传统做法:手写__declspec宏
最传统的手写方式是定义一个跨平台宏:
#ifdef _WIN32
#ifdef MATHLIB_BUILDING_DLL
#define MATHLIB_EXPORT __declspec(dllexport)
#else
#define MATHLIB_EXPORT __declspec(dllimport)
#endif
#else
#define MATHLIB_EXPORT __attribute__((visibility("default")))
#endif
然后在编译库时,通过CMake给mathlib目标定义MATHLIB_BUILDING_DLL宏,而下游用户不定义这个宏,从而自动获得dllimport。
现代做法:让CMake自动生成
手写宏容易出错,而且难以覆盖所有边缘情况。CMake提供了generate_export_header命令,能为你自动生成完全符合规范的头文件:
include(GenerateExportHeader)
add_library(mathlib calculator.cpp ...)
target_include_directories(mathlib
PUBLIC
"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
"$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/include>" # 放生成的头文件
"$<INSTALL_INTERFACE:include>"
)
generate_export_header(mathlib
BASE_NAME MATHLIB
EXPORT_FILE_NAME "${CMAKE_CURRENT_BINARY_DIR}/include/mathlib_export.h"
)
这会在构建目录生成mathlib_export.h,其中自动定义了MATHLIB_EXPORT、MATHLIB_DEPRECATED等宏。你只需在公开头文件里#include "mathlib_export.h",然后把需要导出的类或函数标记上MATHLIB_EXPORT即可。
隐藏不必要的符号(全平台通用)
即使在Linux上,导出所有符号也不是好习惯。它会导致二进制体积膨胀、加载变慢,还可能引发符号冲突。推荐在GCC/Clang下也设置默认隐藏:
set_target_properties(mathlib PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES
)
这样一来,只有显式标记了MATHLIB_EXPORT的符号才会暴露给外界,实现了Windows与Unix在符号可见性上的行为统一。
要点3:版本号与SO名称——Linux动态库版本控制
在Linux世界里,动态库有一套精密的”型号编码系统”,称为soname(Shared Object Name)。如果你观察过/usr/lib目录,会发现类似这样的符号链接链:
libmathlib.so.1.2.3 (真实文件)
libmathlib.so.1 -> libmathlib.so.1.2.3 (ABI版本链接)
libmathlib.so -> libmathlib.so.1 (开发链接)
- 真实文件名(Real Name):包含完整版本号
1.2.3,对应CMake中的VERSION属性。 - SO名称(SO Name):只包含主版本号
1,对应CMake中的SOVERSION属性。它代表了ABI兼容性承诺:只要so.1存在,所有针对该ABI编译的程序都能运行。 - 链接器名(Linker Name):不带版本号的
.so文件,仅用于编译时-lmathlib查找。
CMake中的配置
设置这两个属性非常简单:
set_target_properties(mathlib PROPERTIES
VERSION ${PROJECT_VERSION} # 1.2.3
SOVERSION ${PROJECT_VERSION_MAJOR} # 1
)
执行make install后,CMake会自动在Linux上创建上述符号链接结构。如果某天你发布了1.3.0版本,只要ABI没有破坏(不删除已有虚函数、不改变类布局),保持SOVERSION 1即可,现有用户程序无需重新链接。
Windows下的版本处理
Windows DLL没有soname概念,但可以通过资源文件(.rc)嵌入版本信息,让文件属性对话框显示版本号。你可以写一个version.rc.in,用configure_file填充VERSION_MAJOR等变量,然后添加到库的目标源文件中:
if(WIN32)
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.rc.in"
"${CMAKE_CURRENT_BINARY_DIR}/version.rc"
@ONLY
)
target_sources(mathlib PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/version.rc")
endif()
要点4:生成并安装CMake配置包
库编译好了,头文件和库文件也安装到了/usr/local或自定义前缀下。但其他CMake项目怎么找到你呢?答案是:提供CMake配置文件,让下游能用find_package(MathLib)一键导入。
安装目标并导出
第一步,把库目标本身”登记造册”,让CMake知道哪些目标需要被导出:
install(TARGETS mathlib
EXPORT mathlibTargets # 导出目标集合的名称
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin # Windows下DLL放bin
INCLUDES DESTINATION include # 自动关联接口头目录
)
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/include/"
DESTINATION include
FILES_MATCHING PATTERN "*.h"
)
# 安装生成的export头文件和version头文件
install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/include/"
DESTINATION include
FILES_MATCHING PATTERN "*.h"
)
导出目标定义文件
第二步,生成并安装mathlibTargets.cmake。这个文件包含了下游导入mathlib::mathlib目标所需的所有路径和属性:
install(EXPORT mathlibTargets
FILE mathlibTargets.cmake
NAMESPACE mathlib:: # 给目标加命名空间,避免冲突
DESTINATION lib/cmake/mathlib
)
编写Config.cmake模板
第三步,创建顶层配置文件,负责引入上述导出的目标定义,并检查依赖:
# cmake/mathlibConfig.cmake.in
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
# 如果本库依赖了其他库(例如Threads),在这里find_dependency
# find_dependency(Threads)
include("${CMAKE_CURRENT_LIST_DIR}/mathlibTargets.cmake")
check_required_components(mathlib)
使用@PACKAGE_INIT@宏可以确保文件在被 relocate(用户装到不同前缀)时仍能正确解析路径。然后用configure_package_config_file处理这个模板:
include(CMakePackageConfigHelpers)
configure_package_config_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/mathlibConfig.cmake.in"
"${CMAKE_CURRENT_BINARY_DIR}/mathlibConfig.cmake"
INSTALL_DESTINATION lib/cmake/mathlib
)
版本兼容性声明
最后,生成一个版本文件,让find_package(MathLib 1.2)能正确判断兼容性:
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/mathlibConfigVersion.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion # 语义化版本:主版本相同即兼容
)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/mathlibConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/mathlibConfigVersion.cmake"
DESTINATION lib/cmake/mathlib
)
至此,下游用户只需写:
find_package(MathLib 1.2 REQUIRED)
target_link_libraries(myapp PRIVATE mathlib::mathlib)
所有头文件路径、链接选项、编译定义都会被自动且精准地传播到myapp。这就是Modern CMake”基于目标”理念的威力。
要点5:提供pkg-config支持
尽管CMake已成为C++主流,但现实世界依然有大量项目基于Autotools、Meson,或者底层的构建脚本。pkg-config是这些系统的通用”采购清单”格式。如果你的库不提供.pc文件,就等于拒绝了一批潜在客户。
生成.pc文件
CMake无法原生生成.pc文件,但用configure_file可以轻松实现。首先创建模板cmake/mathlib.pc.in:
prefix=@CMAKE_INSTALL_PREFIX@
exec_prefix=${prefix}
includedir=${prefix}/include
libdir=${prefix}/lib
Name: @PROJECT_NAME@
Description: A cross-platform math library with ABI stability
Version: @PROJECT_VERSION@
Requires: # 如果有依赖其他pkg-config包,在此填写,例如 libcurl
Libs: -L${libdir} -lmathlib
Cflags: -I${includedir}
然后在CMakeLists.txt中配置并安装它:
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/mathlib.pc.in"
"${CMAKE_CURRENT_BINARY_DIR}/mathlib.pc"
@ONLY
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/mathlib.pc"
DESTINATION lib/pkgconfig
)
安装后,用户可以通过pkg-config --cflags --libs mathlib获取编译参数。如果你的库依赖了其他pkg-config管理的库(比如OpenSSL),务必在Requires:行中声明,这样用户只需链接你的库,依赖链会自动展开。
要点6:文档生成——Doxygen与CMake的集成
一份专业的共享库如果没有API文档,就像精密仪器没有说明书。Doxygen是C++领域文档生成的行业标准,而CMake可以把它无缝编织进构建流程。
查找Doxygen
find_package(Doxygen OPTIONAL_COMPONENTS dot)
if(NOT DOXYGEN_FOUND)
message(STATUS "Doxygen not found, documentation will not be built")
return()
endif()
使用OPTIONAL_COMPONENTS dot可以检测是否支持生成类图和依赖图(需要Graphviz)。
配置Doxyfile.in
与其手动维护一份Doxyfile,不如用模板让CMake注入项目路径和版本号:
set(DOXYGEN_IN "${CMAKE_CURRENT_SOURCE_DIR}/docs/Doxyfile.in")
set(DOXYGEN_OUT "${CMAKE_CURRENT_BINARY_DIR}/Doxyfile")
configure_file(${DOXYGEN_IN} ${DOXYGEN_OUT} @ONLY)
在Doxyfile.in中,把原本写死的路径替换为CMake变量:
PROJECT_NAME = @PROJECT_NAME@
PROJECT_NUMBER = @PROJECT_VERSION@
INPUT = @CMAKE_CURRENT_SOURCE_DIR@/include
OUTPUT_DIRECTORY = @CMAKE_CURRENT_BINARY_DIR@/docs
GENERATE_HTML = YES
GENERATE_LATEX = NO
HAVE_DOT = @DOXYGEN_DOT_FOUND@
添加文档构建目标
CMake 3.9+ 提供了doxygen_add_docs命令,这是最简洁的集成方式:
doxygen_add_docs(doc_doxygen
${CMAKE_CURRENT_SOURCE_DIR}/include
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMENT "Generating API documentation with Doxygen"
)
如果你需要更精细的控制(比如把文档也安装到系统目录),可以使用传统的add_custom_target:
add_custom_target(doc_doxygen ALL
COMMAND ${DOXYGEN_EXECUTABLE} ${DOXYGEN_OUT}
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMENT "Generating API documentation"
VERBATIM
)
install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/docs/html"
DESTINATION share/doc/mathlib
OPTIONAL
)
这样,运行cmake --build . --target doc_doxygen(或直接构建默认目标)就能在build/docs/html下看到完整的HTML文档。如果你配置了CI/CD,还可以把这份文档自动推送到GitHub Pages。
总结:一个专业共享库的交付清单
到这里,我们的”预制构件厂”已经具备了现代化生产能力。回顾一下,一个可交付的跨平台共享库应该包含以下内容:
- 稳定的ABI:通过PIMPL和
CXX_VISIBILITY_PRESET hidden确保接口_contract_不被破坏。 - 跨平台符号导出:利用
generate_export_header优雅处理Windows DLL与Linux/macOS的动态符号。 - 版本控制:通过
VERSION与SOVERSION管理Linux soname,通过project()版本号贯穿构建全流程。 - CMake生态集成:安装导出目标(
install(EXPORT))并提供Config.cmake与ConfigVersion.cmake,让下游用find_package丝滑导入。 - 传统生态兼容:生成
.pc文件,支持pkg-config用户。 - 文档配套:Doxygen与CMake联动,自动生成并可选地安装API文档。
在下一节(10.3)中,施工难度将再次升级:我们将进入GUI领域,用CMake构建一个基于Qt和OpenGL的图形应用程序。届时你会看到CMake如何处理Qt特有的MOC、UIC、RCC预处理流程,以及如何把应用打包成可分发的安装包。准备好了吗?


没有回复内容