linux 协程 原理,Libco 协程栈的切换理解

本文深入剖析libco协程切换原理,详细解读其利用函数调用特性保存和恢复执行现场的方法,帮助读者理解协程如何实现高效切换。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

libco协程切换原理解读及简要使用

以前看过libco一点源码,最近组里面分享了一次协程的原理。花了点功夫,借助一点网上的资料,算是摸清楚了libco协程切换的来龙去脉。libco除了协程的切换还涉及系统hook以及相关工程的封装,篇幅及时间限制,这里不涉及。本篇主要把协程切换的来龙去脉以及原理从个人理解角度介绍下。明白和能说出来讲清楚是两种不同的理解程度,这也是本文的主要目的。

函数调用的原理

linux 程序内存布局

传统linux程序(32bit)拥有4G的虚拟内存区域,高1G的区域供内核使用,剩余的3G内存供程序使用。按段划分,主要分程序段(text segement)、数据段、BSS段。BSS段用于未初始化的静态变量的初始化(0值初始化)。栈从高到低地址增长。堆从低到高增长。栈和堆的这两种不同的地址增长方向,需要关注下,后面协程切换中就涉及到该布局的不同。对内存布局有兴趣可以参看这篇文章程序内存布局。

fbd9c6bae7545652b24d4b30ffd9f226.png

栈帧定义

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.

这里定义很清楚,栈帧是从栈上分配的一段内存,每次函数调用时,用于存储自动变量。从物理介质角度看,栈帧是位于esp(栈指针)及ebp(基指针)之间的一块区域。局部变量等分配均在栈帧上分配,函数结束自动释放。

Traditionally, C function calls are made with the caller pushing some parameters onto the stack, calling the function, and then popping the stack to clean up those pushed arguments.

C函数调用,调用者将一些参数放在栈上,调用函数,然后弹出栈上存放的参数。

这里值得一提的是,调用约定,__cdecl 、 __stdcall、 __fastcall,调用约定涉及参数的入栈顺序(从左到右还是从右到左)、参数入栈和清理的是调用者(caller)还是被调用者(callee),函数名的处理。详细的文档可以参看Win32 calling conventions、The history of calling conventions

__cdecl is the default calling convention for C and C++ programs. 参考地址

这里我们看一下一个栈帧在内存中的布局:

0f907f69c39d32542984197c028bc1e9.png

红色区域是进入新的函数中时,栈帧的内存布局。这里需要关注红色区域之上的参数2,参数1,和返回地址。即每次进行函数调用时,采用__cdecl调用约定的调用者会将参数从右到左的入栈,最后将返回地址入栈。这个返回地址是指,函数调用结束后的下一行执行的代码地址。获取参数及返回地址通过EBP相对偏移即可获得,图示为32位系统对应的偏移值。

libco协程切换汇编代码解析

基本概念介绍的有些仓促,详细的可以参看推荐的链接。这里借助协程切换的汇编,继续回顾函数调用的相关知识。这里协程切换出于简化的目的,仅介绍32位系统下的汇编,64位下寄存器多一些寄存器,参数传递约定也有所区别,原理类似。

这里介绍下,协程切换的核心函数coctx_swap,这个函数底层由汇编实现,是进行协程切换的核心,也是本文关注的核心。大部分自定义的协程库,在这个精简的函数基础上可以进行封装。

void coctx_swap( coctx_t *,coctx_t* )//coctx_swap对应的函数原型

//用于分配coctx_swap两个参数内存区域的结构体,仅32位下使用,64位下两个参数直接由寄存器传递

struct coctx_param_t

{

const void *s1;

const void *s2;

};

//32bit下的coctx_t结构体

struct coctx_t

{

void *regs[ 8 ];//用于保存或者设定特定寄存器值

size_t ss_size;//栈帧区域的size

char *ss_sp;//协程栈帧内存区域,这个区域一般在堆上分配

};

coctx_t 这个结构定义了整个协程的核心,设计协程的执行地址,ESP栈帧的地址设置以及其他重要寄存器值的保存。

在函数进入协程切换函数coctx_swap函数前的栈帧的位置如下:

e4393c4dec51a0c5414ed9e50bffdf17.png

ESP往下,是常规的进入函数调用后存放的寄存器、变量等信息,这里并不是coctx_swap执行后的场景,coctx_swap没有按照标准的函数进入的约定来,做了一些保存上下文的事情,所以进入coctx_swap后的栈帧内存布局有所区别。

coctx_swap 汇编函数的代码:

leal 4(%esp), %eax //这里ESP获取到的是对应图中old %EIP的地址,加4对应第一个参数的地址,这里不用第一个参数的地址这样表述,因为可能没有参数,保守起见还是称返回地址+4的位置

movl 4(%esp), %esp //这里将ESP往上移动四个字节,即第一个参数所在的位置

对应的ESP地址,此时ESP已经指向了第一个参数,coctx_t* 类型的变量

| *ss_sp |

| ss_size |

| regs[7] |

| regs[6] |

| regs[5] |

| regs[4] |

| regs[3] |

| regs[2] |

| regs[1] |

| regs[0] |

--------------

leal 32(%esp), %esp //这里取第一个参数的地址,加32。第一个参数的类型coctx_t*,偏移32,即4bytes * 8,刚好位于regs末尾

对应的ESP当前位置

| *ss_sp |

| ss_size |

-------------

| regs[7] |

| regs[6] |

| regs[5] |

| regs[4] |

| regs[3] |

| regs[2] |

| regs[1] |

| regs[0] |

----------- (old ESP)

pushl %eax //将EAX的值压入栈,这里压入的是返回地址+4的值,对应压入了regs[7],栈是往下增长的,push会将ESP往下调

pushl %ebp //push into regs[6]

pushl %esi //push into regs[5]

pushl %edi //push into regs[4]

pushl %edx //push into regs[3]

pushl %ecx //push into regs[2]

pushl %ebx //push into regs[1]

pushl -4(%eax) //push into regs[0] 这里将eax(返回地址+4) 又做了减4操作,得到的刚好是返回地址,放入regs[0]中

七个寄存器压入完毕后ESP对应的位置

| *ss_sp |

| ss_size |

| regs[7] |

| regs[6] |

| regs[5] |

| regs[4] |

| regs[3] |

| regs[2] |

| regs[1] |

| regs[0] |

-----------

movl 4(%eax), %esp //第一个参数在往上偏移4个字节,及第二个参数的地址,ESP指向coctx_t regs[0]处

| *ss_sp |

| ss_size |

| regs[7] |

| regs[6] |

| regs[5] |

| regs[4] |

| regs[3] |

| regs[2] |

| regs[1] |

| regs[0] |

--------------

popl %eax //将第二个coctx_t的regs[0]的值放入EAX中,pop对应的将ESP往上移动

popl %ebx //push into regs[1]

popl %ecx //push into regs[2]

popl %edx //push into regs[3]

popl %edi //push into regs[4]

popl %esi //push into regs[5]

popl %ebp //push into regs[6]

| *ss_sp |

| ss_size |

| regs[7] |

--------------

| regs[6] |

| regs[5] |

| regs[4] |

| regs[3] |

| regs[2] |

| regs[1] |

| regs[0] |

-----------

popl %esp //regs[7] -> ESP //ESP调整为 regs[7]存储的地址

pushl %eax //set ret func addr 这里再push 放入的就是新的堆栈的返回地址的位置,栈的地址是向下增长的,上一步将ESP调到了返回地址+4的位置,这一步ESP刚好减4,即指向应该放入返回地址的位置,压入regs[0]中保存的值

xorl %eax, %eax

ret //这里包含两步,pop当前栈顶即压入的返回地址,然后跳转到返回地址执行,这里执行流程实际上就走到了由regs[0]执行的地址。

