前端也能看懂的协程底层:用 C 和 ARM 寄存器手搓一个有栈协程

Ryan
Ryan
发布时间:2026-08-24| 96人浏览过
原创

前言

很多语言都在讲协程,但一旦脱离具体实现,概念很容易越说越虚。这篇文章换一个更直接的办法:不从语法糖讲起,而是用一个基于 aarch64 macOS 的 C 语言玩具实现,看看协程最底层到底在切换什么、保存什么。读完你至少能判断三件事:有栈协程和线程的边界在哪里,最小协程结构体该保存哪些寄存器状态,以及为什么这类代码会频繁撞上 ABI、栈和编译器优化带来的细节问题。

很多语言都在讲协程,但一旦脱离具体实现,概念很容易越说越虚。这篇文章换一个更直接的办法:不从语法糖讲起,而是用一个基于 aarch64 macOS 的 C 语言玩具实现,看看协程最底层到底在切换什么、保存什么。读完你至少能判断三件事:有栈协程和线程的边界在哪里,最小协程结构体该保存哪些寄存器状态,以及为什么这类代码会频繁撞上 ABI、栈和编译器优化带来的细节问题。

协程到底是什么

不同语言里的协程外观差异很大,但如果只抓住共性,可以先把它理解成一种“用户态控制的执行单元”。这篇文章讨论的是有栈协程,不涉及无栈协程。

从 CPU 的角度看,真正关键的东西其实不多:要执行的指令,以及执行时使用的栈。进程和线程之所以能被 CPU 来回切换,是因为操作系统负责调度,也负责切换对应的执行现场。

如果把这件事换到用户态来做,程序员自己准备栈,自己保存和恢复执行位置,再自己决定切到哪个执行单元,这就进入了协程的范畴。换句话说,协程的核心不是某个语言关键字,而是执行现场由用户代码管理。

一个协程最少要保存哪些状态

如果把协程当成一个 C 结构体来看,它至少要回答两个问题:

  • CPU 接下来要执行哪段代码
  • 这些代码要在哪个栈上执行

这也是本文示例里结构体设计的出发点。

在这个层面,开发已经不是面向 API 或系统调用,而是更接近面向 ABI 和寄存器。程序员不能直接命令 CPU 做什么,但可以通过指令去修改寄存器的值,从而改变 CPU 后续的执行行为。

例如:

  • ret 会利用返回地址寄存器,间接决定下一条要执行的指令
  • mov 可以修改栈相关寄存器,让后续内存读写落到另一个栈上

因此,一个最小的有栈协程,通常要保存:

  • 栈顶位置
  • 栈帧基址
  • 返回地址或入口地址
  • 协程自身的栈内存
  • 执行完成标记

本文示例正是围绕这些状态展开。

示例里的协程结构体怎么设计

原始实现基于 aarch64 macOS,指令使用的是 ARM 指令,编译器使用系统自带的 gcc(clang)。下面这段代码保留了原文实现:

协程结构体保存的关键状态示意图
协程结构体的最小状态把协程看成一个结构体后,最关键的是保存栈位置、返回地址和结束状态。
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

#define STACK_SIZE 1024

typedef struct {
  /** 栈顶寄存器 */
  u_int64_t sp;

  /** 栈底寄存器 */
  u_int64_t x29;

  /** 返回地址寄存器 */
  u_int64_t x30;

  /** 主协程结束,跳回main函数使用 */
  u_int64_t endAddr;
  u_int64_t endSp;
  u_int64_t endX29;
  u_int64_t endX30;

  uint64_t entry;
  u_int8_t* stack;
  size_t size;

  /** 标记协程是否执行完毕 */
  int dead;
} Routine;

typedef void (*Entry)();

/** 记录当前执行的协程 */
Routine* current;

/** 模拟协程池 */
Routine queue[2];

Routine createRoutine(Entry entry) {
  u_int8_t* stack = (u_int8_t*)malloc(STACK_SIZE);
  if (stack == NULL) {
    perror("Failed to allocate stack memory");
    exit(EXIT_FAILURE);
  }

  Routine r;
  uint64_t aligned_sp = (uint64_t)(stack + STACK_SIZE - 1) & ~0xF; // 16字节对齐
  r.sp = aligned_sp;
  r.x29 = aligned_sp;
  r.entry = (uint64_t)entry;
  r.x30 = (uint64_t)entry;
  r.size = STACK_SIZE - 1;
  r.dead = 0;
  r.stack = stack;
  return r;
}

