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 整体移植步骤概览我把整个移植过程拆解为五个步骤后面会逐一详细展开准备基础工程一个能正常编译下载的 STM32 工程包含 GPIO 驱动和 SysTick 延时。获取 U8g2 源码从官方仓库下载源码提取出核心的csrc目录。实现软件 I2C 底层驱动包括 GPIO 初始化、起始信号、停止信号、发送字节、接收应答。实现 U8g2 回调函数包括字节发送回调、GPIO 延时回调。初始化并测试调用 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 通信的所有操作都可以分解为五个基本信号的组合起始信号StartSCL 为高时SDA 由高变低。停止信号StopSCL 为高时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.cSSD1306 的具体驱动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 字节的 RAM128x64/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()时刷新一次约 40ms25 帧每秒。对于显示文字和简单图形25 帧完全够用。如果你需要更快的刷新率可以尝试减少延时但要注意 SSD1306 的时序要求SCL 高电平时间不能小于 0.6 微秒低电平时间不能小于 1.3 微秒。用示波器看一下波形确保满足要求再缩短延时。5. 常见问题与排查技巧实录5.1 屏幕完全不亮怎么办这是最常见的问题排查思路要按顺序来。第一步检查硬件连接。确认 VCC 接 3.3VGND 接地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 时钟不过对于大多数应用场景现在这个方案已经足够好了。