棧切換

yiifburj發表於2021-01-31

實現協程最核心的部分就是棧切換了,其他的和非阻塞io的程式設計方式沒什麼區別。

棧切換,libc中有一個實現,swapcontext,但是已經被標準移除了,未來是否可用不得而知,自己實現需要寫彙編程式碼,這是一個很困難的任務,因為既要熟悉不同cpu指令集又要熟悉不同平臺的標準,好在從boost library的協程實現中找到了已經寫好了的棧切換匯編程式碼,利用這些彙編程式碼可以在c語言中實現棧切換。

這段程式碼是在s_task協程庫中發現的,s_task很好,還可以和libuv結合,如果沒有特殊要求,可以直接使用了,但是如果想根據自己工作中的業務邏輯做定製,還是需要掌握原理,並且不清楚原理,可能不能用最恰當的方式使用,實現最好的設計,出了問題也不知道該怎麼查和用什麼辦法查。

附上s_task 和 boost library的地址,感興趣的可以去研究一下。

https://github.com/xhawk18/s_task

https://github.com/boostorg/context

棧切換程式碼在原始碼的asm目錄中,實際上在c語言中對應兩個函式,

typedef void* fcontext_t;
typedef struct {
    fcontext_t  fctx;
    void* data;
} transfer_t;

extern transfer_t jump_fcontext( fcontext_t const to, void * vp);
extern fcontext_t make_fcontext( void * sp, size_t size, void (* fn)( transfer_t) );

這兩個函式是什麼意思,怎麼用,看了s_task中的程式碼,但是開始的時候還是沒看懂,於是想從彙編的角度入手,最終通過x86_64的彙編程式碼(make_x86_64_sysv_elf_gas.S jump_x86_64_sysv_elf_gas.S)弄清楚了這兩個函式的用法,結論在本文最末尾,如果只想看結果,可以跳到最後面。

直接看程式碼註釋吧。

