前言
从这一章开始,我们要开始内核的测试了。很多工程师觉得“软件定时器”很简单,无非就是链表挂载和 Tick 累加。但在真实的嵌入式高可靠场景下,定时器单测的难点在于:你如何在不接入真实 STM32 硬件定时器、不让物理时间真正流逝的前提下,测试一个需要 500ms 甚至发生 uint32_t 溢出的定时器边界? 而且,内核原语并发操作最怕临界区越界。
在 STM32 裸机或 RTOS 开发中,软件定时器(Software Timer)是控制业务节奏的中枢。通常,它的底层依赖于一个硬件定时器(比如 TIM2/TIM3)或者 Cortex-M 内核的 SysTick 中断。每隔 1ms,硬件中断触发一次,累加全局变量 HAL_GetTick()。
如果在 Host(PC)端做单元测试,我们根本没有这个物理晶振和硬件中断。如果业务代码里硬编码了对硬件寄存器或 ST 官方 HAL_GetTick() 的依赖,测试链直接就会断掉。
为了打破这个僵局,Anbo 微内核采用了一套宿主机硬件抽象层接口(Host Arch Stub)。接下来,我们基于 test_anbo_timer.c 和 anbo_arch_host.c,看看这套虚拟时间轴和临界区单测是如何运转的。
一、 虚拟时间轴
解耦硬件定时器的核心思想是:把“时间的流逝”变成一个由测试用例完全掌控的输入变量。
在宿主机桩函数 anbo_arch_host.c 中,我们实现了一个非常纯粹的静态变量 s_host_tick 来模拟系统心跳:
/* ---- anbo_arch_host.c 内部实现 ---- */static uint32_t s_host_tick = 0u;uint32_tAnbo_Arch_GetTick(void){return s_host_tick; # 业务代码调用的内核接口,在Host端返回的是这个虚拟Tick}voidAnbo_Arch_Host_SetTick(uint32_t tick){s_host_tick = tick; # 允许测试用例直接强行篡改、拨动系统当前时间}
有了这个桩函数,我们在单测里验证一个定时器是否到期,完全不需要在物理世界上等一分一秒。我们可以直接用代码“穿越”到未来。
1. 验证单次定时器(One-shot)的精确触发边界
我们来看 test_anbo_timer.c 里的第一个实战用例。这里要测试一个 100ms 的单次定时器,在 99ms 时不能触发,在 100ms 时必须精准触发。
voidtest_oneshot_fires_once(void){Anbo_Timer tmr;// 1. 创建一个 100ms 的单次定时器,绑定回调函数 cb_recordAnbo_Timer_Create(&tmr, ANBO_TIMER_ONESHOT, 100, cb_record, NULL);Anbo_Timer_Start(&tmr);TEST_ASSERT_TRUE(Anbo_Timer_IsRunning(&tmr)); # 断言定时器已成功启动并挂载/* 2. 模拟边界:时间推进到 99ms —— 此时未到期,绝对不能触发回调 */Anbo_Arch_Host_SetTick(99); # 强行把虚拟时间拨到 99msAnbo_Timer_Update(99); # 触发微内核的定时器更新链表检查TEST_ASSERT_EQUAL_UINT32(0, s_cb_count); # 断言回调次数依然为 0,安全/* 3. 黄金边界:时间精准到达 100ms —— 必须触发回调 */Anbo_Arch_Host_SetTick(100); # 时间跨入 100ms 门槛Anbo_Timer_Update(100); # 再次触发内核检查TEST_ASSERT_EQUAL_UINT32(1, s_cb_count); # 断言回调成功执行了 1 次TEST_ASSERT_EQUAL_PTR(&tmr, s_cb_log[0].timer); # 验证传给回调的指针就是当前定时器TEST_ASSERT_FALSE(Anbo_Timer_IsRunning(&tmr)); # 单次定时器触发后,必须自动转为停止状态/* 4. 彻底死后验证:时间继续推进到 200ms —— 不能二次触发 */Anbo_Arch_Host_SetTick(200);Anbo_Timer_Update(200);TEST_ASSERT_EQUAL_UINT32(1, s_cb_count); # 回调计数必须保持为 1,严防野定时器}
看透本质:这段代码不仅测试了业务,也展示了 TDD 的威力。整个测试运行只需要几微秒,但我们却在虚拟世界里完整走过了 200ms 的生命周期,彻底杜绝了依靠硬件在线调试时用肉眼看 LED 闪烁的低效。
二、 uint32_t 溢出(Wrap-around)测试
在嵌入式开发中,定时器最大的问题往往发生在系统连续运行约 49.7 天之后。因为 2^32 毫秒约等于 49.7 天,此时 uint32_t 类型的 Tick 计数器会直接归零溢出。许多写得不规范的软件定时器,会在溢出发生的瞬间导致死机、定时器永久失效或逻辑错乱。
在硬件上,你几乎不可能为了测这个边界去死等 50 天。但在单测架构里,我们可以直接利用虚拟时间轴把初始时间“顶”到溢出边缘:
void test_timer_wraparound(void){Anbo_Timer tmr;Anbo_Timer_Create(&tmr, ANBO_TIMER_ONESHOT, 100, cb_record, NULL);/* 1. 硬核前置:将系统初始时间直接设为溢出前的临界点(最大值减去50ms) */Anbo_Arch_Host_SetTick(UINT32_MAX - 50);Anbo_Timer_Start(&tmr); # 此时内核内部计算的 deadline = (UINT32_MAX - 50) + 100,自动回绕到 ~49/* 2. 推进到溢出的绝对大限 (0xFFFFFFFF) —— 此时依然没到 100ms 的周期,不应触发 */Anbo_Arch_Host_SetTick(UINT32_MAX);Anbo_Timer_Update(UINT32_MAX);TEST_ASSERT_EQUAL_UINT32(0, s_cb_count); # 严密断言:溢出前夕,哪怕数值再大,也不能误触发/* 3. 跨越时间奇点:Tick 计数器正式发生物理溢出,回绕到虚拟的 49ms */uint32_t wrap_tick = (UINT32_MAX - 50) + 100;Anbo_Arch_Host_SetTick(wrap_tick); # 此时 wrap_tick 的实际数值是 49Anbo_Timer_Update(wrap_tick);/* 4. 终极断言 */TEST_ASSERT_EQUAL_UINT32(1, s_cb_count); # 即使数据经历了从 0xFFFFFFFF 到 0x00000031 的巨变,内核依然能够精准识别并触发}
我们的内核底层在对比时间戳时,使用了 (int32_t)(t1 - t2) >= 0 的标准无符号差值强转有符号算法。这个测试用例,就是演示如何用“防御性测试”来百分之百地保证线上长周期运行系统的绝对稳定。
三、临界区深度嵌套平衡断言
在微内核中,为了防止多任务或中断并发破坏定时器双向链表,在操作定时器挂载、停止时,必须进入临界区(关中断或挂起调度)。
临界区最怕什么?最怕工程师在写代码时,由于复杂的 if-else 分支逻辑,导致某一个退出分支漏写了 Exit,或者多写了 Enter。 这会导致系统中断被永久关闭而直接死机。
在我们的单测框架里,引入了一套“影子临界区计数器”:
/* ---- anbo_arch_host.c 内部追踪机制 ---- */static uint32_t s_crit_enter_count = 0u;static uint32_t s_crit_exit_count = 0u;static int32_t s_crit_depth = 0; # 临界区当前嵌套深度voidAnbo_Arch_Critical_Enter(void){ s_crit_enter_count++; s_crit_depth++; }voidAnbo_Arch_Critical_Exit(void){ s_crit_exit_count++; s_crit_depth--; }
基于这套机制,在每个测试用例的 tearDown()(析构收尾函数)和专项用例里,都做了一针见血的断言:
voidtearDown(void){/* 每一个单测用例执行完毕后,Ceedling 都会强制调用一次 tearDown *//* 确保当前用例运行结束后,临界区的嵌套深度必须绝对等于 0! */TEST_ASSERT_EQUAL_INT32(0, Anbo_Arch_Host_GetCriticalDepth());}voidtest_critical_sections_balanced(void){Anbo_Arch_Host_ResetCritical();Anbo_Timer tmr;Anbo_Timer_Create(&tmr, ANBO_TIMER_PERIODIC, 10, cb_record, NULL);Anbo_Timer_Start(&tmr);Anbo_Arch_Host_SetTick(10);Anbo_Timer_Update(10);Anbo_Timer_Stop(&tmr);uint32_t enters = Anbo_Arch_Host_GetCriticalEnterCount();uint32_t exits = Anbo_Arch_Host_GetCriticalExitCount();/* 专项断言:进入和退出的总次数必须绝对相等,且在过程中确实发生过临界区保护 */TEST_ASSERT_EQUAL_UINT32(enters, exits);TEST_ASSERT_TRUE(enters > 0); # 证明内核定时器确实受到了代码级锁的保护}
本章小结
本章通过解耦软件定时器,展示了架构思维写测试的方法:
掌控时间:利用虚拟 Tick 计数器,将物理时间变成可任意编排的输入,秒杀单次、周期及长达50天的溢出边界。
代码级防御:通过宿主机桩函数监控临界区嵌套深度,在编译阶段和离线状态下就完全杜绝了因开关中断不匹配导致的恶性死机 Bug。
往期精选
【第09期】C语言的嵌入式特化 (三) —— 函数指针与回调 (Callback):解耦神器
C语言从句柄到对象 (二) —— 极致的封装:不透明指针与 SDK 级设计
传送门
内核核心仓库 (Repo A):https://github.com/AnboPeng/Anbo-MOS
应用全栈套件 (Repo B): https://github.com/AnboPeng/Anbo-L4s5-App
/******************************************
* 这是一场主动降频后,回归初心的思考与整理,对过往的嵌入式经验重新校准与复盘。
* 内容以专题系列文的形式持续更新,声明仅代表个人理解与思考,欢迎留言、讨论、补充,共同进步!
* 觉得有共鸣,关注我!并设为星标⭐,欢迎转发分享❤️!让更多人看见!
******************************************/
夜雨聆风