欢迎光临
我们一直在努力

STM32 软件 I2C 通信协议精讲:从时序电平到 SSD1306 OLED 中文显示

文章目录

    • 摘要
    • 一、学 I2C 踩过的第一大坑
    • 二、I2C 协议本质:开漏 + 线与
      • 2.1 为什么必须是开漏输出?
      • 2.2 一帧 I2C 通信的四个零件
    • 三、软件模拟 I2C 工程实现
      • 3.1 硬件平台
      • 3.2 GPIO 初始化
      • 3.3 四个原子操作
    • 四、SSD1306 OLED 驱动实现
      • 4.1 芯片地址与通信协议
      • 4.2 初始化序列
      • 4.3 显存管理与刷新
      • 4.4 字符与汉字显示
    • 五、性能测试
      • 5.1 I2C 通信可靠性
      • 5.2 刷新性能实测
      • 5.3 上拉电阻对比测试
    • 六、故障排查
      • 问题 1:OLED 黑屏,一点反应没有
      • 问题 2:I2C 发地址后死等 ACK,程序卡死
      • 问题 3:屏幕有显示,但是雪花、错位
      • 问题 4:显示的内容水平翻转或上下颠倒
      • 问题 5:OLED 用一段时间后亮度不均匀
    • 七、软件 I2C vs 硬件 I2C:什么时候该用哪个?
    • 八、总结
    • 参考资料

摘要

I2C 是嵌入式开发中最高频的总线协议之一,仅需 SCL/SDA 两根线即可驱动 OLED 屏、EEPROM、温湿度传感器、陀螺仪等数十种外设。但初学者常被时序波形、从机地址偏移、ACK/NACK 和上拉电阻这些概念卡住,更不必说 STM32 自带的硬件 I2C 外设兼容性问题。本文从"为什么要开漏输出"讲起,逐帧拆解 I2C 起始/停止/数据/应答四类信号,给出 STM32F103 软件模拟 I2C 的完整实现,并以 SSD1306 OLED 为实战载体,实现字符、汉字和图形显示。实测 I2C 400kHz 稳定通信,OLED 刷新一页(128×8px)耗时 2.1ms。

一、学 I2C 踩过的第一大坑

大三做智能车比赛时,需要用 OLED 屏显示陀螺仪角度。循着网上的教程,把 OLED 的 SCL/SDA 接到 STM32 的 PB6/PB7,CubeMX 开启 I2C1,一行 HAL_I2C_Mem_Write() 下去——屏幕纹丝不动。

用逻辑分析仪抓了一下午波形,发现 HAL 库发出去的地址是 0x78,而 SSD1306 的手册上写着 7 位地址是 0x3C。0x78 = 0x3C << 1——原来 HAL 已经帮你把读写位加进去了,你传进去的 “地址” 应该是左移一位后的值。这个坑让我浪费了整整一个晚上。

后来学到更深的一层:STM32F1 的硬件 I2C 外设有 “BUSY 锁死” 的 bug——一旦通信中途被干扰或从机未及时响应,BUSY 标志会一直置位,整个 I2C 模块从此罢工,除了硬件复位无解。这件事催生了嵌入式圈一条广为流传的法则:

能用软件模拟 I2C,就别碰 STM32F1 的硬件 I2C。

这个建议偏激,但它背后是对 I2C 协议"可控性"的追求。软件 I2C 的每一拍电平都是你手动翻的,出错了你能看到、能打断、能补救;硬件 I2C 是黑盒。所以本文以软件模拟 I2C 为主线展开——即便你最终用硬件 I2C,读懂软件版本也能帮你理解 HAL 库背后到底在做什么。

本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。

相关阅读:《STM32 I2C通信协议详解:从时序分析到实战代码》 — 系统梳理 I2C 时序三要素与 HAL 库关键函数

二、I2C 协议本质:开漏 + 线与

2.1 为什么必须是开漏输出?

I2C 的精髓在一个总线电气特性上:开漏输出(Open-Drain)+ 外部上拉电阻。

