小伙伴们大家好,今天,我要讲解的知识是ucontext以及与之对应的几个函数。为什么需要去学习这个知识点呢?因为这对于我们理解swoole的协程大有帮助(虽然Swoole不是基于ucontex的库,但是核心思想很类似)。至少来说,得清楚协程切换时大概做了什么事情。然后呢,会让我们的tinyswoole服务器扩展支持协程。
我将会通过gdb调试来分析它。(这里,我假设大家都使用过协程)
我的实验环境是centos。并且gdb使用了peda插件(为了实时观察寄存器、下一条指令位置、函数栈的状态)。
我的实验代码如下:
1 |
|
大概讲解一下里面的变量以及这个程序做了什么事情吧。
co_stack
是我们自定义的栈。CO_DEFAULT_STACK_SIZE
是自定义栈的大小。ctx
和ctx_main
是协程运行时的上下文。
而这个程序的作用就是切换10次task协程。
执行它的效果如下:
1 | hello world |
OK,我们现在开始调试。
1 | sh-4.2# gcc test.c -g |
我们现在main函数处打断点:
1 | b main |
然后运行程序:
1 | Starting program: /root/codeDir/cCode/test/a.out |
此时断点被触发,程序运行到了20行之前。此时,我们打印一下ctx
和ctx_main
的地址:
1 | p &ctx |
我们继续往下走,分配我们自定义栈的空间(执行第20行的malloc代码):
1 | n |
我们看一下自定义栈的地址:
1 | p co_stack |
地址是0x7ffff780d010
。
我们让程序继续执行到26行之前:
1 | u 26 |
此时,我们来看一看当前线程常见的寄存器内容:
1 | info registers |
然后再看看ctx这个结构体的内容:
1 | p ctx |
我们此时重点关注一下ctx
的uc_mcontext
这个成员变量:
1 | p ctx.uc_mcontext |
greps里面保存了寄存器的值。
OK,我们继续往下执行第26行代码,即getcontext(&ctx);
:
1 | n |
此时,我们来看一看ctx.uc_mcontext.greps的内容:
1 | p ctx.uc_mcontext |
我们结合gdb-peda
registers区域显示的寄存器内容以及code区域显示的代码地址,可以大致判断出ctx的gregs里面保存的寄存器为:
1 | 0xffffffffffffffff (r8), 0x200000 (r9), |
并且,下一条指令会是
1 | => 0x40073c <main+79>: mov rax,QWORD PTR [rbp-0x10] |
也就是
1 | ctx.uc_stack.ss_sp = co_stack; |
这条语句的起始汇编代码。所以,getcontext(&ctx)
的行为会把一些寄存器的值保存到ctx这个结构体的uc_mcontext.greps里面(如果ctx里面本来就保存了寄存器的值,也会全部被更新一遍)。
OK,28、29、30这三行是赋值语句,表达的意思很清晰。
我们这里打印一下&ctx_main
的值,同时也是ctx.uc_link
的值:
1 | p &ctx_main |
所以我们让代码运行到31行,也就是makecontext(&ctx, &task, 0)
之前:
1 | u 31 |
(此时,ctx里面存储的寄存器值还没有变化)
我们继续执行makecontext(&ctx, &task, 0)
:
1 | n |
此时,我们看一看ctx里面保存的寄存器的值:
1 | p ctx.uc_mcontext |
对比之前执行getcontext(&ctx);
之后的ctx,我们发现此时ctx存储的sp、rip、bx
寄存器值发生了变化。
1 | 0x7fffffffe500 -> 0x7ffff7a0cff8 (rsp) |
我们发现,ctx里面保存的这个sp的值和co_stack所指向的自定义的栈的地址(0x7ffff780d010)有一点接近。那么这个是如何计算出来的呢?公式如下:
1 | sp = (greg_t *) ((uintptr_t) ucp->uc_stack.ss_sp |
我们根据这个公式来计算一下(我们转化为10进制来计算):
1 | ctx中保存的sp值: 0x7ffff7a0cff8 -> 140737347899384 |
140737347899407 & -16
的作用是使我们自定义的栈对齐。140737347899392 - 8
的作用是预留出8字节的trampoline空间(防止相互递归的发生)。
OK,我们继续来看看ctx中rip的值0x4006dd
,很容易可以猜出来,他就是task这个函数的地址:
1 | p &task |
所以,makecontext的行为之一是修改ctx中rsp、rip、rbx的值。
然后,我们来看一看co_stack自定义的栈里面有没有保存些什么内容吧。
1 | x /48b 0x7ffff7a0cff8 |
我们发现,这里面存放了一些内容:
1 | 0x7ffff75a6010 |
其中,0x7ffff75a6010
的值是__start_context
函数(这个函数是context这个库里面带的)的地址:
1 | p &__start_context |
而0x7fffffffe500
的值是ctx.uc_link
的值:
1 | p ctx.uc_link |
也就是说,执行了makecontext函数之后,会填充我们自定义的栈,填充的内容分别是__start_context
函数的地址和ctx.uc_link
的值。
注意,我们的这个实验因为没有给makecontext后面传递参数,所以,ctx.uc_link
的值是紧挨着__start_context
来存放的,并且只有ctx
里面的rsp和rip发生了变化,如果传递了参数并且小于等于6个,那么还是会有其他寄存器发生变化的。如果大于了6个,那么会把多出来的那些参数压栈(这个和c编译器的行为类似)。
OK,我们继续执行:
1 | n |
然后,再看一下寄存器的内容:
1 | info registers |
再看一下ctx_main
的内容:
1 | p ctx_main.uc_mcontext |
我们发现,此时的ctx_main
里面没有保存任何寄存器的值对吧。(原因很简单,之前我们都是对ctx执行操作,没有对ctx_main
操作过)
OK,我们接着在task函数处打一个断点:
1 | b task |
OK,我们继续执行:
1 | n |
此时,断点触发。
也就是说,我们执行完
1 | swapcontext(&ctx_main, &ctx); |
这条语句之后,程序调到了task函数处执行。为什么呢?我们来看看寄存器的内容:
1 | info registers |
我们可以观察一下这些寄存器的值,会发现,其实就是ctx
里面保存的寄存器值。也就是说,ctx里面的寄存器值,mov
给了cpu里面对应的寄存器。可以看看,cpu里面的rsp
指向的是堆中的内存,而不是栈里面的内存,其实就是我们自定义的那个co_stack
。正是因为这个栈是通过堆来进行模拟的,所以,我们发现这个自定义的栈可以开的比较大,而且切换协程,里面的数据不会被丢失。
OK,我们继续往下执行:
1 | n |
我们发现我们的终端上面打印出了字符串hello world
。
为什么我们的程序执行完task之后可以回到34行之后,也就是swapcontext(&ctx_main, &ctx);
之后继续执行呢?因为ctx.uc_link
的存在,这个成员变量里面保存了一个后继上下文,在这里,就是我们的ctx_main
。
而swapcontext(&ctx_main, &ctx)
的行为除了可以把ctx
里面保存的寄存器值mov
到cpu的寄存器里面,第二个行为就是把当前cpu的寄存器值保存到ctx_main
里面。所以,一旦task
执行完了,那么就会去执行ctx.uc_link
这个上下文,而这个上下文在每次执行swapcontext
的时候,都被提前保存在了ctx_main
里面了。所以,每次都是可以回到34行然后继续执行下去。
那为什么每次执行完swapcontext
之后,需要makecontext
一下呢?因为,ctx
里面保存的上下文被破坏了,所以需要重新makecontext
一下。
如果理解了我这篇文章讲解的知识点,是不是发现可以对代码进行一个小小的改动呢?改动如下:
1 |
|
我们编译运行一下:
1 | sh-4.2# gcc test.c -g |
也就是说,我只需要保证每次swapcontext
的时候,ctx
里面的上下文是正常的,就可以啦。
(本文完结)