记boost协程切换bug发现和分析

时间:2021-12-15 23:26:13

在分析了各大开源协程库实现后,最终选择参考boost.context的汇编实现,来写tbox的切换内核。

在这过程中,我对boost各个架构平台下的context切换,都进行了分析和测试。

在macosx i386和mips平台上实现协程切换时,发现boost那套汇编实现是有问题的,如果放到tbox切换demo上运行,会直接挂掉。

在分析这两个架构上,boost.context切换实现问题,这边先贴下tbox上的context切换demo,方便之后的讲解:

static tb_void_t func1(tb_context_from_t from)
{
// check
tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;
tb_assert_and_check_return(contexts); // 先保存下主函数入口context,方便之后切换回去
contexts[0] = from.context; // 初始化切换到func2
from.context = contexts[2]; // loop
tb_size_t count = 10;
while (count--)
{
// trace
tb_trace_i("func1: %lu", count); // 切换到func2,返回后更新from中的context地址
from = tb_context_jump(from.context, contexts);
} // 切换回主入口函数
tb_context_jump(contexts[0], tb_null);
}
static tb_void_t func2(tb_context_from_t from)
{
// check
tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;
tb_assert_and_check_return(contexts); // loop
tb_size_t count = 10;
while (count--)
{
// trace
tb_trace_i("func2: %lu", count); // 切换到func1,返回后更新from中的context地址
from = tb_context_jump(from.context, contexts);
} // 切换回主入口函数
tb_context_jump(contexts[0], tb_null);
}
static tb_void_t test()
{
// 定义全局堆栈
static tb_context_ref_t contexts[3];
static tb_byte_t stacks1[8192];
static tb_byte_t stacks2[8192]; // 生成两个context上下文,绑定对应函数和堆栈
contexts[1] = tb_context_make(stacks1, sizeof(stacks1), func1);
contexts[2] = tb_context_make(stacks2, sizeof(stacks2), func2); // 切换到func1并传递私有参数:context数组
tb_context_jump(contexts[1], contexts);
}

这里为了测试context切换,直接使用的底层切换接口tb_context_maketb_context_jump,所以代码使用上,比较原始。

这两个接口相当于boost的make_fcontextjump_fcontext,当然实际应用中,tbox的协程库提供了更上层的封装,并不会直接使用这两个接口。

这个demo很简单,就是创建两个context,来回切换,最后结束返回到主函数。

然后再直接尝试使用boost的实现时,出现了两个不同现象的crash

  1. macosx i386下,从func2切换回到func1时发生了崩溃
  2. mips32下,在执行完10次来回切换后,切回主函数是,发生了崩溃

macosx i386下的问题分析

我们先来分析下macosx i386的这个问题,由于之前tbox已经参考了boost的linux i386下的实现,完成了上下文切换,是能正常运行的。

因此,可以在这两个平台下做下对比,结果发现,boost几乎是直接照搬了linux下那套实现,那么问题来了,为甚了linux下ok,macosx上就有问题呢。

大体可以猜到,应该是调用栈布局的不同导致的问题,因此我们看下macosx上的boost jump实现:

.text
.globl _jump_fcontext
.align 2
_jump_fcontext:
pushl %ebp /* save EBP */
pushl %ebx /* save EBX */
pushl %esi /* save ESI */
pushl %edi /* save EDI */ /* store fcontext_t in ECX */
movl %esp, %ecx /* first arg of jump_fcontext() == context jumping to */
movl 0x18(%esp), %eax /* second arg of jump_fcontext() == data to be transferred */
movl 0x1c(%esp), %edx /* restore ESP (pointing to context-data) from EAX */
movl %eax, %esp /* address of returned transport_t */
movl 0x14(%esp), %eax
/* return parent fcontext_t */
movl %ecx, (%eax)
/* return data */
movl %edx, 0x4(%eax) popl %edi /* restore EDI */
popl %esi /* restore ESI */
popl %ebx /* restore EBX */
popl %ebp /* restore EBP */ /* jump to context */
ret $4

jump_fcontext的参数原型是:struct(context, data) = jump_fcontext(context, data),跟tboxtb_context_jump差不多

都是传入一个struct,相当于传入了两个参数,一个context,一个data,返回结果也是一个类似struct

而从上面的代码中可以看到,从esp + 0x18处取了第一个参数context,esp + 0x1c取得是第二个参数data,换算到_jump_fcontext的入口处

可以确定出_jump_fcontext入口处大体的栈布局:

esp + 12: data参数
esp + 8: context参数
esp + 4: ??
esp : _jump_fcontext的返回地址

按照i386的调用栈布局,函数入口处第一个参数,应该是通过 esp + 4 访问的,那为什么context参数却是在esp + 8处呢,esp + 4指向的内容又是什么?

我们可以看下,_jump_fcontext调用处的汇编伪代码:

pushl data
pushl context
pushl hidden
call _jump_fcontext
addl $12, %esp

其实编译器在调用_jump_fcontext处,实际压入了三个参数,这个esp + 4指向的hidden数据,这个是_jump_fcontext返回的struct数据的栈空间地址

用于在_jump_fcontext内部,设置返回struct(context, data)的数据,也就是:

/* address of returned transport_t */
movl 0x14(%esp), %eax
/* return parent fcontext_t */
movl %ecx, (%eax)
/* return data */
movl %edx, 0x4(%eax)

说白了,linux i386上返回struct数据,是通过传入一个指向栈空间的变量指针,作为隐藏的第一个参数,用于设置struct数据返回。

而boost在macosx i386上,也直接照搬了这种布局来实现,那macosx上是否真的也是这么做的呢?

我们来写个测试程序验证下:

static tb_context_from_t test()
{
tb_context_from_t from = {0};
return from;
}

反汇编后的结果如下:

__text:00051BD0 _test           proc near
__text:00051BD0
__text:00051BD0 var_10 = dword ptr -10h
__text:00051BD0 var_C = dword ptr -0Ch
__text:00051BD0 var_8 = dword ptr -8
__text:00051BD0 var_4 = dword ptr -4
__text:00051BD0
__text:00051BD0 push ebp
__text:00051BD1 mov ebp, esp
__text:00051BD3 sub esp, 10h
__text:00051BD6 mov [ebp+var_C], 0
__text:00051BDD mov [ebp+var_10], 0
__text:00051BE4 mov [ebp+var_4], 0
__text:00051BEB mov [ebp+var_8], 0
__text:00051BF2 mov eax, [ebp+var_8]
__text:00051BF5 mov edx, [ebp+var_4]
__text:00051BF8 add esp, 10h
__text:00051BFB pop ebp
__text:00051BFC retn
__text:00051BFC _test endp

可以看到,实际上并没有像linux上那样通过一个struct指针来返回,而是直接将struct(context, data),通过 eax, edx 进行返回。

到这里,我们大概可以猜到,macosx上,对这种小的struct结构体返回做了优化,直接放置在了eax,edx中,而我们的from结构体只有两个pointer,正好满足这种方式。

因此,为了修复macosx上的问题,tbox在实现上,对栈布局做了调整,并且做了些额外的优化:

1. 调整jump实现,改用eax,edx直接返回from结构体
2. 由于不再像linux那样通过保留一个额外的栈空间返回struct,可以把linux那种跳板实现去掉,改为直接jump到实际位置(提升切换效率)

mips32下的问题分析

mips下这个问题,我之前也是调试了很久,在每次切换完成后,打算切换回主函数时,就会发生crash,也就是下面这个位置:

static tb_void_t func1(tb_context_from_t from)
{
// check
tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;
tb_assert_and_check_return(contexts); // 先保存下主函数入口context,方便之后切换回去
contexts[0] = from.context; // 初始化切换到func2
from.context = contexts[2]; // loop
tb_size_t count = 10;
while (count--)
{
// trace
tb_trace_i("func1: %lu", count); // 切换到func2,返回后更新from中的context地址
from = tb_context_jump(from.context, contexts);
} // 切换回主入口函数
tb_context_jump(contexts[0], tb_null); <----- 此处发生崩溃
}

