keil配置与芯片包下载

固件库文件

以F103固件库为例:

STM32F10x_StdPeriph_Lib_V3.5.0固件库是为STM32F1系列微控制器设计的开发辅助库,主要用于简化对STM32F1外设的控制和配置。该库封装了对各类外设(如GPIO、ADC、USART、SPI、I2C等)的控制接口,使开发者无需直接操作复杂的寄存器就能使用这些硬件外设。通过它,开发者可以轻松地进行外设的初始化、配置和数据处理。

主要结构

  • _htmresc:图片没什么用
  • Libraries:库函数的文件,我们之后建工程时会用
    • CMSIS:包含Cortex-Mx内核的相关定义和启动代码。
      • CoreSupport:包含了与Cortex-M系列内核相关的文件,主要是一些适用于ARM Cortex-M内核的通用代码和数据结构,用于管理核心处理器功能。
      • DeviceSupport:含与特定厂商的微控制器设备相关的文件,特别是该设备特定的外设寄存器定义和访问方法。它提供了Cortex-M内核之外的硬件支持,适用于具体的MCU型号。
    • STM32F10x_StdPeriph_Driver标准外设驱动库,包含STM32F1系列的外设驱动源文件和头文件。
  • Projects:是官方提供的工程示例和模板,使用库函数时可以参考
  • Utilities:是STM32官方评估板的相关例程,这个评估版就是官方用STM32做的一个小电路板用来测评STM32的,文件夹内存放的就是小电路板的测评程序
  • Release_Notes.html:这个是库函数的发布文档
  • stm32f10x_stdperiph_lib_um.chm:使用手册,教大家如何使用库函数

keil新建工程

  1. 新建一个 new μVision Project,设置名称后,会出现选择芯片,如果只有ARM,则需要将官方的芯片包(在官方下载.pack文件)导入。

最后会弹出来Manage Run-time Environment的界面,manage run-time environment是一个新建工程的小助手也有固件库,可以帮我快速建立工程,直接勾选即可,是keil5的新功能**。今天我们创建工程的方式是自己去搬运官网给的库,加深一下理解和增加动手能力,所以没有使用manage run-time environment来添加库,直接点击OK即可**

此时就只有一个Target里面什么都没有,我们需要给它添加一点工程的必要文件。

此时我们可以看到刚刚创建的项目文件夹自动生成一堆文件夹,为了便于管理我们先在工程文件夹中创建一个Startup文件夹用于存放启动文件

  1. 此时打开固件库文件夹->“STM32F10x_StdPeriph_Lib_V3.5.0”-“Libraries”-“CMSIS”-“CM3”-“DeviceSupport”-“ST”-“STM32F10x”-“startup”,然后选中全部一起复制到刚刚我们创建的Startup文件夹中。

这些就是STM32的启动文件,STM32程序就是从这些启动文件开始执行的

  1. “Libraries”-“CMSIS”-“CM3”-“DeviceSupport”-“ST”-“STM32F10x,我们把这三个文件也复制下来粘贴到Startup文件夹下

我们可以看到stm32f10x.h和两个system开头的文件

stm32f10x.h:是STM32的外设寄存器描述文件,用来描述STM32有哪些寄存器和它对应的地址。

system文件:这个两个system文件(system_stm32f10x.c/h)用于配置时钟,STM32F103主频72MHz就是在system文件里配置的。


接下来,因为STM32是内核和内核外围设备(外设)组成的,而且内核的寄存器描述和外围设备的描述文件不是在一起的,所以我们还需要添加内核寄存器的描述文件

  1. 进入“STM32F10x_StdPeriph_Lib_V3.5.0”-“Libraries”-“CMSIS”-“CM3”-“CoreSupport”。然后也把这两个相关内核相关的文件复制到Startup文件后所有的准备完成

里面有两个cm3(core_cm3.c和core_cm3.h),这两个Cortex-M3文件就是内核的寄存器描述文件,还有一些内核配置函数

  1. 返回keil中将刚刚的文件添加到工程中,在Target 1中add group然后将新建的文件夹改名为Startup,将刚刚所有的Startup文件夹中的文件添加进去。
  1. 首先是启动文件的添加,有一堆startup文件,我们选择其中的一个startup_stm32f10x_md.s添加到工程中。

    有关启动文件的选择,请看下面的笔记

  2. 然后剩下的所有.c和.h文件都要添加进去

我们可以按住Ctrl键,然后依次选择他们,点击Add,Close即可

这里的文件都是STM32里最基本的文件,是不需要我们修改的,添加到工程即可,此时可以看到这些文件都带有钥匙图标,代表只读文件

  1. 点击魔术棒,打开工程选项,在C/C++中找到include Paths,添加Startup文件夹的路径至其中。

最后我们还要在工程选项里添加上该文件夹的头文件路径,否则找不到.h文件

  1. 回到该项目的文件夹下,新建一个User文件夹,main函数就放在其中。再回到keil中,在Target添加一个组,改名为User,对其右键创建main.c到其中,此时在main.c中创建我们的main函数,进行开发即可。

由于此时工程还没有添加STM32的库函数,所以是基于寄存器开发工程,如果想要使用寄存器开发那么到这里就可以结束了


接下来继续添加库函数

  1. 打开项目文件夹,新建Library文件夹用于存放库函数,接着打开固件库的文件夹,“STM32F10x_StdPeriph_Lib_V3.5.0”-“Libraries”-“STM32F10x_StdPeriph_Driver”-“src”,全选复制粘贴到Library中去,然后再回到Inc中去,再次将头文件全部复制粘贴到Library中去。
  1. 其中misc.c是内核的库函数

  2. 其他的就是外设库函数

  1. 回到keil中,在Target下添加一个组命名为Library,再将Library文件夹中的所有文件添加到工程中去,但是此时的库函数还不能直接使用,我们还需要在添加文件。继续打开固件库文“STM32F10x_StdPeriph_Lib_V3.5.0”-“Project”-“STM32F10x_StdPeriph_Template”中,把一个conf.h文件和两个it中断文件复制粘贴到User文件下,回到keil中将这三个文件添加到User的组中

我们可以看到一个stm32f10x_conf.h的文件,这个config文件是用来配置库函数头文件包含关系,以及用来参数检查的函数定义,这是所有库函数都需要的

两个stm32f10x_it.c/h文件是用来存放中断函数的

  1. 最后我们还需要一个宏定义USE_STDPERIPH_DRIVER,我们打开b,切换到C/C++中,在Define中添加"USE_STDPERIPH_DRIVER",最后别忘了在下方的include Paths再将User和Library目录的路径添加进去。

我们可以在stm32f10x.h文件中的最下方看到有一段

#ifdef USE_STDPERIPH_DRIVER
#include “stm32f10x_conf.h”
#endif

这个的意思是如果定义了USE_STDPERIPH_DRIVER(使用标准外设驱动)这个宏定义,才会包含stm32f10x_conf.h,即才会生效。

由于stm32f10x_conf.h文件包含了所有库函数的头文件,所以我们只需要include stm32f10x.h文件就可以任意调用库函数了

最终我们的基本模板为:

  • DebugConfig
  • Listings
  • Objects
  • Library
  • Startup
  • User

前三个为创建项目后自动生成的文件夹,后三个为我们手动创建的。

一定记得将所有带有头文件的目录添加到C/C++的include Paths中以便于编译器能够找到头文件

启动文件的选择

我们在新建工程向Startup文件夹添加启动文件的时候,有一堆startup文件。当时我们选择其中的一个startup_stm32f10x_md.s添加到工程中。

现在来解释一下这个文件怎么选取:

启动文件有很多类型,至于选择哪一个,我们要根据芯片的型号来选择

看这张表:

缩写 翻译 FLASH容量 型号

LD(High Density) 小容量产品 16-32K STM32F101/102/103

MD(Middle Density) 中容量产品 64-128K STM32F101/102/103

HD(High Density) 大容量产品 256-512K STM32F101/102/103

XL(Extra Large) 加大容量产品 大于512K STM32F101/102/103

CL 互联网产品 - STMF105/107

LD_VL(value line) 小容量产品超值系列 16-32K STM32F100

MD_V 中容量产品超值系列 64-128K STM32F100

HD_VL 大容量产品超值系列 256-512K STM32F100

  1. 先根据型号选择是哪个系列的启动文件
  2. 根据Flash容量选择对应的启动文件添加到Startup即可

stm32f10x.h

我们可以在stm32f10x.h文件中的最下方看到有一段

#ifdef USE_STDPERIPH_DRIVER
#include “stm32f10x_conf.h”
#endif

这个的意思是如果定义了USE_STDPERIPH_DRIVER(使用标准外设驱动)这个宏定义,才会包含stm32f10x_conf.h,即才会生效。

由于stm32f10x_conf.h文件包含了所有库函数的头文件,所以我们只需要在编程的时候include stm32f10x.h文件就可以任意调用库函数了

新建工程步骤总结

  1. 建立工程文件夹,Keil中新建工程、选择型号
  2. 工程文件夹中建立Startup、Library、User等文件夹,复制固件库里面的文件到工程文件夹
  3. 工程里对应建立Start、Library、User等同名称的分组,并将文件夹内的文件添加到工程分组里
  4. 工程选项(魔法棒),C/C++ Include Paths内添加所有包含头文件的文件夹
  5. 工程选项(魔法棒),C/C++,Define中定义宏定义USE_STDPERIPH_DRIVER
  6. 工程选项(魔法棒),Debug,下拉列表选择对应调试器,Settings,Flash,Download里勾选Reset and Run

模块化编程

如果我们把所有的初始化代码都写到main函数中就会显得很杂乱,为此我们单独创建一个Hardware文件夹用于存放各外设驱动(LED.c,LED.h,Key.c,Key.h等)

  1. 在项目文件夹下创一个Hartware文件夹
  2. 回到keil中添加一个名为Hardware的组,然后添加或创建对应外设驱动的文件。
  3. 将Hardware添加到魔法棒中的C/C++的include Paths中

最终我们的基本模板为:

  • DebugConfig
  • Listings
  • Objects
  • Library
  • Startup
  • Hardware
  • User

前三个为创建项目后自动生成的文件夹,后四个个为我们手动创建的。

例如在Hardware里的,LED驱动函数就是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef __LED_H
#define __LED_H

void LED_Init();


void LED1_ON();
void LED1_OFF();

void LED2_ON();
void LED2_OFF();

void LED1_Turn();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include "stm32f10x.h"                  // Device header
#include "LED.h"

void LED_Init()
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;

GPIO_Init(GPIOA,&GPIO_InitStructure);

GPIO_SetBits(GPIOA,GPIO_Pin_6 | GPIO_Pin_7);

}


void LED1_ON()
{
GPIO_ResetBits(GPIOC,GPIO_Pin_2);
}

void LED1_Turn()
{

if(GPIO_ReadOutputDataBit(GPIOC,GPIO_Pin_2)==0)
{
GPIO_SetBits(GPIOC,GPIO_Pin_2);
}
else
{
GPIO_ResetBits(GPIOC,GPIO_Pin_2);
}

}

所有的外设都可以这样独立写成驱动函数,初始化函数等。这样使的项目更好管理

STM32启动文件

startup_stm32xx.s就是启动文件,这是一个用汇编写的文件,定义了中断向量表和中断服务函数等。启动有个复位中断是程序的入口,当stm32按下复位或者上电的时候,程序就会进入复位中断函数执行,复位中断函数做的就是调用SystemInit函数和调用main函数。

我们可以在启动文件文件的注释中知道流程为:

  1. 初始化堆栈指针SP
  2. 初始化程序计数器PC为Reset_Handler
  3. 初始化堆、栈的大小
  4. 设置中断向量表的入口地址

转向Reset_Handler执行:

  1. 调用SystemInit()函数完成系统初始化(系统时钟、闪存接口配置等)
  2. 设置C库的分支入口为 __main(调用我们的main函数)

GPIO

  • GPIO(General Purpose Input Output) 通用输入输出端口,可配置共8种输入输出模式。

  • 引脚电平位0~3.3V,部分引脚可以容忍5V

  • 输出模式下可控制端口输出高低电平,用于驱动LED,控制蜂鸣器,模拟通信协议输出时序

  • 输入模式下可读取端口的高低电平或电压,用于读取按键输入、外接模块电平信号输入、ADC电压采集、模拟通信协议接收数据等

STM32-GPIO介绍_stm32 gpio-CSDN博客

上、下拉电阻(定义、强弱上拉、常见作用、吸电流、拉电流、灌电流)_弱上拉和强上拉的区别-CSDN博客

操作stm32的GPIO分为三个步骤

  1. 使用RCC开启GPIO时钟
  2. 使用GPIO_Init(库函数)函数初始化GPIO
  3. 使用输出或输入函数控制GPIO口

RCC开启时钟

在stm32f10x_rcc.h中,有很多RCC相关函数但是我们最常用的是这三个:

1
2
3
4
5
6
void RCC_AHBPeriphClockCmd(uint32_t RCC_AHBPeriph, FunctionalState NewState);
/*RCC AHB总线外设时钟控制*/
void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph, FunctionalState NewState);
/*RCC APB2总线外设时钟控制*/
void RCC_APB1PeriphClockCmd(uint32_t RCC_APB1Periph, FunctionalState NewState);
/*RCC APB1总线外设时钟控制*/

我们可以跳到这些函数的定义查看注释我们可以知道,这些时钟控制函数就是使能或失能外设时钟的

参数1:选择外设

参数2:使能或失能

如果不知道该外设是否在这个总线上,我们可以在注释上面的列表看,如果出现了代表在这个总线上

GPIO标准库函数

在stm32f10x_gpio.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void GPIO_DeInit(GPIO_TypeDef* GPIOx);
/*指定GPIO外设被复位*/
void GPIO_AFIODeInit(void);
/*指定AFIO复位*/

void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct);
/*初始化外设,为指定的GPIO初始化,使用的是我们自己创建的结构体*/
void GPIO_StructInit(GPIO_InitTypeDef* GPIO_InitStruct);
/*指定结构体赋值*/

uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);
uint8_t GPIO_ReadOutputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadOutputData(GPIO_TypeDef* GPIOx);
/*GPIO读取函数*/

void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);//指定端口拉高
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);//指定端口拉低
void GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal);//端口写入指定值:Bit_SET或Bit_RESET
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);
/*读写GPIO函数*/
  • 对应GPIO的结构体定义如下:
1
2
3
4
5
6
7
8
9
10
11
typedef struct
{
uint16_t GPIO_Pin; /*!< Specifies the GPIO pins to be configured.
This parameter can be any value of @ref GPIO_pins_define */

GPIOSpeed_TypeDef GPIO_Speed; /*!< Specifies the speed for the selected pins.
This parameter can be a value of @ref GPIOSpeed_TypeDef */

GPIOMode_TypeDef GPIO_Mode; /*!< Specifies the operating mode for the selected pins.
This parameter can be a value of @ref GPIOMode_TypeDef */
}GPIO_InitTypeDef;

标准库的GPIO_InitTypeDef结构体参数只有三个:mode、pin、speed

这些跳转到对应定义可以知道值

HAL库有5个参数,对比起来标准库更简单了

具体流程代码

假设我们需要点亮PC2的LED,查看手册后发现挂载再APB2总线上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main()
{
/*1.开启对应外设时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC,ENABLE);

/*2. 初始化GPIOC*/
GPIO_InitTypeDef GPIO_InitStructure;
//定义GPIO_InitStructure结构体,三个参数赋值
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
//调用GPIO_Init初始化对应GPIO口,函数中读取结构体自动配置写入到对应寄存器
GPIO_Init(GPIOC,&GPIO_InitStructure);


while(1)
{

}
}

实验-LED流水灯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "stm32f10x.h"                  // Device header
#include "Delay.h"

int main(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC,ENABLE);

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC,&GPIO_InitStructure);


while(1)
{
GPIO_WriteBit(GPIOC,GPIO_Pin_2,Bit_RESET);
Delay_ms(500);
GPIO_WriteBit(GPIOC,GPIO_Pin_2,Bit_SET);
Delay_ms(500);
GPIO_WriteBit(GPIOC,GPIO_Pin_3,Bit_RESET);
Delay_ms(500);
GPIO_WriteBit(GPIOC,GPIO_Pin_3,Bit_SET);

/*使用Write函数同时操控多个端口*/
// GPIO_Write(GPIOC,~0x0004);
// Delay_ms(500);
// GPIO_Write(GPIOC,0x0004);
// Delay_ms(500);
// GPIO_Write(GPIOC,~0x0008);
// Delay_ms(500);
// GPIO_Write(GPIOC,0x0008);
// Delay_ms(500);

}

}

当有多个引脚时,我们可以使用按位或的方式同时选中多个Pin

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3 | …; 选中了Pin2和Pin3

我们来看对应定义:

#define GPIO_Pin_0 ((uint16_t)0x0001)
#define GPIO_Pin_1 ((uint16_t)0x0002)

#define GPIO_Pin_3 ((uint16_t)0x0008)

#define GPIO_Pin_15 ((uint16_t)0x8000)

#define GPIO_Pin_All ((uint16_t)0xFFFF)

一共16位,每一个引脚对应一个位,只需要使用按位或的操作既可以选中指定端口

知道这个后我们可以使用GPIO_Write函数同时操控多个端口:

1
2
3
4
5
6
7
8
9
10
11
12
while(1)
{
/*由于C语言不支持写2进制,故使用16进制来写*/
GPIO_Write(GPIOC,~0x0004);//~(0000 0000 0000 0100),pin2亮
Delay_ms(500);
GPIO_Write(GPIOC,0x0004);
Delay_ms(500);
GPIO_Write(GPIOC,~0x0008);//~(0000 0000 0000 1000),pin3亮
Delay_ms(500);
GPIO_Write(GPIOC,0x0008);
Delay_ms(500);
}
  • 使用Systick实现的延时函数,直接使用即可,使用时创建一个System文件夹,并把他们放到System文件夹下,在keil创建System组即可
1
2
3
4
5
6
7
8
9
10
/*Delay.h*/
#ifndef __DELAY_H
#define __DELAY_H

void Delay_us(uint32_t us);
void Delay_ms(uint32_t ms);
void Delay_s(uint32_t s);

#endif

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/*Delay.c*/
#include "stm32f10x.h"

/**
* @brief 微秒级延时
* @param xus 延时时长,范围:0~233015
* @retval 无
*/
void Delay_us(uint32_t xus)
{
SysTick->LOAD = 72 * xus; //设置定时器重装值
SysTick->VAL = 0x00; //清空当前计数值
SysTick->CTRL = 0x00000005; //设置时钟源为HCLK,启动定时器
while(!(SysTick->CTRL & 0x00010000)); //等待计数到0
SysTick->CTRL = 0x00000004; //关闭定时器
}

/**
* @brief 毫秒级延时
* @param xms 延时时长,范围:0~4294967295
* @retval 无
*/
void Delay_ms(uint32_t xms)
{
while(xms--)
{
Delay_us(1000);
}
}

/**
* @brief 秒级延时
* @param xs 延时时长,范围:0~4294967295
* @retval 无
*/
void Delay_s(uint32_t xs)
{
while(xs--)
{
Delay_ms(1000);
}
}

输入模式

GPIO相关寄存器

查看芯片对应参考手册,结合对应代码可以得知如何使用GPIO相关寄存器

引脚重映射(复用功能重映像)

参考手册中,有一节复用功能I/O和调试配置(AFIO),这一章节专门就是讲的引脚复用重映像功能,手册描述如下:

为了优化64脚或100脚封装的外设数目,可以把一些复用功能重新映射到其他引脚上。设置复用重映射和调试I/O配置寄存器(AFIO_MAPR)实现引脚的重新映射。这时,复用功能不再映射到它们的原始分配

如果多个外设需要使用同一组引脚,默认引脚的配置可能会导致冲突。引脚重映像允许开发者重新分配功能到其他引脚,避免资源冲突。
实际意义:
在复杂系统中,可以高效利用芯片的引脚资源,而不用为了冲突放弃某些外设功能。

例如:

  • 需要同时使用USART1SPI1,但两者默认引脚有重叠。
  • 通过重映像将USART1PA9/PA10移到PB6/PB7,释放PA9/PA10供其他功能使用。
  • 这样我们就可以同时使用USART1SPI1

对应库函数

1
2
3
4
5
6
void GPIO_PinRemapConfig(uint32_t GPIO_Remap, FunctionalState NewState)

/**
* 参数1:对应外设重映射方式,一般是部分重映像或者完全重映像,具体查看对应手册
* 参数2:是否是能
*/

使用方式

1
2
3
4
5
6
7
8
9
10
void PWM_Init()
{
/*1.开启AFIO时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE);
/*2.选择对应外设的映射,映射方式即可*/
GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2,ENABLE);

//GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable,ENABLE);
/*接触JTAG复用,以便于TIM2能够重映射/
}

注意使用端口映射前,可能有些端口已经被占用了,比如调试端口JTAG,如果重映射使用的是调试端口,那么使用前需要先解除JTAG端口复用

EXTI(外部中断)

介绍

EXTI可以监测指定的GPIO口的电平信号,当其指定的GPIO口产生电平变化时,EXTI将立即向NVIC发出中断申请,经过NVIC裁决后即可中断CPU主程序,使CPU执行EXTI对应的中断程序。

支持的触发方式:上升沿/下降沿/双边沿/软件触发

支持的GPIO口:所有GPIO口,但相同的Pin不能同时触发中断(如:PA1与PB1与PC1之间)

通道数:16个GPIO_Pin,外加PVD输出、RTC闹钟等

触发响应的方式中断响应/事件响应

  • 中断响应:正常的引脚电平变化触发中断
  • 事件响应:不会触发中断,而是触发别的外设操作,属于外设之间的联合工作

库函数

相关的EXTI库函数去stm32f10x_exti.h中查看,这里直接使

初始化配置流程

只需要从GPIO到NVIC这一路出现的外设模块配置好即可,一共5步

image-20241117144622580

  1. 配置RCC,打开相关的外设时钟。这里涉及到的是GPIO、AFIO、EXTI、NVIC的时钟,但由于EXTI和NVIC时钟(内核外设不需要开启时钟)一直都是打开的,不需要我们开启,所以只需要开启GPIO和AFIO时钟即可。
1
2
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);//开启GPIOA时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE);//开启对应AFIO时钟
  1. 配置GPIO,配置我们的端口为输入模式
1
2
3
4
5
6
7
8
9
	
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC,ENABLE);

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC,&GPIO_InitStructure);

在我们的手册中有对应外设每个引脚推荐配置模式,我们可以找到EXTI推荐配置为:浮空,上拉,下拉

  1. 配置AFIO 通过 AFIO 外设将 PC13 引脚映射到 EXTI 外设上,以便用于外部中断
1
2
3
4
5
6
7
8
/*GPIO_EXTILineConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource)
*
* 参数1:使用哪个GPIO作为外部中断源,GPIO_PortSourceGPIOx where x can be (A..G).
* 参数2:指定需要配置的外部中断线, GPIO_PinSourcex where x can be (0..15).
*/

/*这里配置AFIO只需要这个函数即可*/
GPIO_EXTILineConfig(GPIO_PortSourceGPIOC,GPIO_PinSource13);

没有单独写AFIO的库函数,与GPIO库函数放在一起的,可以去gpio的库函数中找这个函数查看对应参数。虽然写的是GPIO,但是我们查看该函数定义可以发现里面操作的是AFIO的寄存器

  1. 配置EXTI,选择边沿触发方式,比如上升沿、双边沿等,还有触发响应方式,可以选择中断响应和事件响应。
1
2
3
4
5
6
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line= EXTI_Line13;//对应选择的外部中断线
EXTI_InitStructure.EXTI_LineCmd= ENABLE; // 使能或不使能
EXTI_InitStructure.EXTI_Mode= EXTI_Mode_Interrupt ; //两种模式,一种是中断响应,一种是事件响应
EXTI_InitStructure.EXTI_Trigger= EXTI_Trigger_Falling; // 触发方式
EXTI_Init(&EXTI_InitStructure);

相关的EXTI库函数去stm32f10x_exti.h中查看,这里直接使用

  1. 配置NVIC,给我们的中断选择一个合适的优先级
1
2
3
4
5
6
7
8
9
10
11
12
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);//优先级配置

/*
NVIC_PriorityGroupConfig()分组方式整个芯片只能只能用一种,按理来说这个分组的代码整个工程只需要执行一次即可。如果把这个函数放到模块里面进行分组,一定要确保每个模块分组都选的是同一个。也可以把这个代码放在主函数的开始,这样就不用每个模块分组
*/

NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; // 使能
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2 ; //指定抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2 ; //指定子优先级
NVIC_Init(&NVIC_InitStructure);

因为NVIC是内核外设,库函数被分配到了misc.h/c(杂项)文件中去了,在这里查找对应参数,对应函数用法。

NVIC_InitTypeDef 中查看注释我们可以知道IRQ需要到stm32f10x.h去找,我们要选择对应芯片的选择编译,这里我们是stm32f10x_MD

在里面的选择编译中,我们找到了EXTI15_10_IRQn = 40 (stm32的EXTI10到15都是合并到了这个通道里)

所以我们定义为EXTI15_10_IRQn即可


注意:配置NVIC时,NVIC_IRQChannel只能接受一个中断通道号,不能接收多个中断通道的组合,如果有多个中断通道配置需要配置多次

1
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn | EXTI0_IRQn;

这样配置不会报错,但是两个中断都不会生效!!!

需要单独调用两次NVIC_Init初始化


对应的设置抢占优先级和子优先级设置我们可以跳转到对应定义处,继续跳转到注释中提到的中断优先级对应的优先级表查看即可

只有两个按键配置流程连起来就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void Key_Init()
{

/*1.配置RCC启用对应外设时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE);
/*2.配置GPIO*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13|GPIO_Pin_12;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC,&GPIO_InitStructure);

/*3.配置AFIO*/ GPIO_EXTILineConfig(GPIO_PortSourceGPIOC,GPIO_PinSource12); GPIO_EXTILineConfig(GPIO_PortSourceGPIOC,GPIO_PinSource13);
/*4.配置EXTI*/
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line= EXTI_Line12 | EXTI_Line13;//对应选择的按键外部中断线
EXTI_InitStructure.EXTI_LineCmd= ENABLE; // 使能或不使能
EXTI_InitStructure.EXTI_Mode= EXTI_Mode_Interrupt ; //两种模式,一种是中断,一种是事件
EXTI_InitStructure.EXTI_Trigger= EXTI_Trigger_Falling; // 触发方式为下降沿触发
EXTI_Init(&EXTI_InitStructure);

/*5.配置NVIC*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; // 使能
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2 ; //指定抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2 ; //指定子优先级
NVIC_Init(&NVIC_InitStructure);

}

中断函数

完成了外部中断初始化配置后,接下来就是编写中断函数

在启动文件的中断向量表中找到对应的中断函数的名字,这里是EXTI15_10_IRQHandler

将其从启动文件中复制到对应位置进行编写,名字一定不能错,错了就无法进入了

编写步骤如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void EXTI15_10_IRQHandler()
{
/*1.判断对应中断标志位是否为1(SET),如果是*/
if(EXTI_GetITStatus(EXTI_Line12) == SET)
{
/*2.清除对应中断标志位,否则会一直触发中断*/
EXTI_ClearITPendingBit(EXTI_Line12);

/*3.用户干的事情*/

}

if(EXTI_GetITStatus(EXTI_Line13) == SET)
{
EXTI_ClearITPendingBit(EXTI_Line13);
/*用户干的事情*/
...

}
}
  1. 中断函数的返回值和参数都是void

  2. 此函数不用声明,中断触发自动调用

  3. 使用的相关函数在对应的exti标准库中去找(EXTI_GetITStatus等),有两个获取标志位的函数,带有IT的是只能在中断中使用的,不带IT的是在中断外使用的

实验- 旋转编码器计数

旋转编码器相关知识见HAL库

此处旋转编码器A相对应:PB0B相对应:PB1

1
2
3
4
5
6
7
8
9
10
11
12
/*Encoder.h*/

#ifndef __ENCODER_
#define __ENCODER_

void Encoder_Init(void);

int16_t Encoder_Get();

#endif


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include "stm32f10x.h"                  // Device header

int16_t EncoderCount;//全局变量

void Encoder_Init(void)
{
/* 1.打开对应外设时钟 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE );
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE );//选择AFIO这个外设,开启时钟,通常用于启用或禁用 AFIO 外设的时钟,以便进行相应的配置操作。

/* 2.配置对应GPIO输入模式 */
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode=GPIO_Mode_IPU;//上拉输入,默认为高电平的输入方式
GPIO_InitStructure.GPIO_Pin=GPIO_Pin_0|GPIO_Pin_1;//旋转编码器分两相,PB0 为A相,PB1为B相
GPIO_InitStructure.GPIO_Speed=GPIO_Speed_50MHz;
GPIO_Init (GPIOB ,&GPIO_InitStructure);//初始化GPIOB外设

/* 3.AFIO配置,映射到对应EXTI线上去 */
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB,GPIO_PinSource0);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB,GPIO_PinSource1);


/* 4.EXTI配置 */
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line=EXTI_Line0|EXTI_Line1;//将第0条线路和第1条线路都初始化为中断模式,下降沿触发连线
EXTI_InitStructure.EXTI_LineCmd=ENABLE;//开启中断
EXTI_InitStructure.EXTI_Mode=EXTI_Mode_Interrupt;//中断模式
EXTI_InitStructure.EXTI_Trigger=EXTI_Trigger_Falling;//下降沿触发,离开就+1
EXTI_Init(&EXTI_InitStructure);

/* 5.NVIC配置,这个地方需要两个中断初始化 */
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);//选两位抢占两位响应,整个程序只需要配置一次

NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel=EXTI0_IRQn;//外部中断0
NVIC_InitStructure.NVIC_IRQChannelCmd=ENABLE;/
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority=1;//抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority=1;//响应优先级
NVIC_Init (&NVIC_InitStructure);

NVIC_InitStructure.NVIC_IRQChannel=EXTI1_IRQn;//外部中断1
NVIC_InitStructure.NVIC_IRQChannelCmd=ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority=1;//抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority=2;//响应优先级2大于1
NVIC_Init(&NVIC_InitStructure);
//PB0和PB1为同一抢占优先级,但PB1的响应优先级比PB0大,所以PB0可以被PB1打断
}

int8_t Encoder_Get(void)//获取中断服务函数中改变的数
{
int8_t num;
num=EncoderCount;
return num;
}


void EXTI0_IRQHandler(void)//这个函数只有0这个引脚可以触发
{
if(EXTI_GetFlagStatus (EXTI_Line0)==SET)//外部中断0的线被触发,进入中断
{
if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_0)==0)
{
if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_1)==0)//A下降沿,B低电平,反转
{
EncoderCount --;//计数--
}
}

EXTI_ClearITPendingBit(EXTI_Line0);//清除中断标志位,跳出中断
}
}
void EXTI1_IRQHandler(void)//这个函数只有1这个引脚可以触发
{
if(EXTI_GetFlagStatus (EXTI_Line1)==SET)//外部中断1的线被触发,进入中断
{
if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_1)==0)
{
if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_0)==0)//B下降沿,A低电平正转,
{
EncoderCount ++;//计数++
}
}
EXTI_ClearITPendingBit(EXTI_Line1);//清除中断标志位
}
}

正转编码器,counter增加,反转编码器,counter增加

实验-对射式红外传感器计次

使用中断注意事项

  1. 中断函数中不要执行耗时过长的代码,不要使用延时函数
  2. 不要再中断函数中和主函数调用相同的函数或操作同一个硬件,操作用一个全局变量时要将该变量声明为volatile,避免编译器优化
  3. 中断建议操作变量或者标志位(状态位)

TIM(定时器)

介绍

  • 定时器可以对输入的时钟进行计数,并在计数值达到设定值时触发中断。

  • 16位计数器、预分频器、自动重装载寄存器的时基单元,在72HZ计数时钟下可以实现最大59.65s((65535*65535)/72MHz)的定时时间

  • 具备基本的定时中断功能,还包含内外时钟源选择、时钟捕获、输出比较、编码器接口,主从触发模式等多种功能

  • 分为三种:高级定时器、通用定时器、基本定时器,难度依次递减

image-20241118144626208

计数器模式

三种:向上计数模式、向下计数模式,中央对齐模式

基本定时器:只支持向上计数模式

通用定时器和高级定时器:支持向上计数、向下计数、中央对齐计数

时基单元

定时器框图中最重要的是时基单元,由三部分构成:预分频器PSC、自动重装载器ARR、计数器CNT

  1. Prescaler(psc)-预分频值:内部有一个预分频器PSC,内部时钟先输入到这里完成分频。简单来说就是分频值

    时钟信号被分频后的频率 F= TCLK/(PSC+1)

  2. auto-reload preload(arr)-自动重装载值:内部有一个自动重装载寄存器,简单来说就是设置计数值上限,最大为65535

  3. CNT-计数器:内部有一个计数器自增,会与自动重装在寄存器比较,当计数值等于自动重装载值arr时,将会触发更新中断或更新事件,同时清零计数器

定时器溢出时间 Tout = (arr+1)/F = (arr+1)*(PSC+1) /TCLK

时钟源

在手册上我们可以看到,stm32通用定时器的时钟源有4种

1、内部时钟(CK_INT)

2、外部时钟模式1:外部输入引脚(TIx)

3、外部时钟模式2:外部触发输入(ETR)

4、内部触发输入(ITRx):使用一个定时器作为另一个定时器的预分频器。如可以配置一个定时器Timer1而作为另一个定时器Timer2的预分频器。用于定时器级联


基本定时器:只能选择内部时钟,也就是系统主频72MHz(F103)

通用(高级)定时器:时钟源不仅可以选择内部时钟输入,也可以选择外部时钟输入ETR(外部引脚输入)

在下面的参考手册通用定时器框图中可以看到通过TIMx_ETR引脚上可以外接一个外部方波时钟,在配置内部极性选择,边沿检测和预分频器,再配置一下输入滤波电路(对外部引脚输入进行滤波),最后分为两路去ETRF(外部时钟模式2)TRGI(外部时钟模式1)

image-20241118151537820

image-20241118153011362

总结:看上面手册中的框图

内部时钟输入:APB1/APB2,一般为系统主频

外部时钟输入:分为外部时钟模式1和外部时钟模式2

  1. ETR引脚(外部引脚输入):经过一堆(极性选择、边沿检测、滤波等)后

    • 独立进入触发控制器(ETR独享),是外部时钟模式2
    • 通过触发器进入从模式控制器,是外部时钟模式1
  2. 其他定时器(ITR):是内部触发输入,来源于其他TIM,可以实现定时器级联,是外部时钟模式1

  3. TIMx_CH1引脚的边沿(TI1F_ED):外部时钟模式1

  4. TIMx_CH1引脚(TI1FP1):外部时钟模式1

  5. TIMx_CH2引脚(TI2FP2):外部时钟模式1

TI1FP1:Timer Input 1 Filter Polarity 1

一般情况下外部时钟通过ETR引脚就可以,其他这么多输入是为了某些特定场景使用,比如:ITR是为了定时器级联设置的

库函数

相关的库函数直接在stm32f10x_tim.h寻找使用,这里给出常用的

时钟源选择

1
2
3
4
5
6
7
8
9
10
11
12
void TIM_InternalClockConfig(TIM_TypeDef* TIMx);
//1.内部时钟
void TIM_ETRClockMode1Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,uint16_t ExtTRGFilter);
//2.外部输入模式1
void TIM_ETRClockMode2Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);
//3.外部输入模式2
void TIM_TIxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_TIxExternalCLKSource,uint16_t TIM_ICPolarity, uint16_t ICFilter);
//4.外部输入模式1
void TIM_ITRxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
//5.内部触发输入,级联

void TIM_ETRConfig(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,uint16_t ExtTRGFilter);

时基单元配置

1
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);

中断输出控制配置

1
void TIM_ITConfig(TIM_TypeDef* TIMx, uint16_t TIM_IT, FunctionalState NewState);

NVIC配置

1
void NVIC_Init()

运行控制配置

1
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState);

单独更改预分频值

1
void TIM_PrescalerConfig(TIM_TypeDef* TIMx, uint16_t Prescaler, uint16_t TIM_PSCReloadMode);

计数器模式配置

1
void TIM_CounterModeConfig(TIM_TypeDef* TIMx, uint16_t TIM_CounterMode);

计数器预装载配置

1
void TIM_ARRPreloadConfig(TIM_TypeDef* TIMx, FunctionalState NewState);

手动写入计数器值

1
void TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);

手动写入ARR值

1
void TIM_SetAutoreload(TIM_TypeDef* TIMx, uint16_t Autoreload);

获取计数器或预分频值

1
2
uint16_t TIM_GetCounter(TIM_TypeDef* TIMx);
uint16_t TIM_GetPrescaler(TIM_TypeDef* TIMx);

中断相关函数

1
2
3
4
FlagStatus TIM_GetFlagStatus(TIM_TypeDef* TIMx, uint16_t TIM_FLAG);//中断外使用
void TIM_ClearFlag(TIM_TypeDef* TIMx, uint16_t TIM_FLAG);
ITStatus TIM_GetITStatus(TIM_TypeDef* TIMx, uint16_t TIM_IT);//中断内使用
void TIM_ClearITPendingBit(TIM_TypeDef* TIMx, uint16_t TIM_IT);

定时器中断(定时或计数功能)

image-20241118154510279.png

TIM配置流程

首先新建Timer.c和Timer.h文件到Hardware中

我们将上方的定时中断基本结构图打通就完成了配置

具体流程:

  1. RCC开启时钟:查看手册我们可以发现TIM2在APB1总线上,故开启APB1总线时钟
1
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE);
  1. 选择时基单元的时钟源
1
TIM_InternalClockConfig(TIM2);//上电后单片机默认使用内部时钟,这一行其实可以省略
  1. 配置时基单元
1
2
3
4
5
6
7
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode =TIM_CounterMode_Up;
TIM_TimeBaseInitStructure.TIM_Period = 10000-1;
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200-1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);

这里并没有对CNT计数器的初始化,如果我们想更改,调用TIM_SetCounter函数进行更改即可

TIM_ClockDivision参数:与滤波器相关的分频,这里随便选

TIM_RepetitionCounter参数:重复计数器的值,高级定时器才有的,不用给0

  1. 配置中断,即配置输出中断控制,允许更新中断输出到NVIC
