在调用co_resume函数执行一个协程,需要调用两个函数,分别是:coctx_make和co_swap,前者用于创建协程的上下文环境,后者用于切换上下文环境。这两个函数是实现协程的关键。
在介绍创建协程的上下文环境和上下文切换时,我们先来介绍一下函数栈的实现原理。
1. 函数栈的实现原理
1.1 函数栈帧
当一个程序运行的时候,Linux会为一个程序分配内存,内存区域包括代码段,数据段,堆,栈等,堆的地址从低地址向高地址增长,而栈的地址从高地址向低地址增长。下图是一个栈的结构。
图中有两个画出了具体结构的栈帧,分别是函数 A 和函数 B。函数 A 的栈帧最上面有一块省略号标识的区域,该区域保存的是上一个栈帧的寄存器值以及函数 A 自己内部创建的局部变量。下面的参数 n 到参数 1 则是函数 A 要传给函数 B 的调用参数。那么函数 B 如何获取?答案是用寄存器。
CPU 计算时会把很多变量放在寄存器中,根据硬件体系的不同,寄存器数量和作用也不同。一般在 x86 32位中,寄存器 %esp
保存了栈帧的栈顶指针值,而 %ebp
则保存栈帧栈底指针的值,所以通过 %esp
和 %ebp
就可以知道当前栈帧的头跟尾。除了这两个寄存器,还有其它一些通用寄存器(%eax
、%edx
等),用于保存程序执行的临时值。在执行pushl指令时,表示往栈中压入一个值,此时%esp的值会往下减去4个字节。而执行popl指令时,表示从栈中弹出一个值,此时%esp的值会增加4个字节。总的来时%esp寄存器的值一直指向栈顶。
了解了寄存器的基本知识后,下面我们就可以知道函数 B 如何获取到函数 A 传给它的参数了。参数 1 的地址是 %ebp + 8
,参数 2 的地址是 %ebp + 12
,参数 n 的地址是 %ebp + 4 + 4 * n
。相信大家已经看明白,通过栈底指针往上找就可以取得这些参数,而这些参数之所以在这里当然是函数 A 预先准备好的。另外在所有参数的最下面保存着返回地址
,这个是在函数 B 返回之后接下来要执行的指令的地址。
看了函数 A 之后,再看看函数 B。在函数 B 的栈帧最上面是 被保存的 %ebp
,这个指的是函数 A 的栈底指针,毕竟 %ebp
这个寄存器就一个,所以新的函数入栈的时候要先把老的保存起来,等函数出栈再恢复。在这个老的栈底指针下面则是其它需要保存的寄存器变量以及函数 B 自己内部用到的局部变量。再往下是 参数构造区域
,也就是函数 B 即将调用另一个函数,在这里先把参数准备好。可以看出,函数 B 与函数 A 的栈帧结构是类似的。
1.2 函数调用实例
int caller()
{
int arg1 = 534;
int arg2 = 1057;
int sum = swap_add(&arg1, &arg2);
int diff = arg1 - arg2;
return sum * diff;
}
int swap_add(int *xp, int *yp)
{
int x = *xp;
int y = *yp;
*xp = y;
*yp = x;
return x + y;
}
接下来我们一行一行分析这个程序,首先是caller函数,如下图所示,左边是汇编代码,右边是函数的调用栈。
首先看前三行代码:
pushl %ebp // 保存旧的 %ebp
movl %esp, %ebp // 将 %ebp 设置为 %esp
subl $24, %esp // 将 %esp 减 24 开辟栈空间
这三行其实是为栈帧做准备工作。第一行保存旧的 %ebp
,也就是caller外层函数的栈帧的栈底指针。此时新的栈空间还没有创建,但保存旧的 %ebp
的这一行空间将作为新栈帧的栈底,也就是栈帧的栈底指针,因此第二行将栈指针 %esp
(永远指向栈顶)的值设置到 %ebp
上。 第三行将 %esp
下移 24 个字节,这一行其实就是为函数 caller
开辟栈空间了。从图中可以看出,下面的空间用于保存 caller
中的局部变量arg1和arg2,以及传给下个函数的参数。有部分空间未使用,这个是为了地址对齐,不影响我们的分析,可以忽略。
在开辟了栈帧之后,就开始执行 caller
内部的逻辑了,caller
首先创建了两个局部变量(arg1
和arg2
。对应的汇编代码为:
movl $534, -4(%ebp)
movl $1057, -8(%ebp)
其中 -4(%ebp)
表示 %ebp - 4
的位置,也就是图中 arg1
所在的位置, arg2
的位置则是 %ebp - 8
的位置。这两行是把 534
和 1057
保存到传送到这两个位置上。继续往下是这几行:
leal -8(%ebp), %eax // 把 %ebp - 8 这个地址保存到 %eax
movl %eax, 4(%esp) // 把 %eax 的值保存到 %esp + 4 这个位置上
leal -4(%ebp), %eax // 把 %ebp - 4 这个地址保存到 %eax
movl %eax, ($esp) // 把 %eax 的值保存到 %esp 这个位置上
第一行把 %ebp - 8
这个地址保存到 %eax
中,而 %ebp - 8
是 arg2
的地址,下一行把这个地址放到 %esp + 4
这个位置上,也就是图中 &arg2
的那个区域块。其实这一行是在为函数 swap_add
准备参数 &arg2
,而下面两行则是准备参数 &arg1
。
再下面一行是 call swap_add
。这一行就是调用函数 swap_add
了,该指令会把函数的返回地址压入栈中,并将程序计数器PC设置为函数 swap_add的起始地址。
这里的返回地址是函数 swap_add
返回后要接着执行的代码的地址,也就是 int diff = arg1 - arg2
地址。我们先进入 swap_add
函数,下面是对应的代码执行图:
pushl %ebp // 保存旧的 %ebp
movl %esp, %ebp // 将 %ebp 设置为 %esp
pushl %ebx // 保存 %ebx
swap_add
对应的汇编代码的前三行与 caller
类似,同样是保存旧的帧指针,但是因为 swap_add
不需要保存额外的变量,只需要多用一个寄存器 %ebx
,所以这里保存了这个寄存器的旧值,但是没有将 %esp
直接下移一段长度的操作。
movl 8(%ebp), %edx // 从 %ebp + 8 取值保存到 %edx
movl 12(%ebp), %ecx // 从 %ebp + 12 取值保存到 %ecx
这两行分别是从 caller
中保存参数 &arg1
和 &arg2
的地方取得地址值,并根据地址取得 arg1
和arg2
的实际数值。
mov1 %edx, %ebx
mov1 %ecx, %eax
mov1 %eax, %edx
mov1 %ebx, %exc
这 4 行是交换操作 。再看下面几行:
addl %ebx, %eax // 将返回值保存到寄存器 %eax
pop %ebx
pop %ebp
ret
函数 swap_add
的返回值保存在 %eax
中,一会儿 caller
就是从这个寄存器获取的。 swap_add
的最后几行是出栈操作,将 %ebx
和 %ebp
分别恢复为 caller
中的值。最后执行 ret
返回到 caller
中,ret指令会把返回地址从栈中弹出,并把程序计数器PC的值设置为返回地址的值。
下面我们继续回到 caller
中,刚才执行到 call swap_add
,下面几行是执行 int diff = arg1 - arg2
,结果保存在 %edx
中。最后一行是计算 sum * diff
,对应的汇编代码为 imull %edx, %eax
。这里是把 %edx
和 %eax
的值相乘并且把结果保存到 %eax
中。在上面的分析中,我们知道 %eax
保存着 swap_add
的返回值,这里还是从 %eax
中取出返回值进行计算,并且把结果继续保存到 %eax
中,而这个值又是 caller
的返回值,这样调用 caller
的函数也可以从这个寄存器中获取返回值了。caller
函数的最后一行汇编代码是 ret
,这会销毁 caller
的栈帧并且恢复相应寄存器的旧值。到此,caller
和 swap_add
这个函数的调用过程就全部分析完了。
2、协程函数:CoRoutineFunc
static int CoRoutineFunc( stCoRoutine_t *co,void * )
{
if( co->pfn )
{
co->pfn( co->arg );
}
co->cEnd = 1; // 协程执行结束标识
stCoRoutineEnv_t *env = co->env;
co_yield_env( env );
return 0;
}
// 协程执行结束,从线程环境栈减1,并切换到另外一个协程
void co_yield_env( stCoRoutineEnv_t *env )
{
stCoRoutine_t *last = env->pCallStack[ env->iCallStackSize - 2 ];
stCoRoutine_t *curr = env->pCallStack[ env->iCallStackSize - 1 ];
env->iCallStackSize--;
co_swap( curr, last);
}
3. 创建协程上下文环境:coctx_make
/* 用于分配coctx_swap两个参数内存区域的结构体,仅32位下使用,64位下两个参数直接由寄存器传递 */
struct coctx_param_t
{
const void *s1;
const void *s2;
};
int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )
{
//make room for coctx_param
/*
* ctx->ss_sp 对应的空间是在堆上分配的,在协程创建时初始化,地址是从低到高的增长,而栈是往低地址方向增长的,
* 所以要使用这一块人为改变的栈帧区域,首先地址要调到最高位,即ss_sp + ss_size的位置
*/
char *sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);
sp = (char*)((unsigned long)sp & -16L); // 16字节对齐
/* 栈中保存函数的参数 */
coctx_param_t* param = (coctx_param_t*)sp ;
param->s1 = s;
param->s2 = s1;
memset(ctx->regs, 0, sizeof(ctx->regs));
ctx->regs[ kESP ] = (char*)(sp) - sizeof(void*); // 保存栈栈顶指针,kESP = 7
ctx->regs[ kEIP ] = (char*)pfn; // 保存函数指针,kEIP = 0
//------- ss_sp + ss_size
//|pading | 这里是对齐区域
//|s2 |
//|s1 |
//|-------- <- 原esp
//|返回地址 |
//|返回地址 |
//|-------- <- sp(原esp - sizeof(void*) * 2)
//| |
//--------- ss_sp
return 0;
}
4. 协程的上下文环境切换:co_swap
/* 当前准备让出CPU的协程叫做current协程,把即将调入执行的叫做 pending 协程 */
void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co)
{
stCoRoutineEnv_t* env = co_get_curr_thread_env();
// 在函数头放一个局部变量,可以获取sp栈顶指针
char c;
curr->stack_sp= &c;
if (!pending_co->cIsShareStack)
{
env->pending_co = NULL;
env->ocupy_co = NULL;
}
else
{
env->pending_co = pending_co;
/* 获取当前占用共享栈的是哪个协程 */
stCoRoutine_t* ocupy_co = pending_co->stack_mem->ocupy_co;
/* 将共享栈的占用协程设置为即将换入的协程 */
pending_co->stack_mem->ocupy_co = pending_co;
/* 保存换出的协程 */
env->ocupy_co = ocupy_co;
/* 保存换出的协程的栈内容到协程实体的结构体中 */
if (ocupy_co && ocupy_co != pending_co)
{
save_stack_buffer(ocupy_co);
}
}
/* 切换协程的上下文 */
coctx_swap(&(curr->ctx),&(pending_co->ctx) );
// stack buffer may be overwrite, so get again;
stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();
stCoRoutine_t* update_ocupy_co = curr_env->ocupy_co;
stCoRoutine_t* update_pending_co = curr_env->pending_co;
if (update_ocupy_co && update_pending_co && update_ocupy_co != update_pending_co)
{
/* 将save_buffer中的栈内容复制到共享栈中 */
if (update_pending_co->save_buffer && update_pending_co->save_size > 0)
{
memcpy(update_pending_co->stack_sp, update_pending_co->save_buffer, update_pending_co->save_size);
}
}
}
/* 将协程的共享栈内容保存到协程实体的结构体中 */
void save_stack_buffer(stCoRoutine_t* ocupy_co)
{
///copy out
stStackMem_t* stack_mem = ocupy_co->stack_mem;
int len = stack_mem->stack_bp - ocupy_co->stack_sp;
if (ocupy_co->save_buffer)
{
free(ocupy_co->save_buffer), ocupy_co->save_buffer = NULL;
}
ocupy_co->save_buffer = (char*)malloc(len); //malloc buf;
ocupy_co->save_size = len;
memcpy(ocupy_co->save_buffer, ocupy_co->stack_sp, len);
}
coctx_swap执行完以后,CPU就跑去执行pendding中的代码了,也就是说执行完执行coctx_swap的这条语句后,下一条要执行的语句不是stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();,而是pedding中的语句。这一点要尤其注意。那么什么时候执行coctx_swap这条语句之后的语句呢?就是在协程被其他地方执行co_resume了以后才会继续执行这里。后面就简单啦,切换出去的时候要把栈内容拷贝下来保存到一个buffer中,切换回来的时候把buffer中的内容拷贝到栈里面,这就是协程的执行过程。
4.1 上下文环境切换:coctx_swap
.globl coctx_swap
#if !defined( __APPLE__ )
.type coctx_swap, @function
#endif
coctx_swap:
#if defined(__i386__)
leal 4(%esp), %eax // 把%esp + 4的地址保存到%eax中
movl 4(%esp), %esp // %esp 保存 %esp + 4地址指向的值
leal 32(%esp), %esp // %esp = %esp + 32,此时%esp指向parm a : ®s[7] + sizeof(void*)
// 接下来把所有的寄存器值保存到当前协程的8个寄存器数组中
pushl %eax // esp ->parm a
pushl %ebp
pushl %esi
pushl %edi
pushl %edx
pushl %ecx
pushl %ebx
pushl -4(%eax)
// 更新%esp的值
movl 4(%eax), %esp // parm b -> ®s[0]
// 把即将运行的协程的寄存器值从内存中弹出保存到CPU的寄存器中
popl %eax //ret func addr
popl %ebx
popl %ecx
popl %edx
popl %edi
popl %esi
popl %ebp
popl %esp
pushl %eax //set ret func addr
xorl %eax, %eax
ret