简介:面向嵌入式初学者与STM32开发者,这份压缩包是一套基于STM32F103C8T6的0.96寸OLED模拟I2C显示例程,底层使用HAL库,覆盖GPIO配置、I2C总线时序、OLED驱动等关键知识点,解决硬件I2C接口被占用或引脚受限时的显示需求,也适合智能硬件与物联网显示终端的快速原型验证。工程共159个文件,以HAL库源码、CubeMX配置、OLED驱动、编译生成的固件等为主,整体4.83MB,包含从工程配置到代码编译的完整链路,可直接导入STM32CubeIDE或Keil查看,文件分类明确、路径规整,便于定位具体外设与模块代码。当前已有3083人学习下载。例程详细演示了GPIO模拟I2C的时序生成、OLED初始化、显存组织与刷新逻辑,并展示HAL库在时钟、GPIO、延时任务中的典型调用;源码按功能拆分,注释清晰,便于对照理解底层协议与库封装,也能快速迁移到其他I2C外设项目中。
1. 为什么用模拟I2C点亮0.96寸OLED:一条不用碰硬件I2C的稳定路线
第一次在 STM32F103C8T6 上点亮 0.96 寸 OLED,多数人默认会开硬件 I2C 外设。但真把 HAL_I2C_Master_Transmit 调通,往往会碰到引脚被复用、ACK 时序怪异、寄存器配置对不上这三座大山。模拟 I2C 的思路完全不同:SCL 和 SDA 就是两个普通 GPIO 口,你想用哪两个引脚就用哪两个,时序由自己的代码逐位控制,出错时可以掰开了看。这篇例程的目标是:不依赖 STM32 的 I2C 外设,用 HAL 库的 GPIO 操作把一个 SSD1306 驱动的 OLED 完整点亮,并附上可复现的初始化、画点、显示字符的代码。适合刚入门、被硬件 I2C 整到头大的新手,也适合想把显示驱动快速移植到其他 MCU 的老手。
2. SSD1306 驱动核心:看懂0.96寸OLED的地址、命令和I2C时序
2.1 先认识SSD1306:一屏就是128x64的可寻址矩阵
0.96 寸 OLED 模块的驱动芯片几乎都是 SSD1306,分辨率 128x64。它的内部显存不是按像素一行一行排的,而是按“页”组织的:整屏分成 8 页,每页 8 行像素、128 列。换句话说,一次 I2C 写入的最小单位是一个字节,这一个字节恰好覆盖某页上某一列的 8 个垂直像素点。
所以画图时要习惯这个逻辑:想在 (x, y) 处点亮一个像素,先算出它属于哪一页(y / 8)、哪一列(x),再在帧缓冲里把对应 bit 置 1。直接往屏幕写单个点的做法不是不行,但效率极低,后面所有显示字符串的功能都建议先改帧缓冲再批量刷新。
页地址命令是 0xB0~0xB7,列地址分成低 4 位(0x00~0x0F)和高 4 位(0x10~0x1F)两次发送。每次刷新前都要重新指定起始页和起始列,否则数据会继续写到上一帧结束的位置。
2.2 I2C总线上到底在传什么:从机地址、控制字节和ACK
I2C 是两根线的半双工总线。SSD1306 作为从机,7 位地址通常是 0x3C,对应的 8 位写地址是 0x78。有的模块把 SA0 引脚拉高后地址变成 0x3D,写地址是 0x7A。这个区别是第一个坑,后面排查部分会细说。
每次传输的帧结构是固定的:起始条件、从机地址字节、ACK、控制字节、ACK、数据字节、ACK,最后停止条件。SSD1306 用控制字节区分后面跟的是命令还是数据:0x00 表示命令,0x40 表示数据。很多人第一次写驱动时,只发了从机地址和数据,漏了控制字节,屏幕毫无反应。
ACK 是从机在每收到一个字节后,把 SDA 拉低一个时钟周期表示“收到了”。如果主机没有读 ACK 的习惯,通常也能跑,但一旦接线变长、时序漂移,问题就会以花屏的形式暴露出来。所以例程里我会保留 ACK 检测,出问题时它就是最直接的诊断信号。
2.3 硬件连接:四根线和上拉电阻的选择
0.96 寸 OLED 模块一般引出 VCC、GND、SCL、SDA 四根线。VCC 接 3.3V,不能接 5V。OLED 面板对电压很敏感,5V 供电虽然不会立刻烧掉,但会明显加速发光材料老化,用几个月后亮度就会肉眼可见地下降。
SCL 和 SDA 是开漏总线,必须靠上拉电阻把电平拉高。多数模块上已经焊好了 4.7k 或 10k 上拉电阻,直接接线就能用。如果你用的是裸屏或者自己画的板子,记得在两根线上各加一个 4.7k 电阻到 3.3V。
接线长度也是个变量。用杜邦线连接超过 20cm 时,线间电容会让上升沿变缓,SCL 高电平时间缩短,从机可能识别不到正确的时钟。这时候除了缩短线长,还可以把上拉电阻换成 2.2k 试试,或者降低模拟 I2C 的翻转速度——这就是后面要调延时参数的原因。
3. CubeMX配置STM32F103C8T6:GPIO、时钟和调试口一次设对
3.1 新建工程:先把SYS调试口设成Serial Wire
用 CubeMX 新建 STM32F103C8T6 工程时,第一件事不是配 OLED,而是把 System Core -> SYS -> Debug 从 No Debug 改成 Serial Wire。这个选项控制 PA13/PA14 的调试功能是否启用。如果选了 No Debug,程序第一次下载后还能跑,但第二次想再烧录时,调试口已经被 GPIO 模式占用了,下载器连不上,只能按住复位键抢时间,或者用串口 ISP 擦除整个芯片。这一步省不得。
RCC 配置里,如果你的最小系统板上有 8MHz 晶振,选 HSE -> Crystal/Ceramic Resonator。时钟树里把 SYSCLK 设到 72MHz,APB1 保持 36MHz,APB2 保持 72MHz,这些默认值都不用动。模拟 I2C 不依赖外设时钟,主频只影响延时循环的执行速度,所以后面调延时参数时要记住自己当前跑在多少兆。
在 Project Manager 里生成工程,Toolchain 选 MDK-ARM 或你习惯的 IDE。生成的 main.c 里已经有 HAL_Init、SystemClock_Config 和 MX_GPIO_Init 三个函数,这三个缺一不可:HAL_Init 负责初始化 SysTick 和 Flash 预取,SystemClock_Config 切换时钟源,MX_GPIO_Init 负责我们接下来要配置的引脚。
3.2 GPIO配置:PB6/PB7的模式和速度按场景选
SCL 用 PB6,SDA 用 PB7,这是最常见的选择,因为这两脚本来就在 I2C1 上,就算后面你要换成硬件 I2C,板子走线也不用改。在 CubeMX 的 Pinout 视图里点 PB6 和 PB7,把它们设成 GPIO_Output。
PB6 的 SCL 我习惯配成推挽输出(Push-Pull),速度选 High。PB7 的 SDA 建议配成开漏输出(Open-Drain),原因是开漏输出写 1 时引脚实际是被释放的,从机想拉低 SDA 就能拉低,这让主机可以直接读回 ACK。如果用推挽输出,SDA 被主机强制拉高时,从机根本拉不下来,读 ACK 永远失败。这是模拟 I2C 里非常典型的一个电气错误。
| 参数 | SCL (PB6) | SDA (PB7) | 说明 |
|---|---|---|---|
| Mode | Output Push-Pull | Output Open-Drain | SDA 开漏才能读 ACK |
| Speed | High (50MHz) | High (50MHz) | 线长时改 Medium 试 |
| Pull-up | No pull-up | No pull-up | 依赖外部或内部上拉 |
| Initial Level | High | High | 总线空闲时为高 |
GPIO 速度这一项,CubeMX 里对应 GPIO_SPEED_FREQ_HIGH 或 GPIO_SPEED_FREQ_MEDIUM。很多人以为速度越高通信越快,其实它只决定引脚输出级的翻转斜率。50MHz 模式下边沿更陡,短线上没问题,但线一长容易在 SDA 上产生振铃。遇到偶发花屏,先把速度改成 Medium 再观察,这比调代码更接近根因。
SDA 开漏输出时,如果模块上没有外部上拉,STM32 内部的上拉电阻大约是 40k 左右,对 400kHz 模式的 I2C 来说太弱。所以还是要确认模块上有 4.7k~10k 的上拉,或者自己在板子上补两个。
3.3 工程目录与代码组织:驱动文件放哪里更顺手
别把 OLED 显示逻辑全塞进 main.c。我会建两个文件:oled.h 和 oled.c,放在 Core/Inc 和 Core/Src 目录下跟 main.c 平级。oled.h 里放引脚宏、帧缓冲大小、对外接口函数的声明;oled.c 里放模拟 I2C 原语、SSD1306 初始化、画点、清屏、显示字符。
这样组织的原因很实际:模拟 I2C 的底层原语只有四个——起始、停止、写字节、读 ACK。上层所有功能都是在这四个原语之上拼起来的。以后想换成硬件 I2C,只需要把 oled.c 里这四个原语的实现替换掉,初始化、画点、显示字符串的代码一行都不用改。工程组织得好不好,在这种驱动类代码上体现得最明显。
main.c 里只需要做三件事:初始化 HAL、初始化时钟、调用 OLED_Init 和 OLED_ShowString。显示逻辑不要跟主流程缠在一起,否则后面加按键、加传感器时,main 函数会变成一锅粥。
4. 手写模拟 I2C 与 OLED 驱动:从初始化到显示字符
4.1 引脚宏与延时原语:为什么不能用 HAL_Delay
模拟 I2C 的第一步是把 GPIO 操作封装成宏。HAL_GPIO_WritePin 每次调用有函数调用开销,但已经足够快,在 72MHz 主频下大约零点几微秒,完全可以接受。真正的问题是延时:I2C 一个时钟周期在 400kHz 模式下只需要 2.5 微秒,而 HAL_Delay 最小单位是 1 毫秒,差着两个数量级。所以要用空循环实现微秒级延时,变量必须加 volatile,否则编译器优化后循环可能被整个删掉。
/* oled.h */ #ifndef OLED_H #define OLED_H #include "main.h" #define OLED_SCL_PIN GPIO_PIN_6 #define OLED_SDA_PIN GPIO_PIN_7 #define OLED_PORT GPIOB #define OLED_SCL_L() HAL_GPIO_WritePin(OLED_PORT, OLED_SCL_PIN, GPIO_PIN_RESET) #define OLED_SCL_H() HAL_GPIO_WritePin(OLED_PORT, OLED_SCL_PIN, GPIO_PIN_SET) #define OLED_SDA_L() HAL_GPIO_WritePin(OLED_PORT, OLED_SDA_PIN, GPIO_PIN_RESET) #define OLED_SDA_H() HAL_GPIO_WritePin(OLED_PORT, OLED_SDA_PIN, GPIO_PIN_SET) static inline void OLED_Delay(void) { volatile uint32_t i = 20; while (i--) { } } #endif逻辑说明:SCL 和 SDA 用宏定义在 oled.h 顶部,换引脚时只改这里,不用动整个驱动文件。OLED_Delay 里的 i=20 是在 72MHz 主频下调出来的典型值,大约 1~2 微秒。如果你用 8MHz 内部时钟,这个值要缩到 2~3;如果屏幕花屏或者 ACK 飘忽,先把 i 加大到 40~60,确认稳定后再慢慢减。
这里有一个值得注意的点:HAL_GPIO_WritePin 内部有参数检查和函数调用,比直接操作 BSRR 寄存器慢。追求极致刷新率的人可以换成GPIOB->BSRR = GPIO_PIN_6这样的寄存器操作,但代价是代码和具体引脚绑定,移植性变差。我的习惯是先跑通再优化,寄存器级优化放到最后。
4.2 起始、停止、发字节、读ACK:四个必须自己写的原语
模拟 I2C 的原理不复杂,但顺序很重要。起始条件是 SCL 高电平时 SDA 从高变低;停止条件是 SCL 高电平时 SDA 从低变高。数据位在 SCL 高电平时必须保持稳定,所以改变 SDA 永远要在 SCL 拉低之后。
/* oled_i2c.c */ #include "oled.h" void OLED_I2C_Start(void) { OLED_SDA_H(); OLED_SCL_H(); OLED_Delay(); OLED_SDA_L(); // SCL 高时 SDA 拉低,产生起始条件 OLED_Delay(); OLED_SCL_L(); // 拉低 SCL,准备后续数据 OLED_Delay(); } void OLED_I2C_Stop(void) { OLED_SDA_L(); OLED_SCL_H(); OLED_Delay(); OLED_SDA_H(); // SCL 高时 SDA 拉高,产生停止条件 OLED_Delay(); } void OLED_I2C_WriteByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { OLED_SCL_L(); if (dat & 0x80) { OLED_SDA_H(); } else { OLED_SDA_L(); } dat <<= 1; OLED_Delay(); OLED_SCL_H(); // 数据就绪后,拉高 SCL 让从机采样 OLED_Delay(); } OLED_SCL_L(); } uint8_t OLED_I2C_WaitAck(void) { OLED_SDA_H(); // 释放 SDA,让从机决定是否拉低 OLED_SCL_H(); OLED_Delay(); uint8_t ack = (HAL_GPIO_ReadPin(OLED_PORT, OLED_SDA_PIN) == GPIO_PIN_RESET) ? 1 : 0; OLED_SCL_L(); OLED_Delay(); return ack; }逻辑说明:发送字节时,每位的流程是“SCL 拉低 -> 设置 SDA 电平 -> 延时 -> SCL 拉高 -> 延时”。先设 SDA 再拉高 SCL,保证了数据在上升沿时已经稳定,这符合 I2C 协议对建立时间的要求。
WaitAck 函数里那句 OLED_SDA_H() 是关键。当 SDA 配置为开漏输出时,写 1 等同于释放总线,从机才有机会把 SDA 拉低。如果 SDA 是推挽输出,从机拉低时等于跟主机打架,不但读不到 ACK,还可能产生短路电流。如果你在 CubeMX 里已经把 SDA 配成推挽,那这个函数里必须临时切换 SDA 为输入模式再读,读完了再切回,非常别扭,所以不如一开始就配开漏。
4.3 写命令和数据字节的封装:地址字节别写错
有了四个底层原语,向 OLED 发命令和数据就是重复拼装帧结构。每次都要先发从机地址,再发控制字节,最后才发真正的数据。别偷懒省略中间任何一步,SSD1306 对帧格式是严格的。
void OLED_WriteCmd(uint8_t cmd) { OLED_I2C_Start(); OLED_I2C_WriteByte(0x78); // 0x3C 左移 1 位,写方向 OLED_I2C_WaitAck(); OLED_I2C_WriteByte(0x00); // 控制字节:命令 OLED_I2C_WaitAck(); OLED_I2C_WriteByte(cmd); OLED_I2C_WaitAck(); OLED_I2C_Stop(); } void OLED_WriteData(uint8_t dat) { OLED_I2C_Start(); OLED_I2C_WriteByte(0x78); OLED_I2C_WaitAck(); OLED_I2C_WriteByte(0x40); // 控制字节:数据 OLED_I2C_WaitAck(); OLED_I2C_WriteByte(dat); OLED_I2C_WaitAck(); OLED_I2C_Stop(); }参数说明:0x78 是 7 位地址 0x3C 左移一位再加写标志位 0 的结果。如果模块上的 SA0 引脚被拉高,地址变成 0x3D,这里就要改成 0x7A。控制字节 0x00 和 0x40 的区别一定要记住:0 后跟命令,1 后跟数据。这个封装后面所有功能都会复用,包括初始化、清屏、刷新。
4.4 SSD1306 初始化序列:每条命令的作用得心里有数
网上能搜到一大把初始化序列,大部分都能点亮屏幕。问题在于不理解每条命令时,一旦屏幕显示异常就不知道往哪查。下面这份是我实际用的,注释写明了每条命令的作用。
void OLED_Init(void) { HAL_Delay(100); // 上电稳定,给内部复位留时间 OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0xD5); OLED_WriteCmd(0x80); // 显示时钟分频 / 振荡频率 OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); // 多路复用比 1/64 OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); // 显示偏移 0 OLED_WriteCmd(0x40); // 显示起始行 0 OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 开启内部电荷泵 OLED_WriteCmd(0x20); OLED_WriteCmd(0x02); // 页寻址模式 OLED_WriteCmd(0xA1); // 段重映射,X 方向从左到右 OLED_WriteCmd(0xC8); // COM 扫描方向翻转 OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); // COM 引脚硬件配置 OLED_WriteCmd(0x81); OLED_WriteCmd(0xCF); // 对比度 OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); // 预充电周期 OLED_WriteCmd(0xDB); OLED_WriteCmd(0x40); // VCOMH 电平 OLED_WriteCmd(0xA4); // 全局显示保持 RAM 内容 OLED_WriteCmd(0xA6); // 正常显示模式,非反显 OLED_WriteCmd(0xAF); // 打开显示 OLED_ClearScreen(); // 清屏,避免显示乱码 }参数说明:0x8D 和 0x14 是开启内部电荷泵,没有这一步,屏幕很可能完全不亮,这是白屏问题的第一大嫌疑。0x81 设置对比度,0xCF 适合大多数 0.96 寸屏,如果你觉得亮度偏高或偏暗,可以在 0x7F~0xFF 之间微调。0xA1 和 0xC8 这两个组合决定屏幕的显示方向,如果你发现字符镜像或上下颠倒,把这两条改成 0xA0 和 0xC0 再试。
初始化的顺序也有讲究:先清屏配置再开显示,而不是反过来。中间如果插入了显示相关的操作,某些模块会出现开机一瞬间的乱码闪烁。
4.5 帧缓冲、画点、清屏与显示字符串
帧缓冲是模拟 I2C 驱动里最能提升体验的部分。它是 8 行 128 列的数组,对应 SSD1306 的 8 页 x 128 列。所有绘画操作先改这个数组,再整帧推给屏幕,这样即使你连续画几十个点,屏幕也不会一个点闪一次。
/* 帧缓冲:8 页 * 128 列 */ static uint8_t OLED_GRAM[8][128]; void OLED_ClearScreen(void) { memset(OLED_GRAM, 0, sizeof(OLED_GRAM)); OLED_Refresh(); } void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t on) { if (x >= 128 || y >= 64) { return; } uint8_t page = y / 8; // 像素所在页 uint8_t bit = y % 8; // 页内行偏移 if (on) { OLED_GRAM[page][x] |= (1 << bit); } else { OLED_GRAM[page][x] &= ~(1 << bit); } } void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); // 定位到第 page 页 OLED_WriteCmd(0x00); // 列地址低字节 0 OLED_WriteCmd(0x10); // 列地址高字节 0 for (uint8_t col = 0; col < 128; col++) { OLED_WriteData(OLED_GRAM[page][col]); } } }逻辑说明:画点函数先做边界检查,防止数组越界。page 和 bit 的计算是页寻址的核心,y/8 确定在哪一页,y%8 确定这一页里的第几行。OLED_Refresh 每次先从页 0 开始,依次写完 8 页数据,每写一页前都重新发送页地址和列地址,保证数据落位正确。
显示字符串需要字模。下面用一个 6x8 的 ASCII 字体做例子,注意字模数据的排列方向:每一列是一个字节,字节的最低位对应屏幕上方。
/* 6x8 字体,索引从 0x20 空格开始 */ const uint8_t font6x8[][6] = { {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, /* 空格 */ {0x00, 0x00, 0x5F, 0x00, 0x00, 0x00}, /* ! */ /* 后续字符按 ASCII 顺序补充 */ }; void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { uint8_t idx = ch - 0x20; for (uint8_t i = 0; i < 6; i++) { uint8_t line = font6x8[idx][i]; for (uint8_t bit = 0; bit < 8; bit++) { OLED_SetPixel(x + i, y + bit, (line >> bit) & 0x01); } } } void OLED_ShowString(uint8_t x, uint8_t y, const char *str) { uint8_t cx = x; while (*str) { OLED_ShowChar(cx, y, *str); cx += 6; if (cx > 128 - 6) { // 超出右边界换行 cx = 0; y += 8; } str++; } }参数说明:6x8 字模里每个字符 6 字节,每字节代表一列,字节内的第 0 位是这一列最上面的像素,所以在画点时用(line >> bit) & 0x01逐位展开。索引用ch - 0x20是为了把 ASCII 码映射到数组下标。换行逻辑里,y 增加 8 正好对应一页的高度,这样显示多行文本时不会出现上下两行互相覆盖。
调用方式很简单:OLED_Init()之后再OLED_ShowString(0, 0, "Hello OLED"),如果需要更新内容,先清屏再重新显示。到这里,STM32F103C8T6 用 HAL 库加模拟 I2C 点亮 0.96 寸 OLED 的完整链路就已经通了。
5. OLED白屏、花屏、闪烁:常见问题和排查路径
5.1 上电后屏幕完全不亮或白屏
现象:程序跑起来,OLED 没有任何显示,屏幕始终保持一块均匀的亮色或者完全黑暗。
原因:最常见的有三个。一是初始化序列里漏了 0x8D/0x14 的电荷泵开启命令,屏幕没有驱动电压。二是从机地址不对,数据根本没送到 SSD1306。三是 VCC 供电不足,OLED 模块虽小,但全亮时电流能到 20mA 以上,如果从 3.3V 稳压器的末端飞线取电,压降可能让屏幕处于临界工作状态。
解决:先用示波器或万用表确认 VCC 在 3.3V 左右,再用逻辑分析仪抓 I2C 波形,看 SDA 上有没有 ACK 低电平。如果波形里只有地址没有 ACK,大概率是地址或接线问题。如果波形完全正常但没有显示,重点检查初始化序列里的电荷泵命令。
5.2 显示乱码、字符偏移和左右镜像
现象:能显示,但字符错位、上下两半颠倒,或者整个屏幕像照镜子一样反了。
原因:这类问题几乎都出在字模数据和屏幕扫描方向不匹配上。取模软件有“逐行式”和“逐列式”两种输出方式,如果存的数据是按行排列的,而 SSD1306 页寻址要求按列排列,字符就会拆成上下两半。左右镜像则是段重映射 0xA1/0xA0 设置不对。
解决:我一般先用一个最简单的字符“T”做测试,它上下左右都不对称,任何方向错位都能一眼看出来。然后分别调整 0xA1/0xA0 和 0xC8/0xC0 的组合,找到正确方向后,再让取模软件配合当前方向批量导出字模。这个试错过程最好记录下来,因为换一块不同厂家的屏幕时经常要重调。
5.3 偶发花屏和ACK丢失
现象:平时显示正常,运行几分钟后突然出现杂点,或者刷新时偶尔丢一行数据,重启又好了。
原因:模拟 I2C 的时序余量不足。接线长、GPIO 速度过快产生振铃、上拉电阻偏大导致上升沿太慢,都会让从机在 SCL 采到错误的 SDA 电平。还有一个容易被忽略的点:你在 OLED_Delay 里用的延时值跟主频强相关,如果在系统时钟配置改变后没有同步调整延时常量,原本稳定的时序会突然变差。
解决:先把 OLED_Delay 的值加大一倍,观察问题是否消失。再把 GPIO 速度从 High 降到 Medium,对比效果。最后检查接线长度,超过 15cm 的飞线是重点怀疑对象。这三个方向都试过还不行,就上逻辑分析仪看 SCL 高电平期间 SDA 是否稳定。
5.4 无论怎么调都等不到ACK
现象:WaitAck 函数一直返回失败,调试时单步走到读引脚那行,SDA 永远是高电平。
原因:最典型的原因是 SDA 被配置成了推挽输出。推挽输出强驱高电平时,从机拉不住这根线,SDA 永远是高。另外也可能是地址字节错误,0x78 和 0x7A 写反了,从机直接不回应。还有一种可能是模块本身已经损坏,换一块屏幕立刻就能确认。
解决:把 SDA 改成开漏输出,确认 CubeMX 里模式是 Output Open-Drain。检查地址是 0x78 还是 0x7A,用万用表量 SDA 空闲电平,正常应该被上拉到 3.3V 附近。如果 SDA 上拉了但波形只有地址没有 ACK,换一块屏幕试试,SSD1306 芯片本身也有翻车的概率。
5.5 刷新慢、肉眼可见闪烁
现象:显示内容更新时能明显看到刷新扫描的过程,尤其是大量文字时,从上往下扫过去的痕迹很清晰。
原因:每次 OLED_Refresh 都要发送 8 页 x 128 列共 1024 个数据字节,加地址、控制、ACK 开销,一次全屏刷新轻松超过 10 毫秒。如果用逐点直接写屏的方式,每画一个点就完整走一遍起始停止流程,那刷新率会低到惨不忍睹。
解决:改帧缓冲是第一步,改完后逐点操作不再直接触发屏幕通信。帧率还不够就用局部刷新,只重发变化的页和列区间。比如一个数字在固定位置变化,只需要刷新数字所在的那一页和那几列,刷新时间从十几毫秒降到一两毫秒,闪烁感会完全消失。
6. 进阶技巧:局部刷新、自定义字库与性能验证
6.1 局部刷新:别每次全屏重发1024字节
OLED_Refresh 整帧刷新是新手阶段最直接的写法,但在做仪表盘、菜单这类界面时,没必要每次把 8 页全推一遍。把刷新函数升级成带范围的版本,只更新发生变化的页和列区间,这是显示驱动性能提升最明显的一步。实际上我后来做任何 SSD1306 的项目都会保留一个 OLED_RefreshRegion 函数,整帧刷新只留给开机清屏用。
void OLED_RefreshRegion(uint8_t page_start, uint8_t page_end, uint8_t col_start, uint8_t col_end) { for (uint8_t page = page_start; page <= page_end; page++) { OLED_WriteCmd(0xB0 + page); OLED_WriteCmd((col_start >> 4) & 0x0F); // 列地址高 4 位 OLED_WriteCmd(col_start & 0x0F); // 列地址低 4 位 for (uint8_t col = col_start; col <= col_end; col++) { OLED_WriteData(OLED_GRAM[page][col]); } } }这里的列地址拆分逻辑跟整帧刷新时固定为 0 不一样,高位在前。如果你忘了设置列地址,数据就会从上次的位置接着写,显示结果会整体跑偏。
6.2 自定义字库:从取模软件到页地址的正确排列
做中文显示或者图标时,16x16 字模是绕不开的。取模软件里设置成“纵向取模、字节倒序”还是“横向取模、逐行式”,取决于你想让数据直接对应到 SSD1306 的页地址模式。最省事的做法是在取模软件里选“逐列式”,让每个字节对应一列上的 8 个像素点,这样字模数据跟 OLED_GRAM 的排列方式天然一致,可以直接按页写入。
void OLED_ShowChinese(uint8_t x, uint8_t y, const uint8_t *font, uint8_t font_w) { for (uint8_t col = 0; col < font_w; col++) { for (uint8_t page = 0; page < 2; page++) { uint8_t dat = font[page * font_w + col]; for (uint8_t bit = 0; bit < 8; bit++) { OLED_SetPixel(x + col, y + page * 8 + bit, (dat >> bit) & 0x01); } } } }这段代码假设 16x16 汉字纵向取模,数据分布是“上半页 16 字节 + 下半页 16 字节”。如果取模方式不同,显示就会出现上下半截错位。遇到这个现象不要怀疑代码逻辑,先回取模软件里调输出格式。
6.3 没有逻辑分析仪也能验证帧率:用 DWT 计数器
验证优化有没有效,不需要高深的仪器。Cortex-M3 内核自带 DWT 周期计数器,用它对任意代码段计时,精度是一个时钟周期。下面是测量 100 次刷新平均耗时的常见做法:
CoreDebug->DEMCR = CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; uint32_t t0 = DWT->CYCCNT; for (int i = 0; i < 100; i++) { OLED_Refresh(); } uint32_t cycle = DWT->CYCCNT - t0; float avg_us = (float)cycle / 100.0f / 72.0f; // 72MHz 主频在 72MHz 下,一次全屏刷新通常在 15~30 毫秒之间,对应每秒 30~60 帧的速度,肉眼已经能感到轻微迟滞。改成局部刷新后,单次刷新能降到 1~2 毫秒,这时候界面的交互体验会有质的提升。
我第一次做 OLED 菜单时,数字变化用全屏刷新,翻页有明显的拖影感,一开始还以为是屏幕响应速度不行,后来测了 DWT 才发现每次刷新要 28 毫秒。改成只刷新数字所在那一页之后,拖影立刻消失。后来我把这个习惯带到了所有显示项目里:先计时,再优化,别凭感觉改代码。这套模拟 I2C 驱动的最大价值,就是让你对每一条总线上传输的字节都心里有数,排查问题时不用对着黑匣子瞎猜。希望帮到你。
本文还有配套的精品资源,点击获取