I2C是蓝桥杯单片机中最重要的通信协议,没有之一。PCF8591(ADC/DAC)和AT24C02(EEPROM)都挂在I2C总线上,而且从第10届到第16届,几乎每届比赛都会考到I2C相关的功能。这篇文章基于项目中的实际代码,把I2C的底层时序、PCF8591的ADC/DAC操作、AT24C02的EEPROM读写全部讲清楚。
I2C协议速览
蓝桥杯用的是软件模拟I2C,不是硬件I2C。说白了就是用GPIO翻转来模拟I2C的时序。
CT107D上的I2C引脚:
- SCL(时钟线)→ P2.0
- SDA(数据线)→ P2.1
I2C通信的核心时序就四个:
起始信号(S):SCL为高时,SDA产生下降沿
停止信号(P):SCL为高时,SDA产生上升沿
发送数据: SCL低时设置SDA,SCL高时从机读取
应答信号: SCL高时SDA=0为ACK,SDA=1为NACK
记住一个原则:所有数据变化必须在SCL为低时进行,SCL为高时数据必须稳定。
I2C底层驱动:完整的代码实现
以下是项目 Driver/iic.c 中的完整I2C驱动代码:
#include <STC15F2K60S2.H>
#include <intrins.h>
sbit scl = P2^0; // I2C时钟线
sbit sda = P2^1; // I2C数据线
#define DELAY_TIME 10 // 时序延时(通信不稳定时调整这个值)
/* 延时函数 */
static void I2C_Delay(unsigned char n) {
do {
_nop_();_nop_();_nop_();_nop_();_nop_();
_nop_();_nop_();_nop_();_nop_();_nop_();
_nop_();_nop_();_nop_();_nop_();_nop_();
} while(n–);
}
/* 起始信号 */
void I2CStart(void) {
sda = 1;
scl = 1;
I2C_Delay(DELAY_TIME);
sda = 0; // SCL高时SDA下降沿
I2C_Delay(DELAY_TIME);
scl = 0;
}
/* 停止信号 */
void I2CStop(void) {
sda = 0;
scl = 1;
I2C_Delay(DELAY_TIME);
sda = 1; // SCL高时SDA上升沿
I2C_Delay(DELAY_TIME);
}
/* 发送一个字节(MSB优先)*/
void I2CSendByte(unsigned char byt) {
unsigned char i;
for(i = 0; i < 8; i++) {
scl = 0;
I2C_Delay(DELAY_TIME);
if(byt & 0x80) // 先发最高位
sda = 1;
else
sda = 0;
I2C_Delay(DELAY_TIME);
scl = 1; // 上升沿,从机采样
byt <<= 1; // 左移
I2C_Delay(DELAY_TIME);
}
scl = 0;
}
/* 接收一个字节 */
unsigned char I2CReceiveByte(void) {
unsigned char da;
unsigned char i;
for(i = 0; i < 8; i++) {
scl = 1;
I2C_Delay(DELAY_TIME);
da <<= 1;
if(sda) da |= 0x01; // 读到1则最低位置1
scl = 0;
I2C_Delay(DELAY_TIME);
}
return da;
}
/* 等待从机应答 */
unsigned char I2CWaitAck(void) {
unsigned char ackbit;
scl = 1;
I2C_Delay(DELAY_TIME);
ackbit = sda; // 0=ACK, 1=NACK
scl = 0;
I2C_Delay(DELAY_TIME);
return ackbit;
}
/* 主机发送应答 */
void I2CSendAck(unsigned char ackbit) {
scl = 0;
sda = ackbit;
I2C_Delay(DELAY_TIME);
scl = 1;
I2C_Delay(DELAY_TIME);
scl = 0;
sda = 1; // 释放SDA
I2C_Delay(DELAY_TIME);
}
如果I2C通信不稳定,首先尝试把 DELAY_TIME 调大。特别是在中断中操作I2C的时候,可能会因为中断打断时序导致通信失败,这时候需要关中断保护。
PCF8591:ADC读取
PCF8591是一个8位ADC/DAC芯片,器件地址:
- 写:0x90
- 读:0x91
CT107D上的ADC通道映射:
- 通道0(0x01):外部输入
- 通道1(0x03):光敏电阻
- 通道2(0x05):滑动变阻器(RB2)
项目中的ADC读取函数:
/*
* Addr参数说明:
* 0x01 → 通道1:光敏电阻
* 0x03 → 通道3:滑动变阻器(RB2)
*/
unsigned char Ad_Read(unsigned char Addr) {
unsigned char Temp;
I2CStart();
I2CSendByte(0x90); // 写地址
I2CWaitAck();
I2CSendByte(Addr); // 控制字节(选通道)
I2CWaitAck();
I2CStart(); // 重新起始(随机读模式)
I2CSendByte(0x91); // 读地址
I2CWaitAck();
Temp = I2CReceiveByte();
I2CStop();
return Temp; // 返回0~255的原始值
}
这里有一个关键的I2C操作——重新起始(Repeated Start)。在读操作中,先写地址和控制字节,然后不发送停止信号,直接发送新的起始信号切换到读模式。这是I2C随机读的标准流程。
第16届省赛里的增强版ADC函数,直接返回电压值:
float AD_Read(unsigned char port) {
unsigned char temp = 0;
I2CStart();
I2CSendByte(0x90);
I2CWaitAck();
I2CSendByte(0x40 | port); // 0x40表示允许DAC输出
I2CWaitAck();
I2CStart();
I2CSendByte(0x91);
I2CWaitAck();
temp = I2CReceiveByte();
I2CSendAck(1); // 发送NACK结束
I2CStop();
return temp / 51.0; // 转换为电压(0~5V)
}
temp / 51.0 的计算:PCF8591是8位ADC,0255对应05V。255 / 5 = 51,所以 ADC值 / 51 = 电压值。
DAC输出
DAC输出也很简单:
void Da_Write(unsigned char Data) {
I2CStart();
I2CSendByte(0x90);
I2CWaitAck();
I2CSendByte(0x41); // 控制字节:使能DAC输出
I2CWaitAck();
I2CSendByte(Data); // DAC数据(0~255 → 0~5V)
I2CWaitAck();
I2CStop();
}
控制字节 0x41 的含义:bit6=1(使能DAC模拟输出),bit0=1(选择通道1)。输出的电压 = Data * 5V / 255。
实际应用中的DAC输出——把距离映射为电压:
void DAC_Transition(unsigned int distance) {
if(distance <= 20)
DA_Output(1); // 20cm以下 → 1V
else if(distance >= 100)
DA_Output(5); // 100cm以上 → 5V
else
DA_Output(((distance – 20) / 20.0) + 1); // 线性映射
}
AT24C02:EEPROM读写
AT24C02是一个256字节的EEPROM,器件地址:
- 写:0xA0
- 读:0xA1
EEPROM写入
void EEPROM_Write(unsigned char *str, unsigned char Addr, unsigned char num) {
I2CStart();
I2CSendByte(0xA0); // 写地址
I2CWaitAck();
I2CSendByte(Addr); // 目标存储地址
I2CWaitAck();
while(num–) {
I2CSendByte(*str++);
I2CWaitAck();
I2C_Delay(200); // 每写一个字节就等!
}
I2CStop();
// 额外延时保证芯片处理完毕
I2C_Delay(255);
I2C_Delay(255);
I2C_Delay(255);
I2C_Delay(255);
}
这里有一个必须注意的点:EEPROM写入后需要5ms的编程时间。I2C_Delay(200) 就是等这个时间。如果写得太快,后面的数据会覆盖前面还没写完的。
第16届省赛里的EEPROM要加了更多的延时保护:
void EEPROM_Write(unsigned char* dat, unsigned char addr, unsigned char len) {
I2CStart();
I2CSendByte(0xA0);
I2CWaitAck();
I2CSendByte(addr);
I2CWaitAck();
while(len–) {
I2CSendByte(*dat++);
I2CWaitAck();
I2C_Delay(200);
}
I2CStop();
I2C_Delay(200); // 这里额外加了更多延时
I2C_Delay(200);
I2C_Delay(200);
I2C_Delay(200);
I2C_Delay(200);
I2C_Delay(200);
}
这说明在实际比赛中,EEPROM的写入稳定性确实是个坑,多加延时不会有坏处。
EEPROM读取
void EEPROM_Read(unsigned char *str, unsigned char Addr, unsigned char num) {
I2CStart();
I2CSendByte(0xA0); // 写地址(设置读起始地址)
I2CWaitAck();
I2CSendByte(Addr);
I2CWaitAck();
I2CStart(); // 重新起始
I2CSendByte(0xA1); // 读地址
I2CWaitAck();
while(num–) {
*str++ = I2CReceiveByte();
if(num)
I2CSendAck(0); // 还要继续读,发ACK
else
I2CSendAck(1); // 最后一个字节,发NACK
}
I2CStop();
}
应答技巧:连续读取多个字节时,除最后一个字节外都发ACK(0),最后一个字节发NACK(1),告诉从机"读完了"。
中断中操作I2C:一个血的教训
DAC输出函数在中断和主循环中都有可能被调用,选择在中断版的DAC函数中关中断:
void DA_Output(float voltage) {
I2CStart();
I2CSendByte(0x90);
I2CWaitAck();
I2CSendByte(0x40);
EA = 0; // 关中断!保护I2C时序
I2C_Delay(40);
EA = 1; // 恢复中断
I2CSendByte((unsigned char)(voltage * 51));
I2CWaitAck();
I2CStop();
}
为什么不直接在中断里正常调用I2C函数?因为Timer1中断每1ms触发一次,I2C通信的时序是微秒级的,如果中断在I2C的某个关键时序点被再次触发(虽然STC15默认不嵌套中断,但其他中断源可能干扰),会导致时序错乱。
更彻底的做法是函数重定义——同一个逻辑写两份:
void DAC_Transition(unsigned int distance) { … } // 非中断版
void DAC_Transition_IRQ(unsigned int distance) { … } // 中断版(逻辑相同)
虽然代码重复了,但保证了中断安全,避免了函数重入导致的数据错误。这是竞赛代码中很实用的技巧。
常见问题排查
| ADC读数始终为0xFF | I2C接线错误或从机未响应 | 检查P2.0/P2.1,确认从机地址 |
| ADC读数跳变剧烈 | 延时不够 | 增大DELAY_TIME |
| EEPROM写入后读回来不对 | 写后延时不够 | 增加 I2C_Delay(200) |
| I2C通信偶尔失败 | 被中断打断 | 在I2C操作前后加 EA=0/EA=1 |
| DAC输出不对 | 控制字节写错 | 确认是0x41而不是0x01 |

