IIC工作原理

IIC概述

IIC物理层面

IIC(Inter-Integrated Circuit,也称I²C) 是一种两线式串行通信总线,由Philips公司(现NXP)发明,常用于MCU与传感器、LCD、EEPROM等外设通信。

物理层:只有两根线,IIC是半双工,同步通信

  • ​SCL(Serial Clock Line)​:时钟线,由主机产生,控制通信节奏。
  • ​SDA(Serial Data Line)​:数据线,双向传输数据。

两根线都是开漏输出,必须外接上拉电阻(通常4.7kΩ或10kΩ,这两个电阻可以做速率和功耗平衡,一般不用芯片内部的上下拉,因为阻值是固定的),这样才能实现多设备共享总线。

支持的总线速度

模式 速度 常见应用
标准模式(SM) 100 kbps 兼容性最广
快速模式(FM) 400 kbps 大部分传感器
快速模式+(FM+) 1 Mbps 高性能设备
高速模式(HS) 3.4 Mbps 特殊场合

硬件I2C vs 软件I2C 的原理差异

对比 硬件I2C 软件I2C(GPIO模拟)
时钟产生 硬件定时器自动生成SCL CPU通过延时循环翻转GPIO
电平检测 硬件自动采样SDA CPU逐位读取GPIO电平
应答处理 硬件自动检测ACK CPU手动读取SDA电平判断
优点 省CPU、速度快 引脚任意、移植简单
缺点 引脚固定、配置复杂 占用CPU、速度慢

I2C标准时序参数

软件I2C的延时时间不是随意设定的,必须严格遵循I2C总线协议中的时序参数。如果延时太短,从机无法正确采样;延时太长,通信速度过慢。

不同速度模式下,关键的时序参数不同:

参数 含义 标准模式(100kHz) 快速模式(400kHz) 快速+模式(1MHz)
tHIGH SCL高电平持续时间 ≥4.0µs ≥0.6µs ≥0.26µs
tLOW SCL低电平持续时间 ≥4.7µs ≥1.3µs ≥0.5µs
tSU;STA 起始条件建立时间 ≥4.7µs ≥0.6µs ≥0.26µs
tHD;STA 起始条件保持时间 ≥4.0µs ≥0.6µs ≥0.26µs
tSU;DAT 数据建立时间 ≥250ns ≥100ns ≥50ns
tHD;DAT 数据保持时间 ≥300ns ≥300ns ≥50ns
tSU;STO 停止条件建立时间 ≥4.0µs ≥0.6µs ≥0.26µs
tBUF 停止到起始的总线空闲时间 ≥4.7µs ≥1.3µs ≥0.5µs

这些参数来自 ​NXP I2C-bus specification and user manual​(UM10204)

建议:用逻辑分析仪实测SCL频率,调整延时值使之符合目标频率。

总线上挂载多个I2C设备时,延时时间​必须以最慢的那个设备为准​。例如:

  • 一个设备支持400kHz,另一个只支持100kHz → 必须用100kHz的延时

IIC通信机制

1. 主从架构

  • ​主机(Master)​:启动通信、产生时钟、发送地址的设备(通常是MCU)。
  • ​从机(Slave)​:响应主机请求的设备(如LCD、传感器)。

2. 设备寻址

每个从机有一个唯一的​7位地址​(部分支持10位扩展),通信时主机先发送地址,只有匹配的从机才会响应。

3. 数据帧结构

IIC通信以帧为单位,典型的一帧包含:

起始条件 | 7位地址 + 读写位 | 应答 | 8位数据 | 应答 | ... | 停止条件

时序

总线处于空闲时,SCL、SDA线均处于高电平状态,所以总线上的设备都处于开漏模式中的高阻态。当某个设备想要通信时,就先在总线上发送start起始信号,把SDA拉低,此时会在SCL上产生时钟信号,是高低电平交错的脉冲信号。

起始条件(START)

SCL高电平时,SDA从高→低跳变,表示总线开始通信。

