活动总览:点此查看(含活动鼓励和活动学习总内容)
本站打卡开始和截止时间:8月21日-8月23日(3天)
打卡任务:
1、阅读cruelfox干货笔记第四篇:FreeRTOS学习笔记 (4)任务间通信
2、跟本帖回复思考题: 设想要将已完成的一个计算器程序(来自某C语言课作业题)移植到单片机上,做成一个带有矩阵式键盘和1602字符型液晶显示屏的计算器。现有的计算器程序使用 getchar() 函数和 putchar() 函数分别进行输入输出,我们想将此程序改写为 FreeRTOS 的一个任务,另外再编写一个键盘扫描任务和一个显示屏接口任务来完成设计。照这个思路,可以使用怎样的手段让这三个任务协同工作?
为了方便大家阅读,将cruelfox“FreeRTOS学习笔记 (4)任务间通信”复制过来了~
前面和大家分享了 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 要使用信号量来通知任务去处理,额外的开销就比中断进出本身多多了。
以上是cruelfox分享的本期内容,阅读后欢迎网友跟帖回复思考题:
设想要将已完成的一个计算器程序(来自某C语言课作业题)移植到单片机上,做成一个带有矩阵式键盘和1602字符型液晶显示屏的计算器。现有的计算器程序使用 getchar() 函数和 putchar() 函数分别进行输入输出,我们想将此程序改写为 FreeRTOS 的一个任务,另外再编写一个键盘扫描任务和一个显示屏接口任务来完成设计。照这个思路,可以使用怎样的手段让这三个任务协同工作?
getchar 和putchar,在台式机比较方便,而单片机不实用,非要往上靠也行,但我的思维不太方便。
我觉得用两作任务完全能实现,一个键盘扫描,一个显示,当键盘扫到一个字符时,发信号量给显示,显示接到后就显示,当回车时,就是等号按下时,直接计算结果,把结果给显示
计算任务调用xTaskNotifyWait()等待键盘任务取得按键键码,键盘任务取得键码后通过xTaskNotify()将键码通过uINotifiedVaule发给计算任务。 计算任务通过队列与显示任务交换数据。显示任务调用xTaskNotifyWait()等待计算任务发送显示数据,计算任务将要显示的数据写入队列后,通过xTaskNotify()通知显示任务显示。 计算任务取出键盘任务发送的键码后,将键码压栈,并将该键码写入队列,通知显示任务显示。 当计算任务接收到键盘任务发送的等号或回车键后,将压栈的数据进行出栈运算。并将等号和运算结果写入队列通知显示任务显示,
可以分为三个任务进行:
任务一负责实时监听按键输入信息,正确地读取信息的输入是很重要的,而信息输入的时机是不可测的。可以通过中断的方法让监听事件处在就绪状态,按键输入后将信息传递给储存计算任务;
任务二负责输入信息的存储和计算;考虑存输入信息快且多的情况,或者需要进行的运算比较复杂,可以专门开辟一个存储信息和进行计算的任务;这个任务在输入信息达到一定数量后被启用执行,执行完毕后输出结果交给显示用的任务三,再将自身挂起等待通知;
任务三负责结果输出,该任务实时性要求不高,主要响应任务二的通知,将计算结果输出到1206,完毕后将自身挂起等待通知。
1. 键盘扫描任务
使用时间片进行周期性任务调动,每隔一段时间执行一次,单次周期执行任务完成,给计算器程序发送消息队列
2. 计算器程序任务
使用 getchar() 函数和 putchar() 函数分别进行输入输出,周期性任务调动,每隔一段时间执行一次,每次周期执行任务完成,若不为空,给显示屏接口任务发送消息队列
3. 显示屏接口任务
等待消息队列任务
本次学习主要针对的是任务间通讯
正常来说想计算器这样的程序一般裸奔是很好解决了,但是为了更好的了解本章的内容做了以下修改
原本的裸奔扩展为几个任务
任务一,扫描键盘,识别键盘输入,将数据传输至运算任务,与显示任务,优先级最高(由于该程序是计算器,使用到的按键会比较多,而且并无其他实时任务占用资源,所以这里使用扫描键盘的做法,节省硬件资源)
任务二,运算任务,存储任务一传输过来的数据,进行存储或运算,具体更具传输指令触发动作
任务三,显示任务,显示其他任务传输过来的数据,及指令触发动作
根据本章学习到的内容定义了三个方案
方案一:任务通知
个人理解,每个任务都有相应的任务通知,应该是可以触发某个任务的任务通知,如果以上不成立,该方案不可行,
任务一,实时在扫描,识别到触发信号,通知到任务二,与任务三,执行相应动作
任务二,等待任务通知,并执行相应动作,运算结果发送至任务三
任务三,等待任务通知,并执行相应动作
方案二:队列
任务一,实时在扫描,识别到触发信号发送队列数据到任务二
任务二,做相应触发动作,并发送队列数据到任务三
任务三,根据指令执行相应动作
方案三:信号量
前提定义全局变量
任务一,实时在扫描,识别到触发信号,发送与任务二的信号量
任务二,识别信号量,获取相应数据,并发送与任务三的信号量
任务三,识别信号量,获取相应数据,并触发相应动作
任务1,按键扫描,时间片定时去检测按键,识别到的按键值放到按键FIFO消息队列 任务2,任务处理,从按键队列中获取消息,如果按键消息队列中数据变化就进行处理,否则等待 任务3,显示任务,定时刷新显示内容