目录
一、有了串口协议为什么还需要IIC协议?
二、IIC协议简介
三、IIC通信协议时序
四、仲裁机制
(1)基本检测
(2)SDA回读
(3)低电平优先
(4)总结
五、IIC通信的全过程
(1)初始化
IIC模块示意图
AFIO复用配置
IIC模块配置
(2)起始信号
(3)地址帧+读写标志
(4)数据帧
(5)应答位ACK
(6)停止通信
六、对比计算机网络通信
七、代码示例
一、有了串口协议为什么还需要IIC协议?
在之前的文章中,我们介绍了UART串口通信的协议。而且由于串口通信是直接让两台主机经过直连得到的。那么两台主机就仅仅只需要最简单的数据帧封装,来区别每一帧的信息,这也是数据链路层和物理层的本质区别。
但是我们发现UART串口通信协议有许多不足的地方,比如必须是点到点端到端的通信,即每一个想要和主机通信的都要一套UART接口,那么一台主机能进行串口通信的数量,则取决于该主机的UART通信接口有多少。比如STM32F103C8T6系列的单片机,就只有3组串口引脚,所以通信会受限于物理硬件的数量。
然后随着通信的需求不断增加,我们必须要设计另一种协议,能使得更多的机器通过一组接口连接到主机,进行通信。IIC通信协议则应运而生。
二、IIC协议简介
I2C(集成电路总线),由Philips公司(2006年迁移到NXP)在1980年代初开发的一种简单、双线双向的同步串行总线,它利用一根时钟线和一根数据线在连接总线的两个器件之间进行信息的传递,为设备之间数据交换提供了一种简单高效的方法。每个连接到总线上的器件都有唯一的地址,任何器件既可以作为主机也可以作为从机,但同一时刻只允许有一个主机。
IIC是一个具有冲突检测机制和仲裁机制的多主机总线协议。他能再多个主机同时请求控制总线,传输数据的时候利用仲裁机制避免数据冲突,并保护数据。

简单总结一下,IIC具有以下特点:
(1)仅需SDA、SCL两根总线,硬件要求低。
(2)没有严格的波特率要求,由SCL时钟线来控制。
(3)IIC提供仲裁机制和冲突检测,能有效避免多个主机的数据碰撞问题。
(4)理论上一个子网可以有2^7或者2^10台主机,但有一部分有特殊用途,比如广播地址。
特殊地址:
比如广播地址,对于每一个连接到该IIC总线上的机器都会接受该消息。是不是有点像网络层中的IP地址,每一台主机在一个子网内都有属于自己的IP地址,并且各个主机通过不同的IP地址进行区分,且在网段下全为1则表示广播地址。

三、IIC通信协议时序
通常情况下,一个完整的I2C通信过程包括以下 4 部分:
- 起始信号
- 地址传送
- 数据传送
- 停止信号
主机在 SCL 线上输出串行时钟信号,数据在 SDA 线上进行传输,每传输一个字节(最高位 MSB 开始传输)后面跟随一个应答位,一个 SCL 时钟脉冲传输一个数据位。