停止条件(STOP)

SCL高电平时,SDA从低→高跳变,表示总线释放。

数据传输(DATA)

  • 每次传输1个字节(8位),高位在前(MSB first)。
  • 发送方在SCL低电平时把数据放到SDA上。
  • 接收方在SCL高电平时采样SDA。

应答位(ACK or NACK)

(1)ACK:应答

(2)NACK:非应答

  • 每传输完1个字节,​接收方必须在第9个时钟周期拉低SDA​,表示“收到”。
  • 如果接收方拉高SDA(NACK),表示“未收到”或“不想继续接收”。

完整通信流程示例

  1. 写操作流程示例:支持连续写入时,每写入一个字节都要等待ACK
// 伪代码示例
Start();
Send(DeviceAddress << 1 | 0);  // 写方向
WaitForACK();                  // 等待从机ACK(从机拉低SDA)
Send(RegisterAddress);
WaitForACK();                  // 等待从机ACK
Send(Data1);
WaitForACK();                  // 等待从机ACK
Send(Data2);
WaitForACK();                  // 等待从机ACK(最后一个字节也要等待!)
Stop();
/*
主机准备发送 → 发起始条件 → 发地址+写位 → 等待应答
    ↓
收到应答?→ 否 → 发送停止条件 → 报错
    ↓ 是
发送数据字节 → 等待应答 → 是否还有数据?
    ↓ 是 → 继续发送
    ↓ 否
发送停止条件 → 通信结束
*/

以上为写模式,如若需要转换读模式接收数据,需要再发一次起始信号,这里后面在IIC读写AT24C02时介绍

  1. 读操作流程示例:连续读取时,主机接收最后一个字节发送NACK表示不想再接收,其他字节发送ACK
// 完整读操作示例
Start();                         // 起始
Send(DevAddr << 1 | 0);         // 写方向
WaitForACK();                    // 等待从机ACK
Send(RegAddr);                   // 发送寄存器地址
WaitForACK();                    // 等待从机ACK
Start();                         // 重启条件(Restart)★核心
Send(DevAddr << 1 | 1);         // 读方向
WaitForACK();                    // 等待从机ACK
ReadByte1();  SendACK();         // 读第1字节,主机返回ACK(还想继续读)
ReadByte2();  SendACK();         // 读第2字节,主机返回ACK
ReadByteN();  SendNACK();        // 读最后1字节,主机返回NACK(不想再接收)
Stop();                          // 停止

先写地址和寄存器,然后重新起始,再发地址+读位,从机才开始发送数据。

IIC 读写AT24Cxx

E2PROM简介——AT24Cxx

AT24Cxx系列主要差异

AT24Cxx系列是经典的 I2C 接口 EEPROM 芯片,用起来简单可靠。不同的型号主要在容量和页写入大小上有所区别。如AT24C02,也写入大小为8字节(一次最多写入8字节,超过需要分批次);AT24C04,页写入大小就是16字节,连续写16个字节是完全正常的,超过会截断。

型号 存储容量 (字节) 页写入大小 (字节) 可挂载数量 (I2C总线)
AT24C01 128 8 8
AT24C02 256 8 8
AT24C04 512 16 4
AT24C08 1024 16 2
AT24C16 2048 16 1
AT24C32/64 4096/8192 32 8
AT24C128/256 16384/32768 64 (资料未明确提及)
AT24C512 65536 128 (资料未明确提及)

