实验和一些好用的工具

欢迎来到 波特律动 | 波特律动 (keysking.com)

串口调试助手、蓝牙调试助手

波特律动 串口助手 (keysking.com)

OLED驱动库、图片字体取模波特律动LED字模生成器 (baud-dance.com)

讲解视频

【工作STM32】第10集 STM32串口DMA模式与收发不定长数据 | keysking的stm32教程_哔哩哔哩_bilibili

GPIO

GPIO介绍

GPIO是通用输入输出端口(General-purpose input/output)的英文简写,是所有的微控制器必不可少的外设之一,可以由STM32直接驱动从而实现与外部设备通信、控制以及采集和捕获的功能。STM32单片机的GPIO被分为很多组,每组有16个引脚,不同型号的MCU的GPIO个数是不同的,比如STM32F103C8T6只有PA、PB以及个别PC引脚而STM32F103ZET6拥有PA~PG的全部112个引脚。所有的GPIO都有基本的输入输出功能,同时GPIO还可以作为其它的外设功能引脚

作为STM32最基本的外设,GPIO最基本的输出功能是由STM32控制 引脚输出高低电平,比如可以把GPIO接LED灯来控制其亮灭,也可以接继电器或者三极管,通过继电器或三极管来控制外部大功率电路的通断。

GPIO工作模式

  • 输入模式:
    • 浮空输入
    • 上拉输入(内部上拉和外部上拉)
    • 下拉输入
    • 模拟输入
  • 输出模式:
    • 推挽输出(PP):高低电平均有驱动能力(一般使用)
    • 开漏输出(OD,open drain):高电平相当于高阻态,没有驱动能力,低电平有驱动能力(特殊使用)
    • 开漏复用输出
    • 推挽复用输出

VCC:C=circuit,表示电路的意思,即接入电路的电压。

VDD:D=device,表示器件的意思,即器件内部的工作电压。

VSS:S=series,表示公共连接的意思,通常指电路公共接地端电压。

STM32基础入门——GPIO详解_stm32gpio采集0、1信号-CSDN博客

STM32-GPIO介绍_stm32 gpio-CSDN博客

点灯

STM32启动流程

启动文件

startup_stm32xxx.s(汇编文件)文件具体工作

RCC

stm32上的**RCC(Reset and Clock Control)**外设,是复位和时钟控制的英文缩写。简单理解为’‘心跳’’

复位

可见参考手册

  • 系统复位
  • 电源复位
  • 后备域复位

原理图上有复位电路

时钟

【STM32】系统时钟RCC详解(超详细,超全面)_rcc时钟-CSDN博客

时钟源

STM32中能够主动发出时钟信号的元器件,可以用作时钟源。STM32中有四个时钟源,还有一个辅助时钟源生成倍频时钟信号的器件锁相环。

  • HSI(高速内部时钟)

时钟信号由内部RC震荡电路提供,时钟频率为8MHz,但是这个时钟频率会随着温度产生漂移,很不稳定,所以一般不使用此时钟信号

  • HSE(高速外部时钟)

时钟信号由外部晶振提供,时钟频率一般在4-16MHz,是经常会用到的时钟源

这里的外部晶振可以是有源晶振,也可以是无源晶振,它们的区别在于与STM32的 连接方式,以及需不需要谐振电容

  • LSI(低速内部时钟)

时钟信号由内部RC振荡电路提供,时钟频率一般为40KHz,这个信号一般用于独立看门狗时钟

  • LSE(低速外部时钟)

时钟信号由外部晶振提供,时钟频率一般为32.768KHz,这个信号一般用于RTC实时时钟

  • PLLCLK(锁相环倍频时钟)

PLL锁相环是辅助产生时钟信号的器件。PLL并不是自己产生的时钟源,而是通过倍频得到的时钟。将时钟信号输入锁相环,锁相环可以将这个时钟信号的频率按照指定倍率提高(倍频)之后,再输出。

与锁相环具有相反作用的是分频器,分频器可以将输入时钟信号分率按照指定倍率降低之后,再输出。简单理解分频就是做除法

系统时钟SYSCLK

系统时钟SYSCLK可来源于三个时钟源

  • HSI振荡器时钟
  • HSE振荡器时钟
  • PLLCLK时钟

配置时钟树

STM32CubeMX一般填入指定最大即可

但对STM32上的时钟,具体怎么配置,根据需求决定。时钟频率选取越高,功耗也会越高。

所以实际情况中考虑芯片的工作条件,根据芯片运行的工作条件选取时钟频率

相关AHB、APB1、APB2总线上的时钟工作条件可以在STM32数据手册的通用工作条件模块可以查看到满足的条件

中断和异常

中断概念:中断是指计算机运行过程中,出现某些意外情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续执行。

STM32中断触发后流程

触发中断->中断向量表(存放中断服务函数地址)->偏移:入口地址

中断可以分为内部中断外部中断

外部中断:STM32说的中断一般指这个外部中断,发生在处理器外部连接的设备或外部信号,例如按键按下、外部传感器信号变化等。外部中断用于响应外部事件,并及时处理相关任务。

异常(内部中断):异常和中断概念相近,异常可以说是内核活动(处理器内部)产生,比如执行未定义指令、除零运算等发生在CPU内部的意外事件。这些异常的发生,会引起CPU运行相应的异常处理程序,因为发生在处理器内部,故叫做内部中断

:中断一般指连接到内核的**外部器件(外设)**产生,。使用一般不严格区分中断和异常,但无论是异常还是中断,都会引起程序执行偏离正常的流程,转而去执行异常/中断的处理函数。

中断优先级

中断优先级分为两种

  1. 可编程

  2. 不可编程

STM32的中断优先级,决定着内核优先响应谁的中断请求:

  • 中断优先级数值越小,优先级越大,中断会被优先响应
  • 中断优先级按照优先级分组配置

在STM32的芯片参考手册中可以找到中断向量表,里面可找到优先级数值等信息

优先级分组

在优先级分组中存在抢占优先级子优先级

分组个数和各优先级数值图片:

stm32入门篇–中断的初步认识及其优先级和分组_中断优先级和分组-CSDN博客

  1. 通过优先级分组,我们可以管理中断的响应顺序

  2. 只有抢占优先级才具有抢占中断的权限,打断了就发生了中断嵌套

中断嵌套:中断嵌套是指中断系统正在执行一个中断服务时,有另一个抢占优先级更高的中断提出,这时会暂时终止当前正在执行的级别较低的的,去处理级别更高的中断源,待处理完毕,再返回到被中断了的的过程。

例如:B中断正在执行,突然发生了A中断,但是A中断的抢占优先级数值更小比B的更小(数值更小, A抢占优先级更高),A中断则抢过B中断的使用权,响应A的中断服务函数,A中断执行完毕后再交回B继续执行。

  1. 如果中断抢占优先级相同,将不会发生抢占行为(只能乖乖的排队等待),这就叫做中断挂起

  2. 如果多个在挂起状态的中断具有相同的抢占优先级,则子优先级高的先响应,如果子优先级也相同,则由IRQ编号(中断编号)小的响应

1
2
3
4
5
6
7
8
9
10
/*一些中断编号的定义,一般在芯片头文件中找到,下列为stm32f103xe.h*/ 
NonMaskableInt_IRQn = -14,
HardFault_IRQn = -13,
MemoryManagement_IRQn = -12,
BusFault_IRQn = -11,
UsageFault_IRQn = -10,
SVCall_IRQn = -5,
DebugMonitor_IRQn = -4,
PendSV_IRQn = -2,
SysTick_IRQn = -1,

总结:抢占优先级>子优先级>IRQ编号。

  1. 可编程的优先级,可以通过**嵌套向量中断控制器(NVIC)**实现

NVIC

NVIC(Nested Vectored Interrupt Controller)-嵌套向量中断控制器,是STM32系列微控制器中的中断控制器模块,其主要功能如下:

  1. 中断优先级管理
  2. 中断使能和禁止
  3. 中断嵌套
  4. 中断状态控制
  5. 中断向量表管理

通常在STM32CubeMX中开启各EXTI线的中断

有关EXTI和NVIC的介绍:

STM32的中断系统详解(嵌入式学习)_stm32中断嵌套-CSDN博客

EXTI

EXTI(External interrupt/event controller),是STM32的外部中断/事件控制器,是STM32的外设,用于处理外部引脚的中断请求。EXTI模块与NVIC紧密合作,使得处理器能够响应外部事件并执行相应的中断处理程序。

通过EXTI线,捕获EXTI线事件,并且去生成中断,在中断回调函数中,翻转LED状态,并且清除EXTI中断标志(中断标志要清除不然就会一直产生中断)。

使用外部中断模块特性:

对于STM32来说,想要获取的信号是外部的很快的突发信号。由外部驱动,STM32只能被动读取。

实验

外部按键中断控制LED亮灭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "main.h"
#include "gpio.h"
#include "tim.h"

void setup()
{

}

void loop()
{

}

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{

if (GPIO_Pin == KEY0_Pin)
{
HAL_GPIO_TogglePin(LED2_GPIO_Port,LED2_Pin);
}

}

测试时发现灯的行为有一些奇怪,比如闪烁,不完全亮等情况会发生,所以需要按键消抖。

按键消抖:

当用户按下一个物理按键时,由于按键使用的是机械式弹簧片的结构,通常会导致按键在接通和断开状态之间快速切换,造成一系列的开关状态变化。这种短暂的状态变化称为按键抖动。按键抖动可能会导致系统误以为用户进行了多次按键操作,从而引发意外行为或错误。

抖动时间的长短由按键的机械特性决定,一般为5ms~10ms。这是一个很重要的时间参数,在很多场合都要用到。按键稳定闭合时间的长短则是由操作人员的按键动作决定的,一般为零点几秒至数秒。为确保CPU对键的一次闭合仅作一次处理,必须去除键抖动。在键闭合稳定时读取键的状态,并且必须判别到键释放稳定后再作处理。

按键消抖分为硬件消抖和软件消抖:

  • 硬件消抖:一般是添加RC滤波电容

  • 软件消抖:延时函数按键消抖或者定时器按键消抖或状态机消抖,消抖时间设置为20ms-100ms即可

首先一定要在CubeMX中的按键引脚设置为上拉(pull up)状态,不要设置为浮空输入(no pull up and no pull down)。

做了按键消抖却失效的原因

按键消抖的有效性通常取决于按键引脚的电气特性,尤其是在没有上拉或下拉电阻的情况下。没有上拉电阻时,处于浮空输入状态,按键引脚可能会更容易受到电气噪声和抖动等外部环境的影响,从而使消抖无法正常工作。

轮询按键控制LED亮灭(延时消抖)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include<gpio.h>


void loop()
{

if(HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET)//按下检测!
{
HAL_Delay(20);//延时消抖
if(HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET)//再次确认是否按下
{
HAL_GPIO_TogglePin(LED_BLUE_GPIO_Port,LED_BLUE_Pin);
}

while(HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET){};//松手检测!

}

if()//其他颜色的灯同理...
}

void setup()
{


}

优点:代码简单

缺点:delay会使cpu空等浪费时间

外部中断+定时器消抖(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*消抖时间设置为20ms-100ms即可,一般设置为20ms,Pre=7199,arr=199~500*/

#include<gpio.h>
#include "tim.h"


void loop()
{

}

void setup()
{
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_SET);

}


void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == KEY1_Pin)
{
HAL_TIM_Base_Start_IT(&htim2);
}
}


void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim2)
{
HAL_TIM_Base_Stop_IT(&htim2);

if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_TogglePin(LED_BLUE_GPIO_Port,LED_BLUE_Pin);
}
}

}


优点:cpu并没有浪费时间

缺点:会浪费一个定时器

注意:在CubeMX配置时注意上升沿触发还是下降沿触发,可能效果会不同,一般都是设置下降沿触发,按下开关为低电平

注意

别在中断回调函数中使用延时函数,可能会出现神奇的bug,严重会导致死机

按键状态机消抖

volatile关键字

volatile关键字作用:避免编译器优化。编译器优化会可以去除无用的繁杂代码,降低代码空间,提升运行效率,但优化后编译器在某些地方可能会弄巧成拙。

例1:空循环延时,编译器就会觉得没什么用浪费时间,会直接给你优化掉

例2:

中断方式下,如果需要访问全局变量,最好把全局变量使用 volatile 来进行修饰,避免编译器对该 变量进行优化。

具体参考 https://blog.csdn.net/dengjin20104042056/article/details/107716564

主函数和中断处理函数相当于两个线程,因为编译器的优化,在一个线程中改变一个全局变量的值,另外一个线程读取到的可能是没有改变前的值,因此需要用 volatile 来标识这个变量,让编译器不优化这个变量的存储(这个变量的值可能因为编译器的优化在寄存器中进行改变,而不是在真 正的内存区间)。这个问题书上在中断这部分也说明了。

1
2
3
4
5
6
7
8
9
10
11
12
13
volatile uint8_t A;


int main()
{
A...
}

HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
A....
}

SysTick系统定时器

介绍

SysTick系统定时器(又叫系统滴答定时器)是属于 Cortex-M3 内核中的一个外设,内嵌在 NVIC中。系统定时器是一个计数宽度24bit的向下递减的计数器,计数器每计数一次的时间为 1/SYSCLK,一般我们设置系统时钟 SYSCLK等于72M。当重装载数值寄存器的值递减到 0 的时候,系统定时器就产生一次中断,以此循环往复

因为 SysTick 是属于 CM3 内核的外设,所以所有基于 Cortex-M 内核的单片机都具有这个系统定时器,使得软件在 CM3 单片机中可以很容易的移植。

  • 计数宽度:24bit来储存数据,2^24=16,777,216
  • 向下递减:计数器的工作模式
  • 计数器的工作周期:1/CLKSource,1/72Mhz(每-1的时间为1/72000000)

常用功能

  • 系统定时器一般用于操作系统,用于产生时基,维持操作系统的心跳

  • 最常用的功能是计数。比如用来进行微秒、毫秒的延时,以此产生特定时序

寄存器介绍

第7课【SysTick定时器】中断 系统定时器 寄存器-CSDN博客

HAL库中HAL_Delay是基于SysTick来实现,使用时基源SysTick

uwTick

uwTick 是 STM32 中用于存储系统运行时间的变量,它在 HAL 库中与滴答定时器(SysTick)一起使用,通常用于实现毫秒级的计时。uwTick 是一个全局变量,保存了从系统启动开始经过的毫秒数,通常用于延时、超时管理或作为定时器的时间基准。

uwTick的工作原理

  • uwTick 由系统的滴答定时器(SysTick Timer)自动递增。每当 SysTick 定时器触发中断时,uwTick 的值增加 1,表示已经过去了 1 毫秒。
  • HAL_InitTick() 函数会初始化 uwTick,并确保 SysTick 定时器按照指定的时钟频率(通常是 1ms)定期递增。

uwTick的使用场景

  • 延时函数uwTick 可用于在没有 RTOS 的情况下实现毫秒级的延时函数。
  • 定时任务:可以使用 uwTick 来实现周期性定时任务。
  • 超时管理:可以通过 uwTick 计算超时,进行时间监控。

如何配置和使用 uwTick

在 STM32 系列中,uwTick 是由 HAL 库自动管理的,因此你不需要手动操作它,只需配置好 SysTick 定时器,并让它触发 uwTick 的更新。

初始化

在调用 HAL_Init() 时,HAL_InitTick() 会被自动调用,确保滴答定时器已启用并正确地递增 uwTick

读取 uwTick

你可以直接读取 uwTick 来获取从系统启动以来的毫秒数。例如:

1
2
3
4
5
6
7
8
9
10
#include "stm32f4xx_hal.h"

void delay_ms(uint32_t ms)
{
uint32_t startTick = uwTick;
while ((uwTick - startTick) < ms)
{
// 等待指定的毫秒数
}
}

该函数可以实现一个简单的延时功能,利用 uwTick 的递增特性来实现时间等待。

获取系统启动后的时间

uwTick 保存了系统从启动以来经过的毫秒数。如果你需要获取系统的运行时间,可以直接读取 uwTick

例如,获取系统启动后的运行时间:

1
2
3
4
uint32_t getSystemUptime(void)
{
return uwTick; // 返回从启动以来的毫秒数
}

处理溢出问题

uwTick 是一个 32 位无符号整数,因此当它达到最大值(0xFFFFFFFF)时会溢出回 0。一般情况下,你不需要担心溢出问题,因为 uwTick 的更新是连续的,你可以通过以下方式避免溢出导致的问题:

1
2
3
4
uint32_t getTickDiff(uint32_t startTick)
{
return (uwTick - startTick); // 自动处理溢出
}

通过这种方式,你可以计算当前 uwTick 和之前记录的时间差,无论它是否经历了溢出。

HAL_Delay

HAL_Delay()函数就是使用系统滴答定时器Systick实现的。

自定义延时函数

除了使用 HAL_Delay(),你也可以编写自定义延时函数。例如:

1
2
3
4
5
6
7
void delay_ms_custom(uint32_t ms)
{
uint32_t start_tick = uwTick;
while ((uwTick - start_tick) < ms) {
// 可以放置其他任务或者空闲等待
}
}

注意事项

  • 定时精度uwTick 的精度依赖于系统时钟频率,如果系统时钟较低,延时精度可能不如高精度时钟。
  • 溢出uwTick 是一个 32 位计数器,每 49.7 天会溢出一次。大部分应用场景中不需要特别处理溢出问题,除非你需要精确计时更长时间段。
  • RTOS 与 uwTick:如果使用 RTOS(如 FreeRTOS),uwTick 可以作为 RTOS 内部定时器的基础,进行任务调度和延时处理。

uwTick与硬件定时器外设区别

uwTick

  • 来源uwTick 是由 SysTick 定时器(系统定时器)更新的全局变量,通常用于提供一个基于毫秒的系统时基。它是由 STM32 的 Cortex-M 内核提供,通常用于时间管理和简单的延时。
  • 精度uwTick 的精度取决于系统时钟频率(通常是 1 毫秒)。对于很多应用场景,这种精度足够了。
  • 功能uwTick 只是一个软件计数器,它每 1 毫秒递增,并通过 SysTick 中断(如果启用)更新。它通常用于:
    • 延时操作(比如定时任务、超时控制)。
    • 简单的时间基准(比如计算系统运行时间)。
  • 优点
    • 简单易用,尤其是没有 RTOS 时,它可以作为一个基本的定时机制。
    • 使用非常灵活,可以通过 uwTick 实现延时、超时、周期性任务等功能。
  • 缺点
    • 只能提供低精度的时间基准(通常为 1 毫秒)。
    • 不能直接处理复杂的定时器任务,如生成特定频率的 PWM 信号,或者需要高精度计时的应用。

