【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数
展示了MSPM0单片机的UART常用配置与字符串收发程序,并使用自定义printf函数增强了串口发送的易用性。
前言
串口通信是单片机与上位机之间的常用通信方式,其接线简单,适合单片机与上位机传输基本的调试和控制数据。这篇文章中,我们来研究MSPM0的UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)模块,实现M0单片机与电脑之间的串口通信。
当前TI已经将CCS的主体转移到了Theia框架上,推出了CCS v20版本,其功能相比于之前的CCS Theia已经更为完善,因此我后续的开发会逐渐转移到新的CCS v20版本上,新版CCS v20的各项功能和设置位置与旧版CCS有一定区别,但基本上可以说是大同小异。
UART概述
UART是异步串行通信,没有时钟线,因此通信双方需要事先确定一个共同的波特率。它的接口具有两根数据线,分别是Tx和Rx,两个设备之间的Tx与Rx交叉连接,可实现全双工通信。由于现在的电脑已经不引出串口接口了,因此在单片机使用串口与电脑通信时,还需要加入一个USB转串口模块,如下图所示。

UART数据帧由起始位、数据位、校验位(可选)和停止位组成,其中数据一般采用最低位(LSB)优先顺序。

在使用UART外设时,UART帧生成是由外设硬件完成的,对于我们来说,只需要根据需求正确配置模块参数,然后研究数据的读写即可。

从官方给出的UART模块框图可以看到其更详细的结构,其中波特率生成器、收发器和收发FIFO等模块用来物理实现UART通信,而时钟控制、事件与中断控制、数据、控制和状态寄存器等模块则使得我们可以通过CPU与UART实现交互。
UART收发实现
利用M0单片机内部的UART模块,可以实现单字节的串口数据收发,而为了发送整个字符串数据,通常有阻塞、中断和DMA共3种方式可以实现:
- 阻塞(轮询)方式:发送时,CPU将当前字节送入UART发送数据寄存器,阻塞等待发送完成,然后送入下一字节,直到整个字符串发送完成。接收时,CPU不断轮询等待UART接收数据寄存器非空,然后读取当前字节。阻塞方式实现逻辑简单,但会占用大量CPU资源;
- 中断方式:可配置发送和接收完成中断,当收到中断时,则表示当前数据已发送或已接收,CPU可以直接写入或读取,无须持续等待,这样大大减少CPU占用;
- DMA方式:可配置DMA发送和接收,由UART中断直接触发DMA进行数据搬运,无须CPU介入,在完成后通过DMA完成中断通知CPU进行处理,占用CPU资源最少。但由于一般串口接收数据不定长,采用DMA接收需要使用空闲中断等更复杂的配置来实现。
通常来说,串口发送数据的时刻和长度可确定,可以使用简单的阻塞方式进行发送;若需要发送大量数据,则可以改为DMA方式发送,来减少CPU占用。串口接收数据的时刻和长度则一般都不确定,因此可使用中断方式接收,来实现CPU占用和配置复杂度的平衡取舍。
UART阻塞方式发送、自定义printf函数
M0单片机UART阻塞方式发送的配置十分简单,在Syscfg中启用UART模块,配置波特率和引脚等参数即可。