make_x86_64_sysv_elf_gas.S

  1 /*
  2             Copyright Oliver Kowalke 2009.
  3    Distributed under the Boost Software License, Version 1.0.
  4       (See accompanying file LICENSE_1_0.txt or copy at
  5             http://www.boost.org/LICENSE_1_0.txt)
  6 */
  7 
  8 // 棧空間圖,棧頂在低地址,注意和jump_x86_64_sysv_elf_gas.S對比著看,裡面代表的是記憶體(棧,執行現場)中的資料
  9 // 不代表暫存器,途中標的暫存器的意思是這些暫存器在儲存現場的時候會儲存到對應的記憶體地址中
 10 /****************************************************************************************
 11  *                                                                                      *
 12  *  ----------------------------------------------------------------------------------  *
 13  *  |    0    |    1    |    2    |    3    |    4     |    5    |    6    |    7    |  *
 14  *  ----------------------------------------------------------------------------------  *
 15  *  |   0x0   |   0x4   |   0x8   |   0xc   |   0x10   |   0x14  |   0x18  |   0x1c  |  *
 16  *  ----------------------------------------------------------------------------------  *
 17  *  | fc_mxcsr|fc_x87_cw|        R12        |         R13        |        R14        |  *
 18  *  ----------------------------------------------------------------------------------  *
 19  *  ----------------------------------------------------------------------------------  *
 20  *  |    8    |    9    |   10    |   11    |    12    |    13   |    14   |    15   |  *
 21  *  ----------------------------------------------------------------------------------  *
 22  *  |   0x20  |   0x24  |   0x28  |  0x2c   |   0x30   |   0x34  |   0x38  |   0x3c  |  *
 23  *  ----------------------------------------------------------------------------------  *
 24  *  |        R15        |        RBX        |         RBP        |        RIP        |  *
 25  *  ----------------------------------------------------------------------------------  *
 26  *                                                                                      *
 27  ****************************************************************************************/
 28 
 29 .file "make_x86_64_sysv_elf_gas.S"
 30 .text
 31 .globl make_fcontext
 32 .type make_fcontext,@function
 33 .align 16
 34 make_fcontext:
 35 // make_fcontext的第一個引數儲存到rax中,rax現在開始代表了這個執行環境的棧頂,
 36 // rax也是本函式的返回值,
 37     /* first arg of make_fcontext() == top of context-stack */
 38     movq  %rdi, %rax
 39 
 40 // 16對齊, 規定的
 41     /* shift address in RAX to lower 16 byte boundary */
 42     andq  $-16, %rax
 43 
 44 // -0x40就是將棧頂指標(棧頂暫存器,這裡不是rsp,是rax,rsp是當前執行環境使用的)移動到圖中的0位置,即分配棧空間
 45 // 遞減棧是向下分配的
 46     /* reserve space for context-data on context-stack */
 47     /* on context-function entry: (RSP -0x8) % 16 == 0 */
 48     leaq  -0x40(%rax), %rax
 49 
 50 // size那個引數這裡沒有使用,第三個引數是fn,要執行的函式
 51 // 這裡把fn的地址放到了0x28的位置,恢復到暫存器就是rbx
 52     /* third arg of make_fcontext() == address of context-function */
 53     /* stored in RBX */
 54     movq  %rdx, 0x28(%rax)
 55 
 56 // 這兩個暫存器不清楚
 57     /* save MMX control- and status-word */
 58     stmxcsr  (%rax)
 59     /* save x87 control-word */
 60     fnstcw   0x4(%rax)
 61 
 62 // 從英文註釋看,實現的是將trampoline的地址儲存在0x38的位置,即rip儲存的地方,返回之後接著執行的地址
 63 // 所以這個地方實際上是設定了跳轉過來之後執行的指令的位置,jump_fcontext認為自己跳到了
 64 // 一個暫停過的地方,即儲存過現場的地方,恢復現場繼續執行,而這裡是首次執行,所以要模擬這種場景
 65 // 也就是說啟動一個任務之後會先執行trampoline那裡。
 66     /* compute abs address of label trampoline */
 67     leaq  trampoline(%rip), %rcx
 68     /* save address of trampoline as return-address for context-function */
 69     /* will be entered after calling jump_fcontext() first time */
 70     movq  %rcx, 0x38(%rax)
 71 
 72 // 這裡是把finish處的地址放到0x30中, 是一個技巧,見trampoline處
 73     /* compute abs address of label finish */
 74     leaq  finish(%rip), %rcx
 75     /* save address of finish as return-address for context-function */
 76     /* will be entered after context-function returns */
 77     movq  %rcx, 0x30(%rax)
 78 
 79 // make_fcontext函式返回
 80     ret /* return pointer to context-data */
 81 
 82 trampoline:
 83     /* store return address on stack */
 84     /* fix stack alignment */
 85     // 從jump_x86_64_sysv_elf_gas.S中可以看出,跳到這裡之前已經從棧空間中恢復了rbp,也就是說
 86     // 現在rbp儲存的是finish的地址,因為rbp是從0x30恢復的,0x30前面已經儲存了finish的地址
 87     // 現在的棧頂rsp呢,是在圖中0x40的位置,見jump_x86_64_sysv_elf_gas.S中的  leaq  0x40(%rsp), %rsp
 88     // 現在push rbp是把fish的地址push到了0x38的位置,也就是本應該儲存返回地址rip的地方,
 89     // 也就是說fn執行結束返回到make_fcontext的finish處繼續執行,就好像是make_fcontext呼叫
 90     // 的fn一樣,當然只是好像而已。可見fn返回了整個程式就退出了,這是make_fcontext指定的。
 91     push %rbp
 92 
 93     // rbx是設定的fn的地址,跳到fn執行,首次執行fn任務才會走到這裡,中間暫停了
 94     // 是恢復從暫停的地方的下一個指令開始執行。
 95     /* jump to context-function */
 96     jmp *%rbx
 97 
 98 finish:
 99 // 這裡的程式碼就是退出程式了
100     /* exit code is zero */
101     xorq  %rdi, %rdi
102     /* exit application */
103     call  _exit@PLT
104     hlt
105 .size make_fcontext,.-make_fcontext
106 
107 /* Mark that we don't need executable stack. */
108 .section .note.GNU-stack,"",%progbits

 

 

