如何在ESP8266上实现真正的预占位多任务处理——无需实时操作系统
如果我们用Arduino核心在ESP8266上写任何代码,很可能已经遇到了这个瓶颈。你写的每一行代码都运行在loop()函数内部,而loop()是协作式的——它从上到下依次执行,只有在完成所有操作后,SDK才会有机会进行后台处理,然后再调用你。只要你的代码足够快且高效,这没有问题。但一旦某段代码决定卡住——比如阻塞式传感器读取、大量数值运算、或等待某个条件的while循环——其他所有操作就会被暂停。你正在闪烁的LED会冻结,串口输出会停滞,定时器也会漂移。通常建议是“不要阻塞,使用millis()和状态机”,这种方法确实有效。但当达到某个程度后,你不得不在每个程序中手动编写自己的小型调度器,这让人感到疲惫不堪。
我真正想要的,是真正的实时操作系统(RTOS)所提供的功能:多个独立的任务,无论是否协作,都能公平地获得CPU资源。我希望在普通的非操作系统SDK上实现这一点,而不是将整个项目切换到FreeRTOS。结果发现这其实非常可行,而有趣的是,一旦掌握了技巧,实际上所需的变动部件却少得可怜。
本项目中看到的调度器、硬件定时器驱动程序以及上下文切换功能,都是从零开始编写的——它们基于PDI框架实现,而该框架中的抢占式调度是其内置子系统之一。将这样一个庞大的框架引入项目只为查看某一项特性,实在过于繁琐。因此,我所做的只是提取其中最关键的这一部分,剥离围绕它的框架辅助代码,将其转化为一个可一次性阅读的小型独立示例程序。这并非框架的重写,而是展示其核心功能的范例。
“先发制人”在这里实际上意味着什么
协作式调度是指任务会保留CPU,直到它主动将其释放。抢占式调度则是指无论任务是否愿意,都有可能被剥夺CPU的使用权。在这款ESP8266上,“某事物”就是硬件定时器。
简单来说,就是:设置一个硬件定时器(FRC1,也称为 timer1),使其大约每毫秒触发一次。每次触发时,它的中断处理程序会静默地记录下当时正在运行的代码的整个CPU状态——包括所有寄存器、程序计数器、状态寄存器等等——然后加载另一个任务的保存状态,并让处理器从该状态继续执行。对于该任务而言,似乎什么都没发生过,它完全不知道自己曾被“冻结”了一段时间,之后又恢复了运行。在多个任务之间,每秒重复一千次这样的操作,对观察LED和串口日志的人来说,它们看起来都同时在运行。这正是整个幻觉所在,而这也正是真正实时操作系统在底层所采用的机制。
ESP8266 的难点在于,Arduino/非操作系统环境并没有提供一个干净的“这里保存了上下文,请替换它”的 API。你需要深入到 Xtensa 中断触发时 SDK 推送的异常帧中,手动重新编写该帧,以便中断返回后能跳转到下一个任务,而不是你之前所在的那个任务。这就是反向工程的汇编部分,也正是使整个系统成为可能的关键所在。
关于示例
因此,该草图在常规的 loop() 旁边运行三个任务:
•Blink — 每250毫秒切换一次板载LED,然后进入睡眠状态。
•Counter — 每秒打印一次心跳行以及空闲堆。
•Hog — 意意地做着粗鲁的事:它在一个紧缩的循环中,整整持续两秒进行浮点数运算,从不休眠,从不让出,也从不给任何人让路。
在普通的协作式调度中,如果有一个进程(hog)运行,那就会造成灾难。在那两秒内,LED灯会一直保持不亮,串行输出也会停止,因为直到hog决定终止之前,其他任务都无法运行。但在这里,这并不重要。LED灯仍以稳定的4赫兹频率闪烁,计数器也持续打印,即使hog处于忙碌状态,因为定时中断不断介入,暂停hog,让其他任务得以占用CPU。当hog执行繁忙时观察LED——那持续不断的闪烁就是证明:抢占式调度是真实存在的,而不仅仅是伪装成协作式调度的假象。
调度器根据优先级来决定下一个执行的任务,但附加了一个老化规则:一个任务如果持续被忽略,其有效优先级会缓慢上升,最终赢得执行机会。这能防止繁忙的高优先级任务永远压制低优先级任务。
此示例中的文件
只有少数几个文件,了解每个文件的用途会有所帮助:
•XtensaContext.c — 上下文切换。这是一段精心编写的 Xtensa LX106 汇编代码,用于保存和恢复所有寄存器。巧妙的实现方式是通过定时器中断内部调用该函数:它不会像正常返回那样退出,而是直接修改被中断的异常帧,使得当中断返回时,CPU 会以一个完全不同的任务状态被唤醒。
•XtensaTimer.cpp — 心跳。初始化 FRC1/timer1 的硬件定时器及其裸露的中断服务例程(ISR)。该 ISR 会抓取异常帧并将其传递给调度程序。它还通过从下次重新加载时间中减去中断期间所花费的时间,确保自身的计时准确,从而防止时钟节拍缓慢漂移。
•PreemptiveScheduler.cpp — 脑部。它维护一个就绪队列和一个睡眠队列(两者均为固定大小的数组,特意设计以避免在中断期间发生堆内存分配,这会带来麻烦)。每次定时器滴答时,它会唤醒所有时间到的睡眠任务,将被中断的任务重新放回队列,根据优先级和老化机制选择下一个任务并切换执行。任务也可以通过 sleep(ms) 或 yield() 自动让出CPU。
•PreemptiveMutex.cpp — 保持共享资源的正常运行。由于任务可能在任何地方被中断,两个同时向串口打印的任务会愉快地将彼此的输出截成碎片。互斥锁解决了这个问题:当锁被获取时,请求任务会被暂停(实际上不会一直旋转,而是被挂起并脱离CPU),并加入队列;持有锁的任务在完成之后会将锁传递给下一位任务。这与任何实时操作系统中的互斥锁原理相同。该演示程序将每次串口打印都包裹在其中,因此输出内容仍然可读。
•ESP8266PreemptiveScheduler.ino — 该演示程序本身。注册三个任务,使用互斥锁保护 Serial,并退出后台运行。
串行监视器(115200 波特率)
类似以下内容将循环显示在串行监视器上:
……在整个打印过程中,板载LED一直正常闪烁——包括在猪只进行2秒的数字计算时。如果你关闭了互斥锁,就会看到线条交错,这能很好地说明互斥锁所解决的问题。
带元件的布线
无需额外布线,它直接使用了内置的LED和USB串口即可。只需将所有文件放入一个文件夹,打开已安装ESP8266核心的Arduino IDE,选择你的开发板,进行刷写,然后在115200波特率下打开串口监视器。每个任务应预留一定的栈空间(打印和浮点运算时,建议保留1–1.5 KB)。需要说明的是:该程序依赖于一些低级且特定于开发板的行为,因此应将其视为教育或实验用途,而非直接用于量产产品。此外,在定时器路径运行期间请避免对SPI闪存进行频繁操作,因为闪存操作会暂时关闭ISR所依赖的缓存。
本文编译自hackster.io





