简介:这是一份面向 51 单片机学习者的 EEPROM 24C02 读写实例,采用 C 语言编写并附带 Proteus 仿真工程,可帮助理解和掌握 I2C 总线的操作流程,适合高校学生、电子爱好者以及需要快速上手 I2C 通信的开发者参考。压缩包内共 11 个文件,主要包含 C 语言源码、HEX 烧录文件、Keil 工程配置文件及备份、编译中间文件等,资源包整体仅约 17KB,内容精简而完整,既可以直接阅读源码学习编程思路,也可以加载到 Keil 和 Proteus 中进行仿真验证。目前已有 1295 人浏览学习,该例程虽然体量小巧,但涵盖了从工程配置、编译输出到仿真调试的完整环节,非常适合课堂实验、课后作业或自学对照。通过该例程可以掌握 51 单片机 I2C 通信的软件实现,理解 EEPROM 的写时序、读时序以及地址寻址方式,并能在 Proteus 中直观观察数据写入与读回的全过程,有助于巩固单片机与存储器接口设计的基础知识。
1. 51 单片机读 24C02 卡在无响应?这个 Proteus 仿真工程把时序摊开给你看
EEPROM24C02 是 51 单片机项目里最常见的掉电保存方案,2Kbit 容量存参数刚好,走 I2C 总线却让不少人在第一期软件开发时翻车。新手写第一版 C 代码通常有两种症状:发完设备地址收不到 ACK,SDA 被外部拉死不下来;或者写进去马上读回来得到 0xFF,仿佛数据从没进去过。234-EEPROM24C02 这套实例工程把 Keil C51 源码、Proteus 仿真和预生成的 24c02.hex 整理在一起,打开仿真就能观察完整读写现象,想改逻辑也能对着 24c02.c 直接改,隐患出在哪一层都看得见。适合正在学 51 单片机 C 语言实例的人照着拆 I2C 底层,也适合已有产品经验、只想核对 ACK 处理和页写边界的人快速定位问题。
2. 器件结构、设备地址、起始停止位:软件模拟 I2C 的三个正确姿势
2.1 24C02 的内部组织与软模拟为什么“慢一点更稳”
24C02 的 2Kbit 容量实际是 2048 位,正好组成 256 字节,地址范围 0x00 到 0xFF。它内部又分成 16 页,每页 8 字节,这个结构直接决定了后面页写函数的边界逻辑。与并行 EEPROM 不同,24C02 只把 SCL、SDA 两根线引到主控,访问时需要先发一个器件地址字节说明“找的是哪颗芯片、接下来是写还是读”,再发一个 8 位内部地址,然后才是数据。51 单片机没有硬件 I2C 模块时,用普通 IO 口做位操作是最通用的方案,缺点是要自己保证时序落在协议范围内。
软件模拟 I2C 有硬件等效性问题:AT24C02 标准模式要求 SCL 高电平宽度不低于 4.0µs,低电平宽度不低于 4.7µs,起始停止条件的建立时间也在 4.7µs 附近。12MHz 晶振下,一段简单的 C 空循环加几条赋值语句,单次延时大约能到 5µs 级别,刚好处在标准模式区间。把延时拉大一些不影响功能,最多是总线频率从 100kHz 掉到 50kHz 甚至更低,所以老代码里的人为延时写得很随意。真正要避免的是延时太短,一旦 SCL 高电平时间低于 4.0µs,24C02 在采样时可能漏掉某一位,表现就是地址写进去但校验始终不通过。
| I2C 时序参数 | AT24C02 要求 | 本工程软件目标 |
|---|---|---|
| SCL 高电平 tHIGH | ≥ 4.0µs | 约 5µs |
| SCL 低电平 tLOW | ≥ 4.7µs | 约 5µs |
| 起始条件建立 tSU:STA | ≥ 4.7µs | 约 5µs |
| 停止条件建立 tSU:STO | ≥ 4.0µs | 约 5µs |
| 写周期 tWR | 典型 5ms,上限 10ms | 固定延时 10ms |
Proteus 仿真中的 24C02 模型会按真实片子的写入周期模拟,不会因为仿真速度加快就自动跳过 tWR,所以代码里的 10ms 等待在仿真里照样生效,现象和真板基本一致。
2.2 C51 的 sbit 引脚定义与延时基准
SCL、SDA 放在 P3.6 和 P3.7 是这套代码最常见的接法,P1 口留给 LED 和按键,P2 口还能外扩其他设备。C51 中定义如下:
#include <reg51.h> sbit I2C_SCL = P3^6; // 时钟线,接 24C02 的 SCL 引脚 sbit I2C_SDA = P3^7; // 数据线,接 24C02 的 SDA 引脚 void i2c_delay(void) { unsigned char i = 10; while (i--); }这段延时没有用定时器,纯粹靠空循环消耗时间。在 12MHz、12 时钟周期模式的 8051 内核下,while(i--)每轮迭代需要几个机器周期,最终单次延时大约 5µs。如果你用的是 STC 的 6T 模式,需要把i的初值调大一点,否则总线上实际频率会逼近甚至超过 400kHz 限制。判断标准不是初值本身,而是最终量到的 SCL 高电平宽度,这个放到第 5 章用逻辑分析仪验证。
注意 P3.6、P3.7 是准双向 IO,向引脚写 1 时实际是关断内部下拉,把电平交给外部上拉电阻决定。也就是说 SDA 能否被拉高,依赖外部电阻而不是单片机内部驱动,这也是为什么 Proteus 仿真电路里漏画上拉电阻时,I2C 会表现出“奇奇怪怪”的行为:SDA 一直被拉低,起始位放不出来,发送任何字节后收到的一律是 NACK。
2.3 起始停止条件与字节收发实现
I2C 协议里,起始和停止条件定义的是 SCL 高电平期间 SDA 的跳变沿,而不是电平本身。这段代码把两个条件分别实现,并且每次执行完都主动把 SCL 拉低:
void i2c_start(void) { I2C_SDA = 1; I2C_SCL = 1; i2c_delay(); I2C_SDA = 0; // SCL=1 时 SDA 由 1 变 0,起始条件 i2c_delay(); I2C_SCL = 0; // 拉低时钟,准备发送数据 i2c_delay(); } void i2c_stop(void) { I2C_SDA = 0; I2C_SCL = 1; i2c_delay(); I2C_SDA = 1; // SCL=1 时 SDA 由 0 变 1,停止条件 i2c_delay(); }为什么每次结束后要把 SCL 拉低?因为后续发送数据时,所有位变化都要求 SCL 处于低电平。如果让 SCL 保持高电平,SDA 上的任何跳变都会被识别成新的起始或停止条件,整帧数据直接作废。这是软件模拟 I2C 中最常见也最难查的隐性错误。
字节发送和接收函数:
// 返回 0 表示收到 ACK,返回 1 表示收到 NACK unsigned char i2c_send_byte(unsigned char dat) { unsigned char i, ack; for (i = 0; i < 8; i++) { I2C_SDA = (dat & 0x80) ? 1 : 0; // 高位先出 dat <<= 1; i2c_delay(); I2C_SCL = 1; // 从机在 SCL 高电平时采样 SDA i2c_delay(); I2C_SCL = 0; i2c_delay(); } I2C_SDA = 1; // 释放数据线,准备接收 ACK i2c_delay(); I2C_SCL = 1; i2c_delay(); ack = I2C_SDA; // 从机回低电平表示应答 I2C_SCL = 0; i2c_delay(); return ack; } unsigned char i2c_recv_byte(unsigned char ack_flag) { unsigned char i, dat = 0; I2C_SDA = 1; // 主机释放总线,进入输入状态 for (i = 0; i < 8; i++) { dat <<= 1; I2C_SCL = 1; i2c_delay(); if (I2C_SDA) dat |= 0x01; // 读取当前位 I2C_SCL = 0; i2c_delay(); } I2C_SDA = ack_flag ? 1 : 0; // ack_flag=0 回 ACK,=1 回 NACK i2c_delay(); I2C_SCL = 1; i2c_delay(); I2C_SCL = 0; i2c_delay(); I2C_SDA = 1; return dat; }ack_flag的语义需要记清楚:主机读最后一个字节前必须回 NACK,否则从机会认为还要继续发送,把 SDA 继续拉低,导致下一字节数据错位。很多初学者固定传 0,跑顺序读时会多出 8 个时钟周期,读出来的数组整体往后移了一个字节。ACK 位是在第 9 个时钟里采样的,发送方要先把 SDA 释放掉,这一点在 51 上尤其重要,因为准双向口不会自动切换方向,必须手动写 1。
3. 字节地址、页写边界与 tWR 等待,读写函数不能再裸奔
3.1 器件控制字节和存储地址的对应关系
访问 24C02 时第一个发送的字节叫器件地址字,格式是 1 0 1 0 A2 A1 A0 R/W。Proteus 仿真图里 A0/A1/A2 都接地,所以写方向地址是 0xA0,读方向是 0xA1。如果板子上这三个引脚有接 VCC 的,地址要相应改变,这也是“把别人的程序抄过来后读不出数据”的高频原因——地址引脚配置不一样。
写入一个字节的完整帧是:起始 → 0xA0 → 内部地址(0x00~0xFF)→ 数据 → 停止 → 等待 tWR。随机读取一个字节则是:起始 → 0xA0 → 内部地址 → 停止 → 起始 → 0xA1 → 读一字节 → 主机回 NACK → 停止。这里先做一次“伪写”只是为了把芯片内部的地址指针指到目标位置,真实要发的内容只在后面的读帧里出现。
| 操作类型 | 第一个字节 | 第二个字节 | 后续内容 | 结束 |
|---|---|---|---|---|
| 单字节写 | 0xA0 | 目标地址 | 1 字节数据 | 停止 |
| 页写 | 0xA0 | 页内起始地址 | 最多 8 字节数据 | 停止 |
| 随机读取 | 0xA0 伪写 | 目标地址 | 停止后重新起始 | 0xA1 + 读数据 |
| 当前地址读 | 0xA1 | 读数据 | 主机回 NACK | 停止 |
“当前地址读”和“随机读取”的差别值得利用:如果刚写完某个地址,下一次读取同一个地址时,芯片内部的地址指针仍然停留在写操作结束的位置,直接发 0xA1 就能读出来,省去一次伪写。在多字节参数连续读写的场合,这个技巧能减少总线占用。
3.2 单字节写、随机读取与写周期等待
代码实现如下,其中delay_ms是工程里已有的毫秒级延时函数:
void at24c02_write_byte(unsigned char addr, unsigned char dat) { i2c_start(); i2c_send_byte(0xA0); // 器件地址,写方向 i2c_send_byte(addr); // 目标存储地址 i2c_send_byte(dat); // 要写入的数据 i2c_stop(); delay_ms(10); // 等待内部写周期结束 } unsigned char at24c02_read_byte(unsigned char addr) { unsigned char dat; i2c_start(); i2c_send_byte(0xA0); // 伪写:只设置内部地址指针 i2c_send_byte(addr); i2c_stop(); i2c_start(); i2c_send_byte(0xA1); // 切换为读方向 dat = i2c_recv_byte(1); // 单字节读必须回 NACK i2c_stop(); return dat; }写后固定延时 10ms 是保证不出错的做法,但不高效。更专业的处理是 ACK 轮询:24C02 在内部写周期结束前,对任何器件地址都不会应答,所以主机可以不断发送“0xA0 + 停止”,直到收到 ACK 再继续执行下一步。
void at24c02_wait_ready(void) { unsigned char ack = 1; do { i2c_start(); ack = i2c_send_byte(0xA0); // 只发器件地址,不发数据 i2c_stop(); } while (ack != 0); }轮询方式把等待时间从固定 10ms 缩短到芯片实际写完的那一刻,连续写入多条参数时,累计节省的时间非常可观,而且在 Proteus 里也能真实反映 24C02 的忙碌状态。
提示:
at24c02_wait_ready里要加一个超时计数器。如果总线上根本没有 24C02 或者地址错误,这个循环会一直执行下去,程序看起来像死机。加了超时后,至少能在调试串口里报出“EEPROM 无应答”的位置。
3.3 页写不是“随便写几个字节”,它有一条 8 字节边界
24C02 把 256 字节分成 16 页,每页 8 字节。页写允许在一个写周期内连续写入最多 8 字节,但内部地址计数器在页内递增,一旦跨过本页最后一位就会回卷到页首,而不是滚到下一页。例如从 0x06 开始写 4 字节,实际落点是 0x06、0x07、0x00、0x01,后两字节会把页首数据覆盖掉。
| 页写起始地址 | 写入字节数 | 实际落点 |
|---|---|---|
| 0x06 | 4 | 0x06、0x07、0x00、0x01 |
| 0x04 | 6 | 0x04~0x07、0x00、0x01 |
| 0x00 | 8 | 0x00~0x07 |
| 0x38 | 5 | 0x38~0x3B、0x00 回卷会被拒绝的部分实现 |
最后一行的表现取决于厂商具体实现,部分芯片会拒绝超出页边界的写入,部分会静默回卷。所以写函数不能只做一个“连续 send 字节”的循环,调用前必须自己拆分边界:
unsigned char page_left = 8 - (addr & 0x07); // 当前页剩余空间 unsigned char chunk = (len > page_left) ? page_left : len; at24c02_write_page(addr, buf, chunk); // 先写本页剩余部分 if (len > chunk) { at24c02_write_page(addr + chunk, buf + chunk, len - chunk); }(addr & 0x07)取的是低 3 位,恰好是页内偏移。把这段拆分逻辑统一收口在一个入口函数里,调用方只关心数据长度,不必每次手工算边界。换用 25C02 这类 SPI 接口 EEPROM 时,页长变成 16 字节甚至 32 字节,但“边界拆分”的套路完全一样,很多人在换芯片后才发现原函数在页边界处丢数据,就是因为只改了器件地址宏而没改页长参数。
4. Proteus 仿真图、Keil 编译链路与实际故障排查
4.1 先认清 24C02 在仿真图里的引脚连接
压缩包里的 Proteus 仿真部分直接打开就能看,不需要重新画一遍。如果自己搭建,最典型的最小系统是:AT89C51、24C02、两个 4.7k 上拉电阻、12MHz 晶振、两个 30pF 电容和复位电路。SCL、SDA 不是直接接到单片机就能工作,两根线都必须有上拉电阻到 VCC。4.7k 对 100kHz 总线是安全取值,10k 也能工作但边沿更缓,如果后续要跑 400kHz,最好换 2.2k 或 1k。
另外文件清单里的24c02.plg是 Keil 编译日志,24c02.M51是链接器生成的存储映射文件,24c02.LST是汇编列表,24c02.OBJ和24c02.hex都是构建产物,真正要改的只有24c02.c,其他都可以视为可再生成的中间文件。
| 24C02 引脚 | 连接网络 | 说明 |
|---|---|---|
| 1 A0 | GND | 地址引脚 0 |
| 2 A1 | GND | 地址引脚 1 |
| 3 A2 | GND | 地址引脚 2 |
| 5 SDA | P3.7 + 4.7k 上拉 | 数据线 |
| 6 SCL | P3.6 + 4.7k 上拉 | 时钟线 |
| 7 WP | GND | 写保护关闭 |
| 8 VCC | +5V | 供电 |
WP 引脚是新手容易踩的坑。WP 接 VCC 后,整个芯片只读不写,写帧照样 ACK,但数据进不了存储单元,读回全是旧值。做带写保护的设备时,WP 应该由单片机 IO 口控制,用它区分“正常保存”和“固化参数”两种场景。仿真阶段直接接地最简单,别让 WP 悬空,悬空在某些模型里会被读成高电平。
4.2 Keil C51 编译与 HEX 加载链路
用 Keil μVision 打开24c02.Uv2,在 Options for Target 的 Output 选项卡里勾选 “Create HEX File”,按 F7 编译通过后,Proteus 才能拿到最新的机器码。接着在 Proteus 中双击 AT89C51 元件,在 Program File 路径里选择生成的24c02.hex,Crystal Frequency 填 12MHz,与 Keil 里的频率保持一致。频率不一致不会导致 I2C 完全失败,但会让软件延时和串口波特率一起跑偏,排查时容易误判成时序问题。
命令行下确认 hex 内容时,可以用 srec_cat 转成二进制再检查:
srec_cat 24c02.hex -Intel -o dump.bin -Binary xxd dump.bin | head能看到代码里确实存在 0xA0、0xA1 这样的设备地址常量。这个检查在“Proteus 里完全没波形”时很管用,先确认程序真的烧进去了,再回头查仿真电路,否则容易在电路上浪费时间。
4.3 仿真异常的三类现象与排查顺序
Proteus 跑起来后可以把问题分成几类,排查顺序从硬件到软件:
| 现象 | 最常见原因 | 排查步骤 |
|---|---|---|
| SDA/SCL 一直是高电平,无波形 | 上拉电阻缺失或阻值过大 | 加 4.7k 上拉到 VCC |
| ACK 始终为 1 | 器件地址错误 | 检查 A0/A1/A2 接线和发送字节 |
| 写后读回 0xFF | 未等待 tWR 或 WP 接了高 | 加延时并确认 WP=GND |
| 写入顺序错乱 | 页写跨越页边界 | 按低 3 位拆分写入 |
| 仿真一运行就暂停 | 单片机 hex 路径失效 | 重新加载 24c02.hex |
P1 口接一个 LED 做运行指示最直观,写成功亮,写失败灭。调试这类 I2C 问题不要一上来怀疑时序分析,先用示波器挂在 SCL、SDA 上看有没有时钟输出。没有时钟输出时,问题几乎都在单片机侧:程序没进 main、晶振频率设置错误、或者 hex 根本没加载进去。时钟有输出但数据不对时,再去抓 ACK 和页边界问题,效率会高得多。
5. 用虚拟逻辑分析仪和循环自检,验证 24C02 真的写进去
5.1 Proteus 中挂接虚拟逻辑分析仪
Proteus 左侧工具栏选择 Virtual Instruments,放入 Logic Analyser。把 D0 接到 P3.6 也就是 SCL,D1 接到 P3.7 也就是 SDA,运行仿真后就能同时看到两根线的波形。判断起始位的方法很简单:SCL 高电平期间 SDA 由高变低,停止位反过来。再量一下 SCL 高电平宽度,如果明显小于 4µs,说明i2c_delay的初值偏小,在标准模式下 24C02 可能采不到稳定数据。如果波形里 SCL 低电平时间几乎为 0,那是拉高后立刻拉低导致的,数据位还没稳定就被采样,需要把循环里两句 SCL 操作之间至少保留一次i2c_delay()。
5.2 一段可重复验证的页写对比测试
把下面这段放进 main 循环,跑起来后 LED 能长期指示读写状态。P1.0 定义为 LED,正常时低电平点亮,出错时熄灭。
#include <reg51.h> sbit LED = P1^0; unsigned char data test_data[8] = {0x12,0x34,0x56,0x78,0x9A,0xBC,0xDE,0xF0}; unsigned char read_buf[8]; unsigned char i, ok; void main(void) { while (1) { at24c02_write_page(0x10, test_data, 8); // 从 0x10 开始写一整页 delay_ms(10); for (i = 0; i < 8; i++) { read_buf[i] = at24c02_read_byte(0x10 + i); // 逐字节随机读回 } ok = 1; for (i = 0; i < 8; i++) { if (read_buf[i] != test_data[i]) { ok = 0; } } LED = ok ? 0 : 1; } }测试数据要放在可写的 RAM 里,不能用code段常量直接传给写函数。Keil C51 里 code 指针和 data 指针属于不同存储空间,直接把 ROM 数组传给unsigned char *形参会编译报错。更稳妥的做法是像上面这样声明普通数组并初始化,确保数据真的来自内存。这段循环跑满 500 次 LED 一直亮,才能说明读写路径稳定,中途只要灭一次,就在出错点前后加多个 LED 指示,定位是写失败还是读失败。
5.3 用 EEPROM 内容窗口做终点校验
Proteus 仿真暂停后,右键点击 24C02,在 Edit Properties 里打开 EEPROM 内容窗口,直接查看 0x10 到 0x17 地址的值,比对刚才写入的 8 字节。这个窗口里显示的是芯片内部逻辑状态,比 LED 闪烁更直观,能确认数据确实进入了存储单元,而不是只通过了 ACK 握手。如果把写周期时间改成 0,仿真会立刻完成写入,但真实芯片不会这么快,建议保留默认参数,让 tWR 等待逻辑得到验证。
这个测试函数建议扩展成带错误计数的统一校验模块:连续写 0x00 到 0xFF 全部 256 个字节,再从 0x00 开始完整读回逐字节比对,错误累计到阈值后点亮红色 LED,0 错误持续一段时间后再切换绿色 LED。把这一整套做成可复用函数,后续接密码表、设备参数和校准数据时直接调用,比每次临时手写读改写逻辑可靠得多。
本文还有配套的精品资源,点击获取