【架构心法】逃离回调地狱:从 Protothreads 到 C++20 协程 (Coroutines) 的嵌入式进化
co_await编程语言的进化方向,永远是让机器去适应人类。汇编:人类适应机器的寄存器。C 语言:人类适应机器的内存地址。回调函数:人类适应机器的中断逻辑。协程机器模拟人类的线性思维。在资源极度受限的 8 位机上,是你的好朋友。在 32 位 ARM (STM32/ESP32) 上,是架构师的终极武器。它让你在写代码时享受“同步的幻觉”,在运行时拥有“异步的极速”。这就是的最高境界。
摘要:嵌入式系统的本质是异步的(中断驱动)。但在代码层面,异步往往意味着复杂的 状态机 或深层嵌套的 回调函数。逻辑被切得七零八落,维护变得异常困难。本文将剖析 “控制流反转” 的痛点,重温经典的 Protothreads(达夫设备) 黑科技,并深入 C++20 的
co_await机制,演示如何将复杂的硬件时序逻辑折叠成一行线性的“伪同步”代码。
一、 阻塞的罪与回调的罚
假设我们要执行一个简单的任务序列:
-
向 I2C 传感器发指令(耗时 1ms)。
-
等待传感器转换(耗时 10ms)。
-
读取数据并打印(耗时 2ms)。
1. 阻塞式 (Blocking) - 系统的毒药
void Task() {
I2C_Write(CMD); // CPU 空转死等
HAL_Delay(10); // CPU 再次死等
I2C_Read(&data); // 继续死等
printf("%d", data);
}
评价:逻辑清晰,但 CPU 99% 的时间都在浪费。在多任务系统中,这会导致其他高优先级任务饿死。
2. 回调式 (Callback) - 逻辑的碎片
为了释放 CPU,我们使用异步中断。
void Task() {
I2C_Write_IT(CMD, On_Write_Done); // 立即返回
}
void On_Write_Done() {
Timer_Start(10, On_Timer_Done); // 开启定时器中断
}
void On_Timer_Done() {
I2C_Read_IT(&data, On_Read_Done);
}
void On_Read_Done() {
printf("%d", data);
}
评价:CPU 利用率高了,但 代码逻辑断裂了。 一个简单的线性流程被拆成了 4 个函数。如果业务复杂一点(比如加个重试机制、错误处理),代码就会变成 “回调地狱 (Callback Hell)”,维护者需要在无数个函数间跳来跳去。
二、 C 语言的黑魔法:Protothreads
在 C++ 普及之前,嵌入式大神 Adam Dunkels 发明了 Protothreads。 它利用 C 语言 switch-case 的穿透特性(Duff's Device),在不使用操作系统堆栈的情况下,实现了 “逻辑挂起”。
核心原理:行号即状态
// 定义宏魔法
#define PT_BEGIN(pt) switch((pt)->lc) { case 0:
#define PT_WAIT(pt, c) (pt)->lc = __LINE__; case __LINE__: if(!(c)) return 0;
#define PT_END(pt) } (pt)->lc = 0; return 2;
// 线性化的异步代码!
int Task(struct pt *pt) {
PT_BEGIN(pt);
I2C_Write_Async(CMD);
// 只要没写完,函数直接 return,下次进函数跳到这一行
PT_WAIT(pt, I2C_Is_Tx_Complete());
Timer_Start(10);
PT_WAIT(pt, Timer_Is_Timeout());
I2C_Read_Async(&data);
PT_WAIT(pt, I2C_Is_Rx_Complete());
printf("%d", data);
PT_END(pt);
}
优势:
-
看起来像阻塞代码,其实是 非阻塞 的状态机。
-
极轻量:每个任务只需要 2 字节(保存行号
lc)。
劣势:
-
局部变量陷阱:因为函数会频繁 return 和 re-enter,局部变量会丢失!必须用
static变量,这会导致重入问题。 -
语法怪异,不支持
switch嵌套。
三、 现代救星:C++20 协程 (Coroutines)
C++20 引入了语言级的协程支持。 这不是操作系统的线程(Thread),它是 无栈的 (Stackless)、编译器生成的 状态机。 它完美解决了 Protothreads 的局部变量问题,且语法优美。
1. 什么是 co_await?
co_await 是一个魔法操作符。 当 CPU 执行到 co_await expression 时:
-
保存现场:编译器自动把当前的局部变量、指令指针打包存入堆(或静态内存)。
-
挂起 (Suspend):函数立即返回(return 到调用者)。
-
恢复 (Resume):当
expression完成(比如中断触发),函数从刚才断开的地方继续执行,局部变量依然有效。
2. 实战:异步 I2C 驱动
我们可以把硬件操作封装成 Awaitable 对象。
// 你的业务代码
Task Async_Sensor_Read() {
// 1. 发送命令,然后挂起,等中断唤醒
co_await I2C_Write_Async(CMD);
// 2. 延时,挂起
co_await System_Delay_Async(10);
// 3. 读取,挂起
auto data = co_await I2C_Read_Async();
// 4. 处理数据
printf("%d", data);
}
这行代码是完全非阻塞的!
-
编译器把它翻译成了一个复杂的状态机。
-
I2C_Write_Async启动 DMA 后,直接return。 -
CPU 去跑别的任务。
-
当 I2C DMA 完成中断触发时,中断处理函数调用
handle.resume(),代码瞬间回到第 2 行继续执行。
四、 零成本抽象:编译器在干什么?
你可能会问:这会不会产生很多开销? 答案是:几乎为零。
在嵌入式中,我们可以定制 promise_type,禁止动态内存分配(使用静态缓冲区或侵入式内存)。 编译器生成的汇编代码,本质上就是一个 函数指针跳转表。
相比于手动写 switch-case 状态机,协程的性能相当(甚至更好,因为编译器能做全局优化),但 可读性提升了 100 倍。
硬件中断与协程的握手
连接 协程 和 硬件 的桥梁是 Awaiter。
struct I2C_Awaiter {
bool await_ready() { return false; } // 永远不就绪,必须挂起
void await_suspend(std::coroutine_handle<> h) {
// 注册回调:当中断发生时,请恢复这个句柄 h
Hardware_Register_Callback([h]{ h.resume(); });
// 启动硬件
HAL_I2C_Master_Transmit_DMA(...);
}
void await_resume() {
// 恢复后执行的动作
}
};
五、 结构化并发:同时等待多个事件
如果我们想同时读取两个传感器,哪个先回来处理哪个,怎么办? std::when_any 或 std::when_all(C++23 或库实现)。
Task Parallel_Read() {
auto [res1, res2] = co_await when_all(
Read_Sensor_A(),
Read_Sensor_B()
);
// 两个都读完了才会走到这里
// 期间 CPU 完全没闲着,在处理中断或其他任务
}
这在传统 C 语言状态机里,写起来极其痛苦(需要维护两个独立的 flag 和状态变量)。而在协程里,只是一个函数调用的问题。
六、 结语:让代码回归人类思维
编程语言的进化方向,永远是 让机器去适应人类。
-
汇编:人类适应机器的寄存器。
-
C 语言:人类适应机器的内存地址。
-
回调函数:人类适应机器的中断逻辑。
-
协程:机器模拟人类的线性思维。
在资源极度受限的 8 位机上,Protothreads 是你的好朋友。 在 32 位 ARM (STM32/ESP32) 上,C++20 Coroutines 是架构师的终极武器。
它让你在写代码时享受 “同步的幻觉”,在运行时拥有 “异步的极速”。这就是 Zero-Cost Abstraction 的最高境界。
更多推荐



所有评论(0)