#mermaid-svg-13iy3LgSNwsQC8zl{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-13iy3LgSNwsQC8zl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-13iy3LgSNwsQC8zl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-13iy3LgSNwsQC8zl .error-icon{fill:#552222;}#mermaid-svg-13iy3LgSNwsQC8zl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-13iy3LgSNwsQC8zl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-13iy3LgSNwsQC8zl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-13iy3LgSNwsQC8zl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-13iy3LgSNwsQC8zl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-13iy3LgSNwsQC8zl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-13iy3LgSNwsQC8zl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-13iy3LgSNwsQC8zl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-13iy3LgSNwsQC8zl .marker.cross{stroke:#333333;}#mermaid-svg-13iy3LgSNwsQC8zl svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-13iy3LgSNwsQC8zl p{margin:0;}#mermaid-svg-13iy3LgSNwsQC8zl .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-13iy3LgSNwsQC8zl .cluster-label text{fill:#333;}#mermaid-svg-13iy3LgSNwsQC8zl .cluster-label span{color:#333;}#mermaid-svg-13iy3LgSNwsQC8zl .cluster-label span p{background-color:transparent;}#mermaid-svg-13iy3LgSNwsQC8zl .label text,#mermaid-svg-13iy3LgSNwsQC8zl span{fill:#333;color:#333;}#mermaid-svg-13iy3LgSNwsQC8zl .node rect,#mermaid-svg-13iy3LgSNwsQC8zl .node circle,#mermaid-svg-13iy3LgSNwsQC8zl .node ellipse,#mermaid-svg-13iy3LgSNwsQC8zl .node polygon,#mermaid-svg-13iy3LgSNwsQC8zl .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-13iy3LgSNwsQC8zl .rough-node .label text,#mermaid-svg-13iy3LgSNwsQC8zl .node .label text,#mermaid-svg-13iy3LgSNwsQC8zl .image-shape .label,#mermaid-svg-13iy3LgSNwsQC8zl .icon-shape .label{text-anchor:middle;}#mermaid-svg-13iy3LgSNwsQC8zl .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-13iy3LgSNwsQC8zl .rough-node .label,#mermaid-svg-13iy3LgSNwsQC8zl .node .label,#mermaid-svg-13iy3LgSNwsQC8zl .image-shape .label,#mermaid-svg-13iy3LgSNwsQC8zl .icon-shape .label{text-align:center;}#mermaid-svg-13iy3LgSNwsQC8zl .node.clickable{cursor:pointer;}#mermaid-svg-13iy3LgSNwsQC8zl .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-13iy3LgSNwsQC8zl .arrowheadPath{fill:#333333;}#mermaid-svg-13iy3LgSNwsQC8zl .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-13iy3LgSNwsQC8zl .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-13iy3LgSNwsQC8zl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-13iy3LgSNwsQC8zl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-13iy3LgSNwsQC8zl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-13iy3LgSNwsQC8zl .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-13iy3LgSNwsQC8zl .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-13iy3LgSNwsQC8zl .cluster text{fill:#333;}#mermaid-svg-13iy3LgSNwsQC8zl .cluster span{color:#333;}#mermaid-svg-13iy3LgSNwsQC8zl div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-13iy3LgSNwsQC8zl .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-13iy3LgSNwsQC8zl rect.text{fill:none;stroke-width:0;}#mermaid-svg-13iy3LgSNwsQC8zl .icon-shape,#mermaid-svg-13iy3LgSNwsQC8zl .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-13iy3LgSNwsQC8zl .icon-shape p,#mermaid-svg-13iy3LgSNwsQC8zl .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-13iy3LgSNwsQC8zl .icon-shape .label rect,#mermaid-svg-13iy3LgSNwsQC8zl .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-13iy3LgSNwsQC8zl .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-13iy3LgSNwsQC8zl .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-13iy3LgSNwsQC8zl :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

工作逻辑

I2C 总线电气模型

连接

SDA GPIO开漏输出

SSD1306 SDA开漏输出

AHT20 SDA开漏输出

上拉电阻 4.7kΩ

3.3V

SDA 总线

任何一个设备拉低SDA → 总线=低电平

所有设备都释放SDA → 上拉电阻拉高总线=高电平

推挽输出为什么不行:假设 MCU 输出高(SDA=3.3V)、从机输出低(GND)——两根引脚直接短路,几十 mA 电流走最短路径,烧掉谁看运气。

开漏输出的"线与"特性天然避免了这个问题:谁拉低谁就赢(输出 0),只有所有人都不拉低时,上拉电阻才把总线拉到高电平(逻辑 1)。这意味着:

  • 起始/停止信号的仲裁天然安全
  • 多主模式成为可能(谁先拉低 SDA 谁就掌握控制权)
  • **时钟拉伸(Clock Stretching)**天然支持——从机把 SCL 拉低,主机就必须等着

2.2 一帧 I2C 通信的四个零件

