基于江科大与铁头山羊的STM32通信详解
1.串口
(1)硬件电路
1.简单双向串口通信有两根通信线(发送端TX和接收端RX)
2.TX与RX要交叉连接
3.当只需单向的数据传输时,可以只接一根通信线
4.当电平标准不一致时,需要加电平转换芯片(这里就可以了解RS232、485的通信协议,RS232简易优点就是增加了电压差,增加了通信距离。而RS485是用差分电压,既能增加通信距离又能强抗干扰。本质都是对于串口的扩展用于更复杂的场景)
这里补充一点的是,当两个设备进行通信时可以不接电源线,但必须要接底线,保证共同的电压基准。串口通信的信号电平(如TX、RX)是相对于地的。如果没有共地,两个设备的“0V”参考点不同,可能导致信号电平偏移、误码甚至损坏接口。
(2)串口参数及时序串口参数及时序
波特率:串口通信的速率
数据位:数据帧的有效载荷,1为高电平,0为低电平,低位先行
校验位:用于数据验证,根据数据位计算得来
停止位:用于数据帧间隔,固定为高电平

我们首先要保证Tx空闲状态时为高电平,将输出GPIO设置复用推挽输出。
起始位:标志一个数据帧的开始,Tx从空闲状态转换为Busy状态---我们将Tx状态拉低标志着我们开始接受数据
数据位:数据帧的有效载荷,1为高电平,0为低电平,低位先行
校验位:用于数据验证,根据数据位的1的奇偶个来判断发送是否正确(一般用不到,1位出错的概率可以检测的到,二位出错就检测不到)
停止位:标志一个数据帧的结束,Tx从Busy状态转换为空闲状态:我们将Tx状态抬高标志着我们接受数据完成

从上图可以清晰的了解波特率的计算,要得到我们想要的波特率只需要反求分频器的数就行。对于STM32硬件来说都是集成的,不需要计算。
这里我还需要介绍一下几个标志位以便于后续理解:
TXE:发送数据寄存器空 (Transmit data register empty)
TC:发送完成 (Transmission complete)
RXNE:读数据寄存器非空 (Read data register not empty),当接受数据寄存器非空时,标志位为1,否则为0。TXE同理
(3)串口发送模式
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); //开启USART1的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启GPIOA的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA9引脚初始化为复用推挽输出
/*USART初始化*/
USART_InitTypeDef USART_InitStructure; //定义结构体变量
USART_InitStructure.USART_BaudRate = 9600; //波特率
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制,不需要
USART_InitStructure.USART_Mode = USART_Mode_Tx; //模式,选择为发送模式
USART_InitStructure.USART_Parity = USART_Parity_No; //奇偶校验,不需要
USART_InitStructure.USART_StopBits = USART_StopBits_1; //停止位,选择1位
USART_InitStructure.USART_WordLength = USART_WordLength_8b; //字长,选择8位
USART_Init(USART1, &USART_InitStructure); //将结构体变量交给USART_Init,配置USART1
/*USART使能*/
USART_Cmd(USART1, ENABLE); //使能USART1,串口开始运行
上述为使用标准库对USART发送模式进行初始化,波特率为9600,无校验位,一位停止位,数据为为8个字节。关于硬件流控不仔细讲,简单介绍一个,硬件流控主要是相当于一个缓冲区,对于通信双方的速度进行约束。避免数据丢失的自动流量控制机制。
/**
* 函 数:串口发送一个字节
* 参 数:Byte 要发送的一个字节
* 返 回 值:无
*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1, Byte); //将字节数据写入数据寄存器,写入后USART自动生成时序波形
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); //等待发送完成
/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}
/**
* 函 数:串口发送一个数组
* 参 数:Array 要发送数组的首地址
* 参 数:Length 要发送数组的长度
* 返 回 值:无
*/
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
uint16_t i;
for (i = 0; i < Length; i ++) //遍历数组
{
Serial_SendByte(Array[i]); //依次调用Serial_SendByte发送每个字节数据
}
}
/**
* 函 数:使用printf需要重定向的底层函数
* 参 数:保持原始格式即可,无需变动
* 返 回 值:保持原始格式即可,无需变动
*/
int fputc(int ch, FILE *f)
{
Serial_SendByte(ch); //将printf的底层重定向到自己的发送字节函数
return ch;
}
对于上述三个函数,我重点讲解一下第三个函数int fputc(int ch,FILE *f),在STM32开发中,重写 int fputc(int ch, FILE *f) 函数可以实现 将标准输出(如 printf)重定向到串口(或其他自定义输出设备)。这之所以可行,本质上依赖于 C标准库的弱符号(weak symbol)机制 和 库函数的设计约定(其实在其他类型单片机也可重定向,我在初学51单片机时也可重定向)。
使用printf函数之前别忘了引用头文件stdio.h。
(3)串口接收模式
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); //开启USART1的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启GPIOA的时钟
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA10引脚初始化为上拉输入
/*USART初始化*/
USART_InitTypeDef USART_InitStructure; //定义结构体变量
USART_InitStructure.USART_BaudRate = 9600; //波特率
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制,不需要
USART_InitStructure.USART_Mode = USART_Mode_Rx; //模式,接收模式选择
USART_InitStructure.USART_Parity = USART_Parity_No; //奇偶校验,不需要
USART_InitStructure.USART_StopBits = USART_StopBits_1; //停止位,选择1位
USART_InitStructure.USART_WordLength = USART_WordLength_8b; //字长,选择8位
USART_Init(USART1, &USART_InitStructure); //将结构体变量交给USART_Init,配置USART1
USART_Cmd(USART1, ENABLE); //使能USART1,串口开始运行
上述为使用标准库对USART接受模式进行初始化,波特率为9600,无校验位,一位停止位,数据为为8个字节。
/* 对于1个字节的数据接收 */
// #1.等待接受数据
while(USART_GetFlagStatus(USART1,USART_FLAG_RXNE) == RESET)
// #2.接受数据
uint8_t btyeRcvd = USART_ReceiveData(USART1)
/* 对于多字节的数据接收 */
/**
* @brief 通过USART1接收指定长度的数据(阻塞方式)
* @param buffer 存放接收数据的缓冲区
* @param len 要接收的字节数
* @retval 实际接收到的字节数(正常情况下 == len)
*/
uint16_t USART1_ReceiveBytes(uint8_t *buffer, uint16_t len)
{
uint16_t cnt = 0;
for(cnt = 0; cnt < len; cnt++)
{
// 等待接收数据寄存器非空
while(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);
// 读取数据并存入缓冲区
buffer[cnt] = USART_ReceiveData(USART1);
}
return cnt;
}
总的来说,对于串口接收与发送,不要忘记两个标志位TXE:发送数据寄存器空 (Transmit data register empty)和RXNE:读数据寄存器非空 (Read data register not empty)
2.IIC
(1)硬件电路

