☰
IO口模拟I2C从机实战:GPIO中断与状态机实现要点
2026/10/15 13:36:19 网站建设 项目流程

简介:面向嵌入式开发者,这份基于STM32的IO口模拟I2C从机实现方案,利用GPIO中断与状态机精确处理SCL/SDA时序,彻底避免轮询空延时,适合硬件I2C资源不足或需灵活定制从机行为的场景。压缩包仅3KB,共4个文件:两个C源文件分别承载状态机核心逻辑与GPIO端口映射,两个头文件声明结构体、枚举和接口函数,核心逻辑与硬件代码分离,可便捷移植至其他微控制器。已有4082人学习下载,内容覆盖起始/停止信号检测、从机地址匹配、数据收发与应答生成等完整流程。实现中采用模块化状态划分,对等待起始、解析地址、接收数据和发送应答等环节逐一处理,并给出基于中断的调试思路,便于直接参考学习或二次开发验证。由于核心逻辑与平台解耦,对深入理解I2C协议与状态机设计的读者同样具有参考价值。

1. IO口模拟I2C从机这件事,先想清楚值不值得做

我之前接过一个仪表项目,MCU 的一路硬件 I2C 外设已经被传感器占用,另一路因为引脚复用冲突也没法挂新设备,可上位机还要求通过 I2C 读取设备状态。重新改板不现实,最后就用两个普通 IO 口把 I2C 从机协议啃了下来。所谓 IO 口模拟 I2C 从机,就是完全不依赖芯片自带的 I2C 外设,只用 GPIO 中断和软件状态机去响应主机的时钟、数据、起始和停止条件,让本机在总线上表现得像一个标准 I2C 从设备。

这件事的难点和很多人想的不一样。模拟 I2C 主机很简单,时钟节奏自己控制,什么时候拉高、什么时候采样都是自己说了算;但模拟从机是“跟着别人的节拍跳舞”,主机什么时候产生边沿、什么时候释放 SDA,都不由你控制。一个边沿没跟上,整个字节就错位,主机这边看到的就是 NACK、超时或者读到乱码。所以软件从机的成败,90% 押在中断响应和状态机设计上。

如果你手头正好遇到硬件 I2C 从机不够用、需要动态仿真寄存器表、或者想临时往一条既有总线上挂一个低成本设备,那这篇文章就是给你写的。新手可以照着把最小框架跑通,熟手则能从参数和边界里判断这个方案能不能压到快速模式。先说结论:IO 口模拟 I2C 从机不是万能的,但在 400kHz 以内、中断延迟可控的场景里,它比想象中可靠得多。

2. 从时序图上先立住:软件从机到底在“抢”什么

IO 口模拟 I2C 从机,本质上是在用软件“抢时间”。每一笔传输都是由一系列边沿组成的,从机的每一次动作都必须落在正确的边沿窗口里。如果你不理解这些窗口,写出来的状态机迟早会在某个频率上翻车。

2.1 把 I2C 从机拆成三个相位:地址、数据、应答

一帧典型的 I2C 从机通信可以拆成三个阶段:地址相位、数据相位、应答相位。地址相位是第一个字节,由 7 位从机地址和 1 位读写标志组成,高位先发;数据相位则是一个或多个字节;应答相位是每个字节之后的第 9 个时钟,由接收方拉低 SDA 表示 ACK,保持高表示 NACK。

从机的核心任务,就是在这三个阶段里做好三件事:在地址相位判断“这个包是不是发给我的”,在数据相位按方向收数据或者发数据,在应答相位正确驱动 SDA 线。下面这张表把每个动作和触发边沿的关系列了出来,后面代码就是按这张表实现的。

阶段触发边沿从机动作
起始条件SCL 高电平期间 SDA 由高变低重置状态机,准备接收地址
地址位每个 SCL 上升沿采样 SDA,移入地址缓冲
地址应答第 8 个 SCL 下降沿之后若地址匹配则拉低 SDA
主机写数据每个 SCL 上升沿采样 SDA,拼装接收字节
主机读数据每个 SCL 下降沿之后更新 SDA,输出下一位
停止条件SCL 高电平期间 SDA 由低变高状态机回到空闲

这里有一个最关键的区别:接收数据时你要在上升沿“看”SDA,因为主机只会在 SCL 高电平期间保证数据稳定;发送数据时你要在下降沿“写”SDA,因为主机只会在 SCL 低电平期间改变数据线,你要趁这个时间窗口把下一位准备好。很多第一次写从机的人把这两个方向搞反,结果就是主机读回来的数据整体错乱。

2.2 从机的两种数据方向:主机写和主机读的边沿分工

