欢迎光临
我们一直在努力

蓝桥杯单片机I2C总线驱动——从时序到PCF8591/AT24C02完整实现

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
赞(0)
未经允许不得转载:171主机测评 » 蓝桥杯单片机I2C总线驱动——从时序到PCF8591/AT24C02完整实现
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址