一、概述
无论是新手还是大佬,基于STM32单片机的开发,使用STM32CubeMX都是可以极大提升开发效率的,并且其界面化的开发,也大大降低了新手对STM32单片机的开发门槛。
本文主要讲述STM32芯片的Uart的配置及其相关知识。Uart因为其协议简洁及使用便捷,算是单片机中,除了GPIO这个外设外,出镜率最高的一个外设了。接下来就来看看如何使用STM32CubeMX初窥门径。
二、软件说明
STM32CubeMX是ST官方出的一款针对ST的MCU/MPU跨平台的图形化工具,支持在Linux、MacOS、Window系统下开发,其对接的底层接口是HAL库,另外习惯于寄存器开发的同学们,也可以使用LL库。STM32CubeMX除了集成MCU/MPU的硬件抽象层,另外还集成了像RTOS,文件系统,USB,网络,显示,嵌入式AI等中间件,这样开发者就能够很轻松的完成MCU/MPU的底层驱动的配置,留出更多精力开发上层功能逻辑,能够更进一步提高了嵌入式开发效率。
演示版本 6.7.0
三、Uart简介
通用异步收发传输器(Universal Asynchronous Receiver/Transmitter),通常称作UART,其具体协议及内容,在我的另一篇文章有详细说明,这里不再赘述。这里主要讲下STM32中Uart的特点。现在我们以G0系列的Uart为例(STM32系列的单片机Uart大同小异,掌握一个就相当于所有都会了),查看手册找到对应的原理框图。这里我们先重点关注发送和接收这两部分及一些通用寄存器。
- 发送(TDR,TxFIFO,Tx Shift Reg)
TDR:发送数据寄存器,因为是在COM Controller中,我们可以直接操作的。在初始化好Uart的情况下,只要往这个寄存器里填充一个字节的数据,硬件会自动将此数据发送出去。
TxFIFO:发送队列,当开启发送队列功能时,往TDR里填充数据时,硬件会将TDR里的数据移至TxFIFO中。
Tx Shift Reg:发送移位寄存器,未开启TxFIFO功能时,如果TDR里有数据,则会先把数据拷贝到发送移位寄存器里,再根据设置的波特率一个字节一个字节往外发。
完整的发送流程(无FIFO)
1、检查当前TXE标志是否为1,为1说明当前TDR是空的,可以填入数据。
2、TXE(发送寄存器为空)标志为1,往TDR写入一个字节的数据,此时硬件会将TXE标志清0。
3、如果此时Tx Shift Reg是空的,硬件则将TDR中的数据拷贝到Tx Shift Reg中,并且将TXE标志置1;否则等到Tx Shift Reg为空再拷贝。
4、硬件根据配置的波特率、数据位、停止位、校验位,将数据包装成Uart协议的形式,一个位一个位地往外发(先发高位,再发低位)。
5、当Tx Shift Reg发送完最后一个停止位时,由硬件将TC(发送完成)标志置1。
- 接收(RDR,RxFIFO,Rx Shift Reg)
RDR:接收数据寄存器,因为是在COM Controller中,我们可以直接进行读取操作。
RxFIFO:接收队列,当开启发送队列功能时,往TDR里填充数据时,硬件会将TDR里的数据移至TxFIFO中。
Rx Shift Reg:接收移位寄存器,当外部有符合Uart协议的数据过来时,由Rx Shift Reg根据当前串口配置一位一位地接收数据。当接收了完整的一个字节数据后(包括起始位,停止位等),会把数据部分拷贝至RDR中。
完整的接收流程(无FIFO)
1、Rx Shift Reg根据当前波特率、数据位、停止位、校验位的配置按位接收数据。
2、当接收完一个完整的字节数据(包括起始位、停止位这些)时,硬件会把数据部分(不包括起始、停止、检验位)拷贝至RDR,并将RXNE标志位置1。
3、查询当前RXNE(接收寄存器非空)标志为1时,读取RDR的数据,将RDR数据读取时,硬件会将RXNE标志清0。
四、功能配置
1、在"Connectivity"选项中找到USART(有些是UART),在右边模式配置框里选择"Asynchronous",意思是异步的,即只需要一收一发两根通信线即可,如果选择"Synchronous"则是同步串口,需要多一个时钟线。其他的就不多讲,主要看下最常用的双线异步串口配置。
2、对串口的基础功能进行配置,正常默认配置即可,一般就改个波特率就行。
波特率(Baud Rate):通信速率,常用是9600,意思是1s传输9600个位。
数据位(Word Length):一个字节中携带的数据位数,一般是8位,ASCII可以只用7位。
校验位(Parity):奇偶检验,用来检验传输的数据是否有误,一般配置为无校验位。
停止位(Stop Bits):串口协议中末尾的停止位位数,一般配置为1个停止位。
数据传输方向(Data Direction):一般收发都要,所以这里配置接收和发送(Receive and Transmit)。
过采样(Over Sampling):有8倍采样和16倍采样两种,8倍采样,也就是一个数据位采样8次,16倍则是16次。采样率高精度会高一些,当然相应的功耗也会变高。
单次采样(Single Sample):使能时使用单次采样值,否则使用三次采样值。前面的过采样会有8或16次采样值,当选择单次采样时,会使用其中的一次采样值作为数据位逻辑电平的结果。三次采样则用三次采样的判断结果为准。同样三次采样也是为了确保数据的准确性。
自动波特率(Auto Baudrate):顾名思义,可以根据接收到的数据进行波特率自适应,即使用一个115200波特率的串口给当前这个串口发送数据,这个串口可以识别该波特率并调整自身波特率与之相对应。但使用这个功能是有个前提的,就是必须规定好第一个字节的数据,有01等几个数据选项,不同数据和不同波特率下,自适应的准确率有所不同,具体看手册有详细说明。
发送电平反转(TX Pin Active Level Inversion):发送引脚电平极性反转,正常情况下空闲电平为高电平,使能该功能后变成空闲电平为低电平。
接收电平反转(RX Pin Active Level Inversion):接收引脚电平极性反转,正常情况下空闲电平为高电平,使能该功能后变成空闲电平为低电平。
数据电平反转(Data Inversion):收发数据的逻辑电平极性反转,正常是高电平为逻辑1,低电平为逻辑0,使能该功能后则变成低电平为逻辑1,高电平为逻辑0。校验位也随着反转。
收发引脚互换(TX and RX Pins Swapping):接收和发送引脚互换,适用于外部硬件连线错误时进行切换。
溢出检测(Overrun):用于开启接收溢出检测,使用该功能后,当接收数据未取出时,又接收到一个数据,此时会触发一个溢出标志。
接收错误时不禁用DMA(DMA on RX Error):使能该功能后,即使出现接收错误也不会关闭DMA传输。
数据高位先发(MSB First):正常数据是先发低位再发高位,使能该功能后可以先发高位数据。
3、选择对应的端口,开启串口功能后,工具会默认配置一对收发引脚,最好根据自己的板子确认是否是这对串口,如果不是,则需要手动修改端口。
PA9和PA10对应开发板这边的D8和D2口。
4、如果需要使用中断来进行收发,则点击"NVIC Setting",使能对应的中断。
5、在"Project Manager"界面中,选择"Advanced Setting",找到USART,选择对应生成的库,串口是可以选择生成HAL库或LL库的。最后点击"GENERATE CODE"生成工程代码即完成配置。
五、功能实现
前面配置完生成的工程,已经实现了串口的初始化,接下来要实现功能逻辑,还需要调用相应的接口。
- 阻塞收发-HAL库
重点关注HAL_UART_Transmit和HAL_UART_Receive这两个函数。两者均为阻塞接口,即当调用发送接口时,需要等到所有数据发送完成才会退出此接口,接收则是查询接收的数据,直到数据接收完成或超时。如果有需要同时进行收发的,可以调用HAL_USART_TransmitReceive这个接口。
uint8_t RecvData[100];
/* 收到数据回显 */
if (HAL_OK == HAL_UART_Receive(&huart1, RecvData, 9, 1000))
{
HAL_UART_Transmit(&huart1, RecvData, 9, 1000);
}
用串口调试助手试下效果,发送"abcdefg\r\n",会回复"abcdefg\r\n"。
这个接口其实挺鸡肋的,因为接收了多少个字节接口并没有给你反馈,所以就出现了一种尴尬的情况,只有当你接收字节数刚好为你传入的字节数时,接口才会给你返回OK状态,否则,正常情况下,都是返回Timeout。
- 阻塞收发-LL库
首先在工程配置中把串口生成库改成LL库。
仿造HAL库的实现,自己实现一个简易版的发送和接收接口。先梳理一下收发接口的基本流程(省略保护相关的操作)。
- 发送接口的发送流程
- 接收接口的接收流程
使用LL库仿造HAL库的实现,这里接收接口我们稍微改造一下,返回接收的字节数。
/*******************************************************************************
* 函数名称 : uint8_t Uart_Send(void *pt,
* 描 述 : 发送函数
* 输 入 : void *pt, // 结构块实体
uint8_t *data, // 发送的数据缓存
uint32_t len, // 发送的数据长度
uint32_t timeout // 超时时间
* 返 回 : 1-成功 0-失败
* 作 者 : Chewie
* 时 间 : 2023.03.20 22:28:21
*******************************************************************************/
uint8_t Uart_Send(void *pt,
uint8_t *data,
uint32_t len,
uint32_t timeout)
{
uint32_t i = 0;
/* 获取当前计数值,用于计算超时时间 */
uint32_t tickstart = HAL_GetTick();
/* 按需要发送的字节数循环发送 */
while (len--)
{
/* 超时时间到,则认为发送失败 */
if ((HAL_GetTick() - tickstart) >= timeout)
{
return 0;
}
/* 当TXE寄存器为1时,可以往发送寄存器里填值 */
while (!LL_USART_IsActiveFlag_TXE((USART_TypeDef *)pt))
{
/* 超时时间到,则认为发送失败 */
if ((HAL_GetTick() - tickstart) >= timeout)
{
return 0;
}
}
LL_USART_TransmitData8((USART_TypeDef *)pt, data[i++]);
}
/* 获取当前计数值,用于计算超时时间 */
tickstart = HAL_GetTick();
/* 等待发送完成--TC标志位为1 */
while (!LL_USART_IsActiveFlag_TC((USART_TypeDef *)pt))
{
/* 超时时间到,则认为发送失败 */
if ((HAL_GetTick() - tickstart) >= timeout)
{
return 0;
}
}
return 1;
}
/*******************************************************************************
* 函数名称 : uint8_t Uart_Recv(void *pt,
* 描 述 : 接收函数
* 输 入 : void *pt, // 结构块实体
uint8_t *data, // 接收的数据缓存
uint32_t max_len, // 可接收的最大字节数
uint32_t *len, // 接收的数据长度
uint32_t timeout // 超时时间
* 返 回 : 1-成功 0-失败
* 作 者 : Chewie
* 时 间 : 2023.03.20 22:33:26
*******************************************************************************/
uint8_t Uart_Recv(void *pt,
uint8_t *data,
uint32_t max_len,
uint32_t *len,
uint32_t timeout)
{
*len = 0;
/* 获取当前计数值,用于计算超时时间 */
uint32_t tickstart = HAL_GetTick();
/* 按需要发送的字节数循环发送 */
while (max_len--)
{
/* 超时时间到,则认为发送失败 */
if ((HAL_GetTick() - tickstart) >= timeout)
{
return 0;
}
/* 当RXNE寄存器为1时,可以获取接收寄存器里的值 */
while (!LL_USART_IsActiveFlag_RXNE((USART_TypeDef *)pt))
{
/* 超时时间到,则认为发送失败 */
if ((HAL_GetTick() - tickstart) >= timeout)
{
return 0;
}
}
data[(*len)++] = LL_USART_ReceiveData8((USART_TypeDef *)pt);
}
return 1;
}
/* 应用代码 */
int main()
{
uint8_t RecvData[255];
uint32_t recv_len = 0;
...
while (1)
{
/* 收到数据回显 */
Uart_Recv(USART1, RecvData, sizeof(RecvData), &recv_len, 1000);
if (0 != recv_len)
{
Uart_Send(USART1, RecvData, recv_len, 1000);
}
}
}
这样子的一个收发接口基本就可以满足基本的应用了。
接收使用查询的方式来接收数据,容易漏数据。且在裸机情况下,使用这接口发送数据,需要在这里等待,无法执行其他任务,如果有多个串口阻塞的情况会更严重。那么有没有什么办法可以提高通信的实时性又不影响其他任务的运行呢?除了使用操作系统外,还可以借用单片机本身一个很重要的功能——中断。下面我们来看下中断的实现方式。
- 中断收发-HAL库
这里我们使用HAL库的另外两个读写接口,HAL_UART_Transmit_IT和HAL_UART_Receive_IT。只需要调用这两个接口,中断会帮我们执行剩余的收发工作。为了可以实时知道接收、发送完成的状态,我们再开启回调函数注册的功能,所以还得再用到一个回调函数注册接口HAL_UART_RegisterCallback。
首先先打开串口中断,再使能串口的回调注册(理论上这里用到USART就打开USART,用到UART就打开UART)。
同样,实现一个简单的字符串回显功能。
/* 发送完成回调,发送完就启动接收 */
void UartTxCallback(UART_HandleTypeDef *huart1)
{
HAL_UART_Receive_IT(huart1, RecvData, 9);
}
/* 接收完成回调,接收完就启动发送 */
void UartRxCallback(UART_HandleTypeDef *huart1)
{
HAL_UART_Transmit_IT(huart1, RecvData, 9);
}
int main(void)
{
/* 初始化部分省略 */
......
/* 注册发送完成回调函数 */
HAL_UART_RegisterCallback(&huart1, HAL_UART_TX_COMPLETE_CB_ID, UartTxCallback);
/* 注册接收完成回调函数 */
HAL_UART_RegisterCallback(&huart1, HAL_UART_RX_COMPLETE_CB_ID, UartRxCallback);
/* 接收启动 */
HAL_UART_Receive_IT(&huart1, RecvData, 9);
while (1)
{
......
}
}
跟前面非中断形式的接口一样的问题,这里的接收接口没有反馈接收到的字节数,所以目前这种写法必须是知道需要接收多少个字节,就设置接收多少个字节,对于不定长的数据就没办法处理。
- 中断收发-LL库
LL库的实现,我们依葫芦画瓢,模仿HAL库的实现。先把HAL实现的基础流程图画一下。
- 发送接口的发送流程
-
接收接口的接收流程
-
中断执行的流程
使用LL库仿造HAL库的实现,同样的,这里对接收回调接口进行改造,使其在接收完成回调时,传回接收到的字节数量。因为现在是不定长接收数据,所以需要把接收完成的判断机制从原本的字节数判断修改为时间超时判断。
struct tagUartRxTxCB
{
USART_TypeDef *Uart;
uint8_t *Data;
uint32_t RxMaxLen;
uint32_t RxCnt;
uint32_t StartRx;
uint32_t Timeout;
uint32_t TxLen;
uint32_t TxCnt;
uint32_t TimeoutCnt;
void (*RxCallback)(void *);
void (*TxCallback)(void *);
};
/* 实例化 */
struct tagUartRxTxCB UartCB =
{
.Uart = USART1,
};
/* 回调类型 */
enum emUartRegCallbackType
{
UART_REGCB_TYPE_rx_complete = 0,
UART_REGCB_TYPE_tx_complete = 1,
};
/*******************************************************************************
* 函数名称 : void Uart_RegisterCallback(void *pt,
* 描 述 : 注册回调函数
* 输 入 : void *pt, // 模块结构块
enum emUartRegCallbackType type, // 回调类型
void (*callback)(void *) // 回调函数
* 返 回 : 无
* 作 者 : Chewie
* 时 间 : 2023.05.01 23:52:49
*******************************************************************************/
void Uart_RegisterCallback(void *pt,
enum emUartRegCallbackType type,
void (*callback)(void *))
{
struct tagUartRxTxCB *cb = (struct tagUartRxTxCB *)pt;
switch (type)
{
case UART_REGCB_TYPE_rx_complete:
{
cb->RxCallback = callback;
break;
}
case UART_REGCB_TYPE_tx_complete:
{
cb->TxCallback = callback;
break;
}
default:
break;
}
}
/*******************************************************************************
* 函数名称 : void Uart_RecvStart(void *pt,
* 描 述 : 开始接收
* 输 入 : void *pt, // 模块结构块
uint8_t *data, // 接收的数据缓存
uint32_t max_len, // 一次接收的最大字节数
uint32_t timeout // 接收超时时间
* 返 回 : 无
* 作 者 : Chewie
* 时 间 : 2023.05.01 23:53:55
*******************************************************************************/
void Uart_RecvStart(void *pt,
uint8_t *data,
uint32_t max_len,
uint32_t timeout)
{
struct tagUartRxTxCB *cb = (struct tagUartRxTxCB *)pt;
/* 记录需要接收的最大字节数 */
cb->RxMaxLen = max_len;
cb->Data = data;
/* 记录超时时间 */
cb->Timeout = timeout;
/* 清空计数 */
cb->RxCnt = 0;
/* 使能接收中断 */
LL_USART_EnableIT_RXNE(cb->Uart);
}
/*******************************************************************************
* 函数名称 : void Uart_SendStart(void *pt,
* 描 述 : 开始发送
* 输 入 : void *pt, // 模块结构块
uint8_t *data, // 发送数据缓存
uint32_t send_len // 发送数据字节数
* 返 回 : 无
* 作 者 : Chewie
* 时 间 : 2023.05.01 23:55:16
*******************************************************************************/
void Uart_SendStart(void *pt,
uint8_t *data,
uint32_t send_len)
{
struct tagUartRxTxCB *cb = (struct tagUartRxTxCB *)pt;
/* 记录需要发送的数据字节数 */
cb->TxLen = send_len;
/* 清空计数 */
cb->TxCnt = 0;
/* 使能TXEIE */
LL_USART_EnableIT_TXE(cb->Uart);
/* 使能TCIE--使能前要清一次TC,主要是第一次使能Uart时会产生一个TC */
LL_USART_ClearFlag_TC(cb->Uart);
LL_USART_EnableIT_TC(cb->Uart);
}
/*******************************************************************************
* 函数名称 : void UartRxIrq(void *pt)
* 描 述 : 接收中断处理函数
* 输 入 : 模块结构块
* 返 回 : 无
* 作 者 : Chewie
* 时 间 : 2023.05.01 23:56:26
*******************************************************************************/
void UartRxIrq(void *pt)
{
struct tagUartRxTxCB *cb = (struct tagUartRxTxCB *)pt;
/* 进一次RXNE中断接收一次数据 */
if ( (LL_USART_IsEnabledIT_RXNE(cb->Uart))
&& (LL_USART_IsActiveFlag_RXNE(cb->Uart))
)
{
/* 刷新接收超时时间 */
cb->TimeoutCnt = HAL_GetTick();
/* 超时时间有效 */
cb->StartRx = 1;
/* 接收数据 */
cb->Data[cb->RxCnt++] = LL_USART_ReceiveData8(cb->Uart);
/* 接收满对应字节数,则回调接收完成函数 */
if (cb->RxCnt == cb->RxMaxLen)
{
cb->RxCallback(cb);
cb->StartRx = 0;
}
}
}
/*******************************************************************************
* 函数名称 : void UartTxIrq(void *pt)
* 描 述 : 发送中断处理函数
* 输 入 : 模块结构块
* 返 回 : 无
* 作 者 : Chewie
* 时 间 : 2023.05.01 23:56:49
*******************************************************************************/
void UartTxIrq(void *pt)
{
struct tagUartRxTxCB *cb = (struct tagUartRxTxCB *)pt;
/* TC为1时触发的中断,则重新启动接收 */
if ( (LL_USART_IsEnabledIT_TC(cb->Uart))
&& (LL_USART_IsActiveFlag_TC(cb->Uart))
)
{
/* 清除标志 */
LL_USART_ClearFlag_TC(cb->Uart);
/* 发送完成回调 */
cb->TxCallback(cb);
return ;
}
/* TXE为1时触发的中断,则发送数据 */
if ( (LL_USART_IsEnabledIT_TXE(cb->Uart))
&& (LL_USART_IsActiveFlag_TXE(cb->Uart))
)
{
LL_USART_TransmitData8(cb->Uart, cb->Data[cb->TxCnt++]);
}
/* 剩余发送字节数为0,则说明发送完,关闭TXEIE防止再次进TXE中断 */
if (cb->TxCnt == cb->TxLen)
{
/* 禁止TXEIE */
LL_USART_DisableIT_TXE(cb->Uart);
}
}
/*******************************************************************************
* 函数名称 : void UartTxCallback(void *pt)
* 描 述 : 发送完成回调函数
* 输 入 : 模块结构块
* 返 回 : 无
* 作 者 : Chewie
* 时 间 : 2023.05.01 23:57:09
*******************************************************************************/
void UartTxCallback(void *pt)
{
Uart_RecvStart(pt, RecvData, sizeof(RecvData), 5);
}
/*******************************************************************************
* 函数名称 : void UartRxCallback(void *pt)
* 描 述 : 接收完成回调函数
* 输 入 : 模块结构块
* 返 回 : 无
* 作 者 : Chewie
* 时 间 : 2023.05.01 23:57:23
*******************************************************************************/
void UartRxCallback(void *pt)
{
struct tagUartRxTxCB *cb = (struct tagUartRxTxCB *)pt;
Uart_SendStart(pt, RecvData, cb->RxCnt);
}
int main(void)
{
......
Uart_RegisterCallback(&UartCB, UART_REGCB_TYPE_rx_complete, UartRxCallback);
Uart_RegisterCallback(&UartCB, UART_REGCB_TYPE_tx_complete, UartTxCallback);
Uart_RecvStart(&UartCB, RecvData, sizeof(RecvData), 5);
while (1)
{
/* 超时时间到,则认为接收完成 */
if ( ((HAL_GetTick() - UartCB.TimeoutCnt) >= UartCB.Timeout)
&& (UartCB.StartRx == 1)
)
{
UartCB.StartRx = 0;
UartCB.RxCallback(&UartCB);
}
}
}
/***********************stm32f0xx_it.c**************************/
void USART1_IRQHandler(void)
{
/* USER CODE BEGIN USART1_IRQn 0 */
UartRxIrq(&UartCB);
UartTxIrq(&UartCB);
/* USER CODE END USART1_IRQn 0 */
/* USER CODE BEGIN USART1_IRQn 1 */
/* USER CODE END USART1_IRQn 1 */
}
目前这种实现方式用在一收一发的场景下,如果需要收发全双工进行,那就需要自己举一反三了。其实目前这种形式的收发过程还是需要内核来介入,有没有一种办法可以减少内核的介入时间呢?当然有,这就是DMA。对于Uart+DMA的配置及使用,可以参考《STM32CubeMX-DMA配置(ADC+DMA 和 UART+DMA)》
六、注意事项
1、如果对外使用的是RS485接口,需要注意收发引脚的转换时序,一般情况下都为接收状态,只有在要发送前切发送状态,发送完成后切接收状态(至少需要在触发TC标志后再切换,否则会丢数据)。485总线上不可同时存在两个设备及以上处于发送状态,否则会导致总线电平被拉低,无法正常接收数据。
2、当单片机主频较高时,配置低波特率时有可能波特率设置的位数不够(大部分设置波特率的寄存器只有16位),应用手册里的公式用串口的时钟频率/波特率后的值可能会超过其寄存器可设置范围,此时可以通过给串口分频以设置更低的波特率。如果使用STM32CubeMX进行配置时,工具会自动限制最低波特率,使用HAL库的设置接口也会有限制,使用LL库自己实现的设置接口就得注意一下了。
3、ST串口里设置的数据位是包含校验位的,比如设置数据位为8位,并且设置了奇偶校验,则实际有效数据只有7位,剩下一个位被奇偶校验位占用。而像Modbus Poll这种工具,设置的数据位则不包含校验位。
七、相关链接
【知识分享】异步串行收发器Uart(串口)-通信协议详解
【工具使用】STM32CubeMX-DMA配置(ADC+DMA 和 UART+DMA)