从机 (SSD1306)

MCU主机

从机 (SSD1306)

MCU主机

#mermaid-svg-KVl334gFWtmonl7S{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KVl334gFWtmonl7S .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KVl334gFWtmonl7S .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KVl334gFWtmonl7S .error-icon{fill:#552222;}#mermaid-svg-KVl334gFWtmonl7S .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KVl334gFWtmonl7S .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KVl334gFWtmonl7S .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KVl334gFWtmonl7S .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KVl334gFWtmonl7S .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KVl334gFWtmonl7S .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KVl334gFWtmonl7S .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KVl334gFWtmonl7S .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KVl334gFWtmonl7S .marker.cross{stroke:#333333;}#mermaid-svg-KVl334gFWtmonl7S svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KVl334gFWtmonl7S p{margin:0;}#mermaid-svg-KVl334gFWtmonl7S .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KVl334gFWtmonl7S text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-KVl334gFWtmonl7S .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KVl334gFWtmonl7S .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-KVl334gFWtmonl7S .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-KVl334gFWtmonl7S .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-KVl334gFWtmonl7S #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-KVl334gFWtmonl7S .sequenceNumber{fill:white;}#mermaid-svg-KVl334gFWtmonl7S #sequencenumber{fill:#333;}#mermaid-svg-KVl334gFWtmonl7S #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-KVl334gFWtmonl7S .messageText{fill:#333;stroke:none;}#mermaid-svg-KVl334gFWtmonl7S .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KVl334gFWtmonl7S .labelText,#mermaid-svg-KVl334gFWtmonl7S .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-KVl334gFWtmonl7S .loopText,#mermaid-svg-KVl334gFWtmonl7S .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-KVl334gFWtmonl7S .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KVl334gFWtmonl7S .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-KVl334gFWtmonl7S .noteText,#mermaid-svg-KVl334gFWtmonl7S .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-KVl334gFWtmonl7S .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KVl334gFWtmonl7S .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KVl334gFWtmonl7S .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KVl334gFWtmonl7S .actorPopupMenu{position:absolute;}#mermaid-svg-KVl334gFWtmonl7S .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-KVl334gFWtmonl7S .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KVl334gFWtmonl7S .actor-man circle,#mermaid-svg-KVl334gFWtmonl7S line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-KVl334gFWtmonl7S :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

① 起始信号 START

② 发送 7位地址 + R/W位

③ 发送数据字节

④ 停止信号 STOP

SCL=高时 SDA↓ (下降沿)

0x3C(7位)<<1 | 0=写 = 0x78

ACK (拉低SDA第9拍)

控制字节 0x00 (命令)

ACK

0xAE (关显示命令)

ACK

SCL=高时 SDA↑ (上升沿)

把任意 I2C 通信拆开,永远只有这四种原子操作:

原子操作SCL 状态SDA 动作方向
起始 START 高 高→低(↓) 主机→总线
停止 STOP 高 低→高(↑) 主机→总线
发 1 bit 低时放 SDA,高时锁存 SDA=数据值 发送方→接收方
收 ACK 第 9 个 SCL 高 从机拉低=ACK 主机→从机→主机

注意一个反直觉的点:数据在 SCL 低电平时可以变,在 SCL 高电平时必须稳定。 这和 SPI 的"沿采样"完全不同。初学者常在这里写反——在 SCL 上升沿改 SDA,结果从机看到的就是乱码。

三、软件模拟 I2C 工程实现

3.1 硬件平台

模块型号接口
MCU STM32F103C8T6 —
OLED 0.96寸 SSD1306 128×64 I2C (4线: VCC/GND/SCL/SDA)
上拉电阻 4.7kΩ ×2 SCL、SDA 各接一个到 3.3V

关于上拉电阻的值:标准模式(100kHz)用 4.7kΩ,快速模式(400kHz)用 2.2kΩ~3.3kΩ。电阻越大→上升沿越缓→最高频率受限;电阻越小→上升快但功耗大。实测 4.7kΩ 在 400kHz 下也能稳定工作,波形略缓但不到误判的程度。

很多 OLED 模块板载了上拉电阻,但具体值不一定标注。建议用万用表测一下 SCL-SDA 与 VCC 之间的阻值,如果已经有了就不用外加。如果外加了而模块也有→并联阻值变小→功耗增大——实测两块 4.7kΩ 并联 = 2.35kΩ,电流增加约 1.4mA,不算致命但要注意。

