简介:这套针对 STM32F103C8T6 的 OLED 模拟 I2C 显示例程,主要面向嵌入式初学者和单片机开发者,演示如何使用 HAL 库通过 GPIO 引脚模拟 I2C 时序,驱动 0.96 英寸 OLED 显示屏。相比占用硬件 I2C 外设的方案,模拟方式更灵活,也适合在硬件接口被其他设备占用时使用,可作为学习 I2C 协议与 HAL 库工程配置的完整样例。压缩包共 159 个文件,大小约 4.83MB,以 C 源码和头文件为主体,含 HAL 库底层驱动与 OLED 显示模块;同时保留了 Keil 工程配置、CubeMX 项目文件、编译中间文件以及可烧录的 hex 文件,工程层级清晰,便于对照学习。已有 3083 人学习下载。通过该例程可以掌握 GPIO 推挽输出配置、软件模拟 I2C 通信时序、OLED 初始化序列与显存刷新流程,也能借鉴模块化的代码组织和常见问题排错思路;参照该工程可快速搭建自己的显示项目,并迁移到温湿度显示、菜单交互界面等类似场景。
1. 交付前 OLED 蓝屏:0.96 寸屏 + 模拟 I2C 这个例程到底在解决什么
做设备联调的最后一天,OLED 屏幕突然只能显示上半屏,用示波器量 SCL/SDA 波形,ACK 位偶尔丢,最后查出来是硬件 I2C 在中断繁忙时被低频任务抢占,总线状态机卡在 BUSY 状态。这种情况下很多人会直接换方案,把手头的 0.96 寸 SSD1306 OLED 从硬件 I2C 改成模拟 I2C——用两根普通 GPIO 按位翻转去拼出 I2C 时序。标题里的「STM32F103C8T6 + HAL 库 + 模拟 I2C」就是这条最常见的落地路径:不需要外设复用配置,不依赖芯片内部 I2C 状态机,代码逻辑完全可控。这篇文章会把从 CubeMX 配置到 SSD1306 初始化和显示字符的完整例程讲清楚,顺带把频率、上拉电阻、ACK 检测这些参数说透,适合正在被显示驱动卡住的嵌入式开发者。
2. 为什么选模拟 I2C 而不是硬件 I2C:SSD1306 在 F103C8T6 上的选型逻辑
2.1 硬件 I2C 的“玄学”翻车:从 BUSY 卡死到 ACK 丢失
STM32F103C8T6 内部有完整的 I2C 外设,理论上用 HAL 库调用HAL_I2C_Mem_Write就能往 SSD1306 写数据,代码还简洁。但实际项目里,硬件 I2C 的表现经常让人摸不着头脑:初始化时一切正常,跑了几分钟后HAL_I2C_GetState返回 BUSY,后续所有写操作超时;或者总线上一有毛刺,状态机就停在错误位,必须复位外设才能恢复。这类问题和具体的库版本、编译优化等级、中断优先级都有关系,有时换了块板子又复现不了,属于典型的“黑匣子”行为。
我见过某开发者把硬件 I2C 的出错现场拿到示波器上看:SCL 波形正常,SDA 在第 9 个时钟的 ACK 窗口没有拉低,从机没应答,但主机已经认为传输完成。SSD1306 对时序要求并不苛刻,问题多半出在主机侧的开漏配置或者总线被其他中断打断。硬件 I2C 外设一旦进入异常状态,需要走完一整串错误恢复流程,而在产线环境和现场调试时,你没有那么多时间去分析外设状态寄存器。
2.2 模拟 I2C 的原理:用两个 GPIO 拼出完整时序
模拟 I2C 的本质是用 GPIO 输出寄存器手动控制 SCL 和 SDA 的电平变化,按 I2C 协议把起始、停止、数据位、应答位逐个拼出来。每条总线上只有主机和从机两个角色,OLED 这种只收不发的外设(读操作极少用)特别适合模拟,因为它不需要处理复杂的总线仲裁和时钟同步。常见做法是把 SCL 和 SDA 配置为推挽输出,SDA 在需要读 ACK 时临时切换为输入模式,读完再切回输出。推挽模式配合外部上拉电阻,电平翻转速度比开漏模式更快,这也是模拟 I2C 能跑到 400kHz 级别的原因之一。
起始条件定义为 SCL 高电平时 SDA 从高变低,停止条件定义为 SCL 高电平时 SDA 从低变高。数据位在 SCL 高电平期间必须保持稳定,SCL 低电平时才允许 SDA 变化。写一个字节就是从最高位开始逐位输出,每个位在 SCL 上升沿被从机采样。第 9 个时钟是 ACK 位,从机如果正常接收会把 SDA 拉低作为应答。这套时序完全由软件循环控制,不受芯片内部状态机影响,只要 GPIO 初始化没问题,代码写对了就一定能跑,这是它最大的价值。
2.3 什么时候该用模拟 I2C,什么时候该用硬件 I2C
我的判断标准是看总线负载和从机数量。如果总线上只有一个 SSD1306,传输频率不超过 400kHz,模拟 I2C 完全够用,而且代码可控、排错直观。如果总线上挂了多个从机,比如温度传感器、EEPROM、OLED 共存,或者需要 DMA 批量传输、需要多主机仲裁,那就老老实实用硬件 I2C,把超时重试和错误恢复写好。另外,如果项目里原本就有现成的硬件 I2C 驱动,且跑了很久没出过问题,不要为了“统一方案”强行改成模拟,改动的风险远大于收益。
下面是两种方案的对比,可以帮你快速做决定:
| 对比项 | 模拟 I2C(GPIO 翻转) | 硬件 I2C(外设状态机) |
|---|---|---|
| 引脚选择 | 任意 GPIO,灵活换脚 | 固定复用引脚,如 PB6/PB7 |
| 传输速率 | 由软件延时可调,常见 100k~400k | 硬件 PCLK 分频,最高 400k |
| 错误恢复 | 代码重新初始化即可 | 需处理 BUSY、AF、BERR 等状态 |
| 多从机支持 | 可支持,但需自行处理仲裁 | 原生支持,配合 DMA 更高效 |
| 排错难度 | 每一拍波形都在代码里,直观 | 状态机复杂,依赖寄存器 |
3. 用 HAL 库搭模拟 I2C OLED 显示例程:从 CubeMX 到屏幕点亮
3.1 接线与 CubeMX 引脚配置
先确定硬件连接。0.96 寸 OLED 模块通常引出 4 个引脚:VCC、GND、SCL、SDA。VCC 接 3.3V,GND 接地,SCL 和 SDA 分别接到 STM32F103C8T6 的两个 GPIO。我习惯选 PB6 和 PB7,理由有两个:一是这两个引脚在默认的调试下载器接口旁边,接线方便;二是即使以后想切回硬件 I2C1,引脚可以复用,不用重新改板。需要注意,板上如果已经接了 10k 或 4.7k 上拉电阻到 3.3V,那就不用再加,如果没有,建议在 SCL、SDA 上各加一个 4.7k 上拉。OLED 模块背面很多已经自带上拉,具体看模块丝印或原理图。
在 CubeMX 里配置时,把 PB6、PB7 都设置为 GPIO_Output,输出模式选 Push Pull,速度选 High,初始电平选 High。这里有两个细节容易被忽略:一是不需要开启 I2C 外设,所以不用在 Connectivity 里勾选 I2C1;二是不要开复用功能,一旦开了 AF,GPIO 输出寄存器就控制不了引脚电平了。配置好之后生成代码,得到MX_GPIO_Init()函数,里面会初始化这两个引脚。
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6 | GPIO_PIN_7, GPIO_PIN_SET); GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);这段代码把 PB6、PB7 设为推挽输出并默认拉高。推挽输出的驱动能力比开漏强,翻转速度快;初始电平设为高是为了避免上电瞬间 SDA、SCL 处于低电平而被误认为起始条件。外部上拉电阻由GPIO_NOPULL承担,也就是说内部上下拉都关闭,避免和外部上拉分压产生不确定电平。
3.2 软件 I2C 底层:起始、停止与字节收发
软件 I2C 的核心是四个基础函数:起始、停止、写字节、读字节。起始和停止函数足够简单,直接操作宏翻转电平即可。写字节函数里最关键的是位输出顺序,一定要从高位开始,逐位移位,每输出一位后拉高 SCL 让从机采样,再拉低 SCL 准备下一位。延时函数放在 SCL 高和低之间,用空循环实现微秒级延时,不需要精确到纳秒级别,但也不能太短,否则高速模式下信号毛刺会增多。
#define OLED_I2C_PORT GPIOB #define OLED_SCL_PIN GPIO_PIN_6 #define OLED_SDA_PIN GPIO_PIN_7 #define OLED_SCL_H() HAL_GPIO_WritePin(OLED_I2C_PORT, OLED_SCL_PIN, GPIO_PIN_SET) #define OLED_SCL_L() HAL_GPIO_WritePin(OLED_I2C_PORT, OLED_SCL_PIN, GPIO_PIN_RESET) #define OLED_SDA_H() HAL_GPIO_WritePin(OLED_I2C_PORT, OLED_SDA_PIN, GPIO_PIN_SET) #define OLED_SDA_L() HAL_GPIO_WritePin(OLED_I2C_PORT, OLED_SDA_PIN, GPIO_PIN_RESET) static void oled_i2c_delay(void) { volatile uint16_t i = 50; while (i--) { __NOP(); } } static void oled_oled_i2c_start(void) { OLED_SDA_H(); OLED_SCL_H(); oled_i2c_delay(); OLED_SDA_L(); oled_i2c_delay(); OLED_SCL_L(); } static void oled_oled_i2c_stop(void) { OLED_SDA_L(); OLED_SCL_H(); oled_i2c_delay(); OLED_SDA_H(); oled_i2c_delay(); OLED_SCL_L(); } static void oled_i2c_write_byte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) { OLED_SDA_H(); } else { OLED_SDA_L(); } dat <<= 1; OLED_SCL_H(); oled_i2c_delay(); OLED_SCL_L(); oled_i2c_delay(); } }这段实现里用宏封装 GPIO 操作,是为了后续如果想换成其他引脚,只需要改宏定义,不用动逻辑层。oled_i2c_delay()用 volatile 局部变量做空循环,编译器不会因为优化把它删掉。延时数值 50 在 72MHz 主频下大约能产生几微秒的延时,对应 I2C 速率在 100kHz 到 200kHz 之间。实际项目里我不建议把延时拆到每一位上单独调,那样代码可读性差,直接调这个统一的延时值就够了。
3.3 SSD1306 初始化序列:命令字与电荷泵
SSD1306 是 OLED 模块里的驱动 IC,它通过命令字控制显示方式,数据字节则直接映射到显存。初始化序列是固定套路,顺序不能乱:先关显示,设置时钟分频和 multiplex 比率,打开电荷泵,设置寻址模式,最后开显示。其中电荷泵必须打开,否则屏幕不会亮,这是新手最容易踩的坑。下面代码里OLED_WrCmd负责发送单个命令字节,发送格式是:起始、写地址 0x78(即 0x3C 左移一位)、写控制字节 0x00(表示后面是命令)、再写命令本身。
#define OLED_ADDR_WRITE 0x78 static void OLED_WrCmd(uint8_t cmd) { oled_oled_i2c_start(); oled_i2c_write_byte(OLED_ADDR_WRITE); oled_i2c_write_byte(0x00); // 控制字节:命令 oled_i2c_write_byte(cmd); oled_oled_i2c_stop(); } static void OLED_Init(void) { HAL_Delay(50); OLED_WrCmd(0xAE); // 关闭显示 OLED_WrCmd(0xD5); OLED_WrCmd(0x80); // 时钟分频 OLED_WrCmd(0xA8); OLED_WrCmd(0x3F); // multiplex 64 OLED_WrCmd(0xD3); OLED_WrCmd(0x00); // 显示偏移 OLED_WrCmd(0x40); // 起始行 0 OLED_WrCmd(0x8D); OLED_WrCmd(0x14); // 打开电荷泵 OLED_WrCmd(0x20); OLED_WrCmd(0x00); // 水平寻址模式 OLED_WrCmd(0xA1); // 段重映射 OLED_WrCmd(0xC8); // COM 扫描方向 OLED_WrCmd(0xDA); OLED_WrCmd(0x12); // COM 引脚配置 OLED_WrCmd(0x81); OLED_WrCmd(0xCF); // 对比度 OLED_WrCmd(0xA4); // 显示内容跟随 RAM OLED_WrCmd(0xA6); // 正常反色模式 OLED_WrCmd(0xAF); // 打开显示 }0x3C是常见的 7 位从机地址,左移一位变成0x78是因为 I2C 传输时最低位表示读写方向,0 为写,1 为读。0x8D, 0x14这两个命令字是电荷泵开关,这是供电电压低于 OLED 内部驱动电压时升压的关键,漏掉它屏幕只有微弱的暗亮。初始化完成后可以用一个简单函数测试屏幕是否正常点亮。
3.4 往显存写数据:全屏刷新与缓存设计
SSD1306 的显存是 128*64 位,分为 8 页,每页 8 像素高、128 像素宽,所以完整缓存是 1024 字节。常见做法是在内存里维护一个uint8_t oled_buffer[1024],所有绘图函数先修改这个缓存,最后统一刷新到屏幕上。这样做的最大好处是避免频繁调用 I2C 写单字节,把多次小传输合并成一次大量传输。全屏刷新时先发一个设置列地址和页地址的命令序列,然后连续写入 1024 字节数据。
static void OLED_SetPos(uint8_t page, uint8_t col) { OLED_WrCmd(0xB0 + page); // 设置页地址 OLED_WrCmd(col & 0x0F); // 列地址低 4 位 OLED_WrCmd(0x10 | (col >> 4)); // 列地址高 4 位 } static void OLED_Refresh(void) { uint8_t i; OLED_SetPos(0, 0); oled_oled_i2c_start(); oled_i2c_write_byte(OLED_ADDR_WRITE); oled_i2c_write_byte(0x40); // 控制字节:数据 for (i = 0; i < 1024; i++) { oled_i2c_write_byte(oled_buffer[i]); } oled_oled_i2c_stop(); }这个函数只发一次起始条件,数据连续写入 1024 字节,中间不加停止条件,因为 SSD1306 在连续写模式下会自动递增列地址和页地址。OLED_SetPos也可以用来定位到指定页和列,比如局部刷新时只更新某个区域。控制字节0x40告诉 SSD1306 后续字节都是数据,和命令控制字节0x00区分开。
3.5 显示字符串:字模与取模方式
字符显示依赖字模,把 ASCII 字符用逐行取模或逐列取模的方式转换成 816 或者 68 的字节数组。常见做法是在工程里放一个oled_font.h,用数组下标对应字符 ASCII 码,每个字符占用 16 字节(8*16 字模)。取模工具有很多,网上能找到,选择逐列取模、低位在前的方式和 SSD1306 的显存布局最匹配。显示字符串时先算好字符总数,逐个把字模写入oled_buffer对应位置。
const uint8_t oled_font_8x16[][16] = { {0x00, 0x00, 0x00, 0x00, 0x00, ...}, // 空格 // 其他字符 }; void OLED_ShowString(uint8_t page, uint8_t col, const char *str) { uint16_t buf_idx; while (*str) { uint8_t char_idx = *str - ' '; // 字模数组从空格开始 for (uint8_t i = 0; i < 16; i++) { if ((*str - ' ') * 16 + i >= 0) { // 计算该字符在缓存中的写入位置 } } ... str++; } }这段代码我只展示了取字模的核心思路,实际写时要注意缓冲区的边界判断,防止字符写到显存末尾时越界覆盖前面的内容。816 字模在 12864 屏幕上最多同时显示 8 行,每行 16 个字符,超出了就要换页。如果只是测试点亮,直接在oled_buffer里填满0xFF再刷新,整屏会变亮,这就是验证驱动是否正常的快速方法。
4. 参数怎么设:模拟 I2C 频率、上拉电阻与 OLED 时序边界
4.1 频率怎么算:从延时循环到 SCL 实际速率
模拟 I2C 的速率不是靠 PCLK 分频得到的,而是由软件延时循环决定的。每次 SCL 高电平持续时间和低电平持续时间相加,再乘 2 才是完整周期。以 72MHz 主频为例,oled_i2c_delay()里volatile uint16_t i = 50的循环大约耗时 3~5 微秒,加上宏翻转 GPIO 的耗时,一个 SCL 周期大约 10 微秒,换算下来就是 100kHz 左右。如果你需要跑 400kHz,把延时值改到 10 以内,但不能改成 0,否则 GPIO 翻转本身的时间会让 SCL 高电平宽度太窄,从机采样容易出错。
// 想提高速率时,把延时值调小 static void oled_i2c_delay(void) { volatile uint16_t i = 8; // 72MHz 下约 1us 左右 while (i--) { __NOP(); } }调参时建议用示波器实测 SCL 引脚波形,看高电平和低电平的宽度,标准 I2C 要求 SCL 高电平最小宽度约 4 微秒(100kHz 模式),高速模式 400kHz 时要求 0.6 微秒。调试时从大往小调,先保证能通信,再追求速率。很多模块上的 SSD1306 内部有时候序检查,SCL 频率太高会偶发丢数据,所以模拟 I2C 一般建议跑在 200kHz 左右,兼顾速度和稳定。
4.2 上拉电阻怎么选:从 10k 换到 4.7k 的调整经验
0.96 寸 OLED 模块的 I2C 引脚通常需要外部上拉电阻,模块出厂时有些已经焊好,有些是预留焊盘。我遇到过一块裸屏,用户直接飞线到 GPIO,没加上拉,结果初始化偶尔成功偶尔失败,波形低电平正常,高电平被拉不到 VDD。用 10k 上拉时信号上升沿偏缓,SCL 高电平在高速下还没稳定就被拉低,从机采样误判。换成 4.7k 上拉后,上升沿明显变陡,通信稳定很多。
| 上拉电阻 | 上升沿特点 | 适用场景 |
|---|---|---|
| 2.2k | 最快,功耗稍高 | 长线、高速 400kHz |
| 4.7k | 均衡,推荐 | 常规 100kHz~200kHz |
| 10k | 慢,低功耗 | 短线、低速,对时序要求不高 |
挑选上拉电阻时还要考虑总线上挂的设备数量,设备多了等效并联电容变大,上拉阻值就需要适当减小。如果模块背板已经自带上拉,外部再加一个会形成并联,阻值变小,但这不会导致损坏,只是上升沿更快,一般不需要特意处理。测量上拉电阻是否合适的方法是看 SCL 高电平是否完整到达 VDD 且维持至少 4 微秒。
4.3 时序边界:起始保持时间、SCL 高电平宽度的注意点
I2C 协议对起始和停止也有时序要求,虽然 SSD1306 的数据手册没有明确标注严格的最小保持时间,但实际调试时我发现:起始条件中 SDA 拉低后,必须等 SCL 拉低这个动作完成,从机才能识别起始条件。有些驱动代码写得太随意,SDA 刚拉低就马上进入数据位发送,SCL 低电平宽度不够,导致从机把第一个数据位误判为起始的一部分。一个稳妥做法是保证每个电平跳变后至少执行一次延时循环。
static void oled_oled_i2c_start(void) { OLED_SDA_H(); OLED_SCL_H(); oled_i2c_delay(); OLED_SDA_L(); // SDA 先拉低 oled_i2c_delay(); // 保持 OLED_SCL_L(); // SCL 再拉低 oled_i2c_delay(); // 保持 }我一般会把 start 和 stop 函数里的延时做成可配置,和字节收发共用一个延时函数。实际踩坑时最典型的错误是删掉 stop 延时,导致连续多次初始化时总线还停在半空状态,后面的起始条件判断不出。这段边界问题在逻辑分析仪上看得最清楚:正常波形里 SDA 和 SCL 的下降沿之间有明显间隔,异常波形里两个沿几乎重叠。
4.4 性能误区:模拟 I2C 速率不等于画面刷新率
很多人以为模拟 I2C 速率慢,OLED 刷新就慢,实际不是一回事。OLED 刷新率的瓶颈在显存写入量和重绘逻辑,不在总线速率。全屏 1024 字节,在 400kHz 下单字节耗时约 22.5 微秒,整屏刷新大约 23 到 30 毫秒,也就是每秒能刷 30 帧以上,这对大部分菜单界面、传感器数值显示已经完全够用。真正影响体验的是绘图函数里频繁调用 I2C 写单字节,比如画一条直线循环 100 次每次都发起始和停止,那就会明显卡顿。
正确的做法是图像绘制全部写进oled_buffer,最后统一OLED_Refresh()。如果需要高帧率动画,可以只刷新变化区域,比如一个实时波形图只更新最新一列,而不是整屏重写。只要理解了「改缓存快、刷屏慢」这个原则,模拟 I2C 的速率完全不会成为性能瓶颈。
5. 避坑:模拟 I2C 驱动 OLED 的 5 个常见问题与排查
5.1 现象:上电后屏幕全黑,初始化时卡死
原因:起始条件或地址发送失败,SSD1306 没收到正确起始条件,从机没有应答。最常见的是 GPIO 配置成了复用功能,或者引脚被其他外设占用,导致宏翻转没生效。
解决:先用最简单的点灯程序测试这两个脚能正常输出高低电平,再检查 CubeMX 里是否误开了 I2C 外设。用示波器量 SCL 和 SDA,看起始条件有没有出现:SCL 高时 SDA 下降沿。如果没有波形,检查代码有没有真正调用OLED_Init()。
5.2 现象:初始化能过,但显示全屏乱码或者重复图案
原因:显存地址推进方式错误,或者字模取模方式与 SSD1306 页地址方向不匹配。常见的是把逐行取模的数组当成逐列取模用,导致字符被拆成碎片。
解决:确认字模工具输出的取模方式是逐列、低位在前,和 SSD1306 的数据位排列一致。全屏填0xFF测试是否整屏全亮,如果亮得均匀,说明寻址正确;如果出现竖条纹或横条纹,就要检查列地址设置和缓存索引page * 128 + col是否一致。
5.3 现象:换了一组 GPIO 后屏幕不亮
原因:宏定义里的引脚和实际接线不一致,或者新引脚没有在 CubeMX 里初始化。很多人把OLED_SCL_PIN改成GPIO_PIN_8后,忘了改端口宏,导致还是操作原来的端口。
解决:核对三处——引脚号宏、端口宏、CubeMX 的 GPIO 配置。建议把所有引脚相关定义集中放在同一个头文件里,换板时只改一处。初始化完成后读取GPIOB->ODR寄存器,确认输出状态和预期一致。
5.4 现象:屏幕能亮,但显示内容偏左或偏右镜像
原因:SSD1306 的段重映射和 COM 扫描方向配置错误,0xA1和0xA0选反,或者0xC8和0xC0选反,导致左右或上下镜像。
解决:初始化序列里确认0xA1是正常的段重映射,0xC8是正常的 COM 扫描方向。如果模块是异形屏或非标准封装,可能需要反着设置,这时直接试验两套组合即可,不用怀疑 I2C 时序。
5.5 现象:模拟 I2C 能稳定工作,硬件 I2C 偶尔失败
原因:F103C8T6 的硬件 I2C 外设对中断优先级和总线复用比较敏感,某些库版本下存在已知的异常行为。这不是玄学,是外设状态机和软件配合的问题。
解决:如果项目已经切到模拟 I2C 且稳定,就不必强行用硬件 I2C。如果必须用硬件,建议开启超时重试和总线释放逻辑,比如连续检测到 BUSY 时,把引脚配置成 GPIO 手动翻转九次时钟,让从机释放总线。最终实践里,单从机 OLED 场景我优先选模拟 I2C,省心。
6. 进阶:局部刷新与双缓冲,把模拟 I2C 的帧率用到极致
模拟 I2C 驱动 SSD1306 想要提升显示帧率,核心思路只有一个:不要全屏刷。SSD1306 支持水平寻址模式和页寻址模式,通过命令0x21设置列地址范围、0x22设置页地址范围,就能只更新屏幕上某一个矩形区域。比如实时温度显示只需要更新数字所在的两个字符区域,大约 16 列 2 页,只写几十字节,比全屏写 1024 字节快一个数量级。
static void OLED_RefreshArea(uint8_t page_start, uint8_t page_end, uint8_t col_start, uint8_t col_end) { uint8_t p, c; OLED_WrCmd(0x21); OLED_WrCmd(col_start); OLED_WrCmd(col_end); OLED_WrCmd(0x22); OLED_WrCmd(page_start); OLED_WrCmd(page_end); for (p = page_start; p <= page_end; p++) { for (c = col_start; c <= col_end; c++) { OLED_WrCmd(0xB0 + p); OLED_WrCmd(c & 0x0F); OLED_WrCmd(0x10 | (c >> 4)); oled_oled_i2c_start(); oled_i2c_write_byte(OLED_ADDR_WRITE); oled_i2c_write_byte(0x40); oled_i2c_write_byte(oled_buffer[p * 128 + c]); oled_oled_i2c_stop(); } } }这段代码演示的是页寻址模式的局部刷新:每次先设置页和列,再写一个数据字节。实际项目里可以做一层脏区标记,绘图时把改动的页记录下来,刷新时只遍历脏页,这样动画的帧率能提高三四倍。另一个建议是学会用OLED_RefreshArea做滚动数字时钟这类界面,秒位变化只刷右下角两个字符区域,拨动感非常流畅。
我自己的使用习惯是始终保留一份oled_buffer作为唯一真源,任何绘图函数只改缓存,刷新函数只管按区域搬运缓存到屏幕,不用在刷新时重复计算字模位置。遇到难调的显示偏移问题时,优先怀疑列地址计算,而不是 I2C 时序——时序出错通常是完全不亮或花屏,而不是轻微偏移。这些经验是从好几个联调项目里踩出来的,希望帮到你。
本文还有配套的精品资源,点击获取