硬件定时器外设

  • 来源:硬件定时器是 STM32 提供的专用硬件模块,它们可以用来精确地生成定时中断、PWM 输出、计数器等。STM32 通常具有多个定时器外设(如 TIM1、TIM2 等)。
  • 精度:硬件定时器的精度非常高,通常由定时器的计数时钟频率(通常是系统时钟或 APB 时钟的一部分)决定,可以达到微秒级别的精度。硬件定时器可以配置为不同的分频因子,以适应不同的应用需求。
  • 功能:硬件定时器可以用于:
    • 生成高精度的定时中断。
    • 生成 PWM 波形输出。
    • 实现高精度延时、时间测量和计数。
    • 用于编码器、频率测量、信号发生等。
  • 优点
    • 提供高精度定时。
    • 不需要依赖系统时钟或软件轮询,硬件定时器直接生成中断,效率较高。
    • 可以配置为多种不同模式,如 PWM、脉冲计数、输入捕获等。
  • 缺点
    • 配置较复杂,尤其是在需要多种不同定时器功能时,配置工作量较大。
    • 受限于硬件资源,每个 STM32 芯片上有固定数量的定时器。

使用场景分析

使用 uwTick 的场景

uwTick 主要适用于以下场景:

  • 简单的定时器应用:如延时、超时控制、周期性任务等。如果你的系统不需要极高的定时精度,并且不需要复杂的定时任务管理,uwTick 是一个非常便捷的选择。

    例如:

    • 延时功能:你可以用 uwTick 实现简单的延时操作(如延时 1 秒、5 秒等)。
    • 超时检测:在没有 RTOS 的情况下,可以用 uwTick 检测外部事件是否超时(如等待串口数据、等待传感器响应等)。
    • 周期性任务:比如每 100 毫秒执行一次某些任务,使用 uwTick 来检测时间是否满足周期要求。
  • 低精度任务:当任务不依赖于极高的定时精度时,uwTick 足够满足需求。比如一些简单的 LED 闪烁、定时任务等。

使用硬件定时器外设的场景

硬件定时器外设适用于以下场景:

  • 高精度定时任务:如精确的时间测量、频率产生等。硬件定时器可以提供比 uwTick 更高精度的定时,通常是微秒级别。

    例如:

    • PWM 生成:当你需要控制电机、灯光等硬件时,通常需要高精度的 PWM 信号,这时就需要硬件定时器。
    • 频率计数:通过硬件定时器的输入捕获功能,可以精确地测量信号的频率。
    • 精确延时:对于一些要求非常精确延时的场景,如通信协议中的时序控制,硬件定时器可以提供更高的精度。
  • 复杂的定时功能:如果你需要多个定时器同步运行,或者需要定时器在某个特定事件(如输入信号)时产生中断,硬件定时器会是一个更合适的选择。

  • RTOS 应用:在 RTOS 环境中,硬件定时器通常用于管理任务的时间片,或者作为周期性任务的时基。

  • 硬件事件捕捉:硬件定时器支持的输入捕获模式使得它可以捕捉外部信号的事件,如测量脉冲宽度、时间间隔等。

总结对比表

image-20250117235719124

通讯概念

通讯方式

通讯方式分类:

  • 串行通讯和并行通讯

按数据传送的方式,通讯可分为串行通讯与并行通讯,串行通讯是指设备之间通过少量数据信号线 (一般是 8 根以下),地线以及控制信号线,按数据位形式一位一位地传输数据的通讯方式并行通讯一般是指使用 8、16、32 及 64 根或更多的数据线进行传输的通讯方式,并行通讯就像多个车道的公路,可以同时传输多个数据位的数据。而串行通讯,而串行通讯就像单个车道的公路,同一时刻只能传输一个数据位的数据。

一般情况下,串行通讯成本更低(节省数据线),且通讯距离、抗干扰能力较强,但传输速率较慢

  • 全双工通讯、半双工、单工通讯

    • 全双工 :在同一时刻,两个设备之间可以同时收发数据
    • 半双工:在同一时刻,两个设备之间可以收发数据,但不能在同一时刻进行
    • 单工 :在任何时刻都只能进行一个方向的通讯,即一个固定为发送设备,另一个固定为接收设备
  • 同步通讯和异步通讯

根据通讯的数据同步方式,又分为同步和异步两种,可以根据通讯过程中是否有使用到时钟信号进行简单的区分,使用时钟信号的叫同步通讯,未使用时钟信号则叫异步通讯

在同步通讯中,收发设备双方会使用一根信号线表示时钟信号,在时钟信号的驱动下双方进行协调,同步数据,通讯中通常双方会统一规定在时钟信号的上升沿或下降沿对数据线进行采样

通讯速率

衡量通讯性能的一个非常重要的参数就是通讯速率,通常以比特率 (Bitrate) 来表示,即每秒钟传输的二进制位数,单位为比特每秒 (bit/s)

容易与比特率混淆的概念是“波特率”(Baudrate),它表示每秒钟传输了多少个码元。而码元是通讯信号调制的概念,通讯中常用时间间隔相同的符号来表示一个二进制数字,这样的信号称为码元。如常见的通讯传输中,用 0V 表示数字 0,5V 表示数字 1,那么一个码元可以表示两种状态 0 和 1,所以一个码元等于一个二进制比特位,此时波特率的大小与比特率一致;如果在通讯传输中,有 0V、2V、4V 以及 6V 分别表示二进制数 00、01、10、11,那么每个码元可以表示四种状态,即两个二进制比特位,所以码元数是二进制比特位数的一半,这个时候的波特率为比特率的一半。

因为很多常见的通讯中一个码元都是表示两种状态,人们常常直接以波特率来表示比特率,虽然严格来说没什么错误,但不能混淆。

USART

STM32—串口通讯详解_串口通讯的原理流程图-CSDN博客

【STM32】HAL库 STM32CubeMX教程四—UART串口通信详解_hal_uart_transmit-CSDN博客

ch340是什么芯片_ch340是干嘛的-CSDN博客

STM32 —— USB 转 TTL(CH340)-CSDN博客

概念

USART-通用同步/异步收发器(Universal Synchronous/Asynchronous Receiver/Transmitter),USART是一个全双工通用同步/异步串行收发模块,该接口是一个高度灵活的串行通信设备。

  • STM32中的USART外设可以实现同步传输功能,所以命名为USART,比UART多了一个S,即synchronous(同步)

UART器件主要用来产生相关接口的协议信号,如TTL串口 /RS232/RS485串行接口标准规范和总线的标准规范,要使用传输数据的这些接口,就要按照接口规定的协议信号发送数据。所以UART期间广泛应用于串口通信中,扮演者传输者的角色

功能

STM32的串口通常用于与其他设备进行通信。

它可以用于与计算机、其他微控制器、传感器、显示屏或其他外围设备进行数据传输。串口通常用于发送和接收数据,可以是文本、二进制数据或其他格式。

常见的应用

通过串口进行调试、控制外部设备、传感器数据采集和与外部设备进行通信等。串口通常是嵌入式系统中基本且常用的通信接口之一。

串口接受与发送

stm32f4xx_hal.c中包含#include <stdio.h>

1
2
3
#include "stm32f4xx_hal.h"
#include <stdio.h>
extern UART_HandleTypeDef huart1; //声明串口

重定向printf函数:

stm32f4xx_hal.c 中重写fget和fput函数:

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


/**
* 函数功能: 重定向c库函数printf到DEBUG_USARTx
* 输入参数: 无
* 返 回 值: 无
* 说 明:无
*/
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}

/**
* 函数功能: 重定向c库函数getchar,scanf到DEBUG_USARTx
* 输入参数: 无
* 返 回 值: 无
* 说 明:无
*/
int fgetc(FILE *f)
{
uint8_t ch = 0;
HAL_UART_Receive(&huart1, &ch, 1, HAL_MAX_DELAY);//该函数在轮询中会堵塞执行
return ch;
}
/**
* UART_HandleTypeDef *huart UATR的别名 如 :
* UART_HandleTypeDef huart1; 别名就是huart1
* *pData 需要发送的数据
* Size 发送的字节数
* Timeout 最大发送时间,发送数据超过该时间退出发送
*/
1
2
3
4
5
6
7
8
9
10
11
/*在main函数中使用printf、getchar、scanf等*/

int main()
{
while(1)
{
char ch = getchar();//从串口获取单个字符
printf("test");
HAL_Delay(1000);
}
}

USART中断

轮询状态下的串口接收数据会使cpu效率一直查看是否有数据,干不了其他事情,使程序堵塞,效率低下。但如果使用中断发送和接收比较高效,cpu需要处理时会被叫回处理,其他时间可以干其他事情,不会长期占用cpu时间。

轮询模式是阻塞模式,程序会等到所有数据发送/接收完成后才会着向下执行

而中断和DMA是非阻塞模式,他们将任务交给外设后就会接着向下执行,不会等待数据发送/接收完成

主要有三个函数

  • 串口中断处理函数
1
HAL_UART_IRQHandler(UART_HandleTypeDef *huart);  

功能:对接收到的数据进行判断和处理 判断是发送中断还是接收中断,然后进行数据的发送和接收,在中断服务函数中使用

  • 中断接收数据
1
HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
  • 中断发送数据
1
HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
  • 串口接收中断回调函数
1
2
3
4
5
6
7
8
HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
.......

HAL_UART_Receive_IT(&huart1,&charBuffer,1);//再次开启串口接收
}

// Cplt代表 complete完成,代表接收完成后触发中断回调

注意:别忘了在中断回调函数中重新启动接收

  • 空闲中断idle对应回调函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if(huart == &huart2)
{
HAL_UART_Transmit_DMA(&huart2, rData, Size);

HAL_UARTEx_ReceiveToIdle_DMA(&huart2,rData,sizeof(rData));
}
}
/**
* Ex代表扩展
* 参数2:size代表接收的数据长度
*/


实验

USART1普通中断回显实验

主要的函数

1
2
3
4
5
6
7
HAL_UART_Receive_IT(&haurt1,(uint8_t*)&buffer,1)
/**
* 函数功能: 用于启用串口的接收中断,并指定接收数据存放位置。每当接收到数据时,将触发一个中断,然后调用相应的中断回调函数处理接收数据
* 输入参数: 一共三个,串口别名,数据存放地址,字节数
* 返 回 值: 无
* 说 明:无
*/

参数:

  • UART_HandleTypeDef *huart UATR的别名 如 : UART_HandleTypeDef huart1; 别名就是huart1
  • *pData 接收到的数据存放地址
  • Size 一次接收的字节数
1
2
3
4
5
6
7
HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);  
/**
* 函数功能: 串口接收中断回调函数,由用户自己编写,当接收到一个或多个字符后会被调用。在这个函数中处理接收到的数据,比如回显到终端或者储存到缓冲区中
* 输入参数: 无
* 返 回 值: 无
* 说 明:无
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include "usart.h"
#include "gpio.h"



uint8_t buffer[255];
uint8_t bufferCounter=0;
uint8_t charBuffer;


void setup()
{
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_2,GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_3,GPIO_PIN_RESET);

HAL_UART_Receive_IT(&huart1,&charBuffer,1);//开启串口接收中断
}



void loop()
{

// HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_2);
// HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_3);
// HAL_Delay(1000);

}



void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{

// if(bufferCounter>=255)
// {
// HAL_UART_Transmit(&huart1,(uint8_t * )"数据溢出",10,HAL_MAX_DELAY);
// }


// else
// {

if(huart == &huart1)
{
buffer[bufferCounter++] = charBuffer;
HAL_UART_Transmit(&huart1,&charBuffer,1,HAL_MAX_DELAY);
}

// }

HAL_UART_Receive_IT(&huart1,&charBuffer,1);//开启串口中断
}



每次中断接收一个字节的数据,数据量大会频繁触发中断

可以改进为一次接收N个字节的数据。

USART2控制LED灯

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



uint8_t buffer[255];
uint8_t bufferCounter=0;
uint8_t charBuffer;


void setup()
{
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_2,GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_3,GPIO_PIN_SET);


printf("请操作小灯:\n1.只有小灯1亮\n2.只有小灯2亮\n3.两个小灯同时亮\n4.两个小灯同时熄灭\n");
HAL_UART_Receive_IT(&huart1,&charBuffer,1);//开启串口接收中断
}



void loop()
{

// HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_2);
// HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_3);
// HAL_Delay(1000);

}



void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{


if(huart == &huart1)
{

if(charBuffer == '1')
{
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_2,GPIO_PIN_RESET),
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_3,GPIO_PIN_SET);
}
else if(charBuffer == '2')
{
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_2,GPIO_PIN_SET),
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_3,GPIO_PIN_RESET);
}

else if(charBuffer == '3')
{
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_2,GPIO_PIN_RESET),
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_3,GPIO_PIN_RESET);
}

else
{
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_2,GPIO_PIN_SET),
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_3,GPIO_PIN_SET);
}

}

HAL_UART_Receive_IT(&huart1,&charBuffer,1);
}

在串口调试助手时,发送1,2,3,4在单片机上可以看到明显的现象

HAL库函数的一些调用流程

1
2
3
4
5
1.HAL_外设名_Init()
-- 该函数一般会调用2函数,实际操作寄存器初始化外设功能,由HAL库实现

2.HAL_外设名_MspInit()
-- 该函数是外设外围相关功能的初始化,比如:引脚、特殊功能比如时钟使能等等,由用户自己实现

以USART初始化来举例

  1. 定义一个UART_HandleTypeDef结构体句柄
  2. 通过HAL_UART_MspInit函数来实现串口外设的底层初始化
  • 要做的功能:

    1. 使能UART外设时钟

    2. 配置UART使用的引脚模式

    3. 如果要使用中断,就配置中断

    4. 如果要用DMA,就配置DMA

  1. 通过前面定义的结构体具备,来配置串口的波特率、数据字长、停止位、奇偶校验位。
  2. 如果要使用异步模式,则通过调用HAL_UART_Init()函数来将串口配置为异步模式。

TIM(定时器)

概念

STM32中的定时器是一种内置的硬件模块,用于生成精确的时间延迟、执行周期性任务、捕获外部事件等。定时器通常用于需要精确时间控制的应用,比如实时操作系统、通信协议、PWM(脉冲宽度调制)生成等

以下常见的几种定时器:

  • 高级定时器
  • 通用定时器
  • 基本定时器

定时器的常见作用

  1. 计时器模式(Timer Mode):定时器可以作为简单的计时器,用于生成一段时间延迟。你可以设置定时器的计数值和时钟频率来控制延迟的精度。
  2. 定时器中断(Timer Interrupts):定时器可以配置为在计数器达到特定值时生成中断请求。这对于周期性任务执行或时间精确控制非常有用。
  3. PWM输出(Pulse Width Modulation):定时器可以用来生成PWM信号,用于控制电机速度、LED亮度、音频输出等。
  4. 捕获模式(Capture Mode):定时器可以捕获外部事件的时间戳,比如测量输入脉冲的周期或脉宽。
  5. 计数器级联(Timer Cascade):一些STM32系列的芯片支持多个定时器级联,可以扩展定时器的计数范围或增加功能。

还有一些相关知识例如:定时器的主从模式

定时器相关寄存器可以查看参考手册、数据手册等

还有一些相关知识(定时器的主从模式等),以下为定时器详细介绍

STM32-定时器详解_stm32定时器-CSDN博客

基于定时器的LED闪烁

CubeMX配置

  1. 确定时钟输入: 这里我们需要一个稳定的时钟输入,以精确的进行定时,因此可以选择内部时钟源。这里以TIM2为例,在CubeMX中将Clock Source选择为Internal Clock, 使用内部时钟源。那么这个内部时钟源是哪来的呢?参考芯片手册。找到时钟源输入频率。即在CubeMX中配置Clock Source为Internal Clock(内部时钟源)

  2. 确定预分频值:预分频值就是指时钟信号输入之后会在这里被分频,也就是降低了输入频率。 假设这里的值设定为2,则最终信号会变成36MHz,也就是计数器每秒会加36M次,很显然这个速度对我们来说还是太大了。 为了便于计算,我这里更倾向于让输出的时钟频率变成10k,这样每0.1ms定时器就加1。

  3. 确定重装载值

当我们在上面分完频后,就很容易计算这里的值应该为多少了。显然,要使定时器100ms触发一次,这里的这个值应该为1000,最后别忘了-1,才是准确的值(从0开始)。别忘了使能自动重装载enable

  1. 使能定时器中断:在NVIC中开启TIM中断。

主要涉及三个概念

  1. Prescaler(psc)-预分频值:内部有一个预分频器PSC,简单来说就是分频值

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

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

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

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

注意:psc和arr的值配置时都要-1,因为从0开始数

举个例子

​ 假设时钟源频率为72MHZ,我们设置psc为7200-1=7199,那么得到分频后的时钟频率为10000HZ, 每秒计数10000次,要使定时器0.1s溢出一次,那么1000-1=999次,1000/10000 = 0.1s

【STM32】HAL库 STM32CubeMX教程六----定时器中断_hal_tim_irqhandler-CSDN博客

函数和业务代码

主要函数

定时器中断处理函数

1
HAL_TIM_IRQHandler(&htim2);

定时器溢出中断回调函数

1
2
3
4
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);   
/**
* 在stm32f1xx_it.c中可以找到HAL_TIM_IRQHandler(&htim2);里面调用了这个溢出中断回调函数
*/

使能定时器中断

1
2
HAL_TIM_Base_Start_IT(&htim2);
/*使用之前别忘了在setup中使能定时器中断*/

业务代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "tim.h"

void setup()
{
/*定时器中断使能*/
HAL_TIM_Base_Start_IT(&htim2);
}



void loop()
{



}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim2)
{
HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_2);
}

}

RTC

STM32 微控制器中的 RTC(Real Time Clock,实时时钟)模块是一个低功耗的独立计时器,用于提供精确的时间和日期信息。RTC 模块设计为即使在主电源关闭的情况下也能继续运行,从而确保时间信息不会丢失。这通常是通过一个备用电池或超级电容来实现的,它能够为 RTC 供电,即便主电源断开

以下是关于 STM32 中 RTC 的一些关键点:

  1. 功能:
    • 提供实时时间:RTC 可以作为系统时钟,提供准确的小时、分钟、秒等。
    • 日历功能:除了时间,RTC 还可以提供年、月、日等日期信息。
    • 闹钟功能:可以设置特定时间触发中断,用于定时唤醒或其他用途。
    • 定时任务:可用于执行定期的任务或事件。
  2. 硬件特性:
    • 低功耗设计:RTC 在低功耗模式下也能工作,适合于电池供电的应用。
    • 备用电源:通过 VBAT 引脚连接备用电源,保证在主电源断开后仍能维持时间信息。
    • 时钟源:可以选择多种时钟源,如 LSE (低速外部晶振, 通常为 32.768kHz)、LSI (低速内部 RC 振荡器) 或 HSE (高速外部晶振) 经过分频后的信号。