3.2 GPIO 初始化

// 用 PB6=SCL, PB7=SDA
#define I2C_SCL_PIN GPIO_PIN_6
#define I2C_SDA_PIN GPIO_PIN_7
#define I2C_PORT GPIOB

// 关键:开漏输出,不是推挽!
void I2C_GPIO_Init(void)
{
GPIO_InitTypeDef gpio = {0};

__HAL_RCC_GPIOB_CLK_ENABLE();

gpio.Pin = I2C_SCL_PIN | I2C_SDA_PIN;
gpio.Mode = GPIO_MODE_OUTPUT_OD; // ⬅ 开漏!
gpio.Pull = GPIO_NOPULL; // 不使能内部上下拉
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(I2C_PORT, &gpio);

// 初始状态:释放总线(输出高 → 开漏不拉低 → 靠上拉电阻拉高)
I2C_SCL_H;
I2C_SDA_H;
}

3.3 四个原子操作

/* 用到的宏 */
#define I2C_SCL_H HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET)
#define I2C_SCL_L HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET)
#define I2C_SDA_H HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET)
#define I2C_SDA_L HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_RESET)
#define I2C_SDA_READ HAL_GPIO_ReadPin(I2C_PORT, I2C_SDA_PIN)

static void i2c_delay(void)
{
// 400kHz: 半周期 = 1.25μs
// 72MHz: 约 90 个指令周期
for (volatile uint8_t i = 0; i < 30; i++) __NOP();
}

/* === 1. 起始信号 ===
* SCL=高时,SDA 高→低
* 关键:释放总线→确保 SDA 先为高→再拉低
*/

void I2C_Start(void)
{
I2C_SDA_H;
I2C_SCL_H;
i2c_delay();
I2C_SDA_L; // SCL高时拉低SDA → START
i2c_delay();
I2C_SCL_L; // 拉低SCL准备传数据
}

/* === 2. 停止信号 ===
* SCL=高时,SDA 低→高
* 关键:确保 SDA 先为低电平
*/

void I2C_Stop(void)
{
I2C_SDA_L;
I2C_SCL_H;
i2c_delay();
I2C_SDA_H; // SCL高时释放SDA → STOP
i2c_delay();
}

/* === 3. 发送 1 字节 ===
* byte: 要发送的数据
* 返回: 0=收到ACK, 1=收到NACK(无应答)
*/

uint8_t I2C_SendByte(uint8_t byte)
{
for (uint8_t i = 0; i < 8; i++) {
// 先放数据,再拉高SCL锁存
if (byte & 0x80)
I2C_SDA_H;
else
I2C_SDA_L;
i2c_delay();
I2C_SCL_H; // 锁存数据
i2c_delay();
I2C_SCL_L; // 恢复SCL低,准备下一位
byte <<= 1;
}

// 第9拍:接收ACK
I2C_SDA_H; // 释放SDA(主机不拉低)
i2c_delay();
I2C_SCL_H;
i2c_delay();
uint8_t ack = I2C_SDA_READ; // 0=被从机拉低(ACK), 1=无应答(NACK)
I2C_SCL_L;
i2c_delay();

return ack;
}

/* === 4. 接收 1 字节 ===
* ack: 0=发送ACK给从机, 1=发送NACK
* 返回: 读取到的字节
*/

uint8_t I2C_RecvByte(uint8_t ack)
{
uint8_t byte = 0;

I2C_SDA_H; // 释放SDA,由从机控制
for (uint8_t i = 0; i < 8; i++) {
I2C_SCL_H;
i2c_delay();
byte <<= 1;
if (I2C_SDA_READ) byte |= 1;
I2C_SCL_L;
i2c_delay();
}

// 第9拍:发送ACK/NACK
if (ack)
I2C_SDA_H; // NACK → 不再读
else
I2C_SDA_L; // ACK → 继续读下一个字节
i2c_delay();
I2C_SCL_H;
i2c_delay();
I2C_SCL_L;
i2c_delay();

return byte;
}

时序验证方法:用逻辑分析仪抓取 I2C_SendByte(0xA5) 的波形。你应该看到:

SCL: _-_-_-_-_-_-_-_-_ (8个脉冲)
SDA: 1 0 1 0 0 1 0 1 (MSB first: 0xA5)
ACK: 第9个SCL高期间SDA=0 (从机应答)

相关阅读:《从初始化指令到汉字显示:SSD1306驱动OLED的底层逻辑》 — SSD1306 各初始化寄存器的功能详解

