跳转至

V1和V2版本串口对比

V1版本串口工作模式

原文参见:

RTT串口V1版本的使用分析及问题排查指南(一):https://club.rt-thread.org/ask/article/c561c8155d747b3d.html

RTT串口V1版本的使用分析及问题排查指南(三):https://club.rt-thread.org/ask/article/0c67969a589e4ccb.html

V1串口框架目前适配了三种硬件工作模式,即串口轮询(发送/接收)、串口中断(发送/接收)、串口DMA(发送/接收)。下面分别对三种模式进行介绍。

操作模式 是否有缓存 是否阻塞 工作原理
POLL_RX 阻塞 等待while循环收到N个数据才返回,一直霸占CPU
POLL_TX 阻塞 等待while循环发送完到N个数据才返回,一直霸占CPU
INT_RX 有,环形BUFF 非阻塞 每收到1个char,触发中断将这个char拷贝到环形buff,并调用用户的rx_indicate(),read的时候从环形buff中拷贝数据。也就是**每收到1字节就触发一次中断**,浪费调度资源
INT_TX 阻塞 理论上INT模式是不阻塞,但是由于RTT的串口驱动框架**INT_TX没有缓存**,只能等while循环发送完成才能返回,这过程一直**霸占CPU**,相当于**退化为POLL_TX**了
DMA_RX 有,环形BUFF 非阻塞 有三种DMA接收中断,分别是DMA空闲中断(IDLE)、DMA半满中断(HFIT)和 DMA满中断(TCIT),中断触发将数据拷贝到环形BUFF,并调用用户rx_indicate(),read的时候从环形buff中拷贝数据
DMA_TX 有,用户BUFF 非阻塞 数据缓冲区是由用户应用层定义的,在传给串口框架的时候,是将应用层定义的数据缓冲区的指针传递过来。如果在发送过程中,该缓冲区的内容被意外修改了,那么将会导致DMA发送的数据出现错误

阻塞:调用rt_device_read/write函数不会立即返回,而是需要**read到数据**或者**write完数据**才返回。这个等待可能是**霸占CPU等待**,也可能是**睡眠线程**(比如rt_take_sem)。

串口数据读取,一般会选择串口中断接收或者是串口DMA接收(好像没有轮询死等串口数据的场景吧,这里不再讨论这个轮询读的方式),这里比较统一,无论是哪种方式,都是非阻塞的接收模式。即,我们可能会通过一个信号量或者消息队列,当接收到数据的时候,就唤醒当前线程进行数据的读取。这里我们参照串口的文档中心的例子:中断接收DMA接收,这两个demo,一个是使用信号量,一个是使用消息队列,来完成串口数据的读取的。

因此可以统一地说,串口的读数据接口,是按照非阻塞的方式进行接收的,当读取不到数据的时候,会将当前线程挂起,以免浪费CPU资源

一:轮询模式

轮询模式即调用串口 接收/发送 的 API 后将**一直占用CPU资源**直到数据收发完成才返回,使用时需要用户主动调用 接收/发送 的API接口才会执行相应的操作。

轮询接收

应用程序调用 rt_device_read() 接收数据,轮询接收模式下会调用到底层串口驱动提供的 getc 接口,每次接收一个字节,如果接收不到数据将**一直占用CPU**资源。

轮询发送

应用程序调用 rt_device_write() 发送数据,轮询发送模式下会最终会调用到底层串口驱动提供的 putc 接口,每次发送一个字节,**循环发送直到**待发送的数据发送完成。

**FinSH的输出,或者说是rt_kprintf**的输出就是采用轮询,显而易见的,轮询模式的写数据接口是**阻塞的方式**进行的。

二:中断模式

中断模式下收发数据时,将数据的收发过程放在中断中进行,不再持续占用CPU资源,应用程序只用负责将数据写入串口数据寄存器,然后线程就会让出资源,空出了程序等待串口硬件收发数据的时间。注意一点:中断接收和发送时,每次在中断中操作的还是**单个字节**。

中断接收

串口硬件接收到一个字节的数据后会触发中断并调用串口驱动框架的 rt_hw_serial_isr() 函数,此函数会将这一字节数据写入环形缓冲区,用户设置的回调函数也会被调用。应用程序读取数据实际是从环形缓冲区读取。

中断发送

串口框架负责调用底层 putc 接口向串口寄存器写入待发送的数据,然后等待此数据发送完成的信号,此时当前线程会被挂起。上一个数据发送完成后会调用串口驱动框架的 rt_hw_serial_isr() 函数,此函数会发送完成信号唤醒发送数据线程,并重置完成信号状态为未完成。线程运行后会向串口寄存器写入下一个数据,然后等待完成信号,重复上一个流程,直到缓冲区数据发送完成。

这个模式其实是**有一些问题**的,我们理想中的中断发送,其实是先打开发送的中断使能,然后在中断服务函数内把数据发送出去,等数据发送空中断后再发送下一个字节的数据,依次单字节循环发送即将数据发送完成。串口V1的中断发送是怎么实现的呢?我们来看一下代码:

rt_inline int _serial_int_tx(struct rt_serial_device *serial, const rt_uint8_t *data, int length)
{
    int size;
    size = length;
    while (length)
    {
        /*
         * to be polite with serial console add a line feed
         * to the carriage return character
         */
        if (*data == '\n' && (serial->parent.open_flag & RT_DEVICE_FLAG_STREAM))
        {
            if (serial->ops->putc(serial, '\r') == -1)
            {
                rt_completion_wait(&(tx->completion), RT_WAITING_FOREVER);
                continue;
            }
        }
        if (serial->ops->putc(serial, *(char*)data) == -1)
        {
            rt_completion_wait(&(tx->completion), RT_WAITING_FOREVER);
            continue;
        }
        data ++; length --;
    }
    return size - length;
}