RTC时钟选择

RTC设备因为其独特的运行方式(即掉电依旧运行)使用HSE分频时钟或者LSI的时候,在主电源VDD掉电的情况下,这两个时钟来源都会受到影响,资源消耗太大,小小的纽扣电池根本吃不消。

​ 所以RTC一般都时钟低速外部时钟LSE,频率为实时时钟模块中常用的32.768KHz,因为32768 = 2^15,分频容易实现,所以被广泛应用到RTC模块.(在主电源VDD有效的情况下(待机),RTC还可以配置闹钟事件使STM32退出待机模式).

故使用RTC时记得使能LSE的外部晶振,配置时钟树时RTC选择LSE

RTC中断

秒中断
这里时钟自带一个秒中断,每当计数加一的时候就会触发一次秒中断,。注意,这里所说的秒中断并非一定是一秒的时间,它是由RTC时钟源和分频值决定的“秒”的时间,当然也是可以做到1秒钟中断一次。我们通过往秒中断里写更新时间的函数来达到时间同步的效果

闹钟中断
闹钟中断就是设置一个预设定的值,计数每自加多少次触发一次闹钟中断

CubeMX配置

Activate Clock Source 激活时钟源
Activate calendar激活日历
这两个都要使能,作用也很明显,先是使能时钟源,再使能RTC日历

RTC_OUT: Not RTC_OUT
Tamper: ×

第一个参数:是否使能tamper引脚作为校正的秒脉冲输出

第二个参数:是否使能tamper引脚作为RTC入侵检测校验功能

剩下的就是时间配置

库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*设置系统时间*/
HAL_RTC_SetTime(RTC_HandleTypeDef *hrtc, RTC_TimeTypeDef *sTime, uint32_t Format)
/*读取系统时间*/
HAL_RTC_GetTime(RTC_HandleTypeDef *hrtc, RTC_TimeTypeDef *sTime, uint32_t Format)
/*设置系统日期*/
HAL_RTC_SetDate(RTC_HandleTypeDef *hrtc, RTC_DateTypeDef *sDate, uint32_t Format)
/*读取系统日期*/
HAL_RTC_GetDate(RTC_HandleTypeDef *hrtc, RTC_DateTypeDef *sDate, uint32_t Format)
/*启动报警功能*/
HAL_RTC_SetAlarm(RTC_HandleTypeDef *hrtc, RTC_AlarmTypeDef *sAlarm, uint32_t Format)
/*设置报警中断*/
HAL_RTC_SetAlarm_IT(RTC_HandleTypeDef *hrtc, RTC_AlarmTypeDef *sAlarm, uint32_t Format)
/*报警时间回调函数*/
__weak void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc)
/*写入备份储存器*/
void HAL_RTCEx_BKUPWrite(RTC_HandleTypeDef *hrtc, uint32_t BackupRegister, uint32_t Data)
/*读取备份储存器*/
uint32_t HAL_RTCEx_BKUPRead(RTC_HandleTypeDef *hrtc, uint32_t BackupRegister

参数1:RTC句柄结构体参数

参数2RTC_TimeTypeDef *sTime: 获取RTC时间的结构体

参数3获取时间的格式
RTC_FORMAT_BIN 使用2进制
RTC_FORMAT_BCD 使用BCD进制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* @brief RTC Time structure definition
*/
typedef struct
{
uint8_t Hours; /*!< Specifies the RTC Time Hour.
This parameter must be a number between Min_Data = 0 and Max_Data = 23 */

uint8_t Minutes; /*!< Specifies the RTC Time Minutes.
This parameter must be a number between Min_Data = 0 and Max_Data = 59 */

uint8_t Seconds; /*!< Specifies the RTC Time Seconds.
This parameter must be a number between Min_Data = 0 and Max_Data = 59 */

} RTC_TimeTypeDef;

/**
* @brief RTC Date structure definition
*/
typedef struct
{
uint8_t WeekDay; /*!< Specifies the RTC Date WeekDay (not necessary for HAL_RTC_SetDate).
This parameter can be a value of @ref RTC_WeekDay_Definitions */

uint8_t Month; /*!< Specifies the RTC Date Month (in BCD format).
This parameter can be a value of @ref RTC_Month_Date_Definitions */

uint8_t Date; /*!< Specifies the RTC Date.
This parameter must be a number between Min_Data = 1 and Max_Data = 31 */

uint8_t Year; /*!< Specifies the RTC Date Year.
This parameter must be a number between Min_Data = 0 and Max_Data = 99 */

} RTC_DateTypeDef;

实验-实时时间OLED显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
char realTime[10];
char realDate[10];
RTC_DateTypeDef GetDate;//获取日期结构体
RTC_TimeTypeDef GetTime;//获取时间结构体

void setup()
{
HAL_Delay(20);
OLED_Init();
}

void loop()
{

HAL_RTC_GetTime(&hrtc, &GetTime, RTC_FORMAT_BIN);
/*分别从RTC获取时间结构体和日期结构体*/ HAL_RTC_GetDate(&hrtc,&GetDate,RTC_FORMAT_BIN);

OLED_NewFrame();
sprintf(realDate,"%02d/%02d/%02d",2000+GetData.Year, GetData.Month, GetData.Date);
sprintf(realTime,"%02d:%02d:%02d",GetTime.Hours, GetTime.Minutes, GetTime.Seconds);

OLED_PrintString(0, 0,realDate ,&font16x16,OLED_COLOR_NORMAL);
OLED_PrintString(0, 16,realTime ,&font16x16,OLED_COLOR_NORMAL);
OLED_ShowFrame();
}

RTC掉电重置

BKP寄存器

是在嵌入式系统(如STM32微控制器)中用于在系统掉电或复位时保存数据的特殊寄存器。它们通常用于需要在掉电或复位后保留的重要数据,这些数据在重新上电或复位时能够被恢复。即使系统复位或电源复位,备份寄存器的内容会保留不变,直到手动修改或在特定条件下被重置

BKP寄存器可以在芯片手册中找到

image-20241008202341686

解决掉电重置代码

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

/* USER CODE BEGIN RTC_Init 0 */

/* USER CODE END RTC_Init 0 */

RTC_TimeTypeDef sTime = {0};
RTC_DateTypeDef DateToUpdate = {0};

/* USER CODE BEGIN RTC_Init 1 */

/* USER CODE END RTC_Init 1 */

/** Initialize RTC Only
*/
hrtc.Instance = RTC;
hrtc.Init.AsynchPrediv = RTC_AUTO_1_SECOND;
hrtc.Init.OutPut = RTC_OUTPUTSOURCE_NONE;
if (HAL_RTC_Init(&hrtc) != HAL_OK)
{
Error_Handler();
}

/* USER CODE BEGIN Check_RTC_BKUP */
/*新增代码处1:*/
if(HAL_RTCEx_BKUPRead(&hrtc,RTC_BKP_DR1)!= 0x1234)
// 读取备份寄存器,检查是否已经初始化过

{
/* USER CODE END Check_RTC_BKUP */

/** Initialize RTC and set the Time and Date
*/
sTime.Hours = 0x19;
sTime.Minutes = 0x0;
sTime.Seconds = 0x0;

if (HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BCD) != HAL_OK)
{
Error_Handler();
}
DateToUpdate.WeekDay = RTC_WEEKDAY_TUESDAY;
DateToUpdate.Month = RTC_MONTH_OCTOBER;
DateToUpdate.Date = 0x8;
DateToUpdate.Year = 0x24;

if (HAL_RTC_SetDate(&hrtc, &DateToUpdate, RTC_FORMAT_BCD) != HAL_OK)
{
Error_Handler();
}
/* USER CODE BEGIN RTC_Init 2 */
/*新增代码处2:*/
// 设置完成后,将标志位写入备份寄存器,标记RTC已初始化,下次开机后将不再初始化
HAL_RTCEx_BKUPWrite(&hrtc,RTC_BKP_DR1,0x1234);
}
/* USER CODE END RTC_Init 2 */

}
  1. 检查BKP寄存器值: 在 USER CODE BEGIN Check_RTC_BKUP 部分,通过 HAL_RTCEx_BKUPRead() 函数读取备份寄存器 RTC_BKP_DR1 的值。我们约定用值 0x1234 来表示RTC已经被初始化。该值自己约定即可!!!!

  2. RTC初始化和时间设置: 如果读取到的值不是 0x1234,说明RTC未初始化,接着会进行RTC的时间和日期设置。否则,跳过初始化,保留当前时间。

  3. 保存初始化标志: 设置完时间后,通过 HAL_RTCEx_BKUPWrite()0x1234 写入 RTC_BKP_DR1,标记RTC已经完成初始化,以便下次重启时检测。

在 STM32 中,备份寄存器可以使用 RTC_BKP_DR1RTC_BKP_DRx(多个寄存器)。上面的例子使用的是 RTC_BKP_DR1,你可以根据需要选择其他备份寄存器。

定时器外部时钟与循迹模块

循迹模块

作用:为传送带测速/测距,计算流水线上的货物数量等

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

抖动脉冲

输入滤波器

PWM

概念

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

PWM无非就是一定的频率(周期),输出不断交替的高低电平信号。

占空比:高电平在一个周期中占用时间的比重叫做占空比,占空比越大其在宏观上表现的电压也就越大,成线性关系。

通用或者高级定时器具有输出比较模式,可以通过不断比较计数器比较寄存器的值

相关函数

  • 启动定时器PWM输出
1
2
3
4
HAL_StatusTypeDef HAL_TIM_PWM_Start(TIM_HandleTypeDef *htim, uint32_t Channel);
/*第一个参数为指向定时器句柄的指针,第二个参数为启动的通道,一个有1,2,3,4共四个,例:TIM_CHANNEL_1*/

/*函数返回一个 HAL_StatusTypeDef 类型的值,表示操作的状态。常见的返回值包括:HAL_OK(操作成功)、HAL_ERROR(操作失败)*/
  • 启动PWM输出,并启动比较中断
1
HAL_TIM_PWM_Start_IT(&htim2);
  • 修改占空比
1
2
3
4
5
6
7
8
9
10
11
__HAL_TIM_SetCompare(&htim2, TIM_CHANNEL_1, pwmVal);
/*或*/
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pwmVal);
/*前两个参数不变,第三个参数为用户占空比,占空比=pwmVal/arr*/

-------------------上下两种都可以----------------------

/*它是HAL库中的一个宏,用于快速设置定时器的比较寄存器的值。*/
TIM2->CCR1 = pwmVal
htim2.Instance->CCR1 = pwmVal
/*直接修改比较寄存器CCRx的值也可以访问定时器相关寄存器*/
  • 修改PSC或ARR的值
1
2
3
4
5
/*修改预分频值,PSC寄存器*/
htim2.Instance->PSC = 72-1;

/*修改自动重装载值,ARR寄存器*/
htim2.Instance->ARR = 1000-1;

实验-PWM控制LED亮度实现呼吸灯

【STM32】HAL库 STM32CubeMX教程七—PWM输出(呼吸灯)_stm32 hal pwm-CSDN博客

CubeMX配置

第一步:配置时钟树

第二步:选用定时器,这里用TIM2,Clock Source选择内部时钟源,四个独立通道channel任选一个,选择PWM模式(PWM Generation CH1),CH1代表通道1,对应引脚将自动配置为复用模式。

Channel1~4 就是设置定时器通道的功能 (输入捕获、输出比较、PWM输出、单脉冲模式)

第三步:Mode选择PWM mode 1,Pulse(宽度/占空比)先选择0,Fast Mode不使能,通道极性(CH Polarity)配置为Low(选择Low可以使占空比变为低电平占整个周期的比例)

PWM mode 1 和 2 区别在于一个是向上计数一个是向下计数,以及频率和占空比是否固定上等

**CHPolarity的选择上:**当占空比为30%时,如果选择High那么高电平占30%,选择Low那么低电平占30%。

但一般情况下占空比都是指高电平所占周期的比例

第四步:设置预分频值和重装载值以及占空比,我设置arr = 100-1,psc = 71,得到的Fpwm = 72000000/7200 = 10000HZ

定时溢出频率即pwm的频率,故Fpwm = TCLK/(arr+1)*(PSC+1)

占空比为高电平所占时间与整个周期比例:TIM2->CCR1/arr

TIM2->CCR1是定时器2的比较(捕获)寄存器1可以修改其值来调整占空比

第五步:生成项目

业务代码(轮询修改占空比)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/*按键控制LED呼吸灯开关*/

//
// Created by keqiu on 24-9-13.
//


#include<gpio.h>


#include "tim.h"
#include "stdbool.h"
uint16_t pwmVal = 0;
volatile bool mode = false;

void loop()
{

if(mode)
{
while(pwmVal<100)
{
pwmVal+=1;
__HAL_TIM_SetCompare(&htim3, TIM_CHANNEL_2, pwmVal);
HAL_Delay(1);

if(!mode)
{
__HAL_TIM_SetCompare(&htim3, TIM_CHANNEL_2, 0);
break;
}
}

while(pwmVal)
{
pwmVal-=1;;
__HAL_TIM_SetCompare(&htim3, TIM_CHANNEL_2, pwmVal);
HAL_Delay(1);

if(!mode)
{
__HAL_TIM_SetCompare(&htim3, TIM_CHANNEL_2, 0);
break;
}
}
HAL_Delay(100);
/*此时占空比为0,代表全为低电平,这里的延迟可以使到达最暗或者最亮之后可以持续一会,以便于灯的变化更加顺畅*/
}

}

void setup()
{
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
}


void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == KEY1_Pin)
{
HAL_TIM_Base_Start_IT(&htim2);
}
}


void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim2)
{
HAL_TIM_Base_Stop_IT(&htim2);

if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
{
mode = !mode; // 注意切换模式要卸载消抖里面,否则可能会出现关不上灯的情况
// HAL_GPIO_TogglePin(LED_BLUE_GPIO_Port,LED_BLUE_Pin);
}
}

}

tips

  • 如果感觉灯到达最亮或者最暗的时间比较慢,可以调整一次循环中pwmVal加或减的值,使亮度变化更平滑。
  • 如果感觉灯到达最亮或者最暗之后的维持的一段时间比较慢(快),可以修改每一次大循环的延迟,HAL_Delay(200)比较合适
  • 每一次pwmVal加或减之后有一个HAL_Delay(1),在每次循环迭代中产生一个较小的延迟,因为每一次while循环都是很快的pwmVal+=1的速度远远小于一个pwm输出周期的时间,如果删除它那么占空比将会很快到达0,我们肉眼观察就是瞬间亮了又瞬间灭了,而不会产生亮度慢慢变化的结果,一般设置为1ms即可

提问:每一个周期不应是有高电平和低电平吗,为什么观察到的不是LED灯一闪一闪的到达最亮或最暗呢?

那么我们平时见到的LED灯,当它的频率大于50Hz的时候,人眼就会产生视觉暂留效果,基本就看不到闪烁了,而是一个常亮的LED灯,

你在1秒内,高电平0.5秒,低电平0.5秒,(频率1Hz)如此反复,那么你看到的电灯就会闪烁,

但是如果是10毫秒内,5毫秒打开,5毫秒关闭,(频率100Hz) 这时候灯光的亮灭速度赶不上开关速度(LED灯还没完全亮就又熄灭了),由于视觉暂留作用 人眼不感觉电灯在闪烁,而是感觉灯的亮度少了 ,然后占空比在不断变化,所以才会感觉在慢慢变亮和慢慢变暗。

业务代码(定时器修改占空比)

另外启用了一个定时器修改占空比,数秒内让占空比从0~100。假定5s内,那么0.05s(50ms)增加一占空比,所以PSC = 72-1,ARR = 20000-1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
//
// Created by keqiu on 24-10-14.
//


#include "main.h"
#include "tim.h"

volatile uint8_t pwmVal1 = 0;
volatile uint8_t pwmVal2 = 0;
volatile uint8_t pwmVal3 = 0;
int flag1 = 0;
int flag2 = 0;
int flag3 = 0;

void setup()
{
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
HAL_TIM_Base_Start_IT(&htim4);
}


void loop()
{

}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim->Instance == TIM4)
{
// 通道1的呼吸效果
if(!flag1)
{
if(pwmVal1 < 100)
{
pwmVal1 += 1;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pwmVal1);
}
else
{
flag1 = 1;
}
}
else
{
if(pwmVal1 > 0)
{
pwmVal1 -= 1;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pwmVal1);
}
else
{
flag1 = 0;
}
}

// 通道2的呼吸效果
if(!flag2)
{
if(pwmVal2 < 100)
{
pwmVal2 += 2;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, pwmVal2);
}
else
{
flag2 = 1;
}
}
else
{
if(pwmVal2 > 0)
{
pwmVal2 -= 2;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, pwmVal2);
}
else
{
flag2 = 0;
}
}

// 通道3的呼吸效果
if(!flag3)
{
if(pwmVal3 < 100)
{
pwmVal3 += 3;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, pwmVal3);
}
else
{
flag3 = 1;
}
}
else
{
if(pwmVal3 > 0)
{
pwmVal3 -= 3;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, pwmVal3);
}
else
{
flag3 = 0;
}
}
}
}

实验-PWM控制无源蜂鸣器

通过改变PWM频率,可以输出不同频率的方波信号。用这个信号驱动无源蜂鸣器,便能播放不同频率的声音。

原理

  • 学习板上的蜂鸣器型号为:QMB-09B-03电磁式无源蜂鸣器
  • 蜂鸣器内部有一个电磁线圈,能够驱动振动膜片发出声音。通过PWM给蜂鸣器提供不同频率的信号,即可发出不同频率的声音
  • 实际操作中,除了控制PWM频率,还需要控制PWM占空比,以使膜片振动趋近于正弦波,从而发出清脆明亮的声音。在学习板上,使用20%占空比可以有较好的响度和音质

CubeMX配置

  1. 配置Debug,打开外部晶振,配置时钟树
  2. 配置对应按键输入模式,上拉等
  3. 找到蜂鸣器对应的TIM引脚和通道,勾选对应TIM内部时钟,对应通道选择PWM生成模式
  4. TIM参数:PSC = 72-1 ARR = 按照要求或随意

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void setup()
{
HAL_TIM_PWM_Start(&htim4,TIM_CHANNEL_4);
}


