资讯详情

资讯详情

用N76E003模拟AT24C02:I2C从机状态机与EEPROM替代实现

简介基于新唐N76E003单片机实现I2C从机并模拟AT24C02 EEPROM的完整工程面向需要掌握I2C通信协议、熟悉单片机外围器件模拟的嵌入式开发者也适合电子竞赛、课程设计与毕业设计参考。程序已经过实际板级测试可直接导入Keil编译下载或根据引脚定义移植到其他8位MCU平台。资源包共16个文件以C源码和头文件为核心涵盖主从机双向通信示例、Keil工程文件、汇编启动文件、Hex固件及调试配置压缩后仅56KB目录模块划分明确便于按需阅读和二次开发。已有1441人在CSDN学习下载。代码完整实现I2C从机逻辑包括总线起始/停止条件检测、从机地址匹配、读写命令解析、256字节存储空间模拟、应答时序生成及错误处理机制同时给出主从机通信的示例工程可帮助理解I2C底层时序和AT24C02的操作流程提升软硬件协同开发能力。1. 这个项目能干什么用N76E003给I2C主机“变”出一颗AT24C02在打样调试一块 I2C 主设备时突然遇到 EEPROM 缺货翻遍抽屉找到一批 N76E003 单片机于是让这颗 8051 内核的单片机以 I2C 从机身份把自己模拟成 AT24C02主机端不用改驱动、不用重新布线读写全部照常。这个已测试的工程正是干这件事的把一块 256 字节 RAM 通过硬件 I2C 从机接口暴露给外部主机协议层面和真实 AT24C02 几乎一致包括随机读、顺序读、页写回卷和写周期时序。适合两类人一类是想在项目里临时替代 EEPROM、或者给 I2C 主机扩一块存储的嵌入式开发者另一类是想搞懂 I2C 从机状态机的新手——这个工程把从机协议拆成了几个可单步调试的中断分支比对着协议手册硬啃直观得多。2. 硬件接线与工程配置I2C Slave引脚复用与最小开发环境2.1 最小接线SDA、SCL、上拉电阻和WP引脚怎么接N76E003 的 I2C 是硬件外设不是 GPIO 模拟所以接线比想象中简单SDA、SCL 两条信号线加上地线和共用的 VCC 就够。既然是 I2C开漏结构决定了两条线必须各接一颗上拉电阻100kHz 速率用 4.7kΩ400kHz 建议换 2.2kΩ电阻接 VCC 而不是接 GND。这里我是踩过坑的有次把上拉接到了 GND逻辑分析仪一看SDA 从头到尾都是低电平主机那边始终报总线忙。AT24C02 的 WP 引脚控制写保护模拟版本可以把它当普通 GPIO 处理。最简单的做法是直接接 GND 表示允许写如果想让代码里出现“写保护”状态就把 WP 接到一个 GPIO 上代码里准备一个变量记录当前是否可写。注意真实芯片的写保护只影响写操作读操作永远放行模拟实现里也要保持这个语义。信号方向说明SDA双向数据线接 4.7k 上拉到 VCCSCL输入时钟线由主机驱动接 4.7k 上拉WP输入写保护直接接 GND 或接 GPIO 控制VCC/GND-与主机同电压同地电平范围看具体型号接好线之后主机扫描总线上应该能直接看到 0x50 这个地址原理上它和一颗焊在 PCB 上的 AT24C02 没有任何区别。2.2 工程配置与烧录关键寄存器设置和地址匹配工程基于官方 BSP编译环境用 Keil C51烧录器用 Nu-Link。打开工程后先确认三处配置一是把单片机主频设成内部 16MHz RC不依赖外部晶振这样最小系统板也能跑二是把 I2C 相关引脚复用打开否则引脚还是普通 GPIO主机发过来的时钟不会进 I2C 模块三是把 I2C 中断优先级调高后面讲 400kHz 的坑会再提到它。初始化代码集中在 I2C 从机初始化函数里#include N76E003.h #define I2C_SLAVE_ADDR 0x50 /* 7位从机地址对应AT24C02的1010A2A1A0 */ #define EEPROM_SIZE 256 /* 模拟2Kbit EEPROM */ #define PAGE_SIZE 16 /* 现代AT24C02页大小老规格有8字节 */ static uint8_t ee_ram[EEPROM_SIZE]; /* 真正的存储区 */ static uint8_t word_addr; /* 当前访问地址指针 */ static volatile uint8_t write_pending; /* STOP后置1模拟内部写周期 */ static volatile uint8_t is_write; /* 当前事务方向1-写 0-读 */ void i2c_slave_init(void) { SFRPAGE 0x01; /* N76E003的I2C寄存器在SFR页1 */ I2CEN 1; /* 使能硬件I2C外设 */ I2CADDR I2C_SLAVE_ADDR; /* 设置7位从机地址 */ EI2C 1; /* I2C中断使能位名以BSP头文件为准 */ EA 1; /* 打开总中断 */ }I2CEN、I2CADDR、EI2C 这几个位名在不同版本的 BSP 头文件里可能有差异编译报错时直接去 N76E003.h 里查实际定义逻辑都一样。关键点是 I2CADDR 里放的是 7 位地址 0x50不是 8 位地址 0xA0——这个细节下面单独说因为十个人里有八个第一次会在这里翻车。2.3 从机地址匹配0x50 与 0xA0 的关系I2C 总线上每次传输的第一个字节是地址字节它由 7 位地址加 1 位读写标志组成。主机访问 AT24C02 时写操作发 0xA0读操作发 0xA1换算成二进制就是 1010 000 0 和 1010 000 1。后三位 000 来自 A2/A1/A0 引脚实际取决于硬件接法。问题在于从机地址匹配只需要关心高 7 位也就是 1010 000即 0x50。如果把 I2CADDR 直接写 0xA0最低位的“0”会被当作 W 位主机发出 0xA1 读地址时从机只匹配到 0xA0读方向就永远应答不上。所以初始化里写 0x50硬件会在收到地址字节时自动把最低位剥离7 位匹配成功后根据 R/W 位产生对应状态码。如果以后要挂多颗模拟从机把 A2/A1/A0 接不同电平只需要把 I2CADDR 改成 0x50 加上对应位偏移即可。这个地址映射关系写清楚后后面所有行为验证都建立在这颗从机地址为 0x50 的基础上。3. 核心模块拆解I2C从机状态机与AT24C02读写完整链路3.1 外围数据结构用一块256字节数组代替EEPROM存储区从机程序的核心是一块 256 字节的 uint8_t 数组它扮演 AT24C02 内部存储阵列。所有读写操作最终都落在这个数组上主机侧感知不到“这是一颗单片机”这件事。typedef enum { SLAVE_IDLE 0, SLAVE_WAIT_ADDR, /* 刚收到SLAW等待第一个字地址字节 */ SLAVE_WRITE_DATA, /* 已收到字地址后续字节为写入数据 */ SLAVE_READ_DATA /* 当前处于读方向 */ } SLAVE_STATE; static volatile SLAVE_STATE slave_state; static volatile uint8_t addr_received; /* 是否已收到字地址 */ static volatile uint8_t wr_bytes; /* 本次事务已写入的字节数 */SLAVE_STATE 由中断服务程序维护主循环只负责读它和清理写周期标志。这里必须加 volatile因为中断和主循环共享这些变量不声明 volatile 的话优化器可能把值缓存在寄存器里导致主循环读到的永远是旧状态这种玄学问题在调试器里看断点看不出任何异常。word_addr 是地址指针它模拟 AT24C02 内部地址计数器。写方向时第一帧数据是字地址读方向时它决定从哪个地址开始输出。真实芯片里这个地址会跨事务保持所以不能随便清零具体清不清要看当前事务属于哪一条协议分支。3.2 状态机主循环从SLA匹配到STOP的四个关键分支I2C 中断服务程序按状态码分流。N76E003 的硬件 I2C 控制器会给出一组标准状态码这里用数值直接写读者对照自己 BSP 里的宏定义即可。void I2C_ISR(void) interrupt 0 /* 中断号以BSP工程宏为准 */ { uint8_t sta I2CSTATUS; switch (sta) { case 0x60: /* SLAW已收到并回ACK */ word_addr 0; addr_received 0; wr_bytes 0; is_write 1; slave_state SLAVE_WAIT_ADDR; break; case 0xB0: /* SLAR已收到并回ACK */ is_write 0; slave_state SLAVE_READ_DATA; I2CDAT ee_ram[word_addr 0xFF]; /* 准备第一个读字节 */ inc_word_addr(); break; case 0x80: /* 收到一个数据字节并ACK */ if (addr_received 0) { word_addr I2CDAT; /* 第一个字节是字地址 */ addr_received 1; slave_state SLAVE_WRITE_DATA; } else { write_one_byte(I2CDAT); } break; case 0xB8: /* 读数据已发送并收到ACK */ I2CDAT ee_ram[word_addr 0xFF]; inc_word_addr(); break; case 0xA0: /* STOP或重复START */ if (is_write wr_bytes 0) { write_pending 1; /* 进入写周期模拟 */ } addr_received 0; slave_state SLAVE_IDLE; break; default: break; } }0x60 分支是写方向事务的起点。收到 SLAW 后主机接下来发的第一个字节一定是字地址所以这里把 addr_received 清零等待 0x80 分支的第一个数据字节。0xB0 分支是读方向起点直接准备第一个数据字节放入 I2CDAT这样主机在 ACK 之后的第一个时钟沿就能读到有效数据。0xA0 分支表示检测到 STOP 或重复 START。注意我只在“确实写过数据字节”的情况下才置 write_pending这样随机读流程里那个纯粹的“写字地址后紧接收重复START”不会被误判成一次 EEPROM 写操作。这个细节是后面对齐真实芯片行为的关键。3.3 写链路字地址、页写回卷与STOP提交AT24C02 写数据有三种形态单字节写、页写、快速连续写不跨页时。主机发送 SLAW 后跟一个字地址再跟 1 到 N 个数据字节最后 STOP。写入动作分两步传输过程中数据只落在 RAM 数组里STOP 到达后才算“提交”。static void write_one_byte(uint8_t dat) { uint8_t page_base; uint8_t offset; ee_ram[word_addr 0xFF] dat; /* 数据落到存储区 */ wr_bytes; page_base word_addr ~(PAGE_SIZE - 1); offset word_addr (PAGE_SIZE - 1); if (offset PAGE_SIZE - 1) word_addr page_base; /* 页写到达页边界回卷到页首 */ else word_addr; }这里模拟了 AT24C02 页写的回卷行为。真实芯片在页写模式下如果主机一次性写入超过页大小的数据地址不会继续往后翻页而是回到当前页的起始地址继续覆盖写。很多第一次写模拟程序的开发者会在这一步翻车把逻辑写成顺序递增结果跨页数据全落在错误位置。参数 PAGE_SIZE 取 16 是按较新的 AT24C02 规格老一些的规格是 8 字节。这个值可以直接用宏改改成 8 或 32 都行但必须保证是 2 的幂否则word_addr (PAGE_SIZE - 1)这种掩码运算就会出错。STOP 到达后主循环检测到 write_pending 为 1就进入写周期模拟逻辑。真芯片在 STOP 之后会有大概 5ms 的内部写周期期间不响应任何访问模拟实现可以不用阻塞那么死但至少要有一个延时或时间戳机制去模拟这个窗口。3.4 读链路当前地址读、随机读和顺序读读操作是 AT24C02 协议里最容易写错的半边。主机可能直接发 SLAR 做当前地址读也可能先写一个字地址再重复 START 转读方向做随机读还有可能连续读任意长度做顺序读。static uint8_t inc_word_addr(void) { word_addr; word_addr 0xFF; /* 容量256字节地址回绕到0 */ return word_addr; }当前地址读的精髓是读出的地址是上一次访问后地址计数器留下的位置。所以每次读写完一个字节word_addr 都要递增。写方向递增的规则分页写和单字节写两种上面已经处理读方向就是简单的加一然后回绕。随机读流程在中断里是这样串起来的主机先发 SLAW0x60 分支清空状态主机发字地址0x80 分支把它存入 word_addr主机发重复 START 并转 SLAR0xA0 分支进来发现 wr_bytes 为 0、不触发写周期随后 0xB0 分支直接按刚保存的 word_addr 取数发送。这个过程只要漏了任何一步读出来的就是上一个事务残留地址的数据。顺序读不需要额外状态主机在收到每个字节后继续回 ACK从机每次进 0xB8 分支就把下一个地址的数据装进 I2CDAT主机回 NACK 后0xC0 分支会把事务收掉。我的经验是把 0xB0、0xB8、0xC0 三个分支按“准备数据、发送数据、事务结束”三个阶段理解读链路就不会乱。3.5 写周期模拟5毫秒窗口让行为更像真芯片真实 AT24C02 在 STOP 后进入内部写周期典型时间是 5ms期间外部访问不会得到正常响应。模拟程序不做这个窗口主机上电测试可能没问题但如果某个驱动严格按照芯片手册在写完后延时 5ms 再读就会出现一个微妙差异写在读之前已经完成了主机读到的永远是新值掩盖不了任何位级问题。我一般在主循环里放一个基于滴答时钟的检查void ee_process_write_cycle(void) { static uint16_t wc_start; if (write_pending 0) return; if (wc_start 0) wc_start get_tick_ms(); /* 记录STOP时间 */ if ((uint16_t)(get_tick_ms() - wc_start) 5) { write_pending 0; wc_start 0; } }这个函数的逻辑很简单第一次检测到 write_pending 时记下时间戳之后每次主循环进来判断是否满 5ms满了才清标志。不要用 delay_ms(5) 在中断里硬等那会把 I2C 从机整个卡死用时间戳是非阻塞写法主循环继续跑只是在这 5ms 窗口内对地址匹配做一些限制。如果追求更接近真芯片的行为可以在 write_pending 期间在 0xA0 分支里直接不回复 ACK 或返回 NACK具体要看主机驱动对 NACK 的处理方式。大多数场景下时间戳窗口已经足够因为主机驱动本来就会在自己的写周期延时里等待。4. 常见问题排查与避坑跑在不同主机上的四类真实踩坑记录4.1 地址扫描不到设备I2CADDR高低位与引脚复用现象主机侧 i2cdetect 扫描所有地址0x50 始终不出现总线其他从机正常。原因一般有两个。第一个是我自己反复撞过的I2CADDR 被写成了 0xA0而不是 0x50。地址字节是 8 位传输最低位是读写标志7 位匹配只看高 7 位写 0xA0 会让读方向地址 0xA1 匹配不上。第二个原因是引脚复用没有打开N76E003 的 I2C 功能引脚出厂默认可能是普通 GPIOSCL/SDA 上的信号根本没进 I2C 模块。解决先把 I2CADDR 改成 0x50再用示波器或逻辑分析仪抓 SDA看主机发起 START 后从机有没有在第 9 个时钟拉低 SDA 回 ACK。如果一直没 ACK再查引脚复用配置和上拉电阻是否接错。4.2 随机读永远读到0xFFSLAR分支没有准备首字节现象主机执行“写地址后重复 START 再读”的随机读流程读回来的数据全是 0xFF但用当前地址读又能读出正确值。原因是 0xB0 分支只修改了状态没有把第一个数据字节提前装入 I2CDAT。硬件 I2C 从机在 SLAR ACK 之后主机立刻开始产生时钟沿接收数据如果此时发送寄存器是空的从机输出的是上次残留的数据通常就是 0xFF。当前地址读如果恰好在上一次写操作之后执行I2CDAT 里可能碰巧有值所以随机读才暴露出来。解决在进入 0xB0 分支时马上执行I2CDAT ee_ram[word_addr 0xFF]并且把 word_addr 递增到下一个地址。这样从机输出的第一字节就是当前指针位置的数据和真芯片行为一致。4.3 跨页写数据丢失页边界回卷被写成了顺序递增现象用 write_i2c_block_data 从字地址 0x00 连续写 32 字节读出来 0x00 到 0x0F 是正常的前 16 字节0x10 到 0x1F 是乱码或旧数据。原因模拟代码在每次写完后直接 word_addr真实 AT24C02 页写跨过 16 字节边界时会回卷到页首继续覆盖写。0x0F 写完后真实芯片的地址计数器变成 0x00接下来写的字节会覆盖页首而顺序递增的实现会写进 0x10造成主机视角的“丢失”。解决按 3.3 节的 write_one_byte 实现用页基址加页内偏移计算回卷位置。这里特别提醒PAGE_SIZE 必须保持为 2 的幂否则掩码运算失效。调试时可以用主机脚本从 0x07 开始连续写 16 字节读回验证回卷位置是否落在 0x00。4.4 400kHz总线偶发错位上拉电阻和中断延迟的影响现象总线从 100kHz 换成 400kHz 后主机偶尔读到单个位错误逻辑分析仪上看波形ACK 之后 SDA 切换的边沿明显比正常时序晚。原因从机中断响应存在延迟如果 I2C 中断优先级低于其他频繁触发的中断数据字节准备不及时同时上拉电阻如果偏大SCL 上升沿变缓在高速率下会在边沿处出现毛刺或采样窗口偏移。解决把 I2C 中断设为最高优先级ISR 里只做状态转移和数据装载不做任何复杂运算上拉电阻从 4.7k 换成 2.2k。如果主机和从机之间有长线连线还要把线距缩短或加上拉电阻调整到 1k 级别试一下。4.5 写完立刻读返回旧值缺少写周期状态模拟现象主机执行单字节写STOP 后立即用当前地址读读回来的还是旧数据但主机延时 10ms 后读数据又是新的。原因模拟程序的 RAM 数组在数据字节收下时就已经更新STOP 后立即读自然返回新值而不是旧值。真实 AT24C02 写周期内读操作行为未被协议定义很多主机驱动会利用这个阶段做状态判断如果模拟实现太快主机侧的时序判断逻辑会被打乱。解决按 3.5 节加入 write_pending 和时间戳机制。在 5ms 窗口内如果主机发来写地址从机可以丢弃写入并回 NACK或者直接不更新存储区。等窗口结束再接受新的写操作这样从主机视角看这颗“芯片”的行为就和真芯片一致了。5. 参数调整与行为对齐把模拟AT24C02调到和真芯片一样5.1 关键参数设置容量、页大小、设备地址和写周期模拟程序里所有行为参数都集中在四个宏里调整它们就能匹配不同规格的 EEPROM。参数AT24C02 参考值本项目模拟值修改注意事项容量256 字节eeprom_buf[256]改容量要同步调整地址掩码页大小8 / 16 / 32 字节PAGE_SIZE 16必须是 2 的幂设备地址0x507位I2CADDR 0x50与 A2/A1/A0 接法对应写周期5ms典型write_pending 时间戳 5ms高速应用可缩小到 2ms如果想把容量扩到 512 字节模拟 AT24C04word_addr 需要从 uint8_t 改成 uint16_t同时word_addr 0xFF地址掩码做成一个可配置的宏比如EEPROM_SIZE - 1。AT24C04 的地址映射会占掉 A0 位主机侧地址改成 0x50 或 0x52 两个地址选择页这块建议先用 256 字节版本跑通再逐步扩展。页大小参数在主机驱动里通常不可见但会影响批量写入的分块逻辑。如果主机驱动按 8 字节分块写而模拟实现按 16 字节回卷只要单块不超过 8 字节行为完全一致一旦主机用了更大的缓冲区就会出现第 4.3 节的问题所以 PAGE_SIZE 必须和你说宣称的那颗芯片一致。5.2 掉电保存扩展把存储区搬到内部FlashRAM 数组的天然缺陷是掉电丢失。真实 AT24C02 是 EEPROM数据断电不丢如果这个模拟程序要被用在正式产品里就得考虑把热点数据同步到 N76E003 内部 Flash。做法是把存储区映射到 APROM 的末尾保留区域用 IAP 写字节、读字节。常见做法是维护一个内存镜像主循环发现 ee_ram 有变化后先把页数据整体读出来对比变化了再擦除扇区、写入整页避免每次单个字节写都触发一次扇区擦除。N76E003 的 Flash 写入寿命一般按千次到万次级别估算如果主机频繁写同一个地址要加简单的磨损均衡比如地址映射做轮换。这里要注意IAP 操作期间 CPU 会暂停执行如果恰好和 I2C 中断重叠可能导致主机侧超时。我的做法是只在 write_pending 窗口内触发保存反正那 5ms 里从机本来就不响应主机。5.3 验证方法用主机脚本覆盖协议边界把模拟从机接到树莓派或者任何 Linux 主机上用 smbus2 写一个验证脚本比在单片机里打日志高效得多。协议边界主要覆盖三类单字节写在页边界、随机读与当前地址读、跨页连续写。import smbus2 bus smbus2.SMBus(1) ADDR 0x50 # 单字节写读回 bus.write_byte_data(ADDR, 0x10, 0xA5) assert bus.read_byte_data(ADDR, 0x10) 0xA5 # 页写跨页测试从0x07写16字节预期回卷覆盖0x00 data list(range(16)) bus.write_i2c_block_data(ADDR, 0x07, data) block bus.read_i2c_block_data(ADDR, 0x00, 16) print(rollback check:, block)脚本跑完后再用逻辑分析仪分别抓一次 100kHz 和 400kHz 的波形重点看三处随机读前重复 START 的地址字节是不是 0xA1、STOP 后写周期窗口内主机发事务时从机有没有正确回 NACK、跨页写在页边界处的地址回卷波形是不是连续无气泡。这三处和真实芯片行为对比一致就可以认为这颗“假 AT24C02”对齐了。6. 二次开发扩展从“模拟存储芯片”到一个I2C寄存器设备6.1 把字地址变成功能寄存器号模拟 EEPROM 最大的扩展空间是字地址不再只代表存储偏移还可以当作功能寄存器号。真实产品里很多 I2C 传感器就是这么干的——主机写入寄存器地址然后读数据。现在你手里已经有完整的 I2C 从机地址匹配、读写状态机、写周期控制只需要把 ee_ram 的读写操作换成功能分发。比如把低 128 字节保留为存储区高 128 字节映射到外设0x80 寄存器写入控制某个 GPIO 输出状态0x81 寄存器读回 ADC 采样值。主机端驱动不用改只是寄存器地址表变了一下。这个扩展用起来最顺的地方是所有协议细节已经被现有状态机处理好了你只需要在 write_one_byte 和读数据这两个入口加一个地址区间判断。6.2 外设扩展示例与回归测试static uint8_t read_byte_at(uint8_t addr) { if (addr 0x80) return ee_ram[addr]; /* 存储区 */ if (addr 0x80) return gpio_get_output(); /* 反馈IO状态 */ if (addr 0x81) return adc_read_ch0(); /* 返回ADC采样值 */ return 0xFF; }改完这个函数原状态机不需要大动只需要把 0xB0 分支的取数操作从ee_ram[word_addr]换成read_byte_at(word_addr)。写方向同理在 write_one_byte 入口按地址区间分流。每次改动后都跑一遍 5.3 节的验证脚本特别是页写跨页测试要改成只写 0x00 到 0x7F 的存储区防止外设寄存器被误覆盖。从那以后我每次新写 I2C 从机外设都会强制先把 SLAW、SLAR、重复 START、STOP 这四类分支在纸上走一遍代码写得再快不如把卡点提前想清楚。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →