前言

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

图1 UART连接图

图1 UART连接图

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

图2 UART数据帧格式

图2 UART数据帧格式

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

图3 M0单片机UART模块框图

图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模块,配置波特率和引脚等参数即可。

图4 UART阻塞方式发送配置

图4 UART阻塞方式发送配置

图5 UART阻塞方式发送引脚配置

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

图6 UART阻塞方式发送效果

图6 UART阻塞方式发送效果

UART DMA方式发送

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

图7 UART DMA方式发送配置

图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 <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) {
    
}

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

图8 UART DMA方式发送效果

图8 UART DMA方式发送效果

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

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

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

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

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

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

UART中断方式接收

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

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

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

  在程序方面,中断方式接收逻辑更复杂一些。发生中断时,接收到的数据仅是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'

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

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

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

结语

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

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