017、I2C实战:读写EEPROM(AT24Cxx)、温湿度传感器(SHT30)、加速度计(MPU6050)
017 I2C实战读写EEPROMAT24Cxx、温湿度传感器SHT30、加速度计MPU6050一、从一次凌晨三点的I2C死锁说起去年做一款工业数据记录仪凌晨三点被测试电话叫醒——设备运行12小时后温湿度数据全部卡死在上一帧。示波器抓SCL/SDA波形发现SDA被拉低后永远不释放。当时第一反应是“从机没释放总线”但换掉SHT30模块后问题依旧。最后查到是主控在异常复位时I2C状态机卡在了发送ACK的中间态SDA被主控自己锁死。这个坑让我养成了一个习惯所有I2C外设初始化前先发一个GPIO级别的总线复位——把SCL和SDA对应的IO口切到普通GPIO模式手动拉低SCL 9个时钟周期再拉高SDA释放总线。这个操作在AT24Cxx和MPU6050上同样有效尤其是热插拔场景。二、AT24CxxEEPROM的“写死循环”陷阱AT24Cxx系列AT24C02/04/08/16是I2C接口最经典的存储芯片但它的写操作有个反直觉的机制每写入一页通常8字节或16字节后芯片会进入内部编程周期此时不响应任何I2C命令。新手最容易犯的错误是连续写入多页时不等待内部编程完成就直接发下一帧数据。2.1 页写入的正确姿势// 伪代码实际工程中需要加超时保护uint8_teeprom_write_page(uint16_taddr,uint8_t*data,uint8_tlen){// 这里踩过坑AT24C02页大小是8字节AT24C16是16字节// 如果len超过页边界必须拆分成多次写入if(lenEEPROM_PAGE_SIZE)returnERROR;i2c_start();i2c_send_byte(DEV_ADDR_W);// 设备地址写位// 别这样写直接发数据地址忘记检查ACKif(i2c_wait_ack()!ACK){i2c_stop();returnERROR;}i2c_send_byte((uint8_t)(addr8));// 高位地址i2c_wait_ack();i2c_send_byte((uint8_t)(addr0xFF));// 低位地址i2c_wait_ack();for(uint8_ti0;ilen;i){i2c_send_byte(data[i]);// 这里有个坑如果从机NACK必须立即停止if(i2c_wait_ack()!ACK){i2c_stop();returnERROR;}}i2c_stop();// 关键等待内部编程完成轮询从机应答// 别这样写用固定delay(5ms)不同批次芯片时间不同uint32_ttimeout10000;// 10ms超时while(timeout--){i2c_start();i2c_send_byte(DEV_ADDR_W);if(i2c_wait_ack()ACK){i2c_stop();returnOK;}i2c_stop();delay_us(1);// 微秒级延时别用毫秒}returnTIMEOUT;}个人经验AT24Cxx的写周期典型值是5ms但工业级芯片在高温下可能延长到10ms。轮询等待时如果连续5次NACK建议做一次总线复位再重试。另外写操作前务必关全局中断否则中断服务函数里的I2C操作会打乱时序。2.2 连续读的“地址自动递增”特性读操作比写简单但有个细节AT24Cxx支持连续读地址会自动递增。如果你只读一个字节发完地址后直接发停止位即可如果要读多个字节主控需要在每个字节后发ACK除了最后一个字节发NACK。// 读多个字节最后一个字节发NACKuint8_teeprom_read_buf(uint16_taddr,uint8_t*buf,uint8_tlen){i2c_start();i2c_send_byte(DEV_ADDR_W);if(i2c_wait_ack()!ACK){i2c_stop();returnERROR;}i2c_send_byte((uint8_t)(addr8));i2c_wait_ack();i2c_send_byte((uint8_t)(addr0xFF));i2c_wait_ack();i2c_start();// 重复起始条件i2c_send_byte(DEV_ADDR_R);i2c_wait_ack();for(uint8_ti0;ilen;i){buf[i]i2c_read_byte();if(ilen-1){i2c_send_nack();// 最后一个字节发NACK}else{i2c_send_ack();}}i2c_stop();returnOK;}踩坑记录某次用AT24C16地址是2字节A0-A10但手册上写的是“高5位地址通过器件地址引脚设定”。实际测试发现AT24C16的地址线A0/A1/A2是悬空的地址完全由I2C从机地址的A0/A1/A2位决定。如果你用AT24C02的驱动去读AT24C16地址会错乱。三、SHT30温湿度传感器的“时钟延展”噩梦SHT30是Sensirion的经典数字温湿度传感器I2C接口精度高但有个坑它支持时钟延展Clock Stretching。当传感器正在测量时如果主控发读命令SHT30会把SCL拉低直到测量完成才释放。很多MCU的硬件I2C外设不支持时钟延展直接卡死。3.1 单次测量模式 vs 周期测量模式SHT30有两种工作模式我强烈建议用单次测量模式因为周期模式下传感器会持续占用总线。// 单次测量命令0x2C 0x06高重复性uint8_tsht30_single_measure(float*temp,float*humi){uint8_tcmd[2]{0x2C,0x06};uint8_traw[6]{0};i2c_start();i2c_send_byte(0x441|0);// SHT30默认地址0x44if(i2c_wait_ack()!ACK){i2c_stop();returnERROR;}i2c_send_byte(cmd[0]);i2c_wait_ack();i2c_send_byte(cmd[1]);i2c_wait_ack();i2c_stop();// 这里踩过坑测量时间最长15ms但时钟延展可能更长// 别这样写直接delay(20ms)浪费CPUdelay_ms(20);// 保守等待实际可优化为轮询状态i2c_start();i2c_send_byte(0x441|1);// 读if(i2c_wait_ack()!ACK){i2c_stop();returnERROR;}for(uint8_ti0;i6;i){raw[i]i2c_read_byte();if(i5)i2c_send_nack();elsei2c_send_ack();}i2c_stop();// 校验CRCSHT30每个数据包后跟一个CRCif(!sht30_crc_check(raw,2,raw[2]))returnCRC_ERROR;if(!sht30_crc_check(raw3,2,raw[5]))returnCRC_ERROR;// 温度转换-45 175 * raw / 65535*temp-45.0f175.0f*(float)((raw[0]8)|raw[1])/65535.0f;*humi100.0f*(float)((raw[3]8)|raw[4])/65535.0f;returnOK;}个人经验SHT30的CRC校验必须做否则数据偶尔会跳变。我遇到过一批芯片温度读数在25℃和-40℃之间来回跳最后发现是CRC校验没做总线噪声导致数据错误。另外SHT30的I2C地址可以通过ADDR引脚修改默认0x44如果接地是0x45别焊错了。3.2 时钟延展的软件处理如果你的MCU硬件I2C不支持时钟延展可以用软件I2C模拟。核心逻辑是在读取每个字节前先检测SCL是否被拉低如果被拉低则等待释放。// 软件I2C读字节带时钟延展处理uint8_tsw_i2c_read_byte(uint8_tack){uint8_tdata0;// 别这样写直接读SDA忽略SCL状态for(uint8_ti0;i8;i){// 等待SCL被从机释放时钟延展uint32_ttimeout1000;while(GPIO_ReadPin(SCL_PIN)0){if(--timeout0)return0xFF;// 超时返回}GPIO_SetPin(SCL_PIN,1);// 主控拉高SCLdelay_us(1);data(data1)|GPIO_ReadPin(SDA_PIN);GPIO_SetPin(SCL_PIN,0);// 拉低SCL准备下一个位delay_us(1);}// 发送ACK/NACKGPIO_SetPin(SDA_PIN,ack?0:1);GPIO_SetPin(SCL_PIN,1);delay_us(1);GPIO_SetPin(SCL_PIN,0);GPIO_SetPin(SDA_PIN,0);returndata;}踩坑记录某次用STM32的硬件I2C读SHT30开启时钟延展支持后发现读回来的数据全是0xFF。查手册发现STM32的I2C外设时钟延展超时时间默认是25个SCL周期而SHT30的测量时间可能超过这个值。解决方案要么关掉硬件超时要么用软件I2C。四、MPU6050加速度计的“寄存器读错位”问题MPU6050是六轴惯性测量单元I2C接口寄存器地址是8位但数据是16位高8位和低8位分别存储。新手最容易犯的错误是读加速度数据时只读了一个字节。4.1 正确的16位数据读取MPU6050的加速度计输出寄存器从0x3B开始每个轴占2个字节高字节在前。// 读加速度计X轴数据int16_tmpu6050_read_accel_x(void){uint8_treg0x3B;// ACCEL_XOUT_Huint8_thigh,low;i2c_start();i2c_send_byte(0x681|0);// MPU6050默认地址0x68i2c_wait_ack();i2c_send_byte(reg);i2c_wait_ack();i2c_start();// 重复起始i2c_send_byte(0x681|1);i2c_wait_ack();highi2c_read_byte();i2c_send_ack();// 这里要发ACK因为还要读下一个字节lowi2c_read_byte();i2c_send_nack();i2c_stop();// 别这样写直接返回(high 8) | low忘记考虑符号位return(int16_t)((high8)|low);}个人经验MPU6050的寄存器地址是自动递增的所以读加速度计三个轴时可以从0x3B开始连续读6个字节一次性读完。但要注意陀螺仪寄存器从0x43开始别读串了。4.2 配置寄存器的“写后读验证”MPU6050有个坑写配置寄存器后必须读回来验证。因为某些寄存器如电源管理寄存器0x6B的复位值不是0直接写可能不生效。// 配置MPU6050为正常模式唤醒uint8_tmpu6050_wakeup(void){uint8_treg_val;// 写0x6B寄存器清除SLEEP位i2c_start();i2c_send_byte(0x681|0);i2c_wait_ack();i2c_send_byte(0x6B);// 电源管理寄存器i2c_wait_ack();i2c_send_byte(0x00);// 清除SLEEP位i2c_wait_ack();i2c_stop();// 读回来验证i2c_start();i2c_send_byte(0x681|0);i2c_wait_ack();i2c_send_byte(0x6B);i2c_wait_ack();i2c_start();i2c_send_byte(0x681|1);i2c_wait_ack();reg_vali2c_read_byte();i2c_send_nack();i2c_stop();// 这里踩过坑如果reg_val 0x40不为0说明SLEEP位没清除if(reg_val0x40){returnERROR;// 唤醒失败}returnOK;}踩坑记录某次量产10%的MPU6050初始化失败读回来的寄存器值全是0xFF。排查发现是I2C总线电容过大导致SCL上升沿变缓。解决方案在SCL和SDA上加4.7kΩ上拉电阻并把I2C速率从400kHz降到100kHz。五、I2C调试的“三板斧”5.1 逻辑分析仪是必备工具别信示波器I2C调试必须用逻辑分析仪。我常用的是Saleae逻辑分析仪设置采样率24MHz触发条件选“起始条件”。抓波形时重点关注起始条件SCL高电平时SDA从高变低停止条件SCL高电平时SDA从低变高ACK/NACK第9个SCL时钟SDA被拉低是ACK保持高是NACK时钟延展SCL被从机拉低超过一个时钟周期5.2 总线复位函数每个I2C驱动里都应该有一个总线复位函数在初始化时调用voidi2c_bus_reset(void){// 把SCL和SDA配置为普通GPIO开漏输出GPIO_Init(SCL_PIN,GPIO_MODE_OUT_OD);GPIO_Init(SDA_PIN,GPIO_MODE_OUT_OD);// 拉高SDAGPIO_SetPin(SDA_PIN,1);delay_us(5);// 发送9个时钟脉冲for(uint8_ti0;i9;i){GPIO_SetPin(SCL_PIN,0);delay_us(5);GPIO_SetPin(SCL_PIN,1);delay_us(5);}// 发送停止条件GPIO_SetPin(SDA_PIN,0);delay_us(5);GPIO_SetPin(SCL_PIN,1);delay_us(5);GPIO_SetPin(SDA_PIN,1);delay_us(5);// 恢复I2C外设模式// ...}5.3 超时保护是底线所有I2C操作都必须加超时保护尤其是等待ACK和等待时钟延展。我习惯用硬件定时器做超时而不是软件循环计数因为软件循环在中断关闭时会失效。六、个人经验总结I2C速率不是越高越好400kHz在短距离10cm没问题但板间连接或线缆超过20cm建议降到100kHz。我见过100kHz下稳定的系统升到400kHz后每天随机死机一次。上拉电阻选型3.3V系统用4.7kΩ5V系统用2.2kΩ。如果总线电容大比如接了多个从机可以并联两个上拉电阻。别用10kΩ上升沿太慢。从机地址冲突AT24Cxx和SHT30的默认地址都是0x50左移后如果同时使用必须通过引脚修改地址。MPU6050的AD0引脚接地是0x68接VCC是0x69。写操作前关中断EEPROM的页写入和SHT30的测量命令执行期间如果被中断打断可能导致I2C状态机错乱。我习惯在写操作前关全局中断写完后开中断但注意关中断时间不要超过100μs。CRC校验不是可选项SHT30和MPU6050都提供了CRC校验别偷懒。我见过因为没做CRC数据偶尔跳变导致设备误报警的案例。硬件I2C vs 软件I2C如果MCU的硬件I2C支持时钟延展和DMA优先用硬件。否则用软件I2C更可控。我个人的经验是STM32的硬件I2C有bug尤其是F1系列建议用软件I2C或者用F4/F7系列。最后说一句I2C调试时逻辑分析仪比示波器好用一百倍。别问我怎么知道的——当年用示波器抓I2C波形抓了三天没找到问题换逻辑分析仪十分钟定位到是ACK时序不对。