2. 调度基础概念

做嵌入式单元测试,选框架是个绕不开的坎。我记得刚带团队那会儿,大家还在用手动打桩、串口打印的方式验证调度器逻辑,每次改个优先级算法,光回归测试就要折腾一整天。后来我下定决心引入自动化测试框架,试了一圈,最后锁定了Unity、Ceedling和CMock这套组合拳。

说白了,这三兄弟各有分工:Unity是测试执行引擎,Ceedling是构建管理工具,CMock是自动打桩生成器。你想想看,如果只拿Unity裸写测试,那跟用assert.h区别不大;但加上Ceedling和CMock,整个测试工程就活了。

2.1 三兄弟的角色定位

先看一张图,理清它们之间的关系:

Unity / Ceedling / CMock 角色关系图 Unity 测试执行引擎 Ceedling 构建管理 / 测试编排 CMock 自动打桩 / Mock 生成 Ceedling 调用 Unity 执行测试用例,调用 CMock 生成桩函数 三者配合实现「编写测试 → 自动构建 → 运行报告」闭环

从图上能看出来,Ceedling是那个「管事的」。它负责扫描测试文件、调用编译器、链接Unity和CMock生成的代码,最后输出测试报告。我习惯把Ceedling比作嵌入式世界的Makefile生成器——你只需要写一个project.yml,它就能帮你搞定一切。

2.2 框架对比:什么时候选谁?

很多初学者会问:「我能不能只用Unity?」当然可以。但你要想清楚场景。我整理了一个对比表,方便你快速决策:

维度 Unity 单独使用 Unity + Ceedling Unity + Ceedling + CMock
适用场景 纯函数测试,无外部依赖 有模块依赖,但不需要复杂打桩 模块间耦合严重,需要隔离测试
配置复杂度 低(手动写main) 中(写project.yml) 中高(需定义mock头文件)
打桩方式 手动写桩函数 手动写桩函数 自动生成桩函数
回归测试效率 低(每次手动编译) 高(一键运行) 高(自动管理桩)
推荐指数 ★★☆☆☆ ★★★★☆ ★★★★★
我的建议:如果你的调度器代码超过2000行,或者有中断、定时器、外设寄存器等硬件依赖,直接上全套组合。别犹豫,手动打桩的维护成本远比你想象的高。

2.3 在MDK中集成:实战步骤

MDK(Keil)是ARM单片机开发的主流IDE。我曾在MDK 5.38上集成过这套框架,踩过不少坑。下面是我验证过的步骤:

  1. 下载源码:从GitHub拉取Unity、Ceedling和CMock的release包。注意版本对齐,我推荐Unity 2.5.x + Ceedling 0.31.x + CMock 2.5.x。
  2. 创建测试目录:在工程根目录下建test/文件夹,里面放unity_config.h和test_runner.c。
  3. 配置Ceedling:在test/下创建project.yml,指定源文件路径、编译器路径、输出格式。
  4. 编写测试用例:用Unity的宏写TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE等断言。
  5. 添加后构建步骤:在MDK的Options for Target → User → After Build/Rebuild中,添加Ceedling命令行调用。

这里有个关键点——MDK默认的ARMCC编译器对Ceedling的Ruby脚本支持不太好。我曾经卡在这里两天,后来发现需要在Windows上装Ruby环境,并且把ceedling命令加到系统PATH里。

// project.yml 核心配置示例
:project:
  :build_root: build
  :test_file_prefix: test_

:paths:
  :source:
    - ../Src/
  :test:
    - .
  :include:
    - ../Inc/

:tools:
  :test_compiler:
    :executable: C:/Keil_v5/ARM/ARMCC/bin/armcc.exe
    :arguments:
      - -c
      - --cpu Cortex-M4
      - -O0
      - --debug
      - -I"$": COLLECTION_PATHS_TEST_TOOLCHAIN
      - -I"$": COLLECTION_PATHS_SOURCE
      - <%= $ce_include_path %>
      - <%= $ce_test_file_path %>