写入关键点

  • 页写入机制:这是E2PROM为了加快写入速度而设计的特性,允许在一个写周期内连续写入一页的数据(页缓冲区)。正是因为AT24C04支持16字节的页写入,所以你一次写入11个字节是它"规定动作"内的正常操作。

  • "地址指针回卷":如果一次写入的数据超过了当前页的剩余空间,地址指针会自动"回卷"到该页的开头,覆盖之前刚写入的数据。(地址指针在页内循环)。比如AT24C02一次最多写入16字节,而你的IIC时序发送的要写入的字节数为20个,超出的会覆盖开始的,因为地址指针回卷到了该页开头。

    所以编写程序时要实现跨页处理或分批写入。

  • 设备地址的差异:为了在一条I2C总线上挂多个设备,不同型号的硬件地址引脚(A2, A1, A0)定义不同。比如AT24C02可以用A0、A1、A2三个引脚组合出8个地址,而AT24C04的A0脚是空脚(No Connect),只用A1和A2,因此最多只能挂4个。

为什么AT24C02只支持8字节页写入?

(其他型号类似,看最大页写入字节数)

AT24C02内部有个​8字节的临时缓冲区​:

  1. 写入时,数据先放到这个缓冲区
  2. 发送停止条件后,芯片才开始真正写入EEPROM
  3. 超过8字节,缓冲区溢出,超出的数据被丢弃!

读取操作

读取操作没有像写入那样的"单次最多16字节"限制。但读操作并非完全不受限,它的限制主要体现在另一个方面:地址自动翻转(Roll-over)。

AT24C04支持三种读操作,其中顺序读(Sequential Read)是实现连续读取大量数据的方式。

在顺序读模式下,当你连续发送ACK信号(应答信号)给EEPROM时,芯片内部的地址计数器会自动加1,并持续输出下一个地址的数据。这个操作可以一直持续下去,直到你发送一个NACK信号(非应答信号)来主动终止它。

这个连续读取过程有一个关键行为:当读取到达整个存储空间的最后一个地址(对于AT24C04是地址511)时,地址计数器不会停止,而是会"回卷"(roll over)到地址0,然后继续输出数据。

读取的限制在于:

  • ​没有单次传输的字节数上限​:理论上,你可以一次读取从地址0到地址511的所有512个字节,只要你不发送NACK信号。
  • ​存在的唯一限制是"地址回卷"​:如果从地址400开始连续读200个字节,当读到地址511后,下一个读出的数据会是地址0的内容。这个行为是符合规范的,不是错误。

软件 IIC 写入&读取AT24C04

这里使用STM32F103C8T6芯片+STM32CubeMx生成的HAL库代码实现编写

分层结构:APP->Driver->BSP->HAL,实现四个文件“ bsp_e2prom.c/.h ”, “driver_e2prom.c/.h”,然后在“main.c中调用“driver_e2prom.h”实现分层解耦

还有一个工具层次:存放延时函数,字符库等,各层按需调用

APP 层 (应用逻辑) ___
↓ 调用 |
Driver 层 (AT24C02、OLED、MPU6050等设备驱动) |
↓ 调用 |
BSP 层 (I2C总线接口 - 可以是硬件或软件实现) |-> utility层
├── 硬件I2C实现 (使用STM32 I2C外设) |
└── 软件I2C实现 (GPIO模拟) |
↓ 调用 |
HAL 层 (GPIO、时钟、中断等) ___ |

"bsp_e2prom.c/.h"

/*--------------------------------------------------bsp_e2prom.h ----------------------------------------------------------*/
#ifndef __BSP_E2PROM_H__
#define __BSP_E2PROM_H__

#include "main.h"
#include "utility_delay.h"

#ifndef USE_SOFT_I2C
#define USE_SOFT_I2C   // 默认使用软件I2C
#endif

#ifndef USE_SOFT_I2C 
//如果没有定义 USE_SOFT_I2C,使用硬件I2C代码




#else
//如果定义了 USE_SOFT_I2C,使用软件I2C代码
#define SOFT_WAIT_ACK_TIME 1000

/*--------------------软件IIC引脚定义--------------------*/
#define SOFT_IIC_SCL_PORT GPIOB
#define SOFT_IIC_SDA_PORT GPIOB
#define SOFT_IIC_SCL_PIN  GPIO_PIN_4
#define SOFT_IIC_SDA_PIN  GPIO_PIN_5

