M24C02 驱动是小型嵌入式设备持久化参数的常用方案。写入时按 16 字节页边界拆分,并用 ACK 轮询等待内部编程完成;上层将 256 字节空间分为 A/B 两槽保存关键参数。下面系统梳理硬件配置、跨页拆分、ACK 轮询,以及上层 A/B 双槽掉电安全存储的完整代码实现。
芯片特性与驱动概览
M24C02 是 2 Kbit(256 × 8 bit)串行 I2C EEPROM,内部按 16 页 × 16 字节组织,支持标准模式 100 kHz 与快速模式 400 kHz。当前固件驱动配置如下:
| 项目 | 当前实现配置 |
|---|---|
| 模块实现 | 纯 C99 驱动(可直接对接标准外设库或硬件 I2C 控制器) |
| 硬件总线 | N32G455 I2C3 主机,7 位寻址,轮询模式(无中断、无 DMA) |
| 通信速率 | 100 kHz(PCLK2 = 64 MHz) |
| 容量与地址范围 | 256 字节,字节地址 0x00~0xFF |
| 页写边界 | 16 字节/页(跨页写入由驱动自动拆分) |
| 默认 7 位地址 | 0x50(可通过编译宏 M24C02_I2C_ADDRESS 覆盖) |
| 单次标志与写周期超时 | 10 ms(覆盖手册最大内部写周期 tWR = 5 ms) |
| 公开接口 | M24c02_Init()、M24c02_Read()、M24c02_Write() |
硬件连接与器件地址编码
引脚与开漏配置
本板使用 I2C3 默认映射(I2C3_RMP = 00),无需调用重映射函数:
| MCU 引脚 | 信号网络 | GPIO 模式 | 说明 |
|---|---|---|---|
| PC0 | I2C3_SCL |
GPIO_Mode_AF_OD(复用开漏,50 MHz) |
必须有外部上拉电阻至 VCC |
| PC1 | I2C3_SDA |
GPIO_Mode_AF_OD(复用开漏,50 MHz) |
必须有外部上拉电阻至 VCC |
M24C02(SO8 封装)的 E0/E1/E2 为硬件地址脚,WC 为写保护脚(低电平允许写入,高电平禁止写入)。若 WC 被拉高,写操作会在内部写周期或数据阶段失败。
器件选择字节(Device Select Byte)
Bit: 7 6 5 4 3 2 1 0
+---+---+---+---+---+---+---+---+
| 1 | 0 | 1 | 0 | E2| E1| E0|R/W|
+---+---+---+---+---+---+---+---+
- 高 4 位固定为
1010; E2/E1/E0由硬件引脚电平决定,本板实测 7 位地址为0x50(对应E2=0, E1=0, E0=0);R/W = 0为写(0xA0),R/W = 1为读(0xA1)。
驱动中将 7 位地址左移 1 位后传给标准外设库 I2C_SendAddr7bit():
#define M24C02_ADDRESS_BYTE ((uint8_t)(M24C02_I2C_ADDRESS << 1U))
存储组织与 16 字节页回绕陷阱
M24C02 共有 16 页,每页 16 字节,页起始地址依次为 0x00, 0x10, 0x20 ... 0xF0。
页写回绕规则(Page Wrap-around):数据手册规定,在单次 Page Write 事务中,每写入一字节,芯片内部字地址仅低 4 位自动加 1,高 4 位页号保持不变。如果一次连续写入跨越了 16 字节页尾(例如从 0x0E 连续写 4 字节),第 3、4 字节不会进入下一页的 0x10, 0x11,而是回绕覆盖本页开头的 0x00, 0x01!
因此,M24c02_Write() 在驱动层实现了严格的页边界自动拆分:
当 length > 0:
本页剩余空间 pageRemain = 16 - (address % 16)
本次写入长度 chunkLen = min(length, pageRemain)
执行单页写入 M24c02_WritePage(address, data, chunkLen)
执行 ACK 轮询等待内部写周期 tWR 完成(最长 10 ms)
address += chunkLen
data += chunkLen
length -= chunkLen
例如从 0x0E 写 4 字节时,驱动自动拆成两笔独立事务:第一笔写 0x0E..0x0F(2 字节)并等待编程完成;第二笔再写 0x10..0x11(2 字节)并等待编程完成,避免页内回绕覆盖。
交互式在线演示:16 字节页回绕陷阱与 A/B 双槽乒乓存储
下面用 256 字节模拟 EEPROM。试着从 0x0E 写入 4 字节,对比分页与不分页的结果;再切换到 A/B 双槽,观察更新中断后会保留哪一份参数。点击单元格可以查看所属页和槽位。
I2C 事务帧格式与 ACK 轮询
随机读单字节
当前 M24c02_Read() 对每个字节采用确定性的随机读时序(在最后 1 字节接收前关闭 ACK 并产生 STOP):
START
→ 发送 0xA0(写器件地址),等待 ADDRF
→ 发送 8 位字地址 word_address,等待 BYTEF
RESTART
→ 关闭 ACK 使能
→ 发送 0xA1(读器件地址),等待 ADDRF
→ 读 STS1 + 读 STS2 清 ADDRF,立即置位 STOP
→ 等待 RXDATNE,读取 1 字节数据
→ 等待 I2C BUSY = 0,重新打开 ACK 使能
页写与内部写周期 ACK 轮询(ACK Polling)
发出页写数据的最后一个字节并产生 STOP 后,M24C02 进入最长 5 ms 的内部非易失性擦写周期(tWR)。在此期间,芯片不会响应外部访问,对器件选择字节会返回 NACK。
驱动不采用固定 Delay(5) 盲等,而是通过 M24c02_WaitWriteComplete() 执行 ACK 轮询,一旦芯片提前写完即可立即返回:
等待 I2C BUSY = 0
循环(以 PlatformTime_GetMs() 无符号差值计时,上限 10 ms):
发出 START
发送 0xA0(写器件地址)
轮询 STS1:
- 若 ADDRF = 1(从机已回 ACK,内部写完成):
读 STS1/STS2 清 ADDRF → 发 STOP → 等 BUSY = 0 → 返回 OK
- 若 ACKFAIL = 1(从机仍忙,回 NACK):
清 ACKFAIL 标志 → 发 STOP → 等 BUSY = 0 → 继续下一轮尝试
若超过 10 ms 仍未收到 ACK → 返回 M24C02_STATUS_TIMEOUT
M24c02_Init() 同样复用该函数探测器件是否在线应答。
N32G455 I2C3 外设配置与错误恢复
时钟与寄存器特性
I2C3 挂载于 APB2 总线(PCLK2 = 64 MHz)。调用标准外设库 I2C_Init() 配置 100 kHz 时:
CLKCTRL (CCR)计算值为64 MHz / (100 kHz × 2) = 320,实际产生标准 100 kHzSCL;- 库函数将大于 36 MHz 的
CTRL2.FREQ封顶记为36,TMRISE设为37。实际波形上升时间受板级外部上拉电阻与走线电容支配,联调时应以示波器实测为准。
外设异常自恢复(M24c02_RecoverPeripheral)
I2C 总线在受到静电、毛刺或从机异常影响时,容易卡在 BUSY 或仲裁错误状态。驱动在任何读写或初始化超时/错误退出前,均自动调用 M24c02_RecoverPeripheral():
- 置位
STOP请求并重新打开ACK配置; - 清除
TIMOUT、OVERRUN、ARLOST、BUSERR、ACKFAIL全部错误标志; - 置位并释放
I2C3软件复位位(SWRST),最后执行I2C_DeInit(I2C3)并按 100 kHz 重新初始化寄存器。
公开接口与最小调用示例
接口定义与状态码
typedef enum
{
M24C02_STATUS_OK = 0,
M24C02_STATUS_INVALID_PARAM, /* 空指针、长度为 0,或 address + length > 256 */
M24C02_STATUS_TIMEOUT, /* 10 ms 内标志未就绪或内部写周期未完成 */
M24C02_STATUS_NACK, /* 地址或数据阶段收到 NACK */
M24C02_STATUS_BUS_ERROR /* 总线错误、仲裁丢失、溢出等 */
} m24c02_status_t;
m24c02_status_t M24c02_Init(void);
m24c02_status_t M24c02_Read(uint16_t address, uint8_t *data, uint16_t length);
m24c02_status_t M24c02_Write(uint16_t address, const uint8_t *data, uint16_t length);
最小调用示例
#include "drv_m24c02.h"
static void Eeprom_Demo(void)
{
/* 选取跨越 0x0F -> 0x10 页边界的 4 字节测试跨页拆分 */
uint8_t tx[4] = { 0x10U, 0x32U, 0x54U, 0x76U };
uint8_t rx[4] = { 0U };
if (M24c02_Init() != M24C02_STATUS_OK)
{
return;
}
if (M24c02_Write(0x0EU, tx, sizeof(tx)) != M24C02_STATUS_OK)
{
return;
}
(void)M24c02_Read(0x0EU, rx, sizeof(rx));
}
与上层 A/B 双槽掉电安全参数存储的协同
驱动层只负责可靠的字节数组读写,不感知具体业务数据结构。上层参数管理模块将 M24C02 的 256 字节空间对半划分为两个 128 字节的乒乓备份槽位:
| 槽位 | 地址范围 | 职责说明 |
|---|---|---|
| 槽 A(Slot A) | 0x00~0x7F(128 B) |
保存完整的魔数、版本号、递增序号、用户设置、标定曲线及 CRC-16/MODBUS |
| 槽 B(Slot B) | 0x80~0xFF(128 B) |
与槽 A 结构完全相同,每次保存总是写入非活动槽 |
A/B 双槽结构体定义与完整 C99 实现
#include <string.h>
#define PARAM_SLOT_SIZE (128U)
#define PARAM_MAGIC (0x5041524DU) /* 'PARM' ASCII 魔数 */
#pragma pack(push, 1)
typedef struct
{
uint32_t magic; /* 4B: 魔数标识 */
uint16_t version; /* 2B: 参数结构版本号 */
uint32_t sequence; /* 4B: 单调递增序号,越大表示版本越新 */
uint8_t payload[116]; /* 116B: 业务配置与标定曲线参数 */
uint16_t crc16; /* 2B: CRC-16/MODBUS 校验码 (覆盖偏移 0..125) */
} ParamSlot;
#pragma pack(pop)
/* 上电扫描:校验 A/B 两槽,自动选择版本最新且无损的镜像恢复 */
bool ParamStore_Load(ParamSlot *outParams, bool *outActiveIsSlotA)
{
ParamSlot slotA, slotB;
bool validA = false;
bool validB = false;
if (M24c02_Read(0x00, (uint8_t *)&slotA, sizeof(ParamSlot)) == M24C02_STATUS_OK)
{
validA = (slotA.magic == PARAM_MAGIC) &&
(slotA.crc16 == Crc16_Modbus((const uint8_t *)&slotA, sizeof(ParamSlot) - 2));
}
if (M24c02_Read(0x80, (uint8_t *)&slotB, sizeof(ParamSlot)) == M24C02_STATUS_OK)
{
validB = (slotB.magic == PARAM_MAGIC) &&
(slotB.crc16 == Crc16_Modbus((const uint8_t *)&slotB, sizeof(ParamSlot) - 2));
}
if (validA && validB)
{
/* 两个槽位均完整有效,选用序号较大(较新)的槽 */
if (slotA.sequence >= slotB.sequence)
{
*outParams = slotA;
if (outActiveIsSlotA) *outActiveIsSlotA = true;
}
else
{
*outParams = slotB;
if (outActiveIsSlotA) *outActiveIsSlotA = false;
}
return true;
}
else if (validA)
{
*outParams = slotA;
if (outActiveIsSlotA) *outActiveIsSlotA = true;
return true;
}
else if (validB)
{
*outParams = slotB;
if (outActiveIsSlotA) *outActiveIsSlotA = false;
return true;
}
return false; /* 两槽均损坏(初次上电或硬件故障),应写入出厂默认值 */
}
/* 掉电安全写入:始终写入非活动槽,并整槽读回校验 */
bool ParamStore_Save(const ParamSlot *inParams, bool currentIsSlotA)
{
uint16_t targetAddr = currentIsSlotA ? 0x80 : 0x00; /* 写入交替备用槽 */
ParamSlot newSlot = *inParams;
newSlot.magic = PARAM_MAGIC;
newSlot.sequence++;
newSlot.crc16 = Crc16_Modbus((const uint8_t *)&newSlot, sizeof(ParamSlot) - 2);
/* 1. 跨 8 页写入目标槽 */
if (M24c02_Write(targetAddr, (const uint8_t *)&newSlot, sizeof(ParamSlot)) != M24C02_STATUS_OK)
{
return false;
}
/* 2. 写后整槽读回逐字节校验,确保物理无损 */
ParamSlot readback;
if (M24c02_Read(targetAddr, (uint8_t *)&readback, sizeof(ParamSlot)) != M24C02_STATUS_OK)
{
return false;
}
return (memcmp(&newSlot, &readback, sizeof(ParamSlot)) == 0);
}
这样设计的工程价值在于:
- 掉电原子性:即使在
M24c02_Write()跨 8 页(128 B)写入中途发生断电,被破坏的也只是正在写入的新槽;下次上电时,AppParam_Load()校验两个槽的 CRC-16 与递增序号,自动回退到完好无损的旧槽镜像。 - 写后读回校验:每次写入非活动槽后,上层立即调用
M24c02_Read()整槽读回并逐字节比对,确认物理存储无误后才更新活动槽指针。 - 看门狗安全边界:写满 128 字节共 8 页,正常耗时约
8 × 5 ms ≈ 40 ms(极端超时上界 < 100 ms),远小于约 1 s 的独立看门狗窗口。
移植检查清单
- 引脚与重映射:确认目标板 SCL/SDA 引脚与
I2C3_RMP配置,GPIO 设为复用开漏(GPIO_Mode_AF_OD)。 - 电气与写保护:确认外部上拉电阻已焊接、
WC脚拉低允许写入、VCC与 MCU I2C 电平一致。 - 跨页边界测试:向
0x0F地址连续写入 2 字节以上,读回确认0x0F与0x10均正确,且本页首地址0x00未被回绕篡改。 - 调用上下文约束:仅在初始化与主循环中调用,严禁在中断服务程序(ISR)中调用阻塞式 I2C 读写。
讨论交流
支持免注册访客评论,填写真实邮箱可接收回复提醒