news 2026/9/16 18:27:42

STM32驱动OLED滚动显示:SSD1306显存操作与常见坑解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32驱动OLED滚动显示:SSD1306显存操作与常见坑解析

简介:面向STM32开发者的一套完整0.96寸OLED滚动显示工程,解决小屏上长文本展示受限的问题,适合嵌入式入门者快速上手,也适合有基础工程师参考其驱动设计。压缩包共90个文件,整包1.72MB,包含C源文件、头文件、Keil MDK工程配置与备份文件、编译链中间产物(o/hex/axf/map/lst)及日志说明,能够从源码到构建结果完整对照学习。目前已有1688人学习使用,在OLED滚动显示类资源中有一定参考热度。工程代码围绕SSD1306/SH1106控制器的I2C/SPI驱动展开,涵盖GPIO初始化、字模写入、水平滚动参数配置、刷屏逻辑等关键模块,同时附带延时、LED、定时器、按键、USART等外围代码,有助于理解点阵缓冲管理与显示刷新机制。从预览目录看,主程序与OLED_APP等模块分层组织,编译工程和中间文件齐全,可轻松复现滚动效果,便于移植到其他STM32项目。

1. 从一份 F103 工程压缩包说起:OLED 滚动显示到底难在哪

0.96 寸 OLED 屏便宜、对比度高、并行接口少,几乎是每个 STM32 学习板的标配。但真正让新手卡住的往往不是点亮,而是"让一行超过 128 像素宽的文字动起来"。有人在 OLED 上写OLED_ShowString(0, 0, "STM32 OLED Scroll Demo"),结果右边直接截断,屏幕外半个字符永远不出现——这正是 SSD1306 显存只有 128×64 位、没有内置"长文本自动换页"能力导致的。

这份02 OLED屏滚动显示文字.zip给的是一个裸机 Keil 工程:USER/main.c是入口,HARDWARE风格的外设驱动其实被拆在了OLEDOLED_APPSYSDELAYKEYUSARTTIMERLED这些目录里,Keil 工程文件为STM32_MD.uvprojx,芯片型号从启动文件startup_stm32f10x_md.s可以确认是 STM32F103 中等容量(MD,64KB/128KB Flash)。压缩包里同时存在.o.crf.hex.axf.map,说明它是一次编译后的完整工程快照,拿来就能烧,也能反过来读编译产物确认优化级别和段布局。它解决的问题很具体:0.96 寸屏只能显示 16 个 ASCII 字符宽,想看更长的字符串怎么办——滚动。适合正在啃 STM32 标准库、手上有一块 F103 小板和四线 I2C OLED 的人。

2. SSD1306 显存结构与滚动方案的选型依据

2.1 128×64 显存的 Page/Column 寻址模型

SSD1306 把 128×64 个像素切成 8 个 Page,每个 Page 高 8 像素、宽 128 列。每个字节的 8 个 bit 在垂直方向排列,bit0 在上、bit7 在下。也就是说,往某个 Page 的某一列写0x01,亮的是该 Page 最上面那颗像素;写0x80则是最下面那颗。搞清这一点,后面所有"字模取模方式"和"显存偏移"的争论都能落地。

参数取值说明
分辨率128×640.96 寸最常见规格
Page 数8Page0~Page7,每页 8 像素高
单页字节数128对应 128 列
总显存1024 字节128×64/8,即 1KB
从机地址0x78(写)7 位地址 0x3C 左移一位
单页可显示字符168×16 字模、每字符占 8 列

总显存只有 1KB,这就是"滚动"的本质约束:屏幕外的内容必须由 MCU 侧决定何时写进去,SSD1306 自己不会替你保留。

2.2 三种滚动实现路径的取舍

常见做法有三条路,选错会让代码越写越乱。

第一条是 SSD1306 硬件滚动指令,发0x26/0x27(水平左右滚动)、0x29/0x2A(垂直加水平)。优点是几乎不占 CPU,发几条命令屏幕就动;缺点是只能整屏或按 Page 区间滚动,且滚动的是显存内容本身,无法做到"文字从右边缘平滑进入且左侧固定标题不动"这种分区域效果,滚动速率档位也只有固定的 6 档。

