从AT24C02数据存储(I2C总线)到定时中断回调函数(或驱动函数)(51单片机学习笔记)
在上一章《从DS1302可调时钟到蜂鸣器播放生日快乐歌(51单片机学习笔记)》中,我们探索了实时时钟模块和蜂鸣器音乐播放的应用。本章我们将继续深入51单片机的外设学习,重点介绍AT24C02 EEPROM存储器和I2C总线通信协议,以及定时中断回调函数的应用。通过这两个核心内容的学习,您将掌握单片机数据持久化存储和高效定时任务调度的关键技术。
AT24C02是一种基于I2C总线的EEPROM存储器,具有掉电不丢失数据的特性,非常适合保存系统配置、用户设置等关键信息。而定时中断回调函数则是单片机编程中实现多任务调度、提高系统响应性的重要设计模式。本章将通过详细的原理讲解、时序分析和完整代码示例,帮助您从理论到实践全面掌握这两项技术。
如果觉得有收获可以为我的文章点点小赞,这将激励我创作更好的内容!谢谢!
一、AT24C02与I2C总线
1.1 存储器介绍
1.2 存储器简化模型
1.3 AT24C02介绍
AT24C02是一种可以实现掉电不丢失的存储器,可用于保存单片机
运行时想要永久保存的数据信息
存储介质:E2PROM
通讯接口:I2C
总线容量:256字节
1.4 引脚及应用电路
1.5 内部结构框图
1.6 I2C总线介绍
I2C总线(Inter IC BUS)是由Philips公司开发的一种通用数据总线
两根通信线:SCL(Serial Clock)、SDA(Serial Data)
同步、半双工,带数据应答
通用的I2C总线,可以使各种设备的通信标准统一,对于厂家来说,使用成熟的方案可以缩短芯片设计周期、提高稳定性,对于应用者来说,使用通用的通信协议可以避免学习各种各样的自定义协议,降低了学习和应用的难度
1.7 I2C电路规范
所有I2C设备的SCL连在一起,SDA连在一起
设备的SCL和SDA均要配置成开漏输出模式
SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右
开漏输出和上拉电阻的共同作用实现了“线与”的功能,此设计主要是为了解决多机通信互相干扰的问题
1.8 I2C时序结构
(1)6种基本操作
起始条件:SCL高电平期间,SDA从高电平切换到低电平
终止条件:SCL高电平期间,SDA从低电平切换到高电平
根据I2C协议的流程,使用代码控制SCL和SDA的引脚,我们可以写出以下代码:
#include <REGX52.H> sbit I2C_SCL=P2^1; sbit I2C_SDA=P2^0; /** * @brief I2C开始 * @param 无 * @retval 无 */ void I2C_Start(void)//I2C起始函数 { I2C_SDA=1;//确认SDA的电平为1 I2C_SCL=1;//确认SCL的电平为1 I2C_SDA=0;//起始标志 I2C_SCL=0;//准备完毕 } /** * @brief I2C停止 * @param 无 * @retval 无 */ void I2C_Stop(void)//I2C终止函数 { I2C_SDA=0;//确认SDA的电平为0 I2C_SCL=1;//终止标志 I2C_SDA=1;//终止完毕 }发送一个字节:SCL低电平期间,主机将数据位依次放到SDA线上(高位在前),然后拉高SCL,从机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可发送一个字节
/** * @brief I2C发送一个字节 * @param Byte 要发送的字节 * @retval 无 */ void I2C_SendByte(unsigned char Byte)//I2C发送一个字节函数 { unsigned char i; for(i=0;i<8;i++) { I2C_SDA=Byte&(0x80>>i);//传入字节的每一位 I2C_SCL=1;//发送一位完毕 I2C_SCL=0;//改回I2C_SCL的值 //我的单片机运行速度不快,可以不插入Delay } }接收一个字节:SCL低电平期间,从机将数据位依次放到SDA线上(高位在前),然后拉高SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节(主机在接收之前,需要释放SDA)
/** * @brief I2C接收一个字节 * @param 无 * @retval 接收到的一个字节数据 */ unsigned char I2C_ReceiveByte(void)//I2C接收一个字节函数 { unsigned char i,Byte=0x00; I2C_SDA=1; for(i=0;i<8;i++) { I2C_SCL=1;//开始接收一位 if(I2C_SDA){Byte|=(0x80>>i);}//接收一位完毕 I2C_SCL=0;//改回I2C_SCL的值 } return Byte; }发送应答:在接收完一个字节之后,主机在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答
接收应答:在发送完一个字节之后,主机在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答(主机在接收之前,需要释放SDA)
/** * @brief I2C发送应答 * @param AckBit 应答位,0为应答,1为非应答 * @retval 无 */ void I2C_SendAck(unsigned char AckBit)//I2C发送应答 { I2C_SDA=AckBit;//写入应答与否 I2C_SCL=1;//发送应答与否 I2C_SCL=0;//改回I2C_SCL的值 } /** * @brief I2C接收应答位 * @param 无 * @retval 接收到的应答位,0为应答,1为非应答 */ unsigned char I2C_ReceiveAck(void)//I2C接收应答 { unsigned char AckBit; I2C_SDA=1;//释放I2C_SCL,确认SDA的电平为1 I2C_SCL=1;//开始接收应答与否 AckBit=I2C_SDA;//接收应答与否 I2C_SCL=0;//改回I2C_SCL的值 return AckBit;//返回应答 }(2)3种基本格式的数据帧
AT24C02 的固定地址为 1010,可配置地址本开发板上为 000
所以 SLAVE ADDRESS+W 为 0xA0,SLAVE ADDRESS+R 为 0xA1
1.发送一帧数据:
完成任务:向谁发什么
2.接收一帧数据:
完成任务:向谁收什么
3.先发送再接收数据帧(复合格式):
完成任务:向谁收指定的什么(有时客机的发送端要知道主机要接收什么数据)
(3)AT24C02规定的2种拓展数据帧
1.字节写:在WORD ADDRESS处写入数据DATA
根据I2C协议的流程,利用第(1)点的6个基本操作函数,我们可以写出以下代码:
#include <REGX52.H> #include "I2C.h" #define AT24C02_ADDRESS 0xA0 /** * @brief AT24C02写入一个字节 * @param WordAddress 要写入字节的地址 * @param Date 要写入的数据 * @retval 无 */ void AT24C02_WriteByte(unsigned char WordAddress,Date) { I2C_Start(); I2C_SendByte(AT24C02_ADDRESS); I2C_ReceiveAck(); I2C_SendByte(WordAddress); I2C_ReceiveAck(); I2C_SendByte(Date); I2C_ReceiveAck(); I2C_Stop(); Delay(5);//等待5ms让数据写入完毕 }注意!!注意!!AT24C02在字节写时,数据手册有一个最大写入时间5ms,写入字节后须等待5ms让数据写入完毕↓↓↓↓
2.随机读:读出在WORD ADDRESS处的数据DATA
AT24C02的固定地址为1010,可配置地址本开发板上为000,所以SLAVE ADDRESS+W为0xA0,SLAVE ADDRESS+R为0xA1:
/** * @brief AT24C02读取一个字节 * @param WordAddress 要读出字节的地址 * @retval 读出的数据 */ unsigned char AT24C02_ReadByte(unsigned char WordAddress) { unsigned char Date; I2C_Start(); I2C_SendByte(AT24C02_ADDRESS); I2C_ReceiveAck(); I2C_SendByte(WordAddress); I2C_ReceiveAck(); I2C_Start(); I2C_SendByte(AT24C02_ADDRESS|0x01);//把写地址变成读地址,然后发送 I2C_ReceiveAck(); Date=I2C_ReceiveByte(); I2C_SendAck(1); return Date; }以上三点根据数据手册提炼出的更易理解的模型,数据手册里对应这2种数据帧的实际描述如下:
完整的I2C与AT24C02代码模块:
I2C.c文件↓
----------------------------
#include <REGX52.H> sbit I2C_SCL=P2^1; sbit I2C_SDA=P2^0; /** * @brief I2C开始 * @param 无 * @retval 无 */ void I2C_Start(void)//I2C起始函数 { I2C_SDA=1;//确认SDA的电平为1 I2C_SCL=1;//确认SCL的电平为1 I2C_SDA=0;//起始标志 I2C_SCL=0;//准备完毕 } /** * @brief I2C停止 * @param 无 * @retval 无 */ void I2C_Stop(void)//I2C终止函数 { I2C_SDA=0;//确认SDA的电平为0 I2C_SCL=1;//终止标志 I2C_SDA=1;//终止完毕 } /** * @brief I2C发送一个字节 * @param Byte 要发送的字节 * @retval 无 */ void I2C_SendByte(unsigned char Byte)//I2C发送一个字节函数 { unsigned char i; for(i=0;i<8;i++) { I2C_SDA=Byte&(0x80>>i);//传入字节的每一位 I2C_SCL=1;//发送一位完毕 I2C_SCL=0;//改回I2C_SCL的值 //我的单片机运行速度不快,可以不插入Delay } } /** * @brief I2C接收一个字节 * @param 无 * @retval 接收到的一个字节数据 */ unsigned char I2C_ReceiveByte(void)//I2C接收一个字节函数 { unsigned char i,Byte=0x00; I2C_SDA=1; for(i=0;i<8;i++) { I2C_SCL=1;//开始接收一位 if(I2C_SDA){Byte|=(0x80>>i);}//接收一位完毕 I2C_SCL=0;//改回I2C_SCL的值 } return Byte; } /** * @brief I2C发送应答 * @param AckBit 应答位,0为应答,1为非应答 * @retval 无 */ void I2C_SendAck(unsigned char AckBit)//I2C发送应答 { I2C_SDA=AckBit;//写入应答与否 I2C_SCL=1;//发送应答与否 I2C_SCL=0;//改回I2C_SCL的值 } /** * @brief I2C接收应答位 * @param 无 * @retval 接收到的应答位,0为应答,1为非应答 */ unsigned char I2C_ReceiveAck(void)//I2C接收应答 { unsigned char AckBit; I2C_SDA=1;//释放I2C_SCL,确认SDA的电平为1 I2C_SCL=1;//开始接收应答与否 AckBit=I2C_SDA;//接收应答与否 I2C_SCL=0;//改回I2C_SCL的值 return AckBit;//返回应答 }----------------------------
AT24C02.c文件↓
----------------------------
#include <REGX52.H> #include "I2C.h" #include "Delay.h" #define AT24C02_ADDRESS 0xA0 /** * @brief AT24C02写入一个字节 * @param WordAddress 要写入字节的地址 * @param Date 要写入的数据 * @retval 无 */ void AT24C02_WriteByte(unsigned char WordAddress,Date) { I2C_Start(); I2C_SendByte(AT24C02_ADDRESS); I2C_ReceiveAck(); I2C_SendByte(WordAddress); I2C_ReceiveAck(); I2C_SendByte(Date); I2C_ReceiveAck(); I2C_Stop(); Delay(5);//等待5ms让数据写入完毕 } /** * @brief AT24C02读取一个字节 * @param WordAddress 要读出字节的地址 * @retval 读出的数据 */ unsigned char AT24C02_ReadByte(unsigned char WordAddress) { unsigned char Date; I2C_Start(); I2C_SendByte(AT24C02_ADDRESS); I2C_ReceiveAck(); I2C_SendByte(WordAddress); I2C_ReceiveAck(); I2C_Start(); I2C_SendByte(AT24C02_ADDRESS|0x01);//把写地址变成读地址,然后发送 I2C_ReceiveAck(); Date=I2C_ReceiveByte(); I2C_SendAck(1); return Date; }----------------------------
1.9 实例一12.1.1AT24C02数据存储
main.c文件↓
----------------------------
#include <REGX52.H> #include "LCD1602.h" #include "Key.h" #include "AT24C02.h" #include "Delay.h" unsigned char Date,KeyNum; unsigned int Num; void main() { LCD_Init(); LCD_ShowNum(1,1,Num,5); while(1) { KeyNum=Key(); if(KeyNum==1) { Num++; LCD_ShowNum(1,1,Num,5); } if(KeyNum==2) { Num--; LCD_ShowNum(1,1,Num,5); } if(KeyNum==3) { AT24C02_WriteByte(0,Num%256); AT24C02_WriteByte(1,Num/256); LCD_ShowString(2,1,"Write OK"); Delay(1000); LCD_ShowString(2,1," "); } if(KeyNum==4) { Num=AT24C02_ReadByte(0); Num|=AT24C02_ReadByte(1)<<8; LCD_ShowNum(1,1,Num,5); LCD_ShowString(2,1,"Read OK"); Delay(1000); LCD_ShowString(2,1," "); } } }----------------------------
二、定时中断回调函数(或驱动函数)
2.1定时中断回调函数/驱动函数
有些时候,多个模块都需要用到单片机的定时器的功能,但定时器只有一个,我们无法在多个模块中调用同一个定时器中断函数,我们不能在程序中定义多个中断函数
这时我们需要有“驱动”的理念,我们可以在main.c中写中断函数,在各个模块写一个函数,这种函数是一个中断函数的调用,然后在main.c里面调用这个函数,假如定时器计时1毫秒计次加1(可以是多个同时开始的计次),模块A要被调用的函数需要30毫秒调用一次,模块B要被调用的函数需要2毫秒调用一次,中断函数里给模块A的计次满30次就调用一次模块A要被调用的函数,模块B同理
void Timer0_Routine() interrupt 1 /*写作定时器函数里面的逻辑操作一定不要过长*/ /*尤其是函数和函数里面的函数不要使用Delay()*/ { static unsigned int T0Count1=0,T0Count2=0,T0Count3=0; TL0=0x66; //设置定时初值 TH0=0xFC; //设置定时初值 T0Count1++;//独立按键定时器驱动(或循环调用) if(T0Count1>=20) { T0Count1=0; Key_Loop(); } T0Count2++;//数码管定时器驱动(或循环调用) if(T0Count2>=2) { T0Count2=0; Nixie_Loop(); } T0Count3++;//时钟定时器驱动(或循环调用) if(T0Count3>=10) { T0Count3=0; Sec_Loop(); } }这种结构可以有效避免代码模块里的内容写入定时器Timer0模块,避免模块间交叉引用,函数出现较高的耦合性,如果函数出现较高的耦合性,这将不利于我们的代码管理
2.2 Key.c(独立按键)与Nixie.c(数码管)模块的升级
通过每20ms扫描获取按键引脚电平,能充分消除按键抖动的影响,不会出现按住按键时其他模块无法正常工作
Key_Task.c文件↓
----------------------------
#include <REGX52.H> #include "Delay.h" unsigned char Key_KeyNumber; /** * @brief 返回独立按键键码——进阶版——搭配定时器使用 * @param 无 * @retval 按下按键的键码,范围:0~4,无按键按下时返回值为0 */ unsigned char Key(void) { unsigned char Temp=0; Temp=Key_KeyNumber; Key_KeyNumber=0; return Temp; } /** * @brief 获取/扫描独立按键键码——进阶版——搭配定时器使用 * @param 无 * @retval 按下按键的键码,范围:0~4,无按键按下时返回值为0 */ unsigned char Key_GetState() { unsigned char KeyNumber=0; if(P3_1==0){KeyNumber=1;} if(P3_0==0){KeyNumber=2;} if(P3_2==0){KeyNumber=3;} if(P3_3==0){KeyNumber=4;} return KeyNumber; } /** * @brief 独立按键松手检测——搭配定时器使用 * @param 无 * @retval 无 */ void Key_Loop(void) { static unsigned char NowState,LastState; LastState=NowState; NowState=Key_GetState(); if(LastState==1 && NowState==0)//松手才有效 { Key_KeyNumber=1; } if(LastState==2 && NowState==0) { Key_KeyNumber=2; } if(LastState==3 && NowState==0) { Key_KeyNumber=3; } if(LastState==4 && NowState==0) { Key_KeyNumber=4; } }----------------------------
Nixie_Task.c文件↓
----------------------------
#include <REGX52.H> #include "Delay.h" /** * @brief 数码管显示数字——进阶版——搭配定时器使用 * @param Location 在开发板从左往右第几位数码管显示,范围:1~8 * @param Number 显示的数字,范围:0~9 * @retval 无 */ unsigned char Nixie_Buf[9]={0,10,10,10,10,10,10,10,10}; unsigned char NixieTable[17]={0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,0x7F,0x6F,0x00,0x40,}; /0到9,无显示,“-”/ void Nixie_SetBuf(unsigned char Location,Number) { Nixie_Buf[Location]=Number; } /** * @brief 数码管扫描数字——搭配定时器使用 * @param Location 在开发板从左往右第几位数码管在定时器触发时显示,完成扫描,范围:1~8 * @param Number 定时器触发时显示的数字,也就是要扫描的数字,范围:0~9 * @retval 无 */ void Nixie_Scan(unsigned char Location,Number) { P0=0x00; switch(Location) { case 1:P2_4=1,P2_3=1,P2_2=1;break; case 2:P2_4=1,P2_3=1,P2_2=0;break; case 3:P2_4=1,P2_3=0,P2_2=1;break; case 4:P2_4=1,P2_3=0,P2_2=0;break; case 5:P2_4=0,P2_3=1,P2_2=1;break; case 6:P2_4=0,P2_3=1,P2_2=0;break; case 7:P2_4=0,P2_3=0,P2_2=1;break; case 8:P2_4=0,P2_3=0,P2_2=0;break; } P0=NixieTable[Number]; } void Nixie_Loop(void) { static unsigned char i=1; Nixie_Scan(i,Nixie_Buf[i]); i++; if(i>=9){i=1;} }----------------------------
2.3 实例二12.2.1秒表(定时器扫描按键数码管)
利用以上知识和上一节AT24C02掉电仍存储数据的特性,我们能写出一个可记录时间的秒表:
main.c文件↓
----------------------------
#include <REGX52.H> #include "Timer0.h" #include "Nixie_Task.h" #include "Key_Task.h" #include "Delay.h" #include "I2C.h" #include "AT24C02.h" unsigned char KeyNum; unsigned char Min,Sec,MiniSec; unsigned char RunFlag; void main() { Timer0_Init(); while(1) { KeyNum=Key(); if(KeyNum==1) { RunFlag=!RunFlag; } if(KeyNum==2) { Min=0,Sec=0,MiniSec=0; } if(KeyNum==3) { AT24C02_WriteByte(0,Min); AT24C02_WriteByte(1,Sec); AT24C02_WriteByte(2,MiniSec); } if(KeyNum==4) { Min=AT24C02_ReadByte(0); Sec=AT24C02_ReadByte(1); MiniSec=AT24C02_ReadByte(2); } Nixie_SetBuf(1,Min/10); Nixie_SetBuf(2,Min%10); Nixie_SetBuf(3,11); Nixie_SetBuf(4,Sec/10); Nixie_SetBuf(5,Sec%10); Nixie_SetBuf(6,11); Nixie_SetBuf(7,MiniSec/10); Nixie_SetBuf(8,MiniSec%10); } } void Sec_Loop(void) { if(RunFlag) { MiniSec++; if(MiniSec>=100) { MiniSec=0; Sec++; if(Sec>=60) { Sec=0; Min++; if(Min>=60) { Min=0; } } } } } void Timer0_Routine() interrupt 1 /写作定时器函数里面的逻辑操作一定不要过长/ /尤其是函数和函数里面的函数不要使用Delay()/ { static unsigned int T0Count1=0,T0Count2=0,T0Count3=0; TL0=0x66; //设置定时初值 TH0=0xFC; //设置定时初值 T0Count1++;//独立按键定时器驱动(或循环调用) if(T0Count1>=20) { T0Count1=0; Key_Loop(); } T0Count2++;//数码管定时器驱动(或循环调用) if(T0Count2>=2) { T0Count2=0; Nixie_Loop(); } T0Count3++;//时钟定时器驱动(或循环调用) if(T0Count3>=10) { T0Count3=0; Sec_Loop(); } }----------------------------
三、总结
本章我们系统学习了AT24C02 EEPROM存储器和I2C总线通信协议,以及定时中断回调函数的设计与应用。通过本章的学习,您应该掌握以下核心知识点:
1. AT24C02与I2C总线
- AT24C02是一种256字节容量的EEPROM存储器,支持I2C总线通信,具有掉电不丢失数据的特性
- I2C总线采用SCL(时钟线)和SDA(数据线)两根线实现同步半双工通信,支持多主机多从机架构
- I2C协议包含起始条件、终止条件、发送字节、接收字节、发送应答、接收应答等6种基本操作
- AT24C02支持字节写和随机读两种数据帧格式,写入后需要等待5ms的写入时间
2. 定时中断回调函数
- 通过定时中断回调函数可以实现模块化、低耦合的代码设计
- 在main.c中编写统一的中断函数,在各模块中编写回调函数,通过定时器计数实现不同频率的任务调度
- 这种设计避免了模块间交叉引用,提高了代码的可维护性和可扩展性
3. 实践应用
- 通过AT24C02数据存储实例,实现了按键计数的掉电保存功能
- 通过秒表示例,将AT24C02存储与定时中断回调函数结合,实现了完整的秒表功能
- 独立按键和数码管模块升级为定时器驱动版本,消除了按键抖动影响
本章内容与上一章的DS1302时钟和蜂鸣器应用形成了良好的技术衔接。掌握了这些技术后,您可以设计更加复杂的单片机系统,如数据采集系统、智能控制器等。下一章我们将探索51单片机的DS18B20温度传感器和LCD1602液晶显示器。
都看到这了点点赞吧!求求了
