Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.5 CI/CD 与集成测试——施工流水线与结构验收

大兴机场的钢构件

2019年,北京大兴国际机场的航站楼钢结构封顶的那天,没有一个工人“感觉差不多就行“。每一根钢管在进场时都贴着二维码,用手机一扫,这根钢管的炉批号、生产日期、力学性能报告、超声波探伤结果、油漆涂层厚度,全部在系统里可见。材料进场→扫码入库→取样复检→合格→吊装定位→焊接→焊缝探伤→验收签字。这是一个不可跳跃、不可绕行、不可人工放行的流程链条。

在这个链条上,“人的判断“被压缩到了最小空间。系统不信任任何人,哪怕他是三十年的老焊工,它只信任数据。你的焊工资格证书在系统里有备案吗?这台焊机今天校准过了吗?环境温度在允许范围吗?任何一个条件不满足,这一道焊缝的系统状态就是“不通过”,下一道工序就无法开始。

这套系统有一个正式的名字:施工质量管控流水线。

软件工程里,它叫 CI/CD(持续集成 / 持续交付)。

核心洞察:CI 是“不可跳跃、不可绕行、不可人工放行“的施工质量管控流水线。 系统不信任任何人,哪怕他是三十年的老焊工,只信任数据:证书备案、焊机校准、环境温度,任一条件不满足,下一道工序就无法开始。软件工程里,把这套制度落地的东西叫 CI/CD。


什么是 CI?——每一次 push,就像一车混凝土进场

CI 的本质是将所有独立的验证步骤串联成一个自动化流水线,每一次代码提交都触发完整的验证流程。如果任何一个步骤失败,流水线停止,提交者收到通知,问题必须在继续开发之前修复。

对于嵌入式项目,一条标准的 CI 流水线包含以下阶段:

代码提交 (git push)
    │
    ▼
[阶段 1: 编译检查]
    ├── 编译目标固件 (Debug 配置, -Wall -Werror)
    ├── 编译目标固件 (Release 配置, 优化等级 -O2)
    └── 编译单元测试 (UT 构建)
    │
    ▼
[阶段 2: 静态分析]
    ├── Cppcheck (零告警通过)
    ├── Clang Static Analyzer
    └── MISRA 检查 (Helix QAC / PC-Lint)
    │
    ▼
[阶段 3: 单元测试]
    ├── 运行全部 UT
    ├── 生成覆盖率报告 (gcov / gcovr)
    └── 覆盖率是否达标 (分支覆盖率 ≥ ASIL 目标)
    │
    ▼
[阶段 4: 集成测试 (SIL / PIL)]
    ├── Software-in-the-Loop 测试
    ├── Processor-in-the-Loop 测试
    └── 回归测试套件
    │
    ▼
[阶段 5: 硬件在环测试 (HIL)]
    ├── 烧录固件到真实 ECU
    ├── 自动化 HIL 测试序列
    └── 通信、诊断、I/O 功能验证
    │
    ▼
[阶段 6: 归档]
    ├── 生成固件制品 (.elf, .hex, .bin)
    ├── 签名/哈希 (安全启动用)
    ├── 上传制品仓库
    └── 创建/更新 Git Tag

关键原则:任何阶段的失败都会阻止流水线继续。这就像施工现场:如果进场钢管的力学性能复检不合格,你不可能说“先焊上去吧,以后再补检“。不合格就是不合格,流程必须停下来。

核心洞察:CI 把独立的验证步骤串联成自动化流水线,任何一步失败都阻止继续。 就像进场钢管复检不合格,不能“先焊上去以后再说“。编译、静态分析、单元测试、SIL、HIL、归档六阶段层层把关,不合格就是不合格,流程必须停下来。


GitHub Actions / GitLab CI 的嵌入式实践

以下是一个嵌入式项目的 GitHub Actions 流水线配置(简化版),逐段解释:

