拓冰建站拓冰建站
首页 / 资讯中心 / 正文

CAT24C16页写与页读底层原理及实战实现

1. 这不是“调个库就完事”的 EEPROMCAT24C16 的页写与页读为什么必须抠到底层你手头有一块 CAT24C16 —— 16Kbit2KB容量、I²C 接口、工业级温度范围、支持硬件写保护的串行 EEPROM。它被焊在你的主控板上负责存校准参数、用户配置、运行日志或设备唯一标识。你查了数据手册知道它支持“页写”Page Write也看到示例代码里用Wire.write()一口气发了 16 字节——但你有没有想过这 16 字节真的能稳稳落进同一个物理页如果第 8 字节刚好撞在页边界上后续字节会不会被“折回”到页首更关键的是当你用Wire.requestFrom()读取连续 32 字节时地址指针是自动递增的吗跨页读会不会出错这些看似底层、枯燥、甚至“过时”的问题在你调试一个连续掉电后参数错乱的终端设备时会突然变成凌晨三点的噩梦。CAT24C16 不是 Flash也不是 RAM。它的存储单元基于浮栅 MOSFET写入需施加高电压内部电荷泵生成擦除与写入本质是同一过程且有严格的寿命限制典型 100 万次。而 I²C 总线本身是开漏结构速率受总线电容和上拉电阻制约标准模式仅 100kHz快速模式才 400kHz。这意味着一次完整的页写操作从起始条件、地址发送、数据逐字节确认到内部写周期最大 10ms结束整个链条环环相扣任何一环松动数据就悬在半空。我见过太多项目前期用 Arduino 库跑通功能就交付后期量产时因电源波动导致 ACK 失败、或未等待写完成就发起下一次访问最终 EEPROM 里存的是一堆不可预测的随机值。这不是玄学是物理定律和协议时序共同作用的结果。这篇文章不讲怎么用 HAL 库初始化 I²C也不贴一段能编译通过的“Hello World”代码。我们要做的是把 CAT24C16 的页写与页读从数据手册的 PDF 文件里“抠”出来还原成你能亲手控制每一个 SCL 电平、每一个 SDA 采样点、每一个 ACK/NACK 判定的底层实现。无论你用的是 STM32 的裸机寄存器、中颖 SH79F 的汇编、还是 FPGA 里的 Verilog 状态机核心逻辑完全一致。下面我们就从最根本的物理层开始一层层剥开。2. 页写不只是“发16个字节”而是对地址空间与内部时序的精确驾驭2.1 CAT24C16 的页结构与地址映射为什么16字节是硬性上限CAT24C16 的 2048 字节地址空间并非线性平坦。它被划分为128 个页Page每页16 字节。这个“16”不是厂商拍脑袋定的而是由其内部地址解码器的物理设计决定的。芯片内部有一个 4 位页内地址计数器Page Address Counter它只保留低 4 位地址A3-A0。当你向 CAT24C16 发送一个写地址例如 0x000F芯片接收到后会将该地址的 A3-A0即 0xF 1111b存入这个计数器同时将高 7 位A10-A4作为页号锁存。接下来你发送的第一个数据字节会被写入页号对应的页中、地址为 0x000F 的位置。第二个字节计数器自动加 1变为 0x0000因为 1111b 1 0000b产生进位但这个进位不会向上影响页号。结果就是第二个字节被错误地写入了同一页的起始地址0x0000覆盖了之前的数据。这就是所谓的“页内地址自动回绕”。提示页写操作的地址必须落在同一页内。计算公式为页号 地址 4。例如地址 0x0000 到 0x000F 属于页 00x0010 到 0x001F 属于页 1以此类推直到页 1270x07F0 - 0x07FF。任何一次页写操作起始地址addr必须满足(addr 0x000F) len 0x10其中len是你要写的字节数。否则就必须拆分成两次写操作。我曾经在一个温控器项目里踩过这个坑。客户要求记录 20 条历史温度数据每条占 10 字节共 200 字节。开发时图省事直接用一个大数组uint8_t history[200]然后eeprom_write_page(0x0000, history, 200)。结果发现每写满 16 字节后面的数据就全乱了。用逻辑分析仪抓波形才发现第 16 个字节地址 0x000F之后第 17 个字节本应是 0x0010被发到了地址 0x0000。根源就在于没做页边界检查。后来改成循环判断for (int i 0; i 200; i 16) { int page_start (0x0000 i) ~0x000F; int write_len min(16, 200 - i); eeprom_write_page(page_start, history[i], write_len); }问题立刻解决。2.2 页写的完整 I²C 事务从 START 到 STOP每个信号都关乎成败一次成功的页写是一个标准的 I²C 写事务但其内部状态机比普通写复杂得多。我们以向页 0地址 0x0000写入 16 字节为例详细拆解START 条件主控拉低 SDA此时 SCL 为高再拉低 SCL。这是事务的绝对起点。发送从机地址 写标志CAT24C16 的 7 位地址是 0x50二进制 1010000加上最低位 R/W0写构成 8 位地址字节0xA0。主控发送此字节然后释放 SDA采样 SCL 高电平期间的 SDA 电平。若从机正确响应会在第 9 个时钟周期将 SDA 拉低即发送ACK。这是第一个关键确认点。如果此处无 ACK说明从机没上电、地址错、或总线被占用。发送内存地址2 字节CAT24C16 是 11 位地址空间0x0000-0x07FF因此需要发送两个字节高字节A10-A3和低字节A2-A0补 5 个 0。对于地址 0x0000就是发送0x00和0x00。每发一个字节都必须等待从机的 ACK。这里有个极易被忽略的细节地址字节的发送顺序是先高后低。很多初学者误以为是“网络字节序”其实 I²C 协议规定高位在前低位在后这是硬件层面的约定。发送数据字节16 个紧接着地址之后主控连续发送 16 个数据字节。每个字节后从机都必须返回 ACK。这是页写能否成功的核心。如果在第 8 个字节后从机因内部写缓冲区满虽然 CAT24C16 缓冲区就是一页大小理论上能撑住 16 字节或电源不稳而无法响应 ACK主控必须立即终止事务否则后续数据全部丢失。STOP 条件在最后一个数据字节的 ACK 后主控拉高 SDA此时 SCL 为低再拉高 SCL。这标志着本次写事务的结束。注意STOP 之后CAT24C16 才真正开始其内部的“写周期”Write Cycle。这个周期最长可达 10ms在 Vcc5V, Ta25°C 条件下在此期间芯片对任何 I²C 访问都会返回 NACK。这意味着你不能在发送完 STOP 后立刻去读它或者发起下一次写。必须等待这个内部周期完成。2.3 “写完成”检测轮询 vs. 定时哪种更可靠如何知道内部写周期结束了数据手册提供了两种方法定时等待Fixed Delay最简单粗暴。在发送 STOP 后主控执行一个delay_ms(10)。优点是代码极简缺点是“过度保守”。10ms 是最大值在常温、电压稳定时实际可能只需 3-5ms。对于实时性要求高的系统如电机控制10ms 的死等是不可接受的。轮询Polling更智能的方法。主控在 STOP 后立即发起一个新的 I²C 写事务发送从机地址0xA0。如果 EEPROM 内部写尚未完成它将不响应 ACK即 SDA 保持高电平主控检测到 NACK就重试。一旦检测到 ACK说明写已完成可以进行下一步操作。轮询的伪代码如下uint8_t eeprom_wait_write_complete(uint8_t dev_addr) { uint8_t retries 100; // 设置最大重试次数防死循环 while (retries--) { // 发送 START i2c_start(); // 发送地址写 if (i2c_write_byte(dev_addr)) { // 返回 1 表示收到 ACK return 1; // 写完成 } // 如果没收到 ACK发送 STOP 并稍作延时再重试 i2c_stop(); delay_us(100); // 短暂延时避免总线冲突 } return 0; // 超时失败 }实测下来轮询法在绝大多数场景下平均耗时在 4-6ms比固定 10ms 节省近一半时间。而且它能真实反映芯片状态是工业级应用的首选。我在一个使用中颖 SH79F085 的燃气表项目中就强制要求所有 EEPROM 写操作后必须轮询。因为燃气表对可靠性要求极高哪怕 0.1% 的写失败率都可能导致计量错误引发严重纠纷。3. 页读地址指针的隐式递增与跨页读的陷阱规避3.1 读操作的本质地址指针的“隐形”行为与写操作不同I²C 读操作本身不包含地址信息。当你想从 EEPROM 读数据时必须先用一个“写事务”来设置内部的地址指针Address Pointer然后再用一个“读事务”来读取数据。这个地址指针是一个 11 位的寄存器它决定了下一个读操作从哪里开始取数。标准的“当前地址读”Current Address Read流程是发送 START。发送从机地址 写标志 (0xA0)等待 ACK。发送要读取的起始地址2 字节等待 ACK。至此地址指针已被设置。发送 REPEATED START重复起始条件。发送从机地址 读标志 (0xA1)等待 ACK。主控接收数据字节。每接收一个字节地址指针自动加 1。在接收最后一个字节前主控发送 NACK然后发送 STOP。这个“自动加 1”是硬件实现的对用户透明。但正是这个透明性埋下了跨页读的隐患。3.2 跨页读的真相指针溢出与“回绕”是设计特性不是 BugCAT24C16 的地址指针是 11 位的范围是 0x0000 到 0x07FF。当它从 0x07FF 加 1 时会自然溢出为 0x0000。这意味着如果你从地址 0x07FF 开始读取 2 个字节你会得到data[0] EEPROM[0x07FF]data[1] EEPROM[0x0000]。这是一个明确的设计特性数据手册称之为“Wraparound Read”。然而问题在于“页”。页是物理存储单元而地址指针的溢出是逻辑上的。当你从页 127 的最后一个地址0x07FF开始读读取 2 字节第二字节确实会落到页 0 的开头。这本身没有错。但如果你的意图是“读取页 127 的最后 1 字节和页 0 的前 1 字节”那没问题但如果你的意图是“读取页 127 的最后 2 字节”那你就错了因为页 127 的倒数第二字节是 0x07FE而不是 0x07FF。更常见的错误是“跨页连续读”。例如你想读取地址 0x000F 到 0x0013共 5 字节。这跨越了页 00x0000-0x000F和页 10x0010-0x001F。按照自动递增规则读取顺序是0x000F - 0x0010 - 0x0011 - 0x0012 - 0x0013。这完全正确数据是连贯的。所以跨页读在逻辑上是完全支持的且是安全的。我之所以强调这一点是因为网上很多资料把它妖魔化了说“跨页读会出错”这是对数据手册的误读。真正的风险在于“跨页写”而非“跨页读”。3.3 页读的底层实现REPEATED START 的时序精度是关键页读的难点不在于地址计算而在于REPEATED START 的时序控制。在标准 I²C 协议中REPEATED START 是在 SCL 为高时SDA 从高变低。这个转换必须发生在 SCL 高电平的“有效窗口”内否则从机可能无法识别。在裸机编程中手动模拟 I²C 时序时这个点最容易出错。例如一个常见的错误序列是完成地址写事务的 STOP。立即拉低 SDA准备 START。然后拉低 SCL。这实际上是发出了一个STOP START的组合而不是 REPEATED START。正确的做法是在地址写事务的最后一个字节的 ACK 后SCL 为低。将 SDA 从低拉高释放总线。等待 SCL 变为高此时总线空闲。在 SCL 保持为高的期间将 SDA 从高拉低。这才是 REPEATED START。这个时序差之毫厘谬以千里。在 FPGA 实现中我曾用 Verilog 写了一个简单的 I²C Master FSM。最初我在状态WAIT_FOR_SCL_HIGH后没有严格等待 SCL 稳定为高至少 4.7μs标准模式下就急于拉低 SDA导致 CAT24C16 偶尔不响应读出来的数据全为 0xFF。后来在WAIT_FOR_SCL_HIGH状态里加入一个 5μs 的计数器并确保 SDA 变化只发生在 SCL 高电平的后半段问题彻底消失。对于使用 MCU 的开发者如果用的是硬件 I²C 外设这个问题通常由外设自动处理。但如果你用的是软件模拟Bit-banging就必须自己严格把控这个时序。这也是为什么我强烈建议在资源允许的情况下优先使用硬件 I²C。它不仅能解放 CPU更能保证时序的绝对精准。4. 实操从零开始的裸机页写/页读函数以 STM32F103 为例4.1 硬件基础与引脚配置我们以最常见的 STM32F103C8T6“蓝 pill”为例。I²C1 的 SCL 引脚是 PB6SDA 引脚是 PB7。上拉电阻至关重要它决定了总线的上升沿速度。根据 I²C 标准模式100kHz的要求总线电容包括 PCB 走线、器件输入电容应小于 400pF。上拉电阻R_p的计算公式为R_p(min) Vcc / 3mA3mA 是 I²C 规范定义的灌电流能力R_p(max) t_r / (0.8473 * C_b)t_r是最大上升时间100kHz 下为 1000nsC_b是总线电容。对于典型的 100pF 总线电容R_p的推荐值在 2.2kΩ 到 10kΩ 之间。我实测下来4.7kΩ 是一个非常平衡的选择既能保证足够快的上升沿又不会在总线低电平时消耗过大电流。在SystemInit()之后我们需要初始化 I²C1void I2C1_Init(void) { RCC-APB2ENR | RCC_APB2ENR_IOPBEN; // 使能 GPIOB 时钟 RCC-APB1ENR | RCC_APB1ENR_I2C1EN; // 使能 I2C1 时钟 // 配置 PB6(SCL) 和 PB7(SDA) 为开漏输出 GPIOB-CRH ~(GPIO_CRH_MODE6 | GPIO_CRH_CNF6 | GPIO_CRH_MODE7 | GPIO_CRH_CNF7); GPIOB-CRH | GPIO_CRH_CNF6_0 | GPIO_CRH_CNF7_0; // CNF 10 (开漏) GPIOB-CRH | (GPIO_CRH_MODE6_1 | GPIO_CRH_MODE7_1); // MODE 10 (50MHz) // I2C1 配置标准模式100kHz7位地址 I2C1-CR2 0x10; // 使能时钟频率为 APB1 频率 (36MHz)这里设为 10MHz 量级 I2C1-OAR1 0x0000; // 从机地址这里主控不用 I2C1-CCR (36000000 / (100000 * 2)) 0xFFF; // CCR Freq/(2*Speed), 36MHz APB1 I2C1-TRISE 36 1; // TRIS Freq/1MHz 1 I2C1-CR1 I2C_CR1_PE; // 使能 I2C1 }这段初始化代码的关键在于CCR寄存器的计算。CCR决定了 SCL 的低/高电平时间。CCR (PCLK1 / (2 * I2C_Speed))。对于 36MHz 的 APB1 时钟和 100kHz 的 I²C 速度CCR应为 180。但 STM32 的CCR寄存器还有额外的时钟分频逻辑所以最终值是36000000 / (100000 * 2) 180再与0xFFF按位与确保不超限。4.2 底层 I²C 原语START, STOP, WRITE_BYTE, READ_BYTE所有高级操作都建立在这些原子操作之上。它们必须是阻塞式的并且要处理超时。// 发送 START 条件 void I2C1_Start(void) { while (I2C1-SR2 I2C_SR2_BUSY); // 等待总线空闲 I2C1-CR1 | I2C_CR1_START; // 发送 START while (!(I2C1-SR1 I2C_SR1_SB)); // 等待 SB 标志START 已发送 } // 发送 STOP 条件 void I2C1_Stop(void) { I2C1-CR1 | I2C_CR1_STOP; // 发送 STOP } // 写一个字节并等待 ACK // 返回 1 表示收到 ACK0 表示 NACK 或超时 uint8_t I2C1_WriteByte(uint8_t byte) { I2C1-DR byte; // 写入数据寄存器 while (!(I2C1-SR1 I2C_SR1_TXE)); // 等待数据寄存器为空 while (!(I2C1-SR1 I2C_SR1_ADDR)); // 等待 ADDR 标志地址已发送 // 清除 ADDR 标志读 SR1再读 SR2 __IO uint32_t tmp I2C1-SR1; tmp I2C1-SR2; while (!(I2C1-SR1 I2C_SR1_BTF)); // 等待 BTF字节传输完成 // 检查 ACK if (I2C1-SR1 I2C_SR1_AF) { I2C1-SR1 ~I2C_SR1_AF; // 清除 AF 标志 return 0; // NACK } return 1; // ACK } // 读一个字节发送 ACK非最后一个字节或 NACK最后一个字节 uint8_t I2C1_ReadByte(uint8_t ack) { if (ack) { I2C1-CR1 | I2C_CR1_ACK; // 发送 ACK } else { I2C1-CR1 ~I2C_CR1_ACK; // 发送 NACK } while (!(I2C1-SR1 I2C_SR1_RXNE)); // 等待数据寄存器非空 return (uint8_t)I2C1-DR; // 读取数据 }这些函数是整个 I²C 通信的基石。其中I2C1_WriteByte的 ACK 检测逻辑尤为关键。AFAcknowledge Failure标志位在从机未拉低 SDA 时置位。我们必须在每次写操作后检查它否则无法得知从机是否在线。I2C1_ReadByte中的ack参数决定了是继续读ACK还是终止读NACK这是实现任意长度读取的核心。4.3 页写函数eeprom_write_page()现在我们可以组装出核心的页写函数。它必须包含页边界检查、地址发送、数据发送和写完成轮询。// CAT24C16 页写向指定地址写入最多 16 字节 // addr: 11-bit 地址 (0x0000 - 0x07FF) // data: 数据缓冲区指针 // len: 要写的字节数 (1-16) // 返回 1 表示成功0 表示失败 uint8_t eeprom_write_page(uint16_t addr, uint8_t *data, uint8_t len) { uint8_t i; uint8_t dev_addr 0x50 1; // 7-bit 地址左移一位R/W0 // 1. 页边界检查 uint8_t page_offset addr 0x000F; if ((page_offset len) 16) { return 0; // 跨页非法 } // 2. 发送 START I2C1_Start(); // 3. 发送从机地址 写 if (!I2C1_WriteByte(dev_addr)) { I2C1_Stop(); return 0; } // 4. 发送 11-bit 地址 (2 字节) if (!I2C1_WriteByte((addr 8) 0xFF)) { // 高字节 I2C1_Stop(); return 0; } if (!I2C1_WriteByte(addr 0xFF)) { // 低字节 I2C1_Stop(); return 0; } // 5. 发送数据字节 for (i 0; i len; i) { if (!I2C1_WriteByte(data[i])) { I2C1_Stop(); return 0; } } // 6. 发送 STOP I2C1_Stop(); // 7. 等待写完成轮询 if (!eeprom_wait_write_complete(dev_addr)) { return 0; } return 1; } // 写完成轮询函数 uint8_t eeprom_wait_write_complete(uint8_t dev_addr) { uint8_t retries 100; while (retries--) { I2C1_Start(); if (I2C1_WriteByte(dev_addr)) { // 收到 ACK表示写完成 I2C1_Stop(); return 1; } I2C1_Stop(); delay_us(100); } return 0; }这个函数的健壮性体现在三个地方一是严格的页边界检查杜绝了“回绕”错误二是每一步操作后都检查 ACK任何一个环节失败都立即返回错误三是使用轮询而非固定延时保证了实时性。你可以把它当作一个“黑盒”函数来调用只要传入合法的地址和数据就能得到确定的结果。4.4 页读函数eeprom_read_page()页读函数同样需要严谨。它要处理 REPEATED START并支持任意长度的读取不超过页大小。// CAT24C16 页读从指定地址读取指定长度的数据 // addr: 11-bit 地址 (0x0000 - 0x07FF) // data: 数据缓冲区指针 // len: 要读的字节数 (1-16) // 返回 1 表示成功0 表示失败 uint8_t eeprom_read_page(uint16_t addr, uint8_t *data, uint8_t len) { uint8_t i; uint8_t dev_addr_w 0x50 1; // 写地址 uint8_t dev_addr_r (0x50 1) | 0x01; // 读地址 // 1. 发送 START I2C1_Start(); // 2. 发送从机地址 写 if (!I2C1_WriteByte(dev_addr_w)) { I2C1_Stop(); return 0; } // 3. 发送 11-bit 地址 (2 字节) if (!I2C1_WriteByte((addr 8) 0xFF)) { I2C1_Stop(); return 0; } if (!I2C1_WriteByte(addr 0xFF)) { I2C1_Stop(); return 0; } // 4. 发送 REPEATED START I2C1_Start(); // 硬件 I²C 自动处理 REPEATED START // 5. 发送从机地址 读 if (!I2C1_WriteByte(dev_addr_r)) { I2C1_Stop(); return 0; } // 6. 读取数据 for (i 0; i len; i) { if (i (len - 1)) { // 最后一个字节发送 NACK data[i] I2C1_ReadByte(0); } else { // 其他字节发送 ACK data[i] I2C1_ReadByte(1); } } // 7. 发送 STOP I2C1_Stop(); return 1; }这个函数的精妙之处在于第 4 步I2C1_Start()。在硬件 I²C 外设中当你在一次事务未结束即未发送 STOP时再次调用I2C1_Start()外设会自动将其解释为 REPEATED START无需你手动控制 SDA/SCL 电平。这大大简化了软件逻辑。I2C1_ReadByte()函数中的ack参数完美地实现了“前 n-1 字节发 ACK最后一字节发 NACK”的标准读取协议。5. 常见问题与排查技巧实录那些让你怀疑人生的“玄学”故障5.1 故障现象写操作后读出来全是 0xFF这是最经典的“EEPROM 没写进去”症状。原因往往不是代码而是硬件。上拉电阻缺失或阻值过大这是头号杀手。用万用表测量 PB6 和 PB7 对地的电阻。如果没接上拉或者接了 100kΩ那么 SDA/SCL 在释放时无法被拉高始终为低I²C 通信完全瘫痪。解决方案焊上 4.7kΩ 电阻。电源噪声过大CAT24C16 内部电荷泵需要稳定的 Vcc。如果你的 MCU 电源滤波很差比如只用了 100nF 电容在 I²C 通信的瞬间Vcc 波动可能导致电荷泵工作异常写入失败。解决方案在 CAT24C16 的 Vcc 引脚旁紧挨着焊一个 1μF 的钽电容或陶瓷电容。地址线接错CAT24C16 的 A0/A1/A2 引脚用于设置 7 位地址。如果它们都接地地址是 0x50如果 A0 接 Vcc地址是 0x51。如果你的代码里写的是0x50但硬件上 A0 接了 Vcc那当然找不到从机。解决方案用逻辑分析仪抓波形看主控实际发送的地址是多少。5.2 故障现象读操作偶尔返回错误数据且无规律这通常是时序问题尤其是在使用软件模拟 I²C 时。SCL 高电平时间不足I²C 标准模式要求 SCL 高电平时间最小为 4.0μs。如果你的延时函数delay_us(1)实际只有 0.8μs那么 SCL 高电平可能达不到要求导致从机无法正确采样 SDA。解决方案用示波器测量 SCL 波形确认高/低电平时间符合规范。SDA 采样点错误I²C 规定主控应在 SCL 为高电平的中间时刻采样 SDA。如果在下降沿或上升沿采样会采到不稳定的数据。解决方案在I2C1_ReadByte()函数中确保在while (!(I2C1-SR1 I2C_SR1_RXNE))之后再读DR寄存器这个标志位就是硬件告诉你的“数据已稳定”。5.3 故障现象连续写入多次后某一页再也无法写入这指向了 EEPROM 的物理寿命耗尽。单页写入次数超限CAT24C16 的擦写寿命是 100 万次但这是针对整个芯片的平均值。如果你的程序总是往同一个地址比如 0x0000写配置那么这一小块存储单元会率先报废。数据手册里说的“100 万次”是指在所有地址上均匀分布写入。解决方案实现“磨损均衡”Wear Leveling。例如维护一个“页使用计数器”每次写入时选择计数器最小的页或者采用“循环缓冲区”Ring Buffer方式让写操作在多页间滚动。注意磨损均衡算法会增加代码复杂度和写入延迟。对于参数存储类应用一个简单的“双备份”策略就足够了将关键参数同时写入页 0 和页 1。读取时优先读页 0如果校验失败如 CRC 错再读页 1。这能极大提升系统的鲁棒性。5.4 故障现象在 FPGA 上实现的 I²C Master 无法与 CAT24C16 通信FPGA 的时序约束是魔鬼。SDA/SCL 引脚未设置为 Open-DrainFPGA 的 IO 引脚默认是推挽输出。你必须在约束文件XDC中将 SDA 和 SCL 引脚的IOSTANDARD设置为LVCMOS33并启用PULLUP内部上
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门