我们先来初步分析下,既然之前的来回切换都是ok的,只有在最后这个切换发生问题,那么可以确定jump的大体实现应该还是ok的

可能是传入jump的参数不对导致的问题,最有可能的是 contexts[0] 指向的主函数上下文地址已经不对了。

通过printf确认,确实值不对了,那么在func1入口处这个contexts[0],是否正确呢,我又继续printf了下,居然还是不对。 = =

然后,我又继续打印contexts[0], contexts[1], contexts[2]这三个在func1入口处的值,发现只有contexts[2]是对的

前两处都不对了,而且值得注意的是,这两个的值,正好是from.context和from.data的值。

由此,可以得出一个初步结论:

1. contexts这块buffer的前两处数据,在jump切换到func1的时候被自动改写了
2. 而且改写后的数据值,正好是from里面的context和data

说白了,也就是发生越界了。。

那什么情况下, contexts指向的数据会发生越界呢,可以先看下contexts的定义:

static tb_void_t test()
{
// 定义全局堆栈
static tb_context_ref_t contexts[3];
static tb_byte_t stacks1[8192];
static tb_byte_t stacks2[8192]; // 生成两个context上下文,绑定对应函数和堆栈
contexts[1] = tb_context_make(stacks1, sizeof(stacks1), func1);
contexts[2] = tb_context_make(stacks2, sizeof(stacks2), func2); // 切换到func1并传递私有参数:context数组
tb_context_jump(contexts[1], contexts);
}

contexts[3]的数据定义,正好在stacks1的上面,而stacks1是作为func1的堆栈传入的,也就是说,如果func1的堆栈发生上溢,就会擦掉contexts里面的数据。

我们接着来看下,boost的实现,看看是否有地方会发生这种情况:

.text
.globl make_fcontext
.align 2
.type make_fcontext,@function
.ent make_fcontext
make_fcontext:
#ifdef __PIC__
.set noreorder
.cpload $t9
.set reorder
#endif
# first arg of make_fcontext() == top address of context-stack
move $v0, $a0 # shift address in A0 to lower 16 byte boundary
move $v1, $v0
li $v0, -16 # 0xfffffffffffffff0
and $v0, $v1, $v0 # reserve space for context-data on context-stack
# including 48 byte of shadow space (sp % 16 == 0)
addiu $v0, $v0, -112 # third arg of make_fcontext() == address of context-function
sw $a2, 44($v0)
# save global pointer in context-data
sw $gp, 48($v0) # compute address of returned transfer_t
addiu $t0, $v0, 52
sw $t0, 36($v0) # compute abs address of label finish
la $t9, finish
# save address of finish as return-address for context-function
# will be entered after context-function returns
sw $t9, 40($v0) jr $ra # return pointer to context-data finish:
lw $gp, 0($sp)
# allocate stack space (contains shadow space for subroutines)
addiu $sp, $sp, -32
# save return address
sw $ra, 28($sp) # restore GP (global pointer)
# move $gp, $s1
# exit code is zero
move $a0, $zero
# address of exit
lw $t9, %call16(_exit)($gp)
# exit application
jalr $t9
.end make_fcontext
.size make_fcontext, .-make_fcontext .text
.globl jump_fcontext
.align 2
.type jump_fcontext,@function
.ent jump_fcontext
jump_fcontext:
# reserve space on stack
addiu $sp, $sp, -112 sw $s0, ($sp) # save S0
sw $s1, 4($sp) # save S1
sw $s2, 8($sp) # save S2
sw $s3, 12($sp) # save S3
sw $s4, 16($sp) # save S4
sw $s5, 20($sp) # save S5
sw $s6, 24($sp) # save S6
sw $s7, 28($sp) # save S7
sw $fp, 32($sp) # save FP
sw $a0, 36($sp) # save hidden, address of returned transfer_t
sw $ra, 40($sp) # save RA
sw $ra, 44($sp) # save RA as PC # store SP (pointing to context-data) in A0
move $a0, $sp # restore SP (pointing to context-data) from A1
move $sp, $a1 lw $s0, ($sp) # restore S0
lw $s1, 4($sp) # restore S1
lw $s2, 8($sp) # restore S2
lw $s3, 12($sp) # restore S3
lw $s4, 16($sp) # restore S4
lw $s5, 20($sp) # restore S5
lw $s6, 24($sp) # restore S6
lw $s7, 28($sp) # restore S7
lw $fp, 32($sp) # restore FP
lw $t0, 36($sp) # restore hidden, address of returned transfer_t
lw $ra, 40($sp) # restore RA # load PC
lw $t9, 44($sp) # adjust stack
addiu $sp, $sp, 112 # return transfer_t from jump
sw $a0, ($t0) # fctx of transfer_t
sw $a1, 4($t0) # data of transfer_t
# pass transfer_t as first arg in context function
# A0 == fctx, A1 == data
move $a1, $a2 # jump to context
jr $t9
.end jump_fcontext
.size jump_fcontext, .-jump_fcontext

