简介:面向STM32学习者的IIC接口OLED显示例程,基于标准外设库实现,演示如何通过IIC总线驱动OLED屏幕完成字符与图形显示,适合入门、课程设计及显示模块快速选型参考。程序工程完整,共130个文件,以32个C源文件与35个头文件为主体,覆盖OLED驱动、STM32外设初始化及主程序逻辑;另有Keil工程配置文件(uvprojx、uvoptx)、可直接烧录的hex与调试用的axf、map、lst等编译输出,压缩包整体仅383KB,结构紧凑易检索。目前已有2040人学习下载。借助该工程,读者可以直观理解IIC时序、OLED写命令/写数据流程,以及自发光、高对比度、宽视角等特点如何映射为代码操作;同时可参照标准库工程学习GPIO、时钟、IIC外设的初始化方式,积累模块化程序组织经验。结合描述中OLED技术背景,还能顺带了解OLED与LCD差异,为后续移植到其他主控或扩展菜单、传感器显示提供扎实基础。
1. STM32 用 IIC 驱动 OLED:显示刷新慢但引脚最省的方案
0.96 寸、128×64 像素、四个引脚(VCC、GND、SCL、SDA),这是 OLED 屏在 IIC 模式下最典型的形态。很多第一次接触这块屏的人都会想:两根线就能刷出一屏字,是不是很轻松?实际移植一遍就会发现,地址偏移、上电时序、初始化序列这三个地方,任何一个错一个字节,结果就是白屏或满屏雪花。这篇笔记基于一份可直接下载运行的 STM32 IIC OLED 驱动工程,从 IIC 物理层讲到 HAL 库驱动移植,再讲到字符、汉字、图形显示,最后把白屏、花屏、IIC 卡死这些高频问题按现场逐一排查。适合正在调屏的嵌入式初学者,也适合想快速把 OLED 驱动移植进现有项目的熟手。
2. IIC 总线与 OLED 硬件选型:上拉电阻、驱动芯片和 128×64 的边界
2.1 IIC 协议要点:两线寻址、应答机制与速度选择
IIC 是半双工同步串行总线,SCL 提供时钟,SDA 负责数据,一次完整传输包含起始条件、7 位从机地址、读写位、数据字节和应答位。STM32 作为主机时,只需要把地址和缓冲区交给 HAL 库的HAL_I2C_Master_Transmit,硬件会自己处理起始、停止和应答,不需要手动翻转时钟线。
速度上,标准模式 100kHz,快速模式 400kHz。对 OLED 这种 1024 字节显存的小屏,100kHz 也能跑,但全屏刷新大概要 90ms 出头;改成 400kHz 之后,同样刷一屏大约 23ms,肉眼感觉就是“不闪烁”和“轻微闪烁”的区别。所以配置 I2C 外设时优先选快速模式,代价只是 SCL 上升沿更陡,对布线的要求稍微高一点。
还有一个容易忽略的点:IIC 是漏极开路,SCL 和 SDA 必须有上拉电阻才能输出高电平。内部上拉一般在 40kΩ 左右,驱动能力偏弱,外部再并联 4.7kΩ 到 10kΩ 是常见做法。这个电阻值不是随便选的,它决定上升沿时间和总线电流,是后面很多奇怪问题的根源。
2.2 驱动芯片差异:SSD1306 与 SH1106 不能共用初始化序列
市面上的 0.96 寸 OLED 模块绝大多数用 SSD1306,1.3 寸模块有些用 SH1106。两者外观接近,但内部 GRAM 大小不一样:SSD1306 是 128×64,SH1106 是 132×64,只是实际显示区仍只取其中 128 列。
| 项目 | SSD1306 | SH1106 |
|---|---|---|
| 内部 GRAM | 128×64(真正 128 列) | 132×64(显示起始列有偏移) |
| 常见尺寸 | 0.96 寸 | 1.3 寸 |
| 列地址设置 | 0x00 低列 / 0x10 高列 | 0x02 起偏移 2 列 |
| 初始化序列 | 0xA8 后跟 0x3F | 部分寄存器值不同 |
把 SSD1306 的初始化序列直接烧给 SH1106,屏幕大概率能亮,但画面会整体向右偏 2 个像素,或者最右侧出现竖条噪声。识别方法很简单:看模块背面丝印,SH1106 字样通常直接印在 PCB 或者驱动芯片封装上。一般建议拿到模块先确认驱动型号,再选择对应的初始化序列,省得后面反复怀疑代码逻辑有问题。
2.3 接线与上拉:3.3V 电平、屏线长度和外部上拉叠加
OLED 的 IIC 是从机,供电电压通常 3.3V,逻辑电平也是 3.3V。如果直接接到 5V 的单片机引脚上,高电平会超过芯片耐压,长期使用可能损坏驱动芯片。STM32 大部分型号 I/O 是 5V 容忍,但 OLED 模块不一定,所以最简单粗暴的原则是:VCC 和逻辑引脚都接 3.3V,两边同电源共地。
屏的排线长度也要控制。IIC 在 400kHz 下,排线超过 20cm 之后,信号反射和线上电容会让 SCL 上升沿变缓,出现在调试器里看起来一切正常、但偶尔第一帧显示不正确的情况。这个时候不是代码的错,是物理层的锅。解决办法是降速到 100kHz,或者换更短的杜邦线。
关于上拉电阻,很多模块上已经焊了板载 2.2kΩ 上拉。如果你在 MCU 侧又接了 4.7kΩ 外部上拉,并联后等效电阻约 1.5kΩ,总线电流变大,虽然一般不会立刻烧坏,但会让低电平最低电压值升高,可能导致从机无法正确识别。判断方法:用万用表测 SDA 对地电阻,如果已经有几十千欧以下的阻值,就不再外加上拉。
3. HAL 库 IIC 驱动移植:初始化序列、命令字节与显存缓存
3.1 CubeMX 配置 I2C1:从引脚分配到时钟频率
工程使用 STM32CubeMX 生成,I2C1 配置为快速模式,引脚一般默认 PB6/PB7,具体看你使用的芯片型号。以下是初始化函数:
I2C_HandleTypeDef hi2c1; void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 400000; // I2C总线时钟 400kHz hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; // 快速模式占空比 2:1 hi2c1.Init.OwnAddress1 = 0; // 主机模式,本机地址不参与寻址 hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } }ClockSpeed决定总线速率,400000 对应快速模式,如果能稳定运行就不动它;如果出现随机错误,第一件事是改成 100000 验证物理链路。OwnAddress1是主机自己的地址,OLED 从机不需要用到,填 0 即可。NoStretchMode保持关闭,这样从机拉低 SCL 请求等待时,主机能硬件感知并等待,在 OLED 内部忙的时候很关键。
3.2 从机地址 0x78 的由来和控制字节:命令与数据怎么区分
SSD1306 的 IIC 从机地址由模块上的地址选择引脚决定,常见默认值是 0x3C(7 位地址)。IIC 传输时最低位是读写标志,所以写操作地址为 0x3C << 1 = 0x78。这也是 OLED 驱动代码里出现 0x78 而不是 0x3C 的原因。
每个数据帧的第一个字节叫控制字节,用来告诉 SSD1306 后续字节是命令还是数据:0x00 表示命令,0x40 表示数据。这个机制是 IIC OLED 特有的,SPI 模式则靠 D/C 引脚区分,所以 IIC 驱动里写命令和写数据是两个独立函数:
#define OLED_ADDR 0x78 // 7位地址 0x3C 左移一位,加上写标志后固定为 0x78 #define OLED_CMD 0x00 // 控制字节:命令 #define OLED_DATA 0x40 // 控制字节:数据 void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {OLED_CMD, cmd}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 50); } void OLED_WriteData(uint8_t dat) { uint8_t buf[2] = {OLED_DATA, dat}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 50); }两个函数都把控制字节和有效数据拼成一个 2 字节数组,再一次性发出去。这样做的原因是 IIC 每帧可以携带多个字节,而 SSD1306 只认每帧第一个字节是控制字节,后续字节全部按控制字节类型处理。HAL_I2C_Master_Transmit最后一个参数是超时时间,单位毫秒,给 50ms 已经足够,IIC 一帧最多几毫秒就结束了。
3.3 OLED 初始化序列:每一行寄存器到底在干什么
初始化序列是移植过程中最容易出岔子的地方,少了任何一行都可能白屏或者显示异常。下面是工程中使用的完整序列:
void OLED_Init(void) { HAL_Delay(100); // 等待电源稳定,防止上电瞬间初始化失败 OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0x20); OLED_WriteCmd(0x02); // 页寻址模式,后续翻页只需 0xB0~0xB7 OLED_WriteCmd(0x40); // 显示起始行 0 OLED_WriteCmd(0xA1); // 段重映射:常规方向,不镜像 OLED_WriteCmd(0xC8); // COM 扫描方向:从上到下 OLED_WriteCmd(0xA6); // 正常显示,非反色 OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); // 复用比 64 行,对应 128×64 屏 OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); // 显示偏移 0 OLED_WriteCmd(0xD5); OLED_WriteCmd(0x80); // 时钟分频,默认值即可 OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); // 预充电周期,偏大一点显示更均匀 OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); // COM 引脚硬件配置 OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 开启电荷泵,必须打开否则白屏 OLED_WriteCmd(0x81); OLED_WriteCmd(0x7F); // 对比度:0x00 最暗,0xFF 最亮 OLED_WriteCmd(0xDB); OLED_WriteCmd(0x40); // VCOMH 电压 OLED_WriteCmd(0xAF); // 打开显示 }前面 100ms 延时是很多人会跳过的:模块刚上电时内部电容还没充满,驱动芯片处于不稳定状态,此时立刻发命令大概率无效,表现出来就是白屏。如果把延时去掉,偶尔能亮、偶尔不能亮,这个现象特别像玄学,实际上就是上电时序问题。0x8D 0x14是电荷泵开关,SSD1306 内部升压电路不开的话,屏幕完全没显示,这一行最容易被精简后误删。
0x81对比度直接影响显示亮度,0x7F 在多数环境下清晰又不刺眼。如果模块显示偏淡,可以往上调到 0xCF 甚至 0xFF,但注意太高时长时间显示固定内容会加速 OLED 烧屏,尤其是室内调试经常一屏静止几小时的项目。
3.4 显存缓存与坐标抽象:1024 字节的 GRAM 操作方式
SSD1306 在页寻址模式下,GRAM 分成 8 页,每页高度 8 像素,宽度 128 像素。驱动代码在 MCU 侧维护一个完全相同大小的缓存数组,所有绘图先写缓存,刷新时再整体推到屏幕:
uint8_t OLED_GRAM[8][128]; // 页 × 列,共 1024 字节 void OLED_Clear(void) { memset(OLED_GRAM, 0, sizeof(OLED_GRAM)); } void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t on) { if (x >= 128 || y >= 64) return; // 越界直接丢弃 uint8_t page = y >> 3; // 行号除以 8,得到所在页 uint8_t bit = y & 0x07; // 行号对 8 取余,得到页内位 if (on) { OLED_GRAM[page][x] |= (1 << bit); } else { OLED_GRAM[page][x] &= ~(1 << bit); } }缓存数组把显示坐标拆成页和列:第 0 页对应屏幕最上方 8 行,第 7 页对应最下方 8 行。OLED_SetPixel把像素写入对应页的对应字节,再用位运算修改该像素所在位。凡是涉及绘图、画线、显示字符,最后都落到这个函数上,坐标转换逻辑就统一了。
刷新函数负责把缓存推送到 SSD1306:
void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); // 设置页地址 OLED_WriteCmd(0x00); // 列地址低 4 位 OLED_WriteCmd(0x10); // 列地址高 4 位 for (uint8_t col = 0; col < 128; col++) { OLED_WriteData(OLED_GRAM[page][col]); } } }每页都要先写页地址和列地址,然后连续写入 128 个字节。SSD1306 在页寻址模式下写入数据后列指针自动加一,不需要每列重新设置地址。整个刷新过程是 8 页 × 128 字节,共 1024 字节,这也是 IIC 模式下全屏刷新的固定开销。
4. 字符、汉字与图形显示:ASCII 字库、取模工具和分页坐标换算
4.1 ASCII 8×16 字库:查表而不是逐像素计算
显示字符最直接的方法是准备好字库表,工程中内置了 95 个可打印 ASCII 字符,每个字符宽度 8 像素、高度 16 像素,存放为 16 个字节。前 8 字节表示字符上半部分的 8 列,后 8 字节表示下半部分:
const unsigned char ASCII8x16[95][16] = { // 0x20 空格 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // 0x21 感叹号 {0x00,0x00,0x18,0x3C,0x3C,0x18,0x18,0x00, 0x18,0x18,0x00,0x00,0x00,0x00,0x00,0x00}, // 其他字符按 ASCII 码顺序依次排列 }; void OLED_ShowChar(uint8_t x, uint8_t page, uint8_t ch) { if (ch < 0x20 || ch > 0x7E) return; // 只接受可打印字符 uint8_t idx = ch - 0x20; // 索引偏移 for (uint8_t i = 0; i < 8; i++) { OLED_GRAM[page][x + i] = ASCII8x16[idx][i]; // 上半行写入当前页 OLED_GRAM[page + 1][x + i] = ASCII8x16[idx][i + 8]; // 下半行写入下一页 } }page参数要求字符起始位置对齐到 8 的倍数,也就是页边界。因为 16 像素高的字符必然占用两页,页地址不齐的话,上半行会出现在一个页的中间位置,显示结果就是上下两半错位。实际项目中把字符串显示的位置限制在 y=0、y=8、y=16 这些整行坐标,问题就杜绝了。
4.2 汉字 16×16 点阵:取模方向决定显示是否正常
中文显示比 ASCII 多一步取模。16×16 的汉字需要 32 个字节,常见取模格式是逐列取模:前 16 字节对应上半页 16 列,后 16 字节对应下半页 16 列。下图示例是“温”“度”两个字的结构:
const unsigned char Font16x16[][32] = { // “温”字的 32 字节点阵数据,由取模软件生成 {0x00,0x00,0x7F,0x08,0x08,0x08,0x7F,0x00, 0x04,0x07,0x04,0x04,0xFF,0x04,0x04,0x00, 0x00,0x00,0x3F,0x20,0x20,0x20,0x3F,0x00, 0x04,0x04,0x04,0x04,0xFF,0x04,0x04,0x00}, // 更多汉字依次排列 }; void OLED_ShowChinese(uint8_t x, uint8_t page, uint8_t index) { for (uint8_t i = 0; i < 16; i++) { // 前 16 字节写上半页,后 16 字节写下半页 OLED_GRAM[page][x + i] = Font16x16[index][i]; OLED_GRAM[page + 1][x + i] = Font16x16[index][i + 16]; } }取模设置里有一个关键选项:取模方式是“逐列”还是“逐行”。逐列取模得到的字节,每一位对应纵向的像素,正好能直接写入 OLED 的页;逐行取模得到的字节,每一位对应横向像素,直接写入 OLED 就会得到竖条状乱码。常用的取模软件里,需要选择“纵向取模”“逆时针 90 度”或者“列行式”这类选项,不同软件叫法不一样,但结果应该是同一个数据格式。
4.3 字符串与数字显示:封装好的调用入口
单个字符比较简单,实际项目里要显示的是“温度:25.3°C”这类整行内容。把字符函数再包一层,就成了字符串显示函数:
void OLED_ShowString(uint8_t x, uint8_t page, const char *str) { while (*str != '\0') { OLED_ShowChar(x, page, *str++); x += 8; // 下一个字符横向偏移 8 像素 if (x > 120) // 超出屏幕宽度则换到下一行 { x = 0; page += 2; // 一个字符高两页,所以行间隔是 2 } } }数字显示我一般直接用snprintf转成字符串再调上面的函数,避免自己写除 10 取余的循环。注意嵌入式工程里要启用微库或者开启浮点 printf 支持,否则%f格式化不出来。整型数字没有这个问题,所以传感器上报的整数用snprintf(buf, sizeof(buf), "%ld", value)就够了。
4.4 简单图形绘制:画线、矩形和进度条
绘图基础是OLED_SetPixel。画线用线性插值,在两端点之间按步数逐点落笔:
void OLED_DrawLine(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2) { int16_t dx = x2 - x1; int16_t dy = y2 - y1; int16_t steps = (dx > dy) ? dx : dy; if (steps < 0) steps = -steps; steps = steps + 1; // 包含两端点 for (int16_t i = 0; i <= steps; i++) { uint8_t x = x1 + (int32_t)dx * i / steps; uint8_t y = y1 + (int32_t)dy * i / steps; OLED_SetPixel(x, y, 1); } }这个实现没有用经典的 Bresenham 算法,而是按最大轴步数做插值。处理斜率接近水平的线时精度足够,代码也短,适合不会频繁调用画线接口的场景。如果要做仪表盘刻度这种每帧几十条线的画面,再换成 Bresenham 也不迟。
进度条是仪表类界面的常用元素:
void OLED_DrawProgressBar(uint8_t x, uint8_t page, uint8_t width, uint8_t percent) { uint8_t fill = width * percent / 100; for (uint8_t i = 0; i < width; i++) { OLED_SetPixel(x + i, page * 8, 1); // 上边框 OLED_SetPixel(x + i, page * 8 + 7, 1); // 下边框 } for (uint8_t col = 0; col < fill; col++) { for (uint8_t row = 1; row < 7; row++) OLED_SetPixel(x + col, page * 8 + row, 1); // 内部填充 } }percent在 0 到 100 之间,fill是对应宽度的填充列数。边框和内部填充都落在同一页的两条边界行上,刷新后显示效果就是一条扁平的进度条。边界判断已经在OLED_SetPixel里做了,所以传入越界坐标不会造成数组越界。
5. OLED 显示避坑手册:白屏、花屏、IIC 卡死与亮度异常排查
5.1 白屏:上电时序和电荷泵未开启
现象:程序运行后屏幕全白,或者初始化后什么都显示不出来。
原因分两类。一是上电后立刻初始化,SSD1306 的电源还没稳定,命令被当作无效数据丢弃。模块电源从零开始充满内部电容需要几十毫秒,初始化前的延时不够,第一批命令很容易丢。二是 0x8D 0x14 这两条电荷泵命令缺失,内部升压电路没开,OLED 面板没有驱动电压,屏幕完全没有内容。
解决:初始化函数最前面加HAL_Delay(100),确保延时不少于 100ms;检查初始化序列里必须有0x8D、0x14、0xAF三段。还有一个细节:如果模块上有 RES 引脚,需要拉高至少 100ms 再发命令,复位引脚悬空会导致芯片工作状态不确定。
5.2 花屏:GRAM 未清零或页地址越界
现象:屏幕局部显示乱码,或者字符显示一半正常一半错位。
原因多数是缓存数组没有初始化。MCU 上电后 SRAM 内容随机,如果第一次刷新就把这个随机值推到屏幕,自然就是雪花点。另一个原因是页地址循环从 0 写到 7 之后,又写了第 8 页,而 SSD1306 只有 8 页,超过部分的行为未定义。
解决:初始化调用OLED_Clear()把OLED_GRAM全部清零;刷新函数里页循环变量用uint8_t page,条件写page < 8,不要写成<= 8。如果显示字符串时出现上下错位,检查OLED_ShowString里换行时page += 2后有没有超过 6,超过 6 表示已经写到屏幕外了。
5.3 亮度偏暗或闪烁:对比度寄存器与刷新方式不当
现象:屏幕能显示但整体发暗,或者刷新时高频闪烁。
原因:对比度寄存器 0x81 的值设得太低,电荷泵本身输出电压正常但液晶驱动能力不足。闪烁则是因为每次更新都先擦除整屏再重画,中间有个全黑的过程,人眼就感知为刷屏闪烁。
解决:把 0x81 后的参数调到 0x7F 以上,0xCF 是多数人常用的亮度值。闪烁问题要从显示策略入手:显示变化的内容时用页刷新而不是全屏刷新,先改缓存再一次性调用OLED_Refresh,不要在清屏之后做多次部分刷新。特别注意的是刷屏闪烁往往不是硬件问题,而是软件把清屏和重画分开了两个步骤执行。
5.4 IIC 卡死:超时设置与 NACK 处理
现象:程序运行到HAL_I2C_Master_Transmit之后不再往下走,或者运行一段时间后 OLED 无响应,重新上电才恢复。
原因:IIC 总线上有设备拉低 SDA 导致无法产生起始条件,或者从机故障不发应答,HAL 库默认等待应答会一直等到超时。OLED 排线接触不良时也会出现总线电平被拉死的情况,这在接插式模块上很常见。
解决:把超时参数从代码里的 50ms 适当加大到 200ms,至少不会让系统因为一个瞬时错误就停在任务里。更稳妥的做法是检测HAL_I2C_Master_Transmit的返回值,返回HAL_ERROR时调用HAL_I2C_DeInit重新初始化 I2C 外设,相当于软复位总线。如果是运行几分钟后随机卡死,优先检查排线插头和引脚接触,这个问题我用杜邦线调了三天才确认是接触问题。
5.5 模块型号识别错误导致显示偏移
现象:屏幕能显示,但内容整体偏右,最左边空出两列,或者右侧多出噪声。
原因:驱动芯片实际是 SH1106,却用了 SSD1306 的初始化序列。SH1106 内部 GRAM 有 132 列,显示区前两列偏移,需要从第 2 列开始映射数据。
解决:查看模块背面丝印,确认驱动型号。SH1106 的初始化序列里,列地址设置要在高 4 位部分加上偏移,很多维护成熟的 OLED 库内部已经做了区分。在这份工程里,直接把底层的列地址命令改为0x02开始的顺序,即可适配 SH1106 模块。
5.6 个别模块怎么调都不亮:检查地址引脚和供电能力
现象:同一份代码,换一个模块就白屏,换回原来的模块又能显示。
原因:模块的地址选择引脚被焊到了另一侧,从机地址从 0x3C 变成了 0x3D,对应 IIC 发送地址变成 0x7A。还有一种情况是模块要求的供电电流超过开发板 3.3V 输出的能力,满亮度时电压被拉低,芯片工作不稳定。
解决:先测模块 IIC 引脚对地逻辑电平,再用示波器看 SDA 上有没有正常波形。确认地址位后,把OLED_ADDR从 0x78 改成 0x7A 再测试。供电方面,如果 3.3V 端压降超过 100mV 并且模块发热明显,建议用单独的稳压芯片供电,不要再从 MCU 的 3.3V 引脚取电。
6. 进阶优化:单页刷新和 DMA 传输让 IIC OLED 的刷新更快
IIC OLED 给人的固有印象就是慢,但实际上把刷新策略做对,显示动态数据完全够用。先算一笔账:400kHz 总线速率下,IIC 传输一个字节需要 9 位时钟(8 位数据加 1 位应答),一字节约 22.5 微秒。全屏 1024 字节,算上每页的页地址命令开销,大约 2.4 万微秒,也就是 23ms 左右,对应每秒 43 帧。这个帧率对于显示传感器数值、状态切换、菜单界面已经足够了,真正的瓶颈往往不在于总线速度,而在于每次刷新把整个屏幕的数据都重新推了一遍。
优化思路是把“全屏刷新”拆成“单页刷新”。页面寻址模式下,一次只更新需要变化的页,而不是每帧都刷 8 页。比如显示温度数值变化时,数字所在的页通常只有 1 到 2 页,就只刷这两页,传输量从 1024 字节降到 256 字节左右,实际耗时约 5ms。实现起来很简单:
void OLED_RefreshPage(uint8_t page) { OLED_WriteCmd(0xB0 + page); // 页地址,0xB0~0xB7 OLED_WriteCmd(0x00); // 列低地址 OLED_WriteCmd(0x10); // 列高地址 for (uint8_t col = 0; col < 128; col++) { OLED_WriteData(OLED_GRAM[page][col]); } }调用OLED_RefreshPage(3)就只刷新第 3 页覆盖的 8 行区域。配合显示逻辑,比如第 0 到 5 页是固定标题,第 6 到 7 页是动态数字,那么平时只刷 6、7 两页,标题完全不用动。这里有一个习惯:凡是把数据写入OLED_GRAM的接口,顺便记录哪些页被修改过,刷新时只对修改过的页调用OLED_RefreshPage。
再进一步,用 DMA 把刷屏从主循环里解放出来。普通HAL_I2C_Master_Transmit是阻塞调用,1024 字节刷屏期间 CPU 一直在等待,虽然 23ms 不算长,但如果在中断频繁的系统里,很容易影响实时任务。DMA 传输方式把数据交给硬件搬运,CPU 只需在完成中断里做收尾:
uint8_t refresh_buf[130]; // 1 字节控制字节 + 128 字节数据 void OLED_RefreshPageDMA(uint8_t page) { refresh_buf[0] = OLED_DATA; // 控制字节,后面都是显示数据 memcpy(&refresh_buf[1], &OLED_GRAM[page][0], 128); OLED_WriteCmd(0xB0 + page); OLED_WriteCmd(0x00); OLED_WriteCmd(0x10); HAL_I2C_Master_Transmit_DMA(&hi2c1, OLED_ADDR, refresh_buf, 130); }这里假设同页内 128 列数据是连续的,所以直接用memcpy一次拷入发送缓冲区。DMA 完成后,HAL 库会调用发送完成回调,在回调里置一个标志位,下一次刷新前检查上一个传输是否结束。要注意的是,I2C 的 DMA 传输虽然不占 CPU,但总线上依然是逐字节搬运,不会比阻塞方式更快,它的价值在于 CPU 可以在等待期间处理其他任务。
实际项目里我习惯把刷新函数放到低优先级任务里,不追求每帧都刷新,而是用一个 20ms 左右的软件定时器触发刷新。经过这几步调整之后,同样的硬件和总线速度,动态数字的响应速度从肉眼可见的迟滞变成基本无感。从那以后我每次移植 IIC OLED 驱动,都会强制走一遍这套流程:确认驱动芯片型号、检查初始化序列、先做单页刷新再考虑 DMA。这套组合下来,我基本没再遇到过 IIC OLED 的翻车问题,希望也能帮你少踩一些坑。
本文还有配套的精品资源,点击获取