简介:面向嵌入式存储开发初学者及使用24C256的工程人员,这份中文资料围绕I2C接口512×64存储器的说明与使用展开,重点梳理16位地址分配、低6位页内地址与高9位页码的关系、连续存储模式及跨页覆盖错误的成因与规避方法。文档还结合MSP430F449给出IIC基本控制模块,包括延时、启动、停止、发送0/1、单字节收发与应答检查等实现片段,便于理解底层时序、核对关键细节和排查读写异常。资源包共1个doc文件,约33KB,篇幅精简但知识点集中,适合课程设计、实验调试时快速查阅与对照。已有1916人学习,可作为24C256地址管理与I2C读写排错的入门参考。
1. 写完 64 个字节,第 0 字节被自己覆盖了
「读回来的数据和写进去的对不上」是 24C256 这类 I2C EEPROM 最典型的一类故障,而且现象特别邪门:单字节读写怎么测都对,一改成连续写几十个字节,前面几个字节就莫名其妙变成了后面的值;有时候整段数据看着像「整体前移」了一段,有时候只剩最后一页的内容是对的。很多人第一反应是时序没调好、上拉电阻不够、从机地址写错,于是改延时、换电阻、上逻辑分析仪,折腾一两天问题还在。真正的原因大概率不在电气层,而在地址:24C256 的 32768 字节被切成 512 页、每页 64 字节,页内地址低 6 位涨到 63 就回卷到 0,硬件不会帮你进位到下一页。下面把我自己踩过的几类 24C256 报错按「现象—根因—解决」拆开讲,包括跨页回卷、写周期没结束就发下一条命令、设备地址与 16 位单元地址搞混,以及用 GPIO 模拟 I2C 时读不出数据的坑。
2. 连续写跨页被整段覆盖:第 63 字节之后地址为什么回到了 0
2.1 现象:单字节写全对,连写 11 个字节就串位
最容易被误判成「偶发故障」的写法是这样的:采集模块每次产出 11 个字节,代码里按 0、11、22、33、44、55、66 这样的地址递增去写,写完之后整体读回来校验,发现第 0、1 字节的内容变成了第 55 号地址那一段的后两个字节。因为出错位置固定在最前面,很多人会怀疑是首地址计算错了,或者第一次写入时芯片还没准备好,于是加延时、重写第一段,结果下次跑还是错。
换一个更直观的测试就能确认:从地址 55 开始连续写 11 个字节,写完把 0 到 70 全部读出来,你会发现 55 到 63 是新的,0 和 1 也被改成了这 11 个字节的最后两个。也就是说,写到页尾之后,数据没有进到下一页,而是绕回本页开头,把老数据盖掉了。这类问题的破坏性在于它不报错:ACK 正常、时序正常、读操作也正常,只是数据静默地错了,等你去查业务逻辑时已经离现场很远。
2.2 根因:低 6 位是页内偏移,第 7 位不会向页号进位
24C256 的容量是 32768 字节,正好是 512 页 × 64 字节。它的单元地址是 16 位,实际有效 15 位,拆开看是这样的:
| 位域 | 含义 | 取值范围 |
|---|---|---|
| bit15 | 未使用 | — |
| bit14 ~ bit6 | 页号 | 0 ~ 511 |
| bit5 ~ bit0 | 页内偏移 | 0 ~ 63 |
写操作时,主机发完设备地址、地址高字节、地址低字节,后面跟的每个数据字节都会让器件内部的地址计数器加一,但这个计数器只在这 6 位页内偏移上滚动。偏移从 63 再加一,得到的是本页的 0,而不是下一页的 0。所以地址 63(二进制 111111)是第 0 页最后一个单元,地址 64(二进制 1_000000)才是第 1 页第一个单元——这两个地址在数值上只差 1,在器件内部却是两个完全不同的页,跨过去的那一步必须由主机重新发起一次写地址流程来完成。
这也解释了一个反直觉的点:数据在形式上确实是连续的,你不需要手动区分页号和页内偏移,器件会自己处理低 6 位的累加;但正因为「看起来连续」,很多人就默认它能一直加下去,翻车就翻在这里。原文里那句「地址不能自动累加,如果不做正确处理,数据将从本页开始的地址重新开始覆盖已经存在的数据」,说的就是这件事。
2.3 改法:按 64 字节边界截断,写函数里加一个 room 判断
与其在业务层小心翼翼地算地址,不如把页边界处理收进驱动里,让所有调用方都无感。核心就一行:每次写之前算一下「本页还剩多少空间」,把这一次的长度截断到这个范围内,写完再重新发起一次带地址的写流程。
/* 24C256 页大小固定 64 字节,任意地址、任意长度的写都走这个函数 */ #define EE_PAGE_SIZE 64u #define EE_DEV_WRITE 0xA2u /* 7 位地址 1010 001 左移一位,最低位为 0 表示写 */ static void ee_write_bytes(const unsigned char *buf, unsigned int addr, unsigned int len) { /* 地址有效范围 0 ~ 32767,越界直接返回,避免高位被截断后写到别处 */ if (addr >= 32768u || len == 0) return; if (len > 32768u - addr) len = 32768u - addr; while (len > 0) { /* 本页剩余空间:64 - (addr % 64),addr 落在页首时结果是 64 */ unsigned int room = EE_PAGE_SIZE - (addr % EE_PAGE_SIZE); unsigned int chunk = (len < room) ? len : room; /* 关键:截断到页尾 */ unsigned int i; ee_start(); ee_send(EE_DEV_WRITE); ee_wait_ack(); ee_send((unsigned char)(addr >> 8)); /* 单元地址高字节,先发 */ ee_wait_ack(); ee_send((unsigned char)(addr & 0xFF)); /* 单元地址低字节,后发 */ ee_wait_ack(); for (i = 0; i < chunk; i++) { ee_send(buf[i]); ee_wait_ack(); } ee_stop(); ee_wait_write_done(EE_DEV_WRITE); /* 等内部写周期结束,见第 3 章 */ buf += chunk; addr += chunk; len -= chunk; } }代码里三个地方值得盯着看。第一,room的计算用的是取模,addr % 64是页内偏移,64 减去它就是还能连续写多少字节,落在页首时恰好得到 64,不需要额外特判。第二,chunk取len和room的较小值,这一步保证循环体里那串连续写的字节永远不会越过页尾,跨页的问题在驱动层就被消灭了。第三,循环末尾用chunk而不是原始的len去推进指针和地址,因为真正写进去的是截断后的长度,用len会漏写或者重复写。addr >> 8和addr & 0xFF的顺序不能反,24C256 要求先高字节后低字节,反了就是往一个完全不相干的地址写数据。
2.4 块大小为什么只能是 2 的幂:30 字节按 32 存、补零
如果业务上做不到「每次不跨页」,还有一个更省事的办法:把存储块大小固定成 64、32、16、8、4、2 之一,并且起始地址按同样的粒度对齐。原因是这些数都能整除 64,步长整除页大小,地址序列 0、32、64、96 就不可能落在某一页的中间再跨出去。反过来,步长 11 不整除 64,前几次看着没事,第五次从 55 开始就必然撞墙,这就是很多「跑了几十次才出错」的现场来源。
具体做法是向上取整到最近的 2 的幂:采集结果 30 字节,就按 32 字节一个块存,不足的部分补零。读的时候按块读回来,再用块头里的长度字段把补的零截掉。牺牲 2 个字节换掉一整类边界 bug,这笔账怎么算都值。补零不要用随机值,一定要显式清零,否则读回来做校验时又会被当成脏数据。
/* 定长块存储:payload 最长 32 字节,第一字节存真实长度,其余补 0 */ #define EE_BLOCK_SIZE 32u static void ee_save_record(const unsigned char *payload, unsigned int len, unsigned int slot) { unsigned char block[EE_BLOCK_SIZE]; unsigned int i; if (len > EE_BLOCK_SIZE - 1) len = EE_BLOCK_SIZE - 1; block[0] = (unsigned char)len; /* 块头记录有效长度 */ for (i = 1; i < EE_BLOCK_SIZE; i++) block[i] = (i <= len) ? payload[i - 1] : 0x00; /* 不足补零,不能留随机值 */ /* slot 从 0 开始编号,地址天然按 32 对齐,永不跨页 */ ee_write_bytes(block, slot * EE_BLOCK_SIZE, EE_BLOCK_SIZE); }slot * EE_BLOCK_SIZE这一步让每个记录的起始地址都是 32 的倍数,配合 32 字节的写入长度,页边界问题从设计上就不存在了。代价是空间利用率,512 个记录槽只用了 16KB,剩下的一半空间你可以再划一个区域存别的定长块,或者干脆把块大小提到 64 换更高利用率。
3. 写完立刻读回还是旧值:ACK 回来了不代表字节落了盘
3.1 现象:第二段写入丢失、偶发整页 0xFF
比跨页更隐蔽的是时序问题。典型现场:连续写两段数据,第一段写 10 字节,加个delay(10),第二段写 10 字节,再读回来发现第二段没写进去,或者第二段只写进去一部分;有时候整页读出来全是 0xFF,重上电再读又是对的。还有一种表现是「每次只有第一段生效」,后面的写入像被吞掉了。这类问题在实验室桌面上可能一次都复现不出来,装到设备上跑几天冒一次,非常折磨人。
很多人在这里会怀疑 ACK 检测写错了,于是把iic_ACK()里的等待逻辑改来改去,或者干脆不检测应答,写完就往下跑。这两种做法都会把问题推到更难查的地方:不检测应答,写失败了你根本不知道;改了等待逻辑但没等够时间,数据依然会丢。
3.2 根因:内部写周期 tWR 期间器件不响应,总线上的 ACK 是假的
24C256 收到停止条件之后,并不会立刻把数据写进存储阵列,而是启动一个内部的擦写周期,这个周期内器件对总线上的一切请求都不作响应,连自己的从机地址都不认。如果你在这个窗口里发起下一次写,从机会直接忽略,主机这边的表现就是「数据没写进去」。原文代码里在WriteNbyte结尾写了delay(10),就是在等这个周期,10ms 对多数批次是够的,但它有三个问题:一是不同批次、不同温度的器件写周期并不完全一致,卡边界时就会翻车;二是写 64 字节和写 1 字节的写周期不一样,固定延时要么浪费要么不够;三是延时期间 MCU 干不了别的事,低功耗场景很难受。
需要强调的是,主机在发完最后一个字节后收到的那个 ACK,只代表器件收下了这个字节并放进了内部缓冲,不代表它已经落盘。把这两个概念混在一起,就会得出「ACK 都回来了怎么还会丢数据」的困惑。
3.3 解决:用应答轮询替代 delay(10)
标准做法叫应答轮询:写完停止条件后,不停地发起始条件 + 设备地址(写方向),如果器件还在内部写周期里,它不会应答,主机读到 NACK;一旦它应答了,说明内部写周期结束,可以发下一条命令了。这样既不会等不够,也不会白等。
/* 返回 0 表示写周期已结束,-1 表示超时(器件没接、地址错、总线被拉死) */ static int ee_wait_write_done(unsigned char dev) { unsigned int guard = 0; for (;;) { ee_start(); if (ee_send_addr_poll(dev) == 0) { /* 收到 ACK,内部写完成 */ ee_stop(); return 0; } ee_stop(); if (++guard > 2000) /* 必须有出口,不能死等 */ return -1; } }ee_send_addr_poll只发 8 位设备地址然后读第 9 位的应答,不发单元地址、不发数据,是最短的探测序列。guard上限按你的主频和循环体耗时折算,一般保证几百毫秒量级就够,太短会误判超时,太长会让真正的故障拖很久才暴露。返回值一定要检查,返回 -1 时不要继续往下写,先把总线状态和器件连接查清楚,否则后面所有写入都是在错误前提上继续。
3.4 轮询为什么要加超时:一个 while 死等把主循环拖死的教训
原文里的应答检测是这样写的:while((P2IN&BIT1)==BIT1);,意思是只要 SDA 还是高就原地等。正常情况没问题,但只要器件没焊好、地址配错、SDA 被某个器件一直拉低,这一行就是死循环,整个固件卡在这里,看门狗不喂就复位,复位后重复同样的流程,表现为「设备每隔几秒重启一次」。这种现场最难查,因为现象是重启,不是存储错误。
从那以后我写任何等待外部器件的循环都强制带计数出口,并且把超时当成正式的错误分支处理,而不是「理论上不会发生所以不写」。同理,读字节函数里那个等 SCL 被拉高的循环、等待总线空闲的循环,全部要加超时。
4. 读回全 0xFF 或错位:设备地址、16 位单元地址、读时序三处最容易混
4.1 从机地址:7 位地址左移,最低位是读写方向
24C256 的 7 位从机地址是1010加上 A2、A1、A0 三个引脚的电平,也就是1010xxx。主机在总线上发的第一个字节是这 7 位左移一位、最低位填读写方向,所以写是0xA2、读是0xA3(对应 A2A1A0 = 001,7 位地址 0x51)。这三个地址引脚接高接低决定了实际地址,同一份固件换到另一块板子上,如果这三个脚的上拉/下拉不一样,地址就变了,现象就是「读出来全是 0xFF」——因为根本没有器件应答,主机读到的 SDA 被上拉电阻拉成高电平。
| A2 A1 A0 | 7 位地址 | 写字节 | 读字节 |
|---|---|---|---|
| 0 0 0 | 0x50 | 0xA0 | 0xA1 |
| 0 0 1 | 0x51 | 0xA2 | 0xA3 |
| 0 1 0 | 0x52 | 0xA4 | 0xA5 |
| 1 0 0 | 0x54 | 0xA8 | 0xA9 |
排查时不要只看代码里的宏,拿万用表量一下三个地址脚的实际电平,再对一遍表。我见过把 A0 脚悬空当成低电平处理的写法,悬空时电平不确定,温度一变地址就飘,属于典型的玄学故障。
4.2 单元地址:必须发两个字节,先高后低
24C256 容量超过 256 字节,单元地址是 16 位,所以写流程必须是「设备地址 + 高字节 + 低字节 + 数据」。只发一个字节,器件会把那一个字节当成高字节,低字节缺失,地址指针就落在一个你完全没预期的地方——现象是数据确实写进去了,但读的地址不对,看起来像「写丢」。同样,读流程里那个「假写」也必须发两个字节的单元地址,少发一个,后面重复起始读出来的就是从错误位置开始的数据。
/* 定位地址指针:写方向发 2 字节单元地址,供后面的读操作使用 */ static void ee_set_pointer(unsigned int addr) { ee_start(); ee_send(0xA2); /* 写方向,最低位 0 */ ee_wait_ack(); ee_send((unsigned char)((addr >> 8) & 0x7F)); /* 高字节,最高位恒为 0 */ ee_wait_ack(); ee_send((unsigned char)(addr & 0xFF)); /* 低字节 */ ee_wait_ack(); /* 这里不发停止条件,紧接着重复起始进入读模式 */ }高字节里只有低 7 位有效,addr >> 8之后再与0x7F是为了防止调用方传进来一个越界地址时把无效位送出去。地址越界在 24C256 上不会报错,它会静默地按位截断,写到你别的地方去。
4.3 读时序:dummy write + 重复起始 + 最后一个字节回 NACK
读操作最容易错的是收尾:除了最后一个字节,每读一个字节主机都要回 ACK,告诉从机「我还要」,最后一个字节必须回 NACK,告诉从机「够了,你可以释放 SDA 了」。如果最后一个字节也回 ACK,从机会继续往总线上推下一个字节,主机这边已经准备发停止了,两边抢总线,读出来的最后一个字节经常是错的,或者干脆把总线拉死。
static void ee_read_bytes(unsigned char *buf, unsigned int addr, unsigned int len) { if (len == 0) return; ee_set_pointer(addr); /* 假写:只为把内部地址指针摆好 */ ee_start(); /* 重复起始条件,不清除地址指针 */ ee_send(0xA3); /* 读方向,最低位 1 */ ee_wait_ack(); while (len--) { *buf++ = ee_read_byte(); if (len) ee_send_ack(); /* 还有:回 ACK 继续读 */ else ee_send_nack(); /* 最后一个:回 NACK,让从机放手 */ } ee_stop(); /* 停止条件必须在 NACK 之后 */ }ee_set_pointer后面紧跟ee_start而不是ee_stop,这一点很关键:停止条件会释放总线,某些情况下器件内部状态会复位,地址指针可能失效;重复起始条件则保持连接,指针继续有效。这也是为什么读操作看起来比写操作「多一步」,那一步不能省。
4.4 GPIO 模拟 I2C:用方向寄存器当开漏,读之前必须把 SDA 放开
原文用方向寄存器模拟开漏输出:把 P2DIR 对应位清零表示「输入」,靠外部上拉电阻得到高电平;置一表示「输出低」。这个技巧在没有硬件 I2C 的 MCU 上很常见,但有一个坑几乎所有人都踩过——读数据之前忘了把 SDA 的方向切回输入。如果 SDA 还停在输出低的状态,你读回来的永远是 0,表现为「读出来全是 0x00」;如果停在输出高,读回来永远是 1,表现为「全是 0xFF」。
/* 读一个字节:每次采样前必须把 SDA 切成输入,让从机驱动总线 */ static unsigned char ee_read_byte(void) { unsigned char i, v = 0; for (i = 0; i < 8; i++) { SDA_INPUT(); /* 关键:释放 SDA,否则读回的是自己输出的电平 */ IIC_Delay(); SCL_HIGH(); /* 时钟拉高,此时总线上的数据有效 */ IIC_Delay(); v <<= 1; if (SDA_READ()) v |= 0x01; SCL_LOW(); /* 拉低时钟,准备下一个位 */ IIC_Delay(); } SDA_OUTPUT(); /* 读完恢复输出,方便后面发 ACK/NACK */ return v; }sda的输入输出切换要成对出现,发完 ACK 之后立刻切回输入,否则从机推不动总线。另外SCL_LOW()之后要给一个延时再进下一轮,太快的话从机来不及准备下一位,高速主频下这个延时往往要按实测调整,不能照抄别人的_NOP()数量。
5. 避坑清单:五个把我拖到半夜的 24C256 问题
5.1 整片读出来全是 0xFF
现象:上电后把 0 到 63 读一遍,全是 0xFF,写进去的数据完全读不到,但写操作的 ACK 检测又是正常的。
原因:0xFF 是总线被上拉电阻拉高的默认电平,说明主机在「自说自话」,没有器件应答。常见的三种情况是设备地址算错(A2A1A0 引脚电平和代码里的宏不一致)、WP 写保护引脚被拉高导致写被拒绝、以及 SDA/SCL 接反或虚焊。
解决:先用万用表量 A0~A2 和 WP 的实际电平,再对照地址表确认写字节;WP 在只需要读的场景可以直接接地;确认硬件后用「发地址读 ACK」这个最小测试单独验证通讯,不要一上来就跑完整的读写流程。
5.2 只有第一个字节对,后面全错
现象:写 10 个字节,读回来第 1 个是对的,后面 9 个是乱值或者旧值。
原因:连续写循环里每个字节后面没有等 ACK,或者 ACK 检测写成了「发完就当时钟已经拉低」,从机还没把 SDA 拉低就去采样,误判成 NACK 后提前退出。也有可能是循环里指针没递增,反复写同一个地址。
解决:把「发一个字节 + 等 ACK」封成一个函数,所有写入路径只调这个函数,避免某处漏掉等待;在 ACK 检测失败时记下当前字节序号,用这个序号定位是第几个字节开始出问题,比盲改延时有效得多。
5.3 程序卡在等 ACK 的 while 里出不来
现象:设备隔几秒复位一次,或者主循环完全停住,调试器断下来发现停在while((P2IN & BIT1) == BIT1);这一行。
原因:等待应答的循环没有超时出口。器件没接、地址错、总线被某个从机拉死,任何一种情况都会让它永远等下去。
解决:所有等待外部信号的循环都加计数器上限,超时后返回错误码并执行总线恢复:主机手动发 9 个时钟脉冲,把卡在半截的从机把数据吐完,然后补一个停止条件,再重新初始化。这个恢复流程建议直接写进驱动初始化里,上电先跑一遍。
5.4 同一份固件换块板子就不通讯
现象:实验室那块板子跑得好好的,装到另一批板子上,一半能通一半不通。
原因:地址引脚的上拉/下拉电阻焊接不一致,或者根本没装,靠悬空碰运气;也可能是上拉电阻阻值偏大,总线电容一大,上升沿变缓,高速下采样出错。
解决:地址引脚一律用明确的上拉或下拉,不留悬空;上拉电阻按总线电容算,短线 4.7k 起步,挂的器件多就适当减小;把通讯速率降一档做对比测试,降速能通基本就是上升沿问题。
5.5 多字节读首字节重复或整体错位
现象:读 8 个字节,第一个字节和第二个字节一样,或者整段数据比预期偏移了一个位置。
原因:读循环里 ACK/NACK 的位置放错了,最后一个字节回了 ACK,从机多推了一个字节,主机没接住,下一次读的时候总线状态已经乱了;也可能是假写之后用了停止条件而不是重复起始,地址指针被复位到 0。
解决:严格按「非最后字节回 ACK、最后字节回 NACK、NACK 之后立刻发停止」的顺序写,并把这段逻辑封在唯一的读函数里;调试时把每个字节的序号打出来跟写入时的序号对照,错位一格还是错位整段,能直接指向是收尾错还是指针错。
6. 快速定位:先分清页回卷、写周期、地址错这三类
伸手改代码之前,先花三分钟做一次分类,比直接改延时省几个小时。判断依据其实就三条:错的位置固定不固定、ACK 有没有回来、读回来的是 0xFF 还是旧值。
| 现象特征 | 最可能的原因 | 一步验证 |
|---|---|---|
| 每次都是页首几个字节被改,错位位置固定 | 跨页回卷 | 从地址 55 写 11 字节,读 0~70 看 0、1 是否被改 |
| 写入长度越长越容易错,写完立刻读是旧值 | 内部写周期未结束 | 把delay(10)换成应答轮询,看是否消失 |
| 读回全 0xFF,写也不生效 | 设备地址或 WP | 单独发设备地址读 ACK,再量 A0~A2、WP 电平 |
| 读回全 0x00 | SDA 方向没切回输入 | 读字节函数第一行加释放 SDA |
| 只有第一段数据生效 | 地址只发了一个字节 | 抓一次完整波形,数地址阶段有几个字节 |
实际排查我一般按这个顺序走:第一步,写一个固定模式的测试块(比如 0x00 到 0x3F 共 64 字节)到地址 0,整段读回比对,先排除地址和接线问题;第二步,把起始地址改成 55 再写 11 字节,读 0~70,专门验证页边界;第三步,连续写 8 段、每段之间用应答轮询而不是固定延时,跑 100 轮看有没有偶发丢数据。这三步过了,基本可以把「驱动层」和「业务层」的问题切开,剩下的错误一定在上层逻辑里。
还有一个习惯值得养成:把每次写的起始地址、长度、以及读回来的首尾各两个字节打进日志。24C256 这类故障的现场往往不可复现,但只要日志里能看到「这次写的地址是 55、长度 11」,一眼就能认出是跨页。从那以后我每次碰 EEPROM 都强制走一遍「页边界 + 写周期 + 地址表」这三项自检,宁可多花十分钟写测试,也不想再经历一次半夜对着逻辑分析仪猜数据去哪了。希望帮到你。
本文还有配套的精品资源,点击获取