第二十五章:持续集成(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知识体系 代码提交 (git push) CI触发 (Actions/Jenkins) 交叉编译固件 主机单元测试 静态代码分析 生成报告 & 归档固件 邮件/钉钉/微信通知

你看,整个流程其实不复杂。关键是把每一步都自动化,让机器替你盯着那些容易忽略的细节。

我的建议:刚开始做CI,别追求一步到位。先搞定编译和单元测试,跑通了再加静态分析、固件大小监控、硬件在环测试。一步步来,稳扎稳打。

好了,这一章就聊到这儿。CI这东西,说白了就是用机器的确定性,对抗人的不确定性。你投入的每一分钟配置时间,都会在未来的某次救急中加倍还给你。


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