3、测试环境搭建:HIL、SIL 与 CI 环境配置

说到测试环境搭建,我见过不少团队在这上面栽跟头。硬件还没回来,软件没法测;软件写好了,硬件环境又迟迟配不好。说白了,测试环境就是你的“战场”,战场没铺好,仗怎么打?

我个人习惯把嵌入式测试环境分成三层:软件在环(SIL)、硬件在环(HIL)和持续集成(CI)。这三层不是替代关系,而是互补关系。你想想看,如果每次改个逻辑都要烧录到真板上跑,那效率得多低?反过来,如果全程只跑模拟器,最后联调时发现硬件时序对不上,那又得返工。

核心原则: SIL 保功能逻辑,HIL 验硬件接口,CI 做持续回归。三层联动,缺一不可。

3.1 软件在环(SIL)环境搭建

SIL 环境,说白了就是把你的嵌入式代码放在 PC 上跑,用模拟器代替真实硬件。我在项目中遇到过最典型的情况:硬件开发板还没到货,但算法团队已经催着要验证控制逻辑了。这时候 SIL 就是救命稻草。

3.1.1 交叉编译与模拟器选择

嵌入式代码要在 PC 上跑,第一步就是交叉编译。你得把目标平台(比如 ARM Cortex-M4)的代码,编译成 x86 架构的可执行文件。常用的工具有:

  • QEMU: 全系统模拟器,能模拟完整的 MCU 外设。我建议用它来做裸机或 RTOS 的 SIL 验证。
  • Unicorn: 轻量级 CPU 模拟器,适合只验证指令逻辑的场景。跑单元测试时特别好用。
  • Renode: 专为嵌入式设计的模拟框架,支持多核、外设建模,还能和 CI 集成。

嗯,这里要注意:模拟器再逼真也不是真硬件。外设时序、中断延迟、ADC 采样抖动这些,模拟器是模拟不出来的。所以 SIL 只适合验证“功能逻辑”,不适合验证“时序性能”。

3.1.2 测试桩与 Mock 层

SIL 环境里最头疼的问题是什么?是硬件依赖。你的代码里肯定有 GPIO_WritePin()、UART_SendByte() 这些硬件操作函数。在 PC 上跑,这些函数根本不存在。

我的做法是:在代码中抽象出一层硬件抽象层(HAL),SIL 测试时把 HAL 替换成 Mock 桩函数。举个例子:

// 原 HAL 函数(真实硬件)
void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState) {
    // 操作寄存器
    GPIOx->BSRR = GPIO_Pin << (PinState ? 0 : 16);
}

// Mock 桩函数(SIL 环境)
void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState) {
    // 记录调用日志,不操作真实硬件
    printf("[MOCK] GPIO_WritePin: port=%p, pin=%d, state=%d\n", GPIOx, GPIO_Pin, PinState);
    // 更新模拟状态机
    mock_gpio_state[GPIO_Pin] = PinState;
}
我的小技巧: 用条件编译控制 HAL 的编译路径。在 Makefile 里加一个 SIL=1 的选项,编译时自动链接 Mock 层,不用改业务代码。

3.2 硬件在环(HIL)环境搭建

HIL 环境,就是把真实硬件接入测试系统。我记得第一次搭 HIL 环境时,光接线就花了两天。后来总结出一套标准流程,效率才提上来。

3.2.1 HIL 硬件拓扑

一个典型的 HIL 环境包含以下组件:

组件 作用 常见选型
被测设备(DUT) 运行目标固件的真实硬件板卡 STM32F4 开发板、树莓派、自研板卡
信号仿真器 模拟传感器输入、执行器负载 NI PXI、dSPACE、自研信号板
数据采集卡 捕获 DUT 的 GPIO、PWM、ADC 输出 NI DAQ、USB-6210、逻辑分析仪
上位机控制软件 运行测试脚本,控制仿真流程 Python + PyVISA、LabVIEW
电源管理模块 控制 DUT 上下电、监测电流 可编程电源(如 Keysight E36312A)

我曾经踩过一个坑:信号仿真器和 DUT 的参考地没接好,导致共模电压漂移,ADC 读数一直不准。查了三天才发现是地线问题。所以 HIL 环境搭建时,接地和屏蔽一定要优先处理好。

3.2.2 HIL 测试脚本框架

HIL 测试的核心是“自动化”。你不能每次都手动按按钮、看波形。我习惯用 Python 写测试脚本,通过串口或以太网控制 DUT,同时用数据采集卡读取响应。

import pyvisa
import serial
import time

class HILTestFixture:
    def __init__(self, dut_port, daq_address):
        self.dut = serial.Serial(dut_port, 115200, timeout=1)
        self.daq = pyvisa.ResourceManager().open_resource(daq_address)
    
    def set_sensor_input(self, voltage):
        """通过 DAQ 输出模拟传感器电压"""
        self.daq.write(f"SOUR:VOLT {voltage}")
        time.sleep(0.01)  # 等待 DUT 采样
    
    def read_dut_output(self):
        """从 DUT 串口读取响应"""
        self.dut.write(b"GET_STATUS\n")
        return self.dut.readline().decode().strip()
    
    def test_overvoltage_protection(self):
        """测试过压保护功能"""
        self.set_sensor_input(5.5)  # 输入 5.5V(超过阈值)
        response = self.read_dut_output()
        assert "PROTECTION_ACTIVE" in response, "过压保护未触发!"
        print("[PASS] 过压保护测试通过")

