STM32 HAL库开发学习笔记
在学习了解过 51 单片机的基本外设和其应用,对单片机作好铺垫后,肯定就到了 STM32 的学习,我选择直接学习可以快速上手的 HAL 库。本文记录基于 STM32F103C8T6 HAL 库入门开发教程的学习笔记。
0. 前置知识
STM32 内部结构
以 STM32F103xx 型号为例,该芯片由 Cortex-M3 内核 和 片上内设 组成。
ICode 总线:’I’ 代表 “Instruction”,该总线作用是 取指,它会一直从 Flash 中读取存储的数据。
总线矩阵:协调仲裁 DCode 总线、System 总线、DMA 总线之间的数据传输。
除了该总线,一般分为 4 个 驱动单元 和 4 个 被动单元。
驱动单元:
- Dcode 总线:’D’ 代表 ‘data’,该总线作用是
取数,它会从 Flash 中读取数据常量,从 SRAM 中读取数据变量。 - System 总线:主要访问外设的寄存器。通过此总线读写寄存器。
- DMA 总线 x2:DMA 总线也是用作传输数据,数据可以在某个外设的数据寄存器(Flash 或 SRAM)。
被动单元:
- 内部 Flash:存储数据和指令的地方。
- 内部 SRAM:程序的变量、堆栈的开销都在此区域发生。
- FSMC:Flexible Static Memory Controller,通过此控制器可以扩展内存。
- AHB - APB桥:AHB 分为 APB2 和 APB1 总线,这些总线上挂载在 STM32 的各种外设。
英文全称:Advanced High Performance Bus / Advanced Peripheral Bus
存储器映射
被控单元的FLASH,RAM,FSMC 和 AHB 到 APB 的桥(即片上外设),这些功能部件共同排列在一个4GB 的地址空间内。厂商也预先为这些分配好相应的物理内存地址。
4GB 的空间被分为 8 小块 512MB,每一小块称作一个 block,对应着不同的地址区间,给已经分配好地址的有特定功能的内存单元取别名的过程就叫寄存器映射。
此部分具体阅读野火 HAL 库实战开发指南的第 4 和第 5 章,此部分可以对整个架构和寄存器原理有一个初步理解。
[野火] STM32 HAL 库开发实战指南——基于 F103 系列开发板
1. 点灯大师
1.1 原理
根据原理图 LED 负极接 GND 和二极管的单向导通性(正极 -> 负极),通过控制 STM32 GPIO 引脚使用 Push-Pull Output,输出 3.3V 高电平使 LED 导通。
- LED - Light-Emitting Diode,发光二极管。
- GND - Ground 的缩写,表示接“地”,但是不是真正意义的“地”,是电路中基准电位。
- GPIO:通用输入输出(General Purpose Input and Output),GPIO 引脚可以读取外部输入电压和向外输出一定电压值。
- 推挽输出 & 开漏输出:当 GPIO 处于输出模式时,GPIO 内部的两个 MOS 管(PMOS & NMOS)排列组合共有 4 个状态:
- (PMOS 高电平导通,NMOS 低电平导通)
- PMOS 打开 | NMOS 关闭 - 输出高电平
- PMOS 关闭 | NMOS 打开 - 输出低电平
- PMOS 关闭 | NMOS 关闭 - 浮空,即高阻态
- PMOS 打开 | NMOS 打开 - VCC直接连通GND短路,MOS管烧坏,不存在这种状态
1.2 实验
假设使用高电平点亮绿灯,在
Pinout view中选中PA7引脚,选择GPIO_Output并配置GPIO output level为high。假如让 LED 灯进行闪烁,在
MX_GPIO_Init()代码片段编写以下代码:1
2
3
4
5
6
7while (1)
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET); // 参数 GPIO_PIN_SET 设置高电平
HAL_Delay(500); // 延时函数,参数单位为毫秒(ms)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET); // 参数 GPIO_PIN_RESET 设置高电平
HAL_Delay(500);
}IDE技巧和细节:
ALT+/- 代码提示- 保留用户代码需要将编写的代码置于
BEIGIN和END注释区间 - 引脚视图中每个引脚右键选中
Enter user label可以重命名引脚名称,方便记忆与配置
2. 按键
2.1 原理
根据原理图,KEY 未按下时,KEY 不接地处于断路状态,直接连通一个上拉电阻到 3.3V 电源。通过设定 GPIO 为 浮空输入模式,浮空输入模式的 GPIO 内部处于高阻态(相当于芯片内部有一个巨大的电阻),那么这个上拉电阻压降几乎为 0V,所以 GPIO 的电压近似可以等于接通 3.3V(高电平)。当 KEY 按下后,通路形成,KEY 处于接地状态,GPIO 即处于低电平 0V 状态。
- 上拉:使用电源将 GPIO 的电平拉高的操作称作上拉,通常需要一个上拉电阻配合。
- 下拉:类似上拉,接通的不是电源而是接地的话,那么该操作称为下拉。
- 电压降:电流流过负载以后相对于同一参考点的电势(电位)变化称为电压降,简称压降。
- 浮空输入模式:输入信号经过施密特触发器接入输入数据存储器。当无信号输入时,电压不确定。
- 消抖:硬件消抖可以采用电容,但传统软件消抖方法是使用延时函数。
2.2 实验
假设使用 KEY1 点亮蓝灯,在
Pinout view中选中 KEY1 的GPIO_Input后,编写以下自定义代码片段:1
2
3
4
5
6
7
8
9
10
11while (1)
{
HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin); // 使用 ReadPin 函数读取是否为低电平
if (HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_SET);
}else
{
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);
}
}STM32 内部有内置的上拉或下拉电阻,即原理图上的 KEY2 没有连接任何电阻。在引脚设置中同样选中 KEY2 的
GPIO_Input,打开详细配置可以发现上拉下拉中默认为no pull-up and on pull-down,即默认为浮空输入模式,然后需要选择使用内部的pull-up上拉电阻后编写以下自定义代码片段使用 KEY2 控制红灯亮灭切换:1
2
3
4
5
6
7
8while(1)
{
if (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_TogglePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin); // 使用 TogglePin 函数切换高低电平
while(HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET){} // 使用 while 检测按键是否被松开,再继续执行逻辑
}
}软件消抖方式:
1
2
3
4
5
6
7
8
9
10
11
12while(1)
{
if (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET)
{
HAL_Delay(10); // 按键按下后,初次读取电平后延时 10ms
if (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET) // 待稳定后再次读取电平
{
HAL_GPIO_TogglePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin); // 使用 TogglePin 函数切换高低电平
while(HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET){} // 使用 while 检测按键是否被松开,再继续执行逻辑
}
}
}
3. GPIO 深入理解
在 STM32 参考手册内可以找到 GPIO 引脚内部结构如下图所示,GPIO 有八大功能模式,其主要分为输出和输入两部分。
3.1 引脚部分
因为引脚能承受的电压有限,引脚部分两侧是两个保护二极管,类似静电等瞬间电压进入引脚时,如果电压大于 VDD 3.3V,上方二极管导通;若电压小于VSS 0V时,下方二极管导通。尽管有保护二极管,长时间接入高负载电压也是会损坏芯片。(部分 IO 口可以承受 5V 电压)
3.2 输出部分
推挽输出:因为 PMOS 管高电平导通,VDD 输出 3.3V 高电平,这一动作称为“推”。反之,低电平 NMOS 导通,GND 输出 0V 低电平,电流往 GND 流动,这一动作称作“挽”。推挽输出具有一定的电压和电流驱动能力,但能力不高,比如 5V 的器件无法驱动。
开漏输出:在此模式下,PMOS 管不工作,只会使用到 NMOS。GPIO 输出高电平 NMOS 不导通,即处于高阻态模式。假设我们想驱动一个连接外部 5V VDD 的工作电压为 5V 器件,那么输出高低电平就可以控制这个器件工作。开漏输出没有驱动能力,但其更加灵活,可以依靠外部电压源。
复用推挽输出:区别于普通推挽输出的复用模式,其输出源来自片上外设。
复用开漏输出:区别于普通开漏输出的复用模式,其输出源来自片上外设。
3.3 输入部分
由于电压并不总是稳定的,电流在输入部分会流经一个施密特触发器,其带有两个参考电压值,判断和输出处理的电平信号,即 1 和 0。后面的输入数据寄存器就可以读取输入的电平。
上拉输入:设置上拉输入,上拉电阻启用。
下拉输入:设置下拉输入,下拉电阻启用。
浮空输入:两个上下拉电阻均不启用,可以依赖外部电路实现更丰富的功能。
模拟输入:读取具体的电压数值,它在施密特触发器之前,将电压引入至与模拟输入相关的片上外设。
4. 中断
中断是 CPU 在接收到中断源信号后转而优先处理中断源任务的一个过程。 举例一个简单的中断输入结构和流程,当按键按下后,外部电平信号经过上下拉电阻、施密特触发器转换、最后抵达输入数据寄存器或者片上外设。再进一步,电平信号会抵达以下结构,即外部中断/事件控制器。
4.1 中断/事件控制器结构
- STM32 一共有 19 组这样的线路结构,每一个外部中断都应着其中的一组线路,也称作外部中断线,前 16 组
EXIT0~EXIT15分别对应相应的 GPIO。
| 中断号 | GPIO引脚 | GPIO引脚 | GPIO引脚 | GPIO引脚 |
|---|---|---|---|---|
| EXIT0 | PA0 | PB0 | PC0 | PD0 |
| EXIT1 | PA1 | PB1 | PC1 | PD1 |
| …… | …… | …… | …… | …… |
| EXITX | PAX | PBX | PCX | PDX |
脉冲发生器和事件屏蔽器可以先暂时忽略,是与事件相关的。中断信号是会抵达处理器调用代码进行处理。而事件信号是送达相应的外设,由外设自行处理。
4.2 原理
电信号进入 边沿检测电路 ,由 上升沿 和 上升沿 两个触发选择寄存器(CubeMX 选择)检测后输出高电平,请求挂起寄存器接收到电信号后,将其第 12 位置 1,再次将电信号传到一个与门,当 中断屏蔽寄存器 中的第 12 位 同样置 1 时,那么电信号才能再次输送到 NVIC(中断控制器)。
- 到达 NVIC 后,控制器会根据几号的外部中断线,选择对应的中断向量,随后执行相应的处理函数。
| 名称(中断向量) | 处理函数 |
|---|---|
| EXIT0 | EXIT0_IRQHandler |
| EXIT1 | EXIT1_IRQHandler |
| EXIT2 | EXIT2_IRQHandler |
| EXIT3 | EXIT3_IRQHandler |
| EXIT4 | EXIT4_IRQHandler |
| …… | …… |
| EXIT9_5 | EXIT9_5_IRQHandler |
| EXIT15_10 | EXIT15_10_IRQHandler |
由于 NVIC 会一直检测中断线是否处于激活状态,所以在执行一次处理函数后便需要重置
请求挂起寄存器,CubeMX 会自动生成函数代码执行这个操作:1
HAL_GPIO_EXIT_IRQHandler();
4.3 中断优先级
倘若多个中断同时发生,单片机如何安排这些执行顺序?STM32 中断优先级分为两种(数字越小越优先):
- 抢占优先级
- 响应优先级
NVIC 会有以下的判断规则:
| 情况 | 比较顺序 |
|---|---|
| 1 - 两个中断同时发生 | 1 - 抢占优先级 |
| 2 - 响应优先级 | |
| 3 - 向量表数字 | |
| 2- A执行中,B发生 | 1 - 抢占优先级 |
STM32 每个中断向量准备 4 个二进制位来储存中断优先级信息,CubeMX 默认是 4 位都设置抢占优先级(0 ~ 15)
4.3 实验
main.c 编写 LED 亮灭代码:
1 | |
将 PB12 引脚设置为 GPIO_EXIT2,并选择下沿触发模式;设置 NVIC 模块中 System Tick 抢占优先级(14) > EXIT line 抢占优先级(15)
stm32f1xx_it.c 编写中断函数 - 检测到下沿触发后执行此函数:
1 | |
5. 串口通信
5.1 原理
普通 TTL 串口的收发结构如下图所示,TX 是发送端,RX 是接收端,GND 是共地(设备正常通讯的前提,保证同一电平)
5.2 USART 实验
USART 全称:Universal Synchronous / Asynchronous Receiver & Transmitter(通用同步/异步接收发送器)。
TTL 串口使用的是异步通信方式。打开 CubeMX,在 Connectivity 下可以见到有 3 个 USART 外设资源。 根据原理图,这里调试串口使用的 IO 口是 PA2、PA3。
将 PA2 引脚设置为 USART2_TX,同时设置 Mode 为 Asynchronous,随后 PA2 和 PA3 自动分别设置为 TX 和 RX。还需要特别注意一些参数:
- 波特率:Baud Rate - 每秒多少次高低电平信号。TTL 串口默认每次传输一个字节(Byte)数据,同时还要添加前后的起始位和停止位,即总共 10bit。通信设备两端都要使用一样的波特率才可以正常通信。
发送数据到 PC - 编写代码如下:
1 | |
接受来自 PC 数据 - 编写代码如下:
1 | |
5.3 轮询模式
以上实验的收发方式称为轮询模式,这种模式效果不佳,会阻塞程序执行,因为 CPU 在一直查询数据并传入移位寄存器,直到完成发送或接收或者等待超时。
该模式会使用到两对寄存器:
- 发送数据寄存器(TDR)& 发送移位寄存器(TX)
- 接收数据寄存器(RDR) & 接收移位寄存器(RD)
5.4 中断模式
因为我们引入了中断串口模式,打开 USART2 的中断功能,随后使用中断串口函数:
1 | |
在此模式中,不能把中断逻辑写在 void USART2_IRQHandler(void) 里,因为 所有USART 只有一个中断向量,需要重新定义以下回调函数(接收完成时才调用此函数):
1 | |
另外接收不要放在 while 循环中执行,否则上一次没有接收完,又重复执行此函数。
1 | |
5.5 DMA 模式
对于中断模式,CPU 会被频繁打断去处理中断搬运数据(TDR -> 发送移位寄存器)(ODR -> 接受移位寄存器)。此时我们可以使用 DMA 来代替 CPU “搬运”数据,HAL 函数如下:
1 | |
如何接收不定长数据?利用串口空闲中断状态(IDLE),即串口完成一帧的数据包输入输出。HAL 函数如下:
1 | |
6. 蓝牙
6.1 原理
蓝牙分为经典蓝牙和低功耗蓝牙(BLE)。蓝牙通讯分为 主机 和 从机。
BT24 蓝牙串口透传模块:转化复杂的蓝牙协议为串口透传
6.2 实验
普通蓝牙通讯
使用杜邦线连接蓝牙模块到开发板
开启
USART3并设置异步通信方式更改波特率到
9600(蓝牙模块默认)开启 接收与发送
DMA通道编写回调函数代码
1
2
3
4
5
6
7
8
9
10
11
12uint8_t data[50]; // 声明数据数组
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart == &huart3)
{
HAL_UART_Transmit_DMA(&huart3, data, Size); // size 参数代表发送与接收相同的字节数
}
HAL_UARTEx_ReceiveToIdle_DMA(&huart3, data, sizeof(data));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx, DMA_IT_HT);
}编写第一次接收数据函数(接收不定长数据)
1
2HAL_UARTEx_ReceiveToIdle_DMA(&huart3, data, sizeof(data));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx, DMA_IT_HT);编译烧录后,使用串口助手验证
蓝牙通讯控制LED灯
定义
0xFF亮灯0x00灭灯0x010x020x03分别代表红绿蓝灯定义一帧数据数据包:
包头数据长度LED亮灭状态检验和校验位用于检验输出数据的正确与否,计算方式是将前面的数据依次相加,取最后一字节数据,接收后会进行对比,如果相同证明大概率没有出错。
开启三个
LED灯的GPIO编写回调函数代码:
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
45void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if(huart == &huart3)
{
HAL_UART_Transmit_DMA(&huart3, data, Size);
if (data[0] == 0xAA)
{
if (data[1] == Size)
{
uint8_t sum = 0;
for (int i = 0; i < Size - 1; i++)
{
sum += data[i];
}
if (sum == data[Size - 1])
{
for (int i = 2; i < Size - 1; i+=2)
{
GPIO_PinState state = GPIO_PIN_SET;
if (data[i+1] == 0x00)
{
state = GPIO_PIN_RESET;
}
if (data[i] == 0x01)
{
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, state);
}else if (data[i] == 0x02)
{
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, state);
}else if (data[i] == 0x03)
{
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, state);
}
}
}
}
}
HAL_UARTEx_ReceiveToIdle_DMA(&huart3, data, sizeof(data));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx, DMA_IT_HT);
}
}编写第一次接收数据函数(接收不定长数据)
1
2HAL_UARTEx_ReceiveToIdle_DMA(&huart3, data, sizeof(data));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx, DMA_IT_HT);
7. IIC
7.1 原理
SDA:Serial Data
SCL:Serial Clock
SDA 线允许双向通信,但同一时刻只能有一个方向,是半双工模式。为了避免冲突,IIC 采用主从模式。这一特性可以与多设备进行通信。
IIC 采用同步通信,主机通过时钟信号线发送固定频率的脉冲信号作为所有设备的统一时间源。
7.2 实验 - AHT20 温湿度计(普通轮询模式)
新建工程 CubeMX 开启 UART 串口 -用于读取返回数据
CubeMX 开启 I2C1
CubeMX 工程开启分别为外设添加头文件和源文件,然后单独新建 aht20.h 和 aht20.c
编写驱动头文件 aht20.h:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15/*
* aht20.h
*
* Created on: Mar 23, 2024
* Author: iamcc
*/
#ifndef INC_AHT20_H_
#define INC_AHT20_H_
#include "i2c.h"
void AHT20_Init();
void AHT20_Read(float *Temperature, float *Humidity);
#endif /* INC_AHT20_H_ */编写驱动源文件 aht20.c:
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/*
* aht20.c
*
* Created on: Mar 23, 2024
* Author: iamcc
*/
#include "aht20.h"
#define AHT_20_ADDRESS 0x70
void AHT20_Init()
{
uint8_t readBuffer;
HAL_Delay(40);
HAL_I2C_Master_Receive(&hi2c1, AHT_20_ADDRESS, &readBuffer, 1, HAL_MAX_DELAY);
if ((readBuffer & 0x80) == 0x00)
{
uint8_t sendBuffer[3] = {0xBE, 0x08, 0x00};
HAL_I2C_Master_Transmit(&hi2c1, AHT_20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);
}
}
void AHT20_Read(float *Temperature, float *Humidity)
{
uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00};
uint8_t readBuffer[6];
HAL_I2C_Master_Transmit(&hi2c1, AHT_20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);
HAL_Delay(75);
HAL_I2C_Master_Receive(&hi2c1, AHT_20_ADDRESS, readBuffer, 6, HAL_MAX_DELAY);
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);
*Humidity = data * 100.0f / (1 << 20);
data = ((uint32_t)readBuffer[3] & 0x0F << 16) + ((uint32_t)readBuffer[4] << 8) + ((uint32_t)readBuffer[5]);
*Temperature = data * 200.0f / (1<<20) - 50;
}
}编写 main.c:
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/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "aht20.h"
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_I2C1_Init();
MX_USART2_UART_Init();
/* USER CODE BEGIN 2 */
AHT20_Init();
float temperature, humidity;
char message[50];
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
AHT20_Read(&temperature, &humidity);
sprintf(message, "Temp: %.1f °C\nHumi: %.1f %%\r\n", temperature, humidity);
HAL_UART_Transmit(&huart2, (uint8_t*)message, strlen(message), HAL_MAX_DELAY);
HAL_Delay(5000);
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */连接串口后在串口助手观察读取的温湿度
7.3 实验 - AHT20 温湿度计(中断模式)
区别于普通轮询模式,中断模式要再开启 I2C 的中断向量 - I2C event interrupt,对应 I2C HAL 函数最后加上 IT 或者 DMA,与 UART 类似。