从零实现模拟I2C:一位嵌入式工程师的实战手记
你有没有遇到过这样的场景?
项目进入关键阶段,突然发现MCU上唯一的硬件I2C接口已经被OLED屏幕占用,而你现在还要接一个温湿度传感器——偏偏它的地址还和另一个设备冲突。怎么办?
这时候,模拟I2C就成了你的“救命稻草”。
它不像硬件I2C那样依赖特定外设模块,而是用最朴素的方式:两个GPIO口 + 精确延时,手动复现整个通信流程。听起来像是“土法炼钢”,但在真实工程中,这招往往比高端方案更管用。
今天,我就带你一步步走完这个过程——不讲虚的,只说实战中踩过的坑、调过的波形、写过的代码。让你不仅能看懂,更能亲手实现一套稳定可靠的软件I2C驱动。
为什么我们需要“模拟”I2C?
I2C协议本身很简单:一根时钟线(SCL),一根数据线(SDA),支持多主多从、地址寻址、应答机制。许多MCU都集成了硬件I2C控制器,配置几个寄存器就能通信。
但现实总是复杂的:
- 引脚不够用?STM32F103C8T6只有I2C1可用,其他都是重映射;
- 地址撞车了?MPU6050默认地址0x68,两个没法共存一条总线;
- 电压不匹配?想让3.3V的ESP32读5V的EEPROM;
- 调试抓瞎?硬件I2C一出错就是“忙等超时”,根本不知道哪一步失败了。
这些问题,模拟I2C全都能解。
因为它的一切都在你的掌控之中:你可以把SCL和SDA接到任意GPIO上,可以为不同设备创建独立的虚拟总线,可以在每一步插入日志打印,甚至能用LED闪烁来“肉眼观察”ACK是否到来。
更重要的是,它是理解底层通信本质的最佳入口。当你亲手拉低SDA、等待SCL上升沿的时候,才会真正明白什么叫“数据在时钟上升沿被采样”。
核心三要素:电平控制、方向切换、精准延时
要让两条普通IO线变成I2C总线,必须搞定三个关键技术点。
1. GPIO操作:不只是高低电平那么简单
很多人以为,只要能设置高低电平就完事了。其实不然。
I2C的数据线SDA是双向的——主机发数据时是输出,接收ACK时又要变成输入去读从机的回应。
这就要求我们动态切换GPIO的方向。以STM32为例,典型宏定义如下:
#define SDA_OUT() { GPIOB->CRH &= ~0xF0; GPIOB->CRH |= 0x30; } // 推挽输出 #define SDA_IN() { GPIOB->CRH &= ~0xF0; GPIOB->CRH |= 0x80; } // 浮空输入 #define SET_SDA GPIOB->BSRR = GPIO_PIN_7 #define CLR_SDA GPIOB->BRR = GPIO_PIN_7 #define READ_SDA ((GPIOB->IDR & GPIO_PIN_7) != 0) #define SET_SCL GPIOB->BSRR = GPIO_PIN_6 #define CLR_SCL GPIOB->BRR = GPIO_PIN_6注意这里的细节:
- 输出模式推荐使用开漏输出配合外部上拉电阻,这样才符合I2C电气规范;
- 输入模式要用浮空输入,避免内部上下拉干扰总线;
- 操作寄存器直接到位带,效率远高于库函数调用。
如果你的平台支持,最好启用开漏模式:
// 开漏输出配置(更贴近真实I2C) #define SDA_OUT_OD() do { \ GPIOB->CRH &= ~0xF0; \ GPIOB->CRH |= 0x10; /* CNF=01, MODE=10 -> 开漏输出 */ \ } while(0)没有外部上拉?别急着通电!至少4.7kΩ的上拉电阻是必须的,否则总线永远拉不上去。
2. 延时函数:决定成败的关键
I2C的标准模式是100kHz,意味着每个时钟周期10μs,高/低电平各占约5μs。快速模式400kHz则压缩到2.5μs以内。
这些时间不是随便给的。比如:
- 数据建立时间(t_SU:DAT)需 ≥ 250ns;
- 起始信号建立时间(t_SU:STA)需 ≥ 4.7μs;
- 停止信号前SCL高电平时间(t_SU:STO)需 ≥ 4.0μs。
稍有偏差,某些“娇气”的传感器就会罢工。
所以延时不能靠for(i=0;i<100;i++);这种玄学写法。我推荐两种实用方案:
方案一:基于DWT周期计数(适用于Cortex-M3/M4)
static void i2c_delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t cycles = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < cycles); }前提是开启DWT时钟:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0;方案二:查表标定循环次数(适合资源紧张场景)
static void i2c_delay(void) { volatile int i = 72; // 经实测@72MHz ≈ 5μs while (i--) __NOP(); }记得加volatile防止编译器优化掉空循环。
四步走通核心流程:起始 → 发地址 → 收ACK → 传数据
所有的I2C通信,归根结底就是四个动作的组合。我们逐个拆解。
第一步:发起通信 —— 起始条件(Start Condition)
规则很明确:当SCL为高时,SDA由高变低。
代码实现要注意顺序:
void i2c_start(void) { SDA_OUT(); // 确保SDA可输出 SET_SDA; // 初始高电平 SET_SCL; i2c_delay_us(4); // 保持≥4.7μs CLR_SDA; // SDA下降,此时SCL仍高 i2c_delay_us(4); CLR_SCL; // 拉低SCL,准备发送数据 }关键点:
- 必须先保证SCL为高;
- SDA下降后要延时足够时间满足t_SU:STA;
- 最后拉低SCL进入数据传输阶段。
如果这里出错,示波器上看就是没有明显的“凹口”起始信号。
第二步:发送设备地址 + R/W位
所有I2C通信都以一个字节开始:7位地址左移一位,最低位表示读(1)或写(0)。
例如,AHT20地址为0x38,则写操作发送0x70,读操作发送0x71。
uint8_t i2c_send_address(uint8_t addr, uint8_t rw) { return i2c_write_byte((addr << 1) | rw); }其中i2c_write_byte按位发送:
uint8_t i2c_write_byte(uint8_t byte) { uint8_t i, ack; for (i = 0; i < 8; i++) { if (byte & 0x80) SET_SDA; else CLR_SDA; i2c_delay_us(1); SET_SCL; // 上升沿采样 i2c_delay_us(4); CLR_SCL; i2c_delay_us(1); byte <<= 1; } // 读取ACK SDA_IN(); i2c_delay_us(2); SET_SCL; i2c_delay_us(3); ack = !READ_SDA(); // 低电平为ACK CLR_SCL; SDA_OUT(); return ack; // 返回1表示收到ACK }重点来了:
- 数据必须在SCL低电平时改变;
- SCL上升沿后保持一段时间供从机采样;
- 发完8位后释放SDA,切换为输入读ACK;
-ACK是低电平有效,即从机拉低才算确认。
如果你发现始终收不到ACK,先检查:
- 地址是否正确?
- 从机是否上电?
- 上拉电阻是否焊好?
- SDA是否被意外锁死?
第三步:读取数据并回应ACK/NACK
读操作稍微复杂一点。主机要在每个字节结束后主动告诉从机:“我还想要”或“到此为止”。
uint8_t i2c_read_byte(uint8_t ack) { uint8_t i, byte = 0; SDA_IN(); for (i = 0; i < 8; i++) { i2c_delay_us(1); SET_SCL; i2c_delay_us(2); byte <<= 1; if (READ_SDA()) byte |= 0x01; CLR_SCL; i2c_delay_us(1); } // 发送ACK/NACK SDA_OUT(); if (ack) CLR_SDA; // ACK: 拉低 else SET_SDA; // NACK: 保持高 i2c_delay_us(1); SET_SCL; i2c_delay_us(3); CLR_SCL; return byte; }最后一字节通常发NACK,提醒从机停止发送。
第四步:结束通信 —— 停止条件(Stop Condition)
SCL高电平时,SDA由低变高。
void i2c_stop(void) { SDA_OUT(); CLR_SDA; SET_SCL; i2c_delay_us(4); SET_SDA; // SDA上升 i2c_delay_us(4); }至此,一次完整的通信完成。
实战案例:读取AHT20温湿度传感器
我们来做一个完整例子,验证上面写的驱动能不能跑通。
AHT20通信流程
- Start
- 发送写地址(0x70)
- 收到ACK
- 发送命令0xE1(初始化)
- 发送参数0x08, 0x00
- Stop
- Delay 10ms
- Start
- 发送读地址(0x71)
- 连续读6字节
- 主机对前5字节回ACK,第6字节回NACK
- Stop
封装成函数:
uint8_t read_aht20(float *temp, float *humi) { uint8_t data[6]; uint32_t raw_humi, raw_temp; i2c_start(); if (!i2c_send_address(0x38, 0)) goto fail; i2c_write_byte(0xE1); i2c_write_byte(0x08); i2c_write_byte(0x00); i2c_stop(); delay_ms(10); i2c_start(); if (!i2c_send_address(0x38, 1)) goto fail; data[0] = i2c_read_byte(1); data[1] = i2c_read_byte(1); data[2] = i2c_read_byte(1); data[3] = i2c_read_byte(1); data[4] = i2c_read_byte(1); data[5] = i2c_read_byte(0); // 最后一个NACK i2c_stop(); raw_humi = ((uint32_t)data[1] << 12) | ((uint32_t)data[2] << 4) | (data[3] >> 4); raw_temp = (((uint32_t)data[3] & 0x0F) << 16) | ((uint32_t)data[4] << 8) | data[5]; *humi = (float)raw_humi / 1048576.0f * 100.0f; *temp = (float)raw_temp / 1048576.0f * 200.0f - 50.0f; return 1; fail: i2c_stop(); return 0; }一次调用,返回温度和湿度值。简单粗暴,但非常可靠。
那些年我们踩过的坑:调试经验分享
别以为写了代码就能一次成功。以下是我亲身经历的问题与解决方案:
❌ 问题1:始终收不到ACK
最常见的原因:
-地址错了:有些芯片文档写的是7位地址,但实际应用要左移一位;
-SDA被卡住:GPIO没配置成输入,或者外部电路短路;
-上拉电阻缺失或过大:用万用表测一下SDA空闲时是不是高电平;
-电源未接稳:从机没工作,自然不会响应。
👉 解决方法:用逻辑分析仪抓波形,看是否有ACK脉冲。
❌ 问题2:读出来的数据全是0xFF或0x00
说明通信建立了,但数据不对。
- 可能是字节顺序搞反了,高位先行还是低位先行;
- 或者采样时机错误,应该在SCL上升沿后立即读,而不是下降沿;
- 也可能是延时太短,从机还没准备好数据。
👉 加大延时试试,特别是SCL上升沿后的等待时间。
❌ 问题3:偶尔通信失败
多半是中断打断了时序。比如SysTick中断打断了延时循环,导致某个高电平持续时间不足。
👉 解决办法:
- 在关键段禁用中断(慎用);
- 使用DWT这类不受中断影响的延时;
- 加入超时检测,避免死锁。
uint8_t i2c_wait_ack_timeout(uint32_t timeout_us) { uint32_t start = micros(); SDA_IN(); while (READ_SDA()) { if (micros() - start > timeout_us) return 0; } return 1; }更进一步:如何写出工业级的模拟I2C驱动?
上面的例子够用了,但如果要做产品级开发,还需要考虑更多。
✅ 模块化设计
将底层GPIO操作抽象出来,便于移植:
typedef struct { void (*init)(void); void (*set_scl)(uint8_t level); void (*set_sda)(uint8_t level); uint8_t (*read_sda)(void); void (*delay_us)(uint32_t us); } i2c_bit_ops_t;这样换MCU只需改操作函数,核心逻辑不变。
✅ 多总线支持
定义多个实例:
i2c_device_t i2c_sensor_bus = { .ops = &sensor_gpio_ops, .speed = 100000 }; i2c_device_t i2c_display_bus = { .ops = &display_gpio_ops, .speed = 400000 };实现互不干扰的独立通信通道。
✅ 错误处理与重试机制
加入CRC校验、自动重试、状态记录:
uint8_t i2c_write_with_retry(uint8_t addr, uint8_t *buf, int len, int retries) { while (retries-- > 0) { if (i2c_do_write(addr, buf, len) == 0) return 0; delay_ms(10); } return -1; }提升系统鲁棒性。
写在最后:为什么你应该掌握这项技能?
在这个动辄用HAL库、CubeMX生成代码的时代,还有必要手动写模拟I2C吗?
我的答案是:非常有必要。
因为当你面对一款冷门国产MCU、一份残缺的数据手册、一块无法识别的传感器时,那些高级工具都会失效。唯有深入底层的能力,才能让你从容应对。
而且你会发现,一旦掌握了模拟I2C,SPI、单总线、红外遥控这些协议也都变得不再神秘。它们的本质,不过是一系列精确的时序控制而已。
所以,不妨今晚就动手试试:选一块开发板,接一个I2C传感器,不用任何库,从头写一遍读写流程。当你第一次在串口看到正确的温湿度数值跳出来时,那种成就感,远胜于复制粘贴一百行代码。
如果你在实现过程中遇到了挑战,欢迎留言交流。我们一起debug,一起进步。