1. I2C总线通信基础解析
I2C(Inter-Integrated Circuit)总线是Philips公司开发的一种简单、双向二线制同步串行总线。作为嵌入式开发中最常用的通信协议之一,I2C以其简洁的硬件连接和灵活的通信方式,在传感器、EEPROM、RTC等外设连接中广泛应用。我初次接触I2C时,最惊讶的是仅用两根线就能实现多设备通信,这背后其实有一套精妙的设计逻辑。
1.1 物理层特性
I2C总线由两根信号线组成:
- SCL(Serial Clock):时钟线,由主机控制,用于同步数据传输
- SDA(Serial Data):数据线,用于双向数据传输
实际电路设计中必须注意:
- 两条线都采用开漏输出设计,配合上拉电阻(通常4.7kΩ)
- 空闲状态下两条线都保持高电平(通过上拉电阻实现)
- 任何设备都可以将线路拉低(强下拉),但释放后必须回到高电平(弱上拉)
重要提示:上拉电阻值需要根据总线电容计算,过小会导致功耗增加,过大会影响上升沿速度。一般400kHz总线用4.7kΩ,100kHz可用10kΩ。
1.2 通信特性
I2C具有几个关键特征:
- 半双工通信:同一时刻只能进行发送或接收
- 同步传输:依赖SCL时钟信号
- 多主多从:支持多个主机设备(需冲突检测)
- 地址寻址:每个从设备有唯一地址(7位或10位)
在实际项目中,我发现大多数应用场景采用一主多从模式。例如,STM32作为主机,连接多个传感器从机。7位地址模式下最多可挂载112个设备(地址0x00-0x7F中有些保留地址不可用)。
2. I2C协议时序详解
2.1 基本时序单元
起始和停止条件
- 起始条件(S):SCL高电平时,SDA从高→低跳变
- 停止条件(P):SCL高电平时,SDA从低→高跳变
这两个条件都由主机产生。我在调试时常用逻辑分析仪捕捉这些信号,发现起始条件后总线必须等待至少4.7μs(标准模式)才能开始传输数据。
数据传输时序
每个数据位传输都遵循:
- SCL低电平期间:发送方改变SDA数据线
- SCL高电平期间:数据保持稳定,接收方采样

