C 语言实现 async/await 异步语法糖

发布时间:2026/7/13 21:33:56
C 语言实现 async/await 异步语法糖 文章目录一、引入二、以往的无栈协程编程方式三、需求拆分1、事件驱动调度2、await 语法糖四、await 语法糖转译1、翻译原理2、如何替换原有代码3、调试黑魔法五、部署一、引入C 当然能做到异步当然能实现协程就像 oop只是不能从原语层面便捷书写而是使用相应的思想进行编程。嵌入式场景常用的 MCU作为微控制器大部分情况只需要做一些控制与通信相关的任务所以常常会通过中断和 DMA 来实现各种异步操作。C 语言在协程与异步原语层面上的缺失使得嵌入式裸机异步编程更加复杂。而rust embassy在嵌入式上的实践无不体现了异步原语的优越性没有乱七八糟的回调。例如QP/C框架会把每个事件状态分割为一个函数非常混乱而且难以移植到其它框架async函数会被编译器编译为协程不需要手动编写出让点和管理状态流转语言层面上的统一带来了更好的开源组件兼容性。C 语言的组件往往需要适配不同厂商的 RTOS因为 C 下的组件很依赖 RTOS 提供非阻塞或者自己轮询管理事件所以性能会有所影响最新的 C 标准依然是没有引入协程或异步原语仍在使用 C99 的二十多年后的今天恐怕再过二十多年依然是用不上 C 语言的await。所以本文的目标就是尝试能否通过一些手段构建 C 语言无栈协程翻译框架并实现异步await语法糖。二、以往的无栈协程编程方式协程你可以理解为协作式多线程。任务之间是相互协作的自行决定什么时候出让 cpu 并记录函数出让恢复点而一般的线程需要调度器拥有一个抢占式内核来给每个任务分配 cpu 时间片。让我们看看一般情况下可以如何通过无栈协程的思想进行编程。例如有一个 led 闪烁任务亮 500ms 灭 500ms这里先通过同步阻塞式的写法完成需求// 阻塞式 delayvoiddelay(intms){intwake_up_ms;wake_up_msget_ms()ms;while(get_ms()wake_up_ms){// 死循环干等}}voidled_blink(void){led_on();// 点亮 leddelay(500);// 等待 500 msled_off();// 关闭 leddelay(500);// 等待 500 ms}intmain(void){while(1){led_blink();// 如果放入其他任务就会相互影响// led_blink2();}}这里是通过阻塞 cpu 的方式完成了闪烁后的等待对于单任务完全没有任何问题。但是如果需要两个 led 不同频率闪烁比如再放一个led_blink2到while(1)里那就完全达不到效果因为他们之间的延时会相互影响。所以说我们希望delay的时候不要阻塞 cpu此时应该切出led_blink去执行别的任务接下来是一种比较常用的非阻塞状态机的写法voidled_blink(void){staticintstep0;staticintlast_ms0;switch(step){case0:led_on();// 点亮 ledlast_msget_ms();// 记录时间step1;// 流转到下一个状态break;case1:// 相当于 delay(500)但是只是进来判断一下就退出不阻塞 cpuif(get_ms()-last_ms500){step2;// 时间到了才能流转到下一个状态}break;case2:led_off();// 关闭 ledlast_msget_ms();// 记录时间step3;// 流转到下一个状态break;case3:// 相当于 delay(500)但是只是进来判断一下就退出不阻塞 cpuif(get_ms()-last_ms500){step0;// 时间到了才能流转到下一个状态}break;}}intmain(void){while(1){// 多个任务协作每个任务主动出让不会阻塞在单一任务内led_blink();// led_blink2();}}这里就已经是无栈协程了不过是通过手写状态机的方式来实现等待 500ms 的过程并不需要阻塞 cpu而是进来判断一下时间到没到就退出去外部的while(1)会切换执行其它任务。事实上已经有人对这种协程进行了宏封装叫做protothread通过封装switch case可以达到差不多的效果intled_blink(structpt*pt){staticintlast_ms0;PT_BEGIN(pt);// 宏封装的 switchwhile(1){led_on();// 点亮 ledlast_msget_ms();// 出让 cpu直到条件为真再继续执行后面的内容PT_WAIT_UNTIL(pt,get_ms()-last_ms500);led_off();// 关闭 ledlast_msget_ms();PT_WAIT_UNTIL(pt,get_ms()-last_ms500);// 做计算密集型任务也可以做到一半主动调用 yield 出让 cpu// PT_YIELD(pt); // 出让 cpu}PT_END(pt);}staticstructptpt1;intmain(void){PT_INIT(pt1);while(1){led_blink(pt1);// led_blink2(pt2);}}但这并不是我想要的结果原因如下这样的写法是靠任务内部自己去poll事件进行状态流转的每个任务不管事件发生与否都要进一次判断一下就像是你不通过手机铃声判断是否来消息而是时不时拿起手机看一眼你不知道系统什么时候是空闲不论是否有事件都要进去poll很难做休眠操作任务如果多的话O(n)轮询就会对响应性造成影响依然是少不了手写状态机来进行任务管理总有需要暂停 led 闪烁的时候在led_blink里面还是要套一层if依然是无意义的轮询还很丑函数内部无法使用switch case不然会和协程宏的switch case冲突层次状态机手动管理复杂三、需求拆分那么需要改进的地方就是下面几点可对接事件驱动调度器避免无意义轮询await语法糖翻译状态机时需要避免冲突switch case还有构建父子关系降低层次状态机复杂度1、事件驱动调度要实现事件驱动就要有一个事件队列while(1)不再无脑轮询所有任务而是不断尝试弹出事件队列头如果为空则是系统空闲可以决定是否休眠如果能弹出事件对象那么就调用所有订阅了这个事件对象的订阅者回调// 轮询写法while(1){// 轮询所有任务// 不知道哪个任务真正需要执行// 难以判断系统空闲task1();task2();task3();task4();// ...}// 事件驱动写法while(1){// 不断弹出事件队列task_xtask_pop();if(task_x!NULL){// 弹出不为空则调用事件回调run_callback(task_x);}else{// 没有任务要执行// 休眠直到产生中断后唤醒 cpu 继续执行__WFI();}}时间也算是一种事件所以还需要有一种闹钟机制时间到了就发布一个事件不是本文重点所以不详细讲这一部分。2、await 语法糖将协程函数作为回调避免了分散的事件回调函数和状态流转关系await就是标记出让和恢复点让编译器能够翻译为协程状态机。我们先尝试手写状态机看看如何更好对await进行封装以及对状态机进行改进还是闪烁 led 的例子理想中的写法// 定义空宏兼容不需要转译的情况避免编译器报错#define_async#define_await// 业务逻辑清晰且可以退化为阻塞或者依赖 rtos_asyncvoidled_blink(void*data){inti;// 闪烁 100 次for(i0;i100;i){led_on();// 点亮 led_awaitdelay(500);// 异步等待 500 msled_off();// 关闭 led_awaitdelay(500);// 异步等待 500 ms}}尝试翻译一下状态机// 延时函数voiddelay(structco_stu*father,structco_stu*co,intms){if(fatherNULL){// 如果调用 delay 的不是 _async 函数#ifdefUSE_FREE_ROTS// 如果用的是 rtosvTaskDelay(pdMS_to_ticks(ms));#else// 如果是裸机则退化为阻塞等待intlast_msget_ms();while(get_ms()-last_msms);#endifreturn;}// 1. 将调用 delay 的协程的软件定时器设置在一定时间后_set_alarm(father-alarm,ms);// 2. 让他订阅自己的软件定时器发布的事件_subscribe(father,father-alarm-event);}// 协程局部变量结构体structled_blink_data_stu{inti;};// 业务函数voidled_blink(structco_stu*father,structco_stu*co,void*data){// 不使用 protothread 的 case 穿透写法而是将跳转写在函数头部// 避免与业务的 switch case 冲突switch(co-step){case1:gotoled_blink_step_1;case2:gotoled_blink_step_2;}// 闪烁 100 次for(((structled_blink_data_stu*)co-privte_data)-i0;((structled_blink_data_stu*)co-privte_data)-i100;((structled_blink_data_stu*)co-privte_data)-i;){led_on();// 点亮 led// 重载 delay// 告知调度器 500ms 后再调用 led_blinkdelay(co,NULL,500);co-step1;// 状态流转return;// 出让led_blink_step_1:led_off();// 关闭 led// 重载 delay// 告知调度器 500ms 后再调用 led_blinkdelay(co,NULL,500);co-step2;// 状态流转return;// 出让led_blink_step_2:}}不过这种翻译效果靠宏是做不到的尤其是对于局部变量的保存肯定是要根据不同的协程函数创建不同的数据结构体然后让 co 的私有数据指针指向对应的结构体实例。结果就是需要在编译阶段插入额外的脚本进行源到源翻译可能有不少朋友会被需要额外的转译操作劝退。不用担心不需要篡改编译器或者安装编译器插件什么的。四、await 语法糖转译1、翻译原理每个async函数生成特定局部变量结构体async函数翻译后会在头部生成switch case来进行goto跳转内部每个调用await的地方都会传入自己的 co 指针作为父对象生成相应的goto_tag以及状态流转和出让操作等等调用async函数时如果自身不是async函数那么父指针传入NULL该函数不需要进行额外的翻译因为还需要传入被调用的async函数的 co 指针所以此时需要动态创建被调用函数的 co 实例以及私有数据结构体实例也可以静态创建await调用完动态创建的 co 实例需要插入 free 操作静态创建的则不插入async函数最后要对自己的资源进行释放2、如何替换原有代码编译器肯定不认识async语法所以需要在编译前用脚本将原始xx.c文件进行翻译得到xx.coro.c编译器实际上编译的是后者所以我构思了以下几条路径方案优点缺点1、编写xx.c编译前用脚本转换xx.coro.c编译时直接指定xx.coro.c适配性强不符合直觉编写xx.c却需要指定编译器编译xx.coro.c可能还会丢失编译器跳转功能2、编译前钩子自动执行脚本编译前替换xx.c编译后换回原文件符合直觉可以直接指定编译xx.c而非中间文件需要注意避免文件时间戳更新否则编译器无法做到增量编译3、替换预处理器预处理器内部直接转换后交给编译器没有中间文件用户无感似乎只有 gcc 可能可以做到4、xx.c内部显式#include xx.coro落地简单不用替换文件侵入性小首次编译前 ide 会有警告最简单兼容性最好的办法就是第 4 条编译前钩子调用脚本自动执行转译操作用户编译时也只需要给编译器指定原始的xx.c不需要文件替换操作我这里选择使用第 4 条进行源码替换设计直接看样例// xx.c#includectx.h// 协程库头文件_asyncvoidabc(void*data){// xxx_awaitdelay(100);// xxx}#includexx.coro// 脚本在编译前自动转译的文件最好最后 include具体实现方法就是函数重载通过宏将调用被_async标记的函数的地方都替换成调用成另一个函数// xx.c#includectx.h// 协程库头文件// ctx.h 里面有一个函数替换宏非 _async 函数创建协程时会替换为重载后的版本// 原本的 _async 函数因为不会和 _co_abc 重名所以不会报错// 而且因为所有调用它的地方都被替换为 _co_abc 了所以编译器也不会编译它// 直接调用 abc 也可以不过会退化为同步_asyncvoidabc(void*data){// xxx}// #include xx.coro// xx.coro 内部的翻译后的重载函数void_co_abc(structco_stu*father,structco_stu*co,void*data){// xxx// _await delay(100);// 替换为调用翻译后的版本_co_delay(co,NULL,100);// 出让与状态流转操作// xxx}3、调试黑魔法上面的小技巧虽然不用修改编译器无痛实现了 await 语法糖但是很显然实际运行的函数和你写的函数是两个那么调试的时候会跳转到另一个函数内此时就要请出宏魔法了#includestdio.hvoidfunc1(void);voidfunc2(void);intmain(void){printf(\nmain running...\n);func1();return0;}voidfunc1(void){#line21main.cprintf(\nfunc 1 running...\n);}voidfunc2(void){printf(\nfunc 2 running...\n);}如果你运行这段代码那么打印的肯定是func 1 running...但是一旦你逐行调试就会发现调试器会跳转到func2而非func1非常奇妙这一切都是靠#line这个宏实现的所以翻译脚本开启--line选项后部署效果会如下图给翻译前的代码打断点依然能停住并且还能看到相关变量的值不过因为翻译前后代码长短不同所以可能会有单步执行走过头的情况后续可能会优化#line的插入逻辑来改善。五、部署在第四节的方案选取中选择了一种侵入性最小的方案下面是简要部署流程将 github 上的库文件和转译脚本都克隆下来添加到工程目录根据开发环境确保在每个 c 文件编译前都会执行一次 python 脚本在需要协程的 c 文件内 include 协程库以及与该文件同名的带.coro与.coro.h后缀的文件移植调度器需要确保每个 tick 都会执行一次嘀嗒函数调度器需要放在 main 函数最后不同环境的详细部署方法可以参考仓库examples目录内的详细说明开源地址Github: TiX233/ctxGitee: TiX233/ctx以上