干货 | FreeRTOS 学习笔记——实验:串口后台打印
EEWorld电子资讯 犀利解读技术干货 每日更新现在来探讨一下如何利用 FreeRTOS 的服务来为单片机程序设计提供方便,也感受下引入 FreeRTOS 后系统处理效率的变化。我想了个简单的常用需求:串口的“后台”打印输出(单片机开发时常用于调试信息输出),来进行实验。串口(异步串行通讯口的简称, UART)相对于 CPU 是一个低速的设备,一般较高通信速率为 115200bit/s,在此速率下输出一个8-bit字符(假设用1个起始位,1个停止位,无校验)用的时间为87us. CPU将字符写入串口的发送数据寄存器需要的时间可以忽略,但是由于串口硬件FIFO很小,务必要等待发送数据寄存器允许写入时才可以进行写操作。于是,简单的程序设计中就不断查询标志位,等待寄存器可以写了就写一个字符,直到要发送的字符串写完。要照顾执行效率的时候一定不能这么做,特别是在多任务环境下,因为这种循环重复查询标志位的做法让其它任务也只能等待,没有利用CPU处理能力。于是我们需要一种“后台”输出的方法,让当前任务将要从串口输出的字符串提交到系统之后,程序往下执行,不用等待字符串从串口发送完;或者是当前任务等待字符串发送完,但是其它任务可以执行。
以下我的实验代码在 ST Nucleo-L4R5ZI 板子上运行,也很容易改动一下以在其它 STM32 上运行(不过 STM32L4R5 的SRAM大,方便挥霍)。在这块开发板上,USB串口连接到了 STM32L4R5 的 LPUART1, 我就编写一个 uart_puts(char *) 函数来从 LPUART1 输出字符串。LPUART1 的 TDR 寄存器时候用来写要发送的数据的,发送完成等状态可以从 ISR 寄存器获取。当然为了实现后台工作,中断是需要用到的了。1简单直接的办法——字符队列不能反复查询,那么就让 LPUART1 的 TDR 寄存器可以写的时候,产生一个中断来告诉 CPU,该写字符了。既然是后台,就要有一个存储空间将 uart_puts() 函数参数提供的字符串存起来(如果完全容得下,函数便可以返回了);中断服务程序需要知道这个存储的位置,以中取出字符。好象 FreeRTOS 的队列(Queue)刚好适合这项功能:uart_puts() 写字符到队列,中断 ISR 代码去读(也可以唤醒一个任务去读,但是多此一举了)队列。按照这个思路,我写出 uart_puts() 函数如下:QueueHandle_t uart_tx_queue;void uart_puts(char *str){for(;;str++){char ch=*str;if(ch==0)break;else{xQueueSend(uart_tx_queue, &ch, portMAX_DELAY);LPUART1->CR1 |= USART_CR1_TXEIE_TXFNFIE; // enable TXE IRQ}}}协同工作的 ISR 程序是:void LPUART1_IRQHandler(void){char data;BaseType_t waken=pdFALSE;if(xQueueReceiveFromISR(uart_tx_queue, &data, &waken)==pdPASS){LPUART1->TDR = data;portYIELD_FROM_ISR(waken);}else // no more data to transmit{LPUART1->CR1 &= ~USART_CR1_TXEIE_TXFNFIE; // disable IRQ}}也就是说,任务调用 uart_puts() 将字符写入队列并打开中断就不用管了,而 ISR 只管从队列取字符,若取完了就把中断屏蔽。当然事先要创建好队列,例如长度为 400 个字符的队列:uart_tx_queue = xQueueCreate(400, 1);我又另外编写了两个任务,分别调用 uart_puts() 来输出不同的文本字符串,然后用 vTaskDelay() 函数作一些延时——让一定时间内写的字符串不要超过串口吞吐率限制。此外,为了测试 CPU 执行情况,可以在上面函数中插入 GPIO 操作的代码,点亮 LED 指示灯或者给数字示波器捕捉。STM32 运行在默认时钟频率 4MHz.借助示波器观察:在 9600 Baud 下,串口吞吐能力用去了大部分。下图中黄色线是 UART TX 输出信号,青色是 uart_puts() 函数中加入的“点灯”代码产生的,围绕在 xQueueSend() 函数调用前后。
相对于串口输出的时间,在 uart_puts() 函数中停留的时间少了许多,达到了“后台”的效果。再把波形时间轴放大看看:
uart_puts() 中循向队列添加字符,大约每次用了60us时间。这个时间比 UART 输出一位需要的时间 104us 还短不少。再探测一下 ISR 的执行,也插入“点灯”代码,在 ISR 的入口和返回处(不精确,堆栈保存恢复现场的时间反映不出来)。可以看到中断频率是和串口输出字符频率一致的。
从这个波形上可以粗略得出一个判断:xQueueSend() 消耗时间比xQueueReceiveFromISR() 要长。如此实现“后台打印”的关键是把字符串装入了 FreeRTOS 提供的队列里面,延后输出。假如队列没有那么大的容量——例如下面把队列长度修改为100, 会是怎样的效果?看测试图
当队列满以后,xQueueSend() 要等待 ISR 从队列取出字符才能完成返回,于是产生了过长的等待(约1ms, 和UART输出字符的周期一致)。当然,这个时间是可以被其它任务利用的,FreeRTOS 会进行任务调度,CPU处理能力并不是浪费掉的。软件的代价分析一下实现的代价问题。上面我估算:向队列添加字符,大约每次用了60us时间——这也是两百多个机器周期了。实验一下,将串口 Baud 改成 115200,效果如何呢?我发现效果是:队列没有起什么作用,调用 uart_puts() 的时间和串口输出是同步的。奇怪了!放大了看是这样(黄线是UART TX信号, 青色线高电平代表在调用 xQueueSend()):
看起来和 9600 Baud 时完全不一样,uart_puts() 函数“开小差”了,居然耗费更多的时间,甚至拖累了串口的输出吞吐率。除了调用 xQueueSend() 它还干了什么?其实我的程序里它没干别的重活,那么队列也不会填满,为什么有一段间隔(青色线低电平)呢?调查了一下 ISR 的执行情况,我立即明白了——相比较 9600 Baud 而言,现在中断的频率提高了一个数量级,需要占用更多的 CPU 时间。当 uart_puts() 写入一个字符到队列后,随即打开了中断,于是 CPU 执行 ISR 去了……看图(这里黄色线代表ISR进出)
ISR 里面,先是从队列取了一个字符写入 TDR 寄存器,然后如果需要调度就做任务调度,再返回。注意此时队列已经空了,因为 uart_puts() 刚只写了一个字符。不过还没等到 uart_puts() 继续添字符到队列,又一次中断发生了(虽然刚写的字符发送未完成,但 TDR 已经允许写了),ISR 发现队列为空,就屏蔽掉中断然后返回。uart_puts() 得以继续执行,往队列里写一个字符,如此往复……结果就是队列里面最多也就有一个字符。原因是什么?队列操作(Send, 接着两次Receive)耗费的时间比 UART 发送一个字符的时间还要多,于是队列起不到缓冲作用,自身操作的代价浪费了 CPU 资源。2 环形缓冲区作为 FreeRTOS 提供的队列的替代品,我分配了一块内存作为缓冲区,用两个整型变量 tail, head (分别是读和写的位置)来记录缓冲区的占用情况。当 head 和 tail 相等时,代表缓冲区没有数据。往缓冲区写入一个字节,则将 tail 加1;相反的,取出一个字节后,则将 head 加1. “环行缓冲”的意义是当 tail 或 head 超过缓冲区大小时就重置为0, 整个缓冲区可容纳的最大字节数是固定的。
先考虑 ISR 的实现,比如这样子:void LPUART1_IRQHandler(void){if(uartbuf.tail!=uartbuf.head) // not empty{LPUART1->TDR = uartbuf.buf[uartbuf.head];uartbuf.head = (uartbuf.head+1)%BUFSIZE;if(uartbuf.tail!=uartbuf.head)return;}// buffer emptyLPUART1->CR1 &= ~USART_CR1_TXEIE_TXFNFIE; // disable IRQ}uart_puts() 的实现就要多考虑一下了,因为现在没有 FreeRTOS 队列的阻塞功能可用,需要自己来实现阻塞——在缓冲区不够存放输出字符串时需要将任务阻塞。于是我使用了一个信号量 uart_buf_av, 如下代码中:void uart_puts(char *str){for(;;str++){char ch=*str;if(ch==0)break;else{if((uartbuf.tail+1)%BUFSIZE == uartbuf.head) // full{LPUART1->CR1 |= USART_CR1_TXEIE_TXFNFIE; // ensure IRQ enabledxSemaphoreTake(uart_buf_av, portMAX_DELAY); // until empty}uartbuf.buf[uartbuf.tail]=ch;uartbuf.tail = (uartbuf.tail+1)%BUFSIZE;}}LPUART1->CR1 |= USART_CR1_TXEIE_TXFNFIE; // enable TXE IRQ}但是前面写的 ISR 中还没有加入信号量的处理,于是在 ISR 末尾加上两条语句xSemaphoreGiveFromISR(uart_buf_av, &waken);portYIELD_FROM_ISR(waken);不过这样写还有一点问题,每当 ISR 中发现缓冲区已空时就会 Give 这个信号量,使之成为有效状态,可是未必先前已经有任务在等待这个信号。任务调用 uart_puts() 时发现缓冲区已满,但是这个信号量是有效的,就没有了阻塞。所以还得防止这种情况出现,于是我增加了一个标志变量。ISR 以及 uart_puts() 函数最后成这样:#define BUFSIZE 512struct{char buf[BUFSIZE];uint16_t tail;uint16_t head;char wait;}uartbuf;SemaphoreHandle_t uart_buf_av;SemaphoreHandle_t uart_mutex;void LPUART1_IRQHandler(void){if(uartbuf.tail!=uartbuf.head) // not empty{LPUART1->TDR = uartbuf.buf[uartbuf.head];uartbuf.head = (uartbuf.head+1)%BUFSIZE;if(uartbuf.tail!=uartbuf.head)return;}// buffer emptyLPUART1->CR1 &= ~USART_CR1_TXEIE_TXFNFIE; // disable IRQif(uartbuf.wait){BaseType_t waken=pdFALSE;xSemaphoreGiveFromISR(uart_buf_av, &waken);portYIELD_FROM_ISR(waken);}}void uart_puts(char *str){xSemaphoreTake(uart_mutex, portMAX_DELAY);for(;;str++){char ch=*str;if(ch==0)break;else{if((uartbuf.tail+1)%BUFSIZE == uartbuf.head) // full{LPUART1->CR1 |= USART_CR1_TXEIE_TXFNFIE; // ensure IRQ enableduartbuf.wait = 1;xSemaphoreTake(uart_buf_av, portMAX_DELAY); // until emptyuartbuf.wait = 0;}uartbuf.buf[uartbuf.tail]=ch;uartbuf.tail = (uartbuf.tail+1)%BUFSIZE;}}LPUART1->CR1 |= USART_CR1_TXEIE_TXFNFIE; // enable TXE IRQxSemaphoreGive(uart_mutex);}在 uart_puts() 中我还增加了一个 mutex, 是针对多任务环境调用的,不允许两个任务同时操作环形缓冲区。检查工作效果:在 115200 Baud 下,ISR 执行一次的时间(通过插入GPIO操作代码来探测)改善到 10us 以内了,这是没有使用 FreeRTOS API 时候的。
再看一下 uart_puts() 调用的时间开销:黄色线是 UART 输出,青色线高电平是表示在 uart_puts() 函数执行当中。
这里观察到一次40多毫秒的执行时间,是因为发生了缓冲区满带来的阻塞。不过 CPU 时间并没有浪费:阻塞时可以调度其它就绪任务执行。对比一下 uart_puts() 写16个字节字符串和 ISR 的执行时间:下图黄色线高电平代表 ISR 执行,青色线高电平是表示在 uart_puts() 函数执行当中。
此处没有阻塞发生,写字符串调用消耗了 233us, 包括其间 ISR 占去的一点时间。相对与8次ISR的时间累计起来也还是蛮长的了。我调查后发现,大部分时间是被 xSempaphoreTake() 和 xSemaphoreGive() 占去了,为了操作 mutex. 但除非能保证不会有多个任务同时调用 uart_puts() 才可以不用 mutex.从这个实验代码的运行效果上看,的确,用自己管理的环型缓冲区替代了 FreeRTOS 的队列作为串口的缓冲,大大提高了程序的效率。主要是因为免去了频繁调用 API 的开销。3用 DMA 解放 CPU在以上的程序中,串口硬件设备每当输出数据寄存器允许写入时(通常意味着前面的数据已经开始传送了),就向 CPU 发出一个中断请求。CPU如果允许这个中断请求,就会暂停当前执行的程序,把关键的寄存器保存在堆栈里面,然后跳转去执行中断服务程序。在中断服务程序中,队列或者环形缓冲区的数据被“取出”一个,写入到硬件设备的数据寄存器,完成一个字节的发送工作。中断服务程序返回后,原先被暂停的程序再继续执行。如此,虽然 CPU 时间没有浪费在反复查询工作上,为了实现“在条件满足的时候写数据到设备”,毕竟付出了一次响应中断请求的代价。尽管对于串口这个低速设备来说,中断并不会很频繁,处理中断使用的时间也不一定不可接受。但是仍然存在一种更高效的机制,实现“在条件满足的时候写数据到设备”,那就是使用 DMA (Direct Memory Access, 直接内存访问)。
在很多单片机里都带有 DMA 控制器,上面是我画的一个简化的示意图。DMA 控制器和 CPU 都是总线上的主设备,可以接管总线,对设备、SRAM等发出读和写的请求。在前面例子里,我们用串口设备的中断请求告诉 CPU, 可以写数据了,然后 CPU 从 SRAM 里面把数据读出来,写到设备的寄存器里面。不要忘了,这个过程中 CPU 还在不断地从 ROM 或者 SRAM 中读取指令,从 SRAM 中读写数据(内存变量、堆栈等)。而在 DMA 工作时,DMA 控制器收到来自硬件设备的 DMA 请求,将直接去 SRAM 某个地址读取一个长度单位的数据,然后写到设备的某个寄存器地址。这里 DMA 控制器和 CPU 都要访问总线,是由一个仲裁器在决定谁可以用。DMA 控制器访问时,CPU 的访问会被延迟一点时间,但对程序执行的影响很小。STM32 的串口是支持 DMA 的,所以再继续演进一下程序吧。设置一次 DMA 传输需要配置 DMA 控制器,选择 DMA 请求的来源,并告诉它传输是从哪里到哪里(这里是从内存到设备),传输单位(字节),传输数量,源地址和目的地址(源地址在 SRAM 中,要写的字符串处;目的地址是 LPUART1 的 TDR 寄存器)。并且我们要启用 LPUART1 的 DMA 发送。在 DMA 传输结束时,也就是传输了指定的字节数,程序需要用到中断来通知。这里得用 DMA 的中断,而不是串口的中断 IRQ 号. 在这个中断里做什么呢?同第2个例子一样,我用了环形的缓冲区,需要决定的是下一次 DMA 传输的缓冲区指针,和字节数。由于 DMA 不会理会这个“环形”缓冲区,到了末尾还不得不停止传输,然后设置从头开始。看代码吧,ISR 的实现还不困难。在 dmabuf 这个结构里记录了下一次传输需要的信息。void UART_DMA_IRQHandler(void){UART_DMA->IFCR = UART_DMA_CTCIF; // must clear flagif(dmabuf.pend_len){uart_conf_dma(dmabuf.pend_off, dmabuf.pend_len);dmabuf.pend_len=0;return;}if(dmabuf.pend_ext){uart_conf_dma(0, dmabuf.pend_ext);dmabuf.pend_ext=0;return;}if(dmabuf.wait){BaseType_t waken=pdFALSE;xSemaphoreGiveFromISR(uart_dma_done, &waken);portYIELD_FROM_ISR(waken);}}然而 uart_puts() 的实现要考虑的情况就多了,判断环形缓冲区的用量不像上一个例子中那样直接:因为 head 变量不存在了——它关乎到 DMA 传输的地址,这又必须访问DMA硬件寄存器。我用 memcpy() 函数来进行缓冲区的填充,也要分情况计算地址。若缓冲区容不下要写的字符串,就阻塞任务直到 DMA 将缓冲区数据处理完。void uart_puts(char *str){uint16_t len;char done=0, dma_stopped=0;xSemaphoreTake(uart_mutex, portMAX_DELAY);taskENTER_CRITICAL();len=strlen(str);if(dmabuf.pend_ext){uint16_t avail, dma_pos;dma_pos = dmabuf.tail - UART_DMAChannel->CNDTR;avail = dma_pos - dmabuf.pend_ext;if(avail>=len){memcpy(dmabuf.buf+dmabuf.pend_ext, str, len);dmabuf.pend_ext += len;done=1;}}else{if(dmabuf.pend_len){uint16_t avail1, dma_pos;avail1 = BUFSIZE - dmabuf.pend_off - dmabuf.pend_len; // till buffer endif(avail1>=len){memcpy(dmabuf.buf+dmabuf.pend_off+dmabuf.pend_len, str, len);dmabuf.pend_len += len;done=1;}else{dma_pos = dmabuf.tail - UART_DMAChannel->CNDTR;if(avail1+dma_pos>=len){memcpy(dmabuf.buf+dmabuf.pend_off+dmabuf.pend_len, str, avail1);dmabuf.pend_len += avail1;memcpy(dmabuf.buf, str+avail1, len-avail1);dmabuf.pend_ext = len-avail1;done=1;}}}else // no pending transfer{if(UART_DMAChannel->CNDTR) // not finished{uint16_t avail1, dma_pos;avail1 = BUFSIZE - dmabuf.tail;if(avail1>=len){memcpy(dmabuf.buf+dmabuf.tail, str, len);dmabuf.pend_off = dmabuf.tail;dmabuf.pend_len = len;done=1;}else{dma_pos = dmabuf.tail - UART_DMAChannel->CNDTR;if(avail1+dma_pos>=len){memcpy(dmabuf.buf+dmabuf.tail, str, avail1);dmabuf.pend_off = dmabuf.tail;dmabuf.pend_len = avail1;memcpy(dmabuf.buf, str+avail1, len-avail1);dmabuf.pend_ext = len-avail1;done=1;}}}else // finished already{dma_stopped=1;}}}taskEXIT_CRITICAL();if(!done){if(!dma_stopped)wait_dma_finish();while(BUFSIZE < len){memcpy(dmabuf.buf, str, BUFSIZE);uart_conf_dma(0, BUFSIZE);wait_dma_finish();len -= BUFSIZE;str += BUFSIZE;}memcpy(dmabuf.buf, str, len);uart_conf_dma(0, len);}xSemaphoreGive(uart_mutex);}注意到和前面不同的地方,我用了 taskENTER_CRITICAL() 设置了关键区域,禁止 DMA 中断打断执行,也禁止了任务调度。因为现在缓冲区的配置比较复杂了,如果 DMA 传输状态在 uart_puts() 执行期间改变,容易造成误判。包括上面用的辅助函数的完整的程序在附件中,这里不全部列出了。性能对比为了测试用 DMA 的程序比只用中断提高了多少性能,我设计了一段测试代码:用一个任务来不停地打印一个计数器变量,这个任务被赋予高优先级,于是串口不会停歇地在输出。volatile int counter;static portTASK_FUNCTION( vPrint, pvParameters ){char str[128];strcpy(str, " **** 0x");for(;;){char *text = str+8;int8_t i;uint32_t x = counter;text[8]=0;for(i=7;i>=0;i--){unsigned char h=x%16;x>>=4;if(h<10)*(text+i)='0'+h;else*(text+i)='A'+h-10;}LED_B_on();uart_puts(str);LED_B_off();}}而这个计数器变量在低一级优先级的任务中被改写:static portTASK_FUNCTION( vCount, pvParameters ){for(;;){counter++;}}于是,在 uart_puts() 函数调用被阻塞的时候,counter 变量会不停地增加。我再设定一个时间: 10秒钟,看 counter 从0开始变成多少,就可以判断 vCount 任务有效的执行时间。这是再用了一个任务,给它最高的优先级:static portTASK_FUNCTION( vControl, pvParameters ){vTaskDelay(10000);vTaskSuspendAll();for(;;){}}
在对比两个实现方法时,我把 uart_puts() 中任务互斥用的代码取消掉了,因为只有一个任务在调用它。对比最后一次输出计数的结果:BUFSIZE=256BUFSIZE=512DMA (Ex 3)0x4DC6F60x4E5AADPIO (Ex 2)0x3D4DC30x3DF186作个直观的图:
在采用 DMA 传输以后,计数任务获得的 CPU 时间增加了 25%. 可以认为是节省了中断 ISR 的开销,以及程序设计方式的改变带来的效率变化。