void switchToRoutine(Routine* routine) {
  __asm__ volatile(
    "add %0, sp, #32\n\t"
    "mov %1, x29\n\t"
    "mov %2, x30\n\t"
    : "=r"(current->sp), "=r"(current->x29), "=r"(current->x30)
    :
    : "memory"
  );

  current = routine;
  __asm__ volatile(
    "ldr x10, [%0]\n\t"
    "mov sp, x10\n\t"
    "ldr x29, [%1]\n\t"
    "ldr x30, [%2]\n\t"
    "ret\n\t"
    :
    : "r"(&current->sp), "r"(&current->x29), "r"(&current->x30)
    : "x10", "x30", "memory"
  );
}

void queueOneDead() {
  queue[1].dead = 1;
}

Routine* queueZero() {
  return &queue[0];
}

Routine* queueOne() {
  return &queue[1];
}

// 模拟用户给协程定义要执行的代码
void hello() {
  printf("hello\n");
  switchToRoutine(&queue[0]);
  printf("world\n");

  // 标记协程结束;
  // 之所以放在一个函数中完成,是因为编译器会优化代码,
  // 将 queue[1] 写入栈中,当写入queue[1]时,并不会
  // 更新全局作用域的queue[1], 而是更新栈上的内容;
  // 这会调至 switchToRoutine 跳转到另一个协程时,
  // 出现问题。
  queueOneDead();
  switchToRoutine(queueZero());
}

void mainRoutineEntry() {
  while(1) {
    printf("enter main routine\n");
    // 模拟调度逻辑
    // 模拟结束调度的条件,目前我们是一个协程,
    // 如果多个协程,改用循环语句判断
    if (queue[1].dead == 1) {
      printf("yes\n");
      Routine* a = queueZero();
      __asm__ volatile(
        "mov sp, %0\n\t"
        "mov x29, %1\n\t"
        "mov x30, %2\n\t"
        "ret\n\t"
        :
        : "r"(a->endSp), "r"(a->endX29), "r"(a->endX30)
      );
     } else {
      // 模拟选中下一个协程,切换过去
      switchToRoutine(&queue[1]);
     }
  }
};

void execute() {
  __asm__ volatile(
    "add sp, sp, #16\n\t"
    "mov x10, sp\n\t"
    "str x10, [%0]\n\t"
    "str x29, [%1]\n\t"
    "str x30, [%2]\n\t"
    "ldr x10, [%3]\n\t"
    "mov sp, x10\n\t"
    "ldr x29, [%4]\n\t"
    "ldr x30, [%5]\n\t"
    "ret\n\t"
    :
    : "r"(&queue[0].endSp), "r"(&queue[0].endX29), "r"(&queue[0].endX30), "r"(&queue[0].sp), "r"(&queue[0].x29), "r"(&queue[0].x30)
    : "x10", "x30", "memory"
  );
}

int main() {
  Routine routine = createRoutine(hello);
  Routine Main = createRoutine(mainRoutineEntry);

  queue[0] = Main;
  queue[1] = routine;

  current = &queue[0];
  execute();
  printf("ok, that's right\n");

  free((void *)(routine.stack));
  free((void*)(Main.stack));

  printf("wow\n");
  return 0;
}

这段结构体和初始化逻辑里,有几个点尤其关键:

1. 保存栈和返回位置

示例把 sp、x29、x30 都放进了 Routine。这三者分别对应当前栈顶、栈帧基址和返回地址,是恢复执行现场的核心。

2. 每个协程都要有独立栈

createRoutine 里通过 malloc(STACK_SIZE) 分配栈空间,并把栈顶按 16 字节对齐:

uint64_t aligned_sp = (uint64_t)(stack + STACK_SIZE - 1) & ~0xF; // 16字节对齐

这个细节和 ABI 有直接关系。如果栈没有按要求对齐,后续执行 C 函数或汇编切换时就可能出现异常行为。

3. 主协程还要保存“返回 main”所需状态

除了正常切换用的 sp、x29、x30,示例还额外准备了 endSp、endX29、endX30。它们的用途很明确:当所有普通协程执行完后,把执行流切回 main,再做资源释放和程序收尾。

协程切换到底在切什么

