第九讲 OLED + U8G2 + 多级菜单

第九讲 OLED + U8G2 + 多级菜单

这节课不是单独学一个 OLED,而是在学一条完整链路:

STM32 HAL I2C -> SSD1306驱动 -> u8g2图形库 -> WouoUI菜单

也就是说,老师这节课是按“底层通信 → 图形显示 → 界面交互”的顺序往上搭。

我整理这篇笔记时,目标不是把课件重抄一遍,而是把老师课件和我自己的代码记录整合成一篇真正能复习、也能继续往后补的笔记。


一、这节课的主线

我先用一句话概括这节课:

先让 STM32 通过 I2C 驱动 SSD1306 OLED,再接上 u8g2 做图形显示,最后接 WouoUI 做多级菜单和参数调节。

所以可以把整节课拆成三层:

  1. 基础层:OLED 点亮和字符串显示

  2. 图形层:u8g2 的回调、缓冲和刷新

  3. UI 层:WouoUI 页面、回调、跳转和参数调节


二、基础层:先把 OLED 点亮

1. 这一层在解决什么

这一层只解决最基础的问题:

  • 单片机怎么和 OLED 通信

  • OLED 用的是什么芯片

  • 怎么把字符串显示出来

如果这一层没通,后面的图形和菜单都不用谈。

2. 先看芯片:SSD1306

不管学什么外设,第一件事都要先知道芯片型号。

这里常见的是 SSD1306,我现在只需要记住下面这些核心点:

  • 常见分辨率:128x64128x32

  • 常见接口:I2CSPI

  • 这节课主要用:I2C

  • 芯片内部有 GDDRAM

  • MCU 把命令和显示数据写进 GDDRAM,OLED 再根据数据点亮像素

所以 OLED 显示的本质就是:

单片机通过 I2C 把命令和数据发给 SSD1306。

3. SSD1306 的基本控制流程

和 SSD1306 交互时,整体流程可以简化成:

  1. 初始化 OLED

  2. 设置显示地址

  3. 发送显示数据

  4. 刷新显示

初始化阶段常见命令包括:

  • 0xAE:关闭显示

  • 0xA8:设置复用比

  • 0xD3:设置偏移

  • 0x8D:配置电荷泵

  • 0x20:设置地址模式

  • 0x81:设置对比度

  • 0xAF:开启显示

这里不要求全部死记,但要知道:

  • OLED 不是上电就能直接正常显示

  • 一定要先发一套初始化命令

  • 这些命令通常都在底层驱动的 OLED_Init()

4. I2C 模式下到底发的是什么

OLED 在 I2C 下主要接收两类东西:

  • 命令

  • 数据

常见控制字节:

  • 0x00:表示后面跟的是命令

  • 0x40:表示后面跟的是显示数据

也就是说,本质就是:

  • 先告诉 OLED 后面这串字节是什么类型

  • 再把真正的命令或数据发过去

5. 课件里的基础截图

先知道芯片,再看协议和 CubeMX 配置。

image-20260514230357186

IIC 协议和 CubeMX 配置在这里主要是辅助理解:

image-20260516164629901

image-20260516164935801

image-20260516200612099

我这里不展开抄配置步骤,只记住一句:

CubeMX 先把 I2C 外设初始化出来,后面 OLED 和 u8g2 都要建立在 hi2c1 上。

6. 基础显示封装:oled_printf()

这段代码很实用,应该保留:

 int oled_printf(uint8_t x, uint8_t y, const char *format, ...)
 {
   char buffer[512];
   va_list arg;
   int len;
 ​
   va_start(arg, format);
   len = vsnprintf(buffer, sizeof(buffer), format, arg);
   va_end(arg);
 ​
   OLED_ShowStr(x, y, buffer, 8);
   return len;
 }

它的作用是:

  • vsnprintf() 先把字符串格式化到缓冲区

  • 再用 OLED_ShowStr() 显示到指定位置

这样以后就可以像 printf 一样写:

 oled_printf(0, 0, "Hello World!!!");
 oled_printf(0, 1, "Welcome to MCU!");

一个基础示例如下:

 void oled_task(void)
 {
   oled_printf(0, 0, "Hello World!!!");
   oled_printf(0, 1, "Welcome to MCU!");
 }

这里的 oled_task() 只是“基础显示演示版本”。

也就是说,它只是为了说明:

  • OLED 已经点亮

  • 字符串已经能正常显示

后面接入菜单以后,oled_task() 的职责会变成“周期驱动 UI”,不再只是打印两行字符串。

7. 这一层的一个小坑

如果同一行新字符串比旧字符串短,旧字符可能残留。

例如:

 oled_printf(0, 1, "speed=123");
 oled_printf(0, 1, "speed=9");

后面可能残留旧内容,所以可以补空格:

 oled_printf(0, 1, "speed=9   ");

8. 基础层我现在真正要记住的点

  • OLED 底层芯片是 SSD1306

  • 通信方式主要是 I2C

  • 先初始化,再发数据

  • oled_printf() 是基础字符串显示封装

  • 这一层解决的是“屏幕能不能显示内容”


三、图形层:接入 u8g2

1. 为什么还要用 u8g2

如果只是显示几行字符串,基础驱动就够了。

但如果要做这些事:

  • 画图

  • 统一字体

  • 做图标

  • 后面接菜单系统

那就需要图形库。老师这里用的是 u8g2

所以我可以这样理解:

  • SSD1306驱动:解决“能不能通信”

  • u8g2:解决“怎么方便地画内容”

2. u8g2 为什么需要回调

u8g2 不是天然认识 STM32 HAL 的,所以要给它提供底层接口。

这里最关键的就是两个回调:

  • u8g2_gpio_and_delay_stm32()

  • u8x8_byte_hw_i2c()

3. u8g2_gpio_and_delay_stm32() 在干什么

这个回调主要负责两类事:

  • 延时

  • 某些底层 GPIO 操作

当前场景是硬件 I2C,所以最重要的是延时部分:

  • U8X8_MSG_DELAY_MILLIHAL_Delay()

  • U8X8_MSG_DELAY_10MICRO → 空循环延时

  • U8X8_MSG_DELAY_100NANO__NOP() 延时

而像:

  • U8X8_MSG_GPIO_I2C_CLOCK

  • U8X8_MSG_GPIO_I2C_DATA

在硬件 I2C 模式下通常可以忽略,因为引脚时序已经交给 I2C 外设了。

4. u8x8_byte_hw_i2c() 在干什么

这个函数才是 u8g2 和 HAL I2C 真正对接的核心。

可以按消息来理解:

  • U8X8_MSG_BYTE_START_TRANSFER
    开始一次传输,缓冲索引清零

  • U8X8_MSG_BYTE_SEND
    把 u8g2 给的数据先暂存在本地 buffer

  • U8X8_MSG_BYTE_END_TRANSFER
    真正调用 HAL_I2C_Master_Transmit() 把数据发出去

核心代码:

 if (HAL_I2C_Master_Transmit(&hi2c1,
                             u8x8_GetI2CAddress(u8x8),
                             buffer,
                             buf_idx,
                             100) != HAL_OK)
 {
   return 0;
 }

我对这段的理解是:

u8g2 负责组织要发送的显示数据,我负责在回调里把这些数据交给 HAL I2C 发送出去。

5. 缓冲刷新函数 OLED_SendBuff()

 void OLED_SendBuff(uint8_t buff[4][128])
 {
   uint8_t *u8g2_buffer = u8g2_GetBufferPtr(&u8g2);
   memcpy(u8g2_buffer, buff, 4 * 128);
   u8g2_SendBuffer(&u8g2);
 }

这段代码做了三件事:

  1. 拿到 u8g2 的缓冲区

  2. 把数据拷进去

  3. u8g2_SendBuffer() 刷新显示

这里要建立一个概念:

  • u8g2 不一定是“画一下就立刻上屏”

  • 它常常是“先画到缓冲区,再统一刷新”

6. u8g2 + WouoUI 初始化链路

你刚指出来的这张图,严格来说不是“菜单效果图”,而是“初始化流程图”。

它对应的主线应该单独记下来:

  1. u8g2_Setup_*():配置 u8g2 实例,绑定底层回调

  2. u8g2_InitDisplay():向 OLED 发送初始化序列

  3. u8g2_SetPowerSave(&u8g2, 0):唤醒屏幕,让屏幕亮起来

  4. WouoUI_SelectDefaultUI():选择当前使用的 UI

  5. WouoUI_AttachSendBuffFun(OLED_SendBuff):把刷新函数绑定给 WouoUI

  6. PIDMenu_Init():初始化页面对象、菜单项和参数窗口

这条链路可以理解成:

 先把屏幕底层配好
 -> 再把图形库配好
 -> 再把 UI 框架和页面绑上去
 -> 最后才能进入菜单运行阶段

这张截图对应的就是上面这条初始化链路:

image-20260517220945090

如果只记一句话,我会记成:

u8g2 负责把屏幕准备好,WouoUI 负责把菜单准备好,二者要通过 OLED_SendBuff() 这类刷新函数接起来。

7. 图形层我现在真正要记住的点

  • u8g2 是图形层,不是底层驱动

  • 需要自己写回调,把它接到 HAL I2C 上

  • 关键回调:延时回调、字节发送回调

  • u8g2_SendBuffer() 表示把缓冲区内容真正送到屏幕

  • u8g2_Setup -> InitDisplay -> SetPowerSave 是图形层初始化主线

  • 这一层解决的是“怎么方便地画图和刷新界面”


四、UI 层:接入 WouoUI 做多级菜单

1. 这一层在解决什么

有了 OLED 驱动和 u8g2 以后,已经可以显示图形和文字了。

但如果还想做这些:

  • 一级菜单

  • 二级菜单

  • 光标移动

  • 页面跳转

  • 参数调节

那就需要 UI 框架。

老师这里用的是 WouoUI

这时问题就从“能不能显示”变成了:

