用 Android.mk 的写法组织 CMake:让跨平台 C++ 构建更像填表

#CMake #C++ #跨平台构建 #Android.mk

一、为什么要再封一层 CMake

CMake 很强,但刚开始接触时也很容易写乱。

比如我们想做一件很普通的事:写一个 hello object library,再写一个 basic_hello_world 可执行程序去使用它。原生 CMake 可能会这样写:

add_library(hello OBJECT hello/src/hello.cpp)
target_include_directories(hello PUBLIC hello/include)

add_executable(basic_hello_world example/basic_hello/src/main.cpp)
target_link_libraries(basic_hello_world PRIVATE hello)

这段代码不难,但项目一大,问题就来了:

  • 每个 target 都要重复写 include、依赖、输出属性。
  • 有人用 PUBLIC,有人用 PRIVATE,规则容易不统一。
  • interface library、object library、静态库、动态库、可执行程序、预编译库的写法分散在各处。
  • 新人读 CMakeLists 时,很难一眼看出“这个模块叫什么、有哪些源码、依赖谁”。

所以我们可以给 CMake 加一层很薄的约定。底层仍然是标准 CMake,外层换成更规整的声明方式。

这次的思路借鉴 Android.mk 的命名风格:用 LOCAL_MODULELOCAL_SRC_FILESLOCAL_SRC_DIRS 这些变量描述当前模块,再通过 BUILD_INTERFACE_LIBRARYBUILD_OBJECT_LIBRARYBUILD_EXECUTABLE 这样的入口真正创建 target。

注意,这不是 Android 专用方案。它只是借用了 Android.mk 的“填表式”命名方式,本质仍然是跨平台 CMake。你可以把它用在 Linux、RISC-V、桌面端、嵌入式系统等不同平台里。

二、Android.mk 风格到底像什么

如果把一个 C++ target 想象成一张表,最重要的信息其实就几项:

  • 这个模块叫什么。
  • 它有哪些源码。
  • 它要暴露哪些头文件目录。
  • 它依赖哪些库。
  • 它最终要生成 interface library、object library、静态库、动态库,还是可执行程序。

Android.mk 风格就是把这些信息拆成一组 LOCAL_* 变量。

例如先声明一个只负责导出头文件目录的 interface library:

include(${CLEAR_VARS})
set(LOCAL_MODULE hello_headers)
set(LOCAL_EXPORT_C_INCLUDES
    ${CMAKE_CURRENT_LIST_DIR}/include)
include(${BUILD_INTERFACE_LIBRARY})

再声明真正编译源码的 object library:

include(${CLEAR_VARS})
set(LOCAL_MODULE hello)
set(LOCAL_SRC_DIRS
    ${CMAKE_CURRENT_LIST_DIR}/src)
set(LOCAL_INTERFACE_LIBRARIES
    hello_headers)
include(${BUILD_OBJECT_LIBRARY})

这段可以按自然语言读出来:

第一段可以读成:“当前模块叫 hello_headers。它不编译源码,只把 include 目录作为接口导出去。”

第二段可以读成:“当前模块叫 hello。源码从 src 目录自动收集。它依赖 hello_headers 这个接口模块。最后把它构建成 object library。”

再看一个可执行程序:

include(${CLEAR_VARS})
set(LOCAL_MODULE basic_hello_world)
set(LOCAL_SRC_FILES
    src/main.cpp)
set(LOCAL_OBJECT_LIBRARIES
    hello)
include(${BUILD_EXECUTABLE})

它的意思也很直接:

“当前模块叫 basic_hello_world。源码是 src/main.cpp。它使用 object library hello。最后把它构建成可执行程序。”

这就是这种写法的核心价值:不要求读者先理解很多 CMake 细节,也能先看懂模块结构。

三、每个变量负责什么

下面用更小白一点的方式解释这些变量。

CLEAR_VARS

每写一个模块前都先:

include(${CLEAR_VARS})

