Diving into the STM32 HAL-----USART
如今,电子行业有许多串行通信协议和硬件接口可用。其中之一是通用同步/异步接收器/发射器接口,也简称为 USART。几乎每个微控制器都至少提供一个 UART 外设。几乎所有的 STM32 MCU 都提供至少两个 UART/USART 接口,但根据 MCU 封装支持的 I/O 数量,大多数提供两个以上的接口(有的多达八个接口)。那么如何使用 CubeHAL 对这个有用的外设进行编程。如何在轮询和中断模式下使用 UART 开发应用程序。
1、UART 和 USART 简介
在我们开始深入研究 HAL 提供的用于操作通用串行设备的功能之前,最好先简要了解一下 UART/USART 接口及其通信协议。
当我们希望两个(甚至更多)设备之间交换数据时,我们有两种选择:我们可以并行传输它,即使用等于每个数据字大小的给定数量的通信线(例如,一个由八位组成的字有八条独立线),或者我们可以一一传输构成我们字的每个位。UART/USART是一种将并行的比特序列(通常分组在一个字节中)转换为单线上流动的连续信号流的设备。
当信息在一个公共通道内的两个设备之间流动时,两个设备(这里为简单起见,我们将它们称为发送者和接收者)必须就时间达成一致,即传输每个单独的信息位所需的时间。在同步传输中,发送方和接收方共享由两个设备之一(通常是充当此互连系统的主设备)生成的公共时钟。
在上图中,我们有一个典型的时序图(Timing Diagram 是时域中一组 signals 的表示。),设备A 使用公共参考时钟向设备 B 串行发送一个字节 (0b01101001)。公共 clock 还用于就何时开始对 bits 序列进行采样达成一致: 当主设备开始对CLK进行计时时,这意味着它将发送一个 bits 序列。
在同步传输中,传输速度和持续时间由时钟定义:它的频率决定了我们在通信通道上传输单个字节的速度。但是,如果数据传输中涉及的两种设备都同意传输单个 bit 需要多长时间以及何时开始和结束对传输位进行采样,那么我们可以避免使用专用的 clock line。这种情况下,是一个异步传输。
上图显示了异步传输的时序图。空闲状态 (即未发生传输) 由高信号表示。传输从 START 位开始,由低电平表示。接收器检测到负沿,在此之后的 1.5 位周期开始对位进行采样。对 8 个数据位进行采样。最低有效位 (LSB) 通常首先发送。然后传输一个可选的奇偶校验位(用于数据位的错误检查)。如果假设传输通道是无噪声的,或者如果协议层的较高层存在错误检查,则通常会省略此位。传输由 STOP 位结束,该位持续 1.5 位。
通用同步接收器/发射器接口是一种能够使用两个 I/O 串行传输数据字的设备,一个充当发射器 (TX),一个作为接收器 (RX),加上一个额外的 I/O 作为一条时钟线,而通用异步接收器/发射器仅使用两个 RX/TX I/O(参见图 8.3)。传统上,我们用术语 USART 指代第一个接口,用术语 UART 指代第二个接口。
UART/USART 定义了信号传输方法,但它没有说明电压电平。这意味着 STM32 UART/USART 将使用 MCU I/O 的电压电平,该电压电平几乎等于 VDD(通常将这些电压电平称为 TTL 电压电平)。这些电压电平的转换方式允许板外的串行通信,需要满足其他通信标准。例如,EIA-RS232 或 EIA-RS485 是两个流行的标准,它们定义了信号电压,除了它们的时序和含义,以及连接器的物理尺寸和引脚排列。此外,UART/USART 接口可用于使用其他物理和逻辑串行接换数据。例如,FT232RL 是一种流行的 IC,它允许将 UART 映射到 USB 接口。
专用 clock line 的存在,或关于传输频率的共同协议,并不能保证字节流的接收器能够以与 发送器相同的传输速率处理它们。因此,一些通信标准,如 RS232 和 RS485,提供了使用专用硬件流量控制线的可能性。例如,使用 RS232 接口进行通信的两台设备可以共享两条额外的线路,分别称为请求发送 (RTS) 和清除发送 (CTS):发送方设置其 RTS,向接收方发出信号以开始监控其数据输入线路。当数据准备好时,接收方将在其互补线路 CTS提供高电平,该线路向发送方发出信号开始发送数据,并让发送方开始监控从站的数据输出线路。
STM32 微控制器提供可变数量的 USART,可以配置为在同步和异步模式下工作。一些 STM32 MCU 还提供只能用作 UART 的接口。大多数 USART 还能够自动实现 RS232 和 RS485 标准的硬件流量控制。
所有DEMO板的设计都使目标 MCU 的 USART2 连接到 STLINK 接口。当我们安装 ST-LINK 驱动程序时,还会安装一个用于虚拟 COM 端口 (VCP) 的附加驱动程序:这允许我们使用 USB 接口访问目标 MCU USART2,而无需使用专用的 TTL/USB 转换器。使用终端仿真程序,我们可以与我们的DEMO板交换消息和数据。
CubeHAL 将用于管理 UART 和 USART 接口的 API 分开。用于处理 USART 的所有函数和 C 类型处理程序都以 HAL_USART 前缀开头,并包含在 files stm32xxx_hal_usart .{c,h}中。,而与 UART 管理相关的 UART 以 UART 前缀开头HAL_UART并包含在 files stm32xxx_hal_uart .{c,h} 中。这两个模块在概念上是相同的,并且UART 是不同模块之间最常见的串行互连形式。术语 USART 和 UART 可以互换使用,除非另有说明。
2、UART 初始化
与所有 STM32 外设一样,即使是 USARTs也被映射到内存映射的外设区域,从 0x4000 0000 开始。CubeHAL 通过 USART_TypeDef描述符抽象出给定 STM32 MCU 的每个 USART 的有效位置。例如,我们可以简单地使用 USART1 宏来指代所有采用 LQFP64 封装的 STM32 微控制器提供的第二个 USART 外设。但是,所有与 UART 管理相关的 HAL 函数都设计为接受 C 结构UART_HandleTypeDef实例作为第一个参数,其定义方式如下:
typedef struct {USART_TypeDef *Instance; /* UART registers base address */UART_InitTypeDef Init; /* UART communication parameters */UART_AdvFeatureInitTypeDef AdvancedInit; /* UART Advanced Features initialization parameters */uint8_t *pTxBuffPtr; /* Pointer to UART Tx transfer Buffer */uint16_t TxXferSize; /* UART Tx Transfer size */uint16_t TxXferCount; /* UART Tx Transfer Counter */uint8_t *pRxBuffPtr; /* Pointer to UART Rx transfer Buffer */uint16_t RxXferSize; /* UART Rx Transfer size */uint16_t RxXferCount; /* UART Rx Transfer Counter */DMA_HandleTypeDef *hdmatx; /* UART Tx DMA Handle parameters */DMA_HandleTypeDef *hdmarx; /* UART Rx DMA Handle parameters */HAL_LockTypeDef Lock; /* Locking object */__IO HAL_UART_StateTypeDef gState; /* UART communication state */__IO HAL_UART_ErrorTypeDef ErrorCode; /* UART Error code */
} UART_HandleTypeDef;
Instance:是指向我们将要使用的 USART 描述符的指针 (即映射外设的内存中的基址)。例如,USART1 是本例要使用的 UART 的描述符。
Init:是 C 结构体UART_InitTypeDef的实例,用于配置 UART 接口。
AdvancedInit:该字段用于配置更高级的 UART 功能,如自动波特率检测和 TX/RX 引换。某些 HAL 不提供此附加字段。发生这种情况是因为并非所有 STM32 MCU 的 USART 接口都相同。
pTxBuffPtr 和 pRxBuffPtr:这些字段分别指向传输和接收缓冲区。它们用作通过 UART 传输 TxXferSize 字节的源,并在 UART 配置为全双工模式时接收 RxXferSize。HAL 在内部使用 TxXferCount 和 RxXferCount 字段来计算传输和接收的字节数。
Lock:此字段由 HAL 在内部用于锁定对 UART 接口的并发访问。
Lock 字段用于规则几乎所有 HAL 例程中的并发访问。如果你看一下 HAL 代码,你可以看到 __HAL_LOCK() 宏的几种用法,它是以这种方式扩展的:
#define __HAL_LOCK(__HANDLE__) \do{ \if((__HANDLE__)->Lock == HAL_LOCKED) \{ \return HAL_BUSY; \} \else \{ \(__HANDLE__)->Lock = HAL_LOCKED; \} \}while (0)
目前尚不清楚为什么 ST 工程师决定处理对 HAL 例程的并发访问。可能他们决定采用一种线程安全的方法,这样,如果多个线程在同一应用程序中运行,则应用程序开发人员无需管理对同一硬件接口的多次访问。但是,这对所有 HAL 用户都有一个令人讨厌的副作用:即使我的应用程序不对同一外围设备执行并发访问,我的代码也会因对 Lock 字段状态的大量检查而优化不佳。此外,这种锁定方式本质上是线程不安全的,因为没有关键部分用于防止竞争条件,以防更高权限的 ISR 抢占正在运行的代码。最后,如果我的应用程序使用 RTOS,最好使用原生操作系统锁定原语(如信号量和互斥锁,它们不仅是原子的,而且可以正确管理任务调度,避免繁忙的等待)来处理并发访问,而无需检查 HAL 函数的特定返回值 (HAL_BUSY)。自 HAL 首次发布以来,许多开发人员不赞成这种锁定外围设备的方式。ST 工程师几年前宣布,他们正在积极研究更好的解决方案。不过,目前还没有消息。
所有 UART 配置活动都是通过使用 C 结构体 UART_ InitTypeDef 的实例来执行的,该实例定义如下:
typedef struct {uint32_t BaudRate;uint32_t WordLength;uint32_t StopBits;uint32_t Parity;uint32_t Mode;uint32_t HwFlowCtl;uint32_t OverSampling;
} UART_InitTypeDef;
BaudRate:该参数是指连接速度,以 bits /秒表示。即使参数可以采用任意值,通常 BaudRate 也来自已知值和标准值的列表。这是因为它是与 USART 关联的外设时钟的函数(在某些 STM32 MCU 中,通过一系列 PLL 和乘法器从主 HSI 或 HSE 时钟派生),并且并非所有波特率都可以在不引入采样误差的情况下轻松实现,从而引入通信误差。下表显示了 STM32F072 MCU 的常见波特率列表以及相关的误差计算。请始终查阅 MCU 的参考手册,以了解哪种外设时钟频率最适合给定 STM32 微控制器上所需的波特率。
WordLength:指定一帧中发送或接收的数据位数。此字段可以假定值 UART_WORDLENGTH_8B 或 UART_WORDLENGTH_9B,这意味着我们可以通过包含 8 或 9 个数据位的 UART 数据包传输。此数字不包括传输的开销位,例如起始位和停止位。
StopBits:此字段指定传输的停止位数。它可以假设值 UART_STOPBITS_1 或 UART_STOPBITS_2,这意味着我们可以使用一个或两个停止位来发出帧结束的信号。
Parity:表示奇偶校验模式。此字段可以采用下表中的值。请注意,当启用奇偶校验时,计算出的奇偶校验将插入到传输数据的 MSB 位置(字长设置为 9 个数据位时为第 9 位;字长设置为 8 个数据位时为第 8 位)。奇偶校验是一种非常简单的错误检查形式。它有两种风格:奇数或偶数。为了生成奇偶校验位,所有数据位相加,总和的偶数决定是否设置该位。例如,假设奇偶校验设置为偶数,并被添加到像 0b01011101 这样的数据字节中,该数据字节的奇数为 1 (5),则奇偶校验位将设置为 1。相反,如果奇偶校验模式设置为 odd,则奇偶校验位将为 0。奇偶校验是可选的,并且不是很广泛。它有助于跨嘈杂的介质进行传输,但它也会稍微减慢数据传输速度,并且需要发送方和接收方都实施错误处理(通常,接收到的失败数据必须重新发送)。当发生奇偶校验错误时,所有 STM32 MCU 都会生成一个特定的中断。
Mode:指定 RX 或 TX 模式是启用还是禁用。此字段可以采用下表中的值之一。
HwFlowCtl:指定 RS232硬件流量控制模式是启用还是禁用。此参数可以采用下表中的值之一。此字段仅用于启用 RS232 流控。为了启用 RS485 流量控制,HAL 提供了一个特定的函数,即 HAL_RS485Ex_Init(),在 stm32XXxx_hal_uart_ex.c 文件中定义。
OverSampling:当 UART 从远程对等体接收到帧时,它会对信号进行采样,以计算构成消息的 1 和 0 的数量。过采样是一种对采样频率明显高于奈奎斯特速率的信号进行采样的技术。接收器通过区分有效的输入数据和噪声,实现不同的用户可配置的过采样技术(同步模式除外)以进行数据恢复。这允许在最大通信速度和噪声/时钟不准确性抗扰度之间进行权衡。OverSampling 字段可以假定值 UART_OVERSAMPLING_16 为每个帧位执行 16 个采样,或者UART_OVERSAMPLING_8执行 8 个采样。前面BaudRate表中显示了在过采样 16 或 8 两种情况下, STM32F072 MCU 中 48 MHz 时编程波特率的误差计算。
编写一些代码,看如何配置 MCU 的 USART2。
int main(void) {UART_HandleTypeDef huart1;/* Initialize the HAL */HAL_Init();/* Configure the system clock */SystemClock_Config();/* Configure the USART1 */huart1.Instance = USART1;huart1.Init.BaudRate = 38400;huart1.Init.WordLength = UART_WORDLENGTH_8B;huart1.Init.StopBits = UART_STOPBITS_1;huart1.Init.Parity = UART_PARITY_NONE;huart1.Init.Mode = UART_MODE_TX_RX;huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;huart1.Init.OverSampling = UART_OVERSAMPLING_16;HAL_UART_Init(&huart1);...
}
第一步是配置 USART1 外设。这里我们使用以下配置: 38400, N, 1. 也就是说,BaudRate 等于 38400 Bps,没有奇偶校验,只有一个停止位。接下来,我们禁用任何通用异步串行通信形式的硬件流控制,并选择最高的过采样率,即每个传输位 16 clock ticks。对 HAL_UART_Init() 函数的调用可确保 HAL 根据给定的选项初始化 USART1。
不要忘记,每个旨在与外界交换数据的外设都必须正确绑定到相应的 GPIO,也就是说我们必须配置 USART1 的 TX 和 RX 引脚。查看 DEMO 原理图(我使用的是野火的板子),我们可以看到 USART1 TX 和 RX 引脚分别为 PA9 和 PA10。此外,我们已经看到,HAL 的设计使 HAL_UART_Init() 函数自动调用 HAL_UART_MspInit()来正确初始化 I/O:我们有责任在我们的应用程序代码中编写此函数,HAL 将自动调用该函数。
是否必须定义此函数?答案是否定的。这只是由 HAL 和 CubeMX 自动生成的代码强制执行的一种做法。HAL_UART_MspInit() 和由 HAL_UART_DeInit() 函数调用的相应函数 HAL_UART_MspDeInit() 在 HAL 中以这种方式声明:__weak void HAL_UART_MspInit(UART_HandleTypeDef *huart);function 属性__weak 是 GCC 的一种方式,用于声明具有较弱范围可见性的符号(此处为函数名称),如果在应用程序中的其他位置(即在另一个可重定位文件中)定义了另一个具有全局范围的同名符号(即没有 __weak 属性),我们将覆盖该符号。如果我们在应用程序代码中实现 HAL 中定义的函数 HAL_UART_MspInit(),则链接器将自动替换对该函数的调用。
下面的代码显示了如何正确地对 HAL_UART_MspInit() 函数进行编码。
void HAL_UART_MspInit(UART_HandleTypeDef* huart) {GPIO_InitTypeDef GPIO_InitStruct;if(huart->Instance==USART1) {/* Peripheral clock enable */__HAL_RCC_USART2_CLK_ENABLE();/**USART1 GPIO ConfigurationPA9 ------> USART1_TXPA10 ------> USART1_RX*/GPIO_InitStruct.Pin = USART_TX_Pin|USART_RX_Pin;GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;GPIO_InitStruct.Pull = GPIO_NOPULL;GPIO_InitStruct.Speed = GPIO_SPEED_LOW;GPIO_InitStruct.Alternate = GPIO_AF1_USART1; /* WARNING: this depends on the specific STM32 MCU */HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);}
}
该函数的设计使其对于应用程序内部使用的每个 USART 都是通用的。if 语句对给定 USART 的初始化代码(在本例中为 USART1)进行约束。其余代码配置 PA9 和 PA10 引脚。
请注意,前面介绍的代码不足以正确初始化某些 STM32 MCU 的 USART 外设。一些 STM32 微控制器(如 STM32F334R8)允许开发人员为给定外设选择时钟源(例如,STM32F334R8 MCU 中的 USART2 可以选择从 SYSCLK、HSI、LSE 或 PCLK1 获得时钟)。强烈建议在首次为 MCU 配置外设时使用 CubeMX,并仔细检查生成的代码以查找此类异常。否则,数据表是此信息的唯一来源。
2.1、使用 CubeMX 的 UART 配置
如前所述,我们第一次为 DEMO 配置 USART1 时,最好使用 CubeMX。第一步是在 Pinout 视图中启用 USART1 外设:单击 Connectivity 部分内的 USART1 条目,然后从 USART1 Mode and Configuration 窗格内的 Mode 组合框中选择 Asynchronous 条目,如下图所示。PA9 和 PA10 引脚都将自动以绿色突出显示。然后,进入 配置 部分并单击 USART1按钮。通过使用 Configuration (配置) 窗格,您可以设置其他选项,例如 BaudRate(波特率)、字长等。
配置 USART 接口后,我们可以生成 C 代码。您会注意到,CubeMX 将所有 USART1 初始化代码都放在 MX_USART1_UART_Init() 中(包含在 main.c 文件中)。相反,所有与 GPIO 配置相关的代码都放在 HAL_UART_MspInit() 函数中,该函数包含在 stm32XXxx_hal_msp.c 文件中。
3、轮询模式下的 UART 通信
STM32 微控制器以及 CubeHAL 提供了三种通过 UART 通信在对等体之间交换数据的方法:轮询、中断和 DMA 模式。从现在开始强调,这些模式不仅仅是处理 UART 通信的三种不同风格。它们是针对同一任务的三种不同的编程方法,从设计和性能的角度来看,它们都带来了几个好处。让我们简要地介绍一下它们。
在轮询模式(也称为阻塞模式)中,主应用程序或其线程之一同步等待数据传输和接收。这是使用此外设的最简单数据通信形式,当传输速率不太低且 UART 不用作我们应用中的关键外设时,可以使用它(经典示例是将 UART 用作调试活动的输出控制台)。
在中断模式(也称为非阻塞模式)下,主应用程序无需等待数据传输和接收完成。数据传输例程在完成配置外设后立即终止。当数据传输结束时,后续中断将向主代码发出有关此消息的信号。与 MCU 执行的其他活动相比,当通信速度较低(低于 38400 Bps)或“很少”发生时,此模式更合适,我们不想让它等待数据传输。
DMA 模式提供了最佳的数据传输吞吐量,这要归功于 UART 外设对 MCU 内部 RAM 的直接访问。当我们完全想将 MCU 从数据传输的开销中解放出来时,此模式最适合高速通信。如果没有 DMA 模式,几乎不可能达到 USART 外设能够处理的最快传输速率。
为了在轮询模式下通过 USART 传输字节序列,HAL 提供了函数
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
其中:
huart:它是指向之前看到的 struct UART_HandleTypeDef实例的指针,用于标识和配置 UART 外设;
pData:是指向数组的指针,长度等于 Size 参数,包含我们要传输的字节序列;
Timeout:是我们要等待传输完成的最长时间,以毫秒表示。如果传输未在指定的超时时间内完成,则函数将中止并返回 HAL_TIMEOUT 值;否则,如果没有发生其他错误,它将返回 HAL_OK 值。此外,我们可以传递一个等于 HAL_MAX_DELAY (0xFFFF FFFF) 的超时,以无限期地等待传输完成。
相反,为了在轮询模式下通过 USART 接收字节序列,HAL 提供了函数
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
其中:
huart:它是指向之前看到的 struct UART_HandleTypeDef实例的指针,用于标识和配置 UART 外围设备;
pData:指向数组的指针,长度至少等于 Size 参数,包含我们将要接收的字节序列。该函数将阻塞,直到收到 Size 参数指定的所有字节。
Timeout:是我们愿意等待接收完成的最长时间,以毫秒表示。如果传输未在指定的超时时间内完成,则函数将中止并返回 HAL_TIMEOUT 值;否则,如果没有发生其他错误,它将返回 HAL_OK 值。此外,我们可以传递一个等于 HAL_MAX_DELAY (0xFFFF FFFF) 的超时来无限期等待接收完成。
需要注意的是,这两个函数提供的超时机制仅在每 1 毫秒调用 HAL_IncTick() 例程时起作用,由 CubeMX 生成的代码完成(增加 HAL 滴答计数器的函数在 SysTick 计时器 ISR 中调用)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>void SystemClock_Config(void);
void printWelcomeMessage(void);
uint8_t readUserInput(void);
uint8_t processUserInput(uint8_t opt);#define WELCOME_MSG "\nwelcome to stm32 usart console\n"
#define MAIN_MENU "\n1 for led toggling\n2 for user button status\n3 for main menu\n"
#define PROMPT "\nenter your choice(1/2/3):"int main(void)
{uint8_t opt = 0;/* Reset of all peripherals, Initializes the Flash interface and the SysTick. */HAL_Init();/* Configure the system clock */SystemClock_Config();/* Initialize all configured peripherals */MX_GPIO_Init();MX_USART1_UART_Init();printMessage:printWelcomeMessage();while (1) {opt = readUserInput();processUserInput(opt);if(opt == 3)goto printMessage;}
}void printWelcomeMessage(void) {//HAL_UART_Transmit(&huart1, (uint8_t*)"\033[0;0H", strlen("\033[0;0H"), HAL_MAX_DELAY);//HAL_UART_Transmit(&huart1, (uint8_t*)"\033[2J", strlen("\033[2J"), HAL_MAX_DELAY);HAL_UART_Transmit(&huart1, (uint8_t*)WELCOME_MSG, strlen(WELCOME_MSG), HAL_MAX_DELAY);HAL_UART_Transmit(&huart1, (uint8_t*)MAIN_MENU, strlen(MAIN_MENU), HAL_MAX_DELAY);
}uint8_t readUserInput(void) {char readBuf[1];HAL_UART_Transmit(&huart1, (uint8_t*)PROMPT, strlen(PROMPT), HAL_MAX_DELAY);HAL_UART_Receive(&huart1, (uint8_t*)readBuf, 1, HAL_MAX_DELAY);return atoi(readBuf);
}uint8_t processUserInput(uint8_t opt) {char msg[30];if(!opt || opt > 3)return 0;sprintf(msg, "%d", opt);HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY);switch(opt) {case 1:HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5);break;case 2:sprintf(msg, "\r\nUSER BUTTON status: %s", HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_SET ? "PRESSED" : "RELEASED");HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY);break;case 3:return 2;};return 1;
}
该示例是一种基本的管理控制台。应用程序开始打印欢迎消息,然后进入循环等待用户选择。第一个选项允许切换LED,而第二个选项用于读取 USER 按钮的状态。最后,选项 3 将导致再次打印欢迎屏幕。
两个字符串 “033[0;0H“ 和 ”033[2J“ 是转义序列。它们是用于操作终端控制台的标准字符序列。第一个选项将光标放在可用控制台屏幕的左上角,第二个选项将清除屏幕。在串口试验中,其实没用到,呵呵。
要与这个简单的管理控制台进行交互,我们需要一个串行通信程序。有几个选项可用。最简单的方法是使用独立程序,例如适用于 Windows 平台的 putty(如果您的 Windows 版本较旧,您也可以考虑使用经典的超级终端工具),或适用于 Linux 和 MacOS 的 kermit,也可以使用STM32CubeIDE 中的集成串行通信工具。
4、中断模式下的 UART 通信
让我们再考虑一下本章的第一个示例。它有什么问题?由于我们的固件都致力于这个简单的任务,因此在轮询模式下使用 UART 不会有什么问题。MCU 基本上被阻止等待用户输入(HAL_MAX_DELAY超时值会阻止 HAL_UART_Receive(),直到通过 UART 发送一个字符)。但是,如果我们的固件必须实时执行CPU其他密集的活动怎么办?假设按以下方式重新编排第一个示例中的 main():
while (1) {opt = readUserInput();processUserInput(opt);if(opt == 3)goto printMessage;performCriticalTasks();
}
在这种情况下,我们不能阻止函数 processUserInput() 的执行等待用户选择,但我们必须为 HAL_UART_Receive() 函数指定一个更短的超时值,否则 performCriticalTasks() 永远不会执行。但是,这可能会导致来自 UART 外设的重要数据丢失(请记住,UART 接口具有 1 字节宽的缓冲区)。
为了解决这个问题,HAL 提供了另一种通过 UART 外设交换数据的方法:中断模式。要使用这种模式,我们必须执行以下任务:
启用 USARTx_IRQn 中断并实现相应的 USARTx_IRQHandler() ISR。
在 USARTx_IRQHandler() 中调用 HAL_UART_IRQHandler():这将执行与管理 UART 外设生成的中断相关的所有活动。如果我们使用 CubeMX 从 NVIC 配置部分启用 USARTx_IRQn,它将自动从 ISR 调用 HAL_UART_IRQHandler()。
使用函数 HAL_UART_Transmit_IT() 和 HAL_UART_Receive_IT() 通过 UART 交换数据。这些功能还启用了 UART 外设的中断模式:以这种方式,外设将在 NVIC 控制器中置位相应的线路,以便在事件发生时引发 ISR。
设计我们的应用程序代码来处理异步事件。
在我们重新编排第一个示例的代码之前,最好先查看可用的 UART 中断和 HAL 例程的设计方式。
4.1、UART 相关中断
每个 STM32 USART 外设都提供下表中列出的中断。这些中断包括与数据传输和通信错误相关的 IRQ。它们可以分为两组: • 传输过程中生成的 IRQ:传输完成、清除发送或传输数据寄存器空中断。• 接收时生成的 IRQ:空闲线路检测、溢出错误、接收数据寄存器不为空、奇偶校验错误、LIN 中断检测、噪声标志(仅在多缓冲区通信中)和成帧错误(仅在多缓冲区通信中)。
如果设置了相应的 Enable Control Bit (上表的第三列),这些事件将生成中断。但是,STM32 MCU 的设计使所有这些 IRQ 都绑定到每个 USART 外设的一个 ISR。例如,USART2 仅将 USART2_IRQn 定义为此外设生成的所有中断的 IRQ。由用户代码来分析相应的 Event Flag 以推断哪个中断生成了请求。
CubeHAL 旨在自动为我们完成这项工作。由于 HAL_UART_IRQHandler() 调用了一系列回调函数,用户会收到有关中断生成的警告,这些函数必须在 ISR 中调用,如前所述。从技术角度来看,轮询和中断模式下的 UART 传输没有太大区别。这两种方法都使用 UART 数据寄存器 (DR) 和以下算法传输字节数组:
• 对于数据传输,在 USART->DR 寄存器中放置一个字节,并等待 Transmit Data Register Empty(TXE) 标志断言为 true。
• 对于数据接收,请等待 Received Data Ready to be Read(RXNE) 断言为 true,然后将 USART->DR 寄存器的内容存储在应用程序内存中。
这两种方法之间的区别在于它们如何等待数据传输完成。在轮询模式下,HAL_UART_Receive()/HAL_UART_Transmit() 函数被设计为,等待每个字节传输完成,置位相应的事件标志。在中断模式下,函数 HAL_UART_Receive_IT()/HAL_UART_Transmit_IT() 的设计使其不用一直等待数据传输完成,而是在生成 RXNEIE/TXEIE 中断时由 ISR 例程完成在 DR 寄存器中放置新字节或将其内容加载到应用程序内存中。
这也是为什么当通信速度太高或我们必须非常频繁地传输大量数据时,在中断模式下传输字节序列并不是一件明智的事情。由于每个字节的传输速度很快,因此 CPU 将被 UART 为传输的每个字节生成的中断“淹没”。为了高速连续传输大量字节序列,最好使用 DMA 模式。
为了在中断模式下传输字节序列,HAL 定义了函数:
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
其中:
huart:它是指向之前看到的 struct UART_HandleTypeDef实例的指针,用于标识和配置 UART 外围设备;
pData:它是指向数组的指针,其长度等于 Size 参数,包含我们将要传输的字节序列;该函数不会阻塞等待数据传输,一旦完成配置UART,它就会将控制权传递给主程序。
相反,为了在中断模式下通过 USART 接收字节序列,HAL 提供函数:
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
其中:
huart:它是指向之前看到的 struct UART_HandleTypeDef实例的指针,用于标识和配置 UART 外围设备;
pData:它是指向数组的指针,长度至少等于 Size 参数,包含我们将要接收的字节序列。该函数不会阻塞等待数据接收,并且会在完成配置 UART 后立即将控制权传递给主程序。
现在我们可以继续重新编写第一个示例。
#include "main.h"
#include "usart.h"
#include "gpio.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>void SystemClock_Config(void);
void printWelcomeMessage(void);
int8_t readUserInput(void);
uint8_t processUserInput(uint8_t opt);
void performCriticalTasks(void);#define WELCOME_MSG "\nwelcome to stm32 usart console\n"
#define MAIN_MENU "\n1 for led toggling\n2 for user button status\n3 for main menu\n"
#define PROMPT "\nenter your choice(1/2/3):"uint8_t UartReady = RESET;int main(void)
{uint8_t opt = 0;/* Reset of all peripherals, Initializes the Flash interface and the SysTick. */HAL_Init();/* Configure the system clock */SystemClock_Config();/* Initialize all configured peripherals */MX_GPIO_Init();MX_USART1_UART_Init();/* Enable USART1 interrupt */HAL_NVIC_SetPriority(USART1_IRQn, 0, 0);HAL_NVIC_EnableIRQ(USART1_IRQn);printMessage:printWelcomeMessage();while (1) {opt = readUserInput();if(opt > 0) {processUserInput(opt);if(opt == 3)goto printMessage;}performCriticalTasks();}
}void USART1_IRQHandler(void) {HAL_UART_IRQHandler(&huart1);
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *UartHandle) {/* Set transmission flag: transfer complete*/UartReady = SET;
}
/*
void printWelcomeMessage(void) {HAL_UART_Transmit_IT(&huart1, (uint8_t*)"\033[0;0H", strlen("\033[0;0H"), HAL_MAX_DELAY);HAL_UART_Transmit_IT(&huart1, (uint8_t*)"\033[2J", strlen("\033[2J"), HAL_MAX_DELAY);HAL_UART_Transmit_IT(&huart1, (uint8_t*)WELCOME_MSG, strlen(WELCOME_MSG), HAL_MAX_DELAY);HAL_UART_Transmit_IT(&huart1, (uint8_t*)MAIN_MENU, strlen(MAIN_MENU), HAL_MAX_DELAY);
}
*/
void printWelcomeMessage(void) {//char *strings[] = {"\033[0;0H", "\033[2J", WELCOME_MSG, MAIN_MENU, PROMPT};char *strings[] = {WELCOME_MSG, MAIN_MENU, PROMPT};for (uint8_t i = 0; i < 3; i++) {HAL_UART_Transmit_IT(&huart1, (uint8_t*)strings[i], strlen(strings[i]));while (HAL_UART_GetState(&huart1) == HAL_UART_STATE_BUSY_TX || HAL_UART_GetState(&huart1) == HAL_UART_STATE_BUSY_TX_RX);}
}/*
uint8_t readUserInput(void) {char readBuf[1];HAL_UART_Transmit(&huart1, (uint8_t*)PROMPT, strlen(PROMPT), HAL_MAX_DELAY);HAL_UART_Receive(&huart1, (uint8_t*)readBuf, 1, HAL_MAX_DELAY);return atoi(readBuf);
}
*/
int8_t readUserInput(void) {int8_t retVal = -1;char readBuf[1];HAL_UART_Receive_IT(&huart1, (uint8_t*)readBuf, 1);if(UartReady == SET) {UartReady = RESET;retVal = atoi(readBuf);}return retVal;
}
uint8_t processUserInput(uint8_t opt) {char msg[30];if(!opt || opt > 3)return 0;sprintf(msg, "%d", opt);HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY);switch(opt) {case 1:HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5);break;case 2:sprintf(msg, "\r\nUSER BUTTON status: %s", HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_SET ? "PRESSED" : "RELEASED");HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY);break;case 3:return 2;};return 1;
}
void performCriticalTasks(void) {HAL_UART_Transmit(&huart1, (uint8_t*)"\nhello, I'm here\n", strlen("\nhello, I'm here\n"), HAL_MAX_DELAY);
}
如上面的代码所示,第一步是使能 USART1_IRQn 并为其分配优先级。接下来,我们定义相应的 ISR,并将调用添加到 HAL_UART_IRQHandler() 中。示例文件的剩余部分是关于重构 printWelcomeMessage() 和 readUserInput() 函数以处理异步事件。
函数 readUserInput() 现在检查全局变量 UartReady 的值。如果等于 SET,则表示用户已向控制台发送了 char。此字符包含在全局数组 readBuf 中。然后,该函数调用 HAL_UART_Receive_IT() 以在中断模式下接收下一个字符。当 readUserInput() 返回大于 0 的值时,将调用函数 processUserInput()。最后,定义了函数 HAL_UART_RxCpltCallback(),当接收到一个字节时,HAL 会自动调用该函数:它简单地设置全局 UartReady 变量,该变量又被 readUserInput() 使用,如前所述。
需要澄清的是,仅当收到使用 Size 参数指定的所有字节(传递给 HAL_UART_Receive_IT() 函数)时,才会调用函数 HAL_UART_RxCpltCallback()。
HAL_UART_Transmit_IT() 函数呢?它的工作方式类似于 HAL_UART_Receive_IT():每次产生 Transmit Data Register Empty(TXE) 中断时,它都会传输数组中的下一个字节。但是,多次调用时必须特别小心。由于该函数在完成设置 UART 后立即将控制权返回给调用方,因此对同一函数的后续调用将失败,并且它将返回 HAL_BUSY 值。
假设按以下方式使用例子中的函数 printWelcomeMessage():
void printWelcomeMessage(void) {HAL_UART_Transmit_IT(&huart2, (uint8_t*)"\033[0;0H", strlen("\033[0;0H"));HAL_UART_Transmit_IT(&huart2, (uint8_t*)"\033[2J", strlen("\033[2J"));HAL_UART_Transmit_IT(&huart2, (uint8_t*)WELCOME_MSG, strlen(WELCOME_MSG));HAL_UART_Transmit_IT(&huart2, (uint8_t*)MAIN_MENU, strlen(MAIN_MENU));HAL_UART_Transmit_IT(&huart2, (uint8_t*)PROMPT, strlen(PROMPT));
}
这里的代码永远不会正常工作,因为每次调用其中一个函数 HAL_UART_Transmit_IT() 都比 UART 传输快得多,下一次调用会失败,从而弄乱 UART 流程。
如果速度不是应用程序的严格要求,并且 HAL_UART_Transmit_IT() 的使用仅限于应用程序的几个部分,则可以使用前面的代码。在前面代码实现中,我们使用 HAL_UART_Transmit_IT() 传输每个字符串,但在传输下一个字符串之前,我们等待传输完成。但是,这只是轮询模式下 HAL_UART_Transmit() 的一个变体,因为我们忙于等待每个 UART 传输。
一个更优雅和执行的解决方案是使用一个临时内存区域来存储字节序列并让 ISR 执行传输。队列是处理 FIFO 事件的最佳选择。有几种方法可以实现队列,包括使用静态和动态数据结构。如果我们决定实现具有预定义内存区域的队列,则循环缓冲区是适合此类应用程序的数据结构。
循环缓冲区只不过是一个具有固定大小的数组,其中两个指针用于跟踪仍需要处理的数据的头部和尾部。在循环缓冲区中,数组的第一个和最后一个位置是“连续的”。这就是这种数据结构被称为 circular 的原因。循环缓冲区也有一个重要的功能:除非我们的应用程序最多有两个并发执行流(在我们的例子中,将字符放入缓冲区内的主流和通过 UART 发送这些字符的 ISR 例程),否则它们本质上是线程安全的,因为“消费者”线程(在我们的例子中是 ISR)将只更新尾指针,而生产者(主程序)将只更新头部指针。可以通过多种方式实现循环缓冲区。其中一些更快,另一些更安全(也就是说,它们增加了额外的开销,确保我们正确处理缓冲区内容)。
使用循环缓冲区,我们可以通过以下方式定义一个新的 UART 传输函数:
uint8_t UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t len) {if(HAL_UART_Transmit_IT(huart, pData, len) != HAL_OK) {if(RingBuffer_Write(&txBuf, pData, len) != RING_BUFFER_OK)return 0;}return 1;
}
该函数只做两件事:它尝试在中断模式下通过 UART 发送缓冲区;如果 HAL_UART_Transmit_IT() 函数失败(这意味着 UART 已经在传输另一条消息),则字节序列将放置在循环缓冲区内。
由 HAL_UART_TxCpltCallback() 来检查循环缓冲区内的待处理字节:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {if(RingBuffer_GetDataLength(&txBuf) > 0) {RingBuffer_Read(&txBuf, &txData, 1);HAL_UART_Transmit_IT(huart, &txData, 1);}
}
printWelcomeMessage() 和 processUserInput() 函数现在可以不用进行发送忙等待,如下所示:
void printWelcomeMessage(void) {char *strings[] = {"\033[0;0H", "\033[2J", WELCOME_MSG, MAIN_MENU, PROMPT};/*for (uint8_t i = 0; i < 3; i++) {HAL_UART_Transmit_IT(&huart1, (uint8_t*)strings[i], strlen(strings[i]));while (HAL_UART_GetState(&huart1) == HAL_UART_STATE_BUSY_TX || HAL_UART_GetState(&huart1) == HAL_UART_STATE_BUSY_TX_RX);}*/for (uint8_t i = 0; i < 5; i++)UART_Transmit(&huart1, (uint8_t*)strings[i], strlen(strings[i]));
}uint8_t processUserInput(uint8_t opt) {char msg[30];if(!opt || opt > 3)return 0;sprintf(msg, "%d", opt);UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg));switch(opt) {case 1:HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5);break;case 2:sprintf(msg, "\r\nUSER BUTTON status: %s",HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_RESET ? "PRESSED" : "RELEASED");UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg));break;case 3:return 2;};UART_Transmit(&huart1, (uint8_t*)PROMPT, strlen(PROMPT));return 1;
}
RingBuffer_Read() 没有使用更高性能的实现时,它就不那么快。这样,对于某些实际情况,HAL_UART_TxCpltCallback() 例程(从 ISR 例程调用)的整个开销可能太高。如果是这种情况,您可以考虑创建一个如下所示的函数:
void processPendingTXTransfers(UART_HandleTypeDef *huart) {if(RingBuffer_GetDataLength(&txBuf) > 0) {RingBuffer_Read(&txBuf, &txData, 1);HAL_UART_Transmit_IT(huart, &txData, 1);}
}
然后,您可以直接从主应用程序代码中调用此函数,如果您使用的是 RTOS,则可以在较低权限的任务中调用此函数。
5、错误管理
在处理外部通信时,错误管理是我们必须认真考虑的一个方面。STM32 UART 外设提供一些与通信错误相关的错误标志。此外,还可以启用相应的中断,以便在发生错误时注意到。
CubeHAL 旨在自动检测错误情况,并向我们发出警告。我们只需要在应用程序代码中实现 HAL_UART_ErrorCallback() 函数。如果发生错误,HAL_UART_IRQHandler() 将自动调用它。要了解发生了哪个错误,我们可以检查 UART_HandleTypeDef->ErrorCode 字段的值。错误代码如下表。
HAL_UART_IRQHandler() 的设计使我们不应该关心 UART 错误管理的实现细节。HAL 代码将自动执行处理错误所需的所有步骤(例如清除事件标志、待处理位等),让我们负责在应用程序级别处理错误(例如,我们可能会要求另一个对等节点重新发送损坏的帧)。
6、HAL_UART 模块中可用的回调
到目前为止,我们了解了如何利用回调机制来通知 CubeHAL 库传输完成事件。CubeHAL 采用事件驱动方法设计,几乎每个 HAL_XXX 模块都提供了一组可用于捕获特定外设事件的回调。
下表列出了与 STM32F4 库中的 UART 模块相关的所有可用回调。前 8 个回调与传输过程中生成的事件相关。HAL_UART_MspInitCallback 和 HAL_UART_MspDeInitCallback 是 CubeMX 在 Core/Src/stm32XXxx_hal_msp.c 文件中自动生成的两个回调。最后,最后两个回调与 UART FIFO 相关,这是最近的 STM32 MCU中提供的功能。
HAL_PPP 和 HAL_PPPEx 模块之间的差异。到目前为止,我们已经遇到了几个 HAL 模块,每个模块都涵盖一个特定的外设或核心功能。每个 HAL 模块都包含在名为 stm32XXxx_hal_ppp.{c,h} 的文件中。其中“XX”代表 STM32 系列,“ppp”代表外设类型。例如,stm32f4xx_hal_uart.c 文件包含 HAL_DMA 模块的所有函数定义,所有这些函数都有一个所有 STM32 系列通用的 API。这加强了 STM32 系列中代码的可移植性。
但是,某些外设功能是给定系列特有的,不能以所有 STM32 产品组合通用的一般方式抽象出来。在这种情况下,HAL 会提供名为 HAL_PPPEX 的扩展模块,并在名为 stm32XXxx_hal_ppp_ex.{c,h} 的文件中实现。例如,前面的 HAL_UARTEx_RxFifoFullCallback() 函数在 HAL_UARTEx 模块中定义,在 stm32f4xx_hal_uart_ex.c 文件中实现。扩展模块中 API 的实现特定于相应的 STM32 系列,甚至特定于该系列中的给定零件编号,使用这些 API 会导致代码的可移植性降低。
最近的 CubeHAL 提供了两种定义回调的方法。标准的方法是在应用程序源文件中定义回调函数,并让编译器覆盖使用 modifier __weak定义的占位符函数。第二种方法包括使用一组专用的 API,这些 API 允许在运行时定义回调例程。对于大多数 HAL 模块,可以在 Core/Inc/stm32f4xx_hal_conf.h 中将宏 USE_HAL_PPP_REGISTER_CALLBACKS设置为 1(其中 PPP 是相应的外设名称)。这将启用以下函数:
HAL_StatusTypeDef HAL_UART_RegisterCallback(UART_HandleTypeDef *huart, HAL_UART_CallbackIDTypeDef CallbackID, pUART_CallbackTypeDef pCallback);
和
HAL_StatusTypeDef HAL_UART_UnRegisterCallback(UART_HandleTypeDef *huart, HAL_UART_CallbackIDTypeDef CallbackID, pUART_CallbackTypeDef pCallback);
这些函数接受与上表中的回调对应的 CallbackID 和指向回调函数的指针。在运行时配置回调的能力使程序员有可能在运行时更改回调行为,但代价是 FW 大小增加。
CubeMX 提供了一种启用运行时回调机制的便捷方法。在 Project Manager 窗格中,单击 Advanced Settings 部分。Register Callback 窗格显示在右侧,如上图所示。对于给定的外设,可以通过在相应字段中选择 ENABLE 来启用运行时回调。