第九讲 OLED + U8G2 + 多级菜单
这节课不是单独学一个 OLED,而是在学一条完整链路:
STM32 HAL I2C -> SSD1306驱动 -> u8g2图形库 -> WouoUI菜单
也就是说,老师这节课是按“底层通信 → 图形显示 → 界面交互”的顺序往上搭。
我整理这篇笔记时,目标不是把课件重抄一遍,而是把老师课件和我自己的代码记录整合成一篇真正能复习、也能继续往后补的笔记。
一、这节课的主线
我先用一句话概括这节课:
先让 STM32 通过 I2C 驱动 SSD1306 OLED,再接上 u8g2 做图形显示,最后接 WouoUI 做多级菜单和参数调节。
所以可以把整节课拆成三层:
-
基础层:OLED 点亮和字符串显示
-
图形层:u8g2 的回调、缓冲和刷新
-
UI 层:WouoUI 页面、回调、跳转和参数调节
二、基础层:先把 OLED 点亮
1. 这一层在解决什么
这一层只解决最基础的问题:
-
单片机怎么和 OLED 通信
-
OLED 用的是什么芯片
-
怎么把字符串显示出来
如果这一层没通,后面的图形和菜单都不用谈。
2. 先看芯片:SSD1306
不管学什么外设,第一件事都要先知道芯片型号。
这里常见的是 SSD1306,我现在只需要记住下面这些核心点:
-
常见分辨率:
128x64、128x32 -
常见接口:
I2C、SPI -
这节课主要用:
I2C -
芯片内部有
GDDRAM -
MCU 把命令和显示数据写进 GDDRAM,OLED 再根据数据点亮像素
所以 OLED 显示的本质就是:
单片机通过 I2C 把命令和数据发给 SSD1306。
3. SSD1306 的基本控制流程
和 SSD1306 交互时,整体流程可以简化成:
-
初始化 OLED
-
设置显示地址
-
发送显示数据
-
刷新显示
初始化阶段常见命令包括:
-
0xAE:关闭显示 -
0xA8:设置复用比 -
0xD3:设置偏移 -
0x8D:配置电荷泵 -
0x20:设置地址模式 -
0x81:设置对比度 -
0xAF:开启显示
这里不要求全部死记,但要知道:
-
OLED 不是上电就能直接正常显示
-
一定要先发一套初始化命令
-
这些命令通常都在底层驱动的
OLED_Init()里
4. I2C 模式下到底发的是什么
OLED 在 I2C 下主要接收两类东西:
-
命令
-
数据
常见控制字节:
-
0x00:表示后面跟的是命令 -
0x40:表示后面跟的是显示数据
也就是说,本质就是:
-
先告诉 OLED 后面这串字节是什么类型
-
再把真正的命令或数据发过去
5. 课件里的基础截图
先知道芯片,再看协议和 CubeMX 配置。

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



我这里不展开抄配置步骤,只记住一句:
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_MILLI→HAL_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);
}
这段代码做了三件事:
-
拿到
u8g2的缓冲区 -
把数据拷进去
-
调
u8g2_SendBuffer()刷新显示
这里要建立一个概念:
-
u8g2 不一定是“画一下就立刻上屏”
-
它常常是“先画到缓冲区,再统一刷新”
6. u8g2 + WouoUI 初始化链路
你刚指出来的这张图,严格来说不是“菜单效果图”,而是“初始化流程图”。
它对应的主线应该单独记下来:
-
u8g2_Setup_*():配置u8g2实例,绑定底层回调 -
u8g2_InitDisplay():向 OLED 发送初始化序列 -
u8g2_SetPowerSave(&u8g2, 0):唤醒屏幕,让屏幕亮起来 -
WouoUI_SelectDefaultUI():选择当前使用的 UI -
WouoUI_AttachSendBuffFun(OLED_SendBuff):把刷新函数绑定给 WouoUI -
PIDMenu_Init():初始化页面对象、菜单项和参数窗口
这条链路可以理解成:
先把屏幕底层配好
-> 再把图形库配好
-> 再把 UI 框架和页面绑上去
-> 最后才能进入菜单运行阶段
这张截图对应的就是上面这条初始化链路:

如果只记一句话,我会记成:
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() 的核心逻辑:
-
检查是不是点击事件
msg_click -
拿到当前选中的菜单项
-
判断是左轮还是右轮
-
先把真实 PID 数据同步到菜单项
val -
跳转到对应的二级菜单
这里最关键的理解是:
页面跳转前,先把真实数据同步到界面。
6. 左右轮菜单回调在做什么
LeftWheelMenu_CallBack() 和 RightWheelMenu_CallBack() 结构基本一致。
它们主要做:
-
判断当前点的是不是
P / I / D -
根据参数类型设置:
-
最小值
-
最大值
-
步长
-
-
跳转到
ValWin
核心思想是:
同一个
ValWin可以重复利用,只是每次进去时范围和步长不同。
7. 参数调节窗口回调在做什么
ParamAdjust_CallBack() 是这套逻辑里最值得看懂的函数。
它主要做三件事:
-
在
msg_up / msg_right时增加数值 -
在
msg_down / msg_left时减少数值 -
在
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 件事:
-
选择默认 UI
-
把真实 PID 值同步到菜单项
-
初始化各个页面对象
-
初始化
ValWin
关键初始化调用:
WouoUI_TitlePageInit(&main_menu, ...);
WouoUI_ListPageInit(&left_wheel_menu, ...);
WouoUI_ListPageInit(&right_wheel_menu, ...);
WouoUI_ValWinPageInit(¶m_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 个点:
-
知道 OLED 底层芯片是
SSD1306 -
知道通过
I2C与 OLED 通信 -
会用
oled_printf()显示字符串 -
知道
u8g2需要通过回调接到 HAL I2C -
知道
WouoUI用页面 + 回调 + 跳转来实现菜单 -
能看懂当前 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. 为什么老师要按这个顺序讲
老师的顺序其实很合理:
-
先学底层驱动
-
再学图形库
-
最后学 UI 框架
因为如果直接上菜单,不知道底层怎么通信,就会变成只会抄代码。
2. 这节课最重要的不是背 API
而是建立一个分层概念:
-
最底层负责通信
-
中间层负责绘制
-
最上层负责交互
以后换屏幕、换图形库、换 UI 框架时,也还是这个思路。
3. 后面这篇笔记还能继续补什么
后面如果继续扩展,我建议补这些:
-
u8g2初始化函数每个参数的含义 -
u8g2和WouoUI的完整初始化顺序 -
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(¶m_adjust_val_win, min_val, step, max_val);
WouoUI_JumpToPage((PageAddr)cur_page_addr, ¶m_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(¶m_adjust_val_win, min_val, step, max_val);
WouoUI_JumpToPage((PageAddr)cur_page_addr, ¶m_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(¶m_adjust_val_win, NULL, 0, 0, 1000, 10, true, true, ParamAdjust_CallBack);
}
/* Oled 显示任务 */
void oled_task(void)
{
WouoUI_Proc(10);
}
十一、一句话总结
这一讲本质上是在学:
如何从 OLED 底层驱动出发,一层层搭到可交互的多级菜单界面。