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 知识体系图

下面这张图总结了回调协程的核心逻辑:

回调协程核心逻辑 调度器 (Scheduler) 协程A (回调函数) 协程B (回调函数) 协程C (回调函数) 状态机 (step管理) switch-case 分支 私有数据 priv 状态机 (step管理) switch-case 分支 私有数据 priv 状态机 (step管理) switch-case 分支 私有数据 priv 返回 (挂起/结束) 调度器循环调用回调 → 回调执行一步后返回 → 调度器继续下一个 每个协程通过 step 变量记住执行位置,实现「挂起/恢复」

29.9 写在最后

回调实现协程,本质上就是「用状态机模拟控制流」。它不完美,但在资源受限的嵌入式环境里,它是一个非常实用的轻量级方案。

我见过有人用这个模式在8位MCU上跑了十几个「伪线程」,效果出奇的好。也见过有人硬要在Cortex-M4上搞完整协程库,结果代码量比RTOS还大,得不偿失。

工具没有好坏,关键看你怎么用。回调协程就是一把小刀,切水果够用,砍树就别勉强了。

一句话总结:回调 + 状态机 = 轻量协程。适合任务少、状态简单的嵌入式场景。如果你需要更强大的协程支持,可以考虑Protothreads或者Contiki的协程库,它们也是基于类似思想实现的。


公众号:蓝海资料掘金营,微信deep3321