第 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)。
实际代码示例(带状态机的超级循环):
// C:带状态机的超级循环(处理耗时任务)
typedef enum {
REPORT_IDLE,
REPORT_CONNECTING,
REPORT_SENDING,
REPORT_WAIT_ACK,
} ReportState;
static ReportState report_state = REPORT_IDLE;
static uint32_t report_timer = 0;
void task_report_data(void) {
switch (report_state) {
case REPORT_IDLE:
if (millis() - report_timer > 5000) {
wifi_start_connect();
report_state = REPORT_CONNECTING;
}
break;
case REPORT_CONNECTING:
if (wifi_is_connected()) {
wifi_send(data, len);
report_state = REPORT_SENDING;
} else if (millis() - report_timer > 3000) {
report_state = REPORT_IDLE; // 超时,放弃
}
break;
case REPORT_SENDING:
if (wifi_send_done()) {
report_state = REPORT_WAIT_ACK;
}
break;
case REPORT_WAIT_ACK:
if (wifi_ack_received()) {
report_timer = millis();
report_state = REPORT_IDLE; // 完成,回到空闲
} else if (millis() - report_timer > 2000) {
report_state = REPORT_IDLE; // 超时
}
break;
}
}c注意这段代码的结构——这就是一个手写的状态机。每个
case是一个状态,break是”让出 CPU”。记住这个模式,因为 Embassy 的 async/await 本质上就是编译器自动生成的这种状态机(第 9 章会详细展开)。
方案二:中断驱动(Interrupt-Driven)#
// C:中断驱动
volatile uint8_t uart_rx_buf[256];
volatile uint16_t uart_rx_head = 0;
volatile uint16_t uart_rx_tail = 0;
// UART 接收中断
void USART2_IRQHandler(void) {
if (USART2->SR & USART_SR_RXNE) {
uart_rx_buf[uart_rx_head] = USART2->DR;
uart_rx_head = (uart_rx_head + 1) % 256;
}
}
// 定时器中断(1ms 周期)
void TIM2_IRQHandler(void) {
system_tick++;
TIM2->SR &= ~TIM_SR_UIF; // 清除中断标志
}
int main(void) {
system_init();
enable_interrupts();
while (1) {
// 主循环只处理"需要时间"的任务
if (uart_rx_head != uart_rx_tail) {
process_command(uart_rx_buf[uart_rx_tail]);
uart_rx_tail = (uart_rx_tail + 1) % 256;
}
if (system_tick % 1000 == 0) {
read_sensor();
}
}
}c工作原理:时间敏感的事件由中断处理(UART 接收、定时器计数),主循环处理不需要实时响应的逻辑。
优点:
- 响应性好:中断可以在任何时刻打断主循环,立即处理紧急事件
- 不丢数据:UART 每收到一个字节就触发中断,不会因为主循环忙而丢失
- CPU 利用率高:主循环可以在无事可做时进入低功耗模式(
WFI)
缺点:
- 逻辑碎片化:一个完整的业务流程被拆散到多个中断和主循环中,难以理解和维护
- 共享数据问题:中断和主循环共享变量(如
uart_rx_buf),需要volatile+ 临界区保护 - 优先级地狱:中断数量增多时,优先级配置变得极其复杂
- 栈溢出风险:中断嵌套时,每层中断都需要栈空间
- 不可重入:如果中断处理函数中调用了
malloc、printf等不可重入函数,行为未定义
适用场景:实时性要求高、事件驱动的系统(如电机控制、通信协议栈)。
方案三:RTOS(实时操作系统)#
// C:FreeRTOS 示例
void sensor_task(void *params) {
while (1) {
int16_t temp = read_temperature();
int16_t hum = read_humidity();
// 通过队列发送数据给显示任务
SensorData data = { .temp = temp, .hum = hum };
xQueueSend(sensor_queue, &data, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1000)); // 等待 1 秒
}
}
void display_task(void *params) {
SensorData data;
while (1) {
// 阻塞等待数据(不消耗 CPU)
if (xQueueReceive(sensor_queue, &data, portMAX_DELAY) == pdTRUE) {
oled_clear();
oled_printf("Temp: %d.%d C", data.temp / 10, data.temp % 10);
oled_printf("Hum: %d.%d %%", data.hum / 10, data.hum % 10);
oled_flush();
}
}
}
void uart_task(void *params) {
uint8_t byte;
while (1) {
// 阻塞等待 UART 数据
if (xQueueReceive(uart_queue, &byte, portMAX_DELAY) == pdTRUE) {
process_command(byte);
}
}
}
int main(void) {
system_init();
sensor_queue = xQueueCreate(4, sizeof(SensorData));
uart_queue = xQueueCreate(64, sizeof(uint8_t));
xTaskCreate(sensor_task, "sensor", 256, NULL, 2, NULL);
xTaskCreate(display_task, "display", 512, NULL, 1, NULL);
xTaskCreate(uart_task, "uart", 256, NULL, 3, NULL);
vTaskStartScheduler(); // 启动调度器,不再返回
while (1); // 不应该到达这里
}c工作原理: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 开销 | ⭐ 极小 | ⭐ 小 | ❌ 中等(内核代码) |
| 确定性 | ✅ 完全确定 | ⭐⭐ 基本确定 | ❌ 调度器引入不确定性 |
| 学习曲线 | ⭐ 极低 | ⭐⭐ 中等 | ⭐⭐⭐ 高 |
| 典型 MCU | 8 位 / 低端 32 位 | 中端 32 位 | 中高端 32 位 |
共同痛点:C 语言缺乏安全的并发抽象#
无论选择哪种方案,C 工程师都面临同一个根本问题:
C 语言没有任何机制来保证并发代码的正确性。
- 共享变量忘了加
volatile?编译器优化后行为未定义。 - 临界区忘了关中断?竞态条件,可能几个月才复现一次。
- 中断中调用了不可重入函数?栈被破坏,随机崩溃。
- 两个任务以不同顺序获取两把锁?死锁,系统挂起。
这些问题在 C 中只能靠程序员的经验和纪律来避免。编译器帮不了你,静态分析工具只能发现一部分,剩下的要等到量产后才暴露。
这正是 Rust 和 Embassy 要解决的问题——但在那之前,我们需要先理解并发的基本机制。
5.3 抢占式调度与协作式调度#
核心区别:谁决定”切换任务”?#
这是理解所有并发模型的最关键问题:
| 抢占式(Preemptive) | 协作式(Cooperative) | |
|---|---|---|
| 谁决定切换? | 调度器(外部强制) | 任务自己(主动让出) |
| 切换时机 | 任意时刻(时钟中断触发) | 只在”让出点”(如 await、yield) |
| 任务能否被”打断”? | ✅ 能(高优先级随时打断低优先级) | ❌ 不能(必须等任务主动让出) |
| 典型代表 | 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上下文切换的代价:
// 一次典型的 Cortex-M4 上下文切换(FreeRTOS 的 PendSV_Handler)
void PendSV_Handler(void) {
// 1. 保存当前任务的寄存器(R4-R11, LR)到当前任务的栈
// ~8 条 STR 指令,~8 个时钟周期
// 2. 更新当前任务指针
// ~2 条指令
// 3. 从新任务的栈恢复寄存器
// ~8 条 LDR 指令,~8 个时钟周期
// 4. 返回(BX LR,触发异常返回)
// ~1 条指令
// 总计:~20 条指令,~20-40 个时钟周期(取决于 Flash 等待周期)
// 在 168MHz 的 STM32F4 上:~120-240 ns
}c这个开销看起来很小,但如果每秒切换 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 的状态机来理解协作式调度:
// 协作式调度的本质:每个任务是一个状态机
// 调度器轮流"推进"每个状态机一步
typedef struct {
uint8_t state;
uint32_t timer;
uint8_t data;
} TaskState;
TaskState task_a = { .state = 0 };
TaskState task_b = { .state = 0 };
// 调度器:轮流执行每个任务的一步
void scheduler_tick(void) {
task_a_step(&task_a); // 推进任务 A 一步
task_b_step(&task_b); // 推进任务 B 一步
}
void task_a_step(TaskState *t) {
switch (t->state) {
case 0: // 初始化
start_sensor_read();
t->state = 1;
break; // ← 这就是"yield":执行完一步就返回
case 1: // 等待传感器
if (sensor_done()) {
t->data = read_sensor();
t->state = 2;
}
// 如果没完成,什么都不做,直接返回(让出 CPU)
break;
case 2: // 处理数据
process(t->data);
t->timer = millis();
t->state = 0; // 回到初始状态,等待下一轮
break;
}
}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:经典的竞态条件
volatile uint32_t shared_counter = 0;
// 主循环中
void main_loop(void) {
while (1) {
// 步骤 1:读取 counter(假设此时 counter = 42)
uint32_t temp = shared_counter;
// ⚡ 此时中断发生!
// 中断中:shared_counter++ → counter 变为 43
// 步骤 2:加 1 并写回(temp = 42,写回 43)
shared_counter = temp + 1;
// 💥 结果:counter = 43,但应该是 44!
// 中断中的 +1 被"丢失"了
}
}
// 中断中
void TIM2_IRQHandler(void) {
shared_counter++; // 这个 +1 可能被主循环覆盖
}c这个问题在 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 溢出)
- 定时器可能漏 tickplaintext原子操作:无锁的并发原语#
原子操作(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 发现标记已清除,返回失败
- 软件重试整个操作plaintextRust 的对应#
| C 的做法 | Rust 的对应 | 说明 |
|---|---|---|
__disable_irq() / __enable_irq() | critical_section::with(|cs| { ... }) | 跨平台的临界区抽象 |
volatile 变量 | core::sync::atomic::AtomicU32 | 原子操作 |
__LDREXW / __STREXW | AtomicU32::fetch_add(Ordering::SeqCst) | 硬件原子指令的封装 |
| 手动管理关/开中断 | RAII 自动管理(离开作用域自动恢复) | 不会忘记恢复中断 |
// Rust:临界区(自动管理,不会忘记恢复)
use critical_section::Mutex;
use core::cell::RefCell;
static SHARED: Mutex> = Mutex::new(RefCell::new(0));
// 在中断中
#[interrupt]
fn TIM2() {
critical_section::with(|cs| {
*SHARED.borrow(cs).borrow_mut() += 1;
});
// 离开 with 块时,中断自动恢复——不可能忘记
}
// 在主循环中
fn main_loop() {
let value = critical_section::with(|cs| {
*SHARED.borrow(cs).borrow()
});
}rust// 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);
}rustRust 的优势:
- RAII 保证:
critical_section::with是一个闭包,离开作用域时自动恢复中断——不可能”忘记开中断” - 类型系统保证:
Mutex<RefCell<T>>强制你通过borrow()访问数据——不可能绕过保护直接访问 - 原子操作的内存序:
Ordering参数让你明确指定内存一致性要求,而非依赖volatile的模糊语义
临界区 vs 原子操作:如何选择?#
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 单个变量的简单操作(+1、赋值) | 原子操作 | 无需关中断,开销最小 |
| 多个变量需要一致更新 | 临界区 | 原子操作无法保证多变量一致性 |
| 涉及复杂数据结构(链表、缓冲区) | 临界区 | 原子操作只适用于单个字 |
| 中断延迟敏感 | 原子操作 | 临界区会增加中断延迟 |
| Cortex-M0(无 LDREX/STREX) | 临界区 | M0 没有硬件原子指令 |
注意:Cortex-M0/M0+ 没有
LDREX/STREX指令,因此不支持硬件原子操作。在这些平台上,AtomicU32的底层实现实际上就是临界区。portable-atomiccrate 提供了跨平台的原子操作抽象(第 15 章详述)。
5.5 中断的简要介绍#
中断的本质:硬件触发的函数调用#
从 CPU 的角度看,中断就是:
在任意两条指令之间,硬件强制跳转到一个预定义的函数地址执行,执行完后返回原来的位置继续。
正常执行流:
指令 1 → 指令 2 → 指令 3 → 指令 4 → 指令 5 → ...
中断发生(在指令 3 之后):
指令 1 → 指令 2 → 指令 3 → [保存现场] → [跳转到 ISR] → [执行 ISR] → [恢复现场] → 指令 4 → 指令 5 → ...plaintext中断的硬件机制(Cortex-M):
┌─────────────────────────────────────────────────────────┐
│ 中断发生时的硬件动作 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. 完成当前指令 │
│ 2. 将 xPSR、PC、LR、R12、R3-R0 压入栈(8 个寄存器) │
│ 3. 从向量表中读取 ISR 地址 │
│ 向量表地址 = 0x00000000(或 SCB->VTOR 配置) │
│ ISR 地址 = *(向量表基址 + 中断号 × 4) │
│ 4. 将 PC 设置为 ISR 地址,开始执行 │
│ │
│ 硬件自动完成,无需软件干预:~12 个时钟周期 │
│ │
├─────────────────────────────────────────────────────────┤
│ 中断返回时的硬件动作 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. 执行 BX LR(LR 中包含特殊的 EXC_RETURN 值) │
│ 2. 硬件自动从栈恢复 8 个寄存器 │
│ 3. 继续执行被中断的代码 │
│ │
│ 硬件自动完成:~12 个时钟周期 │
│ │
└─────────────────────────────────────────────────────────┘plaintext中断优先级与嵌套#
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:极短,只做记录
volatile uint8_t rx_byte;
volatile bool rx_ready = false;
void USART2_IRQHandler(void) {
rx_byte = USART2->DR; // 读取数据(清除中断标志)
rx_ready = true; // 设置标志
}
// 主循环中处理
while (1) {
if (rx_ready) {
rx_ready = false;
process_byte(rx_byte); // 实际处理在主循环中
}
}c// ❌ 坏的 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 本章小结#
核心概念回顾#
┌─────────────────────────────────────────────────────────────┐
│ 嵌入式并发知识图谱 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 并发 vs 并行 │
│ ├── 并发:逻辑上交替(单核 MCU 的常态) │
│ └── 并行:物理上同时(多核 MCU) │
│ │
│ 三种经典做法 │
│ ├── 超级循环:简单,但响应性差 │
│ ├── 中断驱动:响应性好,但逻辑碎片化 │
│ └── RTOS:功能全,但资源开销大 │
│ │
│ 两种调度模型 │
│ ├── 抢占式:调度器强制切换(RTOS) │
│ │ └── 优点:响应性好 / 缺点:上下文切换开销 │
│ └── 协作式:任务主动让出(状态机 / Embassy) │
│ └── 优点:零开销 / 缺点:饿死风险 │
│ │
│ 并发安全 │
│ ├── 竞态条件:共享数据 + 并发访问 + 非原子操作 │
│ ├── 临界区:关中断保护(简单但影响实时性) │
│ └── 原子操作:硬件保证(轻量但只适用于单字) │
│ │
│ 中断 │
│ ├── 本质:硬件触发的函数调用 │
│ ├── 优先级:数值越小越高,支持嵌套 │
│ └── 限制:不能阻塞、不能分配、要尽可能短 │
│ │
└─────────────────────────────────────────────────────────────┘plaintext从本章到 Embassy 的桥梁#
现在,让我们把本章的概念与 Embassy 联系起来:
| 本章概念 | Embassy 中的对应 | 后续章节 |
|---|---|---|
| 超级循环状态机 | #[embassy_executor::task] async fn | 第 9 章 |
状态机的 switch/case | Future 的 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 章——可移植性与生态层次。