主机写从机,也就是从机作为接收方,数据线上的每个 bit 都是由主机驱动的。从机唯一要做的就是在 SCL 上升沿把 SDA 的电平采样进移位寄存器,等凑满 8 个 bit,在第 9 个时钟通过拉低 SDA 给主机一个 ACK。这个过程相对简单,因为从机不需要主动去动 SDA 线,只要保证采样点准确。

主机读从机,也就是从机作为发送方,情况就反过来了。从机必须在 SCL 低电平期间把下一位数据放到 SDA 上,然后等 SCL 变高,让主机去采样。这里有一个很容易被忽略的点:从机在地址应答结束后的第一个下降沿,就要把第一个数据位放到 SDA 上,而不是等主机发出数据时钟之后再准备。如果准备晚了,主机在上升沿采到的可能是旧的 ACK 电平,数据就整体错位。

由这个分工可以看出,软件从机的中断负载集中在 SCL 的上升沿和下降沿两个方向。你需要一个能够同时捕获上升沿和下降沿的引脚中断,并且中断服务函数要足够短。我见过有人在中断里做寄存器数组越界检查、甚至打印日志,这种写法一旦 SCL 频率超过 100kHz 基本就会丢边沿。

3. 用 GPIO 中断搭一个最小可跑的从机框架

这一章直接给代码。我会用抽象 GPIO 接口来写,方便你迁移到具体平台。代码的核心思路是:SDA 引脚中断负责检测起始和停止条件,SCL 引脚中断负责在上升沿采样、在下降沿输出。两个中断配合一套状态机,就能在裸机上跑出最基本的单字节读写。

3.1 引脚配置:SDA/SCL 必须开成什么模式

I2C 总线是开漏结构,所以两个引脚都必须配置为开漏或输入模式,并在外部接上拉电阻。初始化代码如下:

/* 模拟I2C从机引脚初始化框架 */ void i2c_slave_gpio_init(void) { gpio_config_t cfg; /* SDA 方向需要切换,初始化为开漏输出并释放总线 */ cfg.pin = PIN_SDA; cfg.mode = GPIO_OUTPUT_OPEN_DRAIN; cfg.level = 1; /* 开漏输出1相当于释放,靠上拉拉高 */ gpio_init(PIN_SDA, &cfg); /* SCL 始终作为输入,只用来检测边沿 */ cfg.pin = PIN_SCL; cfg.mode = GPIO_INPUT_PULLUP; cfg.it = GPIO_IT_FALLING_RISING; /* 上升沿和下降沿都要触发 */ gpio_init(PIN_SCL, &cfg); /* SDA 也要配边沿中断,用来捕捉 START/STOP */ cfg.pin = PIN_SDA; cfg.mode = GPIO_INPUT_PULLUP; cfg.it = GPIO_IT_FALLING_RISING; gpio_init(PIN_SDA, &cfg); }

这段代码里有三个关键点。第一,SDA 必须能随时在“输入采样”和“开漏输出”之间切换,所以方向控制要留出独立的接口。第二,SCL 不需要输出能力,除非你想用时钟拉伸主动拉低 SCL,那是后面章节的进阶玩法。第三,内部上拉在 400kHz 以下通常凑合能用,但如果你发现总线边沿明显变缓,一定要外接 2.2k 到 4.7k 的上拉电阻,否则高电平建立时间不够,中断采到的电平会是错的。

3.2 状态机函数:在 SCL 边沿上收发一个字节

下面这段代码是从机的中枢。为了减少平台相关性,我把 SDA 的方向控制和电平写入做成了函数,你在移植时只需要把这几个函数映射到实际 GPIO 操作上。

