WS63 嵌入式 SSD1306 OLED 驱动单元测试实战

文章目录

    • 一、背景与目标
    • 二、工程搭建
    • 三、单元测试设计
      • [3.1 难点:被测数据是 `static`](#3.1 难点:被测数据是 static)
      • [3.2 极简测试框架](#3.2 极简测试框架)
      • [3.3 覆盖全部绘图函数](#3.3 覆盖全部绘图函数)
    • [四、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:屏幕什么都没有显示)
    • [六、可视化自检:编译正确 ≠ 显示正确](#六、可视化自检:编译正确 ≠ 显示正确)
    • 七、最终验证结果
    • 八、总结

在 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% 的覆盖率,而在过程中暴露的三个问题------它们都不是"逻辑错",而是"工程接入错":

  1. 构建系统语义误解:目录级宏定义不跟随源文件上抛 → 链接冲突。
  2. RTOS 任务模式 :while(1) 紧循环饿死看门狗 → NMI 复位。
  3. 测试副作用残留:用例关了显示没恢复 → 屏幕全黑。

这三个问题,单纯靠"编译通过"或"host 覆盖率 100%"一个都发现不了。它们分别要靠设备端链接 、设备端运行 、人眼观察屏幕才能暴露。这也正是嵌入式测试的精髓:

编译正确只代表语法没问题,host 覆盖率只代表逻辑没问题,只有真实硬件上显示正确,才算真的正确。

给外设驱动写单测,记得三件事:一要能断言到内部状态(#include 被测源码访问 static);二要 host 侧验证逻辑、设备侧验证端到端,两条腿走路;三要敬畏硬件------软件断言全绿之后,最后那一眼屏幕,才是真正的验收。

相关推荐
CesareCheung4 小时前
高级软件测试工程师面试锦集
面试·单元测试·压力测试
2501_915918411 天前
在 Xcode 中运行和调试单元测试的完整指南
ide·vscode·macos·ios·单元测试·个人开发·xcode
极小狐2 天前
MR 里测试摘要空白、覆盖率不显示?JUnit 与覆盖率配置 9 步排查
数据库·ci/cd·junit·单元测试·mr·devops·代码覆盖率
toooooop82 天前
FastAdmin(ThinkPHP 5.0)单元测试实践:从零到跑通
单元测试·fastadmin
silvamux_2 天前
大模型 API 单元测试怎么 mock 不误伤
单元测试·log4j·php
Helix2502 天前
Python单元测试实战:pytest框架从入门到企业级测试体系搭建指南
python·单元测试·实战·mock·pytest·测试覆盖率·fixture
system_error_x3 天前
自动化常规代码辅助系统
运维·自动化·log4j
看浪的路人3 天前
第10讲:大模型应用测试——从单元测试到 E2E 评测的完整体系
log4j
2302_805771074 天前
Day24:DDT+EXCEL数据驱动
功能测试·单元测试·自动化·测试用例·需求分析·web自动化