“并发不是并行,但在单核上,并发已经足够好用了。” —— Rob Pike
你已经知道硬件怎么连接、屏幕怎么驱动、文件怎么存储。但这些零件就像一堆散落的齿轮——谁来让它们协调转动?本章我们将揭开 CrossPoint Reader 的”调度中心”,看看一个只有单核 CPU 的小芯片是怎么同时处理按键、刷屏、省电等多件事的。

7.1 Arduino 的 loop():看似简单,暗藏陷阱

如果你写过 Arduino 程序,一定对这个结构不陌生:
void setup() {
  // 初始化:只执行一次
}

void loop() {
  // 主逻辑:反复执行
}
Arduino 把程序员从裸机编程中解放出来,只需要填两个函数就能让设备运转。但 loop() 的本质是什么?它其实就是一个死循环
// Arduino 框架内部简化版
int main() {
  setup();
  while (true) {
    loop();
  }
}
所有逻辑都塞在这一个循环里轮询(Polling)——每转一圈,检查一次按键状态,刷新一次屏幕,处理一次网络消息。这种模型有一个致命弱点:任何一步阻塞,整个系统就卡死了。 想象你在一家餐厅当唯一的服务员。你的工作流程是:走到第一桌问要不要点餐 -> 走到第二桌上菜 -> 走到第三桌结账 -> 回到第一桌…如果第一桌的客人花了 10 分钟研究菜单,而你就站在旁边傻等,后面所有桌的客人都得干瞪眼。 在 Arduino 的 loop() 里,如果你写了 delay(2000) 来等墨水屏刷新完毕,那这 2 秒内按键不会被检测、网络不会被处理、什么都不会发生。

一个你可能不知道的事实

在 ESP32 上,Arduino 的 loop() 函数其实已经运行在 FreeRTOS 的一个任务里了。ESP32 的 Arduino 框架在底层悄悄创建了一个 FreeRTOS 任务来执行你的 setup()loop()。这意味着你实际上已经在用操作系统了——只是 Arduino 把这个事实藏了起来,让你感觉像是在写裸机程序。 这也意味着:在 ESP32 上,你完全可以在 loop() 之外再创建新的任务,让多件事”同时”运行。

7.2 为什么阅读器需要多任务

让我们看一个具体的问题。墨水屏不像 LCD 那样可以瞬间刷新。一次完整的墨水屏刷新需要几百毫秒甚至更长——屏幕上的电子墨水微粒需要时间在电场作用下翻转到正确的位置。 如果所有逻辑都在一个 loop() 里:
loop() {
  检查按键();       // 很快,< 1ms
  处理逻辑();       // 很快,几 ms
  刷新墨水屏();     // 很慢,200~800ms ← 在这里卡住了!
}
在墨水屏刷新的那几百毫秒里,按键完全没有人处理。用户按了”下一页”,设备毫无反应。再按一次,还是没反应。等屏幕终于刷完了,两次按键可能同时被响应,直接跳了两页。 用户的感受:这设备太卡了。 解决方案很自然——把工作分给两个”人”来做:
  • 主任务:专心处理按键输入、逻辑判断、状态管理
  • 渲染任务:专心负责把画面推送到墨水屏
这两个任务各司其职,互不阻塞。主任务处理完输入后,拍一下渲染任务的肩膀说”画面该更新了”,然后继续回去处理下一轮输入。渲染任务收到通知后慢悠悠地把画面推到屏幕上——不着急,反正主任务还在正常响应用户的操作。

7.3 FreeRTOS 基础概念

FreeRTOS(Free Real-Time Operating System)是目前嵌入式领域最流行的实时操作系统之一,代码开源,体积小巧,可以运行在资源非常有限的芯片上。ESP32 的官方开发框架 ESP-IDF 已经内置了 FreeRTOS。 下面用通俗的方式介绍几个核心概念。

任务(Task):多个独立的 loop()

