“并发不是并行,但在单核上,并发已经足够好用了。” —— Rob Pike你已经知道硬件怎么连接、屏幕怎么驱动、文件怎么存储。但这些零件就像一堆散落的齿轮——谁来让它们协调转动?本章我们将揭开 CrossPoint Reader 的”调度中心”,看看一个只有单核 CPU 的小芯片是怎么同时处理按键、刷屏、省电等多件事的。
7.1 Arduino 的 loop():看似简单,暗藏陷阱
如果你写过 Arduino 程序,一定对这个结构不陌生:loop() 的本质是什么?它其实就是一个死循环:
loop() 里,如果你写了 delay(2000) 来等墨水屏刷新完毕,那这 2 秒内按键不会被检测、网络不会被处理、什么都不会发生。
一个你可能不知道的事实
在 ESP32 上,Arduino 的loop() 函数其实已经运行在 FreeRTOS 的一个任务里了。ESP32 的 Arduino 框架在底层悄悄创建了一个 FreeRTOS 任务来执行你的 setup() 和 loop()。这意味着你实际上已经在用操作系统了——只是 Arduino 把这个事实藏了起来,让你感觉像是在写裸机程序。
这也意味着:在 ESP32 上,你完全可以在 loop() 之外再创建新的任务,让多件事”同时”运行。
7.2 为什么阅读器需要多任务
让我们看一个具体的问题。墨水屏不像 LCD 那样可以瞬间刷新。一次完整的墨水屏刷新需要几百毫秒甚至更长——屏幕上的电子墨水微粒需要时间在电场作用下翻转到正确的位置。 如果所有逻辑都在一个loop() 里:
- 主任务:专心处理按键输入、逻辑判断、状态管理
- 渲染任务:专心负责把画面推送到墨水屏
7.3 FreeRTOS 基础概念
FreeRTOS(Free Real-Time Operating System)是目前嵌入式领域最流行的实时操作系统之一,代码开源,体积小巧,可以运行在资源非常有限的芯片上。ESP32 的官方开发框架 ESP-IDF 已经内置了 FreeRTOS。 下面用通俗的方式介绍几个核心概念。任务(Task):多个独立的 loop()
一个 FreeRTOS 任务就像一个独立运行的loop() 函数——它有自己的代码、自己的局部变量、自己的栈空间。你可以创建任意多个任务(当然,每个任务都要占用内存,所以实际数量受限于 RAM 大小)。
创建一个任务的基本语法:
调度器(Scheduler):CPU 的”时间管理大师”
ESP32-C3 只有一个 CPU 核心,它在任何一个瞬间只能执行一个任务的代码。那怎么实现”多任务”呢? 答案是时间片轮转(Time Slicing)。调度器会把 CPU 时间切成很小的片段(默认 1 毫秒一个),让每个任务轮流使用 CPU。由于切换速度极快,从宏观上看就像多个任务在”同时”运行。优先级(Priority):谁先用 CPU
每个任务在创建时会被分配一个优先级数字。FreeRTOS 的规则很简单:数字越大,优先级越高。当多个任务同时想要运行时,高优先级的任务会优先获得 CPU。 只有当高优先级的任务主动”让出” CPU(比如进入等待状态),低优先级的任务才有机会运行。 这就像急诊室的分诊制度:重伤患者(高优先级)永远比感冒患者(低优先级)先得到治疗。阻塞(Blocked)状态
一个任务有四种状态:就绪(Ready)、运行(Running)、阻塞(Blocked)和挂起(Suspended)。其中最需要理解的是阻塞状态:当一个任务调用了等待类函数(比如等待通知、等待信号量、或者vTaskDelay()),它会进入阻塞状态。阻塞状态的任务完全不消耗 CPU 时间——调度器会直接跳过它,把 CPU 交给其他就绪的任务。
注意:delay() 在 ESP32 的 Arduino 实现中会调用 vTaskDelay(),所以它不会阻塞其他任务——但它会让当前任务在 delay() 期间不做任何事情。
7.4 CrossPoint Reader 的双任务架构
理论讲完了,来看 CrossPoint Reader 是怎么做的。 整个系统被拆分为两个核心任务:loop() 函数所在的任务,负责处理一切逻辑:按键检测、页面切换、数据加载、网络通信等。
渲染任务是 CrossPoint 额外创建的任务,它的工作很单一:等待主任务发来”该画了”的通知,然后调用当前页面的 render() 方法把画面推送到墨水屏。
主循环完整代码
下面是 CrossPoint Reader 主循环的完整代码。每一行都在做重要的事情:-
gpio.update():每次循环开头都刷新所有按键的状态。这是轮询模型的典型做法——不断地”问”按键有没有被按下。 -
截图组合键:同时按下电源键和下键会触发截图功能。注意这里获取了
RenderLock——因为截图需要读取帧缓冲区的数据,必须确保渲染任务不会同时往里写入(稍后会详细介绍这个锁)。 -
超时休眠:如果用户很长时间没有操作,自动进入深度睡眠以节省电量。
millis()返回开机以来的毫秒数,lastActivityTime记录最后一次用户操作的时间。 - 长按电源键:用户长按电源键超过设定时间,手动进入休眠。
-
activityManager.loop():这是最核心的一行。它把控制权交给”页面管理器”,由当前活动的页面来处理自己的逻辑(下一章会详细展开)。 -
省电策略:如果 3 秒没有操作,就把 CPU 频率降到 10MHz 以节省电量,同时增大
delay()的时间来减少无效的循环次数。有操作时则保持正常频率,delay(10)让出 CPU 给其他任务运行。
渲染任务代码
-
begin():在系统初始化阶段创建渲染任务。8192是栈大小(8KB),1是优先级,renderTaskHandle是任务句柄,后续用来给这个任务发通知。 -
renderTaskTrampoline:这是一个静态函数(FreeRTOS 的xTaskCreate要求传入一个普通函数指针),它内部会调用this->renderTaskLoop()。这种”跳板”模式在 C/C++ 嵌入式代码中很常见。 -
ulTaskNotifyTake(pdTRUE, portMAX_DELAY):这是渲染任务的灵魂。它做的事情是:阻塞等待,直到主任务发来通知。portMAX_DELAY表示永远等下去,不设超时。在等待期间,这个任务不消耗任何 CPU 时间。 -
RenderLock lock:获取互斥锁,确保主任务和渲染任务不会同时操作帧缓冲区。 -
HalPowerManager::Lock powerLock:确保渲染期间 CPU 不会降频——墨水屏刷新对时序有要求,降频可能导致显示异常。 -
currentActivity->render(std::move(lock)):调用当前页面的渲染方法,把画面画到帧缓冲区并推送到墨水屏。std::move(lock)把锁的所有权转移给render()方法,让它在适当的时候释放。
7.5 任务间通信机制
两个任务各自独立运行,但它们需要协调工作。FreeRTOS 提供了多种任务间通信的工具,CrossPoint Reader 主要使用了三种。任务通知(Task Notification)
这是最轻量级的通信方式,CrossPoint Reader 用它来实现”主任务通知渲染任务该画了”的机制。xTaskNotify 就像拍一下对方的肩膀说”轮到你了”。ulTaskNotifyTake 就像闭着眼等别人拍你——在被拍之前,你什么都不做(阻塞状态,不消耗 CPU)。
任务通知的优点是快速且不需要额外的内存分配。缺点是它只能通知到一个特定的任务(一对一通信)。
互斥锁(Mutex)
当两个任务需要访问同一份数据时,必须确保它们不会同时操作。互斥锁(Mutual Exclusion)就是为此设计的。 想象一间只有一把钥匙的会议室。要进去开会,必须先拿到钥匙。如果钥匙被别人拿走了,你就在门口等着。用完之后把钥匙放回去,下一个人才能进去。 在 CrossPoint Reader 中,帧缓冲区(Frame Buffer)就是这间”会议室”——主任务可能在往里写数据,渲染任务可能在从里面读数据并推送到屏幕。如果两个任务同时操作,画面就会出现撕裂或乱码。RenderLock 就是那把”钥匙”,稍后我们会详细介绍它的实现。
信号量(Semaphore)
信号量和互斥锁类似,但更通用。CrossPoint Reader 用StorageLock 信号量来保护 SD 卡的访问——因为 SD 卡通过 SPI 总线通信,同一时刻只能有一个操作在进行。
7.6 RAII 锁模式:让 C++ 帮你善后
CrossPoint Reader 中的RenderLock 使用了一种非常优雅的 C++ 编程模式——RAII(Resource Acquisition Is Initialization,资源获取即初始化)。
先看代码:
RenderLock())在创建锁对象时自动获取互斥锁。析构函数(~RenderLock())在锁对象被销毁时自动释放互斥锁。
这意味着你可以这样使用:
为什么比手动 lock/unlock 安全
如果不用 RAII,你需要在每一个return 语句之前手动调用 xSemaphoreGive(mutex) 来释放锁。函数有 5 个分支,你就需要在 5 个地方写释放代码。漏掉一个,锁就永远不会被释放——其他任务会永远卡在等待锁的地方,系统就”死锁”了。
RAII 把这个责任交给了 C++ 的作用域机制:不管函数以什么方式结束(正常 return、提前 return、甚至发生异常),局部变量的析构函数都一定会被调用。所以锁一定会被释放,程序员不需要操心。
如果你用过 Go 的 defer、Python 的 with、或者 Java 的 try-with-resources,RAII 就是 C++ 版本的同类机制——只不过它是通过对象生命周期自动实现的,甚至不需要特殊的语法关键字。
CrossPoint Reader 中的 RAII 锁家族
项目中有多个 RAII 锁,各自保护不同的资源:| 锁 | 保护的资源 | 使用场景 |
|---|---|---|
RenderLock | 帧缓冲区(互斥锁) | 渲染画面、截图 |
StorageLock | SD 卡 SPI 总线(信号量) | 读写文件 |
HalPowerManager::Lock | CPU 频率(防止降频) | 墨水屏刷新期间 |
7.7 把所有齿轮拼在一起
让我们用一次完整的”用户翻页”操作来串联本章所有知识点:- 主任务不需要等待墨水屏刷新完毕就能继续响应按键
- 渲染任务在没有通知时不消耗 CPU
- 互斥锁保证了帧缓冲区不会被同时读写
- RAII 保证了锁一定会被释放
本章要点
- Arduino 的
loop()本质是一个死循环里的轮询模型。在 ESP32 上,loop()已经运行在 FreeRTOS 任务中。 - 墨水屏刷新耗时几百毫秒,如果在同一个任务里阻塞等待刷屏,按键响应会冻结。CrossPoint Reader 用双任务架构解决了这个问题。
- FreeRTOS 任务就像多个独立运行的
loop()。调度器通过时间片轮转让它们在单核 CPU 上交替执行,看起来像”同时”运行。 - 任务间通信三板斧:任务通知(轻量级一对一通知)、互斥锁(保护共享数据)、信号量(控制资源访问)。
- RAII(Resource Acquisition Is Initialization)利用 C++ 对象的生命周期自动管理锁的获取和释放,从根本上避免了忘记解锁导致的死锁问题。
- CrossPoint Reader 的核心架构:主任务处理输入和逻辑,通过任务通知触发渲染任务更新墨水屏,两者通过
RenderLock互斥锁保护帧缓冲区。
下一章预告:第 8 章我们将深入 CrossPoint Reader 的页面框架——它把 Android 的 Activity 模型搬到了 MCU 上。页面怎么切换?怎么返回?怎么传递数据?一个完整的”Android 式”页面管理系统是怎么在 380KB 内存里运作的。