1
TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE);//配置为更新中断

这里的第二个参数我们选择的是更新中断,其他选择请跳转到对应函数的注释中查看

  1. 配置NVIC,在NVIC中打开定时器中断的通道,并分配一个优先级
1
2
3
4
5
6
7
8
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);//优先级分组

NVIC_InitTypeDef NVIC_InitStructure;//NVIC配置
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn ;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
  1. 运行控制,我们需要使能定时器的运行,否则不会工作
1
TIM_Cmd(TIM2,ENABLE);

连起来就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void Timer_Init()
{
/*1.RCC使能时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE);

/*2.时基单元时钟源选择:此处选择内部时钟*/
TIM_InternalClockConfig(TIM2);//上电后单片机默认使用内部时钟,这一行其实可以省略

/*3.时基单元配置*/
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;//与滤波器相关的分频,这里随便选
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;//计数器模式
TIM_TimeBaseInitStructure.TIM_Period = 10000-1; //周期,也就是ARR的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200-1;//预分频器的值
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;//重复计数器的值,高级定时器才有
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);

/*4.输出中断控制配置,允许更新中断输出到NVIC*/
TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE);//配置为更新中断

/*5.NVIC配置*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn ;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);

/*6.定时器运行控制,使能一下*/
TIM_Cmd(TIM2,ENABLE);
}

中断函数:同样在启动文件中寻找

1
2
3
4
5
6
7
8
9
10
11
void TIM2_IRQHandler()
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update)== SET)
{
/*中间为用户代码*/


TIM_ClearITPendingBit(TIM2,TIM_IT_Update);
}

}

上方函数调用查看更新中断的标志位

实验-使用定时器每秒计数(内部时钟)

1
2
3
4
5
6
7
/*Timer.h*/
#ifndef __TIMER_H
#define __TIMER_H

void Timer_Init();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*Timer.c*/


#include "stm32f10x.h" // Device header

extern uint16_t Num;

void Timer_Init()
{

RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE);

TIM_InternalClockConfig(TIM2);//上电后单片机默认使用内部时钟,这一行其实可以省略

TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;//与滤波器相关的分频,这里随便选
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;//计数器模式
TIM_TimeBaseInitStructure.TIM_Period = 10000-1; //周期,也就是ARR的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200-1;//预分频器的值
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;//重复计数器的值,高级定时器才有
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);

TIM_ClearFlag(TIM2,TIM_FLAG_Update);//避免上电立即进入中断

TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE);//配置为更新中断

NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);

TIM_Cmd(TIM2,ENABLE);
}

void TIM2_IRQHandler()
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update)== SET)
{
Num++;

TIM_ClearITPendingBit(TIM2,TIM_IT_Update);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*main.c*/
#include "stm32f10x.h" // Device header
#include "OLED.h"
#include "Timer.h"


int16_t Num = 0;

int main(void)
{
Timer_Init();
OLED_Init();
while(1)
{
OLED_ShowSignedNum(1,5,Num,5);
OLED_ShowSignedNum(2,5,TIM_GetCounter(TIM2),5);
}

}

这里有个问题,上电后我们可以发现计数器的值直接为1,这代表在上电时就已经进入了一次中断处理程序了。

这是由于TIM_TimeBaseInit()函数中最后一排有个TIMx->EGR = TIM_PSCReloadMode_Immediate;

查看注释可以知道其手动生成了一个更新事件(为了让预分频器缓冲寄存器起作用,更新才会起作用),导致上电立即进入中断。

解决方案:

在TIM_TimeBaseInit()后,开启中断前使用手动清除标志位。

1
2
3
4
5
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);
/*添加到这里即可*/
TIM_ClearFlag(TIM2,TIM_FLAG_Update);

TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE);//配置为更新中断

实验-对射式红外传感器(外部时钟模式2)

参考手册查看到芯片的TIM2_CH1_ETR复用输入引脚为PA0,可以通过PA0引脚将传感器0模块的DO引脚输出波形引入到定时器TIM2中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*Timer.c*/


#include "stm32f10x.h" // Device header

extern uint16_t Num;

void Timer_Init()
{

RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE);

//有外部引脚输出,故先配置外部引脚
GPIO_InitTypeDef GPIO_InitStructure;
/*这里的GPIO输入模式在参考手册可以查到,使用TIM2外部输入,推荐为浮空输入*/
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB,&GPIO_InitStructure);

/*这里选择的外部时钟模式2,对应参数选择跳转到对应函数查看注释*/
TIM_ETRClockMode2Config(TIM2,TIM_ExtTRGPSC_OFF,TIM_ExtTRGPolarity_NonInverted,0x0F);
//选择的是上升沿触发,最后一个参数是外部触发滤波器,取值对应可以在参考手册中从模式控制寄存器(TIMx_SMCR)中看到,取值为0x00~0x0f,如果我们不滤波(0x00)的话就会有很多抖动脉冲,计数很多次。这里一般选择0x0F(15)即可

TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInitStructure.TIM_Period = 10-1;
TIM_TimeBaseInitStructure.TIM_Prescaler = 1-1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);

TIM_ClearFlag(TIM2,TIM_FLAG_Update);
TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE);

NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);

TIM_Cmd(TIM2,ENABLE);
}

void TIM2_IRQHandler()
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update)== SET)
{
Num++;

TIM_ClearITPendingBit(TIM2,TIM_IT_Update);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "Timer.h"

int16_t Num = 0;

int main(void)
{
Timer_Init();
Key_Init();
LED_Init();
OLED_Init();
// OLED_ShowChar(10,10,'a');
while(1)
{
OLED_ShowSignedNum(1,5,Num,5);

OLED_ShowSignedNum(2,5,TIM_GetCounter(TIM2),5);
}

}

当我们把手放遮挡在拿开,计数器+1,计数9次后,Num+1

实验-循迹模块测量商品数量(外部时钟模式2)

【STM32】动画讲解定时器外部时钟 & 实战传送带测速装置_哔哩哔哩_bilibili

参考手册查看到芯片的TIM2_CH1_ETR复用输入引脚为PA0,可以通过PA0引脚将传感器0模块的DO引脚输出波形引入到定时器TIM2中。

代码与上方完全相同

同样也可以使用外部时钟模式1的ETR从模式、TI1F_ED、TI1FP1、TI1FP2都可以实现

最终我们将任意物品从循迹模块下方穿过,一个物品计数+1

输出比较功能(OC)

OC简介

OC(Output Compare)--------输出比较

输出比较可以通过比较CNT计数器CCR(Capture/Compare Register)值的关系进行置1、置0或者翻转的操作,用于输出一定频率和占空比的PWM波形

CCR全称:Capture/Compare Register – 捕获/比较寄存器

使用输入捕获时:就是捕获寄存器

使用输出比较时:就是比较寄存器

  • 每个高级定时器和通用定时器都拥有4个输出比较通道
  • 高级定时器的前三个通道额外拥有死区生成和互补输出的功能

PWM输出简介

脉冲宽度调制-PWM,是英文“Pulse Width Modulation”的缩写,简称脉宽调制,其实是在利用微控制器的定时器模块来生成一种特定频率和占空比的脉冲信号(一段高低电平),调整脉冲的宽度从而影响功率等。PWM信号是一种周期性的脉冲信号,通过调整脉冲的宽度(高电平时间)可以模拟模拟信号,控制电机速度、LED亮度、蜂鸣器响度等。必须具有惯性的系统才能使用PWM。

PWM参数

  1. 频率 = 1 / Ts

  2. 占空比 = Ton / Ts (图中的高电平占整个周期的时间)

  3. 分辨率 = 占空比变化步距(占空比以多少百分比跳变,1%到%2到%3,分辨率就是1%)

image-20241119175400365

PWM频率越快,等效模拟的信号也就越平稳,对应的性能开销也就越大

一般来说PWM的频率都在几K到几十K,这个频率就足够快了


原理图:CCR=30时

image-20241120214655665

参数计算:

PWM频率:Freq = CK_PSC/(PSC+1)*(ARR+1)

PWM占空比:Duty = CCR/(ARR+1)

PWM分辨率:Reso = 1 / (ARR+1) 占空比越细腻越好

舵机简介

舵机是一种根据输入PWM信号占空比来控制输出角度的装置

常见的舵机型号有:SG90,SG92

输入PWM信号要求:周期为20ms高电平时长为0.5ms~2.5ms(0~180°),可以查看对应舵机手册得到驱动角度对应PWM的周期

三根线:一根VCC,一根GND,一根信号线

image-20241120215627230

给一个PWM,舵机就会固定在某一个角度,机械臂等机械机构就可以使用

这里的PWM输出当成通信协议很常见,PWM波形通过信号线输出

直流电机及驱动简介

直流电机是一种将电能转换为机械能的装置,有两个电极,当电极正接时,电机正转,当电极反接时,电机反转。(对应一个引脚高电平一个引脚低电平)

直流电机属于大功率器件,GPIO口无法直接驱动,需要配合电机驱动芯片来操作

TB6612是一款双路H桥型的直流电机驱动芯片,可以驱动两个直流电机并控制其转速和方向,还有DRV8833驱动芯片

image-20241120221616621

在对应模块使用手册可以查看使用方法以及原理图,各引脚含义

库函数

主要初始化函数:

1
2
3
4
5
6
7
8
void TIM_OC1Init(TIM_TypeDef* TIMx, TIM_OCInitTypeDef* TIM_OCInitStruct);
void TIM_OC2Init(TIM_TypeDef* TIMx, TIM_OCInitTypeDef* TIM_OCInitStruct);
void TIM_OC3Init(TIM_TypeDef* TIMx, TIM_OCInitTypeDef* TIM_OCInitStruct);
void TIM_OC4Init(TIM_TypeDef* TIMx, TIM_OCInitTypeDef* TIM_OCInitStruct);
/*对应的是四个输出比较通道的输出比较单元结构体的初始化*/

void TIM_OCStructInit(TIM_OCInitTypeDef* TIM_OCInitStruct);
/*用于为一个结构体赋初值*/
1
2
3
4
5
6
7
8
9
10
11
void TIM_OC1PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
void TIM_OC2PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
void TIM_OC3PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
void TIM_OC4PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
/*极性配置
* 对每个通道极性的单独配置
* 带N是高级定时器中互补通道的配置,OC4没有互补通道
*/
void TIM_OC1NPolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCNPolarity);
void TIM_OC2NPolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCNPolarity);
void TIM_OC3NPolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCNPolarity);
1
2
3
4
void TIM_CCxCmd(TIM_TypeDef* TIMx, uint16_t TIM_Channel, uint16_t TIM_CCx);
void TIM_CCxNCmd(TIM_TypeDef* TIMx, uint16_t TIM_Channel, uint16_t TIM_CCxN);

/*单独修改输出使能参数*/
1
2
3
void TIM_SelectOCxM(TIM_TypeDef* TIMx, uint16_t TIM_Channel, uint16_t TIM_OCMode);

/*单独修改输出比较模式的函数*/
1
2
3
4
5
6
7
8
void TIM_SetCompare1(TIM_TypeDef* TIMx, uint16_t Compare1);
void TIM_SetCompare2(TIM_TypeDef* TIMx, uint16_t Compare2);
void TIM_SetCompare3(TIM_TypeDef* TIMx, uint16_t Compare3);
void TIM_SetCompare4(TIM_TypeDef* TIMx, uint16_t Compare4);

/*单独修改CCR寄存器的函数
* 可用于调整占空比
*/
1
void TIM_CtrlPWMOutputs(TIM_TypeDef* TIMx, FunctionalState NewState);

该函数仅高级定时器使用,在使用高级定时器输出PWM时,需要调用这个函数,使能主输出,否则PWM将不能正常输出


一些小功能配置:使用不多

1
2
3
4
5
6
7
8
void TIM_ForcedOC1Config(TIM_TypeDef* TIMx, uint16_t TIM_ForcedAction);
void TIM_ForcedOC2Config(TIM_TypeDef* TIMx, uint16_t TIM_ForcedAction);
void TIM_ForcedOC3Config(TIM_TypeDef* TIMx, uint16_t TIM_ForcedAction);
void TIM_ForcedOC4Config(TIM_TypeDef* TIMx, uint16_t TIM_ForcedAction);
/*配置强制输出模式:在运行中想要暂停输出波形并且强制输出高或低电平使用
* 一般不怎么使用,修改占空比为0或100也能实现
*
*/
1
2
3
4
5
6
7
8
9
void TIM_CCPreloadControl(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_OC1PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC2PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC3PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC4PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
/*用于配置CCR寄存器的预装功能,也就是影子寄存器
* 也就是:写入的值不会立即生效,而是在更新时间才会生效
* 一般不使用
*/
1
2
3
4
5
6
7
8
9
void TIM_OC1FastConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCFast);
void TIM_OC2FastConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCFast);
void TIM_OC3FastConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCFast);
void TIM_OC4FastConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCFast);

/*用于配置快速使能
* 功能手册中,单脉冲模式有介绍
* 一般不使用
*/
1
2
3
4
5
6
7
8
void TIM_ClearOC1Ref(TIM_TypeDef* TIMx, uint16_t TIM_OCClear);
void TIM_ClearOC2Ref(TIM_TypeDef* TIMx, uint16_t TIM_OCClear);
void TIM_ClearOC3Ref(TIM_TypeDef* TIMx, uint16_t TIM_OCClear);
void TIM_ClearOC4Ref(TIM_TypeDef* TIMx, uint16_t TIM_OCClear);

/*外部事件时清楚REF信号
* 不怎么使用
*/

输出比较配置流程

  1. RCC开启时钟

  2. 配置GPIO:把PWM对应的GPIO口初始化为复用推挽输出模式(TIM复用)

  3. 选择时基单元时钟源

  4. 配置时基单元

  5. 配置输出比较单元:输出比较模式、极性选择、输出状态使能、CCR的值等

  6. 运行控制:启动对应TIM

实验-PWM呼吸灯

配置流程

  1. RCC开启时钟,开启对应GPIO和TIM时钟

  2. 配置GPIO:把PWM对应的GPIO口初始化为复用推挽输出模式(TIM复用)

  3. 选择时基单元时钟源

  4. 配置时基单元

  5. 配置输出比较单元:输出比较模式、极性选择、输出状态使能、CCR的值等

  6. 运行控制:启动对应TIM

1
2
3
4
5
6
7
/*PWM.h*/
#ifndef __PWM_H
#define __PWM_H

void PWM_Init();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*PWM.c*/
#include "stm32f10x.h" // Device header


void PWM_Init()
{
//1.RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
//2.配置GPIO
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//复用推挽模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStructure);

//3.配置时基单元时钟源
TIM_InternalClockConfig(TIM3);
//4.配置时基单元
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up ;
TIM_TimeBaseInitStructure.TIM_Period = 100-1;
TIM_TimeBaseInitStructure.TIM_Prescaler = 720-1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3,&TIM_TimeBaseInitStructure);
//5.配置输出比较单元
TIM_OCInitTypeDef TIM_OCInitStructure;
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1 ; /*PWM1模式*/
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High ; /*极性为高:有效电平为高电平*/
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; /*输出状态使能*/
TIM_OCInitStructure.TIM_Pulse = 0; //配置CCR
/*这里时通用定时器,只需要列举需要的参数即可,高级定时器的参数可以不用管*/
TIM_OC2Init(TIM3,&TIM_OCInitStructure);
//6.运行控制,TIM使能
TIM_Cmd(TIM3,ENABLE);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/*main.c*/

#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "PWM.h"

int16_t Num = 0;
uint8_t pwmVal = 0;
int main(void)
{

PWM_Init();
while(1)
{

for(;pwmVal<100;pwmVal++)
{
TIM_SetCompare2(TIM3,pwmVal);
Delay_ms(10);
}

for(;pwmVal>0;pwmVal--)
{
TIM_SetCompare2(TIM3,pwmVal);
Delay_ms(10);
}


}

}

实验-PWM驱动舵机

驱动舵机工作的频率为50HZ,也就是周期为20ms。

这里对应PSC设置为72-1,ARR设置为20000-1

舵机旋转角度对应的周期为500us~2500us(0.5ms~2.5ms)的高电平时长,对应占空比为:0.5ms/20ms = 2.5% 到 2.5ms/20 =12.5%

即设置ARR的范围应该是500~2500(对应0~180°)

配置流程与上一个实验一样

1
2
3
4
5
6
7
8
/*Servo.h*/
#ifndef __SERVO_H
#define __SERVO_H

void Servo_Init();

void Servo_SetAngle(float Angle);
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*Servo.c*/
#include "stm32f10x.h" // Device header


void Servo_Init()
{

RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE);

GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//复用推挽模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB,&GPIO_InitStructure);
/*PB8为舵机的信号线*/

TIM_InternalClockConfig(TIM4);

TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up ;
TIM_TimeBaseInitStructure.TIM_Period = 20000-1;
TIM_TimeBaseInitStructure.TIM_Prescaler = 72-1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM4,&TIM_TimeBaseInitStructure);

TIM_OCInitTypeDef TIM_OCInitStructure;
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1 ; /*PWM1模式*/
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High ; /*有效电平为高电平*/
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; /*输出状态使能*/
TIM_OCInitStructure.TIM_Pulse = 500; //配置CCR
/*这里时通用定时器,只需要列举需要的参数即可,高级定时器的参数可以不用管*/
TIM_OC3Init(TIM4,&TIM_OCInitStructure);

TIM_Cmd(TIM4,ENABLE);

}

/*设置舵机角度*/
void Servo_SetAngle(float Angle)
{
TIM_SetCompare3(TIM4,(Angle/180)*(2000)+500);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "stm32f10x.h"                  // Device header
#include "Key.h"
#include "Servo.h"

int16_t Num = 0;

int main(void)
{

Key_Init();

Servo_Init();
while(1)
{
Num = Key_GetNum();
if(Num == 1)
{
static float angle=0;
angle+=30;
if(angle > 180)
{
angle = 0;
}
Servo_SetAngle(angle);
}
}

}

按键按下,舵机旋转30度

实验-PWM驱动DRV8833电机

DRV8833相关知识见HAL库笔记

DRV8833对应两个输入引脚,一个PWM输入,一个给高/低电平可以实现正反转,对应查表见HAL库对应章节。

对应PWM占空比越高,电机转速越快

1
2
3
4
5
6
7
8
/*DRV8833.h*/
#ifndef __DRV8833_H
#define __DRV8833_H

void DRV8833_Init();
void DRV8833_SetSpeed();

#endif

这里两个输入引脚为:PA0,PA1。PA0为PWM引脚,PA1为低电平引脚

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/*DRV8833.c*/
#include "stm32f10x.h" // Device header
void DRV8833_Init()
{
//1.
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

//2.
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStructure);
/*配置PA1引脚为输出模式*/

GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;//复用推挽模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStructure);
/*配置PWM引脚PA0,为复用推挽输出模式*/

//3.
TIM_InternalClockConfig(TIM2);
//4.
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up ;
TIM_TimeBaseInitStructure.TIM_Period = 100-1;
TIM_TimeBaseInitStructure.TIM_Prescaler = 72-1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);

//5.
TIM_OCInitTypeDef TIM_OCInitStructure;
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1 ; /*PWM1模式*/
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High ; /*有效电平为高电平*/
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; /*输出状态使能*/
TIM_OCInitStructure.TIM_Pulse = 0; //修改CCR的值可以改变转速
/*这里时通用定时器,只需要列举需要的参数即可,高级定时器的参数可以不用管*/
TIM_OC1Init(TIM2,&TIM_OCInitStructure);

//6.
TIM_Cmd(TIM2,ENABLE);

GPIO_ResetBits(GPIOA,GPIO_Pin_1);
/*设置PA1为低电平*/
}

void DRV8833_SetSpeed(uint8_t speed)
{
if(speed>0)
{
TIM_SetCompare1(TIM2,speed);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "stm32f10x.h"                  // Device header
#include "Key.h"
#include "DRV8833.h"


int main(void)
{

DRV8833_Init();
DRV8833_SetSpeed(50);

while(1)
{

}

}

转速最大值可以设置100,为ARR最大值

输入捕获功能(IC)

IC简介

IC(Input Capture)--------输入捕获

输入捕获模式下,当通道输入引脚出现指定电平跳变时,当前CNT的值将被锁存到CCR(捕获寄存器)中,可用于测量PWM波形的频率、占空比、脉冲间隔、电平持续时间等参数。

CCR全称:Capture/Compare Register – 捕获/比较寄存器

使用输入捕获时:就是捕获寄存器

使用输出比较时:就是比较寄存器

每个高级定时器和通用定时器都拥有4个输入捕获通道:

  • 可配置为PWMI模式,同时测量频率和占空比

  • 可配合主从触发模式,实现硬件全自动测量

频率测量

image-20241125154510808

测频法(测频率):适合测量高频信号,计次数量多一些,有助于减小误差

特点:测量结果更新速度较慢,但是平均值,相当于均值滤波,结果比较稳定

fx = N / T


测周法(测周期):适合测量低频信号,周期比较长,计次比较多,有助于减小误差

特点:只测量一个周期,测量结果更新速度较快,但结果值会受噪声的影响,波动比较大

fx = fc / N fc为标准频率:标准频率就是经过PSC分频后的时钟频率

在这里fc可以是使用输入捕获时定时器的频率,使用时需要每次触发后将CNT的值清0才是一个周期


中界频率fm:

待测信号频率<中界频率时,选用测频法误差更小

待测信号频率>中界频率时,选用测周法误差更小

fm = 根号下(fc / T)

输入捕获通道

image-20241125161551723

同一个引脚TIx的输入信号映射到两个输入通道IC1和IC2,且两个通道的极性检测相反即这里的

TI1引脚的输入信号可以映射到TI1FP1和TI1FP2

TI1FP1连接到的是输入通道1,TI1FP2连接到的是输入通道2,可以使用两个捕获寄存器CCR

T2引脚的输入信号可以映射到TI2FP1和TI2FP2

TI2FP1连接到的是输入通道1,TI2FP2连接到的是输入通道2,也可以使用两个捕获寄存器CCR

一共四种连接方式

输入捕获的直接模式和间接模式:信号从TI1引入,在自己的捕获寄存器1上进行输入捕获,就叫做输入捕获的直接模式。信号从TI1引入借用捕获寄存器2进行输入捕获,则叫做输入捕获的间接模式(交叉模式)

主从触发模式

主从触发模式是:主模式从模式触发源选择这三个功能的简称

image-20241125162539989

主模式:可以将定时器内部信号映射到TRGO 引脚,用于触发别的外设

从模式:接收其他外设或者自身外设的一些信号,用于控制自身定时器的运行,也就是被别的信号控制

触发源选择:就是选择从模式的触发信号源的,可以认为是从模式的一部分。选择指定的一个信号,得到TRGI,TRGI去触发从模式,在从模式列表中选择一项操作自动执行

如:选择TI1FP1触发源,选择Reset操作就可以自动触发从模式,从模式自动清零CNT

主模式选择可以在对应手册TIMx_CRx控制寄存器中查看

从模式选择可以在对应手册TIMx_SMCR从模式控制寄存器中查看

输入捕获和PWMI基本结构图

image-20241125163454859

特点:只使用了一个通道,只能测量频率,使用的是测周法测量频率,所以需要每次触发后将CNT的值清0,使用从模式实现信号触发后自动清0

F = fc / N

fc为标准频率:标准频率就是经过PSC分频后的时钟频率

因为CNT要自增,所以ARR的值要设置的足够大,设置为上限65535

image-20241125164342932

PWMI模式(PWM输入模式)使用两个通道来捕获,可以测量频率占空比,使用的是测周法测量频率,所以需要每次触发后将CNT的值清0,使用从模式实现信号触发后自动清0

下面部分

TI1FP1和TI1FP2以相反的极性检测(TI1FP1检测上升沿,TI1FP2检测下降沿)

CCR1:一整个周期的计数值,每一次上升沿到来时对应CNT都会清零

CCR2:高电平期间的计数值

占空比:duty = CCR2 / CCR1

因为CNT要自增,所以ARR的值要设置的足够大,设置为上限65535


上面部分:选择TI1FP1触发源,使用从模式配合输入捕获,实现CNT自动清零(Reset),完成硬件自动化

相关介绍对应在参考手册的输入捕获和PWM输入模式对应部分

库函数

初始化函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
void TIM_ICInit(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
/*配置输入捕获单元函数,输出比较是四个通道分别有一个函数,而输入捕获配置是4个通道共用一个函数,具体通道选择在结构体中*/

void TIM_PWMIConfig(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
/* 也是配置输入捕获单元的函数,但是这个函数可以快速配置两个通道,将外设电路配置为PWMI模式
* 传入一个结构体之后,该函数会根据传入结构体通道配置自动初始化另外一个通道为相反的配置
* 比如:传入结构体配置为通道1,上升沿触发,直接模式,调用该函数就会配置通道2为下降沿触发,且为交叉模式(间接模式)
*
* 该函数只支持通道1和通道2,不能传入通道3和通道4!!!
*/

void TIM_ICStructInit(TIM_ICInitTypeDef* TIM_ICInitStruct);
/*输入捕获结构体初始化,一般在某些参数不用初始化的时后调用,防止没有初始化某些值造成意外错误*/

主从模式相关函数

1
2
3
4
5
6
7
8
void TIM_SelectOutputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_TRGOSource);
/*选择输出触发源TRGO,对应主模式输出的触发源*/

void TIM_SelectInputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
/*选择输入触发源TRGI,对应从模式的触发源选择*/

void TIM_SelectSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_SlaveMode);
/*选择从模式,对应从模式执行操作*/

预分频配置

1
2
3
4
5
void TIM_SetIC1Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC2Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC3Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC4Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
/*分别配置通道1~4的预分频值,这个参数可以在结构体中配置*/

获取捕获寄存器值

1
2
3
4
5
uint16_t TIM_GetCapture1(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture2(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture3(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture4(TIM_TypeDef* TIMx);
/*分别获取四个通道的捕获寄存器CCR的值*/

输入捕获配置流程

根据上面的结构图可以得到输入捕获配置流程:

  1. RCC开启时钟,将GPIO和TIM的时钟打开

  2. GPIO初始化,配置GPIO为输入模式(上拉或者浮空)

  3. 配置时基单元的时钟源

  4. 配置时基单元,让CNT计数器在内部时钟的驱动下自增运行

  5. 配置输入捕获单元,包括滤波器、极性、直连通道还是交叉通道、分频器等参数

  6. 配置从模式触发源,触发源选择TI1FP1等 (调用库函数)

  7. 配置从模式执行的操作(调用库函数)

  8. 运行控制,开启定时器TIM

实验-输入捕获模式测频率(输入捕获直接模式)

这个地方没有信号发生器,选择将另外一个引脚TIM输出信号输入到该TIM引脚。从PB8输入到PA0

1
2
3
4
5
6
7
8
9
/*IC.h*/
#ifndef __IC_H
#define __IC_H

void IC_Init();

uint32_t IC_GetFreq();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/*IC.c*/

#include "stm32f10x.h" // Device header

void IC_Init()
{
//1.开启对应GPIO和TIM时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
//2.配置GPIO为
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStructure);
//3.时基单元时钟源配置
TIM_InternalClockConfig(TIM2);
//4.时基单元配置
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInitStructure.TIM_Period = 65536-1;//ARR
TIM_TimeBaseInitStructure.TIM_Prescaler = 72-1;//PSC,标准频率fc = 72M/72 = 1MHz
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0 ;
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);

//5.输入捕获单元配置
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; //CH1
TIM_ICInitStructure.TIM_ICFilter = 0xF; //滤波值,越大滤波效果越好
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising ;//上升沿捕获
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; //对应触发信号分频器,不分频DIV1就是每次触发都有效,2分频就是每隔一次生效一次
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;//输入捕获直接模式
TIM_ICInit(TIM2,&TIM_ICInitStructure);

//6.从模式触发源配置
TIM_SelectInputTrigger(TIM2,TIM_TS_TI1FP1);//配置从模式输入触发源
//7.从模式执行操作
TIM_SelectSlaveMode(TIM2,TIM_SlaveMode_Reset);//配置从模式操作
//8.运行控制,启动TIM
TIM_Cmd(TIM2,ENABLE);
}


uint32_t IC_GetFreq()
{

return 1000000/TIM_GetCapture1(TIM2)-1; //这里的-1是为了弥补+-1的误差

}

这个地方标准频率fc = 72MHz / 72 = 1MHz = 1000000

fc即为经过PSC分频过后的频率

测量最低频率为:1MHz / 65535 = 15Hz,再低会溢出。

如果想要降低最低频率,只需要增大PSC的值

如果想要增大最低频率,只需要减小PSC的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*main.c*/
#include "stm32f10x.h" // Device header

#include "OLED.h"
#include "PWM.h"
#include "IC.h"

int main(void)
{

PWM_Init();//输出PWM信号
PWM_SetPrescaler(720-1);
PWM_SetCompare3(25);//CCR = 25

IC_Init();//输入捕获

OLED_Init();
OLED_ShowString(5,1,"Freq:00000HZ");

while(1)
{
OLED_ShowNum(5,6,IC_GetFreq(),5);
}

}

实验-PWMI模式测频率和占空比(输入捕获交叉/间接模式)

1
2
3
4
5
6
7
8
9
/*IC.h*/
#ifndef __IC_H
#define __IC_H

void IC_Init();

uint32_t IC_GetFreq();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/*IC.c*/

#include "stm32f10x.h" // Device header

void IC_Init()
{
//1.开启对应GPIO和TIM时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
//2.配置GPIO为
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStructure);
//3.时基单元时钟源配置
TIM_InternalClockConfig(TIM2);
//4.时基单元配置
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInitStructure.TIM_Period = 65536-1;//ARR
TIM_TimeBaseInitStructure.TIM_Prescaler = 72-1;//PSC,标准频率fc = 72M/72 = 1MHz
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0 ;
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);

//5.输入捕获单元配置
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; //CH1
TIM_ICInitStructure.TIM_ICFilter = 0xF; //滤波值,越大滤波效果越好
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising ;//上升沿捕获
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; //对应触发信号分频器,不分频DIV1就是每次触发都有效,2分频就是每隔一次生效一次
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;//输入捕获直接模式
TIM_PWMIConfig(TIM2,&TIM_ICInitStructure);
/*输入捕获模式和PWMI模式差别就在这里*/
// TIM_ICInit(TIM2,&TIM_ICInitStructure);
// TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; //CH2
// TIM_ICInitStructure.TIM_ICFilter = 0xF;
// TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Falling ;//下降沿捕获,与通道一相反
// TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;
// TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_IndirectTI;//输入捕获交叉模式
// TIM_ICInit(TIM2,&TIM_ICInitStructure);

//6.从模式触发源配置
TIM_SelectInputTrigger(TIM2,TIM_TS_TI1FP1);//配置从模式输入触发源
//7.从模式执行操作
TIM_SelectSlaveMode(TIM2,TIM_SlaveMode_Reset);//配置从模式操作
//8.运行控制,启动TIM
TIM_Cmd(TIM2,ENABLE);
}

/*获取频率*/
uint32_t IC_GetFreq()
{

return 1000000/TIM_GetCapture1(TIM2)-1; //这里的-1是为了弥补+-1的误差

}

/*获取占空比*/
uint32_t IC_GetDuty()
{
return TIM_GetCapture2(TIM2)*100/TIM_GetCapture1(TIM2)+1;//弥补误差
}
  • 使用TIM_PWMIConfig(TIM2,&TIM_ICInitStructure)函数可以快速配置两个通道,见源码可知,该函数自动初始化该通道和初始化另外一个通道为相反的配置。

  • 不使用该函数,就配置两次即可,即上方注释部分,通道1配置为上升沿捕获,直接模式,通道2就配置为下降沿捕获,交叉模式,调用两次初始化函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*main.c*/
#include "stm32f10x.h" // Device header

#include "OLED.h"
#include "PWM.h"
#include "IC.h"

int main(void)
{

PWM_Init();//输出PWM信号
PWM_SetPrescaler(720-1);
PWM_SetCompare3(25);//CCR = 25

IC_Init();//输入捕获

OLED_Init();
OLED_ShowString(5,1,"Freq:00000HZ");
OLED_ShowString(10,1,"Duty:00%");

while(1)
{
OLED_ShowNum(5,6,IC_GetFreq(),5);
OLED_ShowNum(10,6,IC_GetDuty(),2);
}

}

编码器接口

编码器接口简介

Encoder Interface - 编码器接口

编码器接口可接收增量(正交编码器)的信号,根据编码器旋转产生的正交信号脉冲,自动控制CNT自增或自减,从而指示编码器的位置、旋转方向和旋转速度

  • 每个高级定时器和通用定时器都拥有1个编码器接口,基本定时器没有编码器接口

  • 两个输入引脚(CH1、CH2)借用了输入捕获通道的通道1通道2

之前我们使用了编码器中断来手动计次,使用编码器接口可以实现自动计次,减少资源浪费,避免频繁中断

正交编码器介绍

拥有A相和B相,输出的两个正交方波信号,相位相差90°,超前90°和滞后90°代表正传和反转。

编码器测速使用的是测频法测量

image-20241126213304783

编码器的上升沿和下降沿都有效

正传CNT自增

反转CNT自减

image-20241126222220541

三种工作方式

1.仅在T1计数

2.仅在T2计数

3.在T1和T2都计数

一般我们使用第三种

编码器接口执行逻辑总结下来就一句话

正转的时候向上计数,反转的时候向下计数


image-20241126222801623

正交编码器是抗噪声的原理

当遇到毛刺现象时,CNT的值会来回跳动,一会自增一会自减,但最终的值保持不变。

image-20241126223216790

当TI极性选择反相时,需要将图中TI的波形反向后才能得到正确的计数方向。

当我实际使用过程中如果出现想要正传计数+1,但是却出现-1的情况,我们把任意一个引脚极性反相,就能反转计数方向了。或者直接交换A、B相引脚即可

应用

比较常见的应用场景:

编码器测速一般应用在电机控制的项目上,使用PWM驱动电机,再使用编码器测量电机的速度,然后再用PID算法进行闭环控制。

一般电机旋转速度较高,会使用无接触式的霍尔传感器或者光栅进行测速。

这里为了方便,我们使用触点式的旋转编码器。电机旋转呢,我们就用人工旋转来模拟,但实际上旋转编码器和电机的霍尔,光栅编码器都是一样的效果

编码器接口基本结构图

image-20241126221118178

对应CH1的TI1FP1和CH2的TI2FP2,与CH3和CH4无关。

ARR是有效的,一般设置为65535,利用补码的特性可以CNT从0自减时得到的是65535、65534…

编码器的时钟会直接托管驱动计数器,所以在编码器接口模式下时基单元不需要配置内部时钟并且计数模式无效


同时输入捕获单元并没有完全使用,只需要配置滤波器和极性选择即可

更多内容可以在参考手册中的TIM编码器接口模式中查看

库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void TIM_EncoderInterfaceConfig
(TIM_TypeDef* TIMx,
uint16_t TIM_EncoderMode,
uint16_t TIM_IC1Polarity,
uint16_t TIM_IC2Polarity);

/**配置编码器接口的函数
* 参数1:哪个定时器
* 参数2:编码器模式,三种:1.仅在T1计数 2.仅在TI2计数 3.T1和T2都计数
* 一般我们使用第三种T1,T2都计数
* 参数3:通道1极性,Rising为不反相,Falling为反相
* 参数4:通道2极性,Rising为不反相,Falling为反相
* 参数3和参数4根据实际情况选择
*/

编码器接口配置流程

  1. RCC开启时钟,开启GPIO和对应TIM的时钟

  2. 配置GPIO,将对应引脚配置为输入模式

  3. 配置时基单元,预分频器不分频,ARR设置为65535(不用配置时基单元的内部时钟,编码器会托管相当于外部时钟)

  4. 配置输入捕获单元,此处输入捕获单元只有滤波器和极性两个参数有用,其他参数没用到

  5. 配置编码器接口模式

  6. 运行控制,启动定时器

初始化完成后,CNT就会随着编码器旋转而自增自减。

如果想要读出编码器位置,直接读出CNT的值就行了

如果想要测量编码器的速度和方向,就需要每隔一定阀门时间,取出一次CNT,然后再把CNT清零,即测频法测量速度

实验-正交编码器测速

我们使用的是A相:PA8,B相PA9,对应定时器TIM1_CH1和TIM1_CH2

1
2
3
4
5
6
7
8
9
/*Encoder.h*/
#ifndef __ENCODER_
#define __ENCODER_

void Encoder_Init(void);

int16_t Encoder_Get();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/*Encoder.c*/

#include "stm32f10x.h" // Device header

void Encoder_Init(void)
{
//1.配置RCC,开启GPIO和对应TIM
RCC_APB1PeriphClockCmd(RCC_APB2Periph_TIM1,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

//2.GPIO端口配置,配置A,B相端口为输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStructure);

// TIM_InternalClockConfig(TIM1);
/*编码器接口就不用这个,因为编码器接口就是一个带方向控制的外部时钟,内部时钟没有用了*/

//3.时基单元配置
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up;//参数无效,编码器托管
TIM_TimeBaseInitStructure.TIM_Period = 65536-1;//ARR
TIM_TimeBaseInitStructure.TIM_Prescaler = 1-1;//不分频,编码器的时钟直接驱动计数器
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0 ;
TIM_TimeBaseInit(TIM1,&TIM_TimeBaseInitStructure);

//4.输入捕获单元配置
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICStructInit(&TIM_ICInitStructure);//部分参数未使用,调用该函数防止未初始化参数产生影响
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising ;//编码器上升沿和下降沿都有效,所以此处代表极性不反转
TIM_ICInit(TIM1,&TIM_ICInitStructure);

TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising ;//编码器上升沿和下降沿都有效,所以此处代表极性不反转
TIM_ICInit(TIM1,&TIM_ICInitStructure);

//5.编码器接口配置
TIM_EncoderInterfaceConfig(TIM1,TIM_EncoderMode_TI12,TIM_ICPolarity_Rising,TIM_ICPolarity_Rising);//这里的极性与上方极性配置的同一个寄存器,可以删除上方极性配置
//6.运行控制TIM使能
TIM_Cmd(TIM1,ENABLE);

}

int16_t Encoder_Get()
{

int16_t speed = TIM_GetCounter(TIM1);
TIM_SetCounter(TIM1,0);

return speed;

}
/*放到缓存的speed当中,再清零CNT即可,最终在主循环中实现延时即可*/


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*main.c*/

#include "stm32f10x.h" // Device header
#include "OLED.h"
#include "Encoder.h"


int main(void)
{
OLED_Init();
Encoder_Init();

while(1)
{
OLED_ShowNum(2,5,Encoder_Get(),5);
Delay_ms(1000);
/*1s测一次速*/
}

}

如果实现效果与想要的不符合,修改编码器接口配置中任意一个引脚的极性参数即可

此处在循环中使用了Delay函数,影响了效率,更加好的办法是开启一个1s的定时器中断,在定时器中断中更新speed的值即可

ADC

ADC简介

ADC(Analog-Digital Converter)模拟-数字转换器

ADC可以将引脚上连续变化的模拟电压转换为内存中储存的数字变量,建立模拟电路到数字电路的桥梁

12位逐次逼近型ADC,1us的转换时间(ADC转化频率为1MHz)

输入电压范围:0~3.3V,转化结果范围:0~4095(2的12次方)

一共18个输入通道,可测量16个外部信号2个内部信号源(内部温度传感器和内部参考电压,不随外部电压变化)

分为规则组(规则通道)注入组(注入通道)两个转换单元

可模拟看门狗自动监测输入电压范围

STM32F103C8T6 ADC资源:ADC1、ADC2,10个外部输入通道

对应所有的知识都能在芯片参考手册中查看

ADC内部结构

image-20241127201536862

逐次逼近型通过二分法给DAC值进行比较,直到找到未知电压编码

比如8位:0~255,先给128,再给64…相当于二进制的高位到低位

对于8位ADC,高位到低位判断8次即可找到位置电压编码

对于12位ADC,高位到低位判断12次即可找到位置电压编码


STM32的ADC

image-20241127202456782

ADCx_IN0~15 就是对应的16个外部信号

温度传感器和Vrefint就是内部2个信号源


通道分为注入通道(注入组)规则通道(规则组)

规则通道:可以同时转选择16个通道,但是对于转化结果,因为规则通道只有一个数据寄存器,所以存在数据覆盖问题。如果不想结果被覆盖,需要在转换完成后尽快把数据拿走。此时一般配合DMA进行搬运

注入通道:可以同时选择4个通道,拥有四个数据寄存器,不用担心数据被覆盖

对应规则通道和注入通道可以在参考手册中找到对应介绍


流程

前面的通道选择后到模拟至数字转换器,

然后模拟至数字转换器中就执行逐次比较的过程,最终结果放在了注入通道/规则通道数据寄存器中。

转换结束后会有一个EOC信号,该信号是规则组或注入组的完成信号,还有个JEOC是注入组完成的信号,会置状态寄存器标志位,可以读取该标志位判断是否转换完成

同时这两个标志位也可以去到NVIC申请中断,如果开启对应NVIC通道就可以触发中断


触发转换部分:对应START信号启动ADC的转换

image-20241127203720470

对应STM32ADC,触发ADC开始转换的信号有两种:软件触发硬件触发

硬件触发:对应图中注入组的触发源和规则组的触发源,主要来自定时器,在定时器章节中我们知道,定时器可以通向ADC、DAC外设,用于触发转换。也可以使用外部中断引脚触发

比如图中我们给TIM3指定1ms时间,将TIM3的更新事件选择为TIM3_TRGO输出,再把ADC选择开始触发信号为TIM3_TRGO,这样TIM3的更新事件就能通过硬件自动触发ADC转换了,不需要进中断

软件触发:程序中调用代码完成ADC转换触发。


ADC时钟

image-20241127204615868

ADC时钟来自RCC,经过ADC预分频器到达,注意此处最大位14MHz,2分频和4分频结果超过了最大值,不建议使用,这里至少6分频

ADC预分频器小于14MHz即可

模拟看门狗

image-20241127204756265

模拟看门狗用于监测转换结果的范围,其中可以存一个阈值高限和阈值低限,如果启动了看门狗并且设定了通道,该看门狗就会关注看门的通道,一旦超过阈值范围就可以申请一个通向NVIC的ADC中断。

ADC输入通道

image-20241127210445125

我们可以在引脚定义表中找到对应ADC通道0~9的通道对应引脚,这代表该芯片只有10个通道

同时我们可以发现ADC12_INx 这样写代表ADC1和ADC2都是这个引脚。我们可以单独使用一个ADC,也可以同时使用。

ADC还有一种高级模式叫双ADC模式,就是ADC1和ADC2一起配贼和使用,可以配合组成同步、交叉模式等,可以进一步提高采样率

ADC转换模式

一共有四种转换方式:多通道只能使用后面两种模式,必须开启扫描模式

  1. 单次转换,非扫描模式

image-20241127211229845

只会转换第一个序列的通道,每次转换都需要触发一次

想要更换通道转换只需要更改序列1的通道即可

  1. 连续转换,非扫描模式

image-20241127211310674

只会转换第一个序列的通道,但触发转换后不会停止,会一直持续转换。只需要第一次触发即可

  1. 单次转换,扫描模式

image-20241127211656061

按顺序依次转换一组通道,可以指定通道数目,指定的所有通道转换完成后才会触发EOC信号。每次转换都需要触发

  1. 连续转换,扫描模式

image-20241127211932067

按顺序依次转换一组通道,可以指定通道数目,指定的所有通道转换完成后才会触发EOC信号。只需要一次触发就可以一直转换

其实还有个间断模式:可以说是对扫描模式的补充

触发一次,转换一个通道,在触发,在转换。在所选转换通道循环,**由触发信号启动新一轮的转换,直到转换完成为止。**例如:可以把0,1,4,5这四个通道进行分组。可以分成0,1一组,4,5一组。也可以每个通道单独配置为一组。这样每一组转换之前都需要先触发一次。

ADC触发控制

image-20241127212108346

ADC数据对齐

image-20241127212138053

寄存器总共16位需要对齐,高位或低位补0

一般使用右对齐,可以直接读取寄存器就是结果

左对齐得到的结果比实际值偏大16倍,对于裁剪分辨率使用

ADC转换时间

image-20241127212422377

量化、编码:就是ADC逐次比较的过程,位数越多花费时间越长

采样、保持:量化编码需要时间,通过开启采样开关,一段时间后断开后使电压在量化编码时保持不变


ADC总转换时间中

采样时间:就是采样保持时间,采样时间可以在程序中配置,时间越长越能避免毛刺信号的干扰,但会导致转换时间延长。

12.5个ADC周期:量化编码所花费时间,因为是12位,所以需要12个周期,多余的0.5周期可能做了一些其他事情

14个周期:14/14MHz = 1μs


采样时间选择

需要更快的转换,就选小的参数,但容易受干扰

需要更稳定的转换,就选大的参数,转换时间长

ADC校准

image-20241127220041612

校准过程我们不需要理解,校准过程固定,只需要在ADC初始化的最后,加几行代码就行了,至于怎么计算、怎么校准的,我们不需要管


流程

复位校准-等待复位校准(判断标志位)-开始校准-等待校准(判断标志位)

硬件电路

image-20241127220346370

第一个是电位器产生可调电路,通过滑动变阻器可以调节电压0~3.3V


第二个是传感器输出电压电路,光敏电阻、热敏电阻等都可等效为一个可变电阻,电阻阻值没法直接测量,一般直接通过和一个固定电阻串联分压来得到反应电阻值电压的电路。

固定电阻一般可以选择和传感器阻值相近的电阻较好

传感器阻值变小时,下拉作用变强,输出端电压就下降,传感器阻值变大时,下拉作用变弱,输出端受上拉作用电压就会升高


第三个是一个简易电压转换电路,使用电阻分压。根据分压公式可以得到中间的电压位(VIN/50K) * 33K,高电压一般不适用比较危险,高电压一般使用一些采集芯片,做好高低电压的隔离

ADC基本结构图

image-20241127205844255

库函数

配置ADCCLK分频器

1
2
void RCC_ADCCLKConfig(uint32_t RCC_PCLK2);
/*在stm32f10x_rcc.h中可以找到,该函数用于配置ADCCLK分频器,可以对APB2的72MHz时钟选择2、4、6、8分频,输入到ADCCLK*/

ADC初始化:stm32f10x_adc.h

1
2
3
4
5
void ADC_DeInit(ADC_TypeDef* ADCx);//恢复缺省配置

void ADC_Init(ADC_TypeDef* ADCx, ADC_InitTypeDef* ADC_InitStruct);

void ADC_StructInit(ADC_InitTypeDef* ADC_InitStruct);

ADC启动

1
2
void ADC_Cmd(ADC_TypeDef* ADCx, FunctionalState NewState);//开启ADC
void ADC_DMACmd(ADC_TypeDef* ADCx, FunctionalState NewState);//开启DMA输出信号,使用DMA需要调用

ADC通道配置

1
2
3
4
5
6
7
8
9
10
void ADC_RegularChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime);
/**
* 规则组通道配置,为序列添加通道
* 参数1:对应ADC
* 参数2:指定ADC通道
* 参数3:对应序列号
* 参数4:指定通道采样时间
*/

//多个通道指定时,多次调用该函数配置即可

ADC外部触发转换控制

1
2
void ADC_ExternalTrigConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
/*是否允许外部触发转换*/

ADC中断

1
2
void ADC_ITConfig(ADC_TypeDef* ADCx, uint16_t ADC_IT, FunctionalState NewState);
/*用于控制某个中断,能否通往NVIC*/

ADC控制校准:在ADC初始化完成后依次调用即可

1
2
3
4
5
6
7
8
9
10
void ADC_ResetCalibration(ADC_TypeDef* ADCx);
/*复位校准*/
FlagStatus ADC_GetResetCalibrationStatus(ADC_TypeDef* ADCx);
/*获取复位校准状态*/

void ADC_StartCalibration(ADC_TypeDef* ADCx);
/*开始校准*/
FlagStatus ADC_GetCalibrationStatus(ADC_TypeDef* ADCx);
/*获取开始校准复位状态*/

ADC触发控制

1
2
3
4
5
void ADC_SoftwareStartConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
/*ADC软件触发控制,调用即可设置为软件触发*/

FlagStatus ADC_GetSoftwareStartConvStatus(ADC_TypeDef* ADCx);
/*ADC获取软件开始转换状态,返回SWSTART状态,与转换是否结束无关,一般不适用*/

ADC间断模式配置

1
2
3
4
void ADC_DiscModeChannelCountConfig(ADC_TypeDef* ADCx, uint8_t Number);
/*ADC间断模式配置,设置Number可以设置每隔几个通道间断一次*/
void ADC_DiscModeCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
/*使能间断模式*/

ADC转换结束判断

1
2
FlagStatus ADC_GetFlagStatus(ADC_TypeDef* ADCx, uint8_t ADC_FLAG);
/*参数给EOC标志位可以判断是否转换完成*/

ADC获取转换值

1
2
3
4
5
uint16_t ADC_GetConversionValue(ADC_TypeDef* ADCx);
//获取AD转换数据寄存器,读取转换结果

uint32_t ADC_GetDualModeConversionValue(void);
//双ADC模式读取转换结果

ADC注入组

1
2
3
4
5
6
7
8
9
10
void ADC_AutoInjectedConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
void ADC_InjectedDiscModeCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
void ADC_ExternalTrigInjectedConvConfig(ADC_TypeDef* ADCx, uint32_t ADC_ExternalTrigInjecConv);
void ADC_ExternalTrigInjectedConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
void ADC_SoftwareStartInjectedConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
FlagStatus ADC_GetSoftwareStartInjectedConvCmdStatus(ADC_TypeDef* ADCx);
void ADC_InjectedChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime);
void ADC_InjectedSequencerLengthConfig(ADC_TypeDef* ADCx, uint8_t Length);
void ADC_SetInjectedOffset(ADC_TypeDef* ADCx, uint8_t ADC_InjectedChannel, uint16_t Offset);
uint16_t ADC_GetInjectedConversionValue(ADC_TypeDef* ADCx, uint8_t ADC_InjectedChannel);

