STM32 C语言
在STM32中short占16位(2个字节),而int占32位(四个字节)。具体如下。stdint关键字列举了在程序中使用的变量名来代替关键字
一,基本介绍
1,认识
2,外设
片上资源/外设
上图深颜色的是位于Cortex-M3内核里面的外设,剩下的是内核外的外设。以上为STM32F1整个系列的外设,并不是所有型号都有这些外设,比如此款STM32F103C8T6就没有后面4个外设,具体查看手册
Systick是内核里面的一个系统定时器,也被称为滴答定时器,在 ARM Cortex-M 内核的微控制器中广泛使用,SysTick 定时器本质上是一个24 位递减计数器,它从加载值(由 SysTick 加载寄存器 STRELOAD
设置)开始递减计数,当计数值减到 0 时,会产生一个中断(如果使能了中断),同时自动将加载值重新加载到计数器中,继续下一轮的计数,如此循环。主要用来给操作系统提供定时服务(STM32F1 系列是基于 ARM Cortex - M3 内核的微控制器。它有足够的资源来支持一些轻量级的操作系统。其拥有一定容量的闪存(Flash)用于存储代码,还有 SRAM 用于运行程序。例如,STM32F103 系列芯片,其 Flash 容量从 32KB 到 512KB 不等,SRAM 容量从 10KB 到 64KB 不等。这些资源能够为操作系统的运行提供基本的代码存储和数据处理空间。),如果使用操作系统就需要Systick提供定时来进行任务切换的功能。
RCC可以对系统时钟进行配置,还可以使能各模块时钟,STM32中外设在上电情况下默认是没有时钟的,不给时钟的话操作外设是无效的,外设也不会工作(目的是为了降低功耗) ,所以在操作外设之前必须要先使能它的时钟(RCC完成时钟的使能)(在 STM32 芯片中,时钟信号就像是整个系统的 “心跳”。芯片内部的各种外设,如定时器、通用输入输出接口(GPIO)、模数转换器(ADC)等,都需要时钟信号来驱动其工作。从本质上来说,这些外设都是由数字电路组成的,而数字电路中的时序逻辑电路(如触发器等)需要时钟信号来控制状态的更新)。
TIM最常用的外设,分为高级定时器,通用定时器,基本定时器三种类型,常用的是通用定时器,它不仅可以完成定时中断任务,还可以完成测频率,生成PWM波形,配置成专用的编码器接口等功能。USART既支持异步串口,也支持同步串口,我们说的UART是指异步串口。RTC实时时钟在STM内部完成年月日时分秒的计时功能,还可以接外部备用电池,即使掉电也能正常运行
3,命名规则
- STM32F1系列通常基于 ARM Cortex - M3 内核,其性能适用于中低端应用。时钟频率一般最高可达 72MHz,对于一些简单的控制和处理任务,如基本的传感器数据采集、简单的电机控制和基本的通信功能,如通过 USART 或 I2C 与外部设备通信,已经足够。例如,在一个温湿度传感器的数据采集系统中,STM32F1 系列可以有效地读取传感器数据并通过 I2C 将数据发送出去。 具备基本的外设互联功能,例如通过 USART、SPI、I2C 等通信接口与外部设备相连,但在一些高级的互联功能上可能相对较弱。它的通信速度和同时管理多个互联设备的能力有限,例如在使用多个 SPI 设备时,可能会因为处理速度和资源的限制,导致数据传输的延迟和性能下降。
- STM32F4 系列是意法半导体(ST)公司推出的高性能 32 位微控制器系列。它基于 ARM Cortex - M4 内核,该内核集成了浮点运算单元(FPU),这是与 STM32F1 系列的一个重要区别。FPU 使得芯片在处理包含浮点数的运算时,如三角函数计算、科学计算等,能够更加高效快速,大大提高了运算性能。
- STM32F4 系列的时钟频率更高,最高可达 168MHz,相比 STM32F1 系列的 72MHz 有了显著提升。更高的时钟频率意味着单位时间内可以执行更多的指令,能够更高效地处理复杂任务。例如在进行复杂的数字信号处理(DSP)任务时,如音频滤波、图像滤波等,高时钟频率有助于更快地完成算法运算。
- STM32F5 系列是意法半导体推出的 32 位微控制器,采用 ARM Cortex - M7 内核。Cortex - M7 内核相比之前的 Cortex - M3(如 STM32F1 系列)和 Cortex - M4(如 STM32F4 系列)内核,性能有了显著提升。它具有更高的处理性能和更高的计算能力,这主要得益于其更强大的指令集和更高的时钟频率。Cortex - M7 内核支持 Thumb - 2 指令集和 DSP 指令集,能够更高效地处理复杂的算法和数据处理任务。STM32F5 系列的时钟频率可以达到很高的水平,部分型号可超过 200MHz,这使得芯片能够在单位时间内执行更多的指令,为高速运算和处理复杂任务提供了有力支持。例如,在进行复杂的数字信号处理、图像处理、音频处理等应用时,高时钟频率可以保证快速的数据处理和计算速度。部分型号可能配备以太网接口,可实现网络通信功能,方便设备接入局域网或互联网,适用于物联网应用和网络设备,例如可以作为一个网络节点,将设备的数据传输到远程服务器。支持 USB 通信,可作为主机或从机,可用于连接 USB 设备,如 USB 存储设备、USB 鼠标键盘等,扩展了设备的功能和应用范围。
4,引脚定义
标红色的是电源相关的引脚,蓝色的是最小系统相关的引脚,标绿色的是IO口,功能口这些引脚优先使用加粗的IO口。I/O口电平表示它能容忍的电压,FT(FiveV Tolerant)表示5V,没有FT的只能容忍3.3V电压。主功能表示上电之后默认的功能,一般和引脚名称相同。默认复用功能就是IO口上同时连接的外设功能引脚,这个配置IO口时可以选择通用IO口或者是复用功能。重定义功能是如果有两个功能同时复用在了一个IO口上,而且也确实需要用到这两个功能,那就可以把其中一个复用功能重映射到其他端口上。
第一个引脚VBAT是备用电池供电的引脚,在这个引脚可以接3V电池,当系统电源断电时,备用电池可以给内部RTC时钟和备份寄存器提供电源。2号引脚是IO口或者侵入检测或者RTC,IO口可以根据程序输入高低电平,侵入检测可以用来做安全保障的功能(比如产品安全性比较高,可以在外壳加一些防拆的触点,然后接上电路到这个引脚上,如果有人强行拆开设备,触点断开,这个引脚的电平变化就会触发STM32侵入信号,然后清空数据来保证安全),RTC引脚可以用来输出RTC校准时钟,RTC闹钟脉冲或者秒脉冲。3,4号引脚是IO口或者接32.768KHz的RTC晶振。5,6号引脚接系统的主晶振,一般为8MHz,芯片内有锁相环电路,可以对这个8MHz频率进行倍频,最终产生72MHz的频率,作为系统的主时钟。7号NRST是系统复位引脚,N表示是低电平复位。8,9号引脚是内部模拟部分的电源,比如ADC,RC振荡器等,VSS是负极,接GND,VDD是正极。接3.3V。10~19号引脚都是IO口,其中PA0还兼具了WKUP功能,可以用于唤醒处于待机模式的STM32。20号引脚是IO口或者BOOT1引脚,BOOT引脚用来配置启动模式。21,22也为IO口。23,24号的VSS_1和VDD_1都是系统的主电源口,VSS为负极,VDD为正极,另外35,36VSS_2,VDD_2,VSS_3,VDD_3都是系统的主电源口,STM32内部采用分区供电,所以供电口比较多,使用时,VSS接地,VDD接3.3V即可(一般购买的最小系统板会将这几个引脚集中到一起采用typec或micoUSB供电,外扩引脚就不会出现这8个引脚)。25~33都是IO口。34号,37~40号这些是IO口或者调试端口,用来调试程序和下载程序,STM32支持SWD和JITAG两种调试方式,SWD需要两根线,分别是SWDIO和SWCLK,JTAG需要5根线,分别是JTMS,JTCK,JTDI,JTDO,NJTRST。此教程使用STLINK下载调试程序,STLINK使用的是SWD的方式,所以只需要PA13和PA14这两个IO口,剩下的PA15,PB3,PB4可以作为普通IO口使用,但需要在程序中配置,否则不会作为IO口。41~43,45,46都是IO口。44号BOOT0也是用作启动配置的
5,内部系统介绍
Cortex-M3引出三条总线,分别为ICode指令总线,Dcode数据总线,System系统总线,ICode和Dcode主要用来连接Flash闪存(存储编写的程序),System总线连接SRAM,FSMC等。ABH总线挂载外设。DMA相当于CPU小秘书,主要做大量的数据搬运,将其连接在总线矩阵上,它可以和CPU一样拥有总线控制权,用于访问外设。
- ICode主要用于从代码存储区域(如闪存 Flash)读取指令代码。它是专门为处理器内核获取程序指令而设计的。在程序执行过程中,Cortex-M3 处理器内核通过 ICode 总线从存储程序指令的存储器(通常是 Flash)中取指,以便后续执行。这保证了处理器能按顺序或根据跳转指令获取所需的指令信息,从而推动程序的正常运行。例如,当处理器要执行一个函数调用时,会通过 ICode 总线将函数的代码指令从 Flash 中读取到内核中。
- DCode主要用于从存储器中读取数据,这些数据可以是常量数据、变量、堆栈数据等。它服务于数据访问操作,将所需的数据从存储数据的区域(如 SRAM 或 Flash 中的数据部分)传输到处理器内核。例如,当程序需要读取一个全局变量的值时,会通过 DCode 总线从相应的存储位置将数据传输到内核的寄存器中。
- System 总线是 ARM Cortex 微控制器架构中的一个重要组成部分,主要用于连接处理器内核与系统中的各种外设和其他组件,它在系统中起到信息传输和资源调配的作用。该总线是一个多功能的总线,为处理器内核与系统中的各种硬件模块之间提供了数据和控制信息的传输通道。
- DMA(Direct Memory Access,直接内存访问)是一种允许外设和内存之间直接进行数据传输的机制,而无需 CPU 的干预。在传统的数据传输方式中,如从外部设备(如 ADC 的数据寄存器)向内存(SRAM)传输数据时,需要 CPU 参与,即 CPU 首先从外设读取数据,然后将其存储到内存中。而使用 DMA 时,外设和内存之间的数据传输由 DMA 控制器直接完成,CPU 可以继续执行其他任务。
6,启动配置
STM32 的启动配置模式决定了芯片在复位后从何处开始读取程序代码,以启动系统的运行。它允许用户根据具体的应用需求和硬件布局,选择不同的启动源,确保程序能够正确地加载和执行。
一般情况下程序都是在Flash程序存储器开始执行。但是在某些情况下,我们也可以让程序在别的地方执行,用于完成特殊功能。比如系统存储器启动模式,一般是在串口下载程序时使用,其里面存放的是STM32中的一段BootLoader程序(作用就是接收串口的数据,然后刷新到主闪存中),什么时候使用串口下载那?当STM32芯片的5个调试端口都被我们设置成IO口时,这就需要用到串口方式下载程序。内置SRAM启动主要用来程序调试
最后一句话的意思是BOOT只在上电后第四个上升沿之前有效,之后就无所谓了。对于20号引脚BOOT1当 第四个上升沿过去之后,它就变成了IO口功能。
- 内置SRAM启动模式下,芯片从内部的 SRAM 开始启动程序。这通常用于调试和快速测试,因为程序可以直接加载到 SRAM 中,而无需将其烧录到主闪存中。不过,SRAM 中的内容在掉电后会丢失,所以它主要用于开发阶段的临时测试,或者在一些需要快速更新程序代码而又不想频繁擦写主闪存的情况下。例如,在开发初期,可以将一些测试代码直接下载到 SRAM 中,快速验证代码的功能和性能,因为从 SRAM 启动通常速度更快,便于调试和修改。
- 系统存储器中存储着 STM32 的启动引导程序(Bootloader),由芯片制造商预先烧录。这个引导程序可用于通过特定的通信接口(如 USART、USB 等)对主闪存进行固件升级或编程。当选择系统存储器启动时,可通过外部工具(如 ST-Link 等)与 STM32 进行通信,实现对主闪存的更新。例如,在开发过程中,当需要更新固件时,可以将 STM32 配置为系统存储器启动,然后使用 ST-Link 通过 USART 或 USB 接口将新的程序代码下载到主闪存中。
- 在使用 ST-Link 下载程序到 STM32F1 系列芯片时,通常情况下可以不按 BOOT 按钮,当 STM32F1 芯片的 BOOT0 引脚接地(低电平)且 BOOT1 引脚也接地时,芯片从主闪存(Main Flash)启动,这是正常的运行模式。在使用 ST-Link 进行程序下载时,如果 ST-Link 的配置正确,并且开发环境(如 Keil、IAR 等)的配置也正确,ST-Link 可以直接将程序下载到主闪存中。开发环境会自动处理程序下载的流程,包括擦除、编程和验证等步骤,而不需要手动按下 BOOT 按钮。如果之前的操作导致芯片进入了错误的启动状态,例如,之前将 BOOT 引脚配置为从系统存储器或 SRAM 启动,且没有恢复到正常的主闪存启动模式,或者程序运行出错导致系统无法正常运行,可能需要手动按下 BOOT 按钮将芯片强制切换到可下载程序的状态。一般来说,对于日常的开发和程序下载,只要 ST-Link 的连接和开发环境的配置正确,并且 STM32F1 芯片处于正常的主闪存启动模式(BOOT0 = 0,BOOT1 = 0),就不需要按 BOOT 按钮。但在某些特殊情况下,如调试过程中出现异常或需要使用系统存储器的 Bootloader 时,可能需要手动操作 BOOT 引脚。
7,最小系统电路
二,新建STM32工程
1,意法半导体官网下载固态库
要使用库函数就要先下载固态库
打开官网:https://www.st.com/
点击工具与软件
再点击嵌入式软件
再选择下图红圈里的"STM32微控制器软件"
点击后进入如下界面
左侧列表往下滑,找到“STM32标准外设软件库”
选择F1(因为我们用的STM32F103是F1系列的)
然后点击获取最新版本,接受
如果是第一次下载需要注册MyST账户,使用邮箱注册一个然后登陆即可
下载完成后打开文件如下图
2,新建项目
目前STM32开发方式主要有基于寄存器的方式,基于标准库也就是库函数的方式和基于HAL库的方式。基于寄存器的方式与开发51单片机一样,用程序直接配置寄存器来达到我们想要的功能(最底层最直接,效率更高)。但是由于STM32结构复杂,寄存器太多,所以基于寄存器的方式不推荐;基于库函数的方式是使用官方提供的封装好的函数,通过调用这些函数来间接配置寄存器,由于ST对寄存器封装的比较好,所以这种方式既能满足对寄存器的配置,又能提高开发效率;基于HAL库的方式可以直接用图形化界面快速配置STM32,比较适合快速上手STM32的情况。我们采用库函数的方式
全过程如下:

使用库函数的方式我们需要准备一个STM32库函数的压缩包
首先新建一个存放工程的文件夹在桌面,然后打开Keil 5新建工程(菜单栏New μVersion Project ->点击进入刚才新建的文件夹->输入文件名->点击保存->选择好芯片型号)。
(1)Start启动文件
1,启动文件配置
之后根据以下路径找到所需文件:Libraries-CMMSIS-CM3-DeviceSupport-ST-STM32F10x-startup-arm找到的文件如下图。这些文件就是STM32的启动文件,STM32程序就是从启动文件开始执行的。先在工程文件夹下新建一个start文件存放,然后将这些文件全部复制,放到文件工程start文件夹下
补充:前面说过的启动文件有很多类型,选择哪一个要根据芯片型号来选择 ,以下为型号分类。如果用的是STM32F100就选择带VL的启动文件,然后再根据Flash大小选择LD MH 还收是HD。如果使用STM32F105/107的型号,直接选择CL的启动文件即可
2,头文件和时钟文件配置
然后回到STM32F10x文件下,如下,stm32f10x.h就是STM32外设寄存器描述文件,与51单片机头文件REGX52.H一样,剩下的两个system文件主要是用来配置时钟的,STM32主频72MHz,就是System文件的函数配置的,将这三个文件赋值,也粘贴到start文件下。
3,内核文件配置
因为STM32是内核和内核外围设备组成的,而且这个内核寄存器描述和外围设备描述文件不在一起,所以还要添加一个内核寄存器的配置文件。打开CM3-CoreSupport,如下,这两个文件就是内核的寄存器描述,将这俩文件复制下来,也粘贴到start问价夹下。到此为止,工程的必要文件就复制完成了
然后回到Keil,将工程下的文件夹改名为start
然后右键选择Add Existing Files...
打开start文件
添加启动文件后缀为md.s的(只能添加这一个启动文件),然后剩下的.c和.h文件都添加进来
这样start文件夹文件就添加好了
最后我们还要再工程选项里添加上这个文件的头文件路径
把start文件路径添加进来即可
(2)User文件
在工程文件下新建文件夹User,然后回到Keil
右键添加组,
将组改名为刚才新建文件夹的名
然后再此文件夹下新建一个main.c文件
下面的路径要修改成刚才新建的工程下文件夹的路径
修改成如下
然后就可以开始编程了
注意:点击下图所示右上角小扳手,将编码格式Encoding改为UTF-8,防止中文乱码
编程完后,按照如下方式将烧录工具和STM32最小系统板接好
然后配置调试器
我们用的是STLINK,所以修改为ST-Link
再点击右侧Setting按钮,将Reset and Run给勾上(勾上后下载程序后会立马复位并执行。否则每次下载之后还要按一下复位按键才执行)
(3)Library文件
接下来为工程添加库函数。打开工程文件夹,新建文件命名为Library来存放库函数
在如下所示路径中找到库函数文件,misc是内核的库函数,其他为内核外设库函数
将其全部复制到刚才创建的Library文件下 ,然后再打开inc文件夹(如下图)可以看到库函数头文件,也复制粘贴到Library下
然后回到Keil软件添加组,并将添加的组修改名称为Library
右键组,添加已经存在的文件。将刚才创建的Library文件夹内的文件都添加进来
但是对于库函数来说,还不能直接使用。还需要再添加一个文件
按如下路径找到所需文件
stm32f10x_conf.h用来配置库函数头文件的包含关系。下面的两个后缀为it的文件用来存放中断函数
将这三个文件复制下来,粘贴到工程User目录下
然后回到Keil,在User组里将刚才的文件添加进去
在头文件右键打开
找到下方语句,它表示必须定义USE_STDPERIPH_DRIVER,下面的stm32f10x_conf.h才有效,复制USE_STDPERIPH_DRIVER
如下操作,在Define后粘贴刚才复制的
下面的头文件目录也要将刚才的User和Library路径给添加上
(4)问题解决
上述文件都添加完成之后在main.c文件里写入main函数进行编译。如果编译出现如下几百个错误,且大部分错误都是core_cm3文件出错,这是因为下载的是6版编译器(新版的Keil不再自动下载5版编译器),6版编译器和5版编译器不兼容的问题
解决办法就是下载一个5版本的编译器。
参考教程01 KeilMDK Version5 编译器丢失问题的解决方法_哔哩哔哩_bilibili
(5)实现点灯操作
首先配置使能时钟,库函数里有一个函数来开启时钟,输入RCC_APB2PeriphClockCmd(,会自动出现要写的参数,这时我们可以右键进入到函数定义,在函数定义上方选择要用的参数。第一个参数是选择外设,第二个是选择新的状态
第一个参数选择RCC_APB2Periph_GPIOC,第二个参数选择ENABLE。这样GPIOC的外设时钟就配置好了
第二步是配置端口模式,用GPIO_Init函数。此函数有两个参数,第一个是选择哪个GPIO,第二个是参数的结构体。进入到函数定义,根据提示配置函数即可,因为我们用PC13口的LED,所以第一个参数写GPIOC。第二个结构体参数,我们根据注释新建一个GPIO_InitTypeDef变量,变量名为GPIO_InitStructure,然后利用GPIO_InitStructure.来调用结构体参数并配置,如下图
结构体变量有Mode,Pin,Speed三个参数
配置这三个参数,右键进入定义,根据下图红圈内的继续ctrl+f查找定义地方
就可以找到定义好的各种模式。我们选择GPIO_Mode_Out_PP(通用推挽输出)
最后,利用函数GPIO_SetBits来设置端口高电平来点灯。函数GPIO_ResetBits可以将端口置为低电平
最终代码
#include "stm32f10x.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_13;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC, &GPIO_InitStructure);
GPIO_ResetBits(GPIOC. GPIO_Pin_13);
while(1)
{
}
}
调试功能
注意:要修改程序需要退出调试模式重新编译。
点击下图红圈所示进入调试模式
进入调试界面如下,左边红圈为寄存器组和状态标志位等信息。上方黄圈为C语言翻译成的汇编程序
上方从左到右依次为复位,全速运行(程序一直运行至断点),停止全速运行。单步运行,跳过当前行单步运行,跳出当前函数单步运行,跳至光标指定行单步运行
如下图所示,可以查看STM32的所有外设,并进行查看
三,GPIO输出
1,GPIO基本介绍
GPIO叫做通用输入输出,可配置8种输入输出模式。引脚电平在0~3.3V之间,部分标注FT的可容忍5V(即可在端口输入5V,而端口最大输出电压为3.3V)。输出模式下可控制端口输出高低电平,用以驱动LED,控制蜂鸣器,模拟通信协议输出时序等。输入模式下可读取端口高低电平或电压,用于读取按键输入,外接模块电平信号输入,ADC电压采集,模拟通信协议接收数据等
2,GPIO总体结构
左边的为APB2外设总线,也就是之前介绍STM32系统结构时所说的挂载外设的APB2,STM32中,所有的GPIO都是挂载在APB2外设总线上的。GPIO外设的名称按照GPIOA,GPIOB,GPIOC来命名的,每个GPIO外设共有16个引脚(编号从0到15,PA0~PA15)。每个GPIO模块内,主要包含了寄存器和驱动器,寄存器是一种特殊的存储器,内核可以通过APB2总线对寄存器进行读写。寄存器每一位对应一个引脚,因为STM32为32位单片机,所以STM32内部的寄存器都是32位的,但端口只有16位,所以寄存器只有低16位有作用,高16位不使用。
3,GPIO位结构
如下图,整体可以分为两个部分,上面为输入部分,下面为输出部分。中间画虚线框的是驱动器部分.
输入部分:从IO引脚开始,接了两个保护二极管,这两个保护二极管可以对输入电压进行限幅,上面接VDD为3.3V,下面接VSS为0V。当引脚输入的电压超过电源电压 VDD 时,连接在引脚和 VDD 之间的保护二极管会导通。因为二极管导通后,会将过高的电压钳位在 VDD + 二极管正向导通压降(通常约为 0.3 - 0.7V)的水平。例如,若 VDD 为 3.3V,当外部输入电压超过 3.3V 一定程度时,该二极管导通,将引脚电压限制在约 4V 左右,防止过高的电压进入芯片内部电路,从而避免对芯片内部的晶体管等元件造成损坏;当引脚输入的电压低于地电位 VSS 时,连接在引脚和 VSS 之间的保护二极管会导通。导通后,会将过低的电压钳位在 VSS - 二极管正向导通压降的水平,也就是将引脚电压限制在一个相对安全的范围内。比如,阻止负电压对芯片造成损害,保证芯片内部电路不会因承受负电压而出现故障(需要注意的是,保护二极管的保护能力是有限的,如果外部电压异常过大,超过了二极管所能承受的电流和功率极限,仍然可能会损坏芯片,所以在一些对可靠性要求较高的应用中,还需要额外添加外部的过压、欠压保护电路);如果输入电压在0~3.3V之间,那两个二极管都不会导通,这时二极管对电路没有影响。上拉电阻和下拉电阻的开关可以通过程序配置,如果上面导通下面断开就是上拉输入模式(默认为高电平的输入模式),反之为下拉输入模式(默认为低电平的输入模式),如果两个都断开就是浮空输入模式,上拉和下拉作用是为了给输入提供一个默认的输入电平,同时也是为了避免引脚悬空导致的输入数据不稳定。这两个上拉和下拉电阻阻值比较大,是一种弱上拉和弱下拉,目的是尽量不影响正常的输入操作。下一个是施密特触发器(图片有误),它的作用就是对输入电压进行整形,执行逻辑是如果输入电压大于某一阈值,输出就会瞬间升为高电平;如果输入电压低于某一阈值,输出就会瞬间降为低电平,可以有效避免信号由于波动造成的信号抖动现象,接下来经过施密特触发器整形的波形就可以直接写入输入数据寄存器了,我们再用程序读取输入数据寄存器对应某一位的数据,就可以知道端口的输入电平了。最后上面还有两路线路是连接到片上外设的一些端口,模拟输入是接到ADC上的,因为ADC需要接受模拟量所以在施密特触发器前接收。另一个复用功能输入是连接到其他需要读取端口的外设上的。
输出部分:数字部分由输出数据寄存器或片上外设控制,两种控制方式通过数据选择器接到输出控制部分。如果选择通过输出数据寄存器进行控制就是普通的IO口输出,写这个数据寄存器的某一位就可以操作对应的某个端口了,它前面的位设置/清除寄存器,这个可以用来单独操作输出数据寄存器的某一位而不影响其他位(因为输出数据寄存器需要同时控制16个端口,并且这个寄存器只能整体读写,所以需要采用特殊的操作方式只修改某一位) ,如果我们要对某一位进行置1操作,在位设置寄存器的对应位写1即可,剩下位写0,这样它内部电路会自动将输出数据寄存器中对应位置为1,而剩下写0的位则保持不变;如果想对某一位清0,就在位清除寄存器对应位写1即可。输出控制后接到了两个MOS管,这个MOS管就是一种电子开关,信号控制导通和关闭,开关负责将IO口接到VDD或VSS,这里可以选择推挽,开漏或关闭三种输出模式;推挽输出模式下P-MOS和N-MOS均有效,当数据寄存器为1时,上管导通下管断开输出直接接到VDD就是输出高电平,数据寄存器为0时输出低电平,这种模式下高低电平均有较强的驱动能力,所以推挽输出也可以叫强推输出模式,推挽输出模式下,STM32对IO口有绝对控制权。开漏输出模式下P-MOS是无效的,只有N-MOS在工作,数据寄存器为1时,下管断开,这时输出相当于断开,也就是高阻模式;数据寄存器为0时,下管导通,输出直接接到VSS,也就是输出低电平。这个开漏模式可以作为通信协议的驱动方式,比如I2C通信的引脚就是使用开漏模式,在多机通信情况下,这个模式可以避免各个设备的相互干扰;另外,开漏输出可以用于输出5V电平信号,比如在IO口外接一个上拉电阻到5V的电源,当输出低电平时,由内部VSS的N-MOS直接接VSS,输出高电平时由外部上拉电阻拉高至5V。
4,GPIO八种工作模式
通过上述对位结构的介绍,只要对GPIO的端口配置寄存器,端口就可以配置成以下8种模式
输出模式下输入模式是有效的,而输入模式下输出模式是断开的,这是因为一个端口可以有多个输入但是只能有一个输出
STM32上电后如果不初始化,默认为浮空输入模式
复用推挽输出和复用开漏输出和普通的开漏输出推挽输出差不多,只是复用的输出引脚电平由片上外设控制,电路图如下
5,GPIO控制函数使用
函数:以下函数可以在stm32f10x_gpio.h头文件中查看
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);//可以把结构体变量赋一个默认值
//以下四个用来读取IO口状态
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);//用来读取输入寄存器某一个端口的输入值
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);//读取整个输入寄存器,返回值为16位数据,每一位代表一个端口值
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);//指定端口并根据BitVla的值设置指定端口BitVal可以写成Bit_RESET(低电平),Bit_SET(高电平)
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);//根据PortVal对16个端口同时写入
void GPIO_PinLockConfig函数:用来锁定GPIO配置的,调用这个函数,参数指定某个引脚,这个引脚的配置就会被锁定,防止意外更改。
//以下为GPIO8种工作模式
typedef enum
{ GPIO_Mode_AIN = 0x0,//模拟输入
GPIO_Mode_IN_FLOATING = 0x04,//浮空输入
GPIO_Mode_IPD = 0x28,//下拉输入
GPIO_Mode_IPU = 0x48,//上拉输入
GPIO_Mode_Out_OD = 0x14,//开漏输出
GPIO_Mode_Out_PP = 0x10,//推挽输出
GPIO_Mode_AF_OD = 0x1C,//复用开漏
GPIO_Mode_AF_PP = 0x18//复用推挽
}GPIOMode_TypeDef;
6,示例:LED灯点亮
Delay为自己编写的函数,可以直接使用
#include "stm32f10x.h"
#include "delay.h"
int main(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);//也可以按位或同时使能多个时钟
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
//因为每个引脚都占有1位,所以要同时初始化多个引脚的话,只需要逻辑或即可
//如:GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2
//如果要同时初始化PA的所有引脚可以用GPIO_Pin_All
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
while(1)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_RESET);
Delay_ms(500);
GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_RESET);
Delay_ms(500);
}
}
对GPIO_Speed的解释:GPIO_Speed
主要影响 GPIO 引脚的输出驱动能力和信号切换速度。通过合理设置这个参数,可以优化系统的性能,减少功耗,同时避免信号干扰等问题。GPIO 引脚内部有一个输出驱动器电路,它可以提供一定的电流来驱动外部负载。GPIO_Speed
参数实际上是在配置这个输出驱动器的响应速度和电流驱动能力。较高的速度设置意味着驱动器能够更快地改变输出电平,提供更大的电流;而较低的速度设置则意味着驱动器响应较慢,提供的电流较小。当 GPIO 引脚连接的是低速外设,如按键、LED 指示灯等,对信号的切换速度要求不高时,可以选择较低的输出速度,如 GPIO_Speed_2MHz
。这样既能满足外设的工作需求,又能降低功耗。如果 GPIO 引脚用于高速通信接口,如 SPI、I2C 等,为了保证信号能够快速准确地传输,需要选择较高的输出速度,如 GPIO_Speed_50MHz
。
为何驱动器响应速度越快电流驱动能力越强?
输出驱动器通常由晶体管(如 MOS 管)组成。当需要改变 GPIO 引脚的输出电平时,晶体管需要进行导通和截止状态的切换。以 N 沟道 MOS 管为例,要使它从截止状态变为导通状态,需要对其栅极电容进行充电;从导通状态变为截止状态时,则要对栅极电容进行放电。
更快的响应速度意味着晶体管需要在更短的时间内完成状态切换。为了实现这一点,就需要更大的电流来快速地对栅极电容进行充放电,从而让 MOS 管能够迅速地改变导通程度,以快速改变输出电平。
在实际应用中,GPIO 引脚通常会连接一定的负载,这些负载往往存在电容特性,可能是线路的寄生电容、外接设备的输入电容等。当输出电平发生变化时,输出驱动器需要对这些电容进行充放电。例如,当要将一个连接有负载电容的 GPIO 引脚从低电平快速拉高到高电平时,输出驱动器需要提供足够大的电流来快速对电容充电,以尽快达到目标高电平;反之,从高电平拉低时,也需要足够的电流来快速放电。
补充章节:STM32延时函数
1,软件延时
软件延时是通过执行一定数量的空循环来实现延时,其优点是实现简单,不需要额外的硬件资源;缺点是延时精度不高,受系统时钟频率和编译器优化的影响较大。
#include "stm32f10x.h"
// 简单的软件延时函数
void delay_ms(uint32_t ms) {
uint32_t i, j;
for (i = 0; i < ms; i++) {
for (j = 0; j < 7200; j++); // 7200 是一个经验值,可根据实际情况调整
}
}
int main(void) {
while (1) {
// 延时 1000 毫秒
delay_ms(1000);
}
}
delay_ms
函数通过嵌套的 for
循环实现了毫秒级的延时。外层循环控制延时的毫秒数,内层循环执行一定次数的空操作,从而实现延时。
内层循环的次数 7200
是一个经验值,需要根据实际的系统时钟频率进行调整。在系统时钟频率为 72MHz 的情况下,这个值可以大致实现 1 毫秒的延时。每进入一次 for
循环,会消耗多个时钟周期,而并非主频只震动一次。具体消耗的时钟周期数量取决于芯片的架构、编译器的优化程度以及指令的执行时间等因素。在实际应用中,7200
这个经验值是通过实验或者理论计算得出的,目的是让内层 for
循环大约消耗 1 毫秒的时间。
2,SysTick 定时器延时
SysTick 是一个非常重要的系统定时器,是一个 24 位的系统定时器,属于 ARM Cortex - M 内核的一部分。SysTick一般是用于为操作系统提供精确的时基(定时中断),但也可以在非操作系统环境下应用,在没有使用操作系统的裸机开发中,SysTick 可以用来实现精确的延时功能。通过设置 SysTick 的重载值和时钟源,能够让 SysTick 在指定的时间内计数到零,进而实现毫秒级甚至微秒级的精确延时。如此看来,SysTick作为一个定时器与通用定时器有何不同?
- 在来源上。SysTick是 Cortex - M 内核的一部分,所有基于 Cortex - M 内核的微控制器都包含 SysTick 定时器。通用定时器属于 STM32 外设,不同型号的 STM32 芯片所配备的通用定时器数量和功能会有所不同。
- 在计数范围上。SysTick是一个 24 位的向下递减定时器,其计数范围是从 0 到2的24次方-1(即 16777215);通用定时器一般是 16 位或 32 位的,16 位通用定时器的计数范围是从 0 到2的16次方-1(即 65535),32 位通用定时器的计数范围则是从 0 到2的32次方-1(即 4294967295),能处理更大的计数需求。
- 在功能上。SysTick功能相对单一,主要用于产生周期性的定时中断,为系统提供基本的时基;通用定时器:功能丰富多样,除了基本的定时功能外,还支持输入捕获、输出比较、PWM 生成、编码器接口等功能,可应用于电机控制、信号测量等复杂场景。
- 在中断优先级上。SysTick中断优先级是由内核的 NVIC(嵌套向量中断控制器)管理,且优先级是固定的,属于内核级中断;通用定时器:中断优先级可以通过 NVIC 进行灵活配置,开发者可以根据具体需求调整不同定时器中断的优先级。
- 除此之外,通用定时器也可以给操作系统提供时钟,并且32位通用定时器甚至能够满足操作系统较长的定时周期。不过,使用通用定时器为操作系统提供时钟也存在一些缺点,例如代码的移植性不如 SysTick 好,因为不同型号的 STM32 芯片通用定时器的配置和使用方法可能有所不同。同时,通用定时器的配置相对复杂,需要开发者对定时器的各种功能和寄存器有更深入的了解。并且SysTick 的配置相对通用定时器来说要简单得多。它的主要配置参数只有时钟源选择和重载值设置,开发者只需要对几个寄存器进行简单的操作就能完成初始化。
#include "stm32f10x.h"
// 初始化SysTick,使用HCLK作为时钟源
void SysTick_Init(void)
{
// 选择时钟源为HCLK
SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK);
}
// 延时函数,单位为微秒
void delay_us(uint32_t us)
{
// 计算重载值,72是因为系统主频为72MHz
uint32_t reload = us * 72;
// 设置重载值
SysTick->LOAD = reload - 1;
// 清空当前计数值
SysTick->VAL = 0;
// 使能SysTick定时器
SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk;
// 等待计数完成
while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk));
// 关闭SysTick定时器
SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk;
}
// 延时函数,单位为毫秒
void delay_ms(uint32_t ms)
{
uint32_t i;
for (i = 0; i < ms; i++)
{
delay_us(1000);
}
}
int main(void)
{
// 初始化SysTick
SysTick_Init();
while (1)
{
// 延时1000ms
delay_ms(1000);
}
}
SysTick_Init
函数对 SysTick 定时器进行初始化,配置时钟源为系统时钟,并设置每 1 毫秒产生一次中断。
SysTick_Handler
函数是 SysTick 的中断处理函数,每 1 毫秒被调用一次,通过 ms_count
变量记录经过的毫秒数。
delay_ms
函数通过比较 ms_count
的变化来实现指定毫秒数的延时。
3. 定时器延时
除了 SysTick 定时器,还可以使用 STM32 的通用定时器(如 TIM2、TIM3 等)来实现延时。这种方法可以实现更精确的延时,并且可以根据需要灵活配置延时时间。
#include "stm32f10x.h"
// 定时器初始化函数
void TIM2_Init(void) {
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // 自动重装载值
TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 预分频值
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
TIM_Cmd(TIM2, ENABLE);
}
// 延时函数
void delay_ms(uint32_t ms) {
uint32_t i;
for (i = 0; i < ms; i++) {
while (TIM_GetFlagStatus(TIM2, TIM_FLAG_Update) == RESET);
TIM_ClearFlag(TIM2, TIM_FLAG_Update);
}
}
int main(void) {
TIM2_Init();
while (1) {
// 延时 1000 毫秒
delay_ms(1000);
}
}
四,GPIO输入
1,传感器模块简介
传感器元件(光敏电阻/热敏电阻/红外接收管等)的电阻会随外界模拟量的变化而变化,通过与定值电阻分压即可得到模拟电压输出,再通过电压比较器进行二值化即可得到数字电压输出。其电路如下
中间电路R1,N1组成分压电路,N1为传感器变化电阻,传感器变化,AO(IN)点电压 随之变化。IN+作为U1电压比较器的输入端(U1内部为电压比较器,输入电压IN+,IN-进行比较,如果大于IN-则输出D0为高电平,反之为低电平)。IN-输出端为R2可调电阻,以得到不同电压输出IN-,相当于调整阈值。
2,硬件电路
下图按下按键PA0接收到低电平,松开按键引脚处于悬空,这种悬空状态电平是不确定的。为了防止干扰,需要将此引脚设置为上拉输入模式,保证按键不按下时引脚处于高电平状态
下图接法,PA0引脚设置为浮空输入和上拉输入都可以
下图PA0要配置为下拉输入模式
下图PA0配置为下拉输入和浮空输入都可以
STM32与传感器的接法如下。DO数字输出接PA0。AO为模拟输出,暂时不用
3,按键控制
五,OLED调试工具
1,常用的调试方式
串口调试:通过串口通信,将调试信息发送到电脑端,电脑使用串口助手显示调试信息
显示屏调试:直接将显示屏连接到单片机,将调试信息打印到显示屏上
Keil调试模式:借助Keil的调试模式,可使用单步运行,设置断点,查看寄存器及变量等功能
2,基本介绍
OLED叫做有机发光二极管。OLED显示屏:性能优异的新型显示屏,具有功耗小,响应速度快,宽视角,轻薄柔韧等特点
供电:3V~5V 通信协议:I2C(如下图,四针脚一般用I2C,7针脚一般用SPI) 分辨率:128*64
3,硬件电路
四针脚OLED。SCL和SDA为I2C通信引脚,应该接到单片机I2C通信引脚上,而STM32我们用GPIO口模拟的I2C通信。
7针脚OLED。除了VCC和FND,剩下的引脚是SPI通信协议,也可以用GPIO口模拟通信协议
4,OLED驱动函数
一下图为OLED实物图和屏幕坐标图
六,中断系统
EXIT外部中断是众多能产生中断的外设之一。本节主要介绍外部中断
1,基本介绍
2,STM32中断
STM32F1系列最多为70个可屏蔽中断通道(即中断源)(10个内核中断和60个外部中断),包含EXTI(外部中断),TIM(定时器),ADC(模数转换器),USART,SPI,I2C,RTC等多个外设。使用NVIC统一管理中断,每个中断通道都有16个可编程的优先等级,可对优先级进行分组,进一步设置抢占优先级和响应优先级
下图为STM32的中断,灰色的是内核的中断(一般用不到)。第一个Reset为复位中断,当产生复位事件时,程序就会自动执行复位中断函数。非灰色部分就是STM32的外设中断了,比如第一个WWDG窗口看门狗用来检测程序运行状态的中断(程序卡死了,没有及时喂狗,窗口看门狗就会申请中断,使程序跳到看门狗中断程序里);PVD电源电压监测,如果供电电压不足,PVD电路就会申请中断。EXTI0~EXTI4,EXTI9_5和EXTI15_!0即为本节要学的外部中断的中断资源。
下表的地址就是指的中断向量。STM32中断向量表是一个存储中断处理函数地址的数组,位于Flash区的起始地址。每个数组元素对应一个中断源,其地址指向相应的中断服务程序。当中断发生时,处理器会根据中断号查找向量表,然后跳转到对应的中断服务程序执行。中断向量表的作用是解决中断函数地址不固定与中断必须跳转到固定地方执行程序之间的矛盾。由于编译器每次编译都会为中断函数随机分配地址,但硬件要求中断必须跳转到固定位置上,因此,中断向量表就作为这样一个固定的地址列表,其中包含了中断函数的地址及跳转到这些地址的程序。当中断发生时,处理器会跳转到这个固定的中断向量表,然后根据其中的信息跳转到相应的中断处理函数,从而执行中断。
下图为STM32中断框图
中断处理流程:
- 中断发生:当某个中断源产生有效信号时,中断请求被置位。
- 中断响应:CPU 检测到中断请求后,首先判断该中断是否被使能以及其优先级是否满足要求。如果满足条件,CPU 会暂停当前正在执行的程序,保存当前的上下文(如 PC 指针、寄存器值等)到栈中。
- 查找中断服务程序入口:CPU 根据中断源的编号从中断向量表中取出对应的中断服务程序入口地址。
- 执行中断服务程序:CPU 跳转到中断服务程序的入口地址,开始执行中断服务程序。在中断服务程序中,通常需要对中断源进行处理,如清除中断标志位等。
- 恢复上下文:中断服务程序执行完毕后,CPU 从栈中恢复之前保存的上下文,继续执行被中断的程序。
3,NVIC基本结构
NVIC名字叫做嵌套中断向量控制器,在STM32中统一分配中断优先级和管理中断的。NVIC是一个内核外设,是CPU小助手。NVIC有很多输入口,中断线路都可以接过去;NVIC只有一个输出口,它根据每个中断优先级分配中断的先后顺序。
当一个中断请求到达时,NVIC会确定其优先级并决定是否应该中断当前执行的程序,以便及时响应和处理中断请求。
NVIC支持256个中断(16内核和240外部)。支持256优先级,允许裁剪。
NVIC第一个作用是中断使能/失能控制,第二个是中断优先级控制(将内核中断和外部中断一起拿过来做中断判断),第三个作用是优先级分组控制,通过AIRCR寄存器对优先级进行分组,分组排序完后进入CPU
4,NVIC优先级分组
为了处理不同形式的优先级,STM32的NVIC可以对优先级进行分组,分为抢占优先级和响应优先级。
如果一个中断的抢占优先级高于正在执行的中断,那么它就可以打断当前中断,优先得到执行,数值越小,优先级越高。如果两个中断同时到达且它们的抢占优先级相同,那么响应优先级高的中断首先得到响应,数值越小,优先级越高。当多个中断同时发生时,执行顺序首先由抢占优先级决定,如果抢占优先级相同,则进一步由响应优先级决定,如果响应优先级也相同,则最终由自然优先级决定。在中断嵌套的情况下,高抢占优先级的中断可以打断低抢占优先级的中断,但高响应优先级的中断不能打断低响应优先级的中断(当它们具有相同抢占优先级时)
NVIC的中断优先级由优先级寄存器IPR的4位(0~15)决定(对应16个优先级,值越小优先级越高)(IPR有8位,实际中只使用高4位),这4位可以进行切分,分为高n位的抢占优先级和低4-n位的响应优先级,怎么切分又由AIRCR寄存器控制(8到10位)
下图为5种分组方式,程序中自己选择。整个程序中只分一次(一般在init函数中分)
NVIC(嵌套向量中断控制器)控制的中断分为内核中断(Cortex - M 内核自带的异常)和外设中断,其中内核中断的优先级并非都是固定的 。
- 部分固定优先级的内核中断:Cortex - M 内核中有一些内核中断的优先级是固定的,不能通过 NVIC 进行配置。这些中断通常是与系统的基本运行和异常处理密切相关的,为了保证系统的稳定性和可靠性,其优先级被设计为固定值。
例如:复位(Reset):当芯片上电或复位引脚被触发时,会产生复位中断,它的优先级是最高且固定的,目的是确保系统能够从一个已知的初始状态开始运行。不可屏蔽中断(NMI):不可屏蔽中断用于处理一些非常紧急且不能被其他中断打断的事件,如硬件错误等。它的优先级也是固定的,并且仅次于复位中断,以保证在任何情况下都能及时响应。硬故障(HardFault):当系统发生严重错误,如执行未定义的指令、访问非法内存等,会触发硬故障中断。其优先级同样是固定的,用于快速处理系统的严重异常情况。
-
可配置优先级的内核中断:除了上述固定优先级的内核中断外,还有一些内核中断的优先级是可以通过 NVIC 进行配置的。例如:SysTick:系统滴答定时器产生的中断,常用于为操作系统提供时基或实现精确延时。开发者可以根据系统的需求,通过 NVIC 配置其优先级,以确保它与其他中断之间的协调工作。PendSV(可悬起系统调用):通常用于在操作系统中实现任务切换。它的优先级可以根据具体的系统设计进行调整,以满足不同的任务调度需求。SVCall(系统服务调用):用于用户程序调用操作系统的服务函数。其优先级也可以通过 NVIC 进行配置,以适应不同的应用场景。
在 STM32 中,对于可配置优先级的内核中断,可以通过 NVIC 的相关寄存器来设置其优先级。一般步骤如下:
- 选择中断优先级分组:通过设置
AIRCR
(应用程序中断和复位控制寄存器)来选择中断优先级分组,确定抢占优先级和子优先级的位数分配。 - 设置具体中断的优先级:使用
NVIC_SetPriority
函数(在标准库中)或直接操作NVIC_IPRx
寄存器(在寄存器编程中)来设置每个可配置优先级的内核中断的优先级。
5,NVIC寄存器
中断使能寄存器,如果某一位写1,则这一位对应的中断就允许通过,一个寄存器有32位,32X8=256正好对应了前面所说的NVIC支持256个中断。中断失能寄存器,如果某一位写1,则这一位对应的中断就不允许通过。NVIC属于内核,其寄存器属于内核寄存器,需要查看STM32F10xx Cortex-M3手册,如下查看ISER寄存器
中断优先级寄存器IPR一个寄存器有8位,刚才说过,只使用高4位,而且通过寄存器AIRCR又分成了两组。共有240个寄存器。STM32 有多种不同的型号,从入门级到高性能级,应用场景广泛。为了满足不同型号和应用场景下对中断管理的需求,设计上预留了较多的 IPR 寄存器,以支持未来可能的外设扩展和功能增强。这样,即使在高端型号中增加了新的外设或中断源,也有足够的 IPR 寄存器来配置其优先级。
如下图,AIRCR只关注红圈的3位
6,NVIC相关函数介绍
NVIC库函数如下,常用的是四个HAL_NVIC_EnableIRQ(使能功能),HAL_NVIC_DisableIRQ(失能功能),HAL_NVIC_SetPriorityGrouping(优先级分组),HAL_NVIC_SetPriority(优先级)。
7,NVIC配置方法
设置中断分组 -> 设置中断优先级->使能中断
设置中断分组一般在HAL_Init函数中进行,函数如下
分组方式由高亮NVIC_PRIORITYGROUP_2决定,它的取值如下
8,EXTI
(1)简介
(3)EXTI基本结构
STM32的外部中断线配置机制:STM32 的外部中断 / 事件控制器(EXTI)将所有 GPIO 引脚分成了 16 组外部中断线,分别为 EXTI0 - EXTI15。具体分配规则是:
- 所有端口的第 0 号引脚(PA0、PB0、PC0、PD0 等)连接到 EXTI0 中断线。
- 所有端口的第 1 号引脚(PA1、PB1、PC1、PD1 等)连接到 EXTI1 中断线。
以此类推,所有端口的第 15 号引脚连接到 EXTI15 中断线。
有了以上的分配机制,我们来举三个例子来理解:
- PA0和PB0都可以同时触发中断:由于 PA0 和 PB0 都连接到 EXTI0 中断线,当其中一个引脚触发中断时,EXTI0 中断线就会产生中断请求。在这种情况下,CPU 响应的是 EXTI0 中断,而无法区分是 PA0 还是 PB0 触发的中断。也就是说,它们共享同一个中断服务程序,不能独立地触发不同的中断处理流程。虽然 PA0 和 PB0 不能同时作为独立的中断引脚,但可以在 EXTI0 的中断服务程序中通过读取 GPIO 端口的输入状态来区分是哪个引脚触发了中断。以下是中断函数里判断的简单写法:
#include "stm32f10x.h"
void EXTI0_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
{
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 1)
{
// PA0触发中断的处理代码
}
else if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == 1)
{
// PB0触发中断的处理代码
}
// 清除中断标志位
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
int main(void)
{
// 初始化GPIO和EXTI等相关配置
// ...
while (1)
{
// 主循环代码
}
}
- PA0和PB1都可以同时触发中断:由于 PA0 连接到 EXTI0 中断线,PB1 连接到 EXTI1 中断线,它们使用不同的中断线,所以可以独立地触发不同的中断。因为 PA0 和 PB1 分别对应不同的外部中断线,所以能够同时将它们配置为触发外部中断的引脚,并且每个引脚的中断可以有独立的触发条件(如上升沿、下降沿或双边沿触发)和中断服务程序。
- PA0和PA1都可以同时触发中断:由于 PA0 连接到 EXTI0 中断线,PA1 连接到 EXTI1 中断线,它们使用不同的中断线,因此可以独立触发不同的中断。因为 PA0 和 PA1 对应不同的外部中断线,所以能够同时将它们配置为触发外部中断的引脚,并且每个引脚的中断可以有独立的触发条件(如上升沿、下降沿或双边沿触发)和中断服务程序。
(3)AFIO复用IO口
下图为上述AFIO选择中断引脚(数据选择器)的结构图
AFIO主要负责管理 GPIO 引脚的复用功能和外部中断连接。外部中断连接:STM32 的外部中断 / 事件控制器(EXTI)支持多个外部中断线(EXTI0 - EXTI15),但每个外部中断线可以连接来自不同 GPIO 端口的同一编号引脚。AFIO 负责将特定 GPIO 引脚连接到相应的外部中断线上。例如,所有 GPIO 端口的第 0 号引脚(PA0、PB0、PC0 等)都可以通过 AFIO 连接到 EXTI0 中断线。
(4)EXTI框图
边缘检测电路检测上升沿下降沿,如果上升沿触发选择寄存器打开就可以检测上升沿,如果下降沿触发选择寄存器打开就可以检测下降沿,同时打开那就都可以检测到。经过或门后,分两路走,往上面走就是所谓的中断,请求挂起寄存器就是标志位,中断屏蔽寄存器如果置0则中断无效,为1则中断可用。往下走为事件。
常用的就是中断屏蔽寄存器,请求挂起寄存器,上升沿触发选择寄存器,下降沿触发选择寄存器。
(5)EXTI寄存器
中断屏蔽寄存器共20位,其中MR19只用于互联网产品。每个位对应每个引脚(16个GPIO+PVD,USB,RTC,ETH)。它是一个 32 位的寄存器,每一位对应一个外部中断线(EXTI0 - EXTI15 ,不过不同型号的 STM32 芯片支持的外部中断线数量可能有所差异)。
也是有20个位。某个位写1表示允许某跟线上升沿触发。
检测到中断,则对应位置1.
软件中断事件寄存器(EXTI_SWIER)
- 功能:用于软件触发外部中断或事件。该寄存器同样是 32 位,每一位对应一个外部中断 / 事件线(EXTI0 - EXTI22)。当向某一位写入 1 时,会强制产生一个对应的中断或事件请求,即使该外部中断 / 事件线没有检测到实际的电平变化。写入 0 则不产生请求。
- 使用场景:在某些特殊情况下,需要通过软件模拟外部中断或事件的产生,以便进行系统测试、调试或特定功能的实现。
(6)编码器简介
编码器硬件电路
A相输出和B相输出接到STM32两个引脚上
9,实例
(1)初始化配置
模块初始化函数,在模块初始化函数中写入中断配置,配置外部中断参照下图(从GPIO到NVIC这一路出现的外设模块都配置好)
#include"stm32f10x.h"
void CountSensor_Init(void)
{
}
第一步:配置RCC,把这里涉及的外设的时钟都打开(EXTI和NVIC的时钟是一直打开的,不需要再开启。NVIC是内核外设不需要开启时钟)
#include"stm32f10x.h"
void CountSensor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APBPeriph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APBPeriph_AFIO, ENABLE);
}
第二步:配置GPIO,选择端口为输入模式
#include"stm32f10x.h"
void CountSensor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APBPeriph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APBPeriph_AFIO, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
}
像这种其他外设使用GPIO的情况,如果不清楚该配置为什么模式,可以参考手册,如下
第三步:配置AFIO,选择一路GPIO连接到后面的EXTI
AFIO外设官方并没有给它分配专门的库函数文件,它的库函数是和GPIO在一个文件里的(stm32f10x.gpio文件)
所得函数如下
void GPIO_AFIODeInit函数: 用来复位AFIO外设,调用这个函数,AFIO配置会全部清除
void GPIO_EventOutputConfig函数和GPIO_EventOutputCmd函数:这两个函数用来配置AFIO的事件输出功能
void GPIO_PinRemapConfig函数和GPIO_EXTILineConfig函数:这两个函数比较重要,前者用来进行引脚重映射,第一个参数选择重映射方式,第二个参数是新的状态;后者就是本次要使用的函数,调用这个函数就可以配置AFIO数据选择器,来选择我们想要的中断引脚
void GPIO_ETH_MediaInterfaceConfig函数:和以太网有关
可以看到GPIO_EXTILineConfig函数第一个参数可以是GPIO_PortSourceGPIOx(x为ABCD等),第二个参数GPIO_PinSourcex(x从0到15)
#include"stm32f10x.h"
void CountSensor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APBPeriph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APBPeriph_AFIO, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
//将PB14连接到EXTI14中断线
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource14);
}
第四步:配置EXTI,选择边沿触发方式,比如上升沿下降沿或者双边沿;选择触发响应方式,中断响应或事件响应
void EXTI_DeInit函数:调用它就可以把EXTI的配置都清除,恢复成上电默认的状态
void EXTI_Init函数:调用这个函数就可以根据EXTI_InitTypeDef* EXTI_InitStruct这个结构体的参数配置EXTI外设
void EXTI_StructInit函数:调用这个函数可以把参数传递的结构体变量赋一个默认值。前面这三个参数,基本所有外设都有
void EXTI_GenerateSWInterrupt函数:这个函数是用来软件触发外部中断的,调用这个函数,参数给一个指定的中断线,就能软件触发一次这个外部中断
下面四个函数,也是库函数的模板函数,很多模块都有这四个函数。这四个参数主要是查看状态寄存器的,当程序想看标志位时就可以用这四个函数。
FlagStatus EXTI_GetFlagStatus函数可以获取指定的标志位是否被置1了。void EXTI_ClearFlag函数可以对置1的标志位进行清除。
EXTI_GetFlagStatus
:该函数检查的是外部中断 / 事件控制器的标志寄存器(EXTI_PR)中的标志位状态。这个标志位会在外部中断线检测到有效的触发信号(如上升沿、下降沿等)时被置位,无论该中断线是否被使能。也就是说,即使对应的中断线在中断屏蔽寄存器(EXTI_IMR)中被屏蔽,只要检测到触发信号,标志位依然会被置位,EXTI_GetFlagStatus
函数就能检测到这个标志位的变化。EXTI_GetITStatus
:该函数检查的是在中断使能的前提下,外部中断线是否产生了中断请求。它不仅会检查标志寄存器(EXTI_PR)中的标志位,还会结合中断屏蔽寄存器(EXTI_IMR)的状态。只有当标志位被置位且该中断线在中断屏蔽寄存器中是使能状态时,才会认为有中断请求产生,函数返回SET
;否则返回RESET
。
我们使用EXTI_Init配置,右键跳转到定义。可以看到与GPIO初始化类似,使用结构体初始化。
所以首先定义结构体EXTI_InitTypeDef EXTI_InitStructure,然后用.引出成员
- EXTI_InitStructure.EXTI_Line:指定我们要配置的中断线,定义如下。我们要用PB14,所以选择第14个线路,也就是EXTI_Line14
- EXTI_InitStructure.EXTI_LineCmd:指定选择的中断线的新状态。可以是ENABLE和DISABLE
- EXTI_InitStructure.EXTI_Mode:指定外部中断线的模式,可以看到共两个模式,第一个是中断模式,第二个是事件模式
- EXTI_InitStructure.EXTI_Trigger:指定触发信号的有效边沿。Rising为上升沿触发,Falling为下降沿触发。
#include"stm32f10x.h"
void CountSensor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APBPeriph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APBPeriph_AFIO, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource14);//代表我们使用PB14为中断引脚
//EXTI初始化配置
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line14;
EXTI_InitStructure.EXTI_Trigger = ENABLE;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_LineCmd = EXTI_Trigger_Falling;
EXTI_Init(&EXTI_InitStructure);
}
第五步:配置NVIC。给我们这个中断选择一个合适的优先级
void NVIC_SetVectorTable函数:设置中断向量表,此函数用的不多
void NVIC_SystemLPConfig函数:系统低功耗配置 ,此函数用的不多
void NVIC_PriorityGroupConfig函数:用来中断分组,参数为中断分组方式。右键跳转定义,可以看到下图注释的几种模式(pre-emption priority就是抢占优先级)。注意:分组方式整个芯片只能用一种,所以这个分组代码整个工程只需要执行一次
void NCIV_Init函数:根据结构体里面指定的参数初始化NVIC
NVIC_InitStructure.NVIC_IRQChannel:下图翻译:指定中断通道来开启或关闭,这个参数可以是IRQn_Type里的一个值。(对于完整的STM32中断通道列表,请参考stm32f10x.h文件)。意思就是IRQn_Type的定义不在这个文件,要到stm32f10x.h中找。
可以ctrl+F搜索,在STM32F10X_MD中找到如下图所示EXTI15_10_IPQn(STM32的EXTI10到EXTI15都是合并到了这个通道)
NVIC_InitStructure.NVIC_IRQChannelCmd: 指定中断通道是使能还是失能,参数为ENABLE或DISABLE
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority和 NVIC_InitStructure.NVIC_IRQChannelSubPriority:指定所选通道的抢占优先级和响应优先级。根据分组填写0-15数字,也可以在定义中查看,如下
#include"stm32f10x.h"
void CountSensor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APBPeriph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APBPeriph_AFIO, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource14);//代表我们使用PB14为中断引脚
//EXTI初始化配置
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line14;
EXTI_InitStructure.EXTI_Line = ENABLE;
EXTI_InitStructure.EXTI_Line = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Line = EXTI_Trigger_Falling;
EXTI_Init(&EXTI_InitStructure);
//配置NVIC
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreeptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
}
(2)中断函数
中断函数在startup_stm32f10x_md.s启动文件中,找一下可以看到定义的中断向量表,其中以IRQHandler结尾的即为中断函数名字
#include"stm32f10x.h"
void CountSensor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APBPeriph_GPIOB, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APBPeriph_AFIO, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource14);//代表我们使用PB14为中断引脚
//EXTI初始化配置
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line14;
EXTI_InitStructure.EXTI_Line = ENABLE;
EXTI_InitStructure.EXTI_Line = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Line = EXTI_Trigger_Falling;
EXTI_Init(&EXTI_InitStructure);
//配置NVIC
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreeptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
}
void EXTI15_10_IRQHandler(void){//中断函数都是无参无返回值的
//中断函数不需要声明
//这个函数EXTI10~15都能进来,所以要先判断一下是不是我们想要的EXTI14进来
//EXTI_GetITStatus()函数。之前在stm32f10x_exti.h文件中讲过
//看一下EXTI14的中断标志位是否为1。返回值为SET或者RESET
if(EXTI_GetITStatus(EXTI_Line14)==SET){
//执行中断程序代码
//中断程序结束后一定要调用一下清除中断标志位的函数,因为中断标志位为1,程序就会跳转到中
//断函数,不清除中断标志位它就会一直申请中断
EXTI_ClearITPendingBit(EXTI_Line14);
}
}
在中断内最好不要执行耗时过长的代码 ,中断函数要简短快速。另外,最好不要在中断函数和主函数内调用相同的函数或者操作同一个硬件,尤其是硬件相关的函数;在实现功能时,在中断里操作变量或者标志位,当中断返回时再对变量进行操作,这样既能保证中断函数简短迅速,又能保证不产生冲突的硬件操作。
补充章节:AFIO功能
在 STM32 微控制器中,AFIO(Alternate Function I/O,复用功能输入输出)它负责管理 GPIO 引脚的复用功能和外部中断连接
1. 复用功能引脚映射
- 外设与引脚的连接:STM32 芯片的引脚数量有限,但内部集成了众多外设,如串口(USART)、SPI、I2C 等。这些外设的信号需要通过 GPIO 引脚与外部设备进行通信,因此很多 GPIO 引脚具有多种复用功能。AFIO 的主要功能之一就是对这些复用功能进行管理和配置,将特定的外设信号映射到相应的 GPIO 引脚上。
- 重映射功能:部分外设的信号可以通过 AFIO 进行重映射,即从默认的 GPIO 引脚重新分配到其他引脚上。这为电路板的设计提供了更大的灵活性,方便硬件布局。例如,USART1 默认使用 PA9 作为发送引脚(TX),PA10 作为接收引脚(RX),但通过 AFIO 的重映射功能,可以将其发送和接收引脚重映射到 PB6 和 PB7 上。以下是一个简单的重映射配置代码示例(以 STM32F103 为例,使用标准库):
#include "stm32f10x.h"
void USART1_Remap_Config(void)
{
// 使能GPIOA、GPIOB、AFIO和USART1时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO | RCC_APB2Periph_USART1, ENABLE);
// 开启USART1部分重映射
GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE);
// 后续进行USART1和GPIO的初始化配置
// ...
}
2. 外部中断引脚连接配置
- GPIO 与 EXTI 的连接:STM32 的外部中断 / 事件控制器(EXTI)支持多个外部中断线(EXTI0 - EXTI15),每个外部中断线可以连接来自不同 GPIO 端口的同一编号引脚。AFIO 负责将特定 GPIO 引脚连接到相应的外部中断线上。例如,所有 GPIO 端口的第 0 号引脚(PA0、PB0、PC0 等)都可以通过 AFIO 连接到 EXTI0 中断线。
- 配置示例:以下代码展示了如何使用 AFIO 将 PA0 连接到 EXTI0 中断线:
#include "stm32f10x.h"
void EXTI_Config(void)
{
// 使能GPIOA和AFIO时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
// 将PA0连接到EXTI0
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
// 后续进行EXTI和NVIC的初始化配置
// ...
}
3. 调试端口配置
- JTAG 和 SWD 接口管理:AFIO 还用于管理芯片的调试端口,如 JTAG(Joint Test Action Group)和 SWD(Serial Wire Debug)接口。在某些情况下,为了节省 GPIO 引脚资源,可以通过 AFIO 将 JTAG 或 SWD 接口的部分引脚释放出来作为普通 GPIO 使用。例如,使用以下代码可以将 JTAG 接口禁用,释放 PA13、PA14、PA15 和 PB3、PB4 引脚:
#include "stm32f10x.h"
void Debug_Port_Config(void)
{
// 使能AFIO时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
// 禁用JTAG,释放相关引脚
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);
}
4. 事件输出配置
AFIO 可以将特定的 GPIO 引脚配置为事件输出功能,允许将该引脚的电平变化转换为内部事件,这些事件可以用于触发芯片内部的其他操作,如启动定时器、触发 ADC 转换等,而无需 CPU 的干预。相关函数包括 GPIO_EventOutputConfig
和 GPIO_EventOutputCmd
。
七,TIM定时器
STM32中功能最强大,结构最复杂的外设之一——定时器
- 分为以下四个部分:
1,定时器基本定时功能:定一个时间,让定时器每隔这个时间触发一次中断,实现每隔一段时间执行一段程序。定时器可以对输入的时钟进行计数,并在计数值到达设定值时触发中断,定时器实际上就是一个计数器。在STM32中定时器的基准时钟一般都是主频72MHz(如果对72MHz计72个数,那么1MHz也就是1us的时间)
2,定时器输出比较功能:最常见用途就是产生PWM波形,用于驱动电机等设备
3,定时器输入捕获功能:使用输入捕获这个模块来实现测量方波频率的例子
4,定时器编码器接口:使用这个编码器接口,能够更加方便地读取正交编码器的输出波形
STM32拥有16位计数器和32位计数器,预分频器(16位),自动重装寄存器(16位)的时基单元。对于16位计数器在72MHz计数时钟下可以实现最大59.65s的定时,对于32位计数器在72MHz计数时钟下可以实现最大45.51天的定时。这里计数器就是用来执行计数定时的一个寄存器,每来一个时钟,计数器加一;预分频器可以对计数器的时钟进行分频,让计数更灵活;自动重装寄存器就是计数的目标值,就是我想要计多少个时钟申请中断。上述三个部分构成了定时器最核心的部分,称为时基单元。如果预分频器设置最大,自动重装也设置最大,那定时器最大定时时间为59.65s,接近一分钟(72Mhz/65535/65535,得到中断频率,再取倒数)。除此之外,STM32定时器支持级联模式,也就是一个定时器输出作为另一个定时器输入,这样加一起,最大定时时间就是59.65X65535X65535。
1,定时器分类
STM32不仅具备基本的定时中断功能,而且还包含内外时钟源选择、输入捕获、输出比较、编码器接口、主从触发模式等多种功能。根据复杂度和应用场景,分为高级定时器,通用定时器和基本定时器。如下图,高级定时器连接着性能更高的APB2总线。高级定时器额外功能主要是为三相无刷电机驱动设计的
STM32F103C8T6 包含以下几种定时器:
- 高级控制定时器:1 个,即 TIM1。高级控制定时器功能强大,常用于电机控制、PWM 生成等对定时精度和功能要求较高的应用场景。
- 通用定时器:3 个,分别为 TIM2、TIM3 和 TIM4。通用定时器具有多种功能,可用于定时、计数、PWM 输出、输入捕获等。
- 基本定时器:2 个,即 TIM6 和 TIM7。基本定时器功能相对简单,主要用于产生定时中断,为系统提供基本的时间基准。
高级控制定时器是 32 位定时器。这意味着它的计数器可以是一个 32 位的变量,能从 0 计数到2的32次方-1(即 4294967295)。 通用定时器分为 16 位和 32 位两种。其中,像 TIM2 和 TIM5 是 32 位定时器,而 TIM3 和 TIM4 等为 16 位定时器。基本定时器是 16 位定时器,计数器范围是从 0 到2的16次方-1(即 65535)。基本定时器是 16 位定时器
定时器的定时周期 取决于三个关键参数:时钟源频率fCLK 、预分频器的值PSC 以及自动重载值 ARR,它们之间的关系可以用以下公式表示:
(1)基本定时器
其中下面的自动重装载寄存器,计数器和预分频器最重要,预分频器之前,连接的就是基准计数时钟的输入,由于基本定时器只能选择内部时钟,所以可以认为上面的线直接连接到了内部时钟CK_INT,内部时钟来源为RCC_TIMxCLK,这里的频率值一般都是系统的主频72MHz。预分频器可以对72MHz的计数时钟进行预分频,比如预分频器这个寄存器写0就是不分频(输出频率等于输入频率=72MHz),写1就是二分频(输出频率等于输入频率/2=36MHz),写2就是三分频,以此类推,所以预分频器的值和实际分频系数相差为1.由于这个预分频器是16位的,所以最大值可以写65535.也就是65536分频。自动重装寄存器用于存储目标值,当计数值等于自动重装寄存器的目标值,也就是计时时间到了,计数器就会产生一个中断信号并且清零计数器。像这种计数值等于自动重装值产生的中断我们一般把它叫做更新中断,这个更新中断之后就会通往NVIC,我们再配置好NVIC定时器通道,那定时器更新中断就可以得到CPU响应了。
STM32 基本定时器(如 STM32F1 系列的 TIM6 和 TIM7 )的时钟源固定为内部时钟,具体来自 APB1 总线时钟(PCLK1)。在 STM32 系统中,时钟信号由时钟树产生,经过一系列的分频、倍频操作后分配到各个总线上。APB1 是低速外设总线,基本定时器就挂载在这条总线上。假设系统时钟(SYSCLK)为 72MHz,通常情况下,AHB 总线时钟(HCLK)也为 72MHz 。而 APB1 总线的预分频系数可以通过寄存器设置,一般默认设置为 2 分频,即 PCLK1 = 36MHz,由于 APB1 预分频系数不为 1,所以基本定时器的时钟频率是 PCLK1 的 2 倍,也就是 72MHz。
除了上面说到的定时中断原理,主模式触发DAC功能(主从触发模式,它能让内部硬件在不受程序的控制下实现自动运行)。主模式触发DAC:在我们使用DAC时,可能会用DAC输出一段波形,那就需要每隔一段时间触发一次DAC,让它输出下一个电压点;正常思路是使用定时器中断,但是这样会使主程序处于频繁被中断的状态,影响主程序运行和其他中断响应,所以定时器就设计了一个主模式,使用这个主模式可以把定时器的更新事件映射到触发输出TRGO的位置,然后TRGO直接接到DAC的触发转换引脚上,这样定时器的更新就不需要再通过中断来触发DAC转换了,整个过程不需要软件参与,实现硬件的自动化,这就是主模式的作用。
在 STM32 中,定时器的主模式是一种非常有用的功能,它允许一个定时器(主定时器)控制另一个定时器(从定时器)或其他外设的操作,实现定时器之间的同步与协同工作。主模式下,定时器可以产生触发输出(TRGO)信号,该信号可用于驱动其他定时器的输入触发、ADC 的转换启动、DAC 的数据加载等操作。通过主模式,可以实现多个定时器或外设按照特定的时序协同工作,增强了系统的灵活性和功能性。当定时器的计数器达到自动重装载值时会产生更新事件,将其作为触发输出源可以使从定时器或外设按照定时器的周期进行操作。例如,将主定时器的更新事件作为触发输出,触发从定时器的重新启动,实现两个定时器的同步计数。
(2)通用定时器
中间最核心部分还是时基单元,结构与基本定时器一样。但是通用定时器计数方式不止向上计数(从0开始增加直到记到重装值)一种,还支持向下计数模式和中央对齐模式(先从0递增,记到重装值,申请中断,然后在向下自减,减到0,申请中断),常用向上计数模式。
时基单元上面的部分就是内外时钟源选择和主从触发模式的结构,通用定时器时钟源不仅可以选择内部的72MHz的时钟,还可以选择外部时钟,第一个外部时钟就是来自TIMx_ETR引脚上的外部时钟,ETR引脚可以在引脚定义图中找到,如下。这里我们选择TIM2_ETR引脚,也就是PA0,在PA0上接一个外部方波时钟,然后配置一下内部的极性选择,边沿检测和预分频器电路,再配置一下滤波电路,滤波后的信号兵分两路,上面一路ETRF进入触发控制器,紧跟着就可以选择作为时基单元的时钟了,在STM32中,TIMx_ETR这一路也叫做"外部时钟模式2"。除了外部ETR引脚可以提供时钟外,下面还有一路TRGI可以提供时钟,这一路主要用作触发输入来使用,这个触发输入可以触发定时器的从模式(触发模式和从模式后续会讲到),本次讲的是触发输入作为外部时钟使用的情况,把TRGI当作外部时钟的输入来看,这一路就叫做"外部时钟模式1",通过这一路的外部时钟第一个就是ETR引脚的信号(ETR引脚既可以通过上面一路作为时钟,也可以通过下面一路当作时钟,这两种情况等价);第二个是ITR信号,这一部分的时钟信号是来自其他定时器的,从触发控制器右边可以看出,主模式的输出TRGO可以通过其他定时器,这个通向其他定时器就就接到了ITR引脚上了,ITR0~ITR3分别来自其他4个定时器的TRGO输出,具体连接方式可以查看手册,如下。通过ITR这一路就可以实现定时器级联,比如我们可以先初始化TIM3,然后使用主模式把它的更新事件映射到TRGO上,接着再初始化TIM2,这里选择ITR2(对应着TIM3的TRGO),然后再选择时钟为外部时钟模式1。第三个可以选择TI1F_ED(ED是边沿的意思),这里连接的是输入捕获单元的CH1引脚,也就是从CH1引脚获得时钟,ED是边沿也就是从这一路获得的时钟上升沿和下降沿均有效。最后还可以通过TI1FP1和TI2FP2获得,TI1FP1连接CH1引脚的时钟,TI2FP2连接CH2引脚的时钟。总结来说,外部时钟模式1的输入可以是ETR引脚,其他定时器,CH1引脚的边沿,CH1引脚和CH2引脚。对于时钟输入而言最常用的还是内部的72MHz,如果要使用外部时钟首选ETR引脚外部时钟模式2的输入,其他输入是为了满足特殊场景设立的,比如T1FP1,T2FP2,TI1F_ED为了测频率,输入捕获而设计
通用定时器下面部分共分为两块电路
红框内为输出比较电路,总共4个通道,分别对应CH1到CH4的引脚,可以用于输出PWM波形和驱动电机
蓝框内是输入捕获电路,也是四个通道,对应CH1到CH4的引脚,可以用于测量输入方波的频率等
中间的比较/捕获寄存器是输入捕获和输出比较电路共用的。因为输入捕获和输出比较不能同时使用,所以这里的寄存器和引脚都是共用的。关于这部分电路后续讲解,本节讲定时中断和内外时钟源选择。
(3)高级定时器
高级定时器与通用定时器相比,左上红框部分一样,主要改动的是剩下的部分。
首先是蓝框所示部分,申请中断的地方增加了一个重复次数计数器,有了这个计数器之后,就可以实现每隔几个计数周期,才发生一次更新事件和更新中断,相当于对输出信号又做了一次分频,所以对于高级定时器来说,最大定时时间为59.65X65535。剩下的部分就是高级定时器对输出比较模块的升级。
DTG(Dead Time Generate)为死区生成电路,右边的输出引脚由原来的一个变为两个互补的输出,可以输出一对互补的PWM波,这些电路是为了驱动三相无刷电机的,因为三相无刷电机的驱动电路一般需要三个桥臂,每个桥臂2个大功率管控制,总共需要6个大功率管开关来控制,所以这里的输出PWM引脚的前三路就变成了互补的输出,而第四路没什么变化。为了防止互补输出的PWM驱动桥臂时,在开关切换的瞬间可能因为器件的不理想造成短暂的直通现象,所以前面加上了死区生成电路,在开关切换的瞬间,产生一定时长的死区,让桥臂上下管全都关断,防止直通现象。
最后一部分绿框内就是刹车输入功能,这个是为了给电机驱动提供安全保障的,如果外部引脚TIMx BKIN产生了刹车信号或者内部时钟失效产生了故障,那么控制电路就会自动切断电机的输入,防止意外发生。
2,定时中断基本结构
运行控制:控制寄存器的一些位,比如启动停止,向上或向下计数等。时基单元左边是为其提供时钟的部分,而可以选择RCC提供的内部时钟,也可以选择ETR引脚提供的外部时钟2,也可以选择触发输入作为外部时钟,即外部时钟模式1,还有一个编码器模式,一般是编码器独用的模式,普通时钟用不到这个。
产生的中断信号会在状态寄存器里置一个中断标志位,这个中断会通过中断输出控制,到NVIC中申请中断。因为定时器模块有很多地方都要申请中断(可以看定时器结构图),这些中断都要经过中断输出控制,如果需要这个中断就允许,否则禁止,简单来说,中断输出控制就是一个中断输出的允许位
3,时序图 (时基单元运行)
(1)预分频器时序
CK_OSC是预分频器的输入时钟,可以在定时器分类的结构图中看到CK_PSC从时钟连接到了预分频器。选内部时钟的话一般是72MHz。
CNT_EN计数器使能,高电平计数器正常运行,低电平计数器停止。
CK_CNT,计数器时钟 。在定时器分类的结构图中也能看到,位于预分频器和CNT计数器中间。它既是预分频器的时钟输出,也是计数器的时钟输入。可以看到计数器使能关闭,计数器不使能,使能开启后,前半段分频器位为1,则分频器输入时钟等于计数器输入时钟,后半段预分频器变为2,则分频器输入时钟就变为了计数器输入时钟的两倍
在计数器时钟的驱动下,计数器寄存器也跟随时钟的上升沿不断自增
更新事件的下面三个时序,是预分频器寄存器的一种缓冲机制,预分频寄存器实际上是两个 。一个是预分频器控制寄存器,供读写使用,不直接决定分频系数;另外一个缓冲寄存器才是真正起作用的寄存器,比如在某个时刻把预分频寄存器由0改为1,如果此刻立刻改变时钟分频系数就会导致在一个计数周期内前半部分和后半部分频率不一样,使用缓冲寄存器,当分频系数改变时这个变化不会立刻生效,而是会等本次计数周期结束时,产生了更新事件,预分频控制寄存器的值才会传递到缓冲寄存器里生效,过程可以看上图时序。
预分频器内部实际上也是靠计数来分频的,当预分频值为0时,计数器就一直为0,当预分频值为1时,计数器就0,1,0,1,0,1这样计数,在回到0时输出一个脉冲。预分频器的核心原理是对输入的高频信号进行有规律的计数和分频操作。它通常由计数器组成,计数器按照输入信号的脉冲进行计数,当计数值达到预设的分频系数时,输出一个脉冲信号。
(2)计数器时序
内部时钟分频因子为2就是分频系数为2
CK_INT:内部时钟72MHz
CNT_EN:时钟使能
计数到0036时,计数器溢出,产生一个更新事件脉冲,另外还会置一个更新中断位UIF,这个标志位只要置1就会去申请中断,中断响应后需要中断程序手动清零。当CK_CNT再来一个上升沿时计数器寄存器清零。
= CK_PSC / (PSC + 1) / (ARR + 1)
1,计数器无预装时序
有预装时序与无预装时序区别就是有无像预分频缓冲器一样的缓冲器。通过ARPE位就可以选择是否使用预装功能
计数器正在计数时突然更改了自动加载寄存器(自动重装寄存器),由FF改为36,计数的目标值就由FF改为了36
2,计数器有预装时序
在计数器计数时 ,自动加载寄存器用来读写,自动加载影子寄存器真正起作用。突然把计数目标由F5改为36,可以看到自动加载影子寄存器仍为F5,直到此计数周期完成
4,RCC时钟树
RCC时钟树是STM32用来产生和配置时钟的,并且把配置好的时钟发送到各个外设的系统。在程序运行之前还会执行一个SystemInit函数,此函数就是用来配置时钟树的
此时钟树以AHB预分频器为界限,左边为时钟的产生电路,右边为时钟的分配电路。中间的SYSCLK就是系统时钟72MHz。在时钟的产生电路,共有四个时钟源,分别为8MHz HSI RC(内部的8MHz高速RC振荡器),4-16MHz HSE OSC(外部的4-16MHz高速石英晶体振荡器,即晶振,一般接8MHz),LSE OSC 32.768KHz(外部的32.768KHz低速晶振,一般给RTC提供时钟),LSI RC 40 KHz(内部的40KHz低速RC振荡器,给看门狗提供时钟)。上面两个高速晶振,是用来给提供系统时钟的,AHB APB2 APB1的时钟都是来源于这两个高速晶振,这两个高速晶振都是可以用的,只不过外部的石英振荡器比内部的RC振荡器更稳定,所以一般都用外部晶振
在SystemInit函数里,ST配置时钟首先会开启内部时钟,选择内部8MHz为系统时钟,暂时以内部8MHz时钟运行,然后再启动外部时钟,配置外部时钟进入PLL锁相环进行倍频,8MHz倍频9倍得到72MHz,等到锁相环输出稳定后,选择锁相环为系统时钟,这样就把系统时钟从8MHz切换为了72MHz。如果外部晶振损坏,系统就会以8MHz运行,会慢9倍左右。CSS是时钟安全系统,负责切换时钟,可以检测外部时钟运行状态,一旦外部时钟失效,它就会自动把外部时钟切换回内部时钟。
系统时钟72MHz进入AHB总线,AHB总线有分频器,在SystemInit里配置的分配系数为1,那么AHB时钟就是72MHz,然后进入APB1总线,这里的配置分配系数为2 ,所以APB1总线的时钟为36MHz,线路向下红框部分分配给定时器2-7的为72MHz,所以无论是通用定时器,基本定时器还是高级定时器内部基准时钟都是72MHz。蓝框内的外设时钟使能就是我们的RCC_APB2PerphClockCmd作用的地方,使能时钟就是在这个位置写1让左边的时钟能够输出给外设。
补充: SystemInit函数
在使用 STM32 时,通常建议先调用SystemInit
函数,但并非绝对必须,这取决于具体的应用场景和需求
SystemInit
函数在 STM32 的启动过程中扮演着非常重要的角色,它主要完成了一系列系统级的初始化操作:
- 时钟系统配置:该函数会对 STM32 的时钟系统进行初始化设置,包括选择时钟源(如内部 RC 振荡器、外部晶振等)、配置 PLL(锁相环)以实现时钟的倍频等,从而为芯片提供合适的系统时钟频率。例如,在使用 STM32 进行一些对时序要求较高的操作(如高速数据采集、通信等)时,需要一个稳定且合适的系统时钟,
SystemInit
函数能确保时钟系统正确配置。 - Flash 等待周期设置:为了保证 CPU 能够正确地从 Flash 中读取指令和数据,需要根据系统时钟频率来设置 Flash 的等待周期。
SystemInit
函数会根据配置好的系统时钟频率自动调整 Flash 的等待周期,避免因读取数据错误而导致程序运行异常。 - 总线时钟分频配置:它还会对 AHB、APB 等总线的时钟进行分频配置,使各个外设能够在合适的时钟频率下工作。例如,一些低速外设可能不需要太高的时钟频率,通过
SystemInit
函数可以将对应的总线时钟进行适当分频,以满足外设的工作要求。
在某些特殊的应用场景下,可以不调用SystemInit
函数:
- 简单测试或学习场景:如果只是进行一些简单的实验,对时钟频率要求不高,或者只是想快速验证某个外设的基本功能,那么可以手动进行简单的时钟配置,而不调用
SystemInit
函数。例如,仅使用内部 RC 振荡器作为时钟源,并且不进行复杂的时钟分频和倍频操作,手动设置一些基本的寄存器即可。如果不调用SystemInit
函数,STM32 默认会使用 HSI(内部 RC 振荡器)作为系统时钟源,前面介绍的SysTick内核定时器也会采用HSI作为时钟源。 - 已有自定义初始化流程:当开发者有自己特定的初始化流程,并且已经对时钟系统、Flash 等待周期等进行了详细的配置时,就不需要再调用
SystemInit
函数。比如在一些对系统资源和性能有特殊要求的项目中,开发者可能会根据具体需求定制一套独特的初始化方案。
5,定时器基本定时功能
初始化定时器
根据下图流程图一一初始化
#include "stm32f10x.h"
void Timer_Init(void)
{
}
步骤:1,RCC开启时钟(打开后,定时器的基准时钟和整个外设的工作时钟就会同时打开了)
2,选择时基单元的时钟源,对于定时中断我们选择内部时钟源
3,配置时基单元(包括预分频器,自动重装器,计数模式等等,用一个结构体就都可以配置好了)
4,配置输出中断控制,允许更新中断输出到NVIC
5,配置NVIC,在NVIC中打开定时器中断通道,并分配一个优先级
6,运行控制。整个模块配置完成后还要使能一下定时器
7,定时器中断函数
需要用到的定时器库函数
void TIM_DeInit(TIM_TypeDef* TIMx);恢复缺省设置
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);时基单元初始化。参数一TIM_TypeDef* TIMx选择某个定时器;参数二TIM_TimeBaseInitTypeDef*TIM_TimeBaseInitStruct是结构体,包含了配置时基单元的一些参数
void TIM_TimeBaseStructInit(TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);将结构体变量赋一个初值
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState);用来使能计数器(对应运行控制)。TIM_TypeDef* TIMx选择定时器;参数二FunctionalState NewState选择使能还是失能
void TIM_ITConfig(TIM_TypeDef* TIMx, uint16_t TIM_IT, FunctionalState NewState);这个是用来使能中断输出信号的,对应中断输出控制模块。参数一TIM_TypeDef* TIMx选择定时器;参数二uint16_t TIM_IT选择要配置哪个中断输出;参数三FunctionalState NewState选择使能还是失能
以下6个函数对应时基单元的时钟选择部分,可以选择RCC内部时钟,ETR外部时钟,ITRx其他定时器,TIx捕获通道这些
void TIM_InternalClockConfig(TIM_TypeDef* TIMx);选择内部时钟
void TIM_ITRxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);选择ITRx其他定时器的时钟。参数TIM_TypeDef* TIMx选择要配置的定时器, uint16_t TIM_InputTriggerSource选择要接入哪个其他定时器
void TIM_TIxExternalClockConfig(TIM_TypeDef* TIMx, uint16_tTIM_TIxExternalCLKSource,
uint16_t TIM_ICPolarity, uint16_t ICFilter);选择TIx捕获通道的时钟。参数TIM_TIxExternalCLKSource选择TIx具体的某个引脚;uint16_t TIM_ICPolarity选择输入的极性;uint16_t ICFilter选择滤波器。对于外部引脚的波形,一般都会有极性选择和滤波器
void TIM_ETRClockMode1Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);选择ETR通过外部时钟模式1输入的时钟,参数TIM_ExtTRGPrescaler为外部触发预分频器,这里可以对ETR外部时钟再提前做一个分频,参数选择如下图一;第三个参数是选择外部触发的极性,如下图2,第一个TIM_ExtTRGPolarity是反向,就是低电平或者下降沿有效,第二个是不反向,就是高电平或者上升沿有效;第四个参数外部触发滤波器,这个值必须是0x00到0x0F的一个值,他是来决定滤波的f和n的,具体决定方式如下图
void TIM_ETRClockMode2Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler,
uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);选择ETR通过外部时钟模式2输入的时钟。对于ETR输入的外部时钟而言TIM_ETRClockMode1Config和TIM_ETRClockMode1Config都是等效的,如果不需要触发输入的功能,那两个函数可以互换.
void TIM_ETRConfig(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);这个不是用来选择时钟,单独用来配置ETR引脚的预分频器,极性,滤波器这些参数
在初始化结构体里有很多关键的参数,比如自动重装值和预分频值等,这些参数可能在初始化后还需要改,如果再调用初始化函数来更改比较麻烦,以下函数可以单独更改这些参数。
void TIM_PrescalerConfig(TIM_TypeDef* TIMx, uint16_t Prescaler, uint16_t TIM_PSCReloadMode);可以用来单独写预分频值。参数uint16_t Prescaler就是要写入的预分频值;参数二uint16_t TIM_PSCReloadMode写入的模式,之前说过,预分频器有一个缓冲器,写入的值是在更新事件发生后生效,这里的写入模式可以选择是在更新事件发生后生效,也可以是在写入后手动产生一个更新事件,让这个值立刻生效。 void TIM_CounterModeConfig(TIM_TypeDef* TIMx, uint16_t TIM_CounterMode);用来改变计数器的计数模式。 参数uint16_t TIM_CounterMode选择新的计数器模式。 void TIM_ARRPreloadConfig(TIM_TypeDef* TIMx, FunctionalState NewState);自动重装器预装功能配置,而可以选择有无预装 void TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);给计数器写入一个值 void TIM_SetAutoreload(TIM_TypeDef* TIMx, uint16_t Autoreload);给自动重装器写入一个值 uint16_t TIM_GetCounter(TIM_TypeDef* TIMx);获取当前计数器的值 uint16_t TIM_GetPrescaler(TIM_TypeDef* TIMx);获取当前预分频器的值
以下四个函数用来获取标志位和清除标志位
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);
1,RCC开启时钟
因为TIM2是APB1总线的外设,所以要使用APB1开启时钟函数
#include "stm32f10x.h"
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
}
2,选择时基单元的时钟源
#include "stm32f10x.h"
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
//选择内部时钟作为TIM2时基单元的时钟
TIM_InternalClockConfig(TIM2);//定时器上电后默认就是使用内部时钟,所以不写这行也行
}
3,配置时基单元
TIM_TimeBaseInitStructure.TIM_ClockDivision:时钟分频。我们之前说过,在定时器的外部信号输入引脚一般都会有一个滤波器,可以滤掉信号的抖动干扰,它的工作原理就是在一个固定的时钟频率f下进行采样,如果连续N个采样点都为相同的电平,那就代表输入信号稳定了,就把这个采样值输出出去,如果这N个采样值不全都相同,那就说明信号有抖动,这时就保持上一次输出或者直接输出低电平也行。这里的采样频率f和采样点N都是滤波器的参数;而采样频率f可以由内部时钟直接而来,也可以是由内部时钟加一个时钟分频而来,分频多少就是有参数ClockDivision来决定的,所以这个参数跟时基单元关系不大,这个参数取值如下,第一个为1分频,也就是不分频,第二个是二分频,第三个是4分频,这里随便选择一个即可
TIM_TimeBaseInitStructure.TIM_CounterMode:计数器模式。可选择的模式如下,分别是向上计数,向下计数和三种中央对齐的模式
以下三个就是时基单元每个关键寄存器的参数了,不过这里没有CNT计数器的参数,这个如果之后需要的话,可以用之前说的SetCounter和GetCounter来操作。其中前两个来决定定时时间,可以根据之前在时序图说过的公式计算要配置为多少,比如说定时一秒。则PSC给7200,ARR再给10000,然后两个参数都减1(因为预分频器和计数器都有1个数的偏差)
TIM_TimeBaseInitStructure.TIM_Period:周期,就是ARR自动重装器的值
TIM_TimeBaseInitStructure.TIM_Prescaler:PSC预分频器的值
TIM_TimeBaseInitStructure.TIM_RepetitionCounter:重复计数器的值,是高级定时器才有的,这里我们不用,直接给0
#include "stm32f10x.h"
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
//选择内部时钟作为TIM2时基单元的时钟
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;
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200 - 1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
}
4,配置输出中断控制
用函数TIM_ITConfig来使能中断。参数一为TIM2,参数二可以是以下这些值的任意组合,我们选择第一个TIM_IT_Update更新中断,参数三ENABLE。配置完后就开启了更新中断到NVIC的通路
#include "stm32f10x.h"
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
//选择内部时钟作为TIM2时基单元的时钟
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;
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200 - 1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
//配置输出中断控制
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
}
5,配置NVIC
#include "stm32f10x.h"
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
//选择内部时钟作为TIM2时基单元的时钟
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;
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200 - 1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
//配置输出中断控制
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
//配置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,启动定时器
#include "stm32f10x.h"
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
//选择内部时钟作为TIM2时基单元的时钟
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;
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200 - 1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
//配置输出中断控制
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
//配置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);
//使能定时器
TIM_Cmd(TIM2, ENABLE);
}
7,中断函数
#include "stm32f10x.h"
void Timer_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
//选择内部时钟作为TIM2时基单元的时钟
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;
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200 - 1;
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
//由于预分频器缓冲器只有在更新事件时才会起作用,所以初始化时为了让值立刻起作用
//就在初始化代码最后手动生成了一个更新事件,这样,预分频器的值才有效。
//但是更新事件和更新中断是同时发生的,更新中断会置更新中断标志位,当我们一旦初始化完
//之后,更新中断就会立刻进入,这就是为什么刚一上电就立刻进入中断的原因。
//解决方法就是在TIM_TimeBaseInit后,NVIC_Init(开启中断)前加一行如下代码,手动清除一下中断标志位
TIM_ClearFlag(TIM2, TIM_FLAG_Update);
//配置输出中断控制
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
//配置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);
//使能定时器
TIM_Cmd(TIM2, ENABLE);
}
void TIM2_IRQHandler(void)
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) == SET)
{
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
}
6,定时器输出比较功能
(1)基本介绍
输出比较功能主要用来输出PWM波形驱动电机等
OC(Output Compare)输出比较:输出比较可以通过比较CNT计数器与CCR捕获/比较寄存器值的关系,来对输出电平进行置1,置0或翻转的操作,用于输出一定频率和占空比的PWM波形。CNT就是下图红框时基单元里面的计数器,CCR就是蓝框里的捕获/比较寄存器。这块电路会比较CNT和CCR的值,CNT计数自增,CCR是我们给定的一个值,当CNT大于CCR,小于CCR或者等于CCR时就会对应输出高低电平
每个高级定时器和通用定时器都拥有4个输出比较通道,并且高级定时器前3个通道额外拥有死区生成和互补输出的功能
(2)输出比较通道
以下为通用定时器输出比较电路。对应着上图的紫圈内的电路。经过输出比较电路后,最后通过TIM_CH1输出到GPIO引脚上。
下图。左边就是CNT计数器和CCR1第一路的捕获/比较寄存器。当CNT>=CCR1,就会给输出模式控制器传一个信号,输出控制器就会改变它输出oc1ref的高低电平(ref指reference的缩写,意思是参考信号),上面的ETRF输入是定时器的一个小功能,一般不用。接着REF信号可以前往主模式控制器,在这里可以把REF映射到主模式的TRGO输出上去,但是REF主要去向还是下面一路,接着到达TIMxC_CER,这是一个极性选择,给这个寄存器写0,信号就往上走,就是信号电平不翻转(进去啥样,出去啥样),写1信号就会往下走,信号翻转。接着是输出使能电路,选择是否要输出,最后就是OC1引脚,这个引脚就是CH1通道的引脚,在引脚定义表里就可以知道具体哪个GPIO口了
以下为高级定时器的输出比较通道 。右边引脚通常接如图电路。上面是正极,然后是大功率电子开关(MOS管),然后再来一个MOS管,最后接地,MOS管左边为控制极,比如说给高电平右边两根线就导通,低电平就断开,下面MOS管也一样,这就是一个最基本的推挽电路,中间为输出,如果上管导通,下管断开,那输出就是高电平,如果下管导通,上管断开,那输出就是低电平,如果上下管都导通,那就是电源短路,这样是不允许的,如果上下管都断开,那就是高阻态,如果有两个这样的推挽电路,就构成了H桥,就可以控制直流电机正反转了,如果有三个这样的推挽电路,就可以驱动三相无刷电机了。如果直接用单片机来控制的话,就需要两个控制极,并且这两个控制极电平要相反,也就是互补
OC1和OC1N就是两个互补的输出端口,在切换导通状态时,如果上管关断瞬间下管就立刻打开,那可能会因为器件不理想,上管还没完全关断下管就已经导通了,这会导致功率损耗,引起器件发热,所以为了避免这个问题就有了死区生成电路,它会在上管关闭的时候,延迟一小段时间再导通下管。
输出模式控制器工作原理:输出比较8种模式如下,也就是这个输出模式控制器里面的执行逻辑。上图所示的模式控制器的输入是CNT和CCR的大小关系,输出的是REF的高低电平,里面有多种模式可以更加灵活地控制REF输出,这些模式的选择可以通过寄存器TIMx_CCMR1来配置。
第一个模式冻结,可以理解为CNT和CCR无效,REF保持为原状态,这个模式用于比如正在输出PWM波,突然像暂停一会输出,就可以设置为这个模式,一旦切换为冻结模式后,输出就停止了,并且高低电平也维持为暂停时刻地状态,保持不变。二三四个模式有效电平无效电平可以理解为高低电平,这三个模式都是当CNT与CCR值相等时,执行操作,第四个模式就可以用作波形输出了,比如匹配时电平翻转模式,这个模式可以方便输出一个频率可调,占空比始终为50%的PWM波形,第二第三个模式用途不大。第五,六个模式,强制为无效电平和强制为有效电平,这两个模式和冻结差不多,如果想暂停波形输出,并且想在暂停期间保持低电平或者高电平,那就可以设置为这两个模式。
最后两个模式PWM1和PWM2,PWM模式2是PWM模式1输出的取反,改变PWM模式1和PWM模式2,就只是改变了REF电平的极性而已。在上面的通用定时定时器输出比较通道中讲过,REF输出之后还有一个极性的配置,所以使用PWM模式1的正极性和PWM模式2的反极性最终的输出是一样的,所以只使用PWM1即可
(3) PWM基本结构
右上角坐标,蓝色线为CNT计数的值,黄色线是ARR的值,这个过程中设置一条红色的线,这条红色线就是CCR
舵机的PWM信号要求:周期20ms(50Hz),高电平宽度为0.5ms~2.5ms
电机驱动模块:STBY待机控制脚,如果接GND驱动模块不工作,处于待机状态,如果接逻辑电源VCC就正常工作。
(4)实例
定时器输出比较功能函数介绍
以下四个函数用来配置输出比较模块,OC就是(Output Compare) ,输出比较。用来配置上图的输出比较单元,输出比较单元有4个,对应也有四个函数。第一个参数TIM_TypeDef* TIMx选择一个定时器,第二个参数TIM_OCInitTypeDef* TIM_OCInitStruct结构体就是输出比较的那些参数
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);//给输出比较结构体赋一个默认值
以下四个函数用来配置强制输出模式的。如果在运行过程中想要暂停输出波形并强制输出高或低电平,可以用这个函数。
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);
以下四个函数用来配置CCR寄存器的预装功能(即影子寄存器)
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);
以下四个函数用来配置快速使能的,这个在功能手册单脉冲模式有介绍。用的不多
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);
在功能手册外部事件清除REF信号一节有介绍。用的不多
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);
以下函数用来单独设置输出比较的极性的。前面带N的就是高级定时器里互补通道的配置,OC4无互补通道,所以就没有OC4N的函数。这里的函数可以设置极性,在结构体的初始化函数也可以设置极性,这两个设置极性的作用是一样的。一般来说,结构体初始化里的参数都会有一个单独函数可以修改
void TIM_OC1PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
void TIM_OC1NPolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCNPolarity);
void TIM_OC2PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
void TIM_OC2NPolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCNPolarity);
void TIM_OC3PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
void TIM_OC3NPolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCNPolarity);
void TIM_OC4PolarityConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPolarity);
以下两个函数单独修改输出使能参数的
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);
以下函数用来选择输出比较模式
void TIM_SelectOCxM(TIM_TypeDef* TIMx, uint16_t TIM_Channel, uint16_t TIM_OCMode);
以下四个函数用来单独更改CCR寄存器值的函数。更改占空比就需要这四个函数
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);
以下函数仅高级定时器使用。在使用高级定时器输出PWM时,需要调用这个函数使能主输出,否则PWM将不能正常输出
void TIM_CtrlPWMOutputs(TIM_TypeDef* TIMx, FunctionalState NewState);
初始化PWM
根据下图进行初始化:
- RCC开启时钟。把要用的TIM外设和GPIO外设的时钟打开
- 配置时基单元。包括时钟源选择
- 配置输出比较单元。包括CCR的值,输出比较模式,极性选择,输出使能等参数。在函数里用结构体统一来配置
- 配置GPIO。把PWM对应的GPIO口初始化为复用推挽输出的配置
- 运行控制。启动计数器,这样就能输出PWM了
1,RCC开启时钟
void pwm_init(){
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
}
2,选择内部时钟并配置时基单元
void pwm_init(){
//RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, 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 = 100 - 1; //ARR
TIM_TimeBaseInitStructure.TIM_Prescaler = 720 - 1; //PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
}
3,配置输出比较单元
不同输出比较单元对应GPIO口也是不一样的,所以按照GPIO口需求来。这里使用PA0口,对应第一个输出比较通道
输出比较单元初始化结构体成员很多,而且很多参数是给高级定时器用的。比如OCN开头的就是给高级定时器用的,所以一般只把需要用的参数列出来即可
TIM_OCInitStructure.TIM_OCMode:输出比较模式,如下图。第一个为冻结模式,第二个Active就是相等时置有效电平,第三个相等时置无效电平,第四个Toggle相等时电平翻转,最后两个就是PWM了
TIM_OCInitStructure.TIM_OCPolarity:第一个参数High,高极性,就是极性不翻转,REF波形直接输出,第二个参数Low,低极性,就是REF电平取反
TIM_OCInitStructure.TIM_OutputState:输出使能
TIM_OCInitStructure.TIM_Pulse:用来设计CCR寄存器的值。
void pwm_init(){
//RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, 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 = 100 - 1; //ARR
TIM_TimeBaseInitStructure.TIM_Prescaler = 720 - 1; //PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
//配置输出比较单元
TIM_OCInitTypeDef TIM_OCInitStructure;
TIM_OCStructInit(&TIM_OCInitStructure);//给结构体赋初始值,然后修改用到的参数
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_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);
}
4,配置GPIO
在引脚定义一节里,有一列默认复用功能,这一列就是片上外设的端口和GPIO的连接关系,在这一列可以看到PA0对应的TIM2_CH1_ETR,这说明TIM2的ETR引脚和通道1引脚都是借用了PA0这个引脚的位置,所以说要使用TIM2的OC1也就是CH1通道输出PWM,那它就只能在PA0的引脚上输出,而不能任意选择引脚输出,同样地,如果使用TIM2CH2,那就只能在PA1的端口上输出(或者使用AFIO把它重映射到有TIM2_CH1_ETR的位置)
GPIO引脚模式应设置为复用推挽输出。在前面开漏/推挽输出内部结构图中讲过,对于普通 开漏/推挽输出,引脚控制权是来自于输出数据寄存器的,如果想让定时器控制引脚就要使用复用开漏/推挽输出模式,这个模式下输出数据寄存器断开,输出控制权转移给片上外设
void pwm_init(){ //RCC开启时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, 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 = 100 - 1; //ARR TIM_TimeBaseInitStructure.TIM_Prescaler = 720 - 1; //PSC TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure); //配置输出比较单元 TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCStructInit(&TIM_OCInitStructure);//给结构体赋初始值,然后修改用到的参数 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_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); //配置GPIO GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; //GPIO_Pin_15; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); }
5, 启动定时器
TIM_Cmd(TIM2,ENABLE)
void pwm_init(){
//RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, 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 = 100 - 1; //ARR
TIM_TimeBaseInitStructure.TIM_Prescaler = 720 - 1; //PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure);
//配置输出比较单元
TIM_OCInitTypeDef TIM_OCInitStructure;
TIM_OCStructInit(&TIM_OCInitStructure);//给结构体赋初始值,然后修改用到的参数
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_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);
//配置GPIO
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; //GPIO_Pin_15;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
//启动定时器
TIM_Cmd(TIM2, ENABLE);
}
6,补充:引脚重映射
之前说过PA0可以作为TIM2_CH1_ETR引脚功能,但是如果PA0引脚被占用,这时就需要使用引脚重映射功能,如图所示,PA15引脚重定义功能那一列有TIM2_CH1_ETR,就说明TIM2_CH1_ETR可以通过AFIO重映射到这个引脚上,这个引脚就可以输出PWM波形
首先,开启AFIO的时钟。使用void GPIO_PinRemapConfig(uint32_t GPIO_Remap, FunctionalState NewState);函数,这个函数第一个参数可选的重映射方式比较多,如下图为其中一部分。每种方式对应的重映射关系可见功能手册,在AFIO一节
如下图所示为功能手册AFIO重映射部分图像,给出了重映射方式和引脚更改的关系
如下图,TIM的重映射功能和引脚更改的关系,有四种对应关系。如果我们想把PA0改到PA15就可以选择重映像方式1(部分重映像)或者完全重映像。
在重映射方式里找一下,可以看到蓝框所示的部分重映像1,2和完全重映像
增加如下代码理论可以完成重映射。
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2, ENABLE);
但是PA15上电后默认复用为调试端口JTDI,所以想让它作为普通GPIO或者复用定时器的通道,那还需要先关闭调试端口的复用,也是利用GPIO_PinRemapConfig来关闭,如下图所示,调用以下三个参数,就可以解除调试端口的复用。第一个NoJTRST调用后就可以解除PB4的调试端口,PB4就可以作为正常GPIO口使用;第二个JTAGDisable,这个就是解除JTAG调试端口的复用,在引脚定义里就是PA15,PB3,PB4这三个端口变回GPIO;第三个参数SWJ_Disable,这个参数就是把SWD和JTAG的调试端口全部解除。
用如下代码就可以正常使用PA15。注意:重映射后,初始化GPIO时初始化PA15而不是PA0
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2, ENABLE);
GPIO_PinRemapConfig(GPIO_Ramp_SWJ_JTAGDisable, ENABLE);
7,定时器输入捕获功能
(1)基本介绍
IC(Input Capture),可以使用输入捕获功能测频率(必须是高低电平信号,高电平3.3V,低电平0V),PWMI模式测频率占空比
输入捕获模式下,当通道输入引脚出现指定电平跳变时(上升沿或下降沿,可以通过程序配置),当前CNT的值将被锁存在CCR中(即把当前CNT的值读出来,写入到CCR中)。可用于测量PWM波形的频率,占空比,脉冲间隔,电平持续时间等参数
每个高级定时器和通用定时器都拥有4个输入捕获通道
可配置PWMI模式,同时测量频率和占空比
可配合主从触发模式,实现硬件的全自动测量
(2)测频率方法
1,测频法
在闸门时间T内,对上升沿进行计次,得到N,频率fx=N/T
测频法适合测量高频信号。计次N小误差大。测频法测量结果更新慢,数值稳定
2,测周法(测周期,取倒数)
两个上升沿内,以标准频率fc计次,得到N,则频率fx=fc/N。这里测量周期的方法还是使用定时器。
测周法适合测量低频信号。测周法测量结果更新快,数据跳变也非常快
频率高低的标准取决于中界频率(测频法与测周法误差相等的频率点)。当待测频率小于中界频率时,测周法误差更小;当待测频率大于中界频率时,测频法误差更小。
𝑓𝑚=√(fc / T)
(3)输入捕获结构图
首先,左边是四个通道的引脚,引脚进来有一个三输入的异或门,这个异或门输入接到了通道1,2,3端口(异或门执行逻辑:异或运算的本质是判断输入信号中逻辑 “1” 的个数的奇偶性,当输入信号中逻辑 “1” 的个数为奇数时,输出为逻辑 “1”。当输入信号中逻辑 “1” 的个数为偶数时,输出为逻辑 “0”),之后输出通过数据选择器(梯形框)到达输入捕获通道1(数据选择器如果选择上面的,就是3个通道的异或值;如果选择下面一个,那异或门就没有用,四个通道各用各的引脚。输入信号经过数据选择器来到输入滤波器(对信号进行滤波)和边沿检测器(可以选择高电平触发或者低电平触发),当出现指定电平时,边沿检测电路就会触发后续电路执行动作。
另外,实际上它是一个通道设计了两套滤波和边沿检测电路,第一套电路经过滤波和极性选择得到TI1FP1输入给通道1的后续电路;第二套电路经过另一个滤波和极性选择得到TI1FP2输入给下面的通道2的后续电路,同理输入捕获通道2也是这样。这样通道1可以同时输入给通道1和通道2,这样做的好处是可以灵活切换后续捕获电路的输入(可以一会以CH1作为输入,一会以CH2作为输入,可以通过数据选择器灵活选择),其次,可以把一个引脚的输入同时映射到两个捕获单元,这也是PWMI模式的经典结构(第一个通道使用上升沿触发来捕获周期,第二个通道使用下降沿触发,用来捕获占空比,两个通道同时对一个引脚进行捕获,就可以同时测量频率和占空比)。
STM32 的 PWMI 模式(Pulse Width Modulation Input Mode,脉冲宽度调制输入模式)是定时器的一种特殊工作模式,主要用于测量输入 PWM 信号的频率和占空比 。它利用定时器的两个输入捕获通道(IC1 和 IC2)来同时捕获输入 PWM 信号的不同边沿,进而计算出信号的频率和占空比。在使用 PWMI 模式前,需要对定时器进行一系列初始化设置,包括选择合适的定时器(如 TIM2 - TIM5 等)、配置定时器的时钟源、预分频器等,确保定时器能以合适的频率进行计数。同时,要将对应的 GPIO 引脚配置为定时器输入捕获功能。
- 捕获上升沿:当输入的 PWM 信号出现上升沿时,定时器的输入捕获通道 1(IC1)会检测到该上升沿,并触发捕获事件。此时,定时器会将当前的计数值保存到捕获 / 比较寄存器 1(CCR1)中。这个计数值代表了从上次捕获上升沿到本次捕获上升沿之间定时器的计数值,也就是 PWM 信号的一个完整周期内定时器的计数值。
- 捕获下降沿:在捕获到上升沿后,IC1 会自动切换为捕获下降沿模式。当输入信号出现下降沿时,定时器的输入捕获通道 2(IC2)(实际上 IC2 与 IC1 关联,用于捕获下降沿信息)会触发捕获事件,将此时定时器的计数值保存到捕获 / 比较寄存器 2(CCR2)中。这个计数值代表了从本次上升沿到本次下降沿之间定时器的计数值,也就是 PWM 信号高电平持续时间内定时器的计数值,同时 IC1 通道又会切换回捕获上升沿,准备下一次测量。但此时捕获完下降沿之后定时器不复位,直到捕获到下一个上升沿定时器才会复位
输入信号经过滤波和极性选择后就来到了预分频器,分频之后的通道就可以选择触发捕获电路进行工作了。每来一个触发信号,CNT的值就会向CCR转运一次,转运同时发生一个捕获事件CC1I(如图),这个事件会在状态寄存器置标志位,同时也可以产生中断。比如说使用上升沿触发,则每来一个上升沿CNT值向CCR转运一次,下一个上升沿来了再转运一次,两次值相减就可以得到一个周期的时间,这就是测周法测频率。只用一个通道来同时测量频率和占空比的过程如下,但是这需要在软件中频繁切换捕获边沿这会使软件的逻辑变得复杂,并且单一通道在受到干扰时,很容易出现误触发的情况。例如,当信号中存在噪声干扰时,IC1 可能会误将噪声尖峰当作上升沿或下降沿进行捕获,导致测量结果不准确。
- 捕获第一个上升沿:记录此时定时器的计数值
cnt1
,并将 IC1 配置为捕获下降沿。 - 捕获下降沿:记录计数值
cnt2
,此时cnt2 - cnt1
即为高电平持续时间对应的计数值。然后将 IC1 重新配置为捕获上升沿。 - 捕获第二个上升沿:记录计数值
cnt3
,cnt3 - cnt1
即为 PWM 信号一个周期对应的计数值。 - 计算频率和占空比:根据定时器的计数时钟频率
f_clk
,可计算出 PWM 信号的周期T = (cnt3 - cnt1) / f_clk
,频率f = 1 / T
;占空比D = (cnt2 - cnt1) / (cnt3 - cnt1)
。
以下为输入捕获通道的详细框图。可以看到TI1F_ED信号和TI1FP1信号都可以通过从模式控制器,比如TI1FP1的上升沿触发捕获,还可以同时触发从模式(从模式里面有电路可以自动完成CNT的清零等)
- TI1F_ED 是定时器输入 1(TI1)经过滤波和边沿检测后得到的信号。其中,“TI1” 代表定时器的第一个输入通道;“F” 表示滤波(Filter),意味着该信号经过了滤波处理,能够减少输入信号中的噪声干扰;“ED” 表示边沿检测(Edge Detection),表明它会对输入信号的上升沿和下降沿都进行检测。经过滤波后的信号会进行边沿检测,TI1F_ED 会同时检测上升沿和下降沿。当检测到上升沿或下降沿时,会触发相应的捕获事件或更新定时器的状态,方便后续对信号的处理和分析。 TI1F_ED不进入后续捕获电路,而是进入定时器时钟源选择做TRGI或者时钟源。应用场景:在需要对脉冲信号进行精确计数的场景中,TI1F_ED 非常适用。因为它能同时捕获上升沿和下降沿,在相同时间内可以获得更多的计数信息,提高计数的精度。例如在工业自动化中的流量测量系统,通过对传感器输出的脉冲信号进行计数来计算流体流量。
- TI1FP1 是 TI1 经过滤波后得到的信号,它可以被配置为只捕获上升沿、只捕获下降沿或者同时捕获上升沿和下降沿(取决于具体的配置)。
补充:异或门的使用
- 无刷电机
电机转速测量
- 提高信号稳定性
- 无刷电机的位置传感器(三个霍尔传感器)会输出3个脉冲信号。将这些信号分别连接到输入捕获通道的前三个通道(TI1、TI2、TI3),经过异或门处理后,能有效过滤信号中的噪声和干扰。因为噪声通常是随机的,而异或门对信号进行逻辑运算时,可将随机噪声的影响削弱,使输出信号更加稳定。例如,霍尔传感器在实际工作中可能会受到电磁干扰产生一些不规则的脉冲,通过异或门处理,能将这些干扰脉冲剔除,保证后续转速测量的准确性。
- 转速计算
- 异或门输出信号的频率与电机转速相关。利用 STM32 的输入捕获功能,捕获异或门输出信号的上升沿或下降沿,测量相邻两次捕获事件之间的时间间隔,就能计算出信号的周期,进而得到电机的转速。由于异或门对多个输入信号进行了综合处理,相比单一通道捕获信号,能更全面地反映电机的转动情况,提高转速测量的精度。
电机转向判断
- 无刷电机通常配备三个霍尔传感器,它们输出的信号(A、B、C 相)能反映电机转子的位置。电机正反转时,这三个霍尔传感器信号的状态变化顺序是不同的。因此,可以通过监测这三个信号的状态组合及其变化顺序来判断电机的转向。
- 三个霍尔传感器信号共有8种可能的状态组合,但在实际的无刷电机应用中,通常只有 6 种有效状态。为每个有效状态组合分配一个唯一的编号。 例如,设定正转时的状态变化顺序为 (S_1→S_2→S_3→S_4→S_5→S_6),反转时的状态变化顺序则为 (S_6→S_5→S_4→S_3→S_2→S_1)。通过 STM32 的 GPIO 读取功能,实时获取三个霍尔传感器信号的电平状态,并将其组合成一个状态值。记录当前状态和上一个状态,根据状态变化的顺序判断电机的转向。
- 以上是分别检测三个传感器信号来判断转向的,除此之外也可以直接看异或门输出的信号判断。电机正反转时,三个霍尔传感器输出信号的变化顺序不同,导致异或门输出信号的变化规律也不同。电机正转时,异或门输出信号的上升沿和下降沿出现顺序遵循一种规律;电机反转时,顺序则相反。通过检测异或门输出信号的这种变化规律,就可以判断电机的转向。
电机换相控制
- 确定换相时刻
- 无刷电机需要精确的换相控制来保证其正常运行。位置传感器输出的信号能反映电机转子的位置,通过异或门对这些信号进行处理,STM32 可以更准确地捕捉到电机换相的时刻。例如,根据异或门输出信号的特定电平组合,STM32 可以确定何时进行电机绕组的换相操作,确保电机的转矩输出稳定。无刷电机的换相顺序通常是固定的,例如对于三相无刷电机,常见的换相顺序为 AB - BC - CA - AB 等。STM32 根据转子位置信息,按照预先设定的换相顺序控制 PWM 输出,使电机能够持续稳定地转动。
- 优化换相策略
- 异或门处理后的信号能为 STM32 提供更详细的电机运行状态信息,有助于优化换相策略。通过分析异或门输出信号的频率、相位等参数,STM32 可以根据电机的实际运行情况动态调整换相时间,提高电机的效率和性能。
(4)主从触发模式
先看下一章的主从触发模式补充章节
主模式可以将定时器内部信号映射到TRGO引脚,用于触发别的外设。从模式就是接收其他外设或自身外设的一些信号,用于控制自身定时器的运行,也就是被别的信号控制。触发源选择就是选择从模式的触发信号源的(可以认为是从模式的一部分),触发源选择,选择一个指定的信号,得到TRGI,TRGI去触发从模式,从模式可以在它的列表里选择一项操作来自动执行。比如想让TI1FP1信号自动触发CNT清零,那触发源选择就可以选中TI1FP1,从模式执行的操作就可以选择Reset操作。
在库函数里这三部分分别对应三个函数
有关这些信号的具体解释可以参考手册,如下
(5)基本结构图
(6)实例一:输入捕获模式测频率
1,函数介绍
在初始化函数里,TIM_TimeBaseInit函数用来配置时基单元。输出比较初始化函数每个通道都有一个初始化函数TIM_OCxInit,而输入捕获初始化函数4个通道共用一个初始化函数ICInit,在结构体里会额外有一个参数,可以用来选择具体配置哪个通道(因为可能有交叉通道的配置,所以函数合在一起比较方便)。TIM_PWMIConfig也是输入捕获的初始化函数,此函数和ICInit函数类似,都用于初始化输入捕获单元,但ICInit函数只是单一地配置一个通道,而这个函数可以快速配置两个通道,把输入捕获单元配置成如下的PWMI基本结构
TIM_ICStructInit函数可以给输入捕获结构体赋一个初始值
TIM_SelectInputTrigger函数可以选择输入触发源TRGI。对应从模式触发源选择,本节选择TI1FP1
TIM_SelectOutputTrigger函数可以选择输出触发源TRGO,对应着主模式输出的触发源
TIM_SelectSlaveMode 函数可以选择从模式
以上三个参数对应图如下
这里,TIM_SetIC1,2,3,4Prescaler分别单独配置通道1,2,3,4的分频器,这个参数结构体里也可以配置是一样的效果
TIM_GetCapture1,2,3,4, 分别读取4个通道的CCR。这4个函数和上面的SetCompare1,2,3,4是对应的,SetCampare是写CCR寄存器。输出比较模式下,CCR是只写的,要用SetCompare写入。输入捕获模式下,CCR是只读的,要用GetCapture读出
2,初始化
- RCC开启时钟,将GPIO和TIM的时钟打开
- GPIO初始化,将GPIO配置为输入模式(一般选择上拉输入或浮空输入)
- 配置时基单元,让CNT计数器在内部时钟的驱动下自增运行(与之前代码相同)
- 配置输入捕获单元,包括滤波器,极性,直连通道还是交叉通道,分频器这些参数,用一个结构体统一配置
- 选择从模式触发源,触发源选择为TI1FP1,这里调用一个库函数,给一个参数就可以了
- 选择触发之后执行的操作,执行Reset操作,也是调用一个库函数就行了
- 最后调用TIM_Cmd函数,开启定时器
当我们需要读取最新一个周期的频率时,直接读取CCR寄存器,然后按照fc/N计算一下就可以了
(1)RCC开启时钟
#include "stm32f10x.h"
void IC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
}
(2) GPIO初始化
TIM3的CH1和CH2对应PA6和PA7,CH3和CH4对应PB0和PB1。本次使用TIM3的通道1引脚
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
}
(3)配置时基单元
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置时基单元
TIM_InternalClockConfig(TIM3); // 选用内部时钟
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
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
}
(4) 配置输入捕获单元
因为ICInit函数只有一个,所以需要配置是哪一个通道,通过结构体的TIM_ICInitStructure.TIM_Channel配置,以下给出了各通道。
TIM_ICInitStructure.TIM_ICFilter用来选择滤波器,如果信号有毛刺和噪声,就可以增大滤波器参数,有效避免干扰。可以看到以下解释,这个参数可以是0x0到0xF之间的一个数,数越大滤波效果越好,每个数值对应的采样频率和采样次数。滤波器和分频器都计次的,但是滤波器计次不会改变信号的原有频率,一般滤波器的采样频率都会远高于信号频率,所以它只会滤除高频噪声,使信号更加平滑。
TIM_ICInitStructure.TIM_ICPolarity 和TIM_ICInitStructure.TIM_ICPrescaler分别表示极性(选择上升沿触发还是下降沿触发)和分频器。不分频就是每次触发都有效,2分频就是每隔一次有效一次,以此类推
最后一个参数 TIM_ICInitStructure.TIM_ICSelection选择触发信号从哪个引脚输入。这个参数用来配置数据选择器,可以选择直连通道或者是交叉通道或是TRC,这里我们选择直连通道
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置时基单元
TIM_InternalClockConfig(TIM3); // 选用内部时钟
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
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
// 配置输入捕获单元
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF; //选择滤波器
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
}
(5) 配置TRGI的触发源为TI1FP1
触发源选择,使用TIM_SelectInputTrigger,以下为8个可选的触发源
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置时基单元
TIM_InternalClockConfig(TIM3); // 选用内部时钟
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
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
// 配置输入捕获单元
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF; //选择滤波器
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
//配置触发源选择
TIM_SelectInputTrigger(TIM3, TIM_TS_TI1FP1);
}
(6)配置从模式为Reset
使用TIM_SelectSlaveMode选择从模式。这里给出了4种从模式
对应了之前说过的8种从模式的后四种,上面Encoder从模式,是给编码器接口用的,用另外的函数配置
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置时基单元
TIM_InternalClockConfig(TIM3); // 选用内部时钟
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
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
// 配置输入捕获单元
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF; //选择滤波器
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
//配置触发源选择
TIM_SelectInputTrigger(TIM3, TIM_TS_TI1FP1);
//配置从模式为Reset
TIM_SelectSlaveMode(TIM3, TIM_SlaveMode_Reset);
}
(7)启动定时器
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置时基单元
TIM_InternalClockConfig(TIM3); // 选用内部时钟
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
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
// 配置输入捕获单元
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF; //选择滤波器
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
//配置触发源选择
TIM_SelectInputTrigger(TIM3, TIM_TS_TI1FP1);
//配置从模式为Reset
TIM_SelectSlaveMode(TIM3, TIM_SlaveMode_Reset);
//启动定时器
TIM_Cmd(TIM, ENABLE);
}
当定时器启动时,CNT会在内部时钟的驱动下不断自增,有信号来时,它就会在从模式的作用下自动清零。当想要查看频率时,需要读取CCR进行计算
计算频率函数
uint32_t IC_GetFreq()
{ // 1000000就是72MHz经过预分频器到达CNT的频率
return 1000000 / (TIM_GetCapture1(TIM3) + 1);
}
ARR为65535,PSC为72,所以能测的最低频率为1M/65535,大概为15HZ。最大频率并没有明显界限,因为随着待测频率的增大,误差也会逐渐增大,如果非要找一个频率上限,理论上就是标准频率1MHz。最大频率要看对误差的要求,如果要求误差为千分之一,则频率上限为1M/1000=1KHz,如果要求误差可以到百分之一,那频率上限就是1M/100=10KHz
(7)实例二:PWMI测频率占空比
输入捕获初始化部分需要修改成两个通道捕获同一个引脚的模式 ,配置方式代码如下
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置时基单元
TIM_InternalClockConfig(TIM3); // 选用内部时钟
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
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
// 配置输入捕获单元
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF; //选择滤波器
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;
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(TIM3, &TIM_ICInitStructure);
//配置触发源选择
TIM_SelectInputTrigger(TIM3, TIM_TS_TI1FP1);
//配置从模式为Reset
TIM_SelectSlaveMode(TIM3, TIM_SlaveMode_Reset);
//启动定时器
TIM_Cmd(TIM, ENABLE);
}
如上配置是可行的,但是STM32提供了封装函数来快速完成这个配置 。代码如下。使用此函数只需要传入一个通道的参数即可,在函数里会自动把剩下的一个通道初始化成相反的配置,比如这里是通道1,上升沿,直连,那此函数就会配置通道2,交叉,下降沿;如果传入通道2,直连,上升沿,函数就会配置通道1交叉下降沿。此函数只支持通道一通道二的配置,不要传入通道3和通道4
#include "stm32f10x.h"
void IC_Init(void)
{
// RCC开启时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置时基单元
TIM_InternalClockConfig(TIM3); // 选用内部时钟
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
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
// 配置输入捕获单元
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF; //选择滤波器
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_PWMIConfig(TIM3, &TIM_ICInitStructure);
//配置触发源选择
TIM_SelectInputTrigger(TIM3, TIM_TS_TI1FP1);
//配置从模式为Reset
TIM_SelectSlaveMode(TIM3, TIM_SlaveMode_Reset);
//启动定时器
TIM_Cmd(TIM, ENABLE);
}
获取占空比函数
uint32_t IC_GetDuty(void)
{
// 经过实测,CCR总会少一个属-数,所以+1
return (TIM_GetCapture2(TIM3) + 1) * 100 / (TIM_GetCapture1(TIM3) + 1);
}
8,TIM编码器接口
(1)基本介绍
本节程序为编码器测速。通过定时器编码器接口自动计次,使用编码器接口可以节约软件资源。对于这种需要频繁执行,操作又比较简单的任务,一般都会设计一个硬件电路来自动完成。编码器接口就是自动给编码器进行计次的电路,如果我们每隔一段时间取一下计次值就能得到编码器速度。一般电机速度比较高,编码器会使用无接触式的霍尔传感器或者光栅进行测速
编码器接口,英文是Encoder Interface。编码器可接收增量(正交)编码器的信号,根据编码器旋转产生的正交信号脉冲,自动控制CNT自增或自减,从而指示编码器的位置、旋转方向和旋转速度;编码器接口可以根据旋转方向不仅能自增计次,还能自减计次,是一个带方向的测速。每个高级定时器和通用定时器都拥有一个编码器接口,如果一个定时器配置成编码器接口模式,那它基本上就干不了其他活了。编码器接口的两个输入引脚,借用了输入捕获的通道1和通道2,即编码器的两个输入引脚,就是每个定时器的CH1和CH2引脚,CH3和CH4不能接编码器
(2)正交编码器
正交编码器一般可以测量位置,带有方向的速度值。一般有两个信号输出引脚,一个A相,一个B相。
编码器转的越快,方波频率就越快。取出任意一相的信号来测频率就能知道旋转速度了。利用正交信号判断正反转,当正转时,A相提前B相90度,反转时滞后90度;当正转时对应4种状态如右表,反转时同理。
所以,编码器设计逻辑是:首先A相和B相的所有边沿作为计数器的计数时钟,出现边沿信号时计数就自增或自减,增还是减,这个计数的方向由另一相的状态来确定,当出现某个边沿时,我们判断另一相的高低电平。以此来判断增还是减
(3)编码器接口电路
1,输入部分
下图红框所示即为编码器接口,这个接口共有两个输入端TI1FP1和TI2FP2(对应通道CH1和CH2)分别接编码器的A相和B相接口。编码器的A相和B相接到定时器CH1和CH2通道即可(CH1和CH2的输入滤波和边沿检测也有使用,后面的是否交叉和预分频器等没有使用),红线代表通路。
2,输出部分
输出部分相当于从模式控制器,去控制CNT的计时时钟和计数方向。总结来说,如果出现了边沿信号,并且对应另一相状态为正转,则控制CNT自增,否则控制CNT自减。我们之前使用的72MHz内部时钟和在时基单元初始化设置的计数方向并不会使用,因为此时计数时钟和计数方向都处于编码器托管的状态,计数器自增和自减受编码器控制
(4)编码器接口基本结构
ARR一般设置为65535,有利于输出负数,反转时,CNT自减,减到0再减时就是65535,这时会进行一个操作,直接把16位的无符号数转换为16位的有符号数,根据补码的定义,65535就对应-1,65534对应-2等等,这样就可以直接得到负数
(5)工作模式
下表就是编码器接口工作逻辑。编码器分了3种工作模式(仅在TI1计数,仅在TI2计数和TI1 TI2都计数,一般情况下使用TI1 TI2都计数,这个模式计数精度最高)
以下为两个边沿都计数的模式(TI1和TI2均不反相)
毛刺指的是一个引脚信号多次跳变但另一个信号保持低电平不动时,计数器会在自增和自减中跳动,最后也不会改变,这就是正交编码器抗噪声原理
以下为TI1反相的图
在编码器接口基本结构中看过TI1和TI2输入信号进入GPIO引脚都会经过极性选择的部分,输入捕获模式下这个极性选择是选择上升沿有效还是下降沿有效的,但是编码器接口始终都是上升沿和下降沿都有效(都需要计次),所以在编码器接口模式下,这里就不再是边沿的极性选择了,而是高低电平的极性选择,如果选择上升沿的参数,就是信号直通高低电平极性不反转;如果选择下降沿参数,就是信号通过一个非门高低电平极性反转 。最终就是向上计数变为向下计数,向下变为向上计数。
(6)实例:编码器测速
1,函数
定时器编码器接口配置函数。第一个参数选择定时器,第二个参数选择编码器模式,后面两个参数分别选择通道1和通道2的电平极性
编码器模式对应的参数如下
2,初始化
- RCC开启时钟,开启GPIO和定时器时钟
- 配置GPIO,将PA6和PA7配置为输入模式(这里使用定时器3的CH1和CH2通道)
- 配置时基单元,预分频器一般选择不分频,自动重装一般给最大65535
- 配置输入捕获单元
- 配置编码器接口模式,调用库函数即可
- 最后,调用TIM_Cmd启动定时器
如果想要测量编码器位置,直接读CNT值即可,如果想测量编码器的速度和方向,那就需要每隔一段固定的闸门时间取出一次CNT,然后再把CNT清零,这就是测频法测量速度
(1)RCC开启时钟
#include "stm32f10x.h"
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
}
(2)GPIO初始化
GPIO模式的选择:上拉输入和下拉输入的选择可以看外部引脚接的外部模块的默认电平,如果外部模块空闲默认输出高电平,我们就选择上拉输入,反之选择下拉输入,与外部模块默认电平保持一致即可。如果不确认外部模块输出的默认状态或者外部信号的功率特别小,这时就尽量选择浮空输入
#include "stm32f10x.h"
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
}
(3)配置时基单元
#include "stm32f10x.h"
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
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; //PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
}
(4)配置输入捕获单元
配置输入捕获单元时的极性参数在配置编码器接口时也有,属于重复配置,所以这里的也可以删掉。
#include "stm32f10x.h"
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
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; //PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICStructInit(&TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
}
(5)配置编码器接口模式
#include "stm32f10x.h"
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
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; //PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICStructInit(&TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising,TIM_ICPolarity_Rising);
}
(6) 启动定时器
#include "stm32f10x.h" // Device header
void Encoder_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
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; //PSC
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICStructInit(&TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;
TIM_ICInitStructure.TIM_ICFilter = 0xF;
TIM_ICInit(TIM3, &TIM_ICInitStructure);
TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising);
TIM_Cmd(TIM3, ENABLE);
}
获取编码器数值的函数:
注意:此时编码器每转一圈数值加4,因为每转一圈A相和B相都有一个上升沿和一个下降沿,两者都计数,则为4。
int16_t Encoder_Get(){//如果是uint16_t则0再减是65535,改成int16_t可以变为负数
return TIM_GetCounter(TIM3);
}
获取编码器速度的函数
int16_t speed;
int16_t Encoder_Get()
{
int16_t Temp;
Temp = TIM_GetCounter(TIM3);
TIM_SetCounter(TIM3, 0);
return Temp;
}
//每1s读取一次CNT,即速度
void TIM2_IRQHander()
{
if(TIM_GetITStatus(TIM2, TIM_IT_Update) == SET)
{
Speed = Encoder_Get();
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
}
补充章节:主从触发模式
主模式
在 STM32 微控制器中,定时器主模式是一种强大的功能,它允许一个定时器(主定时器)产生触发输出信号(TRGO),用于控制其他定时器(从定时器)或外设的操作,实现多个定时器或外设之间的同步与协同工作。当定时器工作在主模式时,它会根据自身内部发生的特定事件(如更新事件、捕获 / 比较事件等),产生一个触发输出信号(TRGO,Trigger Output)。这个触发输出信号可以连接到其他定时器的触发输入,或者作为其他外设(如 ADC、DAC 等)的启动信号,从而实现不同设备之间的同步操作。
主模式的触发输出源通常有以下几种,不同 STM32 系列可能略有不同:
- 更新事件(Update event):当定时器的计数器达到自动重装载值(ARR),或者进行手动更新操作时,会产生更新事件。将更新事件作为触发输出源,可以使从定时器或外设按照主定时器的计数周期进行同步操作。例如,主定时器每完成一个计数周期,就通过 TRGO 信号触发从定时器重新开始计数。
- 捕获 / 比较事件(Capture/Compare event):当定时器的输入捕获通道检测到特定的输入信号,或者比较寄存器的值与计数器的值匹配时,会产生捕获 / 比较事件。利用捕获 / 比较事件作为触发输出源,可以实现更精确的定时触发。比如,在检测到外部脉冲信号的上升沿(捕获事件)时,通过 TRGO 信号触发 ADC 进行一次数据采集。
- 计数器触发事件(Counter trigger event):某些特定的计数器触发条件也可以作为触发输出源,用于控制其他定时器或外设的启动、停止等操作。
从模式
定时器从模式是一种让定时器能够根据外部信号(如其他定时器的输出、外部引脚输入等)来控制自身行为的工作模式。这种模式增强了定时器之间以及定时器与外部设备的协同工作能力。当一个定时器工作在从模式时,它会将外部的特定信号作为控制源,依据这个控制源的信号来决定自身计数器的启动、停止、复位、计数方向改变等操作,从而实现与其他设备或定时器的同步工作。
定时器从模式有多种触发源,不同的 STM32 系列触发源可能略有差异,但常见的有以下几类:
- 内部触发输入(ITRx):可以将其他定时器的触发输出(TRGO)信号作为从定时器的触发源。例如,TIM2 的触发输出可以作为 TIM3 从模式的触发信号,实现两个定时器的同步。
- 外部触发输入(ETR):通过定时器的外部触发引脚(ETR)接收外部信号作为触发源。这使得定时器可以响应外部设备产生的信号,如传感器的脉冲信号。
- TIxFPx 信号:来自定时器自身的输入捕获通道的信号,可用于触发计数器的操作,例如在捕获到特定的输入信号时对计数器进行复位或启动。
常见的从模式类型
- 复位模式
- 原理:当从定时器检测到触发信号时,计数器会立即复位到 0,并重新开始计数。
- 应用场景:常用于需要周期性复位计数器的场景,如在测量脉冲宽度时,每次检测到脉冲的上升沿,将计数器复位,然后开始计数,直到脉冲下降沿,这样就能准确测量脉冲的宽度。
- 触发模式
- 原理:触发信号到来时,计数器开始计数。如果计数器已经在计数,则触发信号对其没有影响。
- 应用场景:适用于需要在特定事件发生时启动定时器计数的情况,比如在检测到外部设备发出的启动信号后,开始定时器的计时。
- 门控模式
- 原理:计数器的计数操作由触发信号和定时器的使能信号共同控制。只有当触发信号有效且定时器使能时,计数器才会计数。
- 应用场景:可用于精确控制计数器计数的时间段,例如在外部信号满足特定条件时才允许定时器计数。
- 编码器模式
- 原理:主要用于处理编码器输入信号。根据编码器的 A 相和 B 相信号,定时器可以自动判断编码器的旋转方向,并对计数器进行相应的递增或递减计数。
- 应用场景:在电机控制、机器人运动控制等领域,用于测量电机的转速和位置。
八,ADC模数转换器
1,简介
(1)ADC作用
ADC作用:STM32的ADC是12位的,所以AD结果最大值是4095(2^12 - 1),一般对应电压3.3V。对于GPIO来说,它只能读取引脚的高低电平,要么是高电平要么是低电平,而使用了ADC之后就可以对高电平和低电平之间的任意电压进行量化,最后用一个变量来表示,读取这个变量就可以知道引脚的具体电压了,所以ADC就相当于一个电压表,把引脚的电压值测出来放在一个变量里
(2)ADC参考电压的设置
ADC参考电压的设置:上面说AD结果最大值4095一般对应最高参考电压3.3V。实际上ADC转换器的参考电压是可以修改的。在单端输入模式下(只有一个输入电压端口),ADC 的输入电压范围通常由参考电压决定,STM32 主要有两种参考电压引脚:VREF+ 和 VREF-。一般 VREF- 连接到地(GND),VREF+ 连接到一个稳定的参考电压源,ADC 的输入电压范围就是 VREF- 到 VREF+;在STM32F1系列中,LQFP100、LQFP144 封装的芯片具有 VREF + 引脚。而对于 LQFP48、LQFP64 等小封装的 STM32F1 系列芯片,是没有引出 VREF + 引脚的。这类芯片 ADC 的参考电压直接由 VDDA 引脚提供,VDDA 通常连接到 3.3V 电源,此时 ADC 的输入电压范围就是 0 - 3.3V。用户可以通过外部电路为 VREF+ 提供不同的参考电压。如果外部参考电压源设置为 2.5V,那么 ADC 的最高输入电压就是 2.5V;若设置为 5V(前提是芯片支持该电压范围且做好相应的保护和处理),则最高输入电压为 5V 。不过需要注意,并非所有 STM32 芯片都支持 5V 的参考电压,使用时要参考芯片的数据手册。这里再补充一下差分输入模式,差分输入模式下,两个输入引脚(如 ADC_INx 和 ADC_INy)之间的电压差被转换为数字值。其允许的最大共模电压和差分电压范围也取决于具体的芯片型号和参考电压设置。一般来说,共模电压范围要在 VREF- 和 VREF+ 之间,差分电压的最大值也有一定限制,通常是几伏,具体数值需查阅对应芯片的数据手册。
(3) ADC和DAC的比较
- ADC和DAC的比较:ADC可以将模拟信号转换为数字信号,是模拟电路到数字电路的桥梁。DAC是数字到模拟的桥梁,ADC(模拟 - 数字转换器)和 DAC(数字 - 模拟转换器)在功能上是互逆的。常见的 ADC 转换方法有逐次逼近型、积分型、Σ - Δ 型等。以逐次逼近型为例,它通过一个逐次逼近寄存器(SAR),从最高位开始,依次确定每一位的值。比较器将输入的模拟信号与内部 D/A 转换器输出的参考电压进行比较,根据比较结果确定该位是 1 还是 0,直到确定所有位的值,完成一次转换。常见的 DAC 实现方式有电阻网络型,如 R - 2R 梯形电阻网络。通过数字输入控制模拟开关的通断,使不同的电阻接入电路,从而产生与数字输入对应的模拟电流,再经过运算放大器将电流转换为电压输出。
- DAC和PWM的比较:在某些特定情况下,PWM 可以被用来实现简单的 DAC 功能。通过对 PWM 信号进行滤波等处理,将其转换为近似的模拟信号,但这并不意味着 PWM 就是 DAC。DAC:常用于对信号精度要求高的场合,如专业音频设备、高精度测量仪器、视频信号处理等,可提供高保真度的模拟信号输出。PWM:常用于电机调速、LED 亮度调节、开关电源控制等领域,在对成本敏感、对精度要求不特别高,且需要进行功率控制的场景中应用广泛。
(4)ADC的分辨率和转换速度
STM32的ADC是12位逐次逼近型的ADC,1us的转换时间。12位代表ADC的分辨率,它的表示范围是0~2^12-1,位数越高量化结果越精细,对应分辨率越高。1us是转换时间,就是转换频率,AD转换是需要花一小段时间的,这里的1us表示从AD转换开始到产生结果需要1us时间,对应AD转换的频率就是1MHz。
转换速度通常用转换时间或采样频率来表示。转换时间是指完成一次模拟到数字转换所需的时间,采样频率则是单位时间内能够完成的转换次数。不同类型的 ADC 转换速度差异较大,例如,逐次逼近型 ADC 的转换速度一般在几十 kHz 到几 MHz 之间,而 Σ - Δ 型 ADC 的转换速度相对较慢,但分辨率较高。
(5)STM32的ADC资源介绍
STM32的ADC最多有18个输入通道,可测量16个外部和2个内部信号源(外部信号源就是16个GPIO口,在引脚上直接接模拟信号就行了;内部信号源是内部温度传感器和内部参考电压,内部温度传感器可以测量CPU温度,内部参考电压是一个1.2V左右的基准电压,不随外部供电电压变化而变化的)
STM32ADC的增强功能:规则组和注入组两个转换单元。普通的AD转换流程是启动一次转换,读一次值。STM32的AD可以列一个组,一次性启动一个组,连续转换多个值;STM32中有两个组,一个是用于常规使用的规则组,一个是用于突发事件的注入组。之后会介绍
模拟看门狗自动监测输入电压范围:ADC一般可以用于测量光线强度,温度等,有时需要对低于某个或者高于某个阈值时进行一些操作,这时就可以用模拟看门狗来自动执行。模拟看门狗可以检测指定的某些通道,当AD值高于它设定的上阈值或者低于下阈值时,它就会申请中断,这时就可以在中断函数里执行相应的操作。在看门狗一节会详细介绍
STM32F103C8T6 ADC资源:ADC1,ADC2,每个ADC有10个外部输入通道(最多只能测量10个外部引脚的模拟信号),另有2个测量内部信号的通道
2,逐次逼近型ADC
(1)ADC芯片
ADC0809是一款比较经典的ADC芯片,8位逐次逼近型(STM32为12位)。原理与STM32类似。
左边的IN0~IN7是8路输入通道,通过通道选择开关,选中一路,输入到比较器进行转换。
下面为地址锁存和译码,ADDA、ADDB、ADDC 通常是用于选择多路模拟输入通道的地址线。许多 ADC 芯片具有多个模拟输入通道,可以同时连接多个模拟信号源,通过这几个地址线的不同组合来选择具体要转换的模拟输入通道,以一个具有 8 个模拟输入通道(IN0 - IN7)的 ADC 为例,ADDA、ADDB、ADDC 这三根地址线可以组合出 2³ = 8 种不同的二进制编码,分别对应 8 个不同的模拟输入通道。ALE 是一个控制信号,用于锁存 ADDA、ADDB、ADDC 输入的地址信号。在 ADC 转换过程中,需要确保在转换期间地址信号保持稳定,以保证选择的模拟输入通道不变。相当于一个可以通过模拟信号的数据选择器。因为ADC转换是一个很快的过程,所以如果想要转换多路信号,那不必设计多个AD选择器,只需要一个AD转换器和多路选择开关即可。
输入信号到达比较器,采用逐次逼近法来确定电压对应的编码数据。电压比较器可以判断两个输入信号电压的大小关系,输出一个高低电平指示谁大谁小。此AD转换器中的电压比较器两个输入端,一个是待测的电压,另一个是DAC的电压输出端。两者进行判断,以下为过程,核心思想是通过从最高位(MSB)开始,逐位确定数字输出的每一位,逐步逼近输入模拟信号的幅度。
假设要进行一个 n 位的 ADC 转换,下面以 4 位逐次逼近型 ADC 为例,详细说明其工作过程:
- 初始化:转换开始前,逐次逼近寄存器(SAR)的所有位都被清零。
- 最高位试探:控制逻辑电路将 SAR 的最高位(MSB)置为 1,其余位为 0。对于 4 位 ADC,此时 SAR 的值为 1000(二进制)。这个数字量被送入 D/A 转换器,D/A 转换器将其转换为对应的模拟电压 。
- 比较判断:比较器将输入的模拟电压Vin与VDAC进行比较。
- 如果Vin≥VADC ,说明当前试探的数字量偏小,那么最高位的 1 保留,因为实际的数字输出应该大于或等于当前试探值。
- 如果Vin<VADC ,说明当前试探的数字量偏大,将最高位清零,因为实际的数字输出应该小于当前试探值。
- 次高位试探:无论最高位是否保留,接着将次高位置为 1,再次通过 D/A 转换器得到新的 ,并与 进行比较,根据比较结果决定该位是保留 1 还是清零。例如,若最高位保留为 1,此时 SAR 的值变为 1100;若最高位被清零,SAR 的值变为 0100。
- 重复上述过程:按照从高到低的顺序,依次对每一位进行试探和比较,直到最低位(LSB)比较完毕。每一次比较后,根据结果确定该位的值,最终得到完整的 n 位数字输出。
AD转换结束后,DAC的输入数据就是未知电压的编码,通过三态锁存缓冲器输出,EOC是End of Convert,转换结束信号。START是开始转换,给一个输入脉冲开始转换,CLOCK是ADC时钟,因为ADC内部是一步一步进行判断的,所以需要时钟来推动这个过程。VREF+和VREF-是DAC的参考电压。VCC和GND是整个芯片的供电,通常VREF+和VCC是一样的,会接在一起,VREF-和GND也一样,也接到一起,所以一般情况下,ADC的输入电压范围和ADC的供电一样
(2)STM32的ADC
下图红框是ADC输入通道,包括16个GPIO口IN0~IN15和两个内部的通道(内部温度传感器VREFINT内部参考电压)。
绿框内为模拟多路开关,可以同时指定多个我们想要选择的通道,右边是多路开关的输出进入到模数转换器(执行逐次比较的过程),转换时分成了两组(规则通道组和注入通道组),规则组可以一次性最多选中16个通道,注入组最多可以选择4个通道。规则通道得到的转换结果放到规则通道数据寄存器,但是这个寄存器只有一个,只会存放最后一个通道的转换结果,为了避免这个问题最好配合DMA来实现(数据转运帮手,每来一个数据把该数据移到其他地方,防止数据被下一个数据覆盖);注入通道有4个寄存器,也就是可以同时转运4个转换后的数据,一般情况下使用规则组。转换结果会直接放到蓝框内的寄存器中,读取寄存器就可以知道ADC转换的结果了。规则通道和注入通道的配置和转换过程相对独立,可以分别进行设置和控制。
下面详细介绍规则通道和注入通道:规则通道和注入通道是两种不同的模拟输入通道处理方式
-
规则通道:规则通道是最常用的模拟输入通道,用于常规的模拟信号转换。可以将多个规则通道配置成一个转换序列,ADC 会按照设定的顺序依次对这些通道进行转换。规则通道最多可以配置 16 个通道,通过设置规则序列寄存器(ADC_SQR1、ADC_SQR2、ADC_SQR3)来确定每个通道在转换序列中的位置和顺序(例如,将通道 0 设置为序列中的第一个转换通道,通道 1 设置为第二个转换通道等)。可以通过软件触发或外部事件触发规则通道的转换(软件触发时,只需向 ADC_CR2 寄存器中的 SWSTART 位写 1 即可启动转换;外部事件触发则可以选择定时器触发、外部中断线触发等方式)。转换完成后,转换结果会被存储在规则数据寄存器(ADC_DR)中。如果是单通道转换,直接从该寄存器读取结果;如果是多通道转换,每次转换结果会覆盖之前的数据,需要在每次转换完成后及时读取数据。适用于需要按一定顺序对多个模拟信号进行连续转换的场景,如数据采集系统中对多个传感器信号的依次采集。
-
注入通道:注入通道可以理解为一种具有较高优先级的通道,类似于中断的概念。当注入通道被触发时,会打断正在进行的规则通道转换,优先对注入通道进行转换。注入通道最多可以配置 4 个通道,通过注入序列寄存器(ADC_JSQR)来设置每个通道在转换序列中的位置和顺序。注入通道同样可以通过软件触发(向 ADC_CR2 寄存器中的 JSWSTART 位写 1)或外部事件触发。外部触发源与规则通道类似,但通常用于一些需要紧急处理的模拟信号转换。每个注入通道都有独立的数据寄存器(ADC_JDR1 - ADC_JDR4),转换结果会分别存储在对应的寄存器中,方便用户读取。常用于需要实时响应某些紧急模拟信号的场景,例如在一个工业控制系统中,当检测到某个关键传感器的信号发生突变时,通过注入通道优先对该信号进行转换和处理。
紫色部分为模拟看门狗,它里面可以设置一个阈值高限和阈值低限。如果启动了模拟看门狗并指定了看门的通道(下方的数据寄存器),那这个看门狗就会关注它的看门通道,一旦超过阈值范围,它就会申请模拟看门狗的中断,最后通向NVIC。对于规则组和注入组而言,他们转换完成后也会有一个EOC转换完成的信号,EOC是规则组的完成信号,JEOC是注入组完成的信号,这两个信号会在状态寄存器里置一个标志位,读取标志位就知道转换是否结束,这两个标志位也可以去到NVIC申请中断。
灰框部分是触发转换的部分,也就是START信号开始转换。对于STM32的ADC,触发ADC开始转换的信号有两种,一种是软件触发(手动调用一条代码),另一种是硬件触发,就是灰框的这些触发源 ,上面是注入组触发源,下面是规则组触发源,这些触发源主要来自定时器,有定时器各个通道,还有TRGO定时器主模式的输出(定时器可以通向ADC,DAC这些外设,用于触发转换)。ADC经常需要每过一段固定时间转换一次(比如1ms),正常思路是用定时器每隔1ms申请一次中断,在中断里手动开始一次转换,但这样频繁中断会影响主程序运行,所以采用硬件支持,将TIM3定个1ms的时间,并且把TIM3的更新事件选择为TRGO输出,然后在ADC这里选择开始触发的信号为TIM3的TRGO,这样TIM3的更新事件就能通过硬件自动触发ADC转换了,当然如图,也可以选择外部中断引脚来触发转换
ADCCLK就是ADC芯片的CLOCK,用于驱动内部逐次比较的时钟。来自ADC的预分频器来源于RCC,如下图,在RCC时钟树中有。由于ADCCLK最大只能14MHz,所以预分频器的分频系数只能选择6和8
3,ADC基本结构总图
开关控制对应库函数ADC_Cmd函数,用于给ADC上电
4,输入通道
可以看到ADC1和ADC2的通道都相同,这是为双ADC模式准备的,就是ADC1和ADC2一起工作,它俩可以配合组成同步模式,交叉模式等(交叉模式,ADC1和ADC2交叉地对同一个通道进行采样,进一步提高采样率)
5,规则组的四种转换模式
在ADC初始化的结构体里会有两个参数,一个是选择单次转换还是连续转换的,另一个是选择扫描模式还是非扫描模式的
(1)单次转换,非扫描模式
非扫描模式下,下述表格只有第一个序列1位置有效,这时同时选择一组输出的形式就变成了简单地选中一个的方式了。我们可以在序列1的位置指定想要转换的通道,然后就可以触发转换,ADC就会对这个通道2进行模数转换,转换的结果放在数据寄存器里,同时给EOC标志位置1,整个转换过程就结束了。如果想再启动一次转换,那就需要再触发一次,如果想换一个通道转换,那就在转换之前把第一个位置的通道2改成其他通道
(2)连续转换,非扫描模式
他还是非扫描模式,所以列表只用第一个。与单次转换不同的是,它在一次转换结束后不会停止,而是立刻开始下一轮转换,然后一直持续下去。这样就只需要最开始触发一次,之后就可以一直转换了,想要都AD值直接从数据寄存器取就可以了
(3)单次转换,扫描模式
单次转换,所以每触发一次转换结束后就会停下来,下次转换就要再触发才能开始。扫描模式可以在序列里指定多个通道,并且可以重复。在初始化结构体里有一个参数就是选择通道数目。触发后,它就对前7个位置进行AD转换,转换结果都放到数据寄存器中,为了防止数据被覆盖,就需要用DMA及时将数据挪走,7个数据转换完成后,产生EOC信号,转换结束
(4)连续转换,扫描模式
在扫描的模式下还可以有一种模式,叫间断模式,它的作用是在扫面的过程中,每隔几个转换就暂停一次,需要再次触发才能继续
6,触发控制
下表就是规则组的触发源,在这个表里有来自定时器的信号和来自引脚或定时器的信号(具体是引脚还是定时器需要用AFIO重映射来确定),最后是软件控制位(软件触发)。这些信号可以通过设置右边的寄存器来完成,使用库函数的话直接调用一个参数就可以了
7,数据对齐
ADC是12位的,它的转换结果就是一个12位数据,但数据寄存器是16位的,这就存在一个数据对齐的问题。数据对齐分为左对齐与右对齐如下(一般使用右对齐)
8,转化时间
如果不需要非常高速的转换频率,那转换时间就可以忽略。
•AD转换的步骤:采样,保持,量化,编码。(采样保持可以放一起,量化编码(ADC逐次比较)可以放一起,总共为这两大步)
量化编码需要花一段时间,且位数越多,编码时间越长
采样保持作用:AD转化时量化编码是需要一小段时间的,如果在这一小段时间里输入的电压不断变化那就无法定位输入电压,所以在量化编码之前,需要设置一个采样开关,先打开采样开关收集一下外部电压(比如可以用小容量电容存储一下这个电压),存储之后断开采样开关,再进行后面的AD转换。闭合采样时间过一段时间再断开就会产生一个采样时间。
TCONV = 采样时间 + 12.5个ADC周期
采样时间是采样保持花费的时间,可以在程序中配置,采样时间越大,越能避免一些毛刺信号的干扰,不过转换时间也会相应延长,12.5是量化编码花费的时间,因为是12位ADC,所以需要花费12个周期,多的半个周期用于做其他事。ADC周期就是从RCC分频过来的ADCCLK,最大为14MHz
TCONV = 1.5 + 12.5 = 14个ADC周期 = 1μs
9,校准
10,实例
(1)函数说明
ADCCLK配置函数,在RCC文件里,用来配置ADCCLK分频器的,可以对APB2的72MHz时钟选择2 4 6 8分频,输入到ADCCLK
ADC库函数:
以下函数用于初始化,使能等。ADC_Cmd对应开关控制
void ADC_DMACmd(ADC_TypeDef*ADCx, FunctionalState NewState); : 用于开启DMA输出信号的,如果使用DMA转运数据,那就得调用这个函数
void ADC_ITConfig(ADC_TypeDef* ADCx, uint16_t ADC_IT, FunctionalState NewState);:对应中断输出控制
以下四个函数,分别为复位校准,获取复位校准状态,开始校准,获取开始校准状态。这些是用于控制校准的函数,在ADC初始化完成后一次调用即可
以下函数为软件开始转换控制,用于软件触发的函数,这个函数给SWSTART置1,转换开始后硬件马上清除此位。调用一下就能软件触发转换了。第二个函数是返回SWSTART的状态,由于此位在开始转换后立刻清零,所以这个函数的返回值跟转换是否结束毫无关系,所以这个函数其实没啥用
要知道转换是否结束,利用下面的函数获取标志位状态,参数uint8_t ADC_FALG给EOC的标志位,判断标志位是否置1,如果转换结束,EOC标志位置1
下面两个函数用来配置间断模式。第一个函数是每隔几个通道间断一次,第二个函数是是否启用间断模式
下面函数为ADC规则组通道配置,这个函数是给序列的每个位置填写指定的通道。如下图所示序列。第二个ADC_Channel就是你想指定的通道,第三个参数Rank就是序列几的位置,第四个SampleTime就是指定通道的采样时间
ADC外部触发转换控制,就是是否允许外部触发转换
以下两个函数分别是获取转换值和获取双模式转换值(双ADC模式读取转换结果的函数)
接下来的一大批函数都带injected,意思是配置注入组的,暂时不看
以下三个函数对模拟看门狗进行配置。第一个是是否启动模拟看门狗,第二个是配置高低阈值,第三个是配置看门的通道
以下两个是用来开启内部的两个通道的(温度传感器和内部电压参考)
最后四个获取标志位状态,清除标志位状态,获取中断状态,清除中断挂起位
(2)初始化(单次转换,非扫描模式)(AD单通道)
- 开启RCC时钟,包括ADC和GPIO的时钟,另外ADCCLK的分频器(CLOCK)也需要配置
- 配置GPIO,将GPIO配置为模拟输入
- 配置多路开关(黄色梯形块) ,把左边的通道接入到右边的规则组列表中
- 配置ADC转换器,用结构体配置(可以配置AD转换器和AD数据寄存器)。包括ADC转换模式(单次转换还是连续转换,扫描还是非扫描),有几个输入通道,触发源是什么,数据对齐是左对齐还是右对齐。
- 如果需要模拟看门狗,会有几个函数来配置阈值和监测通道
- 如果想要开启中断,那就在中断输出控制里用ITConfig开启对应的中断输出。然后再配置NVIC
- 调用ADC_Cmd函数,开启ADC。
- 开启ADC后,还可以对ADC进行校准,以减小误差
ADC工作时如果想要软件触发转换那会有函数可以触发,如果想读取转换结果,也会有函数可调用
1,开启RCC时钟
之前说过ADCCLK最高只能14MHz,所以选择72MHz的六分频
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
}
2,配置GPIO
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,ADC的专属模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
}
3,配置多路开关(选择规则组的输入通道)
使用函数void ADC_RegularChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime);
第二个参数选择输入通道,可选参数如下
第三个参数Rank,规则组序列器里的次序,参数在1~16之间,对应规则组的16个序列,如下。目前只有PA0一个通道使用的是非扫描的模式,所以指定的通道就放在序列1的位置。如果还想在序列2 3的位置添加通道那就复制此代码函数修改序列和通道就可以了,每个通道也可以选择不同的采样时间。
最后一个参数指定通道的采样时间 。根据需求来,需要更快的转换就选择更小的参数,需要更稳定的转换就选择大的参数,这里没啥要求就随便选了
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,ADC的专属模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
}
4,初始化ADC
结构体成员:第一个ADC_Mode是ADC工作模式。配置ADC在独立模式还是双ADC模式,可选参数如下。第一个Independent就是独立模式(ADC1和ADC2各转换各的),剩下的参数就全是双ADC的模式了,暂时不用
第二个成员ADC_DataAlign是数据对齐,指定ADC数据是左对齐还是右对齐 ,可选参数如下,一般选择右对齐
第三个成员ADC_ExternalTrigConv外部触发选择,就是触发控制的触发源。可选参数如下,对应ADC框图如下。这里选择外部触发ADC_ExternalTrigConv_None,也就是不使用外部触发,使用内部软件触发
第四个成员ADC_ContinuousConvMode连续转换模式,可以选择是连续转换还是单次转换 。参数为ENABLE就是连续模式,DISABLE就是单次模式
第五个成员ADC_ScanConvMode扫描转换模式,可以选择是扫描模式还是非扫描模式。参数为ENABLE就是扫描模式,DISABLE就是非扫面模式
第六个,通道数目,这个是指定在扫描模式下,总共会用到几个通道 。参数在1~16之间
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,ADC的专属模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;
ADC_InitStructure.ADC_ScanConvMode = DISABLE;
ADC_InitStructure.ADC_NbrOfChannel = 1; //非扫描模式下此参数无用
ADC_Init(ADC1, &ADC_InitStructure);
}
5,开启ADC
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,ADC的专属模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;
ADC_InitStructure.ADC_ScanConvMode = DISABLE;
ADC_InitStructure.ADC_NbrOfChannel = 1; //非扫描模式下此参数无用
ADC_Init(ADC1, &ADC_InitStructure);
ADC_Cmd(ADC1, ENABLE);
}
6,对ADC进行校准
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,ADC的专属模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;
ADC_InitStructure.ADC_ScanConvMode = DISABLE;
ADC_InitStructure.ADC_NbrOfChannel = 1; //非扫描模式下此参数无用
ADC_Init(ADC1, &ADC_InitStructure);
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1); // 复位校准
while (ADC_GetResetCalibrationStatus(ADC1) == SET); // 等待复位完成
ADC_StartCalibration(ADC1); // 开始校准
while (ADC_GetCalibrationStatus(ADC1) == SET); // 等待校准完成
}
7,启动转换并获取结果函数
首先软件触发转换,等待转换完成(也就是EOC标志位置1),最后读取ADC数据寄存器即可
EOC在(规则或注入)通道组转换结束时设置,由软件清除或由读取ADC_DR时清除(即读取数据寄存器后自动清除)。为1时表示转换完成
uint16_t AD_GetValue(void)
{
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET);
return ADC_GetConversionValue(ADC1);
}
转换时间计算:上述设计采样时间55.5周期,转换周期固定为12.5周期,共68个周期,上述设置的ADCCLK72MHz的六分频为12MHz,(1/12)*68≈5.6us。
调用一次此函数就执行一次单次转换非扫描模式。
(3)初始化(连续转换,非扫描模式)
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,ADC的专属模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;
ADC_InitStructure.ADC_ScanConvMode = DISABLE;
ADC_InitStructure.ADC_NbrOfChannel = 1; //非扫描模式下此参数无用
ADC_Init(ADC1, &ADC_InitStructure);
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1); // 复位校准
while (ADC_GetResetCalibrationStatus(ADC1) == SET); // 等待复位完成
ADC_StartCalibration(ADC1); // 开始校准
while (ADC_GetCalibrationStatus(ADC1) == SET); // 等待校准完成
ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 连续转换仅需要最开始触发一次就行了,所以这里软
// 件触发转换的函数就可以挪到初始化的最后
}
uint16_t AD_GetValue(void)
{
return ADC_GetConversionValue(ADC1);
}
(4)初始化(单次转换,非扫描模式实现AD多通道)
利用扫描模式实现多通道,最好配合DMA实现,这种实现AD多通道的方式下一节会介绍
利用单次转换,非扫描模式实现AD多通道只需要每次触发转换之前,手动更改以下列表第一个位置的通道即可。比如第一次转换先写入通道0,之后触发,等待,读值,第二次转换再先把通道0改为通道1,之后触发,等待,读值,第三次转换再改为通道2
初始化要用到的通道对应的GPIO,将配置多路开关函数ADC_RegularChannelConfig(ADC1, ADC_Channel, 1, ADC_SampleTime_55Cycles5);放到获取数值的函数
#include "stm32f10x.h" // Device header
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入,ADC的专属模式
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;
ADC_InitStructure.ADC_ScanConvMode = DISABLE;
ADC_InitStructure.ADC_NbrOfChannel = 1; //非扫描模式下此参数无用
ADC_Init(ADC1, &ADC_InitStructure);
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1); // 复位校准
while (ADC_GetResetCalibrationStatus(ADC1) == SET); // 等待复位完成
ADC_StartCalibration(ADC1); // 开始校准
while (ADC_GetCalibrationStatus(ADC1) == SET); // 等待校准完成
}
uint16_t AD_GetValue(uint8_t ADC_Channel)
{
ADC_RegularChannelConfig(ADC1, ADC_Channel, 1, ADC_SampleTime_55Cycles5);
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET);
return ADC_GetConversionValue(ADC1);
}
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "AD.h"
uint16_t AD0, AD1, AD2, AD3;
int main(void)
{
OLED_Init();
AD_Init();
OLED_ShowString(1, 1, "AD0:");
OLED_ShowString(2, 1, "AD1:");
OLED_ShowString(3, 1, "AD2:");
OLED_ShowString(4, 1, "AD3:");
while (1)
{
AD0 = AD_GetValue(ADC_Channel_0);
AD1 = AD_GetValue(ADC_Channel_1);
AD2 = AD_GetValue(ADC_Channel_2);
AD3 = AD_GetValue(ADC_Channel_3);
OLED_ShowNum(1, 5, AD0, 4);
OLED_ShowNum(2, 5, AD1, 4);
OLED_ShowNum(3, 5, AD2, 4);
OLED_ShowNum(4, 5, AD3, 4);
Delay_ms(100);
}
}
九,DMA直接存储器存储
1,简介
DMA(Direct Memory Access) 直接存储器存取。可以协助CPU,完成数据转运的工作。比如一个数组的数据要复制到另一个数组中,正常思路是用for循环对数据一个一个的转运,而DMA可以一次性转运所有数据
DMA可以提供外设和存储器(RAM,Flash)或者存储器和存储器之间的高速数据传输,无须CPU干预,节省了CPU资源。当需要进行数据传输时,CPU 只需向 DMA 控制器发送传输请求和相关的配置信息(如源地址、目标地址、传输数据长度等),之后 DMA 控制器就会自动完成数据的传输工作
STM32有12个独立可配置的通道:DMA1(7个通道),DMA2(5个通道)
每个通道都支持软件触发和特定的硬件触发。如果DMA是存储器到存储器的数据转运(比如把Flash里的一批数据转运到SRAM里),那就需要软件触发,DMA就会一股脑地将数据以最快速度全部转运完成。如果DMA进行的是外设到存储器的数据转运就不能一股脑地转运了,因为外设数据是有一定时机的,所以这时我们就需要用硬件触发,比如转运ADC数据就要等ADC每个通道AD转换完成后硬件触发一次DMA(触发一次,转运一次)。
STM32F103C8T6的DMA资源:DMA1(7个通道)。每个通道专门用来管理来自一个或多个外设对存储器访问的请求
2,存储映像
既然DMA是在存储器之间进行数据转运的,就要了解STM32中的存储器
BootLoader程序是芯片出厂自动写入的,一般不允许修改。选项字节里存的主要是Flash的读保护,写保护和看门狗等配置。运行内存存储运行过程中的临时变量,也就是在程序中定义变量,数组,结构体的地方。
3,DMA框图(总框图)
左上角为Cortex-M3内核,里面包含了CPU和内核外设等等。其他剩下的所有东西都可以看作存储器。Flash是主闪存,SRAM是运行内存,各个外设都可以看成是寄存器(连接软件和硬件的桥梁,软件读写寄存器,寄存器去控制硬件执行),也是一种SRAM存储器。
这样,使用DMA进行数据转运就都可以归为一类问题:从某个地址取内容再放到另一个地址去。
为了高效有条理地访问存储器,设计了一个总线矩阵。总线矩阵的左端(箭头)是主动单元,拥有存储器的访问权;右边为被动单元,他们的存储器只能被左边的主动单元读写。主动单元这里,内核有DCode和系统总线(DCode专门访问Flash,系统总线访问其他存储器),DMA作为主动单元也有访问的主动权。主动单元除了内核CPU也就只有DMA总线了。DMA1有7个通道,DMA2有5个通道,各个通道可以分别设置它们转运数据的源地址和目的地址,这样它们就可以各自单独工作了。DMA1里有一个仲裁器是用来决定优先级的,这是因为虽然多个通道可以独立转运数据,但是DMA总线只有一条,所以所有通道只能分时复用一条DMA总线,如果产生冲突就由仲裁器根据通道优先级决定先后顺序 。另外,在总线矩阵处也会有一个仲裁器,STM32 芯片内部存在多个主设备(如 CPU、DMA 控制器等)和多个从设备(如存储器、外设等)。这些主设备都可能同时发起对从设备的访问请求,而总线资源是有限的,同一时刻只能有一个主设备与一个从设备进行数据传输。因此,需要一个机制来决定哪个主设备的请求能够优先获得总线使用权,总线矩阵仲裁器就承担了这个任务。仲裁器根据预先设定的优先级规则对各个请求进行评估。优先级规则可以是固定的,也可以是动态调整的。例如,一些对实时性要求较高的主设备(如 DMA 控制器在进行紧急数据传输时)可能被赋予较高的优先级,而 CPU 的普通数据访问请求优先级相对较低。不过总线仲裁器仍然会保证CPU得到一半的总线带宽使CPU也能正常工作
DMA中的AHB从设备,也就是DMA自身的寄存器,因为DMA作为一个外设,它自己也会有相应的配置寄存器,而且连接到了总线右边的AHB总线上所以DMA既是总线矩阵的主动单元,也是被动单元,CPU可以对DMA进行配置
注意:CPU或者DMA直接访问Flash的话是只读而不可写的
4,DMA基本结构图(内部细节图)
写代码控制DMA参照下图。外设寄存器和Flash SRAM是转运的两大站点,具体从左到右还是从右到左由参数方向来控制。除此之外还可以从Flash到SRAM,SRAM到SRAM(由于Flash是只读的,DMA不可以进行SRAM到Flash,或者Flash到Flash的转运操作)。转运时外设和存储器两个站点各有三个参数(地址,数据宽度(指定一次转运要按多大的数据宽度进行,可以选择字节Byte,半字HalfWord和字Word,字节就是8位,也就是一次转运一个uint8_t,半字是16位,也就是uint16_t,字是32位,也就是uint32_t,比如转运ADC数据,ADC结果是uint16_t这么大,所以这个参数就要选择半字),地址是否自增),地址是否自增参数的作用是指定一次转运完成后,下一次转运是不是要把地址移动到下一个位置去,比如ADC扫描模式用DMA进行数据转运,外设地址是ADC_DR寄存器,寄存器这里显然地址是不用自增的,而存储器这边地址就需要自增,每转运一个数据后就往后挪个坑,要不然再转就把上次的覆盖掉了。
传输计数器:用来指定总共需要转运几次的。这是一个自减计数器,比如写入5,那DMA就只能转运5次数据,每转运一次计数器的数就会减1,减到0之后DMA就不会再进行数据转运了,并且减到0之后之前自增的地址也会恢复到起始地址的位置,以方便DMA开始新一轮的转运。
自动重装器:当传输计数器减到0后是否要自动恢复到最初的值。如果给5且不使用自动重装器,那转运5次后DMA就结束了;如果使用自动重装器,那转运5次,计数器减到0后,就会立即重装到初始值5。如果想转运一个数组,那一般就是单次模式,转运一轮就结束了;如果是ADC扫描模式+连续转换,那为了配合ADC,DMA也需要使用循环模式,所以这个循环模式和ADC连续模式差不多,都是指定一轮工作完成后是不是立即开始下一轮工作
传输计数器下方就是DMA的触发控制,决定DMA需要在什么时机进行转运,触发源有硬件触发和软件触发,具体选择哪个由M2M这个参数决定(M2M,Memory to Memory,存储器到存储器。给M2M为1时,DMA就会选择软件触发,执行逻辑是以最快的速度连续不断地触发DMA,直到传输计数器清零,而不是调用一个函数执行一次,软件触发和循环模式不能同时使用,否则DMA就无法停止,软件触发一般适用于存储器到存储器的转运。M2M为0。使用硬件触发,硬件触发源可以选择ADC,串口,定时器等,一般都是与外设之间的转运。
开关控制,DMA_Cmd函数,给DMA使能
DMA能够进行转运的条件:1,DMA开关控制使能 2,传输计数器必须大于0 3,必须有触发信号
注意:写传输计数器时必须要先关闭DMA 。
5,数据传输过程
(1)存储器到外设
- DMA 请求:当启动 DMA 传输后,DMA 控制器会向总线矩阵仲裁器发送总线访问请求,请求获得总线使用权。
- 总线仲裁:总线矩阵仲裁器根据各个主设备(包括 DMA 控制器)的请求优先级进行仲裁,将总线使用权分配给 DMA 控制器。
- 数据传输:获得总线使用权后,DMA 控制器按照预先配置的参数,从存储器中读取数据,并将其写入到外设的相应寄存器中。在传输过程中,DMA 控制器会自动更新源地址和目标地址,以及剩余的数据长度。
- 传输完成判断:当传输的数据长度达到预先设置的值时,DMA 传输完成。此时,DMA 控制器会产生一个传输完成中断(如果使能了中断),可以在中断服务函数中进行相应的处理,如提示传输完成、进行下一次传输等。
(2)外设到存储器
- DMA 请求:当外设准备好数据(如 ADC 完成一次转换),会向 DMA 控制器发送 DMA 请求信号。
- 总线仲裁:DMA 控制器接收到请求后,向总线矩阵仲裁器发出总线访问请求。仲裁器依据各主设备(含 DMA 控制器)的请求优先级进行仲裁,将总线使用权分配给 DMA 控制器。
- 数据传输:DMA 控制器获取总线使用权后,按预先配置的参数,从外设的数据寄存器读取数据,并将其写入到存储器的指定地址。在传输过程中,DMA 控制器会自动更新源地址(若外设支持地址递增)和目标地址,以及剩余的数据长度。
- 传输监测:DMA 控制器持续监测传输状态,直至完成预设的数据长度传输。
6,DMA请求
以下结构为上面说到的DMA触发部分的详细结构。共7个DMA1通道,每个通道都有一个数据选择器可以选择软件触发或者硬件触发,EN为开关控制。最左边为硬件触发源,可以看到,每个通道的硬件触发源都是不同的,如果要用ADC1来触发,那必须选择通道1。选择哪个硬件触发源是由对应的外设是否开启了DMA输出来决定的,比如要使用ADC1那就有个库函数叫做ADC_DMACmd。如果都开启了,那就都可以触发。
然后进入仲裁器进行优先级判断,这里的优先级判断类似于中断优先级,每个通道的优先级可以在DMA_CCRx寄存器中设置,共四个等级:最高优先级,高优先级,中等优先级,低优先级。如果两个通道请求有相同的优先级,则较低编号的通道比较高编号的通道有较高优先级。在大容量产品和互联型产品中,DMA1控制器拥有高于DMA2控制器的优先级
7,数据宽度与对齐
在前面的DMA基本结构图看到数据转运的两个站点都有数据宽度这个参数,如果数据参数都一样那就是正常的一个一个转运,如果不一样,处理方式就是下图所现的。
第一列是源端宽度,第二列是目标宽度1,第三列是传输数目。当源端和目标都是8位时,那就将数据从源端挪到目标即可。当源端为8位,目标为16位时,它的操作就是在源端读B0,在目标写00B0,之后读B1,在目标写00B1;这个意思就是如果目标的数据宽度大于源端,那就在目标数据前面多出来的空位补0。源端8位,目标32位也是一样的。
当目标数据宽度小于源端数据宽度时,比如由16位转到8位,现象就是读B1B0,只写入B0,读B3B2,只写入B2,也就是把多出来的高位舍弃掉
8,实例1:数据转运+DMA
这个例子任务是将SRAM里的数组DataA转运到另一个数组DataB中
按照下图进行配置。首先外设站点和存储器站点的起始地址,外设站点起始地址应填写DataA数组的首地址,存储器地址给DataB的首地址,数据宽度两个都是uint8_t,地址是否自增,两个数组位置应该一一对应,所以转运完一个元素后两者的地址都应该自增(p++);如果左边不自增右边自增,那DataB的所有数据都等于DataA[0];如果左边自增右边不自增,那DataB只有DataB[0]有数据,且DataB[0] = DataA[6]。
方向参数:外设站点转运到存储器站点
传输计数器和自动重装:要转运7次,所以传输计数器给7,自动重装暂时不需要
触发选择:使用软件触发,因为是存储器到存储器的数据转运
开关控制:调用DMA_Cmd给DMA使能即可
这里的转运是一种复制转运,转运完成后DataA的数据并不会消失
DMA库函数:
DMA_DeInit恢复缺省设置
DMA_Init初始化
DMA_StructInit结构体初始化
DMA_Cmd使能
DMA_ITConfig中断输出使能
DMA_SetCurrDataCounter设置当前数据寄存器,就是给传输计数器写数据的
DMA_GetCurrDataCounter获取当前数据寄存器,就是返回传输计数器的值
剩下的四个函数获取标志位状态,清除标志位,获取中断状态,清除中断挂起位
DMA_GetFlagStatus参数如下,总共四种标志位,第一个GL是全局标志位,第二个TC是转运完成标志位,第三个HT是转运过半标志位,第四个TE是转运错误标志位
代码实现
(1)定义源端数组和目标数组
#include "stm32f10x.h"
#include "Delay.h"
#include "OLED.h"
uint8_t DataA[] = {0x01, 0x02, 0x03, 0x04};
uint8_t DataB[] = {0, 0, 0, 0};
int main(void)
{
}
接下来开始初始化
- RCC开启DMA时钟
- 调用DMA_Init初始化各个参数。包括外设和存储器站点的起始地址,数据宽度,地址是否自增,方向,传输寄存器,是否需要自动重装,选择触发源,优先级
- 开关控制DMA_Cmd
- 如果选择的是硬件触发,需要在对应的外设调用一下XXX_DMACmd开启一下触发信号的输出
- 如果需要DMA中断就调用DMA_ITConfig开启中断输出,再在相应的NVIC配置中断通道。
- 如果转运完成,传输计数器清0了,这时想给计数器再赋值,步骤:DMA失能,写传输计数器,DMA使能。
(2) 开启时钟
DMA是AHB总线的设备,所以要用AHB开启时钟的函数
#include "stm32f10x.h" // Device header
uint16_t MyDMA_Size;
void MyDMA_Init(uint32_t AddrA, uint32_t AddrB, uint16_t Size)
{
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
}
(3)初始化DMA
成员DMA_PeripheralBaseAddr:外设站点的起始地址。需要写一个32位的地址,比如0x20000000.但是对于SRAM的数组,它的地址是编译器分配的,并不是固定的,所以一般不会直接写0x20000000这样的绝对地址,而是通过数组名来获取地址,将初始化函数参数改为数组,如下初始化函数
成员DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;:外设站点的数据宽度,可选参数如下
成员DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Enable;:外设站点地址是否自增,参数如下
Memory开头的成员指存储器站点
成员DMA_DIR:传输方向。两个参数,第一个是外设站点作为DST(destination,目的地),外设站点作为目的地传输方向就是存储器站点到外设站点;第二个是外设站点作为SRC(source,源头)。
成员DMA_BufferSize:缓冲区大小,就是传输计数器。0~65535
成员DMA_Mode:传输模式,其实就是是否使用自动重装。第一个为循环模式,也就是自动重装
成员DMA_M2M:选择是否是存储器到存储器,其实就是选择硬件触发还是软件触发。Enable就是使用软件触发
DMA_Priority:优先级,按照参数要求给一个优先级即可
#include "stm32f10x.h" // Device header
uint16_t MyDMA_Size;
void MyDMA_Init(uint32_t AddrA, uint32_t AddrB, uint16_t Size)
{
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = AddrA;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Enable;
DMA_InitStructure.DMA_MemoryBaseAddr = AddrB;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStructure.DMA_BufferSize = Size;
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;
DMA_InitStructure.DMA_M2M = DMA_M2M_Enable;
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
DMA_Init(DMA1_Channel1, &DMA_InitStructure);
}
(4)DMA使能
#include "stm32f10x.h" // Device header
uint16_t MyDMA_Size;
void MyDMA_Init(uint32_t AddrA, uint32_t AddrB, uint16_t Size)
{
MyDMA_Size = Size;
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = AddrA;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Enable;
DMA_InitStructure.DMA_MemoryBaseAddr = AddrB;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStructure.DMA_BufferSize = Size;
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;
DMA_InitStructure.DMA_M2M = DMA_M2M_Enable;
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
DMA_Init(DMA1_Channel1, &DMA_InitStructure);
DMA_Cmd(DMA1_Channel1, DISABLE);// 不让DMA开始时就转运,而是等调用DMA_Transfer函数后再转运
}
// 调用一次函数就给启动一次DMA转运
void MyDMA_Transfer()
{
// 重新给传输计数器赋值
DMA_Cmd(DMA1_Channel1, DISABLE);
DMA_SetCurrDataCounter(DMA1_Channel1, MyDMA_Size);
DMA_Cmd(DMA1_Channel1, ENABLE);
// 等待转运完成。如果没有完成就一直等待
while (DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);
DMA_ClearFlag(DMA1_FLAG_TC1); // 手动清除标志位
}
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyDMA.h"
uint8_t DataA[] = {0x01, 0x02, 0x03, 0x04};
uint8_t DataB[] = {0, 0, 0, 0};
int main(void)
{
MyDMA_Init((uint32_t)DataA, (uint32_t)DataB, 4);
while (1)
{
DataA[0] ++;
DataA[1] ++;
DataA[2] ++;
DataA[3] ++;
Delay_ms(1000);
MyDMA_Transfer();
Delay_ms(1000);
}
}
如果想把Flash的数据转运到SRAM中,只需要在DataA前面加一个const,这就把DataA定义在了Flash里了
9,实例2:ADC扫描模式+DMA
左边为ADC扫描模块的流程,有7个通道,触发一次后7个通道依次进行AD转换,转换结果都放到ADC_DR数据寄存器里。在每个单独通道转换完成后,进行一次DMA数据转运。并且目的地址需要自增
外设地址写入ADC_DR这个寄存器的地址,存储器地址可以在SRAM中定义一个数组ADValue,然后把ADValue地址当作存储器地址。之后数据宽度,因为ADC_DR和SRAM数组要的都是uint16_t的数据,所以数据宽度都是16位的半字传输。外设地址不自增,存储器地址自增,传输方向由外设站点到存储器站点。传输计数器为7.如果ADC为单次扫描那DMA的传输计数器可以不自动重装,如果ADC是连续扫描那DMA就可以自动重装。触发选择,DMA转运时机需要和ADC单个通道转换完成同步,所以触发选择ADC的硬件触发,每个通道转运完成后会产生DMA请求去触发DMA转运
在程序中可以使用ADC->DR来访问ADC_DR的地址 ,如下
ADC单次扫描模式+DMA单次模式
#include "stm32f10x.h" // Device header
uint16_t AD_Value[4];
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5);
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;
ADC_InitStructure.ADC_ScanConvMode = ENABLE;
ADC_InitStructure.ADC_NbrOfChannel = 4;
ADC_Init(ADC1, &ADC_InitStructure);
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)AD_Value;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStructure.DMA_BufferSize = 4;
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
DMA_Init(DMA1_Channel1, &DMA_InitStructure);
DMA_Cmd(DMA1_Channel1, ENABLE);
ADC_DMACmd(ADC1, ENABLE);
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1);
while (ADC_GetResetCalibrationStatus(ADC1) == SET);
ADC_StartCalibration(ADC1);
while (ADC_GetCalibrationStatus(ADC1) == SET);
}
void AD_GetValue()
{
DMA_Cmd(DMA1_Channel1, DISABLE);
DMA_SetCurrDataCounter(DMA1_Channel1, 4);
DMA_Cmd(DMA1_Channel1, ENABLE);
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
while (DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);
DMA_ClearFlag(DMA1_FLAG_TC1);
}
ADC连续扫描模式+DMA循环模式
#include "stm32f10x.h" // Device header
uint16_t AD_Value[4];
void AD_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5);
ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5);
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStructure.ADC_ContinuousConvMode = ENABLE;
ADC_InitStructure.ADC_ScanConvMode = ENABLE;
ADC_InitStructure.ADC_NbrOfChannel = 4;
ADC_Init(ADC1, &ADC_InitStructure);
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)AD_Value;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStructure.DMA_BufferSize = 4;
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
DMA_Init(DMA1_Channel1, &DMA_InitStructure);
DMA_Cmd(DMA1_Channel1, ENABLE);
ADC_DMACmd(ADC1, ENABLE);
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1);
while (ADC_GetResetCalibrationStatus(ADC1) == SET);
ADC_StartCalibration(ADC1);
while (ADC_GetCalibrationStatus(ADC1) == SET);
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
}
十,USART串口协议
1,STM32的通信外设模块
下表为STM32中集成的通信外设模块
I2C和SPI有单独时钟线,所以时钟是同步的,接收方可以在时钟信号的指引下进行采样。串口USART,CAN和USB没有时钟线,所以双方需要约定一个采样频率,这就是异步通信,并且还要加一些帧头帧尾等进行采样位置的对齐。
电平特性:USART I2C SPI都是单端信号,也就是它们引脚的高低电平都是对GND的电压差,所以单端信号通信的双方必须要共地,将GND接在一起。CAN USB为差分信号,靠两个差分引脚的电压差来传输信号,通信时可以不需要GND,但USB协议里也有一些地方需要单端信号,所以USB还是需要共地的,使用差分信号可以极大的提高抗干扰特性,所以差分信号一般传输距离和速度都非常高。
2,串口简介
串口可以使单片机和电脑通信,是串口的一大优势。像I2C SPI这些一般都是芯片之间通信,不会接在电脑上
下图第一个设备是USB转串口模块,其上的CH340芯片可以把串口协议转换成USB协议,USB插到电脑上,串口接到芯片上。第二个设备是陀螺仪传感器模块,左右各四个引脚,一边是串口的引脚,另一边是I2C的引脚。第三个是蓝牙串口模块,下面四个脚是串口通信的引脚,上面的芯片可以和手机互联,实现手机遥控单片机功能。
3,串口参数及时序
以下两图即为串口发送一个字节的格式。串口中,每一个字节都装载在一个数据帧里面,每个数据帧都由起始位,数据位和停止位组成,第一张图片数据位有8个,代表一个字节的8位。第二张图片是在最后一个数据位加一个奇偶校验位
串口空闲时默认为高电平,需要传输时需要先发一个起始位(必须是低电平)
波特率:每秒传输码元的个数,单位为码元/s
4,STM32的USART外设
(1)简介
•USART(Universal Synchronous/Asynchronous Receiver/Transmitter)通用同步/异步收发器。S是同步的意思,另外经常遇到的串口叫UART(异步收发器),一般串口也很少使用同步功能,所以USART和UART用起来区别不大。STM32的USART同步模式只是多一个时钟输出,它只支持时钟输出,不支持时钟输入,所以这个同步模式更多的只是为了兼容别的协议或者特殊用途而设计的,并不支持两个USART之间进行同步通信
•USART是STM32内部集成的硬件外设,可根据数据寄存器的一个字节数据自动生成数据帧时序,从TX引脚发送出去,也可自动接收RX引脚的数据帧时序,拼接为一个字节数据,存放在数据寄存器里。当我们配置好了USART电路,直接读写数据寄存器,就能自动发送和接收数据了
自带波特率发生器,最高达4.5Mbits/s,波特率发生器实际上就是一个分频器,比如APB2总线给72MHz的频率,然后波特率发生器进行一个分频,得到我们想要的波特率时钟。一般选择9600或者115200
•可配置数据位长度(8/9)、停止位长度(0.5/1/1.5/2)
可选校验位(无校验/奇校验/偶校验)
•支持同步模式、硬件流控制(如果A设备一直给B设备发送数据,B处理不过来,如果没有硬件流控制,那B就只能抛弃新数据或者覆盖原数据了;如果有硬件流控制,会在硬件电路上多出一根线,如果B没准备好接收就置高电平,如果准备好就置低电平,A接收到B反馈的准备信号,就只会在B准备好时才发数据)、DMA(串口支持DMA进行数据转运,如果有大量数据转运,可以使用DMA转运数据)、智能卡、IrDA、LIN
•STM32F103C8T6 USART资源: USART1(APB2)、 USART2(APB1)、 USART3(APB1)
(2) USART框图
红框内为引脚部分,TX和RX为发送和接收引脚,SW_RX IRDA_OUT/IN这些是智能卡和IrDA通信的引脚,我们不用这些协议。右边黑框,IrDA SIR这些东西也不用管。接着往右看,TX发送脚从发送移位寄存器发送出去,RX接收脚从接收移位寄存器接收。右边阴影框内就是串口的数据寄存器,发送或接收的字节数据都在这里,上面为两个数据寄存器,这两个寄存器占用同一个地址,和51单片机串口的SBUF寄存器一样,在程序上只表现为一个寄存器,就是数据寄存器DR,但实际硬件中分成了两个寄存器,一个用于发送TDR,一个用于接收RDR(TDR只写,RDR只读);然后往下,下面是两个移位寄存器,一个用于发送一个用于接收,发送移位寄存器作用就是把一个字节的数据一位一位地移出去。发送数据寄存器和发送移位寄存器工作方式是比如在某个时刻给TDR写入了0x55这个数据,在寄存器里就是0101 0101,此时硬件检测到写入数据了,它就会检查移位寄存器是不是有数据正在移位,如果没有,0101 0101就会立刻全部发送到移位寄存器准备发送,当数据从数据寄存器发送到移位寄存器时会置一个标志位叫TXE(TX Empty),检查此标志位,如果置1就可以在TDR写入下一个数据了。然后发送移位寄存器就会在下面的发生器控制的驱动下向右移位,一位一位地把数据输出到TX引脚,当数据移位完成后,新的数据就会再次自动地从TDR转移到发送移位寄存器中,如果当前移位寄存器移位还没有完成,TDR数据就会进行等待,一旦移位完成就会立刻转移过去。有了TDR和移位寄存器的双重缓存,可以保证连续发送数据的时候数据帧之间不会有空闲。简单来说这个过程就是数据一旦从TDR转移到移位寄存器了,不管数据有没有移位到TX,都会立刻把下一个数据放到TDR等着,一旦移位完成,新的数据就会立刻跟上。
接收端也类似,数据从RX引脚通向接收移位寄存器,在接收器控制的驱动下一位一位地读取RX电平,先放在最高位然后向右移,移位8次后就能接收一个字节了,因为串口协议规定是低位先行,所以接收移位寄存器是从高位往低位方向移动的,当一个字节移位完成后,这一个字节的数据就会整体地转运到接收数据寄存器RDR里,转移过程中也会置一个标志位RXNE(RX Not Empty),当我们检测到RXNE置1之后,就可以把数据读走了
当然发送还需要加上帧头帧尾,接收还需要剔除帧头帧尾这些操作它内部都会有电路自动执行。
下面左边有硬件数据流控,也就是硬件流控制,它有两个引脚,nRTS是请求发送是输出脚,也就是告诉别人我当前能不能接收,nCTS是清除发送,是输入脚,也就是用于接收别人nRTS信号的。加一个n意思是低电平有效
绿框内的电路用于产生同步的时钟信号,它是配和发送移位寄存器输出的,发送移位寄存器每移位一次,同步的时钟电平就跳变一个周期告诉对方我移出去了一位数据,但是此时钟只支持输出,不支持输入,所以两个USART之间不能实现同步的串口通信。此时钟信号的用途:兼容别的协议(比如串口加上时钟之后就和SPI协议很像,所以有了时钟的串口就可以兼容SPI),另外这个时钟也可以做自适应波特率(比如接收设备不确定发送设备给的什么波特率,那就可以测量一下这个时钟的周期然后再计算得到波特率,此功能需要另外写程序来实现)
中间的唤醒单元,作用是实现串口挂载多个设备,可以在蓝框这里给串口分配一个地址,当发送指定地址时,此设备开始唤醒工作,当发送别的设备地址时,别的设备就唤醒工作。唤醒单元没收到地址就会保持沉默
紫框内为中断控制(配置中断是否能通向NVIC),它所指向的是中断申请位,就是状态寄存器里的各种标志位,TXE RXEN比较重要
最下面为波特率发生器部分,其实就是分频器。这里时钟输入是fPCLKx(x=1,2),USART挂载在APB2所以就是PCLK2的时钟,一般为72MHz,其他USART都挂载在APB1所以是PCLK1的时钟,一般为36MHz。之后时钟/USARTDIV进行分频(此分频支持小数)。分频完成后再除以16得到发送器时钟和接收器时钟通向控制部分。右边如果TE为1就是发送器使能了,则发送器波特率就有效,RE就是接收器使能了,则接收器波特率有效
(3)串口引脚
(4)USART基本结构图
最左边为波特率发生器,用于产生约定的通信速率。经过波特率发生器分频后产生的时钟通向发送控制器和接收控制器,发送控制器和接收控制器用来控制发送移位和接收移位,最后由发送数据寄存器和发送移位寄存器这两个寄存器配合将数据一位一位的发送出去,通过GPIO口的复用输出输出到TX引脚。当数据由数据寄存器转到移位寄存器时会置一个TXE的标志位,我们判断这个标志位就知道是不是可以写下一个数据了。
发送数据寄存器,发送移位寄存器,接收移位寄存器,接收数据寄存器实际上有4个寄存器。但是在软件层面只有一个DR寄存器可以供我们读写,写入DR时数据走上面一条路进行发送;读取DR时数据走下面一条路进行接收。
(5)数据帧
下图为在程序中配置8位字长和9位字长的波形对比。字长就是前面所说的数据为长度
时钟就是之前所说的同步时钟输出的功能,可以看到在每个数据位中间都会有一个时钟上升沿,时钟频率和数据速率是一样的,接收端可以在时钟上升沿进行采样,这样就可以精准定位每一位数据。时钟的最后一位可以通过LBCL控制是否输出,另外此时钟的极性,相位等也可以通过配置寄存器配置。下面的空闲帧和断开帧是局域网协议用的。
接下来再看不同停止位的区别。
STM32可以配置停止位0.5 1 1.5 2这四种,对应波长如下
(6)USART电路输入数据的策略
1,起始位侦测
USART电路输入数据的一些策略
对于串口的输出要比输入简单得多。输出只需要定时翻转TX引脚的高低电平即可,但是输入不仅要保证输入的采样频率和波特率一致还要保证每次输入采样的位置正好处于每一位的正中间,另外输入最好还要对噪声有一定的判断能力
首先,下图为起始位侦测。当输入电路侦测到一个数据帧的起始位后,就会以波特率的频率,连续采样一帧数据,同时,从起始位开始,采样位置就要对其到位的正中间(只要第一位对齐了,后面的就肯定都是对齐的)。为了实现这些功能,首先输入这部分电路对采样时钟进行了细分,它会以波特率的16倍频进行采样(也就是在一位的时间里可以进行16次采样),它的策略是最开始空闲状态为高电平,那采样就一直为1,在某个位置突然采到0就说明在两次采样之间出现了下降沿,如果没有任何噪声那之后应该为起始位,在起始位会进行连续16次采样,没有噪声的话这16次采样应该都为0,但是如果出现噪声的话在某个采样点可能会出现1,这时后续还要再采样几次。根据手册,接收电路会在下降沿出现后的第3次,5次,7次进行第一批采样,在第8次,9次,10次再进行一批采样,且这两批采样都要求每3位里面至少有2个0,就算检测到了起始位(如果三位里有2个0 1个1也算检测到起始位,但是会在状态寄存器里置一个NE,噪声标志位)。如果通过了起始位侦测,接收状态就由空闲变为接收起始位,同时在第8 9 10次采样的位置就正好是起始位的正中间,之后接受的数据为就都在第8 9 10次进行采样
2,数据采样
下图从1到16是一个数据位的时间长度,一个数据位有16个采样时钟。由于起始位侦测已经对齐了采样时钟,所以这里直接就在第8 9 10位采样数据位,为了保证数据准确性这里是连续采样3次,如果1的数量大于0就认为收到1,否则收到0,此时噪声标志位NE也会置1
(7)波特率发生器
波特率发生器就是分频器,发生器和接收器的波特率由波特率寄存器BRR里的DIV确定 ,如下图,DIV分为整数部分和小数部分。波特率和分频系数的关系,用公式(波特率 = fPCLK2/1 / (16 * DIV))来计算。利用库函数可以直接写入需要多少波特率,库函数会自动帮我们计算
5,实例1:串口发送
USB转串口的跳线帽接到VCC和3.3V,表示使用TTL协议的3.3V表示高电平
(1)函数
usart.h里的函数大多数都是增强功能和兼容其他协议的函数,真正常用的很少
下面两个函数用来配置同步时钟输出,包括时钟是否要输出,时钟的极性和相位的等参数,参数比较多所以也是用结构体来配置
以下函数用来开启USART 中断 DMA
设置地址,唤醒,LIN这些函数我们都不用
下面两个函数比较重要。SendDate发送数据(写DR寄存器)。ReceiveData接收数据(读DR寄存器),这两个函数在发送和接收的时候会用到 。DR寄存器内部有四个寄存器,控制发送和接收。这里程序上就比较简单了,写DR就是发送,读DR就是接收
下面的一大段函数是关于智能卡 IrDA的。
最后四为获取标志位函数
(2) 初始化
初始化流程:
- 开启时钟,把需要用到的USART和GPIO时钟打开
- GPIO初始化,把TX配置成复用输出,RX配置成输入
- 配置USART直接使用一个结构体就可以把下图所有参数配置好
- 如果只需要发送功能,就直接开启USART,初始化就结束了。如果需要接收功能还需要配置中断。那就在开启USART之前再加上ITConfig和NVIC的代码即可
- 初始化完成后,如果要发送数据就调用一个发送函数即可就调用一个发送数据的函数即可,如果要接收数据就调用接收的函数。如果要获取发送和接受的状态,就调用获取标志位的函数。
1,开启RCC时钟
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
}
2,GPIO初始化
TX配置为复用推挽输出模式,RX配置为浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所以不使用下拉输入。
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
}
3,配置USART
成员1:USART_BaudRata波特率,可以直接写上需要的波特率数值,比如9600。写入之后,Init函数内部会自动算好9600对应的分频系数,然后写到BRR寄存器
成员2:USART_HardwareFlowControl硬件流控制。取值列表查看可以直接复制这个名称然后ctrl+Alt+空格就可以打开,如下图。这个参数的取值可以是None不使用流控,只用CTS,只用RTS或者CTS RTS都使用
成员3:USART_Mode串口模式。可以选择USART_Mode_TX发送模式和USART_Mode_RX接收模式,如果既需要接收又需要发送 那就写USART_Mode_TX | USART_Mode_RX
成员4:USART_Parity校验位。可以选择USART_Parity_No无校验,USART_Parity_Odd奇校验,USART_Parity_Even偶校验
成员5:USART_StopBits停止位,可以选择USART_StopBits_0_5 USART_StopBits_1 USART_StopBits_1_5 USART_StopBits_2
成员6:USART_WordLength字长,可以选择USART_WordLength_8b(8位) USART_WordLength_9b(9位)
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_Init(USART1, &USART_InitStructure);
}
4,开启USART
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE);
}
5,发送数据函数
调用一次此函数,就可以从TX引脚发送一个字节数据。调用函数USART_SendData要写入的一节数据就写入到TDR了,写完之后还需要等待TDR数据转移到移位寄存器才行,要不然数据还在TDR进行等待,再写入数据就会产生数据覆盖,所以在发送之后,我们还需要等待一下标志位。
可以发送数据0x41等,也可以发送字符'A'(注意单引号)
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1, Byte);
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
}
如下图,标志位置1后不需要手动清零。当下一次使用SendData时标志位会自动清零
6,发送一个数组
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
uint16_t i;
for (i = 0; i < Length; i ++)
{
Serial_SendByte(Array[i]);
}
}
7,发送字符串
void Serial_SendString(char *String)
{
uint8_t i;
for (i = 0; String[i] != '\0'; i ++)
{
Serial_SendByte(String[i]);
}
}
在主函数里用Serial_SendString("HelloWorld");即可发送HelloWorld字符串(写完这个字符串后,编译器会自动补上结束标志位)。如果想要换行需要加\r \n两个转义字符
8,发送一串数字
//次方函数。返回X的Y次方
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{
uint32_t Result = 1;
while (Y --)
{
Result *= X;
}
return Result;
}
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
uint8_t i;
for (i = 0; i < Length; i ++)
{
// 加‘0’为了将十进制数变为字符型
Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');
}
}
9,printf函数移植方法
方法一
先打开工程选项,如下魔法棒图标
将Use MicroLIB勾上,MicroLIB是Keil嵌入式平台优化的一个精简库,等会用到的printf函数就可以用这个精简库
对printf重定向,将printf打印的东西输出到串口。因为printf函数默认是输出到屏幕,单片机没有屏幕,所以要重定向
首先 在串口模块里加上#include <stdio.h>
之后在最后重写fputc函数,将fputc重定向到串口,fputc是printf函数的底层,printf在打印的时候,就是不断调用fputc函数一个个打印的,将fputc函数重定向到串口那printf自然就输出到串口了
int fputc(int ch, FILE *f)
{
Serial_SendByte(ch);
return ch;
}
之后在主函数包含头文件<stdio.h>,然后调用pirntf即可
方法二
上述方法只能有一个printf,重定向到串口1 那串口2再用就不行了。如果多个串口都要用printf函数,这时可以使用sprintf,可以把格式化的字符输出到一个字符串里
先定义一个字符串char string[100];
然后sprintf(string, "Num = %d \r\n", 666);//第一个参数选择打印输出的位置,之后就和上述printf想打印的字符串一样了
最后再来Serial_SendString(String);将字符串String通过串口发送出去
封装sprintf函数
先添加头文件#include<stdarg.h>
然后封装sprintf函数
// 参数format用来接收格式化字符串,之后三个点用来接收后面的可变参数列表
void Serial_Printf(char *format, ...)
{
char String[100];
va_list arg;
va_start(arg, format);
vsprintf(String, format, arg); // sprintf只能接收直接写的参数,对于封装格式需要vsprintf
va_end(arg);
Serial_SendString(String);
}
主函数调用
Serial_Printf("Num=%d\r\n", 666);
打印中文不出现乱码的方式:(使用UTF-8编码)。写下 Serial_Printf("你好世界");
打开工程选项,在杂项控制栏(Misc Controls)写入--no-multibyte-chars即可打印汉字
第二种方式是切换为GB2312编码。切换后先把汉字删掉,然后关闭文件,再打开编码格式就改过来了。串口选择GBK编码
6,实例2:串口发送+接收
在发送的基础上修改。PA10改为上拉输入。USART初始化结构体只需要改模式,改为USART_Mode_Tx | USART_Mode_Rx
对于串口接收,可以使用查询或者中断。如果使用查询配置如下。查询是在主函数里不断判断RXNE标志位,如果置1了就说明收到数据了,那再调用ReceiveData读取DR寄存器即可。读DR操作可以自动清零RXNE
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
uint8_t Serial_RxData;
uint8_t Serial_RxFlag;
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_Init(USART1, &USART_InitStructure);
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
USART_Cmd(USART1, ENABLE);
}
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
uint8_t RxData;
int main(void)
{
OLED_Init();
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);
}
}
}
如果使用中断,代码如下
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
uint8_t Serial_RxData;
uint8_t Serial_RxFlag;
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_Init(USART1, &USART_InitStructure);
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
USART_Cmd(USART1, ENABLE);
}
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
{
Serial_RxData = USART_ReceiveData(USART1);
Serial_RxFlag = 1;
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
7,串口数据包收发
数据包作用是将一个个单独的数据打包起来,方便进行多个字节的数据通信(将多个字节打包为一个整体进行发送)。数据包将属于同一批的数据进行打包和分割,方便接收方识别
串口数据包,通常使用的是额外添加包头和包尾的方式对数据进行分割。数据包格式可以是根据需求自己规定的,以下仅为例子
各种数据类型转换为字节流的问题。数据包都是一个字节一个字节组成的,如果想发送16位整型数据,32位整型数据,float,double,结构体都是没问题的,因为它们的内部都是一个字节一个字节组成的,只需要用uint8_t指针指向它,将其当作一个字节数组发送即可
(1)HEX数据包
固定包长:每个数据包的长度固定不变,数据包前面为包头,后面为包尾。也可以只要包头
可变包长:也就是每个数据包的长度可以不一样
以下定义FF为包头,FE为包尾,如果传输数据本身就是FF和FE可能会引起误判。可以限制载荷数据的范围(不让数据取到FF FE)。另外,如果无法避免载荷数据和包头包尾重复就尽量使用固定长度的数据包。
(2)文本数据包
HEX数据包里,数据都是以原始的字节数据本身呈现的。而在文本数据包里每个字节就经过了一层编码和译码最终表现出文本格式
(3)HEX数据包和文本数据包接收
数据包的发送直接定义数组,存入数据,SendArray发送即可
以下主要讲接收
固定包长数据包的接收。在程序中我们需要设计一个能记住不同状态的机制,在不同状态执行不同操作,同时进行状态的合理转移。状态如下
(4)串口收发HEX数据包代码
以固定包长,含包头包尾(包头为FF,包尾为FE为例),载荷数据固定为4个字节
为了收发数据包,先定义两个缓存区数组。Serial_TxPacket和Serial_RxPacket这4个数据只存储发送或接收的载荷数据,包头包尾不存了。Serial_RxFlag,如果收到一个数据包,就置RxFlag
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
uint8_t Serial_TxPacket[4]; //FF 01 02 03 04 FE
uint8_t Serial_RxPacket[4];
uint8_t Serial_RxFlag;
接下来编写SendPacket的函数:调用此函数,TxPacket数组的4个数据就会自动加上包头包尾发送出去
void Serial_SendPacket(void)
{
Serial_SendByte(0xFF); //发送包头
Serial_SendArray(Serial_TxPacket, 4); //发送数组数据
Serial_SendByte(0xFE); //发送包尾
}
接收数据包代码,在接收中断函数里用状态机执行中断逻辑 。接收数据包,然后把载荷数据存在RxPacket数组里
void USART1_IRQHandler(void)
{
static uint8_t RxState = 0; //static修饰的变量类似于全局变量,函数进入只会初始化一次0,函
//数退出后数据仍然有效,与全局变量不同的是静态变量只能在此函数使用
static uint8_t pRxPacket = 0;//指示接收到哪一个数据了
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
{
uint8_t RxData = USART_ReceiveData(USART1);
if (RxState == 0)
{
if (RxData == 0xFF)
{
RxState = 1;
pRxPacket = 0;
}
}
else if (RxState == 1)
{
Serial_RxPacket[pRxPacket] = RxData;
pRxPacket ++;
if (pRxPacket >= 4)
{
RxState = 2;
}
}
else if (RxState == 2)
{
if (RxData == 0xFE)
{
RxState = 0;
Serial_RxFlag = 1;
}
}
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
主函数:
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
#include "Key.h"
uint8_t KeyNum;
int main(void)
{
OLED_Init();
Key_Init();
Serial_Init();
OLED_ShowString(1, 1, "TxPacket");
OLED_ShowString(3, 1, "RxPacket");
Serial_TxPacket[0] = 0x01;
Serial_TxPacket[1] = 0x02;
Serial_TxPacket[2] = 0x03;
Serial_TxPacket[3] = 0x04;
while (1)
{
if (Serial_GetRxFlag() == 1)
{
OLED_ShowHexNum(4, 1, Serial_RxPacket[0], 2);
OLED_ShowHexNum(4, 4, Serial_RxPacket[1], 2);
OLED_ShowHexNum(4, 7, Serial_RxPacket[2], 2);
OLED_ShowHexNum(4, 10, Serial_RxPacket[3], 2);
}
}
}
这样接收数据可能会遇到问题。就是RxPacket数组是一个同时被写入又同时被读出的数组。在中断函数里我们会依次写入他,在主函数里我们又会一次读出它,这样可能会使数据包混在一起。比如读出速度太慢,前面两个数据刚读出来等了一会才继续往后读取,这时后面的数据就可能会刷新为下一个数据包的数据,也就是读出的数据一部分属于上一个数据包一部分属于下一个数据包。可以在接收部分加入判断,在每个数据包读取处理完毕后再接收下一个数据包。很多情况下其实可以不进行处理 ,因为HEX数据包多是用于传输各种传感器的每个独立数据,比如陀螺仪的X Y Z轴数据,温湿度数据等,它们相邻数据包之间的数据具有连续性。
(5)串口收发文本数据包代码
可变包长,含包头包尾,以@为包头,换行的两个符号为包尾,中间的载荷数量不固定。
以下只写接收部分,发送的话不方便像HEX数组一样一个个更改,所以发送直接在主函数SendString或者printf就可以了
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
char Serial_RxPacket[100]; //"@MSG\r\n"
uint8_t Serial_RxFlag;
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_Init(USART1, &USART_InitStructure);
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
USART_Cmd(USART1, ENABLE);
}
void USART1_IRQHandler(void)
{
static uint8_t RxState = 0;
static uint8_t pRxPacket = 0;
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
{
uint8_t RxData = USART_ReceiveData(USART1);
if (RxState == 0)
{
if (RxData == '@' && Serial_RxFlag == 0)
{
RxState = 1;
pRxPacket = 0;
}
}
else if (RxState == 1)
{
if (RxData == '\r')
{
RxState = 2;
}
else
{
Serial_RxPacket[pRxPacket] = RxData;
pRxPacket ++;
}
}
else if (RxState == 2)
{
if (RxData == '\n')
{
RxState = 0;
Serial_RxPacket[pRxPacket] = '\0'; //加入结束标志位
Serial_RxFlag = 1;
}
}
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
8,扩展:FlyMcu烧录软件和STLINK Utility
FlayMcu可以通过串口给STM32下载程序 。STLINK Utility是配和STLINK使用的一个工具
(1)FlyMcu
此软件和51单片机的STC-ISP类似(可以通过串口给51单片机下载程序)
首先用USB转串口(必须用USART1,因为这个芯片的串口下载只适配了USART1)连接单片机和电脑
与51单片机操作步骤一样,将要烧录的代码生成HEX文件。生成的HEX文件在Objects文件下
先点击搜索串口,然后在port处选择串口通信的com号,bps(波特率)可以保持默认的115200
然后点击三个点的按钮选择程序文件
在开始编程之前,需要配置BOOT引脚让STM32执行BootLoader程序
找到黄色的跳线帽,将配置BOOT0的跳线帽拔下来,插在右边两个针脚,配置BOOT0为1.然后按一下复位键即可(STM32只有在刚复位时才会读取BOOT引脚)。这样芯片就进入BootLoader程序了,STM32执行的程序是不断接收USART的数据并刷新到主闪存
下载程序结束后,再将BOOT引脚恢复成开始的位置,再按一下复位
如果想要在程序下载完成后直接运行(不用复位),这时可以勾选编程后执行,并且取消勾选编程到FLASH时写选项字节。最终界面如下。但这个是一次性的,如果不切换跳线帽,再按一次复位就又进入BootLoader模式了
其他功能:读FLASH功能可以将芯片的程序读出来,不过STM32可以读保护。读出的文件是.bin格式的
清除芯片功能可以把主程序区域全部擦除
都器件信息可以把芯片的序列号信息,FLASH容量,SRAM容量读出来
选项字节,前面说过选项字节用途是存储一些独立于程序代码的配置参数,如下图所示可以点击设置选项字节按钮,选择STM32F1这一项。就可以看到选项字节里的参数了,包括读保护,如下图
如果读保护选择阻止读出,再回到Keil下载程序时就会失败,这时在回到这个软件取消一下即可。另外,在取消读保护的同时会同时清空芯片程序。
接着下面还有硬件选项字节,包括看门狗,停机和待机模式不产生复位。然后就是写保护,可以对FLASH的每几个页单独进行写保护,比如在主程序的最后几页写了一些自定的数据,不想在下载时被擦除,就可以把最后几页设置写保护锁起来。
(2)STLINK Utility
下图即为软件主界面
将STLINK连接好,然后点击下图指针所在的按钮进行连接。连接按钮右边的为断开连接按钮,断开连接按钮右边的橡皮擦为擦除芯片
连接好之后就会出现下图所示的器件信息
点击保存就可以保存此时STM芯片内的程序,可以保存.hex或者.bin文件
要下载程序的话先点击第一个按钮打开文件(支持.hex和.bin),然后点击编程按钮下载程序
选项字节的配置:点击菜单栏Target-Option Bytes,如下
第一个为写保护使能和失能。第二块为硬件选项,灰色选项表示这个型号芯片没有。第三块为用户参数。最后一块是写保护。配置好之后直接点击Apply就可以直接单独更改选项字节的参数了,不像FlyMcu必须要下载程序才能顺便更改选项字节
十一,I2C通信
共分为两大块:第一块介绍协议规则,然后用软件模拟的形式来实现协议。第二块,介绍STM32的I2C外设,然后用硬件来实现协议。
I2C是同步时序,软件模拟协议也非常方便,目前也有很多软件模拟I2C的代码
在51单片机课程中使用的是AT24C02这个存储器模块来学习I2C(如下左图),本次使用MPU6050这个陀螺仪加速度传感器学习I2C
I2C作用:通过通信线实现单片机读写外挂模块寄存器的功能,在指定位置写寄存器和在指定位置读寄存器
1,简介
作为一个通信协议它必须在软件和硬件上都作出规定,硬件上规定就是电路如何连接(端口的输入输出模式是啥样) 。软件上规定就是时序是如何定义,字节如何传输,高位先行还是低位先行,一个完整的时序由那些部分构成等。硬件规定和软件规定配合起来就是一个完整的通信协议
2,I2C硬件规定(硬件电路)
下图即为I2C的一个典型电路模型(一主多从)。CPU为主机,权力很大,包括对SCL线的完全控制,在空闲状态下,主机可以主动发起对SDA的控制,只有在从机发送数据和从机应答的时候主机才会转交SDA的控制权给从机。从机权力小,对于SCL时钟线在任何时刻都只能被动的读取,对于SDA数据线从机不允许主动发起对SDA的控制
所有设备的SDA和SCL连在一起,且设备的SCL和SDA引脚均要配置为开漏输出模式。SCL和SDA各添加一个上拉电阻,阻值一般为4.7Ω左右。这样的结构有一个线与的现象,就是只要有任意一个或多个设备输出了低电平,总线就处于低电平,只有所有设备都输出高电平,总线才处于高电平。I2C可以利用此特征执行多主机模式下的时钟同步和总线仲裁,所以即使SCL可以在一主多从模式下用推挽输出,但它仍采用了开漏加上拉输出的模式,因为在多主机模式下会利用这个特征
3, I2C软件规定(时序)
(1)I2C时序基本单元
起始条件和终止条件都由主机产生,所以在总线空闲状态时,从机必须浮空
由于有时钟线进行同步,所以如果主机一个字节发送一半,突然进中断了,不操作SCL和SDA了,那时序就会在中断位置不断拉长,SCL和SDA电平都暂停变化,传输也完全暂停,等中断结束后主机再回来继续操作,这就是同步时序的好处
1,起始条件
空闲状态下,SCL和SDA都处于高电平状态
SCL高电平期间,SDA从高电平切换到低电平。当SDA处于低电平之后,主机将SCL也置为低电平(一方面占用此总线,另一方面也是为了方便基本单元的拼接。之后保证除了起始和终止条件,每个时序单元的SCL都是以低电平开始低电平结束)
2,终止条件
SCL高电平期间,SDA从低电平切换为高电平
3,发送一个字节
SCL低电平期间,主机将数据位依次放在SDA线上(高位先行),然后释放SCL,从机在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次即可发送一个字节。主机发送数据过程:在SCL低电平期间,主机如果想发送0,就拉低SDA到低电平,如果想发送1就放手SDA回弹到高电平,然后主机松手时钟线,SCL回弹到高电平,高电平期间从机读取SDA,一般在SCL上升沿这个时刻,从机就已经读取完成了。主机在放手SCL一段时间后就可以继续拉低SCL传输下一位了
4,接收一个字节
SCL低电平期间,从机将数据位依次放到SDA线上(高位先行),然后主机释放SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节(主机在接收之前,需要释放SDA)。由于SCL是主机控制的所以从机的数据变换基本上都是贴着SCL下降沿进行的,主机可以在SCL高电平的任意时刻读取
5,发送应答
接收一个字节之后,要给从机一个应答位,告诉从机是否还要继续发,如果从机发送一个数据后得到主机的应答,那从机就会继续发送,如果没有得到主机的应答,那从机就会释放SDA,交出SDA控制权
6,接收应答
主机在发送完一个字节之后,在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答(主机在接收之前,需要释放SDA)
我们在调用发送一个字节之后,就要紧跟着调用接收应答的时序,用来判断从机是否收到刚才给它的数据,如果收到了,那么在应答位这里主机释放SDA的时候从机就应该立刻把SDA拉下来,然后在SCL高电平期间主机读取应答位
(2)I2C完整时序
需要给每个从设备都确定一个唯一的设备地址,主机在起始条件之后先发一个字节叫一下从机名字。所有从机都会收到这个字节并和自己的设备地址进行比较,如果不一样之后的时序就不进行接收。从机设备地址在I2C协议标准里分为7位地址和10位地址,其中7位地址比较简单而且应用广泛。每个I2C设备出厂时厂商都会为它分配一个7位的地址,可以在芯片手册中找到。一般不同型号的芯片地址不相同,相同型号的芯片地址都是一样的,如果有相同的芯片挂载在同一条总线上,就需要用到地址中的可变部分(一般器件地址的最后几位在电路中是可以改变的,比如MPU6050地址的最后一位就可以有板上的AD0引脚确定0或者1,再比如AT14C02地址的最后三位,都可以由板子上的A0 A1 A2引脚确定)
1,指定地址写
对于指定设备(从机地址),在指定地址()从机内部的寄存器地址下写入指定的数据
从左向右分析上图,空闲状态两者都为高电平,主机需要给从机写入数据时,,首先,SCL高电平期间拉低SDA,产生起始条件,起始条件之后紧跟发送一个字节的时序,字节内容必须是从机地址+读写位。接着紧跟着的单元就要是接收从机应答位, 在此之前,主机要释放SDA,从机发送完应答位并且主机在SCL高电平时读完之后,从机释放SDA,SDA回到高电平。之后继续发送一个字节送到指定设备内部,一般第二个字节可以是寄存器地址或者是指令控制字等,然后同样为从机应答。之后再发送一个字节,此字节就是主机想要写入到寄存器里的内容了,然后同样为从机应答,如果主机不需要再传输了就可以产生停止条件,停止条件之前先拉低SDA,为后续SDA的上升沿做准备
2,当前地址读
对于指定设备,在当前地址指针指示的地址下,读取从机数据
最开始还是产生起始条件,起始条件之后主机必须先调用发送一个字节来进行从机的寻址和指定读写标志位,然后从机应答位。由于主机发出的是读操作,所以第一个字节之后,传输方向就要反向,SDA控制权交给从机,主机调用接收一个字节的时序,进行接收操作。但是主机还没有指定要读哪个寄存器,主机没有指定寄存器的地址,这时就需要当前地址指针,在从机中,所有寄存器被分配到了一个线性区域中,并且会有一个单独的指针变量指示着其中一个寄存器,这个指针上电默认一般指向0地址,并且每写入一个字节和读出一个字节后,这个指针就会自动自增一次移动到下一个位置。比如刚刚调用了指定地址写的时序,在0x19位置写入了0xAA,那么指针就会+1,移动到0x1A的位置,再调用当前地址读的时序,返回的就是0x1A地址下的值,再调用一次返回的就是0x1B地址下的值,以此类推。由于当前地址读并不能指定读的地址,所以这个时序用的不是很多
3,指定地址读(复合格式)
对于指定设备,在指定地址下,读取从机数据。
这个时序的前两个字节的数据和指定地址写是一样的,。只指定了地址还没来得及写就调用当前地址读。(指定地址写+当前地址读)(注意其中在指定地址写后不给写入的数据直接来一个起始条件(在之前也可以来个停止条件)再当前地址读,因为读写标志位只能跟着起始条件的第一个字节)
注意:地址读完之后一定要给从机一个非应答,以此来停止从机对SDA的控制权。如果主机读完仍然给从机应答了,从机就会认为主机还想要数据,就会继续发送下一个数据,而此时主机想产生停止条件,SDA可能会因为被从机拽住了而不能正常弹回高电平。
4,MPU6050
(1)简介
3轴加速度传感器,3轴陀螺仪传感器,所以加起来一共六个轴。如果芯片内再集成一个3轴的磁场传感器,测量XYZ轴的磁场强度,那就叫做9轴姿态传感器。如果再集成一个气压传感器(反映高度信息,海拔越高气压越低)测量气压大小,那就叫做10轴传感器。
任何一种传感器都不能获得精确且稳定的欧拉角,要想获得精确且稳定的欧拉角就必须进行数据融合,把这几种传感器结合起来,综合多种传感器数据取长补短才能获得精确且稳定的欧拉角,常见的数据融合算法有互补滤波卡尔曼滤波等
(2)加速度计结构原理
下图为加速度传感器,在XYZ轴这个芯片内部都分别放置一个加速度计。加速度计如下图2,中间小滑块可以左右移动去压缩或拉伸两边的弹簧,滑块移动带动上面的电位器滑动,这个加速度计实际上就是一个弹簧测力计,利用F=ma来测量加速度,静止放在地球上则测得加速度为g,自由落体时测得加速度为0,向左倾斜就可以综合两个力求一个三角函数测得向左的倾角,不过此倾角只有在芯片静置时才是正确的。所以使用加速度计求角度只能在物体静止时使用。加速度计具有静态稳定性,不具有动态稳定性
(3)陀螺仪结构原理
下图为三轴陀螺仪传感器机械模型。中间是一个有一定质量的旋转轮,外面是3个轴的平衡环,当中间的旋转轮高速旋转时,根据角动量守恒原理,这个旋转轮具有保持它原有角动量的趋势,这个趋势可以保持旋转轴方向不变,当外部物体的方向转动时,内部的旋转轴方向并不会转动,这就会在平衡环连接处产生角度偏差,如果在连接处放一个旋转的电位器测量电位器的电压就能得到旋转角度了,这样分析,陀螺仪应该是可以直接得到角度的,但是MPU6050并不能直接测量角度(可能是结构的差异或者工艺的限制)。芯片内部的陀螺仪实际上是角速度,而不是角度,陀螺仪测量XYZ轴的角速度值,对角速度积分即可得到角度,与加速度计测角度一样,这个角速度积分得到的角度也具有局限性,就是当物体静止时角速度值会因为噪声无法完全归零,然后经过积分的不断累积,这个小噪声就会导致计算出来的角度产生缓慢的漂移。不过这个角度不会受物体运动的影响,所以陀螺仪具有动态稳定性,不具有静态稳定性
加速度计具有静态稳定性,陀螺仪具有动态稳定性,两者取长补短进行以下互补滤波就能融合得到的静态和动态都稳定的姿态角
(4)MPU6050参数
····························1101001(AD0=1)如果在程序中用十六进制表示1两种表示方式,以1101000地址为例。第一种就是单纯把这7位二进制数转换为十六进制,110 1000低4位和高3位切开转换为十六进制就是0x68,由于发送字节时最后一位为读写标志位,所以如果认为0x68是从机地址的话就要把0x68左移1位,再按位或上读写位。另一种方式是把0x68左移1位后的数据当作从机地址,0x68左移一位后是0xD0。
(5)硬件电路
以下为MPU6050模块的原理图。可以看到SCL和SDA模块内部已经内置了两个4.7K的上拉电阻了,所以在接线时直接把SCL和SDA接在GPIO口即可。XCL和XDA是芯片里面的主机I2C通信引脚,设计这两个引脚是为了扩展芯片功能,MPU6050是一个六轴姿态传感器,但是只有加速度计和陀螺仪的六个轴融合出来的姿态角是有缺陷的,这个缺陷就是绕Z轴的角度,也就是偏航角,它的漂移无法通过加速度计进行纠正,所以这时候就需要带个指南针提供长时间的稳定偏航角进行参考,来对陀螺仪感知的方向进行纠正,这就是九轴传感器多出的磁力计的作用,另外如果制作无人机还要气压计进行高度的的参考。所以根据项目要求,六轴传感器可能不够用需要进行扩展,这时XCL和XDA就可以起作用了,它俩通常是外接磁力计或者气压计,这样MPU6050的主机接口就可以直接访问这些扩展芯片的数据,将这些数据读取到MPU6050里,在MPU6050里面会有DMP单元,进行数据融合和姿态解算,当然如果不需要MPU6050的解算功能的话,也可以把磁力计或者气压计直接挂载在SCL和SDA总线上
左上角LDO 为供电的逻辑。MPU6050芯片的VDD供电为2.375~3.46V,属于3.3V供电设备,不能直接接5V,所以为了扩大供电范围,就加了3.3V稳压器,输入端电压可以在3.3V到5V之间
INT引脚为中断输出引脚,可以配置芯片内部的一些事件,来触发中断引脚的输出,比如数据准备好了,I2C主机错误等,另外芯片内部还内置了一些实用的小功能,比如自由落体检测,运动检测,零运动检测等,这些信号都可以触发INT引脚产生电平跳变,需要时可以进行中断信号的配置。
(6)MPU6050框图
下图为整个芯片内部结构,左上角为时钟系统,有时钟输入角CLKIN和输出脚CLKOUT。不过我们一般使用内部时钟,在上图硬件电路可以看到CLKIN直接接GND,CLKOUT没有引出
下面灰色的部分就是芯片内部的传感器。另外此芯片还内置了Temp Senso温度传感器,可以用它测量温度。传感器数据统一放到数据寄存器Signal Conditioning。这个芯片内部转换都是全自动进行的,每个ADC输出对应16位数据寄存器,不存在数据覆盖问题。除此之外传感器前面都有一个Self test自测单元用来验证芯片好坏,当启动芯片后,芯片内部会模拟一个外力施加在传感器上,这个外力导致传感器数据会比平时大一些,我们可以使能自测读取数据,再失能自测读取数据,两个数据一相减,得到的数据就叫自测响应,芯片手册会给一个数据范围,如果自测响应在这个范围内就说明芯片没问题。
左下角的Charge Pump叫做电荷泵(也叫充电泵),CPOUT引脚需要外接一个电容(具体何种电容手册里有说明) 。电压泵是一种升压电路(电池和电容并联,电池给电容充电后,电容有和电池同大小电压,这时将电容作为电池与原电池串联,这样就相当于两倍于电池电压的电源,但是电容中电荷很少,所以串并联要快速切换,以此达到升压的目的),由于陀螺仪内部需要高电压支持,所以设计了一个电压泵进行升压。Signal Conditioning右边一大块就是寄存器和通信接口部分了。FIFO为先入先出寄存器,可以对数据流进行缓存(本节暂时不用),配置寄存器Config Registers可以对内部各个电路进行配置,Sensor Registers传感器寄存器也就是数据寄存器,存储了各个传感器的数据。数据运动处理器(DMP)是芯片内部自带的一个姿态解算的硬件算法。
接着为通信接口部分,上面部分(/CS AD0 SCL SDA)为从机的I2C和SPI通信接口,用于和STM32通信,下面部分(AUX_CL AUX_DA)为主机的I2C通信接口,用于和MPU6050扩展的设备进行通信,前面有一个接口旁路选择器(Serial Interface Bypass Mux),实际上是一个开关,如果拨到上面,辅助的I2C引脚就和正常的I2C引脚接到一起,这样两路总线就合在一起了,STM32可以控制所有设备,如果拨到下面,辅助的I2C引脚就由MPU6050控制,两条I2C总线独立分开这时STM32控制MPU6050,MPU6050控制扩展设备。
注意下面这句话:所有的寄存器上电默认值都为0x00,除了寄存器107和117,117为ID号,也就是设备地址。107为电源管理寄存器1,上电默认为0x40,也就是SLEEP位为1,所以此芯片上电默认就是睡眠模式,我们在操作它之前要先解除睡眠,否则操作其他寄存器是无效的。
5,软件I2C读写MPU6050
SCL和SDA接在任意两个GPIO引脚即可
首先建立I2C通信层的.c和.h模块,在通信层里写好I2C底层的GPIO初始化和六个时序基本单元。写好I2C通信层后,我们再建立MPU6050的.c和.h模块,在此层将基于I2C通信的模块来实现指定地地址读和指定地址写,再实现写寄存器对芯片的配置,读寄存器得到传感器数据。最后在main.c里调用MPU6050的模块。初始化,拿到数据显示数据
MyI2C.c文件
软件I2C两个任务:1,把SCL和SDA全都初始化为开漏输出模式。2,把SCL和SDA置高电平(当前接线,SCL为PB10,SDA为PB11).初始化代码如下
void MyI2C_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);
}
在写基本时序单元时,为了定义方便,便于修改,又方便后续延时操作,对修改GPIO_Pin_10和GPIO_Pin_11我们都将其封装到函数内,方便理解。当我们需要替换端口或者将这段程序移植到别的单片机时,就只需要对下面的四个函数进行修改即可
#include "Delay.h"
//BitAction是一个通常用于位操作的枚举类型或数据类型
void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
Delay_us(10);// 单片机主频比较快时加一些延时。对于STM32F1系列,即使不加延时,引脚的翻转速度
// MPU6050也跟得上
}
void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}
uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);
return BitValue;
}
起始条件
void MyI2C_Start(void)
{
MyI2C_W_SDA(1);
MyI2C_W_SCL(1); // 将SCL和SDA都释放。最好把SDA的放在前面
MyI2C_W_SDA(0);
MyI2C_W_SCL(0); // 先拉低SDA,再拉低SCL
}
最好把SDA的放在前面释放:可以看一下下图的指定地址读的时序图。第一个起始单元SCL和SDA都是高电平,这时限释放哪一个都是一样的,但是后续会有重复起始条件Sr,Sr最开始SCL是低电平,SDA电平不确定,所以保险起见,我们趁SCL是低电平,先确保SDA释放,再释放SCL。这样Start就可以兼容起始条件和重复起始条件了
终止条件
//如果stop开始时SCL和SDA都已经是低电平了,那就先释放SCL,再释放SDA就行了。但是在这个时序开始时,
//SDA并不一定是低电平,所以为了确保之后释放SDA能产生上升沿,我们要在时序单元开始时先拉低SDA,再释
//放SCL和SDA
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0);
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
}
发送一个字节
// 发送一个字节时序开始时,SCL为低电平。实际上除了终止条件,其他所有的单元我们都会保证SCL以低电平结
//束,这样方便各个单元的拼接
//将字节数据高位优先的顺序放到SDA线上,每放完一位就释放SCL拉低SCL的操作
void MyI2C_SendByte(uint8_t Byte)
{
uint8_t i;
for (i = 0; i < 8; i ++)
{
MyI2C_W_SDA(!!(Byte & (0x80 >> i)));
MyI2C_W_SCL(1); // 释放SCL后,从机会立刻把刚才放在SDA线上的数据取走
MyI2C_W_SCL(0); // 拉低SCL,放下一位数据
}
}
接收一个字节
uint8_t MyI2C_ReceiveByte(void)
{
uint8_t i, Byte = 0x00;
MyI2C_W_SDA(1); // 主机释放SDA,防止对从机写入数据造成干扰
for (i = 0; i < 8; i ++)
{
MyI2C_W_SCL(1);
if (MyI2C_R_SDA()){Byte |= (0x80 >> i);}
MyI2C_W_SCL(0); // 低电平期间,从机就会把下一位数据放到SDA上
}
return Byte;
}
发送应答
void MyI2C_SendAck(uint8_t AckBit)
{
MyI2C_W_SDA(AckBit); // 主机将AckBit放到SDA上
MyI2C_W_SCL(1); // SCL高电平,从机读取应答
MyI2C_W_SCL(0); // SCL低电平进入下一个时序单元
}
接收应答
uint8_t MyI2C_ReceiveAck(void)
{
uint8_t AckBit;
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
AckBit = MyI2C_R_SDA();
MyI2C_W_SCL(0);
return AckBit;
}
MyI2C.c完整文件
#include "stm32f10x.h" // Device header
#include "Delay.h"
void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
Delay_us(10);
}
void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
Delay_us(10);
}
uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);
return BitValue;
}
void MyI2C_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);
}
void MyI2C_Start(void)
{
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
MyI2C_W_SDA(0);
MyI2C_W_SCL(0);
}
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0);
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
}
void MyI2C_SendByte(uint8_t Byte)
{
uint8_t i;
for (i = 0; i < 8; i ++)
{
MyI2C_W_SDA(!!(Byte & (0x80 >> i)));
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
}
uint8_t MyI2C_ReceiveByte(void)
{
uint8_t i, Byte = 0x00;
MyI2C_W_SDA(1);
for (i = 0; i < 8; i ++)
{
MyI2C_W_SCL(1);
if (MyI2C_R_SDA()){Byte |= (0x80 >> i);}
MyI2C_W_SCL(0);
}
return Byte;
}
void MyI2C_SendAck(uint8_t AckBit)
{
MyI2C_W_SDA(AckBit);
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
uint8_t MyI2C_ReceiveAck(void)
{
uint8_t AckBit;
MyI2C_W_SDA(1);
MyI2C_W_SCL(1);
AckBit = MyI2C_R_SDA();
MyI2C_W_SCL(0);
return AckBit;
}
MyI2C.h文件
#ifndef __MYI2C_H
#define __MYI2C_H
void MyI2C_Init(void);
void MyI2C_Start(void);
void MyI2C_Stop(void);
void MyI2C_SendByte(uint8_t Byte);
uint8_t MyI2C_ReceiveByte(void);
void MyI2C_SendAck(uint8_t AckBit);
uint8_t MyI2C_ReceiveAck(void);
#endif
接下来只需要用.c 文件里的各函数即可拼接成指定地址写,指定地址读等完整时序
MPU6050.c文件
此模块是建立在MPU6050之上的,所以要include"MyI2C.h"
指定地址写寄存器函数
#define MPU6050_ADDRESS 0xD0
// RegAddress为8位寄存器地址,Data为8位数据
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();
MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();
MyI2C_SendByte(Data); //如果想指定地址多写几个字节的话,就可以用for循环将此句和下面一句套起
//来多执行几遍,然后依次把一个数组的多个字节发送出去
MyI2C_ReceiveAck();
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();
MyI2C_SendByte(MPU6050_ADDRESS | 0x01); // 读写位为1
MyI2C_ReceiveAck();
Data = MyI2C_ReceiveByte(); //如果要读取多个1字节将其用for嵌套
MyI2C_SendAck(1); // 不给从机应答,停止从机发送数据
MyI2C_Stop();
return Data;
}
为了配置MPU6050硬件初始化,可以把MPU6050的硬件寄存器地址(十六进制)进行宏定义,由于MPU6050寄存器较多,所以单独用一个文件存放宏定义,MPU6050_Reg.h
#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
MPU6050.c完整文件
#include "stm32f10x.h" // Device header
#include "MyI2C.h"
#include "MPU6050_Reg.h"
#define MPU6050_ADDRESS 0xD0
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
MyI2C_Start();
MyI2C_SendByte(MPU6050_ADDRESS);
MyI2C_ReceiveAck();
MyI2C_SendByte(RegAddress);
MyI2C_ReceiveAck();
MyI2C_SendByte(Data);
MyI2C_ReceiveAck();
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();
MyI2C_SendByte(MPU6050_ADDRESS | 0x01);
MyI2C_ReceiveAck();
Data = MyI2C_ReceiveByte();
MyI2C_SendAck(1);
MyI2C_Stop();
return Data;
}
void MPU6050_Init(void)
{
MyI2C_Init();
//MPU6050读寄存器可以,但是写寄存器需要先解除芯片睡眠
//睡眠模式由电源管理寄存器1(PWR_MGMT_1)的SLEEP位控制的
//直接将这个寄存器写为0x00就可以解除睡眠模式,调用如下函数MPU6050_WriteReg(0x6B, 0x00)即可、
/具体每一位的含义参考手册
MPU6050_WriteReg(MPU6050_PWR_MGMT_1, 0x01);
MPU6050_WriteReg(MPU6050_PWR_MGMT_2, 0x00);
MPU6050_WriteReg(MPU6050_SMPLRT_DIV, 0x09); //采样率分频寄存器。值越小越快。0x09十分频
MPU6050_WriteReg(MPU6050_CONFIG, 0x06); //配置寄存器,第一个配置外部同步,不需要都给0。
//然后是数字低通滤波器,根据需求来
MPU6050_WriteReg(MPU6050_GYRO_CONFIG, 0x18);//陀螺仪配置寄存器,前三位为自测使能。第四位
//第五位为满量程选择,最大量程为11,根据需求来,后三位无关位
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG, 0x18);//加速度计配置寄存器,前几位与陀螺仪配置寄
//存器相同,最后两位为高通滤波器,用不到给00
}
//上述配置之后,陀螺仪内部就在不断进行数据转换了,输出数据存放在数据寄存器里
//获取数据寄存器的函数。此函数需要返回6个uint16_t的数据,分别表示XYZ的加速度值和陀螺仪值
//利用指针实现函数多返回值的函数。之后会在主函数内定义变量,通过指针将这些变量的地址传到子函数
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ,
int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ)
{
uint8_t DataH, DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_H); //高8位
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L); //低8位
*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次才读取完12个寄存器。
//实际上,可以使用I2C读取多个字节的时序,从一个基地址开始连续读取一片的寄存器
//获取ID号函数
uint8_t MPU6050_GetID(void)
{
return MPU6050_ReadReg(MPU6050_WHO_AM_I);
}
MPU6050.h文件
#ifndef __MPU6050_H
#define __MPU6050_H
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);
void MPU6050_Init(void);
uint8_t MPU6050_GetID(void);
void MPU6050_GetData(int16_t *AccX, int16_t *AccY, int16_t *AccZ,
int16_t *GyroX, int16_t *GyroY, int16_t *GyroZ);
#endif
main.c函数
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MPU6050.h"
uint8_t ID;
int16_t AX, AY, AZ, GX, GY, GZ;
int main(void)
{
OLED_Init();
MPU6050_Init();
OLED_ShowString(1, 1, "ID:");
ID = MPU6050_GetID();
OLED_ShowHexNum(1, 4, ID, 2);
while (1)
{
MPU6050_GetData(&AX, &AY, &AZ, &GX, &GY, &GZ);
OLED_ShowSignedNum(2, 1, AX, 5);
OLED_ShowSignedNum(3, 1, AY, 5);
OLED_ShowSignedNum(4, 1, AZ, 5);
OLED_ShowSignedNum(2, 8, GX, 5);
OLED_ShowSignedNum(3, 8, GY, 5);
OLED_ShowSignedNum(4, 8, GZ, 5);
}
}
6,I2C通信外设
(1)简介
I2C也可以有硬件收发器自动操作。对于I2C这样的同步时序软件实现起来相比更加灵活(所以I2C软件模拟的情况还是非常多的)。硬件I2C执行效率高,可以节省软件资源,功能强大,可以实现完整的多主机通信模型,时序波形规整,通信速率快。如果只是简单应用可以选择软件I2C,如果对性能指标要求比较高就可以考虑硬件I2C
对于多主机模式,又分为固定多主机和可变多主机,固定多主机就是这条总线上有两个或多个固定的主机(当多个主机同时想控制总线时就会产生总线冲突,这时就需要总线仲裁,仲裁失败一方让出总线控制权);可变多主机就是I2C总线上挂载多个设备,总线上没有固定的从机和主机,任何一个设备都可以在总线空闲时作为主机,然后指定其他任何一个设备进行通信,当通信完成后这个跳出来的主机就要退回从机的位置。对于STM32而言使用的是可变多主机模式,所以我们要按照可变多主机的思路来操作
10位地址模式:用起始之后的前两个字节作为寻址位。第一个字节的前5位必须是11110,意为后一个字节也为寻址位,如果不是11110,那就是7位寻址模式。第一个字节的6 7 位和第二个字节的8位为地址,第一个字节的第8位为寻址位。
(2)I2C外设框图
首先,左边为I2C的通信引脚SDA和SCL,SMBALERT是SMBus用的。像这种外设模块引出来的引脚一般都是借用GPIO口的复用模式与外部相连,在引脚定义表中查看复用了哪个GPIO口。
先看数据控制部分,数据收发的核心部分就是数据寄存器和数据移位寄存器,当我们需要发送数据时,可以把一个字节数据写到数据寄存器DR,当移位寄存器没有数据移位时,数据寄存器值就会转到移位寄存器,移位过程中就可以把下一个数据放到数据寄存器里等着了,一旦前一个数据移位完成下一个数据就可以无缝衔接继续发送。当数据由数据寄存器转到移位寄存器时就会置状态寄存器TXE位为1,表示发送寄存器为空
接收时也是一样的一路,具体过程和串口一样。数据从移位寄存器到数据寄存器时置RXNE为1。
有了上面的数据收发SDA的数据收发就可以完成,具体何时收发需要写入控制寄存器的对应位进行操作,对于起始条件,终止条件,应答位等也有控制电路可以完成(具体实现细节并没有详画)。
数据移位寄存器下方还有两个功能,一个是比较器和自身地址寄存器,双地址寄存器,另一个是帧错误校验计算和帧错误校验寄存器。比较器和自身地址寄存器,双地址寄存器是从机模式使用的,STM32的I2C是基于可变多主机模型设计的,STM32不进行通信时就是从机,作为从机就应该有从机地址可以被别人召唤,从机地址就可以由这个自身地址寄存器来指定,我们可以自定一个从机地址写到此寄存器。如果STM32收到的寻址通过比较器判断和自身地址相同,那STM32就作为从机响应外部主机召唤,并且STM32支持同时响应两个从机地址。帧错误校验计算和帧错误校验寄存器是STM32设计的一个数据校验模块,当我们发送一个多字节数据帧时,硬件可以自动执行CRC校验计算,CRC是一种常见的数据校验算法,它会根据前面的数据进行各种数据计算,然后得到一个字节的校验位附加在这个数据帧后面,在接收到这一帧数据后,STM32硬件会自动执行校验的判定,如果CRC算法校验未通过硬件就会自动置校验错误标志位,这个校验过程和串口奇偶校验差不多。
SCL时钟部分:在时钟控制寄存器CCR写对应的位电路就会执行对应的功能,写入控制寄存器就可以对整个电路进行控制,读取状态寄存器就可以得知电路的工作状态。当内部有一些标志位置1时就可以申请中断。在进行很多字节收发时,可以配和DMA来提高效率。
(3)I2C基本结构图
(4)硬件I2C操作流程
1,主机发送
当STM32想要执行指定地址写时,就要按照下图传送序列图来进行
首先,初始化之后,总线默认空闲状态,STM32默认为从模式,为了产生一个起始条件,STM32需要写入控制寄存器(翻看手册,在控制寄存器CR1中位8为START位,在这一位写1就可以产生起始条件了,当起始位发出后这一位由硬件清除)之后STM32由从模式转为主模式,控制完硬件后就要检查标志位来看看硬件是否达到我们想要的状态,在这里,起始条件之后会发生EV5事件(可以把它当作标志位,表示起始位已发送,写数据寄存器时硬件自动清除该位)。当我们检测起始条件已发送时,就可以发送一个字节的从机地址了(写到数据寄存器DR中),写到DR后硬件电路会自动把这一个字节转到移位寄存器再发送到总线,之后硬件会自动接收应答位并判断,如果没有应答,硬件就会置应答失败的标志位。寻址完成之后会发生EV6事件 ,代表地址发送结束,EV6事件结束后还有一个EV8_1事件(就是TxE标志位=1),这时就需要我们写入数据寄存器DRR进行数据发送了,因为移位寄存器也为空,所以DR会立刻转到移位寄存器进行发送,这时就是EV8事件(TxE=1,移位寄存器非空,数据寄存器为空,写入DR将清除该事件),一旦检测到EV8数据就可以进行下一个字节数据发送到数据寄存器了。最后,当我们想要发送的数据写完之后,当移位寄存器当前的数据移位完成时,此时就是移位寄存器空,数据寄存器也为空的状态,这个事件就是EV8_2。之后产生终止条件(控制寄存器CR1中,位9 Stop位,写入1就会在当前字节传输或者在当前起始条件发出后产生停止条件)。总结来说就是写入数据寄存器DR和控制寄存器CR就可以控制时序单元的发生,时序单元发生后检查相应EV事件就是检查状态寄存器SR。当然在实际操作中调用库函数即可,不需要配置寄存器
2,主机接收
以下为当前地址读的时序
首先,写入控制寄存器START位产生起始条件,然后等待EV5事件,之后寻址接收应答,结束后产生EV6事件,数据1代表数据正在通过移位寄存器进行输入,EV6_1事件见下方解释。之后当此字节发送完成时硬件会自动根据我们的配置把应答位发送出去,在控制寄存器CR来配置是否要给应答(位10 ACK位 如果写1就在接受一个字节后1返回一个应答,写0不给应答),当数据1结束意味着移位寄存器已经成功移入一个字节的数据,这时移入的一个字节就整体转移到数据寄存器,同时置RxNE标志位,这个状态就是EV7事件,读DR寄存器清除该事件,按照这个流程就可以一直接收数据了。最后当我们不需要继续接收时,需要在最后一个时序单元发生时提前把应答位ACK置0,并且设置终止条件请求,这就是EV7_1事件,这个时序单元结束后就会给出非应答,产生终止条件
7,硬件I2C读写MPU6050
函数介绍
I2C文件里的函数很多,目前只需要挑一部分重点看即可
I2C_Init函数完成初始化
生成起始条件的函数。
生成终止条件的函数。
配置CR1的ACK这一位, ACK就是应答使能。STM32作为主机在接收到一个字节后,是否给从机应答。如果ACK为1,则应答;如果ACK为0,就不给从机应答
发送数据,实际就是把Data数据写到DR寄存器
读取DR寄存器的数据,作为返回值
发送7位地址的专用函数。实际上Address这个参数也是通过DR发送的,只不过是它在发送之前帮我们设置了Address最低位的读写位。可以用此函数发送地址也可以直接用上面的SendData发送地址
库函数下的注释给我们提供了多种监控标志位的方案(就是上一节说过的EV几的各种标志位)。其中第一种是基本状态监控,使用I2C_CheckEvent函数,这个方式就是同时判断一个或多个标志位来确定EV几EV几这个状态是否发生,与ppt流程对应,推荐使用。
第二种叫做高级状态监控,使用I2C_GetLastEvent()函数,它是直接把SR1和SR2这两个状态寄存器拼接为16为数据输出
第三种叫做基于标志位的状态监控,使用 I2C_GetFlagStatus()这个函数,就是之前取标志位的函数,可以判断某一个标志位是否置1了
再往下就是三个函数的说明了
最后就是读取标志位,清除标志位,读取中断标志位,清除中断标志位
配置I2C外设
- 开启I2C外设和对应的GPIO口的时钟(RCC)
- 把I2C外设对应的GPIO口初始化为复用开漏模式
- 使用结构体,对整个I2C进行配置
- I2C_Cmd,使能I2C
1,开启I2C和GPIO的时钟
void MPU6050_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
}
2,初始化GPIO
void MPU6050_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
}
3,初始化I2C硬件电路
成员I2C_Mode:共三个模式,第一个是I2C模式,第二个是SMBus总线的设备,第三个是SMBus总线的主机
成员I2C_ClockSpeed:可以配置SCL的时钟频率,数值越大,SCL频率越高,数据传输越快(转到定义看解释,这个参数必须是一个400KHz以下的值。)之前说过STM32内的I2C支持标准速度(高达100KHz),快速(高达400KHz),所以如果时钟频率在0~100kHz的范围I2C就处于一个标准速度状态,如果写在100~400kHz的范围I2C就处于快速的状态,可以根据项目需求制定。MPU6050也支持最大400KHz的时钟频率
成员I2C_DutyCycle:时钟占空比,这个参数只有在时钟频率大于100kHz(也就是进入快速状态)时才有用,在小于等于100kHz的标准速度下,占空比是固定的1:1。可选占空比有16:9和2:1(低电平时间:高电平时间)。按理说同步时序,SCL高电平和低电平多长时间都没问题,但是这里的占空比是为了快速传输设计的。50KHz和100KHz的传输波形如下图,可以看到标准速度传输下上升沿缓慢上去,下降沿则非常迅速,第三张图片为101KHz的波形,100KHz和101KHz非常接近,但是101KHz进入快速状态,STM32会对SCL的占空比进行调整,增大低电平时间占整个周期的比例(这是因为低电平数据变化,高电平数据读取,数据变化需要一定时间翻转电平,尤其是数据的上升沿,变化比较慢,所以在快速传输状态下要给低电平多分配一些资源)。第四张图是时钟频率为200KHz,第五张图为极限速度400KHz,这张SCL的波形变为三角形。
成员I2C_Ack:应答位配置,也是配置寄存器的ACK位。和库函数的I2C_AcknowledgeConfig是一样的效果,用来确定在接收一个字节后是否给从机应答。默认先给ENABLE应答,后续再用I2C_AcknowledgeConfig函数修改
成员I2C_AcknowledgedAddress:这个参数是指定STM32作为从机,可以响应几位的地址。参数列表可以选择响应10位或7位的地址。只有STM32作为从机才会用到,一般也都为7位地址
成员I2C_OwnAddress1:自身地址1,这个也是STM32作为从机使用的,用于指定STM32的自身地址,如果上一参数选择了7位地址,那这里就给STM32指定一个自身的7位地址。这里随便给一个,不和总线上其他设备地址重复即可
void MPU6050_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
I2C_InitTypeDef I2C_InitStructure;
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
I2C_InitStructure.I2C_ClockSpeed = 50000;
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_InitStructure.I2C_OwnAddress1 = 0x00;
I2C_Init(I2C2, &I2C_InitStructure);
}
4,I2C_Cmd,使能I2C
void MPU6050_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
I2C_InitTypeDef I2C_InitStructure;
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
I2C_InitStructure.I2C_ClockSpeed = 50000;
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_InitStructure.I2C_OwnAddress1 = 0x00;
I2C_Init(I2C2, &I2C_InitStructure);
I2C_Cmd(I2C2, ENABLE);
}
控制外设电路,实现指定地址写的时序
对照主机发送的序列图来写程序
注意,软件写I2C的函数内部都有Delay操作,是一种阻塞式的流程,也就是函数运行完成后,对应的波形肯定也发送完成了,所以上一个函数运行完之后可以紧跟下一个函数。但是硬件I2C函数都不是阻塞式的,它们只管给寄存器写01或者只在DR写入数据就结束,至于波形是否完成,它是不管的,所以对于这种非阻塞式的程序,在函数结束后都要等待相应的标志位来确保这个函数执行到位了。而检查EV几的事件需要用到状态监控函数,我们用I2C_CheckEvent即可
状态监控函数
以下为函数第二个参数可选事件
以下为返回值。返回值为SUCCESS,表示最后一次事件等于我们指定的事件,也就是指定事件发生了;返回值为ERROR,就是表示指定事件未发生
一般使用while循环来等待标志位。但是很多的while循环,一旦总线出问题就很容易造成整个程序卡死,所以我们需要设计一个超时退出的机制
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;
}
}
}
1,生成起始条件(函数I2C_GenerateSTART)
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
}
2, 发送从机地址,接收应答
发送从机地址直接用函数SendData或者Send7bitAddress在DR寄存器里写入一个字节即可
我们使用Send7bitAddress函数,这个函数的第三个参数为方向,也就是从机地址的最低位(读写位),如果选择Transmitter,发送(写),它就给你的地址最低为清0,如果选择Receiver,接收(读),它就给你的地址最低位写1
接收应答:并不需要函数来操作,在库函数中,发送数据都自带了接收应答的过程,同样接收数据也自带了发送应答的过程。如果应答错误,硬件会通过标志位和中断来提示我们,所以发送地址后应答位就不需要再处理了,直接等待事件EV6即可。EV6事件之后有一个EV8_1,这个事件是告诉你该写入DR数据了,并不需要等待这个EV8_1事件
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
}
3,发送数据
写入DR之后需要等待的是EV8事件,EV8事件出现的非常快基本上是不用等的。
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);
}
当等到了EV8事件,可以直接写入下一个数据。当我们有连续的数据需要发送时,我们需要等待EV8事件,而当我们发送完最后一个字节时需要等待的就变成了EV8_2事件(移位完成,并且没有新的数据可以发送了)(等待硬件将两级缓存所有的数据都清空才能产生终止条件)
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);
I2C_SendData(I2C2, Data);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
}
4,终止条件
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);
I2C_SendData(I2C2, Data);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
I2C_GenerateSTOP(I2C2, ENABLE);
}
控制外设电路,实现指定地址读的时序
前面的部分和指定地址写一样,复制如下
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING);
}
1,生成重复起始条件
起始条件之后需要等待EV5事件
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
// 当我们调用起始条件之后,如果当前还有字节正在移位,那此起始条件会延迟,等待当前字节发送完毕
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
}
2,发送从机地址
寻址之后等待EV6事件
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
//用主机接收的EV6
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);
}
3,接收数据
在接收一个字节时有一个EV6_1事件,这个事件没有标志位,也不需要我们等待,只适合接收一个字节的情况,目前是指定地址读一个字节,要注意EV6事件之后将应答位ACK置0,同时停止条件生成位STOP置1(规定在接收最后一个字节之前,要把ACK置0,同时设置停止位STOP) 。如果是读取多个字节,则等待EV7事件,读取DR,在接收最后一个字节之前,也就是E7_1事件,需要提前把CK置0,STOP置1;如果只需要读取一个字节,那在EV6之后就要立刻ACK置0,STOP置1
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED);
I2C_SendData(I2C2, RegAddress);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED);
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT);
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver);
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED);
I2C_AcknowledgeConfig(I2C2, DISABLE);//设置ACK为0
I2C_GenerateSTOP(I2C2, ENABLE);//配置停止位
MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED);//等待EV7产生
//EV7产生后一个字节的数据就已经在DR里了,读取DR即可拿出这一字节
Data = I2C_ReceiveData(I2C2);
I2C_AcknowledgeConfig(I2C2, ENABLE);//将ACK再置回1,默认状态下ACK就是1
return Data;
}
十二,SPI通信
1,简介
SPI通信协议和I2C差不多都是通用的数据总线,两个协议的设计目的都是为了实现主控芯片和各种外挂芯片之间的数据交流
I2C和SPI是有各自的优劣势的,某些芯片使用I2C更好,有些芯片则使用SPI更好。I2C实现了硬件上最少的通信线,又实现了软件上最多的功能,但是I2C由于开漏外加上拉电阻的电路结构,使得通信线高电平的驱动能力很弱,导致通信线由低电平到高电平的时候上升沿耗时比较长,这会限制I2C的最大通信速度,所以I2C标准模式只有100KHz的时钟频率,快速模式也只有400KHz,虽然I2C协议之后通过改进电路的方式设计出了高速模式(可达3.4MHz),但是高速模式普及度并不是很高。400KHz相比较SPI来说还是慢了很多的
SPI相对于I2C传输更快,SPI协议并没有严格规定最大传输速度,这个最大传输速度取决于芯片厂商设计需求。其次,SPI设计比较简单粗暴,没有I2C那么多的功能。SPI硬件开销比较大,通信线的个数比较多,并且通信过程中经常会有资源浪费的现象
以下四个模块为常见的使用SPI的模块
W25Q64,是一个Flash存储器 利用SPI通信的七针脚OLED屏幕
NRF24L01 2.4G无线通信模块 MicroSD卡
2,SPI硬件规定(硬件电路)
3个从机,SS线需要三根。这里没有接GND线,实际上是需要的,所有的主从机都需要共地,除此之外,如果从机没有独立供电的话,主机还需要额外引出电源正极VCC给从机供电。所有SPI设备的SCK MOSI MISO都需要分别连接在一起,时钟线SCK完全由主机掌控。
输出引脚设置为推挽输出,输入引脚设置为浮空或上拉输入。如下图,输入和输出引脚都用箭头标注了。推挽输出的高低电平均有很强的驱动能力,这使得SOI引脚的上升沿和下降沿都非常迅速(而I2C上升沿缓慢,下降沿迅速),传输速度也能达到很快(由于I2C要实现半双工,经常要切换输入输出,另外I2C又要实现多主机的时钟同步和总线仲裁,这些功能,都不允许I2C使用推挽输出,否则一不小心就电源短路了)。SPI有一个冲突点,就是MISO引脚,主机一个输入,而从机三个输出,如果三个从机始终是推挽输出,那势必会导致冲突。所以在SPI协议里规定当从机SS引脚为高电平时,也就是从机未被选中时,它的MISO引脚必须切换为高阻态,高阻态相当于引脚断开不输出任何电平,在SS为低电平时,MISO才允许为推挽输出
下方移位示意图是SPI硬件电路设计的核心。SPI的基本收发电路就是使用了这样一个移位的模型。主机的移位寄存器为8位,有一个时钟输入端,因为SPI一般都为高位先行,所以每来一个时钟,移位寄存器就会向左进行移位,从机移位寄存器同理。移位寄存器的时钟源由主机提供。
MOSI:主机移位寄存器左边移出去的数据通过MOSI引脚输入到从机移位寄存器的右边
MISO:从机移位寄存器左边移出去的数据通过MISO引脚,输入到主机移位寄存器的右边
首先,波特率发生器的上升沿,所有移位寄存器向左移动一位,移出去的位放在引脚上;波特率发生器的下降沿引脚上的位采样输入到移位寄存器的最低位。
具体过程如下。多次重复即可发送完一个字节的数据。SPI的数据收发都是基于字节交换这个基本单元进行的。当主机需要发送一个字节同时需要接受一个字节时就可以执行字节交换的时序,这样主机要发送的数据到从机,从机要发送的数据到主机,这就完成了发送同时接收的目的,如果只想发送不想接收,那就还执行字节交换的时序,不需要看接收的数据即可;如果只想接收不想发送,那就还执行字节交换的时序,只是会随便发送一个数据,只要把从机数据拿到即可,一般在接收时会统一发送0x00或0xFF。
3,SPI软件规定(时序)
(1)SPI时序基本单元
何时开始移位,上升沿还是下降沿移位SPI并没有限制死,我们可以自主配置选择。我们可以配置CPOL(时钟极性)和CPHA(时钟相位)这两个位,每一位可以配置为1或者0,总共有四种模式,如下。模式的功能都是一样的,实际使用时主要学习其中一种即可。与刚才移位模型对应的模式是模式1
模式0是在SCK的第一位就移入数据,所以在第一个边沿之前就需要先移出数据到数据线上,这样在第一个边沿才有数据移入,所以在SS下降沿时就要立刻触发移位输出,所以MOSI和MISO的输出是对齐到SS的下降沿的(将SS的下降沿也作为时钟的一部分)
在SS的上升沿,MOSI还可以变化一次将其置到一个默认的高电平或低电平,也可以不用管,因为SPI没有硬性规定MOSI的默认电平。对于MISO从机必须置回高阻态。如果主机还想继续交换字节,就保持SS为低电平重复交换一个字节的时序即可。模式0和模式1的区别就在于模式0将数据变化提前了,在实际应用中,模式0应用最多,之后的程序都是基于模式0的
(2)I2C完整时序
每个芯片对SPI时序字节流动功能的定义不一样,这里以W25Q64芯片时序为例。SPI对字节流功能的规定不像I2C那样(I2C数据流规定有效数据流第一个字节是寄存器位置,之后依次是读写的数据,使用的是读写寄存器的模型),SPI中通常采用的是指令码加读写数据的模型,SPI起始后第一个交换给从机的数据叫做指令码(在从机中对应的会定义一个指令集),当需要发送指令时就可以在起始后的第一个字节发送指令集里的数据,这样就可以指导从机完成相应的功能,不同的指令可以有不同的数据个数,有的指令只需要一个字节的指令码就可以完成(比如W25Q64的写使能,写失能等),而有的指令后面就需要再跟要读写的数据(比如W25Q64的写数据和读数据,写数据指令后面就要跟上在何处写写什么,读数据指令后就要跟上要在何处读读到的是什么,这就是指令码加读写数据的模型),在SPI从机的芯片手册里,都会定义好指令集,什么指令对应了什么功能,什么指令后面要跟上什么数据。
发送指令(向SS指定的设备发送指令(0x06))。0x06具体指令的功能由芯片厂商自己规定,在W25Q64芯片中,0x06代表的是写使能。如下时序,MISO从机没有数据发送给主机,引脚电平没有变换,因为STM32的MISO是上拉输入,所以为高电平。SCK第一个上升沿进行数据采样,从机采样输入得到0,主机采样输入得到1,下降沿开始移出数据,重复过程。最后,SCK最后一个上升沿结束,一个字节交换完毕,因为写使能是单独的指令,不需要跟随数据,所以结束后SS置回高电平结束通信。
指定地址写(向SS指定的设备发送写指令(0x02),随后在指定地址(Address[23:0])写下,写入数据)。因为W25Q64芯片有8M的存储空间,一个字节的八位数据描述地址肯定不够,所以这里地址是24位的要分三个字节传输。下图第一个字节为写指令,根据W25Q64的规定,第二个字节为地址高位也就是表示发送地址的23~16位,第三个字节为15~8位,第四个字节为发送地址的7~0位,第五个字节为要写入指定地址的内容
指定地址读(向SS指定的设备发送读指令(0x03),随后在指定地址(Address[23:0])下,读取从机数据)
4,W25Q64
(1)简介
W25Q40: 4Mbit / 512KByte
W25Q80: 8Mbit / 1MByte
W25Q16: 16Mbit / 2MByte
W25Q32: 32Mbit / 4MByte
W25Q64: 64Mbit / 8MByte
W25Q128: 128Mbit / 16MByte
W25Q256: 256Mbit / 32MByte
这个芯片使用的是24位地址,3个字节。在读写时肯定要把每个字节都分配一个地址,这样才能z找到他们。2的24次方除以1024再除以1024等于16,也就是说24位地址最大寻址空间为16MB,这对于W25Q40到W25Q128都是足够的,但对于W25Q256就显得不够,根据手册描述W25Q256分为3字节地址模式和4字节地址模式,3字节地址模式下只能读写前16MB的数据,要想读写所有的存储单元可以进入4字节地址的模式
(2)硬件电路
引脚定义图。3.3V供电设备,CS左边加一斜杠代表低电平有效,写保护WP低电平有效,HOLD指如果在正常读写时突然产生中断,想用SPI线去操控其他器件,这时如果想把CS置回高电平,那时序就终止了,所以将HOLD置为低电平,这样不用终止总线也能操控其他器件,相当于SPI总线进入了一次中断。
DI DO WP HOLD都加了括号,写了IO0 IO1 IO2 IO3,这个就对应简介里说过的双重SPI和四重SPI
模块原理图。HOLD和WP如果用的话接STM32的GPIO,如果不用的话接电源正极即可
(3)框图
右上角和左上角一大块描述的是W25Q64的存储器规划示意图.容量为8MB,如果不进行划分而只按照一整块来使用的话不利于管理,而且后续设计Flash擦除和写入的时候都会有一个基本单元。要以这个基本单元为单位进行,所以8MB的存储空间就有必要进行划分,常见的划分方就是一整块存储空间先划分为若干的块Block,其中每一块再划分为若干的扇区Sector(左上角所示),对于每个扇区内部又可以分成很多页Page。
W25Q64划分:右上角矩形空间里为所有存储器,存储器以字节为单位,每个字节都有唯一的地址。在这整个空间里我们以64KB为一个基本单元将它划分为若干的块Block,,左边是对每一个块进行更细的划分,分为多个扇区Sector,一个扇区4KB。地址划分到扇区就结束了,但是在写入数据时,还会有更细的划分,就是页page,页是对整个存储空间划分的,也可以看做在扇区里再进行划分(一个扇区分为16页),页的大小是256个字节
左下角为SPI控制逻辑,也就是芯片内部进行地址锁存,数据读写等操作都可以由控制逻辑来自动完成。控制逻辑上方有个状态寄存器Status Register,可以说明芯片是否处于忙状态,是否写使能,是否写保护等。
High Voltage Generators为高电压生成器,配合Flash进行编程的,用来实现Flash掉电不丢失的功能,如果点亮一个LED表示1熄灭一个LED表示零,但是断电后没有电了,那就无法表示1 0了,所以要想实现掉电不丢失就必须在存储器里产生掉电也不影响的变化,比如给LED加很高的电压直接让它烧坏,用烧坏的LED表示1,没烧坏的LED表示0,所以掉电不丢失的存储器都需要一个高压源。
Page Address Latch/Counter页地址锁存/计数器和Byte Address Latch/Counter字节地址锁存/计数器这两个都是用来指定地址的。我们通过SPI总共发过来3个字节的地址,因为一页为256字节,所以一页内的字节地址就取决于最低的一个字节,而高位的两个字节对应的是页地址,所以3个字节地址的前两个字节会进入到Page Address Latch/Counter,最后一个字节进入到Byte Address Latch/Counter。然后页地址通过写保护和行解码(Write Protect and Row Decode)来选择要操作的页,字节地址通过列解码和256字节页缓存(Column Decode And 256-Byte Page Buffer)来进行指定字节的读写操作。又因为地址锁存都是有一个计数器的,所以地址指针在读写之后可以自动加1,这样就可以实现从指定地址开始连续读写多个字节的目的了。
Column Decode And 256-Byte Page Buffer,256字节的页缓存区实际上是一个RAM存储器,数据读写就是通过RAM缓存区进行的,写入的数据先放入缓存区中,然后在时序结束后再将缓存区里的数据复制到对应的Flash。设置这个RAM而不往Flash里直接写,是因为SPI写入的频率很高,而Flash的写入由于需要掉电不丢失就会慢一些,缓存区是RAM速度很快可以跟上SPI总线的速度。但是这里有个小问题,就是缓存区只有256个字节,所以写入的时序要有一个限制条件,就是写入的一个时序连续写入的数据量不能超过256字节。等一个时序结束后,芯片将RAM中的数据转移到Flash存储器里,这需要一定的时间,所以在写入时序结束后,芯片会进入一断忙的状态,给寄存器的BUSY位置1,忙的时候芯片就不会再响应新的读写时序了。对于读取而言限制很少,速度很快。
(4)Flash操作注意事项
写入操作时:
读取操作时:
(5)补充
1,状态寄存器
状态寄存器有两个状态寄存器1和状态寄存器2,比较重要的是状态寄存器1。状态寄存器1里的BUSY位和WEL(Write Enable Latch)写使能锁存位WEL,执行完写指令后WEL置1,代表芯片可以进行写入操作了,当设备写失能时,WEL位清0(上电后芯片默认为写失能。执行写失能指令后芯片写失能。其次,页编程,扇区擦除等等这些写入操作后WEL会=0,这表明当我们先写使能再执行写入数据操作后不需要再手动写失能了,会自动写失能。这也表明一个写使能只能保证后续的一条写指令可以执行)
2,指令集
下表是是芯片ID号,厂商ID为EF(h代表十六进制)。设备ID如果使用AB和90指令来读是16,如果使用9F来读就是4017。这个写程序的时候首先读取ID验证SPI是否可行
下表即为SPI指令集
写使能(Write Enable),指令码06。要想发送写使能指令,先起始然后交换一个字节,第一个字节是发送,发送0x06指令,下表可以看到,这个指令后续不需要跟数据,直接停止即可
读状态寄存器1(Read Status Register-1),指令码05。起始,交换字节发送指令码05,这是读指令,所以后面有数据,要继续交换字节,通过交换读取一个字节S0-S7,如果继续交换接收的话芯片会不断地输出状态寄存器1.S0为BUSY位,S1为WEL位
页编程(Page Program),写数据,有一个256字节页大小的限制。起始,交换字节发送指令02,然后交换发送地址的24位,然后就可以写入数据了
擦除指令,D8 52 20和整片擦除C7/60
读取ID号(JDDEC ID)。起始,交换发送9F,随后连续读取三个字节,终止(第一个字节为厂商ID,后两个字节为设备ID)
读取数据(Read Data)。起始,交换发送指令03,之后交换发送3个字节的地址,然后就可以交换读取了,读取没有页的限制
5,软件SPI读写W25Q64
此工程共包括3个文件,SPI文件写SPI通信的最底层,包括引脚封装初始化以及SPI通信的三个拼图(起始终止和交换一个字节);基于SPI文件再建一个W25Q64文件(硬件驱动层),这个文件里调用SPI的拼图,来拼接各种指令和功能的完整时序,比如写使能,擦除,页编程,读数据等等;最后一个文件为主函数文件,在主函数里调用驱动层函数完成对应的功能
接线图:PA4接SS(CS),PA5接CLK,PA6接DO,PA7接DI
SPI.c
#include "stm32f10x.h"
//写SS引脚,因为SPI很快。操作完引脚之后就不需要加延时了
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
//BitAction表示非0即1
}
//写SCK
void MySPI_W_SCK(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue);
}
//写MOSI
void MySPI_W_MOSI(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue);
}
//写MISO
uint8_t MySPI_R_MISO(void)
{
return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6);
}
void MySPI_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;//输出引脚为推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;//输入引脚为浮空或上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
//置初始化之后引脚的默认电平
MySPI_W_SS(1);
MySPI_W_SCK(0);
}
void MySPI_Start(void)
{
MySPI_W_SS(0);
}
void MySPI_Stop(void)
{
MySPI_W_SS(1);
}
//实现交换字节的函数。方法一:使用掩码依次提取数据的每一位
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
uint8_t i, ByteReceive = 0x00;//用于接收
for (i = 0; i < 8; i ++)
{
MySPI_W_MOSI(ByteSend & (0x80 >> i));
MySPI_W_SCK(1);//从机会自动将MOSI数据读走,所以这里只操作主机
if (MySPI_R_MISO() == 1){ByteReceive |= (0x80 >> i);}
MySPI_W_SCK(0);
}
return ByteReceive;
}
//方法二:用移位数据本身来进行操作,效率更高,但是会改变ByteSend这个数据
//方法二在思路上更契合之前讲过的移位模型
uint8_t MySPI_SwapByte2(uint8_t ByteSend)
{
uint8_t i;
for (i = 0; i < 8; i ++)
{
MySPI_W_MOSI(ByteSend & 0x80);
ByteSend <<= 1;
MySPI_W_SCK(1);
if (MySPI_R_MISO() == 1){ByteSend |= 0x01;}
MySPI_W_SCK(0);
}
return ByteSend;
}
//上述为SPI模式0的实现方法
//如果想修改为模式1,则需要将MySPI_W_SCK(1);放到MySPI_W_MOSI(ByteSend & (0x80 >> i));上方
//将MySPI_W_SCK(0);放到if (MySPI_R_MISO() == 1){ByteReceive |= (0x80 >> i);}上方
//即先SCK上升沿下降沿再移出移入数据
//要修改为模式2(模式2和0只是极性不一样),将SCK极性反转即可。即所有出现SCK的地方0改为1,1改为0
//要修改为模式3(模式3和1只是极性不一样),将SCK极性反转即可。即所有出现SCK的地方0改为1,1改为0
SPI.h
#ifndef __MYSPI_H
#define __MYSPI_H
void MySPI_Init(void);
void MySPI_Start(void);
void MySPI_Stop(void);
uint8_t MySPI_SwapByte(uint8_t ByteSend);
#endif
W25Q64.c
W25Q64_Ins.h此文件用来宏定义W25Q64的指令集,增强可读性
#ifndef __W25Q64_INS_H
#define __W25Q64_INS_H
#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
#include "stm32f10x.h"
#include "SPI.h"
#include "W25Q64_Ins.h"
void W25Q64_Init(void)
{
MySPI_Init();//初始化底层
}
//以下为参考手册拼接的完整时序(指令集表格)
//先实现读ID号的时序,来验证底层SPI协议是否有问题
void W25Q64_ReadID(uint8_t *MID, uint16_t *DID)//使用指针实现多返回值
{
MySPI_Start();//起始
MySPI_SwapByte(W25Q64_JEDEC_ID);//先交换发送指令字节
*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//厂商ID
*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);//设备ID高8位
*DID <<= 8;
*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);//设备ID低8位,不能直接=否则高8位就变为0了
MySPI_Stop();
}
void W25Q64_WriteEnable(void)
{
MySPI_Start();
MySPI_SwapByte(W25Q64_WRITE_ENABLE);
MySPI_Stop();
}
//读状态寄存器BUSY位。状态寄存器可以被连续读出。如果读出一个数据后不停止时序,状态寄存器就会把
//最新状态数据发送过来
//此函数用来读BUSY位,并且如果BUSY为1的话此函数还会实现等待BUSY为0的功能
void W25Q64_WaitBusy(void)
{
uint32_t Timeout;//用来防止while程序卡死
MySPI_Start();
MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);
Timeout = 100000;
while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01)
{
Timeout --;
if (Timeout == 0)
{
break;
}
}
MySPI_Stop();
}
//页编程。如果只想指定地址写入一个字节,参数给uint8_t Data就可以。但是一般存储东西比较多
//可以直接传递一个数组DataArray,数组要通过指针传递。Count表示一次写入了多少个字节
void W25Q64_PageProgram(uint32_t Address, uint8_t *DataArray, uint16_t Count)
{
uint16_t i;
W25Q64_WriteEnable();//写使能
MySPI_Start();
MySPI_SwapByte(W25Q64_PAGE_PROGRAM);
MySPI_SwapByte(Address >> 16);
MySPI_SwapByte(Address >> 8);
MySPI_SwapByte(Address);//交换3个字节地址,先发送高位
for (i = 0; i < Count; i ++)//for循环写入多个数据
{
MySPI_SwapByte(DataArray[i]);
}
MySPI_Stop();
W25Q64_WaitBusy();//每次写操作之后等待芯片忙状态
}
//扇区擦除(其他擦除方式可以仿照写)
void W25Q64_SectorErase(uint32_t Address)
{
W25Q64_WriteEnable();//写使能
MySPI_Start();
MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);
MySPI_SwapByte(Address >> 16);
MySPI_SwapByte(Address >> 8);
MySPI_SwapByte(Address);
MySPI_Stop();
W25Q64_WaitBusy();//每次写操作之后等待芯片忙状态
}
//uint8_t *DataArray为输出参数。由于读取数据没有字节限制,Count定义的大一点
void W25Q64_ReadData(uint32_t Address, uint8_t *DataArray, uint32_t Count)
{
uint32_t i;
MySPI_Start();
MySPI_SwapByte(W25Q64_READ_DATA);
MySPI_SwapByte(Address >> 16);
MySPI_SwapByte(Address >> 8);
MySPI_SwapByte(Address);
for (i = 0; i < Count; i ++)
{
DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE);
}
MySPI_Stop();
}
W25Q64.h
#ifndef __W25Q64_H
#define __W25Q64_H
void W25Q64_Init(void);
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);
#endif
主函数调用示例
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "W25Q64.h"
uint8_t MID;
uint16_t DID;
uint8_t ArrayWrite[] = {0x01, 0x02, 0x03, 0x04};//要写入的数据
uint8_t ArrayRead[4];//要读取的数据
int main(void)
{
OLED_Init();
W25Q64_Init();
OLED_ShowString(1, 1, "MID: DID:");
OLED_ShowString(2, 1, "W:");
OLED_ShowString(3, 1, "R:");
W25Q64_ReadID(&MID, &DID);
OLED_ShowHexNum(1, 5, MID, 2);
OLED_ShowHexNum(1, 12, DID, 4);
W25Q64_SectorErase(0x000000);//扇区擦除,0x000000表示第0个地址
//之前在框图中可以看到扇区地址规律为xxx000到xxxFFF,所以只要末尾三位数为0
//就肯定是扇区起始地址
W25Q64_PageProgram(0x000000, ArrayWrite, 4);//数组首地址传入
W25Q64_ReadData(0x000000, ArrayRead, 4);
OLED_ShowHexNum(2, 3, ArrayWrite[0], 2);
OLED_ShowHexNum(2, 6, ArrayWrite[1], 2);
OLED_ShowHexNum(2, 9, ArrayWrite[2], 2);
OLED_ShowHexNum(2, 12, ArrayWrite[3], 2);
OLED_ShowHexNum(3, 3, ArrayRead[0], 2);
OLED_ShowHexNum(3, 6, ArrayRead[1], 2);
OLED_ShowHexNum(3, 9, ArrayRead[2], 2);
OLED_ShowHexNum(3, 12, ArrayRead[3], 2);
while (1)
{
}
}
6,SPI通信外设
(1)简介
(2)SPI外设框图
先总体看这张图,大体可以将其分为两部分,红框就是数据寄存器和移位寄存器配合的过程,和串口,I2C的设计思路异曲同工,主要是为了实现连续的数据流。剩下的部分就是控制逻辑了,寄存器的哪些位控制哪些部分会产生哪些效果,可以通过手册的寄存器描述来得知
详细看一下每部分的功能。核心部分就是移位寄存器,右边的数据低位一位一位地从MOSI移出去(红线路径所示),MISO的数据一位一位地移到左边的数据高位(黄线所示) ,这样来看,移位寄存器是一个右移的状态,所以这表示的是低位先行的配置,右下角有一个LSBFIRST控制位,这一位可以控制是低位先行还是高位先行(如下图所示,LSBFIRST控制位位于SPI控制寄存器1的位7,MSB表示高位先行,LSB表示低位先行)
移位寄存器左边有一个黑色的方框,里面把MOSI和MISO做了交叉,这一块主要用来进行主从模式引脚变换的。此SPI外设可以做主机也可以做从机,做主机时这个交叉就可以忽视,做从机时MOSI和MISO就要走交叉的那一路。
上下两个缓冲区是我们熟悉的结构,上面接收缓存区实际上就是数据寄存器RDR ,下面的发送缓冲区就是发送数据寄存器TDR,与串口一样TDR和RDR占用同一个地址,统一叫作DR。写入DR时数据从地址和数据总线写入到TDR,读取DR时数据从RDR读出到地址和数据总线,数据寄存器和移位寄存器配合实现连续的数据流。比如我们需要连续发送一批数据,第一个数据写入到TDR,当移位寄存器没有数据移位时,TDR数据会立刻转入移位寄存器开始移位,这个转入时刻会置状态寄存器TXE为1,表示发送寄存器空,当TXE置1后紧跟着下一个数据就可以提前写入到TDR里等待着了。当移位寄存器里一旦有数据了,它就会自动产生时钟将数据移出去,移出过程中,MISO数据也会移入,移出完成的同时移入也会完成,这时,移入的数据就会整体地从移位寄存器转入到接收缓冲区RDR,这时会置状态寄存器RXNE为1,表示接收寄存器非空,这时就要尽快把数据从RDR读出来。在这方面,SPI和串口,I2C还是有一些区别的,SPI是全双工,发送和接收同步进行,所以它的数据寄存器发送和接收是分离的,而I2C数据寄存器和移位寄存器发送和接收都是共用的,串口是全双工并且发送和接收可以异步进行,所以它的数据寄存器和移位寄存器都是分离的。
右下角内容就是一些控制逻辑。波特率发生器主要用来产生SCK时钟,它的内部主要是一个分频器,输入时钟是PCLK,72M或36M,经过分频器后输出到SCK引脚,每产生一个周期的时钟移入移出一个bit。它的右边SPI_CR1寄存器的三个位BR0 BR1 BR2用来控制分频系数,如下,位于SPI控制寄存器1的位3到位5上,可以对PCLK时钟执行2~256分频
剩下的通信电路和各种寄存器都是一些黑盒子电路。只挑重点的说一下,LSBFIRST刚才说过了;SPE是SPI使能,就是SPI_Cmd函数配置的位;BR配置波特率;MSTR配置主从模式,1是主模式 0是从模式,我们一般用主模式;CPOL和CPHA之前说过,用来选择SPI的四种模式。SPI_SR状态寄存器最后两个TXE和RXNE这两个比较重要。SPI_CR2寄存器就是一些使能位了,比如中断使能,DMA使能等。左下角还有一个NSS,SS就是从机选择,低电平有效,所以这里加了N,之前说的NSS是用来指定某个从机的,但是根据手册里的描述这里的NSS更倾向于实现多主机模型,所以并不会用到,对于SS引脚,我们直接使用GPIO引脚模拟即可。这个NSS是如何实现多主机切换的功能的?假如有3个STM32设备,我们需要把这个个设备的NSS全都连接到一起,NSS可以配置为输出或者输入,当配置为输出时可以输出电平告诉其他设备要我要变为主机,当配置为输入时可以接收别的设备的信号,当有的设备为主机拉低NSS后,这时我就作为从机(当SPI_CR2寄存器中的SSOE=1时,NSS作为输出引脚,并且在当前设备变为主设备时给NSS输出低电平;主机结束后,SSOE清零,NSS变为为输入引脚,这时输入信号就会沿电路到达数据选择器(蓝框所示),SPI_CR1中的SSM位决定选择0还是选择1,当选择上面一路即0的一路时,是硬件NSS模式,也就是说,如果外部输入了低电平,那当前设备就作为从机;当数据选择器选择下面一路时是软件NSS模式,NSS是0还是1由SPI_CR1寄存器中的SSI一位决定),这就是NSS实现多主机的思路,但是这个设计使NSS作为多从机选择的作用消失了,主机发送数据只能发给所有人,如果想实现指定设备通信可能还需要加入寻址机制。SPI最多的情况是一主多从或者一主一从,所以我们重新开辟一个GPIO口作为SS引脚来从机选择。
(3)SPI基本结构图
简化结构图
(4)时序流程
主模式全双工连续传输
下图演示的是借助缓冲区实现连续数据流的过程。但是这个流程比较复杂,也不太方便封装,所以在实际过程中如对性能没有基质的追求,更倾向使用非连续传输,非连续传输使用起来更加简单,只需要4行代码就能完成任务
首先,SCK时钟线,下图CPOL和CPHA都为1,使用的是模式3,所以SCK默认为高电平,然后在第一个下降沿MOSI和MISO移出数据,上升沿移入数据,依次这样来进行。第二行波形为MISO和MOSI输出的波形。第三行为TXE发送寄存器空标志位。第四行为发送缓冲寄存器TDR。第五行BSY,BUSY,是由硬件自动设置和清除的,有数据传输时BUSY置1。
上面演示的是输出的流程和现象,下面为输入的流程和现象。第一个为MISO和MOSI的输入数据,之后是RXNE接收数据寄存器非空标志位,最后是接收缓冲器RDR
总体分析过程,首先SS置低电平开始时序,刚开始时TXE为1表示TDR空,可以写入数据0xF1开始传输,同时TXE变为0表示TDR已有数据,之后TDR里的数据立刻转到移位寄存器开始发送,转入瞬间置TXE为1表示发送寄存器为空,然后移位寄存器就有数据了,波形就自动开始生成,在移位0xF1时下一个字节数据提前写入TDR等待,等要发送的字节完整发送之后BUSY标志由硬件清除。接收流程类似,注意一个字节波形收到后,移位寄存器的数据自动转入RDR会覆盖原有的数据,所以读取RDR要及时
非连续传输
下面是非连续传输发送的波形,接收部分没有画
配置上还是模式3,SCK默认高电平,想发送数据时,如果检测到TXE为1,TDR为空,则软件写入0xF1至SPI_DR,这时TDR值变为F1,TXE变为0,此时移位寄存器也为空,所以F1会立刻转入移位寄存器开始发送。在连续传输时一旦TXE=1了,我们就会把下一个数据放到TDR等候,但是在非连续传输TXE=1了,我们不着急把下一个数据写入,而是一直等待到第一个字节时序结束,这时接收也完成了1.接收的RXNE会置1,等待RXNE为1后先把第一个接收的数据读出来,之后再写入下一个字节的数据
整体步骤:1,等待TXE为1 2,写入发送的数据至TDR 3,等待RXNE为1 4,读取RDR接受的数据 之后交换第二个字节重复上述步骤
(5)软硬件波形对比
软件波形
硬件波形
软硬件波形数据变化趋势相同,采样得到数据也一样。区别就是硬件波形数据线的变化是紧贴SCK边沿的,而软件波形数据线变化在边沿后有一些延迟。无论是哪种方式最终都不会影响数据传输,不过软件波形如果能贴近边沿还是贴近边沿比较好,否则等待太久靠近下一个边沿容易导致数据出错
7,硬件SPI读写W25Q64
首先参考引脚定义表。可以看到SPI使用引脚PA4 PA5 PA6 PA7。NSS之前说过没必要接在PA4上,可以随便选一个GPIO引脚来做从机选择。
硬件SPI实际上就是两部分:1,SPI外设初始化 2,SPI外设操作时序,完成交换一个字节的流程
库函数
有些函数带了I2S,因为SPI和I2S共用一套电路,不用I2S就不用管。
SPI_I2S_DeInit(SPI_TypeDef* SPIx); 恢复缺省配置
SPI_Init(SPI_TypeDef* SPIx, SPI_InitTypeDef* SPI_InitStruct);初始化
SPI_StructInit(SPI_InitTypeDef* SPI_InitStruct); 结构体变量初始化
SPI_Cmd(SPI_TypeDef* SPIx, FunctionalState NewState); SPI使能
SPI_I2S_ITConfig(SPI_TypeDef* SPIx, uint8_t SPI_I2S_IT, FunctionalState NewState);中断使能
SPI_I2S_DMACmd(SPI_TypeDef* SPIx, uint16_t SPI_I2S_DMAReq, FunctionalState NewState);DMA使能
SPI_I2S_SendData(SPI_TypeDef* SPIx, uint16_t Data); 写DR数据寄存器
uint16_t SPI_I2S_ReceiveData(SPI_TypeDef* SPIx);读DR数据寄存器
SPI_NSSInternalSoftwareConfig(SPI_TypeDef* SPIx, uint16_t SPI_NSSInternalSoft);和SPI_SSOutputCmd(SPI_TypeDef* SPIx, FunctionalState NewState); NSS引脚配置
SPI_DataSizeConfig(SPI_TypeDef* SPIx, uint16_t SPI_DataSize);8位或16位数据帧配置
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); CRC校验的配置
SPI_BiDirectionalLineConfig(SPI_TypeDef* SPIx, uint16_t SPI_Direction); 半双工时,双向线的方向配置
FlagStatus SPI_I2S_GetFlagStatus(SPI_TypeDef* SPIx, uint16_t SPI_I2S_FLAG);获取标志位操作,主要用它获取TXE和RXNE标志位的状态
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和GPIO的时钟
-
初始化GPIO。SCK和MOSI是由外设控制的输出信号,配置为复用推挽输出;MISO是硬件外设的输入信号,配置为上拉输入。SS引脚是软件控制的输出信号,配置为通用推挽输出
-
配置SPI外设。数据控制器,波特率发生器以及寄存器使用一个结构体选参数即可。
-
开关控制,调用SPI_Cmd,给SPI使能即可
这里使用非连续传输的方法。初始化之后按照非连续传输的时序图来执行运行控制的代码即可。这里需要的函数就是写DR读DR,获取标志位。
1,开启时钟
#include "stm32f10x.h" // Device header
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}
void MySPI_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
}
void MySPI_Start(void)
{
MySPI_W_SS(0);
}
void MySPI_Stop(void)
{
MySPI_W_SS(1);
}
2,初始化GPIO口
#include "stm32f10x.h" // Device header
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}
void MySPI_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
}
void MySPI_Start(void)
{
MySPI_W_SS(0);
}
void MySPI_Stop(void)
{
MySPI_W_SS(1);
}
3,初始化SPI外设
成员1:SPI_Mode。选择SPI模式,这个参数决定当前设备是SPI的主机还是从机。共用SPI_Mode_Master和SPI_Mode_Slave两个参数。Master指定当前设备为主机,Slave指定当前设备为从机
成员2:SPI_Direction。共有如下四种模式,1Line_RX为单线半双工的接收模式,1Line_TX为单线半双工的发送模式。2Lines_FullDuplex为双线全双工,为双线只接收模式2Lines_RxOnly
成员3:SPI_DataSize。选择是8位还是16位数据帧。有SPI_DataSize_8b和SPI_DataSize_16b两个参数
成员4:SPI_FirstBit。配置高位先行还是低位先行。有SPI_FirstBit_MSB和SPI_FirstBit_LSB两个参数,MSB是高位先行
成员5:SPI_BaudRatePrescaler波特率预分频器。用来配置SCK时钟的频率,参数有如下所示
成员6,7:SPI_CPOL和SPI_CPHA:用来配置SPI模式的,SPI_CPOL参数有SPI_CPOL_High和SPI_CPOL_Low,SPI_CPHA参数有SPI_CPHA_1Edge(第一个边沿开始采样,CPHA=0)和SPI_CPHA_2Edge(第二个边沿开始采样)(CPHA=1)
成员8:SPI_NSS。参数有SPI_NSS_Hard和SPI_NSS_Soft,即硬件NSS和软件NSS。NSS引脚我们计划使用GPIO模拟,这个外设NSS引脚我们并不会用到,所以此参数一般选择软件NSS即可
成员9:SPI_CRCPolynomial。CRC校验的多项式,需要我们填一个数,填多少都行,因为我们不用
#include "stm32f10x.h" // Device header
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}
void MySPI_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
SPI_InitTypeDef SPI_InitStructure;
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128;
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;
SPI_InitStructure.SPI_CRCPolynomial = 7;
SPI_Init(SPI1, &SPI_InitStructure);
}
void MySPI_Start(void)
{
MySPI_W_SS(0);
}
void MySPI_Stop(void)
{
MySPI_W_SS(1);
}
4,使能SPI
#include "stm32f10x.h" // Device header
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}
void MySPI_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
SPI_InitTypeDef SPI_InitStructure;
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128;
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;
SPI_InitStructure.SPI_CRCPolynomial = 7;
SPI_Init(SPI1, &SPI_InitStructure);
SPI_Cmd(SPI1, ENABLE);
MySPI_W_SS(1); //默认给SS高电平,即默认不选择从机
}
void MySPI_Start(void)
{
MySPI_W_SS(0);
}
void MySPI_Stop(void)
{
MySPI_W_SS(1);
}
完成交换字节的函数
整体步骤:1,等待TXE为1 2,写入发送的数据至TDR 3,等待RXNE为1 4,读取RDR接受的数据 之后交换第二个字节重复上述步骤
在我们发送和接收DR时,TXE和RXNE会自动清除,也就不需要我们调用ClearFlag函数手动清除
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) != SET);
SPI_I2S_SendData(SPI1, ByteSend);
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) != SET);
return SPI_I2S_ReceiveData(SPI1);
}
完整MySPI.c
#include "stm32f10x.h" // Device header
void MySPI_W_SS(uint8_t BitValue)
{
GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}
void MySPI_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
SPI_InitTypeDef SPI_InitStructure;
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128;
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;
SPI_InitStructure.SPI_CRCPolynomial = 7;
SPI_Init(SPI1, &SPI_InitStructure);
SPI_Cmd(SPI1, ENABLE);
MySPI_W_SS(1);
}
void MySPI_Start(void)
{
MySPI_W_SS(0);
}
void MySPI_Stop(void)
{
MySPI_W_SS(1);
}
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) != SET);
SPI_I2S_SendData(SPI1, ByteSend);
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) != SET);
return SPI_I2S_ReceiveData(SPI1);
}
十三,RTC实时时钟
实时时钟本质上是一个独立的定时器,但这个定时器是专门用来产生年月日分秒,这种日期和时间信息的,STM32内部拥有一个独立运行的钟表,想要记录或读取日期和时间就可以通过操作RTC来实现。
RTC外设和PWR和BKP这两章的关联性比较强
1,Unix时间戳
(1)简介
由时间戳得到年月日时秒是一个复杂的计算过程,但计算步骤是确定的,C语言官方在time.h库都已经写好了秒计数器转换日期时间,日期时间转换秒计数器的函数。当然也可以在网站上找转换器 工具
(2)UTC/GMT
(3)时间戳转换(time.h)
函数time_t time(time_t*);
#include<stdio.h>
#include<time.h>
time_t time_cnt;//time_t数据类型是一个typedef重命名的数据类型。time_t是64位有符号的整型
struct tm time_data;//struct tm数据类型。他包括的成员如下图
char* time_str;
int main(void)
{
//第一个函数time_t time(time_t*);这个函数既可以通过返回值获取系统时钟
//又可以通过输出参数获取系统时钟。
//此函数可以直接读取电脑的时间。但是在STM32里是用不了的,因为STM32是一个离线的裸机系统
//它不知道现在是何时
time_cnt = time(NULL);//用返回值返回可以给NULL
time(&time_cnt);//使用输出参数返回
}
函数struct tm* gmtime(const time_t*);
#include<stdio.h>
#include<time.h>
time_t time_cnt;
struct tm time_data;
char* time_str;
int main(void)
{ //函数struct tm* gmtime(const time_t*);将秒计数器值转换为伦敦时间。
//返回值为日期时间。输入值为计数器值
time_cnt = 1672588795;//随便给一个秒计数器值
time_data = *gmtime(&time_cnt);
printf("%d\n", time_data.tm_year + 1900);//年.上述结构体看过了,年从1900年开始,要加上1900
//的偏移
printf("%d\n", time_data.tm_mon + 1);//月,从一月经过的年份,加1的偏移
printf("%d\n", time_data.tm_mday);//日
printf("%d\n", time_data.tm_hour);//时
printf("%d\n", time_data.tm_min);//分
printf("%d\n", time_data.tm_sec);//秒
printf("%d\n", time_data.tm_wday);//星期
}
函数struct tm*localtime(const time_t*);这个函数和gmtime使用方法相同,区别就是localtime会根据时区自动添加小时偏移。直接在上述函数中将gmtime改为localtime即可,函数内部会根据当前电脑的设置自动判断我们处于哪个时区
函数time_t mktime(struct tm*)是上述两个函数的逆过程,将日期时间转换为秒计数器。mktime传入的日期时间需要是当地的。mktime的参数并没有加const,实际上这个参数既是输入参数也是输出参数,它内部的工作过程是这样的:日期时间结构体,里面是年月日时分秒星期等数据,但是仅通过年月日时分秒就足以算出秒计数器了,星期参数不作为输入参数,这个函数在算出秒数的同时还会顺便算一下当前年月日是星期几,然后回填到结构体里面的星期之中,这里就不显示这个功能了
#include<stdio.h>
#include<time.h>
time_t time_cnt;
struct tm time_data;
char* time_str;
int main(void)
{ //函数struct tm* gmtime(const time_t*);将秒计数器值转换为伦敦时间。
//返回值为日期时间。输入值为计数器值
time_cnt = 1672588795;//随便给一个秒计数器值
time_data = *gmtime(&time_cnt);
printf("%d\n", time_data.tm_year + 1900);//年.上述结构体看过了,年从1900年开始,要加上
//1900的偏移
printf("%d\n", time_data.tm_mon + 1);//月,从一月经过的年份,加1的偏移
printf("%d\n", time_data.tm_mday);//日
printf("%d\n", time_data.tm_hour);//时
printf("%d\n", time_data.tm_min);//分
printf("%d\n", time_data.tm_sec);//秒
printf("%d\n", time_data.tm_wday);//星期
time_cnt = mktime(&time_data);
}
剩下三个函数转换为字符串。实际上靠上述函数也能做到。这里仅演示ctime
#include<stdio.h>
#include<time.h>
time_t time_cnt;
struct tm time_data;
char* time_str;
int main(void)
{ //函数struct tm* gmtime(const time_t*);将秒计数器值转换为伦敦时间。
//返回值为日期时间。输入值为计数器值
time_cnt = 1672588795;//随便给一个秒计数器值
time_str ctime(&time_cnt);
printf(time_str)
}
输出结果如下(默认时间格式)
除上述函数外,还有一些函数未讲到。比如说计算程序运行时间的clock函数,计算两个时间之间差值的difftime等等
2,BKP备份寄存器
(1)简介
20字节(中容量和小容量)/ 84字节(大容量和互联型)
(2)基本结构
橙色部分可以叫做后备区域,BKP处于后备区域,除此以外,后备区域还有RTC的相关电路。STM32后备区域的特性就是当VDD主电源掉电时,后备区域仍然可以由VBAT的备用电池供电,当VDD主电源上电时,后备区域供电会由VBAT切换到VDD。
BKP里主要有数据寄存器,控制寄存器,状态寄存器和RTC时钟校准寄存器这些。数据寄存器是主要部分用来存储数据,每个数据寄存器为16位,一个寄存器存两个字节。BKP可以从PC13位置的TAMPER引脚引入一个检测信号,当TAMPER产生上升沿或者下降沿时清除BKP所有的内容,以确保安全;时钟输出可以把RTC的相关时钟从PC13位置的RTC引脚输出出去供外部使用,其中输出校准时钟时配合校准寄存器可以对RTC的误差进行校准
3,RTC外设
(1)简介
HSE时钟除以128(通常为8MHz/128)(8MHz频率太高即使有20位分频器也无法分到1Hz,所以先进行128分频)
LSE振荡器时钟(通常为32.768KHz)(提供给RTC的专属晶振一般都为32.768KHz)
LSI振荡器时钟(40KHz)
之前在RCC时钟树一节都说过各种时钟源 。内部晶振RC振荡一般没有外部晶振高,所以LSI可以作为备选方案。这三个时钟源最常用的是LSE振荡器时钟,它专门为RTC提供时钟,并且这三个时钟只有LSE时钟可以通过VBAT备用电池供电,上下两路时钟在主电源断电后是停止运行的。
(2)RTC框图
左边部分是核心的,分频和计数计时部分,右边一块为中断输出使能和NVIC部分,上面部分为APB1总线读写部分,下面一块是和PWR关联的部分(意思就是RTC的闹钟可以唤醒设备,退出待机模式),图中有灰色填充的部分都处于后备区域
首先看分频部分。输入时钟为RTCCLK,时钟进入后备区域首先经过RTC预分频器进行分频,这个分频器有两个寄存器组成,上面是重装载寄存器RTC_PRL,下面的RTC_DIV手册里叫做余数寄存器,实际上这一块和我们之前定时器时基单元里的计数器CNT和重装值ARR一样的作用,分频器其实就是一个计数器,记几个数溢出一次就是几分频,所以对于可编程的分频器来说需要两个寄存器,一个寄存器用来不断计数,另一个寄存器写入一个计数目标值用来设置是几分频(因为计数值包含了0,所以在RTC_PRL里写入n就是n+1分频),DIV是一个自减计数器,自减到0时再来一个输入时钟,DIV输出一个脉冲产生溢出信号,同时从PRL获取重装值,回到重装值继续自减
计时部分。RTC_CNT32位计数器使用Unix时间戳,这之前已经说过了。在其下方还设计有一个闹钟寄存器RTC_ALR,这个ALR也是一个32位的寄存器与CNT是等宽的,它是用来设置闹钟的,可以在ALR写一个秒数设定闹钟,当CNT值与ALR设定的闹钟值一样时就会产生RTC_Alarm闹钟信号通往右边的中断系统,在中断函数里可以执行相应的操作,同时这个闹钟还兼具一个功能,它可以让STM32退出待机模式
中断部分,有三个地方可以触发中断。第一个是RTC_Second秒中断,来源为CNT输入时钟,如果开启此中断则程序会每秒进入一次RTC中断;第二个是RTC_Overflow溢出中断,CNT32位计数器记满溢出了会触发一次中断,这个中断上一节说过,到2106年才会溢出;第三个RTC_Alarm闹钟中断,刚刚说过。RTC_CR是中断标志位,F结尾的为中断标志位,IE结尾的为中断使能,最后三个信号通过一个或门汇聚到NVIC中断控制器。
上面的APB1总线和APB1接口就是我们程序读写寄存器的地方了。
下面的退出待机模式还有一个WKUP (Wake Up)引脚,闹钟信号和WKUP引脚都可以唤醒设备
(3)RTC基本结构
RTCCLK时钟来源需要在RCC里配置
(4)RTC外部硬件电路
为了配合STM32的RTC,外部还是需要有一些电路的,在最小系统电路上,外部电路还要额外加两部分,第一部分就是备用电池,第二部分为外部低速晶振。电路原理图如下
备用电池一般选择下图所示的3V纽扣电池,型号为CR2032
(5)RTC操作注意事项
设置RCC_APB1ENR的PWREN和BKPEN,使能PWR和BKP时钟
设置PWR_CR的DBP,使能对BKP和RTC的访问
4,读写备份寄存器BKP
先初始化,然后写DR读DR
BKP的初始化步骤就是RTC操作注意事项的第一项,写入数据BKP有一个写入的函数,读取数据也有一个读取的函数
以下为BKP所有的库函数
- BKP_DeInit。这个函数可以用来手动清空BKP所有的数据寄存器
- BKP_TamperPinLevelConfig和BKP_TamperPinCmd。这两个函数用来配置TAMPER侵入检测功能。前者可以配置TAMPER引脚的有效电平(高电平触发还是低电平触发),后者是是否开启侵入检测功能。如果需要使用侵入检测的话,调用前者先配置TAMPER有效电平,再使能侵入检测功能即可
- BKP_ITConfig。中断配置,是否开启中断
- BKP_RTCOutputConfig。时钟输出功能的配置,可以选择在RTC引脚上输出时钟信号,输出RTC校准时钟,RTC闹钟脉冲或秒脉冲
- BKP_SetRTCCalibrationValue。设置RTC校准值,就是写入RTC校准寄存器。以上函数就是上节课所说的BKP的附加功能,了解即可
- BKP_WriteBackupRegister。写备份寄存器,第一个参数指定要写到哪个DR里,第二个参数就是要写入的数据了(16位整型数据)
- BKP_ReadBackupRegister。读备份寄存器,参数指定要读哪个DR,返回值就是DR里存的值
- FlagStatus BKP_GetFlagStatus(void);
void BKP_ClearFlag(void);
ITStatus BKP_GetITStatus(void);
void BKP_ClearITPendingBit(void);标志位函数
PWR外设的函数我们下节课讲,这里只关注PWR_BackupAccessCmd这个函数,备份寄存器访问使能。这个函数设置PWR_CR寄存器里的DBP位,使能对BKP和RTC的访问
初始化
1,开启PWR和BKP的时钟
#include "stm32f10x.h" // Device header
#include "OLED.h"
int main(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
}
2,使能对BKP和PWR的访问
#include "stm32f10x.h" // Device header
#include "OLED.h"
int main(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
}
3,写入DR和读出DR
BKP_WriteBackupRegister函数的第一个参数为BKP_DRx(x从1到42)。只有大容量和互联型才有42个DR,STM32F103属于中容量芯片,只有1~10
#include "stm32f10x.h" // Device header
#include "OLED.h"
int main(void)
{
OLED_Init();
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
BKP_WriteBackupRegister(BKP_DR1, 0x1234);
OLED_ShowHexNum(1, 3, BKP_WriteBackupRegister(BKP_DR1);, 4);
}
5,读写RTC时钟
库函数
以下为RCC中与RTC时钟相关的函数。RCC_LSEConfig用于配置LSE外部低速时钟,用于启动LS时钟;RCC_LSICmd用于配置LSI内部低速时钟;RCC_RTCCLKConfig用于选择RTCCLK的时钟源;RCC_RTCCLKCmd,调用完选择时钟源的函数后调用此函数使能一下
FlagStatus RCC_GetFlagStatus(uint8_t RCC_FLAG);调用启动时钟的函数后,还需要等待一下标志位,等RCC的标志位LSERDY置1后这个时钟才能启动完成
- RTC_ITConfig配置中断输出
- RTC_EnterConfigMode进入配置模式(置CRL的CNF位为1,进入配置模式),对应RTC注意事项的第三点,即RTC必须进入配置模式才可以i写入RTC_PRL,RTC_CNT,RTC_ALR寄存器
- RTC_ExitConfigMode退出配置模式
- RTC_GetCounter获取CNT计数器的值,用于读取时钟
- RTC_SetCounter写入CNT计数器的值,用于设置时间
- RTC_SetPrescaler写入预分频器
- RTC_SetAlarm写入闹钟值
- RTC_GetDivider读取预分频器中的DIV余数寄存器
- RTC_WaitForLastTask等待上次操作完成
- RTC_WaitForSynchro等待同步
- 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外设之前,也要像读写BKP一样先开启PWR和BKP的时钟,使能BKP和RTC的访问
- 第二步启动RTC时钟(使用RCC模块里的函数,开启LSE的时钟,LSE时钟为了省电默认是关闭的,所以需要我们调用函数手动开启)
- 第三步配置RTCCLK这个数据选择器,指定LSE为RTCCLK(函数也在RCC模块里)
- 执行注意事项里的等待函数。第一个是等待时钟同步,第二个是等待上一步操作完成。这两个函数调用一下即可
- 配置预分频器
- 配置CNT的值,给RTC一个初始时间。如果需要闹钟的话可以配置闹钟值。需要中断的话可以配置中断部分。
1,开启PWR和 BKP时钟,使能PWR和BKP的访问
#include "stm32f10x.h" // Device header
#include <time.h>
uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};
void MyRTC_SetTime(void);
void MyRTC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
}
2,开启LSE时钟,并等待启动完成
RCC_LSEConfig参数有3个,如下图,分别是LSE振荡器关闭,LSE振荡器打开,LSE时钟旁路(时钟旁路就是不接晶振直接从OSE32_IN这个引脚输入一个指定频率的信号)
#include "stm32f10x.h" // Device header
#include <time.h>
uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};
void MyRTC_SetTime(void);
void MyRTC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
RCC_LSEConfig(RCC_LSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);//等待启动完成
}
3,选择RTCCLK时钟源并使能
#include "stm32f10x.h" // Device header
#include <time.h>
uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};
void MyRTC_SetTime(void);
void MyRTC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
RCC_LSEConfig(RCC_LSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);//等待启动完成
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
RCC_RTCCLKCmd(ENABLE);
}
4,等待时钟同步和上一次写入操作完成
#include "stm32f10x.h" // Device header
#include <time.h>
uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};
void MyRTC_SetTime(void);
void MyRTC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
RCC_LSEConfig(RCC_LSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);//等待启动完成
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
RCC_RTCCLKCmd(ENABLE);
RTC_WaitForSynchro();
RTC_WaitForLastTask();
}
这两行代码是一个安全保障措施,防止因为时钟不同步造成bug。等待同步只需程序一开始调用一下。等待上一次操作完成,在此函数的上面我们并没有写入过RTC寄存器,所以正常情况下是不用等的。这两行不写也没啥大问题,写了之后也对程序没任何影响
5,配置预分频器
目前分频器输入时钟为LSE32.768KHz,需要进行32768分频。所以函数参数,分频系数要给32768-1
#include "stm32f10x.h" // Device header
#include <time.h>
uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};
void MyRTC_SetTime(void);
void MyRTC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
RCC_LSEConfig(RCC_LSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);//等待启动完成
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
RCC_RTCCLKCmd(ENABLE);
RTC_WaitForSynchro();
RTC_WaitForLastTask();
RTC_SetPrescaler(32768 - 1);
RTC_WaitForLastTask();//每次写操作之后等待写入操作完成
}
6,配置CNT的值,给RTC一个初始时间
#include "stm32f10x.h" // Device header
#include <time.h>
uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};
void MyRTC_SetTime(void);
void MyRTC_Init(void)
{
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);
RCC_LSEConfig(RCC_LSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);//等待启动完成
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
RCC_RTCCLKCmd(ENABLE);
RTC_WaitForSynchro();
RTC_WaitForLastTask();
RTC_SetPrescaler(32768 - 1);
RTC_WaitForLastTask();//每次写操作之后等待写入操作完成
RTC_SetCounter(1672588795);
RTC_WaitForLastTask();
MyRTC_SetTime();//设置时间函数,下面会写。每次初始化都设置一次时间
}
以上就是配置RTC的基本步骤。但是给的时间是固定的。接下来利用time.h获取并写入当地时间
实时时钟
STM32中的time.h函数与之前说的C语言编译器里的函数稍有不同。
首先在STM32中的time_t是unsigned int类型的,也就是无符号的32位整型。在struct tm结构体里的秒范围是0~60,允许偶尔的闰秒。time函数本来是用来获取系统时钟的,但是STM32中没有系统时钟,所以这个函数用不了。还有一个问题就是STM32并不知道当前处于哪个时区,所以gmtime和localtime就没有区别了,STM32中只用localtime,不能使用gmtime,并且始终都是0时区。
由于STM32中不能调用time.h函数,所以这个实时时间只能我们通过数组写入到RTC中
//设置时间函数。将数组里时间写入到RTC里
void MyRTC_SetTime(void)
{
time_t time_cnt;
struct tm time_date;
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];
time_cnt = mktime(&time_date) - 8 * 60 * 60; //时区偏移。北京时间
RTC_SetCounter(time_cnt);
RTC_WaitForLastTask();
}
//读取时间函数
void MyRTC_ReadTime(void)
{
time_t time_cnt;
struct tm time_date;
time_cnt = RTC_GetCounter() + 8 * 60 * 60; //时区偏移。北京时间
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;
}
每次复位时钟都会重置,这是因为每次复位后我们都调用了初始化函数,初始化函数里我们给他手动把时间重置了,所以初始化代码要有判断的执行(主电源和备用电源都断电了再初始化),利用刚才学的BKP来检测系统是否完全断电,我们可以在BKP里随便写入一个数据,如果上电检测这个数据没清零则表示备用电池存在
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_LSEConfig(RCC_LSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
RCC_RTCCLKCmd(ENABLE);
RTC_WaitForSynchro();
RTC_WaitForLastTask();
RTC_SetPrescaler(32768 - 1);
RTC_WaitForLastTask();
MyRTC_SetTime();
BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);
}
else
{
RTC_WaitForSynchro();
RTC_WaitForLastTask();
}
}
最终代码
#include "stm32f10x.h" // Device header
#include <time.h>
uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};
void MyRTC_SetTime(void);
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_LSEConfig(RCC_LSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
RCC_RTCCLKCmd(ENABLE);
RTC_WaitForSynchro();
RTC_WaitForLastTask();
RTC_SetPrescaler(32768 - 1);
RTC_WaitForLastTask();
MyRTC_SetTime();
BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);
}
else
{
RTC_WaitForSynchro();
RTC_WaitForLastTask();
}
}
//如果LSE无法起振导致程序卡死在初始化函数中
//可将初始化函数替换为下述代码,使用LSI当作RTCCLK
//LSI无法由备用电源供电,故主电源掉电时,RTC走时会暂停
/*
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);
while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
RCC_RTCCLKCmd(ENABLE);
RTC_WaitForSynchro();
RTC_WaitForLastTask();
}
}*/
void MyRTC_SetTime(void)
{
time_t time_cnt;
struct tm time_date;
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];
time_cnt = mktime(&time_date) - 8 * 60 * 60;
RTC_SetCounter(time_cnt);
RTC_WaitForLastTask();
}
void MyRTC_ReadTime(void)
{
time_t time_cnt;
struct tm time_date;
time_cnt = RTC_GetCounter() + 8 * 60 * 60;
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;
}
十四,PWR电源控制
1,简介
重点学习三种低功耗模式:睡眠模式,停机模式和待机模式。注意处于低功耗模式下的STM32不能随便下载程序,需要按住复位键不放,再下载程序,下载完成后松开复位键。
PWR(Power Control)电源控制,负责管理STM32内部的电源供电部分,可以实现可编程电压监测器和低功耗模式的功能。可编程电压监测器(PVD)可以监控VDD电源电压,当VDD下降到PVD阀值以下或上升到PVD阀值之上时,PVD会触发中断,用于执行紧急关闭任务。低功耗模式包括睡眠模式(Sleep)、停机模式(Stop)和待机模式(Standby),可在系统空闲时,降低STM32的功耗,延长设备使用时间
2,电源框图
下图就是STM32内部的供电方案。整体来看,可以分为三个部分,最上面为模拟部分供电,叫做VDDA(VDD Analog),中间为数字部分供电,包括VDD供电区域和1.8V供电区域,最下面是后备供电叫做VBAT
VDDA供电区域,主要负责模拟部分的供电,其中包括A/D转换器,温度传感器,复位模块,PLL锁相环,这些电路的供电正极为VDDA,负极是VSSA。其中,A/D转换器还有两根参考电压的供电脚VREF+和VREF-,这两个脚在引脚多的型号里会单独引出,在引脚少的型号(比如STM32F103C8T6)这两个引脚在内部已经分别接到了VDDA和VSSA
数字部分供电,由两部分组成,左边区域为VDD供电区域,包括IO口,待机电路,唤醒逻辑和独立看门狗,右边部分是VDD通过电压调节器降压到1.8V,提供给CPU核心,存储器和内置数字外设,可以看出STM32内部的大部分关键电路都是以1.8Vd低电压运行的,使用低电压运行主要目的是为了降低功耗
3,上电复位和掉电复位
当VDD或者VDDA电压过低时,内部电路直接产生复位,让STM32复位住不乱操作;复位与不复位的界限之间设置了一个40mV迟滞电压,大于上限POR时解除复位,小于下限PDR时复位。复位信号RESET低电平有效。
电压上限和下限具体是多少伏,解除复位的滞后时间是多久,这些需要去查看手册
4,可编程电压检测器
它的工作流程和上面的上电复位掉电复位差不多,都是监测VDD和VDDA的供电电压,但是PVD的区别是它的阈值电压可以使用程序指定,可以自定义调节,调节范围如下图手册。配置PLS寄存器的3个位可以选择右边的阈值。
PVD的监测范围在2.2V到2.9V之间,复位电路的监测范围在1.9V左右。PVD触发之后芯片可以正常工作,只不过是电源电压过低,提醒一下用户,复位电路触发后,芯片就不工作了,被复位住。
下图所示,PVD触发时为高电平,这个信号可以去申请中断,在上升沿或者下降沿触发中断,PVD的中断申请是通过外部中断实现的,可以看一下之前学过的外部中断EXTI基本结构图。
5,3低功耗模式
(1)睡眠,停机,待机总体介绍和比较
低功耗模式有睡眠,停机,待机这三种,从上到下关闭的电路越来越多,也越来越省电,同时从上到下也越来越难唤醒。
睡眠模式是浅睡眠,直接调用WFI(Wait For Interrupt)或者WFE(Wait For Event)可进入(WFI和WFE为内核指令,对应库函数里也有对应的函数),调用WFI进入的睡眠模式任何外设发生任何中断芯片都会立刻退出睡眠模式,醒来之后处理中断;调用WFE进入的睡眠模式可以是外部中断配置的事件模式产生事件或者使能了中断但是没有配置NVIC而产生的事件唤醒,醒来之后一般不需要进入中断函数,直接从睡的地方继续运行。它们两者对电路的影响是只把CPU时钟关了,对其他电路没有任何操作,CPU时钟关了程序就会暂停不会继续运行了。这里没有关闭电压调节器,也就是没有关闭1.8V区域的供电,这时只是程序停止运行,但是寄存器和存储器里面保存的数据还可以维持不会消失
停机模式是深睡眠。要进入停机模式,需要将SLEEPDEEP位设置为1 ,PDDS这一位用来区分是停机模式还是待机模式,PDDS等于0进入停机模式,PDDS等于1进入待机模式,LPDS用来设置电压调节器是开启还是进入低功耗模式,LPDS等于0开启电压调节器,LPDS等于1电压调节器进入低功耗模式;将上述位设置好后再调用WFI或者WFE芯片就可以进入停止模式了。要想唤醒停止模式需要任一外部中断(之前在外部中断中学过PVD RTC闹钟 USB和ETH都借道了外部中断,所以中断通道才有20个),所以这四个信号都可以唤醒停止模式。另外,WFI开启的停机模式要用外部中断的中断模式唤醒,WFE要用外部中断的事件模式唤醒。由于没有关闭电压调节器,所以CPU和外设的寄存器数据都是维持原状的
待机模式。只有几个指定的信号才能唤醒待机模式,第一个是WKUP引脚的上升沿,第二个是RTC闹钟事件,第三个NRST引脚上的外部复位(即复位键),第四个IWDG独立看门狗复位。待机模式只留几个唤醒的功能和配和配合RTC及看门狗的低速时钟
(2)模式选择
这些寄存器库函数都已经帮我们封装好了,不用再自己配置
WFI和WFE两个指令是最终开启低功耗模式的触发条件,执行WFI或者WFE指令后,STM32进入低功耗模式,寄存器各位的配置决定STM32进入哪种低功耗模式。
如果想在中断函数里调用WFI/WFE,才需要考虑位SLEEPONEXIT是0还是1
- 睡眠模式
执行完WFI/WFE指令后,STM32进入睡眠模式,程序暂停运行,唤醒后程序从暂停的地方继续运行。SLEEPONEXIT位决定STM32执行完WFI或WFE后,是立刻进入睡眠,还是等STM32从最低优先级的中断处理程序中退出时进入睡眠。在睡眠模式下,所有的I/O引脚都保持它们在运行模式时的状态 WFI指令进入睡眠模式,可被任意一个NVIC响应的中断唤醒 WFE指令进入睡眠模式,。可被唤醒事件唤醒
- 停止模式
执行完WFI/WFE指令后,STM32进入停止模式,程序暂停运行,唤醒后程序从暂停的地方继续运行。1.8V供电区域的所有时钟都被停止,PLL、HSI和HSE被禁止,SRAM和寄存器内容被保留下来。在停止模式下,所有的I/O引脚都保持它们在运行模式时的状态。当一个中断或唤醒事件导致退出停止模式时,HSI被选为系统时钟(这一点要注意,程序默认是在SystemInit函数里的配置默认使用HSE外部高速时钟通过PLL倍频得到72MHz主频,但是进入停止模式后PLL和HSE都停止了,而退出停止模式时并不会自动帮我们开启PLL和HSE,而是默认用HSI的8MHz直接作为主频,所以我们一般在停止模式唤醒后第一时间重新启动HSE,配置主频为72MHz,只需要再调用一下SystemInit就行。)。当电压调节器处于低功耗模式下,系统从停止模式退出时,会有一段额外的启动延时。WFI指令进入停止模式,可被任意一个EXTI中断唤醒 WFE指令进入停止模式,可被任意一个EXTI事件唤醒
- 待机模式
执行完WFI/WFE指令后,STM32进入待机模式,唤醒后程序从头开始运行(与睡眠模式和停机模式有区别)。整个1.8V供电区域被断电,PLL、HSI和HSE也被断电,SRAM和寄存器内容丢失,只有备份的寄存器和待机电路维持供电 在待机模式下,所有的I/O引脚变为高阻态(浮空输入)。WKUP引脚的上升沿、RTC闹钟事件的上升沿、NRST引脚上外部复位、IWDG复位退出待机模式
(3)各模式下电流消耗的比较
下图为正常运行模式下的供应电流。可以看到使能所有外设比关闭所有外设更耗电,所以不需要的外设我们可以把它的时钟关掉;另外,降低主频也可以省电,所以如果需要设备连续运行,并且对于主频和性能没有很高的要求的话可以降低主频来省电
6,修改主频代码
降低主频可以用来降低功耗。
修改主频需要用到sysytem_stm32f10x.c和.h文件,这两个sysytem文件是用来配置系统时钟的,也就是配置RCC时钟树
下图为RCC时钟树的全部电路。左边为四个时钟源HSI HSE LSE LSI用于提供时钟,右边就是各个外设使用时钟的地方。其中用的最多的就是AHB时钟,APB1时钟和APB2时钟,另外还有一些时钟他们的来源不是AHB和APB,比如I2S的时钟直接来源于SYSCLK,USB的时钟直接来自PLL。我们主要研究的是外部的8MHz的晶振如何进行选择,如何进行倍频才能得到72MHz系统主频SYSCLK,系统主频又如何去分配才能得到AHB,APB1和APB2的时钟频率
以下是来自sysytem_stm32f10x.c的文件注释:
- sysytem这两个.c和.h文件提供了两个外部调用函数和一个外部可调用的变量。两个函数是SystemInit()和SystemCoreClockUpdate(),一个外部可调用变量是SystemCoreClock
- SystemInit()是用来配置时钟树的。使用HSE配置主频为72MHz就是这个函数完成的。这个函数在复位后,执行main函数之前在启动文件里自动调用了
- SystemCoreClock变量表示主频频率的值,想知道目前的主频为多少直接显示这个变量即可
- SystemCoreClockUpdate()用来更新上面的SystemCoreClock变量,因为此变量只有在最开始的一次赋值,之后如果改变了主频频根据当前时钟树的配置更新一下上面的变量即可率这个值不会自动跟着变换,调用一下此函数即可更新
下图即为.c文件里用来更改主频的宏定义,解除对应的注释即可选择想要的系统主频。注意,如果使用的是VL超值系列,可选主频只有两个,HSE的8M和24M,否则的话其他主频都可以选用。
系统启动外部晶振并倍频为72MHz的过程:首先进入SystemInit函数,先启动HSI,然后进行各种恢复缺省配置,最后调用SetSysClock。SetSysClock是一个分配函数,根据我们前面解除系统主频注释的宏定义选择执行不同的配置函数,比如SetSysClockTo72,To56等等。在SetSysClockTo72这些函数里才是真正的配置,比如To72就是选择HSE作为锁相环输入,之后锁相环进行9倍频,再选择锁相环输出作为主频即可。
一般不推荐修改主频!
7,睡眠模式代码
要启动睡眠模式只需要在主循环最后加上一个WFI指令即可。__WFI();或者__WFE();
实际上下图所示还涉及两个寄存器的位,是SLEEPDEEP和SLEEPONEXIT这两个位库函数并没有给我们提供一个比较方便的配置方法,暂时先不配置,直接使用默认值0即可。如果确实想配置,需要使用寄存器完成。这两个位位于内核的系统控制块,需要在Cortex内核手册查看。
如下图所示为手册寄存器内容
要控制寄存器配置模式可以在__WFI();前面写SCB ->SCR = 0x.. 0x..的值需要对照着上面的寄存器的位来给
8,停机模式代码
思路和睡眠模式代码比较像。睡眠模式的涉及的寄存器都是在内核里,跟PWR外设关系不大。所以没有用到PWR的库函数。停机模式涉及到内核之外的电路操作,就需要用到PWR外设了
库函数
PWR_DeInit(void)恢复缺省配置
PWR_BackupAccessCmd(FunctionalState NewState);使能后备区域的访问,上一节讲过
和PWR_PVDCmd(FunctionalState NewState);PWR_PVDLevelConfig(uint32_t PWR_PVDLevel);这两个是和PVD相关的函数。LevelConfig就是配置PVD阈值电压,Cmd是使能PVD功能
PWR_WakeUpPinCmd(FunctionalState NewState);使能位于PA0位置的WKUP引脚,配合待机模式使用
PWR_EnterSTOPMode(uint32_t PWR_Regulator, uint8_t PWR_STOPEntry);进入停止模式。调用这个函数就可以进入停止(停机)模式了。第一个参数是指定电压调节器在停止模式里的状态,开启或者低功耗;第二个参数是停止模式的入口参数,选择WFI或者WFE进入停止模式
PWR_EnterSTANDBYMode(void);进入待机模式
PWR_GetFlagStatus(uint32_t PWR_FLAG);P获取标志位和清除标志位WR_ClearFlag(uint32_t PWR_FLAG);
代码演示
首先开启PWR外设时钟,然后调用函数PWR_EnterSTOPMode即可。在此函数内会将SLEPPDEEP,PDDS和LPDS位都配置好
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "CountSensor.h"
int main(void)
{
OLED_Init();
CountSensor_Init();
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
OLED_ShowString(1, 1, "Count:");
while (1)
{
OLED_ShowNum(1, 7, CountSensor_Get(), 5);
OLED_ShowString(2, 1, "Running");
Delay_ms(100);
OLED_ShowString(2, 1, " ");
Delay_ms(100);
PWR_EnterSTOPMode(PWR_Regulator_ON, PWR_STOPEntry_WFI);
SystemInit();//之前说过当一个中断或唤醒事件导致退出停止模式时,HSI会被选为系统时钟
//在我们首次复位后,SystemInit函数配置的是HSE*9倍频的72主频
//而停止模式退出后HSI8M主频选为系统时钟,导致程序运行变慢。
//只需要在停止模式之后重新调用SystemInit函数启动HSE即可
}
}
9,待机模式代码
下列代码是待机模式+RTC闹钟事件唤醒
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyRTC.h"
int main(void)
{
OLED_Init();
MyRTC_Init();
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
OLED_ShowString(1, 1, "CNT :");
OLED_ShowString(2, 1, "ALR :");
OLED_ShowString(3, 1, "ALRF:");
PWR_WakeUpPinCmd(ENABLE);
uint32_t Alarm = RTC_GetCounter() + 10;
RTC_SetAlarm(Alarm);
OLED_ShowNum(2, 6, Alarm, 10);
while (1)
{
OLED_ShowNum(1, 6, RTC_GetCounter(), 10);
OLED_ShowNum(3, 6, RTC_GetFlagStatus(RTC_FLAG_ALR), 1);
OLED_ShowString(4, 1, "Running");
Delay_ms(100);
OLED_ShowString(4, 1, " ");
Delay_ms(100);
OLED_ShowString(4, 9, "STANDBY");
Delay_ms(1000);
OLED_ShowString(4, 9, " ");
Delay_ms(100);
OLED_Clear(); //尽可能将外设模块全部关掉,最大化省电
PWR_EnterSTANDBYMode();
}
}
使用WKUP引脚唤醒时不需要配置GPIO。这是因为使能WKUP引脚后,WKUP引脚被强置为输入下拉的配置。直接调用函数PWR_WakeUpPinCmd(ENABLE);函数即可使能WKUP引脚
十五,看门狗
1,简介
WDG(Watchdog)看门狗。看门狗可以监控程序的运行状态,当程序因为设计漏洞、硬件故障、电磁干扰等原因,出现卡死或跑飞现象时,看门狗能及时复位程序,避免程序陷入长时间的罢工状态,保证系统的可靠性和安全性。看门狗一旦启动,无法关闭。
看门狗本质上是一个定时器,当指定时间范围内,程序没有执行喂狗(重置计数器)操作时,看门狗硬件电路就自动产生复位信号。
STM32内置两个看门狗 独立看门狗(IWDG):独立工作(独立看门狗的时钟是专用的LSI,内部低速时钟。即使主时钟出现问题了,看门狗也能正常工作),对时间精度要求较低(只有一个最晚时间界限,喂狗间隔不超过最晚时间界限即可) 窗口看门狗(WWDG):要求看门狗在精确计时窗口起作用 (喂狗时间有一个最晚的界限,也有一个最早的界限,必须在这个界限的窗口内喂狗)(窗口看门狗使用的是APB1的时钟,没有专用的时钟)
2,IWDG
(1)IWDG框图
它的结构和定时器非常相似,只不过定时器溢出产生中断,而看门狗定时器溢出直接产生复位信号。喂狗操作也就是重置12位递减计数器
可以类比定时器时基单元来看。LSI内部低速时钟40kHz先进入到分频器进行分频,最大256分频,寄存器IWDG_PR可以配置分频系数(PR和定时器PSC是一个意思),预分频器经过分频后时钟驱动递减计数器,计数器最大值为4095,自减到0时产生IWDG复位,正常运行时为了避免复位需要提前在重装载寄存器写一个值,当我们预先写好值后,在运行过程中,我们在键寄存器里写入一个特定数据控制电路进行喂狗,这时重装值就会复制到当前寄存器里。状态寄存器SR是标志电路运行的状态,里面只有两个更新同步位,基本不用看
(2)IWDG键寄存器
键寄存器本质上是控制寄存器,用于控制硬件电路的工作。在可能存在干扰的情况下,一般通过在整个键寄存器写入特定值来代替控制寄存器写入一位的功能,以降低硬件电路受到干扰的概率(所以不单独设置一位来执行控制,降低误操作概率)
(3) IWDG超时时间
超时时间:TIWDG = TLSI × PR预分频系数 × (RL + 1) (其中:TLSI = 1 / FLSI)。对应定时器的话此公式就是72M/(PSC+1)/(ARR+1)
3,WWDG
(1)WWDG框图
左下角为PCLK1时钟源;右边为预分频器;预分频器上面为6位递减计数器CNT,这个计数器是位于控制寄存器CR里的,计数器和控制寄存器合二为一了,窗口看门狗没有重装寄存器,直接在CNT里写入数据即可。看门狗配置寄存器(WWDG_CFR)是窗口值,也就是喂狗的最早时间界限;左边为输出信号的操作逻辑
它的操作流程:时钟来源APB1的PCLK1,默认为36MHz,先经过一个预分频器进行分频,分频之后的时钟驱动6位递减计数器进行计数(只有T5~T0是有效的计数值,最高位T6用来当作溢出标志位,T6=0时表示计数器溢出。如果将T6也当作计数器的一部分,那整个计数器值减到0x40即1000000之后溢出;如果将T6当作溢出标志位,则低六位计数值减到0之后溢出) 。WDGA位为窗口看门狗的激活位,也就是使能,WDGA写1启用窗口看门狗。当递减计数器溢出时,T6置为0,0经过取反或门变为1,进入与门产生复位,这就是最晚时间界限的原理过程,与IWDG类似。
喂狗时间的最早界限由上方的WWDG_CFR来决定,,首先我们要计算一个最早界限的计数值,写入到W6~W0中,这些值写入之后是固定不变的。在比较器左边的与门下方输入"写入WWDG_CR",一旦我们执行写入操作时与门开关就会打开,写入CR实际上也就是写入计数器(喂狗),在喂狗时比较器开始工作,一旦当前的计数器T6:0大于窗口值W6:0,比较结果就等于1,也可以通过或门去复位。
(2)WWDG工作特性
递减计数器T[6:0]的值小于0x40时,WWDG产生复位
递减计数器T[6:0]在窗口W[6:0]外被重新装载时,WWDG产生复位(不能过早喂狗)
递减计数器T[6:0]等于0x40时可以产生早期唤醒中断(EWI)(溢出前一刻发生,也被称为"死前中断",提醒你马上就要中断了。也可以在早期中断产生直接喂狗,阻止系统复位),用于重装载计数器以避免WWDG复位
定期写入WWDG_CR寄存器(喂狗)以避免WWDG复位
下图不允许刷新也就是不允许喂狗,否则会喂狗太早产生复位,刷新窗口允许喂狗。
(3)WWDG超时时间
超时时间:TWWDG = TPCLK1 × 4096 × WDGTB预分频系数 × (T[5:0] + 1)
窗口时间(喂狗最早时间):TWIN = TPCLK1 × 4096 × WDGTB预分频系数 × (T[5:0] - W[5:0]) 其中:TPCLK1 = 1 / FPCLK1
4,IWDG和WWDG的比较
5,独立看门狗代码
(1)配置流程
根据框图进行配置
- 开启LSI时钟。开启LSI代码并不需要我们来写,这是因为如果独立看门狗已经被硬件选项或软件启动,LSI振荡器将被强制在打开状态,并且不能关闭;LSI稳定后,时钟供应给IWDG
- 写入预分频寄存器和重装寄存器(写入之前不要忘记键寄存器的写保护,要先解除写保护)
- 在键寄存器中输入指令0xCCCC来启动独立看门狗
- 在主循环里不断执行指令0xAAAA来进行喂狗
(2)库函数
IWDG_WriteAccessCmd(uint16_t IWDG_WriteAccess);写使能控制,就是在键寄存器写入参数0x5555。参数IWDG_WriteAccess可以是IWDG_WriteAccess_Enable或者IWDG_WriteAccess_Disnable,对应0x5555和0x0000。
IWDG_SetPrescaler(uint8_t IWDG_Prescaler);写预分频器
IWDG_SetReload(uint16_t Reload);写重装值
IWDG_ReloadCounter(void);重新装载寄存器,就是喂狗。其函数内部操作就是在键寄存器写入值0xAAAA
IWDG_Enable(void);启动独立看门狗。其函数内部操作就是在键寄存器写入值0xCCCC
FlagStatus IWDG_GetFlagStatus(uint16_t IWDG_FLAG);获取标志位状态
下列函数为RCC文件里的查看标志位函数,通过此函数可以知道程序复位的时候是看门狗导致的复位还是上电或复位键导致的复位
FlagStatus RCC_GetFlagStatus(uint8_t RCC_FLAG);它可以查看的标志位如下。主要分为两种,一种是RCC_FLAG_HSIRDY RCC_FLAG_HSERDY等等的这些时钟的Ready。另一种就是各种Reset标志位,RCC_FLAG_PINRST是按键复位的时候置1,RCC_FLAG_PORRST为上电复位和掉电复位,RCC_FLAG_SFTRST为软件复位,RCC_FLAG_IWDGRST是独立看门狗复位,RCC_FLAG_WWDGRST是窗口看门狗复位,RCC_FLAG_LPWRRST为低功耗的复位
void RCC_ClearFlag(void);查看完标志位调用此函数清除一下
(3)代码演示
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Key.h"
int main(void)
{
OLED_Init();
Key_Init();
OLED_ShowString(1, 1, "IWDG TEST");
if (RCC_GetFlagStatus(RCC_FLAG_IWDGRST) == SET)//如果是独立看门狗复位
{
//闪烁一个字符串
OLED_ShowString(2, 1, "IWDGRST");
Delay_ms(500);
OLED_ShowString(2, 1, " ");
Delay_ms(100);
RCC_ClearFlag();//必须手动清零
}
else
{
OLED_ShowString(3, 1, "RST");
Delay_ms(500);
OLED_ShowString(3, 1, " ");
Delay_ms(100);
}
IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable);//解除写保护
IWDG_SetPrescaler(IWDG_Prescaler_16);//尽可能选择预分频系数小的
IWDG_SetReload(2499); //1000ms
IWDG_ReloadCounter(); //启动之前喂一次狗
IWDG_Enable(); //启动看门狗。喂狗或者使能的时候会在键寄存器写入5555之外的值
//这时就顺便给寄存器写保护了,所以写完寄存器后不需要手动执行写保护
while (1)
{
Key_GetNum();//按键按下不放时,主函数就会阻塞
IWDG_ReloadCounter();//主循环里不断喂狗
OLED_ShowString(4, 1, "FEED");
Delay_ms(200);
OLED_ShowString(4, 1, " ");
Delay_ms(600);
}
}
6,窗口看门狗代码
(1)配置流程
窗口看门狗时钟来源是PCLK1
- 开启窗口看门狗APB1的时钟
- 配置各个寄存器(窗口看门狗没有写保护)。预分频器,窗口值(看门狗配置寄存器)
- 写入控制寄存器CR。包含看门狗使能位,计数器溢出标志位和计数器有效位
- 运行过程中不断向计数器写入想要的重装值就可以进行喂狗了
(2)库函数
WWDG_DeInit(void);恢复缺省配置
WWDG_SetPrescaler(uint32_t WWDG_Prescaler);写入预分频器
WWDG_SetWindowValue(uint8_t WindowValue);写入窗口值
WWDG_EnableIT(void);使能中断
WWDG_SetCounter(uint8_t Counter);写入计数器,喂狗就用这个函数
WWDG_Enable(uint8_t Counter);使能窗口看门狗。这个函数的参数和计数器参数是一样的,这是因为递减计数器是自由运行状态,在使能时可以是任何值,为了避免刚一使能就立马复位,所以在使能时需要顺便喂一下狗
FlagStatus WWDG_GetFlagStatus(void);
void WWDG_ClearFlag(void);获取标志位和清除标志位的函数
(3)代码演示
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Key.h"
int main(void)
{
OLED_Init();
Key_Init();
OLED_ShowString(1, 1, "WWDG TEST");
if (RCC_GetFlagStatus(RCC_FLAG_WWDGRST) == SET)
{
OLED_ShowString(2, 1, "WWDGRST");
Delay_ms(500);
OLED_ShowString(2, 1, " ");
Delay_ms(100);
RCC_ClearFlag();
}
else
{
OLED_ShowString(3, 1, "RST");
Delay_ms(500);
OLED_ShowString(3, 1, " ");
Delay_ms(100);
}
RCC_APB1PeriphClockCmd(RCC_APB1Periph_WWDG, ENABLE); //开启时钟
WWDG_SetPrescaler(WWDG_Prescaler_8);
WWDG_SetWindowValue(0x40 | 21); //窗口时间30ms
WWDG_Enable(0x40 | 54); //超时时间50ms,将T6位设置为1,所以用|,也可以是+
while (1)
{
Key_GetNum();
OLED_ShowString(4, 1, "FEED");
Delay_ms(20);
OLED_ShowString(4, 1, " ");
Delay_ms(20);
WWDG_SetCounter(0x40 | 54);//喂狗。将此函数放到最后是为了和WWDG_Enable隔开
//否则会因为喂狗时间过短而造成循环复位
}
}
十六,FLASH闪存
1,简介
闪存是一个通用的名词,表示的是一种非易失性,也就是掉电不丢失的存储器。之前学习SPI时用的W25Q64芯片就是一种闪存存储器芯片,而本节所说的闪存,特指STM32的内部闪存,也就是下载程序时程序存储的地方。
STM32F1系列的FLASH包含程序存储器、系统存储器和选项字节三个部分,通过闪存存储器接口(外设)可以对程序存储器和选项字节进行擦除和编程(在DMA一节有介绍过ROM区的程序存储器FLASH,系统存储器和选项字节。程序存储器是最大最主要的部分,所以也称作主存储器)(闪存存储器接口是一个外设,是闪存的管理员,我们需要将指令和数据写入到这个外设的相应寄存器。然后这个外设就会自动去操作相应的存储空间)
读写FLASH的用途:1,利用程序存储器的剩余空间来保存掉电不丢失的用户数据 2,通过在程序中编程(IAP),实现程序的自我更新(在程序中编程,利用程序来修改程序本身,实现程序的自我更新)
在线编程(In-Circuit Programming – ICP)用于更新程序存储器的全部内容,它通过JTAG、SWD协议或系统加载程序(Bootloader)下载程序(比如我们一直在用的STLINK)
在程序中编程(In-Application Programming – IAP)可以使用微控制器支持的任一种通信接口下载程序(实现过程:在整个程序存储器中,自己写一个BootLoader程序,并且要存放在程序更新时不会覆盖的位置,需要更新程序时,控制程序跳转到自己写的BootLoader里来,在这里面我们就可以接收任意一种通信接口传过来的数据,比如串口,USB,蓝牙转串口,WIFI转串口等,这个传过来的数据就是待更新的程序,然后我们控制FLASH读写把收到的程序写入到前面,写完之后再控制程序跳转回正常运行的地方或者直接复位,这样程序就完成了自我升级)。这个过程其实就和系统存储器的BootLoader一样,程序需要自我升级在升级过程中肯定需要一个辅助来临时干活;而系统存储器的BootLoader写死了,只能用串口下载到指定位置,而且只能配置BOOT引脚触发启动。而我们自己写BootLoader的话,就可以想怎么收怎么收,想写到哪写到哪想怎么启动就怎么启动,并且这整个升级过程程序都可以自主完成,实现在程序中编程,更进一步,就可以直接实现远程升级了。
任何读写闪存的操作都会使CPU暂停,直到此次闪存编程结束
2,闪存模块组织
下表是中容量产品的闪存分配情况,C8T6芯片的闪存容量为64K属于中容量产品。小,中,大产品闪存分配方式不同,具体查看手册。信息块里面分为启动程序代码和用户选择字节,启动程序代码就是系统存储器,存放的是原厂写入的BootLoader用于串口下载,用户选择字节就是刚刚说的选项字节,存放一些独立的参数。闪存存储器接口寄存器,这一块的存储器实际上并不属于闪存,它是一个普通的外设,这个闪存存储器接口是上面闪存的管理员,用来控制擦除和编程(擦除和写保护都是以页为单位的。同为闪存,规则和W25Q64都是一样的,比如写入前必须擦除,擦除必须以最小单位进行,擦除后数据位全变为1擦除和写入之后需要等待忙等等)
3,FLASH基本结构图
4,FLASH解锁
这个和之前W25Q64一样,W25Q64操作之前需要写使能,FLASH操作之前需要解锁,都是为了防止误操作。
解锁方式和独立看门狗一样,都是通过在键寄存器写入指定的键值来实现
FPEC共有三个键值:
- RDPRT键 = 0x000000A5 解除读保护
- KEY1 = 0x45670123
- KEY2 = 0xCDEF89AB
解锁:复位后,FPEC被保护,不能写入FLASH_CR(即复位后FLASH默认是锁着的) , 在FLASH_KEYR先写入KEY1,再写入KEY2,解锁(两道锁才能解锁) 。错误的操作序列会在下次复位前锁死FPEC和FLASH_CR (也就是发现有程序在撬锁时,一旦没有先写入KEY1再写入KEY2,整个模块就会被锁死,除非复位)
加锁:解锁之后的加锁,设置FLASH_CR中的LOCK位锁住FPEC和FLASH_CR(即在LOCK位写1就能重新锁住闪存了)
5,使用指针访问存储器
STM32内部的存储器是直接挂在总线上的,所以读写某个存储器直接使用指针来访问即可。
#define __IO volatile
- 使用指针读指定地址下的寄存器(对于闪存的读取不需要进行解锁)
uint16_t Data = *((__IO uint16_t *)(0x08000000)); : 0x08000000为给定的要读取寄存器的地址(0x08000000外的括号可以不加,因为这里面只有一个数,如果要对其加减就要加上括号) ;__IO uint16_t *为强制类型转换,将其强制转换为uint16_t的指针类型,如果想以8位的方式读出指定的数据,就转换为uint8_t* ,_IO在STM32库函数中是一个宏定义,对应C语言的volatile(易变的数据),在其前面加上volatile是一个保障措施,在程序逻辑上没有作用,加上volatile就是为了防止编译器优化(Keil编译器默认情况下为最低优先等级,加不加volatile都没有影响。如果要提高编译器优化等级就会出现问题,编译器优化可以去除无用的繁杂代码降低代码空间,提升运行效率,但优化之后编译器可能在某些地方弄巧成拙,比如想用变量计数空循环的方式实现循环函数,编译器优化时就可能会优化掉这段代码,这时在变量前加上volatile就可以防止被优化掉)。另外,编译器还会利用缓存来加速代码,比如要频繁读写内存中的某个变量,最常见的优化方式就是先把变量转移到高速缓存里,在STM32内核里有一个类似缓存的工作组寄存器,这些寄存器的访问速度最快,先将变量放到缓存里需要读写时直接访问缓存就可以了,用完之后再写回内存。但是如果程序有多线程,比如中断函数,在中断函数里修改了原始变量,而缓存并不知道更改了,这就会造成数据更改不同步的问题,这时在读取变量定义的前面加上一个volatile高速编译器这个变量是易变的,每次读取都要从内存中找,不要用缓存优化。(__IO uint16_t *)(0x08000000)就是一个指针变量并且这个指针已经指向了0x08000000这个位置,最后使用*将指向的存储器内容指出来。
总结来说,如果开启了编译器优化,在无意义加减变量,多线程更改变量,读写与硬件相关的存储器时都需要加上volatile防止被编译器优化。告诉编译器我要直截了当的读取寄存器,不绕弯子
- 使用指针写指定地址下的存储器
*((__IO uint16_t *)(0x08000000)) = 0x1234;对于FLASH闪存需要提前解锁才能写入,对于SRAM可以直接读写
6,程序存储器全擦除
读取LOCK位。如果LOCK位为1,代表芯片锁住了,则执行解锁过程(解锁过程就是在KEYR寄存器先写入KEY1再写入KEY2) ,如果LOCK为0就是没锁住,首先置控制寄存器的MER位为1,再置STRT位为1(STRT为1为触发条件,芯片开始干活),芯片看到MER位为1它就知道要执行全擦除的操作。擦除过程也是需要时间的,擦除过程开始后程序要执行等待,判断状态寄存器的BSY位是否为1,为1表示芯片忙,BSY等于0则跳出循环,全擦除过程结束
7,程序存储器页擦除
与全擦除类似。置控制寄存器PER为1,告诉芯片要执行页擦除操作,AR寄存器里要提前写入一个页起始地址,告诉芯片要擦除哪一页
8,程序存储器编程
STM32闪存在写入之前会检查指定地址有没有擦除,如果没有擦除,STM32不会执行写入操作。置控制寄存器PG位为1表示即将写入数据,然后在指定地址写入半字(16位)写入操作只能以半字的形式写入(如果想写入32位就要分两次完成,如果想写入8位就只能把整页数据都读到SRAM,再随意修改SRAM数据,修改完成之后,再将整页擦除,最后把整页都写回去),写入数据的代码就触发开始条件,不需要置STRT位
9,选项字节
(1)介绍
带n的意思是(以RDP为例)在写入RDP时要对应的在nRDP中写入数据的反码,这样写入操作才是有效的,如果芯片检测到两个存储器不是反码关系,那就代表数据无效,有错误,对应的功能就不执行,这是一个安全保障措施。这个写入反码的过程,硬件会自动计算并写入,不需要我们自己操作
RDP:写入RDPRT键(0x000000A5)后解除读保护(如果RDP不是A5,那闪存就是读保护状态,无法通过调试器读取程序)
USER:配置硬件看门狗和进入停机/待机模式是否产生复位 Data0/1:用户可自定义使用
WRP0/1/2/3:配置写保护,每一个位对应保护4个存储页(中容量)(四个字节共32位,一位保护4页,总共保护128页,正好对应中容量产品的最大128页)
(2)选项字节擦除
- 检查FLASH_SR的BSY位,以确认没有其他正在进行的闪存操作
- 解锁FLASH_CR的OPTWRE位(选项字节的解锁。在解锁闪存后还需要再解锁选项字节,解锁选项字节和解锁闪存一样,需要在OPTKEYR先写入KEY1再写入KEY2)
- 设置FLASH_CR的OPTER位为1,表示即将擦除选项字节
- 设置FLASH_CR的STRT位为1,触发芯片开始干活
- 等待BSY位变为0
- 读出被擦除的选择字节并做验证(可以不需要)
(3)选项字节编程
- 检查FLASH_SR的BSY位,以确认没有其他正在进行的编程操作
- 解锁FLASH_CR的OPTWRE位
- 设置FLASH_CR的OPTPG位为1,表示即将写入字节
- 写入要编程的半字到指定的地址
- 等待BSY位变为0
- 读出写入的地址并验证数据(可以不需要)
10,器件电子签名
电子签名存放在闪存存储器模块的系统存储区域,包含的芯片识别信息在出厂时编写,不可更改,使用指针读指定地址下的存储器可获取电子签名。实际上就是STM32的ID号,存储在系统存储器,也就是说系统存储器不仅有BootLoader程序还有几个字节的ID号。
闪存容量寄存器: 基地址:0x1FFF F7E0 大小:16位 。它的值就是闪存的容量,单位为KB
产品唯一身份标识寄存器: 基地址: 0x1FFF F7E8 大小:96位。每个芯片的这96位数据都不一样。
11,读写FLASH
共两个底层模块,最底层叫做MyFLASH,在里面实现闪存最基本的3个功能(读取,擦除,编程 );在此模块之上再建一个模块叫做Store,主要实现参数数据的读写和存储管理,最终应用层(main.c)实现的功能是任意读写参数,并且这些参数掉电不丢失。在Store这一层会定义一个SRAM数组,需要掉电不丢失的参数就写到SRAM数组里,之后调用保存的函数,SRAM数组就自动备份到闪存里了,上电后,Store初始化,会自动把闪存里的数据读回到SRAM数组里
闪存不需要初始化,直接操作即可
(1)读取数据
直接将uint16_t Data = *((__IO uint16_t *)(0x08000000)); 封装一下即可
(2)擦除
使用上述说的程序存储器页擦除和全擦除的步骤,这两个功能分别对应一个库函数。执行函数之前手动调用一下解锁,执行之后再加锁一下
(3)编程
编程也有对应的函数,调用函数即可。也要解锁加锁。
选项字节的擦除和编程与主闪存的擦除和编程类似,也都对应了函数
(4)库函数
库函数被分为了三部分
第一部分是所有F10x设备都可以使用的函数
第二部分是所有设备都可以使用的,新的函数
第三部分是只有XL加大容量的设备才可以使用的,新的函数
本节只需要使用第一部分的函数。下面两部分新的函数是为了大容量XL产品的。在最开始的时候,这个F10系列只有小容量LD,中容量MD和大容量LD,之后加大容量XL才推出,XL直接又加了一块新的独立的闪存,所以XL产品总共有两块闪存,为了区分它们设计者命名新加的一块叫做Bank2,旧的一块叫做Bank1,加大容量推出后,这些函数又对XL系列进行了适配更新,每个函数做出了哪些更改可以在flash.c文件中查看
接下来依次看一下函数
前面三个函数是和内核运行代码有关的,不用我们调用
FLASH_Unlock(void);是用来解锁的
FLASH_Lock(void);加锁
FLASH_Status FLASH_ErasePage(uint32_t Page_Address);页擦除,参数给页的起始地址,返回值为完成状态,返回FLASH_BUSY表示芯片忙,返回FLASH_ERROR_PG表示编程错误,返回FLASH_ERROR_WRP表示写保护错误,返回FLASH_COMPLETE表示完成,返回FLASH_TIMEOUT表示等待超时
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);自定义Data0和Data1
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);获取读保护状态
FlagStatus FLASH_GetPrefetchBufferStatus(void);获取预取缓冲区状态,对应函数FLASH_PrefetchBufferCmd(uint32_t FLASH_PrefetchBuffer);,不需要了解
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.上面的写入函数内部都调用了这个函数,并不需要我们单独调用
(5)MyFLASH.c和MyFLASH.h
MyFLASH.c
#include "stm32f10x.h" // Device header
uint32_t MyFLASH_ReadWord(uint32_t Address)//读取32位字
{
return *((__IO uint32_t *)(Address));
}
uint16_t MyFLASH_ReadHalfWord(uint32_t Address)//读取16位半字
{
return *((__IO uint16_t *)(Address));
}
uint8_t MyFLASH_ReadByte(uint32_t Address)//读取8位字节
{
return *((__IO uint8_t *)(Address));
}
void MyFLASH_EraseAllPages(void)//全擦除
{
FLASH_Unlock();//解锁FLASH
FLASH_EraseAllPages();
FLASH_Lock();//锁住FLASH
}
void MyFLASH_ErasePage(uint32_t PageAddress)//页擦除。参数输入页起始地址
{
FLASH_Unlock();
FLASH_ErasePage(PageAddress);
FLASH_Lock();
}
void MyFLASH_ProgramWord(uint32_t Address, uint32_t Data)//写入字
{
FLASH_Unlock();
FLASH_ProgramWord(Address, Data);
FLASH_Lock();
}
void MyFLASH_ProgramHalfWord(uint32_t Address, uint16_t Data)//写入半字
{
FLASH_Unlock();
FLASH_ProgramHalfWord(Address, Data);
FLASH_Lock();
}
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 PageAddress);
void MyFLASH_ProgramWord(uint32_t Address, uint32_t Data);
void MyFLASH_ProgramHalfWord(uint32_t Address, uint16_t Data);
#endif
(6)Store.c和Store.h
基于MyFLASH层,实现掉电不丢失的存储
用SRAM缓存数组来管理FLASH的最后一页,实现参数的任意读写和保存。因为闪存每次都是擦除再写入,擦除之后还容易丢失数据,所以要想灵活管理数据,还是要靠SRAM数组,需要备份时再统一转到闪存里
Store.c
#include "stm32f10x.h" // Device header
#include "MyFLASH.h" //基于MyFLASH
#define STORE_START_ADDRESS 0x0800FC00
#define STORE_COUNT 512
uint16_t Store_Data[STORE_COUNT];//512个数据,每个数据16位两个字节,正好对应闪存1024字节
void Store_Init(void)
{
//首先要把闪存初始化一下。比如第一次使用这个代码,那闪存默认都为FF,参数和SRAM默认为0
//所以第一次使用要给闪存的各个参数都置0。以下if来判断闪存最后一页的第一个半字当作标志位
//0xA5A5是自己定义的标志位,如果不是A5A5就是第一次使用,
//如果是A5A5,就说明闪存已经保存过了,上电直接加载回备份的数据
if (MyFLASH_ReadHalfWord(STORE_START_ADDRESS) != 0xA5A5)
{
MyFLASH_ErasePage(STORE_START_ADDRESS);//擦除最后一页
MyFLASH_ProgramHalfWord(STORE_START_ADDRESS, 0xA5A5);//在第一个半字位置写入规定的标
//志位
for (uint16_t i = 1; i < STORE_COUNT; i ++)//把剩余的存储空间都置为默认值0
{
MyFLASH_ProgramHalfWord(STORE_START_ADDRESS + i * 2, 0x0000);
}
}
for (uint16_t i = 0; i < STORE_COUNT; i ++)//上电后将闪存数据全都转存到SRAM数组里
{
Store_Data[i] = MyFLASH_ReadHalfWord(STORE_START_ADDRESS + i * 2);
}
}
//想存储掉电不丢失的参数的时候,先任意更改Store_Data数组除了标志位的其他数据
//更改好之后将数组整体备份到闪存最后一页
void Store_Save(void) //备份保存
{
MyFLASH_ErasePage(STORE_START_ADDRESS);
for (uint16_t i = 0; i < STORE_COUNT; i ++)//备份保存到闪存
{
MyFLASH_ProgramHalfWord(STORE_START_ADDRESS + i * 2, Store_Data[i]);
}
}
void Store_Clear(void)//将SRAM数据全部清0
{
for (uint16_t i = 1; i < STORE_COUNT; i ++)//i从1开始,i=0为标志位,不能清零
{
Store_Data[i] = 0x0000;
}
Store_Save();//更新到闪存
}
Store.h
#ifndef __STORE_H
#define __STORE_H
extern uint16_t Store_Data[];
void Store_Init(void);
void Store_Save(void);
void Store_Clear(void);
#endif
(7)补充
一般情况下,最后一页存储用户数据并不会和程序代码存储的数据冲突,如果程序代码很大,我们也可以通过一些操作将程序代码限定在一定范围内,不让它分配到我们用户数据空间来
点击魔法棒图标,如下。片上ROM起始地址是08000000,它的size是0x10000默认全部的64K闪存都是程序代码分配的空间,如果想把闪存的尾部空间自己留着用,可以把程序空间改小点,比如改成0xFC00。下载程序的起始地址也可以修改,比如想写一个BootLoader程序放在闪存尾部,则可以在这里修改下载到闪存的起始位置。右边是片上RAM的起始地址和大小
下一个问题,点击Settings
打开FlashDownload这里是配置下载选项。我们要选择下图划线的第二个,擦除扇区,也就是页擦除 。第一个是每次下载代码都全擦除再下载,第二个是用到多少页就下载多少页,下载速度更快。如果想在闪存尾部存储数据,最好选择页擦除的下载
如何知道程序编译之后到底占用了多大空间,可以看下图编译后的窗口 。前三个数相加得到的是程序占用闪存的大小,后两个数相加得到的就是SRAM的大小。
除此之外双击Target1文件夹可以打开project.map文件
在此文件最后可以看到只能用的闪存,SRAM大小 。倒数第二行是占用SRAM大小,最后一行是占用闪存大小
(8)读取芯片ID
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
int main(void)
{
OLED_Init();
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)
{
}
}