news 2026/10/26 4:00:49

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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); /* 芯片手册里的 tWR,24C16 常见最大 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,块号是 1,7 位地址是 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_ms,24C16 常见填 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 槽放在 0x0000,B 槽放在 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字段本身算进去。空片读出来全是 0xFF,magic不等于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 能识别这种半截记录;回读校验能在业务层切换配置前就把坏槽拦下来。上电加载时同时

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 4:00:23

BERT情感分类LoRA微调实战:低秩自适应原理与实现

上个月帮一个做电商评论分析的朋友改一版情感分类模型&#xff0c;手上只有两万多条标注语料&#xff0c;机器是一张 24G 显存的卡。他原先的做法是把 BERT 整个放开做全量微调&#xff0c;跑三个 epoch 要将近四十分钟&#xff0c;换一条业务线就得重训一遍&#xff0c;权重存…

作者头像 李华
网站建设 2026/10/26 3:59:55

分布式共识算法面试拆解:Paxos、Raft、ZAB原理与工程边界

分布式共识算法这块内容&#xff0c;几乎是分布式方向面试的必考区&#xff0c;也是很多人准备得最拧巴的一块。我见过不少候选人&#xff0c;能把 Raft 的论文背出七八成&#xff0c;一被问到“为什么 Leader 只能提交当前任期的日志”就开始含糊&#xff1b;也见过反过来&…

作者头像 李华
网站建设 2026/10/26 3:59:15

AI增长幻觉:为何实际GDP提升仅1.5%?

1. 这不是技术预测&#xff0c;而是一次对“增长幻觉”的清醒解剖最近刷到一条标题很扎眼&#xff1a;“微软刊发诺奖经济学家 Acemoglu 对 AI 的悲观预测&#xff1a;十年 GDP 增长仅约 1.5%”。不少朋友第一反应是——“这数据是不是打错了&#xff1f;AI 不是正在引爆新一轮…

作者头像 李华
网站建设 2026/10/26 3:57:35

WinForms自定义画板:GDI+双缓冲与坐标映射实战

1. 项目概述&#xff1a;这不是一个简单的“画图工具”&#xff0c;而是一次对 WinForms 渲染底层逻辑的重新理解“WinForm —— 自定义画板”这八个字&#xff0c;表面看是桌面端绘图功能的实现&#xff0c;但真正踩进去你会发现&#xff0c;它本质是一场关于GDI 绘制生命周期…

作者头像 李华
网站建设 2026/10/26 3:57:09

OLS回归生成β值构建KV指数的金融实操指南

1. 项目概述&#xff1a;用普通最小二乘法回归求β值&#xff0c;再构造KV指数——这不是教科书练习&#xff0c;而是实操中真正能跑通的金融信号工程在金融建模与量化研究一线干了十多年&#xff0c;我经手过上百个因子构建项目&#xff0c;其中“用OLS回归算β值再拼成新指数…

作者头像 李华