简介:面向STM32嵌入式入门与显示驱动实践的0.96寸OLED IIC工程包,围绕STM32F407通过IIC总线驱动128×64 OLED展开,适合已掌握C语言和基础外设、希望打通硬件连接、SSD1306/SH1106指令集、显存寻址与显示刷新流程的学习者,也可用于课程设计和小型仪表界面验证。资源以可直接编译的Keil工程为主体,兼顾源码阅读与上板调试。压缩包共35个文件,约2.19MB,包含C源码与头文件、工程配置、HEX固件、编译中间文件,以及原理图、尺寸图、连接说明等PDF和文本资料,便于对照硬件接线与软件结构。已有534人学习下载。读者可参考其中的IIC初始化、OLED初始化序列、字符与图形显示、字模组织及主循环刷新思路,结合HEX快速上屏验证,并借原理图排查引脚、上拉电阻和设备地址设置问题;对理解从底层通信到界面显示的完整链路具有直接参考价值。
1. 调参调到眼瞎,才想起来给它加块屏
前阵子在调一个采样滤波的参数,串口助手满屏滚数字,115200 的波特率下找一个跳变的 ADC 值,盯了十几分钟眼睛都花了。后来往板子的 IIC 口上挂了块 0.96 寸 OLED,四行字把瞬时值、均值、阈值、状态码摆在那儿,头一抬就看见了,问题五分钟就定位了。这个 stm32_iic_096oled.zip 干的就是这件事:一套能直接编译的工程,软件模拟 IIC 驱动 SSD1306 屏,带字库、带显存、带字符和数字显示接口,改几个宏就能接到自己的板子上。适合刚上手 STM32 外设、想给自制小板子加个本地屏的开发者,也适合想把 IIC 时序彻底搞明白的人。下面按能跑、能看懂、能改的顺序拆。
2. 项目定位与选型:为什么 0.96 寸 OLED 基本都是 IIC 口
2.1 SSD1306 这颗驱动芯片决定了全部玩法
先把一件事说清楚:屏和驱动芯片是两回事。市面上 0.96 寸、128×64 点阵的 OLED 模块,绝大多数里面贴的是 SSD1306 这颗控制器,屏只是它的输出面板。你写的所有代码,本质上是跟 SSD1306 打交道,而不是跟"OLED"打交道。这个 zip 包里的驱动,就是一套面向 SSD1306 的命令封装。
SSD1306 有几个特性直接决定了代码结构。第一,它内部有一块 128×64 bit 的显存(GDDRAM),总共 1024 字节,你写进去的每个 bit 对应屏上一个像素,写 1 亮、写 0 灭。第二,它自己不带字库,屏幕上显示什么字,完全靠 MCU 把点阵数据算好塞进去。第三,128×64 的显存不是按行组织的,而是切成 8 个 page,每个 page 8 行、128 列,一列一个字节,字节的低位(bit0)对应这个 page 里最上面那一行。这个组织方式跟很多人脑子里的"横着一行一行画"完全不一样,是后面所有显示函数的基础。
还有一点容易忽略:模块的尺寸和分辨率不是一回事。0.96 寸通常是 128×64,1/64 duty;0.91 寸一般是 128×32,1/32 duty,两者的初始化序列里复用率(0xA8)和 COM 引脚配置(0xDA)参数不同。如果你拿 128×64 的初始化代码去点 128×32 的屏,会出现只亮一半或者整屏错位。这个工程默认按 128×64 走,换屏的时候这两条命令必须跟着改。
模块上的 IIC 从机地址,7 位值是 0x3C,左移一位加上读写位,写地址就是 0x78。有些模块背面有地址选择电阻(把 0x3C 改成 0x3D),也有的模块出厂就是 0x78/0x7A 两种。如果你确认接线没问题、上拉也有、代码也没错,就是不应答,第一件事是拿逻辑分析仪或者一个简单的地址扫描程序,把 0x08 到 0x77 全扫一遍,看看它到底应的是哪个。
2.2 硬件 IIC 和软件模拟 IIC 的取舍
这个工程采用的是软件模拟 IIC(GPIO 翻转时序),不是硬件外设。这个选择不是偷懒,是这个场景下的常见做法,原因有三个。
第一是可移植性。软件 IIC 只依赖两个普通 GPIO 和一个小延时函数,从 F1 换到 F4、从标准库换到 HAL,改动量就是改几个宏。硬件 IIC 要配时钟、配复用、配中断或 DMA,换一个型号就得重来一遍。对于一块屏这种低速外设,值不值得绑定到特定外设上,答案通常是不值得。
第二是可控性。软件 IIC 的每一段时序都是你自己翻出来的,出问题的时候可以单步、可以在起始信号后打个断点看波形,是个透明的白匣子。硬件 IIC 一旦碰上总线挂死,尤其是从机在传输中途被复位或者被干扰拉住了 SDA,恢复过程要在代码里手动发时钟脉冲把它"顶"出来,这一步对新手来说相当不友好。行业里对早期某些型号的硬件 IIC 外设一直有"玄学"的说法,根源大多在这里——不是外设坏了,是异常恢复没做。
代价也很明确:占 CPU、速率上不去。软件 IIC 在 72MHz 的主频下,靠几个 NOP 做延时,稳定跑 100kHz 到 400kHz 是没问题的。刷一整屏 1024 字节,按 400kHz 算理论上是 20ms 出头,实际加上函数开销在 30ms 上下。对于一秒刷几次的仪表显示完全够用,但如果你想做动画或者滚屏,就得考虑改成硬件 IIC + DMA,这个放在最后一章讲。
如果你的板子上硬件 IIC 引脚正好空着,而且你追求刷屏帧率,那完全可以把这个工程的 IIC 底层换掉,上层 OLED_WriteCmd / OLED_WriteData 的接口保持不变,改造成本很低。接口分层的价值就体现在这儿。
2.3 接线、上拉电阻与从机地址
接线只有四根:VCC、GND、SCL、SDA。供电注意一点,这类模块标称 3.3V,但很多模块板上自带了 LDO 和电平转换,5V 也能点。如果你的模块是裸屏直接引出(没有电平转换芯片),那必须 3.3V 供电,而且 MCU 的 IO 也必须是 3.3V 电平,否则长期跑会伤屏。
上拉电阻是最容易被忽略的一环。IIC 是开漏总线,靠上拉把线拉高。大部分模块板上已经焊了 4.7k 或 10k 的上拉,你直接接过去就行;但如果你是把裸屏飞线接到核心板上,或者杜邦线拉得很长,就一定要自己补两个 4.7k 上拉到 3.3V。上拉缺失的典型现象是:单步调试能过,全速跑就随机不应答,或者干脆一个字节都发不出去。杜邦线长度也别超过 20cm,长了之后线容变大,上升沿变缓,400kHz 就容易翻车。血泪经验是:先用最短的线把屏点亮,确认代码没问题,再去折腾长线和机箱布线。
软件 IIC 的引脚选择上,这个工程默认用的是 PB6/PB7,这两个脚在很多板子上正好是硬件 IIC1 的引脚,好处是将来想切硬件外设时不用改线。你也可以改成任意两个普通 IO,只要不是 JTAG 占用的那几个(PA13/PA14/PA15/PB3/PB4),改完记得同步改宏定义。
3. 工程结构拆解:从 main.c 到 IIC 底层的四层调用链
3.1 目录树与每个文件的职责
解压之后是一个标准的 Keil 工程结构,按职责分文件,没有把所有东西堆进 main.c,这一点对二次开发很友好。整体大致是这样组织的:
stm32_iic_096oled/ ├── CORE/ # 内核启动文件、core_cm3 等,通常不用动 ├── HARDWARE/ │ ├── OLED/ │ │ ├── oled.c # OLED 应用层:初始化、清屏、刷屏、显示字符/数字/字符串 │ │ ├── oled.h # 对外接口声明、显存尺寸宏 │ │ ├── oledfont.h # 6x8 与 8x16 ASCII 点阵字库 │ │ └── bmp.h # 图片/图标点阵(可选) │ └── IIC/ │ ├── myiic.c # 软件 IIC 时序层:起始、停止、收发字节、等待应答 │ └── myiic.h # 引脚宏、方向切换宏、函数声明 ├── SYSTEM/ # delay、usart、sys 等基础模块 ├── USER/ │ ├── main.c # 应用入口,演示怎么调用 │ └── stm32f10x_it.c # 中断服务 └── STM32F10x_FWLib/ # 标准外设库这个划分的关键在于 oled.c 和 myiic.c 是解耦的。oled.c 里只调用 IIC_Start、IIC_SendByte、IIC_WaitAck、IIC_Stop 这几个函数,它根本不知道底下是软件翻转还是硬件外设。你想换驱动方式,只动 myiic.c;你想加个新字体,只动 oledfont.h。这种分层看着朴素,但改起来省事。
如果你用的是 HAL 库工程,映射关系是一样的:myiic.c 换成基于 HAL_GPIO_WritePin 的实现,oled.c 一行都不用改。真正要小心的只有方向切换那部分,下面会讲。
3.2 四层调用链:一个字符是怎么被送到屏幕上的
把调用链从上到下捋一遍,后面看代码就不会迷路。
最上面是应用层,也就是 main.c。你在里面写OLED_ShowString(0, 0, "ADC:")、OLED_ShowNum(30, 0, value, 4),关心的是"在第几页第几列显示什么"。这一层完全不碰硬件。
第三层是 OLED 应用层,在 oled.c 里。它维护了一块 1KB 的显存数组OLED_GRAM[8][128],所有显示函数的本质都是往这个数组里填点阵数据,不直接发给屏。真正把数组推到屏上的动作,是OLED_Refresh()一个函数干的。这个设计叫"显存映射",好处是你画的时候不用管屏当前在哪个位置,画完统一刷一次,屏幕不会出现画一半的撕裂感。
第二层是命令/数据封装层,还是 oled.c,就是OLED_WriteCmd()和OLED_WriteData()这类函数。它负责在真正的数据前面加一个控制字节(0x00 表示后面是命令,0x40 表示后面是数据),这一层是 SSD1306 的协议要求,也是新手最容易漏掉的一步。
最下面是 IIC 时序层 myiic.c,负责把每一个字节拆成 8 个 bit,配合 SCL 的上升沿一位一位送出去,并且读回应答位。再往下就是 GPIO 的置位复位了。
这条链子从"显示什么"一路降到"电平怎么翻转",层次清楚。调试的时候也按这条链子从下往上查:先用示波器确认 SCL/SDA 有波形,再确认从机有应答,再确认控制字节对,最后才怀疑字库。跳过前面几步直接去查字库,是最常见的低效排查方式。
3.3 三个必须改的宏:端口、引脚、从机地址
把工程挪到自己板子上,真正要动的就三处,全在头文件里。
第一处是引脚。myiic.h 里通常有这么一组宏:
#define IIC_SCL_PORT GPIOB #define IIC_SCL_PIN GPIO_Pin_6 #define IIC_SDA_PORT GPIOB #define IIC_SDA_PIN GPIO_Pin_7 #define SCL_H() GPIO_SetBits(IIC_SCL_PORT, IIC_SCL_PIN) #define SCL_L() GPIO_ResetBits(IIC_SCL_PORT, IIC_SCL_PIN) #define SDA_H() GPIO_SetBits(IIC_SDA_PORT, IIC_SDA_PIN) #define SDA_L() GPIO_ResetBits(IIC_SDA_PORT, IIC_SDA_PIN) #define READ_SDA() GPIO_ReadInputDataBit(IIC_SDA_PORT, IIC_SDA_PIN)换引脚只改 PORT 和 PIN 这两组宏,函数体一行不动。注意 SCL 和 SDA 不一定要在同一个端口上,宏里分开定义了端口,就是为了支持跨端口的情况。
第二处是方向切换。SDA 是双向线,发送时是输出,读应答时要切成输入。这个工程用两个宏做切换:
// SDA 切为输入(上拉),用于读从机应答 #define SDA_IN() do { \ GPIO_InitTypeDef g; \ g.GPIO_Pin = IIC_SDA_PIN; g.GPIO_Mode = GPIO_Mode_IPU; \ g.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(IIC_SDA_PORT, &g); \ } while (0) // SDA 切回开漏输出,用于主机发送 #define SDA_OUT() do { \ GPIO_InitTypeDef g; \ g.GPIO_Pin = IIC_SDA_PIN; g.GPIO_Mode = GPIO_Mode_Out_OD; \ g.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(IIC_SDA_PORT, &g); \ } while (0)这里用标准库重配寄存器,可读性好但速度慢。老工程里更常见的是直接改 CRL/CRH 寄存器的那套写法,快是快,但端口一换就得重算位偏移,容易改错。我一般建议保持这一版,除非你确实在追帧率。
第三处是从机地址。oled.h 里定义#define OLED_ADDR 0x78,这是 8 位写地址。如果你的模块地址是 0x7A,改这一处就行。
4. 核心代码逐段拆:一个字节怎么变成屏幕上的像素
4.1 软件 IIC 的起停、应答与位序
IIC 协议本身不复杂,难在细节。起始条件是 SCL 高电平期间 SDA 由高变低,停止条件是 SCL 高电平期间 SDA 由低变高,这两个跳变都必须发生在 SCL 为高的窗口内,否则从机会当成普通数据位。代码长这样:
#include "myiic.h" /* 简易延时,够 100~400kHz 用;主频变了要跟着调 */ static void IIC_Delay(void) { uint8_t i = 4; while (i--) { __NOP(); } } /* 起始信号:SCL 高电平期间 SDA 拉低 */ void IIC_Start(void) { SDA_OUT(); // 确保 SDA 是输出方向 SDA_H(); SCL_H(); IIC_Delay(); SDA_L(); // 这一下就是起始沿 IIC_Delay(); SCL_L(); // 拉低 SCL,准备发第一位 } /* 停止信号:SCL 高电平期间 SDA 拉高 */ void IIC_Stop(void) { SDA_OUT(); SCL_L(); SDA_L(); IIC_Delay(); SCL_H(); IIC_Delay(); SDA_H(); // 这一下就是停止沿 IIC_Delay(); } /* 发送一个字节,高位在前 */ void IIC_SendByte(uint8_t byte) { uint8_t i; SDA_OUT(); SCL_L(); for (i = 0; i < 8; i++) { if (byte & 0x80) { SDA_H(); } else { SDA_L(); } byte <<= 1; IIC_Delay(); SCL_H(); // 从机在 SCL 上升沿采样 IIC_Delay(); SCL_L(); // 拉低准备下一位 } } /* 等待应答:返回 0 表示从机应答,1 表示无应答或超时 */ uint8_t IIC_WaitAck(void) { uint8_t t = 0; SDA_IN(); // 释放 SDA,让从机控制 IIC_Delay(); SCL_H(); IIC_Delay(); while (READ_SDA()) { // 从机应答会把 SDA 拉低 if (++t > 250) { // 等太久说明没接上或者地址错了 SCL_L(); SDA_OUT(); return 1; } } SCL_L(); SDA_OUT(); return 0; }几个参数得说清楚。IIC_Delay()里的循环次数决定了通信速率,主频 72MHz 下这个值大概对应 200kHz 上下,主频换成 168MHz 就得往上加,否则 SCL 太快从机跟不上。判断标准很简单:拿逻辑分析仪测一个完整字节的周期,1 字节 8 位加上应答位,40~50us 就是 200kHz 左右,属于安全区间。
IIC_SendByte里必须高位先行,SSD1306 是按 MSB first 收的,这一条写反了就是全屏乱码。IIC_WaitAck里那个t > 250的超时判断不是可选项,如果没有它,从机没接上时 while 会一直转,现象就是"程序卡死在初始化",看着像死机,其实是在等你。有了超时,函数会返回失败,你可以据此点个 LED 或者打个日志,问题立刻定位。
4.2 控制字节 0x00 和 0x40:命令和数据的分水岭
这是第一次接触 SSD1306 的人最容易懵的地方。IIC 上只传字节,从机怎么知道这一坨是命令还是显示数据?靠的就是每次传输开头那个"控制字节"。0x00 表示后续所有字节都是命令,0x40 表示后续所有字节都是显存数据。
/* 写一条命令 */ void OLED_WriteCmd(uint8_t cmd) { IIC_Start(); IIC_SendByte(0x78); // 从机地址 + 写 IIC_WaitAck(); IIC_SendByte(0x00); // 控制字节:命令模式 IIC_WaitAck(); IIC_SendByte(cmd); // 真正的命令 IIC_WaitAck(); IIC_Stop(); } /* 连续写一串数据(用于刷一页显存) */ void OLED_WriteDataBuf(const uint8_t *buf, uint16_t len) { IIC_Start(); IIC_SendByte(0x78); if (IIC_WaitAck()) { IIC_Stop(); return; } IIC_SendByte(0x40); // 控制字节:数据模式 if (IIC_WaitAck()) { IIC_Stop(); return; } while (len--) { IIC_SendByte(*buf++); if (IIC_WaitAck()) break; } IIC_Stop(); }注意OLED_WriteDataBuf是一次传输写一长串,中间不重新 Start。SSD1306 支持这种连续写,写完一个字节它自己把列地址加一,写到 127 之后行为取决于当前是页寻址模式还是水平寻址模式。这就是为什么刷屏要比"一个字节一次 Start/Stop"快得多——后者每个字节都要走一遍起始、地址、控制字节、停止的完整流程,开销是数据的五六倍。原工程里如果写了单字节版OLED_WriteData,多半也保留了批量版,实际刷屏用批量。
控制字节写错的典型现象是:屏完全不亮,但示波器上数据明明在发。因为从机把所有字节都当成了命令,其中某些值恰好是关显示(0xAE),屏幕就这么黑了。反过来,把命令当数据发,屏幕上会冒出一条条随机的像素。第一次调不通时,先确认这两个常量。
4.3 显存映射:8 页 × 128 列,一个 bit 一个像素
再强调一次 SSD1306 的显存组织:128 列,8 个 page,每个 page 管 8 行像素。OLED_GRAM[page][x]这个字节的 bit0 到 bit7,从上到下对应 page 里的 8 行。有了这个映射,画点函数就很容易理解:
static uint8_t OLED_GRAM[8][128]; // 1KB 显存镜像 /* 画点:x 0~127,y 0~63,color 1 亮 0 灭 */ void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t color) { uint8_t page, bit; if (x > 127 || y > 63) return; page = y / 8; // 落在第几页 bit = y % 8; // 页内第几行 if (color) { OLED_GRAM[page][x] |= (1 << bit); } else { OLED_GRAM[page][x] &= ~(1 << bit); } } /* 设置写指针:指定接下来从哪一页哪一列开始写 */ void OLED_SetPos(uint8_t page, uint8_t col) { uint8_t cmd[3]; cmd[0] = 0xB0 | (page & 0x07); // 页地址 0xB0~0xB7 cmd[1] = 0x00 | (col & 0x0F); // 列地址低 4 位 cmd[2] = 0x10 | ((col >> 4) & 0x0F); // 列地址高 4 位 OLED_WriteCmd(cmd[0]); OLED_WriteCmd(cmd[1]); OLED_WriteCmd(cmd[2]); } /* 整屏刷新:把 GRAM 推到屏上 */ void OLED_Refresh(void) { uint8_t page; for (page = 0; page < 8; page++) { OLED_SetPos(page, 0); OLED_WriteDataBuf(OLED_GRAM[page], 128); } }OLED_SetPos里那三条命令是固定套路:先发页地址(0xB0 + page),再发列地址低 4 位(0x00 + col & 0x0F),再发列地址高 4 位(0x10 + col >> 4)。为什么列地址要拆成两半,因为 SSD1306 的列地址是 8 位的但寄存器只有 4 位宽,只能分两次给。这三条的顺序也不能换。
页寻址模式(初始化里发 0x20, 0x02)下,写完一页的最后列,指针会回到这一页的起始列,不会自动跳到下一页。所以刷屏必须外层循环一页一页来。如果你发的是 0x20, 0x00(水平寻址模式),写满 128 列后会自动换到下一页,那种模式下可以一口气写完 1024 字节,但设置光标的方式也会变,两种模式别混着用。
4.4 初始化序列与上电时序
初始化就是往屏里灌一串固定命令。顺序很重要,有几条必须保证:
static const uint8_t OLED_INIT_CMD[] = { 0xAE, // 关显示,配置期间先别亮 0xD5, 0x80, // 时钟分频 0xA8, 0x3F, // 复用率 1/64(128x32 的屏这里是 0x1F) 0xD3, 0x00, // 显示偏移 0 0x40, // 起始行 0 0x8D, 0x14, // 使能电荷泵,不使能屏幕永远不亮 0x20, 0x02, // 页寻址模式 0xA1, // 段重映射,左右方向 0xC8, // COM 反向扫描,上下方向 0xDA, 0x12, // COM 引脚配置(128x32 的屏这里是 0x02) 0x81, 0xCF, // 对比度 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH 电压 0xA4, // 显示跟随 RAM 内容 0xA6, // 正常显示(0xA7 是反色) 0x2E, // 关闭滚动 0xAF // 开显示 }; void OLED_Init(void) { IIC_Init(); // 先把 GPIO 配好 Delay_ms(100); // 上电稳定,别省这一步 OLED_WriteDataBuf(OLED_INIT_CMD, sizeof(OLED_INIT_CMD)); // 注意这里用命令模式 OLED_Clear(); OLED_Refresh(); }0x8D, 0x14那条是电荷泵使能,这是黑屏第一大元凶。SSD1306 的 OLED 面板需要 7V 以上的驱动电压,靠内部电荷泵升压。电荷泵不打开,命令全对、数据全对,屏就是不亮,而且一点征兆都没有。很多移植过来的初始化表里恰好漏了这两条,找半天找不到原因。
Delay_ms(100)也别省。模块上电到内部稳压稳定需要时间,太快发命令,有些批次会不认。
0xA1和0xC8这两条决定方向。0xA1是段重映射,0xC8是 COM 扫描方向。如果显示出来是镜像的(左右反了或者上下倒了),八成是这两条之一被改过,或者是把它当成"可选优化"删掉了。正常竖屏显示就是 0xA1 + 0xC8 这一组。
4.5 取模方向与字符显示函数
字库是整个工程里最容易翻车的地方,因为取模软件的选项太多。这个工程用的是 6×8 的 ASCII 字模,每个字符 6 列,前 5 列是笔画,第 6 列留空做字间距。取模设置为:阴码、逐列式、逆向(低位在前)。这三项跟代码是绑死的:
/* F6x8 是字库,索引从空格(0x20)开始,每个字符 6 字节 */ void OLED_ShowChar(uint8_t x, uint8_t page, char ch) { uint8_t i, idx; if (x > 121 || page > 7) return; // 6 像素宽,越界直接丢弃 if (ch < ' ' || ch > '~') ch = ' '; // 非可打印字符按空格处理 idx = (uint8_t)(ch - ' '); for (i = 0; i < 6; i++) { OLED_GRAM[page][x + i] = F6x8[idx][i]; // 直接覆盖,先清后写由调用方负责 } } /* 显示定长数字,不足位左补零 */ void OLED_ShowNum(uint8_t x, uint8_t page, uint32_t num, uint8_t len) { uint8_t i; if (len > 10 || x + 6 * len > 128) return; for (i = 0; i < len; i++) { OLED_ShowChar(x + 6 * (len - 1 - i), page, (char)('0' + num % 10)); num /= 10; } }OLED_ShowChar是直接覆盖显存,不先清零。这意味着如果你先把一个 '8' 写在某处,再在同一位置写 '1','8' 的一部分笔画会残留下来,看着像重影。解决办法有两个:写之前先memset清掉这 6 个字节,或者用"或"的方式叠加(那适合画图标不适合写字)。我一般是在字符串显示函数里先清一遍再写。
取模方向对不上的典型现象很有辨识度:文字整体是竖条纹,或者看着像被切碎了。那不是"字库坏了",是逐列式/逐行式选错了,或者逆向/顺向颠倒了。拿一个 'A' 的字模数据对着点阵图看一下就知道——如果第一列出来的是横着的一排点,说明取模软件按行取的,得改回逐列。
5. 避坑与排查:黑屏、花屏、乱码的现场定位法
5.1 通信类故障:完全黑屏和时亮时不亮
现象一:上电后屏一点反应都没有,示波器上 SCL/SDA 都有波形。原因:从机根本没应答,主机在自说自话。可能是地址错、接线反、模块没供电、或者电荷泵没开。解决顺序是:先量 VCC 有没有 3.3V,再用万用表二极管档量 SCL/SDA 对 VCC 是不是有 4.7k 左右的阻值(确认上拉存在),然后写一个最简单的地址扫描,从 0x08 扫到 0x77,把每个地址发一次看谁应答。如果扫出来是 0x7A 而你代码里写的是 0x78,改宏就完了。扫不到任何应答,那基本是接线或供电问题,回到硬件。
现象二:单步调试能显示,全速跑就随机黑屏,或者跑一会儿花掉。原因:时序太快或者上拉不足。软件 IIC 的延时函数在主频改变后没有同步调整,或者杜邦线拉太长导致上升沿变缓。解决:把IIC_Delay()的循环次数翻倍试试,如果好了就是速率问题;把线缩短到 10cm 以内再试一次;实在不行在上拉上并一个 2.2k。还有一种情况是你用了中断或者 RTOS,在 IIC 传输过程中被打断太久,从机超时复位了,这种要在传输前后加临界区保护。
5.2 显示类故障:花屏、镜像、乱码和残影
现象三:屏幕亮,但满屏随机噪点,或者只有一部分区域有内容。原因:控制字节或者寻址模式乱了。最常见的是初始化表里发的是水平寻址模式(0x20, 0x00),但刷屏代码按页寻址写,写满一页后指针跑飞。另一个原因是初始化命令中间被IIC_WaitAck()失败打断,只发出去一半,屏处于半配置状态。解决:先确认0x20后面跟的是0x02;在每个IIC_WaitAck()后面加个返回值判断,失败就重发整个初始化序列,别让它带着半套配置跑下去。
现象四:字显示出来了,但内容是镜像的,或者上下颠倒。原因:0xA1/0xC8这两条方向命令被改过或者被注释掉了。有些移植代码里把0xA0(正常段映射)当成默认值,结果就是左右反。解决:把初始化表里的段重映射和 COM 扫描方向恢复成0xA1+0xC8,重新编译。如果只是想临时翻转显示方向,改这两条就够了,不用动字库。
现象五:字符显示成竖条纹或者碎片,图形也歪。原因:字库取模设置和代码不匹配。这个工程的代码按"阴码、逐列式、逆向"解析,取模软件里如果选了逐行式或者顺向,数据就全乱了。解决:打开取模软件,把输出格式设成阴码、逐列式、逆向(低位在前),宽 6 高 8,重新生成整个字库数组。别想着在代码里做位反转去迁就旧字库,那样每帧都要多算一遍,得不偿失。
现象六:同一位置反复刷新数字,旧笔画残留,看着像重影。原因:OLED_ShowChar是覆盖写,如果两次写的字符宽度一样但笔画不同,被覆盖区域的显存没清零。比如 '8' 换 '1','8' 左边那两竖会留下来。解决:在OLED_ShowString和OLED_ShowNum里,写之前先memset(&OLED_GRAM[page][x], 0x00, 6 * len)把这块区域清掉。代价是多几次内存写,但比看着像故障强。
提示:排查顺序永远是"电 → 线 → 地址 → 应答 → 控制字节 → 命令序列 → 字库"。绝大多数人卡在前三步,却花时间在最后一步,这是最典型的低效路径。
6. 二次开发:把整屏刷新改成脏页局部刷新
原工程的OLED_Refresh()每次都把 8 页 1024 字节全推一遍,按 400kHz 算一次要 30ms 左右。如果你只有一个数值在变,比如每秒刷新一次的 ADC 读数,这 30ms 里 99% 的带宽都浪费在没变的像素上。而且如果主循环里还有其他活,30ms 的阻塞会明显拖慢响应。
改造思路很直接:在显存旁边加一个 8 字节的脏页标记,哪个 page 被写过就置位,刷新时只发被标记的页。
static uint8_t OLED_DIRTY = 0; // 每 bit 对应一页 /* 所有画点/写字函数的末尾都加一句 */ static inline void OLED_MarkDirty(uint8_t page) { OLED_DIRTY |= (uint8_t)(1 << page); } void OLED_RefreshDirty(void) { uint8_t page; for (page = 0; page < 8; page++) { if (OLED_DIRTY & (1 << page)) { OLED_SetPos(page, 0); OLED_WriteDataBuf(OLED_GRAM[page], 128); OLED_DIRTY &= (uint8_t)~(1 << page); // 清标记 } } }调用侧不用改,OLED_ShowNum内部写完显存后调一次OLED_MarkDirty(page)就行。全屏清屏OLED_Clear()里则直接把OLED_DIRTY = 0xFF标记全部。
验证方法有两个层次。软件层面,在OLED_RefreshDirty里打个计数,观察一次局部刷新实际发送的页数,只改一行数字时应该是 1,不是 8。硬件层面,拿逻辑分析仪抓 SDA 上连续传输的字节数,改之前每次刷屏是 1024+ 字节,改之后应该掉到 128 左右,波形长度差一眼就能看出来。
再往前走一步,如果你确实要做动画或者滚屏,软件 IIC 的速率就是瓶颈了,这时候该考虑换成硬件 IIC + DMA,把 128 字节一页的搬运交给 DMA,CPU 只管准备下一帧的数据。换的时候有件事一定要做:先把OLED_WriteDataBuf单独抽成一个文件级接口,保证上层只依赖这一个函数,换底层的时候只改它的实现,别让 DMA 的细节漏到显示逻辑里。
我踩过的坑是改造完忘了清OLED_DIRTY标记,结果那一页每帧都重发,白改一场,还因为多了个分支判断比原来更慢。从那以后我每次做这种缓存优化,都会先在刷新函数里加一行计数打印,确认命中率对得上再往下走。希望帮到你。
本文还有配套的精品资源,点击获取