news 2026/10/22 11:48:59

STM32软件I2C驱动OLED:U8g2移植实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32软件I2C驱动OLED:U8g2移植实战指南

1. 从点灯到点屏:为什么我劝你手撸一遍 U8g2 的软件 I2C 移植

搞嵌入式的人都有一个共识:点亮一颗 LED 只是入门,把一块屏幕驱动起来才算真正摸到了门槛。而当你手里拿着一块 STM32,面对一块 0.96 寸的 OLED,想要显示点像样的东西时,U8g2 这个库几乎是绕不开的选择。它全称是 Universal 8bit Graphics Library,是一个用于嵌入式设备的单色图形库,支持 SSD1306、SH1106、SSD1309 等一大堆常见的 OLED 和 LCD 控制器。它的好处很直接:图形绘制 API 丰富、字体支持完善、内存占用可控,最关键的是它把底层通信抽象得很干净,你只需要提供几个字节的读写函数,剩下的画点、画线、显示汉字、显示图标,它全帮你搞定。

但问题也恰恰出在这个“抽象”上。很多教程一上来就让你用 CubeMX 配好硬件 I2C,然后直接调用u8g2_Setup_ssd1306_i2c_128x64_noname_f,把u8x8_byte_hw_i2c和u8x8_gpio_and_delay两个回调函数一填,编译下载,屏幕就亮了。看起来很美好,可一旦你换一块芯片、换一个引脚、或者硬件 I2C 被其他外设占用了,你就傻眼了。更别提有些 STM32 型号的硬件 I2C 存在各种玄学问题,比如某些 F1 系列的 I2C 死锁、某些 F4 系列的时序抖动,调试起来能让人抓狂。

所以,把 U8g2 移植到 STM32 上,并且用软件 I2C的方式驱动,是一个非常实用且必要的技能。它的核心价值在于:你只需要任意两个普通 GPIO 口,就能驱动 OLED 屏幕,彻底摆脱对硬件 I2C 外设的依赖。这意味着你的引脚分配可以极其灵活,PCB 布线可以更随意,甚至在硬件 I2C 出问题时,软件 I2C 就是你的救命稻草。这篇文章,我就把我在实际项目中反复移植 U8g2 软件 I2C 的经验,从原理到代码,从踩坑到优化,完整地分享出来。无论你是刚接触 STM32 的新手,还是想找一个稳定软件 I2C 方案的老手,这篇内容都能让你直接抄作业。

2. 移植前的整体思路与方案选型

2.1 为什么选软件 I2C 而不是硬件 I2C

在动手之前,先把这个根本问题说清楚。硬件 I2C 的优势是速度快、不占 CPU、时序由硬件保证。但它的劣势同样明显:引脚固定,你必须用特定的 I2C 引脚;资源有限,STM32 一般只有一到两个 I2C 外设,用完了就没了;调试困难,一旦总线锁死,排查起来非常麻烦。而软件 I2C 的优势就是引脚任意、不占外设资源、时序完全可控。对于 OLED 这种刷新率要求不高的场景,软件 I2C 的速度完全够用。我实测过,在 72MHz 的 STM32F103 上,软件 I2C 驱动 128x64 的 OLED,全屏刷新率可以做到 30 帧以上,日常显示完全无压力。

当然,软件 I2C 也有代价:它会占用 CPU 时间,因为每个时钟脉冲都需要 CPU 去翻转 GPIO。但 U8g2 的传输量并不大,一帧 128x64 的单色图像也就 1024 字节,分页传输的话每次只传 128 字节,对 CPU 的占用完全可以接受。所以结论很明确:如果你追求极致的灵活性和稳定性,软件 I2C 是首选;如果你追求极致的速度和低 CPU 占用,硬件 I2C 更合适。这篇文章聚焦软件 I2C,因为它的适用场景更广,也更能体现移植的通用性。

2.2 U8g2 的移植本质是什么

很多人觉得移植 U8g2 很神秘,其实它的本质非常简单:U8g2 不关心你用什么方式通信,它只要求你提供两个东西——一个字节发送函数和一个延时函数。字节发送函数负责把命令和数据通过 I2C 总线送出去,延时函数负责在需要的时候提供微秒级和毫秒级的延时。U8g2 内部的所有绘图操作,最终都会转化为对这两个函数的调用。所以,移植的核心工作就是:实现一个符合 U8g2 接口规范的软件 I2C 字节发送函数,以及一个基于系统滴答定时器的延时函数。