配置好UART模块参数后,使用DL_UART_transmitDataBlocking函数即可实现UART阻塞方式发送单个字符,该函数首先等待发送FIFO不满,然后将数据字节写入发送FIFO(当不使用FIFO模式时,它就是单字节寄存器)。要实现字符串发送,只需要遍历字符串,依次调用字符发送函数即可。当需要发送更复杂的数据时,printf函数比单纯的字符串发送函数要更加好用,通过重写fputc函数的方式,可以实现printf的重定向,但这种重定向方式不便于同时使用多个串口。下面使用stdarg.h标准库的参数列表,以及vsprintf函数,实现自定义的printf函数。
UART.h文件:
#ifndef __USER_UART_H__
#define __USER_UART_H__
#include <stdint.h>
#include <stdarg.h>
#include <stdio.h>
#include "ti_msp_dl_config.h"
#define UART_TX_BUF_SIZE 256 // UART发送缓冲区长度
/**
* @brief UART0发送字符串
* @note 使用阻塞方式
* @param str 待发送字符串指针
* @return 字符串长度
*/
int UART0_sendStr(const char* str);
/**
* @brief UART0 printf
* @param fmt 格式控制字符串与参数列表
* @return 字符串长度(vsprintf返回值)
*/
int UART0_printf(char* fmt, ...);
#endif /* #ifndef __USER_UART_H__ */
UART.c文件:
#include "UART.h"
/**
* @brief UART0发送字符串
* @note 使用阻塞方式
* @param str 待发送字符串指针
* @return 字符串长度
*/
int UART0_sendStr(const char* str) {
int cnt = 0;
while (*str) {
DL_UART_transmitDataBlocking(UART_0_INST, (uint8_t)*str);
str++;
cnt++;
}
return cnt;
}
/**
* @brief UART0 printf
* @param fmt 格式控制字符串与参数列表
* @return 字符串长度(vsprintf返回值)
*/
int UART0_printf(char* fmt, ...) {
static char buf[UART_TX_BUF_SIZE];
int len;
va_list args;
va_start(args, fmt);
len = vsprintf(buf, fmt, args);
va_end(args);
UART0_sendStr(buf);
return len;
}
vsprintf函数的功能是读取格式控制字符串以及参数列表,将字符串格式化后存入指定数组中。因此需要预先定义一个数组作为缓冲区存放格式化字符串,这个缓冲区需要大于程序中可能发送的最长字符串长度。
UserTask.c文件:
#include "UserTask.h"
int n = 0;
void UserTask_init(void) {
}
void UserTask_loop(void) {
UART0_printf("Hello World! %d\n", n);
n++;
Tick_delay(1000);
}
void UserTask_tick(void) {
}
上面是一个简单的测试程序,循环发送Hello World! n。使用USB转串口模块连接电脑与单片机,打开串口助手接收单片机UART数据,效果如下。

UART DMA方式发送
上述UART阻塞方式发送的程序逻辑简单,但UART的波特率对于CPU来说非常缓慢,发送字符串时CPU将被长时间阻塞,这很浪费CPU资源。为了避免数据发送过程占用CPU资源,可以使用DMA方式进行发送。在CPU配置好UART和DMA模块后,UART模块可以自动触发DMA请求实现数据读取,并且在发送完成后产生中断通知CPU。