ADC模拟看门狗

1
2
3
4
5
6
7
8
void ADC_AnalogWatchdogCmd(ADC_TypeDef* ADCx, uint32_t ADC_AnalogWatchdog);
/*是否启动看门狗*/

void ADC_AnalogWatchdogThresholdsConfig(ADC_TypeDef* ADCx, uint16_t HighThreshold, uint16_t LowThreshold);
/*配置看门狗高低阈值*/

void ADC_AnalogWatchdogSingleChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel);
/*配置看门通道*/

ADC内部信号配置

1
2
void ADC_TempSensorVrefintCmd(FunctionalState NewState);
/*ADC温度传感器,内部参考电压控制,用于开启内部两个通道,需要使用时调用开启即可*/

ADC标志位

1
2
3
4
5
6
FlagStatus ADC_GetFlagStatus(ADC_TypeDef* ADCx, uint8_t ADC_FLAG);
void ADC_ClearFlag(ADC_TypeDef* ADCx, uint8_t ADC_FLAG);

ITStatus ADC_GetITStatus(ADC_TypeDef* ADCx, uint16_t ADC_IT);
void ADC_ClearITPendingBit(ADC_TypeDef* ADCx, uint16_t ADC_IT);
//常用的标志位相关函数,带IT的应该在中断服务函数中使用

ADC配置流程

打通上方ADC基本结构图即可:

  1. RCC开启时钟,开启ADC和GPIO时钟,配置ADCCLK分频器

  2. 配置GPIO,配置对应GPIO为模拟输入的模式

  3. 配置多路开关,将左边对应通道接入规则组/注入组中(规则/注入通道配置)

  4. 配置ADC转换器,结构体(单次转换/连续转换、扫描/非扫描,几个通道等)

  5. 如果需要模拟看门狗,配置阈值和监测通道,没有就跳过

  6. 如果想要使用中断,使用ADITConfig开启对应中断输出,配置NVIC即可,没有跳过

  7. 开启ADC,调用ADC_Cmd函数

  8. 校准ADC,减小误差

实验-ADC读取电位器电压(ADC单通道)

对应引脚定义可知电位器PA5对应通道为ADC_IN5,选择ADC_Channel_5即可

1
2
3
4
5
6
7
8
9
/*AD.h*/
#ifndef __AD_H
#define __AD_H

void AD_Init();

uint16_t ADC_GetValue();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/*AD.c*/
#include "stm32f10x.h" // Device header


void AD_Init()
{
//1.开启RCC时钟,ADC和GPIO
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

//2.ADC预分频器配置,72Mhz / 6 =12Mhz,不能超过14Mhz,至少6分频
RCC_ADCCLKConfig(RCC_PCLK2_Div6);

//3.GPIO配置为模拟输入
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_5;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

//4.规则通道配置,将通道5写入规则通道的第一个位置(序列1)
ADC_RegularChannelConfig(ADC1,ADC_Channel_5,1,ADC_SampleTime_239Cycles5);
// ADC_RegularChannelConfig(ADC1,ADC_Channel_1,2,ADC_SampleTime_55Cycles5); 如果还有其他通道,继续调用配置即可
// ADC_RegularChannelConfig(ADC1,ADC_Channel_2,3,ADC_SampleTime_239Cycles5); 如果还有其他通道,继续调用配置即可
//5.ADC转换器配置
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; //ADC转换模式,该参数为独立ADC工作,剩余的其他参数都是双ADC模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right;//数据右对齐
ADC_InitStruct.ADC_ExternalTrigConv =ADC_ExternalTrigConv_None;//这里选择软件触发,剩下的都为硬件触发
ADC_InitStruct.ADC_ContinuousConvMode = DISABLE;//启动连续转换模式
ADC_InitStruct.ADC_ScanConvMode = DISABLE;//是否启动扫描模式
ADC_InitStruct.ADC_NbrOfChannel = 1; // 指定扫描模式下总共使用的通道数
ADC_Init(ADC1,&ADC_InitStruct);

//5.开启ADC
ADC_Cmd(ADC1,ENABLE);

//6.ADC校准
ADC_ResetCalibration(ADC1);//复位校准
while(ADC_GetResetCalibrationStatus(ADC1)== SET){};//RSTCAL寄存器由软件置1后开始复位校准,完成后硬件自动清0该位
ADC_StartCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1) == SET){};//CAL寄存器由软件置1后开始校准,校准结束后硬件自动清0该位

//ADC_SoftwareStartConvCmd(ADC1, ENABLE);在启动连续转换模式时,只需要在这里触发转换一次即可,不需要每次获取值都调用
}

uint16_t ADC_GetValue()
{
/**
* 获取ADC值流程:
* 1.触发转换
* 2.判断标志位等待转换完成
* 3.读取转换结果
*/

//1.触发转换,上面没开启连续转换模式,开启连续转换模式只需要启动一次即可
ADC_SoftwareStartConvCmd(ADC1, ENABLE);//启动软件触发转换,也可以使用硬件触发

//2.判断标志位,等待转换完成
while(ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC) == RESET){};//在手册中有介绍,转换完成前为0,转换完成后自动置1
/*等待转换时间为上面设置的采样周期,239+12.5=251.5个周期,时间为251.5/12Mhz=20.9us*/

//3.读取转换结果
return ADC_GetConversionValue(ADC1);//读取DR寄存器时自动清除EOC位
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*main.c*/
#include "stm32f10x.h" // Device header
#include "OLED.h"
#include "AD.h"
float Voltage = 0;

int main(void)
{

OLED_Init();
AD_Init();

while(1)
{
uint16_t adValue = ADC_GetValue();
Voltage = (float)adValue / 4095 * 3.3;//根据精度对应计算出电压值
OLED_ShowNum(5,5,adValue,4);
OLED_ShowNum(10,5,Voltage,4);
}

}

注意实验中OLED得到的AD值末尾持续抖动,这是正常波动

但是如果我们需要对该AD值进行判断,再执行一些操作时,比如AD值大于某一值开灯,小于某一值就关灯。此时由于AD值存在波动就可能会来回开灯关灯。

解决方法

  1. 使用迟滞比较法,设置上下阈值,高于上阈值开灯,低于下阈值关灯,类似于施密特触发器
  2. 如果数据跳变太厉害,可以使用均值滤波的方式,读取10~20个值取平均值作为输出结果
  3. 还可以裁剪分辨率去掉数据的尾数,也可以避免跳动

实验- ADC多通道采集

多通道采集,我们想到的是启动扫描模式,但是存在数据覆盖的问题。扫描模式是会一次将所有通道全部转换完之后才会发出EOC信号,每一个通道单独转换完成不会产生任何标志位,也不会有中断,同时AD转换很快,所以我们很难做到在一个通道转换完成后将数据手动转移。

但是很难不是不行,我们可以使用间断模式,扫描时没转换一个通道就暂停一次,我们此时可以转移数据,再继续触发,继续下一次转换。由于没有单个通道转换完成后没有标志位,我们只能通过Delay的方式延时等待转换时间,所以这种方式不推荐

所以如果我们想用扫描模式实现多通道,最好配合DMA来实现

扫描模式+DMA搬运实现ADC多通道采集见下一节DMA实验

其实我们可以使用单次转换非扫描模式实现,只需要为AD_GetValue添加一个ADC_Channel的参数,调用时都重新配置规则通道再触发转换即可。

1
2
3
4
5
6
7
8
while(1)
{
adValue1 = ADC_GetValue(ADC_Channel_0);
adValue2 = ADC_GetValue(ADC_Channel_1);
adValue3 = ADC_GetValue(ADC_Channel_2);
adValue4 = ADC_GetValue(ADC_Channel_3);
Delay_ms(100);
}

DMA

DMA简介

image-20241128195544055

DMA-直接内存访问,是一个数据转运小助手,主要是协助CPU完成数据转运的工作,无须CPU干预,CPU就可以干其他更重要的事

这里的外设一般指外设数据寄存器存储器一般指运行内存SRAM和程序存储器(存储变量数组和程序代码的地方)


存储器到存储器的数据转运,一般使用软件触发,比如从FLASH到SRAM,因为软件触发是以最快的方式一股脑搬运过去,越快越好

存储器到外设的数据转运,一般使用特定硬件触发(每个外设对应DMA通道),比如ADC一个通道转换完成后,硬件触发一次DMA,DMA再转运。这样得到的值才正确

常见的用途:

  • 最常见:配合ADC的扫描模式,解决ADC数据覆盖问题
  • 各外设提高效率

阅读参考手册获得所有介绍!!!

STM32存储器映像

image-20241128201135199

FLASH:存放我们编译后的程序以及常量数据(const等)

SRAM:程序的临时变量存储,变量地址都是以20开头

外设寄存器:对应每个外设的寄存器

内核外设只有NVIC和Systick,与其他外设不是一个厂家设计的,所以地址被分开了


1.在STM32的数据手册,也会有个存储器映像的图,里面可以查看各外设起始地址等

2.在对应代码的宏定义不断跳转我们可以看到定义的SRAM、外设等基地址也是上表中给出的地址,通过基地址+偏移量可以得到各外设地址等

DMA框图

image-20241129210721223

主要包括

  • 用于访问各个存储器的DMA总线

  • 内部的多个通道,可以进行独立的数据转运

  • 仲裁器,用于调度各个通道,防止产生冲突

  • AHB从设备:是DMA自身的寄存器,用于配置DMA参数

  • DMA请求,用于硬件触发DMA的数据转运

image-20241129204758768

寄存器:

各个外设都可以看成是寄存器,也是一种SRAM存储器,寄存器是一种特殊的存储器,一方面,CPU可以对寄存器进行读写,另一方面寄存器的每一位背后,都连接了一个线,这些线可以用于控制外设电路的状态,比如设置引脚高低电平,导通和断开开关等,或者多位组合起来当做计数器、数据寄存器等

寄存器是连接软件和硬件的桥梁,软件读写寄存器就相当在控制硬件的执行


外设寄存器,有些是只读的,有些是可读可写的,具体看参考手册上的介绍

image-20241129205958030

DMA请求:

请求就是触发的意思,DMA请求线路的触发源是各个外设,DMA请求就是DMA的硬件触发源,比如ADC转换完成、串口接收到数据时,需要触发DMA转运数据时,就会通过这条线路向DMA发出硬件触发信号,之后DMA就可以在执行数据转运的工作了。

FLASH :

这里是ROM只读存储器的一种,如果通过总线直接访问,无论是CPU还是DMA都是只读的,如果DMA转运的目的地址填写的是FLASH就会出错。

SRAM:可以任意读写

DMA 基本结构图

image-20241129213600867

图中所有参数都是使用结构体配置

有个方向控制的参数可以控制外设寄存器到存储器的方向

外设寄存器和存储器(Flash、SRAM)都有三个参数:

  1. 起始地址
  2. 数据宽度
  3. 地址是否自增

传输计数器:用于指定转运次数,是一个自建计数器,每转运一次,值减1,直到减小到0之后DMA停止转运,对应自增过后的地址恢复到起始地址的位置,以方便DMA开始新一轮转换

注意:不能在DMA开启时,写传输计数器,这是手册的规定!!!

需要写计数器时按照即可:DMA失能–写传输计数器–DMA使能

自动重装器:传输计数器减小到0之后是否需要恢复到最初的值,指定重装就是循环模式


触发控制:决定DMA什么时间进行转运,由M2M(Memory to Memory)参数决定软件触发还是硬件触发

软件触发:一般用于存储器到存储器的转运,这些转运不需要一定时机,参数M2M为1时为软件触发,并不是调用一个函数一次就触发一次,而是以最快的速度,连续不断的触发DMA,以最快速度将传输计数器减少到0,与外部中断和ADC的软件触发可能不太一样,可以理解为自动连续触发

软件触发和循环模式不能同时使用,因为软件触发是想把计数器清0,而循环模式会重载,DMA就停不下来了

硬件触发:一般用于与外设有关的转运,转运需要一定时机当硬件达到这些时机时传一个信号,触发DMA进行转运,比如ADC转换完成等参数M2M为0时为硬件触发,触发源可以选择ADC、串口、定时器等等


开关控制

DMA_Cmd函数

DMA转运需要有三个条件:

  1. 使能DMA,开关控制
  2. 传输计数器不为0
  3. 必须要有触发源

DMA请求映像

image-20241129223925869

每个通道有一个数据选择器,选择硬件触发或者软件触发,EN位决定数据选择器是否工作

硬件触发注意M2M = 0

每个外设触发通道都不一样,所以我们在选择硬件触发源时,一定要先找到对应通道触发,比如ADC1在通道1,定时器更新事件(TIM2_UP)在通道2

对应的有ADC_DMACmd、TIM_DMACmd函数等

软件触发M2M = 1

DMA和通道任意选择,每个通道软件触发都是一样的


优先级:

类似与中断优先级判断,通道号越小优先级越高,也可在程序中配置

数据宽度与对齐

image-20241129224639834

源端宽度目标宽度相同时和不同时的数据传输处理:与变量赋值类似

源端宽度=目标宽度,不变

源端宽度<目标宽度,高位补0

源端宽度>目标宽度,高位舍弃

例子

数据转运+DMA(存储器到存储器转运)

image-20241129225329602

不需要转运时间同步等,使用软件触发即可

ADC扫描模式+DMA(外设到存储器)

image-20241129225519836

我们需要在每个单独的通道转换完成后,进行一个DMA转运,并且使目的地址自增,源地址不自增,方向为外设到存储器,传输计数器7次

ADC连续扫描,DMA可以使用自动重装,使ADC和DMA同时工作

DMA转运时机需要和ADC单个通道转换同步,所以DMA触发要选择ADC硬件触发。

单个通道转换完成时应该可以触发DMA传输,否则无法完成

库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*初始化相关函数*/
void DMA_DeInit(DMA_Channel_TypeDef* DMAy_Channelx);
void DMA_Init(DMA_Channel_TypeDef* DMAy_Channelx, DMA_InitTypeDef* DMA_InitStruct);
void DMA_StructInit(DMA_InitTypeDef* DMA_InitStruct);

/**
* 使能DMA对应通道,第一个参数不再是DMAx,而是DMAy_x,即选择了哪个DMA,又选择了DMA的通道,DMA几的通道几
* 软件触发随意选择通道
* 硬件触发,先查看手册找到对应外设的通道在设置
*/
void DMA_Cmd(DMA_Channel_TypeDef* DMAy_Channelx, FunctionalState NewState);

/*DMA中断输出使能*/
void DMA_ITConfig(DMA_Channel_TypeDef* DMAy_Channelx, uint32_t DMA_IT, FunctionalState NewState);

/*传输计数器值设置和获取*/
void DMA_SetCurrDataCounter(DMA_Channel_TypeDef* DMAy_Channelx, uint16_t DataNumber);
uint16_t DMA_GetCurrDataCounter(DMA_Channel_TypeDef* DMAy_Channelx);

/*标志位相关函数*/
FlagStatus DMA_GetFlagStatus(uint32_t DMAy_FLAG);//获取标志位状态
void DMA_ClearFlag(uint32_t DMAy_FLAG);//清除标志位
ITStatus DMA_GetITStatus(uint32_t DMAy_IT);//获取中断状态
void DMA_ClearITPendingBit(uint32_t DMAy_IT);//清除中断挂起位

DMA配置流程

根据DMA基本结构图配置如下:

  1. RCC开启DMA时钟
  2. 配置DMA,调用DMA_Init,初始化各个参数(外设和存储器站点的起始地址、数据宽度、地址是否自增、方向、传输计数器、是否需要自动重装、选择触发源等参数)
  3. 如果需要DMA中断,调用DMA_ITConfig开启中断输出,配置NVIC各个参数,写好对应中断服务函数即可,不用中断可以直接跳过
  4. DMA使能,DMA_Cmd函数(别忘了对应在外设XXX_DMACmd开启触发信号输出)

实验-DMA数据转运(存储器到存储器)

DMA不涉及外围硬件电路,故.c/.h文件放在System文件夹中

1
2
3
4
5
6
7
8
/*DMA.h*/
#ifndef __DMA_H
#define __DMA_H

void DMAInit(uint32_t AddrA,uint32_t AddrB,uint32_t Size);

void DMA_Transfer();
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/*DMA.c*/

#include "stm32f10x.h" // Device header

uint16_t DMA_Size = 0;

void DMAInit(uint32_t AddrA,uint32_t AddrB,uint32_t Size)
{
//1.RCC开启DMA时钟
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE);

//2.配置DMA
DMA_InitTypeDef DMA_InitStruct;
DMA_InitStruct.DMA_PeripheralBaseAddr = AddrA;//外设站点起始地址
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; //外设站点数据宽度,这里以字节传输
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Enable;//地址是否自增(Increment),这里启用
DMA_InitStruct.DMA_MemoryBaseAddr = AddrB;//存储器站点起始地址
DMA_InitStruct.DMA_MemoryDataSize = DMA_PeripheralDataSize_Byte;//存储器站点数据宽度
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;//地址是否自增
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC ; // 传输方向(direction),这里选择的是外设站点作为数据源
DMA_InitStruct.DMA_BufferSize = Size; // 传输计数器的值,其实就是传输次数,一次传输上面设置的数据宽度的值
DMA_InitStruct.DMA_Mode = DMA_Mode_Normal; // 传输模式,就是是否使用自动重装。Normal不重装,circular是循环重装
DMA_InitStruct.DMA_M2M = DMA_M2M_Enable; //配置软件触发还是硬件触发位(0,1),Enable是软件触发
DMA_InitStruct.DMA_Priority = DMA_Priority_Medium; // 配置优先级,选择中等
DMA_Init(DMA1_Channel1,&DMA_InitStruct);//软件触发使用任意通道都可

//3.DMA使能
DMA_Cmd(DMA1_Channel1,DISABLE);

DMA_Size = Size;
}

/*修改传输计数器的值,使DMA能循环传输*/
void DMA_Transfer()
{
DMA_Cmd(DMA1_Channel1,DISABLE);
DMA_SetCurrDataCounter(DMA1_Channel1,DMA_Size);
DMA_Cmd(DMA1_Channel1,ENABLE);

while(DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET){};//等待转换完成

DMA_ClearFlag(DMA1_FLAG_TC1);//清除标志位
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/*main.c*/
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "DMA.h"

const uint8_t DataA[] = {0x01,0x02,0x03,0x04};//加上const发现存储在FLASH区
uint8_t DataB[] = {0,0,0,0};
int main(void)
{
OLED_Init();
DMAInit((uint32_t)DataA,(uint32_t)DataB,4);


OLED_ShowString(1,2,"DataA:");
OLED_ShowHexNum(1,8,(uint32_t)DataA,8);
OLED_ShowHexNum(2,2,DataA[0],2);
OLED_ShowHexNum(2,5,DataA[1],2);
OLED_ShowHexNum(2,8,DataA[2],2);
OLED_ShowHexNum(2,11,DataA[3],2);

OLED_ShowString(3,2,"DataB:");
OLED_ShowHexNum(3,8,(uint32_t)DataB,8);
OLED_ShowHexNum(4,2,DataB[0],2);
OLED_ShowHexNum(4,5,DataB[1],2);
OLED_ShowHexNum(4,8,DataB[2],2);
OLED_ShowHexNum(4,11,DataB[3],2);
while(1)
{
//DataA[0]++;
//DataA[1]++;
//DataA[2]++;
//DataA[3]++;

OLED_ShowString(1,2,"DataA:");

OLED_ShowHexNum(2,2,DataA[0],2);
OLED_ShowHexNum(2,5,DataA[1],2);
OLED_ShowHexNum(2,8,DataA[2],2);
OLED_ShowHexNum(2,11,DataA[3],2);

Delay_ms(1000);

DMA_Transfer();

OLED_ShowString(3,2,"DataB:");
OLED_ShowHexNum(4,2,DataB[0],2);
OLED_ShowHexNum(4,5,DataB[1],2);
OLED_ShowHexNum(4,8,DataB[2],2);
OLED_ShowHexNum(4,11,DataB[3],2);

Delay_ms(1000);
}

}

实验-DMA+ADC多通道采集

ADC多通道(连续转换+扫描模式+DMA循环转移):

1
2
3
4
5
6
7
8
9
10
11
12
13
/*AD.h*/
#ifndef __AD_H
#define __AD_H

extern uint16_t ADValue[2];

void AD_Init();

//void ADC_GetValue();



#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include "stm32f10x.h"                  // Device header

uint16_t ADValue[2];//有几个通道的结果长度就设定为几

void AD_Init()
{
//1.开启RCC时钟,ADC和GPIO,以及DMA
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE);

//2.ADC预分频器配置,72Mhz / 6 =12Mhz,不能超过14Mhz,至少6分频
RCC_ADCCLKConfig(RCC_PCLK2_Div6);

//3.GPIO配置为模拟输入
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_4;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

//4.规则通道配置,将通道4,5,分贝写入规则通道的第一个位置和第二个位置(序列1,序列2)
ADC_RegularChannelConfig(ADC1,ADC_Channel_4,1,ADC_SampleTime_239Cycles5);
ADC_RegularChannelConfig(ADC1,ADC_Channel_5,2,ADC_SampleTime_55Cycles5);
// ADC_RegularChannelConfig(ADC1,ADC_Channel_6,3,ADC_SampleTime_55Cycles5); 还有通道继续配置即可


//5.ADC转换器配置
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; //ADC转换模式,该参数为独立ADC工作,剩余的其他参数都是双ADC模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right;//数据右对齐
ADC_InitStruct.ADC_ExternalTrigConv =ADC_ExternalTrigConv_None;//这里选择软件触发,剩下的都为硬件触发
ADC_InitStruct.ADC_ContinuousConvMode = ENABLE;//启动连续转换模式
ADC_InitStruct.ADC_ScanConvMode = ENABLE;//是否启动扫描模式,这里多通道启动
ADC_InitStruct.ADC_NbrOfChannel = 2; // 指定扫描模式下总共使用的通道数,使用了几个通道就填几
ADC_Init(ADC1,&ADC_InitStruct);



//6.配置DMA
DMA_InitTypeDef DMA_InitStruct;
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;//ADC1寄存器地址
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; //ADC选择半字16位传输,对于12位,舍弃4位
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable;//地址是否自增(Increment),这里对同一个地方的值运输,不启用
DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)ADValue;//存放到SRAM中的数组中
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//以半字16位传输
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;//由于多个值,需要移动数组的地址,需要自增
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC ; // 传输方向(direction),这里选择的是外设站点作为数据源
DMA_InitStruct.DMA_BufferSize = 2; //其实就是传输次数,一次传输上面设置的数据宽度的值,这里两个通道一共传输2次
DMA_InitStruct.DMA_Mode = DMA_Mode_Circular; // 传输模式,就是是否使用自动重装。Normal不重装,circular是循环重装
DMA_InitStruct.DMA_M2M = DMA_M2M_Disable; // 配置硬件触发,M2M位为0
DMA_InitStruct.DMA_Priority = DMA_Priority_Medium; // 配置优先级,选择中等
DMA_Init(DMA1_Channel1,&DMA_InitStruct);//查看手册得到ADC1通道为1,只能使用通道1

//7.使能DMA和开启ADC到DMA输出
DMA_Cmd(DMA1_Channel1,ENABLE);
ADC_DMACmd(ADC1,ENABLE);//必须在ADC使能之前开启输出到DMA

//8.使能ADC
ADC_Cmd(ADC1,ENABLE);

//9.ADC校准
ADC_ResetCalibration(ADC1);//复位校准
while(ADC_GetResetCalibrationStatus(ADC1)== SET){};//RSTCAL寄存器由软件置1后开始复位校准,完成后硬件自动清0该位
ADC_StartCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1) == SET){};//CAL寄存器由软件置1后开始校准,校准结束后硬件自动清0该位

//10.ADC触发转换
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
/*连续转换,扫描模式下,DMA循环模式下只需要触发一次,ADC和DMA都不用等待,ADC和DMA就同时开始持续协同工作了*/
}


/*这里是触发使用单次转换时需要的*/
//void ADC_GetValue()
//{
// DMA_Cmd(DMA1_Channel1,DISABLE);
// DMA_SetCurrDataCounter(DMA1_Channel1,2);
// DMA_Cmd(DMA1_Channel1,ENABLE);
//
// ADC_SoftwareStartConvCmd(ADC1, ENABLE);//启动软件触发转换
//
// /*不需要等待ADC转换完成了,只需要等待DMA*/
// while(DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET){};//等待DMA传输完成
// DMA_ClearFlag(DMA1_FLAG_TC1);//清除标志位
//
//}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "AD.h"

float Voltage = 0;

int main(void)
{

OLED_Init();
AD_Init();

while(1)
{

Voltage = (float)ADValue[1] / 4095 * 3.3;//根据精度对应计算出电压值

OLED_ShowNum(5,5,ADValue[0],4);
OLED_ShowNum(10,5,ADValue[1],4);
OLED_ShowNum(15,5,Voltage,4);

Delay_ms(1000);
}

}

ADC连续转换+扫描模式下,配合DMA,只需要触发ADC转换后,每当有一个通道转换完成,DMA迅速将值转运到ADValue中,我们直接对ADValue的值进行读取即可


这里我们其实还可以再加一个定时器,ADC用单次扫描,再用定时器去定时触发

即:定时器触发ADC->ADC触发DMA

这样的好处就是整个过程完全自动,不需要手动进行操作,节省软件资源,实现硬件自动化

通信接口

接口简介

image-20241130220652564

这里列举的是最常用,最简单的配置,还有很多配置没有列出

全双工一般都有两根数据线,比如串口、SPI

串口通信

介绍

image-20241201171647997

串口是点对点通信,就是一对一之间通讯

图一USB转串口模块,上面是CH340芯片,可以将串口协议转换为USB协议,USB端可以接在电脑上,另一端串口引脚接在支持串口的芯片上

图二陀螺仪传感器模块,测量加速度,角速度,一边是串口引脚,一边是I2C引脚

图三蓝牙串口模块,上面的芯片可以和手机互联,实现手机遥控单片机的功能

CH340串口驱动

CH340芯片作为USB和串行通信之间的桥梁,它允许你的电脑通过USB端口与仅支持串行通信协议的设备(如许多基于STM32的开发板)进行通信。安装了正确的驱动后,电脑上的应用程序(例如串口调试工具、编程软件等)才能通过这个虚拟出来的COM端口与STM32开发板交换数据。

串口连接我们电脑时,对应设备管理器中找到是否有CH340的驱动,没有的安装即可,有的话就可以使用了。

