
摘要本文从电平信号与差分信号的区别讲起系统梳理 I2C 通信协议的核心知识点包括硬件接线、上拉电阻作用、起始/终止信号、数据传输时序、应答机制与寻址方式。随后以 STM32 为例从零搭建软件 IIC 驱动逐步实现微秒延时、起始信号、8bit 发送、应答检测及单字节/多字节读写函数并对比硬件 IIC 的 HAL 库用法。最后结合 ENS160 多气体传感器演示如何将 I2C 驱动落地到真实项目帮助读者快速掌握从原理到实战的完整链路。一、差分和电平信号1.1简介1. 电平信号电平信号有一根是参考电平线信号线的信号值由信号线和参考电平线之间的电压差决定。2. 差分信号差分信号没有参考电平线传输一路数据需要两根信号线信号线的信号值由两根信号线的电压差决定。区别(1) 信号值的表示不同电平信号的信号值是信号线和参考电平线的电压差决定差分信号的信息值是由两条信号线的电压差决定。(2) 传输相同路数的信号电平信号用的数据线根数比差分信号少。比如传输 3 路信号电平信号需要 1 根参考电平 3 根数据线而差分信号需要 2x36 根数据线。(3) 现在通信大部分是差分信号因为差分信号的抗干扰性更强可以在更短的周期传输一个数据。1.2两种逻辑电平标准1.2.1TTLTTL电平逻辑1: 2.4V--5V 逻辑0: 0V--0.5VTTL电平下的串口协议假设发送的数据为01010101由低位向高位发送RS232电平标准3V15V对应0-3V-15V对应1RS232电平下的串口协议假设发送的数据为01010101需要注意的是rs232 的逻辑电平和TTL 不一样但是协议一样。1.3差分信号-RS485RS-485仅是一个电气标准描述了接口的物理层像协议、时序、串行或并行数据以及链路全部由设计者或更高层协议定义。 RS-485定义的是使用平衡多点传输线的驱动器和接收器的电气特性。RS-485能够进行远距离传输主要得益于使用差分信号进行传输当有噪声干扰时仍可以使用线路上两者差值进行判断使传输数据不受噪声干扰。半双工、是电气协议逻辑12V–6V 逻辑0 -6V— -2V是二线制差分信号也就是实际传输的数据是通过判断这两条信号线上的电压差来实现的RS-485总线弥补了RS-232通信距离短速率低的缺点RS-485的速率可高达10Mbit/s理论通讯距离可达1200米RS-485和RS-232的单端传输不一样是差分传输使用一对双绞线共模抑制二、通信学习方式1 了解电路接线2 了解通信协议3 使用代码实现该协议4 硬件通信练习三、硬件接线同步串行半双工半双工通信输出口都必须是开漏如果是推挽则容易在通信双方一个发1一个发0是产生短路从而烧毁引脚开漏输出本身无法实现高电平输出为了能够实现高电平输出此时需要外接上拉电阻四、上拉电阻的作用4.1避免总线冲突和实现“线与”Wire-AND功能这是最核心的原因。I²C协议支持多主多从Multiple Masters and Slaves的架构。开漏输出意味着芯片内部的输出级电路只有一个连接到地的MOSFET像是一个开关而没有直接连接到电源VCC的上拉部分。当MOSFET关闭开关断开时输出引脚为高阻态不影响总线。当MOSFET开启开关闭合时输出引脚被强行拉低到地GND输出逻辑0。“线与”逻辑由于所有设备的总线SDA和SCL都是开漏结构并通过上拉电阻连接到VCC任何一个设备只要主动拉低总线输出0整条总线就是低电平。逻辑“与”关系总线的最终状态 设备1的输出 AND 设备2的输出 AND ... 设备N的输出。只有所有设备都输出1即释放总线不拉低总线才为高电平1。如何避免冲突这种“线与”特性使得总线天生就具备冲突检测Arbitration 机制。假设两个主设备同时开始发送数据。它们一边发送一边会读取总线上的实际电平并与自己发送的电平进行比较。如果某个主设备发送了一个高电平1释放总线但它检测到总线实际是低电平0被另一个主设备拉低了它就立刻知道自己失去了仲裁会立即停止操作转为从接收模式。这个过程不会损坏硬件也无需额外的冲突检测线路。这是推挽输出Push-Pull绝对无法实现的如果两个推挽输出的设备一个输出高一个输出低会造成电源对地短路烧毁器件。4.2电平转换的灵活性兼容不同电压的设备这是另一个巨大的优势。由于总线上的高电平电压完全由上拉电阻所连接的电源VCC 决定而与各个设备自身的工作电压VDD无关。场景一个3.3V的STM32主设备想要和一个5V的传感器从设备通信。实现将I²C总线的上拉电阻连接到5V电源。3.3V的STM32的I²C引脚设置为开漏模式并且其耐压需要能承受5V很多现代MCU的IO口是5V tolerant的。工作原理当STM32要输出0时它内部MOSFET导通将总线拉低到GND0V。对于5V的传感器来说0V显然是逻辑0。当STM32要输出1时它释放总线高阻态上拉电阻将总线拉高到5V。STM32会读取到这个5V的高电平虽然超过了其本身的3.3V供电但只要引脚耐压足够就没有问题。同时5V的传感器也正确地看到了5V的逻辑1。好处你可以在一条I²C总线上混用不同工作电压的芯片而不需要任何额外的电平转换电路。只需将上拉电阻接到一个所有设备都能接受的最高电压即可。4.3简化驱动电路开漏结构非常简单只需要一个MOSFET管来控制对地的导通而不需要更复杂的推挽电路既有上拉P-MOS又有下拉N-MOS。这使得IC内部的电路更简单成本更低。五、协议时序分析5.1起始信号与终止信号SCL线为高电平期间SDA线由高电平向低电平的变化表示起始信号SCL线为高电平期间SDA线由低电平向高电平的变化表示终止信号起始和终止信号都是由主机发出起始信号产生后总线就处于占用的态终止信号产生后总线就处于空闲态。5.2数据传输时序I2C总线进行数据传送时时钟信号为高电平期间数据线上的数据必须保持稳定; 只有在时钟线上的信号为低电平期间数据线上的高电平或低电平状态才允许变化。在时钟为低电平期间发送器向数据线上写入数据因此数据线上的数据运行变化 在时钟为高电平期间接收器从数据线上读取数据因此必须保持数据线上的数据稳定。最终完成一个时钟周期内发送器发生一个bit位数据接收器接收一个bit位数据。5.3字节传输和应答信号 81每一个字节必须保证是8位长度。数据传送时先传送最高位MSB每一个被传送的字节后面都必须跟随一位应答位即一帧共有9位。在第9个时钟周期的低电平期间接收器向数据线上写入数据。在第9个时钟周期的高电平期间发送器从数据线上读取数据。如果读到的是低电平信号表示应答信号。如果读到的是高电平信号表示非应答信号。5.4 I2C寻址I2C总线上传送的数据信号是广义的既包括地址信号又包括真正的数据信号。主机在起始信号后必须传送一个从机的地址7位第8位是数据的传送方向位R/W用“0”表示主机发送数据W“1”表示主机接收数据R。 “w0我”总线上的每个从机都将这7位地址码与自己的地址进行比较如果相同则认为自己被主机寻址根据R/W位将自己定为发送器或接收器。//IIC地址的表示方式一般使用两位16进制表示//但是IIC地址只有7位两位16进制表示了8位二进制//此时会有两种分配方式// 假设IIC地址为1100 111//1从最低位补零的方式 1100 11100xCE 此种方式写的话地址可以直接用而读时需要或等于0x01//2从最高位补零 0110 01110x67 此种方式发送时需要先左移1位然后根据都或者写操作进行或等于或者与等于5.4.1 主机给从机发送一个字节5.4.2 主机给从机发送多个字节5.4.3 主机从从机接收一个字节5.4.4 主机从从机接收多个字节六、IIC6.1 软件IIC定位引脚6.1.1 定位引脚6.1.2 配置引脚为开漏默认高阻态6.1.3 给引脚添加标签6.2 驱动框架搭建6.2.1 添加驱动文件进行编写6.3 编写.h的框架#ifndef __MY_IIC__ #define __MY_IIC__ #endif6.3.1 编写.h文件的宏定义对时钟、数据线函数、引脚进行宏定义方便后续引脚变化时调整代码也方便使用者阅读#ifndef __MY_IIC__ #define __MY_IIC__ #include main.h //声明函数 #define IIC_SCL_PORT GPIOA #define IIC_SCL_PIN GPIO_PIN_8 #define IIC_SDA_PORT GPIOC #define IIC_SDA_PIN GPIO_PIN_9 #define IIC_SCL_1() HAL_GPIO_WritePin(IIC_SCL_PORT,IIC_SCL_PIN,1) #define IIC_SCL_0() HAL_GPIO_WritePin(IIC_SCL_PORT,IIC_SCL_PIN,0) #define IIC_SDA_1() HAL_GPIO_WritePin(IIC_SDA_PORT,IIC_SDA_PIN,1) #define IIC_SDA_0() HAL_GPIO_WritePin(IIC_SDA_PORT,IIC_SDA_PIN,0) #endif6.4 编写.c 文件编写发送单个字节的完整时序函数#include myiic.h /* 0 初始化IIC 1 写单个字节 2 写多个字节 3 读单个字节 4 读多个字节 */ /* 单片机发送单个字节给从机 param : 从机地址W 寄存器地址 发送的内容 */ void Myiic_SendByte(uint8_t iic_addr,uint8_t reg_addr,uint8_t send_data) { //1 起始标识 //2 发送从机地址写 //3 接收应答 //4 发送寄存器地址 //5 接收应答 //6 发送8为数据 //7 接收应答 //8 发送停止标识 }6.5 为了提高IIC的通信速度需要用到微秒级延时创建微秒级延时的驱动代码mydelay.c#include mydelay.h //使用滴答定时器实现微秒级延时函数 //编写思路初始值与当前计数值相减 //数值大于等于我们设定的延时值就跳出循环 void Delay_us(uint32_t us) { //1.定义变量来存储时间 uint32_t ticks;//需要的滴答数 uint32_t t_old,t_now,t_cnt 0;//之前的时间、现在的时间、计数值 uint32_t reload SysTick-LOAD;//将重载值放在变量reload里 //2.计算需要的滴答数 ticks us * 100; t_old SysTick-VAL;//将最开始的值赋给t_old //3.比较计时时间与设定时间大小 while(1) { t_now SysTick-VAL;//获取当前计数值 if(t_now ! t_old) { if(t_now t_old) { t_cnt t_old - t_now;//将两者差值累加 } else//VAL减到零重置时 { t_cnt reload-t_nowt_old;//把重新装载之后减得数进行累加 } t_old t_now;//更新原来的t_old值 if(t_cnt ticks)//计时时间大于设定时间则跳出while { break; } } } } void Delay_ms(uint32_t ms) { for(int i 0;ims;i) Delay_us(1000); }mydelay.h#ifndef __MY_Delay__ #define __MY_Delay__ #include main.h //高精延时 void Delay_us(uint32_t us); void Delay_ms(uint32_t ms); #endif6.6 添加延迟6.6.1 对延时函数添加宏定义6.7 因为起始信号会被多次复用所以封装成函数更易理解和模块化编写起始函数void Myiic_Start(void) { IIC_SCL_0(); IIC_SDA_1(); IIC_Delay(); IIC_SCL_1(); IIC_SDA_0(); IIC_Delay(); }6.8 封装一个发送8bit数据的函数//发送8bit void Myiic_8Bit(uint8_t send_data) { for(int i 0; i8;i) { /* 时钟线拉低 发送最高位 然后拉高 等待从机读取 再拉低 发送次高位*/ IIC_SCL_0(); if(send_data 0x80) { IIC_SDA_1(); }else{ IIC_SDA_0(); } IIC_Delay(); //拉高时钟线从机可以读取数据了 IIC_SCL_1(); IIC_Delay();//让从机有充分的时间来读取 send_data 1; } }6.9 以此类推可完成写单个函数#include myiic.h void Myiic_Start(void) { //防止发送起始标识之前SDA处于低电平需要提前拉高 IIC_SCL_0(); IIC_SDA_1(); IIC_Delay(); IIC_SCL_1(); IIC_SDA_0();//时钟线高电平时拉低数据线 IIC_Delay(); } void Myiic_Stop(void) { IIC_SCL_0(); IIC_SDA_0(); IIC_Delay(); IIC_SCL_1(); IIC_SDA_1(); IIC_Delay(); } void Myiic_8bit(uint8_t data) { //拉低时钟线准备发送 for(int i 0; i8;i) { IIC_SCL_0(); //p7 p6 p5 p4 p3 p2 p1 p0 // 1 0 0 0 0 0 0 0 if(data 0x80)//0000 000p7 IIC_SDA_1(); else IIC_SDA_0(); IIC_Delay(); //拉高时钟线 从机可以读取数据 IIC_SCL_1(); IIC_Delay();//让高电平保持一段时间给从机留时间读 data 1; } } int Myiic_WaitAck(void) { //拉低时钟线 让从机发送应答 IIC_SCL_0(); //松手 IIC_SDA_1(); //延迟 IIC_Delay(); //拉高时钟线 IIC_SCL_1(); //读信号线 //根据读到的高低电平判断有无应答 if(IIC_SDA_READ() 1) { //无应答 //发送停止结束通信 IIC_SCL_0(); IIC_SDA_0(); IIC_Delay(); IIC_SCL_1(); IIC_SDA_1(); IIC_Delay(); return NACK; }else{ return ACK; } } /* 发送单个字节到从机 param: iic_addr 最低位为0 默认写的IIC地址 reg_addr 要写的寄存器地址 send_data 要发送的数据 return 0应答 1非应答 */ int Myiic_SendByte(uint8_t iic_addr,uint8_t reg_addr,uint8_t send_data) { //1 起始标识 Myiic_Start(); //2 发送8位数---从机地址写 Myiic_8bit(iic_addr); //3 接收应答 if(Myiic_WaitAck() NACK) { return NACK; } //4 发送8位数---寄存器地址 Myiic_8bit(reg_addr); //5 接收应答 if(Myiic_WaitAck() NACK) { return NACK; } //6 发送8位数---要写的数据 Myiic_8bit(send_data); //7 接收应答 if(Myiic_WaitAck() NACK) { return NACK; } //8 发送停止标识 Myiic_Stop(); }#ifndef __MY_IIC__ #define __MY_IIC__ #include main.h #include mydelay.h #define IIC_Delay() Delay_us(2) #define IIC_SCL_PORT GPIOA #define IIC_SCL_PIN GPIO_PIN_8 #define IIC_SDA_PORT GPIOC #define IIC_SDA_PIN GPIO_PIN_9 enum{ ACK0, NACK }; #define IIC_SCL_1() HAL_GPIO_WritePin(IIC_SCL_PORT,IIC_SCL_PIN,1) #define IIC_SCL_0() HAL_GPIO_WritePin(IIC_SCL_PORT,IIC_SCL_PIN,0) #define IIC_SDA_1() HAL_GPIO_WritePin(IIC_SDA_PORT,IIC_SDA_PIN,1) #define IIC_SDA_0() HAL_GPIO_WritePin(IIC_SDA_PORT,IIC_SDA_PIN,0) #define IIC_SDA_READ() HAL_GPIO_ReadPin(IIC_SDA_PORT,IIC_SDA_PIN) #endif6.10 完整驱动#include myiic.h void Myiic_Start(void) { //防止发送起始标识之前SDA处于低电平需要提前拉高 IIC_SCL_0(); IIC_SDA_1(); IIC_Delay(); IIC_SCL_1(); IIC_SDA_0();//时钟线高电平时拉低数据线 IIC_Delay(); } void Myiic_Stop(void) { IIC_SCL_0(); IIC_SDA_0(); IIC_Delay(); IIC_SCL_1(); IIC_SDA_1(); IIC_Delay(); } void Myiic_8bit(uint8_t data) { //拉低时钟线准备发送 for(int i 0; i8;i) { IIC_SCL_0(); //p7 p6 p5 p4 p3 p2 p1 p0 // 1 0 0 0 0 0 0 0 if(data 0x80)//0000 000p7 IIC_SDA_1(); else IIC_SDA_0(); IIC_Delay(); //拉高时钟线 从机可以读取数据 IIC_SCL_1(); IIC_Delay();//让高电平保持一段时间给从机留时间读 data 1; } } int Myiic_WaitAck(void) { //拉低时钟线 让从机发送应答 IIC_SCL_0(); //松手 IIC_SDA_1(); //延迟 IIC_Delay(); //拉高时钟线 IIC_SCL_1(); //读信号线 //根据读到的高低电平判断有无应答 if(IIC_SDA_READ() 1) { //无应答 //发送停止结束通信 IIC_SCL_0(); IIC_SDA_0(); IIC_Delay(); IIC_SCL_1(); IIC_SDA_1(); IIC_Delay(); return NACK; }else{ return ACK; } } void Myiic_NACK(void) { //拉低时钟线 IIC_SCL_0(); //松手 IIC_SDA_1(); //延迟 IIC_Delay(); //拉高时钟线 IIC_SCL_1(); //延迟 通知从机读取信号线 IIC_Delay(); } void Myiic_ACK(void) { //拉低时钟线 IIC_SCL_0(); //拉低数据线 IIC_SDA_0(); //延迟 IIC_Delay(); //拉高时钟线 IIC_SCL_1(); //延迟 通知从机读取信号线 IIC_Delay(); IIC_SCL_0(); IIC_SDA_1(); } void Myiic_RECV8Bit(uint8_t *data) { int recv_data0; //接收8位数据 for(int i0;i8;i) { //拉低时钟线 延迟 让对方来发送 IIC_SCL_0(); IIC_Delay(); //拉高时钟线 IIC_SCL_1(); //读取当前线的电平赋值recv_data第8位 recv_data 1; recv_data | IIC_SDA_READ(); //延迟 IIC_Delay(); } *data recv_data; } /* 发送单个字节到从机 param: iic_addr 最低位为0 默认写的IIC地址 reg_addr 要写的寄存器地址 send_data 要发送的数据 return 0应答 1非应答 */ int Myiic_SendByte(uint8_t iic_addr,uint8_t reg_addr,uint8_t send_data) { //1 起始标识 Myiic_Start(); //2 发送8位数---从机地址写 Myiic_8bit(iic_addr); //3 接收应答 if(Myiic_WaitAck() NACK) { return NACK; } //4 发送8位数---寄存器地址 Myiic_8bit(reg_addr); //5 接收应答 if(Myiic_WaitAck() NACK) { return NACK; } //6 发送8位数---要写的数据 Myiic_8bit(send_data); //7 接收应答 if(Myiic_WaitAck() NACK) { return NACK; } //8 发送停止标识 Myiic_Stop(); } uint8_t Myiic_SendByte_len(uint8_t iic_addr,uint8_t reg_addr,uint8_t *send_data,uint8_t send_len) { //起始标识 Myiic_Start(); Myiic_8bit(iic_addr); if(Myiic_WaitAck() NACK) { return NACK; } Myiic_8bit(reg_addr); if(Myiic_WaitAck() NACK) { return NACK; } for(int i0;isend_len;i) { Myiic_8bit(send_data[i]); if(Myiic_WaitAck() NACK) { return NACK; } } Myiic_Stop(); } uint8_t Myiic_Read_1Byte(uint8_t iic_addr,uint8_t reg_addr,uint8_t *recv_data) { //起始标识 Myiic_Start(); //发送从机地址写 Myiic_8bit(iic_addr); if(Myiic_WaitAck() NACK) { return NACK; } Myiic_8bit(reg_addr); if(Myiic_WaitAck() NACK) { return NACK; } //重新发送起始 Myiic_Start(); Myiic_8bit(iic_addr | 0x01); if(Myiic_WaitAck() NACK) { return NACK; } Myiic_RECV8Bit(recv_data); //发送非应答 Myiic_NACK(); //发送结束信号 Myiic_Stop(); } /* iic主机接收多字节函数 param : 从机地址W 寄存器地址 接收的变量地址 接收长度 */ uint8_t Myiic_Read_Byte_len(uint8_t iic_addr,uint8_t reg_addr,uint8_t *data,uint8_t recv_len) { //1起始标识 Myiic_Start(); //2 发送从机地址写 Myiic_8bit(iic_addr); //3等待应答 if(Myiic_WaitAck() NACK) { return NACK; } //4 发送寄存器地址 Myiic_8bit(reg_addr); //5等待应答 if(Myiic_WaitAck() NACK) { return NACK; } //6发送起始标识 Myiic_Start(); //7发送从机地址读 Myiic_8bit(iic_addr | 0X01); //8等待应答 if(Myiic_WaitAck() NACK) { return NACK; } //9接收第1个字节连读8位 //10发送应答 //。。重复910当接收达到recv_len时发送非应答 for(int i 0;irecv_len;i) { Myiic_RECV8Bit(datai); if(i ! recv_len-1) { Myiic_ACK(); } } Myiic_NACK(); //发送停止标识 Myiic_Stop(); }#ifndef __MY_IIC__ #define __MY_IIC__ #include main.h #include mydelay.h #define IIC_Delay() Delay_us(2) #define IIC_SCL_PORT GPIOA #define IIC_SCL_PIN GPIO_PIN_8 #define IIC_SDA_PORT GPIOC #define IIC_SDA_PIN GPIO_PIN_9 enum{ ACK0, NACK }; #define IIC_SCL_1() HAL_GPIO_WritePin(IIC_SCL_PORT,IIC_SCL_PIN,1) #define IIC_SCL_0() HAL_GPIO_WritePin(IIC_SCL_PORT,IIC_SCL_PIN,0) #define IIC_SDA_1() HAL_GPIO_WritePin(IIC_SDA_PORT,IIC_SDA_PIN,1) #define IIC_SDA_0() HAL_GPIO_WritePin(IIC_SDA_PORT,IIC_SDA_PIN,0) #define IIC_SDA_READ() HAL_GPIO_ReadPin(IIC_SDA_PORT,IIC_SDA_PIN) int Myiic_SendByte(uint8_t iic_addr,uint8_t reg_addr,uint8_t send_data); uint8_t Myiic_SendByte_len(uint8_t iic_addr,uint8_t reg_addr,uint8_t *send_data,uint8_t send_len); uint8_t Myiic_Read_1Byte(uint8_t iic_addr,uint8_t reg_addr,uint8_t *recv_data); uint8_t Myiic_Read_Byte_len(uint8_t iic_addr,uint8_t reg_addr,uint8_t *data,uint8_t recv_len); #endif七、 硬件IIC函数HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);hi2c: I2C 句柄指针比如hi2c1。DevAddress: 目标从设备的地址。STM32 的 HAL 库要求这里传入的是包含读写位的 8 位地址底层的代码会自动帮你把最低位变成 0表示写操作。pData: 要发送的数据缓冲区的指针。Size: 要发送的数据字节数。Timeout: 超时时间毫秒。HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);hi2c: 同上I2C 句柄指针。DevAddress: 目标从设备的地址。同样需要把 7 位地址左移 1 位(DevAddress 1)。底层代码会自动帮你把最低位变成 1表示读操作。pData: 用来存放接收到的数据的缓冲区指针。Size: 你期望读取的数据字节数。Timeout: 超时时间毫秒。八、总结特点软件IIC 通过CPU直接控制GPIO引脚去生成IIC时序优点引脚配置更加的灵活 使用起来更加稳定可控缺点需要自己写时序函数速度上限不如硬件IIC硬件IIC使用硬件IIC外设实现CPU只需要将要发送的数据传给IIC外设时序由IIC外设借助GPIO引脚自动生成优点是速度上限高使用简单方便缺点引脚太固定了IIC外设容易死机