首页 / 资讯中心 / 文章详情

IO口模拟I2C从机实战:GPIO中断与状态机实现要点

IO口模拟I2C从机实战:GPIO中断与状态机实现要点 ★ FEATURED ARTICLE
简介面向嵌入式开发者这份基于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个时钟驱动SDA0继续读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_ACKSDA 中断虽然检测到了跳变但有的实现里只在空闲状态才响应 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 耗时最后才会去测具体功能。这个顺序帮我绕开了很多半夜调线的坑希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站