资讯动态

STM32 I2C接口驱动SSD1306 OLED显示屏:原理到实践

发布时间:2026/10/15 11:02:48 来源:尧图企业网站定制
简介面向STM32嵌入式开发者的IIC接口OLED显示程序工程基于标准外设库完成OLED屏的驱动与显示适合正在学习I2C通信协议、OLED时序控制或需要快速搭建显示模块的入门及进阶开发者。工程包内含130个文件主要为h头文件与c源文件并配套Keil的uvprojx工程文件、hex烧录文件、s启动文件、sct链接脚本及bat清理脚本等整体仅383KB结构清晰方便直接打开编译或烧录验证。除OLED驱动外还集成了STM32F10x系列常用的定时器、Flash、RCC、ADC、I2C、CAN等外设驱动代码可作参考模板多次复用OLED具备自发光、高对比度、低功耗等特性IIC接口仅需两根信号线即可实现显示适合在资源受限的小系统中作为人机交互方案。已有2040人学习下载通过该工程既可查看IIC时序、初始化配置及显示函数实现也能借助hex文件快速对比硬件效果减少重复移植工作量。1. 这个项目到底在解决什么两根线、一颗芯片和一块屏见过不少同行在拿到“STM32 基于IIC的OLED显示程序”这类需求时第一反应是找例程、改引脚、烧录结果屏幕死活不亮最后发现是 I2C 地址 0x3C 和 0x3D 一字之差。IIC 和 I2C 是同一总线的两种写法这个项目往深了说就是两件事把 I2C 的时序跑对把 SSD1306 这颗驱动芯片的显存协议理清。它适合任何想做小体积显示界面的场景DIY 温湿度计、音频电平表、工装调试信息屏I2C OLED 都是引脚占用最少、上手最快的方案。下面把原理、最小工程、参数设置、排障套路和刷新优化一次讲透照着做能点亮点亮后能调稳。2. I2C 总线与 SSD1306 的通信模型从两根线到一块屏2.1 I2C 与 OLED 之间到底在传什么控制字节与数据字节的分工I2C OLED 模块的硬件结构很简单SCL 走时钟SDA 走数据两根线串联起单片机和 SSD1306。SSD1306 内部自带一块 1024 字节的显示 RAM单片机只要把显示内容写进这颗 RAM芯片就会自己扫描并驱动 OLED 像素MCU 不需要持续刷新屏幕。这正是 I2C 速度不高也够用的根本原因。I2C 每次传输的基本帧是起始位、7 位从机地址加读写位、ACK、数据字节、停止位。SSD1306 在 I2C 协议上多加了一个“控制字节”用来告诉芯片后面跟着的字节到底是命令还是显示数据。这个控制字节是 I2C OLED 和普通 I2C 外设最大的区别也是新手最容易踩坑的地方。控制字节含义典型使用0x00后随字节为命令初始化、设置页地址、设置对比度0x40后随字节为显示数据向显示 RAM 写入像素数据一个常见错误是把命令用数据通道发出去SSD1306 会把它当成像素数据写进显存表现就是“有波形、屏幕乱码”。如果你准备好的数据包是[地址][0x40][内容]那就是数据传输如果是[地址][0x00][内容]那就是命令传输。两者在逻辑分析仪上看第二字节就能区分。2.2 SSD1306 的显存结构与页寻址128x64 不是一整块SSD1306 的 GRAM 是 128 列乘 64 行按 8 个像素行分成一页一共 8 页。每页有 128 字节每个字节的 8 个 bit 正好对应一列上的 8 个像素。所以显存布局不是“一行一行”而是“一页一页”页地址模式下的常用命令是0xB0 ~ 0xB7选择第 0 到第 7 页列地址是0x00 ~ 0x7F。在这个模式下写入一个字节后列地址会自动加一但写到页尾会回卷到本页开头不会自动跳到下一页。这就是为什么很多例程里每写一页前都要重新设置页地址和列地址。另一个可选模式是水平寻址模式连续写 1024 字节会自动跨页代码短一些但出问题时更难定位。我一般默认用页寻址模式逻辑直观调试时也容易对着显存计算位置。画点时的坐标换算公式是页号 y / 8页内偏移 y % 8目标索引 页号 * 128 x。这个公式后面写驱动函数时每一步都会用到建议先记下来。2.3 选型与接线0.96 寸模块的常见差异与电平适配市面上 0.96 寸 I2C OLED 绝大多数控制芯片是 SSD1306但也有 SH1106、SSD1315 等兼容或替代芯片。SH1106 的页宽度是 132 字节列地址映射和 SSD1306 不一样直接套 SSD1306 例程会出现错位或花屏买模块时最好确认芯片型号再动手。显示分辨率也会影响初始化参数128x64 和 128x32 的屏多路复用比命令0xA8的值不同前者是0xA8 0x3F后者是0xA8 0x1F。接线方面常见的 4 针模块只需要接电源和两根 I2C 线。模块一般自带 I2C 上拉电阻如果买到的模块没有上拉需要自己加 4.7k 电阻到 VCC。OLED 引脚方向接 STM32VCC电源输入3.3VGND地GNDSCLI2C 时钟任选 GPIO开漏输出SDAI2C 数据任选 GPIO开漏输出电平问题也要提前确认模块上拉电阻接的是模块 VCC如果 VCC 是 5V总线高电平就是 5VSTM32 引脚不一定扛得住。最稳的做法是模块和单片机都用 3.3V 供电。还有一点模块地址由 SA0 引脚的电平决定默认 0x3C部分模块焊了跳线后是 0x3D读一下模块背面丝印就能确认。3. 最小驱动工程I2C 底层、初始化序列与显示函数3.1 底层 I2C硬件 I2C 还是模拟 I2C 的选择STM32 的硬件 I2C 外设功能完善但配套的事件标志和错误处理比较绕很多开发者调不通时会怀疑人生。我的习惯是新工程先用 GPIO 模拟 I2C 跑通显示逻辑后续有性能需求再切换到硬件 I2C。模拟 I2C 代码量小、时序完全可控、出错时也好排查作为第一版驱动非常合适。模拟 I2C 的核心是实现起始、停止、发送字节三个操作。注意 SCL 和 SDA 都要配置为开漏输出并外接上拉电阻这样 SDA 置 1 时总线被上拉释放才能正确读取从机的 ACK。下面这段代码是模拟 I2C 的骨架#define OLED_SCL_PORT GPIOB #define OLED_SCL_PIN GPIO_PIN_8 #define OLED_SDA_PORT GPIOB #define OLED_SDA_PIN GPIO_PIN_9 static void I2C_Delay(void) { // 在 72MHz 主频下空循环约 5 次约 1us for (volatile uint32_t i 0; i 5; i); } static void I2C_Start(void) { HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); I2C_Delay(); HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_RESET); // SCL 高时 SDA 拉低 I2C_Delay(); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_RESET); } static void I2C_Stop(void) { HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); I2C_Delay(); HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_SET); // SCL 高时 SDA 拉高 } static uint8_t I2C_SendByte(uint8_t dat) { for (uint8_t i 0; i 8; i) { uint8_t bit (dat 0x80) ? GPIO_PIN_SET : GPIO_PIN_RESET; HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, bit); dat 1; I2C_Delay(); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); I2C_Delay(); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_RESET); } // 释放 SDA读取从机 ACK HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_SET); I2C_Delay(); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); I2C_Delay(); uint8_t ack (HAL_GPIO_ReadPin(OLED_SDA_PORT, OLED_SDA_PIN) GPIO_PIN_RESET) ? 1 : 0; HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_RESET); return ack; }这段代码里I2C_Delay控制时钟频率空循环次数决定 SCL 高低电平的持续时间。72MHz 主频下循环 5 次大约产生 1us 级延时实际频率会在 200kHz 上下属于 I2C 快速模式范围。如果屏幕在长线缆下不稳定把循环次数加大到 10~20 次降速到 100kHz 标准模式稳定性会好很多。I2C_SendByte的返回值是从机 ACK发送完后检查它是否为 1就能确认设备是否在线。这个函数后面会被 OLED 驱动反复调用也是排查问题的关键锚点。3.2 写命令与写数据两个函数的本质区别OLED 驱动层只要封装两个函数写命令和写数据。它们的区别只在于控制字节是 0x00 还是 0x40。这里我推荐一次传输发两个字节控制字节和内容拼在一个包里减少 I2C 起始/停止的次数比逐字节调用更高效。#define OLED_ADDR 0x3C // 模块实际地址部分模块为 0x3D static void OLED_WriteCmd(uint8_t cmd) { I2C_Start(); I2C_SendByte(OLED_ADDR 1); // 7 位地址左移一位再加写位 0 I2C_SendByte(0x00); // 控制字节命令 I2C_SendByte(cmd); I2C_Stop(); } static void OLED_WriteData(uint8_t dat) { I2C_Start(); I2C_SendByte(OLED_ADDR 1); I2C_SendByte(0x40); // 控制字节数据 I2C_SendByte(dat); I2C_Stop(); }注意这里OLED_ADDR 1是把 7 位地址 0x3C 左移一位再加上写位 0变成 8 位地址 0x78。如果你用的 HAL 库硬件 I2C 接口有些版本的HAL_I2C_Master_Transmit接收的是 8 位地址有些版本接收 7 位地址移植时先查一下当前 HAL 版本的实现这是最常见的移植报错点。两个函数封装好后OLED 的所有操作都是“先发命令、再发数据”的组合。初始化序列是一连串写命令显示内容是一连串写数据命令和数据不能混在一个包里否则芯片会把命令解释成像素数据屏幕立刻花掉。3.3 初始化序列每个命令在干什么SSD1306 的初始化序列在网上有各种版本命令大同小异但顺序很重要。标准做法是先关闭显示、配置基本参数、开启电荷泵、设置寻址模式、最后开启显示。下面这段数组是 128x64 I2C 屏的常用初始化命令序列static const uint8_t ssd1306_init_cmds[] { 0xAE, // 关闭显示配置期间不要点亮屏幕 0xD5, 0x80, // 显示时钟分频与振荡器频率 0xA8, 0x3F, // 多路复用比 1/64对应 128x64 0xD3, 0x00, // 显示偏移 0 0x40, // 起始行 0 0x8D, 0x14, // 开启内部电荷泵这是点亮的关键 0x20, 0x02, // 页寻址模式 0xA1, // 段重映射列 0 映射到 SEG127 0xC8, // COM 扫描方向上下不颠倒 0xDA, 0x12, // COM 引脚硬件配置128x64 常用值 0x81, 0xCF, // 对比度默认 0x7F 也可视效果调整 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH 倍率 0xA4, // 显示内容来自 GRAM 0xA6, // 正常显示不反显 0xAF // 开启显示 }; for (uint32_t i 0; i sizeof(ssd1306_init_cmds); i) { OLED_WriteCmd(ssd1306_init_cmds[i]); }逐个解释关键参数0x8D 0x14是内部电荷泵开关如果漏掉这一条屏幕可能整体发暗或完全黑屏这是“初始化后不亮”的高频原因。0xA8 0x3F必须和屏幕分辨率匹配用了 128x32 屏就要改成0xA8 0x1F。0x20 0x02选择了页寻址模式如果你后面想用水平寻址模式这里改成0x20 0x00否则刷新函数里的页跳转逻辑要一起调整。0xA1和0xC8组合决定画面左右、上下方向如果显示内容镜像或倒置改这两个命令比改坐标代码更省事。初始化完成后要立即清屏并刷新一次否则显存里是随机值屏幕上可能显示花点。清屏就是把帧缓冲数组全部写 0然后调用一次刷新函数。3.4 刷新与显示画点、画字、显示中文字符串清屏和刷新依赖一个全局帧缓冲数组。SSD1306 的显存大小是 128 乘 64 除以 8等于 1024 字节定义一个uint8_t fb[1024]用页号和列号索引。画点函数只操作这个数组不直接和 I2C 打交道uint8_t fb[1024]; // 8 页 * 128 列 void OLED_SetPixel(int16_t x, int16_t y, uint8_t on) { if (x 0 || x 127 || y 0 || y 63) { return; } uint16_t idx (y / 8) * 128 x; uint8_t bit 1 (y % 8); if (on) { fb[idx] | bit; } else { fb[idx] ~bit; } }刷新函数是整个显示链路的核心。它按页寻址模式逐页发送每页先设置页地址和列地址再连续写 128 个字节void OLED_Refresh(void) { for (uint8_t page 0; page 8; page) { OLED_WriteCmd(0xB0 page); // 选择第 page 页 OLED_WriteCmd(0x00); // 列地址低 4 位 OLED_WriteCmd(0x10); // 列地址高 4 位 for (uint8_t col 0; col 128; col) { OLED_WriteData(fb[page * 128 col]); } } }列地址命令有个细节0x00是低 4 位0x10是高 4 位组合起来列地址是 0。如果漏写高 4 位或写成0x00 0x00数据会写到错误列区域屏幕出现整体偏移。刷新时每次都设置页地址列地址能保证数据从正确位置开始写入避免连续刷新时“上次停在哪、这次从哪继续”的错位问题。显示字符时用 8x16 的 ASCII 字模数组每个字符 16 字节对应 8 列 16 行像素。核心逻辑是把字模的每一位映射到帧缓冲里void OLED_DrawChar(uint8_t x, uint8_t y, char c, uint8_t on) { // 简化示例font_8x16 为预置字模表每个字符 16 字节 for (uint8_t row 0; row 16; row) { uint8_t data font_8x16[(uint8_t)c - 0x20][row]; for (uint8_t col 0; col 8; col) { if (data (0x80 col)) { OLED_SetPixel(x col, y row, on); } } } OLED_Refresh(); // 实际工程里应该改成批量绘制后统一刷新 }注意字模的取模方式要和显示代码保持一致。网上常见的是纵向取模、高位在前如果你从别的项目复制字模表先确认取模设置否则字符会上下颠倒或左右镜像。中文字符同理16x16 点阵汉字占 2 页 32 字节显示位置要按页对齐绘制时先算页号再写入两页数据。这套最小工程跑通后显示字符串、数字、汉字都只是往帧缓冲里画点的问题底层 I2C 驱动不用再动。4. I2C OLED 避坑排查从黑屏到花屏的五个现场4.1 屏幕完全不亮先查 ACK 再查地址现象是程序跑起来了逻辑分析仪能看到 SCL 和 SDA 有波形但屏幕一直黑的。此时第一件事不是调初始化序列而是确认从机有没有正确应答。I2C 总线上如果地址不对或模块没上电从机不会拉低 SDA 产生 ACK发送方看到的一直是高电平。写一个 I2C 扫描函数逐个地址发送并检查 ACK很快就能定位问题void I2C_Scan(void) { for (uint8_t addr 1; addr 127; addr) { I2C_Start(); uint8_t ack I2C_SendByte(addr 1); I2C_Stop(); if (ack) { printf(I2C device found at 0x%02X\n, addr); } } }这个扫描函数能直接暴露地址到底是 0x3C 还是 0x3D也能验证模拟 I2C 底层有没有写对。扫不到设备时逐一检查模块 VCC 是否 3.3V、SCL 和 SDA 是否接反、引脚是否配成了推挽输出而不是开漏。开漏配置错误时SDA 被强拉高从机根本无法拉低总线ACK 永远不会出现。4.2 点亮但花屏页地址错乱与控制字节写错现象是屏幕亮了但显示内容上下错位、左右偏移或出现随机噪点。最常见原因是每页刷新时没有设置页地址或者数据写入时把命令和数据通道搞混了。比如你用写数据函数去发送初始化命令SSD1306 会把命令字节当作像素数据写进显存初始化图形就变成了一堆乱码。排查步骤很固定先确认初始化部分每条命令是否都走OLED_WriteCmd确认刷新函数每次循环是否都有0xB0 page和列地址设置再检查控制字节是不是写错了。SH1106 这类兼容芯片的列地址映射和 SSD1306 不同套用例程时也会出现横向错位直接查模块芯片型号最稳妥。花屏还有一个隐性原因初始化序列里的电荷泵命令0x8D 0x14没执行屏幕供电不足显示内容对比度极低看起来像“花了一半”。4.3 显示闪烁刷新姿势不对画面能正常显示但数字或图表更新时明显闪烁这个现象在刷屏频率超过 10Hz 时尤其突出。多数情况是代码里每次刷新前先执行了清屏命令或者反复发送0xAE关屏再0xAF开屏。清屏会把整个显存清零然后再一页页写入新内容这个过程中屏幕会在“亮-暗-亮”之间切换人眼就感觉到了闪烁。正确做法是把清屏只放在初始化阶段运行时刷新直接覆盖写新内容不要在刷新函数内部开关显示。动画场景下要用帧缓冲配合局部刷新只更新变化区域而不是每次把全部 1024 字节重发一遍。另外刷新函数的 I2C 速度太低也会放大闪烁效果确认模拟 I2C 的延时没有把时钟拖到几十 kHz。4.4 硬件 I2C 卡死或复位后 BUSY 标志不消失使用 STM32 硬件 I2C 外设时偶尔会出现程序卡在发送函数里或者复位后外设 BUSY 标志一直为 1。常见原因是 SDA 引脚电平没有按预期释放如果引脚配置成了推挽输出或者外部上拉电阻缺失从机拉低总线后总线状态无法恢复I2C 状态机就卡死了。另一个典型场景是传输过程中 MCU 被复位总线停留在半途状态硬件 I2C 认为总线仍被占用。解决思路有两个方向一是把 SCL 和 SDA 引脚配置为开漏输出并确保模块或板卡上有 4.7k 上拉电阻二是在初始化 I2C 外设之前先对 GPIO 做一次完整复位必要时执行软件复位 I2C 外设。如果硬件 I2C 反复出现莫名其妙的卡死直接换回模拟 I2C 也是一种务实选择毕竟 OLED 显示对实时性要求不高模拟 I2C 的稳定性反而更好。4.5 5V 系统接 3.3V OLED电平不匹配发热和锁死有的开发者习惯用 5V 单片机的 I2C 引脚直接接 3.3V 的 OLED 模块结果屏幕不亮或总线异常摸一下模块还有点烫。原因是模块上的 I2C 上拉电阻接到模块 VCC 3.3V总线上拉电平是 3.3VMCU I/O 是 5V 推挽输出灌电流方向不对甚至可能损坏模块。解决方法是先确认模块 VCC 的供电来源如果模块由 5V 供电但号称兼容 3.3V 逻辑要看它是否内置电平转换电路没有的话不能直接连最安全的是给模块单独供 3.3VMCU I/O 也工作在 3.3V。电平问题在调试初期就确认好能省掉后面一大半的玄学排查。5. 提升刷新率帧缓冲、局部刷新与 DMA 的取舍5.1 帧缓冲绘制与显示解耦前面已经引入了fb[1024]这里专门说说为什么帧缓冲是 OLED 驱动的基本盘。没有帧缓冲时每个画点操作都直接触发一次 I2C 发送画 100 个点就是 100 次总线事务速度慢且闪烁明显。有了帧缓冲所有绘制函数都往内存里写最后统一刷一次显存I2C 事务数量降到最低。更进一步帧缓冲让“绘制”和“发送”彻底分离。比如在显示波形的场景里先在帧缓冲里把整条曲线算好、画好再一次性刷新在菜单界面里可以先绘制背景和文字再整体上屏避免用户看到半个界面。清屏函数也只是把fb全部置 0不操作硬件void OLED_Clear(void) { memset(fb, 0x00, sizeof(fb)); }页面绘制代码全部以fb为画布避免任何绘制接口直接调用 I2C。这个习惯能让显示模块的代码结构清晰很多调试时也能随时把fb内容通过串口打印出来对比屏幕实际显示定位到底是绘制问题还是传输问题。5.2 局部刷新只更新变化的页区域整屏刷新是 1024 字节的 I2C 传输加上每页的地址设置命令总耗时在大几百微秒到几毫秒之间。对于只改变一小块区域的界面来说这个开销是浪费的。局部刷新的思路是用一个 8 位的脏页标记哪位为 1 就说明对应页的内容有变化刷新时只发送这些页的数据。static uint8_t dirty_pages 0x00; // bit0~bit7 对应第 0~7 页 void OLED_MarkDirtyPage(uint8_t page) { dirty_pages | (1 page); } void OLED_RefreshDirty(void) { for (uint8_t page 0; page 8; page) { if (!(dirty_pages (1 page))) { continue; } OLED_WriteCmd(0xB0 page); OLED_WriteCmd(0x00); OLED_WriteCmd(0x10); for (uint8_t col 0; col 128; col) { OLED_WriteData(fb[page * 128 col]); } dirty_pages ~(1 page); } }局部刷新的最小单位是一整页也就是 8 个像素行。如果你只改了 y 坐标为 3 的一个像素刷新时也必须把整个第 0 页发出去这是 SSD1306 页寻址的特性决定的。所以绘制元素时尽量把重要内容放在同一页里或者按页对齐元素位置让一次刷新覆盖尽量少的页。比如时钟数字是 16 像素高正好占 2 页刷新两个数字只需要发 256 字节比全屏 1024 字节快得多。5.3 DMA I2C 的取舍省了 CPU 但没省总线时间很多开发者把 OLED 刷新率上不去归咎于模拟 I2C 占用 CPU想用硬件 I2C 加 DMA 解决。这个方向有一定道理DMA 能让 CPU 在 I2C 传输期间去干别的事但总线物理速率的上限是 400kHz全屏 1024 字节的传输时间并没有因为 DMA 而缩短。如果场景是持续全屏动画DMA 带来的体感提升有限却引入了更复杂的事件管理和 NACK 处理。方案适合场景CPU 占用工程复杂度模拟 I2C 局部刷新菜单、数字、图表局部变化传输期间占用低硬件 I2C DMA全屏动画、波形滚动低高SPI 接口 OLED DMA高帧率全屏显示低中我一般这样决策界面以局部更新为主时模拟 I2C 加局部刷新足够稳定真需要高帧率全屏动画直接换 SPI 接口的 OLED 更实际SPI 的时钟能跑到 MHz 级别DMA 的价值才真正体现出来。不要在 I2C 上死磕 DMA这个接口的物理带宽决定了它的上限。6. 用逻辑分析仪验证 I2C一次写入看清 ACK 与命令数据逻辑分析仪是调试 I2C OLED 最值得投资的工具几十块的入门款够用。接线时把逻辑分析仪的通道 0 接 SCL通道 1 接 SDA共地后设置采样率 1MHz 以上触发方式选下降沿这样能稳定抓到起始位。逻辑分析仪OLED 引脚说明CH0SCL采样时钟线CH1SDA采样数据线GNDGND必须共地抓一次初始化过程波形里第一字节应该是地址字节 0x787 位地址 0x3C 左移加写位 0后面紧跟 ACK 低电平。第二字节是控制字节如果你发的是命令这里应该是 0x00如果是显示数据应该是 0x40。很多花屏问题的根源在波形上非常直观控制字节错误时一眼就能看到第二字节不是预期值。我自己的一个习惯是收到一块新屏或者换了一个新平台第一件事不是直接接显示逻辑而是先抓一次总线波形确认地址、ACK、控制字节三个关键点。这个习惯帮我挡掉了很多“屏幕不亮”的瞎猜时间。OLED 驱动一旦跑通后面所有显示功能的开发都只是在帧缓冲上做文章希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