简介:围绕24C256存储器使用与程序控制展开的中文文档,面向单片机、嵌入式开发初学者及需要调试I2C接口EEPROM的工程人员。内容从512×64组织结构与32768字节容量讲起,梳理16位地址中页内地址和页码的分配关系,重点分析连续存储跨页时数据被覆盖的原因,并给出按64、32、16、8、4、2字节对齐存储、采集数据不足补零等处理思路,同时说明页地址与页内地址在形式连续存储中的使用要点。文档还结合MSP430F449介绍IIC控制模块,包括延时、启动与停止时序、发送0和1、单字符收发以及应答检查,便于读者对照理解底层时序与排错方法。压缩包共1个doc文件,约33KB,篇幅精炼、便于查阅。已有1916人学习下载,适合作为24C256地址管理与I2C通信调试的参考材料。
1. 24C256 最容易被误读的一点
24C256 最容易被误读的一点,是很多人以为它是一颗「64 字节一页的存储器」。事实并非如此:32768 个字节在片内是连续地址空间,所谓「页」只是写操作的边界。片内有一块 64 字节写缓冲器,地址计数器只在低 6 位自增;数到第 64 个字节时,进位被丢掉,指针直接回到本页 0 号单元重新开始。这个回卷(rollover)不报错、不返回失败,只会安静地把刚写的数据盖到同一页开头。几乎所有「写进去读出来不对」的现场,根子都在 15 位地址和 6 位回卷上。把这层机制搞清楚,才算真正拿到这颗芯片写入语义的钥匙——后面那些位操作模拟 I2C 的代码,不过是把它实现出来而已。
2. 地址位图:32768 字节为什么只需要 15 位地址
2.1 从 512×64 拆出来的三段地址
24C256 型号里的 256 指 256 Kbit,除以 8 得 32768 字节。厂商手册常用 512×64 描述它:512 页,每页 64 字节。这个乘法式直接给出了地址位图——512 = 2⁹,64 = 2⁶,9 + 6 = 15,正好 15 位有效地址;16 位地址字里的最高位不做任何事。这也是很多人第一次看到「16 位地址、有效范围 0~32768」时的困惑来源:发出去的是 16 位,芯片实际采信的只有低 15 位。
| 位域 | 位宽 | 取值范围 | 含义 |
|---|---|---|---|
| bit14~bit6 | 9 | 0~511 | 页号,决定页编程落在哪一页 |
| bit5~bit0 | 6 | 0~63 | 页内偏移,决定写缓冲器从哪落下 |
| bit15 | 1 | 不用 | 发送时被器件忽略 |
把这张表写成代码,就是一个拆位函数。它不需要查手册就能自己推导出来:页大小是 2 的幂,就可以用移位和位与代替除法和取模,编译器会把它优化成一条指令。
#define EE_PAGE_SIZE 64u /* 2^6 */ #define EE_PAGE_COUNT 512u /* 2^9 */ #define EE_TOTAL 32768u /* 2^15 */ /* 9 位页号:高 9 位 */ static inline unsigned int ee_page_of(unsigned int addr) { return addr >> 6; /* 等价于 addr / 64,但更快 */ } /* 6 位页内偏移:低 6 位 */ static inline unsigned int ee_off_of(unsigned int addr) { return addr & (EE_PAGE_SIZE - 1); /* 等价于 addr % 64 */ }参数只有一个 15 位地址,返回值分别是页号和页内偏移。这两个函数的用途不同:页号用于判断一次连续写会不会跨越页编程单元,偏移用于计算「本页还剩多少空间」。把 addr 传成大于 0x7FFF 的值时,高字节的最高位会被器件忽略,实际落点回到 0 页附近,行为不可预期——所以驱动入口应该先做一次范围检查,把错误挡在总线外,而不是等它变成一堆错数据。
2.2 页号与页内偏移:形式连续、物理分层的两套编号
地址 63 的二进制是 0011 1111,地址 64 是 0100 0000。软件视角看,这是加一;芯片视角看,低 6 位从 111111 变成 000000,同时高 9 位加了一。前者只是普通加法,后者是一次翻页——写缓冲器的行为在这一点上截然不同。这就是「形式连续、物理分层」的含义:对外是一维字节数组,对内是页号加偏移的两级结构。
分层只在连续写的时候露出水面。单字节写、随机写,你怎么理解都行,因为每次写都重新发地址,芯片自己拆位,结果总对。一旦进入连续写模式,芯片只做一件事:每收一个字节,低 6 位加一,加到 64 就回卷。它既不知道你的意图是「写 11 个字节」,也不知道你后面还打算写几块。所以安全的判断标准不能是「地址看起来在递增」,只能是「这一块会不会跨过页边界」。
顺带说清一个常被误解的点:芯片并不要求你在帧里显式区分页号和页内偏移。你发的是完整 15 位地址,拆位是芯片内部的事,所以完全可以用 memcpy 风格的线性接口封装。区分页号和偏移只在主机侧计算时才有意义——那是你用来判断「会不会跨界」的工具,不是协议要求。
2.3 器件地址字节与地址指针:0xA2 / 0xA3 的来历
I2C 上每个从机有 7 位地址。24C256 的高 4 位固定是 1010,低 3 位由 A2/A1/A0 三个引脚的电平决定,全接地时就是 1010000,写地址字节 0xA2、读地址字节 0xA3,最低位是 R/W 位。引脚接了上拉,地址就变成 0xA6/0xA7 之类,这一点在换板子时最容易翻车。
写帧的结构是:START、0xA2、ACK、地址高字节、ACK、地址低字节、ACK、数据字节……、STOP。地址高字节先发,低字节后发,正好对应 15 位地址的高 7 位和低 8 位(高字节的最低位空着)。每发一个字节,主机都要在第 9 个时钟检查从机有没有拉低 SDA 应答。
读帧多一步。24C256 内部有一个地址指针寄存器,它只接受写帧里的地址字节来设置,读帧本身不携带地址信息。所以正确的读流程是「假写 + 重复起始」:
- START,发 0xA2,等 ACK
- 发地址高字节、低字节,各等 ACK
- 重新 START(不发 STOP),发 0xA3,等 ACK
- 读 N 个字节,前 N-1 个回 ACK,最后一个回 NACK
- STOP
直接 START、发 0xA3 也能读到数据,指针停在哪儿完全取决于上一次操作之后往哪走了。表现就是「读出来的值像是从别的地址开始的」,让人以为是芯片坏了。假写这一步不是多余的仪式,它是唯一能设定地址指针的手段。
3. 页写回卷:连续写为什么会在页尾吃掉开头的数据
3.1 写缓冲器与页编程:回卷的硬件根因
EEPROM 单元写入需要高于电源电压的编程电压,片内靠电荷泵升压。如果每来一个字节就充放电、打一次脉冲,写一个字节和写一页的耗时几乎一样,吞吐会非常难看。于是设计成批量提交:主机发来的连续数据先进入 64 字节的 SRAM 写缓冲器,收到 STOP 之后,电荷泵一次性把整页烧进去。典型页编程时间在 5ms 量级,写一页和写一个字节的等待差不多——这是典型的用硬件复杂度换吞吐。
代价落在地址计数器上。既然一次只提交一页,计数器就只需要 6 位自增能力:低 6 位加一,加到 64 自然回到 0,高 9 位的进位逻辑被有意省略。手册里通常写得很平淡:地址计数器在页尾回卷到本页起始地址。这句话翻译过来就是:你写第 65 个字节时,它会盖掉第 1 个字节。
回卷不是缺陷,是这个设计的自然结果。理解这点之后再看那些「数据莫名被覆盖」的现场,判断路径就很清楚:不是存储器坏了,是写指针回到页首了。
3.2 从地址 63 写起:一次回卷的逐字节推演
原文举的例子很典型:当前地址 63,处于连续写模式,写入就会出错。把四个字节 a、b、c、d 从 63 开始写,落点是这样的:
| 写入序号 | 意图地址 | 实际落点 | 说明 |
|---|---|---|---|
| 第 1 个字节 | 63 | 63 | 第 0 页最后一个单元 |
| 第 2 个字节 | 64 | 0 | 计数器回卷 |
| 第 3 个字节 | 65 | 1 | 覆盖页首旧数据 |
| 第 4 个字节 | 66 | 2 | 继续覆盖 |
结果就是 63 拿到 a,而 0、1、2 三个单元里原本存的数据被 b、c、d 顶掉。整个过程没有任何错误信号:ACK 照常返回,时序照常合法,写完读 63 也能读回 a,看上去一切正常。直到几小时后你去读 0 号单元,才发现早期数据已经不见了——这种延迟暴露的错误,排查成本比直接失败高一个数量级。
3.3 步长 11 的翻车现场:递增不等于跨页安全
更隐蔽的是步长小于 64 但又不是 64 因数的情况。原文给的例子是每次写 11 个字节,地址序列 0、11、22、33、44、55、66……肉眼扫过去,地址每次加 11,规规矩矩,没有任何一个地址超过 32768,怎么看都不像会出错。
问题出在 55 这一块。从 55 开始写 11 个字节,覆盖 55~65:55 到 63 只能放 9 个字节,第 10 个字节地址到 64,回卷落到 0,第 11 个落到 1。于是第 0 块(地址 0~10)的头两个字节被第 6 块的后两个字节覆盖。循环继续,66 号块又从头覆盖一遍。最终结果是:第 0 块的前两个字节永远是最后一块写进去的残渣,其他字节都对。
这种错误的迷惑性在于,它只在特定块上发生,而且发生的块数很少——一个 32768 字节的日志区,只有极少数位置被污染,看起来像随机的位翻转,很容易被误判成电源不稳或者芯片老化。判定方法很简单:把块大小和 64 做一次取模,只要不能整除,就必然存在跨界的块,只是早晚而已。
3.4 2 的幂对齐与补零:把块大小变成页的因数
原文给出的解决办法是唯一正确的那类:连续写模式下,块大小取 64、32、16、8、4、2,此外都不能直接用连续地址存储。原因很直接——这些数都能整除 64,只要每块的起始地址按块大小对齐,任何一块都不会跨过页边界,回卷永远不会发生。
如果采集到的有效数据不足一个整块,比如每次 30 字节,就按 32 字节为单位存,不足的补零。写放大约 6.7%,换来的是绝对的安全性,这个交换很划算。
| 单次有效字节 | 建议块大小 | 补零字节 | 写放大 |
|---|---|---|---|
| 30 | 32 | 2 | 1.07 |
| 20 | 32 | 12 | 1.60 |
| 12 | 16 | 4 | 1.33 |
| 60 | 64 | 4 | 1.07 |
| 100 | 需拆两块 | 视方案 | 至少 1.28 |
注意表格最后两行。块大小超过 64 时,单块必然跨页,2 的幂规则就失效了,必须走拆分逻辑;而 20 字节这种补充零后放大到 1.6 倍的,说明块大小选得偏大,应该往下降一档到 16,或者先把多次采集聚合满 64 字节再一次性写,减少编程次数。
3.5 驱动层拆分跨页写:比约定命名更可靠的做法
靠「命名规范」和「开发约定」保证不跨界,在实际项目里迟早会破防——换个同事写采集逻辑,或者需求把块大小从 32 改成 40,约定就在无声中失效了。更稳的做法是把拆分逻辑放进驱动层,让上层接口长得像 memcpy,但内部绝不跨页:
#define EE_PAGE_SIZE 64u /* 写任意长度,内部自动按页拆分,永不触发回卷 */ int ee_write(unsigned int addr, const unsigned char *buf, unsigned int len) { while (len > 0) { unsigned int off = addr & (EE_PAGE_SIZE - 1); /* 本页内偏移 0~63 */ unsigned int room = EE_PAGE_SIZE - off; /* 本页还能写几个字节 */ unsigned int chunk = (len < room) ? len : room; /* 关键:一次绝不越过页边界 */ if (ee_page_write(addr, buf, chunk) != 0) /* 单页写:发帧、STOP、等 tWR */ return -1; addr += chunk; /* 下一轮地址必然页对齐 */ buf += chunk; len -= chunk; } return 0; }三个中间变量的分工很明确。off由地址算出当前处在页内第几个字节,room是本页剩余空间,chunk取剩余长度和本页空间中的较小值——正因为这一句,本次写入永远落在同一页内,第 64 个字节的进位逻辑根本没有机会触发。循环更新地址之后,addr必然落在页边界上,所以off一定是 0,room恒为 64,后续每一轮都是整页写。
ee_page_write是真正发 I2C 帧的那一层,负责 START、器件地址、两个地址字节、数据流、STOP,以及 STOP 之后的 tWR 等待。参数len的约束是0 < len <= 64;如果上层传进来 0,需要提前返回,否则就要面对下一节会讲到的循环计数陷阱。
4. 位操作模拟 I2C:方向寄存器当开漏用的底层逻辑
4.1 用 P2DIR 切换方向:一根 GPIO 怎么做成开漏
I2C 是开漏总线,靠上拉电阻拉高,任何器件只能主动拉低、不能主动推高。很多单片机没有硬件 I2C 外设,或者引脚被别的功能占了,就只能用两根普通 GPIO 模拟。但普通 GPIO 是推挽输出:写 1 是强高、写 0 是强低,两个器件同时一高一低就是电源对地短路。
标准解法是外接开漏缓冲器,更省成本的做法是利用 GPIO 的输入输出切换。输出锁存器固定写 0,用方向寄存器当开关:DIR 对应位置 1 时引脚输出 0,把线拉低;置 0 时引脚变高阻输入,等于松开手,由上拉电阻把线拉高。项目代码里P2OUT &= 0xf9就是干这件事——先把 P2.1、P2.2 的输出锁存清成 0,之后所有操作只碰 P2DIR,那一位就是这根线的「拉低/松开」开关。这就是常见的准双向口用法,也是这套代码能 work 的电气前提。
| 引脚 | 掩码 | 角色 | 拉低 | 松开 |
|---|---|---|---|---|
| P2.1 | 0x02 | SDA | DIR |= 0x02 | DIR &= ~0x02 |
| P2.2 | 0x04 | SCL | DIR |= 0x04 | DIR &= ~0x04 |
写成宏,可读性比裸写 0xFD、0xFB 好很多,也不容易抄错位:
#define SDA_LOW() (P2DIR |= 0x02) /* 输出 0,主动拉低 */ #define SDA_REL() (P2DIR &= ~0x02) /* 变输入,上拉拉高 */ #define SCL_LOW() (P2DIR |= 0x04) #define SCL_REL() (P2DIR &= ~0x04) #define SDA_READ() ((P2IN & 0x02) != 0) /* 读实际电平,不是读输出锁存 */读电平必须读 P2IN,不能读 P2OUT。P2OUT 读回来的是你写进去的锁存值,跟线上真实电平没关系——当从机把 SDA 拉低时,P2OUT 仍然显示你写的 0 或 1,这也是 ACK 检测写错时的经典坑。
4.2 起始位与停止位:SCL 高电平期间的 SDA 跳变
I2C 把「SCL 高电平期间 SDA 由高变低」定义为起始条件,把「SCL 高电平期间 SDA 由低变高」定义为停止条件。这个定义很讲究:正常传数据时,SDA 只允许在 SCL 低电平期间变化,SCL 高电平期间必须稳定,从机就是在高电平窗口采样的。所以起始和停止用的是「违反常规」的动作来传递信息。
原文的实现顺序是对的,值得逐行看:
void start_IIC(void) { P2OUT &= 0xf9; /* 先把 SDA/SCL 输出锁存清 0,之后靠 DIR 控制 */ P2DIR &= 0xFD; /* SDA 松开 -> 上拉拉高 */ P2DIR &= 0xFB; /* SCL 松开 -> 上拉拉高 */ P2DIR |= 0x02; /* SCL 仍为高时,SDA 拉低 -> 起始条件 */ P2DIR |= 0x04; /* 拉低 SCL,进入数据传输 */ } void stop_IIC(void) { P2DIR |= 0x02; /* SDA 保持低 */ IIC_Delay(); P2DIR &= 0xFB; /* SCL 松开 -> 高 */ P2DIR &= 0xFD; /* SCL 高电平期间 SDA 松开 -> 停止条件 */ IIC_Delay(); P2DIR |= 0x04; /* 拉低 SCL,总线回到空闲的稳定态 */ }起始位里 SDA 拉低之后才拉低 SCL,顺序反了就变成「SCL 低电平期间 SDA 变化」,从机根本不会识别为起始;停止位里必须先让 SDA 保持低、先把 SCL 松开,再松开 SDA,否则生成的是一次起始条件而不是停止条件,总线会被卡在中途,后续所有通信全部失败——现场表现就是「程序突然不响应了,复位才好」。
4.3 收发一个字节:MSB 先行与采样窗口
发送是逐位推送,从最高位开始。每个位周期里,数据在 SCL 低电平时准备好,SCL 拉高让从机采样,再拉低进入下一位:
void send_char(unsigned char data_out) { unsigned char i, tmp = 0x80; /* 从 bit7 开始 */ for (i = 0; i < 8; i++) { if ((data_out & tmp) > 0) send_one(); /* 松开 SDA 再松开 SCL */ else send_zero(); /* 先拉低 SDA 再松开 SCL */ tmp /= 2; /* 或 >>= 1,效果相同 */ } } unsigned char read_char(void) { unsigned char i, tmp = 0x80, data1 = 0; for (i = 0; i < 8; i++) { SDA_REL(); /* 主机让出 SDA,交给从机驱动 */ IIC_Delay(); SCL_REL(); /* 高电平期间从机数据有效 */ IIC_Delay(); if (SDA_READ()) data1 |= tmp; SCL_LOW(); IIC_Delay(); tmp /= 2; } return data1; }send_one和send_zero的差别就在先后顺序:发 1 是「先松开 SDA,再松开 SCL」,保证 SCL 上升沿时数据已经稳定为高;发 0 是「先拉低 SDA,再松开 SCL」。如果两个函数写反了顺序,会出现「SCL 已经高电平了数据才跳变」的情况,从机会把它误判成起始或停止条件,表现为写进去的数据整体错位。
读的时候有个容易忽略的前提:read_char假设从机在 SCL 拉低期间就准备好了下一位,所以主机松开 SCL 之后稍等片刻就能采样。严格按手册应该核对建立时间,实际工程里靠IIC_Delay()兜底——三个_NOP()在低频主频下刚好覆盖。如果主频调高,仍然用三次空操作,采样时机可能过早,读到的是上一位的残留值。
4.4 ACK 检测与 while 死等:应答阶段的隐式约定
第 9 个时钟是应答窗口。主机松开 SDA,让从机拉低表示「我收到了」。原文的iic_ACK用了一个非常直接的写法:
void iic_ACK(void) { ack_flag = 0x00; SDA_REL(); /* 主机让出 SDA */ IIC_Delay(); SCL_REL(); /* 第 9 个时钟高电平 */ IIC_Delay(); while ((P2IN & BIT1) == BIT1); /* 死等:直到从机把 SDA 拉低 */ SCL_LOW(); IIC_Delay(); }逻辑是对的,但没有出口。器件没接、地址引脚接错、总线被别的器件拉住、上电时序不对,这个循环都会永不退出。现场表现是「程序卡死在写函数里,看门狗都没来得及复位」——这种 bug 在现场极难定位,因为屏幕上什么信息都没有。
给它加一个超时计数,是这套位操作代码里最值得做的一处加固:
/* 返回 0 表示从机应答,-1 表示超时 */ int iic_wait_ack(unsigned int timeout) { SDA_REL(); IIC_Delay(); SCL_REL(); /* 第 9 个时钟高电平 */ IIC_Delay(); while ((P2IN & 0x02) != 0) { if (--timeout == 0) { /* 关键:死等必须有出口 */ SCL_LOW(); return -1; } } SCL_LOW(); IIC_Delay(); return 0; }timeout取几百到几千次循环比较合适:太小会在正常器件上误判超时,太大则卡住的时间接近原来的死等。返回值一律向上传,让上层能决定重试还是报错,而不是默默重试。
4.5 WriteNbyte / ReadNbyte:完整帧与 tWR 静默期
把前面几块拼起来就是完整的多字节写。原文的WriteNbyte结构很标准:起始、0xA2、地址高字节、地址低字节、数据流、停止,最后delay(10)。
void WriteNbyte(unsigned char *p, unsigned int addr, unsigned char number) { start_IIC(); send_char(0xa2); /* 器件地址 + 写 */ iic_ACK(); send_char(addr / 256); /* 地址高字节 */ iic_ACK(); send_char(addr % 256); /* 地址低字节 */ iic_ACK(); do { send_char(*p); p++; iic_ACK(); } while (--number); /* 陷阱在下一段文字里 */ stop_IIC(); delay(10); /* 等内部编程完成 */ }这段代码有个典型陷阱:number是unsigned char,如果调用方传 0,do-while不会跳过,而是先写一个字节、然后--number从 0 回绕到 255,一直减到 0 才退出——等于连续写了 256 个字节,把整页反复覆盖好几遍。这属于血泪经验级的问题:调用点传 0 往往来自上层「这次采集没采到数据」的分支,代码看着完全正常。动手之前先判 0 返回,比事后加日志有用得多。
delay(10)不是随手加的。STOP 之后芯片要花 tWR 做内部编程,典型 5ms,最大可能到 10ms 量级。这期间器件不响应任何 I2C 命令,包括地址帧,所以读回来的 ACK 是空的。把等待写成显式函数ee_wait_ready(),比散落在各处的delay更可控——它不仅说明「在等什么」,还能改成 ACK 轮询(反复发器件地址,等到应答出现即表示编程完成),把平均等待从固定 10ms 压到几毫秒。
读的多字节版本多两个细节:先假写设地址指针,再重复起始切读;循环里判断number != 1时才发 ACK,最后一个字节发 NACK。这个边界很容易写错——如果在最后一个字节也回了 ACK,从机会认为主机还想读,继续往总线上推一个字节,地址指针跟着往后走一位,下一次读就会从错误的位置开始。
void ReadNbyte(unsigned char *p, unsigned int addr, unsigned char number) { start_IIC(); send_char(0xa2); /* 假写:只为设置内部地址指针 */ iic_ACK(); send_char(addr / 256); iic_ACK(); send_char(addr % 256); iic_ACK(); start_IIC(); /* 重复起始,不发 STOP */ send_char(0xa3); /* 器件地址 + 读 */ iic_ACK(); do { *p = read_char(); p++; if (number != 1) S_ACK(); /* 不是最后一个字节,回应答继续读 */ } while (--number); iic_NACK(); /* 最后一个字节明确拒绝 */ stop_IIC(); }参数number同样不能传 0。地址部分和写操作完全一致,区别只在最后那个0xa2变成了0xa3,以及 ACK 的方向反过来——读的时候是主机回应答。
注意:tWR 期间读到的数据未定义,可能是旧值、可能是 0xFF,不要拿这段窗口里的读回值做校验,否则会得到一次假的「写失败」告警。
5. 常见问题排查:五类看起来像芯片坏了的现场
5.1 页尾写入后,页首数据被悄悄改写
现象:按固定长度循环记录,跑几轮之后回头读,最早的几条记录开头几个字节变成了最新记录的内容,中间的数据全对。
原因:块大小不是 64 的因数,或者起始地址没有按块大小对齐,某一块写到了页尾之后回卷,覆盖了同页开头的单元。步长为 11 那类配置是重灾区。
解决:把块大小改成能整除 64 的 2 的幂,不足补零;或者直接改用拆分式写函数,让写入粒度永远不超过「本页剩余空间」。改完之后做一次回归:把 0 到 511 每一页的最后一个字节单独标记,写满整个区域后回读校验,能一次性暴露所有跨界点。
5.2 连续读回的字节整体错位
现象:读 N 个字节,内容像是从别的地址开始的,而且首字节经常等于上一次操作末尾的数据。
原因:漏了假写,地址指针停在上一轮的位置;或者ReadNbyte里 ACK/NACK 的边界判断写错,最后一个字节也回了 ACK,从机多推一个字节,指针往后跑了一位。
解决:读操作之前一律完整发送地址帧,不要指望指针停在对的地方;检查number != 1这个条件是不是写成了number > 1或漏判。定位手段很直接:读之前先写一个已知的哨兵字节到目标地址,读回来首字节不是它就是指针没设对。
5.3 上电首次访问全部失败,断电重来又正常
现象:冷启动第一次写,ACK 一直等不到,程序卡在写函数里;断电重新上电,有时又完全正常,像是玄学。
原因:主控复位比 EEPROM 快,器件还在上电复位或内部编程周期里,不响应地址帧;另一种情况是上一次异常复位发生在传输中途,从机仍占着 SDA 不放,总线被锁死。
解决:上电后先延时几毫秒再访问;所有 ACK 等待加超时;对总线锁死,补一段解锁时序——主机发 9 个 SCL 时钟,让从机把手里没发完的字节吐完,再补一个停止条件,总线就恢复了。这段代码不长,但能救回大量现场返修。
5.4 写完立刻读,读到旧值或 0xFF
现象:写完马上回读校验,读到的是写之前的旧数据,隔一会儿再读又是对的。
原因:tWR 没到。器件在内部编程期间不响应命令,此时读出来的地址指针依然是旧的,数据自然也是旧的。如果delay(10)被优化掉、被注释掉,或者调用的是没带等待的裸写函数,就会稳定复现。
解决:STOP 之后必须等 tWR;更稳的做法是 ACK 轮询,反复发器件地址,直到出现应答再继续,平均耗时比固定延时短。校验逻辑也要跟着改——写后立即校验在语义上就是错的,应该等 ready 之后再校验。
5.5 ACK 等待卡死,程序不返回
现象:程序死在while循环里,看门狗复位后又能跑一阵,反复如此。
原因:ACK 检测没有出口。地址引脚接错(A2/A1/A0 电平与代码里的 0xA2 不符)、上拉电阻没焊、器件未贴片,都会让从机永远不应答,循环就永远不退出。
解决:加超时并向上返回错误码;用示波器看第 9 个时钟 SDA 有没有被拉低,一眼就能区分「器件没应答」和「时序不对」;确认地址字节和引脚电平一致,这一步用万用表量比读代码快。
6. 设计取舍:把页当成写事务的原子单位
页边界该由谁负责,是这套驱动里唯一需要认真权衡的设计问题。把拆分藏进驱动层,上层拿到的是 memcpy 一样顺手的接口,代价是写入耗时不再均匀——一次 100 字节的写会被拆成两页,花掉两个 tWR,而调用方从签名上完全看不出来。如果这行代码正好在中断里,两次 5ms 的等待足以让整个系统掉帧,而排查的人只会看到「偶尔卡一下」。
另一种做法是把页边界暴露给上层,让调用方自己对齐。好处是耗时透明、写放大可控,坏处是每个调用点都得记住「块大小必须是 64 的因数」这条约定——步长 11 那种翻车就是这么来的,写代码的人当时觉得地址在递增所以没问题。
我倾向的折中是三层:驱动层拆分,保证正确性兜底;编译期断言块大小是 64 的因数,把约定固化成编译错误而不是运行事故;把 tWR 等待做成显式的ee_sync(),让调用方自己决定在哪里承担这段延迟,而不是藏在写函数里偷偷睡 10ms。补零的代价也要算清楚:30 字节补到 32 只放大 6.7%,可以接受;20 字节补到 32 放大 60%,就该考虑改块大小或者先聚合到 64 再写。这些数字不写在注释里,下一个人就得重新推一遍。
那以后我每次写这类存储驱动,都会强制走一遍「算页内剩余、截断、循环」的流程,宁可多几十行代码,也不再相信「地址每次加一点看着没问题」。希望帮到你。
本文还有配套的精品资源,点击获取