> 技术文档 > 【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习


文章目录

  • 前言
  • UART概述
  • UART收发实现
    • UART阻塞方式发送、自定义printf函数
    • UART DMA方式发送
    • UART中断方式接收
  • 结语

前言

  串口通信是单片机与上位机之间的常用通信方式,其接线简单,适合单片机与上位机传输基本的调试和控制数据。这篇文章中,我们来研究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转串口模块,如下图所示。

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图1 UART连接图  

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

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图2 UART数据帧格式  

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

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图3 M0单片机UART模块框图  

  从官方给出的UART模块框图可以看到其更详细的结构,其中波特率生成器、收发器和收发FIFO等模块用来物理实现UART通信,而时钟控制、事件与中断控制、数据、控制和状态寄存器等模块则使得我们可以通过CPU与UART实现交互。

UART收发实现

  利用M0单片机内部的UART模块,可以实现单字节的串口数据收发,而为了发送整个字符串数据,通常有阻塞、中断和DMA共3种方式可以实现:

  1. 阻塞(轮询)方式:发送时,CPU将当前字节送入UART发送数据寄存器,阻塞等待发送完成,然后送入下一字节,直到整个字符串发送完成。接收时,CPU不断轮询等待UART接收数据寄存器非空,然后读取当前字节。阻塞方式实现逻辑简单,但会占用大量CPU资源;
  2. 中断方式:可配置发送和接收完成中断,当收到中断时,则表示当前数据已发送或已接收,CPU可以直接写入或读取,无须持续等待,这样大大减少CPU占用;
  3. DMA方式:可配置DMA发送和接收,由UART中断直接触发DMA进行数据搬运,无须CPU介入,在完成后通过DMA完成中断通知CPU进行处理,占用CPU资源最少。但由于一般串口接收数据不定长,采用DMA接收需要使用空闲中断等更复杂的配置来实现。

  通常来说,串口发送数据的时刻和长度可确定,可以使用简单的阻塞方式进行发送;若需要发送大量数据,则可以改为DMA方式发送,来减少CPU占用。串口接收数据的时刻和长度则一般都不确定,因此可使用中断方式接收,来实现CPU占用和配置复杂度的平衡取舍。

UART阻塞方式发送、自定义printf函数

  M0单片机UART阻塞方式发送的配置十分简单,在Syscfg中启用UART模块,配置波特率和引脚等参数即可。

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图4 UART阻塞方式发送配置  

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图5 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 #include #include #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数据,效果如下。

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图6 UART阻塞方式发送效果

UART DMA方式发送

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

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图7 UART DMA方式发送配置  

  DMA方式需要配置的内容更多一些,除了UART的基本配置,以及使能DMA发送完成中断外,还需要进行DMA相关配置,主要为:

  1. 将对应DMA通道命名为DMA_UART0Tx以便区分;
  2. 使用UART Tx中断作为DMA触发源;
  3. 寻址方式设为块地址到固定地址,“块地址”即为待发送数据块地址,“固定地址”即为UART发送寄存器地址;
  4. 源和目的数据长度均为字节,源地址自动递增;
  5. 单次传输模式,每次触发传输1次数据,这里不必配置传输大小(数量),传输数量将在发送程序内计算和配置。

  对于UART DMA方式发送,首先等待保证上次发送已完成,然后配置DMA的源与目的地址、传输长度(即字符串长度),并使能DMA通道。DMA方式发送同样可以编写自定义的printf函数,与前面阻塞方式的printf类似,将其改为DMA方式发送即可,但需要等待上次发送完成,才能修改缓冲区数组。

  UART.h文件:

#ifndef __USER_UART_H__#define __USER_UART_H__#include #include #include #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) { }

  烧录运行上述程序,效果如下。

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图8 UART DMA方式发送效果  

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

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图9 UART DMA方式发送时间对比  

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图10 UART DMA方式发送时间对比  

  显然,DMA方式由于不需要等待数据发送完成,因此速度远快于阻塞方式发送。对于\"Hello World! 0\\n\"这个字符串来说,阻塞方式发送需要1.418ms,而DMA方式发送仅需要36μs。

UART中断方式接收

  M0单片机使用中断方式进行串口数据接收的配置较为简单,按正常配置UART功能,并启用接收中断即可。

【MSPM0学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图11 UART DMA方式发送、中断方式接收配置  

  在程序方面,中断方式接收逻辑更复杂一些。发生中断时,接收到的数据仅是1个字节,而不是完整的字符串,因此为了收到完整的字符串,需要将多次中断收到的数据依次存入接收缓冲数组内,从而拼接为完整的字符串。另外为了便于判断完整字符串的结束,可以预先定义一个结束符,例如换行符\'\\n\',当接收到结束符时,表示字符串已接收完整,可以进行后续处理。

  UART.h文件:

#ifndef __USER_UART_H__#define __USER_UART_H__#include #include #include #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学习笔记】04-UART收发(阻塞、中断和DMA方式)与自定义printf函数_mspm0串口学习

图12 UART DMA方式发送、中断方式接收效果  

  可见单片机能够利用串口成功接收字符串。

结语

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