# 运行测试
fixture = HILTestFixture("COM3", "USB0::0x1234::0x5678::INSTR")
fixture.test_overvoltage_protection()
注意: HIL 测试脚本一定要加超时保护。如果 DUT 死机或通信中断,脚本不能无限等待。我一般设 5 秒超时,超时后自动重启 DUT 并记录失败日志。

3.3 持续集成(CI)环境配置

CI 环境,就是把 SIL 和 HIL 测试串起来,每次代码提交都自动跑一遍。我见过很多团队,CI 只跑编译检查,不跑测试。那其实叫“持续编译”,不叫“持续集成”。

3.3.1 CI 流水线结构

一个完整的嵌入式 CI 流水线,我建议分三个阶段:

  1. 静态检查阶段: 代码风格检查(clang-format)、静态分析(cppcheck)、编译检查。这个阶段跑得最快,5 分钟内必须出结果。
  2. SIL 测试阶段: 在 QEMU 或 Unicorn 上运行单元测试和集成测试。这个阶段跑得稍慢,但 15 分钟内应该能完成。
  3. HIL 测试阶段: 连接真实硬件,运行回归测试。这个阶段最慢,但最关键。我建议只在合并到主分支时触发。

为什么会这样设计?因为反馈速度很重要。如果每次提交都要等 HIL 跑完(可能 30 分钟),开发者会疯掉的。所以把快速检查放在前面,慢速检查放在后面。

3.3.2 CI 工具选型与配置

嵌入式 CI 的工具有很多,我比较推荐 Jenkins 和 GitLab CI。Jenkins 插件生态丰富,GitLab CI 配置简洁。下面是一个 GitLab CI 的 .gitlab-ci.yml 示例:

stages:
  - static_check
  - sil_test
  - hil_test

static_check:
  stage: static_check
  script:
    - cppcheck --enable=all --suppress=missingInclude src/
    - clang-format --dry-run --Werror src/*.c
  only:
    - merge_requests

sil_test:
  stage: sil_test
  script:
    - make SIL=1
    - ./build/sil_test_runner
  artifacts:
    paths:
      - build/test_results.xml
    reports:
      junit: build/test_results.xml

hil_test:
  stage: hil_test
  script:
    - python3 scripts/hil_runner.py --config config/hil_board1.json
  tags:
    - hil-runner  # 指定有硬件连接的 runner
  only:
    - main
避坑指南: HIL 阶段的 runner 一定要打标签(tags),不能和普通 runner 混用。我曾经因为 runner 调度问题,导致两个 HIL 测试任务同时抢同一个硬件板卡,结果两个都失败了。后来给每个硬件板卡配了一个专用 runner,问题才解决。

3.4 三层环境的关系与数据流

为了让你更直观地理解这三层环境的关系,我画了一张结构图:

嵌入式测试环境三层架构 持续集成(CI)层 代码仓库触发 流水线调度 报告与通知 软件在环(SIL)层 QEMU/Unicorn 模拟 Mock HAL 层 单元/集成测试 硬件在环(HIL)层 信号仿真器 数据采集卡 电源管理 被测设备 数据流方向:代码提交 → CI 触发 → SIL 验证 → HIL 回归 → 测试报告

从这张图你能看到,数据流是单向的:代码提交触发 CI,CI 先调度 SIL 测试,SIL 通过后再调度 HIL 测试。这样设计的好处是:尽早发现低级错误,避免浪费昂贵的 HIL 资源。

3.5 环境搭建的常见问题与对策

最后,我总结几个我在项目中遇到过的典型问题,希望能帮你少走弯路:

问题 现象 对策
SIL 测试通过,HIL 测试失败 模拟器上逻辑正确,真板上跑飞 检查 HAL 层抽象是否完整,特别是中断和 DMA 部分
HIL 测试结果不稳定 同一测试有时通过有时失败 检查时序问题,增加等待时间,使用示波器抓波形
CI 流水线跑太久 一次提交等 40 分钟才出结果 拆分测试用例,把冒烟测试和全量回归分开跑
硬件资源冲突 两个 CI 任务同时抢同一块板卡 给每个硬件板卡配专用 runner,用 tags 隔离
我曾经踩过的坑: 有一次 SIL 环境里用的编译器版本和 HIL 环境不一致(SIL 用 GCC 9,HIL 用 GCC 12),结果浮点运算结果有微小差异,导致一个控制算法在 SIL 上正常,在 HIL 上发散。后来我强制所有环境使用同一套工具链,问题才解决。所以,工具链一致性是环境搭建的红线。

好了,测试环境搭建这部分就讲到这里。记住:SIL 保逻辑,HIL 验硬件,CI 做串联。三层环境不是一次性搭完就完事的,需要根据项目进展持续优化。你想想看,如果每次迭代都花半天时间在环境问题上,那还怎么快速交付?