DMA方式需要配置的内容更多一些,除了UART的基本配置,以及使能DMA发送完成中断外,还需要进行DMA相关配置,主要为:
- 将对应DMA通道命名为DMA_UART0Tx以便区分;
- 使用UART Tx中断作为DMA触发源;
- 寻址方式设为块地址到固定地址,“块地址”即为待发送数据块地址,“固定地址”即为UART发送寄存器地址;
- 源和目的数据长度均为字节,源地址自动递增;
- 单次传输模式,每次触发传输1次数据,这里不必配置传输大小(数量),传输数量将在发送程序内计算和配置。
对于UART DMA方式发送,首先等待保证上次发送已完成,然后配置DMA的源与目的地址、传输长度(即字符串长度),并使能DMA通道。DMA方式发送同样可以编写自定义的printf函数,与前面阻塞方式的printf类似,将其改为DMA方式发送即可,但需要等待上次发送完成,才能修改缓冲区数组。
UART.h文件:
#ifndef __USER_UART_H__
#define __USER_UART_H__
#include <stdint.h>
#include <stdarg.h>
#include <stdio.h>
#include "ti_msp_dl_config.h"
#define UART_TX_BUF_SIZE 256 // UART发送缓冲区长度
extern volatile uint8_t UART0TxDMADone;
void UART_init(void);
/**
* @brief UART0发送字符串
* @note 使用阻塞方式
* @param str 待发送字符串指针
* @return 字符串长度
*/
int UART0_sendStr(const char* str);
/**
* @brief UART0 printf
* @param fmt 格式控制字符串与参数列表
* @return 字符串长度(vsprintf返回值)
*/
int UART0_printf(char* fmt, ...);
/**
* @brief UART0使用DMA方式发送字符串
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param str 待发送字符串指针
* @param len 字符串长度
*/
void UART0_sendStrDMA(const char* str, uint16_t len);
/**
* @brief UART0 printf (使用DMA方式)
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param fmt 格式控制字符串与参数列表
*/
void UART0_printfDMA(char* fmt, ...);
void UART0_DMADoneTxCallback(void);
#endif /* #ifndef __USER_UART_H__ */
UART.c文件:
#include "UART.h"
volatile uint8_t UART0TxDMADone = 1; // UART0发送DMA完成标志
void UART_init(void) {
NVIC_ClearPendingIRQ(UART_0_INST_INT_IRQN);
NVIC_EnableIRQ(UART_0_INST_INT_IRQN);
}
/**
* @brief UART0发送字符串
* @note 使用阻塞方式
* @param str 待发送字符串指针
* @return 字符串长度
*/
int UART0_sendStr(const char* str) {
int cnt = 0;
while (*str) {
DL_UART_transmitDataBlocking(UART_0_INST, (uint8_t)*str);
str++;
cnt++;
}
return cnt;
}
/**
* @brief UART0 printf
* @param fmt 格式控制字符串与参数列表
* @return 字符串长度(vsprintf返回值)
*/
int UART0_printf(char* fmt, ...) {
static char buf[UART_TX_BUF_SIZE];
int len;
va_list args;
va_start(args, fmt);
len = vsprintf(buf, fmt, args);
va_end(args);
UART0_sendStr(buf);
return len;
}
/**
* @brief UART0使用DMA方式发送字符串
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param str 待发送字符串指针
* @param len 字符串长度
*/
void UART0_sendStrDMA(const char* str, uint16_t len) {
while (!UART0TxDMADone);
UART0TxDMADone = 0;
DL_DMA_setSrcAddr(DMA, DMA_UART0Tx_CHAN_ID, (uint32_t)str);
DL_DMA_setDestAddr(DMA, DMA_UART0Tx_CHAN_ID, (uint32_t)(&UART_0_INST->TXDATA));
DL_DMA_setTransferSize(DMA, DMA_UART0Tx_CHAN_ID, len);
DL_DMA_enableChannel(DMA, DMA_UART0Tx_CHAN_ID);
}
/**
* @brief UART0 printf (使用DMA方式)
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param fmt 格式控制字符串与参数列表
*/
void UART0_printfDMA(char* fmt, ...) {
static char buf[UART_TX_BUF_SIZE];
uint16_t len;
va_list args;
while (!UART0TxDMADone);
va_start(args, fmt);
len = (uint16_t)vsprintf(buf, fmt, args);
va_end(args);
UART0_sendStrDMA(buf, len);
}
// UART0 DMA Tx完成中断回调
void UART0_DMADoneTxCallback(void) {
UART0TxDMADone = 1;
}
当DMA完成传输后,将通过中断告知CPU,因此我们还需要编写对应的中断服务函数。
Interrupts.h文件:
#ifndef __INTERRUPTS_H__
#define __INTERRUPTS_H__
#include "ti_msp_dl_config.h"
#include "Tick.h"
#include "UART.h"
void SysTick_Handler(void);
void UART0_IRQHandler(void);
#endif /* #ifndef __INTERRUPTS_H__ */
Interrupts.c文件:
#include "Interrupts.h"
// SysTick中断服务函数(1ms)
void SysTick_Handler(void) {
Tick_SysTickCallback();
}
// UART0中断服务函数
void UART0_IRQHandler(void) {
switch (DL_UART_getPendingInterrupt(UART_0_INST)) {
case DL_UART_IIDX_DMA_DONE_TX:
UART0_DMADoneTxCallback();
break;
default:
break;
}
}
接下来编写一个简单的测试程序,依次使用阻塞和DMA方式进行串口发送,并用GPIO电平指示两者运行时长进行对比。
UserTask.c文件:
#include "UserTask.h"
int n = 0;
void UserTask_init(void) {
UART_init();
}
void UserTask_loop(void) {
DL_GPIO_setPins(GPIO_TEST_PORT, GPIO_TEST_TEST_PIN);
UART0_printf("Hello World! %d\n", n);
DL_GPIO_clearPins(GPIO_TEST_PORT, GPIO_TEST_TEST_PIN);
Tick_delay(1);
DL_GPIO_setPins(GPIO_TEST_PORT, GPIO_TEST_TEST_PIN);
UART0_printfDMA("Hello World! %d\n", n);
DL_GPIO_clearPins(GPIO_TEST_PORT, GPIO_TEST_TEST_PIN);
n++;
Tick_delay(1000);
}
void UserTask_tick(void) {
}
烧录运行上述程序,效果如下。

