干货 | FreeRTOS学习笔记 ——应用场景
去年NXP KW41大赛的时候被迫啃了一口FreeRTOS,我后来打算系统地学习一下它,再尝试应用到自己的DIY项目当中去。FreeRTOS只是众多的 RTOS (字面意思是实时操作系统)中的一种,因为用得广泛有是开源的,作为学习是个不错的选择。我大概地读过了它的文档,现在回头开始梳理,研究下部分的实现细节,一边写这个连载。
单片机也要用操作系统?如果以日常用的Windows, Linux, BSD这些,甚至是DOS,来代表操作系统的话,在单片机上运行操作系统时候个很荒诞的想法——因为对大多数单片机,RAM实在太少了。而 FreeRTOS 并不是要提供一个在单片机上运行软件的平台,把软件一个个安装到它上面,供用户选择运行什么,它没有用户界面;它不是一个管家,也不带任何硬件驱动程序,也没有文件系统服务提供。FreeRTOS只是一个操作系统内核,它首先提供了操作系统最重要的特性:任务调度。
也就是说,有了FreeRTOS,在单片机上实现多任务会容易一些。这里至少有两层含义,一是多任务是否一定要用RTOS才可以实现? 当然不是。对于单片机开发来讲,所有系统资源都是你的,在不同中断服务里面处理不同的任务并不是很困难的事情。第二,是否没有多任务就完全用不着RTOS? 这也得看具体情况,如何界定“任务”的概念了,一件复杂的事情在程序中也有可能划分为几个任务来处理。
还是举几个例子吧。
暂且不管用户操作界面,在播放状态下,从数据流上看播放机是这样工作的(作了一定简化):
I2S接口DMA缓冲区空闲
用剩余PCM数据填充缓冲区,请求解码下一块MP3数据
请求读取MP3文件下一段内容
定位SD卡上要读取扇区位置
SDIO控制器读命令发送
SDIO控制器接收数据完成中断
填充文件数据缓冲区
解码MP3数据,写PCM数据缓冲区
填满I2S接口的DMA缓冲区
这个过程涉及到四个关键的软件部分:
若按自顶向下的软件设计思路,I2S设备驱动以固定的节拍被唤醒,进行缓冲区PCM数据填充,因此需要定期去调用MP3解码程序。MP3解码器则根据前面一段解码操作的结果来决定是否要访问文件系统(因为MP3解码一块数据产生的PCM音频数据的量并不能刚好是I2S设备驱动请求的大小),以及需要读取多少字节的MP3文件内容,还有解码出来暂时用不到的数据也要保存起来下次用。到了文件系统这里,请求读文件的位置和长度也未必是正好SD卡上的一个扇区,所以也有缓存,而且还需要跟踪文件在SD卡上的索引。SDIO设备驱动则按照文件系统的请求读取SD卡,等待操作完成以后返回,注意它和前面的模块不同的是,有一段什么事情也不干的硬件IO等待时间。
一层层的嵌套调用关系如这样:
注意,每一次的函数调用,都代表一个完整的操作:填充一块缓冲区、解码一段MP3数据、读取一段文件,以及读取SD卡一块数据。下层的子程序被调用,完成后返回上一层的程序中继续执行。在没有异常(中断)发生的情况下,程序是不会离开这个调用关系嵌套的。
又需要注意到,上面几个列举的主要函数,虽然一次调用操作是完整的,但每次操作过后它的内部状态不一定相同。用C语言来说,就是这些函数需要有static型的局部变量,或者是自己用一些全局变量来记住上次调用后状态是怎样了。典型的就是文件系统 read_file() 这样的函数被调用后,它需要记住文件指针的位置,以便下次接着读。
如上描述实现的音乐播放器实现有一个重要的缺点:在SD卡读操作的等待时间里,CPU的执行只能停留在SD卡访问函数中,不能用来进行MP3解码的运算操作,处理能力被浪费了。在I2S设备的缓冲区填满,到下一次需要再填充这段时间 ,CPU也是处于空闲状态。其实完全可以利用这两段空闲时间,做其它的事情,比如预先解码MP3一部分数据。那么问题是,如何在一个函数执行过程中,跳出去执行其它的函数,然后再跳回来?用中断,对,但是把什么放在中断里面呢?
就是利用单片机的USB片上设备,模拟一个U盘。做这样的设备,主要内容就是响应来自USB主机的各种请求。当USB主机向设备发出请求的时候,USB硬件会产生一个IRQ,随后USB的中断服务函数(IRQ handler)被执行。通常,USB驱动程序库会提供一些回调(callback)函数接口,就是由用户写一些函数,供USB IRQ handler在需要的时候调用。
作为USB mass storage设备,需要提供的回调函数必须要有读存储设备、写存储设备等,以产生实际的磁盘数据给USB,以及接收USB要求写的磁盘数据。这些函数什么时候被调用到,是应用程序无法预见的,因为它在USB中断发生之后的响应过程中。这样一来,回调函数处理U盘模拟的事务,主程序里面不用管,可以处理无关的别的工作,也就实现了多任务么!
然而,在中断环境下执行用户代码,好像不是太好的选择,因为这时候优先级同级和更低的中断就不能响应了。在实际的USB mass storage设备里面,U盘的数据要从片外存储设备中读取,比如NAND Flash,情况就会糟糕——读取数据需要等待,而这个等待完全发生在USB IRQ handler里面,USB主机的请求暂时也没法再响应了。如果主程序中的任务也需要硬件I/O中断,可能就会受到影响。
那么,作为只模拟一个U盘,不进行其它任务的应用来说,这样似乎也无关紧要:反正都是等。我亲自做过的USB全速(12Mbps)下U盘设备的表现,传输速度最多到500多kB,比12Mbps差了不少,为什么呢?
看上面这个图,USB IRQ中断响应期间,是没有数据传输的,所以12Mbps的有效带宽被浪费了一部分,U盘的吞吐率自然就达不到理论值了。回调函数在I/O等待的时候,USB主机也在等待USB设备的应答。而U盘数据准备好之后,USB硬件发送数据,CPU又无事可干直到下一次请求到来。比较合理的设计是利用USB TX的时间进行实际存储设备的I/O预读取,也就是猜测USB会继续请求读上次读了的后面的数据,那就先把数据读到内存中来,一旦猜对了,就可以缩短下次请求的响应时间,模拟U盘的读取吞吐率就提高了。要这么实现,就不能单纯地在回调函数中处理I/O任务。
某个应用需要对远程设备(通过UART连接)进行 ReadChipID, ReadStatus, ReadData, EraseData, WriteData 这几项操作,以实现数据管理。这几个操作在具体的实现上,都是通过UART发送约定好格式的命令数据,然后通过解析UART收到的返回数据内容来判断操作是否成功,并接收有效的数据。
倘若把注意力放在UART发送命令这里,程序的结构可能是这样的:
这样的设计假定了远程设备会如期地发送出来应答,对UART交互过程却几乎没有容错能力。一旦交互过程中出现了意外,就无法从错误状态恢复,只能从头再来。
一旦考虑到UART通信可能出现的异常,程序的编写就变得不那么直截了当了。一种办法是将解析UART收到数据的功能写成状态机,在数据是否合乎预定的格式、对返回数据的辨识的基础上确定程序执行的状态。当到达某些状态时,就从UART发送命令。但如此写法,从读代码的角度很难看出程序执行的意图,把操作流程和通信纠错混在了一起,且不利于代码维护。
如何在保持程序流程直观的写法下,融进去通信状态识别和错误处理机制呢?按照一般的思维,这是一个单任务(线程)的程序,程序是顺序执行,都不需要借助中断。程序是在按照一个流程进行操作,但还要处理与流程关系不紧密的UART通信异常,这两项工作在时间上是交错的。倘若设想这是两个任务在进行,但是是在可以预见地相互切换着呢?好像这么说把简单事情又复杂化了……
上面三个例子都有可能借助“任务调度”实现更有效的运行时间分配。在我看来,FreeRTOS提供了一种新的程序组织方式——任务,把复杂的事情划分成独立的小块分开编写;同时又提供一些机制让这些小块功能相互协同,来实现总的目的。FreeRTOS的任务是用C语言函数来编写的,它提供一个特性,可以让几个函数看起来“同时”在执行。当然啰,只有一个CPU的情况下实际上是轮流在执行的,不过这也和普通C语言程序有重大区别。
我们知道,C语言的函数可以嵌套调用,可以递归调用。比如 funcA() 调用 funcB(),然后 funcB() 又调用 funcC(),甚至 funcC() 再调用 funcA() 都可以。但是 funcA() 必须等到 funcB() 返回之后才能继续执行它后面的内容,funcB() 调用 funcC() 也是等待 funcC() 完成了,控制权才回来。
对于FreeRTOS的任务则不是这样的了,taskA() 任务可以在执行过程中主动或者被动地交出控制权,taskB() 可以这时候获得控制权,但它并不是从头再开始执行,而是从上次交出控制权的那个地方后面继续执行。又在某个时候,taskB() 交出控制权,系统选择了执行 taskC(),然后再当 taskC() 交出控制权时,taskA() 又恢复了执行。
上面这个图简化了一点,实际上从一个任务的函数跳出来到另一个任务的函数中继续执行的过程,中间还要执行一段FreeRTOS内核的代码,也就是说是内核在负责调度下一步执行哪个任务,如何暂停当前的任务,如何恢复另一个任务。
是否多任务的调度一定要用某种RTOS来实现?也并不是,用了RTOS会有所帮助,同时资源开销会多那么一点。比如已经提到过的用中断来切换任务,已可以实现简单的多任务。又比如,把一个任务分解成多个步骤,每个步骤对应一个函数,然后在一个大的循环里面每次选择执行哪个任务的哪一步。这种多任务为非抢占式的,而用中断实现的多任务是抢占式的。
请注意,不管是否用了某种RTOS来实现,多任务操作的特点是每个任务只要没有执行完,它的状态(包括私有的数据)就必须完整保存。因为代表任务的函数(比如中断服务程序、任务的某一个步骤)一旦返回了,局部变量的作用域就消失,不能用来保存状态,所以任务状态保存要么用全局变量,要么用static的局部变量,要么用动态分配的存储。FreeRTOS的办法是:让任务函数暂停执行不需要返回,可以同时存在多个这种没有返回的函数,然后任选其中一个恢复执行。如何实现这个特性?请听我下篇分析。