四、仲裁机制
如果在通信的时候,多台主机同一时刻向别的机器发数据,由于IIC协议有且仅有2根线,那么一台主机拉高电平,另一台主机拉低电平,那总线到底是高电平还是低电平呢?我要传输的数据到底是什么就不得而知了。
为了避免这种情况的产生。IIC协议中提供了一种仲裁机制。
(1)基本检测
当主机A想要发送数据的时候,首先需要检测总线是否正在被占用。如果被占用则不发送数据,等一段时间再次尝试。如果没有被占用则主机在SCL为高电平时将SDA拉低,生成起始信号(START)
主设备在发起通信前,需在SCL为高电平时检测SDA是否为高电平。(一般操作是让该设备释放两根线,由上拉电阻把他们都拉高,并且持续检测两根线的电平情况,直到两根线同时为高电平)
while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); // 等待总线空闲
突破了基本检测屏障的主机,会向总线发送起始信号。
I2C_GenerateSTART(I2C1, ENABLE); // 发送起始条件
(2)SDA回读
此时仍然会有多台主机突破了基本检测这一屏障。那么是不是意味着就获取了主线的控制权呢?并非如此。
他们会同时向总线发送起始信号,并发送第一个数据帧-----地址+读写帧。在发送第一个数据帧的时候会持续检测SDA总线的电平情况,并一位位的和自己的数据进行比对,一旦发现一个位和自己不同,则说明有其他机器在使用总线。则自己立马退出。经过一轮的竞争后只会有一台机器占有总线。
也有人把SDA回读称为数据检测机制。因为SDA回读并非只发生在起始信号阶段,也有可能第一个数据帧里面的地址都是一样的,则需要在每一次发送数据后进行等待ACK回应。并且在发送第二个字节(数据帧)的时候能进一步检测是不是自己占用了总线。
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 等待EV5(起始条件已发送)
这句代码就是在利用SDA回读机制判断当前主机是否成功抢占总线。如果从这个循环 退出了,则证明抢占到主线了,则可以进行后续的数据发送。
如果一直抢不到总线,则可以设置超时机制,防止该主机卡死在这一步。比如可以这样写:
uint32_t timeout = 1000; // 超时时间(根据时钟频率调整)
while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT) && --timeout) {delay_us(1);
}
if (timeout == 0) {// 处理超时错误(如复位IIC总线)
}
(3)低电平优先
由于下拉能力往往比上拉电阻的能力强,所以发送高电平的主机会意识到别人正在使用,而发低电平的则以为此时只有自己在使用。则发送高电平的主机退出竞争,而并不会影响低电平数据的发送。
(4)总结
虽然仲裁机制极大程度的防止了数据碰撞而造成混乱的问题。但是这也仅仅是保证了数据的正确性。
还有可能出现以下问题:
(1)多个主设备可能误判总线空闲,导致冲突并触发仲裁。通常需实现退避算法(如随机延时后重试),避免频繁冲突。
(2)还有可能出现两个设备同时想发送消息,且其中一个正是接收方,由于他忙于发送消息,则会错过通信。这里可以仿照TCP协议实现超时重传机制,让发送方重新发送消息即可。
五、IIC通信的全过程
(1)初始化
IIC模块示意图

在双方通信之前,各自的IIC模块是必须要配置好的。想要配置好硬件模块我们先来看看其结构示意图。
先找到该芯片的引脚分布
为了连接的方便性,我们不选择使用原本的引脚,而是让IIC1复用到PB8、PB9的位置
AFIO复用配置
//1.重映射RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE);GPIO_PinRemapConfig(GPIO_Remap_I2C1,ENABLE);//2.对PB8、PB9初始化为开漏复用输出模式RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE);GPIO_InitTypeDef gpio_init_struct;gpio_init_struct.GPIO_Pin=GPIO_Pin_8 | GPIO_Pin_9;gpio_init_struct.GPIO_Mode=GPIO_Mode_AF_OD;gpio_init_struct.GPIO_Speed=GPIO_Speed_2MHz;GPIO_Init(GPIOB,&gpio_init_struct);
IIC模块配置


占空比
我们之前说过IIC通信的关键是在每个时钟周期SDA产生不同的高低电平,从而传输数据。但是实际上这样操作存在信号稳定性、设备兼容性等隐患。占空比的意义在于通过动态调整高电平/低电平比例,优化信号质量、适应不同设备需求,并解决时钟同步等潜在问题。
因为IIC会存在很多设备挂载到一条线上,那么多个设备的电容就会累加到总线上,电容越大电平的变化就越慢,此时配置占空比可以忽略掉由电容引起的错误。因为IIC模块会在SCL上升沿的时候开始准备数据采样,在SCL高电平稳定的时候真正采样。而SDA的变化由于电容大小的不同会消耗不同的时间,此时让占空比加大就是在给SDA变化提供更多的时间,即延缓SCL在一个时钟周期内产生上升沿的时机。


这里有一个复位信号,就相当于重启IIC模块。

//3.配置IIC模块RCC_APB1PeriphClockCmd(I2C1,ENABLE);RCC_APB1PeriphResetCmd(I2C1,ENABLE);RCC_APB1PeriphResetCmd(I2C1,DISABLE);I2C_InitTypeDef i2C_init_struct;i2C_init_struct.I2C_ClockSpeed=400000;i2C_init_struct.I2C_Mode=I2C_Mode_I2C;i2C_init_struct.I2C_DutyCycle=I2C_DutyCycle_2;I2C_Cmd(I2C1,ENABLE);
(2)起始信号
起始信号的作用是让处于休眠状态的从机进入活动状态。
当总线上的主机都不驱动总线,总线进入空闲状态, SCL 和 SDA 都为高电平。总线空闲状态下总线上设备都可以通过发送开始条件启动通信。当 SCL 线为高时,SDA 线上出现由高到低的信号,表明总线上产生了起始信号。当 SCL 线为高时,SDA 线上出现由低到高的信号,表明总线上产生了停止信号,如下图所示:

