测试:embedded-test 与硬件在环

本篇是《Ariel OS 深度实战:Rust 库式 IoT 操作系统全解析》系列第 28 篇。上一篇:持久化存储,下一篇:原生目标、基准测试与 Ferrocene,目录见文末。

测试:embedded-test 与硬件在环

导读

前 27 篇的每个功能,我们都用"烧录 + 看日志"验证。这在教学里够用,在工程里远远不够:回归测试要可重复、可自动判定、可批量执行,人工盯串口既慢又不可靠。本篇讲 Ariel OS 的测试体系:embedded-test 框架(在真实硬件上运行 #[test] 函数)、laze 的 test 任务(编译-烧录-运行-判定一条龙)、以及仓库 tests/ 目录里 16 个官方测试构成的"硬件在环(HIL)"样板。读完本篇,你的应用可以拥有与 OS 本身同款的测试基建。

测试嵌入式代码有个根本困难:cargo test 的默认 harness(test harness 线程、进程退出码、结果收集)假设一个完整的 std 环境,MCU 上全都不存在。embedded-test 的解法是把 harness 替换掉:harness = false + 自定义入口,测试运行在目标硬件上,结果经调试通道(probe-rs/RTT)回传主机,由 laze 判定。Ariel OS 侧的增值是:测试前系统完成完整初始化(日志、网络、存储可用),测试不是"裸机上的函数调用",而是"系统环境里的集成验证"——这正是 embedded-test 集成进 ariel-os-rt 启动流程的意义。

版本:embedded-test 0.7.1(Ariel OS 0.5.0 起适配,feature 名从 ariel-os-09 演进到 0.6.0 的 ariel-os-010);laze test 任务与 embedded-test/embedded-test-only 模块。

为什么嵌入式测试要在硬件上跑

先建立一个分类框架。嵌入式 Rust 代码的测试分三层:

纯逻辑层:协议解析、状态机、算法——输入输出都是普通数据,直接在主机上 cargo test(不需要硬件,跑得飞快)。这类测试嵌入式开发者经常忘记写,其实它们占比可以很高(写存储篇的"类型配对封装"就该有主机测试)。

硬件依赖层:GPIO 行为、I2C 时序、多线程抢占、中断延迟——主机上没有对应物,mock 出来的测试只能验证 mock 本身。这层必须上硬件。

系统集成层:日志初始化顺序、网络栈 bring-up、存储与 CoAP 的联动——依赖整个 OS 的初始化图,同样必须上硬件。

embedded-test 覆盖后两层。它值得专门框架而非"main 里写断言"的原因有三:隔离(每个 #[test] 独立运行、#[init] 提供每测前置、#[ignore] 可跳过——与 cargo test 心智一致);结果结构化(测试名、通过/失败经调试通道回传,laze 可判定退出码——CI 可消费);系统就绪(Ariel OS 集成点保证测试函数运行时日志/网络/存储已初始化,可以直接测"系统级"行为)。第三点是 Ariel OS 集成的独特价值:ariel-os-rt 为测试注册了专用线程(#[ariel_os_macros::thread(autostart, stacksize = 16384)] fn embedded_test_thread())——测试跑在一个 16 KiB 栈的线程里,而不是裸的 reset handler。

硬件在环(HIL)指的则是工程形态:CI 机器通过探针控制真实板卡,自动执行上述测试。Ariel OS 的 ROADMAP 把"CI 增加 hardware-in-the-loop"列为方向,仓库的 tests/ 目录就是这套流程的测试资产——16 个测试 crate 覆盖 GPIO、中断、I2C/SPI/UART 回环、线程互斥与优先级、随机数 getrandom 接口、CoAP 等。laze build test 对单块板是"本地 HIL";接上 CI 轮询多块板就是完整 HIL 流水线。

代码案例:embedded-test 四步接入

以官方 examples/testing 为准,把测试加进应用分四步(Book testing.md 的四步结构)。

第一步:dev-dependency(Cargo.toml):

[dev-dependencies]
embedded-test = { version = "0.7.1", features = ["ariel-os-010"] }

feature ariel-os-010 告诉 embedded-test 按 Ariel OS 0.6.x(dev)的集成约定工作。Book 的配套警告要原样强调:除 ariel-os-* 外,不要在 embedded-test 依赖上启用其它 feature(embedded-test 自身的 panic-handler feature 在启用 ariel-os-* 时会被忽略)——panic handler 的唯一权威在 Ariel OS 运行时,重复注册是冲突源。

第二步:声明测试入口(Cargo.toml):

# for a library crate
[lib]
harness = false
# 或 bin / 单独 test 文件:
[[test]]
name = "test"
harness = false

harness = false 把结果收集的职责从 cargo 内建 harness 移交给 embedded-test 的宏展开。examples/testing 用的是 [[test]] name = "test" harness = false(测试放 tests/test.rs),二进制 crate 也可以直接在 bin 上关 harness(Book 的 [[bin]] name = "ariel-os-hello" harness = false 形式)。

第三步:laze 模块选择(laze.yml):

apps:
  - name: example-testing
    selects:
      - ?defmt
      - embedded-test-only

两个模块的区别要分清:embedded-test(给正常应用加测试——应用本身仍可作为固件运行);embedded-test-only(纯测试 crate——不产生常规固件,仓库 tests/ 目录清一色用它,且配合 SKIP_CARGO_BUILD: "1",只响应 laze build test)。库 crate 的测试用 embedded-test-only;应用的自测用 embedded-test。

第四步:写测试(tests/test.rs):

#![no_main]
#![no_std]

#[cfg(test)]
#[embedded_test::tests]
mod tests {
    // Optional: An init function which is called before every test
    #[init]
    fn init() -> u32 {
        return 42;
    }

    // A test which takes the state returned by the init function (optional)
    // This is an async function, it will be executed on the system executor.
    #[test]
    async fn trivial_async(n: u32) {
        assert!(n == 42)
    }

    // A test which takes the state returned by the init function (optional)
    #[test]
    fn trivial(n: u32) {
        assert!(n == 42)
    }

    // A test which is "ignored".
    #[test]
    #[ignore]
    fn trivial_ignored(n: u32) {
        assert!(n == 42)
    }
}

运行:

laze -C examples/testing build -b esp32-c6-devkitc-1 test

案例 2:仓库测试的真实写法。tests/gpio/laze.yml 展示了 context 限制与冲突声明:

apps:
  - name: tests_gpio
    selects:
      - embedded-test-only
    conflicts:
      - context::native      # gpio 在 native 上不工作

tests/gpio-interrupt-nrf/src/main.rs 展示了按芯片分支的外设声明——同一份测试源码经 #[cfg(context = ...)] 为每个 nRF 芯片定义 ButtonPeripherals,配合 #[embedded_test::tests] 在不同板卡上跑同一测试逻辑;context 限定(如 context: nrf)+ conflicts(如 nrf5340-net)把"这个测试只在哪些硬件上有效"编码进构建系统。tests/threading-mutex 则验证 thread::sync::Mutex 的优先级继承与 thread_flags——低优先级线程持锁时继承等待者的最高优先级、释放后恢复,这是第 14 篇理论上讲过的行为第一次以可执行断言出现。

逐段解析

#[embedded_test::tests] 宏的展开

#[cfg(test)]
#[embedded_test::tests]
mod tests { ... }

#[cfg(test)] 保证测试代码只在 test 构建里编译——laze build(常规固件)不含这些函数。宏把 mod 展开为 embedded-test 的自定义 harness:收集 #[init]/#[test] 项生成测试注册表与运行入口,运行时逐个执行并输出结构化结果(测试名 + 结果)到调试通道。harness = false 之后,cargo 认为该目标是普通 bin,入口逻辑完全由宏生成——这与 std 的 libtest 假设脱钩,正是 no_std 可行的关键。

#[init] 返回的 state 传给每个测试函数参数(fn trivial(n: u32))——每个测试都拿到新的 init 结果(注释明确 called before every test),测试间无状态泄漏。state 类型自定义(这里是 u32,实际项目里可以是初始化好的外设句柄组、栈句柄等),这让"测试前置"从注释约定升级为类型契约。

async 测试(async fn trivial_async)运行在系统 executor 上——Ariel OS 集成点的作用:执行器、定时器、日志都已就绪,async 测试里可以 Timer::after(...).await、可以拿 network_stack()。同步测试跑在 embedded-test 线程上下文(16 KiB 栈的 autostart 线程)。两种测试混合时注意资源访问的互斥(比如两个测试都碰同一个 I2C 总线要自己协调——embedded-test 默认逐个跑测试,这个风险低,但 async 测试若 spawn 了后台任务则要小心)。

#[ignore] 与 cargo test 语义一致:编译但不执行——硬件依赖缺失、需人工干预的测试用这个口子,配合后续"选择性执行"。

laze test 任务的执行链

laze build -b <board> test 做了什么:以 test 配置重新构建(cfg(test) 生效、dev-dependencies 就位)→ 经 runner 烧录(probe-rs 或 espflash)→ 运行目标 → 解析调试通道上的测试结果流 → 汇总报告 → 以第一个失败或全部通过决定任务退出码。对 CI 而言这就是一个原子命令:"把这套测试跑在这块板上"。embedded-test-only 的 crate 因为 SKIP_CARGO_BUILD: "1" 在常规构建任务里跳过 cargo(不产出普通固件),只在 test 任务里真正构建——测试资产不污染正常构建矩阵。

runner 差异回顾(第 6 篇):ESP 系默认 espflash(USB-Serial-JTAG),Cortex-M 默认 probe-rs——test 任务复用同一 runner 机制,所以测试回传通道在 ESP 上是串口日志流、在 probe-rs 板上是 RTT。应用侧无感。

HIL 工程化

把 laze build -b <board> test 塞进 CI 只是第一步,完整 HIL 要回答:板卡池怎么管理(探针编号与板卡的映射)、并发测试怎么隔离(USB hub 与供电独立性)、失败怎么取证(测试输出存档、必要时附加 RTT 全量日志)。Ariel OS 仓库本身的状态(tests/ 目录 + context 矩阵)提供了资产层与选择层;执行器层(板卡池调度)目前是自建/自选——ROADMAP 把 HIL 列为 CI 方向正说明这一层在演进中。实用的最小 HIL:一台常驻主机 + 一块板 + 定时任务跑全量 laze build test,配合 --global 式的全板编译检查(第 30 篇板卡支持流程里的 CONFIG_WIFI_NETWORK='test' CONFIG_WIFI_PASSWORD='password' laze build --global -b <builder>——只为编译检查,凭据无需有效)。

测试策略:host test 与 embedded test 的分工

Ariel OS 的 laze 模块表里还有 host-test-only(NOTES 调研所列)——纯主机测试的模块出口。分工原则:能在主机测的不要上硬件(快、便宜、可并行);硬件测试聚焦"硬件真存在才有意义"的断言。据此给前 27 篇的功能排一个测试矩阵:配置解析、协议封装(CoAP handler 逻辑)、存储的 key 封装——主机测试;GPIO/中断/I2C 回环/SPI 回环/UART 回环/线程优先级——硬件测试(仓库 tests/ 目录正是一一对应);网络端到端(udp/tcp echo)——硬件测试但依赖外部服务,CI 里要自带回环服务端。

运行现象

$ laze -C examples/testing build -b esp32-c6-devkitc-1 test
...(构建输出)
running 3 tests
test trivial_async ... ok
test trivial ... ok
test trivial_ignored ... ignored, (忽略信息)
test result: ok. 2 passed; 1 ignored; 0 failed

(输出格式以实际 embedded-test 版本为准,语义为:逐个测试的执行与结果、最终统计、进程退出码反映成败。)故意写一个失败的测试:

#[test]
fn always_fails(_n: u32) {
    assert!(false, "this always fails");
}

laze 的 test 任务以非零退出码结束,失败信息(含断言消息,经 defmt/panic-printing 管道)出现在输出里——CI 的红绿判定即由此驱动。再故意让一个 async 测试 panic(panic handler 打印后 exit(FAILURE),第 7 篇的机制在测试场景复用),观察后续测试是否继续执行(embedded-test 的默认行为与报告完整性)。

值得做的实验:在 #[init] 里初始化一个 GPIO 并让两个测试分别验证输出电平——体验"state 类型契约"如何保证前置顺序;把测试跑在 native 目标(-b native,下一篇主角)上,对比硬件与模拟环境的测试结果差异(gpio 类测试在 native 上被 conflicts 挡住,正是这个差异的声明式表达)。

常见问题

Q1:laze build test 报错找不到测试或跑的是普通固件?

成因:三处声明缺一不可——dev-dependencies 里带 ariel-os-010 feature 的 embedded-test、目标声明 harness = false、laze.yml 里选 embedded-test/embedded-test-only 模块。最常见的漏项是第三处:选了模块但写的常规 laze build(test 任务才会走测试路径),或 Cargo.toml 的 [[test]] 名与文件名不符。解决方案:按四步清单逐项核对;确认用 laze build test 而非 run/flash;库 crate 检查 [lib] harness = false 与测试代码的 #[cfg(test)] 门。

Q2:测试在 ESP32-C6 上结果回不来/超时?

成因:ESP 的 runner 是 espflash,结果流走 USB-Serial-JTAG 串口——串口被其它监视器占用(espflash monitor 没关)会抢流;或日志 facade 配置与 embedded-test 的输出路径冲突。解决方案:测试运行时不要并行开串口监视器;确认日志传输模块(logging-over-usb 等)与 runner 的通道一致;probe-rs 路径(--select probe-rs)是替代通道,RTT 上回传测试结果同样可行。极慢的超时还要检查 CONFIG_EXECUTOR_STACKSIZE——16 KiB 测试线程已由系统声明,但测试自身再开大 Future 时要评估。

Q3:embedded-test 依赖上开了别的 feature 导致 panic handler 冲突或构建错误?

成因:Book 的显式警告——除 ariel-os-* 外不要给 embedded-test 启用其它 feature;它的 panic-handler feature 与 Ariel OS 运行时的 panic handler 重复注册,链接期符号冲突或运行时行为未定义。解决方案:dev-dependency 精确写 { version = "0.7.1", features = ["ariel-os-010"] },不添加其它 feature;panic 相关需求(打印、退出码)全部经 Ariel OS 的 panic-printing 模块与 ariel_os::debug::exit() 实现。依赖审计时把这条写进 CODEOWNERS/CI 检查(cargo tree 里 embedded-test 的 feature 集应为最小集)。

Q4:硬件在环 CI 里同一块板跑多个测试 crate,结果互相干扰?

成因:HIL 的经典问题——测试 crate 之间的板卡状态残留(存储区、GPIO 电平、网络配置),以及物理层的非原子性(烧录中断、复位时序)。Ariel OS 侧的缓解:每个测试 crate 是独立固件(embedded-test-only),烧录即重置运行环境;存储类测试要有自清理(erase_all 或写后删);GPIO 类测试用回环或指定引脚避免影响板外。执行器层措施:测试间强制 reset/flash-erase-all(按需)、串行调度单板任务、失败后隔板重试定位"板坏"还是"码坏"。仓库 tests/ 里各 crate 的 conflicts/context 声明(如 gpio conflicts native)就是"哪个测试需要哪种物理环境"的机器可读表达,调度器可直接消费。

Q5:纯逻辑测试(主机 cargo test)在 Ariel OS 工程里怎么组织?

成因:workspace 的 default-members 只含 examples/hello-world,直接根目录 cargo test 行为受限;且 no_std 代码在主机测试需要 std 兼容层。解决方案:逻辑代码抽成独立库 crate(std 可选:#![cfg_attr(not(test), no_std)] 模式),dev-dependencies 走标准 libtest;或用 host-test-only 模块组织主机侧测试资产;laze 的 -C <dir> test 定点执行。混合策略的核心是"分层判定":主机层挡住逻辑回归(秒级反馈),硬件层守住集成正确性(分钟级反馈),两层都绿才算通过。这也是 embedded-test 之上 Ariel OS 不再自建逻辑测试框架的原因——主机侧 rust 生态已经足够好。

面试考点

考点 1:embedded-test 为什么要求 harness = false?自定义 harness 与 std libtest harness 的根本差异是什么?

参考答案:std 的 test harness 假设完整 std 环境:libtest 收集测试的进程机制、线程化并行、退出码与结果 JSON——MCU 上无 std、无进程、无线程库(Ariel OS 的线程是自己的)。harness = false 告诉 cargo"这个 target 是普通程序",测试收集与运行完全由 embedded-test 宏生成的代码接管:编译期收集 #[test] 项为注册表,运行时在目标上逐个执行,结果经调试通道(RTT/串口)回传。根本差异是结果通道:libtest 经进程退出码与 stdout 直接给宿主,embedded-test 经"固件运行 + 调试通道解析"间接给宿主——多出的烧录与解析环节由 laze test 任务补齐。这与其他嵌入式测试框架(如 defmt-test)同构,差异在 Ariel OS 集成带来的系统就绪保证。

考点 2:embedded-test 与 embedded-test-only 两个 laze 模块的使用场景划分依据是什么?

参考答案:embedded-test 给"既有应用"附加测试能力:应用固件照常存在,test 任务以测试配置重建运行——适合应用的自带回归(cargo test 式体验)。embedded-test-only 给"纯测试 crate":不产出常规固件(SKIP_CARGO_BUILD: "1",常规构建跳过),只响应 test 任务——适合仓库级测试资产(tests/ 目录的 16 个 crate),它们的存在意义就是被 test 任务消费,作为普通固件烧录反而制造困惑。划分依据是构建产物的语义:应用 + 自测 → embedded-test;测试即产品 → embedded-test-only。类比:一个是"程序自带 --test 模式",一个是"测试就是程序"。

考点 3:Ariel OS 集成 embedded-test 时为什么为测试注册专用线程(stacksize 16384)?与"直接在启动后调用测试"相比好在哪?

参考答案:专用线程(#[ariel_os_macros::thread(autostart, stacksize = 16384)] fn embedded_test_thread())带来三点:栈预算独立——测试代码(可能含深层断言、格式化、async 嵌套)不挤占系统栈,16 KiB 显式声明;调度环境真实——测试运行于正常线程上下文,抢占/优先级行为与真实应用一致(测线程行为时这是前提);时序可控——线程默认等 THREAD_START_EVENT(第 15 篇),即系统 init(日志、网络、存储)完成后才开跑,测试天然运行在"系统就绪"环境。对比"启动后直接调用":裸上下文、栈不明确、系统组件未必初始化,测试结果依赖未声明的时序。集成点的本质:把测试当"一等系统公民"而非"启动后的 hack"。

考点 4:为一个跨 MCU 的 I2C 驱动设计测试矩阵,说明各层测试的位置与判定标准。

参考答案:三层。主机层:协议编解码、地址/寄存器映射逻辑、错误分类——std 单测,判定标准是逻辑等价(不涉及硬件)。硬件单板层:回环或已知器件测试(仓库的 tests/i2c-controller 模式)——#[embedded_test::tests] + #[cfg(context = ...)] 声明各芯片的引脚与外设,断言"扫描到预期地址""读写回读一致",判定标准是物理行为正确;conflicts 精确排除不适用硬件(如 native)。系统集成层:驱动与传感器框架(第 19 篇 REGISTRY)联动、与存储/网络的组合场景,判定标准是端到端行为。矩阵的构建期表达(context/selects/conflicts)让"同一份测试源码 + 不同板卡"自动展开,这正是 Ariel OS 把测试资产工程化的核心价值:测试选择逻辑进构建系统而非人脑。

考点 5:HIL 流水线中"flaky test"(时敏测试偶发失败)的来源与治理,嵌入式与主机测试有何不同?

参考答案:来源差异:嵌入式 flaky 多了物理层因素——供电纹波、探针接触、射频环境(BLE/Wi-Fi 测试的信道竞争)、外设时序(I2C clock stretching、UART 回环的缓冲竞争);软件层的 async 竞争(测试 spawn 的任务与断言竞争)与主机测试同源但更难复现(无 std 的调试设施)。治理:物理层——硬件环境标准化(供电、屏蔽、接线)、关键测试前置自检(探针连通性);软件层——时敏断言改为事件驱动等待(如 wait_for_low().await 而非 sleep(100ms) 后断言)、超时预算放大、失败时保留完整日志通道输出(defmt 全量)供离线分析;流程层——失败自动重烧重跑一次以区分"真回归"与"环境抖动",连败才告警。嵌入式 flaky 的特殊性:复位即可重置环境是优势(比主机进程隔离更彻底),但物理世界不可复位是劣势——测试设计要区分"可复位状态"与"不可复位状态"。

小结

本篇建立了 Ariel OS 的测试观:纯逻辑留主机、硬件行为上真板、集成场景进 HIL。embedded-test 的四步接入(dev-dependency + harness=false + laze 模块 + #[embedded_test::tests])与 laze build test 的单命令执行,让"硬件上的回归测试"成为 CI 可消费的原子操作;embedded-test/embedded-test-only 的产物语义区分、仓库 tests/ 的 context 矩阵,则是把测试资产工程化的样板。至此,前 27 篇的每个子系统都有了验证路径。

下一篇切换赛道:不需要硬件的 Ariel OS——native 目标如何让应用跑成 Linux 进程,benchmark 模块怎么量化性能,Ferrocene 认证工具链怎么接入安全关键流程。

系列目录

  1. 系列总览:Ariel OS 设计理念与生态全景
  2. 环境搭建:Rust 工具链与 laze 构建系统
  3. laze 元构建系统:contexts、modules 与 builders
  4. 第一个应用:从 minimal 到 hello-world
  5. 板卡描述体系:boards yaml 与结构化配置
  6. 烧录与调试:probe-rs、RTT 与日志查看
  7. 日志系统:defmt 与 ariel-os-log
  8. 调试控制台:shell 与交互调试
  9. 异步执行器:Embassy 集成与任务宏
  10. 内存布局与全局分配器
  11. 时钟体系与启动流程
  12. 引脚与 GPIO:静态配置与运行时切换
  13. 多线程(上):抢占式线程与线程宏
  14. 多线程(下):Channel、Event 与互斥
  15. 线程与异步互操作:multicore 与定时器
  16. 低功耗与电源管理
  17. I2C 实战:总线扫描与设备通信
  18. SPI 与 UART 实战
  19. 传感器框架:sensor API 与注册表
  20. 随机数与熵源
  21. 网络栈(上):初始化、Wi-Fi 与 UDP
  22. 网络栈(下):TCP 客户端与回显服务
  23. 应用协议:HTTP 与 CoAP
  24. USB 实战:串口与 HID 键盘
  25. BLE 实战:广播与扫描
  26. 以太网、设备元数据与身份
  27. 持久化存储
  28. 测试:embedded-test 与硬件在环
  29. 原生目标、基准测试与 Ferrocene
  30. 收官:新增板卡支持、横向对比与路线图
posted @ 2026-10-28 00:45  navysummer_hardware  阅读(10)  评论(0)    收藏  举报