☰
用N76E003模拟AT24C02:I2C从机状态机与掉电数据保存
2026/10/16 4:42:20 网站建设 项目流程

简介:一套基于N76E003单片机的I2C从机模拟AT24C02程序,代码已通过实际测试,适合嵌入式开发者、电子竞赛学生以及需要低成本非易失数据存储方案的项目参考。程序覆盖I2C引脚与时钟初始化、0xA0从机地址匹配、读写命令解析、256字节存储区模拟、ACK应答时序及常见错误处理,可直接移植或用于学习协议细节。包内共16个文件,包含5个H头文件、3个C源文件,以及启动文件、HEX烧录文件、Keil工程文件等,整体仅56KB,结构紧凑,目录划分清晰,便于按模块阅读。目前已有1441人学习下载,说明该方案在I2C从机与EEPROM模拟实践中具有一定参考价值。借助源码可以掌握SCL/SDA时序控制、主从应答机制、存储映射方法以及模拟外设的调试思路,为后续类似外设模拟开发提供可复用的模板,也能对照真实AT24C02芯片数据手册排查I2C通信中的读写异常。

1. 模拟AT24C02的本质:把N76E003变成一颗“不丢数据”的EEPROM

做某个跨平台系统的显示面板时,主板上本来有一颗AT24C02负责存亮度、音量、校准参数,结果量产阶段EEPROM缺货,临时换料又得改硬件改驱动。我改用N76E003单片机自带的硬件I2C从机模式,让它直接“扮演”AT24C02:主机端一行驱动不用改,扫描地址还是0x50,读写的存储空间还是256字节,实测跑通后整条产线的备料问题直接消失。这篇文章就把这套已测试的模拟方案拆开讲:I2C从机地址怎么对齐、内置外设怎么初始化、状态机怎么写,最后整理几个我实测中踩过的坑。适合正在做I2C从机通信、或者想省掉一颗EEPROM的开发者。

2. 先盯时序再写代码:AT24C02的寄存器模型与读写协议

2.1 从机地址到底是谁在决定:0xA0/0xA1与N76E003的地址匹配逻辑

AT24C02是2Kbit串行EEPROM,内部按256字节组织,I2C的7位从机地址是0x50,加方向位之后,写地址就是0xA0、读地址是0xA1。A2、A1、A0三个引脚决定7位地址的低3位,贴片封装里基本都接GND,所以地址固定成0x50。这里有个特别容易翻车的地方:N76E003的I2CADDR寄存器保存的是“7位地址左移后”的值。也就是说,主机在总线上发0xA0(8位写地址),你要让I2CADDR等于0xA0,而不是0x50。寄存器bit0是广播呼叫总使能位,bit7:1才是真正参与地址比对的部分,硬件拿这7位去和总线上地址字节的高7位匹配。

我第一次把这个模拟程序烧进板子时,I2CADDR填的是0x50,结果逻辑分析仪上能清楚看到主机发0xA0,从机却一直不回应,总线挂着不动,最后看门狗复位。后来把这一位想明白,发现一点都不玄学:I2CADDR = 7位从机地址 << 1;要启用广播呼叫时,再把bit0补上1。这个错误之所以隐蔽,是很多人拿AT24C02数据手册里的“Device Address”直接填进寄存器,忽略了两者的位宽定义差异。下面是A2/A1/A0与地址字节的对应关系,烧录前可以自己对一遍。

A2A1A07位从机地址8位写首字节8位读首字节
0000x500xA00xA1
0010x510xA20xA3
0100x520xA40xA5
0110x530xA60xA7
1000x540xA80xA9

如果你用软件模拟I2C,从机地址匹配完全靠中断里手动比较,那个7位对齐的规则反而看得清楚。N76E003的硬件I2C把地址匹配做进外设里,省事的同时也把问题藏起来了,配置错了只能靠示波器或逻辑分析仪才能发现。我现在的习惯是每次新板子,先写一个最小的从机初始化程序,主机侧不停发地址扫描,从机这边把收到0xA0的LED点亮,确认物理链路和地址匹配都正常,再往上叠状态机。

