1. 项目初始化:CMakeLists.txt 从零编写,构建系统选型与目录结构设计

说实话,我见过太多 C++ 项目,一上来就开干。代码写得飞起,结果三个月后连编译都成问题。嗯,这其实是可以避免的。项目初始化就像盖楼打地基,地基歪了,后面再漂亮的代码也白搭。

今天我们就聊聊这个「地基」怎么打。我会从三个角度切入:构建系统选型、目录结构设计、以及 CMakeLists.txt 的从零编写。这三件事做好了,后面几十号人协作都不会乱。

构建系统选型:Make 还是 Ninja?

先问个问题:你平时用什么构建?我猜大部分人会回答 Make。但说实话,在大型项目里,我越来越倾向于 Ninja。

为什么会这样?我们来对比一下。

特性 Make Ninja
构建速度 中等,依赖解析较慢 极快,专为增量编译优化
并行支持 支持(-j 参数) 默认并行,自动利用多核
语法复杂度 较复杂,规则多 极简,但需生成器配合
跨平台 Linux 原生,Windows 需额外工具 全平台支持
与 CMake 配合 生成 Makefile 生成 build.ninja

我个人习惯是:开发阶段用 Ninja,发布阶段用 Make。Ninja 快,适合频繁编译调试;Make 生态成熟,适合 CI/CD 流水线。

小技巧: 在 CMake 中切换构建系统很简单:
cmake -G "Ninja" ..cmake -G "Unix Makefiles" ..

我在项目中遇到过一个问题:团队用 Make,每次改一行代码要等 40 秒重新链接。换成 Ninja 后,降到 8 秒。你想想看,一天下来省多少时间?

项目目录结构设计

目录结构这事,看似简单,其实坑很多。我见过把全部代码塞进 src 的,也见过目录深到 7 层的。都不好。

我推荐一个经过多个项目验证的结构:

my_project/
├── CMakeLists.txt          # 顶层构建文件
├── src/                    # 源代码
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── module_a/
│   │   ├── CMakeLists.txt
│   │   ├── a.cpp
│   │   └── a.h
│   └── module_b/
│       ├── CMakeLists.txt
│       ├── b.cpp
│       └── b.h
├── include/                # 公共头文件
│   └── my_project/
│       ├── api.h
│       └── config.h
├── lib/                    # 第三方库(编译后)
│   ├── libfoo.a
│   └── libbar.so
├── test/                   # 单元测试
│   ├── CMakeLists.txt
│   ├── test_a.cpp
│   └── test_b.cpp
└── third_party/            # 第三方源码(如 git submodule)
    ├── googletest/
    └── spdlog/

这个结构有几个好处:

  • src 和 include 分离:内部实现不暴露给外部
  • 模块化:每个模块有自己的 CMakeLists.txt,方便独立编译
  • test 独立:测试代码不污染主工程
  • third_party 统一管理:避免到处拷贝第三方库
注意: 不要把 .h 和 .cpp 混在一起。我曾经接手过一个项目,头文件和源文件在同一个目录,结果找接口定义找了半小时。分开存放,清晰得多。

另外,include 目录下再加一层项目名(如 include/my_project/),这是为了防止头文件冲突。你想想看,如果两个库都有 config.h,那编译时就会出问题。

CMakeLists.txt 从零编写

好,结构定好了,接下来写 CMakeLists.txt。我习惯从顶层开始,一层层往下写。

顶层 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

# C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 构建类型
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type" FORCE)
endif()

# 输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)

# 添加子目录
add_subdirectory(src)
add_subdirectory(test)

这里有几个关键点:

  • cmake_minimum_required 指定最低版本,我一般用 3.16,因为支持很多新特性
  • CMAKE_CXX_STANDARD 17 是现代 C++ 的起点,别再用 C++11 了
  • 输出目录统一到 bin/lib/,方便后续打包

src/CMakeLists.txt

# 添加模块
add_subdirectory(module_a)
add_subdirectory(module_b)

# 主程序
add_executable(my_app main.cpp)
target_link_libraries(my_app
  PRIVATE
    module_a
    module_b
)

src/module_a/CMakeLists.txt

add_library(module_a
  STATIC
    a.cpp
    a.h
)

target_include_directories(module_a
  PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}
)

target_include_directories(module_a
  PRIVATE
    ${CMAKE_SOURCE_DIR}/include
)

这里我用了 PUBLICPRIVATE 来控制头文件可见性。简单说:

  • PUBLIC:模块自己的头文件,其他模块也能用
  • PRIVATE:只有模块内部能用,外部看不到
核心原则: 最小暴露原则。只暴露必要的接口,内部实现藏起来。这样改内部代码时,不会影响其他模块。

test/CMakeLists.txt

# 添加 Google Test
add_subdirectory(${CMAKE_SOURCE_DIR}/third_party/googletest
                 ${CMAKE_BINARY_DIR}/googletest)

# 测试程序
add_executable(test_a test_a.cpp)
target_link_libraries(test_a
  PRIVATE
    module_a
    gtest_main
)

add_test(NAME test_a COMMAND test_a)

测试这块,我习惯用 Google Test。它成熟、文档全,而且 CMake 集成很方便。

知识体系总览

下面这张图,把整个初始化的核心逻辑串起来了。你可以把它当作一个检查清单:

项目初始化核心逻辑 构建系统选型 Make vs Ninja 开发用 Ninja 发布用 Make 目录结构设计 src / include 分离 模块化子目录 test / third_party CMakeLists.txt 编写 顶层配置 模块级 add_library 测试集成 避坑指南 1. 头文件不要和源文件混放 2. 用 PUBLIC/PRIVATE 控制可见性

一些经验之谈

最后,分享几个我踩过的坑:

  • 不要手动管理第三方库:用 git submodule 或 FetchContent。我曾经手动拷贝过 spdlog,结果版本混乱,编译报错查了半天。
  • CMake 版本要统一:团队里有人用 3.10,有人用 3.20,写出来的 CMakeLists.txt 互相不兼容。统一版本,省心。
  • 测试要早加:别等代码写完了再补测试。项目初期就搭好测试框架,后面每加一个模块就写几个测试,养成习惯。
我的习惯: 每次新建项目,先花 30 分钟搭好 CMake 结构和目录。这 30 分钟,后面能省下 3 天。

好了,项目初始化这部分就聊到这。记住:好的开始是成功的一半。把 CMakeLists.txt 写清楚、目录结构设计好,后面的事情就顺了。


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