简介:面向嵌入式初学者的N76E003单片机I2C从机模拟AT24C02完整程序包,作者已实测通过。程序基于新唐N76E003内置I2C外设,完整实现与AT24C02兼容的从机应答、地址匹配、读写命令处理及256字节存储区模拟,覆盖初始化、从机地址设置、命令解析、数据存取、应答与错误处理等关键环节,适合需要理解I2C协议细节或希望用单片机替代外部EEPROM的开发者参考。压缩包共16个文件,以C源码、H头文件为主,另含STARTUP.A51启动文件、HEX烧录文件、Keil工程文件(uvproj/uvopt)及中间文件,整体仅56KB,目录结构清晰,便于直接编译与下载测试。目前已有1439人学习下载。通过该工程可快速掌握N76E003的I2C从机编程方法,获得可直接移植的模拟EEPROM代码与常见问题排错思路,对于嵌入式底层驱动开发实战具有很好的参考价值。
1. 为什么要让单片机冒充一颗EEPROM
把一颗不到两块钱的 8051 内核单片机 N76E003,调教成一颗“人见人爱”的 AT24C02,这件事听起来有点魔幻,但在实际项目里真的能救场。最近我做了一个 N76E003 模拟 AT24C02 的 I2C 从机程序,接在 STM32、树莓派这类主机上读写都正常,这篇把完整思路、协议细节、踩坑过程全部整理出来,给后面做同类工作的朋友省点时间。
可能有人会问:AT24C02 芯片零售也就几毛钱,直接焊一颗不就行了,何必用单片机去模拟?这个问题的答案恰恰是这个方案的核心价值。
1.1 这个需求是怎么来的
第一种常见场景是省料降本。消费电子、小家电方案里,如果主控芯片本身的 Flash 不够存参数,或者不想把参数区写在程序 Flash 里(因为擦写会影响代码区稳定性),一般会外挂一颗 I2C EEPROM。但如果板子上本来就有一颗单片机空闲着,哪怕只是用来做按键扫描、LED 控制,它的资源完全够再虚拟出一颗 EEPROM,这样就能省掉一颗物料。
第二种场景更实用:老平台上电时序不对、配置参数需要动态调整,但硬件设计已经定型,没有多余的 I2C 从机地址可用,或者上位机只认 AT24C02 的地址(0xA0/0xA1),此时让单片机去“冒充”反而比改主机代码更省事。主机侧完全感知不到区别,它以为自己就是在跟一颗标准 EEPROM 通信。
第三种是功能增强。利用模拟从机,可以在数据写入时做过滤、加密、备份,甚至让同一个 I2C 地址在不同逻辑区域映射到不同数据源。真实 EEPROM 做不到这些,单片机模拟却能轻松扩展。
1.2 N76E003 做这件事的优势
N76E003 是新唐推出的一款增强型 8051 内核单片机,TSSOP20 封装,工作电压 2.4V~5.5V,主频最高 16MHz,内置 18KB Flash、1KB SRAM,还有硬件 I2C、SPI、UART、定时器这些外设。
拿它来模拟 AT24C02 有几个天然优势:第一,它本身就能在 5V/3.3V 系统里工作,和大多数 I2C 总线电平兼容,不需要电平转换;第二,1KB RAM 装下 256 字节的 EEPROM 模拟缓冲区绰绰有余;第三,价格低、货源稳定,SOP20 封装手工焊接也方便,打样调试成本极低。
最关键的还是 I2C 从机对实时性要求高,而 N76E003 的 8051 内核在 16MHz 下执行指令速度足够快,用中断配合状态机处理 100kHz 甚至 400kHz 的 I2C 时序,完全没有压力。
1.3 这个方案适合谁
如果你正在做单片机 I2C 从机开发,或者手里有一批闲置的 N76E003,又或者项目里确实遇到“没有 EEPROM 但 I2C 主机非要读 EEPROM”的怪需求,那这篇就很适合你。它需要的基础是:能看懂 C 语言,了解 I2C 的基本概念(起始、停止、ACK、字节传输),上手很快。
2. AT24C02 从机协议,先要装得像
想用单片机模拟一颗 EEPROM,第一步不是写代码,而是把 AT24C02 的 I2C 协议行为吃透。模拟的核心不是物理层那几个时序波形,而是逻辑层的“记忆”:地址指针怎么变、页写怎么回绕、随机读怎么响应。主机端会用各种方式去访问一颗 EEPROM,你模拟得越像,兼容性就越好。
2.1 地址、寄存器与写操作规则
AT24C02 的容量是 2Kbit,也就是 256 字节。在 I2C 总线上,它的设备地址是 7 位地址 0x50(二进制 1010000),写操作用的 8 位地址是 0xA0(设备地址左移一位再补 0),读操作地址是 0xA1。
设备地址的低三位 A2、A1、A0 是硬件引脚决定的,画板子时一般接地,所以默认地址就是 0x50。如果同一个 I2C 总线上挂多颗 AT24C02,靠的就是把这几个引脚接高接低来错开地址。
写操作分两种:
- 字节写:起始 + 0xA0 + 字地址 + 1 字节数据 + 停止。
- 页写:起始 + 0xA0 + 字地址 + 最多 8 字节数据 + 停止。
这里有个特别容易被忽略的细节——AT24C02 的页大小是 8 字节。也就是说,一次页写最多只能连续写 8 个字节,超过 8 个字节后,后面写的数据会覆盖当前页开头的位置,而不是连续写到下一页。这是 EEPROM 硬件设计的典型限制,页写操作是为了配合内部页缓冲区的容量。模拟器如果没有处理这个“页回绕”,在跨页写数据时就会和真实芯片行为不一致,导致主机数据错乱。
2.2 读操作的三种方式与重复起始条件
读操作是模拟 EEPROM 的重点,也是最容易出问题的地方。主机访问 EEPROM 数据一般有三种方式:
- 当前地址读:直接发起始 + 0xA1,读取上一次操作完成后的内部地址指针指向的数据,读完地址指针自动加 1。
- 随机读:先发起始 + 0xA0 + 字地址,然后再发一个重复起始条件(Sr)+ 0xA1,从刚才指定的字地址开始读。
- 顺序读:在随机读或当前地址读的基础上,主机继续发时钟而不发停止,从机就会连续输出数据,地址指针持续自增,直到主机在第 9 个时钟周期发送 NACK 并发出停止条件。
随机读里的重复起始条件,是从机模拟器必须处理的特殊时序。很多初学者在 GPIO 模拟 I2C 从机时,只检测了起始和停止,把重复起始误判成“新的一帧数据传输”,结果随机读出来的第一个字节永远不对。
2.3 页写回绕:模拟器最容易翻车的地方
页写回绕的具体行为是这样的:如果写入页内偏移是 5,主机连续写了 5 个字节,那么地址会从页内偏移 5 走到偏移 1(5+5=10,超过 7 后回绕到 0、1),而不是沿着 5、6、7、8、9 继续跨页写入。
模拟 EEPROM 时,我建议干脆按真实芯片的行为去实现,而不是“好心”做跨页连续写。因为很多主机的驱动和上层逻辑就是按照真实 EEPROM 的规格书编写的,你在模拟器上改了这个行为,短期内可能感觉更“好用”,但一旦换回真实芯片,同样的代码就会出问题。模拟器最重要的原则,就是尽量还原真实器件的每一个行为细节。
3. N76E003 从机端整体架构
明确了协议行为,接下来就是架构选型。N76E003 自带硬件 I2C 外设,也支持 GPIO 模拟 I2C。这个方案我用硬件 I2C 试过,也全部换成 GPIO 模拟重写过,实际对比下来的结论很明确:从机模拟场景,GPIO 模拟方案更可控,调试也更方便。
3.1 硬件 I2C 外设还是 GPIO 模拟
N76E003 的硬件 I2C 支持主机和从机模式,从机模式也有地址匹配中断。听起来很美好,但实际做 EEPROM 模拟时,我从机模式遇到几个难题:
硬件从机的状态机是芯片厂家设计好的,它会在字节接收完成后自动处理 ACK,也能在地址匹配时产生中断,但问题是 EEPROM 模拟需要维护一个“内部地址指针”,并且要区分当前帧是写模式还是读模式。硬件 I2C 外设的中断时机和寄存器状态有些情况下没法直观反映协议层面的状态,尤其处理重复起始条件时,需要同时维护 SI、STA、STO 这些标志位,逻辑复杂而且可读性差。最头疼的是,当模拟 EEPROM 要在中断里判断“现在是读第几个字节,要不要继续发数据”时,硬件外设的中断时机并不总能保证读数据时 SDA 转换及时,稍有偏差就丢 ACK 或产生错误数据。
GPIO 模拟方案则完全掌握主动权。SDA 和 SCL 都用普通 GPIO 配置成开漏模式,配合外部上拉电阻,再用边沿中断检测 SCL 和 SDA 的变化,自己维护一切状态。实测下来,GPIO 模拟的代码逻辑更直观,出问题也容易通过逻辑分析仪定位。
3.2 引脚与电气配置
N76E003 的 P1.3 用作 SDA,P1.4 用作 SCL。这两个引脚配置成准双向模式(开漏输出),外部各接一颗 4.7kΩ 上拉电阻到 VCC。I2C 协议本身就要求 SDA 和 SCL 是开漏结构,这样主机和从机才能通过拉低总线来通信,任何一端都不能强行推挽输出高电平。
初始化时,两个引脚都输出高电平,让总线处于空闲状态。引脚模式配置如下:
P1M1 &= ~0x18; // P1.3、P1.4 清零 P1M2 &= ~0x18; // P1M1/P1M2 = 0,设置为准双向模式 SDA = 1; SCL = 1;需要注意的是,N76E003 的准双向模式内部有弱上拉,但 I2C 总线建议再外接上拉电阻,尤其是总线速度快、挂载设备多的情况下,内部弱上拉驱动能力不足会导致上升沿变缓,影响时序。
3.3 从机状态机设计
我采用的方案是用 P1 中断(两个引脚共用)配合主循环超时看门狗,核心逻辑全部在中断服务程序里完成。中断服务程序要处理的信号变化包括:
- SCL 上升沿:数据线 SDA 电平被主机采样,从机要把当前位的值读进来。
- SCL 下降沿:主机释放 SDA,从机可以切换 SDA 方向。
- SDA 下降沿且 SCL 为高:检测到起始条件。
- SDA 上升沿且 SCL 为高:检测到停止条件。
- 第 9 个时钟周期:生成 ACK 或读取主机的 ACK/NACK。
状态机只需要记住几个关键变量:当前状态(等待设备地址/等待字地址/等待数据/发送数据)、内部地址指针 addr、上次访问的地址 lastAddr、当前已接收的位数 bitCount。核心循环如下:
- 起始后进入 SLAVE_ADDR 状态,接收 8 位,判断设备地址是否为 0x50。
- 地址匹配后,根据第 8 位(R/W 位)决定是写模式还是读模式。
- 写模式:先收字地址,再收数据,写入缓冲区,按页回绕规则更新地址指针。
- 读模式:从地址指针指向的位置发送数据,每发完一字节根据主机的 ACK/NACK 决定继续还是结束。
整个状态机最关键的一点:遇到重复起始条件,必须重置到 SLAVE_ADDR 状态,但保留 lastAddr 指针供随机读使用。实现上,检测到 SDA 下降沿且 SCL 为高时,如果当前状态不是 IDLE,就先把状态重置到 SLAVE_ADDR,而不是当成错误丢弃。这样随机读第一次发地址、第二次发读命令,逻辑上就是连续两次设备地址帧,自然就能正确处理。
4. 关键代码实现与解释
下面把核心代码拆开讲。代码用 N76E003 的标准 C 语言编写,编译环境是 Keil C51,单片机主频 16MHz。
4.1 GPIO 初始化与中断配置
// 引脚定义 #define SDA P13 #define SCL P14 void I2C_Slave_Init(void) { P1M1 &= ~0x18; P1M2 &= ~0x18; SDA = 1; SCL = 1; PIF = 0; // 清除 P1 中断标志 P1IEN |= 0x18; // 使能 P1.3、P1.4 边沿中断 set_EPI; // 使能 P1 中断 EA = 1; }P1 中断是 N76E003 所有 P1 引脚共用的,触发后需要读取 PIF 寄存器判断具体哪个引脚发生变化,然后分别处理。SDA 和 SCL 都在这个中断向量里,所以中断服务程序第一件事就是记录引脚状态快照,避免后续读引脚时电平已经变化。
4.2 起始/停止/重复起始检测
void PINT_ISR(void) interrupt 12 { uint8_t pif = PIF; PIF = 0; // 清标志,防止重复进入 if (pif & 0x10) { // SCL 变化 if (SCL && !lastSCL) { // 上升沿:采样 SDA bitVal = SDA; process_bit(bitVal); } lastSCL = SCL; } if (pif & 0x08) { // SDA 变化 if (SCL) { if (lastSDA && !SDA) { // 下降沿且 SCL 高 = 起始/重复起始 handle_start(); } else if (!lastSDA && SDA) { // 上升沿且 SCL 高 = 停止 handle_stop(); } } lastSDA = SDA; } }这里有个细节:检测到起始条件或停止条件时,SDA 的电平变化和 SCL 的电平变化几乎同时发生,但不完全同步。我实测时发现有时候中断标志里只有 SDA 变化,SCL 没有变化,所以必须在 SDA 变化分支里加 SCL==1 的判断来区分起始/停止和数据位变化。
重复起始条件的处理就在 handle_start() 里:如果当前状态已经接收过设备地址,说明这是随机读的重复起始,此时重置 bitCount=0,状态回到 SLAVE_ADDR,但不清空 lastAddr。这个判断是整个随机读实现的关键。
4.3 地址接收与 ACK 控制
void process_bit(uint8_t bitVal) { bitCount++; rxBuffer = (rxBuffer << 1) | bitVal; if (bitCount == 8) { if (state == STATE_SLAVE_ADDR) { // 判断设备地址 devAddr = rxBuffer >> 1; isRead = rxBuffer & 0x01; if (devAddr == 0x50) { ack_slave(); // 地址匹配,拉低 SDA 给 ACK if (isRead) { state = STATE_READ_DATA; sdaOutput = 1; // 释放 SDA,准备发送数据 // 加载当前地址指针 addr = lastAddr; } else { state = STATE_WORD_ADDR; } } else { nack_slave(); // 地址不匹配,释放 SDA state = STATE_IDLE; } } else if (state == STATE_WORD_ADDR) { addr = rxBuffer; // 写入的字地址 lastAddr = addr; ack_slave(); state = STATE_WRITE_DATA; } else if (state == STATE_WRITE_DATA) { write_byte(addr, rxBuffer); ack_slave(); // 更新地址指针,页回绕 if ((addr & 0x07) == 0x07) { addr &= 0xF8; // 回绕到页首 } else { addr++; } lastAddr = addr; } bitCount = 0; } }ACK 生成的时机要卡在第 9 个时钟的高电平期间。N76E003 的准双向模式下,往引脚写 0 就是拉低,写 1 就是释放。我的实现里,第 8 个数据位接收完成后,立即把 SDA 拉低(写 0),第 9 个时钟高电平时,主机检测到 SDA 为低,即收到 ACK。等第 9 个时钟下降沿到来时,把 SDA 重新释放(写 1),准备好下一个字节。
这里踩过的坑是:如果第 9 个时钟下降沿还没到就把 SDA 释放,主机在 ACK 采样点会读到高电平,判断为 NACK,通信直接中断。所以 ACK 释放的时机必须放在第 9 个时钟的下降沿中断里做,不能用简单延时代替。
4.4 读数据时的 ACK/NACK 检测
读取模式下,从机是数据发送方,主机是接收方。第 8 个数据位发送完后,从机需要释放 SDA,让主机拉低表示 ACK 或保持高表示 NACK。代码如下:
// 读取模式下,第 8 位已经发完 if (state == STATE_READ_DATA) { // 在 SCL 下降沿释放 SDA,等主机在第 9 个时钟给出 ACK/NACK if (sdaOutput) { SDA = 1; // 释放 SDA } // 等第 9 个时钟下降沿时读取 SDA if (bitCount == 9) { if (SDA == 0) { // 主机 ACK,继续发送下一个字节 addr++; if (addr > 0xFF) addr = 0; lastAddr = addr; sdaOutput = 1; } else { // 主机 NACK,意味着读操作结束 state = STATE_IDLE; } bitCount = 0; } }顺序读的核心就是主机每发一个 ACK,从机就继续发下一个地址的数据,地址指针自增。当主机不需要更多数据时,会在最后一个字节的第 9 个时钟不发 ACK 而是释放 SDA,从机检测到 NACK 后,就把状态机复位到 IDLE,等待下一个起始条件。
读模式下位计数要在不同的时刻采不同的逻辑:第 1~8 位是从机往 SDA 上放数据,主机采样;第 9 位是主机往 SDA 上放 ACK,从机采样。所以我的中断服务程序里根据 bitCount 的值区分当前是数据位还是 ACK 位,处理方式不同。
4.5 总线超时保护
I2C 从机最怕总线异常导致状态机卡死。比如主机在传输过程中突然复位,停止条件永远不会来,从机如果一直停在“等待数据”状态,总线上的其他通信也会受影响。
我加了一个 10ms 的定时器看门狗,每次中断服务程序检测到起始/停止/数据位变化时都会刷新定时器。如果 10ms 内没有任何总线活动,就强制复位状态机、清空地址指针、释放 SDA,让总线恢复到空闲状态。这个超时时间要大于主机最长的单字节传输时间,又不能太短导致正常慢速传输被误杀。标准模式下 100kHz 波特率,一个字节加 ACK 大约 90us,10ms 已经留了很大余量。
5. 实测结果和踩坑记录
项目标注“已测试”,这里把真实的测试过程和踩坑记录分享出来。测试环境用 STM32F407 的 GPIO 模拟 I2C 主机,逻辑分析仪抓取总线波形,再用 USB 转 I2C 工具做辅助验证,整个测试持续了三天,中间抓出不少隐蔽问题。
5.1 逻辑分析仪实测波形验证
测试的第一步是验证基础读写。用主机的字节写操作,往地址 0x00 写入 0x55 和 0xAA,然后用当前地址读和随机读两种方式读回来,逻辑分析仪抓到的波形和真实 AT24C02 几乎完全一致:设备地址 ACK 正常、数据字节 ACK 正常、随机读的重复起始条件正确处理、最后的 NACK 和停止条件时序正确。
第二步测试页写回绕。向地址 0x05 连续写 5 字节,按 AT24C02 的规则,写入顺序应该是 0x05、0x06、0x07、0x00、0x01。我用逻辑分析仪抓取 SDA 上的数据,确认回绕行为精确匹配。这一步验证了模拟器和真实 AT24C02 在该细节上完全一致。
第三步测试顺序读。主机连续读 10 字节,从机依次输出 lastAddr 开始、地址自增的 10 个字节,最后一次主机回 NACK 后从机正确停止发送。
5.2 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 从机完全没有 ACK 响应 | SDA/SCL 引脚模式配置错误,或者外部上拉电阻缺失 | 确认准双向模式、外接 4.7kΩ 上拉,用示波器看 SCL/SDA 是否正常拉低 |
| 设备地址能匹配,但写入后读出来全 0xFF | 数据写在缓冲区但地址指针没有更新,或页回绕逻辑错误 | 检查 process_bit 中 addr 更新逻辑,打印内部缓冲区确认写入位置 |
| 随机读第一个字节正确,后续字节错乱 | 重复起始条件没有重置状态机,导致第二次设备地址未被识别 | 在 handle_start 中把状态重置到 SLAVE_ADDR,但保留 lastAddr |
| 顺序读多出一个字节 | ACK/NACK 检测时机太早,把主机的 NACK 误判为 ACK | 确保第 9 个时钟下降沿再采样 SDA,不要在上升沿采样 |
| 总线偶尔锁死,主机一直等不到 ACK | 异常时序导致状态机卡在中间状态 | 增加 10ms 总线超时看门狗,超时强制复位状态机 |
| 拔出主机 USB 转 I2C 工具后,重新插入无法通信 | 上电时 SDA/SCL 电平不确定,从机进入了错误状态 | 代码初始化阶段等待 SCL/SDA 稳定 100ms,再使能中断 |
5.3 这个方案还能怎么扩展
EEPROM 模拟器最直接的扩展是换容量。把协议里的一字节字地址改成两字节,页大小从 8 字节改成 16 字节,就能模拟 AT24C64、AT24C128 这些大容量 EEPROM。改动并不大,但必须同步修改页回绕的掩码:AT24C64 是 16 字节一页,回绕判定要从(addr & 0x0F) == 0x0F判断。
如果想让模拟的数据掉电不丢,可以把 N76E003 的 18KB DataFlash 利用起来。每次写入数据时,先在 RAM 缓冲区里更新,再在空闲时把整个缓冲区备份到 DataFlash。注意 DataFlash 擦写次数有限制,通常几万到十万次,这种方案只适合写入频率不高的场景。
还能做多地址映射。用 N76E003 的 GPIO 读外部跳线状态,决定从机响应 0x50~0x57 中的哪个地址,相当于一颗芯片模拟多颗 EEPROM。这个功能在做 I2C 总线地址调试、产品快速改版时特别有用,不用改硬件就能调整地址分配。
写保护功能也可以做。把 AT24C02 的 WP 引脚用一个 GPIO 模拟,高电平时拒绝写入,只允许读取,和真实芯片的 WP 行为完全一致。这个细节虽然小,但能提升模拟器的完成度,也是我在做完基础功能后顺手加的。
最后提醒一句:EEPROM 模拟器毕竟是软件实现,对中断响应时间比较敏感,如果工程里还有其他高优先级中断必须合理设置优先级。我实测时发现,如果 I2C 中断被其他中断阻塞超过一个位周期(100kHz 下是 10us),就会丢数据。所以除了 I2C 中断,其他中断程序尽量保持精简,必要时可以暂时关闭其他中断响应,确保 I2C 时序不被打断。
本文还有配套的精品资源,点击获取