1. 为什么需要GPIO模拟I2C
在嵌入式开发中,I2C总线是最常用的通信协议之一。GD32F303虽然内置了硬件I2C控制器,但在实际项目中我们经常会遇到硬件资源紧张的情况。比如当需要同时连接多个I2C设备时,硬件I2C接口可能不够用;或者在做跨平台移植时,不同MCU的硬件I2C实现差异较大,这时候用GPIO模拟I2C就显示出它的优势了。
我第一次遇到这个问题是在做一个智能家居控制器项目。主控用的就是GD32F303,需要同时读取温湿度传感器、控制OLED屏幕和EEPROM存储。硬件I2C只有一个,但设备有三个,这时候GPIO模拟的方案就派上用场了。实测下来,GPIO模拟的I2C在100kHz标准模式下完全够用,而且移植起来特别方便,后来换用其他型号的MCU时,这套代码几乎不用改就能直接跑起来。
2. I2C协议的核心时序
2.1 起始和停止条件
I2C协议最基础的就是起始(S)和停止(P)信号。用GPIO模拟时,这两个信号的时序一定要准确。起始信号的定义是:SCL为高电平时,SDA从高变低。停止信号正好相反:SCL为高电平时,SDA从低变高。
这里有个容易踩的坑:时序延时。我刚开始调试时就遇到过,因为延时没控制好,从设备识别不到起始信号。后来发现延时5us是个比较稳妥的值,具体实现是这样的:
void a_sw_i2c_start(void) { SCL_HIGH; SDA_HIGH; delay_us(5); // 保持5us SDA_LOW; // SDA下降沿 delay_us(5); SCL_LOW; // 准备发送数据 delay_us(5); }2.2 数据有效性规则
I2C的数据传输有个重要规则:只有在SCL为低电平时,SDA才能变化;SCL为高电平时,SDA必须保持稳定。这个规则用GPIO模拟时要特别注意,代码实现上就是先设置SDA,再拉高SCL,最后再拉低SCL。
比如发送一个字节的代码:
void a_send_byte(uint8_t byte) { for(int i=0; i<8; i++) { if(byte & 0x80) SDA_HIGH; else SDA_LOW; delay_us(5); SCL_HIGH; delay_us(5); SCL_LOW; byte <<= 1; delay_us(5); } SDA_HIGH; // 释放总线 }3. 应答机制的实现
3.1 主设备发送应答
每次传输完一个字节后,接收方需要发送应答(ACK)或非应答(NACK)信号。用GPIO模拟时,主设备在发送模式下要检测从设备的应答,而在接收模式下要主动发送应答。
检测应答的代码特别容易出错。正确的做法是:主设备释放SDA线(设为输入模式),然后拉高SCL,读取SDA状态,最后再拉低SCL。我遇到过因为忘记释放SDA导致一直检测到ACK的bug,调试了好久才发现。
uint8_t a_wait_ack(void) { uint8_t ack; SDA_HIGH; // 释放SDA delay_us(5); SCL_HIGH; delay_us(5); ack = SDA_READ; // 读取SDA状态 SCL_LOW; delay_us(5); return ack; // 0表示ACK,1表示NACK }3.2 从设备地址处理
I2C的从设备地址是7位的,但实际传输时要左移一位,最低位表示读(1)/写(0)操作。这个细节新手特别容易忽略。在代码中我习惯这样定义:
#define SLAVE_ADDR (0x45) // 实际设备地址 #define WRITE_MODE (SLAVE_ADDR << 1) #define READ_MODE ((SLAVE_ADDR << 1) | 0x01)4. 完整驱动封装
4.1 单字节读写函数
把底层时序封装成面向应用的读写接口,是驱动开发的关键。单字节读写是最基础的操作,但要注意函数参数的设计。我建议把从设备地址、寄存器地址和数据都作为参数传入,这样使用起来最灵活。
uint8_t a_sw_i2c_read_byte(uint8_t slave_addr, uint8_t reg_addr) { uint8_t data = 0; a_sw_i2c_start(); a_send_byte(slave_addr << 1); // 写模式 a_wait_ack(); a_send_byte(reg_addr); // 寄存器地址 a_wait_ack(); a_sw_i2c_start(); a_send_byte((slave_addr << 1) | 0x01); // 读模式 a_wait_ack(); data = a_receive_byte(); a_nack(); a_sw_i2c_stop(); return data; }4.2 多字节读写优化
多字节读写要考虑效率问题。连续读写时,可以只发送一次起始条件和设备地址,然后连续传输多个数据字节。这种方式比单字节读写快很多,特别是在操作EEPROM这类设备时。
void a_sw_i2c_read_bytes(uint8_t slave_addr, uint8_t reg_addr, uint8_t *buf, uint8_t len) { a_sw_i2c_start(); a_send_byte(slave_addr << 1); // 写模式 a_wait_ack(); a_send_byte(reg_addr); // 起始寄存器地址 a_wait_ack(); a_sw_i2c_start(); a_send_byte((slave_addr << 1) | 0x01); // 读模式 a_wait_ack(); while(len--) { *buf++ = a_receive_byte(); if(len) a_ack(); // 最后一个字节发NACK else a_nack(); } a_sw_i2c_stop(); }5. 实际应用中的调试技巧
5.1 用逻辑分析仪抓波形
调试I2C最有效的工具就是逻辑分析仪。我用的是Saleae的逻辑分析仪,配合它们的软件可以直观地看到时序波形。常见的I2C问题比如:
- 起始信号不符合规范
- 时钟频率不稳定
- 应答位缺失 通过波形都能一目了然。
5.2 延时参数的调整
GPIO模拟I2C的关键是延时参数。太快了从设备可能跟不上,太慢了又影响通信效率。我的经验是:
- 标准模式(100kHz)下,每个半周期延时5us
- 快速模式(400kHz)下,延时1-2us 具体数值要根据从设备的手册调整,有些老器件响应比较慢。
5.3 错误处理机制
完善的驱动应该包含错误处理。我通常在以下情况加入超时判断:
- 等待ACK超时
- 起始信号发送失败
- 总线被占用 比如这样实现:
#define I2C_TIMEOUT 1000 uint8_t a_wait_ack(void) { uint32_t timeout = I2C_TIMEOUT; SDA_HIGH; delay_us(5); SCL_HIGH; while(SDA_READ && timeout--) { delay_us(1); } SCL_LOW; delay_us(5); return (timeout == 0) ? 1 : 0; }6. 性能优化实践
6.1 减少函数调用开销
GPIO模拟I2C的性能瓶颈主要在频繁的函数调用和延时。优化时可以:
- 将常用的宏改为内联函数
- 把延时函数改为直接操作SysTick
- 合并一些连续操作
比如SCL和SDA的操作可以这样优化:
__STATIC_INLINE void SCL_HIGH(void) { GPIO_BOP(GPIOB) = GPIO_PIN_6; } __STATIC_INLINE void SDA_LOW(void) { GPIO_BC(GPIOB) = GPIO_PIN_7; }6.2 中断安全实现
如果系统中有中断可能影响I2C时序,就需要关中断保护关键代码段。但要注意关中断时间不能太长,否则会影响系统实时性。
void a_send_byte_safe(uint8_t byte) { uint32_t primask = __get_PRIMASK(); __disable_irq(); a_send_byte(byte); __set_PRIMASK(primask); }6.3 DMA加速思路
虽然GPIO模拟I2C不能用DMA直接加速,但可以通过DMA来准备数据,减少CPU负担。比如要发送大量数据时,可以先用DMA把数据搬运到缓冲区,再用GPIO模拟发送。