嵌入式操作系统中任务之间的交互与Linux同一进程的不同线程之间的交互完全类似,可以通过全局变量和任务间通信机制两种方法来实现。这种交互包括任务间的数据传递、状态通知和动作同步等等。
任务间通信主要用于以下几个目的:
- 在任务之间,或任务与中断处理程序之间传递数据。
- 共享资源管理。如果一个资源为多个任务、或者任务与中断处理程序所访问,该资源即为共享资源。共享资源可以是一个全局变量、全局数据结构,或者是一个外设,比如GPIO、串口等等。为了保证数据一致性和访问逻辑的完整性,必须保证一个任务、或中断处理程序在访问时的排他性。
- 任务同步。是指不同任务的相关处理按用户设计的先后顺序执行。比如有两个任务:任务A负责从UART接收数据,并放入一块共享数据区,当数据区放满以后,通知任务B,然后进入等待状态;任务B从该共享数据区读取数据,等共享数据区被全部读出以后,通知任务A,然后进入等待状态。图1这样通过任务之间的同步,两个不同任务的操作就可以按设计的顺序执行。
图 1 任务同步
任务间数据传递
第一种方式是通过全局变量进行交互,涉及到多个任务对同一个数据区访问的问题。为了保证数据的一致性,必须通过中断禁止加访问控制(也可以使用信号量等其他操作系统机制,但这实际上是任务间通信了)。这种供多个任务访问的数据区叫临界区(critical section)。与临界区访问相关的问题一直是多任务(线程)编程的梦靥,这种问题往往没有确定的再现方法,而且同一个问题导致的现象也可能多种多样,非常难于定位。而利用操作系统的任务间通信服务,可以有效避免避免临界区访问控制的复杂性,提高系统运行的确定性。
队列传递
另一种方式是采用操作系统的队列(Queue)机制,有的操作系统也叫邮箱(Mailbox)。队列是由多个元素构成的一个有顺序的序列,其中的每个元素叫数据项,也可以叫消息,如图 2所示(以FreeRTOS为例)。
图 2 任务间通过队列传递数据
队列具有以下几个特点:
- 数据项的大小固定。
- 可以容纳的数据项个数(也叫队列长度)也是固定的。
- 数据项的大小及队列长度在初始化可以配置。但一旦确定,在运行中一般不可修改。
- 队列中的数据项按先进先出(FIFO)的形式组织。因此往队列中追加数据项时,新数据项总是放在队列的末尾;从队列中读出数据项时,总是从队列的开头读取,被读取的数据项从队列中删除。
- 队列用于多个任务之间的单向通信,也即一个或多个任务往队列里写入数据,一个任务从队列里读出数据。但也可以实现为多个任务从队列中读取数据,这在平行处理的多任务系统中是有用的,可以为相同的处理启动多个任务,从而加快系统整体的数据处理。
在很多嵌入式应用中,一个任务的基本结构通常如图 3所示。拥有一个接收队列,接收一个数据项,执行相应的处理,然后又将处理结果写入别的任务的队列。在这样的结构中,队列一般为接收任务所独占,其他任务需要向该任务发送数据时,将数据写入该队列。接收任务一般在该队列上调用阻塞读操作:如果接收队列非空,则取出第一个数据项,立即返回;如果接收队列为空,则该任务就进入等待状态,直到其接收队列非空。当接收到数据时,该任务进入就绪状态,等待被调度执行。
图 3 任务结构和消息投递
往队列的数据项写入时,有两种方式:拷贝入队/引用入队,这两种方式的传递过程如图 4所示。
如采用拷贝入队,发送任务在自己的缓存中生成需要发送的数据,并将该缓存中的数据拷贝到队列的数据项中;接收任务将队列数据项中的数据拷贝到自己的缓存中,再进行处理。
如采用引用入队,发送任务申请一块公共缓存,在该缓存中生成需要发送的数据,同时把该缓存的指针放入队列的数据项中;接收任务从数据队列中取出该公共缓存的指针,然后可以直接使用该缓存中的数据,待处理结束后,再释放缓存。
这两种方式各有其优缺点,详细参见表 1。
图 4 拷贝入队和引用入队
表 1 拷贝入队和引用入队的比较
| 拷贝入队 | 引用入队 |
缓存管理 | 接收任务和发送任务不共享缓存,分别管理各自的缓存 | 接收任务和发送任务共享缓存,接收任务在使用完数据后,需要释放共享缓存 |
拷贝次数 | 两次数据拷贝 | 不需数据拷贝 |
传递数据灵活性 | 一次传递数据的长度必须小于队列数据项的长度,过长的数据必须分次传送 | 可以根据需要传递数据的长度申请缓存,因此使用比较灵活。需要传递的数据越长,引用入队的优势越明显 |
传递效率 | 需要拷贝两次数据,效率较低 | 不需要拷贝数据,效率较高。 |
综上所述,在需要传递的数据量较少的时候,采用拷贝入队,简化缓存的管理;而需要传递的数据量较多时,采用引用入队,提高效率。当然,这两种方式也完全可以同时使用,只要存入数据项的是缓存地址,就是引用入队。因此,只有在发送任务和接收任务间协商一致,可以灵活使用两种方式。
当然,在一些使用内存保护的系统中,如果两个任务间内存是完全分离、不能共享内存,则只能通过拷贝入队的方式传递数据。
队列流控机制
但往队列中写入速度快于从队列中读取的速度时,队列就会填满。但队列满时,需要适当的队列流控机制来保证整个系统的正常运行。基本的流控机制有以下几种:
- 由发送任务抛弃入队失败的数据项。利用操作系统的数据发送函数就可以实现。比如在FreeRTOS中,采用以下API。
BaseType_t xQueueSendToBack(QueueHandle_t xQueue, const void *pvItemToQueue, TickType_t xTicksToWait)
其中,xTicksToWait用于指定队列满时的等待时间。如果该值指定为0,则不等待,队列满时立即返回,由发送任务丢弃发送失败的数据。
- 抛弃在队列中等待时间最长的数据项。这需要操作系统的支持,FreeRTOS提供了以下的API:
BaseType_t xQueueOverwrite(QueueHandle_t xQueue, const void *pvItemToQueue)
如果队列不满,则与xQueueSendToBack()相同操作;如果队列满了,则替换队列中最老的数据。
- 挂起发送任务,也即发送任务进入等待状态,直至队列中有空的数据项。用xQueueSendToBack()发送数据项、且指定xTicksToWait不为0时,则如果队列满了,发送任务被挂起,最多等待xTicksToWait长的时间。如果等待时间超时,队列还未变空,则xQueueSendToBack()超时退出。
- 用信号量实现流控。这在信号量的部分详细描述。
具体采用哪种策略,跟系统的应用特性有关。如果丢弃的数据可以通过外部的重传来恢复,那么前两种方案都是可接受的。比如一个系统从串口接收数据,然后该数据在不同的任务之间传递,如果在传递途中,由于队列满而不能写入,而且串口通信机制上有重传功能,则丢弃是可以的。还有,如果是系统内部定期生成的数据,而且只有最新的数据才有意义时(比如一些累积统计信息),则可以采用第二个策略。
后两种方案不丢失数据,可用在需要保证系统内数据完整性的场合。但如果应用程序设计不当,可能会导致系统死锁问题。
队列传递中的死锁问题
在消息传递的过程中,会产生死锁的问题,如图 5所示的例子。
- 在任务B的接收队列满时,任务A向B发送消息。由于不能写入,任务A被挂起,等待B的接收队列出现空数据项。
- 任务B完成一个消息的处理,恰好需要向A发送消息。
- 此时如果任务A的接收队列也满了(由于任务A被挂起,不能处理消息),此时任务B也被挂起。
- 由于任务A、B都被挂起,不能处理消息,因此其接收队列就不会变空,进入死锁状态。
- 其他任务如果需要向A、B发送消息,也会被挂起,导致整个系统进入死锁状态,停止运行。
为了避免系统死锁,可以采用几种方法。
- 对所有的消息发送,都指定有效的超时时间。如果超时时间到,而接收队列还没有变空,系统会失败返回,从而发送任务可以继续运行。在发送任务中实现适当的发送失败处理,就可以避免死锁的情况。在像FreeRTOS系统中,必须指定超时时间(超时时间为0表示不等待,立即返回),从而可以有效避免死锁。
图 5消息传递导致的死锁
- 在有的操作系统中,还提供了查询队列状态的API。如FreeRTOS,有以下的API:
UBaseType_t uxQueueSpacesAvailable( const QueueHandle_t xQueue);
用于查询当前队列中空数据项的数量。在发送之前,可用它先查询队列是否为满,如队列满,可以直接进行发送失败的处理。这样也可以避免死锁。