image-20241203185913944

硬件电路

image-20241201172828258

一般串口通信模块有四个引脚:VCC、TX、RX、GND

简单的串口通信只有RX和TX两个信号线,复杂的还有时钟线

TX和RX是单端信号,它们的高低电平都是相对于GND,所以串口通信的RX、TX、GND是必须接的

对于VCC,如果两个设备都有独立供电,VCC可以不用接。如果其中一个设备没有供电,比如设备1是STM32,设备2是蓝牙串口模块,STM32有独立供电,蓝牙串口模块没有独立供电,此时就需要把蓝牙串口的VCC和STM32的VCC接在一起


两根通信线为全双工,只接一根通信线的情况下就变成了单工通信


电平标准不一致需要加电平转换芯片才能通信,一般设备直接出来的是TTL电平,需要另外一个也是TTL电平才能通信

电平标准

image-20241201173753315

TTL电平:单片机这种低压小型设备,使用的都是TTL电平,最远几十米。所以在单片机中如果线路对地是3.3V,就代表发送了逻辑1,对地是0V,就代表发送了逻辑0

RS232:最远几十米。

RS485:两线压差,所以电平是差分信号,差分信号的抗干扰能力非常强,使用RS485电平标准,通讯距离可以达到上千米

不同的电平间,加上电平转换芯片即可使用

串口参数及时序

image-20241201174831622

波特率:每秒传送码元的个数,单片机中的话一个码元对应一位,码元/s对应bit/s

起始位:空闲状态时为高电平,起始位为低电平产生一个下降沿,标志数据发送的开始

数据位低位先行,从数据的低位开始发送。可以把校验位算在数据位中,也可以把校验位单独出来

校验位:三种方式,无校验,奇校验,偶校验,根据数据来决定是0还是1

停止位:固定为高电平为下个起始位做准备


数据帧格式:

<--------10位/11位 ----------->

起始位(低电平)+数据位+停止位(高电平)

​ 1位 8/9位 1 位

8位数据位:不含有奇偶校验位

9位数据为:最后一位添加了一位奇偶校验位


USART简介

image-20241201231743375

一般串口很少使用同步功能,只是多了个时钟输出,只支持输出不支持输入,同步模式更多是为了兼容别的协议和其他用途,并不支持两个USART之间进行同步通信


硬件流控制:在A、B之间有一根单独的一根线,高低电平可以决定接收方是否准备好,准备好了再发送,可以防止接收方处理慢而导致数据丢失的问题。我们一般不使用

DMA:串口有大量的数据时,可以使用DMA,减小CPU负担,提高效率

USART框图

image-20241202174332315

发送数据寄存器TDR:只写,当数据从TDR全部到发送移位寄存器中后,TXE标志位(TX Empty)-发送数据寄存器为空,会置1,此时检查这个标志位为1的话就可以就可以写入下一个数据到TDR

发送移位寄存器:把一个字节的数据一位一位地移出去,正好对应串口协议的波形数据位。通过下方发送器控制,向右移位,一位一位地把数据输出到TX引脚,正好对应串口的低位先行,当数据移位完成后,新的数据会再次自动的从TDR移动到发送移位寄存器中(移位未完成时TDR会等待完成)。

发送整个过程连续,效率高


接收数据寄存器RDR:只读

接收移位寄存器:数据从RX引脚通向接收移位寄存器,在接受器控制下,一位一位地读取RX电平,先放在最高位,然后向右移。当这一个字节数据移位完成后,整体全部转移到接收数据寄存器RDR中,此时将RXNE(RX Not Empty )-接收数据寄存器非空,检测到RXNE置1之后,就可以将数据从RDR读走


硬件数据流控(流控)如果发送设备发得太快,接收设备来不及处理,就会出现丢弃或覆盖数据得现象,有了流控就可以避免这个问题。一般不使用!!!

对应有两个引脚:

nRTS(Request To Send):请求发送,是输出脚,告诉别人当前是否能接收,n代表低电平有效

nCTS(Clear To Send):是清除发送,是输入脚,用于接收别人nRTS信号b,n代表低电平有效

两个有流控的设备RTS和CTS交叉连接即可,CTS引脚接对方RTS引脚,用于判断对方能否接收,RTS引脚接对方CTS引脚,用于告诉对方我能不能接收


右边边的SCLK

产生同步的时钟信号,用于配合发送移位寄存器输出。只支持输出不支持输入,两个USART之间不能实现同步的串口通信。我们一般不使用

作用:

  1. 兼容别的协议,串口加上时钟后和SPI很像,所以可以兼容SPI协议
  2. 自适应波特率

唤醒单元:

实现多设备功能,一般不使用


中断控制:

两个标志位比较重要,TXE(发送中断标志位)和RXNE(接收中断标志位),这两个标志位可以去申请接收中断和发送中断,就可以在接收或者发送数据时,直接进入中断服务函数

其他的标志位看手册可以知道有什么作用


波特率发生器:

波特率发生器其实就是分频器,对APB时钟进行分频,得到发送器和接收器的时钟

对应所有的寄存器等更多知识都能在参考手册中查看!!!!

USART基本结构

image-20241202164204771

波特率发生器:用于产生约定的通信速率

对于TDR和RDR:在软件层面,只有一个DR寄存器可以让我们读写,只不过是在接收或者发送时走上面或者下面这条路

开关控制:对应的Cmd函数

数据帧

发送电路:

image-20241202164659015

四种选择:

8位字长,无校验

8位字长,有校验

9位字长,无校验

9位字长,有校验

起始位侦测

接收电路:

image-20241202165836050

以波特率的16倍进行采样,一位的长度中,每三位至少有2个0,否则认为是噪声。

采样位设置在8,9,10,之后也这样采样就能保证在数据中间

数据采样

image-20241202170417412

连续采样三次,无噪声下三次采样都为0或1

如果有噪声,以2:1进行确定,2次0,数据就为0,2次1,采样的数据就为1。此时会对噪声标志位NE置1,告诉收到数据,但有噪声

波特率发生器

image-20241202170724981

DIV(分频系数)分为整数部分和小数部分,可以实现更细腻的分频

16是因为内部有一个16倍波特率的采样时钟,所以**(fpclk2/1 / DIV)= 16 * 波特率(16倍波特率)**,最终计算波特率要多除以一个16

例:配置9600波特率

带公式:9600 = 72MHz /(16*DIV) ,得到DIV = 468.75

然后将DIV写入到该波特比率寄存器USART_BRR中,转换为2进制写入,有整数位和小数位

我们使用库函数直接输入需要的波特率9600即可,自动计算配置完成

数据模式

image-20241203190308931

通过串口助手显示的数据有以上两种方式:HEX和ASCII

HEX:以原始数据(0x41等)的形式显示,为十六进制

ASCII:将原始数据(0x41)按照ASCII编码码表对应显示(0x41对应编码为‘A’)

库函数

stm32f10x_usart.h中找到

初始化函数:

1
2
3
4
5
6
7
void USART_DeInit(USART_TypeDef* USARTx);
void USART_Init(USART_TypeDef* USARTx, USART_InitTypeDef* USART_InitStruct);
void USART_StructInit(USART_InitTypeDef* USART_InitStruct);

/*这两个函数用于配置同步时钟输出的,包括时钟使能,极性,相位等参数使用结构体*/
void USART_ClockInit(USART_TypeDef* USARTx, USART_ClockInitTypeDef* USART_ClockInitStruct);
void USART_ClockStructInit(USART_ClockInitTypeDef* USART_ClockInitStruct);

中断配置:

1
void USART_ITConfig(USART_TypeDef* USARTx, uint16_t USART_IT, FunctionalState NewState);

USART相关使能:

1
2
3
4
5
void USART_Cmd(USART_TypeDef* USARTx, FunctionalState NewState);
/*使能USART*/

void USART_DMACmd(USART_TypeDef* USARTx, uint16_t USART_DMAReq, FunctionalState NewState);
/*开启对应的USART到DMA的触发通道*/

USART发送和接收数据:

1
2
3
4
5
void USART_SendData(USART_TypeDef* USARTx, uint16_t Data);
//写DR寄存器

uint16_t USART_ReceiveData(USART_TypeDef* USARTx);
//读DR寄存器

标志位相关:

1
2
3
4
5
6
7
FlagStatus USART_GetFlagStatus(USART_TypeDef* USARTx, uint16_t USART_FLAG);
void USART_ClearFlag(USART_TypeDef* USARTx, uint16_t USART_FLAG);
/*终端外使用的标志位函数*/

/*中断服务程序中使用的标志位函数*/
ITStatus USART_GetITStatus(USART_TypeDef* USARTx, uint16_t USART_IT);
void USART_ClearITPendingBit(USART_TypeDef* USARTx, uint16_t USART_IT);

不常用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void USART_SetAddress(USART_TypeDef* USARTx, uint8_t USART_Address);//设置地址
void USART_WakeUpConfig(USART_TypeDef* USARTx, uint16_t USART_WakeUp);//唤醒
void USART_ReceiverWakeUpCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_LINBreakDetectLengthConfig(USART_TypeDef* USARTx, uint16_t USART_LINBreakDetectLength);//LIN
void USART_LINCmd(USART_TypeDef* USARTx, FunctionalState NewState);

/*一大段函数,智能卡、IrDA等相关函数不怎么使用*/
void USART_SendBreak(USART_TypeDef* USARTx);
void USART_SetGuardTime(USART_TypeDef* USARTx, uint8_t USART_GuardTime);
void USART_SetPrescaler(USART_TypeDef* USARTx, uint8_t USART_Prescaler);
void USART_SmartCardCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_SmartCardNACKCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_HalfDuplexCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_OverSampling8Cmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_OneBitMethodCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_IrDAConfig(USART_TypeDef* USARTx, uint16_t USART_IrDAMode);
void USART_IrDACmd(USART_TypeDef* USARTx, FunctionalState NewState);

USART配置流程

  1. RCC开启时钟,打开需要的USART和GPIO时钟

  2. 配置GPIO,对应TX配置为复用输出,RX配置为上拉或浮空输入

  3. 配置USART,使用结构体配置即可

  4. 如果需要接收中断,加上ITConfig和NVIC配置即可。如果不需要中断跳过配置即可

  5. 使能USART,USART_Cmd

实验-串口发送和串口接收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*usart.h*/
#ifndef __USART_H
#define __USART_H

/*包括对Usart的初始化,和对一些常用的函数封装*/
void UsartInit();

void UsartSend(uint8_t Data);//发送一个字符

void UsartSendArray(uint8_t* Array,uint8_t Length);//发送数组

void UsartSendString(char* String);//发送字符串

uint32_t UsartPow(uint32_t x,uint8_t y);//幂函数

void UsartSendNum(uint32_t Num,uint8_t Length);//发送数字

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
/*usart.c*/


#include "stm32f10x.h" // Device header


void UsartInit()
{
//1. RCC开启时钟,打开需要的USART和GPIO时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

//2.配置GPIO,对应TX配置为复用输出,RX配置为上拉或浮空输入,如果只选择一种模式,那么就只配置一个引脚即可
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_2;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);//TX引脚配置

GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_3;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);//RX引脚配置

//3.配置USART,使用结构体配置即可
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600;//波特率配置
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//硬件流控配置,这里不适用
USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; //这里配置串口接收和发送模式都使用,可以只配置一个
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位,不使用校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位配置1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b;//数据位长度,没有奇偶校验位,所以选择8位数据位
USART_Init(USART2,&USART_InitStruct);

/*如果需要使用中断,需要在此处调用USART_ITConfig函数,然后再配置NVIC即可*/


//4.使能USART,USART_Cmd
USART_Cmd(USART2,ENABLE);
}

void UsartSend(uint8_t Data)
{
USART_SendData(USART2,Data);
while(USART_GetFlagStatus(USART2,USART_FLAG_TXE) == RESET);//等待数据移动到移位寄存器中,避免发生数据覆盖
/*手册中可以知道,下一次调用SendData时该TXE标志位会自动清0*/

}

void UsartSendArray(uint8_t* Array,uint8_t Length)
{

for(int i =0;i<Length;i++)
{
UsartSend(Array[i]);
}

}

void UsartSendString(char* String)
{

for(int i=0;String[i]!='\0';i++)
{
UsartSend(String[i]);
}
}


uint32_t UsartPow(uint32_t x,uint8_t y)
{
uint32_t result = 1;

while(y--)
{
result*=x;
}

return result;
}

void UsartSendNum(uint32_t Num,uint8_t Length)
{

for(int i=0;i<Length;i++)
{
UsartSend((Num/UsartPow(10,Length-i-1))%10+'0');//加上一个字符0得到对应数字的ascii码
}


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "stm32f10x.h"                  // Device header
#include "usart.h"

int main(void)
{
uint8_t myArray[] = {0x42,0x43,0x44,0x45};

UsartInit();

UsartSend(0x41);

UsartSendArray(myArray,4);

UsartSendString("hello\r\nworld");//需要两个转义字符才能换行,\r代表回车,\n代表换行

UsartSendNum(65535,5);
while(1)
{
if()
}

}

实验-串口接收单字节(轮询+中断)

在while循环中轮询读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*main.c*/

uint8_t rData;

while(1)
{
if(USART_GetFlagStatus(USART2,USART_FLAG_RXNE)== SET)//手册可知该RXNE标志位再下方调用读取时会自动清0
{
rData = USART_ReceiveData(USART2);
}
OLED_ShowHexNum(2,2,rData,2);
}

使用中断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef __USART_H
#define __USART_H

#include <stdio.h>

void UsartInit();

void UsartSend(uint8_t Data);

void UsartSendArray(uint8_t* Array,uint8_t Length);

void UsartSendString(char* String);

uint32_t UsartPow(uint32_t x,uint8_t y);

void UsartSendNum(uint32_t Num,uint8_t Length);

uint8_t UsartGetFlag();//自定义标志位

uint8_t UsartGetData();//自定义Get方法
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/*usart.c*/

#include "stm32f10x.h" // Device header
#include "stdio.h"

uint8_t rData;
uint8_t rFlag=0;

void UsartInit()
{
//1. RCC开启时钟,打开需要的USART和GPIO时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

//2.配置GPIO,对应TX配置为复用输出,RX配置为上拉或浮空输入,如果只选择一种模式,那么就只配置一个引脚即可
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_2;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);//TX引脚配置

GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_3;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);//RX引脚配置

//3.配置USART,使用结构体配置即可
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600;//波特率配置
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;//硬件流控配置,这里不适用
USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; //这里配置串口接收和发送模式都使用,可以只配置一个
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位,不使用校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位配置1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b;//数据位长度,没有奇偶校验位,所以选择8位数据位
USART_Init(USART2,&USART_InitStruct);

/*如果需要使用中断,需要在此处调用USART_ITConfig函数,然后再配置NVIC即可*/

//4.配置中断和NVIC
USART_ITConfig(USART2,USART_IT_RXNE,ENABLE);

NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = USART2_IRQn;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStruct);

//5.使能USART,USART_Cmd
USART_Cmd(USART2,ENABLE);
}

uint8_t UsartGetData()
{
return rData;
}
//使用extern也是同样的效果’

uint8_t UsartGetFlag()
{
if(rFlag == 1)
{
rFlag = 0;
return 1;
}

return 0;
}//自定义清除标志位

void USART2_IRQHandler()
{
if(USART_GetITStatus(USART2,USART_IT_RXNE)== SET)
{
rData = USART_ReceiveData(USART2);
rFlag = 1;

USART_ClearITPendingBit(USART2,USART_IT_RXNE);
}

}
//中断服务函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "stm32f10x.h"                  // Device header
#include "OLED.h"
#include "usart.h"

int main(void)
{

uint8_t Data;
OLED_Init();

UsartInit();

while(1)
{
if(UsartGetFlag()== 1)
{
Data = UsartGetData();
UsartSend(Data);
}
OLED_ShowHexNum(2,2,Data,2);
}

}

该实验只能实现对单字符的接收

实验-USART串口数据包

实验-printf函数的移植

在Keil中,使用printf函数之前我们需要打开工程选项,找到Target,勾选Use MicroLIB(使用微库)

MicroLIB:是Keil为嵌入式平台优化的一个精简库

方法1:printf的重定向:printf默认输出到屏幕,但是单片机没有屏幕,所以需要我们重定向到串口

在对应串口模块中

1
2
3
4
5
6
7
8
9
10
11
12
/*该定义usart.c的结尾加上*/

#include <stdio.h>//该stdio.h头文件在usart.h中再进行引用一次,即可在调用usart.h时使用printf了

int fputc(int ch,FILE* f)
{
UsartSend(ch);
return ch;
}
/*fputc是printf函数底层,使用printf时,是不断调用fputc进行打印的,我们把fputc函数重定向到串口,printfzi*/


1
2
3
4
5
6
7
8
9
10
11
12
13
/*main.c中*/

int main()
{
UsartInit();

printf("Num=%d\r\n",66666);

while(1)
{

}
}

这种方法只能将printf输出到调用串口发送函数的那一个串口当中,无法在其他串口中使用。

解决方法:使用标准库函数sprintf

方法2:sprintf函数使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*main.c中*/

int main()
{
UsartInit();

char String[100];
sprintf(String,"Num=%d\r\n",99999);
UsartSendString(String);

while(1)
{

}
}

sprintf的作用是:发动格式化字符串到指定str中,可以实现拼接字符串,这样即可实现每个串口的格式化打印

方法三:使用可变参数封装sprintf

使用头文件<stdarg.h>

具体封装过程百度即可

数据包

数据包介绍

数据包分为HEX数据包文本数据包,分别对应文本模式和HEX模式

作用:将属于同一批的数据进行打包和分割。

  1. HEX数据包:

image-20241204165308342

通过添加包头包尾分割数据

数据包头:0xFF

数据包尾:0xFE


优点:传输最直接,解析数据非常简单,比较适合一些模块发送原始的数据,比如一些使用串口通信的陀螺仪、温湿度传感器数据

缺点:灵活性不足,载荷容易和包头包尾重复(可能传输的数据是包头包尾的数据)

解决方案:

  1. 限制载荷数据的范围,在发送时对数据变化范围显示
  2. 如果无法避免载荷数据和包头包尾重复,我们就尽量使用固定长度的数据包,经过几个数据包的数据对齐后,剩下的数据包应该就没有问题了
  3. 增加包头包尾的数量,并且尽量呈现出载荷数据出现不了的状态

HEX数据包格式选择

  1. 如果数据容易出现与包头包尾重复的情况,就最好选择固定包长
  2. 如果数据不容易出现与包头包尾重复的情况,可以选择可变包长,这样就非常的灵活,只需要确定唯一的包头包尾就知道数据包长度
  1. 文本数据包:

image-20241204184721167

在HEX数据包中,数据是以原始字节数据本身呈现,而在文本数据包中,每个字节都经过了编码和译码,表现出的就是文本格式,但实际上都还是一个字节的HEX数据

文本数据包基本不用担心载荷和包头包尾重复的问题

包头:‘@’

包尾:‘\r’ ‘\n’

载荷数据:除了包头包尾的数据


优点:数据直观易理解,非常灵活,比较适合一些输入指令进行人机交互的场合,比如蓝牙模块常用的AT指令,CNC和3D打印机常用的G代码

缺点:解析效率低,比如100,HEX发送的就是一个字节100,文本数据包却是三个字符’1’,‘1’,‘0’。

数据包的发送和接收

  1. 数据包的发送:使用串口对应发送**数组(HEX数据包)或者字符串函数(文本数据包)**即可

  2. 数据包的接收

  • 固定包长HEX数据包接收

image-20241204190804966

使用状态机编程思想:先根据项目要求定义状态,画几个圈,然后考虑号各个状态会在什么情况下会进行转移,如何转移,画好线和转移条件,最后根据这个来编程

  • 可变包长文本数据包接收

image-20241204192226109

实验-串口收发HEX数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
/*usart.h*/
#ifndef __USART_H
#define __USART_H

#include <stdio.h>
/*此处只列举出了新添加的变量和函数,具体的见上方*/

extern uint8_t RxPacket[4];
extern uint8_t TxPacket[4];

void UsartSendPacket();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/*usart.c*/

/*发送数据包,包头包尾自定义*/
void UsartSendPacket()
{
UsartSend(0xFF);//包头
UsartSendArray(TxPacket,4);//固定长度为4的数据
UsartSend(0xFE);//包尾
}

/*使用状态机的编程思想,每次中断接收一个字节数据*/
void USART2_IRQHandler()
{
static uint8_t RxState = 0;
static uint8_t count = 0;
if(USART_GetITStatus(USART2,USART_IT_RXNE)== SET)
{
uint8_t RxData = USART_ReceiveData(USART2);

if(RxState == 0)
{
if(RxData == 0xFF)
{
RxState = 1;
}
}
else if(RxState == 1)
{
RxPacket[count++] = RxData;

if(count>=4)
{
RxState = 2;
}
}
else if(RxState == 2)
{
if(RxData == 0xFE)
{
RxState = 0;
rFlag = 1;
}
}

USART_ClearITPendingBit(USART2,USART_IT_RXNE);
}
}


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/*main.c*/
#include "stm32f10x.h" // Device header
#include "OLED.h"
#include "usart.h"
#include "string.h"


int main(void)
{
LED_Init();
Key_Init();
OLED_Init();

UsartInit();



while(1)
{

if(UsartGetFlag()== 1)
{
UsartSendPacket(TxPacket);
OLED_ShowHexNum(1,2,TxPacket[0],2);
OLED_ShowHexNum(1,2,TxPacket[1],2);
OLED_ShowHexNum(1,2,TxPacket[2],2);
OLED_ShowHexNum(1,2,TxPacket[3],2);
}
}

}

使用串口助手发送FF 05 06 07 08 FE,可以发现串口回显FF 05 06 07 08 FE,OLED上显示05 06 07 08的数据

实验-串口收发文本数据包

1
2
3
4
5
6
7
8
9
10
11
/*usart.h*/

#ifndef __USART_H
#define __USART_H

#include <stdio.h>

extern char RxTextPacket[100];
extern uint8_t rFlag=0;//接受完成标志,还可以避免接收太快来不及使用数据就被覆盖了

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*usart.c*/

void USART2_IRQHandler()
{
static uint8_t RxState = 0;
static uint8_t count = 0;
if(USART_GetITStatus(USART2,USART_IT_RXNE)== SET)
{
uint8_t RxData = USART_ReceiveData(USART2);

if(RxState == 0)
{
if(RxData == '@'&&rFlag == 0)//接收慢一点,等主函数使用完,避免主函数中的数据可能是上一个数据包和这个数据包中的内容
{
RxState = 1;
}
}
else if(RxState == 1)
{
if(RxData == '\r')
{
RxState = 2;
}
else
{
RxTextPacket[count++] = RxData;
}

}
else if(RxState == 2)
{
if(RxData == '\n')
{
RxTextPacket[count] = '\0';//组成完成字符串
count = 0;

RxState = 0;
rFlag = 1;
}
}

USART_ClearITPendingBit(USART2,USART_IT_RXNE);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "stm32f10x.h"                  // Device header
#include "LED.h"
#include "usart.h"
#include "string.h"

int main(void)
{
LED_Init();
OLED_Init();
UsartInit();

while(1)
{

if(rFlag == 1)
{
if(strcmp(RxTextPacket,"LED_ON")== 0)
{
LED1_ON();
UsartSendString("LED1_ON_OK\r\n");
}
else if(strcmp(RxTextPacket,"LED_OFF")== 0)
{
LED1_OFF();
UsartSendString("LED1_OFF_OK\r\n");
}

else
{
UsartSendString("ERROR\r\n");
}

rFlag = 0;//使用完成数据标志,可以避免接收太快,之前那种方式可能会造成接收过快数据覆盖问题
}
}

}

使用串口助手发送@LED_ON + 换行回车,LED灯亮起

使用串口助手发送@LED_OFF + 换行回车,LED灯熄灭

I2C

I2C介绍

image-20241206141429374

SPI特点:同步、半双工,两根通信线SCL和SDA


第1个模块:MPU6050模块,进行姿态测量,使用I2C通信协议

第2个模块:OLED模块,显示图片,字符灯信息,使用I2C协议

第3个模块:AT24C02,存储器模块,使用I2C协议

第4个模块:DS3231,实时时钟模块,使用I2C协议


I2C多主多从:相当于发生总线冲突,I2C协议会进行仲裁,胜利的一方取得总线控制权,失败的一方变回从机。同时在多主机的模型下,还要进行时钟同步

硬件电路与优缺点

image-20241206144150840

一主多从模式:

SCL上挂载多个从设备,任何情况下从机不允许控制SCL,对于SDA数据线,从机不允许主动发起对SDA的控制,只有等待主机发送请求时,从机响应才能短暂获取


I2C采用外置弱上拉电阻加开漏输出的电路结构

1.选择开漏输出,而不选择推挽输出:

如果使用推挽输出,如果总线时序没调整好,可能主机和从机都处于输出状态,且一个输出高电平,一个输出低电平,此时就会出现电源短路的问题。

而开漏输出只能直接输出低电平,高电平下没有驱动能力,输出取决于外部电路。

这样的话,就保证了所有设备都只能输出低电平而不能输出高电平

2.加上拉电阻:

为了避免开漏输出高电平时造成的引脚浮空,同时I2C通信需要输出高电平的能力,由于开漏输出下不能直接输出高电平,此时需要在外部加一个上拉电阻,此时为弱上拉,使能被外部拉高

这样设计电路的好处:

  1. 完全杜绝电源短路现象,保证电路安全
  2. 避免引脚模式的频繁切换,开漏加弱上拉的模式,同时兼具了输入和输出的功能
  3. 开漏模式下具有"线与"的特性,即:只要有任意一个或多个设备输出了低电平,总线就处于低电平,只有所有的设备输出高电平,总线才输出高电平。I2C可以利用这个特性,执行多主机模式下的时钟同步和总线仲裁。

限制:

由于I2C开漏外加上拉电阻的电路结构,使得通信线高电平的驱动能力比较弱,导致通信线由低电平转换为高电平时,上升沿耗时会比较长,这样就限制了I2C的最大通信速度,所以I2C标准模式为100Khz,快速模式也只有400Khz,一般来说我们就认为I2C最快速度为400khz,相对于SPI的速度来说慢了许多

I2C时序基本单元

  • 1.起始条件和终止条件:

image-20241206170442283

正常情况下:SCL和SDA都处于高电平

起始条件:S/Sr

终止条件:P

从机收到起始条件后,自身复位,等待主机发送信息

  • 2.主机发送字节时序

image-20241206170806836

I2C发送字节与串口不同,I2C是高位先行,串口是低位先行

发送字节过程总结下来就是

时钟线SCL处于低电平时,主机设置数据线SDA的电平

时钟线SCL处于高电平时,从机读取数据线SDA的电平。

显然,从机读取到的电平就是主机在时钟线低电平时设置的电平


对中断处理:

如果主机一个字节发送一半,突然进中断了,那么此时SCL和SDA上的电平暂停变化,会不断拉长,传输完全暂停,等中断结束后,主机回来继续操作


SCL和SDA控制权:

该过程中,SCL和SDA都由主机掌控,从机只能被动读取

  • 主机接收字节时序:

image-20241206172222919

发送字节过程总结下来就是

时钟线SCL处于低电平时,从机设置数据线SDA的电平

时钟线SCL处于高电平时,主机读取数据线SDA的电平。

显然,主机读取到的电平就是从机在时钟线低电平时设置的电平


SCL和SDA控制权:

SCL全程由主机控制

主机在接收之前,需要释放SDA(SDA拉高),此时让从机获得SDA控制权才能将数据放到SDA上!!!

  • 3.应答机制:

image-20241206172908982

根据应答机制,可以判断对方是否接收到了该数据

0:表示从机应答

1:表示从机非应答

I2C从机地址

从机地址介绍:

从机地址,每个从机设备出厂时都会分配一个7位或者10位地址,对应地址是什么可以在对应模块找到地址,比如MPU6050的7位地址为1101 000,AT24C02的7位地址为1010 000


相同芯片挂载时对地址的处理:

一般不同芯片地址不同,相同型号芯片地址相同。

当相同芯片挂载在同一条总线时,此时需要用到地址中的可变部分,一般地址最后几位是可以在电路中改变的,比如MPU6050,最后一位由芯片上的AD0引脚决定,当该引脚接低电平就是1101 000,接高电平就是1101 001。而AT24C02地址的最后三位分明由引脚A0,A1,A2决定

一般I2C的从机设备地址,高位都是厂商确定,低位可以由不同引脚切换,这样就可以保证多个相同芯片挂载时地址都不一样

I2C时序

时序1:指定地址写(常用)

image-20241206174948487

image-20241206175159856

该时序是对指定设备的指定寄存器写入指定数据的操作

上图为逻辑分析仪的结果:

  1. 最开始为起始条件

  2. 之后,必须是发送一个字节的时序,内容为从机地址+读写位(7+1共8位)

上图对应为:1101 000 0(前七位为MPU6050地址,最后一位为0代表写入操作)

  1. 紧跟着的单元就是,接收从机的应答位(ACK,RA)。RA之后有个上升沿,代表从机释放SDA产生,交出了SDA控制权。

上图对应为:高电平器件,主机读取SDA,发现是0,代表主机进行寻址,有人应答了

  1. 然后的一个字节为寄存器地址。

上图为00011001(0x19),即操作0x19地址的寄存器

  1. 紧接着又是从机应答位

  2. 然后发送写入寄存器的内容

上图为10101010,即发送数据为0xAA

  1. 又是一个从机应答位

  2. 最后一个为终止条件


指定地址连续写入多个字节:

如果想要发送多个字节,只需要将第6步发送写入字节和接收应答重复N次即可,写入几个字节就重复几次。

因为地址指针会在写入一个数据后自动+1,所以不用移动地址指针

时序2:当前地址读(不怎么使用):

image-20241206213240665

image-20241206213259135

  1. 起始条件
  2. 主机发送一个字节(从机地址+读写位),发送完后紧跟一个从机应答ACK

图上为1101000 1 表示读取1101000的数据

  1. 然后SDA控制权移交给从机,主机开始读取从机发送数据(不用发送读取寄存器地址,从机会将当前地址指针指向的寄存器的值发送),不能指定读的地址。接收完后紧跟发送一个非应答(SA:1),这样从机读取到主机非应答后,就知道主机不想要继续了,交还SDA控制权

图上接收的数据为00001111(0x0F)

  1. 结束条件

由于不能指定读的地址。所以该时序(当前地址读)用的不多


当前地址连续读多个字节:

如果想要读取多个字节,3处的读取一个字节后,就应该发送应答(RA:0),然后重复读取字节,发送应答,最后一个字节读取完后,跟上一个非应答就可以结束读取

因为地址指针也会在读取一个数据后自动+1,所以不用移动地址指针

时序3:指定地址读(常用):

image-20241206214232444

image-20241206214817354

image-20241206214842186

指定地址读为复合格式,是指定地址写和当前地址读的混合

复合格式:前面部分为指定地址写(写的数据的前面),后面部分为当前地址读


  1. 起始条件
  2. 发送一个字节,从机地址+读写位(此处应为写位为0),然后是从机应答位ACK(RA:0)
  3. 在发送一个字节,指定写入的地址,指定完成后从机寄存器指针就指向该地址,然后是从机应答位ACK(RA:0)

该部分为指定地址写前面部分


  1. (这里前面可以加一个停止,也可以不加)不发送写入的内容,而重复起始条件Sr(Start Repeat),相当于另起一个时序
  2. 重新发送一个字节,从机地址+读写位(此处应为读位为0),从机应答位ACK(RA:0)
  3. 主机接收一个字节,主机接收到的数据就是指定写入地址处的数据,然后发送一个非应答(SA:1)
  4. 停止条件

连起来就是:先起始,写入地址,停止。再起始,读当前位置,停止


指定地址连续读多个字节:

如果想要读取多个字节,6处的读取一个字节后,就应该发送应答(RA:0),然后重复读取字节,发送应答,最后一个字节读取完后,跟上一个非应答就可以结束读取

因为地址指针也会在读取一个数据后自动+1,所以不用移动地址指针

MPU6050

MPU6050简介

image-20241207201409336

6轴姿态传感器:3轴加速度(加速度计) + 3轴角速度(陀螺仪传感器)

9轴姿态传感器:3轴加速度 + 3轴角速度 + 3轴磁场强度(磁力计)

10轴姿态传感器:3轴加速度 + 3轴角速度 + 3轴磁场强度 + 1个气压强度(气压计)

一些术语:

加速度计:简称为Accel或者Acc或A,X,Y,Z三轴都有一个测量加速度。上面第二幅图就是加速度计,实际上加速度就是一个就是一个测力计。

使用加速度计求角度的时候只能在物体静止的时候使用,当物体运动起来时,这个角度会受运动加速度的影响而变得不准确。

即特性:加速度计具有静态稳定性,不具有动态稳定性


陀螺仪传感器:简称为Gyro或者G,可以测量三轴的角速度值,分别表示芯片绕X轴、Y轴、Z轴旋转的角速度,对应上面第三幅图,为陀螺仪的机械模型。该芯片只能测得角速度,而不能直接测得角度,通过对角速度积分可以得到角度

当物体静止时,角速度的值会因为噪声无法完全归零,经过积分的不断累积,小噪声就会导致计算出来的角度产生缓慢的漂移,角速度积分得到的角度经不起时间的考验,但角度无论是运动还是静止都是没问题的

特性:陀螺仪具有动态稳定性,不具有静态稳定性


姿态解算:

这两个传感器特性刚好互补,通过互补滤波就能融合得到静态和动态都稳定的姿态角了。


了解姿态角/欧拉角:

什么是欧拉角/姿态角?_欧拉角和姿态角区别-CSDN博客

MPU6050参数

image-20241207205112946

部分重要参数,具体参数清查看MPU6050手册

芯片输出的是一个随姿态变化而变化的电压,要想量化电压那么就需要AD转换器,芯片自带16位ADC,输出结果是有符号数


满量程:决定对应16位ADC值达到最大值时对应的物理参量,具有多个选择,根据具体物体运动的剧烈程度(实际情况)选择,避免超过满量程。

量程越小,分辨率越高,量程越大,范围越广


低通滤波:如果输出数据抖动很厉害,可以加一点低通滤波,这样就会更平缓

可配置时钟源和采样分频:两个参数配合使用,时钟源通过采样分频为AD转换和内部其他电路提供时钟,控制分频系数就可以控制AD转换的快慢了


对从机地址的处理

第一种从机地址:110 1000(0x68)

第二种从机地址(读写位):1101 0000(0xD0)或者1101 0001(0xD1)

所以这两种从机地址说法都是正确的,一个是没带上读写位的从机地址,一个是带上从机位的地址。

实际发送一个字节时,只需要不带读写为的从机地址左移1位,再或上读写位即可

(0x68 << 1 ) | 1/0

也可以直接就写融入读写位的从机地址

0xD1/0xD0

硬件电路

image-20241207221537437

最右边的是MPU6050的芯片

芯片本身引脚非常多,包括I2C通信引脚,供电,帧同步等,很多引脚用不到,还有些引脚是芯片最小系统里的固定连接,一般手册中有


左下角是8针的排针

  • VCC和GND:电源供电
  • SCL和SDA:I2C通信引脚,可以看到外部电路已经内置两个4.7K上拉电阻了,直接接即可,不用再接上拉电阻了
  • XCL、XDA:主机I2C引脚,设计是为了扩展芯片功能,6轴传感器不够时,这个XCL和XDL用于外接磁力计或者气压计扩展为9轴传感器等,接上后MPU6050主机接口可以直接访问这些扩展芯片的数据,将数据读入MPU6050,里面有DMP单元进行数据融合和姿态解算。

如果不需要解算功能,可以直接把磁力计或气压计挂载到SCL和SDA上,因为I2C本来就可以挂载多设备

  • AD0:7位从机地址最低位,电路中可以看到默认若下拉到低电平所以悬空状态下为0。对应从机地址为1101 000,接VCC的话就是1101 001
  • INT:中断输出引脚,可以配置芯片内部事件触发中断引脚输出,如:数据准备好了、I2C主机错误等,不使用可以不配置

除此之外,芯片内部还内置了一些实用的小功能:自由落体监测、运动监测、零运动监测等,这些信号都可以触发INT引脚产生电平跳变,可以进行中断信号的配置


左上角是LDO,低压差线性稳压器

这部分是供电的逻辑,手册中可以看到MPU6050的VDD供电为2.375~3.46V,属于3.3V供电设备,不能直接接5V,为了扩大供电范围,就加了个3.3V的稳压器,使输入端电压VCC_5V可以再3.3V到5V之间,经过3.3V的稳压器给芯片端供电。最后跟上的是一个电源知识灯,如果有点就会亮。

这一模块是否需要可以根据需求来,如果已经有稳定的3.3V的电源就不需要这部分了

MPU6050框图

image-20241207225256921

时钟部分:一般使用内部时钟,硬件电路上也是直接接地和没有引出


灰色部分

是芯片内部传感器,包括XYZ轴的加速度计(X Accel等),XYZ轴的角速度计(X Gyro等)

还内置了一个温度传感器(Temp Sensor),也可用于测量温度。

这些传感器转换完成后,数据统一放在传感器寄存器(Sensor Register)中,读取该数据寄存器就能得到传感器测量的值。

芯片内部转换都是全自动进行(类似与AD连续转换+DMA转运),每个ADC输出对应都是16位数据寄存器,不存在数据覆盖问题。需要数据直接读取即可


最左边Self test

这是芯片的自测单元,当启动自测后,芯片内部会模拟一个外力施加在传感器上,这个外力导致传感器数据会比平时大一些。

自测方法:先使能自测,读取数据,再失能自测,读取数据。两个数据相减得到的数据叫做自测响应,芯片手册中给出了一个范围,如果在这个范围内,代表芯片没问题。如果不在,就说明芯片可能坏了,使用的话就要小心点。

手册找到自测范围如下:在这个范围内就代表芯片正常

image-20241207235118026


最下方Charge Pump:电荷泵,进行升压操作


最右边是寄存器和通信接口部分:

很多寄存器:配置寄存器等

通信接口:从机I2C和SPI接口,主机I2C接口用于扩展设备通信

里面有个Digital Motion Processor-数字运动处理器简称DMP,是芯片内部自带的一个姿态解算的硬件算法,配合官方DMP库可以进行姿态解算


右下角为供电部分:按照手册电压要求和参考电路接线即可

MPU6050产品手册

I2C通信,电气特性等知识均可查看

MPU6050寄存器映像手册

所有的寄存器都在里面,但是不需要全部了解

需要了解的如下:每个寄存器具体介绍在手册向下翻即可

image-20241207233440068

SMPLRT_DIV采样频率分频寄存器,配置采样寄存器的分频系数,分频越小,内部AD转换越快,数据刷新越快。

采样频率 = (陀螺仪晶振)陀螺仪输出时钟频率/(1+分频值)

image-20241218223941780

Bit7~Bit0:值越小,越快,根据实际需求来

对应配置:0x09 (对应10分频)


CONFIG配置寄存器,分为外部同步设置和低通滤波器配置,外部同步这里不使用不看。低通滤波器可以使输出数据更平滑,选择值为0~7,数值越大,抖动越下,0代表不滤波

image-20241218224001365

Bit7~6:无关位

Bit5~3:外部同步,不需要,给0即可

Bit2~0:数字低通滤波器,也是根据需求配置。我们给个110,比较平滑的配置

对应配置为:0x06


GYRO_CONFIG陀螺仪配置寄存器,高3位是XYZ轴的自测使能位,中间两位是满量程选择位,最后三位没使用

image-20241218224017264

Bit7~Bit5:自测使能位,不自测给0即可

Bit4~Bit3: 满量程选择,根据实际需求选择。我们给11为最大量程

Bit2~Bit0:无关位

对应配置为:0x18

image-20241207235348661


ACCEL_CONFIG加速度计配置寄存器,高3位是XYZ轴的自测使能位,中间两位是满量程选择位,最后三位用于配置高通滤波器(内置小功能会用到)

Bit7~Bit5:自测使能位,不自测给0即可

Bit4~Bit3: 满量程选择,根据实际需求选择。我们给11为最大量程

Bit2~Bit0:高通滤波器,不使用给00

对应配置为:0x18

image-20241207233637672

加速度计XYZ轴寄存器:_L 表示低8位,_H表示高8位

ACCEL_XOUT_H :

ACCEL_XOUT_L :

ACCEL_YOUT_H :

ACCEL_YOUT_L :

ACCEL_ZOUT_H :

ACCEL_ZOUT_L :

加速度计XYZ轴寄存器:_L 表示低8位,_H表示高8位

GYRO_XOUT_H:

GYRO_XOUT_L :

GYRO_YOUT_H :

GYRO_YOUT_L :

GYRO_ZOUT_H :

GYRO_ZOUT_L :

温度传感器寄存器:

TEMP_OUT_H :

TEMP_OUT_L :

查看对应手册的寄存器讲解部分即可

image-20241207234205598

具体某位干什么查看手册对应寄存器功能

PWR_MGMT_1:电源管理寄存器1:

image-20241208000537943

image-20241218222543311

Bit7(DEVICE):设备复位,配置给0不复位

Bit6(SLEEP):设备睡眠,配置时给0不睡眠

Bit5(CYCLE):循环模式,配置给0不循环

Bit4(无关位):配置给0即可

Bit3(温度传感器失能):配置给0,不失能

Bit2~0(选择时钟):000选择内部时钟,手册非常建议选择陀螺仪时钟,我们配置为001,选择X轴陀螺仪时钟

对应配置为:0x01


PWR_MGMT_2:电源管理寄存器2:

image-20241218223451238

Bit7~6:循环模式唤醒频率,给00,不需要

Bit5~0:后六位为每个轴的待机位,不需要待机,全部给0即可

对应配置为:0x00


WHO_AM_I:器件ID号:只读的,中间六位固定为110100

image-20241208000027373

image-20241208000214450

注意这里写的是:所有寄存器上电后默认都是0x00,除了107号寄存器和117号寄存器

107号电源管理寄存器1,默认为0x40(01000000),可以看到SLEEP位为1,及上电默认睡眠模式,使用时记得解除睡眠模式,否则操作其他寄存器是无效的

117号:器件ID寄存器配置初始化值为0x68

image-20241208000407336

软件模拟I2C读写

使用任意GPIO口模拟即可,步骤为:

  1. 开启对应RCC时钟
  2. 配置GPIO口为开漏输出
  3. 配置SCL和SDA引脚默认高电平

软件I2C代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*MyI2C.h*/
#ifndef __MYI2C_H
#define __MYI2C_H

void MyI2C_Init();


/*基本单元1:起始条件SCL高电平期间,SDA从高电平切换到低电平*/
void MyI2C_Start();


/*基本单元1:结束条件SCL高电平期间,SDA从低电平切换到高电平*/
void MyI2C_Stop();


/*基本单元2:发送一个字节,1位1位的发送,从最高位开始*/
void MyI2C_SendByte(uint8_t Byte);

/*基本单元2:接收一个字节,SCL低从机设置,SCL高主机读取*/
uint8_t MyI2C_ReceiveByte();


/*基本单元3:发送应答,逻辑和发送字节类似*/
void MyI2C_SendAck(uint8_t AckBit);


/*基本单元3:接收应答,逻辑和接收字节类似*/
uint8_t MyI2C_ReceiveAck();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
/*MyI2C.c*/
#include "stm32f10x.h" // Device header

#include "Delay.h"

void MyI2C_W_SCL(uint8_t BitVal)
{
GPIO_WriteBit(GPIOA,GPIO_Pin_2,(BitAction)BitVal);
Delay_ms(10);//延时用于控制每个时序步骤之间的时间,确保I2C时序的稳定性。
}



void MyI2C_W_SDA(uint8_t BitVal)
{
GPIO_WriteBit(GPIOA,GPIO_Pin_3,(BitAction)BitVal);
Delay_ms(10);//延时用于控制每个时序步骤之间的时间,确保I2C时序的稳定性。
}

uint8_t MyI2C_R_SCL()
{
uint8_t bitValue;
bitValue = GPIO_ReadInputDataBit(GPIOA,GPIO_Pin_2);
Delay_ms(10);

return bitValue;
}

uint8_t MyI2C_R_SDA()
{
uint8_t bitValue;
bitValue = GPIO_ReadInputDataBit(GPIOA,GPIO_Pin_3);
Delay_ms(10);

return bitValue;
}

/*通过前面几个函数的封装我们实现了函数名称、端口号的替换,需要替换端口或者移植时就只需要对前四个函数修改即可,这样编程很方便*/

void MyI2C_Init()
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

GPIO_SetBits(GPIOA,GPIO_Pin_2 | GPIO_Pin_3);

}

/*基本单元1:起始条件SCL高电平期间,SDA从高电平切换到低电平*/
void MyI2C_Start()
{
MyI2C_W_SDA(1);//Sr重复起始条件时SCL是低电平,SDA高低电平不确定,所以先拉高SDA再拉高SCL确保都能回归高电平
MyI2C_W_SCL(1);

MyI2C_W_SDA(0);//SDA先拉低
MyI2C_W_SCL(0);

}
/*基本单元1:结束条件SCL高电平期间,SDA从低电平切换到高电平*/
void MyI2C_Stop()
{
MyI2C_W_SDA(0);//SCL一定为低电平,SDA高低电平不确定,为了确保释放SDA时能产生上升沿,要先拉低SDA

MyI2C_W_SCL(1);//SCL先拉高
MyI2C_W_SDA(1);

}

/*基本单元2:发送一个字节,1位1位的发送,从最高位开始*/
void MyI2C_SendByte(uint8_t Byte)
{

// /*发送最高位*/
// 进来时SCL为低电平
// MyI2C_W_SDA(Byte & 0x80);//因为保证了SCL是低电平,所以此时直接放入数据即可
// MyI2C_W_SCL(1);//SCL先拉高后从机会立刻读取刚刚放入的数据
// MyI2C_W_SCL(0);//SCL拉低完成一个脉冲
//
// /*发送次高位*/
// MyI2C_W_SDA(Byte & 0x40);
// MyI2C_W_SCL(1);
// MyI2C_W_SCL(0);
//
// .......

/*这里使用循环直接实现8个字节的发送,SCL低主机设置,SCL高从机读取*/
for(int i =0;i<8;i++)
{
MyI2C_W_SDA(Byte & (0x80 >> i));
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}

}

/*基本单元2:接收一个字节,SCL低从机设置,SCL高主机读取*/
uint8_t MyI2C_ReceiveByte()
{
uint8_t Byte = 0x00;

MyI2C_W_SDA(1);//主机在接受数据前需要将SDA释放让从机获取SDA控制权,使从机能够将数据放入SDA
for(int i=0;i<8;i++)
{
MyI2C_W_SCL(1);
if(MyI2C_R_SDA()==1) //从机发送的数据
{
Byte |= (0x80 >> i); // 设置第i位为1,否则为0
}
MyI2C_W_SCL(0);
}

return Byte;
}


/*基本单元3:发送应答,逻辑和发送字节类似*/
void MyI2C_SendAck(uint8_t AckBit)
{
//进来时SCL为低电平
MyI2C_W_SDA(AckBit);//主机发送ACK
MyI2C_W_SCL(1);//SCL为高,从机接收ACK
MyI2C_W_SCL(0);//结束一个脉冲


}

/*基本单元3:接收应答,逻辑和接收字节类似*/
uint8_t MyI2C_ReceiveAck()
{
uint8_t AckBit = 0x00;

//进来时SCL为低电平
MyI2C_W_SDA(1);//释放SDA使从机能操控SDA线(使其具有能拉高或拉低SDA的能力),在SCL置高电平之前,从机将ACK放到SDA线上

MyI2C_W_SCL(1);
AckBit = MyI2C_R_SDA();//读取ACK
MyI2C_W_SCL(0);

return AckBit;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*main.c*/
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "usart.h"
#include "MyI2C.h"

int main(void)
{

UsartInit();
MyI2C_Init();

MyI2C_Start();
MyI2C_SendByte(0xD0); // 1101000 0 MPU6050地址+写位
// MyI2C_SendByte(0xB0); // 1101000 0 MPU6050地址+写位
uint8_t ack = MyI2C_ReceiveAck();
MyI2C_Stop();


while(1)
{
UsartSend(ack);
}

}

测试是否能收到ACK,改变对应从机地址后是否应答

MPU6050-测试读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*MPU6050.h*/
#ifndef __MPU6050_H
#define __MPU6050_H



void MPU6050_Init();

/*指定地址写对应时序*/
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data);


/*指定地址读对应时序,指定写+当前读*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress);


#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/*MPU6050.c*/
#include "stm32f10x.h" // Device header
#include "MyI2C.h"

#define MPU6050_ADDRESS 0xD0


void MPU6050_Init()
{
MyI2C_Init();
}


/*指定地址写对应时序*/
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data)
{
/*1.起始,对应从机地址MPU6050,应答*/
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();//可以对应答位判断是否收到从机数据进行处理,这里默认应答


/*2.发送要写入的寄存器地址,应答*/
MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();

/*3.发送写入数据*/
MyI2C_SendByte(Data);
MyI2C_ReceiveAck(); //这里只发送一个字节

// for(int i=0;i<8;i++)
// {
// MyI2C_SendByte(Data);
// MyI2C_ReceiveAck();
// }
/*当要写入多个字节时,使用循环依次将数组的各个字节发送出去*/

/*4.结束*/
MyI2C_Stop();
}