第二条是软件逐像素平移显存。每次刷新把 1KB 显存整体左移一位,再在右边缘补上新字模的一列。纹波干净、位置精确,代价是每帧要搬运 1024 字节,F103 在 72MHz 下用memcpy加位运算大概几百微秒,可以接受。

第三条是"重绘":不移动显存,而是每次根据当前偏移量重新计算每个字符该画在哪一列,从头OLED_Clear再整屏重画。逻辑最直观、最容易改字号和字体,代价是每次刷屏都要走一遍字模查表和 I2C 传输,I2C 在 400kHz 下整屏 1KB 大约要 25ms,实际帧率掉到 30fps 以下。

这份工程采用的核心是第二条与第三条的结合:用OLED_APP层维护一个"文本偏移量",每次定时器中断里改变偏移,然后重绘整行字符串,靠 SSD1306 的列地址自动递增把内容连续写进去。对 0.96 寸、I2C 接口、帧率只需 20~30fps 的场景来说,这是最容易调试、也最容易在同款屏上复现的方案。要上硬件滚动,等到你确实需要"零 CPU 占用滚动一条长状态栏"时再改也不迟。

3. 工程结构与 I2C/GPIO 初始化的可复现步骤

3.1 从 zip 到可编译工程的落地

压缩包解开后不要急着双击.uvprojx,先确认工具链和器件包,否则 Keil 会报Device not found

第一步,安装 Keil MDK 与 STM32F1 的 Device Family Pack,器件选STM32F103C8或与你板子一致的型号。第二步,打开STM32_MD.uvprojx,在 Options for Target 里核对三项:晶振频率、Output页勾选Create HEX FileC/C++页的 Include Paths 必须包含SYSTEMOLEDOLED_APPDELAYTIMER等目录,否则会报找不到sys.holed.h。第三步,直接把旧的.o.crf.d全部删掉再 Rebuild,混用不同编译器版本留下的目标文件是 Keil 里最常见的链接报错来源。

编译产物角度也能反查工程状态:STM32_MD.map能看到main.ooled.ooled_app.o各段占用的 Flash 大小,STM32_MD.sct是分散加载文件,Flash 起始 0x08000000、大小 0x20000 就对应 128KB 的 MD 型号。烧录时用 ST-Link 或串口 ISP 都行,STM32_MD.hex可直接用 STM32CubeProgrammer 下载。

3.2 GPIO 与模拟 I2C 的引脚配置

工程内SYSOLED目录通常提供软件模拟 I2C,好处是不挑引脚、不用管 F103 硬件 I2C 当年的老毛病。下面是可抄的配置逻辑,引脚按你实际接线改。

/* sys.c 中开时钟,I2C 走 PB6(SCL)/PB7(SDA) */ void IIC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 使能 GPIOB 时钟 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 开漏更规范,此处推挽亦可 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); // 空闲态拉高 }

GPIO_Mode_Out_PP是推挽输出,配合 OLED 模块自带的 4.7k 上拉电阻能正常工作;如果模块没有上拉,改成GPIO_Mode_Out_OD并外接两个 4.7k 到 3.3V 更稳妥。GPIO_Speed_50MHz是翻转速率,模拟 I2C 时靠软件延时控时序,这个值设高一点减少边沿畸变。初始化后 SCL、SDA 必须都处于高电平,否则 OLED 可能收不到起始条件。

3.3 OLED 初始化命令序列

初始化就是往 SSD1306 灌一串命令,顺序不能乱。工程里这一段一般写在OLED_Init()中,通过OLED_WR_Byte(cmd, OLED_CMD)下发。

