简介:基于STM32的I2C从机固件工程包,面向嵌入式开发中需要实现从机通信的工程师与学习者。内容围绕STM32标准外设库展开,涵盖I2C协议原理、硬件接口配置、从机地址设置、中断收发及错误处理流程,适合对STM32有一定基础、希望快速落地I2C从机功能的开发者。压缩包共386个文件,以C源码、头文件、编译生成的目标文件及工程配置文件为主,包含hex/axf烧录文件与uvproj工程文件,约10.09MB,可直接对照学习或按需移植。已有1771人学习下载。工程中附带完整中断驱动代码,演示如何通过I2C_FLAG判断读写请求、应答与发送数据,并包含超时、总线冲突等异常处理思路,同时提供调试优化建议,可帮助读者缩短开发周期、避开常见坑点。 我最近在调一块STM32F103的最小系统板,项目里有个很尴尬的需求:主控板上已经没有多余的硬件I2C外设引脚了,但外接的一个传感器模块只认I2C协议,而且它还是从机模式。换句话说,我得让STM32去当那个从机,迎接主机(另一块板子)的访问。但硬件I2C引脚全被占用,怎么办?只能用固件方式去模拟一个I2C从机出来。
“基于stm32的固件I2C从机”这个需求听起来冷门,实际上在嵌入式开发里一点都不少见。尤其是做多板互联、传感器聚合、老设备扩展时,你会经常遇到“主机就那么几个、从机却一大堆”的窘境。如果你也是被硬件资源和引脚分配逼到这个方向,这篇文章就把我整理的方案、踩坑记录、代码骨架和排查思路全部摊开给你看。
1. 为什么选固件(软件)方式实现I2C从机
1.1 先搞清楚:这里的“固件I2C”到底指什么
嵌入式圈子里谈“固件I2C”,通常有两种含义。第一种是基于ST官方固件库(标准外设库、HAL库、LL库)配置硬件I2C外设,让STM32作为从机工作;第二种是彻底抛开I2C外设,纯粹用GPIO和中断/定时器,在固件代码里模拟出I2C从机的时序。
很多人一上来就劝退:“为啥不用硬件I2C外设?”这个问题的答案恰恰是这篇文章存在的意义。硬件外设确实省心,但它的引脚分配不灵活。一旦你的硬件I2C引脚被其他功能占用,或者芯片封装引脚不够,你就得启动备选方案。我这次遇到的场景就是:I2C1的两个引脚被复用成了其他通信功能,I2C2根本不存在于这颗小封装芯片上,而外部从机设备又不能换接口。于是最终方案确定为“GPIO模拟I2C从机”,也就是所谓的软件/固件从机。
注意:本篇文章偏重的是“软件模拟方式实现从机”,这是实现难度更高、但也是真正能救急的方案。硬件外设从机的配置方式我也会在关键环节做对比说明。
1.2 哪些场景需要从机,而且非软件不可
实际碰到的场景里,不是每个主机都愿意或者能够改协议。比如:
- 两个独立控制器之间通信,其中一个必须作为从机被另一个轮询
- K210这类AI视觉模块作为主机,STM32作为从机,处理视觉识别结果下发
- 老的工控主机或上位机软件只支持I2C总线读写,STM32需要伪装成一个寄存器地址可读写的“设备”
- 多个从设备挂在同一条I2C总线,但某一颗芯片的硬件从机地址和别的设备冲突,需要软件方式改地址
这些情况下,STM32的硬件从机如果地址固定、引脚固定、行为模式固定,就很难满足灵活需求。而软件模拟从机,最大的优势是“可定制”。地址可以由代码配置,寄存器映射可以自己设计,ACK/NACK的行为自己掌握。说白了,硬件从机给你一套规定动作,软件从机你可以随时做自由体操。
1.3 方案选型对比:硬件I2C外设 vs 软件模拟
我整理过一张对比表,方便你在方案设计阶段快速决策:
| 对比项 | 硬件I2C外设从机 | GPIO软件模拟从机 |
|---|---|---|
| 引脚占用 | 固定映射,受AFIO/Pinout限制 | 任意支持外部中断的GPIO |
| 地址匹配 | 硬件自动,最多可支持2个地址 | 软件判断,理论上可自定义任意地址 |
| 时钟延展 | 硬件支持、自动延展 | 实现难度大,需外部中断配合 |
| 传输速率 | 可达400kHz甚至1MHz | 100kHz稳定,400kHz很吃力 |
| 开发量 | 低,用CubeMX初始化就行 | 高,需要自己写状态机 |
| 灵活性 | 低 | 高 |
| 调试难度 | 低 | 高,需逻辑分析仪辅助 |
从表格能看出来,硬件外设是首选,只有当它不可用的时候才去碰软件模拟。我这次之所以做软件从机,还有一个隐性原因:老款STM32的硬件I2C外设Bug传闻不少,有些型号在从机模式下会出现总线错误或超时误判。虽然新版本芯片基本修复,但既然引脚已经被占,这个纠结就不存在了——直接走软件。
2. I2C从机必须吃透的几个协议细节
2.1 从机视角的事件时间线
说说我调试过程中的体感。I2C从机不是一个“轮询”就能搞定的角色,它是事件驱动的。总线上的信号变化快,从机必须像一个24小时待命的哨兵,时刻监听SCL和SDA的电平变化。
I2C总线空闲时,SCL和SDA都是高电平。主机发起通信之前,会先拉低SDA,此时SCL还保持高,这就是起始条件(START)。之后SCL开始输出时钟脉冲,每一个脉冲的高电平期间,SDA上的数据是有效的:1个字节共8个bit,按高位在前的方式逐位传输。字节传输完成后,主机会在第9个时钟脉冲释放SDA(不驱动它),此时从机如果应答,就把SDA拉低,这就是ACK;如果从机不应答,就保持高电平,那就是NACK。结束的时候,主机在SCL高电平期间把SDA从低拉高,构成停止条件(STOP)。
这段话背下来不难,难的是在软件实现中,每个事件都要被及时响应。比如起始条件出现时,你如果还在处理别的任务,可能就错过了地址字节。这就是为什么软件模拟从机必须用中断——不能靠主循环去查,除非你的主循环跑得足够快且没有其他耗时任务。
2.2 地址匹配:7位地址和那个R/W位
I2C从机地址匹配是第一个大坑。主机发送的第一个字节是“地址字节”,高7位是从机地址,最低位是读写标志:0表示主机要写数据给从机,1表示主机要从从机读数据。
举个例子:我设定从机地址为0x32(7位地址)。它在总线上的第一个字节实际值是0x64或0x65。0x64 = (0x32 << 1) | 0,表示主机写;0x65 = (0x32 << 1) | 1,表示主机读。很多初学者直接把地址定义为0xA0、0xA1这种8位值,结果和程序里判断的地址对不上,调试许久都不明白为什么从机不响应。
提示:我在代码里一般会把从机地址定义成8位完整形式(含R/W位),然后在匹配时先判断 (addr & 0xFE) == (SLAVE_ADDR << 1),再通过 (addr & 0x01) 区分读写。这样逻辑清晰,不易出错。
2.3 时钟延展:软件从机的救命稻草
时钟延展(Clock Stretching)是I2C从机用来“让主机等一下”的机制。具体做法是从机在需要时间处理数据时,把SCL拉低,主机检测到SCL不是高电平,就会自动等待,直到从机释放SCL。
硬件I2C外设的从机模式普遍支持时钟延展,自动处理这个问题。但软件模拟从机就麻烦了:如果GPIO模拟时,把SCL一直配置成输入模式去检测边沿,那当你想延展时钟的时候,就需要把SCL手动拉低,这时候你同时还要能检测外部的主机何时释放总线,GPIO的输入输出模式切换在中断里来回倒腾,逻辑很容易乱。
我的经验是:软件模拟从机尽量不要依赖时钟延展。除非你的主机端(比如Linux内核的I2C驱动或上位机USB转I2C工具)明确支持从机延展,否则主机超时机制可能直接报错退出。最简单的方式是让主机端的通信速率降到100kHz,并且在每个字节之间留出足够间隔。大多数应用场景是可控的,我从开始就把主机通信速率固定在100kHz,后面的代码和中断负担都会小很多。
2.4 软件模拟从机的I/O配置与中断要点
这一步决定了整个方案的成败。先说GPIO配置,我再三强调一件事:模拟I2C时,SDA必须配置成开漏输出,SCL同样如此。开漏输出的意思是:输出0时拉低电平,输出1时表现为高阻(相当于释放总线)。外部上拉电阻会把总线拉回高电平。很多人直接配置成推挽输出,然后代码里让SDA输出高,这在只有一个主机和一个从机时可能也能跑,但一旦多个设备共用总线,推挽输出会造成电平冲突,甚至烧毁引脚。
重要:STM32的GPIO开漏输出模式下,往ODR寄存器写1就是“释放”,写0才是“拉低”。这个逻辑和很多人直觉相反,写代码之前一定要先想清楚。
中断分配的思路是这样的:SCL配置成双边沿外部中断,SDA也配置成双边沿外部中断。起始条件发生时,SCL为高、SDA从高跳变到低,此时SDA中断触发;停止条件发生时,SCL为高、SDA从低跳变到高,也触发SDA中断。地址和数据bit的读取,则在SCL的上升沿或下降沿中断里完成。
在中断里处理时序,最怕的是中断嵌套和响应延迟。STM32的EXTI中断优先级要设到足够高,特别是SDA的下降沿中断(起始条件检测)不能被打断。我为了稳妥,把SDA和SCL的外部中断优先级都设为最高优先级,同时关掉不必要的中断源,避免在通信过程中被其他外设干扰。
3. 核心代码实现与实操记录
3.1 状态机的整体设计
软件模拟I2C从机,核心不是逐行写时序,而是设计一个状态机。我把它拆成4个核心状态:
- IDLE:空闲,等待起始条件
- ADDR:正在接收地址字节,判断是否匹配
- TX_DATA:主机读模式,从机往总线发数据
- RX_DATA:主机写模式,从机从总线收数据
每个状态内部,还有更细的bit计数器(0到7)和ack位处理。状态转换由外部中断触发。整体思路是:每个SCL脉冲到来时,做一次bit采样或bit输出;每收到8个bit,就进入ACK处理;ACK完成后,根据当前状态决定下一个字节是地址、数据还是停止。
这样的状态机设计,好处是每个中断处理的代码块都很短,满足“中断里尽量少做事”的原则。中断服务函数只要完成“采集一个bit”或“输出一个bit”这种轻量操作,具体业务逻辑(比如寄存器地址更新、数据存储)放到主循环或者低优先级中断里做。
3.2 关键代码:外部中断检测起始与地址接收
我用的是标准外设库风格代码,HAL库思路类似,关键点完全一致。先看初始化部分:
#define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_Pin_7 void I2C_Slave_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); // 开漏输出 + 外部中断输入,先释放总线 GPIO_InitStructure.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); // 两个引脚都拉高(等效释放) GPIO_SetBits(GPIOB, I2C_SCL_PIN | I2C_SDA_PIN); // 重新配置为外部中断 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource6); GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource7); EXTI_InitStructure.EXTI_Line = EXTI_Line6 | EXTI_Line7; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising_Falling; EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = EXTI9_5_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }中断服务函数里,起始条件和停止条件的判断是关键:
uint8_t scl_read(void) { return GPIO_ReadInputDataBit(I2C_SCL_PORT, I2C_SCL_PIN); } uint8_t sda_read(void) { return GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN); } void sda_write(uint8_t bit) { GPIO_WriteBit(I2C_SDA_PORT, I2C_SDA_PIN, bit ? Bit_SET : Bit_RESET); } void EXTI9_5_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line6) != RESET) // SCL中断 { EXTI_ClearITPendingBit(EXTI_Line6); // SCL上升沿:主机在位输出阶段,从机采样SDA if (scl_read()) { i2c_slave_sample_bit(sda_read()); } // SCL下降沿:从机可以改变SDA输出(在写模式) else { i2c_slave_prepare_next_bit(); } } if (EXTI_GetITStatus(EXTI_Line7) != RESET) // SDA中断 { EXTI_ClearITPendingBit(EXTI_Line7); // SCL为高时SDA变化,就是起始或停止 if (scl_read()) { if (sda_read()) { i2c_slave_handle_stop(); } else { i2c_slave_handle_start(); } } } }这个代码有一个细节需要注意:EXTI中断触发时,引脚状态可能还在抖动,所以我加了scl_read()的二次判断。尤其是SDA中断里,如果SCL是低电平,说明只是普通的数据变化,不是起始/停止条件,直接忽略。
3.3 从机读写EEPROM模拟场景的实现
我这次做的不只是一个“响应ACK”的空壳,而是要模拟一颗EEPROM——主机可以往我的寄存器写数据,也可以从寄存器读数据。寄存器映射就一张表,类似于常见的AT24C系列。
核心逻辑是:地址匹配后,如果R/W位是0(主机写),从机先接收第一个数据字节作为寄存器地址,再接收数据字节写入该地址;如果是1(主机读),从机把当前寄存器地址的数据发出去,地址自动加1,实现连续读。
我在状态机里增加了“寄存器地址”变量:
static uint8_t reg_addr = 0; static uint8_t reg_buf[256]; void i2c_slave_sample_bit(uint8_t bit) { // 根据当前状态和bit计数,把bit拼进接收缓存 if (state == ADDR) { addr_buf = (addr_buf << 1) | bit; bit_count++; if (bit_count == 8) { // 地址匹配判断 if ((addr_buf & 0xFE) == (SLAVE_ADDR << 1)) { is_read = addr_buf & 0x01; state = is_read ? TX_DATA : RX_DATA; sda_write(0); // ACK bit_count = 0; first_data_byte = 1; } else { state = IDLE; sda_write(1); // NACK } } } else if (state == RX_DATA) { data_buf = (data_buf << 1) | bit; bit_count++; if (bit_count == 8) { if (first_data_byte) { reg_addr = data_buf; first_data_byte = 0; sda_write(0); // ACK,继续收 } else { reg_buf[reg_addr++] = data_buf; sda_write(0); // ACK } bit_count = 0; } } }读取方向的数据输出,则是在SCL低电平期间设置SDA。注意必须在SCL变为高之前把SDA上的电平稳定好,否则主机采样时会读到不确定值。
3.4 上位机联调与验证
代码写完不是终点,必须验证。我是用一个USB转I2C工具(CH341A)接到PC上,再用Python写了个小脚本给从机发读写指令。核心代码很简单:
import ch341a dev = ch341a.CH341A() dev.i2c_init(speed=100_000) # 向0x32地址的0x10寄存器写0xAA dev.i2c_start() dev.i2c_write(0x64) # 地址+写 dev.i2c_write(0x10) # 寄存器地址 dev.i2c_write(0xAA) # 数据 dev.i2c_stop() # 从0x10寄存器读1字节 dev.i2c_start() dev.i2c_write(0x65) # 地址+读 val = dev.i2c_read(ack=False) dev.i2c_stop() print(hex(val))这里就遇到我之前说的坑:上位机工具默认支持时钟延展,但我的软件从机不支持。如果主机在发完地址后立刻发下一个字节,从机状态机可能还没反应过来。解决办法是在代码里加个“忙碌标志”延时等待,或者在Python里每次操作后加time.sleep(0.001)。实测下来,100kHz + 1ms间隔稳如老狗。
4. 常见问题与排查技巧实录
4.1 问题速查表
我把自己调试过程中遇到的高频问题整理成了表格,方便你对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 主机发送地址后一直等ACK,然后超时 | 从机地址判断错误,或从机中断没触发 | 检查7位地址是否左移1位,用示波器看SDA/SCL是否正常跳变 |
| 数据读到一半变成0xFF | SDA采样点不对,或者GPIO推挽输出冲突 | 检查SCL上升沿时SDA是否已稳定,改开漏输出 |
| 从机不应答任何请求 | 起始条件检测失败,SDA中断优先级被屏蔽 | 检查SDA的EXTI是否配置成双边沿,优先级调最高 |
| 总线一直为低,所有设备卡死 | 某个设备的SDA输出0后没释放 | 检查代码是否意外把SDA写0且没恢复高电平 |
| 通信时灵时不灵 | 主机速率太快,从机状态机处理不过来 | 主机降速到100kHz,或增加字节间延时 |
| 反复复位 | 看门狗超时,中断服务函数执行太久 | 缩短中断服务函数,业务逻辑移出中断 |
4.2 调试从机的三个实用装备
调试这类问题,光靠串口打印还不够,我实际用下来最顺手的工具是:
逻辑分析仪是排第一的。买一个几十块钱的8通道逻辑分析仪,配上软件(比如Saleae Logic或国产的Kingst),直接对I2C总线解码,能直观看到起始位、地址、ACK、数据每个环节的电平序列。我那次排查“地址不匹配”时,就是靠逻辑分析仪抓到实际总线上的地址字节是0x33,而我的代码里拿0x32比较,瞬间定位问题——原来是我把7位地址和8位地址搞混了,地址字节的含义压根不是我以为的那样。
示波器是第二选择。它更擅长看模拟波形质量,比如上拉电阻是否足够、边沿是否过缓。如果SDA上升沿太缓,可能是上拉电阻太大(比如用了100kΩ),或者总线电容过大。
第三是串口日志。在调试阶段,我习惯在关键节点(起始条件、地址匹配、ACK发送、字节接收完成)打印状态值,虽然慢,但能看清状态机内部走到了哪里。上线后把这些打印全部关掉,否则会影响时序。
4.3 几个我从实战中领悟的坑
第一个坑:开漏输出和推挽输出搞混。我前面已经反复强调,但还是要单独拎出来说。模拟I2C的SDA,如果配置成推挽输出,主机拉低时从机也拉高,两个强驱动源直接打架,轻则通信失败,重则引脚发热甚至烧毁。这个我在实验室里实测烧坏过一颗芯片的引脚,从那之后所有I2C模拟代码一律开漏。
第二个坑:中断服务函数里放了大块业务代码。原本以为在中断里把数据处理完更高效,结果因为处理时间过长,错过了下一个SCL脉冲,状态机直接错乱。后来我改成:中断里只做电平采样和bit拼接,数据校验、寄存器更新、回调通知全部放到主循环的轮询里。
第三个坑:起始条件被误判。有时候总线上存在毛刺,SDA在SCL为高时出现极短的低脉冲,误触发起始条件。解决办法是软件滤波——连续采样两次确认SDA确实为低,或者用定时器加个5微秒的延迟确认。实际使用时,简单的“中断里判断SCL状态”就能过滤掉大部分毛刺,如果环境更恶劣,再升级到定时器滤波方案。
5. 从机能力的扩展玩法
5.1 用软件从机做I2C地址扩展
做I2C地址扩展的时候,软件从机这个思路特别好用。比如总线上已经挂了多颗地址固定的传感器,它们的地址都是0x48,直接并联会冲突。这时候可以加一颗STM32当作“翻译官”——它在总线上占用一个自定义地址,代替那些同名设备响应主机的读请求,再通过另一组GPIO去访问实际的传感器。因为软件从机的地址完全由代码决定,想设几个地址都行,甚至可以用同一个STM32在两条不同的I2C总线上分别扮演不同地址的从机。
这种“软件从机+多总线桥接”的模式,我在一个项目里就用过:STM32挂在主机的I2C总线上,对外是一个寄存器设备,内部则同时管理了三颗I2C传感器和一路ADC数据。主机只觉得自己在访问一颗普通EEPROM,根本不知道背后还有这么多设备。
5.2 从机方案还能衍生出哪些功能
除了地址扩展,软件从机方案还有很多衍生玩法。比如I2C转串口:主机通过I2C读写一个缓冲区,STM32再从串口转发出去,实现老式上位机没有串口但能通过I2C访问外部模块的需求。又比如做成I2C固件升级通道:主机往指定寄存器写一个特殊命令,STM32就进入固件升级模式,后面的I2C数据流转成固件写入Flash。这个思路在一些不支持在线升级的设备上非常好用,相当于用软件定义了一个专属Bootloader传输通道。
另外,数据加密和固件安全其实也能和I2C从机挂钩。从机自定义寄存器里可以加一层密钥校验:主机写数据时必须带上正确的校验字段,否则从机直接NACK拒绝写入。这种软防护在工控场景里能挡住不少误操作和恶意访问。
6. 最后分享一点我的真实体会
软件模拟I2C从机不是一条轻松的路,尤其在中断密集型处理和状态机设计上,要比单纯调通硬件外设多花两三倍的时间。我做完这个项目之后的感受是:如果硬件外设可用,优先用硬件外设;只有当引脚、地址、灵活性被限制到非软件不可时,才走这套方案。但一旦掌握,它对I2C协议理解的深度帮助非常大——你会彻底明白每一个bit是从哪来的、要往哪去,而不是单纯配置寄存器。
最后再给一个务实建议:如果你第一次做,先把主机速率降到100kHz,再准备一块带I2C解码的逻辑分析仪,然后从最简单的“只回地址+ACK”开始,逐步加寄存器、加读写逻辑。每加一个功能就验证一次,比一口气写完再调试要省太多时间。别问我是怎么知道的。
本文还有配套的精品资源,点击获取