可以看到,boost在make_fcontext的时候,先对传入的栈顶做了16字节的对齐,然后保留了112字节的空间,用于保存寄存器数据。

然后再jump切换到新context的时候,恢复了新context所需的寄存器,并把新的sp指针+112,把保留的栈空间给pop了。

也就是说,在第一次切换到实际func1函数入口时,这个时候的栈指针指向栈顶的,再往上,已经没有多少空间了(也就只有为了16字节对齐,有可能保留的少部分空间)。

换一句话说,如果传入的stack1的栈顶本身就是16字节对齐的,那么func1的入口处sp指向的就是stack1的栈顶

如果在func1的入口处,有超过stack1栈顶范围的写操作,就有可能会擦掉contexts的数据,因为contexts紧靠着stack1的栈顶位置。

那是否会出现这种情况,我们通过反汇编func1的入口处代码,实际看下:

.text:00453F04 func1:
.text:00453F04
.text:00453F04 var_30 = -0x30
.text:00453F04 var_2C = -0x2C
.text:00453F04 var_28 = -0x28
.text:00453F04 var_20 = -0x20
.text:00453F04 var_18 = -0x18
.text:00453F04 var_14 = -0x14
.text:00453F04 var_10 = -0x10
.text:00453F04 var_8 = -8
.text:00453F04 var_4 = -4
.text:00453F04 arg_0 = 0
.text:00453F04 arg_4 = 4
.text:00453F04
.text:00453F04 addiu $sp, -0x40
.text:00453F08 sw $ra, 0x40+var_4($sp)
.text:00453F0C sw $fp, 0x40+var_8($sp)
.text:00453F10 move $fp, $sp
.text:00453F14 la $gp, unk_5706A0
.text:00453F1C sw $gp, 0x40+var_20($sp)
.text:00453F20 sw $a0, 0x40+arg_0($fp) <------------ 此处发生越界,改写了contexts[0] = from.context
.text:00453F24 sw $a1, 0x40+arg_4($fp) <------------ 此处发生越界,改写了contexts[1] = from.data
.text:00453F28 lw $v0, 0x40+arg_4($fp)
.text:00453F2C sw $v0, 0x40+var_14($fp)
.text:00453F30 lw $v0, 0x40+var_14($fp)
.text:00453F34 sltu $v0, $zero, $v0
.text:00453F38 andi $v0, 0xFF
.text:00453F3C move $v1, $v0

可以看到,确实发生了越界行为,那为什么在函数内部,还会去写当前栈帧外的数据呢,这个要从mips的调用栈布局上说起了。

简单来说,mips在调用某个函数时,会把a0-a3作为参数寄存器,其他参数放置在堆栈中,但是与其他架构有点不同的是:

mips还会去为a0-a3这前四个参数,保留栈空间

调用栈如下:

 ------------
| other args |
|------------|
| a0-a3 | <- 参数传递使用a0-a3,但是还是会为这四个参数保留栈空间出来
|------------|
| ra | <- 返回地址
|------------|
| fp gp s0-7 | <- 保存的一些其他寄存器
|------------|
| locals |
------------