void loop()
{
// KEY1按下: 输出2kHz声波
if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
{
HAL_Delay(20);
if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
{
htim4.Instance->ARR = 500;// 2kHz = 72MHz / 72 / 500
__HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_4, 500/5);// 设置占空比为20%
}
while(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET);
}

// KEY2按下: 输出3kHz声波
else if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) == GPIO_PIN_RESET)
{
HAL_Delay(20);
if (HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) == GPIO_PIN_RESET)
{
htim4.Instance->ARR = 334; // 3kHz = 72MHz / 72 / 334
__HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_4, 1000/5);// 设置占空比为20%
}
while(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET);
}

// 否则: 关闭声波输出
else
{
__HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_4, 0);
}

HAL_Delay(100);
}

实验-PWM控制直流电机

直流电机一端通正极,一端通负极就可以旋转,电压越大,旋转也就越快,正负极反过来还可以实现反转

DRV8833电机驱动模块

通常驱动一个小电机需要几百毫安的电流,但单片机上的I/O口只能支持几毫安的电流,直接接上可能会损坏STM32单片机。所有我们需要一个电机驱动芯片

,常见的有DRV8833等。

image-20240929204303792

一共4个IN口,4个OUT口。其中N1 IN2与OUT1 OUT2一组,N3 N4与OUT3 OUT4一组

我们可以通过单片机输出PWM到IN1 IN2,来控制OUT1 OUT2的输出来控制电机。IN3 IN4同理,也就是说DRV8833电机芯片可以驱动两个电机。

SLEPP引脚可以让芯片暂停工作,实现低功耗。

FAULT的脚可以在出现控制错误时提醒单片机

VCC和GND脚用于给芯片和电机供电

原理

停止旋转时:

STM32向IN1输入高电平,向IN2输入低电平时。OUT1输出高电平,OUT2输出低电平,使电机正转。

STM32向IN1输入低电平,向IN2输入高电平时,OUT1输出低电平,OUT2输出高电平,电机反转

旋转时

IN1、IN2都输入高电平,OUT1与OUT2都输出低电平,相当于将电机的两根线短路,由于电机内的转子由很多线圈组成,相当于电感,产生反电动势阻碍电流变化,因此电流在整个回路中是缓慢变小消失,这种情况称为电流慢衰减。反电动势产生的磁场与定子产生的磁场相互作用,会使电机转子很快的刹停,叫刹车

IN1、IN2都输入低电平,DRV8833会将OUT1与OUT2都输出高电平,使得转子电流瞬间释放,这种情况称为电流快衰减,相当于将电机的两根线断路,无回路不能形成磁场,此时会随着摩擦力慢慢停下来,叫滑行

原理

正转

IN1 IN2 OUT1 OUT2

PWM输入 低电平 正转&滑行(快衰减)

电机一会处于正转一会处于滑行,PWM占空比越高,正转时间越长,宏观上来看也就是电机转速也就越快

IN1 IN2 OUT1 OUT2

高电平 PWM输入 正转&刹车(慢衰减)

电机一会处于正转一会处于刹车,PWM占空比越低,正转时间越长,宏观上来看也就是电机转速也就越快

滑行和刹车区别: 都可以实现控制电机转速,区别就在于占空比一个高越快,一个低越快

应用场景

  • 快衰减能够迅速降低电流,故常用于需要快速变化的高动态响应场景。

  • 慢衰减电流变化比较平稳,因而比较适合用于平稳运行,降低噪音的场景中

对于小电机来说其实感受不到什么区别

反转

IN1 IN2 OUT1 OUT2

低电平 PWM输入 反转&滑行

IN1 IN2 OUT1 OUT2

PWM输入 高电平 反转&刹车

注意:占空比过低时,电机可能无法启动

CubeMX配置

  1. 配置Debug,打开外部晶振,配置时钟树
  2. 查看对应引脚对应的TIM通道,切换到TIM配置,选择内部时钟源,将对应通道设置为PWM生成模式(一个通道正转,一个通道反转)
  3. TIM参数配置:PSC = 72-1 ,ARR = 100-1 ,PWM频率变为10000HZ
  4. 其他配置
  5. 生成代码

业务代码

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

#define COUNT_MID 20 //停止转动
int counter = 0;
int speed = 0;//电机转速由counter计算而来

void setup()
{
HAL_Delay(20);
OLED_Init();

HAL_TIM_Encoder_Start(&htim1, TIM_CHANNEL_ALL);
/*使用旋转编码器控制电机转速*/
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2);
/*启动两个通道*/

__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1,COUNT_MID);
/*设置编码器初始值为20,停止转动*/
}

void loop()
{
counter = __HAL_TIM_GET_COUNTER(&htim1);
/*控制编码器count在0~40之间,0~19代表反转,21~40代表正转,离中点越远转速越快,0最快反转,40最快正转*/


if(counter>60000)
{
counter = 0;
__HAL_TIM_SET_COUNTER(&htim2,0);//保持反转最快
}

else if(counter>COUNT_MID*2)
{
counter = COUNT_MID*2;
__HAL_TIM_SET_COUNTER(&htim2, COUNT_MID*2);//保持正转最快
}
/*控制counter的值在0~40之间*/

if(counter < COUNT_MID)
{
speed = (COUNT_MID - counter) * 100 / COUNT_MID; //通过控制counter0~19,使speed映射在0~100之间

__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, speed);
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0);
/*反转&滑行*/
}
else
{
speed = (counter - COUNT_MID) * 100 / COUNT_MID;//通过控制counter21~40,使speed映射在0~100之间

__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, speed);
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, 0);
/*正转&滑行*/
}

OLED_NewFrame();

sprintf(message, "counter:%d", counter);
OLED_PrintString(0, 0, message,&font16x16,OLED_COLOR_NORMAL);

OLED_ShowFrame();
}

最好将该DRV8833模块拆分为单独的.c/.h 实现驱动的编写!!!

驱动库

DRV8833电机驱动:

波特律动LED字模生成器 (baud-dance.com)

可以参照别人写的驱动,自己编写代码!!!

输入捕获

【STM32】动画讲解输入捕获 并实现超声波测距_哔哩哔哩_bilibili

概念

输入捕获:当定时器输入通道上检测到上升沿(或者下降沿)时,立刻将此时计数器的值记录到捕获寄存器中,等待程序稍后读取,并且可以借用另一个输入通道的捕获寄存器进行输入捕获。此种方法不会受到软件运行时间的干扰,更加准确。

捕获寄存器

对于通用定时器和高级定时器来说,每个输入通道都有它自己的捕获寄存器,。

假设我们启动了输入通道1(TI1FP1)的输入捕获模式,并且设定为上升沿捕获,定时器启动计数后,若输入到输入通道TI1的信号出现了一个上升沿,边沿检测器立即检测到就会通过TI1FP1传递到捕获寄存器1,捕获寄存器1便立刻将此时计数器的值复制到自身,如果对此输入捕获开启了中断,就还会触发输入捕获中断,通知程序尽快读取捕获寄存器中的数值,这样就获取到了上升沿出现时定时器的时刻,当我们再获取下降沿出现时定时器的时刻,就可以获得到高电平持续时间

但一个输入通道的输入捕获只能进行上升沿捕获或者下降沿捕获,不能设置为双边沿捕获。所以STM32又从TI1上引出了一条线 连接到了捕获寄存器2上,这条线就是TI1FP2

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

TI1和TI2是一对可以相互借用,TI3和TI4是一对可以相互借用

超声波测距介绍

超声波测距模块是各种需要测距的产品中常用的一类传感器

测距原理:首先发送一定频率的超声波,超声波遇见被测物体后就会被反射回来,当模块接收到反射回来的超声波后,只要将超声波从发送到接收的时间差乘以声速,再除以2 , 就可以得到超声波测距模块和被测物体的距离了。

(发送时刻-接受时刻)×声速 ÷ 2 = 距离

注意:若被测物体和超声波测距模块之间有障碍物,则测得的是障碍物的距离,因而某些场景不适合,但倒车雷达等需要对一个范围内进行测距的便非常合适。

超声波测距模块以HC-SR04为例:共有四个引脚

  • VCC

  • GND

  • 控制端Trig:用于触发模块进行测距

  • 输出端Echo:用于测量模块输出的高电平持续时间

原理:当需要超声波测距时,只需要通过GPIO口向Trig引脚发送一个脉冲信号,超声波模块接收到脉冲信号就会向外发送一段超声波,然后模块会将Echo拉高,当模块接收到反射回来的超声波后,Echo会被拉低,那么Echo高电平持续时间也就是超声波在往返路途中消耗的时间

向Trig引脚发送脉冲信号(启动超声波测距):先将GPIO口拉高,等待一会后,再将GPIO口拉低即可,等待的时间可以查看超声波模块手册(us级别)

测量Echo上高电平持续时间(得到超声波往返时间):使用STM32定时器上的输入捕获,通过两个输入捕获寄存器的差可以得到高电平持续时间

实验-使用超声波测距

CubeMX配置:

CubeMX配置

先启动Debug,开启外部晶振,配置时钟树72MHZ,使用OLED屏幕显示测距,所以打开I2C1。

查看手册找到控制端Trig和输出端Echo对应引脚,Trig配置为GPIO_Output,Echo配置为TIM1_CH3,并打好对应Label

然后转到TIM1,选择内部时钟源,配置TIM1的Channel3为输入捕获直接模式(Input Caputure direct mode),配置Channel4为输入捕获间接模式(indirect)

转到参数设置:PSC设置为72-1,计数器频率变为1MHZ,每1us计数值+1

Arr = 65535

Input Capture Channel 3 :设置上升沿检测输入捕获直接模式(direct)捕获寄存器前的分频设置为不分频Input Filter(滤波)为0,因为没什么抖动所以不用滤波。

Input Capture Channel 4:置下降沿检测输入捕获间接模式(indirect)捕获寄存器前的分频设置为不分频Input Filter(滤波)为0

因为需要使用中断模式,最后使能TIM1捕获比较中断(TIM1 capture compare interrupt)

函数

输入捕获函数启动

1
2
3
HAL_TIM_IC_Start(&htim1, TIM_CHANNEL_3);

/*IC就是input Capture输入捕获的意思*/

输入捕获中断

1
2
3
HAL_TIM_IC_Start_IT(&htim1, TIM_CHANNEL_4);

/*当下降沿来临时,说明高电平结束,我们需要获取两个输入捕获寄存器的值进行计算,因此需要在通道4捕获完成后,中断通知我们*/

读取捕获寄存器数值

1
2
3
HAL_TIM_ReadCapturedValue(&htim1,TIM_CHANNEL_3);

/*读取对应通道的捕获寄存器值*/

输入捕获中断回调

1
2
3
4
5
6
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{

}
/*在输入捕获完成后将会进入该函数*/

定时器计数值设置

1
2
__HAL_TIM_SET_COUNTER(&htim1,0);
/*参数1为对应定时器句柄,参数2为设置的值*/

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
int upEdge = 0;
int downEdge = 0;
float distance = 0;



void setup()
{
HAL_Delay(20);
OLED_Init();

HAL_TIM_Base_Start(&htim1);//启动定时器
HAL_TIM_IC_Start(&htim1, TIM_CHANNEL_3);//启动上升沿输入捕获
HAL_TIM_IC_Start_IT(&htim1, TIM_CHANNEL_4);//启动下降沿输入捕获,此处中断启动,因为得到捕获值后要计算
}

void loop()
{
/*Tig引脚发送脉冲信号:先拉高Trig引脚,等待一段时间后,再拉低 用于启动超声波测距*/
HAL_GPIO_WritePin(Trig_GPIO_Port,Trig_Pin,GPIO_PIN_SET);
HAL_Delay(1);
HAL_GPIO_WritePin(Trig_GPIO_Port,Trig_Pin,GPIO_PIN_RESET);
/*重置计数器的值,避免下降沿捕获数值小于上升沿捕获数值*/
__HAL_TIM_SET_COUNTER(&htim1,0);

/*使用显示器*/
OLED_NewFrame();
sprintf(message,"距离:%.2fcm",distance);
OLED_PrintString(0,0,message,&font16x16,OLED_COLOR_NORMAL);
OLED_ShowFrame();

HAL_Delay(500);//缓慢发送


}


void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{

/*在HAL_TIM_IRQHandler中可以发现每次进入中断回调函数前,htim->Channel都会被重新赋值*/
/*以表示当前处理的是哪个通道, 注意和之前的HAL_CHANNEL_4是不同的*/
if(htim == &htim1 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_4)
{

upEdge = HAL_TIM_ReadCapturedValue(&htim1,TIM_CHANNEL_3);
/*分别通过两个捕获寄存器得到上升沿对应计数和下降沿对应计数*/
downEdge = HAL_TIM_ReadCapturedValue(&htim1,TIM_CHANNEL_4);
distance = (upEdge - downEdge) * 0.034 / 2;
/*TIM分频后为1Mhz即1μs一次,声速影响条件较多,这里取340m/s,即0.034cm/us,最后单位为cm*/
}

}

启动超声波测距

1
2
3
4
5
6
7
8
9
void startMeasureDistance()
{
/*Trig引脚发送脉冲信号:先拉高Trig引脚,等待一段时间后,再拉低 用于启动超声波测距*/
HAL_GPIO_WritePin(Trig_GPIO_Port,Trig_Pin,GPIO_PIN_SET);
HAL_Delay(1);
HAL_GPIO_WritePin(Trig_GPIO_Port,Trig_Pin,GPIO_PIN_RESET);
/*重置计数器的值,避免下降沿捕获数值小于上升沿捕获数值*/
__HAL_TIM_SET_COUNTER(&htim1,0);
}

计算高电平持续时间和被测物体距离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{

/*在HAL_TIM_IRQHandler中可以发现每次进入中断回调函数前,htim->Channel都会被重新赋值*/
/*以表示当前处理的是哪个通道, 注意和之前的HAL_CHANNEL_4是不同的*/
if(htim == &htim1 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_4)
{
upEdge = HAL_TIM_ReadCapturedValue(&htim1,TIM_CHANNEL_3);
downEdge = HAL_TIM_ReadCapturedValue(&htim1,TIM_CHANNEL_4);
distance = (downEdge - upEdge) * 0.034 / 2;
/*TIM分频后为1Mhz即1μs一次,声速影响条件较多,这里取340m/s,即0.034cm/us,最后单位为cm*/
}

}

旋转编码器

概念:用来测量位置、速度或旋转方向的传感器,当期旋转轴旋转时,其输出端可以输出与旋转速度和方向对应的方波信号,读取方波信号的频率和相位信息可以得知旋转轴的速度和方向

常见的有增量型旋转编码器、绝对型编码器

【STM32教程】扭扭扭,转转转,轻松掌握编码器!_哔哩哔哩_bilibili

增量型旋转编码器

原理

增量型旋转编码器一般有A、B两相输出信号,当旋转编码器没有旋转时,A、B两相均没有电平变化,稳定输出高电平或者低电平。

当旋转编码器被顺时针旋转时,A相会输出一个方波,B相此时也会输出一个方波,但是B相方波领先A相90度,也就是B相先产生上升沿/下降沿,稍后A相再产生上升沿/下降沿。也可以说A相上升沿时,B相为高电平,A相为下降沿时,B相为低电平。

逆时针旋转时,情况相反,B相方波落后A相90度,A相上升沿时,B相为低电平,A相为上升沿时,B相为高电平。

这样我们可以

  1. 通过计算A相或者B相上升沿或者下降沿的数量来获取旋转编码器的角度。
  2. 可以通过A相边沿时,B相电平的情况得知当前的旋转方向

注意:不同旋转方向下A、B相到底谁在前谁在后,也可能根据具体元器件的不同而反过来!!!!

查阅旋转编码器的手册中可以看到,20 pulses/360°,即每360读输出20个脉冲,也就是每个脉冲为18°,所以用脉冲数量乘以18°就能得到真实的旋转角度

旋转角度° = 脉冲数量 * 每个脉冲对应的角度

当然对于旋钮这种用户输入,我们一般只需要知道用户向哪个方向旋转了多大程度就好,不必计算真实角度

实现思路

思路1(中断)

  1. 将A、B相信号接入到GPIO口后,将A相的GPIO口设置为上升沿(或者下降沿触发中断),然后再中断回调中读取B相GPIO口的电平状态,即可判断旋转方向。

  2. 同时,根据旋转方向对计数值加1或减1,来记录脉冲数量。

缺点

处理单片机上转的慢的旋钮效果不错,但是处理转的非常快的电机的旋转编码器时,容易频繁触发中断,导致CPU工作效率低,还有可能出现软件处理跟不上导致丢步问题

思路2(定时器):

通用/高级定时器为增量型编码器准备了专门的编码器接口,只要将A、B两相信号同时输入进去就可以实现正传时计数器自增,反转时计数器自减

那么我们将如何将A、B相信号输入编码器呢?

编码器两个输入接口其实是早已了解过的TI1FP1和TI2FP2,也就是说我们**直接将A、B相信号接入到定时器的通道1(TI1)和通道2(TI2),就可以接入到编码器接口,让编码器可以根据A、B相的信号 控制计数器进行增加和减少,**而且还可以利用这两个通道的滤波器与边沿检测器对A、B相信号初步处理

编码器接口对上下边沿都很敏感,对于A、B相上的一组脉冲会计数两次。例如:A相下降沿时,B相为低电平,计数器+1,A相上升沿时,B相为高电平,计数器又+1。反向时同理。

一句话来说就是:一个脉冲上下降沿都会被计数,一共两次

实验-使用增量型旋转编码器控制小灯亮度

CubeMX配置

  1. 配置Debug,打开外部晶振,配置时钟树

  2. 查阅原理图找到A相、B相、按键找到对应引脚和TIM(该板为TIM1),配置引脚和TIM

  3. 由于旋转编码器能自主产生两路信号,故不用配置内部时钟源。直接找到Combined Channels(组合通道)设置,选择为Encoder Mode(编码器模式),引脚自动被设置。

  4. 旋转编码器对应定时器参数配置

Counter Settings:

PSC:默认为0不分频,由于编码器对上下沿都敏感,此时编码器旋转一次计数为2,如果想要旋转一次计数器计数为1的话,设置PSC = 2-1 (二分频)即可,或者可以在代码中手动对counter值进行修改

ARR:编码器对应定时器的计数器,保持默认的65535,或设置为想要的值即可

Encoder

Encoder Mode:是选择在哪个通道进行计数,如果选择两个通道都计数的话,一个脉冲将会被计数4次,通常配置为TI1即可

Polarity(极性设置):类似于有效电平机制,设置下降沿有效,会将此通道波形翻转,如果与平时顺时针增加,逆时针减少不符,可以修改一个通道的极性即可

IC Selection(输入捕获):只能进行直接捕获 走TI1FP1和TI2FP2

