在core文件dump协程堆栈

core文件使用gdb来dump线程堆栈是很简单的事情,但是dump协程就有点困难了

因为gdb本身不知道协程的数据结构,本文记录了如何解决这个问题

协程的背景在我以前的博文在 C++ 中实现协程已经详细写过了,不再赘述

首先深入协程切换的原理

协程切换原理

协程实现原理

回顾一下协程的实现原理:

协程实例,主要功能是把寄存器的数据和当前栈整个记录在申请的内存中,随时可以将其恢复出来

记录恢复两个功能,就完成不同的栈之间的切换,而如何切换是协程调度器的工作

这个切换有点像游戏中的“存档”功能, 就好比家长(协程调度器)安排几个孩子轮流玩同一台游戏机,每个孩子玩一会儿累了, 暂停游戏并“保存进度”(记录),让出游戏机, 家长再让下一个孩子“读取进度”(恢复)继续他自己之前玩到一半的游戏内容, 就这样依次排队轮流玩,每个孩子随时都能从之前暂停的位置继续玩,不用重新开始游戏。

fcontext_t实际上就是记录的协程数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 定义协程执行栈
struct fc_stack_t {
void* sp; // 栈指针地址(通常指向栈顶,高地址位置)
std::size_t size; // 栈空间大小
fc_stack_t() // 构造函数初始化为空栈
: sp(0) , size(0) {}
};

// 定义浮点寄存器状态(仅保存部分浮点寄存器)
struct fp_t {
uint32_t fc_freg[2]; // 浮点寄存器保存区域 (大小可能不同架构而变化)
fp_t(): fc_freg() {}
};

// 协程上下文结构(fcontext_t)
struct fcontext_t {
uint64_t fc_greg[8]; // 通用寄存器(general registers)保存区:
// fc_greg[0]: RBX
// fc_greg[1]: R12
// fc_greg[2]: R13
// fc_greg[3]: R14
// fc_greg[4]: R15
// fc_greg[5]: RBP (基址指针, FramePointer)
// fc_greg[6]: RIP (指令地址寄存器, Instruction Pointer)
// fc_greg[7]: RSP (栈顶寄存器, Stack Pointer)
fc_stack_t fc_stack; // 分配给当前协程的执行栈空间
fp_t fc_fp; // 浮点寄存器状态保存区域
fcontext_t() // 构造函数初始化寄存器与栈空间
: fc_greg(), fc_stack(), fc_fp(){}
};

而操作fcontext_tmake_fcontextjump_fcontext都是汇编实现

而他们的函数签名是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 创建一个新的协程上下文函数(make function context)
// 参数说明:
// sp: 栈空间的地址(通常指向栈空间的顶端)
// size: 栈的大小,单位字节
// fn: 上下文启动后执行的协程函数指针,函数类型为 void fn(intptr_t)
// 返回值: 返回指向新创建的上下文结构的指针(fcontext_t指针)
extern "C" fcontext_t * make_fcontext(void * sp, std::size_t size, void (* fn)(intptr_t));

// 上下文跳转函数,从当前上下文切换至目标上下文。
// 参数说明:
// ofc: 用于保存当前上下文(old context)的指针
// nfc: 目标上下文(new context)的指针
// vp: 在切换到目标上下文时传递的整型参数
// preserve_fpu: 是否需要保存浮点寄存器 (默认true,防止浮点计算数据丢失)
// 返回值: 从其他上下文再切换回此上下文时,返回传递回来的intptr_t值
extern "C" intptr_t jump_fcontext(fcontext_t * ofc, fcontext_t const* nfc, intptr_t vp, bool preserve_fpu = true);

使用make_fcontext可以新开辟一个协程栈空间,运行自己期望的函数,并返回一个fcontext_t指针

随后使用jump_fcontext就可以跳转到创建的协程栈空间去了

显然jump_fcontext就是对应了上文所述的恢复功能,而记录是通过组合了make_fcontextjump_fcontext实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <cstdint>
#include <cstdlib>
#include <cstddef>

// 您已有的接口
extern "C" fcontext_t * make_fcontext(void * sp, std::size_t size, void (* fn)(intptr_t));
extern "C" intptr_t jump_fcontext(fcontext_t * ofc, fcontext_t const* nfc, intptr_t vp, bool preserve_fpu);

// 用于 trampoline 的临时数据
struct record_bounce_t {
fcontext_t self; // 保存 trampoline 自身上下文(仅用于完成跳转保存,不再使用)
fcontext_t* back; // 要回跳的目标(即我们要“记录”的那个上下文 ofc)
};

