25、汇编级协程实现(续):ARM64 架构下的协程切换

好,咱们接着聊。上一节我们把 ARM64 的寄存器布局和栈帧结构理清了,这一节直接上手——写汇编,做切换。

说实话,ARM64 的协程切换比 x86_64 要清爽不少。为什么?因为它有统一的寄存器窗口,没有 x86 那些历史包袱。我个人第一次在 ARM64 上写协程切换时,最大的感受就是:指令少,逻辑直白

核心思路:保存现场,恢复现场

协程切换说白了就两件事:

  • 把当前协程的寄存器全部压栈
  • 从目标协程的栈顶弹出所有寄存器

嗯,就这么简单。但细节里全是坑。

ARM64 有 31 个通用寄存器(X0~X30),外加 SP、PC、PSTATE。不过我们不需要全部保存。为什么?因为有些寄存器是调用者保存的,有些是被调用者保存的。按照 ARM64 的 ABI 规范:

寄存器角色是否需要保存
X0~X18临时/参数寄存器否(调用者负责)
X19~X29被调用者保存
X30 (LR)链接寄存器
SP栈指针
PC程序计数器通过 LR 间接保存
V0~V31浮点/NEON 寄存器按需(我们暂不处理)

关键点:协程切换属于“非正常调用”,我们不能依赖调用者保存机制。所以,所有可能被破坏的寄存器都要保存。我习惯的做法是:保存 X19~X30,外加 SP 和 PC。一共 13 个 64 位值,正好 104 字节。

ARM64 协程切换汇编实现

先看代码,再解释。这是我实际项目中用过的版本,稍微简化了一下:

// 协程切换函数
// 参数:
//   X0 - 当前协程的上下文指针 (ctx_t *current)
//   X1 - 目标协程的上下文指针 (ctx_t *target)
// 上下文结构体定义:
//   offset 0:  X19
//   offset 8:  X20
//   ...
//   offset 88: X30 (LR)
//   offset 96: SP
//   offset 104: PC (入口点)

.global coroutine_switch
.type   coroutine_switch, %function