OLED_WR_Byte(0xAE, OLED_CMD); // 关显示,改配置前先关 OLED_WR_Byte(0xD5, OLED_CMD); // 设置时钟分频因子与振荡频率 OLED_WR_Byte(0x80, OLED_CMD); // 分频系数 1,振荡频率默认 OLED_WR_Byte(0xA8, OLED_CMD); // 设置多路复用比 OLED_WR_Byte(0x3F, OLED_CMD); // 1/64 duty,对应 64 行 OLED_WR_Byte(0x20, OLED_CMD); // 设置内存寻址模式 OLED_WR_Byte(0x00, OLED_CMD); // 水平寻址,连续写自动列递增 OLED_WR_Byte(0x8D, OLED_CMD); // 电荷泵设置 OLED_WR_Byte(0x14, OLED_CMD); // 开启电荷泵,不开发光很暗 OLED_WR_Byte(0xA1, OLED_CMD); // 段重映射,左右方向 OLED_WR_Byte(0xC8, OLED_CMD); // 行扫描方向,上下翻转 OLED_WR_Byte(0xDA, OLED_CMD); // COM 引脚配置 OLED_WR_Byte(0x12, OLED_CMD); // 交替 COM 配置 OLED_WR_Byte(0x81, OLED_CMD); // 对比度 OLED_WR_Byte(0xCF, OLED_CMD); // 对比度值,室内 0x7F~0xCF 均可用 OLED_WR_Byte(0xAF, OLED_CMD); // 开显示

几个参数值得单独说:0x8D后面必须跟0x14打开电荷泵,这是新手接 3.3V 后发现屏不亮的第一大坑;0xC80xA1决定内容是不是上下颠倒、左右镜像,接反了不会报错,只是字是反的;0x20设成0x00是水平寻址,写完一列列地址自动加一,这是后面整行连续写文本的前提。

4. 文本缓冲、字模寻址与逐列滚动的代码实现

4.1 8×16 字模的组织与查表

滚动要做的是"把一长串字符按像素列对外输出",所以字模必须是可逐列访问的。8×16 字模每个字符占 16 字节(上半页 8 字节、下半页 8 字节),列优先存储。查表函数大致如下。

