简介:面向STM32F407嵌入式开发者和单片机学习者,这套资源是一份已移植好U8g2图形库的标准库工程模板,可通过硬件IIC直接驱动0.96寸SSD1306 OLED屏,省去从零搭建显示驱动和适配图形库的繁琐工作。U8g2支持多种单色显示控制器,提供全屏缓存、页面缓存和U8x8字符三种绘图模式,内置丰富字体并支持中文;借助这套模板,可以快速实现传感器数据展示、时钟、菜单、动画等界面。资源包约31.7MB,共435个文件,主体是C/H源码、Keil工程文件和编译中间文件,其中既有U8g2字体、界面组件代码,也有STM32F4标准库外设驱动;uvprojx等工程配置可直接用MDK打开,便于对照修改。已有1068人学习下载,对需要快速上手OLED图形界面的开发者来说,既能减少底层移植工作量,也能作为学习U8g2绘图流程和STM32硬件IIC驱动的完整参考。
1. 一块 0.96 寸 OLED,为什么值得为它单独移植 U8g2
在标准库工程里点亮 SSD1306 的 0.96 寸 OLED 并不难,难的是一旦要画波形、画曲线、显示中文、做菜单,裸写驱动就会变成一场灾难。之前我在一个 STM32F407 项目里给设备加状态屏,最初用自己写的字符点阵函数,只支持 ASCII,显示一行中文要做取模、查表、换页,改一个字的间距都要动底层代码。后来把 U8g2 图形库整体移植进标准库工程,用硬件 IIC 驱动,整个显示代码量从几百行缩到几十行,画圆、画线、显示任意字体全部开箱即用。
U8g2 是一个面向嵌入式设备的单色图形库,支持 SSD1306、ST7920 等主流控制器,特点是三种绘图模式、丰富的字体支持和统一绘图 API。对 STM32 开发者来说,标准库工程本身没有图形层,U8g2 正好补上这一块。它适合需要快速做界面原型的工程师,也适合在裸机或 RTOS 下做仪表、时钟、菜单交互的场景。本文不讨论怎么点屏,只做一件事:把 U8g2 完整、可复现地搬进 STM32F407 标准库工程,并把工程里最容易踩的坑全部拆开讲清楚。
2. 从显示原理到库选型:U8g2 三种模式与 SSD1306 的 IIC 数据传输
2.1 SSD1306 的页地址机制和控制字节
SSD1306 内部有 128×64 bit 的 GDDRAM,按 8 行一组分成 8 个页(Page)。写显示数据前,必须通过命令设置目标页地址和列地址,然后连续写入数据,数据会在当前页内从左到右填充。这个页机制直接决定了 U8g2 底层发送数据的方式,也解释了为什么不同绘图模式的内存占用和刷新速度差异很大。
在 IIC 接口下,SSD1306 每次传输的基本单位是:起始位 + 设备地址(7 位地址左移 1 位) + 控制字节 + 数据字节。控制字节的最低有效位 D/C# 用来区分当前发的是命令还是数据,倒数第二位 Co 表示后边是否还有连续数据。0x00 表示后续字节全是命令,0x40 表示后续字节全是显存数据。U8g2 的页面模式会频繁发送 0x00 和 0x40 控制字节,而全屏缓存模式则在一次传输里连续发送大量数据,两种模式对 IIC 总线利用率的差异非常明显。
2.2 全屏缓存、页面缓存与 U8x8 字符串模式怎么选
U8g2 的三种绘图模式对应三种不同的内存和速度策略。全屏缓存(F 模式)在 RAM 里维护一块 1024 字节的缓冲区,绘图操作全部在缓冲区内完成,最后调用一次发送函数把整块缓冲区推给屏幕。页面缓存(1 模式)只分配约 128 字节的一页缓冲区,通过 firstPage/nextPage 循环逐页更新屏幕。U8x8 字符模式最轻量,但不支持画线和曲线,只能显示字符块。
STM32F407 有 128KB 起步的 RAM,全屏缓存占用的 1024 字节完全不是问题。页面模式是为 AVR、STM8 这类 RAM 极小的单片机准备的,在 F407 上没有理由为了省 1KB 内存去牺牲绘图 API 的完整性和代码可读性。U8x8 模式适合极端资源受限的场景,或者只需要纯文本输出的日志屏。我的选择很直接:F407 上用 F 模式,U8g2 的全部绘图能力不缩水。
2.3 硬件 IIC 与软件模拟 IIC 的取舍
很多开发者对 STM32 硬件 IIC 有心理阴影,因为早期标准库的硬件 IIC 在事件等待上容易卡死。但 F407 的硬件 IIC 外设本身没有问题,问题出在使用姿势上,比如事件标志未清除、中断优先级配置不当、总线忙标志没有处理。软件模拟 IIC 最大的缺点是占用 CPU 时间片,尤其在 400K 速率下,逐位翻转 GPIO 会让主循环的可用时间明显减少。
既然目标是驱动 0.96 寸 OLED 并保持系统流畅度,硬件 IIC 是更合理的选择。F407 的 I2C1 挂在 APB1 总线上,标准库可以直接用 I2C_Init 配置到 400K 快速模式。硬件 IIC 传输期间 CPU 可以不参与逐位翻转,配合 DMA 甚至能做到显存数据后台搬运。U8g2 的硬件 IIC 移植只需要实现一个发送回调,剩下的交给外设。下面从工程实践角度看一下,U8g2 的源码文件到底怎么组织进标准库工程。
3. 标准库工程移植:U8g2 源码文件、GPIO 复用与 IIC 回调接入
3.1 移植前需要准备的源码文件和工程结构
U8g2 的源码从官方仓库拉取 release 包后,真正需要的是 csrc 目录下的内容。这个目录包含 u8g2.c、u8x8_cad.c、u8x8_i2c.c、u8g2_fonts.c、u8x8_fonts.c 等核心文件,以及若干针对不同硬件平台的 byte 类和 gpio 类文件。STM32 标准库移植时,只保留上层的绘图函数和字体数据,把 mui_u8g2.c 这类菜单库按需加入,如果只是显示数据,可以不引入。
工程里原本有 stm32f4xx_adc.c、stm32f4xx_tim.c、stm32f4xx_rtc.c 这些标准库外设源文件。移植 U8g2 时有一个常见误区:以为所有 .c 文件都要参与编译。实际上只要把 U8g2 的 csrc 文件加进工程,再把标准库的 I2C、GPIO、RCC 对应外设文件保留即可,用不到的外设模块比如 DFSDM、CAN 可以排除,这样能明显缩短编译时间。project.axf 里如果出现了 stm32f4xx_dfsdm.c 这类无关文件,检查一下工程配置,确认它是否真的被链接进来。
3.2 GPIO 复用、I2C 时钟与引脚配置
默认使用 PB8 和 PB9 作为 I2C1 的 SCL 与 SDA,这是 F407 上最常用的硬件 IIC 引脚组合。如果板子上 PA8、PA9 跟 IIC 冲突,可以换到 PB6、PB7 对应的 I2C1 复用功能,只要在 GPIO_PinAFConfig 里换成 GPIO_PinSource6 和 GPIO_PinSource7 即可。配置代码如下:
void I2C1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_OpenDrain; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_PinAFConfig(GPIOB, GPIO_PinSource8, GPIO_AF_I2C1); GPIO_PinAFConfig(GPIOB, GPIO_PinSource9, GPIO_AF_I2C1); I2C_InitStructure.I2C_ClockSpeed = 400000; I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; I2C_InitStructure.I2C_OwnAddress1 = 0x00; I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }这段代码里有两个关键参数:I2C_ClockSpeed 直接决定总线速率,400000 对应 400K 快速模式;I2C_DutyCycle_2 表示快速模式下的 2:1 占空比。注意 I2C1 的时钟源是 APB1,如果 APB1 配置在 42MHz,库内部会自动计算 CCR 分频值,不需要手动去算寄存器,但如果自己写寄存器版本,CCR 的计算公式和 DUTY 位一定要对齐,否则总线速率会偏离预期。
| 配置项 | 取值 | 作用 |
|---|---|---|
| GPIO_OType | OpenDrain | IIC 总线必须开漏,配合外部上拉电阻 |
| GPIO_PuPd | PullUp | 内部上拉可以作为补充,降低信号毛刺概率 |
| I2C_ClockSpeed | 400000 | 快速模式,约 15-20 帧刷新 |
| I2C_Ack | Enable | 主机使能 ACK,检测从机响应 |
| I2C_AcknowledgedAddress | 7bit | SSD1306 是 7 位地址,0x3C |
3.3 让 U8g2 认识 STM32:byte 回调与 gpio 回调
U8g2 的驱动层不直接操作寄存器,它通过两个函数指针完成平台适配:byte 回调负责往 IIC 总线上发送字节流,gpio 回调负责初始化引脚和延时。移植的核心就是实现这两个回调,然后在 Setup 函数里把它们传进去。下面是 byte 回调和 gpio 回调的最小实现:
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: I2C1_Init(); break; case U8X8_MSG_BYTE_SEND: while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0x3C << 1, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); while (arg_int--) { I2C_SendData(I2C1, *(uint8_t *)arg_ptr); arg_ptr++; while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2C1, ENABLE); break; default: return 0; } return 1; } 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: I2C1_Init(); break; case U8X8_MSG_DELAY_MILLI: delay_ms(arg_int); break; case U8X8_MSG_GPIO_I2C_CLOCK: case U8X8_MSG_GPIO_I2C_DATA: break; default: return 0; } return 1; }这里说明两个细节。第一,0x3C 是 SSD1306 的 7 位设备地址,I2C_Send7bitAddress 接收的是 8 位地址,所以左移一位变成 0x78,很多第一次移植的人在这里卡住,表现为能启动但总线上没有 ACK。第二,U8X8_MSG_BYTE_SEND 每次接收一小段数据,通常是一个控制字节紧跟一段显存数据或者一串命令,因此每次都执行完整的 Start/Stop 时序,效率不高但可靠。用逻辑分析仪观察会发现每个小包都重新建立总线会话,这是纯寄存器方式的基本形态。
3.4 Setup、初始化与主循环调用
底层回调就绪后,用 U8g2 的 C 接口完成初始化,并选择全屏缓冲模式。标准库工程是纯 C 环境,不引入 C++ 的 Arduino 风格构造函数,直接用 u8g2_Setup 系列函数:
u8g2_t u8g2; void U8g2_Init(void) { u8g2_Setup_ssd1306_i2c_128x64_noname_f( &u8g2, U8G2_R0, u8x8_byte_stm32_hw_i2c, u8x8_gpio_and_delay_stm32); u8g2_InitDisplay(&u8g2); u8g2_SetPowerSave(&u8g2, 0); u8g2_SetFontMode(&u8g2, 1); u8g2_SetFontDirection(&u8g2, 0); }在 main 循环里,全屏缓冲模式的使用方式是固定的:先 u8g2_ClearBuffer 清空缓冲区,然后执行任意数量的绘图调用,最后用 u8g2_SendBuffer 一次性把 1024 字节推到屏幕。任何直接操作 STM32 IIC 寄存器的代码都不应该出现在主循环里,显存的管理完全由 U8g2 的缓冲区承担。
4. 绘图指令、字体与图片:从点线圆到中文和位图
4.1 基础绘图 API 的坐标体系与常用函数
U8g2 的坐标系以屏幕左上角为原点,x 向右、y 向下,单位是像素,设置字体后的基线以左下角为参考点。这个坐标系和 SSD1306 的页地址模型不完全一致,但 U8g2 在内部已经做了转换,调用方不需要关心页地址。常用函数包括 u8g2_DrawPixel、u8g2_DrawLine、u8g2_DrawCircle、u8g2_DrawBox、u8g2_DrawFrame 等,前四个参数基本都是 x、y、w、h 或者圆心坐标加半径。
| 函数 | 参数 | 用途 |
|---|---|---|
| u8g2_DrawPixel | x, y | 画单个像素 |
| u8g2_DrawLine | x0, y0, x1, y1 | 画直线,支持任意斜率 |
| u8g2_DrawCircle | x, y, radius | 画空心圆,radius 是半径 |
| u8g2_DrawDisc | x, y, radius | 画实心圆 |
| u8g2_DrawBox | x, y, w, h | 画实心矩形 |
| u8g2_DrawFrame | x, y, w, h | 画空心矩形边框 |
以画一个实时曲线为例,可以先把历史数据点循环用 u8g2_DrawLine 连接起来,再画一个边框。因为坐标计算会涉及浮点和整数转换,F407 默认开启 FPU,这里有个容易忽略的点:启动文件里需要确认 CPACR 寄存器已经把 FPU 使能,否则浮点指令会进 HardFault。标准库工程模板一般默认开启 FPU,但从其他工程拷贝启动文件时可不一定。
4.2 中文与 UTF-8 编码:为什么 drawStr 显示的是乱码
U8g2 原生支持 UTF-8 编码的字符串绘制。要显示中文,必须选择支持 CJK 的字库,并在 u8g2.h 中启用对应的字体宏。常用的是 u8g2_font_wqy12_t_gb2312b,这是文泉驿 12 像素点阵字体,覆盖常用简体汉字。启用方法的代码位置在 u8g2.h 头文件里的字体列表部分,把字体对应的宏定义放开,例如:
#define U8G2_ENABLE_UNICODE #define U8G2_ENABLE_FONT_WQY12_GB2312显示中文的代码本身非常简单:
u8g2_SetFont(&u8g2, u8g2_font_wqy12_t_gb2312b); u8g2_DrawStr(&u8g2, 0, 16, "温度: 25.6C");这里最容易踩的坑是源码文件编码。U8g2 内部按 UTF-8 解析字符串,如果源文件保存成 GB2312 编码,中文字符串的字节序列会被错误解析成两个孤立字符。工程里有中文字符串的 .c 文件必须保存为 UTF-8 编码,并在编译器里设置正确的字符集选项,Keil 的 Edit 配置和 ARM Compiler 的编码设置要一致。另一个坑是字库体积,中文字库比 ASCII 字库大很多,一个 GB2312 字库可能占用数百 KB Flash,如果工程 Flash 紧张,可以只选择需要的字体子集。
4.3 显示位图与图片取模
U8g2 支持位图绘制,适合放 Logo 或传感器示意图。和直接全屏刷图片不同,位图数据需要提前取模成二进制数组,U8g2 的 DrawXBM 接受标准 XBM 格式的位数组。取模工具推荐 PCtoLCD2002 或者 Image2Lcd,输出选项设置成单色、行扫描、MSB first。例如显示一个 32×32 的 Logo:
static const unsigned char logo_bits[] = { 0x00, 0xFC, 0x3F, 0x00, /* ... 其余 128 字节省略 */ }; u8g2_DrawXBM(&u8g2, (128 - 32) / 2, (64 - 32) / 2, 32, 32, logo_bits);DrawXBM 的参数依次是起点 x、起点 y、位图宽度、位图高度、位图数据指针。位图是 1 位色深,每个字节表示 8 个像素点。绘制图片不占用额外 RAM,数据直接从 Flash 读取,对 F407 来说完全没有压力。批量显示多张图片时,建议把所有位图数组集中放在一个单独 .c 文件里,方便统一管理和排查取模方向问题。
4.4 显示时钟:RTC 与每秒刷新模式
文件列表里有 stm32f4xx_rtc.c,说明这套工程模板已经带了 RTC 驱动。把 RTC 时间和 U8g2 结合时,不要在主循环里每轮都刷新屏幕,那样既浪费 CPU 又会让屏幕闪烁。正确做法是让 RTC 秒中断或者定时器中断置一个标志位,主循环检测到标志后才刷新一次。
void UpdateClockDisplay(void) { if (rtc_second_flag == 0) return; rtc_second_flag = 0; u8g2_ClearBuffer(&u8g2); u8g2_SetFont(&u8g2, u8g2_font_logisoso32_tn); u8g2_DrawStr(&u8g2, 10, 45, time_str); u8g2_SendBuffer(&u8g2); }每秒刷新一次的频率对 OLED 完全足够,肉眼看到的就是连续走时的数字。这种模式的好处是主循环 99% 的时间都是空闲的,可以留给按键扫描、传感器采集或者无源蜂鸣器这类非阻塞任务。如果用的是页面模式,刷新逻辑还要处理 firstPage/nextPage 的循环,代码量比 F 模式多不少,实际效果却没有差异。
5. 硬件 IIC 卡死、帧率瓶颈与显示优化的三个递进技巧
5.1 加了 OLED 函数就卡死:从事件等待到总线锁死的排查顺序
很多人在标准库工程里调用 U8g2 的 SendBuffer 后程序直接卡死,现象是 OLED 不亮、主循环不跑、调试器停在某个 while 循环里。90% 的情况不是 U8g2 的问题,而是 IIC 事件等待写得过于刚性。I2C_CheckEvent 依赖硬件事件标志,在开启优化或者中断优先级抢占的情况下,事件标志可能被快速消耗掉,导致 while 循环永远等不到下一个事件。
建议的排查顺序是:第一步,确认 SSD1306 的 7 位地址是 0x3C 而不是 0x3D,SA0 引脚的电平决定末位,如果屏幕模块板载电阻焊错,地址会变;第二步,确认 I2C1 的时钟已经使能,检查 RCC_APB1PeriphClockCmd 和 RCC_AHB1PeriphClockCmd 是否都调用了;第三步,确认 GPIO 配置为复用开漏并且外部上拉电阻存在,没有上拉时 SCL 和 SDA 都拉不高,事件等待必然超时;第四步,检查 SysTick 中断优先级,如果 SysTick 优先级高于 IIC 的中断并且频繁抢占,IIC 事件可能在处理中被覆盖。
一个有效的缓解手段是把 IIC 的等待改成带超时的版本,例如限制轮询次数,超时后复位 I2C 外设重新初始化。这样即使总线异常,系统也不会死锁。但治本的办法是避免在中断上下文里执行 IIC 传输,所有 OLED 刷新都放到主循环调用。
5.2 帧率瓶颈:从单字节发送到批量传输
标准库的 I2C_SendData 一次只能发送一个字节,在 400K 速率下,加上每次事件检查的开销,U8g2 SendBuffer 的 1024 字节数据会被拆分成几十个小包,有效带宽利用率很低。用逻辑分析仪测过之后会发现,总线上大量时间花在 Start、Stop 和地址重发上。如果项目需要更流畅的动画,可以改用一个批量发送函数,在单次总线会话里把一页数据全部发完。
void I2C_WriteMultiBytes(I2C_TypeDef *I2Cx, uint8_t addr7, uint8_t *buf, uint16_t len) { while (I2C_GetFlagStatus(I2Cx, I2C_FLAG_BUSY)); I2C_GenerateSTART(I2Cx, ENABLE); while (!I2C_CheckEvent(I2Cx, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2Cx, addr7 << 1, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2Cx, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); for (uint16_t i = 0; i < len; i++) { I2C_SendData(I2Cx, buf[i]); while (!I2C_CheckEvent(I2Cx, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2Cx, ENABLE); }这个函数把整段显存数据在一次总线会话中发完,避免频繁启停。配合 U8g2 的底层字节回调改造,帧率能提升一倍左右。如果是 F407 这样的芯片,更彻底的做法是用 DMA 搬运数据到 I2C1->DR,把 CPU 彻底释放出来,代价是回调函数的状态机复杂度上升。
5.3 与 SPI 模式的对比和选择建议
如果 OLED 要显示动态波形或者菜单动画,IIC 400K 的理论极限带宽是 50KB/s,扣除协议开销后,有效数据带宽约在 25KB/s 到 35KB/s 之间,一帧 1KB 的显存数据大约需要 30 到 40 毫秒,也就是 25 帧上下的极限刷新率。实际加上刷新间隙,稳定在 15 到 20 帧每秒,字符和曲线完全够用,但满屏粒子动画这类效果会有明显撕裂。
追求更高帧率时,可以在 U8g2 的 Setup 函数里换成 SPI 版本,例如 u8g2_Setup_ssd1306_128x64_noname_f,并实现对应的四线 SPI 字节回调。F407 的 SPI 时钟可以到 40MHz 以上,一帧数据只需几百微秒,帧率瓶颈变成 OLED 本身的响应速度。代价是多占用四根 GPIO 和片选信号,对一些引脚紧张的项目不太友好。我的原则是:静态数据显示用 IIC,占用引脚少;动态图形和动画用 SPI,性能上限高。
5.4 一个值得保留的调试技巧:看 SCL 波形判断总线状态
最后分享一个很实用的调试方法。无论是硬件 IIC 还是软件模拟,把示波器或者逻辑分析仪的探头夹在 SCL 引脚上,上电后观察波形的疏密就能判断系统状态。正常情况下,OLED 初始化阶段会有一串密集的脉冲簇,随后进入低频率的主循环刷新,每帧波形是固定的十个左右脉冲群。如果始终没有任何波形,说明代码还没执行到 IIC 初始化;如果只有一个高电平没有脉冲,通常是 GPIO 复用配置错误;如果波形时断时续且有异常长的高低电平,大概率是时钟配置不对或者外部上拉缺失。
我曾在一个同时使用硬件定时器和 DMA 采集的工程里遇到 OLED 刷新卡死,最后就是靠观察 SCL 波形定位到 DMA 的优先级配置问题。U8g2 是运行在裸机上的图形库,它本身不会破坏外设状态,但 DMA、定时器、IIC 三者抢占同一个总线时钟域时,优先级配置错误就会让总线永久锁死。调这类问题不要一上来就翻 U8g2 源码,先量一下总线波形,再对照时钟树检查 PCLK1 的分配,大多数硬件 IIC 的卡死原因都能在一分钟内确定。
本文还有配套的精品资源,点击获取