理解了这一点,整个移植过程就清晰了。你不需要去改 U8g2 的源码,也不需要去研究它的图形算法,你只需要写好这两个回调函数,然后把它们注册给 U8g2 的初始化函数即可。这就是 U8g2 设计的高明之处:它把硬件相关的部分完全剥离出来,让你用最小的代价完成适配。

2.3 整体移植步骤概览

我把整个移植过程拆解为五个步骤,后面会逐一详细展开:

  1. 准备基础工程:一个能正常编译下载的 STM32 工程,包含 GPIO 驱动和 SysTick 延时。
  2. 获取 U8g2 源码:从官方仓库下载源码,提取出核心的csrc目录。
  3. 实现软件 I2C 底层驱动:包括 GPIO 初始化、起始信号、停止信号、发送字节、接收应答。
  4. 实现 U8g2 回调函数:包括字节发送回调、GPIO 延时回调。
  5. 初始化并测试:调用 U8g2 初始化函数,编写测试代码,验证显示效果。

这五个步骤中,第三步和第四步是核心,也是最容易出问题的地方。下面我会把每一步都拆开来讲,把原理和代码都交代清楚。

3. 核心细节解析与实操要点

3.1 软件 I2C 的时序原理与 GPIO 配置

软件 I2C 的本质就是用两个 GPIO 口模拟 I2C 总线的时序。I2C 总线有两条线:SCL(时钟线)和SDA(数据线)。在软件模拟中,这两条线都需要配置为开漏输出模式,并且外接上拉电阻(通常是 4.7k 或 10k)。为什么要开漏?因为 I2C 总线是多主多从结构,任何设备都可以拉低总线,但没有人能主动拉高。总线的高电平是靠上拉电阻实现的。如果你把 GPIO 配置为推挽输出,当两个设备同时输出不同电平时,就会发生短路,烧毁引脚。

在 STM32 中,配置 GPIO 为开漏输出的代码如下:

GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉(外部最好也加上拉) GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

这里有个细节:STM32 的内部上拉电阻阻值较大,大约在 30k 到 50k 之间,对于 I2C 来说偏弱。如果通信距离稍长或者总线电容稍大,波形上升沿会变得很缓,导致通信失败。所以强烈建议在 SCL 和 SDA 上各接一个 4.7k 的外部上拉电阻。这是我踩过的坑:一开始只用了内部上拉,短距离测试没问题,但线一长就随机出错,加了外部上拉后立刻稳定。

3.2 软件 I2C 的五个基本信号

I2C 通信的所有操作,都可以分解为五个基本信号的组合:

  • 起始信号(Start):SCL 为高时,SDA 由高变低。
  • 停止信号(Stop):SCL 为高时,SDA 由低变高。
  • 发送字节(Send Byte):在 SCL 低电平时改变 SDA,在 SCL 高电平时保持 SDA 稳定。
  • 接收应答(Receive Ack):主机释放 SDA,从机拉低 SDA 表示应答。
  • 接收字节(Receive Byte):主机释放 SDA,在 SCL 高电平时读取 SDA。

这五个信号的代码实现如下,我把它封装成一组宏和函数,方便调用:

#define I2C_SCL_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET) #define I2C_SCL_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET) #define I2C_SDA_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET) #define I2C_SDA_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET) #define I2C_SDA_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7) void I2C_Start(void) { I2C_SDA_H(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_L(); I2C_Delay(); } void I2C_Stop(void) { I2C_SDA_L(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_H(); I2C_Delay(); } uint8_t I2C_SendByte(uint8_t byte) { uint8_t i, ack; for (i = 0; i < 8; i++) { if (byte & 0x80) I2C_SDA_H(); else I2C_SDA_L(); byte <<= 1; I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_Delay(); } I2C_SDA_H(); // 释放 SDA I2C_Delay(); I2C_SCL_H(); I2C_Delay(); ack = I2C_SDA_READ(); // 读取应答 I2C_SCL_L(); I2C_Delay(); return ack; }

这里的I2C_Delay()是关键。它决定了 I2C 的通信速率。如果延时太短,时序可能不满足从机要求;如果延时太长,刷新率会下降。我一般用__NOP()循环来实现微秒级延时,具体循环次数需要根据主频调整。在 72MHz 下,一个__NOP()大约 14ns,循环 10 次大约 140ns,加上 GPIO 翻转的时间,SCL 周期大约在 2-3 微秒,对应 300-500kHz 的速率,完全在 SSD1306 的支持范围内(SSD1306 最高支持 400kHz,但实际测试 500kHz 也能稳定工作)。

