最近在做一个基于APM32F003F6P6的低成本嵌入式项目,需要外接AT24C02C EEPROM存储芯片保存配置参数。由于硬件资源有限,我决定用GPIO模拟I2C总线的方式实现通信。这种方案在MCU没有硬件I2C外设或者硬件I2C被占用时特别实用。
APM32F003F6P6是极海半导体推出的一款Cortex-M0内核MCU,主频48MHz,Flash 16KB,RAM 2KB,价格极具竞争力。AT24C02C则是常见的2Kbit(256x8)串行EEPROM,采用I2C接口,工作电压1.7V-5.5V,适合各种低功耗场景。
模拟I2C只需要两个GPIO:
AT24C02C的A0-A2地址引脚全部接地,这样器件地址就是0xA0(写)和0xA1(读)。VCC接3.3V,WP引脚接地(取消写保护)。
注意:上拉电阻必不可少!我在SCL和SDA上各接了4.7KΩ上拉电阻到3.3V。没有上拉会导致信号无法拉高,通信失败。
虽然项目简单,但电源稳定性直接影响EEPROM写入成功率:
首先配置GPIO为开漏输出模式:
c复制void I2C_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 使能GPIOB时钟
RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_GPIOB);
// PB6(SCL), PB7(SDA) 配置为开漏输出
GPIO_InitStruct.pin = GPIO_PIN_6 | GPIO_PIN_7;
GPIO_InitStruct.mode = GPIO_MODE_OUTPUT_OD;
GPIO_InitStruct.speed = GPIO_SPEED_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStruct);
// 初始状态拉高总线
GPIO_SetBits(GPIOB, GPIO_PIN_6 | GPIO_PIN_7);
}
关键点:必须使用开漏输出!推挽输出无法实现I2C的线与特性,会导致总线冲突。
模拟I2C的核心是精确控制时序:
c复制// 微秒级延时函数
void I2C_Delay(uint16_t t)
{
for(; t>0; t--);
}
// 产生起始条件
void I2C_Start(void)
{
SDA_HIGH();
SCL_HIGH();
I2C_Delay(5);
SDA_LOW(); // 在SCL高时拉低SDA
I2C_Delay(5);
SCL_LOW(); // 钳住总线
}
// 产生停止条件
void I2C_Stop(void)
{
SDA_LOW();
SCL_LOW();
I2C_Delay(5);
SCL_HIGH();
I2C_Delay(5);
SDA_HIGH(); // 在SCL高时释放SDA
}
// 等待ACK
uint8_t I2C_Wait_Ack(void)
{
uint8_t timeout = 0;
SDA_INPUT(); // 切换SDA为输入
SCL_HIGH();
I2C_Delay(2);
while(GPIO_ReadInputDataBit(GPIOB, GPIO_PIN_7)) // 检测SDA电平
{
if(timeout++ > 250)
{
I2C_Stop();
return 1; // ACK超时
}
I2C_Delay(1);
}
SCL_LOW();
SDA_OUTPUT(); // 恢复SDA为输出
return 0;
}
时序参数经验值:
完整的字节收发函数:
c复制// 发送一个字节
void I2C_Send_Byte(uint8_t data)
{
uint8_t i;
for(i=0; i<8; i++)
{
SCL_LOW();
if(data & 0x80)
SDA_HIGH();
else
SDA_LOW();
I2C_Delay(2);
SCL_HIGH();
I2C_Delay(5);
SCL_LOW();
data <<= 1;
}
}
// 读取一个字节
uint8_t I2C_Read_Byte(uint8_t ack)
{
uint8_t i, data = 0;
SDA_INPUT();
for(i=0; i<8; i++)
{
data <<= 1;
SCL_HIGH();
I2C_Delay(2);
if(GPIO_ReadInputDataBit(GPIOB, GPIO_PIN_7))
data |= 0x01;
SCL_LOW();
I2C_Delay(5);
}
SDA_OUTPUT();
if(ack)
SDA_LOW(); // 发送ACK
else
SDA_HIGH(); // 发送NACK
SCL_HIGH();
I2C_Delay(5);
SCL_LOW();
return data;
}
封装EEPROM的读写函数:
c复制// 写一个字节到指定地址
void AT24C02_Write_Byte(uint8_t addr, uint8_t data)
{
I2C_Start();
I2C_Send_Byte(0xA0); // 器件地址+写
I2C_Wait_Ack();
I2C_Send_Byte(addr); // 存储地址
I2C_Wait_Ack();
I2C_Send_Byte(data); // 要写入的数据
I2C_Wait_Ack();
I2C_Stop();
// EEPROM写入需要时间,延时5ms
DelayMs(5);
}
// 从指定地址读取一个字节
uint8_t AT24C02_Read_Byte(uint8_t addr)
{
uint8_t data;
I2C_Start();
I2C_Send_Byte(0xA0); // 器件地址+写
I2C_Wait_Ack();
I2C_Send_Byte(addr); // 存储地址
I2C_Wait_Ack();
I2C_Start();
I2C_Send_Byte(0xA1); // 器件地址+读
I2C_Wait_Ack();
data = I2C_Read_Byte(0); // 读完后发NACK
I2C_Stop();
return data;
}
AT24C02C支持页写入(一次最多8字节):
c复制// 页写入(最多8字节)
void AT24C02_Page_Write(uint8_t addr, uint8_t *buf, uint8_t len)
{
uint8_t i;
if(len > 8) len = 8; // 不超过页边界
I2C_Start();
I2C_Send_Byte(0xA0);
I2C_Wait_Ack();
I2C_Send_Byte(addr);
I2C_Wait_Ack();
for(i=0; i<len; i++)
{
I2C_Send_Byte(buf[i]);
I2C_Wait_Ack();
}
I2C_Stop();
DelayMs(5); // 等待写入完成
}
// 顺序读取多个字节
void AT24C02_Sequential_Read(uint8_t addr, uint8_t *buf, uint8_t len)
{
uint8_t i;
I2C_Start();
I2C_Send_Byte(0xA0);
I2C_Wait_Ack();
I2C_Send_Byte(addr);
I2C_Wait_Ack();
I2C_Start();
I2C_Send_Byte(0xA1);
I2C_Wait_Ack();
for(i=0; i<len; i++)
{
if(i == len-1)
buf[i] = I2C_Read_Byte(0); // 最后一个字节发NACK
else
buf[i] = I2C_Read_Byte(1); // 中间字节发ACK
}
I2C_Stop();
}
虽然模拟I2C简单易用,但在高性能场景下仍有优化空间:
这个方案已经成功应用在我最近的几个低端项目中,包括智能家居传感器和工业控制器。模拟I2C虽然效率不如硬件方案,但在资源受限的场景下,它提供了一种灵活可靠的通信方式。