手搓协程调度器:一个 flag、一个定时器、一个轮询器
相关信息
这篇文章最初只是脑袋神游时的一个念头,后来和 Codex 讨论了两个分叉口才定下来形状:协程模型用“跑完再返回”的 A 型(栈式 B 型只当展望),等待只用定时器。它还没落地到我那个 Limine C 模板的玩具内核里,所以放在文章区,当过程记录。
序
某天神游:为每个协程定义一个结构体,里面有一个 flag,标记这个协程是在跑、在等、在睡还是跑完了;一个定时器,靠 ISR 计时;一个全局轮询器,挨个问这些协程“你醒了吗?能跑了吗?”。
凭感觉想的。但越想越觉得这个形状眼熟。
后来反应过来了:这不就是一个迷你调度器吗。FreeRTOS 的 TCB、xv6 的 trapframe、Embassy 的 task,全是同一个东西换皮。区别只在于 flag 有多少位、定时器有多准、轮询器有没有脑子。
一、先起名字
“flag”太含糊,叫 state 更准确。它其实是个枚举:
typedef enum {
TASK_READY, // 可以跑
TASK_RUNNING, // 正在跑
TASK_SLEEPING, // 在睡,等定时器
TASK_DONE, // 跑完了
} TaskState;讨论时纠结过“等”和“睡”是不是一回事。结论是:因为现在只等定时器,一个 SLEEPING 状态就够用了。将来要是想等 GPIO、串口、DMA 完成这类事件,就得再拆一个 WAITING,并且结构体里还得多一个字段记录“我在等什么”,不然事件来了,调度器也不知道该叫醒谁。
二、协程长什么样
模型 A 的核心约定:协程要么跑完,要么跑到一个让出点就返回。让出点上的“挂起”只是一次函数返回,回来报告一声进展。
typedef struct {
void (*fn)(void *ctx); // 协程体
void *ctx; // 协程私有数据
TaskState state;
uint64_t wakeup_tick; // 睡到哪个 tick
} Coroutine;TASK_RUNNING 这个状态其实非常短暂:只有轮询器调用 fn 的那一瞬间成立。函数跑完自己改状态:保持 READY 就下一轮继续,改成 SLEEPING 就记下醒来的时刻,改成 DONE 就退休。
协程体长这样,比如一个闪烁 LED 的协程:
void led_blink(void *ctx) {
LedCtx *c = ctx;
gpio_toggle(c->pin);
co_sleep_ms(c->period / 2); // 设置 wakeup_tick,把 state 改成 SLEEPING
}它每次被调用只做一件事,然后回去睡。这就是 run-to-completion 状态机。好处是零上下文切换开销,坏处也很直白:一个流程想“卡在中间等一会儿再继续”,得自己拆状态,写起来没有线性代码舒服。
三、定时器 ISR
用 SysTick 做 1 ms 的 tick,中断服务函数只做一件事:
volatile uint64_t g_tick;
void SysTick_Handler(void) {
g_tick++;
}睡和醒的判定:
void co_sleep_ms(uint64_t ms) {
current->state = TASK_SLEEPING;
current->wakeup_tick = g_tick + ms;
}
// 调度器里判断是否到期
if (co->state == TASK_SLEEPING
&& (int64_t)(g_tick - co->wakeup_tick) >= 0) {
co->state = TASK_READY;
}两个细节:
- 别用
g_tick == co->wakeup_tick判到期。中断可能迟到、tick 可能被长时间关中断跳过,相等可能永远等不到。存“醒来时刻”,用无符号差值判断“过了没”,从根上绕开这个问题。 - ISR 里只做
tick++。叫醒协程是调度器的活,协程绝不放进中断里跑。这是嵌入式圈子的老规矩,也是这篇文章里唯一一条“必须”级别的原则。
四、轮询器
全局一个数组装所有协程,主循环挨个问:
for (;;) {
for (int i = 0; i < N; i++) {
Coroutine *co = &coros[i];
if (co->state == TASK_SLEEPING && tick_passed(co->wakeup_tick))
co->state = TASK_READY;
if (co->state == TASK_READY) {
co->state = TASK_RUNNING;
co->fn(co->ctx);
}
}
}就这么点东西。O(n) 全表轮询,协程数量少完全无所谓;多了再上 ready 队列,把“哪些能跑”从扫描变成维护。
但协作式调度有一条铁律:协程自己不主动让出,调度器永远轮不到别人。谁在协程里写个死循环,整个系统当场瘫痪。所以“长时间操作必须拆成多步、每步之间睡一觉”是硬约束,不拆就是拿整个系统陪葬。
五、两个小 demo
第一个:两个 LED 协程,一个 500 ms 周期、一个 300 ms 周期。各自 co_sleep_ms,轮询器每轮扫过去,自然就交错闪烁了,不需要任何优先级,谁先醒谁先跑,先后顺序由数组下标和 tick 决定。
第二个:按键消抖。流程是“检测到按下 → 睡 40 ms → 再确认电平 → 触发事件 → 等释放”。这就是一个典型的必须拆状态的流程:不拆,就得在协程里忙等 40 ms,把整个系统卡死。拆开之后,每次被调用只推进一小步:
void button_debounce(void *ctx) {
BtnCtx *b = ctx;
switch (b->step) {
case 0:
if (gpio_read(b->pin) == LOW) {
b->step = 1;
co_sleep_ms(40);
}
break;
case 1:
if (gpio_read(b->pin) == LOW) {
b->step = 2;
on_button_pressed(b->id); // 触发事件
} else {
b->step = 0; // 抖动,重置
}
break;
case 2:
if (gpio_read(b->pin) == HIGH) b->step = 0;
break;
}
}状态机写起来啰嗦,但每个分支都是 O(1),系统永远不会为了一个按键卡住。
六、B 型协程的展望
模型 A 不用保存寄存器,因为函数返回就等于放弃 CPU。B 型想在任意位置挂起,就必须补三样东西:
- 每个协程一块独立栈;
- 切走时保存 SP 和通用寄存器;
- 调度器会做真正的 context switch。
到这里,这套“结构体 + flag + 轮询器”已经变成线程切换的雏形,还要面对栈溢出、栈大小怎么定这些新麻烦。把 A 型吃透之后手痒,再去碰 setjmp/longjmp 或者 ucontext 也不迟。
七、和 Embassy 的关系
Rust 的 async/await 就是编译器自动生成的 A 型状态机:每个 async 函数被编译成一个结构体,保存局部变量和推进到的位置,每次 poll 往前走一步。Embassy 的 executor 就是那个轮询器,只是它更聪明:waker 让协程“事件到了才被唤醒”,没有事做的时候处理器直接 wfi 睡觉,而不是空转扫描。
自己手写一遍这个 flag + 定时器 + 轮询器,再回头看 Embassy 的源码,很多当时看不懂的设计(任务列表、waker、定时器队列)会突然有实感。它把我想的这个形状优化到了极致,仅此而已。
八、为什么放在文章区
因为这东西还没落地。它现在既不在我的玩具内核里,也不在任何能跑的项目里,只是脑袋里的一团形状和纸上的一段草稿。文章区本来就是放“过程”的地方,想明白的过程往往比最终代码更值得留。
后记
这个设计是神游出来的,没从教科书里背过。回头和 FreeRTOS 的 TCB 一比,形状几乎一样。操作系统里很多设计大概就长在人的直觉里,只要你不怕把它写出来、跑起来,再发现自己想错了。
就算最后发现想错了,这段折腾也不算白费,至少脑子里多了一个形状,下次神游还能拿它当起点。