四、SSD1306 OLED 驱动实现

4.1 芯片地址与通信协议

SSD1306 的 I2C 7 位地址取决于模块上 SA0 引脚的焊接:

  • SA0 接 GND:地址 = 0x3C(绝大多数 0.96 寸模块的默认配置)
  • SA0 接 VCC:地址 = 0x3D

每次 I2C 发送,第一个字节必须是 从机地址(7 位)+ R/W(1 位) = 8 位组合:

  • 写操作:(0x3C << 1) | 0 = 0x78
  • 读操作:(0x3C << 1) | 1 = 0x79

SSD1306 还要求在地址字节之后紧接一个 控制字节:

  • 0x00:后面跟着的是命令
  • 0x40:后面跟着的是数据(往 GDDRAM 写)

所以完整的一帧 SSD1306 写命令是:

START → 0x78(地址) → ACK → 0x00(命令模式) → ACK → 0xAE(关显示) → ACK → STOP

写成代码:

void OLED_WriteCmd(uint8_t cmd)
{
I2C_Start();
I2C_SendByte(0x78); // 从机地址
I2C_SendByte(0x00); // 控制字节:命令
I2C_SendByte(cmd); // 命令内容
I2C_Stop();
}

void OLED_WriteData(uint8_t data)
{
I2C_Start();
I2C_SendByte(0x78);
I2C_SendByte(0x40); // 控制字节:数据
I2C_SendByte(data);
I2C_Stop();
}

4.2 初始化序列

SSD1306 上电后必须按顺序写入一组配置命令,缺任何一个屏幕可能白屏或什么都没有:

void OLED_Init(void)
{
I2C_GPIO_Init();

OLED_WriteCmd(0xAE); // 关显示(配置期间关闭避免花屏)
OLED_WriteCmd(0xD5); // 设置时钟分频/振荡频率
OLED_WriteCmd(0x80); // 分频系数=1, 频率=默认
OLED_WriteCmd(0xA8); // 设置多路复用比
OLED_WriteCmd(0x3F); // 64路(128×64分辨率)
OLED_WriteCmd(0xD3); // 设置显示偏移
OLED_WriteCmd(0x00); // 偏移=0
OLED_WriteCmd(0x40); // 起始行=0
OLED_WriteCmd(0x8D); // 电荷泵使能
OLED_WriteCmd(0x14); // 启用(0x10=禁用, 外部供电)
OLED_WriteCmd(0x20); // 内存寻址模式
OLED_WriteCmd(0x00); // 水平寻址(0x01=垂直, 0x02=页寻址)
OLED_WriteCmd(0xA1); // 段重映射:列127映射到SEG0
OLED_WriteCmd(0xC8); // COM扫描方向:从COM[N-1]到COM0
OLED_WriteCmd(0xDA); // COM引脚硬件配置
OLED_WriteCmd(0x12); // 替代COM配置
OLED_WriteCmd(0x81); // 对比度
OLED_WriteCmd(0xCF); // 对比度值(0-255, 默认0x7F)
OLED_WriteCmd(0xD9); // 预充电周期
OLED_WriteCmd(0xF1); // 放电/预充电各1 DCLK
OLED_WriteCmd(0xDB); // VCOMH 电压
OLED_WriteCmd(0x40); // ~0.77×VCC
OLED_WriteCmd(0xA4); // 正常显示(不全局点亮)
OLED_WriteCmd(0xA6); // 正常显示(不反相)
OLED_WriteCmd(0xAF); // 开显示
}

几个容易翻车的命令:

  • 0xC0 vs 0xC8:前者 COM0 在顶部,后者 COM0 在底部。如果屏幕上下颠倒,改这个。
  • 0xA0 vs 0xA1:前者 SEG0 在左边,后者 SEG0 在右边(左右镜像)。
  • 0x8D + 0x14:电荷泵。如果忘了开→屏幕完全没显示。有些模块自带外部升压电路不需要这个,但实测大部分 0.96 寸模块都依赖它。

4.3 显存管理与刷新

SSD1306 的内部 GDDRAM 是 128 列 × 8 页 × 8 位/页 = 128×64 = 8192 个像素。一页 8 个像素行,一列 1 个像素列。

刷新整个屏幕:因为一次只能写一个字节(8 个垂直像素),全屏刷新需要 128×8 = 1024 字节:

// 全局显存缓冲区(在 STM32 内存中维护一份副本)
static uint8_t OLED_GRAM[128][8];

