写点什么

协程实现方式——从程序控制流转移谈起

作者:Jowin
  • 2021 年 12 月 07 日
  • 本文字数:3834 字

    阅读完需:约 13 分钟

1. 内核转移控制

内核控制转移通常是在发生中断或系统调用的场景下,用户程序被打断,这时需要保存所有的执行环境,这其中包括 FPU 状态(实际上只是在必要时保存)。


而用户态的控制转移,是主动选择的,也就是说不存在一个浮点计算执行了一半被打断的情况,所以通常不需要保存完整的fpu环境,只需要根据调用约定(参考x86 and floating-point env),保存几个控制寄存器。


需要说明的是,一直以来 MMX、SSE 是与 FPU 集成在一起的,所以常说的FPU环境,包括所有这些指令体系相关的执行状态。

2. 用户态转移控制

2.1. goto

C 语言的 goto语句 只是一个jmp指令,当前栈帧不发生切换,因此只能在函数内部跳转。


C 语言中使用goto很容易引发内存 BUG(goto 跳过内存释放语句),C++ 的RAII机制很容易解决这个问题。C++保证当退出一个作用域时,所有栈上对象都会被销毁,即便是通过 goto 退出作用域也不例外。


注: goto语句是语言内置支持的,编译器针对这个语句可以做特殊处理,在不同的出口分支都安置析构函数。
复制代码

2.2. sigsetjmp/siglongjmp

C 的标准库里面实现了setjmp/longjmp这一对函数来实现跨栈的跳转功能,这个版本不保存/恢复信号掩码,被 sigsetjmp/siglongjmp 所替代。


int sigsetjmp(sigjmp_buf env, int savesigs)函数返回两次:


  • 第一次是直接返回,返回值 0;

  • 第二次是void siglongjmp(sigjmp_buf env, int val)触发返回,返回值为 val(不能是 0);


siglongjmp() 打断当前函数的执行,跳转到一个簿记地址执行,也实现了栈帧的切换。也因此,导致 C++的 RAII 机制失效。


参考setjmplongjmp汇编代码,sigsetjmp/siglongjmp函数保存/恢复的上下文环境:


  • 寄存器:rbx、rbp、r12、r13、r14、r15、sigsetjmp()返回地址、rsp

  • 由宏定义控制,是否使用 shadow stack(当前函数的 ssp);

  • 信号掩码,调用sigprocmask


    sigjmp_buf结构定义参考:
# if __WORDSIZE == 64 typedef long int __jmp_buf[8]; # else typedef int __jmp_buf[6]; # endif
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int))) typedef struct { unsigned long int __val[_SIGSET_NWORDS]; } __sigset_t;
struct __jmp_buf_tag { /* NOTE: The machine-dependent definitions of `__sigsetjmp' assume that a `jmp_buf' begins with a `__jmp_buf' and that `__mask_was_saved' follows it. Do not move these members or add others before it. */ __jmp_buf __jmpbuf; /* Calling environment. */ int __mask_was_saved; /* Saved the signal mask? */ __sigset_t __saved_mask; /* Saved signal mask. */ };
可以看出,sigjmp_buf和jump_buf定义兼容,setjmp(jmup_buf env) => sigsetjmp(env,0)
复制代码

2.3. ucontext 函数族

posix 标准定义了一系列的上下文切换函数,试图修复 sigsetjmp/siglongjmp 函数的缺点(浮点环境保护和跨线程跳转的 shadow stack 处理?),并标准化(在 POSIX 2008 中,由于难以以真正可移植的方式实现它们,setcontext 和相关函数被删除)。


setcontext相关函数有四个:


    int getcontext(ucontext_t *ucp);    int setcontext(const ucontext_t *ucp);    void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);    int swapcontext(ucontext_t *oucp, ucontext_t *ucp);
复制代码


一般的程序跳转只需要setcontextgetcontext两个就可以了,可以直接实现跳转,与sigsetjmp/siglongjmp使用上的区别是不用再判断返回值。


makecontext函数用来创建上下文环境。在一个已有上下文环境上(通过 getcontext()获得)安排执行新的函数,当新的函数退出后,再继续原来的上下文环境。类似boost.contextontop_fcontext(),下面有说明。


swapcontext函数是保存当前上下文,并切换到新的上下文。


参考 ucontext.hgetcontextsetcontext源码,ucontext函数族保存/恢复的上下文环境:


  • 通用寄存器,CS、GS、FS 寄存器,标志寄存器 EFL、异常错误代码 ERR、异常向量数 TRAPNO、控制寄存器 CR2 等。

  • shadow stack(包括 shadow stack base、调用函数的 ssp、当前函数的 ssp。每个线程对应一个 shadow stack,base 地址在 fs 寄存器中保存,ucontext 函数族处理了跨线程的 fs 切换)。

  • 浮点数运行环境(FXSAVE — Save x87 FPU, MMX Technology, and SSE State)

  • 信号掩码,调用 rt_sigprocmask()。

2.4 boost.context

boost.context提供了一个上下文切换实现,包括三个函数:


    typedef void*   fcontext_t;
