3、setjmp/longjmp 实战:实现一个简单的非局部跳转,模拟协程切换
说实话,很多搞嵌入式的人一听到「协程」就觉得高大上。其实没那么玄乎。
协程切换的核心,说白了就是一件事:保存当前执行现场,跳转到另一个执行现场。而 C 标准库里的 setjmp 和 longjmp,恰好就是干这个的。
我当年第一次接触协程,就是在做一个资源受限的 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_b 里 longjmp 后面的代码根本没机会执行。
个人习惯:我一般用 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 模拟协程切换的完整流程。我建议你仔细看看,尤其是箭头的走向。
从图里可以看得很清楚:主调度器先启动协程 0,协程 0 打印一个数后 yield 回主调度器;主调度器再切换到协程 1,协程 1 打印一个数后 yield 回来。如此反复,直到两个协程都跑完。
3.5 这个方案的局限性
上面这个例子能跑,但离真正的协程库还差得远。我列几个实际项目中会遇到的问题:
| 问题 | 说明 | 影响 |
|---|---|---|
| 栈空间 | 所有协程共用同一个调用栈,嵌套调用会覆盖栈帧 | 不能实现真正的独立栈协程 |
| 局部变量 | longjmp 跳回后,局部变量可能被优化掉 | 需要用 volatile 声明 |
| 不可重入 | setjmp/longjmp 不是线程安全的 | 不能用于多线程环境 |
| 调度策略 | 上面例子是简单的轮转,没有优先级 | 需要自己实现调度算法 |
我建议:如果你只是想在裸机上实现简单的任务切换,setjmp/longjmp 完全够用。但如果你要做一个通用的协程库,还是得用汇编来操作栈指针和寄存器。后面章节我会讲到如何用汇编实现真正的协程切换。
3.6 避坑指南
最后分享几个我实际调试中遇到的坑:
- 不要跨函数调用 longjmp 到已返回的函数:如果
setjmp所在的函数已经返回了,你再longjmp回去,那就是未定义行为。程序大概率会崩溃。 - 注意 volatile 关键字:在
setjmp和longjmp之间被修改的局部变量,如果没加volatile,编译器可能优化掉你的修改。我吃过这个亏,调试了一下午才发现。 - longjmp 不能用于信号处理函数:POSIX 标准明确禁止在信号处理函数里调用
longjmp。如果你需要,请用sigsetjmp/siglongjmp。
好了,这一章的内容就到这里。setjmp/longjmp 虽然简单,但它是理解协程切换机制的最佳起点。下一章我们会在此基础上,引入栈管理和调度器,实现一个真正可用的协程库。
公众号:蓝海资料掘金营,微信deep3321