記boost協程切換bug發現和分析

在分析了各大開源協程庫實現後,最終選擇參考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/

發佈了13 篇原創文章 · 獲贊 8 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章