🚀write in front🚀
🔎大家好,我是黄桃罐头,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流
🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝💬本系列哔哩哔哩江科大STM32的视频为主以及自己的总结梳理📚
🚀Projeet source code🚀
💾工程代码放在了本人的Gitee仓库:iPickCan (iPickCan) - Gitee.com
引用:
STM32入门教程-2023版 细致讲解 中文字幕_哔哩哔哩_bilibili
Keil5 MDK版 下载与安装教程(STM32单片机编程软件)_mdk528-CSDN博客
STM32之Keil5 MDK的安装与下载_keil5下载程序到单片机stm32-CSDN博客
0. 江协科技/江科大-STM32入门教程-各章节详细笔记-查阅传送门-STM32标准库开发_江协科技stm32笔记-CSDN博客
【STM32】江科大STM32学习笔记汇总(已完结)_stm32江科大笔记-CSDN博客
江科大STM32学习笔记(上)_stm32博客-CSDN博客
STM32学习笔记一(基于标准库学习)_电平输出推免-CSDN博客
STM32 MCU学习资源-CSDN博客
stm32学习笔记-作者: Vera工程师养成记
stem32江科大自学笔记-CSDN博客
术语:
英文缩写 | 描述 |
GPIO:General Purpose Input Onuput | 通用输入输出 |
AFIO:Alternate Function Input Output | 复用输入输出 |
AO:Analog Output | 模拟输出 |
DO:Digital Output | 数字输出 |
内部时钟源 CK_INT:Clock Internal | 内部时钟源 |
外部时钟源 ETR:External Trigger | 时钟源 External 触发 |
外部时钟源 ETR:External Trigger mode 1 | 外部时钟源 External 触发 时钟模式1 |
外部时钟源 ETR:External Trigger mode 2 | 外部时钟源 External 触发 时钟模式2 |
外部时钟源 ITRx:Internal Trigger inputs | 外部时钟源,ITRx (Internal trigger inputs)内部触发输入 |
外部时钟源 TIx:exTernal Input pin | 外部时钟源 TIx (external input pin)外部输入引脚 |
CCR:Capture/Comapre Register | 捕获/比较寄存器 |
OC:Output Compare | 输出比较 |
IC:Input Capture | 输入捕获 |
TI1FP1:TI1 Filter Polarity 1 | Extern Input 1 Filter Polarity 1,外部输入1滤波极性1 |
TI1FP2:TI1 Filter Polarity 2 | Extern Input 1 Filter Polarity 2,外部输入1滤波极性2 |
DMA:Direct Memory Access | 直接存储器存取 |
正文:
0. 概述
从 2024/06/12 定下计划开始学习下江协科技STM32课程,接下来将会按照哔站上江协科技STM32的教学视频来学习入门STM32 开发,本文是视频教程 P2 STM32简介一讲的笔记。
1.🚚MP6050
本节我们来用软件I2C读写MPU6050
接线图:
由于我们这个代码使用的是软件I2C,就是用普通的GPIO口,手动翻转电平实现的协议。它并不需要STM32内部的外设资源支持。所以这里的端口其实可以任意指定,不局限于这两个端口,接在任意的两个普通的GPIO口就可以。
然后我们只需要在程序中配置并操作SCL和SDA对应的端口就行了。这算是软件I2C相比硬件I2C的一大优势,就是端口不受限,可以任意指定。
根据I2C协议的硬件规定,SCL和SDA都应该外挂一个上拉电阻,但是我们的接线这里并没有外挂上拉电阻。是因为上一节我们分析模块电路的时候提到过这个模块内部自带了上拉电阻,所以外部的上拉电阻就不需要接了。
目前这里STM32是主机,MPU6050是从机,是一主一从的模型,当然主机和从机的执行逻辑是完全不同的,我们程序中一般只关注主机端的程序。
这里由于模块内置了下拉电阻,所以引脚悬空的话就相当于接地。
2.🚚MyI2C.c
由于我们本代码要使用软件I2C,所以I2C的库函数我们就不用看了。软件I2C只需要用GPIO的读写函数就行了。
初始化函数
然后初始化函数中,我们要做两个任务。第一个任务把SCL和SDA都初始化为开漏输出模式(开漏输出低电平+浮空输入也就是高阻态)。第二个任务把SCL和SDA置高电平。
⚠️⚠️⚠️注意:开漏输出并不只能输出,开漏输出模式仍然可以输入。
⚠️⚠️⚠️输入时先输出1,再直接读取输入数据寄存器就行了。
然后接下来我们就来完成I2C的六个时序基本单元。
起始条件
第一个基本单元是起始条件,这里对应写一个函数。
起始条件:SCL高电平期间,SDA从高电平切换到低电平
我们首先把SCL和SDA都确保释放,然后先拉低SDA,再拉低SCL,这样就能产生起始条件了。
在这里我们可以不断的调用SetBits和RetsetBits手动翻转高低电平。但是这样做的话,会在后面的程序中出现非常多的地方来指定这个GPIO端口号。一方面这样做语义并不是很明显,另一方面,如果我们之后需要换一个端口,就需要改动非常多的地方。所以这时我们就需要在上面做个定义,把这个端口号统一替换一个名字,这样无论是语义,还是端口的修改,都会非常方便。给端口号换一个名字,有很多方法都能实现功能。在51单片机中,我们一般使用sbit来定义端口的名称,但是sbit并不是标准C语言的语法,STM32也不支持这样做。这里一种简单的替换方法就是宏定义define。
修改引脚的时候,直接在上面修改一下宏定义,这是一种简单可行的方法,在STM32程序中也是挺常见的一个操作。
进一步的,如果觉得每次都需要定义port和pin比较麻烦,还可以把这整个函数用宏定义进行替换,并且用宏定义替换的函数还可以有参数,叫有参宏。
以我们之前讲过的OLED的程序为例:
在宏定义后面加一个括号,里面写入形参,在实际引用的时候,传入实参。
这样实际上OLED_W_SCL(1)就等价于GPIO_WriteBit(GPIOB, GPIO_Pin_8, (BitAction)(1));
补充:BitAction是什么意思?
在STM32中,用于强制将特定的操作数转换为一个位值,将一个非零值转换为逻辑高电平(1),将零值转换为逻辑低电平(0)。
在GPI0操作中,可以使用"BitAction"宏定义来设置引脚的状态,例如通过调用GPI0 writeBit()函数来设置引脚的输出状态。
GPI0x表示GPIO端口,GPI0 Pin表示具体的引脚位,而BitAction表示要设置的引脚状态。
但是这种方法在移植到其他库或者其他种类单片机时,很多人都不知道怎么修改。另外还有这种宏定义的方法,如果换到一个主频很高的单片机中,需要对软件的时序进行延时操作的时候也不太方便进一步修改。
所以综合以上缺点,在这里我们就直接一点干脆再套个函数。如果单片机主频比较快,也非常方便加一些延时,比如每次操作引脚之后,都要延时10us。
后面再调用这个W_SCL,参数给1或0就可以释放或拉低SCL了。
对于STM32F1系列,这里即使不加任何延时,这个引脚翻转速度,MPU6050也能跟得上。但是保险起见,还是延时个十微秒。
如果要把这个程序移植到别的单片机,就可以把这个函数里的操作替换为其他单片机对应的操作。比如SCL是51单片机的P10口,就可以把GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);这句替换为P10=BitValue。
操作SDA的函数
接下来封装一下操作SDA的函数:
读和写不是同一个寄存器,再定义一个函数
有了这三个函数的分装,我们就实现了函数名称、端口号的替换。同时也可以很方便的修改时序的延时。当我们需要替换端口,或者把这个程序移植到别的单片机中时,就只需要对这前四个函数里的操作对应更改。
我们回到这个函数,开始调用以上四个函数。
我们需要先把SCL和SDA都释放,也就是都输出1,然后先拉低SDA。再拉低SCL,这就是起始条件的执行逻辑。
📌📌注意:我们最好把释放SDA的放在前面。
如果起始条件之前,SCL和SDA已经是高电平了,先释放哪一个是一样的效果。
📌📌但是后面start还要兼容这里的重复起始条件sr。
如果sr最开始SCL是低电平,SDA电平不敢确定,所以保险起见,趁SCL是低电平时,先确保释放SDA再释放SCL,这时SDA和SCL都是高电平。然后再拉低SDA拉低SCL,这样start就可以兼容起始条件和重复起始条件了。
接下来继续终止条件
终止条件
终止条件:SCL高电平期间,SDA从低电平切换到高电平
果stop开始时SCL和SDA都已经是低电平了,就先释放SCL,再释放SDA就行了。但是在这个时序单元开始时,SDA并不一定是低电平。
所以为了确保之后释放SDA,能产生上升沿,我们要在时序单元开始时先拉低SDA,然后再释放SCL,释放SDA。
然后是发送一个字节
发送一个字节
发送一个字节:SCL低电平期间,主机将数据位依次放到SDA线上(高位先行),然后释放SCL,从机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可发送一个字节
实际上除了终止条件SCL以高电平结束,所有的单元我们都会保证SCL以低电平结束,这样方便各个单元的拼接。
SCL低电平变换数据,高电平保持数据稳定,由于是高位先行,所以变换数据的时候,按照先放最高位再放次高位,依次把一个字节的每一位放在SDA线上,每放完一位后执行释放SCL拉低SCL的操作,驱动时钟运转。
Byte & 0x80 就是保留字节的高位,对其他位清0,假设Byte是xxxx xxxxx
由于调用的这个函数中的参数最后会被强制转换成bitAction类型,所以非0即1,所以最终MyI2C_W_SDA(Byte & (0x80 >> i))也相当于传了一个1
接着继续写接收一个字节
接收一个字节
接收一个字节:SCL低电平期间,从机将数据位依次放到SDA线上(高位先行),然后释放SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节(主机在接收之前,需要释放SDA)
主机需要先释放SDA,释放SDA也相当于切换为输入模式。
SCL低电平变换数据,高电平读取数据,实际上就是一种读写分离的设计,低电平时间定义为写的时间,高电平时间定义为读的时间。
SCL高电平时,SDA下降沿为起始条件,SDA上升沿为终止条件。这个设计也保证了起始和终止的特异性,能够让我们在连续不断的波形中快速的定位起始和终止。因为起始终止和数据传输的波形有本质区别。数据传输时SCL高电平不许动SDA,起始终止条件下是SCL高电平必须动SDA
📌📌注意:I2C是在进行通信,通信是有从机的,当主机不断驱动SCL时钟时,从机就有义务去改变SDA的电平。所以主机每次循环读取SDA的时候,这个读取到的数据是从机控制的,这个数据也正是从机想要给我们发送的数据。
发送应答
然后发送应答和接收应答只要复制发送一个字节和接收一个字节的函数修改一下就可以了。
发送应答:主机在接收完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答
接收应答
接收应答:主机在发送完一个字节之后,在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答(主机在接收之前,需要释放SDA)
📌📌注意:I2C的引脚都是开漏输出+弱上拉的配置。主机输出1并不是强制SDA为高电平,而是释放SDA;I2C通信时,主机释放了SDA,从机在此时把SDA再拉低的。所以这里即使之前主机把SDA置1再读取SDA,读到的值也可能是0,读到0代表从机给了应答,读到1代表从机没给应答。
测试应答功能
想要测试应答功能时主函数可以这样调用
这样就可以测试从机给不给应答的时序
MyI2C.c
#include "stm32f10x.h" // Device header
#include "MyI2C.h"
#include "Delay.h"#define I2C_SCL_GPIO_PIN GPIO_Pin_0
#define I2C_SDA_GPIO_PIN GPIO_Pin_1
#define I2C_GPIO GPIOAvoid MyI2C_Init(void)
{RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);GPIO_InitTypeDef gpioInitStructure;gpioInitStructure.GPIO_Mode = GPIO_Mode_Out_PP;gpioInitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;gpioInitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &gpioInitStructure);//初始化为输出高电平GPIO_SetBits(I2C_GPIO, I2C_SCL_GPIO_PIN);GPIO_SetBits(I2C_GPIO, I2C_SCL_GPIO_PIN);
}void MyI2C_W_SCL(uint8_t bitValue)
{GPIO_WriteBit(I2C_GPIO, I2C_SCL_GPIO_PIN, (BitAction)bitValue);Delay_us(10); //延时10us,防止翻转过快超过I2C速率
}void MyI2C_W_SDA(uint8_t value)
{GPIO_WriteBit(I2C_GPIO, I2C_SDA_GPIO_PIN, (BitAction)value);Delay_us(10); //延时10us,防止翻转过快超过I2C速率
}uint8_t MyI2C_R_SDA(void)
{uint8_t bitValue;bitValue = GPIO_ReadInputDataBit(I2C_GPIO, I2C_SDA_GPIO_PIN);Delay_us(10); //延时10us,防止翻转过快超过I2C速率return bitValue;
}void MyI2C_Start(void)
{MyI2C_W_SDA(1); //先拉高SDA总线MyI2C_W_SCL(1); //再拉高SCL总线MyI2C_W_SDA(0); //SCL为高电平时,SDA从从高到低表示起始条件MyI2C_W_SCL(0); //SCL拉低为低电平
}void MyI2C_Stop(void)
{MyI2C_W_SCL(0); //SCL拉低MyI2C_W_SDA(0); //SDA拉低MyI2C_W_SCL(1); //SCL总线拉为高电平MyI2C_W_SDA(1); //SCL为高电平时,SDA从低到高表示结束条件
}void MyI2C_Send_Ack(void)
{MyI2C_W_SDA(0); //SDA拉低,发送ACKMyI2C_W_SCL(1); //SCL拉高,通知从机读取数据MyI2C_W_SCL(0); //SCL拉低
}void MyI2C_Send_NAck(void)
{MyI2C_W_SDA(1); //SDA拉低,发送NACKMyI2C_W_SCL(1); //SCL拉高,通知从机读取数据MyI2C_W_SCL(0); //SCL拉低
}uint8_t MyI2C_Recv_Ack(void)
{uint8_t bitValue;MyI2C_W_SDA(1); //主机SDA释放总线MyI2C_W_SCL(1); //SCL拉高bitValue = MyI2C_R_SDA(); //读取SDA总线MyI2C_W_SCL(0); //SCL拉低return bitValue;
}void MyI2C_Send_Byte(uint8_t data)
{for(int i=0; i<8; i++){MyI2C_W_SDA(data & (1<<(8-i-1)));MyI2C_W_SCL(1); //SCL拉高,从机读取SDA总线MyI2C_W_SCL(0); //SCL拉低}
}uint8_t MyI2C_Recv_Byte(void)
{uint8_t data = 0;//首先释放SDA总线,防止对从机造成干扰MyI2C_W_SDA(1);for(int i=0; i<8; i++){MyI2C_W_SCL(1); //拉高SCL总线if(MyI2C_R_SDA()){data |= (1 << (8-i-1)); //高位优先}MyI2C_W_SCL(0); //拉低SCL总线}return data;
}
MyI2C.h
#ifndef __MY_I2C_H__
#define __MY_I2C_H__void MyI2C_Init(void);
void MyI2C_W_SCL(uint8_t bitValue);
void MyI2C_W_SDA(uint8_t value);
uint8_t MyI2C_R_SDA(void);
void MyI2C_Start(void);
void MyI2C_Stop(void);
void MyI2C_Send_Ack(void);
void MyI2C_Send_NAck(void);
uint8_t MyI2C_Recv_Ack(void);
void MyI2C_Send_Byte(uint8_t data);
uint8_t MyI2C_Recv_Byte(void);#endif
Main.c
#include "stm32f10x.h" // Device header
#include "oled.h"
#include "Countersensor.h"
#include "Encoder.h"
#include "Timer.h"
#include "AD.h"
#include "Delay.h"
#include "MyDMA.h"
#include "UART.h"
#include <stdio.h>
#include "Key.h"
#include "String.h"
#include "LED.h"
#include "MyI2C.h"int main(int argc, char *argv[])
{uint8_t AckBit = 0;OLED_Init();uint8_t MP6060_I2CAddr = 0xD2;//Delay_s(2);OLED_ShowString(1, 1, "I2C:");//OLED_ShowHexNum(1, 5, MP6060_I2CAddr, 2); //1101 0000OLED_ShowHexNum(1, 5, 0xD2, 2); //1101 0010MyI2C_Init();MyI2C_Start();MyI2C_Send_Byte(MP6060_I2CAddr);AckBit = MyI2C_Recv_Ack();OLED_ShowHexNum(2, 1, AckBit,2);while(1) {}return 1;
}
1101 000是从机的地址,可以理解为是从机的名字,最低位的0是表示“写入操作”
这样运行后显示从机可以给我们应答
使用淘宝购买的19块钱的24MHz 8通道逻辑分析仪抓取下I2C通信寻址的过程,可以看到I2C寻址MP6050的 I2C Addr成功,收到的 ACK应答。
我们接下来讲一下通过AD0引脚改名的功能。
通过AD0引脚改名的功能
我们可以把一根飞线连接AD0引脚和VCC, 这时MPU6050的从机地址就是1101 001了。
这个时候运行就发现从机没有给我们应答了,因为它刚刚改名成1101 001了。
使用淘宝购买的19块钱的24MHz 8通道逻辑分析仪抓取下I2C通信寻址的过程,可以看到I2C寻址MP6050的 I2C Addr成功,收到的 NACK应答。因为它刚刚改名成1101 001了。
这个时候把飞线拔掉,再次运行发现它又可以应答了。
这就是改名的实验现象。目前我们这个芯片只有AD0一个引脚,它就只能拥有两个名字。如果有AD0和AD1两个引脚,就可以拥有总共四个名字。如果有更多的可配置引脚,就有更多的改名机会。当我们需要一条总件挂载多个相同型号的设备时,就可以利用这个改名的功能,避免名字也就是从机地址的重复。
再次把飞线插上,然后修成程序使用“1101 001了”寻址一下,确认下是否可以I2C地址寻址成功。使用淘宝购买的19块钱的24MHz 8通道逻辑分析仪抓取I2C通信的时序,可以看到已经收到了I2C从机MP6050的ACK应答。
接下来我们就继续来写建立在myI2C这一模块之上的MPU6050模块
3.🚚MPU6050.c
先初始化MPU6050
模拟指定地址写和指定地址读的时序
然后封装指定地址写和指定地址读的时序。
以上代码跟我们上节讲的指定地址写的这个时序是一样的,可以对照一下每一句代码:
补充:如果想要指定地址写多个字节就用一个for循环将这两句代码框起来多执行几遍
同理,我们按照指定地址读一个字节的时序来完成读的函数
同理,如果想要指定读取多个字节就可以将这两句代码用for循环框起来多执行几遍:
⚠️⚠️⚠️但是要注意,读取最后一个字节给非应答,这之前都要给应答。
在这里指定句子读一个字节的时序就完成了,我们就可以进一步来进行测试一下。
以这个MPU6050的这个寄存器为例,这个寄存器是只读寄存器,它的地址是0x75,内容是ID号,默认是0x68
头文件大家都会自己声明了,这个就不用说了,以后都略过
在主函数里可以这样调用:
MP6050.c
#include "stm32f10x.h" // Device header
#include "MP6050.h"
#include "MyI2C.h"
#include "Delay.h"#define MP6050_I2CADDR (0x68 << 1)
#define MP6050_I2CRead_DIR 0x01
#define MP6050_I2CWrit_DIR 0x00void MP6050_Init(void)
{MyI2C_Init();
}void MP6050_WriteReg(uint8_t RegAddr, uint8_t data)
{MyI2C_Start();MyI2C_Send_Byte(MP6050_I2CADDR); //发送I2C从机地址MyI2C_Recv_Ack(); //接收应答MyI2C_Send_Byte(RegAddr); //发送寄存器地址MyI2C_Recv_Ack(); //接收应答MyI2C_Send_Byte(data); //发送写数据MyI2C_Recv_Ack(); //接收应答MyI2C_Stop();
}uint8_t MP6050_ReadReg(uint8_t RegAddr)
{uint8_t data;MyI2C_Start(); //发送StartMyI2C_Send_Byte(MP6050_I2CADDR); //发送I2C从机地址,写MyI2C_Recv_Ack(); //接收应答MyI2C_Send_Byte(RegAddr); //发送寄存器地址MyI2C_Recv_Ack(); //接收应答MyI2C_Start(); //发送ReStartMyI2C_Send_Byte(MP6050_I2CADDR | MP6050_I2CRead_DIR); //发送I2C从机地址,读MyI2C_Recv_Ack(); //接收应答data = MyI2C_Recv_Byte(); //接收数据MyI2C_Send_NAck(); //发送NACK非应答MyI2C_Stop();return data;
}
MP6050.h
#ifndef __MP6050_H__
#define __MP6050_H__uint8_t MP6050_ReadReg(uint8_t RegAddr);
void MP6050_WriteReg(uint8_t RegAddr, uint8_t data);
void MP6050_Init(void);#endif
Main.c
#include "stm32f10x.h" // Device header
#include "oled.h"
#include "Countersensor.h"
#include "Encoder.h"
#include "Timer.h"
#include "AD.h"
#include "Delay.h"
#include "MyDMA.h"
#include "UART.h"
#include <stdio.h>
#include "Key.h"
#include "String.h"
#include "LED.h"
#include "MyI2C.h"
#include "MP6050.h"#define MP6050_REG_ID 0x75int main(int argc, char *argv[])
{uint8_t AckBit = 0;uint8_t MP6050Id;OLED_Init();MP6050_Init();OLED_ShowString(1, 1, "MP6060 ID:");MP6050Id = MP6050_ReadReg(MP6050_REG_ID);OLED_ShowHexNum(2, 1, MP6050Id, 2);while(1) {}return 1;
}
可以看到读出的ID号是0x68,这说明我们指定地址读一个字节的时序没问题。
使用淘宝购买的19块钱的24MHz 8通道逻辑分析仪抓取I2C通信的时序