ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

告别Makefile:用Ceedling打造嵌入式C单元测试自动化流水线

2026/9/29 17:00:15 拓冰建站 浏览量
告别Makefile:用Ceedling打造嵌入式C单元测试自动化流水线 上个月给一套电机控制模块补单元测试我在Makefile里加了第四个源文件路径链接器立刻开始报一堆undefined reference。排查了快二十分钟最后发现是模块间依赖顺序写错了。这已经不是第一次了每加一个测试文件得手动更新目标列表每次改头文件得担心缓存和依赖追踪某个宏定义一调整整个构建图就乱了。我盯着屏幕想真正花在写测试上的时间不到一半剩下的全在跟构建脚本较劲这样下去单元测试根本推不动。后来我把这套手写Makefile整个丢进回收站换成了Ceedling。Ceedling是专门为嵌入式C单元测试设计的构建管理工具我习惯把它理解成一条把Unity断言框架和CMock自动mock生成器串起来的流水线。装上环境、写好project.yml、把测试文件丢进test目录敲一条ceedling test:all构建、mock生成、运行、结果汇总全部自动完成。这篇内容主要给两类人看一类是写了不少年C代码、早就受够了手动维护Makefile的嵌入式开发另一类是刚想给项目引入TDD但被构建门槛劝退的同学。我会先把Ceedling的核心逻辑讲透再走一遍完整的建工程流程最后把我实际项目中踩过的坑和排查思路一并分享出来。1. 从Makefile到Ceedling嵌入式单元测试到底卡在哪了1.1 手动Makefile维护成本远比想象中高不少团队对单元测试的第一反应是没必要但实际尝试过的人更多是被构建脚本劝退的。一个典型的嵌入式工程通常有几十个源文件分布在驱动、协议栈、业务逻辑、BSP等不同目录。想在PC上用GCC跑单元测试你得在Makefile里维护所有参与测试的源文件路径新增文件时手动追加头文件搜索路径尤其当某些目录只在特定模块里被引用每个测试文件对应的目标文件依赖关系链接顺序和库依赖一些旧工程还有循环依赖宏定义开关比如UNIT_TESTING、HAL_MOCK不同编译单元可能要不同取值。刚开始只有几个文件时还好说测试用例一多Makefile的复杂度就开始失控。更难受的是测试构建和固件构建往往是两套规则一套针对GCC一套针对ARMCC或者IAR一旦某个模块增加了依赖两边都要同步更新漏掉一处就是编译错误或者链接错误。我当时遇到的那个undefined reference问题本质上就是因为新加了一个协议解析模块而Makefile里忘了让它参与链接。这种问题本身不可怕可怕的是它反复出现而且每次都要靠人肉去排查非常消耗耐心。1.2 嵌入式环境的可测试性障碍跟纯PC端C项目相比嵌入式C代码做单元测试还多了一道坎代码通常跟硬件高度耦合。一个函数里可能直接操作寄存器、调用延时、读取外设状态这些在本地PC上根本没法执行。要解决这个问题通常的思路是把被测模块外部依赖全部mock掉也就是用假的函数替代真实的硬件操作。这样一来测试代码的质量反而依赖mock代码的质量而手写mock是一件极其繁琐的事情每个外部函数都要写一个替身替身要能记录参数、设置返回值、验证调用顺序一旦头文件里新增函数mock代码也要跟着维护。这部分工作如果不自动化单靠手工维护单元测试的推进速度会非常慢这也是很多嵌入式团队测试搞不起来的核心原因之一。1.3 Ceedling并不是测试框架而是测试工程管理者这里先澄清一个概念Ceedling本身不是测试框架它底层用的是Rake构建系统但它把所有规则的实现细节都封装好了。使用者不需要关心如何收集测试文件、如何生成runner、如何调用CMock只需要按规矩放文件、写测试代码、敲命令。从工具链关系来看Ceedling、Unity、CMock其实出自同一个开源组织ThrowTheSwitch。Unity负责断言和执行测试用例CMock负责根据头文件自动生成mock代码Ceedling则负责把这两者与编译器、文件路径、构建选项整合在一起。打个比方的话Unity像发动机CMock像变速箱Ceedling就是那个把发动机和变速箱装进车架、并接管方向盘的人。也有同学问过用CMake加CTest不也能做类似的事吗能做但CMake只是构建工具你需要自己写规则去发现测试文件、生成runner、调用mock工具、处理依赖收集这些工作Ceedling全部内置了。对多数嵌入式项目来说直接用Ceedling是性价比最高的选择。2. Ceedling的核心机制它凭什么能一键跑起来2.1 一切从project.yml开始Ceedling的工作方式不是靠约定大于配置而是靠一个project.yml文件集中管理。这个文件看起来有些长但拆开看并不复杂关键配置我用一个实际项目的例子来解释。下面是我常用的一个最小配置--- :project: :use_exceptions: FALSE :use_test_preprocessor: TRUE :use_auxiliary_dependencies: TRUE :build_root: build :release_build: FALSE :test_file_prefix: test_ :which_ceedling: gem :paths: :test: - test/** :source: - src/** :support: - support/** :defines: :test: - UNIT_TESTING :release: - NDEBUG :flags: :test: :compile: :*: - -stdc99 - -Wall :link: :*: [] :cmock: :mock_prefix: mock_ :when_no_prototypes: :warn :enforce_strict_ordering: TRUE :plugins: - :ignore - :callback - :return_thru_ptr :treat_as: uint8: HEX8 uint16: HEX16 uint32: UINT32 int8: INT8 int16: INT16 int32: INT32 :unity: :defines: - UNITY_INCLUDE_DOUBLE几个关键点我逐一说明。:test_file_prefix定义了测试文件的识别规则默认是test_也就是说test目录下的test_foo.c才会被当成测试文件。如果工程里既有单元测试文件又有集成测试文件可以通过这个前缀做区分。:use_auxiliary_dependencies是Ceedling增量编译的核心。打开这个选项后Ceedling会为每个编译单元生成依赖文件追踪头文件变化某个头文件改了只重编受影响的部分整个工程的构建速度会明显改善。:use_test_preprocessor的作用是对测试文件做预处理。如果你的测试代码里存在大量条件编译比如#ifdef UNIT_TESTING包裹的代码块建议打开这个选项否则某些分支不会被编译器看到。:cmock下面的配置全部作用于mock生成环节后面在讲CMock时再展开。2.2 自动发现测试文件与生成runnerUnity本身运行测试的方式是每个测试文件里编写test_xxx开头的测试函数然后在main函数里逐个调用RUN_TEST(test_xxx)。手写这个main函数很机械而且测试函数一多就容易漏掉。Ceedling做的事情就是自动扫描test目录下的测试文件找出所有void test_xxx(void)格式的函数然后自动生成一个包含main()函数的runner文件。这个runner文件会放在build目录下比如build/test/out/test_led_runner.c你不需要去关心它也不需要提交到版本库里。这也带来一个潜规则测试函数必须严格按照void test_xxx(void)命名返回值和参数都不能带否则Ceedling扫描不到。需要临时跳过某个测试时可以在函数体里调用TEST_IGNORE()而不是注释掉函数这样测试报告里还能保留跳过的记录。2.3 CMock生成mock的触发逻辑CMock的工作方式非常有意思。它不要求你写任何mock声明或注册逻辑只要求你遵守一个约定在测试文件里#include mock_xxx.h。当Ceedling看到mock_hal_gpio.h这个include时会去src目录下寻找hal_gpio.h然后根据这个头文件里的函数声明自动生成对应的mock源文件。这就是它的核心机制。与此同时Ceedling在决定编译哪些被测模块时也会解析测试文件里的include。#include led.h会触发编译src/led.c而#include mock_hal_gpio.h则会触发生成并编译mock_hal_gpio.c不会再编译真实的hal_gpio.c。这个哪些编译真实模块、哪些编译mock模块的决策由Ceedling自动完成所以构建规则不用人工维护。理解了这一层就能明白为什么用Ceedling之前需要先把被测模块的外部依赖抽成接口。CMock只能mock通过头文件声明的函数如果代码直接操作寄存器、直接调用静态函数CMock是无能为力的。2.4 统一构建流程带来的效率提升从输入到输出Ceedling把整个流程串成了这样一条链扫描test目录下的测试文件为每个测试文件生成runner解析include识别被测模块和mock模块编译测试文件、被测模块、mock模块链接成可执行文件执行可执行文件捕获输出汇总断言结果标注失败位置和信息。这套流程每次运行都是确定性的不会因为少更新一行Makefile导致结果不一致。对于调试单测失败来说这是最大价值所在。3. 实战一个带外部依赖的测试工程是怎么搭起来的3.1 环境准备Ruby与Ceedling安装Ceedling依赖Ruby运行时安装Ceedling本身非常简单不同平台的差异主要体现在Ruby环境的安装上。macOS和Linux下系统一般自带Ruby如果版本太低建议用rbenv或系统包管理器装一个较新的版本。Windows下推荐直接安装RubyInstaller安装时勾选Add Ruby executables to your PATH方便后面在命令行直接使用。Ruby就绪后运行gem install ceedling装完验证一下ceedling versionCeedling在首次创建工程或运行时会自动下载Unity和CMock的源码放到vendor目录所以不需要手动去安装这两个工具。这也是Ceedling省事的地方之一依赖关系固化在版本里减少版本不匹配的麻烦。3.2 创建工程与目录结构用命令创建一个新工程ceedling new example_led生成的目录结构大致如下example_led/ ├── project.yml ├── src/ ├── test/ ├── support/ └── vendor/其中src用于放被测源码test用于放测试文件support用于放一些辅助代码比如测试专用的头文件或内存分配器等。build目录会在编译时自动生成不需要手工创建。真实项目中src目录下文件很多测试构建时Ceedling默认会编译src下的全部.c文件吗不是。它只编译测试文件实际include到的模块这种按需编译的机制能让测试更聚焦也避免无关模块引入编译问题。不过有一点要注意如果被测模块之间层层依赖比如led.c依赖gpio.c而gpio.c又依赖uart.c那么最终编译进测试可执行文件的模块会是一个传递闭包。如果uart.c里恰好有不能本地编译的寄存器操作就需要用CMock把它也mock掉或者通过配置排除掉。3.3 第一个Unity测试用例我们用之前的button_driver模块来走一遍完整流程。被测模块是一个按键驱动它依赖hal_gpio读取引脚电平// src/button_driver.h #ifndef BUTTON_DRIVER_H #define BUTTON_DRIVER_H #include stdbool.h #include stdint.h typedef uint8_t button_id_t; bool button_is_pressed(button_id_t btn); void button_init(button_id_t btn); #endif// src/button_driver.c #include button_driver.h #include hal_gpio.h void button_init(button_id_t btn) { hal_gpio_write(btn, 1); } bool button_is_pressed(button_id_t btn) { return (hal_gpio_read(btn) 0); }hal_gpio.h里声明了两个函数// src/hal_gpio.h #ifndef HAL_GPIO_H #define HAL_GPIO_H #include stdint.h int hal_gpio_read(uint8_t pin); void hal_gpio_write(uint8_t pin, uint8_t level); #endif现在写一个最简单的Unity测试先验证寄存器为高电平时按键释放的逻辑// test/test_button_driver.c #include unity.h #include button_driver.h #include mock_hal_gpio.h void setUp(void) { } void tearDown(void) { } void test_button_is_pressed_returns_true_when_gpio_read_is_zero(void) { hal_gpio_read_ExpectAndReturn(5, 0); TEST_ASSERT_TRUE(button_is_pressed(5)); } void test_button_is_pressed_returns_false_when_gpio_read_is_nonzero(void) { hal_gpio_read_ExpectAndReturn(5, 1); TEST_ASSERT_FALSE(button_is_pressed(5)); }这里mock_hal_gpio.h就是CMock根据hal_gpio.h自动生成的mock头文件。hal_gpio_read_ExpectAndReturn(5, 0)表示期望调用hal_gpio_read且参数为5同时返回0。如果被测代码没有按预期调用或者参数不匹配测试就会失败。运行测试ceedling test:allCeedling输出大致如下Test test/test_button_driver.c ------------------------------- Generating runner for test_button_driver.c... Compiling test_button_driver_runner.c... Compiling test_button_driver.c... Compiling button_driver.c... Compiling mock_hal_gpio.c... Linking test_button_driver.out... Running test_button_driver.out... test_button_driver.c:15:test_button_is_pressed_returns_true_when_gpio_read_is_zero:PASS test_button_driver.c:22:test_button_is_pressed_returns_false_when_gpio_read_is_nonzero:PASS ------------------------ 2 Tests 0 Failures 0 Ignored OK从输出里能看到它真的生成了runner编译了被测模块编译了mock链接成.out然后直接在本机运行。整个过程不用写一行Makefile。3.4 处理真实嵌入式代码里的特殊语法前面例子里的hal_gpio是理想化的真实嵌入式代码里经常能看到这些写法__attribute__((packed)) typedef struct { uint8_t id; uint16_t len; } message_header_t; __asm void delay_ms(uint32_t ms); #pragma pack(push, 1)这些语法是编译器扩展GCC本地编译时不一定都支持。最常见的处理办法是在公共头文件里做宏映射或者通过编译选项强行替换。我习惯的做法是在support目录下放一个test_support.h把嵌入式编译器特有的关键字统一替换成空实现或标准实现// support/test_support.h #ifndef TEST_SUPPORT_H #define TEST_SUPPORT_H #define __asm(x) #define __attribute__(x) #define __packed #define __weak #define __inline inline #define __interrupt #define __forceinline inline #endif然后在project.yml里加上编译选项让所有测试编译单元都强制包含这个头文件:flags: :test: :compile: :*: - -include - support/test_support.h如果某个模块内部有大段内嵌汇编而且不影响纯逻辑测试更务实的做法是加一个条件编译开关在单元测试构建下跳过汇编部分。比如#ifdef UNIT_TESTING void delay_ms(uint32_t ms) { } #else __asm void delay_ms(uint32_t ms) { ... } #endif这种方式需要开发人员配合但对那些早期没有可测试性设计的代码来说是成本最低的改造路径。3.5 项目里实际跑通的目录配置实际工程里src目录往往同时包含业务代码和底层驱动其中底层驱动可能需要真实的寄存器定义直接编译进测试可执行文件会出问题。这时可以在project.yml里用排除规则把不需要参与测试的目录或文件拿掉:paths: :source: - src/** - !src/bsp/startup.c - !src/bsp/system_clock.cCeedling会在运行时报出找不到这些模块的依赖比如“无法解析system_clock”这时就需要在测试文件里用CMock生成对应mock或者确认这些模块是否真的被依赖链引用了。实际工程落地时我更倾向于从业务逻辑层开始测底层驱动通过CMock隔离。BSP、启动文件、链接脚本这些跟硬件强相关的东西完全不必出现在单测构建里。4. 方案对比与进阶玩法覆盖率、CI和效率优化4.1 Makefile/CMake自维护方案和Ceedling的差别手工方案和Ceedling在维护成本的差异用一个表格可以看得很清楚维度手写Makefile/CMakeCeedling测试文件新增改构建脚本加目标直接丢进test目录mock生成手写或另写生成工具include mock_xxx.h自动生成runner维护手写main逐个注册测试函数自动生成runner头文件依赖追踪自己生成.d或手动管理默认启用辅助依赖与Unity/CMock版本匹配自己控制统一在vendor里命令入口make 自定义targetceedling test:all从表格能看出来Ceedling的核心优势不是性能而是把维护成本降到了最低。4.2 分模块运行重点测试而不是每次都全量跑项目规模大了以后ceedling test:all全量跑一遍可能要好几分钟。如果只是改了一个驱动文件没必要把全部测试都重跑可以用定向测试ceedling test:button_driver这条命令只跑test/test_button_driver.c。也可以运行多个模块ceedling test:button_driver test:led_driver test:protocol_parserCeedling支持通配符但需要注意shell的展开规则建议直接列模块名更可控。4.3 用gcov做覆盖率统计Ceedling有官方的gcov插件开启后可以统计语句覆盖率和分支覆盖率。在project.yml里启用插件:plugins: :enabled: - :gcov编译时编译器需要加--coverage选项Ceedling会在运行测试后自动生成覆盖率报告。跑到覆盖率这一步通常能找出很多没被测试到的分支比如错误处理路径、超时分支。不过覆盖率数字只是一个参考不必为了追求100%而写一堆没价值的测试重点仍然应该放在核心业务逻辑上。4.4 接入持续集成让测试不依赖本地环境单元测试要发挥作用必须跑在干净的CI环境里。Ceedling生成的命令行工具在CI上的表现很友好测试失败时进程会返回非零退出码CI流水线会直接失败。这里给一个GitLab CI的极简示例unit-test: stage: test image: ruby:3.2 script: - gem install ceedling - ceedling test:all artifacts: when: always paths: - build/artifacts/GitHub Actions也类似name: unit-test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - uses: ruby/setup-rubyv1 with: ruby-version: 3.2 - run: gem install ceedling - run: ceedling test:allCI上跑的测试版本和本地保持一致避免出现本地能过、CI过不了的尴尬。Ceedling在这方面的胜出在于命令单一、退出码明确、构建过程无需交互。5. 老项目落地时容易踩的坑5.1 头文件重名导致的include混乱Ceedling在解析include时是全局搜索所有在:paths: :source和:paths: :include里配置的目录。如果工程里有多个同名头文件比如不同目录下各有一个config.hCeedling会把它们全部加入头文件搜索路径导致编译器选择了预期之外的那一个。排查这类问题很费劲最有效的办法是从源头控制确保工程里头文件命名足够唯一或者严格控制include路径。Ceedling还支持在project.yml里通过:paths: :include单独指定头文件搜索目录这些目录的优先级高于src目录下的同名文件。一个经验是用Ceedling时尽量让src下的头文件和源文件去掉公共目录前缀例如把src/driver/i2c/i2c.h改成src/i2c/i2c.h减少路径层级带来的混淆。5.2 CMock处理不了带特殊返回类型的函数CMock虽然能处理大部分常见函数声明但遇到以下情况还是容易出问题返回结构体按值返回函数指针作为参数可变参数函数文件作用域静态函数void*返回类型。对于返回结构体的函数CMock的支持有限尽量在代码层做一层封装把结构体返回值改成指针参数加返回状态码的形式。对于可变参数函数mock起来很麻烦通常只能绕过让测试直接include真实实现。我在一个通信协议栈项目里就碰到过这种情况某个模块的解码函数返回一个结构体CMock生成的mock能编译过但运行时行为不符合预期最后只能把这个函数用面向测试的接口重新包了一层可测试性反而更好了。5.3 边界对齐和字节序问题嵌入式代码经常有字节序转换比如小端与网络字节序的转换函数。这类函数在PC上跑测试时除非测试环境明确是x86小端否则要特别小心。Ceedling默认编译本机运行的测试二进制如果目标是ARM大端那么字节序相关的断言很可能直接翻车。一个可行的方案是让测试用例不依赖具体字节序而是构造对称的输入输出。比如测试payload大小端转换函数时传入一个字节数组期望得到另一个确定的字节数组而不是只验证数值相等。5.4 大量使用全局状态导致测试互相污染嵌入式C代码里全局变量和静态变量很常见。多个测试函数共享同一个模块时如果前一个测试改动了模块内部的静态状态后一个测试可能开始就挂掉。Unity提供了setUp和tearDown在每个测试前后执行应该尽量把被测模块的状态重置干净。如果模块内部静态变量无法从外部重置一个常用技巧是通过条件编译暴露一个测试专用的重置函数#ifdef UNIT_TESTING void test_only_reset_module_state(void) { current_packet_index 0; } #endif这么做虽然有点丑但能解决实际问题。更干净的方案是长期推动代码减少静态状态依赖。5.5 mock严格顺序与测试可读性的平衡CMock的enforce_strict_ordering一旦打开生成的mock会严格按照测试中声明的Expect顺序校验调用顺序。这在某些场景下很有效但也容易让测试变得脆弱比如代码后续加了无关日志调用原本的测试可能莫名失败。我的经验是模块测试阶段不必全局打开严格顺序只在确实需要验证交互顺序的用例里单独控制。比如配置为:cmock: :enforce_strict_ordering: FALSE如果需要严格顺序可以在具体测试文件里用CMock提供的宏手动校验。这样可以保留大部分mock的灵活性又能在关键路径上锁定行为。5.6 build目录处理与.gitignoreCeedling的构建产物默认都放在build/目录下一定要把这个目录加进.gitignore否则每次跑测试都会产生大量diff噪音。常见的忽略规则是build/而vendor/目录里的Unity、CMock源码如果是通过ceedling new自动拉取的通常也不需要提交进版本库可以在CI安装阶段重新拉取。如果公司网络环境差也可以选择固定版本并提交vendor目录只是会让仓库变大看团队取舍。6. 关于Ceedling落地的一点个人体会用Ceedling替换掉手写Makefile之后最明显的改变不是构建快了而是心理负担小了一大截。以前写测试代码之前要犹豫要不要改Makefile、会不会影响别人的构建、我的源文件路径加对了没有。现在基本只需要在test目录下放一个测试文件然后敲一条命令。从团队落地角度看Ceedling最大的价值在于把搭建测试工程的门槛降到了足够低低到普通嵌入式开发同学花十几分钟就能跑起来第一个用例。门槛一旦消失推广TDD或者单元测试文化的难度就小了很多。根据我个人经验如果项目里已经有大量遗留代码也别指望Ceedling能自动把它们变得可测试。它擅长的是把可测试代码的测试过程自动化对不可测试的代码该做的抽象和接口抽取工作一步都不能少。但从建好脚手架到让测试持续跑起来这一步Ceedling确实是我用过的方案里最省心的一个。