AT24C16 I2C驱动设计:11位地址映射、页写与掉电配置

发布时间:2026/10/26 4:00:53
AT24C16 I2C驱动设计:11位地址映射、页写与掉电配置 简介围绕 I2C 总线读写 AT24C16 串行 EEPROM 的驱动程序代码包面向正在做单片机课程实验、板级配置存储或小型嵌入式项目的开发者与学习者重点解决 I2C 初始化、器件地址设定、字节读写、ACK 应答检测及页边界处理等常见问题。包内共 6 个文件由 3 个 C 源文件与 3 个头文件组成整体约 5KB分别承载 I2C 底层时序、AT24C16 读写封装和延时辅助代码体量轻、依赖较少便于移植到不同 MCU 平台。已有 846 人学习说明其在 AT24C 系列 EEPROM 入门与调试中具有一定参考价值。读者可据此梳理起始/停止信号、7 位地址组织、跨页写入与错误处理的实现思路并结合注释理解读写函数的调用关系用于保存校准参数、用户设置等断电后需保留的数据也能为排查通信超时和数据冲突提供可借鉴的代码框架。1. 24C16 不是“地址多两位”的 24C02先看懂 11 位地址怎么塞进 I2C 帧不少人写 I2C 驱动 AT24C16会把它当成“地址多两位的 24C02”结果第一包数据就 NACK或者写进去读出来串页。AT24C 系列 驱动程序真正的分水岭不是寄存器而是 11 位地址怎么塞进 I2C 帧高 3 位进器件地址低 8 位进字地址页写只有 16 字节连续读跨 256 字节块还要重新发起地址。这个资源包适合正在做参数保存、校准数据、掉电配置的嵌入式工程也适合手里已经有 I2C 底层、想把 EEPROM 驱动写得更耐用的开发者。先把地址映射和页边界看明白后面的读写函数才不会靠玄学调参。2. 驱动分层把 I2C 时序、AT24C16 地址映射和业务配置拆开这份 I2C 驱动 AT24C16 的资源最值得先看的不是某个宏定义而是它把三件事拆开了平台相关的 I2C 收发、器件相关的地址与页边界、业务相关的配置组织。很多人拿到 EEPROM 驱动包直接改两个延时就能跑但换一块板子就翻车原因通常不是芯片坏了而是平台层和器件层搅在一起NACK 之后根本不知道是总线时序问题还是地址映射写错。2.1 资源包先看哪三个入口拿到包后先找三个入口不要急着从头读到尾。第一个入口是总线读写函数看它有没有把“写命令 重复起始 读数据”做成一次事务第二个入口是器件地址生成看 11 位地址的高 3 位有没有进入控制字节第三个入口是页写拆分看它有没有按 16 字节页边界切块。这三处对了AT24C16 的基本读写就成立了。常见做法是把驱动分成at24c16.c/h和平台适配层两部分。器件层只认mem_addr、data、len和超时时间不直接碰寄存器平台层负责 START、STOP、ACK、SCL 频率和 GPIO 方向。这样换 MCU 时只改平台层器件层的页写、地址映射、CRC 校验不用重写。项目建议值说明总线频率100 kHz 起步跑顺后再试 400 kHz先用低速排除接线和上拉问题上拉电阻2.2 kΩ 到 4.7 kΩ阻值太大边沿变慢太小灌电流大写周期5 ms 上限不要用固定 5 ms 硬等优先 ACK 轮询页大小16 字节页写不能跨 16 字节边界块大小256 字节24C16 高 3 位地址在器件地址里跨块要重发地址器件地址7 位 0x50 到 0x57写地址 0xA0 到 0xAE读地址加 1提示24C16 的高 3 位地址通常放进控制字节不要把 24C02 的 A2/A1/A0 拨码思路直接套过来。具体封装和型号的地址脚定义接线前以芯片手册为准。2.2 平台层接口先把事务边界定清楚我一般不会在器件层直接调用HAL_I2C_Mem_Read这类具体接口而是先包一层at24c16_bus_t。原因是 EEPROM 的随机读需要“写字地址 重复起始 读数据”在一次事务里完成如果平台层只给一个普通write和一个普通read中间插了 STOP部分平台会多出一次起始时序边界就变了。typedef enum { AT24C16_OK 0, AT24C16_ERR_PARAM, AT24C16_ERR_NACK, AT24C16_ERR_TIMEOUT, AT24C16_ERR_VERIFY } at24c16_status_t; typedef struct { /* 发送 dev_addr data适用于写内存地址和写数据 */ int (*write)(uint8_t dev_addr, const uint8_t *data, uint16_t len, uint32_t timeout_ms); /* 先发 cmd再重复起始用 dev_addr 读 data */ int (*read)(uint8_t dev_addr, const uint8_t *cmd, uint16_t cmd_len, uint8_t *data, uint16_t len, uint32_t timeout_ms); /* 只发 START dev_addr返回 0 表示收到 ACK */ int (*probe)(uint8_t dev_addr, uint32_t timeout_ms); void (*delay_ms)(uint32_t ms); /* 芯片手册里的 tWR24C16 常见最大 5 ms */ uint32_t write_cycle_ms; } at24c16_bus_t;write用于写单字节、页写read的cmd放 1 字节字地址平台层内部要用dev_addr 0xFE发命令阶段再用dev_addr做重复起始读。probe是 ACK 轮询的关键只发地址不写数据收到 ACK 就说明芯片内部写周期结束。write_cycle_ms不要写死在器件层放在总线对象里换 24C02、24C08 或不同厂家芯片时只改这个参数。2.3 地址映射高 3 位进控制字节低 8 位进字地址24C16 有 2048 字节需要 11 位地址。低 8 位放在字地址字节里高 3 位放进 7 位器件地址的低 3 位。7 位基地址是1010也就是 0x50加上块号后得到 0x50 到 0x57转成 8 位写地址就是 0xA0 到 0xAE读地址再或 1。#define AT24C16_SIZE 2048U #define AT24C16_PAGE_SIZE 16U #define AT24C16_BLOCK_SIZE 256U static uint8_t at24c16_dev_addr(uint16_t mem_addr, uint8_t read) { /* mem_addr 的 bit10..bit8 是块号对应控制字节的低 3 位 */ uint8_t block (uint8_t)((mem_addr 8) 0x07U); uint8_t addr7 (uint8_t)(0x50U | block); /* 1010 A10..A8 */ return (uint8_t)((addr7 1) | (read ? 1U : 0U)); }这个函数看起来只有几行但它决定了后面所有读写能不能落在正确的 256 字节块里。比如mem_addr 0x0123块号是 17 位地址是 0x51写地址是 0xA2字地址是 0x23。很多“写 0x0000 正常写 0x0100 就失败”的问题不是页写写错而是控制字节里的块号没有跟着地址变。3. 单字节与页写四个函数把读写流程跑通AT24C16 的写操作有两个硬约束一是每写完一页芯片进入内部写周期期间不响应 ACK二是页写不能跨 16 字节页边界否则地址会在页内回卷。读操作也有一个容易忽略的约束24C16 的高 3 位地址在器件地址里连续读跨 256 字节块时不要指望它自动进位。把这三个边界写进函数驱动才算能交付。3.1 写单字节与 ACK 轮询写单字节的帧很简单器件写地址、字地址、1 字节数据、STOP。真正需要小心的是 STOP 之后的那段时间。芯片在内部擦写SDA 上不会给你 ACK固定delay_ms(5)能跑但每次写都等满 5 ms批量写配置时整体启动时间会被拖长。更稳的做法是 ACK 轮询反复发 START 器件写地址收到 ACK 就认为写周期结束。static at24c16_status_t at24c16_wait_ready(const at24c16_bus_t *bus, uint16_t mem_addr) { uint8_t dev at24c16_dev_addr(mem_addr, 0); uint32_t waited 0; while (waited bus-write_cycle_ms) { if (bus-probe(dev, 1) 0) { return AT24C16_OK; /* 收到 ACK内部写周期结束 */ } bus-delay_ms(1); waited; } return AT24C16_ERR_TIMEOUT; } at24c16_status_t at24c16_write_byte(const at24c16_bus_t *bus, uint16_t mem_addr, uint8_t value) { uint8_t frame[2]; if (bus 0 || mem_addr AT24C16_SIZE) { return AT24C16_ERR_PARAM; } frame[0] (uint8_t)(mem_addr 0xFFU); /* 字地址低 8 位 */ frame[1] value; if (bus-write(at24c16_dev_addr(mem_addr, 0), frame, 2, 100) ! 0) { return AT24C16_ERR_NACK; } return at24c16_wait_ready(bus, mem_addr); }at24c16_wait_ready的waited上限来自bus-write_cycle_ms24C16 常见填 5。probe的超时给 1 ms 就够因为这里只发一个地址字节不需要等数据。at24c16_write_byte的单次传输超时给 100 ms 是工程余量不是芯片要求如果你的平台层超时单位是 tick要换算成 tick 再传。注意ACK 轮询要在 STOP 之后发不要在写数据帧中间插入 probe。中间插入会打断 EEPROM 内部写周期轻则当前字节没写进去重则整页数据错位。3.2 页写拆分与连续读页写函数不要直接暴露给业务层业务层只调用at24c16_write_buffer。页写函数自己检查(mem_addr 0x0F) len 16不满足就返回参数错误让上层拆分。这样写的好处是错误在页写边界就暴露不会等到读回校验时才发现数据被回卷覆盖。at24c16_status_t at24c16_write_page(const at24c16_bus_t *bus, uint16_t mem_addr, const uint8_t *data, uint16_t len) { uint8_t frame[1 AT24C16_PAGE_SIZE]; if (bus 0 || data 0 || len 0 || len AT24C16_PAGE_SIZE) { return AT24C16_ERR_PARAM; } if (((mem_addr 0x0FU) len) AT24C16_PAGE_SIZE) { return AT24C16_ERR_PARAM; /* 跨 16 字节页边界拒绝写 */ } frame[0] (uint8_t)(mem_addr 0xFFU); for (uint16_t i 0; i len; i) { frame[1 i] data[i]; } if (bus-write(at24c16_dev_addr(mem_addr, 0), frame, (uint16_t)(len 1), 100) ! 0) { return AT24C16_ERR_NACK; } return at24c16_wait_ready(bus, mem_addr); } at24c16_status_t at24c16_write_buffer(const at24c16_bus_t *bus, uint16_t mem_addr, const uint8_t *data, uint16_t len) { while (len 0) { uint16_t page_left AT24C16_PAGE_SIZE - (mem_addr 0x0FU); uint16_t chunk (len page_left) ? len : page_left; at24c16_status_t st at24c16_write_page(bus, mem_addr, data, chunk); if (st ! AT24C16_OK) { return st; } mem_addr chunk; data chunk; len - chunk; } return AT24C16_OK; }at24c16_write_buffer每次只写到当前页剩余空间写完自动 ACK 轮询再进入下一页。假设从 0x000F 写 3 字节第一次page_left 1只写 0x000F 一个字节第二次从 0x0010 开始写剩下 2 字节。这样虽然多了一次写周期但不会出现第二字节跑到 0x0000 的页回卷。连续读则按 256 字节块切。24C16 的块号在器件地址里跨块时如果还沿用旧的器件地址读出来的数据会回到当前块开头现象很像“读指针没有前进”。at24c16_status_t at24c16_read_buffer(const at24c16_bus_t *bus, uint16_t mem_addr, uint8_t *data, uint16_t len) { while (len 0) { uint16_t block_left AT24C16_BLOCK_SIZE - (mem_addr 0xFFU); uint16_t chunk (len block_left) ? len : block_left; uint8_t cmd (uint8_t)(mem_addr 0xFFU); if (bus-read(at24c16_dev_addr(mem_addr, 1), cmd, 1, data, chunk, 100) ! 0) { return AT24C16_ERR_NACK; } mem_addr chunk; data chunk; len - chunk; } return AT24C16_OK; }这里的bus-read必须实现成“先发cmd再重复起始再用读地址收数据”。如果平台层先发cmd后给 STOP再单独发读地址虽然有些板子也能读到但时序边界已经变了换芯片或换速率时就容易出问题。读地址由at24c16_dev_addr(mem_addr, 1)生成平台层在命令阶段用dev 0xFE读阶段用dev这个细节要写在适配层注释里。3.3 错误码与返回值约定驱动层不要只返回 0 和 -1。EEPROM 的问题分得越细现场排查越省时间。参数错、NACK、超时、回读校验失败是四类完全不同的故障混在一起只能靠猜。返回值触发条件现场处理AT24C16_OK读写完成ACK 正常继续业务AT24C16_ERR_PARAM地址越界、长度为 0、页写跨边界检查调用参数和拆分逻辑AT24C16_ERR_NACK器件地址或数据阶段无 ACK查上拉、地址、WP 脚、总线频率AT24C16_ERR_TIMEOUTACK 轮询超过write_cycle_ms查芯片是否焊反、电源是否掉、总线是否被拉死AT24C16_ERR_VERIFY写后回读不一致查页回卷、写保护、掉电中断返回NACK时不要立刻重试十次。先确认第一个 START 有没有 ACK如果第一个地址字节就 NACK重试没有意义如果写数据阶段才 NACK才考虑总线干扰或从机忙。把probe和write的返回值分开记录日志里能看到故障停在哪一段。4. 配置区落地掉电保存、CRC 与双备份怎么排AT24C16 常被用来存配置、校准系数、运行小时数。业务层最怕的不是写不进去而是掉电时写到一半下次上电读到半截记录程序拿错误参数跑。要解决这个问题单靠“写完延时 5 ms”不够还要在布局上留后悔药双副本、序列号、CRC 校验三者一起用。4.1 配置区布局两个槽位按块对齐2048 字节的空间不算大但放一份几十字节的配置绰绰有余。我一般把 A 槽放在 0x0000B 槽放在 0x0100两个槽跨 256 字节块。这样每次提交只覆盖其中一个槽另一个槽保留上一次成功记录即使新槽写坏旧槽还能回退。区域起始地址建议大小用途A 槽0x000064 字节以内主配置副本B 槽0x010064 字节以内备份配置副本日志区0x0200 起按需运行计数、故障码预留区末尾 256 字节256 字节产测数据或以后扩展槽位大小不要贴着 256 字节放满留出余量给结构体加字段。结构体一变长旧数据还能按len字段判断是否兼容。槽位起始地址按 256 字节块对齐读写时块号变化清晰逻辑分析仪上也好分段看。4.2 结构体、序列号与 CRC配置结构体不要只放业务字段至少要有magic、version、len、seq、crc。magic用来判断是不是空片version用来做升级兼容len防止旧版本读越界seq用来选新槽crc用来识别半截记录。#include stddef.h #include stdint.h #define CFG_MAGIC 0x4154U typedef struct { uint32_t seq; /* 每次提交加 1用来选新槽 */ uint16_t magic; /* 空片通常是 0xFFFF不匹配就认为无配置 */ uint8_t version; /* 配置结构版本 */ uint8_t len; /* payload 有效长度 */ uint8_t payload[48]; uint16_t crc; /* 对 crc 之前的字节做 CRC16 */ } cfg_slot_t; static uint16_t crc16_ccitt(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFFU; while (len--) { crc ^ (uint16_t)(*buf) 8; for (uint8_t i 0; i 8; i) { crc (crc 0x8000U) ? (uint16_t)((crc 1) ^ 0x1021U) : (uint16_t)(crc 1); } } return crc; }payload先给 48 字节整个结构体不到 64 字节写一个槽最多拆成 4 个 16 字节页提交时间可控。CRC 计算范围是offsetof(cfg_slot_t, crc)不要把crc字段本身算进去。空片读出来全是 0xFFmagic不等于CFG_MAGIC直接走默认配置不要拿 0xFF 去跑业务。4.3 提交顺序先写旧槽再切序列号提交时不要原地覆盖当前有效槽。正确顺序是先把seq加 1写到另一个槽写完后回读校验校验通过后这个槽自然成为新槽。因为seq更大的槽会被优先选中旧槽不需要擦除下次提交再覆盖它。#define CFG_SLOT_A 0x0000U #define CFG_SLOT_B 0x0100U at24c16_status_t cfg_commit(const at24c16_bus_t *bus, cfg_slot_t *slot) { uint16_t target; uint16_t crc; cfg_slot_t check; if (bus 0 || slot 0) { return AT24C16_ERR_PARAM; } slot-magic CFG_MAGIC; slot-crc 0; crc crc16_ccitt((uint8_t *)slot, offsetof(cfg_slot_t, crc)); slot-crc crc; /* seq 为奇数写 A偶数写 B保证每次写另一槽 */ target (slot-seq 1U) ? CFG_SLOT_A : CFG_SLOT_B; at24c16_status_t st at24c16_write_buffer(bus, target, (uint8_t *)slot, sizeof(*slot)); if (st ! AT24C16_OK) { return st; } st at24c16_read_buffer(bus, target, (uint8_t *)check, sizeof(check)); if (st ! AT24C16_OK) { return st; } if (check.magic ! CFG_MAGIC || check.len sizeof(check.payload) || check.crc ! slot-crc || check.seq ! slot-seq) { return AT24C16_ERR_VERIFY; } return AT24C16_OK; }这段提交逻辑的关键点不是 CRC 算法而是“写完必须回读”。EEPROM 在写周期内掉电可能只写进去前半页CRC 能识别这种半截记录回读校验能在业务层切换配置前就把坏槽拦下来。上电加载时同时本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询