协程可以认为是一种用户态的线程,与系统提供的线程不同点是,它需要主动让出CPU时间,而不是由系统进行调度,即控制权在程序员手上。
既然看成是用户态线程,那必然要求程序员自己进行各个协程的调度,这样就必须提供一种机制供编写协程的人将当前协程挂起,即保存协程运行场景的一些数据,调度器在其他协程挂起时再将此协程运行场景的数据恢复,以便继续运行。这里我们将协程运行场景的数据称为上下文。
在linux里,有getcontext和swapcontext等接口来获取当前的上下文数据和切换上下文。那如果没有提供相应的接口,又该如何来实现呢?
其实说到底,保存下上文数据,不外乎就是保存下当前运行的栈空间的数据,还有cpu各个寄存器相应的值。只要我们能够将其保存下来,在特定的时刻恢复回去就可以了。
有人用c提供的接口setjmp和longjmp来实现协程的切换和恢复,但这里要介绍另外一种方式,即用汇编来保存/恢复cpu寄存器的值。
用汇编的方式依赖于特定的平台,这里举例的是i386 32位的*nix平台。
在开始贴代码前,要先说一个概念–栈帧。
ia32程序用程序栈来支持过程调用。机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分栈称为栈帧。下图描绘了linux下栈帧的通用结构。栈帧的最顶端以两个指针界定,寄存器%ebp为帧指针,而寄存器%esp为栈指针。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。
这里我们可以看到,在调用一个函数前,都会先将各个参数、调用者在被调用函数返回时执行的下一条指令的地址–返回地址压栈,被调用函数在开始前会将%ebp的值保存,然后将当前%esp的值赋予%ebp。弄明白帧指针和栈指针的作用,以及返回地址等如何通过%ebp来获取的话,对下面保存当前上下文的汇编代码理解比较有帮助。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
struct
mcontext
{
/*
* The first 20 fields must match the definition of
* sigcontext. So that we can support sigcontext
* and ucontext_t at the same time.
*/
int
mc_onstack
;
/* XXX - sigcontext compat. */
int
mc_gs
;
int
mc_fs
;
int
mc_es
;
int
mc_ds
;
int
mc_edi
;
int
mc_esi
;
int
mc_ebp
;
int
mc_isp
;
int
mc_ebx
;
int
mc_edx
;
int
mc_ecx
;
int
mc_eax
;
int
mc_trapno
;
int
mc_err
;
int
mc_eip
;
int
mc_cs
;
int
mc_eflags
;
int
mc_esp
;
/* machine state */
int
mc_ss
;
int
mc_fpregs
[
28
]
;
/* env87 + fpacc87 + u_long */
int
__spare__
[
17
]
;
}
;
struct
ucontext
{
/*
* Keep the order of the first two fields. Also,
* keep them the first two fields in the structure.
* This way we can have a union with struct
* sigcontext and ucontext_t. This allows us to
* support them both at the same time.
* note: the union is not defined, though.
*/
sigset_t
uc_sigmask
;
mcontext_t
uc_mcontext
;
struct
__ucontext
*
uc_link
;
stack_t
uc_stack
;
int
__spare__
[
8
]
;
}
;
|
ucontext结构体主要关心的为uc_mcontext和uc_stack这两个成员,其中uc_stack指向一段内存,这段内存做为协程的运行栈;而uc_context为mcontext类型,各个成员保存着CPU同名的寄存器值。
1
|
int
getmcontext
(
mcontext_t
*
)
;
/*保存当前上下文的声明*/
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
/
*保存当前上下文的汇编实现
*
/
.
globl
GET
GET
:
movl
4
(
%esp
)
,
%eax
movl
%fs
,
8
(
%eax
)
movl
%es
,
12
(
%eax
)
movl
%ds
,
16
(
%eax
)
movl
%ss
,
76
(
%eax
)
movl
%edi
,
20
(
%eax
)
movl
%esi
,
24
(
%eax
)
movl
%ebp
,
28
(
%eax
)
movl
%ebx
,
36
(
%eax
)
movl
%edx
,
40
(
%eax
)
movl
%ecx
,
44
(
%eax
)
movl
$1
,
48
(
%eax
)
/
*
%eax
*
/
movl
(
%esp
)
,
%ecx
/
*
%eip
*
/
movl
%ecx
,
60
(
%eax
)
leal
4
(
%esp
)
,
%ecx
/
*
%esp
*
/
movl
%ecx
,
72
(
%eax
)
movl
44
(
%eax
)
,
%ecx
/
*
restore
%ecx
*
/
movl
$0
,
%eax
ret
|
上述分别是保存上下文的C接口声明和汇编实现。根据第4行汇编代码可以看出,GET函数所需要的参数值被保存到%eax,之所以根据4(%esp)来寻址,是因为这时候栈指针指向的是保存返回地址的内存地址。接着将各个寄存器的值保存到参数值指向的mcontext结构体,结合下struct mcontext以及代码里的移位看就可以了,这里就不多说了。唯一比较难理解的可能就是%eip寄存器值的获取了。由于这时候要保存的是调用GET函数的过程的上下文,这时候%eip寄存器保存的并不是调用GET函数过程的下一条指令的值,GET函数栈帧的返回地址才是调用GET函数过程返回后应该往下执行的下一条指令,因此可以看到上面汇编代码18行是将栈指针指向内存保存的值做为%eip的值保存起来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
.
globl
SET
SET
:
movl
4
(
%esp
)
,
%eax
movl
8
(
%eax
)
,
%fs
movl
12
(
%eax
)
,
%es
movl
16
(
%eax
)
,
%ds
movl
76
(
%eax
)
,
%ss
movl
20
(
%eax
)
,
%edi
movl
24
(
%eax
)
,
%esi
movl
28
(
%eax
)
,
%ebp
movl
36
(
%eax
)
,
%ebx
movl
40
(
%eax
)
,
%edx
movl
44
(
%eax
)
,
%ecx
movl
72
(
%eax
)
,
%esp
pushl
60
(
%eax
)
/
*
new
%eip
*
/
movl
48
(
%eax
)
,
%eax
ret
|
至于恢复上下文的SET函数,要说的就是它是如何来改变%eip寄存器的值。根据上面第17行的汇编代码,它只是将新的%eip的值压栈而已,并不是直接赋予ip寄存器。我们这里再看一下当执行到ret后会怎么样。ret可以等效于这句指令–pop %eip。当SET函数返回后即将刚刚压栈的新的%eip的值恢复到ip寄存器当中去了。
使用汇编实现的GET和SET函数,实际上就可以进行上下文的保存和恢复了。但是要实现协程这还不够,协程跟线程一样,都是提供一个函数做为入口,那我们还需要为协程构建好调用其函数入口的准备,即参数压栈,栈指针的指向,还有返回地址的保存等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
void
makecontext
(
ucontext_t
*
ucp
,
void
(
*
func
)
(
void
)
,
int
argc
,
.
.
.
)
{
int
*
sp
;
sp
=
(
int
*
)
ucp
->
uc_stack
.
ss_sp
+
ucp
->
uc_stack
.
ss_size
/
4
;
sp
-
=
argc
;
sp
=
(
void
*
)
(
(
uintptr_t
)
sp
-
(
uintptr_t
)
sp
%
16
)
;
/* 16-align for OS X */
memmove
(
sp
,
&
argc
+
1
,
argc
*
sizeof
(
int
)
)
;
*
--
sp
=
0
;
/* return address */
ucp
->
uc_mcontext
.
mc_eip
=
(
long
)
func
;
ucp
->
uc_mcontext
.
mc_esp
=
(
int
)
sp
;
}
|
第6到第9行实现了用户指定参数的入栈,第11行将返回地址指定为0.实际上linux实现的makecontext接口会根据ucontext结构体uc_link指向的值来进行设定,可以让其返回到另外一个协程继续执行。
12、13行分别设定了ip寄存器和栈指针的值,这就指定了协程开始运行的指令地址和所使用的栈空间。
makecontext函数的调用往往会伴随着SET函数的调用,由于makecontext已经指定好用户传进来的函数入口地址和栈空间的起始地址了,而SET函数返回后就会开始执行用户指定的函数了,协程开始了。
注:上述引用代码均来自于开源项目libtask。