typedef enum { I2C_S_IDLE, I2C_S_ADDR, I2C_S_ADDR_ACK, I2C_S_RX, I2C_S_RX_ACK, I2C_S_TX, I2C_S_TX_ACK } i2c_slave_state_t; typedef struct { i2c_slave_state_t state; uint8_t addr_buf; uint8_t bit_cnt; uint8_t rw; /* 0=主机写,1=主机读 */ uint8_t ack; uint8_t ack_phase; /* 应答时钟内的第几次下降沿 */ uint8_t rx_buf; uint8_t tx_buf; uint8_t tx_bits_sent; uint8_t master_ack; uint8_t reg_idx; volatile uint8_t regs[256]; } i2c_slave_t; static i2c_slave_t slave; /* SDA 引脚中断:SCL高电平期间SDA跳变 = START/STOP */ void i2c_slave_on_sda_edge(void) { if (scl_read() == 1) { if (sda_read() == 0) { /* START:无论当前在哪个状态,强制回到地址接收 */ slave.state = I2C_S_ADDR; slave.bit_cnt = 0; slave.ack_phase = 0; } else { /* STOP:回到空闲,等待下一次START */ slave.state = I2C_S_IDLE; } } } /* SCL 上升沿:采样数据或地址位 */ void i2c_slave_on_scl_rising(void) { uint8_t sda = sda_read(); switch (slave.state) { case I2C_S_ADDR: slave.addr_buf = (slave.addr_buf << 1) | sda; if (++slave.bit_cnt == 8) { slave.bit_cnt = 0; slave.rw = slave.addr_buf & 0x01; slave.ack = ((slave.addr_buf >> 1) == I2C_SLAVE_ADDR); slave.state = I2C_S_ADDR_ACK; } break; case I2C_S_RX: slave.rx_buf = (slave.rx_buf << 1) | sda; if (++slave.bit_cnt == 8) { slave.bit_cnt = 0; slave.state = I2C_S_RX_ACK; } break; case I2C_S_TX_ACK: /* 主机在第9个时钟驱动SDA,0=继续读,1=结束 */ slave.master_ack = sda; break; default: break; } } /* SCL 下降沿:输出ACK或准备数据位 */ void i2c_slave_on_scl_falling(void) { switch (slave.state) { case I2C_S_ADDR_ACK: if (slave.ack_phase == 0) { /* 第9个时钟前的下降沿:拉低SDA表示ACK */ if (slave.ack) { sda_output_enable(1); sda_write(0); } else { sda_output_enable(0); } slave.ack_phase = 1; } else { /* ACK时钟结束:读方向准备bit7,写方向进入接收 */ slave.ack_phase = 0; if (!slave.ack) { slave.state = I2C_S_IDLE; } else if (slave.rw == 1) { slave.tx_buf = slave.regs[slave.reg_idx]; slave.tx_bits_sent = 0; sda_output_enable(1); sda_write((slave.tx_buf >> 7) & 1); slave.state = I2C_S_TX; } else { slave.state = I2C_S_RX; } } break; case I2C_S_RX_ACK: if (slave.ack_phase == 0) { /* 第8个数据位结束后的下降沿:拉低SDA给ACK */ sda_output_enable(1); sda_write(0); slave.ack_phase = 1; } else { /* ACK时钟结束:收下字节,释放SDA,继续下一字节 */ slave.ack_phase = 0; slave.regs[slave.reg_idx] = slave.rx_buf; slave.reg_idx++; sda_output_enable(0); slave.state = I2C_S_RX; } break; case I2C_S_TX: if (slave.tx_bits_sent < 7) { /* 移位输出下一位 */ slave.tx_buf <<= 1; sda_write((slave.tx_buf >> 7) & 1); slave.tx_bits_sent++; } else { /* 8位数据已发完,释放SDA等待主机ACK/NACK */ sda_output_enable(0); slave.state = I2C_S_TX_ACK; slave.tx_bits_sent = 0; } break; case I2C_S_TX_ACK: if (slave.master_ack == 0) { /* 主机还要继续读:自动指向下一个寄存器 */ slave.reg_idx++; slave.tx_buf = slave.regs[slave.reg_idx]; slave.tx_bits_sent = 0; sda_output_enable(1); sda_write((slave.tx_buf >> 7) & 1); slave.state = I2C_S_TX; } else { slave.state = I2C_S_IDLE; } break; default: break; } }

这套状态机的逻辑说明分三块。第一,地址相位里,addr_buf存的是完整的第一字节,所以最低位是读写标志,真正做地址匹配时要右移一位,也就是addr_buf >> 1和I2C_SLAVE_ADDR比较,这一步写错就从机永远不应答。第二,ACK 的拉低动作发生在第 9 个时钟之前的下降沿,而不是等上升沿再去拉,因为主机在第 9 个时钟的高电平期间就要采样 SDA,你拉晚了会被主机当成 NACK。第三,读方向的数据准备发生在 ACK 时钟结束后的下降沿,然后每个下降沿移位一次,这样主机在每个上升沿采到的都是稳定的数据位。

这套代码里几个参数的调整逻辑如下。I2C_SLAVE_ADDR是 7 位从机地址,你要和主机的配置保持一致。regs[256]是简化后的寄存器堆,实际项目里可以替换成更小的数组或者函数映射。reg_idx是寄存器指针,每次写完或者读完一字节都会自增,这个行为后面还会单独讲坑。

4. 性能边界与三个必调参数

代码能跑通只是第一步,能不能稳定挂在既有总线上,取决于你对性能边界的判断和几个关键参数的调校。这一章不讨论玄学,只讲能算清楚的东西。