可见两种方式均可正常发送串口数据,用示波器查看测试引脚波形,可以对比两种方式的发送用时。


显然,DMA方式由于不需要等待数据发送完成,因此速度远快于阻塞方式发送。对于"Hello World! 0\n"这个字符串来说,阻塞方式发送需要1.418ms,而DMA方式发送仅需要36μs。
UART中断方式接收
M0单片机使用中断方式进行串口数据接收的配置较为简单,按正常配置UART功能,并启用接收中断即可。

在程序方面,中断方式接收逻辑更复杂一些。发生中断时,接收到的数据仅是1个字节,而不是完整的字符串,因此为了收到完整的字符串,需要将多次中断收到的数据依次存入接收缓冲数组内,从而拼接为完整的字符串。另外为了便于判断完整字符串的结束,可以预先定义一个结束符,例如换行符'\n',当接收到结束符时,表示字符串已接收完整,可以进行后续处理。
UART.h文件:
#ifndef __USER_UART_H__
#define __USER_UART_H__
#include <stdint.h>
#include <stdarg.h>
#include <stdio.h>
#include "ti_msp_dl_config.h"
#define UART_TX_BUF_SIZE 256 // UART发送缓冲区长度
#define UART_RX_BUF_SIZE 256 // UART接收缓冲区长度
#define UART_RX_TERMINATOR '\n' // UART接收结束符
extern volatile uint8_t UART0TxDMADone;
extern volatile uint8_t UART0RxDone;
extern volatile uint8_t UART0RxBuf[UART_RX_BUF_SIZE];
extern volatile uint16_t UART0RxPos;
extern volatile uint16_t UART0RxLen;
extern volatile uint8_t UART0RxOvf;
void UART_init(void);
/**
* @brief UART0发送字符串
* @note 使用阻塞方式
* @param str 待发送字符串指针
* @return 字符串长度
*/
int UART0_sendStr(const char* str);
/**
* @brief UART0 printf
* @param fmt 格式控制字符串与参数列表
* @return 字符串长度(vsprintf返回值)
*/
int UART0_printf(char* fmt, ...);
/**
* @brief UART0使用DMA方式发送字符串
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param str 待发送字符串指针
* @param len 字符串长度
*/
void UART0_sendStrDMA(const char* str, uint16_t len);
/**
* @brief UART0 printf (使用DMA方式)
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param fmt 格式控制字符串与参数列表
*/
void UART0_printfDMA(char* fmt, ...);
/**
* @brief UART0开始接收数据
* @note 先处理完上次接收数据, 再调用该函数继续接收
*/
void UART0_startReceive(void);
void UART0_DMADoneTxCallback(void);
void UART0_RxCallback(void);
#endif /* #ifndef __USER_UART_H__ */
UART.c文件:
#include "UART.h"
volatile uint8_t UART0TxDMADone = 1; // UART0发送DMA完成标志
volatile uint8_t UART0RxDone = 0; // UART0接收完成标志
volatile uint8_t UART0RxBuf[UART_RX_BUF_SIZE] = {0}; // UART0接收缓冲区
volatile uint16_t UART0RxPos = 0; // UART0接收位置
volatile uint16_t UART0RxLen = 0; // UART0接收长度(不包含结束符)
volatile uint8_t UART0RxOvf = 0; // UART0接收溢出数据
void UART_init(void) {
NVIC_ClearPendingIRQ(UART_0_INST_INT_IRQN);
NVIC_EnableIRQ(UART_0_INST_INT_IRQN);
}
/**
* @brief UART0发送字符串
* @note 使用阻塞方式
* @param str 待发送字符串指针
* @return 字符串长度
*/
int UART0_sendStr(const char* str) {
int cnt = 0;
while (*str) {
DL_UART_transmitDataBlocking(UART_0_INST, (uint8_t)*str);
str++;
cnt++;
}
return cnt;
}
/**
* @brief UART0 printf
* @param fmt 格式控制字符串与参数列表
* @return 字符串长度(vsprintf返回值)
*/
int UART0_printf(char* fmt, ...) {
static char buf[UART_TX_BUF_SIZE];
int len;
va_list args;
va_start(args, fmt);
len = vsprintf(buf, fmt, args);
va_end(args);
UART0_sendStr(buf);
return len;
}
/**
* @brief UART0使用DMA方式发送字符串
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param str 待发送字符串指针
* @param len 字符串长度
*/
void UART0_sendStrDMA(const char* str, uint16_t len) {
while (!UART0TxDMADone);
UART0TxDMADone = 0;
DL_DMA_setSrcAddr(DMA, DMA_UART0Tx_CHAN_ID, (uint32_t)str);
DL_DMA_setDestAddr(DMA, DMA_UART0Tx_CHAN_ID, (uint32_t)(&UART_0_INST->TXDATA));
DL_DMA_setTransferSize(DMA, DMA_UART0Tx_CHAN_ID, len);
DL_DMA_enableChannel(DMA, DMA_UART0Tx_CHAN_ID);
}
/**
* @brief UART0 printf (使用DMA方式)
* @note 调用该函数时, 若上次UART DMA已传送完成, 则占用时间最短
* @param fmt 格式控制字符串与参数列表
*/
void UART0_printfDMA(char* fmt, ...) {
static char buf[UART_TX_BUF_SIZE];
uint16_t len;
va_list args;
while (!UART0TxDMADone);
va_start(args, fmt);
len = (uint16_t)vsprintf(buf, fmt, args);
va_end(args);
UART0_sendStrDMA(buf, len);
}
/**
* @brief UART0开始接收数据
* @note 先处理完上次接收数据, 再调用该函数继续接收
*/
void UART0_startReceive(void) {
UART0RxPos = 0;
UART0RxDone = 0;
}
// UART0 DMA Tx完成中断回调
void UART0_DMADoneTxCallback(void) {
UART0TxDMADone = 1;
}
// UART0 Rx中断回调
void UART0_RxCallback(void) {
if (!UART0RxDone) { // 上次数据处理完成后, 继续接收
// 接收当前字节
UART0RxBuf[UART0RxPos] = DL_UART_receiveData(UART_0_INST);
// 判断结束符
if (UART0RxBuf[UART0RxPos] == UART_RX_TERMINATOR) {
UART0RxBuf[UART0RxPos] = '\0';
UART0RxLen = UART0RxPos;
UART0RxDone = 1;
}
UART0RxPos++;
}
else { // 未及时处理数据放入溢出区
UART0RxOvf = DL_UART_receiveData(UART_0_INST);
}
}
在中断文件中,也需要添加串口接收中断的服务函数。
Interrupts.c文件:
#include "Interrupts.h"
// SysTick中断服务函数(1ms)
void SysTick_Handler(void) {
Tick_SysTickCallback();
}
// UART0中断服务函数
void UART0_IRQHandler(void) {
switch (DL_UART_getPendingInterrupt(UART_0_INST)) {
case DL_UART_IIDX_DMA_DONE_TX:
UART0_DMADoneTxCallback();
break;
case DL_UART_IIDX_RX:
UART0_RxCallback();
break;
default:
break;
}
}
编写测试程序,收到完整字符串后,用DMA发送方式返回字符串的长度和内容。
UserTask.c文件:
#include "UserTask.h"
void UserTask_init(void) {
UART_init();
}
void UserTask_loop(void) {
if (UART0RxDone) {
UART0_printfDMA("Received %d bytes: %s\n", UART0RxLen, UART0RxBuf);
UART0_startReceive();
}
}
void UserTask_tick(void) {
}
烧录运行上述程序,结果如下,注意串口调试软件中,设置发送追加结束符为'\n'。

可见单片机能够利用串口成功接收字符串。
结语
这篇文章展示了MSPM0单片机的UART常用配置与字符串收发程序,并使用自定义printf函数增强了串口发送的易用性。在我之前的实践中,串口通信的一大优势,是能通过连接蓝牙模块很容易地改为无线数据收发,这在智能小车调参等场景中非常实用。
更多推荐



所有评论(0)