简介针对嵌入式开发者在缺少硬件I2C从设备时的调试与验证需求这份资源以GPIO模拟I2C从机为核心通过纯软件方式实现从设备响应适用于单片机、Arduino、树莓派等平台属于中高级嵌入式调试技巧。压缩包共14个文件包含i2c_slave.c/h、i2c_slave_port.c/h等源码文件以及工程配置文件可直观看到协议解析、时序控制、数据处理等模块的实现结构整体体积仅11KB轻量易用。已有1883人学习下载。资源重点覆盖起始/停止位、7位地址应答、读写时序以及错误检测等关键逻辑并给出可移植的端口适配层用户可基于现有工程快速集成也可结合逻辑分析仪验证通信波形是理解I2C协议与调试主设备的实用参考。 很多做嵌入式的朋友应该都有过这种经历主控芯片的I2C外设就那么几个要用的时候才发现不够用或者手头一颗老芯片压根就没有I2C外设。我前阵子遇到的情况是一颗小MCU需要和一个传感器通信同时还要挂在一个系统总线上充当一个从机设备让别人来读它的状态。芯片本身没有多余的硬件I2C总不能再加一颗芯片成本不划算。于是我就干脆用普通GPIO去模拟一个I2C从机出来结果还真就稳定跑起来了。这里说的I2C模拟从机和网上一抓一大把的GPIO模拟I2C主机完全是两码事。模拟主机是主动产生时钟、主动发起通信节奏自己把握而模拟从机是等着别人来读你、来写你你得即时响应外部时钟。本文就围绕这个方向把从零开始用GPIO模拟I2C从机的全过程、底层时序、状态机设计以及调试中踩过的坑都整理出来给准备动手折腾的朋友一份能直接参考的实操记录。1. 为什么非要折腾软件I2C从机三个真实场景先说需求来源很多情况下你压根用不到模拟从机因为硬件I2C外设实在太方便了。但遇到下面这些场景你就得考虑软件方案了。1.1 硬件外设不够用的尴尬我见过不少国产MCU价格确实便宜但外设资源也着实紧张。一颗芯片上可能只有一个I2C控制器接了OLED屏之后就没法再接外部传感器了。这时候如果只是让这个传感器挂在总线上并作为从机响应外部主机的读写那你必须得想办法再变一个I2C从机出来。最直接的方案就是软件模拟。1.2 需要实时响应外部主机的读写请求另一种场景是你的板卡上有一颗MCU它在系统里是作为执行机构存在的。系统的I2C主机可能是另一颗主控或者嵌入式Linux开发板会周期性地来读取MCU的运行状态、温度、设备版本号等也会写一些参数进来。如果这颗MCU没有硬件I2C从机能力那就只能靠GPIO模拟。这种场景下软件从机跟软件主机的最大区别在于——你无法预知主机什么时候发起通信也无法控制主机什么时候释放总线。你只能被动地监听等SCL和SDA的变化。这就对你的响应速度提出了要求你必须确保在SCL的每个周期内都能正确采集到SDA的状态。1.3 做一个调试后门第三种场景是我个人比较喜欢用的用软件I2C从机做一个调试后门。主控在正常运行自己的业务逻辑但同时可以作为一个从机挂在调试总线上通过外部主机实时读取内部变量、修改参数、触发测试流程。这种方案比直接用UART调试方便得多——UART需要占用两个引脚而I2C只需要两根线而且IO口比较节省。更重要的是I2C天然支持多从机你可以把好几块板卡挂在同一条总线上分别读取每一块的状态。2. I2C从机时序细节看得懂波形才能写得对代码想做好模拟从机光会写代码是不够的。你得先把I2C协议的时序细节吃透才能真正写出一个能长时间稳定运行不被总线冲突搞死的状态机。2.1 起始条件、停止条件与地址匹配I2C总线的空闲状态是SCL和SDA同时为高。起始条件START定义为SCL为高电平时SDA产生一个从高到低的跳变。停止条件STOP定义为SCL为高电平时SDA产生一个从低到高的跳变。从机要时刻监听这两个条件。因为你是在用GPIO模拟所以通常会将SCL和SDA配置为外部中断输入在中断里判断是起始、停止还是数据变化。起始条件之后主机发送的第一个字节的高7位就是从机地址最低位是读写标志位0表示写1表示读。从机的地址匹配就发生在接收到第一个字节时。我在第一次写这个逻辑时犯过一个很常见但很低级的错误把地址直接认为是0x50然后代码里比较的是收到的字节是否等于0x50。实际协议里主机发送的地址字节是(addr 1) | RW也就是说如果设备地址是0x50那么主机发送的第一个字节是0xA0写或者0xA1读。所以模拟从机里的地址匹配一定要做移位判断否则永远匹配不上。这个细节说多了都是泪调了半天就是没反应拿逻辑分析仪一看才知道问题在这。2.2 数据位采样必须踩准SCL高电平这个窗口I2C协议规定数据位必须在SCL为低电平时变化在SCL为高电平时保持稳定。从机采样数据的有效时刻是SCL上升沿或者SCL高电平期间的某个稳定点。用GPIO模拟从机时不管你用哪种方式实现核心都绕不开这一步当SCL上升沿到来时读取SDA引脚的电平把这一个bit收下来当SCL低电平时需要发送数据的主机就要把SDA的电平准备好从机则在SCL低电平期间去改变SDA的电平在需要发送数据的时候。这里有个关键点需要理解你是从机你需要根据主机产生的SCL来协调动作。所以比较适合的方式是用SCL的边沿来触发中断或者状态更新。我在代码里最常用的方案就是把SCL引脚配置为双边沿外部中断。SCL上升沿时采SDA接收bitSCL下降沿时判断当前状态机需要做什么比如准备下一个发送bit或者判断是否需要拉低SDA来发ACK。这样的方案有一个天然的好处——你不需要开一个高频定时器去疯狂扫描SCL和SDA的电平省电且实时性高。2.3 ACK与NACK的职责划分每次接收完一个字节8个bit后接收方需要在第9个时钟周期将SDA拉低作为应答。如果是从机接收那么在第9个SCL周期从机负责拉低SDA来回应ACK如果主机接收那么主机在第9个周期拉低SDA从机需要去采样这个ACK位以判断主机是希望继续还是希望停止。从机发送数据时第9个时钟周期主机可能会发ACK来要求继续发下一字节也可能会发NACK来表示够了停止。从机在发送完一个字节后需要在第9个时钟周期释放SDA然后采样一次SDA判断主机给的是ACK还是NACK。如果是NACKSDA为高说明本次读操作到此结束从机状态恢复到空闲等待下一个起始条件。这些时序逻辑看着不难但真正的难点在于在同一个SCL中断回调里你既要区分当前是第几个bit又要处理ACK阶段、地址阶段和数据阶段状态机一旦写不好就会乱掉。这里需要仔细设计。3. 状态机实现用SCL边沿中断驱动从机收发接下来是核心实现部分。我以STM32平台为例用标准外设库或HAL库都可以核心思路是一致的。3.1 引脚的初始化和中断配置首先选定两个GPIO引脚作为SCL和SDA都配置为开漏输出Open-Drain并启用外部中断。因为是开漏模式所以需要外部接上拉电阻一般用4.7kΩ或10kΩ即可。开漏的意义在于你可以通过将寄存器输出置0来主动拉低电平而释放总线时只需要把引脚输出置1但由于开漏实际引脚电平由上拉电阻决定。配置代码大致如下以HAL库为例GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; // 假设SCLPB6, SDAPB7 GPIO_InitStruct.Mode GPIO_MODE_IT_FALLING; // 先配下降沿后续修改 GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); HAL_NVIC_SetPriority(EXTI9_5_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn);注意一个细节把SCL和SDA都配置成外部中断模式但实际只有在SCL上我们才需要边沿中断SDA主要用来检测起始和停止条件以及采样数据。所以在中断服务函数里要先判断是哪个引脚触发的。3.2 状态机核心数据结构为了让状态机代码清晰我通常定义以下枚举和全局变量typedef enum { I2C_SLAVE_STATE_IDLE, // 空闲 I2C_SLAVE_STATE_ADDR, // 接收地址字节 I2C_SLAVE_STATE_ADDR_ACK, // 地址应答 I2C_SLAVE_STATE_TX_DATA, // 发送数据 I2C_SLAVE_STATE_TX_DATA_ACK,// 发送后等待主机应答 I2C_SLAVE_STATE_RX_DATA, // 接收数据 I2C_SLAVE_STATE_RX_DATA_ACK // 接收后发送应答 } i2c_slave_state_t; volatile i2c_slave_state_t i2c_state; volatile uint8_t rx_buffer[256]; volatile uint8_t tx_buffer[256]; volatile uint8_t rx_index, tx_index; volatile uint8_t bit_count; volatile uint8_t byte_tmp;当检测到起始条件时将状态切换到IDLE开始准备接收地址字节。地址字节接收完成后判断地址是否匹配匹配则进入ACK阶段根据需要切换到收发数据状态不匹配则直接忽略后续时钟直到下次起始条件。3.3 统一的中断处理逻辑这里给出核心的EXTI中断处理思路不粘贴完整工程代码但把逻辑写清楚void EXTI9_5_IRQHandler(void) { if (SCL_PIN_EXTI) { // 判断是上升沿还是下降沿 if (READ_SCL() 1) { // SCL上升沿采样SDA if (i2c_state ! I2C_SLAVE_STATE_IDLE) { byte_tmp 1; byte_tmp | READ_SDA(); bit_count; if (bit_count 8) { // 一个字节接收完毕进入处理 bit_count 0; handle_byte_received(); } } } else { // SCL下降沿在发送模式下更新SDA输出的下一个bit if (i2c_state I2C_SLAVE_STATE_TX_DATA) { // 在第8个bit之后SCL为低时释放或准备ACK采样 if (bit_count 8) { if (tx_buffer[tx_index] (0x80 bit_count)) { SDA_HIGH(); } else { SDA_LOW(); } } } } CLEAR_SCL_EXTI_FLAG(); } if (SDA_PIN_EXTI) { // SDA变化用于检测起始和停止 if (READ_SCL() 1 READ_SDA() 0) { // 起始条件 i2c_state I2C_SLAVE_STATE_IDLE; bit_count 0; rx_index tx_index 0; byte_tmp 0; } else if (READ_SCL() 1 READ_SDA() 1) { // 停止条件 i2c_state I2C_SLAVE_STATE_IDLE; } CLEAR_SDA_EXTI_FLAG(); } }这里有一个设计要点SCL下降沿更新发送数据的时候必须在下降沿发生后马上把SDA设置好因为主机在下一个上升沿就要采样了。如果处理得太慢主机采到的就不是你要发的bit。这就是为什么我建议把SCL和SDA都做成中断驱动而不是在主循环里轮询轮询无法保证时序实时性SCL高速运转时会丢数据。3.4 地址匹配与读写切换处理完地址字节后根据最低位决定是进入发送模式还是接收模式void handle_addr_byte(uint8_t addr_byte) { uint8_t addr addr_byte 1; uint8_t rw addr_byte 0x01; if (addr MY_I2C_ADDR) { if (rw 0) { i2c_state I2C_SLAVE_STATE_RX_DATA; rx_index 0; } else { // 准备需要发送的数据 tx_index 0; tx_buffer[0] status_reg; tx_buffer[1] temp_celsius; i2c_state I2C_SLAVE_STATE_TX_DATA; // 在进入发送状态后等待第一个SCL下降沿来设置第一个bit } } else { // 地址不匹配回到空闲状态忽略后续时钟 i2c_state I2C_SLAVE_STATE_IDLE; } }需要注意的是在第8个bit上升沿结束后当前正处于SCL高电平期间。之后的第9个时钟周期主机在SCL高电平期间采ACK/NACK。这个流程必须严密衔接。4. 调试实录逻辑分析仪视角下的几个典型异常软件I2C从机写完之后调试是一个大工程。你可能看着代码逻辑觉得天衣无缝但实际上板子跑起来就是不工作。下面这几个现象是我在调试中真实遇到过的。4.1 现象一主机一直在等待ACK超时结束第一次调试时我把程序烧进去用树莓派模拟I2C主机去读从机的数据结果树莓派报错Remote I/O error。打开逻辑分析仪看波形发现从机在接收到地址字节后在第9个周期根本没有把SDA拉低。这个问题有几种可能一是我的ACK逻辑是在第9个SCL上升沿时判断是否要拉低SDA但由于中断处理代码还没有执行到导致SDA应拉低的时刻没拉住。二是主机发送的地址字节是从机地址左移了一位我在代码里没有做移位处理导致地址匹配失败状态机处于IDLE自然不会拉低SDA。最终确认是第二种情况。排查方法很简单在逻辑分析仪上观察第一个字节的二进制值发现主机发的是0xA0而我代码里比较的地址是0x50显然是少了右移一位的操作。修正后ACK就正常了。4.2 现象二发送数据时主机收到的全部是0xFF通过逻辑分析仪看到地址阶段和ACK阶段都正确但从机发送数据时SDA一直保持高电平。这个问题我排查了很久最后发现是GPIO输出模式的锅。我此前把SDA初始化成了推挽输出为了在接收模式下尽快驱动总线电平。但在发送模式下只有当输出寄存器为0时才会拉低而推挽输出的高电平是强驱动直接拉着总线不放主机当然采到的就是高电平了。把SDA改成开漏输出之后问题立刻消失。开漏模式下将输出寄存器置1时引脚是高阻态总线由上拉电阻拉高这样主机才能正常采样。4.3 现象三低速一切正常高速通信时数据错位当我将主机的I2C速率从100kHz调到400kHz后一切又开始乱套。从机返回的数据经常是错位的有时候是少了一个字节有时候是字节内bit错乱。复盘之后我认为问题出在中断响应延迟上——虽然SCL下降沿中断触发了但是MCU如果正在处理其他高优先级中断或正在执行某些耗时操作就没有及时把SDA更新到位。主机400kHz时SCL低电平时间只有1.25微秒这个时间内你必须完成SDA的切换否则等SCL上升沿来了SDA还没稳定。解决思路有两条一是将模拟从机相关的中断优先级提到最高确保SCL中断能抢占其他任务二是降低I2C总线速率在从机软件模拟的场景下将总线设定为100kHz标准模式。如果你非要跑400kHz那就只能在代码执行效率上下狠功夫用寄存器操作代替HAL函数减少函数调用层级。5. 从能跑到跑得稳容易被忽略的细节与进阶优化功能正常之后的优化是更有意思的阶段。这里把我在实际项目中反复验证过的几个细节展开说说这些也是从能跑到跑得稳的关键。5.1 时钟拉伸从机唯一的喘息机会I2C协议里有一个容易被模拟从机忽略的功能——时钟拉伸Clock Stretching。它允许从机在接收或发送数据的过程中如果来不及准备就把SCL拉低从而强制主机暂停。主机检测到SCL被拉低后会一直等待直到从机释放SCL才继续产生时钟。这对软件模拟从机来说几乎是救命稻草。如果你代码跑得不够快或者需要DMA搬运数据可以在某个字节接收完毕后拉低SCL然后慢慢处理数据处理完了再释放SCL。这样即便MCU主频不高也能在低速主机下稳定工作。实现时你只需要注意在SCL拉低释放期间必须保证SCL引脚是开漏输出否则无法拉低总线的时钟线。5.2 多字节收发与缓冲区管理实际项目里不可能只传输一两个字节。比如你可能需要让主机一次性读取20字节的设备状态数据。这时需要将待发送数据放入一个发送缓冲然后由状态机按bit逐个填入SDA。接收同样需要接收缓冲区并在接收完一帧后触发一个软件标志让主循环去处理。缓冲区设计要注意避免下标越界和覆盖。我常用的方案是定义固定大小数组并增加一个溢出保护当接收索引超过缓冲区大小时强制进入溢出标志状态同时状态机继续接收但不写入缓冲区这就避免了内存越界导致的系统崩溃。5.3 不依赖硬件I2C的中断优先级配置技巧中断优先级的配置直接决定模拟从机的稳定性。我将SCL和SDA的外部中断放在同一个中断优先级组里并把它设置为当前MCU允许的最高优先级。这样即使系统其他业务也使用中断比如定时器、UART也不会在I2C通信过程中打断从机状态机。唯一的例外是非可屏蔽中断但普通应用中极少用到。如果你使用FreeRTOS等实时操作系统还需要注意中断与任务之间的数据同步问题。比如需要在中断里设置标志位然后由任务去处理接收缓冲区数据。这种情况下建议将缓冲区设为volatile并使用critical section保护关键区。5.4 软件模拟从机的适用边界最后说一点实话。软件模拟从机虽然灵活但并不适合所有场景。如果你需要支持1MHz以上的高速模式或者需要同时处理大量I2C中断比如从机同时还要承担PWM波输出那我建议还是老老实实选一颗带硬件I2C从机外设的MCU。软件模拟从机最适合的应用场景是100kHz标准模式下的简单状态查询、参数配置和数据采集。还有个建议在系统设计初期如果预留了硬件I2C引脚最好优先使用硬件外设。软件模拟从机是应急方案是在硬件资源限制下的有效补充而不是替代方案。6. 写在最后的实操心得回看这个软件模拟I2C从机的实现过程我最大的体会是写代码之前一定要先把协议看仔细。I2C协议看起来很简单但真正用GPIO去模拟从机时细节决定成败——地址字节的移位处理、开漏输出的配置、中断响应的实时性、ACK/NACK的状态流转任何一处出了问题都会让主机端产生莫名其妙的错误。如果你也准备动手做强烈建议准备一个逻辑分析仪。我用的是一个几十块钱的USB逻辑分析仪在调试过程中帮了大忙。没有它你可能抓破头也想不到问题出在ACK没拉低还是地址没匹配上。有了波形图一切问题都变得直白。项目的测试方法也很重要。我建议先用树莓派或另一块开发板做主机测试通过i2c-tools工具直接访问模拟从机逐步验证地址匹配、字节读取和写入功能。相比直接用主控芯片调试这种方式可以快速分离问题归属缩短排错时间。本文还有配套的精品资源点击获取