这套玩具实现的核心,不在“并发”,而在执行现场切换。所谓调度,放到这里其实很朴素:从队列里选出一个协程,把 CPU 当前寄存器保存下来,再把目标协程之前保存的寄存器恢复回去。

在 aarch64 体系里,文中重点提到:

  • x29 负责栈帧基址
  • sp 负责栈顶
  • x30 负责返回地址

而 bl 指令的作用,是跳转到某个函数,同时把下一条指令地址写入 x30。等函数执行到结尾,ret 会把 x30 的值写回到 PC,于是函数就“返回”了。

这也是示例为什么能通过改写 x30 来间接控制后续执行位置。直接去改 PC 不现实,但利用调用约定中的返回机制,可以达到类似效果。

switchToRoutine 做的事情,可以概括为两步:

  1. 把当前协程的 sp、x29、x30 保存到结构体里
  2. 从目标协程结构体里取出对应值,写回寄存器,再通过 ret 跳转

因此,切换协程本质上不是“调用另一个函数”,而是恢复另一个执行现场。

这份示例代码的整体执行流程

原文对整体思路做了概括,整理后可以更清楚地看成下面 4 步:

主协程与普通协程来回切换的执行流程图
示例代码的切换路径这份示例的重点不在并发,而在主协程、普通协程与 main 之间如何保存并恢复执行现场。
  1. 先创建主协程 Main 和普通协程 routine,并存入 queue。协程入口函数在 C 里本质上就是函数地址,因此可以用 uint64_t 保存,并在后续恢复到 x30 中。
  2. 调用 execute(),先启动主协程。同时把 execute 后续返回所需的寄存器状态记录下来,方便所有协程结束后切回 main。
  3. 主协程和普通协程之间来回切换执行。示例里的 hello() 会先输出 hello,切回主协程,再继续输出 world。
  4. 当普通协程标记为结束后,主协程恢复到 main 的上下文,释放栈内存,程序退出。

如果你的电脑也是 aarch64 macOS,可以直接按原文方式编译运行:

gcc main.c -o main

# 如果上面的代码无法执行起来,或者执行报错,可以尝试这种方式
gcc main.c -O1 -o main

原作者还给出了完整版说明,包含这份代码的缺陷、改进版本,以及使用 lldb 调试时常见指令:https://zhangzhuang15.github.io/blog/coroutine.html

为什么这种玩具实现很容易出问题

这类代码最容易踩坑的地方,恰恰也是它最有学习价值的地方:你会非常直接地碰到 ABI、编译器优化和内存访问规则。

协程玩具实现常见风险点对比图
底层协程实现的高风险细节真正难的不是把示例跑起来,而是保证改写寄存器、栈和全局状态后依然符合 ABI 与编译器预期。

原文提到,最突出的一个问题是非法内存访问。原因并不神秘:

内联汇编会直接修改 sp,而像 hello() 这样的 C 函数,在访问局部变量时本来就依赖当前 sp 进行寻址。如果 sp 被改到了不正确的位置,后续局部变量读写就可能落到错误内存区域,结果就是崩溃或行为异常。

另一个细节是编译器优化。原文专门把 queue[1].dead = 1 包进 queueOneDead() 函数中,理由是编译器可能会把某些写操作保留在当前栈帧或寄存器视角下,导致切换协程后观察到的全局状态不符合预期。也正因为如此,原文在编译时额外给出了 -O1 的尝试方式。

所以,这份代码更适合作为“理解协程底层切换”的样本,而不是生产环境方案。它展示的不是完整工程能力,而是最核心也最危险的那一层:只要你自己接管栈和返回地址,就必须自己承担现场保存恢复的全部正确性。

怎么理解这个实验的价值

如果只看高级语言里的 async、await 或生成器,协程常常像一个语言特性;但把它拆到 C 和汇编层,你会更容易看清本质:协程首先是执行流管理问题,其次才是语法设计问题。

这份 aarch64 macOS 上的玩具实现虽然粗糙,却把几个关键事实讲得很直白:

  • 有栈协程要自己准备栈
  • 切换协程本质是切换寄存器和返回位置
  • 调度就是选择下一个要恢复的执行现场
  • 一旦进入这层实现,ABI 与编译器细节都会立刻变成实际问题

对于想把“协程”这个词真正落到机器执行层的人来说,这种自己动手做一个最小版本的过程,比单纯看概念定义更有效。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。