Prescaler Division Ratio(预分频器分频比):不进行预分频

Input Filter(输入滤波):可以设置为最大值15,不滤波其实也没为问题

  1. 编码器可以按下当按键使用,配置为输入模式或中断模式都可,注意是否需要开启内部上拉。
  2. 为了实现亮度调节,找到小灯对应的定时器设置(这里为TIM3),勾选内部时钟源,将通道设置为PWM生成模式(PWM Generation),PSC = 72-1 ARR = 100-1
  3. 为了使用OLED,打开I2C1

函数

  • 编码器启动
1
2
3
4
HAL_TIM_Encoder_Start(&htim1,TIM_CHANNEL_ALL);

/*参数1:定时器对应结构体句柄*/
/*参数2:对应通道,对于只有一相信号的单相编码器来说,填通道1或2。若有A、B两项以上的,一般填TIM_CHANNEL_ALL,所有通道,其实也就是通道1和2。*/
  • 获取定时器计数值
1
2
3
__HAL_TIM_GET_COUNTER(&htim1);

/*内部库使用,能够获取当前的计数器值,编码器旋转时计数值会自增或自减*/

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
int counter = 0;

uint32_t Channel[3] = {TIM_CHANNEL_1, TIM_CHANNEL_2, TIM_CHANNEL_3};
int ChannelIndex = 0;

void setup()
{
HAL_Delay(20);
OLED_Init();

/*启动编码器,启动小灯的PWM输出*/
HAL_TIM_Encoder_Start(&htim1, TIM_CHANNEL_ALL);
HAL_TIM_PWM_Start(&htim3,TIM_CHANNEL_1);

}

void loop()
{
counter = __HAL_TIM_GET_COUNTER(&htim1);
__HAL_TIM_SET_COMPARE(&htim3, Channel[ChannelIndex], counter);
/*每次循环更新encoder定时器的计数值*/

OLED_NewFrame();

sprintf(message, "counter:%d", counter);
OLED_PrintString(0, 0, message, &font16x16, OLED_COLOR_NORMAL);
OLED_DrawRectangle(0,20,100,12,OLED_COLOR_NORMAL);
OLED_DrawFilledRectangle(1,20,counter,12,OLED_COLOR_NORMAL);
OLED_ShowFrame();
HAL_Delay(100);
/*OLED上显示counter,和进度条*/

}


void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{

if (GPIO_Pin == KEY_ENCODER_Pin)
{
HAL_TIM_PWM_Stop(&htim3,Channel[ChannelIndex]);
ChannelIndex = (ChannelIndex+1)%3;
HAL_TIM_PWM_Start(&htim3,Channel[ChannelIndex]);
}
/*实现按下编码器切换小灯,未作消抖处理*/
}

舵机(SERVO)

概念:舵机由于经常用于控制航模船模的舵面而得名,是一种比较简易的伺服电机系统。

原理(PWM)

以SG90为例,可以控制舵机从0°旋转到180°**,查看舵机手册**可以得到0~180°分别对应着500~2500us的高电平时长。

我们见到的多数舵机通常使用50HZ,也就是周期为20ms的PWM信号进行控制,因而500us~2500us对应的占空比为:

500us~2500us/20ms = 2.5%~12.5% -> 0°~180°

即输出占空比为2.5%时,舵机旋转到0°,输出占空比为7.5%,舵机旋转到90°,输出占空比为12.5%时,舵机旋转到180°

绝大多数舵机控制的占空比范围都是2.5%~12.5%,因为绝大多数的航模遥控器也是输出这个范围的信号

image-20241123213856589

实验-使用旋转编码器控制舵机(SG90)旋转

CubeMX配置

  1. 配置Debug,打开外部晶振,配置时钟树
  2. 配置好旋转编码器,PSC默认,ARR设置为20,其余默认即可
  3. 查看手册找到舵机对应TIM,打开内部时钟,配置对应通道的PWM生成模式。
  4. 使用50HZ的PWM信号,假设TIM的内部时钟为72MHZ,配置PSC为720-1 ,ARR = 2000-1。
  5. 生成代码

注意:因为旋转编码器20个脉冲对应的是360°,也就是计数40次。而舵机只能旋转180°,所以最大计数值应该设为20

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#define MAX_COUNT 20

char message[50]
int counter = 0;
int duty = 0;

void setup()
{
// AHT20_Init();

HAL_Delay(20);
OLED_Init();

HAL_TIM_Encoder_Start(&htim1, TIM_CHANNEL_ALL);
HAL_TIM_PWM_Start(&htim4,TIM_CHANNEL_3);
}


void loop()
{

counter = __HAL_TIM_GET_COUNTER(&htim1);

/*占空比(duty cycle)*/
duty = ((((float)counter / MAX_COUNT)*10 + 2.5)/100.0)*2000;
/*定时器最大值为2000,计算占空比为2.5%~12.5%对应的比较寄存器的值*/
__HAL_TIM_SET_COMPARE(&htim4, TIM_CHANNEL_3, duty);

OLED_NewFrame();
sprintf(message, "counter:%d", counter);
OLED_PrintString(0, 0, message,&font16x16,OLED_COLOR_NORMAL);
OLED_ShowFrame();
HAL_Delay(10);
}

看门狗

介绍

STM32微控制器中的看门狗外设是一种用于监视系统运行的外设,它是一种硬件计时器,用于检测系统是否正常运行。其基本原理是周期性地重置系统,以确保系统在正常情况下能够响应。当系统出现异常情况(如死循环、软件错误等)导致停止响应时,看门狗定时器将超时并执行其预设的动作,例如重置系统或触发中断,从而使系统得以恢复或采取适当的措施。

在STM32微控制器中,看门狗外设主要分为两种类型:独立看门狗和窗口看门狗。

独立看门狗

独立看门狗-IWDG(Independent Watchdog):

  • 特点: 独立看门狗是一种基本的看门狗类型,本质是一个单独的定时器,独立于主处理器,即使主时钟发生故障仍然有效。
  • 驱动时钟:由专用的低速时钟(LSI)驱动(40kHz),由于LSI的时钟频率不精确,故独立看门狗只适用于对时间精度要求比较低的场合
  • 用途独立看门狗主要用于监视整个系统的运行状态。当系统出现故障、死锁或其他异常情况时,独立看门狗会在预设的超时时间内未收到系统的喂狗信号时,触发重启操作,以恢复系统的正常运行。

独立看门狗可以简单理解为一个12位的递减计数器,看门狗激活后,如果计数器的重装载值递减到0,系统就会产生复位。如果在计数器递减到0之前刷新了计数器值,那么系统就不会产生复位。这个刷新计数器的过程我们称为"喂狗"

CubeMX配置

LSI时钟并不直接提供给计数器时钟,而是通过一个8位的预分频器寄存器IWDG_PR分频后输入给计数器时钟(具体见STM32xxxx的参考手册IWDG寄存器章节)。

CubeMX配置:

预分频系数prescaler取值:4、8、16、32、64、128、256。

溢出时间:Tout = (4*2^pre^ ) / 40 * rl r = (prescaler/40) *rlr 单位:ms

  • pre是预分频系数(0-6),而(4*2^pre^)是prescaler的取值代表4分频、8分频等。

  • rlr是重装载寄存器的值 (12位,rlr<4096,即0xFFF)

  • 40KHz为LSI输入时钟频率

  • prescaler/40是分频后的频率的倒数即周期,再乘rlr代表定时器溢出时间

相关函数

看门狗初始化函数:

1
HAL_IWDG_Init(IWDG_HandleTypeDef *hiwdg)

喂狗函数

1
2
3
HAL_IWDG_Refresh(IWDG_HandleTypeDef *hiwdg)

举例: HAL_IWDG_Refresh(&hiwdg); //看门狗喂狗

窗口看门狗

窗口看门狗-WWDG(Window Watchdog):

  • 特点: 窗口看门狗是一种高级的看门狗类型,它具有两个阈值,即看门狗窗口。只有在这个窗口内喂狗信号才被视为有效,超出窗口范围的喂狗信号会被视为异常。
  • 驱动时钟:由APB1时钟(如:36MHZ)分频后得到时钟驱动,通过可配置的时间窗口来检测应用程序非正常的过迟或过早操作。 窗口看门狗最适合那些要求看门狗在精确计时窗口起作用的程序。
  • 用途: 窗口看门狗不仅可以监视系统的整体运行状态,还可以检测特定时间段内的系统运行状态。它可以帮助系统在特定的时间窗口内完成任务,以确保系统的实时性和稳定性。比如一个程序段正常运行的时间是50ms, 在运行完这个段程序之后紧接着进行喂狗,如果在规定的时间窗口内还没有喂狗,那就说明我们监控的程序出故障了,跑飞了,那么就会产生系统复位,让程序重新运行。

窗口看门狗跟独立看门狗一样,也是一个递减计数器不断的往下递减计数,当减到一个固定值 0x3F 时还不喂狗的话,产生复位,这个值叫窗口的下限,是固定的值,不能改变。

窗口看门狗之所以称为窗口,就是因为其喂狗时间限制在一个有上下限(上下窗口)的范围内(计数器减到某个值~计数器减到0x3F),在这个范围内才可以喂狗,可以通过设定相关寄存器,设定其上限时间(但是下限是固定的0x3F)

中断

相比于独立看门狗,窗口看门狗可以使能中断,如果使能了提前唤醒中断,系统出现问题,喂狗函数没有生效,那么在计数器由减到0x40 (0x3F+1) 的时候,便会先进入中断,之后再递减一次才会复位。当然你也可以在中断里面喂狗

喂狗的操作,必须要在主循环里,而不能放在定时器中断里!这是很多初学者容易犯的错误!因为,如果出现了主循环跑飞或者陷入某个死循环,定时器中断可能还在正常运行,定期进入中断喂狗,则看门狗不能复位系统,起不到监测系统正常运行的作用;

配置

  1. 预分频系数选择(prescaler):1、2、4、8

  2. window value:上窗口值(要求在0x3F和计数器值之间)

  3. free-running downcounter value:向下递减的计数器值,如果不喂狗到0x3F会复位MCU(有7位,取值为0~127)

  4. Early wakeup interrupt:Enable使能提前唤醒中断,并开启中断

若APB1时钟为36MHZ,选择8分频,计数器取最大值127,上窗口设置为126

分频后 CLK = (APB1CLK/4096)/8 = 1098.63281HZ

(127-126)/1098.63281 = 0.910ms (递减到上窗口的最短时间)

(127-0x3F)/1098.63281 = 58.25ms(递减到下窗口的最长时间)

喂狗窗口期为:0.910ms~58.25ms,超出这个时间没有喂狗那么MCU就会复位

相关函数

看门狗初始化:

1
HAL_WWDG_Init(WWDG_HandleTypeDef *hwwdg)

喂狗

1
HAL_WWDG_Refresh(WWDG_HandleTypeDef *hwwdg)

看门狗中断处理函数

1
HAL_WWDG_IRQHandler(WWDG_HandleTypeDef *hwwdg)

看门狗中断回调函数

1
__weak HAL_WWDG_EarlyWakeupCallback(WWDG_HandleTypeDef *hwwdg);

实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*创建的user.c中*/
#include "main.h"
#include "wwdg.h"

uint16_t early_wwdg_flag = 0;
/*中断发生标志位*/

void setUp()
{
HAL_GPIO_WritePin(LED0_GPIO_Port,LED0_Pin,GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED1_GPIO_Port,LED1_Pin,GPIO_PIN_RESET);
}



void loop()
{

if(early_wwdg_flag==1)
{
HAL_GPIO_WritePin(LED1_GPIO_Port,LED1_Pin,GPIO_PIN_SET);
HAL_WWDG_Refresh(&hwwdg);

early_wwdg_flag = 0;
/*清除该标志位,等待下一次中断喂狗,避免一直重复喂狗*/
}s

}


void HAL_WWDG_EarlyWakeupCallback(WWDG_HandleTypeDef *hwwdg)
{
early_wwdg_flag=1;
}

设置了一个中断进入标志位,当主循环中检测到进入中断后,则喂狗,避免复位

烧录到开发板后可以看到,LED1亮起之后熄灭,没有再亮起,代表没有复位,实验成功。

注意:最好不要在中断回调函数中喂狗

【STM32】HAL库 STM32CubeMX教程五----看门狗(独立看门狗,窗口看门狗)_使用hall库程序跑飞-CSDN博客

传感器模块

传感器元件(光敏电阻/热敏电阻/红外接收管等)的电阻会随外界的模拟量的变化而变化,通过与定值电阻分压即可得到模拟电压输出,再通过电压比较器进行二值化即可得到数字电压输出

ADC

概述

Analog-to-Digital Converter的缩写。指模/数转换器或者模拟/数字转换器。是指将连续变量的模拟信号转换为离散的数字信号的器件。

典型的模拟数字转换器将模拟信号转换为表示一定比例电压值的数字信号。

【STM32】HAL库 STM32CubeMX教程九—ADC_cubemx adc-CSDN博客

重要概念

  1. 转换模式:单次转换模式,连续转换模式,扫描模式,间断模式
  2. ADC单/多通道
  3. 数据左/右对齐
  4. 电压输入范围
  5. ADC输入通道
  6. 注入通道(和中断类似),规则通道
  7. ADC时钟
  8. 外部触发转换(定时器、中断)
  9. 中断触发条件三个:规则通道转换结束注入通道转换结束模拟看门狗状态位被设置
  10. DMA触发:只有ADC1和ADC3才可,通常使用ADC都开启DMA

参数配置

CubeMX中配置

ADCs_Common_Settings:
Mode     ADC_Mode_Independent
这里设置为独立模式

独立模式模式下,双ADC不能同步,每个ADC接口独立工作。所以如果不需要ADC同步或者只是用了一个ADC的时候,应该设成独立模式,多个ADC同时使用时会有其他模式,如双重ADC同步模式,两个ADC同时采集一个或多个通道,可以提高采样率

Data Alignment (数据对齐方式): 右对齐/左对齐

这个上方有讲解,数据的左右对齐

Scan Conversion Mode( 扫描模式 ) :

如果只是用了一个通道的话,DISABLE就可以了(也只能DISABLE),如果使用了多个通道的话,会自动设置为ENABLE。 就是是否开启扫描模式

Continuous Conversion Mode(连续转换模式)

设置为ENABLE,即连续转换。如果设置为DISABLE,则是单次转换。两者的区别在于连续转换直到所有的数据转换完成后才停止转换,而单次转换则只转换一次数据就停止,要再次触发转换才可以进行转换

Discontinuous Conversion Mode(间断模式)

因为我们只用到了1个ADC,所以这个直接不使能即可


ADC_RegularConversionMode(规则通道设置):

Enable Regular Conversions (启用常规转换模式)

使能 否则无发进行下方配置

Number OF Conversion(转换通道数)   
用到几个通道就设置为几
多个通道自动使能扫描模式

Extenal Trigger Conversion Source (外部触发转换源)

设定ADC的触发方式:

  • Regular Conversion launched by software 规则的软件触发 调用函数触发即可

  • Timer X Capture Compare X event 外部引脚触发,

  • Timer X Trigger Out event 定时器通道输出触发 需要设置相应的定时器设置


Rank(转换顺序):         
这个只修改通道Sampling Time(采样时间)即可,设置为239.5Cycles

不同的采样时间会影响到ADC的转换精度和转换速度。较长的采样时间可以提供更稳定的转换结果,适合高阻抗的信号源,但会增加总的转换时间;而较短的采样时间可以提高转换速度,但可能会降低精度,特别是在处理高阻抗信号时。

多个通道时会有多个Rank,可以设定每个通道的转换顺序
ADC总转换时间如下计算:

TCONV = 采样时间+ 12.5个周期

当ADCCLK=14MHz(最大),采样时间为1.5周期(最快)时,TCONV =1.5+12.5=14周期=1μs。

因此,ADC的最小采样时间1us(ADC时钟=14MHz,采样周期为1.5周期下得到)


ADC_injected_ConversionMode(注入通道设置):
也就是注入通道的设置,和转换通道没啥太大区别,这里不再详解


WahchDog
Enable Analog WatchDog Mode(使能模拟看门狗中断)

本质也测量值就是超出测量范围或者低于最低范围,启动看门狗

  1. ADC转换结束中断配置
  2. ADC 的DMA传输配置

相关函数

  • 开启ADC的3种模式(轮询模式 中断模式 DMA模式)
1
2
3
• HAL_ADC_Start(&hadcx);       //轮询模式开启ADC
• HAL_ADC_Start_IT(&hadcx);       //中断轮询模式开启ADC
• HAL_ADC_Start_DMA(&hadcx);       //DMA模式开启ADC
  • 关闭ADC的3种模式(轮询模式 中断模式 DMA模式)
1
2
3
• HAL_ADC_Stop()
• HAL_ADC_Stop_IT()
• HAL_ADC_Stop_DMA()
  • 读取ADC转换值
1
• HAL_ADC_GetValue()
  • ADC校准函数
1
2
3
4
5
6
• HAL_ADCEx_Calibration_Start()

/*
* 通常精度要求时使用,一般添加在初始化ADC之后.
* 注意F4系列不支持!!!!
*/
  • 等待转换结束函数
1
2
3
• HAL_ADC_PollForConversion(&hadc1, 50);

第一个参数为指定的ADC,第二个参数为最大等待时间
  • ADC中断回调函数
1
2
3
• HAL_ADC_ConvCpltCallback()

转换完成后回调,DMA模式下DMA传输完成后调用
  • 规则通道以及看门狗的配置
1
2
• HAL_ADC_ConfigChannel() 配置规则组通道
• HAL_ADC_AnalogWDGConfig()

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

电位器介绍

电位器(Potentiometer)是一种三端的可调电阻器,常用于调节电压、电流或信号强度。它通过转动或滑动一个机械部件(如旋钮或滑杆)来改变电阻值,实现电路中的电压分配或控制。

  1. 在stm32单片机上使用VOL标注

  2. 原理图中标注为Variable resistor(可变电阻)

CubeMX配置

  1. 配置Debug,打开外部晶振,配置时钟树。为了ADC转换结果的准确性,配置时钟树时注意分频后的ADC时钟信号最好不超过14Mhz

  2. 从原理图中找到对应引脚,查看其对应ADC通道,然后在对应ADC中勾选通道

    • ADC_Settings -> Continuous Conversion Mode设为Enable,使ADC转换持续进行,不需要每次获取之前手动触发转换
    • ADC_Regular_ConversionMode -> Rank -> Sampling Time设为239.5 Cycles,最长采样时间,可以获得更稳定的转换结果,采样时间越长,转换结果越准确
  3. 若有OLED显示等配置即可

  4. 生成项目

