Marlin 固件单元测试完全指南:从配置矩阵到 Makefile 一键执行
【免费下载链接】MarlinMarlin is a firmware for RepRap 3D printers optimized for both 8 and 32 bit microcontrollers. Marlin supports all common platforms. Many commercial 3D printers come with Marlin installed. Check with your vendor if you need source code for your specific machine.项目地址: https://gitcode.com/GitHub_Trending/ma/Marlin
Marlin 固件在持续演进中引入了两套自动化测试体系,其中**单元测试(Unit Tests)**专门用于在开发机上验证纯逻辑代码的正确性,为重构与功能迭代提供回归护栏。本篇以仓库中 Marlin/tests/README.md 为骨架,结合test目录、Marlin/tests测试源码、顶层 Makefile 与 ini/native.ini 等实际文件,完整讲解 Marlin 单元测试的架构、配置矩阵、测试编写方式与运行方法。读完本文,你将能够在本机或 Docker 中运行全部单元测试,理解"一个配置一个测试二进制"的设计动机,并学会借助MARLIN_TEST宏为 Marlin 的宏、类型与 G-code 解析逻辑编写自己的回归测试。
Marlin 测试体系总览:Build Tests 与 Unit Tests 的分工
根据仓库根目录下的 test/README.md(Unit Tests 的完整说明文档),Marlin 包含两类自动化测试:
- 构建测试(Build Tests):位于
buildroot/tests目录,为数十种真实板卡与配置组合执行完整编译,用于捕获语法错误与代码构建错误。 - 单元测试(Unit Tests):位于
test目录,用于捕获实现逻辑错误,即运行时不涉及硬件的功能行为是否正确。
这两者的分工可以从目录结构直接看出:buildroot/tests下按板卡/机型(如mega2560、STM32F103RC_btt、linux_native等)存放编译测试用的 INI 配置;而test目录则专注于在 Linux 本机以BOARD_SIMULATED方式运行逻辑测试。Marlin/tests/README.md明确说明:Marlin/tests目录下的测试文件由<root>/test目录构建出的单元测试二进制执行。
单元测试的价值在于:它可以在任意开发者本地机器以及通用的 CI 机器上运行,无需连接真实打印板。相比之下,在目标控制器上直接跑单元测试既不现实也未实现——那需要专门的测试台架,远不如在开发/构建机上测试来得通用。
从源码结构看,Marlin 目前采用的是 PlatformIO 单元测试框架 + Unity 断言库的组合,这从 ini/native.ini 中的
lib_deps = throwtheswitch/Unity@^2.6.0可以得到直接印证。
单元测试能做什么、不能做什么
测试边界
单元测试可以验证单个函数或整个特性的逻辑,前提是该逻辑不依赖真实硬件。例如:
- 可以测试 G-code 命令是否正确解析、是否产生预期的状态变更;
- 但无法直接测试"G-code 是否触发了限位开关或断料传感器"——除非额外增加一层引脚模拟。
回归护栏的意义
单元测试最典型的价值在编写测试的初期就能发现大量问题,此后则持续为代码库(尤其是核心类与核心类型)提供防回归能力,对重构尤其有用。原文档 FAQ 中的关键结论值得记住:
- 写单元测试是否很费工夫?是的,对未按可测试性设计的存量代码尤其如此。需要结合实际判断在何处、以什么粒度实施测试。投入虽大,但在防止回归、故障定位上的回报显著。
- 会不会让重构更难?既有会(直接引用被测代码的测试需要同步修改),也有不会——测试反过来给重构提供了"机制仍然按预期工作"的保障。
- 如何调试失败的单元测试?没有现成捷径。理论上可以通过 PlatformIO 交互式调试,但配置起来需要一定创造性。好在单元测试通常极小,即使不交互调试,也能很快定位到问题根源附近。
单元测试架构:一个配置对应一个测试二进制
Marlin 的单元测试架构建立在两个关键事实上:
- Marlin 只编译配置所要求的代码(条件编译贯穿整个源码,如
#if ENABLED(FEATURE))。 - 因此,任何配置变化都必须重新生成一个独立的测试二进制。
于是架构呈现出清晰的四步流程(源自 test/README.md):
test目录存放一组 INI 配置文件(config.ini),每个文件代表一套用于单元测试的配置选项组合;所有适用的单元测试会为其中每一个配置各跑一遍。Marlin/tests目录存放全部单元测试的 C++ 源码。测试通过 Marlin 宏(ENABLED(feature)、TERN(FEATURE, A, B)等)决定注册哪些测试、改变测试行为。linux_native_test这个 PlatformIO 环境指定了一个脚本,负责收集Marlin/tests下全部测试,并把它们加入 PlatformIO 的测试目标列表。- 测试最终由顶层 Makefile 的
unit-test-all-local或unit-test-all-local-docker命令构建并执行。
深入测试基础设施:test目录与 Unity 主程序
单元测试注册机制:MARLIN_TEST宏
test/unit_tests.h 定义了测试注册的核心:一个MarlinTest类和一个MARLIN_TEST(SUITE, NAME)宏。宏会生成一个继承自MarlinTest的类,在静态初始化期把测试实例注册进全局测试链表,测试名格式为SUITE___NAME(三个下划线分隔)。用法形如:
MARLIN_TEST(test_suite_name, test_name) { // Test body }宏内部自动完成类定义、实例化与TestBody静态函数的声明/定义,__FILE__与__LINE__会被记录,供 Unity 报告失败位置。
主程序:unit_tests.cpp
test/unit_tests.cpp 为所有单元测试二进制提供唯一的main():它遍历全局注册链表中的每个MarlinTest并通过 Unity 执行,结构如下:
int main(int argc, char **argv) { UNITY_BEGIN(); run_all_marlin_tests(); UNITY_END(); return 0; }MarlinTest::run()会把file(源文件名)和line交给UnityDefaultTestRun,从而让断言失败信息精确指向源码位置。同时unit_tests.h无条件包含src/inc/MarlinConfig.h,确保所有测试都能看到当前配置宏——这正是"同一份测试源码在不同配置下行为不同"的前提。
测试源码文件
Marlin/tests下的测试按主题组织(对应MARLIN_TEST的 suite 参数):
| 文件 | 覆盖主题 | 关键验证点 |
|---|---|---|
| Marlin/tests/core/test_macros.cpp | 核心宏 | 位操作、几何/数值宏、配置宏、数组与参数展开宏 |
| Marlin/tests/core/test_types.cpp | 核心类型 | XYval/XYZval/XYZEval、Flags<N>、字符串与毫秒宏 |
| Marlin/tests/gcode/test_gcode.cpp | G-code 解析 | 命令号上限、溢出拒绝、参数seen检测 |
| Marlin/tests/feature/test_runout.cpp | 断料传感器 | poll_runout_states默认状态位 |
**宏测试(test_macros.cpp)**是很好的入门样本,它直接验证src/core/macros.h中的行为,例如位操作宏:
MARLIN_TEST(macros_bitwise_8, SBI) { uint8_t n; n = 0x00; SBI(n, 0); TEST_ASSERT_EQUAL(0x01, n); // LSB n = 0x00; SBI(n, 7); TEST_ASSERT_EQUAL(0x80, n); // MSB n = 0x00; SBI(n, 3); TEST_ASSERT_EQUAL(0x08, n); // 中间位 }配置宏测试(如ENABLED/DISABLED/ANY/ALL/NONE/COUNT_ENABLED/MANY/TERN系列、OPTITEM/OPTARG/OPTCODE)验证了 Marlin 条件编译体系本身的正确性,这是整个固件可配置性的根基。
**类型测试(test_types.cpp)**覆盖src/core/types.h中的XYval/XYZval/XYZEval(bool 转换、reset/set、magnitude、small/large、四则运算、ABS/ROUNDL/reciprocal)、Flags<N>(1/8/16/32/64/302 位)、AxisFlags/AxisBits,以及 src/core/mstring.h 的MString/SString与 src/core/millis_t.h 的PENDING/ELAPSED(注意测试注释中提及 32 位毫秒计数的环绕语义)。其中small/large对负数语义的测试甚至保留了BUG?注释,体现了测试即文档的特点。
**G-code 解析测试(test_gcode.cpp)**直接驱动src/gcode/parser.h:
MARLIN_TEST(gcode, parse_g_code_number_limit) { char max_command[] = "G65535"; parser.parse(max_command); TEST_ASSERT_TRUE(parser.is_command('G', UINT16_MAX)); // 上限合法 char overflow_command[] = "G65536"; parser.parse(overflow_command); TEST_ASSERT_EQUAL('?', parser.command_letter); // 溢出被拒绝 }parse_g1_xz与parse_g1_nxz则验证带 N 行号前缀时,parser.seen('X')/seen('Z')等参数检测仍正确工作。
**断料测试(test_runout.cpp)**演示了宏驱动的条件注册——整个测试体被#if ENABLED(FILAMENT_RUNOUT_SENSOR)包裹,只有启用该特性时才编译;测试断言FilamentSensorBase的poll_runout_states()默认返回"每个挤出器一位"的位掩码(~(~0U << NUM_RUNOUT_SENSORS))。
配置矩阵:test目录下的 INI 文件
test目录共三个配置文件,构成单元测试的配置矩阵。文件命名带数字前缀(001-、002-、003-),由收集脚本剥去前缀后作为测试目标名。
001-default.ini —— 最小基线配置
test/001-default.ini 刻意保持"除主板外为空",仅声明使用基础配置并强制模拟主板:
[config:base] ini_use_config = base # Unit tests must use BOARD_SIMULATED to run natively in Linux motherboard = BOARD_SIMULATED关键约定:单元测试必须使用BOARD_SIMULATED才能在 Linux 上原生运行。文件注释还强调:如果测试需要改动配置,应当新增独立配置文件而不是修改本文件。
002-extruders_1_runout.ini —— 单挤出器 + 断料
test/002-extruders_1_runout.ini 针对单挤出器断料场景启用了完整功能链:
[config:base] ini_use_config = base motherboard = BOARD_SIMULATED filament_runout_sensor = on fil_runout_pin = 4 # dummy advanced_pause_feature = on emergency_parser = on nozzle_park_feature = on # Option to support testing parsing with parentheses comments enabled paren_comments = on注意fil_runout_pin = 4标注为dummy——模拟主板上该引脚仅用于通过健全性检查(Sanity Check),并非真实硬件引脚。额外启用paren_comments是为了覆盖带括号注释的 G-code 解析路径(对应test_gcode.cpp中process_parsed_command等用例的运行环境)。
003-extruders_3_runout.ini —— 三挤出器 + 断料
test/003-extruders_3_runout.ini 将矩阵扩展到三个挤出器、三个断料传感器:
[config:base] ini_use_config = base motherboard = BOARD_SIMULATED extruders = 3 temp_sensor_1 = 1 temp_sensor_2 = 1 num_runout_sensors = 3 filament_runout_sensor = on fil_runout_pin = 4 # dummy fil_runout2_pin = 4 # dummy fil_runout3_pin = 4 # dummy filament_runout_script = "M600 %%c" advanced_pause_feature = on emergency_parser = on nozzle_park_feature = on其中filament_runout_script = "M600 %%c"演示了 INI 转义——%%最终解析为字面量%,即断料触发时执行的 M600 脚本带挤出器占位符。NUM_RUNOUT_SENSORS = 3会直接影响test_runout.cpp中断料状态位掩码的期望值,这正是"同一测试在不同配置下验证不同行为"的实例。
这组配置与 buildroot/tests/linux_native/config-01.ini 同属 Linux 原生测试阵营,但前者服务于单元测试,后者服务于构建测试,二者在 buildroot/tests 与 test 两个目录中分离存放。
运行单元测试:Makefile 命令
Marlin/tests/README.md明确指向顶层 Makefile。相关的核心目标如下(见 Makefile):
| 命令 | 作用 | 底层实现 |
|---|---|---|
make unit-test-all-local | 本机运行全部单元测试配置 | platformio run -t test-marlin -e linux_native_test |
make unit-test-all-local-docker | Docker 中运行全部单元测试 | 容器内执行make unit-test-all-local |
make unit-test-single-local | 只跑单个配置的单元测试 | platformio run -t marlin_$(UNIT_TEST_CONFIG) -e linux_native_test |
make unit-test-single-local-docker | Docker 中跑单个配置 | 容器内执行unit-test-single-local |
常用变量
UNIT_TEST_CONFIG:指定要运行的配置名(不含数字前缀),默认值为default,即对应001-default.ini。例如要跑断料配置可执行:make unit-test-single-local UNIT_TEST_CONFIG=extruders_1_runoutVERBOSE_PLATFORMIO:置任意值可输出完整的 PlatformIO 日志。GIT_RESET_HARD:供 CI 使用,会重置本地改动(Makefile 中明确警告"这会撤销你所有的改动"),本地调试慎用。
Docker 方式
若本机缺少 PlatformIO 环境,可以先用make setup-local-docker构建marlin-dev镜像(基于 docker/Dockerfile,带用户名/UID/GID 参数保证容器内文件权限一致),然后执行:
make unit-test-all-local-dockerMakefile 会在镜像不存在时自动先构建(unit-test-single-local-docker、unit-test-all-local-docker均包含镜像检查逻辑)。
PlatformIO 层面:linux_native_test环境与测试收集脚本
环境定义
ini/native.ini 中[env:linux_native_test]继承自[env:linux_native],其关键点:
- 平台为
native(纯本机编译,无 Arduino 框架),编译宏包含-D__PLAT_LINUX__、-std=gnu++17,源码过滤为+<src/HAL/LINUX>(Linux 硬件抽象层); - 通过
extra_scripts的post脚本挂载buildroot/share/PlatformIO/scripts/collect-code-tests.py; build_src_filter追加+<tests>,把test目录纳入构建;- 依赖
throwtheswitch/Unity@^2.6.0提供断言框架,并开启-Werror把警告视为错误。
测试收集脚本
buildroot/share/PlatformIO/scripts/collect-code-tests.py 是整个"配置矩阵"机制的实现核心:
- 扫描
./test/*.ini,用正则^\d+-|\.ini$剥离数字前缀得到配置名(如default、extruders_1_runout、extruders_3_runout); - 为每个配置注册自定义 PIO 目标
marlin_<name>,其动作序列为:restore_configs → cp -f <ini> ./Marlin/config.ini → 运行 configuration.py 生成配置 → platformio test -e linux_native_test -f <name> → restore_configs即把对应 INI 复制为
Marlin/config.ini、重新生成配置、再对指定的测试过滤器运行platformio test,跑完恢复原配置; - 额外注册聚合目标
test-marlin,依次执行所有marlin_<name>,即 Makefile 中unit-test-all-local所触发的目标。
因此,make unit-test-all-local的实际效果等价于:对test目录下每一个 INI 配置,分别构建一个独立的linux_native_test二进制并执行其中所有注册的测试——这正对应Marlin/tests/README.md所述"收集所有测试文件并编译进多个 PlatformIO 测试二进制"。
常见问题与调试建议
- 如何判断某个测试是否适用于当前配置?观察测试源码是否被宏包裹。
test_runout.cpp用#if ENABLED(FILAMENT_RUNOUT_SENSOR)说明它只在启用断料传感器的配置(如extruders_1_runout/extruders_3_runout)中注册;而test_macros.cpp、test_types.cpp中的大部分用例无硬件依赖,会在所有配置下执行。 - 新增一个测试配置矩阵?按
test目录的命名规范新建NNN-<name>.ini(复用[config:base]并设置motherboard = BOARD_SIMULATED),收集脚本会自动发现并注册对应目标——脚本本身不可修改,但新增配置文件即可生效(仓库为只读,本说明仅供理解机制)。 - 失败定位:由于每个测试都记录
__FILE__/__LINE__,Unity 会直接报告失败的源文件与行号;MARLIN_TEST命名中的 suite 前缀(如macros_bitwise_8、types、gcode、runout)也便于按主题筛选。 - 环境差异:
tests-all-local(构建测试)在 macOS 上会跳过linux_native目标(见 Makefile 中uname = Darwin的判断),而单元测试目标不在此限制内;此外 Makefile 要求 Python 3(缺失或版本不符会在解析阶段直接报错)。
结语
从Marlin/tests/README.md这短短五行的入口出发,可以串起 Marlin 单元测试的完整链路:test目录的 INI 配置矩阵定义"测什么配置",Marlin/tests的 C++ 源码定义"测什么逻辑",collect-code-tests.py 与 linux_native_test 环境负责"如何构建多个二进制",而顶层 Makefile 则是统一入口。理解这条链路后,无论是复现 CI 失败、为某个新功能补回归测试,还是扩展配置覆盖,都有据可循、有命令可用。建议进一步阅读 test/README.md 获取测试哲学的完整阐述,并结合 Marlin/tests/core/test_macros.cpp 与 Marlin/tests/gcode/test_gcode.cpp 等真实用例学习断言写法。
【免费下载链接】MarlinMarlin is a firmware for RepRap 3D printers optimized for both 8 and 32 bit microcontrollers. Marlin supports all common platforms. Many commercial 3D printers come with Marlin installed. Check with your vendor if you need source code for your specific machine.项目地址: https://gitcode.com/GitHub_Trending/ma/Marlin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考