协程实现方式——从程序控制流转移谈起
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 退出作用域也不例外。
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 机制失效。
参考setjmp和longjmp汇编代码,sigsetjmp/siglongjmp
函数保存/恢复的上下文环境:
寄存器:
rbx、rbp、r12、r13、r14、r15、sigsetjmp()返回地址、rsp
;由宏定义控制,是否使用 shadow stack(当前函数的 ssp);
信号掩码,调用
sigprocmask
;
2.3. ucontext 函数族
posix 标准定义了一系列的上下文切换函数,试图修复 sigsetjmp/siglongjmp 函数的缺点(浮点环境保护和跨线程跳转的 shadow stack 处理?),并标准化(在 POSIX 2008 中,由于难以以真正可移植的方式实现它们,setcontext 和相关函数被删除)。
setcontext
相关函数有四个:
一般的程序跳转只需要setcontext
和getcontext
两个就可以了,可以直接实现跳转,与sigsetjmp/siglongjmp
使用上的区别是不用再判断返回值。
makecontext
函数用来创建上下文环境。在一个已有上下文环境上(通过 getcontext()获得)安排执行新的函数,当新的函数退出后,再继续原来的上下文环境。类似boost.context
的ontop_fcontext()
,下面有说明。
swapcontext
函数是保存当前上下文,并切换到新的上下文。
参考 ucontext.h、getcontext和setcontext源码,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
提供了一个上下文切换实现,包括三个函数:
make_fcontext
函数创建执行上下文,jump_fcontext
函数跳转到新的上下文执行,ontop_fcontext
跳转到新的上下文,先执行指定的 fn 函数,在继续执行。
boost.context
使用上有两个有意思的地方:
jump_fcontext
可以向即将执行的目标上下文上传递参数void*
。jump_fcontext
函数返回了一个上下文,指向当前上下文从哪个上下文跳转过来。
参考boost.context代码注释,相比较于setjmp
和getcontext
,boost.context 保存的上下文环境小了很多:
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 实模式下不存在内核态这个说法),各个操作系统都不允许在内核态中使用浮点数,因此不会出现中断重入导致的浮点运算问题。
3.3 线程切换流程
版权声明: 本文为 InfoQ 作者【Jowin】的原创文章。
原文链接:【http://xie.infoq.cn/article/04bcb36bed8860b96c86f61f8】。文章转载请联系作者。
评论