
简介面向初学者和嵌入式开发者的STM32 OLED动态显示工程代码基于标准库函数编写将GIF动图拆解为60帧动画序列并逐帧刷新在OLED屏上呈现流畅动效适合练习GPIO控制、定时器中断和画面渲染。压缩包内共196个文件涵盖C/H源文件、Keil工程与编译配置、hex/axf固件、BMP位图素材、启动链接脚本以及txt说明与Word文档等整体约13.05MB组织结构覆盖从源码阅读、编译生成到烧录验证的完整流程便于按需查找。目前已有1253人学习下载代码内附详细注释按接线说明烧录即可看到动画且已集成标准库函数模块和常用外设驱动能帮助读者理解底层时序并快速上手。通过示例代码还可学到静态图案帧数组存储、定时器控制刷新频率、显示性能优化等关键点这些经验对后续LCD或屏幕驱动类项目同样适用同时支持自行替换位图素材方便扩展出个性化动画。1. STM32 驱动 OLED 动态图案先看清“一帧要多久”再动手单看 STM32 驱动 OLED 动态图案显示代码这几个字很容易让人误判觉得只要会往屏幕打印字符再把图片数组连续切换动画自然就出来了。实际做过一遍的人都知道第一步就要先反驳自己——动画卡不卡从来不取决于你画图画得多快而是取决于整帧数据在这条 I2C 或 SPI 链路上走完要多久。如果一帧要 25ms你在主循环里再努力也只能停在 40fps 以下更别提还有清屏、改地址、内存越界这些暗坑。这篇笔记我按自己动手时的顺序讲先讲 SSD1306 的显存为什么是 1024 字节、怎么组织再给一套最小可复现工程然后把花屏、闪烁、越界和复位玄学逐条过一遍最后讲我常用的差分刷新习惯。适合刚用 HAL 库入门的开发者也适合已经能显示静态图、想追求更顺滑动画的熟手。2. 动态图案的底层原理SSD1306 显存映射与帧缓冲区不要一上来就背命令表。先把屏的内部当成一张可读写的位图而不是黑匣子。多数 0.96 寸 OLED 使用 SSD1306 控制器显示区域是 128×64每个像素只占 1 位所以满屏位图只需要 128×64÷81024 字节。这 1024 字节怎么排、怎么分页决定了你后续写动画能顺手到什么程度。2.1 页与列的排列为什么显存数组要用 [8][128] 而不是 [128][8]SSD1306 的内部显存并不是像普通图片那样从上到下逐行排列而是分成 8 页Page每页对应屏幕上垂直方向的 8 行像素。换句话说第 0 页负责显示区域第 0~7 行第 1 页负责第 8~15 行依此类推。每一页里还有 128 列每一列存一个字节这个字节的 8 个 bit 从上到下对应着该页内 8 个点的点亮状态。所以你平时看到的“横着扫”和“竖着排”其实是两回事你从屏幕左边往右看每一列拿到的字节是垂直一长条 8 个点的状态。这个映射关系如果搞反代码里画点函数就会一直画到错行去最后表现出的就是花屏和乱码。#define OLED_W 128 #define OLED_H 64 #define OLED_TOTAL_LINES 8 /* 64 行 / 8 8 页 */ /* 帧缓冲区按 [页][列] 组织对应 SSD1306 的显存排布 */ static uint8_t FB[OLED_TOTAL_LINES][OLED_W]; /* 点亮屏幕上 (x, y) 坐标的像素 */ static void px_on(int x, int y) { /* 越界检查一定要有否则动态图案跑出边界后索引会乱 */ if (x 0 || x OLED_W || y 0 || y OLED_H) return; FB[y 3][x] | (uint8_t)(1u (y 7)); }逻辑说明y 3相当于除以 8用来定位这个点属于哪一页y 7取余数表示它在页内从上往下数的第几行。FB[y 3][x]的写法和你后面刷屏函数完全对上第 0 页数据直接送给 OLED再送第 1 页数据屏就能按正确位置点亮。如果改成[128][8]刷屏时要么跳着取地址要么做一次转置多花 CPU 时间也更容易写错页地址命令。参数说明OLED_H64对应 8 页如果你的屏是 128×32 或 64×48这个宏要跟着改否则页数超界。px_on里做边界剪裁虽然多几条 if但能省掉后面动画移动时一大半越界事故属于我先垫进去的保险。2.2 动态图案的本质先画进帧缓冲区再整帧交给屏幕动态图案说白了就是多张静态画面按时间顺序切换。但 MCU 上有个特殊约束I2C 是串行协议每发一个字节都要等 ACK你没法做到边画边刷还保持流畅。常见做法一定是“先画后刷”所有绘制动作都写进上面这个FB数组等一帧内容全部更新完再一次性把FB里的内容通过刷新函数送给 OLED。/* 清屏把帧缓冲区全部清 0 */ void OLED_ClearBuffer(void) { memset(FB, 0x00, sizeof(FB)); } /* 以移动方块为例绘制一帧方块左上角坐标为 (x0, y0) */ void draw_block(int x0, int y0) { OLED_ClearBuffer(); int x, y; for (y y0; y y0 8; y) { for (x x0; x x0 8; x) { px_on(x, y); } } }逻辑说明draw_block的逻辑很简单就是把某个矩形区域里的像素逐个点亮。关键不在绘图算法而在“绘制目标永远是内存不是屏幕”。你在FB上画 100 次屏幕也不会有任何变化只有调用刷新函数后那一瞬间整屏数据才被送出去。这样动画才不会有单个像素跳变造成的撕裂感。参数说明方块的 8×8 是像素尺寸不是字节尺寸。这个函数里每画一个点都调用px_on效率一般但胜在直观。等你要画复杂的图标或曲线时再换用画直线、画圆的算法往FB里填充即可刷新逻辑完全不用改。2.3 一帧 1024 字节的传输开销I2C 和 SPI 差多少写动态图案前最好先算一算“一帧要走多久”不然你会把主循环 delay 调得很奇怪。I2C 工作在 400kHz 时每发一个字节实际要经过 9 个时钟周期包括 ACK所以裸数据速率大约 44k 字节每秒。刷一屏要传 1024 字节显存再加上页地址、列地址这些命令字节一帧大概 23~25ms也就是说极限帧率约 40fps实际跑 20~30fps 已经很勉强。SPI 屏走的就是另一条路子。标准 4 线 SPI 用硬件 SPI 跑到 12~20Mbps 很常见1ms 左右就能把 1024 字节端到端传完留给 CPU 的余量多很多。如果你的项目还在选型阶段并且确定要做动态图案我会劝你先考虑 SPI 屏如果手里已经有一块 I2C 屏那就老老实实用差分刷新的思路把数据量降下来后面第 5 章会讲具体做法。注意这 25ms 是纯传输时间还没算主循环里清帧、画图的时间。如果你在主循环里还做其他事情动画帧率会进一步下降。排查闪烁时先把这个账算清楚。3. 最小可复现工程HAL 库点亮 OLED 并跑一个移动方块原理讲完接下来就是能抄的工程。我用的是常见的 0.96 寸 I2C OLED控制芯片是 SSD1306引脚就四根VCC、GND、SCL、SDA。下面所有代码都以 HAL 库为准接线和参数都按我常用的那套配置来。3.1 接线与 I2C 初始化参数先把物理连接固定下来避免后面换引脚改代码。常见的接法是OLED 的 SCL 接 MCU 的 PB6SDA 接 PB7对应 I2C1VCC 接 3.3VGND 接 GND。注意 OLED 这类模块一般不推荐从板上 5V 直接供电除非模块自带稳压。OLED 引脚STM32 引脚说明VCC3.3V供电压必须稳定GNDGND共地必须可靠SCLPB6I2C1_SCLSDAPB7I2C1_SDARESET可接 PB8建议用 GPIO 控制I2C 初始化建议开启内部的 400kHz 快速模式同时把 GPIO 设置成开漏加上拉。这里有个常见误区很多人只初始化 I2C 外设忘记配置引脚为复用开漏结果一直 ACK 失败。/* 初始化 I2C1 外设SCLPB6SDAPB7 */ static void MX_I2C1_Init(void) { hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 400000; hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; 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; HAL_I2C_Init(hi2c1); }逻辑说明ClockSpeed400000表示 400kHz这是大多数 OLED 模块支持的快速模式。DutyCycleI2C_DUTYCYCLE_2是快速模式要求的高低电平占空比设置低速模式下不需要关注。OwnAddress1是 MCU 作为从机时的地址我们只做主机填 0 即可。/* 把 PB6/PB7 配成 I2C 复用开漏并内部上拉 */ void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_I2C1_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_OD; /* 复用开漏I2C 标准接法 */ GPIO_InitStruct.Pull GPIO_PULLUP; /* 模块没上拉时靠内部上拉 */ GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Alternate GPIO_AF4_I2C1; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); }逻辑说明开漏输出是为了让 I2C 总线能正确产生低电平高电平完全靠上拉电阻拉起来。很多 OLED 模块板上已经有上拉电阻这时内部上拉并联影响不大如果你的屏在总线上挂得远建议单独在靠近 MCU 的地方上拉 4.7kΩ。AlternateGPIO_AF4_I2C1表示复用功能编号具体编号要看你的芯片型号不是所有系列都叫 AF4。参数说明如果总线上同时挂了传感器等设备ClockSpeed可能要降到 100000否则某些器件不认。I2C 地址在 7 位模式下最常见的是 0x3C也有一部分模块是 0x3D如果 HAL 一直返回超时先把地址换掉再试不要怀疑接线。注意OLED 的 I2C 地址和寄存器读写地址是两回事。HAL 的HAL_I2C_Master_Transmit里填的是 7 位地址不是你之前看到的 0x78/0x7A。3.2 写命令、写数据、整屏刷新三条基础函数初始化完成后的核心就是三条函数写命令、写数据、刷新整屏。SSD1306 的顺序是先发控制字节判断当前是命令还是数据再发命令字节或数据体。命令模式下控制字节是 0x00数据模式下是 0x40。#define OLED_ADDR 0x3C /* I2C 7 位地址地址不对时改成 0x3D */ #define OLED_CMD_MODE 0x00 /* 控制字节后面跟着的是命令 */ #define OLED_DATA_MODE 0x40 /* 控制字节后面跟着的是显存数据 */ /* 写单条命令 */ void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] {OLED_CMD_MODE, cmd}; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 100); } /* 写一段数据数据长度最大按 64 字节分包发送 */ void OLED_WriteDataBuf(uint8_t *data, uint16_t len) { uint8_t tmp[65]; uint16_t sent 0; tmp[0] OLED_DATA_MODE; while (sent len) { uint16_t chunk (len - sent 64) ? 64 : (len - sent); memcpy(tmp 1, data sent, chunk); HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, tmp, chunk 1, 100); sent chunk; } } /* 整屏刷新先设置页地址再发送该页 128 字节 */ void OLED_Refresh(void) { uint8_t page; for (page 0; page OLED_TOTAL_LINES; page) { OLED_WriteCmd(0xB0 page); /* 设置当前页0xB0 到 0xB7 */ OLED_WriteCmd(0x00); /* 列地址低 4 位从第 0 列开始 */ OLED_WriteCmd(0x10); /* 列地址高 4 位 */ OLED_WriteDataBuf(FB[page], OLED_W); } }逻辑说明OLED_WriteDataBuf分包是因为 HAL 的阻塞发送在长数据时容易触发看门狗或被打断超过 64 字节拆开更稳。每包都以临时数组拼好tmp[0]放数据控制字节后面再跟上真正的显存数据。OLED_Refresh里每换一页都要重新设置页地址和列地址不要只发页地址而省略列地址否则第二页开始就会接着上一页末尾的列继续写屏幕右半部分会出现错位色块。参数说明这里的列地址命令 0x00/0x10 对应的是列地址从 0 开始如果你只想刷新某个局部区域把这两个数字改成目标列的地址值就行。100是 HAL 超时时间单位毫秒I2C 正常时远用不完但如果线上有异常过大会让 UI 卡住建议保持 100。3.3 移动方块动画主循环的刷新节奏基础函数都齐了现在做一个最朴素的动态图案一个 8×8 方块从左往右移动。为了让眼睛能看出是动画而不是瞬移每帧之间需要停顿但停顿时间必须比一帧的传输时间长否则刷新函数还没画完下一帧就开始重写缓冲区屏幕上会出现跳动和撕裂。/* 主循环方块从左边界移动到右边界 */ int main(void) { int x 0; HAL_Init(); MX_GPIO_Init(); MX_I2C1_Init(); OLED_Init(); OLED_ClearBuffer(); while (1) { draw_block(x, 8); OLED_Refresh(); HAL_Delay(60); /* 约 16fps留给 I2C 充足时间 */ x 4; /* 每帧移动 4 个像素 */ if (x OLED_W) { x -8; /* 完全移出右侧后再从左侧进入 */ } } }逻辑说明draw_block画的是以(x, 8)为左上角的方块所以 x 最大可以到 119超过这个值方块就会移出屏幕。我让 x 跑到 128 后直接跳到 -8这样方块会从左侧重新进来产生循环移动效果。HAL_Delay(60)不是随便写的I2C 整屏刷新大约要 25ms画图清屏还要时间帧间隔 60ms 剩下至少一半余量给 CPU避免累积延迟造成的卡顿。参数说明x 4每帧移动 4 个像素60ms 一帧视觉速度约为每秒 66 像素看起来很适中。如果改成x 8运动更快但会显得跳跃改成x 2更平滑但帧率不变时速度会偏慢。要真正平滑应该使用定时器中断里设置的标志位来控制帧率而不要用阻塞 delay后面第 5 章会对这个习惯做进一步扩展。4. 动态图案四大坑花屏、闪烁、越界、复位玄学的排查记录写静态文字时踩的坑少一旦开启动态图案问题全冒出来。我把自己反复遇到的四类问题按“现象 → 原因 → 解决”整理出来排查顺序从屏幕显示层一路到程序内存层基本能覆盖大多数翻车现场。4.1 花屏与乱码页地址和列地址没对齐现象屏幕上能看出内容形状但同一张图被横向错开或者出现一条条雪花状噪声尤其是动画移动时错位区域跟着变化。原因刷新函数里页地址和列地址的发射顺序不对或者漏掉了列地址。SSD1306 内部有一个自动递增的“当前列地址”当你在第 0 页发完 128 个字节后列地址停在 127。如果下一页只发0xB1页地址而不重发列地址 0数据会从第 127 列开始向左倒着写于是花屏就出现了。解决无论采用页面寻址模式还是水平寻址模式我刷屏时都坚持每一页单独设置列地址也就是先发0xB0 page再发 0x00 和 0x10然后才发数据。另外一个隐蔽点是屏幕方向翻转命令0xC8和0xC0。如果你在初始化里开了反向扫描而点坐标计算仍按从上到下排列显示出来的图案会上下颠倒这也算花屏的一种。提示遇到花屏先别改像素算法先检查刷新函数的三条命令。多数情况下问题不在“画图画错”而在“数据送错地址”。4.2 刷新闪烁I2C 忙到主循环都插不上手现象动画看起来隔一段时间抖一下或者物体边缘有轻微残影把 delay 调小反而更严重。原因一帧 25ms 的传输时间摆在那里主循环里如果还做了别的任务比如读取传感器、处理按键实际帧间隔不稳定。当HAL_Delay小于传输时间时刷新尚未完成下一帧的draw_block又开始改FB等于把正在发送的数据改了屏幕自然闪烁。解决第一步先测量真实刷新耗时。简单做法是在刷新函数前后翻转一个 GPIO接示波器或者用逻辑分析仪量一下脉宽。测到 25ms 后把帧间隔设成至少两倍于这个时间再谈优化。如果不够用只能走 SPI 屏或差分刷新而不能强行缩短 delay。动态图案不是靠“刷得越频繁越流畅”而是靠“每一帧都在稳定时间内发完”。这地方最容易犯的错是把所有延迟都归咎于 I2C 速率实际上主循环里清屏和画图也占不少时间。我一般用HAL_GetTick在开头和结尾打点先拿到实际时间再决定是优化绘图还是优化传输。动态显示帧率在 30fps 以上属于比较舒服的状态如果只能到 15fps优先保证不掉帧。4.3 越界和 HardFault1KB 缓冲区同样会压垮栈现象程序运行一段时间后进入 HardFault或者调试时停在memcpy有时候还会随机复位。原因最常见的是把FB数组直接定义在函数内部例如void draw_frame(void) { uint8_t buf[1024]; /* 1KB 局部数组很容易压爆任务栈 */ ... }在带 RTOS 的任务里默认栈往往只有几百字节1KB 局部数组一压就溢出。另一个原因是画线、画圆的函数没做边界剪裁比如方块移动到屏幕右侧时px_on(130, 8)直接把FB[y3][130]写出去索引越界。解决第一FB永远定义为全局或静态变量不要放进函数栈。第二所有写入FB的操作都必须经过边界判断前面的px_on已经做了x OLED_W和y OLED_H的剪裁。如果你用 memcpy 直接把整张图片贴进缓冲区也要先计算目标区域是否越过边界再决定复制多少字节。第三宏定义列宽时不要手写数字建议用sizeof(FB[0])推出每页长度这样换屏幕尺寸时不容易留下越界隐患。4.4 冷启动不显示、按复位就好RESET 引脚悬空现象上电瞬间屏幕经常黑屏随便按一下开发板复位键或者重新插拔电源后又能正常显示一旦跑起来动画没问题特别像玄学。原因SSD1306 的复位引脚要求在上电后保持一段时间的低电平之后拉高才能进入正常状态。如果你的模块 RESET 引脚悬空上电时序完全取决于模块内部电阻和电容成功失败各半。冷启动时 MCU 跑得比 OLED 快初始化命令可能在 OLED 还没准备好时就发出了于是命令被丢弃。解决把 RESET 接到一个普通 GPIO 上在OLED_Init之前执行一次手动复位序列。注意拉低时间要给足我一般拉低 10ms 再拉高然后再等 10ms 才开始发初始化命令。#define OLED_RST_PIN GPIO_PIN_8 void OLED_Reset(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin OLED_RST_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOB, OLED_RST_PIN, GPIO_PIN_RESET); HAL_Delay(10); HAL_GPIO_WritePin(GPIOB, OLED_RST_PIN, GPIO_PIN_SET); HAL_Delay(10); }逻辑说明这个函数把 PB8 配置成推挽输出先拉低 10ms 再拉高完成后系统等待 10ms保证 OLED 内部电压稳定。GPIO_SPEED_FREQ_LOW对复位这种低频信号足够还能减少引脚切换噪声。在OLED_Init第一行调用OLED_Reset()冷启动的显示成功率会从“看运气”变成“必亮”。注意如果你用的是集成模块RESET 引脚可能已经由板上电路处理过这时不接也能点亮。但只要你遇到冷启动黑屏、热复位正常的情况第一件事就是查 RESET 引脚。5. 用差分刷新把有效帧率拉高最后一个我反复用的习惯前面一直强调 I2C 整屏刷新太慢现在给出我真正在用的解决办法差分刷新。基本思路很直白——不要每次都把 1024 字节整屏送出去而是比较当前帧和上一帧只发送变化的部分。对于动画里的一小个物体来说变化的区域往往只有 16×16 像素换算成差分数据只有两三个字节加几条命令传输时间可以缩小到原来的十分之一。static uint8_t LAST_FB[OLED_TOTAL_LINES][OLED_W]; /* 只刷新 FB 和 LAST_FB 不同的区域并把 LAST_FB 同步成当前帧 */ void OLED_RefreshDiff(void) { uint8_t page_min OLED_TOTAL_LINES, page_max 0; uint8_t col_min OLED_W, col_max 0; uint8_t p, x; for (p 0; p OLED_TOTAL_LINES; p) { for (x 0; x OLED_W; x) { if (FB[p][x] ! LAST_FB[p][x]) { if (x col_min) col_min x; if (x col_max) col_max x; if (p page_min) page_min p; if (p page_max) page_max p; } } } /* 没有任何变化直接返回省一次 I2C 通信 */ if (col_min col_max) return; /* 只把变化区域对应的页、列区间发出去 */ for (p page_min; p page_max; p) { OLED_WriteCmd(0xB0 p); OLED_WriteCmd(0x00 (col_min 0x0F)); OLED_WriteCmd(0x10 (col_min 4)); OLED_WriteDataBuf(FB[p][col_min], col_max - col_min 1); } memcpy(LAST_FB, FB, sizeof(FB)); }逻辑说明这段代码先扫一遍FB和LAST_FB记录下所有变化点所在的页范围和列范围然后只在最小的矩形区域内重新发数据。由于动画物体通常只占屏幕一小部分实际搬运的数据量比整屏刷新小很多。memcpy最后把当前帧复制成旧帧下一次比较才有参照物。参数说明使用这份代码要多占 1KB 的LAST_FB在 RAM 本就紧张的型号上要先确认剩余空间。如果动画物体不止一个且运动分散在屏幕两侧差分区域会被拉得很大收益下降。我习惯在工程里同时保留OLED_Refresh和OLED_RefreshDiff初始化阶段整屏刷一次动画阶段切到差分刷新这样静态区域不会因为长期不刷新而产生残留。我自己的工程习惯是任何动画上线前先让它跑 30 分钟不重启每隔一段时间给屏幕上放一块静态文字区域确认静态部分没有被动画区域的刷新干扰。这个习惯帮我抓到过好几处因列地址计算错误导致的边缘残影。以前我也迷信“把屏刷得越快越高级”后来踩多了才发现动态图案的功夫一半在显存布局一半在刷新取舍芯片主频反而是最后才需要考虑的事。希望这篇能把你的动画项目从“能显示”带到“敢连续跑起来”的阶段少走我走过的弯路。本文还有配套的精品资源点击获取