(3)地址帧+读写标志
开始条件或者重新开始条件后面的帧是地址帧(一个字节),用于指定主机通信的对象地址,在发送停止条件之前,指定的从机一直有效。
I2C通讯支持:7 位寻址和10 位寻址两种模式。
7 位寻址模式,地址帧(8bit)的高 7 位为从机地址,地址帧第 8 位来决定数据帧传送的方向:7 位从机地址 + 1位 读/写位,读/写位控制从机的数据传输方向(0:写; 1:读) 。帧格式如下所示:
每个从机将主机发送的地址与自己的进行比对,如果地址匹配则发送ACK(即将SDA拉低);如果不匹配则无事发生在上拉电阻的作用下自动让SDA处于高电平。
IIC还支持10位地址,但是本人还没使用过,并未深刻研究其报文格式。
(4)数据帧
地址匹配一致后,总线上的主机根据 R/W 定义的方向一帧一帧的传送数据。 所有的地址帧后传送的数据都视为数据帧。数据帧的长度是 8 位。
SCL 的低电平 SDA 变化, SCL 的高电平 SDA 保持,方便目标从机读取。每个时钟周期发送/读取一位数据。数据帧开始后的第 9 个时钟是应答位,是接收方向发送方传送的握手信号。

如果总线上从机接收数据,在第 9 个时钟周期不响应主机,从机必须发送 NACK。简而言之,接收方如果不想回复,也必须回复一个NACK。注意:NACK/ACK只能由接收方发送!
当主机接收到了NACK后,数据传送终止。主机可以做下列任一动作:
发送停止条件释放总线 ;
发送重新开始条件开始一个新的通信。
(5)应答位ACK
每传输一个字节,后面跟随一个应答位。ACK一定是接收设备发送的。通过将 SDA 线拉低,来允许接收端回应发送端。ACK 为 一个低电平信号,当时钟信号为高时, SDA 保持低电平则表明接收端已成功接收到发送端的数据。
当主机作为发送器件时,如果从机上产生无响应信号(NACK) ,主机可以产生停止信号来退出数据传输,或者产生重复起始信号开始新一轮的数据传输。当主机作为接收器件时,发送无响应信号(NACK) ,从机释放 SDA 线,使主机产生停止信号或重复起始信号。
(6)停止通信
ACK/NACK由硬件自动发送,无需软件干涉。
当接收设备发送了NACK之后,主设备可以在自己的代码逻辑中选择重传、或者终止通信。如果是终止通信,则主机将SCL置高电平,在此期间将SDA置高电平,从机会由硬件接受到该停止信号,进入睡眠状态。
六、对比计算机网络通信
(1)IIC协议与网络层的TCP很相似,因为在通信之间他们都建立了连接。比如IIC通过发送从机地址+检测从机ACK应答确认从机是否在线而TCP通过三次握手四次挥手建立通信。
(2)IIC协议建立的是半双工通信,即同一时刻只能有一端发另一端接收。而TCP则是全双工通信。
(3)TCP具有更完整可靠的丢包重传和序列化机制,IIC需要开发者手动实现。
(4)IIC针对短距离通信,通常用于一块电路板内部各个模块使用。而TCP则是全球网络通信。
(5)嵌入式设备通常CPU算力、内存有限,无法支持TCP的复杂协议栈(如滑动窗口、拥塞控制)。
之前我们讨论过UART串口通信协议。他是无连接的(因为他直接通过物理线将两台机器连接,所以不存在发送地址建立连接的情况)。我们可以把他类比成UDP,而IIC类比TCP。只不过他们由于硬件资源的限制,所以是极简版本的UDP/TCP。
UART与UDP:通过牺牲可靠性换取极简硬件与低延迟,适用于对实时性要求高、允许一定错误率的场景。
I²C与TCP:通过引入连接管理与可靠性机制换取多设备支持与数据完整性,适用于对稳定性要求高的场景。
不同协议都会有自己适应的场景,并不能说谁优于谁,而是要对硬件资源要求以及对数据的准确度进行平衡,以达到最佳的使用场景。
七、代码示例
在这里我只给出了发送字节的代码,接收数据的代码也类似,大家可以自己尝试一下。
然后这里值得注意的的是在判断SDA回读、ACK响应我并没有使用原生的寄存器来判断,而是使用了标注库函数已经封装好的事件标志位来判断,一方面他能提高代码的可读性、另一方面可以让开发者不再需要关心底层寄存器到底是谁。而且由于标准库是ST公司官方提供的,相较于我们手动编写寄存器方式可靠性也较好。

