C/C++协程学习笔记丨C/C++实现协程及原理分析视频( 三 )


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);
}
前面我们已经提到过 , pCallStack 栈顶所指向的即为当前正在运行的协程所对应的结构 , 因此该函数将 curr 取出来 , 并将当前正运行的协程上下文保存到该结构上 , 并切换到协程 last 上执行 。 接下来我们以 32-bit 的系统为例来分析 libco 是如何实现协程运行环境的切换的 。
协程上下文的创建和切换libco 使用结构 struct coctx_t 来表示一个协程的上下文环境:
struct coctx_t
{
#if defined(__i386__)
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif
size_t ss_size;
char *ss_sp;
};
可以看到 , 在 i386 的架构下 , 需要保存 8 个寄存器信息 , 以及栈指针和栈大小 , 究竟这 8 个寄存器如何保存 , 又是如何使用 , 需要配合后续的 coctx_swap 来理解 。 我们首先来回顾一下 Unix-like 系统的 stack frame layout , 如果不能理解这个 , 那么剩下的内容就不必看了 。
C/C++协程学习笔记丨C/C++实现协程及原理分析视频文章插图
结合上图 , 我们需要知道关键的几点:

  1. 函数调用栈是调用者和被调用者共同负责布置的 。 Caller 将其参数从右向左反向压栈 , 再将调用后的返回地址压栈 , 然后将执行流程交给 Callee 。
  2. 典型的编译器会将 Callee 函数汇编成为以 push %ebp; move %ebp, %esp; sub $esp N; 这种形式开头的汇编代码 。 这几句代码主要目的是为了方便 Callee 利用 ebp 来访问调用者提供的参数以及自身的局部变量(如下图) 。
  3. 当调用过程完成清除了局部变量以后 , 会执行 pop %ebp; ret , 这样指令会跳转到 RA 也就是返回地址上面执行 。 这一点也是实现协程切换的关键:我们只需要将指定协程的函数指针地址保存到 RA 中 , 当调用完 coctx_swap 之后 , 会自动跳转到该协程的函数起始地址开始运行 。
了解了这些 , 我们就来看一下协程上下文环境的初始化函数 coctx_make:
int coctx_make( coctx_t *ctx, coctx_pfn_t pfn, const void *s, const void *s1 )
{
char *sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);
sp = (char*)((unsigned long)sp
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*);
ctx->regs[ kEIP ] = (char*)pfn;
return 0;
}
这段代码应该比较好理解 , 首先为函数 coctx_pfn_t 预留 2 个参数的栈空间并对其到 16 字节 , 之后将实参设置到预留的栈上空间中 。 最后在 ctx 结构中填入相应的 , 其中记录 reg[kEIP] 返回地址为函数指针 pfn , 记录 reg[kESP] 为获得的栈顶指针 sp 减去一个指针长度 , 这个减去的空间是为返回地址 RA 预留的 。 当调用 coctx_swap 时 , reg[kEIP] 会被放到返回地址 RA 的位置 , 待 coctx_swap 执行结束 , 自然会跳转到函数 pfn 处执行 。
coctx_swap(ctx1, ctx2) 在 coctx_swap.S 中实现 。 这里可以看到 , 该函数并没有使用 push %ebp; move %ebp, %esp; sub $esp N; 开头 , 因此栈空间分布中不会出现 ebp 的位置 。 coctx_swap 函数主要分为两段 , 其首先将当前的上下文环境保存到 ctx1 结构中:
leal 4(%esp), %eax // eax = old_esp + 4
movl 4(%esp), %esp // 将 esp 的值设为--tt-darkmode-color: #999999;">这里需要注意指令 leal 和 movl 的区别 。 leal 将 eax 的值设置成为 esp 的值加 4 , 而 movl 将 esp 的值设为 esp+4 所指向的内存上的值 , 也就是参数 ctx1 的地址 。 之后该函数将 ctx2 中记录的上下文恢复到 CPU 寄存器中 , 并跳转到其函数地址处运行:
movl 4(%eax), %esp // 将 esp 的值设为--tt-darkmode-color: #999999;">上面的代码看起来可能有些绕:
  1. 首先 line 1 将 esp 设置为参数 ctx2 的地址 , 后续的 popl 操作均在 ctx2 的内存空间上执行 。
  2. line 2-9 将 ctx2->regs[] 中的内容恢复到相应的寄存器中 。 还记得在前面 coctx_make 中设置了 regs[EIP] 和 regs[ESP] 吗?这里刚好就对应恢复了相应的值 。
  3. 当执行完 line 9 之后 , esp 已经指向了 ctx2 中新的栈顶指针 , 由于在 coctx_make 中预留了一个指针长度的 RA 空间 , line 10 刚好将新的函数指针--tt-darkmode-color: #979797;">如何使用 libco我们首先以 libco 提供的例子 example_echosvr.cpp 来介绍应用程序如何使用 libco 来编写服务端程序 。在 example_echosvr.cpp 的 main 函数中 , 主要执行如下几步: