简介面向嵌入式开发者的I2C驱动AT24C16实用代码包以I2C总线协议为基础实现MCU与AT24C16串行EEPROM的双向通信适合在项目中保存配置参数、传感器校准数据或用户设置等非易失性信息。压缩包共6个文件包含3个C源文件与3个头文件分别负责I2C底层时序、AT24C16读写逻辑和延时控制整体仅5KB轻量且易于移植到各类单片机平台。目前已有843人学习下载是调试AT24C系列存储器时值得参考的驱动样例。驱动针对AT24C16的16页×64字节内部结构重点规避跨页写入丢数据问题同时处理设备地址匹配、ACK应答异常等环节开发者既能直接调用读写函数完成数据存取也可通过源码学习I2C起始/停止信号、地址帧与数据传输的具体实现对理解EEPROM驱动原理很有帮助。 做嵌入式这些年I2C总线上打交道最多的存储芯片就是AT24C16。2KB容量存标定参数、设备序列号、运行计数这类掉电不能丢的数据刚刚好价格也便宜随便一颗都能用很久。真正让很多新手卡住的不是芯片本身而是I2C通信的时序细节、设备地址的计算方法以及上了板子之后设备无响应时怎么排查。这篇文章我就从STM32F407的角度把AT24C16的硬件接线、HAL库硬件I2C、软件模拟I2C和常见坑位一次讲清楚。无论你是在调试I2C设备还是刚准备移植EEPROM驱动这份实操记录应该都能派上用场。1. 先弄明白AT24C16到底是个什么货1.1 容量、引脚与存储页划分AT24C16从名字就能看出容量关系16指的是16Kbit换算过来就是2K字节也就是2048个字节。这个容量在EEPROM里算是中等偏实用常见的AT24C02只有256字节AT24C04是512字节AT24C08是1KB而AT24C16到了2KB之后内部结构就发生了一个明显变化——它把存储空间划分成了8个页面每页256字节。芯片引脚不多本质上一共8个脚常用的就这几个A0、A1、A2在AT24C02这类小容量芯片上这三个引脚用来拼接I2C设备地址可以挂多个同型号芯片。但到了AT24C16就不一样了这三个脚不再用于外部地址区分而是被内部用作页选择。也就是说一根I2C总线上默认只能挂一组AT24C16无法再用硬件引脚区分多颗除非外加PCA9546这类I2C多路开关。WP写保护引脚。接高电平时整个芯片只读不写调试时如果发现写入不进去先检查这个脚的电平。SCL、SDA标准I2C时钟和数据线。VCC、GND供电常见3.3V或5V都能工作看具体型号后缀。重点来了AT24C16的寻址方式是器件地址的低3位作为页地址紧接着发送一个字节作为页内偏移地址。比如要访问第0页第0个字节设备地址就是0x50偏移地址是0x00访问第3页第200个字节设备地址是0x53偏移地址是0xC8。这种“设备地址里混合内存页号”的寻址方式是I2C协议里比较少见的用法也是很多人被坑的地方。很多人套用AT24C02的代码把寻址只当成简单的8位地址来用结果发现怎么读写都会出错。1.2 AT24C02到AT24C16地址计算有什么变化用一张表看更清楚型号容量页数地址实际寻址方式AT24C02256字节1页设备地址固定1字节内存地址AT24C04512字节2页A0做页选择1字节地址AT24C081KB4页A0、A1做页选择AT24C162KB8页A0、A1、A2全部做页选择对AT24C16来说你在代码里写的“24C16地址”其实包含了两部分信息高3位是页码低8位是页内偏移。设备地址计算公式可以写成// 以7位地址为例HAL库和大多数Linux I2C工具使用这种格式 #define AT24C16_BASE_ADDR 0x50 #define ADDR(page) (AT24C16_BASE_ADDR ((page) 0x07))如果看到别人的代码用0xA0开头那是8位地址格式。两种写法本质上是一样的0xA0右移一位就是0x50。用STM32 HAL库时地址参数必须传7位格式也就是0x50到0x57这一点特别容易弄混很多人把0xA0直接传给HAL_I2C_Master_Transmit结果从机地址错误通信失败。2. 硬件连接与I2C协议核心2.1 上拉电阻和接线都是老规矩I2C总线是开漏结构两根线必须接上拉电阻才能输出高电平。一般100kHz标准模式用4.7kΩ上拉到VCC400kHz快速模式建议用2.2kΩ或3.3kΩ。上拉电阻数值太小会增大灌电流太大则上升沿变缓通信距离稍长就容易出错。STM32F407内部虽然有上拉但那个阻值偏大只适合很短的板内走线正经产品还是应该在外部配上拉电阻。接线建议SCL接PB6SDA接PB7这是I2C1的默认复用引脚。当然也可以用别的引脚比如PB8、PB9也能映射到I2C1但用CubeMX配置时要注意引脚复用功能别选成普通GPIO输出模式。电源方面VCC对地要加一个0.1μF去耦电容最好靠近芯片引脚放置。我遇到过几次EEPROM数据写乱的情况排查到最后都是电源纹波惹的祸加上去耦电容后问题消失。2.2 时序关键点START、STOP、ACK和读写方向I2C通信本身不复杂掌握几个关键时序列就够用了起始信号STARTSCL为高电平时SDA从高电平跳变到低电平。停止信号STOPSCL为高电平时SDA从低电平跳变到高电平。数据位发送SCL低电平时SDA改变电平SCL高电平时SDA必须稳定且保持时间满足建立时间和保持时间要求。应答ACK从机收到地址或数据后在第9个时钟周期把SDA拉低表示应答。如果从机没应答SDA保持高电平就是NACK。很多初学者看时序图时只关注电平跳变忽略了SCL高电平期间SDA必须稳定这条核心规则。如果违反了这条规则总线上的设备会把它误判为START或STOP信号通信直接乱掉。AT24C16的写操作完整流程是主机发送START发送设备地址最低位为0表示写发送页内偏移地址发送数据字节发送STOP。读操作稍微特殊要先发一个“伪写”序列把内部地址寄存器的值设定好然后重新发送START和带读标志的设备地址再读数据。3. STM32F407使用HAL库硬件I2C读写AT24C163.1 CubeMX初始化配置用CubeMX配置I2C1外设模式选择I2C在Parameter Settings里把速度设为100kHz或400kHz具体看EEPROM的耐压等级。这里有一个容易被忽略的点I2C时钟频率来自APB1总线如果APB1时钟是42MHz那么I2C的时钟源最好手动调整不要让外设时钟频率超过规格上限。生成代码后I2C初始化和引脚复用都是自动完成的。接下来就是写读写函数的问题。3.2 单字节读写代码与地址计算这是最基础也是最重要的代码一定要彻底弄懂#include main.h #include i2c.h #define EEPROM_BASE_ADDR 0x50 // HAL库要求7位地址格式 // 写一个字节到指定绝对地址 void AT24C16_WriteByte(uint16_t memAddr, uint8_t data) { uint8_t page (memAddr 8) 0x07; uint8_t offset memAddr 0xFF; uint8_t devAddr EEPROM_BASE_ADDR page; uint8_t buf[2]; buf[0] offset; buf[1] data; HAL_I2C_Master_Transmit(hi2c1, devAddr, buf, 2, 100); HAL_Delay(5); // 必须等待内部写周期完成 } // 从指定绝对地址读一个字节 uint8_t AT24C16_ReadByte(uint16_t memAddr) { uint8_t page (memAddr 8) 0x07; uint8_t offset memAddr 0xFF; uint8_t devAddr EEPROM_BASE_ADDR page; uint8_t data 0; HAL_I2C_Master_Transmit(hi2c1, devAddr, offset, 1, 100); HAL_I2C_Master_Receive(hi2c1, devAddr, data, 1, 100); return data; }这里最关键的一行就是page (memAddr 8) 0x07。AT24C16的2KB地址范围是0x000到0x7FF把绝对地址右移8位就得到页号。比如绝对地址0x0340对应的页号是0x03页内偏移是0x40设备地址就是0x53。HAL_Delay(5)不能省。EEPROM写操作完成需要时间AT24C16的写周期典型值为5ms左右写完马上又去读或者写大概率会失败。我曾经因为贪快把这个延时去掉结果大量数据写丢后来才意识到EEPROM内部是有擦写时间的不是上电瞬间就能落盘。3.3 页面写能不能一口气写几十字节AT24C16支持页写功能可以在一个写事务里连续写入多个字节前提是这些字节落在同一个存储页内。一次最多能写多少要看芯片具体版本老款AT24C16一般是16字节AT24C16C是32字节。// 写多个字节len最大不超过16且不允许跨越页边界 uint8_t AT24C16_WritePage(uint16_t memAddr, uint8_t *data, uint16_t len) { uint8_t page, offset, devAddr; uint8_t buf[33]; if (len 16) return 0; page (memAddr 8) 0x07; offset memAddr 0xFF; // 检查是否跨界偏移 长度 256 就属于跨页 if (offset len 256) return 0; devAddr EEPROM_BASE_ADDR page; buf[0] offset; for (int i 0; i len; i) { buf[i 1] data[i]; } HAL_I2C_Master_Transmit(hi2c1, devAddr, buf, len 1, 100); HAL_Delay(5); return 1; }注意EEPROM的页写地址到了页尾之后不是自动跳到下一页而是会回卷到当前页开头。也就是说如果你从页内偏移0xF0开始写32字节数据会写到偏移0xF0到0xFF然后回到0x00后面那部分就会把前面的数据覆盖掉。这个坑比设备地址算错更难发现数据在内存里看起来错得毫无规律。4. 用GPIO模拟I2C彻底掌握时序4.1 为什么建议你学一遍软件模拟I2C硬件I2C速度快、CPU占用低但有时候也会遇到一些莫名其妙的问题总线卡死、时钟延展不支持、引脚复用冲突。我见过不少项目最后改用软件模拟I2C反而更稳定。更重要的是用GPIO模拟I2C能让你把时序真正刻进脑子里遇到问题能直接拿示波器量波形来排查。STM32F407主频168MHz模拟I2C跑100kHz完全没压力关键是代码里加合适的延时。软件模拟的精度不如硬件外设但对于EEPROM这种低速设备绰绰有余。4.2 最小模拟I2C驱动代码用GPIO模拟I2C最推荐的方式是把SCL和SDA都配置为开漏输出模式。开漏的好处是双向通信不用反复切换GPIO模式SDA需要读取时先把引脚输出1释放总线然后从IDR寄存器读电平就行。#define SCL_PORT GPIOB #define SCL_PIN GPIO_PIN_6 #define SDA_PORT GPIOB #define SDA_PIN GPIO_PIN_7 #define SCL_H() HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN) static void I2C_Delay(void) { // 100kHz模式下半时钟周期约5us for (volatile int i 0; i 80; i); } void I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin SCL_PIN | SDA_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); SCL_H(); SDA_H(); } void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); I2C_Delay(); SCL_L(); I2C_Delay(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); I2C_Delay(); } uint8_t I2C_WriteByte(uint8_t data) { uint8_t i, ack; for (i 0; i 8; i) { if (data 0x80) SDA_H(); else SDA_L(); data 1; I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); } // 释放SDA等待从机应答 SDA_H(); SCL_H(); I2C_Delay(); ack SDA_READ(); SCL_L(); I2C_Delay(); return ack; // 返回0表示有ACK } uint8_t I2C_ReadByte(uint8_t sendAck) { uint8_t i, data 0; SDA_H(); // 释放SDA让从机驱动 for (i 0; i 8; i) { data 1; SCL_H(); I2C_Delay(); if (SDA_READ()) data | 0x01; SCL_L(); I2C_Delay(); } // 主机发送应答sendAck1时拉低SDA0时释放SDA表示NACK if (sendAck) SDA_L(); else SDA_H(); SCL_H(); I2C_Delay(); SCL_L(); SDA_H(); return data; }写这个驱动时最需要注意的是ACL的接收阶段。写完8位数据之后必须先把SDA释放掉否则从机的应答信号无法拉低SDA。很多人模拟I2C失败就是因为这里少了SDA_H()SCL拉高后读到的一直是主机自己输出的低电平误以为从机在应答结果后面通信全乱。4.3 模拟I2C下访问AT24C16的读写函数有了上面的基础函数读写AT24C16就变成纯粹的逻辑拼接void AT24C16_SimWriteByte(uint16_t memAddr, uint8_t data) { uint8_t devAddr EEPROM_BASE_ADDR ((memAddr 8) 0x07); uint8_t offset memAddr 0xFF; I2C_Start(); I2C_WriteByte(devAddr 1); // 写方向最低位0 I2C_WriteByte(offset); I2C_WriteByte(data); I2C_Stop(); HAL_Delay(5); } uint8_t AT24C16_SimReadByte(uint16_t memAddr) { uint8_t devAddr EEPROM_BASE_ADDR ((memAddr 8) 0x07); uint8_t offset memAddr 0xFF; uint8_t data; I2C_Start(); I2C_WriteByte(devAddr 1); // 伪写设置内部地址 I2C_WriteByte(offset); I2C_Start(); I2C_WriteByte((devAddr 1) | 0x01); // 读方向最低位1 data I2C_ReadByte(0); // 读最后一个字节回NACK I2C_Stop(); return data; }看到这里你可能有个疑问为什么读操作要先发一个START再发一次START这个叫“伪写”机制。EEPROM把上次访问的地址存在内部地址寄存器里读操作时如果没有先写入目标地址读出来的就是当前指针处的数据而不是你想读的地址。所以读之前必须先用写方向发一次地址让指针指到正确位置然后再切换成读方向取数。回NACK也很关键。主机读最后一个字节时如果发出ACK从机会认为后面还想继续读继续拉低SDA试图传输下一个字节。只有主机回NACK从机才明白这是最后一拍。这个细节在连续读多个字节时尤为重要。5. 故障排查实录与调试经验5.1 设备无响应先看ACK和波形遇到读写没反应第一步不急着改代码先用逻辑分析仪或示波器抓SCL和SDA。在线测量时重点看主机发出设备地址后第9个时钟的SDA电平是不是被拉低。如果一直是高说明从机没有应答问题通常出在三个方面设备地址算错了特别是AT24C16这种页号混在地址里的很多人在地址计算上翻车。代码里地址格式与传输函数不匹配比如HAL库应该传7位地址0x50却传了8位地址0xA0。芯片没有正常上电工作查一下VCC和GND以及WP引脚是不是被意外拉高了。我实际调试中最常遇到的场景是硬件I2C使用HAL_I2C_Master_Transmit传地址时直接用了0xA0返回错误码HAL_ERROR。HAL库API内部把地址左移一位再拼上读写位所以你要传的其实是7位地址0x50。这个API设计坑了很多人ST官方文档里没有特别强调。5.2 用I2C扫描方式快速探测设备地址如果实在不确定设备的响应地址可以在程序里写一个扫描函数遍历所有可能的I2C地址看哪个地址能收到ACKvoid I2C_Scan(void) { uint8_t addr; for (addr 0x08; addr 0x78; addr) { if (HAL_I2C_IsDeviceReady(hi2c1, addr, 1, 10) HAL_OK) { printf(I2C device found at 0x%02X\r\n, addr); } } }HAL_I2C_IsDeviceReady内部就是发送一个带上地址字节的空事务检测有没有ACK。打印出来的地址就是7位设备地址。对AT24C16来说正常会扫到0x50到0x57中的一个或多个——等等这里有个细节需要注意AT24C16虽然内部有8个页但它本质上还是一个器件只是地址中包含了页位。扫描时从0x50到0x57都会收到ACK因为它们代表的是同一个芯片的不同页。5.3 写入失败、丢数据和读回全是0xFF的坑读回数据全是0xFF这有两种可能一种是EEPROM出厂本来就是全0xFF状态你从来没写成功过另一种是写操作根本没执行。排除方法很简单先写一个字节等5ms再读回来用串口打印出来对比。如果读回来还是0xFF再检查WP引脚是否悬空或者被拉高。写入后数据丢失最常见原因是连续写操作太频繁没有等待写周期完成。EEPROM内部完成一次写需要几毫秒这段时间内芯片不会响应总线上的任何命令。如果你在HAL_I2C_Master_Transmit返回后立刻再次发起写操作第一次写的字节可能还没真正落盘就被覆盖了。还有一种丢数据情况比较隐蔽设备在I2C总线上发生了总线锁死SDA被某个异常状态卡死在低电平后面的通信全部失败。这时候可以尝试在初始化时为总线恢复做处理比如上电时让SCL翻转9个周期把卡在中间状态的设备复位掉。在Stm32上如果使用硬件I2C又遇到外设锁死最简单的方法是重新初始化I2C外设甚至直接复位整条总线。5.4 Linux下用i2c-tools验证AT24C16如果你是在嵌入式Linux设备上调试i2c-tools是排查问题的最好帮手。假设I2C控制器在/dev/i2c-1上AT24C16挂在7位地址0x50可以这样验证# 扫描总线上的设备 i2cdetect -y 1 # 向第3页偏移0x10处写一个字节0x5A i2cset -y -f 1 0x53 0x10 0x5A # 从该地址读回 i2cget -y -f 1 0x53 0x10这个验证方法特别适合用在“怀疑代码问题”之前先用Linux上层命令确认芯片本身工作正常再回头查驱动和应用代码。我习惯把这种“芯片级验证”和“主机代码验证”分开省去了不少来回折腾的时间。6. 个人实操中的几条经验做过的项目里EEPROM出问题的大多数情况并不是芯片有多复杂而是地址计算和时序等待这两个细节没处理好。对于AT24C16我在代码里永远用绝对地址接口在函数内部再去拆分页号和偏移这样业务逻辑层不用担心页的概念。页面写入虽然效率高但跨界回卷的问题很恶心如果拿捏不准宁可多写几次单字节写和非连续写调用也不要在页边界出问题后埋头查半天。模拟I2C虽然比硬件慢但在某些场景下反而是救命的。比如硬件I2C外设引脚被复用占用、或者芯片I2C状态机莫名其妙卡死的时候软件模拟直接绕过外设用最原始的GPIO和延时去控制波形反而更容易定位问题根源。时序上只要能满足设备手册的最小建立时间和保持时间100kHz左右跑起来完全没有问题。最后分享一个调试小技巧EEPROM写数据成功后我习惯立刻读回并和写入数据比对不一致就报错重试。这样虽然多花一点时间但能提早发现总线不稳定、写周期未完成等问题避免数据坏在用户看不到的角落。本文还有配套的精品资源点击获取