摘要:嵌入式系统的本质是异步的(中断驱动)。但在代码层面,异步往往意味着复杂的 状态机 或深层嵌套的 回调函数。逻辑被切得七零八落,维护变得异常困难。本文将剖析 “控制流反转” 的痛点,重温经典的 Protothreads(达夫设备) 黑科技,并深入 C++20 的 co_await 机制,演示如何将复杂的硬件时序逻辑折叠成一行线性的“伪同步”代码。


一、 阻塞的罪与回调的罚

假设我们要执行一个简单的任务序列:

  1. 向 I2C 传感器发指令(耗时 1ms)。

  2. 等待传感器转换(耗时 10ms)。

  3. 读取数据并打印(耗时 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 时:

  1. 保存现场:编译器自动把当前的局部变量、指令指针打包存入堆(或静态内存)。

  2. 挂起 (Suspend):函数立即返回(return 到调用者)。

  3. 恢复 (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_anystd::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 的最高境界。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