它的作用是清空上一轮的 LOCAL_* 变量。

如果不清空,前一个模块的源码、依赖、头文件目录可能会“串”到下一个模块里。Android.mk 里也有类似习惯:每个模块开始前先清场。

LOCAL_MODULE

set(LOCAL_MODULE hello)

它就是 CMake target 的名字。后面别的模块要依赖它,也用这个名字。

例如:

set(LOCAL_STATIC_LIBRARIES
    hello)

LOCAL_SRC_FILES

set(LOCAL_SRC_FILES
    src/hello.cpp
    src/message.cpp)

这里放源码文件。静态库、动态库、可执行程序都需要它。

LOCAL_SRC_DIRS

set(LOCAL_SRC_DIRS
    ${CMAKE_CURRENT_LIST_DIR}/src/hello
    ${CMAKE_CURRENT_LIST_DIR}/src/common)

当一个模块里 .cpp 文件很多时,不想在 LOCAL_SRC_FILES 里一行一行写文件名,就可以用 LOCAL_SRC_DIRS

它的内部实现使用 CMake 的 aux_source_directory。简单理解就是:“把这个目录当前层里的源码文件收集起来,追加到 LOCAL_SRC_FILES。”

需要注意两点:

  • 它只收集指定目录当前层,不递归进入子目录。
  • 如果某些文件是特殊平台才编译,仍然建议手动放到 LOCAL_SRC_FILES,或者用 if() 判断后再追加。

LOCAL_SRC_FILESLOCAL_SRC_DIRS 可以一起用:

set(LOCAL_SRC_DIRS
    ${CMAKE_CURRENT_LIST_DIR}/src/core)
set(LOCAL_SRC_FILES
    ${CMAKE_CURRENT_LIST_DIR}/src/platform/linux_entry.cpp)

LOCAL_C_INCLUDES

set(LOCAL_C_INCLUDES
    ${CMAKE_CURRENT_LIST_DIR}/private_include)

这是当前模块自己使用的头文件目录。它不会自动传给依赖它的模块。

可以把它理解成“私有 include”。

LOCAL_EXPORT_C_INCLUDES

set(LOCAL_EXPORT_C_INCLUDES
    ${CMAKE_CURRENT_LIST_DIR}/include)

这是当前模块对外暴露的头文件目录。

比如 hello 库提供了 include/hello/hello.hpp,那么 basic_hello_world 链接 hello 后,也应该能包含这个头文件。这种目录就放到 LOCAL_EXPORT_C_INCLUDES

可以把它理解成“公开 include”。

LOCAL_STATIC_LIBRARIESLOCAL_SHARED_LIBRARIESLOCAL_INTERFACE_LIBRARIESLOCAL_OBJECT_LIBRARIES

set(LOCAL_STATIC_LIBRARIES
    hello)

这里写依赖的库 target。

LOCAL_STATIC_LIBRARIES 用来表达静态库依赖,LOCAL_SHARED_LIBRARIES 用来表达动态库依赖。底层实现会把它们转成 CMake 的 target_link_libraries

如果依赖的是 interface library,就放到 LOCAL_INTERFACE_LIBRARIES

set(LOCAL_INTERFACE_LIBRARIES
    hello_headers)

interface library 通常不产生 .a.so 或可执行文件,它更像一组“使用规则”:头文件目录、编译定义、或者继续依赖的其他 target。

如果模块是 object library,就放到 LOCAL_OBJECT_LIBRARIES

set(LOCAL_OBJECT_LIBRARIES
    hello)

这样既能使用 object library 的对象文件,也能继承它对外暴露的 include 目录。

BUILD_*

最后一步用 BUILD_* 决定生成什么:

include(${BUILD_STATIC_LIBRARY})
include(${BUILD_SHARED_LIBRARY})
include(${BUILD_INTERFACE_LIBRARY})
include(${BUILD_OBJECT_LIBRARY})
include(${BUILD_EXECUTABLE})
include(${BUILD_PREBUILT})

