1. 先把需求拆开:这块小屏到底要在项目里干什么
STM32 接一块 0.96 寸 OLED,让它稳定地画出图形、显示中文、跑起动画,这件事听起来像入门作业,但真做下来,坑比想象的多。我这几年在好几个手持设备、桌面小工具和仪表类项目里反复用 U8g2 这套库,最深的一个感受是:大部分"屏幕不亮""花屏""中文变方块"的问题,都不是库的问题,而是移植那一层没对上。所以这篇东西不打算写成 API 手册,而是按我实际落地的顺序,从选型、接线、移植回调、图形绘制、中文字库、动画刷新一路讲到最后的问题排查,把我踩过的坑原样摆出来。
先把需求拆清楚。一个典型的 OLED 界面项目,通常同时存在三类诉求:第一类是静态信息展示,比如设备名、电量、温度、状态图标,这类内容变化频率低,追求的是排版整齐、字体清晰、断电重上电不出错;第二类是动态数据刷新,比如实时曲线、进度条、数值高速跳变,这类内容追求的是刷新率和局部更新的效率;第三类是交互反馈,比如菜单滚动、选中高亮、弹窗动画,这类内容追求的是视觉流畅度和响应延迟。这三类诉求对底层的要求是不一样的,前两类对内存敏感,第三类对帧率敏感,而 0.96 寸 128×64 的单色屏资源极其有限,必须提前想好取舍。
我见过不少新手直接拿厂商给的例程改,例程里通常只提供最基础的显示字符串和清屏功能,写到第二个界面就开始卡顿、闪烁、甚至死机。原因很简单:例程往往是一套"全屏刷"的实现,每次更新都把 1024 字节整帧推一遍,I2C 速度又只有 100kHz,一帧发完二三十毫秒,主循环里稍微多点任务就撑不住了。所以我更倾向从 U8g2 这种成熟的图形库起步,它的抽象层次刚好卡在"足够底层可控制"和"足够上层能省事"之间。
1.1 三个绕不开的核心诉求
第一个诉求是可移植性。项目从一块板子换到另一块板子,从 I2C 屏换到 SPI 屏,从 SSD1306 换成 SH1106,如果每次都要重写显示层,那这活儿没法干。U8g2 的分层设计恰好解决了这个问题:上层的绘图 API 完全不变,变的是底层那几个回调函数和构造器名字。你只要把回调写对,换屏就是改一行u8g2_Setup_xxx的事。
第二个诉求是内存可控。128×64 的单色屏,全屏帧缓冲是 1024 字节,这对 RAM 只有 20KB 的小容量型号来说是笔不小的开销。U8g2 提供了单页缓冲(_1)、双页缓冲(_2)和全屏缓冲(_f)三种模式,分别占 128 字节、256 字节和 1024 字节。选哪个不是拍脑袋,得看你有没有动画、有没有局部刷新、主循环里还有多少别的东西要跑。后面我会专门用一个章节把这笔账算清楚。
第三个诉求是中文和图形并存。纯英文的数字界面,用内置的 6×12、8×16 点阵字库就够了,Flash 占用可以忽略。但一旦要显示"设置""返回""正在连接"这类中文,字库体积会急剧膨胀——一个完整的 16×16 汉字库 GB2312 全集接近 260KB,很多小容量型号根本放不下。所以真实项目里基本都要做字库裁剪,只需要几十个字的场景裁到几 KB 是完全可行的。
1.2 为什么我没选厂商例程和另一套轻量库
厂商例程的优势是"开箱即跑",接线对了就能出字,但它的问题在于抽象太薄。你想要一个圆角矩形,得自己写 Bresenham 加圆弧;想要一个带透明背景的图标,得自己处理位运算;想要局部刷新,得自己算页地址和列地址。这些活儿单看都不难,加起来就是几百行重复劳动,而且每个项目都要重来一遍。
另一套常见思路是用那种极简的点阵库,只提供画点、画线、写字三个函数,代码量小、RAM 占用低。它的优势在资源极度紧张的场景确实明显,但短板是排版能力弱——没有字体的 ascent/descent 概念,没有 UTF-8 解析,没有旋转和镜像,做个稍微复杂点的界面就得疯狂手算坐标。
U8g2 的定位刚好在这两者之间。它的核心体积不算大,基础配置编译下来几十 KB Flash,绘图 API 覆盖了线、框、圆、椭圆、圆角矩形、多边形、位图、XBM、UTF-8 文本,还自带字体测量函数。更重要的是它有完整的"构造函数 + 回调"机制,你能精确控制每一次字节传输,出了问题可以一层层往下查,而不是面对一个黑盒。这也是我最终选它的核心理由。
2. 硬件这一层必须先搞清楚:型号、总线、时序
我遇到过最浪费时间的一次调试,是拿着一块标着 0.96 寸的模块,按 SSD1306 的初始化流程写了半天,屏幕就是只亮一部分。最后换了个构造器,把 SSD1306 改成 SH1106,一次点亮。问题出在模块上贴的标签根本不靠谱,而这两种控制器的显存组织方式不一样:SSD1306 是 128 列,SH1106 是 132 列,用 128 列的初始化序列去驱动 132 列的屏,画面会整体偏移两列,看起来就像是"左边缺一条、右边多一条"。
所以动手写代码之前,硬件这一层的确认工作一步都不能省。我的习惯是先只看模块正面丝印和背面丝印,找型号关键字;找不到就上电后用扫描的方式确认 I2C 地址(常见是 0x3C 和 0x3D 两个,模块背面一般有个电阻位可以改)。地址扫不到,先别怀疑代码,先怀疑线——四根线里只要 SDA 或 SCL 有一根虚焊,扫描就是空的。
2.1 控制器型号认错了,后面全是白干
常见的单色 OLED 控制器就那么几种,我把它们的关键差异整理成一张表,方便对照:
| 控制器 | 常见尺寸 | 显存列数 | 显存组织 | 典型症状 |
|---|---|---|---|---|
| SSD1306 | 128×64、128×32 | 128 | 分页,每页 8 行 | 标准流程即可点亮 |
| SH1106 | 128×64 | 132 | 分页,带 2 列偏移 | 整体偏移、边缘缺列 |
| SSD1309 | 128×64 | 128 | 与 SSD1306 接近 | 多数可直接兼容 |
| SSD1315 | 128×64 | 128 | 与 SSD1306 兼容 | 基本通用 |
表格里"典型症状"那一列是我自己的排查经验总结:如果你发现画面整体往右偏了 2 个像素,或者最左边有两个像素宽的黑边,八成就是控制器按 132 列组织显存,而你把初始化参数按 128 列写了。U8g2 里针对这种情况提供了不同的构造函数,u8g2_Setup_sh1106_i2c_128x64_noname_f和u8g2_Setup_ssd1306_i2c_128x64_noname_f就是两个不同的入口,别图省事混用。
注意:不要靠"试构造器"来确认型号。试错能点亮,但可能点亮的是错误配置,比如把 128×32 的屏按 128×64 驱动,画面下半部分会滚到上半部分去,短时间看不出来,跑久了才发现显示错位。确认型号最靠谱的办法是查模块资料,其次是看驱动 IC 的丝印。
2.2 I2C 还是 SPI:一笔算清楚的速度账
总线的选择直接影响你能做到什么程度的动画。我用一个具体的数字来说明。全屏 128×64 单色,一帧数据是 128×64÷8 = 1024 字节。走 I2C 的时候,每个字节要传 8 个数据位加 1 个应答位,共 9 个时钟周期;再算上每页一个控制字节(8 页共 8 字节)、起始地址和控制字节,总共约 1035 字节,也就是约 9315 个时钟周期。
I2C 跑 400kHz 的时候,9315 ÷ 400000 ≈ 23.3 毫秒。也就是说,一帧的理论下限就是 23 毫秒出头,换算成帧率上限大概是 43 帧每秒。实际测下来,加上函数调用开销、应答等待和主循环里的其他任务,稳定在 25 到 30 帧每秒是比较现实的水平。
换成 SPI,同样的 1035 字节约等于 8280 个时钟周期,SPI 跑 10MHz 的话,8280 ÷ 10000000 ≈ 0.83 毫秒。差了将近 28 倍。这个差距意味着什么?意味着用 I2C 你只能做"局部刷新 + 低频更新"的界面,而用 SPI 你可以做全屏滚动的动画。
| 对比维度 | I2C(400kHz) | SPI(10MHz) |
|---|---|---|
| 单帧理论耗时 | 约 23ms | 约 0.83ms |
| 帧率上限 | 约 43fps | 约 1200fps(受限于屏刷新率) |
| 引脚占用 | 2 根(SDA/SCL) | 4 到 5 根 |
| 抗干扰 | 较弱,长线易出错 | 较强 |
| 适合场景 | 静态信息、低频数据 | 动画、菜单滚动、波形 |
我的建议很直接:如果你的界面里有任何需要平滑移动的元素,直接上 SPI,别在 I2C 上折腾优化,投入产出比太低。如果只是显示几个数字和状态图标,I2C 足够,而且省引脚。
2.3 接线与电源的几个小动作
硬件上还有几个小细节值得单独说。第一是上拉电阻。很多模块自带上拉,直接接就行;但如果你的板子上拉了别的器件,或者线拉了十几厘米,就要考虑上拉阻值是不是合适。上拉太弱(阻值太大),上升沿变缓,400kHz 下容易出错;上拉太强(阻值太小),灌电流变大,功耗上来。一般 4.7k 到 10k 之间比较稳妥。
第二是电源去耦。OLED 在整屏点亮(比如全白画面)时瞬时电流会比平时大不少,如果电源走线细、没有就近的滤波电容,容易出现画面闪烁或者上电不亮。我通常会在模块电源脚旁边放一个 0.1μF 加一个 1μF 的组合,成本几乎为零,收益很明显。
第三是复位脚。四针模块通常只有 VCC、GND、SCL、SDA,没有独立的复位脚,控制器靠上电复位。这种情况下,上电时序就很重要——如果 MCU 启动比屏的上电复位还快,初始化命令可能在屏还没准备好的时候就发出去了,表现就是"偶尔能亮、偶尔不亮"。稳妥的做法是在初始化前加个 100 到 200 毫秒的延时,或者在初始化序列前面多发几次关闭显示的命令,让它强制进入已知状态。
3. 移植的骨架:四个回调函数决定成败
U8g2 的移植,说穿了就是把四个回调函数填对。它的分层是这么设计的:最上面是绘图 API(u8g2_DrawBox、u8g2_DrawStr之类),中间是构造层(负责某种控制器的初始化序列和刷新时序),最下面是通信层(负责把字节真正发出去)。通信层就是由你来实现的,U8g2 通过一组消息号调用你,你的任务是根据消息号做对应的事情。
这组消息号里,最核心的是四类:U8X8_MSG_BYTE_SEND(把一批数据存起来或直接发出去)、U8X8_MSG_BYTE_START_TRANSFER(开始一次传输,I2C 里对应起始条件)、U8X8_MSG_BYTE_END_TRANSFER(结束一次传输,对应停止条件)、U8X8_MSG_BYTE_INIT(通信外设初始化)。另外还有一组 GPIO 和延时消息,用来控制引脚电平和提供各种量级的延时。
我特别要强调一点:U8X8_MSG_BYTE_SEND不一定只调用一次。在一次刷新中,构造层可能分多批把数据交给你,具体批数取决于控制器和缓冲模式。所以我看到过有人只用一个固定长度的小数组接收,结果发到一半就溢出,表现就是"屏幕上半部分正常、下半部分乱码"。这个坑后面在问题排查里还会提到。
3.1 先搞懂 U8g2 的分层结构
我把这个分层用一句话概括:构造层决定"发什么",通信层决定"怎么发"。构造层知道 SSD1306 需要先发 0x00 控制字节再发命令、需要先发 0x40 控制字节再发数据、需要在刷新时按页设置起始行;通信层只关心怎么把一串字节按 I2C 或 SPI 的时序送出去。
理解这一点之后,很多问题就好定位了。如果屏幕上出现的是"内容是对的但位置不对",那多半是构造层选错了(比如控制器型号不对)。如果屏幕上出现的是"内容完全错乱、像噪点",那多半是通信层的问题(时序不对、地址不对、丢字节)。如果是"根本没有任何反应",先查电源和地址,再查初始化是否真的执行了。
这种分层的好处是调试成本低。我在通信层里加一个计数器,统计每次刷新的字节数,如果发现某次刷新只发了 200 个字节,那就说明数据在中间被截断了,问题一定出在我的回调里,跟绘图逻辑无关。这种"用数据量反推问题位置"的思路,比盲目改代码高效得多。
3.2 软件 I2C 版字节回调
先给软件 I2C 的完整实现,因为这是最不依赖外设、最容易在其他平台上复用的写法。底层三个时序函数分别是起始、停止和写字节:
/* 引脚操作宏,按实际 GPIO 修改 */ #define OLED_SCL_SET() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET) #define OLED_SCL_CLR() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET) #define OLED_SDA_SET() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET) #define OLED_SDA_CLR() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET) #define OLED_SDA_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7) static void sw_delay(void) { /* 空循环,具体次数按主频调整,400kHz 下大约 1 到 2 微秒 */ for (volatile uint8_t i = 0; i < 6; i++) { } } static void i2c_start(void) { OLED_SDA_SET(); OLED_SCL_SET(); sw_delay(); OLED_SDA_CLR(); sw_delay(); OLED_SCL_CLR(); sw_delay(); } static void i2c_stop(void) { OLED_SDA_CLR(); OLED_SCL_SET(); sw_delay(); OLED_SDA_SET(); sw_delay(); } static uint8_t i2c_write_byte(uint8_t b) { uint8_t ack; for (uint8_t i = 0; i < 8; i++) { if (b & 0x80) { OLED_SDA_SET(); } else { OLED_SDA_CLR(); } b <<= 1; sw_delay(); OLED_SCL_SET(); sw_delay(); OLED_SCL_CLR(); sw_delay(); } /* 释放 SDA,让从机有机会拉低表示应答 */ OLED_SDA_SET(); sw_delay(); OLED_SCL_SET(); sw_delay(); ack = OLED_SDA_READ(); OLED_SCL_CLR(); sw_delay(); return ack; }有了这三个函数,字节回调就是一层翻译:
uint8_t u8x8_byte_stm32_sw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { uint8_t *data; switch (msg) { case U8X8_MSG_BYTE_SEND: data = (uint8_t *)arg_ptr; while (arg_int--) { i2c_write_byte(*data++); } break; case U8X8_MSG_BYTE_INIT: /* 引脚方向已在外设初始化里配置好,这里留空 */ break; case U8X8_MSG_BYTE_START_TRANSFER: i2c_start(); /* 注意:这里返回的已经是左移一位后的 8 位写地址,不要再左移 */ i2c_write_byte(u8x8_GetI2CAddress(u8x8)); break; case U8X8_MSG_BYTE_END_TRANSFER: i2c_stop(); break; default: return 0; } return 1; }这里面有个我见过最多的错误:u8x8_GetI2CAddress(u8x8)返回的已经是把 7 位地址左移一位之后的值。默认 0x3C 会返回 0x78,正好是 I2C 写操作的地址字节。如果你习惯性地再写一个<< 1,地址就变成 0xF0,屏自然不会有任何反应。这个坑我在早期至少踩过两次,而且每次都以为是硬件问题,折腾半小时才想起来。
3.3 GPIO 与延时回调
第二组回调负责引脚电平和延时。软件 I2C 的时候,引脚电平由上面的宏直接操作,这一层可以留空;但如果用 U8g2 自带的软件 I2C 实现(u8x8_byte_sw_i2c),就必须在这里处理U8X8_MSG_GPIO_I2C_CLOCK和U8X8_MSG_GPIO_I2C_DATA两个消息。
uint8_t u8x8_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: /* 引脚初始化已经在别处完成,这里可留空 */ break; case U8X8_MSG_DELAY_MILLI: HAL_Delay(arg_int); break; case U8X8_MSG_DELAY_10MICRO: for (volatile uint16_t i = 0; i < arg_int * 20; i++) { } break; case U8X8_MSG_DELAY_100NANO: /* 纳秒级延时靠空循环近似,编译优化等级变化时要重新标定 */ break; case U8X8_MSG_DELAY_I2C: for (volatile uint8_t i = 0; i < 4; i++) { } break; case U8X8_MSG_GPIO_I2C_CLOCK: if (arg_int) { OLED_SCL_SET(); } else { OLED_SCL_CLR(); } break; case U8X8_MSG_GPIO_I2C_DATA: if (arg_int) { OLED_SDA_SET(); } else { OLED_SDA_CLR(); } break; default: return 0; } return 1; }延时这里有个容易忽略的细节:空循环的次数和编译优化等级强相关。调试的时候-O0,能跑;换到发布版本-O2,循环被优化掉,时序就崩了,表现是"调试版能用、发布版花屏"。所以我现在的习惯是,凡是靠空循环做的时序,一律用volatile变量并且留足余量,宁可慢一点也不要卡在临界值上。如果 MCU 支持,用硬件定时器或者__NOP()序列更稳妥。
注意:如果软件 I2C 的引脚配置成了推挽输出,那么在从机拉低 SDA 应答的时候,两边会"打架",长期这么用可能损伤引脚。正确做法是把 SDA 配置成开漏输出(外部上拉),或者输出高电平时切换成输入模式读取电平。这一点在数据手册里写得很清楚,但很多人图省事直接用推挽,短期能用,长期是隐患。
3.4 硬件 I2C 版本的改写要点
如果板子上已经有硬件 I2C 外设,用它比软件模拟更省 CPU。改写的时候,结构完全一样,只是把底层的三个时序函数换成外设调用。这里的核心技巧是攒够一批再发,而不是每来一个字节就调一次传输函数,因为每次调用都有起始、地址、停止的固定开销,频繁调用会把总线效率拉得很低。
uint8_t u8x8_byte_stm32_hw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { static uint8_t buf[256]; static uint16_t idx; uint8_t *data; switch (msg) { case U8X8_MSG_BYTE_SEND: data = (uint8_t *)arg_ptr; while (arg_int > 0 && idx < sizeof(buf)) { buf[idx++] = *data++; arg_int--; } break; case U8X8_MSG_BYTE_INIT: break; case U8X8_MSG_BYTE_START_TRANSFER: idx = 0; break; case U8X8_MSG_BYTE_END_TRANSFER: if (HAL_I2C_Master_Transmit(&hi2c1, u8x8_GetI2CAddress(u8x8), buf, idx, 100) != HAL_OK) { /* 传输失败,可以在这里做个错误计数,方便定位 */ } break; default: return 0; } return 1; }缓冲区大小是这里最容易出问题的地方。全屏刷新的时候,构造层会按页把 128 字节一页的数据交过来,加上控制字节,一次大约 129 到 132 字节。如果你按 32 或 64 定义缓冲区,数据就会被截断,屏幕上表现为"每页只有前几十列有内容,后面全是黑的或者乱码"。我直接把缓冲区开到 256 字节,代价是 256 字节 RAM,换来的是彻底不用担心截断,这笔买卖很划算。
4. 图形绘制:把 API 用出组合拳
移植通了之后,接下来就是怎么把界面画好看。U8g2 的绘图 API 数量不少,但常用的就那么十来个,我把它们按用途归成几类,用的时候按类去找,比一个个翻文档快。
坐标系是左上角原点,X 向右,Y 向下,单位是像素。这一点和数学坐标系相反,和大多数屏幕坐标系一致。我刚开始写的时候总把 Y 方向搞反,画进度条的时候从下往上长,调试了半天才发现是y的起始点算错了。记住一句话就不会错:画矩形的时候,y是顶边,高度往下加。
4.1 坐标系与绘制函数族
常用函数的用途和参数含义整理成表格,方便查阅:
| 函数 | 用途 | 关键参数 |
|---|---|---|
u8g2_DrawPixel | 画单个像素 | x, y |
u8g2_DrawLine | 画直线 | x0, y0, x1, y1 |
u8g2_DrawHLine/VLine | 水平/垂直直线 | 起点 + 长度 |
u8g2_DrawBox | 填充矩形 | x, y, w, h |
u8g2_DrawFrame | 空心矩形 | x, y, w, h |
u8g2_DrawRBox/RFrame | 圆角填充/空心矩形 | 多了圆角半径 r |
u8g2_DrawCircle/Disc | 空心/实心圆 | 圆心 + 半径 |
u8g2_DrawEllipse | 椭圆 | 圆心 + 两个半径 |
u8g2_DrawTriangle | 三角形 | 三个顶点 |
u8g2_DrawXBMP | 单色位图 | x, y, w, h, 数据指针 |
u8g2_DrawStr | ASCII 字符串 | x, y, 字符串 |
u8g2_DrawUTF8 | UTF-8 字符串 | x, y, 字符串 |
有一个参数含义需要特别强调:文本绘制函数里的y不是文字的顶边,而是基线位置。也就是说,字母 "g" 的下半部分会画在y的下方。这一点和很多图形库不一样,我第一次用的时候把文字画在了屏幕外面,还以为是字体加载失败。正确的做法是先用u8g2_GetAscent拿到基线到顶部的距离,再把y设成"顶边 + ascent"。
u8g2_SetFont(&u8g2, u8g2_font_6x12_tf); int ascent = u8g2_GetAscent(&u8g2); u8g2_DrawStr(&u8g2, 0, ascent, "HELLO");这段代码的意思是:把文字顶边对齐到屏幕第 0 行。看起来很绕,但理解了基线这个概念之后就顺了。而且这个机制是有好处的——同一行里混排不同大小的字体时,只要基线对齐,视觉上就是齐的,不需要每个字体单独算偏移。
4.2 位图图标:取模、透明色与内存
图标是让界面"看起来像个产品"的关键。单色屏的图标本质是一张 1 位深度的位图,每个像素一个 bit,1 表示亮,0 表示灭。U8g2 提供了u8g2_DrawXBMP,接收的是一种叫 XBM 的格式,这个格式和国内常见的取模软件默认输出格式不一样,这是个大坑。
差异在两处:第一是位序,XBM 是低位在前(LSB first),很多取模软件默认高位在前;第二是排列方向,XBM 是横向逐字节排列(先从左到右扫完一行,再换下一行),而很多取模软件默认是纵向逐字节(先从上到下扫完一列)。这两处只要错一个,图标出来就是乱码。
我的处理办法是统一用同一个工具链:要么全部用支持 XBM 输出的取模工具,要么干脆用脚本把常规取模数据转成 XBM。转换逻辑不复杂,就是把每个字节的 8 个 bit 反转,然后按行重排。写一次脚本,以后所有图标都能复用。
/* 16x16 的图标数据,XBM 格式:低位在前,横向排列 */ static const uint8_t icon_battery[] = { 0x00, 0x00, 0xF8, 0x1F, 0x08, 0x10, 0x08, 0x10, 0x08, 0x10, 0x08, 0x10, 0x08, 0x10, 0xF8, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; u8g2_DrawXBMP(&u8g2, 100, 0, 16, 16, icon_battery);关于"透明色",单色屏没有真正的透明概念,只有"画"和"不画"两种。DrawXBMP的规则是:数据位为 1 的像素点亮,为 0 的像素保持原样不动。这个特性非常有用——意味着你可以先把背景画好,再把图标叠上去,图标里为 0 的部分不会把背景擦掉,效果就是透明的。
内存方面,一个 16×16 的图标占 32 字节,一个 32×32 的占 128 字节。界面上放十来个图标,总共也就几百字节 Flash,完全可以接受。真正占空间的是字库,图标反而是最省的。
4.3 字体与排版:像素对齐这件小事
字体选择上,U8g2 内置了几十种,名字里带_tf的是透明背景(transparent font),带_tr的是带背景色,带_nu的是数字专用。透明字体只画字形本身的像素,背景保持不动,适合叠在已有画面上;带背景的字体每次会把字符框填满,适合做"反白高亮"的效果。
真正影响观感的是像素对齐。128×64 的屏,像素颗粒肉眼可见,如果文字或边框落在半像素位置,边缘就会发虚。虽然单色屏没有抗锯齿,但只要坐标是整数就不会有问题,需要注意的反而是"视觉对齐"——比如一个 12 像素高的字和一个 16 像素高的图标放在同一行,基线和中心线都要分别对齐,看起来才舒服。
我的经验做法是:先在纸上按 8 像素网格画一遍布局草图,确定每一块的 x、y 起点,然后代码里全部用常量或者宏定义,不要到处写魔法数字。改布局的时候只改一处,避免前后不一致。
#define UI_TITLE_Y 12 #define UI_ICON_X 2 #define UI_ICON_Y 0 #define UI_VALUE_X 24 #define UI_VALUE_Y 12 #define UI_BAR_X 2 #define UI_BAR_Y 48 #define UI_BAR_W 124 #define UI_BAR_H 10这种写法看起来啰嗦,但当你要在 128×64 的空间里塞进标题、图标、数值、进度条、底部状态栏这五样东西时,没有一套统一的坐标体系根本排不整齐。我在一个项目里因为没做这件事,后期调整布局花了整整一个下午,改了二十多处坐标。
5. 中文显示:从"找不到字形"到稳定排版
中文显示是这套组合里最容易让人放弃的一环。原因不在技术难度,而在编码和字库这两件事叠在一起,问题现象又特别有迷惑性——屏幕上出现一串方块、问号,或者干脆什么都不显示。
U8g2 本身是支持中文的,u8g2_DrawUTF8会按 UTF-8 规则解析字符串,逐个查字体里的字形。所以"能不能显示中文"取决于两件事:你的字体里有没有这个汉字的字形,你的源文件编码是不是 UTF-8。这两件事只要有一件不满足,出来的就是方块。
5.1 U8g2 自带中文字库的适用边界
U8g2 里确实带了几个中文字库,名字里带wqy的是文泉驿点阵字库,覆盖 GB2312 常用字。用起来很简单:
u8g2_SetFont(&u8g2, u8g2_font_wqy12_t_gb2312); u8g2_DrawUTF8(&u8g2, 0, 12, "正在连接");但它有几个限制必须提前知道。第一是体积,完整覆盖 GB2312 的字库动辄一两百 KB,对于 Flash 只有 64KB 或 128KB 的型号来说是灾难。第二是编码依赖,这类字库在内部是按 GB2312 索引组织的,而你的源码文件通常是 UTF-8,中间需要一次转换,稍不注意就会错位。第三是字重和字号固定,想换个大小就得换整个字库。
所以在真实项目里,我基本不用完整字库,而是自己裁一个。裁到只包含项目里实际出现的几十个字,体积能压到几 KB,这是最务实的路线。
5.2 用 bdfconv 裁一个自己的字库
U8g2 的源码里带了一个叫bdfconv的工具,作用是把 BDF 格式的点阵字库转换成 U8g2 能用的 C 数组,而且支持按字符范围裁剪。整个流程分三步。
第一步,准备一个 BDF 字库文件。这类文件在开源字库项目里很常见,选一个 12 或 16 像素的点阵字库就行,注意看授权是否允许嵌入。
第二步,确定你要哪些字。把所有界面上会出现的中文列出来,去重,转成 Unicode 码点列表。比如"设置""返回""确定""取消""正在连接""电量低"这些,总共可能就三四十个字。
第三步,跑转换命令:
bdfconv -v -f 1 -m "32-126,0x8BBE,0x7F6E,0x8FD4,0x56DE" \ -n my_font_16 my_font.bdf -o my_font_16.c参数含义:-f 1指定输出格式,-m后面跟字符范围,可以是区间也可以是单个码点,-n是生成的字体变量名,最后是输入和输出文件。具体参数的可用值建议用bdfconv -h查一下当前版本,不同版本的选项略有差异。
生成的 C 文件直接加进工程,声明一下就能用:
extern const uint8_t my_font_16[]; u8g2_SetFont(&u8g2, my_font_16); u8g2_DrawUTF8(&u8g2, 0, 16, "设置");这套流程的好处是体积完全可控。我做过一个项目,界面上总共 28 个不同的汉字,裁出来的字库不到 6KB,同时保留了完整的 ASCII 范围,中英混排毫无压力。
注意:裁字库的时候一定要把"未来可能加的字"预留出来。我吃过一次亏,第一版只裁了当前界面的字,后来产品经理加了两个新提示语,字体里没有对应字形,屏幕上直接显示空白,排查了半天才发现是字库不全。现在我至少要按当前用字量的 1.5 倍来裁,多留几十个字,成本很低。
5.3 自建点阵字模 + UTF-8 解码方案
有些场景下 bdfconv 不好用,比如你的字库来源是另一种格式,或者你想完全掌控渲染过程。这时候可以走"自建点阵数组 + 手动解码 UTF-8"的路线。灵活性最高,代码量也不算大。
核心是一个 UTF-8 解码函数,把多字节序列还原成 Unicode 码点:
static uint16_t utf8_next(const char **s) { const uint8_t *p = (const uint8_t *)(*s); uint16_t code; if (p[0] < 0x80) { code = p[0]; *s += 1; } else if ((p[0] & 0xE0) == 0xC0) { code = ((uint16_t)(p[0] & 0x1F) << 6) | (p[1] & 0x3F); *s += 2; } else if ((p[0] & 0xF0) == 0xE0) { code = ((uint16_t)(p[0] & 0x0F) << 12) | ((uint16_t)(p[1] & 0x3F) << 6) | (p[2] & 0x3F); *s += 3; } else { code = '?'; *s += 1; } return code; }拿到码点之后,去一张自己维护的表里查字模数据:
typedef struct { uint16_t code; const uint8_t *data; } glyph_t; static const glyph_t glyph_table[] = { { 0x8BBE, gb_she }, /* 设 */ { 0x7F6E, gb_zhi }, /* 置 */ /* 后面按需添加 */ };绘制的时候,逐字查表,查到就DrawXBMP,查不到就画一个方框占位。这个"占位方框"的细节很重要——它能让"字库缺字"这件事在开发阶段就暴露出来,而不是等到用户面前才发现。
这种方案的一个额外好处是,字模的宽高可以不完全统一。比如数字用 8×16,汉字用 16×16,混排的时候自己控制步进宽度就行,不需要迁就字库的固定字宽。
5.4 中英混排的行高与宽度测量
中文和英文混在一行,最容易出问题的是行高。16 像素的汉字方方正正,但小写英文字母带上下延伸部分,视觉高度不一样。如果只按汉字高度设置行距,英文字母的下降部分(比如 g、p、y)会压到下一行。
我的做法是先测量,再排版:
u8g2_SetFont(&u8g2, my_font_16); int ascent = u8g2_GetAscent(&u8g2); int descent = u8g2_GetDescent(&u8g2); int height = ascent - descent; /* descent 通常是负值 */u8g2_GetDescent返回的是负值,所以这里是减。算出来的height就是这一行字体实际占用的垂直空间,行间距在这个基础上再加 2 到 4 个像素,视觉上比较舒服。
宽度测量用u8g2_GetUTF8Width,它会计入所有字符的实际宽度,中英文混合也没问题。这个函数在做"文字居中"和"文字超长截断"的时候非常关键:
const char *msg = "正在连接服务器..."; int w = u8g2_GetUTF8Width(&u8g2, msg); if (w > 128) { /* 需要截断,按字符逐个累加宽度,超过 128 就停 */ } u8g2_DrawUTF8(&u8g2, (128 - w) / 2, y, msg);居中这个功能看起来简单,但如果你手动去数"中文算 16 宽、英文算 8 宽",遇到标点符号和数字就会算错。老老实实用库提供的测量函数,省心而且准确。
6. 动画显示:把刷新率榨到极限
动画这部分,是区分"能用"和"好用"的分水岭。我见过很多项目的界面,静态看着挺漂亮,一旦数值跳动或者菜单滚动,就出现明显的撕裂和拖影。根本原因在于刷新策略没做对——每次都全屏重画全屏发送,I2C 那点带宽根本撑不住。
先把账算清楚。前面算过,I2C 400kHz 下单帧传输约 23 毫秒。如果你的动画是每 30 毫秒更新一次,那么传输本身就占了 23 毫秒,留给计算的时间只有 7 毫秒,主循环里稍微有点别的工作就超时了。超时的表现就是掉帧、卡顿、按键响应变慢。
所以动画优化的核心思路只有一条:能少发就少发。具体来说有三个层次的手段:减少绘制内容、减少传输范围、降低更新频率。
6.1 先测一遍基线耗时
动手优化之前,必须先有基线数据,否则你不知道优化有没有效果。测量方法很朴素:在刷新前后各取一次时间戳,算差值。
uint32_t t0 = HAL_GetTick(); u8g2_SendBuffer(&u8g2); uint32_t dt = HAL_GetTick() - t0;HAL_GetTick的精度是 1 毫秒,对于十几毫秒的测量来说够用了。如果要更精确,可以用 DWT 计数器读 CPU 周期数,或者用定时器的计数寄存器。
我在一块 72MHz 的板子上实测的数据是这样的(I2C 400kHz,全屏 128×64,全屏缓冲模式):
| 操作 | 耗时 |
|---|---|
| 清屏 + 画 20 个字符 + 画进度条 | 约 1.2ms |
全屏发送(SendBuffer) | 约 24ms |
| 两者合计 | 约 25ms |
这个数据说明了一个很关键的事实:绘制本身几乎不花时间,时间全花在传输上。所以优化的重点绝对是减少传输量,而不是优化绘图代码。很多人一上来就想着怎么让画线更快,方向就错了。
6.2 局部刷新与脏矩形
全屏缓冲模式下,U8g2 提供了u8g2_UpdateDisplayArea函数,可以只把指定区域的数据发出去。它的参数单位不是像素,而是 tile,一个 tile 是 8×8 像素。所以 128×64 的屏横向有 16 个 tile,纵向有 8 个 tile。
/* 只刷新 x=0..63、y=32..47 这块区域 */ u8g2_UpdateDisplayArea(&u8g2, 0, 4, 8, 2);参数依次是:起始 tile 列、起始 tile 行、tile 宽、tile 高。上面这行的意思是,从第 0 列、第 4 行开始,刷新 8 列 2 行,对应像素范围是 x 从 0 到 63,y 从 32 到 47。
这一招的收益有多大?假设你只刷新一个 128×8 的进度条区域,只发 1 个 tile 行,也就是 128 字节,传输时间从 24 毫秒降到约 3 毫秒,快了 8 倍。这就是局部刷新的威力。
更进一步的做法是维护一个"脏矩形"——记录这一帧里哪些区域发生了变化,只把这些区域的并集刷出去。实现上可以简单地用一个标志数组按 tile 行记录,哪一行被改过就置位,刷新时只发置位的行。
static uint8_t dirty_row_mask; #define MARK_DIRTY(row) (dirty_row_mask |= (1u << (row))) void ui_flush(void) { for (uint8_t row = 0; row < 8; row++) { if (dirty_row_mask & (1u << row)) { u8g2_UpdateDisplayArea(&u8g2, 0, row, 16, 1); } } dirty_row_mask = 0; }这段代码很粗糙,但思路是对的:把屏幕切成 8 个横向条带,哪个条带动了才刷哪个。实际项目里可以做得更细,按矩形区域而不是整行来管理,收益会更大。
注意:
u8g2_UpdateDisplayArea只在全屏缓冲模式