在编写Green_Thread(用户态协程)中踩过的坑
手写 Green Thread 踩坑实录
作者:我 & Kimi
日期:2025-10-12
关键词:用户态协程、make/jump_fcontext、System V AMD64 ABI、栈对齐、RBP/rip 丢失、编译器优化、gdb
本文记录作者在 15 天里徒手搓一套 64-bit Linux 用户态协程(Green Thread)时踩过的 6 个史诗级大坑。
全程高能:被 AI 忽悠、被 ABI 打脸、被编译器优化按在地上摩擦,最终靠 gdb 翻盘。
代码段与调试日志均为真实现场,可直接复现。
警告:
- 本文所有汇编均以 Linux x86-64、System V ABI 为背景。
- 不要盲信 AI,哪怕它此刻看起来无比笃定——请把 gdb 打在屏幕上再说话。
0. 写在最前
| 项目周期 | 有效工时 | 代码行数 |
|---|---|---|
| 9.27-10.11 | 5.5 个晚上 | ~500 |
“游戏太好玩了,国庆 8 天一行没动。” —— 作者自白
一路踩坑密度堪比「朵拉顶分局智斗」,好在最后把协程跑通了。
把过程整理成这篇「踩坑实录」,权当给后人留一张「藏宝图」。
1. 第一个坑:RSP 16 字节对齐 —— 「AI 说的」vs「ABI 写的」
背景
我用 mmap 给每个协程分配独立栈,顶层汇编 make_fcontext 负责伪造初始上下文。
开局问 AI:「ABI 要求栈如何对齐?」
AI 秒回:「16 字节对齐。」
于是代码写成:
leaq -0x40(%rdi), %rax # rax = stack_top - 64
andq $-16, %rcx # 16 字节对齐现象
只要一碰 printf 就 SEGV,用 write 打日志怀疑人生。
AI 继续甩锅
「独立栈没金丝雀」「缓冲区不够」「你栈没开 guard page」……
我跟着改了一天,毫无进展。
转机
AI 随口补了一句:「进入函数时 RSP 模 16 应该等于 8。」
我顺手用内联汇编把 rsp 拖出来打印:
unsigned long rsp;
__asm__ volatile("movq %%rsp, %0" : "=r"(rsp));
write(1, &rsp, sizeof(rsp));跳板入口 rsp 尾号确实是 8,但原生线程的函数入口尾号是 0。
瞬间明白:AI 把条件说反了。
ABI 原文如下:
"The value (%rsp + 8) is always a multiple of 16 when control is transferred to the function entry point."
—— System V AMD64 ABI §3.2.2
翻译成大白话: call 指令压入 8 字节返回地址后,刚进入函数时 RSP ≡ 8 (mod 16)。
因此伪造的上下文必须满足:
andq $-16, %rcx
addq $-8, %rcx # 补上 call 压栈的 8 字节改完一行,printf 瞬间复活。
教训
AI 能指路,但ABI 文档才是真理。
gdb + 官方 PDF 永远是最短路径。
2. 第二个坑:跳板函数被「结构体传参」坑到无限循环
代码
C 端跳板:
void coroutine_trampoline(transfer_t t) // 看起来人畜无害
{
tcb_t* tcb = t.data;
...
tcb->func(tcb->arg);
jump_fcontext(scheduler->ctx, NULL);
}现象
任务执行完应跳回调度器,却重新从 trampoline 开头执行——无限套娃。
gdb 现场 scheduler->ctx 与 curr->ctx 地址居然相同。
翻 jump_fcontext 汇编才发现:
# 返回结构体 transfer_t 的方式:
# 调用者在栈上分配 16 字节,把地址放 %rdi 传进来
# 于是 rdi = &transfer_t,而非 transfer_t.data
#==============================================================================
# transfer_t jump_fcontext(fcontext_t const to, void* userdata)
#==============================================================================
# 参数: rdi = to (目标上下文指针)
# rsi = userdata (用户数据)
# 返回: rax = transfer_t.prev_ctx (前一个上下文)
# rdx = transfer_t.userdata (用户数据)
jump_fcontext:
# 在当前栈上分配 0x40 字节保存当前上下文
subq $0x40, %rsp
# 保存 callee-saved 寄存器到当前栈
...
# 保存返回地址(从调用者栈帧获取)
movq 0x40(%rsp), %rax # 获取原返回地址
movq %rax, 0x38(%rsp) # 保存到 ctx->rip
# 保存当前栈指针
movq %rsp, 0x00(%rsp) # 保存 ctx->rsp
# 设置返回值(在切换上下文之前)
movq %rsp, %rax # transfer_t.prev_ctx = 当前上下文
movq %rsi, %rdx # transfer_t.userdata = userdata而 C 端把 trampoline 当普通函数,编译器继续用 rdi 当第一个参数。
结果 tcb = (tcb_t*)rdi 实际拿到的是上下文指针,逻辑全崩。
修复
手动从 rax/rdx 取数,并钉死内存防止编译器优化:
__attribute__((noinline, noreturn))
void coroutine_trampoline(void)
{
fcontext_t prev;
void* arg;
__asm__ volatile (
"movq %%rax, %0\n\t"
"movq %%rdx, %1"
: "=m"(prev), "=m"(arg) : : "memory");
transfer_t t = { .ctx = prev, .data = arg };
...
}收获
AI 教会我 gdb 命令: disas/r coroutine_trampoline info reg rdi rax rdx
先让证据落地,再让 AI 解释原因,姿势才对。
3. 第三个坑:原生栈返回地址被「谁」抹了?
代码
void green_run(void)
{
for (;;) {
if (next_task()) { ... }
else { return; } // 这里
}
}现象 green_run 执行 ret 后没有回到 main,而是跳到 0x55730 这种随机地址,直接 SEGV。
gdb 看栈:
#0 green_run () at scheduler.c:207
#1 0x000055555555b730 in ?? ()
#2 0x0000000000000000 in ?? ()应急方案
在入口把返回地址先备份:
unsigned long saved_ret = *(unsigned long*)__builtin_frame_address(0);
...
*(unsigned long*)__builtin_frame_address(0) = saved_ret;
return;能跑,但债留给了未来。
(至今没抓到真凶,怀疑是 jump_fcontext 内部 movaps 把栈当 xmm 寄存器擦了 8 字节…… 欢迎有缘人补刀。)
4. 第四个坑:编译器优化把 rip 藏起来了
背景
协作式任务 cooperative_task 里调用 green_yield(),回来却从头开始执行。
// 协作式任务(会主动yield)
void cooperative_task(void* arg) {
struct { int task_id; green_t* scheduler; } *params = arg;
int task_id = params->task_id;
green_t* scheduler = params->scheduler;
printf("协作任务 %d 开始\n", task_id);
for (int i = 0; i < 5; i++) {
printf("协作任务 %d: 步骤 %d\n", task_id, i + 1);
atomic_fetch_add(&global_counter, 1);
if (i < 4) { // 最后一次不yield
green_yield();
}
}
printf("协作任务 %d 完成\n", task_id);
atomic_fetch_add(&task_completed, 1);
}gdb 抓三个地址:
(gdb) p cooperative_task
$1 = 0x55555555531d
(gdb) disas cooperative_task
...
0x5555555553a2 <call green_yield>
0x5555555553a7 <next insn> ← 理想返回点
(gdb) p/x *(unsigned long*)(curr->ctx+0x38)
$2 = 0x555555556100 ← 实际被存档的 rip差之千里。
AI 猜测:
gcc 做了 shrink-wrapping / tail-call,用 jmp 代替 call,*(rbp+8) 被提前 pop。
修复
在 green_yield 里手动拿返回地址:
*(uint64_t*)(curr->ctx+0x38) = (uint64_t)__builtin_return_address(0);教训
任何依赖「编译器一定把 rip 留在栈上」的假设,都会被 -O2 打脸。
能拿现成的就别指望编译器给你留位置。
5. 第五个坑:局部变量「消失」——RBP 寻址失效
现象 yield 回来后,局部变量全变 0 或 SEGV。
gdb 连变量地址都打印不出:
Cannot access memory at address 0xffffffffffffffd8当时真的没有头绪,gdb一进这个函数连值也看不了。到处找问题,到处看寄存器和内存
(gdb) x/gx $rbp
0x7ffff7d87ed0: 0x0000000000000000 #注意这里
(gdb) x/gx $rbx
0x55555555b780: 0x0000555000000001
(gdb) p &task_id
$2 = (int *) 0x7ffff7d87ebc
(gdb) p &i
$3 = (int *) 0x7ffff7d87eb8
(gdb) p ¶ms
$4 = (struct {...} **) 0x7ffff7d87ecAI 一针见血
「编译器用 rbp-0x28 寻址,看你 rbp 是不是 0?」
一看果然:
(gdb) x/gx $rbp
0x7ffff7d87ed0: 0x0000000000000000ai给出解决方案
__attribute__((noinline, noreturn))
void green_yield(void)
{
/* 1. 抓当前帧指针 */
void *frame = __builtin_frame_address(0); /* = rbp 值 */
/* 2. 写回存档区 */
*(uint64_t *)(curr->ctx + 0x00) = (uint64_t)__builtin_frame_address(0); /* rsp 已 OK */
*(uint64_t *)(curr->ctx + 0x08) = (uint64_t)frame; /* rbp 修正 */
*(uint64_t *)(curr->ctx + 0x38) = (uint64_t)__builtin_return_address(0); /* rip 已 OK */
jump_fcontext(scheduler->ctx, NULL);
__builtin_unreachable();
}
//ai这代码写的挺糖的还是不行
应用更改后,能跑了,再也不是寻址错误,但是一看值全是0和NULL,说明rbp还是有问题。
还是不对,ai已经放弃了这条线索,我灵机一动,在上次,yeild的rip也有问题,rbp怎么可能就不会出错?
于是进yeild的前面拿rbp信息
#yeild后
Breakpoint 3, green_yield () at /home/green_thread/src/scheduler.c:151
151 {
(gdb) p/x $rbp
$1 = 0x7ffff7d87e90
(gdb) p/x frame
$2 = 0x7ffff7d87e90
#yeild前
#1 0x00005555555553a7 in cooperative_task (arg=0x7fffffffe120) at /home/green_thread/src/test_scheduler.c:55
55 green_yield();
(gdb) p/x $rbp
$3 = 0x7ffff7d87ed0在进入yeild前的rbp和进入后的不一样?而且原来的居然是的对的?
恍然大悟,应该拿rbp当时的值而不是拿rbp地址
根因
上一次 yield 时存档的 rbp 是 __builtin_frame_address(0)——当前函数帧指针,
而不是调用者帧指针。
进入 green_yield 后 rbp 已变,存档值应为 *(uint64_t*)rbp。
最终代码
__attribute__((noinline))
void green_yield()
{
if (!scheduler) handle_no_scheduler();
if (!curr) handle_no_curr();
unsigned long caller_rbp;
/* 从调用者帧抓 rbp:*$rbp */
asm volatile("movq (%%rbp), %0" : "=r"(caller_rbp));
/* 写回存档区 */
*(uint64_t *)(curr->ctx + 0x00) = (uint64_t)__builtin_frame_address(0); /* rsp */
*(uint64_t *)(curr->ctx + 0x08) = caller_rbp; /* 调用者 rbp */
*(uint64_t *)(curr->ctx + 0x38) = (uint64_t)__builtin_return_address(0); /* rip */
jump_fcontext(scheduler->ctx,NULL);
}6. 第六个坑:寄存器变量也「丢」了
现象
还记得我们的coroutine_trampoline函数吗?应用2中的修改它变成了这样子
__attribute__((noinline, noreturn))
void coroutine_trampoline(void)
{
fcontext_t prev;
void *arg;
/* 一次汇编把 rax/rdx 钉死,并声明内存副作用 */
__asm__ volatile (
"movq %%rax, %0\n\t"
"movq %%rdx, %1"
: "=m"(prev), "=m"(arg) // 强制写进内存
:
: "memory");
transfer_t t = { .ctx = prev, .data = arg };
tcb_t *tcb = curr;
set_curr(tcb);
scheduler->ctx = t.ctx;
tcb->state = ST_RUNNING;
tcb->func(tcb->arg);
tcb->state = ST_DEAD;
jump_fcontext(scheduler->ctx, NULL);
__builtin_unreachable();
}任务跑完设置 curr->state = ST_DEAD 时,curr 突然变成 NULL。
奇怪的是prev和arg都在
说起来也是技术债,其实早就依稀记得这里的tcb可以使用curr,但是在前面的过程中太焦头烂额了,在这里炸了。
刚开始以为优势把栈帧写坏了,但是为什么只有这一个变量掉了呢?
gdb 发现:
(gdb) p &prev
$1 = (fcontext_t *) 0x7ffff7d87ee0
(gdb) p &arg
$2 = (void **) 0x7ffff7d87ee8
(gdb) p &tcb
Can't take address of "tcb" which isn't an lvalue.
#任务完成之后
(gdb) p arg
$3 = (void *) 0x0
(gdb) p &tcb
Address requested for identifier "tcb" which is in register $rbx原因
编译器把 tcb 优化进寄存器, rbx早就经过了无数次call+jump的轮流洗礼,回来早就凉了。
解决方案
既然是小小的技术栈,直接抛弃局部变量(我也是躺的厉害,能写出这种玩意儿)
使用早就已经有的curr代替,顺便把原来代码留下来作为纪念
__attribute__((noinline, noreturn))
void coroutine_trampoline(void) /* 故意不写参数 */
{
fcontext_t prev;
void *arg;
/* 一次汇编把 rax/rdx 钉死,并声明内存副作用 */
__asm__ volatile (
"movq %%rax, %0\n\t"
"movq %%rdx, %1"
: "=m"(prev), "=m"(arg) // 强制写进内存
:
: "memory");
transfer_t t = { .ctx = prev, .data = arg };
scheduler->ctx = t.ctx;
curr->state = ST_RUNNING;
curr->func(curr->arg);
curr->state = ST_DEAD;
/**
* 编译器将tcb优化进寄存器,等回来的时候tcb不见
* 使用curr更好的方案
*/
// tcb_t *tcb = curr;
// set_curr(tcb);
// scheduler->ctx = t.ctx;
// tcb->state = ST_RUNNING;
// tcb->func(tcb->arg);
// tcb->state = ST_DEAD;
jump_fcontext(scheduler->ctx, NULL);
__builtin_unreachable();
}(注释里把旧代码留作「黑历史纪念碑」。)
7. 小结 & 彩蛋:gdb 封神时刻
| 指令 | 用途 | 备注 |
|---|---|---|
disas/r | 看二进制指令 | 对比优化前后 |
info registers | 全部寄存器快照 | 对齐问题必用 |
x/40gx $rsp-0x40 | 看上下文存档区 | 快速定位 rip/rbp |
set $pc=0x... | 手动改执行流 | 验证修复方案 |
watch *(uint64_t *)addr | 监控内存被谁动 | 抓“幽灵写” |
“gdb 太牛了,早点学早少掉 3 天头发。” —— 作者血泪谏言
- 最大收获:学会用 gdb 把 AI 的猜测变成证据。
- AI 正确用法:
「给出假设」+「对应的 gdb 命令」→ 自己验证,别让 AI 替你思考。
8. 阶段小结 & 下一步
- 徒手搓完上下文切换、调度器、协作式 / 抢占式任务。
- 踩穿 ABI、编译器优化、寄存器保存 3 大深渊。
- 形成一套“AI 给方向 → gdb 给证据 → 汇编给终局”的 debug 方法论。
下一阶段目标
- 协程间通信(channel / mailbox)
- 生产者-消费者模型
- valgrind / sanitizers 全身体检
9. 致谢
感谢 Kimi 全程陪聊、陪 debug、陪背锅,
“AI 会犯错,gdb 不说谎,ABI 永远在那里等你。” —— huangzizhu · 2025-10-12
源码已上传 GitHubhuangzizhu/green_thread,欢迎来 issues 里继续挖坑。