你是否遇到过单片机硬件I^2C资源不够用的尴尬?
是否好奇如何用几行代码“凭空”造出一个I^2C接口?
今天带你揭开模拟I^2C的神秘面纱,从理论到实战一网打尽!
一、为什么要用软件模拟IIC?
大多数现代MCU(如STM32、ESP32)都内置了硬件IIC外设,但软件模拟IIC依然有它的价值:
- 灵活性:在没有硬件IIC支持的芯片上也能实现通信。
- 学习性:手写代码能帮助你深入理解IIC的每一步时序。
- 调试性:可以更精细地控制信号,方便排查问题。
软件IIC的核心是通过GPIO模拟SCL和SDA的电平变化,严格遵循IIC协议的时序规则。接下来,我们将按IIC的时序要求,逐步实现以下功能:起始信号、停止信号、发送字节、接收字节和应答处理。
二、IIC时序回顾:代码的“蓝图”
在编写代码前,我们先快速回顾IIC的时序规则,确保代码逻辑与之吻合:
- 起始信号(START):SCL高电平时,SDA从高到低跳变。
- 停止信号(STOP):SCL高电平时,SDA从低到高跳变。
- 数据传输:SCL低电平时,SDA设置数据;SCL高电平时,数据被采样。每8位数据后跟1位应答(ACK/NACK)。
- 应答信号(ACK):接收方拉低SDA表示确认,释放SDA(高电平)表示非确认。
- 时钟同步:SCL由主设备控制,从设备可通过拉低SCL进行时钟拉伸。
这些规则将直接体现在我们的代码中。假设我们使用两个GPIO引脚(例如,PB6作为SCL,PB7作为SDA),下面是完整的实现。
三、代码实现:从时序到功能
以下代码以C语言为基础,适用于大多数嵌入式平台。为了清晰起见,我们假设硬件平台支持基本的GPIO操作(具体引脚配置需根据实际MCU调整)。代码分为初始化、基础时序函数和高层通信函数三部分。
1. 头文件与宏定义
#ifndef __IIC_H
#define __IIC_H
#include "stm32f10x.h" // 根据实际MCU替换
// 定义SCL和SDA的GPIO引脚
#define IIC_SCL_PORT GPIOB
#define IIC_SDA_PORT GPIOB
#define IIC_SCL_PIN GPIO_Pin_6
#define IIC_SDA_PIN GPIO_Pin_7
// 宏定义GPIO电平操作
#define IIC_SCL_H() GPIO_SetBits(IIC_SCL_PORT, IIC_SCL_PIN)
#define IIC_SCL_L() GPIO_ResetBits(IIC_SCL_PORT, IIC_SCL_PIN)
#define IIC_SDA_H() GPIO_SetBits(IIC_SDA_PORT, IIC_SDA_PIN)
#define IIC_SDA_L() GPIO_ResetBits(IIC_SDA_PORT, IIC_SDA_PIN)
#define IIC_SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_PORT, IIC_SDA_PIN)
// 函数声明
void IIC_Init(void);
void IIC_Start(void);
void IIC_Stop(void);
void IIC_SendByte(uint8_t data);
uint8_t IIC_ReadByte(void);
uint8_t IIC_WaitAck(void);
void IIC_Ack(void);
void IIC_NAck(void);
void IIC_Delay(void);
#endif
2. 初始化函数
IIC的SCL和SDA需要配置为开漏输出,并加上拉电阻(通常4.7kΩ)。初始状态下,两线保持高电平。
void IIC_Init(void) {
GPIO_InitTypeDef GPIO_InitStructure;
// 使能GPIO时钟(根据MCU调整)
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
// 配置SCL和SDA为开漏输出
GPIO_InitStructure.GPIO_Pin = IIC_SCL_PIN | IIC_SDA_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(IIC_SCL_PORT, &GPIO_InitStructure);
// 初始化为高电平
IIC_SCL_H();
IIC_SDA_H();
}
3. 延时函数
软件IIC需要精确控制时序,延时函数用于模拟适当的时钟周期(例如,100kHz对应10μs周期)。
void IIC_Delay(void) {
volatile uint8_t i = 10; // 根据实际时钟频率调整
while (i--);
}
4. 起始信号
严格按照时序:SCL高时,SDA从高到低。
void IIC_Start(void) {
IIC_SDA_H(); // 确保SDA初始为高
IIC_SCL_H(); // 确保SCL初始为高
IIC_Delay();
IIC_SDA_L(); // SDA拉低,产生起始信号
IIC_Delay();
IIC_SCL_L(); // SCL拉低,为后续数据准备
IIC_Delay();
}
5. 停止信号
SCL高时,SDA从低到高。
void IIC_Stop(void) {
IIC_SDA_L(); // 确保SDA初始为低
IIC_SCL_H(); // SCL拉高
IIC_Delay();
IIC_SDA_H(); // SDA拉高,产生停止信号
IIC_Delay();
}
6. 发送一个字节
数据在SCL低电平时设置,SCL高电平时被采样。
void IIC_SendByte(uint8_t data) {
uint8_t i;
for (i = 0; i < 8; i++) {
IIC_SCL_L(); // SCL拉低,准备设置数据
IIC_Delay();
if (data & 0x80) // 发送最高位
IIC_SDA_H();
else
IIC_SDA_L();
data <<= 1; // 数据左移
IIC_Delay();
IIC_SCL_H(); // SCL拉高,采样数据
IIC_Delay();
}
IIC_SCL_L(); // 传输结束,SCL拉低
}
7. 接收一个字节
主设备读取SDA上的数据,同样在SCL高电平时采样。
uint8_t IIC_ReadByte(void) {
uint8_t i, data = 0;
IIC_SDA_H(); // 释放SDA,准备接收
for (i = 0; i < 8; i++) {
data <<= 1; // 数据左移
IIC_SCL_L();
IIC_Delay();
IIC_SCL_H(); // SCL高电平,采样SDA
IIC_Delay();
if (IIC_SDA_READ())
data |= 0x01; // 读取数据位
}
IIC_SCL_L();
return data;
}
8. 应答与非应答
主设备检查从设备的ACK,或发送ACK/NACK给从设备。
uint8_t IIC_WaitAck(void) {
uint8_t timeout = 200;
IIC_SDA_H(); // 释放SDA
IIC_Delay();
IIC_SCL_H(); // SCL拉高,等待ACK
IIC_Delay();
while (IIC_SDA_READ()) { // 检查SDA是否被拉低
timeout--;
if (timeout == 0) {
IIC_Stop(); // 超时,发送停止信号
return 1; // 无应答
}
}
IIC_SCL_L();
return 0; // 收到ACK
}
void IIC_Ack(void) {
IIC_SCL_L();
IIC_SDA_L(); // 拉低SDA,发送ACK
IIC_Delay();
IIC_SCL_H();
IIC_Delay();
IIC_SCL_L();
}
void IIC_NAck(void) {
IIC_SCL_L();
IIC_SDA_H(); // 释放SDA,发送NACK
IIC_Delay();
IIC_SCL_H();
IIC_Delay();
IIC_SCL_L();
}
四、代码使用示例:读写EEPROM
以AT24C02 EEPROM为例,展示如何用上述代码实现写操作。
void EEPROM_WriteByte(uint8_t addr, uint8_t data) {
IIC_Start();
IIC_SendByte(0xA0); // 器件地址+写(0x50 << 1)
IIC_WaitAck();
IIC_SendByte(addr); // 寄存器地址
IIC_WaitAck();
IIC_SendByte(data); // 数据
IIC_WaitAck();
IIC_Stop();
}
读取操作类似,只需将器件地址改为0xA1(读模式),并调用IIC_ReadByte。
五、调试与优化
- 时序调试:用示波器检查SCL和SDA的波形,确保起始/停止信号和数据位的时序正确。
- 延时调整:根据MCU主频和IIC速率(如100kHz或400kHz),调整IIC_Delay中的循环次数。
- 错误处理:在IIC_WaitAck中增加超时机制,避免程序卡死。
- 总线负载:确保上拉电阻合适(4.7kΩ为常见值),否则可能导致信号失真。
六、总结:从时序到代码的完美落地
通过手写软件IIC,不仅实现了完整的通信功能,还深入理解了IIC协议的每一步时序。这种从“原理到代码”的过程,正是嵌入式开发的乐趣所在!希望这篇文章能帮你快速上手IIC编程,并在项目中灵活运用。
关注我,获取更多技术干货