coroutine_switch:
    // 1. 保存当前协程的寄存器到 current->ctx
    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]
    stp     x29, x30, [x0, #80]   // x29 = FP, x30 = LR

    // 保存栈指针
    mov     x2, sp
    str     x2, [x0, #96]

    // 保存返回地址(PC)
    // 这里有个技巧:用 LR 来保存返回地址
    // 因为执行完切换后,我们要跳转到目标协程的 LR 位置
    str     x30, [x0, #104]

    // 2. 恢复目标协程的寄存器
    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]
    ldp     x29, x30, [x1, #80]

    // 恢复栈指针
    ldr     x2, [x1, #96]
    mov     sp, x2

    // 3. 跳转到目标协程
    // 这里用 BR 指令跳转到 X30(LR)
    // 注意:X30 里存的是目标协程上次被切换时的返回地址
    br      x30

// 协程首次启动的入口
.global coroutine_entry
.type   coroutine_entry, %function

coroutine_entry:
    // 此时 X0 里是协程函数的参数
    // 直接跳转到协程函数
    blr     x19   // x19 里存的是协程函数指针

    // 协程函数返回后,调用退出处理
    // 这里可以调用一个清理函数
    // 实际项目中我会在这里调用 coroutine_exit()
    ret

我的习惯:在保存 PC 时,我直接用了 LR(X30)。因为协程切换是通过函数调用进入的,LR 里存的就是返回地址。恢复时用 br x30 跳转,相当于从目标协程的“上次切换点”继续执行。

首次启动:协程入口怎么处理?

你想想看,一个协程第一次被切换进来时,它的栈上没有任何“历史”寄存器。那它的 LR 和 SP 从哪来?

嗯,这里需要特殊处理。我在初始化协程时,会手动构造一个“伪上下文”:

// 协程初始化
void coroutine_init(ctx_t *ctx, void (*func)(void*), void *arg, void *stack_base, size_t stack_size) {
    // 栈顶指针(ARM64 栈向下生长)
    uint64_t *sp = (uint64_t*)((char*)stack_base + stack_size);

    // 预留 13 个寄存器的空间(104 字节)
    sp -= 13;

    // 设置初始上下文
    ctx->regs[0]  = (uint64_t)func;     // X19 = 函数指针
    ctx->regs[1]  = (uint64_t)arg;      // X20 = 参数
    // ... 其他寄存器清零 ...
    ctx->regs[10] = (uint64_t)coroutine_entry;  // X29 (FP) = 入口函数
    ctx->regs[11] = (uint64_t)coroutine_entry;  // X30 (LR) = 入口函数
    ctx->sp       = (uint64_t)sp;               // SP
    ctx->pc       = (uint64_t)coroutine_entry;  // PC = 入口函数
}

我曾经踩过的坑:第一次写 ARM64 协程时,我忘了设置 X29(FP)。结果协程第一次切换进去后,函数调用链断了,导致栈回溯(backtrace)完全乱掉。调试了整整一个下午才发现。所以,FP 一定要指向一个合理的地址,哪怕只是个占位符。

浮点/NEON 寄存器怎么办?

如果你的协程里用到了浮点运算或者 NEON 指令,那 V0~V31 也得保存。但说实话,大部分嵌入式场景用不到。我一般提供一个编译选项:

#ifdef CONFIG_COROUTINE_SAVE_FPU
    // 保存 V0~V31(每个 16 字节,共 512 字节)
    stp     q0, q1, [x0, #112]
    stp     q2, q3, [x0, #144]
    // ... 以此类推 ...
    stp     q30, q31, [x0, #1072]
#endif

不过要注意,保存浮点寄存器会显著增加切换开销。我个人建议:只在确实需要时开启,默认关掉。

性能实测:ARM64 协程切换有多快?

我在树莓派 4B(Cortex-A72)上测过,纯汇编切换一次大约 40~50 个 CPU 周期。换算成时间:

  • 1.5GHz 主频下,约 27~33 纳秒
  • 如果加上浮点寄存器保存,大约 80~100 纳秒

这个速度,比操作系统线程切换快了两个数量级。你想想看,一个系统调用就要几百纳秒,再加上上下文切换的 TLB 刷新、Cache 污染……协程的优势就在这里。

避坑指南:我遇到过的三个典型问题

  1. 栈对齐问题:ARM64 要求 SP 必须 16 字节对齐。我刚开始没注意,结果协程里一调用 printf 就崩。后来在初始化时强制对齐 SP:sp &= ~0xF
  2. LR 被意外修改:有一次我在协程切换前调用了调试打印函数,结果 LR 被覆盖了。从那以后,我规定切换函数内部不能调用任何其他函数,纯汇编搞定。
  3. 中断上下文切换:在中断处理函数里做协程切换?千万别!ARM64 的中断处理会使用独立的 SP_EL1 栈,和用户栈不同。我吃过一次亏,后来加了断言检查:assert(CurrentEL() == 0)

核心逻辑流程图

下面这张图展示了 ARM64 协程切换的完整数据流:

ARM64 协程切换数据流 当前协程 (Current) 保存 X19~X30 保存 SP, PC current->ctx 目标协程 (Target) 恢复 X19~X30 恢复 SP, PC target->ctx br x30 跳转 保存当前 → 恢复目标 → br x30 完成切换

小结

ARM64 的协程切换,核心就是 13 个寄存器的保存与恢复。代码量不大,但每个细节都关乎稳定性。我个人建议:

  • 先把基础版本跑通(不保存浮点寄存器)
  • 加上栈对齐检查
  • 再考虑优化和扩展

嗯,这一节就到这。代码虽然短,但值得你亲手敲一遍。调试时记得用 GDB 单步跟踪,看看寄存器是怎么流动的——那感觉,比看任何文档都直观。