文章目录
-
- 一、背景与目标
- 二、工程搭建
- 三、单元测试设计
-
- [3.1 难点:被测数据是 `static`](#3.1 难点:被测数据是
static) - [3.2 极简测试框架](#3.2 极简测试框架)
- [3.3 覆盖全部绘图函数](#3.3 覆盖全部绘图函数)
- [3.1 难点:被测数据是 `static`](#3.1 难点:被测数据是
- [四、host 侧验证:桩件 + 覆盖率](#四、host 侧验证:桩件 + 覆盖率)
- 五、设备端接入与三个真实踩坑
-
- [坑 1:`multiple definition of 'main'` + `undefined reference to g_stub_*`](#坑 1:
multiple definition of 'main'+undefined reference to g_stub_*) - [坑 2:`Oops:NMI` 看门狗复位](#坑 2:
Oops:NMI看门狗复位) - [坑 3:屏幕什么都没有显示](#坑 3:屏幕什么都没有显示)
- [坑 1:`multiple definition of 'main'` + `undefined reference to g_stub_*`](#坑 1:
- [六、可视化自检:编译正确 ≠ 显示正确](#六、可视化自检:编译正确 ≠ 显示正确)
- 七、最终验证结果
- 八、总结
在 MCU/RTOS 工程里给外设驱动写单元测试,听起来有点"奢侈",但一旦 OLED 显示出问题,你会发现"编译通过"和"显示正确"之间隔着一条巨大的鸿沟。本文记录在 WS63 平台(HiSilicon)上为 SSD1306 OLED 驱动搭建单元测试工程的完整过程,包括 host 侧覆盖率验证、设备端构建接入,以及三个真实踩坑(链接冲突、看门狗 NMI、屏幕全黑)的排查与修复。
一、背景与目标
工程里已有一个 helloworld_oled 样例,它在 OLED 上显示 Hello World,驱动文件 ssd1306.c 实现了一组绘图函数:
ssd1306_Fill / DrawPixel / DrawChar / DrawString / SetCursor /
DrawLine / DrawPolyline / DrawRectangle / DrawCircle /
DrawBitmap / DrawRegion / UpdateScreen / FillBuffer /
WriteCommand / WriteData / SetContrast / SetDisplayOn / Reset / Init / ClearOLED / ssd1306_printf
目标是参考 helloworld_oled 新建一个 ssd1306_unittest 工程,对 ssd1306.c 的全部绘图函数做单元测试,并接入 SDK 的 CMake / Kconfig 构建体系。
二、工程搭建
WS63 的 peripheral 样例通过 Kconfig 的 SAMPLE_SUPPORT_* 开关 + CMakeLists.txt 的 add_subdirectory_if_exist 注册。新建工程目录:
application/samples/peripheral/ssd1306_unittest/
├── CMakeLists.txt
├── Kconfig
├── ssd1306.c / ssd1306.h # 被测源码(与 helloworld_oled 一致)
├── ssd1306_fonts.c / ssd1306_fonts.h
├── ssd1306_unittest.c # 单元测试主程序
└── host_test/
├── build_host.py # PC 侧编译+运行+覆盖率脚本
└── stubs/ # i2c.h / soc_osal.h / securec.h / os_stubs.c 桩件
在 peripheral/CMakeLists.txt 与 peripheral/Kconfig 中各加一段,把新工程挂进构建树:
cmake
# peripheral/CMakeLists.txt
if(DEFINED CONFIG_SAMPLE_SUPPORT_SSD1306_UNITTEST)
add_subdirectory_if_exist(ssd1306_unittest)
endif()
kconfig
# peripheral/Kconfig
config SAMPLE_SUPPORT_SSD1306_UNITTEST
bool
prompt "Enable SSD1306 UnitTest Sample"
default n
if SAMPLE_SUPPORT_SSD1306_UNITTEST
menu "SSD1306 UnitTest Sample Configuration"
osource "application/samples/peripheral/ssd1306_unittest/Kconfig"
endmenu
endif
三、单元测试设计
3.1 难点:被测数据是 static
ssd1306.c 把屏幕影子显存和屏幕对象都声明为 static:
c
static uint8_t SSD1306_Buffer[SSD1306_BUFFER_SIZE];
static SSD1306_t SSD1306;
绘图函数的"输出"就是修改这块缓冲区。如果只通过公共接口调用,根本观测不到绘制结果,断言就成了空中楼阁。
解决办法 :测试文件直接 #include "ssd1306.c",把被测源码包含进同一个翻译单元,static 对象即对测试可见,从而做像素级断言 。代价是:本工程 CMake 不能再单独编译 ssd1306.c,否则重复符号。
c
#include "ssd1306.c" /* 访问 static 的 SSD1306_Buffer / SSD1306 */
static uint8_t ut_get_pixel(uint8_t x, uint8_t y)
{
uint32_t index = (uint32_t)x + ((uint32_t)y / 8u) * (uint32_t)SSD1306_WIDTH;
return (uint8_t)((SSD1306_Buffer[index] >> ((uint32_t)y % 8u)) & 0x01u);
}
3.2 极简测试框架
不引入第三方框架,几十行宏搞定用例/断言统计:
c
#define UT_BEGIN_CASE(name) \
do { \
g_case_total++; \
g_current_case_failed = 0; \
if (g_ut_verbose) { \
printf(" [CASE %02u] %s\r\n", \
(unsigned)g_case_total, (name)); \
} \
} while (0)
#define UT_EXPECT(cond, ...) \
do { \
g_assert_total++; \
if (!(cond)) { \
g_assert_fail++; \
g_current_case_failed++; \
printf(" [FAIL] "); \
printf(__VA_ARGS__); \
printf("\r\n"); \
} \
} while (0)
3.3 覆盖全部绘图函数
共设计 17 个用例,每个绘图函数至少一个用例,并刻意覆盖边界与非法输入:
| 用例 | 覆盖接口 | 关键断言 |
|---|---|---|
| 01 | Init / Reset / Init_CMD |
Initialized==1、DisplayOn==1、缓冲区清黑 |
| 02 | Fill |
Black→全 0x00,White→全 0xFF |
| 03 | DrawPixel |
四角像素、越界坐标不改缓冲区、反色分支 |
| 04 | SetCursor |
CurrentX/Y 精确更新 |
| 05 | DrawChar |
有效字符返回自身;<32、>126、横/纵向空间不足返回 0 |
| 06 | DrawString |
整串返回 '\0';遇非法字符提前返回 |
| 07 | DrawLine |
水平/垂直/对角/反向/单点/陡峭线 |
| 08 | DrawPolyline |
折线连接;size<=1 与 NULL 不崩溃 |
| 09 | DrawRectangle |
四角点亮、内部空心 |
| 10/11 | DrawCircle |
端点像素、圆心越界、32 种半径几何分支 |
| 12 | DrawBitmap |
位图位序、高度裁剪 |
| 13 | DrawRegion |
正常/越界/w*h==0 |
| 14 | WriteCommand/WriteData/SetContrast/SetDisplayOn/GetDisplayOn |
I2C 写调用次数、显示开关状态 |
| 15 | FillBuffer |
合法长度 OK、超长 ERR |
| 16 | UpdateScreen |
I2C 成功/失败两条分支 |
| 17 | ClearOLED / ssd1306_printf |
行位置复位、格式化绘制、fmt=NULL |
四、host 侧验证:桩件 + 覆盖率
设备端依赖 i2c.h、soc_osal.h、securec.h 等头文件和一堆 uapi_* / osal_* 接口。要在 PC 上跑起来,得给这些依赖提供桩件 :host_test/stubs/ 下放一份只实现"空操作 + 调用计数"的头和源文件。
其中 I2C 写桩件带计数器,用来断言底层接口是否真的发起了传输:
c
/* os_stubs.c */
int g_stub_i2c_write_count = 0;
uint32_t g_stub_i2c_write_ret = 0;
errcode_t uapi_i2c_master_write(uint8_t bus, uint32_t len, uint8_t *buf)
{
(void)bus; (void)len; (void)buf;
g_stub_i2c_write_count++;
return (errcode_t)g_stub_i2c_write_ret;
}
测试里用 #if !defined(SSD1306_UNITTEST_ON_DEVICE) 包裹这些桩件断言,设备端走真实 I2C,不验证桩件计数------这就解释了为什么 host 是 101 条断言、设备端是 98 条(差 3 条 I2C 写次数断言)。
一个脚本 build_host.py 完成"编译 + 运行 + gcov 覆盖率":
$ python application/samples/peripheral/ssd1306_unittest/host_test/build_host.py
cases : 17 total, 17 passed, 0 failed
asserts: 101 total, 0 failed
RESULT : ALL PASS
Lines executed:99.58% of 239 # ssd1306.c 行覆盖
ssd1306.c 行覆盖 99.58% 、分支覆盖 100% 。唯一没覆盖到的是 vsnprintf_s 返回负值的 CRT 错误路径------桩件触发不了,可以接受。
host 测试证明了"软件逻辑正确",但证明不了"显示正确"------这一点后面会反复提到。
五、设备端接入与三个真实踩坑
把工程挂进 SDK 构建后,烧录到 WS63 开发板,连续踩了三个坑。每一个都值得记录,因为它们都是嵌入式工程里极具普遍性的问题。
坑 1:multiple definition of 'main' + undefined reference to g_stub_*
现象:设备端链接报错
main.c:618: multiple definition of `main';
ssd1306_unittest.c:744: first defined here
... undefined reference to `g_stub_i2c_write_count'
根因 :测试文件用宏 SSD1306_UNITTEST_ON_DEVICE 区分设备端/host 端分支,设备端走 app_run 入口、不定义 main,host 端才定义 main 并引用桩件符号 g_stub_*。
我在 CMakeLists.txt 里写了:
cmake
add_definitions(-DSSD1306_UNITTEST_ON_DEVICE)
但 SDK 的样例构建模式是 set(SOURCES ... PARENT_SCOPE) 把源文件上抛到上层 libsamples.a target 编译。add_definitions 是目录级 的,不会跟随源文件上抛。结果设备端编译 ssd1306_unittest.c 时这个宏根本没定义,于是误走 host 分支:定义了 main(和固件 main.c 冲突)+ 引用了 host 桩件符号(设备端没定义)。
修复 :反转默认------源文件默认按设备端编译,只有 host 桩件脚本显式 opt-in 才走 host 分支:
c
#ifndef SSD1306_UNITTEST_HOST
#define SSD1306_UNITTEST_ON_DEVICE
#endif
python
# build_host.py 编译命令里加
"-DSSD1306_UNITTEST_HOST",
CMake 里删掉那行无效的 add_definitions。这样源文件无论被上抛到哪一层 target,都能正确选择设备端路径。
教训:在"源文件上抛"型构建系统里,不要用目录级
add_definitions给单个源文件传宏,要么用set_source_files_properties(... COMPILE_DEFINITIONS ...),要么让源文件自带默认。
坑 2:Oops:NMI 看门狗复位
现象:程序跑起来,17 用例全 PASS,可视化自检页开始循环刷屏,第二轮刷到一半崩溃:
APP|exception:8000000c
APP|Oops:NMI
task:ssd1306UT
ssd1306UT ... Running ... CPUP 99.6 100.0 100.0
根因 :mcause=0x8000000c + 任务 CPU 99.6% ,是典型的看门狗超时 NMI。我在可视化自检里写了 while(1) 无限循环刷屏:
c
while (1) {
ut_show_result_page();
osal_mdelay(2500);
...
}
而基准样例 helloworld_oled 的任务是画完即返回、不循环 。无限循环 + osal_mdelay 未能有效让出 CPU,本任务长期占用 99.6%,喂狗任务被饿死,约 10 秒后看门狗触发 NMI 复位。
修复 :参照基准"画完即退"的模式,去掉 while(1),可视化自检只播放一轮后让任务退出:
c
static void ut_visual_selfcheck(void)
{
printf("==== 可视化自检 ====\r\n");
ut_show_result_page();
osal_mdelay(SSD1306_UT_PAGE_MS);
ut_show_rect_page();
osal_mdelay(SSD1306_UT_PAGE_MS);
ut_show_circle_page();
osal_mdelay(SSD1306_UT_PAGE_MS);
ut_show_text_page();
osal_mdelay(SSD1306_UT_PAGE_MS);
/* 不再 while(1),任务到此返回并结束 */
}
教训:在带看门狗的 RTOS 上,用户任务不要写
while(1)紧循环(即便里面有mdelay),除非你确认延时函数会真正 yield。能"画完即退"就退。
坑 3:屏幕什么都没有显示
现象 :坑 2 修好后程序不崩了,串口 4 个自检页说明都正常打印,但 OLED 屏幕全黑。
根因 :串口能打印说明 UpdateScreen 执行了,但面板没显示。回溯用例 14 ut_test_low_level:
c
ssd1306_SetDisplayOn(1);
UT_EXPECT(ssd1306_GetDisplayOn() == 1, ...);
ssd1306_SetDisplayOn(0); /* ← 末尾关了显示! */
UT_EXPECT(ssd1306_GetDisplayOn() == 0, ...);
这个用例为了测试显示开关,末尾调用了 SetDisplayOn(0),之后没有任何用例再打开显示。可视化自检的 4 个页面只更新 GDDRAM,不重新开显示,面板一直处于 display-off,自然全黑。
修复 :可视化自检开头先 ssd1306_Init() 重新初始化面板。Init 内部会经 Init_CMD() 调用 SetDisplayOn(1) 打开显示并清屏:
c
static void ut_visual_selfcheck(void)
{
printf("==== 可视化自检 ====\r\n");
ssd1306_Init(); /* 恢复被用例改掉的面板状态(显示开关等) */
ut_show_result_page();
...
}
教训:单元测试会反复改设备状态,测试结束后设备可能停在任意状态。任何后续的"真实输出"前,必须把设备恢复到已知良好状态。
六、可视化自检:编译正确 ≠ 显示正确
这是整个过程中最值得强调的一点。
host 侧 99.58% 的覆盖率,只证明了 ssd1306.c 算出的 SSD1306_Buffer 位模式正确------这是软件逻辑。而"显示正确"还依赖一整条端到端链路:
- pinctrl 引脚复用、I2C 速率、从机地址
0x3C UpdateScreen下发的命令序列是否符合 OLED 控制器预期- 面板 com 扫描方向 / segment remap 是否配反(配反会镜像或上下颠倒)
- 字体在真实屏上的呈现
而且 SSD1306 的 I2C 是只写 接口,GDDRAM 无法可靠读回,软件没法自闭环。所以"显示对不对"这最后一环,只能靠人眼。
为此我加了 4 个可视化自检页,每页绘制位置可预判的图形并刷屏,串口同步打印"应看到什么":
| 页 | 屏幕应看到 | 验证什么 |
|---|---|---|
| 1/4 | 屏中央正立大字 PASS |
一眼知结果;字号大便于发现点阵错位 |
| 2/4 | 四边完整边框 + 居中十字线 | DrawRectangle / DrawLine |
| 3/4 | 中央一个圆 + 两条交叉对角线 | DrawCircle / DrawLine |
| 4/4 | 4 行文字 0123456789/Hello WS63/... |
DrawString;文字可读即证明面板方向没配反 |
第 4 页的文字是关键判据:如果 com 扫描方向或 segment remap 配反,文字会上下颠倒或左右镜像,一眼就能看出来。

七、最终验证结果
设备端串口输出:
======== ssd1306 unit test start ========
[CASE 01] ssd1306_Reset / ssd1306_Init / ssd1306_Init_CMD
PASS
...
[CASE 17] ssd1306_ClearOLED / ssd1306_printf
PASS
======== ssd1306 unit test result ========
cases : 17 total, 17 passed, 0 failed
asserts: 98 total, 0 failed
RESULT : ALL PASS
==== 可视化自检:请对照以上串口说明核对 OLED 屏 ====
[自检 1/4] 屏中央应显示正立可读的大字 "PASS"。
[自检 2/4] 应看到屏幕四边完整边框 + 居中十字线。
[自检 3/4] 应看到屏幕中央一个圆 + 两条交叉对角线。
[自检 4/4] 应按顺序读到 4 行文字,且不镜像、不倒置。
- 串口:17 用例 / 98 断言 ALL PASS
- 屏幕:4 个自检页依次正确显示,文字正立可读、无镜像无倒置
- 覆盖率(host):
ssd1306.c行 99.58%、分支 100%
两者都符合预期,才算端到端正确。
八、总结
回过头看,这次单元测试的价值恰恰不在那 99.58% 的覆盖率,而在过程中暴露的三个问题------它们都不是"逻辑错",而是"工程接入错":
- 构建系统语义误解:目录级宏定义不跟随源文件上抛 → 链接冲突。
- RTOS 任务模式 :
while(1)紧循环饿死看门狗 → NMI 复位。 - 测试副作用残留:用例关了显示没恢复 → 屏幕全黑。
这三个问题,单纯靠"编译通过"或"host 覆盖率 100%"一个都发现不了。它们分别要靠设备端链接 、设备端运行 、人眼观察屏幕才能暴露。这也正是嵌入式测试的精髓:
编译正确只代表语法没问题,host 覆盖率只代表逻辑没问题,只有真实硬件上显示正确,才算真的正确。
给外设驱动写单测,记得三件事:一要能断言到内部状态(#include 被测源码访问 static);二要 host 侧验证逻辑、设备侧验证端到端,两条腿走路;三要敬畏硬件------软件断言全绿之后,最后那一眼屏幕,才是真正的验收。