第01章 STM32简介及开发环境搭建
1.1 STM32简介
1.1.1 STM32F103C8T6
系列:主流系列STM32F1
内核:ARM Cortex-M3
主频:72MHz
RAM:20K(SRAM)
ROM:64K(Flash)
供电:2.0~3.6V(标准3.3V)
51单片机是5V供电。USB输出的电压也是5V供电,不能直接给STM32供电,如果是5V电压,需要加一个稳压芯片,把电压降到3.3V,再给STM32供电。
封装:LQFP48,如下图所示。
1.1.2 STM32F1系列片上资源/外设
|----------|-----------|----------|-----------|
| 英文缩写 | 名称 | 英文缩写 | 名称 |
| NVIC | 嵌套向量中断控制器 | CAN | CAN通信 |
| SysTick | 系统滴答定时器 | USB | USB通信 |
| RCC | 复位和时钟控制 | RTC | 实时时钟 |
| GPIO | 通用IO口 | CRC | CRC校验 |
| AFIO | 复用IO口 | PWR | 电源控制 |
| EXTI | 外部中断 | BKP | 备份寄存器 |
| TIM | 定时器 | IWDG | 独立看门狗 |
| ADC | 模数转换器 | WWDG | 窗口看门狗 |
| DMA | 直接内存访问 | DAC | 数模转换器 |
| USART | 同步/异步串口通信 | SDIO | SD卡接口 |
| I2C | I2C通信 | FSMC | 可变静态存储控制器 |
| SPI | SPI通信 | USB OTG | USB主机接口 |
NVIC/SysTick是位于Cortex-M3内核里面的外设,剩下的都是内核外的外设。
NVIC(嵌套向量中断控制器):内核里面用于管理中断的设备,比如配置中断优先级这些东西。
SysTick(系统滴答定时器):内核里面的一个定时器,主要用来给操作系统提供定时服务的。STM32是可以加入操作系统的,比如FreeRTOS、UCOS等。如果使用了这些操作系统,就需要SysTick提供定时来进行任务切换的功能。可以用这个定时器来完成Delay函数的功能。
RCC(复位和时钟控制):对系统的时钟进行配置,使能各模块的时钟。在STM32中,其它的外设在上电的情况下默认是没有时钟的,不给时钟的情况下,操作外设是无效的,外设也不会工作,这样的目的是降低功耗。所以在操作外设之前,必须要先使能它的时钟,这就需要我们用RCC来完成时钟的使能。
GPIO(通用IO口):通用IO口,可以用GPIO来点灯,读取按键等。
AFIO(复用IO口):完成复用端口的重定义,还有中断端口的配置。
EXTI(外部中断):外部中断,配置好外部中断后,当引脚有电平变化时,就可以触发中断,让CPU来处理任务。
TIM(定时器):也是STM32最常用、功能最多的外设;分为高级定时器、通用定时器、基本定时器三种类型,其中高级定时器最为复杂,常用的是通用定时器,这个定时器不仅可以完成定时中断的任务,还可以完成测频率、生成PWM波形、配置成专用的编码器接口等功能,像PWM波形,就是电机驱动、舵机驱动最基本的要求。
ADC(模数转换器):模数转换器,STM32内置了12位的AD转换器,可以直接读取IO口的模拟电压值,无需外部连接AD芯片,使用非常方便。
DMA(直接内存访问):可以帮助CPU完成搬运大量数据这样的繁杂任务。
USART(同步/异步串口通信):同步或异步串口,平常用的UART是异步串口的意思,USART即支持同步串口,也支持异步串口,当然实际使用还是异步串口比较多。
I2C和SPI:非常常用的两种通信协议,STM32内置了它们的控制器,可以用硬件来输出时序波形,使用起来更高效,当然用通用IO口来模拟时序波形也是没有问题的。
CAN和USB:也是通信协议,CAN通信一般用于汽车领域,生活中到处都是USB设备,利用STM32的USB外设,可以做模拟鼠标、模拟U盘等设备。
RTC(实时时钟):在STM32内部完成年月日、时分秒的计时功能,而且可以接外部备用电池,即使掉电也能正常进行。
CRC(CRC校验):数据校验方式,用于判断数据的正确性,有了在这个外设的支持,进行CRC校验会更加方便一些。
PWR(电源控制):可以让芯片进入睡眠模式等状态,来达到省电的目的。
BKP(备份寄存器):这是一段存储器,当系统掉电时,仍可由备用电池保持数据,这个根据需要,可以完成一些特殊功能。
IWDG和WWDG(独立看门狗和窗口看门狗):当单片机因为电磁干扰死机或者程序设计不合理出现死循环时,看门狗可以及时复位芯片,保证系统的稳定性。
DAC(数模转换器):可以在IO直接输出模拟电压,是ADC模数转换的逆过程。
SDIO(SD卡接口):可以用来读取SD卡。
FSMC(可变静态存储控制器):可以用于扩展内存或者配置成其他总线协议,用于某些硬件的操作。
USB OTG(USB主机接口):用OTG功能,可以让STM32作为USB主机去读取其他USB设备。
需要注意的是:以上是STM32F1整个系列的所有外设,并非所有型号都拥有全部的外设。比如STM32F103C8T6没有后面四个外设。具体芯片有哪些外设,需要查看相应数据手册。
1.1.3 芯片命名规则
1.1.4 芯片系统结构
可以分为以下四个部分:
(1)Cortex-M3内核引出了三条总线[ICode(指令总线)、DCode(数据总线)、System(系统总线)]。
(2)ICode(指令总线)和DCode(数据总线)主要用来连接Flash闪存,Flash里面存储的是我们编写的程序,ICode(指令总线)就是用来加载程序指令的;DCode数据总线就是用来加载数据的,比如常量和调试数据这些。
(3)System(系统总线)连到其它东西上面,比如SRAM,用于存储程序运行时的变量数据;还有FSMC,本课程不会用到;AHB系统总线,用于挂载主要的外设,AHB指的是先进高性能总线,挂载的一般是最基本的或者性能比较高的外设,比如复位和时钟控制这些最基本的电路,还有SDIO也是挂载在AHB上的;再后来是两个桥接,接到APB1和APB2两个外设总线上,这个APB的意思是先进外设总线,用于连接一般的外设;因为AHB和APB的总线协议、总线速度、还有数据传送格式的差异,所以中间需要加两个桥接,来完成数据的转换和缓存;AHB的整体性能比APB高一些,其中APB2的性能又比APB1高一些,APB2一般和AHB同频率,都是72MHz,APB1一般是36MHz,所以APB2连接的都是一般外设中稍微重要的部分,比如GPIO端口,还有一些外设的1号选手等(比如USART1、SPI1、TIM1、TIM8(TIM1和TIM8一样,也是高级定时器,所以也是重要的外设),还有ADC、EXTI、AFIO,也是接在APB2上面的),其它的像2、3、4、5号的外设,还有DAC、PWR、BKP等,这些次要一点的外设,都会分配到APB1上去。在使用的时候,个人一般感觉不到APB2和APB1的性能差异, 只需要知道这个外设是挂载到哪个总线上既可以。
(4)DMA可以当作内核CPU的小秘书,比如一些大量的数据搬运的活,让CPU来干太浪费时间。比如一个外设ADC模数转换,这个模数转换可以配置成连续模式,比如1ms转换一次,转换完的数据必须得转运出来,否则数据就会被覆盖丢失。如果直接让CPU来干这个工作,那么PCU每过1ms就得来转运一下数据,会费时费力,影响CPU的正常工作。但这个任务就是简单的数据搬运,没必要让CPU做,于是DMA就出现了,主要就是干数据搬运这样简单而反复要干的工作,那DMA通过DMA总线连接到总线矩阵上,它可以拥有和CPU总线一样的总线控制权,用于访问这些外设小弟,当需要DMA搬运数据时,外设小弟就会通过请求线发送DMA请求,然后DMA就会获得总线控制权,访问并转运数据,整个过程不需要CPU的参与,省下了CPU的时间来干其它的事情,这就是DMA的用途。
1.1.5 芯片引脚定义
下图标红色是电源相关的引脚,标蓝色的是最小系统相关的引脚,标绿色的是IO口、功能口这些引脚。
类型:S代表电源、I代表输入、O代表输出、IO代表输入输出 。
I/O口电平:代表IO口所能容忍的电压,有FT的,代表它能容忍5V的电压,没有FT的,只能容忍3.3V的电压。如果没有FT需要接5V的电平,就需要加装电平转换电路了。
主功能:上电后默认的功能,一般和引脚名称相同,如果不同的话,引脚的实际功能是主功能而不是引脚名称的功能。
默认复用功能:IO口上同时连接的外设功能引脚,这个配置IO口的时候可以选择是通用IO口还是复用功能。
重定义功能:作用是如果有两个功能同时复用在了一个IO口上,并且确实需要用到这两个功能,可以把其中一个复用功能映射到其它端口上,当然前提是重定义功能的表里有对应的端口。
推荐优先使用加粗的IO口,没有加粗的IO口可能需要配置或者兼具其它功能,使用时需要注意一下。
引脚定义:
1号引脚VBAT:备用电池供电的引脚,在这个引脚可以接一个3V的电池,当系统电源断电时,备用电池可以给内部得RTC时钟和备份寄存器提供电源。
2号引脚PC13-TAMPER-RTC(IO口/侵入检测/RTC):IO口可以根据程序输出或读取高低电平,是最基本也是最常用的功能;侵入检测可以用来做安全保障的功能(比如产品安全性比较高,可以在外壳加一些防拆的触点,然后街上电路到这个引脚,若有人强行拆开设备,那触点断开,这个引脚的电平变化,就会触发STM32的侵入信号,然后就会清空数据来保证安全);RTC引脚可以用来输出RTC校准时钟、STC闹钟脉冲或者秒脉冲。
3号4号引脚PC14-OSC32_IN和PC15-OSC32_OUT:IO口或者32.768KHz的RTC晶振。
5号6号引脚 OSC_IN和OSC_OUT:接系统的主晶振,一般是8MHz的,然后芯片内有锁相环电路,可以对这个8MHz的频率进行倍频,最终产生72MHz的频率,作为系统的主时钟。
7号引脚NRST(系统复位引脚):N代表它是低电平复位的。
8号9号引脚 VSSA和VDDA:内部模拟部分的电源,比如ADC、RC震荡器等,VSS是负极,接GND;VDD是正极,接3.3V。
10号-19号引脚:IO口,其中PA0兼具了WKUP的功能,这个可以用于唤醒处于待机模式的STM32。
20号引脚:IO口或者BOOT1引脚,BOOT引脚是用来配置启动模式的。
21号-22号引脚:IO口。
21号-22号引脚VSS_1和VDD_1:系统的主电源口,VSS是负极,VDD是正极。
35号-36号引脚VSS_2和VDD_2:系统的主电源口,VSS是负极,VDD是正极。
47号-48号引脚VSS_3和VDD_3:系统的主电源口,VSS是负极,VDD是正极。
STM32采用了分区供电的方式,所以供电口比较多,在使用时,把VSS都接GND,VDD都接3.3V即可。
25号-33号引脚:IO口。
34号和37号-40引脚:IO口或者调试端口,默认的主功能是调试端口,调试端口就是用来调试和下载程序的,这款STM32支持SWD和JTAG两种调试方式;SWD需要两根线,分别是SWIDO和SWCLK;JTAG需要5根线,分别是JTMS、JTCK、JTDI、JTDO、NJTRST。教程采用STLINK来下载调试程序,STLINK用的是SWD的方式,所以只需要占用PA13和PA14这两个端口,在使用SWD的调试方式时,剩下的PA15、PB3、PB4可以切换位普通的IO口来使用,但要在程序中进行配置,不配置的话默认是不会用作IO口的。
41号-43号引脚和45号-46号引脚:IO口。
44号引脚BOOT0:和BOOT1一样,是用来做启动配置的。
1.1.6 启动配置
这个启动配置的作用就是指定程序开始运行的位置,一般情况下,程序都是在Flash程序存储器开始执行,但是在某些情况下,也可以让程序在别的地方开始执行,用以完成特殊的功能。
BOOT0接0,BOOT1接1或者0时:采用主闪存存储器启动模式;
BOOT0接1,BOOT1接0时:采用系统存储器启动模式:
这个模式就是用来做串口下载用的,这个系统存储器寸的就是STM32中的一段BootLoader程序,BootLoader程序的作用就是接收串口的数据,然后刷新到主闪存中,这样就可以使用串口下载程序了,一般我们需要串口下载程序的时候会配置到这个模式上。
什么时候需要用到串口下载呢?34号和37号-40引脚,如果在程序中把这5各端口全部配置成IO口,那么这个芯片就没有调试端口了,也就不能下载程序了,所以配置端口的时候需要注意。
如果想使用串口下载,就需要配置BOOT1为0,BOOT0为1;如果没有STLINK,也没有JLINK,那就可以使用串口来进行下载程序。
BOOT0接1,BOOT1接1时:采用内置SRAM启动模式:
主要用来进行程序调试,现阶段使用比较少。
BOOT引脚的值是在上电复位后的一瞬间有效的,之后就随便了。BOOT1/和PB2是在同一个引脚上的,也就是在上电的瞬间是BOOT1的功能,当第4个时钟过后,就是PB2的功能。
根据引脚定义表。如果想要STM32正常工作,首先就需要把电源部分和最小系统部分的电路连接好,也就是表中标红色和蓝色部分。
1.1.7 STM32最小系统电路
一般来说,单片机只有一个芯片是无法工作的,需要为它连接最基本的电路。这些最基本的电路就叫做最小系统电路。
分区供电的主电源和模拟部分电源都接了供电引脚。VSS都连接了GND,VDD都连接了3V3,也就是3.3V。在3.3V和GND之间,一般会连接一个滤波电容,这个电容可以把保证供电电压的稳定,我们在设计电路的时候,一般只要遇到供电,都会习惯的加上几个滤波电容。
VBAT是接备用电池的,如果需要接备用电池,可以选择一个3V的纽扣电池,正极接VBAT,负极接GND就行了。备用电池是给RTC和备份寄存器服务的,如果不需要这些功能,就不用接备用电池。那这个VBAT直接接3.3V即可,或者悬空也是没问题的。
晶振电路:
接了一个8MHz的主时钟晶振,STM32的主晶振一般都是8MHz,8MHz经过内部锁相环倍频,得到72MHz的主频,这个晶振的两根引脚分别通过两个网络标号接到STM32的5、6号引脚;另外还需要接两个20pF的电容,作为启震电容,电容的另一端接地即可,这就是晶振电路,如果需要RTC功能的话,还需要再接一个32.768KHz的晶振,电路和在这个一样,接在3、4号引脚。
这个OSC32就是32.768KHz晶振的意思,为什么要用32.768KHz,因为32768是2的15次方,内部RTC电路经过2的15次方分频,就可以生成1s的时间信号了。
复位电路:
一个10K的电阻和一个0.1uF的电容组成的,用来给单片机提供复位信号,中间的NRST接在STM32的7号引脚,NRST是低电平复位的,当这个复位电路在上电的瞬间,电容是没有电的,电源通过电阻向电容充电,并且此时电容呈现的是短路状态,当电容逐渐充满电后,电容就相当于断路,此时NRST就会被R1上拉为高电平,那上电瞬间的波形就是先低电平,然后逐渐高电平,这个低电平就可以提供STM32的上电复位信号,让然电容充电是非常快的,所以在我们看来单片机在上电的一瞬间就复位了,这就是复位电路的作用;电容的左边还并联了一个按键,可以提供一个手动复位的功能,当我们按下按键时,电容被放电,并且NRST引脚也通过按键被直接接地了,这就相当于通过手动产生了低电平复位信号,按键松手后,NRST又回归高电平,此时单片机就从复位状态转换为工作状态,平时我们也可以见到这种复位按键,一般在设备上有个小孔,当设备死机并且还不方便断电重启时,我们使用针戳一下小孔里的按键,这样就会使设备复位了,这就是手动复位的功能,按下按键,程序就从头开始运行的意思。
启动配置电路:
H1相当于开干的作用,拨动这个开关,就可以让BOOT引脚选择接3.3V还是GND了。
下载端口电路:
使用STLINK下载程序的话,需要把SWDIO和SWCLK这两个引脚印出来方便接线,另外再把3.3V和GND引出来,这个GND是必须引出来的,3.3V如果板子自己有供电的话,可以不引,不过建议都引出来,方便一些。
1.2 软件安装
1.2.1 Keil5 MDK安装
略,微信公众号上破解版很多。
1.2.2 安装器件支持包
新建一个工程。
如果电脑上安装时是C51和MDK共存的情况,想要切换C51, 在下拉列表选择Legacy Device即可切换。
1.2.2.1 离线安装
找到我们需要安装的相应芯片支持包,双击进入安装程序,会默认选中keil安装目录,点击next,安装完后Finish。
1.2.2.2 在线安装
1.2.2.3 keil软件注册
(1)以管理员身份运行keil软件
(2)
(3)以管理员身份运行破解工具,注意关闭杀毒软件
(4)
出现以下窗口表示没有以管理员身份运行。
1.2.3 安装STLINK驱动
(1)将STLINK查到电脑上,打开设备管理器
(2) 安装驱动
1.2.4 安装USB转串口驱动
(1)将CH340插到电脑上
(2)安装驱动
1.3 新建工程
目前STM32的开发方式主要有基于寄存器的方式、基于标准库也就是库函数的方式和基于HAL库的方式。
(1)基于寄存器的方式与51单片机开发方式一样,是用程序直接配置寄存器,来达到我们想要的功能,这种方式最底层、最直接、效率会更高一些。但是由于STM32结构复杂、寄存器太多,所以基于寄存器的方式目前是不推荐的。
(2)基于库函数的方式是使用ST官方提供的封装好的函数,通过调用这些函数来间接的配置寄存器。由于ST对寄存器封装的比较好,所以这种方式既能满足对寄存器的配置,对开发人员比较友好,有利于提高开发效率,本课程就是使用库函数开发方式。
(3)基于HAL库的方式可以用图形化界面快速配置STM32,比较适合快速上手STM32的情况,但是这种方式隐藏了底层逻辑,如果对STM32不熟悉,基本只能停留在很浅的水平。建议学过标准准库以后,了解一下这种方式。
使用固件库的方式需要提前准备好固件库的资料。库函数文件夹目录如下:
Libraries:库函数的文件;
Project:官方提供的工程示例和模板 ;
Utilities:STM32官方评估板的相关例程,官方用STM32做的一个小电路板,用来评测STM32的,这个文件夹里面存放的就是这个小电路板的测评程序;
Release_Notes.html:库函数的发布文档,里面有一些版本说明;
stm32f10x_stdperiph_lib_um.chm:库函数的使用手册,使用手册里有教怎么使用这个库函数。
3.1 新建基于标准库的工程
3.1.1 新建工程
(1)先建一个存放工程的文件夹
(2)新建工程:
选择器件型号:
下图暂时不用,关闭。
工程创建完毕,如下图所示:
3.1.2 添加工程所需要的必要文件
3.1.2.1 添加STM32的启动文件
复制上图启动文件, 在工程文件夹下新建一个**"Start"文件夹**,将启动文件拷贝进去。
3.1.2.2 添加STM32外设寄存器文件
stm32f10x.h:STM32的外设寄存器描述文件,它的作用就跟51单片机的头文件"RGEX52.H"一样,是用来描述STM32有哪些寄存器和它对应的地址的;
system_stm32f10x.c 和system_stm32f10x.h文件:主要是用来配置时钟的,STM32主频72MHz,就是system文件里的函数配置的。
将上述3个文件复制粘贴到**"Start"文件夹**下。
3.1.2.3 添加STM32内核寄存器文件
因为STM32是内核和内核外围的设备组成的,而且内核的寄存器描述和外围设备的描述文件不是在一起的,所以还需要添加内核寄存器文件。
将上述两个文件粘贴到 "Start"文件夹下。
3.1.2.4 将以上文件添加到工程中
将以下文件添加进去(md.s文件以及.c和.h文件):
以上文件都是STM32里最基本的文件,是不需要修改的,添加进来即可。 文件图标上带了个钥匙🔑,表示这些文件是只读文件,无法被修改。
3.1.2.5 在工程选项里添加Start文件夹头文件路径
3.1.3 添加源代码文件
创建**"User"文件夹**,用来存放main函数等文件 。
这个工程还没有添加STM32的库函数,还是一个基于寄存器开发的工程,如果想用寄存器开发STM32,那么工程建立到这里就可以了。
3.1.4 通过寄存器的方式完成点灯的操作
修改编辑器字体大小:
(1)连接电路:
3.1.4.1 配置调试器
接在PC13引脚的LED灯停止闪烁。
3.1.4.2 配置寄存器点灯
(1) 首先配置RCC的一个寄存器,来使能GPIOC的时钟,GPIO都是APB2的外设。
因为测试LED灯接在PC13引脚,参照STM32引脚定义 ,该引脚主功能为IO口,再参照STM32芯片系统结构,GPIO都是APB2的外设。
位4写1就是打开GPIOC的时钟,其它的无关项都给0, 那么将整个RCC时钟控制信号换成16进制便是0x0000 0010;
(2)配置GPIOC的模式
换算成16进制就是0x0030 0000。
(3)配置端口输出数据寄存器
main.c代码如下:
cpp
#include "stm32f10x.h" // Device header
int main(void) //main函数是一个int型返回值,void参数的函数
{
RCC->APB2ENR = 0x00000010; //打开GPIOC的时钟
GPIOC->CRH = 0x00300000; //配置GPIOC的输出模式
GPIOC->ODR = 0x00002000; //这个灯是低电平点亮,给0x00002000就是灭,给0x00000000就是亮
while(1)
{
}
}
3.2 通过库函数的方式点灯
在通过寄存器的基础上进行。
3.2.1 添加库函数
(1)工程文件夹下新建**"Library"文件夹**。
(2)将库函数复制到 "Library"文件夹下。
①复制库函数的源文件。
misc.c:内核的库函数,其它的都是内核外的外设库函数。misc就是混杂的意思。
将以上文件全部复制到 "Library"文件夹下。
②复制库函数的头文件粘贴到**"Library"文件夹**下。
(3)将库函数文件添加进工程
但是这个库函数还不能直接使用。
(4)添加库函数头文件包含文件
stm32f10x_conf.h:
用来配置库函数头文件的包含关系的,另外里面还有个用来参数检查的函数定义,这是所有库函数都需要的。
stm32f10x_it.h和stm32f10x_it.c:
用来存放中断函数。
将上述三个文件复制粘贴到**"User"文件夹**下。
将这三个文件添加进工程:
3.2.2 配置库函数
cpp
#ifdef USE_STDPERIPH_DRIVER //如果定义了USE_STDPERIPH_DRIVER这个字符串,下面的"stm32f10x_conf.h"才有效
#include "stm32f10x_conf.h"
#endif
复制"USE_STDPERIPH_DRIVER"
将 "Library"文件夹和"User"文件夹也添加到目录里。
这样,基于库函数的工程就建好了,"Library" 里面的库函数也都带了钥匙🔑,不需要更改。位移需要更改的是**"User"**里面的文件。
点击上图所示按钮,挪一下文件组的位置。
3.2.3 通过库函数点灯
库函数也是间接的配置寄存器,所以步骤是一样的。
(1)配置RCC外设时钟
查看函数简介 :
cpp
/**
* @brief Enables or disables the High Speed APB (APB2) peripheral clock. //这个函数用来使能或失能APB2的外设时钟
* @param RCC_APB2Periph: specifies the APB2 peripheral to gates its clock.
* This parameter can be any combination of the following values://第一个参数可以是下面这些值
* @arg RCC_APB2Periph_AFIO, RCC_APB2Periph_GPIOA, RCC_APB2Periph_GPIOB,
* RCC_APB2Periph_GPIOC, RCC_APB2Periph_GPIOD, RCC_APB2Periph_GPIOE,
* RCC_APB2Periph_GPIOF, RCC_APB2Periph_GPIOG, RCC_APB2Periph_ADC1,
* RCC_APB2Periph_ADC2, RCC_APB2Periph_TIM1, RCC_APB2Periph_SPI1,
* RCC_APB2Periph_TIM8, RCC_APB2Periph_USART1, RCC_APB2Periph_ADC3,
* RCC_APB2Periph_TIM15, RCC_APB2Periph_TIM16, RCC_APB2Periph_TIM17,
* RCC_APB2Periph_TIM9, RCC_APB2Periph_TIM10, RCC_APB2Periph_TIM11
* @param NewState: new state of the specified peripheral clock.//第二个参数可以是ENABLE or DISABLE
* This parameter can be: ENABLE or DISABLE.
* @retval None
*/
void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph, FunctionalState NewState)
{
/* Check the parameters */
/* 内部还是配置RCC_APB2ENR这个寄存器,但是经过函数的包装,我们不需要查手册,确认哪一位的作用
而且函数中已经使用"|="和"&="使得这个库函数的配置不会影响到寄存器的其它位*/
assert_param(IS_RCC_APB2_PERIPH(RCC_APB2Periph));
assert_param(IS_FUNCTIONAL_STATE(NewState));
if (NewState != DISABLE)
{
RCC->APB2ENR |= RCC_APB2Periph;
}
else
{
RCC->APB2ENR &= ~RCC_APB2Periph;
}
}
(2)配置端口模式
查看函数简介:
cpp
/**
* @brief Initializes the GPIOx peripheral according to the specified
* parameters in the GPIO_InitStruct.//根据GPIO_Init结构体的参数来配置GPIO
* @param GPIOx: where x can be (A..G) to select the GPIO peripheral.//第一个参数GPIOx,其中x可以是A到G,来选择需要配置哪个GPIO
* @param GPIO_InitStruct: pointer to a GPIO_InitTypeDef structure that//第二个参数是一个GPIO_InitTypeDef的结构体,因此需要先定义一个GPIO_InitTypeDef结构体
* contains the configuration information for the specified GPIO peripheral.
* @retval None
*/
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct)
查看GPIO_Mode的定义:
通过查找可查看GPIO_Mode可以使用哪些参数,GPIO_Pin和GPIO_Speed同理。
查看GPIO_Pin的定义:
以上是通过库函数点亮PC13口LED的库函数方法,最终"main.c"文件如下:
cpp
#include "stm32f10x.h" // Device header
int main(void) //main函数是一个int型返回值,void参数的函数
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC,ENABLE); //配置GPIOC外部时钟
GPIO_InitTypeDef GPIO_InitStructure; //定义一个GPIO_InitTypeDef结构体,名字最好取为GPIO_InitStructure,然后配置其三个参数
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; //配置GPIO模式是通用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC,&GPIO_InitStructure); //配置GPIOC端口的输出模式
// GPIO_SetBits(GPIOC,GPIO_Pin_13); //设置GPIO_Pin_13高电平,灯灭
GPIO_ResetBits(GPIOC,GPIO_Pin_13); //设置GPIO_Pin_13低电平,灯亮
while(1)
{
}
}
3.3 关于启动文件的注意事项
新建工程以后第一个加的就是启动文件,这个启动文件有很多类型,我们要根据芯片型号来选择。
下表是STM32F1系列中的型号分类
|--------|-----------|------------------|-------------------|
| 缩写 | 释义 | Flash 容量 | 型号 |
| LD_VL | 小容量产品超值系列 | 16~32K | STM32F100 |
| MD_VL | 中容量产品超值系列 | 64~128K | STM32F100 |
| HD_VL | 大容量产品超值系列 | 256~512K | STM32F100 |
| LD | 小容量产品 | 16~32K | STM32F101/102/103 |
| MD | 中容量产品 | 64~128K | STM32F101/102/103 |
| HD | 大容量产品 | 256~512K | STM32F101/102/103 |
| XL | 加大容量产品 | 大于512K | STM32F101/102/103 |
| CL | 互联型产品 | - | STM32F105/107 |
如果使用的是STM32F100的型号,就选择带VL的启动文件,然后再根据Flash的大小选择LD、MD还是HD。
如果使用的是STM32F101/102/103的型号,就选择不带VL的启动文件,然后再根据Flash的大小选择LD、MD、HD还是XL。
如果使用的是STM32F105/107的型号,就选择CL的启动文件。
3.4 工程架构
startup启动文件,这个是程序执行最基本的文件,keil中启动文件是用汇编写的,启动文件内定义了中断向量表、中断服务函数等。中断服务函数中有个复位中断,这就是整个程序的入口,当STM
32上电复位或者按下复位键后,程序就会进入复位中断函数执行,复位中断函数主要做两件事情,第一个是调用SystemInit函数,第二个是调用main函数。
第02章 GPIO通用输入输出
2.1 GPIO输出
2.1.1 GPIO简介
(1)GPIO(General Purpose Input Output)通用输入输出口
(2)可配置为8种输入输出模式
(3)引脚电平:0V~3.3V,部分引脚可容忍5V
数据0就是低电平,也就是0V,数据1就是高电平,也就是3.3V。容忍5V就是指可以在这个端口输入5V的电压,也认为是高电平,但是对于输出而言,最大就只能输出3.3V,因为供电就只有3.3V。具体哪些端口可以容忍5V,可以参考引脚定义。
(4)输出模式下可控制端口输出高低电平,用以驱动LED、控制蜂鸣器、模拟通信协议输出时序等。
在其它的应用场景,只要是可以用高低电平来控制的,都可以用GPIO来完成,如果控制的是功率比较大的设备,只需要加个驱动电路即可。用GPIO可模拟通信协议(比如I2C,SPI或者某个芯片特定的协议),都可以用GPIO的输出模式来模拟其中的输出时序部分。
(5)输入模式下可读取端口的高低电平或电压,用于读取按键输入、外接模块电平信号输入、ADC电压采集、模拟通信协议接收数据等。
输入模式最常见的就是读取按键,用来捕获按键按下事件。另外也可以读取带有数字输出的一些模块,比如光敏电阻模块、热敏电阻模块等。如果这个模块输出的是模拟量,那GPIO还可以配置成模拟输入的模式,再配合内部的ADC外设,就能直接读取端口的模拟电压了。除此之外,模拟通信协议时,接收通信线上的数据,也是靠GPIO的输入来完成的。
2.1.2 GPIO基本结构
在STM32中,所有SPIO都是挂载在APB2外设总线上的,其中GPIO外设的名称是按照GPIOA、GPIOB、GPIOC等等这样来命名的,每个GPIO外设,总共有16个引脚,编号是从0到15。那GPIOA的第0号引脚,我们一般把它称作PA0,接着第1号就是PA1,然后PA2,依次类推,直到PA15。
在每个GPIO模块内,主要包含了寄存器和驱动器这些东西,寄存器就是一个特殊的存储器,内核通过APB2总线对寄存器进行读写,这样就可以完成输出电平和读取电平的功能了。这个寄存器的每一位对应一个引脚,其中输出寄存器写1,对应的引脚就会输出高电平,写0输出低电平;输入寄存器读取为1,就证明对应的端口目前是高电平,读取为0就是低电平。
因为STM32是32位的单片机,所以STM32内部的寄存器都是32位的。但这个端口只有16位,所以只有低16位对应的有端口,高16位是没有用到的。
驱动器是用来增加信号的驱动能力的,寄存器只负责存储数据。如果要进行点灯这样的操作的话,还是需要驱动器来负责增大驱动能力。
2.1.3 GPIO位结构
整体结构分为两个部分,上面输入部分,下面输出部分。
IO引脚,接了两个保护二极管,这个是对输入电压进行限幅的。上面二极管接VDD、3.3V,下面接VSS、0V。
如果输入电压比3.3V还要高,那上方的二极管就会导通,输入电压产生的电流就会直接流入VDD而不会流入内部电路,就可以避免过高的电压对内部电路产生伤害。
如果输入电压比0V还要低,这个电压是相对于VSS的电压,所以是可以有负电压的,那么下方这个二极管导通,电流会直接从VSS流出去,而不会从内部电路汲取电流,也是可以保护内部电路的。
如果输入电压在0~3.3V之间,那两个二极管均不会导通,这时二极管对电路没有影响,这就是保护二极管的用途。
2.1.3.1 输入部分
①上拉和下拉电阻
输入部分接了一个上拉电阻和一个下拉电阻,上拉电阻至VDD、下拉电阻至VSS,开关是可以通过程序进行配置的,如果上面导通、下面断开,就是上拉输入模式;如果下面导通、上面断开,就是下拉输入模式;如果两个都断开,就是浮空输入模式。
上拉和下拉的作用其实是为了给输入提供一个默认的输入电平的, 因为对于一个数字端口,输入不是高电平就是低电平,那如果输入引脚啥都不接,那到底算高电平还是低电平。这就不好说了,实际情况是,如果输入啥都不接,这时候输入就会处于一种悬空的状态,引脚的输入电平极易受外界干扰而改变,为了避免引脚悬空导致的输入数据不确定,我们就需要在这里加上上拉或者下拉电阻了。如果接入上拉电阻,当引脚悬空时,还有上拉电阻来保证引脚的高电平,所以上拉电阻又可以称作是默认位高电平的输入模式;下拉也是同理,就是默认位低电平的输入方式。上拉电阻和下拉电阻的阻值都是比较大的,是一种弱上拉和弱下拉,目的是尽量不影响正常的输入操作。
②肖特基触发器
实际上应该是施密特触发器,这里写肖特基触发器应该是翻译错误。这个施密特触发器的作用就是对输入电压进行整型的。它的执行逻辑是,如果输入电压大于某一阈值,输出就会瞬间升为高电平,如果输入电压小于某一阈值,输出就会瞬间降为低电平。
举例:IO引脚的波形是外界输入的,虽然是数字信号,实际情况下可能会产生各种失真。下图红色为输入信号,蓝色为整形后的信号。
一个夹杂了波动的高低变化电平信号,如果没有施密特触发器,那很有可能因为干扰而导致误判。如果有了施密特触发器,那比如定一个这样的阈值上限和下限,高于上限输出高,低于下限输出低。假设由于信号波动导致某一时刻低于上限单未低于下限,对于施密特触发器来说,只有高于上限或者低于下限,输出才会变化,所以此时输出并不会变化。可以看出,相比较输入信号,经过整形以后的信号就很完美了。在这里使用两个比较阈值来进行判断,中间留有一定的变化范围,可以有效地避免因信号波动造成地输出抖动现象。
经过施密特触发器整形的波形就可以直接写入输入数据寄存器了,再用程序读取输入寄存器的输入寄存器对应的某一位数据,就可以知道端口的输入电平了。
③片上外设
上面还有两路线路,这些就是连接到片上外设的一些端口。其中有模拟输入,这个是连接到ADC上的,因为ADC需要接收模拟量,所以这根线是接到施密特触发器前面的。另一个是复用功能输入,这个是连接到其他需要读取端口的外设上的,比如串口的输入引脚等,这根线接收的数字量,所以在施密特触发器后面。
2.1.3.2 输出部分
数字部分可以由输出寄存器或片上外设控制,两种控制方式通过数据选择器接到了输出控制部分,如果选择通过输出寄存器进行控制,就是普通的IO口输出,写这个寄存器的某一位就可以操作对应的某个端口了。左边还有一个叫做位设置/清除寄存器,可以用来单独操作输出数据寄存器的某一位,而不影响其它位。因为这个输出数据寄存器同时控制16个端口,并且这个寄存器只能整体读写,所以如果想单独控制其中某一个端口而不影响其它端口的话,就需要一些特殊的操作方式。第一种方式是先读出这个寄存器,然后用按位与和按位或的方式更爱某一位,最后再将更改后的数据写回去,在C语言中就是**&=** 和**|=**的操作,这种方法比较麻烦,效率不高,对于IO口的操作而言不太合适。第二种方式就是通过设置位设置/清除寄存器,如果我们要对某一位进行置1的操作,在位设置寄存器的对应位写1即可,剩下不需要操作的写0,这样它内部就会有电路,自动将输出数据寄存器对应位置置为1,而剩下写0的位则保持不变,这样就保证了只操作其中某一位而不影响其它位,并且这是一步到位的操作,如果想对某一位进行清0的操作,就在位清除寄存器的对应位写1即可,这样内部就会把这一位清0了,这就是第二种方式也就是位设置/清除寄存器的作用。另外还有第三种操作,就是读写STM32中的"位带"区域,这个位带的作用跟51单片机的位寻址作用差不多,在STM32中,专门分配的有一段地址区域,这段地址映射了RAM和外设寄存器的所有位,读写这段地址中的数据,就相当于读写所映射位置的某一位,这就是位带的操作方式。库函数使用就是读写位设置/清除寄存器的方法。
输出控制之后接到了两个MOS管,上面是P-MOS,下面是N-MOS,这个MOS管就是一种电子开关,我们的信号来控制开关的导通和关闭,开关负责将IO口接到VDD或者VSS。在这里可以选择推挽、开漏或关闭三种输出方式。在推挽输出模式下,P-MOS和N-MOS均有效,数据寄存器为1时,上管导通、下管断开,输出直接接到VDD,就是输出高电平,数据寄存器为0时,上管断开、下管导通,输出直接接到VSS,就是输出低电平,这种模式下,高低电平均有较强的驱动能力,所以推挽输出模式也可以叫强推输出模式。在推挽输出模式下,STM32对IO口具有绝对的控制权,高低电平都由STM32说的算。在开漏输出模式下,P-MOS是无效的,只有N-MOS在工作,数据寄存器为1时,下管断开,这时输出相当于断开,也就是高阻态模式;数据寄存器为0时,下管导通,输出直接接到VSS,也就是输出低电平;这种模式下只有低电平有驱动能力,高电平没有是没有驱动能力的;这个开漏模式可以作为通信协议的驱动方式,比如I2C通信的引脚,就是使用的开漏模式,在多机通信的情况下,这个模式可以避免各个设备的相互干扰;另外开漏模式还可以用于输出5V的电平信号,比如在IO口外接一个上拉电阻到5V的电源,当输出低电平时,由内部的N-MOS直接接VSS,当输出高电平时,由外部的上拉电阻拉高至5V,这样就可以输出5V的电平信号,用于兼容一些5V电平的设备,这就是开漏输出的主要用途。剩下的一种状态就是关闭,这个是当引脚配置为输入模式的时候,这两个MOS管都无效,也就是输出关闭,端口的电平由外部信号来控制。
2.1.4 GPIO工作模式
通过配置GPIO的端口配置寄存器,端口可以配置成以下8种模式:
|----------|--------|---------------------------|
| 模式名称 | 性质 | 特征 |
| 浮空输入 | 数字输入 | 可读取引脚电平,若引脚悬空,则电平不确定 |
| 上拉输入 | 数字输入 | 可读取引脚电平,内部连接上拉电阻,悬空时默认高电平 |
| 下拉输入 | 数字输入 | 可读取引脚电平,内部连接下拉电阻,悬空时默认低电平 |
| 模拟输入 | 模拟输入 | GPIO无效,引脚直接接入内部ADC |
| 开漏输出 | 数字输出 | 可输出引脚电平,高电平为高阻态,低电平接VSS |
| 推挽输出 | 数字输出 | 可输出引脚电平,高电平接VDD,低电平接VSS |
| 复用开漏输出 | 数字输出 | 由片上外设控制,高电平为高阻态,低电平接VSS |
| 复用推挽输出 | 数字输出 | 由片上外设控制,高电平接VDD,低电平接VSS |
浮空输入、上拉输入、下拉输入这三个模式的电路结构基本是一样的,区别就是上拉电阻和下拉电阻的连接,都可以读取端口的高电平。浮空输入的电平是不确定的,所以在使用浮空输入时,端口一定要接上一个连续的驱动源,不能出现悬空的状态。
2.1.4.1 浮空/上拉/下拉输入
在输入模式下,输出驱动器是断开的,端口只能输入而不能输出,上面两个电阻可以选择为上拉工作、下拉工作或者都不工作。对应的就是上拉输入、下拉输入或浮空输入,然后通过施密特触发器进行波形整形后,连接到输入数据寄存器。另外输入保护那里写着VDD或VDD_FT,这就是3.3V端口和容忍5V端口的区别。这个容忍5V的引脚,它的保护二极管要做一下处理,要不然直接接VDD3.3V的话,外部再接入5V电压就会导致上边二极管开启,并且产生比较大的电流,这个是不太妥当的。
2.1.4.2 模拟输入
ADC模数转换器的专属配置。输出是断开的,输入的施密特触发器也是关闭的无效状态,只剩下从引脚直接接入片上外设,也就是ADC,所以当我们使用ADC的时候,将引脚配置为模拟输入就行了,其他时候一般用不到模拟输入。
2.1.4.3 开漏/推挽输出
输出是由输出数据寄存器控制的,P-MOS无效,就是开漏输出,如果P-MOS和N-MOS都有效,就是推挽输出。另外在输出模式下,输入模式也是有效的 ,在输入模式下,输出是无效的,这是因为一个端口只能有一个输出,但可以有多个输入,所以当配置成输出模式的时候,内部也可以顺便输入以下,这个也是没啥影响的。
2.1.4.4 复用开漏/推挽输出
这两模式跟普通的开漏输出和推挽输出也差不多, 只不过是复用的输出,引脚电平是由片上外设控制的。
引脚的控制权转移到了片上外设,由片上外设来控制,在输入部分,片上外设也可以读取引脚的电平,同时普通的输入也是有效的,顺便接收一下电平信号。
在GPIO的8中模式中,处理模拟输这个模式会关闭数字的输入功能,在其它的7个模式中,素有的输入都是有效的。
2.1.5 STM32数据手册-GPIO
GPIO配置寄存器,每一个端口的模式由4位进行配置, 16个端口就需要64位,所以这里的配置寄存器有2个,一个是端口配置低寄存器,另一个是端口配置高寄存器。
这个 GPIO的输出速度可以限制输出引脚的最大翻转速度,这个设计出来是为了低功耗和稳定性的,一般要求不高的时候直接配置成MHz就可以了。
端口输入数据寄存器,里面的低16位对应16个引脚,高16位没有使用。
端口输出数据寄存器,里面的低16位对应16个引脚,高16位没有使用。
端口位设置/清除寄存器:这个寄存器的高16位是进行位清除的,低16位是进行位设置的。 写1就是设置或者清除,写0就是不产生影响。
端口位清除寄存器:这个寄存器的低16位和端口位设置/清除寄存器高16位功能是一样的 ,是为了方便操作设置的。如果只想单一的进行位设置或者位清除,那位设置时,用端口位设置/清除寄存器,位清除时,用端口位清除寄存器。因为在设置和清除时,使用的都是低16位的数据,就会方便一些。如果想对多个端口同时进行位设置和位清除,那就使用端口位设置/清除寄存器,这样就可以保证设置和位清除的同步性。当然如果对信号的同步性要求不高的话,先位设置再位清除也是没有问题的。
可以对端口的配置进行锁定,防止意外更改。
2.2 STM32外部设备和电路
2.2.1 LED和蜂鸣器
(1)LED:发光二极管,正向通电点亮,反向通电不亮。
如果是引脚没有剪过的LED,那其中长脚是正极、短脚是负极,通过LED内部也可以判断,较小的一半是正极,较大的一半是负极。
(2)有源蜂鸣器:内部自带振荡源,将正负极接上直流电压即可持续发声,频率固定。
(3)无源蜂鸣器:内部不带振荡源,需要控制器提供振荡脉冲才可发声,调整提供振荡脉冲的频率,可发出不同频率的声音。
2.2.2 LED的硬件电路
使用STM32的GPIO口驱动LED的电路:
上面是低电平驱动的电路,LED正极接3.3V,负极通过一个限流电阻接到PA0上;
限流电阻一般都是要接的,一方面防止LED因为电流过大而烧毁,另一方面它可以调整LED的亮度。
下面是高电平驱动的电路。
两种方式如何选择就得看IO口高低电平的驱动能力如何了,这个IO口载推挽输出模式下,高低电平均有比较强的驱动能力,所以在这里,两种方法均可。但是在单片机电路里,一般倾向于第一种接法,因为很多单片机或者芯片,都使用了高电平弱驱动,低电平强驱动的规则,这样可以一定程度上避免高低电平打架,所以如果高电平驱动能力弱,那就不能用用第二种连接方法了。
2.2.3 蜂鸣器电路
使用了三极管开关的驱动方案,三极管开关是最简单的驱动电路了,对于功率稍微大一点的,直接用IO口驱动就会导致STM32负担过重,这是就可以用一个三极管驱动电路来完成驱动的任务。
上面是PNP三极管的驱动电路,三极管的左边是基极,带箭头的是发射极,剩下的是集电极,它左边的基极给低电平,三极管就会导通,通过3.3V和GND,就可以给蜂鸣器提供驱动电流了;基极给高电平,三极管截至,蜂鸣器就没有电流i。
下面这个图是NPN三极管驱动电路,同样左边是基极,带箭头的是发射极,剩下的是集电极,驱动逻辑跟上面是相反的。基极给高电平导通,低电平断开。
PNP的三极管最好接在上边,NPN的三极管最好接在下边,这是因为三极管的通断,是需要在发射极和基极直接产生一定的开启电压的,如果把负载接在发射极那边,可能会导致三极管不能开启。
2.3 LED闪烁
2.3.1 硬件电路
2.3.2 新建工程
略,参考第一章 。注意:通过以下方式可快速创建文件分组。
2.3.3 实用工具
该文件可以把工程编译产生的中间文件都删掉,把它复制到工程文件夹里, 工程产生的中间文件主要在"Listings"和"Objects"文件夹下,而且都比较大,使用这个批处理文件可快速把中间文件都删掉。
2.3.4 软件实现
2.3.4.1 RCC外设
打开Library文件夹下"_rcc.h"文件,在.h文件的最下面,一般都是库函数所有函数的声明。RCC提供了很多库函数,最常用的是以下三个库函数(RCC AHB外设时钟控制、RCC APB2外设时钟控制、RCC APB1外设时钟控制):
2.3.4.2 GPIO外设
下图是GPIO全部的库函数,目前需要了解的是粉红色线框内的函数。
GPIO8种模式:
cpp
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;
GPIO写入函数:
cpp
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); //前两个参数指定端口、第三个是根据参数的值来设置指定的端口
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal); //第一个参数选择外设,第二个参数是PortValue,这个函数可以同时对16个端口进行写入操作
要实现LED闪烁,需要延时功能。在工程种新建一个"System"文件夹,将下图文件复制进去。
接PA0的LED闪烁程序如下:
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
int main(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //配置RCC_APB2时钟,因为GPIO外设都是挂载在APB2上的,引脚PA0,使能后开启时钟
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; //推挽输出工作模式
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0; //引脚0
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; //输入输出速度50MHz
GPIO_Init(GPIOA,&GPIO_InitStruct); //GPIOA外设的初始化,使用一个结构体定义了GPIO的工作模式、引脚、输入输出速度
// GPIO_SetBits(GPIOA,GPIO_Pin_0); //将GPIO外设引脚0设置为高电平
// GPIO_ResetBits(GPIOA,GPIO_Pin_0); //将GPIO外设引脚0设置为低电平
while(1)
{
// GPIO_ResetBits(GPIOA,GPIO_Pin_0); //将GPIO外设引脚0设置为低电平,点亮LED
// Delay_ms(500); //延时500ms
// GPIO_SetBits(GPIOA,GPIO_Pin_0); //将GPIO外设引脚0设置为高电平,熄灭LED
// Delay_ms(500); //延时500ms
// GPIO_WriteBit(GPIOA,GPIO_Pin_0,Bit_RESET); //将PA0设置为低电平,点亮LED
// Delay_ms(500); //延时500ms
// GPIO_WriteBit(GPIOA,GPIO_Pin_0,Bit_SET); //将PA0设置为高电平,熄灭LED
// Delay_ms(500); //延时500ms
/*如果想用0和1表示高低电平,需要用BitAction将0和1强制转换为枚举类型*/
GPIO_WriteBit(GPIOA,GPIO_Pin_0,(BitAction)0); //将PA0设置为低电平,点亮LED
Delay_ms(500); //延时500ms
GPIO_WriteBit(GPIOA,GPIO_Pin_0,(BitAction)1); //将PA0设置为高电平,熄灭LED
Delay_ms(500);
}
}
将LED正负极调转一下可以测试推挽输出模式下高低电平都有较强的驱动能力,将GPIO工作模式设置为开漏输出模式,可验证开漏输出模式下高电平不具备驱动能力、低电平具备驱动能力。
2.4 LED流水灯
2.4.1 硬件电路
2.4.2 软件部分
这里我们需要配置PA0-PA7八个引脚,可按如下操作:
cpp
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5|GPIO_Pin_6|GPIO_Pin_7; //按位或
原因如下:
cpp
#define GPIO_Pin_0 ((uint16_t)0x0001) /*!< Pin 0 selected */
#define GPIO_Pin_1 ((uint16_t)0x0002) /*!< Pin 1 selected */
#define GPIO_Pin_2 ((uint16_t)0x0004) /*!< Pin 2 selected */
GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2相当于0000 0000 0000 0001 | 0000 0000 0000 0010 | 0000 0000 0000 0100 -->0000 0000 0000 0111
处了GPIO引脚可以用这种方式外,时钟控制也可以用这种方式选择多个外设。
接PA0-PA7的LED流水灯程序如下:
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
int main(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //配置RCC_APB2时钟,因为GPIO外设都是挂载在APB2上的,引脚PA0,使能后开启时钟
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; //推挽输出工作模式
// GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5|GPIO_Pin_6|GPIO_Pin_7; //按位或
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_All; //所有位都位1,相当于选择中了所有的引脚
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; //输入输出速度50MHz
GPIO_Init(GPIOA,&GPIO_InitStruct); //GPIOA外设的初始化,使用一个结构体定义了GPIO的工作模式、引脚、输入输出速度
while(1)
{
GPIO_Write(GPIOA,~0x0001);//第二个参数需要直接写到GPIO的ODR寄存器里的,0x0001对应0000 0000 0000 0001,低电平点亮,按位取反
Delay_ms(500);
GPIO_Write(GPIOA,~0x0002);//0000 0000 0000 0010
Delay_ms(500);
GPIO_Write(GPIOA,~0x0004);//0000 0000 0000 0100
Delay_ms(500);
GPIO_Write(GPIOA,~0x0008);//0000 0000 0000 1000
Delay_ms(500);
GPIO_Write(GPIOA,~0x0010);//0000 0000 0001 0000
Delay_ms(500);
GPIO_Write(GPIOA,~0x0020);//0000 0000 0010 0000
Delay_ms(500);
GPIO_Write(GPIOA,~0x0040);//0000 0000 0100 0000
Delay_ms(500);
GPIO_Write(GPIOA,~0x0080);//0000 0000 1000 0000
Delay_ms(500);
}
}
2.5 蜂鸣器
2.5.1 硬件电路
注意:这款芯片A15、B3、B4这几个端口先别选 ,从引脚定义可知,这三个是JTAG的调试端口,如果要用做普通IO口,还需要再进行一些配置。
2.5.2 软件部分
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
int main(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE); //配置RCC_APB2时钟,因为GPIO外设都是挂载在APB2上的,引脚PB12,使能后开启时钟
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; //推挽输出工作模式
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_12; //引脚0
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; //输入输出速度50MHz
GPIO_Init(GPIOB,&GPIO_InitStruct); //GPIOB外设的初始化,使用一个结构体定义了GPIO的工作模式、引脚、输入输出速度
while(1)
{
GPIO_ResetBits(GPIOB,GPIO_Pin_12); //将GPIO外设引脚12设置为低电平,蜂鸣器发出声音
Delay_ms(100);
GPIO_SetBits(GPIOB,GPIO_Pin_12); //将GPIO外设引脚0设置为高电平,蜂鸣器停止发出声音
Delay_ms(100);
GPIO_SetBits(GPIOB,GPIO_Pin_12); //将GPIO外设引脚0设置为高电平,蜂鸣器停止发出声音
Delay_ms(100);
GPIO_SetBits(GPIOB,GPIO_Pin_12); //将GPIO外设引脚0设置为高电平,蜂鸣器停止发出声音
Delay_ms(700);
}
}
2.6 GPIO输入
2.6.1 按键简介
按键:常见的输入设备,按下导通,松手断开。
按键抖动:由于按键内部使用的是机械式弹簧片来进行通断的,所以在按下和松手的瞬间会伴随有一连串的抖动。
假设按键没按下是高电平,按下为低电平,在按下的瞬间, 信号由高电平变为低电平时,就会来回的抖动几下,这个抖动比较快,通常在5~10ms之间,人眼是分辨不出来的,但是对于告诉运行的单片机而言,5~10ms的时间还是很漫长的,所以要对这个抖动进行过滤,否则就会出现按键按一下,单片机却反应了多次的现象。另外在按键松手的时候,也会有一段时间的抖动,这个我们在程序中也要注意过滤一下。最简单的过滤办法就是加一段延时,把这个抖动的时间耗过去,这样就没问题了。
2.6.2 传感器模块介绍
传感器模块:传感器元件(光敏电阻/热敏电阻/红外接收管等)的电阻会随外界模拟量的变化而变化,通过与定值电阻分压即可得到模拟电压输出,再通过电压比较器进行二值化即可得到数字电压输出。
下图四个传感器分别是:光敏电阻传感器、热敏电阻传感器、对射式红外传感器、反射式红外传感器。
第三部分电路:
N1就是传感器元件所代表的可变电阻,它的阻值可以根据环境的光线、温度等模拟量进行变化。上面的R1是和N1进行分压的定值电阻,R1和N1串联,一端接在VCC正极、一端接在GND负极。这就构成了基本的分压电路,左边的C2是一个滤波电容,它是为了给中间的电压输出进行滤波的,用来滤除一些干扰,保证输出电压的平滑。一般我们在电路里遇到这种一端接在电路中,另一端接地的电容,都可以考虑一下这个是不是滤波电容的作用,如果是滤波电容的作用,那这个电容就是用来保证电路稳定的,并不是电路的主要框架,这时候我们在分析电路的时候,就可以把这个电路先抹掉,这样就可以使我们的电路分析更加简单。我们把电容给抹掉以后,整个电路的主要框架就是定值电阻和传感器电阻的分压电路了,在这里可以用分压定理来分析一下传感器电阻的阻值变化对输出电压的影响,当然还可以用上下拉电阻的思维来分析。当这个N1阻值变小时,下拉作用就会增强,中间的AO端的电压就会拉低;极端情况下,N1阻值为0,AO输出被完全下拉,输出0V;当N1阻值变大,下拉作用减弱,中间的引脚由于R1的上拉作用,电压就会升高;极端情况下,N1阻值无穷大,相当于断路,输出电压被R1拉高至VCC。
AO就是我们想要的模拟电压输出了。
第一部分电路:
模块还支持有数字输出,这个数字输出就是对AO进行二值化的输出。这个二值化是通过LM393来完成的,这个LM393是一个电压比较器芯片,里面有两个独立的电压比较器电路,然后剩下的是VCC和GND供电,VCC接到了电路的VCC,GND也接到了电路的GND。
左边还有一个电容,是一个电源供电的滤波电容。这个电压比较器其实就是一个运算放大器。当同相输入端的电压大于反相输入端的电压时,输出就会瞬间升为最大值也就是输出接VCC,反之当同相输入端的电压小于反相输入端的电压时,输出就会瞬间降为最小值,也就是输出接GND,这样就可以对一个模拟电压进行二值化了。
同相输入端IN+接到AO,就是模拟电压端,IN-接了一个电位器,这个电位器的接法也是分压电阻的原理,拧动电位器,IN-就会生成一个可调的阈值电压,两个电压进行比较,最终输出结果就是DO,数字电压输出,DO最终接到了引脚的输出端,这就是数字电压的由来。
第四部分电路:
还有两个指示灯电路,左边LED1的是电源指示灯,通电就亮;右边的LED2是DO输出指示灯,它可以指示DO的输出电平,低电平点亮、高电平熄灭,右边DO这里还多了个R5上拉电阻,这是为了保证默认输出为高电平的。P1的排针分别是VCC、GND、DO和AO。
对于光敏电阻传感器来说,N1就是光敏电阻;对于热敏电阻传感器来说,N1就是热敏电阻;对于红外传感器来说,N1就是红外接收管,当然还会多一个点亮红外发射管的电路,发射管发射红外光,接收管接收红外光。模拟电压表示的是接收光的强度。
对射式红外传感器通常用来检测通断,所以阈值也不需要过多的调整。、
反射式红外传感器也是一个红外发射管和一个红外接收管,只不过它是向下发射红外光,然后检测反射光的。这个可以用来做循迹小车。
2.6.3 按键和传感器模块硬件电路
(1)按键硬件电路
上面两种是下接按键的方式,下面两个是上接按键的方式,一般来说我们的按键都是用上两种方式,也就是下接的方式 。原因跟LED的接法类似,是电路设计的习惯和规范。
第一个图是电源按键的最常用的接法了,在这里随便选取一个GPIO口,比如PA0,然后通过K1接到地,当按键按下时,PA0直接下拉到GND,此时读取PA0的电压就是低电平。当按键松手时,PA0被悬空,悬空后会导致引脚的电压不确定,因此这时候必须要求PA0是上拉输入的模式(在内部接一个上拉电阻),否则就会出现引脚电压不确定的错误现象。如果PA0是上拉输入模式,引脚再悬空,PA0就是高电平。所以这种方式下,按下按键,引脚为低电平,松手,引脚为高电平。
第二个图相比于第一个图在外部接了一个上拉电阻,当按键松手时,引脚由于上拉作用,自然保持为高电平,当按键按下时,引脚直接接到GND,也就是一股无穷大的力把这个引脚往下拉,引脚就为低电平。这种状态下,引脚不会出现悬空状态,所以此时PA0引脚可以配置为浮空输入或者上拉输入。如果是上拉输入,那就是内外两个上拉电阻共同作用了,这是高电平就会更强一些,对应高电平更加稳定,但是这样的话,当引脚被强行拉到低时,损耗也会大一些。
第三个图,PA0通过按键接到3.3V,这样也是可以的,不过要求PA0必须要配置成下拉输入的模式,当按键按下时候,引脚为高电平,松手时,引脚回到默认值低电平,这要求单片机的引脚可以配置为下拉输入模式,一般单片机可能不一定有下拉输入的模式,所以最好还是用上面的接法。
最后一种方法需要PA0需要配置为下拉输入模式或者浮空输入模式。
总结:上面两种接法按键按下是低电平,松手是高电平;下面两种接法按键按下时是高电平,松手是低电平。左边两种接法要求引脚必须是上拉或者下拉输入的模式,右边两种接法可以允许引脚是浮空输入的模式,因为已经外置了上拉电阻和下拉电阻。一般都用上面两种接法,下面两种接法用的比较少。
(2)传感器硬件电路
因为是使用模块的方案,所以电路还是非常简单的。 DO数字输出随便接一个端口,比如PA0,用于读取数字量,AO模拟输出呢,之后学习ADC模数转换的时候使用,暂时不用接。
2.7 C语言学习补充
2.7.1 C语言数据类型
|--------------------|--------|---------------------------|--------------------|----------------|
| 关键字 | 位数 | 表示范围 | stdint 关键字 | ST 关键字 |
| char | 8 | -128 ~ 127 | int8_t | s8 |
| unsigned char | 8 | 0 ~ 255 | uint8_t | u8 |
| short | 16 | -32768 ~ 32767 | int16_t | s16 |
| unsigned short | 16 | 0 ~ 65535 | uint16_t | u16 |
| int | 32 | -2147483648 ~ 2147483647 | int32_t | s32 |
| unsigned int | 32 | 0 ~ 4294967295 | uint32_t | u32 |
| long | 32 | -2147483648 ~ 2147483647 | | |
| unsigned long | 32 | 0 ~ 4294967295 | | |
| long long | 64 | -(2^64)/2 ~ (2^64)/2-1 | int64_t | |
| unsigned long long | 64 | 0 ~ (2^64)-1 | uint64_t | |
| float | 32 | -3.4e38 ~ 3.4e38 | | |
| double | 64 | -1.7e308 ~ 1.7e308 | | |
(1)需要注意的是,在51单片机中,int是占16位的,在STM32中int是占32位的,如果要用16位的数据,要用short来表示。
(2)stdint关键字和ST关键字是对这些变量的重命名,因为左边的名字比较长,而且这个int的位数根据系统的不同还有可能不一样,还有的时候这个名字会有名不对题(比如char本意是字符型数据的意思,按名字来说它应该是存放字符的,但单片机中通常用它来存放整数而不是字符),所以stdint关键字和ST关键字给这些变量换了个名字。C语言提供的有stdint这个头文件,使用了新的名字,比如int8_t就是char的新名字,表示的意思就是8位整型数据,右边加个_t表示这是用typeddef重新明名的变量类型。
ST关键字是老版本库函数的命名方式,不过目前新版本库函数仍可以使用,建议使用stdint关键字,这是C语言stdint.h头文件里提供的官方定义。
2.7.2 C语言宏定义
关键字:#define
用途:用一个字符串代替一个数字,便于理解,防止出错;提取程序中经常出现的参数,便于快速修改
定义宏定义:
cpp
#define ABC 12345
引用宏定义:
cpp
int a = ABC; //等效于int a = 12345;
2.7.3 C语言typedef
关键字:typedef
用途:将一个比较长的变量类型名换个名字,便于使用
定义typedef:
cpp
typedef unsigned char uint8_t;
引用typedef:
cpp
uint8_t a; //等效于unsigned char a;
2.7.4 宏定义和typedef的区别
宏定义的新名字在左边,typedef的新名字在右边;宏定义不需要分号,typedef后面必须加分号;宏定义任何名字都可以换,而typedef只能专门给变量类型换名字。对于变量类型重命名而言,使用typedef更加安全。
2.7.5 C语言结构体
数组只能组合相同类型的数据,于是结构体就出现了,可组合不同类型的数据。
关键字:struct
用途:数据打包,不同类型变量的集合
定义结构体变量:
cpp
struct{char x; int y; float z;} StructName;
因为结构体变量类型较长,所以通常用typedef更改变量类型名
引用结构体成员:
cpp
StructName.x = 'A';
StructName.y = 66;
StructName.z = 1.23;
或
cpp
pStructName->x = 'A'; //pStructName为结构体的地址
pStructName->y = 66;
pStructName->z = 1.23;
2.7.6 C语言枚举
关键字:enum
用途:定义一个取值受限制的整型变量,用于限制变量取值范围;宏定义的集合
定义枚举变量:
cpp
enum{FALSE = 0, TRUE = 1} EnumName;
因为枚举变量类型较长,所以通常用typedef更改变量类型名
引用枚举成员:
cpp
EnumName = FALSE;
EnumName = TRUE;
2.8 按键控制LED
2.8.1 硬件电路
2.8.2 软件部分
在这个工程下完成按键和LED的驱动代码,但是如果把这两部分代码都混在主函数里面,代码会比较混乱,不容易管理,也不容易移植,所以对于这种驱动代码而言,我们一般会把它封装起来。单独放在另外的.c和.h文件里,这就是模块化编程的方式。
(1)在工程目录下新建"Hardware"文件夹,用来存放硬件驱动程序。
(2)编写硬件驱动程序
①LED驱动程序
LED.c文件用来存放LED驱动程序的主体代码,LED.h用来存放这个驱动程序可以对外提供的函数或者变量的声明。
keil如果不显示代码提示,可以按下**"Ctrl+Alt+空格"**,这样就可以显示代码提示框了。
②按键驱动程序
(3) LED.c
cpp
#include "stm32f10x.h" // Device header
/*LED初始化函数*/
void LED_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启时钟,LED接在PA1和PA2引脚,GPIOA端口,GPIO外设挂载在APB2上
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_1|GPIO_Pin_2;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_1|GPIO_Pin_2); //将PA1和PA2引脚置位高电平,不操作的时候LED熄灭
}
/*点亮LED1(PA1)的函数*/
void LED1_ON(void)
{
GPIO_ResetBits(GPIOA,GPIO_Pin_1);
}
/*熄灭LED1(PA1)的函数*/
void LED1_OFF(void)
{
GPIO_SetBits(GPIOA,GPIO_Pin_1);
}
/*LED1(PA1)端口电平翻转函数*/
void LED1_Turn(void)
{
if(GPIO_ReadOutputDataBit(GPIOA,GPIO_Pin_1)==0) //如果GPIO_Pin_1的输出寄存器为0,此时LED1点亮,
{
GPIO_SetBits(GPIOA,GPIO_Pin_1);
}
else
{
GPIO_ResetBits(GPIOA,GPIO_Pin_1);
}
}
/*点亮LED2(PA2)的函数*/
void LED2_ON(void)
{
GPIO_ResetBits(GPIOA,GPIO_Pin_2);
}
/*熄灭LED2(PA2)的函数*/
void LED2_OFF(void)
{
GPIO_SetBits(GPIOA,GPIO_Pin_2);
}
/*LED2(PA11)端口电平翻转函数*/
void LED2_Turn(void)
{
if(GPIO_ReadOutputDataBit(GPIOA,GPIO_Pin_2)==0) //如果GPIO_Pin_11的输出寄存器为0,此时LED2点亮
{
GPIO_SetBits(GPIOA,GPIO_Pin_2);
}
else
{
GPIO_ResetBits(GPIOA,GPIO_Pin_2);
}
}
(2)LED.h
cpp
#ifndef __LED_H
#define __LED_H /*如果没有定义__LED_H这个字符串,那么就定义__LED_H这个字符串*/
void LED_Init(void);
void LED1_ON(void);
void LED1_OFF(void);
void LED1_Turn(void);
void LED2_ON(void);
void LED2_OFF(void);
void LED2_Turn(void);
#endif //下方有一条空行,文件最后一行一定要以空行结尾,不然会警告
(3)Key.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h"
/*按键初始化函数*/
void Key_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; //我们要读取按键输入,因此选择上拉输入模式
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_1|GPIO_Pin_11;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; //GPIO输出速度,在输入模式下,实际上没用的
GPIO_Init(GPIOB,&GPIO_InitStruct);
}
/*获取按下按键的键码*/
uint8_t Key_GetNum(void)
{
uint8_t KeyNum = 0;
if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_1) == 0) //读取GPIO_Pin_1端口值如果等于0,代表按键按下
{
Delay_ms(20); //延时20ms,消抖
while(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_1) == 0) //20ms后GPIO_Pin_1端口值如果等于0,那么按键确实按下
Delay_ms(20);
KeyNum = 1;
}
else if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_11) == 0) //读取GPIO_Pin_11端口值如果等于0,代表按键按下
{
Delay_ms(20); //延时20ms,消抖
while(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_11) == 0) //20ms后GPIO_Pin_11端口值如果等于0,那么按键确实按下
Delay_ms(20);
KeyNum = 2;
}
return KeyNum;
}
(4)Key.h
cpp
#ifndef __KEY_H
#define __KEY_H
void Key_Init(void);
uint8_t Key_GetNum(void);
#endif
(5)main.c
①实现按键1按下,LED1点亮,按键2按下,LED1熄灭
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "LED.h" // 调用LED驱动文件
#include "Key.h"
uint8_t KeyNum;
int main(void)
{
LED_Init(); // 初始化LED
Key_Init(); // 初始化按键
while(1)
{
KeyNum = Key_GetNum(); // 读取键码值
if (KeyNum == 1) // 如果按键1被按下,点亮LED1
LED1_ON();
if(KeyNum == 2) // 如果按键2被按下,熄灭LED1
LED1_OFF();
}
}
②实现按键1按下,LED1点亮,按键1再次按下,LED1熄灭;按键2按下,LED2点亮,按键2再次按下,LED2熄灭。
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "LED.h" // 调用LED驱动文件
#include "Key.h"
uint8_t KeyNum;
int main(void)
{
LED_Init(); // 初始化LED
Key_Init(); // 初始化按键
while(1)
{
KeyNum = Key_GetNum(); // 读取键码值
if (KeyNum == 1) // 如果按键1被按下,点亮LED1
LED1_Turn();
if(KeyNum == 2) // 如果按键2被按下,熄灭LED1
LED2_Turn();
}
}
2.9 光敏传感器控制蜂鸣器
2.9.1 硬件电路
2.9.2 软件部分
(1)新建驱动文件
(2)Buzzer.c
cpp
#include "stm32f10x.h" // Device header
/*蜂鸣器初始化函数*/
void Buzzer_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启时钟,蜂鸣器接在PB12,GPIO外设挂载在APB2上
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_12;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB,&GPIO_InitStruct);
GPIO_SetBits(GPIOB,GPIO_Pin_12); //将PA12引脚置位高电平,不操作的时候蜂鸣器不响
}
/*开启蜂鸣器的函数*/
void Buzzer_ON(void)
{
GPIO_ResetBits(GPIOB,GPIO_Pin_12);
}
/*停止蜂鸣器的函数*/
void Buzzer_OFF(void)
{
GPIO_SetBits(GPIOB,GPIO_Pin_12);
}
/*蜂鸣器状态翻转函数*/
void Buzzer_Turn(void)
{
if(GPIO_ReadOutputDataBit(GPIOB,GPIO_Pin_12)==0)
{
GPIO_SetBits(GPIOB,GPIO_Pin_12);
}
else
{
GPIO_ResetBits(GPIOB,GPIO_Pin_12);
}
}
(3)Buzzer.h
cpp
#ifndef __BUZZER_H
#define __BUZZER_H
void Buzzer_Init(void);
void Buzzer_ON(void);
void Buzzer_OFF(void);
void Buzzer_Turn(void);
#endif
(4)LightSensor.c
cpp
#include "stm32f10x.h" // Device header
/*初始化光敏传感器引脚的函数*/
void LightSensor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; //我们要读取按键输入,因此选择上拉输入模式,保证引脚不会悬空即可
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_13;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; //GPIO输出速度,在输入模式下,实际上没用的
GPIO_Init(GPIOB,&GPIO_InitStruct);
}
/*读取光敏传感器引脚信号的函数*/
uint8_t LightSensor_Get(void)
{
return GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_13);
}
(5)LightSensor.h
cpp
#ifndef __LIGHT_SENSOR_H
#define __LIGHT_SENSOR_H
void LightSensor_Init(void);
uint8_t LightSensor_Get(void);
#endif
(6)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "Buzzer.h" // 调用蜂鸣器驱动文件
int main(void)
{
Buzzer_Init(); // 初始化蜂鸣器
LightSensor_Init(); // 初始化光敏传感器
while(1)
{
if(LightSensor_Get() == 1) // 光线比较暗的情况,蜂鸣器鸣叫
Buzzer_ON();
else
Buzzer_OFF();
}
}
第03章 OLED
3.1 OLED调试工具
3.1.1 调试方式
(1)串口调试:通过串口通信,将调试信息发送到电脑端,电脑使用串口助手显示调试信息.。
相当于把电脑的屏幕挂载到单片机上,方便单片机显示调试信息。
(2)显示屏调试:直接将显示屏连接到单片机,将调试信息打印在显示屏上
(3)Keil调试模式:借助Keil软件的调试模式,可使用单步运行、设置断点、查看寄存器及变量等功能。
3.1.2 OLED简介
OLED(Organic Light Emitting Diode):有机发光二极管
OLED显示屏:性能优异的新型显示屏,具有功耗低、相应速度快、宽视角、轻薄柔韧等特点
0.96寸OLED模块:小巧玲珑、占用接口少、简单易用,是电子设计中非常常见的显示屏模块
供电:3~5.5V,通信协议:I2C/SPI,分辨率:128*64
3.1.3 OLED硬件电路
SCL和SDA是I2C的通信引脚,需要接在单片机I2C通信引脚上,本教程提供的函数模块是用GPIO口模拟的I2C通信,所以这两个端口可以接在任意的GPIO口上。
除VCC和GND外,剩下的引脚是SPI通信的引脚, 如果是GPIO口模拟的通信协议,那也是接在任意的GPIO口上就行了。
3.1.4 本教程OLED驱动函数
|---------------------------------------|------------|
| 函数 | 作用 |
| OLED_Init(); | 初始化 |
| OLED_Clear(); | 清屏 |
| OLED_ShowChar(1, 1, 'A'); | 显示一个字符 |
| OLED_ShowString(1, 3, "HelloWorld!"); | 显示字符串 |
| OLED_ShowNum(2, 1, 12345, 5); | 显示十进制数字 |
| OLED_ShowSignedNum(2, 7, -66, 2); | 显示有符号十进制数字 |
| OLED_ShowHexNum(3, 1, 0xAA55, 4); | 显示十六进制数字 |
| OLED_ShowBinNum(4, 1, 0xAA55, 16); | 显示二进制数字 |
3.2 OLED显示屏驱动模块使用
3.2.1 硬件电路
VCC和GND需要接电源的正极和 负极,所以在屏幕背面接了两根线为OLED提供电源,当然也接上了STM32的PB6和PB7引脚,不过没关系,不初始化这两个引脚就可以了。STM32的引脚上电后,如果不初始化,默认是浮空输入的模式,在这个模式下,引脚不会输出电平,所以不会有什么影响。当然也可以不接这两根条线,直接用程序给PB6输出低电平,PB7输出高电平,用GPIO口直接给OLED供电也没问题,因为这个OLED功率很小,所以也是可以驱动的,不过用GPIO口供电不是很规范,做实际项目最好还是用电源供电。
3.2.2 软件部分
复制《按键控制LED》工程文件夹,改名为《OLED显示屏》。
(1)将OLED显示屏驱动函数添加到工程中。
(2)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
uint8_t KeyNum;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
OLED_ShowChar(1,1,'A'); // 在1行1列显示字符A
OLED_ShowString(1,3,"HelloWOrld!"); // 在1行3列显示字符串
OLED_ShowNum(2,1,12345,5); // 在2行1列显示12345这个数字,长度为5,如果最后一个参数比数字长度长,会在前面补0,比数字长度小,会把高位数字切掉
OLED_ShowSignedNum(2,7,12345,5); // 显示有符号十进制数字
// OLED_ShowSignedNum(2,7,-66,2); // 显示有符号十进制数字
OLED_ShowHexNum(3,1,0xAA55,4); // 显示16进制数
OLED_ShowBinNum(4,1,0xAA55,16); // 显示二进制数,C语言不支持直接写二进制数
Delay_s(2);
OLED_Clear(); // 清屏
while(1)
{
}
}
3.3 Keil的调试模式
以《LED闪烁》为例。
默认使用右边这一项,在硬件上在线仿真, 需要把STLINK和STM32都连接好。如果不想连接硬件,也可以选择左边的使用仿真器这个选项,这样就是电脑模拟STM32的运行了。使用在硬件上在线仿真测试。
首先编译一下确保代码没有问题。
如果想看结构体值得变化:
查看外设寄存器
如果要修改程序,是不能直接在调试模式下修改的,需要先退出调试模式,重新编译,再进入调试模式。
第04章 EXTI外部中断
4.1 中断系统
中断:在主程序运行过程中,出现了特定的中断触发条件(中断源),使得CPU暂停当前正在运行的程序,转而去处理中断程序,处理完成后又返回原来被暂停的位置继续运行。
中断就是在正常主程序执行的某个时刻,发生了中断触发条件。比如对于外部中断来说,可以是引脚发生了电平跳变;对于定时器来说,可以是定时时间到了;对于串口通信来说,可以是接收到了数据。当这些时间发生时,情况会比较紧急,比如外部中断来了,如果不来处理,下一个跳变信号就跟着过来了。比如串口中断来了,如果不来读取接收到的数据,那下一个数据再过来,就会把原来的数据覆盖掉,所以我们希望当中断条件满足时,CPU能够立即停止当前执行的程序,转而去处理中断事件的程序。比如外部终端来了,我想要计次,那就++;串口中断来了,就把接收到的数据转存起来。当这些紧急事件处理完后,CPU还能够回到原来停下的地方继续运行。这就是中断的处理流程和用途,使用中断系统,能够极大地提高程序的效率。比如没有中断系统,为防止外部中断被忽略或者串口数据被覆盖,那主程序就只能不断地查询是否有这些事件发生,不能再干其它地事情了。比如没有定时器中断,那主程序只有靠Delay函数,才能实现定时的功能。但有了中断系统之后,主程序就可以放心执行其它事情,有中断的时候再去处理,这样效率就会大大提升。
中断优先级:当有多个中断源同时申请中断时,CPU会根据中断源的轻重缓急进行裁决,优先响应更加紧急的中断源。
中断优先级就是中断的紧急程度,这个中断优先级是我们根据程序设计的需求,自己设置的。中断优先级是为了在多个中断同时申请时,判断一下,应该先处理哪个,如果事件非常紧急,那就把优先级设置高一些,如果不是那么紧急,就可以把优先级设置低一些。这样可以更好地安排这些中断事件,防止紧急地事件被别的中断耽误。
在STM32中,中断优先级还有多种分类。
中断嵌套:当一个中断程序正在运行时,又有新的更高优先级的中断源申请中断,CPU再次暂停当前中断程序,转而去处理新的中断程序,处理完成后依次进行返回。
中断嵌套也是为了照顾非常紧急的中断的,如果CPU已经在执行某个中断程序了,这时候又发生了一个非常紧急的中断,那这个中断就可以把当前的中断进行二次中断,这样新的紧急中断就可以被立即执行了。是否进行中断嵌套,也是由中断优先级来决定的。
4.2 中断执行流程
中断的执行流程,左边是主程序,当它执行到某个地方时,外设的中断条件满足了。 那这时,无论主程序在干什么事情,比如加减乘除还没算完,OLED显示程序才执行一般、Delay函数还在等待,中断来了,主程序都得立即暂停,程序由硬件电路自动跳转到中断程序中,当中断程序执行完成之后,程序再返回被暂停得地方继续运行。比如加减乘除没算完,现在就可以继续算了;OLED没显示完,现在就继续进行显示;Delay没延时完,现在就继续延时。这个被暂停的地方,我们称它为断点。为了程序返回后能够继续在原来的地方工作,在中断执行前,会对程序的现场进行保护,中断执行后,会再还原现场,这样可以保证主程序即使被中断了,回来之后也能继续执行。
使用C语言编程,保护现场和还原现场的工作并不需要我们做,编译器自动帮我们做好了。
中断嵌套的执行流程,当第一个中断程序正在执行时,又有心得优先级更高的中断来,那这里就会再次打断,然后执行新的中断,新的中断结束,再继续原来的中断,原来的中断结束,再继续主程序,这就是中断嵌套的执行流程。
一般中断程序都是在一个子函数里的, 这个函数不需要我们调用,当中断来临时,由硬件自动调用这个函数,这就是C语言程序中,中断的执行流程。
4.3 STM32中断
68个可屏蔽中断通道,包含EXTI、TIM、ADC、USART、SPI、I2C、RTC等多个外设。
中断通道就是中断源的意思,68个中断源是F1系列最多的中断数量,对于一个具体型号,可能没有那么多中断具体以对应型号的数据手册为准。STM32的中断是非常多的,几乎所有的模块都能申请中断。
使用NVIC统一管理中断,每个中断通道都拥有16个可编程的优先等级,可对优先级进行分组,进一步设置抢占优先级和响应优先级。
这个NVIC就是STM32中用来管理中断、分配优先级的。
最上面灰色部分是内核的中断:比如第一个复位中断,当产生复位事件时,程序就会自动执行复位中断函数,也就是我们复位后程序开始执行的我位置;
然后NMI不可屏蔽中断、硬件失效、存储管理、总线错误、错误应用等等。这些都是内核里的,一般这些中断都比较高深。我们看上去也挺难理解的,但是这些中断我们一般用不到,所以了解一下即可。
下面不是灰色的部分,就是STM32外设的中断了。
WWDG:窗口看门狗,这个是用来检测程序运行状态的中断。比如程序卡死了,没有及时喂狗,窗口看门狗就会申请中断,让程序跳到窗口看门狗的中断程序里。在中断程序里就可以进行一些错误检查,看看出现什么问题了。
PVD:电源电压监测。如果供电电压不足,PVD电路就会申请中断 ,在中断里就知道,现在供电不足,是不是电池没电了,要赶紧保存一下重要数据。
TAMPER、RTC、FLASH:类似的功能,外设电路检测到什么异常或者事件,需要提示一下CPU的时候,它就可以申请中断,让程序跳到对应的中断函数里运行一次,用来处理这个异常或事件。
EXTI0~EXTI4、EXTI9_5和EXTI15_10:就是本教程要学的外部中断对应的中断资源。
表的右边有一个中断的地址,这个地址是用来干什么的呢?这是因为我们程序的中断函数,它的地址是由编译器来分配的,是不固定的 ,但是我们的中断跳转,由于硬件的限制,只能跳到固定的地址执行程序,所以为了能够让硬件跳转到一个不固定的中断函数里,这里就需要在内存中定义一个地址的列表,这个列表地址是固定的,中断发生后,就跳到这个固定位置,然后在这个固定位置,由编译器,再加上一条跳转到中断函数的代码,这样中断跳转就可以跳转到任意位置了。
这个中断地址列表,就叫做中断向量表,相当于中断跳转的一个跳板。如果我们用C语言编程,是不需要管这个中断向量表的,因为编译器已经帮我们做好了。
4.4 NVIC基本结构
这个NVIC的名字叫做嵌套中断向量控制器, 在STM32中,它是用来统一分配中断优先级和管理中断的。NVIC是一个内核外设,是CPU的小助手。STM32的终端非常多,如果把这些中断全部接到CPU上,CPU还得引出很多先进行适配,设计上就很麻烦。并且如果很多中断同时申请,或者中断很多产生了拥堵,CPU也会很难处理,毕竟CPU主要是用来运算的,中断分配的任务就放到别的地方。NVIC有很多输入口,有多少个中断线路,都可以接过来,比如可以接到EXTI、TIM、ADC、USART等。线上画了个斜杠,上面写个n,这个意思是:一个外设可能会占用多个中断通道,所以这里由n条线。然后NVIC只有一个输出口,NVIC根据每个中断的优先级分配中断的先后顺序,之后通过右边这一个输出口告诉CPU,你该处理哪个中断,对于中断先后顺序分配的任务,CPU不需要知道,所以举个例子:比如CPU是一个医生,如果医院只有医生的话,当看病的人很多时,医生就得安排下先看谁、后看谁,如果由紧急的病人,那还得让紧急的病人最先来,这个安排先后次序的任务很繁琐,会影响医生看病的效率,所以医院安排了一个叫号系统,为病人统一取号,并且根据病人的等级,分配一个优先级,然后叫号系统看一下现在在排队的病人,优先叫号紧急的病人,最后叫号系统给医生输出的就是一个一个排好队的病人,医生就可以专心看病了。这个叫号系统在STM32里就是NVIC。
4.5 NVIC优先级分组
为了处理不同形式的优先级,STM32的NVIC可以对优先级进行分组,分为抢占优先级 和响应优先级,那这两种形式的优先级有什么区别呢?
还是以病人叫号的例子为例:对于紧急的病人,其实有两种形式的优先,一种是上一个病人在看病,外面排队了很多病人,当上一个病人看完后,紧急的病人即使是后来的,也会最先进去看病,这种相当于插队的优先级,叫做响应优先级。响应优先级高的,可以插队提前看病。
另外,如果一个病人更加紧急,并且此时已经有人在看病了,那他还可以不等上一个人看完,直接冲到医生屋里,让上一个病人先靠边站,先给他看病,等他看完了,然后上一个病人再继续,上一个病人结束了,叫号系统再看看有没有人来,这种形式的优先就是我们之前讲的中断嵌套。这追踪决定是不是可以中断嵌套的优先级,叫做抢占优先级。抢占优先级高的,可以进行中断嵌套。
每个中断有16个优先级,为了把这个优先级再区分为抢占优先级和响应优先级,就需要对这16个优先级进行分组了。
NVIC的中断优先级由优先级寄存器的4位(0~15)决定,这4位可以进行切分,分为高n位的抢占优先级和低4-n位的响应优先级。
四位二进制可以表示0~15的数,对应16个优先级,这个优先级的数是值越小、优先级越高、0就是最高优先级。
抢占优先级高的可以中断嵌套,响应优先级高的可以优先排队,抢占优先级和响应优先级均相同的按中断号排队(按照中断向量表排队,数值小的优先)。
|----------|-------------|-------------|
| 分组方式 | 抢占优先级 | 响应优先级 |
| 分组0 | 0位,取值为0 | 4位,取值为0~15 |
| 分组1 | 1位,取值为0~1 | 3位,取值为0~7 |
| 分组2 | 2位,取值为0~3 | 2位,取值为0~3 |
| 分组3 | 3位,取值为0~7 | 1位,取值为0~1 |
| 分组4 | 4位,取值为0~15 | 0位,取值为0 |
因为优先级总共是4位,所以就有(0,4),(1,3),(2,2),(3,1),(4,0)这5种分组方式。分组0就是0位的抢占等级,取值只能为0,4位的响应等级,取值可以是0~15。
这种分组方式是我们在程序中自己选择的,选好分组方式之后,我们在配置优先级时候,就要注意抢占优先级和响应优先级的取值范围了,不要超出表里规定的取值范围。
4.6 EXTI简介
EXTI(Extern Interrupt)外部中断。
EXTI可以监测指定GPIO口的电平信号,当其指定的GPIO口产生电平变化时,EXTI将立即向NVIC发出中断申请,经过NVIC裁决后即可中断CPU主程序,使CPU执行EXTI对应的中断程序。
简单来说就是引脚电平变化,申请中断。
支持的触发方式:上升沿 (电平从低电平变到高电平的瞬间触发中断)/下降沿 (电平从高电平变到低电平的瞬间触发中断)/双边沿 (上升沿和下降沿都可以触发中断)/软件触发 (引脚啥事没有,程序里执行一段代码,就能触发中断)。
支持的GPIO口:所有GPIO口,但相同的Pin不能同时触发中断。
所有的GPIO口都可以当作外部中断的引脚,但是PA0和PB0、或者PA1、PB1、PC1这样的,端口GPIO_Pin一样的,只能选1个作为中断引脚。所以如果有多个中断引脚,要选择不同Pin的引脚。
通道数:16个GPIO_Pin,外加PVD输出、RTC闹钟、USB唤醒、以太网唤醒。
16个就是GPIO_Pin0~GPIO_Pin15,加起来总共有20个中断线路,16个GPIO_Pin是外部中断的主要功能。后面四个东西其实是来"蹭网"的,因为这个外部中断有个功能,就是从低功耗模式的停止模式下唤醒STM32,那对于PVD电源电压监测、当电源从电压过低恢复时,就需要PVD借助一下外部中断退出停止模式;对于RTC闹钟来说,有时候为了省电,RTC订一个闹钟之后,STM32会进入停止模式,等到闹钟想的时候在唤醒,这也需要借助外部中断;还有USB唤醒、以太网欢迎,也都是类似的作用。本节主要学习引脚的外部中断。
触发响应方式:中断响应/事件响应。
中断响应就是申请中断、让CPU执行中断函数;事件响应是STM32对外部中断增加的一种额外的功能,当外部中断检测到引脚电平变化时,正常的流程是选择触发中断,但在STM32中,也可以选择触发一个事件,如果选择触发事件,那外部中断的信号就不会通向CPU了,而是通向其它外设、用来触发其它外设的操作,比如触发ADC转换,触发DMA等。中断响应是正常的流程,引脚电平变化触发中断;事件响应不会触发中断、而是触发别的外设操作、属于外设之间的联合工作。
4.7 EXTI外部中断的基本结构
最左边是GPIO口的外设,比如GPIOA、GPIOB、GPIOC等 ,每个GPIO外设有16个引脚,所以进来16根线。EXTI模块只有16个GPIO的通道,但这里的每个GPIO外设都有16个引脚,如果每个引脚都占用一个通道,那EXTI的16个通道显然就不够用了。所以会有一个AFIO中断选择引脚的电路模块,这个AFIO就是一个数据选择器,它可以在这前面3个GPIO外设的16个引脚里选择其中一个连接到后面EXTI的通道里。所以相同的Pin不能同时触发中断,因为PA0、PB0、PC0这些,通过AFIO选择之后,只有其中一个能接到EXTI的通道0上。通过AFIO选择之后的16个通道,就接到了EXTI边沿检测极控制电路上,同时下面蹭网的四个外设也是并列进来了。这些加起来,就组成了EXTI的20个输入信号,然后经过EXTI电路之后,分为了两种输出。其中上面的部分接到了NVIC,是用来触发中断的。需要注意的是,本来20路输出,应该有20路中断的输出,但是可能ST公司觉得20个输出太多了,比较占用NVIC的通道资源,所以就把外部中断的9~5和15~10,给分到一个通道里,也即是说,外部通道的9~5会触发同一个中断函数,15~10也会触发同一个中断函数。在编程的时候,我们在这两个中断函数里,需要再根据标志位来区分到底是哪个中断进来的。
然后是下面有20条输出线路到了其它外设,这就是用来触发其它外设操作的,也就是我们刚才说的事件响应。
4.8 AFIO复用IO口
AFIO主要用于引脚复用功能的选择和重定义。
数据选择器的作用。
在STM32中,AFIO主要完成两个任务:复用功能引脚重映射、中断引脚选择。
4.9 EXTI框图
EXTI的右边,就是20根输入线,然后输入线首先进入边沿检测电路,在上面的上升沿寄存器和下降沿寄存器可以选择是上升沿触发还是下降沿触发,或者两个都触发。接着往左,触发信号进入或门的输入端,在这里,硬件触发和软件中断寄存器的值接到了这个或门上,也就是任意一个为1,火门就可以输出1。触发信号经过或门之后,兵分两路,上一路是触发中断的,下一路是触发事件的。
触发中断首先会置一个挂起寄存器,这相当于是一个中断标志位了,我们可以读取这个寄存器判断是哪个通道触发的中断,如果中断挂起寄存器置1,它就会继续往左走,和中断屏蔽寄存器共同进入一个与门,然后是至NVIC中断控制器。这里的与门实际上就是开关的作用,因为对于与门来说,1与上任意的数x,等于这个任意的数x,0与上任意的数x,都等于0。这就相当于、中断屏蔽寄存器给1,那另一个输入就是直接输出,也就是允许中断,中断屏蔽寄存器给0,那另一个输入无论是什么,输出都是0,相当于屏蔽了这个中断。这就是这个与门的作用,相当于一个开关控制。
下一路事件的输出部分,首先也是一个事件屏蔽寄存器来进行开关控制,最后通过一个脉冲发生器,到其它外设,这个脉冲发生器就是给一个电平脉冲,用来触发其它外设的动作。
最上面就是外设接口和APB总线,我们可以通过总线访问这些寄存器,这些就是EXTI的内部结构了。
4.10 旋转编码器
4.10.1 旋转编码器简介
什么样的设备需要用到外部中断呢,使用外部中断有什么好处呢?
使用外部中断模块的特性:对于STM32而言,想要获取的信号是外部驱动很快的突发信号。比如旋转编码器的输出信号,可能很久都不会拧它,这时候不需要STM32做任何事,但是一拧它,就会有很多脉冲波形需要STM32接收,这个信号是突发的,STM32不知道什么时候来,同时它是外部驱动的,STM32只能被动读取。这个信号非常快,STM32稍微晚一点读取,就会错过很多波形,所以对于这种情况来说,就可以考虑使用STM32的外部中断了,有脉冲过来,STM32立即进入中断函数处理,没有脉冲的时候,STM32专心做其它事情。另外还有,比如红外遥控接收头的输出,接收到遥控数据之后,它会输出一段波形,这个波形转瞬即逝,并且不会等待,所以需要用外部中断来读取。
最后还有按键,虽然它的动作也是外部强驱动的突发事件,但并不推荐使用外部中断来读取按键,因为用外部中断不好处理按键抖动和松手检测的问题,对于按键来说,它的输出波形也不是转瞬即逝的,所以要求不高的话可以在主程序中循环读取,如果不想用主循环读取的话,可以考虑一下定时器中断读取的方式,这样既可以做到后台读取按键值,不阻塞主程序,也可以很好地处理按键检测和松手抖动的问题。
旋转编码器:用来测量位置、速度或旋转方向的装置,当其旋转轴旋转时,其输出端可以输出与旋转速度和方向对应的方波信号,读取方波信号的频率和相位信息即可得知旋转轴的速度和方向。
类型:机械触点式/霍尔传感器式/光栅式。
第一个图是一种最简单的编码器了, 这里使用的也是对射式红外传感器来测速的,为了测速,还需要配合一个光栅编码盘,当这个编码盘转动时,红外传感器的红外光就会出现遮挡、透过、遮挡、透过这样的现象。对应模块输出的电平就是高低电平交替的方波。这个方波的个数代表了转过的角度,方波的频率表示转速。那我们就可以用外部中断来捕获这个方波的边沿,以此判断位置和速度,不过这个模块只有一路输出,正转反转输出波形没法区分,所以这种方法只能测外置和速度,不能测量旋转方向。为了进一步测量方向,可以用后面几种编码器。
第二个图和第三个图是本教程套件里的旋转编码器,左边是它的外观,右边是它的内部拆解结构。内部是用金属触点来进行通断的,所以它是一种机械触点式编码器,这里左右是两部分开关触点,其中内测的这两根细的触点,都是和中间的引脚连接的。中间的圆的金属片是一个按键,旋转编码器的轴是可以按下去的,按键的两根线从上面引出,按键的轴按下,上面两根线短路,松手,上面两根线断开,就是个普通按键。编码盘也是一系列像光栅一样的东西,只不过这是金属触点,在旋转时,依次接通和断开两边的触点。并且,还有一个关键得到部分是,这个金属盘的位置是经过设计的。它嗯呢让两侧触点的通断产生一个90度的相位差,最终配合一下外部电路,编码器的两个输出会输出这样的波形。比如正向旋转时,B相输出是滞后90度的,输出信号是这样的:
反向旋转时,B相提前90度,输出信号是这样的:
这样便区分开了正转和反转。
这种相位相差90°的波形就叫正交波形, 带正交波形信号输出的编码器,是可以用来测方向的。
第4个图是霍尔传感器形式编码器,中间是一个圆形磁铁,边上有两个位置错开的霍尔传感器,当磁铁旋转时,通过霍尔传感器,就可以输出正交的方波信号。
最后一个就是独立的编码器元件了,它的输入轴转动时,输出就会有波形,这个也是可以测速和测方向的。
第二个编码器一般是用来调节的,比如音响调节音量这样的用途,因为它是触点接触的形式,所以不适合电机这种高速旋转的地方。另外几种都是非接触的形式,可以用于电机测速,电机测速在电机驱动的应用中还是非常常见的。
4.10.2 旋转编码器硬件电路
左边是模块的硬件电路图:
中间是旋转编码器,上面按键的两根线,这个模块并没有使用,是悬空的,下面是编码器内部的两个触点,旋转轴旋转时,这两个触点以相位相差90度的方式交替导通,因为这只是个开关信号,所以要配合外围电路才能输出高低电平。
左边接了一个10K的上拉电阻,默认没旋转的情况下,R1和R3中间这个点被上拉为高电平,通过R3这个电阻输出到A端口,也就是高电平。当旋转时,内部触点导通,这个点就直接被拉低到GND了,再通过R3输出,A端口就是低电平了。之后R3是一个输出限流电阻,它是为了防止模块引脚电流过大的,C1是输出滤波电容,可以防止一些输出信号抖动。右边电路和左边一模一样。
4.11 外设手册
4.11.1 NVIC
因为NVIC是内核外设,所以要在Cortex-M3编程手册里面找。
4.11.2 STM32的手册
4.12 对射式红外传感器计次
4.12.1 硬件电路
4.12.2 软件部分
(1)复制《 OLED显示屏》工程并改名为《对射式红外传感器计次》。
(2)添加驱动文件:
(3)GPIO库函数
cpp
void GPIO_DeInit(GPIO_TypeDef* GPIOx);
void GPIO_AFIODeInit(void); //复位AFIO外设的,调用这个函数,AFIO的配置会全部清除
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct);
void GPIO_StructInit(GPIO_InitTypeDef* GPIO_InitStruct);
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);
uint8_t GPIO_ReadOutputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadOutputData(GPIO_TypeDef* GPIOx);
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);
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);
void GPIO_PinLockConfig(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);//锁定GPIO配置的,调用这个函数,参数指定某个引脚,这个引脚的配置就会被锁定,防止意外更改
void GPIO_EventOutputConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
void GPIO_EventOutputCmd(FunctionalState NewState);
//上面两个函数用来配置AFIO的事件输出功能的
void GPIO_PinRemapConfig(uint32_t GPIO_Remap, FunctionalState NewState); //用来进行引脚重映射,第一个参数选择重映射的方式,第二个参数是新的状态
void GPIO_EXTILineConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
//EXTI中断需要用到的函数,调用这个函数,就可以配置AFIO的数据选择器,来选择我们的中断引脚
void GPIO_ETH_MediaInterfaceConfig(uint32_t GPIO_ETH_MediaInterface); //和以太网有关,这款芯片没有以太网外设
(4)EXTI库函数
cpp
void EXTI_DeInit(void); // 清除EXTI的配置,恢复成上电默认的状态
void EXTI_Init(EXTI_InitTypeDef* EXTI_InitStruct); // 调用这个函数根据结构体里的参数配置EXTI外设,使用方法和GPIO_Init一样
void EXTI_StructInit(EXTI_InitTypeDef* EXTI_InitStruct); // 调用这个函数可以把参数传递的结构体变量复制一个默认值
// 前面三个函数,基本所有的外设都有,像是库函数的模板函数一样,基本每个外设都需要这些类型的函数
void EXTI_GenerateSWInterrupt(uint32_t EXTI_Line);
/*软件触发外部中断的,调用这个函数,参数给一个指定的中断线,就能软件触发一次外部中断 ,如果程序需要用到这个功能的话,可以使用这个函数,如果
只需要外部引脚出啊发中断,那就不需要这个函数了*/
/*如果想在主程序里查看和清除标志位,就用下面两个函数*/
FlagStatus EXTI_GetFlagStatus(uint32_t EXTI_Line); // 获取指定的标志位是否被置1了
void EXTI_ClearFlag(uint32_t EXTI_Line); // 对置1的标志位进行清除
/*对于这些标志位,有的比较紧急,在置标志位后会触发中断,在中断函数里,如果想查看标志位和清除标志位,就用下面两个函数*/
ITStatus EXTI_GetITStatus(uint32_t EXTI_Line); // 获取中断标志位是否被置1了
void EXTI_ClearITPendingBit(uint32_t EXTI_Line); // 清除中断挂起标志位
/*上面4个函数也是库函数的模板函数,很多模块都有这四个函数,因为在外设运行过程中,会产生一些状态标志位,比如外部中断来了,
会有一个挂起寄存器置了一下标志位,对于其它外设,比如串口收到数据,会置标志位,定时器时间到,也会置标志位,这些标志位都是放在状态寄存器的
,当程序想要看这些标志位时,就可以用到这四个函数*/
(5) NVIC库函数
cpp
void NVIC_PriorityGroupConfig(uint32_t NVIC_PriorityGroup); // 这个函数是用来中断分组的,参数是中断分组的方式
void NVIC_Init(NVIC_InitTypeDef* NVIC_InitStruct); // 根据结构体里面的指定参数初始化NVIC
void NVIC_SetVectorTable(uint32_t NVIC_VectTab, uint32_t Offset); //设置中断向量表
void NVIC_SystemLPConfig(uint8_t LowPowerMode, FunctionalState NewState); //系统低功耗配置
(6)CountSensor.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h"
uint16_t CountSensor_Count; //定义一个变量用来计次
/*对射式红外传感器初始化函数:
第1步:打开时钟外设
第2步:配置GPIO,选择我们的端口为输入模式
第3步:配置AFIO,选择我们用的这一路GPIO,连接到后面的EXTI
第4步:配置EXTI,选择边沿触发方式,比如上升沿、下降沿或者双边沿,还有选择出发响应方式,可以选择中断响应和事件响应(一般都是中断响应)
第5步:配置NVIC,给中断选择一个合适的优先级,最后,通过NVIC,外部中断信号就能进入CPU了,这样CPU才能收到中断信号,才能跳转到中断函数里执行中断程序
*/
void CountSensor_Init(void)
{
/*第1步:打开时钟外设*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE); // 开启GPIOB的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE); // 开启AFIO的时钟
/*EXTI和NVIC的时钟一直是打开的,不需要开启:
EXTI作为一个独立外设,按理说应该是需要开启时钟的但是寄存器里没有EXTI时钟的控制位
NVIC是内核的外设,内核的外设是不需要开启时钟的
RCC管的都是内核外的外设*/
/*第2步:配置GPIO*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; // 配置GPIO为上拉输入模式
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB,&GPIO_InitStruct);
/*第3步:配置AFIO:
它的库函数是和GPIO在一个文件里的*/
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB,GPIO_PinSource14);
/*第4步:配置EXTI*/
EXTI_InitTypeDef EXTI_InitStruct;
EXTI_InitStruct.EXTI_Line = EXTI_Line14; //指定中断线为第14个线路
EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt; //指定中断线的模式为中断触发
EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Rising; //指定中断触发方式为下降沿触发
EXTI_InitStruct.EXTI_LineCmd = ENABLE; //指定选择的中断线的新状态
EXTI_Init(&EXTI_InitStruct);
/*第5步:配置NVIC
因为NVIC是内核外设,所以它的库函数是被ST发配到杂项这里来了,在misc.h文件里*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
/*设置优先级分组方式,需要注意的是,这个分组方式整个芯片只能用一种,所以这个分组代码整个工程只需要
执行依次就行了,如果把它放在模块里面进行分组,要确保每个模块分组都是选的同一个,也可以把这个分组代码
放在主函数的最开始,这样模块里就不用在再进行分组了*/
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = EXTI15_10_IRQn; //指定中断通道
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1; //将抢占优先级设置为1,优先级是在多个中断源同时申请,产生拥挤的时候才有作用
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1; //将响应优先级设置为1
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; //使能中断通道
NVIC_Init(&NVIC_InitStruct);
}
/*返回一下中断次数*/
uint16_t CountSensor_Get(void)
{
return CountSensor_Count;
}
/*中断函数:中断函数的名字是固定的,在"startup_stm32f10x_md.s"文件里查看*/
void EXTI15_10_IRQHandler(void) //中断函数的名字都是固定的,并且无参无返回值
{
/*注意:中断程序结束后,一定要再调用一下清除中断标志位的函数,因为只要中断标志位置1,程序就会跳转到中断函数,如果不清楚标志位,会一直执行中断*/
if(EXTI_GetITStatus(EXTI_Line14)==SET) //因为10~15通道是在一起的,所以判断一下中断信号是否是我们需要的通道产生的
{
Delay_ms(500); //延时消抖,不进行消抖会出现上升和下降沿遮挡和离开都触发中断的情况
CountSensor_Count ++; //每中断一次,计次一次
EXTI_ClearITPendingBit(EXTI_Line14); //将通道14中断标志位清除
}
}
(7)CountSensor.h
cpp
#ifndef __COUNT_SENSOR_H
#define __COUNT_SENSOR_H
void CountSensor_Init(void);
uint16_t CountSensor_Get(void);
//void EXTI15_10_IRQHandler(void); //中断函数不需要调用,是自动执行的
#endif //下方有一条空行,文件最后一行一定要以空行结尾,不然会警告
(8)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "CountSensor.h"
uint8_t KeyNum;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
CountSensor_Init();
OLED_ShowString(1,1,"Count:"); // 在1行3列显示字符串
while(1)
{
OLED_ShowNum(1,7,CountSensor_Get(),5); //将中断次数显示到OLED屏幕上
}
}
4.13 旋转编码器计次
4.13.1 硬件电路
4.13.2 软件部分
(1)复制《 OLED显示屏》工程并改名为《旋转编码器计次》。
(2)添加硬件驱动文件。
初始化PB0和PB1两个GPIO口的外部中断:
这里只初始化一个外部中断其实也是可以的,因为对于这个编码器,如果把1相的下降沿用作触发中断,在中断时刻读取另一相的电平,正向就是高电平,反转就是低电平,这样就能区分方向了。
不过有一些瑕疵,比如正转时候,A相先出现下降沿,所以刚开始动,就中断了,而反转时,A相后出现下降沿,所以就是转到位了,才进入中断,所以最好是A、B都触发中断,只有B相下降沿和A相低电平时,才判断正转;在A相下降沿和B相低电平时,才判断为反转。这样就能保障正转和反转都转到位了,才执行数字加减的操作。
(3)Encoder.c
cpp
#include "stm32f10x.h" // Device header
int16_t Encoder_Count; //因为要判断正反转,所以取一个带符号的计数
/*旋转编码器初始化函数*/
void Encoder_Init(void)
{
/*初始化PB0和PB1两个GPIO口的外部中断:
这里只初始化一个外部中断其实也是可以的,因为对于这个编码器,如果把1相的下降沿用作触发中断,
在中断时刻读取另一相的电平,正向就是高电平,反转就是低电平,这样就能区分方向了,不过有一些瑕疵*/
/*第1步:打开时钟外设*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE); // 开启GPIOB的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE); // 开启AFIO的时钟
/*EXTI和NVIC的时钟一直是打开的,不需要开启:
EXTI作为一个独立外设,按理说应该是需要开启时钟的但是寄存器里没有EXTI时钟的控制位
NVIC是内核的外设,内核的外设是不需要开启时钟的
RCC管的都是内核外的外设*/
/*第2步:配置GPIO*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; // 配置GPIO为上拉输入模式
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB,&GPIO_InitStruct);
/*第3步:配置AFIO:
它的库函数是和GPIO在一个文件里的*/
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB,GPIO_PinSource0);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB,GPIO_PinSource1);
/*第4步:配置EXTI*/
EXTI_InitTypeDef EXTI_InitStruct;
EXTI_InitStruct.EXTI_Line = EXTI_Line0 | EXTI_Line1; //指定中断线为第0和1个线路
EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt; //指定中断线的模式为中断触发
EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Rising; //指定中断触发方式为下降沿触发
EXTI_InitStruct.EXTI_LineCmd = ENABLE; //指定选择的中断线的新状态
EXTI_Init(&EXTI_InitStruct);
/*第5步:配置NVIC
因为NVIC是内核外设,所以它的库函数是被ST发配到杂项这里来了,在misc.h文件里*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
/*设置优先级分组方式,需要注意的是,这个分组方式整个芯片只能用一种,所以这个分组代码整个工程只需要
执行依次就行了,如果把它放在模块里面进行分组,要确保每个模块分组都是选的同一个,也可以把这个分组代码
放在主函数的最开始,这样模块里就不用在再进行分组了*/
//要对两个中断分别设置优先级
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = EXTI0_IRQn; //指定中断通道
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1; //将抢占优先级设置为1,优先级是在多个中断源同时申请,产生拥挤的时候才有作用
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1; //将响应优先级设置为1
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; //使能中断通道
NVIC_Init(&NVIC_InitStruct);
NVIC_InitStruct.NVIC_IRQChannel = EXTI1_IRQn; //指定中断通道
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1; //将抢占优先级设置为1,优先级是在多个中断源同时申请,产生拥挤的时候才有作用
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 2; //将响应优先级设置为2
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; //使能中断通道
NVIC_Init(&NVIC_InitStruct);
}
/*获取一下计数值*/
int16_t Encoder_CountGet(void)
{
int16_t temp;
temp = Encoder_Count;
Encoder_Count = 0;
return temp;
}
/*中断函数*/
void EXTI0_IRQHandler(void)
{
if(EXTI_GetITStatus(EXTI_Line0)==SET)
{
if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_1) == 0) //获取一下引脚电平状态,PB0触发中断,如果PB1是低电平,那么说明是反转
{
Encoder_Count--;
}
EXTI_ClearITPendingBit(EXTI_Line0); //清除中断标志位
}
}
void EXTI1_IRQHandler(void)
{
if(EXTI_GetITStatus(EXTI_Line1)==SET)
{
if(GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_0) == 0) //获取一下引脚电平状态,PB1触发中断,如果PB0是低电平,那么说明是正转
{
Encoder_Count++;
}
EXTI_ClearITPendingBit(EXTI_Line1); //清除中断标志位
}
}
(4)Encoder.h
cpp
#ifndef __ENCODER_H
#define __ENCODER_H
void Encoder_Init(void);
int16_t Encoder_CountGet(void);
#endif
(5)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Encoder.h"
int16_t Num;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Encoder_Init(); // 初始化编码器
OLED_ShowString(1,1,"Num:");
while(1)
{
Num += Encoder_CountGet();
OLED_ShowSignedNum(1,5,Num,5);
}
}
4.14 中断编程的建议
(1)中断函数里最好不要执行耗时过长的代码,中断函数要简短快速,避免刚进中断就执行一个Delay函数,因为中断是处理突发的事情,为了处理中断长时间待在中断函数里,中断就会受到严重的阻塞。
(2)最好不要在中断函数和主函数调用相同的函数或者操作同一个硬件,尤其是硬件相关的函数,比如OLED显示函数。如果即在主函数调用OLED,又在中断里调用OLED,OLED就会显示错误,因为在主程序中OLED可能刚显示一半,进中断了,结果中断里还是OLED显示函数,OLED就挪到其它地方显示了,这时候还是没有问题,但当中断结束,需要继续原来的显示的时候,就会出问题了。
第05章 TIM定时器
5.1 TIM定时中断
定时器的内容很多,本章共分为4个部分:第一部分主要讲定时器定时功能,也就是定一个时间、然后让定时器每隔这个时间产生一个中断,来实现每隔一个固定时间执行一段程序的目的,比如要做个时钟、秒表,或者使用一些程序算法的时候,都需要用到定时中断的这个功能。第二部分主要讲的是定时器输出比较的功能,输出比较这个模块最常见的用途就是产生PWM波形、用于驱动电机等设备,在这个部分,我们将会学习到,使用STM32输出的PWM波形,来驱动舵机和直流电机的例子。第三部分主要讲定时器输入捕获的功能,在这部分,我们将会学习到使用输入捕获这个模块来实现测量方波频率的例子。第四部分学习定时器编码接口,使用这个编码器接口,能够更加方便地读取正交编码器地输出波形,在编码电机测速中,应用也是非常广泛的。
5.1.1 TIM简介
(1)TIM(Timer)定时器。
(2)定时器可以对输入的时钟进行计数,并在计数值达到设定值时触发中断。
定时器最基本的功能就是在定时触发中断,同时也可以看出来,定时器就是一个计数器,当这个计数器的输入是一个准确可靠的基准时钟的时候,那它在对这个基准时钟进行计数的过程,实际上就是计时的过程,比如在STM32中,定时器的基准时钟一般都是主频72MHz,如果对72MHz计72个数,那么1MHz就是1us的时间,如果计72000个数,那就是1KHz,也就是1ms的时间。
(3)16位计数器、预分频器、自动重装寄存器的时基单元,在72MHz计数时钟下可以实现最大59.65s的定时。
这里计数器就是用来执行计数定时的一个寄存器,每来一个时钟,计数器加1;预分频器可以对计数器的时钟进行分频,让这个计数更加灵活;自动重装寄存器就是计数的目标值,就是我们想要计多少个时钟申请中断。这些寄存器构成了定时器最核心的部分,我们把这一块电路称为时基单元。时基单元里面的计数器、预分频器、自动重装寄存器都是16位的。2的16次方是65536,也就是如果预分频器设置最大、自动重装也设置最大,那定时器的最大定m时时间就是59.65s,接近1分钟。这个怎么算的呢?就是72MHz/65536/65536,得到的是中断频率,然后取倒数,就是59.65秒多。如果觉得这个时间还不够长,STM32的定时器还支持级联的模式,也就是一个定时器的输出当作顶一个定时器的输入,这样加一起,最大定时时间就是59.65s再乘2次65536,这个时间就是八千多年,如果还觉得短,那就再级联一个定时器,定时时间再延长65536×65536倍,这个时间大概是34万亿年,可见指数爆炸的威力。
(4)不仅具备基本的定时中断功能,而且还包含内外时钟源选择、输入捕获、输出比较、编码器接口、主从触发模式等多种功能。
定时器的这个基本结构是非常通用的,很多模块电路都能用到,所以STM32的定时器上扩展了非常多的功能。第一部分讲的就是定时中断和内外时钟源选择的功能;第二部分讲输出比较;第三部分讲输入捕获和主从触发模式;第四部分讲编码器接口。
(5)根据复杂度和应用场景分为了高级定时器、通用定时器、基本定时器三种类型。
5.1.2 定时器类型
|--------|---------------------|--------|----------------------------------------------------|
| 类型 | 编号 | 总线 | 功能 |
| 高级定时器 | TIM1、TIM8 | APB2 | 拥有通用定时器全部功能,并额外具有重复计数器、死区生成、互补输出、刹车输入等功能 |
| 通用定时器 | TIM2、TIM3、TIM4、TIM5 | APB1 | 拥有基本定时器全部功能,并额外具有内外时钟源选择、输入捕获、输出比较、编码器接口、主从触发模式等功能 |
| 基本定时器 | TIM6、TIM7 | APB1 | 拥有定时中断、主模式触发DAC的功能 |
除了TIM1到8,在库函数中还出现了TIM9、10、11等等,这些一般用不到。
基本定时器还可以和DAC联合使用。
这三种定时器是由高级到低级向下兼容的,高级定时器拥有通用计时器的全部功能,通用计时器又有基本定时器的全部功能。
STM32F103C8T6定时器资源:TIM1、TIM2、TIM3、TIM4 。
不同的型号,定时器的数量是不同的,在使用这个外设之前,一定要查一下它是不是这个外设,如果操作了不存在的外设是不起作用的。
5.1.3 高级定时器、通用定时器、基本定时器结构图
5.1.3.1 基本定时器
首先是3个最重要的寄存器,分别是预分频器、计数器及自动重装寄存器,它们构成了最基本的计数计时电路,所以这一块电路就叫做时基单元。预分频器之前,连接的就是基准计数时钟的输入,最终来到控制器,由于基本定时器只能选择内部时钟,,所以可直接认为预分频器连接到了输入端的内部时钟CK_INT,内部时钟的来源是RCC_TIMxCLK,这里的频率值一般都是系统的主频72MHz。所以通向时基单元的计数基准频率就是72MHz。再来看时基单元,首先是预分频器,它可以对72MHz的计数时钟进行预分频,比如这个寄存器写0,那就是不分频、或者说是1分频,这时候输出频率=输入频率=72MHz;如果预分频器写1,那就是2分频,输出频率=输入频率/2=36MHz;如果写2,就是3分频,输出=输入/3,依次类推。所以预分频器的值和实际的分频系数相差了1,即实际分频系数=预分频器的值+1。这个预分频器是16位的,所以最大值可以写65535,也就是65536分频。这就是预分频器,就是对输入的基准频率提前进行一个分频的操作。然后是计数器,这个计数器可以对预分频后的计数时钟进行计数,计数时钟每来一个上升沿,计数器的值就加1,这个计数器也是16位的,所以里面的值可以从0一直加到65535,如果再加的话,计数器就会回到0重新开始。所以计数器的值再计时过程中会不断地自增运行,当自增运行到目标值时,产生中断,那就完成了定时地任务。所以现在还需要一个存储目标值的寄存器,那就是自动重装寄存器了。自动重装寄存器也是16位的,它存的就是我们写入的计数目标。在运行的过程中,计数值不断自增,自动重装值是固定的目标,当计数值等于自动重装值时,也就是计时时间到了。那它就会产生一个中断信号,并且清零计数器,计数器开始下一次的计数计时。图上画了一个向上的折线箭头(UI处),就代表这里会产生中断信号,像这种计数值等于自动重装值产生的中断,我们一般把它叫做"更新中断"。这个更新中断之后就会通过NVIC,我们再配置好NVIC的定时器通道,那定时器的更新中断就能够得到CPU的响应了。向下的箭头代表的是会产生一个事件,这里对应的事件就叫做"更新事件",更新事件不会触发中断、但可以触发内部其他电路的工作。以上就是定时器定时中断的全部流程了。
从基准时钟到预分频器再到计数器,计数器计数自增,同时不断地与自动重装寄存器进行比较。它两值相等时,即计时时间到,这时会产生一个更新中断和更新事件。CPU响应更新中断,就完成了定时中断的任务了。
STM32的一大特色,就是主从触发模式,它能让内部的硬件在不受程序的控制下实现自动运行,如果能把这个主从触发模式掌握好,那在某些情况下将会极大地减轻CPU的负担。主模式触发DAC的用途就是在我们使用DAC的时候,可能会用DAC输出一段波形。那就需要每隔一段事件来触发DAC,让它输出下一个电压点。如果用正常的思路来实现的话,就是先设置一个定时器产生中断,每隔一段时间在中断程序中调用代码手动触发一次DAC转换,然后DAC输出,这样也是没问题的,但事实这样会使主程序处于频繁被中断的状态,这会影响主程序的运行和其他中断的响应。所以定时器设置了一个主模式,使用这个主模式就可以把这个定时器的更新事件,映射到触发输出TRGO(Trigger Out)的位置,然后TRGO直接接到DAC的触发转换引脚上,这样,定时器的更新就不需要再通过中断来触发DAC转换了,仅需要把更新事件通过主模式映射到TRGO,然后TRGO就会直接去触发DAC了。整个过程不需要软件的参与,实现了硬件的自动化,这就是主模式的作用。当然除了这个主模式外,还有更多硬件自动化的设计。
5.1.3.2 通用定时器
首先最核心的部分还是中间的时基单元, 这部分结构和基本定时器是一样的,由预分频器、计数器、自动重装寄存器构成,每部分的工作流程和基本定时器是一样的。预分频器对时钟进行分频,计数器自增计数,当计数值达到自动重装值时,计数值清零同时产生更新中断和更新事件。不过对于通用定时器而言,这个计数器的计数模式就不止向上计数这一种了。除了向上计数外,通用定时器和高级计时器还支持向下计数模式 和中央对齐模式。向下计数模式就是从重装值开始,向下自减,减到0之后,回到重装值同时申请中断,然后继续下一轮,一次循环,这就是向下计数。还有中央对齐的计数模式,就是从0开始,先向上自增,计到重装值,然后再向下自减,减到0,再申请中断。然后继续下一轮,依次循环,这就是中央对齐模式。总结一下就是,基本定时器仅支持向上计数这一种模式,通用定时器和高级定时器支持向上计数、向下计数、中央对齐这三种模式。这个模式比较多,不过我们最常用的还是向上计数模式。
上面部分就是内外时钟源选择和主从触发模式的结构了。
先看一下内外时钟源选择,对于基本定时器而言,定时只能选择内部时钟,也就是系统频率72MHz,到了通用定时器这里,时钟源不仅可以选择内部的72MHz时钟,还可以选择外部时钟,具体有:
第一个外部时钟就是来自TIMx_ETR引脚上的外部时钟,这个ETR(External)引脚的位置,可以参考一下引脚定义表。例如,我们可以再TIM2的ETR引脚,也就是PA0上接一个外部方波时钟,然后配置一下外部的极性选择,边沿检测和预分频器电路,再配置一下输入滤波电路。这两块电路可以对外部时钟进行一定的整形,因为是外部引脚的时钟,所以难免会有毛刺,那这些电路就可以对输入的波形进行滤波,同时也可以选择一下极性和预分频器。最后,滤波后的信号,兵分两路,上面一路ETRF进入触发控制器,紧跟着就可以选择作为时基单元的时钟了。如果想在ETR外部引脚提供时钟,或者想对ETR时钟进行计数,把这个定时器当作计数器来用的话,那就可以配置这一路的电路,在STM32中,这一路也叫做"外部时钟模式2"。
除了外部ETR引脚可以提供时钟外,下面还有一路可以提供时钟,就是TRGI(Trigger In),这一路从名字上看的话,它主要是用作触发输入来使用的,这个触发输入可以触发定时器的从模式。这个触发输入作为外部使用的情况下,暂且可以把这个TRGI当做外部时钟的输入来看,当这个TRGI当做外部时钟来使用的时候,这一路就叫做"外部时钟模式1"。通过这一路的外部时钟都有哪些呢?第一个就是ETR引脚的信号,这里ETR引脚既可以通过上面那一路当作时钟,又可以通过下面这一路进来当作时钟,两种情况对于时钟输入而言是等价的,只不过下面这一路输入会占用触发输入的通道而已。然后第二个就是ITR信号,这一部分的时钟信号是来自其他定时器的。从右边可以看出,这个主模式的输出TRGO可以通向其他定时器,那通向其他定时器的时候,就接到了其他定时器的ITR引脚上来了。这个ITR0到ITR3分别来自其他4个定时器的TRGO输出,至于具体的连接方式是怎么样的:
TIM2的ITR0是接在了TIM1的TRGO上,ITR1接在TIM8,ITR2接在TIM3,ITR3接在TIM4,其它定时器也都可以参照上表,这就是ITR和定时器的连接关系。通过这一路我们就可以实现定时器级联的功能,比如我们可以先初始化TIM3,然后使用主模式把它的更新事件映射到TRGO上,接着在初始化ITM2,这里选择ITR2,对应的就是TIM3的TRGO,然后后面再选择时钟为外部时钟模式1,这样TIM3的更新事件就可以驱动TIM2的时基单元,也就实现了定时器的级联。
还可以选择TI1F_ED,这里连接的是输入捕获单元的CH1引脚,也就是从CH1引脚获得时钟,这里后缀加一个ED(Edge)就是边沿的意思。也就是通过这一路输入的时钟,上升沿和下降沿均有效,最后这个时钟还能通过TI1FP1和TI2FP2获得,其中TI1FP1连接到了CH1引脚的时钟,TI2FP2连接到了CH2引脚的时钟,到这里外部时钟模式1的输入就介绍完了。
总结一下就是,外部时钟模式1的输入可以是ETR引脚、其他定时器、CH1引脚的边沿、CH1引脚和CH2引脚。这还是比较复杂的,一般情况下外部时钟通过ETR引脚就可以了。下面设置这么复杂地输入,不仅仅是为了扩大时钟输入的范围,更多的还是为了某些特殊应用场景而设计的。比如为了定时器的级联而设计的ITR路线。对于时钟输入而言,最常用的还是内部的72MHz的时钟,如果要使用外部时钟,首选ETR引脚外部时钟模式2的输入,这一路最简单、最直接。有关时钟输入的部分就结束了。
最后还有一块编码器接口,这个是定时器的一个编码器接口,可以读取正交编码器的输出波形。TRGO输出那里就是主模式的输出了,编码器接口这部分顶电路可以把内部的一些事件映射到这个TRGO引脚上。比如基本定时器分析时,将更新事件映射到TRGO,用于触发ADC,这里也是一样,我们可以把定时器内部的一些事件映射到这里来,用于触发其它定时器、DAC或者ADC,可见这个触发输出的范围是比基本定时器更广一些的。
然后是最下边部分:右边这一块是输出比较电路,总共有4个通道,分别对应CH1到CH4的引脚,可以用于输出PWM波形,驱动电机。左边这一块是输入捕获电路,也是有4个通道,对应的也是CH1到CH4的引脚,可以用于测量输入方波的频率等。中间这个寄存器是捕获/比较寄存器,是输入捕获和输出比较电路共用的,因为输入捕获和输出比较不能同时使用,所以这里的寄存器是共用的,引脚也是共用的。
5.1.3.3 高级定时器
对比通用定时器的结构,高级定时器这里,左上的这一大部分都没有变化, 主要改动的是右边和下边。第一个申请中断的地方,增加了重复次数计数器,有了这个计数器之后,就可以实现每隔几个计数周期,才发生一次更新事件和更新中断。原来的结构是每个计数周期完成后都会发生更新,现在有个计数器在这里,可以实现每隔几个周期再更新一次,这个就相当于对输出的更新信号又做了一次分频。对于高级定时器的话,之前计算的最大定时时间是59秒多,在这里就还需要再乘以一个65536,这就又提升了很多定时时间,这就是重复计数器的功能。
然后剩下新增部分就是高级定时器对输出比较模块的升级。这个DTG(Dead Time Generate)是死区生成电路,右边这里的输出引脚由原来的一个变为了两个互补的输出,可以输出一对互补的PAM波,这些电路是为了驱动三相无刷电机的,三相无刷电机还是比较常用的,比如四轴飞行器、电动车的后轮、电钻等,里面都可能是这个三相无刷电机。因为三相无刷电机一般需要三个桥臂,每个桥臂2个大功率开关管来控制,总共需要6个大功率开关管来控制,所以这里的输出PWM引脚的前三路就变为了互补的输出,而第四路却没什么变化,因为三相电机只需要三路就行了。另外,为了防止互补输出的PWM驱动桥臂时,在开关切换的瞬间,由于器件的不理想,造成短暂的直通现象,所以前面就加上了死区生成电路,在开关切换的瞬间,产生一定时长的死区。让桥臂的上下管全部关断,防止直通现象。
最后一部分就是刹车输入的功能了,这个是为了给电机驱动提供安全保障的。如果外部引脚BKIN(Break IN)产生了刹车信号,或者内部时钟失效,产生了故障,那么控制电路就会自动切断电机的输出,防止意外的发生,这就是刹车输入的功能。
5.1.4 定时中断基本结构
首先中间最重要的还是PSC(Prescaler)预分频器、CNT(Counter)计数器、 ARR(AutoReloadRegister)自动重装器这三个寄存器构成的时基单元。下面是运行控制,就是控制寄存器的一些位,比如启动停止、向上或向下计数等等。我们操作这些寄存器就能控制时基单元的运行了。左边是为时基单元提供时钟的部分,这里可以选择RCC提供的内部时钟,也可以选择ETR引脚提供的外部时钟模式2,在本小节示例程序里目的一个定时器定时中断就是用的内部时钟这一路,第二个定时器外部时钟就是用的外部时钟模式2这一路,当然还可以选择这里的触发输入当作外部时钟,即外部时钟模式1,对应的有ETR外部时钟、ITRx其它定时器、TIx输入捕获通道,这些就是定时器的所有可选择时钟源了。 最后还有个编码器模式,这一般是编码器独用的模式,普通的时钟用不到这个。接下来右边这里,就是计时时间到,产生更新中断后的信号去向,在这里如果是高级定时器的话,还会多一个重复计数器。中断信号会先在状态寄存器里置一个中断标志位,这个标志位会通过中断输出控制,到NVIC申请中断。为什么会有一个中断输出控制呢,因为这个定时器模块有很多地方都要申请中断,不仅更新要申请中断,这里触发信号也会申请中断,还有下面的输入捕获和输出比较匹配时也会申请,所以这些中断都要经过输出控制,如果需要这个中断,那就允许,如果不需要,那就禁止,简单来说,这个中断输出控制就是一个中断输出的允许位,如果需要某个中断,就记得允许一下。
5.1.5 时序图
了解一下时基单元运行的细节问题。
5.1.5.1 预分频器时序图
CK_PSC:预分频器的输入时钟,选内部时钟的话一般是72MHz,这个时钟在不断地运行。
CNT_EN:计数器使能,高电平计数器正常运行,低电平计数器停止。
CK_CNT:计数器时钟,即是预分频器的时钟输出,也是计数器的时钟输入。开始时,计数器未使能,计数器时钟不运行,使能后,前半段预分频器系数为1,计数器的时钟等于预分频器前的时钟,后半段预分频器的系数变为2,计数器的时钟也就变为预分频器前时钟的一半了。
计数器寄存器:在计数器时钟的驱动下,下面的计数器寄存器也跟随时钟的上升沿不断自增,在中间位置FC之后,计数值变为0了,这里虽然没写,但是可以推断出ARR自动重装值就是FC。当计数值计到和重装值相等,并且下一个时钟来临时,计数值才清零。同时下面产生一个更新事件。这就是一个计数周期的工作流程。
下面还有三行时序,这三行时序描述的意思是:这个预分频寄存器的一种缓冲机制,也就是预分频寄存器实际上是有两个,一个是预分频控制寄存器,供我们读写用的,它并不直接决定分频系数。另外还有一个缓冲寄存器或者说是影子寄存器,缓冲寄存器和影子寄存器,这两个说法其实是一个意思。这个缓冲寄存器才是真正起作用的寄存器,比如我们在某个时刻,把预分频寄存器由0改成了1。如果在此时立刻改变时钟的分频系数,那么就会导致在一个计数周期内,前半部分和后半部分的频率不一样,这里计数计到一半,计数频率突然就会改变了,这虽然一般并不会有什么问题,但是STM32的定时器比较严谨,设计了这个缓冲寄存器,这样,当我们在计数计到一半的时候改变了分频值,这个变化不会立刻生效,而是会等到本次技术周期结束时,产生了更新事件,预分频器的值才会被传递到缓冲寄存器里面去,才会生效。所以在这里看到,即使我在技术中途改变了预分频器值,技术频率仍然会保持原来的频率,直到本轮计数完成,在下一轮计数时,改变后的分频值才会起作用。预分频器内部实际上也是靠计数来分频的,当预分频值为0时,计数器就一直为0,直接输出原频率,当预分频器为1时,计数器就0、1、0、1、0、1、0、1这样计数。在回到0的时候,输出一个脉冲,这样输出频率就是输入频率的2分频。预分频器的系数和实际的分频系数之间有一个数的偏移。
计数器计数频率:CK_CNT = CK_PSC / (PSC + 1)
5.1.5.2 计数器时序
CK_INT:内部时钟72MHz;
CNT_EN:时钟使能,高电平启动;
CK_CNT:计数器时钟,因为分频系数为2,所以这个频率是CK_INT/2
计数器在每个时钟上升沿自增,当增到0036的时候发生溢出,那计到36之后,再来一个上升沿,计数器清零,计数器溢出,产生一个更新事件脉冲,另外还会置一个更新中断标志位UIF,这个标志位只要置1了,就回去申请中断,然后中断响应后,需要在中断程序中手动清零,这就是计数器的工作流程。
计数器溢出频率:CK_CNT_OV = CK_CNT / (ARR + 1) = CK_PSC / (PSC + 1) / (ARR + 1)
这就是计算定时时间的一个式子,用72MHz/(PSC + 1) / (ARR + 1)就能得到溢出频率,如果想算溢出时间,那就只需要再取倒数就行了。
预分频器为了防止计数中途更改数值造成错误,设计了缓冲寄存器,这个计数器也少不了这样的设计。像下图所示框内带有黑色阴影的寄存器都是有这样的缓冲机制的,包括预分频器,自动重装寄存器和下面的捕获比较寄存器。所以计数器的ARR自动重装寄存器,也是有一个缓冲寄存器的,并且这个缓冲寄存器是用还是不用,是可以自己设置的。
通过设置ARPE位可以选择是否使用预装功能。
5.1.5.2.1 计数器无预装时序
没有缓冲寄存器的情况。在这里,计数器正在自增计数,突然更改了自动加载寄存器,就是自动重装寄存器,由FF改成了36,那计数值的目标值就由FF改成了36,所以这里计到36之后,就直接更新,开始下一轮计数。
5.1.5.2.2 计数器有预装时序
有缓冲寄存器的情况。在计数的中途,突然把计数目标由F5改成了36,可以看到下面有一个影子寄存器,这个影子寄存器才是真正起作用的,它还是F5,所以现在计数的目标还是计到F5,产生更新事件,同时,要更改的36才被传递到影子寄存器。在下一个计数周期这个更改的36才有效。所以可以看出,引入这个影子寄存器的目的,实际上是为了同步,就是让值得变化和更新事件同步发生,防止在运行途中更改造成错误。从这个图也可以看出,如果不使用影子寄存器得话,F5改成36立刻生效,但此时计数值已经到了F1,已经超过36了,F1只能增加,但它得目标确是36,比它还小,这样F1就只能一直加到FFFF,再回到0,再加到36,才能产生更新,这样就造成一些小问题。
5.1.6 RCC时钟树结构图
这个时钟树,就是STM32用来产生和配置时钟, 并且把配置好的时钟发送到各个外设的系统。时钟是所有外设运行的基础,所以时钟也是最先需要配置的东西。程序中,主函数之前还会执行一个SystemInit的函数,这个函数就是用来配置这个时钟树的,这个结构看上去比较复杂,配置起来也比较麻烦,不过ST公司已经写好了配置这个时钟树的SystemInit函数。
这个图左边都是时钟的产生电路,右边的都是时钟的分配电路。 中间的SYSCLK就是系统时钟72MHz。在时钟产生电路,有4个振荡源,分别是内部的8MHz高速RC振荡器、外部的4~16MHz高速石英晶体振荡器,也就是晶振,一般都是接8MHz、外部的32.768KHz低速晶振,这个一般是给RTC提供时钟的,最后是内部的40KHz低速RC振荡器,这个可以给看门狗提供时钟。上面这两个高速晶振,是用来提供系统时钟的。AHB、APB2、APB1的时钟都是来源于这两个高速晶振。这里内部和外部都有一个8MHz的晶振,都是可以用的。只不过是外部的石英振荡器比内部的RC振荡器更加稳定,所以我们一般用外部晶振。但是如果系统简单,而且不需要那么精确的时钟,那也是可以使用内部的RC振荡器的,这样就可以省下外部晶振的电路了。
在SystemInit函数里,ST是这样配置时钟的:首先启动内部时钟,选择8MHz为系统时钟,暂时以内部8MHz的时钟运行,然后再启动外部时钟,进入PLL锁相环进行倍频,8MHz倍频9倍,得到72MHz,等到锁相环输出稳定后,选择锁相环输出为系统时钟,这样就把系统时钟由8MHz切换为了72MHz。这样分析之后,可以解决实际应用的一个问题,那就是如果外部晶振出问题了,可能会导致一个现象,程序的时钟会慢了大概10倍,比如用定时器定了个1s的时间,结果过了大概10s才进中断。问题就出在这里,如果外部晶振出问题了,系统时钟就无法切换到72MHz,就会以内部的8MHz进行运行,8MHz相较于72MHz,慢了大概10倍。
另外还有一个CSS(Clock Security System),这个是时钟安全系统,也是负责切换时钟的,它可以监测外部时钟的运行状态,一但外部时钟失效,它就会自动把外部时钟切换为内部时钟,保证系统时钟的运行,防止程序卡死造成事故。
在高级定时器里,也有这个CSS的身影。在刹车输入那里,一旦CSS检测到外部时钟失效,通过或门,就会立刻反应到输出比较那里,让输出控制的电机立即停止,防止意外,这就是STM32里的一些安全保障措施。
右边的时钟分配电路:首先系统时钟72MHz进入AHB总线,AHB总线有个预分频器,在SystemInit里配置的分配系数为1,那AHB的时钟就是72MHz,然后进图APB1总线,这里配置的分配系数是2,所以APB1总线的时钟为72MHz/2=36MHz。
现在可能会有个疑问,就是通用定时器和基本定时器是接在APB1上的,而APB1的时钟是36MHz,按理说它们的时钟应该是36MHz,但是在讲定时器的时候,一直都是说所有的定时器的时钟都是72MHz。这是因为预分频器出来后,下面还有一条支路,上面写的是如果APB1预分频系数=1,则频率不变,否则频率×2",然后再看右边,这一路是单独为定时器2~7开通的,那因为这里我们给的预分频器系数是2,所以这里频率要再乘2,所以通向2~7的时钟,就又回到了72MHz。所以这里有个结论,无论是高级定时器、还是通用定时器、基本定时器,它们的内部基准时钟都是72MHz。
APB2的时钟给的分频器系数为1,和AHB一样,都是72MHz,这里接在APB2上的高级定时器也单开了一路,上面写的也是"如果APB2预分频系数=1,则频率不变,否则频率×2",但是这里APB2的预分频系数就是1,所以频率不变,定时器1和8的时钟就是72MHz。
在时钟输出这里,都有一个与门进行输出控制,控制位写的是外部时钟使能,这就是我们在程序中写RCC_APB2/1PeriphClockCmd作用的地方,打开时钟,就是在这个位置写1,让左边的时钟能够通过与门输出给外设。
5.2 定时器定时中断
5.2.1 硬件电路
5.2.2 软件部分
(1)复制《OLED显示屏代码 》并改名为《定时器定时中断》。
(2)添加定时器驱动文件
因为定时器不涉及外部硬件,所以放在"system"文件夹下。
/*初始化定时器函数
第一步:RCC开启时钟,打开始终后,定时器的基准时钟和整个外设的工作时钟就会同步打开了;
第二步:选择时基单元的时钟源,对于定时中断,我们选择内部时钟源;
第三步:配置时基单元,包括预分频器、自动重装器、计数模式等,用一个结构体就可以配置好了;
第四步:配置输出中断控制,允许更新中断输出到NVIC;
第五步:配置NVIC,在NVIC中打开定时器中断的通道,并分配一个优先级;
第六步:运行控制,整个模块配置完成后,还要使能一下计数器,要不然计数器是不会运行的,当计时器
使能后,计数器就会开始计数了,当计数器更新时,触发中断;
最后再学习一个定时器的中断函数,这样中断函数每隔一段时间就能自动执行一次了*/
(3)定时器库函数
cpp
void TIM_DeInit(TIM_TypeDef* TIMx); // 恢复缺省配置
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
//时基单元初始化,用来配置时基单元,第一个参数用来选择某个定时器,第二个是结构体,里面包含了配置时基单元的一些参数
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_ICInit(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_PWMIConfig(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_BDTRConfig(TIM_TypeDef* TIMx, TIM_BDTRInitTypeDef *TIM_BDTRInitStruct);
void TIM_TimeBaseStructInit(TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
void TIM_OCStructInit(TIM_OCInitTypeDef* TIM_OCInitStruct);
void TIM_ICStructInit(TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_BDTRStructInit(TIM_BDTRInitTypeDef* TIM_BDTRInitStruct);
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState); // 用来使能计数器,对应运行控制,第一个参数选择定时器,第二个参数选择使能还是失能
void TIM_CtrlPWMOutputs(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_ITConfig(TIM_TypeDef* TIMx, uint16_t TIM_IT, FunctionalState NewState);
// 用来使能中断输出信号,对应中断输出控制,第一个参数选择定时器,第二个参数选择要配置哪个中断输出,第三个参数选择
void TIM_GenerateEvent(TIM_TypeDef* TIMx, uint16_t TIM_EventSource);
void TIM_DMAConfig(TIM_TypeDef* TIMx, uint16_t TIM_DMABase, uint16_t TIM_DMABurstLength);
void TIM_DMACmd(TIM_TypeDef* TIMx, uint16_t TIM_DMASource, FunctionalState NewState);
//下面6个函数对应时基单元的时钟选择部分,可以选择RCC内部时钟、ETR外部时钟、ITRx其它定时器、TIx捕获通道等等
void TIM_InternalClockConfig(TIM_TypeDef* TIMx); //选择内部时钟,
void TIM_ITRxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
//选择ITRx其它定时器的时钟,参数是ITMx,选择要配置的定时器,TIM_InputTriggerSource选择要接入哪个其它的定时器
void TIM_TIxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_TIxExternalCLKSource,
uint16_t TIM_ICPolarity, uint16_t ICFilter);
/*选择TIx捕获通道的时钟,第二个TIM_TIxExternalCLKSource选择TIx具体的某个引脚,TIM_ICPolarity和ICFilter:输入的极性和滤波器
对于外部引脚的波形,一般都会由极性选择和滤波器,这样更灵活一些*/
void TIM_ETRClockMode1Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,
uint16_t ExtTRGFilter);
/*选择ETR通过外部时钟模式1输入的时钟,TIM_ExtTRGPrescaler:外部触发预分频器,这里可以对ETR的外部时钟再提前做一个分频,
TIM_ExtTRGPolarity和ExtTRGFilter:极性选择和过滤器*/
void TIM_ETRClockMode2Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler,
uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);
/*选择ETR通过外部时钟模式2输入的时钟,TIM_ExtTRGPrescaler:外部触发预分频器,这里可以对ETR的外部时钟再提前做一个分频,
TIM_ExtTRGPolarity和ExtTRGFilter:极性选择和过滤器*/
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);
/*单独写预分频值,Prescaler就是要写入的预分频值,TIM_PSCReloadMode写入的模式(预分频器有一个缓冲器,写入的值实在更新事件发生后才有效的
所以这里有一个写入的模式,可以选择是听从安排,在更新事件生效或者是在写入后,手动产生一个更新事件,让这个值立刻生效)*/
void TIM_CounterModeConfig(TIM_TypeDef* TIMx, uint16_t TIM_CounterMode);
/*改变计数器的计数模式,TIM_CounterMode选择新的计数器模式*/
void TIM_SelectInputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
void TIM_EncoderInterfaceConfig(TIM_TypeDef* TIMx, uint16_t TIM_EncoderMode,
uint16_t TIM_IC1Polarity, uint16_t TIM_IC2Polarity);
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);
void TIM_ARRPreloadConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
/*自动重装器预装功能配置*/
void TIM_SelectCOM(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectCCDMA(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_CCPreloadControl(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_OC1PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC2PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC3PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC4PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
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);
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);
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);
void TIM_UpdateDisableConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_UpdateRequestConfig(TIM_TypeDef* TIMx, uint16_t TIM_UpdateSource);
void TIM_SelectHallSensor(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectOnePulseMode(TIM_TypeDef* TIMx, uint16_t TIM_OPMode);
void TIM_SelectOutputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_TRGOSource);
void TIM_SelectSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_SlaveMode);
void TIM_SelectMasterSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_MasterSlaveMode);
void TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);
/*给计数器写入一个值,如果想手动给一个计数值,就可以用这个函数*/
void TIM_SetAutoreload(TIM_TypeDef* TIMx, uint16_t Autoreload);
/*给自动重装器写入一个值,如果想手动给一个自动重装值,就可以用这个函数*/
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);
void TIM_SetIC1Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC2Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC3Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC4Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetClockDivision(TIM_TypeDef* TIMx, uint16_t TIM_CKD);
uint16_t TIM_GetCapture1(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture2(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture3(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture4(TIM_TypeDef* TIMx);
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);
/*上面四个函数就是用来获取和清除标志位的*/
在定时器的外部信号输入引脚,一般都会有一个滤波器,这个滤波器可以滤掉信号的抖动干扰。工作方式是:
在一个固定的时钟频率f下进行采样,如果连续N个采样点都为相同的电平,那就代表输入信号稳定了,就把这个采样值输出出去,如果这N个采样值不全都相同,那就说明信号有抖动,这时就保持上一次的输出,或者直接输出低电平也行,这样就能保证输出信号在一定程度上的滤波,这里的采样频率f和采样点数N都是滤波器的参数,频率越低,采样点数越多,那滤波效果就越好,不过相应的信号延迟就越大,这就是滤波器的工作原理。
采样频率f可以由内部时钟直接而来,也可以由内部时钟加一个分频而来,分频多少就是由
cpp
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
这个结构体初始化时候的
cpp
TIM_TimeBaseInitStructure.TIM_ClockDivision = ;
决定的。
跨文件使用变量:
cpp
extern uint16_t Num;
extern声明变量以后,就是告诉编译器,有一个Num变量,它在别的文件里定义了,让编译器自己去找。
(4)Timer.c
cpp
#include "stm32f10x.h" // Device header
/*初始化定时器函数
第一步:RCC开启时钟,打开始终后,定时器的基准时钟和整个外设的工作时钟就会同步打开了;
第二步:选择时基单元的时钟源,对于定时中断,我们选择内部时钟源;
第三步:配置时基单元,包括预分频器、自动重装器、计数模式等,用一个结构体就可以配置好了;
第四步:配置输出中断控制,允许更新中断输出到NVIC;
第五步:配置NVIC,在NVIC中打开定时器中断的通道,并分配一个优先级;
第六步:运行控制,启动定时器,整个模块配置完成后,还要使能一下计数器,要不然计数器是不会运行的,当计时器
使能后,计数器就会开始计数了,当计数器更新时,触发中断;
最后再学习一个定时器的中断函数,这样中断函数每隔一段时间就能自动执行一次了*/
void Timer_Init(void)
{
/*第一步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE); //开启挂载在APB1上的TIM2时钟
/*第一步:选择时基单元的时钟源*/
TIM_InternalClockConfig(TIM2); //TIM2的时基单元由内部时钟来驱动
/*第三步:配置时基单元*/
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //指定计数模式为向上计数
TIM_TimeBaseInitStructure.TIM_Period = 10000-1; //指定ARR自动重装器的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 7200-1; //指定预分频器的值
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);
TIM_ClearFlag(TIM2,TIM_FLAG_Update); //手动清除一下更新中断标志位,这样计数就从0开始,否则是从1开始(刚初始化就进中断)
/*第四步:使能更新中断*/
TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE); //TIM_IT_Update更新中断
/*第五步:配置NVIC
因为NVIC是内核外设,所以它的库函数是被ST发配到杂项这里来了,在misc.h文件里*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
//要对两个中断分别设置优先级
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = TIM2_IRQn; //指定中断通道
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 2; //将抢占优先级设置为2,优先级是在多个中断源同时申请,产生拥挤的时候才有作用
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1; //将响应优先级设置为1
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; //使能中断通道
NVIC_Init(&NVIC_InitStruct);
/*第六步:运行控制,启动定时器*/
TIM_Cmd(TIM2, ENABLE);
}
/*TIM2的中断函数*/
/*void TIM2_IRQHandler(void)
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update) == SET) //判断一下中断标志位状态
{
TIM_ClearITPendingBit(TIM2,TIM_IT_Update); //清除标志位
}
}
*/
(5)Timer.h
cpp
#ifndef __TIMER_H
#define __TIMER_H
void Timer_Init(void);
#endif
(6)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Timer.h"
uint16_t Num;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Timer_Init(); // 初始化定时器
OLED_ShowString(1,1,"Num:"); // 在1行3列显示字符串
while(1)
{
OLED_ShowNum(1,5,Num,5);
OLED_ShowNum(2,5,TIM_GetCounter(TIM2),5); //显示计数器值
}
}
/*TIM2的中断函数*/
void TIM2_IRQHandler(void)
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update) == SET) //判断一下中断标志位状态
{
Num++;
TIM_ClearITPendingBit(TIM2,TIM_IT_Update); //清除标志位
}
}
5.3 定时器外部时钟
5.3.1 硬件电路
PA0引脚就是TIM2的ETR引脚,在这个引脚输入一个外部时钟。
5.3.2 软件部分
(1)复制《定时器定时中断》工程改名为《定时器外部时钟》。
(2)Timer.c
cpp
#include "stm32f10x.h" // Device header
/*初始化定时器函数
第一步:RCC开启时钟,打开始终后,定时器的基准时钟和整个外设的工作时钟就会同步打开了;
第二步:选择时基单元的时钟源,对于定时中断,我们选择内部时钟源;
第三步:配置时基单元,包括预分频器、自动重装器、计数模式等,用一个结构体就可以配置好了;
第四步:配置输出中断控制,允许更新中断输出到NVIC;
第五步:配置NVIC,在NVIC中打开定时器中断的通道,并分配一个优先级;
第六步:运行控制,启动定时器,整个模块配置完成后,还要使能一下计数器,要不然计数器是不会运行的,当计时器
使能后,计数器就会开始计数了,当计数器更新时,触发中断;
最后再学习一个定时器的中断函数,这样中断函数每隔一段时间就能自动执行一次了
第七步:配置GPIO,要用到GPIO*/
void Timer_Init(void)
{
/*第一步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE); //开启挂载在APB1上的TIM2时钟
/*第二步:选择时基单元的时钟源*/
TIM_ETRClockMode2Config(TIM2, TIM_ExtTRGPSC_OFF,TIM_ExtTRGPolarity_NonInverted, 0x0F);
//通过ETR引脚的外部时钟模式2配置,TIM_ExtTRGPSC_OFF预分频器不需要分频,TIM_ExtTRGPolarity_NonInverted外部触发极性设置为不反向(高电平或上升沿有效),0x0F用滤波器防止干扰
/*第三步:配置时基单元*/
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //指定计数模式为向上计数
TIM_TimeBaseInitStructure.TIM_Period = 10-1; //指定ARR自动重装器的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 1-1; //指定预分频器的值
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);
TIM_ClearFlag(TIM2,TIM_FLAG_Update); //手动清除一下更新中断标志位,这样计数就从0开始,否则是从1开始(刚初始化就进中断)
/*第四步:使能更新中断*/
TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE); //TIM_IT_Update更新中断
/*第五步:配置NVIC
因为NVIC是内核外设,所以它的库函数是被ST发配到杂项这里来了,在misc.h文件里*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
//要对两个中断分别设置优先级
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = TIM2_IRQn; //指定中断通道
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 2; //将抢占优先级设置为2,优先级是在多个中断源同时申请,产生拥挤的时候才有作用
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1; //将响应优先级设置为1
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; //使能中断通道
NVIC_Init(&NVIC_InitStruct);
/*第六步:运行控制,启动定时器*/
TIM_Cmd(TIM2, ENABLE);
/*第七步:配置GPIO*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
}
/*获取计数器的值*/
uint16_t Timer_GetCounter(void)
{
return TIM_GetCounter(TIM2);
}
/*TIM2的中断函数*/
/*void TIM2_IRQHandler(void)
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update) == SET) //判断一下中断标志位状态
{
TIM_ClearITPendingBit(TIM2,TIM_IT_Update); //清除标志位
}
}
*/
(3)Timer.h
cpp
#ifndef __TIMER_H
#define __TIMER_H
void Timer_Init(void);
uint16_t Timer_GetCounter(void);
#endif
(4)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Timer.h"
uint16_t Num;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Timer_Init(); // 初始化定时器
OLED_ShowString(1,1,"Num:");
OLED_ShowString(2,1,"CNT:");
while(1)
{
OLED_ShowNum(1,5,Num,5);
OLED_ShowNum(2,5,Timer_GetCounter(),5); //显示计数器值
}
}
void TIM2_IRQHandler(void)
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update) == SET) //判断一下中断标志位状态
{
Num++;
TIM_ClearITPendingBit(TIM2,TIM_IT_Update); //清除标志位
}
}
5.4 TIM输出比较
5.4.1 输出比较简介
这个输出比较功能是非常重要的,主要用来输出PWM波形的,PWM波形又是驱动电机的必要条件,所以如果想用STM32做一些有电机的项目,比如智能车、机器人等。
(1)OC(Output Compare)输出比较;
(2)输出比较可以通过比较CNT与CCR寄存器值的关系,来对输出电平进行置1、置0或翻转的操作,用于输出一定频率和占空比的PWM波形;
CCR就是捕获比较寄存器,CC是捕获/比较的意思 ,R是Register,寄存器的意思。这个捕获/比较寄存器是输入捕获/输出比较共用的,当使用输入捕获时,它就是捕获寄存器;当使用输出比较时,它就是比较寄存器。在输出比较这里,这块电路会比较CNT和CCR的值,CNT计数自增,CCR是我们给定的一个值,当CNT大于CCR、小于CCR或者等于CCR时,这里输出就会对应的置1、置0、置1、置0,这样就可以输出一个电平不断跳变的PWM波形了。
(3)每个高级定时器和通用定时器都拥有4个输出比较通道;
(4)高级定时器的前3个通道额外拥有死区生成和互补输出的功能。
基本定时器没有输入捕获和输出比较的功能,所以基本定时器不能使用。通用定时器和高级定时器都有四个输出比较的通道,可以同时输出4路PWM波形,这四个通道上有各自的CCR寄存器,但是它们是共用一个CNT计数器的。高级定时器前三个输出比较通道还额外有死区生成和互补输出的功能,这个是用来驱动三相无刷电机的。
5.4.2 PWM简介
PWM(Pulse Width Modulation)脉冲宽度调制;
PWM波形是一个数字输出信号,也是由高低电平组成的。
在具有惯性的系统中,可以通过对一系列脉冲的宽度进行调制,来等效地获得所需要的模拟参量,常应用于电机控速等领域
PWM参数:
频率 = 1 / TS 占空比 = TON / TS 分辨率 = 占空比变化步距
占空比决定了PWM等效出来地模拟电压的大小, 占空比越大,那等效的模拟电压就趋近于高电平,占空比越小,那等效的模拟电压就越趋近于低电平,这个等效关系一般来说是线性的。比如高电平是5V,低电平是0V,那50%占空比就等效于中间电压,就是2.5V。20%占空比就等效于,1/5处的电压,就是1V。
分辨率:比如有的占空比只能是1%、2%、3%等等这样的以1%的步距跳变,那它的分辨率就是1%,如果可以以1.1%、1.2%、1.3%等等这样以0.1%的步距跳变,那它的分辨率就是0.1%。所以这个分辨率就是占空比变化的精细程度。这个分辨率需要多高,得看项目时基需求。如果既要高频率,又要高分辨率,这时对硬件电路要求就比较高了。不过一般要求比较高得话,1%的分辨率也就已经足够使用了。
使用PWM波形,就可以在数字系统等效输出模拟量,就能实现LED控制亮度、电机控速等功能了。
5.4.3 输出比较通道(通用)
上图是通用定时器的输出比较部分电路,包括高级定时器的第四个通道和这个机构也基本是一样的。对应下图标注部分电路。
左边是CNT和CCR比较的结果,右边就是输出比较电路,最后通过TIM_CH1输出到GPIO引脚上。 下面还有三个同样的单元,分别是输出到CH2、CH3、CH4。
在这个图里,左边就是CNT计数器和CCR1第一路的捕获/比较寄存器。 它两进行比较,当CNT>CCR1或者CNT=CCR1时,就会给输出模式控制器传一个信号,然后输出模式控制器就会改变它输出OC1REF的高低电平。REF信号实际上就是指信号的高低电平,这个REF是reference的缩写,意思是参考信号。上面还有一个ETRF输入,这个是定时器的一个小功能,一般不用。接着REF信号可以前往主模式控制器,可以把这个REF信号映射到主模式的TRGO输出上去,不过REF的主要去向还是下面这一路,通过这一路到达TIMx_CCER,给这个寄存器写0,信号就会往上走,就是信号电平不翻转,进来啥样,出去啥样;写1的话,信号会往下走,就是信号通过一个非门取反,那输出的信号就是输入信号高低电平反转的信号;这就是极性选择,就是选择是不是要把高低电平反转一下。接着就是输出使能电路了,选择要不要输出,这个引脚就是CH1通道的引脚,最后就是OC1引脚,这个引脚就是CH1通道的引脚,在引脚定义表里就可以直到具体是哪个GPIO口了。
输出模式控制器是如何工作的:什么时候给REF低电平,什么时候给高电平。输出模式控制器里面执行逻辑入下表所示。
|----------|-------------------------------------------------------------------------------------|
| 模式 | 描述 |
| 冻结 | CNT=CCR时,REF保持为原状态 |
| 匹配时置有效电平 | CNT=CCR时,REF置有效电平 |
| 匹配时置无效电平 | CNT=CCR时,REF置无效电平 |
| 匹配时电平翻转 | CNT=CCR时,REF电平翻转 |
| 强制为无效电平 | CNT与CCR无效,REF强制为无效电平 |
| 强制为有效电平 | CNT与CCR无效,REF强制为有效电平 |
| PWM模式1 | 向上计数:CNT<CCR时,REF置有效电平,CNT≥CCR时,REF置无效电平 向下计数:CNT>CCR时,REF置无效电平,CNT≤CCR时,REF置有效电平 |
| PWM模式2 | 向上计数:CNT<CCR时,REF置无效电平,CNT≥CCR时,REF置有效电平 向下计数:CNT>CCR时,REF置有效电平,CNT≤CCR时,REF置无效电平 |
冻结模式:CNT和CCR没有用,处于无效状态,REF保持为原状态,这都是一样的效果。这个模式也比较简单,它根部不管CNT和CCR谁大谁小,直接REF保持不变,维持上一个状态就可以了。用处:比如正在输出PWM波、突然想暂停一会儿输出、就可以设置成这个模式。一旦切换为冻结模式后,输出就暂停了。并且高低电平也维持为暂停时刻的状态,保持不变,这就是冻结模式的作用。
匹配时置有效电平/匹配时置无效电平/匹配时电平翻转:这个有效电平和无效电平,一般是高级定时器里面的一个说法,是和关断、刹车这些功能配合表述的,它说的比较严谨,所以叫有效电平和无效电平。所以可以直接理解为置有效电平就是置高电平,置无效电平就是置低电平。这些模式就可以用作波形输出了。比如相等时电平翻转这个模式,这个可以方便地输出一个频率可调,占空比始终为50%的PWM波形,比如设置CCR为0,那CNT每次更新清0时,就会产生一次CNT=CCR的事件,这就会导致输出电平翻转一次,每更新两次、输出为一个周期,并且高电平和低电平的时间是始终相等的,也就是占空比时钟为50%。当改变定时器更新频率时,输出波形的频率也会随之改变,它两的关系是输出波形的频率=更新频率/2,因为更新两次输出才为一个周期,这就是匹配时电平翻转模式的用途。匹配时置有效电平/匹配时置无效电平都是一次性的,置完高电平/低电平后,就不管事了,所以这两个模式不适合输出连续变化的波形,如果想定时输出一次性的信号,可以考虑一下这两个模式。
强制为无效电平/强制为有效电平:这两个模式是CNT与CCR无效,REF强制为无效电平或者强制为有效电平。这两个模式和冻结模式也差不多。如果想暂停波形输出,并且在暂停期间保持低电平或者保持高电平,就可以设置这两个强制输出模式。
PWM模式1/PWM模式2:它们可以用于输出频率和占空比都可调的PWM波形,也是我们主要使用的模式。
5.4.4 输出比较通道(高级)
上图是高级定时器前三个通道的输出比较部分电路。在它外面,通常要接这样一个外部电路,如下图所示, 上面是正极,接的是一个大功率开发管,一般都是MOS管,就是一种大功率电子开关,然后再来一个MOS管,最后到GND。MOS管左边是控制极、比如说给高电平右边两根线就导通,低电平就断开;下面也是一样,高电平导通,低电平断开。这就是一个最基本的推挽电路,中间是输出;如果上管导通,下管断开,那输出就是高电平;如果下管导通,上管断开,输出就是低电平;如果上下管都导通,那就是电源短路,这样是不允许的;如果上下管都断开,那输出就是高阻态;这就是推挽电路的工作流程。如果有两个这样的推挽电路,那就构成了H桥电路,就可以控制直流电机正反转了。如果有三个这样的推挽电路,就可以用于驱动三相无刷电机了。这就是这个电路的用途。对于这个电路来说,如果直接用单片机来控制的话,那就需要两个控制极,并且这两个控制极电平是相反的,也就是互补,因为上管导通,下管就必须断开,下管导通,上管就必须断开。知道了外部电路的需求,再来理解内部电路结构,那自热就好理解了。首先这个OC1和OC1N就是两个互补的输出端口,分别控制上口和下口的导通和关闭,然后是在切换上下管导通状态时,如果在上管关断的瞬间,下管就会立刻打开,那可能会因为器件的不理想,上管还没有完全关断,下管就已经导通了,出现了短暂的上下管同时导通的现象,这会导致功率损耗,引起器件发热,所以在这里为了避免这个问题,就有了死区生成电路,它会在上管关闭的手,延迟一小段时间,再导通下管,下管关闭的时候,延时一小段时间,再导通上管,这样就可以避免上下管同时导通的现象了,这就是死区生成和互补输出的用途。
5.4.5 PWM基本结构
左上角是时基单元和运行控制部分,最左边是时钟源选择,这里省略了,输出PWM不需要中断。配置好了时基单元,CNT就可以开始不断地自增运行了。
下面就是输出比较单元,总共有4路。输出比较单元地最开始,是CCR捕获/比较寄存器,CCR是我们自己设定地,CNT不断自增运行,同时它两还在不断运行比较,后面就是输出模式控制器。以PWM模式1为例:CNT<CCR时,REF置有效电平、CNT≥CCR时,REF置无效电平。如何输出PWM波形如右上图所示:蓝色线是CNT的值,黄色线是ARR的值。蓝色线从0开始自增,一直增到ARR,也就是99,之后清零继续自增。在这个过程中,再设置一条红色线,这条红色线就是CCR,比如我们设置CCR为30。之后再执行PWM模式1逻辑,下面绿色线就是输出。并且占空比是受CCR的值调控的,如果CCR设置高一些,输出的占空比就变大。这里REF就是一个频率可调,占空比也可调的PWM波形,最终再经过极性选择,输出使能,最终通向GPIO口,这样就能完成PWM波形的输出了。
5.4.6 PWM参数计算
PWM频率: Freq = CK_PSC / (PSC + 1) / (ARR + 1)
PWM占空比: Duty = CCR / (ARR + 1)
PWM分辨率: Reso = 1 / (ARR + 1)
5.4.7 舵机简介
舵机是一种根据输入PWM信号占空比来控制输出角度的装置。
图示舵机型号为SG90,它有三根输入线,两根电源线,一根是信号线,PWM就是输入到这个信号线,来控制舵机的。有一个白色的输出轴,它的轴会固定再一个指定的角度不动,至于固定在哪个位置,是由信号线的PWM信号来决定的。这就是舵机的工作方式。PWM输出到控制板,给控制板一个指定的目标角度,然后这个电位器检测输出轴的当前角度,如果大于目标角度,电机就会反转,如果小于目标角度,电机就会正转。最终使输出轴固定在指定角度,这就是舵机的内部工作流程。不管怎样,输入一个PWM波形,输出轴固定在一个角度就行了。
输入PWM信号要求:周期为20ms,高电平宽度为0.5ms~2.5ms。
实际应用的时候,比如机器人、机械臂,可以用舵机来控制关节,遥控车、遥控船可以用舵机来控制方向。还有一些其它的机器结构,都可以考虑用这个舵机。这里的PWM波形其实是当作一个协议来使用的。
5.4.7.1 舵机硬件电路
舵机引脚有以下几种区分:
|--------|----------|
| 颜色 | 信号 |
| 黑 | GND |
| 红 | VCC(+5V) |
| 黄 | PWM |
|--------|----------|
| 颜色 | 信号 |
| 棕 | GND |
| 红 | VCC(+5V) |
| 橙 | PWM |
|--------|----------|
| 颜色 | 信号 |
| 黑 | GND |
| 红 | VCC(+5V) |
| 白 | PWM |
实际应用的时候,电源正的5V,一般电机都是大功率设备,它的驱动电源也必须是一个大功率的输出设备,如果能单独供电最好,如果不能也要注意电源功率是否能达标。单独供电的话,供电的负极要和STM32共地,然后正极接在5V供电引脚上。对于套件,可以直接从STLINK的5V输出角,引一根线,接到这里,这样就是使用USB的5V供电。
然后是PWM,因为舵机内部是有驱动电路的,所以单片机的引脚可以直接接上去,PWM只是一根通信线,是不需要大功率的。
5.4.8 直流电机及驱动简介
直流电机是一种将电能转换为机械能的装置,有两个电极,当电极正接时,电机正转,当电极反接时,电机反转。
套件里的是130直流电机,一边接正,一边接负、电机就朝一个方向转,如果把正负极对调,那电机就朝另一个方向转。
直流电机属于大功率器件,GPIO口无法直接驱动,需要配合电机驱动电路来操作。
电机这类器件都属于大功率驱动设备,必须要加驱动电路才能控制。市面上有很多驱动电路可以选择,比如TB6612、DRV8833、L9110、L298N等等,这些都是比较常见的电机驱动芯片。另外还有一些用分立元件MOS管搭建的电路,这个功率可以做的更大一些,当然也可以自己用MOS管来设计电路。
TB6612是一款双路H桥型的直流电机驱动芯片,可以驱动两个直流电机并且控制其转速和方向。
这个芯片是有两路驱动电路的,可以独立地控制两个电机,又因为它是H桥型地驱动电路,里面一路有4个开关管,所以可以控制正反转。像有些芯片,比如ULN2003,它里面只有一路就只有一个开关管,所以就只能控制电机在一个方向转。
5.4.8.1 硬件电路
STBY(Stand By)引脚:这个是待机控制脚,如果接GND,芯片就不工作,处于待机状态,如果接逻辑电源VCC,芯片就正常工作。这个引脚如果不需要待机的话,可以直接接VCC,3.3V。如果需要的话,可以接任意一个GPIO,给高低电平就可以控制了。这就是电机驱动板的硬件电路。
5.5 PWM驱动LED呼吸灯
5.5.1 硬件电路
5.5.2 软件部分
(1)复制《OLED显示屏》工程修改为《PWM驱动LED呼吸灯》
(2)添加PWM驱动文件
PWM初始化就是打通下图所示结构:
(3)相关库函数功能
cpp
void TIM_DeInit(TIM_TypeDef* TIMx);
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
/*下面4个函数就是用来配置输出比较模块的,OC就是Output Compare,输出比较*/
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_ICInit(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_PWMIConfig(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_BDTRConfig(TIM_TypeDef* TIMx, TIM_BDTRInitTypeDef *TIM_BDTRInitStruct);
void TIM_TimeBaseStructInit(TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
void TIM_OCStructInit(TIM_OCInitTypeDef* TIM_OCInitStruct);
/*给输出比较结构体赋一个默认值*/
void TIM_ICStructInit(TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_BDTRStructInit(TIM_BDTRInitTypeDef* TIM_BDTRInitStruct);
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_CtrlPWMOutputs(TIM_TypeDef* TIMx, FunctionalState NewState);
/*上面这个函数仅高级定时器使用,在高级定时器PWM时需要调用这个函数,使能输出,否则PWM将不能正常输出*/
void TIM_ITConfig(TIM_TypeDef* TIMx, uint16_t TIM_IT, FunctionalState NewState);
void TIM_GenerateEvent(TIM_TypeDef* TIMx, uint16_t TIM_EventSource);
void TIM_DMAConfig(TIM_TypeDef* TIMx, uint16_t TIM_DMABase, uint16_t TIM_DMABurstLength);
void TIM_DMACmd(TIM_TypeDef* TIMx, uint16_t TIM_DMASource, FunctionalState NewState);
void TIM_InternalClockConfig(TIM_TypeDef* TIMx);
void TIM_ITRxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
void TIM_TIxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_TIxExternalCLKSource,
uint16_t TIM_ICPolarity, uint16_t ICFilter);
void TIM_ETRClockMode1Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,
uint16_t ExtTRGFilter);
void TIM_ETRClockMode2Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler,
uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);
void TIM_ETRConfig(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,
uint16_t ExtTRGFilter);
void TIM_PrescalerConfig(TIM_TypeDef* TIMx, uint16_t Prescaler, uint16_t TIM_PSCReloadMode);
void TIM_CounterModeConfig(TIM_TypeDef* TIMx, uint16_t TIM_CounterMode);
void TIM_SelectInputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
void TIM_EncoderInterfaceConfig(TIM_TypeDef* TIMx, uint16_t TIM_EncoderMode,
uint16_t TIM_IC1Polarity, uint16_t TIM_IC2Polarity);
/*下面4个函数是用来配置强制输出模式的,如果在运行中想要暂停输出波形并且强制输出高低电平,可以使用这几个函数,不过一般用的不多
因为强制输出高电平和设置占空比100%是一样的,强制输出低电平和设置占空比0%是一样的*/
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);
void TIM_ARRPreloadConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectCOM(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectCCDMA(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_CCPreloadControl(TIM_TypeDef* TIMx, FunctionalState NewState);
/*下面4个函数用来配置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);
/*下面4个函数用来配置快速使能*/
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);
/*下面7个就是用来单独设置输出比较的极性的,带N的就是高级定时器里互补通道的配置,OC4没有互补通道。
这里有函数可以设置极性,在结构体初始化那个函数里也可以设置极性,这两个地方设置极性的作用是一样的,
只不过用结构体是一起初始化的,在这里是一个单独的函数进行修改的,一般来说,结构体里的参数,
都会有一个单独的函数可以进行更改,这里的函数就是用来单独更改输出极性的*/
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);/*选择输出比较模式,这个是用来单独更改输出比较模式的函数*/
void TIM_UpdateDisableConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_UpdateRequestConfig(TIM_TypeDef* TIMx, uint16_t TIM_UpdateSource);
void TIM_SelectHallSensor(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectOnePulseMode(TIM_TypeDef* TIMx, uint16_t TIM_OPMode);
void TIM_SelectOutputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_TRGOSource);
void TIM_SelectSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_SlaveMode);
void TIM_SelectMasterSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_MasterSlaveMode);
void TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);
void TIM_SetAutoreload(TIM_TypeDef* TIMx, uint16_t Autoreload);
/*下面四个函数是用来单独更改CCR寄存器值的函数,这4个函数比较重要,在运行的时候,更改占空比,就需要用到这4个函数*/
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);
void TIM_SetIC1Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC2Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC3Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC4Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetClockDivision(TIM_TypeDef* TIMx, uint16_t TIM_CKD);
uint16_t TIM_GetCapture1(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture2(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture3(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture4(TIM_TypeDef* TIMx);
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);
如果使用TIM2的OC1也就是CH1通道输出PWM,那就只能在PA0输出;如果选择TIM2的CH3,就只能选择PA2。其它外设也是同理,这个关系是定死的,不能更改,不过STM32还是提供了一次更改的机会,就是重定义或者叫重映射功能。比如既要用USTART2的TX引脚,又要用TIM2的CH3通道,它两冲突了,没办法同时用,那我们就可以在重映射的列表里找一下,发现TIM2的CH3通道可以重映射到PB10引脚;如果重映射列表里找不到,那外设复用的GPIO就不能挪位置,这就是重映射的功能,配置重映射是用AFIO来完成的。
(4)PWM.c
cpp
#include "stm32f10x.h" // Device header
/*PWM初始化函数:
第1步:RCC开启时钟,把我们需要的TIM外设和GPIO外设的时钟打开
第2步:配置时基单元,包括前面的时钟源选择和这里的时基单元,都配置好
第3步:配置输出比较单元,包括CCR的值、输出比较模式、极性选择、输出使能这些参数,在库函数里也是用结构体统一配置的
第4步:配置GPIO,把PWM对应的GPIO口,初始化为复用推挽输出的配置,这个PWM和GPIO的对应关系是怎样的?可以参考引脚定义表
第5步:运行控制,启动计数器,这样就能输出PWM了
*/
void PWM_Init(void)
{
/*第1步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE); //开启挂载在APB1上的TIM2时钟
/*第2步:选择时基单元的时钟源*/
TIM_InternalClockConfig(TIM2); //TIM2的时基单元由内部时钟来驱动
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //指定计数模式为向上计数
TIM_TimeBaseInitStructure.TIM_Period = 100-1; //指定ARR自动重装器的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 720-1; //指定预分频器PCS的值
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);
/*第3步:配置输出比较单元*/
TIM_OCInitTypeDef TIM_OCInitStruct;
/*对于这个结构体变量来说,它现在是一个局部变量,如果不给它的成员赋初始值,它成员的值就是不确定的,可能回导致一些问题,
比如当要把高级定时器当作通用定时器输出PWM时,自然会把TIM2改为TIM1,这个结构体原来不用的成员现在又需要用了,而这些成员又
没有赋值,就会导致高级定时器输出PWM出现一些奇怪的问题*/
TIM_OCStructInit(&TIM_OCInitStruct); //为避免上述提到的问题,给这个结构体赋初始值
TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1; //输出比较模式设置为PWM1
TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High; //极性选择设置为高极性,有效电平是高电平,REF有效时,输出高电平
TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable; //输出使能
TIM_OCInitStruct.TIM_Pulse = 0; //设置CCR的值
TIM_OC1Init(TIM2,&TIM_OCInitStruct);
/*第4步:配置GPIO*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
/*对于普通的开漏/推挽输出,引脚的控制全是来自输出数据寄存器的,如果想用定时器来控制引脚,那就需要使用复用开漏/推挽输出的模式
这里输出数据寄存器将被断开,输出控制全将转移给片上外设,只有把GPIO设置成复用推挽输出,引脚的控制权才能交给片上外设,PWM波形才能
通过引脚输出*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_0);
/*第六步:运行控制,启动定时器*/
TIM_Cmd(TIM2, ENABLE);
}
/*更改占空比*/
void PWM_SetCompare1(uint16_t Compare)
{
TIM_SetCompare1(TIM2,Compare);
}
(5)PWM.h
cpp
#ifndef __PWM_H
#define __PWM_H
void PWM_Init(void);
void PWM_SetCompare1(uint16_t Compare);
#endif
(6)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "PWM.h"
uint8_t i;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
PWM_Init();
while(1)
{
for(i=0;i<=100;i++)
{
PWM_SetCompare1(i); //更改占空比
Delay_ms(10);
}
for(i=0;i<=100;i++)
{
PWM_SetCompare1(100-i); //更改占空比
Delay_ms(10);
}
}
}
(7)使用复用功能(引脚重映射)实现
PWM.c如下
cpp
#include "stm32f10x.h" // Device header
/*PWM初始化函数:
第1步:RCC开启时钟,把我们需要的TIM外设和GPIO外设的时钟打开
第2步:配置时基单元,包括前面的时钟源选择和这里的时基单元,都配置好
第3步:配置输出比较单元,包括CCR的值、输出比较模式、极性选择、输出使能这些参数,在库函数里也是用结构体统一配置的
第4步:配置GPIO,把PWM对应的GPIO口,初始化为复用推挽输出的配置,这个PWM和GPIO的对应关系是怎样的?可以参考引脚定义表
第5步:运行控制,启动计数器,这样就能输出PWM了
*/
void PWM_Init(void)
{
/*第1步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE); //开启挂载在APB1上的TIM2时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE); //开启挂载在APB2上的AFIO时钟
GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2,ENABLE); //将TIM2的CH1通道重映射到PA15
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable,ENABLE);
/*PA15上电后默认复用为了调试端口JTDI,所以想让它作为普通GPIO或者复用定时器通道,要先关闭调试端口的复用*/
/*第2步:选择时基单元的时钟源*/
TIM_InternalClockConfig(TIM2); //TIM2的时基单元由内部时钟来驱动
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //指定计数模式为向上计数
TIM_TimeBaseInitStructure.TIM_Period = 100-1; //指定ARR自动重装器的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 720-1; //指定预分频器PCS的值
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);
/*第3步:配置输出比较单元*/
TIM_OCInitTypeDef TIM_OCInitStruct;
/*对于这个结构体变量来说,它现在是一个局部变量,如果不给它的成员赋初始值,它成员的值就是不确定的,可能回导致一些问题,
比如当要把高级定时器当作通用定时器输出PWM时,自然会把TIM2改为TIM1,这个结构体原来不用的成员现在又需要用了,而这些成员又
没有赋值,就会导致高级定时器输出PWM出现一些奇怪的问题*/
TIM_OCStructInit(&TIM_OCInitStruct); //为避免上述提到的问题,给这个结构体赋初始值
TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1; //输出比较模式设置为PWM1
TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High; //极性选择设置为高极性,有效电平是高电平,REF有效时,输出高电平
TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable; //输出使能
TIM_OCInitStruct.TIM_Pulse = 0; //设置CCR的值
TIM_OC1Init(TIM2,&TIM_OCInitStruct);
/*第4步:配置GPIO*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
/*对于普通的开漏/推挽输出,引脚的控制全是来自输出数据寄存器的,如果想用定时器来控制引脚,那就需要使用复用开漏/推挽输出的模式
这里输出数据寄存器将被断开,输出控制全将转移给片上外设,只有把GPIO设置成复用推挽输出,引脚的控制权才能交给片上外设,PWM波形才能
通过引脚输出*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_15;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_15);
/*第六步:运行控制,启动定时器*/
TIM_Cmd(TIM2, ENABLE);
}
/*更改占空比*/
void PWM_SetCompare1(uint16_t Compare)
{
TIM_SetCompare1(TIM2,Compare);
}
5.6 PWM驱动舵机
5.6.1 硬件电路
5.6.2 软件部分
(1)复制《PWM驱动LED呼吸灯》工程改为《PWM驱动舵机》
(2)添加舵机驱动文件
(3)SERVO.c
cpp
#include "stm32f10x.h" // Device header
#include "PWM.h"
/*舵机初始化函数*/
void Servo_Init(void)
{
PWM_Init(); //初始化一下PWM底层
}
void Servo_SetAngle(float Angle)
{
PWM_SetCompare2(Angle/180*2000+500); //角度到CCR值的映射(0对应500,180对应2500)
}
(4)SERVO.h
cpp
#ifndef __SERVO_H
#define __SERVO_H
void Servo_Init(void);
void Servo_SetAngle(float Angle);
#endif
(5)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "SERVO.h"
#include "Key.h"
uint8_t KeyNum;
float Angle;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Servo_Init();
OLED_ShowString(1,1,"Angle:");
while(1)
{
KeyNum = Key_GetNum();
if(KeyNum == 1)
{
Angle += 30;
if(Angle>180)
{
Angle = 0;
}
Servo_SetAngle(Angle);
OLED_ShowNum(1,7,Angle,3);
}
}
}
5.7 PWM驱动直流电机
5.7.1 硬件电路
5.7.2 软件部分
(1)复制《PWM驱动LED呼吸灯》工程改为《PWM驱动直流电机》
(2)新建驱动文件
(3)Motor.c
cpp
#include "stm32f10x.h" // Device header
#include "PWM.h"
/*直流电机初始化函数*/
void Motor_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_4|GPIO_Pin_5;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
PWM_Init();
}
/*直流电机速度函数*/
void Motor_SetSpeed(int8_t Speed)
{
if(Speed >= 0) //正转
{
GPIO_SetBits(GPIOA,GPIO_Pin_4);
GPIO_ResetBits(GPIOA,GPIO_Pin_5);
PWM_SetCompare3(Speed);
}
else //反转
{
GPIO_SetBits(GPIOA,GPIO_Pin_5);
GPIO_ResetBits(GPIOA,GPIO_Pin_4);
PWM_SetCompare3(-Speed);
}
}
(4)Motor.h
cpp
#ifndef __MOTOR_H
#define __MOTOR_H
void Motor_Init(void);
void Motor_SetSpeed(int8_t Speed);
#endif
(5)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "Key.h"
#include "OLED.h"
#include "Motor.h"
uint8_t KeyNum;
int8_t Speed;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Motor_Init();
Key_Init();
OLED_ShowString(1,1,"Speed:");
while(1)
{
KeyNum = Key_GetNum();
if(KeyNum == 1)
{
Speed += 20;
if(Speed>100)
{
Speed = -100;
}
}
Motor_SetSpeed(Speed);
OLED_ShowSignedNum(1,7,Speed,3);
}
}
5.8 TIM输入捕获
5.8.1 输入捕获简介
(1)IC(Input Capture)输入捕获。
左圈就是输入捕获、右边就是输出比较部分。4个输入捕获和输出比较通道,共用4个CCR寄存器,另外它们的CH1到CH4,4个通道的引脚,也是共用的。 所以对于同一个定时器,输入捕获和输出比较,只能使用其中一个,不能同时使用。
(2)输入捕获模式下,当通道输入引脚出现指定电平跳变时,当前CNT的值将被锁存到CCR中,可用于测量PWM波形的频率、占空比、脉冲间隔、电平持续时间等参数。
指定电平跳变就是指上升沿或者下降沿,可以通过程序配置。发生电平跳变的瞬间,输入捕获电路会干些啥呢?会让当前CNT的值锁存到CCR中,锁存到CCR的意思就是,把当前CNT的值读出来,写入到CCR中去,这就是功能描述。
左边的4个边沿信号输入引脚,一旦有边沿,比如说上升沿,那输入滤波和边沿检测电路就会检测到这个上升沿,让输入捕获电路产生动作。所以输入滤波和边沿检测电这一块的作用和外部中断差不多,都是检测电平跳变,然后执行动作,只不过外部中断执行的动作是向CPU申请中断,而这里电路执行的动作就是控制后续电路,让当前CNT的值,锁存到CCR寄存器中。对比一下输出比较,输出比较引脚是输出端口;输入捕获,引脚是输入端口;输出比较,是根据CNT和CCR的大小关系来执行输出动作;输入捕获是接收到输入信号,执行CNT锁存到CCR的动作,这就是输入捕获的执行流程与输出比较的区别。
脉冲间隔实际上是和频率差不多的意思,电平持续时间和占空比差不多。
(3)每个高级定时器和通用定时器都拥有4个输入捕获通道。
输入捕获电路,通用定时器和高级定时器没有区别,都是一样的,然后基本定时器,没有输入捕获的功能。
(4)可配置为PWMI模式,同时测量频率和占空比。
PWMI模式就是PWM的输入模式,是专门为测量PWM频率和占空比设计的。
(5)可配合主从触发模式,实现硬件全自动测量。
PWMI模式和主从才触发模式,设计的非常巧妙,把这两个功能结合起来,测量频率占空比就是硬件全自动执行,软件不需要进行任何干预、也不需要中断。需要测量的时候,直接读取CCR寄存就行了,使用非常方便,极大减轻了软件的压力。
5.8.2 频率测量
测频法:在闸门时间T内,对上升沿计次,得到N,则频率
测周法:两个上升沿内,以标准频率fc计次,得到N ,则频率
中界频率:测频法与测周法误差相等的频率点
对于STM32测频率而言,它也是只能测量数字信号的,如果需要测量一个正弦波,那还需要搭建一个信号预处理电路,最简单的就是用运放搭一个比较器,把正弦波转换为数字信号,再输入给STM32就行了。如果测量的信号电压非常高,还要考虑一下隔离的问题,比如用一些隔离放大器、电压互感器等元件,隔离高压端和低压端,保证电路的安全,总之,经过处理,最终输入给STM32的信号,要是上图所示的高电平信号,高电平3.3V,低电平0V。
测量上图所示这样一个信号的方法:
首先为了测量频率,有两种方法可以选择,第一种是测频法,执行流程是**:**在闸门时间T内,对上升沿计次(计次下降沿也是可以,只是极性不同,都是一样的,之后为了方便,我们统一以上升沿为一个周期的开始进行描述),得到N,则频率。、
5.8.2.1 测评法
比如我们要测量上图框内所示部分的频率,就可以自定义一个闸门时间T,通常设置为1s,在1s时间内,对信号上升沿计次,从0开始计,每来一个上升沿,计次+1,每来一个上升沿,其实就是来了一个周期的信号,所以在1s时间内,来了多少个周期,那它的频率就是多少Hz。闸门时间也可以是2s,然后除以2;也可以是0.5秒,然后乘以2就是频率。
5.8.2.2 测周法
我们捕获信号的两个上升沿, 然后测量一下之间持续的时间就可以了。但实际上,我们并没有一个精度无穷大的秒表来测量时间,测量时间的方法,实际上也是定时器计次。我们使用一个已知的标准频率fc的计次时钟,来驱动计数器。从一个上升沿开始计,计数器从0开始,一直计到下一个上升沿,停止。计一个数的时间是,计N个数,时间就是,就是周期,再取个倒数,就得到了公式,。每次捕获之后,我们都要把CNT清零一下,这样下次上升沿再捕获的时候,取出的CNT才是两个上升沿的时间间隔;这个在一次捕获后自动将NCT清零的步骤,可以用主从触发模式,自动来完成。
5.8.2.3 测频法和测周法的区别
(1)测频法适合测量高频信号,测周法适合测量低频信号。
测频法在闸门时间内最好要多出现一些上升沿,计次数量多一些,这样有助于减小误差,加入定了1s的闸门时间,结果信号频率非常低,1s时间只有寥寥无几的几个上升沿,甚至一个上升沿都没有,总不能认为频率是0,在计次N很少时,误差会非常大,所以测频法要求,信号频率要稍微高一些。
对于测周法,就要求信号频率低一些,低频信号,周期比较长,计次就会比较多,有助于减小误差;否则的话,比如标准频率为1MHz,待测信号频率太高,比如待测信号是500KHz,那在一个周期内只能计一两个数,甚至一个数也记不到,总不能认为频率无穷大,所以测周法需要待测信号频率低一些。
(2)测评法测量结果更新慢一些,数值相对稳定;测周法更新的快,数据跳变也非常快。
测频法测量的是在闸门时间内的多个周期,所以它自带一个均值滤波,如果在闸门时间内波形频率有变化,那得到的其实是在这一段时间内的平均频率,如果闸门时间选为1s,那么每隔1s才能得到一次结果,所以测评法结果更新慢,测量结果是一段时间的平均值,值比较平滑。
反观测周法,只测量一个周期,就能出一次结果,所以出结果的速度取决于待测信号的频率,一般而言,待测信号都是几百几千Hz,所以一般情况下,测周法结果更新更快;但是由于它只测量一个周期,所以结果值会受到噪声的影响,波动比较大。这就是这两种方法的基本特征对比。
高频适合测评法,低频适合测周法,那多高算高,多低算低呢?
5.8.2.4 中界频率
中界频率是测频法与测周法误差相等的频率点。
测频法计次要求计次数量N尽量要大一些,N越大,相对误差越小;因为在测频法中,计次可能会存在正负1误差,比如测评法,在闸门时间内,并不是每个周期信号都是完整的,比如在最后时间里,可能有一个周期刚出现一半,闸门时间就到了,那这就只有半个周期,只能舍弃或者当作一整个周期来看,因为计次只有整数,不可能计次0.5个数,就会出现多计一个或者少计一个,这就叫做正负1误差。
另外在测周法中,标准频率fc计次,在最后时刻,有可能一个数刚数到一半,计时就结束了,那这半个数也只能舍弃或者按一整个数来算,这里也会出现正负1误差,正负1误差是这两种方法都固有的误差。要想减小正负1误差的影响,就只能尽量多计一些数。
如果有一个频率,测频法和测周法计次的N相同,就说明误差相同,这就是中界频率了。当待测信号频率小于中界频率时,测周法误差更小,选用测周法更合适;当待测信号频率大于中界频率时,测频法误差更小,选用测频法更合适。
5.8.2.5 使用STM32实现测频
对射式红外传感器计次、定时器外部时钟,这些代码稍加改进,就是测频法;比如对射式红外传感器计次,每来一个上升沿,计次+1;那我们再用一个定时器,订一个1s的中断,在中断里,每隔1s取一下计次值,同时清0计次,为下一次做准备,这样每次读取的计次值就直接是频率。对于外部定时器时钟的代码也是如此,每隔1s取一下计次,就能实现测频法测量频率的功能了。本节使用测周法。
从左往右看,最左边是4个通道的引脚, 参考引脚定义表,就能知道这个引脚是复用在了哪个位置。然后引脚进来,有一个三输入的异或门,这个异或门的输入接在了通道1、2、3端口,异或门的执行逻辑是,当三个输入引脚的任何一个有电平翻转时,输出引脚就产生一次电平翻转,之后输出通道数据选择器,到达输入捕获通道1,数据选择器如果选择上面一个,那输入捕获通道1的输入,就是3个引脚的异或值,如果选择下面一路,异或门就没有用,4个通道各用各的引脚。设计在这个异或门,其实还是为三相无刷电机服务的,无刷电机有三个霍尔传感器检测转子的位置,可以根据转子的位置进行换相,有了这个异或门,就可以在前三个通道接上无刷电机的霍尔传感器,然后在这个定时器就作为无刷电机的接口定时器,去驱动换相电路工作。输入信号过来以后,连在了输入滤波器和边沿检测器,输入滤波器可以对信号进行滤波,避免一些高频的毛刺信号误触发;然后边沿检测器,这就和外部中断那里是一样的了,可以选择高电平触发或者电平触发,当出现指定的电平时,边沿检测电路就会触发后续电路执行动作;另外这里,它其实是设计了两套滤波和边沿检测电路,第一套电路经过滤波和极性选择得到TI1FP1(TI1 Filter Polarity 1),输入给通道1的后续电路;第二套电路,经过另一个滤波和极性选择得到TI1FP2(TI1 Filter Polarity 2),输入给通道2的后续电路。同理,下面TI2信号进来,也经过两套滤波和极性选择,得到TI2FP1和TI2FP2,TI2FP1输入给上面,TI2FP2输入给下面。在这里两个信号进来,可以选择各走各的,也可以选择进行一个交叉,让CH2引脚输入给通道1,或者CH1引脚输入给通道2.这里为什么要进行交叉连接呢,这样做的目的主要有两个,第一个,可以灵活切换后续捕获电路的输入,比如一会儿想以CH1作为输入,一会儿想以CH2作为输入,这样就可以通过这个数据选择器,灵活地进行选择。第二个目的,也是它交叉的主要目的,就是可以把一个引脚的输入,同时映射到两个捕获单元,这也是PWMI模式的经典结构。第一个捕获通道使用上升沿触发,用来捕获周期,第二个通道使用下降沿触发,用来捕获占空比。两个通道同时对1个引脚进行捕获,就可以同时测量频率和占空比,这就是PWMI模式。通道3和通道4也是一样的结构,可以选择各自独立连接,也可以选择进行交叉。另外还有一个TRC信号,也可以选择作为捕获部分的输入。输入信号经过滤波和极性选择后,就来到了预分频器,每个通道各有一个,可以选择对前面的信号进行分频,分频之后的触发信号,就可以触发捕获电路进行工作了,每来一个触发信号,CNT的值,就会向CCR转运一次,转运的同时,会发生一个捕获事件,这个事件会在状态寄存器置标志位,同时也可以产生中断。若果需要在捕获的瞬间,处理一些事情的话,就可以开启这个捕获中断。这就是整个电路的工作流程,比如我们可以配置上升沿触发捕获,每来一个上升沿,CNT转运到CCR一次,又因为这个CNT计数器是由内部的标准时钟驱动的,所以CNT的数值,其实就可以用来记录两个上升沿之间的时间间隔,这个时间间隔,就是周期,再取个倒数,就是测周法测量的频率了。
5.8.3 输入捕获通道1框图
这个框图是上一个框图的细化结构,基本功能都是一样的。引脚进来还是先经过一个滤波器,滤波器的输入是TI1,就是CH1的引脚。输出的TI1F就是滤波后的信号,fDTS是滤波器的采样时钟来源,下面CCM1寄存器里的ICF位可以控制滤波器的参数,那这个滤波器具体怎么工作呢?
简单理解,这个滤波器工作原理就是: 以采样频率对输入信号进行采样,当连续N个值都为高电平,输出才为高电平;连续N个值都为低电平,输出才为低电平。如果信号出现高频抖动,导致连续采样N个值不全都一样,那输出就不会变化,这样就可以达到滤波的效果。采样频率越低,采样个数N越大,滤波效果越好。在实际应用中,如果波形噪声比较大,就可以把这个参数设置大一些,这样就可以过滤噪声了。
滤波之后的信号通过边沿检测器,捕获上升沿或者下降沿,用这个CCER寄存器里的CC1P位,就可以选择极性了,最终得到TI1FP1触发信号,通过数据选择器,进入通道1后续的捕获电路;实际应该还有一套一样的电路,得到TI1FP2触发信号,连通到通道2的后续电路,这里并没有画出来,同理,通道2有TI2FP1连通到通道1的后续,通道2也还有TI2,连通到通道2的后续。总共是4总连接方式,然后经过数据选择器,进入后续捕获电路部分,CC1S位可以对数据选择器进行选择,之后ICPS位,可以配置这里的分频器,可以选择不分频、2分频、4分频、8分频,最后CC1E位,控制输出使能或失能。如果使能了输出,输入端产生指定边沿信号,经过层层电路,到达ICPS,就可以让这里CNT的值,转运到CCR里。另外,每捕获一次,都要把CNT的清零一下,以便于下一次的捕获,在这里硬件电路就可以在捕获之后自动完成CNT的清零工作。如何自动清零呢?这个TI1FP1信号和TI1的边沿信号,都可以通向从模式控制器,比如TI1FP1信号的上升沿出发捕获,那通过这里,TI1FP1还可以同时触发从模式,这个从模式里就有电路可以自动完成CNT的清零。所以这个从模式就是完成自动化操作的利器。
5.8.4 主从触发模式
主从触发模式就是 主模式、从模式、触发源选择这三个功能的简称。其中主模式可以将定时器内部的信号,映射到TRGO引脚,用于触发别的外设,所以这部分叫做主模式。从模式就是接收其它外设或者自身外设的一些信号。用于控制自身定时器的运行,也就是被别的信号控制,所以这部分叫从模式。触发源选择就是选择从模式的触发信号源的,可以认为它是从模式的一部分,触发源选择,选择指定的一个信号,得到TRGI,TRGI去触发从模式,从模式可以在这个列表里,选择一项操作来自动执行,若果想要完成TI1FP1信号自动触发CNT清零,那触发源选择,就可以选中这里的TI1FP1,从模式执行的操作,就可以选择执行Reset的操作。这样TI1FP1的信号就可以自动触发从模式,从模式清零CNT,实现硬件全自动测量。
假如想实现定时器的级联,就可以选择一个定时器主模式输出更新信号到TRGO ,另一个定时器选择上一个定时触发从模式,从模式选择执行外部时钟模式1的操作。这样就能实现定时器的级联了。还有很多其它高级功能,都可以用主从触发模式来实现。主模式还可以选则复位、使能、比较脉冲和4个OCREF信号,作为TRGO的输出。
上图是从模式触发源的可选信号。
库函数里, 上图对应三个函数,调用函数,给个参数就行了。
5.8.5 输入捕获基本结构
上图所示结构只是用了一个通道,所以它目前只能测量频率。右上角是时基单元,我们把时基单元配置好,启动定时器, 那这个CNT就会在预分频之后的这个时钟驱动下,不断自增,这个CNT就是我们测周法用来计数计时的东西,经过预分频之后这个位置的时钟频率,就是驱动CNT的标准频率fc,这里不难看出,标准频率=72M/预分频系数。然后下面输入捕获通过通道1的GPIO口,输入一个方波信号,经过滤波器和边沿检测,选择TI1FP1为上升沿触发,之后输如选则直连的通道,分频器选择不分频,当TI1FP1出现上升沿之后,CNT的当前计数值转运到CCR1里,同时触发源选择,选中TI1FP1为触发信号。从模式选择复位操作,这样TI1FP1的上升沿,也会通过上面这一路,去触发CNT清零,当然这里会有个先后顺序,肯定得是先转CNT的值到CCR里去,再触发从模式给CNT清零,或者是非阻塞的同时转移。CNT的值转移到CCR,同时0转移到CNT里面去。总之肯定不会是先清零,再捕获,要不然捕获值肯定都是0了。这是这两条路二点执行逻辑。左上角的方波信号,在这里,信号出现一个上升沿,CCR1=CNT,就是把CNT的值转运到CCR1里面去,这是输入捕获自动执行的,然后CNT=0,清零计数器,这是从模式自动执行的,然后在一个周期内,CNT在标准时钟驱动下,不断自增,并且由于之前清零过了,所以CNT就是从上升沿开始,,从0开始计数,一直++,直到下一次上升沿来临。然后执行相同的操作,CCR1 = CNT,CNT = 0。CNT的值是有上限的,ARR一般设置为最大65535,那CNT最大也只能计65535个数,如果信号频率太低,CNT计数值可能会溢出;另外还有就是,从模式的触发源选择,只有TI1FP1和TI2FP2,没有TI3和TI4的信号,所以这里如果想使用从模式自动清零,就只能用通道1和通道2.对于通道3和通道4,就只能开启捕获中断,在中断里手动清零了。不过这样,程序就会处于频繁中断的状态,比较消耗软件资源。
5.8.6 PWMI基本结构
PWMI模式使用了两个通道同时捕获一个引脚,可以同时测量周期和占空比,上面这部分结构,和上面演示的一样,下面多了一个通道,首先TI1FP1配置上升沿触发,触发捕获和清零CNT,正常地捕获周期,这时我们再来一个TI1FP2,配置为下降沿触发,通过交叉通道去触发通道2的捕获单元。这是会发生什么呢?由左上图可知,最开始上升沿,CCR1捕获,同时清零CNT,之后CNT一直++,然后在下降沿这个时刻,触发CCR2捕获,所以这是CCR2的值,就是CNT从上升沿到下降沿的计数值,就是高电平期间的计数值;CCR2捕获,并不触发CNT清零,所以CNT继续++,直到下一次上升沿,CCR1捕获周期,CNT清零,这样执行之后,CCR1就是一整个周期的计数值,CCR2就是高电平期间的计数值,用CCR2/CCR1就是占空比了。另外这里,可以用两个通道同时捕获第一个引脚的输入,这样通道2的前面部分就没有用到了;当然也可以配置两个通道同时捕获第二个引脚的输入,这样我们就是使用TI2FP1和TI2FP2这两个引脚了,这两个输入可以去灵活切换。
5.9 输入捕获模式测频率
5.9.1 硬件电路
PA0产生信号给PA6, 测量PA6的信号频率。如果由信号发生器的话,也可以设置成方波信号输出,高电平3.3V,低电平0V,然后直接接到PA6,当然别忘了共地。
5.9.2 软件部分
(1)复制《PWM驱动LED呼吸灯》工程改为《输入捕获模式测频率》
(2)添加驱动文件
(3)IC库函数
cpp
void TIM_DeInit(TIM_TypeDef* TIMx);
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
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_ICInit(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
/*通过结构体配置输入捕获单元的函数,第一个参数选择定时器,第二个参数是包含各个配置的结构体
输入捕获和输出比较都有4个通道,ICInit4个通道共用一个函数,在结构体里会额外有一个参数,选择
具体配置哪个通道,因为可能有交叉通道的配置,所以函数合在一起比较方便*/
void TIM_PWMIConfig(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
/*输入捕获的初始化函数,和上一个函数类似,都是用于初始化输入捕获单元的,但是上一个函数只是单一的配置
一个通道,这个函数可以快速配置两个通道,把外设电路配置成PPT里展示的PWMI模式*/
void TIM_BDTRConfig(TIM_TypeDef* TIMx, TIM_BDTRInitTypeDef *TIM_BDTRInitStruct);
void TIM_TimeBaseStructInit(TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
void TIM_OCStructInit(TIM_OCInitTypeDef* TIM_OCInitStruct);
void TIM_ICStructInit(TIM_ICInitTypeDef* TIM_ICInitStruct);
/*给输入捕获结构体赋一个初始值*/
void TIM_BDTRStructInit(TIM_BDTRInitTypeDef* TIM_BDTRInitStruct);
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_CtrlPWMOutputs(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_ITConfig(TIM_TypeDef* TIMx, uint16_t TIM_IT, FunctionalState NewState);
void TIM_GenerateEvent(TIM_TypeDef* TIMx, uint16_t TIM_EventSource);
void TIM_DMAConfig(TIM_TypeDef* TIMx, uint16_t TIM_DMABase, uint16_t TIM_DMABurstLength);
void TIM_DMACmd(TIM_TypeDef* TIMx, uint16_t TIM_DMASource, FunctionalState NewState);
void TIM_InternalClockConfig(TIM_TypeDef* TIMx);
void TIM_ITRxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
void TIM_TIxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_TIxExternalCLKSource,
uint16_t TIM_ICPolarity, uint16_t ICFilter);
void TIM_ETRClockMode1Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,
uint16_t ExtTRGFilter);
void TIM_ETRClockMode2Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler,
uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);
void TIM_ETRConfig(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,
uint16_t ExtTRGFilter);
void TIM_PrescalerConfig(TIM_TypeDef* TIMx, uint16_t Prescaler, uint16_t TIM_PSCReloadMode);
void TIM_CounterModeConfig(TIM_TypeDef* TIMx, uint16_t TIM_CounterMode);
void TIM_SelectInputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
/*选择输入触发源TRGI,从模式的触发源选择,调用函数选择从模式的触发源*/
void TIM_EncoderInterfaceConfig(TIM_TypeDef* TIMx, uint16_t TIM_EncoderMode,
uint16_t TIM_IC1Polarity, uint16_t TIM_IC2Polarity);
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);
void TIM_ARRPreloadConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectCOM(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectCCDMA(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_CCPreloadControl(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_OC1PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC2PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC3PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC4PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
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);
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);
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);
void TIM_UpdateDisableConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_UpdateRequestConfig(TIM_TypeDef* TIMx, uint16_t TIM_UpdateSource);
void TIM_SelectHallSensor(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectOnePulseMode(TIM_TypeDef* TIMx, uint16_t TIM_OPMode);
void TIM_SelectOutputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_TRGOSource);
/*选择输出触发源TRGO,选择主模式输出的触发源*/
void TIM_SelectSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_SlaveMode);
/*选择从模式*/
void TIM_SelectMasterSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_MasterSlaveMode);
void TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);
void TIM_SetAutoreload(TIM_TypeDef* TIMx, uint16_t Autoreload);
void TIM_SetCompare1(TIM_TypeDef* TIMx, uint16_t Compare1);
void TIM_SetCompare2(TIM_TypeDef* TIMx, uint16_t Compare2);
void TIM_SetCompare3(TIM_TypeDef* TIMx, uint16_t Compare3);
void TIM_SetCompare4(TIM_TypeDef* TIMx, uint16_t Compare4);
/*读写的CCR寄存器,输出比较模式下,CCR是只写的,要用SetCompare写入*/
void TIM_SetIC1Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC2Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC3Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC4Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
/*上面4个函数分别配置通道1、2、3、4的分频器,这个参数结构体里也可以配置,是一样的效果*/
void TIM_SetClockDivision(TIM_TypeDef* TIMx, uint16_t TIM_CKD);
uint16_t TIM_GetCapture1(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture2(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture3(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture4(TIM_TypeDef* TIMx);
/*上面4个函数分别读取4个通道的CCR值,和上面TIM_SetCompare3是对应的,读写的都是CCR寄存器
输出比较模式下,CCR是只写的,要用SetCompare写入,输入捕获模式下,CCR是只读的,要用GetCapture读出*/
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);
(4)输入捕获配置
(5)PWM.c
cpp
#include "stm32f10x.h" // Device header
/*PWM初始化函数:
第1步:RCC开启时钟,把我们需要的TIM外设和GPIO外设的时钟打开
第2步:配置时基单元,包括前面的时钟源选择和这里的时基单元,都配置好
第3步:配置输出比较单元,包括CCR的值、输出比较模式、极性选择、输出使能这些参数,在库函数里也是用结构体统一配置的
第4步:配置GPIO,把PWM对应的GPIO口,初始化为复用推挽输出的配置,这个PWM和GPIO的对应关系是怎样的?可以参考引脚定义表
第5步:运行控制,启动计数器,这样就能输出PWM了
*/
void PWM_Init(void)
{
/*第1步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2,ENABLE); //开启挂载在APB1上的TIM2时钟
// RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO,ENABLE); //开启挂载在APB2上的AFIO时钟
// GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2,ENABLE); //将TIM2的CH1通道重映射到PA15
// GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable,ENABLE);
// /*PA15上电后默认复用为了调试端口JTDI,所以想让它作为普通GPIO或者复用定时器通道,要先关闭调试端口的复用*/
/*第2步:选择时基单元的时钟源*/
TIM_InternalClockConfig(TIM2); //TIM2的时基单元由内部时钟来驱动
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //指定计数模式为向上计数
TIM_TimeBaseInitStructure.TIM_Period = 100-1; //指定ARR自动重装器的值
TIM_TimeBaseInitStructure.TIM_Prescaler = 720-1; //指定预分频器PCS的值
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStructure);
/*第3步:配置输出比较单元*/
TIM_OCInitTypeDef TIM_OCInitStruct;
/*对于这个结构体变量来说,它现在是一个局部变量,如果不给它的成员赋初始值,它成员的值就是不确定的,可能回导致一些问题,
比如当要把高级定时器当作通用定时器输出PWM时,自然会把TIM2改为TIM1,这个结构体原来不用的成员现在又需要用了,而这些成员又
没有赋值,就会导致高级定时器输出PWM出现一些奇怪的问题*/
TIM_OCStructInit(&TIM_OCInitStruct); //为避免上述提到的问题,给这个结构体赋初始值
TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1; //输出比较模式设置为PWM1
TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High; //极性选择设置为高极性,有效电平是高电平,REF有效时,输出高电平
TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable; //输出使能
TIM_OCInitStruct.TIM_Pulse = 0; //设置CCR的值
TIM_OC1Init(TIM2,&TIM_OCInitStruct);
/*第4步:配置GPIO*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
/*对于普通的开漏/推挽输出,引脚的控制全是来自输出数据寄存器的,如果想用定时器来控制引脚,那就需要使用复用开漏/推挽输出的模式
这里输出数据寄存器将被断开,输出控制全将转移给片上外设,只有把GPIO设置成复用推挽输出,引脚的控制权才能交给片上外设,PWM波形才能
通过引脚输出*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_0);
/*第六步:运行控制,启动定时器*/
TIM_Cmd(TIM2, ENABLE);
}
/*更改占空比*/
void PWM_SetCompare1(uint16_t Compare)
{
TIM_SetCompare1(TIM2,Compare);
}
/*改变PCS的值进而改变频率*/
void PWM_SetPrescaler(uint16_t Prescaler)
{
TIM_PrescalerConfig(TIM2,Prescaler,TIM_PSCReloadMode_Immediate);
}
(6)PWM.h
cpp
#ifndef __PWM_H
#define __PWM_H
void PWM_Init(void);
void PWM_SetCompare1(uint16_t Compare);
void PWM_SetPrescaler(uint16_t Prescaler);
#endif
(7)IC.c
cpp
#include "stm32f10x.h" // Device header
/*初始化函数:
第1步:RCC开启时钟,把GPIO和TIM时钟打开;
第2步:GPIO初始化,把GPIO配置为输入模式,一般选择上拉输入或者浮空输入模式
第3步:配置时基单元,让CNT计数器在内部时钟的驱动下自增运行
第4步:配置输入捕获单元,包括滤波器、极性、直连通道还是交叉通道、分频器这些参数,用结构体就可以统一进行配置了
第5步:选则从模式的触发源,触发源选择为TF1FP1,调用库函数给个参数就可以了
第6步:选择触发之后执行的操作,执行Reset操作,也是调用库函数即可
第7步:调用TIM_Cmd函数,开启定时器
*/
void IC_Init(void)
{
/*第1步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3,ENABLE); //开启挂载在APB1上的TIM3时钟作为输入捕获的定时器,TIM2要输出PWM
/*第2步:GPIO初始化*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; /*上拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_6);
/*第3步:选择时基单元的时钟源*/
TIM_InternalClockConfig(TIM3); //TIM2的时基单元由内部时钟来驱动
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //指定计数模式为向上计数
TIM_TimeBaseInitStructure.TIM_Period = 65536-1; //指定ARR自动重装器的值,设置大一些,防止计数溢出
TIM_TimeBaseInitStructure.TIM_Prescaler = 72-1; //指定预分频器PCS的值
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM3,&TIM_TimeBaseInitStructure);
/*第4步:配置输入捕获单元*/
TIM_ICInitTypeDef TIM_ICInitStruct;
TIM_ICInitStruct.TIM_Channel = TIM_Channel_1; //选择TIM3的通道1
TIM_ICInitStruct.TIM_ICFilter = 0xF; //选择输入捕获的滤波器,滤除高频噪声,使信号更平缓
TIM_ICInitStruct.TIM_ICPolarity = TIM_ICPolarity_Rising; //设置边沿检测,极性选择为上升沿触发
TIM_ICInitStruct.TIM_ICPrescaler = TIM_ICPSC_DIV1; //配置分频器为不分频,我们现需要每次触发都有效
TIM_ICInitStruct.TIM_ICSelection = TIM_ICSelection_DirectTI; //配置数据选择器为直连通道
TIM_ICInit(TIM3,&TIM_ICInitStruct);
/*第5步:选则从模式的触发源*/
TIM_SelectInputTrigger(TIM3,TIM_TS_TI1FP1);
/*第6步:配置从模式为Reset*/
TIM_SelectSlaveMode(TIM3,TIM_SlaveMode_Reset);
/*第7步:启动定时器*/
TIM_Cmd(TIM3,ENABLE);
}
uint32_t IC_GetFreq(void)
{
return 1000000/(TIM_GetCapture1(TIM3)+1); //返回一下频率,fx = fc/N,这里fc已经设置为1MHz,N是计数器的值,这里(N+1)是为了消除正负1误差
}
(8)IC.h
cpp
#ifndef __IC_H
#define __IC_H
void IC_Init(void);
uint32_t IC_GetFreq(void);
#endif
(9)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "PWM.h"
#include "IC.h"
uint8_t i;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
PWM_Init();
IC_Init();
OLED_ShowString(1,1,"Freq:00000Hz");
PWM_SetPrescaler(720-1); //Freq = 72M/(PSC+1)/(ARR+1),目前ARR固定为100
PWM_SetCompare1(50); //Duty = CCR/ARR
while(1)
{
OLED_ShowNum(1,6,IC_GetFreq(),5);
}
}
5.10 PWMI测频率占空比
5.10.1 硬件电路
5.10.2 软件部分
(1)复制《输入捕获模式测频率》工程改名为《PWMI测频率占空比》
(2)IC.c
cpp
#include "stm32f10x.h" // Device header
/*初始化函数:
第1步:RCC开启时钟,把GPIO和TIM时钟打开;
第2步:GPIO初始化,把GPIO配置为输入模式,一般选择上拉输入或者浮空输入模式
第3步:配置时基单元,让CNT计数器在内部时钟的驱动下自增运行
第4步:配置输入捕获单元,包括滤波器、极性、直连通道还是交叉通道、分频器这些参数,用结构体就可以统一进行配置了
第5步:选则从模式的触发源,触发源选择为TF1FP1,调用库函数给个参数就可以了
第6步:选择触发之后执行的操作,执行Reset操作,也是调用库函数即可
第7步:调用TIM_Cmd函数,开启定时器
*/
void IC_Init(void)
{
/*第1步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3,ENABLE); //开启挂载在APB1上的TIM3时钟作为输入捕获的定时器,TIM2要输出PWM
/*第2步:GPIO初始化*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; /*上拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_6);
/*第3步:选择时基单元的时钟源*/
TIM_InternalClockConfig(TIM3); //TIM2的时基单元由内部时钟来驱动
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //指定计数模式为向上计数
TIM_TimeBaseInitStructure.TIM_Period = 65536-1; //指定ARR自动重装器的值,设置大一些,防止计数溢出
TIM_TimeBaseInitStructure.TIM_Prescaler = 72-1; //指定预分频器PCS的值
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM3,&TIM_TimeBaseInitStructure);
/*第4步:配置输入捕获单元*/
TIM_ICInitTypeDef TIM_ICInitStruct;
TIM_ICInitStruct.TIM_Channel = TIM_Channel_1; //选择TIM3的通道1
TIM_ICInitStruct.TIM_ICFilter = 0xF; //选择输入捕获的滤波器,滤除高频噪声,使信号更平缓
TIM_ICInitStruct.TIM_ICPolarity = TIM_ICPolarity_Rising; //设置边沿检测,极性选择为上升沿触发
TIM_ICInitStruct.TIM_ICPrescaler = TIM_ICPSC_DIV1; //配置分频器为不分频,我们现需要每次触发都有效
TIM_ICInitStruct.TIM_ICSelection = TIM_ICSelection_DirectTI; //配置数据选择器为直连通道
TIM_ICInit(TIM3,&TIM_ICInitStruct);
/*
TIM_ICInitStruct.TIM_Channel = TIM_Channel_2; //选择TIM3的通道2
TIM_ICInitStruct.TIM_ICFilter = 0xF; //选择输入捕获的滤波器,滤除高频噪声,使信号更平缓
TIM_ICInitStruct.TIM_ICPolarity = TIM_ICPolarity_Falling; //设置边沿检测,极性选择为下降给沿触发
TIM_ICInitStruct.TIM_ICPrescaler = TIM_ICPSC_DIV1; //配置分频器为不分频,我们现需要每次触发都有效
TIM_ICInitStruct.TIM_ICSelection = TIM_ICSelection_IndirectTI; //配置数据选择器为交叉通道
TIM_ICInit(TIM3,&TIM_ICInitStruct);
*/
TIM_PWMIConfig(TIM3,&TIM_ICInitStruct);
//这一个函数可以实现上面注释部分的功能,自动把另一个通道初始化为相反的配置,这个函数支支持通道1和通道2的配置,不要传入通道3和通道4
/*第5步:选则从模式的触发源*/
TIM_SelectInputTrigger(TIM3,TIM_TS_TI1FP1);
/*第6步:配置从模式为Reset*/
TIM_SelectSlaveMode(TIM3,TIM_SlaveMode_Reset);
/*第7步:启动定时器*/
TIM_Cmd(TIM3,ENABLE);
}
/*获取频率的函数*/
uint32_t IC_GetFreq(void)
{
return 1000000/(TIM_GetCapture1(TIM3)+1); //返回一下频率,fx = fc/N,这里fc已经设置为1MHz,N是计数器的值,这里(N+1)是为了消除正负1误差
}
/*获取占空比的函数*/
uint32_t IC_GetDuty(void)
{
return (TIM_GetCapture2(TIM3)+1)*100/(TIM_GetCapture1(TIM3)+1); //扩大100倍按整数显示,这个CCR总会少1个,加1补回来。
}
(3)IC.h
cpp
#ifndef __IC_H
#define __IC_H
void IC_Init(void);
uint32_t IC_GetFreq(void);
uint32_t IC_GetDuty(void);
#endif
(4)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "PWM.h"
#include "IC.h"
uint8_t i;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
PWM_Init();
IC_Init();
OLED_ShowString(1,1,"Freq:00000Hz");
OLED_ShowString(2,1,"Duty:00%");
PWM_SetPrescaler(720-1); //Freq = 72M/(PSC+1)/(ARR+1),目前ARR固定为100
PWM_SetCompare1(80); //Duty = CCR/ARR
while(1)
{
OLED_ShowNum(1,6,IC_GetFreq(),5);
OLED_ShowNum(2,6,IC_GetDuty(),2);
}
}
5.11 TIM编码器接口
本节实现的功能和之前写的旋转编码器计次的功能基本都是一样的。本节代码本质上也是旋转编码器计次,只不过这个代码是通过定时器的编码器接口,来自动计次,而之前的代码是通过触发外部中断,在中断函数里手动进行计次,使用编码器接口的好处就是节约软件资源;如果使用外部中断来计次,那当电机高速旋转时,编码器每秒产生成千上万个脉冲,程序就得频繁进入中断,然后进中断的之后,完成的任务又只是简单的加一减一,软件资源就被这种简单又低级的工作给占用了,所以对于这种需要频繁执行,操作又比较简单的任务,一般都会设计一个硬件电路模块,来自动完成。本节这个编码器接口,就是用来自动给编码器计次的电路,如果每隔一段时间取一次计数值,就可以得到编码器旋转的速度了。
编码器测速一般用于电机控制的项目上,时使用PWM驱动电机,再使用编码器测量电机的速度,然后使用PID算法进行闭环控制,是一个比较常见的使用场景,一般电机旋转速度比较高,会使用无接触式的霍尔传感器或者光栅进行测速。
5.11.1 编码器接口简介
Encoder Interface 编码器接口。
编码器接口可接收增量(正交)编码器的信号,根据编码器旋转产生的正交信号脉冲,自动控制CNT自增或自减,从而指示编码器的位置、旋转方向和旋转速度。
这个编码器接口其实就是相当于一个带有方向控制的外部时钟,它同时控制值CNT的计数时钟和计数方向,这样的话,CNT的值就表示了编码器的位置,如果我们每隔一段时间取一次CNT的值,再把CNT清零,那就是每次取出来的值就表示编码器的速度。这个编码器测速实际上就是测频法测正交脉冲的频率,CNT计次,然后每隔一段时间取一次计次,就是测频法的思路。只不过这个编码器计次更高级,它能根据旋转方向,不仅能自增计次,还能自减计次,是一个带方向的计次。
每个高级定时器和通用定时器都拥有1个编码器接口。
这个编码器的接口还是比较紧张的,如果一个定时器配置成了编码器接口模式,那它基本上就干不了其他活了。我们的STM32F103C8T6只有TIM1、2、3、4这四个定时器,所以最多只能接4个编码器,而且接完4个编码器,就没有定时器可用了。如果编码器比较多的话,需要考虑一下资源够不够用,不过实在不行的话,还是可以用外部中断来接编码器的,这样就是用软件资源来弥补硬件资源了。所以这里可以看出,硬件资源和软件资源是互补的,硬件资源越多,软件就会越轻松,硬件不够,那就软件来凑,比如PWM可以直接来个定时中断,然后在中断里手动计数,手动翻转电平,比如输入捕获,可以来个外部中断,然后在中断里手动把CNT取出来,放在变量里。比如编码器接口,也可以拿外部中断,然后在外部中断里,手动自增或自减计数,这都可以实现功能,什么输出比较、输入捕获、编码器接口都不需要,但是这样就是消耗软件资源了,所以一般有硬件资源的情况下,可以优先使用硬件资源,这样节约下来的软件资源,可以去干更重要的事情。
两个输入引脚借用了输入捕获的通道1和通道2。
编码器的两个输入引脚就是每个定时器的CH1和CH2引脚,CH3和CH4不能接编码器。
5.11.2 正交编码器
正交编码器一般可以测量位置,或者带有方向的速度值,它一般有两个信号输出引脚,一个是A相、一个是B相。当编码器的旋转轴转起来时,就会输出下图所示的方波信号,转的越快,方波的频率就越高,所以方波的频率就代表了速度,我们取任意一相的信号来测频率,就能直到旋转速度了。但是只有一相的信号,无法测量旋转方向,因为无论正转还是反转,它都是这样的方波。想要测量方向,还必须要有另一根线的辅助。比如可以不用这个B相,在定义一个方向输出引脚,正转置高电平,反转置低电平,这是一种解决方案,但是这样的信号并不是正交信号。另一种解决方案就是我们所说的正交信号,当正转时,A相提前B相90度;反转时A相滞后B相90度;当然这个正转1是A相提前还是A相滞后,并不是绝对的,这只是一个极性问题。毕竟正转和反转的定义也是相对的。总之就是朝一个方向转是A相提前,另一个方向是A相滞后。那使用正交信号相比较单独定义一个方向引脚的好处就是:
(1)正交信号精度更高,因为A、B相都可以计次,相当于计次频率提高了一倍;
(2)其次就是正交信号可以抗噪声,因为正交信号,两个信号必须是交替跳变的,所以可以设计一个抗噪声电路,若果一个信号不变,另一个信号连选跳变,也就是产生了噪声,那这时计次值是不会变化的。
编码器接口的设计逻辑就是,首先把A相和b相的所有边沿作为计数器的计数时钟, 出现边沿信号时,就计数自增或自减,然后到底是增还是减呢?这个计数的方向由另一相的状态来确定,当出现某个边沿时,我们判断另一相的高低电平,如果对应另一相的高低状态出现在上面表里,那就是正转,计数自增;反之,另一相的状态出现在下表,那就是反转计数自减。
5.11.3 编码器接口基本情况
每个定时器只有一个编码器接口,基本定时器是没有编码器接口的。
编码器接口有两个输入端,分别要接到编码器的A相和B相, 编码器接口的两个引脚借用了输入捕获单元的前两个通道,所以最终编码器的输入引脚,就是定时器的CH1和CH2这两个引脚.其中CH1和CH2的输入捕获滤波器和边沿检测,编码器接口也有使用,但是后面的是否交叉,预分频器和CCR寄存器,与编码器接口无关。编码器接口的输出部分,其实就相当于从模式控制器了,去控制CNT的计数时钟和计数方向。在这里,我们之前一直在使用的72MHz内部时钟,和我们在时基单元初始化时设置的计数方向,并不会使用,因为此时计数时钟和计数方向都处于编码器接口托管的状态,计数器的自增和自减,受编码器控制。
输入捕获的前两个通道,通过GPIO接口编码器的A、B相, 然后通过滤波器和边沿检测极性选择,产生TI1FP1和TI2FP2,通向编码器接口,编码器接口通过预分频器控制CNT计数器的时钟,同时,编码器接口还根据编码器的旋转方向,控制CNT的计数方向。编码器正转时,CNT自增,编码器反转时,CNT自减,这里ARR也是有效的,一般我们会设置ARR为65535,最大量程。这样的话,利用补码的特性,很容易得到负数。比如CNT初始为0,正转,CNT自增,0、1、2、3、4、5、6、7等等;反转的时候,CNT自减,0下一个数就是65535、然后是65534...但是没关系,会进行一个操作,直接把这个16位的无符号数转换为16位的有符号数,根据补码的定义,这个65535就对应-1,65534就对应-2等等。
5.11.4 工作模式
上图就是编码器接口的工作逻辑,这里TI1FP1和TI2FP2接的就是编码器的A相和B相。
5.11.5 实例(均不反相)
毛刺处就是正交编码器抗噪声的原理。
5.11.6 实例(TI1反相)
TI1反相后才是实际给编码器接口的电平。
5.12 编码器接口测速
5.12.1 硬件部分
5.12.2软件部分
(1)复制《定时器定时中断》工程改名为《编码器接口测速》
(2)添加编码器驱动文件
(3)编码器所需要的库函数
cpp
void TIM_DeInit(TIM_TypeDef* TIMx);
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
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_ICInit(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_PWMIConfig(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_BDTRConfig(TIM_TypeDef* TIMx, TIM_BDTRInitTypeDef *TIM_BDTRInitStruct);
void TIM_TimeBaseStructInit(TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
void TIM_OCStructInit(TIM_OCInitTypeDef* TIM_OCInitStruct);
void TIM_ICStructInit(TIM_ICInitTypeDef* TIM_ICInitStruct);
void TIM_BDTRStructInit(TIM_BDTRInitTypeDef* TIM_BDTRInitStruct);
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_CtrlPWMOutputs(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_ITConfig(TIM_TypeDef* TIMx, uint16_t TIM_IT, FunctionalState NewState);
void TIM_GenerateEvent(TIM_TypeDef* TIMx, uint16_t TIM_EventSource);
void TIM_DMAConfig(TIM_TypeDef* TIMx, uint16_t TIM_DMABase, uint16_t TIM_DMABurstLength);
void TIM_DMACmd(TIM_TypeDef* TIMx, uint16_t TIM_DMASource, FunctionalState NewState);
void TIM_InternalClockConfig(TIM_TypeDef* TIMx);
void TIM_ITRxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
void TIM_TIxExternalClockConfig(TIM_TypeDef* TIMx, uint16_t TIM_TIxExternalCLKSource,
uint16_t TIM_ICPolarity, uint16_t ICFilter);
void TIM_ETRClockMode1Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,
uint16_t ExtTRGFilter);
void TIM_ETRClockMode2Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler,
uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter);
void TIM_ETRConfig(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity,
uint16_t ExtTRGFilter);
void TIM_PrescalerConfig(TIM_TypeDef* TIMx, uint16_t Prescaler, uint16_t TIM_PSCReloadMode);
void TIM_CounterModeConfig(TIM_TypeDef* TIMx, uint16_t TIM_CounterMode);
void TIM_SelectInputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_InputTriggerSource);
void TIM_EncoderInterfaceConfig(TIM_TypeDef* TIMx, uint16_t TIM_EncoderMode,
uint16_t TIM_IC1Polarity, uint16_t TIM_IC2Polarity);
/*第一个参数选择定时器,第二个参数选择编码器模式,然后后面两个参数分别选择通道1和通道2的电平极性*/
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);
void TIM_ARRPreloadConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectCOM(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectCCDMA(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_CCPreloadControl(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_OC1PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC2PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC3PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
void TIM_OC4PreloadConfig(TIM_TypeDef* TIMx, uint16_t TIM_OCPreload);
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);
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);
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);
void TIM_UpdateDisableConfig(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_UpdateRequestConfig(TIM_TypeDef* TIMx, uint16_t TIM_UpdateSource);
void TIM_SelectHallSensor(TIM_TypeDef* TIMx, FunctionalState NewState);
void TIM_SelectOnePulseMode(TIM_TypeDef* TIMx, uint16_t TIM_OPMode);
void TIM_SelectOutputTrigger(TIM_TypeDef* TIMx, uint16_t TIM_TRGOSource);
void TIM_SelectSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_SlaveMode);
void TIM_SelectMasterSlaveMode(TIM_TypeDef* TIMx, uint16_t TIM_MasterSlaveMode);
void TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);
void TIM_SetAutoreload(TIM_TypeDef* TIMx, uint16_t Autoreload);
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);
void TIM_SetIC1Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC2Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC3Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetIC4Prescaler(TIM_TypeDef* TIMx, uint16_t TIM_ICPSC);
void TIM_SetClockDivision(TIM_TypeDef* TIMx, uint16_t TIM_CKD);
uint16_t TIM_GetCapture1(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture2(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture3(TIM_TypeDef* TIMx);
uint16_t TIM_GetCapture4(TIM_TypeDef* TIMx);
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);
(4)Encoder.c
cpp
#include "stm32f10x.h" // Device header
/*编码器初始化函数*/
/*
第1步:RCC开启时钟,开启GPIO和定时器的时钟
第2步:配置GPIO,这里需要把PA6和PA7配置成输入模式
第3步:配置时基单元,这里预分频器我们一般选择不分频,自动重装,一般给最大65535,只需要CNT执行计数就行了
第4步:配置输入捕获单元,这里输入捕获单元只有滤波器和极性这两个参数有用
第5步:配置编码器接口模式
第6步:调用TIM_Cmd,启动定时器
电路初始化完成之后,CNT就会随着编码器旋转而自增自减,如果想测量编码器的值,直接读出CNT的值就行了;
如果想测量编码器的速度和方向,那就需要每隔一段固定的闸门时间,取出一次CNT,然后再把CNT清零。
*/
void Encoder_Init(void)
{
/*第1步:RCC开启时钟*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3,ENABLE); //开启挂载在APB1上的TIM3时钟作为输入捕获的定时器
/*第2步:GPIO初始化*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;
/*上拉输入(可以选择上拉、下拉或者浮空:根据接在这个引脚的外部模块输出的默认电平,如果外部模块空闲默认输出高电平,就选择上拉输入,
默认输入高电平,如果外部模块空闲默认输出低电平,配置下拉输入,默认输入低电平,和外部模块保持默认状态一致,防止默认电平打架;
如果不确定外部模块输出的默认状态或者外部信号输出功率非常小,这时就尽量选择浮空输入,没有上拉电阻和下拉电阻去影响外部信号,缺点就是
当引脚悬空时,没有默认的电平了,输入就会受噪声干扰,来回不断地跳变)*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_6 | GPIO_Pin_7);
/*第3步:选择时基单元的时钟源*/
//TIM_InternalClockConfig(TIM3); //不需要了,编码器接口会托管时钟,编码器接口就是一个带方向的外部时钟,所以这个内部时钟就没有用了
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; // 指定时钟划分为1分频
TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; //这个参数也没有作用了,因为计数方向也是被编码器接口托管了
TIM_TimeBaseInitStructure.TIM_Period = 65536-1; //指定ARR自动重装器的值,设置大一些,计数范围最大,而且方便换算为负数
TIM_TimeBaseInitStructure.TIM_Prescaler = 1-1; //这里不分频,编码器时钟直接驱动计数器
/*配置上面两个参数使得定时1s,也就是定时频率为1Hz,然后它们的取值都在0~65535之间*/
TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0; //指定重复计数器的值,这个是高级定时器才有的,这里不需要用,直接给0
TIM_TimeBaseInit(TIM3,&TIM_TimeBaseInitStructure);
/*第4步:配置输入捕获单元*/
TIM_ICInitTypeDef TIM_ICInitStruct;
TIM_ICStructInit(&TIM_ICInitStruct); //因为有两个参数用不到,所以先默认初始化一下
TIM_ICInitStruct.TIM_Channel = TIM_Channel_1; //选择TIM3的通道1
TIM_ICInitStruct.TIM_ICFilter = 0xF; //选择输入捕获的滤波器,滤除高频噪声,使信号更平缓
// TIM_ICInitStruct.TIM_ICPolarity = TIM_ICPolarity_Rising;
// /*这里的上升沿参数代表的是高电平极性不反转
// 和下面TIM_EncoderInterfaceConfig配置的是同一个寄存器,所以可以删除*/
// TIM_ICInitStruct.TIM_ICPrescaler = TIM_ICPSC_DIV1; //这个参数与编码器无关
// TIM_ICInitStruct.TIM_ICSelection = TIM_ICSelection_DirectTI; //这个参数与编码器无关
TIM_ICInit(TIM3,&TIM_ICInitStruct);
TIM_ICInitStruct.TIM_Channel = TIM_Channel_2; //选择TIM3的通道2
TIM_ICInitStruct.TIM_ICFilter = 0xF; //选择输入捕获的滤波器,滤除高频噪声,使信号更平缓
// TIM_ICInitStruct.TIM_ICPolarity = TIM_ICPolarity_Rising;
// /*这里的上升沿参数代表的是高电平极性不反转
// 和下面TIM_EncoderInterfaceConfig配置的是同一个寄存器,所以可以删除*/
TIM_ICInit(TIM3,&TIM_ICInitStruct);
/*第5步:配置编码器接口模式*/
TIM_EncoderInterfaceConfig(TIM3,TIM_EncoderMode_TI12,TIM_ICPolarity_Rising,TIM_ICPolarity_Rising);
/*第二个参数使用TI1和TI2都计数,第3和第4个参数配置通道不反相,如果极性与自己想要的不一致,更改第3或第4个参数其中一个,
比如将第3个改为TIM_ICPolarity_Falling*/
/*第6步:启动定时器*/
TIM_Cmd(TIM3,ENABLE);
}
///*获取计数器的值*/
//int16_t Encoder_Get(void)
//{
// return (TIM_GetCounter(TIM3));
//}
/*测速:使用闸门时间测速*/
int16_t Encoder_Get(void)
{
int16_t Temp;
Temp = TIM_GetCounter(TIM3);
TIM_SetCounter(TIM3,0); //给CNT清零
return Temp;
}
(5)Encoder.h
cpp
#ifndef __ENCODER_H
#define __ENCODER_H
void Encoder_Init(void);
int16_t Encoder_Get(void);
#endif
(6)mian.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Timer.h"
#include "Encoder.h"
int16_t Speed;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Timer_Init(); // 初始化定时器
Encoder_Init();
OLED_ShowString(1,1,"Speed:"); // 在1行3列显示字符串
while(1)
{
OLED_ShowSignedNum(1,7,Speed,5);
// Delay_ms(1000); //闸门时间,不建议这么做,会堵塞程序,因此使用中断去做
}
}
/*TIM2的中断函数*/
void TIM2_IRQHandler(void)
{
if(TIM_GetITStatus(TIM2,TIM_IT_Update) == SET) //判断一下中断标志位状态
{
Speed = Encoder_Get(); //每隔1s读取一下速度
TIM_ClearITPendingBit(TIM2,TIM_IT_Update); //清除标志位
}
}
第06章 ADC模数转换器
6.1 ADC模数转换器
STM32的ADC是12位的,所以AD结果最大值是4095,也就是2^12-1。对于GPIO来说,它只能读取引脚的高低电平,要么是高电平,要么是低电平,只有两个值。而使用了ADC之后,我们就可以对这个高电平和低电平之间的任意电压进行量化,最终用一个变量来表示,读取这个变量,就可以知道引脚的具体电压是多少了。
6.1.1 ADC简介
ADC(Analog-Digital Converter)模拟-数字转换器;
ADC可以将引脚上连续变化的模拟电压转换为内存中存储的数字变量,建立模拟电路到数字电路的桥梁;
反过来DAC就是就数模转换器。
12位逐次逼近型ADC,1us转换时间;
从AD转换开始到产生结果,需要花1us的时间,对应AD转换的频率就是1MHz
输入电压范围:0~3.3V,转换结果范围:0~4095;
ADC的输入电压,一般都是要求在芯片供电的负极和正极之间变化的,最低电压就是负极0V,最高电压是正极3.3V,经过ADC转换之后,最小值就是0,最大值是4095,0V对应0,3.3V对应4095,中间都是一一对应的线性关系。
18个输入通道,可测量16个外部和2个内部信号源;
外部信号源就是16个GPIO口,在引脚上直接模拟信号就行了,不需要任何额外的电路,引脚就能直接测电压;2个内部信号源是内部温度传感器和内部参考电压,温度传感器可以测量CPU的温度,比如电脑可以显示一个CPU温度,就可以用ADC读取这个温度传感器来测量。内部参考电压是一个1.2V左右的基准电压,这个基准电压是不随外部供电电压变化而变化的,所以如果芯片的供电不是标准的3.3V,那测量外部引脚的电压可能就不对。这时就可以读取这个基准电压进行校准,这样就能得到正确的电压值了。
规则组和注入组两个转换单元;
这个就是STM32ADC的增强功能了,普通的AD转换流程是,启动一次转换,读一次值,然后再启动,再读值这样的流程,但是STM32的ADC就比较高级,可以列一个组,一次性启动一个组,连续转换多个值;并且有两个组,一个是用于常规使用的规则组,一个是用于突发事件的注入组。
模拟看门狗自动监测输入电压范围;
这个ADC一般可以用于测量光线强度、温度这些值,并且经常会有个需求,就是如果光线高于某个阈值、低于某个阈值;或者温度高于某个阈值、低于某个阈值时,执行一些操作。这个高于某个阈值、低于某个阈值的判断,,就可以用模拟看门狗来自动执行。模拟看门狗可以监测指定的某些通道,当AD值高于它设定的上阈值或者低于下阈值时,它就会申请中断,就可以在中断函数里执行相应的操作,这样就不用不断地手动读值,再用if进行判断了。
STM32F103C8T6 ADC资源:ADC1、ADC2,10个外部输入通道。
也就是最多只能测量10个外设引脚的模拟信号。上面所说的16个外部信号源,这是这个系列最多有16个外部信号源。但是我们这个系列引脚比较少,有很多引脚没有引出来,,所以只有10个外部信号源。如果想要更多的外部通道,可以选择引脚更多的型号,具体有多少个通道,就要参考数据手册了。
6.1.2 逐次逼近型ADC
这个图是ADC0809的内部结构图, 它是一个独立的8位逐次逼近型ADC芯片,在以前的时候,单片机的性能还不是很强,所以需要外挂一个ADC芯片才能进行AD转换,这个ADC0809就是一款比较经典的ADC芯片,现在单片机的性能和集成度都有很大的提升。很多单片机内部就已经继承了ADC外设,这样就不用外挂芯片了,引脚可以直接测电压。
这个结构左边是IN0~IN7,是8路输入通道,通过通道选择开关,选择一路,输出后进行转换。下面是地址锁存器和译码,就是想选择哪一路,就把通道号放在这三个引脚上,然后给一个锁存信号,上面对应的通路开关就可以自动拨好了。上面部分就相当于一个可以通过模拟信号的数据选择器。因为ADC转换是一个很快的过程,给一个开始信号,过一个几us就转换完成了,所以说如果想转换多路信号,不必设计多个AD转换器,只需要一个AD转换器,然后加一个多路选择开关,想转换哪一路,先拨一下开关,选中对应通道,然后再开始转换就行了。这就是输入通道选则的部分。这个ADC0809只有8个输入通道,STM32内部有18个输入通道,所以对应这里就是一个18路输入的多路开关。输入通道选好后,怎么知道对应的数据是多少呢,就需要用逐次逼近的方法来一一比较了。首先是一个电压比较器,它可以判断两个输入信号电压的大小关系,输出一个高电平只是谁大谁小,它的两个输入端,一个是待测的电压,另一个是DAC的电压输出端,DAC是数模转换器,给它一个数据,它就可以输出数据对应的电压,DAC内部是使用加权电阻网络来实现的转换。有了一个外部通道输入的,未知编码的电压,和一个DAC输出的,已知编码的电压,它两1同时输入到电压比较器,进行大小判断,如果DAC输出的电压比较大,就调小DAC输出的数据,如果DAC输出的数据比较小,就增大DAC数据。直到DAC输出的电压和外部通道输入的电压近似相等。这样DAC输入的数据就是外部电压的编码数据了,这就是DAC的实现原理。这个电压调节的过程就是这个逐次逼近SAR来完成的。为了最快找到未知电压的编码,通常我们会使用二分法来进行寻找。比如这里是8位的ADC,那编码就是从0~255,第一次比较的时候,我们就给DAC输入255的一半进行比较,那就是128,然后看谁大谁小,如果DAC电压大了,那第二次比较的时候吗,再就给128的一半,64,如果还大,第三次比较的时候就给32,如果这次DAC电压小了,那就给32到64中间的值,依次进行下去,就能最快找到未知电压的编码。并且这个过程,如果用二进制来表示的话,就会发现128、64、32这些数据,正好是二进制每一位的位权,这个判断过程就相当于是,对二进制高位到低位依次判断是1还是0的过程。这就是逐次逼近型名字的来源。那对于8位的ADC,从高位到地位依次判断8次就能找到未知电压的编码了。对于12位ADC,就需要依次判断12次,这就是逐次逼近的过程。然后,ADC转换结束后,DAC的输入数据,就是未知电压的编码,往右走进行输出,8位就有8根线,12位就有12根线。EOC是End of Convert,转换结束信号,START是开始转换,给一个输入脉冲,开始转换,CLOCK是ADC时钟,因为ADC内部是一步一步进行判断的,所以需要时钟来推动这个过程。下面VREF+和VREF-是DAC的参考电压,比如给一个数据255,对应5V还是3.3V,就由这个参考电压决定。这个DAC的参考电压也决定了ADC的输入范围,所以它也是ADC参考电压。最后左边是整个芯片的供电,VCC和GND,通常参考电压的正极和VCC是一样的,会接在一起,参考电压的负极和GND也是一样的,也接在一起。所以一般情况下,ADC输入电压的范围和ADC的供电是一样的。
6.1.3 STM32ADC框图
左边是ADC的输入通道,包括16个GPIO口,IN0~IN15 ,和两个内部二点通道,一个是内部温度传感器,另一个是VREFINT(V Reference Internal),内部参考电压,总共是18个输入通道,然后到达模拟多路开关,可以指定我们想要选则的通道,右边是多路开关的输出,进入到模数转换器。这里模数转换器就是执行刚刚上面讲的逐次比较的过程。转换结果会直接放在数据寄存器里,我们读取寄存器就能直到ADC转换的结果了。对于普通的ADC,多路开关一般是只选中1个的,就是选中某个通道、开始转换、等待转换完成、取出结果、这是普通的流程。但是STM32上就比较高级了,它可以同时选中多个,而且在转换的时候,还分成了两个组,规则通道组和注入通道组;其中规则组可以一次性最多选择中16个通道,注入组最多可以选中4个通道。这有什么作用呢?举个例子:就像是去餐厅点菜、普通的ADC是,指定一个菜,老板做完后送给自己,这里就是,指定一个菜单,这个菜单最多可以填16个菜,老板按照菜单的顺序依次做好,一次性端上来,这样就大大提高效率,当然,菜单也可以只写一个菜,就简化成普通的模式了。对于这个菜单也有两种,1种是规则组,可以同时上16个菜,但是有个尴尬的地方,就是规则组只有一个数据寄存器,就是这个桌子比较小,只能放一个菜,如果上16个菜,前15个菜就会被挤掉。所以对于规则组来说,如果使用这个菜单的话,最好配合DMA来实现。DMA是一个数据转运小帮手,它可以在每上一个菜之后,把这个菜挪到其它地方去,防止被覆盖。规则组虽然可以同时转换16个通道,但是数据寄存器只能存一个结果,如果不想之前的结果被覆盖,那就转换完成之后,要尽快把结果拿走。注入组,这个组就比较高级,相当于是餐厅的VIP座位,在这个座位上,一次性最多可以点4个菜,并且数据寄存器有4个,是可以同时上4个菜的,对于注入组而言,就不用担心数据覆盖的问题了。这就是规则组和注入组的介绍。一般情况下,使用规则组就完全足够了,如果要使用规则组的菜单,那就再配合DMA转运数据。
模数转换器外围电路:左下角是触发转换的部分,也就是ADC0809的START。对于STM32的ADC,触发ADC开始转换的信号有两种,一种是软件触发,就是再程序中手动调用一条代码,就可以启动转换了;另一种是硬件触发,就是左下角的触发源。上面是注入组的触发源,下面是规则组的触发源,这些触发源主要是来自定时器,有定时器的各个通道,还有TRGO定时器主模式的输出。之前讲定时器的时候也介绍过,定时器可以通过ADC、DAC这些外设,用于触发转换。那因为ADC经常要过一个固定时间段转换一次,比如每隔1ms转换一次,正常的思路就是,用定时器,每隔1ms申请1次中断,在中断里手动开始一次转换,这样也是可以的。但是频繁进中断对程序是有一定影响的,比如有很多中断都需要频繁进入,肯定会影响主程序的执行,并且不同中断之间,由于优先级的不同,也会导致某些中断不能及时得到响应,如果触发ADC的中断不能及时响应,那我们ADC的转换频率就肯定会产生影响了,所以对于这种需要频繁进中断,并且在中断里只完成了简单工作的情况,一般都会有硬件的支持。比如这里就可以给TIM3定一个1ms的时间,并且把TIM3的更新事件选择位TRGO输出,然后在ADC这里,选择开始触发信号为TIM3的TRGO,这样TIM3的更新事件就能通过硬件自动触发ADC转换了,整个过程不需要进中断,节省了中断资源。这就是这里定时器触发的作用。当然这里还可以选择外部中断引脚来触发转换,都可以在程序中配置,这就是触发转换的部分。
左上角是VREF+、VREF-、VDDA和VSSA,上面两个是ADC的参考电压,决定了ADC输入电压的范围,下面两个是ADC的供电引脚,一般情况下,VREF+要接VDDA、VREF-要接VSSA,在这款芯片上,没有VREF+和VREF-的引脚,它在内部就已经和VDDA和VSSA接在一起了。VDDA和VSSA在引脚定义里就可以看到。VDDA和VSSA是内部模拟部分的电源,比如ADC、RC振荡器、锁相环等等,在这里VDDA接3.3V、VSSA接GND、所以ADC的输入电压范围就是0~3.3V。
右边ADCCLK是ADC的时钟,也就是ADC0809上的CLOCK,是用于驱动内部逐次比较的时钟。这个是来自ADC预分频器,这个ADC预分频器是来源于RCC的。如下图(RCC时钟树框图)所示:APB272MHz通过预分频器进行分频,得到ADCCLK,ADCCLK最大是14MHz,所以这个预分频器就有点尴尬,它可以选择2、4、6、8分频,如果选择2分频,72M/2=36M,超出允许范围了;4分频之后是18M,也超了;所以对于ADC预分频器,只能选择6分频,结果是12M和8分频、结果是9M,这两个值。
DMA请求是用于触发DMA进行数据转运的。
两个数据寄存器是用于存放转换结果的,上面还有一个模拟看门狗,里面可以存一个阈值高限和阈值低限,如果启动了模拟看门狗,并指定了看门的通道,那这个看门狗就会关注它看门的通道、一旦超过这个阈值范围了,它就会乱叫,就会在上面申请一个模拟看门狗的中断,最后通向NVIC。然后对于规则组和注入组而言,它们转换完成后,也会有一个EOC转换完成的信号,在这里EOC是规则组的完成信号,JEOC是注入组完成的信号,这两个信号会在状态寄存器里置一个标志位,读取这个标志位,就能知道是不是转换结束了,同时这两个标志位也可以去到NVIC,申请中断,如果开启了NVIC对应的通道,它们就会触发中断。
6.1.4 ADC基本结构图
左边是输入通道,16个GPIO口、外加两个内部的通道;然后进入AD转换器、AD转换器里有两个组、一个是规则组、一个是注入组。规则组最多可以选中16个通道、注入组最多可以选择4个通道,然后转换的结果可以存放在AD数据寄存器里,其中规则组只有一个数据寄存器,注入组有4个,下面有触发控制、提供了开始转换这个START信号,触发控制可以选择软件触发和硬件触发,硬件触发主要来自于定时器,当然也可以选择外部中断的引脚。右边是来自RCC的ADC时钟CLOCK,ADC逐次比较的过程就是由这个时钟推动的。然后上面可以布置一个模拟看门狗用于监测转换结果的范围,如果超出设定的阈值,就通过中断输出控制,向NVIC申请中断,另外、规则组和注入组转换完成后会有个EOC信号,它会置一个标志位,当然也可以通向NVIC。最后右下角还有一个开关控制,在库函数种,就是ADC_Cmd函数,用于给ADC上电的。
6.1.5 输入通道
|--------|----------|----------|----------|
| 通道 | ADC1 | ADC2 | ADC3 |
| 通道0 | PA0 | PA0 | PA0 |
| 通道1 | PA1 | PA1 | PA1 |
| 通道2 | PA2 | PA2 | PA2 |
| 通道3 | PA3 | PA3 | PA3 |
| 通道4 | PA4 | PA4 | PF6 |
| 通道5 | PA5 | PA5 | PF7 |
| 通道6 | PA6 | PA6 | PF8 |
| 通道7 | PA7 | PA7 | PF9 |
| 通道8 | PB0 | PB0 | PF10 |
| 通道9 | PB1 | PB1 | |
| 通道10 | PC0 | PC0 | PC0 |
| 通道11 | PC1 | PC1 | PC1 |
| 通道12 | PC2 | PC2 | PC2 |
| 通道13 | PC3 | PC3 | PC3 |
| 通道14 | PC4 | PC4 | |
| 通道15 | PC5 | PC5 | |
| 通道16 | 温度传感器 | | |
| 通道17 | 内部参考电压 | | |
总共有18个通道、通道16对应ADC1的温度传感器,通道17对应ADC1的内部参考电压。我们使用的这款芯片没有ADC3,这颗芯片也没有PC0到PC5。
上表从引脚定义表也可以看出来,下图所示ADC12_IN0的意思是ADC1和ADC2的IN0都是在PA0上的。而且下面也是全都是ADC12,这说明ADC1和ADC2的引脚全都是相同的,既然都相同,ADC2还有啥用呢?这就是因为ADC的另一个高级功能了,就是双ADC模式,这个模式比较复杂,双ADC模式就是ADC1和ADC2一起工作,它两可以配合组成同步模式、交叉模式等等模式,比如交叉模式、ADC1和ADC2交叉地对一个通道进行采样,这样就可以进一步提高采样率。就像打拳一样,左手一拳右手一拳快速交叉地打拳,打击地频率就比一个拳头快。这就是ADC1和ADC2配合使用的双ADC模式了。ADC1和ADC2也是可以分开使用的,可以分别对不同的引脚进行采样,也是可以的。
6.1.6 规则组的4种转换模式
在ADC初始化的结构体里,会有两个参数,一个是选择单次转换还是连续转换的,另一个是选择扫描模式还是非扫描模式的。这两个参数组合,就是以下4种转换方式。
6.1.6.1 单次转换、非扫描模式
上图所示列表相当于规则组里的菜单,可以在这里"点菜",就是写入要转换的通道,在非扫描的模式下,这个菜单就只有第一个序列1的位置有效,这是菜单同时选中1组的方式就退化为简单地选中一个的方式了,在这里我们可以在序列1的位置指定我们想要转换的通道,比如通道2,然后就可以触发转换,ADC就会对这个通道2进行模数转换,过一小段时间后,转换完成,转换结果放在数据寄存器里,同时给ROC标志位置1,整个转换过程就结束了。我们判断这个EOC标志位,如果转换完了,那我们就可以在数据寄存器里读取结果了,如果想再启动一次转换,就需要再触发一次,转换结束,置EOC标志位,读结果。如果想换一个通道转换,那在转换之前,把第一个位置的通道2改成其它通道,然后再启动转换就行了。
6.1.6.2 连续转换、非扫描模式
还是非扫描模式,所以菜单列表只选择一个。与单次转换不同的是,在一次转换后不会停止,而是立刻开始下一轮的转换,然后一直持续下去。这样就可以只需要最开始触发一次,之后就可以一直转换了。这个模式的好处就是,开始转换之后不需要等待一段时间的,因为它一直都在转换,所以就不需要手动开始转换了,也不用判断是否结束的,想要读AD值的时候,直接从数据寄存器取就是了。
6.1.6.3 单次转换、扫描模式
这个模式也是单次转换,所以每转换一次,转换结束后,就会停下来, 下次转换就得再触发才能开始,然后它是扫描模式,这就会用到菜单列表了,可以在菜单里点菜,比如第一道是通道2,第二道是通道5等等,这里每个位置是通道几可以任意指定,并且也是可以重复的,然后初始化结构体里还会有个参数,就是通道数目,因为这16个位置可以不用完,只用前几个,那就需要再给一个通道数目的参数,高速它,我有几个通道,比如这里指定通道数目为7,那它就只看前7个位置。然后每次触发之后,就依次对这前7个位置进行AD转换,转换结果都放在数据寄存器里,这里为了放在之数据被覆盖,就需要用DMA及时讲数据挪走,那7个通道转换完成之后,产生EOC信号,转换结束,然后再触发下一次,就又开始新一轮的转换。
6.1.6.4 连续转换、扫描模式
在扫描模式的情况下,还可以有另一种模式,叫间断模式,它的作用是,在扫描的过程中,每隔几个转换,就暂停一次,需要再次触发,才能继续。
6.1.7 触发控制
上图是规则组的触发源, 也就是下图标注部分。有来自定时器的信号,还有来自外部引脚或定时器的信号,这个具体是引脚还是定时器,需要用AFIO重映射来确定,最后是软件控制位,也就是我们之前说的软件触发,这些触发信号的选择,可以通过设置右边所示的寄存器来完成,使用库函数的话直接给一个参数就可以了。
6.1.8 数据对齐
我们这个ADC是12位的,它的转换结果就是一个12位的数据,但是这个数据寄存器是16位的,所以就存在一个数据对齐的问题。一般使用数据右对齐,这样读取的16位寄存器,直接就是转换结果。选择左对齐得到的结果会比实际的大,因为数据左对齐就是把数据左移了4次。二进制有个特点,就是数据左移一次,就等效于把这个数据乘2,左移4次就相当于把结果乘16了,直接读的话会比实际值大16倍。左对齐的作用就是,如果不想要这么右对齐这么高的分辨率,觉得0~4095数太大了,就做个简单的判断,不需要这么高分辨率,就可以选择左对齐,然后再把这个数据的高8位取出来,这样就舍弃了后面4个精度,这样12位的ADC就退化成了8位的ADC了。
数据右对齐:
数据左对齐:
6.1.9 转换时间
转换时间这个参数,一般不太敏感,因为一般AD转换都很快,若果不需要非常高速的转换频率,那转换时间就可以忽略了。AD转换是需要一小段时间的。
AD转换的步骤:采样,保持,量化,编码
采样保持可以放在一起,量化编码可以放在一起,总共是这两大步。量化编码就是之前讲过的ADC逐次比较的过程,这个是需要花一段时间的,一般位数越多,花的时间就越长;
采样保持:这是因为AD转换,就是后面的量化编码,需要一小段时间的,如果在这一小段时间里,输入的电压还在不断变化、那就没办法定位输入电压到底是在哪里了,所以在量化编码之,需要设置一个采样开关,先打开采样开关,收集一下外部的电压,比如可以用一个小容量的电容存储一下这个电压,存储好了之后,断开采样开关,再进行后面的AD转换,这样在量化编码期间,电压始终保持不变,这样才能精确定位未知电压的位置,这就是采样保持电路。采样保持的过程需要闭合采样开关、过一段时间再断开,这里就会产生一个采样时间。
STM32 ADC的总转换时间为:
TCONV = 采样时间 + 12.5个ADC周期
采样时间就是采样保持花费的时间,这个时间可以在程序中进行配置,采样时间越大,越能避免一些毛刺信号的干扰,不过转换时间也会相应延长。12.5个ADC周期是量化编码所花费的时间,因为是12位的ADC,所以需要花费12个ADC,这里多半个周期,可能是做一些其它东西花的时间。ADC周期就是从RCC分频过来的ADCCLK,这个ADCCLK最大是14MHz。
例如:当ADCCLK=14MHz,采样时间为1.5个ADC周期
TCONV = 1.5 + 12.5 = 14个ADC周期 = 1μs
这就是最快1us时间的来源,如若过采样周期再长一些,它就达不到1us了。 也可以把ADCCLK的时钟设置超过14MHz,这样ADC就是在超频了,那转换时间可以比1us还短,不过这样稳定性就没办法保证了。
6.1.10 校准
ADC有一个内置自校准模式。校准可大幅减小因内部电容器组的变化而造成的准精度误差。校准期间,在每个电容器上都会计算出一个误差修正码(数字值),这个码用于消除在随后的转换中每个电容器上产生的误差。
建议在每次上电后执行一次校准。
启动校准前, ADC必须处于关电状态超过至少两个ADC时钟周期。
这个校准过程是固定的,我们只需要在ADC初始化的最后,加几条代码就行了。
6.1.11 硬件电路
如何设计ADC的外围电路:
上图第一个是电位器产生1个可调的电压,这里电位器的两个固定端、一端接3.3V、另一端接GND,这样中间的滑动段就可以输出一个0~3.3V可调的电压输出了。右边可以接ADC的输入通道,比如PA0口,当滑动端往上滑时,电压增大,往下滑时,电压减小。注意电阻的阻值不要给太小,因为这个电阻两端也是直接跨接在电源正负极的,如果阻值太小,这个电阻就会比较费电,再小就有可能发热冒烟了。一般至少要接KΩ级的电阻,比如这里接的10K的电阻。
第二个是传感器产生输出电压的电路,一般来说,像光敏电阻、热敏电阻、红外接收管、麦克风等等。都可以等效为一个可变电阻,电阻值没办法直接测量,所以可以通过和一个固定电阻串联分压,来得到一个反应电阻值电压的电路,这里传感器阻值变小时,下拉作用强,输出电压就下降;传感器阻值变大时,下拉作用变弱,输出端受上拉电阻的作用,电压就会升高。这个固定电阻一般可选择和传感器阻值相近的电阻,这样可以得到一个位于中间电压区域比较好的输出。传感器和固定电阻的位置也可以缓过来,这样输出电压的极性就反过来了。
第3个电路是一个简单的电压转换电路,比如想要测一个0~5V的VIN电压,但是ADC只能接收0~3.3V的电压,就可以搭建这样的简易转换电路。在这里还是使用电阻进行分压,上面阻值17K,下面阻值33K,加一起50K,中间的电压就是0~3.3V,就可以进入ADC转换了。如果采集的是5V、10V就可以采用这个电路1了。比这个还高就不能使用了,高电压采集最好还是使用一些专用的采集芯片,比如隔离放大器等等,做好高低电压的隔离,保证电路的安全。
6.2 AD单通道
6.2.1 硬件电路
只有ADC通道能接模拟电压,所以连接时候要注意选择的引脚是否是ADC通道。
6.2.2 软件部分
(1)复制《OLED显示屏》工程并改名为《AD单通道》
(2)添加驱动文件
(3)ADC的初始化及相关库函数
cpp
/*RCC库函数里*/
void RCC_ADCCLKConfig(uint32_t RCC_PCLK2); // 配置ADCCLK分频器的,可以对APB2的72MHz时钟选择2、4、6、8分频,输入到ADCCLK
/*ADC库函数里*/
void ADC_DeInit(ADC_TypeDef* ADCx); // 恢复缺省配置
void ADC_Init(ADC_TypeDef* ADCx, ADC_InitTypeDef* ADC_InitStruct); // 初始化
void ADC_StructInit(ADC_InitTypeDef* ADC_InitStruct); // 结构体初始化
void ADC_Cmd(ADC_TypeDef* ADCx, FunctionalState NewState); // 用于给ADC上电,也就是开关控制
void ADC_DMACmd(ADC_TypeDef* ADCx, FunctionalState NewState); // 用于开启DMA输出信号,若果使用DMA转运数据,就得调用这个函数
void ADC_ITConfig(ADC_TypeDef* ADCx, uint16_t ADC_IT, FunctionalState NewState); // 中断输出控制,用于控制某个中断,能不能通往NVIC
/*下面4个函数是用于获取控制校准的函数、在ADC初始化完成之后,一次调用就可以了*/
void ADC_ResetCalibration(ADC_TypeDef* ADCx); // 复位校准
FlagStatus ADC_GetResetCalibrationStatus(ADC_TypeDef* ADCx); // 获取复位校准状态
void ADC_StartCalibration(ADC_TypeDef* ADCx); // 开始校准
FlagStatus ADC_GetCalibrationStatus(ADC_TypeDef* ADCx); // 获取开始校准状态
void ADC_SoftwareStartConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
// ADC_软件开始转换控制,这个就是用于软件触发的函数了,调用一下,就能软件触发转换了
FlagStatus ADC_GetSoftwareStartConvStatus(ADC_TypeDef* ADCx);
//ADC获取软件开始转换状态,给SWSTART位置1,以开始转换,这个函数一般不用
/*下面2个函数是用来配置ADC间断模式的*/
void ADC_DiscModeChannelCountConfig(ADC_TypeDef* ADCx, uint8_t Number);//每隔几个通道间断一次
void ADC_DiscModeCmd(ADC_TypeDef* ADCx, FunctionalState NewState); //是不是启用间断模式
void ADC_RegularChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime);
/*ADC规则组通道配置,很重要,作用就是给序列的每个位置填写指定的通道
ADC_Channel:要指定的通道;
Rank:序列几的位置;
ADC_SampleTime:指定通道的采样时间。
*/
void ADC_ExternalTrigConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
//ADC外部触发转换控制,就是是否允许外部触发转换
uint16_t ADC_GetConversionValue(ADC_TypeDef* ADCx); // ADC获取转换值,获取ADC转换的数据寄存器,读取转换结果就是要用这个函数
uint32_t ADC_GetDualModeConversionValue(void);//ADC获取双模式转换通道,这个是双ADC模式读取转换结果的函数,暂时不用
/*下面带Injected的函数都是对ADC注入组进行配置的*/
void ADC_AutoInjectedConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
void ADC_InjectedDiscModeCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
void ADC_ExternalTrigInjectedConvConfig(ADC_TypeDef* ADCx, uint32_t ADC_ExternalTrigInjecConv);
void ADC_ExternalTrigInjectedConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
void ADC_SoftwareStartInjectedConvCmd(ADC_TypeDef* ADCx, FunctionalState NewState);
FlagStatus ADC_GetSoftwareStartInjectedConvCmdStatus(ADC_TypeDef* ADCx);
void ADC_InjectedChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel, uint8_t Rank, uint8_t ADC_SampleTime);
void ADC_InjectedSequencerLengthConfig(ADC_TypeDef* ADCx, uint8_t Length);
void ADC_SetInjectedOffset(ADC_TypeDef* ADCx, uint8_t ADC_InjectedChannel, uint16_t Offset);
uint16_t ADC_GetInjectedConversionValue(ADC_TypeDef* ADCx, uint8_t ADC_InjectedChannel);
/*下面三个函数是对模拟看门狗进行配置的*/
void ADC_AnalogWatchdogCmd(ADC_TypeDef* ADCx, uint32_t ADC_AnalogWatchdog); //是否启动模拟看门狗
void ADC_AnalogWatchdogThresholdsConfig(ADC_TypeDef* ADCx, uint16_t HighThreshold, uint16_t LowThreshold); //配置高低阈值
void ADC_AnalogWatchdogSingleChannelConfig(ADC_TypeDef* ADCx, uint8_t ADC_Channel); //配置看门的通道
void ADC_TempSensorVrefintCmd(FunctionalState NewState);//ADC温度传感器、内部参考电压控制,用来开启内部的两个通道的
FlagStatus ADC_GetFlagStatus(ADC_TypeDef* ADCx, uint8_t ADC_FLAG);
//获取标志位状态,参数给EOC标志位,就可以判断EOC标志位是不是置1了,如果转换结束,EOC标志位置1
void ADC_ClearFlag(ADC_TypeDef* ADCx, uint8_t ADC_FLAG); //清除标志位
ITStatus ADC_GetITStatus(ADC_TypeDef* ADCx, uint16_t ADC_IT); //获取中断状态
void ADC_ClearITPendingBit(ADC_TypeDef* ADCx, uint16_t ADC_IT); //清除中断挂起位
(4)AD.c
①单次转换,非扫描模式
cpp
#include "stm32f10x.h" // Device header
/*初始化函数:
第1步:开启RCC时钟,包括ADC和GPIO的时钟,ADCCLK的分频器,也需要配置一下;
第2步:配置GPIO,把需要用的GPIO配置成模拟输入的模式;
第3步:配置多路开关,把最左边的通道接入右边的规则组列表里;
第4步:配置ADC转换器,库函数里使用结构体配置,包括ADC是单次转换还是连续转换、
扫描还是非扫描、有几个通道、触发源是什么、数据对齐是左对齐还是右对齐
如果需要模拟看门狗,会有几个函数用来配置阈值和监测通道的;如果想开启中断,那就在中断输出控制
里用ITConfig函数开启对应的中断输出,然后再在NVIC里,配置中断优先级,这样就能触发中断了。
第5步:开关控制,调用一下ADC_Cmd函数,开启ADC,这样ADC就配置完成了。开启ADC之后,
还可以对ADC进行校准、这样可以减小误差。
在ADC工作的时候,如果想要触发转换,会有函数可以触发;如果想读取结果,也会有函数可以读取结果。
*/
void AD_Init(void)
{
/*第1步:开启RCC时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1,ENABLE); //开启ADC1的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //要使用PA0口采样
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //配置APB2六分频,分频后ADCCLK=72MHz/6=12MHz
/*第2步:配置GPIO*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
/*配置GPIO口是模拟输入模式,AIN模式下,GPIO是无效的,断开GPIO口,防止GPIO口的输入输出对模拟电压造成干扰;
所以AIN模式是ADC的专属模式*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_0);
/*第3步:配置多路开关*/
ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_55Cycles5);
/*ADC_Channel_0:通道0,1:规则组序列里的次序,ADC_SampleTime_55Cycles5:采样时间55.5个ADCCLK的周期*/
/*第4步:配置ADC转换器*/
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; //配置ADC工作在独立模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; //配置ADC数据对齐为右对齐
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; //配置不使用外部触发,使用内部软件触发
ADC_InitStruct.ADC_ContinuousConvMode = DISABLE; //配置转换模式为单次转换
ADC_InitStruct.ADC_ScanConvMode = DISABLE; //配置扫描模式为非扫描
ADC_InitStruct.ADC_NbrOfChannel = 1; //目前是1个通道,给1,这个参数仅在扫描模式下可用,非扫描模式,整个列表只有第一个序列有用
ADC_Init(ADC1,&ADC_InitStruct);
/*第5步:开关控制,开启ADC的电源*/
ADC_Cmd(ADC1,ENABLE);
ADC_ResetCalibration(ADC1); //复位校准
ADC_GetResetCalibrationStatus(ADC1); //返回复位校准的状态
while(ADC_GetResetCalibrationStatus(ADC1) == SET); //读取这一位,如果它是1,那就需要一直空循环等待;如果它变为0,那就说明复位校准完成,可以跳出等待了
ADC_StartCalibration(ADC1); //启动校准,之后内部电路会自动校准
ADC_GetCalibrationStatus(ADC1); //获取校准状态
while(ADC_GetCalibrationStatus(ADC1) == SET); //判断校准状态是否完成
}
uint16_t AD_GetValue(void)
{
ADC_SoftwareStartConvCmd(ADC1,ENABLE); //软件触发转换,启动
while(ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC) == RESET); //获取标志位状态并等待完成,ADC_FLAG_EOC:规则组转换完成标志位,自动清除标志位,等待
/*
ADC_FLAG_AWD:模拟看门狗标志位
ADC_FLAG_EOC:规则组转换完成标志位
ADC_FLAG_JEOC: 注入组转换完成标志位
ADC_FLAG_JSTRT: 注入组开始转换标志位
ADC_FLAG_STRT: 规则组开始转换标志位
*/
/*具体等待时间:采样周期:55.5、转换周期12.5,12MHz进行68个周期转换完成*/
return ADC_GetConversionValue(ADC1); //返回转换值
}
②连续转换,非扫描模式
cpp
#include "stm32f10x.h" // Device header
/*初始化函数:
第1步:开启RCC时钟,包括ADC和GPIO的时钟,ADCCLK的分频器,也需要配置一下;
第2步:配置GPIO,把需要用的GPIO配置成模拟输入的模式;
第3步:配置多路开关,把最左边的通道接入右边的规则组列表里;
第4步:配置ADC转换器,库函数里使用结构体配置,包括ADC是单次转换还是连续转换、
扫描还是非扫描、有几个通道、触发源是什么、数据对齐是左对齐还是右对齐
如果需要模拟看门狗,会有几个函数用来配置阈值和监测通道的;如果想开启中断,那就在中断输出控制
里用ITConfig函数开启对应的中断输出,然后再在NVIC里,配置中断优先级,这样就能触发中断了。
第5步:开关控制,调用一下ADC_Cmd函数,开启ADC,这样ADC就配置完成了。开启ADC之后,
还可以对ADC进行校准、这样可以减小误差。
在ADC工作的时候,如果想要触发转换,会有函数可以触发;如果想读取结果,也会有函数可以读取结果。
*/
void AD_Init(void)
{
/*第1步:开启RCC时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1,ENABLE); //开启ADC1的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //要使用PA0口采样
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //配置APB2六分频,分频后ADCCLK=72MHz/6=12MHz
/*第2步:配置GPIO*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
/*配置GPIO口是模拟输入模式,AIN模式下,GPIO是无效的,断开GPIO口,防止GPIO口的输入输出对模拟电压造成干扰;
所以AIN模式是ADC的专属模式*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_0);
/*第3步:配置多路开关*/
ADC_RegularChannelConfig(ADC1,ADC_Channel_0,1,ADC_SampleTime_55Cycles5);
/*ADC_Channel_0:通道0,1:规则组序列里的次序,ADC_SampleTime_55Cycles5:采样时间55.5个ADCCLK的周期*/
/*第4步:配置ADC转换器*/
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; //配置ADC工作在独立模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; //配置ADC数据对齐为右对齐
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; //配置不使用外部触发,使用内部软件触发
ADC_InitStruct.ADC_ContinuousConvMode = ENABLE; //配置转换模式为连续转换
ADC_InitStruct.ADC_ScanConvMode = DISABLE; //配置扫描模式为非扫描
ADC_InitStruct.ADC_NbrOfChannel = 1; //目前是1个通道,给1,这个参数仅在扫描模式下可用,非扫描模式,整个列表只有第一个序列有用
ADC_Init(ADC1,&ADC_InitStruct);
/*第5步:开关控制,开启ADC的电源*/
ADC_Cmd(ADC1,ENABLE);
ADC_ResetCalibration(ADC1); //复位校准
ADC_GetResetCalibrationStatus(ADC1); //返回复位校准的状态
while(ADC_GetResetCalibrationStatus(ADC1) == SET); //读取这一位,如果它是1,那就需要一直空循环等待;如果它变为0,那就说明复位校准完成,可以跳出等待了
ADC_StartCalibration(ADC1); //启动校准,之后内部电路会自动校准
ADC_GetCalibrationStatus(ADC1); //获取校准状态
while(ADC_GetCalibrationStatus(ADC1) == SET); //判断校准状态是否完成
ADC_SoftwareStartConvCmd(ADC1,ENABLE); //软件触发转换,启动,使用连续转换的时候放到初始化函数里就可以了
}
uint16_t AD_GetValue(void)
{
// ADC_SoftwareStartConvCmd(ADC1,ENABLE); //软件触发转换,启动
// while(ADC_GetFlagStatus(ADC1,ADC_FLAG_EOC) == RESET); //获取标志位状态并等待完成,ADC_FLAG_EOC:规则组转换完成标志位,自动清除标志位,等待
/*
ADC_FLAG_AWD:模拟看门狗标志位
ADC_FLAG_EOC:规则组转换完成标志位
ADC_FLAG_JEOC: 注入组转换完成标志位
ADC_FLAG_JSTRT: 注入组开始转换标志位
ADC_FLAG_STRT: 规则组开始转换标志位
*/
/*具体等待时间:采样周期:55.5、转换周期12.5,12MHz进行68个周期转换完成*/
//连续转换时,就不需要配置状态标志位了
return ADC_GetConversionValue(ADC1); //返回转换值
}
(5)AD.h
cpp
#ifndef __AD_H
#define __AD_H
void AD_Init(void);
uint16_t AD_GetValue(void);
#endif
(6)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "AD.h"
uint16_t ADValue;
float Voltage;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
AD_Init();
OLED_ShowString(1,1,"ADValue:");
OLED_ShowString(2,1,"Voltage:0.00V");
while(1)
{
ADValue=AD_GetValue();
Voltage=(float)ADValue / 4095*3.3;
OLED_ShowNum(1,9,ADValue,4);
OLED_ShowNum(2,9,Voltage,1);
OLED_ShowNum(2,11,(uint16_t) (Voltage*100) % 100,2); //当前OLED驱动程序不能直接显示浮点数,因此处理一下
Delay_ms(100);
}
}
AD值得末尾数据会有一些抖动,这是正常的波动, 如果想对这个值进行判断,再执行一些操作,比如光线得AD值小于某一阈值,就开灯,大于某一阈值,就关灯。可能会存在这样的情况,比如光线逐渐变暗,AD值逐渐减小,但是由于波动,AD值会在判断阈值附近来回跳变,这会导致输出产生抖动,来回开灯关灯开灯关灯。为了避免这种情况,可以使用迟滞比较的方法来完成,设置两个阈值,低于下阈值时,开灯,高于上阈值时,才关灯,这样就可以避免输出抖动的问题了。和GPIO那一节讲的施密特触发器是一样的原理。如果觉得数据跳变太厉害,还可以采取滤波的方法,让AD值平滑一些,比如均值滤波,就是读取10个或20个AD值,取平均值,作为滤波的AD值,或者还可以裁剪分辨率,把数据的尾数去掉,这样也可以减少数据波动。
6.3 AD多通道
6.3.1 硬件电路
6.3.2 软件部分
(1)复制《AD单通道》工程改名为《AD多通道》
(2)转换模式选择
多通道可以使用扫描模式,但是会有数据覆盖的问题,用这种方式需要配合DMA来实现。一个通道转换完成之后,手动把数据转运出来这种方案看似简单,实际会出现一些问题。第一个问题就是在扫描模式下,启动列表之后,它里面每一个单独的通道转换完成之后,不会产生任何的标志位,也不会触发中断,不知道某一个通道是不是转换完了,它只有在两个列表都转换完成之后,才会产生一次EOC标志位,才能触发中断,而这时候前面的数据已经覆盖丢失了。第二个问题就是AD转换是非常快的,转换一个通道只有几us,也就是说,如果不能在几us的时间内把数据转运走,拿数据就会丢失,这对我们手动转运程序,要求就比较高了,所以在扫描模式下,手动转运数据是比较困难的。不过手动转运也不是不可行,我们可以使用间断模式,在扫描的时候,每转换一个通道就暂停一次,等我们手动把数据转运走之后,再继续触发,继续下一次转换,这样可以实现手动转运数据的功能,但是由于单个通道转换完成之后,没有标志位,所以启动完成之后,只能通过Delay延时的方式,延时足够长的时间,才能保证转换完成。这种方式既不省心,也不能提高效率,所以不推荐使用。
我们可以使用单次转换,非扫描的模式来实现多通道,只需要在每次触发转换之前,手动更改一下列表第一个位置的通道就可以了。比如第一次转换,先写入通道0,之后触发、等待、读值;第二次转换,再把通道0改成通道1、之后触发、等待、读值;第三次转换,先改成通道2,等等等。
(3)AD.c
cpp
#include "stm32f10x.h" // Device header
/*初始化函数:
第1步:开启RCC时钟,包括ADC和GPIO的时钟,ADCCLK的分频器,也需要配置一下;
第2步:配置GPIO,把需要用的GPIO配置成模拟输入的模式;
第3步:配置多路开关,把最左边的通道接入右边的规则组列表里,使用多个通道,再触发转换函数里实现;
第4步:配置ADC转换器,库函数里使用结构体配置,包括ADC是单次转换还是连续转换、
扫描还是非扫描、有几个通道、触发源是什么、数据对齐是左对齐还是右对齐
如果需要模拟看门狗,会有几个函数用来配置阈值和监测通道的;如果想开启中断,那就在中断输出控制
里用ITConfig函数开启对应的中断输出,然后再在NVIC里,配置中断优先级,这样就能触发中断了。
第5步:开关控制,调用一下ADC_Cmd函数,开启ADC,这样ADC就配置完成了。开启ADC之后,
还可以对ADC进行校准、这样可以减小误差。
在ADC工作的时候,如果想要触发转换,会有函数可以触发;如果想读取结果,也会有函数可以读取结果。
*/
void AD_Init(void)
{
/*第1步:开启RCC时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1,ENABLE); //开启ADC1的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //要使用PA0口采样
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //配置APB2六分频,分频后ADCCLK=72MHz/6=12MHz
/*第2步:配置GPIO*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
/*配置GPIO口是模拟输入模式,AIN模式下,GPIO是无效的,断开GPIO口,防止GPIO口的输入输出对模拟电压造成干扰;
所以AIN模式是ADC的专属模式*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
/*ADC_Channel_0:通道0,1:规则组序列里的次序,ADC_SampleTime_55Cycles5:采样时间55.5个ADCCLK的周期*/
/*第4步:配置ADC转换器*/
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; //配置ADC工作在独立模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; //配置ADC数据对齐为右对齐
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; //配置不使用外部触发,使用内部软件触发
ADC_InitStruct.ADC_ContinuousConvMode = DISABLE; //配置转换模式为单次转换
ADC_InitStruct.ADC_ScanConvMode = DISABLE; //配置扫描模式为非扫描
ADC_InitStruct.ADC_NbrOfChannel = 1; //目前是1个通道,给1,这个参数仅在扫描模式下可用,非扫描模式,整个列表只有第一个序列有用
ADC_Init(ADC1,&ADC_InitStruct);
/*第5步:开关控制,开启ADC的电源*/
ADC_Cmd(ADC1,ENABLE);
ADC_ResetCalibration(ADC1); //复位校准
ADC_GetResetCalibrationStatus(ADC1); //返回复位校准的状态
while(ADC_GetResetCalibrationStatus(ADC1) == SET); //读取这一位,如果它是1,那就需要一直空循环等待;如果它变为0,那就说明复位校准完成,可以跳出等待了
ADC_StartCalibration(ADC1); //启动校准,之后内部电路会自动校准
ADC_GetCalibrationStatus(ADC1); //获取校准状态
while(ADC_GetCalibrationStatus(ADC1) == SET); //判断校准状态是否完成
ADC_SoftwareStartConvCmd(ADC1,ENABLE); //软件触发转换,启动,使用连续转换的时候放到初始化函数里就可以了
}
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); //获取标志位状态并等待完成,ADC_FLAG_EOC:规则组转换完成标志位,自动清除标志位,等待
/*
ADC_FLAG_AWD:模拟看门狗标志位
ADC_FLAG_EOC:规则组转换完成标志位
ADC_FLAG_JEOC: 注入组转换完成标志位
ADC_FLAG_JSTRT: 注入组开始转换标志位
ADC_FLAG_STRT: 规则组开始转换标志位
*/
/*具体等待时间:采样周期:55.5、转换周期12.5,12MHz进行68个周期转换完成*/
//连续转换时,就不需要配置状态标志位了
return ADC_GetConversionValue(ADC1); //返回转换值
}
(4)AD.h
cpp
#ifndef __AD_H
#define __AD_H
void AD_Init(void);
uint16_t AD_GetValue(uint8_t ADC_Channel);
#endif
(5)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "AD.h"
uint16_t AD0,AD1,AD2,AD3;
float Voltage;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
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);
}
}
第07章 DMA直接存储器存取
DMA主要用来协助CPU完成数据转运的工作。
7.1 DMA直接存储器存储
7.1.1 DMA简介
DMA(Direct Memory Access)直接存储器存取。
DMA这个外设,可以直接访问STM32内部的存储器的,包括运行内存SRAM、程序存储器Flash和寄存器等等,DMA都有权限访问它们,所以DMA能完成数据转运的工作。
DMA可以提供外设和存储器或者存储器和存储器之间的高速数据传输,无须CPU干预,节省了CPU的资源。
外设指的是外设寄存器,一般是外设的数据寄存器DR、Data Register,比如ADC的输入寄存器,串口的数据寄存器等等。存储器指的就是运行内存SRAM和程序存储器Flash,是我们存储变量数组和程序代码的地方。
12个独立可配置的通道: DMA1(7个通道), DMA2(5个通道)。
这个通道就是数据转运的路径,从一个地方移动到另一个地方,就需要占用一个通道,如果有多个通道进行转运,那它们之间各转各的,互不干扰。
每个通道都支持软件触发和特定的硬件触发。
如果DMA进行的是存储器到存储器的数据转运,比如想把Flash里的一批数据,转运到SRAM里去,那就需要软件触发了,使用软件触发,DMA会一股脑地,把这批数据,以最快的速度,全部转运完成。若果DMA进行的是外设到存储器的数据转运,就不能一股脑的转运了,因为外设的数据是有一定时机的,所以这时候需要用硬件触发,比如转运ADC的数据,就得ADC每个通道AD转换完成后,硬件触发一次DMA,之后DMA再转运,触发一次,转运一次,这样数据才是正确的。所以存储器到存储器的数据转运,我们一般使用软件触发;外设到存储器的数据转运,我们一般使用硬件触发。特定的硬件触发指的是每个DMA的通道,它的硬件触发源是不一样的,如果要使用某个外设的的硬件触发源,就得使用它连接的那个通道,而不能任意选择通道。
STM32F103C8T6 DMA资源:DMA1(7个通道)。
7.1.2 存储器映像
计算机设备的五大组成部分:运算器、控制器、存储器、输入设备和输出设备,运算器和控制器合在一起叫做CPU,所以计算机的核心关键部分就是CPU和存储器。
|--------|-------------|------------|---------------------|
| 类型 | 起始地址 | 存储器 | 用途 |
| ROM | 0x0800 0000 | 程序存储器Flash | 存储C语言编译后的程序代码 |
| ROM | 0x1FFF F000 | 系统存储器 | 存储BootLoader,用于串口下载 |
| ROM | 0x1FFF F800 | 选项字节 | 存储一些独立于程序代码的配置参数 |
| RAM | 0x2000 0000 | 运行内存SRAM | 存储运行过程中的临时变量 |
| RAM | 0x4000 0000 | 外设寄存器 | 存储各个外设的配置参数 |
| RAM | 0xE000 0000 | 内核外设寄存器 | 存储内核各个外设的配置参数 |
ROM:只读存储器,是一种非易失性、掉电不丢失的存储器。
RAM:随机存储器,是一种易失性、掉电丢失的存储器。
程序存储器Flash:主闪存,我们下载程序的位置。运行程序一般也是从主闪存里面开始运行的。如果再软件里看到,某个数据的地址是0800开头的,就可以确定,它是属于主闪存的数据。
系统存储器和选项字节:存储介质也是Flash,不过我们一般说Flash指的是主闪存Flash,而不指这两块区域。选项字节里寸的主要是Flash的读保护、写保护,还有看门狗等等的配置。
运行内存SRAM:我们在程序中定义变量、数组、结构体的地方。定义一个变量,读取它的地址显示出来,它的地址就是0x2000开头的,类比于电脑的话,运行内存就是内存条。
外设寄存器:我们初始化各个外设,最终所读写的东西。存储器也是SRAM。
内核外设寄存器:内核外设就是NVIC和SysTick。内核外设和其它外设不是一个厂家设计的,所以它们的地址也是被分开了。
在STM32中,所有的存储器都被安排到了0到8个F这个地址的范围,这个地址范围内,因为CPU是32的,所以寻址范围就是32位的范围。32位的寻址范围是非常大的,最大可以支持4GB容量的存储器,而STM32的存储器都是KB级别的,所以这个4GB的寻址空间,会有大量的地址都是空的。
上图里灰色部分是Reserved区域,也就是保留区与,没有使用到。
7.1.3 DMA框图
左上角是Cortex-M3内核,里面包含了CPU和内核外设等等。剩下的所有东西,都可以把它看成是存储器。所以总共是CPU和存储器两个东西,Flash是主闪存,SRAM是运行内存。各个外设都可以看成是寄存器,也是一种SRAM存储器。寄存器是一种特殊的存储器,一方面,CPU可以对寄存器进行读写,就像读写运行内存一样;另一方面,寄存器的每一位背后,都连接了一根导线,这些导线可以用于控制外设电路的状态,比如置引脚的高低电平、导通和断开开关、切换数据选择器,或者多位组合起来,当作计数器、数据寄存器等等。所以计数器是连接硬件和软件的桥梁,软件读写寄存器,就相当于在控制硬件的执行。既然外设就是寄存器,寄存器就是存储器,那使用DMA进行数据转运,就都可以归为一类问题了,就是从某个地址取内容,再放到另一个地址去,为了高效有条理地访问存储器,设计了一个总线矩阵,总线矩阵地左端,是主动单元,也就是拥有存储器地访问权。右边是被动单元,它们的存储器只能被左边的主动单元读写。主动单元这里,内核有Dcode和系统总线,可以访问右边的存储器,其中Dcode总线是专门访问Flash的,系统总线是访问其它东西的。另外,由于DMA要转运数据,所以DMA也必须要有访问的主动权,那主动单元,除了内核CPU,剩下的就是DMA总线了。DMA1有一条DMA总线,DMA2也有一条DMA总线;下面还有一条DMA总线,这是以太网外设自己私有的DMA,所以这里可以不用管。在DMA1和DMA2里,可以看到DMA1有7个通道,DMA2有5个通道,各个通道可以分别设置它们转运数据的源地址和目的地址,这样它们就可以各自独立地工作了。
仲裁器材:这是因为,虽然多个通道可以独立运转数据,但是最终DMA总线只有一条,所以所有的通道都只能分时复用这一条DMA总线,如果产生了冲突,那就会由仲裁器、根据通道的优先级决定谁先用,谁后用。另外在总线矩阵这里,也有个仲裁器,如果DMA和CPU都要访问同一个目标,那么DMA就会暂停CPU的访问,以防止冲突,不过总线仲裁器,仍然会保证CPU得到一半的总线带宽,使CPU也能正常工作。这就是仲裁器的作用。
AHB从设备,也就是DMA自身的寄存器,因为DMA作为一个外设,它自己会有相应的配置寄存器,这里连接在了总线右边的AHB总线上,所以DMA即是总线矩阵的主动单元,可以读写各种存储器;也是AHB总线上的被动单元,就可以对DMA进行配置了。
DMA请求就是触发,触发源是各个外设,所以这个DMA请求就是DMA的硬件触发源,比如ADC转换完成、串口接收到数据,需要触发DMA转运数据的时候,就会通过中间的DMA请求这条线路,向DMA发出硬件触发信号,之后DMA就可以执行数据转运的工作了,这就是DMA请求的作用。
DMA总线:用于访问各个存储器。
DMA内部多个通道:可以独立的进行数据转运。
仲裁器:用于调动各个通道,防止产生冲突。
AHB从设备:用于配置DMA参数。
DMA请求,用于硬件触发DMA的数据转运。
Flash:ROM只读存储器的一种,如果通过总线直接访问的话,无论是CPU还是DMA,都是只读的,只能读取数据,而不能写入,如果DMA的目的地址,填了Flash的区域,那转运时,就会出错。Flash也不是绝对的不可写入,可以配置Flash控制器,对Flash进行写入,这个流程比较麻烦,要先对Flash按页进行擦除,再写入数据。总之就是CPU或者DMA直接访问Flash的话,是只可以读而不可以写的。
SRAM是运行内存,可以任意读写,没有问题。
外设寄存器可以看参考手册,有的寄存器是只读的,有的寄存器是只写的,不过我们用的主要是数据寄存器,数据寄存器都是可以正常读写的。
7.1.4 DMA基本结构
外设寄存器、Flash/SRAM:数据转运的两大站点,左边是外设寄存器站点,右边是存储器站点,包括Flash和SRAM。在STM32手册里所说的存储器一般特指Flash和SRAM,不包含外设寄存器。外设寄存器,一般直接称作外设,所以就是外设到存储器,存储器到存储器,这样来描述。
DMA的数据转运可以是从外设到存储器,也可以是从存储器到外设,具体向左还是向右,有一个方向的参数可以进行控制。另外还有一种转运方式,就是存储器到存储器,比图Flash到SRAM或者SRAM到SRAM两种方式。由于Flash是只读的,所以DMA不可以进行SRAM到Flash,或者Flash到Flash的转运操作。
两边的参数:既然要进行数据转运,那肯定就要指定从哪里转到哪里,具体怎么转。所以外设和存储器两个站点,就都有3个参数。第一个是其实地址,由外设端的起始地址和存储器端的起始地址。这两个参数决定了数据是从哪里来,到哪里去的;第二参数是数据宽度,这个参数的作用是指定一次转运要按多大的数据宽度来进行,可以选择字节Byte、半字HalfWord和字Word;字节就是8位、也就是一次转运一个uint8_t这么大的数据;半字是16位,也就是一次转运一个uint16_t这么大的数据;字是32位,就是一次转运uint32_t这么大的数据;比如转运ADC的数据,ADC的结果是uint16_t这么大,所以这个参数就要选择半字,一次转运一个uint16_t。第3个参数是地址是否自增,这个参数的作用是指定一次转运完成后,下一次转运是不是要把地址移动到下一个位置去,这就相当于是指针,P++这个意思;比如ADC扫描模式,用DMA进行数据转运,外设地址是ADC_DR寄存器,寄存器这边,显然地址是不用自增的,如果自增,那下一次转运就跑到别的寄存器那里去了,存储器这边,地址就需要自增,每转运一个数据之后,就往后挪个坑,要不然下次再转就把上次的覆盖掉了,这就是地址是否自增的作用。就是指定是不是要转运一次挪个坑这个意思。这就是外设站点和存储器站点各自的3个参数了。
如果要进行存储器到存储器的数据转运,那我们就需要把其中一个存储器的地址,放在外设的这个站点,这样就能进行存储器到存储器的转运了,只要在外设起始地址里写Flash或者SRAM的地址,那它就会去Flash或SRAM找数据。这个站点虽然叫外设寄存器,但是它就只是个名字而已,并不是说这个地址只能写寄存器的地址,如果写Flash的地址,就回去Flash里去找,写SRAM,它就会去SRAM里找,这个没有限制,甚至可以在外设站点写存储器的地址,甚至可以在外设站点写存储器的地址,存储器站点写外设的地址,方向给反过来,这样也是可以的。
传输计数器,这个东西是用来指定我们共需要转运几次的,这个传输计数器是一个自减计数器,比如给它写一个5,那DMA就只能进行5次数据转运,转运过程中,每转运一次,计数器的数就会减1;当传输计数器减到0之后,DMA就不会再进行数据转运了,另外,它减到0之后,之前自增的地址,也会恢复到起始地址的位置,以方便之后DMA开始新一轮的转运。
在传输计数器的右边,有一个自动重装器,这个自动重装器的作用就是,传输计数器减到0之后,是否要自动恢复到最初的值。比如最初传输计数器给5,若果不使用自动重装器,那转运5次后,DMA就结束了;如果使用自动重装器,那转运5次,计数器减到0后,就会立即重装到初始值5。这个就是自动重装器,它决定了转运的模式,如果不重装,就是正常的单次模式,如果重装就是循环模式。比如如果想转运一个数组,那一般就是单次模式,转运一轮就结束了。如果是ADC扫描模式+连续转换,那为了配合ADC,DMA也需要使用循环模式,所以这个循环模式和ADC的连续模式差不多,都是指定一轮工作完成后,是不是立即开始下一轮工作。
最下面就是ADC的触发控制了,触发就是决定DMA需要在什么时机进行转运的。触发源有硬件触发和软件触发,具体选择哪个由M2M(Memory to Memory,因为two和to同音,所以M2M就是M to M,存储器到存储器的意思)这个参数决定。当我们给M2M位1时,DMA就会选择软件触发,这个软件触发并不是调用某个函数一次,触发一次,这个软件触发的逻辑是,以最快的速度,连续不断地触发DMA,争取早日把传输计数器清零,完成这一轮的转换。所以这里的软件触发和之前外部中断、ADC的软件触发不太一样,可以把它理解为连续触发。软件触发和循环模式不能同时使用,因为软件触发就是想把传输计数器清零,循环模式是清零后自动重装,如果同时用的话,DMA就停不下来了。软件触发一般适用于存储器到存储器的转运,因为存储器到存储器的转运,是软件启动、不需要时机,并且想尽快完成的任务。所以上面这里,M2M位给1,就是软件触发,就是应用在存储器到存储器的情况。当M2M位给0,就是使用硬件触发了,硬件触发可以选择ADC、串口、定时器等等。使用硬件触发的转运,一般都是与外设有关的转运,这些转运需要一定的时机,比如ADC转换完成、串口收到数据、定时时间到等等,所以需要硬件触发,在硬件达到这些时机时,传一个信号过来,来触发DMA进行转运,这就是硬件触发。
最后就是开关控制了,也就是DMA_cmd函数了,当给DMA使能后,DMA就准备就绪,可以进行转运了。DMA进行转运,有几个条件:
第1,就是开关控制,DMA_Cmd必须使能;
第2,传输计数器必须大于0;
第3,触发源,必须有触发信号,触发一次,转运一次,传输计数器自减一次,当传输计数器等于0,且没有自动重装时,这时无论是否触发,DMA都不会再进行转运了,此时就需要DMA_Cmd给DISABLE,关闭DMA,再为传输计数器写一个大于0的数,再DMA_CMd,给ENABLE,开启DMA,DMA才能继续工作;注意一下,写传输计数器时,必须要先关闭DMA,再进行,不能在DMA开启时,写传输计数器,这是手册里的规定。
7.1.5 DMA请求
下图就是DMA基本结构里DMA触发的部分。
上图是DMA1的请求映像, 下面是DMA的7个通道,每个通道都有一个数据选择器,可以选择硬件触发或软件触发。
EN位:EN并不是数据选择器的控制位,而且决定这个数据选择器要不要工作,EN=0,数据选择器不工作;EN=1,数据选择器工作。
软件触发(MEM2MEM位)的意思是:当M2M位等于1时,选择软件触发。
每个通道的硬件触发源都是不同的,如果需要ADC1来触发的话,那必须选择通道1;如果需要定时器2的更新事件来触发的话,那就必须选择同道2。如果使用软件触发,那每个通道都可以选择了,因为每个通道的软件触发都是一样的
通道1的硬件触发有:ADC1、定时器2的通道3和定时器4的通道1,那到底选择哪个定时器的触发源呢,这个是对应的外设是否开启了DMA输出决定的。比如要使用ADC1,那会有个库函数叫ADC_DMACmd,必须使用这个库函数开启ADC1的这一路输出,它才有效。如果想选择定时器2的通道3,那也会有个TIM_DMACmd函数,用来进行DMA输出控制。所以这三个触发源,具体使用哪一个,取决于把哪个外设的DMA输出开启了。如果3个都开启了,右边是一个或门,理论上3个硬件都可以进行触发,不过一般情况下,我们都是开启其中一个。
最后,7个触发源进入到仲裁器,进行优先级判断,最终产生内部的DMA1请求。这个优先级的判断,类似于中断的优先级,默认优先级是通道号越小,优先级越高,当然也可以在程序中配置优先级。
7.1.6 数据宽度与对齐
数据转运的两个站点,都有一个数据宽度的参数, 如果数据宽度都一样,那就是正常的一个个转运,如果数据宽度不一样呢?上表就是说明这个问题的。
以第二行为例:源宽8位,目标宽16位,那就在目标数据前面多出来的空位补0;
以第四行为例:源宽16位,目标宽8位,现象就是读B1B0,只写入B0,舍弃高位。
7.1.7 数据转运+DMA
任务:将SRAM里的数组DataA转运到另一个数组DataB中。
如何配置:
1.首先是外设站点和存储器站点的起始地址、数据宽度、地址是否自增这三个参数。
在这个任务里,外设地址显然应该填DataA数组的首地址,存储器地址给DataB的首地址。两个数组的数据宽度都是uint8_t,所以数据宽度都是按8位的字节传输。转运完DataA[0]数据之后,两个站点的数据都应该自增,都移动到下一个数据的位置。
2.方向参数
外设单元转运到存储器单元。
3.传输计数器和是否要自动重装
转运7次,所以计数器给7,自动重装暂时不需要。
4.触发选择
软件触发,因为这是存储器到存储器的数据转运。是不需要等待硬件时机的,尽快转运完就行了。5.最后,调用DMA_Cmd,给DMA使能。这样数据就从DataA转运到DataB了。转运7次之后,传输计数器自减到0,DMA停止,转运完成。
这里的转运是一种复制转运,转运完成后DataA的数据并不会消失。这个过程相当于是把DataA的数据复制到了DataB的位置。
7.1.8 ADC扫描模式+DMA
左边是ADC扫描模式的执行流程,触发一次后,7个通道依次进行AD转换, 然后转换结果都放到了ADC_DR数据寄存器里。我们要做的就是,在每个单独的通道转换完成之后,进行一次DMA数据转运,并且目的地址进行自增,这样数据地址就不会覆盖了。
所以在这里DMA的配置就是:外设地址,写入ADC_DR这个寄存器的地址;存储器的地址,可以在SRAM中定义一个数据ADValue,然后把ADValue的地址当作存储器的地址,之后数据宽度,因为ADC_DR和SRAM数组,我们要的都是uint16_t的数据,所以数据宽度都是16位的半字传输。继续是地址是否自增,这个图里显然是外设寄存器地址不自增,存储器地址自增。传输方向是外设站点到存储器站点。传输计数器,这里通道有7个,所以计数7次。计数器是否自动重装,可以看ADC的配置,ADC如果是单次扫描,那DMA的传输计数器可以不自动重装,转换一轮就停止。如果ADC是连续扫描,那DMA就可以使用自动重装,在ADC启动下一轮转换的时候,DMA也启动下一轮的转运。ADC和DMA同步工作。最后是触发选择,这里ADC_DR的值是在ADC单个通道转换完成后才会有效,所以DMA转运完成的时机需要和ADC单个通道转换完成同步,所以DMA的触发选择要选择ADC的硬件触发。
硬件触发这里需要说明一下:ADC扫描模式,在每个单独的通道转换完成后,没有任何标志位,也不会触发中断,所以程序不太好判断,某一个通道转换完成的时机是什么时候。虽然单个通道转换完成后,不产生任何标志位和中断,但是它应该会产生DMA请求,去触发DMA转运。
一般来说,DMA最常见的用途就是配合ADC的扫描模式,因为ADC的扫描模式有个数据覆盖的特征,或者说这个数据覆盖的问题是ADC固有的缺陷,这个缺陷使ADC和DMA成为了最常见的伙伴。ADC对DMA的需求是非常强烈的,像其它的一些外设,使用DMA可以提高效率,是锦上添花的操作,不使用也是可以的,顶多是损失一些性能。但是这个ADC的扫描模式,如果不使用DMA,功能都会受到很大的限制。
7.1.8 数据手册
7.2 DMA数据转运
7.2.1 硬件电路
7.2.2 软件部分
(1)复制《OLED显示屏》工程改名为《DMA数据转运》
(2)测试一下下表
|--------|-------------|------------|---------------------|
| 类型 | 起始地址 | 存储器 | 用途 |
| ROM | 0x0800 0000 | 程序存储器Flash | 存储C语言编译后的程序代码 |
| ROM | 0x1FFF F000 | 系统存储器 | 存储BootLoader,用于串口下载 |
| ROM | 0x1FFF F800 | 选项字节 | 存储一些独立于程序代码的配置参数 |
| RAM | 0x2000 0000 | 运行内存SRAM | 存储运行过程中的临时变量 |
| RAM | 0x4000 0000 | 外设寄存器 | 存储各个外设的配置参数 |
| RAM | 0xE000 0000 | 内核外设寄存器 | 存储内核各个外设的配置参数 |
①main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
uint8_t aa = 0x66;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
OLED_ShowHexNum(1,1,aa,2);
OLED_ShowHexNum(2,1,(uint32_t)&aa,8);
// 变量取地址之后,如果当作数字显示,要进行强制类型转换,如果不进行转换,就是指针跨级赋值了
while(1)
{
}
}
运行后aa这个变量存储在0x20开头的地址,说明存储在SRAM区,具体地址是多少是由编译器决定的,目前SRAM区没什么东西,所以编译器就把这个变量放在了SRAM区的第一个位置。
②main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
const uint8_t aa = 0x66;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
OLED_ShowHexNum(1,1,aa,2);
OLED_ShowHexNum(2,1,(uint32_t)&aa,8);
// 变量取地址之后,如果当作数字显示,要进行强制类型转换,如果不进行转换,就是指针跨级赋值了
while(1)
{
}
}
aa变量前加上const关键字后,表示aa是一个常量, 被const修饰的变量在程序中只能读,不能写,Flash也是只能读不能写的,所以const就和flash联系起来了。在STM32中,使用const定义的变量,是存储在Flash里面的,当然这里就不应该说是变量了,而应该说是常量。运行后地址是0x08开头的,说明常量aa被存储在Flash里面了。
③main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
const uint16_t aa = 0x66;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
OLED_ShowHexNum(1,1,aa,2);
OLED_ShowHexNum(2,1,(uint32_t)&ADC1->DR,8);
// 变量取地址之后,如果当作数字显示,要进行强制类型转换,如果不进行转换,就是指针跨级赋值了
while(1)
{
}
}
对于变量或者常量来说,它的地址是由编译器决定的,对于外设寄存器来说,它的地址是固定的,在手册里能查到,在程序里可以使用结构体很方便地访问寄存器。比如要访问ADC1的DR寄存器,就可以写ADC1->DR,这样就可以访问ADC1的DR寄存器了。
(3)添加驱动文件
(4)初始化DMA
cpp
void DMA_DeInit(DMA_Channel_TypeDef* DMAy_Channelx); //恢复缺省配置
void DMA_Init(DMA_Channel_TypeDef* DMAy_Channelx, DMA_InitTypeDef* DMA_InitStruct); //初始化
void DMA_StructInit(DMA_InitTypeDef* DMA_InitStruct); //结构体初始化
void DMA_Cmd(DMA_Channel_TypeDef* DMAy_Channelx, FunctionalState NewState); //使能
void DMA_ITConfig(DMA_Channel_TypeDef* DMAy_Channelx, uint32_t DMA_IT, FunctionalState NewState); //中断输出使能
void DMA_SetCurrDataCounter(DMA_Channel_TypeDef* DMAy_Channelx, uint16_t DataNumber); //设置当前数据寄存器,这个函数就是给传输计数器写数据的
uint16_t DMA_GetCurrDataCounter(DMA_Channel_TypeDef* DMAy_Channelx); //DMA获取当前数据寄存器,返回传输计数器的值
FlagStatus DMA_GetFlagStatus(uint32_t DMAy_FLAG); //获取标志位状态
void DMA_ClearFlag(uint32_t DMAy_FLAG); //清除标志位
ITStatus DMA_GetITStatus(uint32_t DMAy_IT); //获取中断状态
void DMA_ClearITPendingBit(uint32_t DMAy_IT); //清除中断挂起位
(5)MyDMA.c
cpp
#include "stm32f10x.h" // Device header
uint16_t MyDMA_Size; //用于接收和存储传输次数的值,方便后续给传输计数器重新赋值
/*DMA初始化函数:
第1步:RCC开启DMA的时钟
第2步:直接调用DMA_Init,初始化里面的各个参数
*/
void MyDMA_Init(uint32_t AddrA,uint32_t AddrB,uint16_t Size) //输入参数源地址、目的地址和传输次数
{
MyDMA_Size = Size;
/*第1步:RCC开启DMA的时钟*/
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE);
DMA_InitTypeDef DMA_InitStruct;
DMA_InitStruct.DMA_PeripheralBaseAddr = AddrA; //外设站点起始地址
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; //外设站点数据宽度
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Enable; //外设站点是否自增,不自增
DMA_InitStruct.DMA_MemoryBaseAddr = AddrB; //存储器站点起始地址
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; //存储器站点数据宽度
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable; //存储器站点是否自增
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC; //传输方向:外设站点到存储器
DMA_InitStruct.DMA_BufferSize = Size; //缓存区大小,其实就是传输计数器
DMA_InitStruct.DMA_Mode = DMA_Mode_Normal; //传输模式,是否使用自动重装:正常模式,不自动重装,转运一次就停止
DMA_InitStruct.DMA_M2M = DMA_M2M_Enable; //选择是否是存储器到存储器,就是选择软件触发还是硬件触发:使用软件触发
DMA_InitStruct.DMA_Priority = DMA_Priority_Medium; //优先级,按照参数要求给一个优先级即可:中等优先级
DMA_Init(DMA1_Channel1,&DMA_InitStruct);
//初始化DMA1的通道1,因为用的是软件触发,所以通道可以任意选择
DMA_Cmd(DMA1_Channel1,DISABLE);
//不让DMA初始化之后立刻进行转运,而是执行了MyDMA_Transfer函数之后再进行转运
}
/*给传输计数器重新赋值函数*/
void MyDMA_Transfer(void)
{
DMA_Cmd(DMA1_Channel1,DISABLE); //失能DMA
DMA_SetCurrDataCounter(DMA1_Channel1,MyDMA_Size);
DMA_Cmd(DMA1_Channel1,ENABLE); //重新使能DMA
/*
* @arg DMA1_FLAG_GL1: DMA1 Channel1 global flag. //全局标志位
* @arg DMA1_FLAG_TC1: DMA1 Channel1 transfer complete flag. //转运完成标志位
* @arg DMA1_FLAG_HT1: DMA1 Channel1 half transfer flag. //转运过半标志位
* @arg DMA1_FLAG_TE1: DMA1 Channel1 transfer error flag. //转运错误标志位
*/
while(DMA_GetFlagStatus(DMA1_FLAG_TC1)==RESET); //等待转运完成
DMA_ClearFlag(DMA1_FLAG_TC1); //需要手动清除标志位
}
(6)MyDMA.h
cpp
#ifndef __MYDMA_H
#define __MYDMA_H
void MyDMA_Init(uint32_t AddrA,uint32_t AddrB,uint16_t Size);
void MyDMA_Transfer(void);
#endif
(7)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "MyDMA.h"
uint8_t DataA[] = {0x01,0x02,0x03,0x04}; //要转运的源数组
//如果想把Flash的数据转运到SRAM里,前面加一个SRAM,但后面就不允许修改这个数了
uint8_t DataB[] = {0,0,0,0}; //目的数组:不写0的话,全局变量也是0
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
MyDMA_Init((uint32_t)DataA,(uint32_t)DataB,4);
OLED_ShowString(1,1,"DataA");
OLED_ShowString(3,1,"DataB");
OLED_ShowHexNum(1,8,(uint32_t)DataA,8);
OLED_ShowHexNum(3,8,(uint32_t)DataB,8);
OLED_ShowHexNum(2,1,DataA[0],2);
OLED_ShowHexNum(2,4,DataA[1],2);
OLED_ShowHexNum(2,7,DataA[2],2);
OLED_ShowHexNum(2,10,DataA[3],2);
OLED_ShowHexNum(4,1,DataB[0],2);
OLED_ShowHexNum(4,4,DataB[1],2);
OLED_ShowHexNum(4,7,DataB[2],2);
OLED_ShowHexNum(4,10,DataB[3],2);
// OLED_ShowHexNum(3,1,DataA[0],2);
// OLED_ShowHexNum(3,4,DataA[1],2);
// OLED_ShowHexNum(3,7,DataA[2],2);
// OLED_ShowHexNum(3,10,DataA[3],2);
// OLED_ShowHexNum(4,1,DataB[0],2);
// OLED_ShowHexNum(4,4,DataB[1],2);
// OLED_ShowHexNum(4,7,DataB[2],2);
// OLED_ShowHexNum(4,10,DataB[3],2);
while(1)
{
DataA[0]++;
DataA[1]++;
DataA[2]++;
DataA[3]++;
/*转运前*/
OLED_ShowHexNum(2,1,DataA[0],2);
OLED_ShowHexNum(2,4,DataA[1],2);
OLED_ShowHexNum(2,7,DataA[2],2);
OLED_ShowHexNum(2,10,DataA[3],2);
OLED_ShowHexNum(4,1,DataB[0],2);
OLED_ShowHexNum(4,4,DataB[1],2);
OLED_ShowHexNum(4,7,DataB[2],2);
OLED_ShowHexNum(4,10,DataB[3],2);
Delay_ms(1000);
MyDMA_Transfer(); //开始转运
/*转运后*/
OLED_ShowHexNum(2,1,DataA[0],2);
OLED_ShowHexNum(2,4,DataA[1],2);
OLED_ShowHexNum(2,7,DataA[2],2);
OLED_ShowHexNum(2,10,DataA[3],2);
OLED_ShowHexNum(4,1,DataB[0],2);
OLED_ShowHexNum(4,4,DataB[1],2);
OLED_ShowHexNum(4,7,DataB[2],2);
OLED_ShowHexNum(4,10,DataB[3],2);
Delay_ms(1000);
}
}
7.3 DAM+AD多通道
7.3.1 硬件电路
7.3.2 软件部分
(1)复制《AD多通道》工程并改名为《DMA+AD多通道》
(2)AD.c
cpp
#include "stm32f10x.h" // Device header
uint16_t AD_Value[4];
/*初始化函数:
第1步:开启RCC时钟,包括ADC和GPIO的时钟,ADCCLK的分频器,也需要配置一下;
第2步:配置GPIO,把需要用的GPIO配置成模拟输入的模式;
第3步:配置多路开关,把最左边的通道接入右边的规则组列表里,使用多个通道,再触发转换函数里实现;
第4步:配置ADC转换器,库函数里使用结构体配置,包括ADC是单次转换还是连续转换、
扫描还是非扫描、有几个通道、触发源是什么、数据对齐是左对齐还是右对齐
如果需要模拟看门狗,会有几个函数用来配置阈值和监测通道的;如果想开启中断,那就在中断输出控制
里用ITConfig函数开启对应的中断输出,然后再在NVIC里,配置中断优先级,这样就能触发中断了。
第5步:DMA初始化
第6步:开关控制,调用一下ADC_Cmd函数,开启ADC,这样ADC就配置完成了。开启ADC之后,
还可以对ADC进行校准、这样可以减小误差。
在ADC工作的时候,如果想要触发转换,会有函数可以触发;如果想读取结果,也会有函数可以读取结果。
*/
void AD_Init(void)
{
/*第1步:开启RCC时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1,ENABLE); //开启ADC1的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //要使用PA0口采样
RCC_ADCCLKConfig(RCC_PCLK2_Div6); //配置APB2六分频,分频后ADCCLK=72MHz/6=12MHz
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1,ENABLE); //开启DMA时钟
/*第2步:配置GPIO*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
/*配置GPIO口是模拟输入模式,AIN模式下,GPIO是无效的,断开GPIO口,防止GPIO口的输入输出对模拟电压造成干扰;
所以AIN模式是ADC的专属模式*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_SetBits(GPIOA,GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3);
/*ADC_Channel_0:通道0,1:规则组序列里的次序,ADC_SampleTime_55Cycles5:采样时间55.5个ADCCLK的周期*/
/*第4步:配置ADC转换器*/
ADC_InitTypeDef ADC_InitStruct;
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; //配置ADC工作在独立模式
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; //配置ADC数据对齐为右对齐
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; //配置不使用外部触发,使用内部软件触发
ADC_InitStruct.ADC_ContinuousConvMode = ENABLE; //配置转换模式为循环转换
ADC_InitStruct.ADC_ScanConvMode = ENABLE; //配置扫描模式为扫描模式
ADC_InitStruct.ADC_NbrOfChannel = 4; //目前是4个通道扫描模式,整个列表只有第一个序列有用
ADC_Init(ADC1,&ADC_InitStruct);
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); // 以变量形式传递通道
/*第5步:DMA初始化*/
DMA_InitTypeDef DMA_InitStruct;
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; //外设站点起始地址,ADC_DR的地址
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; //外设站点数据宽度,我们想要DR寄存器低16位的数据
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable; //外设站点是否自增,不自增
DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)&AD_Value; //存储器站点起始地址
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; //存储器站点数据宽度
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable; //存储器站点是否自增
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC; //传输方向:外设站点到存储器
DMA_InitStruct.DMA_BufferSize = 4; //缓存区大小,其实就是传输计数器
DMA_InitStruct.DMA_Mode = DMA_Mode_Circular; //模式,是否使用自动重装:循环模式
DMA_InitStruct.DMA_M2M = DMA_M2M_Disable; //选择是否是存储器到存储器,就是选择软件触发还是硬件触发:不使用软件触发,需要硬件触发
DMA_InitStruct.DMA_Priority = DMA_Priority_Medium; //优先级,按照参数要求给一个优先级即可:中等优先级
DMA_Init(DMA1_Channel1,&DMA_InitStruct);
//初始化DMA1的通道1,因为用的是软件触发,这里不能任意选择ADC1的硬件触发只接到了DMA1的通道1上
DMA_Cmd(DMA1_Channel1,ENABLE);
ADC_DMACmd(ADC1,ENABLE); //开启ADC到DMA的输出
/*第6步:开关控制,开启ADC的电源*/
//不让DMA初始化之后立刻进行转运,而是执行了MyDMA_Transfer函数之后再进行转运
ADC_Cmd(ADC1,ENABLE);
ADC_ResetCalibration(ADC1); //复位校准
ADC_GetResetCalibrationStatus(ADC1); //返回复位校准的状态
while(ADC_GetResetCalibrationStatus(ADC1) == SET); //读取这一位,如果它是1,那就需要一直空循环等待;如果它变为0,那就说明复位校准完成,可以跳出等待了
ADC_StartCalibration(ADC1); //启动校准,之后内部电路会自动校准
ADC_GetCalibrationStatus(ADC1); //获取校准状态
while(ADC_GetCalibrationStatus(ADC1) == SET); //判断校准状态是否完成
ADC_SoftwareStartConvCmd(ADC1,ENABLE); //软件触发转换,启动,使用连续转换的时候放到初始化函数里就可以了,这样后面的AD_GetValue就不需要了
}
//void AD_GetValue(void)
//{
// DMA_Cmd(DMA1_Channel1,DISABLE); //失能DMA
// DMA_SetCurrDataCounter(DMA1_Channel1,4);
// DMA_Cmd(DMA1_Channel1,ENABLE); //重新使能DMA
// //因为现在是单次模式,所以还需要软件触发一下ADC开始
//
// while(DMA_GetFlagStatus(DMA1_FLAG_TC1)==RESET); //等待转运完成
// DMA_ClearFlag(DMA1_FLAG_TC1); //需要手动清除标志位
//}
(3)AD.h
cpp
#ifndef __AD_H
#define __AD_H
extern uint16_t AD_Value[4];
void AD_Init(void);
//void AD_GetValue(void);
#endif
(4)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "AD.h"
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
AD_Init();
OLED_ShowString(1,1,"AD0:");
OLED_ShowString(2,1,"AD1:");
OLED_ShowString(3,1,"AD2:");
OLED_ShowString(4,1,"AD3:");
while(1)
{
// AD_GetValue();
OLED_ShowNum(1,5,AD_Value[0],4);
OLED_ShowNum(2,5,AD_Value[1],4);
OLED_ShowNum(3,5,AD_Value[2],4);
OLED_ShowNum(4,5,AD_Value[3],4);
Delay_ms(100);
}
}
第08章 USART串口
8.1 USART串口协议
8.1.1 通信接口
在STM32中,集成了很多用于通信的外设模块,比如下表所列。
通信的目的:将一个设备的数据传送到另一个设备,扩展硬件系统。
针对STM32内部没有的功能,比如蓝牙无线遥控的功能、陀螺仪加速度计测量姿态的功能,这些功能STM32没有,所以只能外挂芯片来完成。外挂的芯片,它的数据都在STM32外面。STM32获取这些数据就需要我们在这些设备之间,连接上一根或多跟通信线,通过通信线路发送或者接收数据,完成数据交换,从而实现控制外挂模块和读取1外挂模块数据的目的。
通信协议:制定通信的规则,通信双方按照协议规则进行数据收发。
|--------|-------------------|--------|--------|--------|--------|
| 名称 | 引脚 | 双工 | 时钟 | 电平 | 设备 |
| USART | TX、RX | 全双工 | 异步 | 单端 | 点对点 |
| I2C | SCL、SDA | 半双工 | 同步 | 单端 | 多设备 |
| SPI | SCLK、MOSI、MISO、CS | 全双工 | 同步 | 单端 | 多设备 |
| CAN | CAN_H、CAN_L | 半双工 | 异步 | 差分 | 多设备 |
| USB | DP、DM | 半双工 | 异步 | 差分 | 点对点 |
(1) USART
TX:Transmit Exchange,数据发送脚
RX:Receive Exchange,数据接收脚
(2) I2C
SCL:Serial Clock,时钟
SDA:Serial Data,数据
(3)SPI
SCLK:Serial Clock,时钟
MOSI:Master Output Slave Input,主机输出数据脚
MISO:Master Input Slave Output,主机输入数据脚
CS:Chip Select,片选,用于指定通信的对象
(4)CAN
CAN_H、CAN_L:两个引脚是差分数据脚,用两个引脚表示一个差分数据。
(5)USB
差分数据脚。
DP(D+):Data Positive
DM(D-):Data Minus
(6)全双工
指通信双方能够同时进行双向通信,一般来说,全双工的通信都有两根通信线,比如串口,一根TX发送,一根RX接收;SPI,一根MOSI发送,一根MISO接收。发送线路和接收线路互不影响,全双工。
(7)半双工
I2C、CAN、USB都只有一根数据线,CAN和USB两根差分线也是组合成为一根数据线的,就是半双工。
(8)单工
数据只能从一个设备到另一个设备,而不能反着来,比如把串口的RX去掉,就退化成单工了。
(9)时钟
发送一个波形,高电平然后低电平,接收方怎么知道你是1、0还是1、1、0、0呢?这就需要有一个时钟信号来告诉接收方,什么时候需要采集数据,时钟特性分为同步和异步。I2C和SPI有单独的时钟线,所以它们是同步的,接收方可以在时钟信号的指引下进行采样。剩下的串口、CAN和USB没有时钟线,所以需要双方约定一个采样频率,这就是异步通信,并且还要加一些帧头帧尾等等,进行采样位置的对齐。
(10)电平特性
USART、I2C、SPI都是单端信号,也就是它们引脚的高低电平都是对GND的电压差,所以单端信号通信的双方必须要共地,就是把GND接在一起,所以它们通信的引脚,还应该加一个GND引脚,不接GND是没法通信的。
CAN和UBS是差分信号,是靠两个差分引脚的电压差来传输信号的,是差分信号,在通信的时候可以不需要GND。不过USB协议里面也有一些地方需要单端信号,所以USB还是需要共地的。使用差分信号可以极大地提高抗干扰特性,所以差分信号一般传输速度和距离都会非常高,性能也是很不错的。
(11)设备特性
串口和USB属于点对点的通信,中间三个是可以在总线上挂载多个设备的。点对点通信就相当于老师找你去办公室谈话,只有两个人,直接传输数据就可以了。多设备就相当于老师在教室里,面对所有同学谈话,需要有一个寻址的过程,以确定通信的对象。
8.1.2 串口通信
串口是一种应用十分广泛的通讯接口,串口成本低、容易使用、通信线路简单,可实现两个设备的互相通信。
在单片机领域,串口其实是一种最简单的通信接口,它的协议相比I2C、SPI等,已经是非常简单的了,而且一般单片机,它里面都会有串口的硬件外设,使用也是非常方便的。一般串口都是点对点的通信,所以是两个设备之间的互相通信。
单片机的串口可以使单片机与单片机、单片机与电脑、单片机与各式各样的模块互相通信,极大地扩展了单片机的应用范围,增强了单片机系统的硬件实力。
单片机和电脑通信,是串口的一大优势,可以接电脑屏幕、非常适合调试程序、打印信息。像I2C、SPI这些,一般都是芯片之间的通信,不会接在电脑上。
上图第一个是USB转串口模块, 上面有个芯片,型号是CH340,这个芯片可以把串口协议转换为USB协议。它一边是USB口,可以插在电脑上,另一边是串口的引脚,可以和支持串口的芯片接在一起。这样就能实现串口和电脑的通信了。
中间这个图是一个陀螺仪传感器的模块,可以测量角速度、加速度这些姿态参数,左右各有4个引脚、一边是串口的引脚、另一边是I2C的引脚。
最右边这个图是蓝牙串口模块,下面4个脚是串口通信的引脚,上面的芯片可以和手机互联,实现手机遥控单片机的功能。那这些就是串口通信,和一些使用串口通信的模块。
8.1.3 硬件电路
简单双向串口通信有两根通信线(发送端TX和接收端RX)。
复杂一点的串口通信还有其它的引脚,比如时钟引脚、硬件流控制的引脚。这些引脚STM32的串口也有,不过我们最常用的还是简单的串口通信,也就是VCC、GND、TX、RX这4个引脚。
TX与RX要交叉连接。
TX是发送,RX是接收。
当只需单向的数据传输时,可以只接一根通信线。
当电平标准不一致时,需要加电平转换芯片。
串口也是有很多电平标准的,像我们这种直接从控制器里出来的信号,一般都是TTL电平,相同的电平才能互相通信。不同的电平信号,需要加一个电平转换芯片,转接一下。
TX和RX是单端信号,它们的高低电平都是相对于GND的,所以严格上来说GND也算是通信线, 所以串口通信的TX、RX、GND是必须要接的。上面的VCC,如果两个设备都有独立供电,那VCC可以不接,如果其中一个设备没有供电,比如这里设备1是STM32,设备2是蓝牙串口模块,那就需要把STM32的VCC和蓝牙串口的VCC接在一起,STM32通过这根线,向右边的子模块供电。供电的电压也需要注意一下,要按照子模块的要求来。
8.1.4 电平标准
电平标准是数据1和数据0的表达方式,是传输线缆中人为规定的电压与数据的对应关系,串口常用的电平标准有如下三种:
TTL电平:+3.3V或+5V表示1,0V表示0;
单片机这种低压小型设备,使用的都是TTL电平。如果做设备需要其他的电平,那就再加电平转换芯片就可以了。
SS232电平:-3~-15V表示1,+3~+15V表示0;
RS232电平一般在大型的机器上使用,由于环境可能会比较恶劣,静电干扰比较大,所以这里电平的电压都比较大,而且允许波动的范围很大。
RS485电平:两线压差+2~+6V表示1,-2~-6V表示0(差分信号)。
这里电平参考是两线压差,所以RS485的电平是差分信号,差分信号抗干扰能力非常强。使用RS485电平标准,通信距离可以达到上千米。而上面两种电平,最远距离只能达到几十米,再远就传不了了。
在软件层面,它们都属于串口,所以程序并不会有什么变化。
8.1.5 串口参数及时序
波特率:串口通信的速率;
串口一般是使用异步通信,所以双方需要约定一个通信速率,比如每个1s发送一位,那就得每隔1s接收一位,如果接收快了,就会重复接收某些位;如果接收慢了,就会漏掉某些位,所以说发送和接收必须约定好速率。这个速率参数,就是波特率。波特率本来的意思是,每秒传输码元的个数,单位是码元/s,或者直接叫波特(Baud)。另外还有一个速率表示,叫比特率,比特率的意思是每秒传输的比特数。单位是bit/s或者叫bps。在二进制调制的情况下,一个码元就是一个bit,此时波特率就等于比特率。像我们单片机的串口通信,基本都是二进制调制,也就是高电平表示1、低电平表示0、1位就是1bit,所以说这个串口的波特率经常和比特率混用。如果是多进制调制,那波特率和比特率就不一样了。反映到波形上,比如我们双方规定波特率是1000bps,那就表示1s要发1000位,每一位的时间就是1ms,发送方每隔1ms发送一位,接收方每隔1ms接收一位。这就是波特率,它决定了每隔多久发送一位。
起始位:标志一个数据帧的开始,固定为低电平;
首先,串口的空闲状态是高电平,也就是没有数据传输的时候,引脚必须要置高电平,作为空闲状态,然后需要传输的时候,必须要先发送一个起始位,这个起始位必须是低电平,来打破空闲状态的高电平,产生一个下降沿。这个下降沿,就告诉接收设备,这一帧的数据要开始了。如果没有起始位,那当我发送8个1的时候,数据线一直处于高电平,没有任何波动,这样接收方无法知道我们发送数据了。所以这里必须要有一个固定为低电平的起始位,产生下降沿,来告诉接收设备,我要发送数据了。同理在一个数据发送完成后,必须要有一个停止位。
数据位:数据帧的有效载荷,1为高电平,0为低电平,低位先行;
比如想要发送一个字节是0x0F,那就把0x0F转换成二进制,就是0000 1111。然后低位先行,所以数据要从低位开始发送,也就是11110000这样,依次放在发送引脚上。
数据位有两种表示方法,一种是把校验位作为数据位的一部分,就像下图所示的时序一样,分为8位数据和9位数据,其中9位数据就是8位有效载荷和1位置校验位。另一种就是把校验位和数据位独立开,数据位就是有效载荷,校验位就是独立的1位。
校验位:用于数据验证,根据数据位计算得来;
这里串口使用的是一种叫做奇偶校验的数据验证方法,奇偶校验可以判断数据传输是不是出错了,如果数据出错了,可以选择丢弃或者要求重传。校验可以选择3种方式,无校验、奇校验和偶校验。无校验就是不需要校验位,波形就是下图左边这个。奇校验和偶校验的波形就是下图右边这个,起始位、数据位、校验位、停止位4个部分。如果使用了奇校验,那么包括校验位在内的9位数据会出现奇数个1。比如如果传输0000 1111,目前总共4个1,是偶数个,那么校验位就需要再补一个1,连通校验位就是0000 11111,总共5个1,保证1为奇数;如果数据是0000 1110,此时3个1,是奇数个,那么校验位就补一个0,连同校验位就是0000 11100,总共3个1,1的个数为奇数。发送方在发送数据后,会补一个校验位,保证1的个数为奇数;接收方在接收数据后,会验证数据位和校验位,如果1的个数还是奇数,就认为数据没有出错;如果在传输中、因为干扰、有一位由1变为0或者由0变成1了。那么整个数据的奇偶特性就会变化,接收方一验证,发现1的个数不是奇数,那就认为传输出错,就可以选择丢弃或者要求重传。这就是奇校验的差错控制方法。若果选择双方约定偶校验,那就是保证1的个数是偶数,检验方法也是一样的道理,但是奇偶校验的检出率并不是很高。比如如果有两位数据同时出错,奇偶特性不变,就校验不出来了。所以奇偶校验只能保证一定程度上的数据校验。如果想要更高的检出率,可以了解一下CRC校验。
停止位:用于数据帧间隔,固定为高电平。
同时这个停止位,也是为下一个起始位做准备的,如果没有停止位,那当我最后一位数据为0的时候,下次再发送新的一帧,就没办法产生下降沿了。这就是停止位的作用。起始位固定为0,产生下降沿、表示传输开始;停止位固定为1,把引脚恢复成高电平,方便下一次的下降沿,如果没有数据了,正好引脚也为高电平,代表空闲状态。
上面两个图就是串口发送一个字节的格式, 这个格式是串口协议规定的.串口中每一个字节都装载在一个数据帧里面,每个数据帧都有起始位、数据位和停止位组成。数据位有8个,代表一个字节的8位。在右边的数据帧里面,还可以在数据位的最后,加一个奇偶校验位,这样数据位总共就是9位,其中有效载荷是前8位、代表1个字节,校验位跟在有效载荷后面,占1位。这就是串口数据帧的整体结构。
8.1.6 串口通信实际波形
8.2 USART串口外设
8.2.1 USART简介
USART(Universal Synchronous/Asynchronous Receiver/Transmitter)通用同步/异步收发器;
UART:异步收发器。
一般我们串口很少使用同步功能,所以USART和UART使用起来没有什么区别。STM32的USART同步模式,只是多了个时钟输出而已,它只支持时钟输出,不支持时钟输入,所以这个同步模式更多的是为了兼容别的协议或者特殊用途而设计的。并不支持两个USART之间进行同步通信。所以我们学习串口,主要还是异步通信。
USART是STM32内部集成的硬件外设,可根据数据寄存器的一个字节数据自动生成数据帧时序,从TX引脚发送出去,也可自动接收RX引脚的数据帧时序,拼接为一个字节数据,存放在数据寄存器里;
USART外设就是串口通信的硬件支持电路。USART大体可以分为发送和接收两部分,发送部分就是将数据寄存器的一个字节数据,自动转换为协议规定的波形,从TX引脚发送出去,接收部分就是自动接收RX引脚的波形,按照协议规定,解码一个字节数据,存放在数据寄存器里,这就是USART电路的功能。当我们配置好了USART,直接读写数据寄存器,就能自动发送和接收数据了。
自带波特率发生器,最高达4.5Mbits/s;
这个波特率发生器是用来配置波特率的,它其实就是一个分频器,比如我们APB2总线给个72MHz的频率,然后波特率发生器进行一个分频,得到我们想要的波特率时钟。最后在这个时钟下,进行收发,就是我们指定的通信波特率。
可配置数据位长度(8/9)、停止位长度(0.5/1/1.5/2);
这些就是STM32 SUART支持配置的参数了。
数据位长度就是上图所示参数,有8位和9位,9位是包含奇偶校验位的长度。一般不需要校验就选8位,需要校验就选9位。
在进行连续发送时,停止位长度决定了帧的间隔。我们最常用的是1位停止位,其它的很少用。
可选校验位(无校验/奇校验/偶校验);
最常用的是无校验。
以上所有的参数,都是可以通过配置寄存器来完成的。使用库函数就更简单了,直接给结构体赋值就行。
串口参数我么最常用的就是波特率(9600或115200)、数据位8位、停止位1位、无校验。一般我们都选择这种常用的参数。
支持同步模式、硬件流控制、DMA、智能卡、IrDA、LIN;
同步模式就是多了个时钟CLK的输出。
硬件流控制:比如这个A设备有个TX通向B设备的RX发送数据,A设备一直在发,发的太快,B处理不过来,如果没有硬件流控制,那B就只能抛弃新数据或者覆盖原数据了。如果有硬件流控制,在硬件电路上,会多出一根线,如果B没准备好接收,就置高电平,如果准备好了,就置低电平,A接收到了B反馈的准备信号,就只会在B准备好的时候,才发数据。如果B没准备好,那数据就不会发送出去。这就是硬件流控制,可以防止因为B处理慢而导致数据丢失的问题。硬件流控制,STM32也是有的,不过我们一般不用。
DMA:这个串口支持DMA数据转运,如果有大量的数据进行收发,可以使用DMA转运数据,减轻CPU的负担。
智能卡、IrDA、LIN:这些是其它的一些协议。因为这些协议和串口非常的像,所以STM32就对USART加了一些小改动,就能兼容这么多的协议了。不过我们一般不用。智能卡应该是跟我们刷的饭卡、公交卡这些有关的;IrDA是用于红外通信的,这个红外通信就是一个红外发光管,另一边是红外接收管,靠闪烁红外光通信,并不是遥控器的那个红外通信,所以并不能模拟遥控器。LIN是局域网的通信协议。
STM32F103C8T6 USART资源: USART1、 USART2、 USART3。
总共三个独立的USART外设,可以挂载很多串口设备。USART1是APB2总线上的设备,剩下的收拾APB1总线上的设备,开启时钟的时候注意一下。在使用的时候,挂载哪个总线,影响并不是很大。
8.2.2 USART框图
左上角的引脚部分有TX和RX,这两个就是发送和接收引脚;下面的SW_RX、IRDA_OUT/IN这些就是智能卡和IrDA通信的引脚,我们不用这些引脚,所以这些引脚可以不用管。右边的IrDA、SIR这些东西也都不用管。引脚这块,TX发送脚、就是从发送移位寄存器接出去的,RX接收脚,就是从接收移位寄存器接出去的。
右上角整体这一块就是串口的数据寄存器了,发送或接收的字节就存在这里。上面有两个数据寄存器,一个是发送数据寄存器TDR(Transmit DR),另一个是接收数据寄存器RDR(Receive DR)。这两个寄存器占用同一个地址,就跟51单片机串口的SBUF寄存器一样。在程序上只表现为一个寄存器,就是数据寄存器DR(Data Register),但实际硬件中,是分成了两个寄存器,一个用于发送TDR,一个用于接收RDR,TDR是只写的,RDR是只读的。当进行写操作时,数据就写到TDR;当进行读操作时,数据就是从RDR读出来的。下面是两个移位寄存器,一个用于发送,一个用于接收,发送移位寄存器的作用就是把一个字节的数据一位一位地移出去,正好对应串口协议地波形地数据位。发送数据寄存器(TDR)和发送移位寄存器是怎么工作的呢?举个例子:比如在某个时刻给TDR写入了0x55这个数据,在寄存器里就是二进制存储,0101 0101,那么此时硬件检测到写入的数据,它就会检查当前移位寄存器是不是有数据正在移位,如果没有这个0101 0101就会立刻全部移动到发送移位寄存器,准备发送。当数据从TDR移动到移位寄存器时,会置一个标志位,叫TXE(TX Empty),发送寄存器空。我们检查这个标志位,如果置1了,就可以在TDR写入下一个数据了;当TXE标志位置1时,数据其实还没有发送出去,只要数据从TDR转移到发送移位寄存器了,TXE就会置1,我们就可以写入新的数据了,然后发送移位寄存器就会在下面这里的发生器控制的驱动下,向右移位,然后一位一位地,把数据输出到TX引脚,这里是向右移位地,所以正好和串口协议规定的低位先行,是一致的,当数据移位完成后,新的数据就会再次自动地从TDR转移到发送移位寄存器里来,如果当前移位寄存器移位还没有完成,TDR的数据就会进行等待,一但移位完成,就会立刻转移过来,有了TDR和移位寄存器的双重缓存,可以保证连续发送数据的时候,数据帧之间不会有空闲,提高了工作效率。简单来说,就是数据一旦从TDR转移到了移位寄存器里,管你有没有完成,我就立刻把下一个数据放在TDR等着,一旦移完了,新的数据就会立刻跟上。接收端这里数据也是类似的,数据从RX引脚通向接收移位寄存器,在接收器控制的驱动下,一位一位地读取RX电平,先放在最高位,然后向右移,移位8次之后就能接收一个字节了,同样,因为串口协议规定是低位先行,所以接收移位寄存器是从高位往低位这个方向移动的,之后,当一个字节移位完成之后,这一个字节的数据,就会整体地一下子转移到接收数据寄存器地RDR里,在转移地过程中,也会置一个标志位,叫RXNE(RX Not Empty),接收数据寄存器非空,当我们检测到RXNE置1之后,就可以把数据读走了,同样这里也是两个寄存器进行缓存,当数据从移位寄存器转移到RDR时,就可以直接移位接收下一帧数据了。这就USART外设整个的工作流程。
发送器控制是用来控制发送移位寄存器的工作的;接收器控制,用来控制接收移位寄存器的工作。左边有一个硬件数据流控,也就是硬件流控制,简称流控。流控有两个引脚,一个是nRTS,一个是nCTS;nRTS(Request To Send)是请求发送,是输出脚,也就是告诉别人,我当前能不能接收,nCTS(Clear To Send)是清除发送,是输入脚,也就是用于接收别人nRTS的信号的,前面加个n意思是低电平有效。这两个引脚是如何工作的呢?首先得找另一个支持流控得传偶,它的TX接到我的RX,然后我的RTX要输出一个能不能接收得反馈信号,接到对方得CTS,当我能接收的时候,RTS就置低电平,请求对方发送,对方得CTS接收到之后,就可以一直发,当我处理不过来时,比如接收数据寄存器我一直没有读,又有新的数据过来了,现在就代表我没有及时处理,那RTS就会置高电平,对方CTS接收到之后,就会暂停发送,直到这里接收数据寄存器被读走,RTS置低电平,新的数据才会继续发送。那反过来,当我的TX给对方发送数据时,我们的CTS就要接到对方的RTS,用于判断对方,能不能接收,TX和CTS是一对的,RX和RTS是一对的,CTS和RTS也要交叉连接,这就是流控的工作模式。当然我们一般不使用流控。
SCLK控制用于产生同步的时钟信号,它是配合发送移位寄存器输出的,发送移位寄存器每移位依次,同步时钟电平就跳变一个周期,时钟告诉对方,我移出去一位数据了,你看要不要我这个时钟信号来指导你接收一下。当然这个时钟只支持输出,不支持输入。所以两个USART之间,不能实现同步的串口通信。那这个时钟信号有什么用呢?第一个用途就是兼容别的协议,比如串口加上时钟之后,就跟SPI协议特别像,所以有了时钟输出的串口,就可以兼容SPI,另外这个时钟也可以做自适应波特率,比如接收设备不确定发送设备给的什么波特率,那就可以测量一下这个时钟的周期,然后再计算得到波特率,不过就需要另外写程序来实现这个功能了。这个时钟功能我们一般不用。
唤醒单元:这部分的作用是实现串口挂载多设备,串口一般是点对点的通信,点对点只支持两个设备互相通信,想法数据直接发就行;而多设备,在一条总线上,可以接多个从设备,每个设备分配一个地址,我想跟某个设备通信,就先进行寻址,确定通信对象,再进行数据首收发。这个唤醒单元就可以用来实现多设备的功能,在这里可以给串口分配一个地址,当发送指定地址时,此设备唤醒开始工作,发送别的都设备地址时,别的设备就唤醒工作,这个设备没受到地址,就会保持沉默,这样就可以实现多设备的串口通信了。这部分功能我们一般不用。
中断控制:中断申请位,就是状态寄存器这里的各种标志位,状态寄存器这里,有两个标志位比较重要,一个是TXE发送寄存器空,另一个是RXNE接收寄存器非空,这两个是判断发送状态和接收状态的必要标志位。中断输出控制这里,就是配置中断能不能通向NVIC
最下面是波特率发生器部分,波特率发生器其实就是分频器,APB时钟进行分频,得到发送和接收移位的时钟。这里时钟输入是fPCLKx(x=1或2),USART1挂载再APB2上,所以就是PCLK2的时钟,一般是72M,其它的USART都挂载在APB1,所以PCLK1的时钟,一般是36M。之后这个时钟进行分频,除以一个USARTDIV的分频系数,USARTDIV里面就是右边这样,是一个数值,并且分为了整数部分和小数部分,因为有些波特率,用72M除一个整数的话,可能除不尽,会有误差,所以这里分频系数是支持小数点后4位的,分频就更加精准,之后分频完之后,还要再除一个16,得到发送器时钟和接收器时钟,通向控制部分。然后右边,如果TE为1,就是发送器使能了,发送部分的波特率就有效,如果RE(RX Enable)为1,就是接收器使能了,接收部分的波特率就有效。
8.2.3 USART基本结构
最左边是波特率发生器,用于产生约定的通信频速率,时钟来源是PCLK2或1,经过波特率发生器分频后,产生的时钟通向发送控制器和接收控制器。发送控制器和接收控制器用来控制发送移位和接受移位,之后由发送数据寄存器和发送移位寄存器这两个寄存器的配合,将数据一位一位的移出去,通过GPIO口的复用输出,输出到TX引脚,产生串口协议规定的波形,这里画了几个右移的符号,就是代表这个移位寄存器是往右移的,是低位先行,当数据由数据寄存器转移到移位寄存器时,会置一个TXE的标志位,我们判断这个标志位,就可以知道是不是可以写下一个数据了。接收部分也是类似,RX引脚的波形,通过GPIO输入,在接收控制器的控制下,一位一位地移入接收移位寄存器,这里画了右移的符号,也是右移的,因为是低位先行,所以要从左边开始移进来,移完一帧数据后,数据就会统一转运到接收数据寄存器,在转移的同时,置一个RXNE标志位,我们检查这个标志位,就可以知道是不是收到数据了。同时这个标志位也可以去申请中断,这样就可以在收到数据时,直接进入中断函数,然后快速地读取和保存数据。右边实际上有4个寄存器,但是在软件层面,只有1个DR寄存器可以供我们读写,写入DR时,数据走上面这条路,进行发送,读取DR时,数据走下面这条路,进行接收。这就是USART进行串口数据收发的过程。右下角是一个开控制,就是配置完成之后,用Cmd开启一下外设。
8.2.4 数据帧
8.2.5 起始位侦测
这个图以及下一个图展示的是USART电路输入数据的一些策略。 对于串口来说,根据前面的介绍,串口的输出TX应该是比输入RX简单得多,输出就定时翻转TX引脚高低电平就行了。输入就复杂一些,不仅要保证输入得采样频率和波特率一致,还要保证每次输入采样的位置,要正好处于每一位的正中间,只有在每一位的正中间采样,这样的高低电平读出来,才是最可靠的,如果采样点过去考前或靠后,那有可能高低电平还正在翻转,电平还不稳定,或者稍有误差,数据就采样错了。另外,输入最好还要对噪声有一定的判断能力,如果是噪声,最好能置个标志位提醒我一下,这些就是输入数据所面临的问题。
上图:当输入电路侦测到一个数据帧的起始位之后,就会以波特率的频率,连续采样一帧数据,同时,从起始位开始,采样位置就要对齐到位的正中间,只要第一位对齐了,后面的肯定都是对齐的。为了实现这样的功能,首先输入的这部分电路对采样时钟进行了细分,它会以波特率的16倍频率进行采样,也就是在一位的时间里,可以进行16次采样。它的策略是,最开始空闲状态高电平,那采样就一致是1,在某一个位置,突然采样一个0,那说明在这两次采样之间,出现了下降沿,如果没有任何噪声,那之后应该就是起始位了。在起始位,会进行连续16次采样,没有噪声的话,这16次采样,肯定都是0,这没问题。但是实际电路还是会存在一些噪声的,所以这里即使出现下降沿了,后续也要再采样几次,以防万一。根据手册描述,这个接收电路还会在下降沿之后的第3次、5次、7次进行一批采样,在第8次、9次、10次再进行一批采样,且这两批采样,都要要求每3位里面至少有2个0,如果没有噪声,那肯定全是0,满足情况。如果有一些轻微的噪声,导致这里3位里面,有两个0,另一个是1,那也算是检测到了起始位,但是在状态寄存器里会置一个NE(Noise Error),噪声标志位,就是提醒你一下,数据我是收到了,但是有噪声,你悠着点用。如果这三位里面,只有1个0,那就不算检测到了起始位,可能前面那个下降沿是噪声导致的,这是电路就忽略前面的数据,重新开始捕捉下降沿。这就是STM32的串口,在接收过程中,对噪声的处理。如果通过了这个起始位侦测,那接收状态就由空闲,变为接收起始位,同时,第8、9、10次采样的位置,就正好是起始位的正中间,之后接收数据位时,就都在第8、9、10次进行采样,这样就能保证采样位置在位的正中间了。这就是起始位侦测和采样位置对齐的策略。
8.2.6 数据采样
在一个数据位,有16个采样时钟,由于起始位侦测已经对齐了采样时钟,所以这里就直接在第8、9、10次采样数据位。为了保证数据的可靠性、这里连续采样3次,没有噪声的理想情况下,这3次肯定全为1或者全为0,全为1就认为收到了1,全为0就认为收到了0。如果有噪声,导致3次采样不是全为1或者全为0,那就按照2:1的规则来。在这种情况下,噪声标志位NE也会置1,告诉你,我收到数据了,但是有噪声,悠着点用。
8.2.7 波特率发生器
波特率发生器就是分频器。
发送器和接收器的波特率由波特率寄存器BRR里的DIV确定;
DIV分为整数部分和小数部分,可以实现更细腻的分频。
计算公式:波特率 = fPCLK2/1 / (16 * DIV)。
举例:如果我要配置USART1为9600的波特率,那如何配置这个BRR寄存器呢?带入公式:
转换成二进制后,最终写入寄存器的就是0001 1101 0100.1100。
使用库函数配置的话,直接写波特率就可以了。
8.3 串口发送
8.3.1 硬件电路
跳线帽插在VCC和3V3这两个引脚上,选择通信的TTL电平为3.3V。
8.3.2 驱动安装
端口目录下,有这个CH340的驱动, 如果出现了COM号,并且前面图标没有感叹号,那就证明串口驱动没有问题,否则需要安装一下串口模块的驱动。
8.3.3 软件部分
(1)复制《OLED显示屏》工程并改名为《串口发送》
(2)添加驱动文件
(3)相关库函数
cpp
void USART_DeInit(USART_TypeDef* USARTx);
void USART_Init(USART_TypeDef* USARTx, USART_InitTypeDef* USART_InitStruct);
void USART_StructInit(USART_InitTypeDef* USART_InitStruct);
/*下面两个时钟是用来配置同步时钟输出的,包括时钟是不是要输出,时钟的极性相位等参数*/
void USART_ClockInit(USART_TypeDef* USARTx, USART_ClockInitTypeDef* USART_ClockInitStruct);
void USART_ClockStructInit(USART_ClockInitTypeDef* USART_ClockInitStruct);
void USART_Cmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_ITConfig(USART_TypeDef* USARTx, uint16_t USART_IT, FunctionalState NewState);
void USART_DMACmd(USART_TypeDef* USARTx, uint16_t USART_DMAReq, FunctionalState NewState);
/*开启USART到DMA的触发通道*/
void USART_SetAddress(USART_TypeDef* USARTx, uint8_t USART_Address);
void USART_WakeUpConfig(USART_TypeDef* USARTx, uint16_t USART_WakeUp);
void USART_ReceiverWakeUpCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_LINBreakDetectLengthConfig(USART_TypeDef* USARTx, uint16_t USART_LINBreakDetectLength);
void USART_LINCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_SendData(USART_TypeDef* USARTx, uint16_t Data); // 发送数据
uint16_t USART_ReceiveData(USART_TypeDef* USARTx); // 接收数据
void USART_SendBreak(USART_TypeDef* USARTx);
void USART_SetGuardTime(USART_TypeDef* USARTx, uint8_t USART_GuardTime);
void USART_SetPrescaler(USART_TypeDef* USARTx, uint8_t USART_Prescaler);
void USART_SmartCardCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_SmartCardNACKCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_HalfDuplexCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_OverSampling8Cmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_OneBitMethodCmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_IrDAConfig(USART_TypeDef* USARTx, uint16_t USART_IrDAMode);
void USART_IrDACmd(USART_TypeDef* USARTx, FunctionalState NewState);
FlagStatus USART_GetFlagStatus(USART_TypeDef* USARTx, uint16_t USART_FLAG);
void USART_ClearFlag(USART_TypeDef* USARTx, uint16_t USART_FLAG);
ITStatus USART_GetITStatus(USART_TypeDef* USARTx, uint16_t USART_IT);
void USART_ClearITPendingBit(USART_TypeDef* USARTx, uint16_t USART_IT);
(4)Serial.c
cpp
#include "stm32f10x.h" // Device header
/*串口初始化函数
第1步:开启时钟,把需要用的USART和GPIO的时钟打开
第2步:GPIO初始化,把TX配置成复用输出,RX配置成输入
第3步:配置USART,直接使用一个结构体,就可以把这里所有参数配置好了
第4步:如果只需要发送的功能,就直接开启USART,初始化就结束了;
如果需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就可以了
*/
void Serial_Init(void)
{
/*第1步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1,ENABLE); //开启挂载再APB2总线上的USART的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //USRTA1是复用再PA9和PA10上的
/*第2步:GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; //复用推挽输出模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
/*第3步:配置USART*/
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600; //配置波特率9600
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制:不使用流控
USART_InitStruct.USART_Mode = USART_Mode_Tx; //串口模式设置为发送模式
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位无校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b; //字长选择8位
USART_Init(USART1,&USART_InitStruct);
/*第4步::开启USART*/
USART_Cmd(USART1,ENABLE);
}
/*发送数据函数*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1,Byte);
while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET); //获取TXE标志位,由手册可知不需要手动清零
}
/*发送数组函数*/
void Serial_SendArray(uint8_t *Array,uint16_t Length)
{
uint16_t i;
for(i=0;i<Length;i++)
Serial_SendByte(Array[i]);
}
/*发送字符串函数*/
void Serial_SenString(char *String)
{
uint8_t i;
for(i=0;String[i] != '\0';i++) //字符串自带一个结束标志位"\0",使用字符串标志位来结束循环
Serial_SendByte(String[i]);
}
/*求一个数的指数函数,用于分离一个数的每一位做准备*/
uint32_t Serial_Pow(uint32_t x,uint32_t y)
{
uint32_t Result = 1;
while(y--)
{
Result *= x; //x的y次方
}
return Result;
}
/*发送数字函数,在电脑频幕上显示字符形式的数字*/
void Serial_SendNumber(uint32_t Number,uint8_t Length)
{
uint8_t i;
for(i=0;i<Length;i++)
{
Serial_SendByte(Number/Serial_Pow(10,Length - i -1)%10+'0'); //提取数字中的每一位,后面+'0'是ASCII的偏移量
}
}
(5)Serial.h
cpp
#ifndef __SERIAL_
#define __SERIAL_
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SenString(char *String);
uint32_t Serial_Pow(uint32_t x,uint32_t y);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
#endif
(6)mian.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Serial.h"
uint8_t KeyNum;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Serial_Init();
// Serial_SendByte(0x41);
// Serial_SendByte('A');
// uint8_t MyArray[] = {0x42,0x43,0x44,0x45};
// Serial_SendArray(MyArray,4);
// Serial_SenString("HelloWorld!\r\n"); //使用"\r\n"来进行换行
Serial_SendNumber(12345,5);
while(1)
{
}
}
8.3.4 数据模式
HEX模式/十六进制模式/二进制模式:以原始数据的形式显示
文本模式/字符模式:以原始数据编码后的形式显示
8.3.5 printf函数的移植方法
8.3.5.1 方法1
MicaoLIB是Keil为嵌入式平台优化的一个精简库, 使用printf函数就可以用这个MicroLIB。还需要对printf函数进行重定向,将printf函数打印的东西输出到串口。因为printf默认是输出到屏幕,我们单片机没有屏幕,所以要进行重定向,步骤就是在串口模块里加上:
cpp
#include <stdio.h>
之后,重写fputc函数。
(1)Serial.c
cpp
#include "stm32f10x.h" // Device header
#include <stdio.h>
/*串口初始化函数
第1步:开启时钟,把需要用的USART和GPIO的时钟打开
第2步:GPIO初始化,把TX配置成复用输出,RX配置成输入
第3步:配置USART,直接使用一个结构体,就可以把这里所有参数配置好了
第4步:如果只需要发送的功能,就直接开启USART,初始化就结束了;
如果需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就可以了
*/
void Serial_Init(void)
{
/*第1步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1,ENABLE); //开启挂载再APB2总线上的USART的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //USRTA1是复用再PA9和PA10上的
/*第2步:GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; //复用推挽输出模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
/*第3步:配置USART*/
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600; //配置波特率9600
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制:不使用流控
USART_InitStruct.USART_Mode = USART_Mode_Tx; //串口模式设置为发送模式
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位无校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b; //字长选择8位
USART_Init(USART1,&USART_InitStruct);
/*第4步::开启USART*/
USART_Cmd(USART1,ENABLE);
}
/*发送数据函数*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1,Byte);
while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET); //获取TXE标志位,由手册可知不需要手动清零
}
/*发送数组函数*/
void Serial_SendArray(uint8_t *Array,uint16_t Length)
{
uint16_t i;
for(i=0;i<Length;i++)
Serial_SendByte(Array[i]);
}
/*发送字符串函数*/
void Serial_SenString(char *String)
{
uint8_t i;
for(i=0;String[i] != '\0';i++) //字符串自带一个结束标志位"\0",使用字符串标志位来结束循环
Serial_SendByte(String[i]);
}
/*求一个数的指数函数,用于分离一个数的每一位做准备*/
uint32_t Serial_Pow(uint32_t x,uint32_t y)
{
uint32_t Result = 1;
while(y--)
{
Result *= x; //x的y次方
}
return Result;
}
/*发送数字函数,在电脑频幕上显示字符形式的数字*/
void Serial_SendNumber(uint32_t Number,uint8_t Length)
{
uint8_t i;
for(i=0;i<Length;i++)
{
Serial_SendByte(Number/Serial_Pow(10,Length - i -1)%10+'0'); //提取数字中的每一位,后面+'0'是ASCII的偏移量
}
}
int fputc(int ch,FILE *f)
/*fputc是printf函数的底层,printf函数在打印的时候,就是不断调用fputc函数一个一个打印的
把fputc函数重定向到了串口,那printf自然就输出到串口了,这样printf就移植好了*/
{
Serial_SendByte(ch); //把fputc重定向到串口
return ch;
}
(2)Serial.h
cpp
#ifndef __SERIAL_
#define __SERIAL_
#include <stdio.h>
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SenString(char *String);
uint32_t Serial_Pow(uint32_t x,uint32_t y);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
#endif
(3)main.h
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Serial.h"
uint8_t KeyNum;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Serial_Init();
// Serial_SendByte(0x41);
// Serial_SendByte('A');
// uint8_t MyArray[] = {0x42,0x43,0x44,0x45};
// Serial_SendArray(MyArray,4);
// Serial_SenString("HelloWorld!\r\n"); //使用"\r\n"来进行换行
// Serial_SendNumber(12345,5);
printf("Num=%d\r\n",666);
while(1)
{
}
}
8.3.5.1 方法2
第一种方法,printf只能有一个,如果重定向到串口1了,那串口2再用就没有了。多个串口都想用printf,这时就可以用sprintf。sprintf可以把格式化字符输出到一个字符串里,所以这里可以先定义一个字符串。
main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Serial.h"
uint8_t KeyNum;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Serial_Init();
// Serial_SendByte(0x41);
// Serial_SendByte('A');
// uint8_t MyArray[] = {0x42,0x43,0x44,0x45};
// Serial_SendArray(MyArray,4);
// Serial_SenString("HelloWorld!\r\n"); //使用"\r\n"来进行换行
// Serial_SendNumber(12345,5);
// printf("Num=%d\r\n",666);
char String[100];
sprintf(String,"Num=%d\r\n",666); //第一个参数是指定打印输出的位置,目前"Num=%d\r\n"这个格式化的字符串在String里
/*sprintf可以指定打印位置,不涉及重定向的东西,所以每个串口都可以用sprintf进行格式化打印*/
Serial_SenString(String);
while(1)
{
}
}
8.3.5.1 方法3
sprintf每次都得先定义字符串,再打印到字符串,再发送字符串。所以可以对sprintf函数进行封装这个过程。
(1)Serial.c
cpp
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
/*串口初始化函数
第1步:开启时钟,把需要用的USART和GPIO的时钟打开
第2步:GPIO初始化,把TX配置成复用输出,RX配置成输入
第3步:配置USART,直接使用一个结构体,就可以把这里所有参数配置好了
第4步:如果只需要发送的功能,就直接开启USART,初始化就结束了;
如果需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就可以了
*/
void Serial_Init(void)
{
/*第1步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1,ENABLE); //开启挂载再APB2总线上的USART的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //USRTA1是复用再PA9和PA10上的
/*第2步:GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; //复用推挽输出模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
/*第3步:配置USART*/
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600; //配置波特率9600
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制:不使用流控
USART_InitStruct.USART_Mode = USART_Mode_Tx; //串口模式设置为发送模式
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位无校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b; //字长选择8位
USART_Init(USART1,&USART_InitStruct);
/*第4步::开启USART*/
USART_Cmd(USART1,ENABLE);
}
/*发送数据函数*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1,Byte);
while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET); //获取TXE标志位,由手册可知不需要手动清零
}
/*发送数组函数*/
void Serial_SendArray(uint8_t *Array,uint16_t Length)
{
uint16_t i;
for(i=0;i<Length;i++)
Serial_SendByte(Array[i]);
}
/*发送字符串函数*/
void Serial_SenString(char *String)
{
uint8_t i;
for(i=0;String[i] != '\0';i++) //字符串自带一个结束标志位"\0",使用字符串标志位来结束循环
Serial_SendByte(String[i]);
}
/*求一个数的指数函数,用于分离一个数的每一位做准备*/
uint32_t Serial_Pow(uint32_t x,uint32_t y)
{
uint32_t Result = 1;
while(y--)
{
Result *= x; //x的y次方
}
return Result;
}
/*发送数字函数,在电脑频幕上显示字符形式的数字*/
void Serial_SendNumber(uint32_t Number,uint8_t Length)
{
uint8_t i;
for(i=0;i<Length;i++)
{
Serial_SendByte(Number/Serial_Pow(10,Length - i -1)%10+'0'); //提取数字中的每一位,后面+'0'是ASCII的偏移量
}
}
int fputc(int ch,FILE *f)
/*fputc是printf函数的底层,printf函数在打印的时候,就是不断调用fputc函数一个一个打印的
把fputc函数重定向到了串口,那printf自然就输出到串口了,这样printf就移植好了*/
{
Serial_SendByte(ch); //把fputc重定向到串口
return ch;
}
void Serial_Printf(char *format,...) //format用来接收格式化字符串,后面...这部分用来接收后面的可变参数列表
{
char String[100];
va_list arg; //定义一个参数列表变量,va_list是一个类型名,arg是变量名
va_start(arg,format); //从format位置开始接收参数表,放在arg里面
vsprintf(String,format,arg);
va_end(arg); //释放参数表
Serial_SenString(String); //把String发送出去
}
(2)Serial.h
cpp
#ifndef __SERIAL_
#define __SERIAL_
#include <stdio.h>
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SenString(char *String);
uint32_t Serial_Pow(uint32_t x,uint32_t y);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
void Serial_Printf(char *format,...);
#endif
(3)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Serial.h"
uint8_t KeyNum;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Serial_Init();
// Serial_SendByte(0x41);
// Serial_SendByte('A');
// uint8_t MyArray[] = {0x42,0x43,0x44,0x45};
// Serial_SendArray(MyArray,4);
// Serial_SenString("HelloWorld!\r\n"); //使用"\r\n"来进行换行
// Serial_SendNumber(12345,5);
// printf("Num=%d\r\n",666);
// char String[100];
// sprintf(String,"Num=%d\r\n",666); //第一个参数是指定打印输出的位置,目前"Num=%d\r\n"这个格式化的字符串在String里
// /*sprintf可以指定打印位置,不涉及重定向的东西,所以每个串口都可以用sprintf进行格式化打印*/
// Serial_SenString(String);
Serial_Printf("Num=%d\r\n",666);
while(1)
{
}
}
8.3.6 显示汉字的方法
8.3.6.1 utf-8不乱码的方案
这里直接写汉字,编译器有时候会报错,需要进行以下配置:
--no-multibyte-chars
8.3.6.2 直接使用GB2312编码
编写程序时,,直接使用GB2312编码,串口使用GBK输出。
8.4 串口发送+接收
8.4.1 硬件电路
8.4.2 软件部分
8.4.2.1 查询方法
适用于程序比较简单情况。
(1)复制《串口发送》工程并改名为《串口发送+接收》
(2)Serial.c
cpp
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
/*串口初始化函数
第1步:开启时钟,把需要用的USART和GPIO的时钟打开
第2步:GPIO初始化,把TX配置成复用输出,RX配置成输入
第3步:配置USART,直接使用一个结构体,就可以把这里所有参数配置好了
第4步:如果只需要发送的功能,就直接开启USART,初始化就结束了;
如果需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就可以了
*/
void Serial_Init(void)
{
/*第1步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1,ENABLE); //开启挂载再APB2总线上的USART的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //USRTA1是复用再PA9和PA10上的
/*第2步:GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; //复用推挽输出模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; //接收引脚选择上拉输入模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
/*第3步:配置USART*/
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600; //配置波特率9600
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制:不使用流控
USART_InitStruct.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //串口模式设置为发送模式和接收模式同时开启
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位无校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b; //字长选择8位
USART_Init(USART1,&USART_InitStruct);
/*第4步::开启USART*/
USART_Cmd(USART1,ENABLE);
}
/*发送数据函数*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1,Byte);
while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET); //获取TXE标志位,由手册可知不需要手动清零
}
/*发送数组函数*/
void Serial_SendArray(uint8_t *Array,uint16_t Length)
{
uint16_t i;
for(i=0;i<Length;i++)
Serial_SendByte(Array[i]);
}
/*发送字符串函数*/
void Serial_SenString(char *String)
{
uint8_t i;
for(i=0;String[i] != '\0';i++) //字符串自带一个结束标志位"\0",使用字符串标志位来结束循环
Serial_SendByte(String[i]);
}
/*求一个数的指数函数,用于分离一个数的每一位做准备*/
uint32_t Serial_Pow(uint32_t x,uint32_t y)
{
uint32_t Result = 1;
while(y--)
{
Result *= x; //x的y次方
}
return Result;
}
/*发送数字函数,在电脑频幕上显示字符形式的数字*/
void Serial_SendNumber(uint32_t Number,uint8_t Length)
{
uint8_t i;
for(i=0;i<Length;i++)
{
Serial_SendByte(Number/Serial_Pow(10,Length - i -1)%10+'0'); //提取数字中的每一位,后面+'0'是ASCII的偏移量
}
}
int fputc(int ch,FILE *f)
/*fputc是printf函数的底层,printf函数在打印的时候,就是不断调用fputc函数一个一个打印的
把fputc函数重定向到了串口,那printf自然就输出到串口了,这样printf就移植好了*/
{
Serial_SendByte(ch); //把fputc重定向到串口
return ch;
}
void Serial_Printf(char *format,...) //format用来接收格式化字符串,后面...这部分用来接收后面的可变参数列表
{
char String[100];
va_list arg; //定义一个参数列表变量,va_list是一个类型名,arg是变量名
va_start(arg,format); //从format位置开始接收参数表,放在arg里面
vsprintf(String,format,arg);
va_end(arg); //释放参数表
Serial_SenString(String); //把String发送出去
}
(3)Serial.h
cpp
#ifndef __SERIAL_
#define __SERIAL_
#include <stdio.h>
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SenString(char *String);
uint32_t Serial_Pow(uint32_t x,uint32_t y);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
void Serial_Printf(char *format,...);
#endif
(4)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Serial.h"
uint8_t RxData;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
Serial_Init();
while(1)
{
if(USART_GetFlagStatus(USART1,USART_FLAG_RXNE)==SET) //if成立,就说明收到数据了
{
RxData = USART_ReceiveData(USART1);
OLED_ShowHexNum(1,1,RxData,2);
//无需手动清零
}
}
}
(5)结果
8.4.2.2 中断方法
(1)Serial.c
cpp
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
uint8_t Serial_RxData;
uint8_t Serial_RxFlag;
/*串口初始化函数
第1步:开启时钟,把需要用的USART和GPIO的时钟打开
第2步:GPIO初始化,把TX配置成复用输出,RX配置成输入
第3步:配置USART,直接使用一个结构体,就可以把这里所有参数配置好了
第4步:开启中断
第5步:开启RXNE标志位到NVIC的输出
第6步:如果只需要发送的功能,就直接开启USART,初始化就结束了;
如果需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就可以了
*/
void Serial_Init(void)
{
/*第1步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1,ENABLE); //开启挂载再APB2总线上的USART的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //USRTA1是复用再PA9和PA10上的
/*第2步:GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; //复用推挽输出模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; //接收引脚选择上拉输入模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
/*第3步:配置USART*/
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600; //配置波特率9600
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制:不使用流控
USART_InitStruct.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //串口模式设置为发送模式和接收模式同时开启
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位无校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b; //字长选择8位
USART_Init(USART1,&USART_InitStruct);
/*第4步::开启中断*/
USART_ITConfig(USART1,USART_IT_RXNE,ENABLE); //RXNE标志位一旦置1,就会向NVIC申请中断
/*第5步::开启RXNE标志位到NVIC的输出*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStruct);
/*第6步::开启USART*/
USART_Cmd(USART1,ENABLE);
}
/*发送数据函数*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1,Byte);
while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET); //获取TXE标志位,由手册可知不需要手动清零
}
/*发送数组函数*/
void Serial_SendArray(uint8_t *Array,uint16_t Length)
{
uint16_t i;
for(i=0;i<Length;i++)
Serial_SendByte(Array[i]);
}
/*发送字符串函数*/
void Serial_SenString(char *String)
{
uint8_t i;
for(i=0;String[i] != '\0';i++) //字符串自带一个结束标志位"\0",使用字符串标志位来结束循环
Serial_SendByte(String[i]);
}
/*求一个数的指数函数,用于分离一个数的每一位做准备*/
uint32_t Serial_Pow(uint32_t x,uint32_t y)
{
uint32_t Result = 1;
while(y--)
{
Result *= x; //x的y次方
}
return Result;
}
/*发送数字函数,在电脑频幕上显示字符形式的数字*/
void Serial_SendNumber(uint32_t Number,uint8_t Length)
{
uint8_t i;
for(i=0;i<Length;i++)
{
Serial_SendByte(Number/Serial_Pow(10,Length - i -1)%10+'0'); //提取数字中的每一位,后面+'0'是ASCII的偏移量
}
}
/*printf重定向函数*/
int fputc(int ch,FILE *f)
/*fputc是printf函数的底层,printf函数在打印的时候,就是不断调用fputc函数一个一个打印的
把fputc函数重定向到了串口,那printf自然就输出到串口了,这样printf就移植好了*/
{
Serial_SendByte(ch); //把fputc重定向到串口
return ch;
}
/*sprintf函数的封装*/
void Serial_Printf(char *format,...) //format用来接收格式化字符串,后面...这部分用来接收后面的可变参数列表
{
char String[100];
va_list arg; //定义一个参数列表变量,va_list是一个类型名,arg是变量名
va_start(arg,format); //从format位置开始接收参数表,放在arg里面
vsprintf(String,format,arg);
va_end(arg); //释放参数表
Serial_SenString(String); //把String发送出去
}
/*自动清除标志位函数*/
uint8_t Serial_GetRxFlag(void)
{
if(Serial_RxFlag == 1)
{
Serial_RxFlag = 0;
return 1;
}
return 0;
}
uint8_t Serial_GetRxData(void)
{
return Serial_RxData;
}
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); //清除一下标志位
}
}
(2)Serial.h
cpp
#ifndef __SERIAL_
#define __SERIAL_
#include <stdio.h>
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SenString(char *String);
uint32_t Serial_Pow(uint32_t x,uint32_t y);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
void Serial_Printf(char *format,...);
uint8_t Serial_GetRxFlag(void);
uint8_t Serial_GetRxData(void);
#endif
(3)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Serial.h"
uint8_t RxData;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
OLED_ShowString(1,1,"RxData:");
Serial_Init();
while(1)
{
if(Serial_GetRxFlag()== 1) //if成立,就说明收到数据了
{
RxData = Serial_GetRxData();
Serial_SendByte(RxData);
OLED_ShowHexNum(1,8,RxData,2);
//无需手动清零
}
}
}
(4)结果
8.5 USART串口数据包
8.5.1 HEX数据包
数据包的作用是,把一个个单独的数据给打包起来,方便我们进行多字节的数据通信。比如说,我们有一个陀螺仪传感器,需要用串口发送数据到STM32,陀螺仪的数据,比如X轴一个字节,Y轴一个字节,Z轴一个字节,总共3个数据需要连续不断地发送,当像这样,XYZXYZ连续发送的时候,就会出现一个问题,就是接收方,它不知道这数据哪个对应X、哪个对应Y、哪个对应Z,因为接收方可能会从任意位置开始接收,所以会出现数据错位的现象。这时候我们就需要研究一种方式,把这个数据进行分割,把XYZ这一批数据分割开,分成一个个数据包,这样再接收的时候,就知道了,第一个数据是X,第二个数据是Y,第三个是Z。这就是数据包的任务,就是把属于同一批的数据进行打包和分割,方便接收方进行识别。有关分割打包的方法,可以自己发挥想象力来设计,只要逻辑行得通就行。比如我可以设计,在这个XYZXYZ数据流中,数据包的第一个数据,也就是X的数据包,它的最高位置1,其余数据包,最高位都置0。当我接收到数据之后,判断一下最高位,如果是1,那就是X数据,然后紧跟着的两个数据就是YZ。这就是一种可行的分割方法,这种方法是把每个数据的最高位当作标志位来进行分割的。实际也有应用的例子,比如UTF8的编码方法,和这就是类似的,不过它那个编码更高级一些。
我们串口数据包,通常使用的是额外添加包头包尾这种方式。比如以下两种数据包格式。
包头包尾和数据载荷重复的问题:这里定义0xFF为包头,FE为包尾,如果传输的数据本身就是0x
FF和0xFE怎么办呢?这个问题确实存在,如果数据和包头包尾重复,可能会引起误判,对应这个问题,有如下几种解决方法:
第一种:限制载荷数据的范围,如果可以的话,我们可以在发送的时候,对数据进行限幅,比如XYZ3个数据,变化范围都可以是0~100,那就好办了,我们可以在载荷中只发送0~100的数据,这样就不会和包头包尾重复了。
第二种:如果无法避免载荷数据和包头包尾重复,那我们就尽量使用固定长度的数据包,这样由于载荷数据是固定的,只要我们通过包头包尾对齐了数据,我们就可以严格知道,哪个数据应该是包头包尾,哪个数据应该是载荷数据,在接收载荷数据的时候,我们并不会判断它是否是包头包尾。而在接收包头包尾的时候,我们会判断它是不是确实是包头包尾,用于数据对齐。这样,在经过几个数据包的对齐之后,剩下的数据包,应该就不会出现问题了。
第三种:增加包头包围的数量,并且让它尽量呈现出载荷输出出现不了的状态,比如我们使用FF、FE作为包头,FD、FC作为包尾,这样也可以避免载荷数据和包头包尾重复的情况发生。
第二个问题是,包头包尾并不是全部都需要的,比如我们可以只要一个包头,把包尾删掉,这样数据包的格式就是,一个包头FF加4个数据,当检测到FF时开始接收,收够4个字节后,置标志位,一个数据包接收完成,不过这样的话,载荷和包头重复的问题会更严重一些。
第三个问题,就是固定包长和可变包长的选择问题,对于HEX数据包来说,如果你的载荷会出现和包头包尾重复的情况,那就最好选择固定包长,这样可以避免接收错误。如果又会重复,又选择可变包长,那数据就容易乱套了。如果载荷不会和包头包尾重复,数据长度可以选择可变包长。
最后一个问题就是各种数据转换为字节流的问题,这里数据包都是一个字节一个字节组成的,如果你想发送16位的整型数据,32位的整型数据,float、double甚至是结构体,其实都没问题,因为它们内部其实都是由一个字节一个字节组成的,只需要用一个uint8_t的指针,指向它,把它们当作一个字节数组发送就行了。
(1)固定包长,包含包头包尾
每个数据包的长度不变,数据包的前面是包头,后面是包尾。
定义oxFF位包头, 在4个字节之后,加一个包尾,比如定义0xFE为包尾。当我接收到0xFF之后,我就知道了,一个数据包来了,接着我再接收到的4个字节,就当作数据包的第1、2、3、4个数据,存在一个数组里,最后跟一个包尾,当我收到0xFE之后,就可以置一个标志位,告诉程序,我收到了一个数据包。然后新的数据包过来,再重复之前的过程。这样就可以在一个连续不断的数据流中分割出我们想要的数据包了。
(2)可变包长,含包头包尾
每个数据包的长度可以是不一样的,数据包的前面是包头,后面是包尾。
数据包的格式,可以是用户根据需求,自己规定的,也可以是买一个模块,别的开发者规定的。
8.5.2 文本数据包
实际上每个文本最后其实都还是一个字节的HEX数据。在载荷数据中间可以出现除了包头包尾的任意字符。所以文本数据包基本不用担心再和包头包尾重复的问题。
文本数据包通常会以换行作为包尾,这样在打印的时候,就可以一行一行地显示了。
(1)固定包长,含包头包尾
(2)可变包长,含包头包尾
8.5.3 HEX数据包接收
每收到一个字节,程序都会进一遍中断,在中断函数里,我们可以拿到这一个字节,但拿到之后,我们就得退出中断了,所以每拿到一个数据都是一个独立的过程,而对于数据包来说,它具有前后关联性,包头之后是数据、数据之后是包尾,对于包头、数据和包尾这3种状态。我们都需要有不同的处理逻辑。所以在程序中,我们需要设计一个能够记住不同状态的机制,在不同状态执行不同的操作,同时还要进行状态的合理转移,这种程序设计思维,就叫做"状态机"。在这里我们就使用状态机的思维来接收一个数据包。
最开始,S=0, 收到一个数据,进中断,根据S=0,进入第一个状态的程序,判断数据是不是包头FF,如果是FF则代表收到包头,之后置S=1,退出中断,结束。这样下次再进中断,根据S=1,就可以进行接收数据的程序了。那在第一个状态,如果收到的不是FF,就证明数据包没有对齐,我们应该等待数据包包头的出现,这时候状态仍然是0,下次进中断,就还是判断包头的逻辑,直到出现FF,才能转到下一个状态。那之后,出现了FF,我们就可以转移到接收数据的状态了,这时,再收到数据,我们就直接把它存在数组中,另外再用一个变量,记录收了多少个数据,如果没收够4个数据,就一直是接收状态,如果收够了,就置S=2。下次中断时,就可以进入下一个状态了,那最后一个状态就是等待包尾了,判断数据是不是FE,正常情况下应该是FE。这样就可以置S=0回到最初的状态,开始下一个轮回。当然也有可能这个数据不是FE,比如数据和包头重复,导致包头位置判断错了,那这个包尾位置就有可能不是FE,这时候就可以进入重复等待包尾的状态,直到接收到真正的包尾,这样加入包尾的判断,更能预防因数据和包头重复造成的错误。
状态机使用的基本步骤:
先根据项目要求定义状态,画几个圈,然后考虑不好各个状态在什么情况下会进行转移,如何转移,画好线和转移条件,然后根据这个图来进行编程。
8.5.4 文本数据包接收
8.6 串口收发HEX数据包
8.6.1 硬件电路
8.6.2 软件部分
(1)复制《串口发送+接收》并改名为《串口收发HEX数据包》
(2)Serial.c
cpp
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
uint8_t Serial_TxPacket[4]; //用于缓存发送的数据包
uint8_t Serial_RxPacket[4]; //用于缓存接收的数据包
uint8_t Serial_RxFlag; //缓存状态标志位
/*串口初始化函数
第1步:开启时钟,把需要用的USART和GPIO的时钟打开
第2步:GPIO初始化,把TX配置成复用输出,RX配置成输入
第3步:配置USART,直接使用一个结构体,就可以把这里所有参数配置好了
第4步:开启中断
第5步:开启RXNE标志位到NVIC的输出
第6步:如果只需要发送的功能,就直接开启USART,初始化就结束了;
如果需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就可以了
*/
void Serial_Init(void)
{
/*第1步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1,ENABLE); //开启挂载再APB2总线上的USART的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //USRTA1是复用再PA9和PA10上的
/*第2步:GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; //复用推挽输出模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; //接收引脚选择上拉输入模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
/*第3步:配置USART*/
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600; //配置波特率9600
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制:不使用流控
USART_InitStruct.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //串口模式设置为发送模式和接收模式同时开启
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位无校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b; //字长选择8位
USART_Init(USART1,&USART_InitStruct);
/*第4步::开启中断*/
USART_ITConfig(USART1,USART_IT_RXNE,ENABLE); //RXNE标志位一旦置1,就会向NVIC申请中断
/*第5步::开启RXNE标志位到NVIC的输出*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStruct);
/*第6步::开启USART*/
USART_Cmd(USART1,ENABLE);
}
/*发送数据函数*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1,Byte);
while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET); //获取TXE标志位,由手册可知不需要手动清零
}
/*发送数组函数*/
void Serial_SendArray(uint8_t *Array,uint16_t Length)
{
uint16_t i;
for(i=0;i<Length;i++)
Serial_SendByte(Array[i]);
}
/*发送字符串函数*/
void Serial_SenString(char *String)
{
uint8_t i;
for(i=0;String[i] != '\0';i++) //字符串自带一个结束标志位"\0",使用字符串标志位来结束循环
Serial_SendByte(String[i]);
}
/*求一个数的指数函数,用于分离一个数的每一位做准备*/
uint32_t Serial_Pow(uint32_t x,uint32_t y)
{
uint32_t Result = 1;
while(y--)
{
Result *= x; //x的y次方
}
return Result;
}
/*发送数字函数,在电脑频幕上显示字符形式的数字*/
void Serial_SendNumber(uint32_t Number,uint8_t Length)
{
uint8_t i;
for(i=0;i<Length;i++)
{
Serial_SendByte(Number/Serial_Pow(10,Length - i -1)%10+'0'); //提取数字中的每一位,后面+'0'是ASCII的偏移量
}
}
/*printf重定向函数*/
int fputc(int ch,FILE *f)
/*fputc是printf函数的底层,printf函数在打印的时候,就是不断调用fputc函数一个一个打印的
把fputc函数重定向到了串口,那printf自然就输出到串口了,这样printf就移植好了*/
{
Serial_SendByte(ch); //把fputc重定向到串口
return ch;
}
/*sprintf函数的封装*/
void Serial_Printf(char *format,...) //format用来接收格式化字符串,后面...这部分用来接收后面的可变参数列表
{
char String[100];
va_list arg; //定义一个参数列表变量,va_list是一个类型名,arg是变量名
va_start(arg,format); //从format位置开始接收参数表,放在arg里面
vsprintf(String,format,arg);
va_end(arg); //释放参数表
Serial_SenString(String); //把String发送出去
}
/*自动清除标志位函数*/
uint8_t Serial_GetRxFlag(void)
{
if(Serial_RxFlag == 1)
{
Serial_RxFlag = 0;
return 1;
}
return 0;
}
/*发包函数:调用这个函数,Serial_TxPacket的数据自动加上包头包尾发出去*/
void Serial_SendPacket(void)
{
Serial_SendByte(0xFF);
Serial_SendArray(Serial_TxPacket,4);
Serial_SendByte(0xFE);
}
/*接收中断函数*/
void USART1_IRQHandler(void)
{
static uint8_t RxState = 0; //状态变量,0、1、2
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; //接收到0xFF,转移状态
pRxPacket = 0; //清零,为下一次进入接收数据程序做准备
}
}
else if(RxState == 1) //进入接收数据的程序
{
Serial_RxPacket[pRxPacket] = RxData;
pRxPacket++; //每进一次接收状态,数据就转一次缓存数组,同时寸的位置++
if(pRxPacket>=4) //4个载荷数据已经收完了,这时就可以转移到下一个状态了
{
RxState = 2;
}
}
else if(RxState == 2) //进入等待包尾的程序
{
if(RxData == 0xFE)
{
RxState = 0; //接收到包尾,回到最初的状态
Serial_RxFlag = 1; //置一个接收标志位
}
}
USART_ClearITPendingBit(USART1,USART_IT_RXNE); //清除一下标志位
}
}
(3)Serial.h
cpp
#ifndef __SERIAL_
#define __SERIAL_
#include <stdio.h>
extern uint8_t Serial_TxPacket[];
extern uint8_t Serial_RxPacket[];
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SenString(char *String);
uint32_t Serial_Pow(uint32_t x,uint32_t y);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
void Serial_Printf(char *format,...);
uint8_t Serial_GetRxFlag(void);
void Serial_SendPacket(void);
#endif
(4)main.c
cpp
#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(); // 初始化OLED屏幕
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)
{
KeyNum = Key_GetNum();
if(KeyNum == 1)
{
Serial_TxPacket[0]++;
Serial_TxPacket[1]++;
Serial_TxPacket[2]++;
Serial_TxPacket[3]++;
Serial_SendPacket();
OLED_ShowHexNum(2,1,Serial_TxPacket[0],2);
OLED_ShowHexNum(2,4,Serial_TxPacket[1],2);
OLED_ShowHexNum(2,7,Serial_TxPacket[2],2);
OLED_ShowHexNum(2,10,Serial_TxPacket[3],2);
}
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);
}
}
}
8.7 串口收发文本数据包
8.7.1 硬件电路
8.7.2 软件部分
(1)复制《串口收发HEX数据包》工程并改名为《串口收发文本数据包》
(2)Serial.c
cpp
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>
char Serial_RxPacket[100]; //用于缓存接收的数据包,单条指令最长不能超过100个字符
uint8_t Serial_RxFlag = 0; //缓存状态标志位
/*串口初始化函数
第1步:开启时钟,把需要用的USART和GPIO的时钟打开
第2步:GPIO初始化,把TX配置成复用输出,RX配置成输入
第3步:配置USART,直接使用一个结构体,就可以把这里所有参数配置好了
第4步:开启中断
第5步:开启RXNE标志位到NVIC的输出
第6步:如果只需要发送的功能,就直接开启USART,初始化就结束了;
如果需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就可以了
*/
void Serial_Init(void)
{
/*第1步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1,ENABLE); //开启挂载再APB2总线上的USART的时钟控制
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE); //USRTA1是复用再PA9和PA10上的
/*第2步:GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; //复用推挽输出模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; //接收引脚选择上拉输入模式
/*引脚模式:TX引脚是USART外设控制的输出脚,所以要选择复用推挽输出,
RX引脚是USART引脚外设的数据输入脚,所以要选择输入模式(输入模式并不分普通输入、复用输入),
一般RX配置成浮空输入或者上拉输入,因为串口波形空闲状态是高电平,所有不使用下拉输入*/
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10; //这个案例只需要发送
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA,&GPIO_InitStruct);
/*第3步:配置USART*/
USART_InitTypeDef USART_InitStruct;
USART_InitStruct.USART_BaudRate = 9600; //配置波特率9600
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制:不使用流控
USART_InitStruct.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //串口模式设置为发送模式和接收模式同时开启
USART_InitStruct.USART_Parity = USART_Parity_No; //校验位无校验
USART_InitStruct.USART_StopBits = USART_StopBits_1; //停止位1位
USART_InitStruct.USART_WordLength = USART_WordLength_8b; //字长选择8位
USART_Init(USART1,&USART_InitStruct);
/*第4步::开启中断*/
USART_ITConfig(USART1,USART_IT_RXNE,ENABLE); //RXNE标志位一旦置1,就会向NVIC申请中断
/*第5步::开启RXNE标志位到NVIC的输出*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStruct);
/*第6步::开启USART*/
USART_Cmd(USART1,ENABLE);
}
/*发送数据函数*/
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1,Byte);
while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET); //获取TXE标志位,由手册可知不需要手动清零
}
/*发送数组函数*/
void Serial_SendArray(uint8_t *Array,uint16_t Length)
{
uint16_t i;
for(i=0;i<Length;i++)
Serial_SendByte(Array[i]);
}
/*发送字符串函数*/
void Serial_SenString(char *String)
{
uint8_t i;
for(i=0;String[i] != '\0';i++) //字符串自带一个结束标志位"\0",使用字符串标志位来结束循环
Serial_SendByte(String[i]);
}
/*求一个数的指数函数,用于分离一个数的每一位做准备*/
uint32_t Serial_Pow(uint32_t x,uint32_t y)
{
uint32_t Result = 1;
while(y--)
{
Result *= x; //x的y次方
}
return Result;
}
/*发送数字函数,在电脑频幕上显示字符形式的数字*/
void Serial_SendNumber(uint32_t Number,uint8_t Length)
{
uint8_t i;
for(i=0;i<Length;i++)
{
Serial_SendByte(Number/Serial_Pow(10,Length - i -1)%10+'0'); //提取数字中的每一位,后面+'0'是ASCII的偏移量
}
}
/*printf重定向函数*/
int fputc(int ch,FILE *f)
/*fputc是printf函数的底层,printf函数在打印的时候,就是不断调用fputc函数一个一个打印的
把fputc函数重定向到了串口,那printf自然就输出到串口了,这样printf就移植好了*/
{
Serial_SendByte(ch); //把fputc重定向到串口
return ch;
}
/*sprintf函数的封装*/
void Serial_Printf(char *format,...) //format用来接收格式化字符串,后面...这部分用来接收后面的可变参数列表
{
char String[100];
va_list arg; //定义一个参数列表变量,va_list是一个类型名,arg是变量名
va_start(arg,format); //从format位置开始接收参数表,放在arg里面
vsprintf(String,format,arg);
va_end(arg); //释放参数表
Serial_SenString(String); //把String发送出去
}
/*自动清除标志位函数*/
//uint8_t Serial_GetRxFlag(void)
//{
// if(Serial_RxFlag == 1)
// {
// Serial_RxFlag = 0;
// return 1;
// }
// return 0;
//}
/*接收中断函数*/
void USART1_IRQHandler(void)
{
static uint8_t RxState = 0; //状态变量,0、1、2
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); //清除一下标志位
}
}
(3)Serial.h
cpp
#ifndef __SERIAL_
#define __SERIAL_
#include <stdio.h>
extern char Serial_RxPacket[];
extern uint8_t Serial_RxFlag;
void Serial_Init(void);
void Serial_SendByte(uint8_t Byte);
void Serial_SendArray(uint8_t *Array,uint16_t Length);
void Serial_SenString(char *String);
uint32_t Serial_Pow(uint32_t x,uint32_t y);
void Serial_SendNumber(uint32_t Number,uint8_t Length);
void Serial_Printf(char *format,...);
//uint8_t Serial_GetRxFlag(void);
#endif
(4)main.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "Serial.h"
#include "Key.h"
#include "LED.h"
#include <string.h>
int main(void)
{
LED_Init();
OLED_Init(); // 初始化OLED屏幕
Serial_Init();
OLED_ShowString(1,1,"TxPacket");
OLED_ShowString(3,1,"RxPacket");
while(1)
{
if(Serial_RxFlag == 1)
{
OLED_ShowString(4,1," "); //擦除第4行,因为如果前面的数据包比后面的长,不擦除的话,显示后面数据包的时候将遗留前一个数据包的后半部分
//注意这里不能用tab键替换空格,否则OLED显示会出现乱码
OLED_ShowString(4,1,Serial_RxPacket);
if(strcmp(Serial_RxPacket,"LED_ON")==0)
{
LED1_ON();
Serial_SenString("LED_ON_OK\r\n");
OLED_ShowString(2,1," "); //擦除第2行,因为如果前面的数据包比后面的长,不擦除的话,显示后面数据包的时候将遗留前一个数据包的后半部分
OLED_ShowString(2,1,"LED_ON_OK");
}
else if(strcmp(Serial_RxPacket,"LED_OFF")== 0)
{
LED1_OFF();
Serial_SenString("LED_OFF_OK\r\n");
OLED_ShowString(2,1," "); //擦除第2行,因为如果前面的数据包比后面的长,不擦除的话,显示后面数据包的时候将遗留前一个数据包的后半部分
OLED_ShowString(2,1,"LED_OFF_OK");
}
else
{
Serial_SenString("ERROR_COMMAND\r\n");
OLED_ShowString(2,1," "); //擦除第2行,因为如果前面的数据包比后面的长,不擦除的话,显示后面数据包的时候将遗留前一个数据包的后半部分
OLED_ShowString(2,1,"ERROR_COMMAND");
}
Serial_RxFlag = 0;
}
}
}
8.8 FlyMcu串口下载
先点搜索串口,找到我们串口通信的COM号
把STM32芯片上跳线帽拔下来 插在右边两个针脚(更换后如下图所示),配置BOOT0为1,按一下复位键,让程序重新开始运行。这样芯片就进入BootLoader程序了,进入BootLoader程序之后,STM32执行的程序就是、不断接收USART1的数据,刷新到主闪存。
然后回到FlyMcu软件,点击开始编程。
此时,程序通过 BootLoader刷新到主闪存里了,但程序还没开始运行,还处于主闪存刷新状态。
把跳线帽换回到原位置。然后按一下复位。程序开始运行。
为了解决更换跳线帽这个问题,需要配置一键下载电路, 来配置DTR和RTS进而配置BootLoader。
使用上图方案需要更改第一次跳线帽和按第一次复位按钮。
BootLoader模式下才可以下载。
8.9 STLINK Utility
STLINK连接好后,打开STLINK Utility。
STLINK固件更新功能:
第09章 I2C通信
9.1 I2C通信协议
9.1.1 I2C通信
串口通信没有时钟线的异步全双工的协议。
案例:通信协议设计:
某个公司开发了一款芯片,可以干很多事情,比如AD转换、温湿度测量、姿态测量等等。这个芯片里的众多外设也是通过读写寄存器来控制运行的,寄存器本身也是存储器的一种,这个芯片所有的寄存器也都被分配到了一个线性的存储空间,如果我们想要读写寄存器来控制硬件电路,就至少需要定义两个字节数据,一个字节是我们要读写哪个寄存器,也就是指定寄存器的地址,另一个字节就是这个地址下存储器存的内容,写入内容就是控制电路,读出内容就是获取电路状态。这整个流程和单片机操作外设的原理是一样的。那么问题是,单片机读取自己的寄存器,可以直接通过内部的数据总线来实现,直接用指针操作就行,不需要我们操心,但是现在这个模块的寄存器在单片机的外面,要是直接把单片机内部的数据总线拽出来,把2个芯片合为一体是不现实的。所以,设计一个通信协议,在单片机外部连接少量几根线,实现单片机读写外部模块寄存器的功能。
举个例子,如果用串口实现,假设我们的数据包一共三位,前两位代表寄存器地址,后1为为数据内容进行发送。那么,新的需求又来了。
需求:
1.目前串口这个设计,是一个需要两根通信线的全双工协议,但是可以吗明显的发现,我们的这个操作流程是一种基于对话的形式来进行的,我们在整个过程中,并不需要同时进行发送或接收,发送的时候就不需要接收,接收的时候就不需要发送。这样就会导致始终存在一条信号线处于空闲状态,这就是资源的浪费。所以要求1是删掉一根通信线,只能在同一根线上进行发送和接收,也就是把全双工变成半双工。
.我们的串口通信并没有一个应答机制,也就是单片机发送了一个数据,对方有没有收到,单片 机完全不了解的,所以为了安全起见,要求增加应答机制,要求每发送一个字节,对方都要给我一个应答,每接收一个字节,我也要给对方一个应答。
3.一根线上能够同时接多个模块,单片机可以指定和任意一个模块通信,同时单片机在跟某个模块通信时,其它模块不能对正常的通信产生干扰。
4.这个串口是异步的时序,也就是发送方和接收方约定的传输速率是非常严格的,时钟不能有过大的偏差。也不能说,在传输过程中,单片机有点事,进中断了,这个时序能不能中断一下,对异步时序来说,这是不行的,单片机一个字节暂停了,接收方可是不知道的,它仍然会按照原来约定的那个速率读取,这就会导致传输出错。所以异步时序的缺点就是非常依赖硬件外设的支持,必须要有USART电路才能方便地使用,如果没有USART硬件电路地支持,那么串口是很难用软件来模拟的,虽然说软件模拟串口通信也是行得通的,但是由于异步时序,对时间要求很严格,一般我们很少用软件来模拟串口通信。**所以要求是,把这个串口通信的协议改成同步的协议,另外加一条时钟线来指导对方读写,由于存在时钟线,对传输要求就不高了,单片机也可以随时暂停传输,去处理其它事情,因为暂停传输的同时,时钟线也暂停了,所以传输双方都能定格在暂停的时刻,等过一段时间再来继续,不会对传输造成影响。**这就是同步时序的好处,使用同步时序就可以极大地降低单片机对硬件电路的依赖。即使没有硬件电路的支持,也可以很方便地使用软件手动翻转电平来实现通信。比如51单片机里,那个单片机就没有I2C的硬件外设,但是同样不影响51单片机进行软件模拟的I2C通信。异步时序的好处就是省一根时钟线,节省资源;缺点就是对时间要求严格,对硬件电路的依赖比较严重。同步时序的好处就是反过来,对时间要求不严格,对硬件电路不怎么依赖。在一些低端单片机,没有硬件资源的情况下,也很容易使用软件来模拟时序,缺点就是多一根时钟线。考虑到这个协议要主打下沉市场,所以需要一个同步的协议。
I2C(Inter IC Bus)是由Philips公司开发的一种通用数据总线;
两根通信线:SCL(Serial Clock)、SDA(Serial Data);
SCL时钟线满足了使用同步的时序、降低对硬件的依赖,同时同步的时序稳定性也比异步时序更高;然后只有一根SDA数据线,就满足了要求1,变全双工为半双工,一根兼具发送和接收、最大化利用资源。
同步,半双工;
满足要求1。
带数据应答;
满足要求2。
支持总线挂载多设备(一主多从、多主多从)。
满足要求3。
一主多从的意思就是单片机作为主机,主导I2C总线的运行,挂载在I2C总线的所有外部模块都是从机,从机只有被主机点名之后才能控制I2C总线,不能在未经允许的情况下去碰I2C总线,防止冲突。我们使用I2C的场景绝大多数都是一主多从的场景,一个单片机作为主机,挂载一个或多个模块作为从机。
多主多从:也就是多个主机,多主多从的模型,在总线上任何一个模块都可以主动跳出来,说接下来我就是主机,你们都得听我的。就像是在教室里,老师正在讲话,突然有个同学站起来说,老师,打断一下,接下来让我来说,所有同学听我指挥。但是同一个时间只能有一个人讲话,这时候就发生了总线冲突。在总线冲突时,I2C协议会进行仲裁,仲裁胜利的一方取得总线控制权,失败的一方自动变回从机。当然由于时钟线也是由主机控制的,所以在多主机的模型下,还要进行时钟同步,多主机的情况下,协议是比较复杂的。
第1个图片是套件里的MPU6050模块, 可以进行姿态测量,使用了I2C通信协议;
第2个图片是套件里的OLED模块, 可以显示字符、图片等下信息,使用了I2C通信协议;
第3个图片是AT24C02、存储器模块,51单片机教程里学习I2C的模块;
第4个图片是DS3231、实时时钟模块,也是使用I2C通信。
作为一个通信协议,I2C必须要在硬件和软件上,都作出规定,硬件上的规定,就是你的电路应该如何连接,端口的输入输出模式都是啥样的。软件上的规定,就是你的时序是怎么定义的,字节如何传输,高位先行还是低位先行,一个完整的秩序由哪些部分构成。硬件的规定和软件的规定配合起来,就是一个完整的通信协议。
9.1.2 硬件电路
所有I2C设备的SCL连在一起,SDA连在一起;
假设我们就向下图这样连接电路,那如何规定每个设备SCL和SDA地输入输出模式呢?
SCL应该好归档,因为是一主多从,主机拥有SCL地绝对控制权,所以主机的SCL可以配置成推挽输出,所有从机的SCL都配置成浮空输入或者上拉输入。数据流向是主机发送,所有从机接收。但是到SDA线这里就比较麻烦了,因为这是半双工的协议,所以主机的SDA在发送的时候是输出,在接收的时候是输入,同样,从机的SDA也会在输入和输出之间反复切换。如果能协调好输入输出的切换时机,那其实也没问题,但是这样做,如果总线时序没协调好,极有可能发生两个引脚同时处于输出的状态。
设备的SCL和SDA均要配置成开漏输出模式;
SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右。
左边这个图就是I2C的一个典型电路模型,这是一个一主多从的模型,左边CPU就是我们的单片机,作为总线的主机,主机的权力很大,包括对SCL线的完全控制,任何时候,都是主机完全掌控SCL线,另外在空闲状态下,主机可以主动发起对SDA的控制,只有在从机发送数据和从机应答的时候,主机才会转交SDA的控制权给从机,这就是主机的权力。下面都是一系列被控IC,也就是挂载在I2C总线上的从机,这些从机可以是姿态传感器、OLED、存储器、时钟模块等等,从机的权利比较小。对于SCL时钟线,在任何时刻都只能被动的读取,从机不允许控制SCL线,对于SDA数据线,从机不允许主动发起对SDA的控制,只有主机发送读取从机的命令后,或者从机应答的时候,从机才能短暂地取得SDA地控制权,这就是一主多从模型中协议的规定。
右边这一块是SDA的结构,这里DATA就是SDA的意思,首先引脚的信号进来,都可以通过一个数据缓冲器或者是施密特触发器,进行输入,因为输入对电路没有任何影响,所以任何设备在任何时刻都是可以输入的,但是在输出的这部分,采用的是开漏输出的配置。输出低电平,这个开关管导通,引脚直接接地,是强下拉;输出高电平,这个开关管断开,引脚什么都不接,处于浮空状态,这样的话,所有的设备都只能输出低电平而不能输出高电平。为了避免高电平造成的引脚浮空,这时候就需要在总线外面,SCL和SDA各外置一个上拉电阻,这是通过一个电阻拉到高电平的,所以这是一个弱上拉。用之前二点弹簧和杆子模型来解释就是,SCL或SDA就是一根杆子,为了防止有人向上推杆子,有人向下拉杆子,造成冲突,我们就规定,所有的人不允许向上推杆子,只能选择向下拉或者放手。然后我们再外置一根弹簧向上拉,你要输出低电平,就往下拽,这跟弹簧肯定拽不赢你,所以弹簧被拉伸,杆子处于低电平状态,你要输出高电平,就放手,杆子在弹簧的拉力下,回弹到高电平。这就是一个弱上拉的高电平,但是完全不影响数据传输。这样做有什么好处呢?第一,完全杜绝了电源短路现象,保证电路的安全,所有人无论怎么拉杆子或者放手,杆子都不会处于一个被同时强拉和强推的状态,即使有多个人同时向下拉杆子,也没问题。第二,避免了引脚模式的频繁切换,开漏加弱上拉的模式,同时兼具了输入和输出的功能,你要是想输出,就去拉杆子或放手,操作杆子变化就行了,要是想输入,就直接放手,然后观察杆子高低就行了。因为开漏模式下,输出高电平就相当于断开引脚,所以在输入之前可以直接输出高电平,不需要再切换成输入模式了。第三,这个模式会有一个**"线与"**的现象,就是只要有一个或多个设备输出了低电平,总线就处于低电平,只有所有设备都输出高电平,总线才处于高电平,I2C可以利用这个电路特性,执行多主机模式下的时钟同步和总线仲裁,所以这里SCL虽然在一主多从模式下可以使用推挽输出,但是它仍然采用了开漏加上拉输出的模式,因为在多主机模式下会利用到这个特征。
9.1.3 I2C时序基本单元
起始条件:SCL高电平期间,SDA从高电平切换到低电平;
在I2C总线处于空闲状态时,SCL和SDA都处于高电平状态,也就是没有任何一个设备去碰SCL和SDA,SCL和SDA由外挂的上拉电阻拉高至高电平,总线处于平静的高电平状态,当主机需要进行数据收发时,首先就要打破总线的宁静,产生一个起始条件,这个其实条件就是,SCL处于高电平不去动它,然后把SDA拽下来,产生一个下降沿,当从机捕获到这个SCL高电平,SDA下降沿信号时,就会进行自身的复位,等待主机的召唤。然后在SDA下降沿之后,主机要再把SCL拽下来,拽下SCL,一方面是占用这个总线,另一方面也是为了方便我们这些基本单元的拼接,就是我们之后会保证,处理起始和终止条件,每个时序单元的SCL都是以低电平开始,低电平结束。这样这些单元拼接起来,SCL才能续得上。
终止条件:SCL高电平期间,SDA从低电平切换到高电平。
SCL先放手,回弹到高电平,SDA再放手,回弹高电平,产生一个上升沿,这个上升沿触发终止条件,同时终止条件之后,SCL和SDA都是高电平,回归到最初的平静状态。
这个起始条件和终止条件就类似串口时序里的起始位和停止位。一个完整的数据帧,总是以起始条件开始,终止条件结束。另外起始和终止都是由主机产生的,从机不允许产生起始和终止,所以在总线空闲状态时,从机必须始终双手放开,不允许主动跳出来,去碰总线。如果允许的话,那就是多主机模型了。
发送一个字节:SCL低电平期间,主机将数据位依次放到SDA线上(高位先行),然后释放SCL,从机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可发送一个字节。
起始条件之后,第一个字节,也必须是主机发送的,主机如何发送呢?就是最开始,SCL低电平,主机如果想发送0,就拉低SDA到低电平,如果想发送1,就放手,SDA回弹到高电平;在SCL低电平期间,允许改变SDA的电平,当这一位放好之后,主机就松手时钟线,SCL回弹到高电平,在高电平期间,是从机读取SDA的时候,所以高电平期间,SDA不允许变化。SCL处于高电平之后,从机需要尽快地读取SDA,一般都是在上升沿这个时刻,从机就已经读取完成了,因为时钟是主机控制的,从机并不知道什么时候就会产生下降沿了,从机要是磨磨唧唧的主机可不会等你,所以从机在上升沿时,就会立刻把数据读走。那主机在放手SCL一段时间后,就可以继续拉低SCL,传输下一位了,主机也需要在SCL下降沿之后尽快把数据放在SDA上,但是主机有时钟的主导权,所以主机并不需要那么着急,只需要在低电平的任意时刻把数据放在SDA上就行了,晚点也没关系,数据放完之后,主机再松手SCL,SCL高电平,从机读取这一位。就这样的流程,主机拉低SCL,把数据放在SDA上,主机松开SCL,从机读取SDA的数据,在SCL的同步下,依次进行主机发送和从机接收,循环8次,就发送了8位数据,也就是1个字节。
另外要注意的是,这里是高位先行,所以第一位是一个字节的最高位B7,依次是次高位...最后发送最低为B0。这个和串口是不一样的,串口时序是低位先行,这里I2C是高位先行。
另外,由于这里有时钟线去同步,所以如果主机一个字节发送一半,突然进中断了,不操作SCL和SDA了,那时序就会在中断的位置不断拉长,SCL和SDA电平都暂停变化,传输也完全暂停,等中断结束后,主机回来继续操作,传输仍然不会出问题,这就是同步时序的好处,最后就是,由于这整个时序是主机发送一个字节,所以在这个单元里,SCL和SDA全程都由主机掌控,从机只能被动读取,这就是发送一个字节的时序。
接收一个字节:SCL低电平期间,从机将数据位依次放到SDA线上(高位先行),然后释放SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节(主机在接收之前,需要释放SDA) 。
释放SDA其实就相当于切换成输入模式,或者这样来理解,所有设备包括主机都始终处于输入模式,当主机需要发送的时候,就可以主动去拉低SDA,而主机在被动接收的时候,就必须先释放SDA,不要去动它,以免影响别人发送。因为总线是"线与"的特征,任何一个设备拉低了,总线就是低电平,如果你接收的时候,还拽着SDA不放手,那别人无论发什么数据,总线始终都是低电平,你自己给它拽着不放,还让别人怎么发送呢。所以主机在接收之前,需要先释放SDA。
从流程上来看,接收一个字节和发送一个字节是非常相似的,区别就是发送一个字节是,低电平主机发数据,高电平从机读数据;而接收一个字节是,低电平从机放数据,高电平主机读数据。
主机在接收之前要先释放SDA,然后这时从机就取得了SDA的控制权,从机需要发送0,就把SDA拉低,从机需要发送1,就放手,SDA回弹高电平;然后同样的,低电平变换数据,高电平读取数据。下图SDA时序实线部分表示主机控制的电平,虚线部分表示从机控制的电平,SCL全程由主机控制,SDA主机在接受前要释放,交由从机控制。之后还是一样,因为SCL始终是由主机控制的,所以从机的数据变换基本都是贴着SCL下降沿进行的;而主机可以在SCL高电平的任意时刻读取,这就是接收一个字节的时序。
应答机制分为发送应答和接收应答,它们的时序分别和发送一个字节,接收一个字节的其中一位是相同的。可以理解位发送给一位和接收一位,这一位就用来做应答。
发送应答:主机在接收完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答;
接收应答:主机在发送完一个字节之后,在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答(主机在接收之前,需要释放SDA)。
右图:我们在调用发送一个字节之后,就要紧跟着接收应答的时序,用来判断从机有没有收到刚才给它的数据,如果从机收到了,那在应答位这里,主机释放SDA的时候,从机就应该立刻把SDA拉下来,然后在SCL高电平期间,主机读取应答位,如果应答位为0,就说明从机确实收到了。这个场景就是,主机刚发送一个字节,然后说有没有人收到啊,我现在要把SDA放手了哈,如果有人收到的话,就把SDA拽下来,然后主机高电平读取数据,发现,确实有人给它拽下来了,那就说明有人收到了;如果主机发现,我松手了,结果这个SDA就跟着回弹到高电平了,那就说明没有人回应我,刚发的一个字节可能没人收到,或者它收到了但是没给我回应,这就是发送一个字节,接收应答的流程。
左图:同理,在接收一个字节之后,我们也要给从机发送一个应答位,发送应答位的目的是告诉从机,你是不是还要继续发;如果从机发送一个数据后,得到了主机的应答,那从机就还会继续发送,如果从机没得到主机的应答,那从机就会认为,我发送了一个数据,但是主机不理我,可能主机不想要了吧,这时从机就会乖乖地释放SDA,交出SDA的控制权,防止干扰主机之的操作。
9.1.4 I2C时序
我们的I2C是一主多从的模型,主机可以访问从线上的任何一个设备,那如何发出指令,来确定要访问的是哪个设备呢,这就需要首先把每个设备都确定一个唯一的设备地址,从机设备地址就相当于每个设备的名字,主机在起始条件之后,要先发送一个字节叫一下从机名字,所有从机都会收到第一个字节,和自己的名字进行比较,如果不一样,则认为主机没有叫我,之后的时序我就不管了,如果一样,就说明,主机现在在叫我,那我就响应之后主机的读写操作。在同一条I2C总线里,挂载的每个设备地址必须不一样,否则主机叫一个地址,有多个设备响应,就乱套了。
从机设备地址,在I2C协议标准里分为7位地址和10位地址,目前只讲7位地址的模式,因为7位地址比较简单而且应用范围最广。在每个I2C设备出厂时,厂商都会为它分配一个7位的地址,这个地址具体是什么可以在芯片手册里找到。
比如MPU6050这个芯片的地址是1101 000;AT24C02的7位地址是1010 000。一般不同型号的芯片地址都是不同的,相同型号的芯片地址都是一样的。那如果有相同的芯片挂载在同一条总线上怎么办呢?这就需要用到地址中的可变部分了,一般器件地址的最后几位是可以在电路中改变的,比如MPU6050的最后一位,就可以由板子上的AD0引脚确定,这个引脚接低电平,那它的地址就是1101 000,这个引脚接高电平,那它的地址就是1101 001;比如AT24C02的最后三位,都可以分别由这个板子上的A0、A1、A2引脚确定,比如A0引脚接低电平,地址对应的位就是0,接高电平对应的位就是1,A17、A2也是同理。一般I2C的从机设备地址,高位都是由厂商确定的,低位可以由引脚来灵活切换,这样即使相同型号的芯片,挂载在同一个总线上,也可通过切换地址低位的方式,保证每个设备的地址都不一样,这就是I2C设备的从机地址。
指定地址写;
对于指定设备(Slave Address),在指定地址(Reg Address)下,写入指定数据(Data)。
Slave Address:从机地址;
Reg Address:某个设备内部的寄存器地址。
空闲状态,它两都是高电平, 然后主机需要给从机写入数据的时候;首先SCL高电平期间,拉低SDA,产生起始条件(Start,s),在起始条件之后,紧跟着的时序,必须是发送一个字节的时序,字节的内容,必须是从机地址+读写位,正好从机地址是7位,读写位是1位,加起来是一个字节,8位,发送从机地址,就是确定通信的对象,发送读写位,就是确定我们接下来是要写入还是要读出。具体发送的时候,在这里,低电平期间,SDA变换数据,高电平期间,从机读取SDA,绿色的线标明了从机读到的数据,比如上如所示波形,从机收到的第一位就是高电平1,然后SCL低电平,主机继续变换数据,因为第二位还是1,所以这里SDA电平没有变换,然后SCL高电平,从机读到第二位是1,之后继续,低电平变换数据,高电平读取数据,第三位就是0,这样持续8次,就发送了一个字节数据。其中这个数据的定义是,高7位表示从机地址,比如这个波形下,主机寻找的地址就是1101 000,这个就是MPU6050的地址;然后最低为表示读写位,0表示,之后的时序,主机要进行写入操作;1表示之后的时序主机要进行读出操作,这里是0说明之后我们要进行写入操作。目前,主机发送了一个字节,字节的内容转换为16进制,高位先行,就是0xD0,然后根据协议规定,紧跟着的单元,就得是接收从机的应答位(Receive Ack,RA),在这个时刻,主机要释放SDA,由于SDA被从机拽住了,所以主机松手后,SDA并没有回弹高电平,这个过程就代表从机产生了应答,最终高电平期间,主机读取SDA,发现是0,就说明我进行寻址,有人给我应答了,传输没问题;如果主机读取SDA,发现是1,就说明我进行寻址,应答位期间,我松手了,但是没拽住它,没人给我应答,那就直接产生停止条件,并提示一些信息,这就是应答位。然后从机产生上升沿,从机释放SDA,交出SDA的控制权,因为从机要在低电平尽快变换数据,所以这个上升沿和SCL的下降沿几乎是同时发生的。继续往后,之前我们读写位给了0,所以我们继续发送一个字节,同样的时序,再来一遍,第二个字节,就可以送到指定设备的内部了。从机设备可以自己定义第二个字节和后续字节的用途,一般第二个字节可以是寄存器地址或者是指令控制字等,比如MPU6050定义的第二个字节就是寄存器地址;AD转换器,第二个字节可能就是指令控制字;存储器第二个字节可能就是存储器地址。接着是从机应答,主机释放SDA,从机拽住SDA,SDA表现为低电平,主机收到应答位为0,表示收到了从机的应答。然后继续,同样的流程再来一遍,主机再发送一个字节,这个字节就是主机想要写入到0x19地址寄存器的内容了,最后是接收应答位。如果主机不需要继续传输了,就可以产生停止条件(Stop,p),再停止条件之前,先拉低SDA,为后续SDA的上升沿做准备,然后释放SCL,再释放SDA,这样就产生了SCL高电平期间,SDA的上升沿,这样一个完整的数据帧就拼接完了。
这个数据帧的作用就是,对于指定从机地址位1101 000的设备,在其内部0x19地址的寄存器中,写入0xAA这个数据。
当前地址读;
对于指定设备(Slave Address),在当前地址指针指示的地址下,读取从机数据(Data)。
最开始,还是SCL高电平期间,拉低SDA,产生起始条件, 起始条件开始后,主机必须首先调用发送一个字节,来进行从机的寻址和指定读写标志位,比如图示的波形,表示本次寻址的目标是1101 000的设备,同时,最后一位读写标志为1,表示主机接下来需要读取数据,紧跟着发送一个字节之后,接收一下从机应答位,从机应答0,代表从机收到了第一个字节,在从机应答之后,从这里开始,数据的传输方向就要反过来了,因为主机刚刚发了读的命令,所以主机就不能再继续发送了,要把SDA的控制权交给从机,主机调用接收一个字节的时序,进行接收操作。从机得到主机的允许后,可以再SCL低电平期间,写入SDA,然后主机再SCL高电平期间读取SDA,那最终,主机在SCL高电平期间,依次读取8位,就接收到了从机发送的一个字节数据0000 1111也就是0x0F,那这个0x0F是从机哪个寄存器的数据呢?在读的过程中,I2C协议的规定是,主机进行寻址时,一旦读写标志位给1了,下一个字节就要立马转为读的时序,所以主机还来不及指定,我想要读哪个寄存器,就得开始接收了,所以这里没有指定地址的这个环节,那主机并没有指定寄存器的地址,从机到底该发哪个寄存器的数据呢?就需要用到上面说的,当前地址指针了,在从机中,所有的寄存器被分配到了一个线性区域中,并且会有一个单独的指针变量,指示着其中一个寄存器,这个指针上电默认一般指向0地址,并且每写入一个字节和读出一个字节后,这个指针就会自动自增一次,移动到下一个位置,那么在调用当前地址读的时序时,主机没有指定要读哪个地址,从机就会返回当前指针指向的寄存器的值。
那假设,刚刚调用了这个指针地址写的时序,在0x19的位置写入了0xAA,那么指针就会+1,移动到0x1A的位置,再调用当前地址读的时序,返回的就是0x1A地址下的值,如果在调用一次返回的就是0x1B地址下的值,依次类推。这就是当前地址读时序的操作逻辑,由于当前地址读并不能指定读的地址,所以这个时序用的不是很多。
指定地址读;
对于指定设备(Slave Address),在指定地址(Reg Address)下,读取从机数据(Data)。
最开始,仍然是启动条件, 然后发送一个字节进行寻址,这里指定从机地址是1101000,读写标志位是0,代表我要进行写的操作,经过从机应答之后,再发送一个字节;第二字节用来指定地址,这个数据就写入到了从机的地址指针里了,也就说说从机接收到这个数据之后,它的寄存器指针就指向了0x19这个位置,之后我们要写入的数据,不给它发,而是直接再来个起始条件,这个Sr(Start Repeat)的意思就是重复起始条件,相当于另起一个时序,因为指定读写标志位只能是跟着起始条件的第一个字节,所以如果想切换读写方向,只能再来个起始条件,然后起始条件后,重新寻址并且指定读写标志位,此时读写标志位是1,代表我要开始读了。接着主机接收一个字节,这个字节就是0x19地址下的数据。这就是指定地址读。在写入时序后面也可以再加一个停止条件,这样也行,这样的话,这就是两个完整的时序了。先起始,写入地址,停止,因为写入的地址会存在地址指针里面,所以这个地址并不会因为时序的停止而消失,我们就可以再起始,读当前位置,停止。这样两条时序,也可以完成任务。但是I2C官方规定的复合格式是一整个数据帧,就是先起始,再重复起始,再停止,相当于把两条时序拼接成1条了。
如果指向读一个字节就停止的话,一定要给从机发个非应答(Send Ack,SA),非应答就是该主机应答的时候,主机不把SDA拉低,从机读到SDA为1,就代表主机没有应答,从机收到非应答之后,就知道主机不想要再继续了,从机就会释放总线,把SDA的控制权交还给主机。如果主机读完仍然给从机应答了,从机就会认为主机还想要数据,就会继续发送下一个数据,而这时,主机如果想要产生停止条件,SDA可能就会因为被从机拽住了,而不能正常弹回高电平。如果主机想连续读取多个字节,就需要再最后一个字节给非应答,而之前的所有字节都要给应答。简单来说就是主机如果给应答了,从机就会继续发,主机给非应答了,从机就不会再发了,交出SDA的控制权。从机控制SDA发送一个字节的权利,开始于读写标志位1,结束于主机给应答为1,这就是主机给从机发送应答位的作用。
9.2 MPU6050简介
9.2.1 MPU6050简介
MPU6050是一个6轴姿态传感器,可以测量芯片自身X、Y、Z轴的加速度、角速度参数,通过数据融合,可进一步得到姿态角(欧拉角),常应用于平衡车、飞行器等需要检测自身姿态的场景;
欧拉角是什么:以飞机为例,欧拉角就是飞机机身相对于初始3个轴的夹角:飞机机头下倾或者上仰,这个轴的夹角叫做俯仰,Pitch;飞机机身左翻滚或者右翻滚,这个轴的夹角叫作滚转,Roll;飞机机身保持水平,机头向左转或者向右转向,这个轴的转向叫做偏航,Yaw。
为了保持飞机姿态平稳,那么得到一个精确且稳定的欧拉角就至关重要;但是可惜的是,加速度计、陀螺仪、磁力计任何一种传感器都不能获得精确且稳定的欧拉角,要想获得精确且稳定的欧拉角,就必须进行数据融合,把这几种传感器的数据结合起来,综合多种传感器的数据,取长补短,这样才能获得精确且稳定的欧拉角。常见的数据融合算法,一般有互补滤波、卡尔曼滤波等等,这就涉及到惯性导航领域里,姿态结算的知识点了。本节课的侧重点是I2C通信,最终的程序现象就是把这些传感器的原始数据读出来,显示在OLED上,就结束了。所以有关姿态解算的内容暂时不会涉及。
3轴加速度计(Accelerometer):测量X、Y、Z轴的加速度;
在XYZ轴,这个芯片内部都分别布置了一个加速度计。如下图所示:水平的这根线是感应轴线,中间是一个具有一定质量、可以左右滑动的小滑块,左右各有一个弹簧顶着它。如果把这个东西拿在手上,来回晃,中间这个小滑块就会左右移动去压缩或者拉伸两边的弹簧,当滑块移动时,就会带动上面的电位器滑动,这个电位器其实就是一个分压电阻,然后我们测量电位器输出的电压,就能得到小滑块所受到的加速度值了。这个加速度计,实际上就是一个弹簧测力计,根据就牛顿第二定律,F=ma,我们想测量加速度a,就可以找一个单位质量的物体,测量它所受的力F就行了。
加速度计具有静态稳定性,不具有动态稳定性。
3轴陀螺仪传感器(Gyroscope):测量X、Y、Z轴的角速度。
当中间的旋转轮高速旋转时,根据角动量守恒的原理,这个旋转轮具有保持它原有角动量的趋势,这个趋势可以保持旋转轴方向不变,当外部物体的方向转动时,内部的旋转轴方向并不会转动,这就会子啊平衡环连接处产生角度偏差,如果我们在连接处放一个旋转的电位器,测量电位器的电压,就能得到旋转的角度了。从这里分析,陀螺仪是可以直接得到角度的,但是我们这个MPU6050的陀螺仪,并不能直接测量角度,可能是结构的差异或者是工艺的限制。我们这个芯片测量的实际上是角速度,而不是角度。
如果想通过角速度得到角度,只需要对角速度进行积分即可,角速度积分,就是角度,和加速度计测角度也是一样。这个角速度积分得到的角度也有局限性,就是当物体静止时,角速度值会因为噪声无法完全归零,然后经过积分的不断累积,这个小噪声就会导致计算出来的角度产生缓慢的漂移。也就是角速度积分得到的角度经不起时间的考验。不过这个角度呢,无论是运动还是静止都是没问题的,不会受物体运动的影响。
所以总结下来就是,陀螺仪具有动态稳定性、不具有静态稳定性。
加速度计具有静态稳定性,不具有动态稳定性;陀螺仪具有动态稳定性、不具有静态稳定性。这两种传感器的特性正好互补,所以我们取长补短,进行一下互补滤波。就能融合得到静态和动态都稳定的姿态角了。
如果芯片里再集成一个3轴的磁场传感器,测量XYZ轴的磁场强度,那就叫做9轴传感器;如果再集成一个气压传感器,测量气压大小,那就叫做10轴传感器。一般气压值反应的是高度信息,海拔越高,气压越低,所以气压计是单独测量垂直地面的高度信息的。
9.2.2 MPU6050参数
16位ADC采集传感器的模拟信号,量化范围:-32768~32767
加速度计满量程选择:±2、±4、±8、±16(g)
-32768~32767对应±2、±4、±8、±16等模拟量。
陀螺仪满量程选择: ±250、±500、±1000、±2000(°/sec)
-32768~32767对应±250、±500、±1000、±2000(°/sec)等模拟量。
可配置的数字低通滤波器
可以配置寄存器来选择对输出数据进行低通滤波,如果觉得输出数据抖动太厉害,就可以加一点低通滤波。这样输出数据就会平缓一些。
可配置的时钟源
可配置的采样分频
上面两个参数是配合使用的,时钟源经过分频器的分频,可以为AD转换和内部其它电路提供时钟。控制分频系数就是,就可以控制AD转换的快慢了。
I2C从机地址:1101000(AD0=0)
1101001(AD0=1)
在程序中,如果用16进制表示上述7位地址,一般有两种表示方式,以1101000为例:
第一种,单纯地把这7位二进制数转换为16进制,这里110 1000低4位和高3位切开,转换16进制就是0x68,所以有的地方就说MPU6050的从机地址是0x68;如果认为0x68是从机地址的话,在发第一个字节时,要先把0x68左移1位,在按位或上读写位,读1写0。
第二种,把0x68左移一位后的数据当作从机地址,0x68左移一位之后是0xD0,那种这样MPU6050的从机地址就是0xD0,这时,在实际发送第一个字节时,如果要写,就把0xD0当作第一个字节,如果要读,就把0xD0或上0x01,即0xD1当作第一个字节。这种表示方式就不需要进行左移的操作了。或者说这种表示方式是把读写位也融入到从机地址里了。
9.2.3 硬件电路
LDO:低压差线性稳压器。
右边MPU6050芯片:芯片本身的引脚是非常多的,包括时钟、I2C通信引脚、供电、帧同步等等,不过这里很多引脚我们都用不到,还有一些引脚,是这个芯片的最小系统里的固定连接,这个最小系统电路,一般手册里都会有。
左下角:引出来的引脚有VCC和GND,这两个引脚是电源供电,然后SCL和SDA,SCL和SDA,在模块内部已经内置了两个4.7K的上拉电阻了,所以在接线的时候,直接把SCL和SDA接在GPIO口就行了。不需要再在外面另外接上拉电阻了。XCL和XDA,这两个是芯片里面的主机I2C通信引脚,设计这两个引脚是为了扩展芯片功能,MPU6050是一个6轴姿态传感器,但是只有加速度计和陀螺仪的6个轴,融合出来的姿态角是有缺陷的,这个缺陷就是绕Z轴的角度,也就是偏航角,它的漂移无法通过加速度计进行纠正。就相当于坐在车里,不看任何窗户,然后辨别当前车子的行驶方向,短时间内可以通过陀螺仪得知方向的变化,从而确定变化后的行驶方向,但是时间一长,车子到处转弯,没有稳定的参考了,就会迷失方向,所以这时候就要带个指南针在上边,提供长时间的稳定偏航角进行参考。XCL和XDA,通常就是用于外界磁力计或者气压计,当接上磁力计或气压计之后,MPU6050的主机接口可以直接访问这些扩展芯片的数据。把这些扩展芯片的数据读取到MPU6050里面,在MPU6050里面,会有DMP单元进行数据融合和姿态解算。当然如果不需要MPU6050的姿态解算功能的话,也可以把这个磁力计或者气压计直接挂载在SCL和SDA这条总线上,因为I2C本来就可以挂载很多设备,所以把多个设备挂载在一起也是没问题的。这是XCL和XDA的用途。
AD0:接低电平的话,7位从机地址就是1101 000;接高电平的话,7位从机地址就是1101 001。电路中可以看到,有一个电阻,默认弱下拉到低电平了。所以引脚悬空的话,就是低电平。如果想接高电平,就可以把AD0直接引到VCC,强上拉至高电平。
INT:终端输出引脚,可以配置芯片内部的一些事件,来触发中断引脚的输出,比如数据准备好了,I2C主机错误等等。另外芯片内部还内置了一些实用的小功能,比如自由落体检测、运动检测、零运动检测等。这些信号都可以触发INT引脚产生电平跳变,需要的话可以进行中断信号的配置,如果不需要的话,就可以不配置。
LDO:供电的逻辑,从手册里可以查到,这个MPU6050芯片的VDD供电是2.375~3.46V,属于3.3V供电的设备,不能直接接5V,所以为了扩大供电范围,这个模块的设计者就加了个3.3V的稳压器,输入端电压VCC_5V可以在3.3V到5V之间,然后经过3.3V的稳压器,给芯片端供电。右边的电源指示灯,只要3.3V端有电,电源指示灯就会亮,所以这一块需不需要可以根据项目要求来。如果已经有了稳定的3.3V电源了,就不再需要这一部分了。
|---------|-----------|
| 引脚 | 功能 |
| VCC、GND | 电源 |
| SCL、SDA | I2C通信引脚 |
| XCL、XDA | 主机I2C通信引脚 |
| AD0 | 从机地址最低位 |
| INT | 中断信号输出 |
9.2.4 MPU6050框图
左上角是时钟系统,有时钟输入脚和输出脚,不过我们一般使用内部时钟。硬件电路那里,CLKIN直接接了GND,CLKOUT没有引出,所以这部分不需要过多关心。
下面灰色的部分就是芯片内部的传感器,这个芯片还内置了一个温度传感器,用它来测量温度也是没问题的。这么多传感器本质上也都相当于可变电阻,通过分压后,输出模拟电压,然后通过ADC,进行模数转换,转换完成之后,这些传感器的数据同一都放到数据寄存器中,我们读取数据寄存器就能得到传感器测量的值了。这个芯片内部的转换,都是全自动进行的,就类似我们之前学的AD连续转换+DAM转运,每个ADC输出,对应16位的数据寄存器,不存在数据覆盖的问题,我们配置好转换频率之后,每个数据就自动以我们设置的频率刷新到数据寄存器,我们需要数据的时候,直接来读就行了。每个传感器都有个自测单元(Self test),这部分是用来验证芯片好坏的,当启动自测后,芯片内部就会模拟一个外力施加在传感器上,这个外力导致传感器数据会比平时大一些,那如何进行自测呢?我们可以线使能自测,读取数据;再失能自测,读取数据,两个数据一相减,得到的数据叫自测响应,这个自测响应,芯片手册里给出了一个范围,如果自测响应在这个范围内,就说明芯片没有问题,如果不在,就说明芯片可能坏了,使用的话就要小心点。
Charge Pump:电荷泵,或者叫充电泵,CPOUT引脚需要外接一个电容,什么样的电容在手册里有说明,电荷泵是一种升压电路,在其它地方也有出现过,比如我们用的OLED屏幕,里面就有电荷泵进行升。
电荷泵的升压原理:首先电池和电容并联,电池给电容充电,充满之后,电容也相当于一个5V的电池了,然后修改电路的接法,让电源与电容串联起来,这样就相当于输出10V电压了,不过由于电容电荷较少,用一下就不行了,所以这个并联、串联的切换速度要快,乘电容还没放电完,就要及时并联充电,这样一直持续,并联充电、串联放电、并联充电、串联放电,然后后续在加个电源滤波,就能进行平稳地升压了。这就是电荷泵地升压原理。
由于陀螺仪内部是需要一个高电压支持的, 所以这里设计了一个电荷泵进行升压,这个升压过程是自动的,不需要我们管。
右边这一大块就是寄存器和通信接口的部分了。
中断状态寄存器:可以控制内部的哪些事件到中断引脚的输出;
FIFO:先入先出寄存器,可以对数据流进行缓存;
Config Registers(配置寄存器):可以对内部的各个电路进行配置;
传感器寄存器:也就是数据寄存器,存储了各个传感器的数据;
工厂校准:这个意思就是内部的传感器都进行了校准;
Digital Motion Processor(DMP):数字运动处理器,是芯片内部自带一个姿态解算的硬件算法,配合官方的DMP库,可以进行姿态解算;
FSYNC:帧同步;
上面部分用于和STM32通信,下面这一部分是主机的I2C通信接口,用于和MPU6050扩展的设备进行通信;
Serial Interface Bypass Mux:接口旁路选择器,就是一个开关,如果拨到上面,辅助的I2C引脚就和正常的I2C引脚接到一起,这样两路总线就合在一起了,STM32可以控制所有设备;如果拨到下面,辅助的I2C引脚就由MPU6050控制,两条I2C总线独立分开。
Bias&LDO:供电部分。
9.2.5 MPU6050器件说明书
略
9.2.6 MPU6050寄存器映像手册
寄存器总表:
第1列是16进制表示的寄存器地址;第2列是十进制表示的寄存器地址;第3列是寄存器的名称;第4列是读写权限,RW代表可读可写,R代表只读;后面是寄存器内的每一位的名字。
|--------------|-------|----------------------------|
| 寄存器英文名称 | 中文名称 ||
| SMPLRT_DIV | 采样分频器 ||
| CONFIG | 配置寄存器 ||
| GYRO_CONFIG | 陀螺仪配置寄存器 ||
| ACCEL_CONFIG | 加速度计配置寄存器 ||
| ACCEL_XOUT_H | 数据寄存器 | 加速度计XYZ轴 (_H表示高8位,_L表示低8位) |
| ACCEL_XOUT_L | 数据寄存器 | 加速度计XYZ轴 (_H表示高8位,_L表示低8位) |
| ACCEL_YOUT_H | 数据寄存器 | 加速度计XYZ轴 (_H表示高8位,_L表示低8位) |
| ACCEL_YOUT_L | 数据寄存器 | 加速度计XYZ轴 (_H表示高8位,_L表示低8位) |
| ACCEL_ZOUT_H | 数据寄存器 | 加速度计XYZ轴 (_H表示高8位,_L表示低8位) |
| ACCEL_ZOUT_L | 数据寄存器 | 加速度计XYZ轴 (_H表示高8位,_L表示低8位) |
| TEMP_OUT_H | 数据寄存器 | 温度传感器 (_H表示高8位,_L表示低8位) |
| TEMP_OUT_L | 数据寄存器 | 温度传感器 (_H表示高8位,_L表示低8位) |
| GYRO_XOUT_H | 数据寄存器 | 陀螺仪XYZ轴 (_H表示高8位,_L表示低8位) |
| GYRO_XOUT_L | 数据寄存器 | 陀螺仪XYZ轴 (_H表示高8位,_L表示低8位) |
| GYRO_YOUT_H | 数据寄存器 | 陀螺仪XYZ轴 (_H表示高8位,_L表示低8位) |
| GYRO_YOUT_L | 数据寄存器 | 陀螺仪XYZ轴 (_H表示高8位,_L表示低8位) |
| GYRO_ZOUT_H | 数据寄存器 | 陀螺仪XYZ轴 (_H表示高8位,_L表示低8位) |
| GYRO_ZOUT_L | 数据寄存器 | 陀螺仪XYZ轴 (_H表示高8位,_L表示低8位) |
| PWR_MGMT_1 | 电源管理寄存器1 ||
| PWR_MGMT_2 | 电源管理寄存器2 ||
| WHO_AM_I | 器件ID号 ||
(1)SMPLRT_DIV(采样分频器) :里面的8位作为一个整体,作为分频值,这个寄存器可以配置采样频率的分频系数,简单来说就是分频越小,内部的AD转换越快,数据寄存器刷新就越快;反之越慢
采样频率(数据刷新率) = 陀螺仪输出时钟频率/(1+分频值)
这个时钟就是我们刚才说的那个时钟源, 内部晶振、陀螺仪晶振和外部时钟引脚的方波,这里直接就是以外陀螺仪晶振作为例子了,陀螺仪时钟/这个寄存器指定的分频系数,就是采样频率。不使用低通滤波器时,陀螺仪时钟为8KHz,使用滤波器了,时钟就是1KHz了。
(2)CONFIG(配置寄存器):配置寄存器,内部有两部分,外部同步设置和低通滤波器配置。
低通滤波器,可以选择上表所示各个参数,这个低通滤波器可以让输出数据更加平滑,配置滤波器参数越大,输出数据抖动就越小,0是不使用低通滤波器,陀螺仪时钟为8KHz,之后使用了滤波器,陀螺仪时钟就是1KHz, 最大的参数是保留位,没有用到。
(3)GYRO_CONFIG(陀螺仪配置寄存器):高3位是XYZ轴的自测使能位,中间两位是满量程选择位,后面三位没用到
自测的用法:
自测响应 = 自测使能时的数据-自测失能时的数据
上电后,先使能自测,读取数据,再失能自测,读取数据,两者相减得到自测响应。如果在下图所示范围里,芯片就通过了自测。
满量程选择:量程越大,范围越广,量程越小,分辨率越高。
(4)ACCEL_CONFIG(加速度计配置寄存器):
高三位是自测使能位,中间两位是满量程选择,最后三位是配置高通滤波器的,这个高通滤波器就是内置小功能,运动检测用到的,对数据输出没有影响。
(5) 加速度计数据寄存器
我们想读取数据的话,直接读取数据寄存器就行了。
这是一个16进制的有符号数,以二进制补码的方式存储,我们读出高8位和低8位,高位左移8次,或上低位数据,最后再存在一个int16_t的变量里, 这样就可以得到数据了。
(6)温度传感器数据寄存器
(7)陀螺仪数据寄存器
(8)电源管理寄存器1
第1位、设备复位, 这一位写1,所有寄存器都恢复到默认值;
第2位、睡眠模式,这一位写1,芯片睡眠,芯片不工作,进入低功耗;
第3位、循环模式,这一位写1,设备进入低功耗,过一段时间,启动一次,并且唤醒的频率,由下一个寄存器的高2位确定
第5位、温度传感器失能,写1之后,禁用那日不的温度传感器;
最后3位、用来选择系统时钟来源。
上表: 内部晶振、XYZ轴陀螺仪晶振、外部引脚的两个方波,一般我们选择内部晶振或者陀螺仪晶振。
(9)电源管理寄存器2
后面6位可以分别控制6个轴进入待机模式,如果只需要部分轴的数据,可以让其它轴待机,这样比较省电。
(10)器件ID号
这个寄存器是只读的,ID号不可以修改,ID号中间这6位固定为110100,实际上这个ID号就是这个芯片的I2C地址,它的最高位和最低为其实都是0,那读出这个寄存器,值就固定位0x68。AD0引脚的值并不反映在这个寄存器上,意思就是,之前我们说过这个I2C地址可以通过AD0引脚进行配置, 但是这里的ID号的最低位是不随AD0引脚变化而变化的,读出ID号,始终都是0x68,当然这个ID号也不是非要和I2C地址一样。
所有的寄存器上电默认值都是0x00,除了下面这两个。
117是器件ID号,107是电源管理寄存器1,默认是0x40,也就是 次高位为1,这里次高位是SLEEP,所以这个芯片上电默认就是睡眠模式,我们在操作它之前,要先记得接触睡眠,否则操作其它寄存器是无效的。
9.3 软件I2C读写MPU6050
9.3.1 硬件电路
9.3.2 软件部分
(1)复制《OLED显示屏》工程并改名为《软件I2C读写MPU6050》
(2)添加驱动文件
(3) MyI2C.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h"
/*封装SCL电平翻转函数*/
void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB,GPIO_Pin_10,(BitAction) BitValue);
Delay_us(10);
}
/*封装SDA电平翻转函数*/
void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB,GPIO_Pin_11,(BitAction) BitValue);
Delay_us(10);
}
/*封装读取SDA函数*/
uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB,GPIO_Pin_11);
Delay_us(10);
return BitValue;
}
/*初始化函数*/
/*我们使用的是软件I2C,所以库函数I2C不用看了,自己实现*/
/*
第1个任务:把SCL和SDA都初始化为开漏输出模式
第2个任务:把SCL和SDA置高电平
*/
void MyI2C_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD; //把GPIO端口配置成开漏输出模式
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10|GPIO_Pin_11;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB,&GPIO_InitStruct);
GPIO_SetBits(GPIOB,GPIO_Pin_10|GPIO_Pin_11); //释放总线,让SCL和SDA处于高电平
}
/*I2C起始条件函数*/
void MyI2C_Start(void)
{
MyI2C_W_SDA(1); //释放SCL和SDA,让SCL和SDA处于空闲状态,为了能够兼容重复起始条件,将释放SDA放在前面
MyI2C_W_SCL(1);
MyI2C_W_SDA(0); //先拉低SDA
MyI2C_W_SCL(0); //再拉低SCL
}
/*I2C终止条件函数*/
/*stop开始时,SCL和SDA都已经是低电平了,但是在释放时序单元开始时,SDA不一定是低电平,为了确保之后释放SDA能产生上升沿,
所以再时序单元开始时先拉低SDA,然后再释放SCL、释放SDA*/
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0);
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
}
/*I2C发送字节函数*/
void MyI2C_SendByte(uint8_t Byte)
{
uint8_t i;
for(i=0;i<8;i++) //循环发送8位数
{
MyI2C_W_SDA(Byte & (0x80>>i)); //在SCL处于低电平状态,将数据发送出去
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); //主机释放SCL,这时就能读取数据了
if(MyI2C_R_SDA() == 1){ Byte |= (0x80 >> i); } //读取数据
MyI2C_W_SCL(0); //拉低SCL,开始读取下一位数据
}
return Byte;
}
/*I2C发送应答函数*/
void MyI2C_SendAck(uint8_t AckBit)
{
MyI2C_W_SDA(AckBit); //函数进来时,SCL处于低电平状态,主机把数据放到SDA上。
MyI2C_W_SCL(1); //释放SCL,从机立刻把刚才放在SDA的数据读走
MyI2C_W_SCL(0); //再拉低SCL,驱动时钟走一个脉冲,可以继续放下一位数据
}
/*I2C接收应答函数*/
/*函数进来时,SCL低电平,主机释放SDA,防止干扰从机*/
uint8_t MyI2C_ReceiveAck(void)
{
uint8_t AckBit;
MyI2C_W_SDA(1); //主机释放SDA,然后从机把应答位放在SDA上
MyI2C_W_SCL(1);
AckBit = MyI2C_R_SDA(); //主机读取应答位
MyI2C_W_SCL(0); //SCL低电平,进入下一个时序单元
return AckBit;
}
(4) MyI2C.h
cpp
#ifndef __MYI2C_
#define __MYI2C_
void MyI2C_W_SCL(uint8_t BitValue);
void MyI2C_W_SDA(uint8_t BitValue);
uint8_t MyI2C_R_SDA(void);
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
(5) MPU6050_Reg.h
cpp
/*定义我们要用到的MPU6050内部寄存器的名称*/
#ifndef __MPU6050_
#define __MPU6050_
#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
(6) MPU6050.c
cpp
#include "stm32f10x.h" //Device header
#include "MyI2C.h"
#include "MPU6050_Reg.h"
#define MPU6050_ADDRESS 0xD0 //MPU6050的从机地址
/*使用I2C向MPU6050发送数据*/
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();
}
/*使用I2C向MPU6050读取数据*/
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|0xD1); //现在要让MPU6050写地址,最后一位通过或操作变为1
MyI2C_ReceiveAck();
Data = MyI2C_ReceiveByte();
MyI2C_Stop();
return Data;
}
/*MPU6050初始化通信函数*/
void MPU6050_Init(void)
{
MyI2C_Init();
MPU6050_WriteReg(MPU6050_PWR_MGMT_1,0x01); //配置电源管理寄存器1
MPU6050_WriteReg(MPU6050_PWR_MGMT_2,0x00); //配置电源管理寄存器2
MPU6050_WriteReg(MPU6050_SMPLRT_DIV,0x09); //配置分频寄存器,10分频
MPU6050_WriteReg(MPU6050_CONFIG,0x06); //配置配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG,0x18); //配置陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG,0x18); //配置陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG,0x18); //配置加速度计配置寄存器
}
/*获取MPU6050ID号的函数*/
uint8_t MPU6050_GetID(void)
{
return MPU6050_ReadReg(MPU6050_WHO_AM_I);
}
/*获取MPU6050数据寄存器的函数*/
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);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);
*AccX = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);
*AccY = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);
*AccZ = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);
*GyroX = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);
*GyroY = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);
*GyroZ = (DataH << 8)| DataL;
}
(7) MPU6050.h
cpp
#ifndef __MPU6050_
#define __MPU6050_
void MPU6050_Init(void);
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);
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
(8) mian.c
cpp
#include "stm32f10x.h" // Device header
#include "Delay.h" // 调用延时头文件
#include "OLED.h"
#include "MPU6050.h"
int16_t AX,AY,AZ,GX,GY,GZ;
uint8_t ID;
int main(void)
{
OLED_Init(); // 初始化OLED屏幕
MPU6050_Init();
// MPU6050_WriteReg(0x6B,0x00); //向MPU6050写入数据前,要先解除电源管理寄存器2的睡眠模式
// MPU6050_WriteReg(0x19,0x66); //向采样分频器写入0x66
// uint8_t ID = MPU6050_ReadReg(0x75); //读取ID寄存器
// uint8_t Data = MPU6050_ReadReg(0x19); //读取ID寄存器
// OLED_ShowHexNum(1,1,Data,2);
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);
}
}
9.4 I2C通信外设
9.4.1 I2C外设简介
STM32内部集成了硬件I2C收发电路,可以由硬件自动执行时钟生成、起始终止条件生成、应答位收发、数据收发等功能,减轻CPU的负担;
支持多主机模型;
固定多主机:一个教室里多位老师发言,学生不允许发言;
可变多主机:教室里的一个或多个学生均可发言。STM32是按照可变多主机设计的。
支持7位/10位地址模式;
支持不同的通讯速度,标准速度(高达100 kHz),快速(高达400 kHz);
这个标准速度是协议规定的标准速度,也就是说如果某个设备声称支持快速的I2C,那它就最大支持400KHz的时钟频率,当然,作为一个同步协议,这个时钟并不严格,所以只要不超过这个最大频率,多少都可以,所以这个频率的具体值,我们一般关注不多。
支持DMA;
在多字节传输的时候,可以提高传输效率,比如指定地址读多字节或写多字节的时序,如果我想要连续读或写非常多的字节,那用一下DMA自动帮我们转运数据,这个过程效率就会大大提升。如果只有几个字节,那就没必要用DMA了。
兼容SMBus协议;
SMBus(System Management Bus):系统管理总线,SMBus是基于I2C总线改进而来的,主要用于电源管理系统中,SMBus和I2C非常像,所以STM32的I2C外设计就顺便兼容了一下SMBus。
STM32F103C8T6 硬件I2C资源:I2C1、I2C2。
使用软件I2C,想开几路开几路。
9.4.2 STM32的I2C外设框图
左边是这个外设的通信引脚SDA和SCL ,这就是I2C通信的两个引脚,SMBALERT是SMBus用的,I2C用不到。像这种外设模块引出来的引脚,一般都是借用GPIO口的复用模式与外部世界相连的,具体是复用在了哪个GPIO口呢?
上面这一步分,是SDA,也就是数据控制部分, 数据收发的核心部分是这里的数据寄存器和数据移位寄存器,当我们需要发送数据时,可以把一个字节数据写到数据寄存器DR,当移位寄存器没有数据移位时,这个数据寄存器的值就会进一步,转移到移位寄存器里,在移位的过程中,我们就可以直接把下一个数据放到数据寄存器里等着了。一旦前一个数据移位完成,下一个数据就可以无缝衔接,继续发送,当数据由数据寄存器转到移位寄存器时,就会置状态寄存器的TXE位为1,表示发送寄存器为空,这是发送的流程。那在接收时,也是这一路,输入的数据一位一位的,从引脚移入到移位寄存器里,当一个字节的数据收齐之后,数据就整体从移位寄存器转到数据寄存器,同时置标志位RXNE,表示接收寄存器非空,这时我们就可以把数据寄存器读出来了。这个流程和串口那里一样,串口的数据收发也是由数据寄存器和移位寄存器两级实现的,只不过串口是全双工,这里数据收和发是分开的;在I2C这里,是半双工,所以数据收发是同一组数据寄存器和移位寄存器,但是这个数据寄存器和移位寄存器的配合,设计思路都是异曲同工,有了这一块,SDA的数据收发就可以完成了。至于什么时候收,什么时候发,需要我们写入控制寄存器的对应位进行操作。对于起始条件、终止条件、应答位什么的,这里也都有控制电路可以完成。
比较器和地址寄存器是从机模式用的,STM32的I2C是基于可变多主机模型设计的,STM32不进行通信的时候,就是从机,既然作为从机,它就可以被别人召唤,想被别人召唤,就应该有从机地址,从机地址是多少就可以由这个自身地址寄存器指定,我们可以自定义一个从机地址,写到这个寄存器,当STM32作为从机,在被寻址时,如果收到的寻址通过比较器判断,和自身地址相同,那STM32就作为从机,响应外部主机的召唤。并且这个STM32支持同时响应两个从机地址,所以就有自身地址寄存器和双地址寄存器,这一块我们需要在多主机的模型下来理解,把角色转换一下,STM32作为从机,才需要有这一部分。我们只需要一主多从的模型,STM32就不会作为从机,这一块就不需要使用。
帧错误校验(PEC)计算:这是STM32设计的一个数据校验模块,当我们发送一个多字节的数据帧时,在这里硬件可以自动执行CRC校验计算,CRC是一种很常见的数据校验算法,它会根据前面这些数据,进行各种数据运算,然后会得到一个字节的校验位,附加在这个数据帧后面,在接收到这一帧数据后,STM32的硬件也可以自动执行校验的判定,如果数据在传输的过程中出错了,CRC校验算法就通不过,硬件就会置校验错误标志位,告诉你数据错了,使用的时候注意点。这个数据校验过程就跟串口的奇偶校验差不多,也是用于进行数据有效性验证的。
SCL部分,时钟控制是用来控制SCL线的,在这个时钟控制寄存器写对应的位,电路就会执行对应的功能。控制逻辑电路也是黑盒子,写入控制寄存器,可以对整个电路进行控制,读取状态寄存器,可以得知电路的工作状态。之后是中断,当内部有一些标志位置1之后,可能事件比较紧急,就可以申请中断,如果我们开启了这个中断,那当这个事件发生之后,程序就可以跳到中断函数来处理这个事件了。
最后是DMA请求与响应,在进行很多字节的收发时,可以配合DMA来提高效率。
9.4.3 STM32的I2C基本结构
首先,移位寄存器和数据寄存器DR的配合是通信的核心部分, 这里因为I2C是高位先行,所以这个移位寄存器是向左移位,在发送的时候,最高位先移出去,然后是次高位等等,一个SCL时钟,移位一次,移位8次,这样就能把一个字节,由高位到低位,依次放到SDA线上了,那在接收的时候,数据通过GPIO口从右边依次移进来,最终移8次,一个字节就接收完成了。之后GPIO口这里,使用硬件I2C的时候,这两个对应的GPIO口,都要配置成复用开漏输出的模式,复用就是GPIO的状态是交由片上外设来控制的,开漏输出,这是I2C协议要求的端口配置,之前也说过,这里即使是开漏输出模式,GPIO口也是可以进行输入的,然后SCL这里,时钟控制器通过GPIO去控制时钟线,这里简化成一主多从的模型了,所以时钟这里只画了输出的方向。
SDA的部分,输出数据通过GPIO输出到端口,输入数据也是通过GPIO输入到移位寄存器。
最后,有个开关控制,也就是I2C_Cmd,配置好了,就使能外设,外设就能正常工作了。
实际上,如果是多主机的模型,时钟线也是会进行输入的,这个时钟的输入可以先不管。
9.4.4 硬件I2C的操作流程
9.4.4.1 主机发送
9.4.4.2 主机接收
9.4.5 软件/硬件I2C波形对比
9.5 硬件I2C写MPU6050
9.5.1 硬件电路
9.5.2 软件部分
(1)复制《软件I2C读写MPU6050》并更改名为《硬件I2C读写MPU6050》
(2)I2C库函数
cpp
void I2C_DeInit(I2C_TypeDef* I2Cx);
void I2C_Init(I2C_TypeDef* I2Cx, I2C_InitTypeDef* I2C_InitStruct);
void I2C_StructInit(I2C_InitTypeDef* I2C_InitStruct);
void I2C_Cmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_DMACmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_DMALastTransferCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_GenerateSTART(I2C_TypeDef* I2Cx, FunctionalState NewState); //生成起始条件
void I2C_GenerateSTOP(I2C_TypeDef* I2Cx, FunctionalState NewState); //生成终止条件
void I2C_AcknowledgeConfig(I2C_TypeDef* I2Cx, FunctionalState NewState); //配置应答使能,在收到一个字节之后,是否给从机应答
void I2C_OwnAddress2Config(I2C_TypeDef* I2Cx, uint8_t Address);
void I2C_DualAddressCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_GeneralCallCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_ITConfig(I2C_TypeDef* I2Cx, uint16_t I2C_IT, FunctionalState NewState);
void I2C_SendData(I2C_TypeDef* I2Cx, uint8_t Data); //发送数据,写数据到数据寄存器DR
uint8_t I2C_ReceiveData(I2C_TypeDef* I2Cx); //读取DR的数据,作为返回值
void I2C_Send7bitAddress(I2C_TypeDef* I2Cx, uint8_t Address, uint8_t I2C_Direction); //发送7位地址的专用函数
uint16_t I2C_ReadRegister(I2C_TypeDef* I2Cx, uint8_t I2C_Register);
void I2C_SoftwareResetCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_NACKPositionConfig(I2C_TypeDef* I2Cx, uint16_t I2C_NACKPosition);
void I2C_SMBusAlertConfig(I2C_TypeDef* I2Cx, uint16_t I2C_SMBusAlert);
void I2C_TransmitPEC(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_PECPositionConfig(I2C_TypeDef* I2Cx, uint16_t I2C_PECPosition);
void I2C_CalculatePEC(I2C_TypeDef* I2Cx, FunctionalState NewState);
uint8_t I2C_GetPEC(I2C_TypeDef* I2Cx);
void I2C_ARPCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_StretchClockCmd(I2C_TypeDef* I2Cx, FunctionalState NewState);
void I2C_FastModeDutyCycleConfig(I2C_TypeDef* I2Cx, uint16_t I2C_DutyCycle);
ErrorStatus I2C_CheckEvent(I2C_TypeDef* I2Cx, uint32_t I2C_EVENT); //基本状态监控,监控
uint32_t I2C_GetLastEvent(I2C_TypeDef* I2Cx);
FlagStatus I2C_GetFlagStatus(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
void I2C_ClearFlag(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);
ITStatus I2C_GetITStatus(I2C_TypeDef* I2Cx, uint32_t I2C_IT);
void I2C_ClearITPendingBit(I2C_TypeDef* I2Cx, uint32_t I2C_IT);
(3)删除" MyI2C.c"和" MyI2C.h",更改"MPU6050.c" 和"MPU6050.h"
(4)MPU6050.c
cpp
#include "stm32f10x.h" //Device header
#include "MPU6050_Reg.h"
#define MPU6050_ADDRESS 0xD0 //MPU6050的从机地址
/*带超时退出的状态监测函数*/
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; //超时跳出循环,防止程序卡死,更严谨应该增加错误处理函数
}
}
}
/*使用I2C向MPU6050发送数据*/
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data)
{
I2C_GenerateSTART(I2C2,ENABLE); //起始条件
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_MODE_SELECT); //等待EV5状态监测完成
I2C_Send7bitAddress(I2C2,MPU6050_ADDRESS,I2C_Direction_Transmitter); //配置发送从机地址以及是要发送还是接收,发送数据自带接收应带功能
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6事件完成
I2C_SendData(I2C2,RegAddress); //发送要写到的外设寄存器的地址
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_BYTE_TRANSMITTING); //等待EV8事件完成
I2C_SendData(I2C2,Data); //发送数据
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8_2事件完成
I2C_GenerateSTOP(I2C2,ENABLE); //终止条件
}
/*使用I2C向MPU6050读取数据*/
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
I2C_GenerateSTART(I2C2,ENABLE); //起始条件
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_MODE_SELECT); //等待EV5状态监测完成
I2C_Send7bitAddress(I2C2,MPU6050_ADDRESS,I2C_Direction_Transmitter); //配置发送从机地址以及是要发送还是接收,发送数据自带接收应带功能
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); //等待EV6事件完成
I2C_SendData(I2C2,RegAddress); //发送要写到的外设寄存器的地址
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_BYTE_TRANSMITTED); //等待EV8事件完成
I2C_GenerateSTART(I2C2,ENABLE); //生成重复起始条件
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_MODE_SELECT); //等待EV5状态监测完成
I2C_Send7bitAddress(I2C2,MPU6050_ADDRESS,I2C_Direction_Receiver);
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED); //等待EV6事件完成
I2C_AcknowledgeConfig(I2C2, DISABLE); //设置ACK=0,不给应答
I2C_GenerateSTOP(I2C2,ENABLE); //申请产生终止条件
MPU6050_WaitEvent(I2C2,I2C_EVENT_MASTER_BYTE_RECEIVED); //等待EV7事件完成
Data = I2C_ReceiveData(I2C2);
I2C_AcknowledgeConfig(I2C2, ENABLE); //设置默认状态下ACK就是1,给从机应答
return Data;
}
/*MPU6050初始化通信函数
第1步:配置I2C外设,对I2C2外设进行初始化;
第2步:控制外设电路,实现指定地址写的时序;
第3步:控制外设电路,实现指定地址读的时序;
*/
void MPU6050_Init(void)
{
/*第1步:初始化I2C外设*/
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB,ENABLE);
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD; //复用开漏模式
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10|GPIO_Pin_11;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB,&GPIO_InitStruct);
I2C_InitTypeDef I2C_InitStruct;
I2C_InitStruct.I2C_Mode = I2C_Mode_I2C;
I2C_InitStruct.I2C_ClockSpeed = 50000;
I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStruct.I2C_Ack = I2C_Ack_Enable;
I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_InitStruct.I2C_OwnAddress1 = 0x00;
I2C_Init(I2C2,&I2C_InitStruct);
I2C_Cmd(I2C2,ENABLE);
MPU6050_WriteReg(MPU6050_PWR_MGMT_1,0x01); //配置电源管理寄存器1
MPU6050_WriteReg(MPU6050_PWR_MGMT_2,0x00); //配置电源管理寄存器2
MPU6050_WriteReg(MPU6050_SMPLRT_DIV,0x09); //配置分频寄存器,10分频
MPU6050_WriteReg(MPU6050_CONFIG,0x06); //配置配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG,0x18); //配置陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_GYRO_CONFIG,0x18); //配置陀螺仪配置寄存器
MPU6050_WriteReg(MPU6050_ACCEL_CONFIG,0x18); //配置加速度计配置寄存器
}
/*获取MPU6050ID号的函数*/
uint8_t MPU6050_GetID(void)
{
return MPU6050_ReadReg(MPU6050_WHO_AM_I);
}
/*获取MPU6050数据寄存器的函数*/
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);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_XOUT_L);
*AccX = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_YOUT_L);
*AccY = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_ACCEL_ZOUT_L);
*AccZ = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_XOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_XOUT_L);
*GyroX = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_YOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_YOUT_L);
*GyroY = (DataH << 8)| DataL;
DataH = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_H);
DataL = MPU6050_ReadReg(MPU6050_GYRO_ZOUT_L);
*GyroZ = (DataH << 8)| DataL;
}
(5)MPU6050.h
cpp
#ifndef __MPU6050_
#define __MPU6050_
void MPU6050_Init(void);
void MPU6050_WriteReg(uint8_t RegAddress,uint8_t Data);
uint8_t MPU6050_ReadReg(uint8_t RegAddress);
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
(6)main.c