怎么把显示内容组织成能交互的菜单系统。

2. 这套菜单的结构

我现在这套例子是 PID 参数菜单,结构如下:

主菜单
├─ Left Wheel
│  ├─ P Parameter
│  ├─ I Parameter
│  └─ D Parameter
└─ Right Wheel
   ├─ P Parameter
   ├─ I Parameter
   └─ D Parameter

进入某个参数项后,再跳到 ValWin 页面做数值调节。

3. 页面对象

TitlePage main_menu;
ListPage left_wheel_menu;
ListPage right_wheel_menu;
ValWin param_adjust_val_win;

我的理解:

  • TitlePage:适合主菜单

  • ListPage:适合参数列表

  • ValWin:适合数值调节窗口

4. 真实数据和界面数据要分开看

真实 PID 数据:

typedef struct
{
  float p;
  float i;
  float d;
} PIDParams;

PIDParams left_wheel_pid = {1.0, 0.1, 0.01};
PIDParams right_wheel_pid = {1.0, 0.1, 0.01};

界面里的菜单选项:

Option left_wheel_options[] = {
    {.text = (char *)"- Left Wheel PID"},
    {.text = (char *)"$ P Parameter", .val = 0, .decimalNum = DecimalNum_2},
    {.text = (char *)"$ I Parameter", .val = 0, .decimalNum = DecimalNum_2},
    {.text = (char *)"$ D Parameter", .val = 0, .decimalNum = DecimalNum_2}};

这里一定要分清:

  • left_wheel_pid / right_wheel_pid 是真实业务数据

  • Option[].val 是页面显示和调节时用的值

两者需要同步。

5. 主菜单回调在做什么

MainMenu_CallBack() 的核心逻辑:

  1. 检查是不是点击事件 msg_click

  2. 拿到当前选中的菜单项

  3. 判断是左轮还是右轮

  4. 先把真实 PID 数据同步到菜单项 val

  5. 跳转到对应的二级菜单

这里最关键的理解是:

页面跳转前,先把真实数据同步到界面。

6. 左右轮菜单回调在做什么

LeftWheelMenu_CallBack()RightWheelMenu_CallBack() 结构基本一致。

它们主要做:

  1. 判断当前点的是不是 P / I / D

  2. 根据参数类型设置:

    • 最小值

    • 最大值

    • 步长

  3. 跳转到 ValWin

核心思想是:

同一个 ValWin 可以重复利用,只是每次进去时范围和步长不同。

7. 参数调节窗口回调在做什么

ParamAdjust_CallBack() 是这套逻辑里最值得看懂的函数。

它主要做三件事:

  1. msg_up / msg_right 时增加数值

  2. msg_down / msg_left 时减少数值

  3. msg_click 时把结果写回真实 PID 数据

关键代码:

Page *parent = (Page *)val_win->page.last_page;
Option *select_opt = WouoUI_ListTitlePageGetSelectOpt(parent);

这说明:

  • ValWin 知道自己是从哪个页面跳过来的

  • 它也知道来源页面当前选中的是哪个选项

所以它就能判断:

  • 这是左轮还是右轮

  • 当前调的是 P、I 还是 D

  • 最终应该把值写回哪个真实变量

8. PIDMenu_Init() 在做什么

PIDMenu_Init() 是把整套 UI 串起来的地方,主要做了 4 件事:

  1. 选择默认 UI

  2. 把真实 PID 值同步到菜单项

  3. 初始化各个页面对象

  4. 初始化 ValWin

关键初始化调用:

 WouoUI_TitlePageInit(&main_menu, ...);
 WouoUI_ListPageInit(&left_wheel_menu, ...);
 WouoUI_ListPageInit(&right_wheel_menu, ...);
 WouoUI_ValWinPageInit(&param_adjust_val_win, ...);

我现在不用死背参数,但要知道:

每个页面都要先初始化,后面的跳转和回调才会正常工作。

9. UI 的周期任务

 void oled_task(void)
 {
   WouoUI_Proc(10);
 }

这个函数可以理解成 UI 的“心跳”。

注意,这里的 oled_task() 已经不是前面“基础显示示例”的那个语义了。

这里它真正的职责是:

  • 周期调用 WouoUI_Proc()

  • 驱动菜单状态更新

  • 驱动界面刷新

所以如果按学习阶段来区分:

  • 前面的 oled_task():是 OLED 点亮后的演示任务

  • 这里的 oled_task():是菜单系统运行任务

实际工程里通常只保留最终版本,不会把两个阶段的写法同时留在一个工程里。

只要它周期运行,WouoUI 才能不断处理:

  • 输入消息

  • 页面状态

  • 动画和更新

  • 刷新显示

如果它不执行,菜单就不会正常动起来。

10. UI 层我现在真正要记住的点

  • TitlePage 管主菜单

  • ListPage 管参数列表

  • ValWin 管数值调节

  • 回调函数是菜单逻辑核心

  • 跳转前先同步数据

  • 确认后要把界面值写回真实业务变量

  • PIDMenu_Init() 负责把页面和菜单初始化起来

  • WouoUI_Proc() 需要周期调用


