38. 10.2 项目二:跨平台共享库开发

引言:从”独栋别墅”到”预制构件厂”

在上一节(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_EXPORTMATHLIB_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。

总结:一个专业共享库的交付清单

到这里,我们的”预制构件厂”已经具备了现代化生产能力。回顾一下,一个可交付的跨平台共享库应该包含以下内容:

  1. 稳定的ABI:通过PIMPL和CXX_VISIBILITY_PRESET hidden确保接口_contract_不被破坏。
  2. 跨平台符号导出:利用generate_export_header优雅处理Windows DLL与Linux/macOS的动态符号。
  3. 版本控制:通过VERSIONSOVERSION管理Linux soname,通过project()版本号贯穿构建全流程。
  4. CMake生态集成:安装导出目标(install(EXPORT))并提供Config.cmakeConfigVersion.cmake,让下游用find_package丝滑导入。
  5. 传统生态兼容:生成.pc文件,支持pkg-config用户。
  6. 文档配套:Doxygen与CMake联动,自动生成并可选地安装API文档。

在下一节(10.3)中,施工难度将再次升级:我们将进入GUI领域,用CMake构建一个基于Qt和OpenGL的图形应用程序。届时你会看到CMake如何处理Qt特有的MOC、UIC、RCC预处理流程,以及如何把应用打包成可分发的安装包。准备好了吗?

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……