摘要:一般情況下程序計數器中的值總是按照程序指令順序更新,只有在執行跳轉指令和函數調用指令時才會打破執行的順序。不同的體系都提供了特定的函數調用指令來實現函數調用的功能。位體系下的函數調用規則函數的調用函數調
古器合尺度,法物應矩規。--蘇洵
一、什么是函數
可執行程序是為了實現某個功能而由不同機器指令按特定規則進行組合排列的集合。無論高級還是低級程序語言,無論是面向對象還是面向過程的語言最終的代碼都會轉化為一條條機器指令的形式被執行。為了管理上的方便和對代碼的復用,往往需要將某一段實現特定功能的指令集合進行抽離和處理從而形成了函數的概念,函數也可以稱之為子程序或者子例程。出現函數的概念后可執行程序的機器指令集合將不再是單一的一塊代碼,而是由多個函數組成的分塊代碼,這樣可執行程序就變成了由函數之間相互調用這種方式來構建和組織了。
一個函數由函數簽名、參數、返回、實現四部分組成。函數的前三者定義了明確的邊界信息,也稱之為函數接口描述。函數接口描述的意義在于調用者不再需要了解被調用者函數的實現細節,而只需要按被調用者的定義的接口進行交互即可。如何去定義一個函數,如何去實現一個函數,如何去調用一個函數,如何將參數傳遞給被調用的函數,如何使用被調用者函數的返回這些都需要有統一的標準規范來進行界定,這個規則有兩個層面的標準:在高級語言層面的規則稱之為API規則;而在機器指令層面上則由于不同的操作系統以及不同的CPU體系結構下提供的指令集和構造程序的方式不同而不同,所以在系統層面的規則稱之為ABI規則。本文的重點是詳細介紹函數調用、函數參數傳遞、函數返回值這3個方面的ABI規則,通過對這些規則的詳細介紹相信您對什么是函數就會有更加深入的了解。需要注意的是這里的ABI規則是指基于OC語言實現的程序的ABI規則,這些規則并不適用于通過Swift實現的程序以及不適用于Linux等其他操作系統的ABI規則。
由于內容過多因此我將分為兩篇文章來做具體介紹,前一篇文章介紹函數接口相關的內容,后一篇文章介紹函數實現相關的內容。
二、函數調用
CPU中的程序計數器(IP/PC)中總是保存著下一條將要執行的指令的內存地址,這樣每執行一條指令就會更新程序計數器中的值,從而可以繼續執行下一條指令。系統就是這樣通過不停的變化程序計數器中的值來實現程序指令的執行的。一般情況下程序計數器中的值總是按照程序指令順序更新,只有在執行跳轉指令和函數調用指令時才會打破執行的順序。
函數調用的本質就是將函數在內存中的首地址賦值給程序計數器(IP/PC),這樣下一條執行的指令就變為了函數首地址處的指令,從而實現函數的調用。除了要更新程序計數器的值外還需要保存調用現場,以便當函數調用返回后繼續執行函數調用的下一條指令,所以這里所謂的保存調用現場就是將函數調用的下一條指令的地址保存起來。不同的CPU體系都提供了特定的函數調用指令來實現函數調用的功能。比如x86系統提供一條稱之為call的指令來實現函數調用,call指令除了會更新程序計數器的值外還會把函數調用的下一條指令壓入到棧中進行保存;arm系統則提供b系列的指令來實現函數調用,b系列指令除了會更新程序計數器的值外還會把函數調用的下一條指令保存到LR寄存器中。
函數返回的本質就是將前面說到的保存的調用現場地址賦值給程序計數器,這樣下一條執行的指令就變為了調用者調用被調函數的下一條指令了。不同的CPU體系也都提供了特定的函數返回指令來實現函數返回的功能(arm32位系統除外)。比如x86系統提供一條稱之為ret的指令來實現函數返回,此指令會將棧頂保存的地址賦值給程序計數器然后執行出棧操作;arm64位系統也提供一條ret指令來實現函數的返回,此指令則會把當前的LR寄存器的值賦值給程序計數器。
對于x86系統來說因為執行函數調用前會將調用者的下一條指令壓入棧中,而被調用者函數內部因為有本地棧幀(stack frame)的定義又會將棧頂下移,所以在被調用者函數執行ret指令返回之前需要確保當前堆棧寄存器SP所指向的棧頂地址要和被調用函數執行前的棧頂地址保持一致,不然當ret指令執行時取出的調用者的下一條指令的值將是錯誤的,從而會產生崩潰異常。
對于arm系統來說因為LR寄存器只有一個,因此如果被調用函數內部也調用其他函數時也會更新LR寄存器的值,一旦LR寄存器被更新后將無法恢復正確的調用現場,所以一般情況下被調用函數的前幾條指令做的事情就是將LR寄存器的值保存到棧內存中,而被調用函數的最后幾條指令所的事情就是將棧內存中保存的內容恢復到LR寄存器。
有一種特殊的函數調用場景就是當函數調用發生在調用者函數的最后一條指令時,則不需要進行調用現場的保護處理,同時也會將函數調用指令改為跳轉指令,原因是因為調用者的最后一條指令再無下一條有效的指令,而仍然采用調用指令的話則保存的調用現場則是個無效的地址,這樣當函數返回時將跳轉到這個無效的地址從而產生執行異常!
為了更好的描述函數的調用規則,假設A函數內部調用了B函數和C函數,下面定義了各函數的地址,以及函數調用處的地址,以及函數調用的偽代碼塊:
//這里的XX,YY,ZZ代表的是函數指令在內存中的地址。 A XX1: XX2: 調用B函數地址YY1 XX3: XX4: XXn: 跳轉到C函數ZZ1 B YY1: YY2: YY3: YYn: 返回 C ZZ1: ZZ2: ZZ3: ZZn: 返回
1. x86_64體系下的函數調用規則
1.1 函數的調用
函數調用的指令是call 指令。在匯編語言中call 指令后面的操作數是調用的目標函數的絕對地址,而實際的機器指令中的操作數則是一個相對地址值,這個地址值是目標函數地址距離當前指令地址的相對偏移值。無論是x86系統還是arm系統如果指令中的操作數部分的值是內存地址的話,一般都是相對當前指令的偏移地址而不是絕對地址。下面就是函數調用指令以及其內部實現的等價操作。
call YY1 <==> RIP = YY1, RSP = RSP-8, *RSP = XX3
也就是說執行一條函數調用指令等價于將指令中的地址賦值給IP寄存器,同時把函數的返回地址壓入棧寄存器中去。
1.2 函數的跳轉
函數跳轉的指令是jmp指令。在匯編語言中jmp 指令后面的操作數是調用的目標函數的絕對地址,而實際的機器指令中的操作數則是一個相對地址值,這個地址值是目標函數地址距離當前指令地址的相對偏移值,下面就是函數跳轉指令以及其內部實現的等價操作。
jmp ZZ1 <==> RIP = ZZ1
也就是說執行一條跳轉指令等價于將指令中的地址賦值給IP寄存器。
1.3 函數的返回
函數返回的指令是ret指令。ret指令后面一般不跟操作數,下面就是函數返回指令以及其內部實現的等價操作。
ret <==> RIP = *RSP, RSP = RSP + 8
也就是說執行一條ret指令等價于將當前棧寄存器中的值賦值給IP寄存器,同時棧寄存器執行POP操作。
2. arm32位體系下的函數調用規則
2.1 函數的調用
函數的調用指令為bl/blx。 這兩條指令的操作數可以是相對地址偏移也可以是寄存器。bl/blx的區別就是bl函數調用不會切換指令集,而blx調用則會從thumb指令集切換到arm指令集或者相反切換。arm32系統中存在著兩套指令集即thumb指令集和arm指令集,其中的arm指令集中的所有的指令的長度都是32位而thumb指令集則存在著32位和16位兩種長度的指令集。兩種指令集是以函數為單位進行使用的,也就是說一個函數中的所有指令要么都是arm指令要么就都是thumb指令。正是因為如此如果調用者函數和被調用者函數之間用的是不同的指令集則需要通過blx來執行函數調用,而如果二者所用的指令集相同則需要通過bl指令來執行調用。下面就是函數調用指令以及其內部實現的等價操作。
bl/blx YY1 <==> PC = YY1, LR = XX3
也就是說執行一條函數調用指令等價于將指令中的地址賦值給PC寄存器,同時把函數的返回地址賦值給LR寄存器中去。
2.2 函數的跳轉
函數的跳轉指令是b/bx, 這兩條指令的操作數可以是相對地址偏移也可以是寄存器,b/bx的區別就是b函數調用不會切換指令集。下面就是函數跳轉指令以及其內部實現的等價操作。
b/bx ZZ1 <==> PC = ZZ1
也就是說跳轉指令等價于將指令中的地址賦值給PC寄存器。
2.3 函數的返回
arm32位系統沒有專門的函數返回ret指令,因為arm32位系統可以直接修改PC寄存器的值,所以函數返回可以直接給PC指令賦值,也可以通過調用b/bx LR 來實現函數的返回處理。
b/bx LR //或者 mov PC, XXX
arm32位系統可以直接修改PC寄存器的值,因此函數返回時可以直接設置PC寄存器的值為函數的返回地址,也可以執行b/bx跳轉指令并指定目標地址為LR寄存器中的值。
3.arm64位體系下的函數調用規則
3.1 函數的調用
函數調用的指令是bl/blr 其中bl指令的操作數是距離當前位置相對距離的偏移地址,blr指令的操作數則是寄存器,表明調用寄存器所指定的地址。因為bl指令中的操作數部分是函數的相對偏移地址,又因為arm64位系統的一條指令占用4個字節,根據指令的定義bl指令所能跳轉的范圍是距離當前位置±32MB的范圍,所以如果要跳轉到更遠的地址則需要借助blr指令。 下面就是函數調用指令以及其內部實現的等價操作。
//如果YY1地址離調用指令的距離是在±32MB內則使用bl指令即可。 bl YY1 <==> PC = YY1, LR = XX3 //如果YY1地址離調用指令的距離超過±32MB則使用blr指令執行間接調用。 ldr x16, YY1 blr x16
也就是說執行一條函數調用指令等價于將指令中的地址賦值給PC寄存器,同時把函數的返回地址賦值給LR寄存器中去。
3.2函數的跳轉
函數跳轉的指令是b/br, 其中b指令的操作數是距離當前位置相對距離的偏移地址,br指令的操作數則是寄存器,表明跳轉到寄存器所指定的地址中去。下面就是函數跳轉指令以及其內部實現的等價操作。
b ZZ1 <==> PC = ZZ1
也就是說跳轉指令等價于將指令中的地址賦值給PC寄存器。
3.3 函數的返回
函數返回的指令是 ret, 下面就是函數返回指令以及其內部實現的等價操作。
ret <==> PC = LR
也就是說執行一條ret指令等價于將LR寄存器中的值賦值給PC寄存器。
三、函數參數傳遞
某些函數定義中有參數需要傳遞,需要由調用者函數將參數傳遞給被調用者函數,因此在調用這類函數時,需要在執行函數調用指令之前,進行函數參數的傳遞。函數的參數個數可以為0個,也可以為某個固定的數量,也可以為任意數量(可變參數)。 函數的每個參數類型可以是整型數據類型,也可以是浮點數據類型,也可以是指針,也可以是結構體。因此在函數傳遞的規則上需要明確指出調用者應該如何將參數進行保存處理,而被調用者又是從什么地方來獲取這些外部傳遞進來的參數值。不同體系下的系統會根據參數定義的個數和類型來制定不同的規則。一般情況下各系統都會約定一些特定的寄存器來進行參數傳遞交換,或者使用棧內存來進行參數傳遞交換。
1. x86_64體系下的參數傳遞規則
1.1 常規類型參數
這里面的常規類型參數是指除浮點和結構體類型以外的參數類型,下面就是常規參數傳遞的規則:
R1: 如果函數沒有參數則除了進行執行函數調用外不做任何處理,如果函數有參數則在執行函數調用指令之前需要按下面的規則設置參數值。
R2: 如果函數的參數個數<=6,則參數傳遞時將按照從左往右的定義的順序依次保存到RDI, RSI, RDX, RCX, R8, R9這6個寄存器中。
R3: 如果參數的個數>6, 那么超過6個的參數,將會按從右往左的順序依次壓入到棧中。(因為棧是從高地址往低地址遞減的,所以從棧頂往上來算的話后面的參數依然是從左到右的順序)
R4: 如果每個參數的類型的尺寸<8個字節的情況下,則前6個參數會分別保存在上述寄存器的對應的32位或者16位或者8位版本的寄存器中。
下面是幾個函數的定義以及在執行這個函數調用和參數傳遞的實現規則(下面代碼塊中上面部分描述的函數接口,下面部分是函數調用ABI規則):
//函數的簽名 void foo1(long, long); void foo2(long, long, long, long, long, long); void foo3(long, long, long, long, long, long, long, int, short); //高級語言的函數調用以及對應的機器指令偽代碼實現 foo1(a,b) <==> RDI = a, RSI = b, call foo1 foo2(a,b,c,d,e,f) <==> RDI = a, RSI = b, RDX = c, RCX = d, R8 = e, R9 = f, call foo2 foo3(a,b,c,d,e,f,g,h,i) <== > RDI = a, RSI = b, RDX = c, RCX = d, R8 = e, R9 = f, RSP -= 2, *RSP = i, RSP-=4, *RSP = h, RSP-=8, *RSP = g, call foo3
1.2 浮點類型參數
如果函數參數中有浮點數(無論是單精度還是雙精度)類型。則參數保存的地方則不是通用寄存器,而是特定的浮點數寄存器。下面就是傳遞的規則:
R5: 如果浮點數參數的個數<=8,那么參數傳遞將按從左往右的定義順序依次保存到 XMM0 - XMM7這8個寄存器中。
R6: 如果浮點數參數個數>8,那么超過數量部分的參數,將會按從右往左的順序依次壓入到棧中。
R7: 如果函數參數中既有浮點也有常規參數那么保存到寄存器中的順序和規則不會相互影響。
R8: 如果參數類型是擴展浮點類型(long double),擴展浮點類型的長度是16個字節, 那么所有的long double類型的參數都將直接壓入到棧(注意這個棧不是浮點寄存器棧)中而不存放到浮點寄存器中。
下面是幾個函數的例子:
//函數簽名
void foo4(double, double);
void foo5(double, float, double, double, double, double, double, double, float, double);
void foo6(long, double, long, double, long, long, double);
void foo7(double, long double, long);
//高級語言的函數調用以及對應的機器指令偽代碼實現
foo4(a,b) <==> XMM0 = a, XMM1 = b, call foo4
foo5(a,b,c,d,e,f,g,h,i,j) <==> XMM0 = a, XMM1 = b, XMM2 = c, XMM3 = d, XMM4 = e, XMM5 = f, XMM6 = g, XMM7 = h, RSP-=8, *RSP = j, RSP-=4 *RSP = i, call foo5
foo6(a,b,c,d,e,f,g) <==> RDI = a, XMM0 = b, RSI = c, XMM1 = d, RDX = e, RCX = f, XMM2 = g, call foo6
foo7(a,b,c) <==> XMM0=a, RSP-=16, *RSP = b的低8字節, *(RSP+8) = b的高8字節, RDI = c, call foo7
1.3 結構體參數
針對結構體類型的參數,需要考慮結構體中的成員的數據類型以及結構體的尺寸兩個因素。這里的結構體的尺寸分為:小于等于8字節、小于等于16字節、大于16字節三種。而結構體成員類型組成則分為:全部都是常規數據類型、全部都是浮點數據類型(不包括long double)、以及混合類型三種。這樣一共分為9種組合情況,下面表格描述結構體參數的的傳遞規則:
R9:
類型/尺寸 | <=8 | <=16 | >16 |
---|---|---|---|
全部都是常規數據類型 | 6個通用寄存器中的某一個 | 6個通用寄存器中的某連續兩個 | 壓入棧內存中 |
全部都是浮點數據類型 | 8個浮點寄存器中的某一個 | 8個浮點寄存器中的某連續兩個 | 壓入棧內存中 |
混合類型 | 優先考慮通用寄存器,再考慮浮點寄存器,以及成員排列的順序 | 參考左邊 | 壓入棧內存中 |
R10: 小于等于16個字節的結構體保存到寄存器中的規則并不是按每個數據成員來分別保存到寄存器,而是按結構體中的內存布局邊界順序以8字節為分割單位來保存到寄存器中的。
R11: 如果參數中混合有結構體、常規參數、浮點參數則按照前10個規則分別保存傳遞的參數
下面就是幾個結構體在當做參數時的示例代碼:
//長度<=8個字節的結構體
struct S1
{
char a;
char b;
int c;
};
//長度<=16的混合結構體
struct S2
{
float a;
float b;
double c;
};
//長度<=16的混合結構體
struct S3
{
int a;
int b;
double c;
};
//長度>16個字節的結構體
struct S4
{
long a;
long b;
double c;
}
//函數簽名
void foo8(struct S1);
void foo9(struct S2);
void foo10(struct S3);
void foo11(struct S4);
//高級語言的函數調用以及對應的機器指令偽代碼實現
struct S1 s1;
struct S2 s2;
struct S3 s3;
struct S4 s4;
foo8(s1) <==> RDI = s1.a | (s1.b <<8) | (s1.c << 32), call foo8
foo9(s2) <==> XMM0 = s2.a | (s2.b << 32), XMM1 = s2.c, call foo9
foo10(s3) <==> RDI = s3.a | (s3.b << 32), XMM0 = s3.c, call foo10
foo11(s4) <==> RSP -= 24, *RSP = s4.a, *(RSP+8) = s4.b, *(RSP+16)=s4.c, call foo11
針對結構體類型的參數建議是傳指針而不是傳結構體值本身。
1.4 可變參數
可變參數函數因為其參數的類型和參數的數量不固定,所以系統在編譯時會根據函數調用時傳遞的參數的值類型而進行不同的處理,因此規則如下:
R12: 函數調用時會根據傳遞的參數的數量和類型從左到右依次存放在對應的6個常規參數傳遞的寄存器或者XMM0-XMM7中,如果數量超過規定則剩余的參數依次壓入棧內存中。
R13:對于可變參數函數的調用會使用AL寄存器,其規則為:如果傳遞的可變參數中沒有浮點數類型則AL寄存器被設置為0,如果可變參數中出現了浮點數類型則AL寄存器會被設置為1。之所以用AL寄存器來標志的原因是可變參數內部實現因為不知道外部會傳遞什么類型的參數以及參數的個數,所以內部實現中會將所有作為參數傳遞的常規寄存器和作為參數傳遞的浮點數寄存器都會保存到一個數組中去,以方便進行處理。因此這里借助這個AL寄存器來判斷是否有浮點就可以在一定程度上減少將數組的長度。
下面是可變參數的調用示例:
//函數簽名 void foo12(int a, ...); //高級語言的函數調用以及對應的機器指令偽代碼實現 foo12(10,20,30.0, 40) <==> RDI = 10, RSI = 20, XMM0 = 30.0, RDX = 40,AL=1, call foo12 foo12(10,20,30,40) <==> RDI = 10, RSI = 20, RDX = 30, RCX = 40,AL=0, call foo7
一個有意思的例子: 當調用printf函數傳遞的參數如下:
printf("%f,%d,%d", 10, 20.0, 30.0); //輸出的結果將是: 20.0,10, );
原因就是參數傳遞的規則和格式字符串不匹配導致的,通過上面對可變參數的傳遞規則,你能解釋為什么嗎?
2. arm32位體系下的參數傳遞規則
整個arm32位體系下的參數傳遞和參數返回都不會用到浮點寄存器。對于大于4字節的基本類型則會拆分為兩部分依次保存到連續的兩個寄存器中。
2.1 常規參數
R1: 對于32位的常規參數,如果數量<=4則分別保存到 R0 - R3中, 如果數量>4則剩余的參數從右往左分別壓入棧內存中。
R2: 如果參數中有64位的參數比如long long 類型,則參數會占用2個寄存器,其中低32位部分保存在前一個寄存器,高32位部分保存在后一個寄存器。
R3: 如果前面3個參數是32位的參數,而第四個參數是64位的參數,那么前面三個參數分別放入R0,R1,R2中,而第四個參數的低32位部分則放入R3中,高32位部分則壓入到棧內存中。
2.2 浮點參數
R4: 浮點參數和常規參數一樣使用R0到R3寄存器,對于單精度浮點則使用一個寄存器,而雙精度浮點則使用兩個寄存器。超出部分則壓入棧內存中。
2.3 結構體參數
R5: arm32位系統的結構體不區分成員數據類型,只區分結構體尺寸,系統根據結構體的內存布局以4個字節為分割單位保存到寄存器或者棧內存中。
R6: 結構體尺寸<=4則會將參數保存到一個寄存器中,如果尺寸<=8則保存到連續的兩個寄存器中, 如果尺寸<=12則保存到3個連續的寄存器中, 如果尺寸<=16則保存到4個連續的寄存器中。如果尺寸>16則保存到棧內存中去。
R7: 如果前3個參數都是32位的參數,而第4個參數為尺寸>4的結構體,那么第4個參數的低4個字節的部分會保存到R3中,其他部分保存到棧內存中。
2.4 可變參數
R8: 可變參數傳遞根據參數的個數從左到右依次保存到R0-R3四個寄存器中,超過的部分從右往左依次保存到棧內存中。 下面的實例代碼:
//函數簽名 void foo1(int a, ...); //高級語言的函數調用以及對應的機器指令偽代碼實現。 foo1(10,20,30,40,50) <==> R0 = 10, R1 = 20, R2 = 30, R3 =40, SP -=4, *SP = 50, bl foo1
3.arm64位體系下的參數傳遞規則
3.1 常規參數
這里面的常規參數是指參數的類型是非浮點和非結構體類型的參數,下面就是常規參數傳遞的規則:
R1: 如果函數沒有參數則除了進行執行函數調用外不做任何處理,如果函數有參數則在執行函數調用指令之前需要按下面的規則設置參數值。
R2: 如果函數的參數個數<=8個, 參數傳遞將按照從左往右的定義的順序依次保存到X0 - X7 這8個寄存器中。
R3: 如果參數的個數>8個,那么超過數量部分的參數,將會按從右往左的順序依次壓入到棧中。
R4: 如果參數的類型是小于8個字節的情況下,則前8個參數會分別保存在對應的32位或者16位或者8位寄存器中。
下面是幾個函數的例子:
//函數簽名 void foo1(long, long); void foo2(long, long, long, long, long, long, long, long); void foo3(long, long, long, long, long, long, long, long, long, int, short); //高級語言的函數調用以及對應的機器指令偽代碼實現。 foo1(a,b) <==> X0 = a, X1 = b, bl foo1 foo2(a,b,c,d,e,f,g,h) <==>X0 = a, X1 = b, X2 = c, X3 = d, X4 = e, X5 = f, X6=g, X7 =h, bl foo2 foo3(a,b,c,d,e,f,g,h,i,j,k) <==>X0 = a, X1 = b, X2 = c, X3 = d, X4 = e, X5 = f, X6=g, X7=h, *SP -=2, *SP=k, SP-=4, *SP = j, SP-= 8, *SP = i, bl foo3
3.2 浮點參數
如果函數參數中有浮點數(無論是單精度還是雙精度)。則參數保存的地方則不是通用寄存器,而是特定的浮點數寄存器。系統提供32個128位的浮點寄存器Q0-Q31(V0-V31),其中的低64位則被稱為D0-D31,其中的低32位則被稱為S0-S31,其中的低16位則被稱為H0-H31,其中的低8位則被稱之為B0-B31。 也就是說單精度浮點保存到S開頭的寄存器, 雙精度浮點保存到D開頭的寄存器。 arm系統中 long double 的長度都是8字節,因此可被當做雙精度浮點。
下面就是傳遞的規則:
R5: 如果浮點數參數的個數<=8個,那么參數傳遞將按從左往右的順序依次保存到 D0-D7或者S0-S7 這8個寄存器中。
R6: 如果浮點數參數個數>8個時,那么超過數量部分的參數,將會按從右往左的順序依次壓入到棧中。
R7: 如果函數參數中既有浮點也有常規參數那么保存到寄存器中的順序和規則不會相互影響。
下面是幾個函數的例子:
//函數簽名
void foo4(double, double);
void foo5(double, float, float, double, double, double, double, double, double, double);
void foo6(long, double, long, double, long, long, double);
//高級語言的函數調用以及對應的機器指令偽代碼實現。
foo4(double a, double b) <==> D0 = a, D1 = b, bl foo4
foo5(double a, float b, float c, double d, double e, double f, double g, double h, double i, double j) <==> D0 = a, S1 = b, S2 = c, D3 = d, D4 = e, D5 = f, D6 = g, D7 = h, *SP -=8, *SP = j, *SP -=8, *SP = i, bl foo5
foo6(long a, double b, long c, double d, long e, long f, double g) <==> X0 = a, D0 = b, X1 = c, D1 = d, X2 = e, X3 = f, D2 = g, bl foo6
3.3 結構體參數
針對結構體類型的參數,需要考慮結構體的尺寸以及數據類型和數量。這里的結構體的尺寸分別是考慮小于等于8字節,小于等于16字節,大于16字節。而結構體成員類型則分為:全部都是非浮點數據成員、全部都是浮點數成員(這里會區分單精度和雙精度)、以及混合類型的成員(如果結構體中有單精度和雙精度都算混合)。下面是針對結構體參數的規則:
R8: 如果數據成員全部都是非浮點數據成員則 如果尺寸<=8則會將值保存到X0-X8中的某一個寄存器中, 如果尺寸<=16則會將值保存到X0-X8中的某兩個連續的寄存器中,如果尺寸>16則結構體將不再按值傳遞而是以指針的形式進行傳遞并保存到X0-X8中的某一個寄存器中。
R9: 如果數據成員全部都是單精度浮點成員則如果成員數量<=4則會將數據成員保存到S0-S7中的某4個連續的浮點寄存器中,如果數量>4則結構體將不再按值傳遞而是以指針的形式進行傳遞并保存到X0-X8中的某一個寄存器中。
R10: 如果數據成員全部都是雙精度浮點成員則如果成員數量<=4則會將數據成員保存到D0-D7中的某4個連續的浮點寄存器中,如果數量>4則結構體將不再按值傳遞而是以指針的形式進行傳遞并保存到X0-X8中的某一個寄存器中。
R11: 如果數據成員是混合類型的則如果尺寸<=8則保存到X0-X8中的某一個寄存器中,如果尺寸<=16則保存到X0-X8中的某兩個連續的寄存器中, 如果尺寸>16則結構體將不再按值傳遞而是以指針的形式進行傳遞并保存到X0-X8中的某一個寄存器中。
R12: 因為結構體參數的寄存器規則會影響到上述非結構體參數的傳遞規則,因此一定程度上可以將結構體當做多個參數傳遞來看待。
下面是演示的代碼:
//長度<=8個字節的結構體
struct S1
{
char a;
char b;
int c;
};
//長度<=16的單精度浮點結構體
struct S2
{
float a;
float b;
float c;
};
//長度<=16的混合結構體
struct S3
{
int a;
int b;
double c;
};
//長度>16個字節的結構體
struct S4
{
long a;
long b;
double c;
}
//函數簽名
void foo8(struct S1);
void foo9(struct S2);
void foo10(struct S3);
void foo11(struct S4);
//高級語言的函數調用以及對應的機器指令偽代碼實現
struct S1 s1;
struct S2 s2;
struct S3 s3;
struct S4 s4;
foo8(s1) <==> X0= s1.a | (s1.b <<8) | (s1.c << 32), bl foo8
foo9(s2) <==> S0 = s2.a, S1 = s2.b, S3 = s2.c bl foo9
foo10(s3) <==> X0 = s3.a | (s3.b << 32), X1 = s3.c, bl foo10
foo11(s4) <==> X0 = &s4, bl foo11
3.4 可變參數
可變參數函數因為其參數的類型和參數的數量不固定,所以系統在編譯時會根據函數調用時傳遞的參數的值類型而進行不同的處理,因此規則如下:
R13: 函數調用時會根據傳遞的參數的數量和類型來決定,其中明確類型的部分按照上面介紹的規則進行傳遞,而可變部分則從右往左依次壓入到堆棧中。
下面是示例代碼:
//函數簽名 void foo7(int a, ...); //高級語言的函數調用以及對應的機器指令偽代碼實現 foo7(10, 20, 30.0, 40) <==> X0 = 10, SP-=8, *SP = 40, SP-=8, *SP = 30.0, SP-=8, *SP = 20, bl foo7
一個有意思的例子: 當執行printf函數而傳遞參數如下:
printf("%f,%d,%d", 10, 20.0, 30.0); //那么輸出的結果將是: );
因為arm系統對可變參數的傳遞和x86系統對可變參數的處理不一致,就會出現真機和模擬器的結果不一致的問題。甚至在參數傳遞規則上arm32位和arm64位系統都有差異。上面的參數傳遞和描述不匹配的情況下你可以說出為什么輸出的結果不確定嗎?
四、函數返回值
函數調用除了有參數傳遞外,還有參數返回。參數的傳遞是調用者向被調函數方向的傳遞,而函數的返回則是被調用函數向調用函數方向的傳遞,因此調用者和被調用者之間應該形成統一的規則。被調用函數內對返回值的處理應該在被調用函數返回指令執行前。而調用函數則應該在函數調用指令的下一條指令中盡可能早的對返回的結果進行處理。函數的返回類型有無、非浮點數、浮點數、結構體四種類型,因此針對不同的返回類型系統有不同的處理規則。
1. x86_64體系下的函數返回值規則
1.1 常規類型返回
R1: 如果函數有返回值則總是將返回值保存到RAX寄存器中。
1.2 浮點類型返回
R2: 返回的浮點數類型保存到XMM0寄存器中。
R3: 返回的(擴展雙精度)long double 類型則保存到浮點寄存器棧頂中。FPU計算單元中提供了8個獨立的128位的寄存器STMM0-STMM7,這8個寄存器以堆棧形式組織在一起,統稱為浮點寄存器棧。系統同時也提供了專門的指令來對浮點寄存器棧進行入棧和出棧處理, 編寫浮點指令時這些寄存器也寫作st(x),這里的x是浮點寄存器的索引。需要明確的是XMM系列的寄存器和STMM系列的寄存器是完全不同的兩套寄存器。
1.3 結構體類型返回
針對結構體類型的返回,需要考慮結構體的尺寸以及成員的數據類型。這里的結構體的尺寸分為:小于等于8字節,小于等于16字節,大于16字節。而結構體成員類型則分為:全部都是非浮點數據成員、全部都是浮點數據成員(不包括 long double)、以及混合類型的成員。這樣一共分為9種情況,下面表格描述針對結構體返回的規則:
R4
類型/尺寸 | <=8 | <=16 | >16 |
---|---|---|---|
全部非浮點數據成員 | RAX | RAX,RDX | 返回的結構體將保存到RDI寄存器所指向的內存地址中。也就是RDI寄存器是一個結構體地址指針,這樣函數參數中的第一個參數將由保存到RDI,變為保存到RSI寄存器了。 |
全部為浮點數據成員 | XMM0 | XMM0,XMM1 | 同上 |
混合類型 | 優先存放到RAX,或者XMM0,然后再存放到RDX或者XMM1中。一個特殊情況就是如果成員中有long double類型,則總是按>16字節的規則來處理返回值 | 同左 | 同上 |
下面是一個展示的代碼:
//長度<=8個字節的結構體 struct S1 { char a; char b; int c; }; //長度<=16的混合結構體 struct S2 { int a; int b; double c; }; //長度>16個字節的結構體 struct S3 { long a; long b; double c; } //函數簽名 struct S1 foo1(); struct S2 foo2(); struct S3 foo3(int ); //高級語言的函數調用以及對應的機器指令偽代碼實現 struct S1 s1 = foo1() <==> 函數調用時:call foo1, 函數返回時 s1 = RAX struct S2 s2 = foo2() <==> 函數調用時:call foo2, 函數返回時s2.a&s2.b = RAX, s2.c = XMM0 struct S3 s3 = foo3(a) <==> 函數調用時: RDI = &s3, RSI = a, call foo3
2. arm32位體系下的函數返回值規則
2.1 常規類型返回
R1: 函數的返回值的尺寸<=4字節則保存到R0寄存器,如果返回值的尺寸<=8字節(比如 long long類型)則保存到R0,R1寄存器其中低32位保存到R0,高32位保存到R1
2.2 浮點類型返回
R2: 單精度浮點數保存到R0寄存器,雙精度浮點數保存在R0,R1中其中R0保存低32位,R1保存高32位。 long double 類型的返回同雙精度浮點返回一致。
2.3 結構體類型返回
R3: 不管任何類型的結構體,總是將結構體返回到R0寄存器所指向的內存中, 因此R0寄存器中保存的是一個指針,這樣函數的第一個參數將保存到R1寄存器并依次往后推,也就是說如果函數返回的是一個結構體那么系統就會將返回的值當做第一個參數,而將真實的第一個參數當做第二個參數。
下面的代碼說明了這種情況:
struct XXX { //任意內容 }; //函數返回結構體 struct XXX foo(int a) { //... } 實際在編譯時會轉化為函數 void foo(struct XXX *pret, int a) { }
也就是在arm32位的系統中凡是有結構體作為返回的函數,其實都會將結構體指針作為函數調用的第一個參數保存到R0中,而將源代碼中的第一個參數保存到R1中。
3.arm64位體系下的函數返回值規則
2.1 常規類型返回
R1: 函數的返回參數保存到X0寄存器上
2.2 浮點類型返回
R2: 單精度浮點返回保存到S0,雙精度浮點返回保存到D0
2.3 結構體類型返回
針對結構體類型的參數,需要考慮結構體中的成員的數據類型以及整體結構體的尺寸。這里的結構體的尺寸分別是考慮小于等于8字節,小于等于16字節,大于16字節。而結構體成員類型則分為:全部都是非浮點數據成員、全部都是浮點數成員(這里會區分單精度和雙精度)、以及混合類型的成員(如果結構體中有單精度和雙精度都算混合)。這樣一共分為9種情,下面就是針對結構體類型返回的規則:
R3:針對非浮點數據成員的結構體來說如果結構體的尺寸<=8,那么結構體的值會保存到X0, 如果尺寸<=16,那么保存到X0,X1中,如果尺寸>16則結構體返回會保存到X8寄存器所指向的內存中,也就是X8寄存器比較特殊,專門用來保存返回的結構體的指針。
R4: 如果結構體的成員都是單精度并且數量<=4 則返回結構體的每個成員分別保存到S0,S1,S2, S3四個寄存中,如果結構體成員數量超過4個則結構體返回會保存到X8寄存器所指向的內存中。
R5: 如果結構體的成員都是雙精度并且數量<=4 則返回結構體的每個成員分別保存到D0,D1,D2,D3四個寄存器中,如果結構體成員數量超過4個則結構體返回會保存到X8寄存器所指向的內存中。
R6: 如果結構體是混合型數據成員,并且結構體的尺寸<=8字節,那么結構體的值保存到X0, 如果尺寸<=16字節則保存到X0,X1中,如果尺寸>16則結構體返回會保存到X8寄存器所指向的內存中。
下面演示幾個結構體定義以及返回結構體的函數:
//長度為16字節的結構體
struct S1
{
char a;
char b;
double c;
};
//長度超過16字節的混合成員結構體
struct S2
{
int a;
int b;
int c;
double d;
};
//長度小于等于8字節的結構體
struct S3
{
int a;
int b;
};
CGRect foo1()
{
//高級語言實現的返回
return CGRectMake(10,20,30,40);
//機器指令的函數返回的偽代碼如下:
/*
D0 = 10
D1 = 20
D2 = 30
D3 = 40
ret
*/
}
struct S1 foo2()
{
//高級語言實現的返回
return (struct S1){10, 20, 30};
//機器指令的函數返回的偽代碼如下:
/*
X0 = 10 | 20 << 8
X1 = 30
ret
*/
}
struct S2 foo3()
{
//高級語言實現的返回
return (struct S2){10, 20, 30, 40};
//機器指令的函數返回的偽代碼如下:
/*
struct S2 *p = X8 //X8中保存返回的結構體內存地址
p->a = 10
p->b = 20
p->c = 30
p->d = 40
ret
*/
}
struct S3 foo4()
{
//高級語言實現的返回
return (struct S3){20, 30};
//機器指令的函數返回的偽代碼如下:
/*
X0 = 20 | 30 << 32
ret
*/
}
從上面的代碼可以看出來在x86_64/arm32兩種體系結構下如果返回的類型是結構體并且滿足特定要求時,系統會將結構體指針當做函數的第一個參數,而將源代碼中的第一個參數傳遞的寄存器往后移動,而在arm64位系統中則x8寄存器專門負責處理返回值為特殊結構體的情況。
六、談談objc_msgSend系列函數
所有的OC方法最終都會通過objc_msgSend系列函數進行調用。這個函數系列有如下函數:
objc_msgSend(void /* id self, SEL op, ... */ ) objc_msgSend_stret(void /* id self, SEL op, ... */ ) objc_msgSend_fpret(void /* id self, SEL op, ... */ ) objc_msgSend_fp2ret(void /* id self, SEL op, ... */ )
這一系列的函數的差別主要是針對返回類型的不同而使用不同的消息發送函數。
從上述的函數返回值規則可以看對于long double 類型的函數返回在x86_64位系統的處理方式比較特殊,其返回的值將保存在特定的浮點堆棧寄存器中,所以objc_msgSend_fpret函數只用在x86_64位系統中返回類型為long double的OC方法的消息分發中,其他體系結構都不會用到這個函數。同樣因為C99中引入了復數類型 _Complex 關鍵字,所以針對這種類型的 long double 返回會使用objc_msgSend_fp2ret函數。
從上述的函數的返回值規則還可以看出對于結構體返回,如果結構體尺寸大于一定的閾值后,x86_64位系統和arm32位系統都會將返回的結構體轉化為第一個參數來進行傳遞,這樣就會使得真實的參數傳遞的寄存器往后順延,而arm64則直接只用x8寄存器來保存大于閾值的結構體指針且并不會影響到參數的傳遞順序。因此除了arm64位系統外其他體系結構系統中針對那些返回結構體大于一定閾值的OC方法將使用objc_msgSend_stret函數進行消息分發。
上述的函數返回規則對
針對函數的調用、參數傳遞、函數的返回值的介紹規則就是這些了,當然這些規則除了對普通函數適用外對OC類方法也是同樣適用的。至于一個函數內部應該怎樣實現,其實也是有一定的規則的。通過這些規則你可以了解到函數是如何跟棧內存結合在一起的,以及函數調用棧是如何被構造出來的,你還可以了解為什么一些函數調用不會出現在調用棧中等等相關的知識,以及可變參數函數內部是如何實現的等等這部分的詳細介紹將會在: 深入iOS系統底層之函數(二):實現 進行深入的探討。
七、參考
blog.csdn.net/q_l_s/artic…
developer.apple.com/library/arc…
armv8,armv7, x86_64位系統CPU手冊
blog.sina.com.cn/s/blog_8619…
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/7070.html
摘要:序一直想寫一些關于系統底層方面的知識點,并且醞釀了很久,后來也跟其他人交流,你為何不出一個系列呢不必要一次性把所有的東西都寫完后才發表,我聽說后覺得非常的有道理,雖然自己的水平也很一般,但是想想自己還是有一些積累的。序 一直想寫一些關于系統底層方面的知識點,并且醞釀了很久,后來也跟其他人交流,你為何不出一個系列呢? 不必要一次性把所有的東西都寫完后才發表,我聽說后覺得非常的有道理,雖然自己的...
閱讀 730·2023-04-25 19:43
閱讀 3974·2021-11-30 14:52
閱讀 3801·2021-11-30 14:52
閱讀 3865·2021-11-29 11:00
閱讀 3796·2021-11-29 11:00
閱讀 3894·2021-11-29 11:00
閱讀 3571·2021-11-29 11:00
閱讀 6154·2021-11-29 11:00