知识门户

返回

第 5 章:并发基础——从轮询到抢占

Part1 了解嵌入式rust

第 5 章:并发基础——从轮询到抢占#

本章要回答的问题:嵌入式并发是什么?抢占式和协作式有什么区别?C 和 Rust 各自怎么处理并发?

本章定位:严格限定在概念层面,不涉及 Rust/Embassy 的具体实现。为第 9 章(Embassy 运行时)做理论准备。


5.1 并发与并行的概念#

一个生活类比#

想象你在厨房里做饭:

  • 并行(Parallelism):你和你的伴侣同时做饭——你切菜,她烧水。两个任务在物理上同时进行。
  • 并发(Concurrency):你一个人做饭——切一会儿菜,去搅一下锅,再回来切菜,再去翻一下煎蛋。多个任务交替进行,但在任意时刻只有一个任务在执行。

对应到嵌入式:

概念定义硬件对应示例
并行多个任务在物理上同时执行多核 MCU(RP2040 双核、ESP32 双核)Core 0 跑通信,Core 1 跑电机控制
并发多个任务在逻辑上交替执行单核 MCU + 调度机制单核 STM32F4 上同时处理 UART 接收和 LED 闪烁

为什么单核 MCU 也需要并发?#

你可能会想:“我的 STM32F4 只有一个核,一次只能做一件事,为什么需要并发?”

答案是:响应性(Responsiveness)和吞吐量(Throughput)。

考虑一个实际场景——一个温湿度监测器:

任务 A:每 1 秒读取一次传感器(I2C,耗时 ~2ms)
任务 B:每 100ms 刷新一次 OLED 显示(SPI,耗时 ~5ms)
任务 C:监听 UART 命令(随时可能到来)
任务 D:每 5 秒上报一次数据到服务器(WiFi,耗时 ~50ms)
plaintext

如果没有并发(纯顺序执行):

时间线:
|---A(2ms)---|---B(5ms)---|---C(等待...)---|---D(50ms)---|---A---|---B---|...

                                          这 50ms 内,UART 命令来了怎么办?
                                          传感器数据过期了怎么办?
                                          显示卡住了怎么办?
plaintext

如果有并发(交替执行):

时间线:
|A|B|C|A|B|C|A|B|C|...|D(分片执行)|...|A|B|C|...
 ↑ 每个任务都能及时得到响应
plaintext

关键洞察:嵌入式系统中的”并发”不是为了”快”(单核不可能真正同时做两件事),而是为了不让任何一个任务饿死

并发 ≠ 多线程#

在 Linux 应用开发中,“并发”几乎等同于”多线程”。但在嵌入式中,并发的实现方式远不止多线程:

实现方式典型场景本书后续章节
超级循环 + 状态机简单产品5.2 节
中断驱动实时响应5.2 节、5.5 节
RTOS 多线程复杂产品5.2 节
async/await(Embassy)I/O 密集型产品Part 2 全部

本章的目标是让你理解所有这些方式的共同本质——这样当你学到 Embassy 的 async/await 时,你会意识到:“哦,这本质上就是一个协作式状态机,只不过编译器帮我写了。“


5.2 嵌入式并发的三种经典做法#

方案一:超级循环(Super Loop)#

这是最简单、最古老的嵌入式并发模式:

// C:超级循环
int main(void) {
    system_init();
    peripheral_init();

    while (1) {
        task_read_sensor();    // 任务 A
        task_update_display(); // 任务 B
        task_check_uart();     // 任务 C
        task_report_data();    // 任务 D
    }
}
c

工作原理:所有任务在一个 while(1) 中顺序执行,每个任务执行一小步就返回,下一轮循环再执行下一步。

优点

  • 极其简单,无需任何操作系统
  • 执行顺序完全确定(确定性)
  • 没有栈溢出风险(只有一个栈)
  • 调试简单(单步执行即可)

缺点

  • 响应性差:如果任务 D(WiFi 上报)耗时 50ms,那么在这 50ms 内,任务 A/B/C 都无法执行
  • CPU 利用率低:如果所有任务都在”等待”(等传感器数据、等 UART 字节),CPU 只能空转
  • 任务耦合:一个任务的 bug(如死循环)会阻塞所有其他任务
  • 难以扩展:任务数量增加时,循环周期变长,响应性进一步恶化

适用场景:任务少、实时性要求低、资源极度受限(如 8 位 MCU)。

实际代码示例(带状态机的超级循环):

注意这段代码的结构——这就是一个手写的状态机。每个 case 是一个状态,break 是”让出 CPU”。记住这个模式,因为 Embassy 的 async/await 本质上就是编译器自动生成的这种状态机(第 9 章会详细展开)。