业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
char message[50];
char message2[50];

int ADValue = 0;
float voltage = 0;

void setup()
{
// AHT20_Init();

HAL_Delay(20);
OLED_Init();

HAL_ADC_Start(&hadc1);
/*启动ADC连续转换*/
}

void loop()
{
/*ADC读取原始值获取*/
ADValue = HAL_ADC_GetValue(&hadc1);
/*12位精度的ADC,测量范围为0~3.3V,进行电压计算*/
/*该处电压范围可以在对应原理图上的VOL找到*/
voltage = ADValue * 3.3 / 4096;

OLED_NewFrame();
sprintf(message, "ADValue:%d", ADValue);
sprintf(message2, "voltage:%.2fV", voltage);
OLED_PrintString(0, 0, message,&font16x16,OLED_COLOR_NORMAL);
OLED_PrintString(0,16,message2,&font16x16,OLED_COLOR_NORMAL);
OLED_ShowFrame();
HAL_Delay(1000);
}

实验-使用NTC热敏传感器测量温度(单通道)

NTC介绍

NTC 是指 负温度系数热敏电阻Negative Temperature Coefficient thermistor),是一种随着温度升高电阻值下降的热敏电阻器件。它的主要特点是电阻值与温度呈反比关系。

原理

NTC热敏电阻本身是一个单独的电阻。在这个电路中,通常会与一个已知电阻(如10kΩ)串联,以形成一个分压器,这样可以通过测量电压来推算出NTC的电阻值。具体来说,NTC传感器的电阻值会随着温度变化而变化,而通过这个分压器电路可以得到一个对应的电压信号。

单片机上的原理图如下:

image-20240927235951857

电路工作原理

  1. NTC与固定电阻串联:电路中有一个NTC热敏电阻R_ntc和一个已知固定电阻R1串联。
  2. 电源:整个电路连接到一个电源Vcc(例如3.3V)。
  3. 分压器:在NTC热敏电阻上得到的电压V_ntc 。

计算步骤

  1. 通过ADC(12位)读取电压:ADC测量得到的值adc_value 代表的是在NTC热敏电阻上的电压:V_ntc = (adc_value / 4096 )* Vcc

  2. 由分压公式有: V_ntc = (R_ntc/R_ntc+R1 ) * Vcc

R_ntc = R1 * (v_ntc / vcc - v_ntc)

  1. 将ADC的值带入后简化得到R_ntc的值

R_ntc = R1 * (adc_value / 4096 - adc_value)

计算公式为
$$
R_{ntc} = R_1*\frac{adValue}{4096-adValue}
$$

得到R_ntc的值之后,使用B值公式计算出NTC热敏电阻的阻值对应温度T_ntc
$$
T_{ntc} = \frac{B}{ln\frac{R_{ntc}}{R1}+\frac{B}{T_0}}
$$
R_ntc:T_ntc温度下通过ADC计算得到的NTC电阻(单位为Ω)

R1:参考温度下ntc的电阻值(常温25°c,298.15K)

B:给出的B值(NTC热敏电阻的材料常数,单位K)

T0:常温(25℃,298.15K)

CubeMX配置

  1. 配置Debug,打开外部晶振,配置时钟树。为了ADC转换结果的准确性,配置时钟树时注意分频后的ADC时钟信号最好不超过14Mhz

  2. 从原理图中找到对应引脚,查看其对应ADC通道,然后在对应ADC中勾选通道

    • ADC_Settings -> Continuous Conversion Mode设为Enable,使ADC转换持续进行,不需要每次获取之前手动触发转换
    • ADC_Regular_ConversionMode -> Rank -> Sampling Time设为239.5 Cycles,最长采样时间,可以获得更稳定的转换结果,采样时间越长,转换结果越准确
  3. 若有OLED显示等配置即可

  4. 生成项目

业务代码

  • 通过ADC值计算NTC电阻值
1
2
3
4
5
6
float getResistance(uint32_t adValue) {
return (adValue / (4096.0f - adValue)) * 10000.0f;
}
/*参数adValue:ADC转换的结果*/
/*10000.f 为串联电阻值大小10KΩ*/
/*返回值:NTC电阻值,浮点数类型,单位Ω*/
  • 通过NTC阻值计算温度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float calTemperature(float R)
{
float B = 3950.0f;
float R1 = 10000.0f;
float T0 = 25.0f;
return B /(log(R/R1)+ B/(273.15+T0)) - 273.15;

/*C语言中log函数就是以e为底数的函数,即lnx*/
}

/** 参数R:通过adValue计算得到的电阻
* B:手册上给出的B值常数
* R1: 串联电阻大小10KΩ
* T0: 常温25℃
* 返回值:温度,float类型,单位摄氏度
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
float getResistance(uint32_t adValue);
float calTemperature(float R);

char message[50];
char message2[50];
int adValue = 0;
float R = 0;
float temp = 0 ;

void setup()
{
HAL_Delay(20);
OLED_Init();

HAL_ADC_Start(&hadc1);
}

void loop()
{
adValue = HAL_ADC_GetValue(&hadc1);

R = getResistance(adValue);
/*计算当前NTC电阻*/
temp = calTemperature(R);
/*C语言中log函数就是以e为底数的函数,即ln*/

OLED_NewFrame();
sprintf(message, "ADValue:%d", adValue);
sprintf(message2, "temp:%.2f", temp);
OLED_PrintString(0, 0, message,&font16x16,OLED_COLOR_NORMAL);
OLED_PrintString(0,16,message2,&font16x16,OLED_COLOR_NORMAL);
OLED_ShowFrame();
HAL_Delay(1000);
}

DMA

概念

DMA,全称Direct Memory Access,即直接存储器访问。

DMA传输将数据从一个地址空间复制到另一个地址空间,提供在外设和存储器之间或者存储器和存储器之间的高速数据传输,无须CPU的干预,通过DMA数据可以快速地移动。这就节省了CPU的资源来做其他操作。

DMA的作用就是实现数据的直接传输,而去掉了传统数据传输需要CPU寄存器参与的环节

一般在 数据量大的时候,频繁进入中断可能会出现问题,此时采用DMA搬运

DMA是CPU的小助手

【STM32】HAL库 STM32CubeMX教程十一—DMA (串口DMA发送接收)_stm32h7 串口dma 发送 第一次成功-CSDN博客

DMA传输方式

  • 方法1:DMA_Mode_Normal,正常模式。

当一次DMA数据传输完后,停止DMA传送 ,也就是只传输一次

  • 方法2:DMA_Mode_Circular,循环传输模式。

当传输结束时,硬件自动会将传输数据量寄存器进行重装,进行下一轮的数据传输。 也就是多次传输模式

串口DMA模式与收发不定长数据

DMA模式

1
2
3
4
5
6
7
8
9
10
/*只需要将串口中断发送函数改为DMA即可*/

串口DMA发送数据:
HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)

串口DMA接收数据:
HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)

串口DMA恢复函数
HAL_UART_DMAResume(&huart1)

使用DMA方式时,还是有中断参与其中,RxCpltCallback函数同样是由中断触发,只不过不是串口中断,而是DMA传输完成中断,DMA还有的就是传输过半中断

收发不定长数据-Idle

收发不定长数据主要依靠IDLE(串口空闲中断),所谓串口空闲中断就是,中断触发条件与接收的字节数无关,串口无数据接收时不会触发,必须要从接收到第一个数据开售,当RX引脚上无后续数据进入,串口接收从忙碌转为空闲时才会触发。可以认为空闲中断IDLE发生时就是一帧的数据包接收完成了,此时对数据进行分析处理即可。

一般用于接收大量数据

  • 串口接收数据
1
2
3
4
5
6
7
HAL_UARTEx_ReceiveToIdle();//阻塞
HAL_UARTEx_ReceiveToIdle_IT();//中断
HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);//DMA
/**
* Ex代表扩展,Idle代表空闲中断
* 以DMA为例,参数1,2不变,参数3并不是想要接收的长度,而是一次接收的最大长度
*/
  • 空闲中断对应回调函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if(huart == &huart2)
{
HAL_UART_Transmit_DMA(&huart2, rData, Size);

HAL_UARTEx_ReceiveToIdle_DMA(&huart2,rData,sizeof(rData));
}
}
/**
* Ex代表扩展,Idle代表空闲中断
* 参数2:size代表接收的数据长度
*/


使用ReceiveToIdle相关函数时,不再调用RxCpltCallback回调,而是使用了RxEventCallback进行回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//
// Created by keqiu on 24-9-13.
//


#include<gpio.h>


#include "tim.h"
#include "stdbool.h"
#include "usart.h"
uint16_t pwmVal = 0;
volatile bool mode = false;

uint8_t rData[50];

void loop()
{

}

void setup()
{
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);

/*首先还是要以Idle,启动中断或DMA模式*/
HAL_UARTEx_ReceiveToIdle_DMA(&huart2,rData,50);
// HAL_UARTEx_ReceiveToIdle_IT(&huart2,rData,50);
}


void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if(huart == &huart2)
{
HAL_UART_Transmit_DMA(&huart2, rData, Size);

HAL_UARTEx_ReceiveToIdle_DMA(&huart2,rData,sizeof(rData));//再次开启串口DMA或中断接收模式
}
}

DMA传输过半中断

可以使用IT或DMA方式启动空闲中断,但是使用DMA模式时,除了串口的空闲中断外,DMA的传输过半中断也会触发RxEventCallback回调函数,即接收的数据量到达我们设置的最大值的一半时,也会触发这个回调函数,一般场景不适用,但某些场景有用。

可以通过加大数组长度或关闭DMA传输过半中断解决

所以一般情况下要关闭DMA传输过半中断

  • 关闭DMA传输过半中断
1
2
3
4
5
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx,DMA_IT_HT);

//参数1:DMA通道的指针地址,上面是usart2的rx通道
//参数2:需要关闭的中断,DMA_IT_HT就是传输过半中断
//每次DMA方式启动时都需要关闭!!!!

注意:如果勾选了外设.c/.h文件单独生成,那么需要在usart.h中使用extern DMA_HandleTypeDef hdma_usart2_rx,否则找不到定义

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

#include "tim.h"
#include "stdbool.h"
#include "usart.h"

extern DMA_HandleTypeDef hdma_usart2_rx;

uint16_t pwmVal = 0;
volatile bool mode = false;

uint8_t rData[10];
void loop()
{

}

void setup()
{
// HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);

HAL_UARTEx_ReceiveToIdle_DMA(&huart2,rData,10);
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx,DMA_IT_HT);
/*关闭DMA半传输中断*/
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if(huart == &huart2)
{
HAL_UART_Transmit_DMA(&huart2, rData, Size);

HAL_UARTEx_ReceiveToIdle_DMA(&huart2,rData,sizeof(rData));
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx,DMA_IT_HT);
/*再次关闭DMA半传输中断*/
}
}

蓝牙模块与简易数据包解析

蓝牙模块介绍

蓝牙通信是其常见的无线通信方式之一。蓝牙模块可以帮助STM32实现与其他设备(如手机、电脑、其他蓝牙设备)之间的无线通信。蓝牙模块在STM32系统中可以通过UART、SPI、I2C等接口与主控制器连接。

蓝牙主要分类是经典蓝牙低功耗蓝牙(Bluetooth Low Energy,简称BLE)

经典蓝牙:一般像耳机这种持续传输数据的

低功耗蓝牙:间歇性同步数据设备常用于嵌入式,如手环

协议

实验(蓝牙发送数据控制LED-UART)

简易数据包解析

首先三个小灯、亮灭对应:

0x01 0x02 0x03 0xFF 0x00

红灯 绿灯 蓝灯 亮 灭

例如: 0x01 0x00 0x03 0xFF 代表红灯熄灭,蓝灯亮

  • 指令一般都会有包头,表示一帧数据的开始,这里规定包头为0xAA

  • 包头后往往有一位数据包长度,指示此数据包一共多长

  • 最后一位为校验和,为前面所有数据的和取1字节(16进制最后两位),当收到数据后自行计算出的结果与数据包中自带的校验位比较,相同则用,不同舍弃

假定:

AA 09 01 FF 02 FF 03 FF

AA + 9 +1 + FF + 3 + FF = 3B6 故校验位为B6

当向蓝色发送数据为

AA 09 01 FF 02 FF 03 FF B6(校验位) 代表包头0xAA 长度0x09 红灯绿灯蓝灯都亮

蓝牙发送数据控制LED开关

【keysking的STM32教程】第11集 使用蓝牙模块与简易数据包解析_哔哩哔哩_bilibili

  • 使用DX-BT24模块(BLE5.1),实现蓝牙透传通信,波特率设置为9600
  • 使用UART连接,UARTEx扩展库,实现不定长数据快速传输,不占用CPU资源
  • 使用DMA通道,实现串口数据的快速传输,不占用cpu资源

CubeMX配置:【BLE 蓝牙】蓝牙透传通信 | 波特律动 (keysking.com)

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


#include<gpio.h>
#include "stm32f1xx_hal.h"

#include "tim.h"
#include "stdbool.h"
#include "usart.h"





uint8_t rData[10];

uint8_t ReceiveData[50];

void loop()
{


}

void setup()
{
HAL_UARTEx_ReceiveToIdle_DMA(&huart3,ReceiveData,sizeof(ReceiveData));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx,DMA_IT_HT);

}









void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{

GPIO_PinState state = GPIO_PIN_RESET;

if(huart == &huart3)
{

if(ReceiveData[0] == 0xAA)
{
if(ReceiveData[1] == Size)
{
uint8_t sum = 0;
for(int i=0;i<Size-1;i++)
{
sum += ReceiveData[i];
}

if(sum == ReceiveData[Size-1])
{
for(int i=2;i<Size-1;i+=2)
{
if(ReceiveData[i+1] == 0xFF)
{
state = GPIO_PIN_SET;
}

if(ReceiveData[i] == 0x01)
{
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port,LED_BLUE_Pin,state);
}

else if(ReceiveData[i] == 0x03)
{
HAL_GPIO_WritePin(LED_RED_GPIO_Port,LED_RED_Pin,state);
}
}
}
}
}
HAL_UART_Transmit_DMA(&huart3, ReceiveData, Size);

HAL_UARTEx_ReceiveToIdle_DMA(&huart3,ReceiveData,sizeof(ReceiveData));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx,DMA_IT_HT);
}
}

最后使用蓝牙调试助手蓝牙,打开微信小程序【大夏无线传输助手】,点击【搜索】,找到 BT24,连接后发送

注意:切换16进制发送

IIC

介绍

I²C(Inter-Integrated Circuit)总线是一种由NXP(原PHILIPS)公司开发的两线式串行总线,用于连接微控制器及其外围设备。多用于主控制器和从器件间的主从通信,在小数据量场合使用,传输距离短,主从模式下,任意时刻只能有一个主机等特性,可以有多个从机。

IIC是一种低速的,半双工,同步的通信总线 ,常用于连接微控制器与各种外围设备。

在 STM32 中,I²C 接口可以应用于以下几种场景:

  1. 传感器读取:许多环境传感器(如温度、湿度、加速度等)使用 I²C 作为通信方式。通过 STM32 的 I²C 接口可以轻松地与这些传感器进行数据交换。
  2. 显示模块控制:一些小型的 OLED 或 LCD 显示屏使用 I²C 接口进行命令和数据传输。STM32 可以用来驱动这些显示模块以显示信息或图像。
  3. 音频编解码器:部分音频编解码芯片使用 I²C 作为配置接口,STM32 可以通过 I²C 配置这些芯片的工作模式,并接收或发送音频信号。
  4. 实时时钟模块(RTC):某些 RTC 模块也采用 I²C 接口来同步时间和日期信息,STM32 可以用作主控来更新 RTC 或从 RTC 获取时间。

这里要注意IIC是为了与低速设备通信而发明的,所以IIC的传输速率比不上SPI

IIC原理超详细讲解—值得一看-CSDN博客!!!!!!!!

分类

IIC分为软件IIC和硬件IIC

软件IIC:软件IIC通信指的是用单片机的两个I/O端口模拟出来的IIC,用软件控制管脚状态以模拟I2C通信波形,软件模拟寄存器的工作方式。

硬件IIC:一块硬件电路,硬件I2C对应芯片上的I2C外设,有相应I2C驱动电路,其所使用的I2C管脚也是专用的,硬件(固件)I2C是直接调用内部寄存器进行配置。

硬件I2C的效率要远高于软件的,而软件I2C由于不受管脚限制,接口比较灵活。

特点

IIC一共有只有两个总线: 一条是双向的串行数据线SDA,一条是串行时钟线SCL,数据线同时间只能发送或接收数据,故为半双工通信

  • SDA(Serial data)是数据线,D代表Data也就是数据,Send Data 也就是用来传输数据的。

  • SCL(Serial clock line)是时钟线,C代表Clock 也就是时钟 也就是控制数据发送的时序的

所有接到I2C总线设备上的串行数据SDA都接到总线的SDA上,各设备的时钟线SCL接到总线的SCL上。I2C总线上的每个设备都自己一个唯一的地址,来确保不同设备之间访问的准确性。

物理层与协议层

  • 物理层

I2C 总线在物理连接上非常简单,分别由SDA(串行数据线)和SCL(串行时钟线)及上拉电阻组成。

SCL和SDA都需要接上拉电阻 (大小由速度和容性负载决定一般在3.3K-10K之间) 保证数据的稳定性,减少干扰。

通信原理:是通过对SCL和SDA线高低电平时序的控制,来产生I2C总线协议所需要的信号进行数据的传递。在总线空闲状态时,SCL和SDA被上拉电阻Rp拉高,使SDA和SCL线都保持高电平。

  • 协议层

I2C 总线在传送数据过程中共有三种类型信号, 它们分别是:开始信号、结束信号和应答信号。

  • 开始信号:SCL 为高电平时,SDA 由高电平向低电平跳变,开始传送数据。

  • 结束信号:SCL 为高电平时,SDA 由低电平向高电平跳变,结束传送数据。

  • 应答信号:每当主机向从机发送完一个字节的数据,主机总是需要等待从机给出一个应答信号,以确认从机是否成功接收到了数据。

应答信号:主机SCL拉高,读取从机SDA的电平,为低电平表示产生应答

应答信号为低电平时,规定为有效应答位(ACK,简称应答位),表示接收器已经成功地接收了该字节;
应答信号为高电平时,规定为非应答位(NACK),一般表示接收器接收该字节没有成功。

每发送一个字节(8个bit)在一个字节传输的8个时钟后的第九个时钟期间,接收器接收数据后必须回一个ACK应答信号给发送器,这样才能进行数据传输。

应答出现在每一次主机完成8个数据位传输后紧跟着的时钟周期,低电平0表示应答,1表示非应答。