一个 FreeRTOS 任务就像一个独立运行的 loop() 函数——它有自己的代码、自己的局部变量、自己的栈空间。你可以创建任意多个任务(当然,每个任务都要占用内存,所以实际数量受限于 RAM 大小)。 创建一个任务的基本语法:
xTaskCreate(
  taskFunction,   // 任务要执行的函数
  "TaskName",     // 任务名字(用于调试)
  stackSize,      // 栈大小(字节)
  parameter,      // 传给任务函数的参数
  priority,       // 优先级
  &taskHandle     // 任务句柄(用于后续操作这个任务)
);

调度器(Scheduler):CPU 的”时间管理大师”

ESP32-C3 只有一个 CPU 核心,它在任何一个瞬间只能执行一个任务的代码。那怎么实现”多任务”呢? 答案是时间片轮转(Time Slicing)。调度器会把 CPU 时间切成很小的片段(默认 1 毫秒一个),让每个任务轮流使用 CPU。由于切换速度极快,从宏观上看就像多个任务在”同时”运行。
时间轴:
─────┬────┬────┬────┬────┬────┬────→
     │主  │渲染│主  │主  │渲染│主  │
     │任务│任务│任务│任务│任务│任务│
─────┴────┴────┴────┴────┴────┴────→
      1ms  1ms  1ms  1ms  1ms  1ms
这就是所谓的**“伪并行”**——不是真的同时运行,而是快速交替运行。在单核的 ESP32-C3 上,这种方式已经完全够用了。

优先级(Priority):谁先用 CPU

每个任务在创建时会被分配一个优先级数字。FreeRTOS 的规则很简单:数字越大,优先级越高。当多个任务同时想要运行时,高优先级的任务会优先获得 CPU。 只有当高优先级的任务主动”让出” CPU(比如进入等待状态),低优先级的任务才有机会运行。 这就像急诊室的分诊制度:重伤患者(高优先级)永远比感冒患者(低优先级)先得到治疗。

阻塞(Blocked)状态

一个任务有四种状态:就绪(Ready)、运行(Running)、阻塞(Blocked)和挂起(Suspended)。其中最需要理解的是阻塞状态:当一个任务调用了等待类函数(比如等待通知、等待信号量、或者 vTaskDelay()),它会进入阻塞状态。阻塞状态的任务完全不消耗 CPU 时间——调度器会直接跳过它,把 CPU 交给其他就绪的任务。 注意:delay() 在 ESP32 的 Arduino 实现中会调用 vTaskDelay(),所以它不会阻塞其他任务——但它会让当前任务在 delay() 期间不做任何事情。

7.4 CrossPoint Reader 的双任务架构

理论讲完了,来看 CrossPoint Reader 是怎么做的。 整个系统被拆分为两个核心任务:
主任务(Main Task)                渲染任务(Render Task)
┌─────────────────┐              ┌─────────────────┐
│ 按键输入         │              │ 等待通知         │
│ Activity.loop() │  ──通知──→   │ Activity.render()│
│ 网络处理         │              │ 推送到墨水屏     │
│ 状态管理         │              │                  │
└─────────────────┘              └─────────────────┘
     优先级:1(默认)                 优先级:1
主任务就是 Arduino 的 loop() 函数所在的任务,负责处理一切逻辑:按键检测、页面切换、数据加载、网络通信等。 渲染任务是 CrossPoint 额外创建的任务,它的工作很单一:等待主任务发来”该画了”的通知,然后调用当前页面的 render() 方法把画面推送到墨水屏。

主循环完整代码

