基于longjmp的手动栈切换栈式协程崩溃问题及longjmp对主栈的依赖探究
基于longjmp的手动栈切换栈式协程崩溃问题及longjmp对主栈的依赖探究
我太懂你现在的挫败感了——手动抠寄存器切换栈实现栈式协程,结果不管是协程内部跳转还是跳回主栈,直接崩在ntdll的chkstk()里,用的还是MinGW GCC 14.2.0 x86_64。咱们先把你的代码摆出来,再一步步拆解根因:
#include <iostream> #include <functional> #include <cstdint> #include <csetjmp> #include <memory> #include <cstdint> struct coroutine_context { std::jmp_buf exit; std::size_t stack_memory_size = 0; std::unique_ptr<std::byte[]> stack_memory; std::function<void(coroutine_context&)> coroutine_body; int value_thoroughfare = 0; }; #define SET_RSP(new_rsp) \ asm volatile ( \ "mov %0, %%rsp" \ : \ : "r" (new_rsp) \ : "memory" \ ) std::uintptr_t get_stack_base(const coroutine_context& context) { const static bool towards_address_LSB = []() -> bool { volatile int a; volatile int b; return &a > &b; }(); if(towards_address_LSB) return (reinterpret_cast<std::uintptr_t>(context.stack_memory.get() + context.stack_memory_size) & (~0xF)); else return (reinterpret_cast<std::uintptr_t>(context.stack_memory.get()) & (~0xF)) + 0x10; } #if 0 std::jmp_buf coro_jmp_buf; void coro_test(coroutine_context& context) { if(setjmp(coro_jmp_buf) == 0) // Note: Even if the position where setjmp is called is within a coroutine, longjmp will crash after being called. std::longjmp(coro_jmp_buf, 1); context.value_thoroughfare = 114514; std::longjmp(context.exit, 1); } #else void coro_test(coroutine_context& context) { context.value_thoroughfare = 114514; std::longjmp(context.exit, 1); // The program crashes here. } #endif void create_coroutine_stack_frame(coroutine_context& context) { if(setjmp(context.exit)) return; SET_RSP(get_stack_base(context)); coro_test(context); } int main() { coroutine_context context; context.stack_memory_size = 1024 * 16; context.stack_memory = std::make_unique<std::byte[]>(1024 * 16); context.coroutine_body = coro_test; create_coroutine_stack_frame(context); std::cout << context.value_thoroughfare; return 0; }
一、崩溃的核心:Windows x64栈检查机制与手动栈的冲突
你遇到的chkstk()崩溃,本质是Windows x64线程的栈范围校验机制在搞事情:
- TEB的栈范围锁定:Windows x64每个线程的
TEB(线程环境块,存在FS:0地址)里,有StackBase和StackLimit两个字段,记录了当前线程默认栈的上下边界。 - chkstk的强制检查:GCC在MinGW环境下,会给需要栈空间的函数(比如
longjmp、有大局部变量的函数)插入chkstk()调用,用来检查当前RSP是否在TEB记录的栈范围内。如果不在,直接触发段错误。 - 手动栈的“非法身份”:你在堆上分配的协程栈,完全没更新
TEB里的栈范围。当切换到协程栈后,RSP落在了主栈的StackLimit之外(因为主栈从高地址往低地址增长,堆分配的栈地址可能比主栈下限还低),chkstk()一检查就判定栈越界,直接崩了。
二、setjmp/longjmp的隐藏依赖:不止是寄存器
你可能以为setjmp只保存寄存器状态,但在MinGW的实现里,它还间接依赖当前的栈上下文:
jmp_buf里虽然没直接存TEB信息,但longjmp执行时,会假设当前栈和setjmp保存时的栈是同一个连续栈。- 当你跨栈(主栈→协程栈)使用
longjmp时,longjmp内部的栈操作(比如建立自己的栈帧)会触发chkstk()检查,而此时RSP不在TEB的合法范围内,直接触发崩溃。
三、两种场景的具体崩溃拆解
场景1:协程内部的setjmp+longjmp(#if 0分支)
当你在协程栈上调用setjmp(coro_jmp_buf),再立刻longjmp回去:
setjmp保存的jmp_buf里,包含了协程栈的RSP和返回地址(这个返回地址是主栈上create_coroutine_stack_frame里调用coro_test的位置)。longjmp恢复后,coro_test继续执行后续代码,但此时栈帧的上下文已经混乱:后续要返回的地址在主栈,而当前RSP在协程栈,函数返回时的栈清理操作会触发chkstk()检查,直接崩掉。
场景2:协程跳回主栈的longjmp(#else分支)
当你在协程里调用longjmp(context.exit)(context.exit是主栈上setjmp保存的):
- 调用
longjmp本身就需要在当前协程栈上建立栈帧,这个操作会触发chkstk()检查栈空间是否足够。 - 此时
TEB里的StackLimit还是主栈的下限,协程栈的RSP已经低于这个值,chkstk()判定栈越界,直接触发SIGSEGV。
四、解决思路
要解决这个问题,你需要让手动栈被Windows的栈机制“认可”,或者绕过栈检查:
- 临时禁用栈检查:编译时加
-fno-stack-check选项,让GCC不插入chkstk()调用,这是最快验证根因的方法。 - 更新TEB的栈范围:通过Windows API修改当前线程的
TEB中StackBase和StackLimit,让系统把堆分配的栈当成合法栈。你可以用NtSetInformationThread或者直接修改FS:0指向的TEB结构(注意权限和结构偏移,x64下TEB的StackBase偏移是0x8,StackLimit是0x10)。 - 用系统API分配合法栈:不要用
std::unique_ptr直接分配,改用VirtualAlloc分配带保护页的栈,这样栈扩展时会自动触发页分配,同时更新TEB的栈范围(或者手动更新)。
结论
手动实现栈式协程在Windows MinGW环境下,不能只单纯切换RSP——必须处理Windows线程的栈上下文(TEB),否则chkstk()这类系统级栈检查会直接判定非法。setjmp/longjmp的跨栈使用,本质上是因为它们依赖当前线程的默认栈上下文,脱离这个上下文就会触发崩溃。
内容来源于stack exchange
相关产品推荐
相关产品推荐

