第25章:CMake与Makefile混合使用:在CMake中调用Makefile,在Makefile中调用CMake,过渡策略与最佳实践

说实话,做了这么多年嵌入式项目,我见过太多团队在构建系统迁移上栽跟头。有的项目想从Makefile切到CMake,结果一上来就全盘推翻重写,最后搞得鸡飞狗跳。有的项目想保留老代码里的Makefile,又想让新模块用CMake,结果两边互不搭理,编译一次要敲三四个命令。

我个人习惯的做法是:不要搞革命,要搞改良。CMake和Makefile不是水火不容的关系,它们完全可以共存。这一章我就跟你聊聊,怎么让这两个构建系统在同一个项目里和平共处,甚至互相调用。

25.1 为什么需要混合使用?

你想想看,一个嵌入式项目往往不是一天建成的。很多老项目积累了十几年的Makefile脚本,里面包含了各种芯片厂商的烧录脚本、链接脚本、后处理命令。这些东西不是说换就能换的。

我遇到过最典型的情况是这样的:

  • 底层驱动库:用Makefile写的,依赖特定工具链的编译选项
  • 中间件层:部分用CMake,部分用Makefile
  • 应用层:新开发的模块,全部用CMake

这种混合状态可能会持续好几年。所以,学会让它们互相调用,是每个嵌入式工程师的必修课。

25.2 在CMake中调用Makefile

这是最常见的场景。你的CMake项目里,需要编译一个老旧的第三方库,它只提供了Makefile。怎么办?

CMake提供了add_custom_targetadd_custom_command,可以让你直接执行外部命令。说白了,就是让CMake当个调度员,把Makefile的编译任务外包出去。

25.2.1 基本用法:add_custom_target

# CMakeLists.txt
add_custom_target(
    build_legacy_lib
    COMMAND $(MAKE) -C ${CMAKE_CURRENT_SOURCE_DIR}/legacy
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/legacy
    COMMENT "Building legacy library with Makefile"
)

# 让主目标依赖这个自定义目标
add_dependencies(my_app build_legacy_lib)

这里有个坑,我曾经踩过:$(MAKE)这个变量,CMake会自动把它替换成当前使用的make工具路径。如果你写死了make,在交叉编译环境下可能会出问题。

25.2.2 传递编译参数

很多时候,Makefile需要接收外部参数,比如工具链路径、编译选项。你可以这样传:

add_custom_target(
    build_legacy_lib
    COMMAND ${CMAKE_MAKE_PROGRAM} 
        CROSS_COMPILE=${CMAKE_C_COMPILER}
        OPT_FLAGS=-O2
        -C ${CMAKE_CURRENT_SOURCE_DIR}/legacy
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/legacy
)

嗯,这里要注意:Makefile里必须支持这些变量,否则传了也没用。我建议你在Makefile里加上默认值,这样两边都能用。

25.2.3 处理输出产物

Makefile编译出来的.a或.so文件,CMake怎么知道在哪?你需要告诉CMake这些文件的位置:

# 假设Makefile把库输出到legacy/build目录
set(LEGACY_LIB_PATH "${CMAKE_CURRENT_SOURCE_DIR}/legacy/build/liblegacy.a")

# 导入这个库
add_library(legacy_lib STATIC IMPORTED)
set_target_properties(legacy_lib PROPERTIES
    IMPORTED_LOCATION ${LEGACY_LIB_PATH}
)

# 让导入库依赖自定义目标
add_dependencies(legacy_lib build_legacy_lib)

# 链接到主项目
target_link_libraries(my_app PRIVATE legacy_lib)
注意:Makefile的编译产物路径一定要稳定。我见过有的Makefile每次编译都清空输出目录,结果CMake这边找不到文件,链接失败。建议在Makefile里固定输出路径,或者用CMake的BYPRODUCTS关键字声明产物。

25.3 在Makefile中调用CMake

反过来,如果你的主构建系统还是Makefile,但想引入CMake管理的新模块,怎么办?

说白了,就是在Makefile里执行cmake命令,然后调用make来编译CMake生成的构建文件。

25.3.1 基本模式

# Makefile
CMAKE_BUILD_DIR := ./build_cmake

cmake_module:
    @echo "Building CMake module..."
    @mkdir -p $(CMAKE_BUILD_DIR)
    @cd $(CMAKE_BUILD_DIR) && cmake $(CURDIR)/cmake_module
    @$(MAKE) -C $(CMAKE_BUILD_DIR)

clean_cmake:
    @rm -rf $(CMAKE_BUILD_DIR)

.PHONY: cmake_module clean_cmake

这个模式我用了好几年,简单可靠。但有个问题:每次make都会重新运行cmake配置,很慢。优化一下:

CMAKE_BUILD_DIR := ./build_cmake
CMAKE_CONFIG_FILE := $(CMAKE_BUILD_DIR)/CMakeCache.txt

$(CMAKE_CONFIG_FILE):
    @mkdir -p $(CMAKE_BUILD_DIR)
    @cd $(CMAKE_BUILD_DIR) && cmake $(CURDIR)/cmake_module

cmake_module: $(CMAKE_CONFIG_FILE)
    @$(MAKE) -C $(CMAKE_BUILD_DIR)

clean_cmake:
    @rm -rf $(CMAKE_BUILD_DIR)

这样只有第一次或者CMakeLists.txt变化时,才会重新运行cmake配置。

25.3.2 传递变量给CMake

Makefile里定义的变量,可以通过-D选项传给CMake:

# Makefile
TOOLCHAIN_PREFIX := arm-none-eabi-
CMAKE_DEFS := -DTOOLCHAIN_PREFIX=$(TOOLCHAIN_PREFIX)
CMAKE_DEFS += -DCMAKE_BUILD_TYPE=Release

$(CMAKE_CONFIG_FILE):
    @mkdir -p $(CMAKE_BUILD_DIR)
    @cd $(CMAKE_BUILD_DIR) && cmake $(CMAKE_DEFS) $(CURDIR)/cmake_module
我的经验:在Makefile里调用CMake时,最好把CMake的构建目录放在项目根目录下,不要放在CMake模块内部。否则清理的时候容易误删源文件。我就干过这种事,把整个cmake_module目录删了,还好有git。

25.4 双向依赖的处理

最复杂的情况是:CMake模块依赖Makefile模块的输出,而Makefile模块又依赖CMake模块的输出。这就形成了循环依赖。

我遇到过这种场景:一个老旧的音频编解码库(Makefile)需要调用新写的DSP算法库(CMake),而DSP算法库又需要音频编解码库提供的接口头文件。

解决方案是:分阶段构建

# 顶层构建脚本 build.sh
#!/bin/bash

# 第一阶段:编译Makefile模块,只生成头文件
make -C legacy_lib headers_only

# 第二阶段:编译CMake模块,依赖第一阶段生成的头文件
mkdir -p build_cmake
cd build_cmake
cmake .. -DLEGACY_HEADERS_PATH=$(pwd)/../legacy_lib/include
make

# 第三阶段:编译Makefile模块的库文件,链接CMake的输出
make -C legacy_lib all \
    DSP_LIB_PATH=$(pwd)/build_cmake/libdsp.a

# 第四阶段:最终链接
make -C linker all

说白了,就是打破循环依赖,把构建过程拆成多个阶段。每个阶段只依赖前一个阶段的输出。

25.5 过渡策略与最佳实践

如果你正在从Makefile向CMake迁移,我建议你采用渐进式策略:

25.5.1 从外围向核心迁移

不要一上来就动核心的链接脚本和启动代码。先迁移那些独立的、依赖少的模块。比如:

  1. 先把工具函数库、日志模块这些独立组件迁移到CMake
  2. 再迁移中间件层,比如协议栈、文件系统
  3. 最后才动核心的驱动层和链接脚本

25.5.2 保持两个构建系统并行

在过渡期间,我建议你同时维护Makefile和CMake。每次修改代码,两个系统都要能编译通过。这样万一CMake出了问题,还能切回Makefile应急。

# 项目目录结构
project/
├── Makefile          # 旧的构建系统
├── CMakeLists.txt    # 新的构建系统
├── build/            # Makefile输出
├── build_cmake/      # CMake输出
├── src/
│   ├── legacy/       # 还没迁移的模块
│   └── new/          # 已迁移的模块
└── scripts/
    └── build_all.sh  # 同时调用两个系统

25.5.3 统一变量命名

Makefile和CMake里用到的变量名,尽量保持一致。比如工具链前缀、编译选项、输出路径。这样在互相调用时,参数传递更清晰。

用途 Makefile变量 CMake变量
工具链前缀 TOOLCHAIN_PREFIX TOOLCHAIN_PREFIX
编译优化级别 OPT_LEVEL CMAKE_C_FLAGS
输出目录 OUT_DIR CMAKE_BINARY_DIR
库文件路径 LIB_PATH LIBRARY_OUTPUT_PATH

25.5.4 使用包装脚本

我建议你写一个顶层的构建脚本,统一调用入口。这样开发人员不用关心底层用的是Makefile还是CMake:

#!/bin/bash
# build.sh - 统一构建入口

BUILD_SYSTEM=${1:-cmake}  # 默认用CMake

if [ "$BUILD_SYSTEM" = "cmake" ]; then
    mkdir -p build && cd build
    cmake ..
    make -j$(nproc)
elif [ "$BUILD_SYSTEM" = "make" ]; then
    make -j$(nproc)
else
    echo "Usage: $0 [cmake|make]"
    exit 1
fi
核心原则:混合使用只是过渡手段,不是最终目标。你的长期目标应该是完全迁移到CMake。但在过渡期间,让两个系统和平共处,比强行一刀切要明智得多。

25.6 本章知识体系

下面这张图展示了CMake与Makefile混合使用的核心逻辑和过渡策略:

CMake与Makefile混合使用知识体系 CMake 调用 Makefile • add_custom_target 执行外部命令 • 传递编译参数给Makefile • 导入Makefile的编译产物 • 使用BYPRODUCTS声明产物 Makefile 调用 CMake • 在Makefile中执行cmake命令 • 通过-D传递变量给CMake • 缓存CMake配置避免重复执行 • 统一构建目录管理 过渡策略与最佳实践 ① 从外围向核心迁移:先迁移独立模块,最后动核心驱动和链接脚本 ② 保持两个构建系统并行:修改代码后,两个系统都要能编译通过 ③ 统一变量命名:Makefile和CMake使用相同的变量名 ④ 使用包装脚本:统一构建入口,开发人员无需关心底层构建系统 混合使用是过渡手段,最终目标是完全迁移到CMake

这张图把整个知识体系分成了三块:左边是CMake调用Makefile的四种方式,右边是Makefile调用CMake的四种方式,底部是过渡期的四条最佳实践。你可以把它当作一个速查表,在实际项目中对照使用。

最后说一句:混合使用CMake和Makefile,就像是在装修老房子。你不能把承重墙拆了,但可以在不破坏结构的前提下,逐步替换老旧的管线。保持耐心,分步实施,最终你会得到一个干净、统一的构建系统。

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