CMake学习:动态库场景下的应用

本期我们就来介绍CMake在动态库的场景下的应用

相关的代码已经上传至我的gitee:CMake 学习: CMake工具开发介绍仓库,虽然标记的是C++喜欢请点个赞谢谢

目录

示例代码

目录

app

calculator

include

src

test

CMake的rPath机制与Linux动态库加载

相关概念

[Linux 动态链接器 (ld.so) 的搜索宝图](#Linux 动态链接器 (ld.so) 的搜索宝图)

[CMake 的幕后策划:构建时的设定与安装时的"手术"](#CMake 的幕后策划:构建时的设定与安装时的“手术”)

[构建树 (Build Tree):为"原地可运行"而生](#构建树 (Build Tree):为“原地可运行”而生)

[安装树 (Install Tree):通往可部署性的"外科手术"](#安装树 (Install Tree):通往可部署性的“外科手术”)

[如何设计你的 RPATH 策略](#如何设计你的 RPATH 策略)

场景一:打造可重定位的便携软件包

场景二:服务器固定路径部署

[CMake 的 RPATH 工具箱:从全局到局部的精细控制](#CMake 的 RPATH 工具箱:从全局到局部的精细控制)

诊断与修复工具:你的尖刀和针线

CMake安装动态库

指令

如何安装到指定目录路径

[方式 1:配置阶段指定(最常用)](#方式 1:配置阶段指定(最常用))

[方式 2:安装阶段临时覆盖](#方式 2:安装阶段临时覆盖)

[方式 3:DESTDIR(传统 Linux 打包)](#方式 3:DESTDIR(传统 Linux 打包))

其他项目使用

find_package

[find_package 的两种模式](#find_package 的两种模式)

[Module 模式(Find .cmake)](#Module 模式(Find .cmake))

[Config 模式( Config.cmake 或 -config.cmake)](#Config 模式( Config.cmake 或 -config.cmake))

[find_package 在 Config 模式下需要准备哪些文件?](#find_package 在 Config 模式下需要准备哪些文件?)

包配置文件:calculatorConfig.cmake

导入目标文件:calculator-targets.cmake(或其他命名)

版本文件:calculatorConfigVersion.cmake(推荐)

头文件与库文件本身

[CMake 如何找到这些文件?(搜索顺序)](#CMake 如何找到这些文件?(搜索顺序))

如何让其他项目也能用find_apckage找到动静态库

改造目标

[同时构建静态库和动态库的 CMakeLists.txt](#同时构建静态库和动态库的 CMakeLists.txt)

[包配置模板 calculator-config.cmake.in](#包配置模板 calculator-config.cmake.in)

头文件中如何处理静态/动态符号导出?

使用者如何选择静态库还是动态库?

更优雅的方式:提供一个默认选择


示例代码

我们以一个简单的计算器为例,实现加减乘除的功能,来讲解一下

目录

CMakeLists.txt

bash 复制代码
# 指定CMake最低版本要求,确保使用的CMake特性在该版本及以上可用
cmake_minimum_required(VERSION 3.10)

# 定义项目名称和使用的编程语言
# VERSION参数指定项目版本号,后续可通过PROJECT_VERSION变量访问
project(Calculator 
        VERSION 1.0.0 
        LANGUAGES CXX)

# 设置C++标准为C++11,确保使用现代C++特性
# CMAKE_CXX_STANDARD是CMake内置变量,控制C++标准版本
set(CMAKE_CXX_STANDARD 11)

# 设置C++标准为"必需",如果编译器不支持指定标准则报错
# CMAKE_CXX_STANDARD_REQUIRED为ON表示必须支持该标准
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 禁止C++标准的扩展,确保代码严格符合标准
# CMAKE_CXX_EXTENSIONS为OFF表示不使用编译器特定的扩展
set(CMAKE_CXX_EXTENSIONS OFF)

# 设置输出目录,将编译产物(库文件和可执行文件)统一输出到项目根目录下的build子目录
# CMAKE_ARCHIVE_OUTPUT_DIRECTORY: 静态库输出目录
# CMAKE_LIBRARY_OUTPUT_DIRECTORY: 动态库输出目录
# CMAKE_RUNTIME_OUTPUT_DIRECTORY: 可执行文件输出目录
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)

# 启用测试功能,允许使用add_test()等测试相关命令
enable_testing()

# 添加子目录,CMake会进入每个子目录并执行其中的CMakeLists.txt
# calculator: 动态库目录
# app: 应用程序目录
# test: 测试目录
add_subdirectory(calculator)
add_subdirectory(app)
add_subdirectory(test)

app

main.cpp

cpp 复制代码
#include <iostream>
#include <limits>
#include "calculator.h"

int main() 
{
    Calculator calc;
    double num1, num2;
    char operation;
    
    std::cout << "简单计算器" << std::endl;
    std::cout << "支持运算: + - * /" << std::endl;
    
    while (true) 
    {
        std::cout << "\n请输入第一个数字 (或输入 q 退出): ";
        if (!(std::cin >> num1)) 
        {
            std::string input;
            std::cin.clear();
            std::cin >> input;
            if (input == "q") 
            {
                break;
            }
            std::cout << "无效输入,请重试。" << std::endl;
            continue;
        }
        
        std::cout << "请输入运算符 (+, -, *, /): ";
        std::cin >> operation;
        
        std::cout << "请输入第二个数字: ";
        if (!(std::cin >> num2)) 
        {
            std::cout << "无效输入,请重试。" << std::endl;
            std::cin.clear();
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
            continue;
        }
        
        try 
        {
            double result;
            switch (operation) 
            {
                case '+':
                    result = calc.add(num1, num2);
                    break;
                case '-':
                    result = calc.subtract(num1, num2);
                    break;
                case '*':
                    result = calc.multiply(num1, num2);
                    break;
                case '/':
                    result = calc.divide(num1, num2);
                    break;
                default:
                    std::cout << "无效的运算符!" << std::endl;
                    continue;
            }
            std::cout << "结果: " << num1 << " " << operation << " " << num2 << " = " << result << std::endl;
        }
        catch (const std::exception& e) 
        {
            std::cout << "错误: " << e.what() << std::endl;
        }
    }
    
    std::cout << "感谢使用计算器!" << std::endl;
    return 0;
}

CMakeLists.txt

bash 复制代码
# 定义可执行文件的源文件
# APP_SOURCES变量存储应用程序的所有源文件
set(APP_SOURCES
    main.cpp
)

# 创建可执行文件目标
# calculator_app: 目标名称,也是生成的可执行文件名
# ${APP_SOURCES}: 源文件列表
add_executable(calculator_app ${APP_SOURCES})

# 链接库文件
# PRIVATE: 私有链接,仅calculator_app需要calculator库
# calculator: 要链接的库名称,对应add_library()创建的目标
target_link_libraries(calculator_app PRIVATE calculator)

# 设置输出目录(可选,根目录已统一设置)
# set_target_properties(calculator_app PROPERTIES
#     RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
# )

# 安装规则
# 将可执行文件安装到bin目录
install(TARGETS calculator_app
    RUNTIME DESTINATION bin
)

calculator

include

calculator.h

cpp 复制代码
#pragma once
// 跨平台符号导出宏
// CALCULATOR_EXPORTS在编译动态库时定义,在导入时未定义
#ifdef CALCULATOR_EXPORTS
    #ifdef _WIN32
        #define CALCULATOR_API __declspec(dllexport)
    #else
        #define CALCULATOR_API __attribute__((visibility("default")))
    #endif
#else
    #ifdef _WIN32
        #define CALCULATOR_API __declspec(dllimport)
    #else
        #define CALCULATOR_API
    #endif
#endif

class CALCULATOR_API Calculator 
{
public:
    Calculator() noexcept=default;
    ~Calculator() noexcept=default;
    
    double add(double a, double b) noexcept;
    double subtract(double a, double b) noexcept;
    double multiply(double a, double b) noexcept;
    double divide(double a, double b) noexcept;
};
src

calculator.cpp

cpp 复制代码
#include "calculator.h"
#include <stdexcept>


double Calculator::add(double a, double b) noexcept 
{
    return a + b;
}

double Calculator::subtract(double a, double b) noexcept 
{
    return a - b;
}

double Calculator::multiply(double a, double b) noexcept 
{
    return a * b;
}

double Calculator::divide(double a, double b) noexcept 
{
    if (b == 0.0) 
    {
        throw std::runtime_error("Division by zero");
    }
    return a / b;
}

CMakeLists.txt

cpp 复制代码
# 定义动态库的源文件
# CALCULATOR_SOURCES变量存储所有源文件路径,便于后续引用
set(CALCULATOR_SOURCES
    src/calculator.cpp
)

# 定义动态库的头文件
# CALCULATOR_HEADERS变量存储所有头文件路径,用于IDE支持和安装
set(CALCULATOR_HEADERS
    include/calculator.h
)

# 创建动态库目标
# calculator: 目标名称,也是库文件名(生成libcalculator.so或calculator.dll)
# SHARED: 指定生成共享库(动态库),对应STATIC生成静态库
# ${CALCULATOR_SOURCES}: 源文件列表,展开为实际的源文件路径
add_library(calculator SHARED ${CALCULATOR_SOURCES} ${CALCULATOR_HEADERS})

# 设置目标属性
# TARGET_NAME: 目标名称,这里是calculator
# PROPERTY: 属性名称
# VERSION: 库的版本号,用于生成带版本号的库文件(如libcalculator.so.1.0.0)
# SOVERSION: 库的API版本号,用于生成符号链接(如libcalculator.so.1)
set_target_properties(calculator PROPERTIES
    VERSION ${PROJECT_VERSION}
    SOVERSION 1
)

# 定义预处理器宏,用于Windows平台的动态库符号导出
# CALCULATOR_EXPORTS: 宏名称,在头文件中用于判断是导出还是导入
# PRIVATE: 可见性,仅在当前目标内部可见
target_compile_definitions(calculator PRIVATE CALCULATOR_EXPORTS)

# 设置头文件包含路径
# PUBLIC: 公开可见,意味着链接此库的目标也能访问这些路径
# $<BUILD_INTERFACE:...>: 生成器表达式,指定构建时的接口路径
# $<INSTALL_INTERFACE:...>: 生成器表达式,指定安装后的接口路径
target_include_directories(calculator PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

# 设置C++标准
# PUBLIC: 公开可见,确保使用此库的目标也使用相同的标准
target_compile_features(calculator PUBLIC cxx_std_11)

# 安装规则
# 将库文件安装到指定目录
# LIBRARY: 动态库文件(Unix-like系统)
# DESTINATION: 安装目标目录,${CMAKE_INSTALL_PREFIX}/lib
# ARCHIVE: 静态库文件
# RUNTIME: Windows平台的DLL文件
install(TARGETS calculator
    LIBRARY DESTINATION lib
    ARCHIVE DESTINATION lib
    RUNTIME DESTINATION bin
)

# 安装头文件
# FILES: 要安装的文件列表
# DESTINATION: 安装目标目录,${CMAKE_INSTALL_PREFIX}/include
install(FILES ${CALCULATOR_HEADERS} DESTINATION include)

test

test.cpp

cpp 复制代码
#include "calculator.h"
#include <iostream>
#include <cmath>
#include <string>
#include <vector>

// 简单的测试框架实现

// 测试统计信息
struct TestStats {
    int total = 0;
    int passed = 0;
    int failed = 0;
};

static TestStats g_stats;
static std::vector<std::string> g_failed_tests;

// 断言宏
#define EXPECT_TRUE(condition) \
    do { \
        g_stats.total++; \
        if (!(condition)) { \
            g_stats.failed++; \
            std::cout << "  [FAILED] " << #condition << " is false" << std::endl; \
            std::cout << "    at " << __FILE__ << ":" << __LINE__ << std::endl; \
        } else { \
            g_stats.passed++; \
        } \
    } while(0)

#define EXPECT_FALSE(condition) EXPECT_TRUE(!(condition))

#define EXPECT_EQ(expected, actual) \
    do { \
        g_stats.total++; \
        if ((expected) != (actual)) { \
            g_stats.failed++; \
            std::cout << "  [FAILED] Expected: " << (expected) \
                      << ", Actual: " << (actual) << std::endl; \
            std::cout << "    at " << __FILE__ << ":" << __LINE__ << std::endl; \
        } else { \
            g_stats.passed++; \
        } \
    } while(0)

#define EXPECT_NE(expected, actual) \
    do { \
        g_stats.total++; \
        if ((expected) == (actual)) { \
            g_stats.failed++; \
            std::cout << "  [FAILED] Expected: " << (expected) \
                      << " != " << (actual) << std::endl; \
            std::cout << "    at " << __FILE__ << ":" << __LINE__ << std::endl; \
        } else { \
            g_stats.passed++; \
        } \
    } while(0)

#define EXPECT_NEAR(expected, actual, abs_error) \
    do { \
        g_stats.total++; \
        double diff = std::abs((expected) - (actual)); \
        if (diff > (abs_error)) { \
            g_stats.failed++; \
            std::cout << "  [FAILED] Expected: " << (expected) \
                      << ", Actual: " << (actual) \
                      << ", Diff: " << diff << std::endl; \
            std::cout << "    at " << __FILE__ << ":" << __LINE__ << std::endl; \
        } else { \
            g_stats.passed++; \
        } \
    } while(0)

#define EXPECT_THROW(statement, exception_type) \
    do { \
        g_stats.total++; \
        bool caught = false; \
        try { \
            statement; \
        } catch (const exception_type&) { \
            caught = true; \
        } catch (...) { \
            g_stats.failed++; \
            std::cout << "  [FAILED] Wrong exception type thrown" << std::endl; \
            std::cout << "    at " << __FILE__ << ":" << __LINE__ << std::endl; \
            break; \
        } \
        if (!caught) { \
            g_stats.failed++; \
            std::cout << "  [FAILED] No exception thrown" << std::endl; \
            std::cout << "    at " << __FILE__ << ":" << __LINE__ << std::endl; \
        } else { \
            g_stats.passed++; \
        } \
    } while(0)

// 测试用例宏
#define TEST(test_name) \
    void test_##test_name(); \
    struct TestRegistrar_##test_name { \
        TestRegistrar_##test_name() { \
            register_test(#test_name, test_##test_name); \
        } \
    }; \
    static TestRegistrar_##test_name g_test_registrar_##test_name; \
    void test_##test_name()

// 测试注册
using TestFunc = void(*)();
struct TestCase {
    std::string name;
    TestFunc func;
};

static std::vector<TestCase> g_test_cases;

void register_test(const std::string& name, TestFunc func) {
    g_test_cases.push_back({name, func});
}

// 运行所有测试
bool run_all_tests() {
    std::cout << "\n========================================" << std::endl;
    std::cout << "Running " << g_test_cases.size() << " test cases..." << std::endl;
    std::cout << "========================================\n" << std::endl;
    
    for (const auto& test_case : g_test_cases) {
        std::cout << "[TEST] " << test_case.name << std::endl;
        test_case.func();
        std::cout << std::endl;
    }
    
    std::cout << "========================================" << std::endl;
    std::cout << "Test Results:" << std::endl;
    std::cout << "  Total:  " << g_stats.total << std::endl;
    std::cout << "  Passed: " << g_stats.passed << std::endl;
    std::cout << "  Failed: " << g_stats.failed << std::endl;
    std::cout << "========================================\n" << std::endl;
    
    return g_stats.failed == 0;
}

// ==================== 测试用例 ====================

TEST(AddOperation) {
    Calculator calc;
    EXPECT_NEAR(calc.add(2.0, 3.0), 5.0, 0.0001);
    EXPECT_NEAR(calc.add(-1.0, 1.0), 0.0, 0.0001);
    EXPECT_NEAR(calc.add(0.0, 0.0), 0.0, 0.0001);
}

TEST(SubtractOperation) {
    Calculator calc;
    EXPECT_NEAR(calc.subtract(5.0, 3.0), 2.0, 0.0001);
    EXPECT_NEAR(calc.subtract(1.0, 1.0), 0.0, 0.0001);
    EXPECT_NEAR(calc.subtract(-1.0, -1.0), 0.0, 0.0001);
}

TEST(MultiplyOperation) {
    Calculator calc;
    EXPECT_NEAR(calc.multiply(2.0, 3.0), 6.0, 0.0001);
    EXPECT_NEAR(calc.multiply(-1.0, 1.0), -1.0, 0.0001);
    EXPECT_NEAR(calc.multiply(0.0, 5.0), 0.0, 0.0001);
}

TEST(DivideOperation) {
    Calculator calc;
    EXPECT_NEAR(calc.divide(6.0, 3.0), 2.0, 0.0001);
    EXPECT_NEAR(calc.divide(1.0, 2.0), 0.5, 0.0001);
    EXPECT_NEAR(calc.divide(-1.0, 1.0), -1.0, 0.0001);
}

TEST(DivideByZero) {
    Calculator calc;
    EXPECT_THROW(calc.divide(1.0, 0.0), std::runtime_error);
}

TEST(EdgeCases) {
    Calculator calc;
    EXPECT_NEAR(calc.add(1e10, 1e10), 2e10, 1e5);
    EXPECT_NEAR(calc.multiply(0.1, 0.1), 0.01, 0.0001);
    EXPECT_NEAR(calc.divide(1.0, 3.0), 0.333333, 0.000001);
}

// ==================== 主函数 ====================

int main() 
{
    bool all_passed = run_all_tests();
    return all_passed ? 0 : 1;
}

CMakeLists.txt

bash 复制代码
# 启用测试功能
enable_testing()

# 定义测试源文件
# TEST_SOURCES变量存储所有测试源文件
set(TEST_SOURCES
    test.cpp
)

# 创建测试可执行文件
# calculator_test: 测试可执行文件名
# ${TEST_SOURCES}: 测试源文件列表
add_executable(calculator_test ${TEST_SOURCES})

# 链接被测试的calculator库
# PRIVATE: 私有链接,仅测试可执行文件需要这些库
# calculator: 被测试的动态库
target_link_libraries(calculator_test PRIVATE calculator)

# 添加测试到CTest
# calculator_test: 测试名称
# COMMAND: 要执行的命令,这里运行calculator_test可执行文件
add_test(NAME calculator_test COMMAND calculator_test)

# 设置测试属性
# TIMEOUT: 设置测试超时时间(秒)
# LABELS: 为测试添加标签,便于分类和筛选
set_tests_properties(calculator_test PROPERTIES
    TIMEOUT 30
    LABELS "unit"
)

CMake的rPath机制与Linux动态库加载

相关概念

RPATH(Runtime search PATH)或 RUNPATH 就像硬编码在 ELF 可执行文件或动态库(.so)里的"藏宝图",告诉 ld.so 在程序启动时应该优先去哪些非标准路径寻找它依赖的 .so 文件。

在 Linux 中,它对应 ELF 文件的 .dynamic 段里的 DT_RPATH 条目。而现代的 RUNPATH,对应 DT_RUNPATH 条目。

RPATH (DT_RPATH) 与 RUNPATH (DT_RUNPATH) 的核心差别:一个影响动态库搜索顺序的关键不同。

这两种机制的主要区别在于它们与 LD_LIBRARY_PATH 环境变量的优先级关系,这正是理解许多运行时行为的关键:

  • RPATH (旧) :优先级高于 LD_LIBRARY_PATH。这意味着一旦在编译时设定了 RPATH,用户将无法在运行时通过 LD_LIBRARY_PATH 来覆盖它,这可能导致部署或调试时的死板。

  • RUNPATH (新/推荐) :优先级低于 LD_LIBRARY_PATH。这赋予了用户更大的灵活性,他们可以在不重新编译程序的情况下,通过设置环境变量来临时覆盖或调整库的加载路径,更方便调试和问题排查。

关键规则 :当一个可执行文件或库中同时 设置了 RPATH 和 RUNPATH 时,RPATH 会被忽略,动态链接器将以 RUNPATH 为准。

binutils 2.17 起,使用 -Wl,-rpath,<path> 链接选项时,大多数现代 Linux 发行版默认生成的是 RUNPATH。如果需要强制生成 RPATH,可使用 -Wl,--disable-new-dtags

Linux 动态链接器 (ld.so) 的搜索宝图

理解 ld.so 的搜索顺序是解决问题的关键。当程序启动时,ld.so 会按照以下顺序查找其依赖的 .so 文件:

  1. 直接依赖库的 RPATHld.so 会先检查导致本次查找的那个库 自身的 RPATH。比如,libbar.so 的 RPATH 会优先于 foo 的 RPATH 被搜索。

  2. 可执行文件的 RPATH:然后检查可执行文件本身的 RPATH。

  3. LD_LIBRARY_PATH 环境变量 :如果此时还未找到,则搜索此变量中的路径。注意 :如果可执行文件是 setuid/setgid 的,出于安全原因,LD_LIBRARY_PATH 会被忽略。

  4. 可执行文件的 RUNPATHRPATHLD_LIBRARY_PATH"打断"后,轮到 RUNPATH 登场。

  5. 系统缓存文件 (/etc/ld.so.cache) :由 ldconfig 命令生成,包含 /etc/ld.so.conf 中配置的所有路径。

  6. 系统默认路径 :最后,搜索 /lib/usr/lib 等硬编码的默认路径

CMake 的幕后策划:构建时的设定与安装时的"手术"

CMake 在 RPATH 管理中扮演着总导演的角色,它根据项目所处的构建树(Build Tree)安装树(Install Tree) 两个阶段,智能、自动地处理 RPATH 问题。

构建树 (Build Tree):为"原地可运行"而生

当你在 build/ 下运行 make 后,CMake 会自动为你的可执行文件和库设置 BUILD_RPATH,使其指向构建树内其他依赖库的实际路径。这让你能在构建目录下直接运行程序进行测试,无需任何环境变量。

  • BUILD_RPATH (目标属性):用于指定额外的构建期搜索路径。

  • CMAKE_BUILD_RPATH (全局变量) :为所有目标设置默认的 BUILD_RPATH

安装树 (Install Tree):通往可部署性的"外科手术"

当你执行 make install 时,CMake 知道这些文件的最终位置会发生变化,因此它会进行关键的一步:剥离(Stripping)或替换 RPATH

这个过程是"外科手术式"的:CMake 会利用 chrpathpatchelf 等工具,直接编辑二进制文件,清除 构建树的 BUILD_RPATH 路径,替换 为你预先设定的安装位置的路径。你可以通过 CMAKE_NO_BUILTIN_CHRPATH 变量来控制此编辑行为。这个步骤确保了最终部署版本的干净、灵活和可移植性。

如何设计你的 RPATH 策略

理论说再多,最终还是要落在 CMakeLists.txt 里。请根据你的部署场景选择策略。

场景一:打造可重定位的便携软件包

推荐做法 :利用 $ORIGIN@loader_path 等动态变量,将所有依赖库放在程序可执行文件能通过相对路径找到的地方。这是分发软件的最佳方式。

bash 复制代码
# 假设安装后目录结构:
# /opt/myapp/
#   ├── bin/myapp
#   └── lib/libmyutils.so

# 全局设置,让所有目标都能在运行时找到 ../lib 下的库
set(CMAKE_INSTALL_RPATH "\$ORIGIN/../lib")
# 在 CMake 中,$ORIGIN 必须加反斜杠转义,防止被当成 CMake 变量

# 可选:如果还需在构建树中直接运行,可保留构建期 RPATH
set(CMAKE_BUILD_WITH_INSTALL_RPATH FALSE)

# 安装规则
install(TARGETS myapp RUNTIME DESTINATION bin)
install(TARGETS myutils LIBRARY DESTINATION lib)
场景二:服务器固定路径部署

推荐做法:使用绝对路径。这种方式不灵活但最直接,适合最终的安装位置固定不变的场景。

bash 复制代码
# 为特定 target 设置绝对路径
set_target_properties(myapp PROPERTIES
  INSTALL_RPATH "/opt/myapp/lib"
)

如果需要精确控制且路径不标准,可启用此选项让 CMake 自动添加链接库的原生路径:

bash 复制代码
set_target_properties(myapp PROPERTIES
  INSTALL_RPATH_USE_LINK_PATH TRUE
)

CMake 的 RPATH 工具箱:从全局到局部的精细控制

CMake 提供了从全局到局部的多层 RPATH 控制选项:

  • CMAKE_INSTALL_RPATH (推荐全局变量):设置所有目标的默认安装 RPATH。

  • INSTALL_RPATH (推荐目标属性):为特定目标精确设置安装 RPATH,可覆盖全局设定。路径之间用分号分隔。

  • INSTALL_RPATH_USE_LINK_PATH (实用属性) :这是一个非常方便的选项。当设置为 TRUE 时,CMake 会自动 将所有链接时用到的库的路径追加到 INSTALL_RPATH 中。这在依赖关系复杂时尤其有用,能省去手动罗列路径的麻烦

诊断与修复工具:你的尖刀和针线

遇到问题时,以下工具能帮你快速定位和修复 RPATH:

  • readelf -d <binary>:查看 ELF 文件的动态段信息,是诊断 RPATH/RUNPATH 问题的不二选择。
bash 复制代码
readelf -d myapp | grep -E 'RPATH|RUNPATH'
  • ldd <binary>:打印程序所需的共享库,以及它们是否能被找到,是发现缺失库最快速的手段。
bash 复制代码
ldd myapp # 如果看到某行结尾是 "not found",就说明该库缺失
  • chrpathpatchelf :用于在编译后修改或删除二进制文件中的 RPATH。patchelf 功能更强大,可以修改一个原本没有 RPATH 的二进制文件。
bash 复制代码
# 使用 patchelf 修改或添加 RPATH
patchelf --set-rpath '/opt/new/lib/path' ./myapp

CMake安装动态库

指令

动态库(.so)在 CMake 的安装语境下,属于 LIBRARY 类型。一个库目标可能需要安装多种产物:

产物类型 CMake 关键字 Linux 下对应 Windows 下对应
动态库(共享库) LIBRARY libxxx.so xxx.dll (注意:.dll 归 RUNTIME 管)
静态库 ARCHIVE libxxx.a xxx.lib
可执行文件 RUNTIME 可执行文件 .exe.dll
头文件 PUBLIC_HEADER/PRIVATE_HEADER .h .h

我们已经写好了对应的CMake脚本(这里是一部分)

bash 复制代码
# 安装规则
# 将库文件安装到指定目录
# LIBRARY: 动态库文件(Unix-like系统)
# DESTINATION: 安装目标目录,${CMAKE_INSTALL_PREFIX}/lib
# ARCHIVE: 静态库文件
# RUNTIME: Windows平台的DLL文件
install(TARGETS calculator
    LIBRARY DESTINATION lib
    ARCHIVE DESTINATION lib
    RUNTIME DESTINATION bin
)

# 安装头文件
# FILES: 要安装的文件列表
# DESTINATION: 安装目标目录,${CMAKE_INSTALL_PREFIX}/include
install(FILES ${CALCULATOR_HEADERS} DESTINATION include)

如何安装到指定目录路径

你有三种方式控制最终安装的根目录,取决于你的使用场景:

方式 1:配置阶段指定(最常用)

bash 复制代码
cmake -B build -DCMAKE_INSTALL_PREFIX=/opt/calculator
cmake --build build
cmake --install build

所有 DESTINATION 中写的相对路径都会以 /opt/calculator 为基础。

方式 2:安装阶段临时覆盖

bash 复制代码
cmake --install build --prefix /home/me/my_install

这会忽略配置时设的 CMAKE_INSTALL_PREFIX,直接用 /home/me/my_install

方式 3:DESTDIR(传统 Linux 打包)

bash 复制代码
cmake --install build --prefix /usr/local
# 等价于
DESTDIR=/tmp/staging cmake --install build

最终文件会落到 /tmp/staging/usr/local/... 下,打包 .deb/.rpm 时非常方便。

推荐 :始终在 CMakeLists.txt 里使用相对路径,外部通过 CMAKE_INSTALL_PREFIX 决定安装位置

其他项目使用

其他项目想要使用它,只能:

bash 复制代码
# 很不优雅的用法
include_directories(/opt/calculator/include)
target_link_libraries(myapp /opt/calculator/lib/libcalculator.so)

每次都得记住头文件和库路径,而且没有版本检查,容易出错。

专业做法:安装 CMake 包配置文件,让使用者只需:

bash 复制代码
find_package(calculator REQUIRED)
target_link_libraries(myapp calculator::calculator)

但是这需要额外的讲解

find_package

find_package 的两种模式

find_package(calculator) 会按以下优先级尝试查找:

Module 模式(Find<Package>.cmake

CMake 会在 CMAKE_MODULE_PATH 中搜索一个名为 Findcalculator.cmake 的文件。

如果找到,就由这个脚本全权负责定位库的所有部分(通常用 find_pathfind_library 等命令)。

这种模式通常由库的使用者或 CMake 自身提供 (例如 FindZLIB.cmake),库作者一般不需要关心

Config 模式(<Package>Config.cmake<package>-config.cmake

这是库作者最需要负责的模式。CMake 会搜索由库安装后提供的配置文件:

  • <Package>Config.cmake (首推)

  • <lowercase-package>-config.cmake (也常被支持)

在我们的场景中,你作为 calculator 库的开发者,要准备的就是这套配置。
find_package(calculator REQUIRED) 找到 calculatorConfig.cmake 后,就会加载它,从而获得所有的导入目标(如 calculator::calculator)。

find_package 在 Config 模式下需要准备哪些文件?

为了让 find_package(calculator) 顺利工作,你需要在安装目录中包含以下文件集合(正好对应你之前脚本生成的):

包配置文件:calculatorConfig.cmake

这是 find_package 最直接寻找的文件入口

通常由模板 calculatorConfig.cmake.in 通过 configure_package_config_file() 生成,安装到:

bash 复制代码
<prefix>/lib/cmake/calculator/calculatorConfig.cmake

其典型内容:

bash 复制代码
@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/calculator-targets.cmake")

check_required_components(calculator)
  • @PACKAGE_INIT@ 会被展开为包初始化代码,实现可重定位(无论安装包被移动到哪里,路径都能自动适配)。

  • 它负责加载包含导入目标的文件(下一项)。

导入目标文件:calculator-targets.cmake(或其他命名)

这个文件由 install(EXPORT ...) 命令生成,记录了:

  • 导入目标 calculator::calculator 的库文件位置(IMPORTED_LOCATION

  • 接口头文件路径(INTERFACE_INCLUDE_DIRECTORIES

  • 编译特性、依赖的其它库......

  • 所有编译器/链接器要求(例如 C++11 标准)

生成它的关键指令:

bash 复制代码
install(EXPORT calculator-targets
  FILE calculator-targets.cmake
  NAMESPACE calculator::
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/calculator
)

calculatorConfig.cmake 正是通过 include("${CMAKE_CURRENT_LIST_DIR}/calculator-targets.cmake") 把它拉进来的。

版本文件:calculatorConfigVersion.cmake(推荐)

虽然严格来说不是必须的,但没有版本文件,任何版本要求都会失败

如果你的 find_package 调用了:

bash 复制代码
find_package(calculator 1.2 REQUIRED)

CMake 就必须找到版本配置文件,否则会报错。

它由 write_basic_package_version_file() 生成:

bash 复制代码
write_basic_package_version_file(
  "${CMAKE_CURRENT_BINARY_DIR}/calculatorConfigVersion.cmake"
  VERSION ${PROJECT_VERSION}
  COMPATIBILITY SameMajorVersion
)

文件名必须是 <Config>.cmake 同名的后缀 Version.cmake,即 calculatorConfigVersion.cmake

头文件与库文件本身

当然,光有 CMake 文件不够,真正的资产(.so.h)也必须安装到位。这就是你安装命令里的基础操作:

bash 复制代码
install(TARGETS calculator ...)
install(FILES ${CALCULATOR_HEADERS} DESTINATION include/calculator)

导入目标 calculator::calculator 中的路径会指向这里。

CMake 如何找到这些文件?(搜索顺序)

Config 模式下,CMake 会在以下位置寻找 <package>Config.cmake(以 calculator 为例):

  1. 用户指定的路径

    • -Dcalculator_DIR=/some/path (精确指向包含配置文件的目录)

    • -DCMAKE_PREFIX_PATH=/opt/calculator (会在其下的 lib/cmake/calculator 中寻找)

    • -DCMAKE_FRAMEWORK_PATH-DCMAKE_APPBUNDLE_PATH(macOS 框架相关)

  2. 环境变量

    • export calculator_DIR=/opt/calculator/lib/cmake/calculator
  3. 系统默认路径

    /usr/local/lib/cmake/calculator//usr/lib/cmake/calculator/ 等(由 CMAKE_SYSTEM_PREFIX_PATH 决定)。

最佳实践

安装路径采用 <prefix>/lib/cmake/<package>/ 结构,这样使用者只需设置 CMAKE_PREFIX_PATH=<prefix>,就能一次性找到所有库。你的脚本已经做到了这一点:

bash 复制代码
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/calculator

如何让其他项目也能用find_apckage找到动静态库

改造目标

我们想要最终用户能这样使用:

bash 复制代码
find_package(calculator REQUIRED)

# 想用动态库
target_link_libraries(my_app PRIVATE calculator::calculator_shared)

# 或想用静态库
target_link_libraries(my_app PRIVATE calculator::calculator_static)

两个目标都会自动带上正确的头文件路径、编译定义和链接依赖,互不冲突。

同时构建静态库和动态库的 CMakeLists.txt

下面是我为你写的完整 CMakeLists.txt(基于你上一个版本优化,并补全了同时导出的所有细节):

bash 复制代码
cmake_minimum_required(VERSION 3.14)
project(calculator VERSION 1.0.0 LANGUAGES CXX)

include(GNUInstallDirs)

# 源文件
set(CALCULATOR_SOURCES src/calculator.cpp)
set(CALCULATOR_HEADERS include/calculator.h)

# ========== 动态库目标 ==========
add_library(calculator_shared SHARED ${CALCULATOR_SOURCES} ${CALCULATOR_HEADERS})
add_library(calculator::calculator_shared ALIAS calculator_shared)

set_target_properties(calculator_shared PROPERTIES
    VERSION ${PROJECT_VERSION}
    SOVERSION 1
)

# 动态库符号导出宏(Windows)
target_compile_definitions(calculator_shared PRIVATE CALCULATOR_EXPORTS)

# 头文件包含路径(构建树和安装树)
target_include_directories(calculator_shared PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)

target_compile_features(calculator_shared PUBLIC cxx_std_11)

# ========== 静态库目标 ==========
add_library(calculator_static STATIC ${CALCULATOR_SOURCES} ${CALCULATOR_HEADERS})
add_library(calculator::calculator_static ALIAS calculator_static)

# 静态库通常没有 VERSION 和 SOVERSION,可选设置输出名
set_target_properties(calculator_static PROPERTIES
    OUTPUT_NAME "calculator"
)

# 静态库需要定义宏告诉使用者当前是静态链接(Windows 下重要)
target_compile_definitions(calculator_static PUBLIC CALCULATOR_STATIC)

target_include_directories(calculator_static PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)

target_compile_features(calculator_static PUBLIC cxx_std_11)

# ================= 安装规则 =================

# 安装动态库和静态库,同时导出到一个导出集
install(TARGETS calculator_shared calculator_static
    EXPORT calculator-targets
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}    # 动态库 .so
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}    # 静态库 .a
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}    # Windows DLL
    INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

# 安装头文件
install(FILES ${CALCULATOR_HEADERS} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})

# 导出导入目标文件
install(EXPORT calculator-targets
    FILE calculator-targets.cmake
    NAMESPACE calculator::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/calculator
)

# 生成版本文件
include(CMakePackageConfigHelpers)
write_basic_package_version_file(
    "${CMAKE_CURRENT_BINARY_DIR}/calculator-config-version.cmake"
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY SameMajorVersion
)

# 生成包配置文件
configure_package_config_file(
    "${CMAKE_CURRENT_SOURCE_DIR}/calculator-config.cmake.in"
    "${CMAKE_CURRENT_BINARY_DIR}/calculator-config.cmake"
    INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/calculator
)

# 安装 Config 和 Version 文件
install(
    FILES
        "${CMAKE_CURRENT_BINARY_DIR}/calculator-config.cmake"
        "${CMAKE_CURRENT_BINARY_DIR}/calculator-config-version.cmake"
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/calculator
)

包配置模板 calculator-config.cmake.in

和之前的几乎一样,因为它要做的只是加载那个包含了所有导入目标的文件:

bash 复制代码
@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/calculator-targets.cmake")

check_required_components(calculator)

不需要额外处理,因为两个目标都已经在 calculator-targets.cmake 里定义好了。

头文件中如何处理静态/动态符号导出?

为了让同一个头文件同时支持静态库和动态库的正确链接(尤其在 Windows 下必须区分 __declspec(dllimport)),你需要在 calculator.h 中这样写:

cpp 复制代码
#ifndef CALCULATOR_H
#define CALCULATOR_H

#if defined(_WIN32) || defined(__CYGWIN__)
  #ifdef CALCULATOR_STATIC
    #define CALCULATOR_API
  #elif defined(CALCULATOR_EXPORTS)
    #define CALCULATOR_API __declspec(dllexport)
  #else
    #define CALCULATOR_API __declspec(dllimport)
  #endif
#else
  #define CALCULATOR_API
#endif

class CALCULATOR_API Calculator {
public:
    double add(double a, double b);
    double sub(double a, double b);
};

#endif // CALCULATOR_H

解释:

  • 当构建 动态库 时,CALCULATOR_EXPORTS 被私有地定义在 calculator_shared 上,所以类被标记为 dllexport

  • 当构建 静态库 时,CALCULATOR_STATIC 被公开地传给使用方的编译定义,因此 CALCULATOR_API 为空(不需要导入导出)。

  • 当使用者链接动态库 时,没有定义 CALCULATOR_EXPORTS,也没有 CALCULATOR_STATIC,因此会触发 dllimport

使用者如何选择静态库还是动态库?

安装好库后,使用方只需:

bash 复制代码
cmake_minimum_required(VERSION 3.14)
project(my_app)

find_package(calculator REQUIRED)

add_executable(my_app main.cpp)

# 链接动态库
target_link_libraries(my_app PRIVATE calculator::calculator_shared)

# 或者链接静态库
# target_link_libraries(my_app PRIVATE calculator::calculator_static)

这两个目标可以在同一个项目中同时被使用,只要不混链同一个符号即可。

更优雅的方式:提供一个默认选择

你还可以在 calculator-config.cmake.in 中提供一个开关,让用户通过变量选择默认目标:

bash 复制代码
@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/calculator-targets.cmake")

# 如果用户没有显式选择,可设置默认目标别名
if(NOT TARGET calculator::calculator)
    if(DEFINED calculator_USE_STATIC_LIBS AND calculator_USE_STATIC_LIBS)
        add_library(calculator::calculator ALIAS calculator::calculator_static)
    else()
        add_library(calculator::calculator ALIAS calculator::calculator_shared)
    endif()
endif()

check_required_components(calculator)

这样用户就可以用熟悉的 calculator::calculator,并通过

bash 复制代码
-Dcalculator_USE_STATIC_LIBS=ON 

控制链接方式。

本期内容就到这里了,请点个赞谢谢

封面图自取:

相关推荐
威迪斯特2 小时前
Gorilla框架:Go语言生态中的模块化开发利器
运维·开发语言·后端·golang·web框架·维护·gorilla
jingshaoqi_ccc2 小时前
使用QT6创建一个可编辑的表格并导出和载入
c++·qt·表格
光影少年2 小时前
vite+rust生态链工具链
开发语言·前端·后端·rust·前端框架
天若有情6732 小时前
C++进阶:普通重载运算符 vs 隐式类型转换重载运算符,一篇讲透区别
开发语言·c++·算法
南境十里·墨染春水2 小时前
linux学习进程 线程同步——读写锁
java·jvm·学习
IT_陈寒2 小时前
为什么我的JavaScript变量老是不听使唤?
前端·人工智能·后端
笨蛋不要掉眼泪2 小时前
面试篇-java基础下
java·后端·面试·职场和发展
知识分享小能手2 小时前
R语言入门学习教程,从入门到精通,R语言基础 - 完整知识点与案例代码(1)
开发语言·学习·r语言
云深麋鹿2 小时前
C++ | 二叉搜索树
开发语言·c++