做项目时总会遇到一类需求:设备参数断电后不能丢。校准系数、用户配置、开机次数、系统状态标志……这些数据不大,但对可靠性要求极高。方案可以考虑用内置Flash模拟EEPROM,省掉一颗芯片的成本,但STM32F103的Flash按页擦除、写寿命通常只有一万次左右,频繁改写很容易磨损,而且掉电瞬间的数据保护逻辑写起来相当考验功力。更省心的方案是直接外挂一颗AT24C02——2Kbit容量、I2C接口、SOP-8封装,几毛钱一颗,在工业板卡、传感器节点、小家电主控板上的出镜率极高。
这篇文章不打算讲PPT式的原理,直接拆解怎么把STM32F103和AT24C02稳定跑起来:从最小系统怎么搭、I2C上拉电阻怎么选,到协议里的起始、停止、应答到底怎么回事,再到驱动代码逐段怎么实现,最后是调试阶段最常见的几个坑和排查方法。不管你是刚接触单片机、准备把第一个I2C设备打通的新手,还是想彻底搞清楚软件模拟I2C细节的进阶者,这篇都能给到可落地的参考。
1. 项目涉及的核心问题与方案选型
1.1 为什么选择AT24C02而不是直接刷Flash
在单片机项目里保存少量数据,很多人的第一反应是"直接写Flash"。STM32F103内置Flash确实可以擦写,但有几个现实问题。第一,Flash擦除的最小单位是一页(通常1KB),哪怕只改1个字节,也得先把整页读出来、擦掉、再写回去,代码复杂度和时间开销都不小。第二,STM32F103的Flash数据保存寿命标称是10k次擦写(TA=25℃时),如果系统每隔几分钟就记录一次状态,这块Flash很快就不安全了。第三,Flash写入时如果发生掉电,可能出现半个页的脏数据,恢复逻辑很麻烦。
AT24C02是真正的串行EEPROM,按字节擦写、寿命100万次、数据保存100年,而且每个字节都可以独立操作。2Kbit换算下来是256个字节,存校准参数、设备配置、掉电标志绰绰有余。实际项目里我见过很多经典用法:电子秤的零点校准值、温控器的目标温度上下限、电机驱动的电流环PID参数、通信模块的地址和波特率配置。这些数据一个共同特征是"不常写、每次开机都要读、丢不得",正好是EEPROM的主场。
1.2 I2C对比SPI和单总线,为什么它是合适的选择
AT24C02的接口是I2C,但同样容量的EEPROM也有SPI版本(比如25AA02),为什么项目里多数人最终选了I2C版本?看一个简单的对比。
| 总线 | 信号线数量 | 典型速率 | 核心优势 | 核心短板 |
|---|---|---|---|---|
| I2C | 2(SCL+SDA) | 100kHz~400kHz | 引脚少、支持多设备寻址、协议标准化 | 速率不如SPI、时序细节多 |
| SPI | 4(SCK+MOSI+MISO+CS) | 可达18MHz | 速率快、全双工、时序简单 | 引脚多、每设备需要一个CS |
| 单总线 | 1(DQ) | 约15kbps | 最省引脚 | 时序窗口苛刻、难调试 |
对AT24C02这种2Kbit的小容量存储来说,跑满400kHz同样能在一秒内读完所有数据,速率根本不是瓶颈。I2C的真正优势在于"两根线挂一堆设备"——传感器(温湿度、加速度、气压)、RTC、OLED屏、EEPROM都可以并到同一条I2C总线上,靠地址区分彼此。STM32F103的PB6/PB7(I2C1默认引脚)挂上多个外设,是很多产品板卡的标准做法。
1.3 硬件I2C还是软件模拟I2C
这是STM32F103老生常谈的问题。硬件I2C指的是芯片内部集成的I2C外设,配置好寄存器后由硬件自动产生时序、处理应答,CPU只在特定节点(事件中断)介入。软件模拟I2C则是用两个普通GPIO,通过代码延时逐位翻转电平,把协议手工"画"出来。
两款方案我都长期用过,结论很明确:小项目、单主机、低速场景,优先用软件模拟。原因有几个。第一,STM32F103的硬件I2C在ERRATA里确实记录了一些边界情况,网上大量讨论"硬件I2C卡死在BUSY状态"的问题,处理不好会严重影响开发进度;第二,软件模拟对引脚完全自由,PB6/PB7被复用占用时随便换两个GPIO即可;第三,软件模拟逻辑完全在自己手里,出了时序问题可以直接用示波器对照代码排查,学习价值也高。
硬件I2C并非不能用,官方库和HAL库都支持得很好,但需要处理总线错误恢复、超时控制、事件标志顺序等问题,对协议理解的要求反而更高。这篇文章的核心部分按软件模拟I2C展开,把协议底层的逻辑讲透,后面再补一段硬件I2C的个人使用心得。
2. 硬件电路设计与连线细节
2.1 先看懂AT24C02的引脚
AT24C02是标准的SOP-8或者DIP-8封装,8个引脚功能非常规整。
| 引脚号 | 名称 | 功能 | 电路连接 |
|---|---|---|---|
| 1 | A0 | 地址选择位0 | 接地或接VCC |
| 2 | A1 | 地址选择位1 | 接地或接VCC |
| 3 | A2 | 地址选择位2 | 接地或接VCC |
| 4 | GND | 电源地 | 0V |
| 5 | SDA | 数据线 | 开漏,接上拉 |
| 6 | SCL | 时钟线 | 开漏,接上拉 |
| 7 | WP | 写保护 | 接地允许写,接高禁止写 |
| 8 | VCC | 电源正 | 1.8V~5.5V |
A0、A1、A2三位地址决定了芯片在总线上的器件地址。三个引脚全部接地时,7位I2C地址是0x50(二进制1010000),加上读写位后,写地址是0xA0,读地址是0xA1。同一条I2C总线上最多可以挂8颗AT24C02(通过A0/A1/A2区分),或者混合挂其他地址不冲突的I2C设备。WP引脚是写保护,接高电平时芯片只读,任何写操作都会被忽略,实际产品里WP一般接地,如果担心程序跑飞误改数据,也可以把WP接到MCU的GPIO控制。
2.2 上拉电阻的计算:为什么典型值是4.7kΩ
I2C的SDA和SCL都是开漏结构,必须通过外部上拉电阻把电平拉高。上拉电阻太小,灌电流过大,器件可能拉不动;上拉电阻太大,总线电容充电太慢,上升沿变缓,高速模式下波形会变成"圆弧",导致误采样。阻值通常落在1kΩ到10kΩ之间,常见选4.7kΩ或2.2kΩ,这背后有简单的计算逻辑。
I2C标准模式下(100kHz),要求信号上升时间不超过1μs。总线电容Cb由所有芯片引脚电容、走线寄生电容组成,典型估算在100pF到300pF之间。电阻的最大值由RC充电时间常数决定,使用公式Rmax ≈ t_r / (0.8473 × Cb),代入1μs和200pF,得到约5.9kΩ,所以4.7kΩ是个安全值。最小值要保证在VOL最大0.4V时灌电流不超器件的IOL能力(一般3mA),Rmin = (3.3V - 0.4V) / 3mA ≈ 967Ω,取1kΩ以上即可。总线挂的设备越多、走线越长,电容越大,就要适当减小上拉电阻,否则波形上升沿会太慢。
3.3V系统里,无论接4.7kΩ还是2.2kΩ都能正常工作;如果系统是5V供电,也可以用4.7kΩ,AT24C02的SDA/SCL耐压范围很宽。注意STM32F103的PB6和PB7作为I2C引脚时,直接用GPIO开漏模式加上拉电阻,不要把上拉接到5V——除非你对STM32F103的引脚"5V容忍"特性做过确认,大多数F103的普通IO标注的是FT(5V容忍)结构,但稳妥起见,3.3V主控配3.3V上拉最省心。
2.3 STM32F103最小系统与I2C总线的连接要点
用软件模拟I2C时,SCL和SDA任意GPIO都能用。推荐的默认选择是PB6(SCL)和PB7(SDA),原因很朴素:这是STM32F103硬件I2C1的默认引脚,现阶段用软件模拟,以后想切到硬件I2C,硬件连线不用改动。加上模块化的OLED、温度传感器、RTC模块普遍默认I2C接口,集中在PB6/PB7这条总线上,后续扩展方便。
最小系统的搭建不需要复杂:STM32F103C8T6的BOOT0接10kΩ下拉、BOOT1接10kΩ下拉,NRST接100nF电容到地,VDD每个引脚并一个100nF去耦电容,晶振用8MHz加两个20pF负载电容。这些基础部分不必多说,重点是I2C相关的几项:
- SDA和SCL走线尽量短,远离电源线和强干扰源
- 上拉电阻靠近AT24C02放置,而不是靠近MCU
- AT24C02的VCC和GND之间加一个100nF去耦电容
- WP直接接地,或者接一个10kΩ下拉电阻保证默认低电平
如果不小心把上拉电阻漏焊或虚焊,I2C总线会表现为SDA一直为高、怎么发设备地址都等不到ACK,或者数据随机错误。调试方波或逻辑分析仪时,第一步永远先确认SCL和SDA上有没有正常的方波和电平转换。
2.4 硬件层面的常见翻车点
分享几个实际踩过的坑。第一个是同时挂OLED和EEPROM的兼容问题,市面上0.9寸OLED模块很多不带板载上拉电阻,如果把I2C上拉只做了靠近EEPROM的4.7kΩ,总线上拉等效电阻可能偏大(串联后相当于只靠一个电阻),导致OLED初始化不稳定、屏幕闪烁。解决办法是每个设备就近放一组上拉,或者用万用表量一下总线对地电阻是否在2kΩ~5kΩ之间。
第二个是电平不匹配。有些EEPROM模块、传感器模块板载了5V上拉到VCC,如果VCC接的是5V,而MCU是3.3V,SDA被拉到5V高电平,MCU引脚如果只是普通IO而非FT引脚,可能损伤芯片。确认MCU引脚的5V容忍特性,或者统一用3.3V供电给模块。
第三个坑是WP引脚悬空。悬空状态下AT24C02内部逻辑检测到的电平不确定,有时候能写有时候不能写,表现为"第一次烧写成功,第二次上电写不进去"。解决方案就是明确接地或接GPIO控制,不要悬空。
3. I2C协议核心细节拆解
3.1 总线结构与数据传递规则
I2C是半双工、多主机总线。这里只讨论本文要用的单主机模式——STM32F103作为主机,AT24C02作为从机。总线两条线SCL(时钟)和SDA(数据),空闲时都被上拉电阻拉到高电平。数据传输以字节为单位,高位先出,每传完一个字节,接收方必须回一个应答位(ACK),相当于双方对账一次。
理解I2C的关键时刻是"数据变化窗口"。SDA数据线上的电平变化只允许发生在SCL为低电平的期间,SCL高电平期间SDA必须保持稳定。这样接收方才能在SCL高电平时把SDA的电平稳定采样下来,避免误判。从机的行为也是同样的规则——主机在SCL高电平时读SDA,所以所有变化都安排在低电平窗口。
3.2 起始、停止、应答信号到底长什么样
这几种信号是I2C最基础的组成,用语言可以描述清楚:
- 起始条件(START):SCL保持高电平,SDA从高电平跳变到低电平。这个"高→低"的下降沿代表总线开始被占用。
- 停止条件(STOP):SCL保持高电平,SDA从低电平跳变到高电平。代表一次传输结束、主机释放总线。
- 应答(ACK):接收方在第9个时钟周期的低电平阶段把SDA拉低,并在SCL高电平阶段保持低电平,告诉对方"收到,请继续"。
- 非应答(NACK):第9个时钟周期SDA保持高电平,意思是"不要再发了"或"没收到"。
停止条件的特殊之处在于,SCL高电平期间SDA执行了低→高跳变,这和"SCL高电平时SDA必须稳定"的规则形成了唯一例外。协议规定得比较清楚:SDA在SCL高电平期间的电平变化,只用于表示起始和停止。
3.3 AT24C02的设备地址怎么来的
AT24C02的设备地址一共8位,高4位固定为1010,接下来3位由A2、A1、A0引脚的电平决定,最低位是读写选择位R/W。A0/A1/A2全部接地时:
- 写地址:0b1010_000_0 = 0xA0
- 读地址:0b1010_000_1 = 0xA1
如果把A0接VCC,写地址变成0xA2,读地址0xA3,其他同理。总线上挂多颗时,用这三个引脚编码区分。需要特别注意,很多OLED模块和传感器模块的I2C地址是可配置的,如果地址与AT24C02冲突(比如同地址的0x50),就需要通过A0/A1/A2把EEPROM改到其他地址位置。
3.4 读写的四种操作模型
AT24C02的读写命令分为四类:字节写、页写、当前地址读、随机读。实际项目中用到的两个核心流程是:
字节写流程:起始 → 发送写地址0xA0 → 等待ACK → 发送目标字节地址(0~255)→ 等待ACK → 发送1字节数据 → 等待ACK → 停止。数据进入芯片内部的写锁存器后,芯片需要约5ms内部编程时间,把这字节真正写入EEPROM阵列,在此期间芯片对总线的ACK轮询不响应。
随机读流程:起始 → 发送写地址0xA0 → 等待ACK → 发送目标字节地址 → 等待ACK → 重启起始 → 发送读地址0xA1 → 等待ACK → 接收1字节数据 → 回NACK → 停止。随机读的诀窍是第一段先"假写"设置内部地址指针,然后用重复起始信号切换为读模式,从指针位置读出数据。
页写多用在小批量初始化数据时。AT24C02的一页长度是8字节,连续写8字节内不跨页,超过页边界会地址回卷——比如从地址7开始写3字节,第3字节会写到地址0,而不是地址9。这是最容易写错的地方,稍后代码部分会展开处理。
4. 软件实现:从零手写软件模拟I2C驱动
4.1 GPIO初始化:为什么必须用开漏模式
软件模拟I2C的第一步,是把SCL和SDA对应的两个GPIO配置为开漏输出。开漏模式的原理是:引脚内部只有下拉MOS管,能主动拉低电平,但输出高电平时引脚处于高阻状态,电平由外部上拉电阻决定。这正是I2C总线工作方式——所有设备都只能拉低,谁都不主动推高,靠上拉电阻提供高电平。
如果用推挽输出做I2C,当SDA线上有其他设备拉低时,主机推挽输出高电平,两个输出会"顶牛",可能损伤引脚甚至整条总线上的设备。所以在I2C场景下,开漏是唯一正确的配置。
以标准外设库为例:
void I2C_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; // PB6=SCL, PB7=SDA GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); // 默认高电平,释放总线 }需要读SDA电平(接收字节、等待应答)时,把SDA引脚临时切换为输入模式,或者利用开漏输出模式下读取IDR寄存器也能拿到引脚电平。标准库中GPIO_ReadInputDataBit可以直接读取输入数据寄存器,在开漏输出模式下这个值是准确的。
4.2 基础时序函数:Start、Stop、SendByte、RecvByte
软件模拟I2C的核心是四个基础函数。延时我统一用一个简单的循环,在72MHz主频下每次循环大约1μs。因为I2C标准的最低要求是SCL半周期不低于约4μs(100kHz模式),这个延时配合完全满足要求。
static void delay_us(uint32_t n) { uint32_t i; while (n--) { for (i = 0; i < 12; i++); // 72MHz空循环约1us,实际需根据编译优化调整 } }起始信号:
void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(5); SDA_LOW(); // SCL高电平时SDA产生高->低跳变 delay_us(5); SCL_LOW(); // 拉低SCL,准备传数据 }停止信号:
void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); delay_us(5); SDA_HIGH(); // SCL高电平时SDA产生低->高跳变 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) SDA_HIGH(); else SDA_LOW(); data <<= 1; delay_us(2); SCL_HIGH(); // SCL高电平期间SDA必须稳定 delay_us(3); SCL_LOW(); delay_us(2); } // 第9个时钟:释放SDA,读取从机应答 SDA_HIGH(); delay_us(2); SCL_HIGH(); delay_us(3); ack = SDA_READ(); // 低电平=ACK,高电平=NACK SCL_LOW(); delay_us(2); return ack; // 返回0表示收到ACK }接收一个字节并决定是否回ACK:
uint8_t I2C_RecvByte(uint8_t send_ack) { uint8_t i; uint8_t data = 0; SDA_HIGH(); // 释放SDA,让从机控制 for (i = 0; i < 8; i++) { data <<= 1; SCL_HIGH(); delay_us(3); if (SDA_READ()) data |= 0x01; SCL_LOW(); delay_us(2); } // 第9个时钟,主机发应答 if (send_ack) SDA_LOW(); // 拉低=ACK,表示还要继续读 else SDA_HIGH(); // 保持高=NACK,表示读够了 delay_us(2); SCL_HIGH(); delay_us(3); SCL_LOW(); SDA_HIGH(); // 释放SDA return data; }这几个函数是全部I2C协议的基础。后续所有对AT24C02的操作,都是以这四个函数为积木搭起来的。需要注意SDA_READ之前要先SDA_HIGH释放总线,否则引脚输出模式会把电平钳住,读到的一直是自己输出电平,这个问题我见过不少新手踩坑。
4.3 单字节写:最基础的数据传输演示
有了基础函数,写AT24C02单个字节只需要按照协议帧顺序组装。
uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); // 发送设备写地址0xA0,如果回NACK说明器件不在总线上 if (I2C_SendByte(0xA0)) { I2C_Stop(); return 1; } I2C_SendByte(addr); // 目标字节地址 0x00~0xFF I2C_SendByte(data); // 数据 I2C_Stop(); delay_ms(10); // 等待内部写周期完成,AT24C02最大5ms return 0; }地址0xA0发出去后,第三个电平是ACK判断的时机。如果总线上没有AT24C02(地址不对、上拉虚焊、芯片损坏),I2C_SendByte会返回1,此时立刻停止,避免继续操作。代码里的delay_ms(10)是简单粗暴的写法,实际想高效一点可以用ACK轮询:写停止后不延时,直接发起始信号和0xA0,芯片内部编程没完成时不会响应ACK,一旦收到ACK就说明写入结束。这样可以把等待时间从固定的10ms缩短到芯片实际需要的时间。
4.4 页写与跨页保护:项目里最容易被忽视的坑
页写可以用一次I2C事务连写8字节,效率比单字节写高很多。但AT24C02的页大小是8字节,硬件上如果写入操作跨越页边界,地址指针会回卷到本页的起始地址,导致数据覆盖。举例来说,从地址7开始连续写3字节(7、8、9),芯片会分别写入地址7、0、1——地址8、9被"卷"成了0、1。这个问题在初始化一组配置参数时特别容易遇到。
安全的页写函数应该对长度做拆分处理:
void AT24C02_PageWrite(uint8_t addr, uint8_t *buf, uint8_t len) { while (len > 0) { uint8_t page_remain = 8 - (addr % 8); // 当前页剩余字节数 uint8_t chunk = (len < page_remain) ? len : page_remain; I2C_Start(); I2C_SendByte(0xA0); I2C_SendByte(addr); for (uint8_t i = 0; i < chunk; i++) { I2C_SendByte(buf[i]); } I2C_Stop(); delay_ms(10); // 每页写入都要等待编程完成 addr += chunk; buf += chunk; len -= chunk; } }拆分的核心逻辑是先算出当前地址在页内的偏移,取"剩余可写字节数"和"需要写入字节数"的较小值,一次只写不超过页边界的量,然后更新地址指针,循环处理剩余部分。这种模式在任何页写EEPROM(AT24C01/02/04/08/16)里都通用,只是页大小不一样。
4.5 随机读和顺序读的实现
读操作最常用的是随机读,流程前面讲过,先用"假写"把内部地址指针设到目标位置,再重启动读出。
uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data; I2C_Start(); I2C_SendByte(0xA0); // 第一阶段:假写,设置地址指针 I2C_SendByte(addr); I2C_Start(); // 重复起始信号 I2C_SendByte(0xA1); // 切换为读模式 data = I2C_RecvByte(0); // 只读1字节,回NACK I2C_Stop(); return data; }最后一个字节读完后主机必须回NACK,作用是告诉从机"后面不用再发了"。如果回ACK,AT24C02会认为主机还想继续读,继续输出下一地址的数据,破坏停止信号的时序。顺序读就是在每个字节读完后回ACK,直到最后一个字节才回NACK,适合连续读多个地址。
如果想连续读取任意长度的数据块,可以利用顺序读模式:
void AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint8_t len) { I2C_Start(); I2C_SendByte(0xA0); I2C_SendByte(addr); I2C_Start(); I2C_SendByte(0xA1); for (uint8_t i = 0; i < len; i++) { // 最后一个字节回NACK,其余回ACK buf[i] = I2C_RecvByte((i < len - 1) ? 1 : 0); } I2C_Stop(); }这个函数在读取整块配置时用起来非常顺手,一次调用把长度和缓冲填好,不用在外层多次调单字节读。而且AT24C02的顺序读不受页边界限制,可以跨256字节读完整块。
4.6 完整写入流程里如何选择等待方式
写操作之后的等待,项目里有两种流派。一种是固定延时,简单可靠,但每次写都要空等5至10ms,如果写几十个字节,累计延迟明显。另一种是ACK轮询,写命令发完停止后,立即尝试发设备地址,EEPROM在内部编程期间对一切总线命令不响应(NACK),一旦写出地址收到ACK,说明编程完成。
void AT24C02_WaitWriteComplete(void) { I2C_Start(); while (I2C_SendByte(0xA0)) { // 循环发送写地址,直到收到ACK I2C_Stop(); delay_us(100); I2C_Start(); } I2C_Stop(); }实际使用中,我习惯写一个统一的封装函数:发起写操作后,调用等待函数代替固定delay_ms,时间开销从"固定最坏情况"降到"按实际需要等待"。尤其是刷写大量参数时,总时间能省下一半还多。唯一要注意的是ACK轮询期间总线不能长时间占住不放,每个NACK之后发停止、延时100μs再重试,避免不停占线。
5. 调试实录与常见问题排查
5.1 设备地址收不到ACK,总线从头就没通
现象是调用AT24C02_WriteByte,I2C_SendByte(0xA0)返回1(NACK)。排查顺序建议这样走:
先量电压:SDA和SCL空闲时应该都被上拉电阻拉到3.3V左右。如果量到0V,说明有设备在拉低总线,或者上拉电阻没焊好、短路。用万用表或示波器直接看。
再用示波器看起始信号之后的SDA波形:正常应该看到0xA0这个字节(10100000再加ACK位),SCL上有8个时钟脉冲。如果没有脉冲,说明代码里SCL没有翻转或者引脚配置错了;如果有脉冲但SDA没有正确波形,检查引脚是不是接反了。
然后是地址确认:A0/A1/A2接地时写地址是0xA0,如果模块上A0/A1/A2有跳线帽改过,地址可能变成其他值。这属于常见低级错误。
顺着排查一圈,问题多半在硬件连线或初始化的开漏配置上。软件模拟I2C的时序只要按代码走,很少出逻辑错。
5.2 写进去读出来全是0xFF
这是最典型的EEPROM"数据没写进去"症状。排除地址和连线问题后,优先级最高的怀疑对象是WP引脚。
WP悬空或接高电平时,AT24C02处于只读状态,所有写命令都在芯片内部被忽略,但I2C通信正常——主机发地址、数据都能收到ACK,因为写操作本身在协议层是"成功"的。这很容易误导人,总觉得通信是通的,但读出来永远是0xFF。把WP接地再试,问题立刻消失。
还有一个可能是延时不够。AT24C02的写周期最大5ms,如果写完后没有等待就去读,读到的还是EEPROM内部的旧数据(新数据还在写锁存器里,没刷进阵列)。延时10ms后读取,基本能规避。
5.3 数据偶尔写错,或者某几个字节串位
这种现象多为页写跨页问题。比如在地址7写3字节,本意是写7、8、9三个地址,实际写到了7、0、1。排查手段是先做一次回环测试:连续写0x00到0x0F共16字节,再读回来逐字节比对,看看错位规律。如果发现写3字节去了别的位置,十有八九是页边界回卷。
另一个偶发数据错误的常见原因是时序余量不足。软件模拟I2C的延时太短、SCL高电平太窄时,数据采样不稳定。修改方式是适当增大delay_us参数,让一个位周期大于4μs,符合标准模式100kHz。测试中在SCL高电平后加3μs左右的延时,可靠性会明显提升。
5.4 用逻辑分析仪快速验证I2C波形
调试I2C不要只用万用表,强烈建议用逻辑分析仪。淘宝上几十块的那种24MHz采样率的8通道逻辑分析仪就够用了,配合开源软件,带I2C协议解码功能,简直是小项目调试神器。
连接方式:逻辑分析仪的通道0接SCL、通道1接SDA,共地线接好,设置采样率不小于8MHz。抓一次写操作,软件会自动解码出START、设备地址0xA0、ACK、数据字节0x01、STOP等信息。哪一步出问题一目了然:没有START说明时序函数没跑对,没有ACK说明从机未响应,数据字节不对说明总线竞争或者引脚接错。
往年调I2C用示波器一帧一帧数脉冲的日子,有了逻辑分析仪之后轻松太多。手头没有逻辑分析仪的话,示波器至少能看波形形状和ACK位电平,也能做基本判断。
5.5 硬件I2C使用心得与补充建议
虽然这篇文章主体按软件模拟展开,但有必要聊几句STM32F103硬件I2C的个人经验,因为总有人会问"官方外设到底能不能用"。
硬件I2C确实可用,我用HAL库的I2C_Mem_Write/I2C_Mem_Read在项目里跑过AT24C02,稳定运行没问题。但有几个点必须注意:第一,初始化时一定要使能I2C外设的时钟和GPIO的AFIO时钟,配置好复用功能;第二,要处理总线BUSY状态——上电时SDA/SCL电平不确定可能导致I2C外设认为自己忙,这个状态不处理,一次通信都发不出去;第三,HAL库的I2C接口有时会因为超时机制返回超时错误,代码里要做好重试或错误恢复。
软件模拟I2C没有外设状态的复杂性,随时可以软件复位总线,调试直观,学习价值也高,所以新人入门用软件模拟是对的选择。等对协议真正理解了,再根据自己的实际需求选择硬件I2C的优化配置,心态上会更从容。
5.6 写在最后的实用建议
整套打通的顺序,建议先搭好最小系统,确认LED闪烁或者串口打印正常,再写I2C驱动,最后接AT24C02测试。测试第一步不要急着写业务逻辑,先把读ID(如果有)或者写入一个字节再读回的功能跑通,确认I2C链路没问题了再往上加功能。
实话说,I2C调试的坑并不深,大多数问题出在非常朴素的地方:上拉电阻没焊、WP没接地、地址记错、页写越界。把这几个点刻在脑子里,AT24C02这套流程基本一次就能跑通。后续在同一个I2C总线上扩展OLED、RTC、传感器时,你会发现现在下的功夫完全值得——协议理解到位了,换什么从设备都是同一套思路,无非是查一下器件手册里的寄存器地址和命令字。
做嵌入式就是这样,看似繁琐的外设协议,归根到底就是"时序+状态"四个字。用软件模拟的方式亲手把每一个时钟沿拉出来,心里对I2C的理解就扎下根了。之后的开发路上,无论在哪个平台、哪颗芯片上遇到I2C,你都能一眼看穿它。