恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于51单片机的I2C通信与AT24C02数据读写显示实战
首页
资讯中心
/
基于51单片机的I2C通信与AT24C02数据读写显示实战
基于51单片机的I2C通信与AT24C02数据读写显示实战
发布时间:2026/10/12 8:14:15
做单片机开发这些年我一直觉得I2C是新手迈入串行通信大门最合适的一个协议。原因很简单它只有两根线逻辑清晰时序直观而且你随便找一块AT24C02就能验证收发是否正确。今天整理的这个项目——基于51单片机的I2C通信以及从AT24C02读取保存数据并显示在4位数码管上——属于典型的入门级综合小工程但它把通信协议、存储芯片操作、动态扫描显示三块核心知识全串起来了。我见过不少初学者在学完GPIO点灯之后面对I2C协议一脸茫然。其实你只要把起始条件、停止条件、应答信号这三个东西搞明白AT24C02基本就拿下大半了。更妙的是51单片机没有硬件I2C外设你必须用普通IO口模拟时序这反而逼着你把协议细节彻底吃透。等项目跑通了你会发现以后再看其他带I2C外设的芯片比如STM32、ESP32简直轻车熟路。这篇内容我就以这个项目为主线从硬件连接讲到软件驱动把写字节、读字节、页写入、应答轮询、数码管动态扫描这些关键点逐一拆开讲最后再说说我实际调试中踩过的坑。无论你是课程设计还是自学练手照着这套思路走一遍收获会非常实在。1. 项目整体设计与思路拆解这个项目最经典的形态是STC89C52或者AT89S51做主控P2口驱动4位数码管的位选P0口做段选另外用两个普通IO口模拟I2C的SDA和SCL挂一颗AT24C02。平时数码管显示当前存储地址里的数据你按下写入按键就把某个数值写进AT24C02再读出来显示到数码管上。整个过程其实就是一个完整的“写入-读取-显示”闭环。1.1 为什么是AT24C02加4位数码管这个组合先说AT24C02。它是一颗2Kbit也就是256字节的EEPROM容量不大但胜在便宜、通用、协议简单。你以后接触到的很多物联网设备里存配置参数的芯片很多就是类似的东西只是封装和容量不同。学会操作AT24C02等于掌握了这类存储芯片的通用用法。至于4位数码管它承担的是“结果呈现”的角色。很多人会质疑为什么不用LCD1602显示多方便我的看法是数码管动态扫描本身就是一个必须掌握的知识点而且用数码管显示能非常直观地看到数据是否被正确读回来——你写入一个0x5A读回来数码管就亮一个5A对错一眼便知。LCD1602当然也行但4位一体数码管成本更低、时序更简单更适合新手阶段做练习。另外这个项目表面上是“I2C通信”实际上它还隐藏了两个重要知识点一个是掉电保存AT24C02是非易失性存储另一个是数据的串行收发过程。你把一个字节从单片机发到存储芯片里再原封不动取回来这个过程里你会对“数据到底是怎么在线上传输的”这件事建立起非常具象的认识。1.2 方案背后的取舍先说为什么用51单片机。不是因为51性能强恰恰相反是因为它基础、干净、没有太多外设干扰。STM32的硬件I2C虽然省事但配置起来寄存器一堆出了问题你根本分不清是时序问题还是寄存器配置问题。51单片机没有硬件I2C你必须用GPIO模拟这反而让你把协议的每个电平变化都搞得清清楚楚。再说为什么用模拟I2C而不是外挂一个带硬件I2C的芯片。这里有个非常现实的工程考量很多8位单片机根本没有硬件I2C外设或者I2C引脚被其他功能占用了。这时候你学会软件模拟代码粘过来就能用完全不依赖硬件资源。我在实际项目里就经常这么干特别是临时要挂一颗传感器或者存储芯片的时候找两个空闲GPIO就能搞定。还需要提一下数码管的驱动方式。4位一体的数码管有两种接法静态锁存驱动和动态扫描。静态驱动要每个位单独配锁存器电路复杂、占IO口多动态扫描则是轮流点亮每一位利用人眼视觉暂留效应“骗”过眼睛。这个项目用的是动态扫描代码量不大效果却很好而且为以后驱动更多位数的数码管打下了基础。整个项目的代码结构也可以分成三个模块I2C底层驱动、AT24C02读写逻辑、数码管显示逻辑。这三个模块各自独立接口清晰。我建议你写代码的时候也这么分层以后换LCD、换存储芯片只需要改对应的模块主程序基本不用动。2. 电路连接与硬件搭建要点硬件连接是整个项目的基础很多新手代码写得没问题结果硬件上某个引脚接错或者漏了上拉电阻导致现象全乱。这里我把每个关键元件的连接方式都讲一遍你照着搭基本不会出错。2.1 AT24C02引脚定义与典型接法AT24C02是8引脚的DIP封装引脚定义如下表所示引脚号名称功能典型接法1A0地址输入0接GND2A1地址输入1接GND3A2地址输入2接GND4GND电源地接GND5SDA数据线接P1.0或P2.06SCL时钟线接P1.1或P2.17WP写保护接GND允许写8VCC电源正接5VA0、A1、A2是地址选择脚这三位组合起来可以决定芯片在I2C总线上的地址。如果你只挂一颗AT24C02这三脚全部接GND即可。需要注意的是A0-A2这三位参与构成芯片的7位地址也就是说地址的最低位是读写控制位后面讲I2C寻址的时候详细说。WP是写保护引脚接高电平时芯片只读不写接低电平时允许正常写入。调试阶段一定要把这脚接GND否则你会发现数据怎么写都写不进去。我见过有人因为WP引脚悬空导致写入失败查了半天才发现问题。SDA和SCL都需要接上拉电阻到VCC。这个很容易被忽略但它的作用是决定性的。I2C协议规定SDA和SCL都是开漏结构也就是说芯片只能把线拉低不能主动拉高。没有上拉电阻这两根线就永远停留在低电平通信根本无法建立。阻值一般选4.7k到10k之间我常用10k跑100kHz标准模式完全没问题。5V供电下4.7k也是很多人喜欢用的切换速度更快。2.2 数码管与单片机的连接4位一体数码管内部有两种接法共阴和共阳。这里以常见的共阴4位一体数码管为例它的公共端是四个独立的引脚每个位的段选a-g、dp一般是并联的。典型的动态扫描接法是P0口接数码管的段选即P0.0对应a段、P0.1对应b段依此类推P2.0到P2.3分别接四个位的公共端。这样你选中某一位的公共端同时在P0口输出该位要显示的段码这一位就亮了。轮流对四位做同样的操作只要刷新够快看起来就是四位同时点亮。这里有个硬性要求P0口是开漏输出内部没有上拉必须外接上拉电阻才能驱动数码管段选。一般用一个排阻比如8个10k电阻集成在一体的那种把P0口全部上拉到VCC。这个排阻在很多51单片机开发板上都是标配如果你用的板子自带就不用额外处理了。位选那边如果是共阴数码管公共端接低电平选中该位如果是共阳数码管则公共端接高电平选中该位。所以写程序之前一定要先确认你手里的数码管是共阴还是共阳这会直接影响位选逻辑和段码表。2.3 上拉电阻与电源处理除了I2C的SDA/SCL需要上拉数码管的段选也需要上拉这两处千万别漏。另外AT24C02的供电和单片机是同一个5V电源电源引脚旁边最好加一个0.1uF的瓷片电容做去耦位置尽量靠近芯片的VCC和GND引脚。这是很多开发板的常规做法能减少电源噪声对通信的干扰。还有一个容易被忽略的问题如果你的开发板用的是STC89C52这类芯片它的P1口内部有弱上拉但P0口没有。很多人习惯把SDA和SCL接在P1口或者P2口这样即使忘了接上拉电阻有时候也能碰巧工作因为内部弱上拉勉强能拉高电平。但我不建议你依赖这种碰巧因为弱上拉的驱动能力很弱抗干扰差通信距离稍微长一点或者线路上有干扰就会出现随机读错数据的情况。老老实实接4.7k或10k上拉电阻一劳永逸。接线的时候还有个小技巧把I2C的两根线SDA和SCL尽量缩短不要太长能直接插在开发板附近最好。I2C本来就是为短距离板内通信设计的线拉长了波形会变形误码率直线上升。你要是用杜邦线接了二三十厘米出现莫名奇怪的问题不要惊讶先检查线路长度。3. I2C协议底层细节从波形到应答I2C协议是这个项目的灵魂。如果你只是照着别人的代码抄一遍运行成功了那其实什么都没学会。我建议你把协议手册上最关键的几个时序彻底搞清楚再回去看代码就会有“原来如此”的感觉。3.1 起始、停止与数据有效性I2C通信的所有动作都围绕SCL时钟线和SDA数据线这两根线展开。起始条件START定义为SCL保持高电平期间SDA由高电平跳变到低电平。这是一个“边沿事件”它告诉总线上所有的从设备主机要开始通信了大家做好准备。需要注意的是起始条件之后SDA上传输的数据必须在SCL高电平期间保持稳定只能在SCL低电平期间变化。停止条件STOP定义为SCL保持高电平期间SDA由低电平跳变到高电平。它表示一次通信结束总线释放。如果你把这两个条件理解为“交通灯”起始条件就是绿灯亮了允许通行停止条件就是红灯亮了禁止通行而SCL低电平期间SDA变化则相当于“车辆在路口内换车道”的时刻。这几个概念在画波形图的时候特别清晰实际调试时可借助逻辑分析仪观察。数据有效性规则是I2C真正不容易理解的地方数据线SDA上的电平状态只有在SCL为高电平期间才是有效的SCL低电平期间SDA可以随意变化为下一个bit做准备。这意味着发送方必须在SCL低电平期间先把SDA设置好然后在SCL拉高之后让从设备采样。软件模拟时这个先后顺序直接对应代码里“先改SDA电平再拉高SCL延时再拉低SCL”的操作步骤。3.2 地址与应答机制I2C总线上的每个从设备都有一个7位地址。AT24C02的7位地址是1010加上A2、A1、A0三位的组合。当A2A1A0都接地时7位地址就是1010000换算成8位加上读写位后写地址是0xA0读地址是0xA1。这里有一个新手很容易头晕的地方为什么手册上写的是0xA0而不是0x50因为0xA0是完整的8位地址字节其中最高7位是设备地址最低位LSB是读写控制位。0表示接下来要写1表示接下来要读。但还有一些库和驱动代码里用的是0x50这样的7位地址表示法那是不包括读写位的。你只要认准你的驱动函数里如果发送“写地址”8位形式就是0xA0发送“读地址”就是0xA1。应答信号ACK是I2C通信中最体现“对话感”的机制。每发送完8个bit数据后发送方会释放SDA线置高然后在第9个时钟脉冲期间接收方如果一切正常会把SDA拉低表示“我收到了继续发”。如果接收方没有拉低SDASDA保持高电平那就是NACK一般表示接收方没准备好、地址错误或者数据传完了不想要了。主机读AT24C02的最后一个字节时主机应该在第9个时钟发送NACK告诉从设备“我读完了不用再发了”然后再发停止条件。这个细节很多教程讲不清楚导致很多人在读最后一个字节时多发了ACK芯片会继续输出下一个字节数据就乱了。3.3 三种读时序与写周期AT24C02支持几种不同的读写方式日常练习中最常用的是“写一个字节”和“读一个字节”随机读。写一个字节的时序流程是主机发起始条件主机发送设备地址写位0xA0主机发送要写入的字节地址0x00到0xFF主机发送要写入的数据主机发停止条件。数据发完之后AT24C02内部会启动一个写周期Write Cycle把数据从页缓存真正写入EEPROM存储单元。这个写周期最长大约5ms在写周期结束之前芯片不会响应任何新的写命令。所以你现在发完停止条件后千万别立刻再发下一条写命令必须等5ms以上。等这5ms有几种办法最简单粗暴的是延时5ms以上更专业的是用“应答轮询”Acknowledge Polling即反复给芯片发送设备地址写位直到芯片回应ACK为止。因为芯片在写周期内不会应答任何请求一旦应答了说明写周期已经结束。应答轮询的好处是等待时间自适应写周期短的时候就省时间。读一个字节随机读的时序就稍微绕一点主机发起始条件主机发送设备地址写位0xA0告诉芯片“我要定位地址”主机发送要读取的字节地址主机再发一个起始条件重复起始Repeated START主机发送设备地址读位0xA1告诉芯片“现在开始读”主机读取8个bit的数据主机发送NACK不应答主机发停止条件。第二次发起始条件的关键在于不需要先发停止条件直接在当前总线上再来一次起始条件即可。很多初学I2C协议的人就是在这里卡住以为每一次通信都必须有起始和停止配套其实重复起始是很常见的一种用法。如果你用软件模拟只需要把起始条件的函数再调用一次就行。3.4 软件模拟I2C的延时设计关于延时我一直强调一个原则软件模拟I2C的延时宁长勿短。AT24C02在标准模式下SCL时钟频率不能超过100kHz也就是一个时钟周期至少10us。你写代码时就可以把SCL高电平保持时间设为5us左右SCL低电平保持时间5us左右加起来10us符合标准模式。实际用51单片机跑12MHz晶振一个机器周期是1us所以延时函数里写几个NOP、几个循环就能凑够。问题是很多人写的延时函数在编译优化后变得特别短你以为延时了5us实际只有1us。结果就是时序不对通信不稳定。我的建议是写延时函数的时候用volatile变量做循环并且取值不要太抠门比如SCL高/低电平各保持10us宁可慢一点也不要翻车。反正AT24C02和51单片机都足够慢不需要追求极致速度。还有一点51是12M晶振时用Keil写C51代码一条普通语句编译出来往往好几个机器周期所以延时5us可能只需要一个很短的循环。你可以在仿真器里用示波器或逻辑分析仪实际看一下SCL的频率心里就有数了。后面我在排查部分再细说。4. 软件实现从零手写I2C驱动到数码管显示代码这部分我会按模块拆开讲每个模块都给关键代码。你写的时候不用做一个巨大的main函数塞到底分层写逻辑清晰。4.1 I2C底层基础函数首先是IO口初始化。如果用P1.0做SDA、P1.1做SCL初始化就是把两个引脚配置成准双向IO口51默认就是准双向并让SDA和SCL都输出高电平。#include reg52.h sbit SDA P1^0; sbit SCL P1^1; void I2C_Init(void) { SDA 1; SCL 1; }然后是起始条件和停止条件。起始条件是SCL高电平时SDA从高变低停止条件是SCL高电平时SDA从低变高。void I2C_Start(void) { SDA 1; // 先释放SDA SCL 1; // SCL拉高 delay_us(5); SDA 0; // SCL高电平期间SDA拉低产生起始条件 delay_us(5); SCL 0; // 拉低SCL为后续数据传输做准备 } void I2C_Stop(void) { SDA 0; // 先拉低SDA SCL 1; // SCL拉高 delay_us(5); SDA 1; // SCL高电平期间SDA拉高产生停止条件 delay_us(5); }写一个字节核心逻辑是依次发送8个bit每个bit都在SCL低电平期间设置SDA电平然后拉高SCL让从设备采样再拉低SCL结束该bit。void I2C_WriteByte(unsigned char dat) { unsigned char i; for(i 0; i 8; i) { SDA (dat 0x80) ? 1 : 0; // 取最高位 dat 1; delay_us(2); SCL 1; delay_us(5); SCL 0; delay_us(2); } }读一个字节则是主机在每个bit的SCL高电平期间读取SDA电平。unsigned char I2C_ReadByte(void) { unsigned char i, dat 0; for(i 0; i 8; i) { SCL 1; delay_us(5); dat 1; if(SDA) dat | 0x01; SCL 0; delay_us(5); } return dat; }主机发送应答或者非应答关键在第9个时钟。主机要发ACK就把SDA拉低要发NACK就把SDA释放置高。void I2C_SendAck(unsigned char ack) { if(ack) SDA 0; // 应答SDA拉低 else SDA 1; // 非应答SDA释放 delay_us(2); SCL 1; delay_us(5); SCL 0; delay_us(2); SDA 1; // 释放SDA }这里还必须有一个等待应答的函数。厂商的AT24C02会在每个8bit数据后由接收方拉低SDA作为ACK但主机发完一个字节后需要确认SDA是否被拉低。如果SDA一直为高说明从设备没有应答。unsigned char I2C_WaitAck(void) { unsigned char ack 0; SDA 1; // 主机释放SDA让从设备拉低 delay_us(2); SCL 1; delay_us(5); if(SDA) // 高电平 NACK ack 0; else // 低电平 ACK ack 1; SCL 0; delay_us(2); return ack; }4.2 AT24C02读写函数有了I2C底层函数AT24C02的驱动就顺理成章了。写一个字节到指定地址void AT24C02_WriteByte(unsigned char addr, unsigned char dat) { I2C_Start(); I2C_WriteByte(0xA0); // 设备地址写 I2C_WaitAck(); I2C_WriteByte(addr); // 字节地址 I2C_WaitAck(); I2C_WriteByte(dat); // 数据 I2C_WaitAck(); I2C_Stop(); delay_ms(10); // 等待写周期结束 }从指定地址读取一个字节unsigned char AT24C02_ReadByte(unsigned char addr) { unsigned char dat; I2C_Start(); I2C_WriteByte(0xA0); // 设备地址写定位地址 I2C_WaitAck(); I2C_WriteByte(addr); // 字节地址 I2C_WaitAck(); I2C_Start(); // 重复起始 I2C_WriteByte(0xA1); // 设备地址读 I2C_WaitAck(); dat I2C_ReadByte(); I2C_SendAck(0); // 发送NACK I2C_Stop(); return dat; }如果你一次要存多个连续字节AT24C02还支持页写入一次最多写8个字节一页8字节内部不用等写周期。但跨页连续写会出错必须手动分页。新手练习阶段只写单字节就够了页写入这个功能可以等理解了基础时序再扩展。4.3 数码管动态扫描数码管显示模块的核心是段码表和动态扫描函数。共阴数码管的段码表0-9以及A-F大概是这样的unsigned char code seg_code[16] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F, // 9 0x77, // A 0x7C, // b 0x39, // C 0x5E, // d 0x79, // E 0x71, // F };如果你用的是共阳数码管段码表要取反按位取反同时位选逻辑也会反过来这一点一定要记得。动态扫描函数的核心是“位选-段选-延时-消隐”四个步骤。以四位共阴数码管为例P2.0到P2.3分别对应第1到第4位void Display_Num(unsigned int num) { unsigned char i; unsigned char digit[4]; digit[0] num % 10; // 个位 digit[1] (num / 10) % 10; // 十位 digit[2] (num / 100) % 10; // 百位 digit[3] (num / 1000) % 10; // 千位 for(i 0; i 4; i) { P2 0xFF; // 消隐先关闭所有位选 P0 seg_code[digit[i]]; // 送段码 P2 ~(0x01 i); // 选中第i位共阴低电平选中 delay_ms(2); } }注意消隐这一步很重要。扫描完当前位之后如果不先把位选全部关闭马上切换段码片选就会在下一位出现“鬼影”——也就是上一位的残影还留在数码管上微微发光。所以在送新的段码之前先关闭所有位然后送段码再打开对应位。刷新频率方面四位一轮扫描的时间建议控制在8ms以内也就是每位显示2ms左右这样人眼看起来比较连续。如果你主循环里又有I2C读写的延时注意不要让扫描的间隔太长否则会明显看到闪烁。4.4 主程序逻辑主程序的逻辑简单清晰初始化I2C读取AT24C02中某个地址比如0x00的数据显示到数码管检测按键如果按下就写入一个新值到AT24C02然后重新读出来显示。void main(void) { unsigned char dat; unsigned char key_flag 0; I2C_Init(); dat AT24C02_ReadByte(0x00); // 上电先读一次 Display_Num(dat); while(1) { if(KEY 0) // 按键按下 { delay_ms(10); // 消抖 if(KEY 0) { dat; if(dat 99) dat 0; AT24C02_WriteByte(0x00, dat); dat AT24C02_ReadByte(0x00); while(KEY 0); // 等待松开 } } Display_Num(dat); } }写完之后重新读一遍再显示这一步很关键。它不只是让你确认写入成功更重要的是让你观察“刚写进去的值读回来是否和写进去的一致”这是验证整个链路的最直接方法。如果读回来跟写进去不一样那就需要按下一节的方法排查问题了。5. 常见问题与排查技巧实录程序写完烧录进去很多时候并不会一次就通过。我把自己调试过程中遇到过的典型问题整理成了一份对照表你在遇到类似现象时可以逐条排查。5.1 现象对照排查表现象可能原因排查方向数码管完全不亮电源没接好、晶振没起振、复位电路异常先确认单片机最小系统正常测VCC和GND数码管乱码但程序在跑段码表不对、共阴共阳搞反对照共阴/共阳段码表重新确认数码管亮度低且有残影动态扫描消隐不到位、段选驱动能力不足检查消隐代码、P0口上拉电阻不管写什么读出来都是0xFF读取操作前地址定位失败或者芯片没有应答用逻辑分析仪查看A0/A1时序写入数据后读回正常但是断电丢失写入没有真正完成、WP写保护引脚未接GND确认停止条件后等待5ms检查WP引脚I2C通信时好时坏上拉电阻缺失、线太长、SDA/SCL接反加4.7k上拉缩短杜邦线数码管显示闪烁明显动态扫描间隔太久、主循环里有长延时把扫描函数放进定时器中断或者精简显示循环5.2 调试工具与判断技巧我强烈建议你入手一个十几块钱的逻辑分析仪调试I2C的时候务必用上。AT24C02的时序很简单你用逻辑分析仪抓一下SDA和SCL两根线就能直接看出起始条件、停止条件、地址字节、数据字节的波形是不是和手册里的时序一致。没有逻辑分析仪时也可以用示波器看波形。如果连示波器都没有那就用笨办法写一段测试代码让单片机反复读AT24C02的某个地址然后把读到的数据直接P1口点亮LED用肉眼观察结果。比如读回来是0x00就全灭是0xFF就全亮这样你可以快速判断通信是否建立。还有一个小技巧在调试I2C的时候先把数码管显示和按键逻辑全部注释掉只做“写一个固定值、读回来、判断是否一致”的测试。等通信完全正常了再逐步把显示和按键加回来。这样分层调试能帮你迅速缩小问题范围而不是一上来就面对一堆乱码。关于延时我是真的踩过不少坑。51单片机用12MHz晶振一个机器周期是1us和标准I2C 100kHz的时序要求基本匹配。但我发现很多人把延时函数写得特别“精细”结果不同厂家的51芯片或者不同优化级别下实际延时差很多。所以我更倾向于把SCL高低电平的时间各放宽到10us宁可慢也不要时好时坏。在这个项目里性能完全不是瓶颈稳定才是第一位的。AT24C02的地址还有一个容易搞混的点如果你把A0、A1、A2接了不同的电平比如A0接了VCC那么写地址就从0xA0变成了0xA2。很多人复制别人的代码没有注意这个以为AT24C02的地址永远是0xA0结果一换芯片或一改接法就读不到。总之每次接线之前先确认三个地址脚的电平再计算实际地址。最后讲一下按键消抖。这个项目里按键用来手动触发写入如果不做消抖一次按下可能被识别成多次数据会连续递增好几下。消抖的办法就是按下之后延时大约10ms再确认一次确认有效后等到松开再执行下一轮判断。你在代码里看到那段delay_ms(10)和while(KEY0)就是在干这事。我个人在实际操作中最满意的地方是这整套代码在换到其他型号的单片机时只需要改一下SDA和SCL的引脚定义就能复用。后来我做项目需要外挂传感器或存储芯片基本都是把这个I2C驱动原封不动搬过去改一下设备地址就完事。所以说这个项目表面上是个小练习实际上它给了你一套长在身上的基本功。等你哪天突然发现不用查任何手册凭记忆就能写出起始、停止、ACK这些波形和代码那这趟功夫才算真正下到位了。