☰
24C256页写机制与跨页回卷故障排查
2026/10/28 5:32:42 网站建设 项目流程

简介:面向嵌入式存储开发者的24C256使用说明文档,聚焦I2C接口512×64存储器在地址管理与连续读写中的常见问题,适合正在使用MSP430F449等MCU调试EEPROM的初学者与工程人员参考。内容围绕32768字节容量、16位地址中低6位页内地址与高9位页码的划分展开,重点解释连续存储跨越页边界时数据被覆盖的成因,并给出按64、32、16、8、4、2字节对齐分块、不足补零的处理思路。文档还结合IIC_Delay、start_IIC、stop_IIC、send_zero、send_one及单字符收发、ACK/NACK等基本控制模块,说明底层时序与数据传输控制方法。包内共1个doc文件,约33KB,篇幅精炼,便于快速查阅地址计算、分页写入和排错要点。已有1916人学习,可作为24C256读写调试与程序移植的参考资料。

1. 写完不报错,读出来却从第 64 字节开始串位

写入函数每一位都收到了 ACK,示波器上时序也挑不出毛病,可把 128 字节写进 24C256 再整段读回来,前 64 字节对得上,从第 65 字节开始整个错位,后半段数据反过来盖在了页首。这件事的迷惑性在于:它不报错、不死机、单字节读写测试全过,只有当你连续写的长度跨过 64 字节,或者起始地址的页内偏移加上长度超过 64,才会露馅。新手一般先怀疑上拉电阻、SCL 频率、延时不够,把 4.7k 换成 2.2k、把速率从 400k 降到 100k 甚至 10k,改一圈回来数据还是错的。真正的根因在 24C256 的页写机制上:它是 512 页 × 64 字节的结构,连续写不会自动翻页,写到页尾会回到本页第 0 字节继续覆盖。下面按“地址怎么拆、块怎么对齐、读回全 FF 怎么查、总线卡死怎么救”的顺序,把这类故障一路排到底。

2. 跨页写入回卷:地址 63 之后数据为什么又回到了页首

2.1 把地址拆开看:低 6 位是页内偏移,高 9 位是页号

24C256 的容量是 32768 字节,地址线给的是 16 位,但真正有效的只有 15 位,最高位丢弃不用。这 15 位不是一整个线性偏移,硬件内部把它劈成了两段:低 6 位(bit0~bit5)是页内偏移,范围 0~63;高 9 位(bit6~bit14)是页号,范围 0~511。63 和 64 这两个相邻地址就是最典型的分界点:

  • 63 =0b0_0000_0011_1111,页号 0,页内偏移 63,第 0 页的最后一个字节
  • 64 =0b0_0000_0100_0000,页号 1,页内偏移 0,第 1 页的第一个字节

在代码里我习惯把这两个拆位动作做成宏,调试时直接打印出来看,比对着十六进制地址干瞪眼快得多:

#define EE_PAGE_SIZE 64u #define EE_PAGE_MASK 0x3Fu /* 页号 0~511,用于判断是否跨页 */ #define EE_PAGE_NUM(a) ((unsigned int)(a) >> 6) /* 页内偏移 0~63,加上长度超过 64 就会回卷 */ #define EE_PAGE_OFF(a) ((unsigned int)(a) & EE_PAGE_MASK)

EE_PAGE_OFF里那个& 0x3F等价于对 64 取模,取出来的就是本页还剩多少空间的计算起点。有了它,任何一次写入之前都能先算一笔:EE_PAGE_OFF(addr) + len > 64为真,这一笔就一定会回卷,不需要上电试。

需要强调的是,形式上看数据是连续存放的,读写时确实不用手动区分页地址和页内地址,器件自己会拼。但这个“自动”只在一个前提下成立——单次写操作不跨页。一旦跨页,硬件不会把地址进位到下一页,而是把页内偏移清零,从本页第 0 字节重新开始,覆盖掉原有内容。这就是最容易被忽视、也最容易导致数据出错的地方。

2.2 一次写超过 64 字节:回卷覆盖的实际推演

拿一次具体的写入来看。起始地址 60,一次连续写 8 个字节A B C D E F G H,实际落盘位置是这样的:

写入顺序目标地址页号页内偏移结果
A60060正常
B61061正常
C62062正常
D63063正常
E64 → 回卷到 000覆盖第 0 页首字节
F65 → 回卷到 101覆盖
G66 → 回卷到 202覆盖
H67 → 回卷到 303覆盖

