涉及文件:
coctx_swap.S
coctx.cpp
coctx.h
其中核心文件为coctx_swap.S,利用汇编语言实现了协程的切换功能。coctx.{cpp,h}是在其基础上进行C语言的封装,很简单,所以我会重点分析coctx_swap.S中的代码。可能会有一些人没有学过汇编语言,我自己在接触Libco之前也没有学过,我觉得如果有C语言基础,把一些寄存器的作用记一下,基本的指令学习下,看懂coctx_swap.S应该不成问题。
底层协程切换的原理
协程切换是协程调度的关键,他将一个协程的状态保存并切换到另一个协程中继续执行
线程
一般而言,线程具有以下四个要素:
- 执行代码
- 栈
- 堆
- 寄存器
如果我们想要实现一个用户级的线程,即协程,每个协程就需要独立的以上四个属性。其中执行代码由寄存器eip保存。一些数据虽然是在堆中分配的,但他也是借助栈上分配的指针等间接保存的。而栈是由ebp和esp寄存器所保存,所以决定一个协程的要素被简化为寄存器。
寄存器(以x86 64位为例)
libco使用了14个寄存器,存储在reg数组中,分别是:
reg[0]: r15 低地址
reg[1]: r14
reg[2]: r13
reg[3]: r12
reg[4]: r9
reg[5]: r8
reg[6]: rbp 栈底
reg[7]: rdi 第一个参数地址
reg[8]: rsi 第二个参数地址
reg[9]: ret 返回函数
reg[10]: rdx
reg[11]: rcx
reg[12]: rbx
reg[13]: rsp 栈顶
其中rbp表示栈底,rsp表示栈顶,这两个寄存器表现出栈的环境。rdi和rsi分别表示传入函数的第一个和第二个参数的地址。ret表示函数返回后的地址。
协程切换(以x86 64位为例)
协程切换的本质实际就是一个偷梁换柱的把戏,将寄存器的环境改变来实现协程的切换
leaq 8(%rsp),%rax//rax为返回函数的上一个地址
leaq 112(%rdi),%rsp//rdi存放第一个参数的地址,rsp指向rdi+112的位置,结构体中的参数在栈中是倒着排的,推算下来rsp指向的是ss_size
pushq %rax //rax存放之前栈的rsp指针+8的地址
pushq %rbx
pushq %rcx
pushq %rdx
pushq -8(%rax) //ret func addr//这个值为前面的栈返回的地址
pushq %rsi
pushq %rdi
pushq %rbp
pushq %r8
pushq %r9
pushq %r12
pushq %r13
pushq %r14
pushq %r15
movq %rsi, %rsp//rsi为第二个参数的地址,将他作为栈顶指针传给rsp,进入另一个函数
popq %r15
popq %r14
popq %r13
popq %r12
popq %r9
popq %r8
popq %rbp
popq %rdi
popq %rsi
popq %rax //ret func addr
popq %rdx
popq %rcx
popq %rbx
popq %rsp
pushq %rax
xorl %eax, %eax
ret
解析:
首先当前处在调用这个函数的协程所在的栈,有两个参数,第一个参数是当前栈的信息,第二个参数是要切换的目标协程所在栈的信息
rdi表示第一个参数的地址,rsi为第二个参数的地址,rsp为当前栈顶指针
第一行将rsp指向地址的上一个地址传给rax,这个值本身没什么用,rax只是作为临时存放rsp地址的变量,为什么是$rsp+8呢,后面分析就知道了
第二行rdi是第一个参数的地址,要了解112(%rdi)的具体含义,需要了解结构体在栈中的存储方式,首先第一个参数是一个指向结构体的指针,结构体在栈
中的存储为从后往前入栈,coctx_t的结构体为
struct coctx_t
{
void *regs[ 14 ];
size_t ss_size;
char *ss_sp;
};
那么先入栈的是ss_sp,然后是ss_size,最后数组regs也是倒着入栈的,所以最后入栈的是regs[0]
那么112(%rdi)的位置就可以计算出来是ss_size的位置,将这个位置的地址传给rsp
接下来就是一系列的push操作,push就可以把值存入rsp的下一个地址,然后自身也指向下一个地址
第一个pushq %rax 就是将之前栈的rsp指针+8的地址,这个值作为以后重新调用这个协程的rsp的值
接下来就是其他寄存器的push操作
-8(%rax) 就是调用coctx_swap函数的函数的地址,即返回地址
push完后,这个协程的状态就存进了参数一中了,接下来就可以进行协程的切换了
movq %rsi, %rsp rsi是第二个参数的地址,我们让rsp等于它,便可以对第二个参数进行操作了
接下来一系列的pop操作就是将第二个参数保存的寄存器状态全部恢复过来
还记得之前push的-8(%rax)吗,这个是函数的返回地址,我们先把他赋值给rax
我们让rsp等于之前存进去的rax,这个rax是存放替换的栈的rsp指针+8的地址
pushq %rax 就可以成功让我们当前的rsp指向当前协程的返回地址了,ret以后便可以继续这个协程的函数执行了!
Other
Libco在19年底的时候对coctx_swap.S进行了一次很大的修改,之前都是通过将rdi和rsi的指针给rsp通过pop和push操作保存或恢复寄存器。但是修改以后是通过mov指令来进行操作。对于改动的原因,我觉得可能是这个issues所提到的Sys V ABI约定的问题。所以对于上面代码的解析是针对之前push pop版本的,但其实对于mov版本原理是一样的。