jump_x86_64_sysv_elf_gas.S

  1 /*
  2             Copyright Oliver Kowalke 2009.
  3    Distributed under the Boost Software License, Version 1.0.
  4       (See accompanying file LICENSE_1_0.txt or copy at
  5             http://www.boost.org/LICENSE_1_0.txt)
  6 */
  7 
  8 // 棧空間圖,棧頂在低地址,注意和make_x86_64_sysv_elf_gas.S對比著看,裡面代表的是記憶體(棧,執行現場)中的資料
  9 // 不代表暫存器,途中標的暫存器的意思是這些暫存器在儲存現場的時候會儲存到對應的記憶體地址中
 10 /****************************************************************************************
 11  *                                                                                      *
 12  *  ----------------------------------------------------------------------------------  *
 13  *  |    0    |    1    |    2    |    3    |    4     |    5    |    6    |    7    |  *
 14  *  ----------------------------------------------------------------------------------  *
 15  *  |   0x0   |   0x4   |   0x8   |   0xc   |   0x10   |   0x14  |   0x18  |   0x1c  |  *
 16  *  ----------------------------------------------------------------------------------  *
 17  *  | fc_mxcsr|fc_x87_cw|        R12        |         R13        |        R14        |  *
 18  *  ----------------------------------------------------------------------------------  *
 19  *  ----------------------------------------------------------------------------------  *
 20  *  |    8    |    9    |   10    |   11    |    12    |    13   |    14   |    15   |  *
 21  *  ----------------------------------------------------------------------------------  *
 22  *  |   0x20  |   0x24  |   0x28  |  0x2c   |   0x30   |   0x34  |   0x38  |   0x3c  |  *
 23  *  ----------------------------------------------------------------------------------  *
 24  *  |        R15        |        RBX        |         RBP        |        RIP        |  *
 25  *  ----------------------------------------------------------------------------------  *
 26  *                                                                                      *
 27  ****************************************************************************************/
 28 
 29 .file "jump_x86_64_sysv_elf_gas.S"
 30 .text
 31 .globl jump_fcontext
 32 .type jump_fcontext,@function
 33 .align 16
 34 jump_fcontext:
 35 // 儲存當前的執行現場,caller-saved registers呼叫者儲存,這裡只儲存callee-saved registers
 36     leaq  -0x38(%rsp), %rsp /* prepare stack */
 37 
 38 #if !defined(BOOST_USE_TSX)
 39     stmxcsr  (%rsp)     /* save MMX control- and status-word */
 40     fnstcw   0x4(%rsp)  /* save x87 control-word */
 41 #endif
 42 
 43     movq  %r12, 0x8(%rsp)  /* save R12 */
 44     movq  %r13, 0x10(%rsp)  /* save R13 */
 45     movq  %r14, 0x18(%rsp)  /* save R14 */
 46     movq  %r15, 0x20(%rsp)  /* save R15 */
 47     movq  %rbx, 0x28(%rsp)  /* save RBX */
 48     movq  %rbp, 0x30(%rsp)  /* save RBP */
 49     // 這裡為什麼沒有使用0x38位置的8個位元組,因為這裡面存的是返回地址,即本函式返回的時候
 50     // 要繼續執行的指令的地址,在呼叫jump_fcontext的時候已經儲存了,所以這裡不用儲存
 51     // 看make_fcontext可以看到,那裡使用了0x38,因為那個fcontext不是通過呼叫jump_fcontext
 52     // 生成的,而是人為製造的。
 53     //
 54 // 儲存現場完畢
 55 
 56 // 函式的呼叫者會在被呼叫函式退出的時候讀取rax作為返回值
 57 // transfer_t需要兩個暫存器儲存,另一個是rdx
 58 // 但注意這裡的返回值不是本次jump_fcontext呼叫的返回值,
 59 // 而是另一個棧空間的jump_fcontext,本次呼叫是不返回的,
 60 // 實際上所有的jump_fcontext都是不返回的,都是別的地方
 61 // 跳過去,看起來好像是它返回
 62 //
 63 // 函式呼叫就好比是一維世界,從哪裡進,再從哪裡出,而jump_fcontext好比是二維的世界,
 64 // 從“天上”跑了,再從“天上”掉下來。
 65     /* store RSP (pointing to context-data) in RAX */
 66     movq  %rsp, %rax
 67 
 68 // 把jump_fcontext第一個引數給rsp,第一個引數即執行上下文,別的地方
 69 // 儲存現場或make_fcontext生成的棧頂地址,這裡放到
 70 // rsp中,即完成了棧空間的切換,下條命令開始就是在另一個
 71 // 棧空間執行了,已經跳走完成, 跳之前做了什麼呢,儲存現場
 72 // 並且把現場放到rax中,跳過去之後的返回值會返回跳之前的現場
 73     /* restore RSP (pointing to context-data) from RDI */
 74     movq  %rdi, %rsp
 75 
 76 // 這裡開始是一個新的棧空間了,也就是一個新的任務
 77 // 分兩種情況,
 78 // 1. 如果是恢復一個任務的執行,那麼rsp是它呼叫jump_fcontext切換走
 79 //    的時候的現場,現在開始恢復,這時0x38位置是返回地址,也就是這個
 80 //    任務在上一次呼叫jump_fcontext(暫停,切換到其他任務)的時候儲存
 81 //    的呼叫jump_fcontext後面的那個指令對應的地址,即從暫停的位置
 82 //    繼續執行。
 83 // 2. 如果是新制造出來的任務,0x38儲存的是make_fcontext中的trampoline
 84 //    也就是會跳到那裡執行。
 85 //
 86 //  目前只是放到了r8中,還沒有開始執行,後面的過程是繼續恢復現場。
 87     movq  0x38(%rsp), %r8  /* restore return-address */
 88 
 89 #if !defined(BOOST_USE_TSX)
 90     ldmxcsr  (%rsp)     /* restore MMX control- and status-word */
 91     fldcw    0x4(%rsp)  /* restore x87 control-word */
 92 #endif
 93 
 94 // 儲存和恢復的過程是對稱的,正是棧的特點
 95     movq  0x8(%rsp), %r12  /* restore R12 */
 96     movq  0x10(%rsp), %r13  /* restore R13 */
 97     movq  0x18(%rsp), %r14  /* restore R14 */
 98     movq  0x20(%rsp), %r15  /* restore R15 */
 99     movq  0x28(%rsp), %rbx  /* restore RBX */