/*指定地址读对应时序,指定写+当前读*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;

MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();

MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();

/*前一半为写的时序*/

/*后一半为读的时序*/
MyI2C_Start();//Sr
MyI2C_SendByte(MPU6050_ADDRESS|0x01);//最后一位改成读
MyI2C_ReceiveAck();

/*接收从机数据*/
Data = MyI2C_ReceiveByte();
MyI2C_SendAck(1);//想继续读数据给应答,这里只读一位,直接不应答了

// for(int i=0;i<8;i++)
// {
// Datas[i] = MyI2C_ReceiveByte();
// if(i==7)
// {
// MyI2C_SendAck(1);//最后一位读取后不应答
// break;
// }
// MyI2C_SendAck(0);//读取多个数据给应答
// }
// 多个数据读取

MyI2C_Stop();

return Data;
}

读功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*main.c*/

#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "usart.h"
#include "MPU6050.h"

int main(void)
{
uint8_t Id = 0;

UsartInit();
MPU6050_Init();

/*读取寄存器*/
Id = MPU6050_ReadReg(0x75);//WHO_AM_I,ID寄存器

while(1)
{
UsartSend(Id);
Delay_ms(2000);
}

}

image-20241218220447684

读读取0x75后,得到的结果是0x68,与手册对应代表一字节时序正确

写功能

在使用写功能是必须先接触睡眠模式,否则操控其他寄存器无效

image-20241218220917107

睡眠模式由该电源资源管理寄存器的SLEEP位控制,对应寄存器地址为0x6B,初始值为0x40(0100000),SLEEP位为1

只需要对该寄存器写入0x00即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "usart.h"
#include "MPU6050.h"
int main(void)
{

uint8_t Data = 0;

LED_Init();

UsartInit();
MPU6050_Init();

MPU6050_WriteReg(0x6B,0x00);//电源资源管理寄存器,必选先接触睡眠模式

MPU6050_WriteReg(0x19,0xAA);//采样分频寄存器

Data = MPU6050_ReadReg(0x19);

while(1)
{
UsartSend(Data);
Delay_ms(2000);
}

}

测试发现对应寄存器值修改成功,串口显示AA

MPU6050-读取加速度/角速度

当需要使用的寄存器宏定义比较多时,建议单独创建头文件管理。

该头文件为MPU6050部分寄存器,从MPU6050寄存器手册搬过来即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#ifndef __MPU6050_REG_H
#define __MPU6050_REG_H

#define MPU6050_SMPLRT_DIV 0x19
#define MPU6050_CONFIG 0x1A
#define MPU6050_GYRO_CONFIG 0x1B
#define MPU6050_ACCEL_CONFIG 0x1C

#define MPU6050_ACCEL_XOUT_H 0x3B
#define MPU6050_ACCEL_XOUT_L 0x3C
#define MPU6050_ACCEL_YOUT_H 0x3D
#define MPU6050_ACCEL_YOUT_L 0x3E
#define MPU6050_ACCEL_ZOUT_H 0x3F
#define MPU6050_ACCEL_ZOUT_L 0x40
#define MPU6050_TEMP_OUT_H 0x41
#define MPU6050_TEMP_OUT_L 0x42
#define MPU6050_GYRO_XOUT_H 0x43
#define MPU6050_GYRO_XOUT_L 0x44
#define MPU6050_GYRO_YOUT_H 0x45
#define MPU6050_GYRO_YOUT_L 0x46
#define MPU6050_GYRO_ZOUT_H 0x47
#define MPU6050_GYRO_ZOUT_L 0x48

#define MPU6050_PWR_MGMT_1 0x6B
#define MPU6050_PWR_MGMT_2 0x6C
#define MPU6050_WHO_AM_I 0x75

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*MPU6050.h*/

#ifndef __MPU6050_H
#define __MPU6050_H

void MPU6050_Init();
/*指定地址写对应时序*/
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data);
/*指定地址读对应时序,指定写+当前读*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress);

void MPU6050_GetData(int16_t *AccX,int16_t *AccY,int16_t *AccZ, int16_t *GyroX,int16_t *GyroY,int16_t *GyroZ);

uint8_t MPU6050_GetID()
/*读取对应MPU6050ID*/
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/*MPU6050.c*/
#include "stm32f10x.h" // Device header

#include "MyI2C.h"
#include "MPU6050_Reg.h"


#define MPU6050_ADDRESS 0xD0 //1101000 0



/*指定地址写对应时序*/
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data)
{
/*1.起始,对应从机地址MPU6050,应答*/
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();//可以对应答位判断是否收到从机数据进行处理,这里默认应答


/*2.发送要写入的寄存器地址,应答*/
MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();

/*3.发送写入数据*/
MyI2C_SendByte(Data);
MyI2C_ReceiveAck(); //这里只发送一个字节

// for(int i=0;i<8;i++)
// {
// MyI2C_SendByte(Data);
// MyI2C_ReceiveAck();
// }
/*当要写入多个字节时,使用循环依次将数组的各个字节发送出去*/

/*4.结束*/
MyI2C_Stop();
}

/*指定地址读对应时序,指定写+当前读*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;

MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();

MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();

/*前一半为写的时序*/

/*后一半为读的时序*/
MyI2C_Start();//Sr
MyI2C_SendByte(MPU6050_ADDRESS|0x01);//最后一位改成读
MyI2C_ReceiveAck();

/*接收从机数据*/
Data = MyI2C_ReceiveByte();
MyI2C_SendAck(1);//想继续读数据给应答,这里只读一位,直接不应答了

// for(int i=0;i<8;i++)
// {
// Datas[i] = MyI2C_ReceiveByte();
// if(i==7)
// {
// MyI2C_SendAck(1);//最后一位读取后不应答
// break;
// }
// MyI2C_SendAck(0);//读取多个数据给应答
// }
// 多个数据读取

MyI2C_Stop();

return Data;
}

/*主要修改如下*/

void MPU6050_Init()
{
MyI2C_Init();
MPU6050_WriteReg(MPU6050_PWR_MGMT_1,0x01);//配置电源管理寄存器1,解除睡眠选择陀螺仪时钟
MPU6050_WriteReg(MPU6050_PWR_MGMT_2,0x00);//配置电源管理寄存器2,6个轴均不待机

MPU6050_WriteReg(MPU6050_SMPLRT_DIV,0x09);//配置采样分频寄存器,选择10分频,滤波参数给最大
MPU6050_WriteReg(MPU6050_CONFIG ,0x06);//配置寄存器,滤波参数110
MPU6050_WriteReg(MPU6050_GYRO_CONFIG ,0x18);//陀螺仪最大量程
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG,0x18);//加速度计最大量程
/*这里配置完后,陀螺仪内部就在不停的转换了,对应数据存放在对应各轴数据寄存器中*/

}

/*对应6轴的数据都是16位,分为高8位和低8位,分别读取高8位和低8位拼接后*/
void MPU6050_GetData(int16_t *AccX,int16_t *AccY,int16_t *AccZ,int16_t *GyroX,int16_t *GyroY,int16_t *GyroZ)
{
uint16_t DataH,DataL;

DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);

*AccX = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);

*AccY = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);

*AccZ = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);

*GyroX = (DataH << 8) | DataL;


DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);

*GyroY = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);

*GyroZ = (DataH << 8) | DataL;

}
/*这里的读取分为12次,我们可以改进,使用连续读取的时序,大大提升效率*/

uint8_t MPU6050_GetID()
{
return MPU6050_ReadReg(MPU6050_WHO_AM_I);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "usart.h"
#include "MPU6050.h"
int16_t AX=0,AY=0,AZ=0,GX=0,GY=0,GZ=0;

int main(void)
{

uint8_t Data = 0;

LED_Init();

UsartInit();

MPU6050_Init();
while(1)
{
MPU6050_GetData(&AX,&AY,&AZ,&GX,&GY,&GZ);
UsartSend(AX);
UsartSend(AY);
UsartSend(AZ);
UsartSend(GX);
UsartSend(GY);
UsartSend(GZ);
Delay_ms(1000);
}
}

对应数据:

X轴加速度:水平基本没加速度

Y轴加速度:水平基本没加速度

Z轴加速度:平放在水面通过得到AZ,我们选择的是满量程16g(看手册上)

对应加速度计算公式为 AZ / 32768 = x / 16g ,解方程的得到的x为Z轴方向加速度,这个值理论上来说应该是重力加速度g


X轴角速度:

Y轴角速度:

Z轴角速度:


上方所有数据计算的公式为:

(读取的数据)/(32768) = (x) /( 满量程)

解出对应x的值即可

I2C外设(硬件读写I2C)

image-20241220151445355

由硬件电路自动实现引脚电平反转,软件只需要写入控制寄存器CR数据寄存器DR即可。

为了监控时序状态,软件还得读取状态寄存器SR来了解当前的外设电路处于什么状态

这就像开车一样:写入控制寄存器CR,就像是踩油门、打方向盘来控制汽车的运行,读取状态寄存器SR,就像是观看仪表盘,来了解汽车的运行状态。

使用库函数封装后,有了I2C外设,硬件就可以自动实现时序,就可以减轻CPU的负担,节省软件资源


一主多从:一个主机多个从设备…

多主机模式:分为固定多主机和可变多主机

  • 固定多主机:固定有几个主机,多个主机控制产生冲突时需要仲裁
  • 可变多主机:总线上无固定主机和从机,任何设备可以在总线空闲时主动跳出作为主机与其他从机通信,通信完成后回归从机

对于STM32使用的是可变多主机模型


地址模式:7/10位

7位:一个字节中的前7位为设备地址,最后一位是读写位。

10位:使用两个字节,如果第二个字节也是寻址的话,第一个字节的前5位必须是11110(10位地址标志位),第一个字节剩下2位和第二个字节的8位一共10位作为寻址

7位与10位区别:前5位是否为11110


通讯速度

  • 标准速度:100KHz

  • 快速速度:400KHz


DMA:…


兼容SMBus:System Management Bus-系统管理总线

类似于I2C,是兼容的另一种总线

对应寄存器,标志位查看芯片参考手册的I2C接口模块阅读即可!!!

I2C框图

image-20241221144132847

对应使用时一般是GPIO口复用,查询对应引脚定义表即可,使用硬件I2C时只能使用指定的引脚,不能像软件I2C那样引脚任意指定


由于I2C是半双工,只有一组移位寄存器和数据寄存器,接收和发送都是使用这组,而串口有两组


比较器和地址寄存器:从机模式使用,支持同时响应两个从机地址,在多主机模式下使用


帧错误校验PEC计算:CRC校验算法,数据有效性验证,对应数据错误标志位置位


中断:某个紧急事件发生后可以申请中断


DMA:配合提高效率

SMBALERT是SMBus相关,I2C使用时不用管它

I2C基本结构

image-20241221150048169

图中是简化的一主多从结构,如果是多主机应该还有时钟输入

图中的两个GPIO:一个是复用输入,另一个是复用输出


移位寄存器和数据寄存器DR

发送时数据先写入数据寄存器DR,如果移位寄存器没有数据,就会转到移位寄存器进行发送

主机发送流程

image-20241221162408618

EV事件 是多个标志位的集合,所有标志位在手册中都可以找到

EV5,EV6,EV8…

流程简化下来就是:操作-等待-操作-等待…

主机接收流程

image-20241221162433975

图中给的时序是当前地址读的,想要指定地址读数据需要组合一下即可

具体的流程见下面的代码

从机发送接收

见手册!!!

库函数

初始化函数:

1
2
3
4
5
void I2C_DeInit(I2C_TypeDef* I2Cx);

void I2C_Init(I2C_TypeDef* I2Cx, I2C_InitTypeDef* I2C_InitStruct);

void I2C_StructInit(I2C_InitTypeDef* I2C_InitStruct);

起始条件和终止条件:

1
2
3
void I2C_GenerateSTART(I2C_TypeDef* I2Cx, FunctionalState NewState);//调用该函数生成起始条件,对I2C_CR1寄存器中的START位置1

void I2C_GenerateSTOP(I2C_TypeDef* I2Cx, FunctionalState NewState);//调用该函数生成终止条件,对I2C_CR1寄存器中的STOP位置1

应答ACK:

1
void I2C_AcknowledgeConfig(I2C_TypeDef* I2Cx, FunctionalState NewState);//手动ACK配置,在收到一个字节后,是否给从机应答

发送和接收数据:

1
2
3
void I2C_SendData(I2C_TypeDef* I2Cx, uint8_t Data);//写入数据到数据寄存器DR

uint8_t I2C_ReceiveData(I2C_TypeDef* I2Cx);//读取数据寄存器DR作为返回值,接收到下一个字节之前读出数据寄存器,防止数据覆盖,实现连续数据流

发送地址专用函数:

1
void I2C_Send7bitAddress(I2C_TypeDef* I2Cx, uint8_t Address, uint8_t I2C_Direction);//主要用于设置自动设置读写位

这个函数可以不使用,而是自己确定读写位的最后一位,直接使用SendData函数进行发送

中断配置:

1
void I2C_ITConfig(I2C_TypeDef* I2Cx, uint16_t I2C_IT, FunctionalState NewState);

DMA相关:

1
2
void I2C_DMACmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_DMALastTransferCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);

使能函数:

1
void I2C_Cmd(I2C_TypeDef* I2Cx, FunctionalState NewState);

标志位:

1
2
3
4
5
6
7
8
FlagStatus I2C_GetFlagStatus(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
void I2C_ClearFlag(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
/*中断外使用*/


ITStatus I2C_GetITStatus(I2C_TypeDef* I2Cx, uint32_t I2C_IT);
void I2C_ClearITPendingBit(I2C_TypeDef* I2Cx, uint32_t I2C_IT);
/*中断中使用*/

状态监控:给了三种方法

1
2
3
4
5
6
7
8
/*第一种:基本状态监控,同时判断一个或多个标志位,来确定EV几EV几这个状态是否发生,对应发送接收流程*/
ErrorStatus I2C_CheckEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT);//推荐使用!!!,第二个参数为监测的事件

/*第二种:高级状态监控,实际上并不高级,对应库函数注释可以找到是将SR1和SR2这两个状态寄存器拼接成16位数据然后给你就完了*/
uint32_t I2C_GetLastEvent(I2C_TypeDef* I2Cx);。//一般不使用,了解即可

/*第三种:基于标志位的状态监控,我们一直使用的判断某个标志位方法*/
FlagStatus I2C_GetFlagStatus(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);

I2C外设配置

参考上方I2C框图和基本结构进行配置:

  1. 开启I2C外设和对应GPIO口的时钟
  2. 结构体配置对应GPIO口为复用开漏输出模式(区别:软件模拟I2C是直接开漏没有复用开漏)
  3. 结构体配置I2C
  4. 使能I2C,I2C_Cmd

实验-硬件I2C读取MPU6050数据

部分与软件I2C相同,去掉了软件I2C最底层的部分,使用库函数代替

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*MPU6050.h*/

#ifndef __MPU6050_H
#define __MPU6050_H

void MPU6050_Init();

/*指定地址写对应时序*/
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data);


/*指定地址读对应时序,指定写+当前读*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress);


void MPU6050_GetData(int16_t *AccX,int16_t *AccY,int16_t *AccZ,int16_t *GyroX,int16_t *GyroY,int16_t *GyroZ);

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
#include "stm32f10x.h"                  // Device header

//#include "MyI2C.h"
#include "MPU6050_Reg.h"


#define MPU6050_ADDRESS 0xD0 //1101000 0

void MPU6050_WaitEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT);

/**
* 程序中出现大量while等待的情况比较危险,容易发生卡死的现象,所以我们需要让它超时退出。
*
* 不必使用定时器,我们只需要简单用一个计数值循环判断即可,该Timeout值根据实际情况更改大小,while循环正常不会执行到Timeout
*/

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)
{
break;
}
}
}
/**
* 该函数为CheckEvent的封装
* 下方没有替换,该函数使用时只需要将使用while循环的部分直接使用该函数全部替换即可
*
*/


/*指定地址写对应时序*/
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data)
{
/*软件I2C代码:*/
// /*1.起始,对应从机地址MPU6050,应答*/
// MyI2C_Start();
// MyI2C_SendByte(MPU6050_ADDRESS);
// MyI2C_ReceiveAck();//可以对应答位判断是否收到从机数据进行处理,这里默认应答
//
//
// /*2.发送要写入的寄存器地址,应答*/
// MyI2C_SendByte(RegAddress);
// MyI2C_ReceiveAck();
//
// /*3.发送写入数据*/
// MyI2C_SendByte(Data);
// MyI2C_ReceiveAck(); //这里只发送一个字节
//
//// for(int i=0;i<8;i++)
//// {
//// MyI2C_SendByte(Data);
//// MyI2C_ReceiveAck();
//// }
// /*当要写入多个字节时,使用循环依次将数组的各个字节发送出去*/
//
// /*4.结束*/
// MyI2C_Stop();


/*硬件I2C下使用的都是非阻塞的函数,需要等待标志位结束才能保证对应波形执行完成*/
/*对应等待的事件根据硬件I2C发送流程中来写*/

I2C_GenerateSTART(I2C1,ENABLE);//1.发送起始条件
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_MODE_SELECT)!=SUCCESS)
{
}//等待EV5事件完成

I2C_Send7bitAddress(I2C1,MPU6050_ADDRESS,I2C_Direction_Transmitter);//2.发送从机地址,第三个参数自动置读写位,应答位会自动处理
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)!=SUCCESS)
{
}//等待发送模式的EV6事件完成

I2C_SendData(I2C1,RegAddress);//3.发送指定寄存器地址
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_BYTE_TRANSMITTING)!=SUCCESS)
{
}//等待EV8事件

/*发送多个字节数据时,中间的数据发送完成后等待EV8事件,而最后一个字节变为等待EV8_2事件*/

I2C_SendData(I2C1,Data);//4.发送要写入的数据
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_BYTE_TRANSMITTED)!=SUCCESS)
{
}//发送最后一个字节完成后,等待EV8_2事件


I2C_GenerateSTOP(I2C1,ENABLE);//5.终止条件


}

/*指定地址读对应时序,指定写+当前读*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;

/*软件模拟I2C:*/
// MyI2C_Start();
// MyI2C_SendByte(MPU6050_ADDRESS);
// MyI2C_ReceiveAck();
//
// MyI2C_SendByte(RegAddress);
// MyI2C_ReceiveAck();
//
// /*前一半为写的时序*/
//
// /*后一半为读的时序*/
// MyI2C_Start();//Sr
// MyI2C_SendByte(MPU6050_ADDRESS|0x01);//最后一位改成读
// MyI2C_ReceiveAck();
//
// /*接收从机数据*/
// Data = MyI2C_ReceiveByte();
// MyI2C_SendAck(1);//想继续读数据给应答,这里只读一位,直接不应答了
//
//// for(int i=0;i<8;i++)
//// {
//// Datas[i] = MyI2C_ReceiveByte();
//// if(i==7)
//// {
//// MyI2C_SendAck(1);//最后一位读取后不应答
//// break;
//// }
//// MyI2C_SendAck(0);//读取多个数据给应答
//// }
//// 多个数据读取
//
// MyI2C_Stop();

/*硬件I2C:实现指定地址接收一个字节,使用的是复合形式*/
I2C_GenerateSTART(I2C1,ENABLE);//1.发送起始条件
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_MODE_SELECT)!=SUCCESS);//起始条件等待EV5事件完成

I2C_Send7bitAddress(I2C1,MPU6050_ADDRESS,I2C_Direction_Transmitter);//2.发送从机地址,第三个参数自动置读写位,应答位会自动处理
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)!=SUCCESS)//等待发送模式的EV6事件完成

I2C_SendData(I2C1,RegAddress);//3.发送指定寄存器地址
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_BYTE_TRANSMITTED)!=SUCCESS)//这里因为是数据流最后一个字节所以变成了等待EV8_2事件

/*前一半为写的时序*/

/*后一半为读的时序*/

I2C_GenerateSTART(I2C1,ENABLE);//4.重复起始条件。这个地方会等待上方字节发送完成后才会产生,所以上方用TRANSMITTING或TRANSMITTED都可以
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_MODE_SELECT)!=SUCCESS);//起始条件等待EV5事件完成

I2C_Send7bitAddress(I2C1,MPU6050_ADDRESS,I2C_Direction_Receiver);//2.发送从机地址,这个地方应变成读的方向,第三个参数修改
while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)!=SUCCESS)//等待接收模式的EV6事件完成,与上方EV6事件不同


//如果只需要读取一个字节,需要恰好在EV6之后,立刻把ACK置0,STOP置1,避免在本次ACK发送之后才去置0,这样时序会多一个字节

//如果是需要读取多个字节,直接等待EV7事件,读取DR,就能收到数据,依次接收,直到最后一个字节之前也就是EV7_1事件(EV7_1不用等待),提前把ACK置0,STOP置1


I2C_AcknowledgeConfig(I2C1, DISABLE);
I2C_GenerateSTOP(I2C1,ENABLE);//STOP置1后该终止条件不会截断当前字节,会等当前字节接收完成后,再产生终止条件波形

while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_BYTE_RECEIVED)!=SUCCESS)//等待EV7事件完成
Data = I2C_ReceiveData(I2C1);//5.接收从机数据


// for(int i=0;i<N;i++)
// {
//
// if(i==N-1)
// I2C_AcknowledgeConfig(I2C1, DISABLE);
// I2C_GenerateSTOP(I2C1,ENABLE);//STOP置1后该终止条件不会截断当前字节,会等当前字节接收完成后,再产生终止条件波形
//
// while(I2C_CheckEvent(I2C1,I2C_EVENT_MASTER_BYTE_RECEIVED)!=SUCCESS)//等待EV7事件完成
// Data = I2C_ReceiveData(I2C1);
// }
// 接收多个字节:使用循环处理,最后一个字节前执行一次ACK=0和STOP


I2C_AcknowledgeConfig(I2C1, ENABLE);//最后再恢复默认ACK=1,方便收多个字节

return Data;
}



void MPU6050_Init()
{
/*软件I2C:*/
// MyI2C_Init();

/*硬件I2C:*/
/*第1步*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

/*第2步*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_2|GPIO_Pin_3;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

/*第3步*/
I2C_InitTypeDef I2C_InitStruct;
I2C_InitStruct.I2C_Mode = I2C_Mode_I2C ;
I2C_InitStruct.I2C_Ack = I2C_Ack_Enable;//确认要发送应答位
I2C_InitStruct.I2C_ClockSpeed = 100000;//最大为400KHZ,0~100KHZ为标准速度,100~400khz为快速
I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2;//时钟占空比参数,用与快速模式,只有当时钟频率大于100Khz才有效,小于100Khz是占空比固定为1:1
I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;//响应7或10位地址,stm32作为从机时才使用。作为主机随便给
I2C_InitStruct.I2C_OwnAddress1 = 0x00; //自身地址1,stm32作为从机才使用,方便别的主机呼叫,指定地址位数=上一个参数选择响应的位数。作为主机随便给

/*第4步*/
I2C_Cmd(I2C1,ENABLE);

MPU6050_WriteReg(MPU6050_PWR_MGMT_1,0x01);//配置电源管理寄存器1,解除睡眠选择陀螺仪时钟
MPU6050_WriteReg(MPU6050_PWR_MGMT_2,0x00);//配置电源管理寄存器2,6个轴均不待机

MPU6050_WriteReg(MPU6050_SMPLRT_DIV,0x09);//配置采样分频寄存器,选择10分频,滤波参数给最大
MPU6050_WriteReg(MPU6050_CONFIG ,0x06);//配置寄存器,滤波参数110
MPU6050_WriteReg(MPU6050_GYRO_CONFIG ,0x18);//陀螺仪最大量程
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG,0x18);//加速度计最大量程
/*这里配置完后,陀螺仪内部就在不停的转换了,对应数据存放在对应各轴数据寄存器中*/

}

/*对应6轴的数据都是16位,分为高8位和低8位,分别读取高8位和低8位拼接后*/
void MPU6050_GetData(int16_t *AccX,int16_t *AccY,int16_t *AccZ,
int16_t *GyroX,int16_t *GyroY,int16_t *GyroZ)
{
uint16_t DataH,DataL;

DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);

*AccX = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);

*AccY = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);

*AccZ = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);

*GyroX = (DataH << 8) | DataL;


DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);

*GyroY = (DataH << 8) | DataL;

DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);

*GyroZ = (DataH << 8) | DataL;

}
/*这里的读取分为12次,我们可以改进,使用连续读取的时序,大大提升效率*/

uint8_t MPU6050_GetID()
{
return MPU6050_ReadReg(MPU6050_WHO_AM_I);
}

程序中出现大量while等待的情况比较危险,容易发生卡死的现象,所以我们需要让它超时退出。

不必使用定时器,我们只需要简单用一个计数值循环判断即可,该Timeout值根据实际情况更改大小,while循环正常不会执行到Timeout

软件和硬件I2C对比

波形对比:

上方是软件波形,下方是硬件波形

从时钟线的规整程度上看:

硬件I2C:SCL波形会更加规整,每个时钟的周期、占空比都非常一致

软件I2C:由于操作引脚后都加了延时,有时候加的多有时候加的少,所以软件时序的时钟周期、占空比可能不规整,但由于是同步时序不规整也不会影响通信


在SCL低电平写,高电平读的时候,可以在整个电平的任意时候都可以读写,但是一般要求保证尽早原则,所以可以认为SCL下降沿写,上升沿读。

软件波形中,因为操作端口后有延时,所以都是等了一会在写的

硬件波形中,可以看到SCL下降沿的时候,SDA也立马切换数据。读写都是紧贴上下沿进行


应答结束时最为明显:

从机在SCL下降沿立刻释放SDA,但是软件I2C由于有延时,所以在应答结束后主机等了一会才变换数据,所以在软件I2C中有一个短暂的高电平后才拉低SDA。

硬件I2C中应答结束后,从机在SCL下降沿立刻释放SDA,同时主机也立刻拉低SDA,所以就是直接一个小尖峰。

软件模拟I2C (Bit-Banging):

优点:

  • 灵活性高:可以完全控制每个I2C数据位的发送和接收过程,适用于没有硬件I2C模块的STM32型号,或者需要多个I2C总线时,可以通过软件模拟多个I2C设备。
  • 成本低:不依赖硬件I2C外设,因此适合成本敏感的应用。
  • 可以在任意GPIO上运行:可以自定义SCL和SDA引脚,不需要专用的硬件I2C引脚。

缺点:

  • 效率低:每个数据位的发送和接收都需要进行软件延时处理,导致比硬件I2C慢得多。对于高速通信,可能无法满足实时性要求。
  • 占用CPU资源:模拟I2C需要CPU不断参与数据的传输,可能影响系统其他任务的执行,尤其是当系统负载较高时。
  • 稳定性差:如果系统中有其他任务需要占用大量CPU时间,可能会导致I2C通信不稳定,或者丢失数据。

硬件I2C (利用硬件I2C模块):

优点:

  • 高效:硬件I2C通过专用的I2C控制器进行数据传输,不需要CPU参与数据位的传送,速度快且稳定,通常能够提供更高的传输速率。
  • 节省CPU资源:硬件I2C控制器可以自动完成数据传输任务,释放CPU处理其他任务,特别适合多任务或实时系统。
  • 稳定性强:硬件I2C模块有专门的硬件实现,避免了由于软件延迟或CPU占用过高导致的通信不稳定问题。

