资讯动态

手写最小协程库:30行汇编实现上下文切换与调度

发布时间:2026/10/21 21:21:31 来源:尧图企业网站定制
协程这两个字听起来像是需要啃完一整本编译原理才敢碰的高级货汇编更不用说很多人第一反应是先跑为敬。但我这几年反复跟同事、徒弟讲同一个观点把协程和汇编放在一起反而会把两件事都拆穿。协程的核心动作只有一个——把当前执行到一半的现场保存下来下次再原样恢复汇编在这个故事里只干一件事保存寄存器、换栈指针。它不神秘更不需要你会写整个汇编程序。这篇文章把我自己写过的一个最小实现完整走一遍x86-64 下用 30 行左右的汇编做一个上下文切换再用不到 100 行 C 代码包一个能创建、能 yield、能调度的协程库。适合的人群很明确想搞懂 goroutine 底层为什么能挂起的开发者、面试被问到协程原理的求职者、以及所有对调用约定只有一个模糊概念但想亲手验证的程序员。跟着写完你会发现所谓协程原理其实就是计算机组成原理里现场保护那一节的实践版。1. 先想清楚协程到底在解决什么问题1.1 挂起与恢复本质是一次现场保护普通的函数调用是一条单行道main调foofoo调barbar返回后foo继续foo返回后main继续。每一步都严格嵌套每次返回都会把栈展开到调用前的状态。这套机制非常可靠但它有一个天然限制一个函数一旦开始执行就必须一口气跑完或者通过 return 提前退出中间没有暂停一下、下次接着跑的选项。协程打破的正是这个限制。协程可以运行到一半主动让出 CPU过一段时间后从让出的那一条指令继续执行而且所有局部变量、调用栈都保持原样。这里的关键词是现场。一个执行流的现场由两部分组成CPU 当前执行到哪条指令也就是 RIP以及栈当前长什么样也就是 RSP 加栈上的数据。函数调用靠压栈和弹栈天然地管理这两样东西而协程需要的是把这两样东西冷冻起来等下次再解冻。我打个比方普通函数是一本必须从头读到尾的书协程是一本带书签的书。想干别的就先夹上书签合上回头再从那一页接着读。汇编要做的就是帮你夹书签和翻回那一页。1.2 为什么不能靠回调硬拼有人会说异步逻辑我用回调不就行了确实可以但回调解决的是结果回来之后干什么解决不了调用栈去哪儿了。异步回调的经典困境是一个复杂的业务流程被拆成十几个回调函数每个回调都丢失了前一个上下文的调用栈状态只能靠全局变量或者参数传来传去。写多了之后代码就成了一锅粥。协程的价值在于它不拆你的逻辑。你可以把异步操作写成顺序代码发起请求、让出、等结果回来、继续往下写。底层由协程库负责保存和恢复现场。这个价值在 IO 密集场景里尤其明显。当然这篇文章不打算展开讨论异步框架的架构我只想传达一件事协程解决的是如何在单个线程里维护多个独立的调用栈。这个问题绕不开的就是栈和寄存器。1.3 三条路线对比setjmp、ucontext、自己写汇编想实现协程常见的路线有三条我把它们的取舍列个表方案保存内容能否换新栈缺点setjmp / longjmp寄存器 部分现场不能换栈属于未定义行为简单但不够用只适合错误恢复ucontext 系列接口完整上下文能makecontext 建栈涉及系统调用、接口重量级兼容性参差自己写汇编做切换6 个寄存器 rsp 返回地址能想怎么建就怎么建要自己处理对齐和红区但完全可控setjmp 看起来最省事但它是为错误回跳设计的它可以让你从深层嵌套里跳回之前的某个位置但它不负责给你一块新栈。你没法让一个 setjmp 出来的现场跑在另一个栈上。强行改栈指针属于未定义行为实测经常翻车。ucontext 是系统提供的完整方案能建新栈、能切换。但它为了通用性做了很多额外工作而且它走的是系统库的接口封装比较重。我在做教学 demo 时总是希望把每一行都讲清楚ucontext 的黑盒子属性不太符合需求。自己写汇编是最直白的一条路。因为一个挂起的 C 函数它的活状态在 ABI二进制接口约定里写得清清楚楚被调用者保存的寄存器加栈。把这几个寄存器保存好再把栈指针换掉就完成了协程切换。这就是全部原理没有更多玄机。2. 切换现场之前先弄懂现场长什么样2.1 一个函数调用栈上到底留下了什么在 x86-64 平台上一次函数调用的过程是这样的调用方先把参数放进寄存器然后执行call指令。call会做两件事把返回地址也就是call下一条指令的地址压到栈上然后跳转到被调函数的入口。被调函数如果还需要更多空间就通过修改 rsp 向下扩展栈帧。所以任何一个函数的栈帧里最低地址处一定有一个返回地址。函数执行完毕ret指令把这个地址弹出来CPU 跳回去。这就是调用链能够层层返回的物理基础。对于协程切换来说我们不需要走完整的一层层返回流程。我们要做的是把当前栈指针整体搬走换一个栈指针然后让 CPU 跳到一个已经保存好的位置。这个位置对老协程来说是之前让出时的那条指令对新协程来说是它的入口。2.2 调用约定哪些寄存器必须我来保存这里必须讲清楚一个概念C 语言的函数调用约定ABI里寄存器被分成两类。调用者保存的寄存器rax、rcx、rdx、rsi、rdi、r8-r11。函数调用方在调用前如果需要这些值必须先自己保存被调函数可以随意使用并覆盖它们。被调用者保存的寄存器rbx、rbp、r12-r15。被调函数如果要用它们必须先保存原值在返回前恢复。这个规则的本意是每个函数只需要在进出边界保存有限的寄存器其余的都靠谁用谁负责来分摊成本。这对我们协程库的意义极其重大。我们写的ctx_switch本质上就是一个普通函数调用只不过它永远不正常返回到原来的栈上。因此只要我们的汇编代码保存并恢复了被调用者保存的那些寄存器C 编译器就会认为协程的现场完整无损。至于调用者保存的寄存器编译器知道ctx_switch是一次函数调用它本来就不会把重要的值跨调用放在这些寄存器里而是会主动存到栈上或者重新计算。所以协程切换真正需要的就是保存rbx、rbp、r12、r13、r14、r15外加栈指针 rsp。指令指针 RIP 不需要额外保存因为它在栈上那个返回地址槽位里。我把这个对应关系列成表类别寄存器谁负责保存被调用者保存RBX, RBP, R12-R15协程切换代码也就是我们调用者保存RAX, RCX, RDX, RSI, RDI, R8-R11C 编译器自己处理栈指针RSP切换时显式保存和恢复指令指针RIP返回地址call/ret 指令隐式压栈和弹栈理解了这张表后面那 30 行汇编就完全没有理解障碍了。2.3 红区和 16 字节对齐为什么和协程有关有两个细节经常被新手忽略但它们恰恰是协程实现里最容易踩坑的地方。第一个是红区red zone。在 x86-64 ABI 里函数可以不用调整栈指针直接使用 rsp 向下 128 字节的区域。也就是说编译器可能生成代码往 rsp-128 到 rsp 这个范围内写数据而不让 rsp 本身发生变化。这通常是叶子函数的优化手段。在我们的协程场景里如果旧栈被完整保存红区自然安全但是新协程的初始栈必须给这个红区留出足够的空间否则协程第一次冷启动时可能写到未映射的内存上。第二个是 16 字节对齐。ABI 要求栈在函数调用边界上保持 16 字节对齐。编译器生成的 SSE 指令比如 movaps要求内存地址按 16 字节对齐如果栈指针不对齐这些指令会直接触发段错误。我们手工给新协程造初始栈时必须精确地算好每一个字节的位置。这两个问题我会在第 5 章详细展开这里先埋个伏笔。3. 手写上下文切换30 行汇编搞定核心3.1 定义上下文结构体先画好内存布局一个上下文需要保存 7 个东西6 个被调用者保存的寄存器外加栈指针。我用 C 定义一个结构体让它的内存布局和汇编代码里的偏移量一一对应struct ctx { void *rbx; // 偏移 0 void *rbp; // 偏移 8 void *r12; // 偏移 16 void *r13; // 偏移 24 void *r14; // 偏移 32 void *r15; // 偏移 40 void *rsp; // 偏移 48 };为什么要手动对齐偏移因为汇编里用的是movq %rbx, 0(%rdi)这种绝对偏移寻址C 结构体的字段顺序必须和汇编里的偏移严格一致。多一个字段、换一个顺序整个切换就乱套了。这种两份代码共享同一个内存布局的写法是系统编程里很常见的默契。3.2 ctx_switch 实现与ret 即跳转的魔法下面是完整的上下文切换代码放在一个.S文件里.text .globl ctx_switch .type ctx_switch, function # void ctx_switch(struct ctx *from, struct ctx *to) # rdi from 的指针rsi to 的指针 ctx_switch: # 保存当前上下文到 from movq %rbx, 0(%rdi) movq %rbp, 8(%rdi) movq %r12, 16(%rdi) movq %r13, 24(%rdi) movq %r14, 32(%rdi) movq %r15, 40(%rdi) movq %rsp, 48(%rdi) # 从 to 恢复上下文 movq 0(%rsi), %rbx movq 8(%rsi), %rbp movq 16(%rsi), %r12 movq 24(%rsi), %r13 movq 32(%rsi), %r14 movq 40(%rsi), %r15 movq 48(%rsi), %rsp ret .size ctx_switch, .-ctx_switch .section .note.GNU-stack,,progbits这段代码的核心就是最后一个ret。注意在恢复完 rsp 之后执行retCPU 弹出的返回地址不再是当前栈上的地址而是目标栈顶的地址。这个技巧是整个协程实现的心脏如果目标是挂起过的协程目标栈顶存放的是它之前调用ctx_switch时压入的返回地址ret会让它精确地回到让出后的下一条指令。如果目标是新创建的协程目标栈顶存放的是我们预先埋好的入口地址ret会直接跳进去。每次切换CPU 就像在两条不同的执行线之间跳了一次频道而每条线都有自己的栈、自己的返回链。3.3 新协程的初始栈伪造一个正要返回的现场新协程还没有调用过任何函数它的栈是空的但我们依然要让ctx_switch的ret能跳进它的入口。办法是手动往新栈上放一个假返回地址。假设协程栈是从堆上分配的一块连续内存地址从 stack_base 到 stack_base stack_size栈向下生长。我从高地址开始规划高地址 ------------------------ | | - 对齐后的栈顶 top | [slot8] 调度器指针 | | [slot] coro_entry | - 初始 rsp slot ------------------------ | 未使用的栈空间 | ------------------------ - stack_base 低地址当调度器第一次切换到新协程时ret从slot位置弹出coro_entry然后 rsp 变成slot 8CPU 跳进入口。入口处的汇编代码再从这个位置读取预埋的参数.text .globl coro_entry .type coro_entry, function # 新协程第一次被切换进来时rsp 指向预埋的调度器指针 coro_entry: movq 0(%rsp), %rdi # 取出调度器指针 subq $8, %rsp # 调整对齐保证 trampoline 入口满足 ABI call coroutine_trampoline ud2 # 正常情况下永远执行不到这里 .size coro_entry, .-coro_entry .section .note.GNU-stack,,progbits这里有一个很容易被忽略的对齐细节。coro_entry是被ret跳进来的这相当于函数入口处的栈状态rsp 应该是 8 模 16。但接下来它用call调用coroutine_trampoline时如果 rsp 保持 8 模 16 不变call再压一个返回地址后trampoline 入口的 rsp 就变成 0 模 16与 ABI 要求的 8 模 16 正好相反。所以我在call之前手动subq $8, %rsp把偏移纠正过来。这一步是我第一次写这个 demo 时被段错误教育出来的后面第 5 章还会再提到。4. 封装成协程库创建、切换、调度4.1 协程控制块、状态机和就绪队列有了最底层的ctx_switch剩下的就是纯 C 的包装。我先设计协程控制块enum co_status { CO_READY, // 就绪等待被调度 CO_RUNNING, // 正在运行 CO_DEAD // 已结束 }; struct coroutine { struct ctx ctx; void (*fn)(void *arg); // 协程要执行的函数 void *arg; void *stack_base; // 栈底低地址 size_t stack_size; int status; struct coroutine *next; // 就绪队列的 next 指针 };调度器本身维护一个 FIFO 就绪队列以及当前正在运行的协程指针struct scheduler { struct coroutine *ready_head; struct coroutine *ready_tail; struct coroutine *running; struct ctx main_ctx; // 主线程的上下文相当于调度器自己的工作现场 };这里有个巧妙的地方调度器自己并不需要单独的栈它复用主线程的栈。每次从协程切回调度器就是把现场保存到某个协程的 ctx 里然后恢复main_ctx。4.2 trampoline让结束的协程优雅退场协程函数是什么时候结束的我们没办法保证用户写的协程函数不会 return。如果它真的执行了ret会从栈上弹出一个不知道是什么的地址程序大概率崩溃。解决方法是引入一个蹦床 trampoline 函数协程真正执行的入口不是用户的 fn而是这个蹦床。蹦床调用 fn等 fn 返回后把协程标记为 DEAD再主动切回调度器。__attribute__((noreturn)) static void coroutine_trampoline(struct scheduler *s) { struct coroutine *co s-running; co-fn(co-arg); co-status CO_DEAD; s-running NULL; ctx_switch(co-ctx, s-main_ctx); __builtin_unreachable(); }你看这里ctx_switch的参数方向是从协程切到主线程。它的语义和调度器里的调用正好对称ctx_switch(A, B) 的意思就是把当前现场存进 A然后恢复 B。调度器切进协程是ctx_switch(s-main_ctx, co-ctx)协程主动让出是ctx_switch(co-ctx, s-main_ctx)来回两次调用就把两边的现场都保存好了。4.3 完整代码创建、让出、调度先看协程创建。它负责分配栈、往新栈上埋入口地址和参数、初始化上下文结构体并放进就绪队列struct coroutine *co_create(struct scheduler *s, void (*fn)(void *), void *arg, size_t stack_size) { struct coroutine *co calloc(1, sizeof(*co)); if (!co) return NULL; co-stack_base malloc(stack_size); if (!co-stack_base) { free(co); return NULL; } co-fn fn; co-arg arg; co-stack_size stack_size; co-status CO_READY; // 从高地址往下规划初始栈 uintptr_t top (uintptr_t)co-stack_base stack_size; top ~(uintptr_t)15; // 向下对齐到 16 字节 uintptr_t slot top - 16; // 预留入口地址 参数的位置 *(uintptr_t *)slot (uintptr_t)coro_entry; *(uintptr_t *)(slot 8) (uintptr_t)s; co-ctx.rsp (void *)slot; // 插入就绪队列尾部 if (s-ready_tail) { s-ready_tail-next co; s-ready_tail co; } else { s-ready_head s-ready_tail co; } return co; }让出操作很直接。当前协程把自己放回队列尾部然后切回主线程void co_yield(struct scheduler *s) { struct coroutine *co s-running; co-status CO_READY; // 重新入队 co-next NULL; if (s-ready_tail) { s-ready_tail-next co; s-ready_tail co; } else { s-ready_head s-ready_tail co; } s-running NULL; ctx_switch(co-ctx, s-main_ctx); }调度主循环从队头取一个协程切换进去等它让出或结束再回到这里void co_schedule(struct scheduler *s) { struct coroutine *co; while ((co s-ready_head) ! NULL) { // 出队 s-ready_head co-next; if (!s-ready_head) s-ready_tail NULL; s-running co; co-status CO_RUNNING; ctx_switch(s-main_ctx, co-ctx); // 从协程切回来了 if (co-status CO_DEAD) { free(co-stack_base); free(co); } } }我在这里留了个可以再优化的点协程结束后直接释放它的栈和控制块。如果协程还在队列里也就是 yield 过它会在队列里等着被再次调度status 不会是 DEAD所以不会被误释放。4.4 编译运行与预期输出写两个测试协程一个让出 3 次一个让出 5 次验证局部变量跨切换保持不变#include coroutine.h #include stdio.h #include stdlib.h static struct scheduler *g_sched; static void task_a(void *arg) { (void)arg; for (int i 0; i 3; i) { printf([A] step %d\n, i); co_yield(g_sched); } printf([A] done\n); } static void task_b(void *arg) { (void)arg; for (int i 0; i 5; i) { printf([B] step %d, local%d\n, i, i * 10); co_yield(g_sched); } printf([B] done\n); } int main(void) { struct scheduler sched; g_sched sched; sched.ready_head sched.ready_tail NULL; sched.running NULL; co_create(sched, task_a, NULL, 64 * 1024); co_create(sched, task_b, NULL, 64 * 1024); co_schedule(sched); printf(all done\n); return 0; }编译命令gcc -g -O2 -fno-omit-frame-pointer -Wall -Wextra \ demo.c coroutine.c ctx_switch.S -o demo运行结果[A] step 0 [B] step 0, local0 [A] step 1 [B] step 1, local10 [A] step 2 [B] step 2, local20 [A] done [B] step 3, local30 [B] step 4, local40 [B] done all done两个任务像两个独立的小线程在轮转。注意 task_b 的local变量在每次 yield 之后都保持原值这说明它的栈帧被完整地保存并恢复了。5. 实操踩坑对齐、红区、调试器5.1 第一次跑就崩栈对齐与红区的教训我最早写这个 demo 的时候协程入口的汇编里没有subq $8, %rsp测试函数里也没有任何 SSE 指令所以跑起来像是正常的。后来我把测试函数换成一段带局部数组并且开启了优化编译的代码就时不时在奇怪的位置段错误。查了很久才发现问题就是 trampoline 入口的栈没对齐。这里我想强调一个经验栈对齐这种问题不是每次都会崩而是可能在某个完全无关的改动后突然出现。编译器是否生成对齐敏感的 SSE 指令取决于优化等级、变量类型和代码结构。所以协程切换代码对对齐的严谨性应该是一开始就写对的而不是等出问题再修。红区的问题也类似。第一次冷启动时rsp 指向 slot 附近而蹦床函数和用户函数的第一批栈帧都在 slot 往下扩展。如果栈分配得不够大或者top对齐后把可用空间吃掉了太多就可能踩到未映射的内存。我的习惯是演示用 64KB生产环境至少 1MB 起步并且用第 6 章的 mmap 方案加 guard page。5.2 编译选项哪些能开哪些会给你挖坑这个实现用了 C 和汇编混编编译选项的影响值得专门说。常规的-O2完全没问题。因为ctx_switch对编译器来说就是一个普通的外部函数调用编译器会严格按照 ABI 来假定它破坏了调用者保存的寄存器、完整保留了被调用者保存的寄存器。这正是我们想要的契约。-fno-omit-frame-pointer强烈建议加上。带上它之后每个 C 函数都会维护 rbp 栈帧链GDB 的bt命令在协程内部也能正确打出调用栈。如果不加调试协程会痛苦得多。-fstack-protector一般也能正常工作因为栈保护用的 canary 值是从线程局部存储读的和栈本身不冲突。但如果出了问题第一反应可以先换成-fno-stack-protector排除变量。-fsanitizeaddress这个要特别小心。ASan 假设程序只有一条常规栈它通过影子内存跟踪每一个栈地址的合法性。协程的栈是堆内存ASan 会把它当成普通堆对象来对待。一旦协程切换进去ASan 对栈上操作的很多假设都会失效会蹦出一堆看不懂的报告。学习阶段别开 ASan实在要查内存问题可以用-fsanitizeundefined先顶着。5.3 用 GDB 观察协程切换现场协程的调试和普通程序不太一样但掌握了方法之后会很有意思。我在 GDB 里一般这么干先给ctx_switch下断点每次切换都会停在这里(gdb) break ctx_switch (gdb) run停下来之后info registers rbx rbp r12 r13 r14 r15 rsp能看当前寄存器快照x/4gx $rsp能看当前栈顶的返回地址。要确认某个协程的上下文可以直接打印它的 ctx 字段(gdb) p sched.running-ctx.rsp (gdb) x/4gx sched.running-ctx.rsp你会发现栈顶上蹲着的、正是它之前让出时的那条指令地址也就是 ret 之后的下一条指令。这是协程原理最直观的验证所谓现场就是能把执行流精确拉回那一刻的若干字节。另外中断在某个协程内部时bt默认可能显示不出跨协程的调用链这是正常的。汇编切换回旧栈的行为对调试器来说是凭空转移GDB 没有钩子感知协程的存在。这也是很多工业级协程库会带自己调试插件的根本原因。5.4 常见问题速查表现象可能原因处理办法第一次切换到新协程就崩溃入口地址没放对或者初始 rsp 计算错误打印co-ctx.rsp检查 slot 处的 8 字节是不是 coro_entry栈对齐相关的偶发段错误进入 trampoline 时 rsp 不满足 8 模 16在 coro_entry 里 call 之前手动调整 rsp局部变量值跨 yield 后丢失没有把某个被调用者保存的寄存器存进去对照表检查 rbx/rbp/r12-r15 是否齐全协程函数 return 后整个程序崩缺少 trampoline函数把垃圾地址当成返回地址弹了确保入口统一走 trampoline优化开高后行为变化编译器生成对齐敏感的 SSE 指令先关优化验证再确认对齐和红区偶尔越界写坏堆协程栈不够大或红区被下溢加大栈、用 mmap guard page见第 6 章6. 还能往哪走移植、事件驱动、栈管理6.1 换到 ARM64换汤不换药的寄存器名这套思路换到 ARM64 上几乎不需要重新设计只需要换一批寄存器和压栈方式。ARM64 的 ABI 规定 x19 到 x28 是被调用者保存的寄存器返回地址放在 x30LR而不是压栈。切换函数看起来是这样# void ctx_switch(struct ctx *from, struct ctx *to) # x0 from, x1 to ctx_switch: stp x19, x20, [x0, #0] stp x21, x22, [x0, #16] stp x23, x24, [x0, #32] stp x25, x26, [x0, #48] stp x27, x28, [x0, #64] mov x9, sp str x9, [x0, #80] str x30, [x0, #88] # LR ldp x19, x20, [x1, #0] ldp x21, x22, [x1, #16] ldp x23, x24, [x1, #32] ldp x25, x26, [x1, #48] ldp x27, x28, [x1, #64] ldr x9, [x1, #80] mov sp, x9 ldr x30, [x1, #88] retARM64 平台没有 x86 那个返回地址压栈的隐式机制所以结构体里要显式多存一个 LR。新协程的初始上下文就是让 x30 指向 coro_entrysp 指向预埋参数的位置。原理完全一样只是搬家的货物从 6 个寄存器变 10 个。6.2 从轮转调度到事件驱动调度器进化方向第 4 章的调度器是无脑轮转每个协程 yield 就排到队尾。真实世界的协程调度器很少这么简单。常见的进化方向是引入等待机制协程不 yield 自己而是声明我在等某个条件。比如等待 IO 可读、等待定时器到期、等待一个锁。做法是给每个协程加一个等待类别和对应的回调。调度器不再只是盲转队列而是在空闲时去事件循环里等。以单线程网络服务器为例协程发起网络请求后把自己挂到等待可读列表里然后让出。主循环用 epoll/poll 等待所有 fd 的事件。某个 fd 可读了主循环把对应的协程重新放回就绪队列。这样协程的代码看起来就是同步的顺序逻辑而底层是事件驱动。很多高性能服务器框架的协程模型本质都是这个结构。走到这一步你就已经从一个写汇编的手工党进化成了能设计调度器的系统程序员。6.3 协程栈的分配与回收从 malloc 到 mmap guard page第 4 章我直接用 malloc 分配协程栈演示够用但不严谨。malloc 是一段连续的大块内存它的末尾没有保护页。如果协程把栈用穿会直接写坏堆里的其他数据而且往往不会立即崩溃而是等到某个莫名奇妙的时刻才炸出来。生产级做法是使用内存映射并在栈的低地址方向放一页不可读写的 guard page#include sys/mman.h void *stack mmap(NULL, stack_size page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); mprotect(stack, page_size, PROT_NONE); // 第一页设为不可访问 // 协程栈的有效区域是 stack page_size 到 stack stack_size栈向下生长一旦越过 base就会踩到 PROT_NONE 的那一页触发段错误。虽然程序还是崩但崩的位置是栈越界的那一瞬间而不是几个小时后堆被写坏的现场。这个定位成本差异在真实项目里能救你无数个夜晚。栈的回收也有讲究。释放协程栈必须发生在协程彻底死亡、且不在其栈上执行任何代码的时候。我的 co_schedule 已经把释放放在了ctx_switch返回之后这个时机是对的。更精细的做法是维护一个空闲栈池协程结束后把栈放回池子而不是立刻 munmap避免频繁映射的开销。工业级协程库还会做栈的增长和收缩但这已经超出人人都可以写协程的范畴了。最后分享一点个人体会。这个 demo 我前后写过好几版第一版抄了很多现成代码跑通了但心里是虚的后来逼着自己从空文件开始一行一行把汇编和 C 拼起来才真正理解了几件事为什么调用约定是协程的地基为什么 ret 的语义可以如此优雅地被复用为什么栈对齐这种小事值回票价。这套东西再往上走就是各种 fiber 库和轻量级线程的雏形了。建议你也动手敲一遍遇到段错误时不要急着搜答案先看看 rsp 和 rbp 指着哪里——那个瞬间你对计算机的理解会上一个台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