下面是 CrossPoint Reader 主循环的完整代码。每一行都在做重要的事情:
// src/main.cpp
void loop() {
  gpio.update();  // 轮询所有按键状态

  // 截图组合键:Power + Down
  if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) {
    RenderLock lock;
    ScreenshotUtil::takeScreenshot(renderer);
    return;
  }

  // 超时自动休眠
  if (millis() - lastActivityTime >= SETTINGS.getSleepTimeoutMs()) {
    enterDeepSleep();
    return;
  }

  // 长按电源键 -> 休眠
  if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
    enterDeepSleep();
    return;
  }

  activityManager.loop();  // 把控制权交给当前页面

  // 省电:3 秒无操作降到 10MHz
  if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
    powerManager.setPowerSaving(true);
    delay(50);
  } else {
    delay(10);
  }
}
逐段解读:
  1. gpio.update():每次循环开头都刷新所有按键的状态。这是轮询模型的典型做法——不断地”问”按键有没有被按下。
  2. 截图组合键:同时按下电源键和下键会触发截图功能。注意这里获取了 RenderLock——因为截图需要读取帧缓冲区的数据,必须确保渲染任务不会同时往里写入(稍后会详细介绍这个锁)。
  3. 超时休眠:如果用户很长时间没有操作,自动进入深度睡眠以节省电量。millis() 返回开机以来的毫秒数,lastActivityTime 记录最后一次用户操作的时间。
  4. 长按电源键:用户长按电源键超过设定时间,手动进入休眠。
  5. activityManager.loop():这是最核心的一行。它把控制权交给”页面管理器”,由当前活动的页面来处理自己的逻辑(下一章会详细展开)。
  6. 省电策略:如果 3 秒没有操作,就把 CPU 频率降到 10MHz 以节省电量,同时增大 delay() 的时间来减少无效的循环次数。有操作时则保持正常频率,delay(10) 让出 CPU 给其他任务运行。

渲染任务代码

// src/activities/ActivityManager.cpp
void ActivityManager::begin() {
  xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
              8192, this, 1, &renderTaskHandle);
}

void ActivityManager::renderTaskLoop() {
  while (true) {
    ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // 阻塞等待通知
    RenderLock lock;
    if (currentActivity) {
      HalPowerManager::Lock powerLock;
      currentActivity->render(std::move(lock));
    }
  }
}
逐行解读:
  • 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(renderTaskHandle, 1, eIncrement);

// 渲染任务中:等待通知
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
xTaskNotify 就像拍一下对方的肩膀说”轮到你了”。ulTaskNotifyTake 就像闭着眼等别人拍你——在被拍之前,你什么都不做(阻塞状态,不消耗 CPU)。 任务通知的优点是快速且不需要额外的内存分配。缺点是它只能通知到一个特定的任务(一对一通信)。

互斥锁(Mutex)

当两个任务需要访问同一份数据时,必须确保它们不会同时操作。互斥锁(Mutual Exclusion)就是为此设计的。 想象一间只有一把钥匙的会议室。要进去开会,必须先拿到钥匙。如果钥匙被别人拿走了,你就在门口等着。用完之后把钥匙放回去,下一个人才能进去。 在 CrossPoint Reader 中,帧缓冲区(Frame Buffer)就是这间”会议室”——主任务可能在往里写数据,渲染任务可能在从里面读数据并推送到屏幕。如果两个任务同时操作,画面就会出现撕裂或乱码。 RenderLock 就是那把”钥匙”,稍后我们会详细介绍它的实现。

信号量(Semaphore)

信号量和互斥锁类似,但更通用。CrossPoint Reader 用 StorageLock 信号量来保护 SD 卡的访问——因为 SD 卡通过 SPI 总线通信,同一时刻只能有一个操作在进行。
主任务想读取书籍文件         渲染任务想读取字体文件
        │                           │
        │    StorageLock (信号量)     │
        │         ┌───┐              │
        ├────→    │ 🔒│    ←────────┤
        │         └───┘              │
        │                            │
  先拿到锁,开始读文件        等待...锁释放后再读

7.6 RAII 锁模式:让 C++ 帮你善后

CrossPoint Reader 中的 RenderLock 使用了一种非常优雅的 C++ 编程模式——RAII(Resource Acquisition Is Initialization,资源获取即初始化)。 先看代码:
// src/activities/RenderLock.h
RenderLock::RenderLock() {
  xSemaphoreTake(activityManager.renderingMutex, portMAX_DELAY);
  isLocked = true;
}