name: Firmware CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  # ==============================
  # 阶段 1: 编译
  # ==============================
  build:
    runs-on: ubuntu-latest
    container:
      image: company/arm-gcc-toolchain:10.3-2021.10

    steps:
      - uses: actions/checkout@v4

      - name: Build Debug
        run: |
          make BUILD_CONFIG=debug \
               CFLAGS="-Wall -Werror -Wextra" \
               -j$(nproc)

      - name: Build Release
        run: |
          make BUILD_CONFIG=release \
               CFLAGS="-O2 -Wall -Werror" \
               -j$(nproc)

      - name: Build Unit Tests
        run: |
          ceedling test:build

      - name: Archive firmware artifacts
        uses: actions/upload-artifact@v4
        with:
          name: firmware-elf
          path: build/*.elf

  # ==============================
  # 阶段 2: 静态分析
  # ==============================
  static-analysis:
    needs: build
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Cppcheck
        run: |
          cppcheck --enable=all --inconclusive \
                   --suppress=missingIncludeSystem \
                   --error-exitcode=1 \
                   src/

      - name: Clang Static Analyzer
        run: |
          scan-build --status-bugs make BUILD_CONFIG=debug

  # ==============================
  # 阶段 3: 单元测试 + 覆盖率
  # ==============================
  unit-test:
    needs: build
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Run Unit Tests
        run: ceedling test:all

      - name: Generate Coverage Report
        run: |
          gcovr --xml coverage.xml \
                --exclude 'test/.*' \
                --exclude 'third_party/.*'

      - name: Check Coverage Threshold
        run: |
          BRANCH_COV=$(xmllint --xpath \
            'string(//coverage/@branch-rate)' coverage.xml)
          if (( $(echo "$BRANCH_COV < 0.80" | bc -l) )); then
            echo "Branch coverage $BRANCH_COV below 80% threshold"
            exit 1
          fi

      - name: Upload Coverage Report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml

  # ==============================
  # 阶段 4: 集成测试 (SIL)
  # ==============================
  integration-sil:
    needs: unit-test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: SIL Integration Tests
        run: |
          # 编译 SIL 版本(硬件抽象层替换为模拟实现)
          make BUILD_CONFIG=sil -j$(nproc)
          ./build/sil_test_runner --gtest_output=xml:sil_results.xml

      - name: Publish SIL Test Results
        uses: actions/upload-artifact@v4
        with:
          name: sil-results
          path: sil_results.xml

  # ==============================
  # 阶段 5: HIL 测试(需硬件环境)
  # ==============================
  hil-test:
    needs: integration-sil
    runs-on: [self-hosted, hil-rig]  # 运行在连接 HIL 设备的物理机器上
    if: github.ref == 'refs/heads/main'  # 仅在 main 分支执行

    steps:
      - uses: actions/checkout@v4

      - name: Flash ECU
        run: |
          python scripts/flash_ecu.py \
            --elf build/firmware.elf \
            --serial $ECU_SERIAL_ID

      - name: Run HIL Test Sequence
        run: |
          python scripts/run_hil_tests.py \
            --test-suite hil/regression.yaml \
            --report hil_report.xml

      - name: Validate HIL Results
        run: |
          python scripts/check_results.py \
            --report hil_report.xml \
            --threshold 0.98  # 98% 测试通过率

  # ==============================
  # 阶段 6: 归档与打标签
  # ==============================
  archive:
    needs: hil-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@v4

      - name: Generate version and tag
        run: |
          VERSION=$(cat build/version.txt)
          git tag -a "v$VERSION" -m "CI build ${{ github.run_id }}"
          git push origin "v$VERSION"

      - name: Upload to Artifact Repository
        run: |
          curl -X PUT \
            -H "Authorization: Bearer $ARTIFACTORY_TOKEN" \
            -F "firmware=@firmware-elf/firmware.elf" \
            "${ARTIFACTORY_URL}/releases/v${VERSION}/"

这段配置体现了 CI 的几个关键设计原则:

1. 阶段的串行与并行

  • build 阶段独立运行。
  • static-analysis 和 unit-test 并行运行,它们依赖于 build 完成,但彼此之间不需要等待。这节省了一半的时间。
  • integration-sil 在单元测试通过后才运行:因为如果单元测试都挂了,集成测试不可能通过,提前跑毫无意义。
  • hil-test 仅当所有之前的阶段通过且当前分支是 main 时运行:HIL 测试依赖真实硬件(一个 ECU 和 HIL 测试台架),硬件资源有限且昂贵,不能每次 PR 都跑。

2. “门控“机制

每个阶段都是一个门(Gate)。门打开的条件是前一个阶段的输出满足质量标准。代码覆盖率低于 80%?门不打开,流水线停止。SIL 测试有失败用例?门不打开,后面的 HIL 不会被执行。

门控 ≠ 惩罚。门控 = 保护。 它保护的是下一个阶段的资源(时间、硬件、人的注意力)不被浪费在质量不达标的输入上。

核心洞察:CI 配置的艺术是阶段的串行、并行与“门控“。 独立阶段并行省时间,依赖阶段串行保质量:单元测试挂了就不跑集成,HIL 只在 main 分支跑,因为它占用昂贵且稀缺的硬件。门控不是惩罚,是保护——保护下一阶段的时间、硬件与人的注意力不被浪费。


HIL(硬件在环)测试:结构验收的软件等价物

结构工程有一个环节叫静载试验:在桥梁建成后但不通车之前,用几十辆满载的卡车排成设计荷载的最不利位置,测量桥面的挠度和关键位置的应变。如果实测挠度超过设计限值,这座桥不能通车。

HIL 测试就是嵌入式系统的“静载试验“:

  • 在真实的 ECU 硬件上跑,不是模拟器上跑。
  • 按预先设计的测试序列测试,系统地覆盖所有对外的电气接口。
  • 输入是由信号发生器产生的真实电压/电流/CAN 波形。
  • 输出是由数据采集卡采集的真实引脚电压/CAN 报文/诊断响应。

一个典型的 HIL 测试台架包含:

┌──────────────────────────────────────────────┐
│                 HIL 测试台架                    │
│                                               │
│  ┌─────────┐    真实I/O     ┌──────────┐      │
│  │  被测ECU  │◄─────────────►│  I/O 板卡 │      │
│  │ (DUT)    │  CAN/LIN/     │ (NI/     │      │
│  │          │  ADC/GPIO     │  Vector) │      │
│  └─────────┘               └────┬─────┘      │
│                                 │             │
│                            ┌────▼─────┐      │
│                            │ 测试主机   │      │
│                            │           │      │
│                            │ Python/   │      │
│                            │ ECU-TEST  │      │
│                            │ 运行测试   │      │
│                            │ 脚本      │      │
│                            └───────────┘      │
└──────────────────────────────────────────────┘

HIL 测试要覆盖的内容包括但不限于:

  • 上电时序:电源从 0V 逐渐上升到 12V 的过程中,MCU 是否正确复位?Bootloader 是否在正确的时刻跳转到 App?
  • 通信完整性:在 CAN 总线负载 80% 的环境下,被测 ECU 是否能正确收发所有报文?错误帧计数是否在允许范围内?
  • 模拟故障注入:传感器信号突然断开时,ECU 是否进入安全状态?恢复连接后是否恢复正常?
  • 诊断服务:UDS 22 服务是否能正确读取所有 DID?2E 服务的写入在安全访问前是否被正确拒绝?
  • 边界电源:供电电压 9V(冷启动工况的最低电压)和 16V(抛负载工况的最高电压)下,所有功能是否正常?

HIL 测试的自动化脚本示例(Python + Vector VN1640 CAN 接口卡):

def test_can_busoff_recovery():
    """
    测试 CAN 控制器进入 Bus-Off 状态后的自动恢复
    对应需求: TSR-CAN-028
    """
    ecu = EcuController(serial="ECU-042")
    can_if = CanInterface(channel=0, bitrate=500000)

    # 步骤 1: 上电并等待 ECU 初始化
    ecu.power_on()
    ecu.wait_for_init(timeout_ms=5000)

    # 步骤 2: 验证正常通信
    response = can_if.send_uds_request(0x7E0, b'\x3E\x00')  # Tester Present
    assert response is not None, "ECU 对 Tester Present 无响应"

    # 步骤 3: 制造 Bus-Off 条件:发送连续高错误率报文
    # 通过 VN1640 的 Error Frame 生成能力强制产生错误帧
    can_if.induce_busoff(target_id=0x7E0, duration_ms=500)

    # 步骤 4: 等待 ECU 恢复(T_recover = 128 × 11 × 隐性位时间)
    time.sleep(0.5)  # 500ms 应该足够恢复

    # 步骤 5: 验证通信已恢复
    response = can_if.send_uds_request(0x7E0, b'\x3E\x00')
    assert response is not None, "ECU 在 Bus-Off 后未恢复通信"

    # 步骤 6: 验证 Bus-Off 计数器已递增(DID 0xF18A)
    did_f18a = can_if.read_did(0x7E0, 0xF18A)
    assert did_f18a.value >= 1, f"Bus-Off 计数器未递增: {did_f18a.value}"

    ecu.power_off()

核心洞察:HIL 是嵌入式系统的“静载试验“:在真实 ECU 上用真实电信号验证完整外部接口。 它覆盖上电时序、通信完整性、故障注入、诊断服务与边界电源——这些是模拟器与单元测试永远给不出的保证。桥面能承受多少卡车,只有真的压上去才知道。


ASPICE SWE.5 和 SWE.6:集成与合格性测试的认证位置

过程名称验证对象建筑类比
SWE.4软件单元验证独立函数/模块材料试验(单块砖、单根钢筋)
SWE.5软件集成与集成测试模块间的接口和交互分项验收(柱-梁连接节点)
SWE.6软件合格性测试完整软件系统 vs 软件需求竣工验收(整栋楼的静载试验)

SWE.5 的核心关注点是接口:函数调用接口、数据流、控制流、模块间的时序依赖关系。集成测试的策略通常是自底向上:先把底层驱动和中间件集成测试好,再把应用层软件叠加上去。这就像建筑施工中的“隐蔽工程验收“:混凝土浇筑前先验收钢筋绑扎,封板前先验收预埋管线。

SWE.6 的核心关注点是需求覆盖:每一条软件需求必须有对应的合格性测试。测试的输入条件必须与需求中定义的使用场景一致,测试的判定准则必须从需求中推导出来。这是从“做得对“到“做出了正确的东西“的关键区别:单元测试和集成测试验证的是前者,合格性测试验证的是后者。

核心洞察:SWE.5 验证接口交互,SWE.6 验证需求覆盖,两者的区别是“做得对“与“做出了正确的东西“。 集成测试像隐蔽工程验收,自底向上逐层叠加;合格性测试要求每条软件需求有对应测试,判定准则必须从需求推导。单元与集成测“怎么做得对“,合格性测“是不是对了的东西“。


人的核心:流水线在凌晨三点不会犯困

回到大兴机场工地上那条不可绕行的质量管控流水线。为什么要把人的判断压缩到最小?

原因在于人的注意力是有限且波动的资源,而不是不信任人。

凌晨三点,一位工程师刚刚完成了一个紧急 Bug 修复。他的咖啡已经凉了,他的大脑正在把“累“的信号解读为“差不多就行了“的信号。他运行了一遍编译,编译通过了。他手动跑了两三个测试用例,全绿了。他觉得没问题了,把固件发给了测试组。

第二天早上,测试组发现 CAN 通信在特定负载下间歇性失效。原因:他修改了 SPI 驱动的一个时钟配置,而这个修改影响了 CAN 控制器的 SPI 总线时序。这不是逻辑错误,他的修改本身是对的,但他在凌晨三点的状态下无法把“修改了 SPI 配置“和“CAN 控制器也挂在同一条 SPI 总线上“这两件事关联起来。

如果 CI 流水线在那次 git push 之后就自动运行:编译→静态分析→单元测试→SIL→HIL,HIL 阶段会在几十秒内挂掉,因为真实的 CAN 通信已经失败了。凌晨三点的工程师会立刻收到通知:你的提交没有通过。这不会等到第二天上午测试组打来电话。

人类工程师发起提交,机器流水线执行验证,结果再交回人类工程师。 流水线不是替代人,是保护人:保护凌晨三点的那个你,不让你因为疲劳而犯一个需要整个团队花一整天来排查的错误。

核心洞察:流水线不是替代人,是保护凌晨三点的那个你。 人的注意力有限且波动,疲劳时“差不多就行了“的信号会压过严谨判断;CI 让 HIL 在几十秒内拦截凌晨三点提交的 SPI 改动,而不是等测试组第二天来电话。人类发起提交、机器执行验证、结果交还人类——制度永远比人的意志更持久。


本篇小结

  • CI = 施工质量管控流水线。每一次代码提交触发自动化验证链,任何阶段失败都阻止流水线。
  • 标准 CI 六阶段:编译 → 静态分析 → 单元测试 → SIL 集成测试 → HIL 硬件测试 → 归档与打标签。
  • 阶段串并行设计:独立阶段并行以节省时间,依赖阶段串行以保证输入质量。
  • HIL 测试 = 结构静载试验。在真实 ECU 上,用真实电信号,测完整外部接口。覆盖上电时序、通信完整性、故障注入、边界电源。
  • ASPICE SWE.5(软件集成测试):验证模块间接口与交互。SWE.6(软件合格性测试):验证软件系统满足软件需求。
  • CI/CD 的核心价值 = 制度化。把质量检查从“人记住要做“变为“系统强制做“。凌晨三点不犯困。
  • 流水线不是替代人,是保护凌晨三点的那个人。

【下集预告】:混凝土试块压过了、钢筋拉过了、焊缝探伤过了,一栋楼合格了。但建筑的故事没有结束。如果有一天,这栋楼的某一段发生了结构性裂缝,调查委员会需要回答一个问题:这个裂缝的根源在哪里? 从裂缝追溯回焊缝、从焊缝追溯回焊工、从焊工追溯回焊接工艺评定:这是一条不能在任何环节断裂的追溯链。在汽车嵌入式软件里,这条链叫“安全追溯“。下一节,我们走进 ISO 26262 的追溯链:从安全目标一直到代码行。

你的 HIL 测试全绿,固件归档完毕,CI 流水线在 v2.1.3-build42 上盖下了“通过“的戳。但你有没有想过,为什么这个版本的 ADC_Filter_Coefficient 是 0.85 而不是 0.80?这个值的来源是一条安全需求,从 HARA 分析一路传导下来。如果有人将来问你“这个系数凭什么这样设“,你需要指着一条完整的追溯链回答他。下一节,追溯链的每一个环节,都在你的眼前展开。