从零手写协程:用C语言和汇编实现上下文切换与调度器

发布时间:2026/10/21 18:41:53
从零手写协程:用C语言和汇编实现上下文切换与调度器 先给结论这个标题不是我突发奇想而是我实际花了一个晚上把协程从“会用”到“能写”完整走了一遍之后最想分享的一篇东西。先问个问题你印象里的协程是不是要么属于框架作者要么属于内核开发者大多数时候我们拿现成的调度器几行代码就创建了一个协程根本不觉得它神秘。但只要你愿意静下心把一个基于汇编的上下文切换函数亲手写出来就会发现问题没有想象中复杂。这篇博文要做的就是不借助任何第三方协程库只用系统原生的 C 语言加一小段汇编实现一个能创建协程、主动让出、自动收尾的最小编程库。我把它定位成“人人都可以写协程”因为核心代价只有一个理解函数调用栈和寄存器现场。适合想深入搞懂协程底层原理的开发者也适合被各种调度框架源码劝退、想先跑通最小实现的人。1. 动手之前先把协程的底裤看清楚1.1 协程到底是什么一个能暂停的函数最直白的理解普通函数是“一条道走到黑”的。调用函数时call 指令把返回地址压栈然后跳进函数体函数执行完ret 指令把返回地址从栈里弹出来跳回调用处继续执行。这个过程中函数一旦开始跑调用方就只能干等。协程不一样。它可以跑着跑着主动停下来把“我在哪一行、栈上有什么、寄存器里是什么”全部保存好然后去执行另一个协程。那个被暂停的协程看起来就像被冻结了之后被重新激活时又精确地接着原来的位置继续往下跑。这就像你看书看到某页夹了个书签下次翻开书直接继续读而不是重新从头读。核心是“现场保存与恢复”。函数调用天然依赖返回地址栈而协程有多个执行流每个执行流有自己的栈和现场。从这个角度看协程本质上是在用户态手动完成了内核线程调度中一部分工作切栈、切寄存器、切指令指针。它不需要陷入内核不需要操作系统感知所以才可能用一小段汇编做出来。1.2 为什么非要用汇编C 语言做不了吗我见过不少人第一反应是能不能用 C 语言本身实现上下文切换答案是很难做到足够通用。C 标准库里的 setjmp / longjmp 确实能保存一些现场也能做非局部跳转但它无法创建互相独立的调用栈更无法重新进入一个已经“跳走”的栈帧。你需要的不只是“跳转”而是“切栈”。跳到另一个栈上继续执行再在某个时刻带着原寄存器状态跳回来。这已经超出 C 语言抽象层的表达能力。另一个思路是直接用系统提供的 ucontext 相关接口但那些接口的底层实现同样是一段精心写好的汇编。换句话说只要你想彻底摊开看最终都会遇到汇编。我自己选择从汇编写起还有个原因是上下文切换必须精确控制栈指针和指令指针而 C 编译器可能在函数体内任意调整寄存器用法、插入栈操作你没法在源码层面保证“切换点”不会被优化掉。汇编虽然原始但每一步都可控。1.3 先定一个不复杂但完整的目标为了让这篇文章可以落地我给自己定的目标非常小支持创建协程协程体是一个普通的 C 函数协程内可以调用 coroutine_yield 主动让出 CPU协程函数返回后自动进入“已结束”状态用一个极简调度器在多个协程间轮转不依赖任何第三方库只有一个汇编函数作为上下文切换内核。平台就选最常见的 x86-64 环境汇编语法采用 GNU 汇编器默认风格也就是寄存器名带 % 前缀的那种老派写法。讲解时我不会往底层寄存器细节里钻太深而是把每个步骤的意图说清楚。2. 核心难点一条能切换现场的汇编函数2.1 现场是什么到底要保存哪几个寄存器切换协程第一步是回答“现场由什么组成”。在一个普通函数执行过程中影响后续执行的首先是栈指针 RSP其次是帧指针 RBP再然后是一些通用寄存器。但 x86-64 平台有一个非常重要的调用约定把寄存器分成了两类一类是“调用者保存”一类是“被调用者保存”。简单理解跨过一个函数调用之后调用者保存寄存器的值不被保证编译器如果想继续使用旧值就必须自己在调用前保存被调用者保存寄存器则不同任何一个被调函数内部如果动了它们负责在返回前恢复原样。这个约定对协程来说极其重要。因为 ctx_switch 这个函数调用对协程内的 C 代码而言就是一次普通函数调用。编译器在生成 yield 调用代码时会遵循同样的规则它不会假设 RAX、RCX、RDX、RSI、RDI 这些调用者保存寄存器在 ctx_switch 返回后还有旧值。所以我们只需要保存被调用者保存寄存器外加栈指针和指令指针。被调用者保存寄存器是 RBX、RBP、R12、R13、R14、R15加上 RSP 和当前返回地址一共 8 个 64 位值。如果你把浮点寄存器、向量寄存器也算进去会更完备但入门实现先不管它们跑通普通整数逻辑和字符串打印完全没问题。2.2 结构体设计让 C 语言能读写现场汇编负责保存和恢复寄存器但 C 层需要一份“现场描述”来存放这些值。我用一个结构体来存typedef struct { uint64_t rsp; uint64_t rbp; uint64_t rbx; uint64_t r12; uint64_t r13; uint64_t r14; uint64_t r15; uint64_t rip; } co_ctx;这里每个字段对应一个寄存器。RIP 是当前指令地址但请注意它的实际来源是栈上的返回地址。当一个协程调用 ctx_switch 让出时ctx_switch 函数入口的栈顶正好放着 call 指令压进去的返回地址也就是“切回来之后应该继续执行的那条指令”的地址。所以汇编里保存 RIP本质是在保存调用点。结构体字段的顺序很重要必须和汇编里使用的偏移量完全一致。我在项目里把 RSP 放在偏移 0RBP 放在偏移 8RBX 在偏移 16以此类推。汇编里写0(%rdi)、8(%rdi)就是在往这些槽位读写。2.3 ctx_switch 逐行拆解下面是完整的切换函数我用经典的“老式寄存器语法”写.text .globl ctx_switch ctx_switch: # 保存当前现场到 old_ctx movq %rsp, 0(%rdi) movq %rbp, 8(%rdi) movq %rbx, 16(%rdi) movq %r12, 24(%rdi) movq %r13, 32(%rdi) movq %r14, 40(%rdi) movq %r15, 48(%rdi) # 返回地址在栈顶 movq (%rsp), %rax movq %rax, 56(%rdi) # 恢复 next_ctx 的现场 movq 0(%rsi), %rsp movq 8(%rsi), %rbp movq 16(%rsi), %rbx movq 24(%rsi), %r12 movq 32(%rsi), %r13 movq 40(%rsi), %r14 movq 48(%rsi), %r15 # 跳转到目标指令地址 movq 56(%rsi), %rax jmpq *%rax调用约定中rdi 是第一个参数rsi 是第二个参数。调用 ctx_switch(old, new) 时rdi 指向旧现场rsi 指向新现场。函数先保存当前协程现场到 old再恢复 new 里的现场。为什么用 jmp 而不是 ret因为恢复现场时RSP 已经被改成了另一个协程的栈顶。如果这时候执行 ret它会再从“新 RSP”位置弹一个返回地址而这不是我们想要的行为。我们想要的是直接跳到目标地址。jmpq *%rax 可以做到这一点。再想一步rdi、rsi 这两个寄存器本身没有保存和恢复也不需要。因为它们属于调用者保存寄存器协程内的 C 代码不会指望跨过 ctx_switch 调用后这两个寄存器还保留原值。这个契约只要编译器遵守就能稳定工作。2.4 红区和栈对齐两个容易翻车的细节x86-64 的调用约定里还有一个容易被忽略的概念叫“红区”。它允许函数在 RSP 下方 128 字节的区域内自由使用临时存储不需要主动分配栈空间。绝大多数 C 编译器都会悄悄利用这个区域。这带来一个提示在我们的汇编切换函数里除了必要的栈操作不要随手往 RSP 下方写东西否则可能覆盖编译器放在红区里的临时数据。另一个坑是栈对齐。x86-64 平台的主流调用约定要求在 call 指令执行前RSP 需要按 16 字节对齐当被调函数入口执行时因为 call 已经压入了 8 字节返回地址所以 RSP 对 16 取模的结果应该是 8。如果你初始化协程栈时没有模拟这个状态协程内部的第一个 printf 或者别的标准库函数可能在某些编译选项下随机崩溃。这种事情最容易出现在“第一个协程一切正常第二个协程在莫名其妙的地方崩掉”的场景里。3. 把切换函数变成能用的协程库3.1 数据结构与 API有了 ctx_switch 这个心脏接下来就是把它组织成协程库。我做了一个极简版本所有协程放在一个固定数组里用状态字段区分空闲、就绪、运行中、已结束。#define MAX_CO 16 typedef void (*coroutine_func)(void *arg); typedef struct { co_ctx ctx; void *stack_base; size_t stack_size; coroutine_func fn; void *arg; int state; // 0DEAD 1READY 2RUNNING } cothread_t; static cothread_t pool[MAX_CO]; static int pool_count; static int current_id -1; // -1 表示当前在主上下文中 static co_ctx main_ctx;对外只暴露三个接口coroutine_create注册一个协程coroutine_yield让出 CPU由调度器选择下一个协程coroutine_poll从主上下文主动跑一个就绪协程。为什么用固定数组而不是链表因为初学者更容易理解环形数组的轮转逻辑。固定数组还有额外好处就算协程结束它的槽位暂时不会被内存反复分配释放干扰。3.2 新协程如何“凭空”诞生一个新协程没有任何历史现场它凭什么能在 ctx_switch 里被恢复出来答案是伪造一份现场让它看起来像是“已经被调用过、正等着继续执行”。做法是给新协程分配一块栈然后计算一个符合调用约定的 RSP 初始值。在创建函数里我这样写uintptr_t sp (uintptr_t)co-stack_base co-stack_size; sp ~0xFULL; // 16 字节对齐 sp - 8; // 模拟刚被 call 进入时的 RSP 状态 co-ctx.rsp sp; co-ctx.rbp 0; co-ctx.rbx 0; co-ctx.r12 0; co-ctx.r13 0; co-ctx.r14 0; co-ctx.r15 0; co-ctx.rip (uintptr_t)coroutine_stub;把 ctx.rip 直接指向 coroutine_stub而不是用户函数本身。因为用户函数需要参数而参数要放在 rdi 寄存器里ctx_switch 恢复现场时并不会主动设置 rdi。所以在用户函数外面套一层 stub让这段 C 代码主动去取参数static void coroutine_stub(void) { int id current_id; pool[id].fn(pool[id].arg); coroutine_exit_handle(); }这样的好处是协程体哪怕在内部 yield 过、被调度器切走、又被切回来最后返回时依然会走到 coroutine_exit_handle自动完成收尾。用户不用记得手动调用“退出”函数不会漏。3.3 调度器一个最朴素的轮转队列yield 的逻辑本身非常简单就是把当前协程标记为就绪然后在就绪队列里找一个下一个要运行的协程切过去。如果找不到其他就绪协程就切回主上下文。void coroutine_yield(void) { if (current_id 0) return; int old current_id; pool[old].state 1; // READY int next sched_next(old); if (next 0) { current_id -1; ctx_switch(pool[old].ctx, main_ctx); return; } current_id next; pool[next].state 2; // RUNNING ctx_switch(pool[old].ctx, pool[next].ctx); // 从其他协程切回来时从这里继续执行 pool[old].state 2; // RUNNING }这段代码里的状态变化值得多看两眼。第一次进入 ctx_switch 时把当前协程 A 的现场保存到 A.ctx恢复协程 B 的现场并跳到 B。B 得到运行。当 B 后续 yield 并选择切回 A 时ctx_switch 恢复 A 的现场让 A 从刚才 ctx_switch 之后的下一行继续执行。A 的局部变量、栈、指令位置全部还原。一个协程结束后会走进 coroutine_exit_handlestatic void coroutine_exit_handle(void) { int old current_id; pool[old].state 0; // DEAD int next sched_next(old); if (next 0) { current_id -1; ctx_switch(pool[old].ctx, main_ctx); return; } current_id next; pool[next].state 2; ctx_switch(pool[old].ctx, pool[next].ctx); __builtin_unreachable(); }sched_next 就是从当前下标往后扫描一圈找第一个 READY 协程。协程结束后调度器不会再把 DEAD 的协程视为可运行对象。3.4 一个可直接运行的完整示例写两个会交替输出的协程能直观看到协作式调度的效果void worker_a(void *arg) { int *n (int *)arg; for (int i 0; i 3; i) { printf(A: %d (arg%d)\n, i, *n); coroutine_yield(); } } void worker_b(void *arg) { for (int i 0; i 3; i) { printf(B: %d\n, i); coroutine_yield(); } } int main(void) { int val 7; coroutine_create(worker_a, val, 64 * 1024); coroutine_create(worker_b, NULL, 64 * 1024); while (coroutine_poll()) { /* 只要还有就绪协程就继续调度 */ } return 0; }coroutine_poll 在主上下文中选一个就绪协程运行等到它 yield 回主上下文就返回。所以主循环会反复调用 poll直到所有协程都进入 DEAD 状态。预期输出类似这样A: 0 (arg7) B: 0 A: 1 (arg7) B: 1 A: 2 (arg7) B: 2需要强调这不是“同时运行”而是严格交替运行。它们都在同一个系统线程里同一时刻只有一个协程在执行这是协作式调度的核心。4. 把工程编译起来并解决实际坑4.1 工程文件结构与构建脚本我把完整工程分成四个文件头文件、主实现、汇编切换函数、演示程序。结构如下cothread.h cothread.c ctx_switch.S main.c其中 ctx_switch.S 就是第二节里那段汇编。用构建脚本组织起来CC cc CFLAGS -O1 -g -Wall TARGET cothread_demo OBJS main.o cothread.o ctx_switch.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $(TARGET) $(OBJS) ctx_switch.o: ctx_switch.S $(CC) $(CFLAGS) -c ctx_switch.S -o ctx_switch.o main.o: main.c cothread.h $(CC) $(CFLAGS) -c main.c -o main.o cothread.o: cothread.c cothread.h $(CC) $(CFLAGS) -c cothread.c -o cothread.o clean: rm -f $(TARGET) $(OBJS)编译过程其实就是三条编译命令加一条链接命令和普通 C 工程没有区别。汇编文件不要放错后缀最好用大写 .S这样预处理阶段可以正确处理宏如果写成小写 .s某些环境会跳过预处理。4.2 我踩过的编译与链接坑第一个坑是符号对不上。C 代码里声明了 extern void ctx_switch(co_ctx *, co_ctx *)但汇编文件里如果符号名写错或者用了 Intel 语法而没有正确标识链接阶段会报 undefined reference。检查最快的办法是先把汇编文件单独编译成目标文件再用系统自带的符号导出工具看里面是否导出了 ctx_switch 符号。第二个坑是优化级别。汇编切换函数本身不受编译器优化影响但 C 调度器在较高优化级别下可能对 current_id 这类全局变量做激进优化。如果发现切换回去后状态值不对可以先开 -O0 验证确认逻辑没问题再逐步提升优化级别。第三个坑比较隐蔽协程栈大小。我选了 64KB跑简单 demo 足够。如果你在协程里放很大的局部数组或者写无保护递归爆栈就是一瞬间的事。栈溢出的表现通常是协程运行一小段后突然段错误而且每次崩溃位置可能还不一样。4.3 调试协程现场的小技巧调试这种协程最大的困惑是调用栈跳来跳去。当你从协程 A 切到协程 B再看 backtrace可能显示的是 main 函数或者某个看起来不相关的栈帧而不是协程 A 的调用栈。这不是汇编实现错了是因为多个栈不连续调试器默认只认识当前栈。我实际调试时喜欢在 ctx_switch 的汇编里临时加断点观察每次切换前后的 RSP 和 RIP。确认恢复现场后 RSP 落在一个合法栈范围内、RIP 落在预期函数里就说明切换管道的核心是好的。如果协程内部解释是乱码先检查 RSP 是否对 16 字节对齐。这个检查一两分钟就能定位掉一大批莫名崩溃。5. 常见问题与排查技巧速查表现象可能原因处理建议第一个协程一运行就段错误栈初始化不对或者 ctx.rip 指向了空地址检查创建时 sp 计算确认 rsp 落在分配栈内协程里调用 printf 随机崩溃RSP 没有按 16 字节对齐用调试器看入口时 rsp 对 16 取模是否为 8两个协程交替几次后状态乱套切换回来后没有把协程重新标记为 RUNNING在 ctx_switch 返回后重新设置当前协程 state协程结束后再调度同一个协程DEAD 协程被误当成 READY 调度检查 sched_next 是否严格过滤 state READY栈溢出但无明显报错协程栈太小或递归过深增大栈体积或在栈底设置哨兵模式协程里浮点运算结果偶尔不对没有保存 XMM 向量寄存器入门实现可接受生产环境需要扩展现场保存多个协程共享 errno 值互相干扰errno 是线程局部状态协程在同一线程内共享避免在协程中跨 yield 依赖 errno我再补一个非常实际的排查流程。遇到诡异崩溃时先在看得到的地方打印协程 id、rsp、rip然后在 ctx_switch 入口和出口分别记录现场。如果保存现场值和恢复现场值一致问题多半出在调度器状态机而不是汇编反之问题多半出在汇编偏移量或栈布局。用这个流程我把此前遇到的一个“第二个协程必崩”的 bug 定位到了创建协程时的栈对齐。说到栈溢出我在单独测试时用过一个小技巧给每个协程的栈底填充固定模式字节比如 0xAA协程跑一段时间后检查这些字节有没有被改写。如果被改写说明栈已经溢出了。这个技巧比在崩溃后瞎猜高效得多。正规产品还会用内存保护页但学习阶段用哨兵模式完全够。还有一点容易被忽略协程与线程不同所有协程共享同一个线程里的 errno、局部线程存储和某些标准库内部状态。printf 本身是线程安全的但在你的最小实现里如果两个协程同时处理同一个临时文件描述符你要自己负责同步。不要指望协程调度能替代锁。写完这套东西之后我最想说的是亲手写完这个最小协程库我对“函数调用栈”的认知发生了根本性变化。以前理解 ABI、被调用者保存寄存器、16 字节对齐都是书本上的教条当这些概念直接决定我的程序崩不崩时才真正变成肌肉记忆。如果让我给后来者一个建议那就是不要一上来就追求抢占式调度、多核运行、事件循环。先拿一个汇编切换函数加一个数组队列把两个协程跑通你就已经掌握了协程最核心的秘密。之后的每个特性都是在“上下文切换 调度策略”这两个方向上做加法。最后分享一个小技巧调试协程库的时候在 ctx_switch 汇编函数入口处记下 old_ctx 的地址做成一个环形日志每次切换都记录来源和目标。崩溃后翻这个日志你会比任何打印语句都更快定位到问题发生在哪一次切换。这个办法帮我解决过至少三个“看起来完全随机”的段错误。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询