ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32软件模拟I2C读写AT24C02:从协议时序到工程实践全解析

STM32软件模拟I2C读写AT24C02:从协议时序到工程实践全解析 直接说结论这篇内容适合所有想彻底搞懂 I2C 通信和 EEPROM 读写的朋友无论你是刚点亮第一个 LED 的新手还是被硬件 I2C 的 Bug 折磨过的老手都能在这里找到对应的解决方案。文章会从最底层的协议时序讲起到 AT24C02 的存储原理再到 STM32F103 上的完整代码实现最后连排查硬件问题的思路和调试技巧也一并奉上。我写这篇文章时尽量不讲废话每个环节都按照实际跑通项目的顺序来你完全可以照着一步步复现。1. 为什么用软件模拟 I2C 而不是硬件 I2C一次权衡后的选择1.1 硬件 I2C 的纠缠STM32F103 的一个经典痛点STM32F103 内置了硬件 I2C 外设但很多从 STM32 入门一路走过来的开发者都有过被这个硬件外设折腾得死去活来的经历。它的状态寄存器 I2C_SR1 和 I2C_SR2 配合起来判断总线状态时逻辑非常绕尤其容易在“忙标志位”上卡死——总线明明已经释放了硬件却还认为它忙导致后续通信直接罢工。为了解决这个问题网上流传着各种奇葩招数比如复位外设、延时等待、调整中断优先级但都治标不治本。当然我不是说硬件 I2C 绝对不能用如果你的项目对时序稳定性要求非常高比如高速连续读写且你愿意深入啃参考手册 RM0008 里 I2C 那一百多页的细节硬件外设放电然后重新初始化是最粗暴但有效的复位方式。这里恨不能一笔带过。我的选择是如果项目里只有一颗 EEPROM 这类低速设备用 GPIO 模拟 I2C 足矣。1.2 软件模拟的优势可控性和可移植性软件模拟 I2C 的好处就是用 GPIO 的电平翻转去模仿 I2C 协议的时序整个流程完全在你的掌控之内。时钟线产生多快、数据线什么时候切换、从设备没应答时你怎么处理——每一步的过程你都看得见摸得着。代码的可移植性更是没得说。今天你用的是 STM32F103明天换了 STM32F407后天用上 CH32V307只要把底层两个 GPIO 管脚的宏定义改一改剩下协议层代码可以直接复制粘贴。这对喜欢用 Proteus 仿真比如仿真 STM32F103 驱动 OLED12864 或 AT24C02的朋友来说更是个福音因为仿真里硬件 I2C 模型时常有奇怪行为而软件模拟完全绕开了这套机制。1.3 什么时候不用软件模拟不过我要说句公道话软件模拟也有它的短板。一是它需要 CPU 全程参与反复翻转 GPIO 会占用不少主循环时间不适合高速场景。二是它受中断影响很大如果你在通信过程中频繁进出中断时序可能被拉长甚至破坏对实时性要求苛刻的项目需要特别小心。所以我的最终建议是功能验证、学习原理、多平台移植——用软件模拟量产产品、高速传输、有 DMA 需求——老老实实调通硬件 I2C。下面的内容我以软件模拟为主线最后会提一下怎么迁移到硬件 I2C。2. AT24C02 的存储机制只有弄懂它才能正确读写2.1 芯片引脚与内存地址映射AT24C02 是 Atmel现在叫 Microchip家的经典 EEPROM 芯片容量 2Kbit也就是 256 字节。它的内存地址范围是 0x00 到 0xFF属于最基础的串行 EEPROM 类型。芯片有 8 个引脚A0、A1、A2 是地址选择脚WP 是写保护脚SCL 接时钟SDA 接数据VCC 和 GND 接电源。地址选择脚的接法直接决定了芯片在 I2C 总线上的从设备地址。I2C 总线上一开始要发送一个设备地址字节AT24C02 的地址是 0xA0 作为基地址实际上它由固定部分 1010 加 A2 A1 A0 加 R/W 位组成。如果 A0、A1、A2 全部接地那么设备地址写操作就是 0xA0读操作就是 0xA1。如果你在同一条 I2C 总线上挂了两颗 AT24C02把其中一颗的 A0 接 VCC那么它的地址就变成了 0xA2这样就能区分开了。这个细节在项目里非常实用。2.2 页写入与页边界新手最容易踩的坑AT24C02 有一个很微妙又很重要的特性——页写入。它内部把 256 字节分成了 32 页每页 8 字节。写入操作支持连续写多个字节但有一个限制你一次连续写入的字节数不能跨页边界而且在页写入模式下如果你写入的数据超过了当前页的末尾地址会自动回卷到该页的起始位置而不是顺序进入下一页。举个例子你往地址 0x07 连续写入 3 个字节数据会写到 0x07、0x08、0x09 吗不会——因为 0x07 是第 0 页的最后一个字节之后字节会回卷到 0x00把 0x00 和 0x01 覆盖掉。这个坑我见过太多人踩了写出的数据地址错乱排查半天找不到原因到最后一查参考手册里那句英文才恍然大悟。解决办法很简单写多字节前先计算从当前地址到页末尾还剩多少字节超过就拆分写入。2.3 写周期延时EEPROM 的“刷牙时间”EEPROM 写入数据和读数据不一样写操作需要芯片内部通过电荷泵把电荷注入浮栅这个过程需要时间通常叫做写周期 tWR。AT24C02 的典型写周期是 5ms也就是你发出一个完整的写命令后不能马上進行下一次操作必须等待 5ms 以上。这有点像你刷牙要刷满两分钟才有效果——命令发出去了芯片内部还在忙你这时候去问它“好了没”它给你的唯一反馈就是 SDA 线保持高电平不响应。很多人的代码逻辑看起来没错但实际运行时老是丢数据原因就出在连续写入时没有等待写周期完成。常见的等待方式有两种一种是写完后固定延时 5ms 或 10ms简单粗暴但可靠另一种是采用 ACK 轮询——写完一个字节后再次发送起始信号看从设备能不能拉低 SDA 回应答如果可以了说明内部写完成。后者省时间但在软件模拟场景下我更推荐前者的简单可靠为什么后面细说。3. I2C 协议时序逐位拆解通信的骨架3.1 起始条件、停止条件与空闲状态I2C 通信在总线空闲时SCL 和 SDA 都保持高电平这正是为什么两颗上拉电阻必须接上。要开始一次通信主机需要产生一个起始条件START时序是在 SCL 为高电平时SDA 从高电平跳变到低电平。注意这个跳变必须发生在 SCL 高电平期间这是从设备判断“数据开始”的唯一信号。停止条件STOP正好相反在 SCL 高电平时SDA 从低电平跳变到高电平。起始和停止都是 SDA 线在 SCL 高电平期间发生的电平变化这是它和数据位传输数据位只在 SCL 低电平时改变最本质的区别。代码实现也很直白无非是把 GPIO 电平翻转封装成几个基础函数。这里有一个容易被忽略的细节起始条件的 SDA 要先拉高再拉低不能直接拉低因为总线上可能残留上一个操作的时序混乱状态先恢复高电平可以确保芯片正确识别起始。3.2 数据位传输与应答位机制正式的数据传输过程中每传输一位数据都需要配合一个 SCL 时钟脉冲。主机在 SCL 低电平时把 SDA 引脚设置为输出并放置数据高电平为1低电平为0等到 SCL 拉高从设备在 SCL 高电平期间读取 SDA 电平接着 SCL 拉低准备下一位。数据的传输顺序是从高位开始也就是 MSB first。经过 8 个 SCL 脉冲传完一个字节后根据是写还是读决策不同。写情况之下主机在第 9 个时钟脉冲释放 SDA变为输入模式如果从设备正确收到数据并写入内部寄存器它会主动把 SDA 拉低这就是 ACK 应答。如果从设备不响应比如地址不对、芯片损坏、设备正忙SDA 保持高电平就是 NACK。读情况之下主机作为接收方在第 9 个脉冲时可以选择发出 ACK表示还要继续读下一字节或者 NACK表示就到这里之后主机发出停止条件。3.3 字节的发送与接收基础函数把这些协议抽象成函数就是整个 I2C 软件模拟的核心代码。发送一个字节的函数逻辑很简单循环 8 次每次取数据最高位放到 SDA 上然后拉高 SCL、延时、拉低 SCL最后释放 SDA 等待 ACK。接收一个字节的函数则相反每次 SCL 高电平时读取 SDA 电平移位拼接成字节。这两个函数是整个通信的基础后面所有读写操作都是基于它们的排列组合。延时参数很关键。I2C 标准模式下 SCL 频率是 100kHz快速模式是 400kHz。软件模拟时把时钟线拉高、拉低之间的延时控制在 1~5 微秒基本没问题主要看你系统的主频和 GPIO 翻转速度。主频 72MHz 的 STM32F103翻转一次 GPIO 大约 50~100ns理论上最快能把 SCL 做到几百 kHz。不过实际工程里我会把延时调大一点给自己留缓冲也防止线上信号质量变差时通信出错。4. AT24C02 读写全流程从单字节到页写入的代码实现4.1 基础宏定义与 I2C 初始化这部分我做了一个完整的代码示例按实际工程的组织习惯来写。先是管脚定义我用 PB6 做 SCLPB7 做 SDA原因很简单——这个组合在最小系统板和很多开发板上都是预留好的位置方便接线。如果你要改成 PA 口只需要更换宏定义里的 GPIO 名字。#define I2C_SCL_GPIO_PORT GPIOB #define I2C_SCL_GPIO_PIN GPIO_Pin_6 #define I2C_SDA_GPIO_PORT GPIOB #define I2C_SDA_GPIO_PIN GPIO_Pin_7 #define I2C_SCL_HIGH() GPIO_SetBits(I2C_SCL_GPIO_PORT, I2C_SCL_GPIO_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(I2C_SCL_GPIO_PORT, I2C_SCL_GPIO_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(I2C_SDA_GPIO_PORT, I2C_SDA_GPIO_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(I2C_SDA_GPIO_PORT, I2C_SDA_GPIO_PIN)初始化函数里有一点和普通 GPIO 初始化不同SDA 线在等待 ACK 时需要切换方向所以我把它配置为开漏输出模式。开漏输出的好处是既能拉低又能释放总线变高阻态配合外部上拉电阻可以把电平拉起。如果用了推挽输出释放 SDA 的动作做不出来总线时序就乱了。SCL 也需要开漏模式因为 SCL 在有些情况比如时钟拉伸主要是某些从设备的特殊能力下也可能需要从设备来拉低虽然 AT24C02 不支持时钟拉伸但养成开漏输出的习惯对后续移植很有帮助。void I2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin I2C_SCL_GPIO_PIN | I2C_SDA_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); I2C_SDA_HIGH(); I2C_SCL_HIGH(); }初始化完成之后别忘了检查外部电路这两根线上必须各有一个上拉电阻阻值常见 4.7kΩ。如果你用的是现成开发板或最小系统板多数已经集成如果是自己画 PCB这个电阻忘记焊的话I2C 总线直接瘫痪SDA 根本拉不高所有通信都失败。4.2 核心时序层起始、停止、发送字节、接收字节void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_LOW(); delay_us(5); I2C_SCL_LOW(); } void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_HIGH(); delay_us(5); } uint8_t I2C_SendByte(uint8_t data) { uint8_t i; uint8_t ack; for (i 0; i 8; i) { if (data 0x80) I2C_SDA_HIGH(); else I2C_SDA_LOW(); data 1; delay_us(2); I2C_SCL_HIGH(); delay_us(3); I2C_SCL_LOW(); delay_us(2); } // 第9个时钟释放SDA读取应答 I2C_SDA_HIGH(); delay_us(2); I2C_SCL_HIGH(); delay_us(3); ack GPIO_ReadInputDataBit(GPIOB, I2C_SDA_GPIO_PIN); I2C_SCL_LOW(); delay_us(2); return ack; } uint8_t I2C_ReceiveByte(uint8_t ack) { uint8_t i; uint8_t data 0; I2C_SDA_HIGH(); // 释放总线准备读取 for (i 0; i 8; i) { delay_us(2); I2C_SCL_HIGH(); delay_us(3); if (GPIO_ReadInputDataBit(GPIOB, I2C_SDA_GPIO_PIN)) data | 0x80; I2C_SCL_LOW(); delay_us(2); if (i 7) data 1; } // 第9个时钟发出应答或非应答 if (ack) I2C_SDA_LOW(); else I2C_SDA_HIGH(); delay_us(2); I2C_SCL_HIGH(); delay_us(3); I2C_SCL_LOW(); delay_us(2); I2C_SDA_HIGH(); return data; }4.3 设备地址拼装与写操作实现有了上面这些底层函数写 AT24C02 就变成了按部就班地发出命令序列。写一个字节到指定地址的操作完整时序是这样的起始条件→发送设备地址(0xA0)→发送内存地址→发送数据→停止条件。#define AT24C02_ADDR_W 0xA0 #define AT24C02_ADDR_R 0xA1 uint8_t AT24C02_WriteByte(uint8_t mem_addr, uint8_t data) { I2C_Start(); if (I2C_SendByte(AT24C02_ADDR_W) 1) // 非应答说明设备不存在 { I2C_Stop(); return 1; } I2C_SendByte(mem_addr); I2C_SendByte(data); I2C_Stop(); delay_ms(5); // 等待内部写周期完成 return 0; }你可能会好奇为什么设备地址发送失败时要停止并返回原因很现实如果总线上其他从设备或者根本没接这颗芯片你继续往下发送内存地址和数据毫无意义只会浪费时间。提前退出并返回错误码比闷头往下执行要好得多——这是我在调试多个 I2C 设备共存的总线时学到的经验。4.4 页写入的实现与拆分逻辑如果要连续写入多个字节直接用页写入能大幅节省通信时间。但前面提到过页边界回卷的坑所以我在写批量数据函数里做了一件事先计算当前页剩余空间再限制本次写入长度然后按页边界拆分成若干段写入。void AT24C02_WritePage(uint8_t mem_addr, uint8_t *buf, uint8_t len) { uint8_t page_remain; uint8_t chunk; uint8_t i; while (len 0) { // 当前页剩余字节数 8 - (mem_addr % 8) page_remain 8 - (mem_addr % 8); chunk (len page_remain) ? page_remain : len; I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); I2C_SendByte(mem_addr); for (i 0; i chunk; i) { I2C_SendByte(*buf); } I2C_Stop(); delay_ms(5); // 每一页写完都要等写周期 mem_addr chunk; len - chunk; } }这里我提一个容易忽略的细节连续写入多个字节时芯片内部会自动把内存地址加一所以你在页写入过程中只需要连续发送数据即可不需要每次单独送内存地址。前提是没超过页边界——超过页码回卷数据就乱套了。函数里那个page_remain计算就是为了防止这种情况。4.5 读操作立即读、随机读、顺序读读操作比写操作稍微绕一点因为要先“假写”一下来确定你想要的地址。具体流程起始→发送设备地址(写模式0xA0)→发送内存地址→重新发送起始这就是所谓的 repeated start 或 restart→发送设备地址(读模式0xA1)→读取数据→停止。uint8_t AT24C02_ReadByte(uint8_t mem_addr) { uint8_t data; I2C_Start(); I2C_SendByte(AT24C02_ADDR_W); I2C_SendByte(mem_addr); // 重复起始 I2C_Start(); I2C_SendByte(AT24C02_ADDR_R); data I2C_ReceiveByte(0); // 最后一个字节发 NACK I2C_Stop(); return data; }顺序读取从上一次读的地址继续往下读的原理是每次你读完一个字节如果向从设备发送 ACK芯片就会自动把地址加一继续输出下一个字节。连续读 256 字节时最后一个字节之前要发 ACK读到最后一个字节时要发 NACK 再停止——如果始终发 ACK 且从不停止读完之后芯片会继续输出但地址会回卷数据就乱了。引出一个经验读多字节时尤其是超过页边界时读操作没有写那样的页边界限制。读地址加一是芯片端的自动进位到 0xFF 后会回到 0x00这个行为在参考手册里有说明记住就好。5. 实测中发现的高频坑写周期、上拉电阻、电平转换5.1 写周期为什么数据“丢”了然后又出现在奇怪的地方我不知道你们有没有遇到过这个现象往 EEPROM 里写了数据阅读的时候发现——第一个字节写进去是对的后面几个全丢了或者读出来的是上一轮残留的数据。大多数人会以为是代码 BUG 或者芯片坏了反复调试最后发现只是没有等待内部写周期完成就直接发出了下一笔写命令。事情的真相是AT24C02 在执行内部写操作的 5ms 内芯片完全不响应外部命令此时你发出的任何起始条件、命令字节它都“假装听不见”。等你 5ms 后想看它写了什么发现数据确实没存进去它就一脸无辜。调试这种问题建议用逻辑分析仪抓信号非常直白——你会看到第二笔写命令的设备地址根本没有 ACK。也有一个更加高效的轮询方式。在发出写命令之后不固定延时而是反复发送“写地址字节”如果芯片返回 ACK说明内部写周期已结束。这种做法的优点是省时间比如写很多页时能把总耗时从几十毫秒砍到几毫秒。代价是代码逻辑复杂一点。我在实际批量写数据比较多时经常用这个技巧给你们一个实现思路发一次起始然后只发送设备地址0xA0检查应答位有应答就表示可以继续操作无应答就循环等待直到超时退出。5.2 上拉电阻必须要有这事实在强调多少遍都不算多I2C 总线是开漏结构SDA 和 SCL 本身不会被主动拉高只能靠外部上拉电阻。如果你把这两根线直接接到 STM32F103 的 GPIO设置为开漏输出上却发现通信失败先不要怀疑代码拿万用表量一下 SCL 和 SDA 对地电压如果是 0V 而不是 3.3V那必然是上拉电阻缺失或虚焊。阻值选择标准模式用 4.7kΩ 到 10kΩ快速模式建议 2.2kΩ 到 4.7kΩ。阻值太小灌电流太大能力弱的 GPIO 拉低会拉不动阻值太大RC 延迟明显SCL 上升沿变缓通信速率上不去。我常用 4.7kΩ基本兼顾了稳定性和速率。这里还要插一句如果你使用的模块板载了上拉电阻且模块供电电压是 3.3V那没问题如果模块是 5V 供电有些 AT24C02 模块可以直接接 5V它的上拉电阻接的是 5V那你拿到只支持 3.3V 的 MCU 上直接通信IO 电平不匹配长期使用有烧毁风险。遇到这种情况需要用 5V 转 3.3V 电平转换电路最简单的方式是两个 MOS 管搭双向电平转换。5.3 5V 供电模块接 3.3V MCU电平转换的一个实用方案网上有大量 AT24C02 模块是 5V 兼容的因为 AT24C02 本身供电范围能到 5.5V很多人就直接供 5V。而 STM32F103 的 GPIO 耐压不超过 3.6V除非是 FT 容忍类型的引脚但电压推荐也不要超过 4V 左右把 5V 上拉后的 SDA 直接接进去时间长了引脚内部保护二极管可能会出问题。解决思路不是换芯片而是做一个电平转换电路。最简单的是 MOSFET 双向电平转换方案——两个 N-MOS 管一个接 SDA一个接 SCL。3.3V 侧接 MCU5V 侧接模块中间靠 MOS 管的导通特性实现双向传输。这个电路的细节3.3V 侧需要上拉到 3.3V5V 侧上拉到 5VMOS 管门极接 3.3V当任何一侧拉低时另一侧也会被拉低高电平时各自被上拉到自己的电压。这就是所谓的“低电平一致高电平各自上拉”对 I2C 这种开漏协议是完美适配。5.4 Proteus 仿真时的一个特殊现象你们知道搜索关键词里有一个“Proteus OLED12864 I2C”很热门我在 Proteus 里仿真 STM32F103 驱动 AT24C02 时也踩过坑。Proteus 的 I2C 仿真模型对时序比较敏感如果你的软件模拟 I2C 延时过短SCL 频率太高仿真模型经常出现“无应答”的意外现象。这不是你的代码错了是仿真速度太快、模型反应不过来。在仿真工程里把延时从 1~2 微秒加大到 5~10 微秒通信立刻就稳定了。真机跑的时候再缩短不影响。如果你发现仿真里 EEPROM 写入后立即读取显示 0xFF别慌。先确认是否等待了写周期。Proteus 的 EEPROM 模型在执行写指令后和真实芯片一样会有一段时间不响应但它的“写周期结束”不是按时间去模拟的而是按是否给它时钟判定的。所以仿真时如果忽略延时它很可能根本没有完成内部存储操作。解决办法依然是老老实实等 5ms 再读。6. ULN2003 这类 5V 器件都扯不上关系但 24C02 能接 5V停顿一下不要把话题扯开。回到正经的技术点如果你用的是 0.9 寸 OLED 模块SSD1306 驱动它的 I2C 接口兼容性也要注意。SSD1306 的 I2C 地址也是 0x78/0x7A 这类但有些模块的默认地址会和 AT24C02 的 0xA0 冲突吗并不会因为它们走不同的设备地址I2C 总线本来就是多地址共存的。只是如果两者同时挂在一根总线上上拉电阻只要一组就够了不用重复加。实测下来OLED 和 EEPROM 共存时总线电容会变大SDA 上升沿变缓如果你通信速率调高可能偶发失败。降低 SCL 频率、或者干脆给两根线各加一颗 4.7kΩ 上拉问题就解决了。另一个常见误区是 0.9 寸 OLED 的 I2C 地址可能有两种0x787 位地址 0x3C 左移一位或 0x7A7 位地址 0x3D 左移一位。很多人在这上面耗时耗力我用过很多模块发现大多数默认是 0x78但也有少数是 0x7A判断方法就是看模块背面丝印或直接扫描总线。写一个 I2C 地址扫描函数把所有 7 位地址循环发送一遍看哪个地址有 ACK 回应一下子就能找出所有挂载设备的地址这对排错极其有用——也顺便能验证你的 AT24C02 地址是不是真的 0xA0。7. Keil MDK 工程搭建与 ST-Link 调试的全流程7.1 工程创建少走弯路的配置既然标题里有“STM32F103”那必然绕不开 Keil MDK 工程搭建和 ST-Link 调试这两个基础步骤。很多刚上手的朋友把大量时间花在了“找不到头文件路径”“版本对不上”“Pack 包安装失败”这些环境问题上代码本身反而没写几行。我建议工程搭建顺序先装 Keil MDK然后装对应的 Device Pack。搜索词里提到的“STM32F103 pack 包下载”指的是 Keil 的 Device Family Pack如果你用的芯片是 STM32F103C8T6 这种主流货直接在 Keil 的包管理器里搜索 STM32F1 系列安装即可。要注意的是 Pack 版本和 Keil 版本要兼容太老的 Keil 打不开新版的 Pack。创建工程时选择芯片型号时搜索 STM32F103C8 就好。新建工程后你还需要把启动文件、系统文件这些全部想清楚。我一般用标准外设库Standard Peripheral Library来写代码这篇文章的示例也都是基于这一套库的。如果你选择使用 CubeMX 生成 HAL 库工程逻辑也差不多只是把 GPIO 初始化换成了 HAL 风格——注意 CubeMX 生成的初始化里GPIO 默认是输入模式还是输出模式要确认改成开漏输出。7.2 ST-Link 调试用观察窗口盯时序变量调试 I2C 最有效的手段是看变量变化和波形。在 Keil 里用 ST-Link 连接后除了常规的打断点查看寄存器你可以在 I2C_SendByte 函数里加断点单步执行观察 SDA 引脚对应的 GPIO IDR 寄存器值变化或者更直接地用示波器/逻辑分析仪看波形。没有硬件调试工具时还可以用串口打印调试把每一次通信的 ACK 状态发到上位机。这里有一个实用技巧在调试软件模拟 I2C 时把 SCL 频率尽量降低延时拉大这样用逻辑分析仪抓波形时非常清晰肉眼就能数出位序。我当时调通整套读写就是靠逻辑分析仪抓取 SDA 波形逐个核对每一段时序——起始之后 8 位地址、ACK 位、8 位内存地址、ACK 位、数据位、停止条件。只要波形能和人脑里的协议时序图对上代码基本就没问题。7.3 标准库和 HAL 库的 GPIO 开漏配置差异这个值得单独说一句很多人从标准库迁移到 HAL 库时很容易漏掉开漏配置。标准库里是GPIO_Mode_Out_ODHAL 库则是GPIO_MODE_OUTPUT_OD同时还要配置GPIO_PULLUP否则开漏模式下 SDA 在高电平位上有可能悬空。HAL 库初始化 GPIO 时连带设置内部上拉也行但如果你外部已有 4.7kΩ 上拉内部上拉不开也不会出问题。8. 硬件排查链路写完代码先问自己这几个问题8.1 通信失败的定向排查思路如果你按上面的代码写完发现运行后读出来的全是 0xFF 或者数据不对不要着急怀疑芯片坏了按这个顺序排查大概率能找到病根上电后量一下 VCCAT24C02 的供电是否在正常工作范围1.8V~5.5V如果模块独立供电看看电源是否稳定。万用表量 SDA 和 SCL 的空闲电压都应该是接近供电电压的高电平。如果其中一根是低电平那就有问题——要么上拉电阻缺失、要么接线错误、要么某个设备把总线一直拉低。用 I2C 地址扫描程序循环发送 7 位地址看 0xA0/0xA1 地址是否有 ACK。如果扫描不到先检查接线和设备地址配置脚。确认延时参数软件模拟 I2C 的延时如果太短在面包板上容易因线路电容导致信号畸变。区分“数据写不进”和“数据读不出”写后用读命令验证才确认是写入环节的问题还是读取环节的问题。8.2 为什么读回数据总是 0xFF读回 0xFF 的常见原因有三个一是地址不对比如你写进了 0x00却从 0x10 去读二是芯片写周期未完成就断电或执行下一笔写导致写入真实失败三是从设备供电和逻辑电平不匹配芯片根本没被正确寻址。有一个排查技巧初始化后先把整片 256 字节读出来如果全部是 0xFF大概率是芯片没写入成功或没被正确寻址。这时候你用 I2C 地址扫描工具看能否扫到 EEPROM 的 ACK。如果扫描时能扫到设备但读全是 FF那更应该怀疑写失败而不是读失败。8.3 用示波器和逻辑分析仪的实测建议如果你手头只有万用表能测出空闲电平但不能细致看波形。这时候我更推荐几十块钱的逻辑分析器加上配套软件可以轻易抓到通信过程中 SDA 和 SCL 的完整时序。抓一波后你可以直接对照 I2C 时序图很容易判断到底是起始条件没出来、还是 ACK 没等到、还是数据字节的位序错了常见坑把数据位从 LSB 开始发而协议要求 MSB first。9. 扩展路径同一套 I2C 框架还能干很多事9.1 把 AT24C02 换成 SSD1306 OLED等你能跑通 AT24C02 的读写再回头去驱动 0.9 寸 OLEDSSD1306或者 12864 OLED其实只是换了一套命令序列的事。SSD1306 的 I2C 地址是 0x78写/0x7A读初始化时发一串控制命令之后就是往显存里写数据。底层那个软件模拟 I2C 都不用动只需要封装一个 OLED 专用的“写命令”和“写数据”函数。这就是为什么学好软件模拟 I2C 一劳永逸——协议是通用的变的只有上层命令。9.2 挂多颗 I2C 从设备时要注意的总线仲裁一个 I2C 总线上可以同时挂 AT24C02、OLED、传感器等一堆设备它们靠设备地址区分。每次通信前都要确认目标和这次通信方向对应的设备地址。设备多了之后总线的等效电容上升SCL/SDA 上升时间增长所以要有测试调试余量。如果实在不行也可以用 I2C 总线扩展器比如 PCA9548A通过多路选择来隔离不同总线段的电容负载。9.3 从模拟到硬件外设的迁移路径等你吃透了这套时序再回头用 STM32F103 的硬件 I2C你会发现理解成本低了很多——硬件外设本质上就是把你刚才用 GPIO 手搓的时序封装在芯片内部你只需要配置寄存器即可。硬件 I2C 在连续大批量读写时性能占用更少还可以配合 DMA。迁移的关键是把原来对 SCL/SDA 的 GPIO 操作全部替换成外设寄存器操作同时初始化时注意 I2C 外设的时钟、占空比、ACLK 配置以及最重要的是开启外设之前要让 GPIO 复用到 AF 功能推挽开漏模式设置接好。10. 结尾留点总结之外的实在话最后聊聊我在实际项目中做 I2C 通信的一些体会。第一软件模拟 I2C 调试成功的关键不是“代码”是“波形”。代码写错往往是看逻辑分析仪一眼就能发现但你不去抓波形就很容易在代码海里浪费时间。我有一次调了半天发现只是某个 GPIO 端口时钟没开因为 STM32F103 的 GPIO 在读写 IDR 之前必须使能对应端口的时钟否则读上来的数据全是乱的。第二EEPROM 写周期真的不是小事。我在批量写入参数时踩过一次连续写 100 个字节没有等内部写周期结果读回来的数据一半是旧值一半是新值。后来用 ACK 轮询方式优化才真正做到了又快又稳。第三如果准备把这个代码放到产品里持续运行建议把所有 I2C 通信函数里的延时都用定时器精确控制而不是裸的 delay 循环因为低优先级的中断随时可能打断裸延时导致时序被拉长到不可控裸延时的问题在工程环境里不触发还好触发起来很难排查。如果你照着文章里的代码和思路走一遍把 AT24C02 读写跑通那 I2C 这套总线协议基本上就吃透了。之后无论是换主控芯片、换存储器、挂传感器都是顺水推舟的事底层逻辑你已经完全掌握了。遇到问题欢迎在评论区提出我再根据大家的实战反馈继续整理更刁钻的调试案例。
返回列表