2.2 三种读操作的差异:当前地址读、随机读、顺序读

AT24C02的读操作分三种:当前地址读、随机读、顺序读。当前地址读最简单,芯片内部维护一个地址指针,上电后默认是0x00,写完数据后指针会停在“最后访问地址+1”的位置,主机直接发SLA+R,就能从这个位置读出数据。随机读要分两步:先向从机写入目标字地址,然后发重复起始(REPEATED START),方向位从写切到读,再连续拉数据。顺序读本质上是“读操作重复执行”,每读一个字节主机回ACK,从机内部地址自动加1,地址到0xFF之后回卷到0x00。

模拟EEPROM时,最容易露馅的就是随机读和顺序读的衔接。很多人把这两个功能分开写,结果漏了对重复起始状态的判断,状态机卡在“等写地址”那一帧,总线直接锁死。我一般把I2C从机状态机拆成四类状态:空闲、等字地址、收数据、发数据。收到SLA+W之后进入“等字地址”;等字地址状态下,再收到数据字节就把这个字节当字地址存起来;之后收到的字节才真正写入存储区。收到SLA+R则进入“发数据”,地址沿用刚才保存的字地址,每发一字节加1,加满256自动回卷。这样不管主机用哪种读方式,状态机都只有一条路径,不会因为读模式不同而写出多套分支。

还要注意AT24C02的页写行为。真实芯片的页大小是8字节,页内写地址会自动回卷,不会跨页。比如写了0x03这个地址连续写5字节,实际写到0x03、0x04、0x05、0x06、0x07,再往下就回卷到0x00,把页首覆盖掉。模拟时有两种路线:严格复现这个回卷,或者把整块256字节当成一个“大页”处理。我倾向于后者,因为上位机驱动通常都是按固定偏移写,极少依赖页回卷特性;如果哪天遇到较真的驱动,再补一段页边界判断也不难。

2.3 存储映射的选择:RAM数组、片内Flash还是两级方案

模拟AT24C02,数据到底放哪,是个要提前决定的问题。最省事的办法是定义一块256字节的RAM数组,I2C读写直接命中数组,速度最快、逻辑最简单,但掉电数据全丢。很多应用场景其实只要求“运行期间保存配置”,主机每次上电都会主动把参数下发一遍,这种就完全够用。

要掉电保存,就得用N76E003的片内Flash。这颗芯片支持IAP自编程,可以拿一部分空间模拟数据存储。但这里有个代价:Flash擦写要时间,而且擦写期间CPU会停止取指令,如果在I2C中断里做Flash操作,SCL被从机拉低的时间会超过主机允许的等待上限,轻则超时,重则整个I2C总线卡死。所以我不建议直接在中断里擦Flash,更稳的做法是两级方案:RAM做主缓冲区,所有I2C读写都落在RAM上,中断立刻返回;主循环里检测“脏页标记”,有空时把改动过的数据页搬进Flash。这样既保证I2C响应速度,又有掉电保存能力。

代码里先定义好这两块区域:

#define EEPROM_SIZE 256 #define EEPROM_PAGE 8 uint8_t eeprom_buf[EEPROM_SIZE]; uint8_t eeprom_dirty[EEPROM_SIZE / EEPROM_PAGE];

EEPROM_SIZE固定为256,对应AT24C02的256字节空间,不要随便改,除非你同时改主机端驱动里的长度定义。EEPROM_PAGE取8是沿用AT24C02页写的大小,每个字节对应RAM里8字节数据,脏标记置1表示这一页数据在RAM里被改过、还没同步进Flash。这里EEPROM_PAGE的取值不影响I2C实时读写,只影响后续Flash搬运的粒度,取值越小,搬运越频繁、单次耗时越短;取值越大,搬运次数少但单次更久。我一般取8,和真实EEPROM页对齐,出问题也好对照硬件行为。

3. N76E003内置I2C从机初始化:引脚配置与中断入口

3.1 引脚复用:P1.4/SCL、P1.5/SDA的开漏与上拉配置