比较清晰的看到,中断发送最终是调用的putc接口,而serial->ops->putc其实代表的就是stm32_putc:

static int stm32_putc(struct rt_serial_device *serial, char c)
{
    struct stm32_uart *uart;
    RT_ASSERT(serial != RT_NULL);
    uart = rt_container_of(serial, struct stm32_uart, serial);
    UART_INSTANCE_CLEAR_FUNCTION(&(uart->handle), UART_FLAG_TC);
#if defined(SOC_SERIES_STM32L4) || defined(SOC_SERIES_STM32WL) || defined(SOC_SERIES_STM32F7) || defined(SOC_SERIES_STM32F0) \
    || defined(SOC_SERIES_STM32L0) || defined(SOC_SERIES_STM32G0) || defined(SOC_SERIES_STM32H7) \
    || defined(SOC_SERIES_STM32G4) || defined(SOC_SERIES_STM32MP1) || defined(SOC_SERIES_STM32WB)
    uart->handle.Instance->TDR = c;
#else
    uart->handle.Instance->DR = c;
#endif
    while (__HAL_UART_GET_FLAG(&(uart->handle), UART_FLAG_TC) == RESET);
    return 1;
}

这个函数里面,是相当于将数据发送给了TDR数据发送寄存器,然后通过**while(1)死等的方式**,直到数据的发送完成。因此串口**中断发送模式,其实就是和串口轮询发送模式是一样的,并没有用到中断发送的功能**。

三:DMA模式

DMA模式下收发数据与中断模式很相像,可以粗略的认为,两种模式唯一不同的就是,中断模式每次收发数据为单字节,而DMA模式则是多个字节进行收发的。

DMA接收

使用 DMA 接收模式时,首先应用程序打开串口设备会指定打开标志为 RT_DEVICE_FLAG_DMA_RX,此时串口驱动框架会创建环形缓冲区并调用串口驱动层 control 接口使能 DMA 接收完成中断。

这里再啰嗦一句,在DMA中断处理流程中,有三种DMA接收中断,分别是DMA空闲中断(IDLE)、DMA半满中断(HFIT)和 DMA满中断(TCIT),这三个中断相辅相成,最后统一交由串口框架中断处理函数 rt_hw_serial_isr(RT_SERIAL_EVENT_RX_DMA_DONE)中做处理。其中DMA空闲中断是在uart_isr中触发的,另外两个是在DMA接收回调中触发的,这两个接收回调是在DMA接收使能启动的时候,由HAL库注册的,用户无需关心这两个DMA接收回调的注册逻辑,只需知道,如果用户不想使用这两个DMA接收回调的时候,只要把函数里面的执行代码注释掉即可。类似下图这样 :

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    struct stm32_uart *uart;
    RT_ASSERT(huart != NULL);
    // uart = (struct stm32_uart *)huart;
    // dma_isr(&uart->serial);
}
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
    struct stm32_uart *uart;
    RT_ASSERT(huart != NULL);
    // uart = (struct stm32_uart *)huart;
    // dma_isr(&uart->serial);
}

DMA发送

应用程序调用 rt_device_write() 发送数据,最终待发送数据的地址和大小会被放入数据队列,如果此时 DMA 空闲,就会开始发送数据。当 DMA 发送完成触发中断时,rt_hw_serial_isr() 函数会判断数据队列是否有待传输的数据并开始下次 DMA 传输,用户设置的回调函数也会被调用。

在使用DMA发送时,串口框架**并未使用环形缓冲区**,然而我们又知道,DMA发送时,肯定是需要缓冲区来存放数据的,那么DMA发送时候的数据块,存放在哪里呢?这里就需要注意了,DMA发送时候,数据缓冲区是由用户应用层定义的,在传给串口框架的时候,是将应用层定义的数据缓冲区的指针传递过来。如果在发送过程中,该缓冲区的内容被意外修改了,那么将会导致DMA发送的数据出现错误。为什么会被意外修改呢,就是因为发送接口返回了,而数据并没有发送完成。由于发送接口传递的是缓冲区指针,因此改变数据的内容时,DMA发送的地址并不会变,这样的话就相当于直接修改了DMA发送的数据内容,导致数据发送数据错误、丢包等问题。

由上面讲解我们知道,串口发送轮询模式和中断模式,其实都是阻塞发送模式,这个DMA发送模式则不是这样的。DMA发送最终调用的是stm32_dna_transmit,我们看函数代码:

static rt_size_t stm32_dma_transmit(struct rt_serial_device *serial, rt_uint8_t *buf, rt_size_t size, int direction)
{
    if (RT_SERIAL_DMA_TX == direction)
    {
        if (HAL_UART_Transmit_DMA(&uart->handle, buf, size) == HAL_OK)
        {
            return size;
        }
    }
    return 0;
}

这里是调用了HAL_UART_Transmit_DMA函数,有兴趣的可以再跟踪一下这个函数,我这里直接说结论了,这个函数是将buf数据直接传递给数据发送端,然后就直接返回了,也就是说,当函数返回的时候,其实数据并没有发送完成,这就意味着,串口DMA发送模式,其实是一个非阻塞发送模式

V2版本串口工作模式