libco源碼解析(4) 協程切換,coctx_make與coctx_swap

libco源碼解析(1) 協程運行與基本結構
libco源碼解析(2) 創建協程,co_create
libco源碼解析(3) 協程執行,co_resume
libco源碼解析(4) 協程切換,coctx_make與coctx_swap
libco源碼解析(5) poll
libco源碼解析(6) co_eventloop
libco源碼解析(7) read,write與條件變量
libco源碼解析(8) hook機制探究
libco源碼解析(9) closure實現

引言

題目說的很清楚,這篇文章旨在把協程最爲神祕的部分,也即是協程的切換講的清楚明白,這部分也是令很多人望而生畏的地方,因爲在切換協程時用到了一部分彙編代碼。所以想要真正理解這部分,還是得先花一點時間把丟掉的彙編先拿回來。

基礎知識

首先我們來看下棧幀的定義:

In C and modern CPU design conventions, the stack frame is a chunk of memory, allocated from the stack, at run-time, each time a function is called, to store its automatic variables. Hence nested or recursive calls to the same function, each successively obtain their own separate frames.
Physically, a function’s stack frame is the area between the addresses contained in esp, the stack pointer, and ebp, the frame pointer (base pointer in Intel terminology). Thus, if a function pushes more values onto the stack, it is effectively growing its frame.
.

在C語言和現代CPU的設計規範中,棧幀是一塊由棧分配的內存塊,在運行時,每當調用一次函數時,都要存儲其自動變量。因此對於同一函數的遞歸調用在每一次都會連續的獲得自己獨立的棧幀。
從物理上將,函數的棧幀是指esp和ebp之間的一塊地址。因此如果一個函數把更多的值壓入堆棧,實際上是在擴展它本身的棧幀。

這裏算是講的的非常清楚了,棧幀就是esp和ebp之間的一塊內存。

我們來看一下一個棧幀的實際佈局;
在這裏插入圖片描述

在這幅圖中我們應該關注的重點就是紅框中EBP上面的值,即EIP和採用__cdecl調用約定的參數。這裏出現了一個新的名詞__cdecl,這其實是函數調用的一種調用約定,下面羅列出來:

  1. __stdcall :函數採用從右到左的壓棧方式,自己在退出時清空堆棧。
  2. __cdecl:即C調用約定(The C default calling convention),按從右至左的順序壓參數入棧,由調用者把參數彈出棧。對於傳送參數的內存棧是由調用者來維護的(正因爲如此,實現可變參數vararg的函數(如printf)只能使用該調用約定)。
  3. __fastcall: __fastcall調用的主要特點就是快,因爲它是通過寄存器來傳送參數的(實際上,它用ECX和EDX傳送前兩個雙字(DWORD)或更小的參數,剩下的參數仍舊自右向左壓棧傳送,被調用的函數在返回前清理傳送參數的內存棧)。

我們回到上面那幅圖,採用__cdecl調用約定的調用者會將參數從右到左的入棧,最後將返回地址入棧。這個返回地址是指,函數調用結束後的下一行執行的代碼地址。獲取參數和返回地址的話我們只需要通過EBP加偏移就可以了。當然圖上的偏移量是32爲系統的。

正文

上面簡單的過了一下基礎知識,接下來我們通過對libco中coctx_makecoctx_swap的解析,搞清楚協程切換的本質,因爲學彙編的時候學習的都是32位的,我們以32位爲例子進行講解。64位只是多了一些寄存器和一些調用規則的上的不同罷了,基本的邏輯都是一樣的,所以我們選擇32位系統進行分析。

我們先來看看與協程切換相關的數據結構:

// 用於分配coctx_swap兩個參數內存區域的結構體,僅32位下使用,64位下兩個參數直接由寄存器傳遞
struct coctx_param_t
{
	const void *s1;
	const void *s2;
};
struct coctx_t
{
#if defined(__i386__)	
	// 上下文
	void *regs[ 8 ];
#else
	void *regs[ 14 ]; 
#endif 
	size_t ss_size;// 棧的大小
	char *ss_sp; // 棧頂指針esp
	 
};  

coctx_t結構可以說是libco中最爲重要的結構了,它直接存儲了協程的上下文。

coctx_make

調用coctx_swap之前的準備工作由coctx_make設置完成,我們來看看其實現:

int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1) {
  // make room for coctx_param
  // 此時sp其實就是esp指向的地方 其中ss_size感覺像是這個棧上目前剩餘的空間,

  char* sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);
  	//------- ss_sp + ss_size
	//|     |
	//|     |
	//------- ss_sp
  //ctx->ss_sp 對應的空間是在堆上分配的,地址是從低到高的增長,而堆棧是往低地址方向增長的,
  //所以要使用這一塊人爲改變的棧幀區域,首先地址要調到最高位,即ss_sp + ss_size的位置

  sp = (char*)((unsigned long)sp & -16L);// 字節對齊,16L是一個magic number,下文會做解釋

	// param用來給我們預留下來的參數區設置值
  coctx_param_t* param = (coctx_param_t*)sp;
  void** ret_addr = (void**)(sp - sizeof(void*) * 2); // 函數返回值
  // (sp - sizeof(void*) * 2) 這個指針存放着指向ret_addr的指針
  *ret_addr = (void*)pfn; // 新協程要執行的指令函數,也即執行完這個函數要cotx_swap要返回的值
  param->s1 = s; //即將切換到的協程 
  param->s2 = s1; // 切換出的線程
  	//------- ss_sp + ss_size
	//|pading| 這裏是對齊區域
	//|s2    |
	//|s1    |
	//|原esp |
	//| 返回地址  |
	//|esp實際空間|
	//-------  <- sp(原esp - sizeof(void*) * 2)
	//|      |
	//------- ss_sp
	// 對照着上面那個棧幀的圖去看
 
  memset(ctx->regs, 0, sizeof(ctx->regs));

  // ESP指針sp向下偏移2,因爲除了ebp還有一個返回地址  
  // 進入函數以後就會push ebp了
  ctx->regs[kESP] = (char*)(sp) - sizeof(void*) * 2; 
  //sp初始指向第一個參數的起始地址
  //函數調用,壓入參數之後,還有一個返回地址要壓入,所以還需要將sp往下移動8個字節,
  //32位彙編獲取參數是通過EBP+8, EBP+12來分別獲取第一個參數,第二個參數的,
  //這裏減去4個字節是爲了對齊這種約定,這裏可以看到對齊以及參數還有4個字節的虛擬返回地址已經
  //佔用了一定的棧空間,所以實際上供協程使用的棧空間是小於分配的空間。另外協程且走調用co_swap參數入棧也會佔用空間,
  // KESP(7)在swap中是賦給esp的
  return 0;
}

其實就是一個函數調用過程的模擬,功能就是給coctx_swap做一些準備工作,關鍵是要理解那個(sp - sizeof(void*) * 2),在理解的時候搭配着那張棧幀的圖可以更有效率。

16L的哲學

然後我們來說一說那個16L的魔法數字到底有什麼用,我們在代碼中提到了這個magic number其實是爲了字節對齊。16這個數字非常奇怪,一般來說我們的認知都是32位下字節對齊應該是4,64位系統下當然就是8了,這個16是什麼情況?答案就是GCC默認的堆對齊設置的就是16字節。具體可查看這篇文章:《Why does System V / AMD64 ABI mandate a 16 byte stack alignment?

coctx_swap

接下來我們來看看coctx_swap執行協程切換的過程:

    movl 4(%esp), %eax 
    這裏ESP獲取到的是對應圖中old %EIP的地址,加4對應第一個參數的地址,把這個值賦給eax,當然也隱藏着eax[0]的賦值
    
    | *ss_sp  |
	| ss_size |
	| regs[7] |
	| regs[6] |
	| regs[5] |
	| regs[4] |
	| regs[3] |
	| regs[2] |
	| regs[1] |
	| regs[0] |
	--------------   <---EAX

    movl %esp,  28(%eax)  
    movl %ebp, 24(%eax)
    movl %esi, 20(%eax)
    movl %edi, 16(%eax)
    movl %edx, 12(%eax)
    movl %ecx, 8(%eax)
    movl %ebx, 4(%eax)
	// 想想看,這裏eax加偏移不就是對應了regs中的值嗎?這樣就把所有寄存器中的值保存在了參數中
 
	
	// ESP偏移八位就是第二個參數的偏移了,這樣我們就可以把第二個參數regs中的上下文切換到寄存器中了
    movl 8(%esp), %eax 
    movl 4(%eax), %ebx
    movl 8(%eax), %ecx
    movl 12(%eax), %edx  
    movl 16(%eax), %edi
    movl 20(%eax), %esi
    movl 24(%eax), %ebp
    movl 28(%eax), %esp

	ret
	// 這樣我們就完成了一次協程的切換

這裏面對於協程切換來說最重要的就是regs[0]和regs[7]了,regs[0] 存放下一個指令執行地址,也即返回地址。regs[7] 存放切換到新協程後,ESP指針調整的新地址,也就是棧上的偏移。這樣程序的數據和代碼都被改變,當然也就做到了一個線程可以跑多份代碼了。

參考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章