博主头像
huangzizhu的blog

在编写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 翻盘。
代码段与调试日志均为真实现场,可直接复现。

警告:

  1. 本文所有汇编均以 Linux x86-64、System V ABI 为背景。
  2. 不要盲信 AI,哪怕它此刻看起来无比笃定——请把 gdb 打在屏幕上再说话

0. 写在最前

项目周期有效工时代码行数
9.27-10.115.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->ctxcurr->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 &params
$4 = (struct {...} **) 0x7ffff7d87ec

AI 一针见血
「编译器用 rbp-0x28 寻址,看你 rbp 是不是 0?」
一看果然:

(gdb) x/gx $rbp
0x7ffff7d87ed0:    0x0000000000000000

ai给出解决方案

__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_yieldrbp 已变,存档值应为 *(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 方法论。

下一阶段目标

  1. 协程间通信(channel / mailbox)
  2. 生产者-消费者模型
  3. valgrind / sanitizers 全身体检

9. 致谢

感谢 Kimi 全程陪聊、陪 debug、陪背锅,

“AI 会犯错,gdb 不说谎,ABI 永远在那里等你。” —— huangzizhu · 2025-10-12

源码已上传 GitHubhuangzizhu/green_thread,欢迎来 issues 里继续挖坑。

发表新评论