而刚刚在func1内,就是回写了a0-a3处保留的栈空间,导致了越界,因为boost的实现在jump后,栈空间已经到栈顶了,空间不够了。。

因此,为了修复这个问题,只需要在make_fcontext里面,多保留a0-a3这32字节的空间就行了,也就是:

.globl make_fcontext

    # reserve space for context-data on context-stack
# including 48 byte of shadow space (sp % 16 == 0)
# addiu $v0, $v0, -112
addiu $v0, $v0, -146

而在tbox内,除了对此处的额外的栈空间保留,来修复此问题,还对栈数据进行了更加合理的分配利用,不再需要保留146这么多字节数

只需要保留96字节,就够用了,节省了50个字节,如果同时存在1024个协程的话,相当于节省了50K的内存数据。

并且boost的jump实现上,还有其他两处问题,tbox里面一并修复了:

jump_fcontext:
# reserve space on stack
addiu $sp, $sp, -112 sw $s0, ($sp) # save S0
sw $s1, 4($sp) # save S1
sw $s2, 8($sp) # save S2
sw $s3, 12($sp) # save S3
sw $s4, 16($sp) # save S4
sw $s5, 20($sp) # save S5
sw $s6, 24($sp) # save S6
sw $s7, 28($sp) # save S7
sw $fp, 32($sp) # save FP
sw $a0, 36($sp) # save hidden, address of returned transfer_t
sw $ra, 40($sp) # save RA
sw $ra, 44($sp) # save RA as PC
<-------------------- 此处boost虽然为gp保留了48($sp)空间,但是确没去保存gp寄存器 # store SP (pointing to context-data) in A0
move $a0, $sp # restore SP (pointing to context-data) from A1
move $sp, $a1 lw $s0, ($sp) # restore S0
lw $s1, 4($sp) # restore S1
lw $s2, 8($sp) # restore S2
lw $s3, 12($sp) # restore S3
lw $s4, 16($sp) # restore S4
lw $s5, 20($sp) # restore S5
lw $s6, 24($sp) # restore S6
lw $s7, 28($sp) # restore S7
lw $fp, 32($sp) # restore FP
lw $t0, 36($sp) # restore hidden, address of returned transfer_t
lw $ra, 40($sp) # restore RA
<-------------------- 此处boost也没去恢复gp寄存器 # load PC
lw $t9, 44($sp) # adjust stack
addiu $sp, $sp, 112 # return transfer_t from jump
sw $a0, ($t0) # fctx of transfer_t
sw $a1, 4($t0) # data of transfer_t <------------- 此处应该使用 a2 而不是 a1
# pass transfer_t as first arg in context function
# A0 == fctx, A1 == data
move $a1, $a2 # jump to context
jr $t9
.end jump_fcontext

最后说一下,本文是针对boost 1.62.0 版本做的分析,如有不对之处,欢迎指正哈。。


个人主页:TBOOX开源工程

原文出处:http://tboox.org/cn/2016/11/13/boost-context-bug/

