I2C工作原理&I2C读写AT24C04
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),表示“未收到”或“不想继续接收”。
完整通信流程示例
- 写操作流程示例:支持连续写入时,每写入一个字节都要等待ACK
// 伪代码示例
Start();
Send(DeviceAddress << 1 | 0); // 写方向
WaitForACK(); // 等待从机ACK(从机拉低SDA)
Send(RegisterAddress);
WaitForACK(); // 等待从机ACK
Send(Data1);
WaitForACK(); // 等待从机ACK
Send(Data2);
WaitForACK(); // 等待从机ACK(最后一个字节也要等待!)
Stop();
/*
主机准备发送 → 发起始条件 → 发地址+写位 → 等待应答
↓
收到应答?→ 否 → 发送停止条件 → 报错
↓ 是
发送数据字节 → 等待应答 → 是否还有数据?
↓ 是 → 继续发送
↓ 否
发送停止条件 → 通信结束
*/
以上为写模式,如若需要转换读模式接收数据,需要再发一次起始信号,这里后面在IIC读写AT24C02时介绍
- 读操作流程示例:连续读取时,主机接收最后一个字节发送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字节的临时缓冲区:
- 写入时,数据先放到这个缓冲区
- 发送停止条件后,芯片才开始真正写入EEPROM
- 超过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)
{
}
}
更多推荐


所有评论(0)