【问题标题】:How can I create a parallel stack and run a coroutine on it?如何创建并行堆栈并在其上运行协程?
【发布时间】:2011-03-06 14:43:17
【问题描述】:

我决定我应该尝试实现协程(我认为我应该这样称呼它们)以获得乐趣和利润。我希望必须使用汇编程序,如果我想让它对任何事情真正有用,可能还需要一些 C。

请记住,这是出于教育目的。使用已经构建好的协程库太容易了(而且真的没有乐趣)。

你们知道setjmplongjmp 吗?它们允许您将堆栈展开到预定义的位置,并从那里恢复执行。但是,它不能倒退到堆栈上的“稍后”。只能早点回来。

jmpbuf_t checkpoint;
int retval = setjmp(&checkpoint); // returns 0 the first time
/* lots of stuff, lots of calls, ... We're not even in the same frame anymore! */
longjmp(checkpoint, 0xcafebabe); // execution resumes where setjmp is, and now it returns 0xcafebabe instead of 0

我想要的是一种无需线程即可在不同堆栈上运行两个函数的方法。 (显然,一次只运行一个。没有线程,我说过。)这两个函数必须能够恢复另一个的执行(并停止它们自己的)。有点像他们对对方是longjmping。一旦它返回到另一个函数,它必须从它离开的地方恢复(也就是说,在将控制权交给另一个函数的调用期间或之后),有点像 longjmp 返回到 setjmp 的方式。

我是这样想的:

  1. 函数A 创建并行堆栈并将其归零(分配内存等)。
  2. 函数A 将其所有寄存器推入当前堆栈。
  3. 函数A 将堆栈指针和基指针设置到该新位置,并压入一个神秘的数据结构,指示要跳转回的位置以及将指令指针设置回的位置。
  4. 函数A 将其大部分寄存器归零,并将指令指针设置为函数B 的开头。

那是为了初始化。现在,以下情况将无限循环:

  1. 函数B 在该堆栈上工作,做它需要做的任何工作。
  2. 函数B 需要中断并再次给予A 控制权。
  3. 函数B 将其所有寄存器推入堆栈,采用神秘的数据结构 A 一开始就给它,并将堆栈指针和指令指针设置为@ 987654337@告诉它。在此过程中,它交还 A 一个新的、修改后的数据结构,该结构告诉在哪里恢复 B
  4. 函数A 被唤醒,将其推入堆栈的所有寄存器弹回,并继续工作,直到它需要中断并再次给予B 控制权。

这一切对我来说都很好。但是,有很多事情我并不完全放心。

  • 显然,在好的 ol' x86 上,有这条 pusha 指令会将所有寄存器发送到堆栈。然而,处理器架构不断发展,现在有了 x86_64,我们有了更多的通用寄存器,并且可能还有几个 SSE 寄存器。我找不到任何证据表明pusha 确实推动了他们。现代 x86 CPU 中大约有 40 个公共寄存器。我必须自己完成所有pushes 吗?此外,没有用于 SSE 寄存器的 push(尽管肯定会有一个等价物——我对整个“x86 汇编器”这件事很陌生)。
  • 改变指令指针就这么简单吗?我可以像mov rip, rax(英特尔语法)那样做吗?此外,由于它不断变化,因此从中获取价值必须有些特殊。如果我喜欢mov rax, rip(又是英特尔语法),rip 会定位在mov 指令上,还是在它之后的指令上,或者介于两者之间? 只是jmp foo。假人。
  • 我曾多次提到神秘的数据结构。到目前为止,我假设它至少需要包含三件事:基指针、堆栈指针和指令指针。还有什么吗?
  • 我是不是忘了什么?
  • 虽然我真的很想了解事情是如何工作的,但我很确定有一些库可以做到这一点。你知道任何?是否有任何 POSIX 或 BSD 定义的标准方法,例如 pthread 用于线程?

感谢您阅读我的问题文字墙。