这一步才是真正创建 CMake target 的地方。

前面的 LOCAL_* 是填表,最后的 BUILD_* 是提交表单。

ALL_SUBDIR_CMAKELISTS

前面这些 BUILD_* 负责创建一个具体 target。还有一种常见需求是:一个目录下面有很多子模块,每个子模块都有自己的 CMakeLists.txt,父目录不想手动一行行写:

add_subdirectory(module_a)
add_subdirectory(module_b)
add_subdirectory(module_c)

这时可以用:

include(${ALL_SUBDIR_CMAKELISTS})

它会扫描当前 CMakeLists.txt 所在目录的直接子目录,只要子目录里有 CMakeLists.txt,就自动 add_subdirectory 进去。

这个接口适合扁平的模块目录,例如:

example/
├── CMakeLists.txt
├── basic_hello/
│   └── CMakeLists.txt
└── auto_lists/
    └── CMakeLists.txt

example/CMakeLists.txt 就可以只写:

include(${ALL_SUBDIR_CMAKELISTS})

这样新增一个同级 example 时,只要给它建好自己的 CMakeLists.txt,父目录不用再改。

ALL_CMAKELISTS_UNDER

ALL_SUBDIR_CMAKELISTS 只看直接子目录,不递归。它适合“当前目录下面每个子目录都是一个模块”的场景。

如果模块树更深,例如 feature 下面还分 core、ui、platform 等多层目录,就可以用:

set(LOCAL_PATH ${CMAKE_CURRENT_LIST_DIR}/modules)
include(${ALL_CMAKELISTS_UNDER})
unset(LOCAL_PATH)

它会从 LOCAL_PATH 指定的根目录开始,递归查找下面所有 CMakeLists.txt,再逐个加入构建。

例如:

example/auto_lists/recursive/
├── CMakeLists.txt
└── modules/
    ├── core/
    │   └── CMakeLists.txt
    └── features/
        └── phrase/
            └── CMakeLists.txt

recursive/CMakeLists.txt 可以先把 modules/ 下所有 target 加进来,然后再声明自己的可执行程序:

set(LOCAL_PATH ${CMAKE_CURRENT_LIST_DIR}/modules)
include(${ALL_CMAKELISTS_UNDER})
unset(LOCAL_PATH)

include(${CLEAR_VARS})
set(LOCAL_MODULE auto_lists_demo)
set(LOCAL_SRC_FILES
    src/main.cpp)
set(LOCAL_STATIC_LIBRARIES
    auto_lists_direct
    auto_lists_phrase)
include(${BUILD_EXECUTABLE})

简单区分一下:

  • ALL_SUBDIR_CMAKELISTS:只扫一层,适合 example、modules、plugins 这类扁平集合目录。
  • ALL_CMAKELISTS_UNDER:递归扫描,适合更深的 feature tree 或分层模块树。

这两个接口不是用来把所有 .cpp 收集成一个大 target 的。它们内部仍然是 add_subdirectory,所以每个子目录保留自己的 CMake 作用域和 target 声明。也就是说,它们解决的是“自动加入子模块”的问题,而不是“自动收集源码”的问题。

四、一个完整 hello world

目录结构如下:

hello/
├── CMakeLists.txt
├── include/
│   └── hello/
│       └── hello.hpp
└── src/
    └── hello.cpp

example/
├── CMakeLists.txt
└── basic_hello/
    ├── CMakeLists.txt
    └── src/
        └── main.cpp

hello.hpp

#pragma once

#include <string>

namespace hello {

std::string message(const std::string& name);

}

hello.cpp

#include "hello/hello.hpp"

namespace hello {

std::string message(const std::string& name)
{
    return "Hello, " + name + "!";
}

}

main.cpp

#include "hello/hello.hpp"

#include <iostream>
#include <string>

namespace {

std::string default_name()
{
    return "world";
}

}