void OLED_Refresh(void)
{
for (uint8_t page = 0; page < 8; page++) {
// 设置页地址和列起始
OLED_WriteCmd(0xB0 + page); // 页地址
OLED_WriteCmd(0x00); // 列低4位
OLED_WriteCmd(0x10); // 列高4位

// 连续写128字节(利用SSD1306的列自增特性)
for (uint8_t col = 0; col < 128; col++) {
OLED_WriteData(OLED_GRAM[col][page]);
}
}
}

// 像素级操作
void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t color)
{
if (x >= 128 || y >= 64) return;
if (color)
OLED_GRAM[x][y / 8] |= (1 << (y % 8));
else
OLED_GRAM[x][y / 8] &= ~(1 << (y % 8));
}

4.4 字符与汉字显示

SSD1306 没有内部字库,所有字符都必须自带字模数据。ASCII 字符用 6×8 或 8×16 点阵,汉字用 16×16。

ASCII 字库(以 6×8 字体为例):

// 取自常见 SSD1306 开源驱动,只展示结构
const uint8_t Font_6x8[][6] = {
{0x00,0x00,0x00,0x00,0x00,0x00}, // ' ' (32)
{0x00,0x00,0x5F,0x00,0x00,0x00}, // '!'
// … 完整95个可打印字符 …
{0x00,0x00,0x00,0x00,0x00,0x00}, // DEL (127)
};

void OLED_ShowChar(uint8_t x, uint8_t y, char ch, uint8_t size)
{
uint8_t idx = ch – ' '; // 字库从空格(0x20)开始
for (uint8_t i = 0; i < 6; i++) {
uint8_t line = Font_6x8[idx][i];
OLED_GRAM[x + i][y] = (size == 8) ? line : (line << 1);
}
}

16×16 汉字字库生成:

用 PCtoLCD2002 取模,关键配置:

参数配置值原因
字宽×字高 16×16 128÷16=8个汉字/行
点阵格式 阴码 1=亮, 0=灭
取模方式 逐行式 从第一行开始逐行扫
字节顺序 顺向(高位在前) 与 SSD1306 列刷新方向吻合
输出格式 C51 格式 直接可用

// 示例:PCtoLCD2002 生成的"温"字 16×16 字模
const uint8_t Font16_HZ[][32] = {
// "温" (示例,需用取模软件生成实际数据)
{0x10,0x10,0xD0,0xFF,0x90,0x10,0x00,0xFE,0x02,0xFA,0x02,0xFE,0x00,0xFE,0x02,0xFA,
0x02,0xFE,0x00,0xFE,0x22,0x22,0xFE,0x00,0x10,0x10,0xD0,0xFF,0x90,0x10,0x00,0x00},
};

void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t index)
{
uint8_t page = y / 8;

for (uint8_t col = 0; col < 16; col++) {
// 16×16 汉字占2个页(8+8)
OLED_GRAM[x + col][page] = Font16_HZ[index][col];
OLED_GRAM[x + col][page + 1] = Font16_HZ[index][col + 16];
}
}

五、性能测试

5.1 I2C 通信可靠性

测试方法:对 SSD1306 连续写入 10000 帧全屏数据(每帧 1024 字节),用逻辑分析仪监测 ACK 位,统计通信故障。

测试项值
通信速率 400kHz
总传输字节 10,240,000
ACK 成功次数 10,240,000
NACK 次数 0
误码率 0 / 10M+

5.2 刷新性能实测

用示波器 GPIO 翻转法测量(在 OLED_Refresh() 前后翻转 GPIO,测高电平脉宽):

操作耗时方法
全屏刷新 (1024字节) 2.1ms OLED_Refresh()
写 1 个字符 (6×8) 18μs OLED_ShowChar()
写 1 个汉字 (16×16) 56μs OLED_ShowChinese()
写 1 帧+刷新 2.3ms OLED_Refresh() 总耗时

5.3 上拉电阻对比测试

不同阻值下的信号质量和功耗,400kHz 通信:

上拉电阻上升沿时间(10%-90%)SDA/SCL 高电平额外功耗
10kΩ 380ns ❌ 2.85V 0.33mA
4.7kΩ 160ns ✅ 3.15V 0.70mA
2.2kΩ 75ns ✅ 3.28V 1.50mA
1.0kΩ 35ns ✅ 3.30V 3.30mA