3.3 U8g2 回调函数的实现要点

U8g2 要求你提供两个回调函数:u8x8_byte_cb和u8x8_gpio_and_delay_cb。前者负责字节传输,后者负责延时和 GPIO 控制。对于软件 I2C,我们只需要在u8x8_byte_cb中处理U8X8_MSG_BYTE_START_TRANSFER、U8X8_MSG_BYTE_SEND、U8X8_MSG_BYTE_END_TRANSFER这三个消息即可。

uint8_t u8x8_byte_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_SendByte(*data++); } break; case U8X8_MSG_BYTE_START_TRANSFER: I2C_Start(); I2C_SendByte(u8x8_GetI2CAddress(u8x8) << 1); // 发送从机地址 break; case U8X8_MSG_BYTE_END_TRANSFER: I2C_Stop(); break; default: return 0; } return 1; }

注意u8x8_GetI2CAddress(u8x8) << 1这一行。U8g2 内部存储的 I2C 地址是 7 位格式,而 I2C 总线上的地址是 8 位格式,最低位是读写位。所以需要左移一位,最低位为 0 表示写操作。SSD1306 的默认地址通常是 0x3C 或 0x3D,左移后就是 0x78 或 0x7A。

延时回调函数则根据消息类型调用不同的延时:

uint8_t u8x8_gpio_and_delay_cb(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; }

这两个函数写好后,移植工作就完成了 90%。剩下的就是调用初始化函数,把这两个回调注册进去。

4. 完整实操过程与核心环节实现

4.1 工程搭建与 U8g2 源码裁剪

首先,你需要一个能正常工作的 STM32 工程。我用的是 HAL 库,标准库也可以,原理完全一样。工程里需要包含 GPIO 驱动和 SysTick 延时。然后从 U8g2 的官方仓库下载源码,你只需要csrc目录下的文件。这个目录里文件很多,但实际用到的并不多。对于 SSD1306 128x64 的软件 I2C 驱动,你只需要以下几个文件:

  • u8g2.h和u8g2.c:核心图形库
  • u8x8.h和u8x8.c:底层字符和通信抽象
  • u8x8_d_ssd1306_128x64_noname.c:SSD1306 的具体驱动
  • u8g2_fonts.c:字体文件(如果你需要显示文字)

把这些文件添加到你的工程中,并在编译器设置里把csrc目录加入头文件搜索路径。这里有个小技巧:u8g2_fonts.c文件非常大,编译一次要很久。如果你只需要显示 ASCII 字符,可以只保留u8g2_font_6x10_tf等几个常用字体,把其他字体的定义删掉,能显著减少编译时间和 Flash 占用。

4.2 软件 I2C 驱动的完整实现

把前面讲的五个基本信号组合起来,就是一个完整的软件 I2C 驱动。我把它整理成一个独立的soft_i2c.c和soft_i2c.h,方便复用。头文件里定义好引脚和延时宏,源文件里实现起始、停止、发送字节、接收字节等函数。这里再补充一个细节:在发送字节后,一定要读取应答位。虽然 SSD1306 通常都会应答,但如果你不读取应答,就无法知道通信是否成功。在调试阶段,可以把应答位通过串口打印出来,一旦发现无应答,就说明地址错了或者硬件连接有问题。

uint8_t I2C_WaitAck(void) { uint8_t ack; I2C_SDA_H(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); ack = I2C_SDA_READ(); I2C_SCL_L(); I2C_Delay(); return ack; // 0 表示应答,1 表示非应答 }

在实际项目中,我建议把I2C_Delay()做成可配置的。你可以定义一个全局变量i2c_delay_cnt,在初始化时根据主频设置合适的值。这样同一份代码可以方便地移植到不同主频的 STM32 上。

4.3 U8g2 初始化与测试代码

一切就绪后,就可以初始化 U8g2 了。初始化的核心是选择一个合适的构造函数。对于软件 I2C 驱动 SSD1306 128x64,我们使用u8g2_Setup_ssd1306_i2c_128x64_noname_f。这个函数的名字有点长,但含义很清晰:ssd1306是控制器型号,i2c是通信方式,128x64是分辨率,noname表示通用,_f表示全屏缓冲模式。

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_cb); u8g2_InitDisplay(&u8g2); u8g2_SetPowerSave(&u8g2, 0); // 唤醒显示 u8g2_ClearBuffer(&u8g2); u8g2_SendBuffer(&u8g2); }

