第二十五章:持续集成(CI):GitHub Actions与CMake、Jenkins构建、自动化测试
说实话,持续集成这事儿,我早年做嵌入式的时候根本没当回事。
那时候觉得,不就是编译个固件嘛,本地敲两下命令就完事了。直到有一次,我改了一个头文件,自以为没问题,结果同事拉下来编译直接报错——嗯,那天下午我们俩对着屏幕找了半小时,才发现是我忘了提交某个宏定义。
从那以后,我养成了一个习惯:任何嵌入式项目,必须上CI。今天咱们就聊聊,怎么把CMake和CI工具结合起来,让机器替你盯着代码质量。
25.1 为什么嵌入式项目需要CI?
你想想看,嵌入式开发有几个痛点:
- 交叉编译环境复杂——每个人的工具链版本可能不一样
- 硬件依赖强——不是所有人手里都有开发板
- 静态检查容易被忽略——比如内存对齐、堆栈溢出这些
CI能帮你做什么?说白了就是:每次提交代码,自动拉环境、编译、跑测试、出报告。有问题当场暴露,不用等到集成时炸锅。
核心原则:CI不是用来替代本地测试的,而是用来兜底的。本地没测出来的问题,CI帮你抓住。
25.2 GitHub Actions + CMake:轻量级CI方案
我个人习惯用GitHub Actions做嵌入式CI,原因很简单:免费、配置简单、和GitHub仓库无缝集成。
下面是一个典型的嵌入式CMake项目的CI配置。假设我们用的是ARM Cortex-M系列,工具链是arm-none-eabi-gcc。
# .github/workflows/build.yml
name: Embedded CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install ARM Toolchain
run: |
sudo apt-get update
sudo apt-get install -y gcc-arm-none-eabi
- name: Configure with CMake
run: cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-gcc-toolchain.cmake
- name: Build Firmware
run: cmake --build build --target all
- name: Run Unit Tests (Host)
run: cmake --build build --target test_host
- name: Archive Firmware Binary
uses: actions/upload-artifact@v3
with:
name: firmware.hex
path: build/firmware.hex
这里有个细节:我分了两步编译。第一步是交叉编译固件,第二步是编译主机上的单元测试。为什么?因为嵌入式单元测试可以在本地x86上跑,速度快,还能用Valgrind检查内存泄漏。
小技巧:在CMakeLists.txt里用 add_compile_options() 加上 -Wall -Wextra -Werror,CI一旦有警告就直接报错。我曾经因为一个未使用的变量警告没处理,结果线上版本出了个诡异bug——嗯,从那以后我再也不敢放过任何警告了。
25.3 Jenkins:老牌CI工具的嵌入式实践
GitHub Actions虽好,但有些公司内部网络隔离,或者需要对接私有仓库,这时候Jenkins还是主力。
Jenkins做嵌入式CI,我建议用Pipeline as Code的方式,把构建脚本写在Jenkinsfile里,和代码一起管理。
// Jenkinsfile
pipeline {
agent any
environment {
TOOLCHAIN_PATH = '/opt/gcc-arm-none-eabi/bin'
BUILD_DIR = 'build'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Configure') {
steps {
sh 'cmake -B ${BUILD_DIR} -DCMAKE_TOOLCHAIN_FILE=cmake/arm-gcc-toolchain.cmake'
}
}
stage('Build') {
steps {
sh 'cmake --build ${BUILD_DIR} --target all'
}
}
stage('Static Analysis') {
steps {
sh 'cmake --build ${BUILD_DIR} --target cppcheck'
}
}
stage('Archive') {
steps {
archiveArtifacts artifacts: 'build/*.hex', fingerprint: true
}
}
}
post {
failure {
emailext(
subject: "Build Failed: ${env.JOB_NAME} - ${env.BUILD_NUMBER}",
body: "Check the build log at: ${env.BUILD_URL}",
to: 'team@example.com'
)
}
}
}
Jenkins的好处是插件生态丰富。比如你可以用Warnings Next Generation Plugin收集编译器警告,用JUnit Plugin展示测试结果。我个人习惯在Jenkins里加一个构建耗时趋势图——如果某次提交后编译时间突然变长,八成是有人加了不该加的头文件。
注意:Jenkins节点上的工具链版本一定要和开发环境保持一致。我曾经遇到过Jenkins上用的是gcc 10,本地用的是gcc 9,结果编译出来的固件行为不一样——查了两天才发现是编译器优化导致的差异。
25.4 自动化测试:不止是编译通过
很多团队做CI,只检查编译是否通过。这远远不够。
嵌入式项目的自动化测试,我一般分三层:
| 测试层级 | 运行环境 | 典型工具 | CI触发时机 |
|---|---|---|---|
| 单元测试 | 主机(x86) | CppUTest, Unity | 每次提交 |
| 集成测试 | 模拟器/QEMU | QEMU + 自定义脚本 | 每日构建 |
| 硬件在环测试 | 真实开发板 | Jenkins + 串口/网络 | 发布前 |
拿单元测试举例。很多嵌入式代码直接依赖硬件寄存器,没法在主机上跑。我的做法是:用CMake的编译选项做桩模块。
# CMakeLists.txt 片段
option(ENABLE_UNIT_TESTS "Build unit tests" OFF)
if(ENABLE_UNIT_TESTS)
add_subdirectory(tests)
# 用桩模块替换硬件驱动
target_compile_definitions(firmware PRIVATE USE_STUB_DRIVERS=1)
endif()
然后在测试代码里,你可以这样写:
#ifdef USE_STUB_DRIVERS
// 桩函数:模拟硬件寄存器
uint32_t stub_read_register(uint32_t addr) {
return 0xDEADBEEF;
}
#define READ_REGISTER stub_read_register
#endif
这样,CI里编译单元测试时,自动启用桩模块,不用连硬件也能跑逻辑测试。
25.5 避坑指南:我踩过的几个坑
做嵌入式CI这些年,我踩过不少坑。挑几个典型的说说:
- 缓存问题:CMake的缓存文件(CMakeCache.txt)在CI里经常导致诡异问题。我的做法是每次CI都
rm -rf build重新配置,虽然慢一点,但干净。 - 工具链路径:不同CI runner的工具链安装路径可能不一样。我习惯在CMake工具链文件里用
find_program(),而不是硬编码路径。 - 测试超时:嵌入式测试有时会卡在某个循环里。Jenkins Pipeline里一定要加
timeout指令,比如timeout(time: 10, unit: 'MINUTES')。 - 固件大小监控:我曾在CI里加了一个步骤,用
arm-none-eabi-size检查固件大小,如果超过某个阈值就报警。有一次团队加了太多日志,固件膨胀了30%,CI直接拦住了——嗯,这功能救过我好几次。
25.6 知识体系总览
下面这张图总结了本章的核心逻辑:从代码提交到CI触发,再到编译、测试、报告,形成一个闭环。
你看,整个流程其实不复杂。关键是把每一步都自动化,让机器替你盯着那些容易忽略的细节。
我的建议:刚开始做CI,别追求一步到位。先搞定编译和单元测试,跑通了再加静态分析、固件大小监控、硬件在环测试。一步步来,稳扎稳打。
好了,这一章就聊到这儿。CI这东西,说白了就是用机器的确定性,对抗人的不确定性。你投入的每一分钟配置时间,都会在未来的某次救急中加倍还给你。
公众号:蓝海资料掘金营,微信deep3321