RenderLock::~RenderLock() {
  if (isLocked) {
    xSemaphoreGive(activityManager.renderingMutex);
    isLocked = false;
  }
}
构造函数(RenderLock())在创建锁对象时自动获取互斥锁。析构函数(~RenderLock())在锁对象被销毁时自动释放互斥锁。 这意味着你可以这样使用:
void someFunction() {
  RenderLock lock;        // 自动获取锁
  // ... 操作共享数据 ...

  if (error) return;      // 即使提前返回,lock 被销毁时也会自动释放锁

  // ... 更多操作 ...
}                         // 函数结束,lock 被销毁,自动释放锁

为什么比手动 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帧缓冲区(互斥锁)渲染画面、截图
StorageLockSD 卡 SPI 总线(信号量)读写文件
HalPowerManager::LockCPU 频率(防止降频)墨水屏刷新期间
它们的使用方式完全一致:创建一个局部变量,作用域结束时自动释放。

7.7 把所有齿轮拼在一起

让我们用一次完整的”用户翻页”操作来串联本章所有知识点:
时间线:用户按下"下一页"按键
─────────────────────────────────────────────────────

1. 主任务 loop()

   ├─ gpio.update()            ← 检测到按键按下

   ├─ activityManager.loop()
   │   │
   │   └─ ReaderActivity.loop()
   │       │
   │       ├─ 计算下一页内容     ← 翻页逻辑
   │       │
   │       └─ requestUpdate()   ← 触发 xTaskNotify()
   │                              "渲染任务,该你画了!"

   └─ delay(10)                ← 让出 CPU

2. 调度器切换到渲染任务

   ├─ ulTaskNotifyTake()       ← 收到通知,从阻塞中醒来

   ├─ RenderLock lock          ← 获取帧缓冲区的锁

   ├─ ReaderActivity.render()  ← 画新页面内容
   │   │
   │   └─ renderer.displayBuffer() ← 推送到墨水屏

   └─ lock 析构               ← 自动释放锁(RAII)

3. 主任务继续下一轮 loop()     ← 渲染还没完也没关系,
                                 主任务已经在处理下一次输入了
整个过程中:
  • 主任务不需要等待墨水屏刷新完毕就能继续响应按键
  • 渲染任务在没有通知时不消耗 CPU
  • 互斥锁保证了帧缓冲区不会被同时读写
  • RAII 保证了锁一定会被释放
这就是 CrossPoint Reader 在单核 ESP32-C3 上实现流畅用户体验的秘密。

本章要点

  1. Arduino 的 loop() 本质是一个死循环里的轮询模型。在 ESP32 上,loop() 已经运行在 FreeRTOS 任务中。
  2. 墨水屏刷新耗时几百毫秒,如果在同一个任务里阻塞等待刷屏,按键响应会冻结。CrossPoint Reader 用双任务架构解决了这个问题。
  3. FreeRTOS 任务就像多个独立运行的 loop()。调度器通过时间片轮转让它们在单核 CPU 上交替执行,看起来像”同时”运行。
  4. 任务间通信三板斧:任务通知(轻量级一对一通知)、互斥锁(保护共享数据)、信号量(控制资源访问)。
  5. RAII(Resource Acquisition Is Initialization)利用 C++ 对象的生命周期自动管理锁的获取和释放,从根本上避免了忘记解锁导致的死锁问题。
  6. CrossPoint Reader 的核心架构:主任务处理输入和逻辑,通过任务通知触发渲染任务更新墨水屏,两者通过 RenderLock 互斥锁保护帧缓冲区。

下一章预告:第 8 章我们将深入 CrossPoint Reader 的页面框架——它把 Android 的 Activity 模型搬到了 MCU 上。页面怎么切换?怎么返回?怎么传递数据?一个完整的”Android 式”页面管理系统是怎么在 380KB 内存里运作的。