前言
IIC协议,是嵌入式领域常用的通讯协议,本文介绍了使用软件模拟IIC协议的简单实现,通过阅读本文,可以了解IIC协议的基本原理和实现。
目录
一、I2C协议简介
I2C(inter-integrated circuit,集成电路总线) 是飞利浦公司开发的一种通信协议,是一种简单的 双向两线制 的 总线协议 标准,支持 同步串行半双工通讯。
I2C的特点是:引脚少、硬件简单、可扩展性强。在嵌入式领域应用非常广泛。后来飞利浦公司将这个协议进行了开放,成为了一个通用协议。
目前I2C协议广泛的使用在嵌入式系统内 多个集成电路间的低速通讯。
1.传输速率:
标准模式:100kbit/s;
快速模式:400kbit/s;
高速模式:3.4Mbit/s,(目前大多IIC设备上不支持高速模式)
2.与USART相比:
115200波特率下:115.2kbit/s
理论最高速率:4.5Mbit/s
可见I2C和USART速率相差不大,都是低速串行通信。但是I2C协议比USART的硬件组成更加简单。
二、I2C的物理层
I2C的物理层组成如下图所示:

1.I2C通信物理层规则
- 主机(MCU)和从机之间组成一个I2C通信总线,总线由两根线组成:SCL时钟线和SDA数据线。
- **SCL:**串行时钟总线,由发送信号的主机控制,用于同步时钟信号。
- **SDA:**串行数据总线,由信息发送方控制(可以是主机,也可以是从机)用高低电平表示数据。
- I2C协议可以连接多个IIC设备,支持一主多从 也支持多主多从。
- 每个设备都有一个唯一的地址,主机通过这个地址与从机通信。
- 主设备先要找到从设备,通过从设备的地址寻找。
- 与I2C通信的芯片引脚需要设置为通用开漏输出。
- 所有连接到I2C总线上的设备引脚必须为高阻态,总线空闲时由上拉电阻保持高电平。
三、I2C的 协议层
**1.按位传输:**I2C协议采用串行通信,按照位传输,通常先传高位(底层硬件由移位寄存器实现);
**2.起始信号:**SCL为高电平时,SDA从高电平向低电平切换。这个动作就是起始信号;
**3.停止信号:**SCL为高电平时,SDA从低电平向高电平切换,这个动作就是终止信号;

4.数据的有效性-发送数据
I2C协议规定:必须在SCL为高电平期间读取SDA的数据才有效。SCL为低电平时改变SDA上的数据(SCL为高电平时不能改变SDA,否则不是有效数据,可能错误的识别为开始信号或终止信号)。

5.响应/不响应-ACK/NACK
I2C协议规定:依次发送完成起始信号、8位数据后,在下一个时钟周期内SDA发送一个低电平代表ACK,发送一个高电平代表NACK。

6.传输地址及读写命令
- I2C协议规定:主机通过SDA信号线传送设备地址来查找从机,设备地址可以是7位或10位,实际应用中7位地址应用比较广泛。
- 紧跟设备地址的一个数据位用来表示数据传输方向,它是数据方向位,第8位或第11位。
- 数据方向位为1时,表示主机由从机读数据
- 数位方向位为0时,表示主机向从机写数据

四.操作时序图整理
起始和停止信号

数据有效性

响应和非响应

写入一个字节时序

读出一个字节时序

单次写入多个字节时序

一次性写入多个字节的操作,称为页写入(Page Write)。M24C02每页有16个字节,每次写操作能写入单独的一个页中,所以一次性最多可以写入16个字节。当一次性写入超过16个字节的时候,超过的部分会重新从这页的首地址重新写入。
单次读出多个字节时序

