3、setjmp/longjmp 实战:实现一个简单的非局部跳转,模拟协程切换

说实话,很多搞嵌入式的人一听到「协程」就觉得高大上。其实没那么玄乎。

协程切换的核心,说白了就是一件事:保存当前执行现场,跳转到另一个执行现场。而 C 标准库里的 setjmplongjmp,恰好就是干这个的。

我当年第一次接触协程,就是在做一个资源受限的 MCU 项目。那时候没有 RTOS,又想实现多任务,硬是用 setjmp/longjmp 手搓了一个微型调度器。嗯,虽然简陋,但跑得还挺稳。

3.1 setjmp/longjmp 到底干了什么?

先看两个函数的原型:

#include <setjmp.h>

int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);

setjmp 会保存当前的「执行上下文」——包括程序计数器、栈指针、寄存器等——到 env 里。然后返回 0。

longjmp 则干相反的事:它从 env 恢复之前保存的上下文,然后跳回去。注意,longjmp 不会返回,它直接让程序流回到 setjmp 的位置。这时候 setjmp 的返回值就是 longjmp 传进去的 val

关键点setjmp 可以被调用两次——第一次是正常调用,返回 0;第二次是被 longjmp 跳回来的,返回非 0 值。这个特性,就是协程切换的基石。

你想想看,如果我们在一个函数里调用 setjmp,保存现场,然后跳转到另一个函数去执行。等另一个函数执行到某个点,再 longjmp 回来——这不就是协程的 yield 和 resume 吗?

3.2 一个最简单的非局部跳转示例

先别急着谈协程,我们从一个最基础的例子开始。

#include <stdio.h>
#include <setjmp.h>

jmp_buf env;

void func_b() {
    printf("进入 func_b\n");
    longjmp(env, 42);  // 跳回 setjmp 处,返回值为 42
    printf("这行永远不会执行\n");
}

int main() {
    int ret = setjmp(env);
    if (ret == 0) {
        printf("第一次调用 setjmp,ret = %d\n", ret);
        func_b();
    } else {
        printf("从 longjmp 跳回来了,ret = %d\n", ret);
    }
    return 0;
}

运行结果:

第一次调用 setjmp,ret = 0
进入 func_b
从 longjmp 跳回来了,ret = 42

看到了吗?func_b 里的 longjmp 直接让程序跳回了 main 里的 setjmp 位置。而且 func_blongjmp 后面的代码根本没机会执行。

个人习惯:我一般用 longjmp 的第二个参数来传递状态码。0 表示正常返回,非 0 表示异常或切换。这样在 setjmp 的返回值里就能区分是第一次进入还是被跳回。

3.3 用 setjmp/longjmp 模拟协程切换

好,现在我们来点实战。假设我们有两个「协程」:一个打印奇数,一个打印偶数。它们交替执行,每次打印一个数后主动让出 CPU。

#include <stdio.h>
#include <setjmp.h>

#define MAX_COROUTINES 2

jmp_buf main_env;
jmp_buf coro_env[MAX_COROUTINES];
int current = 0;

void coroutine_even() {
    int i;
    for (i = 0; i <= 10; i += 2) {
        printf("偶数协程: %d\n", i);
        if (!setjmp(coro_env[0])) {
            longjmp(main_env, 1);  // 让出 CPU,回到主循环
        }
    }
    // 结束前跳回主循环,不再回来
    longjmp(main_env, 0);
}

void coroutine_odd() {
    int i;
    for (i = 1; i <= 9; i += 2) {
        printf("奇数协程: %d\n", i);
        if (!setjmp(coro_env[1])) {
            longjmp(main_env, 1);  // 让出 CPU
        }
    }
    longjmp(main_env, 0);
}

int main() {
    printf("开始协程切换演示\n");

    // 初始化:先保存主循环的上下文
    if (setjmp(main_env) == 0) {
        // 第一次进入,启动协程 0
        current = 0;
        coroutine_even();
    } else {
        // 被 longjmp 跳回,切换协程
        current = 1 - current;  // 0 -> 1, 1 -> 0
        if (current == 0) {
            longjmp(coro_env[0], 1);
        } else {
            longjmp(coro_env[1], 1);
        }
    }

    printf("所有协程执行完毕\n");
    return 0;
}

这个例子虽然简单,但已经具备了协程调度的雏形:

  • 每个协程有自己的 jmp_buf,保存自己的执行现场
  • 协程内部用 setjmp 保存自己的断点,然后用 longjmp 跳回主调度器
  • 主调度器用 setjmp 保存自己的位置,然后根据当前协程 ID 决定跳转到哪个协程

我曾经踩过的坑setjmp 保存的上下文里,局部变量的值是不确定的。如果你在 setjmp 之后修改了局部变量,然后 longjmp 跳回来,这些变量的值可能不是你想的那样。解决办法是:把需要跨跳转保留的变量声明为 volatile

3.4 核心逻辑流程图

下面这张图展示了 setjmp/longjmp 模拟协程切换的完整流程。我建议你仔细看看,尤其是箭头的走向。

主调度器 (main) 协程 0 (偶数) 协程 1 (奇数) setjmp(main_env) setjmp(coro_env[0]) setjmp(coro_env[1]) longjmp(main_env) longjmp(coro_env[0]) longjmp(coro_env[1]) 启动协程0 yield 回到主 切换协程1 yield 回到主 循环直到结束

从图里可以看得很清楚:主调度器先启动协程 0,协程 0 打印一个数后 yield 回主调度器;主调度器再切换到协程 1,协程 1 打印一个数后 yield 回来。如此反复,直到两个协程都跑完。

3.5 这个方案的局限性

上面这个例子能跑,但离真正的协程库还差得远。我列几个实际项目中会遇到的问题:

问题 说明 影响
栈空间 所有协程共用同一个调用栈,嵌套调用会覆盖栈帧 不能实现真正的独立栈协程
局部变量 longjmp 跳回后,局部变量可能被优化掉 需要用 volatile 声明
不可重入 setjmp/longjmp 不是线程安全的 不能用于多线程环境
调度策略 上面例子是简单的轮转,没有优先级 需要自己实现调度算法

我建议:如果你只是想在裸机上实现简单的任务切换,setjmp/longjmp 完全够用。但如果你要做一个通用的协程库,还是得用汇编来操作栈指针和寄存器。后面章节我会讲到如何用汇编实现真正的协程切换。

3.6 避坑指南

最后分享几个我实际调试中遇到的坑:

  • 不要跨函数调用 longjmp 到已返回的函数:如果 setjmp 所在的函数已经返回了,你再 longjmp 回去,那就是未定义行为。程序大概率会崩溃。
  • 注意 volatile 关键字:在 setjmplongjmp 之间被修改的局部变量,如果没加 volatile,编译器可能优化掉你的修改。我吃过这个亏,调试了一下午才发现。
  • longjmp 不能用于信号处理函数:POSIX 标准明确禁止在信号处理函数里调用 longjmp。如果你需要,请用 sigsetjmp/siglongjmp

好了,这一章的内容就到这里。setjmp/longjmp 虽然简单,但它是理解协程切换机制的最佳起点。下一章我们会在此基础上,引入栈管理和调度器,实现一个真正可用的协程库。


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