缺点:

  • 硬件资源有限:不同型号的STM32有不同数量的硬件I2C模块,因此可用的硬件I2C通道有限。如果需要多个I2C总线,可能无法满足需求。
  • 引脚固定:硬件I2C通常需要专用的引脚(SCL和SDA),不能任意选择GPIO。如果这些引脚已被占用或在项目设计中不方便,可能会限制设计的灵活性。
  • 复杂性较高:硬件I2C需要配置硬件相关的寄存器和管理中断等,比软件模拟I2C配置复杂一些。

使用场景总结

**软件模拟I2C:**灵活、成本低,但效率低、占用CPU资源。不受限制,引脚够基本上就是想开几路就是几路

硬件I2C不足时可以使用软件I2C

**硬件I2C:**效率高、稳定,节省CPU资源,但受硬件资源和引脚限制。

对性能指标要求比较高,实时性、多任务等时使用硬件I2C

SPI

SPI介绍

image-20250101153932297

图中的四种使用SPI通信的芯片:引脚名称可能不标注,可以查看对应手册了解

  1. W25QXX,FLASH存储芯片,作为从机,引脚写的DI和DO
  2. OLED屏幕
  3. 2.4G无线通信模块,芯片型号为NRF24L01
  4. Micro SD卡:官方通信协议为SDIO,但也支持SPI协议,可以对其进行读写操作

SPI特点:同步,全双工,四根通信线


SPI的四根线:

  1. SCK(Serial Clock):时钟线,也叫做SCLK、CLK、CK等
  2. MOSI(Master Output Slave Input):主机输出从机输入线,也叫做DI(Data Output-对应从机输入)
  3. MISO(Master Output Slave Input):主机输入从机输出线,也叫做DO(Data Input-对应从机输出))
  4. SS(Slave Select):从机选择线,片选,也叫做NSS(Not Slave Select)、CS(Chip Select-片选),有几个从机就用几条线。这样以后就不用像I2C那样先发送一个字节寻址、分配地址等操作

注意:主机和从机不能同时配置为输入或输出


SPI没有应答位,SPI是不管你有没有收到信息的


SPI相对于I2C的优缺点:

  • 速度上:SPI传输比I2C更快,SPI协议并没有严格规定最大传输速度,最大传输速度一般由芯片厂商的设计需求,如W25Q64存储器芯片,手册中写的是最大可达80MHz

  • 设计上:SPI设计比较简单粗暴,实现的功能没有I2C那么多,仅支持一主多从,不支持多主机

  • 开销上:SPI的硬件开销比较大,通信线的个数比较多,通信过程中经常会有资源浪费的情况

硬件电路

image-20250101165230579

主机一般是STM32,从机一般是存储器、显示屏、通信 模块、传感器等,对应有几个从设备就有几个SS线


SCK:时钟线完全由主机掌控,对于主机来说时钟线为输出,对于从机来说时钟线为输入

MOSI:主机输出从机输入,左边主机对应MO,主机输出,右边从机SI,从机输入。数据从MOSI输出,所有从机从MOSI输入

MISO:主机输入从机输入,与MOSI类似

SS:从机选择线,低电平有效,主机初始化后所有的SS都输出高电平,代表都不指定,需要与谁通信,就将对应的SS线拉低即可,当对应从机通信完之后,对应SS线就重新置回高电平。同一时间只能选择一个从机(只能置一个为低电平)


输出引脚配置为推挽输出:高低电平均有很强驱动能力,使得SPI信号引脚上升沿和下降沿信号非常迅速,不像I2C那样下降沿迅速上升沿缓慢,所以SPI信号变化的快所以SPI具有更快的传输速度,一般的SPI信号都能轻松达到Mhz的速度级别。

输出引脚为:SCK、MOSI、SS

输入引脚配置为浮空或上拉输入

输入引脚为:MISO

SPI可能存在的冲突

​ 对于SPI的从机MISO引脚,当某个从机的SS引脚被选择后,其他从机的SS引脚会被保持为高阻态,相当于断开不输出,这样可以防止一条线有多个输出而导致的电平冲突的问题

数据移位示意图

image-20250101173950632

SPI数据是高位先行,通信基础是交换一个字节,数据收发都是基于字节交换这个基本单元进行。主机要发送的数据跑到从机,从机要发送的数据跑到主机,这样就可以实现发送一个字节、接收一个字节、发送同时接收一个字节三种功能

对于只想接收数据时:我们会随便发送数据(一般统一为0x00或0xFF),把数据置换过来即可

对于只想发送数据时:只需要把数据发送过去,对方置换过来的数据不管即可


这里波特率发生器就是时钟

主机移位寄存器左边出去的数据通过MOSI引脚,输入到从机移位寄存器的右边,从机移位寄存器左边移出去的数据通过MISO引脚,输入到主机移位寄存器的右边,形成一个环

每当规定的时钟沿(上升沿/下降沿)到来时,对应移位寄存器的最高位分别移动,分别放到MOSI和MISO的通信线上,然后对应的时钟沿(上升沿或下降沿)再次到来时,数据分别从MOSI和MISO线上,采样到从机和主机的最低位。这样就完成了一个时钟

例:规定上升沿数据移动,下降沿数据采集。就是在上升沿时移位寄存器的最高位到对应的线上。下降沿时,会采样输入到对应的最低位。


SPI时序基本单元

  • 起始条件和终止条件:

image-20250101174746261

起始条件:SS由高到低,代表选中从机通信开始

结束条件:SS由低到高,代表结束选中从机通信结束

所以SS低电平期间就代表正在通信


SPI中有两个可以配置的位,每一位可以配置为1或0,组合起来就有模式0、模式1、模式2、模式共四种模式,但功能实际都是一样的,任意选择一种使用即可。

CPOL(Clock Polarity):时钟极性,决定空闲时SCK的低电平,值为0或1

CPHA(Clock Phase):时钟相位,决定第一个时钟采样移入还是第二时钟采样移入,并不是规定上升沿采样还是下降沿采样,值为0或1


  • 模式0:使用最多重点掌握!!!

image-20250101175308487

CPOL = 0:代表空闲状态SCK低电平

CPHA = 0:SCK第一个边沿移入数据第二个边沿移出数据

由于数据需要先移出来,才能移入,所以SCK第一个边沿之前就要提前开始移出数据了。

​ 这里把SS当作了时钟下降沿所以要移出数据,MOSI就提前移出数据,等SCK上升沿来到,两个数据B7分别移入到主机和从机。然后SCK到下降沿,两个B6数据就移出,上升沿的两个B6数据采样输入到主机和从机…一直到B0最后一位,SS拉高,MOSI任意,MISO高阻态,这样一个字节的交换就完成。

如果想要交换多个字节,只需要在B0最后继续紧跟第二个字节的B7重复前述过程即可


总结交换字节步骤

  1. SS拉低后,主机和从机立刻移出数据(主机移出它的数据最高位放到MOSI上,从机移出它的最高位数据放到MISO上)。从机的事主机不管,所以我们直接写MOSI即可
  2. SCK上升沿,主机和从机同时移入数据(从机会自动把这位数据读走),主机只需要读入MISO的数据位即可
  3. SCK下降沿,主机和从机同时移出次高位数据,开始循环8次,共1字节8bit
  • 模式1:与上方的移位过程相同

image-20250101180327881

模式1与模式0的区别:模式0把数据变化的实际提前了,而模式1没有


CPOL = 0:代表空闲状态SCK低电平

CPHA = 1:SCK第一个边沿移出数据第二个边沿移入数据

也能表述为:CPHA=1表示SCK的第二个边沿进行数据采样,或SCK的偶数边沿进行数据采样

MOSI为主机发送的B7,B6…,默认状态下没有规定高低电平(均可)

MISO为从机发送的B7,B6…,默认状态下应为高阻态

  • 模式2:

image-20250101183828210

模式2与模式0区别:就把SCK极性取反即可

  • 模式3:

image-20250101183921953

模式1与模式3区别:就把SCK极性取反即可

SPI时序

​ SPI对字节流功能的规定与I2C不同,I2C是有效数据流第一个字节是寄存器地址,之后依次是读写的数据,使用的是读写寄存器的模型

​ 而在SPI中,通常采用指令码加读写数据的模型,即SPI第一个发送给从机的数据叫做指令码,在从机中对应的会定义一个指令集,只需要发送指令集中对应的数据即可指定想要完成的功能。

​ 不同的指令,可以有不同的数据个数,有的指令只需要一个字节的指令码就可以完成,比如:W25Q64的写使能失能等。而写数据时,包含指令码+在哪里写+写的数据

对应的指令集都会在SPI从机芯片手册中可以找到!!!!

时序举例

  • 写使能

image-20250101184358794

image-20250101185239046

在W25Q64中0x06代表写使能。

这里使用SPI模式0,SS拉低后,立马准备移出数据,这里数据第一个Bit为0,所以没有变化,SCK第一个上升沿采样移入数据,从机采样输入(MOSI)得到0,主机采样输入(MISO)得到1。然后第一个下降沿移除数据…一直到最后一位交换完成一个字节。SS置回高电平结束

结果:主机用收到从机的0xFF,即从机输出不使用(0xFF为默认值),从机收到主机的0x06,就会进行写使能

由于使用软件模拟时序有延时,所以图中的MOSI数据变化有些延迟,没有紧贴SCK的下降沿是正常的

  • 指定地址写一个/多个字节

image-20250101191437487

由于W25Q64有8M的存储空间,一个字节的8位地址不够,所以这里的指定地址使用24位分成3个字节发送

