29、回调与C语言协程:利用回调实现简单的协程切换
协程,这个词听起来挺高大上的。
很多嵌入式开发者一听到「协程」,就觉得那是Python、Go这些高级语言才有的东西。C语言?不可能吧?
其实不然。C语言虽然没有原生协程关键字,但利用函数指针和回调机制,我们完全可以实现一个轻量级的协程调度器。我在一个资源受限的MCU项目里就干过这事——当时需要同时处理传感器轮询、LED闪烁和按键扫描,但又不想上RTOS,于是就用回调搭了个简易协程框架。
29.1 协程的本质是什么?
说白了,协程就是一个可以暂停、可以恢复执行的函数。
普通函数一旦调用,必须从头跑到尾。协程不一样——它可以在中间某个点「挂起」,把CPU让给别人,等时机合适了再「恢复」,从上次挂起的地方继续往下跑。
你想想看,这不就是回调函数的「延迟调用」思想吗?
核心要点:协程切换 = 保存当前执行状态 + 跳转到另一个协程执行 + 恢复之前的状态。
29.2 用回调模拟协程的基本思路
我习惯把每个协程看作一个「状态机 + 回调函数」的组合体。每个协程维护一个状态变量,回调函数根据当前状态决定执行哪段逻辑,执行完后更新状态,然后返回。
调度器呢?就是一个简单的循环,挨个调用这些回调函数。
举个例子:
#include <stdio.h>
// 协程状态
typedef enum {
CORO_STATE_INIT,
CORO_STATE_RUNNING,
CORO_STATE_SUSPENDED,
CORO_STATE_DONE
} coro_state_t;
// 协程控制块
typedef struct {
coro_state_t state;
int step; // 当前执行到哪一步
void (*entry)(void*); // 入口函数(其实用不上,但保留)
int (*handler)(struct coro_ctrl*); // 核心:回调函数
void* priv; // 私有数据
} coro_ctrl_t;
// 一个简单的协程:交替打印A和B
int coro_print_a(coro_ctrl_t* ctrl) {
switch (ctrl->step) {
case 0:
printf("[A] step 0\n");
ctrl->step = 1;
return 0; // 挂起,让出CPU
case 1:
printf("[A] step 1\n");
ctrl->step = 2;
return 0;
case 2:
printf("[A] done\n");
ctrl->state = CORO_STATE_DONE;
return 1; // 结束
default:
return 1;
}
}
int coro_print_b(coro_ctrl_t* ctrl) {
switch (ctrl->step) {
case 0:
printf("[B] step 0\n");
ctrl->step = 1;
return 0;
case 1:
printf("[B] step 1\n");
ctrl->step = 2;
return 0;
case 2:
printf("[B] done\n");
ctrl->state = CORO_STATE_DONE;
return 1;
default:
return 1;
}
}
// 调度器
void scheduler(coro_ctrl_t* tasks[], int n) {
int all_done = 0;
while (!all_done) {
all_done = 1;
for (int i = 0; i < n; i++) {
if (tasks[i]->state != CORO_STATE_DONE) {
all_done = 0;
tasks[i]->handler(tasks[i]); // 调用回调
}
}
}
}
int main() {
coro_ctrl_t task_a = {CORO_STATE_RUNNING, 0, NULL, coro_print_a, NULL};
coro_ctrl_t task_b = {CORO_STATE_RUNNING, 0, NULL, coro_print_b, NULL};
coro_ctrl_t* tasks[] = {&task_a, &task_b};
scheduler(tasks, 2);
return 0;
}
运行结果:
[A] step 0
[B] step 0
[A] step 1
[B] step 1
[A] done
[B] done
看到了吗?A和B交替执行,每次只跑一步。这就是最朴素的协程切换。
个人经验:我在一个温控器项目里用过类似模式。主循环里跑了5个协程:温度采集、PID计算、按键扫描、LCD刷新、看门狗喂食。每个协程只跑一小段就返回,整个系统看起来就像「同时」在做多件事。其实呢?就是回调函数轮流上场。
29.3 协程切换的三种模式
根据我的实践,回调式协程切换通常有三种实现方式:
| 模式 | 实现方式 | 适用场景 |
|---|---|---|
| 状态机模式 | 用switch-case + step变量 | 简单任务,步骤固定 |
| 函数指针表模式 | 用函数指针数组,每个步骤一个函数 | 步骤较多,逻辑复杂 |
| 栈切换模式 | 保存/恢复CPU寄存器上下文 | 需要真正抢占式切换 |
第一种模式就是上面代码展示的,最简单,也最常用。第二种模式其实是对第一种的升级——把每个case里的代码块拆成独立的函数,用函数指针数组来索引。第三种模式就复杂了,需要汇编操作栈指针,我一般不建议在普通嵌入式项目里用,除非你真的需要。
我曾经踩过的坑:有一次我试图用setjmp/longjmp实现协程切换,结果在ARM Cortex-M3上跑出了硬fault。原因是longjmp会恢复所有寄存器,但如果你在中断里调用它,栈帧就乱套了。后来我老老实实用状态机模式,虽然代码看起来没那么「酷」,但稳定多了。
29.4 回调协程的调度策略
调度器怎么决定下一个跑哪个协程?我常用的策略有三种:
- 轮询调度:挨个调用,每个协程跑一步。公平,但效率低。
- 优先级调度:给每个协程一个优先级,调度器先跑优先级高的。适合实时性要求不同的场景。
- 事件驱动调度:协程只在特定事件发生时被调度。比如按键按下才唤醒按键处理协程。
我个人最喜欢事件驱动+优先级混合调度。平时所有协程都挂起,只有事件来了才唤醒对应的协程。这样CPU空闲时可以睡大觉,省电。
29.5 协程间的数据传递
协程之间怎么通信?回调函数返回后,数据怎么传给下一个协程?
我的做法是:用一个全局的「消息队列」或者共享结构体。每个协程的私有数据指针priv可以指向这个共享区。
// 共享数据区
typedef struct {
int sensor_value;
int button_state;
int lcd_buffer[16];
} shared_data_t;
// 每个协程的priv都指向同一个shared_data_t实例
coro_ctrl_t task_sensor = {..., .priv = &shared};
coro_ctrl_t task_button = {..., .priv = &shared};
coro_ctrl_t task_lcd = {..., .priv = &shared};
这样,传感器协程把数据写进去,LCD协程读出来显示。回调函数之间通过共享内存通信,简单高效。
注意:共享数据要加锁吗?在单线程的协程调度里,不需要。因为同一时刻只有一个协程在跑,不存在竞态条件。但如果你在中断里也访问这些数据,那就得小心了——关中断或者用原子操作。
29.6 一个更完整的例子:LED闪烁 + 按键检测
下面这个例子,我实际在STM32上跑过。两个协程:一个让LED以500ms间隔闪烁,一个检测按键按下次数。
#include <stdio.h>
#include <unistd.h> // 模拟延时
// 协程控制块(同上)
typedef struct {
int step;
int (*handler)(struct coro_ctrl*);
void* priv;
int done;
} coro_ctrl_t;
// LED协程
typedef struct {
int led_state;
int tick;
} led_priv_t;
int led_task(coro_ctrl_t* ctrl) {
led_priv_t* p = (led_priv_t*)ctrl->priv;
switch (ctrl->step) {
case 0:
p->led_state = 1;
printf("LED ON\n");
ctrl->step = 1;
p->tick = 0;
return 0;
case 1:
p->tick++;
if (p->tick >= 5) { // 模拟500ms
p->led_state = 0;
printf("LED OFF\n");
ctrl->step = 2;
p->tick = 0;
}
return 0;
case 2:
p->tick++;
if (p->tick >= 5) {
ctrl->step = 0; // 循环
p->tick = 0;
}
return 0;
default:
return 1;
}
}
// 按键协程
typedef struct {
int press_count;
int last_state;
} btn_priv_t;
int button_task(coro_ctrl_t* ctrl) {
btn_priv_t* p = (btn_priv_t*)ctrl->priv;
// 模拟按键检测
static int simulated_button = 0;
simulated_button = (simulated_button + 1) % 3; // 模拟按下/释放
if (simulated_button == 1 && p->last_state == 0) {
p->press_count++;
printf("Button pressed! count=%d\n", p->press_count);
}
p->last_state = simulated_button;
return 0;
}
// 调度器
void scheduler(coro_ctrl_t* tasks[], int n) {
while (1) {
for (int i = 0; i < n; i++) {
if (!tasks[i]->done) {
tasks[i]->handler(tasks[i]);
}
}
usleep(100000); // 100ms调度一次
}
}
int main() {
led_priv_t led = {0, 0};
btn_priv_t btn = {0, 0};
coro_ctrl_t task_led = {0, led_task, &led, 0};
coro_ctrl_t task_btn = {0, button_task, &btn, 0};
coro_ctrl_t* tasks[] = {&task_led, &task_btn};
scheduler(tasks, 2);
return 0;
}
这个例子展示了两个关键点:
- 每个协程通过
priv维护自己的私有状态 - 调度器以固定时间片轮询,模拟了「并发」效果
29.7 回调协程的优缺点
用回调实现协程,好处很明显:
- 不需要额外栈空间,每个协程只占几个字节
- 没有上下文切换开销,就是一次函数调用
- 代码完全可移植,不依赖任何硬件特性
缺点也有:
- 每个协程必须手动管理step,代码看起来像面条
- 不能嵌套调用——你在协程里调用另一个协程?那step就乱套了
- 没有自动的局部变量保存,所有状态都得放到priv结构体里
我的建议:如果你的项目里只有3-5个简单任务,而且每个任务的状态机不超过10个状态,那回调协程完全够用。如果任务多了、状态复杂了,还是老老实实上RTOS吧。别硬撑。
29.8 知识体系图
下面这张图总结了回调协程的核心逻辑:
29.9 写在最后
回调实现协程,本质上就是「用状态机模拟控制流」。它不完美,但在资源受限的嵌入式环境里,它是一个非常实用的轻量级方案。
我见过有人用这个模式在8位MCU上跑了十几个「伪线程」,效果出奇的好。也见过有人硬要在Cortex-M4上搞完整协程库,结果代码量比RTOS还大,得不偿失。
工具没有好坏,关键看你怎么用。回调协程就是一把小刀,切水果够用,砍树就别勉强了。
一句话总结:回调 + 状态机 = 轻量协程。适合任务少、状态简单的嵌入式场景。如果你需要更强大的协程支持,可以考虑Protothreads或者Contiki的协程库,它们也是基于类似思想实现的。
公众号:蓝海资料掘金营,微信deep3321