注意:MDK的ARMCC编译器不支持-std=c99以外的C标准。如果你用了C11的特性(比如匿名结构体),编译会报错。我建议在测试代码中只用C99语法,或者用GCC交叉编译器替代ARMCC。

2.4 在IAR中集成:差异与技巧

IAR Embedded Workbench的集成思路跟MDK类似,但有几个差异点:

  • 编译器路径不同:IAR的编译器是iccarm.exe,链接器是ilinkarm.exe。在project.yml中要对应修改。
  • 调试信息格式:IAR默认输出.out或.elf,Ceedling需要配置:test_linker的:executable为ilinkarm.exe。
  • 预处理器宏:IAR用-D定义宏,但语法跟ARMCC略有不同。我建议在unity_config.h中统一管理宏定义。
// IAR 编译器配置片段
:tools:
  :test_compiler:
    :executable: C:/Program Files/IAR Systems/Embedded Workbench 8.5/arm/bin/iccarm.exe
    :arguments:
      - --cpu Cortex-M4
      - --no_cse
      - --no_unroll
      - --no_inline
      - --no_tbaa
      - --no_clustering
      - --debug
      - -e
      - -On
      - -I"$": COLLECTION_PATHS_TEST_TOOLCHAIN
      - -I"$": COLLECTION_PATHS_SOURCE
避坑指南:我曾经在IAR 8.42上遇到一个诡异问题——测试用例全部通过,但链接时报Error[Li005]: no definition for "__iar_program_start"。后来发现是Ceedling生成的main()跟IAR的启动文件冲突。解决方案是在project.yml中加上:test_linker: :arguments: --entry main,强制入口点。

2.5 一个完整的测试用例长什么样?

说了这么多理论,不如看个实际例子。假设我们要测试调度器中的任务插入函数:

// test_scheduler_insert.c
#include "unity.h"
#include "scheduler.h"
#include "mock_timer.h"  // CMock 自动生成的桩头文件

static task_t task1, task2;

void setUp(void) {
    scheduler_init();
    task1.id = 1;
    task1.priority = 10;
    task2.id = 2;
    task2.priority = 5;
}

void tearDown(void) {
    // 清理工作
}

void test_insert_should_add_task_to_ready_queue(void) {
    // 模拟定时器当前 tick
    timer_get_tick_ExpectAndReturn(100);

    // 执行插入
    scheduler_insert_task(&task1);

    // 验证就绪队列长度
    TEST_ASSERT_EQUAL(1, scheduler_get_ready_count());
}

void test_insert_should_sort_by_priority(void) {
    timer_get_tick_ExpectAndReturn(100);
    timer_get_tick_ExpectAndReturn(100);

    scheduler_insert_task(&task1);  // priority 10
    scheduler_insert_task(&task2);  // priority 5

    // 优先级高的(数值小)应该在队首
    task_t *head = scheduler_get_next_task();
    TEST_ASSERT_EQUAL(2, head->id);
}

你看,用CMock之后,timer_get_tick这个硬件依赖被自动打桩了。我们只需要用ExpectAndReturn声明「期望调用一次,返回100」,测试就能在PC上跑通,完全不需要硬件。

2.6 测试报告与持续集成

Ceedling默认输出两种报告格式:控制台文本和JUnit XML。我习惯把XML报告接入Jenkins或GitLab CI,这样每次提交代码后自动跑测试,失败时发邮件通知。

// 在 project.yml 中启用 XML 报告
:plugins:
  :load_paths:
    - vendor/plugins
  :enabled:
    - xml_tests_report
    - html_tests_report

:reporting:
  :build_time: true
  :threshold: 100

关键指标:我要求团队每个调度器模块的单元测试覆盖率不低于85%。实测下来,用Unity + Ceedling + CMock这套组合,一个中等复杂度的调度器(约3000行C代码)的测试代码量大约是生产代码的1.5倍,但维护成本远低于手动测试。