/*--------------------------时序-------------------------*/
#define SCL(x) HAL_GPIO_WritePin(SOFT_IIC_SCL_PORT,SOFT_IIC_SCL_PIN,(GPIO_PinState)x)
#define SDA(x) HAL_GPIO_WritePin(SOFT_IIC_SDA_PORT,SOFT_IIC_SDA_PIN,(GPIO_PinState)x)
#define SDA_READ() (HAL_GPIO_ReadPin(SOFT_IIC_SDA_PORT, SOFT_IIC_SDA_PIN) == GPIO_PIN_SET)

void I2C_Start(void);
void I2C_Stop(void);
int8_t I2C_SendACK(void);
int8_t I2C_SendNACK(void);
int8_t I2C_WaitACK(void);
int8_t I2C_WaitNACK(void);
void I2C_WriteByte(uint8_t byte);
uint8_t I2C_ReadByte(void);

		
#endif

#endif

/*--------------------------------------------------bsp_e2prom.c ----------------------------------------------------------*/
#include "bsp_e2prom.h"

#ifndef USE_SOFT_I2C 
//如果没有定义 USE_SOFT_I2C,使用硬件I2C代码




#else
//如果定义了 USE_SOFT_I2C,使用软件I2C代码

void I2C_Start(void)
{
	SCL(1);        		// 1. SCL拉高,准备产生起始条件
	SDA(1);        		// 2. 确保SDA初始为高
	udelay(4);      	// 3. 保持稳定
	SDA(0);         	// 4. SCL高时SDA下降沿 → 起始条件
	udelay(4);      	// 5. 保持起始条件
	SCL(0);         	// 6.	拉低SCL,准备发送数据位
}

void I2C_Stop(void)
{
	SDA(0);
	udelay(1);
	SCL(1);   
	udelay(4);
	SDA(1);   
	udelay(4);
}

int8_t I2C_SendACK(void)
{
	SDA(0);           // 拉低SDA = ACK
	udelay(1);        // 数据建立时间
	SCL(1);          // 拉高SCL,从机在第9个时钟采样
	udelay(4);        // SCL高电平保持时间
	SCL(0);           // 拉低SCL,准备下一帧
	udelay(1);        // SCL低电平保持时间
	SDA(1);           // 释放SDA总线

	return 0;           // 成功
}

int8_t I2C_SendNACK(void)
{
	SDA(1);           // 拉高SDA = NACK
	udelay(1);        // 数据建立时间
	SCL(1);           // 拉高SCL,从机在第9个时钟采样
	udelay(4);        // SCL高电平保持时间
	SCL(0);           // 拉低SCL,准备下一帧
	udelay(1);        // SCL低电平保持时间
										// 释放SDA总线,前面已是高电平

	return 0;           // 成功
}

int8_t I2C_WaitACK(void)
{
	uint32_t timeout=SOFT_WAIT_ACK_TIME;
	
	SDA(1);
	udelay(1);
	SCL(1);
	udelay(1);
	
	while(SDA_READ()!=0)
	{
		if(--timeout==0)
		{
			SCL(0);
			return -1;
		}
		
		udelay(1);
		
	}
	
	SCL(0);
	udelay(1);
	
	return 0;
}

int8_t I2C_WaitNACK(void)
{
	uint32_t timeout=SOFT_WAIT_ACK_TIME;
	
	SDA(1);
	udelay(1);
	SCL(1);
	udelay(1);
	
	while(SDA_READ()==0)
	{
		if(--timeout==0)
		{
			SCL(0);
			return -1;
		}
		
		udelay(1);
	}
	
	SCL(0);
	udelay(1);
	
	return 0;
}

void I2C_WriteByte(uint8_t byte)
{
	for(uint8_t i=0;i<8;i++)
	{
		if(byte & 0x80)
		{
			SDA(1);
		}
		else
		{
			SDA(0);
		}
		byte <<= 1;
		SCL(1);
		udelay(4);
		SCL(0);
		udelay(4);
	}
	 
	SDA(1);   // 释放总线,准备接收ACK
}
	