N76E003的硬件I2C外设固定挂在P1.4(SCL)和P1.5(SDA)上,不需要额外切换引脚功能。这点比用普通IO模拟I2C要省心得多,起始、停止、应答都是硬件检测,我们只需要在中断里处理数据。第一步是把这两个引脚配成开漏输出,同时在板子上接上拉电阻。电阻阻值的选择和I2C总线频率有关:100kHz标准模式用4.7kΩ没问题,400kHz快速模式建议降到2.2kΩ左右,具体还要看总线上挂了多少设备、走线有多长。

端口模式是通过N76E003的P1M1/P1M2寄存器配置的,把bit4和bit5同时置1就切到开漏输出:

// P1.4 -> SCL, P1.5 -> SDA,开漏输出 P1M1 |= 0x30; P1M2 |= 0x30;

P1M1和P1M2对应位都为1时端口是开漏模式。开漏的好处很直接:输出高电平时只靠外部上拉电阻拉高,这是I2C协议的基本要求,也避免两个设备同时驱动SDA时出现一个拉低一个拉高的冲突。如果板子已经焊了上拉电阻,用准双向模式也能跑,但开漏是最规范的选择。引脚功能上还要注意一点:N76E003的引脚复用比一般8051多一些,P1.4/P1.5还可能是其它外设的通道,如果工程里同时开了UART或定时器捕捉功能,要检查是否把引脚抢走了。我习惯在初始化早期就把I2C引脚模式设好,并且把用不到的外设中断都关掉。

3.2 I2C外设寄存器初始化:I2CON、I2CADDR与中断开关

从机模式的初始化代码很简短,但每一行的含义要清楚:

#define I2C_SLAVE_ADDR_7BIT 0x50 void i2c_slave_init(void) { // 7位地址0x50左移一位,bit0是广播呼叫控制,不启用 I2CADDR = (I2C_SLAVE_ADDR_7BIT << 1); // 先把控制寄存器清零,再逐位置位 I2CON = 0x00; // bit6 = I2CEN,使能I2C外设 I2CON |= 0x40; // bit2 = AA,应答位使能,地址匹配后自动回ACK I2CON |= 0x04; // bit3 = SI,串行中断标志,清掉可能残留的旧值 I2CON &= 0xF7; // 打开I2C中断与全局中断 EI2C = 1; EA = 1; }

这段代码的逻辑是:I2CADDR装的是7位地址左移后的值,我直接传入0x50让编译器左移,避免手算出错。接着把I2CON清零,保证复位后的未知状态不影响外设。然后置位I2CEN和AA,前者让I2C模块开始工作,后者让从机在地址匹配后自动回应ACK;如果AA是0,从机对自身地址不回应,总线上相当于没有这个设备。之后清一次SI标志,是为了把上电过程中可能因为引脚抖动产生的挂起中断清掉。最后打开EI2C和EA,两个中断开关缺一不可:只开EI2C不开EA,I2C中断永远不会进入,主机端表现为ACK之后无反应。

有个参数可以按项目需要调整:I2CADDR的低位bit0是广播呼叫使能位,默认0表示不响应广播地址0x00。如果主机那边有设备用广播呼叫同步时间,你就要把I2CADDR的bit0置1,让从机也参与广播。这里不置1是正常的,因为AT24C02本身也不响应广播呼叫,严格模拟就不该开。

3.3 从机中断入口与状态变量:不是所有中断都要处理

N76E003的I2C中断向量,在编译环境里对应interrupt 7。中断服务函数进入后,第一步要读I2CSTATUS状态寄存器,硬件已经把当前所在状态分类好了,我们只需按状态码分支处理。这里列一张常用的从机状态码表,写代码时对照着看:

状态码含义下一步动作
0x60收到自身地址+写方向(SLA+W),已回ACK准备接收字地址
0x80收到数据字节,已回ACK保存数据,或作为字地址保存
0xA0收到STOP条件或重复起始复位本次会话状态
0xA8收到自身地址+读方向(SLA+R),已回ACK准备发送数据
0xB8数据字节已发送,主机回ACK地址加1,继续发下一字节
0xC0数据字节已发送,主机回NACK结束本次读操作

