
0.96 寸的那块小 OLED几乎是每个碰单片机的人都会买的第一批模块。它便宜、够亮、对比度高、不需要背光两三块钱就能拿到一块 128×64 的单色屏挂在任何一款 MCU 上都能跑起来。但真正上手之后你会发现让屏幕亮起来和让屏幕上显示自己想要的东西完全是两码事——前者照着例程抄一遍就行后者绕不开两个核心问题IIC 协议怎么把数据准确地送进屏幕的显存以及取模工具怎么把一张图、一个汉字变成能塞进代码里的字节数组。这两个问题看着简单实际坑特别多。你去搜一下就会发现批量点不亮加了 OLED 函数就卡死矩阵按键突然没反应这三类求助帖常年霸榜说明大家踩的其实是同一批坑。我自己前后用 SSD1306 做过好几个小项目——时钟、菜单交互、波形显示被 0x78 和 0x7A 这两个地址坑过一次被取模参数选反坑过两次也被字库数组放栈上把栈打爆坑过一次。这篇就把这些东西从头到尾捋一遍底层显存怎么排的、IIC 每一帧长什么样、取模软件那几个参数到底在干什么、HAL 库怎么写才不卡死、出了问题按什么顺序排查。手上有开发板、想把自己那块 OLED 用明白的都能直接抄。1. 先把需求拆开屏幕上出内容到底卡在哪1.1 拆成三件事驱动、字模、刷新很多人一上手就想显示一张图片结果发现连字符都出不来问题就出在没把任务分层。让 OLED 出内容这件事实际上要拆成三层每层解决一个问题顺序不能乱。第一层是驱动层负责把字节准确送进 SSD1306 的显存。这一层关心的是 IIC 时序、从机地址、控制字节、初始化命令序列。它只做一件事把某个位置改成某个值。第二层是数据层也就是取模工具产出的那些数组。这一层关心的是字节和像素的对应关系一个字节是横着排的八个点还是竖着排的八个点1 是亮点还是灭点。第三层是应用层负责往显存里填什么内容以及什么时候刷新——时钟怎么每秒只改那几位、菜单怎么高亮选中项、图片怎么滚动。我见过太多人把这三层搅在一起IIC 还没通就开始调取模不亮就怀疑字模错了改来改去最后发现是从机地址写了个 0x7A。所以我的建议是调试顺序严格按层来先用一个全屏点亮的命令确认通信正常再打一个已知的 8×8 方框确认像素对应关系最后才上真正的字模和图片。每层单独验证出问题的范围会小一个数量级。1.2 硬件选型IIC 和 SPI 到底选哪个市面上最常见的 0.96 寸模块基本就两种接口四针的 IIC 和七针的 SPI。两者的屏体、驱动芯片其实是一样的都是SSD1306差别只在通信通路和引脚数。对比项IIC 版本4 针SPI 版本7 针引脚占用VCC / GND / SCL / SDA共 4 根VCC / GND / SCL / SDA / RES / DC / CS共 7 根默认从机地址7 位 0x3C8 位写地址 0x78不涉及地址靠 CS/DC 片选区分理论速率标准 100kHz快速 400kHz可达 8MHz 以上实测常用 4~8MHz全屏刷新耗时128×64 全屏 1KB 数据400kHz 下约 25~35ms同数据量可压到 5ms 以内硬件资源需要 IIC 外设或两个普通 IO 模拟需要三个 IO 加一个 SPI 外设是否需要整屏反显常见模块可整屏反显多数模块不支持整屏反显多屏并联地址冲突需改板上电阻或加 IIC 多路开关各给一个 CS 即可天然支持多屏选型逻辑其实很简单。只显示文字和静态图标、刷新频率不高、IO 紧张就用 IIC四根线接上去就能跑省事。要做动画、刷波形、刷视频、多屏拼接或者 MCU 主频低又想跑流畅交互就选 SPI——IIC 那条总线在 400kHz 下刷全屏要三十毫秒折算下来一秒钟最多三十帧而且还是整屏重画做菜单高亮这种局部更新时浪费非常明显。还有一个容易被忽略的点很多 IIC 模块板上带了电平转换和上拉电阻标称 3.3V~5V 通用。但如果你是自己画板子直接接裸屏IIC 的 SCL/SDA 上必须各挂一个 4.7kΩ 上拉电阻到 3.3V漏了这个波形会变成难看的斜坡通信时好时坏。这是我踩过的坑症状是上电偶尔能亮动一下线就黑屏典型的上拉不足。1.3 软件选型HAL 硬件 IIC 还是软件模拟用 STM32 的话这两条路都有人走。HAL 库硬件 IIC的好处是省 CPU、时序由外设保证、代码短坏处是 HAL 的 IIC 状态机比较敏感一旦从机没响应又没有超时保护HAL_I2C_Master_Transmit会一直卡在等待标志位里表现出来就是加了 OLED 函数整个程序死了。软件模拟 IIC的好处是引脚随便挑、时序完全可控、出了问题能单步看波形坏处是每条字节都要靠delay撑时序CPU 占用高。我个人的选择是产品里用硬件 IIC但所有 HAL 调用都带超时参数调试和教学场景用软件模拟方便定位。后面两套代码我都会给。顺便说一个和热词里那个卡死直接相关的点HAL 的HAL_I2C_Master_Transmit(hi2c1, addr, buf, len, timeout)最后一个参数是超时毫秒数。写成HAL_MAX_DELAY就等于把死锁的门自己打开了只要从机没应答程序就永远停在那儿。永远给个具体的数字比如 20、50。2. SSD1306 显存结构和 IIC 帧格式2.1 页-列寻址为什么一个字节管竖直的八个像素SSD1306 内部的显存叫 GDDRAM128×64 的屏一共 1024 字节。它不按一行一行排而是把 64 行切成8 个页Page每页 8 行高、128 列宽一页正好 128 字节。关键在于一页里的一个字节对应的是同一列的上下 8 个像素。也就是说如果你往 (列 10, 页 3) 写一个 0xFF屏幕上竖着会亮起 8 个点写 0x0F亮的是其中 4 个。这个竖直打包的结构直接决定了取模软件里必须选逐列式——后面讲取模时会重点说。三个坐标概念要分清弄混了就会出现字跑到屏幕外面去了这种问题页地址Page0~7一页 8 行用的是0xB0 | page这条命令列地址Column0~127需要拆成高四位和低四位两条命令分别加0x10和0x00偏移起始行Start Line通常固定为 0用于滚动一般不动。页地址模式也是最常用的寻址模式初始化时用0x20, 0x02设定。它和水平寻址、垂直寻址的差别在于页模式下写完一个字节列地址会自动加一写到 127 就回到本页开头如果不设窗口这就是为什么整屏刷新要手动一页一页地设地址。2.2 一帧 IIC 数据到底长什么样这是最容易出错的地方。很多人以为往 IIC 上扔字节就完事了其实每一次传输都有固定结构起始条件 → 从机地址(7位) 写标志(0) 0x78 → 应答 → 控制字节0x00 表示后面是命令0x40 表示后面是数据 → 应答 → 数据字节 1 → 应答 → 数据字节 2 ... → 停止条件那个控制字节就是命令/数据的开关。少了它屏幕会把命令当数据、把数据当命令结果就是通信有应答但屏幕没反应。这个症状特别具有迷惑性你用逻辑分析仪一看波形漂亮得很地址也应答了就是不出图——十有八九是控制字节写错了或者软件 IIC 里把mode参数传反了。用 HAL 库写的时候一次传输可以把控制字节和数据打包成一个 buffer效率比一字节一字节发高得多#define OLED_ADDR 0x78 // 7位地址 0x3C 左移一位 void OLED_WrCmd(uint8_t cmd) { uint8_t buf[2] {0x00, cmd}; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 20); } void OLED_WrData(uint8_t *dat, uint16_t len) { // 一次最多 127 字节数据 1 字节控制字分批发 while (len) { uint16_t n (len 127) ? 127 : len; uint8_t buf[128]; buf[0] 0x40; memcpy(buf[1], dat, n); if (HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, n 1, 50) ! HAL_OK) return; // 不要用 HAL_MAX_DELAY给自己留条退路 dat n; len - n; } }软件模拟的版本逻辑一样只是把每次传输拆成起始、发字节、等应答、停止static void IIC_Start(void); static void IIC_Stop(void); static void IIC_SendByte(uint8_t b); static uint8_t IIC_WaitAck(void); void OLED_WrByteSW(uint8_t dat, uint8_t mode) { IIC_Start(); IIC_SendByte(OLED_ADDR); if (IIC_WaitAck()) { IIC_Stop(); return; } // 没应答直接退出 IIC_SendByte(mode ? 0x40 : 0x00); IIC_WaitAck(); IIC_SendByte(dat); IIC_WaitAck(); IIC_Stop(); }注意软件 IIC 的等待应答一定要有退出机制。如果从机被拉低 SDA 卡住总线无脑死等会让整个程序挂在这里这就是加了 OLED 函数卡死最常见的成因之一。2.3 显存缓冲区到底要不要开不开缓冲区的写法是直接往屏幕写OLED_ShowChar直接算坐标然后发数据。这种写法省 1KB 内存但有两个致命问题一是没法做叠加你想在字符上叠个光标就得重画整个区域二是局部刷新很别扭因为没有当前屏幕内容的概念。开一个uint8_t OLED_GRAM[8][128]缓冲区正好 1024 字节所有绘制操作先改内存画完了统一刷一次。好处是叠加、异或、取反、区域比较都很方便做时钟这种只改几个数字的场景尤其明显static uint8_t OLED_GRAM[8][128]; // 所有绘制函数只改内存不碰总线 void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t on) { if (x 127 || y 63) return; if (on) OLED_GRAM[y / 8][x] | (1 (y % 8)); else OLED_GRAM[y / 8][x] ~(1 (y % 8)); } // 提交整屏一页一页刷 void OLED_Refresh(void) { for (uint8_t page 0; page 8; page) { OLED_SetPos(0, page); OLED_WrData(OLED_GRAM[page], 128); } }1024 字节对 STM32F103C8T6 的 20KB RAM 来说完全够用但要注意别把字库和图片数组也定义成全局非 const 变量。字库动辄几 KB 到几十 KB全塞 RAM 里很快就不够了。养成习惯所有字模图片数据加const让它待在 Flash 里。3. 取模工具怎么用参数选错就全白干3.1 五个必须搞懂的参数取模工具PCtoLCD2002、Image2Lcd 这一类界面上选项一大堆其实真正影响结果的只有五个剩下的都是输出格式的壳子。参数常见取值对 SSD1306 的正确选择选错的后果点阵格式极性阴码 / 阳码需要1 表示点亮的那种显示成反白背景亮、笔画暗取模方式逐行式 / 逐列式逐列式内容错乱成麻花完全认不出字节内位序顺向 / 逆向与屏幕一致需实测出现上下镜像、锯齿、竖线错位点阵大小8×16 / 16×16 / 自定义按显示需求定尺寸不匹配显示被截断输出格式C51 / A51 / 二进制C51十六进制数组拷不进代码得手工转换这里我要说一句得罪人的话网上关于阴码还是阳码的说法非常混乱不同教程给的结论甚至互相矛盾因为有些教程的驱动函数里偷偷做了取反。所以别抄结论抄验证方法。我的做法是固定用逐列式 阳码然后拿一个上下不对称的图案比如上字或者一个箭头打上去看显示正常就对了如果是反白的把初始化里的0xA6改成0xA7或者对数据按位取反两种改法等价。3.2 汉字、ASCII 和图片三种取模的差别汉字一般是 16×16 点阵一个汉字占 32 字节是两页。取模时工具会按上半页 16 字节 下半页 16 字节的顺序输出这也是为什么写显示函数时要分两次设置页地址。这里有个细节横排汉字第一个字节对应的是汉字最左边一列的上 8 个像素第二个字节是最左边一列的下 8 个像素……不对准确的说是先输出上半部分的 16 列再输出下半部分的 16 列。所以显示函数的偏移量是idx * 32 i和idx * 32 i 16写成idx * 32 i 32就全错了。ASCII 字符通常用 8×16 或者 6×8。8×16 的一个字符占 16 字节分两页逻辑和汉字一样只是宽度减半。6×8 的更省地方但字库得自己拼工具里一般没有现成的通常是拿一个 8×8 的取模然后裁掉两列。图片是最容易翻车的一块。正确姿势是先把原图处理成单色位图尺寸严格等于你要显示的区域比如 128×64 或者 64×64然后导入取模工具同样选逐列式。如果你用的是 Image2Lcd它默认输出横向扫描的字节流直接喂给 SSD1306 会变成一堆横向条纹——必须自己在代码里做转置或者在软件里把扫描方式改成纵向。我更推荐直接用 PCtoLCD2002 的图片模式省掉这道转换。一张 128×64 的全屏黑白图就是 1024 字节写进代码是这样的const uint8_t BMP_Logo[1024] { 0x00, 0x00, 0x00, 0x00, /* ... 共 1024 字节 ... */ }; void OLED_ShowBMP(const uint8_t *bmp) { for (uint8_t page 0; page 8; page) { OLED_SetPos(0, page); OLED_WrData((uint8_t *)bmp[page * 128], 128); } }提示整屏图片数组一定要加const。不加的话它会被加载到 RAM1024 字节看着不多但如果你同时放了好几张大图栈和堆的空间会被挤爆——这正是很多人加了 OLED 函数就卡死的真实原因跟 IIC 一点关系都没有。3.3 三个能自己动手验证的小方法调试取模参数有三个不依赖逻辑分析仪的自检办法我一直用。第一字节数自检。一个 W×H 的单色点阵字节数一定是W/8 * H。16×16 汉字是 32 字节8×16 字符是 16 字节64×64 图片是 512 字节128×64 全屏是 1024 字节。取出来数一遍对不上就是点阵大小选错了这一步能筛掉一半的低级错误。第二图形自检。用取模工具画一个不对称的图案比如一个L形或者一个左斜的对角线打到屏幕上。上下的方向对不对看0xC8这个扫描方向位左右的方向对不对看0xA1这个段重映射位。左右反了就把0xA1换成0xA0上下反了就把0xC8换成0xC0。这两个命令是救命命令比重新取模快得多。第三极性自检。显示一个 16×16 的实心方块。如果显示出来是一圈边框、中间是暗的说明极性反了如果是一块实心黑块也就是屏幕全亮只有这块暗也是极性反了。改0xA6/0xA7或者重新按另一个极性取模都行。4. HAL 库驱动 OLED 的完整落地4.1 驱动层骨架初始化序列一条都不能少初始化序列我建议直接写成一个数组一次遍历发完比一行一行OLED_WrCmd清爽。下面是 128×64 的 SSD1306 标准序列注释说明了每一条在干什么static const uint8_t OLED_InitCmd[] { 0xAE, // 关闭显示配置期间先关掉避免花屏 0xD5, 0x80, // 时钟分频与振荡频率0x80 是常用安全值 0xA8, 0x3F, // 多路复用比0x3F 63即 64 行 0xD3, 0x00, // 显示偏移0 表示不偏移 0x40, // 起始行地址 0 0x8D, 0x14, // 电荷泵使能0x14 打开不打开就是一片黑 0x20, 0x02, // 页寻址模式 0xA1, // 段重映射左右方向 0xC8, // COM 扫描方向上下方向 0xDA, 0x12, // COM 引脚硬件配置128x64 用 0x12 0x81, 0xCF, // 对比度0xCF 是常用亮度 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH 电平 0xA4, // 关闭全屏强制点亮 0xA6, // 正常显示非整屏反色 0xAF // 打开显示 }; void OLED_Init(void) { HAL_Delay(100); // 上电后给屏一点时间别急着发命令 for (uint16_t i 0; i sizeof(OLED_InitCmd); i) OLED_WrCmd(OLED_InitCmd[i]); OLED_Clear(); }这几条命令里最容易漏的是 0x8D 电荷泵和 0xAF 开显示。电荷泵不开屏内部没有升压就是彻底的纯黑0xAF 忘了配得再对也是黑屏。另外HAL_Delay(100)这个上电延时别省有些模块的复位电路做得比较糙上电立刻发命令会吃不上。4.2 显示字符和字符串设置光标位置的函数是后面所有显示函数的基础void OLED_SetPos(uint8_t x, uint8_t y) { OLED_WrCmd(0xB0 y); // 页地址 OLED_WrCmd(((x 0xF0) 4) | 0x10); // 列高四位 OLED_WrCmd(x 0x0F); // 列低四位 }显示一个 16×16 的汉字要分上下两页写void OLED_ShowCN(uint8_t x, uint8_t y, uint8_t idx) { OLED_SetPos(x, y); for (uint8_t i 0; i 16; i) OLED_WrByteSW(F16x16[idx * 32 i], 1); // 上半部分 OLED_SetPos(x, y 1); for (uint8_t i 0; i 16; i) OLED_WrByteSW(F16x16[idx * 32 i 16], 1); // 下半部分 }那 16 个字的字库数组长这样注意每个汉字 32 字节用逗号隔开加constconst uint8_t F16x16[][32] { {0x00,0x00,0xF0,0x1F,0x10,0x10,0x10,0x10,0x10,0x10,0xF0,0x1F,0x00,0x00,0x00,0x00, 0x00,0x04,0x07,0x04,0x04,0x04,0x04,0x04,0x04,0x04,0x07,0x04,0x00,0x04,0x00,0x00}, // 示例 /* 后续汉字按同样格式追加 */ };字符串就是循环调字符函数、游标右移。这里有个细节要注意中文和英文混排时一个汉字占 16 列一个 8×16 的英文占 8 列换行判断必须按实际像素宽度算不能按字符个数算否则碰到汉字就会突出去。1306 的屏幕宽 128 像素横着最多 8 个汉字或者 16 个英文字符。4.3 图片显示整屏、局部窗口和动画整屏图片最简单前面给过函数了。但实际项目里更多的是局部刷新——比如在一个菜单里换图标、在一个时钟界面里换冒号。这时候就要用0x21和0x22这两条命令来设窗口// 设定数据写入的矩形窗口列 0~127页 0~7 void OLED_SetWindow(uint8_t x0, uint8_t x1, uint8_t p0, uint8_t p1) { OLED_WrCmd(0x21); // 列地址命令 OLED_WrCmd(x0); OLED_WrCmd(x1); OLED_WrCmd(0x22); // 页地址命令 OLED_WrCmd(p0); OLED_WrCmd(p1); // 设完之后后续数据会自动在这个矩形里按列递增填充 }有了窗口显示一个小图标就变成设窗口 → 发数据 → 完事。比起每次都重画全屏节省的 IIC 时间非常可观。做一个 32×32 的图标数据量只有 128 字节而全屏是 1024 字节差了八倍。要做动画比如一个滚动的进度条或者一张会动的小图思路是预先把几帧都取好模放进一个二维数组用一个定时器按固定间隔切换帧并只刷新那块区域。别用delay卡在主循环里切帧那样按键、串口全都响应不了这是很多人的交互程序写到最后卡成PPT的原因。4.4 亮度调节和低功耗调亮度走的是对比度命令0x81后面跟一个 0x00~0xFF 的值值越大越亮void OLED_SetBrightness(uint8_t level) // level: 0~255 { OLED_WrCmd(0x81); OLED_WrCmd(level); }实测下来 0x7F 到 0xCF 之间视觉效果比较舒服低于 0x30 基本就看不清了直接给 0xFF 会有点偏色和残影不建议。想省电的话有两条路一是设成对比度很低但不关屏二是直接0xAE关显示。注意0xAE只是不显示显存内容还在再发0xAF回来画面立刻恢复这一点很适合做息屏功能——比清屏再重画要省事得多。另外如果你是在跑嵌入式 Linux 的板子上用这类小屏比如挂在标准显示接口上的情况亮度一般走的是系统暴露的通用亮度接口直接读改写那个节点就行不用去碰 SSD1306 的寄存器。但如果你是自己写驱动把屏挂在 IIC 上的那还是老老实实发0x81命令别指望系统能替你去调。5. 现场排障不亮、卡死、按键失灵5.1 批量点不亮的四层排查表批量点不亮是个很有代表性的问题——单独一块能跑一次做十块有五块不亮这基本可以排除代码问题往硬件方向找。按我自己的排查顺序整理成表排查层级具体检查项典型现象供电层3.3V 是否稳定、电流是否足够、万用表量 VCC-GND完全没反应或上电亮一下马上黑总线上拉层SCL/SDA 上拉电阻是否焊了、阻值 4.7kΩ 左右偶尔亮偶尔不亮晃动线材有变化地址层板上地址选择电阻焊没焊、是否误焊成 0x7A通信没应答逻辑分析仪看不到 ACK模块层排针虚焊、FPC 接触不良、屏体本身损坏换了板子就好同一块怎么都不亮批量不亮最隐蔽的原因是地址电阻。有些模块背面有两个 0Ω 电阻用来选 0x78 或 0x7A出厂不一定都按同一个位置焊。你手里十块板子可能混着两种地址。判断方法很简单写一个扫描程序从 0x08 扫到 0x77把所有应答的地址打出来一目了然。这个扫描程序我建议永远留在工程里省得以后每次都要怀疑地址。还有一类是排针虚焊。批量手工焊的板子IIC 那两根信号线虚焊的概率非常高症状就是有时候亮有时候不亮用手指按着就正常。这种别改代码拿烙铁补一遍全解决。5.2 加了 OLED 函数就卡死五个常见原因这个问题太典型了我把它拆成五个方向按可能性从高到低排。第一个无超时的 HAL 调用。HAL_MAX_DELAY是万恶之源从机不应答就死等。把超时改成 20~50ms最多是显示偶尔掉一帧不会整个程序挂掉。第二个字库数组没加 const 导致栈溢出。这个特别隐蔽因为编译能过、逻辑也看起来对。现象是程序跑到某个显示函数就跳进硬件错误中断。检查方法看一下 map 文件里那个大数组落在哪个段或者直接给所有字模加const试试。一张 128×64 的图是 1024 字节几张加起来很容易把默认栈挤爆。第三个在中断里刷屏。有些人在定时器中断里调用刷新函数一次刷屏三十毫秒中断服务程序占着 CPU 不放主循环、其他中断全被拖死。刷屏这件事只能放在主循环里做中断里最多设个标志位。第四个IIC 总线被从机拉死。现象是程序在等待应答处出不来。处理办法是加一层保护如果超时了把 SCL 手动打 9 个时钟脉冲把从机状态机踢出死锁再发一个停止条件复位总线。这个总线恢复函数值得单独写一个产品里很实用。第五个初始化顺序问题。外设时钟没使能、GPIO 复用没配对、IIC 还没HAL_I2C_Init就去发数据。这种一般从头就卡跟加了个函数关系不大但顺手一起检查。5.3 矩阵按键为什么在 OLED 上没反应热词里矩阵按键在 OLED 上没有反应这个问题我遇到过两次两次原因完全不同值得单独讲。第一种情况是刷新把 CPU 占满了。软件模拟 IIC 的每条字节都要靠延时撑时序一条字节几十微秒刷一屏 1024 字节加命令开销就是几十毫秒。如果你的按键扫描写在主循环里后面还跟着一句OLED_Refresh()那按键的实际扫描周期就被拉到了几十毫秒。人按一下按键大约 50~100ms理论上是能扫到的但如果消抖还用了delay再加上其他任务就可能彻底扫不到。解决办法是把扫描放到定时器中断里或者用状态机 时间戳的方式消抖别用延时。第二种情况是引脚冲突。有人用了PB6/PB7做软件 IIC同时又拿PB5~PB7去接矩阵键盘的行线——共用引脚了。这种问题代码上完全看不出来只能对原理图。养成好习惯画图或者接线前先把所有外设的引脚分配列成一张表标出复用和冲突。还有一种少见但存在的OLED 的 IIC 和矩阵键盘共用了一个上拉电阻网络键盘按下时把某条线拉低连累 IIC 时序。这种情况表现为一按键屏幕就花。解决办法是给 IIC 单独留上拉别共用。5.4 时钟和交互程序的刷新优化显示时钟是这类屏最经典的用法也最能体现会不会做优化。最笨的写法是每秒清屏重画全部内容结果就是屏幕每秒闪一下而且 IIC 占用高。正确的写法是分层刷新静态的边框、时字、分字这些永远不变只在开机时画一次变化的只有数字本身而且每次只改真正有变化的那一位。具体做法是把上一秒的数字存起来跟当前秒比较static uint8_t last_sec 0xFF; void Clock_Update(uint8_t hour, uint8_t minute, uint8_t second) { if (second ! last_sec) { OLED_ShowNum(64, 0, second, 2); // 只重画秒的两位 last_sec second; } // 时和分同理各自缓存一份上次值 }这样每秒实际发出去的字节从一千多降到了十几个屏幕不闪了总线也空出来了按键响应自然就跟上了。做菜单交互也是同一个思路记一份当前界面状态只画变化的部分选中项的高亮用异或操作切换效率最高。还有一个提升观感的小技巧在缓冲区里做双缓冲。准备两份 1024 字节的数组一份是当前显示一份是下一帧主循环里先拼好下一帧再一次性刷过去。这样不会出现画了一半的画面被看到的撕裂感。对 128×64 这种小屏多花 1KB RAM 换来干净的视觉效果我觉得很值。再补一个很多人会忽略的点别在显示函数里到处HAL_Delay。我见过有人在每个字符显示后面加 1ms 延时让它稳一点结果八个汉字就是 8ms时钟要显示六七个数字一帧下来四十多毫秒交互手感直接废掉。SSD1306 不需要这种延时IIC 的应答机制本身就是同步的。在最后一个问题上我个人最深的体会是这套小屏折腾到最后真正难的不是让它亮而是让它稳。会亮只需要抄对一份例程稳则要求你把超时、缓冲区、局部刷新、引脚分配这些看不见的地方都处理干净。我现在写任何一块 0.96 寸屏的驱动第一件事就是先把带超时的OLED_WrData和总线恢复函数写好再动手做显示内容——这一个习惯替我挡掉了后面至少七成的玄学 bug。