目录
前言
GPIO(通用输入输出引脚)
推挽输出模式
浮空输入和上拉输入模式
GPIO其他模式以及内部电路原理
输出驱动器
输入驱动器
中断
外部中断(EXTI)
深入中断(内部机制及原理)
外部中断/事件控制器
NVIC(嵌套向量中断控制器)
串口
轮询模式
中断模式
DMA模式与收发不定长数据
DMA模式
接收不定长数据
补充:回调函数的模板以及解决传输过半中断
I2C
标准I2C模式读取AHT20传感器
aht20.c
main.c
I2C中断模式读取AHT20传感器
aht20.c
main.c
I2CDMA模式
时钟树
CubeMX配置
定时器TIM
基本定时功能
知识点补充
定时器更新中断
外部时钟与循迹模块
外部时钟模式2
外部时钟模式1
定时器从模式
复位模式 Reset Mode
门模式 Gated Mode
触发模式 Trigger Mode
知识点补充:定时器上电自动触发一次中断
输入捕获&超声波测距
超声波测距模块
输入捕获
输出比较&PWM脉冲宽度调制
PWM
输出比较模式
旋转编码器
ADC模拟-数字转换技术
单次转换模式
连续转换模式
ADC多通道采集功能
单次转换模式
连续转换模式
RTC实时时钟
HAL库RTC库
自己实现RTC库函数
前言
这是一份专注于STM32 HAL库的实战学习笔记,你将通过它掌握两大核心技能:
手把手教你通过CubeMX配置常用外设,摆脱对标准库的依赖;
像查字典一样随时检索HAL库函数用法,快速解决开发中的配置难题。
无论你是:
● 从标准库转向HAL库的开发者
● 需要快速搭建原型的竞赛选手
● 希望建立规范化开发流程的工程师
这里都会提供开箱即用的代码模板和直击痛点的配置要点。话不多说,让我们直接开启HAL库的学习之旅,本教程会持续更新,希望大家关注支持一下。
GPIO(通用输入输出引脚)
其作用就是在STM32的控制之下,读取外部电路的电压值,或者向外输出一定的电压值,它一共有8大使用模式;我们先来学习最简单也是最常用的推挽输出。
推挽输出模式
可以在STM32的控制下向外部输出0V的低电平,或者3.3V的高电平;假设我们要控制单片机上的PA7引脚来点亮连接在这个引脚上的LED灯,配置如下:
打开CubeMX,配置PA7引脚为 GPIO_Output ,这样PA7就设置成了输出模式:
我们再细化一下设置,点击左侧的System Core,再点击GPIO就能看到我们刚刚设置的PA7引脚:
再点击PA7就能进行详细的设置:
HAL库为我们提供了控制IO电平的函数:
/* GPIOx:要设置的GPIO的分组
* GPIO_Pin:要设置的GPIO的编号
* PinState:GPIO状态(GPIO_PIN_SET/GPIO_PIN_RESET)
*/
HAL_GPIO_WritePin(GPIOx, GPIO_Pin, PinState);
浮空输入和上拉输入模式
本小节学习如何使用GPIO的输入功能,实现按键控制小灯,在下节再对GPIO的几种模式进行稍微深入一点的探索。
本小节要实现的效果为:按住KEY1不松开时,LED1亮起,松开KEY1时,LED1熄灭;按一下KEY2,LED2亮起,再按一下KEY2,LED2熄灭。KEY1的硬件原理图如下:
外部自带了一个上拉电阻,平时按键松开,PB12直接通过一个电阻连接到了3.3V电源上,一会我们会将PB12设置成GPIO八大模式之一的浮空输入模式,浮空输入模式下的GPIO口内部处于高阻态,相当于芯片内部有一个巨大的电阻,根据电阻分压原理,10K电阻的压降几乎为0V,因此PB12处就几乎是3.3V。懂了这个原理,其他场景下也是这样分析的。
我们开始CubeMX的配置,假设LED1和2分别为PA7和PB0,按键引脚为PB12,将PB12设置成 GPIO_Input :
到这里就可以直接生成代码了,不需要进行更加详细的设置,设置成输入模式后默认是浮空输入模式:
HAL库为我们提供了读取引脚电平的函数:
/* 返回值:GPIO_PIN_SET/GPIO_PIN_RESET
*/
GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
我们先来实现KEY1控制LED1的代码,十分简单,在main函数的while循环里实现:
while (1)
{
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_12) == GPIO_PIN_RESET)
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET);
}
else
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET);
}
}
我们再来看看KEY2的硬件原理图:
可见KEY2并没有外置的上拉电阻,而上拉下拉的操作也非常常见,因而STM32为我们在芯片内部提前准备好了上拉与下拉电阻,我们回到CubeMX,来实现KEY2控制LED2的效果,KEY2的引脚为PB13:
点击PB13,进入详细设置,设置成Pull-up,启用上拉输入模式,之后我们就来写代码实现KEY2每按一次,LED2的电平就翻转的效果:
while (1)
{
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_13) == GPIO_PIN_RESET)
{
//软件消抖
HAL_Delay(10);
//10ms后如果任然检测到按键被按下,就执行相应操作
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_13) == GPIO_PIN_RESET)
{
HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
//每按下一次按键只能翻转一次电平,因此按键没松开之前要一直卡在这里,避免多次触发
while(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_13) == GPIO_PIN_RESET);
}
}
}
GPIO其他模式以及内部电路原理
前面我们学习了GPIO的推挽输出、浮空输入、上拉输入以及下拉输入(原理和上拉输入类似),尚未见过的还有开漏输出、复用推挽输出、复用开漏输出以及模拟输入。本小节来了解一下它们在芯片内部的电路原理,并通过电路来推理各个模式的应用场景,本小节围绕下面这张图展开:
输出驱动器
我们先来看输出驱动器,可以看到被输出控制模块控制的两个MOS,其作用可以简化为两个被控制的开关,我们假设IO口连接了一个小灯,小灯另一端是GND,小灯工作电压为3.3V,则我们可以使用推挽输出模式,此模式下P-MOS和N-MOS协同工作,当我们控制IO口输出高低电平时,对应的MOS将会激活,连接VDD(3.3V)或者VSS(0V),从而对外输出高电平或低电平:
但总有一些更高或者更低的电压驱动,推挽输出最大只能输出3.3V的电压,例如小灯的工作电压为5V,此时就需要用到开漏输出:此模式下只有N-MOS工作,P-MOS一直处于断开的状态,当使用HAL库的函数控制IO口输出高电平时,N-MOS断开,整个IO口内部处于高阻态,或者说"断路",并不对外输出特定的电平信号,若控制IO口输出低电平,则N-MOS激活,IO口与VSS连接,小灯两端都是0V,若将小灯另一端连接到5V,这时候就能通过开漏输出模式控制小灯的亮灭:
需要注意的是:这里我们需要用支持5V容忍的IO口,否则会使上方保护二极管长期导通,将5V引入电源中造成损坏。
对于输出控制模块,它有两个控制指令的来源,一个是我们使用HAL库的函数控制的输出寄存器,而另一个则是我们后面将会学习的片上外设,例如串口模块、I2C模块等,因而根据控制来源的不同,STM32将两种输出模式又细分成了普通的推挽输出和开漏输出,以及复用推挽输出与复用开漏输出。
输入驱动器
来到输入驱动器,外部输入的电流从IO口引脚进入后,首先经过一对上拉下拉电阻,可配置成前面说的三种输入模式(浮空输入、上拉输入、下拉输入):
后面的肖特基触发器用于稳定电平,处理后的电平信号被写入输入数据寄存器,等待我们使用HAL库的函数对寄存器进行读取,这就是最基本的GPIO口读取高低电平的原理。注意到这条线路中有两个分支,第一条分支通向了模拟输入,浮空、上拉、下拉这三种输入模式都是仅读取了高低电平,也就是数字信号,而模拟输入则是读取输入电压的具体数值,因此在触发器前产生分支,将电压引入了模拟输入相关的片上外设,将会在以后的ADC相关知识了解到;
另一条支线则是在触发器后,接入了例如串口模块等需要数字输入的片上外设,这里要注意的是:输入部分的不同分支可以同时读取触发器的输出,因此不会出现复用上拉输入等模式,而是在片上外设上也使用普通的输入模式即可。
中断
STM32要随时准备着去处理一些我们为其规定的各种突发事件,处理完成后还要继续执行之前正在执行的任务,而这些可以打断正常工作流程去处理的任务,我们就将其称之为"中断"。
对于STM32芯片来说,可以产生中断的事件多种多样:例如指令出错、定时器结束、串口接收到数据、GPIO电平变化等等。。。本小节先来学习检测GPIO口电平变化的中断,称之为外部中断(EXTI)
外部中断(EXTI)
假设我们有这样的需求:LED1以4秒为周期循环闪烁,亮两秒灭两秒,当KEY1按下时,LED2要翻转亮灭状态。
在配置CubeMX之前,我们先想想为什么需要用中断的方式来检测按键是否被按下?
如果用之前的方式,将按键引脚配置成普通的输入模式,由于还没有学到定时器,要让LED1周期闪烁,肯定要用到 HAL_Delay(); 这个函数,它会延迟等待我们设定的时间后再执行下面的代码,因此当我们按下按键时,程序大概率在执行延时函数,那我们按下按键就是在做无用功,因此我希望当我按下按键时,能快速响应,就引入了中断。
来到CubeMX界面,配置KEY1的PB12引脚为 GPIO_EXTI12 ,也就是第12号外部中断线,具体概念在下一小节会讲,还没完,来到System Core下的GPIO对PB12进行详细设置:
点击GPIO mode的选项框,会有六个选项,其中前三个与中断有关:
很显然,如果有上拉电阻的话,当按键被按下时电平从高变成低,那我们选择下降沿触发中断,然后我们还要点击NVIC,也就是中断控制器:
勾选上开启中断向量EXTI15_10,NVIC和中断向量的细节,我们会在下一小节讲解:
生成代码后打开 stm32f1xx_it.c ,后缀it表示它是与interrupt(中断)相关的文件,此文件的最底部有一个CubeMX帮我们自动生成的函数:
它就是我们按下按键触发中断后STM32会调用执行的中断处理函数,因此我们在这个函数中翻转LED2的亮灭,就能实现我们想要的效果。
main.c
while(1)
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET);
HAL_Delay(2000);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET);
HAL_Delay(2000);
}
stm32f1xx_it.c
void EXTI15_10_IRQHandler(void)
{
/* USER CODE BEGIN EXTI15_10_IRQn 0 */
HAL_Delay(10);
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_12) == GPIO_PIN_RESET)
HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
/* USER CODE END EXTI15_10_IRQn 0 */
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_12);
/* USER CODE BEGIN EXTI15_10_IRQn 1 */
/* USER CODE END EXTI15_10_IRQn 1 */
}
延时10ms是为了进行软件消抖,按键按下或者抬起时的抖动都会有下降沿,从而触发中断,所以我们先延时10ms等待抖动过去,10ms后再来判断引脚电平是否为低电平,来判断按键是因为按下时的下降沿触发还是因为抬起时抖动中的下降沿触发,确认是按键按下的下降沿触发后翻转LED2的电平。
注意:此时还不能编译运行,程序会跑飞!因为这里涉及到了发生多个中断时的优先级问题,这里的 HAL_Delay(); 函数需要依赖一个叫做 System tick timer(系统滴答)的中断,但此中断的优先级比我们所触发的中断的优先级要低,也就是说要等我们的中断处理函数执行完后才能执行 HAL_Delay(); 那就尬住了,解决方法很简单,回到CubeMX:
让 System tick timer 的优先级数字小于我们的 EXTI15_10 即可(数字越小,优先级越高,优先级也会在下一小节讲到),也就是说当我们的中断处理函数触发的时候,调用的HAL_Delay可以先执行,执行完后再回来执行我们的中断处理函数。这里要补充的是:正规的项目中,直接在中断中实现按键逻辑,尤其是在中断中调用延时函数(HAL_Delay),是一种不被推荐的做法,因为我们需要尽可能的保证中断任务尽快执行完成,以将中断对正常流程的影响降到最低。
深入中断(内部机制及原理)
外部中断/事件控制器
前面我们提到了NVIC、EXTI、中断向量、中断优先级,本小节就详细讲讲。从上小节接触到的EXTI(外部中断)入手,外部的电平信号进入到GPIO口后,来到了输入驱动器,经过上拉下拉电阻后经过施密特触发器转化,最后抵达输入数据寄存器或者片上外设,再接下来,电平信号还会抵达这么一个结构:
这样的结构在STM32F1系列芯片中共有19个,这19个外部中断控制器共用一套寄存器,但连线是独立的,共有19组连线,每个外部中断都对应其中一组线路:前16个,也就是EXTI0~EXTI15,分别对应与其编号相同的GPIO口,就是说PA0、PB0、PC0、PD0进入的电平信号都可以进入EXTI0,以此类推:
左下角的脉冲发生器和事件屏蔽寄存器与中断无关,而是与"事件(Event)"相关的结构,事件信号会送达相应的外设,由外设自行处理,本小节先忽略,得到下面的简化图,我们先看高亮这一部分:
边沿检测电路可以检测输入的电平信号中有没有发生高低电平的转换,我们之前在CubeMX选择是上升沿还是下降沿触发中断,就是在配置上面两个寄存器,当检测到我们设置的模式时,就会向后传递一个高电平信号,然后经过一个或门。注意这里的的软件中断事件寄存器,让我们可以通过程序模拟产生一个中断,一般不需要,这里先忽略:
请求挂起寄存器是需要注意的点,其接收到高电平信号后会将对应位置1,并将此位输出到一个与门,因此中断屏蔽寄存器就起到了决定性的作用,只有它的对应位置上为1,输出高电平,请求挂起寄存器的信号才能通过与门,进入到NVIC,中断屏蔽寄存器的开启在CubeMX将引脚设置成GPIO_EXTIx时就自动帮我们在代码中生成了,高电平一直向后,就来到了中断最高城——NVIC。
NVIC(嵌套向量中断控制器)
其主要作用就是掌管这样一张中断向量表:
在所有外部中断线中,只有EXTI0~4有自己的中断向量,EXTI5~9共享中断向量EXTI9_5,10~15同理,因此我们之前的PB12外部中断,它的中断向量就是EXTI15_10,然后执行相对应的函数。这里需要注意的是:NVIC会一直检测某个中断线是否被激活,为了防止中断处理函数重复执行,需要将请求挂起寄存器的对应位清除为0,但我们上一节写的代码并没有这么做,这是因为:
当有多个中断同时触发,就要引入中断优先级,分为抢占优先级和响应优先级(数字越小优先级越高):
若两个都相同,就按照它们在中断向量表中的顺序决定;
由上可得,响应优先级仅在两中断同时发生时起到辅助作用,抢占优先级才是大哥,STM32为每个中断向量准备了4个二进制位来存储中断优先级信息:
在CubeMX中,我们可以自由选择这4位中,几位用来设置抢占优先级,几位用来设置响应优先级,默认4位都用来设置抢占优先级,这时优先级可以设置的范围为0~15。
此处插播一个小知识点,如果中断模式下需要上拉或下拉,在CubeMX中也可以直接配置:
串口
本小节开始学习单片机中最常见的串口——TTL串口,仅需两根数据线就可完成两个设备的双向通信,连接方式如下,注意要共地:
轮询模式
我们先学习串口三大模式中最基本的模式——轮询模式,它会阻塞程序的执行,直到完成发送或接收,或者等待超时,接收时需要接收固定长度的数据。
接下来进入实战环节,日常开发中,我们常使用串口与电脑进行通信,本小节实现电脑向STM32发送指令,指令的样式定长为两个字节,STM32收到指令后需回复电脑当前收到的指令,话不多说,打开CubeMX:
点击左侧的Connectivity就可以看到USART,F1C8T6的芯片有三个串口,本小节以USART2为例,点击后选择异步模式(Asynchronous),可以看到PA2和PA3分别被设置成了USART2的TX引脚和RX引脚:
通信两设备要使用相同的波特率才能正常通信,后面三个通常保持默认即可,生成代码后,我们来实现效果:
串口轮询模式的发送函数和接收函数为:
/* 第一个参数是串口的句柄,要使用哪个串口进行发送
* 最后一个参数是超时时间,你愿意等待多久无论是否发送成功,单位为ms
* 填写HAL_MAX_DELAY表示愿意永久等待
*/
HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout);
/* 参数用法和Transmit相同
*/
HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
main.c
//使用strlen函数获得字符串长度需要包含此头文件
#include <string.h>
uint8_t receiveData[2];
/*省略,直接来看while循环*/
while (1)
{
HAL_UART_Receive(&huart2, receiveData, 2, HAL_MAX_DELAY);
HAL_UART_Transmit(&huart2, receiveData, strlen(receiveData), 100);
}
中断模式
本小节学习中断模式解决程序等待问题,在轮询模式中,CPU需要不断去查询发送数据寄存器中的数据是否已经移送到发送移位寄存器,移了的话,就赶紧把下一个数据塞进TDR,没移就不断查询,直到把要发送的数据全部发完,或者用时超过设定的超时时间;轮询模式下的接收也是类似,CPU会一直查询接收数据寄存器(RDR)中是否有新数据可以读。很明显,CPU会一直查询和搬运,无暇顾及其他任务,即堵塞。
中断模式下,CPU将数据塞入寄存器后就可以执行其他代码了,当发送移位寄存器中的数据发送出去后,会触发"发送数据寄存器空"中断,把CPU叫回来,再塞入数据,以此反复,我们来到CubeMX,打开System Core下的NVIC,然后打开USART2的中断功能,就这么简单:
生成代码来看看如何使用中断模式发送数据,使用中断发送数据的函数与轮询模式十分类似,只是加上了_IT后缀。函数如下:
/* 中断模式下的串口发送十分简单,不再需要设置超时时间
*/
HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size);
/* 中断模式下的串口接收有些特殊
* 因为其不会堵塞程序的执行,往往出现上次的数据还没接收完,又开始执行串口中断接收
* 因此将中断模式下的接收放在while循环前面,把它当作"启动函数"
*/
HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
怎样知道何时数据接收完成?
使用中断处理函数。来到我们之前提到过的 stm32f1xx_it.c 文件,在最底部就可以找到USART2的中断处理函数 USART2_IRQHandler
这里需要注意的是:我们的逻辑代码不能再写在IRQHandler里了,这是因为每个USART只有一个中断向量,除了我们要用到的"接收数据寄存器非空"中断,还有"发送数据寄存器空"中断等等,都共用了此中断处理函数,因此需要判断是什么原因引起的中断,这在 HAL_UART_IRQHandler 函数里为我们封装好了,我们需要找到对应的回调函数!!!
进入上面的函数,往下找到带__weak前缀的函数,我们可以重写它实现自己的逻辑代码,找到这个函数(往往根据函数名就知道它的功能):
/* 接收完成回调函数
* 当接收到我们想要的字节数后会调用该函数
*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
/*处理数据等操作*/
/* !!!退出回调函数之前重新启动接收中断,为下一次的接收做准备
*/
HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
}
DMA模式与收发不定长数据
中断模式学习完了,不知道你有没有发现,接收到的数据仍然是固定长度的,只不过CPU的利用率变高了,本小节来学习效率最高的DMA模式以及如何使用扩展函数实现收发不定长的数据。
DMA模式
中断模式下,发送和接收的数据寄存器空和非空都会产生中断,把CPU叫回来搬运数据,我们还能再压榨一下性能,给CPU找一个小助手,让它在寄存器和内存之间搬运数据,它就是DMA(直接内存访问),只要我们创建一个DMA通道,告诉DMA将数据从哪里搬到哪里,等全部搬运完成,DMA会通过中断提醒我们,例如我们只需要为串口的接收和发送创建两条DMA通道,就可以让DMA帮着在串口的寄存器与内存变量之间搬运数据了,来到CubeMX,看看如何创建DMA通道:
来到USART2的配置界面,可以看到还有其他选项卡,一眼就找到了DMA的配置界面:
点击DMA Settings,点击Add按钮就可以添加一个DMA通道,然后为新的通道选择功能:
我们先为USART2的发送添加一个DMA通道:
上面这些都是默认生成的,我并没有改动,数据地址是否自增要结合具体情况来选择,寄存器只有一字节的长度,因此地址不需要自增,而从内存变量中是依次搬运要发送的数据,因此内存地址选择了自增。为USART2的RX添加一个DMA通道同理:
生成代码后来看看DMA模式下的串口发送函数,只需要将后缀的_IT改成_DMA即可,回调函数仍然是 HAL_UART_RxCpltCallback ,仍然要注意退出回调函数时重新启动DMA接收:
/* DMA模式下的串口发送函数
*/
HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size);
/* DMA模式下的串口接收函数
* 同样的,第一次调用该函数在while循环之前调用一次,之后在回调函数退出之前调用
*/
HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
接收不定长数据
主要靠的就是串口空闲(idle)中断,此中断的触发条件与接收的字节数无关,只有当RX引脚上无后续数据进入时触发,因而认为串口空闲中断发生时,就是一帧数据包接收完成了,在此时对数据进行分析处理即可。
只需要调用以下函数即可,可以看到有3个版本,选择有DMA后缀的:
HAL_UARTEx_ReceiveToIdle(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint16_t *RxLen, uint32_t Timeout);
HAL_UARTEx_ReceiveToIdle_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
/* 这里只讲DMA模式
* 前两个参数和之前一样
* 第三个参数不是想要接收的数据长度,而是一次能接收的最大数据长度!!!
* 一般填写接收数组的长度,避免接收数据太长导致数组越界
*/
HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
例如我定义了一个数组:uint8_t receiveData[50]; 那么第三个参数可以传入 sizeof(receiveData),还要注意的是:ReceiveToIdle的回调函数并不是我们之前用的RxCpltCallback了,来到stm32f1xx_hal_uart.c文件,找到 HAL_UARTEx_RxEventCallback 回调函数:
/* 与之前的回调函数不同的是多了一个入参Size
* 可以通过Size得知本次接收到了多少个字节的数据
*/
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size);
补充:回调函数的模板以及解决传输过半中断
//拿上面刚讲到的举例
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
//每次进入回调函数之前判断是哪个句柄?假设是USART2触发的中断
if(huart == &huart2)
{
/*自己的逻辑代码,我这里将接收到了任意个字节的数据原封不动发回给PC*/
HAL_UART_Transmit_DMA(&huart2, receiveData, Size);
//在每次退出之前和初始化的时候别忘了这句
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, (uint8_t *)receiveData, sizeof(receiveData));
/* 关闭传输过半中断
* 第一个参数是DMA通道的指针地址
* 第二个参数是要关闭的中断:选择传输过半中断
* 别忘了在while循环之前(初始化)也禁止一下,避免第一次就触发了传输过半中断
*/
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
}
}
这里需要注意的是:退出回调函数时启动下一次接收使用普通模式的 HAL_UARTEx_ReceiveToIdle 和中断模式的 HAL_UARTEx_ReceiveToIdle_IT 都是可以的,但是DMA模式除了串口空闲中断以外,还有一个"传输过半中断",就是说接收的数据量达到最大值的一半时,会触发这个回调函数,未察觉到是因为最大接收的字节数设置的比较大,而发送数据的时候比较短,自然不会触发,使用一个很大的接收数组自然能解决,但治标不治本,因此需要关闭"DMA传输过半中断"。
!!!需要在usart.h里手动定义 extern DMA_HandleTypeDef hdma_usart2_rx; 否则使用 __HAL_DMA_DISABLE_IT 函数时第一个参数会报错,显示未定义,需要我们手动定义。
I2C
I2C和串口不同,只有一条线可以用来传递数据(SDA),另一条用于提供同步时钟脉冲的时钟线(SCL),并且为"半双工"通信,同一时刻只能进行一个方向的通信,因此采用主从模式:一台为主机,另一台(多台)为从机:
标准I2C模式读取AHT20传感器
具体的原理不详细展开了,我们直接来使用CubeMX实现I2C读取温湿度传感器AHT20(DHT20),点击左侧的Connectivity里的I2C1,将其配置成标准的I2C模式:
关于I2C的中断和DMA模式会在下小节讲到,这次的代码需要为AHT20写一下驱动文件,来到Project Manager(项目管理),点开Code Generator(代码生成器),勾选上为每个外设生成一对.c/.h文件:
生成代码后来到keil,新建aht20.c和aht20.h文件,然后根据传感器读取流程来直接写代码:
这里需要注意的是,AHT20模块的设备地址其实为0x38,I2C通信一般使用7位地址码,那么它的地址0111000按理说是0x38,但是I2C通信每次发送都是一字节的数据,规定从机地址向左移动一位,因此是0x70,那为什么一开始要发送0x71呢?这是因为协议规定,主机是要写从机,最后一位就是0,读数据最后一位就是1,不过HAL库对这一位的设置在函数里帮我们自动处理,因此我们默认从机地址为8位,且最后一位为0。
初始化后就可以开始读取数据了,步骤如下:
读到的六字节数据如下,温度数据和湿度数据各占两个半字节,因此后续还需要拼接:
最后对读到的数据根据公式进行转化就可以获得温度和湿度:
普通的I2C发送和接收函数:
/* 普通I2C接收函数
* 第一个参数是I2C句柄
* 第二个参数是从机地址,8位地址,最后一位为0,函数内部会自动处理最后一位
* 第三个参数是要发送的数据的指针
* 第四个参数是要发送的字节数
* 第五个参数是超时时间
*/
HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);
/*参数同上*/
HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);
aht20.c
#include "aht20.h"
#define AHT20_ADDRESS 0x70 // AHT20 I2C设备地址
/**
* @brief AHT20传感器初始化函数
* @note 初始化前需保证传感器已上电至少40ms,用于稳定内部状态
* 如果传感器未校准,发送校准命令(0xBE 0x08 0x00)
*/
void AHT20_Init(void)
{
uint8_t readBuffer; // 用于读取状态寄存器的缓冲区
// 等待传感器上电稳定(数据手册要求至少40ms)
HAL_Delay(40);
// 读取状态寄存器(1字节)
HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, &readBuffer, 1, HAL_MAX_DELAY);
// 检查校准状态:状态寄存器bit3(0x08)为1表示已校准
if((readBuffer & 0x08) == 0x00)
{
// 发送初始化校准命令:0xBE(命令),0x08(参数1),0x00(参数2)
uint8_t sendBuffer[3] = {0xBE, 0x08, 0x00};
HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);
}
}
/**
* @brief 读取AHT20传感器的温湿度数据
* @param *Tem : 输出温度值(单位:摄氏度)
* @param *Hum : 输出湿度值(单位:百分比RH)
* @note 发送触发测量命令(0xAC 0x33 0x00)后需等待至少75ms
* 原始数据为20位,需按公式转换为实际物理量
*/
void AHT20_Read(float *Tem, float *Hum)
{
uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00}; // 触发测量命令
uint8_t readBuffer[6]; // 数据接收缓冲区(状态+5字节数据)
// 发送测量命令
HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);
// 等待测量完成(数据手册要求至少75ms)
HAL_Delay(75);
// 读取6字节数据(1字节状态 + 5字节测量数据)
HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, readBuffer, 6, HAL_MAX_DELAY);
// 检查状态寄存器bit7(0x80):0表示数据就绪
if((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
/*---------------- 湿度计算 ----------------*/
// 将readBuffer[1]、readBuffer[2]、readBuffer[3]高4位组合成20位数据
data = ((uint32_t)readBuffer[3] >> 4) + // 取第3字节高4位(低位)
((uint32_t)readBuffer[2] << 4) + // 第2字节左移4位(中间)
((uint32_t)readBuffer[1] << 12); // 第1字节左移12位(高位)
// 转换为百分比:Hum = (data / 2^20) * 100
*Hum = data * 100.0f / (1 << 20);
/*---------------- 温度计算 ----------------*/
// 将readBuffer[3]低4位、readBuffer[4]、readBuffer[5]组合成20位数据
data = (((uint32_t)readBuffer[3] & 0x0F) << 16) + // 取第3字节低4位并左移16位
((uint32_t)readBuffer[4] << 8) + // 第4字节左移8位
((uint32_t)readBuffer[5]); // 第5字节直接使用
// 转换为摄氏度:Tem = (data / 2^20) * 200 - 50
*Tem = data * 200.0f / (1 << 20) - 50;
}
}
头文件里声明这两个函数就行了,接下来通过串口来打印一下温湿度信息:
main.c
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "aht20.h"
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */
int main(void)
{
/* USER CODE BEGIN 2 */
AHT20_Init();
float tem,hum;
char message[50];
/* USER CODE END 2 */
while (1)
{
AHT20_Read(&tem, &hum);
sprintf(message, "温度: %.1f ℃, 湿度: %.1f %%\r\n",tem, hum);
HAL_UART_Transmit(&huart2, (uint8_t *)message, strlen(message), HAL_MAX_DELAY);
HAL_Delay(1000);
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
}
使用sprintf函数拼接一下字符串就能够通过串口打印出来了:
I2C中断模式读取AHT20传感器
使用起来I2C的中断与DMA模式其实也跟串口的这两种模式用法一样,来到CubeMX:
中断模式下的I2C发送和接收函数,以及发送和接收完成回调函数如下:
//I2C中断模式发送函数
HAL_I2C_Master_Transmit_IT(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size);
//I2C中断模式接收函数
HAL_I2C_Master_Receive_IT(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size);
//I2C主机发送完成回调函数
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c);
//I2C主机接收完成回调函数
void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c);
由于是中断模式,发送和接收自然不用超时时间,而是引入了回调函数的机制,和串口一样,现在就用中断模式的函数改写一下读取温湿度的函数,只在原来的函数后面加上_IT后缀是远远不够的,对数据的处理得放在回调函数里实现,因此使用状态机编程来实现:
aht20.c
#include "aht20.h"
#define AHT20_ADDRESS 0x70
uint8_t readBuffer[6];
void AHT20_Init(void)
{
uint8_t readBuffer;
HAL_Delay(40);
HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, &readBuffer, 1, HAL_MAX_DELAY);
if((readBuffer & 0x08) == 0x00)
{
uint8_t sendBuffer[3] = {0xBE, 0x08, 0x00};
HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);
}
}
//把发送测量指令、数据读取、数据解析都拆开来,分步骤执行
void AHT20_Measure(void)
{
static uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00};
HAL_I2C_Master_Transmit_IT(&hi2c1, AHT20_ADDRESS, sendBuffer, 3);
}
void AHT20_Get(void)
{
HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, readBuffer, 6);
}
void AHT20_Analysis(float *Tem, float *Hum)
{
if((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
data = ((uint32_t)readBuffer[3] >> 4) + ((uint32_t)readBuffer[2] << 4) + ((uint32_t)readBuffer[1] << 12);
*Hum = data * 100.0f / (1 << 20);
data = (((uint32_t)readBuffer[3] & 0x0F) << 16) + ((uint32_t)readBuffer[4] << 8) + ((uint32_t)readBuffer[5]);
*Tem = data * 200.0f / (1 << 20) -50;
}
}
main.c
#include "aht20.h"
#include <stdio.h>
#include <string.h>
//0:初始状态 发送测量指令 1:正在发送测量指令 2:测量指令发送完成 等待75ms后读取AHT20 3:读取中 4:读取完成 解析数据后恢复为初始状态
uint8_t aht20state = 0;
//I2C主机发送完成回调函数
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c == &hi2c1)
{
aht20state = 2;
}
}
//I2C主机接收完成回调函数
void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c == &hi2c1)
{
aht20state = 4;
}
}
int main(void)
{
/* USER CODE BEGIN 2 */
AHT20_Init();
float tem,hum;
char message[50];
while (1)
{
if(aht20state == 0)
{
AHT20_Measure();
aht20state = 1;
}
else if(aht20state == 2)
{
HAL_Delay(75);
AHT20_Get();
aht20state = 3;
}
else if(aht20state == 4)
{
AHT20_Analysis(&tem, &hum);
sprintf(message, "温度:%.1f ℃, 湿度:%.1f %%\r\n",tem, hum);
HAL_UART_Transmit(&huart2, (uint8_t *)message, strlen(message), HAL_MAX_DELAY);
HAL_Delay(1000);
aht20state = 0;
}
}
}
I2CDMA模式
十分简单,回到CubeMX,为I2C的TX和RX分别添加一条DMA通道即可,参数保持默认即可:
DMA模式和中断模式代码几乎一样,只不过将发送和接收函数的后缀改成带DMA的即可,发送和接收完成调用的回调函数和中断模式一样。
时钟树
多数情况下,只需要在CubeMX里做这几步即可:
CubeMX配置
进入RCC设置,将其高速时钟源(HSE)设置为晶振(Crystal/Ceramic Resonator):
随后来到时钟设置(Clock Configuration),将HCLK修改为最高频率72MHz(不同芯片型号可能不同),敲下回车即可:
定时器TIM
只需记住一句话:定时器就是计数器!定时器除了实现普通的定时功能,还可以实现捕获脉冲宽度、计算PWM占空比、输出PWM波形、编码器计数等等各种功能...
在STM32的F10系列芯片中,最多有8个定时器可以使用,其中TIM6、7为基本定时器,它们只有简单的定时功能(以及一个触发DAC的功能);TIM2~5称为通用定时器;TIM1、8称为高级(控制)定时器,一般用于电机领域。
基本定时功能
本小节先从最简单的定时功能入手,如何进行计时?
很简单,只要有一个时钟源,经过预分频器后再将脉冲送给计数器,想要让预分频器进行n分频,只需要将预分频器设置为n-1即可。
知道了如何计时,那如何实现定时呢?
这就要靠自动重装载寄存器了,它的作用就是实时监控计数器的值是否与自己的值相同,当计数器记到与自己相同的值时,便将计数器的值重置为0,并且可以触发一次定时器更新中断,通知STM32定时时间到了。如果我们想定时m个脉冲,就需要设置自动重装载寄存器的值为m-1。
来到CubeMX,看看如何配置,先打开串口(使用什么模式随便),用于发送数据,然后设置RCC的高速外部时钟源为晶振,打开时钟设置,HCLK为72MHz(这些都是之前设置过的,这里不演示了),这里需要补充一张图,由下图可知STM32的外设接在了哪根线上,这对于以后计算定时时间很关键:
可以看到修改HCLK为72MHz后,不同APB线的时钟频率:
回到CubeMX,点击Timers就可以看到当前芯片的所有定时器,我们使用的F103C8T6芯片只有四个定时器,不过仍然具有基本定时器的功能,毕竟是通过基本定时器扩展出来的,这里以TIM4为例,可以看到很多复杂的功能,但大多数和基本定时器无关,只需要勾选Internal Clock(内部时钟)作为时钟源即可:
如果是TIM2,则是将Clock Source(时钟源)选择为Internal Clock:
回到TIM4,来到下面的详细配置,假设我想定时1秒,如何实现?前面的图可知TIM4连接在APB1线上,它的定时器时钟线为72MHz,那么我可设置预分频器的值,让72MHz变成10000Hz,然后设置自动重装载值,计数一万次,就实现了定时一秒的效果:
注意这里的两个值,因为是从0开始,因此-1!按照上面这样设置,计数器里的值从0数到9999的时候就会从0开始重新计数。我们先来了解一下定时器相关的函数,然后来写代码:
/* 基本定时器启动函数
* 参数传入定时器句柄
*/
HAL_TIM_Base_Start(TIM_HandleTypeDef *htim);
/* 这个宏定义可以获得计数器的值
* 参数也是定时器句柄
*/
__HAL_TIM_GetCounter(TIM_HandleTypeDef *htim);
代码比较简单,延时100ms(HAL_Delay函数有个小bug,想要准确延时100ms,参数需要填99)后发送一次计数器值,直接看结果:
除了上面那个宏,还有下面这几个宏:
/* 设置计数器的值
* 第一个参数为句柄,第二个参数为值
*/
__HAL_TIM_SetCounter
/* 获得重装载值
* 只有一个参数,传入句柄
*/
__HAL_TIM_GetAutoreload
/* 设置重装载值
* 句柄+值
*/
__HAL_TIM_SetAutoreload
/* 设置预分频值
* 句柄+值
*/
__HAL_TIM_SET_PRESCALER
知识点补充
预分频寄存器有个叫影子寄存器的结构,真正工作的其实是影子寄存器,我们通过 __HAL_TIM_SET_PRESCALER 宏将新的预分频值给到预分频寄存器后,直到计数器的值归零后,才会把新的预分频值给它的影子寄存器
自动重装载寄存器也有个它的影子寄存器,我们设置完后,也是在下个周期(计数器的值归零)后生效,这样的好处是不会影响当前的计数周期。不过重装载寄存器可以自己控制是否开启影子寄存器机制,默认是关闭影子寄存器的,来到CubeMX,这个选项设置成Enable就是打开:
要注意的是:如果我们没启动重装载寄存器的影子寄存器机制,那么当我们将重装载寄存器的数值调小时,可能会小于当前计数器的值,这样的话计数器会数到最大值65535才会触发更新,回到0开始下一次计数。
定时器更新中断
之前只打印计数值,没做事,回到CubeMX,勾上这个就行了:
启动函数以及回调函数如下:
HAL_TIM_Base_Start_IT(TIM_HandleTypeDef *htim);
//定时器周期结束回调函数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);
/* 模板如下
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim4)
{
}
}
*/
小小总结一下,只要记住这张图就好了,后续基于这个结构不断扩展:
外部时钟与循迹模块
上小节基本定时功能的时钟源是来自时钟数上的APB时钟线,它在STM32内部,因此使用的是"内部时钟",我们还能对外部的信号,来自GPIO口上的信号进行计数。
STM32并没有直接将GPIO口接入进来,F103芯片上,每个定时器(本芯片只有TIM1~4)有4个输入通道,其输入的信号称为TI1到TI4(TIM Input),其中TI3和TI4并没有接入到触发控制器,因此先忽略;STM32为每个通道都配套了输入滤波器和边沿检测器,边沿检测器可以检测输入信号的边沿并输出一个脉冲(可检测上升沿、下降沿、双边沿);对于通道1(TI1)来说,其边沿检测器输出的脉冲信号有两个(TI1FP1和TI1FP2),通道2(TI2)也有两个(TI2FP1和TI2FP2),其中TI1FP2和TI2FP1后面才会用到,因此先忽略;TI1FP1和TI2FP2来到了触发控制器前,另外通道1(TI1)上还有一根称为TI1_ED的信号也来到触发控制器前(TI1FP1和TI2FP2可以选择三种边沿触发方式,TI1_ED只能由双边沿触发产生脉冲),这三个信号会连接到触发器(即只能选择一个作为输入信号),触发器又接入到触发控制器中一个叫做从模式选择器的结构;除了来自通道1和通道2的这三根信号,触发器还有一个独立的外部时钟信号,外部触发器 ETR(External Trigger),ETR经过极性选择、边沿检测、预分频以及输入滤波,其中边沿检测只能检测上升沿,但是极性选择可以将电平的极性翻转,因此极性选择和边沿检测配合就能检测上升沿和下降沿;ETR最终进入到触发器。被触发器选择后进入从模式控制器的这条路我们称之为"外部时钟模式1",而ETR除了能进入触发器外,还能独立直接进入触发控制器(注意不是从模式控制器),这条独享的路称之为"外部时钟模式2",为何要如此设置后面会讲到。
外部时钟模式2
现在来到实战环节,需要记录流水线传送带转动多远的距离,以及传送带此时的速度,因此引入了循迹模块/黑白线模块(红外反射光电开关):
蓝色小灯泡能发射红外光,黑色小灯泡能接收红外光,黑色小灯泡检测到红外强度后会通过AO引脚,以模拟量的形式输出出来(接收红外反射越强,电压越低),但这不是我我们需要的,模块上还有一个变阻器(上图左边的蓝色正方体),可以借此调整此模块的检测阈值(灵敏度),即调整橙色线:
当红外光强度大于阈值(即电压小于橙线时),模块上的小灯会亮起,同时模块上的DO引脚会输出低电平,红外光小于阈值时相反,灯灭,DO输出高电平:
因此,模块面前有可以反射红外光的物体时,DO引脚输出低电平,当模块面前没有物体,或者有黑色物体吸收了红外光时,DO引脚输出高电平;所以假如我们在传送带边缘铺上一圈黑白间隔条纹,运动起来时模块的DO引脚会输出方波信号,借此就可以实现想要的效果,来到CubeMX,这次先不要设置HCLK为72MHz,而是保持在8MHz,打开Timers,这次我们使用TIM2,这是因为由于该芯片引脚有限,TIM3、4没有引出外部触发器ETR的引脚,进入TIM2的设置,选择时钟源(Clock Source)为ETR2:
下面的各个参数都保持默认即可,滤波器很快会讲到,预分频的存在是因为最终输入到触发器的ETR信号最快只能是内部时钟(APB1/APB2)频率的1/4,因此通过这个分频器可以把速度降下来,但是我们本次的输入信号非常慢(手动用黑白条纹纸来模拟传送带运动),因此不分频,然后我们就可以来写代码了(结果用串口输出还是OLED屏幕显示随意):
main.c
#include <stdio.h>
int main(void)
{
/*虽然是外部时钟来触发定时器,但本质还是让定时器进行基础的计数功能*/
HAL_TIM_Base_Start(&htim2);
int cnt = 0;
while(1)
{
/*使用这个宏获得计数器值*/
cnt = __HAL_TIM_GetCounter(&htim2);
/*串口或屏幕显示,这里不演示了*/
}
}
可以看到成功计数了,但是幅度不对,这是因为黑白交接的边缘会出现抖动,因此需要滤波:
回到CubeMX,详细参数的Clock Filter就是设置滤波器的,绝大多数情况直接填15就行了:
修改后再试试效果就正常了:
现在来实现一开始要的效果,测传送带的速度,需要在CubeMX设置自动重装载值为10,并且开启中断,我直接给出代码(作为参考,重点是学习外部时钟):
/*============ 头文件及宏定义 ============*/
#include "main.h"
#include "oled.h" // OLED显示库
#include "tim.h" // 定时器配置
#define Period 10 // 定时器自动重装值(ARR),需与MX配置中的Counter Period一致
#define Width 1.5 // 每产生一次脉冲对应的物理宽度(单位:cm)
/*============ 全局变量声明 ============*/
volatile uint32_t loop = -1; // 定时器中断计数器,初始化为-1以抵消首次中断
// volatile确保中断和主循环中变量的同步
uint32_t lastCounter = 0; // 上一次的脉冲计数值(用于计算速度)
uint32_t lastTime = 0; // 上一次计算速度的时间戳(单位:ms)
char message[20] = ""; // OLED显示缓存
/*============ 中断回调函数 ============*/
// 定时器溢出中断回调函数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if (htim == &htim2) { // 仅处理TIM2的中断
loop++; // 每次中断表示定时器溢出一次(计满Period)
}
}
/*============ 主程序初始化 ============*/
int main(void) {
HAL_Init(); // 初始化HAL库
SystemClock_Config(); // 配置系统时钟
MX_TIM2_Init(); // 初始化TIM2定时器
HAL_Delay(20); // 短暂延时确保硬件稳定
OLED_Init(); // 初始化OLED屏幕
// 启动定时器中断(TIM2)
HAL_TIM_Base_Start_IT(&htim2);
/*============ 主循环 ============*/
while (1) {
OLED_NewFrame(); // 开始OLED新帧绘制
// 计算总脉冲数 = 溢出次数 * Period + 当前计数值
uint32_t counter = loop * Period + __HAL_TIM_GET_COUNTER(&htim2);
// 显示脉冲计数
sprintf(message, "Counter: %lu", counter);
OLED_PrintString(0, 0, message, &font16x16, OLED_COLOR_NORMAL);
// 计算速度(基于时间差)
uint32_t currentTime = HAL_GetTick(); // 获取当前时间戳(ms)
float deltaTime = (currentTime - lastTime) / 1000.0f; // 转换为秒
if (deltaTime > 0.05f) { // 至少间隔50ms计算一次,避免除零错误
float speed = (counter - lastCounter) * Width / deltaTime;
lastCounter = counter; // 更新上一次计数值
lastTime = currentTime; // 更新上一次时间戳
// 显示速度
sprintf(message, "Speed: %.1fcm/s", speed);
OLED_PrintString(0, 20, message, &font16x16, OLED_COLOR_NORMAL);
}
OLED_ShowFrame(); // 刷新OLED显示
HAL_Delay(50); // 主循环延时(降低CPU负载)
}
}
外部时钟模式1
外部时钟模式1如何使用?回到CubeMX,我们知道外部时钟模式1的触发器是接入到触发控制器中一个叫做从模式控制器的东西上,因此我们要使用从模式才能使用外部时钟模式1:
关掉TIM2的时钟源(Clock Source)选项,选择从模式为外部时钟模式1(External Clock Mode 1),然后就可以选择使用触发器的哪一条路输入了(Trigger Source):
选择ETR1
选择TI1_ED
只能配置滤波器了,还记得吗,TI1_ED只能是双边沿触发的产物:
不管白到黑还是黑到白都会计数:
选择TI1FP1
与TI1_ED相比,多了一个可以选择什么边沿触发:上、下、双
定时器从模式
上小节说到了,要想使用外部时钟模式1,就需要用到从模式,将从模式设置为外部时钟模式1,本小节来探究一下设置从模式时的另外三个选项是什么?
外部时钟模式1的功能是给定时器提供计数的信号,而这三种模式的功能,则是控制定时器的工作状态。
复位模式 Reset Mode
它可以对定时器的计数状态进行复位,假设从模式被配置成复位模式,由于从模式控制器不再是外部时钟模式1(而是复位模式),也就不能为定时器提供计数信号,所以我们需要使用另外的时钟源,如果单纯的计时,可以使用内部时钟,想对外部信号进行计数,也可以使用外部触发器ETR,通过外部时钟模式2接入定时器(这就是为何ETR可以独辟蹊径,跳过触发器,直接接到触发控制器的原因,即在从模式控制器被占用时,还可以从ETR引入外部信号)。
回到正题,假设我们这是使用内部时钟源,预分频器设置为0,自动重装载寄存器设置为5,另外从模式的触发源选择为TIFP1上升沿触发;那么在定时器正常的计数过程中(0~5),如果TI1输入了一个带上升沿的信号,就会从TI1FP1输出一个脉冲,进入到从模式控制器,此时从模式控制器就会执行复位模式的功能:将定时器进行复位,所谓复位操作其实与自动重装载一样,也是进行更新事件(计数器清零、设置对应的影子寄存器、触发定时器更新中断(如果开启了中断))。
来到CubeMX,按照我们上面所说的来配置,内部时钟源的频率为8MHz,我并没有修改HCLK为72MHz,因此定时事件是用8MHz来计算的,每5s发生一次中断:
我们来写代码:
#include <string.h>
#include <stdio.h>
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim2)
{
/*触发了定时器更新中断后打印字符串"自动重装载"*/
}
}
int main(void)
{
HAL_TIM_Base_Start_IT(&htim2);
int cnt = 0;
while (1)
{
cnt = __HAL_TIM_GetCounter(&htim2);
/*打印cnt,此处省略*/
HAL_Delay(500);
}
}
可以看到,正常是计数到接近4999会触发一次更新中断,在计数过程中使用循迹模块引入一个上升沿,就可以看到才技术到1300多就触发了更新中断:
这里引入一个问题:如何区分是复位模式还是自动重装载?其实就像定时器更新中断有一个更新中断标志位一样,从模式控制器在接收到触发信号后,还会设置一个触发器中断标志
回到代码中的回调函数,优化一下:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim2)
{
//自行判断触发中断标志位
if(__HAL_TIM_GET_FLAG(htim, TIM_FLAG_TRIGGER) != RESET)
{
//手动清除标志位
__HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_TRIGGER);
/*触发了触发器中断后打印字符串"从模式触发"*/
}
else
{
/*触发了定时器更新中断后打印字符串"自动重装载"*/
}
}
}
可以看到由从模式的复位模式而引发的更新中断会打印"从模式触发"
门模式 Gated Mode
保持上面的设置都不变,只不过从模式设置成门模式:当输入信号为高电平(严格讲是检测到上升沿)时,门就打开,时钟信号可以进入到定时器,定时器正常计数;当输入信号为低电平(检测到下降沿)时,门就关闭,定时器暂停计数,输入通道的边沿检测器就可以改变高低电平对门的开关控制。
来到CubeMX,将从模式改为门模式,其他无需修改,这里需要注意的是:门模式下,控制信号出现上升或下降沿,从模式控制器就会暂停或继续定时器计数,这两个边沿的时刻会将触发器中断标志位置1,但和复位模式不同,只是将标志位置1,并不复位计数器值,也不触发定时器更新中断(不调用回调函数),因此代码这样写:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim2)
{
/*触发了定时器更新中断后打印字符串"自动重装载"*/
}
}
int main(void)
{
HAL_TIM_Base_Start_IT(&htim2);
int cnt = 0;
while (1)
{
if(__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_TRIGGER) != RESET)
{
__HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_TRIGGER);
/*触发了触发器中断后打印字符串"从模式触发"*/
}
cnt = __HAL_TIM_GetCounter(&htim2);
/*每500ms打印cnt*/
HAL_Delay(500);
}
}
当循迹模块输出低电平时(由高变低),从模式触发并且一直输出一个数,证明计数器暂停了:
变为高电平后计数继续,并且也将触发中断标志位置1:
触发模式 Trigger Mode
它的作用就是检测到设定的边沿后,让定时器开始计数,不需要改代码,和门模式的代码一样,直接来看效果:
可以看到定时器没有默认启动计数,所以一直输出0:
循迹模块输出上升沿后,从模式的触发模式触发,开始计数:
后面再输入上升沿后,虽然也会打印"从模式触发",但定时器不会停止:
因此,触发模式仅能启动定时器计数,并不能停止,所以有时触发模式会配合单脉冲模式一起使用:
所谓单脉冲模式,就是定时器不再循环计数,而是计数到自动重装载值后,便停止计数,代码不需要改,直接看现象:
上升沿启动技术后5s触发自动重装载,然后计数值清零,上升沿再触发计数,循环往复。
知识点补充:定时器上电自动触发一次中断
注意到,每次程序重新启动,或者复位时,都会打印一次"自动重装载",也就是触发了定时器中断,进入了回调函数:
这是因为定时器初始化函数 MX_TIM2_Init(); 在初始化时,会将定时器更新中断标志位置1,后面调用启动函数自然就会触发中断,如果影响到了你,可以这样做:
int main(void)
{
MX_TIM2_Init();
/* USER CODE BEGIN 2 */
/*这两个函数任选一个即可,内部实现是一样的*/
__HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE);
__HAL_TIM_CLEAR_IT(&htim2, TIM_IT_UPDATE);
//在调用定时器启动函数之前清除更新中断标志位即可
HAL_TIM_Base_Start_IT(&htim2);
while(1)
{
/*-----------*/
}
}
输入捕获&超声波测距
超声波测距模块
该模块是测距常用的传感器(注意供电电压),其原理是发出一定频率的超声波,遇见被测物体后反射回来,如何测距?只需要将(发送时刻 - 接收时刻)x 声速 / 2 即可,以HC-SR04模块为例:它有一个控制端Trig,以及一个输出端Echo,当需要测量时,只需要通过GPIO口向Trig引脚发送一个脉冲信号,接收到脉冲信号后就会将Echo拉高并发出超声波,当接收到反射回来的超声波后,模块会将Echo拉低,我们测量Echo引脚高电平的持续时间,就是超声波往返所消耗的时间。
操控Trig非常简单:
测量Echo高电平持续时间也很简单:
但这肯定不是本节课要使用的方法,测量高电平读取时间浪费了太多CPU资源。
输入捕获
一句话概括其功能:当定时器输入通道上检测到上升沿(或者下降沿)时,立刻将此时计数器的数值记录到捕获寄存器中,以待程序稍后读取。
我们来了解一下它的机制,见下面这张图:隐掉一些与输入捕获无关的线路,时钟源保留内部时钟源,输入捕获的关键就是捕获寄存器,对于通用和高级定时器来说,每个输入通道都有它自己的捕获寄存器,TI1FP1经过一个预分频器后连接到捕获寄存器1上,预分频器可以进行/2 /4 /8分频,TI2FP2同理:
假设我们启动通道1(TI1)的输入捕获模式,设定为上升沿捕获,若TI1捕获到上升沿,捕获寄存器1会立刻将计数器的值复制到自身,这很好理解,并且如果我们还为此输入捕获开启了中断,就还会触发"输入捕获中断",通知程序尽快读取;那么只要再获取到下降沿出现时定时器的时刻,两者相减就可以知道时间了,不过一个通道的输入捕获只能捕获上升或下降沿;
能不能将信号同时输入到通道1和通道2?然后一个捕获上升沿,一个捕获下降沿?难道要在模块的Echo引脚引出两根线?
别人早帮我们考虑好了,STM32又从TI1和TI2上分别引出一条线连接到对方,这两根线就是我们之前按下不表的TI1FP2和TI2FP1,如果信号从TI1的GPIO口引入,则输入通道1(TI1)叫做输入捕获的直接模式,TI2叫做间接模式,反之同理:
一直被我们忽视的TI3和TI4也是一对,拥有和TI1与TI2一模一样的结构,唯一的区别是TI3FP3和TI4FP4没有接入到从模式控制器中(从FP后面的数字就可以看出谁和谁是一对的),总体如下:
话不多说,来到CubeMX,超声波模块的Trig引脚我接到了PA11,Echo引脚接到了PA10,首先将PA11设置成GPIO的推挽输出:
点击PA10发现,它对于TIM1的通道3:
因而来到TIM1:
然后来到下面的详细设置,在此之前我设置了HCLK为72MHz,高速外部时钟HSE为晶振,为了方便计算,定时器的分频值为72分频:
我们需要用到定时器的输入捕获中断,来到NVIC选项卡,开启TIM1捕获/比较中断:
搞定后生成代码,然后我们先来认识输入捕获相关的函数和回调函数:
/* 输入捕获启动函数
* 第一个参数是定时器句柄
* 第二个参数是定时器的哪个输入通道?
* 输入通道:TIM_CHANNEL_1~4
*/
HAL_TIM_IC_Start(TIM_HandleTypeDef *htim, uint32_t Channel);
//中断模式的启动函数
HAL_TIM_IC_Start_IT(TIM_HandleTypeDef *htim, uint32_t Channel);
//定时器输入捕获回调函数 给出模板
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
//养成习惯判断是哪个定时器触发的回调函数
//这里还要判断是哪个通道触发的输入捕获回调函数
//htim的Channel属性,每次进入中断回调之前都会被重新赋值
//赋值:HAL_TIM_ACTIVE_CHANNEL_1~4
if(htim == &htim1 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_4)
{
}
}
特别注意的是htim句柄的Channel属性和启动函数的Channel不同!!!
main.c
/* USER CODE BEGIN 0 */
int upEdge = 0;
int downEdge = 0;
float distance = 0;
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim1 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_4)
{
upEdge = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_3);
downEdge = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_4);
//1us计数值+1
//(downEdge - upEdge)就是具体多少us
//声速340m/s转化为0.034cm/us
//因为是往返,所以÷2
//最后的单位是cm
distance = ((downEdge - upEdge) * 0.034) / 2;
}
}
/* USER CODE END 0 */
int main(void)
{
//没有用到定时器更新中断,因此不需要IT后缀
HAL_TIM_Base_Start(&htim1);
//启动输入捕获的函数
//通道3捕获到上升沿,还不急着读
HAL_TIM_IC_Start(&htim1, TIM_CHANNEL_3);
//通道4捕获到下降沿后,就要读了,因此带IT后缀
HAL_TIM_IC_Start_IT(&htim1, TIM_CHANNEL_4);
while (1)
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_11, GPIO_PIN_SET);
HAL_Delay(1);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_11, GPIO_PIN_RESET);
//模块启动后将计数器的值清零,避免捕获完上升沿后直接重装载了
__HAL_TIM_SetCounter(&htim1, 0);
/*等待模块测量*/
HAL_Delay(20);
/*通过串口或者OLED屏幕显示出来*/
//防止测量过快
HAL_Delay(500);
}
}
输出比较&PWM脉冲宽度调制
PWM
PWM信号就是方波,一组高低电平所占用的时间为周期,周期倒数为频率,占空比为高电平在一个周期里所占的比例,方波的占空比为50%:
PWM就是一种用数字信号尽可能地模式模拟信号的技术:
那么如何输出PWM波?用输出比较模式
输出比较模式
回到定时器框图,时钟源还是选择内部时钟源,并隐掉不相干的线路;在输出比较模式下,捕获寄存器摇身一变,改名成了比较寄存器,用于输入脉冲信号的GPIO口也变为了输出脉冲信号,这里以通道1为例:在输出比较模式下,我们要首先向比较寄存器中写一个数值,然后定时器会一直比较计数器值与比较寄存器数值的大小关系,根据此大小关系来决定输出有效电平还是无效电平,有这么几种模式:
冻结模式:
这种模式下,输出通道的GPIO口不理会比较结果,维持旧的输出状态
强制有效:
不理会比较结果,强制输出通道输出有效电平
强制无效:
与上面相反,强制输出无效电平
匹配时有效:
当计数器值与比较寄存器完全相等时,输出有效电平,如果已经是有效电平,就继续维持
匹配时无效:
相等时,输出无效电平
匹配时翻转:
顾名思义,这个模式就可以输出占空比50%的PWM波
如果要输出任意占空比的PWM信号,要使用专门为此设计的PWM模式,此模式也有两种:
但这是基于计数器向上计数模式下,还有向下计数和中央对齐模式,但只做了解即可,一般都是用向上计数模式,还有我一直说的有效电平和无效电平,STM32在输出模式最后加了一个输出控制器,它的作用就是设置输出模式输出有效电平时,对应GPIO口为何种电平,一般都设置成高电平为有效电平,低电平为无效电平。
一言以蔽之:PWM模式时生成PWM信号的关键,设定一个自动重装载值,让计数器从0数到自动重装载值,周而复始,也就确定了PWM的周期和频率;而给某个通道的比较寄存器设定一个值,PWM模式让此通道在计数器小于比较寄存器时输出一种电平,大于时输出另一种电平,通过调节比较寄存器值的大小,也就能控制PWM的占空比。
接下来就来到CubeMX,产生PWM信号,并不断调节占空比实现呼吸灯的效果,我的三色LED分别接在了PA6、PA7、PB0引脚,它们分别对应着TIM3的通道1、2、3,因此来到TIM3的配置界面,时钟源选择内部时钟,通道1的功能设置为输出比较(Output Compare CH1),下方的详细配置可以选择是冻结、匹配时有效、强制有效等等没啥用的模式:
来到本小节重点,通道1设置成PWM模式:
详细设置如下,在此之前设置HCLK为72MHz,因此APB1的时钟线也是72MHz,TIM3连接在APB1上,因此用72MHz来计算:
一切就绪后生成代码,先来看看PWM的相关函数:
/* PWM启动函数
* 第一个参数是定时器句柄
* 第二个参数是哪个通道:TIM_CHANNEL_1~4
*/
HAL_TIM_PWM_Start(TIM_HandleTypeDef *htim, uint32_t Channel);
/*PWM停止函数*/
HAL_TIM_PWM_Stop(TIM_HandleTypeDef *htim, uint32_t Channel);
/* 设置比较寄存器的值
* 前两个参数就不讲了
* 第三个参数为值
*/
__HAL_TIM_SetCompare(&htimx, TIM_CHANNEL_x, x);
来看看实现呼吸灯的代码:
int main(void)
{
//这样PWM就被开启了,按照我们的设置,0.1ms为周期,每秒生成10000此占空比为50%的PWM波
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
while (1)
{
for(int i = 0; i < 100; i++)
{
__HAL_TIM_SetCompare(&htim3, TIM_CHANNEL_1, i);
HAL_Delay(10);
}
for(int i = 99; i >= 0; i--)
{
__HAL_TIM_SetCompare(&htim3, TIM_CHANNEL_1, i);
HAL_Delay(10);
}
}
}
占空比=比较寄存器的值/自动重装载寄存器的值+1
旋转编码器
最常见的就是增量型旋转编码器:
一般有A、B两相输出信号,顺时针旋转时:B相方波领先A相90度,即A相上升沿时,B相高电平,A相下降沿时,B相低电平;逆时针旋转反过来:
我们就可以通过计数A相或者B相上升沿或下降沿的数量,获得编码器旋转角度;还能根据A相边沿时B相的电平情况,得知旋转方向。
对于增量式旋转编码器的使用,一个简单的思路是,将A、B相信号接入到GPIO口后,将A相GPIO口设置为上升沿(或下降沿)触发中断,在中断回调函数里读取B相GPIO口的电平,来判断旋转方向,并且根据旋转方向对计数值+1或者-1,来记录脉冲数量,知道每个脉冲的旋转角度,就可以知道转了多少角度。
上面那种方法固然可以,但如果旋转速度过快,就会频繁调用回调函数;其实通用/高级定时器为增量型编码器准备了专门的编码器接口,只要将A、B两相信号同时输入进去,就可以实现正转时计数器自增,反转时计数器自减;这里要注意:编码器接口对上升沿和下降沿都敏感,所以对于一组脉冲会计数两次。那编码器接口是哪里呢?其实就是我们早已了解的TI1FP1和TI2FP2:
来到CubeMX,在此之前先看看旋转编码器的原理图:
A相和B相分别接到了PA8和PA9(TIM1的通道1和通道2),编码器不止能旋转,还能按下,引脚为PB15:
来到TIM1,因为是记编码器的脉冲数,所以不需要设置时钟源,找到组合通道(Combined Channels)设置,选择为编码器模式(Encoder Mode):
来到下面的详细设置,注意这里的Encoder Mode,可选TI1或TI2,也可以两个通道都计数,但记住我之前说的,编码器接口对上升沿和下降沿都敏感,如果选择两个通道都计数,那么每次脉冲计数值会+4
如果要使用编码器的按键功能,记得看原理图有没有上拉电阻,如果没有,要用GPIO的上拉输入;好了,我们就来实现旋转编码器调节LED灯的亮度功能,假设我的小灯接在了PA6(即TIM3的通道1),来复习一下PWM模式的设置:TIM3使用内部时钟源,通道1使用PWM模式,预分频为72-1,自动重装载值为100-1,其他保持默认(比较寄存器的值在代码里写,这里可以先不动):
完成后来写代码,先认识一下编码器模式的函数:
/*编码器启动函数*/
/*对于我们这种有A、B两相的编码器,第二个参数填TIM_CHANNEL_ALL表示启动所有通道*/
HAL_TIM_Encoder_Start(TIM_HandleTypeDef *htim, uint32_t Channel);
我们先看看编码器旋转和计数器数值之前的改变关系,代码如下:
int main(void)
{
int cnt = 0;
HAL_TIM_Encoder_Start(&htim1, TIM_CHANNEL_ALL);
while (1)
{
cnt = __HAL_TIM_GetCounter(&htim1);
/*用串口或OLED屏幕显示出来*/
HAL_Delay(100);
}
}
初始值为0,顺时针旋转后值变成了65534,继续顺时针旋转,数值会不断变小,且步长为2;逆时针旋转,数值变大,65534后变回0,然后不断变大;想要更符合常理,回到CubeMX,很简单:
对于计两次数的问题:直接进行一个2分频就行了,预分频值为2-1:
计数改变和旋转方向的问题:我们改变TI2的极性就可以了,让TI2的波形翻转:
问题完美解决,来实现旋转编码器控制LED亮灭程度吧:
int main(void)
{
int cnt = 0;
HAL_TIM_Encoder_Start(&htim1, TIM_CHANNEL_ALL);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
while (1)
{
cnt = __HAL_TIM_GetCounter(&htim1);
//如果计数值大于60000,肯定反转小于0,因此反转限制为0
if(cnt > 60000)
{
cnt = 0;
__HAL_TIM_SetCounter(&htim1, 0);
}
//正转也限制为100,因为要根据计数值来设置占空比
else if(cnt > 100)
{
cnt = 100;
__HAL_TIM_SetCounter(&htim1, 100);
}
//自动重装载值为99,占空比为cnt/(99+1)
__HAL_TIM_SetCompare(&htim3, TIM_CHANNEL_1, cnt);
/*用串口或OLED屏幕显示cnt即可*/
HAL_Delay(100);
}
}
ADC模拟-数字转换技术
STM32使用是逐次逼近法(SAR),通过不断进行二分比较最终确定电压值的方法,STM32F103是12位分辨率的ADC,即最终结果是以12个二进制位存储(0~4095),其中0代表0V,4095代表参考电压的值(一般为3.3V),得到测量后的12位二进制数据后,将其除以4095再乘以参考电压3.3V,便可以知道测得的电压值。
在STM32中,有16个GPIO口可以进行电压值的采样工作,称其为16个ADC采样通道,不过STM32F103C8T6只有前10个通道,另外还有两个内部通道,用于采集STM32内部提供的电压值:一个连接在芯片内部的温度传感器上,一个连接在内部参考电压上。
有两个用于转换的ADC结构:ADC1和ADC2,每个ADC中有注入组和规则组,注入组我们先忽略,规则组可以理解为"普通组",它就像一个用于注册排队的表格,我们将某个ADC通道"注册"在上面,当我们触发ADC时,ADC就会对此通道进行采样、转换,转换结果放入"规则通道数据寄存器"中等待程序读取,我们甚至可以"注册"多个通道,让ADC依次进行转换,这个在下小节来讲:
话不多说,来到CubeMX,假设我们有一个滑动变阻器,原理图如下,我们来测量它的电压值:
点击PA5,发现它可以设置为ADC1或ADC2的通道5,我们选择ADC1:
单次转换模式
找到ADC1的设置,打开通道5,其他先不动:
发现时钟设置里出现了错误,这是因为ADC1、2都是依靠APB2的时钟线,并且频率不宜过快,在F103上不要超过14MHz,因此将ADC专用的分频器改为/6:
先来认识ADC的相关函数:
/* ADC启动函数
*/
HAL_ADC_Start(ADC_HandleTypeDef* hadc);
/* 读取ADC值
*/
HAL_ADC_GetValue(ADC_HandleTypeDef* hadc);
/* 等待转换完成的函数
* 一直轮询检查是否转换完成
* 第二个参数是超时时间,一般传入HAL_MAX_DELAY表示无限等待
*/
HAL_ADC_PollForConversion(ADC_HandleTypeDef* hadc, uint32_t Timeout);
/* ADC校准函数
* 建议在每次上电后执行一次校准
*/
HAL_ADCEx_Calibration_Start(ADC_HandleTypeDef* hadc);
好了来写代码,十分简单:
int main(void)
{
int value = 0;
float voltage = 0.0;
HAL_ADCEx_Calibration_Start(&hadc1);
while (1)
{
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, HAL_MAX_DELAY);
value = HAL_ADC_GetValue(&hadc1);
voltage = (value / 4095.0) * 3.3;
/*串口或屏幕显示出来*/
HAL_Delay(500);
}
}
上面的程序是靠HAL_ADC_Start函数触发一次采样与转换,等待转换完成后读取测量结果,其实STM32的ADC还为我们提供了一种连续转换功能。
连续转换模式
开启此功能后,我们只需要触发一次ADC,ADC在完成一次转换后,会马上进行下一次的采样转换工作,持续不断更新寄存器里的测量结果,随时读取即可:
开启方法也很简单,来到CubeMX,开启持续转换模式即可:
来看看代码有什么不同,把启动函数和等待转换完成函数放在循环前即可:
结果如下:
ADC多通道采集功能
本小节来同时采集电位器(滑动变阻器)、NTC热敏电阻、芯片内部温度、芯片内部参考电压。我们之前说过,ADC的规则组好像一个用来注册排队的表格,当时我们在ADC1的规则组上只注册了通道5,检测电位器的电压值,当我们启动一次ADC,规则组就会对通道5进行一次采样&转换,转换完成后,结果放进规则通道数据寄存器中,并且将ADC状态寄存器的转换结束标志位置1。当我们调用HAL_ADC_GetValue函数读取寄存器时,转换结束标志位会被自动置0,以待下一次转换,如果我们还开启了连续转换功能,就可以只启动一次ADC,ADC就不断对通道5进行采样转换。
本小节我们会将通道5,通道4,内部温度传感器通道,内部参考电压通道都注册到规则组上来,并且开启ADC的扫描模式,这样每次触发ADC测量时,ADC就会先采样&转换通道5,将结果放在规则通道数据寄存器中,紧接着采样&转换通道4,转换完通道4结果也放入规则通道数据寄存器中,紧接着转换内部温度传感器通道,同样的,最后转换内部参考电压通道;通过ADC的扫描模式,我们就可以在一个ADC上连续测量多个通道的电压值;
但有一个问题是,每个通道的数据转换完成后,程序可能还没来得及读取,下一个通道的转换就完成了,导致旧数据被覆盖,而且程序也不好区分当前从数据寄存器中取出的数据,到底是哪个通道的转换结果;因此需要用到DMA,我们可以告诉DMA,我们有一个大小为4的数组,要将规则组中的数据依次搬运到此数组中;这样,每次转换完成,转换结束标志位被置1时,转换结束事件就会通知DMA进行搬运;如果我们给ADC开了连续转换模式,还可以给DMA设置循环模式;这样的话ADC连续不断地依次对四个通道进行转换,DMA也同时依次循环搬运转换数据到数组中,形成配合。
单次转换模式
话不多说,来到CubeMX,NTC电阻的原理图如下,接在了PA4引脚,也是ADC1的通道4:
来到ADC1的设置,打开这四个通道:
来到下面的详细设置:
内部参考电压的存在,是因为我们不能保证参考电压一定稳定在3.3V,所以STM32在内部提供了一个一直输出1.2V的内部参考电压,然后我们测得内部参考电压通道的ADC值,与1.2V进行计算,就能得出真正的参考电压:
根据芯片手册,内部参考电压的采样时间典型值是5.1us,按照我们设置的ADC时钟周期为12MHz,5.1us就是61.2个周期,因此采样周期我都设置成了71.5个周期。为了使用DMA搬运,来到DMA选项卡:
保持默认即可,每次传输的数据宽度为半字,即16位,我们是12位分辨率的ADC,因此16位足够了,一切就绪生成代码,我们先打印这四个通道的ADC值,看看行不行的通:
#include <stdio.h>
uint16_t values[4];
char mes[50] = "";
//ADC转换完成回调函数
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
if(hadc == &hadc1)
{
sprintf(mes, "%d %d %d %d", values[0], values[1], values[2], values[3]);
//串口打印
}
}
int main(void)
{
//校准
HAL_ADCEx_Calibration_Start(&hadc1);
while (1)
{
//第三个参数是要搬运多少次
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)values, 4);
/*假设这里有其他任务*/
HAL_Delay(500);
}
}
成功打印:
除了在循环里触发转换,然后等待中断回调,我们也可以像之前一样,让其持续转换,转换完一遍后,马上进行下一轮转换
连续转换模式
回到CubeMX,将连续转换模式开启:
还记得组合拳吗,来到DMA选项卡,将DMA模式改为Circular循环模式:
这样内存地址在自增时,到了最后一个后,就会回到开头,就能和持续模式配合起来,生成代码试试,因为在一直转换,我们可以随时取数据,所以就不需要中断回调函数了:
#include <stdio.h>
int main(void)
{
//校准
HAL_ADCEx_Calibration_Start(&hadc1);
//第三个参数是要搬运多少次
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)values, 4);
uint16_t values[4];
char mes[50] = "";
while (1)
{
sprintf(mes, "%d %d %d %d", values[0], values[1], values[2], values[3]);
//串口打印
HAL_Delay(500);
}
}
运行结果是一样的。
RTC实时时钟
RTC的结构非常像一个简化版的定时器,核心当然是一个计数器,与定时器的16位计数器只能从0计数到65535不同,RTC的计数器是32位的,可以从0技术到4294967295,RTC的计数器前还有个RTC预分频器,可以将时钟源的时钟信号分频为1Hz的信号;我们往往会采用unix时间戳的形式在RTC计数器中记录时间,unix时间戳是一种在计算机领域通用的时间表示方式,其记录的是从1970年1月1日0时0分0秒到当前时刻的秒数,一些使用有符号32位整数记录unix时间戳的系统,其仅能计数到2038年,随后就会溢出,不过我们的RTC计数器无需符号位,可以从1970年记录到2106年。
我们通过将3.3V接入VDD引脚为其供电,以执行我们的代码程序,而VDD掉电后,程序便无法继续执行。那RTC时钟如何保持掉电后继续走时呢?STM32还为我们提供了VBAT引脚,即使VDD已经掉电,只要我们继续为VBAT引脚供电,STM32就可以维持芯片上一块叫做后备区域的地方继续运行,后备区域内的功能比较简单,耗电很少,因而我们就可以在PCB电路板上板载一颗纽扣电池为其供电,而RTC就在这后备区域中。
接入到RTC的时钟源有三种:LSE(低速外部时钟)、LSI(低速内部时钟)、HSE(高速外部时钟)的128分频。但只有LSE可以在VDD掉电后继续提供时钟信号,因而通常选择低速外部时钟LSE作为RTC时钟信号。其频率为32.768KHz,后经分频器分频为1Hz。另外STM32的RTC上还有简单的闹钟功能,往闹钟寄存器上设置一个时间戳,计数器与闹钟寄存器数值相等时,触发对应的"闹钟中断"。
除了RTC时钟,后备区域内另一个主要功能叫做备份寄存器,在我们使用的STM32F103C8T6芯片中,有10个16位的备份寄存器可以用于存储数据,这些寄存器在VDD掉电后依旧靠VBAT维持,因而可以掉电不丢失,并且配有入侵检测功能,产品可以设计使得外壳被开启后给予入侵检测引脚信号,即使是设备外部供电已经断掉,备份寄存器中的数据也能被清除,适合防止重要数据。此外由于均是VBAT供电,VBAT掉电后RTC与备份寄存器中的数据均会丢失,因而备份寄存器也可以用来检测RTC时钟是否被设置过,或者时间是否因为VBAT掉电而丢失。
为了兼容F407、H743等具有独立日期寄存器的芯片,HAL库的RTC时钟部分将日期与时间分开处理,导致未将日期数据记录在RTC计数器中,从而使得日期数据会在VDD掉电后丢失,另外默认生成的代码每次都会初始化RTC,初始化过程中会使得RTC停止运行一小会,导致每次程序重启时RTC时间会变慢一点,因而本小节我们会写一个自己的RTC时钟库,方便日后使用。
话不多说,来到CubeMX,将Debug模式设置为Serial Wire,RCC里设置高速外部时钟(HSE)源和低速外部时钟(LSE)源为晶振:
点开Timer就可以看到RTC的设置,只需要勾选Activate Clock Source就可以将其开启:
如果勾选Activate Calendar,那么在下方的详细设置还可以直接设置一个时间(年月日时分秒),不过我们要自己写库,因此不需要;RTC OUT可以将RTC的信号输出到GPIO口上;Tamper是刚刚提到的入侵检测功能的开关;看到下面的详细设置,第一个是数据格式,我们自己写库,因此无需理会;Auto Predivider Calculation自动计算预分频器要开启,如果关闭了在下方的Asynchronous Predivider value需要我们自己计算填写预分频器的值;Output是与上面的RTC OUT功能配合的,用于选择将哪种信号输出到GPIO口,无需理会。
来到时钟设置,将主频设置为72MHz,左上角是对RTC时钟的设置,为了RTC的断电走时,我们需要将其修改到LSE低速外部时钟:
一切就绪后生成代码,在工程文件夹的Core目录下的Inc文件夹和Src文件夹里新建myrtc.h和myrtc.c文件,然后在keil5里添加文件:
先不急着直接写代码,我们要在HAL库的基础上进行改进,因此先来看看HAL库是怎么写的:
HAL库RTC库
来到stm32f1xx_hal_rtc.c文件,在704行找到HAL_RTC_SetTime函数,在下方还有一个设置日期的函数HAL_RTC_SetDate,因而HAL_RTC_SetTime函数不涉及年月日数据,在其代码中也可以印证这点:
其将时分秒数据单位均换算为秒并相加,随后在内部调用了RTC_WriteTimeCounter函数,这就是用来设置RTC的计数器的函数:
我们自己的库也要设置RTC的计数器,因此点击这个函数的定义,看看其内部实现:
为什么要进入和退出初始模式?根据芯片手册得知:
必须要设置RTC_CRL寄存器中的CNF位,才能对RTC的计数器进行写操作,并且如果之前的写操作未完成,就不能进行下一次写操作;可以对RTC_CR寄存器中的RTOFF状态位进行查询,来确认上一次写操作是否完成。
因此,我们自己的RTC库要写设置时间函数的话,首先是将年月日时分秒信息转化为unix时间戳,然后调用RTC_WriteTimeCounter函数,设置RTC的计数器为此时间戳,这个函数被static关键字声明了,因此在我们的.c/.h文件里无法调用,后面我们直接复制过来就行了。
自己实现RTC库函数
先将进入与退出初始模式的函数复制到myrtc.c中,然后是RTC_WriteTimeCounter,写计数器的函数,顺便将其上面的读计数器的函数RTC_ReadTimeCounter也复制过来,在myrtc.h里引用stm32f1xx_hal.h和rtc.h解决报错,现在我们来写自己的时间设置函数,仿照HAL库的写法:
我们的返回值也为HAL_StatusTypeDef,第一个参数由于我们在myrtc.h中包含了rtc.h,这里边就声明了这个指针,因此我们直接使用就行,不需要传入指针句柄;后面两个参数是HAL库自己定义的时间结构体以及时间格式,我们使用C语言提供的用于表示日期和时间的结构体tm比较好,在myrtc.h中包含time.h,tm结构体内有这些成员变量:
我们要做的事很简单,将传进来的tm类型的参数,转换为32位的unix时间戳,然后将其设置到RTC计数器就好了,即RTC_WriteTimeCounter函数,关于将tm类型转换为unix时间戳的方式,time.h里帮我们提供了函数,调用mktime函数即可完成转换,传入参数也是struct tm的指针类型;然后返回写计数器的函数就好了:
HAL_StatusTypeDef sakabu_RTC_SetTime(struct tm *time)
{
uint32_t unixTime = mktime(time);
return RTC_WriteTimeCounter(&hrtc, unixTime);
}
接下来写读取RTC时间的函数,返回值为tm的指针,没有入参;思路很简单,读取RTC计数器中的时间戳,转换为tm类型即可;读取RTC的函数我们已经复制过来了,RTC_ReadTimeCounter,接收变量的类型需要注意一下,需要使用time.h提供的time_t(64为位),而不是uint32_t,然后就可以使用time.h的时间戳转换为结构体tm的函数gmtime进行转换,其接收time_t类型的指针:
struct tm* sakabu_RTC_GetTime(void)
{
time_t unixTime = RTC_ReadTimeCounter(&hrtc);
return gmtime(&unixTime);
}
在myrtc.h中声明就可以来写代码试试效果了:
#include "myrtc.h"
#include <stdio.h>
#include <string.h>
int main(void)
{
char mes[50] = "";
struct tm *now;
struct tm time = {
//年要存储的是年份和1900年的差值
.tm_year = 2025 - 1900,//2025
//月份的取值是0~11,代表1到12月
.tm_mon = 1-1,//1月
.tm_mday = 1,
.tm_hour = 23,
.tm_min = 59,
.tm_sec = 55,
};
sakabu_RTC_SetTime(&time);
while (1)
{
now = sakabu_RTC_GetTime();
sprintf(mes, "%d-%d-%d %02d:%02d:%02d",now->tm_year + 1900, now->tm_mon + 1, now->tm_mday,
now->tm_hour, now->tm_min, now->tm_sec);
/*用串口输出*/
HAL_Delay(1000);
}
}
运行效果和我们预期的一样:
不过每次复位都会重新从1月1号23点59分55秒开始计时,这就需要用到我们之前说的备份寄存器,实现掉电不丢失(VDD掉电后由VBAT引脚供电,我这里用的是纽扣电池)。思路如下:我们可以在初始化设定时间时,同时往一个备份寄存器中写一个数据,每次启动代码时,检查此备份寄存器中是否有我们写入的数据,如果是,说明已经设定过时间,就跳过此步骤;否则便设置时间并写此备份寄存器:
回到myrtc.c,写一个RTC初始化函数sakabu_RTC_Init,这里提前预知一个bug,就是一直复位的话,RTC会停止运行,这也是之前说过的HAL库的bug:是因为每次复位后运行的MX_RTC_Init函数中的这一句:
其在初始化RTC的过程中会值RTC停止运行一小会,解决思路和之前一样,只要初始化过RTC后,就不用每次都初始化了:
#define RTC_INIT_FLAG 0xAAAA
void sakabu_RTC_Init(void)
{
//读取备份寄存器的函数,10个寄存器
uint32_t initFlag = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1);
if(initFlag == RTC_INIT_FLAG) return;
//没有设置过RTC才执行这一句
if (HAL_RTC_Init(&hrtc) != HAL_OK)
{
Error_Handler();
}
struct tm time = {
//年要存储的是年份和1900年的差值
.tm_year = 2025 - 1900,//2025
//月份的取值是0~11,代表1到12月
.tm_mon = 1-1,//1月
.tm_mday = 1,
.tm_hour = 23,
.tm_min = 59,
.tm_sec = 55,
};
sakabu_RTC_SetTime(&time);
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, RTC_INIT_FLAG);
}
最后,修改一下CubeMX自动生成的初始化函数MX_RTC_Init,在rtc.c中,先添加我们自己的头文件:
然后这样修改,把原本的初始化函数移到BEGIN RTC_Init 0注释对中,再调用我们自己写的sakabu初始化函数,直接return跳过原本的代码即可:
后续会持续更新。。。希望大家点赞支持一下!