第一个参数的寄存器及返回地址保存顺序由coctx_swap 设置完成,而当前需要转向的地址需要程序设置,这里libco封装了一个接口,这里为了阅读方便,常量直接替换为值:

int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )

{

//make room for coctx_param

//这里ctx->ss_sp 对应的地址是新的协程执行的栈空间,即进入到pfn中能够使用的栈空间。

char *sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);//这里回顾下,函数调用时,会将参数从右到左入栈,然后将返回地址入栈,这里的调整是从栈空间上挪出位置给参数存放。

//ctx->ss_sp 对应的空间是在堆上分配的,地址是从低到高的增长,而堆栈是往低地址方向增长的,所以要使用这一块人为改变的栈帧区域,首先地址要调到最高位,即ss_sp + ss_size的位置

//------- ss_sp + ss_size

//| |

//| |

//------- ss_sp

sp = (char*)((unsigned long)sp & -16L);//这里字节对齐,这里为什么是16,下文单独列出一点介绍

//这里给从栈区域空下来的参数区设置值,32位系统下,参数通过堆栈传递

coctx_param_t* param = (coctx_param_t*)sp ;

param->s1 = s;

param->s2 = s1;

//------- ss_sp + ss_size

//|pading| 这里是对齐区域

//|s2 |

//|s1 |

//-------

//| |

//------- ss_sp

memset(ctx->regs, 0, sizeof(ctx->regs));

ctx->regs[ 7 ] = (char*)(sp) - sizeof(void*); //函数调用,压入参数之后,还有一个返回地址要压入,所以还需要将sp往下移动4个字节,32位汇编获取参数是通过EBP+4, EBP+8来分别获取第一个参数,第二个参数的,这里减去4个字节是为了对齐这种约定,这里可以看到对齐以及参数还有4个字节的虚拟返回地址已经占用了一定的栈空间,所以实际上供协程使用的栈空间是小于分配的空间。另外协程且走调用co_swap参数入栈也会占用空间,

ctx->regs[ 0 ] = (char*)pfn;//regs[0]填入函数地址,即新协程要执行的指令地址

return 0;

}

coctx_make填充的regs[7]和 coctx_swap 保存的regs[7]有所区别:

coctx_make 填充的regs[7]只是在自定义的内存区域上的一个地址,该值会被存入ESP中,作为ESP的开始。按照参数传递的规则,该值+4的位置为第一个参数,该值+8的位置为第二个参数。 coctx_swap 执行完,控制流程进入pfn,同时ESP被设定。进入pfn函数时的堆栈指针如下:

------------------ ss_sp + ss_size

| ... |

| ... |

| s2 |

| s1 |

| void * |

------------------

| pfn 栈帧空间 |

| |

| |

------------------ ss_sp

这里可以看到ctx->regs[ 7 ] = (char*)(sp) - sizeof(void*)这行代码的用意了,这里void*作为返回地址的占位符。这样再pfn函数中才能按照约定的读取参数1(ESP+4)和参数2(ESP+8)。

coctx_swap main所在的regs[7]保存的是返回地址值+4,这样regs[0]的值放入eax后再压栈放入的刚好是应该存放返回地址的位置。

栈空间16直接对齐的哲学

为什么栈的地址 sp需要 16字节对齐:

sp = (char*)((unsigned long)sp & -16L);

16这个数看起来一点不特殊,32位下4字节对齐,64位系统下8字节对齐,感觉更好理解,那么16是什么。查了一堆资料,原来GCC默认的堆对齐设置的就是16字节,Stack Overflow上找到了一个靠谱的解释。 小小的魔术值,真的藏龙卧虎。

Why does System V / AMD64 ABI mandate a 16 byte stack alignment?

协程切换思考

这里再梳理下一个简单例子的协程切换

main 中对应的协程相关代码

coctx_make(&pending_ctx, (coctx_pfn_t)RoutineFunc, &pending,&mainRo);