注意:这些信号中,起始信号是必需的,结束信号和应答信号,都可以不要。

  • 时序图

image-20241103171954458

  • 应答

image-20241103174539094

主从

主机和从机的概念

主机就是负责整个系统的任务协调与分配,从机一般是通过接收主机的指令从而完成某些特定的任务,主机和从机之间通过总线连接,进行数据通讯。

  • 发布主要命令的称为主机
  • 接受命令的称为从机

I2C是一种主从通信协议,允许多个设备连接在同一条总线上。每个从设备都有一个唯一的地址,称为Slave Address,以便主设备能够与特定的从设备进行通信。

Slave Address是由7位或10位组成的二进制数字,在通信时主设备将此地址发送到总线上,从设备根据地址进行识别。

当主设备想要与某个从设备通信时,它会发送从设备的地址。如果从设备检测到自己的地址匹配,它将响应通信请求。主设备可以发送读命令或写命令,以此来从从设备获取数据或向其发送数据。

例如,假设STM32作为I2C主设备,而一个SSD1306 OLED显示屏作为从设备。SSD1306的I2C地址为0x3C(或0x78表示为8位地址)。主设备通过发送0x3C的地址来选择并与这个显示屏通信。

IIC相关函数

IIC读写

  • IIC写函数
1
2
3
4
5
6
7
 HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);

- hi2c 设置使用的是那个IIC 例:&hi2c2
- DevAddress 写入的地址 设置写入数据的地址 例 0xA0
- *pData 需要写入的数据
- Size 要发送的字节数
- Timeout 最大传输时间,超过传输时间将自动退出传输函数
  • IIC读函数
1
2
3
HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout);

- 参数同上
  • IIC写数据函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);
/* 第1个参数为I2C操作句柄
第2个参数为从机设备地址
第3个参数为从机寄存器地址
第4个参数为从机寄存器地址长度
第5个参数为发送的数据的起始地址
第6个参数为传输数据的大小
第7个参数为操作超时时间   */


- *hi2c: I2C设备号指针,设置使用的是那个IIC 例:&hi2c2
- DevAddress: 从设备地址 从设备的IIC地址 例E2PROM的设备地址 0xA0
- MemAddress: 从机寄存器地址 ,每写入一个字节数据,地址就会自动+1,如果是256K的寄存器,那么就是00~FF
- MemAddSize: 从机寄存器地址字节长度 8位或16
写入数据的字节类型 8位还是16
8bit:I2C_MEMADD_SIZE_8BIT
16bit:I2C_MEMADD_SIZE_16BIT
- *pData: 需要写入的的数据的起始地址
- Size: 传输数据的大小 多少个字节
- Timeout: 最大读取时间,超过时间将自动退出函数

8位读写

1
2
3
4
HAL_I2C_Mem_Write(&hi2c2, ADDR, i, I2C_MEMADD_SIZE_8BIT,&(I2C_Buffer_Write[i]),8, 1000);

HAL_I2C_Mem_Read(&hi2c2, ADDR, i, I2C_MEMADD_SIZE_8BIT,&(I2C_Buffer_Write[i]),8, 1000);

16位读写

1
2
3
HAL_I2C_Mem_Write(&hi2c2, ADDR, i, I2C_MEMADD_SIZE_16BIT,&(I2C_Buffer_Write[i]),8, 1000);

HAL_I2C_Mem_Read(&hi2c2, ADDR, i, I2C_MEMADD_SIZE_16BIT,&(I2C_Buffer_Write[i]),8, 1000);

与上方写函数区别

IIC写多个数据 该函数适用于IIC外设里面还有子地址寄存器的设备,比方说E2PROM,除了设备地址,每个存储字节都有其对应的地址

**如果只往某个外设中写数据,则用Master_Transmit。 如果是外设里面还有子地址,例如我们的E2PROM,有设备地址,还有每个数据的寄存器存储地址。则用Mem_Write。
Mem_Write是2个地 **

址,Master_Transmit只有从机地址

IIC中断

  • 中断和DMA相关函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HAL_I2C_Master_Transmit_IT(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer));
/*中断读写*/
HAL_I2C_Master_Receive_IT(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer));

HAL_I2C_Master_Transmit_DMA(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer));
/*DMA模式读写*/
HAL_I2C_Master_Receive_DMA(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer));


/*中断回调函数和DMA模式下进入的回调函数相同,都是下面的函数*/
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c);//发送回调


void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c);//接收回调

实验-读写EEPROM(AT24C02)

【STM32】HAL库 STM32CubeMX教程十二—IIC(读取AT24C02 )_hal iic-CSDN博客

EEPROM介绍

AT24C02是一个2K Bit的串行EEPROM存储器(掉电不丢失),内部含有256个字节。在24C02里面有一个8字节的页写缓冲器

image-20241103234626099

可以通过存储IC的型号来计算芯片的存储容量是多大,比如24C02后面的02表示的是可存储2Kbit的数据,转换为字节的存储量为2*1024/8 = 256byte。

那么24C04后面的04表示的是可存储4Kbit的数据,转换为字节的储存量为41024/8 = 512byte,以此来类推其它型号的存储空间。

设备地址

下图为芯片从地址:

可以看出对于不同大小的24Cxx,具有不同的从器件地址。由于24C02为2k容量,也就是说只需要参考图中第一行的内容:

image-20241103234639611

这些只需要查看对应的手册都能找到

芯片的寻址
AT24C设备地址为如下,前四位固定为1010,A2~A0为由管脚电平。AT24CXX EEPROM Board模块中默认为接地。所以A2~A0默认为000,最后一位表示读写操作。所以AT24Cxx的读地址为0xA1,写地址为0xA0。

也就是说如果是
写24C02的时候,从器件地址为10100000(0xA0);
读24C02的时候,从器件地址为10100001(0xA1)。

片内地址寻址

芯片寻址可对内部256Byte中的任一个进行读/写操作,其寻址范围为00~FF,共256个寻址单位。

对应的修改 A2A1A0 三位数据即可

写读数据

注意:

  1. 在写数据的过程中,每成功写入一个字节,E2PROM存储空间的地址就会自动加1,当加到0xFF后,再写一个字节,地址就会溢出又变成0x00

  2. 写数据的时候需要注意,E2PROM是先写到缓冲区,然后再“搬运到”到掉电非易失区。所以这个过程需要一定的时间,AT24C02这个过程是不超过5ms!
    所以,当我们在写多个字节时,写入一个字节之后,再写入下一个字节之前,必须延时5ms才可以

对应教程:https://blog.csdn.net/as480133937/article/details/105259075

CubeMX配置

  1. 查看芯片手册打开对应I2C外设,参数全部默认即可
  2. 启动对应的串口

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
//
// Created by keqiu on 24-11-4.
//
#include "userCode.h"
#include "i2c.h"
#include "usart.h"
#include <stdio.h>
#include <string.h>

#define AT24C02_ADDR_WRITE 0xA0
#define AT24C02_ADDR_READ 0xA1

uint8_t message[10]={0};
uint8_t sendBuffer[256]={0};
uint8_t receiveBuffer[256]={0};


void setup()
{
HAL_GPIO_WritePin(LED0_GPIO_Port,LED0_Pin,GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED1_GPIO_Port,LED1_Pin,GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED2_GPIO_Port,LED2_Pin,GPIO_PIN_RESET);

// HAL_UART_Transmit(&huart1,"Hello World",11,1000);

for (int i = 0;i < 256; i++)
{
sendBuffer[i] = i;
}

for(int j=0;j<32;j++)
{
if(HAL_I2C_Mem_Write(&hi2c1,AT24C02_ADDR_WRITE,j*8,
I2C_MEMADD_SIZE_8BIT,sendBuffer+j*8,8,1000) == HAL_OK)
{
HAL_UART_Transmit(&huart1,"Write Test OK\n",14,1000);
HAL_Delay(20);//写数据的时候大于5ms的延时
}
else
{
HAL_UART_Transmit(&huart1,"Write Test Failed\n",18,1000);
HAL_Delay(20);//同上
}
}
/*
// wrinte date to EEPROM 如果要一次写一个字节,写256次,用这里的代码
for(i=0;i<BufferSize;i++)
{
HAL_I2C_Mem_Write(&hi2c1, ADDR_24LCxx_Write, i, I2C_MEMADD_SIZE_8BIT,&WriteBuffer[i],1,0xff);//使用I2C块读,出错。因此采用此种方式,逐个单字节写入
HAL_Delay(5);//此处延时必加,与AT24C02写时序有关
}
printf("\r\n EEPROM 24C02 Write Test OK \r\n");
*/

HAL_I2C_Mem_Read(&hi2c1,AT24C02_ADDR_READ,0,I2C_MEMADD_SIZE_8BIT,receiveBuffer,256,1000);

for(int k=0;k<256;k++)
{
sprintf(message,"0x%02X ",receiveBuffer[k]);
HAL_UART_Transmit(&huart1,message,6,1000);
}


}


void loop()
{
// HAL_UART_Transmit(&huart1,"Hello World",11,1000);
// HAL_Delay(1000);
}

注意事项

  • AT24C02的IIC每次写之后要延时一段时间才能继续写 每次写之后要delay 5ms左右 不管硬件IIC采用何种形式(DMA,IT),都要确保两次写入的间隔大于5ms;

  • AT24C02页写入只支持8个byte,所以需要分32次写入。这不是HAL库的bug,而是AT24C02的限制,其他的EEPROM可以支持更多byte的写入。
    当然,你也可以每次写一个字节,分成256次写入,也是可以的 那就用注释了的代码即可

  • 读写函数最后一个超时调整为1000以上 因为我们一次写8个字节,延时要久一点

  • 注意读取AT24C02数据的时候延时也要久一点,否则会造成读的数据不完整

实验-使用AHT20温湿度传感器(轮询)

【I²C总线】AHT20温湿度传感器 | 波特律动 (keysking.com)

【STM32入门教程-2024】第12集 IIC通信与温湿度传感器AHT20(DHT20)_哔哩哔哩_bilibili

CubeMX配置:

  1. 打开I2C外设,参数全部默认即可
  2. 启动对应的串口

传感器读取流程

打开温湿度传感器AHT20数据手册,找到5.4传感器读取流程

image-20240920220016640

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*读取流程1*/
void AHT20_Init()
{
uint8_t readBuffer;
HAL_Delay(40);
HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,&readBuffer,1,100);

if( (readBuffer & 0x08) == 0x00)
{
uint8_t sendBuffer[3] = {0xBE,0x08,0x00};
HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer),100);
}

}

(readBuffer & 0x08) == 0x00 这里按位与就是确定第4位Bit[3]是否为1,不为1就发送sendBuffer

注意&的优先级很低,要加括号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void AHT20_ReadData(float* temperature, float* humidity)
{
uint8_t sendBuffer[3] = {0xAC,0x33,0x00};
uint8_t readBuffer[6];

/*流程2*/ HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer),100);
HAL_Delay(75);
HAL_I2C_Master_Receive(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer),100);
----------------------------------------
/*流程3*/
if((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
data = ((uint32_t)readBuffer[1] << 12) + ((uint32_t)readBuffer[2] << 4) + ((uint32_t)readBuffer[3] >> 4);
/*流程4*/
*humidity = (data * 100.0)/(1<<20);

data = ( ((uint32_t)readBuffer[3]&0x0F) << 16) + ((uint32_t)readBuffer[4] << 8) + (uint32_t)readBuffer[5];
/*流程4*/
*temperature = (data*200.0)/(1<<20)-50;

}
}

image-20240920221150638

上图中蓝色为从机发送给主机的数据,可得温度和湿度都是2.5个字节

需要将其拆开后拼接起来可得到温湿度数据,使用位操作,此时注意强制转换为uint32_t避免移动时丢失数据

最后计算image-20240920221420656

SRH就是拼接后的湿度,ST就是拼接后的温度

代码

我们通常会为不同的模块单独建立驱动文件, 新建.c/.h文件aht20.c和aht20.h,在main.c中进行获取温湿度并显示

image-20240920220428021

设备地址7位,但是发送时通常是8位,包括一位读写为,故要左移一位即01110000故设备地址为0x70,使用函数时会根据读或写,自动帮我们确定最后一位为0还是1,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*aht20.h文件*/

//
// Created by keqiu on 24-9-20.
//

#ifndef AHT20_H
#define AHT20_H

#include <i2c.h>

#define AHT20_ADDRESS 0x70

void AHT20_Init();

void AHT20_ReadData(float* temperature,float* humidity);


#endif //AHT20_H

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

//
// Created by keqiu on 24-9-20.
//

#include "aht20.h"
void AHT20_Init()
{
uint8_t readBuffer;
HAL_Delay(40);
HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,&readBuffer,1,100);
if( (readBuffer & 0x08) == 0x00)
{
uint8_t sendBuffer[3] = {0xBE,0x08,0x00};
HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer),100);
}

}


void AHT20_ReadData(float* temperature, float* humidity)
{
uint8_t sendBuffer[3] = {0xAC,0x33,0x00};
uint8_t readBuffer[6];

HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer),100);
HAL_Delay(75);
HAL_I2C_Master_Receive(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer),100);

if((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
data = ((uint32_t)readBuffer[1] << 12) + ((uint32_t)readBuffer[2] << 4) + ((uint32_t)readBuffer[3] >> 4);
*humidity = (data * 100.0)/(1<<20);

data = ( ((uint32_t)readBuffer[3]&0x0F) << 16) + ((uint32_t)readBuffer[4] << 8) + (uint32_t)readBuffer[5];
*temperature = (data*200.0)/(1<<20)-50;

}
}

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

//
// Created by keqiu on 24-9-13.
//


#include<gpio.h>

#include "aht20.h"
#include "stm32f1xx_hal.h"

#include "tim.h"
#include "stdbool.h"
#include "usart.h"
#include <stdio.h>


float temperature;
float humidity;
char message[50];

void setup()
{
AHT20_Init();

}

void loop()
{

AHT20_ReadData(&temperature,&humidity);
sprintf(message,"温度:%.lf°c 湿度: %.lf %%",temperature,humidity);
/*sprintf函数可以拼接字符串,需要包含stdio.h文件*/ HAL_UART_Transmit(&huart2,message,sizeof(message),1000);
/*发送到串口,使用串口调试助手*/
HAL_Delay(1000);

}

IIC中断与DMA以及状态机编程

IIC中断和DMA使用方法和串口类似

相关函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HAL_I2C_Master_Transmit_IT(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer));
/*中断读写*/
HAL_I2C_Master_Receive_IT(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer));

HAL_I2C_Master_Transmit_DMA(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer));
/*DMA模式读写*/
HAL_I2C_Master_Receive_DMA(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer));


/*中断回调函数和DMA模式下进入的回调函数相同,都是下面的函数*/
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c);//发送回调


void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c);//接收回调


状态机编程

在STM32微控制器(MCU)编程中,状态机(State Machine)是一种常用的设计模式,用来管理复杂系统的不同状态以及在状态之间进行的切换。状态机编程有助于使代码结构清晰、易于维护,特别适用于处理嵌入式系统中的顺序逻辑、通信协议、控制流程等问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*状态机编程通常是使用枚举设定状态,例如该模块实验中可以设置如下状态*/
typedef enum
{
AHT_MEASURE,
AHT_SEND,
AHT_GET,
AHT_RECEIVE,
AHT_ANALYSIS
} AHT20State;
/*
* 0:初始状态发送测量命令
* 1:发送中
* 2:发送完成,75ms后进行读取
* 3:读取中
* 4:读取完成,数据解析
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*轮询中对应处理*/
void loop()
{
switch (aht20State)
{
case AHT_MEASURE:
AHT20_Measure();
aht20State = AHT_SEND;
case AHT_GET:
HAL_Delay(75);
AHT20_Get();
aht20State = AHT_RECEIVE;
case AHT_ANALYSIS:
AHT20_Analysis(&temperature,&humidity);
sprintf(message,"温度:%.1lf°c 湿度:%.1lf %%",temperature,humidity);

HAL_UART_Transmit(&huart2,message,sizeof(message),1000);
HAL_Delay(1000);
aht20State =0;
default:
break;
}
}

实验-使用AHT20温湿度传感器(状态机)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*aht20.h文件*/

//
// Created by keqiu on 24-9-20.
//

#ifndef AHT20_H
#define AHT20_H

#include <i2c.h>

#define AHT20_ADDRESS 0x70

typedef enum
{
AHT_MEASURE,
AHT_SEND,
AHT_GET,
AHT_RECEIVE,
AHT_ANALYSIS
} AHT20State;
/*
* 0:初始状态发送测量命令
* 1:发送中
* 2:发送完成,75ms后进行读取
* 3:读取中
* 4:读取完成,数据解析
*/

void AHT20_Init();
//void AHT20_ReadData(float* temperature,float* humidity);

void AHT20_Measure();
void AHT20_Get();
void AHT20_Analysis(float* temperature, float* humidity);
#endif //AHT20_H

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

#include "aht20.h"

uint8_t readBuffer[6]={0};

void AHT20_Init()
{
uint8_t readBuffer;
HAL_Delay(40);
HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,&readBuffer,1,100);
if( (readBuffer & 0x08) == 0x00)
{
uint8_t sendBuffer[3] = {0xBE,0x08,0x00};
HAL_I2C_Master_Transmit(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer),100);
}

}

void AHT20_Measure()
{
/*此处的sendBuffer为了避免该函数作用域结束后回收,故设置为static方便多次测量*/
static uint8_t sendBuffer[3] = {0xAC,0x33,0x00};
HAL_I2C_Master_Transmit_DMA(&hi2c1,AHT20_ADDRESS,sendBuffer,sizeof(sendBuffer));//或者以中断模式启动,都是非阻塞模式
}
void AHT20_Get()
{ HAL_I2C_Master_Receive_DMA(&hi2c1,AHT20_ADDRESS,readBuffer,sizeof(readBuffer));//或者以中断模式启动,都是非阻塞模式
}