uint8_t I2C_ReadByte(void)
{
	uint8_t byte = 0x00;	//接收字节
	
	SDA(1);	//释放SDA
	
	for(uint8_t i=0;i<8;i++)
	{
		byte <<= 1; //为新数据腾出一位
		SCL(1);	//SCL高电平读取数据
		udelay(4);	//等待数据稳定
		if(SDA_READ())
		{
			byte |= 0x01;
		}
		udelay(4);
		SCL(0);
		udelay(1);
	}
	
	return byte;

}

#endif

“driver_e2prom.c/.h”

/*--------------------------------------------------driver_e2prom.h ----------------------------------------------------------*/
#ifndef __DRIVER_E2PROM_H__
#define __DRIVER_E2PROM_H__

#include "main.h"
#include "bsp_e2prom.h"
/*-------------------------AT24C04-----------------------*/
#define E2PROM_ADDR 0xa0
#define E2PROM_PAGE 16

int8_t Driver_E2PROM_Write_Page(uint8_t write_addr,uint8_t *p_data,uint8_t len);

int8_t Driver_E2PROM_Write(uint8_t write_addr,uint8_t *data,uint8_t len);

int8_t Driver_E2PROM_Read(uint8_t write_addr,uint8_t *data,uint8_t len);


#endif

/*--------------------------------------------------driver_e2prom.c ----------------------------------------------------------*/
#include "driver_e2prom.h"

#ifndef USE_SOFT_I2C 



#else
/**
 * @brief   页内指定地址写多字节,最多8个
 * @param   write_addr:要写入EEPROM的地址
 * @param   p_data:要写入数据的地址
 * @param   len:要写入数据的长度
 * @return 暂无
 */
int8_t Driver_E2PROM_Write_Page(uint8_t write_addr,uint8_t *p_data,uint8_t len)
{
	
	I2C_Start();
	I2C_WriteByte(E2PROM_ADDR);
	I2C_WaitACK();
	I2C_WriteByte(write_addr);
	I2C_WaitACK();
	
	for(int i=0;i < len;i++)
	{
		I2C_WriteByte(p_data[i]);
		I2C_WaitACK();
	}
	
	I2C_Stop();
	
	mdelay(5);
	
}
/**
 * @brief   指定地址写指定字节,支持跨页写入
 * @param   write_addr:要写入EEPROM的地址
 * @param   p_data:要写入数据的地址
 * @param   len:要写入数据的长度
 * @return  暂无
 */
int8_t Driver_E2PROM_Write(uint8_t write_addr,uint8_t *p_data,uint8_t len)
{
	uint8_t offset = write_addr % E2PROM_PAGE;  // 页内偏移
  uint8_t max_len =E2PROM_PAGE - offset;     // 当前页可写字节数
    
	if (len <= max_len) 
	{
		// 单页写入
		Driver_E2PROM_Write_Page(write_addr, p_data, len);
	} 
	else 
	{
		// 跨页写入
		Driver_E2PROM_Write_Page(write_addr, p_data, max_len);
		Driver_E2PROM_Write_Page(write_addr + max_len, p_data + max_len, len - max_len);
	}
}
/**
 * @brief 指定地址读多字节
 * @param   write_addr:要读取的起始EEPROM地址
 * @param   p_data:读取数据的缓冲区地址
 * @param   len:要读取数据的长度
 * @return 暂无
 */
int8_t Driver_E2PROM_Read(uint8_t write_addr,uint8_t *data,uint8_t len)
{
	I2C_Start();
	I2C_WriteByte(E2PROM_ADDR);
	I2C_WaitACK();
	I2C_WriteByte(write_addr);
	I2C_WaitACK();
	I2C_Start();
	I2C_WriteByte(E2PROM_ADDR|0x01);
	I2C_WaitACK();
	
	for(int i=0;i < len;i++)
	{
		data[i]=I2C_ReadByte();
		if(i==len-1)
		{
			I2C_SendNACK();
		}
		else
		{
			I2C_SendACK();
		}
	}
	
	I2C_Stop();
	
}

