24C256 EEPROM页写边界、地址回卷与I2C时序实践
简介:围绕24C256存储器使用整理的这份中文资料,面向单片机开发初学者及需要调试I2C EEPROM的工程师,重点讲清容量与16位地址划分、页内/页地址关系、连续写跨页覆盖等易错点,并给出MSP430F449平台下IIC延时、启动停止、发送0/1、读写字符与应答检测等基本控制模块。资源包仅含1个doc文件,约33KB,篇幅集中,适合作为24C256地址管理与底层时序的速查笔记。已有1916人学习下载,说明其在实际项目排错中具有一定参考价值。读者可据此理解32768字节存储空间的组织方式,掌握连续存储时按64、32、16、8、4、2对齐及不足补零的处理思路,减少跨页写入导致的数据覆盖问题,并对照代码模块梳理I2C通信的起停、收发与ACK流程。
1. 24C256 的 32768 字节不是一块连续画布:先认清 15 位地址模型
去年帮人调一块采集板,30 字节一帧的数据写进 24C256,前两帧读出来完全正确,第三帧开始整体错位,现场一度以为是电源纹波在捣乱。最后把写入地址打印出来才看明白:不是器件坏了,是地址模型没吃透。24C256 是 I2C 接口、512 页 × 64 字节的 EEPROM,有效容量 32768 字节,总线协议却给了 16 位地址,其中只有低 15 位真正参与译码,低 6 位是页内偏移(0~63),中间 9 位是页号(0~511)。它形式上是一段连续地址,物理上却是 512 个互不相通的 64 字节格子。谁在写裸机位操作 I2C、谁从 24C02 那种单字节地址器件迁移过来,只要跨过一次页边界,一样会出问题。
2. 页写边界:64 字节一页,地址回卷是怎么吃掉数据的
24C256 最反直觉的地方在于:连续写模式下,器件内部的地址计数器只递增低 6 位,溢出后不向页号进位,而是回到本页 0 地址重新开始。总线上的地址看起来仍然在往前走,落盘位置却已经绕回去了。所以「地址每次加 N、看上去很整齐」这件事,完全不能保证数据不互相覆盖。
2.1 从 63 到 64 的那一步,到底发生了什么
把地址拆成二进制看最清楚。63 是0b0000_0000_0011_1111,64 是0b0000_0000_0100_0000。63 的低 6 位全为 1,表示第 0 页最后一个字节;64 的低 6 位归零、页号位加一,表示第 1 页第一个字节。在被动写入路径上,这两个地址是两套完全不同的页选择;但在器件内部的连续写状态机里,它只认低 6 位这个 0~63 的指针。
假设你从地址 0x003F 开始连续发两个字节:第一个字节落在 0x003F,指针加一变 64,越过 6 位边界后回卷成 0,第二个字节直接写到 0x0000。你原本以为它写到 0x0040,结果它把第 0 页开头的数据覆盖了,而且写完之后读 0x0040 依然是旧值,读 0x0000 却是新值。这种错位最难查,因为读回的每一帧单看都合法,只有把整片 dump 出来对比地址才发现错位。
原文举的 11 字节步进是个更隐蔽的例子。地址序列是 0、11、22、33、44、55,看着每次加 11 毫无问题。但 55 这一块只有 55~63 共 9 个字节空间,第 10 和第 11 个字节回卷到 0x00 和 0x01,正好把第一块的前两字节冲掉。也就是说,前五块写得好好的,第六块一落笔,历史数据就没了。这是典型的「错误延迟暴露」,写入动作本身没错,错在它破坏了更早的正确数据。
注意:页回卷只发生在写路径。读路径的地址计数器是 15 位全宽递增的,可以一路从 0 读到 0x7FFF 再绕回 0,不会在 64 字节边界打转。很多人口口相传「读写都要按页切」,其实是把写的限制错安到读头上,白白把一次连续读拆成几十次事务。
2.2 块大小只取 64/32/16/8:把 30 字节按 32 存,尾部补零
解决办法不复杂,核心是把「逻辑记录」映射到「页对齐槽位」。如果一帧有效数据是 30 字节,就在存储层把它塞进 32 字节的槽,多余的 2 字节补零。槽大小必须取 2 的幂,且不大于 64,这样任意第 index 个槽的起始地址天然满足index × 32 % 32 == 0,永远不会骑在页边界上。
原文给的建议是 64、32、16、8、4、2,这几个数不是随便挑的:只有 2 的幂才能在 64 以内整除,保证第 N 块的起点对齐到块大小,第 N+1 块才不会从半页中间开始。像 11 这种非 2 的幂步进,无论从哪个地址起步,迟早会撞上页尾。
| 槽大小 | 单页可放槽数 | 起始地址约束 | 30 字节帧的实际占用 | 每帧浪费 |
|---|---|---|---|---|
| 64 | 1 | addr % 64 == 0 | 64 字节 | 34 字节 |
| 32 | 2 | addr % 32 == 0 | 32 字节 | 2 字节 |
| 16 | 4 | addr % 16 == 0 | 32 字节(两槽) | 2 字节 |
| 8 | 8 | addr % 8 == 0 | 32 字节(四槽) | 2 字节 |
| 1 | 64 | 无 | 30 字节 | 0 字节 |
从表里能看出取舍:槽越大,跨页风险越小、地址计算越简单,但空间浪费越多;槽越小越省空间,可事务次数上去了,一帧要拆成好几次写,每次都要等写周期,吞吐直接掉下来。我的习惯是取「不小于有效长度且不大于 64 的最小 2 的幂」,30 字节就取 32,既省空间又只写一次。
#define PAGE_SIZE 64u #define FRAME_LEN 30u #define SLOT_SIZE 32u /* 必须是 2 的幂, 且 <= PAGE_SIZE */ /* 把一帧有效数据装进槽位, 尾部补零 */ void pack_frame(unsigned char *slot, const unsigned char *frame) { unsigned char i; for (i = 0; i < SLOT_SIZE; i++) /* 先整槽清零, 保证补零结果确定 */ slot[i] = 0x00; for (i = 0; i < FRAME_LEN; i++) /* 只覆盖有效部分, 尾部保持 0 */ slot[i] = frame[i]; } /* 帧序号换算绝对地址, 编译期即可确定, 不需要运行时累加 */ unsigned int frame_addr(unsigned int index) { return index * SLOT_SIZE; /* SLOT_SIZE 是 2 的幂, 乘法等价于左移 */ }pack_frame里先整槽清零再填有效数据,是为了让补零字节的内容确定。如果只在尾部补零而槽是从栈上来的,前一次调用的残留数据会混进去,做 CRC 校验时又是一场玄学。frame_addr用乘法而不是维护一个全局递增地址,目的是让地址只依赖帧序号,掉电重启后不会因为计数器丢失而错位。参数上唯一要盯的是上限:SLOT_SIZE为 32 时最多 1024 帧,index超过这个值就绕回 0 覆盖最早的数据,环形日志的语义要提前想清楚。
3. 位操作模拟 I2C:MSP430 上五个基础原语的时序拆解
手上有硬件 I2C 外设当然优先用外设,但很多老设计把两个引脚分给了别的功能,只能靠 GPIO 翻转模拟。原文给的 MSP430F449 版本就是这条路子:P2.1 当 SDA、P2.2 当 SCL,靠改方向寄存器来模拟开漏输出。这套写法在 8 位机和 16 位机上很常见,坑也集中在这里。
3.1 用方向寄存器模拟开漏:为什么释放总线是「切输入」
真正的 I2C 引脚是开漏结构,只能主动拉低,不能主动拉高,高电平靠外部上拉电阻。普通 GPIO 是推挽的,输出高就是硬顶一个高电平。如果从机此时正在拉低总线应答,两边对顶,轻则波形畸变,重则瞬时大电流烧引脚。所以模拟 I2C 的标准做法是:要拉低,就把引脚配成输出、输出锁存器写 0;要释放,就把引脚配成输入,让它变成高阻,由上拉电阻把线拉回高电平。
MSP430 的方向寄存器正好支持这个语义:P2DIR某位置 1 是输出,清 0 是输入。原文里P2DIR &= 0xFD是清 bit1,等价于P2DIR &= ~0x02,即把 SDA 切成输入(释放);P2DIR |= 0x02是把 SDA 切成输出(拉低)。SCL 对应的 bit2 同理,0xFB是清 bit2,0x04是置 bit2。
#define SDA_BIT 0x02 /* P2.1 */ #define SCL_BIT 0x04 /* P2.2 */ /* 释放 SDA: 方向切输入, 由上拉电阻把线拉高 */ #define SDA_RELEASE() do { P2DIR &= ~SDA_BIT; } while (0) /* 拉低 SDA: 先把输出锁存器清 0, 再切输出 */ #define SDA_LOW() do { P2OUT &= ~SDA_BIT; P2DIR |= SDA_BIT; } while (0) #define SCL_RELEASE() do { P2DIR &= ~SCL_BIT; } while (0) #define SCL_LOW() do { P2OUT &= ~SCL_BIT; P2DIR |= SCL_BIT; } while (0)提示:
SDA_LOW()里那句P2OUT &= ~SDA_BIT不是多余动作。如果输出锁存器里残留的是 1,切成输出的瞬间引脚会先顶出一个高电平,与从机的下拉形成对顶,波形上会出现一个几十纳秒的尖峰。短接时间极短时通常看不出来,但在从机应答窗口里出现,就可能被误判成 ACK 或 NACK。
原文的start_IIC()里还有一句P2OUT &= 0xf9,作用是把 P2.1、P2.2 的输出锁存预置为 0,同时不影响其他位。这句和上面的宏是同一个意思,只是写法更紧凑。移植到别的芯片时,这一步千万不能省,很多「换了个 MCU 就通信不上」的问题就出在复位后 GPIO 默认电平不同。
3.2 起始位、停止位、读位与 ACK 采样窗口
I2C 的五个基础动作,本质上都是「SCL 处于某个电平时,SDA 做一次跳变或保持」。起始位是 SCL 为高时 SDA 由高变低;停止位是 SCL 为高时 SDA 由低变高;数据位是 SCL 为低时改 SDA、SCL 为高时采样;应答位是主机发完 8 位后释放 SDA,在第 9 个时钟高电平期间读 SDA 是否为低。
/* 半周期延时: 直接决定 SCL 频率, 必须按实际 MCLK 校准 */ static void iic_delay(void) { _NOP(); _NOP(); _NOP(); } void iic_start(void) { SDA_RELEASE(); SCL_RELEASE(); iic_delay(); SDA_LOW(); /* SCL 保持高, SDA 下降沿 = START */ iic_delay(); SCL_LOW(); /* 占住总线, 准备发第一位 */ } void iic_stop(void) { SDA_LOW(); iic_delay(); SCL_RELEASE(); iic_delay(); SDA_RELEASE(); /* SCL 为高, SDA 上升沿 = STOP */ iic_delay(); } void iic_send_byte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { if (dat & 0x80) SDA_RELEASE(); else SDA_LOW(); dat <<= 1; iic_delay(); SCL_RELEASE(); /* 从机在 SCL 高电平期间采样 */ iic_delay(); SCL_LOW(); } } unsigned char iic_read_byte(void) { unsigned char i, dat = 0; SDA_RELEASE(); /* 读之前必须放开 SDA, 否则和从机对顶 */ for (i = 0; i < 8; i++) { iic_delay(); SCL_RELEASE(); iic_delay(); dat <<= 1; if (P2IN & SDA_BIT) dat |= 0x01; SCL_LOW(); } return dat; }iic_delay是整个模块里最容易被忽略、又最影响成败的参数。MSP430 的_NOP()是一个 MCLK 周期,配上函数调用、返回和位操作的开销,实际半周期远大于注释里写的数字。按 100kHz 总线算,半周期需要 5 微秒;MCLK 为 1MHz 时,三个 NOP 加调用开销大约 6~8 微秒,勉强合格;一旦把 MCLK 拉到 8MHz,半周期掉到 1 微秒以内,SCL 频率冲到 400kHz 以上,短走线或许还能跑,长排线一定出问题。所以这个宏不能用#define生吞,最好上示波器量一次 SCL 周期,或者用一个volatile计数循环把延时写成可调参数。
应答检测同理,原文的while((P2IN & BIT1) == BIT1);是死等,从机不在线就永远出不来。我一般会改成带超时的版本,顺便把返回值带出去,方便上层定位到底是地址没应答还是数据没应答:
/* 返回 0 = 收到 ACK, 1 = 超时无应答 */ unsigned char iic_wait_ack(void) { unsigned int timeout = 2000; unsigned char ret = 1; SDA_RELEASE(); iic_delay(); SCL_RELEASE(); iic_delay(); while (timeout--) { if ((P2IN & SDA_BIT) == 0) { ret = 0; break; } /* 从机拉低 = ACK */ } SCL_LOW(); return ret; }超时计数值 2000 不是随便写的,它要大于一个完整字节传输所需的循环次数,又不能大到让故障响应慢到不可接受。按 100kHz 估算,9 个时钟约 90 微秒,2000 次空转在 1MHz 主频下大约几毫秒,足够覆盖应答窗口,又能在器件掉线时快速返回。
4. WriteNbyte 与 ReadNbyte:双字节地址和高低位顺序
24C256 和 24C02 最大的接口差异在于地址宽度。24C02 只需发一个字节的地址,24C256 要发两个字节,先高后低。这一条听起来简单,实际是最常见的移植失败原因:代码从 24C02 抄过来,函数签名都没改,只发了一个地址字节,器件把第二个字节当成数据直接写进去了,结果是地址错位、数据错乱,读回来一堆看似合理实则无意义的值。
4.1 写时序:0xA2、高地址字节、低地址字节、数据、tWR
一次页写事务的完整序列是:起始位、设备地址加写标志、等 ACK、地址高字节、等 ACK、地址低字节、等 ACK、若干数据字节、每字节等 ACK、停止位、等待内部写周期结束。设备地址的高 4 位固定为1010,低 3 位由 A2/A1/A0 引脚电平决定,最低位是读写标志。原文用的0xA2对应1010 001 0,即三根地址脚接成了 001,读地址就是0xA3。这个值不是常量,板上改一次跳线帽就得跟着改。
#define DEV_ADDR_W 0xA2u /* 1010 A2A1A0 0, 此处 A2A1A0 = 001 */ #define DEV_ADDR_R 0xA3u /* 返回 0 成功, 1 总线无应答, 2 参数错, 3 跨页 */ unsigned char ee_write(unsigned int addr, const unsigned char *buf, unsigned char len) { unsigned char i; if (len == 0 || len > 64) return 2; /* 单次不能超一页 */ if (((addr & 0x3F) + len) > 64) return 3; /* 跨页直接拒绝, 由上层切分 */ iic_start(); if (iic_wait_ack_after(DEV_ADDR_W)) { iic_stop(); return 1; } if (iic_wait_ack_after((unsigned char)(addr >> 8))) { iic_stop(); return 1; } if (iic_wait_ack_after((unsigned char)(addr & 0xFF))) { iic_stop(); return 1; } for (i = 0; i < len; i++) { if (iic_wait_ack_after(buf[i])) { iic_stop(); return 1; } } iic_stop(); return 0; }addr >> 8取高字节、addr & 0xFF取低字节,这个顺序写反了器件不会报错,只会把地址解释成一个完全不同的位置,属于最难查的一类错误。函数里那句跨页判断((addr & 0x3F) + len) > 64是整个封装的护城河,它用一个位与运算换算出当前页内偏移,加上待写长度,一旦越过 64 就直接返回错误码,把问题挡在总线动作之前。相比让数据悄悄回卷,早失败要好得多。
停止位之后不能立刻发起下一次写,器件内部要跑完擦写周期,这段时间总线上的任何寻址都不会被应答。原文在WriteNbyte末尾放了delay(10),按 10 毫秒算,对 5 毫秒的最大写周期是够的。用固定延时简单可靠,代价是每写一页白等一半时间;更常见的做法是应答轮询,反复发起起始位加设备写地址,直到器件回 ACK 为止:
void ee_wait_ready(void) { unsigned int guard = 0; do { iic_start(); if (iic_wait_ack_after(DEV_ADDR_W) == 0) { iic_stop(); return; } iic_stop(); /* 器件仍在忙, 不发 ACK, 重试 */ } while (++guard < 1000); }guard是防止器件真的掉线时死循环,1000 次上限在绝大多数主频下都能覆盖 5 毫秒写周期。轮询的本质是利用了 EEPROM 的一个特性:内部写周期进行时,器件不响应任何地址,包括自己的设备地址。
4.2 读时序:伪写定地址、重复起始、0xA3、最后必须 NACK
读操作比写多一步。24C256 的读地址指针不能由外部直接设定,必须先做一次「伪写」:发起起始位、发写地址、等 ACK、发两个字节的目标地址、等 ACK,但不发任何数据,然后用重复起始位重新接管总线,再发读地址。这一步如果漏了,读出来的永远是从上一次操作遗留指针开始的内容。
unsigned char ee_read(unsigned int addr, unsigned char *buf, unsigned char len) { unsigned char i; if (len == 0) return 2; iic_start(); if (iic_wait_ack_after(DEV_ADDR_W)) { iic_stop(); return 1; } /* 伪写, 只定地址 */ if (iic_wait_ack_after((unsigned char)(addr >> 8))) { iic_stop(); return 1; } if (iic_wait_ack_after((unsigned char)(addr & 0xFF))) { iic_stop(); return 1; } iic_start(); /* 重复起始, 不产生 STOP */ if (iic_wait_ack_after(DEV_ADDR_R)) { iic_stop(); return 1; } for (i = 0; i < len; i++) { buf[i] = iic_read_byte(); if (i < len - 1) iic_send_ack(); /* 还要继续读, 回 ACK */ } iic_send_nack(); /* 最后一字节必须 NACK */ iic_stop(); return 0; }最后那个 NACK 是协议强制要求,不是可选优化。主机回 ACK 的意思是「我还要下一字节」,回 NACK 才表示「到此为止」,从机收到 NACK 才会释放 SDA,主机才能顺利发出停止位。漏掉 NACK 的典型现象是:单字节读正常,多字节读最后一字节变成 0xFF,或者停止位发不出去、总线被占住。
这里的len可以大于 64,因为读路径不存在页回卷,从机会沿 15 位地址一路递增,读到 0x7FFF 再绕回 0x0000。所以整片 dump 完全可以一次读完,不必按页切分。这点和写路径的不对称,值得在接口设计时明确写进注释,否则接手的人很容易把写的限制照搬到读上,把一次事务拆成几十次。
5. 常见问题排查:五个把数据写丢的坑
5.1 现象:所有地址读回都是 0xFF,示波器上看不到任何应答
原因通常是设备地址算错。常见的情形有两种:一是板上 A2/A1/A0 接了跳线,代码里还留着0xA2;二是从 24C02 移植过来,把 24C02 的0xA0直接抄过来用。24C256 的高 4 位固定1010,低 3 位地址脚加读写位才凑成完整字节,地址脚接法不同就必须改常量。
解决办法是先用扫描的方式把所有可能的设备地址试一遍,把0xA0到0xAE的偶数地址逐个发写地址等 ACK,哪个有应答就用哪个。另外别忘了查上拉电阻:SDA/SCL 没有 4.7k 左右的上拉,或者上拉接到了 5V 而 MCU 是 3.3V 供电,波形会抬不起来,同样表现为全程无应答。
5.2 现象:单帧写读正常,连续写几十帧后前面数据被覆盖
这是典型的跨页回卷。原因在于写入长度没有对齐到 2 的幂,或者虽然长度对齐了,但起始地址没对齐。比如一帧 30 字节存进 32 字节槽,槽的起点却是index * 30,第 3 槽从 60 开始,写 32 字节就会越过 63 回卷到 0。
解决办法是把地址计算和长度计算统一到同一个块大小常量上,起始地址只能是块大小的整数倍。写完一段立刻回读校验,尤其是紧挨页边界的那几帧,回读能第一时间暴露回卷。
5.3 现象:偶发丢帧,重试就好,负载一高必现
多半是写周期没等够。固定delay(10)在多数情况下够用,但如果主频漂移、编译器把延时循环优化掉、或者系统里有中断打断,10 毫秒可能实际只有几毫秒,下一次写的设备地址正好落在器件忙的窗口里,直接无应答,数据就丢了。重试能成功,是因为第二次恰好错过了忙窗口。
解决办法是改用 ACK 轮询,不要赌延时。如果坚持用固定延时,至少把它写成一个不被优化掉的volatile循环,并且在最坏温度条件下实测一次。
5.4 现象:程序卡在某一行出不来,看门狗复位后又能跑一阵
死等应答是元凶。while ((P2IN & BIT1) == BIT1);这种写法在器件掉线、总线被某个从机拉死、或者上电时序不对时永远不会退出。改进办法是全部应答检测加超时计数,超时后主动发停止位释放总线并返回错误码。如果总线真的被拉死,还需要补一段恢复动作:给 SCL 手动打 9 个时钟,让卡在半截的从机把字节发完,再补一个停止位。
5.5 现象:读回的数据整体右移一位,或者首字节变成 0xFF
先查 SDA 方向切换。读字节之前如果没有把 SDA 切回输入,主机仍在推挽输出低电平,从机发的 1 根本抬不起来,读到的高位全是 0。另一个可能是发送字节时先改 SCL 再改 SDA,违反「SCL 低电平期间改数据」的规则,从机在上升沿采样到的就是上一个位的值,于是整串数据错位一位。
解决办法很简单:把「SCL 拉低 → 设置 SDA → 拉高 SCL → 采样」这个四拍写成一个不可拆的函数,所有位操作都走它,别在业务代码里手写引脚动作。
6. 进阶用法:把任意长度记录塞进 32 字节槽位并做掉电校验
页对齐解决的是不覆盖,掉电校验解决的是不信任。EEPROM 擦写过程中掉电,落在半个字节上的概率不低,读回来是一个合法但错误的值。我一般会在每个 32 字节槽里切出 4 字节做头部:2 字节序号、1 字节长度、1 字节 CRC8,剩下 28 字节放数据,写入前算好 CRC,读出后重新校验,不通过就当这一槽无效。
#define SLOT_SIZE 32 #define SLOT_NUM (32768u / SLOT_SIZE) /* 1024 槽 */ /* 头部 4 字节 + 数据 28 字节, 显式序列化, 不用结构体直接落盘 */ static unsigned char crc8(const unsigned char *p, unsigned char n) { unsigned char c = 0x00, i, b; for (i = 0; i < n; i++) { c ^= p[i]; for (b = 0; b < 8; b++) c = (c & 0x80) ? (unsigned char)((c << 1) ^ 0x07) : (unsigned char)(c << 1); } return c; } unsigned char log_append(unsigned short seq, const unsigned char *frame) { unsigned char slot[SLOT_SIZE]; unsigned int addr = (unsigned int)(seq % SLOT_NUM) * SLOT_SIZE; unsigned char i; slot[0] = (unsigned char)(seq >> 8); /* 序号高字节 */ slot[1] = (unsigned char)(seq & 0xFF); /* 序号低字节 */ slot[2] = 28; /* 有效长度 */ for (i = 0; i < 28; i++) slot[4 + i] = frame[i]; slot[3] = crc8(&slot[4], 28); /* 只校验数据区 */ if (ee_write(addr, slot, SLOT_SIZE)) return 1; ee_wait_ready(); return 0; }这里刻意不用typedef struct直接强转指针落盘。结构体有对齐填充,不同编译器、不同优化等级下 sizeof 可能不是 32,字段偏移也会变,换个工具链读旧数据就是一片乱码。手动按字节序列化虽然笨,但字节序和偏移都写在明面上,跨平台移植时不用返工。
| 校验策略 | 额外开销 | 能发现的错误 | 适用场景 |
|---|---|---|---|
| 无校验 | 0 字节 | 无 | 可重采的临时缓存 |
| 序号 + 长度 | 3 字节 | 槽位错位、长度异常 | 定长记录 |
| CRC8 | 4 字节 | 单字节翻转、短突发错误 | 参数区、日志 |
| CRC16 + 双备份 | 8 字节 | 长突发错误、整槽损坏 | 标定参数、掉电敏感数据 |
seq % SLOT_NUM让写入位置自动环形推进,序号本身单调递增,读取时按序号排序就能还原时间顺序,比维护一个读写指针更抗掉电。代价是老数据会被覆盖,如果某些记录必须长期保留,就得把环形区和高可靠区分开,高可靠区用双备份加 CRC16。
从那以后我每次碰 EEPROM,都强制先跑一遍「地址对齐检查 + 跨页拒绝 + 回读 CRC」这三步,哪怕只是存几个标定参数。这三步加起来不到五十行代码,却能省掉大量「数据偶尔不对但复现不了」的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取