从表面现象来看,你写入的是 60~67 这 8 个字节,读回来 60~63 是对的,64~67 读到的却是本页 0~3 的内容——而 0~3 原本存着更早写进去的参数,现在被 E F G H 顶掉了。这就是“前一段对、后一段错、还顺手毁掉了开头”的典型现场。很多人第一次遇到会以为是读函数地址算错了,把读的起始地址改了又改,其实错的在写。

还有一种更隐蔽的形态:单次写入长度确实小于 64,但起始地址没对齐,比如从 40 开始写 30 字节,40+30=70 > 64,同样会回卷 6 个字节。判断标准永远是“偏移 + 长度”,不是“长度有没有超过 64”。

2.3 按页切分的写入函数

最稳妥的改法是把任意长度的写请求在软件层切成不跨页的小块,逐块提交,每块之前等写周期。这样上层调用者完全不用关心页边界:

/* 把任意长度缓冲按 64 字节页边界切分,逐页提交,避免回卷 */ void ee_write_buffer(unsigned int addr, const unsigned char *buf, unsigned int len) { while (len > 0) { /* 本页还能放下多少字节:1~64 */ unsigned int page_left = EE_PAGE_SIZE - EE_PAGE_OFF(addr); /* 取剩余长度和本页可用空间的较小值 */ unsigned int chunk = (len < page_left) ? len : page_left; ee_page_write(addr, buf, chunk); /* 一次页写,绝不跨页 */ ee_wait_ready(); /* 等内部写周期结束 */ addr += chunk; buf += chunk; len -= chunk; } }

page_left是整个函数的灵魂:当地址正好落在页首(偏移 0)时它是 64,允许一次写满一页;当地址偏移 60 时它是 4,这一笔就只发 4 个字节,剩下的交给下一轮循环。chunk取两者较小值,保证单次ee_page_write的长度永远不超过本页剩余空间。

ee_wait_ready()这一步不能因为“以前没加也没事”而省掉。24C256 在收到停止条件后会进入内部写周期,这个期间它不应答任何地址,典型 5ms 量级。如果上一页刚写完立刻发起下一页的起始位,从机会直接无视,表现出来就是“随机丢一页数据”,非常像玄学问题。用 ACK 轮询代替死等固定延时,既快又准:

/* ACK 轮询:写周期内从机不回 ACK,一旦回 ACK 说明内部写完 */ unsigned char ee_wait_ready(void) { unsigned int retry = 0; do { start_IIC(); if (send_char_ack(0xA2)) { /* 收到 ACK 说明器件已就绪 */ stop_IIC(); return 1; } stop_IIC(); delay_us(200); } while (++retry < 100); /* 上限约 20ms,超时退出避免挂死 */ return 0; /* 返回 0 让上层知道器件没响应 */ }

注意这里的send_char_ack是带返回值的版本,跟原始代码里那个while((P2IN&BIT1)==BIT1);死等 ACK 的iic_ACK()不是一回事,后者一旦器件不在线就是死循环,现场根本没法定位。返回值一定要往上传,别写成void。

2.4 两种页边界处理思路:动态切分与定长槽位

动态切分适合“数据本来就是流”的场景,比如日志、固件参数区,长度不固定但要求连续存放。它的代价是每次写都要算一次偏移,代码路径多一个分支。

定长槽位适合“数据是一条条记录”的场景,比如每 30 字节一条采集结果。做法是把整个 32768 字节切成 1024 个 32 字节的槽,每次都从 32 的倍数地址起写,天然不跨页。这种写法把边界问题从运行期挪到了设计期,后面第 3 章会展开。

两条路都能走通,关键不是选哪条,而是别混用——最怕的是有的地方按页切、有的地方直接甩一个长缓冲给WriteNbyte,出问题时根本不知道是哪条路径闯的祸。

3. 连续写不是 2 的幂:每次写 11 字节,为什么写到 55 就出事

3.1 逐次递推:每个起始地址还剩多少页内空间

有一种故障比“一次写超 64 字节”更难查:每次写的长度都远小于 64,看上去完全合规,但结果照样错。原始笔记里那个例子非常经典——每次写 11 字节,起始地址依次是 0、11、22、33、44、55、66……表面看地址每次加 11,规规矩矩,可写到第四次之后就崩了。

把每一步的页内偏移和剩余空间列出来,问题一眼就清楚了:

第几次起始地址页内偏移本页剩余本次 11 字节是否跨页
10064否
2111153否
3222242否
4333331否
5444420否
655559是,第 10 个字节回卷到 0
766262否,但地址已被上一步带偏

