写在前面

本文配合基于CMake构建规范c++系统的指南一起食用,旨在讲清楚cmake如何组织完整的一套程序;

我原来有一篇基于ROS的cmake构建指南,但是因为用到了ROS的一些东西,导致不具有泛用性,因此重新写一篇完整的说明

本文删改了好多次,甚至快写完了又重写,总是觉得讲不到位,像是在repeat api手册,主要还是对目标的理解不够深刻;
因此,这次我尝试从整体的角度来讲cmakelists的编写

CMake的任务目标

可以概括为五个核心阶段,它们也是我们将程序从代码到执行需要经过的最完整的步骤:

阶段 现实问题 CMake 要做的事 关键命令
1️⃣ 定义项目 我是谁?要编译什么语言? 创建工程、指定语言和标准 project()set(CMAKE_CXX_STANDARD)
2️⃣ 找依赖 我需要哪些外部库?头文件和.so 在哪? 搜索路径、找到并记录依赖 find_path()find_library()、find_package()
3️⃣ 生成目标 我要编译哪些文件?生成可执行文件或库 声明目标(executable/library) add_executable()add_library()
4️⃣ 编译链接 编译时要 -I 哪些目录?链接哪些库? 设置 include 路径、库路径、链接命令 target_include_directories()target_link_libraries()
5️⃣ 运行分发 运行时要能找到.so,怎么安装? 设置 RPATH、安装规则 set(CMAKE_INSTALL_RPATH)install()

这五个阶段就是任何 CMake 项目背后的逻辑骨架

阶段一,定义项目部分不再赘述

举例:让程序能直接 #include <zmq.hpp> 并调用 API

想象我们要写个程序 imu.cpp,里面有:

#include <zmq.hpp>

int main() {
    zmq::context_t ctx;
    zmq::socket_t sock(ctx, zmq::socket_type::pub);
    sock.bind("ipc:///tmp/imu_pub");
}

现在编译器吐槽:

fatal error: zmq.hpp: No such file or directory
undefined reference to zmq_ctx_new

这两个报错其实对应两个现实问题

错误 原因 解决思路
“找不到 zmq.hpp” 编译器不知道去哪找头文件 设置 include 路径
“undefined reference” 链接器没把 libzmq.so 加进来 设置链接库路径

于是,我们需要用CMake 解决这俩问题。

问题1:让编译器能找到头文件

现实情况:我把 zmq.hpp 放在deps/libzmq/include/zmq.hpp

但编译器默认只找系统路径(/usr/include),找不到

CMake 解决它的方法:

find_path(ZMQ_INCLUDE_DIR
  NAMES zmq.hpp zmq.h
  HINTS "${CMAKE_SOURCE_DIR}/deps/libzmq/include"
  NO_DEFAULT_PATH
)

这行命令的作用就像:

“去指定目录里看看有没有 zmq.hpp,找到了就把它的路径存在变量 ZMQ_INCLUDE_DIR 里”

然后告诉编译器:

target_include_directories(imu PRIVATE ${ZMQ_INCLUDE_DIR})

这句等价于给 g++ 增加 -I/path/to/deps/libzmq/include

编译器于是知道去哪找 <zmq.hpp>

问题2:让链接器能找到库

现在编译没错,但链接时报:

undefined reference to zmq_ctx_new

意思是:代码里声明了函数,但链接器找不到实现
实现其实就在 libzmq.so

CMake 让我们解决这个问题的方式是:

find_library(ZMQ_LIBRARY
  NAMES zmq libzmq
  HINTS "${CMAKE_SOURCE_DIR}/deps/libzmq/lib"
  NO_DEFAULT_PATH
)

这相当于:

“去指定目录里看看有没有 libzmq.so,把路径记下来。”

然后告诉链接器:

target_link_libraries(imu PRIVATE ${ZMQ_LIBRARY})

等价于给 g++ 加上 -Ldeps/libzmq/lib -lzmq

于是链接通过,程序能用 ZMQ 的 API

Question 2.5: use function find_package() instead of find_path() and find_library()

find_package() 是 CMake 的高级封装,用于查找一整个库的使用环境
它不仅能找到 include、lib,还能生成可直接使用的 target

在现代 CMake(>=3.x)生态中,推荐:

  • 直接使用 find_package(),而不是find_path和find_library;
  • 库作者提供自己的 <Package>Config.cmake;
  • 通过 target 引入依赖,不再关心路径变量,不用再手动 find_path / find_library

