简介围绕24C08 E2PROM在51单片机上的读写实现这份资料面向电子工程初学者、嵌入式开发入门者以及需要复习I²C总线时序的读者重点解决器件寻址、字节读写、掉电保存与开机计数等常见问题。压缩包共16个文件约36KB内有C51源码、Keil工程文件、hex可烧录文件以及lst、m51、obj等编译链接中间文件并附带若干bak备份便于直接打开工程、核对编译结果和回溯修改。配套的“24C08开机计数实验”将读取计数值、加一写回与数码管显示串成完整流程可帮助理解起始与停止信号、应答检测、7位器件地址及读写位、超时与数据冲突处理等关键点也可结合源码逐句对照对读写流程与应答时序建立直观认识。已有1127人学习适合作为I²C存储器入门的可运行参考工程也便于在此基础上改造成参数保存、运行次数统计等小型应用。1. 一块 8 脚小芯片救活整张参数表24C08 读写程序到底解决什么问题前阵子帮一位做温控板的开发者看程序现象很典型设备一断电设定温度、风机档位、累计运行小时数全清零用户每次上电都得重新按键。换 MCU 带 EEPROM 成本上去了加纽扣电池又要考虑更换和漏液最后方案落到一颗 8 脚贴片上——24C081024 字节I2C 两线接口掉电不丢。这份 C51 写的 24C08 读写程序干的就是把「参数存哪儿、怎么存、掉电写坏了怎么办」这几件事一次性收干净。它适合谁手上有 8051 内核板子、用 Keil C51 开发、需要存几十到几百字节配置或标定数据的开发者。也适合拿它当 I2C 时序模板学的人软件模拟两根 GPIO不依赖硬件外设移植到别的单片机上改几个 sbit 就能跑。算不上什么高深技术但真到现场写进去读不出来、读到一半全 0xFF 的坑一个都少不了。2. 协议底座24C08 的块地址映射与 I2C 两线时序拆解2.1 从 24C02 到 24C08被「吃掉」的那两位地址脚很多人第一次用 24C08 会栽在设备地址上。24C02 是 256 字节8 位地址刚好塞满一个字节设备地址低三位 A2/A1/A0 全部用来区分总线上挂的 8 个器件。24C08 是 1024 字节8 位地址装不下厂商的做法是把 A1、A0 两个引脚从「器件地址」改成「页块地址」芯片内部按 256 字节切成 4 块用这两位选中当前访问哪一块。所以 24C08 的地址字节长这样位b7b6b5b4b3b2b1b0含义1010A2P1P0R/W取值固定固定固定固定引脚电平块号高位块号低位0 写 / 1 读A2 仍然由引脚决定所以同一条总线上最多挂两片 24C08A2 接 GND 和 VCC。这一点跟 24C02 完全不同直接照抄 24C02 的驱动去驱动 24C08会得到一个诡异的结果前 256 字节读写正常地址一过 0x00FF 就绕回 0 覆盖掉开头的数据。原因就是地址字节里的块号没跟着变芯片始终认为你在访问第 0 块。把那层映射抽成一个函数后面所有读写都走它逻辑会干净很多#include reg52.h #include intrins.h sbit SCL P1^0; /* 时钟线外接 4.7k 上拉到 VCC */ sbit SDA P1^1; /* 数据线开漏同样 4.7k 上拉 */ #define EE_BASE_ADDR 0xA0 /* 1010 0000A2 接地 */ #define EE_PAGE_SIZE 16 /* 24C08 页大小 16 字节 */ /* 把 0~1023 的线性地址翻译成设备地址字节不含 R/W 位 */ static unsigned char ee_dev_addr(unsigned int addr) { /* addr 的 bit9、bit8 就是块号 P1、P0左移一位落到地址字节的 bit2、bit1 */ return EE_BASE_ADDR | (unsigned char)(((addr 8) 0x03) 1); }ee_dev_addr()只做一件事把线性地址的高两位搬到设备地址字节的 bit2 和 bit1 上。注意(addr 8) 0x03这一步不能省虽然调用方理论上不会传超过 1023 的地址但一旦传了不做掩码就会把块号溢出到 A2 位上表现成「莫名其妙访问不到芯片」。参数用unsigned int而不是unsigned char就是为了放下 10 位地址。后面每次发起传输写操作传ee_dev_addr(addr) | 0x00读操作传ee_dev_addr(addr) | 0x01。2.2 软件模拟 I2C起始、应答、停止三段的电平约定C51 上很少有人用硬件 I2C一是 8051 的硬件 I2C 外设不是标配二是中断一多时序容易被打断。软件模拟的好处是完全可控坏处是延时得自己调。我一般把延时做成宏方便按主频改/* 11.0592MHz 晶振下一个 _nop_() 约 1us4 个约 4us 对应约 100kHz 的 I2C 速率半周期 5us 留一点余量 */ #define I2C_DELAY() do { _nop_(); _nop_(); _nop_(); _nop_(); } while (0)起始和停止是 I2C 里唯一两个「SDA 在 SCL 高电平期间变化」的合法动作记反了整条总线都不应答static void i2c_start(void) { SDA 1; I2C_DELAY(); SCL 1; I2C_DELAY(); SDA 0; I2C_DELAY(); /* SCL 高电平期间 SDA 下降沿 起始条件 */ SCL 0; I2C_DELAY(); /* 拉低 SCL 占住总线准备发数据 */ } static void i2c_stop(void) { SDA 0; I2C_DELAY(); SCL 1; I2C_DELAY(); SDA 1; I2C_DELAY(); /* SCL 高电平期间 SDA 上升沿 停止条件 */ }字节收发是整份程序里被调用最频繁的两段写法上有个细节特别容易翻车发送时要把 SDA 拉高再让从机决定应答读的时候同理主机必须先释放总线。C51 的sbit读回来的是引脚实际电平只要前一步把口锁存器写成 1读到的就是外部状态这点比某些带方向寄存器的 MCU 省事。/* 返回 0 表示收到应答1 表示非应答失败 */ static bit i2c_write_byte(unsigned char dat) { unsigned char i; bit nack; for (i 0; i 8; i) { SDA (dat 0x80) ? 1 : 0; /* 高位先行 */ dat 1; I2C_DELAY(); SCL 1; I2C_DELAY(); SCL 0; I2C_DELAY(); } SDA 1; /* 释放 SDA把应答权交给从机 */ I2C_DELAY(); SCL 1; I2C_DELAY(); nack SDA; /* 从机拉低 应答 */ SCL 0; I2C_DELAY(); return nack; } /* send_ack 1 继续读下一字节 0 表示这是最后一字节 */ static unsigned char i2c_read_byte(bit send_ack) { unsigned char i, dat 0; SDA 1; /* 释放数据线让从机驱动 */ for (i 0; i 8; i) { dat 1; SCL 1; I2C_DELAY(); if (SDA) dat | 0x01; SCL 0; I2C_DELAY(); } SDA send_ack ? 0 : 1; /* 主机应答0 继续1 结束 */ I2C_DELAY(); SCL 1; I2C_DELAY(); SCL 0; I2C_DELAY(); SDA 1; return dat; }参数说明一下i2c_write_byte()的返回值是「非应答位」语义上跟常见的ack 1相反所以调用处统一写成if (i2c_write_byte(...)) { 失败处理 }读起来顺一点。i2c_read_byte()的send_ack参数控制第 9 个时钟周期主机拉低还是释放 SDA最后一个字节必须传 0否则从机会以为你还要读地址指针继续往前挪下一次读的起始位置就错了。2.3 写周期与页边界16 个字节是道硬门槛24C08 内部写不是立即完成的收到停止条件后芯片进入自定时写周期典型 3.5ms、最大 5ms有些批次标 10ms这期间它对任何地址都不应答。新手常犯的错误是写完立刻读读回 0xFF 或旧值然后怀疑时序、怀疑接线、怀疑芯片坏了。正确的做法只有两个一是死等 5~10ms二是做应答轮询。应答轮询更优雅不浪费 CPU反复发起始条件 设备地址只要芯片回 NACK 就说明还在写回 ACK 说明写完了。/* 等待内部写周期结束最多等 20ms 防止芯片异常时死循环 */ static void ee_wait_ready(void) { unsigned int guard 0; do { i2c_start(); if (i2c_write_byte(ee_dev_addr(0) | 0x00) 0) { break; /* 应答了内部写完成 */ } i2c_stop(); /* 没应答必须补停止否则总线卡死 */ delay_ms(1); } while (guard 20); i2c_stop(); }页边界是第二个门槛。24C08 的页大小是 16 字节一次页写最多写 16 个字节而且必须落在同一个 16 字节对齐的页内。如果起始地址是 0x000C写 8 个字节芯片实际只会写到 0x000F 就回卷到 0x0000把开头的 4 个字节覆盖掉——不报错、不应答失败数据静悄悄地错位这种问题在现场最难查。所以页写函数入口必须自己校验长度跨页由上层拆。3. 核心链路拆解从单字节写到跨页缓冲的四个函数3.1 文件划分与对外接口这份 C51 工程结构很简单因为没有操作系统、没有动态内存两个文件就能封完整文件职责关键内容i2c_sim.c/.h纯协议层起始、停止、字节收发、延时宏不含任何 24C08 语义ee24c08.c/.h器件层地址翻译、字节写、页写、缓冲读写、应答轮询param.c/.h应用层参数结构体、校验、默认值、双备份main.c调用示例上电加载参数、修改后保存分层的好处很直接换主控只需改i2c_sim.c里的 sbit 和延时换芯片比如 24C32只需改ee24c08.c里的地址翻译应用层一行不动。对外暴露的接口我一般只留四个bit ee_write_byte(unsigned int addr, unsigned char dat); /* 单字节写成功返回 0 */ bit ee_read_byte(unsigned int addr, unsigned char *dat); /* 随机读 */ bit ee_write_buffer(unsigned int addr, const unsigned char *buf, unsigned int len); bit ee_read_buffer(unsigned int addr, unsigned char *buf, unsigned int len);返回bit而不是void是因为 EEPROM 是真的会坏的擦写次数用尽、写保护脚没接对、总线被其他器件拉住这些都应该让上层知道而不是假装成功。3.2 写路径起始、设备地址、字地址、数据、停止单字节写是整条链路的最小闭环也是最容易漏 stop 的地方。每一段i2c_write_byte()失败都必须补i2c_stop()再返回否则总线停在数据相位后续任何操作都进不去。bit ee_write_byte(unsigned int addr, unsigned char dat) { i2c_start(); if (i2c_write_byte(ee_dev_addr(addr) | 0x00)) { /* 设备地址 写 */ i2c_stop(); return 1; } if (i2c_write_byte((unsigned char)(addr 0xFF))) { /* 字地址低 8 位 */ i2c_stop(); return 1; } if (i2c_write_byte(dat)) { /* 数据 */ i2c_stop(); return 1; } i2c_stop(); ee_wait_ready(); /* 等内部写周期否则紧接着读会失败 */ return 0; }三个参数的含义addr是 0~1023 的线性地址高两位在ee_dev_addr()里被搬到设备地址字节低 8 位作为字地址发出——注意 24C08 只发一个字节的字地址这一点跟 24C32/24C64 发两个字节不同后面迁移时要改。dat是要写的字节。返回值 0 成功。有人会问既然有页写为什么还留单字节写因为参数表里经常是「改一个温度值立刻保存」写一个字节走页写反而多一次长度判断。代价是每次都要等 5ms如果一秒钟改十次总线上就浪费 50ms。这种情况我一般加一层 RAM 缓存 延时落盘不在这份驱动里做交给应用层。3.3 读路径重复起始与「读到最后必须 NACK」读操作比写多一个动作——重复起始条件Repeated START。因为你要先「写」设备地址和字地址把内部地址指针定位好再「读」数据中间不能发停止条件否则指针复位。bit ee_read_buffer(unsigned int addr, unsigned char *buf, unsigned int len) { unsigned int i; if (len 0) return 0; i2c_start(); if (i2c_write_byte(ee_dev_addr(addr) | 0x00)) { i2c_stop(); return 1; } if (i2c_write_byte((unsigned char)(addr 0xFF))) { i2c_stop(); return 1; } i2c_start(); /* 重复起始地址指针保持不变 */ if (i2c_write_byte(ee_dev_addr(addr) | 0x01)) { i2c_stop(); return 1; } for (i 0; i len; i) { buf[i] i2c_read_byte((i (len - 1)) ? 0 : 1); } i2c_stop(); return 0; }(i len - 1) ? 0 : 1这个三元表达式是整个读路径的核心除了最后一字节其余全部回 ACK。如果这里写成常量 1芯片会持续输出你在停止条件之后读到的是总线上的浮动值写成常量 0只能读到一个字节后面的全是 0xFF。还有个隐藏细节顺序读在芯片内部会自动递增地址但不会跨块。从 0x00FE 开始读 8 个字节读到 0x00FF 之后指针会回卷到 0x0000仍在第 0 块内而不是跳到 0x0100。所以ee_read_buffer()一次调用不要跨 256 字节边界跨了就由上层拆成两次调用每次重新定位。这个坑我在第 5 章会展开。随机读就是ee_read_buffer()传len 1的特例不用单独写函数省一份代码也少一处 bug。3.4 跨页写把长缓冲拆成「页内片段」的循环页写函数只负责一页之内的事长度不合法直接拒绝把拆页的责任交给它的调用者。这种「下层严格、上层编排」的分法比在页写里偷偷处理跨页要好调试得多。bit ee_write_page(unsigned int addr, const unsigned char *buf, unsigned char len) { unsigned char i; unsigned int room EE_PAGE_SIZE - (addr % EE_PAGE_SIZE); if (len 0 || len room) return 1; /* 超页就拒绝由上层拆 */ i2c_start(); if (i2c_write_byte(ee_dev_addr(addr) | 0x00)) { i2c_stop(); return 1; } if (i2c_write_byte((unsigned char)(addr 0xFF))) { i2c_stop(); return 1; } for (i 0; i len; i) { if (i2c_write_byte(buf[i])) { i2c_stop(); return 1; } } i2c_stop(); ee_wait_ready(); return 0; }room EE_PAGE_SIZE - (addr % EE_PAGE_SIZE)算的是「从当前地址到本页末尾还剩几个字节」。起始地址 0x000C 时room 4最多能写 4 个字节。外层的缓冲写就是个 while 循环每次吃掉当前页剩余的空间bit ee_write_buffer(unsigned int addr, const unsigned char *buf, unsigned int len) { while (len 0) { unsigned int room EE_PAGE_SIZE - (addr % EE_PAGE_SIZE); unsigned char n (len room) ? (unsigned char)room : (unsigned char)len; if (ee_write_page(addr, buf, n)) return 1; addr n; buf n; len - n; } return 0; }这段代码的价值在于调用方写ee_write_buffer(0x0000, param, sizeof(param))就够了不用关心参数表是 20 字节还是 60 字节、起始地址有没有对齐。每跨一页多付一次 5ms 写周期60 字节大概要 4 次页写20ms 左右对绝大多数参数保存场景完全可以接受。4. 上层封装把 24C08 做成一份带校验的断电参数表4.1 参数结构体与偏移分配裸读写函数只是工具真正决定这套代码好不好用的是上层怎么组织数据。我习惯把整个 EEPROM 当成一张表来规划而不是随机往地址里塞变量#define EE_PARAM_ADDR 0x0000 /* 主参数区占 0x0000~0x002F */ #define EE_BACKUP_ADDR 0x0040 /* 备份区占 0x0040~0x006F */ typedef struct { unsigned char magic; /* 0x5A 本区已初始化 */ unsigned char ver; /* 参数结构版本升级时判断是否要迁移 */ signed char set_temp; /* 目标温度-20~80 */ unsigned char fan_level; /* 风机档位 0~3 */ unsigned char mode; /* 运行模式 */ unsigned int run_hours; /* 累计运行小时 */ unsigned char sum; /* 前面所有字节的累加和 */ } sys_param_t; sys_param_t g_param; /* RAM 里的工作副本 */偏移分配上留了两个空档0x0030~0x003F和0x0070~0x00FF预留给后续加字段0x0100以后可以放标定数据。表格规划好之后写进ee24c08.h的注释里半年后回来改也不至于忘。读写时统一转成unsigned char *因为 C51 的结构体可能有对齐填充虽然这份结构体全是 char/int 不会填但用指针遍历更稳妥bit param_save(void) { g_param.sum param_calc_sum(g_param); if (ee_write_buffer(EE_PARAM_ADDR, (unsigned char *)g_param, sizeof(g_param))) return 1; /* 备份区同步写一份 */ if (ee_write_buffer(EE_BACKUP_ADDR, (unsigned char *)g_param, sizeof(g_param))) return 1; return 0; }4.2 校验、默认值与首次上电初始化EEPROM 出厂状态是全 0xFF用户第一次上电读出来的magic不可能是 0x5A。加载函数必须能区分三种情况数据有效、数据无效、读取失败。static unsigned char param_calc_sum(const sys_param_t *p) { const unsigned char *q (const unsigned char *)p; unsigned char i, s 0; for (i 0; i sizeof(sys_param_t) - 1; i) s q[i]; return s; } void param_load(void) { if (ee_read_buffer(EE_PARAM_ADDR, (unsigned char *)g_param, sizeof(g_param)) 0 g_param.magic 0x5A g_param.sum param_calc_sum(g_param)) { return; /* 主区有效直接用 */ } if (ee_read_buffer(EE_BACKUP_ADDR, (unsigned char *)g_param, sizeof(g_param)) 0 g_param.magic 0x5A g_param.sum param_calc_sum(g_param)) { return; /* 主区坏了从备份恢复 */ } param_set_default(); /* 两边都不可用回默认值 */ param_save(); } static void param_set_default(void) { g_param.magic 0x5A; g_param.ver 1; g_param.set_temp 25; g_param.fan_level 1; g_param.mode 0; g_param.run_hours 0; }累加和比 CRC16 弱但胜在判断逻辑极其简单、不怕字节序参数表只有十几个字节时完全够用。如果存的是标定系数这种对位翻转敏感的数据我会换成 CRC8 查表代价是多 256 字节 ROM。param_load()的判断顺序值得注意先看读取是否成功再看 magic最后校验和。三步里任何一步失败都往下一级降级。写这段的时候别图省事把条件合并成一个if出问题时无法定位到底是总线挂了还是数据坏了等于把黑匣子留给自己。4.3 掉电写坏的两道保险参数保存最怕的是「写到一半掉电」。24C08 单次页写有内部时序保护一页 16 字节要么全成要么全不成但一个 20 字节的参数表要跨两页写完第一页掉电就会出现新旧数据各一半的状态校验和必然对不上——这时候备份区的价值就体现出来了。更稳的做法是加个版本号每次保存递增加载时比较主区和备份区谁的版本更新/* 在结构体里加一个 unsigned char seq; 保存时自增 */ g_param.seq;主区有效但seq比备份区旧说明上一次保存是在写主区的过程中被打断的应该以备份区为准。这套机制比纯校验和多花不了几行代码但把「掉电打断写入」这个玄学问题变成了可判断的状态。另外别忘了写保护脚。24C08 的 WP 脚拉高时芯片进入只读模式字节写会「正常应答」然后一个字节都不写进去——这是最坑的一种失败因为总线时序完全正确你从协议层查不出任何问题。板上如果没有 GPIO 控制 WP直接接地别悬空。5. 排查清单读回 0xFF、写入无应答、数据错位的五种现场5.1 现象全片读回 0xFF写也不报错原因最常见的是 WP 引脚悬空或拉高。悬空时引脚电平不确定有些批次读出来是随机的有些干脆一直进写保护。其次是 SDA 上拉电阻漏贴靠 MCU 内部弱上拉勉强能跑起始条件一到读数据相位就拉不高读回全 1。解决先拿万用表量 WP 脚确认是 0V再量 SCL、SDA 静态电平都应该是接近 VCC。上拉电阻 4.7k 是常用值总线长了或挂的器件多降到 2.2k。软件上没什么可调的这类问题一定是硬件。5.2 现象前 256 字节正常往后的数据一写就把开头覆盖掉原因地址翻译没做块映射设备地址字节的 P1、P0 始终是 0芯片认为你一直在访问第 0 块。地址传到 0x0100 时字地址字段变成 0x00正好绕回开头。解决检查ee_dev_addr()里有没有((addr 8) 0x03) 1这一段。另外别用 24C02 的驱动直接套 24C08两者的地址语义完全不一样前者低三位全是器件地址后者低两位是块地址。5.3 现象写完立刻读读回旧值或 0xFF延时 10ms 后再读就对了原因没等内部写周期。24C08 收到停止条件后进入自定时写典型 3.5ms、最大 5ms这期间它对所有地址都不应答你的读请求会被无声丢弃。解决在任何写操作之后调用ee_wait_ready()做应答轮询或者简单粗暴地delay_ms(10)。前者省时间后者省脑子量产的板子上我一般两个都留着用宏切换。5.4 现象缓冲区读写时数据整体错位比如第 5 个字节跑到了第 0 个原因页写起始地址没对齐写到页尾后芯片内部回卷到本页开头把前面的数据覆盖了。0x000C 起写 8 字节实际效果是写了 0x000C~0x000F 和 0x0000~0x0003 两段。解决坚持用ee_write_buffer()而不是直接调ee_write_page()让拆页逻辑集中在while循环里。如果出于性能考虑要直接调页写入口那句len room的检查千万别删。5.5 现象读参数偶发校验失败重启又好了原因顺序读跨了 256 字节块边界。读取过程中地址指针在块内自动递增到块尾回卷到本块开头不会跳到下一块于是读到的是本块开头的旧数据校验和自然对不上。解决单次读长度控制在块内跨块时显式拆成两次调用每次都重新发设备地址和字地址。参数表放在 0x0000 起的连续区域能规避这个问题但如果是遍历整片做自检就必须按块拆。6. 二次开发换 24C32 与多器件共用总线时必须改的三处项目做着做着容量不够是最常见的事参数表加上标定数据、日志、设备序列号1024 字节很快就见底。换成 24C324096 字节或 24C64改动其实只有三处但不改就完全不工作。改动点24C0824C32 / 24C64设备地址字节1010 A2 P1 P0 R/W1010 A2 A1 A0 R/W全部是器件地址字地址长度1 字节2 字节先高后低页大小16 字节32 字节跨块行为按 256 字节分块回卷地址连续递增不回卷地址翻译函数直接退化成常量容量的扩展全落到字地址上/* 24C32设备地址固定 0xA0字地址 2 字节 */ static bit ee_send_addr(unsigned int addr, bit read_op) { i2c_start(); if (i2c_write_byte(0xA0 | (read_op ? 0x01 : 0x00))) { i2c_stop(); return 1; } if (i2c_write_byte((unsigned char)(addr 8))) { i2c_stop(); return 1; } /* 高字节 */ if (i2c_write_byte((unsigned char)(addr 0xFF))) { i2c_stop(); return 1; } /* 低字节 */ return 0; }EE_PAGE_SIZE从 16 改成 32ee_write_buffer()里的拆页逻辑一个字不用动——这正是当初把页大小做成宏的原因。读函数里那个「跨 256 字节回卷」的注意事项也随芯片消失24C32 以上地址是连续递增的靠一个停止条件就能一口气读完。第二处要留神的是多器件共用总线。24C08 靠 A2 只能挂两片工程上一旦还要挂 RTC、传感器地址冲突是血泪经验级的坑某些 RTC 芯片固定地址段跟 EEPROM 重合两个器件同时应答读回来的数据是线与的结果看着像随机噪声。我一般的做法是把所有 I2C 器件的地址列成一张表贴在i2c_sim.h顶部加器件之前先对一遍。第三处是速率。软件模拟的I2C_DELAY()是按 100kHz 算的24C32 支持 400kHz把延时从 4 个 nop 降到 2 个 nop 能明显加快大块写入速度但前提是上拉电阻和走线扛得住飞线板子上跑 400k 必翻车。改之前先用示波器看一眼上升沿别信示波器就用原参数。从那以后我每次改这类驱动都强制先在 RAM 里做一遍「写—读—比对」的回环测试全通过了再接真实参数三行代码能挡掉九成的地址映射和长度错误。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?