第 6 次写入从偏移 55 开始,本页只剩 9 个字节空间,而要写 11 个字节。前 9 个字节落在 55~63,第 10、11 个字节回卷到本页 0、1,把第 1 次写入的数据盖掉了。等第 7 次去读地址 66 期望拿到第 7 条记录,读到的其实是第 6 条被回卷冲散后的残片。

难点在于:前 5 次全部正确,第 6 次才暴露,而且错的位置不是“最后一笔”,是“更早的历史数据被改”。如果测试时只校验刚写的那一条,甚至发现不了;只有整段回读比对,才会看到中间某几条被污染。

3.2 为什么块大小必须取 2 的幂且不超过 64

能不能找到一个“长度”让这种递推永远不跨页?可以,但条件很苛刻。设每次写入长度固定为 b,起始地址依次是 0、b、2b、3b……要让任意一个起始地址都满足“偏移 + b ≤ 64”,等价于要求 b 必须整除 64。64 的因数只有 1、2、4、8、16、32、64 这几个,其他任何长度都会在某个时刻踩到页尾。

拿 b=11 验证:11 不整除 64,64 mod 11 = 9,也就是说每隔几次就会有一个起始偏移落在 55、56 这类“剩余空间不足 11”的位置,必然回卷。b=30 也一样,64 mod 30 = 4,写第三次的时候偏移 60,只剩 4 个字节,照样出问题。而 b=32 时,起始偏移只可能是 0 或 32,32+32=64 刚好贴边不越界,永远安全。

这就是原始资料里那句结论的来历:连续存储模式下,块大小取 64、32、16、8、4、2,其余都不能用连续地址存储。它不是经验值,是整除关系的必然结果。

3.3 采集 30 字节按 32 字节对齐存储

实际采集的有效数据常常是 30 字节这种非 2 的幂长度。做法不是把长度改成 32 去采集,而是保持采集 30 字节,存储时按 32 字节一个槽位落地,不足的补零:

#define EE_SLOT_SIZE 32u /* 必须是 2 的幂且 ≤ 64 */ #define EE_SLOT_COUNT (32768u / EE_SLOT_SIZE) /* 1024 个槽位 */ /* 把第 slot 条记录存进 24C256,记录长度不足 32 字节补 0 */ unsigned char ee_save_slot(unsigned int slot, const unsigned char *sample, unsigned int n) { unsigned char tmp[EE_SLOT_SIZE]; unsigned int base; if (slot >= EE_SLOT_COUNT || n > EE_SLOT_SIZE) return 0; /* 越界直接拒绝 */ base = slot * EE_SLOT_SIZE; /* base 一定是 32 的倍数,页内偏移只会是 0 或 32 */ memset(tmp, 0x00, EE_SLOT_SIZE); /* 尾部补零,保证槽位定长、读回可判有效长度 */ memcpy(tmp, sample, n); ee_write_buffer(base, tmp, EE_SLOT_SIZE); return 1; }

base = slot * 32保证了起始地址的低 6 位只能是 0 或 32 两种取值,加上固定写 32 字节,两种情况都不会越过页边界,回卷这件事从设计上被消掉了。补零除了对齐,还附带一个好处:读回时可以通过“第一个 0 的位置”判断这条记录的有效长度,不用担心上一条记录的残留数据混进来。

注意n > EE_SLOT_SIZE的越界判断要放在最前面。真出现采集长度超过槽位的情况,宁可返回失败让上层处理,也不要静默截断——静默截断在现场就是“数据少了几个字节但没人知道”,比报错难查得多。

3.4 对齐的空间代价与容量核算

按 32 字节存 30 字节,利用率 93.75%,32768 字节能放 1024 条记录,浪费 2048 字节。这个代价换来的是一整类边界 bug 的消失,我觉得非常划算。如果确实对容量敏感,可以换成 16 字节槽位加两条记录合并写,但要注意合并后的写操作本身又变成 32 字节,起始地址必须是 32 的倍数,绕一圈还是回到对齐上。

容量算清楚之后,顺手把“记录编号 → 地址”的映射也固定下来,写进头文件里,别在两处各算一遍。真正的翻车往往不是算错,而是一个地方按 30 字节步进、另一个地方按 32 字节步进,写进去的和读出来的根本不在同一套坐标里。

4. 读回全 0xFF 或首字节丢失:起始位、ACK 与地址字节的坑

4.1 写周期没等完就发下一个起始位

现象是写完立刻读,读回一整片0xFF,或者第一条对、后面几条全FF。0xFF是擦除态,说明这些位置压根没写进去。根因基本都在写周期上:24C256 收到停止条件后开始内部烧写,这段时间内它不响应任何地址,主机发出的起始位加器件地址会全部石沉大海。原始代码里WriteNbyte末尾那个delay(10)就是在等这个周期,但固定延时的问题是会随温度、供电电压、批次漂移,高温下 5ms 可能不够,低温下又白等。

换成前面给出的 ACK 轮询就稳了:什么时候器件重新应答0xA2,什么时候才发下一笔。轮询失败返回 0 之后,上层要能把“第几页写失败”报出来,别吞掉。

4.2 两段式读:先伪写设地址,再重复起始读

读操作比写多一步。24C256 没有“读地址”这个独立命令,必须先用写时序把 16 位字地址送进去(这叫 dummy write),再发一次起始条件,把器件地址的读写位改成 1,才能开始吐数据。整个序列是:起始 →0xA2→ ACK → 地址高字节 → ACK → 地址低字节 → ACK → 重复起始 →0xA3→ ACK → 读数据…… → NACK → 停止。

最常见的错法是漏掉重复起始,直接在第二个地址字节的 ACK 之后继续读时钟,结果读回来的是移位寄存器里的残余值;另一种是把第二个起始写成了停止再起始,多插了一个停止条件,某些批次上会直接把地址指针复位。原始代码里ReadNbyte的结构是对的,问题在别处——它调用的iic_ACK()是死等版本,一旦器件没焊好或者地址错,程序就停在那儿了。

4.3 连续读的最后一个字节必须回 NACK

读多个字节时,每读一个字节主机要回一个 ACK 告诉从机“继续”,只有最后一个字节回 NACK,从机收到 NACK 才释放 SDA 让主机发停止条件。原始代码里这段判断是对的:

do { *p = read_char(); p++; if (number != 1) /* 不是最后一个字节才回 ACK */ S_ACK(); } while (--number); iic_NACK(); /* 最后一个字节之后回 NACK */ stop_IIC();

如果偷懒每个字节都回 ACK,从机会一直往下吐数据,地址自动递增,读到页尾之后的行为就不受控了,表现是“多读出来的尾部数据串到了下一段”。反过来,如果第一个字节就回 NACK,只能拿到一个字节,后面的指针全是没动的旧值。

另外number的类型是unsigned char,传 0 进去时do-while(--number)会先减成 255 再继续,直接读 256 个字节。这类回绕在调试时极难看出来,因为循环确实“正常”跑完了,只是跑的次数离谱。入口处加一句if (number == 0) return;是成本最低的后悔药。

4.4 位操作模拟 I2C 时 GPIO 方向切换的细节

原始代码用改P2DIR的方式模拟开漏:要输出 1 就把引脚设成输入,让外部上拉电阻把线拉高;要输出 0 就设成输出并写 0。这个技巧在 MSP430F449 这类没有硬件 I2C 或者懒得配外设的场合很常见,但有两个前提:SDA、SCL 都必须有上拉,且上拉阻值不能太大。

send_char里逐位判断(data_out & tmp),tmp从 0x80 开始每次除以 2,高位先发,这个顺序不能反。read_char里则是在 SCL 拉高之后采P2IN & 0x02,采样点必须落在 SCL 高电平期间,早一点晚一点都会读到跳变沿上的不稳定值。

还有一点容易被忽略:start_IIC和stop_IIC里对 SDA、SCL 的操作顺序是有讲究的。起始条件是 SCL 高时 SDA 由高变低,停止条件是 SCL 高时 SDA 由低变高。写反了在示波器上就是一个毛刺,器件偶尔能认、偶尔不认,属于典型的“换个板子就好了”的玄学故障。真遇到这种,别猜,把两个引脚的波形抓出来,对着起始/停止的时序图比。

5. 避坑清单:24C256 调试里反复踩的五个坑

5.1 现象:程序跑着跑着卡死不返回,复位也不好使

原因:iic_ACK()里用的是while((P2IN&BIT1)==BIT1);,器件不在线、地址写错、上拉缺失,SDA 永远是高,这个循环就永远出不来。现场表现是主循环停住, watchdog 没开的话只能断电。

解决:所有等待 ACK 的地方都换成带计数上限的版本,超时返回错误码并向上传递;同时把 watchdog 打开,让异常路径至少能自恢复。

5.2 现象:单条记录读写测试全过,整段回读发现中间某几条被改

原因:块大小不是 64 的因数,写到某一笔时页内剩余空间不足,数据回卷覆盖了本页开头。典型触发长度是 11、30、100 这类非 2 的幂。

解决:把块大小对齐到 64/32/16/8/4/2,或者改用第 2 章那个按页切分的ee_write_buffer,两条路任选其一,别混着来。

5.3 现象:写入函数参数传 0,结果整片区域被写成同一个值

原因:do { ... } while(--number);在number为 0 时会先回绕成 255,实际写了 256 个字节。unsigned char的隐式回绕不报任何警告。

解决:入口处加长度判空,并且把长度参数类型提到unsigned int,让 0 和 256 在类型上就区分开。

5.4 现象:代码从 24C02 移植过来,读写全乱,读回全 FF

原因:24C02 是单字节字地址,24C256 是双字节。只发了低字节,器件把高字节当成了数据,地址整体错位;写入时同理,多出来的那个字节被当成第一个数据字节吃掉了。

解决:把地址发送统一封装成“先高字节addr/256、再低字节addr%256”,并且用一个宏标记器件地址宽度,移植时只改宏不改流程。

5.5 现象:偶尔上电后第一次读写就失败,之后一直失败

原因:上一次运行中途复位,从机正卡在某个字节的应答位上拉着 SDA 不放,主机重新初始化后发的起始条件它根本不认。

解决:初始化时先做一次总线解锁——给 SCL 打 9 个脉冲,直到 SDA 被释放,再补一个停止条件回到空闲态:

/* 从机拉死 SDA 时的解锁:9 个 SCL 脉冲逼它把当前字节吐完 */ void iic_bus_recover(void) { unsigned char i; P2DIR &= ~0x02; /* SDA 释放为输入,靠上拉为高 */ for (i = 0; i < 9; i++) { P2DIR |= 0x04; IIC_Delay(); /* SCL 低 */ P2DIR &= ~0x04; IIC_Delay(); /* SCL 高 */ } stop_IIC(); /* 补停止条件,回到空闲 */ }

上电初始化第一句就调它,成本几微秒,能省掉一堆“这块板子有时候开不了机”的扯皮。

6. 快速定位:三步判断是回卷、总线还是器件本身

现场拿到一块“24C256 读写不对”的板子,我一般不看代码,先做三步实验,五分钟内能定性。

第一步,做地址边界测试。选四组数据,分别从地址 0 写 64 字节、从地址 0 写 65 字节、从地址 60 写 8 字节、从地址 32 写 32 字节,每写一组就整段回读比对。判据很直接:

起始地址写入长度页内偏移是否跨页预期结果
0640否全部正确
0650是第 65 字节覆盖地址 0
60860是第 5 字节起覆盖 0~3
323232否全部正确

如果第 1、4 组对,第 2、3 组错,且错的位置正好是“回卷到页首”,那就是页边界问题,去查块大小和切分逻辑,别再动硬件。如果四组全错,往下走。

第二步,做最小复现。只保留start_IIC、send_char、iic_ACK、stop_IIC四个函数,写一个固定地址、固定单字节、延时足够长的测试:写0x5A到地址 0,等 10ms,读地址 0。这一步过不了,问题一定在时序或硬件上,跟分页无关。同时用万用表量 SDA、SCL 的静态电平,两条线空闲时都应该是高,任何一条是低就先去查上拉和焊接,别在代码里绕。

第三步,看 ACK 有没有回来。把iic_ACK()改成带超时并返回状态的版本,打印每一次写地址、写数据、读数据后的 ACK 结果。正常序列应该是:器件地址 ACK、地址高字节 ACK、地址低字节 ACK、每个数据字节 ACK。如果器件地址就没 ACK,是地址配置或器件没上电;如果前三个 ACK 有、数据字节没 ACK,多半是写保护引脚被拉高或者器件正在写周期里;如果一切 ACK 正常但数据不对,回到第一步,问题在地址规划。

这三步的顺序不能颠倒。很多人一上来就怀疑芯片坏了,换了三片新的还是错,因为根因从来不在芯片上。血泪经验是:能 ACK 就说明总线和器件都活着,剩下的错误基本都在地址计算里。

从那以后我每次接 24C256 这类分页 EEPROM,第一件事不是写业务代码,而是先写一个“页边界自检”函数,把这四组边界数据跑一遍,全过了才往下写。这个习惯救过我很多次——它把“数据偶尔错一条”这种最耗时间的模糊故障,压缩成了上电后三秒钟的一次自检。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询