这通常是更推荐的,以及更加现代的写法,但是它有个前提:

  • 当前安装的目标库 third-party lib 包含 abcdConfig.cmake文件
  • 它们通常存放在三方库安装路径下的/lib/cmake or /share/cmake文件夹中
find_package(ZeroMQ REQUIRED CONFIG
  PATHS "${ZMQ_ROOT}"
  NO_DEFAULT_PATH
)
target_link_libraries(imu PRIVATE 
  libzmq
  Threads::Threads
)

需要注意的是:

  1. 目标名就是find_path / find_library / find_package 被调用后导入的对象,像上面的程序就是libzmq
  2. 从中不难看出包名不等于目标名,上面的程序中,包名是ZeroMQ,而目标名是libzmq,因此在链接时也需要使用目标名libzmq
  3. 因此,find_package并不是一个完全可控的api,依赖于库的作者,在库比较简单,或者find_package效果不对时,也可以考虑回退到手动 find_path / find_library

前两个问题是编译时会遇到的,现在我们运行时还有新的问题产生


问题3:运行时找不到库(libzmq.so)

我们运行时又遇到:

error while loading shared libraries: libzmq.so: cannot open shared object file

原因:运行时动态链接器不知道去哪找 .so

Linux 默认只看 /usr/lib,但我们把 .so 放在项目的 deps/libzmq/lib

CMake 的解决方案:
在生成可执行文件时,把库路径写进 ELF 的 RPATH 字段

set(CMAKE_BUILD_RPATH "${CMAKE_SOURCE_DIR}/deps/libzmq/lib")
set(CMAKE_INSTALL_RPATH "\$ORIGIN/../deps/libzmq/lib")

解释:

  • BUILD_RPATH:在 build/bin 里直接跑的时候,用绝对路径
  • INSTALL_RPATH:安装后(比如在 install/bin 下)运行,用相对路径 $ORIGIN,表示“当前可执行文件目录”

这一部分就是在明确告诉系统,在不同的状态下运行时,分别去哪里找动态库依赖

问题4:安装分发程序,让程序脱离原环境也能运行

当我们要把程序发给别人时,我们希望他只需:

cd install/bin
./imu

就能跑。

所以要让 CMake 在安装时把所有必要的文件拷过去:

install(TARGETS imu RUNTIME DESTINATION bin)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/deps/libzmq/lib/ DESTINATION lib FILES_MATCHING PATTERN "libzmq*.so*")

这两行干了两件事:

  1. 把可执行文件安装到 install/bin
  2. .so 库安装到 install/lib

结合上面的 RPATH 设置,程序一运行就能自己找到依赖库

问题5:多个组件协同(子目录)

当我们的项目有多个可执行文件或模块(比如 imucontroller),就需要组织结构

这时顶层只管资源路径与依赖,具体编译逻辑交给子目录:

add_subdirectory(src/imu)
add_subdirectory(src/controller)

子目录各自有:

add_executable(imu src/imu_pub.cpp)
target_link_libraries(imu PRIVATE ${ZMQ_LIBRARY})

完整流程总结

需要说明的是,cmakelists的编写并没有严格的顺序,函数与函数之间通常是并列的关系,但是存在一些惯例,出于美观上的考量,下文中会遵循这些惯例

项目顶级根目录(例如 ./CMakeLists.txt)

  1. cmake_minimum_required
  2. project + 语言/标准(set(CMAKE_CXX_STANDARD …)
  3. 依赖根路径 set()
  4. 依赖查找 find_path / find_library / find_package + message(STATUS …)
  5. 全局输出目录、安装前缀、RPATH 策略 set(...)
  6. 线程库 find_package(Threads REQUIRED)
  7. 共享变量下发到子目录 set(... CACHE INTERNAL ...)
  8. 顶层资源/依赖的 install(DIRECTORY ...)
  9. add_subdirectory(...) 引入子工程
    10.(可选)便捷构建目标 add_custom_target(copy_...)

子目录(例如 src/imu/CMakeLists.txt)

  1. add_executable(imu ...)
  2. target_include_directories(imu ...)
  3. target_link_libraries(imu ...)
  4. install(TARGETS imu RUNTIME DESTINATION bin)
    5.(可选)add_custom_command(TARGET imu POST_BUILD ...)

还是需要强调,这些惯例只作推荐,并非绝对

我不再逐条解释这些api的使用,这实在没意思,需要了直接搜就好了,我们只需要知道在每一步需要用什么api即可,形象的记忆比抽象的事情容易的多

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