记boost协程切换bug发现和分析的更多相关文章

  1. 云风协程库coroutine源码分析

    前言 前段时间研读云风的coroutine库,为了加深印象,做个简单的笔记.不愧是大神,云风只用200行的C代码就实现了一个最简单的协程,代码风格精简,非常适合用来理解协程和用来提升编码能力. 协程简 ...

  2. 基于汇编的 C&sol;C&plus;&plus; 协程 - 切换上下文

    在前一篇文章<基于汇编的 C/C++ 协程 - 背景知识>中提到一个用于 C/C++ 的协程所需要实现的两大功能: 协程调度 上下文切换 其中调度,其实在技术实现上与其他的线程.进程调度没 ...

  3. go runtime&period;Gosched&lpar;&rpar; 和 time&period;Sleep&lpar;) 做协程切换

    网上看到个问题: package main import ( "fmt" "time" ) func say(s string) { ; i < ; i+ ...

  4. python gevent自动挡的协程切换。

    import gevent def func(): print('running func 111')#第一步运行 gevent.sleep(2)#切换到下个协程 print('running fun ...

  5. greenlet 实现手动协程切换

    from greenlet import greenlet def test1(): print('12') gr2.switch() #切换到gr2 print('34') gr2.switch() ...

  6. &lbrack;dev&rsqb; Go的协程切换问题

    子标题:runtime.Gosched() 是干嘛用的? 1. go程序都有一个环境变量,做线程数设置 GOMAXPROCS 2. 当协程数小于等于线程数的时候,程序行为上与多线程没有区别. 3. 当 ...

  7. Python协程的引入与原理分析

    相关概念 并发:指一个时间段内,有几个程序在同一个cpu上运行,但是任意时刻只有一个程序在cpu上运行.比如说在一秒内cpu切换了100个进程,就可以认为cpu的并发是100. 并行:值任意时刻点上, ...

  8. 基于ASIO的协程与网络编程

    协程 协程,即协作式程序,其思想是,一系列互相依赖的协程间依次使用CPU,每次只有一个协程工作,而其他协程处于休眠状态.协程可以在运行期间的某个点上暂停执行,并在恢复运行时从暂停的点上继续执行. 协程 ...

  9. 写个百万级别full-stack小型协程库——原理介绍

    其实说什么百万千万级别都是虚的,下面给出实现原理和测试结果,原理很简单,我就不上图了: 原理:为了简单明了,只支持单线程,每个协程共享一个4K的空间(你可以用堆,用匿名内存映射或者直接开个数组也都是可 ...

随机推荐

  1. nginx中将POST数据写到日志里面的正确方式

    http://www.cnblogs.com/meteorx/p/3188647.html

  2. Laravel5&period;1-Eloquent ORM:起步

    概述 有很多朋友问,MCV的M是在哪里介绍的,这里就是介绍M的地方了.Laravel有一个强大的数据库ORM Eloquent,它的原理是每张数据表对应一个Model,对Model的操作就对应数据库的 ...

  3. maven设置---Dmaven&period;multiModuleProjectDirectory system propery is not set&period;

    设置maven 环境变量: MAVEN_HOME:D:\Java\apache-maven-3.3.3 M2_HOME:D:\Java\apache-maven-3.3.3 path:%MAVEN_H ...

  4. ni

    坚强歌词 马天宇 - 坚强 天使的翅膀挥动着的光芒一路走来学会了坚强每一次你努力认真的模样让我很欣赏 雨天的路上会有一缕阳光温暖被淋湿的希望再小的河也能汇成海洋让我去远航 一路上陪伴我的目光是最感动的 ...

  5. MSSQL发现第五到数据的第十

    第二十数据查询数据库,第十条数据,两起案件: 1,ID是连接的,当然这样的情况比較好查.直接SELECT就能够了.取ID大于5小于10就能够了, 这样的情况比較少. 2.ID不是连接的.假设要取第五条 ...

  6. ElasticSearch 学习记录之ES如何操作Lucene段

    近实时搜索 提交(Commiting)一个新的段到磁盘需要一个 fsync 来确保段被物理性地写入磁盘,这样在断电的时候就不会丢失数据.但是每次提交的一个新的段都fsync 这样操作代价过大.可以使用 ...

  7. &lbrack;物理学与PDEs&rsqb;第2章第2节 粘性流体力学方程组 2&period;3 广义 Newton 法则---本构方程

    1.  ${\bf P}=(p_{ij})$, 而 $$\bex p_{ij}=-p\delta_{ij}+\tau_{ij}, \eex$$ 其中 $\tau_{ij}$ 对应于摩擦切应力. 2. ...

  8. java控台输入

    import java.util.Scanner;//访问util包的Scanner(控台输入) public class HelloWorld {public static void main(St ...

  9. SQL 在OPENQUERY中使用参数,并作为表查询对象&sol;不允许使用远程表值函数调用。

    SQL 在OPENQUERY中使用参数 DECLARE @tmptable AS TABLE(ccode nvarchar(20),str1 NVARCHAR(50),str15 NVARCHAR(2 ...

  10. header 格式

    headers = { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,* ...