参考:
https://blog.csdn.net/weixin_54742551/article/details/132409170?spm=1001.2014.3001.5502
https://blog.csdn.net/m0_61712829/article/details/132434192
https://blog.csdn.net/Johnor/article/details/128539267?spm=1001.2014.3001.5502
SPI:https://blog.csdn.net/weixin_62127790/article/details/132015224?spm=1001.2014.3001.5502
目录
- 1、STM32简介
- 2、软件安装、新建工程
- 外设篇
- 3、GPIO输出
- LED闪烁、LED流水灯、蜂鸣器
- 4、OLED显示屏及调试
- 5、EXTI外部中断
- 6、TIM(Timer)定时器
- 7、ADC数模转换器
- 8、DMA直接存储器存取
- 9、USART串口
- 9-2 串口发送+接受
- 9-3 串口收发HEX数据包
1、STM32简介
我们主要学习的就是STM32的外设。
- NVIC:内核里面用于管理中断的设备,比如配置中断优先级这些东西
- SysTick:内核里面的定时器,主要用来给操作系统提供定时服务的,STM32是可以加入操作系统的,比如FreeRTOS、UCOS等。如果用了这些操作系统,就需要SysTick提供定时来进行任务切换的功能。也可以用这个定时器来完成Delay函数的功能
- RCC:可以对系统的时钟进行配置,还有就是使能各模块的时钟。在STM32中,其他(非内核)外设在上电的情况下默认是没有时钟的,不给时钟操作外设是无效的,目的是降低功耗。所以在操作外设前,必须要先使能时钟,这就需要用RCC来完成时钟的使能
- AFIO:可以完成复用功能端口的重定义,还有中断端口的配置
- EXTI:配置好外部中断后,当引脚有电平变化时,就可以触发中断,让CPU来处理任务
- TIM:整个STM32最常用、功能最多的外设。分为高级定时器、通用定时器、基本定时器
- ADC:内置了12位的AD转换器,可以直接读取IO口的模拟电压值,无需外部连接AD芯片,使用非常方便
- DMA:帮助CPU完成搬运大量数据这样的繁杂工作
- PWR:可以让芯片进入睡眠模式等状态,来达到省电的目的
- BKP:是一段存储器,当系统掉电时,仍可由备用电池保持数据,可以根据需要完成一些特殊功能
- DAC:它可以在IO口直接输出模拟电压,是ADC模数转换的逆过程
- FSMC:可以用于扩展内存,或者配置成其他总线协议,用于某些硬件的操作
- 三个总线icode指令总线(加载程序指令)、dcode数据总线(加载数据,比如常量何调试数据)、system系统总线。icode与dcode总线主要用来连接flash闪存(flasd存储的是编写的程序)。
- sram用于存储程序运行时的变量数据
- ahb(先进高性能总线)系统总线用于挂载主要的外设(挂载最基本或者性能比较高的外设,比如复位和时钟控制这些基本的电路)sdio也是挂载在ahb上的。
- 两个桥接,接到了apb1(先进外设总线)和apb2两个外设总线上(用来连接一般的外设)
- ahb和apb的总线协议、总线速度还有数据传输格式的差异,所以中间需要加两个桥接来完成数据的转换和缓存
- ahb的整体性能比apb高一些,APB2的性能比APB1高一些。
- APB2一般和AHB同频率都是72MHz,APB1一般是36MHz,所以APB2连接的一般是外设中稍微重要的部分(例如GPIO端口,还有一些外设的一号选手比如USART1、SPI1、TIM1、TIM8(高级定时器)、ADC、EXTI、AFIO),Apb1连接次要一点的外设2、3、4号外设还有DAC\PWR\BKP等。
- DMA是CPU的小秘书,比如一些大量的数据搬运这样简单且重复干的事情,让cpu来干会浪费时间。
- DMA通过DMA总线连接到总线矩阵上,可以拥有和cpu一样的总线控制权,用于访问外设小弟,当需要DMA搬运数据时,外设就会通过请求线发送DMA请求,然后DMA就会获的总线控制权,访问并转运数据,整个过程不需要cpu的参与
引脚定义:
- 标红色的是电源相关的引脚
- 标蓝色的是最小系统相关的引脚
- 标绿色的是IO口、功能口这些引脚
S代表电源、I代表输入、O代表输出、I/O代表输入输出、FT代表代表能容忍5V电压,没有FT的只能容忍3.3V电压,如果没有FT的需要接5V的电平,就需要加装电平转换电路了。
如果我们想让STM32正常工作,首先就需要把电源部分和最小系统部分的电路连接好,也就是上表中标注红色和蓝色的部分。
- 红色为电源相关引脚,蓝色为最小系统相关引脚,绿色为IO口、功能口
- S代表电源、I代表输入、O代表输出,IO代表输入输出
- IO口电平代表IO口所能容忍的电压,FT代表容忍5V的电压,没有FT的智能容忍 3.3V电压(如果没有FT,需要接5V的电平,需要加装电平转换电路)
- 主功能就是上电后默认的功能,一般和引脚名称相同。如果不同的话引脚的实际功能是主功能而不是引脚名称的功能
- 默认复用功能是,IO口上同时连接的外设功能引脚,配置IO口时可以选择是通用IO口还是复用功能
- 重定义功能,作用是如果有两个功能同时复用在了一个IO口上,而且确实需要用到这两个功能,可以将其中一个复用功能重映射到其他端口上(前提是,这个重定义功能的表里有对应的端口)
- 优先使用加粗的IO口,没有加粗的IO口可能需要进行配置或者兼具其他功能。
- 1引脚VBAT是备用电池供电引脚,可接3v电池,当系统电源断电时,备用电池可给内部的RTC时钟和备份寄存器提供电源
- 2引脚是IO口或侵入检测或RTC,IO口可以根据程序输出或读取高低电平。侵入检测可以用来做安全保障的功能(比如你的产品安全性比较高,可以在外壳加一些防拆的触电,然后接上电路到这个引脚上,若有人强行拆开设备,则触电断开,这个引脚的电平变化就会触发STM32的侵入信号,然后就会清空数据来保证安全)。RTC的引脚可以用来输出RTC校准时钟、RTC闹钟脉冲或者秒脉冲
- 3、4引脚是IO口或者接32.768KHz的RTC晶振
- 5、6号引脚接系统的主晶振,一般是8MHz,然后芯片内有锁相环电路,可以对这个8MHz的频率进行倍频,最终产生72Mhz的频率作为系统的主时钟
- 7引脚NRST是系统复位引脚,N代表是低电平复位
- 8、9引脚是内部模拟部分的电源,比如ADC、RC振荡器等。vss是负极,接GND,VDD是正级,接3.3V
- 10-19号引脚都是IO口,其中PA0还兼具了WKUP功能(可以用于唤醒处于待机模式的STM32)
- 20号引脚是IO口或BOOT1引脚,BOOT引脚是用来配置启动模式的
- 21、22引脚是IO口
- 23、24引脚是VSS_1和VDD_1是系统的主电源口,同样的VSS是负极,VDD是正级
- 下面的VSS_2和VDD_2以及VSS_3和VDD_3都是系统的主电源口,这里STM32内部采用分区供电的方式,所以供电口比较多,在使用时,把VSS都接GND,VDD都接3.3V即可
- 25-33引脚都是IO口
- 34-40引脚再加27号引脚,都是IO口或者调试端口,默认功能是调试端口(用来调试程序和下载数据),这个STM32支持SWD(需要两根线,分别是SWDIO和SEDLK)和JTAG(需要五根线,分别是JTMS、JTCK、JTDL、JTDO、NJTRST)两种调试方式。
- STLINK调试程序用的是SWD方式(只需占用PA13和PA14这两个IO口,剩下的PA15、PB3、PB4可以切换为普通的IO口使用(需在程序中配置,不配置的话默认是不会用作IO口的)
- 41、42、43、45、46引脚都是IO口
- 44引脚BOOT0和BOOT1一样用来做启动配置
启动配置的作用是指定程序开始运行的位置,一般情况下,程序都是在Flash程序存储器开始执行。但是在某些情况下,我们也可以让程序在别的地方开始执行。
第二种启动模式(串口下载用的,区别于使用Jlink):系统存储器存的就是STM32中的一段BootLoader程序,BootLoader的作用就是接收串口的数据,然后刷到主闪存中
第三种启动模式:主要用来程序调试的,用的比较少。
BOOT引脚的值是在上电一瞬间有效的,之后就随便了。查看上面的引脚分布图,发现BOOT1和PB2是在同一个引脚上,也就是在上电瞬间是BOOT1功能,在第四个时钟过后,就是PB2的功能了。
供电部分电路:
在3.3V和GND之间,一般都会连接一个滤波电容,保证供电电压的稳定。
VBAT接的备用电池,用来给RTC和备份寄存器服务的。如果不用备用电池,VBAT可以直接接3.3V或者悬空。
晶振电路:
接了一个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就会产生低电平,当电容逐渐充满电时,电容就相当于短路,此时、NRST就会被R1上拉为高电平。那上电瞬间的波形就是先低电平,然后逐渐高电平,这个低电平就可以提供STM32的上电复位信号。当然电容充电还是非常快的,所以在我们看来单片机在上电的一瞬间复位了,这就是复位电路的作用。
电容左边还并联了一个按键,提供手动复位的功能。按键按下式,电容被放电,并且NRST引脚也通过按键被直接接地了,相当于手动产生了低电平复位信号。按键松手后,NRST又回归高电平,此时单片机就从复位状态转为工作状态。一般复位按键都是在一个小孔里,拿针戳一下设备就复位了。
启动配置:
跳线帽的方式。接拨码开关也可以。
2、软件安装、新建工程
库函数底层也是操作寄存器,只是封装了一下,方便我们使用。详细函数都在各个外设寄存器头文件里定义好了,可以去这些头文件里查看各接口的使用方法(入参、出参),用多了掌握套路就容易了,这样就不用查看寄存器和芯片手册了。
外设篇
片内外设、片上外设和片外外设的区别
3、GPIO输出
驱动器是用来增加信号驱动能力的,寄存器只负责存储数据,如果进行点灯这样操作的话,需要驱动器增大驱动能力。
GPIO位结构
上面是输入部分,下面是输出部分。
最右侧I/O引脚的保护二极管是对输入电压进行限幅的,上面的二极管接VDD, 3.3V,下面接VSS, 0V,当输入电压:
- 大于3.3V
那上方这个二极管就会导通,输入电压产生的电流就会直接充入VDD而不会流入内部电路,避免过高电压对内部电路产生伤害; - 小于0V(这个电压是相对于VSS的电压,所以是可以有负电压的)
那这时下方这个二极管就会导通,电流会从VSS直接流出去,而不会从内部电路汲取电流,也是可以保护内部电路的; - 在0~3.3V之间
那两个二极管均不会导通,这时二极管对电路没有影响,这就是保护二极管的用途。
开关(可以通过程序配置):如果上面导通、下面断开,就是上拉输入模式,如果下面导通、上面断开,就是下拉输入模式,如果两个都断开,就是浮空输入模式。
上拉和下拉的作用——>为了给输入提供一个默认的输入电平
因为对应一个数字的端口,输入不是高电平就是低电平,那如果输入引脚什么都不接,那就不确定算高电平还是低电平。而实际情况是,如果啥也不接,这时输入就会处于一种浮空的状态,引脚的输入电平极易受外界干扰而改变。为了避免引脚悬空导致的输入数据不确定,我们就需要在这里加上拉或者下拉电阻了,如果接入上拉电阻,当引脚悬空时,还有上拉电阻来保证引脚的高电平,所以上拉输入又可以称作是默认为高电平的输入模式。下拉也是同理,就是默认为低电平的输入方式。
这个上拉电阻和下拉电阻的阻值都是比较大的,是一种弱上拉和弱下拉,目的是尽量不影响正常的输入操作。
肖特基触发器:英文原文档是施密特触发器,(模电里这叫迟滞/滞回比较器,也就是施密特触发器的电路)
施密特触发器的作用就是对输入电压进行整形的,它的执行逻辑是,如果输入电压大于某一阈值,输出就会瞬间升为高电平,如果输入电压小于某一阈值,输出就会瞬间降为低电平,这样可以有效的避免由于信号波动造成的输出抖动现象。
接下来经过施密特触发器整形的波形就可以直接写入输入数据寄存器了,我们再用程序读取输入数据寄存器对应某一位的数据,就可以知道端口的输入电平了。最后上面这还有两路线路,这些就是连接到片上外设的一些端口,其中有模拟输入,这个是连接到ADC上的,因为ADC需要接收模拟量,所以这根线是接到施密特触发器前面的;另一个是复用功能输入,这个是连接到其他需要读取端口的外设上的,比如串口的输入引脚等,这根线接收的是数字量,所以在施密特触发器后面。
输出部分:
输出部分可以由 输出数据寄存器或片上外设 控制,两种控制方式通过这个数据选择器接到了输出控制部分。
如果选择通过输出数据寄存器进行控制,就是普通的IO口输出,写这个数据寄存器的某一位就可以操作对应的某个端口了。
最左侧位设置/清除寄存器:这个可以用来单独操作输出数据寄存器的某一位,而不影响其它位。因为这个输出数据寄存器同时控制16个端口,并且这个寄存器只能整体读写,所以如果想单独控制其中某一个端口而不影响其他端口的话,就需要一些特殊的操作方式。
- 第一种方式是先读出这个寄存器,然后用 按位与 和 按位或 的方式更改某一位,最后再将更改后的数据写回去,在C语言中就是&=和 |=的操作,这种方法比较麻烦,效率不高,对于IO口的操作而言不太合适;
- 第二种方式是通过设置这个位设置和位清除寄存器,如果我们要对某一位进行置1的操作,在位设置寄存器的对应位写1便可,剩下不需要操作的位写0,这样它内部就会有电路,自动将输出数据寄存器中对应位置为1,而剩下写0的位则保持不变,这样就保证了只操作其中某一位而不影响其它位,并且这是一步到位的操作。如果想对某一位进行清0的操作,就在位清除寄存器的对应位写1即可,这样内部电路就会把这一位清0了,这就是第二种方式也就是这个位设置和位清除寄存器的作用。【作用:将设置/清除寄存器的某一位写1/0就能达到单独影响输出寄存器的某一位,从而单独影响某个端口】
- 第三种操作方式【了解即可】 ,就是读写STM32中的“位带”区域,这个位带的作用就跟51单片机的位寻址作用差不多,在STM32中,专门分配的有一段地址区域,这段地址映射了RAM和外设寄存器所有的位,读写这段地址中的数据,就相当于读写所映射位置的某一位,这就是位带的操作方式,这个方式我们本课程暂时不会用到。我们的教程主要使用的是库函数来操作的,库函数使用的是读写位设置和位清除寄存器的方法。
上面是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,也就是输出低电平;这种模式下,只有低电平有驱动能力,高电平是没有驱动能力的。那这个模式有什么用呢,这个开漏模式可以作为通信协议的驱动方式,比如12C通信的引脚,就是使用的开漏模式,在多机通信的情况下,这个模式可以避免各个设备的相互干扰,另外开漏模式还可以用于输出5V的电平信号。
比如在IO口外接一个上拉电阻到5V的电源,当输出低电平时,由内部的N-MOS直接接VSS,当输出高电平时,由外部的上拉电阻拉高至5V,这样就可以输出5V的电平信号,用于兼容一些5V电平的设备,这就是开漏输出的主要用途。
开漏模式下,输出1时,两个mos管都相当于关断,左侧相当于断路。外接5V的电能只能流向右侧,故输出5V。反之,输出0时,左下方mos管导通,外接5V的电能流到左下方Vss,且两者之间几乎没有电压降,可看做5V电压降在了上拉电阻上,故引脚输出0V
关闭
剩下的一种状态就是关闭,这个是当引脚配置为输入模式的时候,这两个MOS管都无效,也就是输出关闭,端口的电平由外部信号来控制。
GPIO8种工作模式模式
输入模式:
首先是前三个,浮空输入、上拉输入和下拉输入。这三个模式的电路结构基本是一样的,区别就是上拉电阻和下拉电阻的连接,它们都属于数字的输入口,那特征就是,都可以读取端口的高低电平,当引脚悬空时,上拉输入默认是高电平,下拉输入默认是低电平,而浮空输入的电平是不确定的,所以在使用浮空输入时,端口—定要接上一个连续的驱动源,不能出现悬空的状态。
那我们来看一下这三种模式的电路结构,这里可以看到,在输入模式下,输出驱动器是断开的,端口只能输入而不能输出,上面这两个电阻可以选择为上拉工作、下拉工作或者都不工作,对应的就是上拉输入、下拉输入和浮空输入,然后输入通过施密特触发器进行波形整形后,连接到输入数据寄存器。
另外右边这个输入保护这里,上面写的是VDD或者VDD_FT,这就是3.3V端口和容忍5V端口的区别。这个容忍5V的引脚,它的上边保护二极管要做一下处理,要不然这里直接接VDD 3.3V的话,外部再接入5V电压就会导致上边二极管开启,并且产生比较大的电流,这个是不太妥当的。
接着我们再来看一下下面这一个模拟输入,特征是GPIO无效,引脚直接接入内部ADC,这个模拟输入可以说是ADC模数转换器的专属配置了。
这里输出是断开的,输入的施密特触发器也是关闭的无效状态,所以整个GPIO的这些都是没用的,那么只剩下从引脚直接接入片上外设,也就是ADC,所以,当我们使用ADC的时候,将引脚配置为模拟输入就行了,其他时候,一般用不到模拟输入。
输出模式:
开漏输出和推挽输出,这两个电路结构也基本一样,都是数字输出端口,可以用于输出高低电平,区别就是开漏输出的高电平呈现的是高阻态,没有驱动能力,而推挽输出的高低电平都是具有驱动能力的。 这时候,输出是由输出数据寄存器控制的,如果P-MOS无效,就是开漏输出;如果P-MOS和N-MOS都有效,就是推挽输出。另外我们还可以看到,在输出模式下,输入模式也是有效的,但是在我们刚才的电路图,在所有输入模式下,输出都是无效的,这是因为,一个端口只能有一个输出,但可以有多个输入,所以当配置成输出模式的时候,内部也可以顺便输入一下,这个也是没啥影响的。
最后我们再来看一下复用开漏输出和复用推挽输出,这俩模式跟普通的开漏输出和推挽输出也差不多。
可以看到通用的输出/数据寄存器没有连接的,引脚的控制权转移到了片上外设,由片上外设来控制,在输入部分,片上外设也可以读取引脚的电平,同时普通的输入也是有效的,顺便接收一下电平信号其实在GPIO的这8种模式中,除了模拟输入这个模式会关闭数字的输入功能,在其他的7个模式中,所有的输入都是有效的。
GPIO寄存器讲解23:45~26:32
STM32外部的设备和电路:
上图左侧高电平驱动和低电平驱动两种驱动方式应该如何选择呢?
这就得看这个IO口高低电平的驱动能力如何了,我们刚才介绍,这个GPIO在推挽输出模式下,高低电平均有比较强的驱动能力,所以在这里,这两种接法均可。但是在单片机的电路里,一般倾向使用第一种接法(低电平驱动),因为很多单片机或者芯片,都使用了高电平弱驱动,低电平强驱动的规则,这样可以一定程度上避免高低电平打架。所以如果高电平驱动能力弱,那就不能使用第二种连接方法了
本节内容跟手册第8章的GPIO相关,AFIO暂时不用管。
LED闪烁、LED流水灯、蜂鸣器
外设的GPIO配置查看
STM32F10xxx参考手册 P110有列出了各个外设的引脚配置,例如:
实战1: 如何进行基本的GPIO输入输出
操作STM32的GPIO总共需要3个步骤:
第一步,使用RCC开启GPIO的时钟
涉及的函数如下:
void RCC_APB2PeriphClockCmd(uint32_t RCC_APB2Periph, FunctionalState NewState)
作用:使能(开启)或失能(关闭)APB2外设时钟
参数说明:
其它两个外设时钟函数也是大差不差的,根据不同外设选择相应的函数开启就行。
第二步,使用GPIO_Init函数初始化GPIO
涉及的函数如下:
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct)
作用:根据GPIO_InitStruct中的指定参数初始化GPIOx外设。
参数说明:
指定要配置的GPIO引脚。
其中 GPIO InitTypeDef结构体配置信息如下:
typedef struct
{uint16_t GPIO_Pin; GPIOSpeed_TypeDef GPIO_Speed; GPIOMode_TypeDef GPIO_Mode;
}GPIO_InitTypeDef;
参数说明:
引脚的工作模式如下:
举例:根据LED闪烁接线图设置
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);GPIO_InitTypeDef GPIO_InitStructure;GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure);
第三步,使用输出或者输入的函数控制GPIO口
涉及的函数如下:
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
作用:设置所选数据端口位。对某个端口写1,也就是高电平
参数说明:
类似的还有:GPIO_ResetBits 函数,同样的用法,只不过这个函数是写0
3-1.LED闪烁
接线图:
3-2.LED流水灯
3-3.蜂鸣器
4、OLED显示屏及调试
调试方式:
- 串口调试:通过串口通信,将调试信息发送到电脑端,电脑使用串口助手显示调试信息
- 显示屏调试:直接将显示屏连接到单片机,将调试信息打印在显示屏上
- Keil调试模式:借助Keil软件的调试模式,可使用单步运行、设置断点、查看寄存器及变量等功能
接线图:
改引脚配置和端口初始化,就可以直接使用OLED驱动函数了
比如我这里SCL接在了PB8,那这个地方就是GPIOB,GPIO_Pin_8,如果你换个端口,比如接在PA6上,那这个地方就要改成GPIOA,GPIO_Pin_6;下面这个SDA的引脚配置也是一样,SDA接在了哪个位置,就改成GPIO啥,GPIO_Pin_啥。
具体更改就是,使用到的GPIO外设都先用RCC开启一下时钟,然后下面初始化GPIOB的Pin8,再初始化GPIOB的Pin9
Keil的调试模式演示
Keil的调试模式演示视频片段
5、EXTI外部中断
表的详细内容在STM32F10xxx参考手册132页有,
然后右边这里还有个中断的地址,这个地址是干什么的呢?这个是因为我们程序中的中断函数,它的地址是由编译器来分配的,是不固定的。但是我们的中断跳转由于硬件的限制,只能跳到固定的地址执行程序,所以为了能让硬件跳转到一个不固定的中断函数里,这里就需要在内存中定义一个地址的列表。这个列表地址是固定的,中断发生后,就跳到这个固定位置,然后在这个固定位置由编译器,再加上一条跳转到中断函数的代码,这样中断跳转就可以跳转到任意位置。这个中断地址的列表,就叫中断向量表。
NVIC基本结构
这个NVIC的名字叫做嵌套中断向量控制器,在STM32中,它是用来统一分配中断优先级和管理中断的。
NVIC是一个内核外设,是CPU的小助手。STM32的中断非常多,如果把这些中断全都接到CPU上,那CPU还得引出很多线进行适配,设计上就很麻烦,并且如果很多中断同时申请,或者中断很多产生了拥堵,CPU也会很难处理,毕竟CPU主要是用来运算的,中断分配的任务就放到别的地方吧,所以NVIC就出现了。
NVIC有很多输入口,你有多少个中断线路,都可以接过来,比如这里可以接到EXTI、TIM、ADC、USART等等,这里线上画了个斜杠,上面写个n,这个意思是一个外设可能会同时占用多个中断通道,所以这里有n条线。然后NVIC只有一个输出口,NVIC根据每个中断的优先级分配中断的先后顺序,之后,通过右边这一个输出口就告诉CPU,你该处理哪个中断。对于中断先后顺序分配的任务,CPU不需要知道。
13:18~14:00举了例子 && 14:00讲了下面的NVIC中断分组
EXTI结构
但相同的Pin不能同时触发中断:这个意思就是,比如PA0和PB0不能同时用,或者,PA1、PB1、PC1这样的,端口GPIO_Pin一样的。
然后再看一下外部中断占用的通道,其中有16个GPIO_Pin,这就对应GPIO_Pin_0到GPIO_Pin_15,外加PVD输出、RTC闹钟、USB唤醒、以太网唤醒,这些加起来总共有20个中断线路。这里的16个GPIO_Pin是外部中断的主要功能,后面跟着的这四个东西其实是来“蹭网”的。因为这个外部中断有个功能,就是从低功耗模式的停止模式下唤醒STM32,那对于PVD电源电压监测,当从电源从电压过低恢复时,就需要PVD借助一下外部中断退出停止模式;对于RTC闹钟来说,有时候为了省电,RTC定一个闹钟之后,STM32会进入停止模式,等到闹钟响的时候再唤醒,这也需要借助外部中断;还有USB唤醒、以太网唤醒,也都是类似的作用。
中断响应,就是申请中断,让CPU执行中断函数;事件响应是STM32对外部中断增加的一种额外的功能。当外部中断检测到引脚电平变化时,正常的流程是选择触发中断,但是在STM32中,也可以选择触发一个事件,如果选择触发事件,那外部中断的信号就不会通向CPU了,而是通向其它外设,用来触发其它外设的操作,比如触发ADC转换、触发DMA等。所以总结一下:中断响应是正常的流程,引脚电平变化触发中断;事件响应不会触发中断,而是触发别的外设操作,属于外设之间的联合工作。
这里注意一下,本来20路输入,应该有20路中断的输出,但是可能ST公司觉得这20个输出太多了,比较占用NVIC的通道资源,所以就把其中外部中断的 9~5 和15 ~ 10给分到一个通道里。也就是说,外部中断的9~5会触发同一个中断函数,15~10也会触发同一个中断函数,在编程的时候,我们在这两个中断函数里,需要再根据标志位来区分到底是哪个中断进来的。
外部中断的使用场景:
就是对于STM32来说,想要获取的信号是外部驱动的很快的突发信号。比如旋转编码器的输出信号,你可能很久都不会拧它,这时不需要STM32做任何事,但是我一拧它,就会有很多脉冲波形需要STM32接收。这个信号是突发的,STM32不知道什么时候会来,同时它是外部驱动的,STM32只能被动读取,最后这个信号非常快,STM32稍微晚一点来读取,就会错过很多波形。那对于这种情况来说,就可以考虑使用STM32的外部中断了。有脉冲过来,STM32立即进入中断函数处理,没有脉冲的时候,STM32就专心做其它事情。
另外还有,比如红外遥控接收头的输出,接收到遥控数据之后,它会输出一段波形,这个波形转瞬即逝,并且不会等你,所以就需要我们用外部中断来读取。
最后还有按键,虽然它的动作也是外部驱动的突发事件,但我并不推荐用外部中断来读取按键。因为用外部中断不好处理按键抖动和松手检测的问题,对于按键来说,它的输出波形也不是转瞬即逝的。所以要求不高的话可以在主程序中循环读取,如果不想用主循环读取的话,可以考虑一下定时器中断读取的方式。这样既可以做到后台读取按键值、不阻塞主程序,也可以很好地处理按键抖动和松手检测的问题。
NVIC以及中断、事件手册讲解视频
代码实战2:如何使用中断和对射式红外传感器&旋转编码器
注意:我们这里是用到了PB14来做外部中断的
5-1 对射式红外传感器计次接线图
当挡光片在对射式红外传感器中间经过时,DO输出电平跳变信号,触发PB14号口的中断,在中断断数执行Num++
第一步,配置RCC,将程序涉及外设的时钟都打开
提示:有GPIOB和AFIO
第二步,配置GPIO,选择端口为输入模式
第三步,配置AFIO,选择硬件所用用的那一路GPIO,连接到后面的EXTI
涉及函数如下:
void GPIO_EXTILineConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource)
作用:选择用作EXTI线的GPIO引脚。
参数说明:
第四步,配置EXTI,选择边沿触发方式,比如上升沿、下降沿或者双边沿,还有选择触发响应方式,可以选择中断响应和事件响应
涉及函数如下:
void EXTI_Init(EXTI_InitTypeDef* EXTI_InitStruct)
作用:根据EXTI InitStruct中的指定参数初始化EXTI外设。
参数说明:
EXTI InitTypeDef结构体说明:
typedef struct
{uint32_t EXTI_Line; EXTIMode_TypeDef EXTI_Mode; EXTITrigger_TypeDef EXTI_Trigger; FunctionalState EXTI_LineCmd;
}EXTI_InitTypeDef;
参数说明以及举例
举例:
/* Enables external lines 12 and 14 interrupt generation on falling
edge */
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line12 | EXTI_Line14;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
第五步,配置NVIC,给我们这个中断选择一个合适的优先级
涉及函数如下:
void NVIC_PriorityGroupConfig(uint32_t NVIC_PriorityGroup)
作用:配置优先级分组:抢占优先级和子优先级。
参数说明:
取值范围:
例如:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
最后,通过NVIC,外部中断信号就能进入CPU了,这样CPU才能收到中断信号,才能跳转到中断函数里执行中断程序
涉及函数如下:
void NVIC_Init(NVIC_InitTypeDef* NVIC_InitStruct)
作用:根据NVIC InitStruct中指定的参数初始化NVIC外设。
参数说明:
举例:
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
中断函数的格式:
根据中断向量表,找到所需中断函数,这里面以IRQHandler结尾的字符串就是中断函数的名字,再根据名字写中断函数。
例如:void EXTI15_10_IRQHandler(void){ }
这就是中断函数的格式,中断函数都是无参无返回值的,中断函数的名字不要写错了,写错了就进不了中断了,最好是直接从启动文件复制过来,这样就不会有问题了。
注:启动文件为
然后在中断函数里,一般都是先进行一个中断标志位的判断,确保是我们想要的中断源触发的这个函数,因为这个函数EXTI10到EXTI15都能进来,所以要先判断一下是不是我们想要的EXTI14进来的。所用函数:EXTI_GetITStatus(uint32_t EXTI_Line)
最后,中断程序结束后,一定要再调用一下清除中断标志位的函数,因为只有中断标志位置1了,程序就会跳转到中断函数。如果你不清除中断标志位,那它就会一直申请中断,这样程序就会不断响应中断,执行中断函数,那程序就卡死在中断函数里了。所用函数:EXTI_ClearITPendingBit(uint32_t EXTI_Line)
中断函数就不用声明了,因为中断函数不需要调用,它是自动执行的。
其它涉及函数:
ITStatus EXTI_GetITStatus(uint32_t EXTI_Line)
作用:检查指定的 EXTI 线路触发请求发生与否(是不是我们想要的中断触发源)
返回值:(SET或RESET)
参数说明:
void GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
作用:读取指定端口管脚的输入
参数说明:
void EXTI_ClearITPendingBit(uint32_t EXTI_Line)
作用:清除EXTI线路挂起位
参数说明:
EXTI和NVIC两个外设,这两个外设的时钟是一直都打开着的,不需要我们再开启时钟了。EXIT模块是由NVIC模块直接控制的,并不需要单独的外设时钟。NVIC也不需要开启时钟,是因为NVIC是内核的外设,内核的外设都是不需要开启时钟的。
代码如下:
蓝线部分是我自己需要注意的地方
5-2 旋转编码器计次37:30
在写中断函数的核心思想:
只有在B相下降沿和A相低电平时,才判断为正转
在A相下降沿和B相低电平时,才判断为反转
代码如下:
6、TIM(Timer)定时器
为什么在72MHz计数时钟下可以实现最大59.65s的定时?
72M/65536/65536,得到的是中断频率,然后取倒数,就是59.65秒多,大家可以自己算一下。
详细解释:在定时器中,预分频器和计数器都是16位的,所以它们的最大值是65535,而不是65536。预分频器的最大值决定了计数时钟的频率,而计数器的最大值决定了定时器的最大计数周期。因此,如果预分频器和计数器的最大值都设置为65535,那么定时器的最大时间就是72MHz/65536/65536,得到的是中断频率,倒数就是中断时间。【最大值是65536,但计数是从0~65535】
接下来,我们就依次来看一下高级定时器、通用定时器和基本定时器的结构图,看一下这三种定时器是怎么样来工作的,设计这些结构都能完成哪些任务。
1.1 基本定时器(TIM6和TIM7)
1.1_1_ 时基单元
这个可编程定时器的主要部分是一个带有自动重装载的16位累加计数器,计数器的时钟通过一个预分频器得到。
软件可以读写计数器、自动重装载寄存器和预分频寄存器,即使计数器运行时也可以操作。时基单元包含:
预分频寄存器(TIMx_PSC)
预分频器
预分频可以以系数介于1至65536之间的任意数值对计数器时钟分频,就是对输入的基淮频率提前进行一个分频的操作。它是通过一个16位寄存器(TIMx-PSC)的计数实现分频。因为TIMx-PSC控制寄存器具有缓冲,可以在运行过程中改变它的数值,新的预分频数值将在下一个更新事件时起作用。
假设这个寄存器写0,就是不分频,或者说是1分频,这时候输出频率=输入频率=72MHz;如果预分频器写1,那就是2分频,输出频率=输入频率/2=36MHz,所以预分频器的值和实际的分频系数相差了1,即实际分频系数=预分频器的值+1。
时序图讲解32:34
注意:实际的设置计数器使能信号CNT_EN相对于CEN滞后一个时钟周期。
计数器寄存器(TIMx_CNT)
计数器由预分频输出CK_CNT驱动,设置TIMx_CR1寄存器中的计数器使能位(CEN)使能计数器计数。这个计数器可以对预分频后的计数时钟进行计数,计数时钟每来一个上升滑,计数器的值就加1,由于这个计数器也是16位的,所以里面的值可以从0一直加到65535,如果再加的话,计数器就会回到0重新开始。所以计数器的值在计时过程中会不断地自增运行,当自增运行到目标值时,产生中断,那就完成了定时的任务,所以现在还需要一个存储目标值的寄存器,那就是自动重装寄存器了。
时序图讲解
自动重裝载寄存器(TIMx_ARR)
自动重装载寄存器是预加载的,每次读写自动重装载寄存器时,实际上是通过读写预加载寄存器实现。根据TIMx CR1寄存器中的自动重装载预加载使能位(ARPE),写入预加载寄存器的内容能够立即或在每次更新事件时,传送到它的影子寄存器。当TIMx CR1寄存器的UDIS位为’0’,则每当计数器达到溢出值时,硬件发出更新事件;软件也可以产生更新事件;关于更新事件的产生,随后会有详细的介绍。
38:47讲解
39:27讲解
1.2 通用定时器(TIM2、3、4、5)
通用计时器库函数
关于图中引脚对应可以参考引脚定义图
红框所标出来的意思:这个TIM2的CH1和ETR脚都复用在PA0引脚,下面还有CH2、CH3、CH4(CH是通道)和其他定时器的一些引脚,也都可以在这里找到。
中间由红框标出来的寄存器是捕获/比较寄存器,是输入捕获和输出比较电路共用的,因为输入捕获和输出比较不能同时使用,所以这里的寄存器是共用的,引脚也是共用的。
)
1.2_1_ 计数器模式
像这样带一个黑色阴影的寄存器,都是有影子寄存器这样的的缓冲机制的,包括预分频器,自动重装寄存器和下面的捕获比较寄存器,所以计数的这个ARR自动重装寄存器,也是有一个缓冲寄存器的,并且这个缓冲寄存器是用还是不用,是可以自己设置的
38:45计数器有无缓冲寄存器的情况
1.2_2_ 时钟选择(电路讲解)
时钟源的输入——时钟源
预分频器之前,连接的就是基准计数时钟的输入,由于基本定时器只能选择内部时钟,所以你可以直接认为时基单元直接连到了输入端,也就是内部时钟CK_INT。内部时钟的来源是RCC_TIMXCLK,这里的频率值一般都是系统的主频72MHz,所以通向时基单元的计数基准频率就是72M。
计数器的时钟由内部时钟(CK_INT)提供。TIMx CR1寄存器的CEN位和TIMx EGR寄存器的UG位是实际的控制位, (除了UG位被自动清除外)只能通过软件改变它们。一旦置CEN位为’1’,内部时钟即向预分频器提供时钟。下图示出控制电路和向上计数器在普通模式下,没有预分频器时的操作。
计数器时钟可由下列时钟源提供:
内部时钟(CK_INT)
外部时钟模式1:外部输入脚(TIx)
外部时钟模式2:外部触发输入(ETR)
内部触发输入(ITRx):使用一个定时器作为另一个定时器的预分频器,如可以配置一个定时器Timer1而作为另一个定时器Timer2的预分频器。
【注:编码器接口可以读取编码器的输出波形】
内部时钟(CK_INT)
外部时钟模式1:外部输入脚(TIx)
当TIMx_SMCR寄存器的SMS=111时,此模式被选中。计数器可以在选定输入端的每个上升沿或下降沿计数。
当这个TRGI当做外部时钟来使用的时候,这一路就叫做“外部时钟模式1”,那通过这一路的外部时钟都有哪些呢?
第一个,就是ETR引脚的信号
然后第二个,就是ITR信号,这一部分的时钟信号是来自其他定时器,从右边可以看出,这个主模式的输出TRGO可以通向其他定时器,那通向其他定时器的时候,就接到了其他定时器的ITR引脚上来了。
这个ITRO到ITR3分别来自其他4个定时器的TRGO输出,至于具体的连接方式是怎么的,手册的这个位置有一张表。这里可以看到,TIM2的ITRO是接在了TIM1的TRGO上,ITR1接在了TIM8,ITR2接在了TIM3,ITR3接在了TIM4,其他定时器也都可以参照一下这个表,这就是TR和定时器的连接关系。通过这一路我们就可以实现定时器级联的功能.比如我可以先初始化TIM3,然后使用主模式把它的更新事件映射到TRGO上,接着再初始化TIM2,这里选择ITR2,对应的就是TIM3的TRGO,然后后面再选择时钟为外部时钟模式1,这样TIM3的更新事件就可以驱动TIM2的时基单元,也就实现了定时器的级联.
这里还可以选择TI1F_ED,这里连接的是这里输入捕获单元的CH1引脚,也就是从CH1引脚获得时钟,这里后缀加一个ED(Edge)就是边沿的意思,也就是通过这一路输入的时钟,上升沿和下降沿均有效
最后,这个时钟还能通过TI1FP1和TI2FP2获得
总结一下就是,外部时钟模式1的输入可以是ETR引脚、其他定时器,CH1引脚的边沿、CH1引脚和CH2引脚,这还是比较复杂的,一般情况下外部时钟通过ETR引脚就可以了。上面设置这么复杂的输入,不仅仅是为了扩大时钟输入的范围,更多的还是为了某些特殊应用场景而设计的,比如为了定时器的级联而设计的ITRx引脚,最后的一部分,我们之后讲输入捕获和测频率时,还会继续讲到。
注:对于时钟输入而言,最常用的还是内部的72MHz的时钟,如果要使用外部时钟,如果要使用外部时钟,首选ETR引脚外部时钟模式2的输入,这一路最简单、最直接。
外部时钟模式2:外部触发输入(ETR),
计数器能够在外部触发ETR的每一个上升沿或下降沿计数。
这个ETR(External)引脚的位置,可以参考一下引脚定义表。
可以看到这里有TIM2_CH1_ETR,意思就是这个TIM2的CH1和ETR都是复用在了这个位置,也就是PA0引脚,下面还有CH2,CH3,CH4和其他定时器的一些引脚,也都可以在这里找到。
那这里我们可以在这个TIM2的ETR引脚,也就是PA0上接一个外部方波时钟,然后配置一下内部的极性选择、边沿检测和预分频器电路,再配置一下输入滤波电路,这两块电路可以对外部时钟进行一定的整形。因为是外部引脚的时钟,所以难免会有的毛刺,那这些电路就可以对输入的波形进行滤波,同时也可以选择一下极性和预分频器。最后,滤波后的信号,兵分两路,上面一路ETRF进入触发控制器,紧跟着就可以选择作为时基单元的时钟了。
如果你想在ETR外部引脚提供时钟或者想对ETR时钟进行计数,把这个定时器当做计数器来用的话,那就可以配置这一路的电路,在STM32中,这一路也叫做“外部时钟模式2“。
例如,要配置在ETR下每2个上升沿计数一次的向上计数器,使用下列步骤:
1,本例中不需要滤波器,置TIMx_SMCR寄存器中的ETF(握)= 0000
2,设置预分频器,置TIMx_SMCR寄存器中的早期胸腺祖细胞(1:0)= 1
3.设置在ETR的上升沿检测,置TIMx_SMCR寄存器中的ETP=0
4,开启外部时钟模式2,置TIMx_SMCR寄存器中的ECE=1
5.,启动计数器,置TIMx_CR1寄存器中的CEN=1
计数器在每2个ETR上升沿计数一次。
在ETR的上升沿和计数器实际时钟之间的延时取决于在ETRP信号端的重新同步电路。
内部触发输入(ITRx)(定时器同步)
所有TIMx定时器在内部相连,用于定时器同步或链接。当一个定时器处于主模式时,它可以对另一个处于从模式的定时器的计数器进行复位、启动、停止或提供时钟等操作。
配置定时器1为主模式,它可以在每一个更新事件UEV时输出一个周期性的触发信号。在TIM1_CR2寄存器的MMS='010’时,每当产生一个更新事件时在TRGO1上输出一个上升沿信号。
连接定时器1的TRGO1输出至定时器2,设置TIM2_SMCR寄存器的TS =‘000’,配置定时器2为使用ITR1作为内部触发的从模式。(为什么是‘000’,硬件底层已经根据不同选择定义好了)
然后把从模式控制器置于外部时钟模式1(TIM2 SMCR寄存器的SMS-111):这样定时器2即可由定时器1周期性的上升沿(即定时器1的计数器溢出)信号驱动。
最后,必须设置相应(TIMx_CR1寄存器)的CEN位分别启动两个定时器。如果OCx已被选中为定时器1的触发输出(MMS=1xx),它的上升沿用于驱动定时器2的计数器。
注:如果OCx已被选中为定时器1的触发输出(MMS=1xx),它的上升沿用于驱动定时器2的计数器。
这一段内容是涉及参考手册14.3.15的内容,关于这个模式还有更多功能,比如:使用一个定时器使能另一个定时器;使用一个定时器去启动另一个定时器;使用一个定时器作为另一个的预分频器;使用一个外部触发同步地启动2个定时器,感兴趣的可以自己去了解
编码器模式
最后这里还有一块没有讲到,这个是定时器的一个编码器接口,可以读取正交编码器的输出波形,这个我们后续课程也会再讲。
这部分电路可以把内部的一些事件映射到这个TRGO引脚上,比如我们刚才讲基本定时器分析的,将更新事件映射到TRGO,用于触发DAC。这里也是一样,它可以把定时器内部的一些事件映射到这里来,用于触发其它定时器、DAC或者ADC,可见这个触发输出的范围是比基本定时器更广一些的。
输入捕获输出比较电路粗讲
那有关输入捕获和输出比较这部分电路,在之后具体分析
了解:通用定时器中异或门的作用
1.3 高级定时器
36:00这里内容根据需求学习
2.TIM定时中断
这一段的内容主要搞懂定时中断和内外时钟源选择及如何配置。
首先中间最重要的还是PSC(Prescaler)预分频器、CNT (Counter)计数器、ARR (AutoReloadRegister)自动重装器这三个寄存器构成的时基单元。下面这里是运行控制,就是控制寄存器的一些位,比如启动停止、向上或向下计数等等,我们操作这些寄存器就能控制时基单元的运行了。
左边是为时基单元提供时钟的部分,这里可以选择RCC提供的内部时钟,也可以选择ETR引脚提供的外部时钟模式2。在本小节示例程序里,第一个定时器定时中断就是用的内部时钟这一路,第二个定时器外部时钟就是用的外部时钟模式2这一路。当然还可以选择这里的触发输入当做外部时钟,即外部时钟模式1,对应的有ETR外部时钟、TTRX其他定时器、TlX输入捕获通道,这些就是定时器的所有可选的时钟源了。最后这里,还有个编码器模式,这一般是编码器独用的模式,普通的时钟用不到这个。
接下来右边这里,就是计时时间到,产生更新中断后的信号去向。那这里中断信号会先在状态寄存器里置一个中断标志位,这个标志位会通过中断输出控制,到NVIC申请中断。
为什么会有一个中断输出控制呢?
因为这个定时器模块有很多地方都要申请中断。比如上面这个图不仅更新要申请中断,这里触发信号也会申请中断,还有下面的输入捕获和输出比较匹配时也会申请。所以这些中断都要经过中断输出控制,如果需要这个中断,那就允许,如果不需要,那就禁止。简单来说,这个中断输出控制就是一个中断输出的允许位。
代码实战3:定时中断和内外时钟源选择
6-1 定时器定时中断
第一步,RCC开启时钟,这个基本上每个代码都是第一步。在这里打开时钟后,定时器的基准时钟和整个外设的工作时钟就都会同时打开了
第二步,选择时基单元的时钟源。对于定时中断,我们就选择内部时钟源
注:没选择时钟,会默认内部时钟
然后最后一个函数,TIM_ETRConfig,这个不是用来选择时钟的,就是单独用来配置ETR引脚的预分频器、极性、滤波器这些参数的
涉及函数如下:
void TIM_InternalClockConfig(TIM_TypeDef* TIMx)
作用:配置TIMx内部时钟
参数说明:
第三步,配置时基单元。包括这里的预分频器、自动重装器、计数模式等等,这些参数用一个结构体就可以配置好了。
涉及函数如下:
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct)
作用:根据TIM_TimeBaseInitStruct中指定的参数初始化TIMx时基单元外设。
参数说明:
18:49~20:17
如何确定时间参数讲解
假设定时1s,也就是定时频率为1Hz,那我们就可以PSC给一个7200,ARR给一个10000,然后两个参数都再减一个1,因为预分频器和计数器都有1个数的偏差,所以这里要再减个1。然后注意这个PSC和ARR的取值都要在0~65535之间,不要超范围了
第四步,配置输出中断控制,允许更新中断输出到NVIC(开启更新中断到NVIC的通路)
涉及函数如下:
void TIM_ITConfig(TIM_TypeDef* TIMx, uint16_t TIM_IT, FunctionalState NewState)
作用:启用或禁用指定的TIM中断。
参数说明:
注:TIM_IT_Update 更新中断
在STM32库里还提及其它中断源
第五步,配置NVIC,在NMC中打开定时器中断的通道,并分配一个优先级。这部分在上节我们也用过,流程基本是一样的
涉及函数:
void NVIC_PriorityGroupConfig(uint32_t NVIC_PriorityGroup)
void NVIC_Init(NVIC_InitTypeDef* NVIC_InitStruct)
第六步,就是运行控制了。整个模块配置完成后,我们还需要使能一下计数器。要不然计数器是不会运行的。当定时器使能后,计数器就会开始计数了,当计数器更新时,触发中断。
涉及函数如下:
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState)
作用:启用或禁用指定的TIM外设。
参数说明:
这样初始化基本上就OK了,接下来,我们再看几个函数,因为在初始化结构体里有很多关键的参数,比如自动重装值和预分频值等等,这些参数可能会在初始化之后还需要更改,如果为了改某个参数还要再调用一次初始化函数,那太麻烦了。所所以这里有一些单独的函数,可以方便地更改这些关键参数。
比如这里的TIM_PrescalerConfig(TIM_TypeDef* TIMx, uint16_t Prescaler, uint16_t TIM_PSCReloadMode),就是用来单独写预分频值的,看一下参数,Prescaler,就是要写入的预分频值;后面还有个参数,PSCReloadMode,写入的模式。我们上一小节说了,预分频器有一个缓冲器,写入的值是在更新事件发生后才有效的,所以这里有个写入的模式,可以选择是听从安排,在更新事件生效,或者是,在写入后,手动产生一个更新事件,让这个值立刻生效。
TIM_CounterModeConfig(TIM_TypeDef* TIMx, uint16_t TIM_CounterMode);,用来改变计数器的计数模式,参数CounterMode,选择新的计数器模式。
TIM_ARRPreloadConfig(TIM_TypeDef* TIMx, FunctionalState NewState);,自动重装器预装功能配置。
TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);,给计数器写入一个值。如果你想手动给一个计数值,就可以用这个函数
TIM_SetAutoreload(TIM_TypeDef* TIMx, uint16_t Autoreload);给自动重装器写入一个值,如果你想手动给一个自动重装值,就可以用这个函数
uint16_t TIM_GetCounter(TIM_TypeDef* TIMx);获取当前计数器的值,如果你想看当前计数器计到哪里了,就可以调用一下这个函数,返回值就是当前的计数器的值
uint16_t TIM_GetPrescaler(TIM_TypeDef* TIMx);获取当前的预分频器的值
最后我们再写一个定时器的中断函数。这样这个中断函数每隔一段时间就能自动执行一次了。
6-2 定时器外部时钟31:35
提示:
这里推荐配置是浮空是输入,但是我一般不太喜欢浮空输入平因为一旦悬空,电平就会跳个没完,所以我准备给上拉输入,这也是可以的。
那什么时候需要用浮空输入呢?就是如果你外部的输入信号功率很小,内部的这个上拉电阻可能会影响到这个输入信号,这时就可以用一下浮空输入,防止影响外部输入的电平。
在6-1的基础上更改,尤其注意在第二步更改时基单元的时钟源,通过ETR引脚的外部时钟模式2配置。
void TIM_ETRClockMode2Config(TIM_TypeDef* TIMx, uint16_t TIM_ExtTRGPrescaler, uint16_t TIM_ExtTRGPolarity, uint16_t ExtTRGFilter)
作用:配置TIMx外部时钟模式2
参数说明:
3.TIM输出比较
捕获/比较寄存器是输入捕获和输出比较共用的,当使用输入捕获时,它就是捕获寄存器;当使用输出比较时,它就是比较寄存器。那在输出比较这里,这块电路会比较CNT和CCR的值,CNT计数自增,CCR是我们给定的一个值,当CNT大于CCR、小于CCR或者等于CCR时,这里输出就会对应的置1、置0、置1、置0,这样就可以输出一个电平不断跳变的PWM波形了。这就是输出比较的基本功能。
使用这个PWM波形,是用来等效地实现一个模拟信号的输出
问题:数字输出端口控制LED,按理说LED只能有完全亮和完全灭两种状态,怎么能实现控制亮度大小呢?
通过PWM就可以实现,我们让LED不断点亮、熄灭、点亮、熄灭,当这个点亮、熄灭的频率足够大时,LED就不会闪烁了,而是呈现出一个中等亮度。当我们调控这个点亮和熄灭的时间比例时,就能让LED呈现出不同的亮度级别。对于电机调速也是一样。
当然,PWM的应用场景必须要是一个惯性系统,就是说LED在熄灭的时候,由于余晖和人眼视觉暂留现象,LED不会立马熄灭,而是有一定的惯性,过一小段时间才会熄灭。电机也是,当电机断电时,电机的转动不会立马停止,而是有一定的惯性,过一会才停。
那接下来我们就来具体地分析一下,定时器的输出比较模块是怎么来输出PWM波形的,我们先看一下通用定时器的这个结构。
接下来我们还需要看一下这个输出模式控制器,它具体是怎么工作的。什么时候给REF高电平,什么时候给REF低电平。我们看一下下面的这个表,这就是输出比较的8种模式,也就是这个输出模式控制器里面的执行逻辑。这个模式控制器的输入是CNT和CCR的大小关系,输出是REF的高低电平,里面可以选择多种模式来更加灵活地控制REF输出。这个模式可以通过寄存器来进行配置,具体操作看下面的表
冻结
那这个模式也比较简单,它根本就不管CNT谁大谁小,直接REF保持不变、维持上一个状态就行了,这有什么用呢?比如你正在输出PWM波,突然想暂停一会儿输出,就可以设置成这个模式,一但切换为冻结模式后,输出就暂停了,并且高低电平也维持为暂停时刻的状态,保持不变。这就是冻结模式的作用
这个有效电平和无效电平,一般是高级定时器里面的一个说法,是和关断、刹车这些功能配合表述的,它说的比较严谨,所以叫有效电平和无效电平。在这里为了理解方便,你可以直接认为置有效电平就是置高电平,置无效电平就是置低电平.
匹配时…
这三个模式都是当CNT与CCR值相等时,执行操作。
这些模式就可以用做波形输出了,比如相等时电平翻转这个模式,这个可以方便地输出一个频率可调,占空比始终为50%的PWM波形。比如你设置CCR为0,那CNT每次更新清0时,就会产生一次CNT=CCR的事件,这就会导致输出电平翻转一次,每更新两次,输出为一个周期,并且高电平和低电平的时间是始终相等的,也就是占空比始终为50%,当你改变定时器更新频率时,输出波形的频率也会随之改变。它俩的关系是输出波形的频率=更新频率/2,因为更新两次输出才为一个周期。这就是匹配时电平翻转模式的用途。
那上面这两个相等时置高电平和低电平,感觉用途并不是很大,因为它们都只是一次性的,置完高或低电平后,就不管事了,所以这俩模式不适合输出连续变化的波形。如果你想定时输出一个一次性的信号,那可以考虑一下下这两个模式。
强制为无效电平|有效电平
如果你想暂停波形输出,并且在暂停期间保持低电平或者高电平,那你就可以设置这两个强制输出模式。
PWM模式1|2
它们可以用于输出频率和占空比都可调的PWM波形,也是我们主要使用的模式。这个情况比较多,一般我们都只使用向上计数,PWM模式2实际上就是PWM模式1输出的取反(改变PWM模式1和PWM模式2,就只是改变了REF电平的极性而已),是因为REF输出之后还有一个极性的配置,所以使用PWM模式1的正极性和PWM模式2的反极性最终的输出是一样的。所以使用的话,我们可以只使用PWM模式1,并且是向上计数,这一种模式就行了。
那PWM模式1向上计数是怎么输出频率和占空比都可调的PWM波形的呢?
在这里,我给出了输出PWM的基本结构,这也是我们本节课的重点内容
我们就再来看一下PWM的参数是如何计算的
25:46高级定时器的输出比较电路了解即可
代码实战:PWM的实际使用
6-3 PWM驱动LED呼吸灯
现象:在PA0端口接入LED,LED在不断地变换亮度,实现了一个呼吸灯的效果
第一步,RCC开启时钟,把我们要用的TIM外设和GPIO外设的时钟打开
第二步,配置时基单元
第三步,配置输出比较单元,里面包括这个CCR的值、输出比较模式、极性选择、输出使能这些参数
涉及函数:
void TIM_OCXInit(TIM_TypeDef* TIMx, TIM_OCInitTypeDef* TIM_OCInitStruct)其中TIM_OCXInit的X为1、2、3、4,对应4个输出比较单元,或者说输出比较通道。你需要初始化哪个通道,就调用哪个函数。不同的通道对应的GPIO口也是不一样的,所以这里要按照你GPIO口的需求来。这里使用的是PAO口,对应的就是第一个输出比较通道。对于TIM2来说,就是下图对应引脚
你要使用哪个外设,就只能用对应的引脚,不过,但是虽然它是定死的,STM32还是给了我们一次更改的机会的,这就是重定义,或者叫重映射。比如如果你既要用USART2的TX引脚,又要用TIM2的CH3通道,它俩冲突成,没办法同时用,那我们就可以在这个重映射的列表里找一下,比如这里我们找到了TIM2的CH3,那TIM2的CH3就可以从原来的引脚,换到这里的引脚,这样就避免了两个外设引脚的冲突。如果这个重映射的列表里找不到,那外设复用的GPIO就不能挪位置.这就是重映射的功能,配置重映射是用AFIO来完成的,重映射在最后会讲
作用:根据TIM_OCInitStruct中指定的参数初始化TIMx channel。
参数说明:
TIM_OCInitTypeDef structure结构体说明:
实际上通用计时器只用到了这些结构体成员,但结构体里面还有些成员是面向高级定时器,比如:
但是如果当你中途想把高级定时器当做通用定时器输出PWM时,那你自然就会把TIM_OCXInit的TIM2改成TIM1。这样的话,这个结构体原本没有用到的成员,现在需要使用,但是对于那些成员并没有赋值,那就会导致高级定时器输出PWM出现一些奇怪的问题最终找到的原因,就是因为这里结构体成员没有配置完整。所以为了避免程序中出现不确定的因素,把结构体所有的成员都配置完整;需要么就先给结构体成员都赋一个初始值,再修改部分的结构体成员,
所以void TIM_OCStructInit(TIM_OCInitTypeDef* TIM_OCInitStruct)有了用武之地。
作用:TIM_OCInitStruct 中的每一个参数按缺省值填入
参数说明:
第四步,配置GPIO.把PWM对应的GPIO口,初始化为复用推挽输出的配置。为什么选择这个模式呢?对于普通的开漏/推挽输出,引脚的控制权是来自于输出数据寄存器的
那通过刚才看到引脚定义表,我们就知道了,这里片上外设引脚连接的就是TIM2的CH1通道。所以,只有把GPIO设置成复用推挽输出,引脚的控制权才能交给片上外设,PWM波形才能通过引脚输出。
那最后,第五步,就是运行控制了.启动计数器,这样就能输出PWM了
void TIM_SetCompare1(TIM_TypeDef* TIMx, uint16_t Compare1)(通道1 )
作用:设置TIMx捕获比较寄存器值(CCR)
参数说明:
重映射:
根据你所要重映射的引脚,在下图找到所需要的模式,比如:如果我们想把PAO改到PA15,就可以选择这个部分重映射方式1,或者完全重映射。
在但是PA15在引脚定义图里没有加粗,因为它上电后已经默认复用为了调试端口JTDI,所以如果想让他作为普通的GPIO或者复用定时器的通道。那还需要先关闭调试端回的复用,也是用这个GPIO PinRemapConfig函数
如果你想让PA15、PB3、PB4这三个引脚当做GPIO来使用的话,那就加一下这里的第一句和第三句,先打开AFIO时钟,再用AFIO将JTAG复用解除掉,这样就行了;
如果你想重映射定时器或者其他外设的复用引脚,那就加一下这里的第一句和第二句,先打开AFIO时钟,再用AFIO重映射外设复用的引脚,这样就行了;
如果你重映射的引脚又正好是调试端口,那这三句就都得加上,打开AFIO时钟,重映射引脚,解除调试端口,这样才行。
6-4PWM驱动舵机
!!这里一定要注意正负极!!接错可能会烧坏电脑!!
接线说明:
SG90舵机,它有三根线,第一个GND,就是棕色线,接在面包板的GND;第二个5V正极,就是红色线,这里要接5V的电机电源,大家不要把它接在面包板的正极了,这个STM32芯片正极只有3.3V的电压,而且输出功率不太,带不动电机的,所以我们需要把它接在STLINK的5V输出引脚;然后看第三个引脚,PWM信号,就是橙色线,接在PA1引脚上(这里用的是PA1的通道2)【看数据手册,里面的引脚定义表,PA0的复用功能是TIM2_CH1(通道一),PA1的复用功能是TIM2_CH2(通道2)】
那最后,再在PB1接一个按键,用来控制舵机,这样这个电路就完成了。
6-5PWM驱动直流电机
4.TIM输入捕获
输入捕获对于PID控制算法很重要,没有输入捕获就不能完成闭环控制,要做平衡车的一定要认真学
8:38~18:18频率测量的相关知识讲解
测频法:定时器中断,并记录捕获次数;测周法:捕获中断,并记录定时器次数。
输入捕获电路的工作流程
由四个问题来深入输入捕获的工作流程
输入捕获和输出比较的区别?
为什么要进行一个交叉连接呢?
滤波器具体是怎么工作的呢?
如何自动清零CNT呢?
输入捕获和输出比较的区别?
对比一下输出比较,就是:
输出比较,引脚是输出端口;输入捕获,引脚是输入端口;
输出比较,是根据CNT和CCR的大小关系来执行输出动作;输入捕获,是接收到输入信号,执行CNT锁存到CCR的动作。
交叉连接的目的:
为什么要进行一个交叉连接呢?
这样做的目的,个人认为主要有两个,第一个目的,可以灵活切换后续捕获电路的输入;第二个目的,也是它交叉的主要目的,就是可以把一个引脚的输入,同时映射到两个捕获单元,这也是PWMI模式的经典结构。第一个捕获通道,使用上升沿触发,用来捕获周期,第二个通道,使用下降沿触发,用来捕获占空比。两个通道同时对一个引脚进行捕获,就可以同时测量频率和占空比,这就是PWMI模式,等会儿再来继续分析。一个通道灵活切换两个引脚,和两个通道同时捕获一个引脚,这就是这里交叉一下的作用和目的。同样,下面通道3和通道4,也是一样的结构,可以选择各自独立连接,也可以选择进行交叉。另外,这里还有一个TRC信号,也可以选择作为捕获部分的输入,这样设计,也是为了无刷电机的驱动。
到这里,电路的整个工作流程讲完了。比如我们可以配置上升沿触发捕获,每来一个上升沿,CNT转运到CCR一次,又因为这个CNT计数器是由内部的标准时钟驱动的,所以CNT的数值,其实就可以用来记录两个上升沿之间的时间间隔,这个时间间隔,就是周期,再取个倒数,就是测周法测量的频率了。另外这里还有个细节问题,就是每次捕获之后,我们都要把CNT清0一下,这样下次上升沿再捕获的时候,取出的CNT才是两个上升沿的时间间隔,这个在一次捕获后自动将CNT清零的步骤,我们可以用主从触发模式,自动来完成。
接下来就是执行细节的问题,把电路执行的细节都了解清楚,这样写程序的时候才能得心应手。好,那接着看一下这里,这是输入捕获通道1的一个更详细的框图,基本功能都是一样的。
滤波器具体是怎么工作的呢?
可以看一下手册,在CCMR1寄存器这里有IC1F位,根据它的描述简单理解,这个滤波器工作原理就是:以采样频率对输入信号进行采样,当连续N个值都为高电平,输出才为高电平,连续N个值都为低电平,输出才为低电平。如果你信号出现高频抖动,导致连续采样N个值不全都一样,那输出就不会变化,这样就可以达到滤波的效果。采样频率越低,采样个数N越大说滤波效果就越好,那下面这些描述,就是每个参数对应的采样频率和采样个数。在实际应用中,如果波形噪声比较大入100,就可以把这个参数设置大一些,这样就可以过滤噪声了。
如何自动清零CNT呢?
看一下这里,这个TI1FP1信号和TI1的边沿信号,都可以通向从模式控制器,比如TI1FP1信号的上升沿触发捕获,那通过这里,TI1FP1还可以同时触发从模式,这个从模式里面,就有电路,可以自动完成CNT的清零。所以可以看出,这个从模式就是完成自动化操作的利器。
那接下来我们就来研究一下这个主从触发模式。主从触发模式有什么用,如何来完成硬件自动化的操作。
主从触发模式,就是主模式、从模式。
如果想完成我们刚才说的任务,想让TI1FP1信号自动触发CNT清零,那触发源选择,就可以选中这里的TI1FP1,从模式执行的操作,就可以选择执行Reset的操作。这样TI1FP1的信号就可以自动触发从模式,从模式自动清零CNT,实现硬件全自动测量,这就是主从触发模式的用途。
那有关这些信号的具体解释,可以看手册
那回到PPT,总结下来就是这三个图,主模式,触发源选择,从模式,在库函数里也非常简单。这三块东西,就对应三个函数,调用函数,给个参数,就行了,这些就是主从触发模式的内容。接下来,我们就来最后理一下思路,把之前的东西组合在一起,得到这两个图。这两个图也分别对应了我们演示两个代码的逻辑,先看第一个,输入捕获基本结构:
然后还有几个注意事项说明一下,首先是这里CNT的值是有上限的,ARR—般设置为最大65535,那CNT最大也只能计65535个数。如果信号频率太低,CNT计数值可能会溢出(因为CNT计数的快慢是根据时基单元的时钟频率而变化的,如果时钟频率很高,CNT增长非常快,如果被测信号频率太低,完全有可能CNT计满65536都不到被测信号的一个周期)。另外还有就是,这个从模式的触发源选择,在这里看到,只有TI1FP1和TI2FP2,没有TI3和TI4的信号,所以这里如果想使用从模式自动清零CNT,就只能用通道1和通道2。对于通道3和通道4,就只能开启捕获中断,在中断里手动清零了,不过这样,程序就会处于频繁中断的状态,比较消耗软件资源,这个注意一下。
好,接下来我们继续来看最后一个PPT,这里展示的是PWMI基本结构。
这个PWMI模式,使用了两个通道同时捕获一个引脚,可以同时测量周期和占空比。
我们来看一下,上面这部分结构,和刚才演示的一样,下面这里多了一个通道。
首先,TI1FP1配置上升沿触发,触发捕获和清零CNT,正常地捕获周期,这时我们再来一个TI1FP2,配置为下降沿触发,通过交叉通道,去触发通道2的捕获单元,这时会发生什么呢?
我们看一下左上角的这个图,最开始上升沿,CCR1捕获,同时清零CNT,之后CNT一直++,然后,在下降沿这个时刻,触发CCR2捕获,所以这时CCR2的值,就是CNT从这里到这里的计数值,就是高电平期间的计数值,CCR2捕获,并不触发CNT清零,所以CNT继续++。
直到下一次上升沿,CCR1捕获周期,CNT清零,这样执行之后CCR1就是一整个周期的计数值,CCR2就是高电平期间的计数值,我们用CCR2/CCR1,是不是就是占空比了。这就是PWMI模式,使用两个通道来捕获频率和占空比的思路。
另外这里,你可以两个通道同时捕获第一个引脚的输入,这样通道2的前面这一部分就没有用到。
当然也可以配置两个通道同时捕获第二个引脚的输入,这样我们就是使用TI2FP1和TI2FP2这两个引脚了,这两个输入可以灵活切换。
好,到这里,我们本小节的内容差不多就结束了,最后大致看一下手册37:28
代码实战:输入捕获模式测频率和占空比
6-6 输入捕获模式测频率
现象:在这里,为了测量外部信号的频率,我们先得有个信号源,产生一个频率和占空比可调的波形,但是考虑到大家可能没有信号发生器,所以我这里就借用了一下上一小节的代码。先用PWM模块,在PAO端口输出一个频率和占空比可调的波形,然后我们本节的代码,测量波形的输入口是PA6,所以我们直接用一根线,把PAO和PA6连在一起,这样就能测量自己PWM模块产生波形的频率了。
目前这个程序只能测频率,还不能测量占空比,如果想同时测量频率和占空比,STM32的输入捕获还设计了一个PWM模式,即PWM输入模式。
在6-3 PWM驱动LED呼吸灯的工程基础上写
前置操作:
PWM模块这里,我们还要再进行一些改进。目前这个代码的逻辑是初始化TIM2的通道1,产生一个PWM波形,输出引脚是PA0。然后通过SetCompare1函数,可以调节CCR1寄存器的值,从而控制PWM的占空比。但是目前PWM的频率,是在初始化里写好了的,是固定的,运行的时候调节不太方便,所以我们在最后再加一个函数,用来便捷地调节PWM频率。
如何调节PWM频率呢?
通过公式,我们知道PWM频率=更新频率=72M/(PSC+1/(ARR+1),所以PSC和ARR都可以调节频率,但是占空比=CCR/(ARR+1),所以通过ARR调节频率,还同时会影响到占空比,而通过PSC调节频率,不会影响占空比,显然比较方便。所以我们的计划是,固定ARR为100-1,通过调节PSC来改变PWM频率,另外ARR为100-1,CCR的数值直接就是占空比,用起来比较直观。
当然实际使用也是有技巧的,一般我们可以根据分辨率的要求,先确定好ARR,比如分辨率,1%就足够了;那ARR给100-1,这样PSC决定频率,CCR决定占空比。如果我想要更高的分辨率,比如0.1%,那ARR就先固定1000-1,这样频率就是72M/预分频/1000,占空比就是CCR/1000,这样也好算。
在这里,目前ARR我们固定给100-1,初始化操作的PSC就先不管,我们后面再写一个函数,在初始化之后单独修改PSC。
例如:定义一个void PWM_SetPrescaler(uint16_t Prescaler)函数,在自定义函数里面,我们就要调用库函数里单独写入PSC的函数了,TIM_PrescalerConfig,就是单独写入PSC的函数。因为这个函数还有一个重装模式的参数,所以它并不叫SetPrescaler,而叫PrescalerConfig。这是这个库的命名规范。
void TIM_PrescalerConfig(TIM_TypeDef* TIMx, uint16_t Prescaler, uint16_t TIM_PSCReloadMode)
可能是因为手册版本太低了,并没有提到中间参数,那我们就看库里面的注释
参数Prescaler:要写入PSC的值。
接下来就可以写输入捕获的代码
第一步,RCC开启时钟,把GPIO和TIM的时钟打开
注意:我们这个代码还需要TIM2输出PWM,所以输入捕获的定时器要换一个,我们就换到TIM3(这里在组建IC捕获模块,TIM2是PWM已经定义好的,捕获模块要重新定义一个)。其次我们这里用到的是TIM3通道1,查引脚定义表,你就知道为什么连PA6。
第二步,GPIO初始化,把GPIO配置成输入模式,一般选择上拉输入或者浮空输入模式
第三步,配置时基单元,让CNT计数器在内部时钟的驱动下自增运行,这一步和之前的代码是一样的
ARR自动重装值,根据之前的分析,arr越大,输入捕获越能更精准地测更小的频率,其次防止计数溢出。
72M/预分频,就是计数器自增的频率,就是计数标准频率。这个需要根据你信号频率的分布范围来调整,我暂时先给72-1,这样标准频率就是72M/72=1MHz。
第四步,配置输入捕获单元,包括滤波器、极性、直连通道还是交叉通道、分频器这些参数,用一个结构体就可以统一进行配置了
第五步,选择从模式的触发源。触发源选择为TI1FP1,这里调用一个库函数,给一个参数就行了
第六步,选择触发之后执行的操作。执行Reset操作,这里也是调用一个库函数就行了
最后,当这些电路都配置好之后,调用TIM_Cmd函数,开启定时器,这样所有的电路就能配合起来,按照我们的要求工作了。直接读取CCR寄存器,然后按照fc/N,(N是读取CCR的值)计算一下就行了。这就是整个程序的思路
fc=72M/(PSC+1)
6-7 PWMI模式测频率占空比
在6-6 输入捕获模式测频率做修改
需要将输入捕获初始化的部分,需要进行一下升级,配置成两个通道同时捕获同一个引脚的模式,怎么配置呢?
两种方法:
第一种,把这个通道初始化的部分,复制一份,这个结构体定义的不要复制了。然后呢,通道1是直连输入,上升沿触发,沿用这个配置。接着下面,通道1改成通道2,直连输入,改成这个交叉输入,上升沿触发,改成下降沿触发,这样看一下,是不是就对应我们PPT的这个结构了。通道1,直连输入,上升沿触发;通道2,交叉输入,下降沿触发,这样就可以了。
第二种:库里有专门的封装函数。只针对于通道1和通道2
写一个获取占空比的函数,根据上一小节的分析,高电平的计数值存在CCR2里,整个周期的计数值存在CCR1里,我们用CCR2/CCR1,就能得到占空比了
CCR总少1,应该是CCR从0开始计数的
测频率的性能讲解视频片段
最后,我们来研究一下这个测频率的性能。
首先是测频率的范围,目前我们给的标准频率是1MHz,计数器最大只能计到65535。所以所测量的最低频率是1M/65535,这个值算一下大概是15Hz。如果信号频率再低,计数器就要溢出了,所以最低频率就是15Hz左右。那如果想要再降低一些最低频率的限制,我们可以把这个预分频再加大点,这样标准频率就更低,所支持测量的最低频率也就更低。这是测量频率的下限。
测得的频率等于fc/N,这里的N值就是CNT里面过去的,当N越大,频率越小,但是CNT最大不能超过ARR的值(最大为65535)所以测量的最小频率大概是15Hz
然后是测量的上限,就是支持的最大频率。这个最大频率,并没有一个明显的界限,因为随着待测频率的增大,误差也会逐渐增大,如果非要找个频率上限,那应该就是标准频率1MHZ,超过1MHz,信号频率比标准频率还高,那肯定测不了了。但是这个1MHz的上限并没有意义,因为信号频率接近1MHz时,误差已经非常大了,所以最大频率要看你对误差的要求。上一小节我们说到了正负1误差,计100个数,误差1个,相对误差就是百分之一;计1000个数,误差1个,相对误差就是千分之一,所以正负1误差可以认为是1/计数值。在这里,如果要求误差等于千分之一时,频率为上限那这个上限就是1M/1000=1KHz;如果要求误差可以到百分之一,那频率上限就是1M/100=10KHz,这就是频率的上限.如果想提高频率的上限,那我们在这里(时基单元初始化时),就要把PSC给降低一点.,提高标准频率,上限就会提高。除此之外,如果频率还要更高,那我们就要考虑一下测频法了。测频法适合高频,测周法适合低频,我们这里是测周法,所以对于非常高的频率,还是交给测频法来解决吧。
然后呢,还有一个就是误差分析。除了我们之前说的正负1误差外,在实际测量的时候,还会有晶振误差。比如我们STM32的晶振不是那么准,在计次几百几万次之后,误差累积起来,也会造成一些影响
5.TIM编码器接口
那使用正交信号相比较单独定义一个方向引脚,有什么好处呢?
首先就是正交信号精度更高,因为A、B相都可以计次,相当于计次频率提高了一倍;其次就是正交信号可以抗噪声,因为正交信号,两个信号必须是交替跳变的,所以可以设计一个抗噪声电路。如果一个信号不变,另一个信号连续跳变,也就是产生了噪声,那这时计次值是不会变化的。
所以我们编码器接口的设计逻辑就是,首先把A相和B相的所有边沿作为计数器的计数时钟,出现边沿信号时,就计数自增或自减,然后到底是增还是减呢,这个计数的方向由另一相的状态来确定。当出现某个边沿时,我们判断另一相的高低电平,如果对应另一相的状态出现在上面这个表里,那就是正转,计数自增;反之,另一相的状态出现在下面这个表里那就是反转,计数自减,这样就能实现编码器接口的功能了,这也是我们STM32定时器编码器接口的执行逻辑。
接下来,我们就来看一下这个定时器的框图,看一下这个编码器接口的电路是如何设计的。
注意使用编码器模式的时候,我们之前一直在使用的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、65533等等这里负数不应该是-1、-2吗,65535是不是就出问题了。但是没关系,直接把这个16位的无符号数转换为16位的有符号数。根据补码的定义,这个65535就对应-1,65534就对应-2(有符号编码时负数按补码计算,2^16 的补码= -1)等等,这样就可以直接得到负数,非常方便,这就是我们读出数据得到负数的一个小技巧。
最后我们来看一些工作细节,和两个小例子。
这个工作描述的表,描述的就是我们刚才说什么时候正转、反转的,编码器接口的工作逻辑
这个实例展示的是极性的变化对计数的影响。
TI1反相是什么意思呢?
此时看下这个图,这里TI1和TI2进来,都会经过这个极性选择的部分。
在输入捕获模式下,这个极性选择是选择上升没有效还是下降沿有效的。但是根据我们刚才的分析,编码器接口,显然始终都是上升沿和下降沿都有效的,上升沿和下降沿都需要计次,所以在编码器接口模式下,这里就不再是边沿的极性选择了而是高低电平的极性选择。如果我们选择上升沿的参数,就是信号直通过来,高低电平极性不反转;如果选择下降沿的参数,就是信号通过一个非门过来,高低电平极性反转,所以这里就会有两个控制极性的参数,选择要不要在这里加一个非门,反转一下极性。
显然,这两个实例图的计数方向是相反的,这有什么作用呢?
比如你接一个编码器,发现它数据的加减方向反了,你想要正转的方向,结果它自减了,你想要反转的方向,结果它自增了,这时,就可以调整一下极性,把任意一个引脚反相,就能反转计数方向了。当然如果想改变计数方向的话,我们还可以直接把A、B相两个引脚换一下。
我们本节的内容(4.编码器接口),对应手册这里的14.3.12 编码器接口模式
代码实战
这里编码器测速一般应用在电机控制的项目上,使用PWM驱动电机,再使用编码器测量电机的速度,然后再用PID算法进行闭环控制。
现象:接了一个旋转编码器模块,这个代码和之前我们写的旋转编码器计次的代码,实现的功能基本都是一样的。目前我们这个代码,本质上也是旋转编码器计次,只不过这个代码是通过定时器的编码器接口,来自动计次。而我们之前的代码是通过触发外部中断,然后在中断函数里手动进行计次,使用编码器接口的好处就是节约软件资源,
如果使用外部中断来计次,那当电机高速旋转时,编码器每秒产生成千上万个脉冲,程序就得频繁进中断,然后进中断之后,完成的任务又只是简单的加—减一,是不是我们的软件资源就被这种简单而又低级的工作给占用了。所以,对于这种需要频繁执行,操作又比较简单的任务,一般我们都会设计一个硬件电路模块,来自动完成。那我们本节这个编码器接口,就是用来自动给编码器进行计次的电路。如果我们每隔一段时间取一下计次值,就能得到编码器旋转的速度了。
第一步,RCC开启时钟,开启GPIO和定时器的时钟
第二步,配置GPIO,这里需要把PA6和PA7配置成输入模式
第三步,配置时基单元,这里预分频器我们一般选择不分频
第四步,配置输入捕获单元。不过这里输入捕获单元只有滤波器和极性这两个参数有用,后面的参数没有用到,与编码器无关
第五步,配置编码器接口模式。这个直接调用一个库函数就可以了
最后,调用TIM_Cmd,启动定时器,就完事了
7、ADC数模转换器
那对于GPIO来说,它只能读取引脚的高低电平,要么是高电平,要么是低电平,只有两个值,而使用了ADC之后,我们就可以对这个高电平和低电平之间的任意电压进行量化,最终用一个变量来表示,读取这个变量,就可以知道引脚的具体电压到底是多少了。所以ADC其实就是一个电压表,把引脚的电压值测出来,放在一个变量里,这就是ADC的作用。
逐次逼近型这是这个ADC的工作模式。然后12位和1us的转换时间,这里就涉及到ADC的两个关键参数了,第一个是分辨率,一般用多少位来表示,12位AD值,它的表示范围就是0-2^12-1,就是量化结果的范围是0~4095。位数越高,量化结果就越精细,对应分辨率就越高;第二个是转换时间,就是转换频率,AD转换是需要花一小段时间的,这里1us就表示从AD转换开始到产生结果,需要花1us的时间,对应AD转换的频率就是1MHz,这个就是STM32 ADC的最快转换频率。如果你需要转换一个频率非常高的信号,那就要考虑一下这个转换频率是不是够用,如果你的信号频率比较低,那这个最大1MHz的转换频率也完全够用了。
外部信号源就是16个GPIO口,在引脚上直接接模拟信号就行了,不需要任何额外的电路,引脚就直接能测电压。2个内部信号源是内部温度传感器和内部参考电压。温度传感器可以测量CPU的温度,比如你电脑可以显示一个CPU温度,就可以用ADC读取这个温度传感器来测量;内部参考电压是一个1.2V左右的基淮电压,这个基准电压是不随外部供电电压变化而变化的,所以如果你芯片的供电不是标准的3.3V,那测量外部引脚的电压可能就不对,这时就可以读取这个基准电压进行校准,这样就能得到正确的电压值了。
规则组和注入组两个转换单元,这个就是STM32 ADC的增强功能了。普通的AD转换流程是,启动一次转换、读一次值,然后再启动、再读值,这样的流程。但是STM32的ADC就比较高级,可以列一个组,一次性启动一个组,连续转换多个值。并且有两个组,一个是用于常规使用的规则组,一个是用于突发事件的注入组。
模拟看门狗自动监测输入电压范围,这个ADC,一般可以用于测量光线强度、温度这些值,并且经常会有个需求,就是如果光线高于某个阈值、低于某个阈值或者温度高于某个阈值、低于某个阈值时,执行一些操作。这个高于某个阈值、低于某个阈值的判断,就可以用模拟看门狗来自动执行。模拟看门狗可以监测指定的某些通道,当AD值高于它设定的上阈值或者低于下阈值时,它就会申请中断,你就可以在中断函数里执行相应的操作,这样你就不用不断地手动读值,再用if进行判断了。
ADC可以将模拟信号转换为数字信号,是模拟电路到数字电路的桥梁。那反过来,有模拟到数字的桥梁,那肯定就有数字到模拟的桥梁。这就是DAC,数字模拟转换器,使用DAC就可以将数字变量转化为模拟电压。
不过在上一节,我们还学到了一个数字到模拟的桥梁,PWM。上一节我们使用PWM来控制LED的亮度、电机的速度,这就是DAC的功能,同时PWM只有完全导通和完全断开两种状态,在这两种状态上都没有功率损耗。所以在直流电机调速这种大功率的应用场景,使用PWM来等效模拟量,是比DAC更好的选择,并且PWM电路更加简单,更加常用。所以可以看出PWM还是挤占了DAC的很多应用空间。
目前DAC的应用主要是在波形生成这些领域,比如信号发生器、音频解码芯片等,这些领域PWM还是不好替代的。
接下来我们来了解一下这个逐次逼近型ADC到底是怎么测电压的,我们看一下这个图,这就是逐次逼近型ADC的内部结构。了解这个结构对你学习STM32的ADC有很大帮助,因为STM32的ADC原理和这个是一样的,但是STM32只画了一个框表示ADC,并没有描述内部结构,所以我们先介绍一下这个结构,这样再理解STM32的ADC就会简单一些了。
我们来看一下,这个图是ADCO809的内部结构图,它是一个独立的8位逐次逼近型ADC芯片。在以前单片性能不太好的时候,是通过外挂一个ADC芯片才能进行AD转换,这个ADCO809就是一款比较经典的ADC芯片。随着单片机的性能和集成度都有很大的提升,很多单片机内部就已经集成了ADC外设。
输入选择部分:
首先左边这里INO~IN7,是8路输入通道,通过通道选择开关,选中一路,输入到所标点进行转换。
下面这里是地址锁存和译码,就是你想选中哪个通道,就把通道号放在这三个脚(ADD…)上,然后给一个锁存信号(ALU),上面这里对应的通路开关就可以自动拨好了。这部分就相当于一个可以通过模拟信号的数据选择器。
因为ADC转换是一个很快的过程,你给个开始信号,过几个us就转换完成了。所以说如果你想转换多路信号,那不必设计多个AD转换器,只需要一个AD转换器,然后加一个多路选择开关,想转换哪一路,就先拨一下开关,选中对应通道,然后再开始转换就行了。这就是这个输入通道选择的部分,这个ADC0809只有8个输入通道,我们STM32内部的ADC是有18个输入通道的,所以对应输入电路,就是一个18路输入的多路开关
核心结构:
那然后输入信号选好了,到这里(所标红点)来,怎么才能知道这个电压对应的编码数据是多少呢?这就需要我们用逐次逼近的方法来——比较了
首先这是一个电压比较器,它可以判断两个输入信号电压的大小关系,输出一个高低电平指示谁大谁小。它的两个输入端,一个是待测的电压,另一个是这里DAC的电压输出端,DAC是数模转换器。我们之前说过了,给它一个数据,它就可以输出数据对应的电压,DAC内部是使用加权电阻网络来实现的转换,具体可以江科大51单片机教程里的AD/DA那一节。
那现在,我们有了一个外部通道输入的未知编码的电压,和一个DAC输出的已知编码的电压。它俩同时输入到电压比较器,进行大小判断,如果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次,**这就是逐次逼近的过程。
那然后,AD转换结束后,DAC的输入数据,就是未知电压的编码,通过右边电路进行输出,8位就有8根线,12位就有12根线。
好,到这里,相信你对逐次逼近型ADC就已经了解差不多了,接下来,我们就来看看STM32的逐次逼近型ADC,看看STM32的ADC和这个相比,有什么更高级的变化,那我们看一下STM32的这个ADC框图。
STM(32逐次逼近型)ADC电路图详解
总图:
核心的大概工作流程:
注入规则组和规则通道组:
比喻解释注入组和规则组:
这有什么作用呢?举个例子,这就像是你去餐厅点菜,普通的ADC是,你指定一个菜,老板给你做,然后做好了送给你;这里就是,你指定一个菜单,这个菜单最多可以填16个菜,然后你直接递个菜单给老板,老板就按照菜单的顺序依次做好,一次性给你端上菜,这样的话就可以大大提高效率。当然,你的菜单也可以只写一个菜,这样这个菜单就简化成了普通的模式了。
那对于这个菜单呢,也有两种,一种是规则组菜单,可以同时上16个菜,但是它有个尴尬的地方。就是这个规则组只有一个数据寄存器,就是这个桌子比较小,最多只能放一个菜,你如果上16个菜,那不好意思,前15个菜都会被挤掉些,你只能得到第16个菜。所以对于规则组转换来说,如果使用这个菜单的话,最好配合DMA来实现。DMA是一个数据转运小帮手,它可以在每上一个菜之后,把这个菜挪到其他地方去,防止被覆盖。这个DMA我们下一节就会讲,现在先大概了解一下,那现在我们就知道了,这个规则组虽然可以同时转换16个通道,但是数据寄存器只能存一个结果,如果不想之前的结果被覆盖,那在转换完成之后,就要尽快把结果拿走。
接着我们看一下注入组,这个组就比较高级了,它相当于是餐厅的VIP座位,在这个座位上,一次性最多可以点4个菜,并且这里数据寄存器有4个,是可以同时上4个菜的。对于注入组而言,就不用担心数据覆盖的问题了,这就是规则组和注入组的介绍。
一般情况下,我们使用规则组就完全足够了,如果要使用规则组的菜单,那就再配合DMA转运数据,这样就不用担心数据覆盖的问题了。所以接下来就只讲规则组的操作,注入组涉及的不多,大家可以看手册自行了解。
那我们接着继续看这个模数转换器外围的一些线路
首先,左下角这里是触发转换的部分,也就是这里的START信号,开始转换。那对于STM32的ADC,触发ADC开始转换的信号有两种,一种是软件触发,就是你在程序中手动调用一条代码,就可以启动转换了;另一种是硬件触发,就是这里的这些触发源。上面这些是注入组的触发源,下面这些是规则组的触发源,这些触发源主要是来自于定时器,有定时器的各个通道,还有TRGO定时器主模式的输出,这个之前讲定时器的时候也介绍过。定时器可以通向ADC、 DAC这些外设,用于触发转换。那因为ADC经常需要过一个固定时间段转换一次。比如每隔1ms转换一次,正常的思路就是,用定时器,每隔1ms申请一次中断,在中断里手动开始一次转换,这样也是可以的。但是频繁进中断对我们的程序是有一定影响的,比如你有很多中断都需要频繁进入,那肯定会影响主程序的执行,并且不同中断之间,由于优先级的不同,也会导致某些中断不能及时得到响应。如果触发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是内部模拟部分的电源,比如ADC、RC振荡器、锁相环等。在这里VDDA接3.3V, VSSA接GND,所以ADC的输入电压范围就是0~3.3V。
然后继续看 右边这里是ADCCLK是ADC的时钟,也就是这里的CLOCK,是用于驱动内部逐次比较的时钟。这个ADCCLK是来自ADC预分频器,而ADC预分频器是来源于RCC的。
APB2时钟72MHZ,然后通过ADC预分频器进行分频,得到ADCCLK,ADCCLK最大是14MHZ,所以这个预分频器就有点尴尬。它可以选择2、4、6、8分频,如果选择2分频,72M/2=36M,超出允许范围了;4分频之后是18M,也超了,所以对于ADC预分频器只能选择6分频,结果是12M和8分频,结果是9M,这两个值。这个在程序里要注意一下
继续看上面这里是DMA请求,这个就是用于触发DMA进行数据转运的,我们下节再讲。
好,有关ADC的这个框图,我们就介绍完了。
ADC基本结构
那接下来就来看一下我这里总结的一个ADC基本结构图,再来回忆一下。
左边是输入通道,16个GPIO口,外加两个内部的通道,然后进入AD转换器。AD转换器里有两个组,一个是规则组,一个是注入组,规则组最多可以选中16个通道,注入组最多可以选择4个通道。然后转换的结果可以存放在AD数据寄存器里,其中规则组只有1个数据寄存器,注入组有4个。
然后下面这里有触发控制,提供了开始转换这个START信号,触发控制可以选择软件触发和硬件触发。硬件触发主要是来自于定时器,当然也可以选择外部中断的引脚,右边这里是来自于RCC的ADC时钟CLOCK,ADC逐次比较的过程就是由这个时钟推动的。
然后上面,可以布置一个模拟看门狗用于监测转换结果的范围,如果超出设定的阈值,就通过中断输出控制,向NVIC申请中断,另外,规则组和注入组转换完成后会有个EOC信号,它会置一个标志位,当然也可以通向NVIC。最后右下角这里还有个开关控制,在库函数中,就是ADC_Cmd函数,用于给ADC上电的,那这些,就是STM32 ADC的内部结构了。
接下来我们再了解一些细节的问题,这些就是ADC通道和引脚复用的关系,这个对应关系也可以通过引脚定义表看出来。另外由于我们这个芯片没有PC0~PC5,所以这些通道也就没有了。
ADC1和ADC2的引脚全都是相同的,既然都相同,那要ADC2还有啥用呢。这个就要再说一个ADC的高级功能了,就是双ADC模式,,这个模式比较复杂。这里只简单介绍一下,不需要掌握。双ADC模式就是ADC1和ADC2一起工作,它俩可以配合组成同步模式、交叉模式等等模式。比如交叉模式,ADC1和ADC2交叉地对一个通道进行采样,这样就可以进一步提高采样率。
规则组的4种转换模式
接下来,我们再来了解一下规则组的4种转换模式,分别是单次转换,非扫描模式和连续转换,扫描模式。那在我们ADC初始化的结构体里,会有两个参数,一个是选择单次转换还是连续转换的,另一个是选择扫描模式还是非扫描模式的,这两个参数组合起来,就有这4种转换方式。我们来逐一看一下。
第一种,单次转换,非扫描模式,这里我画了一个列表,这个表就是规则组里的菜单,有16个空位,分别是序列1到序列16,你可以在这里“点菜”,就是写入你要转换的通道,在非扫描的模式下,这个菜单就只有第一个序列1的位置有效,这时,菜单同时选中一组的方式就退化为简单地选中一个的方式了。在这里我们可以在序列1的位置指定我们想转换的通道,比如通道2,写到这个位置。然后,我们就可以触发转换,ADC就会对这个通道2进行模数转换,过一小段时间后,转换完成,转换结果放在数据寄存器里,同时给EOC标志位置1,整个转换过程就结束了。我们判断这个EOC标志位,如果转换完了, 那我们就可以在数据寄存器里读取结果了。如果我们想再启动一次转换,那就需要再触发一次,转换结束,置EOC标志位,读结果。如果想换一个通道转换,那在转换之前,把第一个位置的通道2改成其他通道,然后再启动转换,这样就行了。这就是单次转换,非扫描的转换模式。没有用到这个菜单列表,也是比较简单的一种模式
接下来我们看一下连续转换,非扫描模式。首先,它还是非扫描模式,所以菜单列表就只用第一个,然后它与上一种单次转换不同的是,它在一次转换结束后不会停止,而是立刻开始下一轮的转换,然后一直持续下去。这样就只需要最开始触发一次,之后就可以一直转换了。这个模式的好处就是,开始转换之后不需要等待一段时间的,因为它直都在转换,所以你就不需要手动开始转换了,也不用判断是否结束的,想要读AD值的时候,直接从数据寄存器取就是了。这就是连续转换,非扫描的模式
然后继续看,单次转换,扫描模式。这个模式也是单次转换,所以每触发一次,转换结束后,就会停下来,下次转换就得再触发才能开始。然后它是扫描模式,这就会用到这个菜单列表了,你可以在这个菜单里点菜,比如第一个菜是通道2,第二个菜是通道5,等等等等,这里每个位置是通道几可以任意指定,并且也是可以重复的,然后初始化结构体里还会有个参数,就是通道数目。因为这16个位置你可以不用完,只用前几个,那你就需要再给一个通道数目的参数,告诉它,我有几个通道。比如这里指定通道数目为7,那它就只看前7个位置,然后每次触发之后,它就依次对这前7个位置进行AD转换,转换结果都放在数据寄存器里,这里为了防止数据被覆盖,就需要用DMA及时将数据挪走。那7个通道转换完成之后,产生EOC信号,转换结束,然后再触发下一次,就又开始新一轮的转换,这就是单次转换,扫描模式的工作流程。
那最后再看一下连续转换,扫描模式。它就是在上一个模式的基础上,变了一点,就是一次转换完成后,立刻开始下一次的转换。和上面这里非扫描模式的单次和连续是一个套路,这就是连续转换,扫描模式。
当然在扫描模式的情况下,还可以有一种模式,叫间断模式。它的作用是,在扫描的过程中,每隔几个转换,就暂停一次,需要再次触发,才能继续。这个模式没有列出来,要不然模式太多了。大家了解一下就可以了,暂时不需要掌握,好,这些就是STM32 ADC的4种转换模式。
几个小知识点|细节:
触发控制
这个表就是规则组的触发源,也就是ADC总框图中的ADC。在这个表里,有来自定时器的信号;还有这个来自引脚或定时器的信号,这个具体是引脚还是定时器,需要用AFIO重映射来确定;最后是软件控制位,也就是我们之前说的软件触发。这些触发信号怎么选择,可以通过设置右边这个寄存器来完成,当然使用库函数的话,直接给一个参数就行了,这就是触发控制。
数据对齐
转换时间
这个大概讲一下,不过转换时间这个参数,我们一般不太敏感,因为一般AD转换都很快,如果不需要非常高速的转换频率,那转换时间就可以忽略了。
我们来看一下,之前我们说了,AD转换是需要一小段时间的,就像厨子做菜一样,也是需要等一会儿才能上菜的,那AD转换的时候都有哪些步骤需要花时间呢?AD转换的步骤,有4步,分别是采样,保持,量化,编码,其中采样保持可以放在一起,量化编码可以放在一起,总共是这两大步。量化编码好理解,就是我们之前讲过的,ADC逐次比较的过程,这个是要花一段时间的,一般位数越多,花的时间就越长。
那采样保持是干啥的呢?这个我们前面这里并没有涉及,为什么需要采样保持呢?这是因为,我们的AD转换,就是后面的量化编码,是需要一小段时间的,如果在这一小段时间里,输入的电压还在不断变化,那就没法定位输入电压到底在哪了,所以在量化编码之前,我们需要设置一个采样开关。先打开采样开关,收集一下外部的电压,比如可以用一个小容量的电容存储一下这个电压,存储好了之后,断开采样开关,再进行后面的AD转换。这样在量化编码的期间,电压始终保持不变,这样才能精确地定位未知电压的位置,这就是采样保持电路。
那采样保持的过程,需要闭合采样开关,过一段时间再断开,这里就会产生一个采样时间。那回到这里,我们就得到了第二条,STM32 ADC的总转换时间为TCONV=采样时间+12.5个ADC周期,采样时间是采样保持花费的时间,这个可以在程序中进行配置,采样时间越大,越能避兔一些毛刺信号的干扰,不过转换时间也会相应延长。12.5个ADC周期是量化编码花费的时间,因为是12位的ADC,所以需要花费12个周期,这里多了半个周期,可能是做其他一些东西花的时间。ADC周期就是从RCC分频过来的ADCCLK,这个ADCCLK最大是14MHz。
所以下面有个例子,这里就是最快的转换时间,当ADCCLK=14MHz,采样时间为1.5个ADC周期,TCONV = 1.5 +12.5 = 14个ADC周期,在14MHz ADCCLK的情况下就 = 1us,这就是转化时间最快1us时间的来源。如果你采样周期再长些,它就达不到1us了;另外你也可以把ADCCLK的时钟设置超过14MHz,这样的话ADC就是在超频了,那转换时间可以比1us还短,不过这样稳定性就没法保证了。
校准
这个看上去挺复杂,但是我们不需要理解,这个校准过程是固定的。我们只需要在ADC初始化的最后,加几条代码就行了,至于怎么计算、怎么校准的,我们不需要管。
ADC外围电路设计
对于ADC的外围电路,我们应该怎么设计呢?
如果你想采集5V,10V这些电压的话,可以使用这个电压转换电路,但是如果你电压再高一些,就不建议使用这个电路了,那可能会比较危险高电压采集最好使用一些专用的采集芯片,比如隔离放大器等等,做好高低电压的隔离,保证电路的安全。
手册粗讲
代码实战:AD单通道+AD多通道
7-1 AD单通道
程序现象:在面包板的中间,也就是芯片左边接了一个电位器,就是滑动变阻器。用这个电位器产生一个0~3.3V连续变化的模拟电压信号。然后接到STM32的PA0口上,之后用STM32内部的ADC读取电压数据,显示在屏幕上。这里屏幕第一行显示的是AD转换后的原始数据,第二行是经过处理后实际的电压值。电位器往左拧,AD值减小,电压值也减小,AD值最小是0,对应的电压就是0V;反之同理STM32的ADC是12位的,所以AD结果最大值是4095,也就是2^12-1,对应的电压是3.3V。
第一步,开启RCC时钟,包括ADC和GPIO的时钟,另外这里ADCCLK的分频器,也需要配置一下
第二步,配置GPIO。把需要用的GPIO配置成模拟输入的模式
第三步,配置这里的多路开关。把左边的通道接入到右边的规则组列表里。这个过程就是我们之前说的点菜,把各个通道的菜,列在菜单里
第四步,就是配置ADC转换器了。在库函数里,是用结构体来配置的,可以配置这一大块电路的参数。包括ADC是单次转换还是连续转换、扫描还是非扫描、有几个通道,触发源是什么,数据对齐是左对齐还是右对齐。
如果你需要模拟看门狗,那会有几个函数用来配置阈值和监测通道的
如果你想开启中断,那就在中断输出控制里用ITConfig函数开启对应的中断输出,然后再在NVIC里,配置一下优先级,这样就能触发中断了。
不过这一块,模拟看门狗和中断,我们本节暂时不用,如果你需要的话,可以自己配置试一下
接下来,就是开关控制,调用一下ADC_Cmd函数,开启ADC,这样ADC就配置完成了,就能正常工作了。
当然,在开启ADC之后,根据手册里的建议,我们还可以对ADC进行一下校准,这样可以减小误差,那在ADC工作的时候,
这里有四个函数,对应校准的四个步骤:第一步,调用第一个函数ADC_ResetCalibration,复位校准;第二步,调用第二个函数ADC_GetResetCalibrationStatus,等待复位校准完成;第三步,调用第三个函数ADC_StartCalibration,开始校准;第四步,调用第四个函数ADC_GetCalibrationStatus,等待校准完成。
如果想要软件触发转换,那会有函数可以触发。如果想读取转换结果,那也会有函数可以读取结果,这个等会儿介绍库函数的时候就可以看到了。好,这些就是我们程序的大概思路了。
首先,软件触发转换;然后等待转换完成,也就是等待EOC标志位置1;最后,读取ADC数据寄存器,就完事了。
7-1 AD单通道
程序现象:在这里分别接了光敏电阻、热敏电阻和反射红外模块三个传感器模块。把它们的AO、模拟电压输出端,分别接在了A1、A2、A3引脚,加上刚才的电位器,总共4个输出通道。然后测出来的4个AD数据分别显示在屏幕上
现象:这里AD值的末尾会有些抖动,这是正常的波动,如果你想对这个值进行判断,再执行一些操作。比如光线的AD值小于某一阈值,就开灯,大于某一阈值,就关灯,那可能会存在这样的情况:比如光线逐渐变暗,AD值逐渐变小,但是由于波动,AD值会在判断阈值附近来回跳变,这会导致输出产生抖动,反复开关灯。
那如何避兔这种情况呢?有很多种方法,比如可以使用迟滞比较的方法来完成,设置两个阈值,低于下阈值时,开灯,这就可以避免输出抖动的问题了。另外,如果你觉得数据跳变太厉害,还可以采取滤波的方法,让AD值平滑一些,比如均值滤波,就是读10个或20个值,取平均值,作为滤波的AD值;或者还可以裁剪分辨率,把数据的尾数去掉。
7-2 AD多通道
如何实现多通道呢?
我们首先想到的应该是后面这两种扫描模式(连续转换、扫描模式和单次转换、扫描模式),但如果想要用扫描模式实现多通道,最好要配合DMA来实现,来解决数据覆盖的问题。
那你可能会问,我们一个通道转换完成之后,你启动列表之后,它里面每一个单独的通道转换完成之后,不会产生任何的标志位,也不会触发中断,你不知道某一个通道是不是转换完了。它只有在整个列表都转换完成之后,才会产生一次EOC标志位,才能触发中断,而这时,前面的数据就已经覆盖丢失了。其次,AD转化时很快的,如果你不能在几us的时间内把数据转运走,那数据就会丢失,这对我们程序手动转运数据,要求就比较高了.
所以在扫描模式下,手动转运数据是比较困难的,不过比较困难也不是说手动转运不可行,我们可以使用间断模式,在扫描的时候,每转换一个通道就暂停一次,等我们手动把数据转运走之后,10再继续触发,继续下一次转换。但是由于单个通道转换完成之后,没有标志位。所以启动转换之后,只能通过Delay延时的方式,延迟足够长的时间,才能保证转换完成。这种方式既不能让我们省心,也不能提高效率,所以我暂时不推荐使用。
我们可以使用上面的这个单次转换、非扫描的模式,来实现多通道。只需要在每次触发转换之前,手动更改一下列表第一个位置的通道就行了
8、DMA直接存储器存取
所以存储器到存储器的数据转运,我们一般使用软件触发,外设到存储器的数据转运,我们一般使用硬件触发。
我们来看一下STM32的存储器映像,既然DMA是在存储器之间进行数据转运的,那我们就应该要了解一下,STM32中都有哪些存储器,这些存储器又是被安排到了哪些地址上,这就是存储器映像的内容。
在这个表里,无论是Flash,还是SRAM,还是外设寄存器,它们都是存储器的一种,包括外设寄存器,实际上也是存储器。在DMA简介中,我们说的是外设到存储器,存储器到存储器,本质上其实都是存储器之间的数据转运,说成外设到存储器,只不过是STM32他特别指定了可以转运外设的存储器而已。
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总线上的被动单元。CPU通过这一条线路,就可以对DMA进行配置了。
接着继续看这里,是DMA请求,请求就是触发的意思,这条线路右边的触发源,是各个外设,所以这个DMA请求就是DMA的硬件触发源。比如ADC转换完成、串口接收到数据,需要触发DMA转运数据的时候,就会通过这条线路,向DMA发出硬件触发信号,之后DMA就可以执行数据转运的工作了。这就是DMA请求的作用
到这里,有关DMA的结构就讲的差不多了,其中包括:用于访问各个存储器的DMA总线;内部的多个通道,可以进行独立的数据转运;仲裁器,用于调度各个通道,防止产生冲突;AHB从设备,用于配置DMA参数;DMA请求,用于硬件触发DMA的数据转运,这就是这个DMA的各个部分和作用。
注意一下:就是这里的Flash,它是ROM只读存储器的一种,如果通过总线直接访问的话,无论是CPU,还是DMA,都是只读的,只能读取数据,而不能写入,如果你DMA的目的地址,填了Flash的区域,那转运时,就会出错。当然Flash也不是绝对的不可写入,我们可以配置这个Flash接口控制器,对Flash进行写入,这个流程就比较麻烦了,要先对Flash按页进行擦除,再写入数据。总之就是CPU或者DMA直接访问Flash的话,是只可以读而不可以写的,然后SRAM是运行内存,可以任意读写,没有问题,外设寄存器的话,得看参考手册里面的描述。
DMA基本结构
刚才这个框图只是一个笼统的结构图,对于DMA内部的执行细节,它还是没体现出来,所以我们再来分析一下这个图,看看DMA具体是怎么工作的。
这就是外设站点和存储器站点各自的3个参数了。
在STM32手册里,所说的存储器,一般是特指Flash和SRAM,不包含外设寄存器。外设寄存器,他一般直接称作外设,所以就是外设到存储器,存储器到存储器,这样来描述。虽然我们刚才说了,寄存器也是存储器的一种,但是STM32还是使用了外设和存储器来作为区分,这个注意一下描述方法的不同。那在这里可以看到,
这就是外设站点和存储器站点各自的3个参数了。
传输计数器和自动重装器:
触发控制部分:
然后最后,就是开关控制了,也就是DMA_Cmd函数.当给DMA使能后,DMA就准备就绪,可以进行转运了。
基于DMA基本结构的一些问题
问题1:那如何进行存储器到存储器的数据转运,方向反过来可以吗?
如果要进行存储器到存储器的数据转运。那我们就需要把其中一个存储器的地址,放在外设的这个站点,这样就能进行存储器到存储器的转运了。只要你在外设起始地址里写Flash或者SRAM的地址,那它就会去Flash或SRAM找数据。这个站点虽然叫外设寄存器,但是它就只是个名字而已。甚至你可以在外设站点写存储器的地址,存储器站点写外设的地址,然后方向参数给反过来,这样也是可以的,只是ST公司给它起了这样的名字而已。你也可以把它叫做站点A、站点B,从A到B或者从B到A转运数据。
问题2:在DMA中软件触发的执行逻辑?和外部中断、ADC的软件触发有什么区别?
这个软件触发并不是调用某个函数一次,触发一次,它这个软件触发的执行逻辑是,以最快的速度,连续不断地触发DMA,争取早日把传输计数器清零,完成这一轮的转换。所以这里的软件触发,和我们之前外部中断和ADC的软件触发可能不太一样,你可以把它理解成连续触发,那这个软件触发和(自动重装器)循环模式,不能同时用。因为软件触发就是想把传输计数器清零,循环模式是清零后自动重装,如果同时用的话,那DMA就停不下来了,这就是软件触发。
问题3:DMA的转运条件?
DMA进行转运,有几个条件,第一,就是开关控制,DMA_Cmd必须使能;第二,就是传输计数器必须大于0;第三,就是触发源,必须有触发信号。触发一次,转运一次,传输计数器自减一次,当传输计数器等于0,且没有自动重装时。这时无论是否触发,DMA都不会再进行转运了,此时就需要DMA_Cmd,给DISABLE,关闭DMA,再为传输计数器写入一个大于0的数,再DMA_Cmd,给ENABLE,开启DMA,DMA才能继续工作。
注意一下,写传输计数器时,必须要先关闭DMA,再进行,不能在DMA开启时,写传输计数器,这是手册里的规定。
几个小知识点|细节:
DMA请求
数据宽度与对齐
细节讲解
DMA数据转运的两个站点,都有一个数据宽度的参数,如果数据宽度都一样,那就是正常的一个个转运,如果数据宽度不一样,那会怎么处理呢?
这个表就是来说明问题的,
总之一这个表的意思就是如果你把小的数据转到大的里面去,高位就会补0;如果把大的数据转到小的里面去高位,就会舍弃掉;如果数据宽度一样,那就没事。
那最后,我们再来看两个例子,看看在这些实际的任务下,DMA是如何工作的。这两个例子和程序例子对应的。
数据转运+DMA
这个例子的任务是将SRAM里的数组DataA,转运到另一个数组DataB中,我们看一下这种情况下,这个基本结构里的各个参数该如何配置。
首先是外设站点和存储器站点的起始地址、数据宽度、地址是否自增这三个参数。那在这个任务里,外设地址显然应该填DataA数组的首地址,存储器地址,给DataB数组的首地址,然后数据宽度,两个数组的类型都是uint8_t,所以数据宽度都是按8位的字节传输。之后地址是否自增,在中间可以看到,我们想要的效果是DataA[0]转到DataB[0],DataA[1]转到DataB[1],等等。所以转运完DataA[0]和DataB[0]之后,两个站点的地址都应该自增,都移动到下一个数据的位置,继续转运DataA[1]和DataB[1],这样来进行。
之后,这里的方向参数,那显然就是外设站点转运到存储器站点了,当然如果你想把DataB的数据转运到DataA,那可以把方向参数换过来,这样就是方向转运了。
然后是传输计数器和是否要自动重装,在这里,显然要转运7次,所以传输计数器给7,自动重装暂时不需要,之后触发选择部分,这里,我们要使用软件触发。因为这是存储器到存储器的数据转运,是不需要等待硬件时机的,尽快转运完成就行了。
那最后,调用DMA_Cmd,给DMA使能,这样数据就会从DataA转运到DataB了。转运7次之后,传输计数器自减到0,DMA停止,转运完成。这里的数据转运是一种复制转运,转运完成后DataA的数据并不会消失,这个过程相当于是把DataA的数据复制到了DataB的位置。
ADC扫描模式+DMA
讲解细节,精彩!
左边是ADC扫描模式的执行流程,在这里有7个通道,触发一次后,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扫描模式,在每个单独的通道转换完成后,没有任何标志位,也不会触发中断。所以我们程序不太好判断,某一个通道转换完成的时机是什么时候。但是根据UP主的研究,虽然单个通道转换完成后,不产生任何标志位和中断,但是它应该会产生DMA请求,去触发DMA转运,这部分内容,手册里并没有详细描述,根据我实际实验,单个通道的DMA请求肯定是有的。
这些就是ADC扫描模式和DMA配合使用的流程。一般来说,DMA最常见的用途就是配合ADC的扫描模式,因为ADC扫描模式有个数据覆盖的特征,这个缺陷使ADC和DMA成为了最常见的伙伴。
手册
代码实战: DMA数据转运&&DMA+AD多通道
验证存储器映像的内容
8-1 DMA数据转运
也就是把一个数组里面的数据,复制到另一个数组里
这就是我们第一个代码的任务定义一下,DMA转运的源端数组和目的数组,初始化DMA,然后让DMA把这里DataA的数据,转运到DataB里面去。
初始化第一步,RCC开启DMA的时钟
注意:这里开启DMA时钟的时候,根据型号不同开启时钟参数也不同
第二步,就可以直接调用DMA_Init,初始化这里的各个参数了,包括外设和存储器站点的起始地址、数据宽度、地址是否自增,方向、传输计数器、是否需要自动重装、选择触发源、通道优先级,那这所有的参数,通过一个结构体,就可以配置好了
例:
/* Initialize the DMA Channel1 according to the DMA_InitStructure
members */
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = 0x40005400;
DMA_InitStructure.DMA_MemoryBaseAddr = 0x20000100;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStructure.DMA_BufferSize = 256;
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;
DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
DMA_Init(DMA_Channel1, &DMA_InitStructure);
之后,就可以进行开关控制,DMA_Cmd,给指定的通道使能,就完成了。那在这里,如果你选择的是硬件触发不要忘了在对应的外设调用一下XXX_DMACmd,开启一下触发信号的输出;如果你需要DMA的中断,那就调用DMA_ITConfig,开启中断输出,再在NVIC里,配置相应的中断通道,然后写中断函数就行了
最后,在运行的过程中,如果转运完成,传输计数器清0了。这时想再给传输计数器赋值的话,就DMA失能、写传输计数器、DMA使能,这样就行了。
MyDMA部分:
mian.c:
8-2 DMA+AD多通道
用ADC的扫描模式来实现多通道采集,然后使用DMA来进行数据转运
AD+DMA部分:
main.c部分:
模块篇
按键
传感器模块
对于光敏电阻传感器来说,这个N1就是光敏电阻;对于热敏电阻传感器来说,这个N1就是热敏电阻;对应这个红外传感器来说,这个N1就是一个红外接收管
左边这个C2是一个滤波电容,它是为了给中间的电压输出进行滤波的用来滤除一些干扰,保证输出电压波形的平滑一般我们在电路里遇到这种一端接在电路中,另一端接地的电容都可以考虑一下这个是不是滤波电容的作用,如果是滤波电容的作用,那这个电容就是用来保证电路稳定的。并不是电路的主要框架,这时候我们在分析电路的时候,就可以先把这个电容给抹掉,这样就可以使我们的电路分析更加简单。
那我们把这个电容抹掉,整个电路的主要框架就是定值电阻和传感器电阻的分压电路了。在这里可以用分压定理来分析一下传感器电阻的阻值变化对输出电压的影响,当然我们还可以用上下拉电阻的思维来分析,当这个N1阻值变小时,下拉作用就会增强,中间的AO端的电压就会拉低,极端情况下,N1阻值为0,AO输出被完全下拉,输出0V;当N1阻值变大,下拉作用就会减弱,中间的引脚由于R1的上拉作用,电压就会升高极端情况下,N1阻值无穷大,相当于断路,输出电压被R1拉高至VCC
这个LM393是一个电压比较器芯片,里面有两个独立的电压比较器电路,然后剩下的是VCC和GND供电。这个电压比较器其实就是一个运算放大器,当这个同相输入端的电压大于反相输入端的电压时,输出就会瞬间升高为最大值也就是输出接VCC,反之当同相输入端的电压小于反相输入端的电压时,输出就会瞬间降低为最小值也就是输出接GND,这样就可以对一个模拟电压进行二值化了,这里同相输入端IN+接到了AO这里,就是模拟电压端。
IN-呢,接了一个电位器,这个电位器的接法也是分压电阻的原理,拧动电位器,IN-就会生成一个可调的阈值电压,两个电压进行比较,最终输出结果就是DO,数字电压输出,DO最终就接到了引脚的输出端,这就是数字电路的由来,然后右边这里还有两个指示灯电路,左边的是电源指示灯,通电就亮;右边的是DO输出指示灯,它可以指示DO的输出电平,低电平点亮,高电平熄灭。那右边DO这里还多了个R5上拉电阻,这个是为了保证默认输出为高电平的。
旋转编码器
31:05开始介绍了编码器
舵机
如果单独供电的话,供电的负极要和STM32共地,然后正极接在5V供电引脚上。不同的电源需要共地
可以看出,舵机其实并不是一种单独的电机,它的内部是由直流电机驱动的,它里面还有一个控制电路板,是一个电机的控制系统。大概的执行逻辑是:PWM信号输入到控制板,给控制板一个指定的目标角度,然后,这个电位器检测输出轴的当前角度。如果大于目标角度,电机就会反转;如果小于目标角度,电机就会正转,最终使输出轴固定在指定角度,这就是舵机的内部工作流程。
棕色是电源负,红色是电源正,橙色是信号线
直流电机
补充篇
参考资料:
电路分析基础(6)-总说电路的“地”
关于在外设接线中注意的问题——共地
这是因为,在实际中,各处的零电位实际上是不太相同的,将地线接在一起是为了统一零电位,以保证各处的电压,即电势差有统一的关系。
C语言基础
C语言
区分:串口,COM口,UART,USART
https://blog.csdn.net/qq_26904271/article/details/79829363请跳转这个链接去看,这个博主写的挺好的。
图片内容为:[1]潘南红,黄连帅,莫秋燕.基于STM32的USART串口异步通信及应用实验设计[J].信息与电脑(理论版),2021,33(19):217-219.
UART和IrDA、LIN的关系
UART和IrDA、LIN的关系
printf函数重定向
USART串口中提到,真的牛掰!
前置知识
相当于专有名词解释
串行与并行
数字数据通信接口可以分为两大类:串行接口和并行接口。
串行通信,又称为逐位传输(Bit-by-Bit Transmission),是指按顺序逐个传输数据位的通信方式。在串行通信中,数据位按照顺序逐一传输,通过传输线进行数据传输。虽然传输速度较慢,但实现简单。串行通信常用于短距离的数据传输,如串口、USB接口等。
并行通信是一种同时传输多个数据位的通信方式,也称为同时传输多个数据位(Word-by-Word Transmission)。在并行通信中,数据被分成多个并行传输,同时通过多个传输线进行数据传输。虽然传输速度快,但实现起来较为复杂。并行通信常用于短距离的数据传输,如计算机内部数据总线等。
并行数据传输,可以将一个完整的字节(单词或更大的数据)一下子从发送器传输到了接收器。如你所料,并行接口比串行接口快得多,因为并行-串行和串行-并行的解/译码步骤被省略了。而并行传输的缺点是:需要足够数量的传输线(导线)来传输单独的数字。
同步与异步通讯
根据通讯的数据同步方式,又分为同步和异步两种,可以根据通讯过程中是否有使用到时钟信号进行简单的区分。
在同步通讯中,收发设备双方会使用一根信号线表示时钟信号,在时钟信号的驱动下双方进行协调, 同步数据,见图 同步通讯 。 通讯中通常双方会统一规定在时钟信号的上升沿或下降沿对数据线进行采样。
同步通信的数据帧组成一般是:同步信号+若干数据。在最前面是个同步信号,接收端接收数据分析出同步信号之后,就认为后边的数据都是实际传输的数据了。理论上来说同步通信一个数据帧里面的若干数据的位数是不受限制的。
同步通信中,数据之间是不能有间隔的,因为双方在同一个时钟下工作,这边接收的,必然是另一边发送的。在同步信号之后,认为所有的数据都是实际数据,所以当没有信息要传输是,同步信号要填上空字符。
异步通信是一种常用的通信方式,发送字符之间的时间间隔可以是任意的。在异步通讯中不使用时钟信号进行数据同步,它们直接在数据信号中穿插一些同步用的信号位,或者把主体数据进行打包, 以数据帧的格式传输数据,某些通讯中还需要双方约定数据的传输速率,以便更好地同步。
异步通信在发送字符时,所发送的字符之间的时间间隔可以是任意的。因为每一帧的数据都有开始和停止位,他们之间的数据位才是实际数据。所以接收方评判数据是否为完整的一帧数据的方式就是分析这一堆数据中的开始位和停止位。发送端可以在任意时刻开始发送字符,接收端必须时刻做好接收的准备。因为每传输一个数据帧都会有一个开始位和一个停止位,实际数据一般只占到5-8位,这就导致了异步通信的传输效率较低。
同步与异步通信区别:
1.同步通信要求接收端和发送端时钟频率一致,而异步通信不要求时钟同步。
2.同步通信效率高,异步通信效率较低。
3.同步通信较复杂,时钟允许误差较小,而异步通信相对简单,时钟可允许一定误差。
4.同步通信可用于点对多点,而异步通信只适用于点对点。
补充:I2C和SPI由于具有独立的时钟线,因此它们是同步的。在时钟信号的指引下,接收方可以采样数据。然而,串口、CAN和USB没有时钟线,因此需要双方约定一个采样频率,这就是异步通信。为了对齐采样位置,还需要添加一些帧头和帧尾等标识。
同步靠时钟线,异步靠比特率
通讯速率
衡量通讯性能的一个非常重要的参数就是通讯速率,通常以**比特率(Bitrate)**来表示,即每秒钟传输的二进制位数, 单位为比特每秒(bit/s)。
容易与比特率混淆的概念是“波特率”(Baudrate),它表示每秒钟传输了多少个码元。 而码元是通讯信号调制的概念,通讯中常用时间间隔相同的符号来表示一个二进制数字,这样的信号称为码元。 如常见的通讯传输中,用0V表示数字0,5V表示数字1,那么一个码元可以表示两种状态0和1,所以一个码元等于一个二进制比特位, 此时波特率的大小与比特率一致;如果在通讯传输中,有0V、2V、4V以及6V分别表示二进制数00、01、10、11, 那么每个码元可以表示四种状态,即两个二进制比特位,所以码元数是二进制比特位数的一半,这个时候的波特率为比特率的一半。
因为很多常见的通讯中一个码元都是表示两种状态,人们常常直接以波特率来表示比特率,虽然严格来说没什么错误,但希望您能了解它们的区别。
在计算机科学里,大部分复杂的问题都可以通过分层来简化。如芯片被分为内核层和片上外设;STM32标准库则是在寄存器与用户代码之间的软件层。 对于通讯协议,我们也以分层的方式来理解,最基本的是把它分为物理层和协议层 。物理层规定通讯系统中具有机械、电子功能部分的特性, 确保原始数据在物理媒体的传输。协议层主要规定通讯逻辑,统一收发双方的数据打包、解包标准。 简单来说物理层规定我们用嘴巴还是用肢体来交流,协议层则规定我们用中文还是英文来交流。
9、USART串口
注意:在串口助手的接收模式中有文本模式和HEX模式两种模式,那么它们有什么区别?
文本模式和Hex模式是两种不同的文件编辑或浏览模式,不是完全相同的概念。文本模式通常是指以ASCII编码格式表示文本文件的编辑或浏览模式。在文本模式下,文本文件的内容以可读的字符形式显示,包括字母、数字、符号等,这些字符被转换为计算机能够识别和处理的二进制编码。而Hex模式则是指以十六进制编码格式显示文件内容的编辑或浏览模式。在Hex模式下,文件的内容以16进制数值的形式显示,每个字节(byte)用两个十六进制数表示,从0x00到0xFF,可以查看文件的二进制编码,包括数据、指令、标志位等信息。因此,虽然文本模式和Hex模式都是用于文件编辑或浏览的模式,但它们的显示和处理方式不同,用途也不同。
STM32如何才能获取到陀螺仪、蓝牙器等这些外挂模的数据呢?
这就需要我们在这两个设备之间,连接上一根或多根通信线,通过通信线路发送或者接收数据,完成数据交换,从而实现控制外挂模块和读取外挂模块数据的目的。所以在这里,通信的目的是,将一个设备的数据传送到另一个设备,单片机有了通信的功能,就能与众多别的模块互联,极大地扩展了硬件系统。
下面我们分别对串口通讯协议的物理层及协议层进行讲解。
物理层
串口通讯的物理层有很多标准及变种,我们主要讲解RS-232标准 ,RS-232标准主要规定了信号的用途、通讯接口以及信号的电平标准。
使用RS-232标准的串口设备间常见的通讯结构见图 串口通讯结构图 。
在上面的通讯方式中,两个通讯设备的“DB9接口”之间通过串口信号线建立起连接,串口信号线中使用“RS-232标准”传输数据信号。 由于RS-232电平标准的信号不能直接被控制器直接识别,所以这些信号会经过一个“电平转换芯片”转换成控制器能识别的“TTL标准”的电平信号,才能实现通讯。
电平标准
根据通讯使用的电平标准不同,串口通讯可分为TTL标准及RS-232标准,见表 TTL电平标准与RS232电平标准 。
使用RS232与TTL电平校准表示同一个信号时的对比见图 RS-232与TTL电平标准下表示同一个信号 。
因为控制器一般使用TTL电平标准,所以常常会使用MAX3232芯片对TTL及RS-232电平的信号进行互相转换。
RS-232信号线
在最初的应用中,RS-232串口标准常用于计算机、路由与调制调解器(MODEN,俗称“猫”)之间的通讯 ,在这种通讯系统中, 设备被分为数据终端设备DTE(计算机、路由)和数据通讯设备DCE(调制调解器)。我们以这种通讯模型讲解它们的信号线连接方式及各个信号线的作用。
在旧式的台式计算机中一般会有RS-232标准的COM口(也称DB9接口),见图 电脑主板上的COM口及串口线.
其中接线口以针式引出信号线的称为公头,以孔式引出信号线的称为母头。在计算机中一般引出公头接口,而在调制调解器设备中引出的一般为母头,使用上图中的串口线即可把它与计算机连接起来。通讯时,串口线中传输的信号就是使用前面讲解的RS-232标准调制的。
在这种应用场合下,DB9接口中的公头及母头的各个引脚的标准信号线接法见图 DB9标准的公头及母头接法 及表 DB9信号线说明 。
上表中的是计算机端的DB9公头标准接法,由于两个通讯设备之间的收发信号(RXD与TXD)应交叉相连, 所以调制调解器端的DB9母头的收发信号接法一般与公头的相反,两个设备之间连接时,只要使用“直通型”的串口线连接起来即可, 见图 计算机与调制调解器的信号线连接 。
串口线中的RTS、CTS、DSR、DTR及DCD信号,使用逻辑 1表示信号有效,逻辑0表示信号无效。 例如,当计算机端控制DTR信号线表示为逻辑1时,它是为了告知远端的调制调解器,本机已准备好接收数据,0则表示还没准备就绪。
在目前的其它工业控制使用的串口通讯中,一般只使用RXD、TXD以及GND三条信号线, 直接传输数据信号,而RTS、CTS、DSR、DTR及DCD信号都被裁剪掉了。
协议层
串口通讯的数据包由发送设备通过自身的TXD接口传输到接收设备的RXD接口。在串口通讯的协议层中, 规定了数据包的内容,它由启始位、主体数据、校验位以及停止位组成,通讯双方的数据包格式要约定一致才能正常收发数据, 其组成见图 串口数据包的基本组成 。
串口中,每一个字节都装载在一个数据帧里面,每个数据帧都由起始位、数据位和停止位组成.
波特率
本章中主要讲解的是串口异步通讯,异步通讯中由于没有时钟信号(如前面讲解的DB9接口中是没有时钟信号的), 所以两个通讯设备之间需要约定好波特率,即每个码元的长度,以便对信号进行解码, 图 串口数据包的基本组成中用虚线分开的每一格就是代表一个码元。常见的波特率为4800、9600、115200等。
例如,如果每隔1秒发送一位,那么接收方也必须每隔1秒接收一位。如果接收方过早接收,则可能会重复接收某些位;如果接收方过晚接收,则可能会错过某些位。因此,发送方和接收方必须约定好传输速率,这个速率参数,就是波特率。那反应到波形上,比如我们双方规定波特率为1000bps,那就表示,1s要发1000位,每一位的时间就是1ms,发送方每隔1ms发送一位,接收方每隔1ms接收一位,这就是波特率,它决定了每隔多久发送一位。
通讯的起始和停止信号
起始位,它是标志一个数据帧的开始,固定为低电平。首先,串口的空闲状态是高电平,也就是没有数据传输的时候,然后需要传输的时候,必须要先发送一个起始位,这个起始位必须是低电平,来打破空闲状态的高电平,产生一个下降沿。这个下降沿,就告诉接收设备,这一帧数据要开始了。如果没有起始位,那当我发送8个1的时候,是不是数据线就一直都是高电平,没有任何波动,对吧。这样,接收方怎么知道我发送数据了呢。
同理,在一个字节数据发送完成后,必须要有一个停止位,这个停止位的作用是,用于数据帧间隔,固定为高电平。同时这个停止位,也是为下一个起始位做准备的,如果没有停止位,那当我数据最后一位是0的时候,下次再发送新的一帧,是不是就没法产生下降沿了,对吧。这就是起始位和停止位的作用。起始位固定为0,产生下降沿,表示传输开始;停止位固定为1,把引脚恢复成高电平,方便下一次的下降沿,如果没有数据了,正好引脚也为高电平,代表空闲状态。
数据位:
这里数据位表示数据帧的有效载荷,1为高电平,0为低电平,低位先行。比如我要发送一个字节,是0x0F,那就首先把0F转换为二进制,就是0000 1111,然后低位先行,所以数据要从低位开始发送,也就是1111 0000,像这样,依次放在发送引脚上。所以说如果你想发0x0F这一个字节数据,那就按照波特率要求,定时翻转引脚电平,产生一个这样的波形就行了。
有效数据
在数据包的起始位之后紧接着的就是要传输的主体数据内容,也称为有效数据,有效数据的长度常被约定为5、6、7或8位长。
数据校验
最后看一下校验位,它的用途是,用于数据验证,是根据数据位计算得来的。这里串口,使用的是一种叫奇偶校验的数据验证方法,奇偶校验可以判断数据传输是不是出错了。如果数据出错了,可以选择丢弃或者要求重传,校验可以选择3种方式,无校验、奇校验和偶校验。无校验,就是不需要校验位,波形就是左边这个,起始位、数据位、停止位,总共3个部分。
奇校验要求有效数据和校验位中“1”的个数为奇数,比如一个8位长的有效数据为:01101001,此时总共有4个“1”, 为达到奇校验效果,校验位为“1”,最后传输的数据将是8位的有效数据加上1位的校验位总共9位。
偶校验与奇校验要求刚好相反,要求帧数据和校验位中“1”的个数为偶数, 比如数据帧:11001010,此时数据帧“1”的个数为4个,所以偶校验位为“0”。
0校验是不管有效数据中的内容是什么,校验位总为“0”,1校验是校验位总为“1”。
当然奇偶校验的检出率并不是很高,比如如果有两位数据同时出错。奇偶特性不变,那就校验不出来了,所以奇偶校验只能保证一定程度上的数据校验。如果想要更高的检出率,可以了解一下CRC校验,这个校验会更加好用,当然也会更复杂。我们这个STM32内部也有CRC的外设,可以了解一下,那到这里,串口的时序我们就了解了。
说明:我们这里的数据位,有两种表示方法,一种是把校验位作为数据位的一部分,分为8位数据和9位数据,其中9位数据,就是8位有效载荷和1位校验位;另一种就是把数据位和校验位独立开,数据位就是有效载荷,校验位就是独立的1位,像我这上面的描述,就是把数据位和校验位分开描述了,在串口助手里也是分开描述,总之,无论是合在一起,还是分开描述,描述的都是同一个东西,这个应该也好理解。
串口时序
总结一下就是,TX引脚输出定时翻转的高低电平,RX引脚定时读取引脚的高低电平。每个字节的数据加上起始位、停止位、可选的校验位,打包为数据帧,依次输出在TX引脚,另一端RX引脚依次接收,这样就完成了字节数据的传递,这就是串口通信。
STM32的USART串口
另外我们经常还会遇到串口,叫UART,少了个S,就是通用异步收发器,一般我们串口很少使用这个同步功能,所以USART和UART使用起来,也没有什么区别。其实这个STM32的USART同步模式,只是多了个时钟输出而已,它只支持时钟输出,不支持时钟输入,所以这个同步模式更多的是为了,兼容别的协议或者特殊用途而设计的,并不支持两个USART之间进行同步通信。所以我们学习串口,主要还是异步通信。
串行通信一般是以帧格式传输数据,即是一帧一帧的传输,每帧包含有起始信号、数据信息、停止信息, 可能还有校验信息。USART就是对这些传输参数有具体规定,当然也不是只有唯一一个参数值,很多参数值都可以自定义设置,只是增强它的兼容性。
我们之前学习了串口的协议,串口主要就是靠收发这样的、约定好的波形来进行通信的,那这个USART外设,就是串口通信的硬件支持电路。
这个同步模式,就是多了个时钟CLK的输出;硬件流控制,比如A设备的TX脚向B设备的RX脚发送数据,A设备一直在发,发的太快了,B处理不过来,如果没有硬件流控制,那B就只能抛弃新数据或者覆盖原数据了。如果有硬件流控制,在硬件电路上,会多出一根线,如果B没准备好接收,就置高电平,如果准备好了,就置低电平。A接收到了B反馈的准备信号,就只会在B准备好的时候,才发数据,如果B没准备好,那数据就不会发送出去。这就是硬件流控制,可以防止因为B处理慢而导致数据丢失的问题;之后DMA,是这个串口支持DMA进行数据转运,可以使用DMA转运数据,减轻CPU的负担;最后,智能卡、IrDA、LIN,这些是其他的一些协议。因为这些协议和串口是非常的像,所以STM32就对USART加了一些小改动,就能兼容这么多协议了,不过我们一般不用,像这些协议,Up主也都没用过。
USART框图详解
引脚部分:
TX: 发送数据输出引脚。
RX: 接收数据输入引脚。
SCLK: 发送器时钟输出引脚。这个引脚仅适用于同步模式。
下面这里的SWRX、IRDA_OUT/IN这些是智能卡和IrDA通信的引脚,我们不用这些协议,所以这些引脚就不用管的。
SW_RX: 数据接收引脚,只用于单线和智能卡模式,属于内部引脚,没有具体外部引脚。
nRTS: 请求以发送(Request To Send),n表示低电平有效。如果使能RTS流控制,当USART接收器准备好接收新数据时就会将nRTS变成低电平; 当接收寄存器已满时,nRTS将被设置为高电平。该引脚只适用于硬件流控制。
nCTS: 清除以发送(Clear To Send),n表示低电平有效。如果使能CTS流控制,发送器在发送下一帧数据之前会检测nCTS引脚, 如果为低电平,表示可以发送数据,如果为高电平则在发送完当前数据帧之后停止发送。该引脚只适用于硬件流控制。
数据寄存器:
USART_DR包含了已发送的数据或者接收到的数据。USART_DR实际是包含了两个寄存器,一个专门用于发送的可写TDR, 一个专门用于接收的可读RDR。这两个寄存器占用同一个地址在程序上,只表现为一个寄存器。当进行发送操作时,往USART_DR写入数据会自动存储在TDR内;当进行读取操作时,向USART_DR读取数据会自动提取RDR数据。
USART数据寄存器(USART_DR)只有低9位有效,并且第9位数据是否有效要取决于USART控制寄存器1(USART_CR1)的M位设置, 当M位为0时表示8位数据字长,当M位为1表示9位数据字长,我们一般使用8位数据字长。
TDR和RDR都是介于系统总线和移位寄存器之间。串行通信是一个位一个位传输的,发送时把TDR内容转移到发送移位寄存器, 然后把移位寄存器数据每一位发送出去,接收时把接收到的每一位顺序保存在接收移位寄存器内然后才转移到RDR。
USART支持DMA传输,可以实现高速数据传输,具体DMA使用将在DMA章节讲解。
移位寄存器:
然后往下看,下面是两个移位寄存器,一个用于发送,一个用于接收。发送移位寄存器的作用就是,把一个字节的数据一位一位地移出去,正好对应串口协议的波形的数据位。
这两个寄存器是怎么工作的呢?(图中主要讲的是发送寄存器)
注意一下,当TXE标志位置1时,数据其实还没有发送出去,只要数据从TDR转移到发送移位寄存器了,TXE就会置1,我们就可以写入新的数据了。【就是发送数据寄存器里一直有数据,而发送移位寄存器里的数据一旦移位完成,那么发送数据寄存器里的数据就会立刻传输进入发送移位寄存器里再次传输】
看一下接收端这里,也是类似的。数据从RX引脚通向接收移位寄存器,在接收器控制的驱动下,一位一位地读取RX电平,先放在最高位,然后向右移,移位8次之后,就能接收一个字节了。同样,因为串口协议规定是低位先行,所以接收移位寄存器是从高位往低位这个方向移动的。之后,当一个字节移位完成之后,这一个字节的数据就会整体地,一下子转移到接收数据寄存器RDR里来,在转移的过程中,也会置一个标志位叫RXNE (RXNot Empty),接收数据寄存器非空,当我们检测到RXNE置1之后,就可以把数据读走了。同样,这里也是两个寄存器进行缓存,当数据从移位寄存器转移到RDR时,就可以直接移位接收下一帧数据了。
这就是USART外设整个的工作流程,其实讲到这里,这个外设的主要功能就差不多了。大体上,就是数据寄存器和移位寄存器,发送移位寄存器往TX引脚移位,接收移位寄存器从RX引脚移位。当然发送还需要加上帧头帧尾,接收还需要剔除帧头帧尾,这些操作,它内部有电路会自动执行。我们知道有硬件帮我们做了这些工作就行了
接着我们继续看一下下面的控制部分和一些其他的增强功能
硬件流控:
下面这里是发送器控制,它就是用来控制发送移位寄存器的工作的;接收器控制,用来控制接收移位寄存器的工作;然后左边这里,有一个硬件数据流控,也就是硬件流控制,简称流控。
这里流控有两个引脚,一个是nRTS,一个是nCTS。nRTS(Request To Send)是请求发送,是输出脚,也就是告诉别人,我当前能不能接收;nCTS (Clear To Send)是清除发送,是输入脚,也就是用于接收别人nRTS的信号的。
这里前面加个n意思是低电平有效,那这两个脚上怎么玩的呢?
首先,我们需要找到一个支持流控的串口,并将它的TX连接到我们的RX。同时,我们的RTS需要输出一个接收反馈信号,并将其连接到对方的CTS。当我们可以接收数据时,RTS会置为低电平,请求对方发送。对方的CTS接收到信号后,就可以继续发送数据。如果处理不过来,比如接收数据寄存器未及时读取,导致新数据无法接收,此时RTS会置为高电平,对方的CTS接收到信号后,就会暂停发送,直到接收数据寄存器被读取,RTS重新置为低电平,数据才会继续发送。
当我们的TX向对方发送数据时,对方的RTS会连接到我们的CTS,用于判断对方是否可以接收数据。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的分频系数,并且分为了整数部分和小数部分,因为有些波特率,用72M除一个整数的话,可能除不尽,会有误差。所以这里分频系数是支持小数点后4位的,分频就更加精准,之后分频完之后,还要再除个16,得到发送器时钟和接收器时钟,通向控制部分。然后右边这里,如果TE (TX Enable)为1,就是发送器使能了,发送部分的波特率就有效;如果RE(RX Enable)为1,就是接收器使能了,接收部分的波特率就有效。
然后剩下还有一些寄存器的指示
比如各个CR控制寄存器的哪一位控制哪一部分电路,SR状态寄存器都有哪些标志位,这些可以自己看看手册里的寄存器描述,那里的描述比这里清晰很多
引脚定义表,这里复用功能这一栏,就给出了每个USART它的各个引脚都是复用在了哪个GPIO上的。
这些引脚都必须按照引脚定义里的规定来,或者看一下重映射这里,有没有重映射,这里有USART1的重映射,所以有机会换一次口,剩下引脚,就没有机会作为USART1的接口了。
USART基本结构
那到这里,USART的基本结构就讲完了。
几个小细节
数据帧:
这个图,是在程序中配置8位字长和9位字长的波形对比。这里的字长,就是我们前面说的数据位长度。他这里的字长,是包含校验位的,是这种描述方式。
总的来说,这里有4种选择,9位字长,有校验或无校验;8位字长,有校验或无校验。但我们最好选择9位字长 有校验,或8位字长 无校验,这两种,这样每一帧的有效载荷都是1字节,这样才舒服。
配置停止位:
那最后这些时钟什么的,和上面也都是类似的
接下来我们继续来看这个数据帧,看一下不同停止位的波形变化。STM32的串口,可以配置停止位长度为0.5、1、1.5、2,这四种。
这四种参数的区别,就是停止位的时长不一样。第一个是1个停止位,这时停止位的时长就和数据位的一位,时长一样;然后是1.5个停止位,这时的停止位就是数据位一位,时长的1.5倍;2个停止位,那停止位时长就是2倍;0.5个停止位,时长就是0.5倍。这个也好理解,就是控制停止位时长的,一般选择1位停止位就行了,其他的参数不太常用。这个是停止位。
起始位侦测和数据采样:
那之后,我们继续来看一些细节问题,这两个图展示的是USART电路输入数据的一些策略。对于串口来说,根据我们前面的介绍,可以想到,串口的输出TX应该是比输入RX简单很多,输出你就定时翻转TX引脚高低电平就行了。但是输入,就复杂一些。你不仅要保证,输入的采样频率和波特率一致,还要保证每次输入采样的位置,【要正好处于每一位的正中间,只有在每一位的正中间采样,这样高低电平读进来,才是最可靠的,如果你采样点过于靠前或靠后,那有可能高低电平还正在翻转,电平还不稳定,或者稍有误差,数据就采样错了】。另外,输入最好还要对噪声有一定的判断能力,如果是噪声,最好能置个标志位提醒我一下,这些就是输入数据所面临的问题。
那我们来看一下STM32是如何来设计输入电路的呢?
第一个图展示了USART的起始位侦测。当输入电路侦测到数据帧的起始位后,将以波特率的频率连续采样一帧数据。同时,从起始位开始,采样位置要对齐到位的正中间。只要第一位对齐了,后面就都是对齐的。
为了实现这些功能,输入电路对采样时钟进行了细分,以波特率的16倍频率进行采样。在一位的时间里,可以进行16次采样。比如最开始时,空闲状态为高电平,采样一直是1。在某个位置突然采到0,说明两次采样之间出现了下降沿,如果没有噪声,那之后就应该是起始位了。在起始位,会进行连续16次采样,没有噪声的话,这16次采样肯定都是0。但是实际电路还是会存在一些噪声,所以这里即使出现下降沿了,后续也要再采样几次以防万一。
根据手册描述,接收电路在下降沿之后的第3次、5次、7次进行一批采样,在第8次、9次、10次再进行一批采样。这两批采样都要求每3位里面至少应有2个0。如果没有噪声,那肯定全是0,满足情况;如果有一些轻微的噪声导致3位里面只有两个0,另一个是1,那也算是检测到了起始位(但是在状态寄存器里会置一个NE(Noise Error),提醒你数据收到了但是有噪声,你悠着点用);如果3位里面只有1个0,那就不算检测到了起始位,可能前面那个下降沿是噪声导致的,这时电路就忽略前面的数据重新开始捕捉下降沿。
这就是STM32的串口在接收过程中对噪声的处理。如果通过了这个起始位侦测那接收状态就由空闲变为接收起始位同时第8、9、10次采样的位置就正好是起始位的正中间。之后接收数据位时就在第8、9、10次进行采样这样就能保证采样位置在位的正中间了。这就是起始位侦测和采样位置对齐的策略。
那紧跟着,我们就可以看这个数据采样的流程了。
这里,从1到16,是一个数据位的时间长度,在一个数据位,有16个采样时钟,由于起始位侦测已经对齐了采样时钟,所以,这里就直接在第8、9、10次采样数据位。为了保证数据的可靠性,这里是连续采样3次,没有噪声的理想情况下,这3次肯定全为1或者全为0,全为1,就认为收到了1,全为0,就认为收到了0;如果有噪声,导致3次采样不是全为1或者全为0,那它就按照2:1的规则来,2次为1,就认为收到了1,2次为0,就认为收到了0,在这种情况下,噪声标志位NE也会置1,告诉你,我收到数据了,但是有噪声,你悠着点用,这就是检测噪声的数据采样,可见STM32对这个电路的设计考虑还是很充分的
波特率发生器:
那最后,我们再来看一下波特率发生器
为什么这里公式有个16,因为它内部还有一个16倍波特率的采样时钟,所以这里输入时钟/DV要等于16倍的波特率,最终计算波特率,自然要多除一个16了。
举个例子,比如我要配置USART1为9600的波特率,那如何配置这个BRR寄存器呢?
我们代入公式,就是9600等于 USART1的时钟是72M 除 16倍的DIV,解得,DIV=72M/9600/16,最终等于468.75,则二进制数是11101 0100.11v。所以最终写到这个寄存器就是整数部分为11101 0100,前面多出来的补0,小数部分为11,后面多出来的补0。这就是根据波特率写BRR寄存器的方法,了解一下,不过,我们用库函数配置的话,就非常方便,需要多少波特率,直接写就行了,库函数会自动帮我们算。
手册讲解
USB转串口模块的内部电路图
代码实战:串口发送&&串口发送+接受
9-1串口发送:
下面这个是我们的USB转串口的模块,这里有个跳线帽,上节也说过,要插在VCC和3V3这两个脚上,选择通信的TTL电平为3.3V,然后通信引脚,TXD和RXD,要接在STM32的PA9和PA10口。为什么是这两个口呢,我们看一下引脚定义表就知道USART1的TX是PA9, RX是PA10,我们计划用USART1进行通信,所以就选这两个脚。TX和RX交叉连接,这边一定要注意,别接错了。然后,两个设备之间要把负极接在一起,进行共地,一般多个系统之间互连,都要进行共地。最后,这个串口模块和STLINK都要插在电脑上,这样,STM32和串口模块都有独立供电,所以这里通信的电源正极就不需要接了。
当然我们第一个代码,只有STM32发送的部分,所以,通信线只有这个发送的有用,另一根线,第一个代码没有用到,暂时可以不接,在我们下一个串口发送+接收的代码,两根通信线就都需要接了。所以我们把这两根通信线一起都接上吧,这样两个代码的接线图是一模一样的。
老规矩,上来先写一个初始化函数
第一步,开启时钟,把需要用的USART和GPIO的时钟打开
第二步,GPIO初始化,把TX配置成复用输出,RX配置成输入
第三步,配置USART,直接使用一个结构体,就可以把这里所有的参数都配置好了
第四步,如果你只需要发送的功能,就直接开启USART,初始化就结束了。如果你需要接收的功能,可能还需要配置中断,那就在开启USART之前,再加上ITConfig和NVIC的代码就行了。
那初始化完成之后,如果要发送数据,调用一个发送函数就行了;如果要接收数据,就调用接收的函数;如果要获取发送和接收的状态,就调用获取标志位的函数,这就是USART外设的使用思路。
Serial.c部分:
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>/*** 函 数:串口初始化* 参 数:无* 返 回 值:无*/
void Serial_Init(void)
{/*开启时钟*/RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); //开启USART1的时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启GPIOA的时钟/*GPIO初始化*/GPIO_InitTypeDef GPIO_InitStructure;GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA9引脚初始化为复用推挽输出/*USART初始化*/USART_InitTypeDef USART_InitStructure; //定义结构体变量USART_InitStructure.USART_BaudRate = 9600; //波特率USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制,不需要USART_InitStructure.USART_Mode = USART_Mode_Tx; //模式,选择为发送模式USART_InitStructure.USART_Parity = USART_Parity_No; //奇偶校验,不需要USART_InitStructure.USART_StopBits = USART_StopBits_1; //停止位,选择1位USART_InitStructure.USART_WordLength = USART_WordLength_8b; //字长,选择8位USART_Init(USART1, &USART_InitStructure); //将结构体变量交给USART_Init,配置USART1/*USART使能*/USART_Cmd(USART1, ENABLE); //使能USART1,串口开始运行
}/*** 函 数:串口发送一个字节* 参 数:Byte 要发送的一个字节* 返 回 值:无*/
void Serial_SendByte(uint8_t Byte)
{USART_SendData(USART1, Byte); //将字节数据写入数据寄存器,写入后USART自动生成时序波形while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); //等待发送完成/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}/*** 函 数:串口发送一个数组* 参 数:Array 要发送数组的首地址* 参 数:Length 要发送数组的长度* 返 回 值:无*/
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{uint16_t i;for (i = 0; i < Length; i ++) //遍历数组{Serial_SendByte(Array[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:串口发送一个字符串* 参 数:String 要发送字符串的首地址* 返 回 值:无*/
void Serial_SendString(char *String)
{uint8_t i;for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止{Serial_SendByte(String[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:次方函数(内部使用)* 返 回 值:返回值等于X的Y次方*/
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{uint32_t Result = 1; //设置结果初值为1while (Y --) //执行Y次{Result *= X; //将X累乘到结果}return Result;
}/*** 函 数:串口发送数字* 参 数:Number 要发送的数字,范围:0~4294967295* 参 数:Length 要发送数字的长度,范围:0~10* 返 回 值:无*/
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'); //依次调用Serial_SendByte发送每位数字}
}/*** 函 数:使用printf需要重定向的底层函数* 参 数:保持原始格式即可,无需变动* 返 回 值:保持原始格式即可,无需变动*/
int fputc(int ch, FILE *f)
{Serial_SendByte(ch); //将printf的底层重定向到自己的发送字节函数return ch;
}/*** 函 数:自己封装的prinf函数* 参 数:format 格式化字符串* 参 数:... 可变的参数列表* 返 回 值:无*/
void Serial_Printf(char *format, ...)
{char String[100]; //定义字符数组va_list arg; //定义可变参数列表数据类型的变量argva_start(arg, format); //从format开始,接收参数列表到arg变量vsprintf(String, format, arg); //使用vsprintf打印格式化字符串和参数列表到字符数组中va_end(arg); //结束变量argSerial_SendString(String); //串口发送字符数组(字符串)
}
mian.c部分:
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"int main(void)
{/*模块初始化*/OLED_Init(); //OLED初始化Serial_Init(); //串口初始化/*串口基本函数*/Serial_SendByte(0x41); //串口发送一个字节数据0x41uint8_t MyArray[] = {0x42, 0x43, 0x44, 0x45}; //定义数组Serial_SendArray(MyArray, 4); //串口发送一个数组Serial_SendString("\r\nNum1="); //串口发送字符串Serial_SendNumber(111, 3); //串口发送数字/*下述3种方法可实现printf的效果*//*方法1:直接重定向printf,但printf函数只有一个,此方法不能在多处使用*/printf("\r\nNum2=%d", 222); //串口发送printf打印的格式化字符串//需要重定向fputc函数,并在工程选项里勾选Use MicroLIB/*方法2:使用sprintf打印到字符数组,再用串口发送字符数组,此方法打印到字符数组,之后想怎么处理都可以,可在多处使用*/char String[100]; //定义字符数组sprintf(String, "\r\nNum3=%d", 333);//使用sprintf,把格式化字符串打印到字符数组Serial_SendString(String); //串口发送字符数组(字符串)/*方法3:将sprintf函数封装起来,实现专用的printf,此方法就是把方法2封装起来,更加简洁实用,可在多处使用*/Serial_Printf("\r\nNum4=%d", 444); //串口打印字符串,使用自己封装的函数实现printf的效果Serial_Printf("\r\n");while (1){}
}
9-2 串口发送+接受
Serial.c部分:
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>uint8_t Serial_RxData; //定义串口接收的数据变量
uint8_t Serial_RxFlag; //定义串口接收的标志位变量/*** 函 数:串口初始化* 参 数:无* 返 回 值:无*/
void Serial_Init(void)
{/*开启时钟*/RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); //开启USART1的时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启GPIOA的时钟/*GPIO初始化*/GPIO_InitTypeDef GPIO_InitStructure;GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA9引脚初始化为复用推挽输出GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA10引脚初始化为上拉输入/*USART初始化*/USART_InitTypeDef USART_InitStructure; //定义结构体变量USART_InitStructure.USART_BaudRate = 9600; //波特率USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制,不需要USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //模式,发送模式和接收模式均选择USART_InitStructure.USART_Parity = USART_Parity_No; //奇偶校验,不需要USART_InitStructure.USART_StopBits = USART_StopBits_1; //停止位,选择1位USART_InitStructure.USART_WordLength = USART_WordLength_8b; //字长,选择8位USART_Init(USART1, &USART_InitStructure); //将结构体变量交给USART_Init,配置USART1/*中断输出配置*/USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); //开启串口接收数据的中断/*NVIC中断分组*/NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); //配置NVIC为分组2/*NVIC配置*/NVIC_InitTypeDef NVIC_InitStructure; //定义结构体变量NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; //选择配置NVIC的USART1线NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //指定NVIC线路使能NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; //指定NVIC线路的抢占优先级为1NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; //指定NVIC线路的响应优先级为1NVIC_Init(&NVIC_InitStructure); //将结构体变量交给NVIC_Init,配置NVIC外设/*USART使能*/USART_Cmd(USART1, ENABLE); //使能USART1,串口开始运行
}/*** 函 数:串口发送一个字节* 参 数:Byte 要发送的一个字节* 返 回 值:无*/
void Serial_SendByte(uint8_t Byte)
{USART_SendData(USART1, Byte); //将字节数据写入数据寄存器,写入后USART自动生成时序波形while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); //等待发送完成/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}/*** 函 数:串口发送一个数组* 参 数:Array 要发送数组的首地址* 参 数:Length 要发送数组的长度* 返 回 值:无*/
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{uint16_t i;for (i = 0; i < Length; i ++) //遍历数组{Serial_SendByte(Array[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:串口发送一个字符串* 参 数:String 要发送字符串的首地址* 返 回 值:无*/
void Serial_SendString(char *String)
{uint8_t i;for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止{Serial_SendByte(String[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:次方函数(内部使用)* 返 回 值:返回值等于X的Y次方*/
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{uint32_t Result = 1; //设置结果初值为1while (Y --) //执行Y次{Result *= X; //将X累乘到结果}return Result;
}/*** 函 数:串口发送数字* 参 数:Number 要发送的数字,范围:0~4294967295* 参 数:Length 要发送数字的长度,范围:0~10* 返 回 值:无*/
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'); //依次调用Serial_SendByte发送每位数字}
}/*** 函 数:使用printf需要重定向的底层函数* 参 数:保持原始格式即可,无需变动* 返 回 值:保持原始格式即可,无需变动*/
int fputc(int ch, FILE *f)
{Serial_SendByte(ch); //将printf的底层重定向到自己的发送字节函数return ch;
}/*** 函 数:自己封装的prinf函数* 参 数:format 格式化字符串* 参 数:... 可变的参数列表* 返 回 值:无*/
void Serial_Printf(char *format, ...)
{char String[100]; //定义字符数组va_list arg; //定义可变参数列表数据类型的变量argva_start(arg, format); //从format开始,接收参数列表到arg变量vsprintf(String, format, arg); //使用vsprintf打印格式化字符串和参数列表到字符数组中va_end(arg); //结束变量argSerial_SendString(String); //串口发送字符数组(字符串)
}/*** 函 数:获取串口接收标志位* 参 数:无* 返 回 值:串口接收标志位,范围:0~1,接收到数据后,标志位置1,读取后标志位自动清零*/
uint8_t Serial_GetRxFlag(void)
{if (Serial_RxFlag == 1) //如果标志位为1{Serial_RxFlag = 0;return 1; //则返回1,并自动清零标志位}return 0; //如果标志位为0,则返回0
}/*** 函 数:获取串口接收的数据* 参 数:无* 返 回 值:接收的数据,范围:0~255*/
uint8_t Serial_GetRxData(void)
{return Serial_RxData; //返回接收的数据变量
}/*** 函 数:USART1中断函数* 参 数:无* 返 回 值:无* 注意事项:此函数为中断函数,无需调用,中断触发后自动执行* 函数名为预留的指定名称,可以从启动文件复制* 请确保函数名正确,不能有任何差异,否则中断函数将不能进入*/
void USART1_IRQHandler(void)
{if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) //判断是否是USART1的接收事件触发的中断{Serial_RxData = USART_ReceiveData(USART1); //读取数据寄存器,存放在接收的数据变量Serial_RxFlag = 1; //置接收标志位变量为1USART_ClearITPendingBit(USART1, USART_IT_RXNE); //清除USART1的RXNE标志位//读取数据寄存器会自动清除此标志位//如果已经读取了数据寄存器,也可以不执行此代码}
}
main.c部分:
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"uint8_t RxData; //定义用于接收串口数据的变量int main(void)
{/*模块初始化*/OLED_Init(); //OLED初始化/*显示静态字符串*/OLED_ShowString(1, 1, "RxData:");/*串口初始化*/Serial_Init(); //串口初始化while (1){if (Serial_GetRxFlag() == 1) //检查串口接收数据的标志位{RxData = Serial_GetRxData(); //获取串口接收的数据Serial_SendByte(RxData); //串口将收到的数据回传回去,用于测试OLED_ShowHexNum(1, 8, RxData, 2); //显示串口接收的数据}}
}
USART串口数据包
先来看两张图,是关于我规定的数据包格式,一种是HEX数据包,一种是文本数据包,之后两个图,展示的就是接收数据包的思路。
接着我们来研究几个问题:
第一个问题:包头包尾和数据载荷重复的问题,这里定义FF为包头,FE为包尾,如果我传输的数据本身就是FF和FE怎么办呢?那这个问题确实存在,如果数据和包头包尾重复,可能会引起误判。对应这个问题我们有如下几种解决方法:第一种,限制载荷数据的范围。如果可以的话,我们可以在发送的时候,对数据进行限幅,比如XYZ,3个数据,变化范围都可以是0~100 那就好办了,我们可以在载荷中只发送0-100的数据,这样就不会和包头包尾重复了;第二种,如果无法避免载荷数据和包头包尾重复,那我们就尽量使用固定长度的数据包。这样由于载荷数据是固定的,只要我们通过包头包尾对齐了数据,我们就可以严格知道,哪个数据应该是包头包尾,哪个数据应该是载荷数据。在接收载荷数据的时候,我们并不会判断它是否是包头包尾,而在接收包头包尾的时候,我们会判断它是不是确实是包头包尾,用于数据对齐。这样,在经过几个数据包的对齐之后,剩下的数据包应该就不会出现问题了;第三种,增加包头包尾的数量,并且尽量让它呈现出载荷数据出现不了的状态。比如我们使用FF、FE作为包头,FD、FC作为包尾,这样也可以避免载荷数据和包头包尾重复的情况发生
第二个问题:这个包头包尾并不是全部都需要的,比如我们可以只要一个包头,把包尾删掉,这样数据包的格式就是,一个包头FF,加4个数据,这样也是可以的。当检测到FF,开始接收,收够4个字节后,置标志位,一个数据包接收完成,这样也可以。不过这样的话,载荷和包头重复的问题会更严重一些,比如最严重的情况下,我载荷全是FF,包头也是FF,那你肯定不知道哪个是包头了,而加上了FE作为包尾,无论数据怎么变化,都是可以分辨出包头包尾的。
第三个问题:固定包长和可变包长的选择问题,对应HEX数据包来说,如果你的载荷会出现和包头包尾重复的情况,那就最好选择固定包长,这样可以避免接收错误,如果你又会重复,又选择可变包长那数据很容易就乱套了;如果载荷不会和包头包尾重复,那可以选择可变包长,数据长度,像这样,4位、3位、等等,1位、10位,来回任意变,肯定都没问题。因为包头包尾是唯一的,只要出现包头,就开始数据包,只要出现包尾,就结束数据包,这样就非常灵活了,这就是固定包长和可变包长选择的问题。
最后一个问题:各种数据转换为字节流的问题。这里数据包都是一个字节一个字节组成的,如果你想发送16位的整型数据、32位的整型数据,float、double,甚至是结构体,其实都没问题,因为它们内部其实都是由一个字节一个字节组成的,只需要用一个uint8_t的指针指向它,把它们当做一个字节数组发送就行了。
好,有关HEX数据包定义的内容,就讲这么多,接下来看一下文本数据包。
文本数据包和HEX数据包分别对应了文本模式和HEX模式。在HEX数据包中,数据以原始字节形式呈现。而在文本数据包中,每个字节经过了一层编码和译码,最终以文本格式呈现。实际上,每个文本字符背后都有一个字节的HEX数据。
综上所述,我们需要根据实际场景来选择和设计数据包格式。在需要直接传输和简单解析原始数据的情况下,HEX数据包是更好的选择。而在需要输入指令进行人机交互的场合,文本数据包则更为适用。
好,数据包格式的定义讲完了,接下来我们就来学一下数据包的收发流程。
首先,发送数据包的过程相对简单。在发送HEX数据包时,可以通过定义一个数组,填充数据,然后使用之前我们写过的SendArray函数发送即可。在发送文本数据包时,可以通过写一个字符串,然后调用SendString函数发送。因此,发送数据包的过程是可控的,我们可以根据需要发送任何类型的数据包。相比之下,接收数据包的过程较为复杂。
那接下来,接收一个数据包,这就比较复杂了,我们来学习一下,我这里演示了固定包长HEX数据包的接收方法,和可变包长文本数据包的接收方法,其他的数据包也都可以套用这个形式,等会儿我们写程序就会根据这里面的流程来。
我们先看一下如何来接收这个固定包长的HEX数据包。要接收固定包长的HEX数据包,我们需要设计一个状态机来处理。根据之前的代码,我们知道每当收到一个字节,程序会进入中断。在中断函数里,我们可以获取这个字节,但获取后需要退出中断。因此,每个收到的数据都是独立的过程,而数据包则具有前后关联性,包括包头、数据和包尾。为了处理这三种状态,我们需要设计一个能够记住不同状态的机制,并在不同状态下执行不同的操作,同时进行状态合理转移。这种程序设计思维就是“状态机”。
这就是使用状态机接收数据包的思路。这个状态机其实是一种很广泛的编程思路,在很多地方都可以用到,使用的基本步骤是,先根据项目要求定义状态,画几个圈,然后考虑好各个状态在什么情况下会进行转移,如何转移,画好线和转移条件,最后根据这个图来进行编程,这样思维就会非常清晰了。
那接下来继续,我们来看一下这个可变包长、文本数据包的接收流程。
好,到这里,我们这个数据包的,定义、分类、优缺点和注意事项,就讲完了,接下来,我们就来写程序,验证一下刚才所学的内容吧。
代码实战:串口收发HEX数据包&&串口收发文本数据包
9-3 串口收发HEX数据包
Serial.c部分:
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>uint8_t Serial_TxPacket[4]; //定义发送数据包数组,数据包格式:FF 01 02 03 04 FE
uint8_t Serial_RxPacket[4]; //定义接收数据包数组
uint8_t Serial_RxFlag; //定义接收数据包标志位/*** 函 数:串口初始化* 参 数:无* 返 回 值:无*/
void Serial_Init(void)
{/*开启时钟*/RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); //开启USART1的时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启GPIOA的时钟/*GPIO初始化*/GPIO_InitTypeDef GPIO_InitStructure;GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA9引脚初始化为复用推挽输出GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA10引脚初始化为上拉输入/*USART初始化*/USART_InitTypeDef USART_InitStructure; //定义结构体变量USART_InitStructure.USART_BaudRate = 9600; //波特率USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制,不需要USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //模式,发送模式和接收模式均选择USART_InitStructure.USART_Parity = USART_Parity_No; //奇偶校验,不需要USART_InitStructure.USART_StopBits = USART_StopBits_1; //停止位,选择1位USART_InitStructure.USART_WordLength = USART_WordLength_8b; //字长,选择8位USART_Init(USART1, &USART_InitStructure); //将结构体变量交给USART_Init,配置USART1/*中断输出配置*/USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); //开启串口接收数据的中断/*NVIC中断分组*/NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); //配置NVIC为分组2/*NVIC配置*/NVIC_InitTypeDef NVIC_InitStructure; //定义结构体变量NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; //选择配置NVIC的USART1线NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //指定NVIC线路使能NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; //指定NVIC线路的抢占优先级为1NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; //指定NVIC线路的响应优先级为1NVIC_Init(&NVIC_InitStructure); //将结构体变量交给NVIC_Init,配置NVIC外设/*USART使能*/USART_Cmd(USART1, ENABLE); //使能USART1,串口开始运行
}/*** 函 数:串口发送一个字节* 参 数:Byte 要发送的一个字节* 返 回 值:无*/
void Serial_SendByte(uint8_t Byte)
{USART_SendData(USART1, Byte); //将字节数据写入数据寄存器,写入后USART自动生成时序波形while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); //等待发送完成/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}/*** 函 数:串口发送一个数组* 参 数:Array 要发送数组的首地址* 参 数:Length 要发送数组的长度* 返 回 值:无*/
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{uint16_t i;for (i = 0; i < Length; i ++) //遍历数组{Serial_SendByte(Array[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:串口发送一个字符串* 参 数:String 要发送字符串的首地址* 返 回 值:无*/
void Serial_SendString(char *String)
{uint8_t i;for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止{Serial_SendByte(String[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:次方函数(内部使用)* 返 回 值:返回值等于X的Y次方*/
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{uint32_t Result = 1; //设置结果初值为1while (Y --) //执行Y次{Result *= X; //将X累乘到结果}return Result;
}/*** 函 数:串口发送数字* 参 数:Number 要发送的数字,范围:0~4294967295* 参 数:Length 要发送数字的长度,范围:0~10* 返 回 值:无*/
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'); //依次调用Serial_SendByte发送每位数字}
}/*** 函 数:使用printf需要重定向的底层函数* 参 数:保持原始格式即可,无需变动* 返 回 值:保持原始格式即可,无需变动*/
int fputc(int ch, FILE *f)
{Serial_SendByte(ch); //将printf的底层重定向到自己的发送字节函数return ch;
}/*** 函 数:自己封装的prinf函数* 参 数:format 格式化字符串* 参 数:... 可变的参数列表* 返 回 值:无*/
void Serial_Printf(char *format, ...)
{char String[100]; //定义字符数组va_list arg; //定义可变参数列表数据类型的变量argva_start(arg, format); //从format开始,接收参数列表到arg变量vsprintf(String, format, arg); //使用vsprintf打印格式化字符串和参数列表到字符数组中va_end(arg); //结束变量argSerial_SendString(String); //串口发送字符数组(字符串)
}/*** 函 数:串口发送数据包* 参 数:无* 返 回 值:无* 说 明:调用此函数后,Serial_TxPacket数组的内容将加上包头(FF)包尾(FE)后,作为数据包发送出去*/
void Serial_SendPacket(void)
{Serial_SendByte(0xFF);Serial_SendArray(Serial_TxPacket, 4);Serial_SendByte(0xFE);
}/*** 函 数:获取串口接收数据包标志位* 参 数:无* 返 回 值:串口接收数据包标志位,范围:0~1,接收到数据包后,标志位置1,读取后标志位自动清零*/
uint8_t Serial_GetRxFlag(void)
{if (Serial_RxFlag == 1) //如果标志位为1{Serial_RxFlag = 0;return 1; //则返回1,并自动清零标志位}return 0; //如果标志位为0,则返回0
}/*** 函 数:USART1中断函数* 参 数:无* 返 回 值:无* 注意事项:此函数为中断函数,无需调用,中断触发后自动执行* 函数名为预留的指定名称,可以从启动文件复制* 请确保函数名正确,不能有任何差异,否则中断函数将不能进入*/
void USART1_IRQHandler(void)
{static uint8_t RxState = 0; //定义表示当前状态机状态的静态变量static uint8_t pRxPacket = 0; //定义表示当前接收数据位置的静态变量if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) //判断是否是USART1的接收事件触发的中断{uint8_t RxData = USART_ReceiveData(USART1); //读取数据寄存器,存放在接收的数据变量/*使用状态机的思路,依次处理数据包的不同部分*//*当前状态为0,接收数据包包头*/if (RxState == 0){if (RxData == 0xFF) //如果数据确实是包头{RxState = 1; //置下一个状态pRxPacket = 0; //数据包的位置归零}}/*当前状态为1,接收数据包数据*/else if (RxState == 1){Serial_RxPacket[pRxPacket] = RxData; //将数据存入数据包数组的指定位置pRxPacket ++; //数据包的位置自增if (pRxPacket >= 4) //如果收够4个数据{RxState = 2; //置下一个状态}}/*当前状态为2,接收数据包包尾*/else if (RxState == 2){if (RxData == 0xFE) //如果数据确实是包尾部{RxState = 0; //状态归0Serial_RxFlag = 1; //接收数据包标志位置1,成功接收一个数据包}}USART_ClearITPendingBit(USART1, USART_IT_RXNE); //清除标志位}
}
main.c部分:
#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) //按键1按下{Serial_TxPacket[0] ++; //测试数据自增Serial_TxPacket[1] ++;Serial_TxPacket[2] ++;Serial_TxPacket[3] ++;Serial_SendPacket(); //串口发送数据包Serial_TxPacketOLED_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);}}
}
9-4 串口收发文本数据包
Serial.c部分:
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdarg.h>char Serial_RxPacket[100]; //定义接收数据包数组,数据包格式"@MSG\r\n"
uint8_t Serial_RxFlag; //定义接收数据包标志位/*** 函 数:串口初始化* 参 数:无* 返 回 值:无*/
void Serial_Init(void)
{/*开启时钟*/RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); //开启USART1的时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); //开启GPIOA的时钟/*GPIO初始化*/GPIO_InitTypeDef GPIO_InitStructure;GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA9引脚初始化为复用推挽输出GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;GPIO_Init(GPIOA, &GPIO_InitStructure); //将PA10引脚初始化为上拉输入/*USART初始化*/USART_InitTypeDef USART_InitStructure; //定义结构体变量USART_InitStructure.USART_BaudRate = 9600; //波特率USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; //硬件流控制,不需要USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; //模式,发送模式和接收模式均选择USART_InitStructure.USART_Parity = USART_Parity_No; //奇偶校验,不需要USART_InitStructure.USART_StopBits = USART_StopBits_1; //停止位,选择1位USART_InitStructure.USART_WordLength = USART_WordLength_8b; //字长,选择8位USART_Init(USART1, &USART_InitStructure); //将结构体变量交给USART_Init,配置USART1/*中断输出配置*/USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); //开启串口接收数据的中断/*NVIC中断分组*/NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); //配置NVIC为分组2/*NVIC配置*/NVIC_InitTypeDef NVIC_InitStructure; //定义结构体变量NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; //选择配置NVIC的USART1线NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //指定NVIC线路使能NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; //指定NVIC线路的抢占优先级为1NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; //指定NVIC线路的响应优先级为1NVIC_Init(&NVIC_InitStructure); //将结构体变量交给NVIC_Init,配置NVIC外设/*USART使能*/USART_Cmd(USART1, ENABLE); //使能USART1,串口开始运行
}/*** 函 数:串口发送一个字节* 参 数:Byte 要发送的一个字节* 返 回 值:无*/
void Serial_SendByte(uint8_t Byte)
{USART_SendData(USART1, Byte); //将字节数据写入数据寄存器,写入后USART自动生成时序波形while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); //等待发送完成/*下次写入数据寄存器会自动清除发送完成标志位,故此循环后,无需清除标志位*/
}/*** 函 数:串口发送一个数组* 参 数:Array 要发送数组的首地址* 参 数:Length 要发送数组的长度* 返 回 值:无*/
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{uint16_t i;for (i = 0; i < Length; i ++) //遍历数组{Serial_SendByte(Array[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:串口发送一个字符串* 参 数:String 要发送字符串的首地址* 返 回 值:无*/
void Serial_SendString(char *String)
{uint8_t i;for (i = 0; String[i] != '\0'; i ++)//遍历字符数组(字符串),遇到字符串结束标志位后停止{Serial_SendByte(String[i]); //依次调用Serial_SendByte发送每个字节数据}
}/*** 函 数:次方函数(内部使用)* 返 回 值:返回值等于X的Y次方*/
uint32_t Serial_Pow(uint32_t X, uint32_t Y)
{uint32_t Result = 1; //设置结果初值为1while (Y --) //执行Y次{Result *= X; //将X累乘到结果}return Result;
}/*** 函 数:串口发送数字* 参 数:Number 要发送的数字,范围:0~4294967295* 参 数:Length 要发送数字的长度,范围:0~10* 返 回 值:无*/
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'); //依次调用Serial_SendByte发送每位数字}
}/*** 函 数:使用printf需要重定向的底层函数* 参 数:保持原始格式即可,无需变动* 返 回 值:保持原始格式即可,无需变动*/
int fputc(int ch, FILE *f)
{Serial_SendByte(ch); //将printf的底层重定向到自己的发送字节函数return ch;
}/*** 函 数:自己封装的prinf函数* 参 数:format 格式化字符串* 参 数:... 可变的参数列表* 返 回 值:无*/
void Serial_Printf(char *format, ...)
{char String[100]; //定义字符数组va_list arg; //定义可变参数列表数据类型的变量argva_start(arg, format); //从format开始,接收参数列表到arg变量vsprintf(String, format, arg); //使用vsprintf打印格式化字符串和参数列表到字符数组中va_end(arg); //结束变量argSerial_SendString(String); //串口发送字符数组(字符串)
}/*** 函 数:USART1中断函数* 参 数:无* 返 回 值:无* 注意事项:此函数为中断函数,无需调用,中断触发后自动执行* 函数名为预留的指定名称,可以从启动文件复制* 请确保函数名正确,不能有任何差异,否则中断函数将不能进入*/
void USART1_IRQHandler(void)
{static uint8_t RxState = 0; //定义表示当前状态机状态的静态变量static uint8_t pRxPacket = 0; //定义表示当前接收数据位置的静态变量if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) //判断是否是USART1的接收事件触发的中断{uint8_t RxData = USART_ReceiveData(USART1); //读取数据寄存器,存放在接收的数据变量/*使用状态机的思路,依次处理数据包的不同部分*//*当前状态为0,接收数据包包头*/if (RxState == 0){if (RxData == '@' && Serial_RxFlag == 0) //如果数据确实是包头,并且上一个数据包已处理完毕{RxState = 1; //置下一个状态pRxPacket = 0; //数据包的位置归零}}/*当前状态为1,接收数据包数据,同时判断是否接收到了第一个包尾*/else if (RxState == 1){if (RxData == '\r') //如果收到第一个包尾{RxState = 2; //置下一个状态}else //接收到了正常的数据{Serial_RxPacket[pRxPacket] = RxData; //将数据存入数据包数组的指定位置pRxPacket ++; //数据包的位置自增}}/*当前状态为2,接收数据包第二个包尾*/else if (RxState == 2){if (RxData == '\n') //如果收到第二个包尾{RxState = 0; //状态归0Serial_RxPacket[pRxPacket] = '\0'; //将收到的字符数据包添加一个字符串结束标志Serial_RxFlag = 1; //接收数据包标志位置1,成功接收一个数据包}}USART_ClearITPendingBit(USART1, USART_IT_RXNE); //清除标志位}
}
mian.c部分:
#include "stm32f10x.h" // Device header
#include "Delay.h"
#include "OLED.h"
#include "Serial.h"
#include "LED.h"
#include "string.h"int main(void)
{/*模块初始化*/OLED_Init(); //OLED初始化LED_Init(); //LED初始化Serial_Init(); //串口初始化/*显示静态字符串*/OLED_ShowString(1, 1, "TxPacket");OLED_ShowString(3, 1, "RxPacket");while (1){if (Serial_RxFlag == 1) //如果接收到数据包{OLED_ShowString(4, 1, " ");OLED_ShowString(4, 1, Serial_RxPacket); //OLED清除指定位置,并显示接收到的数据包/*将收到的数据包与预设的指令对比,以此决定将要执行的操作*/if (strcmp(Serial_RxPacket, "LED_ON") == 0) //如果收到LED_ON指令{LED1_ON(); //点亮LEDSerial_SendString("LED_ON_OK\r\n"); //串口回传一个字符串LED_ON_OKOLED_ShowString(2, 1, " ");OLED_ShowString(2, 1, "LED_ON_OK"); //OLED清除指定位置,并显示LED_ON_OK}else if (strcmp(Serial_RxPacket, "LED_OFF") == 0) //如果收到LED_OFF指令{LED1_OFF(); //熄灭LEDSerial_SendString("LED_OFF_OK\r\n"); //串口回传一个字符串LED_OFF_OKOLED_ShowString(2, 1, " ");OLED_ShowString(2, 1, "LED_OFF_OK"); //OLED清除指定位置,并显示LED_OFF_OK}else //上述所有条件均不满足,即收到了未知指令{Serial_SendString("ERROR_COMMAND\r\n"); //串口回传一个字符串ERROR_COMMANDOLED_ShowString(2, 1, " ");OLED_ShowString(2, 1, "ERROR_COMMAND"); //OLED清除指定位置,并显示ERROR_COMMAND}Serial_RxFlag = 0; //处理完成后,需要将接收数据包标志位清零,否则将无法接收后续数据包}}
}