简介
IIC(Inter-Integrated Circuit)是一种串行通信的总线协议。在这条总线上可以挂载多个设备,其中控制时钟信号且可收发数据的称为主设备,只能收发数据的称为从设备。在我们学习一种通信协议的时候,需要搞清除三个问题:物理接口、时序和通信过程。下面就从这三个方面来了解IIC。
物理接口
IIC有两个接口,时钟线SCL和数据线SDA。SCL负责时钟信号,SDA则用来传输数据。
时序
在开始介绍时序前,先了解一段关于IIC的小历史。在学习IIC的时候,经常能遇到教程中使用任意的GPIO,通过软件模拟的方式来模拟IIC的时序信号,而不是使用STM32提供的硬件IIC。这是因为在过去流行使用标准库的时代,硬件IIC的库函数存在固有缺陷。这里的缺陷主要有以下几点:
- 1.硬件I2C的库函数存在固有缺陷
- 总线状态处理问题:标准库的硬件I2C驱动在总线异常(如干扰、从机无响应)时容易陷入“死等”状态。例如,当I2C总线处于“忙”状态时,硬件I2C可能因未正确处理超时机制而卡死,导致程序无法继续运行
- ACK信号处理缺陷:在读取数据时,若从机未正确返回ACK信号,硬件I2C可能无法自动恢复,需手动干预总线状态(如强制拉高SDA/SCL)才能解除阻塞
- 2.抗干扰能力不足
- 硬件I2C对时序要求严苛:硬件I2C的时序由状态机控制,若总线受到干扰(如电压波动、信号毛刺),可能导致状态机进入异常状态,需重启外设或复位芯片才能恢复
因此,为了避免上述问题,往往使用软件模拟I2C通过GPIO手动控制时序,开发者可灵活加入超时检测和错误恢复逻辑,避免程序死锁。
如今,ST官方已经停止维护标准库,转而推广HAL库,而HAL库的硬件I2C驱动通过优化状态机设计和超时机制,上面这些问题已经得到了解决。
所以在日常开发中,建议根据使用场景来灵活的选择是使用软件模拟IIC,还是硬件IIC。
一般,在适合低速(100kHz)、高干扰的环境或需要快速移植的项目中使用软件模拟IIC,若需要高性能(400kHz)且使用HAL库,则可以使用硬件IIC。
下面在介绍时序的时候,为了更直观的看到IIC的工作过程,会给出软件模拟IIC的代码。在最后会给出HAL库下的IIC代码。
IIC总线时序图如下,总共可以分为以下几部分。
1.起始信号
当SCL为高电平时,SDA从高到低为起始信号。
void iic_start(void){
IIC_SDA(1);
IIC_SCL(1);
iic_delay();
IIC_SDA(0); /* START信号: 当SCL为高时, SDA从高变成低, 表示起始信号 */
iic_delay();
IIC_SCL(0); /* 钳住I2C总线,准备发送或接收数据 */
iic_delay();
}
2.停止信号
当SCL为高电平时,SDA从低跳变到高为停止信号
void iic_stop(void){
IIC_SDA(0); /* STOP信号: 当SCL为高时, SDA从低变成高, 表示停止信号 */
iic_delay();
IIC_SCL(1);
iic_delay();
IIC_SDA(1); /* 发送I2C总线结束信号 */
iic_delay();
}
3.应答信号
在SCL的第九个脉冲时,SDA为低电平时,为有效应答信号ACK。SDA为高电平时,为无效应答信号NACK。
/**
* @brief 产生ACK应答
*/
void iic_ack(void){
IIC_SDA(0); /* SCL 0 -> 1 时 SDA = 0,表示应答 */
iic_delay();
IIC_SCL(1); /* 产生一个时钟 */
iic_delay();
IIC_SCL(0);
iic_delay();
IIC_SDA(1); /* 主机释放SDA线 */
iic_delay();
}
/**
* @brief 不产生ACK应答
*/
void iic_nack(void){
IIC_SDA(1); /* SCL 0 -> 1 时 SDA = 1,表示不应答 */
iic_delay();
IIC_SCL(1); /* 产生一个时钟 */
iic_delay();
IIC_SCL(0);
iic_delay();
}
/**
* @brief 等待应答信号到来
* @param 无
* @retval 1,接收应答失败
* 0,接收应答成功
*/
uint8_t iic_wait_ack(void){
uint8_t waittime = 0;
uint8_t rack = 0;
IIC_SDA(1); /* 主机释放SDA线(此时外部器件可以拉低SDA线) */
iic_delay();
IIC_SCL(1); /* SCL=1, 此时从机可以返回ACK */
iic_delay();
while (IIC_READ_SDA) /* 等待应答 */
{
waittime++;
if (waittime > 250)
{
iic_stop();
rack = 1;
break;
}
}
IIC_SCL(0); /* SCL=0, 结束ACK检查 */
iic_delay();
return rack;
}
4.数据有效性
在SCL高电平期间,SDA的电平即为发送/接收的数据。只有在SCL下降沿期间,SDA才允许变化。数据必须在SCL上升沿到来前稳定。
/**
* @brief IIC发送一个字节
* @param data: 要发送的数据
*/
void iic_send_byte(uint8_t data){
uint8_t t;
for (t = 0; t < 8; t++)
{
IIC_SDA((data & 0x80) >> 7); /* 高位先发送 */
iic_delay();
IIC_SCL(1);
iic_delay();
IIC_SCL(0);
data <<= 1; /* 左移1位,用于下一次发送 */
}
IIC_SDA(1); /* 发送完成, 主机释放SDA线 */
}
/**
* @brief IIC读取一个字节
* @param ack: ack=1时,发送ack; ack=0时,发送nack
* @retval 接收到的数据
*/
uint8_t iic_read_byte(uint8_t ack){
uint8_t i, receive = 0;
for (i = 0; i < 8; i++ ) /* 接收1个字节数据 */
{
receive <<= 1; /* 高位先输出,所以先收到的数据位要左移 */
IIC_SCL(1);
iic_delay();
if (IIC_READ_SDA)
{
receive++;
}
IIC_SCL(0);
iic_delay();
}
if (!ack)
{
iic_nack(); /* 发送nACK */
}
else
{
iic_ack(); /* 发送ACK */
}
return receive;
}
通信过程
先来看看写的通信过程
如图所示,写操作的流程是主机发送起始信号S,给出从机地址(7位),数据方向(1位),从机给出应答ACK,主机发出数据DATA,从机回复应答ACK,最后主机发出停止信号。
/**
* @brief 写入一个数据
* @param addr: 从机地址
* @param data: 要写入的数据
*/
void write_one_byte(uint16_t addr, uint8_t data){
iic_start(); /* 发送起始信号 */
iic_send_byte(addr); /* 发送地址 */
iic_wait_ack(); /* 每次发送完一个字节,都要等待ACK */
/* 因为写数据的时候,不需要进入接收模式了,所以这里不用重新发送起始信号了 */
iic_send_byte(data); /* 发送1字节 */
iic_wait_ack(); /* 等待ACK */
iic_stop(); /* 产生一个停止条件 */
delay_ms(10); /* 注意: EEPROM 写入比较慢,必须等到10ms后再写下一个字节 */
}
读操作流程
读操作与写操作类似,区别在于从机地址后的读写位,以及数据和应答信号的发送方不一样。
/**
* @brief 读出一个数据
* @param addr: 从设备地址
* @retval 读到的数据
*/
uint8_t read_one_byte(uint16_t addr)
{
uint8_t temp = 0;
iic_start(); /* 发送起始信号 */
iic_send_byte(addr); /* 发送低位地址据 */
iic_wait_ack(); /* 每次发送完一个字节,都要等待ACK */
temp = iic_read_byte(0); /* 接收一个字节数据 */
iic_stop(); /* 产生一个停止条件 */
return temp;
}
HAL库
与软件模拟IIC的方式比起来,HAL库的使用十分简单。以STM32F103ZET6的I2C1为演示,使用的PB6和PB7引脚。
首先初始化
I2C_HandleTypeDef hi2c = {0}; /* 句柄 */
void iic_init(void){
hi2c.Instance = I2C1;
hi2c.Init.ClockSpeed = 400000;
hi2c.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c.Init.OwnAddress1 = 0;
hi2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
hi2c.Init.OwnAddress2 = 0;
hi2c.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
hi2c.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
HAL_I2C_Init(&hi2c);
}
void HAL_I2C_MspInit(I2C_HandleTypeDef *hi2c){
if(hi2c->Instance == I2C1){
// 使能时钟
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_I2C1_CLK_ENABLE();
// 初始化GPIO
GPIO_InitTypeDef gpio_init = {0};
// SCL
gpio_init.Pin = GPIO_PIN_6;
gpio_init.Mode = GPIO_MODE_AF_OD;
gpio_init.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(IIC_SCL_PORT, &gpio_init);
// SDA
gpio_init.Pin = GPIO_PIN_7;
HAL_GPIO_Init(IIC_SDA_PORT, &gpio_init);
}
}
接下来只需要调用读写函数就可以了。这里给出阻塞方式下的常用读写函数。DMA与中断方式的可以参考HAL库。
/* 主机发送数据 */
HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);
/* 主机读取数据 */
HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);
/* 主机向指定地址发送数据 */
HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);
/* 主机从指定地址读取数据 */
HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);
下面给出一些使用例子
/* OLED通过iic发送数据 */
#define OLED_ADDRESS 0x78 // OLED地址
void OLED_Send(uint8_t *data, uint8_t len){
HAL_I2C_Master_Transmit(&hi2c, OLED_ADDRESS, data, len, HAL_MAX_DELAY);
}
/*****************************************************************************/
/* AT24C02通过IIC指定地址读写 */
#define EEPROM_ADDR 0xA0 // AT24C02地址
#define WRITE_DELAY 5 // 写入延时(单位ms)
/* 写入24C02 */
HAL_StatusTypeDef EEPROM_Write(uint16_t mem_addr, uint8_t *data, uint16_t size) {
// 24C02的地址是8位,所以使用单字节地址模式
HAL_StatusTypeDef status = HAL_I2C_Mem_Write(&hi2c, EEPROM_ADDR, mem_addr, I2C_MEMADD_SIZE_8BIT, data, size, HAL_MAX_DELAY);
HAL_Delay(WRITE_DELAY); // 等待EEPROM写入完成
return status;
}
/* 读取24C02 */
HAL_StatusTypeDef EEPROM_Read(uint16_t mem_addr, uint8_t *data, uint16_t size) {
return HAL_I2C_Mem_Read(&hi2c, EEPROM_ADDR, mem_addr, I2C_MEMADD_SIZE_8BIT, data, size, HAL_MAX_DELAY);
}