中断服务函数的基本骨架如下:

void I2C_IRQ(void) interrupt 7 { uint8_t sta = I2CSTATUS; // 读取状态后立刻清SI,避免重复触发 I2CON &= 0xF7; switch (sta) { case 0x60: // SLA+W,进入等待字地址状态 i2c_state = I2C_STATE_WAIT_ADDR; break; // 其余状态在下一章补全 default: break; } }

这里有个细节要提醒:SI标志是I2C串行中断标志,进入中断后把它清掉是常规操作,但清得太早会有风险。如果从机内部还没准备好下一拍数据,新到的字节就会把中断标志再次置位,状态机可能读到旧状态。所以更稳的顺序是“先读I2CSTATUS,再清SI,最后根据状态码准备数据或应答位”。这套顺序在下一章完整状态机里会反复用到。另外,不要在I2C中断服务函数里加延时函数,N76E003主频16MHz,一条delay语句就可能让SCL低电平保持超过主机容忍值,总线就容易出问题。

4. 模拟读写状态机:从地址匹配到页写入的完整实现

4.1 状态变量与会话流程:把AT24C02的“字地址”当成内部指针

状态机实现前,先定义好状态常量和两个关键变量,一个保存当前状态,一个保存字地址:

#define I2C_STATE_IDLE 0 #define I2C_STATE_WAIT_ADDR 1 #define I2C_STATE_WAIT_DATA 2 #define I2C_STATE_READ 3 static uint8_t i2c_state = I2C_STATE_IDLE; static uint8_t mem_addr = 0x00;

i2c_state表示当前会话处于哪个阶段,mem_addr就是AT24C02内部的字地址指针。用uint8_t定义mem_addr是有意为之:当地址从0xFF加1时,uint8_t自动回卷成0x00,正好符合EEPROM顺序读的地址回卷行为。如果这里用了uint16_t,反而要额外写一段判断,属于给自己找麻烦。

整个会话流程是这样走的:主机发起起始,发送SLA+W,N76E003地址匹配后进0x60中断,状态切到等待字地址。主机再发一个字地址字节,进0x80中断,此时我们不知道这个字节是“字地址”还是“数据”,只能靠状态区分。收到字地址后,状态切到等待数据。往后每次0x80中断,都是真正的数据写入,直接写进eeprom_buf。当主机发出STOP或重复起始,进0xA0中断,本次会话结束,状态复位到IDLE。

4.2 写流程:字地址与数据字节怎么区分

写方向最关键的代码就是0x80状态里那段分支,很多人写错都是因为没区分“等字地址”和“等数据”两个阶段。完整的中断处理如下:

void I2C_IRQ(void) interrupt 7 { uint8_t sta = I2CSTATUS; I2CON &= 0xF7; switch (sta) { case 0x60: // 收到SLA+W,主机要开始写了 i2c_state = I2C_STATE_WAIT_ADDR; I2CON |= 0x04; // 保持AA,继续应答数据 break; case 0x80: // 收到数据字节 if (i2c_state == I2C_STATE_WAIT_ADDR) { // 这一字节是字地址,决定后续写入的位置 mem_addr = I2CDAT; i2c_state = I2C_STATE_WAIT_DATA; } else if (i2c_state == I2C_STATE_WAIT_DATA) { // 这一字节是真正的数据,写入RAM缓冲 eeprom_buf[mem_addr] = I2CDAT; eeprom_dirty[mem_addr >> 3] = 1; mem_addr++; } I2CON |= 0x04; // 继续ACK,等下一个字节 break; case 0xA0: // 收到STOP或重复起始,复位会话 i2c_state = I2C_STATE_IDLE; break; // 读方向的case在下一节补上 } }

逻辑是这样:第一次进入0x80时,状态还是WAIT_ADDR,数据当成字地址保存;下一次再进0x80,状态已经切到WAIT_DATA,数据直接写入缓冲。这里没有判断数据长度,因为在RAM模式里可以连续写任意字节,mem_addr从0开始写255字节,再继续写就会自动回卷到0,行为和真实AT24C02连续跨页写基本一致。写入的同时置位脏标记,mem_addr >> 3就是把地址除以8,定位到对应的页标记位。如果你不需要Flash掉电保存,这一行可以删掉,RAM方案里它只是给主循环搬运Flash用的。

还有一个小地方要处理:写方向时,主机会在最后一字节后发STOP,这时进0xA0将状态复位。但有一种特殊顺序,主机写完数据后不立即停,而是紧跟着发重复起始来切到读方向,这时候0xA0也复位移交给下一个SLA+R帧处理。所以0xA0的复位对随机读尤其重要。

4.3 读流程:地址自增、0xFF回卷与NACK收尾

读方向的实现补进同一个中断函数里:

case 0xA8: // 收到SLA+R,主机要开始读了 // 从当前字地址读数据,先放到发送缓冲 I2CDAT = eeprom_buf[mem_addr]; i2c_state = I2C_STATE_READ; I2CON |= 0x04; // 保持AA,准备接收主机的ACK break; case 0xB8: // 上一字节已发出,主机回ACK,继续发下一字节 mem_addr++; I2CDAT = eeprom_buf[mem_addr]; break; case 0xC0: // 上一字节已发出,主机回NACK,本次读结束 i2c_state = I2C_STATE_IDLE; break;

0xA8状态发生时,主机刚刚发送SLA+R,从机要立刻把第一字节放到I2CDAT发送寄存器。这里数据来源是eeprom_buf[mem_addr],mem_addr可能就是之前随机读或当前地址读指定的位置。主机读完第一字节后回ACK,0xB8再把地址加1,继续发下一个字节。这个循环会一直持续到主机回NACK,也就是0xC0。收到NACK说明主机不再需要数据,状态复位,准备下一帧。整个过程没有在代码里显式判断地址回卷,因为uint8_t的加法自动把0xFF变成0x00。

有一点要单独说明:当前地址读和随机读完全共用这套状态机。随机读的前半段走写流程,把字地址存进mem_addr;后半段SLA+R进入0xA8,直接从这个地址发数据。当前地址读则直接以0xA8进入,mem_addr还是上一次写操作后残留的值,行为上和真实EEPROM一致。顺序读则是0xB8持续循环,直到主机NACK。

完整代码里还需要一个默认分支处理那些不关心的状态码,比如0x70、0x78(广播呼叫),0x88(收到数据后回NACK)。这些在本工程里保持AA=1的情况下不会出现,但写个default分支总没坏处:

default: break;

把所有case组合起来,这个中断函数就是模拟AT24C02的核心。把它和前面的初始化代码放一起,编译烧录后接上主机I2C控制器,只要地址对齐,读写就已经能跑通。剩下的问题基本都集中在边界情况上。

5. 常见问题与排查:5次实测踩过的坑

5.1 现象:主机一直NAK,从机不响应——I2CADDR地址位没对齐

现象:主机用0xA0做写地址扫描,逻辑分析仪上能看到SCL正常翻转,但SDA上只有主机自己在驱动,从机始终不拉低SDA回ACK。

原因:I2CADDR寄存器写的是0x50,没有左移一位,地址比对永远失败;或者A2/A1/A0引脚悬空、板子有上拉下拉干扰,导致实际从机地址漂移。

解决:把I2CADDR改成0x50<<1,重新烧录后先扫描地址。给A2/A1/A0焊上下拉电阻固定电平,不要靠引脚悬空碰运气。我后来养成的习惯是写一个地址扫描固件,主机逐个地址探测,确认能回ACK的那个地址再固化到驱动里。

5.2 现象:读写数据错位——中断里拖了太长时间

现象:写入256字节后回读,前面几个字节正常,越往后错位越严重,严重时主机直接报I2C超时。

原因:I2C中断服务函数里做了耗时的Flash擦写、串口打印或者长延时,SCL被从机拉低的时间超过了主机允许的等待上限。真实AT24C02写周期有5ms,主机驱动是会等这5ms的,但模拟时不能在中断里真等5ms。

解决:把耗时操作全部移出中断。RAM缓冲直接写,脏标记只置位不落Flash;Flash搬运放到主循环或者定时器回调里执行。中断函数保持精简,只做状态转移和数据存取。

5.3 现象:掉电后数据丢失——RAM模拟的物理局限

现象:断电再上电,配置参数恢复成默认值,主机回读全是对应RAM初始化的内容。

原因:用的是纯RAM数组模拟,掉电瞬间RAM内容全部清零,没有把数据同步到非易失介质。

解决:改用两级方案。RAM做主存,主循环里检查脏标记,把改过的页搬进片内Flash。注意Flash擦写有寿命,不能每个字节都触发整页擦除;还要处理好“断电瞬间正在擦Flash”的情况,搬运前关中断,搬运完再开,避免写到一半被I2C新数据打断。

5.4 现象:和板上其它I2C设备地址冲突——0x50太常用了

现象:同一根I2C总线上挂了别的存储或传感器设备,主机设备扫描时发现两个从机都响应0x50,数据读写出现随机性串扰。

原因:AT24C02的默认地址0x50太常见,很多传感器芯片的地址也落在附近,或者另一个AT24C02也是同地址。

解决:把A2/A1/A0引脚接到高电平,把从机地址改到0x52、0x54甚至0x56;如果硬件引脚已经固定接GND,就在主机驱动里改设备地址,同时把模拟程序里的I2CADDR同步修改。这种问题在设计阶段就要排查,等PCBA贴完再改就麻烦。

5.5 现象:随机读时死锁——重复起始漏处理

现象:主机执行随机读,先写字地址,再发重复起始切换读方向,从机直接卡死,总线一直挂在低电平。

原因:收到重复起始时,状态码是0xA0,但代码里没有处理这一帧,从机还停留在等待数据状态,后续SLA+R进来时状态错乱,主机等不到数据。

解决:在case 0xA0里复位状态到IDLE,同时保留前面写入的mem_addr,这样后面SLA+R到达0xA8时能直接从正确地址送数据。判断方法是看逻辑分析仪波形,重复起始之后如果从机没有按时回ACK,基本就是0xA0处理丢了。

6. 给模拟EEPROM加“写保护”:软件锁、校验回读与磨损均衡

模拟器跑通之后,我还加了几个小功能,让这颗“软件EEPROM”更贴近真实芯片的工程习惯。第一个是软件写保护。AT24C02有WP引脚,拉高就禁止写,但单片机模拟时不好动引脚,我用一个解锁序列替代:主机要连续写两个固定字节,程序才放开写操作,否则写命令直接丢弃。

#define UNLOCK_KEY1 0x5A #define UNLOCK_KEY2 0xA5 static uint8_t unlock_state = 0x00; uint8_t eeprom_check_unlock(uint8_t dat) { if (unlock_state == 0x00 && dat == UNLOCK_KEY1) { unlock_state = 0x01; return 0xFF; } if (unlock_state == 0x01 && dat == UNLOCK_KEY2) { unlock_state = 0x02; return 0xFF; } return 0x00; }

实际写入前先检查unlock_state是否为0x02,不是就拒绝写入。这个机制能挡住很多意外写操作,缺点是主机驱动要做两次额外写操作,适合对安全要求高的项目。第二个是数据校验回读。我在这套程序里对Flash里的数据块存了魔数和CRC16,读出来先校验,校验不过就返回默认配置,防止“写到一半掉电”把坏参数当作好参数用。第三个是磨损均衡,把频繁更新的参数轮流写在多个Flash页里,每页记录一个序列号,下次写入就找序号最旧的一页。

从那以后,我每次做模拟EEPROM这类I2C从机外设,都会先用逻辑分析仪把地址字节和数据波形完整抓一遍,确认状态码和预期一致再往上叠应用逻辑。模拟外设最大的成本不是协议本身,而是这些地址错位、状态码漏处理的小坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询