方案二:中断驱动(Interrupt-Driven)#

工作原理:时间敏感的事件由中断处理(UART 接收、定时器计数),主循环处理不需要实时响应的逻辑。

优点

  • 响应性好:中断可以在任何时刻打断主循环,立即处理紧急事件
  • 不丢数据:UART 每收到一个字节就触发中断,不会因为主循环忙而丢失
  • CPU 利用率高:主循环可以在无事可做时进入低功耗模式(WFI

缺点

  • 逻辑碎片化:一个完整的业务流程被拆散到多个中断和主循环中,难以理解和维护
  • 共享数据问题:中断和主循环共享变量(如 uart_rx_buf),需要 volatile + 临界区保护
  • 优先级地狱:中断数量增多时,优先级配置变得极其复杂
  • 栈溢出风险:中断嵌套时,每层中断都需要栈空间
  • 不可重入:如果中断处理函数中调用了 mallocprintf 等不可重入函数,行为未定义

适用场景:实时性要求高、事件驱动的系统(如电机控制、通信协议栈)。

方案三:RTOS(实时操作系统)#

工作原理:RTOS 内核管理多个任务(线程),调度器根据优先级和时间片决定哪个任务运行。任务可以”阻塞”(等待事件),阻塞时不消耗 CPU。

优点

  • 编程模型直观:每个任务是一个独立的”线程”,逻辑完整
  • 阻塞等待vTaskDelay()xQueueReceive() 让任务在等待时不消耗 CPU
  • 优先级管理:内核自动处理优先级调度
  • 丰富的 IPC:队列、信号量、互斥锁、事件组
  • 成熟生态:FreeRTOS 有 20+ 年历史,文档和社区极其丰富

缺点

  • 资源开销
    • RAM:每个任务需要独立的栈(通常 256-1024 字节)
    • Flash:RTOS 内核本身占用 5-20 KB
    • CPU:上下文切换有开销(保存/恢复寄存器,通常 1-10 μs)
  • 优先级反转:低优先级任务持有锁时,高优先级任务被阻塞
  • 栈溢出:任务栈是静态分配的,溢出时没有保护(静默破坏内存)
  • 不确定性:调度器的行为增加了系统分析的难度
  • 调试困难:多线程 bug(竞态条件、死锁)难以复现

适用场景:任务多、逻辑复杂、需要阻塞等待的中大型系统。

三种方案的对比总结#

维度超级循环中断驱动RTOS
复杂度⭐ 极低⭐⭐ 中等⭐⭐⭐ 高
响应性❌ 差✅ 好✅ 好
CPU 利用率❌ 低(空转)⭐⭐ 中等✅ 高(阻塞等待)
代码可维护性⭐⭐ 中等❌ 差(碎片化)✅ 好(逻辑完整)
RAM 开销⭐ 极小⭐ 小❌ 大(每任务一个栈)
Flash 开销⭐ 极小⭐ 小❌ 中等(内核代码)
确定性✅ 完全确定⭐⭐ 基本确定❌ 调度器引入不确定性
学习曲线⭐ 极低⭐⭐ 中等⭐⭐⭐ 高
典型 MCU8 位 / 低端 32 位中端 32 位中高端 32 位

共同痛点:C 语言缺乏安全的并发抽象#

无论选择哪种方案,C 工程师都面临同一个根本问题:

C 语言没有任何机制来保证并发代码的正确性。

  • 共享变量忘了加 volatile?编译器优化后行为未定义。
  • 临界区忘了关中断?竞态条件,可能几个月才复现一次。
  • 中断中调用了不可重入函数?栈被破坏,随机崩溃。
  • 两个任务以不同顺序获取两把锁?死锁,系统挂起。

这些问题在 C 中只能靠程序员的经验和纪律来避免。编译器帮不了你,静态分析工具只能发现一部分,剩下的要等到量产后才暴露。

这正是 Rust 和 Embassy 要解决的问题——但在那之前,我们需要先理解并发的基本机制。


5.3 抢占式调度与协作式调度#

核心区别:谁决定”切换任务”?#

这是理解所有并发模型的最关键问题:

抢占式(Preemptive)协作式(Cooperative)
谁决定切换?调度器(外部强制)任务自己(主动让出)
切换时机任意时刻(时钟中断触发)只在”让出点”(如 awaityield
任务能否被”打断”?✅ 能(高优先级随时打断低优先级)❌ 不能(必须等任务主动让出)
典型代表FreeRTOS、RT-Thread、Zephyr超级循环状态机、Embassy
上下文切换需要(保存/恢复寄存器)不需要(或极轻量)

抢占式调度的工作原理#

时间线(FreeRTOS,3 个任务):

优先级:Task_C(高) > Task_B(中) > Task_A(低)

Task_A: |====运行====|         |====运行====|         |====
Task_B:              |==运行==|              |==运行==|
Task_C:                       |=|                     |=|
                              ↑                       ↑
                         Task_C 就绪              Task_C 就绪
                         (抢占 Task_B)           (抢占 Task_A)

调度器行为:
- 每个时钟 tick(通常 1ms),调度器检查是否有更高优先级的任务就绪
- 如果有,立即保存当前任务的上下文(寄存器 → 栈),切换到高优先级任务
- 高优先级任务完成后,恢复被抢占任务的上下文,继续执行
plaintext

上下文切换的代价

这个开销看起来很小,但如果每秒切换 1000 次(1ms tick),累计开销就是 0.12-0.24 ms/s——对于大多数应用可以忽略,但对于极端实时场景(如 100kHz 电机控制)就不可忽视了。

协作式调度的工作原理#

时间线(协作式,3 个任务):

Task_A: |==步骤1==|  yield  |==步骤2==|  yield  |==步骤3==|  yield  |...
Task_B:           |==步骤1==|  yield  |==步骤2==|  yield  |...
Task_C:                     |==步骤1==|  yield  |...

调度器行为:
- 没有时钟中断驱动的强制切换
- 任务在"让出点"(yield / await)主动交出控制权
- 调度器选择下一个就绪任务执行
- 没有上下文切换(每个任务的状态保存在自己的状态机变量中)
plaintext

用 C 的状态机来理解协作式调度

关键洞察

协作式调度 = 调度器 + 一组状态机。每个任务在”让出点”保存自己的状态,下次被调度时从保存的状态继续。

Embassy 的 async/await 就是编译器自动生成的这种状态机。 await 就是 yield,Future 就是状态机,Executor 就是调度器。你写的是”顺序代码”,编译器把它变成状态机。

各自的优缺点#

维度抢占式协作式
响应性✅ 极好(高优先级随时打断)❌ 取决于让出点密度
确定性❌ 调度器行为增加不确定性✅ 执行顺序完全可预测
开销❌ 上下文切换 + 每任务一个栈✅ 几乎零开销
编程模型✅ 直观(每个任务是独立线程)❌ 需要理解状态机思维
饿死风险❌ 低(调度器保证)⚠️ 高(一个任务不让出,其他全饿死)
共享数据❌ 需要锁(互斥量)✅ 单线程执行,无需锁(同一优先级内)
栈使用❌ 每任务独立栈(浪费 RAM)✅ 所有任务共享一个栈
实时性保证✅ 可分析(WCET 分析)⚠️ 取决于任务行为

协作式的”饿死”问题#

协作式调度的最大风险是:一个任务不让出 CPU,其他所有任务都无法执行。

// 危险代码:一个"不让出"的任务
void bad_task_step(TaskState *t) {
    switch (t->state) {
        case 0:
            // 💥 这个循环可能执行 10 秒!
            // 在这 10 秒内,其他所有任务都无法执行
            for (int i = 0; i < 10000000; i++) {
                heavy_computation(i);
            }
            t->state = 1;
            break;
    }
}
c

在 Embassy 中,等价的问题:

// 危险代码:一个没有 .await 的循环
#[embassy_executor::task]
async fn bad_task() {
    loop {
        // 💥 这个循环永远不会让出 CPU!
        // 其他所有任务都无法执行
        for i in 0..10_000_000 {
            heavy_computation(i);
        }
    }
}
rust

解决方案

  • 在长循环中插入 yield_now().await(Embassy)
  • 将长计算拆分为多个步骤(状态机思维)
  • 对时间关键任务使用抢占式(InterruptExecutor,第 9 章)

5.4 临界区与原子操作#

什么是竞态条件?#

竞态条件(Race Condition):当两个或多个执行流(任务/中断)同时访问共享数据,且最终结果取决于执行顺序时,就产生了竞态条件。

这个问题在 C 中极其常见,且极难调试——因为它只在特定的时序下才会发生。你可能测试了 1000 次都没问题,但量产后第 3 年突然出现了。

临界区:关中断保护#

临界区(Critical Section) 是最简单粗暴的解决方案:在访问共享数据时,禁止中断(或其他任务)执行。

// C:临界区保护
void main_loop(void) {
    while (1) {
        // 进入临界区:关闭中断
        __disable_irq();

        // 现在中断不会发生,可以安全地读-改-写
        uint32_t temp = shared_counter;
        shared_counter = temp + 1;

        // 退出临界区:恢复中断
        __enable_irq();
    }
}
c

临界区的规则

规则说明
尽可能短临界区内只做必须的操作,不要调用耗时函数
不要嵌套嵌套的临界区容易出错(忘记恢复)
不要阻塞临界区内不能等待(while(!flag);
不要分配内存malloc 在临界区内可能导致死锁

临界区的代价

时间线(临界区的影响):

主循环:  |====| 关中断 |==临界区==| 开中断 |====|
中断:         ↑                    ↑
          中断到来,但被屏蔽     中断被响应
          (延迟 = 临界区时间)

如果临界区太长:
- 中断响应延迟增大(影响实时性)
- UART 可能丢字节(FIFO 溢出)
- 定时器可能漏 tick
plaintext

原子操作:无锁的并发原语#

原子操作(Atomic Operation) 是比临界区更轻量的方案:硬件保证某个操作”不可分割”——要么完全执行,要么完全不执行。

// C:ARM Cortex-M 的原子操作(CMSIS 提供)
#include "core_cm4.h"

// 原子加:硬件保证不会被中断打断
__atomic_fetch_add(&shared_counter, 1, __ATOMIC_SEQ_CST);

// 或者使用 CMSIS 的封装
__LDREXW(&shared_counter);  // 独占读取
__STREXW(new_value, &shared_counter);  // 独占写入(如果期间被修改,返回失败)
c

原子操作的原理(Cortex-M 的 LDREX/STREX)

LDREX(Load-Exclusive):
- 读取内存值
- 同时在该地址设置一个"独占标记"

STREX(Store-Exclusive):
- 检查独占标记是否仍然存在
- 如果存在:写入成功,清除标记,返回 0
- 如果不存在(被中断/其他核修改过):写入失败,返回 1

如果在 LDREX 和 STREX 之间发生了中断:
- 中断处理程序会清除独占标记
- STREX 发现标记已清除,返回失败
- 软件重试整个操作
plaintext

Rust 的对应#

C 的做法Rust 的对应说明
__disable_irq() / __enable_irq()critical_section::with(|cs| { ... })跨平台的临界区抽象
volatile 变量core::sync::atomic::AtomicU32原子操作
__LDREXW / __STREXWAtomicU32::fetch_add(Ordering::SeqCst)硬件原子指令的封装
手动管理关/开中断RAII 自动管理(离开作用域自动恢复)不会忘记恢复中断
// Rust:原子操作(无需临界区)
use core::sync::atomic::{AtomicU32, Ordering};

static COUNTER: AtomicU32 = AtomicU32::new(0);

// 在中断中
#[interrupt]
fn TIM2() {
    COUNTER.fetch_add(1, Ordering::Relaxed);  // 一条硬件指令,无需关中断
}

// 在主循环中
fn main_loop() {
    let value = COUNTER.load(Ordering::Relaxed);
}
rust

Rust 的优势

  1. RAII 保证critical_section::with 是一个闭包,离开作用域时自动恢复中断——不可能”忘记开中断”
  2. 类型系统保证Mutex<RefCell<T>> 强制你通过 borrow() 访问数据——不可能绕过保护直接访问
  3. 原子操作的内存序Ordering 参数让你明确指定内存一致性要求,而非依赖 volatile 的模糊语义

临界区 vs 原子操作:如何选择?#

场景推荐方案原因
单个变量的简单操作(+1、赋值)原子操作无需关中断,开销最小
多个变量需要一致更新临界区原子操作无法保证多变量一致性
涉及复杂数据结构(链表、缓冲区)临界区原子操作只适用于单个字
中断延迟敏感原子操作临界区会增加中断延迟
Cortex-M0(无 LDREX/STREX)临界区M0 没有硬件原子指令

注意:Cortex-M0/M0+ 没有 LDREX/STREX 指令,因此不支持硬件原子操作。在这些平台上,AtomicU32 的底层实现实际上就是临界区。portable-atomic crate 提供了跨平台的原子操作抽象(第 15 章详述)。


5.5 中断的简要介绍#

中断的本质:硬件触发的函数调用#

从 CPU 的角度看,中断就是:

在任意两条指令之间,硬件强制跳转到一个预定义的函数地址执行,执行完后返回原来的位置继续。

正常执行流:
  指令 1 → 指令 2 → 指令 3 → 指令 4 → 指令 5 → ...

中断发生(在指令 3 之后):
  指令 1 → 指令 2 → 指令 3 → [保存现场] → [跳转到 ISR] → [执行 ISR] → [恢复现场] → 指令 4 → 指令 5 → ...
plaintext

中断的硬件机制(Cortex-M)

中断优先级与嵌套#

Cortex-M 的中断优先级机制:

优先级数值越小 → 优先级越高

┌────────────────────────────────────────────────────┐
│  优先级 0(最高):HardFault、NMI                   │
│  优先级 1:        关键实时任务(如电机 PWM)        │
│  优先级 2:        通信(UART、SPI)                │
│  优先级 3:        定时器(系统 tick)              │
│  ...                                              │
│  优先级 15(最低):非关键外设                      │
└────────────────────────────────────────────────────┘

嵌套规则:
- 高优先级中断可以打断低优先级中断(嵌套)
- 同优先级中断不能互相打断
- 主循环的"优先级"低于所有中断
plaintext
时间线(中断嵌套):

主循环:  |========|                    |========|
低优先级中断:     |====|                    ↑
                   ↑                   低优先级 ISR 返回
              低优先级中断触发

高优先级中断:          |==|

                   高优先级中断触发
                   (打断低优先级 ISR)

执行顺序:
主循环 → 低优先级 ISR → 高优先级 ISR → 低优先级 ISR(继续)→ 主循环(继续)
plaintext

中断中的限制#

在中断服务函数(ISR)中,以下操作是禁止的

禁止的操作原因
malloc / free堆分配器不是可重入的,且耗时不确定
printf / 格式化输出通常使用 malloc,且耗时极长
阻塞等待(while(!flag);会阻塞所有更低优先级的中断和主循环
调用不可重入函数如果主循环正在执行同一函数,状态会被破坏
长时间计算增加中断延迟,可能导致丢数据
获取互斥锁(可能阻塞)如果锁被主循环持有,ISR 会死锁

ISR 的黄金法则

ISR 应该尽可能短——只做”记录事件”和”设置标志”,把实际处理留给主循环或任务。

// ❌ 坏的 ISR:太长,做了太多事
void USART2_IRQHandler(void) {
    uint8_t byte = USART2->DR;

    // 💥 以下操作都不应该在 ISR 中做:
    parse_protocol(byte);           // 可能很耗时
    update_display();               // SPI 传输,耗时
    log_to_flash(byte);             // Flash 写入,极耗时
    send_response_via_wifi(byte);   // WiFi 通信,可能阻塞
}
c

与后续 Embassy 中断处理的关联#

在 Embassy 中,中断的角色发生了变化:

传统裸机Embassy
ISR 中设置标志,主循环轮询ISR 中唤醒一个等待中的 Future
手动管理 volatile 标志Waker 机制自动通知
中断优先级手动配置InterruptExecutor 利用中断优先级实现抢占
ISR 和主循环共享数据需要临界区同一 Executor 内的任务无需锁(单线程)

这些内容将在第 9 章(Embassy 运行时)中详细展开。 现在你只需要记住:Embassy 没有消除中断——它把中断用作”唤醒机制”,而非”处理机制”。


5.6 本章小结#

核心概念回顾#

从本章到 Embassy 的桥梁#

现在,让我们把本章的概念与 Embassy 联系起来:

本章概念Embassy 中的对应后续章节
超级循环状态机#[embassy_executor::task] async fn第 9 章
状态机的 switch/caseFuture 的 poll() 方法第 9 章
状态机的 break(让出).await第 7、9 章
中断设置标志Waker 唤醒 Future第 9 章
临界区 / 原子操作embassy-sync 的通信原语第 10 章
RTOS 队列Channel第 10 章
RTOS 信号量Signal / Mutex第 10 章
RTOS 事件组PubSubChannel第 10 章
中断优先级InterruptExecutor(抢占式)第 9 章

一个关键认知#

如果你只记住本章的一件事:

Embassy 的 async/await 不是”魔法”——它就是编译器自动生成的协作式状态机。

你在第 5.2 节看到的那个手写的 switch/case 状态机,就是 Embassy 在编译后生成的东西。区别在于:

  • 你写的是”顺序代码”(直观、易读)
  • 编译器生成的是”状态机”(高效、零开销)
  • 你不需要手动管理状态变量、不需要写 switch/case、不需要担心遗漏状态

这就是为什么 Embassy 被称为”零开销异步”——它没有引入任何运行时抽象层,只是让编译器帮你做了你本来就要手动做的事情。

下一步#

在第 6 章中,我们将讨论 Rust 嵌入式生态的层次结构——从芯片寄存器到应用代码之间有哪些抽象层?embedded-hal 是什么?为什么 Embassy 是本书选择的并发框架?


下一章:第 6 章——可移植性与生态层次。