image-20250101191742942`

在0x123456的地址下写入0x55数据

指令码

第一个字节:主机先发送0x02指令代表写数据,收到从机数据为0xFF不使用


指定地址

第二个字节:主机发送地址前8位(第23~16位)为0x12,收到从机数据0xFF

第三个字节:主机发送地址中8位(第15~8位)为0x34,收到从机数据0xFF

第四个字节:主机发送地址后8位(第7~0位)为0x56,收到从机数据0xFF

从机收到的地址为0x123456


发送的数据

第五个字节:主机写入的数据0x55,收到从机数据0xFF

到这里便写入一个字节,没有应答位,一个接一个交换字节即可


如果想指定地址,写入多个字节:

SPI中也有类似于I2C中的地址指针,每读写一个字节,地址指针自动+1,如果发送一个字节后不停止,继续发送的字节就会依次写入到后续的存储空间里,这样即实现多个字节写入

  • 指定地址读一个/多个字节:

image-20250101193028026

image-20250101193633764

和指定地址写时序差不多

指令码:0x03

地址三个字节:0x123456

读取的数据:主机随便发送数据0xFF,交换数据后得到0x55。

这样就读取到了0x123456地址的数据为0x55


实现读取指定地址的多个字节:

也是有地址指针,读取自动+1,实现连续读取即可


时序细节

图中MISO是硬件控制波形,所以数据变化紧贴下降沿

W25Q64介绍

image-20250101193816880

容量:

该芯片存储为M级别,在手机可能很小,但是在嵌入式领域还是挺大的

还有的芯片是KB级别的,例如AT24C02芯片,使用的I2C通信的E2PROM芯片


存储器分类:

分为非易失性存储器(FLASH、E2PROM)和易失性存储器(SRAM、DRAM)

该芯片是非易失性存储器,数据掉电不丢失,存储介质为Nor Flash


通信协议:

使用SPI串行通信,SCK时钟线频率最大为80MHz,双重SPI模式等效频率为160Mhz,四重SPI模式等效的频率为320MHz

频率相对于stm32非常快


应用:

数据存储:

字库存储:可以存放中文字库等,使用时读取数据后显示中文


型号:

芯片使用24位地址,3个字节

2的24方 = 16MB,所以24位地址的最大寻址空间为16MB,所W25Q40~W25Q128都是够用的,但是W25Q256是32MB的存储不够用

W25Q256分为3字节地址模式和4字节地址模式,使用3字节模式下只能读写到前16MB地址,而要想读写所有的地址进入4字节地址模式即可

更换芯片型号,硬件电路和底层驱动程序都不用更改

硬件电路

image-20250101233710004

该芯片的供电VCC接3.3V


WP:写保护,低电平不允许写,高电平可以写

HOLD:数据保持,低电平有效

作用:在正常读写时突然产生中断,然后想用SPI通信线去操控其他器件,此时如果把CS置回高电平,那时序就会终止,但如果不想终止总线又想操作其他器件,此时就可以将HOLD引脚置低电平,芯片释放总线,芯片的时序也不会终止,会记住当前的状态,操作完后可以回来HOLD置高电平,继续之前的时序。

相当于SPI总线进了一次中断

HOLD和WP如果想用就接到GPIO引脚上,如果不想用就直接接VCC即可


IO1 、IO2与双重SPI和四重SPI有关

框图

image-20250102170255168

几个基本概念:块、扇区、页:

块大小:64KB

扇区大小:4KB

页大小:256B

8MB的地址空间从0x000000到0x7FFFFF

右边:以64KB为一个基本单元块划分为若干个块Block 8MB/64KB = 128块(块0~127)

左上:以4KB为一个基本单元再将每个Block划分为若干个单元,每个单元叫做Sector-扇区 64KB/4KB = 16个扇区(扇区0~15)

我们在写入数据时其实还有一个划分叫做页,是对扇区进行的一个更细的划分,一页为256字节 4KB/256 = 16页(0~15页)


左下角:芯片的控制逻辑

芯片内部的地址锁存、数据读写等操作都由控制逻辑自动完成

控制逻辑的左边连接的是SPI的引脚

控制逻辑上方是状态寄存器,可以知道芯片是否处于忙状态、是否写使能等

状态寄存器上方是写控制逻辑与WP引脚连接,实现硬件写保护

内部集成了高电压发生器(High Voltage Generators),用于实现掉电不丢失


最下方:字节地址锁存/计数器

用于指定地址,SPI放过来3个字节的地址,前2个字节进到页地址锁存计数器,最后一个字节进入字节地址锁存计数器

然后页地址锁存计数器通过写保护和行解码选择操作的哪一页

字节地址锁存计数器通过列解码和256字节页缓存来进行指定字节的读写操作

其中的计数器与地址指针自动+1有关


右下方:列解码和256Byte 页缓冲区

实际上是个RAM缓冲区,写入数据时先放到这个RAM缓冲区,时序结束后,芯片对应状态BUSY会置1,芯片再将缓冲区的数据复制到对应的Flash中,此时不会响应新的写入

缓冲区作用:SPI的写入频率是非常高的,而Flash的写入速度比较慢(掉电不丢失特性),所以需要使用一个RAM页缓存区来存储写入的数据,从而可以跟得上SPI总线的速度。

Flash操作注意事项

image-20250102173909328

第1条规定:直接使能即可

第2条规定:不擦除写入数据可能会与实际不符,实际值为(写入数据&芯片内数据)

第3条规定:是为了弥补第2条规定设立,保证每次写入数据不出错,由专门的擦除电路自动进行,我们只需要发送擦除的指令即可

一定要在每次写入前擦除数据!!!

第4条规定:擦除时只能选择整个芯片擦除、按块擦除、按扇区擦除,所以最小的擦除单元是一个扇区为4KB-4096字节。不能指定对某个字节擦除,如果想要擦除某一个字节,只能对该字节所在的扇区的4096个字节进行擦除

为了弥补这个缺点,我们需要在程序逻辑上进行一些优化:比如上电后先将Flash备份一份到RAM中

第5条规定:一次性不能写太多,一个写入时序最多写256字节(由缓冲区限制)

第6条规定:写入时序结束或者擦除芯片之后,芯片进入忙状态(BUSY位=1),当状态寄存器BUSY位为0后才会继续响应

第7条规定:读取时序基本没有限制,唯一就是不能在忙状态时读取


由于Flash掉电不丢失的特性,成本低,存储量大,所以在操作的便携性上不是很友好,以及速度不是很快。但在非易失性存储器中速度却是很快的。

与RAM想在哪些就在哪写,想写多少就写多少不同,且可以覆盖写入区别较大

芯片手册

主要包含写保护配置表、状态寄存器、指令集,以及对每条指令的详细解释。还有芯片电器特性(供电电压范围等)

  • 状态寄存器:

image-20250102191517893

BUSY位:

当设备执行页编程(Page Program)、扇区擦除(Sector Erase)、块擦除(Block Erase)、芯片擦除(Chip Erase)或写状态寄存器指令时会被设置为1。在该位为1时,表示设备正在忙碌,不会响应其他指令,除了读取状态寄存器和进入/擦除挂起操作的指令。程序、擦除或写状态寄存器操作完成后,“BUSY”位会清零,表明设备已准备好接收新的指令。

WEL位(写使能锁存位):

WEL 是一个状态寄存器(Status Register)中的位,仅在执行了 Write Enable 指令后被设置为。在数据写入完成后,该位会被清零以禁用写操作。 当电源关闭或者执行以下任一指令时,WEL也会被禁用:Write Disable、Page Program(页编程)、Sector Erase(扇区擦除)、Block Erase(块擦除)、Chip Erase(芯片擦除) 和 Write Status Register(写入数据后会自动进行写使能)。

例子说明: 假设你有一块闪存芯片,在你想要向其写入数据之前,必须先发送 Write Enable 指令来激活WEL位。只有当这个位是激活状态(即值为1),才能对芯片进行编程或擦除操作。一旦这些操作完成或者直接通过发送特定指令显式地禁用它(比如Write Disable),WEL会回到未激活状态(即值为0),从而防止意外改变数据。

注意:一条写指令只能保证后续的一条写指令可以执行,所以每次写入都要进行写使能


其他的位请自行查看手册!!

  • 指令集(Instruction):

image-20250102193355258

厂商ID是:0xEF

设备ID:

0x16 (如果使用0xAB和0x90来读)

0x4017 (使用0x9F来读)


image-20250102193817312

Write Enable:写使能,发送0x06

Write Disable:写失能,发送0x04


Read Status Register-1:读取状态寄存器第1位,发送0x05,用于查看忙状态,S0是BUSY位,S1是WEL位


Page Program:页编程就是写数据,发送0x02,然后继续发送地址23-16位,15-8位,7-0位三个字节,然后发送写入的数据

注意:该芯片不能跨页写入,在执行跨页写入时,多出来的数据会从页首的地方覆盖写入,一页为256B对应16进制为0x000000~0x0000FF。

若确实需要跨页写入,需要连续写入很大的数组,只能从软件上进行分批次写入,先计算数组要跨多少也,擦除对应区域,最后分批次一页一页写,最后封装成一个函数


Block Erase:块擦除,包括按64KB的块擦除、32KB的块擦除、4KB的扇区擦除。发送0xD8/0x62,再发送三个字节的地址即可

Sector Erase:扇区擦除,包括4KB的扇区擦除。发送0x20,再发送三个字节的地址即可,对应一个扇区为0x000000~0x000FFF

Chip Erase:发送0xC7或0x60h即可


JEDEC ID:读取ID指令,发送0x9F,然后连续交换三个字节数据,得到的第一个字节是厂商ID,后两个字节是设备ID

image-20250102195113035

Read Data:读取数据,发送0x03,之后交换发送3个字节地址,接下来读取的就是该地址下的数据

  • 执行所用时间:

image-20250102200118784

页编程以及各种擦除所用时间,基本都是ms级别

  • dummy数据:

image-20250104225524682

手册中可以看到发送的数据为dummy,这个dummy就代表无用数据,发送0xFF即可此时发送和接收的数据都没有意义。

作用:可能是做一些延时

软件SPI读写W25Q64

软件SPI底层代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*MySPI.h*/
#ifndef __MYSPI_H
#define __MYSPI_H


//void MySPI_W_SS(uint8_t BitVal);

//void MySPI_W_SCK(uint8_t BitVal);

//void MySPI_W_MOSI(uint8_t BitVal);

//uint8_t MySPI_W_MISO();
/*上面四个函数只有MySPI.c中会使用,所以我们不用放在头文件中,保持模块的独立,封装!!!!!!*/

void MySPI_Init();

void MySPI_Start();

void MySPI_Stop();

uint8_t MySPI_SwapByte(uint8_t ByteSend);

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#include "stm32f10x.h"                  // Device header

//写SS
void MySPI_W_SS(uint8_t BitVal)
{

GPIO_WriteBit(GPIOA,GPIO_Pin_4,(BitAction)BitVal);
}


//写SCK
void MySPI_W_SCK(uint8_t BitVal)
{

GPIO_WriteBit(GPIOA,GPIO_Pin_5,(BitAction)BitVal);
}

//写MOSI
void MySPI_W_MOSI(uint8_t BitVal)
{

GPIO_WriteBit(GPIOA,GPIO_Pin_7,(BitAction)BitVal);
}

//读MISO
uint8_t MySPI_R_MISO()
{
return GPIO_ReadInputDataBit(GPIOA,GPIO_Pin_6);
}
/*由于SPI速度很快,操作引脚后就不用加延时了*/
/*上面这样单独对写入引脚的封装,有利于单片机移植或者添加延时*/

void MySPI_Init()
{
//1.开启对应引脚RCC
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);

//2.配置GPIO
/*三个推挽输出引脚*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

/*一个上拉/浮空输入引脚*/
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

//3.配置默认电平:初始化SS引脚为高电平,由于使用模式0,所以初始化SCK引脚为低电平
MySPI_W_SS(1);
MySPI_W_SCK(0);

}

//SPI起始条件
void MySPI_Start()
{
MySPI_W_SS(0);


}

//SPI终止条件
void MySPI_Stop()
{
MySPI_W_SS(1);
}


//SPI字节交换,由于是交换1字节,所以需要有返回值得到从机发送的1字节
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
uint8_t ByteReceive = 0x00;

/*1.依次1位,1位操作*/
// MySPI_W_MOSI(ByteSend &= 0x80); //移出第一位数据
// MySPI_W_SCK(1);
// if(MySPI_W_MISO() == 1) //接收从机发送的第一位数据(最高位)
// {
// ByteReceive |= 0x80;
// }
// MySPI_W_SCK(0);
// /*一个时序的结束*/
//
// MySPI_W_MOSI(ByteSend &= 0x40);//移出第二位数据
// MySPI_W_SCK(1);
//
// if(MySPI_W_MISO() == 1) //接收从机发送的第二位数据(次高位)
// {
// ByteReceive |= 0x40;
// }
//
// .....一位一位发送,可以简化为循环

/*2.通过掩码,依次挑出每一位进行操作*/
for(int i=0;i<8;i++)
{
MySPI_W_MOSI(ByteSend & (0x80 >> i)); //移出第i位数据,每次只发送了1bit
MySPI_W_SCK(1);
if(MySPI_R_MISO() == 1) //接收从机发送的第i位数据(i=0时为最高位),每次只接收了1bit
{
ByteReceive |= (0x80 >> i);
}
MySPI_W_SCK(0);
}

/*3.通过SPI中主机和从机移位寄存器交换字节的方式,更加契合SPI的移位模型讲解*/
// for(int i=0;i<8;i++)
// {
// MySPI_W_MOSI(ByteSend &= 0x80); //移出第i位数据,每次只发送了1bit
// ByteSend <<= 1 ; //最高位发送后,左移1位后最低位为0,空出最低为
// MySPI_W_SCK(1);
// if(MySPI_W_MISO() == 1) //接收从机发送的第i位数据(i=0时为最高位),每次只接收了1bit
// {
// ByteSend |= 0x01; //主机发送数据的移位寄存器最后1bit置为从机移位发送的1bit
// }
// MySPI_W_SCK(0);
//
// //最终得到的ByteSend就是移位交换后的数据,就可以不用定义ByteReceive,提高效率
// }

return ByteReceive;
}

上面演示的是SPI模式0的时序,如果需要修改模式,只需要对照时序图进行修改即可

比如修改为模式1,对照模式1的时序图只需要将for循环中的前两行代码交换顺序即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint8_t MySPI_SwapByte(uint8_t ByteSend) 
{
for(int i=0;i<8;i++)
{
MySPI_W_SCK(1);
/*这两行*/
MySPI_W_MOSI(ByteSend &= (0x80 >> i));
if(MySPI_W_MISO() == 1)
{
ByteReceive |= (0x80 >> i);
}
MySPI_W_SCK(0);
}
}

这里的模式0和模式1的时钟极性相同,如果改为模式2或模式3时,只需要将所有出现SCK的地方电平全部翻转一下即可

W25Q64测试代码

由于指令码直接书写不太美观,也不够清楚,所以我们可以对指令集进行宏定义。新建一个头文件,单独存放指令集的宏定义!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*W25Q64_Ins*/
#ifndef __W25Q64_INS_H
#define __W25Q64_INS_H

/*根据W25Q64的手册将所有的指令码抄过来*/

#define W25Q64_WRITE_ENABLE 0x06
#define W25Q64_WRITE_DISABLE 0x04
#define W25Q64_READ_STATUS_REGISTER_1 0x05
#define W25Q64_READ_STATUS_REGISTER_2 0x35
#define W25Q64_WRITE_STATUS_REGISTER 0x01
#define W25Q64_PAGE_PROGRAM 0x02
#define W25Q64_QUAD_PAGE_PROGRAM 0x32
#define W25Q64_BLOCK_ERASE_64KB 0xD8
#define W25Q64_BLOCK_ERASE_32KB 0x52
#define W25Q64_SECTOR_ERASE_4KB 0x20
#define W25Q64_CHIP_ERASE 0xC7
#define W25Q64_ERASE_SUSPEND 0x75
#define W25Q64_ERASE_RESUME 0x7A
#define W25Q64_POWER_DOWN 0xB9
#define W25Q64_HIGH_PERFORMANCE_MODE 0xA3
#define W25Q64_CONTINUOUS_READ_MODE_RESET 0xFF
#define W25Q64_RELEASE_POWER_DOWN_HPM_DEVICE_ID 0xAB
#define W25Q64_MANUFACTURER_DEVICE_ID 0x90
#define W25Q64_READ_UNIQUE_ID 0x4B
#define W25Q64_JEDEC_ID 0x9F
#define W25Q64_READ_DATA 0x03
#define W25Q64_FAST_READ 0x0B
#define W25Q64_FAST_READ_DUAL_OUTPUT 0x3B
#define W25Q64_FAST_READ_DUAL_IO 0xBB
#define W25Q64_FAST_READ_QUAD_OUTPUT 0x6B
#define W25Q64_FAST_READ_QUAD_IO 0xEB
#define W25Q64_OCTAL_WORD_READ_QUAD_IO 0xE3

#define W25Q64_DUMMY_BYTE 0xFF
//用于表示接受时交换过去的无用数据

#endif
1
2
3
4
5
6
7
8
/*W25Q64.h*/
#ifndef __W25Q64_H
#define __W25Q64_H

void W25Q64_Init();
void W25Q64_ReadID(uint8_t* mId,uint16_t* dId);

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*W25Q64.c*/
#include "stm32f10x.h" // Device header

#include "MySPI.h"

void W25Q64_Init()
{
MySPI_Init();
}

/*查看芯片手册对应指令集编写时序*/

void W25Q64_ReadID(uint8_t* mId,uint16_t* dId)
{
MySPI_Start();

MySPI_SwapByte(0x9F);//发送读取厂商ID和设备ID的指令码

*mId = MySPI_SwapByte(0xFF);//交换数据得到从机返回的第一个字节厂商ID,主机随便发送什么默认0xFF

*dId = MySPI_SwapByte(0xFF);//交换数据得到从机返回的第二个字节设备ID(前8bit为存储器类型,后8bit为存储器大小),主机随便发送什么默认0xFF
*dId <<= 8; //设置16bit高8位

*dId |= MySPI_SwapByte(0xFF);//设置16bit低8位,不能直接等于,否则高八位会被置0

MySPI_Stop();
}

测试代码:先使用该读取ID的代码测试是否能使用SPI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*main.c业务代码*/
#include "stm32f10x.h" // Device

#include "usart.h"
#include "W25Q64.h"

int main(void)
{
uint8_t mid=0x00;
uint16_t did=0x00;

UsartInit();

W25Q64_Init();

W25Q64_ReadID(&mid,&did);
UsartSendNum(mid,2);
UsartSendNum(did,4);
while(1)
{

}

}

我们读取的指令码为0x9F,对应手册上收到的应该是

厂商ID:0xEF

设备ID:0x4017

将OLED上显示或者串口发送的结果对比发现正确

W25Q64完整代码

由于每次操作需要我们判断是否芯片是否处于BUSY,所有我们可以在每次写操作的时候进行等待BUSY位。等待分为事前等待和事后等待

事前等待:在写操作开始前调用W25Q64_WaitBusy等待BUSY位置0

事后等待:在写操作结束后需要调用W25Q64_WaitBusy

两者区别:

  1. 事后等待比事前等待更保险,事后等待可以保证在进行写操作之外的地方芯片肯定不处于BUSY状态,保证安全。

  2. 事前等待效率比事后等待效率高,可以在执行完写操作后执行其他代码,刚好可以利用这段代码来消耗等待时间

  3. 事前等待需要在读取和写入前都要调用。因为读的时候也需要在BUSY位不为1的时候。而事后等待只需要在写入后调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*W25Q64.h*/
#ifndef __W25Q64_H
#define __W25Q64_H


void W25Q64_Init();
void W25Q64_ReadID(uint8_t* mId,uint16_t* dId);

void W25Q64_PageProgram(uint32_t Address,uint8_t* DataArray,uint16_t count);

void W25Q64_SectorErase(uint32_t Address);

void W25Q64_ReadData(uint32_t Address,uint8_t* DataArray,uint32_t count);

//void W25Q64_WriteEnble();

//void W25Q64_WaitBusy();
/*这两个函数为了模块化,不用外部调用了*/
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
/*W25Q64.c*/
#include "stm32f10x.h" // Device header

#include "MySPI.h"
#include "W25Q64_Ins.h"

void W25Q64_Init()
{
MySPI_Init();
}

/*查看芯片手册对应指令集编写时序!!!*/

void W25Q64_ReadID(uint8_t* mId,uint16_t* dId)
{
MySPI_Start();

MySPI_SwapByte(W25Q64_JEDEC_ID);//发送读取厂商ID和设备ID的指令码

*mId = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//交换数据得到从机返回的第一个字节厂商ID,主机随便发送什么默认0xFF

*dId = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//交换数据得到从机返回的第二个字节设备ID(前8bit为存储器类型,后8bit为存储器大小),主机随便发送什么默认0xFF
*dId <<= 8; //设置16bit高8位

*dId |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);//设置16bit低8位,不能直接等于,否则高八位会被置0

MySPI_Stop();
}

//每次写之前都要写使能
void W25Q64_WriteEnble()
{
MySPI_Start();

MySPI_SwapByte(W25Q64_WRITE_ENABLE);

MySPI_Stop();
}

//等待状态寄存器BUSY位
void W25Q64_WaitBusy()
{
uint32_t Timeout=100000;
MySPI_Start();

MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);
while((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01) //状态寄存器有8位,最低位代表BUSY位,读取判断BUSY位若为1就一直等待
{
/*状态寄存器可以被连续读取,如果不停止就会一直发送状态寄存器当前的值*/
Timeout--;
if(Timeout == 0)//超时退出避免程序卡死
{
break;
}
}

MySPI_Stop();
}

//页编程,也就是写入数据到W25Q64。需要先写使能!!
void W25Q64_PageProgram(uint32_t Address,uint8_t* DataArray,uint16_t count)//读取最大值count为256,所以要定义为uint16,uint8=255
{
W25Q64_WriteEnble();//每次写入数据前都要进行写使能!!!,在这里可以方便我们避免每次都要手动写使能
MySPI_Start();
MySPI_SwapByte(W25Q64_PAGE_PROGRAM);
MySPI_SwapByte(Address >> 16);//取高8位
MySPI_SwapByte(Address >> 8); //取中8位该函数只会发送8bit数据,高位会舍弃
MySPI_SwapByte(Address);//取低8位发送

for(int i=0;i<count;i++)
{
MySPI_SwapByte(DataArray[i]);
}

MySPI_Stop();

W25Q64_WaitBusy();//写操作进行事后等待
}

//扇区擦除,其他擦除同理,Address为指定擦拭的4个字节,也就是1个扇区。需要先写使能!!
void W25Q64_SectorErase(uint32_t Address)
{
W25Q64_WriteEnble(); //每次写入数据前都要进行写使能!!!,在这里可以方便我们避免每次都要手动写使能
MySPI_Start();
MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);
MySPI_SwapByte(Address >> 16);//取高8位
MySPI_SwapByte(Address >> 8); //取中8位该函数只会发送8bit数据,高位会舍弃
MySPI_SwapByte(Address);//取低8位发送
MySPI_Stop();

W25Q64_WaitBusy();//写操作进行事后等待
}

void W25Q64_ReadData(uint32_t Address,uint8_t* DataArray,uint32_t count)//读取时数量没有限制
{
MySPI_Start();

//1.发送读取数据指令码
MySPI_SwapByte(W25Q64_READ_DATA);

//2.发送读取的24位地址
MySPI_SwapByte(Address >> 16);//取高8位
MySPI_SwapByte(Address >> 8); //取中8位该函数只会发送8bit数据,高位会舍弃
MySPI_SwapByte(Address);//取低8位发送

//3.置换得到数据
for(int i=0;i<count;i++)
{
DataArray[i]= MySPI_SwapByte(W25Q64_DUMMY_BYTE);//发送0xFF将数据置换过来
}

MySPI_Stop();
}


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*main.c*/

#include "stm32f10x.h" // Device header
#include "usart.h"
#include "W25Q64.h"

int main(void)
{
uint8_t mid=0x00;
uint16_t did=0x00;

uint8_t ArrayWrite[] = {0xA1,0xB2,0xC3,0xD4};
uint8_t ArrayRead[4];

W25Q64_Init();

W25Q64_ReadID(&mid,&did);

W25Q64_SectorErase(0x000000);//指定擦除扇区起始地址,后3位代表一个扇区内地址,6位代表块地址
W25Q64_PageProgram(0x000000,ArrayWrite,4);

W25Q64_ReadData(0x000000,ArrayRead,4);

// OLED_ShowHexNum(2,3,ArrayWrite[0],2);
// OLED_ShowHexNum(2,3,ArrayWrite[0],2);
// OLED_ShowHexNum(2,3,ArrayWrite[0],2);
// OLED_ShowHexNum(2,3,ArrayWrite[0],2);
//
// OLED_ShowHexNum(3,3,ArrayRead[0],2);
// OLED_ShowHexNum(3,3,ArrayRead[0],2);
// OLED_ShowHexNum(3,3,ArrayRead[0],2);
// OLED_ShowHexNum(3,3,ArrayRead[0],2);
while(1)
{
}

}

1.每次写入数据前要先写使能!!!

2.每次写入前一定要记得擦除!!!

最终OLED上显示发送和读取的数据相同,断电后不写入直接读取数据也不变

硬件SPI外设(硬件读写)

image-20250105141343897

SPI最常用配置就是8位数据帧,高位先行。16位和低位先行用的很少


时钟频率:SPI的时钟由PCLK(外设时钟)分频得来,可以2~256分频,时钟频率越快,对应传输速率越快。

频率不能任意指定,也就是说SPI的时钟频率只能是对面8种分频后对应的时钟频率,且对于SPI1和SPI2来说,PCLK也不相同,SPI1挂载在APB2,PCLK是72MHz,SPI2挂载在APB1,PCLK是36MHz


多主机模型:使用较少,可以看手册学习

主机或从机:通常作为主机


精简为半双工/单工通信:

可以节省一条数据线,半双工或单工通信。一般不适用


DMA:大量数据传输时使用


兼容I2S协议:数字音频传输专用协议,与SPI有一些共同特征


以上所有内容都可以查看参考手册进行学习!!!

SPI框图

image-20250108114540986

与对应寄存器描述结合理解


左上角:通过LSBFIRST控制位控制低位先行还是高位先行,移位寄存器通过MOSI和MISO,分别移动和接收一位位数据。SPI发送和接收可以同时进行

接收缓冲区:就是RDR寄存器

发送缓冲区:就是TDR寄存器

TDR和RDR占用同一个地址,统一叫作DR,写入经过RDR,发送数据经过TDR,对应也是TXE(发送寄存器空)和RXNE(接收寄存器非空)两个标志位

移位寄存器配合数据寄存器实现连续数据流

发送数据先写入TDR,再转到移位寄存器发送,发送的同时接收数据,接收的同时转到RDR,我们再从RDR读取数据


左下角:

波特率发生器:内部有一个分频器,输入时钟为PCLK,72M或36M,经过分频器后输出到SCK,生成时钟与移位寄存器同步


右下角:

LSBFIRST:用于控制高位先行还是低位先行

SPE:SPI使能位

BR2,BR1,BR0:用于控制分频系数

MSTR:配置主从模式

CPOL和CPHA:选择SPI四种模式


右上角:

一些使能位与控制位,重要的是TXE和RXNE


NSS引脚:与多主机有关

SPI外设基本结构:

image-20250108122752353

TDR整体转入移位寄存器的时刻,置TXE标志位为1

移位寄存器数据整体转入RDR的时刻,置RXNE标志位为1


图中没画出SS引脚,我们使用普通的GPIO口来模拟

主模式全双工连续传输

image-20250108172721631

这里演示的是模式3,低位先行的模式:

首先等待TXE=1后就软件写入数据到SPI_DR(TDR),TXE也就变为0,转入完成后,立马将数据再移动到移位寄存器,转入到移位寄存器瞬间把TXE置为1,表示发送寄存器空,波形产生开始传输,但此时又要立马把下个数据写入到TDR中


流程简述下来就是:

发送数据1、发送数据2-- 接收数据1 --发送数据3 – 接收数据2 - 发送数据4 – 接收数据3

数据交换的流程是交叉的,要求很严格,对程序设计不太友好

如果对效率要求很高,就可以研究这个

非连续传输

image-20250108174720946

这里演示的是模式3,低位先行的模式:

接收时序与发送时序是同步的,具体见连续传输的图,因为是主机和从机交换字节发送所以发送的同时也在接收。但是必须先发送才会产生时序,然后才会有接收!!!

首先等待TXE=1后就软件写入数据到SPI_DR(TDR),TXE也就变为0,转入完成后,立马将数据再移动到移位寄存器,转入到移位寄存器瞬间把TXE置为1,表示发送寄存器空,一旦移位寄存器有数据了,时序波形会自动产生,开始交换数据

此时不立马将下个数据写入TDR,而是等待第一个字节时序结束,意味着接收第一个字节也结束了,RXNE置1,先把第一个接收到的数据读出来,之后再写入数据2

流程即:四行代码完成任务

第1步:等待TXE为1

第2步:写入发送的数据至TDR

第3步:等待RXNE为1

第4步:读取RDR接收的数据

然后重复第2、3、…字节

所以我们只需要将这四步封装到一个函数中就可以实现字节的交换,与软件SPI的流程基本上是一样的。


非连续与连续传输的区别:没有及时将数据送入TDR寄存器,等到第一个字节时序完成后,第二个字节还没有送过来,数据传输就会等候,所以时钟和数据的时序在字节与字节之间产生了间隙,拖慢了整体数据传输的速度。

image-20250108175801926

间隙在SCK频率低时影响不大,但在非常高时就非常严重

频率在2分频下的波形:

image-20250108181920148

可以看到等待时间都远大于数据交换时间了

所以我们要在想在极限频率下,进一步提高数据传输速率,我们需要使用连续传输模式,或者使用DMA

TXE和RXNE标志位清除问题

image-20250108211408772

手册上的描述,TXE和RXNE会在写入DR和读DR的时候由硬件清除

所以我们不需要手动调用ClearFlag清除标志位

连续和非连续的优缺点

非连续传输:

好处:容易封装,好理解,好用

缺点:会损失一点点性能


连续传输:对传输效率有要求的话可以使用

好处:传输更快

缺点:操作起来相对复杂,对软件的需求较高,每个标志位产生后数据都要及时处理

软件硬件波形对比

image-20250108182601125

区别:

下降沿和低电平期间,都可以作为数据变化的时刻,只是硬件波形一般会紧贴边沿,软件波形,一般只能在电平期间,无论哪种方式都不会影响数据传输。

对于软件波形,尽量要贴近边沿,否则等待太久贴近下一个边沿了数据也会容易出错

SPI库函数

标准库函数中包含的SPI很多名称中带有I2S相关的函数,因为I2S与SPI共用一套电路。直接使用即可

  • 初始化:
1
2
3
4
5
6
7
void SPI_I2S_DeInit(SPI_TypeDef* SPIx);
void I2S_Init(SPI_TypeDef* SPIx, I2S_InitTypeDef* I2S_InitStruct);
void I2S_StructInit(I2S_InitTypeDef* I2S_InitStruct);
/*-----------------------------------------*/

void SPI_Init(SPI_TypeDef* SPIx, SPI_InitTypeDef* SPI_InitStruct);
void SPI_StructInit(SPI_InitTypeDef* SPI_InitStruct);
  • 外设使能:
1
2
3
void SPI_Cmd(SPI_TypeDef* SPIx, FunctionalState NewState);

void I2S_Cmd(SPI_TypeDef* SPIx, FunctionalState NewState);
  • 中断配置:
1
void SPI_I2S_ITConfig(SPI_TypeDef* SPIx, uint8_t SPI_I2S_IT, FunctionalState NewState);
  • DMA:
1
void SPI_I2S_DMACmd(SPI_TypeDef* SPIx, uint16_t SPI_I2S_DMAReq, FunctionalState NewState);
  • 发送和接收数据:
1
2
3
4
void SPI_I2S_SendData(SPI_TypeDef* SPIx, uint16_t Data);
//数据到DR
uint16_t SPI_I2S_ReceiveData(SPI_TypeDef* SPIx);
//从DR读数据

不常用函数:

  • NSS引脚配置:
1
2
void SPI_NSSInternalSoftwareConfig(SPI_TypeDef* SPIx, uint16_t SPI_NSSInternalSoft);
void SPI_SSOutputCmd(SPI_TypeDef* SPIx, FunctionalState NewState);
  • 数据帧位数配置:
1
2
void SPI_DataSizeConfig(SPI_TypeDef* SPIx, uint16_t SPI_DataSize);
//8位或16为数据帧配置
  • CRC校验配置:
1
2
3
4
void SPI_TransmitCRC(SPI_TypeDef* SPIx);
void SPI_CalculateCRC(SPI_TypeDef* SPIx, FunctionalState NewState);
uint16_t SPI_GetCRC(SPI_TypeDef* SPIx, uint8_t SPI_CRC);
uint16_t SPI_GetCRCPolynomial(SPI_TypeDef* SPIx);
  • 半双工时双向线的方向配置:
1
void SPI_BiDirectionalLineConfig(SPI_TypeDef* SPIx, uint16_t SPI_Direction);
  • 标志位:
1
2
3
4
FlagStatus SPI_I2S_GetFlagStatus(SPI_TypeDef* SPIx, uint16_t SPI_I2S_FLAG);
void SPI_I2S_ClearFlag(SPI_TypeDef* SPIx, uint16_t SPI_I2S_FLAG);
ITStatus SPI_I2S_GetITStatus(SPI_TypeDef* SPIx, uint8_t SPI_I2S_IT);
void SPI_I2S_ClearITPendingBit(SPI_TypeDef* SPIx, uint8_t SPI_I2S_IT);

硬件SPI初始化流程

根据SPI框图:

  1. 开启RCC对应SPI和GPIO口的时钟
  2. 初始化GPIO口,SCK和MOSI是硬件控制输出的信号,配置为复用推挽输出。MISO是,是硬件外设配置的输入信号,配置为上拉输入。还有一个SS引脚,使用软件模拟控制的输出信号,配置为通用推挽输出即可
  3. 配置SPI外设,调用SPI_Init配置各参数即可
  4. 使能,开关控制,调用SPI_Cmd即可
  5. 默认SS为高电平,不使用从机

硬件SPI读写W25Q64

对于W25Q64Q驱动层的代码我们不需要修改,我们只需要修改底层SPI代码即可

1
2
3
4
5
6
7
8
9
10
/*MySPI.h*/
#ifndef __MYSPI_H
#define __MYSPI_H

void MySPI_Init();
void MySPI_Start();
void MySPI_Stop();
uint8_t MySPI_SwapByte(uint8_t ByteSend);

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/*MySPI.c*/

#include "stm32f10x.h" // Device header

//写SS
void MySPI_W_SS(uint8_t BitVal)
{

GPIO_WriteBit(GPIOA,GPIO_Pin_4,(BitAction)BitVal);
}


void MySPI_Init()
{
//1.开启对应引脚RCC
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1,ENABLE);

//2.配置GPIO
/*SCK和MOSI复用推挽输出引脚*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

/*使用软件模拟SS,SS通用推挽输出引脚*/
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

/*MISO上拉输入引脚*/
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);

//3.配置SPI
SPI_InitTypeDef SPI_InitStruct;
SPI_InitStruct.SPI_Mode = SPI_Mode_Master;//选择主机
SPI_InitStruct.SPI_Direction = SPI_Direction_2Lines_FullDuplex;//选择全双工
SPI_InitStruct.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStruct.SPI_FirstBit = SPI_FirstBit_MSB;//8位先行
SPI_InitStruct.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128;//频率为72Mhz/128 = 500多khz
SPI_InitStruct.SPI_CPHA = SPI_CPHA_1Edge;
SPI_InitStruct.SPI_CPOL = SPI_CPOL_Low;
SPI_InitStruct.SPI_NSS = SPI_NSS_Soft;//我们使用的GPIO引脚模拟,使用软件NSS
SPI_InitStruct.SPI_CRCPolynomial = 7;//我们不使用CRC校验,只需要随便给个值即可
SPI_Init(SPI1,&SPI_InitStruct);

//4.使能
SPI_Cmd(SPI1,ENABLE);

//5.默认不选中从机
MySPI_W_SS(1);
}

//SPI起始条件
void MySPI_Start()
{
MySPI_W_SS(0);

}

//SPI终止条件
void MySPI_Stop()
{
MySPI_W_SS(1);
}


//SPI字节交换,硬件自动控制SCK、MOSI、MISO引脚
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
/*对应非连续传输的四个步骤*/
//1.等待TXE为1,代表发送数据寄存器为空,可以写入数据了
while(SPI_I2S_GetFlagStatus(SPI1,SPI_I2S_FLAG_TXE) != SET)
{
}

//2.写入发送数据到TDR(发送数据寄存器),同时自动清除TXE
SPI_I2S_SendData(SPI1,ByteSend);

//3.等待RXNE为1,接收数据寄存器非空,代表接收完成
while(SPI_I2S_GetFlagStatus(SPI1,SPI_I2S_FLAG_RXNE) != SET)
{
}

//4.从RDR(接收数据寄存器)读取接收到的数据,同时自动清除RXNE
return SPI_I2S_ReceiveData(SPI1);

}

RTC

常识

Unix时间戳

image-20250109131815285

时间戳:1970年1月1日0时0分0秒开始所经过的秒数,没有进位,不同时区通过对秒计数器添加偏移就可以得到当地时间

计算机底层使用时间戳会很方便,只需要一个很大的寄存器存储秒数即可

给人类观看的时候,只需要将时间戳转换为对应的日期即可

但是,时间戳比较占用软件资源,在每次进行秒计数器和日期时间转换时,软件都需要经过比较复杂的运算

RTC/GMT

image-20250109133115489

闰秒:由于地球自转会越转越慢,地球自转一周的时间会变化,当地球自转一周的时间与原子钟计时一天的时间相差超过0.9s时,UTC就会执行闰秒操作,即多走1s来等等地球,1分钟可能会出现61s。、

现在世界使用的就是UTC

时间戳转换

image-20250109134112560

localtime是在gmtime的基础上,加一个时区偏移得到

image-20250109134249030

BKP简介

image-20250109135916881

BKP其实就是一个存储器,只有当VDD和VBAT都断电了数据才会清零


TAMPER引脚:侵入事件将所有备份寄存器内容清除,用于安全等


RTC引脚:输出RTC校准时钟,或输出RTC闹钟脉冲


BKP20字节:中容量和小容量

BKP84字节:大容量和互联型

基本结构

image-20250109142941243

橙色部分叫后备区域,BKP处于后备区域,但后备区域不只有BKP,还有RTC相关电路

后备区域特性:当VDD主电源掉电时,后备区域仍可以由VBAT的备用电池供电,当VDD主电源上电时,后备区域供电会由VBAT切换到VDD,也就是在主电源有电时,VBAT不会用到,这样可以节省电池电量。

每个数据寄存器空间为2个字节


BKP的几个功能:

  • 侵入检测:可以从PC13位置的TAMPER引脚引入一个检测信号,当TAMPER产生上升沿或者下降沿时,清除BKP所有的内容,以保证安全
  • 时钟输出:可以从PC13位置的RTC引脚输出出去,供外部使用,其中输出RTC校准时钟时配合校准寄存器可以对RTC的误差进行校准

BKP库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void BKP_DeInit(void);//手动清空bkp所有寄存器

void BKP_TamperPinLevelConfig(uint16_t BKP_TamperPinLevel);
//配置tamper引脚有效电平
void BKP_TamperPinCmd(FunctionalState NewState);
//是否开启侵入检测功能

void BKP_ITConfig(FunctionalState NewState);
//BKP中断配置
void BKP_RTCOutputConfig(uint16_t BKP_RTCOutputSource);
//BKP时钟输出配置,可选择输入RTC校准时钟,RTC闹钟脉冲或秒脉冲

void BKP_SetRTCCalibrationValue(uint8_t CalibrationValue);
//设置RTC校准值,写入RTC校准寄存器


void BKP_WriteBackupRegister(uint16_t BKP_DR, uint16_t Data);
//写备份寄存器
uint16_t BKP_ReadBackupRegister(uint16_t BKP_DR);
//读备份寄存器

/*标志位相关函数*/
FlagStatus BKP_GetFlagStatus(void);
void BKP_ClearFlag(void);
ITStatus BKP_GetITStatus(void);
void BKP_ClearITPendingBit(void);

读写BKP

  • 使用BKP或RTC必须先执行前两步:
  1. 开启PWR和BKP时钟
  2. 设置PWR,使能BKP和RTC的访问
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "LED.h"
#include "Key.h"
#include "usart.h"
#include "W25Q64.h"



uint16_t test = 0;

int main(void)
{


//1.开启对应BKP和PWR时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR|RCC_APB1Periph_BKP,ENABLE);
//2.使能PWR,使能BKP和RTC的访问
PWR_BackupAccessCmd(ENABLE);

//3.写bkp寄存器
BKP_WriteBackupRegister(BKP_DR1,0x1234);//写入DR1寄存器,uint16_t

//4.读bkp寄存器
test = BKP_ReadBackupRegister(BKP_DR1);

while(1)
{

}

}

RTC外设

RTC简介

image-20250109144513674

其中计数都是用一个秒计数器,对应Unix的时间戳,使用c库中的time.h库中localtime函数可以得到年月日时分秒信息了。


RTC可选三种时钟源输入:HSE、LSE、LSI

一般都使用LSE:32.768KHz 提供给RTC时钟

特殊情况下,HSE和LSI为备选时钟

选择LSE原因:HSE和LSI都有自己的用途,而LSE是专用,同时只有LSE时钟可以通过VBAT备用电池供电,HSE和LSI在主电源掉电后是停止运行的。

所以要想实现RTC主电源掉电继续走时的功能,必须选择RTC专用时钟LSE


BKP和RTC内容都可以在参考手册中查看!!!

RTC框图

image-20250109145649841

图中灰色部分都处于后备区域,主电源掉电后,可以使用备用电池供电

左边:核心的分频和计数计时部分

RTCCLK:为时钟来源在RCC配置,主要选择LSE

RTC预分频器:实际上就是一个计数器,计几个数就溢出1次就是几分频。由重装载寄存器RTC_PRL(相当于ARR),和余数寄存器RTC_DIV(相当于CNT计数器,但是为自减计数器)。

若RTC_PRL=32768,来一个输入时钟RTC_DIV自减一次,直到变为0,然后再来一个输入时钟就会产生一个溢出信号,同时DIV变回32767。也就是每来32768个输入脉冲计数器就溢出一次,产生一个输出脉冲,也就是产生了32768分频,分频后输出的时钟频率为1Hz,也就是1s提供给后续

RTC_CNT:就是Unix时间戳的秒计数器

RTC_ALR:闹钟寄存器RTC_ALR,32位值,可以在其写入一个秒数,设定闹钟,当RTC_CNT == RTC_ALR,这时闹钟响了就会产生RTC_Alarm信号,通往右边的中断系统,执行对应操作,同时闹钟信号RTC_Alarm可以让STM32退出待机模式。

可以实现定时唤醒待机芯片采集数据,完成后继续待机,以节省电源的作用


右边:中断使能和NVIC部分

RTC_Second:秒信号,触发秒中断,每秒触发一次中断

RTC_Overflow:溢出信号,触发溢出中断,计数值溢出触发一次中断,这个一般不会触发

RTC_Alarm:闹钟信号,触发闹钟中断,可以设定闹钟或者唤醒待机设备


上边:AP1总线相关部分

读写寄存器可以通过APB1总线完成,且RTC是APB1总线上的设备


下边:PWR关联的部分

RTC基本结构

image-20250109151703477

硬件电路

image-20250109151911012

在最小系统的电路上需要额外添加两个部分:

  1. **备用电池电路:**根据数据手册得到简单连接,参考手册得到推荐连接

    图右上角:使用3V纽扣电池充当备用电池,型号位CR2032/CR1220等,有字的那面为正极

image-20250109152015914

画板子设计产品应该选择推荐连接方案更保险

  1. **外部低速晶振:**根据参考手册设计

图右下角:黑色的为外部低速晶振32.768khz,白色的为外部高速8Mhz晶振

image-20250109152430959

RTC操作注意事项

image-20250109152914363

  • 使用BKP或RTC必须先执行前两步:
  1. 开启PWR和BKP时钟
  2. 设置PWR,使能BKP和RTC的访问

  • 由于APB1和RTC_CRL使用的是不同时钟,面临着同步问题,所以需要在初始化时,调用一个等待同步的函数

  • RTC有一个进入配置模式的标志位,必须先将RTC中的RTC_CRL中的CNF标志位置1才能进入配置模式。

当然,在每个写入寄存器的库函数中都自动加上了这个操作,我们可以不用写


  • 每次写入操作,都需要等待RTC_CR中的RTOFF标志位,只有为1时才能写入RTC寄存器。也就是调用一个等待上一个任务函数

原因:因为PCLK1和RTCCLK时钟频率不一样,使用PCLK1的频率写入后,这个值不能直接更新到RTC寄存器中,因为RTC是由RTCCLK驱动,所以PCLK1写完后需要等一下RTCCLK时钟,RTCCLK来一个上升沿使值更新到RTC寄存器中

RCC库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void RCC_LSEConfig(uint8_t RCC_LSE);//配置LSE外部低速时钟
void RCC_LSICmd(FunctionalState NewState);//配置LSI内部低速时钟
void RCC_RTCCLKConfig(uint32_t RCC_RTCCLKSource);//RTCCLK配置,配置时钟源选择
void RCC_RTCCLKCmd(FunctionalState NewState);//在调用上面的RTCCLK配置函数后,需要再次调用该函数使能

FlagStatus RCC_GetFlagStatus(uint8_t RCC_FLAG);//调用时钟启动函数后需要该函数等待LSERDY标志位置1,时钟才算启动完成稳定

/*---------------------------------------------*/
void RCC_GetClocksFreq(RCC_ClocksTypeDef* RCC_Clocks);
void RCC_AHBPeriphClockCmd(uint32_t RCC_AHBPeriph, FunctionalState NewState);
void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph, FunctionalState NewState);
void RCC_APB1PeriphClockCmd(uint32_t RCC_APB1Periph, FunctionalState NewState);
void RCC_APB2PeriphResetCmd(uint32_t RCC_APB2Periph, FunctionalState NewState);
void RCC_APB1PeriphResetCmd(uint32_t RCC_APB1Periph, FunctionalState NewState);
void RCC_BackupResetCmd(FunctionalState NewState);
void RCC_ClockSecuritySystemCmd(FunctionalState NewState);
void RCC_MCOConfig(uint8_t RCC_MCO);
void RCC_ClearFlag(void);
ITStatus RCC_GetITStatus(uint8_t RCC_IT);
void RCC_ClearITPendingBit(uint8_t RCC_IT);

RTC库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void RTC_ITConfig(uint16_t RTC_IT, FunctionalState NewState);

void RTC_EnterConfigMode(void);//RTC进入配置模式
void RTC_ExitConfigMode(void);//RTC退出配置模式
uint32_t RTC_GetCounter(void);//RTC获取CNT计数器
void RTC_SetCounter(uint32_t CounterValue);//写入CNT
void RTC_SetPrescaler(uint32_t PrescalerValue);//写入预分频器
void RTC_SetAlarm(uint32_t AlarmValue);//RTC写入闹钟值,该该寄存器是只写的不可读

uint32_t RTC_GetDivider(void);//读取预分频器种的DIV余数寄存器,

/*注意事项中的两个等待函数*/
void RTC_WaitForLastTask(void);//等待上次操作完成,循环直到RTOFF状态位为1
void RTC_WaitForSynchro(void);//等待同步,等待RSF置1

/*标志位相关函数*/
FlagStatus RTC_GetFlagStatus(uint16_t RTC_FLAG);
void RTC_ClearFlag(uint16_t RTC_FLAG);
ITStatus RTC_GetITStatus(uint16_t RTC_IT);
void RTC_ClearITPendingBit(uint16_t RTC_IT);

RTC配置

  1. 开启PWR和BKP时钟,设置PWR,使能BKP和RTC的访问
  2. 使用RCC开启LSE时钟(LSE不行的话换成40khz的LSI),且使用RCC开启LSE时钟(LSE省电默认关闭),且等待LSERDY标志位为1
  3. 配置RTCCLK时钟源,指定LSE为RTCCLK时钟源,并且调用RTCCLK_Cmd使能
  4. 调用两个等待函数,注意事项中的等待同步与等待上一次操作完成
  5. 配置预分频器,RTC_SetPrescaler,并且再调用等待上一次操作完成函数
  6. 配置CNT,并且再调用等待上一次操作完成函数
  7. 如果有闹钟值则配置闹钟
  8. 如果有中断就配置中断

RTC没有结构体进行配置,且没有Cmd函数,开启时钟后就会运行

RTC显示当前时间

1
2
3
4
5
6
7
8
9
10
11
12
13
/*MyRTC.h*/
#ifndef __MYRTC_H
#define __MYRTC_H

extern uint16_t MyRTC_Time[];

void MyRTC_SetTime(void);

void MyRTC_ReadTime(void);

void MyRTC_Init();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
/*MyRTC.C*/
#include "stm32f10x.h" // Device header
#include "time.h"
#include "MyRTC.h"

uint16_t MyRTC_Time[] = {2025,1,9,19,23,00};

void MyRTC_Init()
{
//1.开启BKP和PWR时钟,设置PWR,使能BKP和RTC的访问
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP|RCC_APB1Periph_PWR,ENABLE);
PWR_BackupAccessCmd(ENABLE);


//2.使用RCC开启LSE时钟,等待LSERDY标志位为1
RCC_LSEConfig(RCC_LSE_ON);//使用LSI修改处1:RCC_LSICmd(ENABLE);
while(RCC_GetFlagStatus(RCC_FLAG_LSERDY)!= SET)//使用LSI修改处2:RCC_FLAG_LSIRDY
{
}

//3.配置RTCCLK数据选择器,指定LSE为RTCCLK,并且调用RTCCLK_Cmd使能
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);//使用LSI修改处3:RCC_FLAG_LSERDY
RCC_RTCCLKCmd(ENABLE);

//4.调用两个等待函数,注意事项中的等待同步与等待上一次操作完成
RTC_WaitForSynchro();
RTC_WaitForLastTask();
//这两行代码是安全保障措施,防止因为时钟不同步而出现bug

//5.配置预分频器,RTC_SetPrescaler,并且再调用等待上一次操作完成函数
RTC_SetPrescaler(32768-1);//LSE频率为32768HZ,32768分频后可以使频率为1hz
//使用LSI修改处4:40KHz对应40000-1分频得到1hz
RTC_WaitForLastTask();

//6.配置CNT,并且再调用等待上一次操作完成函数
RTC_SetCounter(1672588795);
RTC_WaitForLastTask();

MyRTC_SetTime();//设置起始时间
}

void MyRTC_SetTime(void)
{
time_t time_cnt;//unsigned int类型数据
struct tm time_date;
//1.数组指定时间填充到struct tm结构体中
time_date.tm_year = MyRTC_Time[0]-1900;
time_date.tm_mon = MyRTC_Time[1]-1;
time_date.tm_mday = MyRTC_Time[2];
time_date.tm_hour = MyRTC_Time[3];
time_date.tm_min = MyRTC_Time[4];
time_date.tm_sec = MyRTC_Time[5];

//2.使用mktime得到秒数
time_cnt = mktime(&time_date) - 8*60*60;//此处是北京时间转为伦敦时间,因为RTC中的秒数是以伦敦时间计算的,写入到CNT中也应该是伦敦时间

//3.写入RTC的CNT中
RTC_SetCounter(time_cnt);
RTC_WaitForLastTask();
}

void MyRTC_ReadTime(void)
{
time_t time_cnt;//unsigned int类型数据
struct tm time_date;

time_cnt = RTC_GetCounter()+ 8*60*60;//此处是伦敦时间转换为北京时间,东8区,要多8个小时,对应8个小时的秒数

time_date = *localtime(&time_cnt);

MyRTC_Time[0] = time_date.tm_year+1900;
MyRTC_Time[1] = time_date.tm_mon+1;
MyRTC_Time[2] = time_date.tm_mday;
MyRTC_Time[3] = time_date.tm_hour ;
MyRTC_Time[4] = time_date.tm_min;
MyRTC_Time[5] = time_date.tm_sec;
}

在程序中由于F103芯片等的BUG,可能使用LSE时不会起振,此时函数会卡死在初始化中,我们可以用示波器观察是否产生波形判断一下

解决方案:

此时我们可以使用LSI作为时钟源,但是就不能实现掉电时间不重置了因为LSI不像LSE一样可以由VBat备用电池供电

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*main.c*/

#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyRTC.h"

int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
MyRTC_Init(); //RTC初始化

/*显示静态字符串*/
OLED_ShowString(1, 1, "Date:XXXX-XX-XX");
OLED_ShowString(2, 1, "Time:XX:XX:XX");
OLED_ShowString(3, 1, "CNT :");
OLED_ShowString(4, 1, "DIV :");

while (1)
{
MyRTC_ReadTime(); //RTC读取时间,最新的时间存储到MyRTC_Time数组中

OLED_ShowNum(1, 6, MyRTC_Time[0], 4); //显示MyRTC_Time数组中的时间值,年
OLED_ShowNum(1, 11, MyRTC_Time[1], 2); //月
OLED_ShowNum(1, 14, MyRTC_Time[2], 2); //日
OLED_ShowNum(2, 6, MyRTC_Time[3], 2); //时
OLED_ShowNum(2, 9, MyRTC_Time[4], 2); //分
OLED_ShowNum(2, 12, MyRTC_Time[5], 2); //秒

OLED_ShowNum(3, 6, RTC_GetCounter(), 10); //显示32位的秒计数器
OLED_ShowNum(4, 6, RTC_GetDivider(), 10); //显示余数寄存器
}
}

BKP寄存器解决掉电时间不丢失问题

在对RTC的初始化中,我们要有判断的去执行

  1. 当系统完全断电了,备用电池也断电了,我们就执行初始化

  2. 当系统只是主电源断电,备用电池没断的话,LSE一直都在运行,就不用执行初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*我们只需要修改MyRTC_Init()的代码即可,为其添加一个BKP寄存器自定义标志位的判断*/
void MyRTC_Init()
{
/*开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE); //开启PWR的时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE); //开启BKP的时钟

/*备份寄存器访问使能*/
PWR_BackupAccessCmd(ENABLE); //使用PWR开启对备份寄存器的访问

if (BKP_ReadBackupRegister(BKP_DR1) != 0xAAAA) //通过写入备份寄存器的标志位,判断RTC是否是第一次配置
//if成立则执行第一次的RTC配置
{
RCC_LSEConfig(RCC_LSE_ON); //开启LSE时钟
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET); //等待LSE准备就绪

RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); //选择RTCCLK来源为LSE
RCC_RTCCLKCmd(ENABLE); //RTCCLK使能

RTC_WaitForSynchro(); //等待同步
RTC_WaitForLastTask(); //等待上一次操作完成

RTC_SetPrescaler(32768 - 1); //设置RTC预分频器,预分频后的计数频率为1Hz
RTC_WaitForLastTask(); //等待上一次操作完成

MyRTC_SetTime(); //设置时间,调用此函数,全局数组里时间值刷新到RTC硬件电路

BKP_WriteBackupRegister(BKP_DR1, 0xAAAA); //在备份寄存器写入自己规定的标志位,用于判断RTC是不是第一次执行配置
}
else //RTC不是第一次配置
{
RTC_WaitForSynchro(); //等待同步
RTC_WaitForLastTask(); //等待上一次操作完成
}
}

如果LSE无法起振导致程序卡死在初始化函数中,可将初始化函数替换为下述代码,使用LSI当作RTCCLK

LSI无法由备用电源供电,故主电源掉电时,RTC走时会暂停

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void MyRTC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);

PWR_BackupAccessCmd(ENABLE);

if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)
{
RCC_LSICmd(ENABLE);
while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);

RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
RCC_RTCCLKCmd(ENABLE);

RTC_WaitForSynchro();
RTC_WaitForLastTask();

RTC_SetPrescaler(40000 - 1);
RTC_WaitForLastTask();

MyRTC_SetTime();

BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);
}
else
{
RCC_LSICmd(ENABLE); //即使不是第一次配置,也需要再次开启LSI时钟
while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);

RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
RCC_RTCCLKCmd(ENABLE);

RTC_WaitForSynchro();
RTC_WaitForLastTask();
}
}

PWR电源控制

PWR简介

image-20250110170201038

实现可编程电压检测器低功耗模式

**可编程电压检测器PVD:**监视VDD电压,在供电电压下降到PVD阀值以下时或上升到PVD阈值以上时,产生一个中断,通知软件做紧急处理

**低功耗模式:**睡眠模式Sleep、停机模式stop、待机模式standby。在空闲状态时关闭不必要的硬件,比如把CPU断电或关闭时钟,但需要保留必要的唤醒电路

所有知识在手册上可以找到

电源框图

image-20250110170713455

STM32内部供电方案图可分为三个部分:

模拟部分供电:VDDA(VDD Analog)

包括A/D转换器、温度传感器、复位模块、PLL

供电正极为:VDDA

供电负极为:VSSA

其中还有两个参考电压供电脚:VREF- 和VREF+

在该芯片中直接接入了VSSA和VDDA,也可能会单独引出去


数字部分供电:VDD供电区域和1.8V供电区域

VDD供电区域:I/O电路、待机电路、电压调节器(为1.8V区域供电)

1.8V供电区域:CPU、存储器、外设

我们可以看到CPU、存储器和外设都是以1.8V的低电压运行的,当需要与外界交流时,才会通过I/O电路转换为3.3V


后备供电区域:VBAT为以下供电

LSE 32K晶体振荡器后备寄存器

RCC BDCR寄存器,即备份域控制寄存器

RTC

低电压检测器:控制开关,VDD有电时由VDD供电,VDD没电时由VBAT供电

image-20250110171925509

上电复位和掉电复位(POR/PDR)

image-20250110181242741

设置了阈值电压:40mV迟滞避免电压来回波动,造成输出也来回抖动

Reset:低电平有效

对应滞后时间在stm32数据手册可以找到

image-20250110181444794

可编程电压检测器PVD

image-20250110181535199

PVD-(Programmable Votage Detector)作用

监视VDD电压,在供电电压下降到PVD阀值以下时或上升到PVD阈值以上时,产生一个中断,通知软件做紧急处理。

PVD在电压过低时为1,正常时输出0

PVD在上升沿或下降沿时触发中断,通过外部中断实现,提醒用户处理。


PVD的阈值电压可以使用程序指定,配置PLS寄存器3位,迟滞电压上限为100mV

image-20250110181653488

低功耗模式介绍

手册中的低功耗模式介绍:

image-20250110182653384

三种模式从上到下:睡眠停机(止)待机

  1. 关闭的电路越来越多
  2. 越来越省电
  3. 越来越难唤醒

图中可以知道关闭电路通常由两个做法:关闭时钟关闭电源

关闭时钟:所有的运算和涉及时序的操作都会暂停,寄存器和存储器保存的数据可以维持不会消失

关闭电源:电路直接断电,电路操作和寄存器数据都丢失,比关闭时钟更省电


睡眠模式:一般省电

**WFI(wait for interrupt):**等待中断,对应唤醒条件为中断唤醒,意思就是处于睡眠状态,如果有中断发生再叫我起来。

调用WFI进入的睡眠模式,任何外设发生任何中断时,芯片都会立刻醒来,进入中断处理程序

**WFE(wait for event):**等待事件,对应唤醒条件为唤醒事件,可以是外部中断配置为事件模式,也可以是使能了中断但没有配置NVIC

调用WFE进入的睡眠模式,产生唤醒事件时,会立刻醒来。一般不需要进中断函数

对电路影响:只关闭了CPU时钟,其他电路没有影响。看上图中描述


停机模式:非常省电

SLEEPDEEP位:置1进入深度睡眠

PDDS位:区分停机和待机。PDDS=0,进入停机模式,PDDS=1,进入待机模式

LPDS位设置电压调节器,开启或进入低功耗模式。LPDS=0,电压调节器开启LPDS=1,电压调节器进入低功耗(更省电但唤醒延迟更高)。

设置流程:SLEEPDEEP=1,PDDS=0,LPDS=0/1,再调用WFI或WFE,芯片就可以进入停止模式了

唤醒条件:只有任一的外部中断能唤醒,其他中断不能唤醒

WFI用外部中断中断模式唤醒,WFE用外部中断事件唤醒

对电路影响:关闭所有1.8V区域的时钟(CPU、AD、外设),以及HSI和HSE振荡器,但不会关闭LSI和LSE,电压调节器开启(1.8V区域电源仍开启)。看上图中描述


待机模式:极为省电

设置流程:SLEEPDEEP=1,PDDS=1,再调用WFI或WFE,芯片就可以进入停止模式了

**唤醒条件:**普通外设中断或外部中断都无法唤醒待机模式,只能由以下四个信号唤醒

  1. WKUP引脚上升沿(如PA0-Wakeup,即PA0)
  2. RTC闹钟事件
  3. NRST引脚的外部复位(Reset一下)
  4. IWDG复位

对电路影响:关闭所有1.8V区域的时钟(CPU、AD、外设),两个高速时钟关闭,两个低速时钟不会关闭,电压调节器关闭(即1.8V区域电源关闭)。看上图中描述

模式选择的图

image-20250110195522341

配置其他寄存器在执行WFI或WFE之前。

图中最左边是执行WFI/WFE后,根据各个寄存器判断启动什么模式的流程

三种模式特性及注意事项

image-20250110211426203

GPIO高低电平保持睡眠前运行时的状态,唤醒后程序从暂停的地方继续运行

手册对事件唤醒描述:

image-20250110211647415

比较麻烦所以还是使用中断唤醒吧


image-20250110211742039

GPIO高低电平保持睡眠前运行时的状态,唤醒后程序从暂停的地方继续运行

注意:

停止模式唤醒时,因为HSI被选为了系统时钟,所以变成了8MHz的主频,所以我们在停止模式唤醒后第一时间应该重新启动HSE,配置主频为72MHz(调用SystemInit即可)


image-20250110212325583

待机模式下,GPIO输出引脚变为高阻态(浮空输入),唤醒后程序从头开始运行

仅备份寄存器和待机电路供电

仅四种方式退出待机模式

节电方法

在数据手册工作条件的供电电流特性测试电流部分可以得到省电方法

  1. 关闭不需要的外设对应时钟
  2. 降低主频,耗电电流下降,对于省电也挺划算的。设备需要连续运行,但是对于主频和性能没这么高要求的话,就可以选择降低主频

产品使用电池的话低功耗模式也是很必要使用的

SLEEPDEEP和SLEEPONEXIT位配置

这两个位位于内核系统控制块,没有提供什么简单的配置方法,只能通过操作寄存器来配置,默认值为0

我们需要打开Cortex-M3编程手册,第4章内核外设的系统控制块SCB中找到寄存器介绍

image-20250111155405959

对应SCB->SCR寄存器等按照配置编程手册上的位配置即可

修改主频

在system_stm32f10x.c文件中可以看到的描述是有两个函数和一个变量与系统主频有关

在system_stm32f10x.h文件中也可以找到

1
2
3
4
5
extern uint32_t SystemCoreClock;//系统时钟

extern void SystemInit(void);//系统时钟初始化

extern void SystemCoreClockUpdate(void);//系统时钟更新

在system_stm32f10x.c文件中,对应部分找到自己的型号然后进行修改主频即可

image-20250111152640885

修改主频后,使用主频的地方需要修改,比如Delay函数

image-20250111152842134

在72Mhz下是正确延时指定时间,但是变为36Mhz后,就会变为原来的1/2。所以最好将SystemCoreClock变量带入计算中做自适应


所以一般条件下不推荐修改主频,除非有需求

睡眠模式实例

睡眠模式使用寄存器都是内核中的,与PWR没什么关系,所以没使用PWR库函数

串口收发

对于这种靠任意中断触发,没中断就没什么事的,平时主循环会耗电

我们就可以给它加入低功耗模式

根据分析,这种情况下串口只能使用睡眠模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"

uint8_t RxData; //定义用于接收串口数据的变量

int main(void)
{
OLED_Init(); //OLED初始化
OLED_ShowString(1, 1, "RxData:"); //显示静态字符串

Serial_Init(); //串口初始化

while (1)
{
if (Serial_GetRxFlag() == 1) //检查串口接收数据的标志位
{
RxData = Serial_GetRxData(); //获取串口接收的数据
Serial_SendByte(RxData); //串口将收到的数据回传回去,用于测试
OLED_ShowHexNum(1, 8, RxData, 2); //显示串口接收的数据
}

OLED_ShowString(2, 1, "Running"); //OLED闪烁Running,指示当前主循环正在运行
Delay_ms(100);
OLED_ShowString(2, 1, " ");
Delay_ms(100);

__WFI(); //执行WFI指令,CPU睡眠,并等待中断唤醒
//__WFE();WFE,事件唤醒
}
}

对于睡眠模式我们在while循环中加入WFI或WFE即可实现睡眠模式,唤醒后继续执行上一次执行的操作

1
2
__WFI();//执行WFI指令,CPU睡眠,并等待中断唤醒
__WFE();//WFE,事件唤醒

现象:

OLED上不再持续显示Running,此时用串口助手发送信息,每发送一次,Running闪烁显示一次,说明只有接收中断后才会唤醒工作一次,然后立马睡眠

停机模式实例

库函数

停机模式涉及内核外的电路操作,需要使用库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void PWR_DeInit(void);
void PWR_BackupAccessCmd(FunctionalState NewState);
//使能后备区域的访问,在RTC初始化时需要使用

void PWR_PVDCmd(FunctionalState NewState);//PVD使能
void PWR_PVDLevelConfig(uint32_t PWR_PVDLevel);//PVD阈值电压配置

void PWR_WakeUpPinCmd(FunctionalState NewState);
//WKUP引脚唤醒功能使用需要调用此函数开启

void PWR_EnterSTOPMode(uint32_t PWR_Regulator, uint8_t PWR_STOPEntry);
//停机模式:调用该函数就可以进入停止模式了

void PWR_EnterSTANDBYMode(void);
//待机模式:调用该函数就可以进入待机模式了

//PWR标志位相关函数
FlagStatus PWR_GetFlagStatus(uint32_t PWR_FLAG);
void PWR_ClearFlag(uint32_t PWR_FLAG);

对射式红外传感器外部中断(停机模式)

使用外部中断触发的可以考虑使用更省电的停机模式

虽然停机模式关闭了外设时钟,但是外部中断使用不需要时钟就能工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "CountSensor.h"

int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
CountSensor_Init(); //红外计数传感器初始化

/*开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE); //开启PWR的时钟!!!
//停止模式和待机模式一定要记得开启

/*显示静态字符串*/
OLED_ShowString(1, 1, "Count:");

while (1)
{
OLED_ShowNum(1, 7, CountSensor_Get(), 5); //OLED不断刷新显示CountSensor_Get的返回值

OLED_ShowString(2, 1, "Running"); //OLED闪烁Running,指示当前主循环正在运行
Delay_ms(100);
OLED_ShowString(2, 1, " ");
Delay_ms(100);

PWR_EnterSTOPMode(PWR_Regulator_ON, PWR_STOPEntry_WFI); //STM32WFI指令进入停止模式,并等待中断唤醒

SystemInit(); //唤醒后,要重新配置时钟
}
}

PWR_EnterSTOPMode(PWR_Regulator_ON, PWR_STOPEntry_WFI):

该函数最后调用了WFI或WFE指令之后就进入了静止模式


SystemInit():唤醒后时钟变为了HSI的8M,需要我们重新启动HSE,配置72M的主频,调用该函数即可

现象:红外传感器计数一次,Running闪烁一次,确实进入了停机模式

待机模式实例

RTC闹钟(待机模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyRTC.h"

int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
MyRTC_Init(); //RTC初始化

/*开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE); //开启PWR的时钟
//停止模式和待机模式一定要记得开启时钟

/*显示静态字符串*/
OLED_ShowString(1, 1, "CNT :");
OLED_ShowString(2, 1, "ALR :");
OLED_ShowString(3, 1, "ALRF:");

/*使能WKUP引脚*/
PWR_WakeUpPinCmd(ENABLE); //使能位于PA0的WKUP引脚,WKUP引脚上升沿唤醒待机模式,接上VCC即可唤醒

/*设定闹钟:由于该寄存器是只写的不可读,所以我们使用变量显示*/
uint32_t Alarm = RTC_GetCounter() + 10; //闹钟为唤醒后当前时间的后10s
RTC_SetAlarm(Alarm); //写入闹钟值到RTC的ALR寄存器
OLED_ShowNum(2, 6, Alarm, 10); //显示闹钟值

while (1)
{
OLED_ShowNum(1, 6, RTC_GetCounter(), 10); //显示32位的秒计数器
OLED_ShowNum(3, 6, RTC_GetFlagStatus(RTC_FLAG_ALR), 1); //显示闹钟标志位

OLED_ShowString(4, 1, "Running"); //OLED闪烁Running,指示当前主循环正在运行
Delay_ms(100);
OLED_ShowString(4, 1, " ");
Delay_ms(100);

OLED_ShowString(4, 9, "STANDBY"); //OLED闪烁STANDBY,指示即将进入待机模式
Delay_ms(1000);
OLED_ShowString(4, 9, " ");
Delay_ms(100);

OLED_Clear(); //OLED清屏,模拟关闭外部所有的耗电设备,以达到极度省电

PWR_EnterSTANDBYMode(); //STM32进入停止模式,并等待指定的唤醒事件(WKUP上升沿或RTC闹钟)
/*待机模式唤醒后,程序会重头开始运行*/
}
}

PWR_EnterSTANDBYMode():

该函数最后统一调用WFI指令进入待机模式


待机模式下只有这四种可以唤醒

  1. WKUP引脚上升沿(如PA0-Wakeup,即PA0):手册中对应描述不需要GPIO初始化,被强制下拉,只需要接VCC即可唤醒
  2. RTC闹钟事件
  3. NRST引脚的外部复位(Reset一下)
  4. IWDG复位

现象:等待10s后,Running闪烁一下后消失,CNT和ALR的值都更新,说明待机模式下唤醒后,程序从头开始运行,所以我们不用重新调用SystemInit了。

最大化省电:进入待机模式前把外接的模块能关的全关,需要精心设计电路。否则待机模式无法做到真正的极度省电!!!

看门狗WDG

看门狗简介

image-20250111173313542

看门狗:由于程序出现漏洞、硬件故障、电磁干扰等原因出现卡死或跑飞现象时可以及时复位程序。

本质是一个定时器,在指定时间范围内,程序没有执行喂狗(手动重装计数器)操作时,看门狗硬件电路就自动产生复位信号。

作用:提高系统的可靠性和健壮性,避免程序陷入长时间罢工状态

可以预料的漏洞应该尽量解决,开门狗只是一个复位的作用,可能会出现复位也不能解决的问题


STM32内置两个看门狗:独立看门狗(IWDG)和窗口看门狗(WWDG)

独立看门狗(IWDG):使用专门的LSI时钟,即使主时钟出现问题,独立看门狗也能正常工作,这就是独立的命名。对时间精度要求较低,只有一个喂狗最晚界限。

窗口看门狗(WWDG):使用APB1时钟,要求看门狗在精确的计时窗口作用,有喂狗最早界限和最晚界限,必须在这个界限窗口内喂狗,这就是窗口的命名。喂早和喂晚都会发生复位

对应更多内容请查看参考手册!!!

独立看门狗IWDG

IWDG框图

image-20250112155237497

IWDG_PR:其实就是定时器中的预分频Prescaler缩写,8位,最大为256

IWDG_RLR:就是定时器中的ARR=Auto Reloader,RLR = Reloader,12位最大为4096

IWDG_SR:状态寄存器

工作流程:计数器为递减计数器,自减到0之前执行喂狗操作,重置计数器为4096-1,当计数器自减到0时,就会进行复位


上面寄存器处于1.8V供电区,而下面的电路处于VDD供电区,所以在停机和待机模式下只会关闭1.8V时钟,所以可以在停机和待机模式下可以运行。

待机模式下的唤醒条件之一就包含看门狗

IWDG键寄存器

image-20250112160401346

在多个位确定代替一位的情况下,该寄存器就算值变化,也很难恰巧出现以上的值,这样就更保险。

同时对IWDG_PR、IWDG_RLR设计了写保护,只有写入键寄存器的值为0x5555时才会解除写保护,其他情况下都不允许写入

IWDG_SR由于是只读的就不用管

IWDG超时时间

image-20250112160724383

就是定时器溢出时间,和定时器溢出时间相同

最大值只能是

PR[2:0]:写入0~7,固定上面几个分频系数

窗口看门狗WWDG

WWDG框图

image-20250112161153418

**时钟源:**PCK1 = 36M,进入预分频器前还进行了4096分频,图中没画出

**看门狗的预分频器WDGTB:**与独立看门狗PR和定时器的PSC都是一样的道理


**看门狗控制寄存器(WWDG_CR):WDGA(看门狗使能位)**和计数器,与计数器合二为一了。

**6/7位递减计数器CNT:**CNT的有效位为T0~T5,T6位用于判断是否溢出。启动时必须将此位写入1,值为1代表计数器没有溢出,值为0代表计数器溢出,产生看门狗复位。位于控制寄存器CR中,计数器和控制寄存器合二为一。

窗口看门狗没有重装寄存器,直接向CNT写数据。

当T6~T0位为1 0 0 0 0 0 0 (0x40),第1位为标志位T6为1,6位计数器的值为000000。再减一次后T6位变为0,计数器溢出,T6通过图中线路产生复位信号

  • 如果把T0~T5看成计数器,就是6位计数器,那么就是自减到0时溢出

  • 如果把T0~T6为看成计数器,就是7位计数器,那么就是自减小于0x40时溢出

喂狗操作:写入WWDG_CR寄存器,也就是写入CNT


看门狗配置寄存器WWDG_CFR:

用于设置喂狗的窗口值的最早界限,写入W6~W0,7位数据,固定不变


最左边是比较器逻辑,什么时候产生复位操作的逻辑


工作流程:

首先时钟从PCLK1(36M时钟进入),然后经过预分频器分频,驱动计数器进行计数,每来一个时钟自减一次。最终比较进行复位

WWDG工作特性

image-20250112163354916

image-20250112182947095

定期写入WWDG_CR寄存器喂狗,避免WWDG复位

W[6:0]:喂狗的最早界限,对应窗口时间

T[6:0]: 喂狗的计数器值,对应超时时间

0x3F:喂狗的最晚界限,由0x40-1得到,对应超时时间的最大值

窗口时间~超时时间:之间喂狗才不会复位


递减计数器T[6:0] = 0X40 产生早期唤醒中断EWI

WWDG超时时间

image-20250112164040798

IWDG和WWDG对比

image-20250112164553712

窗口看门狗的精度比独立看门狗高

独立看门狗代码

相关库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*stm32f10x_iwdg.h*/

void IWDG_WriteAccessCmd(uint16_t IWDG_WriteAccess);
//写使能控制,键寄存器写入ENABLE就是0x5555,Disable就是0x0000

void IWDG_SetPrescaler(uint8_t IWDG_Prescaler);
//设置预分频
void IWDG_SetReload(uint16_t Reload);
//设置重装载值

void IWDG_ReloadCounter(void);
//喂狗操作:重新装载寄存器

void IWDG_Enable(void);
//启用独立看门狗,键寄存器写入0xCCCC

FlagStatus IWDG_GetFlagStatus(uint16_t IWDG_FLAG);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*RCC库函数,stm32f10x_rcc.c*/
FlagStatus RCC_GetFlagStatus(uint8_t RCC_FLAG)
void RCC_ClearFlag(void);

/* * For @b other_STM32_devices, this parameter can be one of the following values:
* @arg RCC_FLAG_HSIRDY: HSI oscillator clock ready
* @arg RCC_FLAG_HSERDY: HSE oscillator clock ready
* @arg RCC_FLAG_PLLRDY: PLL clock ready
* @arg RCC_FLAG_LSERDY: LSE oscillator clock ready
* @arg RCC_FLAG_LSIRDY: LSI oscillator clock ready
* @arg RCC_FLAG_PINRST: Pin reset
* @arg RCC_FLAG_PORRST: POR/PDR reset
* @arg RCC_FLAG_SFTRST: Software reset
* @arg RCC_FLAG_IWDGRST: Independent Watchdog reset
* @arg RCC_FLAG_WWDGRST: Window Watchdog reset
* @arg RCC_FLAG_LPWRRST: Low Power reset
*/

使用RCC中的获取标志位函数可以根据可选参数得到是什么造成的复位,这里用于判断复位是否由看门狗完成

同时我们的看门狗标志位必须手动清除,因为及时按下复位键也不会手动清0。如果不清零下次即使是复位键复位也会判断为看门狗复位

配置流程

  1. 开启时钟LSI**(不需要我们写代码,开启看门狗时会自动强制开启LSI)**

在手册的看门狗时钟部分可以看到,开启独立看门狗后LSI会被强制打开,等LSI稳定后,就可以自动为独立看门狗提供时钟

  1. 写入键寄存器0x5555关闭写保护,再写入预分频值和重装值**(直接调用库函数,不需要我们寄存器操作)**

  2. 写入键寄存器0xCCCC启动看门狗**(直接调用库函数IWDG_Enable)**

  3. 主循环执行喂狗操作

按键触发独立看门狗

image-20250112171912193

设置1000ms超时:图中可以看到前两个分频系数最大超时时间<1000ms不满足,所以选下面的,优先选择预分频系数小的最大化利用计数器值

LSI时钟:40kHz -> 0.025ms

计算预分频系数PR:16

计算重载寄存器RL: 1000/(0.025*16) = 2499+1

重载寄存器RL值:2499

0.025ms * 16 * (2499+1) = 1000ms

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Key.h"

int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
Key_Init(); //按键初始化

/*显示静态字符串*/
OLED_ShowString(1, 1, "IWDG TEST");

/*判断复位信号来源*/
if (RCC_GetFlagStatus(RCC_FLAG_IWDGRST) == SET) //如果是独立看门狗复位
{
OLED_ShowString(2, 1, "IWDGRST"); //OLED闪烁IWDGRST字符串
Delay_ms(500);
OLED_ShowString(2, 1, " ");
Delay_ms(100);

RCC_ClearFlag(); //清除标志位!!!
}
else //否则,即为其他复位
{
OLED_ShowString(3, 1, "RST"); //OLED闪烁RST字符串
Delay_ms(500);
OLED_ShowString(3, 1, " ");
Delay_ms(100);
}

/*IWDG初始化*/
IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); //1.独立看门狗写使能
IWDG_SetPrescaler(IWDG_Prescaler_16); //2.设置预分频为上面计算的16,
IWDG_SetReload(2499); //2.设置重装值为双面计算的2499,独立看门狗的超时时间为1000ms
IWDG_ReloadCounter(); //3.重装计数器,喂狗,这样更严谨一点使下个周期为1000ms
IWDG_Enable(); //4.独立看门狗使能

while (1)
{
Key_GetNum(); //调用阻塞式的按键扫描函数,模拟主循环卡死

IWDG_ReloadCounter(); //重装计数器,喂狗,避免复位

OLED_ShowString(4, 1, "FEED"); //OLED闪烁FEED字符串
Delay_ms(200); //喂狗间隔为200+600=800ms,没到1000ms不会重装
OLED_ShowString(4, 1, " ");
Delay_ms(600);

//Delay_ms(1010);//模拟超时喂狗,多留一点冗余时间
}
}

按键使用的是阻塞式消抖,其中包含while循环,一直按着不松就会卡死在while循环,这样就会造成超时主循环阻塞没喂狗,触发看门狗复位

现象:正常情况下屏幕间断显示FEED

正常按下Reset按键,屏幕显示"RST"复位。

按下按键不松手,屏幕显示"IWDGRST"

窗口看门狗代码

库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  
void WWDG_DeInit(void);

void WWDG_SetPrescaler(uint32_t WWDG_Prescaler);//写入预分频器
void WWDG_SetWindowValue(uint8_t WindowValue);//设置窗口最早界限

void WWDG_EnableIT(void);//使能EWI中断

void WWDG_SetCounter(uint8_t Counter);//喂狗操作

void WWDG_Enable(uint8_t Counter);//使能看门狗,手册上说明了计数器时刻自减,所以可能是任何值,所以需要使能的时候喂一下狗


FlagStatus WWDG_GetFlagStatus(void);
void WWDG_ClearFlag(void);

配置流程

  1. RCC开启APB1总线上的WWDG时钟。

  2. 设置预分频值,窗口值

  3. 使能看门狗(带有喂狗值,需要使能的时候喂一下狗)

  4. 主循环在窗口时间~超时时间内喂狗

按键触发窗口看门狗

image-20250112174602385

设定超时时间50ms:图中看到只能选择最后一个分频系数

PCLK1的T:1/36M

4096是进入预分频前对PCLK1的一个分频

**计算预分频系数:**2的3次方 = 8

计算T[5:0]写入计数器的值: 50ms /[(1/36M)*4096 *8] = 54.931640625 = 55(取整) = 54+1

喂狗值:54

超时时间 = 1/36M * 4096 * 8 * (54+1) = 约为50ms


image-20250112180841188

设定窗口时间为30ms:

PCLK1的T:1/36M

4096是进入预分频前对PCLK1的一个分频

计算窗口值W[5:0]:54 - 30ms /[(1/36M)*4096 *8] = 54 - 33(取整) = 21

窗口值: 21

窗口时间 = 1/36M * 4096 * 8 * (54-33) = 约为30ms


最终我们主循环的喂狗周期为:30ms ~ 50ms

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "Key.h"

int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
Key_Init(); //按键初始化

/*显示静态字符串*/
OLED_ShowString(1, 1, "WWDG TEST");

/*判断复位信号来源*/
if (RCC_GetFlagStatus(RCC_FLAG_WWDGRST) == SET) //如果是窗口看门狗复位
{
OLED_ShowString(2, 1, "WWDGRST"); //OLED闪烁WWDGRST字符串
Delay_ms(500);
OLED_ShowString(2, 1, " ");
Delay_ms(100);

RCC_ClearFlag(); //清除标志位!!!
}
else //否则,即为其他复位
{
OLED_ShowString(3, 1, "RST"); //OLED闪烁RST字符串
Delay_ms(500);
OLED_ShowString(3, 1, " ");
Delay_ms(100);
}

/*1.开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_WWDG, ENABLE); //开启WWDG的时钟

/*WWDG初始化*/
WWDG_SetPrescaler(WWDG_Prescaler_8); //设置预分频为8
WWDG_SetWindowValue(21 | 0x40); //设置窗口值,窗口时间为30ms
WWDG_Enable(54 | 0x40); //使能并第一次喂狗,超时时间为50ms


while (1)
{
Key_GetNum(); //调用阻塞式的按键扫描函数,模拟主循环卡死

/*如果把喂狗放在这里的话程序会一直触发看门狗复位,因为距离第一次喂狗时间<30ms,就会触发看门狗复位*/

OLED_ShowString(4, 1, "FEED"); //OLED闪烁FEED字符串
Delay_ms(20); //喂狗间隔为20+20=40ms
OLED_ShowString(4, 1, " ");
Delay_ms(20);

WWDG_SetCounter(54 | 0x40); //重装计数器,喂狗
}
}

代码中的或上0x40操作:

每次喂狗或上0x40是对计数器的T6位设置为1,避免每次喂狗后立即产生一个复位

而设置窗口时的或上0x40是为了使W6位也为1,这样才能将该窗口值与喂狗值比较,否则窗口值一直小于喂狗值

FLASH 闪存

FLASH简介

image-20250112184103510

image-20250113122739972

FLASH:程序存储器、系统存储器、选项字节三部分。

通过闪存存储器接口(外设):可以对程序存储器和选项字节进行擦除和编程


读写FLASH的用途:

  1. 利用程序存储器Flash的剩余空间来保存掉电不丢失的用户数据
  2. 通过在程序中编程IAP,实现程序的自我更新

在线编程(In-Circuit Programming-ICP):用于更新程序存储器的全部内容,它通过JTAG、SWD协议或系统加载程序(Bootloader)下载程序

在程序中编程(In-Application Programming-lAP): 可以使用微制器支持的任一种通信接口下载程序


下图各个流程都是闪存编程参考手册中的内容,可以观看闪存编程手册编程!!!

当有些参数数据需要掉电不丢失的时候,我们可以将其写入内部FLASH中,这样不用外挂存储器芯片,节省了资源

闪存模块组织

image-20250113123459899

闪存三部分:

主存储器对应:1.程序存储器Flash,起始地址0x0800 0000。也是我们平时说的闪存容量的部分,另外两部分也属于闪存,但不统计进入容量内。

信息块

  • 启动程序代码对应:2.系统存储器,存放bootloader。起始地址0x1FFF F000

  • 用户选择字节对应:3.选项字节,起始地址0x1FFF F800

闪存存储器接口寄存器(外设):从地址来看就是普通的外设寄存器,SRAM的内容


与W25Q64分为块、扇区、页不同

内部Flash只有页为基本单位,每页大小为1K

以000、400、800、C00结尾的都一定是页的起始地址。


对于不同容量产品,闪存的分配方式有些区别,参考单独的闪存编程参考手册!!!

FLASH基本结构

image-20250113143613020

  1. 闪存存储器接口也叫闪存编程和擦除控制器FPEC。可以对程序存储器擦除和编程、选项字节擦除和编程

  2. 选项字节可以配置程序存储器的读写保护

FLASH解锁

image-20250113143920049

FPEC:闪存存储器接口/闪存编程和擦除控制器,其中的键寄存器有三个键值

通过向键寄存器写入指定值可以解锁FLASH的写操作,对于读操作不用执行解锁操作

解锁方式:

  1. 先向FLASH_KEYR写入KEY1
  2. 再向FLASH_KEYR写入KEY2

保护机制:一旦没有先写入KEY1,再写入KEY2就会锁死,除非复位

加锁方式:

  1. 设置FLASH_CR中给的LOCK位锁住FPEC和FLASH_CR

操作闪存方式:先解锁,操作完后,再加锁即可

使用指针访问存储器(指针写入操作)

image-20250113144515473

想以什么形式的方式读出数据,就把uint16_t* 改为对应类型即可,比如想以8位读取,将uint16_t*改为uint8_t*即可

使用 __IO原因:

当单片机通过指针访问外部硬件设备的寄存器时,由于这些寄存器的值可能会由硬件自动更新(例如,一个定时器寄存器的值会随时间变化),因此应该将这些寄存器对应的变量声明为volatile。 这样,每次访问这些变量时,都会直接从硬件寄存器中读取值,而不是使用可能已过时的缓存值。

程序存储器FLASH的擦除和编程

以下内容对应stm32闪存编写手册的2.3.4节和2.3.3节

image-20250113151653732

image-20250113151600875

程序存储器FLASH的全擦除

image-20250113145605786

程序的全擦除在库函数中使用了一个函数,我们可以直接使用,以下就是函数的底层寄存器操作步骤

步骤如下:

  1. 读取FLASH_CR的LOCK位,如果锁住了需要先解锁(库函数都是直接解锁)
  2. 置FLASH_CR的MER = 1 (MER表示执行的是全擦除),置FLASH_CR的STRT = 1 (STRT为触发条件,置1后芯片开始干活)
  3. 检测FLASH_SR的BSY位是否为1,为1就一直等待,直到BSY位置0后全擦除结束

读出并验证被擦除页的数据我们可以不管

程序存储器FLASH的页擦除

image-20250113172548064

程序的页擦除在库函数中使用了一个函数,我们可以直接使用,以下就是函数的底层寄存器操作步骤

步骤如下:

  1. 读取FLASH_CR的LOCK位,如果锁住了需要先解锁(库函数都是直接解锁)

  2. 置FLASH_CR的PER = 1 (PER表示执行的是页擦除),

    然后在FLASH_AR中选择要擦除的页,此地址提前写入

    最后置FLASH_CR的STRT = 1 (STRT为触发条件,置1后芯片开始干活)

  3. 检测FLASH_SR的BSY位是否为1,为1就一直等待,直到BSY位置0后全擦除结束

读出并验证被擦除页的数据我们可以不管

程序存储器FLASH编程

image-20250113145516235

注:STM32的闪存会在写入前检查指定地址有没有擦除,如果没有擦除,STM32不执行写入操作,除非写入的全为0!!!

程序的全擦除在库函数中使用了一个函数,我们可以直接使用,以下就是函数的底层寄存器操作步骤

步骤如下:

  1. 读取FLASH_CR的LOCK位,如果锁住了需要先解锁(库函数都是直接解锁)
  2. 置FLASH_CR的PG = 1 (PG表示执行的是编程操作)
  3. 在指定地址写入半字(16位),只能以半字写入16位(使用指针写入操作)
  4. 检测FLASH_SR的BSY位是否为1,为1就一直等待,直到BSY位置0后全擦除结束

每次流程只能写入半字,如果想要写入很多字节,只需循环调用上面的步骤即可


字、半字、字节:

Word(字):32位数据

HalfWord(半字):16位数据

Byte(字节):8位数据

选项字节

image-20250113151106010

选项字节:存放独立于程序代码的配置参数

内容对应闪存编程手册2.5节

image-20250113151906094

选项字节擦除

image-20250113151712143

  1. 先解锁闪存

  2. 检查FLASH SR的BSY位,以确认没有其他正在进行的闪存操作

  3. 解锁FLASH_CR的OPTWRE(Option Write Enable)位,即:对FLASH_OPTKEY写入类似对FLASH_KEYR解锁的两个KEY操作

  4. 设置FLASH CR的OPTER位为1,设置FLASH CR的STRT位为1

  5. 等待BSY位变为0,即擦除结束

  6. 读出被擦除的选择字节并做验证(不必要操作)

选项字节的编程

image-20250113151403201

  1. 先解锁闪存

  2. 检查FLASH SR的BSY位,以确认没有其他正在进行的闪存操作

  3. 解锁FLASH_CR的OPTWRE(Option Write Enable)位,即:对FLASH_OPTKEY写入类似对FLASH_KEYR解锁的两个KEY操作

  4. 设置FLASH CR的OPTPG位为1

  5. 写入要编程的半字到指定地址

  6. 等待BSY位变为0,即写入结束

  7. 读出被擦除的选择字节并做验证(不必要操作)

器件电子签名

image-20250113152633326

器件电子签名相关内容对应参考手册第28章的内容!!!

电子签名其实就是芯片ID号

**存放在系统存储器区域:**包含BootLoader和几个字节的电子签名

image-20250113152728389

FLASH 库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/*stm32f10x_flash.h*/
/*------------ Functions used for all STM32F10x devices -----*/
void FLASH_SetLatency(uint32_t FLASH_Latency);
void FLASH_HalfCycleAccessCmd(uint32_t FLASH_HalfCycleAccess);
void FLASH_PrefetchBufferCmd(uint32_t FLASH_PrefetchBuffer);
FlagStatus FLASH_GetPrefetchBufferStatus(void);
/*内核运行代码相关,我们不需要使用*/

/*加锁解锁:*/
void FLASH_Unlock(void);
void FLASH_Lock(void);
//芯片的加锁和解锁,KEY1,KEY2

/*芯片擦除:*/
FLASH_Status FLASH_ErasePage(uint32_t Page_Address);
FLASH_Status FLASH_EraseAllPages(void);
FLASH_Status FLASH_EraseOptionBytes(void);
//分别是页擦除、全擦除、选项字节擦除

/*芯片写入(编程):*/
FLASH_Status FLASH_ProgramWord(uint32_t Address, uint32_t Data);//指定地址写入字
FLASH_Status FLASH_ProgramHalfWord(uint32_t Address, uint16_t Data);////指定地址写入半字

/*选项字节读写相关:*/
FLASH_Status FLASH_ProgramOptionByteData(uint32_t Address, uint8_t Data);//选项字节写入Data
FLASH_Status FLASH_EnableWriteProtection(uint32_t FLASH_Pages);//选项字节写保护使能
FLASH_Status FLASH_ReadOutProtection(FunctionalState NewState);//选项字节都保护
FLASH_Status FLASH_UserOptionByteConfig(uint16_t OB_IWDG, uint16_t OB_STOP, uint16_t OB_STDBY);//用户选项的三个配置位

/*选项字节状态获取:*/
uint32_t FLASH_GetUserOptionByte(void);
uint32_t FLASH_GetWriteProtectionOptionByte(void);
FlagStatus FLASH_GetReadOutProtectionStatus(void);

/*标志位相关*/
void FLASH_ITConfig(uint32_t FLASH_IT, FunctionalState NewState);
FlagStatus FLASH_GetFlagStatus(uint32_t FLASH_FLAG);
void FLASH_ClearFlag(uint32_t FLASH_FLAG);
FLASH_Status FLASH_GetStatus(void);

FLASH_Status FLASH_WaitForLastOperation(uint32_t Timeout);//等待上一次操作,也就是等待BSY为0,在上面读写擦除的库函数内部已经调用了,我们不需要调用


--我们这里用不到下面的函数
/*------------ New function used for all STM32F10x devices -----*/
void FLASH_UnlockBank1(void);
void FLASH_LockBank1(void);
FLASH_Status FLASH_EraseAllBank1Pages(void);
FLASH_Status FLASH_GetBank1Status(void);
FLASH_Status FLASH_WaitForLastBank1Operation(uint32_t Timeout);


#ifdef STM32F10X_XL
/*---- New Functions used only with STM32F10x_XL density devices -----*/
void FLASH_UnlockBank2(void);
void FLASH_LockBank2(void);
FLASH_Status FLASH_EraseAllBank2Pages(void);
FLASH_Status FLASH_GetBank2Status(void);
FLASH_Status FLASH_WaitForLastBank2Operation(uint32_t Timeout);
FLASH_Status FLASH_BootConfig(uint16_t FLASH_BOOT);
#endif

上面三个库函数分为部分:通用函数、新加的通用函数、新加的只能被大容量XL系列使用的函数

图中的Bank2是后面新推出加大容量XL系列新加的一块Flash

实验

简单读写FLASH(FLASH底层代码实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include "stm32f10x.h"                  // Device header



//读取一个字32bit
uint32_t MyFlash_ReadWord(uint32_t Address)
{
return *((__IO uint32_t*)(Address));////使用指针访问指定地址下的数据并返回

}

//读取半字16bit
uint16_t MyFlash_ReadHalfWord(uint32_t Address)
{
return *((__IO uint16_t*)(Address));//使用指针访问指定地址下的数据并返回
}
//将32位地址强转为指向uint16_t数据的指针,Address值也就是该指针不会变,仅仅将这个指针(地址)指向的数据变为了uint16_t

//读取字节8bit
uint8_t MyFlash_ReadByte(uint32_t Address)
{
return *((__IO uint8_t*)(Address));//使用指针访问指定地址下的数据并返回
}

//全擦除
void MyFlash_EraseAllPages(void)
{
FLASH_Unlock();//解锁
FLASH_EraseAllPages();//全擦除
FLASH_Lock();//加锁
}

//页擦除
void MyFlash_ErasePage(uint32_t Page_Address)
{
FLASH_Unlock();//解锁
FLASH_ErasePage(Page_Address);//全擦除
FLASH_Lock();//加锁
}

//选项字节擦除
void MyFLash_EraseOptionBytes()
{
FLASH_Unlock();//解锁
FLASH_EraseOptionBytes();//全擦除
FLASH_Lock();//加锁
}

//写入字32bit
void MyFlash_ProgramWord(uint32_t Address, uint32_t Data)
{
FLASH_Unlock(); //解锁
FLASH_ProgramWord(Address, Data); //编程字
FLASH_Lock(); //加锁
}

//写入半字16bit
void MyFlash_ProgramHalfWord(uint32_t Address, uint16_t Data)
{
FLASH_Unlock(); //解锁
FLASH_ProgramHalfWord(Address, Data); //编程半字
FLASH_Lock(); //加锁
}


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*MyFlash.h*/
#ifndef __MYFLASH_H
#define __MYFLASH_H

uint32_t MyFlash_ReadWord(uint32_t Address);

uint16_t MyFlash_ReadHalfWord(uint32_t Address);

uint8_t MyFlash_ReadByte(uint32_t Address);

void MyFlash_EraseAllPages(void);
void MyFlash_ErasePage(uint32_t Page_Address);
void MyFLash_EraseOptionBytes();

void MyFlash_ProgramWord(uint32_t Address, uint32_t Data);
void MyFlash_ProgramHalfWord(uint32_t Address, uint16_t Data);
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "stm32f10x.h"                  // Device header
#include "MyFlash.h"

uint32_t data1 = 0;
uint16_t data2 = 0;
uint8_t data3 = 0;

int main(void)
{


MyFlash_ErasePage(0x0800FC00);//写入前要先擦除,写入区域

MyFlash_ProgramWord(0x0800FC00,0x12345678);//写入Flash最后的区域一个字,数据为0x12345678
MyFlash_ProgramHalfWord(0x0800FC10,0xABCD);//写入半个字,数据为0xABCD

data1 = MyFlash_ReadWord(0x0800FC00);
data2 = MyFlash_ReadHalfWord(0x0800FC00);


while(1)
{


}

}

最终读取的结果如下keil变量的值,以及内存中的值如图

我们也可以使用STM32 ST-LINK Utility软件直接观察

image-20250113194907167

image-20250113194929798

在SRAM中定义数组和标志位对FLASH数据存储

在SRAM定义数组写入数据到FLASH,并且实现上电FLASH数据读取到SRAM操作

1
2
3
4
5
6
7
8
9
10
11
12
13
/*store.h*/
#ifndef __STORE_H
#define __STORE_H

extern uint16_t Store_Data[];

void Store_Init();

void Store_Save();

void Store_Clear();

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/*store.c*/
#include "stm32f10x.h" // Device header
#include "MyFLASH.h"

/*对于这种在很多地方出现,对应不同芯片有不同值得数据我们使用宏定义,提高程序的复用*/
#define STORE_START_ADDRESS 0x0800FC00 //存储的起始地址
#define STORE_COUNT 512 //存储数据的个数

uint16_t Store_Data[STORE_COUNT]; //定义SRAM数组

/**
* 函 数:参数存储模块初始化
* 参 数:无
* 返 回 值:无
*/
void Store_Init(void)
{
/*地址的第一个半字存储标志位以此判断是不是第一次使用,*/
if (MyFLASH_ReadHalfWord(STORE_START_ADDRESS) != 0xA5A5) //读取第一个半字的标志位,if成立,则执行第一次使用的初始化
{
MyFLASH_ErasePage(STORE_START_ADDRESS); //擦除指定页
MyFLASH_ProgramHalfWord(STORE_START_ADDRESS, 0xA5A5); //在第一个半字写入自己规定的标志位,用于判断是不是第一次使用
for (uint16_t i = 1; i < STORE_COUNT; i ++) //循环STORE_COUNT次,除了第一个标志位
{
MyFLASH_ProgramHalfWord(STORE_START_ADDRESS + i * 2, 0x0000); //除了标志位的有效数据全部清0
}
}

/*上电时,将闪存数据加载回SRAM数组,实现SRAM数组的掉电不丢失*/
for (uint16_t i = 0; i < STORE_COUNT; i ++) //循环STORE_COUNT次,包括第一个标志位
{
Store_Data[i] = MyFLASH_ReadHalfWord(STORE_START_ADDRESS + i * 2); //将闪存的数据加载回SRAM数组
}
}

/**
* 函 数:参数存储模块保存数据到闪存
* 参 数:无
* 返 回 值:无
*/
void Store_Save(void)
{
MyFLASH_ErasePage(STORE_START_ADDRESS); //擦除指定页
for (uint16_t i = 0; i < STORE_COUNT; i ++) //循环STORE_COUNT次,包括第一个标志位
{
MyFLASH_ProgramHalfWord(STORE_START_ADDRESS + i * 2, Store_Data[i]); //将SRAM数组的数据备份保存到闪存
}
}

/**
* 函 数:参数存储模块将所有有效数据清0
* 参 数:无
* 返 回 值:无
*/
void Store_Clear(void)
{
for (uint16_t i = 1; i < STORE_COUNT; i ++) //循环STORE_COUNT次,除了第一个标志位
{
Store_Data[i] = 0x0000; //SRAM数组有效数据清0
}
Store_Save(); //保存数据到闪存
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*main.c*/
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Store.h"
#include "Key.h"

uint8_t KeyNum; //定义用于接收按键键码的变量

int main(void)
{
/*模块初始化*/
OLED_Init(); //OLED初始化
Key_Init(); //按键初始化
Store_Init(); //参数存储模块初始化,在上电的时候将闪存的数据加载回Store_Data,实现掉电不丢失

/*显示静态字符串*/
OLED_ShowString(1, 1, "Flag:");
OLED_ShowString(2, 1, "Data:");

while (1)
{
KeyNum = Key_GetNum(); //获取按键键码

if (KeyNum == 1) //按键1按下
{
Store_Data[1] ++; //变换测试数据
Store_Data[2] += 2;
Store_Data[3] += 3;
Store_Data[4] += 4;
Store_Save(); //将Store_Data的数据备份保存到闪存,实现掉电不丢失
}

if (KeyNum == 2) //按键2按下
{
Store_Clear(); //将Store_Data的数据全部清0
}

OLED_ShowHexNum(1, 6, Store_Data[0], 4); //显示Store_Data的第一位标志位
OLED_ShowHexNum(3, 1, Store_Data[1], 4); //显示Store_Data的有效存储数据
OLED_ShowHexNum(3, 6, Store_Data[2], 4);
OLED_ShowHexNum(4, 1, Store_Data[3], 4);
OLED_ShowHexNum(4, 6, Store_Data[4], 4);
}
}

实现按键1按下存入FLASH中,按键2按下清除数据

读取芯片ID(使用指针直接访问读取)

image-20250113152633326

对应手册28章内容:

image-20250113204952775

image-20250113205028352

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*main.c*/
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"

int main(void)
{
OLED_Init(); //OLED初始化

OLED_ShowString(1, 1, "F_SIZE:"); //显示静态字符串
OLED_ShowHexNum(1, 8, *((__IO uint16_t *)(0x1FFFF7E0)), 4); //使用指针读取指定地址下的闪存容量寄存器

OLED_ShowString(2, 1, "U_ID:"); //显示静态字符串
OLED_ShowHexNum(2, 6, *((__IO uint16_t *)(0x1FFFF7E8)), 4); //使用指针读取指定地址下的产品唯一身份标识寄存器
OLED_ShowHexNum(2, 11, *((__IO uint16_t *)(0x1FFFF7E8 + 0x02)), 4);
OLED_ShowHexNum(3, 1, *((__IO uint32_t *)(0x1FFFF7E8 + 0x04)), 8);
OLED_ShowHexNum(4, 1, *((__IO uint32_t *)(0x1FFFF7E8 + 0x08)), 8);

while (1)
{
}
}

**闪存容量寄存器:**使用半字读取

**产品唯一身份标识寄存器:**2个半字和2个字读取

全部显示到OLED上去

存在的BUG-当程序很大时覆盖用户存储参数区

问题描述:

我们上面由于程序较小,程序存储在Flash的靠前区域,我们使用最后一页存储用户数据,但是如果程序很大的时候,可能就会到最后一页,造成程序和用户数据存储的位置冲突。

解决方法:给程序文件限定一个存储范围,不让它分配到后面我们的用户数据空间来。

  1. 打开工程管理

image-20250113203529438

  1. 目前使用的是起始地址为0x80000000,Size大小为0x10000(64KB),刚好使用完了程序存储区

image-20250113203606973

  1. 如果我们想把程序区的最后自己使用,修改Size为0xFC00,这样到FC00之前就是程序存储的地方,后面地址我们可以自己存放用户参数

image-20250113203950691

注意修改size大小不能太小,太小了也会报错

Flash写入之前的擦除必要性

1.Flash 默认状态是“全 1”

  • Flash 存储器在擦除之前的默认值是“1”,即每个字节的每一位都设为 1。当你想要写入数据时,必须将目标区域从“1”改为“0”。但是 Flash 存储器并不支持直接将 “0” 恢复为 “1”,只能将其设置为“0”。

2.擦除是将区域复位为“全 1”

  • 擦除操作是将整个存储单元(如页面或扇区)恢复为“全 1”状态,为后续的写入做好准备。只有当区域恢复为全 1 后,才能写入新的数据。

3.Flash 的写入操作是“增量”

  • 你可以在一个已擦除区域中写入数据,但不能直接覆盖其中的

image-20250113171621586

我们可以使用STM32 ST-LINK Utility这个软件直接查看和修改我们芯片内部的各个地址的内容。

关于上方的内部FLASH和选项字节模块对应都可以查看和修改

使用代码配置读写保护时如果造成了芯片自锁,但程序里没有预留解除写保护的代码,造成没法下载程序了,我们可以使用这个这个软件直接去掉读写保护就可以解除芯片的自锁。