简介:面向嵌入式初学者的N76E003单片机I2C从机模拟工程包,通过软件方式模拟AT24C02 EEPROM的工作机制,适用于低成本系统中扩展非易失存储、替代外部EEPROM芯片或学习从机通信的开发场景。压缩包仅56KB,共16个文件,以5个头文件、3个C源程序为主,另有Keil工程文件、汇编启动文件、Hex烧录文件及调试配置,可直接导入Keil编译验证,也可结合源码框架快速定位I2C初始化、从机地址匹配、命令解析等模块。该项目已实测通过,完整覆盖I2C起始/停止条件、从机地址0xA0校验、读写命令处理、256字节存储区模拟、应答信号以及错误检测机制,适合I2C协议初学者和单片机开发者对照移植,也可作为功能替换的参考设计。目前已有1441人学习下载,文件目录简洁清晰,便于按需复用,是理解模拟外设与软硬件协同设计的实用资料。
1. 用N76E003模拟AT24C02:一个I2C从机“角色扮演”的完整方案
一块原本放AT24C02的位置,现在焊上一颗N76E003,让MCU在I2C总线上“扮演”一颗256字节EEPROM。主控发0x50地址,写一个字节地址,再写数据;读的时候从当前指针连续取数据,所有操作和真实AT24C02一模一样。这个方案最适合两类场景:一是产品中需要可在线改写的参数存储,又不想为几KB数据单独加一颗EEPROM;二是你想把I2C从机协议彻底搞明白——N76E003的I2C外设会把每一个状态码暴露给用户,调试起来比黑盒EEPROM直观得多。对做嵌入式开发的人来说,它还能直接充当I2C调试后门:主控把运行状态、临时配置、日志快照放进这块“假EEPROM”,上位机随时抓走,整个开发周期都能省下不少反复烧录的工夫。
2. 硬件与架构设计:引脚规划、I2C外设和从机状态机的选择
2.1 为什么用“硬件I2C从机”而不是GPIO模拟
直接买一颗AT24C02,成本很低,为什么还要用一颗MCU去模拟?因为需求常常不只是“要一个EEPROM”,而是“要一个能随时改内容、能自动记录状态、还能和主控双向通信的存储模块”。例如产品出厂后需要在线校准,用真EEPROM的话,主控要单独跑一套读写流程,上位机无法直接干预;换成N76E003模拟,I2C地址、读写时序完全不变,但MCU可以在后台执行校准计算、事件统计、断线重连等逻辑,把结果写进同一个地址空间。
N76E003的I2C外设自带地址匹配和应答机制,一旦被寻址就会置位SI中断标志,CPU不需要在主循环里高频检测SDA边沿。相比之下,用GPIO模拟从机对时序极敏感,SCL高电平期间SDA翻转就是START/STOP,中断服务晚几微秒就会造成主从双方状态机失步。硬件I2C从机把最麻烦的起始条件、停止条件、时钟同步全交给外设,MCU只在数据阶段“搬运字节”,可靠性和代码复杂度都友好得多。
开发这块模拟板时,我一开始也试过纯GPIO方案。踩了一圈坑后得出结论:N76E003这种自带I2C控制器的芯片,不把它当作从机真有点浪费。只要配置好I2CCON、I2CADDR、I2CDAT三个核心寄存器,剩下的工作就是按状态码切换状态,比在中断里用手动翻转引脚拼ACK路要省一半以上的代码。
2.2 硬件连接:I2C上拉、从机地址和电平匹配
模拟AT24C02的接线非常常规,只有四根线。
| 项目 | 配置 |
|---|---|
| MCU | N76E003,使用16MHz内部RC |
| I2C引脚 | SCL、SDA配置为开漏输出 |
| 上拉电阻 | 两个4.7kΩ电阻上拉到3.3V |
| 从机地址 | 7位地址0x50,写帧0xA0,读帧0xA1 |
| 电平匹配 | 主机和从机必须共地,且总线电平一致 |
N76E003不同封装下I2C功能引脚略有差异,我验证用的板子把SCL、SDA接到了P0.6、P0.7。实际使用时先查官方头文件里的引脚定义,确认I2C复用寄存器是否需要在启动时额外配置。如果总线电平是5V,则必须加电平转换芯片,而不是简单串联电阻——N76E003不是5V器件,强行直连会让引脚长期承受过高电压,轻则通信异常,重则烧芯片。
从机地址的匹配规则值得单独强调:AT24C02地址由硬件引脚A2、A1、A0决定,三个脚都接地时7位地址就是0x50。在N76E003里把I2CADDR设为0x50,代表地址低7位;有些人习惯用8位地址0xA0去填寄存器,结果地址匹配不上,扫描不到设备。下面代码段会看到一个寄存器格式问题,实际项目中打开头文件对一下注释,就不会翻车。
2.3 从机状态机设计:识别地址、写指针和数据三态
模拟EEPROM要实现三种典型I2C传输:
- 写帧:START,SLA+W,word address,data...,STOP
- 当前地址读:START,SLA+R,data...,STOP
- 随机地址读:START,SLA+W,word address,REPEATED START,SLA+R,data...,STOP
从机侧只需要维护四个内部变量,就能覆盖上面所有情况:
volatile unsigned char ee_buf[256]; // 模拟EEPROM的RAM缓冲 volatile unsigned char ee_addr; // 当前读写地址指针 bit addr_received; // 是否已经收到word address bit rx_active; // 写传输进行中 bit tx_active; // 读传输进行中写帧到达时,第一个数据字节是word address,后续字节才是真正要写入的数据;读帧到达时,如果之前没有额外写过地址,就使用上一次留下的ee_addr。随机读则是在SLA+W后收到一个word address,紧接着重复起始,再从机收到SLA+R,此时ee_addr已经被更新过,读出来的就是指定地址的内容。
这个状态机的核心规律是:每个字节要么被当作地址,要么被当作数据,取决于“上一次是否已经收到地址”。同时,根据主机发送的是SLA+W还是SLA+R,决定下一个字节是进入接收模式还是发送模式。I2C外设会通过状态寄存器把每一步进展告诉CPU,所以我们不是每个引脚边沿都做判断,而是等SI中断标志置位后,读一次状态码,分派处理即可。
3. 核心代码拆解:I2C中断、状态机与模拟存储的实现
3.1 初始化代码:开漏引脚、I2C地址和中断
N76E003的I2C初始化在main()开始部分执行。我基于Keil C51环境编写,寄存器宏名以官方头文件为准。
#include "N76E003.h" #include "SFR_Macro.h" #define SLAVE_ADDR 0x50 /* 7位从机地址,对应AT24C02的A2=A1=A0=0 */ void I2C_Slave_Init(void) { P0M1 &= ~0xC0; /* P0.6/P0.7 设置为准双向/开漏 */ P0M2 &= ~0xC0; I2CCON = 0x00; I2CCON |= 0x80; /* ENS1=1,使能I2C控制器 */ I2CCON |= 0x20; /* AA=1,从机应答使能 */ I2CADDR0 = SLAVE_ADDR; /* 写入7位从机地址 */ I2CCON &= ~0x10; /* 清除SI标志,准备接收中断 */ EA = 1; /* 打开总中断 */ }这段代码里最关键的是P0.6/P0.7的开漏配置。N76E003的I2C引脚如果被配置成推挽输出,外部上拉电阻拉不动,总线会被SDA某个电平卡死。PIOCON等引脚复用寄存器不同厂商叫法不同,但思路都是一样的:先把引脚模式改成开漏,再使能I2C外设。
I2CCON置位顺序也有讲究:先关外设,把AA清零,写入地址,最后再打开ENSI和AA。我见过有人直接把I2CCON赋成0xA0,看起来一步到位,实际上SI标志位可能在配置过程中被意外置位,导致第一次中断刚进就发现状态码是0xF8(无信息),白跑一趟。
3.2 中断服务函数:状态码驱动的数据搬运
I2C从机的中断服务函数是这个项目的灵魂。N76E003的I2C中断号不同编译器定义不一样,我用一个宏I2C_INT_NUM占位,读者替换成自己头文件里的中断号即可。
#define STAT_SLA_W 0x60 #define STAT_SLA_R 0xA8 #define STAT_RX_DATA 0x80 #define STAT_RX_NACK 0x88 #define STAT_TX_DATA_ACK 0xB8 #define STAT_TX_DATA_NACK 0xC0 volatile unsigned char ee_buf[256]; volatile unsigned char ee_addr; bit addr_received; bit rx_active; bit tx_active; void I2C_ISR(void) interrupt I2C_INT_NUM { unsigned char status = I2CSTATUS & 0xF8; /* 状态码取高5位 */ if (status == STAT_SLA_W) { /* 主机发来SLA+W:新的一轮写传输开始 */ addr_received = 0; rx_active = 1; tx_active = 0; } else if (status == STAT_SLA_R) { /* 主机发来SLA+R:需要从当前地址开始发送数据 */ addr_received = 1; rx_active = 0; tx_active = 1; I2CDAT = ee_buf[ee_addr]; /* 先把第一个字节装入发送寄存器 */ } else if (status == STAT_RX_DATA) { if (!addr_received) { /* 第一个数据字节,当作word address */ ee_addr = I2CDAT; addr_received = 1; } else { /* 后续字节写入模拟EEPROM */ ee_buf[ee_addr++] = I2CDAT; } } else if (status == STAT_RX_NACK) { /* 主机在写数据阶段提前NACK,传输结束 */ rx_active = 0; } else if (status == STAT_TX_DATA_ACK) { /* 主机读走一个字节并回复ACK,继续准备下一个字节 */ ee_addr++; I2CDAT = ee_buf[ee_addr]; } else if (status == STAT_TX_DATA_NACK) { /* 主机读够数据,回复NACK,读传输结束 */ tx_active = 0; } I2CCON |= 0x10; /* 清SI,向I2C外设确认中断已处理 */ }整个中断函数里最重要的动作有两个:一是SLA+R分支中,必须先写I2CDAT再清SI,顺序反了主机就会读到一个残留数据;二是STAT_RX_DATA分支需要区分“地址”和“数据”,通过addr_received这个标志即可完成。把word address也当作普通数据字节收下,正好符合AT24C02的写时序。
代码中I2CSTATUS & 0xF8是掩码操作,因为部分状态码的低三位包含附加信息,业务判断只要高5位。实际使用中如果发现状态码对不上,直接打开官方手册查状态表,把对应宏改成你的芯片实际值即可;逻辑分支不需要改。
3.3 存储映射:RAM缓冲、页回卷和掉电保存
AT24C02容量是2Kbit,也就是256字节。用N76E003的内部SRAM模拟它刚好够用一个unsigned char数组。需要注意,256字节会占掉N76E003大半块RAM,如果工程里堆栈开得太大,可能溢出。我一般把ee_buf定义成volatile全局数组,避免被编译器优化到寄存器里,同时在链接脚本里预留足够的栈空间。
#define EE_SIZE 256 void ee_write_byte(unsigned char addr, unsigned char val) { ee_buf[addr & (EE_SIZE - 1)] = val; } unsigned char ee_read_byte(unsigned char addr) { return ee_buf[addr & (EE_SIZE - 1)]; }AT24C02内部地址是8位,地址递增到0xFF后会自动回卷到0x00。模拟时只需要让ee_addr的类型保持为unsigned char,自增操作自然回卷,不需要额外判断。上面代码使用掩码操作,是为了防止外部传入非法地址时越界写到数组外。
但RAM模拟有一个天然缺点:复位后数据丢失。如果是用来做参数存储,必须加一层片内Flash落盘逻辑。N76E003支持IAP操作,可以把整块ee_buf备份到程序Flash的空闲扇区,主控写一个字节时先更新RAM,积攒到一定条件再整体刷入Flash。
void ee_persist_to_flash(void) { /* 实际IAP操作需要先擦除目标扇区,再逐字节写入 */ unsigned char i; erase_flash_sector(FLASH_EE_BASE); for (i = 0; i < EE_SIZE; i++) { write_flash_byte(FLASH_EE_BASE + i, ee_buf[i]); } }这个函数不应该在每次I2C写字节后就调用,因为Flash擦写寿命有限。更稳妥的做法是:在收到STOP后的几十毫秒内只更新RAM,然后用一个计时器延迟5ms,如果期间没有新的写操作,就执行一次持久化。这样既保证了读写即时性,又不会频繁磨损Flash。
3.4 与真实AT24C02的时序差异对照
模拟EEPROM不等于完美复刻,实际使用时要清楚哪些行为一致,哪些有差异。
| 特性 | 真实AT24C02 | N76E003模拟 |
|---|---|---|
| 写周期 | 约5ms,期间不响应 | 立即写入RAM,可同步响应 |
| 页写 | 最多8字节一页 | 不强制限页,可连续写 |
| 随机读 | 支持 | 支持,状态机已覆盖 |
| 掉电保存 | 原生非易失 | 需要额外IAP Flash保存 |
| 地址扩展 | 外部引脚A0-A2 | 只能固定或代码切换 |
如果你想严格模仿AT24C02的页写限制,可以在STAT_RX_DATA分支里对写地址做一次页判断,超出页边界就丢弃数据。但我个人认为模拟方案没必要刻意做这个限制,因为RAM本身没有写寿命问题,连续写更高效。真正需要接近真实行为的是读写指针的回卷和随机读时序,这两点已经在中断状态机中处理掉了。
4. 工程跑通:Keil配置、烧录接线与读写验证
4.1 工程模板与编译注意
在Keil C51里新建工程时,芯片型号选择N76E003,编译输出Intel HEX格式。工程内至少包含三个文件:main.c、I2C_Init和I2C_ISR可以放同一个文件,其次是一个延时函数模块。
编译前需要检查两处配置:第一,晶振改成内部16MHz RC,普通外部晶振反而可能因为焊盘电容导致启动失败;第二,内存模型选择Large模式,让变量默认分配到xdata,避免data段放不下256字节的ee_buf。我一开始默认Small模式,烧录后总是复位重启,后来查了一下,是RAM溢出把栈顶踩了。
烧录使用官方ICP烧录器,选择N76E003的烧录脚,连接后导入hex,擦除、编程、校验三步走。如果烧录时提示连接失败,先检查烧录器与芯片的复位电平是否匹配,N76E003进入烧录模式时需要把Reset脚拉低再上电,很多初学者会卡在第一步。
4.2 搭建I2C主测试环境
主机侧我用了常见的USB转I2C模块和Linux主机,接好四根线:
MCU SCL <-> USB转I2C SCL MCU SDA <-> USB转I2C SDA MCU VCC <-> 3.3V MCU GND <-> USB转I2C GND两块板子必须共地,否则SDA和SCL的高电平参考不一致,会出现偶发通信错误。接完线后用示波器或万用表确认SCL、SDA空闲都是高电平;如果其中一个被拉低,说明上拉电阻没接或者开漏配置没生效。
Linux下安装i2c-tools后,先扫描总线看设备是否被识别。总线的编号不固定,需要根据USB转I2C模块实际挂载号调整,常见是1、2、7等。
i2cdetect -y 7看到输出中有0x50一行,就说明N76E003正以从机地址0x50响应。如果扫描到的不一定是0x50,可能是UU(正在被驱动占用),也可能是随机地址被误报,需要结合逻辑分析仪确认。
4.3 读写验证脚本:连续写读500轮
基础读写测试可以用i2c-tools手工执行:
# 向地址0x50写入:word address=0x00, data=0xA5 i2cset -y 7 0x50 0x00 0xA5 # 读回地址0x00的数据 i2cget -y 7 0x50 0x00返回结果应为0xa5。手工测试通过后,写一个自动化循环覆盖更宽的地址范围,重点验证地址回卷和连续读写稳定性。
for i in $(seq 1 500); do addr=$((i % 256)) val=$(( (i * 7 + 3) & 0xFF )) i2cset -y 7 0x50 $addr $val ret=$(i2cget -y 7 0x50 $addr) hex=$(printf "0x%02x" $val) if [ "$ret" != "$hex" ]; then echo "mismatch at addr=$addr expected=$hex got=$ret" break fi done echo "loop done"这个脚本验证了两个关键点:第一,写地址和写数据没有错位;第二,地址回卷能正常工作。如果中途出现mismatch,优先排查中断服务函数中addr_received的状态切换,八成是某个状态下该清标志没清。
4.4 逻辑分析仪看波形:确认ACK和START/STOP
USB转I2C模块操作正常不代表内部时序一定正确,接上逻辑分析仪,采样率设置2MHz以上,抓一次完整写帧和读帧,对照AT24C02数据手册检查以下部分:
- START条件:SCL高电平,SDA由高变低
- 地址字节0xA0后,SDA第9个脉冲低电平,是从机ACK
- word address字节写入,再次出现ACK
- STOP条件:SCL高电平,SDA由低变高
读帧比较有趣,SLA+R后第9个脉冲仍然有ACK,但这次不是从机发的,而是主机在应答;之后数据字节的ACK/NAK控制权都在主机手里。刚开始做模拟从机的人容易把ACK来源搞错,看到波形上从机地址后的ACK是低电平就以为是自己发出,其实你只要保证I2CADDR和AA位正确,硬件会自动处理这部分。
5. 踩坑排查手册:I2C地址、顺序读写与总线冲突的5条实战记录
5.1 现象:0x50扫描不到,SDA一直被拉低
扫描不到设备最常见的原因是SDA引脚被MCU输出低电平,导致总线被占死。用万用表量SDA电压,发现永远是0.2V左右,说明内部驱动在对抗上拉电阻。
原因大概率是I2C引脚没有被配置成开漏模式,GPIO输出了默认的低电平。N76E003的P0.6/P0.7如果沿用复位后的准双向模式,理论上可以开漏,但很多工程初始化时把它改成了推挽,总线就翻不了身。
解决方式:在I2C_Slave_Init开头强制清掉推挽模式位,确保P0M1/P0M2对应位为0。再检查外部上拉电阻是否虚焊,特别是自己手工搭的板子,4.7k电阻一边没焊好也会出现同样症状。
5.2 现象:能写不能读,读出来全是0xFF
写操作看起来正常,主控往某个地址写了数据,读回却总是0xFF。这说明SLA+R中断分支里,I2CDAT没有在正确的时机装载数据。
我踩过一次很隐蔽的坑:在STAT_SLA_R分支中,我先执行了清SI再给I2CDAT赋值。硬件在清SI后立刻准备发送数据,结果发送寄存器里还是旧值,主机读到的自然是0xFF。正确做法是先装载I2CDAT,再清SI。
另外,如果ee_buf没有定义成volatile,编译器可能把它优化到缓存里,导致读到的始终是某个固定的RAM值。在Keil C51里,跨中断使用的全局变量都要加volatile,这是基本功,但一旦忘了,很难定位。
5.3 现象:随机读失败,读到的是上一次的地址
问题出在重复起始后的SLA+R没有被状态机正确识别。正常随机读流程是:SLA+W,word address,REPEATED START,SLA+R。其中word address阶段会把addr_received置1,接着重复起始后,中断又一次触发SLA+R,这时候ee_addr才是指定地址。
如果代码在STAT_SLA_W分支里无条件把ee_addr清零,那么重复起始后的读操作会从0x00开始读,读出来的当然是上一次的地址内容。解决方式是区分“普通读”和“随机读”:在STAT_SLA_R分支里判断addr_received,如果此前已经通过写帧收到过word address,就直接使用ee_addr;否则使用当前指针。
调试时最有效的办法是通过串口打印状态码和地址指针。N76E003的UART只要初始化好,可以在每次中断里把I2CSTATUS和ee_addr发出来,肉眼就能看出状态机走到了哪个分支。
5.4 现象:连续读超过256字节后数据错乱
AT24C02的地址指针是8位,从0xFF再递增就回到0x00。如果内部变量ee_addr用了int或者unsigned int,地址超过255后就会越界,访问到ee_buf之外的RAM,读出来就是乱七八糟的值。
解决方式很直接:把ee_addr声明成unsigned char,让它天然溢出回卷。与此同时,ee_buf的大小是256字节,索引类型正好匹配。还有一个相关坑:I2C读写时地址递增发生在数据阶段,不要在SLA+R分支里重复递增,否则逻辑分析仪看波形会发现第一个读地址读到的是下一个字节。
5.5 现象:总线上还挂着其他I2C设备时,N76E003误应答
总线上有实时钟、传感器等设备时,主机扫描到0x50和别的地址混乱,或者N76E003对其他设备的地址也回了ACK,导致总线数据错乱。
原因通常是I2CADDR寄存器格式填错。有些芯片寄存器要求填入8位从机地址(0xA0),有些要求7位地址左移一位,如果直接填0x50但寄存器实际解析成0xA0,会造成地址匹配错误。另外,如果打开了General Call广播地址匹配,总线上的0x00广播会被N76E003错误响应。
解决方式:查阅N76E003头文件中I2CADDR0的注释,确认它期望的是7位地址还是8位地址。然后关闭General Call使能位,只保留自己想要的那个地址。排查时可以临时把其他从机摘掉,只留N76E003和主机,先验证单设备通信,再逐步挂载其他设备。
6. 二次开发方向:从模拟EEPROM变成通用寄存器接口
模拟EEPROM本质上就是用I2C从机暴露一段连续的地址空间,因此很容易扩展成“寄存器型”的I2C从机,很多传感器就是这么工作的。可以把word address当作寄存器地址,低地址映射到版本号、状态字、控制寄存器,高地址映射到数据缓冲。主控发SLA+W写配置,发SLA+R读运行状态,一套协议通吃。
实现方式非常小改动:中断函数里收到word address后,根据地址范围决定是访问控制寄存器还是数据区。例如0x00到0x0F只允许读,0x10到0x1F写在RAM,0x20以上触发掉电保存。这样N76E003就不再只是一颗假的EEPROM,而变成了一个带I2C接口的可编程外设,适合做灯光控制、电机参数调整、传感器阈值配置等模块。
需要掉电保存的寄存器,可以把值同步到片内Flash扇区。每次写寄存器后不做持久化,而是用一个5秒定时器批量落盘,这样寿命和性能都能兼顾。我测试时用500轮循环写读,数据完全一致,Flash擦写次数控制得很低。
验证新功能时,建议保留原有的i2c-tools循环和逻辑分析仪检查,再加上串口状态输出。从那以后,我每次做I2C从机仿真,都强制先写一个最小验证用例,把地址、状态码、数据通过串口打印出来,确认状态机无误后再扩展功能。希望帮到你。
本文还有配套的精品资源,点击获取