struct transfer_t { fcontext_t fctx; void * data; }; transfer_t jump_fcontext(fcontext_t const to, void * vp); fcontext_t make_fcontext(void * sp, std::size_t size, void (* fn)( transfer_t)); transfer_t ontop_fcontext(fcontext_t const to, void * vp, transfer_t (* fn)( transfer_t));
复制代码


make_fcontext函数创建执行上下文,jump_fcontext函数跳转到新的上下文执行,ontop_fcontext跳转到新的上下文,先执行指定的 fn 函数,在继续执行。


boost.context 使用上有两个有意思的地方:


  • jump_fcontext 可以向即将执行的目标上下文上传递参数void*

  • jump_fcontext函数返回了一个上下文,指向当前上下文从哪个上下文跳转过来。


参考boost.context代码注释,相比较于setjmpgetcontext,boost.context 保存的上下文环境小了很多:


 *  ----------------------------------------------------------------------------------  * *  |    0    |    1    |    2    |    3    |    4     |    5    |    6    |    7    |  * *  ----------------------------------------------------------------------------------  * *  |   0x0   |   0x4   |   0x8   |   0xc   |   0x10   |   0x14  |   0x18  |   0x1c  |  * *  ----------------------------------------------------------------------------------  * *  | fc_mxcsr|fc_x87_cw|        R12        |         R13        |        R14        |  * *  ----------------------------------------------------------------------------------  * *  |    8    |    9    |   10    |   11    |    12    |    13   |    14   |    15   |  * *  ----------------------------------------------------------------------------------  * *  |   0x20  |   0x24  |   0x28  |  0x2c   |   0x30   |   0x34  |   0x38  |   0x3c  |  * *  ----------------------------------------------------------------------------------  * *  |        R15        |        RBX        |         RBP        |        RIP        |  * *  ----------------------------------------------------------------------------------  *
1. 通用寄存器: `R12、R13、R14、R15、RBX、RBP、RIP(返回地址)`2. 2个浮点数控制寄存器: /* save MMX control- and status-word */ stmxcsr (%rax) /* save x87 control-word */ fnstcw 0x4(%rax)
复制代码


boost.context没有使用shadow stack做栈保护(具体参考协程笔记),没有处理信号掩码。

2.5. libco

微信的协程库libco实现了自己的上下文切换代码,参考代码注释libco可以发现,它也没有使用shadow stack做栈保护(具体参考协程笔记),没有处理信号掩码,没有保护FPU环境。

3. FAQ

3.1 什么是 shadow stack?

影子栈是一种保护函数返回地址免受堆栈缓冲区溢出的机制。影子栈本身是第二个单独的堆栈,它“影子”程序调用堆栈。在进入函数时,函数将其返回地址存储到调用堆栈和影子栈。在退出函数时,函数从调用堆栈和影子栈加载返回地址,然后比较它们。如果返回地址的两个记录不同,则检测到攻击。


影子堆类似于堆栈金丝雀,这两种机制的目的都是通过检测攻击者在利用企图期间篡改存储的返回地址的攻击来维护受保护程序的控制流完整性。


影子栈面临一些兼容性问题。当程序抛出异常或出现longjmp时,影子栈顶部的返回地址将与调用堆栈弹出的返回地址不匹配。这个问题的典型解决方案是从影子堆栈中弹出条目,直到找到匹配的返回地址,并且只有在影子堆栈中没有找到匹配时才终止程序。


一个多线程程序,对于每个执行线程都有一个调用堆栈,也会有一个影子栈来跟踪每个调用堆栈。

3.2 Linux 内核如何处理浮点数执行环境?

内核代码通常会避免使用 FPU,Linux 将 FPU 状态保留为从系统调用进入时未保存的状态,仅在实际上下文切换到其他 用户空间进程之前保存kernel_fpu_begin。通常会在相同的内核上返回相同的用户空间进程,因此无需恢复 FPU 状态,因为内核没有碰到它。


如果是软件中的函数重入,这是没有关系的,算好后才能执行下一指令。


如果是多线程的并行浮点运算,在操作系统切换上下文的时候,会判断程序是否使用了 FPU,如果使用了,则同时保存 FPU 的上下文,所以可以并行运算浮点。


如果是中断发生,那么 CPU 会从 IVT(x86 处理器实模式下)或 IDT(x86 处理器保护模式下)中找到位于内核态的中断处理程序,并且在这个时候由硬件切换到内核态(x86 实模式下不存在内核态这个说法),各个操作系统都不允许在内核态中使用浮点数,因此不会出现中断重入导致的浮点运算问题。


参考 抽丝剥茧 Linux 浮点运算的原理

3.3 线程切换流程

参考 Steps in Context Switching

发布于: 2021 年 12 月 07 日阅读数: 8
用户头像

Jowin

关注

爱代码,爱生活 2013.08.19 加入

C++/Go Programmer,关注分布式存储,目前从事金融数据开发。

评论

发布
暂无评论
协程实现方式——从程序控制流转移谈起