五、把三层串起来理解

这节课最容易学乱的地方,是把所有库函数混在一起记。

其实按层理解就清楚了:

1. 基础层

负责:

  • I2C 通信

  • SSD1306 初始化

  • 基本文字显示

关键词:

  • HAL_I2C

  • SSD1306

  • OLED_ShowStr

  • oled_printf

2. 图形层

负责:

  • 图形绘制

  • 缓冲区管理

  • 屏幕刷新

关键词:

  • u8g2

  • 回调函数

  • u8g2_SendBuffer

3. UI 层

负责:

  • 页面

  • 菜单

  • 回调

  • 数值调节

关键词:

  • WouoUI

  • TitlePage

  • ListPage

  • ValWin

  • WouoUI_Proc

所以整个链路可以写成:

 按键输入
 -> WouoUI 处理页面逻辑
 -> u8g2 负责图形缓冲和绘制
 -> HAL I2C 把数据发给 SSD1306
 -> OLED 显示结果

六、我现在最该掌握的最小主线

如果要应付当前这节课,我觉得最小主线只有这 6 个点:

  1. 知道 OLED 底层芯片是 SSD1306

  2. 知道通过 I2C 与 OLED 通信

  3. 会用 oled_printf() 显示字符串

  4. 知道 u8g2 需要通过回调接到 HAL I2C

  5. 知道 WouoUI 用页面 + 回调 + 跳转来实现菜单

  6. 能看懂当前 PID 菜单代码的跳转关系

只要这 6 个点通了,这一讲主干就算真正学到了。


七、u8g2 裁剪的目的和思路

u8g2 是一个通用图形库,内部支持很多不同型号的显示屏、接口方式和缓冲模式。
但在实际嵌入式项目中,我们通常只会用到其中一种硬件组合,比如:

  • OLED 控制器:SSD1306

  • 分辨率:128x32

  • 接口:I2C

  • 缓冲模式:Full Buffer

如果把 u8g2 的所有驱动和初始化代码全部编译进工程,会占用大量不必要的 Flash 空间。
因此,需要对库进行裁剪,只保留当前项目真正会用到的部分。

1. 精简显示驱动文件

csrc 文件夹中的 u8x8_d_*.c 文件是不同显示控制器的底层驱动实现。
如果当前使用的是 SSD1306 128x32 OLED,那么只需要保留和它对应的驱动文件,例如:

  • u8x8_d_ssd1306_128x32_univision.c

其他不相关的 u8x8_d_*.c 文件都可以删除。

这样做的原因是:

  • 减少无关显示驱动参与编译

  • 节省单片机 Flash

  • 让工程更精简、更容易理解

2. 精简 u8g2_d_setup.c

u8g2_d_setup.c 中包含很多 u8g2_Setup_...() 函数,
每个函数都对应一种“显示控制器 + 接口 + 分辨率 + 缓冲模式”的组合。

例如:

 u8g2_Setup_ssd1306_i2c_128x32_univision_f

这个函数表示:

  • ssd1306:控制器型号

  • i2c:通信接口

  • 128x32:分辨率

  • univision:屏幕型号/布局

  • _f:全缓冲模式

如果工程里只会调用这个 setup 函数,那么其他 u8g2_Setup_...() 函数都可以删除。

这样做的原因是:

  • 只保留当前硬件组合的初始化入口

  • 进一步减少 Flash 占用

3. 精简 u8g2_d_memory.c

u8g2_d_memory.c 中包含不同缓冲模式对应的内存管理函数 u8g2_m_...
如果当前保留的 setup 函数使用的是:

 u8g2_Setup_ssd1306_i2c_128x32_univision_f

那么它会配套使用某个特定的内存函数,例如:

 u8g2_m_16_4_f

因此,只需要保留这个被实际调用的内存管理函数,其他 u8g2_m_... 函数都可以删除。

这样做的原因是:

  • 删除其他缓冲模式对应的冗余代码

  • 继续节省 Flash

4. 关于缓冲区大小的理解

这里要特别注意一个容易出错的地方:

128x32 单色 OLED 的全缓冲大小不是 64 bytes,而是:

 128 × 32 ÷ 8 = 512 bytes

u8g2_m_16_4_f 中的 16_4 不是表示 16字节 × 4行 = 64字节
而是表示:

  • 横向 16 个 tile

  • 纵向 4 个 tile

  • 每个 tile 是 8x8 像素,对应 8 bytes

所以总缓冲区大小是:

 16 × 4 × 8 = 512 bytes

这正好对应 128x32 屏幕的全缓冲需求。

5. 总结

u8g2 裁剪的本质就是:

  • 只保留当前 OLED 所需的驱动文件

  • 只保留当前使用的 setup 函数

  • 只保留当前缓冲模式对应的内存函数

这样做主要是为了节省 Flash,让工程更小、更清晰。
而真正决定 RAM 占用大小的,主要是所选择的缓冲模式,例如:

  • _f:全缓冲,占用 RAM 更多

  • _1_2:页缓冲,占用 RAM 更少

