首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

串口通信(2)

上一篇文章中,我们介绍了STM32的USART的基本结构, 实现了通过查询和等待的方式一个字节一个字节的发送数据,并且通过接收中断一个字节一个字节的接收数据。 显然,这种工作方式是很低效的。 现在的MCU一般都会提供直接内存访问(DMA)机制, 用于提高大量数据交换时的系统效率。

本文中,我们将介绍如何通过DMA进行串口收发。 我们在上文例程的基础上进行修改, 仍然通过USART1与上位机进行通信。

1. 初步实现DMA模式的串口

直接内存访问(DMA)一文中,我们提到进行DMA操作有三个要素: DMA请求信号与握手机制、数据源地址、数据目标地址。

在DMA模式下串口在接收到数据后或者发送数据前,需要由USART1向DMA控制器提出一个DMA请求,即发送一个请求信号。 在没有更高优先级请求的情况下,DMA控制器将向USART1发送一个应答信号,USART1接收到应答信号释放请求信号, 然后DMA控制器释放应答信号。这个过程称为握手机制,它是由MCU内部的硬件实现的,在软件操作过程中我们只需要关注请求的优先级。

完成一次握手机制后,就建立了一个DMA通道,USART1才可以直接向我们指定的内存写入接收的数据, 或者从指定内存获取将要发送的数据。所以在接收数据时,数据源地址就是USART1的数据寄存器DR,目标地址由我们指定。 在发送数据时,数据源地址则是我们指定的内存中的某个空间,而目标地址则是USART1的数据寄存器DR。

在STM32中有DMA1和DMA2两个控制器,它们分别有8个Stream可以同时提供16种DMA请求。每个Stream又有8个通道,同一时间只有一个有效。 根据参考手册中Table 43的描述(如上图所示), 我们可以查到USART1的发送和接收请求分别被映射到DMA2的Stream7和Stream5的通道4上了。

下面我们增加了一个函数对发送的DMA进行了初始化,配置DMA2控制器的Stream7选择通道4。数据传送方向位从内存到外设, 由于发送数据时只需要写寄存器DR就可以了,所以设定外设的地址不增长,而内存地址向上增长。 而内存地址(即源地址)只有在需要发送数据时才能够确认,所以对于这部分的配置我们将放置到DMA发送函数中。

        void usart1_init_txdma(void) {
            RCC->AHB1ENR.bits.dma2 = 1;
            // 发送DMA
            DMA_ResetStream(DMA2_Stream7);
            DMA2_Stream7->CR.bits.CHSEL = 4;                // 通道选择
            DMA2_Stream7->CR.bits.DIR = DMA_DIR_M2P;        // 传输方向
            DMA2_Stream7->CR.bits.CIRC = 0;                 // 关闭循环模式
            DMA2_Stream7->CR.bits.PL = DMA_Priority_Low;    // 低优先级
            DMA2_Stream7->CR.bits.PINC = 0;                 // 外设地址不增长
            DMA2_Stream7->CR.bits.PSIZE = DMA_PSIZE_8Bits;  // 外设数据宽度
            DMA2_Stream7->CR.bits.MINC = 1;                 // 内存增长
            DMA2_Stream7->CR.bits.MSIZE = DMA_PSIZE_8Bits;  // 内存数据宽度
            DMA2_Stream7->CR.bits.MBURST = DMA_Burst_0;     // Single Transfer
            DMA2_Stream7->CR.bits.PBURST = DMA_Burst_0;     // Single Transfer
            DMA2_Stream7->FCR.bits.DMDIS = 0;               // 保持Direct Mode
            DMA2_Stream7->FCR.bits.FTH = DMA_FIFO_4;
            DMA2_Stream7->PAR = (uint32)(&(USART1->DR));    // 指定外设地址(目标地址)
            DMA2_Stream7->CR.bits.TCIE = 1;                 // 开启传送结束中断
        }

类似的,我们还定义了函数usart1_init_rxdma()对接收DMA进行了初始化操作。

        void usart1_init_rxdma(void) {
            RCC->AHB1ENR.bits.dma2 = 1;
            // 接收DMA
            DMA_ResetStream(DMA2_Stream5);
            DMA2_Stream5->CR.bits.CHSEL = 4;                // 通道选择
            DMA2_Stream5->CR.bits.DIR = DMA_DIR_P2M;        // 传输方向
            DMA2_Stream5->CR.bits.CIRC = 0;                 // 关闭循环模式
            DMA2_Stream5->CR.bits.PL = DMA_Priority_Low;    // 低优先级
            DMA2_Stream5->CR.bits.PINC = 0;                 // 外设地址不增长
            DMA2_Stream5->CR.bits.PSIZE = DMA_PSIZE_8Bits;  // 外设数据宽度
            DMA2_Stream5->CR.bits.MINC = 1;                 // 内存增长
            DMA2_Stream5->CR.bits.MSIZE = DMA_PSIZE_8Bits;  // 内存数据宽度
            DMA2_Stream5->CR.bits.MBURST = DMA_Burst_0;     // Single Transfer
            DMA2_Stream5->CR.bits.PBURST = DMA_Burst_0;     // Single Transfer
            DMA2_Stream5->FCR.bits.DMDIS = 0;               // 保持Direct Mode
            DMA2_Stream5->FCR.bits.FTH = DMA_FIFO_4;
            DMA2_Stream5->PAR = (uint32)(&(USART1->DR));    // 指定外设地址(源地址)
            DMA2_Stream5->CR.bits.TCIE = 1;                 // 开启传送结束中断
        }

在完成初始化工作之后,我们通过函数usart1_send_bytes_dma()触发进行一次DMA形式的发送,如下代码所示。 这个函数有两个参数buf和len,分别描述了将要发送的数据缓存地址和数据长度。在函数中我们先关闭DMA2的Stream7, 在修改内存地址(也就是发送数据时的源地址),以及DMA通信的数据长度之后,重新打开。并通过串口的控制器开启发送数据的DMA模式。 类似的可以通过usart1_receive_bytes_dma()开启接收的DMA模式。

    void usart1_send_bytes_dma(uint8 *buf, int len) {
        DMA2_Stream7->CR.bits.EN = 0;
        DMA2_Stream7->M0AR = (uint32)buf;
        DMA2_Stream7->NDTR.all = len;
        DMA2_Stream7->CR.bits.EN = 1;
        
        USART1->CR3.bits.DMAT = 1;
    }
    void usart1_receive_bytes_dma(uint8 *buf, int len) {
        DMA2_Stream5->CR.bits.EN = 0;
        DMA2_Stream5->M0AR = (uint32)buf;
        DMA2_Stream5->NDTR.all = len;
        DMA2_Stream5->CR.bits.EN = 1;
            
        USART1->CR3.bits.DMAR = 1;
    }

2. 串口DMA发送的并发问题

虽然采用DMA的方式进行数据搬运可以节省宝贵的计算资源,提高系统效率。但会产生所谓的并发问题, 如果处理不合适将导致系统产生异常的行为,有时异常是随机出现的,很难定位。

所谓的并发问题是指DMA控制器与CPU对于内存的竞争。当一次DMA工作尚未结束时,CPU又发起了一次DMA操作就会导致这种并发问题。 比如下面的代码,我们连续两次通过函数usart1_send_bytes_dma()发送数据,中间没有任何延迟。 此时系统几乎不会如我们想象的那样发送两个字符串出去,而错误的形式又是各种各样的。

        usart1_send_bytes_dma("laiyadouniwan\n", 14);
        usart1_send_bytes_dma("laiyadouniwan\n", 14);
这是因为在第一次触发dma发送之后,数据需要经过一个较长的时间才能够发送完毕,这个时间足够CPU做很多工作。 所以在第二次调用usart1_send_bytes_dma()时,第一次的发送操作还没有结束,就会导致错误。而错误的形式是不可确定的, 因为DMA与CPU可以看作是并行工作的两个设备,第二次调用时DMA的工作进行到哪里CPU是不清楚的, 完全是由于整个系统的各个方面因素决定的。我们可以在两次调用之间增加一个长时间的延迟来解决这个问题, 但这样一来就违背了我们想提高系统效率的初衷。

我们可以通过锁的方式予以解决,只要保证一次DMA发送是一个原子的过程就可以了。 我们需要一个变量或者一把锁来标记当前是否有数据正在发送。在开始一个新的发送之前,先检查一下这个变量。 若没有,我们就可以告知DMA控制器发送数据的地址和长度了。但是我们需要在这之前先标记这个变量,也就是上锁, 以防止其他人或者操作打断整个发送过程。在DMA发送结束之后,还需要将重置变量,打开锁,这样才可以进行下一次发送。

添加一个变量很简单,我们对上述的发送函数做出如下的修改。我们定义了一个_usart1_is_sending的变量, 赋予初值为0标志着一开始没有任何数据需要发送。在usart1_send_bytes_dma()函数中,我们先通过一个while循环来查询这个变量, 只有在该变量为0时才可以继续。在接下来就立即将变量置1了,标志着开始发送数据了。再之后的内容就和原来一样。

        uint8 _usart1_is_sending = 0;
        void usart1_send_bytes_dma(uint8 *buf, int len) {
            while (_usart1_is_sending);
            _usart1_is_sending = 1;
            
            DMA2_Stream7->CR.bits.EN = 0;
            DMA2_Stream7->M0AR = (uint32)buf;
            DMA2_Stream7->NDTR.all = len;
            DMA2_Stream7->CR.bits.EN = 1;
            
            USART1->CR3.bits.DMAT = 1;
        }

这就会产生两个问题。首先用一个while循环来查询锁变量是一个很低效的过程,因为它将一直占用CPU资源。 如果我们有操作系统, 那么在查询到锁没有被释放的情况下切换到其它进程将节省很多计算资源。这里暂时不理会这个问题。 其次,我们怎么知道一次发送什么时候结束,我们又该如何释放锁呢?

解决这个问题,我们就需要用到中断的方式了。 在STM32中,为每一个DMA stream都提供了5个中断事件:

再回过头来看初始化时的函数usart1_init_txdma()的第18行,已经开启了传送结束的中断。 我们可以在它们的传送结束中断服务函数中释放掉锁变量。其中断服务函数如下所示,确定中断源是DMA2的Stream7发送结束信号后, 我们先通过写DMA2的HIFCR寄存器清除中断,否则将一直触发中断。然后关闭串口的DMA发送模式,最后释放锁变量_usart1_is_sending。
        void DMA2_Stream7_IRQHandler(void) {
            if (1 == DMA2->HISR.bits.TCIF7) {
                DMA2->HIFCR.bits.TCIF7 = 1;
                USART1->CR3.bits.DMAT = 0;
                _usart1_is_sending = 0;
            }
        }
这样我们再连续两次的通过函数usart1_send_bytes_dma()发送数据就不会有什么问题了。 再配合上操作系统就可以显著的提高系统的效率。

3. 串口的DMA接收

在本文中第一部分提到的DMA接收函数usart1_receive_bytes_dma(),也存在着并发问题。对于这个问题, 我们同样可以通过添加一个变量和中断服务函数来解决。

    uint8 _usart1_is_receiving = 0;
    void usart1_receive_bytes_dma(uint8 *buf, int len) {
        while (_usart1_is_receiving);
        _usart1_is_receiving = 1;
        
        DMA2_Stream5->CR.bits.EN = 0;
        DMA2_Stream5->M0AR = (uint32)buf;
        DMA2_Stream5->NDTR.all = len;
        DMA2_Stream5->CR.bits.EN = 1;
        
        USART1->CR3.bits.DMAR = 1;
    }
    void DMA2_Stream5_IRQHandler(void) {
        if (1 == DMA2->HISR.bits.TCIF5) {
            DMA2->HIFCR.bits.TCIF5 = 1;
            USART1->CR3.bits.DMAR = 0;
            _usart1_is_receiving = 0;
        }
    }

除此之外还有一个严重的问题,对于一个MCU而言,它并不能够控制自己在什么时候接收什么样的数据, 整个过程完全是由通信协议和外部事件决定的。它只能被动的接收,选择处理还是不处理这些数据而已。 关于接收我们将采用多个缓存的形式解决。未完待续。。。。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1