手撕I2C和SPI协议实现
目录
I2C协议原理
I2C(Inter-Integrated Circuit)是一种串行通信总线,使用两根线:SCL(时钟线)和SDA(数据线)。
基本特性
- 主从架构
- 双向半双工通信
- 每个设备都有唯一地址
- 支持多主设备
- 通信速率通常为100kHz(标准模式)、400kHz(快速模式)或1MHz以上(高速模式)
信号状态
- 空闲状态:SCL和SDA均为高电平
- 起始信号(START):SCL高电平时,SDA从高变低
- 停止信号(STOP):SCL高电平时,SDA从低变高
- 数据位:SCL低电平时,准备数据;SCL高电平时,采样数据
- 应答信号(ACK):接收方在第9个时钟周期将SDA拉低表示接收成功
通信流程
- 主设备发送起始信号(START)
- 发送从设备地址(7位)和读/写位(1位)
- 从设备发送应答(ACK)
- 数据传输(8位一组),每组后跟应答位
- 主设备发送停止信号(STOP)
I2C位操作实现
首先需要实现基本的I2C底层函数:
GPIO配置
// 配置GPIO为开漏输出模式
void I2C_GPIO_Config(void) {
// 使能GPIO时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; // SCL: PB6, SDA: PB7
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
// 空闲状态,均为高电平
GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7);
}
I2C基本操作函数
// SCL和SDA控制函数
#define SCL_H GPIO_SetBits(GPIOB, GPIO_Pin_6)
#define SCL_L GPIO_ResetBits(GPIOB, GPIO_Pin_6)
#define SDA_H GPIO_SetBits(GPIOB, GPIO_Pin_7)
#define SDA_L GPIO_ResetBits(GPIOB, GPIO_Pin_7)
#define SDA_READ GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)
// 延迟函数
void I2C_Delay(void) {
uint8_t i = 10; // 可根据时钟频率调整
while(i--);
}
// 起始信号
void I2C_Start(void) {
SDA_H;
SCL_H;
I2C_Delay();
SDA_L; // SDA从高到低,产生起始信号
I2C_Delay();
SCL_L; // 钳住I2C总线,准备发送或接收数据
}
// 停止信号
void I2C_Stop(void) {
SDA_L;
SCL_H;
I2C_Delay();
SDA_H; // SDA从低到高,产生停止信号
I2C_Delay();
}
// 等待应答
uint8_t I2C_WaitAck(void) {
uint8_t ack;
SDA_H; // 释放SDA
I2C_Delay();
SCL_H; // 产生时钟脉冲
I2C_Delay();
ack = SDA_READ; // 读取SDA状态
SCL_L;
return ack; // 返回0表示有应答
}
// 发送应答
void I2C_Ack(void) {
SDA_L; // SDA拉低,表示ACK
I2C_Delay();
SCL_H;
I2C_Delay();
SCL_L;
SDA_H; // 释放SDA
}
// 发送非应答
void I2C_NAck(void) {
SDA_H; // SDA保持高电平,表示NACK
I2C_Delay();
SCL_H;
I2C_Delay();
SCL_L;
}
// 发送一个字节
void I2C_SendByte(uint8_t byte) {
uint8_t i = 8;
while(i--) {
SCL_L;
I2C_Delay();
if(byte & 0x80)
SDA_H;
else
SDA_L;
byte <<= 1;
I2C_Delay();
SCL_H;
I2C_Delay();
}
SCL_L;
}
// 读取一个字节
uint8_t I2C_ReadByte(uint8_t ack) {
uint8_t i = 8;
uint8_t byte = 0;
SDA_H; // 释放SDA,准备读取数据
while(i--) {
byte <<= 1;
SCL_L;
I2C_Delay();
SCL_H;
I2C_Delay();
if(SDA_READ)
byte |= 0x01;
}
SCL_L;
if(ack)
I2C_Ack(); // 发送应答
else
I2C_NAck(); // 发送非应答
return byte;
}
I2C驱动代码编写
基于上面的底层函数,实现设备读写操作:
// 写入一个字节到指定设备的指定寄存器
uint8_t I2C_WriteReg(uint8_t DevAddr, uint8_t RegAddr, uint8_t data) {
I2C_Start();
I2C_SendByte(DevAddr << 1); // 设备地址 + 写位(0)
if(I2C_WaitAck()) {
I2C_Stop();
return 1; // 无应答,失败
}
I2C_SendByte(RegAddr); // 寄存器地址
if(I2C_WaitAck()) {
I2C_Stop();
return 1;
}
I2C_SendByte(data); // 写入数据
if(I2C_WaitAck()) {
I2C_Stop();
return 1;
}
I2C_Stop();
return 0; // 成功
}
// 从指定设备的指定寄存器读取一个字节
uint8_t I2C_ReadReg(uint8_t DevAddr, uint8_t RegAddr) {
uint8_t data;
I2C_Start();
I2C_SendByte(DevAddr << 1); // 设备地址 + 写位(0)
if(I2C_WaitAck()) {
I2C_Stop();
return 0xFF; // 无应答,失败
}
I2C_SendByte(RegAddr); // 寄存器地址
if(I2C_WaitAck()) {
I2C_Stop();
return 0xFF;
}
I2C_Start(); // 重复起始
I2C_SendByte((DevAddr << 1) | 0x01); // 设备地址 + 读位(1)
if(I2C_WaitAck()) {
I2C_Stop();
return 0xFF;
}
data = I2C_ReadByte(0); // 读取数据,发送非应答
I2C_Stop();
return data;
}
实际应用示例:MPU6050读取数据
#define MPU6050_ADDR 0x68 // MPU6050设备地址
void MPU6050_Init() {
I2C_WriteReg(MPU6050_ADDR, 0x6B, 0x00); // 唤醒MPU6050
I2C_WriteReg(MPU6050_ADDR, 0x19, 0x07); // 采样率设置
I2C_WriteReg(MPU6050_ADDR, 0x1A, 0x06); // 配置数字低通滤波器
I2C_WriteReg(MPU6050_ADDR, 0x1B, 0x18); // 陀螺仪量程:±2000dps
I2C_WriteReg(MPU6050_ADDR, 0x1C, 0x01); // 加速度计量程:±2g
}
void MPU6050_GetAcceleration(int16_t *ax, int16_t *ay, int16_t *az) {
uint8_t buf[6];
// 读取加速度计数据
buf[0] = I2C_ReadReg(MPU6050_ADDR, 0x3B);
buf[1] = I2C_ReadReg(MPU6050_ADDR, 0x3C);
buf[2] = I2C_ReadReg(MPU6050_ADDR, 0x3D);
buf[3] = I2C_ReadReg(MPU6050_ADDR, 0x3E);
buf[4] = I2C_ReadReg(MPU6050_ADDR, 0x3F);
buf[5] = I2C_ReadReg(MPU6050_ADDR, 0x40);
*ax = (buf[0] << 8) | buf[1];
*ay = (buf[2] << 8) | buf[3];
*az = (buf[4] << 8) | buf[5];
}
SPI协议原理
SPI(Serial Peripheral Interface)是一种同步串行通信接口,使用四根线:
基本特性
- MOSI (Master Out Slave In):主设备发送,从设备接收
- MISO (Master In Slave Out):主设备接收,从设备发送
- SCK (Serial Clock):时钟信号,由主设备产生
- SS/CS (Slave Select/Chip Select):片选信号,用于选择从设备
工作模式
SPI有四种工作模式,由CPOL(时钟极性)和CPHA(时钟相位)决定:
- 模式0:CPOL=0, CPHA=0,空闲时SCK低电平,第一个边沿采样
- 模式1:CPOL=0, CPHA=1,空闲时SCK低电平,第二个边沿采样
- 模式2:CPOL=1, CPHA=0,空闲时SCK高电平,第一个边沿采样
- 模式3:CPOL=1, CPHA=1,空闲时SCK高电平,第二个边沿采样
通信流程
- 主设备将对应从设备的CS线拉低(激活)
- 主设备通过SCK产生时钟信号
- 数据通过MOSI和MISO线同时双向传输
- 传输完成后,主设备将CS线拉高(释放)
SPI位操作实现
GPIO配置
// 配置SPI GPIO
void SPI_GPIO_Config(void) {
// 使能GPIO时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
// 配置SCK、MOSI为推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; // SCK: PA5, MOSI: PA7
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置MISO为浮空输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; // MISO: PA6
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置CS为推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; // CS: PA4
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 初始状态:CS高,SCK低
GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS高电平,不选中从设备
GPIO_ResetBits(GPIOA, GPIO_Pin_5); // SCK低电平,模式0初始状态
}
SPI基本操作函数
// SPI引脚定义
#define SPI_CS_H GPIO_SetBits(GPIOA, GPIO_Pin_4)
#define SPI_CS_L GPIO_ResetBits(GPIOA, GPIO_Pin_4)
#define SPI_SCK_H GPIO_SetBits(GPIOA, GPIO_Pin_5)
#define SPI_SCK_L GPIO_ResetBits(GPIOA, GPIO_Pin_5)
#define SPI_MOSI_H GPIO_SetBits(GPIOA, GPIO_Pin_7)
#define SPI_MOSI_L GPIO_ResetBits(GPIOA, GPIO_Pin_7)
#define SPI_MISO GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6)
// 延迟函数
void SPI_Delay(void) {
uint8_t i = 2;
while(i--);
}
// SPI发送并接收一个字节(模式0)
uint8_t SPI_ReadWriteByte(uint8_t data) {
uint8_t i;
uint8_t temp = 0;
for(i = 0; i < 8; i++) {
// 准备发送数据
if(data & 0x80)
SPI_MOSI_H;
else
SPI_MOSI_L;
data <<= 1; // 左移一位,准备下一位
SPI_Delay();
SPI_SCK_H; // 时钟上升沿,从设备采样MOSI
SPI_Delay();
temp <<= 1; // 左移一位,为接收新的数据位腾出空间
if(SPI_MISO)
temp++; // 如果MISO为高,则置1
SPI_SCK_L; // 时钟下降沿,主设备采样MISO
SPI_Delay();
}
return temp; // 返回接收到的数据
}
SPI驱动代码编写
基于上面的底层函数,实现设备读写操作:
// 向指定寄存器写入一个字节
void SPI_WriteReg(uint8_t reg, uint8_t value) {
SPI_CS_L; // 使能片选
SPI_ReadWriteByte(reg); // 发送寄存器地址
SPI_ReadWriteByte(value); // 发送数据
SPI_CS_H; // 禁用片选
}
// 从指定寄存器读取一个字节
uint8_t SPI_ReadReg(uint8_t reg) {
uint8_t value;
SPI_CS_L; // 使能片选
SPI_ReadWriteByte(reg | 0x80); // 发送寄存器地址(最高位置1表示读操作)
value = SPI_ReadWriteByte(0xFF); // 发送任意值,读取结果
SPI_CS_H; // 禁用片选
return value;
}
// 从指定寄存器读取多个字节
void SPI_ReadMulti(uint8_t reg, uint8_t *buf, uint8_t len) {
SPI_CS_L; // 使能片选
SPI_ReadWriteByte(reg | 0x80); // 发送寄存器地址(最高位置1表示读操作)
while(len--) {
*buf = SPI_ReadWriteByte(0xFF);
buf++;
}
SPI_CS_H; // 禁用片选
}
实际应用示例:读取W25Q64闪存
// W25Q64命令定义
#define W25Q64_READ_ID 0x90
#define W25Q64_READ_DATA 0x03
#define W25Q64_WRITE_ENABLE 0x06
#define W25Q64_PAGE_PROGRAM 0x02
#define W25Q64_ERASE_SECTOR 0x20
#define W25Q64_READ_STATUS 0x05
// 读取W25Q64芯片ID
uint16_t W25Q64_ReadID(void) {
uint16_t id = 0;
SPI_CS_L;
SPI_ReadWriteByte(W25Q64_READ_ID); // 发送读取ID命令
SPI_ReadWriteByte(0x00); // 发送3个虚拟地址
SPI_ReadWriteByte(0x00);
SPI_ReadWriteByte(0x00);
id |= SPI_ReadWriteByte(0xFF) << 8; // 读取厂商ID
id |= SPI_ReadWriteByte(0xFF); // 读取设备ID
SPI_CS_H;
return id;
}
// 读取W25Q64状态寄存器
uint8_t W25Q64_ReadStatus(void) {
uint8_t status;
SPI_CS_L;
SPI_ReadWriteByte(W25Q64_READ_STATUS);
status = SPI_ReadWriteByte(0xFF);
SPI_CS_H;
return status;
}
// 等待W25Q64操作完成
void W25Q64_WaitBusy(void) {
while((W25Q64_ReadStatus() & 0x01) == 0x01);
}
// 读取W25Q64数据
void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint16_t len) {
SPI_CS_L;
SPI_ReadWriteByte(W25Q64_READ_DATA); // 发送读取命令
SPI_ReadWriteByte((addr >> 16) & 0xFF); // 发送地址
SPI_ReadWriteByte((addr >> 8) & 0xFF);
SPI_ReadWriteByte(addr & 0xFF);
while(len--) {
*buf = SPI_ReadWriteByte(0xFF);
buf++;
}
SPI_CS_H;
}
总结
I2C协议实现要点
- 使用开漏输出模式配置GPIO
- 实现起始、停止、发送、接收、应答等基本信号操作
- 按照协议时序编写读写函数
- 注意时钟速率控制和时序延迟
SPI协议实现要点
- 配置MOSI、SCK为输出,MISO为输入
- 确定使用的SPI模式(时钟极性和相位)
- 实现基本的读写字节函数
- 根据具体设备实现寄存器读写操作
注意事项
- 时序要严格遵循协议规范
- 延时函数需根据实际系统时钟频率调整
- 注意不同设备可能有特殊的地址或命令要求
- 调试时可以使用示波器观察信号波形
- 加入错误处理和超时机制提高鲁棒性
通过以上步骤,您可以实现对I2C和SPI协议的"手撕",即从底层位操作实现完整的通信协议,而不依赖于硬件外设。这种方式虽然占用CPU资源较多,但灵活性高,适用于不需要高速通信的场景或硬件外设不足的情况。