直接说结论:这标题里最有含金量的词,不是“IIC”,也不是“AT24CXX”,而是“任意地址读写任意字节”。很多人在STM32上挂EEPROM,CubeMX点一点生成代码,然后调HAL_I2C_Mem_Write、HAL_I2C_Mem_Read,读写固定地址、固定长度没问题,但项目一改需求:要把一串不定长的数据写到任意偏移地址,再按任意长度读回来,问题就全冒出来了——跨页写失败、地址自增越界、读到一半数据错位、总线忙死锁。这篇文章就把这套东西彻底讲透:为什么AT24CXX需要跨页处理,HAL库硬件IIC的API到底该怎么用,以及一个能真正“任意地址读写任意字节”的通用驱动怎么封装。
这篇内容的定位很明确:给已经会用CubeMX配好I2C、但被EEPROM读写折腾过的开发者看。零基础也能跟下来,但至少要知道寄存器、地址、字节这些基本概念,否则跳到代码段你会蒙。我会把原理、代码、坑一次性讲完,代码基于STM32F103 HAL库,但思路适用于F0/F4/G0全系列。
1. 先搞明白AT24CXX的“页”为什么是绕不过去的坎
1.1 页写机制:EEPROM不是你想怎么写就怎么写
AT24CXX系列是Atmel(现在是Microchip)的I2C接口EEPROM,常见型号从AT24C01到AT24C256,容量从1Kbit到256Kbit。这系列芯片有个共同设计:内部存储按“页”划分,写操作必须遵守页边界规则。
具体来说:
- AT24C01/02:每页8字节
- AT24C04/08/16:每页16字节
- AT24C32及以上:每页32字节
- AT24C512:每页128字节
页写规则是:一次写操作最多写入“当前页剩余空间”那么多字节。如果你从地址0x00开始写10字节,而页大小是8字节,那么前8字节写入第0页,地址指针自增到0x07后溢出回卷到0x00,第9、第10字节会被写到页首——把刚才写进去的前两个字节覆盖掉。
这就是“任意地址任意字节”最大的坑。我的数据是0x00~0x09,实际EEPROM里存的却是0x02~0x09(前两字节被覆盖)再加0x00、0x01(回卷写到了页头)。数据错得乱七八糟,还不好排查。
1.2 为什么要特别强调“任意地址”
固定地址固定长度的读写,你可以在写之前人为保证“不从页中间开始写”“长度不超过页大小”,一切相安无事。但这个前提在实际项目中根本不成立:
- 系统需要分段存储日志,每条日志长度不固定,从上次结束位置接着写
- 校准参数结构体变更,版本号、CRC、数据分布位置调整
- 掉电保存键值对,键的偏移地址动态计算
这些场景下,调用方只关心“我要把n字节写到addr”,驱动层必须自己处理跨页拆分。把跨页逻辑封装在驱动内部,上层业务就不用天天惦记“我这页还剩几个字节”。
1.3 读操作其实没有页限制,但地址自增同样有边界
读操作不受页写限制,可以从任意地址连续读取任意字节。因为EEPROM内部读操作地址指针在芯片内是自动递增的,只要不越过整个存储空间的末尾,就可以一直读下去(部分型号超过末尾后回卷,行为取决于具体芯片)。
所以读这边搞“任意地址任意字节”其实天然支持,真正的门槛在写。但读也不是完全没有坑:HAL库的I2C_Mem_Read在连续读的时候如果中间发生NACK或总线错误,地址指针停在什么位置是不可预知的,恢复策略要考虑到。
2. HAL库I2C的API选型:Mem系列和Master系列到底差在哪
2.1 CubeMX/HAL库的I2C外设配置
先用CubeMX把I2C外设配好。我以I2C1为例,PB6-SCL、PB7-SDA,配置如下:
- I2C Speed Mode:Standard Mode(100KHz)起步,不要一上来就Fast Mode
- I2C Clock Speed:100000
- 内部上拉:CubeMX里I2C没有内部上拉配置选项(上拉在GPIO配置里),STM32的I2C引脚是开漏模式,外部必须接上拉电阻,这个后面第三节细讲
生成代码后你会看到hi2c1这个句柄,所有I2C操作都通过它展开。
2.2 HAL_I2C_Mem_Write/Mem_Read的适用边界
先看HAL库提供的两组关键API:
// 写单个字节/多字节(不含内存地址参数,依赖从机内部地址指针) HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 读单个字节/多字节(同样不含内存地址参数) HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 指定内存地址写(EEPROM这类器件专用) HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 指定内存地址读 HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);HAL_I2C_Master_Transmit/Receive只发设备地址(DevAddress),后跟纯数据。适用于寄存器地址由上次操作决定的器件(比如某些传感器内部自己维护寄存器指针),或者你在应用层手动拼装完整帧的情况。
HAL_I2C_Mem_Write/Mem_Read则不同:HAL库会自动帮你构造“Start → 设备地址+写 → 内存地址(1字节或2字节) → 数据/重复Start读”的完整时序。AT24CXX这种需要通过I2C帧内携带内存地址访问的器件,天然适合Mem系列API。
选择逻辑一句话:只要从机内部有“地址寄存器”这个概念,就得用Mem系列。AT24CXX就是典型代表。
2.3 为什么我不推荐直接用Master_Transmit写EEPROM地址
有人会问:“我可以先发设备地址,再发EEPROM内存地址,最后发数据,这不就是Master_Transmit的活吗,为什么非要Mem系列?”
能这么做的前提是:你能精确控制I2C帧的每一个字节顺序。HAL_I2C_Master_Transmit确实可以,你把整个帧(设备地址、内存地址高字节、内存地址低字节、数据……)拼在一个数组里发出去就行。但这样有几个副作用:
- 设备地址的读写控制位(bit0)需要自己拼,写操作发0xA0,读操作要先发0xA0再重复Start发0xA1,这套逻辑HAL库已经在底层帮你处理好了
- 数组缓冲区的构造、字节序转换都是额外代码
- 代码可读性差,别人看你代码不知道你在操作EEPROM还是在操作传感器
用Mem系列,语义清晰,还省掉手动拼帧的麻烦。你只要告诉HAL库“设备地址是什么、内存地址是什么、数据在哪、多长”,HAL库自己处理剩下的。
2.4 MemAddSize参数:1字节还是2字节的坑
MemAddSize是内存地址宽度,I2C_MEMADD_SIZE_8BIT对应1字节地址(适合AT24C01/02/04),I2C_MEMADD_SIZE_16BIT对应2字节地址(适合AT24C08及以上)。
为什么AT24C04是8BIT,AT24C08反而要16BIT?因为AT24C04只用1个字节的低4位做页选择+PAGE内偏移,剩下3位是块选择,地址空间只有512字节,1字节够了。AT24C08有1024字节,需要11位地址,1字节放不下,得2字节地址帧。
这个参数传错,数据读写位置完全错乱。我见过有人把AT24C16配成8BIT地址,读出来的数据全是乱的,排查了半天发现是MemAddSize的问题。CubeMX不会帮你判断这个,得自己根据型号选。
3. 硬件IIC和模拟IIC的选型:F1的硬件I2C到底能不能用
3.1 网上对硬件IIC的骂声从哪来
玩STM32的人应该都听过“STM32F1的硬件I2C有bug”这种说法,于是大量教程默认用GPIO模拟I2C时序,甚至国内有些开发板厂商直接放弃硬件I2C。这个说法的源头有几个:
- F1的老批次芯片I2C在总线错误恢复上确实有缺陷,BUSY位可能在异常总线状态后无法自动清除
- 早期标准外设库(StdPeriph)的I2C驱动写得不够顺手,事件处理逻辑复杂,很多人调不通就归咎于“硬件有bug”
- 部分开发板PCB上I2C上拉电阻没焊或焊错,导致时序工作不稳定,最后也甩锅给硬件I2C
但从F0、F3、F4、L0、L4这些后来的系列起,I2C外设已经非常成熟。即便在F1上,大部分“卡死”问题也是因为:总线忙没有恢复逻辑、中断优先级配置错误、在中断里做阻塞式读写。这些是使用方式问题,不是硬件问题。
3.2 HAL库下硬件IIC的优势
用HAL库的硬件I2C配合Mem系列API,一个HAL_I2C_Mem_Write调用就完成了整个帧的收发,底层由外设自动管理时钟和数据的位时序。相比模拟IIC的“拉高拉低延时翻转”循环,优势很明显:
- CPU占用率低:阻塞模式下也只是一次函数调用,模拟IIC每个bit都要CPU参与,速率越高越吃力
- 时序稳定:硬件生成精确的SCL频率、建立保持时间,不依赖延时函数精度
- 支持中断/DMA:模拟IIC很难做DMA,硬件I2C可以直接把内存数据搬走
- 代码简洁:不需要自己实现Start/Stop/ACK/NACK时序函数
我的结论是:新项目直接上硬件IIC,遇到问题解决问题。模拟IIC作为兜底方案保留,但不再默认使用。
3.3 什么时候依然建议用模拟
也不是说硬件IIC永远最优。下面这些情况你可以继续用模拟IIC:
- 引脚不够,需要用任意GPIO复用出I2C功能(硬件I2C引脚是固定的)
- 芯片组太老,确实存在已确认的硬件I2C锁死问题且没有规避手段
- 需要同时挂很多I2C器件到不同引脚,外设数量不够
模拟IIC还有一个隐形优势:它天然带时序韧性,严格的主从时序要求可以被软件fudge掉。但这属于用软件bug对抗硬件特性的做法,工程上不推荐在正式产品里依赖这种“韧性”。
4. 通用驱动的完整实现:支持任意地址、任意长度、自动跨页
4.1 驱动文件骨架和数据结构
实际做项目,不要每次用到EEPROM就现写一遍读写逻辑,封装一个通用驱动文件,一劳永逸。我的驱动一般分两个文件:at24cxx.h和at24cxx.c。
#ifndef AT24CXX_H #define AT24CXX_H #include "main.h" /* 根据实际型号定义容量和页大小 */ #define AT24CXX_PAGE_SIZE 8 // AT24C01/02为8字节,型号更大改16或32 #define AT24CXX_MAX_ADDR 0xFF // AT24C02:256字节,最大地址0xFF /* 操作结果枚举 */ typedef enum { AT24CXX_OK = 0, AT24CXX_ERR_NACK, AT24CXX_ERR_TIMEOUT, AT24CXX_ERR_INVALID_PARAM, AT24CXX_ERR_OTHER } AT24CXX_Status; AT24CXX_Status AT24CXX_WriteBytes(I2C_HandleTypeDef *hi2c, uint16_t devAddr, uint16_t memAddr, uint8_t *pData, uint16_t len); AT24CXX_Status AT24CXX_ReadBytes(I2C_HandleTypeDef *hi2c, uint16_t devAddr, uint16_t memAddr, uint8_t *pData, uint16_t len); #endifdevAddr这里传7位设备地址,AT24C02默认为0x50(即A0A1A2全接地时的地址)。注意HAL库内部会自动左移1位加上读写位,传0x50就对了,千万别自作主张传0xA0。
4.2 任意字节写的核心:跨页切分算法
跨页写的核心思路:把一次“任意长度”的写请求,按页边界切分成多次“当前页剩余空间内连续写”的子操作。
AT24CXX_Status AT24CXX_WriteBytes(I2C_HandleTypeDef *hi2c, uint16_t devAddr, uint16_t memAddr, uint8_t *pData, uint16_t len) { if (hi2c == NULL || pData == NULL || len == 0) { return AT24CXX_ERR_INVALID_PARAM; } if (memAddr + len - 1 > AT24CXX_MAX_ADDR) { return AT24CXX_ERR_INVALID_PARAM; } while (len > 0) { /* 计算当前页还剩多少字节可以写 */ uint16_t pageRemain = AT24CXX_PAGE_SIZE - (memAddr % AT24CXX_PAGE_SIZE); uint16_t chunk = (len < pageRemain) ? len : pageRemain; if (HAL_I2C_Mem_Write(hi2c, (uint16_t)(devAddr << 1), memAddr, I2C_MEMADD_SIZE_8BIT, pData, chunk, 100) != HAL_OK) { return AT24CXX_ERR_NACK; } /* 等待EEPROM内部写周期结束 */ HAL_Delay(5); memAddr += chunk; pData += chunk; len -= chunk; } return AT24CXX_OK; }关键地方就一个:pageRemain = AT24CXX_PAGE_SIZE - (memAddr % AT24CXX_PAGE_SIZE)。memAddr是页内偏移,页大小减去当前偏移,得到的就是本页还能连续写的字节数。比如页大小8,memAddr=5,pageRemain=3,说明从地址5开始最多连续写3字节(地址5、6、7),再往后就到下一页了。
HAL_Delay(5)是AT24CXX内部写周期的等待时间。芯片规格书里写周期一般最大5ms,写完一页后必须等它把数据真正烧录进非易失存储,才能发起下一次写操作。这个等待是必要的。后面第5节我会讲怎么用轮询替代固定延时,进一步提速。
4.3 读函数:为什么不需要跨页切分
读操作直接调HAL_I2C_Mem_Read即可,一次读多长都行(不能超过存储空间尾部):
AT24CXX_Status AT24CXX_ReadBytes(I2C_HandleTypeDef *hi2c, uint16_t devAddr, uint16_t memAddr, uint8_t *pData, uint16_t len) { if (hi2c == NULL || pData == NULL || len == 0) { return AT24CXX_ERR_INVALID_PARAM; } if (memAddr + len - 1 > AT24CXX_MAX_ADDR) { return AT24CXX_ERR_INVALID_PARAM; } if (HAL_I2C_Mem_Read(hi2c, (uint16_t)(devAddr << 1), memAddr, I2C_MEMADD_SIZE_8BIT, pData, len, 100) != HAL_OK) { return AT24CXX_ERR_NACK; } return AT24CXX_OK; }EEPROM的读时序是:先发设备地址+写位、内存地址,然后重复Start,再发设备地址+读位,从机转为发送模式,主机连续接收。HAL_I2C_Mem_Read在内部完整处理了这套流程。
需要注意的是,如果len很大(比如超过128字节),还是建议分批次读,避免阻塞时间太长影响其他任务。对大多数应用,读几十字节一次搞定没问题。
4.4 测试用例:验证跨页写入正确性
驱动写完,上板验证。我习惯写一个跨页测试函数,故意让起始地址落在页内部,写长度超过页边界,然后回读比对:
void AT24CXX_Test(I2C_HandleTypeDef *hi2c) { uint8_t writeBuf[20]; uint8_t readBuf[20]; uint16_t startAddr = 0x05; // 故意从页中间开始 uint16_t i; for (i = 0; i < 20; i++) { writeBuf[i] = (uint8_t)(i + 1); } if (AT24CXX_WriteBytes(hi2c, 0x50, startAddr, writeBuf, 20) == AT24CXX_OK) { HAL_Delay(10); if (AT24CXX_ReadBytes(hi2c, 0x50, startAddr, readBuf, 20) == AT24CXX_OK) { if (memcmp(writeBuf, readBuf, 20) == 0) { // 数据一致,跨页写正确 } else { // 数据不一致,排查时序或地址计算 } } } }另一个快速验证:先把整片EEPROM擦成0xFF(全部写0xFF),再在目标地址写已知数据,读回看是否只有目标区域变化。这样能排除“覆盖了别的数据”这种干扰。
5. HAL库硬件IIC踩坑实录:从BUSY锁死到无应答
5.1 总线BUSY锁死:最经典最致命的坑
现象:程序运行一段时间后,调用HAL_I2C_Mem_Write一直返回HAL_BUSY,后续所有I2C操作全部卡死。SCL和SDA线上看波形有异常,或者SCL正常但SDA一直低。
根因:I2C总线上出现异常状态(比如主从不同步、中途断线、从机复位导致时钟 stretch 异常),外设内部状态机的BUSY位被置1,如果HAL库没有复位外设的操作,BUSY永远不会自清除。
解决方案:在每次操作前加一个“总线恢复”函数,检测BUSY标志如果是置位状态,就把I2C外设复位,重新初始化,同时把SCL线拉几个时钟脉冲,让总线上的从机恢复空闲状态。
void I2C_Bus_Reset(I2C_HandleTypeDef *hi2c) { GPIO_InitTypeDef GPIO_InitStruct = {0}; if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BUSY) == SET) { __HAL_I2C_DISABLE(hi2c); // 将SCL和SDA配置为推挽输出,手动拉出9个时钟脉冲 HAL_GPIO_DeInit(hi2c->SCL_GPIO_Port, hi2c->SCL_Pin); GPIO_InitStruct.Pin = hi2c->SCL_Pin | hi2c->SDA_Pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(hi2c->SCL_GPIO_Port, &GPIO_InitStruct); for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(hi2c->SCL_GPIO_Port, hi2c->SCL_Pin, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(hi2c->SCL_GPIO_Port, hi2c->SCL_Pin, GPIO_PIN_RESET); HAL_Delay(1); } // 重新初始化I2C外设 HAL_I2C_Init(hi2c); } }这个方法能恢复绝大多数总线死锁情况。注意别在I2C中断回调里调用这个函数,会递归或造成不可预料的行为。
5.2 无应答(NACK)的两种常见场景
场景一:写地址后从机不应答。排查思路:
- 设备地址是否正确(7位地址左移一位变成8位地址后是否匹配)
- A0/A1/A2硬件地址引脚是否和代码里一致
- EEPROM是否处于内部写周期中(上一次写操作还没完成就不应答)
场景二:写完数据后没有等内部写周期完成,紧接着去读,大概率NACK。解决办法就是上文的延时5ms,或者轮询写周期状态。AT24CXX有个“Acknowledge Polling”机制:在内部写周期里,从机不应答任何地址,但当写周期完成后,第一次地址查询会正常应答。你可以利用这个特性,把固定5ms延时换成更快更可靠的“等待应答”:
AT24CXX_Status AT24CXX_WaitReady(I2C_HandleTypeDef *hi2c, uint16_t devAddr) { uint32_t tick = HAL_GetTick(); while (HAL_I2C_Master_Transmit(hi2c, (uint16_t)(devAddr << 1), NULL, 0, 10) != HAL_OK) { if (HAL_GetTick() - tick > 100) { return AT24CXX_ERR_TIMEOUT; // 超过100ms认为出错 } } return AT24CXX_OK; }用这个函数替代HAL_Delay(5),写多页时每页节省几毫秒等待时间,批量写大块数据的效率会有明显提升。
5.3 I2C中断/DMA方式下容易忽略的“重入问题”
阻塞方式下,HAL_I2C_Mem_Write是不允许被中断嵌套调用的。比如主循环在主从通信过程中,定时器中断里又来了一次I2C读写,HAL库内部的I2C_Lock机制会直接返回HAL_BUSY,数据根本没发出去,代码却不知道。
解决方案:
- 用一个互斥信号量/标志位保护I2C总线,谁拿到锁谁用
- 或者所有I2C操作都放在一个地方,其他任务通过队列请求I2C操作
- 或者在中断里只置标志,主循环里统一处理
第二种方案在裸机工程里最推荐。简单可靠,不用引入RTOS的信号量机制,还能统一管理I2C操作的优先级。
5.4 上拉电阻:硬件IIC的“隐形参数”
很多I2C问题根本不是代码问题,是上拉电阻没接对。I2C是开漏总线,SCL/SDA必须通过外部上拉电阻接VCC。
电阻大小选择原则:
- 100KHz标准模式:4.7KΩ~10KΩ
- 400KHz快速模式:2.2KΩ~4.7KΩ
- 线缆较长或从机数量多:适当减小电阻(但别小于1KΩ,否则驱动能力不够)
在开发板上,很多板子已经焊了上拉电阻。自己画板子时,这4.7K×2的电阻千万别省。我踩过的坑就是:自己画的板子忘加上拉,I2C波形上升沿缓慢,偶尔通信正常偶尔超时,查了两天才发现是硬件问题。
6. 从“能用”到“好用”:驱动进一步优化与功能扩展
6.1 写保护引脚的处理
AT24CXX系列大多有WP(Write Protect)引脚,高电平时整个芯片只读。量产产品里经常用MCU的GPIO控制WP引脚,写数据前拉低,写完后拉高,防止误写。
#define WP_ENABLE() HAL_GPIO_WritePin(WP_GPIO_Port, WP_Pin, GPIO_PIN_SET) #define WP_DISABLE() HAL_GPIO_WritePin(WP_GPIO_Port, WP_Pin, GPIO_PIN_RESET)为什么值得这么做?因为掉电瞬间MCU的IO口电平不确定,如果WP引脚悬空或上拉为高,正好在掉电时序里有一次未经授权的写操作,就会破坏EEPROM里的关键参数。用MCU引脚主动控制WP,是产品级设计该有的基本功。
批量写时,我在AT24CXX_WriteBytes函数开头WP_DISABLE(),写完全部数据后WP_ENABLE()。这样既保证写过程顺畅,又让非写时间段EEPROM处于硬件保护状态。
6.2 跨芯片型号兼容:用一个宏搞定
项目升级换更大的EEPROM(比如从AT24C02换到AT24C256),驱动的修改只涉及两个宏:
#define AT24CXX_PAGE_SIZE 32 // 换成AT24C256时改32 #define AT24CXX_MAX_ADDR 0x7FFF // 32K字节空间 #define AT24CXX_MEMADDR_SIZE I2C_MEMADD_SIZE_16BIT // 地址宽度也要改改完重新编译,驱动内部逻辑不用动。如果追求极致通用性,可以把页大小、最大地址、地址位宽做成结构体成员,通过一个初始化函数传参:
typedef struct { uint16_t pageSize; uint16_t maxAddr; uint16_t memAddrSize; } AT24CXX_Config; AT24CXX_Status AT24CXX_Init(AT24CXX_Config *cfg);这样同一个驱动文件可以同时服务多个不同型号的EEPROM,在多实例场景(比如一块板子上挂了AT24C02和AT24C256)下非常舒服。
6.3 大数据块写入的缓冲区管理
如果上层业务要一次写几KB数据(比如存储一段日志),应用层调AT24CXX_WriteBytes时传一个大数组,驱动内部会自动跨页切分。但要注意:
- MCU RAM不够时,不要一次性把几KB数据都读到RAM里再写,考虑边读边写、分帧处理
- 每页写完的5ms等待时间,对实时性要求高的系统来说是不能接受的。解决方案是用DMA+I2C中断的方式,配合“写完成中断再把下一页数据灌进去”,具体实现后面有机会单独写一篇
6.4 校验与冗余:掉电保存可靠性的最后防线
EEPROM用来保存关键参数,一定要加校验。我常用的方案:
- 结构体尾部加CRC16或简单的和校验
- 记录双份备份,一份损坏时自动用另一份恢复
- 写入前先读回验证(写后回读)
typedef struct { uint16_t magic; // 固定魔数,判断是否被初始化过 uint16_t calValue; uint16_t crc; // 数据区CRC } SysParam_t;读数据时检查magic和crc,任一不匹配就回滚到默认参数。这套机制用不了多少代码量,但在掉电保存场景里能避免最尴尬的“设备重启后参数全丢/全乱”问题。
7. 贴两个实际调试方法,帮你快速定位I2C问题
7.1 用逻辑分析仪看I2C时序
排查I2C问题,最有效的手段是逻辑分析仪。淘宝几十块钱的8通道逻辑分析仪,配上软件就能解码I2C协议。抓一次读操作,你能清楚地看到:
- Start条件
- 设备地址+读写位
- ACK/NACK
- 数据字节
- Stop条件
实际排查时我最常用的步骤:
- 抓正常读操作波形,确认设备和地址正确
- 抓跨页写操作,看每次子写之间的地址和数据是否按预期递增
- 如果出现丢失ACK,把示波器探头(或逻辑分析仪通道)挂在SDA上,看ACK位时SDA是高是低,判断从机为什么不应答
有了波形图,很多“玄学”问题直接变成“看得见摸得着”的问题。
7.2 用SCL时钟脉冲练手:手动扫描I2C设备
如果总线上挂的从机地址不确定(比如同一个项目里既有EEPROM又有传感器),可以写一个I2C地址扫描函数:
void I2C_Scan(I2C_HandleTypeDef *hi2c) { for (uint8_t addr = 1; addr < 127; addr++) { if (HAL_I2C_IsDeviceReady(hi2c, (uint16_t)(addr << 1), 3, 50) == HAL_OK) { printf("Found device at 0x%02X\r\n", addr); } } }HAL_I2C_IsDeviceReady会通过发地址+读位探测从机是否应答,不影响总线数据。项目初期用它确认硬件连接和地址是否正确,比对着原理图猜快多了。
AT24C02接地时地址是0x50,扫描输出会看到这一条。如果输出的地址和期望不一致,优先检查A0/A1/A2引脚的接法,以及I2C总线有没有接错线。
结尾就不做总结了,继续分享最后一个坑。我早期用HAL库的I2C做EEPROM时,特别喜欢在初始化后立刻做第一次读写,结果总是偶发失败。后来才意识到问题:CubeMX生成的I2C初始化代码里,外设使能之后需要一小段时间让总线稳定,尤其在上电瞬间电源还没升稳的时候。实际工程里,我在main函数里加了一个100ms的上电延时,或者用状态机等电源稳定标志后再访问EEPROM,这个问题就再没出现过。你可以试试。