void AHT20_Analysis(float* temperature, float* humidity)
{
if((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
data = ((uint32_t)readBuffer[1] << 12) + ((uint32_t)readBuffer[2] << 4) + ((uint32_t)readBuffer[3] >> 4);
*humidity = (data * 100.0)/(1<<20);

data = ( ((uint32_t)readBuffer[3]&0x0F) << 16) + ((uint32_t)readBuffer[4] << 8) + (uint32_t)readBuffer[5];
*temperature = (data*200.0)/(1<<20)-50;

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

//
// Created by keqiu on 24-9-13.
//


#include<gpio.h>

#include "aht20.h"
#include "stm32f1xx_hal.h"

#include "tim.h"
#include "stdbool.h"
#include "usart.h"
#include <stdio.h>


float temperature;
float humidity;
char message[50];

AHT20State aht20State;//状态机枚举变量

void setup()
{
AHT20_Init();

}

void loop()
{

switch (aht20State)
{
case AHT_MEASURE:
AHT20_Measure();
aht20State = AHT_SEND;
case AHT_GET:
HAL_Delay(75);
AHT20_Get();
aht20State = AHT_RECEIVE;
case AHT_ANALYSIS:
AHT20_Analysis(&temperature,&humidity);
sprintf(message,"温度:%.1lf°c 湿度:%.1lf %%",temperature,humidity);

HAL_UART_Transmit(&huart2,message,sizeof(message),1000);
HAL_Delay(1000);
aht20State =0;
default:
break;
}
}


void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c == &hi2c1)
{
aht20State = AHT_GET;
}
}

void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c == &hi2c1)
{
aht20State = AHT_ANALYSIS;
}
}

SPI

介绍

SPI 是英语Serial Peripheral interface的缩写,顾名思义就是串行外围设备接口。是Motorola(摩托罗拉)首先在其MC68HCXX系列处理器上定义的。

SPI是一种高速的,全双工,同步的通信总线,并且在芯片的管脚上只占用四根线,节约了芯片的管脚,同时为PCB的布局上节省空间,提供方便。

主要应用在 EEPROM,FLASH,实时时钟,AD转换器,还有数字信号处理器和数字信号解码器之间。

SPI是全双工且SPI没有定义速度限制,一般的实现通常能达到甚至超过10 Mbps

学习资料

SPI原理超详细讲解—值得一看-CSDN博客

【STM32】HAL库 STM32CubeMX教程十四—SPI_cubemx spi-CSDN博客

主从模式

SPI分为主、从两种模式,一个SPI通讯系统需要包含一个(且只能是一个)主设备,一个或多个从设备。提供时钟的为主设备(Master),接收时钟的设备为从设备(Slave)

SPI接口的读写操作,都是由主设备发起。当存在多个从设备时,通过各自的片选信号进行管理。

信号线

SPI接口有四条信号线通信:

  1. SDI(数据输入)

  2. SDO(数据输出)

  3. SCK(时钟)

  4. CS(片选)

  • MISO(Master input Slave output): 主设备输入/从设备输出引脚。该引脚在从模式下发送数据,在主模式下接收数据。
  • MOSI: 主设备输出/从设备输入引脚。该引脚在主模式下发送数据,在从模式下接收数据。
  • SCLK串行时钟信号,由主设备产生。
  • CS/SS从设备片选信号,由主设备控制。它的功能是用来作为“片选引脚”,也就是选择指定的从设备,让主设备可以单独地与特定从设备通讯,避免数据线上的冲突。

对应硬件上为4根线

SPI一对一

image-20241106150546130

SPI一对多:

image-20241106150618856

数据发送和接收

SPI主机和从机都有一个串行移位寄存器(8位),主机通过向它的SPI串行寄存器写入一个字节来发起一次传输。

  1. 首先拉低对应SS信号线,表示与该设备进行通信
  2. 主机通过发送SCLK时钟信号,来告诉从机写数据或者读数据
    这里要注意,SCLK时钟信号可能是低电平有效,也可能是高电平有效,因为SPI有四种模式,这个我们在下面会介绍
  3. 主机(Master)将要发送的数据写到发送数据缓存区(Memory),缓存区经过移位寄存器(0~7),串行移位寄存器通过MOSI信号线将字节一位一位的移出去传送给从机,,同时MISO接口接收到的数据经过移位寄存器一位一位的移到接收缓存区。
  4. 从机(Slave)也将自己的串行移位寄存器(0~7)中的内容通过MISO信号线返回给主机。同时通过MOSI信号线接收主机发送的数据,这样,两个移位寄存器中的内容就被交换。

image-20241106151444762

工作模式

根据时钟极性(CPOL)及相位(CPHA)不同SPI有四种工作模式
时钟极性(CPOL)定义了时钟空闲状态电平:

CPOL=0为时钟空闲时为低电平
CPOL=1为时钟空闲时为高电平
时钟相位(CPHA)定义数据的采集时间。

CPHA=0:在时钟的第一个跳变沿(上升沿或下降沿)进行数据采样。
CPHA=1:在时钟的第二个跳变沿(上升沿或下降沿)进行数据采样。

对应组合四种模式为

image-20241106151100423

SPI原理超详细讲解—值得一看-CSDN博客

W25Q128 FLASH芯片

W25Q128是一款SPI通信的FLASH芯片,可以通过标准/两线/四线SPI控制

FLASH的大小为16M,分为 256 个块(Block),每个块大小为 64K 字节,每个块又分为 16个扇区(Sector),每个扇区 4K 个字节。

通过SPI通信协议即可实现MCU(STM32)和 W25Q128 之间的通信。实现W25Q128的控制需要通过SPI协议发送相应的控制指令,并满足一定的时序。

打开芯片手册可以找到对应的操作:

  1. 写使能

image-20241106154535945

阅读最后一段英文可知:

写入使能通过将 /CS 驱动为低电平,将指令代码“06H(0x06)”在 CLK 的上升沿时移至数据输入 (DI) 引脚,然后驱动 /CS 为高电平

即:向FLASH发送0x06 写使能命令即可开启写使能,首先CS片选拉低,控制写入字节函数写入命令,CS片选拉高。

  1. 扇区擦除指令(Sector Erase)

image-20241106155057196

第二排可以看到

必须先执行 Write Enable 指令,设备才会接受 Sector Erase。通过将 /CS 引脚驱动为低电平来启动该指令,再将指令代码“20H”+24为扇区地址 (A23-A0)

在最后一个字节的第 8 位被锁存后,必须将 /CS 引脚驱动为高电平。如果未执行此操作,则Sector Erase 指令将不会被执行。

即:

扇区擦除指令,数据写入前必须擦除对应的存储单元,该指令先拉低/CS引脚电平,接着传输“20H”指令和要24位要擦除扇区的地址。

  1. 读命令

image-20241106175057582

第一排:读取可以一次读取一个或多个数据字节

先拉低CS电平,再传输03H,接着通过DI管教传输24为地址,最终数据通过DO引脚引出。每传输一个字节地址自动递增,所以只要时钟继续传输,就可以不断读取出储存器中的数据。

  1. 状态读取命令(Read Status Register)

  2. 写入命令(Page Program)

image-20241106175641621

在对W25Q128 FLASH的写入数据的操作中一定要先擦出扇区,在进行写入,否则将会发生数据错误。
W25Q128 FLASH一次性最大写入只有256个字节。
在进行写操作之前,一定要开启写使能(Write Enable)。
当只接收数据时不但能只检测RXNE状态 ,必须同时向发送缓冲区发送数据才能驱动SCK时钟跳变。

实验读写FLASH(W25Q128)

CubeMX配置

主要说SPI配置页面

  1. Mode: Full-Duplex为全双工 Half-Duplex半双工
  • 有主机模式全双工/半双工
  • 从机模式全双工/半双工
  • 只接收主机模式/只接收从机模式
  • 只发送主机模式

2. Hardware NSS Signal(硬件片选信号):片选分为软件片选和硬件片选。STM32有硬件片选信号,可以选择使能,也可以使用其他IO口接到芯片的NSS上进行代替

其中SIP1的片选NSS : SPI1_NSS(PA4)
其中SIP2的片选NSS : SPI2_NSS(PB12)

如果片选引脚没有连接 SPI1_NSS(PA4)或者SPI2_NSS(PB12),则需要选择软件片选

NSS管脚及我们熟知的片选信号,作为主设备NSS管脚为高电平,从设备NSS管脚为低电平。当NSS管脚为低电平时,该spi设备被选中,可以和主设备进行通信。在stm32中,每个spi控制器的NSS信号引脚都具有两种功能,即输入和输出。

所谓的输入就是NSS管脚的信号给自己。所谓的输出就是将NSS的信号送出去,给从机。

对于NSS的输入,又分为软件输入和硬件输入。

软件输入
NSS分为内部管脚和外部管脚,通过设置spi_cr1寄存器的ssm位和ssi位都为1可以设置NSS管脚为软件输入模式且内部管脚提供的电平为高电平,其中SSM位为使能软件输入位。SSI位为设置内部管脚电平位。同理通过设置SSM和SSI位1和0则此时的NSS管脚为软件输入模式但内部管脚提供的电平为0。若从设备是一个其他的带有spi接口的芯片,并不能选择NSS管脚的方式,则可以有两种办法:

1. 将NSS管脚直接接低电平。

2. 通过主设备的任何一个gpio口去输出低电平选中从设备。

硬件输入
主机接高电平,从机接低电平。

硬件片选信号直接配置即可,下面说软件片选

只需要对应软件片选引脚(选择硬件片选对应引脚),选择GPIO_Output,然后设置下备注即可SPI2_CS

W25Q128V芯片闪存芯片进行通信,所以设置为主机全双工


然后进行基本参数配置:

Basic Parameters:

Frame Format(帧格式):默认Motorola通信格式

Data Size: 默认8bit

First Bit:有MSB First(高位在前)和LSB First(低位在前)

Clock Parameters:

Prescaler(for Baud Rate):SPI波特率分频值,决定SPI时钟参数

Baud Rate:上面设置分频后得到的传输速率

Clock Polarity(CPOL):时钟极性,选择是高还是低电平

Clock Phase(CPHA):时钟相位,选择第几个跳变沿(上升/下降沿)采样

Advanced Parameters:

CRC Calculation:CRC校验项,提高通信可靠性

NSS Signal Type:选择软件片选或者硬件片选

SPI配置中设置数据长度为8bit,MSB先输出分频为64分频,则波特率为125KBits/s。其他为默认设置。
Motorla格式,CPOL设置为Low,CPHA设置为第一个边沿。不开启CRC检验,NSS为软件控制。

代码

因为我们是软件使能片选,定义片选引脚,CS片选低电平为有效使能CS片选高电平不使能

这里用两个宏定义来代替

1
2
3
//以W25Q128为例
#define SPI_CS_Enable() HAL_GPIO_WritePin(GPIOA, SPI1_CS_Pin, GPIO_PIN_RESET)
#define SPI_CS_Disable() HAL_GPIO_WritePin(GPIOA, SPI1_CS_Pin, GPIO_PIN_SET)

使用野火官方提供的驱动即可

函数

从对应SPI头文件可以看到对应轮询,中断和DMA三种方式

  • SPI发送/接收数据函数
1
2
3
4
5
6
7
8
9
10
HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout);//发送数据

/**
* hspi: 选择SPI1/2,比如&hspi1,&hspi2
* pData : 需要发送的数据,可以为数组
* Size: 发送数据的字节数,1 就是发送一个字节数据
* Timeout: 超时时间,就是执行发送函数最长的时间,超过该时间自动退出发送函数
*/
HAL_SPI_Receive(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout);//接收数据

  • SPI中断函数
1
2
3
4
5
6
 
HAL_SPI_TransmitReceive_IT(&hspi1, TXbuf,RXbuf,CommSize);
//中断启动,当SPI上接收出现了 CommSize个字节的数据后,中断函数会调用SPI回调函数:
HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi)
//中断回调

OLED

介绍

所谓OLED,就是由一个个发光的二极管(发光小灯)组成,每个小灯称为一个像素,只要在屏幕上有选择的点亮一部分小灯,就可以显示我们想要的图案,而小灯排列的数目就是分辨率

常见尺寸分辨率:128X64 ->128列,64行小灯,此时如果再按照之前的方法一个引脚控制一个小灯的话消耗太大,显然不可能。所以我们需要屏幕驱动芯片

有了屏幕驱动芯片,我们只需要通过IIC或者SPI等通讯协议与屏幕驱动芯片进行通信,就可以操控这些小灯的亮灭。

常见屏幕驱动芯片有SSD1306、CH1116、SH1106等

【STM32入门教程-2024】第14集 如何在OLED屏幕上挥毫_哔哩哔哩_bilibili

原理

与CH1116的通信分为两类:指令和数据

以CH1116为例:分辨率为64X128,将64行划分为page0~page7(8页),

每一个page从0到7共8行,列数为128不变。

从芯片数据手册可以查到,CH1116的从地址为0x7A

通常在对应芯片手册(下面为CH1116芯片)中可以找到I2C地址,如下图7位为:0111100或0111101,第8位为R/W位,故为0x7A

image-20240922165310629

  1. 指令通讯格t式

0x7A + 0x00 一字节指令

0x7A为IIC地址,0x00开头+一字节指令是我们需要发送的。

设置页地址只分为一次,例如:

想设置页地址为page0 0xB0 -> 0x7A 0x00 0xB0

想设置页地址为 page7 0xB7 -> 0x7A 0x00 0xB7

设置列地址需要发送两次指令,假设我们需要设置列地址为0x5A

第一次发送 0x0A,将列地址低4位设置为A

第二次发送 0x15,将列地址高4位设置为5

即,低位0x0,高位0x1

  1. 数据通讯格式

0x7A + 0x40 任意数量的数据

0x7A为IIC地址,(0x40开头+任意数量数据)为我们发送的。

CH1116等芯片特性:设置完一字节的8个像素后,列地址会自动+1,这样下一个数据就可以写到本页的下一列里。

利用这特性我们只需要将页地址和列地址都设置为0,然后一次性发送128个字节,就可以直接完成一页屏幕的像素设置

设置第0页: 0x7A 0x00 0xB0 ->需要在循环中手动自增,遍历

设置第0列: 0x7A 0x00 0x00 & 0x7A 0x00 0x10 ->自动自增

发送显示数据: 0xFF 0xFF …共128个

驱动函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/*OLED在使用之前需要初始化*/
void OLED_Init()
{

OLED_SendCmd(0xAE); /*关闭显示 display off*/

OLED_SendCmd(0x02); /*设置列起始地址 set lower column address*/
OLED_SendCmd(0x10); /*设置列结束地址 set higher column address*/

OLED_SendCmd(0x40); /*设置起始行 set display start line*/

OLED_SendCmd(0xB0); /*设置页地址 set page address*/

OLED_SendCmd(0x81); /*设置对比度 contract control*/
OLED_SendCmd(0xCF); /*128*/

OLED_SendCmd(0xA1); /*设置分段重映射 从右到左 set segment remap*/

OLED_SendCmd(0xA6); /*正向显示 normal / reverse*/

OLED_SendCmd(0xA8); /*多路复用率 multiplex ratio*/
OLED_SendCmd(0x3F); /*duty = 1/64*/

OLED_SendCmd(0xAD); /*设置启动电荷泵 set charge pump enable*/
OLED_SendCmd(0x8B); /*启动DC-DC */

OLED_SendCmd(0x33); /*设置泵电压 set VPP 10V */

OLED_SendCmd(0xC8); /*设置输出扫描方向 COM[N-1]到COM[0] Com scan direction*/

OLED_SendCmd(0xD3); /*设置显示偏移 set display offset*/
OLED_SendCmd(0x00); /* 0x00 */

OLED_SendCmd(0xD5); /*设置内部时钟频率 set osc frequency*/
OLED_SendCmd(0xC0);

OLED_SendCmd(0xD9); /*设置放电/预充电时间 set pre-charge period*/
OLED_SendCmd(0x1F); /*0x22*/

OLED_SendCmd(0xDA); /*设置引脚布局 set COM pins*/
OLED_SendCmd(0x12);

OLED_SendCmd(0xDB); /*设置电平 set vcomh*/
OLED_SendCmd(0x40);

// OLED_NewFrame();
// OLED_ShowFrame();

OLED_SendCmd(0xAF); /*开启显示 display ON*/
}

/*该函数内容一般厂商会提供,网上搜即可,当然没有的话只有自己写了*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*发送指令*/

void OLED_SendCmd(uint8_t cmd)
{
uint8_t sendBuffer[2] = {0x00,cmd};
HAL_I2C_Master_Transmit(&hi2c1,OLED_ADDRESS,sendBuffer,sizeof(sendBuffer),HAL_MAX_DELAY);

}

void OLED_Test()
{
OLED_SendCmd(0xB0);//设置页
OLED_SendCmd(0x02);//设置列低四位
OLED_SendCmd(0x10);//设置列高四位
/*发送指令,设置页和列*/

uint8_t sendBuffer[] = {0x40,0xAA};
HAL_I2C_Master_Transmit(&hi2c1,OLED_ADDRESS,sendBuffer,sizeof(sendBuffer),HAL_MAX_DELAY);
/*发送数据,设置亮灭*/
}

由于屏幕任意点亮灭是随机的,启动时会花屏,所以需要我们利用显存刷新一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
uint8_t GRAM[8][128];//定义显存

void OLED_NewFrame()
{
memset(GRAM,0,sizeof(GRAM));
}//将所有像素清空

void OLED_ShowFrame()
{
uint8_t sendBuffer[129];
sendBuffer[0] = 0x40;
for(uint8_t i=0;i<8;i++)
{
for(uint8_t j=0;j<128;j++)
{
sendBuffer[j+1] = GRAM[i][j];
}
OLED_SendCmd(0xB0+i);
OLED_SendCmd(0x02);
OLED_SendCmd(0x10); HAL_I2C_Master_Transmit(&hi2c1,OLED_ADDRESS,sendBuffer,sizeof(sendBuffer),HAL_MAX_DELAY);
/*只需要对page进行增加,列由于特性会自增,故不需要增加*/
}
}

void OLED_SetPixel(uint8_t x,uint8_t y)
{
/*该函数作用是使指定坐标亮起*/
/*描述屏幕时,使用的是下x,y坐标系第四象限,x为列坐标,y为行坐标*/

if(x>=128 || y>=64) return ;

GRAM[y/8][x] = 0x01 << (y%8);
}

void loop()
{
for(uint8_t i=0;i<64;i++)
{
OLED_NewFrame();//清空显存

OLED_SetPixel(2*i,i);//画点
OLED_ShowFrame();//显示显存
}
/*你将会得到一个在屏幕上移动的点*/
}

void setup()
{
/*OLED初始化通常前面跟一个延时,*/
HAL_Delay(20);
OLED_Init();
/*STM32启动比OLED上电快, 可等待20ms再初始化OLED,避免LED比STM32启动更早*/
}

取模(图模+字模)

CubeMX配置

正常启动I2C配置,由于有大量数据,故只需要将I2C模式标准模式切换为快速模式(Fast Mode),使用外部高速时钟即可.。

移植

为了便于使用,驱动库可以直接移植

波特律动LED字模生成器 (baud-dance.com)

图模和字模的使用方法:

使用波特率动取模后,将取模后的代码复制到font.c文件最下方,最后再调用OLED_DrawImage或OLED_PrintString即可使用