4.1 从机能扛多快的 SCL:用中断耗时倒推

软件从机的极限速度,直接受中断服务函数耗时影响。I2C 的 SCL 每个周期包含高电平和低电平两段,而你的中断必须在一个半周期内完成响应。以下面的表格作为参考,估算时要把中断进出开销、GPIO 读取耗时全部算进去。

主机 SCL 频率位周期半个位周期预算软件从机可行性
100kHz10us5us很稳,中断里做完整状态机无压力
400kHz2.5us1.25us临界,ISR 需控制在 1us 左右
1MHz1us0.5us不建议,绝大多数 MCU 中断响应都不够

我一般会在目标平台实测一下中断耗时,方法很简单:在 SCL 中断入口拉高一个测试引脚,出口拉低,用逻辑分析仪量高电平宽度。如果这个宽度超过了 SCL 半周期的 1/3,就不要硬上更高频率。还有一个容易被忽略的因素:SDA 中断和 SCL 中断可能同时触发,如果两个中断没有优先级区分,抖动会叠加,实际可用频率还会再打折扣。

4.2 三个必调参数:地址位序、ACK 时机、时钟拉伸

第一个参数是地址匹配方式。很多新手把主机地址 0x50 直接拿来和addr_buf比较,但 I2C 第一字节是 7 位地址加 1 位读写标志,所以主机发 0x50 时总线上实际出现的是 0xA0(读)或 0xA1(写)。从机匹配时必须用addr_buf >> 1,否则永远匹配不上。这个参数调不对,后面所有工作都是白费。

第二个参数是 ACK 的建立时机。我在代码里强调在第 9 个时钟之前的下降沿就要把 SDA 拉低,实际调的时候还要看你的中断延迟。如果平台中断响应偏慢,可以把 ACK 动作提前到第 8 个下降沿触发,不要等ack_phase的第一次下降沿再去操作。这个提前量需要根据 ISR 耗时微调,一般提前一个边沿窗口问题不大。

第三个参数是时钟拉伸。时钟拉伸是从机在需要更多时间处理时,主动把 SCL 拉低并保持,迫使主机等待。这在 IO 模拟从机里是很好的后悔药,因为你可以把本应在中断里做的事情挪到 SCL 低电平期间慢慢做。但要注意,时钟拉伸需要把 SCL 引脚临时切换成开漏输出并写 0,而且很多主机对拉伸时间有限制,一般建议不超过 1ms。能用中断解决就不要轻易开拉伸,否则会引入新的时序依赖。

4.3 寄存器指针自增与多字节读写

如果你模拟的是一个带寄存器地址的设备,主机通常会先写一个寄存器偏移,再连续读写多个字节。简化代码里每完成一字节就把reg_idx自增,这在连续读场景里很好用,但有一个边界:主机在最后一字节读了之后会给 NACK,然后发 STOP,此时reg_idx已经指向下一个不存在的寄存器。如果你在这个自增逻辑里做了越界检查,就会把 NACK 当成异常去处理。

我习惯的做法是给reg_idx加一个上限检查,超过寄存器末尾就回卷到 0,或者返回一个固定值。具体回卷还是固定值,取决于你要模拟的设备行为。如果设备手册规定寄存器是循环的,就回卷;如果不规定,就回 0。这个细节看起来很小,但主机端很多驱动在读设备信息时会对返回值的边界特别敏感,处理不好就会出现读一帧多出来几个字节的怪问题。

5. 模拟 I2C 从机的 5 个典型故障与排查顺序

代码写得再小心,第一次上板联调总会遇到问题。这里把最常见、最容易让人排查半天的故障按现象、原因、解决三个步骤列出来。

5.1 主机一直收不到 ACK:地址匹配与应答滞后

现象是主机发送地址后一直收到 NACK,逻辑分析仪上能看到 SDA 在第 9 个时钟是高的。

原因通常有两个:一是地址匹配表达式写成了addr_buf == I2C_SLAVE_ADDR,忘了右移一位,导致匹配永远失败;二是 ACK 动作写在了第 9 个上升沿之后,SDA 拉低的时间太晚,主机已经完成了采样。

解决方法是先查匹配表达式,确认第一字节最低位的读写标志被排除在外;再查 ACK 动作是在哪个边沿触发的,按前面的代码结构把拉低动作提前到第 8 个下降沿之后立刻执行。用逻辑分析仪抓 SDA 在第 9 个时钟的波形,如果能看见一个明显的低电平脉冲,说明位置对了。

5.2 主机读回的数据位序错乱:数据更新点不对

现象是主机读数据时,读回来的值和寄存器里存的对不上,而且每位都像整体左移或右移了一位。