测试代码可以这样写:

void OLED_Test(void) { u8g2_ClearBuffer(&u8g2); u8g2_SetFont(&u8g2, u8g2_font_6x10_tf); u8g2_DrawStr(&u8g2, 0, 12, "Hello STM32!"); u8g2_DrawStr(&u8g2, 0, 24, "Soft I2C OK"); u8g2_DrawCircle(&u8g2, 64, 48, 10, U8G2_DRAW_ALL); u8g2_SendBuffer(&u8g2); }

把OLED_Init()放在main()的开头,然后在循环里调用OLED_Test(),如果屏幕显示正常,恭喜你,移植成功了。

4.4 刷新率优化与内存占用分析

U8g2 的全屏缓冲模式会占用 1024 字节的 RAM(128x64/8)。对于 RAM 只有 20KB 的 STM32F103C8T6 来说,这占了 5%,完全可以接受。但如果你用的是 RAM 更小的芯片,比如 8KB 的 STM32F030,就要考虑使用u8g2_Setup_ssd1306_i2c_128x64_noname_1这种页缓冲模式,它只占用 128 字节的 RAM,但代价是你只能按页刷新,不能做复杂的图形叠加。

刷新率方面,软件 I2C 的瓶颈主要在I2C_Delay()。我实测过,在 72MHz 下,把I2C_Delay()设置为 5 个__NOP()时,全屏刷新一次大约需要 25ms,也就是 40 帧每秒。设置为 10 个__NOP()时,刷新一次约 40ms,25 帧每秒。对于显示文字和简单图形,25 帧完全够用。如果你需要更快的刷新率,可以尝试减少延时,但要注意 SSD1306 的时序要求,SCL 高电平时间不能小于 0.6 微秒,低电平时间不能小于 1.3 微秒。用示波器看一下波形,确保满足要求再缩短延时。

5. 常见问题与排查技巧实录

5.1 屏幕完全不亮怎么办

这是最常见的问题,排查思路要按顺序来。第一步,检查硬件连接。确认 VCC 接 3.3V,GND 接地,SCL 和 SDA 没有接反。我遇到过好几次是杜邦线内部断了,外表看不出来,换一根线就好了。第二步,检查 I2C 地址。SSD1306 的地址由模块上的电阻决定,通常是 0x3C 或 0x3D。你可以写一个简单的 I2C 扫描程序,遍历所有地址,看哪个地址有应答。第三步,检查初始化顺序。u8g2_InitDisplay必须在u8g2_SetPowerSave之前调用,否则屏幕不会亮。第四步,检查延时。如果I2C_Delay()太短,时序不满足要求,屏幕也不会亮。可以先把延时加大到 20 个__NOP(),确认能亮之后再逐步减小。

5.2 显示内容错乱或闪烁

显示错乱通常有两个原因:一是 I2C 通信不稳定,数据在传输过程中出错。解决办法是加长I2C_Delay(),或者在 SCL 和 SDA 上并联 20pF 的小电容滤除毛刺。二是 RAM 溢出,如果你同时使用了多个大数组,可能导致 U8g2 的缓冲区被覆盖。检查一下u8g2_SendBuffer调用前后有没有其他内存操作。闪烁则通常是刷新频率太低,或者u8g2_ClearBuffer和u8g2_SendBuffer之间间隔太长。把绘图操作集中在一起,画完立刻发送,能有效减少闪烁。

5.3 通信偶尔失败或死锁

软件 I2C 一般不会死锁,因为每个时钟脉冲都是 CPU 主动发出的。但如果从机因为某种原因拉住了 SDA 不放,就会导致总线忙。解决办法是在I2C_Start()之前加一个总线恢复函数:连续发送 9 个时钟脉冲,让从机释放 SDA。这个技巧在硬件 I2C 中很常用,软件 I2C 中也可以加上,提高鲁棒性。

void I2C_BusRecovery(void) { I2C_SDA_H(); for (int i = 0; i < 9; i++) { I2C_SCL_L(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); } I2C_Stop(); }

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
屏幕完全不亮硬件连接错误万用表测通断重新接线
屏幕完全不亮I2C 地址错误写扫描程序修改地址为 0x3C 或 0x3D
屏幕完全不亮初始化顺序错误检查代码先 InitDisplay 再 SetPowerSave
显示错乱通信不稳定示波器看波形加长延时或加上拉电阻
显示错乱RAM 溢出检查内存使用减少缓冲区或换页模式
显示闪烁刷新率太低测量刷新时间减小延时或优化绘图
通信死锁从机拉住 SDA测量 SDA 电平加总线恢复函数

