从最小项目开始的 CMake 教程

目录

  • [1. 从最小 CMake 项目开始](#1. 从最小 CMake 项目开始)
    • [1.1 最小项目](#1.1 最小项目)
    • [1.2 多源文件项目](#1.2 多源文件项目)
    • [1.3 把代码拆成库](#1.3 把代码拆成库)
  • [2. 构建目标 target](#2. 构建目标 target)
    • [2.1 PRIVATE、PUBLIC 和 INTERFACE](#2.1 PRIVATE、PUBLIC 和 INTERFACE)
    • [2.2 编译选项和宏定义](#2.2 编译选项和宏定义)
  • [3. 工程化配置](#3. 工程化配置)
  • [4. 标准多目录项目](#4. 标准多目录项目)
    • [4.1 顶层 CMakeLists.txt](#4.1 顶层 CMakeLists.txt)
    • [4.2 src/CMakeLists.txt](#4.2 src/CMakeLists.txt)
    • [4.3 app/CMakeLists.txt](#4.3 app/CMakeLists.txt)
    • [4.4 tests/CMakeLists.txt](#4.4 tests/CMakeLists.txt)
    • [4.5 构建并运行整个项目](#4.5 构建并运行整个项目)
  • [5. CMake 交叉编译](#5. CMake 交叉编译)

你可能需要的 Make/Makefille/CMake 知识

在 C 语言中,gcc 是最底层的编译器,它真正负责把源代码编译、链接成可执行文件;make 是底层的执行工具,它并不理解代码,只是按照规则调用 gcc;Makefile 则是这些规则的具体形式,明确写出了哪些文件依赖哪些文件、在什么情况下调用 gcc 执行哪些命令;而 CMake 站在更高一层,用来描述工程的整体结构,并根据这些结构描述自动生成 Makefile 等构建文件。

开发者用CMake 描述工程结构,CMake根据环境生成构建规则,具体的构建工具负责执行,而编译器只专注于把代码变成程序。每一层只做一件事,大家各司其职。

在 Linux 下,常见流程是:

bash 复制代码
cmake -S . -B build
cmake --build build

其中:

text 复制代码
cmake -S . -B build

是配置阶段,意思是:

-S .:源码目录是当前目录;

-B build把生成的构建文件放到 build 文件夹里。

text 复制代码
cmake --build build

用于构建,意思是:进入 build 对应的构建目录,调用实际构建工具生成可执行文件或库文件。


1. 从最小 CMake 项目开始

1.1 最小项目

创建如下结构:

复制代码
hello_cmake/
├── CMakeLists.txt
└── main.cpp

main.cpp

cpp 复制代码
#include <iostream>

int main() {
    std::cout << "Hello CMake!" << std::endl;
    return 0;
}

CMakeLists.txt

cmake 复制代码
cmake_minimum_required(VERSION 3.16) 

project(HelloCMake LANGUAGES CXX)

add_executable(hello main.cpp)

其中:

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

表示这个项目要求 CMake 版本至少为 3.16。

cmake 复制代码
project(HelloCMake LANGUAGES CXX)

定义项目名称为 HelloCMake,使用的语言是 C++。

如果是 C 项目,可以写:

cmake 复制代码
project(MyCProject LANGUAGES C)

如果同时使用 C 和 C++:

cmake 复制代码
project(MyProject LANGUAGES C CXX)
cmake 复制代码
add_executable(hello main.cpp)

表示生成一个可执行文件,名字叫 hello,由 main.cpp 编译而来。

在 Linux 中最终生成:hello

在 Windows 中可能生成:hello.exe

hello_cmake 目录下执行:

bash 复制代码
cmake -S . -B build
cmake --build build

运行程序:

bash 复制代码
./build/hello

1.2 多源文件项目

真实项目不会只有一个 main.cpp

复制代码
multi_source/
├── CMakeLists.txt
├── main.cpp
├── print.cpp
└── print.h

print.h

cpp 复制代码
#pragma once

void print_message();

print.cpp

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

void print_message() {
    std::cout << "Message from print.cpp" << std::endl;
}

main.cpp

cpp 复制代码
#include "print.h"

int main() {
    print_message();
    return 0;
}

CMakeLists.txt

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

project(MultiSource LANGUAGES CXX)

add_executable(app
    main.cpp
    print.cpp
)

app 这个可执行文件由 main.cpp 和 print.cpp 共同编译链接得到。

头文件 print.h 不一定写进 add_executable,因为它不会单独编译。当然也可以写:

cmake 复制代码
add_executable(app
    main.cpp
    print.cpp
    print.h
)

这不会改变最终链接逻辑,只是让头文件也出现在工程管理中。


头文件通常会放到 include 目录。

text 复制代码
include_project/
├── CMakeLists.txt
├── include/
│   └── print.h
└── src/
    ├── main.cpp
    └── print.cpp

此时CMakeLists.txt

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

project(IncludeProject LANGUAGES CXX)

add_executable(app
    src/main.cpp
    src/print.cpp
)

target_include_directories(app PRIVATE
    ${PROJECT_SOURCE_DIR}/include
)

表示:编译 app 这个目标时,额外去 include 目录里寻找头文件。

其中:${PROJECT_SOURCE_DIR}表示当前项目源码根目录,也就是包含顶层 CMakeLists.txt 的目录。


1.3 把代码拆成库

真实项目中,通常不会把所有 .cpp 都塞进一个 add_executable 里。更常见的是一部分代码形成库,main.cpp 只负责调用库

复制代码
library_project/
├── CMakeLists.txt
├── include/
│   └── math_utils.h
└── src/
    ├── main.cpp
    └── math_utils.cpp

include/math_utils.h

cpp 复制代码
#pragma once

int add(int a, int b);

src/math_utils.cpp

cpp 复制代码
#include "math_utils.h"

int add(int a, int b) {
    return a + b;
}

src/main.cpp

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

int main() {
    std::cout << add(3, 4) << std::endl;
    return 0;
}

此时的 CMakeLists.txt 为:

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

project(LibraryProject LANGUAGES CXX)

add_library(math_utils
    src/math_utils.cpp
)

target_include_directories(math_utils PUBLIC
    ${PROJECT_SOURCE_DIR}/include
)

add_executable(app
    src/main.cpp
)

target_link_libraries(app PRIVATE
    math_utils
)

这里有两个 target:

cmake 复制代码
add_library(math_utils src/math_utils.cpp)

创建一个库 target,名字是 math_utils

cmake 复制代码
add_executable(app src/main.cpp)

创建一个可执行文件 target,名字是 app

之后:

cmake 复制代码
target_link_libraries(app PRIVATE math_utils)

表示 app 依赖 math_utils,所以链接 app 时要把 math_utils 链接进去。

由于 math_utils 的 include 目录是 PUBLIC

cmake 复制代码
target_include_directories(math_utils PUBLIC
    ${PROJECT_SOURCE_DIR}/include
)

所以当 app 链接 math_utils 时,app 也自动获得了 math_utils 的头文件搜索路径。

静态库和动态库

静态库 就是在链接阶段被合并进最终可执行程序的库。如:

cmake 复制代码
add_library(math_utils STATIC
    src/math_utils.cpp
)

程序 app 运行时,不需要再去找 math_utils 这个库文件,因为库里的代码已经被合进 app 了。

动态库 不是在链接阶段完全合进程序,而是在程序运行时被加载。写法是

cmake 复制代码
add_library(math_utils SHARED
    src/math_utils.cpp
)

最终程序 app 运行时,系统需要能找到这个动态库文件。如果找不到,就可能出现"缺少 dll""cannot open shared object file"之类的错误。

如果不写 STATICSHARED,CMake 会根据变量 BUILD_SHARED_LIBS 决定生成静态库还是动态库。

2. 构建目标 target

在 CMake 中,target 可以理解为一个构建目标。所有东西都围绕 target。app 需要什么头文件、需要什么 C++ 标准、需要什么宏、需要链接什么库,都写成:

cmake 复制代码
target_xxx(app ...)

下面这个 calculator 就是一个 target:

cmake 复制代码
add_executable(calculator
    src/main.cpp
    src/add.cpp
)

可以给这个 target 添加头文件目录:

cmake 复制代码
target_include_directories(calculator
    PRIVATE
        ${PROJECT_SOURCE_DIR}/include
)

还可以给它添加 C++ 标准:

cmake 复制代码
target_compile_features(calculator
    PRIVATE
        cxx_std_17
)

也可以添加编译警告:

cmake 复制代码
target_compile_options(calculator
    PRIVATE
        -Wall
        -Wextra
)

链接库:

cmake 复制代码
target_link_libraries(calculator
    PRIVATE
        some_library
)

这是 cmake 的一般写法,先创建 target,再给 target 添加属性

2.1 PRIVATE、PUBLIC 和 INTERFACE

在 CMake 中:

PRIVATE 表示只有我自己用,别人不需要。

PUBLIC 表示自己要用,链接我的人也要用。

INTERFACE 表示自己不用,链接我的人要用。

自己需要 使用者需要 写法
PRIVATE
PUBLIC
INTERFACE

PUBLIC

对于库:

text 复制代码
math_utils

它的头文件在:

text 复制代码
include/math_utils.h

它的源文件:

cpp 复制代码
#include "math_utils.h"

别的程序也会:

cpp 复制代码
#include "math_utils.h"

那么 include 目录应该写成:

cmake 复制代码
target_include_directories(math_utils PUBLIC include)

因为math_utils 自己编译时需要 include;使用 math_utils 的 app 也需要 include。所以是 PUBLIC

PRIVATE

如果某个 include 目录只给库内部 .cpp 使用,外部用户完全不需要知道:

复制代码
library_project/
├── include/
│   └── public_api.h
├── src/
│   ├── internal_impl.h
│   └── internal_impl.cpp

则可以写:

cmake 复制代码
target_include_directories(my_lib
    PUBLIC
        ${PROJECT_SOURCE_DIR}/include
    PRIVATE
        ${PROJECT_SOURCE_DIR}/src
)

意思是:include 是公共头文件目录,别人也要用;src 是内部实现目录,只有库自己用。

INTERFACE

有些库只有头文件,没有 .cpp,比如模板库、纯头文件工具库。

项目结构:

复制代码
header_only_project/
├── CMakeLists.txt
├── include/
│   └── logger.h
└── src/
    └── main.cpp

logger.h

cpp 复制代码
#pragma once
#include <iostream>

inline void log_message(const char* msg) {
    std::cout << msg << std::endl;
}

CMakeLists.txt:

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

project(HeaderOnlyProject LANGUAGES CXX)

add_library(logger INTERFACE)

target_include_directories(logger INTERFACE
    ${PROJECT_SOURCE_DIR}/include
)

add_executable(app src/main.cpp)

target_link_libraries(app PRIVATE logger)

logger 没有自己的 .cpp 要编译,它自己不需要 include 目录参与编译。但是使用它的 app 需要这个 include 目录。


2.2 编译选项和宏定义

编译选项就是传给编译器的参数。

例如:

cmake 复制代码
target_compile_options(app PRIVATE
    -Wall
    -Wextra
    -Wpedantic
)

意思是:

cmake 复制代码
-Wall      开启常见警告
-Wextra    开启更多额外警告
-Wpedantic 更严格地按照标准检查

CMake 最终生成的编译命令中,会自动带上这些参数

编译选项不是 CMake 自己定义的,而是具体编译器定义的。这些选项是 GCC/Clang 风格,在 MSVC 下不一定适用。

更严谨的写法是:

cmake 复制代码
if(MSVC)
    target_compile_options(app PRIVATE /W4)
else()
    target_compile_options(app PRIVATE -Wall -Wextra -Wpedantic)
endif()

如果当前使用的是 MSVC 编译器,就使用 /W4;否则使用 GCC/Clang 风格的 -Wall -Wextra -Wpedantic。

宏定义是给 C/C++ 预处理器看的开关

C++ 代码:

cpp 复制代码
#include <iostream>

int main() {
#ifdef USE_FAST_MODE
    std::cout << "Fast mode enabled" << std::endl;
#else
    std::cout << "Normal mode" << std::endl;
#endif

    return 0;
}

如果 CMake 中定义了:

cmake 复制代码
target_compile_definitions(app PRIVATE USE_FAST_MODE)

程序会编译成:Fast mode enabled;如果没有定义这个宏,则会编译成:Normal mode

如果要定义带值的宏

cpp 复制代码
#include <iostream>

int main() {
    std::cout << "Version: " << VERSION << std::endl;
    return 0;
}

CMake 中可以写:

cmake 复制代码
target_compile_definitions(app PRIVATE VERSION="1.0.0")

3. 工程化配置

这一章解决真实项目中常见的配置问题:

3.1 Debug 和 Release

同一份 C++ 代码,可以有很多种编译方式。最常见的就是:Debug、Release

我们写了一个程序,程序运行后崩溃了。这时候最关心的是:程序崩在哪里?变量是多少?调用栈是什么?为了方便调试,编译器需要:保留变量信息、行号,让调试器容易追踪代码。构建方式就是Debug

但是Debug 程序通常更大、更慢,因为它保留了大量调试信息。真正发布程序时,用户不关心源码行号,编译器会开启优化、删除无用代码、减少调试信息,构建方式就是Release

CMake 里,构建类型通通过 CMAKE_BUILD_TYPE 控制。例如:

cmake 复制代码
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug

意思是生成 Debug 配置的构建系统。然后

cmake 复制代码
cmake --build build

真正执行编译。

单配置生成器

CMake 本身不负责编译;它只是生成构建系统。在 Linux 中,通常是单配置生成器。一个 build 目录,只对应一种配置。

配置 Debug:

bash 复制代码
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build

配置 Release:

bash 复制代码
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build

常见构建类型:

  • Debug 带调试信息,优化较少
  • Release 优化较多,适合发布
  • RelWithDebInfo 优化 + 调试信息
  • MinSizeRel 尽量减小体积

多配置生成器

Visual Studio 是多配置生成器。配置时不一定指定 CMAKE_BUILD_TYPE,而是在 build 时指定:

bash 复制代码
cmake -S . -B build
cmake --build build --config Release

3.2 变量、option 和缓存

项目变复杂以后,我们经常会遇到一些问题,源文件很多,每次都写一长串不方便;有些配置希望第一次设置后,下一次还能记住;Debug / Release、是否构建测试、是否启用示例程序,都需要配置,CMake 提供了几类常见配置方式:

  • 普通变量:set(...)
  • 用户开关:option(...)
  • 缓存变量:CMakeCache.txt

普通变量

cmake 复制代码
set(SRC_FILES
    src/main.cpp
    src/print.cpp
)

add_executable(app ${SRC_FILES})

这里定义了一个变量:变量名:SRC_FILES,变量值:src/main.cpp 和 src/print.cpp

后面可以这样使用:

cmake 复制代码
add_executable(app
    ${SRC_FILES}
)

其中:${SRC_FILES}表示取出变量 SRC_FILES 的值。对于小项目,直接写源文件列表往往更清晰:

option

option 用来给用户提供开关。

cmake 复制代码
option(ENABLE_TESTS "Build tests" ON)

定义一个名为 ENABLE_TESTS 的选项;它的说明文字是 Build tests;默认值是 ON。

使用这个开关:

cmake 复制代码
if(ENABLE_TESTS)
    add_subdirectory(tests)
endif()

如果 ENABLE_TESTS 是 ON,就进入 tests 目录,构建测试代码。如果 ENABLE_TESTS 是 OFF,就跳过 tests 目录。

完整例子:

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

project(option_demo LANGUAGES CXX)

add_executable(app
    src/main.cpp
)

option(ENABLE_TESTS "Build tests" ON)

if(ENABLE_TESTS)
    add_subdirectory(tests)
endif()

这样用户就可以决定要不要构建测试。不想构建测试,可以写:

cmake 复制代码
cmake -S . -B build -DENABLE_TESTS=OFF

这里的:-DENABLE_TESTS=OFF意思是:在配置项目时,把 ENABLE_TESTS 设置为 OFF。

CMakeCache.txt

当执行:

cmake 复制代码
cmake -S . -B build -DENABLE_TESTS=OFF

CMake 会在 build 目录中生成一个文件:build/CMakeCache.txt,这个文件就是 CMake 的缓存文件。它会记录很多配置结果,例如:

ENABLE_TESTS:BOOL=OFF

CMAKE_BUILD_TYPE:STRING=Debug

CMAKE_CXX_COMPILER:FILEPATH=/usr/bin/c++

也就是说,CMakeCache.txt 会记住:

  1. 用户通过 -D 传入的配置
  2. CMake 自动检测到的编译器路径
  3. 一些项目配置选项
  4. 某些第三方库查找结果

避免每次重新配置时都从零开始;让上一次配置结果可以继续使用。

下次再运行:

bash 复制代码
cmake -S . -B build

它可能还记得之前的值。如果配置很乱,最简单的重置方式是删除 build:

bash 复制代码
rm -rf build

然后重新配置。


3.3 接入第三方库:find_package

真实项目经常需要使用第三方库,例如 OpenCV、Eigen、fmt、Boost、Qt 等。基本形式为

cmake 复制代码
find_package(SomeLib REQUIRED)

在系统中找到 SomeLib,并加载它的 CMake 配置。REQUIRED表示这个库必须存在;找不到就直接报错停止

我们自己的库 add_library(math_utils ...) 会产生一个 target:math_utils,第三方库也一样。例如:

cmake 复制代码
find_package(fmt REQUIRED)

之后,fmt 会提供 fmt::fmt 这个 target。可以直接:

cmake 复制代码
target_link_libraries(app PRIVATE
    fmt::fmt
)

库名::目标名 是现代 CMake 常见的风格,它的意义类似 命名空间::对象。表面上只是"链接 fmt",实际上 CMake 自动知道 fmt 的 include 路径、库文件路径、宏定义、依赖库、Debug / Release 区别、平台差异等信息

早期很多库还没有现代 target 风格,比如OpenCV,它通过变量暴露信息

cmake 复制代码
find_package(OpenCV REQUIRED)

target_link_libraries(app PRIVATE
    ${OpenCV_LIBS}
)

3.4 安装规则 install

当项目不仅仅是自己运行,还要被别人使用时,或要发布 SDK 给其他项目使用,CMake 提供了安装规则 install(...),用于描述最终应该把哪些文件安装到哪里

假设我们写了一个库:my_project_core,别人也想使用,如果没有 install,别人必须手动去 build 目录里找头文件在哪、库在哪。而 install 的目标就是:把最终需要的文件整理成标准结构

例如:

复制代码
install/
├── bin/
├── lib/
└── include/

程序放 bin、库放 lib、头文件放 include,别人就知道怎么使用了。

安装可执行文件

假设项目:

复制代码
my_project/
├── CMakeLists.txt
└── src/
    └── main.cpp

CMakeLists.txt

cmake 复制代码
add_executable(my_app
    src/main.cpp
)

想要安装这个程序,可以写:

cmake 复制代码
install(TARGETS my_app
    RUNTIME DESTINATION bin
)

这里 TARGETS my_app 表示安装 my_app 这个 target。RUNTIME DESTINATION bin 表示把可执行文件安装到 bin 目录。

真正执行 install 需要先 build:

cmake 复制代码
cmake -S . -B build
cmake --build build

然后执行:

cmake 复制代码
cmake --install build --prefix install

这里 --prefix install 意思是:把安装目录设置为 ./install。最终得到:

复制代码
install/
└── bin/
    └── my_app

安装库

假设:

cmake 复制代码
add_library(my_project_core STATIC
    src/core.cpp
)

可以写:

cmake 复制代码
install(TARGETS my_project_core
    ARCHIVE DESTINATION lib
    LIBRARY DESTINATION lib
    RUNTIME DESTINATION bin
)

含义:

ARCHIVE:静态库,例如 .a、.lib,安装到 lib 目录

LIBRARY:动态库主体,例如 .so、.dylib,放到 lib 目录

RUNTIME:可执行文件或 Windows 下的 .dll,放 bin

安装头文件

cmake 复制代码
install(DIRECTORY ${PROJECT_SOURCE_DIR}/include/
    DESTINATION include
)

这里DIRECTORY表示安装整个目录。DESTINATION include表示复制到 install/include

例如:

复制代码
include/
└── my_project/
    └── core.h

最终安装后:

复制代码
install/
└── include/
    └── my_project/
        └── core.h

3.5 CMake Presets

项目越来越大,这些命令越来越长,越来越难记,团队里每个人的配置也可能不一样。如何配置项目本身也变成了一个需要管理的问题。CMake Presets是 CMake 提供的一种标准化配置方案。

它的意思很简单,把配置方案保存下来。

保存在 CMakePresets.json,它把一长串 cmake 命令参数保存成一个有名字的配置。

例如:

复制代码
debug
release
clang
gcc
windows
linux
arm

以后不再需要记:

cmake 复制代码
-DCMAKE_BUILD_TYPE=Release
-DENABLE_TESTS=OFF
...

而是:

cmake 复制代码
cmake --preset release

这会简单很多。假设项目根目录:

复制代码
my_project/
├── CMakeLists.txt
└── CMakePresets.json

创建:CMakePresets.json

json 复制代码
{
  "version": 3,
  "configurePresets": [
    {
      "name": "debug",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/debug",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug"
      }
    }
  ]
}

这里configurePresets 就是这一阶段的配置,generator表示CMake 最终生成哪种构建系统文件

Generator 生成什么
Ninja build.ninja
Unix Makefiles Makefile
Visual Studio VS 工程
Xcode Xcode 工程

binaryDir 表示build 目录放在哪里。这里${sourceDir}是项目源码根目录,最终build/debug会成为这个 preset 对应的构建目录,等价于:

cmake 复制代码
-B build/debug

cacheVariables 就是平时命令行里的 -D 参数

复制代码
"cacheVariables": {
    "CMAKE_BUILD_TYPE": "Debug",
    "ENABLE_TESTS": "ON"
}

等价于:

-DCMAKE_BUILD_TYPE=Debug

-DENABLE_TESTS=ON

因为这些变量最终都会进入CMakeCache.txt,所以叫cacheVariables

使用 Preset:

cmake 复制代码
cmake --preset debug

CMake 也允许定义 build 阶段的 preset。例如:

cmake 复制代码
"buildPresets": [
  {
    "name": "debug",
    "configurePreset": "debug"
  }
]

表示这个 build preset对应 configure preset: debug

然后可以:

cmake 复制代码
cmake --build --preset debug

而不再需要:

cmake 复制代码
cmake --build build/debug

一个完整例子

cmake 复制代码
{
  "version": 3,

  "configurePresets": [
    {
      "name": "debug",
      "displayName": "Debug Build",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/debug",

      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "ENABLE_TESTS": "ON"
      }
    },

    {
      "name": "release",
      "displayName": "Release Build",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/release",

      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release",
        "ENABLE_TESTS": "OFF"
      }
    }
  ],

  "buildPresets": [
    {
      "name": "debug",
      "configurePreset": "debug"
    },

    {
      "name": "release",
      "configurePreset": "release"
    }
  ]
}

这里定义了两套完整配置方案。debug preset等价于:

cmake 复制代码
cmake -S . -B build/debug \
    -G Ninja \
    -DCMAKE_BUILD_TYPE=Debug \
    -DENABLE_TESTS=ON

release preset等价于:

cmake 复制代码
cmake -S . -B build/release \
    -G Ninja \
    -DCMAKE_BUILD_TYPE=Release \
    -DENABLE_TESTS=OFF

以后只需要:

cmake 复制代码
cmake --preset debug
cmake --build --preset debug

或者:

cmake 复制代码
cmake --preset release
cmake --build --preset release

4. 标准多目录项目

当项目变大后,所有内容放在一个 CMakeLists.txt 里会很乱。这时可以使用多目录分层管理,每个子目录可以有自己的 target。

复制代码
my_project/
├── CMakeLists.txt
├── include/
│   └── my_project/
│       ├── math_utils.h
│       └── signal_filter.h
├── src/
│   ├── CMakeLists.txt
│   ├── math_utils.cpp
│   └── signal_filter.cpp
├── app/
│   ├── CMakeLists.txt
│   └── main.cpp
├── tests/
│   ├── CMakeLists.txt
│   └── test_math_utils.cpp
└── README.md

每个目录的职责比较清楚

include/ 放公共头文件,供库本身和外部程序使用。

src/ 放核心库源码,生成 library target

app/ 放主程序入口,例如 main.cpp

tests/ 放测试代码,生成测试用的 executable target。

CMakeLists.txt 顶层 CMake 文件,只负责组织子目录和全局选项。

公共头文件放在:include/my_project/xxx.h,这样在代码中包含头文件时可以写成:

c 复制代码
#include <my_project/math_utils.h>

而不是:

c 复制代码
#include "math_utils.h"

减少头文件重名的风险。比如别的库里也可能有一个 math_utils.h,但是不太可能也叫 my_project/math_utils.h

4.1 顶层 CMakeLists.txt

顶层 CMakeLists.txt 负责声明项目、定义选项、加入子目录。

cmake 复制代码
cmake_minimum_required(VERSION 3.16)

project(MyProject
    VERSION 1.0.0
    DESCRIPTION "A modern CMake example project"
    LANGUAGES CXX
)

option(ENABLE_TESTS "Build tests" ON)

add_subdirectory(src)
add_subdirectory(app)

if(ENABLE_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()
cmake 复制代码
add_subdirectory(src)

表示进入 src 目录,读取 src/CMakeLists.txt

cmake 复制代码
add_subdirectory(app)

表示进入 app 目录,读取 app/CMakeLists.txt

测试部分用条件控制:

cmake 复制代码
if(ENABLE_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()

意思是:如果 ENABLE_TESTSON,就启用 CMake 的测试功能,并进入 tests 目录构建测试程序。

4.2 src/CMakeLists.txt

src/ 目录一般用来放项目的核心源码。这里我们把 math_utils.cppsignal_filter.cpp 编译成一个库,名字叫 my_project_core

cmake 复制代码
add_library(my_project_core
    math_utils.cpp
    signal_filter.cpp
)

target_include_directories(my_project_core
    PUBLIC
        ${PROJECT_SOURCE_DIR}/include
)

target_compile_features(my_project_core PUBLIC cxx_std_17)

if(MSVC)
    target_compile_options(my_project_core PRIVATE /W4)
else()
    target_compile_options(my_project_core PRIVATE -Wall -Wextra -Wpedantic)
endif()

4.3 app/CMakeLists.txt

cmake 复制代码
add_executable(my_app
    main.cpp
)

target_link_libraries(my_app PRIVATE
    my_project_core
)

my_app 需要链接 my_project_core

4.4 tests/CMakeLists.txt

测试代码单独放在 tests/ 目录中。

cmake 复制代码
add_executable(test_math_utils
    test_math_utils.cpp
)

target_link_libraries(test_math_utils PRIVATE
    my_project_core
)

add_test(NAME test_math_utils COMMAND test_math_utils)

第三句:

cmake 复制代码
add_test(NAME test_math_utils COMMAND test_math_utils)

表示把 test_math_utils 注册成一个 CTest 测试项。测试名字:test_math_utils;执行命令:test_math_utils

以后运行:

cmake 复制代码
ctest --test-dir build

CTest 就会执行这个测试程序。如果程序返回 0,CTest 认为测试通过。如果程序返回非 0,CTest 认为测试失败。

4.5 构建并运行整个项目

配置工程:

cmake 复制代码
cmake -S . -B build -DENABLE_TESTS=ON # 配置工程
cmake --build build # 编译工程
ctest --test-dir build # 运行测试

如果想查看详细测试输出,可以使用:

cmake 复制代码
ctest --test-dir build --verbose

如果想查看详细编译命令:

cmake 复制代码
cmake --build build --verbose

如果不想构建测试,可以关闭 ENABLE_TESTS

cmake 复制代码
cmake -S . -B build -DENABLE_TESTS=OFF
cmake --build build

这时顶层的这段代码:

cmake 复制代码
if(ENABLE_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()

就不会执行,tests/ 目录也不会被加入构建系统。

5. CMake 交叉编译

交叉编译是指:编译发生在一个平台上,但最终程序要运行在另一个平台上 。例如做嵌入式 Linux、树莓派、ARM 平台,此时CMake 无法自动判断目标平台,不能简单地从当前系统目录中查找目标平台的头文件和库,而且交叉编译生成的可执行文件通常不能直接在构建主机上运行。因此,需要通过 toolchain 文件告诉 CMake:目标系统是什么、使用哪个编译器、去哪里找目标平台的头文件和库。

toolchain 文件是一个 CMake 脚本,通常命名为:

arm-linux-toolchain.cmake

toolchain-arm.cmake

aarch64-linux-gnu.cmake

它提前告诉 CMake要编译给哪个系统、目标 CPU 架构是什么?、C 编译器、C++ 编译器在哪里、目标平台的 sysroot 在哪里、find_library、find_path、find_package 应该去哪里找等

建立一个最小工程:

复制代码
cross-demo/
├── CMakeLists.txt
├── main.cpp
└── toolchains/
    └── arm-linux-gnueabihf.cmake

main.cpp 内容如下:

CPP 复制代码
#include <iostream>

int main()
{
    std::cout << "Hello cross compile!" << std::endl;
    return 0;
}

CMakeLists.txt 内容如下:

CMAKE 复制代码
cmake_minimum_required(VERSION 3.16)

project(CrossDemo LANGUAGES C CXX)

add_executable(cross_demo main.cpp)

target_compile_features(cross_demo PRIVATE cxx_std_11)

这里没有手动指定 g++,也没有写死编译器路径。CMakeLists.txt 描述的是"这个项目怎么构建",而 toolchain 文件描述的是"用哪套工具链为哪个目标平台构建"。两者职责不同。

在 Linux 环境里,执行:

bash 复制代码
sudo apt update
sudo apt install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf

安装 Ubuntu 自带的 ARM 交叉编译器,安装完成后检查:

bash 复制代码
which arm-linux-gnueabihf-gcc
which arm-linux-gnueabihf-g++

输出文件路径:

/usr/bin/arm-linux-gnueabihf-gcc

/usr/bin/arm-linux-gnueabihf-g++

那么可以写一个 toolchains/arm-linux-gnueabihf.cmake

cmake 复制代码
# 1. 指定目标系统
set(CMAKE_SYSTEM_NAME Linux)

# 2. 指定目标处理器架构
set(CMAKE_SYSTEM_PROCESSOR arm)

# 3. 指定交叉编译器
set(CMAKE_C_COMPILER   /usr/bin/arm-linux-gnueabihf-gcc)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabihf-g++)
# 不要用当前电脑默认的 /usr/bin/gcc 和 /usr/bin/g++,而是用 ARM 交叉编译器。

# 4. 指定目标系统根目录
set(CMAKE_SYSROOT /opt/arm-sysroot)

# 5. 控制 CMake 查找程序、库和头文件的位置
set(CMAKE_FIND_ROOT_PATH /opt/arm-sysroot)

# 构建过程中需要运行的程序,通常应该在宿主机上找
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)

# 头文件、库、CMake 包,通常在目标系统环境中找
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

这一行:

cmake 复制代码
set(CMAKE_SYSROOT /opt/arm-sysroot)

sysroot 可以理解成"目标系统根目录的复制品"。里面一般会有目标板上的头文件和库

最后这几行:

cmake 复制代码
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

含义是:

find_program找构建过程中要运行的工具,例如 Python、protoc、代码生成器。这些程序要在当前电脑上运行,所以应该从宿主机环境里找。

find_library找目标程序链接用的库。这些库必须属于 ARM 目标平台,所以应该从 sysroot 里找。

find_path / find_file找头文件。头文件也应该来自目标平台 sysroot。

find_package找 CMake 包配置。交叉编译应该优先找目标平台的包。

进入工程目录后,建立单独的构建目录:

cmake 复制代码
cd cross-demo

cmake -S . -B build-arm \
  -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-linux-gnueabihf.cmake \
  -DCMAKE_BUILD_TYPE=Release

然后编译:

cmake 复制代码
cmake --build build-arm

file 命令检查:

bash 复制代码
file build-arm/cross_demo

看到信息:

复制代码
ELF 32-bit LSB executable, ARM, EABI5, dynamically linked ...

更改 toolchain

修改 toolchain 文件之后,重新执行 cmake --build 不一定会重新识别所有工具链设置。因为 CMake 第一次 configure 时会把很多结果写入 CMakeCache.txt。如果换了工具链、换了编译器、换了 sysroot,稳妥做法是删除构建目录:

bash 复制代码
rm -rf build-arm

然后重新配置:

cmake 复制代码
cmake -S . -B build-arm \
  -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-linux-gnueabihf.cmake \
  -DCMAKE_BUILD_TYPE=Release

不要在同一个 build 目录里反复切换不同 toolchain 文件。这样很容易出现缓存污染。

安装路径

cmake 复制代码
install(TARGETS cross_demo
    RUNTIME DESTINATION bin
)

执行安装

cmake 复制代码
cmake --install build

会安装 cross_demo 这个可执行文件,并放到安装前缀下面的 bin 目录中。

如果安装前缀是:/usr/local,那么目标运行环境中的安装位置就是/usr/local/bin/cross_demo

交叉编译时,问题会变复杂一点。因为这里至少有两个系统:宿主机与目标机,所以 /usr/local 这个路径必须说清楚到底是谁的 /usr/local

在交叉编译语境下,CMAKE_INSTALL_PREFIX 表示的是:程序最终在目标设备上的运行安装前缀 。也就是说,即使是在当前电脑上执行 cmake --installCMAKE_INSTALL_PREFIX=/usr/local 仍然应该理解为目标设备上的 /usr/local

例如:

cmake 复制代码
-DCMAKE_INSTALL_PREFIX=/usr/local

表示程序将来放到开发板上时,应该位于:/usr/local/bin/cross_demo

但是,现在执行 cmake --install 的机器是宿主机。如果直接把 ARM 程序安装到宿主机的 /usr/local/bin,通常是不合适的。因为这个程序是给 ARM 开发板用的。这时就可以使用:

cmake 复制代码
CMAKE_STAGING_PREFIX

它的作用是指定一个宿主机上的临时安装目录。

如果我们希望:宿主机上的临时安装目录是当前工程目录下的 stage-arm/,目标设备上的最终安装前缀:/usr/local,那么可以在配置时这样写:

cmake 复制代码
cmake -S . -B build-arm \
  -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-linux-gnueabihf.cmake \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX=/usr/local \
  -DCMAKE_STAGING_PREFIX="$PWD/stage-arm"

$PWD/stage-arm 的意思是:在当前工程目录下创建一个 stage-arm 目录,用它作为临时安装目录。然后编译:

cmake 复制代码
cmake --build build-arm

再执行安装:

cmake 复制代码
cmake --install build-arm

安装完成后,在当前工程目录下看到:

复制代码
stage-arm/
└── bin/
    └── cross_demo

文件实际先被安装到了宿主机的:当前工程目录/stage-arm/bin/cross_demo,但是这个程序在目标设备上的设计安装位置仍然是:/usr/local/bin/cross_demo。这两个路径不是一回事。

相关推荐
lifewange1 小时前
pytest 找不到文件?直接在 pytest.ini 配置根目录 + 路径(最简单方案)
开发语言·python·pytest
大鹏说大话1 小时前
MySQL + Redis + Caffeine:Java后端通用三级缓存架构实战
开发语言
yuanpan1 小时前
Python 桌面 GUI 入门开发:从 tkinter 窗口到简易记事本
开发语言·python
User_芊芊君子1 小时前
聊聊自由开发者常用的学习机会全解析
开发语言·人工智能·python
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第40题:Java中的深拷贝和浅拷贝有什么区别
java·开发语言·后端·面试
xh didida2 小时前
算法 -- 位运算
开发语言·c++·算法
hele_two2 小时前
VS Code + CMake 调用 SDL2 & SDL2_image 完整编译教程(Windows 平台)
c++·windows·vscode·图形渲染
谙弆悕博士2 小时前
快速学C语言——第2章:编程规范与代码风格
服务器·c语言·开发语言·经验分享·程序人生·学习方法·业界资讯
byzh_rc3 小时前
[AI编程从入门到入土] 装饰器decorator
开发语言·python·ai编程