所以可以一句话概括为:

u8g2 的裁剪主要是为了去掉不用的显示驱动、初始化入口和缓冲管理代码,只保留 SSD1306 + I2C + 128x32 + Full Buffer 这一套真正需要的实现,从而减小程序体积。


八、我自己的理解补充

1. 为什么老师要按这个顺序讲

老师的顺序其实很合理:

  1. 先学底层驱动

  2. 再学图形库

  3. 最后学 UI 框架

因为如果直接上菜单,不知道底层怎么通信,就会变成只会抄代码。

2. 这节课最重要的不是背 API

而是建立一个分层概念:

  • 最底层负责通信

  • 中间层负责绘制

  • 最上层负责交互

以后换屏幕、换图形库、换 UI 框架时,也还是这个思路。

3. 后面这篇笔记还能继续补什么

后面如果继续扩展,我建议补这些:

  • u8g2 初始化函数每个参数的含义

  • u8g2WouoUI 的完整初始化顺序

  • WouoUI 的按键消息是怎么送进去的

  • main() 或任务循环里的完整调用链

  • 当前 PID 菜单的页面跳转图


九、当前这篇笔记对应的关键函数

我现在最应该重点读懂的是这些函数:

  • oled_printf():基础字符串显示封装

  • u8g2_gpio_and_delay_stm32():u8g2 的延时/GPIO回调

  • u8x8_byte_hw_i2c():u8g2 的 I2C 发送回调

  • OLED_SendBuff():缓冲区刷新到屏幕

  • MainMenu_CallBack():主菜单跳转

  • LeftWheelMenu_CallBack():左轮参数菜单

  • RightWheelMenu_CallBack():右轮参数菜单

  • ParamAdjust_CallBack():参数调节和写回

  • PIDMenu_Init():整套菜单初始化

  • oled_task():周期驱动 UI


十、附录:完整代码保留

下面把我原来笔记里的完整代码保留下来,方便后面直接对照和抄写。

1. 初始化流程关键代码(根据课堂截图整理)

这段不是完整工程代码,而是老师课上强调的“初始化主线”。

 // --- u8g2 初始化 ---
 u8g2_Setup_ssd1306_i2c_128x32_univision_f(&u8g2, U8G2_R0, u8x8_byte_hw_i2c, u8g2_gpio_and_delay_stm32);
 u8g2_InitDisplay(&u8g2);
 u8g2_SetPowerSave(&u8g2, 0);
 ​
 // --- WouoUI 初始化 ---
 WouoUI_SelectDefaultUI();
 WouoUI_AttachSendBuffFun(OLED_SendBuff);
 PIDMenu_Init();

我现在对这段的理解是:

  • 前三句让 OLED 和 u8g2 真正进入可工作状态

  • 后三句让 WouoUI 和菜单系统真正挂到显示链路上

2. 基础显示代码

 #include "oled_app.h"
 ​
 int oled_printf(uint8_t x, uint8_t y, const char *format, ...)
 {
   char buffer[512]; // 临时存储格式化后的字符串
   va_list arg;      // 处理可变参数
   int len;          // 最终字符串长度
 ​
   va_start(arg, format);
   // 安全地格式化字符串到 buffer
   len = vsnprintf(buffer, sizeof(buffer), format, arg);
   va_end(arg);
 ​
   OLED_ShowStr(x, y, buffer, 8);
   return len;
 }
 ​
 void oled_task(void)
 {
   // 清屏通常是需要的,否则旧内容会保留
   oled_printf(0, 0, "Hello World!!!");
   oled_printf(0, 1, "Welcome to MCU!");
   // 刷新显示到屏幕 (如果驱动库需要)
   // OLED_Refresh_Gram(); // 取决于驱动库是否有显存刷新机制
 }

补充:

 oled_printf(0, 1, "      ");

如果变量字符串变短,可以在结尾多补几个空格,避免旧字符残留。

注意:

  • 这里的 oled_task() 是“基础显示阶段”的演示写法

  • 后面菜单系统里的 oled_task() 是“UI运行阶段”的写法

  • 两者代表不同学习阶段,不是要同时放进最终工程里