原因是数据位放到 SDA 上的时间点不对。常见错误是在第 9 个上升沿之后才准备第一个数据位,这时候 SCL 已经变高,主机马上要采样,SDA 上还是 ACK 电平,于是第一个位被采样成 ACK 的值,后续全部错位。

解决方法是严格按“ACK 时钟结束后的下降沿准备 bit7,之后每个下降沿移位一次”的节奏来。你在逻辑分析仪上把 SCL 和 SDA 对齐看,应该看到 SDA 在每个 SCL 低电平中间发生变化,到高电平时保持稳定,这才符合 I2C 数据线规则。

5.3 SCL 引脚悬空导致的“幽灵时钟”

现象是从机在没有主机通信的时候,状态机会自己跳进地址接收,偶尔还会发 ACK,总线看起来像是被一只看不见的手在操作。

原因是 SCL 引脚没有可靠上拉,悬空后电平漂移,GPIO 中断检测到了虚假边沿。内部上拉在某些板子上阻抗偏高,加上引脚走线长,很容易被干扰。

解决方法是给 SCL 和 SDA 都加上外部上拉电阻,取值 2.2k 到 4.7k,靠近从机引脚放置。如果现场干扰特别强,还可以在代码里加一个总线空闲超时,详见下一章。

5.4 重复 START 后状态机卡死

现象是主机在连续读数据前发了一个重复 START,然后再次发送地址,从机完全没反应,总线一直等到超时。

原因是重复 START 发生时,从机状态机还停在I2C_S_TX_ACK或I2C_S_RX_ACK,SDA 中断虽然检测到了跳变,但有的实现里只在空闲状态才响应 START,于是把重复 START 当成普通高电平忽略了。

解决方法是把 START 处理写成无条件重置:只要检测到 SCL 为高且 SDA 发生下降沿,不管当前状态是什么,一律回到地址接收状态。前面的代码已经这样写了,实际移植时不要图省事把它包在if (state == I2C_S_IDLE)里面。

5.5 加入实时系统后随机 NACK:临界区保护

现象是裸机下跑得好好的,把代码放进 RTOS 任务里管理之后,出现随机 NACK,频率不高但不好复现。

原因是主线程可能在中断触发的同时修改了slave.regs或slave.reg_idx,导致中断里的状态机读到了半新旧混合的数据。软件从机的状态机运行在中断上下文,而寄存器数据可能会被业务线程更新,两边没有互斥。

解决方法是把对从机寄存器数据的读写放进临界区。简单方式是进入临界区时关中断,临界区结束恢复,比如key = enter_critical();和exit_critical(key);的经典写法;再把状态机里的公共变量尽量减少,只留regs和reg_idx。如果追求高效,可以用双缓冲区分业务侧和中断侧的数据,避免长时间关中断影响实时性。

6. 进阶技巧:把状态机做稳的三个习惯

到这里你已经能跑通基本读写,但一个可靠的从机还需要额外三个习惯。先加一个总线超时看门狗:用定时器每 1ms 检查一次 SCL 电平,如果超过 50ms 没有边沿变化,就强制把状态机重置到I2C_S_IDLE,并释放 SDA。这个习惯救过我很多次,因为 I2C 从机一旦错过 STOP,状态机会一直卡在某个数据相位,总线上所有后续通信都会被打乱。有了看门狗,最坏情况也只是等 50ms 恢复,而不是永久挂死。

第二个习惯是动手量中断耗时。在 SCL 中断入口写dbg_pin_high(),出口写dbg_pin_low(),用逻辑分析仪看这段高电平的宽度。你不需要每行代码都优化,但要清楚自己的平台在 100kHz 和 400kHz 下到底还剩多少余量。我最初把状态机做得特别完整,结果 400kHz 下 ISR 超过 1.2us,主机偶尔就丢 ACK;后来把寄存器写回挪到 ACK 结束后的下降沿,余量就出来了。

第三个习惯是把软件从机和硬件从机做成可切换配置。用宏#define I2C_USE_HW_SLAVE 0控制编译路径,两边共用同一份寄存器表。软件从机适合调试和新方案验证,因为你可以随时加打印、加断点;硬件从机适合交付和高负载场景。这个切换让我在同一个项目里既能快速定位问题,又能确保最终固件走的是可靠外设路径。

这几个技巧都不复杂,但它们决定了你的从机是“能跑”还是“能长期跑”。我现在的习惯是:任何 IO 模拟 I2C 从机上板之前,先加看门狗,再量 ISR 耗时,最后才会去测具体功能。这个顺序帮我绕开了很多半夜调线的坑,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询