串口通信至少三条线(TX、RX、GND),I2C仅需两条信号线(SDA、SCL)
串口通信只支持一对一通信,我想一对多呢?------I2C
串口通信无应答机制,I2C必须有应答机制
串口通信一般是异步通信,我想同步呢?------I2C
串口
https://blog.csdn.net/2301_76153977/article/details/154564412STM32个人专栏
https://blog.csdn.net/2301_76153977/category_13070974.html?spm=1001.2014.3001.5482
1. I2C总线介绍
同步 串行 半双工

主机主动发起信号,从机被动接受,若从机想发送信号,需得到主机允许
每个设备都有对应的编号,要先喊一下对应设备的编号,再对它进行通信
最常用:一主多从、一主一从
STM32有硬件I2C,但是某个IO口已经固定死了,所以一般使用软件I2C进行模拟
2. I2C总线时序
2.1 I2C必须"依次接收8位"
要理解**I2C必须"依次接收8位"**的原因,需要从物理层限制、协议时序规则两个核心角度分析

若不深入理解按位传递,将看不懂以下代码的含义
以下是I2C主机接收字节的标准实现
逐行拆解代码逻辑代码功能:主机从从机接收1个字节(8位),存储到data变量中。
- 变量初始化
cppuint8_t i = 0, data = 0;• i:循环计数器(处理8位数据);
• data:接收缓冲区(初始化为0,等待拼接从机发送的位)。
- 释放SDA线(关键!)
cppI2C_SDA_SET();• 作用:主机接收时,SDA需由从机控制,因此主机要释放SDA线(避免与从机冲突)。
• 原理:I2C的SDA引脚是开漏输出(Open-Drain),I2C_SDA_SET()将SDA置为高电平(此时主机不驱动SDA,电平由从机或上拉电阻决定)。
- 循环处理8位数据(核心逻辑)
cppfor(i = 0; i < 8; i++) { I2C_SCL_SET(); // 1. 主机拉高SCL,生成时钟高电平 if(I2C_SDA_VALUE() == GPIO_PIN_SET) { // 2. 读取SDA电平(从机发送的数据位) data |= (0x80 >> i); // 3. 若SDA为高,将对应位写入data } I2C_SCL_RESET(); // 4. 主机拉低SCL,完成一个时钟周期 }循环8次(对应8位),每次处理1位,顺序是最高位→最低位(符合I2C规则)。以下是每一步的细节:
++(1)主机拉高SCL:生成时钟高电平++
cppI2C_SCL_SET();• I2C的时钟由主机完全控制,I2C_SCL_SET()将SCL线拉高,进入时钟脉冲的高电平阶段。
• 此时从机必须将当前要发送的位稳定在SDA线上(I2C要求:SCL高电平期间,数据不能变化)。
++(2)读取SDA电平:获取从机发送的位++
cppif(I2C_SDA_VALUE() == GPIO_PIN_SET) { ... }• I2C_SDA_VALUE():读取SDA线的当前电平(从机发送的数据位)。
• GPIO_PIN_SET:表示SDA为高电平(从机发送的是1);若为GPIO_PIN_RESET,则是低电平(从机发送的是0)。
++(3)将数据位写入data:拼接字节++
cppdata |= (0x80 >> i);这是最关键的一行,需结合二进制位操作理解:
• 0x80:十六进制80对应二进制10000000,是8位中的最高位(第7位,从0开始计数)。
• 0x80 >> i:将最高位逐步右移,依次对应第7-i位(从最高位到最低位):
• i=0:0x80 >> 0 = 0x80→ 第7位(最高位);
• i=1:0x80 >> 1 = 0x40→ 第6位;
• i=2:0x80 >> 2 = 0x20→ 第5位;
• ...
• i=7:0x80 >> 7 = 0x01→ 第0位(最低位)。
• data |= (0x80 >> i):若SDA为高电平(从机发送1),则将当前位置为1;若为低电平(发送0),则该位保持0(因为data初始为0,不操作即保留0)。
++(4)主机拉低SCL:完成一个时钟周期++
cppI2C_SCL_RESET();• 将SCL拉低,进入时钟脉冲的低电平阶段。此时从机可以准备下一位数据(SDA电平可变化)。
2.1.1 物理层
→最根本的原因:I2C是"串行通信"
串行通信(Serial Communication)的定义是:数据通过一根数据线逐位(Bit)传输(对比并行通信:通过多根数据线同时传输多位)。
I2C的物理层只有1根数据线(SDA),从机无法"一次性发送8位"------因为没有足够的硬件线路同时承载8位信号。因此,无论软件如何设计,物理层都强制要求数据必须"一位一位传",接收方也必须"一位一位收"。
2.2.2 协议规则
→I2C协议的"位级时序"要求
即使物理层允许并行传输,I2C的协议规则也强制要求"逐位处理",因为每一位的传输都需要时钟同步和电平采样:
① 时钟同步(SCL的作用)
I2C是同步通信,由主机生成时钟(SCL线),从机必须根据SCL的节奏发送数据:
• 从机在SCL低电平期间准备下一位数据(SDA电平可变化);
• 从机在SCL高电平期间保持SDA电平稳定(确保主机能准确采样);
• 主机在SCL高电平期间读取SDA的电平(即当前位的值)。
因此,每一位都需要一个完整的SCL周期(低→高→低),8位数据就需要8个SCL周期------这是协议的硬规则,无法跳过。
② 位级采样的必要性
主机必须逐位验证SDA的电平,因为:
• I2C的数据是最高位(MSB)优先,必须按顺序接收(第1位是bit7,第8位是bit0);
• 每一位的采样时机必须严格对应SCL的高电平(早了或晚了都会读错);
• 若跳过逐位处理,主机无法判断"当前是第几位",也无法正确拼接字节(比如MSB在前还是LSB在前)。
2.2 起始信号
通知总线上所有设备即将开始传输
起始信号由主设备发起,用于通知总线上的所有设备即将开始数据传输。
当 SCL 为高电平时, SDA 由高电平变为低电平,产生一个下降沿,表示起始信号的开始。
2.3 停止信号
通知总线上所有设备传输已经结束
停止信号也由主设备发起,用于通知总线上的所有设备数据传输已经结束。
当 SCL 为高电平时, SDA 由低电平变为高电平,产生一个上升沿,表示停止信号的开始。
2.4 应答信号
每当一个字节的数据传输完成后,接收方会向发送方发送一个应答信号( ACK )或非应答信号
( NACK )。
应答信号在 SCL 的第 9 个时钟周期(当 SCL 为高电平时)发送。
如果 SDA 线为低电平,则表示 ACK (有效应答),表示接收方已成功接收该字节。
如果 SDA 线为高电平,则表示 NACK (非应答),通常表示接收方接收该字节没有成功.
2.5 读写时序
按照SCL指挥主机和从机动作
SCL为低电平时,发数据,把数据放到SDA里
SCL为高电平时,SDA不能动,从机开始读数据
发送一个字节: SCL 低电平期间,主机将数据位依次放到 SDA 线上(高位先行),然后释放 SCL ,从机将在SCL高电平期间读取数据位,所以 SCL 高电平期间 SDA 不允许有数据变化,依次循环上述过程 8 次,即可发送一个字节
接收一个字节: SCL 低电平期间,从机将数据位依次放到 SDA 线上(高位先行),然后释放 SCL ,主机将在SCL高电平期间读取数据位,所以 SCL 高电平期间 SDA 不允许有数据变化,依次循环上述过程 8 次,即可接收一个字节(主机在接收之前,需要释放SDA )
2.6 完整I2C时序

核心规则
I2C是同步串行通信协议,由主机生成时钟(SCL线),从机根据时钟节奏发送/接收数据(SDA线)。关键时序要求:
• 数据在SCL高电平期间稳定(主机读取SDA值);
• 数据按最高位(MSB)到最低位(LSB)的顺序传输;
• 主机接收时,需释放SDA线(由从机控制SDA电平)。
举个🥥:从机发送字节 0xAB
0xAB的二进制是10101011(按 最高位(MSB,bit7)到最低位(LSB,bit0) 排列)我们对应
i从0到7,逐步解析从机发送的位 、主机的处理 ,以及data的变化:bit位 bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
数值 1 0 1 0 1 0 1 11. 第1位(i=0,处理bit7,最高位)
从机发送:bit7=1 → SDA线拉高(GPIO_PIN_SET);
主机操作:
拉高SCL,读取SDA=高;
掩码:
0x80 >> 0 = 0x80(二进制10000000);
data |= 0x80→data从0变为0x80(二进制10000000);结果 :
data=0x80。2. 第2位(i=1,处理bit6)
从机发送:bit6=0 → SDA线拉低(GPIO_PIN_RESET);
主机操作:
拉高SCL,读取SDA=低;
不执行
data |=操作(因为条件不满足);结果 :
data保持0x80(二进制10000000)。3. 第3位(i=2,处理bit5)
从机发送:bit5=1 → SDA线拉高;
主机操作:
掩码:
0x80 >> 2 = 0x20(二进制00100000);
data |= 0x20→data=0x80+0x20=0xA0(二进制10100000);结果 :
data=0xA0。4. 第4位(i=3,处理bit4)
从机发送:bit4=0 → SDA线拉低;
主机操作 :不执行
data |=;结果 :
data保持0xA0(二进制10100000)。5. 第5位(i=4,处理bit3)
从机发送:bit3=1 → SDA线拉高;
主机操作:
掩码:
0x80 >> 4 = 0x08(二进制00001000);
data |= 0x08→data=0xA0+0x08=0xA8(二进制10101000);结果 :
data=0xA8。6. 第6位(i=5,处理bit2)
从机发送:bit2=0 → SDA线拉低;
主机操作 :不执行
data |=;结果 :
data保持0xA8(二进制10101000)。7. 第7位(i=6,处理bit1)
从机发送:bit1=1 → SDA线拉高;
主机操作:
掩码:
0x80 >> 6 = 0x02(二进制00000010);
data |= 0x02→data=0xA8+0x02=0xAA(二进制10101010);结果 :
data=0xAA。8. 第8位(i=7,处理bit0,最低位)
从机发送:bit0=1 → SDA线拉高;
主机操作:
掩码:
0x80 >> 7 = 0x01(二进制00000001);
data |= 0x01→data=0xAA+0x01=0xAB(二进制10101011);结果 :
data=0xAB。
3. 如何驱动OLED屏幕显示内容?
告诉OLED屏幕在哪里显示什么东西
3.1 告诉:时序

SA0:由硬件决定,接上拉电阻,SA0=1;没接上拉电阻,SA0=0;
R/W:1读,0写
起始信号发出后,接下来发出的数据就是0x78
Co=1:control byte+data byte+control byte+data byte交替着
Co=0:control byte+data byte+data byte+data byte都是data byte

第一个红框是Co=1,第二个红框是Co=0,一般不看第一个红框,第二个红框用的多
3.2 OLED屏幕



通过通信接口接收一个字节数据,根据3.1,如果D/C#=1,则是写数据,数据写入GDDRAM;如果D/C#=0,则是写命令,命令传进命令编码器
GDDRAM控制OLED小灯点亮,想让某个灯点亮,对应位置置1
一个字节8位,一个字节一次性可控制8个灯
一共64行,所以纵向需要8个字节

3.3 在哪里


3.4 显示什么东西
用取模软件提前分配好

4. OLED实验
4.1 准备
功能实现:驱动OLED屏幕,显示点、线、字符、字符串、汉字、图片等内容

4.2 oled.c
第1步. 最开始肯定是要写gpio的初始化函数
封装SCL和SDA相关函数
cpp
#define OLED_I2C_SCL_CLK() __HAL_RCC_GPIOB_CLK_ENABLE();
#define OLED_I2C_SCL_PORT GPIOB
#define OLED_I2C_SCL_PIN GPIO_PIN_6
#define OLED_I2C_SDA_CLK() __HAL_RCC_GPIOB_CLK_ENABLE();
#define OLED_I2C_SDA_PORT GPIOB
#define OLED_I2C_SDA_PIN GPIO_PIN_7
#define OLED_SCL_RESET() HAL_GPIO_WritePin(OLED_I2C_SCL_PORT, OLED_I2C_SCL_PIN, GPIO_PIN_RESET)
#define OLED_SCL_SET() HAL_GPIO_WritePin(OLED_I2C_SCL_PORT, OLED_I2C_SCL_PIN, GPIO_PIN_SET)
#define OLED_SDA_RESET() HAL_GPIO_WritePin(OLED_I2C_SDA_PORT, OLED_I2C_SDA_PIN, GPIO_PIN_RESET)
#define OLED_SDA_SET() HAL_GPIO_WritePin(OLED_I2C_SDA_PORT, OLED_I2C_SDA_PIN, GPIO_PIN_SET)
写gpio的init函数可以参照最早的led.c,在此进行更改
cpp
void oled_gpio_init(void)
{
GPIO_InitTypeDef gpio_initstruct;
OLED_I2C_SCL_CLK();
OLED_I2C_SDA_CLK();
gpio_initstruct.Pin = OLED_I2C_SCL_PIN;
gpio_initstruct.Mode = GPIO_MODE_OUTPUT_PP;
gpio_initstruct.Pull = GPIO_PULLUP;
gpio_initstruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(OLED_I2C_SCL_PORT, &gpio_initstruct);
gpio_initstruct.Pin = OLED_I2C_SDA_PIN;
HAL_GPIO_Init(OLED_I2C_SDA_PORT, &gpio_initstruct);
}
根据之前讲的"告诉",搭出框架,对以下画圈函数进行封装

第2步:写start函数
根据前面所讲起始信号

cpp
void oled_i2c_start(void)
{
OLED_SCL_SET();
OLED_SDA_SET();
OLED_SDA_RESET();
OLED_SCL_RESET();
}

cpp
void oled_i2c_stop(void)
{
OLED_SCL_SET();
OLED_SDA_RESET();
OLED_SDA_SET();
}

cpp
void oled_i2c_ack(void)
{
OLED_SCL_SET();
OLED_SCL_RESET();
}
第3步:读写一个字节/命令/数据

循环8次,那肯定要有个循环因子i嘛
怎么判断最高位是0是1------&0x80(对应二进制:1000 0000)
次高位左移一位,用以上方法继续比较
cpp
void oled_i2c_write_byte(uint8_t data)
{
uint8_t i, tmp;
tmp = data;
for(i = 0; i < 8; i++)
{
if((tmp & 0x80) == 0x80)
OLED_SDA_SET();
else
OLED_SDA_RESET();
tmp = tmp << 1;
OLED_SCL_SET();
OLED_SCL_RESET();
}
}
有了写一个字节,就能进行写命令了,依次发红框里内容

cpp
void oled_write_cmd(uint8_t cmd)
{
oled_i2c_start();
oled_i2c_write_byte(0x78);
oled_i2c_ack();
oled_i2c_write_byte(0x00);
oled_i2c_ack();
oled_i2c_write_byte(cmd);
oled_i2c_ack();
oled_i2c_stop();
}
命令模式 vs 数据模式:
0x00:后续字节是命令 (如初始化、显示控制)。
0x40:后续字节是显示数据(像素数据)。
有了写命令,就能写数据了
cpp
void oled_write_data(uint8_t data)
{
oled_i2c_start();
oled_i2c_write_byte(0x78);
oled_i2c_ack();
oled_i2c_write_byte(0x40);
oled_i2c_ack();
oled_i2c_write_byte(data);
oled_i2c_ack();
oled_i2c_stop();
}
第4步:写oled的init函数
关键命令详解------from官方数据手册
(1) 0xAE / 0xAF -- 显示开关
0xAE:关闭显示(初始化时使用,避免配置过程中闪烁)。
0xAF:最后开启显示。
(2) 0xD5 -- 显示时钟分频比参数 0x80:
高 4 位 0x8:分频比(D=1,表示分频系数 = 2)。
低 4 位 0x0:振荡器频率(默认)。
(3) 0xA8 -- 多路复用率复用率=16:每帧扫描16行,需4个周期完成全屏刷新(64/16=4)。
复用率=64:每帧扫描全部64行,1个周期完成刷新。参数 0x3F:
128x64 OLED 设为 0x3F(64 行)。
128x32 OLED 设为 0x1F(32 行)。
(4) 0xA1 / 0xC8 -- 方向设置0xA1:左右方向(0xA0 左右反转)。
0xC8:上下方向(0xC0 上下反转)。
用途:适应不同的 PCB 布局或屏幕方向。
(5) 0x8D -- 充电泵内置充电泵电路,将低电压(如3.3V)升压至OLED面板所需的高电压(7~15V),驱动像素发光
参数 0x14:
启用内部充电泵(必需,否则 OLED 可能不亮或亮度低)。
(6) 0x81 -- 对比度参数 0xCF:
0x00(最低亮度)~ 0xFF(最高亮度)。
典型值:0x7F ~ 0xCF。
(7) 0xD9 / 0xDB -- 预充电周期 & VCOMH定义每行像素充电前的准备时间,确保电容充分充电,避免拖影或亮度不均。时间过短可能导致充电不足,显示暗淡;时间过长则增加功耗并降低刷新率。
0xD9 0xF1:预充电时间(影响亮度)。
0xDB 0x30:VCOMH 电压(影响对比度)。
cpp
void oled_init(void)
{
oled_gpio_init();
delay_ms(100);
oled_write_cmd(0xAE); //设置显示开启/关闭,0xAE关闭,0xAF开启
oled_write_cmd(0xD5); //设置显示时钟分频比/振荡器频率
oled_write_cmd(0x80); //0x00~0xFF
oled_write_cmd(0xA8); //设置多路复用率
oled_write_cmd(0x3F); //0x0E~0x3F
oled_write_cmd(0xD3); //设置显示偏移
oled_write_cmd(0x00); //0x00~0x7F
oled_write_cmd(0x40); //设置显示开始行,0x40~0x7F
oled_write_cmd(0xA1); //设置左右方向,0xA1正常,0xA0左右反置
oled_write_cmd(0xC8); //设置上下方向,0xC8正常,0xC0上下反置
oled_write_cmd(0xDA); //设置COM引脚硬件配置
oled_write_cmd(0x12);
oled_write_cmd(0x81); //设置对比度
oled_write_cmd(0xCF); //0x00~0xFF
oled_write_cmd(0xD9); //设置预充电周期
oled_write_cmd(0xF1);
oled_write_cmd(0xDB); //设置VCOMH取消选择级别
oled_write_cmd(0x30);
oled_write_cmd(0xA4); //设置整个显示打开/关闭
oled_write_cmd(0xA6); //设置正常/反色显示,0xA6正常,0xA7反色
oled_write_cmd(0x8D); //设置充电泵
oled_write_cmd(0x14);
oled_write_cmd(0xAF); //开启显示
}
第5步:封装坐标函数、清屏函数
OLED 的显示内存(GRAM)按 页(Page) 和 列(Column) 寻址:
页地址(y):
每页高度为 8 像素(1 页 = 8 行)。
例如,y=2 表示从第 2 页(垂直像素位置 16~23)开始写入。
列地址(x):
分为低 4 位和高 4 位发送(I2C 协议限制,单次最多发送 8 位数据)。
例如,x=45(0x2D)会被拆分为 0x0D(低 4 位)和 0x02(高 4 位)。
cpp
void oled_set_cursor(uint8_t x, uint8_t y)
{
oled_write_cmd(0xB0 + y);
oled_write_cmd((x & 0x0F) | 0x00);
oled_write_cmd(((x & 0xF0) >> 4) | 0x10);
}
封装一个清屏函数,要进行两轮循环
外循环page,内循环列
cpp
void oled_fill(uint8_t data)
{
uint8_t i, j;
for(i = 0; i < 8; i++)
{
oled_set_cursor(0, i);
for(j = 0; j < 128; j++)
oled_write_data(data);
}
}
第6步:OLED 显示屏的字符、字符串、汉字和图像显示函数
会用到这个取模软件

要设置好


显示单个字符
cpp
void oled_show_char(uint8_t x, uint8_t y, uint8_t num, uint8_t size)
{
uint8_t i, j, page;
num = num - ' ';
page = size / 8;
if(size % 8)
page++;
for(j = 0; j < page; j++)
{
oled_set_cursor(x, y + j);
for(i = size / 2 * j; i < size /2 * (j + 1); i++)
{
if(size == 12)
oled_write_data(ascii_6X12[num][i]);
else if(size == 16)
oled_write_data(ascii_8X16[num][i]);
else if(size == 24)
oled_write_data(ascii_12X24[num][i]);
}
}
}
字符编码处理:num - ' ' 将 ASCII 码转换为字库数组的索引(空格是第一个字符)。
分页显示:
如果字符高度 size 不是 8 的倍数(如 12、16、24),则计算占用页数 page。例如,size=12 → 12/8=1 余 4 → page=2(占用 2 页)。
字库选择:ascii_6X12:6x12 像素(宽 6,高 12)。
ascii_8X16:8x16 像素。
ascii_12X24:12x24 像素。
显示字符串
cpp
void oled_show_string(uint8_t x, uint8_t y, char *p, uint8_t size)
{
while(*p != '\0') // 遍历字符串直到结束符
{
oled_show_char(x, y, *p, size); // 显示当前字符
x += size / 2; // 移动光标到下一个字符位置(字符宽度=size/2)
p++; // 指向下一个字符
}
}
自动换行 :通过
x += size/2实现字符水平排列。字符间距 :
size/2是字符宽度(如size=16→ 字符宽 8 像素)。终止条件 :遇到
'\0'(字符串结束符)停止。
显示汉字
cpp
void oled_show_chinese(uint8_t x, uint8_t y, uint8_t N, uint8_t size)
{
uint16_t i, j;
for(j = 0; j < size/8; j++)
{
oled_set_cursor(x, y + j);
for(i = size *j; i < size * (j + 1); i++)
{
if(size == 16)
oled_write_data(chinese_16x16[N][i]);
else if(size == 24)
oled_write_data(chinese_24x24[N][i]);
}
}
}
汉字编码: N 是汉字在字库中的索引(如 N=0 表示第一个汉字)。
字库选择:chinese_16x16:16x16 像素(宽 16,高 16)。
chinese_24x24:24x24 像素。
分页写入:size/8 计算占用页数(如 size=16 → 2 页)。
每页写入 size 字节(如 size=16 → 每页 16 字节)。
显示图像
cpp
void oled_show_image(uint8_t x, uint8_t y, uint8_t width, uint8_t height, uint8_t *bmp)
{
uint8_t i, j;
for(j = 0; j < height; j++)
{
oled_set_cursor(x, y + j);
for(i = 0; i < width; i++)
oled_write_data(bmp[width * j + i]);
}
}
位图格式: bmp 是一个一维数组,按行优先存储(width * j + i)。
支持任意尺寸: width 和 height 可自定义(如 64x32、128x64)。
**数据来源:**bmp 可以是静态数组或动态生成的图像数据。




