简介:这是一份基于C语言讲解I2C通信协议的完整工程源码包,适合单片机初学者、嵌入式开发人员以及需要掌握I2C底层时序的工程师。资料从协议基础入手,覆盖SCL/SDA时序、7位设备地址、开始/停止条件、读写流程,并结合具体C代码展示在Keil环境下的实现方法,能帮助读者快速理解并上手I2C总线编程。压缩包共18个文件,主要包含C源程序、头文件、Keil工程配置(uv2/opt/plg)、汇编启动文件、链接与调试辅助文件,以及编译生成的hex和obj等文件,整体仅25KB,结构精简、便于对照学习。目前已有1123人学习下载。通过查看源码与工程配置,读者可以直观看到I2C初始化、主设备发送/接收、ACK应答处理等关键代码段,同时结合STARTUP.A51和内存映射文件理解单片机启动与编译过程,是一份适合边读边练的实战型学习资料。 搞嵌入式的人,十个有八个都跟I2C打过交道。不管是读个温湿度传感器、驱动一块OLED屏,还是往EEPROM里存参数,I2C这个通信协议几乎是绕不开的基础课。而用C语言实现I2C通信程序,又是从单片机入门到项目实战的必经之路。这篇文章我会从协议本身的逻辑讲起,把软件模拟I2C和硬件I2C两条路线都拆开对比,再带着你把起始、停止、字节收发、应答检测这些核心时序用C代码完整落地,最后聊一聊我在STM32F407上调I2C时踩过的坑和排查套路。不管你是刚接触通信协议的新手,还是想优化现有代码的老手,应该都能从这里拿走一些能直接上板子的东西。
1. I2C通信协议的核心基础
1.1 一根时钟一根数据,先弄懂总线底层逻辑
I2C全称Inter-Integrated Circuit,是Philips在上世纪80年代提出的串行通信总线。它最大的特点就是省引脚,整个通信只需要两根线:SCL时钟线和SDA数据线。最早在PC主板上用来接EEPROM、温度传感器等低速设备,现在几乎任何带MCU的项目里都看得到它的身影。
物理层有两个关键点需要先想明白。第一,SCL和SDA都是开漏输出结构,外部必须有上拉电阻。这意味着任何设备都可以主动把电平拉低,总线上天然具备“线与”特性——谁拉低谁就生效,高电平是“释放”而不是“驱动”。第二,I2C通信有明确的主从关系,主机负责产生时钟,从机在时钟节拍下收发数据,一条总线上可以挂多个从机,每个从机有唯一的7位或10位地址,主机通过地址来“点名”通信。
从协议层面看,最核心的就是那几个状态:起始条件(START)、停止条件(STOP)、数据位传输、应答位(ACK/NACK)。很多初学者死记时序图也记不住,其实抓住三个点就够了:时钟高电平期间SDA的电平必须保持稳定,数据才算有效;时钟低电平期间SDA才能翻转,这是数据变化的窗口;起始和停止两个特殊信号都是在SCL高电平期间,SDA产生特定跳变来定义的。理解到这一层,后面看时序图就不会再一头雾水。
1.2 时序参数与速率档位,别一上来就抄代码
I2C有几种标准速率:标准模式100kbps、快速模式400kbps、快速模式+(1Mbps)和高速度模式(3.4Mbps)。我们在C语言里写模拟I2C时,最常用到的就是100k和400k这两个档位。每个档位对时序参数都有明确要求,比如100k标准模式下,SCL高电平最小时间为4us,低电平最小时间为4.7us,SDA数据建立时间最小250ns,保持时间最小0ns。到了400k快速模式,这些时间会等比例缩短。
为什么我强调“别一上来就抄代码”?因为网上很多例程的延时函数是随便拍的,有的延时过长导致通信速率只有几十k,有的延时时长不够导致从机采不到数据。软件模拟I2C的核心本质就是“拉高SCL、延时、拉低SCL、延时”这样一个循环,延时的长短直接决定通信速率。正确的做法是先确定目标速率,再根据MCU主频计算或实测延时时间,最后用逻辑分析仪验证波形是否满足协议要求。对于大多数传感器和EEPROM,400k已经够用了,但如果你是软件模拟而且MCU主频不高,建议先从100k开始调通,再去提速,这样排查问题会轻松很多。
2. 硬件I2C与软件模拟I2C,C语言实现前先想清楚
2.1 硬件外设省心但也有翻车场景
既然要写I2C的C语言程序,逃不开一个选择:用MCU自带的I2C外设,还是用GPIO模拟时序。这两个方案在C语言代码上的写法差别很大。
硬件I2C的好处是省CPU资源,收发过程由外设的状态机自动处理,数据从寄存器里读写就行,速度也能轻松跑到400k甚至1M。代码量确实小,紧跟HAL库或标准库的接口调用即可。但硬件I2C也有让人头疼的地方,ST系列早期的I2C外设实现比较复杂,一旦收发出错,外设状态机容易卡死,需要额外的恢复逻辑,比如手动复位外设、把GPIO切回普通输出口模拟时钟脉冲去释放总线。这一点在热搜词里“stm32f407模拟i2c”出现频率这么高,就能说明大家的痛点所在。
软件模拟I2C则完全绕开了外设状态机的复杂性,直接用GPIO把协议层的电平变化逐个实现出来,出问题后逻辑简单、好查好改。而且它的灵活性极高——当I2C外设数量不够用,或者需要把某两个普通引脚复用为I2C时,随便找两个GPIO就能扩展出一条I2C总线。我个人的看法是:如果项目对功耗和CPU占用要求严格,用硬件I2C;如果追求代码可控、稳定排障,在STM32上软件模拟I2C反而更实际。
2.2 软件模拟I2C的代码分层思路
软件模拟I2C本质上就四件事:起始、停止、发送一个字节、接收一个字节。剩下的一切,不管是读某个寄存器、写某个寄存器,还是连续读多个字节,都是在这四个基础操作之上做组合。
我强烈建议把代码按照下面的层次来组织:
- 底层硬件层:只负责GPIO初始化、电平翻转和延时。
- 协议层:封装I2C_Start()、I2C_Stop()、I2C_SendByte()、I2C_RecvByte()这些协议级函数。
- 业务层:根据具体器件封装读写接口,比如BH1750_ReadLux()、AT24C02_WriteByte()。
这样做的好处是底层驱动跟具体传感器型号完全解耦。以后换个芯片,协议层一个字节都不用动,只需要新建一个业务文件,用协议层的函数去实现新芯片的访问时序。我见过太多初学者把I2C所有代码直接写在一个大函数里,换一块传感器就要重写大半个驱动,这种代码维护起来相当痛苦。
3. 手写一遍软件模拟I2C,核心时序代码完整解析
3.1 起始信号与停止信号实现
我用STM32F407来演示,MCU主频168MHz,SCL和SDA分别接两个普通GPIO。先定义好底层的电平操作宏:
#define SCL_H() HAL_GPIO_WritePin(SCL_GPIO_Port, SCL_Pin, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(SCL_GPIO_Port, SCL_Pin, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(SDA_GPIO_Port, SDA_Pin, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(SDA_GPIO_Port, SDA_Pin, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(SDA_GPIO_Port, SDA_Pin) #define I2C_DELAY() delay_us(2) // 半周期延时,约2us,对应约250k速率起始条件的协议定义是:SCL高电平时,SDA从高跳变到低。停止条件则是:SCL高电平时,SDA从低跳变到高。注意,SDA的跳变必须发生在SCL高电平期间,这个顺序错了从机压根不会识别。
void I2C_Start(void) { SDA_H(); SCL_H(); I2C_DELAY(); SDA_L(); // SCL高电平期间,SDA拉低 => 起始信号 I2C_DELAY(); SCL_L(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_DELAY(); SDA_H(); // SCL高电平期间,SDA拉高 => 停止信号 I2C_DELAY(); SCL_L(); }起始前把SDA和SCL都拉到高,这是一个“空闲态”准备过程,保证总线处于释放状态。很多网上例程漏掉了起始前的SDA_H()操作,在连续多次通信时可能出现问题。
3.2 字节发送与应答检测
发送一个字节采用MSB先行,也就是先发最高位。每一位的时序核心是:先设置SDA的电平,然后拉高SCL,在SCL高电平期间保持SDA不动,延时后拉低SCL,这个才允许切换SDA。
uint8_t I2C_SendByte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { if (data & 0x80) { SDA_H(); } else { SDA_L(); } data <<= 1; I2C_DELAY(); SCL_H(); I2C_DELAY(); SCL_L(); } // 第9个时钟周期,释放SDA,读取从机应答 SDA_H(); I2C_DELAY(); SCL_H(); I2C_DELAY(); uint8_t ack = (SDA_READ() == GPIO_PIN_RESET) ? 1 : 0; SCL_L(); return ack; }应答位的逻辑是:主机发送完第8位数据后,第9个时钟周期由从机控制SDA。从机正常接收后会主动拉低SDA表示ACK,主机读取到低电平说明通信正常。函数返回值1表示收到应答,0表示无应答。这里有个容易忽略的细节:主机释放SDA的方式是把SDA输出置高,依靠上拉电阻把电平拉上去,而不是直接切换成输入模式,这一点在上拉电阻阻值过大时尤其要注意,否则SDA电平爬升太慢,会导致应答位误判。
3.3 完整读取流程:以BH1750光传感器为例
BH1750是数字环境光传感器,软件模拟I2C的经典案例。读一次光照度的流程是这样的:
- 发送起始信号。
- 发送设备地址加写位,BH1750的7位地址是0x23,写成8位发送就是0x46(0x23 << 1 | 0)。
- 等待ACK。
- 发送指令码,比如0x10,表示连续H分辨率模式。
- 等待ACK。
- 发送停止信号。
- 再发起始信号。
- 发送设备地址加读位,即0x47(0x23 << 1 | 1)。
- 等待ACK。
- 连续读两个字节,先高位后低位。
- 倒数第二个字节读完后主机回ACK,最后一个字节读完后主机回NACK,表示不再读了。
- 发送停止信号。
对应到C代码,核心读字节函数如下:
uint8_t I2C_RecvByte(uint8_t ack) { uint8_t i, data = 0; // 切换SDA为输入?这里我们依赖释放SDA(置高)配合上拉 for (i = 0; i < 8; i++) { data <<= 1; SDA_H(); // 释放SDA,让从机控制电平 SCL_H(); I2C_DELAY(); if (SDA_READ() == GPIO_PIN_SET) { data |= 0x01; } SCL_L(); I2C_DELAY(); } // 第9个时钟,根据ack参数决定回ACK还是NACK if (ack) { SDA_L(); // 拉低SDA,表示ACK } else { SDA_H(); // 释放SDA,表示NACK } SCL_H(); I2C_DELAY(); SCL_L(); SDA_H(); return data; }多字节读取时,注意最后一个字节必须返回NACK,否则从机会认为主机还想继续读,导致通信结束时机错乱。这个逻辑一旦写错,经常会出现“读出来的数据多一个字节”或者“下一轮忙等待超时”的怪现象。
4. 调试I2C最常见的几个坑与排查技巧
4.1 总线看不到设备,先查这三处
I2C调不通,90%的情况不是代码逻辑问题,而是硬件连接和地址配置问题。我总结了一套排查顺序,按照这个顺序查,基本能快速定位。
第一步,用万用表测SCL和SDA对地电压。总线空闲时两根线都应该是上拉电压(3.3V或5V)。如果发现其中一根被拉低到0V,说明总线上有设备异常占用,这时候改代码是没用的,先排查硬件。第二步,确认上拉电阻有没有焊,阻值对不对。I2C标准推荐的上拉电阻一般在2.2k到10k之间,总线电容越大阻值就要越小,否则上升沿太慢会导致高速通信失败。第三步,确认从机地址,尤其是7位地址和8位地址的换算。很多传感器的数据手册给出的是7位地址0x23,但实际发送时要左移一位拼上读写位变成0x46或0x47。这个换算半年不写就能忘,遇到无应答先检查这里。
4.2 总线锁死的现场恢复方法
I2C总线锁死是嵌入式圈子的“经典老番”。现象是SCL和SDA中有一根或两根被拉低,主机的收发函数卡死在某个等待ACK的循环里,看门狗都想骂人。
原因通常是主从通信过程中,从机突然掉电或者被复位,主机的SCL可能停留在低电平,SDA也可能停在不完整的数据位上,从机的状态机彻底乱了。最直接的恢复办法:把SCL切换到普通GPIO输出模式,手动翻转9个时钟脉冲,让从机的移位寄存器复位,然后发一个停止条件,把SDA释放回高电平。
我在项目里会预置一个恢复函数,在每次通信前检查总线忙状态,如果出现忙就调用:
void I2C_Bus_Recovery(void) { // 前提:SCL和SDA已经切到GPIO推挽输出模式 SDA_H(); for (int i = 0; i < 9; i++) { SCL_L(); I2C_DELAY(); SCL_H(); I2C_DELAY(); } // 发停止条件 SDA_L(); SCL_H(); SDA_H(); }实测下来,绝大多数锁死情况都能被这9个时钟脉冲救回来。个别特殊芯片,比如某些电池管理芯片,可能要求恢复时序更复杂,但9个时钟是通用的“万能钥匙”,值得先试。
4.3 问题排查速查表
下面这张表我贴在工位上很久了,遇到I2C问题直接对照着看,比翻半天手册高效得多。
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| 一直在等待ACK超时 | 设备地址错误、器件未上电、上拉电阻缺失 | 测SDA电平、检查从机地址换算 |
| 能收发但数据乱码 | 速率太高、延时不足、上升沿过慢 | 降低到100k试、换更小的上拉电阻 |
| 总线锁死、SCL被拉低 | 从机异常复位、时序中途被打断 | 切换GPIO输出发9个时钟恢复 |
| 偶发性无响应 | 电源波动、共地不良、干扰 | 检查电源纹波、共地、走线长度 |
| 最后一个字节多读 | 主机收尾时没有回NACK | 检查读字节函数ack参数逻辑 |
5. 代码健壮性优化与个人经验
5.1 给代码加上超时和错误处理
大多数初学者的I2C代码都存在一个隐患:在等待ACK时用的是死循环,从机不响应就会永远卡住。这在产品级代码里是不可接受的,一旦总线上某个设备异常,整个系统都可能死掉。
我建议每次发送地址或数据后,对返回的ACK做判断,失败时直接退出当前操作并返回错误码,而不是继续执行后面的数据读取。另外,等待SCL释放或者SDA释放的过程也可以加上超时,比如用一个计数器循环等待,超过一定次数就报错。这样把错误向上抛,由上层决定是重试还是重启设备,系统的健壮性会好很多。
C语言实现时可以考虑这样设计协议层的返回值:0表示成功,非0表示错误类型。这个思路配合调试打印,能让你在几秒内定位到到底是地址阶段失败还是数据阶段失败。
5.2 我推荐的工作习惯与效率工具
最后分享几个我个人的习惯,不一定适合所有人,但对提升调试效率确实有帮助。
第一,善用逻辑分析仪。别觉得几十块的逻辑分析仪不上档次,在调I2C时序这件事上,它比示波器更直观。直接把SCL和SDA两根线夹上去,电脑上就能解出每一帧的地址、数据和ACK状态,一眼就能看出是哪个阶段出错。第二,移植代码时尽量只改底层宏。把引脚定义、延时函数集中在一个头文件里,换板子只需要改这一处,省去大量重复劳动。第三,不要迷信HAL库或者标准库,软件模拟I2C是理解协议最好的练习方式,哪怕最后产品里用硬件I2C,也建议先手写一遍模拟时序,这样遇到任何诡异问题都能从根上思考。
我自己做STM32项目多年,相当一部分排查时间都是在看I2C的时序。软件模拟I2C看起来“低级”,但它把协议的一举一动都摊开在眼前,反而让我把这个协议彻底吃透了。后期再做硬件I2C,碰到状态机卡死、总线锁死这些问题,脑子里随时能浮现出底层时序的运作过程,修复起来自然就快。希望这篇文章能帮你在I2C这条路上少走点弯路。
本文还有配套的精品资源,点击获取