关键细节:
- 数据变化只能在SCL低电平时发生
- 采用MSB(高位优先)传输方式
- 每个字节后跟随一个应答位(ACK/NACK)
2.2 三种基本操作时序
2.2.1 指定地址写操作
用于向从设备的特定寄存器写入数据:
- 发送起始条件
- 发送从机地址+写位(0)
- 等待从机应答(ACK)
- 发送寄存器地址
- 等待从机应答
- 发送数据字节
- 等待从机应答
- 发送停止条件
c复制// 伪代码示例
void I2C_WriteReg(uint8_t devAddr, uint8_t regAddr, uint8_t data) {
StartCondition();
SendByte(devAddr << 1 | 0); // 写模式
WaitACK();
SendByte(regAddr);
WaitACK();
SendByte(data);
WaitACK();
StopCondition();
}
2.2.2 当前地址读操作
读取从设备当前指针指向的寄存器:
- 发送起始条件
- 发送从机地址+读位(1)
- 等待从机应答
- 接收数据字节
- 发送非应答(NACK)
- 发送停止条件
注意:很多从设备在读写操作后会自动递增地址指针,这是实现连续读写的关键。
2.2.3 指定地址读操作
组合前两种操作,先设置地址再读取:
- 执行指定地址写操作(不发送数据)
- 发送重复起始条件(Sr)
- 执行当前地址读操作
c复制uint8_t I2C_ReadReg(uint8_t devAddr, uint8_t regAddr) {
// 先设置寄存器地址
StartCondition();
SendByte(devAddr << 1 | 0); // 写模式
WaitACK();
SendByte(regAddr);
WaitACK();
// 重复起始条件
StartCondition();
SendByte(devAddr << 1 | 1); // 读模式
WaitACK();
uint8_t data = ReceiveByte();
SendNACK();
StopCondition();
return data;
}
3. 软件模拟I2C实现
当硬件I2C资源不足或需要灵活配置引脚时,软件模拟是很好的选择。我在多个项目中使用GPIO模拟I2C,总结出以下关键点:
3.1 基本函数实现
c复制// GPIO初始化
void I2C_GPIO_Init() {
GPIO_InitTypeDef GPIO_InitStruct;
// 配置SCL和SDA为开漏输出
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(I2C_PORT, &GPIO_InitStruct);
// 初始状态置高
HAL_GPIO_WritePin(I2C_PORT, SCL_PIN|SDA_PIN, GPIO_PIN_SET);
}
// 产生起始条件
void I2C_Start() {
SDA_HIGH();
SCL_HIGH();
Delay_us(4); // 保持时间
SDA_LOW();
Delay_us(4);
SCL_LOW();
}
// 产生停止条件
void I2C_Stop() {
SDA_LOW();
SCL_LOW();
Delay_us(4);
SCL_HIGH();
Delay_us(4);
SDA_HIGH();
Delay_us(4);
}
3.2 字节传输实现
c复制// 发送一个字节
uint8_t I2C_SendByte(uint8_t byte) {
for(int i=0; i<8; i++) {
SCL_LOW();
if(byte & 0x80) SDA_HIGH(); // MSB first
else SDA_LOW();
Delay_us(2);
SCL_HIGH();
Delay_us(2);
byte <<= 1;
SCL_LOW();
}
// 接收ACK
SDA_HIGH(); // 释放SDA
SCL_HIGH();
uint8_t ack = !SDA_READ();
SCL_LOW();
return ack;
}
// 接收一个字节
uint8_t I2C_ReceiveByte(uint8_t ack) {
uint8_t byte = 0;
SDA_HIGH(); // 释放SDA
for(int i=0; i<8; i++) {
SCL_LOW();
Delay_us(2);
SCL_HIGH();
byte = (byte << 1) | SDA_READ();
Delay_us(2);
}
// 发送ACK/NACK
SCL_LOW();
if(ack) SDA_LOW();
else SDA_HIGH();
SCL_HIGH();
Delay_us(2);
SCL_LOW();
SDA_HIGH(); // 释放SDA
return byte;
}
3.3 软件I2C的注意事项
- 时序精度:必须严格保证时序间隔,特别是标准模式(100kHz)和快速模式(400kHz)
- 中断处理:最好在传输过程中禁用中断,避免时序被打断
- 总线恢复:异常时需发送多个时钟脉冲帮助从机复位
- 上拉电阻:即使软件模拟,物理线路仍需上拉电阻
我在实际项目中遇到过因中断干扰导致的通信失败,后来采用以下改进措施:
- 在关键时序段关闭中断
- 增加超时检测机制
- 添加总线状态恢复函数
4. 硬件I2C模块使用(以STM32为例)
STM32内置硬件I2C外设能大幅减轻CPU负担。以STM32F1系列为例:
4.1 硬件初始化
c复制void MX_I2C1_Init(void) {
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000; // 100kHz
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c1.Init.OwnAddress1 = 0;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
hi2c1.Init.OwnAddress2 = 0;
hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
HAL_I2C_Init(&hi2c1);
}
// GPIO配置
void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
if(hi2c->Instance == I2C1) {
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_I2C1_CLK_ENABLE();
// PB6-SCL, PB7-SDA
GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7;
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD; // 复用开漏
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}
}
4.2 硬件I2C的读写操作
使用HAL库可以简化操作:
c复制// 写寄存器
HAL_I2C_Mem_Write(&hi2c1, devAddr<<1, regAddr,
I2C_MEMADD_SIZE_8BIT, &data, 1, 100);
// 读寄存器
HAL_I2C_Mem_Read(&hi2c1, devAddr<<1, regAddr,
I2C_MEMADD_SIZE_8BIT, &data, 1, 100);
4.3 硬件I2C常见问题排查
-
通信失败:
- 检查物理连接和上拉电阻
- 确认从机地址正确(通常左移1位)
- 用示波器观察波形质量
-
时钟速率问题:
- 确保不超过从设备支持的最大速率
- 检查APB1时钟配置(I2C挂载在APB1)
-
DMA配置:
- 连续传输时建议使用DMA
- 注意DMA缓冲区对齐问题
我在使用STM32硬件I2C时,发现库函数有时会卡在等待标志位状态。解决方案是:
- 增加超时检测
- 异常时调用
HAL_I2C_Init()重新初始化 - 在
I2C_ITError中断中处理错误
5. CRC校验在I2C中的应用
虽然I2C协议本身不强制要求CRC校验,但在高可靠性应用中值得考虑。我曾在工业传感器项目中实现过CRC-8校验:
5.1 CRC校验原理
CRC校验本质是模2除法:
- 发送方:计算数据CRC值并附加到帧尾
- 接收方:重新计算CRC并与接收值比较
5.2 CRC-8实现示例
c复制uint8_t Calc_CRC8(uint8_t *data, uint8_t len) {
uint8_t crc = 0xFF; // 初始值
const uint8_t poly = 0x31; // CRC-8多项式
for(uint8_t i=0; i<len; i++) {
crc ^= data[i];
for(uint8_t j=0; j<8; j++) {
if(crc & 0x80) {
crc = (crc << 1) ^ poly;
} else {
crc <<= 1;
}
}
}
return crc;
}
// 使用示例
uint8_t data[4] = {0x12, 0x34, 0x56, 0x78};
uint8_t crc = Calc_CRC8(data, 4);
5.3 CRC校验注意事项
-
发送和接收方必须使用相同的:
- 多项式
- 初始值
- 结果异或值
- 输入/输出是否反转
-
常见标准:
- CRC-8:常用多项式0x31(SMBus)
- CRC-16:常用多项式0x8005(Modbus)
-
硬件加速:某些STM32型号支持硬件CRC计算
在实际项目中,我发现CRC校验虽然增加了少量开销,但能有效发现传输错误。特别是在工业环境中,电气噪声可能导致偶发通信错误,CRC校验能提高系统可靠性。