// trampoline:什么也不做,立刻跳回,完成“记录”
static void record_trampoline(intptr_t vp) {
record_bounce_t* b = reinterpret_cast<record_bounce_t*>(vp);
// 从 trampoline 跳回去:这一步会把“当前(trampoline)上下文”保存到 b->self
// 并恢复到 b->back(也就是调用方的上下文),从而让调用方的 jump_fcontext 返回
jump_fcontext(&b->self, b->back, 0, true);
// 不会返回到这里
}

// 最简单的“记录当前上下文”函数:把调用点的 CPU 上下文保存到 *out 中
void record_context(fcontext_t* out) {
// 1) 准备一个小栈给 trampoline
constexpr std::size_t kStackSize = 64 * 1024; // 示例大小
void* stack = std::malloc(kStackSize);
void* sp = static_cast<char*>(stack) + kStackSize;

// 2) 生成 trampoline 上下文
fcontext_t* tramp = make_fcontext(sp, kStackSize, record_trampoline);

// 3) 组织传递数据
record_bounce_t b{};
b.back = out;

// 4) 跳到 trampoline:
// - 这一步会把“当前上下文”保存到 *out(完成记录)
// - 然后开始在 trampoline 上运行 record_trampoline
// - record_trampoline 立刻跳回,于是本调用返回
jump_fcontext(out, tramp, reinterpret_cast<intptr_t>(&b), true);

// 5) 释放 trampoline 的栈(一次性使用)
std::free(stack);
}

fcontext

在make_fcontext和jump_fcontext中都有一张字符串图,对应了fcontext的数据分布,0x54代表从0x54开始的4个字节,因此一共0x58字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/****************************************************************************************
* *
* ---------------------------------------------------------------------------------- *
* | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | *
* ---------------------------------------------------------------------------------- *
* | 0x0 | 0x4 | 0x8 | 0xc | 0x10 | 0x14 | 0x18 | 0x1c | *
* ---------------------------------------------------------------------------------- *
* | RBX | R12 | R13 | R14 | *
* ---------------------------------------------------------------------------------- *
* ---------------------------------------------------------------------------------- *
* | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | *
* ---------------------------------------------------------------------------------- *
* | 0x20 | 0x24 | 0x28 | 0x2c | 0x30 | 0x34 | 0x38 | 0x3c | *
* ---------------------------------------------------------------------------------- *
* | R15 | RBP | RSP | RIP | *
* ---------------------------------------------------------------------------------- *
* ---------------------------------------------------------------------------------- *
* | 16 | 17 | 18 | 19 | | *
* ---------------------------------------------------------------------------------- *
* | 0x40 | 0x44 | 0x48 | 0x4c | | *
* ---------------------------------------------------------------------------------- *
* | sp | size | | *
* ---------------------------------------------------------------------------------- *
* ---------------------------------------------------------------------------------- *
* | 20 | 21 | | *
* ---------------------------------------------------------------------------------- *
* | 0x50 | 0x54 | | *
* ---------------------------------------------------------------------------------- *
* | fc_mxcsr|fc_x87_cw| | *
* ---------------------------------------------------------------------------------- *
* *
* **************************************************************************************/
  • 0x00 ~ 0x1C(每 8 字节一项)
    • 0x00: RBX
    • 0x08: R12
    • 0x10: R13
    • 0x18: R14
  • 0x20 ~ 0x3C
    • 0x20: R15
    • 0x28: RBP
    • 0x30: RSP(该上下文恢复时使用的栈指针)
    • 0x38: RIP(该上下文恢复时跳转的指令地址;对于新上下文,这里保存的是入口函数地址)
  • 0x40 ~ 0x4C
    • 0x40: sp(调用 make_fcontext 传入的栈基指针,通常为栈内存的高地址)
    • 0x48: size(栈大小)
  • 0x50 ~ 0x54
    • 0x50: fc_mxcsr(SSE/MMX 控制与状态字)
    • 0x54: fc_x87_cw(x87 FPU 控制字)

注意:

  • RSP 和 sp 的区别:
    • RSP(偏移 0x30):该上下文“当前有效”的栈指针,在切入时会加载到 CPU 的 RSP。
    • sp(偏移 0x40):原始的栈顶指针(高地址),便于调试或做边界检查,不直接作为运行时 RSP。
  • 保存的是“非易失寄存器”(RBX、RBP、R12~R15),因为按 ABI 约定这些必须在函数调用间保持不变;而像 RAX、RCX、RDX 等易失寄存器不需要在上下文控制块中持久保存。

make_fcontext

源码在这里:https://github.com/tedcy/sheep_cpp/blob/master/src/coroutine/tc_make_x86_64_sysv_elf_gas.S

首先看下参数和返回值定义:

1
extern "C" fcontext_t * make_fcontext(void * sp, std::size_t size, void (* fn)(intptr_t));
  • 参数(SysV x86-64 调用约定):
    • RDI:sp,指向栈内存的“顶端”(高地址)指针
    • RSI:size,栈大小(字节数)
    • RDX:context function 的入口地址(即将来首次切入该上下文时要执行的函数)
  • 返回值:RAX 返回 fcontext_t 的指针(对齐到 16 字节)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.text
.globl make_fcontext
.type make_fcontext,@function
.align 16
make_fcontext:
# 在栈顶(sp)下方预留 0x58 字节空间用于 fcontext_t 控制块(x86-64 栈向下增长)
leaq -0x58(%rdi), %rax

# 将fcontext_t指针向下对齐到 16 字节边界(与 ~0xF 做 AND,遵守 SysV ABI 的 16 字节对齐要求)
andq $-16, %rax

movq %rdi, 0x40(%rax) # 保存传入的栈顶指针 sp
movq %rsi, 0x48(%rax) # 保存栈大小 size
movq %rdx, 0x38(%rax) # 保存上下文入口函数地址到 RIP 字段

stmxcsr 0x50(%rax) # 保存当前的 MXCSR(SSE/MMX 控制状态字)
fnstcw 0x54(%rax) # 保存 x87 FPU 控制字

leaq -0x8(%rax), %rdx # rax指向fcontext_t指针,把fcontext_t指针-0x8存到rdx寄存器
movq %rdx, 0x30(%rax) # 把rdx寄存器值写到RSP

leaq finish(%rip), %rcx # 计算finish标签的绝对地址,存到rcx寄存器
movq %rcx, (%rdx) # 把rcx寄存器值写到rdx寄存器值指向的地址(也就是RSP)

ret # 栈上弹出返回地址,跳转到返回地址

finish:
call abort@PLT # context function如果ret了,就会返回到finish,从而abort
hlt # 防止继续执行(理论上不会到这里)
.size make_fcontext,.-make_fcontext

总的来说,这里可以分成两个部分

  • 创建除了RSP以外的非易失寄存器值
  • 创建RSP寄存器值

重点看下创建RSP寄存器值的逻辑

1
2
3
4
5
leaq   -0x8(%rax),      %rdx        # rax指向fcontext_t指针,把fcontext_t指针-0x8存到rdx寄存器
movq %rdx, 0x30(%rax) # 把rdx寄存器值写到RSP

leaq finish(%rip), %rcx # 计算finish标签的绝对地址,存到rcx寄存器
movq %rcx, (%rdx) # 把rcx寄存器值写到rdx寄存器值指向的地址(也就是RSP)

这里为什么不能直接把fcontext_t指针作为RSP,而是写入了一个finish的label作为RSP呢

之前博文栈回溯原理在总结hook的backtrace问题时,对函数调用的函数序言(prologue)和函数后记(epilogue)有过详述:

简单来说

调用一个foo函数时是把函数调用返回的那一行(.Lafter_foo)指令计数器 % rip 写入到寄存器 % rax 以后,push 到栈里面,然后跳转到 foo 函数

1
2
3
4
5
    #call   foo
leaq .Lafter_foo(%rip), %rax
pushq %rax
jmp foo
.Lafter_foo:

那么返回的时候调用ret实际上是如下汇编

1
2
3
#ret
popq %rax # 弹出返回地址(也就是.Lafter_foo:)
jmp *%rax # 跳转到返回地址继续执行

重新再看看make_context创建的内存栈:

1
2
3
4
5
6
7
8
9
10
11
高地址
┌───────────────────────────────┐
sp (入参) │ ← 用户提供的栈顶(高地址)
├───────── 未用/对齐填充 ─────────┤
fcontext_t (0x58B) │ ← rax 指向这里(16B 对齐)
└───────────────────────────────┘ rax
│ fake return addr = &finish │ ← rdx = rax - 8 = 初始 RSP
└───────────────────────────────┘ rsp(新上下文初始)
│ │ ↓ 函数执行时进一步向下生长
│ ... │
低地址

初始RSP指向finish以后,当make_fcontext传入的context func执行完毕以后

假如没有切换协程,而是正常返回了,那么当ret的时候

1
2
3
#ret
popq %rax # 弹出返回地址(也就是&finish:)
jmp *%rax # 跳转到返回地址继续执行

从而abort掉(协程栈已经到顶了,没得ret了,协程实例应该把控制权还给协程调度器)

jump_fcontext

源码在这里:https://github.com/tedcy/sheep_cpp/blob/master/src/coroutine/tc_jump_x86_64_sysv_elf_gas.S

首先看下参数和返回值定义:

1
extern "C" intptr_t jump_fcontext(fcontext_t * ofc, fcontext_t const* nfc, intptr_t vp, bool preserve_fpu = true);
  • 参数(SysV x86-64 调用约定):
    • RDI:ofc,当前上下文的 fcontext_t 存放位置(把当前状态保存到这里)
    • RSI:nfc,目标上下文的 fcontext_t(从这里恢复状态)
    • RDX:vp,传递给目标上下文入口函数的“用户值”,同时也将作为“对原调用者的返回值”(当将来切回时)
    • RCX:preserve_fpu_flag,是否保存/恢复 FPU 控制字(mxcsr/x87_cw);非 0 则执行保存/恢复,0 则跳过
  • 返回值:RAX 返回 从其他上下文再切换回此上下文时,返回传递回来的intptr_t值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
.text
.globl jump_fcontext
.type jump_fcontext,@function
.align 16
jump_fcontext:
movq %rbx, (%rdi) # 保存 RBX
movq %r12, 0x8(%rdi) # 保存 R12
movq %r13, 0x10(%rdi) # 保存 R13
movq %r14, 0x18(%rdi) # 保存 R14
movq %r15, 0x20(%rdi) # 保存 R15
movq %rbp, 0x28(%rdi) # 保存 RBP

cmp $0, %rcx # rcx==0 则跳到1这个label,跳过FPU控制字处理
je 1f

stmxcsr 0x50(%rdi) # 保存当前FPU 控制字(mxcsr/x87_cw)到 ofc
fnstcw 0x54(%rdi)

ldmxcsr 0x50(%rsi) # 从目标 nfc 恢复相应FPU 控制字(mxcsr/x87_cw)
fldcw 0x54(%rsi)
1:

leaq 0x8(%rsp), %rax # 计算“去掉返回地址后的栈顶”
movq %rax, 0x30(%rdi) # 保存为 ofc->RSP
movq (%rsp), %rax # 取保存在栈上的返回地址(上一层栈调用jump_context的下一行地址)
movq %rax, 0x38(%rdi) # 保存为 ofc->RIP

movq (%rsi), %rbx # 恢复 RBX
movq 0x8(%rsi), %r12 # 恢复 R12
movq 0x10(%rsi), %r13 # 恢复 R13
movq 0x18(%rsi), %r14 # 恢复 R14
movq 0x20(%rsi), %r15 # 恢复 R15
movq 0x28(%rsi), %rbp # 恢复 RBP

movq 0x30(%rsi), %rsp # 恢复 RSP
movq 0x38(%rsi), %rcx # 恢复 RIP

movq %rdx, %rax # vp作为返回值存到rax
movq %rdx, %rdi # vp作为参数传递给即将进入的context func

jmp *%rcx # 跳转到context func
.size jump_fcontext,.-jump_fcontext

这里有两个注意点:

  • RSP和RIP的值

    回顾调用foo的例子:

    调用一个foo函数时是把函数调用返回的那一行(.Lafter_foo)指令计数器 % rip 写入到寄存器 % rax 以后,push 到栈里面,然后跳转到 foo 函数

    1
    2
    3
    4
    5
        #call   foo
    leaq .Lafter_foo(%rip), %rax
    pushq %rax
    jmp foo
    .Lafter_foo:

    jmp foo以后的堆栈如下:

    1
    2
    3
    4
    5
    6
    7
    高地址
    ┌───────────────────────────────┐
    │ ...(更老的调用栈数据) │
    ├───────────────────────────────┤
    return addr → .Lafter_foo │ ← %rsp
    └───────────────────────────────┘
    低地址

    那么返回的时候调用ret指令实际上是如下汇编:

    1
    2
    3
    #ret
    popq %rax # 弹出返回地址(也就是.Lafter_foo:)
    jmp *%rax # 跳转到返回地址继续执行

    当返回调用ret指令以后,堆栈如下:

    1
    2
    3
    4
    5
    6
    7
    高地址
    ┌───────────────────────────────┐
    │ ...(更老的调用栈数据) │ ← %rsp
    ├───────────────────────────────┤
    return addr → .Lafter_foo │ ← %rsp - 8(已经被弹出)
    └───────────────────────────────┘
    低地址

    回到jump_context的汇编代码来

    1
    2
    3
    4
    leaq     0x8(%rsp),  %rax         # 计算“去掉返回地址后的栈顶”
    movq %rax, 0x30(%rdi) # 保存为 ofc->RSP
    movq (%rsp), %rax # 取保存在栈上的返回地址(上一层栈调用jump_context的下一行地址)
    movq %rax, 0x38(%rdi) # 保存为 ofc->RIP
    • ofc->RSP = %rsp_cur + 8

      那么RSP就会指向已经被弹出的正确位置

    • `ofc->RIP =