简介:面向STM32F4开发者的I2C通信例程包,演示使用I2C1对EEPROM(24LC02)进行页写和顺序读操作,并将读回数据通过RS232发送到PC,以验证读写一致性。例程中给出I2C_Test()测试函数:先填充256字节缓冲区,调用I2C_Write向EEPROM写入,再调用I2C_Read读回,最后通过RS232_Send_Data把数据发送到上位机,逻辑清晰适合对照学习。资源适合刚接触I2C总线协议、正在调试STM32F4外设库或需要快速集成EEPROM功能的工程师。压缩包共1028个文件,约6.92MB,除了核心C源文件和头文件外,还包含Keil工程配置、编译生成的中间文件、链接脚本、HTML与JS/PNG等辅助文档,能够完整还原一次开发与调试过程。目前已有2461人学习下载。通过这份例程,可以梳理I2C初始化、起始/停止条件、应答检测、EEPROM内部地址管理等关键节点,结合代码注释理解标准外设库函数调用方式,也可直接参考工程配置将读写流程移植到自研硬件上,减少踩坑时间。 搞嵌入式这几年,I2C算是我见过最多的“看着简单、用起来翻车”的外设之一。尤其是在STM32F4上跑I2C例程,网上资料一抓一大把,但很多人把代码复制到自己的板子上,结果不是卡死在等待标志位,就是读回来的数据全是0xFF。我自己早期也被这玩意儿折腾过好几个晚上,后来把协议细节、例程架构和调试手段都理清楚之后,才发现绝大多数问题根本不是芯片不行,而是几个非常具体的配置和时序细节没做到位。
这篇东西就围绕STM32F4的I2C通信例程来写,从协议基础、开发环境准备、硬件I2C与模拟I2C怎么选,到完整可复现的例程代码、地址扫描工具和常见故障排查,一次性讲透。适合正在学STM32F4、准备调I2C传感器或EEPROM的同学,也适合被硬件I2C卡住想换软件模拟方案的朋友。
1. 先弄明白I2C到底在干什么
1.1 两根线、一个地址,外加一套时序规则
I2C全称Inter-Integrated Circuit,总线结构非常简单:一条SCL时钟线、一条SDA数据线。所有挂在总线上的设备都并联在这两根线上,主机负责产生时钟、发起通信,从机通过地址来识别主机是不是在叫自己。每个从机设备都有一个唯一的I2C地址,一般是7位,主机发送地址时实际发出8位,最低位表示读还是写。
从机的地址是怎么来的?拿我常用的BH1750光照传感器举例,它的ADDR引脚接低电平时,7位地址是0x23,接高电平时变成0x5C。但在代码里,用HAL库函数发送地址时,要注意填入的是8位地址,也就是左移一位后加上读写位:写地址0x46,读地址0x47。很多新手直接填0x23进去,然后发现设备没反应,就是栽在这个地方。关于地址,我建议你每个新器件都先查一下数据手册里的地址表和ADDR引脚配置,不要想当然。
I2C协议里有一个特别容易忽略的点:总线上的通信必须满足“起始条件”和“停止条件”的时序要求。SCL为高电平期间,SDA由高跳变为低,表示起始条件;SCL为高电平期间,SDA由低跳变为高,表示停止条件。数据位的传输则要求SDA在SCL低电平期间变化,SCL高电平期间保持稳定,每个字节8位、高位先出,第9个时钟周期是接收方发送的ACK/NACK应答位。主机在时钟线上产生第9个脉冲,从机如果正常工作就把SDA拉低表示ACK,如果没拉低就是NACK,说明设备没准备好、地址不对或者数据出错。
1.2 用生活化类比理解主从通信过程
可以把I2C通信想象成对讲机呼叫。起始条件相当于按下对讲机的通话键,停止条件等于松开按键说“完毕”。主机呼叫时先报对方的编号(设备地址),被叫到的设备回一声“在”(ACK),然后才开始传数据。每次传完一个字节,接收方都要回一个“收到”(ACK),不回就说明这个字节有问题。如果主机发完地址后等不到ACK,基本可以确定要么地址错了,要么设备没有正常上电,要么总线物理连接有问题。
标准I2C模式速率100kbit/s,快速模式400kbit/s,高速模式3.4Mbit/s。STM32F4的硬件I2C外设支持标准和快速模式,在实际使用中默认配置400kHz是没有问题的,但前提是你的上拉电阻和总线电容匹配。如果线材很长,或者传感器模块上用了比较强的上拉,速率太高会导致信号边沿变缓,误码率直线上升。这个我们后面在故障排查部分细说。
2. 环境准备:Keil5添加STM32F4器件与库的选择
2.1 新建工程时找不到STM32F4的解决办法
很多刚开始用Keil5的人会遇到一个尴尬问题:打开软件新建工程,Device列表里找不到STM32F407VET6或者F4系列的其他芯片。原因不是芯片太老,而是你的Keil5缺少对应的器件支持包,也就是DFP(Device Family Pack)。解决办法很简单,打开Pack Installer,在左侧找到STMicroelectronics,展开后选择STM32F4 Series,点击Install按钮安装对应版本的DFP。装完之后回到新建工程向导,芯片型号就会正常出现在列表里。
如果你使用的电脑不方便联网,也可以去Keil官网的器件支持包下载页面手动下载Keil.STM32F4xx_DFP.pack文件,然后在Pack Installer右下角的File菜单里选择Import from Local Directory导入本地pack文件,效果完全一样。装完pack后,启动文件、系统初始化文件、链接脚本这些都不用自己管,开发工具会自动根据所选芯片把对应的启动文件添加进来,这也是为什么网上老的“标准外设库”教程会需要你手动添加各种文件,而现在新建F4工程这么省事的原因。
2.2 标准外设库和HAL库该怎么选
这个问题几乎每个初学者都会纠结。我的建议非常直接:新工程一律用HAL库加CubeMX生成初始化代码,老工程如果已经在跑标准库、且整个团队都很熟,那就继续用标准库,不要中途迁移。标准外设库的特点是寄存器的封装比较薄,代码执行效率高,网上老例程多,但ST官方早在几年前就已经停止更新标准库了,新的芯片型号和新的外设功能都不再支持。HAL库是ST当前主推的库,配合CubeMX图形化配置,生成I2C初始化代码基本属于“点几下鼠标”的事,接口也封装得很简洁。
这里要特别说一个让我自己踩过坑的地方:很多人用HAL库跑I2C例程,代码逻辑看着完全没问题,但就是卡在HAL_I2C_Mem_Read或者HAL_I2C_Master_Transmit里出不来。翻看代码,超时参数TimeOut设的是HAL_MAX_DELAY,无限等待。这种写法写例程演示可以,但放在实际项目里,一旦I2C总线异常,系统直接死在那里。你至少应该给I2C通信设置一个合理的超时值,比如100ms或者200ms,并对返回值做错误处理,这样总线异常时程序还有机会恢复。
3. 硬件I2C和软件模拟I2C:选哪条路更稳
3.1 STM32F4的硬件I2C真的有那么多毛病吗
网上传得最多的说法是STM32的硬件I2C不好用,容易卡死,尤其是F1时代的问题被放大到了整个STM32家族。老实说,F4的硬件I2C其实已经靠谱很多,绝大多数“卡死”的案例,最后查出来都是配置问题:GPIO没有配置成开漏模式、上拉电阻没接、中断优先级配错,又或者是在中断服务函数里做了过重的操作。硬件I2C真正麻烦的地方在于状态机的维护,尤其是在处理错误标志位和总线恢复时,寄存器就那么几个,但组合起来的状态判断如果不熟练,会非常烧脑。
我的结论是这样的:如果你用的是CubeMX生成的初始化代码,HAL库把底层状态机都封装好了,那你完全可以直接用硬件I2C,稳定性和效率都优于模拟I2C。但如果你要在很老的板子或者特殊引脚上复用I2C,或者需要跑一些非标准的时序,软件模拟I2C反而更灵活,时序完全由代码控制,出问题也好排查。
3.2 软件模拟I2C的代码框架
软件模拟I2C本质上就是用两个GPIO分别模拟SCL和SDA,通过延时控制时序。GPIO要配置成开漏输出模式,这样外部上拉电阻能把电平拉高,拉低时通过开漏结构实现。基本操作代码可以这样写:
#define I2C_SCL_H() HAL_GPIO_WritePin(I2C_SCL_GPIO_Port, I2C_SCL_Pin, GPIO_PIN_SET) #define I2C_SCL_L() HAL_GPIO_WritePin(I2C_SCL_GPIO_Port, I2C_SCL_Pin, GPIO_PIN_RESET) #define I2C_SDA_H() HAL_GPIO_WritePin(I2C_SDA_GPIO_Port, I2C_SDA_Pin, GPIO_PIN_SET) #define I2C_SDA_L() HAL_GPIO_WritePin(I2C_SDA_GPIO_Port, I2C_SDA_Pin, GPIO_PIN_RESET) #define I2C_SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_GPIO_Port, I2C_SDA_Pin) static void I2C_Delay(void) { volatile uint32_t i = 10; while (i--); } void Soft_I2C_Start(void) { I2C_SDA_H(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_L(); } void Soft_I2C_Stop(void) { I2C_SDA_L(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_H(); I2C_Delay(); }写字节和读字节的核心逻辑也很简单,发送时逐位把SDA设置成对应电平,然后翻转SCL产生时钟;读取时在SCL高电平期间读取SDA引脚电平,逐位拼成字节。模拟I2C的延时决定了通信速率,这个延时的大小可以通过示波器或逻辑分析仪来调整,只要满足从机时序要求即可。我个人的经验是,延时尚且能用,但不要为了追求速度把延时压得太短,否则每个字节之间的建立时间不够,数据就会飘。
4. 实战:HAL库硬件I2C完整例程(以BH1750为例)
4.1 CubeMX配置与硬件接线
这个例程我用的是STM32F407VET6核心板,外接一个BH1750光照传感器模块。BH1750是环境光强度传感器,通过I2C接口读取两字节数据,量程最大65535 lx,典型应用是手机屏幕亮度调节和智能灯光系统,非常适合拿来当I2C例程的练习对象。
硬件接线非常简单:传感器模块的VCC接3.3V,GND接GND,SCL接PB6,SDA接PB7。需要注意BH1750模块一般已经板载了上拉电阻,可以直接和F4核心板连接。如果用的是自己焊的传感器,一定要在SCL和SDA上各接一个4.7k欧姆上拉电阻到3.3V,这是I2C总线工作的必要条件。
CubeMX里开I2C1外设,把PB6配置为I2C1_SCL、PB7配置为I2C1_SDA,模式下选择I2C,速度设置为400kHz。生成代码后用HAL库函数实现读取,核心逻辑分三步:发送上电指令、发送连续高分辨率测量指令、等待测量完成后读取两字节数据。BH1750的指令是8位的,直接用HAL_I2C_Master_Transmit发送即可。
4.2 完整例程代码拆解
#include "main.h" I2C_HandleTypeDef hi2c1; // 向BH1750发送一条指令 void BH1750_Send_Cmd(uint8_t cmd) { uint8_t temp = cmd; HAL_I2C_Master_Transmit(&hi2c1, 0x46, &temp, 1, 100); } // 读取BH1750的2字节光照数据 uint16_t BH1750_Read_Data(void) { uint8_t buf[2] = {0}; uint16_t lux = 0; HAL_I2C_Master_Receive(&hi2c1, 0x47, buf, 2, 100); lux = (buf[0] << 8) | buf[1]; lux = (uint16_t)(lux / 1.2f); return lux; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); // 上电后准备BH1750 BH1750_Send_Cmd(0x01); // Power On HAL_Delay(10); BH1750_Send_Cmd(0x10); // 连续H-分辨率模式 while (1) { uint16_t light = BH1750_Read_Data(); printf("Light: %d lux\r\n", light); HAL_Delay(500); } }这段代码看起来很简单,但有三个细节值得展开说说。第一,BH1750的地址0x46和0x47分别对应写和读,它是根据总线上的读写位自动切换的,不需要你手动在代码里算来算去。第二,0x10指令代表连续高分辨率模式,这个模式下的测量时间最长约180ms,所以两次读取之间最好间隔大于等于200ms,否则读取到的数据可能是上一次的测量结果。第三,原始两字节数据是0到65535之间的数值,除以1.2只是大多数照度计算中的常用近似系数,不同厂家手册里的公式略有差异,用哪个系数要以你手里芯片的数据手册为准。
4.3 EEPROM读写例程的也是同一个套路
如果你手里有AT24C02这类EEPROM,I2C例程的框架也完全一样,区别在于它多了一个内部地址阶段。EEPROM读写的流程是:先发送设备地址和写指令,再发送EEPROM内部的寄存器地址,然后才能写数据或者切换为读模式开始读数据。HAL库里对应的HAL_I2C_Mem_Read和HAL_I2C_Mem_Write两个函数,就是专门为这种“带内部寄存器地址”的设备设计的。这样调用:
uint8_t data_buf[4] = {0x11, 0x22, 0x33, 0x44}; HAL_I2C_Mem_Write(&hi2c1, 0xA0, 0x00, I2C_MEMADD_SIZE_8BIT, data_buf, 4, 100); HAL_I2C_Mem_Read (&hi2c1, 0xA0, 0x00, I2C_MEMADD_SIZE_8BIT, data_buf, 4, 100);EEPROM写操作后有写周期延时,通常在5ms左右,写完马上读会失败,需要加一点延时或者用轮询Acknowledge Polling的方式等待EEPROM内部擦写完成。这个“写完不能立刻读”的问题,在批量连续写入时尤其明显,我早期做数据存储功能时在这里卡了大半天,最后翻数据手册才发现有写周期时间这个参数。
5. 调试利器:I2C地址扫描与逻辑分析仪
5.1 用一段小代码扫描总线上有哪些设备
拿到一块新板子,不确定传感器地址、不确定接线的引脚是否正确,最有效的办法就是先做一次I2C地址扫描。总线上有个从机设备,就会在对应地址上返回ACK,没有设备则返回NACK。利用HAL库函数,我们可以遍历地址0x01到0x7F,逐个发送“只写地址”探测:
void I2C_Scan(void) { uint8_t error; uint8_t address; for (address = 1; address < 127; address++) { error = HAL_I2C_IsDeviceReady(&hi2c1, (uint16_t)(address << 1), 1, 10); if (error == HAL_OK) { printf("Found device at 0x%02X\r\n", address); } } }HAL_I2C_IsDeviceReady内部会向目标地址发送一个探测帧,如果收到ACK就返回成功。两个细节值得注意:一是这里的地址参数是7位地址左移一位后的8位地址,和上面说的HAL库规则保持一致;二是扫描速度不要太快,每个地址探测之间最好加一点小延时,否则总线上信号切换太频繁,对于走线较长的板子可能出现误判。
这个函数我几乎在每个I2C项目里都会先跑一遍,比拿万用表量电平快得多,也能直接验证接线和上电状态。扫描结果如果能正确列出设备地址,后面所有通信问题基本上都能缩小到“通信协议”本身,而不是物理连接的问题。
5.2 逻辑分析仪看时序图,问题一秒浮现
排查I2C通信问题,最直观的手段是拿逻辑分析仪抓波形。现在市面上的USB逻辑分析仪都很便宜,几十块钱就能买到8通道版本,配合软件里的I2C协议解析功能,可以看到起始条件、停止条件、设备地址、数据字节和ACK/NACK状态。我一直建议做嵌入式的小伙伴手里至少备一个,看起来只是多一个工具,实际上能把排障时间从几小时缩短到几分钟。
实际操作中,把逻辑分析仪的通道0接SCL、通道1接SDA、共地接好,然后设置采样率至少2MHz以上,触发方式选择下降沿,运行程序的同时抓取波形。波形上最常见的两个问题:一是SDA和SCL的电平翻转不完整,比如SCL高电平持续时间明显偏短,说明延时参数不合适或者GPIO速度配置太高,边沿太陡导致振铃;二是总线上出现连续多个NACK,说明从机设备没有正确响应,硬件I2C模式下要检查地址对不对,模拟I2C模式下要检查时序是否满足数据手册要求。另外,如果你抓到的波形上完全没有起始和停止条件,那就别纠结协议了,先回头查代码和GPIO配置。
6. 常见问题与排查技巧实录
6.1 卡死在while循环里的常见原因
HAL库的老版本代码里,很多人照着网上博客抄I2C例程,代码中会出现类似while (I2C_GetFlagStatus(...) != SET);这样的忙等写法。这种写法在标准外设库时代的代码里非常常见,一旦总线出现异常,程序就永久停在这个循环里。用HAL库时,问题更多表现为函数超时参数设成HAL_MAX_DELAY,然后函数内部状态一直停在等待ACK或者等待总线空闲。排查这一类问题,我总结了一个固定的优先级顺序:先检查SCL和SDA引脚配置是否是开漏输出,再查外部上拉电阻是否接好,然后用地址扫描确认从机地址是对的,最后用逻辑分析仪看波形,逐个排除。
我见过一个特别典型的案例:某块板上I2C设备偶尔正常工作、偶尔无响应,最后测量发现SDA引脚的波形上升沿非常缓慢,原因是MCU的引脚配置成了推挽输出而不是开漏输出。推挽输出驱动高电平时会直接和外部上拉电阻“打架”,时间长了甚至可能损坏引脚。开漏模式下,引脚只能拉低,拉高完全靠上拉电阻,这才是I2C总线的正确工作方式。
6.2 读回数据全是0xFF或0x00的情况
读回0xFF通常意味着SDA线上没有有效的低电平应答,相当于主机在“读空气”。可能原因包括:地址错误、设备未上电、传感器模块被锁死需要断电重启、SDA接触不良。读回0x00则常见于地址正确、ACK正常,但寄存器地址或读取长度不对,设备返回了无效数据。拿EEPROM举例,如果你用0x00地址读取、但实际上之前写入的地址是0x10,读出来的内容当然是默认值0xFF,这时候需要回读和写入地址保持一致。
还有一种非常容易踩的坑:有些传感器模块的I2C地址可以通过板载跳线或拨码开关修改,但模块的丝印或者默认状态和你想的不一样。遇到这种情况,不要凭经验猜地址,直接用5.1小节的地址扫描函数扫一遍,扫出来的结果就是最真实的情况。地址扫描在排查这类问题时,简直比找说明书还快。
6.3 快速排查速查表
| 现象 | 可能原因 | 验证方法 | 解决办法 |
|---|---|---|---|
| 找不到设备 | 引脚接反/上拉电阻缺失 | 万用表量SCL、SDA电压,应为高电平 | 接4.7k上拉到3.3V |
| 卡死在I2C函数内 | 超时参数无限等待 | 抓住总线上是否有波形 | 设置100ms超时,加错误处理 |
| 地址扫描无应答 | 设备地址错误/未上电 | 检查模块电源指示灯 | 用扫描函数遍历地址,确认地址 |
| 数据读回0xFF | 从机未应答/NACK | 逻辑分析仪看第9个时钟的SDA电平 | 检查地址、接线、供电电压 |
| 速率高了就不稳定 | 上拉过强/线容过大 | 降低速率测试 | 改为100kHz标准模式 |
| 写入后立即读失败 | 忘掉EEPROM写周期 | 读回值还是旧值 | 加5~10ms延时或轮询ACK |
6.4 再多说一句关于模拟I2C的耐受力
为什么很多老工程师在项目里坚持用软件模拟I2C,哪怕片上明明有硬件外设?因为模拟I2C在排查问题时有一个独特优势:你可以通过调试器打断点、单步执行,随时看到当前SCL和SDA的电平状态,定位逻辑错误非常直接。更关键的是,模拟I2C改引脚非常灵活,I2C协议本来就不限制引脚复用功能,你用哪两个GPIO都可以,不需要查数据手册里的AF映射表。硬件I2C虽然高效、处理器开销低,但引脚必须绑定在固定的复用功能上,布局布线时会受到一些限制。
我个人的建议是:学习阶段把硬件I2C和模拟I2C都亲手跑一遍,理解两种方式的差异。项目中如果追求稳定和省心,优先用HAL库的硬件I2C,但一定要加超时保护;如果涉及非标传感器或者需要频繁改动引脚,模拟I2C会让你舒服很多。两个方案没有绝对的优劣,关键是在正确的场景选正确的手段。
最后分享一个我现在的习惯:每次在新板子上调I2C,我不会一上来就写业务逻辑,而是先跑一遍地址扫描,确保总线物理层OK,再看器件的数据手册,确认寄存器和时序,最后才写应用代码。这套流程帮我省下了大量排查问题的时间,希望你也能少走弯路。
本文还有配套的精品资源,点击获取