coctx_swap(mainRo.ctx, pending.ctx);

RoutineFunc 实现:

static int RoutineFunc(stRo_t *cur, stRo_t *pre){

if(cur->fn){

(*cur->fn)(&cur->counter);

}

coctx_swap(cur->ctx, pre->ctx);

return 0;

}

mainRo 指代main本身

pending 指代 即将切换到的协程

回顾下汇编代码,两个最关键的存储:

regs[0] 存放下一个指令执行地址,也即返回地址。

regs[7] 存放切换到新协程后,ESP指针调整的新地址。

程序的指令和数据均会重新设置,这也是程序能够切换的技术保证。

main -> pending

pending 通过coctx_make构建,main 通过第一次切换时函数 coctx_swap 填充。

pengding 主要设置 regs[7] 位新协程ESP的开始地址,regs[0] 为新协程函数执行地址。

main 主要regs[0] 保存了 coctx_swap 执行往后的下一个指令的地址,这里主要是利用了 __cdecl 约定在函数调用发起的时候,由caller将参数及返回地址入栈,这样刚进入coctx_swap时 ESP指向的就是 coctx_swap 执行完毕后的下一个指令的地址,libco这里有ESP +4及 -4这样调整,实际最终放入的还是ESP的值。regs[7] 用于调整ESP的栈帧,coctx_swap保存的main 的regs[7]为返回地址+4的位置,也即第一个参数所在的栈帧位置。当函数调用完成后ret指令返回后,返回地址已经被pop出来了,执行完函数回到caller中时ESP此时应该就是指向返回地址+4的位置。返回主协程后,按照调用约定,有caller清空调用时入栈的参数,这样ESP就恢复到了正常的位置。

这里回顾下 call及ret两个汇编指令,call将返回地址入栈,然后跳转到目标地址。ret 将返回地址出栈,将控制流程恢复到返回地址所在指令。所以函数调用完成返回后ESP的标准位置ret弹出返回地址之后的位置,也就是返回地址+4的位置。

libco的汇编代码里面关于返回地址 及返回地址+4的这种操作,初看很奇怪。仔细思考下__cdecl调用约定及函数调用过程,就能明白其设计的精妙。通过在返回地址+4后面执行一个push操作覆盖返回地址为需要执行的指令地址,达到欺骗程序的目的实现自定义跳转。这个欺骗是建立在ESP的基础上的,push操作严格依赖ESP位置,ESP指向了返回地址+4的位置,这样再push就没有问题了。这就是为何coctx_swap在main的 ctx regs[7]保存的是返回地址+4的位置。

这里和工程实践最关键的问题出来了,ESP怎么调,调到哪里,影响是什么。ESP既然是栈帧,就不可能无限增长,能用多大,取决于我们预先分配。ctx->ss_sp就是程序制定的新协程执行的栈帧空间,这个空间使用其实并不简单,在协程里面进行函数调用变量使用,均会消耗栈帧空间。稍不注意超过了ctx->ss_sp能访问的空间程序就会core。libco提供了两种方式分配改栈帧空间,私有的和共享的。利弊很多文章均有表述,主要还是取决于实际应用场景。

pending -> main

main coctx 并非用户主动创建,而是由第一次coctx_swap 自动保存。为何不能主动创建?没有函数调用保存ESP、返回地址和对应的汇编修改函数的默认行为,在main中是无法自动创建这样的上下文的。这个自动保存需要非常谨慎,一旦恢复不回来整个程序就core了,毕竟main的栈帧由操作系统严格分配,执行完后,涉及main函数的EBP等的恢复,如果回到了错误的内存区域,其结果完全是未定义的。main并不是创世者,也是一个函数调用,改变其栈帧不及时恢复,完全破坏了其原本行为,会导致core。可以试验下 协程切走了,不切回来,看看会不会core。从pending切换回来, 需要恢复main的执行现场,这里先不管是调用的什么函数切走的。按照正常的函数调用,执行结束后,控制流程回到main中时,此时ESP应该指向的是该函数返回地址+4的位置,然后由main执行coctx_swap 函数调用时入栈参数的清理工作(如果有)。所以保存现场需要保存的ESP的地址为 返回地址+4。返回地址很容易获得,进入任何一个callee总时,ESP还没有发生变化,此时ESP指向就是返回地址 4(ESP)即返回地址+4的位置。返回地址本身则保存在regs[0]中,用于指令切换回来。

