做嵌入式显示,尤其是单色OLED、LCD这类小屏幕,U8g2这个名字几乎天天见。它是一套面向单片机和小型处理器的单色图形库,支持I2C、SPI、并行等通信方式,驱动的控制器型号非常广,从常见的SSD1306到某些冷门型号都能覆盖。我最初接触U8g2时其实有点不以为然,觉得一个显示库而已,按照芯片手册自己写初始化、画点,也不是多难的事。但真正在几个项目里用下来,才发现它帮我把“显示”这件事从底层细节里彻底抽象出来了,你只需要关心画什么,不用关心怎么送数据。这篇文章适合准备用单色屏做界面的开发者,也适合已经用U8g2但总被各种异常折腾的人。我会按实际项目里的使用顺序,把几年攒下来的经验整理成一份可以照着做的应用笔记。
1. 为什么单色屏项目里大家都绕不开U8g2
先聊一个根本问题:U8g2到底是干什么的,以及它凭什么能成为很多开发者的首选。它不是某个特定屏幕的驱动,而是把“驱动单色屏幕”这件事做成了一套通用框架。你买的OLED模块,可能用的是SSD1306、SH1106、SSD1309这些不同控制器,接线方式可能是I2C、SPI,甚至老的并口屏,U8g2都给你适配好了。更关键的是,它内置了大量点阵字体,小到5x7的英文字母,大到能显示中文的完整字库,选个字体就能画文字,不需要自己做字模表。
1.1 它到底解决了什么问题
单色屏的显示原理其实不复杂:屏幕内部有一块显存,你往显存里写的数据,会直接映射到屏幕上的点。以最常见的128x64 OLED为例,显存是128*64,也就是1024字节,一个bit代表一个像素点。控制器的核心任务就是把这块显存的数据,通过I2C、SPI或并行接口从单片机搬进屏幕里。
但问题是,不同控制器的显存布局不一样,初始化寄存器序列不一样,命令字也不一样。有的屏幕扫描方向从左到右,有的从上到下,有的需要设置电荷泵,有的需要调节对比度。如果你换了屏幕型号,之前写的驱动代码可能完全作废,需要重新对着几百页的芯片手册改。U8g2的价值就在这里:它用一套统一的API封装了这些差异,构造函数里标明屏幕型号和接口方式,底层的事情全部交给库处理。你在业务代码里就是画点、画线、画圆、显示字符串,换屏幕只改初始化那一行,画图的逻辑几乎不用动。
1.2 跟自绘驱动相比,省在哪里
自己写驱动,第一件头疼的事就是初始化序列。以SSD1306为例,你要依次设置显示关闭、时钟分频、多路复用比、显示偏移、起始行、电荷泵、寻址模式、对比度、预充电周期、VCOMH电平、显示开,每一步顺序错了都可能白屏或者花屏。好不容易点亮了,又要处理画点函数——8x8的不规则矩阵里,每个字节的8个bit对应屏幕上下排列的8个像素,你要先算出坐标落在哪个page,再做移位和掩码操作。等你想显示字符,还得自己准备ASCII字模表,想做中文?那就得自己处理几千个汉字的取模和编码映射。这些工作单个拎出来都不难,叠在一起就是无底洞。
U8g2把这些事情全部替你做了。它还额外提供了一个很实用的能力:分页缓冲。后面我会详细说,简单理解就是MCU内存很紧张的时候,它不需要一整块显存空间,只需要一个页大小的缓冲就能完成整屏绘制,这在只有1~2KB RAM的单片机上非常重要。这是很多自绘驱动很难顾及到的地方。
1.3 典型适用场景与限制
U8g2适合的场景非常明确:嵌入式仪表盘、温控器、手持设备、桌面小摆件、菜单界面、状态指示面板,这些需要显示文字、数字、简单图形的项目。它把单色屏能做的大部分事情都覆盖了。
但它也有明确的边界。第一,它只支持单色显示,虽然U8g2提供了灰度模拟能力,但效果和刷新率都有限,做图片平滑过渡不现实。第二,绘图性能受限于通信方式,I2C模式下频繁全屏刷新会明显感到卡顿,如果要做高速动画,必须用SPI或者优化绘制策略。第三,它对大屏彩色屏无能为力,那是其他图形库的领域。第四,中文字体体积非常庞大,后面我会单独讲,稍不注意Flash就被字体吃光了。如果你清楚这些边界,选型时就不会走弯路。
2. 环境准备与最简移植流程
很多教程上来就堆代码,但不解释为什么这么写,结果新手抄完了也不知道哪里该改。我建议先花几分钟搞清楚一件事:U8g2的构造函数名称,其实是一张“配置单”。以Arduino环境为例,你在代码里写的这一行,包含了屏幕分辨率、控制器型号、缓冲区模式、接口方式、旋转方向,所有关键信息都在里面。
2.1 开发板环境下的快速跑通
以最常见的SSD1306 128x64 I2C屏为例,在Arduino的库管理器里搜索U8g2并安装,然后输入下面这段代码:
#include <Arduino.h> #include <U8g2lib.h> U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8G2_PIN_NONE); void setup(void) { u8g2.begin(); u8g2.setFont(u8g2_font_unifont_t_latin); u8g2.drawStr(0, 20, "Hello U8g2!"); u8g2.sendBuffer(); } void loop(void) {}这段代码的核心是构造函数名字里的几个字段。SSD1306是控制器型号,128X64是分辨率,NONAME表示标准版型,F表示full framebuffer全帧缓冲模式,HW_I2C表示硬件I2C接口。如果你用的是SPI接口屏幕,要换成U8G2_SSD1306_128X64_NONAME_F_4W_HW_SPI,并且要额外指定CS、DC、RES引脚:
U8G2_SSD1306_128X64_NONAME_F_4W_HW_SPI u8g2(U8G2_R0, /* cs=*/ 10, /* dc=*/ 9, /* reset=*/ 8);如果某些模块没有接RES引脚,或者RES已经用RC电路处理了,第二个参数传U8G2_PIN_NONE即可。这里最容易犯的错误是构造参数和实际接线不对应。我建议拿到一块新屏幕,先确认是I2C还是SPI版本,再看丝印上标注的引脚位置,然后再选构造函数,不要想当然。
2.2 STM32/裸机环境下的移植要点
除了Arduino,U8g2在STM32、ESP32等平台也很常用。ESP32可以用Arduino框架,ST标准库、HAL库也能跑,但比Arduino多一步:U8g2对Arduino做了很多预设,换成裸机环境时,需要自己实现底层的字节发送和延时回调。
U8g2的架构分两层:底层叫U8x8,负责和屏幕通信;上层叫U8g2,负责图形绘制。在裸机工程里,你需要把u8g2源码加入项目,并把u8x8_byte和u8x8_gpio_and_delay这两个接口对接上。以I2C为例,核心就是处理U8X8_MSG_BYTE_SEND消息,把数据通过HAL库发出去:
uint8_t u8x8_byte_stm32_hw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { if (msg == U8X8_MSG_BYTE_SEND) { HAL_I2C_Master_Transmit(&hi2c1, u8x8_GetI2CAddress(u8x8), (uint8_t *)arg_ptr, arg_int, 100); } return 1; }延时函数则对应U8X8_MSG_DELAY_MILLI和U8X8_MSG_DELAY_10MICRO,调用HAL_Delay。需要注意,U8g2库本身是C++写的,纯C工程要确保编译环境支持C++,否则链接阶段会报出大量未定义符号。第一次移植的人常常在这一步卡住,其实不是代码问题,是工程语言标准配错了。
2.3 初始化之后先调什么
屏幕点亮后不要急着写业务界面,先做三个基础验证。第一个是清屏:调用u8g2.clear(),确认屏幕能变黑或变白,排除硬件接线问题。第二个是画网格:用drawFrame画外框,再用drawLine画几条横线和竖线,确认旋转方向和坐标轴理解正确。第三个是显示字符:用drawStr输出一段ASCII文本,验证字体渲染正常。
我习惯把这三个步骤写到一个测试函数里,叫displayTest(),每次新板子第一次上电先跑它。这么做的好处是,把“显示层问题”和“业务逻辑问题”隔离开。如果屏幕不亮或者显示错乱,问题基本能锁定在接线、配置、通信这三类;如果测试正常但界面异常,那就是运行逻辑的问题。很多开发者跳过这一步,直接写复杂界面,结果屏幕花了半天找不出原因,其实问题出在最开始的构造函数参数上。
3. 全屏缓冲与分页缓冲:先想清楚再写代码
U8g2最需要理解的概念是缓冲区模式。它决定了内存占用、绘制方式,也直接影响刷新性能。很多人代码抄了一堆,却不明白为什么有的项目用_F_,有的用_1_,直到编译报内存不足才回头研究。这里我尽量一次讲透。
3.1 全屏缓冲(F)模式
全屏缓冲,就是U8g2在内存里开辟一整块和屏幕分辨率对应的帧缓冲。128x64的屏幕,真正需要的缓冲大小是128x64除以8,等于1024字节。绘制流程是先操作内存中的缓冲,再一次性把整块缓冲刷到屏幕:
u8g2.clearBuffer(); u8g2.drawFrame(0, 0, 128, 64); u8g2.setFont(u8g2_font_unifont_t_latin); u8g2.drawStr(8, 20, "Full Buffer Test"); u8g2.sendBuffer();注意这里的API和开始时的drawStr不同:全屏模式下先用clearBuffer清空内存缓冲,画完内容后必须调用sendBuffer(),才能真正显示出来。全屏模式最大的优势是绘制灵活。你可以随意画图形、叠加元素、用setDrawColor(2)做异或操作,不需要担心超出当前页被裁剪。因为内存里就是一张完整的画布,所有绘制操作都在内存中完成,最后一次性上屏,画面完整统一。
它的缺点是内存占用固定。如果MCU的RAM只有2KB,光帧缓冲就吃掉了一半,再加上U8g2内部的工作变量、堆栈、动态分配,很容易爆。所以这个模式适合RAM相对充裕的平台,比如STM32F103、ESP32、Arduino Mega这些。对于ATmega328P这种2KB RAM的芯片,理论上勉强能跑,但我实测下来危险系数很高,稳定性不好保证。
3.2 分页缓冲(1/2)模式
分页缓冲的出现,是为了解决小内存MCU跑不了全屏的问题。它的思路是把屏幕横着切成很多个高度为8像素的“页”,内存里只保存一页或两页的数据。绘制过程变成循环:
u8g2.firstPage(); do { u8g2.drawFrame(0, 0, 128, 64); u8g2.setFont(u8g2_font_unifont_t_latin); u8g2.drawStr(8, 20, "Page Buffer Test"); } while (u8g2.nextPage());我用一个生活化的类比来解释:全屏模式像你在整张A4纸上画完一幅画,然后把整张纸贴到墙上;分页模式像你只有一张小纸条,于是把大纸裁成几行,每行分别画、分别贴到墙上。代价是整个绘制代码会重复执行多次,屏幕有多少页就执行多少次。128x64的屏幕有8页,如果你在循环体里做了复杂的运算或读取传感器,这些操作就会白白重复8遍,帧率会明显下降。
3.3 选型建议与内存估算
选择哪种模式,核心看两个东西:MCU的RAM容量,以及绘制逻辑的复杂度。我先把常见分辨率对应的缓冲大小算一下。
| 屏幕分辨率 | 全屏缓冲 | 单页缓冲 |
|---|---|---|
| 128x64 | 1024字节 | 128字节 |
| 128x32 | 512字节 | 128字节 |
| 64x48 | 384字节 | 64字节 |
| 256x64 | 2048字节 | 256字节 |
我的选型建议很简单:RAM可用空间在4KB以上,优先用全屏_F_;RAM只有1~2KB,或者绘制逻辑简单、只是显示文字和数字,就用分页_1_或_2_。_2_是双页缓冲,比单页省去一部分页面切换开销,又比全屏省内存,属于中间方案。
还要提醒一点:分页模式下,所有的绘制逻辑都必须放在firstPage和nextPage构成的循环体内。你把这个循环当做一个“每次被调8次的函数”,不要在循环体里写阻塞延时,也不要在循环完后再画东西,否则那些内容不会出现在屏幕上。我见过不少初学者把drawStr写在循环外面,然后疑惑为什么屏幕一片空白。原因就是页循环只执行了最后一次,绘制内容被下一轮覆盖了。
4. 字体与中文显示,这里坑最多
单色屏的字体处理,是U8g2用得越多越觉得“水很深”的地方。英文还好,随便选一个内置字体就能显示;一到中文,编码、体积、字库缺失,各种问题接踵而至。我在这上面踩过的坑,比在通信上的多得多。
4.1 U8g2字体体系与编码
U8g2内置了数量庞大的字体,从5x7的小像素字体,到10x20、16x32的大号字体,覆盖拉丁字母、希腊字母、西里尔字母,还有若干中文字体。字体名称的后缀是有规律的,理解了这个规律,选字体就不会两眼一抹黑。
_t_表示transparent,透明模式,只更新有像素的点,不会把背景整块涂掉,适合在已有图形上叠加文字。_f_表示full/新版完整字体,通常性能更好但体积可能更大。_latin表示这个字体只包含Latin字符,_gb2312表示包含GB2312编码的中文子集,_chinese常见于unifont系列,支持UTF-8的中文显示。
还有一个关键点:U8g2有drawStr和drawUTF8两个字符串绘制接口,前者只能画ASCII,后者能解析UTF-8编码的中文。你可以这样理解:drawStr是给英文和数字用的,drawUTF8才是给中文用的。如果你拿drawStr去显示中文,屏幕上只会出现乱码或空白。
4.2 中文显示实战:内置字体与自提字模
U8g2对中文的支持其实超出很多人预期,它内置了文泉驿点阵字体和Unifont字体。最简单的使用方法是:
u8g2.setFont(u8g2_font_unifont_t_chinese); u8g2.drawUTF8(0, 20, "温度:25.6℃");看起来只有几行代码,但这里藏着两个陷阱。第一个陷阱是编码问题。unifont_t_chinese期望的是UTF-8编码的字符串,而Arduino IDE默认源码文件是UTF-8,所以一般没问题;但如果你用某些古老的编辑器,把源码保存成了GB2312或者其它编码,drawUTF8就会得到错误的字节流,显示出来的中文全是乱码。解决办法是统一使用UTF-8保存源码文件。第二个陷阱是字体体积。unifont_t_chinese这类全中文点阵字体,体积动辄几十KB甚至上百KB,对于Flash只有32KB的芯片来说,字库一进去,程序空间直接爆掉。我实测定方案时经常遇到“编译成功但下载失败”的情况,其实程序已经超过Flash容量了。
那么项目只需要显示“温度”“湿度”“设置”这十几个汉字,怎么处理?我的建议是不要用内置大字体,自己提取字模。取模工具生成的C数组,本质上就是一个个小位图,用U8g2的drawXBM接口绘制:
static const unsigned char tem_char[] U8X8_PROGMEM = { 0x3C, 0x42, 0x42, 0x42, 0x3C, 0x00, 0x00, 0x00, 0x3C, 0x42, ... // 这里填取模工具输出的数据 }; u8g2.drawXBM(x, y, 16, 16, tem_char);每个字16x16像素,就算20个汉字,也就640字节左右的Flash,比内置字体省出几十倍空间。代价是你得先把所有要用的字提取出来,后期改文案时还要重新生成。我的体会是:如果产品文案基本固定、目标芯片Flash又小,自提字模是唯一合理的方案;如果Flash充足、需要频繁改文案,就用内置字体。
还有一个小坑:_gb2312系列的字体,期望的是GB2312编码的字节流,而很多开发环境默认字符串是UTF-8。如果混用,中文也会乱码。我在某个项目里图省事,直接用了wqy12_t_gb2312,结果中文全是问号,排查了一晚上,最后把源码文件另存为GB2312编码才解决。
5. 刷新提速的几条实用路子
单色屏没有颜色计算压力,但刷新率依然受通信带宽和绘制逻辑限制。很多项目把界面做出来后,感觉画面发卡、刷新很慢,不一定全是屏幕的锅。这里我把提速思路分成几条线,每一条都有明确的取舍。
5.1 绘制逻辑优化:能少画就少画
最容易被忽略的性能瓶颈,是绘制函数本身,而不是通信。全屏模式下,你画10个矩形和100个矩形,CPU消耗完全不同;分页模式下,代码要执行N次,复杂计算的代价还要再乘以页数。所以优化思路的第一条:减少无效绘制。
静态背景元素,比如边框、标题栏、固定图标,在全屏模式下只要画一次,之后不清屏,就能保留。很多新手每个循环都先clearBuffer再全部重画,导致每个像素点都被重新计算一遍。正确的做法是:划分区域,只更新变化的部分。U8g2提供了异或绘制模式,setDrawColor(2)可以让后续绘制变成XOR叠加,常用于光标闪烁、选中态切换,不需要手动擦除背景:
u8g2.setDrawColor(2); // XOR模式 u8g2.drawBox(cursor_x, cursor_y, 16, 16); // 反色显示光标 u8g2.setDrawColor(1); // 恢复正常模式这个方法在处理菜单选择状态时非常香,不用保存和恢复背景图像,画两次同一个位置就自动还原了。我在做仪表选单时就用这个方式实现光标移动,代码简洁,效果也顺畅。
5.2 通信接口与总线时钟
如果硬件条件允许,通信接口的选择对刷新率的影响是决定性的。I2C接口只有两根线,接线方便,但速度天然受限;SPI有独立的CLK和数据线,可以跑更高频率。我随手列个对比:
| 接口方式 | 典型时钟 | 占用引脚 | 优缺点 |
|---|---|---|---|
| I2C | 100kHz~400kHz | 2 | 省引脚,但刷新慢、受上拉电阻影响 |
| 4线SPI | 4MHz~8MHz | 4~5 | 速度快,需要CS/DC/RES,接线略多 |
| 并行 | 可到数十MHz | 8+ | 最快,但引脚占用大,基本不用 |
很多人在SPI模式下发现刷新率还是上不去,第一反应是换接口,其实先看一下总线时钟。U8g2在Arduino中的默认SPI时钟可能只有几MHz,你可以用setBusClock显式调高:
u8g2.setBusClock(8000000); // 8MHz SPI如果你用的是硬件SPI,大部分模块在8MHz下都能稳定工作,再高就可能出现花屏。如果用的是软件模拟SPI,速度取决于GPIO翻转速度,提升空间有限,建议能换硬件SPI就换硬件SPI。
在有DMA的MCU上,还可以把sendBuffer的传输过程交给DMA,CPU不用一个个字节地等。比如在STM32上用HAL_SPI发送显示缓冲,配合传输完成中断,刷屏过程几乎不占CPU。这里要注意一点:DMA传输期间,缓冲区的数据不能被修改,否则会出现画面错乱,所以需要在传输完成回调里加一个标志位,等标志位置位后再继续绘制下一帧。
5.3 帧率上限与瓶颈认知
弄懂了通信结构,你才能真正判断“帧率够不够”。I2C模式要传输一屏数据,每个数据字节前还要多传一个控制字节,128x64全屏等于要发1024个显示字节加上1024个控制字节,即使按400kHz算,光是传输时间就很可观。SPI四线模式没有这个额外开销,效率明显更高。所以很多做动画效果的项目,无一例外都选SPI。
想精确估刷新率,可以用一个笨办法:在loop里计数,每秒统计刷新次数。我实测过一些方案,I2C下整屏刷新可能在个位数帧率,SPI在8MHz时能提升数倍,但离流畅动画还是有一定距离。如果你要做的是“仪表盘指针摆动”“数字滚动”这类局部动画,建议把静态背景画好后只更新变化区域,这样UI看起来依然流畅,又不需要付出整帧刷新的带宽成本。
6. 常见的坑与排查实录
U8g2用久了你会发现,真正让你花时间的往往不是库本身,而是接线、配置、环境这些“外围问题”。我把遇到过的高频问题集中整理成排查指南,每条都是真实项目里的教训。
6.1 屏幕点不亮
屏幕完全没反应是最高频的问题,遇到时先按顺序检查这几个点:
- 供电电压。OLED模块一般支持3.3V或5V,但有些老模块对电压很敏感,先确认模块的VCC接法。
- I2C地址。同一颗SSD1306在不同模块上地址可能是0x3C或0x3D,U8g2构造函数里带
ALT0的变体通常对应0x3D。用I2C扫描代码快速确认设备地址,再选对应构造函数。 - RES引脚。如果RES悬空,初始化时序可能不对,很多模块已经把RES用电路固定了,这时构造函数里填
U8G2_PIN_NONE即可。 - SDA/SCL是否接反。这个错误比想象中常见,万用表量一下或者换个顺序试试。
- 是否真的调用了
begin。这不是废话,我见过把u8g2.begin()遗漏的,屏幕自然不会有反应。
如果以上都正常,大概率是模块本身或焊接问题,可以换一块屏交叉验证。
6.2 花屏、残影、错位
屏幕有显示但内容不对,这类问题更隐蔽。花屏最常见的原因是SPI时钟过高。我在某些模块上把setBusClock调到12MHz就开始出现细碎噪点,降回8MHz立刻稳定。如果你用了杜邦线飞线,线长超过10厘米,高速信号容易衰减和串扰,建议降低速率或用短线连接。
还有一种花屏是电源噪声引起的,尤其是在动态亮度变化大的场景。屏幕瞬间电流波动大,如果电源纹波严重,会影响SPI信号。这时候在模块电源脚并联一个10~100uF电容往往能解决。
残影问题得分两类。一类是OLED正常现象,长时间显示固定画面后,切换画面会短暂看到上一帧的影子,一般会逐渐消退。另一类是代码没清屏,反复叠加绘制导致“鬼影”,这个就要检查绘制逻辑。错位或者镜像,通常是旋转参数设错了。U8G2_R0不旋转,U8G2_R2是180度,U8G2_R3是270度。有些模块镜像安装,靠这几个参数就能调整,不需要改硬件。
6.3 编译资源不足与字符编码出错
编译报错比运行时问题更让人焦虑,但绝大多数都能归到三类。
region 'data' overflowed这类RAM溢出,通常是你选了_F_全帧缓冲,而芯片RAM不够。解决办法是换成分页_1_或_2_,或者换大RAM芯片。text region overflowed是Flash不够,多半是字库选大了,优先考虑去掉不用的字体、改用小字号、或者转用自提字模方案。
还有一个隐蔽的坑:U8g2是C++写的,纯C工程编译时会出现大量“undefined reference”错误。这不是你代码写错了,是工程没有启用C++编译器。Arduino环境没这个问题,但在STM32裸机工程里要特别注意,把源文件后缀改为.cpp,或者在工程设置里启用C++支持。
字符编码问题前面已经反复提过,这里再强调一遍:源码文件编码、字体期望编码、字符串接口三者的编码必须一致。想要UTF-8中文,就用u8g2_font_unifont_t_chinese+drawUTF8+ UTF-8源文件;想要GB2312,就用_gb2312字体 + GB2312源文件 +drawStr。混搭是乱码的根源。
最后说点我的个人习惯。每拿到一块新屏幕,我不会直接跑U8g2示例,而是先用最小代码点亮,验证接线和构造函数,再做完整测试。驱动部分点亮之后,我会把静态UI和动态刷新分成两个模块,静态内容尽量少重绘,动态内容用异或和局部更新。这个习惯帮我躲过了很多“奇怪问题”,比如屏幕卡顿、数据串位、刷新闪烁。U8g2是一个你把基础规则摸透之后会非常顺手的东西,但它的很多坑都藏在规则边界上,提前有意识地去规避,比出了问题再抓头发要省事得多。