ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Ceedling:嵌入式C语言单元测试框架的安装与入门实践

Ceedling:嵌入式C语言单元测试框架的安装与入门实践 1. Ceedling 到底解决了什么问题做嵌入式 C 语言开发的同学多半都有过这样的经历项目代码写到几千行之后想给核心模块加几个测试却发现无从下手。目标板上跑测试太麻烦每次都要烧录、复位、看串口日志PC 上想编译又发现代码和硬件耦合太深一编译就报缺头文件。最后只能靠 printf 大法人肉验证改一处代码就要把所有相关功能重新手点一遍效率和安全感都谈不上。我第一次接触 Ceedling 也是在这种背景下。当时手上有个通信协议解析的模块逻辑复杂、边界条件多光靠肉眼检查实在不放心于是开始找 C 语言的单元测试方案。对比了 Check、CUnit、Unity 这类测试框架之后我最终选了 Ceedling。原因很直接它不只是单个框架而是把测试框架Unity、Mock 生成工具CMock、异常处理库CException整合成了完整工具链再加上 Ruby 的自动化构建能力安装完就能用配置好就能跑非常适合嵌入式 C 项目的测试场景。Ceedling 能解决的核心痛点有这么几个在 PC 上直接编译运行被测代码不需要依赖目标板硬件自动扫描源文件和测试文件省去手写 Makefile 的工作量自动生成函数级别的 Mock轻松隔离外部依赖比如 HAL 库、驱动层自带测试报告输出可以和持续集成环境对接如果你是做嵌入式开发、系统级 C 开发或者维护一个历史包袱很重的 C 项目Ceedling 是一个值得认真了解的测试基础设施。这篇先讲安装、工程初始化和第一个测试用例的编写运行让整个流程先跑通。2. 理解 Ceedling 的架构设计2.1 三个核心组件的关系Ceedling 不是从零写的一套新框架它是把三样东西粘合在一起再用 RakeRuby 的构建工具统一调度。明白这三者的分工后面配置和使用就顺理成章了。Unity 是测试执行的核心提供 TEST_ASSERT 系列的断言宏以及测试用例的运行入口。如果说测试是一个裁判Unity 就是那个吹哨子的人——它负责判断某个条件成立与否把结果汇报给测试运行器。Unity 是纯 C 实现的没有任何平台相关代码所以可以在 PC 上编译运行也可以交叉编译到目标板。CMock 负责生成 Mock 函数。假设被测试的模块调用了外部函数hal_uart_send()在单元测试里我们并不关心这个函数的真实行为只关心“它有没有被调用”“参数传得对不对”这时就要给hal_uart_send()做一个替身。CMock 会根据头文件里的函数声明自动生成替身并记录每次调用的参数供测试代码验证。手写这些 Mock 是非常枯燥且容易出错的工作CMock 把它自动化了省下的时间相当可观。CException 处理的是异常流程。C 语言没有 try-catch但有些模块会有“出错就跳转”的流程控制需求CException 提供了一套 ANSI C 兼容的异常处理机制。不是所有项目都用它但当你测到类似解析协议出现非法帧头需要中断处理的场景它就有用了。2.2 它和 Makefile 方式的核心差异传统做法的痛点在于你维护了一个 Makefile 或者 CMakeLists手动指定源文件、头文件路径、编译选项还要自己写测试入口。而 Ceedling 的方案是约定大于配置——你只要把文件放在约定位置源文件丢进src/测试文件丢进test/Ceedling 会自动完成以下工作扫描src/目录获取被测源文件列表扫描test/目录获取测试文件列表解析被测源文件的#include识别外部依赖根据依赖生成对应的 Mock 文件编译所有源文件、Mock 文件和测试文件链接成测试可执行文件运行测试生成报告这整个过程通过 Ruby 的 Rake 任务串联。第一次跑的时候会有点慢因为要生成 Mock 和编译之后增量编译会快很多。不需要手写任何构建脚本这大幅降低了入门门槛。3. 安装步骤与踩坑记录3.1 安装 Ruby 环境Ceedling 是 Ruby 写的所以第一步是安装 Ruby。这里要额外说一声Ceedling 对 Ruby 版本有要求Ruby 2.4 以上基本没问题我用的 3.x 版本跑目前最新的 Ceedling 0.31.x 也完全正常。Windows去 RubyInstaller 网站下载安装包安装时建议勾选“Add Ruby executables to your PATH”选项省去手动配置环境变量的麻烦。装完打开命令提示符执行ruby -v验证版本。Linux以 Ubuntu 为例执行sudo apt install ruby-full即可。安装完同样用ruby -v验证。macOS系统自带的 Ruby 版本比较老建议用 Homebrew 安装brew install ruby。安装完成之后还需要确认gem命令可用。gem 是 Ruby 的包管理器类似 apt 对 Ubuntu 的角色。在终端执行gem -v如果输出一个版本号说明 gem 环境正常。3.2 用 gem 安装 Ceedling环境就绪之后安装 Ceedling 只需要一行命令gem install ceedling正常情况下gem 会从 RubyGems 仓库拉取 ceedling 以及它的依赖包rake、thor 等一两分钟内完成安装。安装完成后执行ceedling version如果输出了类似Ceedling 0.31.0的信息说明安装成功。这里说两个国内开发者经常踩的坑第一个是 gem 下载超时或报 SSL 错误。gem 默认源在国外网络不稳定时安装会失败。可以更换为 Ruby 中国的镜像源gem sources --add https://gems.ruby-china.com/ --remove https://rubygems.org/ gem sources -l确认输出里只有一个https://gems.ruby-china.com/源之后再执行gem install ceedling。第二个是安装完成后ceedling命令找不到。这种情况常见于 Windows 环境Ruby 安装时没有把 bin 目录加进 PATH。解决方法是找到 Ruby 安装目录下的bin文件夹手动添加到系统环境变量里。添加完之后重新打开终端命令就能识别了。3.3 验证安装是否可用的快速方法装完之后建议快速验证一下工具链是否完整。找个临时目录执行ceedling new demo_project cd demo_project ceedling test:all如果看到类似TEST PASSED的提示说明整个工具链已经跑通了。ceedling new会生成一个最小可用的示例工程里面自带测试代码可以直接运行。到这里安装部分结束。整个过程本质上就是“装 Ruby、装 Ceedling、验证”没有太多复杂的地方。真正需要花心思的是项目初始化和测试代码的结构。4. 初始化工程与目录结构解析4.1 ceedling new 生成了什么执行ceedling new demo_project之后会在当前目录下创建demo_project文件夹内部结构如下demo_project/ ├── project.yml ├── src/ │ └── main.c ├── test/ │ └── test_main.c ├── test/support/ │ └── (可能为空或包含头文件) ├── lib/ │ └── (第三方库目录) ├── build/ │ └── (运行测试后生成的构建产物目录) └── vendor/ └── (Unity、CMock、CException 的源码)值得关注的是vendor/目录。Ceedling 在创建工程时会把 Unity、CMock、CException 的源码拷贝到本地 vendor 目录后续构建时直接从本地引用并不需要额外联网下载这点对离线环境特别友好。src/main.c里默认是一段很简单的代码test/test_main.c里是对应的测试。这个模板就是最好的入门教材建议先跑一遍再动手改。src/和test/就是后面主要打交道的两个目录——被测源文件放src/测试文件放test/test/support/通常放测试辅助头文件。4.2 project.yml 里最关键的几个配置项project.yml是 Ceedling 工程的唯一配置文件采用 YAML 格式。打开它之后会发现内容很精简默认的配置可能只有寥寥几行。新手不需要把所有配置都搞懂先关注下面几个核心项就能开始用:project: :use_exceptions: FALSE :use_test_preprocessor: FALSE :use_auxiliary_dependencies: TRUE :build_root: build第一项:use_exceptions默认是 FALSE如果项目用到 CException 就改成 TRUE。第二项:use_test_preprocessor默认是 FALSE如果为了 Mock 非侵入式地替换某些宏需要开启预处理。第三项:use_auxiliary_dependencies: TRUE建议保留它可以实现源文件的依赖追踪改动某个源文件后只重编受影响的部分增量编译效率高很多。最后:build_root是构建产物根目录保持默认即可。还有一块值得关注的是路径和工具链配置:extension: :header: .h :source: .c :tools: :test_compiler: :executable: gcc :arguments: - -c - ${1} - -I$: COLLECTION_PATHS_TEST_TOOLCHAIN_INCLUDE - -o${2} :test_linker: :executable: gcc :arguments: - ${1} - -o${2} - -lm如果你有多套交叉编译工具链需求比如要测的是 ARM 平台专用代码可以在这里替换成arm-none-eabi-gcc。不过初学者建议先用 gcc 和 PC 环境跑通流程交叉编译测试放到后面再说。4.3 build 目录里藏了哪些重要产物执行过一次测试之后build/目录里会出现很多文件。我刚开始用时也被这一堆生成物搞懵过后来慢慢摸清了它的门道。build/test/下存放的是中间编译产物包括.o目标文件和.d依赖文件不需要关心细节。值得关注的是build/artifacts/目录这里存放了测试报告。默认情况下是build/artifacts/test/ ├── results/ │ └── test_main.txt └── test_main.exetest_main.txt是测试结果的文本报告CI 集成时会解析这个文件。如果安装了对应的插件还可以生成test_results.html和test_results.xml格式的报告。了解 build 目录的结构很有用因为排查编译连接失败问题时经常要来这里看具体的编译命令和中间产物。5. 写第一个测试用例并跑通5.1 测试文件的命名与函数格式Ceedling 对测试文件的命名有明确约定test_前缀加被测模块名。这种约定不是随意设计的Ceedling 靠文件名识别哪个测试对应哪个被测模块别乱改。关键点在于测试函数名的格式所有测试用例函数必须以test_开头注意是函数名不是文件名并且返回类型为void、无参数。Ceedling 会在生成的测试入口函数中通过函数指针自动调用这些测试函数所以命名规则必须严格遵守void test_解析正常帧返回成功(void) { TEST_ASSERT_EQUAL(0, parse_frame(frame_buf, result)); }如果函数没有以test_开头Ceedling 会把它当成普通辅助函数直接忽略测试执行时不会调用它。这个坑我踩过当时一个精心设计的测试用例写上去了测试结果却显示只跑了几个用例排查了半天才发现是函数命名问题。测试文件还默认包含两个固定部分被测模块的头文件以及 Unity 的头文件。最简测试文件长这样#include unity.h #include parser.h void setUp(void) { } void tearDown(void) { } void test_解析一个合法数据帧(void) { TEST_ASSERT_EQUAL(0, parse_frame(valid_frame, 20)); }setUp和tearDown是每个用例执行前和执行后自动调用的钩子函数可以在这里做资源初始化和清理。暂时用不上就留空但函数名必须保留。5.2 测试一个真实的协议解析函数理论说这么多不如实际写一个。假设我要测一个串口协议解析模块功能是从数据帧里解析出设备地址、命令字和数据负载。被测函数原型如下放在src/frame_parser.c里int frame_parse(const uint8_t *frame, uint16_t len, frame_info_t *info);frame_info_t包含device_addr、cmd、data和data_len字段。解析成功返回 0帧头错误返回 -1长度不够返回 -2校验失败返回 -3。测试文件test/test_frame_parser.c的内容大致如下#include unity.h #include frame_parser.h static uint8_t valid_frame[8] {0xAA, 0x55, 0x01, 0x10, 0xDE, 0xAD, 0xBE, 0xEF}; static frame_info_t info; void setUp(void) { memset(info, 0, sizeof(info)); } void tearDown(void) { } void test_正常帧解析成功(void) { TEST_ASSERT_EQUAL(0, frame_parse(valid_frame, 8, info)); TEST_ASSERT_EQUAL(0x01, info.device_addr); TEST_ASSERT_EQUAL(0x10, info.cmd); TEST_ASSERT_EQUAL(4, info.data_len); } void test_帧长度不足时返回错误(void) { TEST_ASSERT_EQUAL(-2, frame_parse(valid_frame, 3, info)); }这里用到了几个 Unity 断言宏TEST_ASSERT_EQUAL比较两个整数是否相等TEST_ASSERT_EQUAL_UINT8等可以针对不同位宽做精确比较TEST_ASSERT_NULL、TEST_ASSERT_NOT_NULL检查指针TEST_ASSERT_BITS检查指定位域。实际写测试时按需选择即可。5.3 运行测试与结果报告测试文件写好后执行ceedling test:allCeedling 会重新扫描文件、生成 Mock、编译并运行测试。输出结果大致长这样Test test_frame_parser.c -------------------------- TEST PASSED ----------------------- OVERALL TEST SUMMARY TESTED: 2 PASSED: 2 FAILED: 0 IGNORED: 0也可以只运行单个测试文件加快调试速度ceedling test:test_frame_parser如果只想跑某一个具体的用例可以在test:test_frame_parser后通过环境变量指定行号不过日常调试仍然推荐用插件模式Terminal 插件来跑快捷方便。运行通过之后去看看build/artifacts/test/目录里面会有test_frame_parser.txt文件保存了同样的测试报告。这个文件就是给后续自动化流程用的在做 CI 流水线时非常关键。6. 常见问题与配置优化6.1 各种典型报错的处理思路使用 Ceedling 的过程中我积累了一些高频问题的排查经验这里整理成速查表希望帮你少走弯路。报错信息或现象可能原因解决方案gcc: command not found系统没装 gcc 编译器Linux 执行sudo apt install build-essentialWindows 安装 MinGW 并加入 PATHNo such file or directory - test/test_xxx.c测试文件不在 test 目录或文件名不规范确认测试文件以test_开头放在test/目录undefined reference to xxx_mock调用了外部函数但没有包含对应 Mock 头文件在被测源文件里#include mock_xxx.hMock 头文件按mock_原头文件名.h命名TEST_FAILED但没有明确输出差异断言宏比较类型不匹配确认TEST_ASSERT_EQUAL两边参数类型一致必要时用_UINT8、_HEX32等强类型断言测试报告显示用例数量远少于实际测试函数命名未以test_开头检查所有测试函数名必须严格以test_开头实际调试中报错信息的末尾通常会给出具体文件与行号。Ceedling 的错误提示整体还算友好顺着文件和行号定位就能找到问题。一个容易混淆的点undefined reference to xxx_mock并不一定说明 Mock 头文件缺失也可能是被测源文件里调用了某个函数但该函数所在的头文件并没有被 Ceedling 识别为需要 Mock 的对象。这时要确认被测源文件是否包含了对应声明头文件通过#includeCeedling 是解析#include来生成 Mock 的。6.2 让输出更适合人看的插件配置Ceedling 默认的测试输出已经能用但输出信息比较零散查看具体某个用例失败原因时不太直观。这时可以启用朗读插件优化输出:plugins: :load_paths: - #{Ceedling.load_path} :enabled: - report_tests_bash_verbose这个插件会在控制台打印更详细的测试信息包括每个用例的执行时间、断言失败时的具体值和期望值出错定位效率高很多。类似的插件还有report_tests_bash_stdout、module_generator自动生成测试文件模板、warnings_report统计编译警告等按需开启即可。在project.yml里加入这些插件后重新跑测试输出会变得清晰很多。建议把前面例子中的断言宏和这个插件配置结合使用调试体验接近商业级 IDE。6.3 和现有代码工程的整合方式很多读者可能已经在维护已有的代码工程想引入 Ceedling 但担心改动太大。实际上 Ceedling 的设计很好地考虑了这个问题不需要把整个仓库结构推翻重来。第一种方案是把现有源代码目录链接到src/路径。在project.yml中可以通过:paths: :source: - src/**来指定被测源文件的实际位置。比如我的一个旧工程源码在lib/protocol/目录只需要配置:paths: :source: - lib/protocol/** - src/** :test: - test/**这里要留意**递归匹配子目录的写法。指定了路径之后Ceedling 会把lib/protocol/下的源文件视为被测对象并自动扫描其中的依赖。第二种方案是设置统一的基础路径把多个模块的源码都加进来:paths: :test: - tests/** :support: - tests/support/** :include: - include/**关键点是Ceedling 的:include路径会被加入编译器的头文件搜索路径如果你的模块头文件不在默认位置需要在这里补上。这个配置解决了很多旧代码工程“头文件到处放”的尴尬局面。第三种方案需要小心理顺依赖关系把平台相关的底层驱动排除在编译对象之外只编译纯逻辑模块。这时可以用:paths: :source: - src/**: - src/drivers/**这样的排除语法**后面的-表示排除。比如- src/drivers/**就会排除 drivers 子目录下的所有文件。6.4 关于 Ceedling 使用习惯的几点个人心得用了 Ceedling 大半年之后我形成了一套自己的工作节奏。需要提前说明的是这些是基于我个人的实践体会设备型号、系统环境不同最佳实践也可能有差异。第一点尽量保持“一小步、一大步”。第一次接入 Ceedling 时不要试图一天内把所有模块都加上测试。先挑一个逻辑复杂、边界条件多的纯计算模块把环境跑通、积累信心再逐步扩展。一下子铺开太多模块排查问题时往往分不清是环境问题还是测试代码问题。第二点测试代码也是代码要重视可读性。很多人潜意识里觉得测试代码不重要随手一写就行。但当你三个月后回来看一个测试用例发现根本看不懂它测试了什么逻辑就明白可维护性的价值了。测试用例的命名尽量描述清楚测试意图“test_一帧超过最大长度时返回错误”比“test_3”好看太多对关键测试数据形成直接用宏定义。第三点谨慎处理编译警告。在单元测试中编译警告可能掩盖真实问题。Ceedling 的warnings_report插件可以统计所有编译警告数量并显示位置。建议把被测模块的编译警告清零否则测试通过了也不代表代码质量过关。尤其是未初始化变量、隐式类型转换这类编译器警告往往是运行期麻烦的前兆。最后一点持续集成的接入时机。如果项目在做 CI建议在接入 Ceedling 的初期就把ceedling test:all加进流水线。测试自动化真正发挥价值就是在每笔提交都自动跑一遍用例。不一定要等所有模块都覆盖了才接哪怕只有几个模块也比完全没有强因为测试跑起来之后才会倒逼自己保持代码可测性。回到开头说的那个协议解析模块我现在的做法已经变成了每次修改解析逻辑都会在本地敲一遍ceedling test:test_frame_parser确认用例全过才提交代码。虽然不能百分之百保证没有缺陷但那种靠 printf 眼测排障的日子总算彻底结束了。这篇先到这里。下一部分可以继续聊 CMock 的详细用法——如何处理带指针参数的函数、嵌套结构体、函数指针等复杂场景以及怎么配置 Mock 框架来应对“头文件里有编译器内置函数声明”这种特殊情况。希望这篇安装和初体验的记录能让你少走一些我当初绕的弯路。
返回列表