- 所有I2C设备的SCL连在一起,SDA连在一起
- 设备的SCL和SDA均要配置成开漏输出模式(保证SCL、SDA在空闲状态时都要保持高电平)
- SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右(对于绝大数外挂模块来说都两根线都自带上拉电阻、所以在大多数时单片机都不需要外加上拉电阻)
(2)I2C时序基本单元
- 起始条件:SCL高电平期间,SDA从高电平切换到低电平
- 终止条件:SCL高电平期间,SDA从低电平切换到高电平

发送一个字节:SCL低电平期间,主机将数据位依次放到SDA线上(高位先行)
接收一个字节:SCL低电平期间,从机将数据位依次放到SDA线上(高位先行)
在SCL低电平定义为写的时间,SCL高电平定义为读的时间
发送应答:主机在接收完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答
(3)IIC发送格式

起始位+地址位(7位地址位+1位读写位,读1写0,方法(0x## << 1 & 0x00)+ACK应答位+数据位+ACK+......+停止位。
(4)IIC硬件读写

先提前介绍几个标志位以便于后续代码的理解:
ACK:应答使能 (Acknowledge enable)
软件可以设置或清除该位,或当PE=0时,由硬件清除。
0:无应答返回;
1:在接收到一个字节后返回一个应答(匹配的地址或数据)。(切记不要在这里搞混,ACK1是将电平拉高,使SDA线置于低电平,接受应答)
STOP:停止条件产生 (Stop generation)
软件可以设置或清除该位;或当检测到停止条件时,由硬件清除;当检测到超时错误时,硬件
将其置位。
START:起始条件产生 (Start generation)
软件可以设置或清除该位,或当起始条件发出后或PE=0时,由硬件清除。
AF:应答失败 (Acknowledge failure)
TxE:数据寄存器为空(发送时) (Data register empty (transmitters))
RxNE:数据寄存器非空(接收时) (Data register not empty (receivers))
BTF:字节发送结束 (Byte transfer finished)
ADDR:地址已被发送(主模式)/地址匹配(从模式) (Address sent (master mode)/matched
(slave mode))
SB:发送起始位
补充:我简易的讲述一下如何用标志位去写入数据,便于大家记忆更深
先附代码,我再讲解
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
// 1. 产生起始条件
I2C2->CR1 |= I2C_CR1_START;
while(!(I2C2->SR1 & I2C_SR1_SB)); // 等待起始条件发送完成 (EV5)
// 2. 发送设备地址 + 写方向 (地址左移1位,最后一位=0表示写)
I2C2->DR = (MPU6050_ADDRESS << 1) | 0x00;
while(!(I2C2->SR1 & I2C_SR1_ADDR)); // 等待地址发送完成 (EV6)
// 读取SR1和SR2清除ADDR标志位
(void)I2C2->SR1;
(void)I2C2->SR2;
// 3. 发送寄存器地址
I2C2->DR = RegAddress;
while(!(I2C2->SR1 & I2C_SR1_TXE)); // 等待数据寄存器空 (EV8)
// 4. 发送要写入的数据
I2C2->DR = Data;
while(!(I2C2->SR1 & I2C_SR1_BTF)); // 等待字节传输完成 (EV8_2)
// 5. 产生停止条件
I2C2->CR1 |= I2C_CR1_STOP;
}
1.发送起始位,然后等待SB(发送起始位)
2.发送地址位,等待ADDR(寻址成功位)
**这里必须要读取SR1和SR2清除ADDR标志位才能开始下一步,这里我也带有疑问,但数据手册硬性要求,有懂的小伙伴可以在评论区分享一下
3.发送寄存器地址,等待发送寄存器(TxE)为空
**这一步其实不是硬性要求,只是对于复杂的外挂,如MPU6050是一个地址,其内又有多个地址
4.发送数据,等待字节发送位(BTF)为空
5.产生停止条件
下面是集成模块用法。
/* IIC硬件初始化 */
/*开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE); //开启I2C2的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启GPIOB的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure); //将PB10和PB11引脚初始化为复用开漏输出
/*I2C初始化*/
I2C_InitTypeDef I2C_InitStructure; //定义结构体变量
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; //模式,选择为I2C模式
I2C_InitStructure.I2C_ClockSpeed = 50000; //时钟速度,选择为50KHz
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; //时钟占空比,选择Tlow/Thigh = 2
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; //应答,选择使能
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; //应答地址,选择7位,从机模式下才有效
I2C_InitStructure.I2C_OwnAddress1 = 0x00; //自身地址,从机模式下才有效
I2C_Init(I2C2, &I2C_InitStructure); //将结构体变量交给I2C_Init,配置I2C2
/*I2C使能*/
I2C_Cmd(I2C2, ENABLE); //使能I2C2,开始运行
/*------------------------以江科大用IIC读取MPU6050历程源码为例---------------------------*/
/*------------------------发送多个字节---------------------------*/
/**
* 函 数:MPU6050等待事件
* 参 数:同I2C_CheckEvent
* 返 回 值:无
*/
void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)
{
uint32_t Timeout;
Timeout = 10000; //给定超时计数时间
while (I2C_CheckEvent(I2Cx, I2C_EVENT) != SUCCESS) //循环等待指定事件
{
Timeout --; //等待时,计数值自减
if (Timeout == 0) //自减到0后,等待超时
{
/*超时的错误处理代码,可以添加到此处*/
break; //跳出等待,不等了
}
}
}
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t *Pdata, uint32_t len)
{
I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成起始条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //硬件I2C发送从机地址,方向为发送
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6
I2C_SendData(I2C2, RegAddress); //硬件I2C发送寄存器地址
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING); //等待EV8
while(Len--)
{
I2C_SendData(I2C2, *Pdata++); //硬件I2C发送数据
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8_2
}
I2C_GenerateSTOP(I2C2, ENABLE); //硬件I2C生成终止条件
}
这里我要清晰的解释一下I2C_CheckEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT)函数
I2C_CheckEvent 是 STM32 标准外设库中用于 I2C 通信的核心函数,它的主要作用是检查指定的 I2C 通信事件是否已经发生,并自动清除相关标志位(这里注意一下,调用这个函数是会自动清除标志位的,我初写文章,这里就暂时不解释IIC用标志位具体写入的流程,这里简单叙述怎么用,后续我会在上文补充讲述I2C不用集成函数去讲解,如何用标志位写入)函数执行时对状态寄存器的读操作会自动清除大部分事件标志位(如 ADDR、SB、BTF 等),省去了手动清除标志位的步骤。
现在我要讲述一下这个函数的参数作用:
| 事件宏定义 | 对应事件 | 含义 |
|---|---|---|
I2C_EVENT_MASTER_MODE_SELECT |
EV5 | 起始条件已发送,已进入主模式 |
I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED |
EV6 | 地址已发送(写方向),已进入发送模式 |
I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED |
EV6 | 地址已发送(读方向),已进入接收模式 |
I2C_EVENT_MASTER_BYTE_TRANSMITTED |
EV8_2 | 一个数据字节已发送完成 |
I2C_EVENT_MASTER_BYTE_RECEIVED |
EV7 | 一个数据字节已接收完成 |
对于此函数就可以轻易的,发送一个或多个数据了。
下面开始对于接受数据的讲解。这里就不再在讲解寄存器的写法了,下面有详解,大家可以去看看,也是类似的写法。
嵌入式铁头山羊STM32-各章节详细笔记-查阅传送门-CSDN博客
/**
* 函 数:MPU6050读寄存器
* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
* 返 回 值:读取寄存器的数据,范围:0x00~0xFF
*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成起始条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //硬件I2C发送从机地址,方向为发送
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6
I2C_SendData(I2C2, RegAddress); //硬件I2C发送寄存器地址
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8_2
I2C_GenerateSTART(I2C2, ENABLE); //硬件I2C生成重复起始条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //等待EV5
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver); //硬件I2C发送从机地址,方向为接收
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED); //等待EV6
I2C_AcknowledgeConfig(I2C2, DISABLE); //在接收最后一个字节之前提前将应答失能
I2C_GenerateSTOP(I2C2, ENABLE); //在接收最后一个字节之前提前申请停止条件
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED); //等待EV7
Data = I2C_ReceiveData(I2C2); //接收数据寄存器
I2C_AcknowledgeConfig(I2C2, ENABLE); //将应答恢复为使能,为了不影响后续可能产生的读取多字节操作
return Data;
}
1.起始位
2.发送初始地址(I2C_Send7bitAddress + 写方向)
3.发送内部地址(意为我要对这个地址进行操作)
4.重新起始位(这里重始起始位是为了方便开始对寄存器读数据)
5.发送内部地址,我要读取这个数据
6.读取数据
7.提前失能,不再应答
8.停止位
9.接受数据并返回
(5)IIC软件读写
这里我是推荐有硬件资源就用硬件去写,这里不得不说发送天才的人真是个stm32,迫不得已再用软件。而且用软件读取IIC会有很多精度问题,初学者是很难排除原因的。
/*引脚配置层*/
/**
* 函 数:I2C写SCL引脚电平
* 参 数:BitValue 协议层传入的当前需要写入SCL的电平,范围0~1
* 返 回 值:无
* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SCL为低电平,当BitValue为1时,需要置SCL为高电平
*/
void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue); //根据BitValue,设置SCL引脚的电平
Delay_us(10); //延时10us,防止时序频率超过要求
}
/**
* 函 数:I2C写SDA引脚电平
* 参 数:BitValue 协议层传入的当前需要写入SDA的电平,范围0~1
* 返 回 值:无
* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SDA为低电平,当BitValue为1时,需要置SDA为高电平
*/
void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue); //根据BitValue,设置SDA引脚的电平,BitValue要实现非0即1的特性
Delay_us(10); //延时10us,防止时序频率超过要求
}
/**
* 函 数:I2C读SDA引脚电平
* 参 数:无
* 返 回 值:协议层需要得到的当前SDA的电平,范围0~1
* 注意事项:此函数需要用户实现内容,当前SDA为低电平时,返回0,当前SDA为高电平时,返回1
*/
uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11); //读取SDA电平
Delay_us(10); //延时10us,防止时序频率超过要求
return BitValue; //返回SDA电平
}
/**
* 函 数:I2C初始化
* 参 数:无
* 返 回 值:无
* 注意事项:此函数需要用户实现内容,实现SCL和SDA引脚的初始化
*/
void MyI2C_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启GPIOB的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure); //将PB10和PB11引脚初始化为开漏输出
/*设置默认电平*/
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11); //设置PB10和PB11引脚初始化后默认为高电平(释放总线状态)
}
/*协议层*/
/**
* 函 数:I2C起始
* 参 数:无
* 返 回 值:无
*/
void MyI2C_Start(void)
{
MyI2C_W_SDA(1); //释放SDA,确保SDA为高电平
MyI2C_W_SCL(1); //释放SCL,确保SCL为高电平
MyI2C_W_SDA(0); //在SCL高电平期间,拉低SDA,产生起始信号
MyI2C_W_SCL(0); //起始后把SCL也拉低,即为了占用总线,也为了方便总线时序的拼接
}
/**
* 函 数:I2C终止
* 参 数:无
* 返 回 值:无
*/
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0); //拉低SDA,确保SDA为低电平
MyI2C_W_SCL(1); //释放SCL,使SCL呈现高电平
MyI2C_W_SDA(1); //在SCL高电平期间,释放SDA,产生终止信号
}
/**
* 函 数:I2C发送一个字节
* 参 数:Byte 要发送的一个字节数据,范围:0x00~0xFF
* 返 回 值:无
*/
void MyI2C_SendByte(uint8_t Byte)
{
uint8_t i;
for (i = 0; i < 8; i ++) //循环8次,主机依次发送数据的每一位
{
/*两个!可以对数据进行两次逻辑取反,作用是把非0值统一转换为1,即:!!(0) = 0,!!(非0) = 1*/
MyI2C_W_SDA(!!(Byte & (0x80 >> i)));//使用掩码的方式取出Byte的指定一位数据并写入到SDA线
MyI2C_W_SCL(1); //释放SCL,从机在SCL高电平期间读取SDA
MyI2C_W_SCL(0); //拉低SCL,主机开始发送下一位数据
}
}
/**
* 函 数:I2C接收一个字节
* 参 数:无
* 返 回 值:接收到的一个字节数据,范围:0x00~0xFF
*/
uint8_t MyI2C_ReceiveByte(void)
{
uint8_t i, Byte = 0x00; //定义接收的数据,并赋初值0x00,此处必须赋初值0x00,后面会用到
MyI2C_W_SDA(1); //接收前,主机先确保释放SDA,避免干扰从机的数据发送
for (i = 0; i < 8; i ++) //循环8次,主机依次接收数据的每一位
{
MyI2C_W_SCL(1); //释放SCL,主机机在SCL高电平期间读取SDA
if (MyI2C_R_SDA()){Byte |= (0x80 >> i);} //读取SDA数据,并存储到Byte变量
//当SDA为1时,置变量指定位为1,当SDA为0时,不做处理,指定位为默认的初值0
MyI2C_W_SCL(0); //拉低SCL,从机在SCL低电平期间写入SDA
}
return Byte; //返回接收到的一个字节数据
}
/**
* 函 数:I2C发送应答位
* 参 数:Byte 要发送的应答位,范围:0~1,0表示应答,1表示非应答
* 返 回 值:无
*/
void MyI2C_SendAck(uint8_t AckBit)
{
MyI2C_W_SDA(AckBit); //主机把应答位数据放到SDA线
MyI2C_W_SCL(1); //释放SCL,从机在SCL高电平期间,读取应答位
MyI2C_W_SCL(0); //拉低SCL,开始下一个时序模块
}
/**
* 函 数:I2C接收应答位
* 参 数:无
* 返 回 值:接收到的应答位,范围:0~1,0表示应答,1表示非应答
*/
uint8_t MyI2C_ReceiveAck(void)
{
uint8_t AckBit; //定义应答位变量
MyI2C_W_SDA(1); //接收前,主机先确保释放SDA,避免干扰从机的数据发送
MyI2C_W_SCL(1); //释放SCL,主机机在SCL高电平期间读取SDA
AckBit = MyI2C_R_SDA(); //将应答位存储到变量里
MyI2C_W_SCL(0); //拉低SCL,开始下一个时序模块
return AckBit; //返回定义应答位变量
}
这里我直接引用江科大的软件时序了,因为他可写的太棒了。
函数1.void MyI2C_W_SCL(uint8_t BitValue):对时钟线写高低电平(对于不同的高速芯片需要不同的延时时间,要不然短时间读取不到电平状态,72Mhz的主频的芯片可以不用延时)
函数2.void MyI2C_W_SDA(uint8_t BitValue)、uint8_t MyI2C_R_SDA(void):对于数据线读写高低电平
函数3.void MyI2C_Init(void):注意要用复用开漏模式
函数4.void MyI2C_Start(void):起始位,这里注意一个问题,起始位要在SCL线保持高电平,SDA产生下降沿才能算一个起始位。起始后拉低SCL方便后续时序的拼接
函数5.void MyI2C_Stop(void):停止位,在SCL线为高电平时,SDA产生一个下降沿
函数6.void MyI2C_SendByte(uint8_t Byte)、uint8_t MyI2C_ReceiveByte(void):发送一个字节、接受一个字节,不解释了,具体看注释吧
函数7.void MyI2C_SendAck(uint8_t AckBit)、uint8_t MyI2C_ReceiveAck(void):发送应答、接受应答
上述是重点,下述就是与套着时序图读取,写入的函数了
/**
* 函 数:MPU6050写寄存器
* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
* 参 数:Data 要写入寄存器的数据,范围:0x00~0xFF
* 返 回 值:无
*/
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
MyI2C_Start(); //I2C起始
MyI2C_SendByte(MPU6050_ADDRESS); //发送从机地址,读写位为0,表示即将写入
MyI2C_ReceiveAck(); //接收应答
MyI2C_SendByte(RegAddress); //发送寄存器地址
MyI2C_ReceiveAck(); //接收应答
MyI2C_SendByte(Data); //发送要写入寄存器的数据
MyI2C_ReceiveAck(); //接收应答
MyI2C_Stop(); //I2C终止
}
/**
* 函 数:MPU6050读寄存器
* 参 数:RegAddress 寄存器地址,范围:参考MPU6050手册的寄存器描述
* 返 回 值:读取寄存器的数据,范围:0x00~0xFF
*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
MyI2C_Start(); //I2C起始
MyI2C_SendByte(MPU6050_ADDRESS); //发送从机地址,读写位为0,表示即将写入
MyI2C_ReceiveAck(); //接收应答
MyI2C_SendByte(RegAddress); //发送寄存器地址
MyI2C_ReceiveAck(); //接收应答
MyI2C_Start(); //I2C重复起始
MyI2C_SendByte(MPU6050_ADDRESS | 0x01); //发送从机地址,读写位为1,表示即将读取
MyI2C_ReceiveAck(); //接收应答
Data = MyI2C_ReceiveByte(); //接收指定寄存器的数据
MyI2C_SendAck(1); //发送应答,给从机非应答,终止从机的数据输出
MyI2C_Stop(); //I2C终止
return Data;
}
3.SPI
(1)硬件电路
四根通信线:SCK(Serial Clock)、MOSI(Master Output Slave Input)、MISO(Master Input Slave Output)、SS(Slave Select)
同步,全双工
支持总线挂载多设备(一主多从)
所有SPI设备的SCK、MOSI、MISO分别连在一起
主机另外引出多条SS控制线,分别接到各从机的SS引脚
输出引脚配置为推挽输出,输入引脚配置为浮空或上拉输入
(2) SPI时序单元

-
片选信号 (SS/CS):通信开始前,主设备将 CS 拉低(激活低有效),选中从设备。结束后拉高。
-
时钟极性 (CPOL):决定空闲时 SCK 的电平。
-
CPOL = 0:空闲低电平。 -
CPOL = 1:空闲高电平。
-
-
时钟相位 (CPHA):决定数据采样发生在时钟的哪个边沿。
-
CPHA = 0:在第一个时钟边沿采样(前沿采样)。 -
CPHA = 1:在第二个时钟边沿采样(后沿采样)。
-
-
数据收发:在同一时钟脉冲内,主设备通过 MOSI 线发送数据,从设备通过 MISO 线回复数据。这个过程是同时完成的(全双工),每位数据通常需要 半个时钟周期 建立稳定,另半个周期供对方采样。(这里注意一下,数据发送和接送都是同时进行,发出一个bite就要接受一个bite)

这里再介绍两个标志位(可见是比IIC简单多了哈)
TXE:发送缓冲为空 (Transmit buffer empty)
0:发送缓冲非空;
1:发送缓冲为空。
RXNE:接收缓冲非空 (Receive buffer not empty)
0:接收缓冲为空;
1:接收缓冲非空。
(3) SPI交换字节
* 函 数:SPI写SS引脚电平,SS仍由软件模拟
* 参 数:BitValue 协议层传入的当前需要写入SS的电平,范围0~1
* 返 回 值:无
* 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SS为低电平,当BitValue为1时,需要置SS为高电平
*/
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue); //根据BitValue,设置SS引脚的电平
}
/**
* 函 数:SPI初始化
* 参 数:无
* 返 回 值:无
*/
void MySPI_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启GPIOA的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); //开启SPI1的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA4引脚初始化为推挽输出
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA5和PA7引脚初始化为复用推挽输出
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA6引脚初始化为上拉输入
/*SPI初始化*/
SPI_InitTypeDef SPI_InitStructure; //定义结构体变量
SPI_InitStructure.SPI_Mode = SPI_Mode_Master; //模式,选择为SPI主模式
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; //方向,选择2线全双工
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; //数据宽度,选择为8位
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; //先行位,选择高位先行
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128; //波特率分频,选择128分频
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; //SPI极性,选择低极性
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; //SPI相位,选择第一个时钟边沿采样,极性和相位决定选择SPI模式0
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; //NSS,选择由软件控制
SPI_InitStructure.SPI_CRCPolynomial = 7; //CRC多项式,暂时用不到,给默认值7
SPI_Init(SPI1, &SPI_InitStructure); //将结构体变量交给SPI_Init,配置SPI1
/*SPI使能*/
SPI_Cmd(SPI1, ENABLE); //使能SPI1,开始运行
/*设置默认电平*/
MySPI_W_SS(1); //SS默认高电平
}
/**
* 函 数:SPI起始
* 参 数:无
* 返 回 值:无
*/
void MySPI_Start(void)
{
MySPI_W_SS(0); //拉低SS,开始时序
}
/**
* 函 数:SPI终止
* 参 数:无
* 返 回 值:无
*/
void MySPI_Stop(void)
{
MySPI_W_SS(1); //拉高SS,终止时序
}
/**
* 函 数:SPI交换传输一个字节,使用SPI模式0
* 参 数:ByteSend 要发送的一个字节
* 返 回 值:接收的一个字节
*/
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) != SET); //等待发送数据寄存器空
SPI_I2S_SendData(SPI1, ByteSend); //写入数据到发送数据寄存器,开始产生时序
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) != SET); //等待接收数据寄存器非空
return SPI_I2S_ReceiveData(SPI1); //读取接收到的数据并返回
}
这里我直接引用了江科大的代码,大家可清晰的了解是江科大非常喜欢封装函数,这是非常好的习惯。
一、下面介绍一下初始化函数(挑选几个容易概念重的,其他的大家可行了解哈):
1.SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128; //波特率分频,选择128分频:
这里是挂载总线的时钟频率 ÷ 分频,这里有个原则,你的实际波特率必须 ≤ 从设备的最高频率。
2.SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; //SPI极性,选择低极性
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; //SPI相位,选择第一个时钟边沿采样,极性和相位决定选择SPI模式0
这两个函数是关于时序的读取,总共四个模式,按道理来说,无论那个模式选择都是可以读取外挂设备的,只不过代码不一罢了。但为了大家,我还是建议用模式0,因为关于SPI大多数代码都是用模式1
3.SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; //NSS,选择由软件控制
这里如果用硬件控制就可以直接外加上拉电阻,如果选用软件模式,我的建议是在SPI下外加SPI_NSSInternalSoftwareConfig(SPI1, SPI_NSSInternalSoft_Set);函数,这句话的意思内部软件使NSS置于高电位,但我看江科大没写,我是为了保险起见。
二、下面介绍一下封装的函数,防止大家第一次看的时候比较懵
void MySPI_W_SS(uint8_t BitValue)这个函数就是对单个片选信号线进行封装,0选中从机
在初始化中写入MySPI_W_SS(1),为了方便后续代码管理,这样理解就是后续有多个从机设备,而再初始化中全都拉高片选信号,方便后续再写单个模块。
三、下面介绍一下uint8_t MySPI_SwapByte(uint8_t ByteSend)函数
1. 等待发送缓冲区为空 (TXE 标志)
2.写入数据,触发时序生成
3.等待接收缓冲区非空 (RXNE 标志)
4.读取接收到的数据并返回
四、我引入一个多字节交换函数
// 更高效的连续发送(适合大量数据)
void SPI_SendMultiBytes_Fast(SPI_TypeDef* SPIx, uint8_t* txData, uint8_t* rxData, uint16_t len)
{
uint16_t i;
// 先发送第一个字节
while(SPI_I2S_GetFlagStatus(SPIx, SPI_I2S_FLAG_TXE) == RESET);
SPI_I2S_SendData(SPIx, txData[0]);
// 循环处理中间的数据(利用流水线)
for(i = 1; i < len; i++)
{
// 等待上一个数据发送完成(缓冲区空)
while(SPI_I2S_GetFlagStatus(SPIx, SPI_I2S_FLAG_TXE) == RESET);
// 立即发送下一个数据
SPI_I2S_SendData(SPIx, txData[i]);
// 此时读取上一个数据的结果
while(SPI_I2S_GetFlagStatus(SPIx, SPI_I2S_FLAG_RXNE) == RESET);
if(rxData != NULL)
rxData[i-1] = SPI_I2S_ReceiveData(SPIx);
else
SPI_I2S_ReceiveData(SPIx);
}
// 读取最后一个字节
while(SPI_I2S_GetFlagStatus(SPIx, SPI_I2S_FLAG_RXNE) == RESET);
if(rxData != NULL)
rxData[len-1] = SPI_I2S_ReceiveData(SPIx);
else
SPI_I2S_ReceiveData(SPIx);
}
这种代码写入方式可以有效地节省交换时间。先介绍一下函数吧
void SPI_SendMultiBytes_Fast(SPI_TypeDef* SPIx, uint8_t* txData, uint8_t* rxData, uint16_t len)
SPI_TypeDef* SPIx,选SPI
uint8_t* txData,要发送的数据
uint8_t* rxData,发送后交换过来的数据
uint16_t len、要交换的程度
介绍一下这种代码的优势:请看下图,不解释了