/**
 * @brief 检测AT24C02是否存在
 * @return 0:存在, -1:不存在
 */
int8_t Driver_AT24C02_Check(void)
{
    I2C_Start();
    I2C_WriteByte(E2PROM_ADDR);
    int8_t ret = I2C_WaitACK();
    I2C_Stop();
    return ret;
}

#endif

工具层的延时函数:

/*--------------------------------------------------utility_delay.h ----------------------------------------------------------*/
#ifndef __UTILITY_DELAY_H__
#define __UTILITY_DELAY_H__

#include "stm32f1xx_hal.h"

void udelay(uint32_t us);
void mdelay(uint32_t ms);
uint64_t system_get_ns(void);



#endif
/*--------------------------------------------------utility_delay.c ----------------------------------------------------------*/
#include "utility_delay.h"

void udelay(uint32_t us)
{
	uint32_t t_old=0;
	uint32_t t_now=0;
	uint32_t t_cnt=0;
	uint32_t load=0;
	
	
	if (us == 0) return;
	
	t_old=SysTick->VAL;
	load=SysTick->LOAD;
	
	/*LOAD+1 ge clock =1ms
	n us = n*(LOAD+1)/1000
	*/
	uint64_t ticks=(uint64_t)us*(load+1)/1000;
	
		// 至少需要 1 个 tick,避免死循环
    if (ticks == 0) 
		{
        ticks = 1;
    }
	
	while(1)
	{
		t_now=SysTick->VAL;  //book has tell why
		
		if(t_now!=t_old)
		{
			if(t_now<t_old)
			{
				t_cnt+=t_old-t_now;
			}
			else
			{
				t_cnt+=t_old+(load+1)-t_now;
			}
			t_old=t_now;
			
			if(t_cnt>=ticks)
			{
				break;
			}
		}
		
	}
	
}
void mdelay(uint32_t ms)
{
	while(ms--)
	{
		udelay(1000);
	}

}
uint64_t system_get_ns(void)
{
    // 1. 获取毫秒数并转换为纳秒
    uint64_t ns = (uint64_t)HAL_GetTick() * 1000000ULL;
    
    // 2. 获取SysTick当前值(递减计数器)
    uint32_t t_old = SysTick->VAL;
    uint32_t load = SysTick->LOAD;
    
    // 3. 计算当前周期内已过去的时间(纳秒)
    // 注意:SysTick是递减计数器,从LOAD减到0
    // 已过去的周期数 = LOAD - t_old
    uint64_t elapsed_ns = (uint64_t)(load - t_old) * 1000000ULL / (load + 1);
    
    // 4. 总纳秒 = 已完成的毫秒对应的纳秒 + 当前周期内过去的纳秒
    return ns + elapsed_ns;
}

APP层 main.c (部分)

static char g_str[20];
void main(void)
{
	#define 	MAX_CH 20
	uint8_t test[MAX_CH];

	for(int i=0;i<MAX_CH;i++) 
	{
		test[i] ='0'+i;
	}	
	for(uint8_t i=0;i<MAX_CH;i++)
	{
		sprintf(&g_str[i],"%c",test[i]);
	}

	LCD_DisplayStringLine(1, g_str);

	Driver_E2PROM_Write(0,test,MAX_CH);
	mdelay(10);

	uint8_t test2[MAX_CH];

	for(int i=0;i<MAX_CH;i++) 
	{
		test2[i] ='1'+i;
	}
	Driver_E2PROM_Read(0,test2,MAX_CH);
	for(uint8_t i=0;i<MAX_CH;i++)
	{
		sprintf(&g_str[i],"%c",test2[i]);
	}
	LCD_DisplayStringLine(2, g_str);

	while(1)
	{
		
	}
}
Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