【问题讨论】:

  • 我记得上个学期在我的编程语言课程中读到了协程和仙人掌堆栈。只能说祝你好运,请不要把你的野心用于邪恶。
  • 在 [3] 中:'59 6F 75 20 73 68 6F 75 6C 64 20 67 65 74 20 61 20 6C 69 66 65 2E 20 53 65 72 69 6F 75 73 6C 79 2E'。 (' ', '').decode('hex') Out[3]: '你应该得到一条生命。严重地。'呸呸呸

标签: c assembly stack x86-64 coroutine


【解决方案1】:

很好的学习参考:libcoroutine,尤其是他们的 setjmp/longjmp 实现。我知道使用现有的库并不好玩,但你至少可以大致了解你要去哪里。

【讨论】:

  • 非常感谢!我会调查它(尤其是ucontext,因为它似乎只是在没有做所有工作的情况下解决了问题)。
【解决方案2】:

Simon Tatham 有一个interesting implementation of coroutines in C,它不需要任何特定于架构的知识或堆栈摆弄。这并不完全是您所追求的,但我认为它可能至少具有学术兴趣。

【讨论】:

    【解决方案3】:

    你是对的,PUSHA 不会在 x64 上工作,它会引发异常 #UD,因为PUSHA 推送 16 位或 32 位通用寄存器。请参阅Intel manuals 了解您想知道的所有信息。

    设置RIP 很简单,jmp rax 会将RIP 设置为RAX。要检索 RIP,如果您已经知道所有协程退出来源,您可以在编译时获取它,或者您可以在运行时获取它,您可以在调用之后调用下一个地址。像这样:

    a:
    call b
    b:
    pop rax
    

    RAX 现在将是 b。这是因为CALL 推送了下一条指令的地址。这种技术也适用于 IA32(尽管我认为在 x64 上有更好的方法,因为它支持 RIP 相对寻址,但我不知道有哪一种)。当然如果你做一个函数coroutine_yield,它就可以截取调用者地址:)

    由于您不能在一条指令中将所有寄存器推入堆栈,因此我不建议将协程状态存储在堆栈中,因为无论如何这会使事情变得复杂。我认为最好的做法是为每个协程实例分配一个数据结构。

    为什么要在函数A 中归零?这可能没有必要。

    以下是我将如何处理整个事情,并尝试使其尽可能简单:

    创建一个包含以下内容的结构coroutine_state

    • initarg
    • arg
    • registers(也包含标志)
    • caller_registers

    创建一个函数:

    coroutine_state* coroutine_init(void (*coro_func)(coroutine_state*), void* initarg);

    其中coro_func是一个指向协程函数体的指针。

    此函数执行以下操作:

    1. 分配一个coroutine_state结构cs
    2. initarg 分配给cs.initarg,这些将是协程的初始参数
    3. coro_func 分配给cs.registers.rip
    4. 将当前标志复制到cs.registers(不是寄存器,只有标志,因为我们需要一些健全的标志来防止天启)
    5. 为协程的堆栈分配一些大小合适的区域并将其分配给cs.registers.rsp
    6. 返回指向分配的coroutine_state结构的指针

    现在我们有了另一个函数:

    void* coroutine_next(coroutine_state cs, void* arg)

    其中cs 是从coroutine_init 返回的结构,表示协程实例,arg 将在协程恢复执行时被输入。

    这个函数被协程调用者调用,向协程传递一些新参数并恢复它,这个函数的返回值是协程返回(产生)的任意数据结构。

    1. 将所有当前标志/寄存器存储在cs.caller_registers 中,RSP 除外,请参见步骤 3。
    2. arg 存储在cs.arg
    3. 修复调用程序堆栈指针(cs.caller_registers.rsp),如果幸运的话,添加2*sizeof(void*) 将修复它,你必须查看它以确认它,你可能希望这个函数是 stdcall 所以没有寄存器在调用它之前被篡改
    4. mov rax, [rsp],将RAX分配给cs.caller_registers.rip;说明:除非您的编译器已破解,否则[RSP] 将保存指向调用此函数的调用指令后面的指令的指令指针(即:返回地址)
    5. cs.registers加载标志和寄存器
    6. jmp cs.registers.rip,有效恢复协程的执行

    请注意,我们永远不会从这个函数返回,我们跳转到的协程为我们“返回”(参见coroutine_yield)。另请注意,在此函数中,您可能会遇到许多复杂情况,例如 C 编译器生成的函数序言和结尾,以及注册参数,您必须处理所有这些。就像我说的,stdcall 会为你省去很多麻烦,我认为 gcc 的 -fomit-frame_pointer 会删除结尾的东西。

    最后一个函数声明为:

    void coroutine_yield(void* ret);

    这个函数在协程内部被调用来“暂停”协程的执行,并返回给coroutine_next的调用者。

    1. 存储标志/寄存器in cs.registers
    2. 修复协程栈指针(cs.registers.rsp),再次添加2*sizeof(void*),并且你希望这个函数也是stdcall
    3. mov rax, arg(让我们假设编译器中的所有函数都在 RAX 中返回它们的参数)
    4. cs.caller_registers加载标志/寄存器
    5. jmp cs.caller_registers.rip 这实质上是从协程调用程序的堆栈帧上的coroutine_next 调用返回的,并且由于返回值是在RAX 中传递的,所以我们返回了arg。假设argNULL,那么协程就终止了,否则就是任意数据结构。

    回顾一下,您使用coroutine_init 初始化一个协程,然后您可以使用coroutine_next 重复调用实例化的协程。

    协程的函数本身被声明: void my_coro(coroutine_state cs)

    cs.initarg 保存初始函数参数(想想构造函数)。每次调用my_coro 时,cs.arg 都有一个由coroutine_next 指定的不同参数。这就是协程调用者与协程通信的方式。最后,每次协程想要暂停自己时,它都会调用coroutine_yield,并传递一个参数给它,这是协程调用者的返回值。

    好的,您现在可能认为“这很容易!”,但我忽略了以正确顺序加载寄存器和标志的所有复杂性,同时仍保持未损坏的堆栈帧并以某种方式保持协程数据结构的地址(您只是以线程安全的方式覆盖了所有寄存器)。对于那部分,您将需要了解您的编译器在内部是如何工作的......祝你好运:)

    【讨论】:

    • 哇,要说的东西太多,每条评论中允许的字符太少!第一件事。是的,归零寄存器可能没用。翻来覆去,我从 XNU 项目中找到了 Apple 的 getcontext/setcontext 实现:opensource.apple.com/source/Libc/Libc-594.1.4/x86_64/gen 他们通过取消引用 rsp 来获取 rip(不涉及整个函数的堆栈),这是有道理的,因为我们希望返回调用开始getcontext 将离开的地方,而不是 getcontext 的某个地方。他们还保留了非 GPR 寄存器。
    • 为什么我们需要复制标志?不复制它们只会让它们保持原样,不是吗?对于通过寄存器传递的参数,在 Mac OS x86 上没有什么可做的。 八个 寄存器专用于参数传递。另一方面,这可能是一个优势,因为这意味着我必须保存更少的东西(它们是调用者保存的)。
    • @zneak:好吧,假设一个正在执行的协程函数以某种方式设置方向标志,然后让步。恢复后,我猜您想恢复该标志。 (再说一次,在调用普通函数时通常没有人保存标志......)我不确定,您粘贴的链接中有 RFLAGS 的定义,但他们从未触及它,这完全取决于您的编译器和运行时是如何设置。
    • 实际上,他们在 DEBUG 中构建时会保存它,并在 RELEASE 中单独保留它。我不认为他们曾经恢复它(也许这真的是为了调试目的,比如在某个时刻通过 getcontext “检查 rflags 中的内容”)。
    • 请注意,您还需要保存许多其他寄存器,例如 FPU 和 Vector (XMM/SSE/AVX)。在此处查看 Microsoft 的 RtlCaptureContext 实现github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/rtl/…
    【解决方案4】:

    boost.org 上的 boost.coroutine (boost.context) 为你做所有事情

    【讨论】:

      猜你喜欢
      • 2014-12-28
      • 2019-12-18
      • 1970-01-01
      • 2021-12-23
      • 2021-02-23
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多