100     movq  0x30(%rsp), %rbp  /* restore RBP */
101 
102 // 恢復另一個任務儲存現場之前的rsp(棧頂)
103     leaq  0x40(%rsp), %rsp /* prepare stack */
104 
105 // 這裡面有條件編譯,似乎是和32位和64位相關,這裡按照上面的分支來分析
106     /* return transfer_t from jump */
107 #if !defined(_ILP32)
108     /* RAX == fctx, RDX == data */
109     // rsi是jump_fcontext的第二個引數vp,這裡賦值給rdx,rax rdx
110     // 裡面儲存的是返回值transfer_t的內容,rax是前面我們儲存現場
111     // 獲得的,也就是說是切換之前的現場,rdx呢,也是切換之前傳的,
112     // 也就是說切換之後返回的內容都是切換之前的資訊。
113     // 一般網上查到的都是說rax是返回值,但是這裡返回結構體,裡面
114     // 是兩個8位元組,到底是怎麼傳遞的很少有地方說,我也是網上查到的
115     // 這種情況是用兩個暫存器儲存的,rdx也會用來儲存返回值,有一個文件可以參考
116     // System V Application Binary Interface AMD64 Architecture Processor Supplement
117     movq  %rsi, %rdx
118 #else
119     /* RAX == data:fctx */
120     salq  $32, %rsi
121     orq   %rsi, %rax
122 #endif
123     /* pass transfer_t as first arg in context function */
124 #if !defined(_ILP32)
125     /* RDI == fctx, RSI == data */
126 #else
127     /* RDI == data:fctx */
128 #endif
129 
130     // rdi是函式呼叫的第一個引數,rsi是第二個引數
131     // 這裡把rax放到rdi中,是為了把切換之前儲存的現場作為第一個引數傳給要執行的函式
132     // 這是給第一次執行準備的,fn的引數transfer_t的兩個成員,一個是rdi,一個是rsi
133     // rsi就是切換之前呼叫的(即本次呼叫)那個jump_fcontext傳遞的第二個引數vp
134     //
135     // 如果不是第一次呼叫呢,會不會有影響,不會,rdi是caller-saved register,呼叫者
136     // 負責儲存,如果即將切換到的那個任務的對應的函式需要rdi的值,它會自己儲存的,
137     // 從jump_fcontext出來之後它會自己恢復,對它來講就是呼叫了jump_fcontext這個函式,
138     // 然後過了一段時間返回了,中間的過程完全不知情。 天上走了,又從天上回來。
139     movq  %rax, %rdi
140 
141 //  前面提到了將返回地址放到r8中,現在跳過去開始執行了
142     /* indirect jump to context */
143     jmp  *%r8
144 .size jump_fcontext,.-jump_fcontext
145 
146 /* Mark that we don't need executable stack.  */
147 .section .note.GNU-stack,"",%progbits

 

簡單測試一下

 

 1 #include <stdio.h>
 2 
 3 char stack0[10240];//一個printf會佔用1千多位元組的棧空間!!!!!
 4 char stack1[10240];
 5 
 6 typedef void* fcontext_t;
 7 typedef struct {
 8     fcontext_t  fctx;
 9     void* data;
10 } transfer_t;
11 
12 extern transfer_t jump_fcontext(fcontext_t const to, void * vp);
13 extern fcontext_t make_fcontext(void * sp, size_t size, void (* fn)( transfer_t));
14 
15 fcontext_t fcmain, fc0, fc1;
16 
17 void fn0(transfer_t t){
18     printf("%s %d\n", __func__, __LINE__);
19 
20     fcontext_t *p = t.data;
21     *p = t.fctx;
22 
23     // 切換fn1
24     transfer_t ret = jump_fcontext(fc1, &fc0);
25     p = ret.data;
26     *p = ret.fctx;
27     printf("%s %d\n", __func__, __LINE__);
28     // 切換main
29     jump_fcontext(fcmain, &fc0);
30     printf("never back\n");
31 }
32 
33 void fn1(transfer_t t){
34     printf("%p\n", t.fctx);
35     printf("%s %d\n", __func__, __LINE__);
36     fcontext_t *p = t.data;
37     *p = t.fctx;
38     // 切換fn0
39     jump_fcontext(fc0, &fc1);
40     printf("never back\n");
41 }
42 
43 int main(int argc, char **argv) {
44 
45     fc0 = make_fcontext(stack0 + sizeof stack0, sizeof stack0, fn0);
46     fc1 = make_fcontext(stack1 + sizeof stack1, sizeof stack1, fn1);
47 
48     printf("%s %d\n", __func__, __LINE__);
49     // 切換fn0
50     jump_fcontext(fc0, &fcmain);
51     printf("%s %d\n", __func__, __LINE__);
52 
53     return 0;
54 }

 

 % gcc -O t.c asm/make_gas.S asm/jump_gas.S
 % ./a.out
main 48
fn0 18
0x5591d8183800
fn1 35
fn0 27
main 51

 

結論:

make_fcontext中的sp是棧頂指標,size是棧空間的大小,雖然cpu可能支援兩種方向,但目前我還沒有聽過棧方向遞增的系統,如果是遞減棧,應該把棧頂指標設定成最高地址,比如申請了一段記憶體作為函式棧,大小1024,地址在void *p中,那麼應該這樣用

make_fcontext(p+1024, 1024, fn);

fn是協程任務啟動的時候執行的函式,和執行緒建立的時候指定的函式類似。make_fcontext的返回值就是一個執行(棧空間)上下文,它將被用於jump_context中的to引數,這樣可以啟動這個任務。啟動任務之後呼叫fn,fn是jump_context中呼叫的,函式引數傳遞的實際上是兩部分,第一部分,fcontext_t型別,是棧切換之前的執行上下文,第二個引數是

jump_fcontext中傳遞的vp引數。jump_fcontext的返回值也分為兩部分,第一部分是棧切換之前的執行上下文,第二部分是傳遞的引數vp(這個vp不是本任務jump_fcontext呼叫的vp,而是其他任務切換到本任務的時候呼叫jump_fcontext傳遞的vp),這是因為 jump_fcontext不是普通的函式,普通的函式在一個棧空間上執行,而jump_fcontext是跨越棧空間的,函式呼叫前,要儲存現場,然後再恢復,jump_fcontext也要儲存現場,但是恢復並不是在這個函式呼叫中恢復的,這個呼叫已經一去不復返了,只負責跳走,不負責回來,甚至可能永遠也回不來了,與其說它回來了,不如說是別的地方跳過來了,jump_fcontext "返回" 的就是跳過來的那個人的資訊,它的執行上下文(儲存著它的執行現場,cpu暫存器,棧空間),還有它呼叫jump_fcontext跳過來時傳遞的引數vp。

一個任務只要執行,它的執行上下文就是在變化的,也就是說只要一個任務一執行,那麼儲存它的執行上下文的變數(fcontext_t)就失效了,也就是每執行一次就要更新一次,什麼時候更新呢,就是本次執行暫停的時候更新,也就是呼叫jump_fcontext跳到其他地方的時候,也就是說目標任務要更新本任務的執行上下文變數,目標任務啟用的時候表現為從它的jump_fcontext返回(實際上不是它返回,而是本務跳到那去執行),返回值中的fcontext_t就是跳之前的執行上下文,也就是本任務的最新的執行上下文,為了更新本任務的執行上下文變數,需要把本任務的執行上下文變數的地址傳遞給目標任務,這需要藉助jump_fcontext的vp引數,它也會出現在目標任務返回的transfer_t中,這就是引數vp和返回值transfer_t的作用吧,當然藉助vp還可以傳遞更多需要互動的資訊。

每一個普通任務都是通過make_fcontext創造出來的,但主任務(main函式)不是,主任務切換到了其他任務執行,如果想切換回主任務,就必須獲取主任務的執行上下文,fn函式本來只需要自己執行的資料就夠了,而它的引數transfer_t還帶一個fcontext,就是幹這件事用的,它是最新的切換之前的執行上下文,誰啟動的它,就更新誰,一個任務切換到一個曾經執行過的任務的時候一定是跳到jump_fcontext的下條指令,可以通過"返回"的transfer_t來實現更新,但是對於新任務,不會從jump_fcontext繼續執行,不會得到"返回"的transfer_t,就需要利用引數傳遞的transfer_t更新。

主任務啟動 -> task0 -> jump_fcontext , task0恢復執行,這時候返回的transfer_t不一定是主任務的執行上下文,因為它是切換task0任務,使task0再次執行的那個任務的執行上下文,那個任務未必是主任務,主任務只是第一次啟動task0的時候的任務,並不一定是後續啟用task0的任務。

如果fn函式執行完成,沒有通過jump_fcontext切換任務,直接返回,那麼結果是整個程式都退出了,因為它返回之後是返回到了make_fcontext的一段程式碼中,那段程式碼的操作是退出程式。從gdb裡用bt可以看到,一個任務的呼叫者是make_fcontext,而不是啟動它或再次啟用它的時候對應的函式,這是因為make_fcontext裡面設定這個函式的時候同時設定好了它的返回地址,所以如果不想退出程式,一個任務完成的時候需要跳到其他任務,然後釋放儲存本任務的棧空間資源,需要注意的是釋放是要在別的地方釋放(s_task庫中有一個join任務的地方),而不能在跳轉之前釋放,因為跳轉的時候要儲存當前執行上下文到當前的棧空間,如果釋放了再跳轉會造成非法地址的錯誤。