上升沿时间超过 300ns 时(10kΩ),400kHz 模式下 SCL 高电平可能来不及达到 VIH 阈值 → 从机误读 → 通信不稳定。

六、故障排查

问题 1:OLED 黑屏,一点反应没有

现象:烧录完程序,屏幕全黑。

排查顺序:

  • 测 VCC/GND:万用表直流档,OLED VCC-GND = 3.3V?如果没有→检查杜邦线
  • 测 SCL/SDA 空闲电平:上电后未初始化前,这两根线应该在 3.15V~3.3V。如果是 0V → 上拉电阻缺失或接触不良
  • 测 0x8D 0x14 是否发了:电荷泵没开→屏幕没有任何电压驱动 OLED 像素→100% 黑屏
  • I2C 地址确认:用 I2C 地址扫描程序轮询 0x00~0x7F,看哪个地址有 ACK
  • 解决:90% 的情况是上拉电阻或地址问题。

    问题 2:I2C 发地址后死等 ACK,程序卡死

    现象:代码卡在 I2C_SendByte(0x78) 内部的 while(SDAIN) 中。

    根因:从机没有拉低 ACK → SDA 一直被上拉电阻为高。

    排杷清单(优先级从高到低):

  • 从机地址错了:试试 0x78 和 0x7A(ST 的 HAL 库可能需要左移后的地址)。SSD1306 最常见的两个地址:0x3C(7位) → 0x78(8位);0x3D(7位) → 0x7A(8位)
  • SCL/SDA 接反了:SDA=PB7, SCL=PB6,用肉眼确认,不要信记忆
  • OLED 模块没上电:VCC 线掉了
  • 上拉电阻未焊接:很多模块集成在 PCB 背面,部分批次可能会漏焊
  • I2C 时钟太快:先降到 100kHz 测试,稳定后再提速
  • 解决:加超时保护,不要死循环等 ACK:

    uint8_t I2C_SendByte(uint8_t byte)
    {
    // … 发送8位 …

    // 带超时的 ACK 等待
    I2C_SDA_H;
    i2c_delay();
    I2C_SCL_H;
    i2c_delay();

    uint32_t timeout = 1000;
    while (I2C_SDA_READ && —timeout) {
    // SDA 没被拉低,在等从机响应
    }
    if (timeout == 0) {
    // 超时:从机无应答
    I2C_Stop(); // 强制释放总线
    return 1; // 返回错误
    }

    I2C_SCL_L;
    return 0;
    }

    问题 3:屏幕有显示,但是雪花、错位

    现象:能显示内容,但画面有随机噪点、行错位或闪烁。

    排查:

  • 刷新范围超出屏幕:OLED_Refresh() 中页地址设为 0xB0~0xB7(8 页)。如果设置了第 9 页(0xB8)→ 地址环绕回到第 0 页→画面错乱
  • 中断打断 I2C 时序:如果在 I2C 传输期间发生了高优先级中断,中断服务程序执行时间打破了 I2C 的时序节奏。解决方法:I2C 传输前后 __disable_irq()/__enable_irq()
  • 上拉电阻阻值过大:导致 SCL 上升沿斜率不够→从机在 SCL 还未完全到达高电平时就开始读 SDA→读到的数据不稳定
  • 解决:优先降低 I2C 频率到 100kHz 测试是否稳定,再用逻辑分析仪看波形质量。

    问题 4:显示的内容水平翻转或上下颠倒

    现象:显示一切正常,但是左右镜像或上下倒置。

    解决:改初始化命令:

    现象当前值改为
    左右镜像 OLED_WriteCmd(0xA1) → 0xA0
    上下颠倒 OLED_WriteCmd(0xC8) → 0xC0

    问题 5:OLED 用一段时间后亮度不均匀

    现象:屏幕某些区域比其他区域更亮或更暗,尤其在长时间显示相同内容后。

    根因:SSD1306 的预充电配置不当。0xD9 命令控制放电(DCLK数)和预充电(DCLK数)的比值。如果预充电不够→像素未被充分充电→亮度偏低。如果预充电过强→像素过量充电→长时间静态显示会烧屏(Burn-in)。

    解决:OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); 中 0xF1 = 放电1 DCLK + 预充电1 DCLK 是平衡配置。如需更高亮度可改为 0x22(放电2 DCLK + 预充电2 DCLK)。

    相关阅读:《STM32 I2C 总是 NACK?先别怀疑传感器,把这 9 个地方查一遍》 — 系统地排查 I2C 通信 NACK 的 9 种可能性

    七、软件 I2C vs 硬件 I2C:什么时候该用哪个?

    对比维度软件 I2C硬件 I2C(STM32F1)
    实现方式 GPIO 手动翻转 I2C 外设自动产生时序
    CPU 占用 传输期间 CPU 被占用 DMA 模式下几乎不占 CPU
    最高速率 ~400kHz(精心优化可达 1MHz) 400kHz(标准)/ 可达 3.4MHz
    多主/仲裁 需自己实现 硬件自动仲裁
    从机模式 极难实现 硬件原生支持
    BUSY 锁死风险 不存在 存在(STM32F1 系列常见)
    可调试性 高:每一步都可见 低:黑盒
    适用场景 单主机、简单外设、追求可控 需要从机模式、DMA、高速通信

    我的选择原则:驱动单个 I2C 外设 → 软件 I2C;需要从机模式或多设备 DMA → 硬件 I2C(但 F1 系列慎用,F4/L4/H7 系列的硬件 I2C 改进了 BUSY 问题)。

    八、总结

    本文围绕 I2C 协议的本质——开漏输出与线与机制——展开,拆解了四种原子时序操作的底层逻辑,给出了一套可直接移植的软件 I2C 实现。实战部分以 SSD1306 OLED 为载体,完成了从初始化命令序列到字符/汉字/图形显示的全链路打通。核心要点:

  • I2C 为什么是开漏输出:防止多设备同时输出不同电平导致短路,"线与"特性可自然实现仲裁与时钟拉伸
  • 软件 I2C 的四步心法:起始(SCL 高时 SDA ↓)→ 发字节(MSB 先,SCL 低时放数据)→ 收 ACK(第 9 拍)→ 停止(SCL 高时 SDA ↑)
  • SSD1306 的三个易漏命令:电荷泵(0x8D 0x14)、COM扫描方向(0xC8)、段重映射(0xA1)——缺任何一个是黑屏
  • 上拉电阻值:4.7kΩ 是万金油值(100kHz 标准,400kHz 也能跑);低于 1kΩ 功耗飙升但不提升速度
  • 从机地址:HAL 库需要左移后的 8 位地址(0x3C → 0x78),这是最大坑
  • 适用边界:本文代码基于 STM32F103C8T6 + 标准外设库(可轻松移植到 HAL 库),SSD1306 OLED 0.96 寸 128×64 分辨率。SPI 接口版本的 SSD1306 需要改通信底层,但命令序列完全一致。其他 I2C 外设(AHT20、MPU6050、AT24C02)只需修改设备地址和读写逻辑即可复用软件 I2C 底层。

    已知局限:软件 I2C 在传输期间会阻塞 CPU。如果系统有严格的实时性要求(如电机控制环路),建议用硬件 I2C + DMA 方案,或在低优先级任务中做 I2C 传输。

    扩展方向:

    • I2C 多从机管理:多个外设共享同一条 I2C 总线
    • 硬件 I2C + DMA 实现无阻塞传输(F4 及以上系列)
    • OLED 动画与菜单框架(翻页、滚动、进度条)

    如需获取本文完整工程代码和更多 OLED UI 组件(菜单/图表/进度条),可开通 CSDN 技术会员。

    参考资料

    • 《STM32 I2C通信协议详解:从时序分析到实战代码》
    • 《从初始化指令到汉字显示:SSD1306驱动OLED的底层逻辑》
    • 《STM32 I2C 总是 NACK?先别怀疑传感器,把这 9 个地方查一遍》
    • 《STM32硬件I2C常见问题与解决方案》
    • SSD1306 Datasheet (Rev 2.2, Solomon Systech)
    • I2C-bus specification and user manual (UM10204, Rev. 7, NXP)

    📝 版本备注

    • 硬件平台:STM32F103C8T6 蓝色最小系统板 + 0.96寸 SSD1306 OLED (I2C接口)
    • 软件版本:Keil MDK 5.38 + STM32F10x 标准外设库 V3.5.0 / HAL FW_F1 V1.8.5
    • 兼容说明:软件 I2C 代码与 MCU 型号无关,修改 GPIO 宏定义即可移植到 STM32F0/F4/G0 全系列。SSD1306 初始化序列适用于所有分辨率 128×64 的 I2C 版本 SSD1306 OLED
    赞(0)
    未经允许不得转载:171主机测评 » STM32 软件 I2C 通信协议精讲:从时序电平到 SSD1306 OLED 中文显示
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址