5.5 独家避坑经验

最后分享几个我在实际项目中总结的经验。第一,不要迷信内部上拉。STM32 的内部上拉电阻太大,I2C 通信很容易受干扰。花几分钱加两个 4.7k 的外部上拉电阻,能省去你几个小时的调试时间。第二,把 I2C 引脚配置为高速模式。GPIO 的翻转速度直接影响 I2C 的波形质量,配置为GPIO_SPEED_FREQ_HIGH能让上升沿更陡峭。第三,在u8x8_byte_sw_i2c中加一个超时机制。如果I2C_WaitAck一直返回非应答,就跳出循环,避免程序卡死。第四,调试阶段用逻辑分析仪或者示波器看波形。软件 I2C 的问题,90% 都能从波形上看出端倪。没有逻辑分析仪的话,用另一个 GPIO 口在关键位置翻转电平,用示波器看时序,也能达到类似效果。

这个移植方案我已经在多个项目中验证过,从 F103 到 F407,从 0.96 寸到 1.3 寸的 OLED,都能稳定运行。核心代码就那么几十行,但带来的灵活性是硬件 I2C 无法比拟的。后续如果你想进一步优化,可以尝试用 DMA 来加速 GPIO 翻转,或者用定时器中断来产生更精确的 I2C 时钟,不过对于大多数应用场景,现在这个方案已经足够好了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 11:48:07

AutoGluon实战指南:自动机器学习如何实现开箱即用的高质建模

1. 这不是调参&#xff0c;是让模型自己学会“挑衣服”——AutoGluon 入门即实战你有没有过这种体验&#xff1a;刚跑通一个机器学习流程&#xff0c;信心满满准备提交结果&#xff0c;结果发现模型在验证集上表现平平&#xff1b;一查才发现&#xff0c;光是XGBoost的max_dept…

作者头像 李华
网站建设 2026/10/22 11:48:04

5G-A上行载波聚合(UL CA)增益量化分析与AI优化实践

1. UL CA在5G-A里的位置&#xff1a;为什么上行突然成了瓶颈做无线通信的同行应该都有体感&#xff1a;过去几轮技术迭代&#xff0c;讨论热度基本都被下行速率牵着走。从4G的载波聚合&#xff0c;到5G的Massive MIMO&#xff0c;再到毫米波&#xff0c;大家关注的重点一直是&q…

作者头像 李华
网站建设 2026/10/22 11:47:38

基于Java的预约制家政服务平台:Spring Boot订单调度与状态管理实战

简介&#xff1a;这是一份以家政服务平台为研究对象的毕业设计论文&#xff0c;完整记录了基于Java语言、Spring Boot框架和MySQL数据库从需求分析到系统实现的全部过程。平台面向管理员、雇主、雇员三个角色&#xff1a;管理员可进行个人中心、雇主管理、雇员管理、资料认证、…

作者头像 李华
网站建设 2026/10/22 11:47:09

从Sanic原型链污染到内核提权:完整攻击链复盘

今年的暑期挑战赛有一道题让我印象很深&#xff1a;入口是个写着 Sanic 的 Web 服务&#xff0c;绕到最后却变成了一次 Linux 内核提权实战。题面本身不复杂&#xff0c;但 Web 层和内核层的衔接非常自然&#xff0c;特别适合用来梳理一条完整的攻击链路——当你的业务代码把一…

作者头像 李华
网站建设 2026/10/22 11:46:40

Umap高维数据可视化原理与业务落地实战指南

1. 项目概述&#xff1a;为什么高维数据“看不见”&#xff1f;Umap如何把它“画出来”你有没有遇到过这样的情况&#xff1a;手头有一批包含几十甚至上百个特征的数据&#xff0c;比如用户行为日志&#xff08;点击、停留、滑动、分享、收藏、搜索关键词、设备型号、网络类型、…

作者头像 李华
网站建设 2026/10/22 11:44:58

STC51串口通信与RS485总线实战指南:从原理到避坑

1. 串口通信为什么是嵌入式里的“万能胶”做了这么多年单片机开发&#xff0c;要说哪个外设用得最勤、最容易翻车、也最绕不开&#xff0c;串口绝对排第一。STC51这个芯片&#xff0c;到现在还在大量项目里用&#xff0c;原因很简单&#xff1a;便宜、稳定、资料多&#xff0c;…

作者头像 李华