读出多个字节的时候没有限制,可以读出任意多个。
总结:
- 发送数据信息时,改变SDA数据线状态前,必须确认SCL时钟线处于低电平;
- 发送开始/停止信号时,需保持SCL时钟线处于高电平,再改变SDA数据线的状态产生跳变信号:
- 此期间SDA高→低跳变,代表起始信号;
- 此期间SDA低→高跳变,代表停止信号。
- 完成普通数据/应答位的发送后,需将SCL拉低(为下一位数据准备做准备);若发送的是停止信号,发送完成后需释放SDA和SCL(交由上拉电阻拉为高电平)。
- SCL时钟线拉高并保持稳定的期间为采样时间:
- 此期间SDA保持稳定不变,代表传输的是1位有效数据(0或1);
- 此期间SDA发生跳变,仅用于传递起始/停止信号(而非数据)。
- SCL时钟线拉低的期间,可自由修改SDA的数据状态,此阶段不发生有效数据传递,仅用于为下一次采样准备数据。
五、软件模拟I2C
以STM32F103ZET6为主控芯片,DS24C02芯片为EEPROM。实现软件模拟I2C。
1.硬件电路

2.软件模拟实现I2C协议
2.1.头文件
选择使用GPIOB10引脚和GPIOB11引脚作为I2C通信议引脚,其中GPIOB10为SCL(时钟线),GPIOB11为SDA(数据线)。
为提升代码可读性,可以将对应的管脚操作定义为宏:
代码文件:iic.h
cpp
#ifndef __IIC_H
#define __IIC_H
// 包含的头文件
#include "stm32f10x.h"
#include "delay.h"
// 宏定义
// IIC通信接口定义:选用GPIOB10\11两个接口作为IIC通信接口,GPIOB10口为时钟接口IIC_SCL;GPIOB11口为数据口IIC_SDA;
#define IIC_SCL_HIGH (GPIOB->ODR |= GPIO_ODR_ODR10)
#define IIC_SCL_LOW (GPIOB->ODR &= ~GPIO_ODR_ODR10)
#define IIC_SDA_HIGH (GPIOB->ODR |= GPIO_ODR_ODR11)
#define IIC_SDA_LOW (GPIOB->ODR &= ~GPIO_ODR_ODR11)
// 获取数据线的状态
#define READ_SDA (GPIOB->IDR & GPIO_IDR_IDR11)
// 应答宏定义
#define ACK 0
#define NACK 1
// 延时宏定义
#define DELAY (Delay_nus(10))
// 函数声明
// 初始化函数
void IIC_Init(void);
// 起始信号
void IIC_Start(void);
// 终止信号
void IIC_Stop(void);
// 发送ACK
void IIC_Send_ACK(void);
// 发送NACK
void IIC_Send_NACK(void);
// 等待ACK
uint8_t IIC_Wait_ACK(void);
// 发送一字节数据
void IIC_Send_Byte(uint8_t send_byte);
// 接收一字节数据
uint8_t IIC_Receive_Byte(void);
#endif
3.2.IIC协议函数实现
用于I2C通信的引脚需要工作在 通用开漏输出模式,通过总线的上拉电阻保持默认高电平状态。
代码文件:iic.c
cpp
#include "iic.h"
// 初始化函数
void IIC_Init(void)
{
// 打开时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;
// 配置引脚模式,GPIOB10作为时钟线使用;GPIOB11作为数据线,模拟IIC通信时,管脚需要配置为通用开漏输出模式CNF-01;MODE-11;--需要外接上拉电阻
// 配置前先清空可能得残留配置
GPIOB->CRH &= ~GPIO_CRH_CNF10;
GPIOB->CRH &= ~GPIO_CRH_CNF11;
GPIOB->CRH &= ~GPIO_CRH_MODE10;
GPIOB->CRH &= ~GPIO_CRH_MODE11;
// 端口模式配置
GPIOB->CRH |= GPIO_CRH_CNF10_0;
GPIOB->CRH &= ~GPIO_CRH_CNF10_1;
GPIOB->CRH |= GPIO_CRH_MODE10;
GPIOB->CRH |= GPIO_CRH_CNF11_0;
GPIOB->CRH &= ~GPIO_CRH_CNF11_1;
GPIOB->CRH |= GPIO_CRH_MODE11;
}
// 起始信号
void IIC_Start(void)
{
// 1.准备好信号发出前的数据线和时钟线的状态。首先将SCL拉低,之后再操作SDA
IIC_SCL_LOW; // 拉低时钟线
DELAY;
IIC_SDA_HIGH; // 拉高数据线
IIC_SCL_HIGH; // 拉高时钟线,开始采样
DELAY;
// 2.时钟线保持不变,数据线拉低,发出开始信号。延时
IIC_SDA_LOW;
DELAY;
// 3.时钟线拉低,结束采样,延时
IIC_SCL_LOW;
DELAY;
}
// 终止信号
void IIC_Stop(void)
{
// 1.准备好信号发出前数据线和时钟线的状态,数据线低,时钟线高
IIC_SCL_LOW; // 操作数据线之前先拉低时钟线
DELAY;
IIC_SDA_LOW; // 拉低数据线
IIC_SCL_HIGH; // 拉高时钟线,开始采样
DELAY;
// 2.操作数据线,发出停止信号
IIC_SDA_HIGH; // 拉高数据线,发出停止信号
DELAY;
// 3.注意:发出停止信号后,需要让出数据线和时钟线的控制权,不可再执行拉低操作
}
// 发送ACK
void IIC_Send_ACK(void)
{
// 1.准备好信号发出前的数据线和时钟线的状态
IIC_SCL_LOW; // 先拉低时钟线
DELAY;
IIC_SDA_HIGH; // 拉高数据线
DELAY;
// 2.发送ACK信号
IIC_SDA_LOW; // 拉低数据线,发出信号
DELAY;
IIC_SCL_HIGH; // 拉高时钟线,开始采样
DELAY;
IIC_SCL_LOW; // 拉低时钟线,结束采样
DELAY;
// 3.结束,保证时钟线为低电平,数据线为高电平(释放状态)
IIC_SDA_HIGH;
DELAY;
}
// 发送NACK
void IIC_Send_NACK(void)
{
// 1.准备好信号发出前的数据线和时钟线的状态
IIC_SCL_LOW; // 先拉低时钟线
DELAY;
IIC_SDA_HIGH; // 拉高数据线
DELAY;
// 2.发送ACK信号,数据线保持高电平
IIC_SCL_HIGH; // 拉高时钟线,开始采样
DELAY;
IIC_SCL_LOW; // 拉低时钟线,结束采样
DELAY;
// 3.结束,保证时钟线为低电平,数据线为高电平(释放状态)
}
// 等待ACK
uint8_t IIC_Wait_ACK(void)
{
uint8_t ack = 0;
// 1.准备好接收信号前的数据线和时钟线的状态
IIC_SCL_LOW;
IIC_SDA_HIGH;
DELAY;
// 2.发出时钟脉冲,采样数据线
IIC_SCL_HIGH; // 拉高时钟线
DELAY;
(READ_SDA == 0) ? (ack = ACK) : (ack = NACK); // READ_SDA如果是0,代表ACK,如果是1,代表NACK
IIC_SCL_LOW; // 结束采样
return ack;
}
// 发送一字节数据
void IIC_Send_Byte(uint8_t send_byte)
{
uint8_t temp = 0x80;
// 1.拉低时钟线,准备发送数据
IIC_SCL_LOW;
DELAY;
// 2.取需要发送的数据的1位发送到SDA,从高位开始
for(uint8_t i = 0; i < 8; i++)
{
if(send_byte & temp)
{
IIC_SDA_HIGH;
}
else
{
IIC_SDA_LOW;
}
temp >>= 1;
DELAY;
IIC_SCL_HIGH; // 拉高时钟线,开始采样
DELAY;
IIC_SCL_LOW; // 拉低时钟线,结束采样
DELAY;
}
// 3.发送完毕后释放数据线
IIC_SDA_HIGH;
}
// 接收一字节数据
uint8_t IIC_Receive_Byte(void)
{
uint8_t temp = 0;
// 1.拉低时钟线,释放数据线,准备接收数据
IIC_SCL_LOW;
IIC_SDA_HIGH;
DELAY;
// 2.接收数据
for(uint8_t i = 0; i < 8; i++)
{
IIC_SCL_HIGH; // 拉高时钟线,开始采样
DELAY;
temp <<= 1; // 为保证最低位在最右边,需要先位移后赋值
if(READ_SDA) // 非0,代表读到1
{
temp |= 1;
}
IIC_SCL_LOW;
DELAY;
}
// 3.完成接收
return temp;
}
以上的iic.h和iic.c文件实现了IIC的基础操作:发送起始/停止信号,发送/接收ACK/NACK,发送/接收1byte数据。如果要实现EEPROM的读写,还需要结合EEPROM对应的操作要求,这些要求,我们通过DS24C02.h和DS24C02.c文件实现。
六、信息的读写操作
1.头文件
文件名:DS24C02.h
cpp
#ifndef __DS24C02_H
#define __DS24C02_H
#include "iic.h"
#define WRITE_ADD 0XA0
#define READ_ADD 0XA1
// 写一个字节
void DS24C02_Write_Byte(uint8_t write_byte, uint8_t rom_add);
// 写连续n个字节
void DS24C02_Write_Bytes(uint8_t* buffer, uint8_t rom_start_add, uint8_t n);
// 读一个字节
uint8_t DS24C02_Read_Byte(uint8_t rom_add);
// 读连续n个字节
void DS24C02_Read_Bytes(uint8_t* buffer, uint8_t rom_start_add, uint8_t n);
#endif
2.源文件
文件名:DS24C02.c
cpp
#include "ds24c02.h"
// 写一个字节
void DS24C02_Write_Byte(uint8_t write_byte, uint8_t rom_add)
{
// 1.假写阶段--向芯片发送寻址、写入地址等信息
// 开始信号
IIC_Start();
// 发送芯片地址+写命令
IIC_Send_Byte(WRITE_ADD);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 发送写入数据的地址
IIC_Send_Byte(rom_add);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 2.真写阶段
// 发送写入的数据
IIC_Send_Byte(write_byte);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 发送停止信号
IIC_Stop();
// 给DS24C02一点反映时间
Delay_nms(5);
}
// 写连续n个字节
void DS24C02_Write_Bytes(uint8_t* buffer, uint8_t rom_start_add, uint8_t n)
{
// 1.假写阶段--向芯片发送寻址、写入地址等信息
// 开始信号
IIC_Start();
// 发送芯片地址+写命令
IIC_Send_Byte(WRITE_ADD);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 发送写入数据的起始地址
IIC_Send_Byte(rom_start_add);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 2.真写阶段
for(uint8_t i = 0; i < n; i++)
{
// 发送写入的数据
IIC_Send_Byte(*buffer);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
buffer++;
}
// 发送停止信号
IIC_Stop();
// 给DS24C02一点反映时间
Delay_nms(5);
}
// 读一个字节
uint8_t DS24C02_Read_Byte(uint8_t rom_add)
{
// 1.假写阶段--向芯片发送寻址、写入地址等信息
// 开始信号
IIC_Start();
// 发送芯片地址+写命令
IIC_Send_Byte(WRITE_ADD);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 发送读取数据的地址
IIC_Send_Byte(rom_add);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 2.读数据阶段
// 开始信号
IIC_Start();
// 发送芯片地址+读命令
IIC_Send_Byte(READ_ADD);
// 等待ack
IIC_Wait_ACK();
// 接收数据
uint8_t temp = IIC_Receive_Byte();
// 发送NACK
IIC_Send_NACK();
// 完成接收后发送停止信号
IIC_Stop();
// 返回接收到的数据
return temp;
}
// 读连续n个字节
void DS24C02_Read_Bytes(uint8_t* buffer, uint8_t rom_start_add, uint8_t n)
{
// 1.假写阶段--向芯片发送寻址、写入地址等信息
// 开始信号
IIC_Start();
// 发送芯片地址+写命令
IIC_Send_Byte(WRITE_ADD);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 发送读取数据的起始地址
IIC_Send_Byte(rom_start_add);
// 等待ack,此处先简化处理,后期再建立错误报告机制
IIC_Wait_ACK();
// 2.读数据阶段
// 开始信号
IIC_Start();
// 发送芯片地址+读命令
IIC_Send_Byte(READ_ADD);
// 等待ack
IIC_Wait_ACK();
// 接收数据
for(uint8_t i = 0; i < n; i++)
{
*buffer = IIC_Receive_Byte();
buffer++;
(i == (n - 1)) ? IIC_Send_NACK() : IIC_Send_ACK(); // 按照协议要求:连续接收数据时,每次接收一个数据后接收方需要向发送方回应一个ACK,最后一个字节接受完后,回复一个NACK
}
// 完成接收后发送停止信号
IIC_Stop();
// 返回接收到的数据
}
七、其余辅助功能模块
在协议实现的过程中,使用到了延时功能,后期调试时也会用到串口输出功能,下面给出这些辅助功能模块的实现。
1.延时模块
头文件
文件名:delay.h
cpp
#ifndef __DELAY_H
#define __DELAY_H
#include "stm32f10x.h"
// 函数声明
void Delay_nus(uint16_t nus);
void Delay_nms(uint16_t nms);
#endif
源文件
文件名:delay.c
cpp
#include "delay.h"
void Delay_nus(uint16_t nus)
{
SysTick->LOAD = 72 * nus;
SysTick->CTRL |= SysTick_CTRL_CLKSOURCE;// 使用系统时钟,不分频
SysTick->CTRL &= ~SysTick_CTRL_TICKINT;// 不产生中断
SysTick->CTRL |= SysTick_CTRL_ENABLE;// 打开时钟
while((SysTick->CTRL & SysTick_CTRL_COUNTFLAG) == 0);// 计时结束后countflag位会置1
SysTick->CTRL &= ~SysTick_CTRL_ENABLE;// 关闭时钟
}
void Delay_nms(uint16_t nms)
{
while(nms--)
{
Delay_nus(1000);// 1ms
}
}
2.串口输出模块
头文件
文件名:usart.h
cpp
#ifndef __USART_H
#define __USART_H
// 包含需要的头文件
#include "stm32f10x.h"
#include <stdio.h>
// 声明函数
// 串口初始化
void USART1_Init(void);
// 发送一个字节
void USART1_Send_Char(uint8_t ch);
// 实现一个简单的fputc函数,供printf调用,函数的返回值和参数不能变
int fputc(int ch, FILE* file);
#endif
源文件
文件名:usart.c
cpp
#include "usart.h"
//函数初始化
void USART1_Init(void)
{
// 1.打开时钟
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
// 2.设置引脚工作模式
// 2.1.GPIOA9-TX,设置为复用功能推挽输出模式;CNF-10;MODE-11;
// 2.2.GPIOA10-RX,设置为浮空输入模式;CNF-01;MODE-00;
GPIOA->CRH &= ~GPIO_CRH_CNF9_0;
GPIOA->CRH |= GPIO_CRH_CNF9_1;
GPIOA->CRH |= GPIO_CRH_MODE9;
GPIOA->CRH |= GPIO_CRH_CNF10_0;
GPIOA->CRH &= ~GPIO_CRH_CNF10_1;
GPIOA->CRH &= ~GPIO_CRH_MODE10;
// 3.设置波特率,时钟频率:72MHz,波特率115200
USART1->BRR = 0X271;
// 4.使能串口
USART1->CR1 |= (USART_CR1_UE | USART_CR1_TE | USART_CR1_RE);
// 5.选配(默认配置)
USART1->CR1 &= ~USART_CR1_M;
USART1->CR1 &= ~USART_CR1_PCE;
USART1->CR2 &= ~USART_CR2_STOP;
}
// 发送一个字节
void USART1_Send_Char(uint8_t ch)
{
while((USART1->SR & USART_SR_TXE) == 0);// 等待上一个数据发送完成;TXE:初始值1 代表发送缓冲区内没有可发送的数据
USART1->DR = ch;
}
// 实现一个简单的fputc函数,供printf调用,函数的返回值和参数不能变
int fputc(int ch, FILE* file)
{
USART1_Send_Char((uint8_t)ch);
return ch;
}