int main()
{
    std::cout << hello::message(default_name()) << '\n';
    return 0;
}

hello/CMakeLists.txt

include(${CLEAR_VARS})
set(LOCAL_MODULE hello_headers)
set(LOCAL_EXPORT_C_INCLUDES
    ${CMAKE_CURRENT_LIST_DIR}/include)
include(${BUILD_INTERFACE_LIBRARY})

include(${CLEAR_VARS})
set(LOCAL_MODULE hello)
set(LOCAL_SRC_DIRS
    ${CMAKE_CURRENT_LIST_DIR}/src)
set(LOCAL_INTERFACE_LIBRARIES
    hello_headers)
include(${BUILD_OBJECT_LIBRARY})

example/CMakeLists.txt 用来自动加入 example 子目录:

include(${ALL_SUBDIR_CMAKELISTS})

example/basic_hello/CMakeLists.txt

include(${CLEAR_VARS})
set(LOCAL_MODULE basic_hello_world)
set(LOCAL_SRC_FILES
    src/main.cpp)
set(LOCAL_OBJECT_LIBRARIES
    hello)
include(${BUILD_EXECUTABLE})

根目录 CMakeLists.txt 只需要先引入 helper,再加入模块和 example:

cmake_minimum_required(VERSION 3.16)

project(CMakeHelper
    VERSION 0.1.0
    LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

include(${CMAKE_CURRENT_LIST_DIR}/cmake/CMakeHelper.cmake)

option(BUILD_EXAMPLE "Build examples" ON)

add_subdirectory(hello)

if(BUILD_EXAMPLE)
    add_subdirectory(example)
endif()

构建并运行:

cmake -S . -B build
cmake --build build
./build/example/basic_hello/basic_hello_world
./build/example/auto_lists/recursive/auto_lists_demo

输出:

Hello, world!
direct child + recursive child via ALL_CMAKELISTS_UNDER

这里的 C++11 是在顶层 CMakeLists.txt 配置的。cmake/ helper 模块本身不强制 C++ 标准,这样同一套模块也可以服务 C++11、C++14、C++17 或更高标准的项目。

五、为什么它不绑定单一平台

这套写法虽然借用了 Android.mk 的变量名,但没有调用 Android NDK,也没有依赖 Android 平台。

它真正做的事只有这些标准 CMake 操作:

  • add_library
  • add_executable
  • target_include_directories
  • target_link_libraries
  • set_target_properties

所以它可以运行在 Linux、macOS、Windows,也可以放进 Android、iOS、RISC-V、嵌入式 Linux 等项目里。区别只在于你给 CMake 选择什么 toolchain。

换句话说:

Android.mk 风格只是“前台界面”,CMake target 才是“后台实现”。

六、什么时候适合这样做

这种封装适合下面几类项目:

  • 项目里有很多 C++ 静态库、动态库和可执行程序。
  • 团队成员对 CMake 熟悉程度不一致。
  • 希望每个模块的写法保持统一。
  • 希望从 Android.mk 迁移到 CMake 时降低理解成本。
  • 希望保留跨平台能力,而不是把构建逻辑绑死在单个平台上。

但它不适合过度包装。

如果项目只有一个 main.cpp,直接写原生 CMake 就很好。封装的价值来自重复场景:当你发现每个模块都在重复设置 C++ 标准、include、依赖、输出名时,再抽出这层会更划算。

七、一个简单判断标准

可以用一句话判断这层 helper 有没有价值:

如果一个新人打开 CMakeLists.txt,不懂太多 CMake,也能看出每个模块“叫什么、编哪些文件、依赖谁、生成什么”,那这层封装就是有意义的。

Android.mk 风格的优点不在于高级,而在于稳定、直观、重复成本低。

它让 CMakeLists 更像模块清单,也让跨平台 C++ 项目的构建结构更容易维护。

本文对应的示例代码仓库:CMakeHelper

评论