#include "stm32f10x.h" // Device header
#include "stm32f10x_gpio.h"
#include "stm32f10x_rcc.h"
#include "stm32f10x_i2c.h"// I2C1 引脚定义
#define I2C1_SCL_PIN GPIO_Pin_6
#define I2C1_SDA_PIN GPIO_Pin_7
#define I2C1_GPIO_PORT GPIOB
#define I2C1_RCC RCC_APB2Periph_GPIOB// I2C1 时钟和速度配置
#define I2C1_SPEED 100000 // 100kHz
#define I2C1_DUTY_CYCLE I2C_DutyCycle_2void My_I2C_Init(void)
{GPIO_InitTypeDef gpio_init_struct;I2C_InitTypeDef i2c_init_struct;//1.开启GPIO和IIC模块的时钟RCC_APB2PeriphClockCmd(I2C1_RCC,ENABLE);RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1,ENABLE);//2.配置GPIO为复用输出开漏模式gpio_init_struct.GPIO_Mode=GPIO_Mode_AF_OD;gpio_init_struct.GPIO_Pin=I2C1_SCL_PIN | I2C1_SDA_PIN;gpio_init_struct.GPIO_Speed=GPIO_Speed_50MHz;//3.配置IIC模块的参数i2c_init_struct.I2C_Mode=I2C_Mode_I2C; //标准IIC模式、支持主设备从设备切换i2c_init_struct.I2C_DutyCycle=I2C_DutyCycle_2; //占空比i2c_init_struct.I2C_Ack=I2C_Ack_Enable; //启用ACK应答,每次接收消息后都发送ACK而不是发送NACKi2c_init_struct.I2C_OwnAddress1=0x01; //自身设备的地址(随便设置一个),让别人能找到并通过地址给你发消息。如果你就是唯一主机则不用设置这一行i2c_init_struct.I2C_AcknowledgedAddress=I2C_AcknowledgedAddress_7bit; //7位地址模式i2c_init_struct.I2C_ClockSpeed=I2C1_SPEED; //波特率设置//4.初始化IIC,并开启IIC总开关I2C_Init(I2C1,&i2c_init_struct);I2C_Cmd(I2C1,ENABLE);
}void My_I2C_Write_One_Byte(uint8_t slaveAddress,uint8_t data)
{//1.等待总线空闲while(I2C_GetFlagStatus(I2C1,I2C_FLAG_BUSY));//2.发送起始条件I2C_GenerateSTART(I2C1,ENABLE);while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_MODE_SELECT));//等待起始条件发送完成//3.发送从机地址(唤醒从机)I2C_Send7bitAddress(I2C1,slaveAddress,I2C_Direction_Transmitter);while(I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));//等待从机响应ACK//4.发送数据字节I2C_SendData(I2C1, data);while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 等待字节发送完成//5.发送停止条件I2C_GenerateSTOP(I2C1, ENABLE);
}void My_I2C1_Write_Multiple_Bytes(uint8_t slaveAddr, uint8_t *data, uint16_t len)
{// 1. 等待总线空闲while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY));// 2. 发送起始条件I2C_GenerateSTART(I2C1, ENABLE);while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));// 3. 发送从机地址(写模式)I2C_Send7bitAddress(I2C1, slaveAddr, I2C_Direction_Transmitter);while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));// 4. 循环发送数据字节for (uint16_t i = 0; i < len; i++) {I2C_SendData(I2C1, data[i]);while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));}// 5. 发送停止条件I2C_GenerateSTOP(I2C1, ENABLE);
}int main(void)
{while(1){}
}