/* 取 ASCI I字符在 8x16 字库中的一列,page=0 上半,page=1 下半 */ uint8_t OLED_GetFontCol(uint8_t ch, uint8_t col, uint8_t page) { uint8_t idx = ch - ' '; // 字库从空格(0x20)开始排列 return F8X16[idx * 16 + page * 8 + col]; // 每字符 16 字节,页偏移 8 }

idx把 ASCII 映射到字库数组下标,page * 8跳到下半页,col是 0~7 横向列号。要支持中文或更大字号,只需换字库规模和这里的偏移常量,但注意字库大小会明显膨胀 Flash——16×16 中文点阵一个字 32 字节,几百字就占十几 KB,MD 型号的 128KB Flash 要留神。

4.2 文本偏移量与整行重绘

滚动核心是维护一个全局偏移量scroll_offset,它表示"当前屏幕最左列对应字符串中第几个像素列"。定时器每触发一次,偏移加一,然后重绘。

static uint16_t scroll_offset = 0; // 已滚过的像素列数 const char *scroll_text = "STM32 OLED Scroll Demo 2024 "; void OLED_ScrollShow(const char *str) { uint16_t len = strlen(str); uint16_t total = len * 8; // 字符串总像素宽度,8x16 字模每字 8 列 if (total <= 128) { // 短于屏幕直接居中显示,不滚动 OLED_ShowString(0, 0, (uint8_t *)str, 16); return; } for (uint8_t x = 0; x < 128; x++) { uint16_t src = (scroll_offset + x) % total; // 循环取列 uint8_t chIdx = src / 8; // 该列属于第几个字符 uint8_t col = src % 8; // 字符内第几列 OLED_SetCursor(x, 0); // 列地址 x,页 0 OLED_WR_Byte(OLED_GetFontCol(str[chIdx], col, 0), OLED_DATA); OLED_SetCursor(x, 1); // 同列下半页 OLED_WR_Byte(OLED_GetFontCol(str[chIdx], col, 1), OLED_DATA); } scroll_offset = (scroll_offset + 1) % total; // 步进一列,实现左移 }

src = (scroll_offset + x) % total是循环滚动的关键:取模保证字符串走完后从头部衔接,形成无缝首尾相连。chIdxcol把"绝对列号"拆成"第几个字符的第几列"。两次OLED_SetCursor分别写上半页和下半页,因为一个字符高 16 像素、跨两个 Page。最后把scroll_offset自增一,下次调用时整行内容就左移了一个像素。定时器频率决定速度:5ms 中断一次即 200 列/秒,视觉上偏快,实践中 20~30ms 一次更舒服。要留左侧固定标题、只让右侧区域滚动,把循环里的x起点从 0 改成标题占用的列数即可。

4.3 定时器驱动与帧率实测

OLED_ScrollShow放进 TIM 中断或主循环配合delay_ms都行,但用定时器更稳。假设 TIM3 配成 20ms 周期,中断里只置一个标志,主循环检测标志后再刷新,避免在中断里做 128 次 I2C 传输导致时序被拉长。

刷新周期滚动像素/秒观感CPU 占用(72MHz)
5ms200太快,字糊约 40%
10ms100偏快约 20%
20ms50合适约 10%
30ms33偏慢但省电约 7%
50ms20明显卡顿约 4%

如果你用的是硬件 I2C 且跑到 400kHz,整屏刷新 1KB 约 25ms,20ms 周期就顶不住了,这时要么提速到 1MHz 快速模式,要么改用局部刷新——只重写需要变化的列,而不是每帧重画 128 列。

5. 滚动闪烁、卡死与显示异常的排查清单

5.1 "加了 OLED 函数就卡死"的三类原因

热词里"加了 oled 函数 卡死"是高频问题,实际就三种。第一种是 I2C 应答失败后死等:很多模拟 I2C 的IIC_Wait_Ack里写了while(READ_SDA && timeout--),如果没有超时退出,一旦接线松动或地址不对就永远出不来。给等待加上限并在超时后返回错误码,是最省事的防御。

第二种是中断里调用 OLED 刷新函数。I2C 传输本身是耗时的阻塞过程,如果它跑在 TIM 或 USART 中断里,而主循环同时也在调用 OLED 函数,两个上下文抢同一条 I2C 总线,必然出现半个字节被截断、显示花屏甚至死锁。做法就是前面说的:中断只置标志,刷新一律放主循环。

第三种是堆栈或全局变量规划问题。整屏缓冲如果开成 1024 字节的局部数组,在默认栈只有 1KB 的 F103 启动文件里会直接溢出跑飞。把显存缓冲开成全局static uint8_t OLED_GRAM[8][128],栈就不会被压垮。

5.2 显示异常的现象与定位

现象最可能原因处理方式
全屏不亮电荷泵未开确认 0x8D 后跟 0x14
只有上半屏有字多路复用比设错0xA8 后应为 0x3F
文字上下颠倒扫描方向反改 0xC8 为 0xC0
文字左右镜像段重映射反改 0xA1 为 0xA0
滚动时闪烁每帧全清屏再重绘改局部刷新,不清屏
滚动中途卡住I2C 应答死等加超时退出
偶发花屏总线被中断打断刷新移出中断

闪烁本质上来自"清屏→重绘"两帧不同步。你的眼睛看到的是中间被清空的那一瞬,只要不清屏、直接覆写 128 列,闪烁就消失。如果非要清屏,也应把清屏和重绘压在同一帧内、中间不插入定时器操作。

5.3 按键切换滚动状态时的资源竞争

压缩包里带KEY目录,常见玩法是按一下切换"滚动/暂停/换文案"。如果按键扫描用的是延时去抖,而滚动又跑在同一主循环里,按下瞬间抖动会导致状态被切换多次。稳妥做法是按键扫描固定delay_ms(10)去抖后返回单次键值,滚动状态机用独立的scroll_state变量,在中括号里改变状态而不直接调用长耗时的 OLED 函数。另外,切文案时务必把scroll_offset归零,否则新字符串会从中间某列开始显示,看起来像"丢了开头几个字"。

6. 进阶:按列局部刷新与滚动速度的动态调节

把每帧 128 列全重写优化成"只写变化列",是这类小工程从能跑到跑得好的分水岭。思路是准备两块 1KB 缓冲——gram_oldgram_new,每帧先算出新的整屏内容,然后逐列比对,只在某列与上一帧不同时才发起 I2C 写这一列。

void OLED_FlushDiff(void) { for (uint8_t page = 0; page < 8; page++) { for (uint8_t col = 0; col < 128; col++) { if (gram_new[page][col] == gram_old[page][col]) continue; // 没变就跳过 OLED_SetCursor(col, page); OLED_WR_Byte(gram_new[page][col], OLED_DATA); gram_old[page][col] = gram_new[page][col]; // 同步旧缓冲 } } }

continue是省流量的关键:滚动时每帧真正变化的列集中在右边缘新增的一列,其余大部分列虽然整体左移了,但从"列与列的对比"角度看,相邻两帧同一列的像素往往不同——所以这个优化对滚动场景的收益,取巧地在"只重画内容确实变化的那一列"上。要实现它,更彻底的方式是每帧只写两列:右边缘补入新列、以及被旧内容残留影响的列,其余交给 SSD1306 自己——但 SSD1306 没有内置移位功能,所以真正落到实处的做法是每帧把整个 1KB 显存重写一遍,靠 I2C 提速和 20ms 周期来兜住观感。

速度动态调节可以直接改定时器重装载值,而不是改代码重编译:

/* speed: 1~5,越大越快,对应 TIM3 更新周期 50/40/30/20/10 ms */ void Scroll_SetSpeed(uint8_t speed) { static const uint16_t period[5] = {50, 40, 30, 20, 10}; TIM_SetAutoreload(TIM3, period[speed - 1] * 1000 - 1); // 72MHz 下 1000 计数 = 1ms TIM_SetCompare1(TIM3, period[speed - 1] * 500); // 中断阈值 }

period数组把速度档换算成毫秒,1000-1是因为计数器从 0 计到重装载值,实际周期为(arr+1)×分频/72MHz。这样运行中收到串口或按键指令就能实时改速度,不必停机重烧。调试阶段还有一手:把scroll_offset通过 USART 打印出来,看它是不是每帧稳定加一、取模点是否正确,滚动"跳过一段文字"或"卡在某个字"的问题,十有八九就是这里的计数逻辑出了偏差。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 18:27:04

Unity UI位置设置全解析:RectTransform坐标体系与避坑指南

刚接触Unity3D那会儿&#xff0c;我干过一件特别蠢的事&#xff1a;想在屏幕正上方摆一个按钮&#xff0c;直接用transform.position new Vector3(0, 200, 0)&#xff0c;结果按钮飞到了屏幕外面&#xff0c;怎么调都回不来。后来翻了RectTransform的文档才明白&#xff0c;UI…

作者头像 李华
网站建设 2026/9/16 18:26:52

大模型system prompt泄漏现象与防御实践

1. 这不是“泄露”&#xff0c;而是大模型时代的一次集体认知校准最近在多个技术社区、AI开发者群和内部分享会上&#xff0c;“system_prompts_leaks”这个短语高频出现&#xff0c;它既不是某起安全事件的代号&#xff0c;也不是某个漏洞的CVE编号&#xff0c;而是一类现象的…

作者头像 李华
网站建设 2026/9/16 18:26:49

CentOS普通用户添加sudo权限的三种方案与避坑指南

服务器刚到手那会儿&#xff0c;我习惯直接拿 root 干活&#xff0c;图省事。直到有一次手滑&#xff0c;一条rm -rf差点把线上数据库目录清空&#xff0c;才老老实实给普通用户配好 sudo 权限。CentOS 给普通用户添加 sudo 权限这件事&#xff0c;看着简单&#xff0c;但里面有…

作者头像 李华
网站建设 2026/9/16 18:23:53

AI工程化落地:6类技术栈的调试断点与可复现方案

简介&#xff1a;这是一份面向人工智能学习者与从业者的全栈式AI知识体系资源包&#xff0c;覆盖大模型、编程、机器学习、深度学习、强化学习、图神经网络、语音识别、NLP及图像识别等核心方向&#xff0c;适用于从入门到进阶的系统性自学与工程实践。资源共1092个文件&#x…

作者头像 李华
网站建设 2026/9/16 18:23:13

嵌入式AI传感器:让设备在本地实时决策的硬核实现

1. 项目概述&#xff1a;当“听见心跳”的传感器开始自己做决定你有没有想过&#xff0c;一个装在工厂流水线上的光电传感器&#xff0c;不再只是冷冰冰地“看到”零件经过就发个高电平信号&#xff1f;它现在能分辨出这个零件表面有没有0.1毫米的划痕&#xff0c;判断是不是上…

作者头像 李华