(4)W25Q64介绍
这里我介绍一下W25Q64FLASH存储模块吧,以便后续有小伙伴用SPI进行实际操作
1.W25Q64的存储结构
W25Qxx系列是一种低成本、小型化、使用简单的非易失性存储器,常应用于数据存储、字库存储、固件程序存储等场景
存储介质:Nor Flash(闪存)
时钟频率:80MHz / 160MHz (Dual SPI) / 320MHz (Quad SPI)
存储容量(24位地址)


对W25Q64进行写入分为六步
1.写使能
2.扇区擦除(这里可以小伙伴自己查看一下手册,其实是可以块擦除的)
3.等待空闲
4.写使能
5.页编程(这里写入只能按页写入)
6.等待空闲
下面列出几个W25Q64的基础命令定义
// ============ W25Q64 基础命令定义 ============
#define W25X_WriteEnable 0x06
#define W25X_WriteDisable 0x04
#define W25X_ReadStatusReg 0x05
#define W25X_SectorErase 0x20 // 4KB扇区擦除
#define W25X_ChipErase 0xC7
#define W25X_PageProgram 0x02 // 页编程(256字节)
#define W25X_ReadData 0x03

小伙伴们自行理解哈。
现在开始进行写操作
// ============ W25Q64 基础命令定义 ============
#define W25X_WriteEnable 0x06
#define W25X_WriteDisable 0x04
#define W25X_ReadStatusReg 0x05
#define W25X_SectorErase 0x20 // 4KB扇区擦除
#define W25X_ChipErase 0xC7
#define W25X_PageProgram 0x02 // 页编程(256字节)
#define W25X_ReadData 0x03
// CS引脚定义(根据你的实际硬件修改)
#define W25Q64_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4)
#define W25Q64_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4)
// 等待W25Q64空闲(读取状态寄存器)
void W25Q64_WaitBusy(void)
{
uint8_t status;
uint8_t cmd = W25X_ReadStatusReg; // cmd = 0x05
W25Q64_CS_LOW();
// 发送读状态寄存器命令
SPI_SendMultiBytes_Fast(SPI1, &cmd, NULL, 1);
// 循环读取状态,直到BUSY位为0
do {
SPI_SendMultiBytes_Fast(SPI1, (uint8_t*)"\xFF", &status, 1);
} while(status & 0x01); // BUSY位在bit0
W25Q64_CS_HIGH();
}
// 写使能
void W25Q64_WriteEnable(void)
{
W25Q64_CS_LOW();
uint8_t cmd = W25X_WriteEnable;
SPI_SendMultiBytes_Fast(SPI1, &cmd, NULL, 1);
W25Q64_CS_HIGH();
}
// 扇区擦除(4KB对齐,地址必须是4096的倍数)
void W25Q64_SectorErase(uint32_t sector_addr)
{
uint8_t cmd[4];
// 扇区地址必须是4096的倍数
if(sector_addr % 4096 != 0) {
// 错误处理:地址未对齐
return;
}
W25Q64_WriteEnable(); // 擦除前需要写使能
W25Q64_CS_LOW();
cmd[0] = W25X_SectorErase;
cmd[1] = (sector_addr >> 16) & 0xFF;
cmd[2] = (sector_addr >> 8) & 0xFF;
cmd[3] = sector_addr & 0xFF;
SPI_SendMultiBytes_Fast(SPI1, cmd, NULL, 4);
W25Q64_CS_HIGH();
W25Q64_WaitBusy(); // 等待擦除完成
}
// ============ 页编程(一次最多256字节)============
void W25Q64_PageProgram(uint32_t addr, uint8_t* data, uint16_t len)
{
uint8_t cmd[4];
uint16_t i;
W25Q64_WriteEnable(); // 编程前需要写使能
W25Q64_CS_LOW();
cmd[0] = W25X_PageProgram;
cmd[1] = (addr >> 16) & 0xFF;
cmd[2] = (addr >> 8) & 0xFF;
cmd[3] = addr & 0xFF;
// 发送页编程命令和地址
SPI_SendMultiBytes_Fast(SPI1, cmd, NULL, 4);
// 发送要写入的数据
SPI_SendMultiBytes_Fast(SPI1, data, NULL, len);
W25Q64_CS_HIGH();
W25Q64_WaitBusy(); // 等待编程完成
}
// ============ 写入任意长度数据(自动处理跨页)============
void W25Q64_Write(uint32_t addr, uint8_t* data, uint32_t len)
{
uint32_t page_offset = addr & 0xFF; // 页内偏移
uint32_t page_remaining = 256 - page_offset; // 当前页剩余空间
uint32_t write_len;
uint32_t current_addr = addr;
uint32_t current_len = len;
uint8_t* pData = data;
// 按照流程图顺序执行
// 步骤1-4:扇区擦除(如果目标区域未擦除,需要先擦除)
// 注意:实际应用中需要判断是否需要擦除,这里简化处理
while(current_len > 0)
{
// 计算本次写入的数据长度(不超过页边界)
if(current_len < page_remaining) {
write_len = current_len;
} else {
write_len = page_remaining;
}
// 步骤5:页编程
W25Q64_PageProgram(current_addr, pData, write_len);
// 更新地址和长度
current_addr += write_len;
pData += write_len;
current_len -= write_len;
// 重置页偏移(下一页从头开始)
page_remaining = 256;
}
}
这里小编不想解释了,对于上述代码迷惑的可以问一下ai,其实也蛮好理解,这里交给你们了。
下面是读取数据
void W25Q64_ReadData(uint32_t addr, uint8_t* rxData, uint32_t len)
{
uint8_t cmd[4];
W25Q64_CS_LOW();
// 发送读命令和地址
cmd[0] = W25X_ReadData;
cmd[1] = (addr >> 16) & 0xFF;
cmd[2] = (addr >> 8) & 0xFF;
cmd[3] = addr & 0xFF;
SPI_SendMultiBytes_Fast(SPI1, cmd, NULL, 4);
// 连续读取数据(不需要等待,直接读)
for(uint32_t i = 0; i < len; i++) {
// 发送任意数据(通常是0xFF),同时接收数据
uint8_t dummy = 0xFF;
SPI_SendMultiBytes_Fast(SPI1, &dummy, &rxData[i], 1);
}
W25Q64_CS_HIGH();
}
这里小编也不解释,小编相信你们能够自行理解
4.总结对比
下面是我根据AI生成的,基本正确。
| 对比维度 | UART | SPI | I2C (IIC) |
|---|---|---|---|
| 引脚数量 | 2根 (TX, RX) | 4根 (SCK, MOSI, MISO, SS) | 2根 (SCL, SDA) |
| 连接拓扑 | 点对点 (一对一) | 一主多从 (共享SCK/MOSI/MISO,从机独占SS) | 多主多从 (所有设备并接到两根总线上) |
| 同步方式 | 异步 (无时钟线) | 同步 (主设备提供时钟) | 同步 (主设备提供时钟) |
| 传输方向 | 全双工 | 全双工 | 半双工 |
| 典型速度 | 低速:9600 ~ 115200 bps (上限约5Mbps) |
高速:10 ~ 50 MHz (上限可达100MHz+) |
中低速:标准100kbps,快速400kbps,高速3.4Mbps |
| 寻址方式 | 无 (硬件连接确定) | 无 (硬件片选SS确定) | 有 (7位/10位设备地址) |
| 硬件应答 | 无 (需软件协议补充) | 无 (无应答位) | 有 (每字节后含ACK/NACK位) |
| 总线仲裁 | 不支持 | 不支持 | 支持 (多主设备可抢占总线) |
| 电气特性 | TTL电平 (需转换长距离) | TTL/CMOS电平 (板内短距) | 开漏输出,需上拉电阻 (板内短距) |
| 协议复杂度 | ⭐ 简单 | ⭐⭐ 较简单 | ⭐⭐⭐ 较复杂 |
| 抗干扰能力 | 较强 (差分RS-422/485时强) | 一般 (高速时对布线敏感) | 一般 (开漏结构抗干扰稍弱) |
| 通信距离 | 中长 (配合驱动芯片可达千米) | 短 (板内厘米级) | 短 (板内厘米级) |
| 典型外设 | 调试串口、GPS、蓝牙模块 | Flash、LCD屏、ADC、SD卡 | 传感器(温度/湿度)、EEPROM、RTC |
更多推荐

所有评论(0)