STM32 U8g2 OLED移植:中文显示、字库裁剪与局部刷新
1. 接手一块只有 0.96 寸的屏为什么我把手写驱动整个删了前几年做一个小设备主板上一块 128×64 的 OLED 单色屏最初的方案是网上抄来的一段 SSD1306 驱动自己写了个OLED_ShowChar(0, 16, A)的函数硬编码 8×16 的 ASCII 点阵能跑。等到产品那边说「屏幕上想显示一行中文再加个进度条和一个小图标」的时候我盯着那个OLED_ShowChar看了半天——它连换行都不会更别说画圆、画线、显示变量宽度字体。于是我把那套自制驱动整个删掉换成了U8g2。U8g2是嵌入式圈子里用得非常多的一套单色图形库它的定位不是某一颗屏幕的驱动而是一层「绘图 API 显示控制器适配」的中间层。底下一层负责跟 SSD1306、SH1106、ST7565 这些屏控芯片打交道上面一层给你DrawBox、DrawCircle、DrawUTF8、DrawXBM这一堆画图函数。你把这个库接到 STM32 上之后画图这件事就从「自己算点阵坐标」变成了「告诉库我要在第几行第几列写什么字」。这篇内容适合谁看手里已经有一块 I2C 或 SPI 接口的单色 OLED能在屏幕上点亮一个像素或者显示个「Hello」但接下来不知道怎么写中文、怎么让画面动起来的人。我会把从接线到移植、从全缓冲改局部刷新、从默认 ASCII 字库换到自带汉字的裁剪字库这条链路完整走一遍代码都是可以直接改改引脚就编译的。先说结论省得你踩一遍STM32 上跑 U8g2真正花时间的不是「移植」是字库裁剪和刷新策略。底层四个回调函数半小时能写完字库那一步能耗掉你一整天。2. 上代码之前先想清楚U8g2 这套东西的代价和收益2.1 手写驱动在什么规模下还能撑住很多人对 U8g2 的第一反应是「太占空间了」。这个印象不算错但要分场景。自己写驱动的时候你的代码量大概是这样的初始化命令表 20 多行、写命令和写数据两个函数、一个OLED_ShowChar里面套着 8×16 的字模数组一个字符 16 字节96 个字符就是 1536 字节、ShowString再套一层循环。这套东西 Flash 占用不到 3KBRAM 占用几乎为零因为你是直接往屏里写没有缓冲区。对于「显示两行数字」的需求这是最优解没必要上库。但只要你提出下面任意一个需求自研路线的成本就会急剧上升需求手写驱动的代价U8g2 的处理方式显示中文自己建字模数组每个字 32 字节1000 字就是 32KB用工具从 BDF 生成 C 数组库负责查表渲染画进度条 / 圆 / 线自己写 Bresenham 算法和填充逻辑DrawBox、DrawDisc、DrawLine一行搞定变量宽度字体每个字宽不同坐标计算得自己维护库内部按字形宽度推进游标图形旋转 90 度坐标全要重算初始化时传U8G2_R1即可局部刷新做动画得自己算页地址、列地址、写入范围内置脏区域刷新接口问题在于U8g2 的这些能力不是白给的。全缓冲模式下它会在 RAM 里开辟一块 1024 字节的显存128×64÷8。STM32F103C8 只有 20KB RAM1024 字节占 5%还能接受但如果你同时跑了 FreeRTOS 的几个任务、开了串口 DMA 的环形缓冲、还有一堆传感器缓存这 1KB 就成了压死骆驼的最后一根稻草。这一点在后面第 4 章会详细算账。2.2 它到底替你省了哪一段活把 U8g2 拆开看它其实是三层结构理解这三层对你后面排查问题非常关键第一层是绘图层也就是u8g2_DrawLine、u8g2_DrawUTF8这些函数。它们不关心屏幕是什么芯片只往缓冲区里写位。这一层跟硬件完全无关你在 PC 上编译一个模拟器也能跑。第二层是控制器适配层u8g2_Setup_ssd1306_i2c_128x64_noname_f这类函数负责把绘图层产出的缓冲区数据按照 SSD1306 的页寻址方式加上命令头和数据头。屏幕型号变了换个 Setup 函数就行。第三层是通信层这一层是唯一需要你写代码的地方。U8g2 通过几个回调函数问你「帮我把这 32 个字节发出去」「帮我延时 10 微秒」「帮我把 SCL 拉高」。你把这些动作翻译成 STM32 的 HAL 库调用移植就完成了。所以移植的本质是你在给 U8g2 当搬运工它指挥你干活你只需要保证每个动作准确。2.3 三个必须提前接受的现实在动手之前有三件事我得提前讲明白不然你会在后面某个时刻怀疑人生。第一U8g2 的默认字库里没有中文。它自带的u8g2_font_wqy12_t_gb2312这类包含汉字的字库其实存在但体积大得吓人——完整 GB2312 一二级汉字加上各种全角符号12 点阵版本就有 200 多 KB16 点阵版本接近 400KB。STM32F103C8 的 64KB Flash 连零头都放不下。所以你迟早要做裁剪这件事躲不掉。第二I2C 接口的刷新率有硬上限。这个上限不是你代码写得烂是物理决定的。128×64 的屏一帧 1024 字节I2C 标准模式 100kHz 下传一帧要接近 100ms也就是理论 10fps快速模式 400kHz 下 25ms 一帧理论 40fps。你要是想做流畅动画光靠 I2C 全屏刷新是不够的必须配合局部刷新或者直接换 SPI 接口的屏。第三U8g2 的源码不能整个拖进工程。完整源码加上全部字库编译出来能到好几 MBKeil 直接报错。你必须做裁剪只保留自己用到的那部分。第 2.3 节会讲怎么裁。3. 硬件接线与工程骨架把坑挡在第一行代码之前3.1 I2C 还是 SPI选之前先算一笔时间账这是第一个真正需要做决策的地方我的建议是先看你动画需求有多强。接口典型速率传一帧 1024 字节的理论时间实测全屏刷新接线适用场景软件 I2CGPIO 模拟约 100~200kHz50~100ms80ms 左右2 根调试、引脚紧张硬件 I2C100kHz约 100ms100ms 左右2 根静态文本、数据面板硬件 I2C400kHz约 25ms30~35ms2 根中等动态效果硬件 I2C DMA400kHz约 25ms28ms且不占 CPU2 根需要 CPU 空闲做别的事硬件 SPI8MHz约 1ms2~3ms4 根动画、波形、菜单注意实测值永远比理论差一截因为每发送一个字节都有应答位、起始位、停止位的开销而且 HAL 库的函数调用本身也有成本。软件 I2C 那个「约 100~200kHz」是你在u8x8_gpio_and_delay里不加大延时的情况实际上 U8g2 的软件 I2C 默认会插入微秒级延时速度会更慢一些。我的经验是如果屏幕上要显示滚动文字、逐帧动画、实时波形别犹豫上 SPI 屏。差几块钱的屏钱能省掉后面一整周的优化时间。如果只是显示温湿度、菜单选项、几个图标I2C 400kHz 完全够用。3.2 工程目录怎么摆源码裁到哪一步U8g2 的官方仓库里有几十个文件别全加进去。你需要的是这几类Middlewares/u8g2/ ├── csrc/ │ ├── u8g2_d_setup.c # 只保留你用的那一个 Setup 函数 │ ├── u8g2_d_memory.c # 只保留对应的显存定义 │ ├── u8g2_fonts.c # 只保留你用的字库 │ ├── u8g2_ll_hvline.c # 绘图底层必须留 │ ├── u8g2_hvline.c # 必须留 │ ├── u8g2_buffer.c # 必须留 │ ├── u8g2_cleardisplay.c # 可选 │ ├── u8g2_box.c # 画矩形 │ ├── u8g2_circle.c # 画圆 │ ├── u8g2_line.c │ ├── u8g2_polygon.c │ ├── u8g2_bitmap.c # 画 XBM 图像必须留 │ ├── u8g2_string.c # 文字相关必须留 │ ├── u8g2_utf8.c # 中文显示必须留 │ ├── u8x8_byte.c # 软件 I2C/SPI 的字节层 │ ├── u8x8_cad.c # 命令/数据分离层 │ ├── u8x8_d_ssd1306_128x64_noname.c │ ├── u8x8_gpio.c │ ├── u8x8_display.c │ ├── u8x8_setup.c │ ├── u8x8_string.c │ └── u8x8_u8toa.c # 数字转字符串显示数值很方便这里有个非常重要的裁剪技巧u8g2_d_setup.c和u8g2_d_memory.c这两个文件里把整个 U8g2 支持的所有屏幕型号都列了一遍。如果你的 Keil 工程把所有 .c 都加进去Flash 会凭空多出几百 KB。正确做法是打开这两个文件把除了你要用的那几行之外的所有内容全部删掉或用#if 0包起来。比如u8g2_d_setup.c里你只保留这一组void u8g2_Setup_ssd1306_i2c_128x64_noname_f(u8g2_t *u8g2, const u8g2_cb_t *rotation, u8x8_msg_cb byte_cb, u8x8_msg_cb gpio_and_delay_cb) { uint8_t tile_buf_height; uint8_t *buf; u8g2_SetupDisplay(u8g2, u8x8_d_ssd1306_128x64_noname, u8x8_cad_001, byte_cb, gpio_and_delay_cb); buf u8g2_m_16_8_f(tile_buf_height); u8g2_SetupBuffer(u8g2, buf, tile_buf_height, u8g2_ll_hvline_vertical_top_lsb, rotation); }u8g2_d_memory.c里保留对应的uint8_t *u8g2_m_16_8_f(uint8_t *page_cnt) { static uint8_t buf[1024]; *page_cnt 8; return buf; }这一删Flash 占用能少掉一大半。我第一次没删编出来 300 多 KB还以为是库本身的问题找了半天才发现是没裁剪。3.3 编译选项上的两个必调参数工程配置里有两个地方容易漏掉。第一个是浮点输出和无格式化输出。如果你用的编译器默认开了--no_float之类的选项u8g2_DrawUTF8里拼接数值时会出问题。建议在 IDE 里把-u _printf_float这类选项打开或者干脆不用sprintf改用u8x8_u8toa这种库自带的数字转换函数体积小、速度快。第二个是栈大小。U8g2 内部有些函数会用到几百字节的局部数组尤其DrawUTF8处理多字节字符时默认 Keil 启动文件里Stack_Size是0x00000400也就是 1KB勉强够但如果你还在中断里调显示函数就会溢出。我一般改成0x00000800。提示不要在中断服务函数里调用任何u8g2_Draw*函数。U8g2 不是线程安全的中断里改缓冲区会和主循环的刷新打架症状就是偶发花屏或者屏幕半边乱码。4. 移植的核心就四个回调但每个都有讲究4.1 一条回调链是怎么跑起来的先理清 U8g2 调用你的顺序这样你写回调时才知道每个 case 是干嘛的。一次完整的刷新大概是这个流程绘图层在缓冲区里画完内容 → 适配层调用u8x8_cad_001把数据切分成「命令」和「数据」 → 命令和数据通过byte_cb发给通信层 → 通信层里如果是硬件 I2C就直接调你的HAL_I2C_Master_Transmit如果是软件 I2Cu8x8_byte_sw_i2c它会反过来通过gpio_and_delay_cb让你拉高拉低 GPIO 模拟时序。所以你有两条路可以走路线 A用u8x8_byte_sw_i2c库自带的软件 I2C 字节层你只需要写gpio_and_delay_cb。优点是代码量极小缺点是速度慢且占用 CPU。路线 B自己写byte_cb内部直接调硬件外设。优点快缺点是要理解每个 msg 的含义。两条路我都会给出完整代码。4.2 路线 A软件 I2C 的最小实现这个版本适合调试阶段接上就能出图先确认屏幕和接线没问题#include u8g2.h #include main.h extern I2C_HandleTypeDef hi2c1; /* GPIO 与延时回调U8g2 通过这套接口模拟 I2C 时序 */ 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: /* 这里什么都不用做GPIO 已在 MX_GPIO_Init 里配置好 */ break; case U8X8_MSG_DELAY_100NANO: __NOP(); break; case U8X8_MSG_DELAY_10MICRO: /* 用简单的循环替代1 次循环约 10ns按主频调 */ for (volatile uint32_t i 0; i (SystemCoreClock / 3000000U) * arg_int; i) __NOP(); break; case U8X8_MSG_DELAY_MILLI: HAL_Delay(arg_int); break; case U8X8_MSG_GPIO_I2C_CLOCK: HAL_GPIO_WritePin(OLED_SCL_GPIO_Port, OLED_SCL_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case U8X8_MSG_GPIO_I2C_DATA: HAL_GPIO_WritePin(OLED_SDA_GPIO_Port, OLED_SDA_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; default: return 0; } return 1; }这里有个细节很多人不注意SCL 和 SDA 必须是开漏输出如果配成推挽主从设备同时驱动时可能会烧引脚或者拉不出低电平。STM32 的 GPIO 配置里GPIO_MODE_OUTPUT_OD加上拉电阻外部 4.7k 或者用内部上拉是标准做法。初始化就三行u8g2_t u8g2; void OLED_Init(void) { u8g2_Setup_ssd1306_i2c_128x64_noname_f(u8g2, U8G2_R0, u8x8_byte_sw_i2c, u8x8_gpio_and_delay_stm32); u8g2_InitDisplay(u8g2); u8g2_SetPowerSave(u8g2, 0); /* 0 表示退出休眠 */ u8g2_ClearBuffer(u8g2); u8g2_SendBuffer(u8g2); }如果你的屏是 0x3D 地址少数模块需要加一句u8x8_SetI2CAddress(u8g2.u8x8, 0x3D 1)。默认的 0x78 就是 0x3C 左移一位U8g2 内部统一用 8 位地址。4.3 路线 B接管硬件 I2C速度提升一倍以上软件模拟虽然能跑但每次拉 GPIO 都有函数调用开销实测 128×64 全屏刷新要 80ms 以上。换成硬件 I2C代码短得多uint8_t u8x8_byte_stm32_hw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch (msg) { case U8X8_MSG_BYTE_INIT: /* 硬件 I2C 已由 CubeMX 初始化这里不用管 */ break; case U8X8_MSG_BYTE_SEND: /* U8g2 的 CAD 层已经插好了 0x00/0x40 控制字节 这里把 arg_int 个字节原封不动丢给硬件即可 */ if (HAL_I2C_Master_Transmit(hi2c1, OLED_I2C_ADDR, (uint8_t *)arg_ptr, arg_int, 50) ! HAL_OK) return 0; break; case U8X8_MSG_BYTE_SET_DC: /* I2C 接口没有 DC 引脚空实现 */ break; case U8X8_MSG_BYTE_START_TRANSFER: case U8X8_MSG_BYTE_END_TRANSFER: break; default: return 0; } return 1; }注意HAL_I2C_Master_Transmit的超时参数不要给 0 或者太大。给 0 会在总线被拉死时立刻返回错误给 1000 会让整个系统卡 1 秒。50ms 是个比较平衡的值足够传完一帧也不会让系统卡太久。这个版本的实测刷新时间从 80ms 降到了 32ms 左右提升很明显。要注意OLED_I2C_ADDR定义为0x788 位形式。4.4 一份能直接跑的 main 模板int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); OLED_Init(); u8g2_ClearBuffer(u8g2); u8g2_SetFont(u8g2, u8g2_font_6x12_tf); u8g2_DrawStr(u8g2, 0, 14, STM32 U8g2); u8g2_DrawFrame(u8g2, 0, 20, 128, 20); u8g2_DrawBox(u8g2, 2, 22, 60, 16); u8g2_DrawCircle(u8g2, 100, 30, 6, U8G2_DRAW_ALL); u8g2_SendBuffer(u8g2); while (1) { /* 主循环里做业务逻辑 */ } }这一版跑通屏幕上应该能看到一行英文、一个带填充的矩形框、一个圆。三样东西都出来了说明绘图层、适配层、通信层全部打通后面就只剩「画什么」的问题。5. 显存模型决定了你能画多复杂的画面5.1 全缓冲和页缓冲的取舍本质是 RAM 换灵活度U8g2 的 Setup 函数名里那个下划线和字母是含义的..._128x64_noname_f里的f是 full代表全缓冲1024 字节..._128x64_noname_1里的1代表 1 页缓冲即 8 行像素128 字节_2是 2 页缓冲256 字节。差别在哪全缓冲下你可以随便画画到屏幕任何一个位置都行因为所有像素都在 RAM 里最后一次性刷出去。页缓冲下你只能在一个 8 像素高的横条里画画完必须立刻刷出去因为下一行的数据会覆盖掉当前缓冲区。用页缓冲的写法是这样u8g2_FirstPage(u8g2); do { /* 这里画的内容必须落在当前页范围内 */ u8g2_DrawStr(u8g2, 0, 8, page content); } while (u8g2_NextPage(u8g2));这种写法对 RAM 极度友好128×64 的屏只要 128 字节缓冲。但代价是你没法画一个横跨多页的图形比如一个 20 像素高的图标它跨越了 2.5 个页页缓冲模式下一画就断。所以带图片、带大图标、带动画的项目老老实实用全缓冲。我的判断标准很简单RAM 余量超过 2KB 就用全缓冲省下来的那点内存不值得牺牲灵活性。5.2 坐标系和几个坑人的边界条件U8g2 的坐标原点在左上角X 向右Y 向下。但要注意DrawStr的 Y 坐标指的是基线baseline不是字符串顶部。这意味着你写u8g2_DrawStr(u8g2, 0, 10, ABC)字符的底部在 Y10 那条线上顶部大约在 Y10 减去字高。这一点如果搞错症状是「文字上半部分被切掉了」或者「文字压到了下面的框线上」。我一般会用一个固定公式/* 在第 n 行从 0 开始显示文字行高 16 */ #define ROW_Y(n) (14 (n) * 16) u8g2_DrawStr(u8g2, 4, ROW_Y(0), Line 1); u8g2_DrawStr(u8g2, 4, ROW_Y(1), Line 2);另外一个坑是画框函数的参数是「左上角坐标 宽 高」不是「左上角 右下角」。u8g2_DrawFrame(u8g2, 0, 0, 128, 64)画的是整个屏幕的边框但右下角那两个像素的边界处理要小心画在 Y63 的横线和画在 Y64 的横线后者直接画不出来。5.3 局部刷新让 I2C 也能跑到 20fps 以上全屏刷新 32ms意味着每分钟最多 30 帧看着还行对吧但如果你在做一个计数器动画每秒变一次数字为了这一个数字刷新整屏 32ms纯属浪费。U8g2 提供了一个专门干这事的接口u8g2_UpdateDisplayArea参数是以 tile 为单位一个 tile 8×8 像素的区域坐标。/* 只刷新 X 从 0 到 127 像素、Y 从 8 到 23 像素的区域 tile 坐标x0, y1, 宽 16 个 tile, 高 2 个 tile */ u8g2_UpdateDisplayArea(u8g2, 0, 1, 16, 2);128 宽 16 个 tile64 高 8 个 tile。这个函数只会把那 2 行 tile 的数据发出去传输量从 1024 字节降到 256 字节时间从 32ms 降到 8ms 左右。如果只刷新一个 8×8 的小图标那就是 8 个字节几乎瞬间完成。我做过一个对比测试同样一块 I2C 400kHz 的屏刷新方式单次传输字节实测耗时理论可达帧率全屏SendBuffer102432ms~30fps刷新 2 个 tile 行2568ms~120fps刷新 1 个 tile81ms基本无感所以做动画的正确姿势是缓冲区里保留完整画面但每次只发变化的那一小块。这比「清屏重画」的方案省下 90% 的时间。6. 中文显示字库裁剪才是真正决定成败的一步6.1 为什么默认字库显示出来的都是方框直接用u8g2_DrawUTF8显示中文如果你的字体选的是u8g2_font_6x12_tf这种结果是一堆问号或者空白。原因很简单这个字库里只存了 ASCII 字符的点阵遇到中文字符的 Unicode 码点它查表查不到只能画个占位符。U8g2 官方仓库里确实提供了一批带中文的字库文件名里通常带gb2312后缀。但我说过这类字库体积是几百 KB 起步STM32F103C8 根本放不下。即便是 Flash 大一些的型号比如 256KB 的 F103RC把整个 GB2312 字库塞进去也很浪费因为你的项目可能只用到「温度湿度压力电量」这十几个字。所以必须自己生成一个字库。6.2 用 bdfconv 把 BDF 编译成 C 数组U8g2 的字库源格式是 BDFBitmap Distribution Format一种文本格式的点阵字库描述。整个工具链是这样的TTF 字体 --(FontForge)-- BDF 点阵字库 --(bdfconv)-- C 数组文件第一步把 TTF 转成指定像素大小的 BDF。用 FontForge 的命令行模式fontforge -langff -c Open(source.ttf); SetPixelSize(16); Generate(myfont16.bdf)SetPixelSize(16)决定了字库的点阵大小。12 点阵适合做小字标签16 点阵是最常用的正文尺寸24 点阵适合做标题或者数字显示。注意 BDF 的像素大小必须和你的显示需求匹配转成 16 点阵的字库用来画 12 像素高的字效果会糊成一团。第二步用 bdfconv 生成 C 文件。这一步的关键是-m参数它决定了哪些字符会被收进字库bdfconv -v -f 1 \ -m 32-126,0x4E00-0x9FA5 \ -n u8g2_font_cn16 \ -o u8g2_font_cn16.c \ myfont16.bdf参数解释-f 1把多个 Unicode 区间合并成一个连续的映射表生成单个 group方便调用-m字符范围32-126是 ASCII 可见字符0x4E00-0x9FA5是 CJK 统一汉字区-n生成的字体变量名前缀-o输出文件名别真的全用0x4E00-0x9FA5那是两万多个汉字16 点阵下每个字 32 字节总共 700 多 KB哪个 STM32 都放不下。正确做法是把你项目里所有会显示的中文字符列出来用-M参数指定一个映射文件# chars.txt 里一行一个字符或者直接写成码点列表 bdfconv -v -f 1 -M chars.txt -n u8g2_font_cn16 -o u8g2_font_cn16.c myfont16.bdfchars.txt的内容就是把界面上所有中文文本去重后列举出来比如温度湿度压力电压电流程运行停止设置返回确认取消 年月日时分秒星期一星期二星期三星期四几百个字的字库16 点阵下大概 10~20KB非常可控。6.3 体积、点数与 Flash 占用的换算这里给你一张表方便估算点数单字字节数近似500 汉字占用1000 汉字占用适用场景12189KB18KB小字标签、状态栏163216KB32KB正文主力字号247236KB72KB数字、标题3212864KB128KB大号数字显示对照一下你的芯片 FlashF103C8 是 64KB扣掉程序本身 20~30KB留给字库的也就 30KB 出头。这时候你可能只能选「16 点阵 500 字」或者退一步用 12 点阵。如果确实需要多套字库又放不下有几个思路只保留一套主字库大号数字用u8g2_font_logisoso24_tn这种纯数字的现成字库只有数字和少量符号体积很小或者把字库放到外部 SPI Flash 里运行时按需读入内存——但这条路要自己改写 U8g2 的字形访问接口工作量大除非项目规模上来了否则不建议。6.4 中文渲染的两种路径和中英混排字库生成好之后引入到工程里声明一下extern const uint8_t u8g2_font_cn16[]; u8g2_SetFont(u8g2, u8g2_font_cn16); u8g2_DrawUTF8(u8g2, 0, 16, 温度25.6℃);这里要注意DrawUTF8和DrawStr的区别。DrawStr把字符串当作单字节序列每个字节对应一个字库里的索引DrawUTF8会先把 UTF-8 编码解析成 Unicode 码点再去字库里查。如果你的源文件编码是 UTF-8Keil 里要设置--localeenglish或者用-finput-charsetUTF-8必须用DrawUTF8。用DrawStr显示中文的话会变成乱码或者干脆不显示。如果你用的是 GB2312 编码的源文件Keil 老项目常见那就得用u8g2_DrawGB2312并且字库生成时也要按 GB2312 映射。这两种路径不能混用混用必然乱码。中英混排的话DrawUTF8天然支持因为 ASCII 字符在字库里也有一份点阵。但要注意中英文的宽度差异16 点阵的字库里一个汉字宽 16 像素一个英文字母可能只有 8 像素。如果你要做一个左右对齐的表格需要自己算宽度/* 计算一段 UTF-8 字符串的像素宽度 */ u8g2_uint_t w u8g2_GetUTF8Width(u8g2, 温度25.6℃); /* 右对齐显示 */ u8g2_DrawUTF8(u8g2, 128 - w - 4, 16, 温度25.6℃);u8g2_GetUTF8Width这个函数在排版时极其有用做进度条旁边的百分比数字、右对齐的时间和日期、居中的标题都靠它。有个小坑u8g2_GetUTF8Width返回的是当前设置的字体的宽度如果你中途换过字体记得先SetFont再调宽度函数不然算出来的值对不上。7. 让画面动起来刷新策略比绘图技巧更重要7.1 先算清楚你的时间预算做动画之前先想明白一件事你一帧有多少时间可用。如果目标是 20fps那一帧就是 50ms。这 50ms 里I2C 传输占掉 32ms全屏留给绘图的只有 18ms。而 U8g2 每画一个字符大约要几百微秒到 1 毫秒取决于字库大小和是否透明画 20 个字符就吃掉 10ms 以上剩下的时间还得跑业务逻辑、读传感器、处理按键。结论I2C 全屏刷新 复杂绘图注定到不了 20fps。想在 I2C 上做动画只有两条路局部刷新或者降低分辨率比如只用屏幕的一半。7.2 脏矩形只重画变了的区域最朴素的动画写法是每帧ClearBuffer再全部重画然后SendBuffer。这套写法在 SPI 屏上完全没问题但在 I2C 上就是灾难。稍微好一点的做法是「脏矩形」记录上一帧和这一帧哪些区域变了只清那块、只画那块、只发那块。typedef struct { uint8_t x0, y0, x1, y1; /* 像素坐标闭区间 */ uint8_t dirty; } dirty_rect_t; static dirty_rect_t g_rect {0, 0, 0, 0, 0}; void mark_dirty(uint8_t x, uint8_t y, uint8_t w, uint8_t h) { if (!g_rect.dirty) { g_rect.x0 x; g_rect.y0 y; g_rect.x1 x w - 1; g_rect.y1 y h - 1; g_rect.dirty 1; return; } if (x g_rect.x0) g_rect.x0 x; if (y g_rect.y0) g_rect.y0 y; if (x w - 1 g_rect.x1) g_rect.x1 x w - 1; if (y h - 1 g_rect.y1) g_rect.y1 y h - 1; } void flush_dirty(void) { if (!g_rect.dirty) return; /* 换算成 tile 坐标向下取整到 8 的倍数 */ uint8_t tx g_rect.x0 / 8; uint8_t ty g_rect.y0 / 8; uint8_t tw (g_rect.x1 / 8) - tx 1; uint8_t th (g_rect.y1 / 8) - ty 1; /* 先清掉这块区域再重画 */ u8g2_SetDrawColor(u8g2, 0); u8g2_DrawBox(u8g2, tx * 8, ty * 8, tw * 8, th * 8); u8g2_SetDrawColor(u8g2, 1); /* 这里重新绘制这块区域的内容 */ draw_region(tx * 8, ty * 8, tw * 8, th * 8); u8g2_UpdateDisplayArea(u8g2, tx, ty, tw, th); g_rect.dirty 0; }这套机制的关键在于draw_region它必须能在指定区域内重画所有内容。这在设计上有个约束——你的绘制函数要能接受区域参数不能全局一把梭。所以做动画前先把界面拆成「静态层」和「动态层」静态层只在初始化时画一次动态层每次按区域重绘。7.3 三个常见动画效果的具体实现第一个进度条。最简单也最实用。假设进度条区域是 X 从 10 到 118Y 从 40 到 52。void draw_progress(uint8_t percent) { /* 外框只画一次放在初始化里 */ /* 这里只处理内部填充 */ uint8_t inner_w 106; /* 118 - 10 - 2 边框宽度 */ uint8_t fill (uint16_t)inner_w * percent / 100; /* 先把内部清空避免上一次的填充残留 */ u8g2_SetDrawColor(u8g2, 0); u8g2_DrawBox(u8g2, 11, 41, inner_w, 10); u8g2_SetDrawColor(u8g2, 1); u8g2_DrawBox(u8g2, 11, 41, fill, 10); /* 右侧显示百分比数字 */ char buf[5]; u8x8_u8toa(percent, 3, buf); /* 3 位宽右对齐 */ u8g2_DrawStr(u8g2, 120, 50, buf); }刷新的时候只需要u8g2_UpdateDisplayArea(u8g2, 1, 5, 15, 2)覆盖 X 的 8~127、Y 的 40~55 这一块传输量约 240 字节。第二个滚动字幕。这个稍微麻烦一点因为要处理边界。思路是把文字画两遍一遍在正常位置一遍在右侧接续然后整体左移。void scroll_text(const char *str, int16_t offset) { u8g2_uint_t w u8g2_GetUTF8Width(u8g2, str); int16_t x 128 - offset; u8g2_DrawUTF8(u8g2, x, 30, str); if (x (int16_t)w 128) u8g2_DrawUTF8(u8g2, x w 16, 30, str); /* 尾部接续 */ }offset每帧加 2~4 像素超过128 w 16之后归零。这个效果整行都要刷新所以要么用 SPI要么就接受 30fps 的上限。第三个图标旋转。这个简单把旋转好的几帧图标存成几个 XBM 数组然后循环调用extern const uint8_t icon_frame0[]; extern const uint8_t icon_frame1[]; extern const uint8_t icon_frame2[]; extern const uint8_t icon_frame3[]; const uint8_t *frames[] { icon_frame0, icon_frame1, icon_frame2, icon_frame3 }; /* 每帧换一张画在固定位置 */ u8g2_DrawXBMP(u8g2, 56, 24, 16, 16, frames[frame_index]);XBM 数组怎么来用在线工具把图片转成 1 位位图或者用 ImageMagick 生成convert icon.png -monochrome -resize 16x16 icon.xbm生成的 XBM 就是 C 数组格式改一下开头加上const uint8_t和PROGMEMSTM32 上不需要 PROGMEM那是 AVR 的写法直接放 Flash 里用就行。7.4 DMA 传输把 CPU 从搬运工的位置上解放出来如果你用的是 SPI 屏或者硬件 I2C可以再优化一步把HAL_I2C_Master_Transmit换成HAL_I2C_Master_Transmit_DMA。case U8X8_MSG_BYTE_SEND: /* 注意arg_ptr 指向的数据在传输完成前必须保持有效 U8g2 的缓冲区在传输期间不会被改写所以可以直接用 */ HAL_I2C_Master_Transmit_DMA(hi2c1, OLED_I2C_ADDR, (uint8_t *)arg_ptr, arg_int); /* 等待传输完成再返回因为下一段数据会覆盖 arg_ptr 的内容 */ while (HAL_I2C_GetState(hi2c1) ! HAL_I2C_STATE_READY) { } break;这里有个陷阱DMA 是异步的函数返回时数据还没发完。而 U8g2 发完一段数据后马上就会准备下一段如果你不等 DMA 完成就返回下一段数据可能覆盖掉正在传输的缓冲区结果是屏幕内容错乱。所以上面的代码里加了忙等。那我用了 DMA 还有什么意义意义在于等待期间 CPU 可以去响应中断、处理别的任务而不是在里面死循环做HAL_I2C_Master_Transmit的轮询。在中断密集的系统里这能显著改善响应延迟。真正把 DMA 优势发挥出来的做法是「双缓冲 传输完中断回调」但那需要改写 U8g2 的整条调用链复杂度高一个数量级。一般项目里DMA 忙等已经够用了。8. 移植之后的一堆怪现象花屏、乱码、卡死的排查链路8.1 花屏先怀疑时序再怀疑初始化花屏是最常见的症状表现有几种整个屏幕雪花点、上半部分正常下半部分乱码、每次刷新随机出现错位。排查第一步把 I2C 速率降下来。把 400kHz 改成 100kHz如果花屏消失说明是时序问题。原因通常是上拉电阻太大10k 以上或者杜邦线太长、接触电阻大波形建立时间不够。换 4.7k 上拉、缩短线长通常能解决。排查第二步检查初始化序列有没有漏。SSD1306 的初始化命令表有 20 多条其中0xAE关显示、0xD5时钟分频、0xA8复用率、0x20内存寻址模式这些顺序