从pending切到main,pending的上下文也发生了改变,这里的上下文通过coctx_swap保存重新设置,切出去那个点的返回地址压入到regs[0], 返回地址+4的值压入到regs[7],和从main切过来过程就完全一样了。

贡献一个helloworld

libco例子封装的有点复杂,其实理解切换流程,留一个helloworld对于理解二者的切换再好不过。如果要实现自己的协程库,只需要包含coctx.h coctx.cpp coctx_swap.S 三个文件,就可以展开手脚干活了,可以不用沿用libco过渡封装的这一套。

#include "coctx.h"

#include

#include

#include

#include

using std::string;

const static int STACK_SIZE = 128 * 1024;

const static int BUFF_SIZE = 2 * STACK_SIZE;

extern "C"

{

extern void coctx_swap( coctx_t *,coctx_t* ) asm("coctx_swap");

};

struct stRo_t{

coctx_t *ctx;

string content;

void (*fn) (stRo_t *);

};

static int RoutineFunc(stRo_t *cur, stRo_t *pre){

if(cur->fn){

(*cur->fn)(cur);

}

coctx_swap(cur->ctx, pre->ctx);//第一次切回去

if(cur->fn){

(*cur->fn)(cur);

}

coctx_swap(cur->ctx, pre->ctx);//第二次切回去

printf("invalid here!\n");//走到这里来,表示错误的切换过来了,后续没有切回去的代码了,走到这里会core

return 0;

}

static void UserFunc(stRo_t* co){

printf("%s", co->content.c_str()) ;

return;

}

int main(){

char* buffer = new char[2 * STACK_SIZE];

memset(buffer,0,sizeof(char) * BUFF_SIZE);

coctx_t main_ctx;

main_ctx.ss_size = STACK_SIZE;

main_ctx.ss_sp = buffer;

coctx_t pending_ctx;

pending_ctx.ss_size = STACK_SIZE;

pending_ctx.ss_sp = buffer + STACK_SIZE;

stRo_t pending, mainRo;

pending.ctx = &pending_ctx;

pending.fn = UserFunc;

pending.content = "hello ";

mainRo.ctx = &main_ctx;

mainRo.fn = NULL;

//printf("main 0, before switch to hello\n");

//构建pending协程coctx_t结构

coctx_make(&pending_ctx, (coctx_pfn_t)RoutineFunc, &pending,&mainRo);

//第一次切到协程

coctx_swap(mainRo.ctx, pending.ctx);

//printf("main 1, switch back to main\n");

pending.content = "world!\n";

//第二次切到协程

coctx_swap(mainRo.ctx, pending.ctx);

//printf("main 2, switch back to main\n");

delete []buffer;

return 0;

}

执行结果:

hello world!

总结

协程切换看起来有点高深,确实比较精妙。

缺乏汇编的经验,对这些寄存器相关的理解并不深刻,有种讳莫如深的感觉。

协程切换利用了函数调用caller会将参数及返回地址入栈这一特性,

使得coctx_swap有机会保存现场指令及栈帧位置,

实现代码在切来切去还能保证指令的延续以及数据的延续。

切的本质也是巧妙的利用了ret指令约定从当前栈顶弹出返回地址放入IP寄存器,让出控制流。

(因为懒,先发在内网上了,今天才挪出来,不涉及内部任何信息,纯粹个人学习心得)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值