第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_target和add_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)
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
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 从外围向核心迁移
不要一上来就动核心的链接脚本和启动代码。先迁移那些独立的、依赖少的模块。比如:
- 先把工具函数库、日志模块这些独立组件迁移到CMake
- 再迁移中间件层,比如协议栈、文件系统
- 最后才动核心的驱动层和链接脚本
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
25.6 本章知识体系
下面这张图展示了CMake与Makefile混合使用的核心逻辑和过渡策略:
这张图把整个知识体系分成了三块:左边是CMake调用Makefile的四种方式,右边是Makefile调用CMake的四种方式,底部是过渡期的四条最佳实践。你可以把它当作一个速查表,在实际项目中对照使用。
公众号:蓝海资料掘金营,微信deep3321