3. u8g2 + 多级菜单完整代码

 #include "oled_app.h"
 ​
 // PID 参数范围和步进宏定义
 #define PID_PARAM_P_MIN 0    // P 参数最小值 (0.00)
 #define PID_PARAM_P_MAX 1000 // P 参数最大值 (10.00)
 #define PID_PARAM_P_STEP 10  // P 参数步进值 (0.10)
 ​
 #define PID_PARAM_I_MIN 0    // I 参数最小值 (0.00)
 #define PID_PARAM_I_MAX 1000 // I 参数最大值 (10.00)
 #define PID_PARAM_I_STEP 10  // I 参数步进值 (0.10)
 ​
 #define PID_PARAM_D_MIN 0   // D 参数最小值 (0.00)
 #define PID_PARAM_D_MAX 100 // D 参数最大值 (1.00)
 #define PID_PARAM_D_STEP 1  // D 参数步进值 (0.01)
 ​
 /**
  * @brief   使用类似printf的方式显示字符串,显示6x8大小的ASCII字符
  * @param x  Character position on the X-axis  range:0 - 127
  * @param y  Character position on the Y-axis  range:0 - 3
  * 例如:oled_printf(0, 0, "Data = %d", dat);
  **/
 int oled_printf(uint8_t x, uint8_t y, const char *format, ...)
 {
   char buffer[512]; // 临时存储格式化后的字符串
   va_list arg;      // 处理可变参数
   int len;          // 最终字符串长度
 ​
   va_start(arg, format);
   // 安全地格式化字符串到 buffer
   len = vsnprintf(buffer, sizeof(buffer), format, arg);
   va_end(arg);
 ​
   OLED_ShowStr(x, y, buffer, 8);
   return len;
 }
 ​
 // u8g2 的 GPIO 和延时回调函数
 uint8_t u8g2_gpio_and_delay_stm32(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
 {
   switch (msg)
   {
   case U8X8_MSG_GPIO_AND_DELAY_INIT:
     // 初始化 GPIO (如果需要,例如 SPI 的 CS, DC, RST 引脚)
     // 对于硬件 I2C,这里通常不需要做什么
     break;
   case U8X8_MSG_DELAY_MILLI:
     HAL_Delay(arg_int);
     break;
   case U8X8_MSG_DELAY_10MICRO:
     {
       for (volatile uint32_t i = 0; i < 480; i++)
       {
         __NOP();
       }
     }
     break;
   case U8X8_MSG_DELAY_100NANO:
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     __NOP();
     break;
   case U8X8_MSG_GPIO_I2C_CLOCK:
   case U8X8_MSG_GPIO_I2C_DATA:
     break;
   case U8X8_MSG_GPIO_CS:
     break;
   case U8X8_MSG_GPIO_DC:
     break;
   case U8X8_MSG_GPIO_RESET:
     break;
   case U8X8_MSG_GPIO_MENU_SELECT:
     u8x8_SetGPIOResult(u8x8, 0);
     break;
   default:
     u8x8_SetGPIOResult(u8x8, 1);
     break;
   }
   return 1;
 }
 ​
 // u8g2 的硬件 I2C 通信回调函数
 uint8_t u8x8_byte_hw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
 {
   static uint8_t buffer[32];
   static uint8_t buf_idx;
   uint8_t *data;
 ​
   switch (msg)
   {
   case U8X8_MSG_BYTE_SEND:
     data = (uint8_t *)arg_ptr;
     while (arg_int > 0)
     {
       buffer[buf_idx++] = *data;
       data++;
       arg_int--;
     }
     break;
   case U8X8_MSG_BYTE_INIT:
     break;
   case U8X8_MSG_BYTE_SET_DC:
     break;
   case U8X8_MSG_BYTE_START_TRANSFER:
     buf_idx = 0;
     break;
   case U8X8_MSG_BYTE_END_TRANSFER:
     if (HAL_I2C_Master_Transmit(&hi2c1, u8x8_GetI2CAddress(u8x8), buffer, buf_idx, 100) != HAL_OK)
     {
       return 0;
     }
     break;
   default:
     return 0;
   }
   return 1;
 }
 ​
 /* 缓存刷新函数 */
 void OLED_SendBuff(uint8_t buff[4][128])
 {
   uint8_t *u8g2_buffer = u8g2_GetBufferPtr(&u8g2);
   memcpy(u8g2_buffer, buff, 4 * 128);
   u8g2_SendBuffer(&u8g2);
 }
 ​
 // 页面对象
 TitlePage main_menu;
 ListPage left_wheel_menu;
 ListPage right_wheel_menu;
 ValWin param_adjust_val_win;
 ​
 // 左右轮PID参数数据
 typedef struct
 {
   float p;
   float i;
   float d;
 } PIDParams;
 ​
 PIDParams left_wheel_pid = {1.0, 0.1, 0.01};
 PIDParams right_wheel_pid = {1.0, 0.1, 0.01};
 ​
 // 第一级菜单选项(左右轮选择)
 #define MAIN_MENU_NUM 2
 Option main_menu_options[MAIN_MENU_NUM] = {
     {.text = (char *)"+ Left Wheel", .content = (char *)"Left"},
     {.text = (char *)"+ Right Wheel", .content = (char *)"Right"}};
 ​
 // 第一级菜单图标
 Icon main_menu_icons[MAIN_MENU_NUM] = {
     [0] = {0xFC, 0xFE, 0xFF, 0x3F, 0x1F, 0x0F, 0x07, 0x03, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
            0x01, 0x01, 0x01, 0x01, 0x01, 0x03, 0x07, 0x07, 0x0F, 0x1F, 0x3F, 0xFF, 0xFE, 0xFC, 0xFF, 0x01,
            0x00, 0x00, 0x00, 0x00, 0xFC, 0xFC, 0xFC, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFC, 0xFC,
            0x00, 0x00, 0xFC, 0xFC, 0xFC, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xF0, 0xC0, 0x00,
            0x00, 0x00, 0x03, 0x07, 0x0F, 0x1F, 0x3E, 0x3C, 0x3C, 0x3C, 0x1E, 0x1F, 0x0F, 0x03, 0x00, 0x00,
            0x1F, 0x3F, 0x3F, 0x1F, 0x00, 0x00, 0x00, 0xC0, 0xF0, 0xFF, 0xCF, 0xDF, 0xFF, 0xFF, 0xFE, 0xFC,
            0xF8, 0xF8, 0xF0, 0xF0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0xF0,
            0xF8, 0xF8, 0xFC, 0xFE, 0xFF, 0xFF, 0xDF, 0xCF},
     [1] = {0xFC, 0xFE, 0x7F, 0x3F, 0x1F, 0x0F, 0x07, 0x03, 0x83, 0x81, 0x01, 0x01, 0x81, 0xE1, 0xE1, 0xE1,
            0xE1, 0x81, 0x01, 0x81, 0x81, 0x83, 0x03, 0x07, 0x0F, 0x1F, 0x3F, 0x7F, 0xFE, 0xFC, 0xFF, 0x01,
            0x00, 0x00, 0x00, 0xE0, 0xE0, 0xF3, 0xFF, 0xFF, 0x3F, 0x0F, 0x07, 0x07, 0x03, 0x03, 0x07, 0x07,
            0x0F, 0x3F, 0xFF, 0xFF, 0xF7, 0xE0, 0xE0, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0xE0, 0x80, 0x00,
            0x00, 0x01, 0x01, 0x3B, 0x7F, 0x7F, 0x7F, 0x3C, 0x78, 0xF8, 0xF0, 0xF0, 0xF8, 0x78, 0x3C, 0x3F,
            0x7F, 0x7F, 0x33, 0x01, 0x01, 0x00, 0x00, 0x80, 0xE0, 0xFF, 0xCF, 0xDF, 0xFF, 0xFF, 0xFE, 0xFC,
            0xF8, 0xF0, 0xF0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE1, 0xE1, 0xE1, 0xE1, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0,
            0xF0, 0xF8, 0xFC, 0xFC, 0xFF, 0xFF, 0xDF, 0xCF}
 };
 ​
 // 左轮PID菜单选项
 Option left_wheel_options[] = {
     {.text = (char *)"- Left Wheel PID"},
     {.text = (char *)"$ P Parameter", .val = 0, .decimalNum = DecimalNum_2},
     {.text = (char *)"$ I Parameter", .val = 0, .decimalNum = DecimalNum_2},
     {.text = (char *)"$ D Parameter", .val = 0, .decimalNum = DecimalNum_2}};
 ​
 // 右轮PID菜单选项
 Option right_wheel_options[] = {
     {.text = (char *)"- Right Wheel PID"},
     {.text = (char *)"$ P Parameter", .val = 0, .decimalNum = DecimalNum_2},
     {.text = (char *)"$ I Parameter", .val = 0, .decimalNum = DecimalNum_2},
     {.text = (char *)"$ D Parameter", .val = 0, .decimalNum = DecimalNum_2}};
 ​
 // 主菜单回调函数
 bool MainMenu_CallBack(const Page *cur_page_addr, InputMsg msg)
 {
   if (msg_click == msg)
   {
     Option *select_item = WouoUI_ListTitlePageGetSelectOpt(cur_page_addr);
     if (!strcmp(select_item->content, "Left"))
     {
       left_wheel_options[1].val = (int32_t)(left_wheel_pid.p * 100);
       left_wheel_options[2].val = (int32_t)(left_wheel_pid.i * 100);
       left_wheel_options[3].val = (int32_t)(left_wheel_pid.d * 100);
       WouoUI_JumpToPage((PageAddr)cur_page_addr, &left_wheel_menu);
     }
     else if (!strcmp(select_item->content, "Right"))
     {
       right_wheel_options[1].val = (int32_t)(right_wheel_pid.p * 100);
       right_wheel_options[2].val = (int32_t)(right_wheel_pid.i * 100);
       right_wheel_options[3].val = (int32_t)(right_wheel_pid.d * 100);
       WouoUI_JumpToPage((PageAddr)cur_page_addr, &right_wheel_menu);
     }
   }
   return false;
 }
 ​
 // 左轮菜单回调函数
 bool LeftWheelMenu_CallBack(const Page *cur_page_addr, InputMsg msg)
 {
   if (msg_click == msg)
   {
     Option *select_item = WouoUI_ListTitlePageGetSelectOpt(cur_page_addr);
     if (select_item->order >= 1 && select_item->order <= 3)
     {
       int min_val, max_val, step;
       switch (select_item->order)
       {
       case 1:
         min_val = PID_PARAM_P_MIN;
         max_val = PID_PARAM_P_MAX;
         step = PID_PARAM_P_STEP;
         break;
       case 2:
         min_val = PID_PARAM_I_MIN;
         max_val = PID_PARAM_I_MAX;
         step = PID_PARAM_I_STEP;
         break;
       case 3:
         min_val = PID_PARAM_D_MIN;
         max_val = PID_PARAM_D_MAX;
         step = PID_PARAM_D_STEP;
         break;
       default:
         return false;
       }
 ​
       WouoUI_ValWinPageSetMinStepMax(&param_adjust_val_win, min_val, step, max_val);
       WouoUI_JumpToPage((PageAddr)cur_page_addr, &param_adjust_val_win);
     }
   }
   return false;
 }
 ​
 // 右轮菜单回调函数
 bool RightWheelMenu_CallBack(const Page *cur_page_addr, InputMsg msg)
 {
   if (msg_click == msg)
   {
     Option *select_item = WouoUI_ListTitlePageGetSelectOpt(cur_page_addr);
     if (select_item->order >= 1 && select_item->order <= 3)
     {
       int min_val, max_val, step;
       switch (select_item->order)
       {
       case 1:
         min_val = PID_PARAM_P_MIN;
         max_val = PID_PARAM_P_MAX;
         step = PID_PARAM_P_STEP;
         break;
       case 2:
         min_val = PID_PARAM_I_MIN;
         max_val = PID_PARAM_I_MAX;
         step = PID_PARAM_I_STEP;
         break;
       case 3:
         min_val = PID_PARAM_D_MIN;
         max_val = PID_PARAM_D_MAX;
         step = PID_PARAM_D_STEP;
         break;
       default:
         return false;
       }
 ​
       WouoUI_ValWinPageSetMinStepMax(&param_adjust_val_win, min_val, step, max_val);
       WouoUI_JumpToPage((PageAddr)cur_page_addr, &param_adjust_val_win);
     }
   }
   return false;
 }
 ​
 // 参数调整弹窗回调函数
 bool ParamAdjust_CallBack(const Page *cur_page_addr, InputMsg msg)
 {
   ValWin *val_win = (ValWin *)cur_page_addr;
   Page *parent = (Page *)val_win->page.last_page;
   Option *select_opt = WouoUI_ListTitlePageGetSelectOpt(parent);
 ​
   if (msg == msg_up || msg == msg_right)
   {
     WouoUI_ValWinPageValIncrease(val_win);
     return true;
   }
   else if (msg == msg_down || msg == msg_left)
   {
     WouoUI_ValWinPageValDecrease(val_win);
     return true;
   }
   else if (msg == msg_click)
   {
     if (parent == (Page *)&left_wheel_menu)
     {
       switch (select_opt->order)
       {
       case 1:
         left_wheel_pid.p = val_win->val / 100.0f;
         break;
       case 2:
         left_wheel_pid.i = val_win->val / 100.0f;
         break;
       case 3:
         left_wheel_pid.d = val_win->val / 100.0f;
         break;
       }
     }
     else if (parent == (Page *)&right_wheel_menu)
     {
       switch (select_opt->order)
       {
       case 1:
         right_wheel_pid.p = val_win->val / 100.0f;
         break;
       case 2:
         right_wheel_pid.i = val_win->val / 100.0f;
         break;
       case 3:
         right_wheel_pid.d = val_win->val / 100.0f;
         break;
       }
     }
   }
   return false;
 }
 ​
 void PIDMenu_Init(void)
 {
   WouoUI_SelectDefaultUI();
 ​
   WouoUI_BuffClear();
   WouoUI_BuffSend();
   WouoUI_GraphSetPenColor(1);
 ​
   left_wheel_options[1].val = (int32_t)(left_wheel_pid.p * 100);
   left_wheel_options[2].val = (int32_t)(left_wheel_pid.i * 100);
   left_wheel_options[3].val = (int32_t)(left_wheel_pid.d * 100);
 ​
   right_wheel_options[1].val = (int32_t)(right_wheel_pid.p * 100);
   right_wheel_options[2].val = (int32_t)(right_wheel_pid.i * 100);
   right_wheel_options[3].val = (int32_t)(right_wheel_pid.d * 100);
 ​
   WouoUI_TitlePageInit(&main_menu, MAIN_MENU_NUM, main_menu_options, main_menu_icons, MainMenu_CallBack);
   WouoUI_ListPageInit(&left_wheel_menu, sizeof(left_wheel_options) / sizeof(Option), left_wheel_options, Setting_none, LeftWheelMenu_CallBack);
   WouoUI_ListPageInit(&right_wheel_menu, sizeof(right_wheel_options) / sizeof(Option), right_wheel_options, Setting_none, RightWheelMenu_CallBack);
   WouoUI_ValWinPageInit(&param_adjust_val_win, NULL, 0, 0, 1000, 10, true, true, ParamAdjust_CallBack);
 }
 ​
 /* Oled 显示任务 */
 void oled_task(void)
 {
   WouoUI_Proc(10);
 }

十一、一句话总结

这一讲本质上是在学:

如何从 OLED 底层驱动出发,一层层搭到可交互的多级菜单界面。