在听完leader的<分布式rpc框架的介绍>课程后,对其中协程的实现方式有了基本的了解,无论的POSIX的ucontex,boost::fcontext,还是libco,都是通过保存和恢复寄存器状态,来进行各个协程上下文的保存和切换。所以有了这篇对ucontext实现原理的分析。
文章首先初略的温习了一下汇编的一些基础知识,其次就是ucontext源码分析,最后是一个ucontext示例极其调试过程。
欢迎批评指正
1. 汇编基础
8086/8088的cpu的寄存器都是16位的,我相信这是我们最熟悉的CPU,因为大学的时候大家的汇编语言课程千篇一律都是讲这一代cpu的,到了80x86的时候,cpu都是32位的,再后来都是64位的时代了。
1.1 寄存器分类
下面是一些对开发者来说,相对重要的寄存器,也是分析ucontext源码的全部寄存器:
这些寄存器是最基本也是汇编中直接使用的寄存器最多的。
其中AX,BX,CX,DX这些寄存器用来保存操作数和运算结果等信息,从而节省读取操作数所需占用总线和访问存储器的时间,可以认为随便用。
- 指针寄存器:SP,BP,用于维护和访问堆栈存储单元。
BP为基指针(Base Pointer)寄存器,用它可直接存取堆栈中的数据;
SP为堆栈指针(Stack Pointer)寄存器,用它只可访问栈顶。 - 变址寄存器:寄存器SI,DI称为变址寄存器(Index Register),它们主要用于存放存储单元在段内的偏移量,
- 指令指针寄存器
指令指针IP(Instruction Pointer)是存放下次将要执行的指令在代码段的偏移量。在具有预取指令功能的系统中,下次要执行的指令通常已被预取到指令队列中,除非发生转移情况。
这里没有列出所有的寄存器,比如说各个段寄存器CS,DS,ES,SS,GS,访问进程各个段空间的基本地址。这些寄存器伴随这每一条指令的执行,所以很重要,但这里不详解,因为这些寄存器的使用,对汇编代码的开发者说,很多时候是透明的。
1.2.指令格式
下面列出了简单的汇编指令,知道这些指令的含义看汇编代码就不会有什么问题了:
指令类型 | 名称 |
---|---|
通用数据传输指令 | mov, push,pop, lea |
算术指令 | add,adc,inc,sub,sbb,dec,neg,cmp,mul,imul,div,idiv |
逻辑指令 | and,or,xor,not,shl,shr,test |
控制转移指令 | jmp,call,ret,retf |
这里要说明的是:在like unix系统上的汇编语法格式采用AT&T 格式,和Wins下面的intel风格有很大差异,这里只列出三点:
- AT&T格式和Intel格式的指令的源操作数和目的操作数的顺序是相反的
AT&T格式 | Intel格式 |
---|---|
mov %rax %rdi | mov rdi rax |
上面指令的含义是将rax寄存器的值存入rdi寄存器中
- AT&T格式寄存器操作数前面要加上‘%’,Intel格式不需要,从上面一点可以看出;
- AT&T格式指令如果要控制操作数长度是通过指令来进行的,Intel格式是通过在操作数前加限定来进行:
AT&T格式 | Intel格式 |
---|---|
movb val, %al | mov al byte ptr val |
所以你会在AT&T格式的指令中,看到各种mov:movb,movw,movl,movq,分别代表8bits, 16bits, 32bits, 64bits。同样其他指令也是这样。
为了能够清晰的看懂ucontext族的glibc的汇编实现,这里还要说明几点:
- 汇编中的寻址方式:立即数寻址, 直接寻址,间接寻址,变址寻址;下面是AT&T指令格式示例:
寻址方式 | 指令 | AT&T格式 |
---|---|---|
立即数寻址 | movl $0x123, %edx | 数字->寄存器 |
直接寻址 | movl 0x123, %edx | 0x123指向内存数据->寄存器 |
间接寻址 | movl (%ebx), %edx | ebx寄存器指向内存数据-> edx寄存器 |
变址寻址 | movl 4(%ebx), %edx | ebx+4指向内存数据-> edx寄存器 |
- lea指令,装入有效地址到寄存器;
- 跳转指令:call,ret
cpu执行call跳转指令时,cpu做了如下操作:
1 | rsp = rsp – 8 |
类似ret指令会将栈顶的内容弹出到rip寄存器中,继续执行:
1 | rip = rsp |
1.3. gcc关于寄存器的使用
GCC中对这些寄存器的调用规则如下:
- rax 作为函数返回值使用。
- rsp 栈指针寄存器,指向栈顶;
- rdi,rsi,rdx,rcx,r8,r9 用作函数参数,依次对应第1参数,第2参数。。。当参数超过6个,才会通过压栈的方式传参数。
- rbx,rbp,r12,r13,r14,r15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改;
- r10,r11 用作数据存储,遵循调用者使用规则,简单说就是使用之前要先保存原值;
要想看懂linux下的汇编源码,这些规则是很很很重要的,否则下面ucontext族的汇编实现会看的一脸蒙逼.
2. ucontext分析
协程切换的时候,保存当前协程的上下文,主要是各个寄存器和信号状态。我们先看看POSIX标准提供的用于用户级线程切换的接口ucontext族函数:
1 | /* Userlevel context. */ |
ucontext提供的一套api接口,有以下四个个:
1 | int getcontext(ucontext_t *ucp); |
具体功能:getcontext获取线程的当前上下文;setcontext相反是从ucp中恢复出上下文;makecontext是修改ucp指向的上下文环境,swapcontext是保存当前上下文,并切换到新的上下文。下面看他们的具体实现:
2.1. getcontext实现
我们看看getcontext的glibc实现:
1 | ENTRY(__getcontext) |
getcontext的汇编代码中,第一部分就是保存当前上下文中的各个寄存器到第一个参数rdi中,即ucontext_t中,其中目标操作数(%rdi)前面的oRBX,oRBP…的含义如下,
1 | sysdeps/unix/sysv/linux/x86_64/ucontext_i.sym |
所以oRBP = offsetof(ucontext_t, un_mcontext.greps[REG_RBP]),即ucontext_t结构中用于保存各个寄存器的相对位移。
getcontext中,第一部分保存各个寄存器状态值如下:
进入getcontext之后
- 首先保存rbx,rbp,r12,r13,r14,r15,这6个数据寄存器,因为他们遵循被调用者使用,所以需要保存,
- 然后是保存rdi,rsi,rdx,rcx,r8,r9这6个寄存器,因为它用于保存函数参数,也是遵循被调用者使用。但大家发现没有,getcontext只有一个ucontext参数,所以保存后面5个寄存器是多余的。
- 其次,读取rsp寄存器指向的进程stack栈顶中的RIP值,该栈顶的值,是在调用getcontext时,即执行call指令时,默认会做的事情:将下一条指令地址push进栈顶空间。读取后会将该值保存到ucontext中,当恢复时,恢复到RIP寄存器中。
- 再次,将栈顶指针加8,即获得调用getcontext()之前的栈顶指定,并保存到ucontext中,当恢复时,恢复到RSP寄存器中。
getcontext的第二部分设置浮点计数器, 第三部分就是保存当前线程的信号屏蔽掩码;
2.2. makecontext实现
1 | void |
makecontext用于修改已经获取的上下文信息,其支持将运行stack切换为用户自定义栈,并可以修改ucontext上下文中保存的RIP指针,这样当恢复此ucontext的上下文时,就会将RIP寄存器的恢复为ucontext中的RIP字段值,跳到指定的代码处进行执行,这也是协程运行的基本要求。
makecontex的glibc实现中,
- 首先是对用户自定义栈进行处理,将sp移动到栈底(栈空间是递减的),然后进行对齐,并预留出8字节的trampoline空间(防止相互递归的发生)。
- 然后,将传入的上下文ucontext中的rip字段设置为fun函数的地址,rbx字段指向继承上下文,rsp字段指向自定义栈的栈顶
- 其次就是将start_context和uc_link,存入栈中
- 最后,是将makecontext的参数存入ucontext的上下文中,对于多余的参数,进行压栈操作。
修改后的ucontext上下文如下:
makecontext支持后继上下文的功能,即当前ucontext执行完毕后,会执行ucontext中设置的uc_link所指向的另一个ucontext,这个功能就是通过__start_context()来实现的,上面的图中可知,makecontext()中将用户自定义栈的栈顶push进了__start_context,当makecontext()修改的上下文执行结束后,会将栈顶的__start_context指针 pop到当RIP寄存器中,然后执行,下面是__start_context()的glibc汇编源码:
1 | ENTRY(__start_context) |
该代码首先就是将当前寄存器中的rbx值赋给rsp寄存器,我们知道rbx的值,是从ucontext的rbx字段中恢复出来的,其是指向栈顶的uc_link,所以,就是将当前的栈顶指针指向uc_link,即pop出了makecontext时,传入的所有参数,然后会调用setcontext()来恢复后继上下文的环境,参数rdi就是uc_link的值。整个流程如下图:
2.3. swapcontext实现
1 | ENTRY(__swapcontext) |
swapcontext就是在getcontext的基础上,将参数二中上下文中保存的各个寄存器字段恢复到当前进程的各个寄存器中,和getcontext的流程相反。就不细说,有兴趣的可以自己看。
3. ucontext示例
下面从最简单的代码来解析ucontext切换的过程:
1 |
|
getcontext调用时,进程上下文信息,以及ucontext的变化情况:
在调用getcontext之前,进程栈空间只有两个值stack_size和p_stack,当执行getcontext后,会将下一调指令地址即RIP寄存器值push入栈, 然后进入getcontext中后,会将当前进程的上下文全部保存到传入的ucontext参数中,以及上面说的进程的信号屏蔽掩码。在getcontext操作中保存的两个最重要的寄存器信息就是rip和rsp了,分别用于恢复上下后所要执行的指令地址和栈顶指针。
当执行makecontext后:当前进程上下文没有任何变化,只是对传入的ucontext上下文进行操作,变化如下:
当执行到swapcontext时,当前进程的栈空间会切换到uc自定义的栈空间,且会从uc上下文uc_mcontext字段中恢复出各个寄存器的值,
- 其中rbx用于uc执行完毕后,执行其后继上下文;
- rip中指向下一条要执行的指令,即函数foo()的地址
- rsp指向uc自定义栈空间的栈顶;
foo函数执行完毕后,会将uc自定义栈的局部变量全部弹出,然后栈空间又恢复到刚进入foo()的状态,此时会弹出栈顶的__start_context()到RIP中,其做完函数执行完毕的下一条指令,下面__start_context()的执行就是将自定义stack的uc_link所指向的上下文恢复执行。所以此时进程的上下文状态就会回到swapcontext的时候的下一条语句继续执行。
下面是执行的结果:
1 | foo |
为什么会coredump呢,我是想他输出100个foo的,那就gdb调试看看自定义栈出了什么问题:
gdb进入后在swapcontext之前打断点,如下:自定义栈的起始地址为0x602010,
自定义栈大小为:
1 | size_t co_stack_size = 64*1024; |
所以uc上下文的自定义栈底为0x612010,栈顶的位置在0x611ff8,在makecontext()后,自定义栈内部压入了uc上下文的后继上下文ucm和用于跳转到后继上下文的函数__start_context()地址。
当swapcontext执行后,进程切换到uc上下文执行,执行foo()函数,foo()执行结束之前,uc自定义栈的数据如下:
可以看到foo()ret之前,自定义站栈顶指向0x611ff0,其内容是没有swapcontext之前栈顶指针,这里不管它,因为foo()执行完之前会pop出该内容,然后栈顶指针就是指向0x611ff8,然后再执行ret指令。ret指令,会首先从栈顶pop出内容到RIP寄存器,进行下一条指令的执行:前面说了0x611ff8是__start_context()地址。
进入__start_context的汇编代码后,栈顶指针已经指向0x612000,如上图,此时栈顶就是后继上下文ucm的地址;然后将栈顶pop到rdi中,然后会通过调用setcontext,将ucm上限为恢复到进程的寄存器中,在call调用setcontext时,同时会压入下一条指令的地址,就是0x7ffff7a5db6e,如下:
这里我们就会发现一点:uc的自定义栈的数据已经完全被破坏,所以,当执行完后继上下文ucm,然后在swapcontext()中,再次切换到foo()后,uc的自定义栈已经完全不是第一次使用的状态,当再次进行后继上下文执行时,core在了后继上下文的回复过程中,因为此时的0x612000已经不在执行ucm,而是一个__start_context()指令地址。
uc上下文执行过程中,只有自定义栈会在运行时被修改,uc 的ucontext_t数据结构是不会发生改变的, 为了能够让该代码达到预期:进行如下修改就好了。
1 | for (int i = 0; i < 100; i++) |
[参考]
https://github.com/bminor/glibc/blob/master/sysdeps/unix/sysv/linux/x86_64/swapcontext.S
https://github.com/bminor/glibc/blob/master/sysdeps/unix/sysv/linux/x86_64/makecontext.c
https://github.com/bminor/glibc/blob/master/sysdeps/unix/sysv/linux/x86_64/__start_context.S
https://github.com/bminor/glibc/blob/master/sysdeps/unix/sysv/linux/x86_64/getcontext.S
http://www.cnblogs.com/ym65536/p/4542646.html
http://www.linuxidc.com/Linux/2014-10/108574.htm
https://www.ibm.com/developerworks/cn/linux/l-assembly/
http://www.cnblogs.com/wisehead/articles/3819233.html
http://www.cnblogs.com/hicjiajia/archive/2012/05/22/2513994.html
http://blog.luoyuanhang.com/2015/07/07/%E5%87%A0%E7%A7%8D%E5%9F%BA%E6%9C%AC%E6%B1%87%E7%BC%96%E6%8C%87%E4%BB%A4%E8%AF%A6%E8%A7%A3/
http://www.cnblogs.com/lilongjiang/archive/2011/06/15/2081124.html