1. 先把需求拆开:这块 0.96 寸小屏到底能做到什么程度
0.96 寸 OLED、STM32、U8g2,这三样凑在一起,几乎是每个嵌入式新手绕不开的一道坎。有人想做个温湿度小仪表,有人想做设备状态面板,有人就是想给毕设加个"看着高级"的界面。但真动手才发现,网上教程千篇一律地"点亮一个 Hello World"就没了,往下走图形、中文、动画,全是断的。我这篇就按从 0 到 1 的完整路径写,把STM32 移植 U8g2 库实现图形、中文与动画显示这条链路里每一个会遇到问题的节点摊开讲:从选型取舍、总线配置、库文件裁剪,到回调函数怎么写、中文字库怎么造、动画怎么跑顺,再到我这些年踩过的、文档里绝对不会写的坑。
先说清楚这块屏的能力边界。市面上常见的 0.96 寸模块,绝大多数是 128×64 分辨率、单色、SSD1306 控制器(也有一批是 SH1106,两者指令集接近但不完全兼容,尺寸也有 132×64 的差异,买板子的时候一定要看清单页参数)。128×64 单色听起来寒酸,实际上能塞下 21 个 6×8 的英文字符 × 8 行,或者 10 个 12×12 的汉字 × 5 行。做菜单、做仪表盘、做简单的数据显示,完全够用;做视频、做复杂 UI 就不现实了。认清这一点,后面的所有取舍才有依据。
1.1 放弃自己写驱动、选择 U8g2 的真实理由
很多人第一反应是"我自己对着数据手册写 SSD1306 的初始化序列不就行了"。确实不难,一次写对也就两三百行。但你很快会撞上第二堵墙:字库。自己写驱动意味着字库也得自己管,ASCII 还好说,中文怎么办?一个 GB2312 一级字库就是几千个字,取模、存表、查索引,这套东西做下来,比你想象的耗时间。
U8g2 解决的恰好就是这件事。它把控制器驱动和字体渲染分成了两层:底层只负责"把一段字节按某种时序吐给屏",上层负责"把字符、线段、方框变成像素点"。这种分层带来的直接好处是,换屏、换总线、换字体都不用动底层逻辑。我在几个项目里做过对比,同样是 128×64、同一颗 MCU,自己写驱动加手工取模字库,从零到能显示中文要两三天;用 U8g2,硬件连对之后一个下午就能出图。
代价也要说清楚。U8g2 的代码量不小,全量加进来 Flash 占几十 KB,选中文字库之后会更夸张。它的刷屏 API 是阻塞式的,默认没有做脏矩形,全屏刷新在低速总线上会成为帧率瓶颈。这两点后面会专门处理。
1.2 I2C 还是 SPI:这个选择决定了你的动画上限
买模块的时候会看到两个版本,四针的(VCC/GND/SCL/SDA)是 I2C,七针的(多了 DC、RST、CS)是 SPI。很多人随手选了 I2C 因为接线少,然后在做动画的时候发现怎么调都卡。
算笔账就明白了。一屏 128×64 单色,显存是 128×64/8 = 1024 字节。I2C 快速模式 400kHz,每个字节含应答位实际传输约 9 bit,光数据就是 1024×9/400000 ≈ 23 毫秒,再加上每页的命令开销和 HAL 库函数调用本身的固定耗时,实测一次全屏刷新普遍落在 28~35 毫秒,也就是理论上限 30 帧左右,实际能稳住 20 帧就算不错。SPI 跑到 10MHz 的话,1024 字节纯数据只需 0.8 毫秒,算上开销也就两三毫秒,差距是十倍级的。
所以我的经验判断很直接:只做文字和静态图形,I2C 完全够用;要做 20 帧以上的动画、要做滚屏、要做波形滚动,尽量上 SPI。如果已经焊死了 I2C 板子,也不是不能动,后面第 5 章会讲怎么用局部刷新把 I2C 的动画救回来。
1.3 内存这件事必须先算清楚,再动手写代码
U8g2 有三种缓冲模式,命名规则藏在 setup 函数后缀里,这是新手最容易忽略的一处设计:
| 后缀 | 缓冲模式 | RAM 占用(128×64) | 适用场景 |
|---|---|---|---|
_1 | 单页缓冲 | 约 128 字节 | RAM 极度紧张的小容量 MCU |
_2 | 双页缓冲 | 约 256 字节 | 折中方案,画图时不能跨页 |
_f | 全屏缓冲 | 1024 字节 | 标准选择,支持任意绘图与局部刷新 |
另外u8g2_t这个结构体本身也要吃掉几百字节(具体随编译器和配置浮动,可以在编译映射文件里查)。对于 RAM 只有 8KB 的型号,1024 字节的显存不算轻松但完全能承受;对于 RAM 有 20KB 的型号,就没什么好犹豫的,直接上_f。
需要注意:单页缓冲模式下,你画的东西必须落在当前页(8 像素高的横条)内才能正确显示,跨页绘制会被裁掉。很多人抄了
_1的示例代码然后发现图形"只剩一条",八成就是这个原因。
2. 工程骨架搭建:从裸机点亮到库文件落位
动手之前,把工程结构先理清楚,后面调试会省很多事。我习惯的做法是在工程根目录下建一个Drivers/u8g2/目录,把源码全部放进去,然后在 IDE 里把这个目录加进头文件搜索路径。不要把源文件散落在各个角落,等你要裁剪字体、要升级版本的时候就知道有多麻烦。
2.1 引脚与总线配置的具体做法
以 I2C 为例,硬件 I2C 和软件模拟 I2C 是两条路。
硬件 I2C 的好处是不占 CPU,通信速率稳定,缺点是要占用固定的复用引脚,而且某些早期型号的 I2C 外设存在已知的时序缺陷,超时处理要格外小心。用 CubeMX 配置的时候,把 I2C 设成 Fast Mode,速率填 400000;时钟树里 APB1 的频率决定了定时寄存器的分频值,CubeMX 会自动算,但你要去看一眼它算出来的是不是真的接近 400kHz,有些版本在低主频下算出来的实际速率会偏低。
软件模拟 I2C 的优点是指脚随便挑,移植到不同板子上几乎不用改代码,缺点是要在 GPIO 回调里手动翻转电平,速度上限取决于你的延时实现。U8g2 自带了一个u8x8_byte_sw_i2c,配合 GPIO 回调里处理 SCL/SDA 的置位清零就能跑,我在这类调试场景里用得挺多。
SPI 的话,接线上要分清四线制的含义:SCLK 是时钟,MOSI 是数据,DC 是数据/命令选择(高电平数据、低电平命令),CS 是片选。RST 建议接到一个普通 GPIO 上,用软件复位比靠上电复位可靠得多。SPI 模式配成 Mode 0(CPOL=0、CPHA=0),数据宽度 8 位,先发高位。速率可以先从 4MHz 起步确认能跑通,再往上加到 10MHz 甚至更高,具体上限看模块的走线和排线长度,长排线跑高速容易出雪花点。
2.2 U8g2 源码裁剪与目录组织
把整个源码包拖进工程是很粗暴的做法,编译时间会明显变长。真正需要的文件其实不多:
u8g2.h、u8g2.c:核心绘图与字体渲染u8x8.h、u8x8.c:底层通信与消息分发u8x8_d_ssd1306_128x64_noname.c:你实际使用的这颗控制器驱动u8g2_fonts.c或者你选定的字体文件u8x8_byte_*.c、u8x8_gpio_and_delay.c:通信接口模板,可改可自写
其余几十个u8x8_d_xxx.c是给别的控制器用的(比如各种 LCD、电子纸),全删掉不影响编译,能省不少 Flash 和编译时间。裁剪的时候从驱动文件名反推,比如你用的是u8g2_Setup_ssd1306_i2c_128x64_noname_f,那对应的就是u8x8_d_ssd1306_128x64_noname.c。删完编译一次,如果报链接错误,说明还漏了文件,报错信息会直接告诉你哪个符号找不到,照着补就行。
2.3 三个环节把库"接上电":setup、byte 回调、gpio 回调
U8g2 的移植本质上就三件事,做完这三件事屏就能亮。
第一件是选对 setup 函数。命名规则是u8g2_Setup_<控制器>_<总线>_<分辨率>_<型号变体>_<缓冲模式>:
u8g2_t u8g2; u8g2_Setup_ssd1306_i2c_128x64_noname_f(&u8g2, U8G2_R0, u8x8_byte_hw_i2c, u8x8_gpio_and_delay);第二个参数U8G2_R0是旋转方向,R0 是正常方向,R1/R2/R3 分别转 90/180/270 度。如果你的屏装反了,改这个参数就行,不用改硬件。
第二件是写字节传输回调。硬件 I2C 的版本是自己实现的,U8g2 官方没给现成的硬件实现(因为它不想绑死在某家的 HAL 上),所以要你自己补:
uint8_t u8x8_byte_hw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { static uint8_t buf[256]; static uint16_t cnt = 0; switch (msg) { case U8X8_MSG_BYTE_INIT: break; case U8X8_MSG_BYTE_START_TRANSFER: cnt = 0; break; case U8X8_MSG_BYTE_SEND: if (cnt + arg_int > sizeof(buf)) return 0; memcpy(&buf[cnt], arg_ptr, arg_int); cnt += arg_int; break; case U8X8_MSG_BYTE_END_TRANSFER: HAL_I2C_Master_Transmit(&hi2c1, u8x8_GetI2CAddress(u8x8), buf, cnt, 100); break; default: return 0; } return 1; }这段代码里有两个细节值得说。一是buf的大小,一次START_TRANSFER到END_TRANSFER之间的数据量不是固定的,全屏刷新时按页发送,单页就是 128 字节数据加上控制字节,所以 256 是安全值,但一定要加边界检查,不然越界写坏栈上的变量会表现为"莫名其妙重启",极难排查。二是u8x8_GetI2CAddress(u8x8)返回的已经是 8 位形式(即 0x78),直接丢给 HAL 就行,不要再左移一位,这是很多人第一次调不通的原因——地址算错了,设备根本没应答。
第三件是 GPIO 与延时回调:
uint8_t u8x8_gpio_and_delay(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch (msg) { case U8X8_MSG_DELAY_MILLI: HAL_Delay(arg_int); break; case U8X8_MSG_DELAY_10MICRO: delay_us(arg_int * 10); break; case U8X8_MSG_DELAY_100NANO: __NOP(); break; default: return 0; } return 1; }硬件 I2C 模式下这个回调里其实不需要处理引脚电平消息,那些是给软件模拟总线用的。但延时消息必须实现,U8g2 内部在复位时序和初始化序列里会用到毫秒级延时,你如果直接return 0,初始化会失败,表现就是白屏。
初始化顺序也别搞反:
u8g2_InitDisplay(&u8g2); // 送初始化序列 u8g2_SetPowerSave(&u8g2, 0); // 唤醒,0 是开显示 u8g2_ClearBuffer(&u8g2); // 清显存 u8g2_SendBuffer(&u8g2); // 推屏u8g2_SetPowerSave这一步是新手头号杀手。很多示例代码里写了但没强调,你漏掉它,屏就是黑的,然后开始怀疑接线、怀疑地址、怀疑人生。
3. 图形显示:把绘图 API 用出层次感
点亮之后进入正题。U8g2 的绘图 API 数量不算多,但组合起来能做的东西不少,关键在于理解它的坐标系和刷新逻辑。
3.1 坐标系、基线位置与刷新时机
坐标系是左上角原点,x 向右、y 向下,单位是像素。听起来简单,但文字绘制有个基线问题容易被坑:u8g2_DrawStr传入的 y 默认是字符的基线位置,不是顶部。你写u8g2_DrawStr(&u8g2, 0, 10, "ABC"),字符的顶部大约在 y=2 左右,下面还有 2 到 3 像素的降部空间。如果你按"y 是顶部"去排版,文字会整体偏高,跟方框对不齐。
解决办法有两个:一是用u8g2_SetFontPosTop(&u8g2)把对齐参考点改成顶部,排版时心算简单;二是统一用u8g2_GetAscent和u8g2_GetDescent拿到字体的上下沿,自己算偏移。我在需要精确对齐的界面里都用第二种,因为换字体的时候不用改排版代码。
刷新时机的逻辑是:所有Draw开头的函数都只改显存(RAM 里的那块缓冲区),不动屏幕。必须调用u8g2_SendBuffer才真正推给屏。这个"画完再推"的模型有个很大的好处——不会闪烁。如果你边画边推,屏幕上会出现明显的撕裂感,尤其是画多个元素的时候。所以标准套路永远是:ClearBuffer→ 一堆 Draw →SendBuffer。
3.2 常用原语与界面排版的实际套路
常用的几个函数我按使用频率列一下:
u8g2_DrawStr/u8g2_DrawUTF8:画英文字符串 / UTF-8 字符串u8g2_DrawBox/u8g2_DrawFrame:实心矩形 / 空心矩形u8g2_DrawRBox/u8g2_DrawRFrame:带圆角的实心 / 空心矩形,做现代感界面很出效果u8g2_DrawLine:直线,配合循环能画斜线和简单图表u8g2_DrawCircle/u8g2_DrawDisc:空心 / 实心圆u8g2_DrawXBMP:画位图,动画和图标的核心u8g2_DrawPixel:单点,做波形曲线时用
有个细节:绘制模式。u8g2_SetDrawColor(&u8g2, 1)是画上去,0是擦掉,2是异或。异或模式在单色屏上非常好用——在已有内容上盖一个反色区域,不用先擦后画,做光标闪烁、图片反白特别省事。
做个实际的状态面板吧,128×64 分成三块:顶部一条 16 像素高的标题栏、中间数据显示区、底部进度条。
u8g2_ClearBuffer(&u8g2); // 标题栏 u8g2_DrawBox(&u8g2, 0, 0, 128, 16); u8g2_SetDrawColor(&u8g2, 0); u8g2_SetFont(&u8g2, u8g2_font_6x10_tf); u8g2_DrawStr(&u8g2, 4, 12, "SYSTEM STATUS"); u8g2_SetDrawColor(&u8g2, 1); // 数据区 u8g2_SetFont(&u8g2, u8g2_font_7x14B_tf); u8g2_DrawStr(&u8g2, 4, 38, "23.5C"); u8g2_SetFont(&u8g2, u8g2_font_5x7_tf); u8g2_DrawStr(&u8g2, 70, 38, "HUM 41%"); // 底部进度条 u8g2_DrawFrame(&u8g2, 4, 50, 120, 10); u8g2_DrawBox(&u8g2, 6, 52, (uint8_t)(116 * 0.65), 6); u8g2_SendBuffer(&u8g2);这段代码里"反色标题栏"的做法值得单独提一句:先画实心矩形把背景填满,再把绘制颜色切成 0(擦除),这时画上去的文字就是在挖空,最后切回 1。这是单色屏做层次感最基础也最有效的手法,比花里胡哨的边框强得多。
3.3 位图与图标:取模参数选错等于白干
用u8g2_DrawXBMP画位图,最容易出问题的地方是取模软件的参数没配对。XBM 格式的规则是:字节按水平方向排列,每个字节表示连续 8 个像素,最低位对应最左边的像素,整体按行从上到下。每行的字节数是(宽度 + 7) / 8。
对应到常见的取模工具设置:
| 设置项 | 选择 |
|---|---|
| 取模方式 | 阴码(或阳码,视你需要正显还是反显) |
| 取模走向 | 逐行式 |
| 字节内位序 | 逆向(低位在前) |
| 输出格式 | C51 格式 |
选成"行列式"或者"高位在前",画出来的图就是一堆随机噪点,很多人看到这个现象会以为是画布尺寸算错了,其实是取模方向反了。
宽度不是 8 的整数倍时,最后多出来的那几位要补齐,数组长度按((w + 7) / 8) * h算。比如 20×20 的图标,每行 3 字节,总共 60 字节。用sizeof验证一下,对不上就说明参数配错了。
static const uint8_t icon_wifi[] = { /* 20x20 */ }; u8g2_DrawXBMP(&u8g2, 100, 4, 20, 20, icon_wifi);提示:
u8g2_DrawXBMP的位图指针在部分编译环境中要求常量位于 Flash,用const修饰是必须的,否则会被复制到 RAM 里,Flash 大的 MCU 上不明显,RAM 小的型号上直接爆。
4. 中文显示这条最容易被卡住的路
英文跑通之后,几乎所有人下一步都是"我要显示中文"。然后在这上面卡住的时间往往比前面加起来还长。原因不复杂:中文涉及编码、字库、渲染三层,任何一层对不上都表现为"啥也不显示"或者"一堆问号"。
4.1 UTF-8 编码与字库后缀的关系
U8g2 处理中文的路线是基于 UTF-8 的。一个汉字在 UTF-8 下占 3 个字节,库内部会逐字节解析,判断出这是一个多字节字符,然后去字库里按 Unicode 码位查字形。所以两件事必须同时成立:源文件编码是 UTF-8,字库是按 Unicode 索引的。
字库是不是 Unicode 索引,看函数名后缀就知道。带_t_的(比如u8g2_font_wqy12_t_gb2312)是支持 UTF-8 透明传输的字体;不带这个标记的,只能按单字节索引,画中文必然失败。
u8g2_SetFont(&u8g2, u8g2_font_wqy12_t_gb2312); u8g2_DrawUTF8(&u8g2, 0, 14, "温度");注意必须用u8g2_DrawUTF8而不是u8g2_DrawStr。DrawStr是单字节版本,它会把一个汉字拆成三个独立的字节去查字形,结果就是显示三个乱码字符。
还有个大坑:源文件编码。某些编辑器默认存成 GB2312 或者带 BOM 的 UTF-8,编译器读进去的字节序列跟你看到的不一样,屏幕上要么乱码要么空白。判断方法很简单,把字符串里的内容换成十六进制打出来看看前几个字节是不是符合 UTF-8 的规则(汉字首字节在 0xE4~0xE9 之间)。确认后,把工程里所有包含中文字符串的源文件统一改成 UTF-8 无 BOM,编辑器的默认编码也要改,一劳永逸。
4.2 三种中文字库方案的成本对比
| 方案 | 做法 | Flash 占用 | 优点 | 缺点 |
|---|---|---|---|---|
| 现成大字库 | 直接用官方的全字库字体 | 数十到上百 KB | 零工作量 | 占用大,小容量 MCU 放不下 |
| 裁剪子集 | 用转换工具只取需要的字 | 几 KB | 体积可控 | 需要维护字符集,改动要重新生成 |
| 外挂存储 | 字库放片外 Flash / SD | 几乎为零 | 本地 Flash 不占 | 增加硬件和读取逻辑,速度慢 |
我实际项目里的选择逻辑是:产品界面文字固定且有限(菜单项、提示语、单位符号),用裁剪子集,把界面上所有会用到的汉字收集起来,一次性生成,通常两三百个字,体积在 5KB 到 10KB 之间,非常轻。如果是通用设备、要显示用户输入的内容或者动态数据,才考虑外挂方案。
4.3 手工造一个精简字库的完整流程
工具链一般是:BDF 点阵字体文件 + 转换工具。转换工具的典型用法是这样:
bdfconv -f 1 -m "32-127" -M my_chars.txt -n my_font_cn -o my_font_cn.c wenquanyi_12pt.bdf参数含义:-f 1指 1bpp 单色输出,-m "32-127"保留 ASCII 可见字符区间(这样中英混排不用切字体),-M指定字符映射文件,-n是生成的字体变量名,-o是输出文件。具体参数以工具自带的帮助说明为准,不同版本有细微差别。
映射文件my_chars.txt里列出所有要保留的字符,一行一个,可以直接贴汉字,也可以写码位区间。建议把界面上所有文案先整理成一份文档,去重之后再生成,避免漏字。改文案的时候同步更新这份文件,重新跑一遍脚本即可。
生成出来的.c文件里会有一个u8g2_font_my_font_cn变量,声明成extern之后就能像内置字体一样使用。体积可以直接看编译后的映射文件,或者看目标文件里这个符号的大小,别凭感觉估。
注意:中文字体的行高比同像素的英文字体更紧凑。12 像素的中文字体,行距给 13 到 14 比较舒服;给 12 会挤在一起,给 16 又浪费空间。多行中文排版的时候,务实用
u8g2_GetFontAscent减去 descent 拿到实际行高,比硬编码数字可靠。
4.4 中英混排与宽度测量的实战细节
中英混排最麻烦的是对齐。英文字符宽度不一(除非用等宽字体),中文是等宽的,混在一起算总宽度就很容易算错。U8g2 提供了u8g2_GetUTF8Width,传入字符串返回像素宽度,直接拿来做居中和右对齐:
int w = u8g2_GetUTF8Width(&u8g2, "温度: 23.5C"); u8g2_DrawUTF8(&u8g2, (128 - w) / 2, 30, "温度: 23.5C");这套写法在中英混排下是准确的,因为库内部就是按 UTF-8 逐字符累加宽度的。但要注意,如果你的字体是裁剪子集,字符串里有不在子集内的字符,宽度计算会跳过它,显示上也会缺字,排版会歪。所以发布前把所有界面文案跑一遍,肉眼确认没有缺字。
另外,12 像素的中文字体在 128 宽的屏上,一行最多放 10 个汉字加标点,稍微长一点的句子就要折行。手工折行是可行的,但如果文案会变,建议写一个简单的自动折行函数:按字符逐个累加宽度,超过阈值就换行。二十行代码的事,比每次改文案都调坐标省心得多。
5. 动画显示:从"能跑"到"跑得顺"是两回事
图形和中文搞定,动画就是最后一关。这一关的难点不在 API,而在性能。128×64 全屏刷新的数据量是固定的 1024 字节,能优化的只有"刷多少"和"怎么刷"。
5.1 先定位瓶颈,再谈优化
动手前先量一下基线。在SendBuffer前后各翻转一个空闲 GPIO,用示波器或者逻辑分析仪测脉宽,得到的就是真实的单帧推屏耗时。我实测过的几组数据供参考:
| 总线配置 | 单帧耗时 | 理论帧率上限 |
|---|---|---|
| 软件 I2C,延时保守 | 40~60 ms | 16~25 fps |
| 硬件 I2C 400kHz | 28~35 ms | 28~35 fps |
| 硬件 SPI 4MHz | 4~6 ms | 160 fps 以上 |
| 硬件 SPI 10MHz | 2~3 ms | 300 fps 以上 |
看到这组数据就明白了:I2C 下你就算把绘图代码优化到零开销,帧率也上不去,因为时间全花在总线上。这时候要做的是减少传输量,而不是优化绘图。
5.2 全屏刷新与局部刷新的取舍
U8g2 提供了一个局部刷新接口u8g2_UpdateDisplayArea,只把指定区域推给屏。它的参数单位不是像素,而是8×8 的块(tile):
// 只刷新 x=0..63, y=16..47 这块区域 u8g2_UpdateDisplayArea(&u8g2, 0, 2, 8, 4); // tx ty tw th(单位:8x8 块)这里 tx 是起始块的列号(像素 x / 8),ty 是起始块的行号(像素 y / 8),tw 和 th 是块的数量。上面这行的意思是:从第 0 列块、第 2 行块开始,刷新 8 列块 × 4 行块,对应像素范围就是 x 0~63、y 16~47。
用这个接口做动画,思路就变了:整个界面里真正在动的那部分往往只占一小块,比如一个进度条、一段滚动的数字、一个跳动的小图标。把这块圈出来单独刷新,传输量可能只有全屏的十分之一,帧率立刻翻几倍。我在一个 I2C 的项目里做过这样的改造:一个全屏滚动的列表,改成只刷新内容区(去掉固定的标题栏和底栏),单帧从 32 毫秒降到 18 毫秒,观感从"卡顿"变成"顺滑"。
代价是它要求你用全屏缓冲模式_f,并且必须自己维护哪些区域变了。简单场景可以手工指定固定区域,复杂场景就需要一个脏矩形管理逻辑:记录本帧所有绘制操作影响到的矩形,合并成一个大的刷新区域。合并算法不需要多精确,取所有变化区域的包围盒就够用了。
5.3 位图序列动画的实际做法
最常见的动画需求是图标动起来,比如加载转圈、信号强度跳动、小人走路。做法就是把每一帧预先取模成 XBM 数组,然后按时间切换。
static const uint8_t *anim_frames[] = { frame_0, frame_1, frame_2, frame_3, }; void draw_anim(uint8_t idx, uint8_t x, uint8_t y) { u8g2_DrawXBMP(&u8g2, x, y, 24, 24, anim_frames[idx % 4]); }帧数一多,Flash 占用就要算清楚:24×24 的一帧是 3×24 = 72 字节,4 帧才 288 字节,随便放。但如果做的是 128×64 的全屏动画,一帧就是 1024 字节,30 帧就是 30KB,这时必须确认 Flash 余量够,别写到一半发现空间不够再回来删帧。
时间控制别用HAL_Delay硬等,那样整个主循环都被卡住,按键响应会变迟钝。正确做法是记时间戳,在主循环里判断间隔:
static uint32_t last_ms = 0; uint32_t now = HAL_GetTick(); if (now - last_ms >= 100) { // 10 帧每秒 last_ms = now; draw_anim(frame_idx++, 52, 20); u8g2_UpdateDisplayArea(&u8g2, 6, 2, 3, 3); }这里还有个小技巧:动画帧率不需要很高。人的视觉对 8~12 帧的循环动画接受度已经不错了,尤其是图标类的小动画。把帧率压到 10 帧,省下来的总线时间可以留给其他刷新,整体观感反而更稳。我见过有人死磕 30 帧,结果主循环里的传感器读取被拖慢,得不偿失。
5.4 DMA 与非阻塞刷新能救到什么程度
如果连局部刷新都还是不够,就上 DMA。思路是把"发送"这个动作交给硬件,CPU 发完启动命令就去干别的事,等传输完成中断回来再切下一帧。这在 SPI 上效果最明显,10MHz 下一帧 1024 字节只要 0.8 毫秒,DMA 完成中断几乎感觉不到。
I2C 上就复杂一些。HAL 库的 I2C DMA 传输在某些早期型号上存在已知的时序处理问题,表现为传输结束后总线没正确释放,下一次传输直接返回忙状态。规避办法是不用 DMA,改用中断方式,或者干脆接受阻塞,把优化重心放在减少传输量上。我在 I2C 项目上的经验是:局部刷新带来的收益远大于 DMA,不要在这上面死磕。
真要上 DMA,还有个绕不开的问题:什么时候可以改写缓冲区。DMA 正在发的那块显存,你不能同时往里写新内容,否则屏幕上会出现半新半旧的撕裂。需要双缓冲,画在 A 块、发 B 块,发完交换。U8g2 本身不提供这套机制,得自己在外面管两块 1024 字节的缓冲,用u8g2_GetBufferPtr拿到当前缓冲指针自己复制,或者干脆绕过SendBuffer自己写发送函数。这套东西做下来代码量不小,除非帧率是硬需求,否则性价比不高。
6. 常见问题速查与踩坑实录
写到这儿,把这些年遇到过的问题整理成一张速查表,遇到症状直接对号入座。
| 症状 | 最可能的原因 | 排查办法 |
|---|---|---|
| 完全黑屏 | 忘了SetPowerSave(0) | 检查初始化序列是否漏了唤醒 |
| 黑屏但总线有波形 | I2C 地址错(7 位/8 位搞混) | 用扫描逻辑确认设备应答地址 |
| 花屏、雪花点 | SPI 速率过高或排线过长 | 降到 1MHz 先确认,再逐步往上加 |
| 显示内容整体偏移 | 用了 SH1106 的屏但按 SSD1306 配置 | 换 SH1106 的 setup 函数或加列偏移 |
| 图形只剩一条横带 | 用了_1单页缓冲还跨页绘制 | 换成_f全屏缓冲 |
| 中文显示成乱码 | 源文件编码不是 UTF-8 | 改成 UTF-8 无 BOM 后重新编译 |
| 中文完全不显示 | 用了DrawStr而不是DrawUTF8 | 换函数 |
| 中文缺字 | 裁剪字库漏了字符 | 补字符重新生成字库 |
| 文字和边框对不齐 | 忽略了基线位置 | 用SetFontPosTop或手动算 ascent |
| 偶发重启 | 回调里的缓冲区越界 | 检查 memcpy 边界判断 |
| 动画卡顿 | 全屏刷新,总线带宽不够 | 改局部刷新或换 SPI |
| 屏幕闪烁 | 边画边推 | 改成画完再统一 SendBuffer |
6.1 白屏和花屏的排查顺序
我总结的顺序是:先看电源,再看复位,然后看总线,最后看配置。
电源这一条经常被跳过。OLED 模块对电压比较敏感,3.3V 供电时如果电流不足(尤其是屏上点亮像素多的时候),会出现局部发暗或者整屏闪。用万用表量一下模块 VCC 引脚在刷新时的电压波动,掉到 3.0V 以下就要考虑加电容或者换供电。
复位要分清硬件复位和软件复位。接了 RST 引脚的话,初始化前先拉低 10 毫秒再拉高,给控制器足够的上电稳定时间;没接 RST 的话,靠上电复位,某些模块在这种情况下初始化会失败,表现为白屏。这类模块我一般会飞一根线把 RST 接出来,麻烦一次省心很久。
总线问题用逻辑分析仪抓波形最快。看有没有应答位、数据是不是符合预期。如果没有设备应答,先确认地址;如果有应答但显示不对,看发送的数据前几个字节是不是初始化命令。
6.2 字库越界这种隐蔽问题
有个问题我遇到过一次,排查了大半天:屏幕上偶尔出现几个不该有的点,位置飘忽,重刷一次又好了。最后定位到是字库数组的索引越界读到了相邻的常量数据。
原因是我用了裁剪字库,字符串里混进了一个不在子集里的字符(从别处复制文案带过来的一个全角空格)。库去查这个码位,索引算出来超出了字库表的范围,读到了后面的内存。因为是在 Flash 里,不报错,只是显示成随机图案。
这类问题的排查手段是:在绘制前把字符串的每个码位打出来,跟字库支持的范围比对。写个一次性的检查函数,遍历所有界面文案,把不在范围内的码位列出来,比肉眼找快得多。字库生成的时候也尽量把常用标点、全角空格这些都包含进去,省得后面踩。
6.3 内存与栈的边界
全屏缓冲 1024 字节不是小数。如果在函数里定义u8g2_t结构体(虽然它内部是动态指向缓冲的,但结构体本身几百字节),再加上局部的大数组,很容易把默认栈撑爆。栈溢出在裸机上的表现千奇百怪:可能是某个变量莫名其妙变了值,可能是进 HardFault,可能是看着正常但跑久了重启。
稳妥的做法是把u8g2_t定义成全局变量或静态变量,不放栈上。启动文件里的栈大小也要看一眼,默认值通常只有 1KB 到 2KB,跑 U8g2 加浮点运算加点别的,很容易不够。改到 4KB 起步,RAM 紧张的型号按实际情况权衡。
实操心得:调栈大小的时候不要一次加太多,那样有问题也发现不了。先从 2KB 加到 4KB,跑一遍完整流程,没问题就停。RAM 是宝贵资源,留着给别的模块用。
7. 我在几个项目里沉淀下来的几条规矩
最后一个想聊的点,是关于"要不要把 U8g2 用在产品里"的判断。
早期我对这个库是有点偏见的,觉得它代码重、刷屏慢、不够"裸"。后来在几个不同规模的项目里反复用下来,看法变了。它真正值钱的不是绘图 API,而是那套字体渲染和控制器抽象。你自己写一套等价的,功能上能做到,但稳定性和边界处理的成熟度很难追上——尤其是各种奇奇怪怪的字体、各种控制器变体、各种分辨率组合,这套东西是用时间堆出来的。
所以我现在的判断标准很明确:如果项目里屏幕只是"显示信息",不需要高帧率,U8g2 是首选,能省下大量时间;如果屏幕本身是产品核心交互(比如要做 30 帧以上的动态效果、要做复杂图形),那就得往底层走,自己控驱动,甚至换带显存的方案,这时候 U8g2 的阻塞式刷屏会成为结构性瓶颈,绕不过去。
还有一条关于调试节奏的经验。永远分阶段验证:先确认屏能亮(哪怕只显示一个像素),再确认能画图形,再确认能显示英文,最后才上中文和动画。跳到后面的阶段去调,一旦出问题,你无法确定是前面哪一环埋的雷。我见过太多人一上来就把网上的完整工程整个拷进去,跑不通,然后不知道从哪下手,最后推倒重来。分阶段虽然看着慢,实际上是最快的。
再补一个具体的小技巧。调试 UI 布局的时候,在屏幕四角各画一个单像素点,再在中间画个十字,能瞬间判断出是坐标算错了还是驱动有问题。这个习惯我保持了七八年,看着土,但每次换新屏都是它先帮我确认方向。图形界面这东西,眼睛看到的和代码写的对不上是常态,有个已知的参照系,排查效率完全不一样。
关于裁剪字库,还有一个容易忽略的管理问题。字符集文件一定要跟工程一起进版本管理,并且在文件头写清楚"这个字库对应界面 v几"。我吃过一次亏:改文案的时候加了个"暂"字,忘了重新生成字库,测试的时候那一处显示成空白,还以为是对齐出了问题,折腾了半天才发现是缺字。现在我的习惯是生成脚本跟源文件放一起,改文案必跑脚本,跟编译一个节奏,这样就不会漏。