記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)n{n // checkn tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;n tb_assert_and_check_return(contexts);nn // 先保存下主函數入口context,方便之後切換回去n contexts[0] = from.context;nn // 初始化切換到func2n from.context = contexts[2];nn // loopn tb_size_t count = 10;n while (count--)n {n // tracen tb_trace_i("func1: %lu", count);nn // 切換到func2,返回後更新from中的context地址n from = tb_context_jump(from.context, contexts);n }nn // 切換回主入口函數n tb_context_jump(contexts[0], tb_null);n}nstatic tb_void_t func2(tb_context_from_t from)n{n // checkn tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;n tb_assert_and_check_return(contexts);nn // loopn tb_size_t count = 10;n while (count--)n {n // tracen tb_trace_i("func2: %lu", count);nn // 切換到func1,返回後更新from中的context地址n from = tb_context_jump(from.context, contexts);n }nn // 切換回主入口函數n tb_context_jump(contexts[0], tb_null);n}nstatic tb_void_t test()n{ n // 定義全局堆棧n static tb_context_ref_t contexts[3];n static tb_byte_t stacks1[8192];n static tb_byte_t stacks2[8192];nn // 生成兩個context上下文,綁定對應函數和堆棧n contexts[1] = tb_context_make(stacks1, sizeof(stacks1), func1);n contexts[2] = tb_context_make(stacks2, sizeof(stacks2), func2);nn // 切換到func1並傳遞私有參數:context數組n tb_context_jump(contexts[1], contexts);n}n

這裡為了測試context切換,直接使用的底層切換介面tb_context_make和tb_context_jump,所以代碼使用上,比較原始。

這兩個介面相當於boost的make_fcontext和jump_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實現:

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

jump_fcontext的參數原型是:struct(context, data) = jump_fcontext(context, data),跟tbox的tb_context_jump差不多

都是傳入一個struct,相當於傳入了兩個參數,一個context,一個data,返回結果也是一個類似struct

而從上面的代碼中可以看到,從esp + 0x18處取了第一個參數context,esp + 0x1c取得是第二個參數data,換算到_jump_fcontext的入口處

可以確定出_jump_fcontext入口處大體的棧布局:

esp + 12: data參數nesp + 8: context參數nesp + 4: ??nesp : _jump_fcontext的返回地址n

按照i386的調用棧布局,函數入口處第一個參數,應該是通過 esp + 4 訪問的,那為什麼context參數卻是在esp + 8處呢,esp + 4指向的內容又是什麼?

我們可以看下,_jump_fcontext調用處的彙編偽代碼:

pushl datanpushl context npushl hidden ncall _jump_fcontextnaddl $12, %espn

其實編譯器在調用_jump_fcontext處,實際壓入了三個參數,這個esp + 4指向的hidden數據,這個是_jump_fcontext返回的struct數據的棧空間地址

用於在_jump_fcontext內部,設置返回struct(context, data)的數據,也就是:

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

說白了,linux i386上返回struct數據,是通過傳入一個指向棧空間的變數指針,作為隱藏的第一個參數,用於設置struct數據返回。

而boost在macosx i386上,也直接照搬了這種布局來實現,那macosx上是否真的也是這麼做的呢?

我們來寫個測試程序驗證下:

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

反彙編後的結果如下:

__text:00051BD0 _test proc near n__text:00051BD0n__text:00051BD0 var_10 = dword ptr -10hn__text:00051BD0 var_C = dword ptr -0Chn__text:00051BD0 var_8 = dword ptr -8n__text:00051BD0 var_4 = dword ptr -4n__text:00051BD0n__text:00051BD0 push ebpn__text:00051BD1 mov ebp, espn__text:00051BD3 sub esp, 10hn__text:00051BD6 mov [ebp+var_C], 0n__text:00051BDD mov [ebp+var_10], 0n__text:00051BE4 mov [ebp+var_4], 0n__text:00051BEB mov [ebp+var_8], 0n__text:00051BF2 mov eax, [ebp+var_8]n__text:00051BF5 mov edx, [ebp+var_4]n__text:00051BF8 add esp, 10hn__text:00051BFB pop ebpn__text:00051BFC retnn__text:00051BFC _test endpn

可以看到,實際上並沒有像linux上那樣通過一個struct指針來返回,而是直接將struct(context, data),通過 eax, edx 進行返回。

到這裡,我們大概可以猜到,macosx上,對這種小的struct結構體返回做了優化,直接放置在了eax,edx中,而我們的from結構體只有兩個pointer,正好滿足這種方式。

因此,為了修復macosx上的問題,tbox在實現上,對棧布局做了調整,並且做了些額外的優化:

1. 調整jump實現,改用eax,edx直接返回from結構體n2. 由於不再像linux那樣通過保留一個額外的棧空間返回struct,可以把linux那種跳板實現去掉,改為直接jump到實際位置(提升切換效率)n

mips32下的問題分析

mips下這個問題,我之前也是調試了很久,在每次切換完成後,打算切換回主函數時,就會發生crash,也就是下面這個位置:

static tb_void_t func1(tb_context_from_t from)n{n // checkn tb_context_ref_t* contexts = (tb_context_ref_t*)from.priv;n tb_assert_and_check_return(contexts);nn // 先保存下主函數入口context,方便之後切換回去n contexts[0] = from.context;nn // 初始化切換到func2n from.context = contexts[2];nn // loopn tb_size_t count = 10;n while (count--)n {n // tracen tb_trace_i("func1: %lu", count);nn // 切換到func2,返回後更新from中的context地址n from = tb_context_jump(from.context, contexts);n }nn // 切換回主入口函數n tb_context_jump(contexts[0], tb_null); <----- 此處發生崩潰n}n

我們先來初步分析下,既然之前的來回切換都是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的時候被自動改寫了n2. 而且改寫後的數據值,正好是from裡面的context和datan

說白了,也就是發生越界了。。

那什麼情況下, contexts指向的數據會發生越界呢,可以先看下contexts的定義:

static tb_void_t test()n{ n // 定義全局堆棧n static tb_context_ref_t contexts[3];n static tb_byte_t stacks1[8192];n static tb_byte_t stacks2[8192];nn // 生成兩個context上下文,綁定對應函數和堆棧n contexts[1] = tb_context_make(stacks1, sizeof(stacks1), func1);n contexts[2] = tb_context_make(stacks2, sizeof(stacks2), func2);nn // 切換到func1並傳遞私有參數:context數組n tb_context_jump(contexts[1], contexts);n}n

contexts[3]的數據定義,正好在stacks1的上面,而stacks1是作為func1的堆棧傳入的,也就是說,如果func1的堆棧發生上溢,就會擦掉contexts裡面的數據。

我們接著來看下,boost的實現,看看是否有地方會發生這種情況:

.textn.globl make_fcontextn.align 2n.type make_fcontext,@functionn.ent make_fcontextnmake_fcontext:n#ifdef __PIC__n.set noreordern.cpload $t9n.set reordern#endifn # first arg of make_fcontext() == top address of context-stackn move $v0, $a0nn # shift address in A0 to lower 16 byte boundaryn move $v1, $v0n li $v0, -16 # 0xfffffffffffffff0n and $v0, $v1, $v0nn # reserve space for context-data on context-stackn # including 48 byte of shadow space (sp % 16 == 0)n addiu $v0, $v0, -112nn # third arg of make_fcontext() == address of context-functionn sw $a2, 44($v0)n # save global pointer in context-datan sw $gp, 48($v0)nn # compute address of returned transfer_tn addiu $t0, $v0, 52n sw $t0, 36($v0)nn # compute abs address of label finishn la $t9, finishn # save address of finish as return-address for context-functionn # will be entered after context-function returnsn sw $t9, 40($v0)nn jr $ra # return pointer to context-datannfinish:n lw $gp, 0($sp)n # allocate stack space (contains shadow space for subroutines)n addiu $sp, $sp, -32n # save return addressn sw $ra, 28($sp)nn # restore GP (global pointer)n# move $gp, $s1n # exit code is zeron move $a0, $zeron # address of exitn lw $t9, %call16(_exit)($gp)n # exit applicationn jalr $t9n.end make_fcontextn.size make_fcontext, .-make_fcontextnn.textn.globl jump_fcontextn.align 2n.type jump_fcontext,@functionn.ent jump_fcontextnjump_fcontext:n # reserve space on stackn addiu $sp, $sp, -112nn sw $s0, ($sp) # save S0n sw $s1, 4($sp) # save S1n sw $s2, 8($sp) # save S2n sw $s3, 12($sp) # save S3n sw $s4, 16($sp) # save S4n sw $s5, 20($sp) # save S5n sw $s6, 24($sp) # save S6n sw $s7, 28($sp) # save S7n sw $fp, 32($sp) # save FPn sw $a0, 36($sp) # save hidden, address of returned transfer_tn sw $ra, 40($sp) # save RAn sw $ra, 44($sp) # save RA as PCnn # store SP (pointing to context-data) in A0n move $a0, $spnn # restore SP (pointing to context-data) from A1n move $sp, $a1nn lw $s0, ($sp) # restore S0n lw $s1, 4($sp) # restore S1n lw $s2, 8($sp) # restore S2n lw $s3, 12($sp) # restore S3n lw $s4, 16($sp) # restore S4n lw $s5, 20($sp) # restore S5n lw $s6, 24($sp) # restore S6n lw $s7, 28($sp) # restore S7n lw $fp, 32($sp) # restore FPn lw $t0, 36($sp) # restore hidden, address of returned transfer_tn lw $ra, 40($sp) # restore RAnn # load PCn lw $t9, 44($sp)nn # adjust stackn addiu $sp, $sp, 112n n # return transfer_t from jumpn sw $a0, ($t0) # fctx of transfer_tn sw $a1, 4($t0) # data of transfer_tn # pass transfer_t as first arg in context functionn # A0 == fctx, A1 == datan move $a1, $a2 nn # jump to contextn jr $t9n.end jump_fcontextn.size jump_fcontext, .-jump_fcontextn

可以看到,boost在make_fcontext的時候,先對傳入的棧頂做了16位元組的對齊,然後保留了112位元組的空間,用於保存寄存器數據。

然後再jump切換到新context的時候,恢復了新context所需的寄存器,並把新的sp指針+112,把保留的棧空間給pop了。

也就是說,在第一次切換到實際func1函數入口時,這個時候的棧指針指向棧頂的,再往上,已經沒有多少空間了(也就只有為了16位元組對齊,有可能保留的少部分空間)。nn換一句話說,如果傳入的stack1的棧頂本身就是16位元組對齊的,那麼func1的入口處sp指向的就是stack1的棧頂nn如果在func1的入口處,有超過stack1棧頂範圍的寫操作,就有可能會擦掉contexts的數據,因為contexts緊靠著stack1的棧頂位置。n

那是否會出現這種情況,我們通過反彙編func1的入口處代碼,實際看下:

.text:00453F04 func1: n.text:00453F04n.text:00453F04 var_30 = -0x30n.text:00453F04 var_2C = -0x2Cn.text:00453F04 var_28 = -0x28n.text:00453F04 var_20 = -0x20n.text:00453F04 var_18 = -0x18n.text:00453F04 var_14 = -0x14n.text:00453F04 var_10 = -0x10n.text:00453F04 var_8 = -8n.text:00453F04 var_4 = -4n.text:00453F04 arg_0 = 0n.text:00453F04 arg_4 = 4n.text:00453F04n.text:00453F04 addiu $sp, -0x40n.text:00453F08 sw $ra, 0x40+var_4($sp)n.text:00453F0C sw $fp, 0x40+var_8($sp)n.text:00453F10 move $fp, $spn.text:00453F14 la $gp, unk_5706A0n.text:00453F1C sw $gp, 0x40+var_20($sp)n.text:00453F20 sw $a0, 0x40+arg_0($fp) <------------ 此處發生越界,改寫了contexts[0] = from.contextn.text:00453F24 sw $a1, 0x40+arg_4($fp) <------------ 此處發生越界,改寫了contexts[1] = from.datan.text:00453F28 lw $v0, 0x40+arg_4($fp)n.text:00453F2C sw $v0, 0x40+var_14($fp)n.text:00453F30 lw $v0, 0x40+var_14($fp)n.text:00453F34 sltu $v0, $zero, $v0n.text:00453F38 andi $v0, 0xFFn.text:00453F3C move $v1, $v0n

可以看到,確實發生了越界行為,那為什麼在函數內部,還會去寫當前棧幀外的數據呢,這個要從mips的調用棧布局上說起了。

簡單來說,mips在調用某個函數時,會把a0-a3作為參數寄存器,其他參數放置在堆棧中,但是與其他架構有點不同的是:

mips還會去為a0-a3這前四個參數,保留棧空間n

調用棧如下:

------------n| other args |n|------------|n| a0-a3 | <- 參數傳遞使用a0-a3,但是還是會為這四個參數保留棧空間出來n|------------|n| ra | <- 返回地址n|------------|n| fp gp s0-7 | <- 保存的一些其他寄存器n|------------|n| locals |n ------------n

而剛剛在func1內,就是回寫了a0-a3處保留的棧空間,導致了越界,因為boost的實現在jump後,棧空間已經到棧頂了,空間不夠了。。

因此,為了修復這個問題,只需要在make_fcontext裡面,多保留a0-a3這32位元組的空間就行了,也就是:

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

而在tbox內,除了對此處的額外的棧空間保留,來修復此問題,還對棧數據進行了更加合理的分配利用,不再需要保留146這麼多位元組數

只需要保留96位元組,就夠用了,節省了50個位元組,如果同時存在1024個協程的話,相當於節省了50K的內存數據。

並且boost的jump實現上,還有其他兩處問題,tbox裡面一併修復了:

jump_fcontext:n # reserve space on stackn addiu $sp, $sp, -112nn sw $s0, ($sp) # save S0n sw $s1, 4($sp) # save S1n sw $s2, 8($sp) # save S2n sw $s3, 12($sp) # save S3n sw $s4, 16($sp) # save S4n sw $s5, 20($sp) # save S5n sw $s6, 24($sp) # save S6n sw $s7, 28($sp) # save S7n sw $fp, 32($sp) # save FPn sw $a0, 36($sp) # save hidden, address of returned transfer_tn sw $ra, 40($sp) # save RAn sw $ra, 44($sp) # save RA as PCn <-------------------- 此處boost雖然為gp保留了48($sp)空間,但是確沒去保存gp寄存器nn # store SP (pointing to context-data) in A0n move $a0, $spnn # restore SP (pointing to context-data) from A1n move $sp, $a1nn lw $s0, ($sp) # restore S0n lw $s1, 4($sp) # restore S1n lw $s2, 8($sp) # restore S2n lw $s3, 12($sp) # restore S3n lw $s4, 16($sp) # restore S4n lw $s5, 20($sp) # restore S5n lw $s6, 24($sp) # restore S6n lw $s7, 28($sp) # restore S7n lw $fp, 32($sp) # restore FPn lw $t0, 36($sp) # restore hidden, address of returned transfer_tn lw $ra, 40($sp) # restore RAn <-------------------- 此處boost也沒去恢復gp寄存器nn # load PCn lw $t9, 44($sp)nn # adjust stackn addiu $sp, $sp, 112n n # return transfer_t from jumpn sw $a0, ($t0) # fctx of transfer_tn sw $a1, 4($t0) # data of transfer_t <------------- 此處應該使用 a2 而不是 a1 n # pass transfer_t as first arg in context functionn # A0 == fctx, A1 == datan move $a1, $a2 nn # jump to contextn jr $t9n.end jump_fcontextn

最後說一下,本文是針對boost 1.62.0 版本做的分析,如有不對之處,歡迎指正哈。。

個人主頁:TBOOX開源工程

原文出處:tboox.org/cn/2016/11/13


推薦閱讀:

Qt和wxWidgets哪個好?
Google Chrome 的開發團隊是怎麼開發不同平台版本的?
既然.NET平台如此優秀,為什麼微軟不推廣到Linux、Mac等平台上?

TAG:协程 | BoostC库 | 跨平台 |