21、CMake实战:单文件项目、多文件项目、库项目(静态库与动态库)

说实话,CMake 这东西,我刚开始接触的时候也觉得挺烦的。明明 Makefile 写得挺顺手,为什么非要再学一套?但后来项目越做越大,跨平台需求一上来,我才发现——嗯,Makefile 那套手写方式,真的扛不住。

今天这一讲,咱们就踏踏实实地把 CMake 的三种典型项目结构走一遍。单文件、多文件、还有库项目。你跟着我敲一遍,基本就能应付日常 80% 的场景了。

21.1 单文件项目:Hello World 的 CMake 写法

先来个最简单的。你有一个 main.c,想编译成可执行文件。用 CMake 怎么写?

项目结构长这样:

hello/
├── CMakeLists.txt
└── main.c

main.c 的内容我就不贴了,就是个打印 "Hello CMake" 的简单程序。关键看 CMakeLists.txt

cmake_minimum_required(VERSION 3.10)
project(hello VERSION 1.0 LANGUAGES C)

add_executable(hello main.c)

三行搞定。我个人的习惯是,cmake_minimum_required 尽量写一个你实际测试过的版本,别写太老也别写太新。3.10 是个比较稳妥的选择。

编译步骤:

mkdir build && cd build
cmake ..
make

你想想看,比起手写 Makefile,是不是清爽多了?而且 CMake 会自动检测你的编译器、链接器,跨平台的时候特别省心。

小提示: 我建议你养成在 build 目录下编译的习惯。不要把编译产物混在源码里,否则哪天 git clean 一下,哭都来不及。

21.2 多文件项目:模块化组织代码

实际项目里,没人会把所有代码塞进一个文件。咱们来看一个稍微复杂点的结构:

math_tool/
├── CMakeLists.txt
├── main.c
├── add.c
├── add.h
├── sub.c
└── sub.h

CMakeLists.txt 怎么写?

cmake_minimum_required(VERSION 3.10)
project(math_tool VERSION 1.0 LANGUAGES C)

# 收集所有源文件
set(SOURCES
    main.c
    add.c
    sub.c
)

add_executable(math_tool ${SOURCES})

这里我用 set(SOURCES ...) 把源文件列出来。你可能觉得手动列文件有点傻,但说实话,小项目这样写最清晰。我在项目中见过有人用 file(GLOB ...) 自动收集,结果新加文件忘了重新 cmake,折腾了半天才发现——嗯,手动列其实更靠谱。

头文件不需要列在 add_executable 里,CMake 会自动扫描依赖。但如果你想让头文件出现在 IDE 的项目视图中,可以这样:

add_executable(math_tool ${SOURCES} add.h sub.h)

这样在 Visual Studio 或 CLion 里,头文件也会显示出来,方便编辑。

21.3 库项目:静态库与动态库

这才是重头戏。很多初学者搞不清静态库和动态库的区别,我简单说两句:

  • 静态库(.a / .lib):编译时直接嵌入到可执行文件里。优点是部署简单,缺点是文件大,更新库要重新编译。
  • 动态库(.so / .dll):运行时加载。优点是节省空间、方便更新,缺点是部署时要带上库文件,搞不好还会出现「找不到动态库」的经典报错。

我曾经在一个嵌入式项目里吃过动态库的亏。交叉编译出来的 .so 版本不对,板子上跑起来直接段错误。查了两天才发现是库的依赖链没处理好。从那以后,嵌入式项目我优先用静态库,除非有明确的动态加载需求。

21.3.1 构建静态库

项目结构:

mylib/
├── CMakeLists.txt
├── src/
│   ├── mylib.c
│   └── mylib.h
└── main.c

CMakeLists.txt

cmake_minimum_required(VERSION 3.10)
project(mylib VERSION 1.0 LANGUAGES C)

# 构建静态库
add_library(mylib STATIC src/mylib.c)

# 构建可执行文件,并链接静态库
add_executable(app main.c)
target_link_libraries(app PRIVATE mylib)

注意 add_library 的第二个参数 STATIC,明确告诉 CMake 我们要生成静态库。编译后你会得到 libmylib.a(Linux)或 mylib.lib(Windows)。

21.3.2 构建动态库

STATIC 换成 SHARED 就行:

add_library(mylib SHARED src/mylib.c)

就这么简单。但动态库有个坑——导出符号。在 Windows 上,你需要用 __declspec(dllexport) 标记要导出的函数。Linux 下默认全部导出,但如果你用了 -fvisibility=hidden,也得手动标记。

CMake 里可以这样控制:

set(CMAKE_C_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN 1)

然后在代码里用宏控制导出:

#if defined(_WIN32) || defined(_WIN64)
  #define MYLIB_EXPORT __declspec(dllexport)
#else
  #define MYLIB_EXPORT __attribute__((visibility("default")))
#endif

MYLIB_EXPORT void mylib_hello(void);

嗯,这部分确实有点繁琐,但跨平台库都得这么搞。我建议你把这个宏定义放到一个单独的 export.h 头文件里,每个需要导出的函数都包一下。一劳永逸。

21.4 知识体系总览

下面这张图,是我梳理的本章核心逻辑。你看一眼,心里就有数了:

CMake 项目结构实战 单文件项目 CMakeLists.txt + main.c add_executable 直接编译 适合入门、测试小功能 多文件项目 CMakeLists.txt + 多个 .c/.h set(SOURCES ...) 收集文件 模块化组织,日常项目标配 库项目 add_library(STATIC/SHARED) target_link_libraries 链接 代码复用,大型项目基石 静态库 vs 动态库 对比 特性 静态库 (.a/.lib) 动态库 (.so/.dll) 链接时机 编译时嵌入 运行时加载 部署 单个可执行文件 需附带 .so/.dll 更新 需重新编译 替换库文件即可

21.5 几个实用技巧

最后分享几个我踩过的坑,你遇到了能少走弯路:

1. 库的链接顺序

在 Linux 下,链接库的顺序很重要。比如你的 app 依赖 libA,libA 依赖 libB,那么链接顺序应该是:

target_link_libraries(app PRIVATE A B)

如果写成 B A,可能会报未定义引用。CMake 的 target_link_libraries 会自动处理依赖顺序,但如果你手写 Makefile 或者用其他构建系统,这个坑很容易踩。

2. 动态库的 RPATH

编译出来的动态库,运行时怎么找到?Linux 下可以用 RPATH 或 RUNPATH。CMake 里设置:

set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib")

$ORIGIN 表示可执行文件所在目录。这样你把可执行文件和 lib 目录一起分发,就能直接运行,不用设置 LD_LIBRARY_PATH

3. 调试与 Release 切换

CMake 支持多配置。调试时加调试符号,发布时优化:

cmake -DCMAKE_BUILD_TYPE=Debug ..
cmake -DCMAKE_BUILD_TYPE=Release ..

我个人的习惯是,开发阶段一直用 Debug,准备发布前切到 Release 跑一遍完整测试。有些 bug 只在 Release 模式下出现,比如未初始化的变量——优化器一开,内存布局变了,问题就暴露了。

21.6 总结

这一章的内容,说白了就是 CMake 的三种基本项目骨架。单文件练手,多文件组织模块,库项目实现复用。你把这三种结构吃透了,以后遇到再大的项目,无非就是在这个基础上加目录、加子模块、加第三方依赖。

嗯,CMake 的学习曲线确实有点陡,但一旦上手,你会发现它比手写 Makefile 高效太多了。下一讲我们会深入 CMake 的变量、函数和自定义命令,到时候你就能写出更灵活的构建脚本了。


公众号:蓝海资料掘金营,微信 deep3321