libco源码解析(1) 协程运行与基本结构
libco源码解析(2) 创建协程,co_create
libco源码解析(3) 协程执行,co_resume
libco源码解析(4) 协程切换,coctx_make与coctx_swap
libco源码解析(5) poll
libco源码解析(6) co_eventloop
libco源码解析(7) read,write与条件变量
libco源码解析(8) hook机制探究
libco源码解析(9) closure实现
文章目录
引言
题目说的很清楚,这篇文章旨在把协程最为神秘的部分,也即是协程的切换讲的清楚明白,这部分也是令很多人望而生畏的地方,因为在切换协程时用到了一部分汇编代码。所以想要真正理解这部分,还是得先花一点时间把丢掉的汇编先拿回来。
基础知识
首先我们来看下栈帧的定义:
In C and modern CPU design conventions, the stack frame is a chunk of memory, allocated from the stack, at run-time, each time a function is called, to store its automatic variables. Hence nested or recursive calls to the same function, each successively obtain their own separate frames.
Physically, a function’s stack frame is the area between the addresses contained in esp, the stack pointer, and ebp, the frame pointer (base pointer in Intel terminology). Thus, if a function pushes more values onto the stack, it is effectively growing its frame.
.
在C语言和现代CPU的设计规范中,栈帧是一块由栈分配的内存块,在运行时,每当调用一次函数时,都要存储其自动变量。因此对于同一函数的递归调用在每一次都会连续的获得自己独立的栈帧。
从物理上将,函数的栈帧是指esp和ebp之间的一块地址。因此如果一个函数把更多的值压入堆栈,实际上是在扩展它本身的栈帧。
这里算是讲的的非常清楚了,栈帧就是esp和ebp之间的一块内存。
我们来看一下一个栈帧的实际布局;
在这幅图中我们应该关注的重点就是红框中EBP上面的值,即EIP和采用__cdecl调用约定的参数。这里出现了一个新的名词__cdecl
,这其实是函数调用的一种调用约定,下面罗列出来:
__stdcall
:函数采用从右到左的压栈方式,自己在退出时清空堆栈。__cdecl
:即C调用约定(The C default calling convention),按从右至左的顺序压参数入栈,由调用者把参数弹出栈。对于传送参数的内存栈是由调用者来维护的(正因为如此,实现可变参数vararg的函数(如printf)只能使用该调用约定)。__fastcall
: __fastcall调用的主要特点就是快,因为它是通过寄存器来传送参数的(实际上,它用ECX和EDX传送前两个双字(DWORD)或更小的参数,剩下的参数仍旧自右向左压栈传送,被调用的函数在返回前清理传送参数的内存栈)。
我们回到上面那幅图,采用__cdecl调用约定的调用者会将参数从右到左的入栈,最后将返回地址入栈。这个返回地址是指,函数调用结束后的下一行执行的代码地址。获取参数和返回地址的话我们只需要通过EBP加偏移就可以了。当然图上的偏移量是32为系统的。
正文
上面简单的过了一下基础知识,接下来我们通过对libco中coctx_make
与coctx_swap
的解析,搞清楚协程切换的本质,因为学汇编的时候学习的都是32位的,我们以32位为例子进行讲解。64位只是多了一些寄存器和一些调用规则的上的不同罢了,基本的逻辑都是一样的,所以我们选择32位系统进行分析。
我们先来看看与协程切换相关的数据结构:
// 用于分配coctx_swap两个参数内存区域的结构体,仅32位下使用,64位下两个参数直接由寄存器传递
struct coctx_param_t
{
const void *s1;
const void *s2;
};
struct coctx_t
{
#if defined(__i386__)
// 上下文
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif
size_t ss_size;// 栈的大小
char *ss_sp; // 栈顶指针esp
};
coctx_t
结构可以说是libco中最为重要的结构了,它直接存储了协程的上下文。
coctx_make
调用coctx_swap
之前的准备工作由coctx_make
设置完成,我们来看看其实现:
int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1) {
// make room for coctx_param
// 此时sp其实就是esp指向的地方 其中ss_size感觉像是这个栈上目前剩余的空间,
char* sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);
//------- ss_sp + ss_size
//| |
//| |
//------- ss_sp
//ctx->ss_sp 对应的空间是在堆上分配的,地址是从低到高的增长,而堆栈是往低地址方向增长的,
//所以要使用这一块人为改变的栈帧区域,首先地址要调到最高位,即ss_sp + ss_size的位置
sp = (char*)((unsigned long)sp & -16L);// 字节对齐,16L是一个magic number,下文会做解释
// param用来给我们预留下来的参数区设置值
coctx_param_t* param = (coctx_param_t*)sp;
void** ret_addr = (void**)(sp - sizeof(void*) * 2); // 函数返回值
// (sp - sizeof(void*) * 2) 这个指针存放着指向ret_addr的指针
*ret_addr = (void*)pfn; // 新协程要执行的指令函数,也即执行完这个函数要cotx_swap要返回的值
param->s1 = s; //即将切换到的协程
param->s2 = s1; // 切换出的线程
//------- ss_sp + ss_size
//|pading| 这里是对齐区域
//|s2 |
//|s1 |
//|原esp |
//| 返回地址 |
//|esp实际空间|
//------- <- sp(原esp - sizeof(void*) * 2)
//| |
//------- ss_sp
// 对照着上面那个栈帧的图去看
memset(ctx->regs, 0, sizeof(ctx->regs));
// ESP指针sp向下偏移2,因为除了ebp还有一个返回地址
// 进入函数以后就会push ebp了
ctx->regs[kESP] = (char*)(sp) - sizeof(void*) * 2;
//sp初始指向第一个参数的起始地址
//函数调用,压入参数之后,还有一个返回地址要压入,所以还需要将sp往下移动8个字节,
//32位汇编获取参数是通过EBP+8, EBP+12来分别获取第一个参数,第二个参数的,
//这里减去4个字节是为了对齐这种约定,这里可以看到对齐以及参数还有4个字节的虚拟返回地址已经
//占用了一定的栈空间,所以实际上供协程使用的栈空间是小于分配的空间。另外协程且走调用co_swap参数入栈也会占用空间,
// KESP(7)在swap中是赋给esp的
return 0;
}
其实就是一个函数调用过程的模拟,功能就是给coctx_swap
做一些准备工作,关键是要理解那个(sp - sizeof(void*) * 2)
,在理解的时候搭配着那张栈帧的图可以更有效率。
16L的哲学
然后我们来说一说那个16L的魔法数字到底有什么用,我们在代码中提到了这个magic number其实是为了字节对齐。16这个数字非常奇怪,一般来说我们的认知都是32位下字节对齐应该是4,64位系统下当然就是8了,这个16是什么情况?答案就是GCC默认的堆对齐设置的就是16字节。具体可查看这篇文章:《Why does System V / AMD64 ABI mandate a 16 byte stack alignment?》
coctx_swap
接下来我们来看看coctx_swap执行协程切换的过程:
movl 4(%esp), %eax
这里ESP获取到的是对应图中old %EIP的地址,加4对应第一个参数的地址,把这个值赋给eax,当然也隐藏着eax[0]的赋值
| *ss_sp |
| ss_size |
| regs[7] |
| regs[6] |
| regs[5] |
| regs[4] |
| regs[3] |
| regs[2] |
| regs[1] |
| regs[0] |
-------------- <---EAX
movl %esp, 28(%eax)
movl %ebp, 24(%eax)
movl %esi, 20(%eax)
movl %edi, 16(%eax)
movl %edx, 12(%eax)
movl %ecx, 8(%eax)
movl %ebx, 4(%eax)
// 想想看,这里eax加偏移不就是对应了regs中的值吗?这样就把所有寄存器中的值保存在了参数中
// ESP偏移八位就是第二个参数的偏移了,这样我们就可以把第二个参数regs中的上下文切换到寄存器中了
movl 8(%esp), %eax
movl 4(%eax), %ebx
movl 8(%eax), %ecx
movl 12(%eax), %edx
movl 16(%eax), %edi
movl 20(%eax), %esi
movl 24(%eax), %ebp
movl 28(%eax), %esp
ret
// 这样我们就完成了一次协程的切换
这里面对于协程切换来说最重要的就是regs[0]和regs[7]了,regs[0] 存放下一个指令执行地址,也即返回地址。regs[7] 存放切换到新协程后,ESP指针调整的新地址,也就是栈上的偏移。这样程序的数据和代码都被改变,当然也就做到了一个线程可以跑多份代码了。
参考: