干货 | FreeRTOS 学习笔记——任务间通信
EEWORLD电子资讯 犀利解读技术干货 每日更新
本文推荐人:cruelfox 如果您也愿意成为 EEWorld 微信推荐人,请参加:EEWorld 微信你做主,随手推荐得红包!前面和大家分享了 FreeRTOS 的任务是如何建立,以及CPU是如何从一个任务切换至另一个任务的。要发挥多任务的长处,光有调度器和时间片管理还不够,必须有机制让多个任务能协同工作。比如,设想串口输出字符串信息的如下场景:(1) 一个任务是专门管理串口发送的,它在没有数据需要操作 UART 硬件的时候处不会被执行,收到发送请求时需要得到执行,将要发送的字符串存入自己的缓冲区,再按顺序写入 UART 硬件 FIFO,写满后立即暂停执行(2) 另外还有几个任务需要从串口输出信息,当某一任务要求从串口输出一个字符串时,如果串口非空闲(上次的字符串没有发送完,或者其它任务的字符串正在发送),则该任务被阻塞,等待机会;当输出字符串的请求被处理后,任务继续执行(3) UART 硬件在传输时,CPU时间交给了其它的任务,直到传输完成的中断到来,再继续执行负责串口的任务这里需要有任务间的通信——将数据(字符串)从一个任务传递给另一个任务;需要任务同步——只有收到请求、请求得到响应,才继续执行,也就是CPU执行上下文的切换;需要任务互斥——一个任务申请到了串口使用权,其它任务就不能申请到,避免输出交织混乱;还需要用中断来触发任务切换的方法。FreeRTOS 任务间通信有两种方法:一是直接发通知(Notification),一是使用通信对象(Communication objects). 主要区别在于通知是发向一个指定的任务的,直接改变该任务TCB的某些变量;通信对象是独立于任务的实体,有单独的存储空间,可以实现数据传递和较复杂的同步、互斥功能。1. 任务通知 (Notification)在启用了任务通知(这是默认的)以后,任务TCB数据结构会多两个成员:#if( configUSE_TASK_NOTIFICATIONS == 1 )volatile uint32_t ulNotifiedValue;volatile uint8_t ucNotifyState;#endif其中一个是记录任务通知的状态,一个是通知的数据。当然我们写程序不需要直接操作这些变量,而是用 FreeRTOS 的 API,例如最常用的是这两组函数:发送方目标任务xTaskNotify()xTaskNotifyWait()xTaskNotifyGive()ulTaskNotifyTake()注:还有 xTaskNotifyFromISR() 和 xTaskNotifyGiveFromISR() 两个 API 是 ISR 专用的版本。本篇暂不讨论 ISR,所以下面也不提及各种名称如 xxxxFromISR() 的 API 函数,下一篇单独讨论中断。对于一个需要等待事件的任务,调用 xTaskNotifyWait() 来等待其它任务(或者中断ISR)给它一通知。如果已经有通知(pending状态),则立即返回;否则任务切换到阻塞状态,直到通知到来或者超时。通知的内容是32位整型数,用法也有几种,在API参数中说明。简化版本的 xTaskNotifyGive() 和 ulTaskNotifyTake() 将通知内容作为一个计数器。任务的 ucNotifyState 有三种状态,如下图。
对于通知的发送方,是没有阻塞功能的,也就是不能等待目标任务的通知状态变化,这一点和使用某些通信对象有所不同。和通信对象比起来通知的实现更简单,增加的内存开销也很小。2. 信号量 (Semaphore)信号量是操作系统中的概念,在实现任务或进程、线程同步过程中扮演重要的角色。FreeRTOS 提供以下四种类型信号量:类型创建方法普通型xSemaphoreCreateBinary()计数型xSemaphoreCreateCounting()互斥锁xSemaphoreCreateMutex()嵌套互斥锁xSemaphoreCreateRecursiveMutex()信号量是一种通信对象,需要创建后才可以使用。若不再需要可以调用 vSemaphoreDelete() 将它删除,释放占用的内存。前三类(除了 recursive mutex)信号量都是用 xSemaphoreTake() 和 xSemaphoneGive() 两个 API 分别进行"获取"和"给予"操作。最普通的信号量只有两个状态(有/无),计数型的可以是0到某个数之间的整数,代表资源的余量。它们看起来和任务通知的用法很像,也的确经常可以用任务通知替代。区别在于 "Give" 信号量的时候并不需要知道是哪个任务想 "Take" 它,也的确可以支持多个任务 "Take" 同一个信号量。
互斥锁(mutex, 这个词是 mutual exclusion 缩写而来)也只有两个状态,但用法不同。互斥锁用来避免多个任务争用同一资源的问题,让一个任务获取它以后别的任务都不能再获取而只能阻塞,直到取得它的任务交还出来。也就是说,互斥锁是同一个任务在 "Take" 和 "Give",用过必须归还;而普通信号量是“施”与“受”分开的,往往还是某个中断 ISR 在“施”。
互斥锁使用不当可能造成死锁(deadlock),比如有两个任务:甲和乙,都需要互斥锁A和B代表的资源;甲先取得A锁,等待B锁,但B锁已由乙取得,而乙还在等待A锁。嵌套互斥锁(recursive mutex)是让同一个任务可以重复申请已取得的互斥锁,避免自己造成死锁这种不合逻辑的现象。对应的操作函数是 xSemaphoreTakeRecursive() 和 xSemaphoreGiveRecursive(). 例如,某任务先获得这个锁,然后调用一个子程序,子程序中又再次申请获得这个锁,那么既然资源是自己独占的,这个申请立即成功。子程序进行一些操作后释放该锁,但更早的申请还有效,资源仍然属于这个任务独占。3. 队列 (Queue)FreeRTOS 的队列除了提供任务同步机制外,本身就是一个数据传递的通道。实际上信号量的实现也是通过队列,这一点研究一下 FreeRTOS 代码就知道。用 xQueueCreate() 函数创建一个队列时,需要指定队列长度和队列元素大小(每一项数据字节数),以分配队列的数据存储空间。在不需要用某个队列的时候,也最好调用 xQueueDelete() 将它清除。队列操作函数主要有:API 函数名功能同步作用xQueueReceive()接收队列头的数据无数据时阻塞xQueuePeek()获取队列头部数据,但不移除无数据时阻塞xQueueSend(), xQueueSendToBack()在队列尾添加数据无剩余空间时阻塞xQueueSendToFront()在队列头添加数据无剩余空间时阻塞uxQueueMessagesWaiting()返回队列已用量uxQueueSpacesAvailable()返回队列剩余量xQueueReset()清空队列和任务通知、普通/计数型的信号量相比,在任务同步作用上队列可以使发送方阻塞。另外还有一个特殊的函数 xQueueOverwrite(), 用于长度为1的队列,如果队列有数据(满)也强行改写。当多个任务同时等待读一个队列,或者多个任务同时等待写一个队列时,任务的优先级也会起作用,让优先级高的任务获得资源(而不是“先来先服务”)。4. 队列集合 (Queue Set)尽管队列作为通信对象可以多任务共用,消息发送方和接收方可以是一对多,也可以是多对一的关系,在消息类型不同(不同的数据结构)不一样的时候,用多个队列从代码编写角度是更好的选择。FreeRTOS 提供队列集合,用于选择多个队列以及信号量进行“监听”,只要其中不管哪一个有消息到来,都可以让任务退出阻塞状态。这个功能和 TCP/IP socket 库函数中的 select() 有相似之处。用 xQueueCreateSet() 创建队列集合,再用 xQueueAddToSet() 将若干队列(或信号量)添加进队列集合之后,就可以用 xQueueSelectFromSet() 来等待其中任何一个队列有新数据。不过还有一些限制,例如一个队列只能加入一个队列集合;例如队列被加入到队列集合之后,就不能像以前那样自由使用。5. 事件组 (Event Group)
事件组这个通信对象和前几个又不相似,它的存储有点像硬件上的中断标志寄存器。虽然“事件”只用0或1表示,含有的信息有限,但事件组提供了队列不具有的一些功能:(1) 用于等待几个同步事件同时满足,而不是依次满足(2) 多个任务共享一个或几个事件的触发,同时离开阻塞状态至于事件是什么,有多少,完全是由应用程序自己决定的。FreeRTOS 一个事件组最多可以容纳24个事件(标志位),设置和清除事件的 xEventGroupSetBits() 与 xEvencGroupClearBits() 函数就像 GPIO 端口的位操作那样简单。在事件组的 API 中,用来起等待(同步)作用的是以下两个函数xEventGroupWaitBits()等待若干事件标志中一个或全部都为真xEventGroupSync()置位指定的若干事件标志,并等待一组事件标志位同时为真以下是对实现细节一些粗略分析我写了个最简单的使用 binary semaphore 的例子,GDB 跟踪一下:
xSemaphoreTake() 实际上调用的函数是 xQueueGenericReceive(),这也印证了信号量是由队列来实现的(似乎有点小题大做了)。查看 handle 确认我创建的信号量是一个特殊的队列,它的数据结构和队列是一样的。在 queue.c 里面定义了 xQUEUE 这一结构体:
作为简单的 semaphore, 用这么一个结构是浪费了些RAM.跟踪 xQueueGenericReceive() 的执行,发现关键之处在vTaskPlaceOnEventList( &( pxQueue->xTasksWaitingToReceive ), xTicksToWait );这一条操作,字面意思是把当前任务插入队列的“等待接收”的任务列表中。再看这个函数:void vTaskPlaceOnEventList( List_t * const pxEventList, const TickType_t xTicksToWait ){configASSERT( pxEventList );vListInsert( pxEventList, &( pxCurrentTCB->xEventListItem ) );prvAddCurrentTaskToDelayedList( xTicksToWait, pdTRUE );}它有两步操作:一是把当前任务 TCB 中 xEventListItem 这一项插入 xTasksWaitingToReceive 列表,二是把当前任务放到延迟执行的列表中(也就是从ready状态改为阻塞了)。再回到 xQueueGenericReceive() 当中,不久便执行任务调度。再回顾一下任务 TCB 数据结构,有两个 ListItem_t 类型的数据ListItem_t xStateListItem;ListItem_t xEventListItem;在前一贴介绍过,xStateListItem 是用来记录任务状态的。我大胆猜想一下,xStateListItem 是用来记录任务从阻塞到恢复需要的外部事件的——当事件发生时,顺藤摸瓜找到这个任务。接着,跟踪 xSemaphoreGive() 函数,也就是实际的 xQueueGenericSend() 函数里面,用了xTaskRemoveFromEventList( &( pxQueue->xTasksWaitingToReceive );将等待列表中的任务移出,实际上是把该任务 TCB 中 xStateListItem 从所在列表删除,把该任务添加到就绪列表。从我观摩过的部分代码看来,FreeRTOS 用队列进行任务间通信、实现调度的CPU开销还是蛮大的。虽然我还没有仔细评估,感觉跟踪过的代码很频繁地用 vPortEnterCritical(), vPortExitCritical(). 倘若 ISR 要使用信号量来通知任务去处理,额外的开销就比中断进出本身多多了。