標籤:

x86上為什麼C語言調用一個函數要先把參數壓棧,之後才是返回地址?

先返回地址後參數,那麼實現tailcall就很容易了。尾遞歸和循環就可以生成相同的代碼了。這個說法有問題嗎?

----------------

補充

先可以無視寄存器里的參數。反正多出來的肯定是要壓棧的

參數先壓棧的問題是,一個函數的參數個數和tailcall函數的參數個數不一致,就很難處理了。

地址先壓棧,tailcall只需要改寫argv這一段,改完了直接jump過去就可以了。即便參數個數不一致也無所謂。

在jump時,棧長這樣

----------
saved_rip
saved_rbp &<- rbp argv[...] ... argv[...] &<- rsp local[...] ... local[...]

此時local部分還沒有意義


第一從 call 的角度看

這樣就的代碼很自然:

push arg3
push arg2
push arg1

# 下面兩個指令相當於 call callee
push ip+n # n 固定為最後兩個指令的長度
jmp callee # 注: 為了 leaf 函數的性能, bp 由 callee 靈活處理

如果按照你的提案, 或者先 push return address:

push ip+n+x # x 為下面 3 個 push 指令的長度總和
push arg3
push arg2
push arg1
jmp callee

x86 坑爹的地方是, push 竟然是不定長的... 而且 push 某arg 的效果可能是經過了複雜的計算甚至 call 別的函數得出來的, 這樣計算就略麻煩, 而且用不了 call 指令了

或者後設置 return address:

push 0 # 留個坑
push arg3
push arg2
push arg1

# 設計一個新的 x86 指令 call 3, callee 代替下面兩個?
sp[3] = ip+n # 把 return address 填到坑裡
jmp callee

指令變得更多了, 依然是不可能用上 call 指令.

------

第二從 return 的角度看

C call 和 stdcall 的區別是: stdcall 參數個數恆定, C call 參數個數可變. 至於接收了多少個參數, 完全由程序自己的邏輯來決定, 所以編譯器在編譯 callee 的時候不知道棧上會推進去多少個參數.

C call 的 stack frame (向下(低)增長) 像這樣

------- 這部分由 caller 設置
argv[argc]
...
argv[1]
argv[0]
saved_ip # 返回地址
------- 以下屬於 callee 添加的
saved_bp # caller 的棧幀地址 &<-- 保存完以後 bp 指向這裡 locals[0] locals[1] ...

注意 bp 剛好在局部變數的上面和固定參數的下面, 所以代碼中局部變數和固定參數的訪問都可以用 bp[i] 這種方式進行 (但不定參數的訪問還是完全取決於程序邏輯)

在 callee 中, 返回代碼要做的事情, 就是先恢復 bp 和 sp, 然後恢復 ip, 偽代碼像這樣:

sp = bp
bp = pop # 恢復棧幀
ip = pop # 返回 (事實上 ret 指令一併操作了 ip 和 sp 並保證了線程安全)

如果把返回地址 (saved_ip) 擺在 argv 之上, 那 saved_bp 擺哪裡? 如果緊接在 saved_ip 下面, 都放到 argv 之上, 那麼由於 argv 個數不定, 局部變數就不能和固定的 bp[i] 對應了, 否決.

所以棧幀會是這樣:

------- 這部分由 caller 設置
saved_ip # 返回地址
argv[argc]
...
argv[1]
argv[0]
argc
------- 以下屬於 callee 添加的
saved_bp # caller 的棧幀地址 &<-- 保存完以後 bp 指向這裡 locals[0] locals[1] ...

得多壓棧了一個元素 argc 才能在返回時定位出 saved_ip, 而且 return 代碼要改成像這樣:

sp = bp
bp = pop
ip = sp[top + 1] # 不能用上線程安全的 ret 指令了

想想初衷不過就是為了在 tail call 的時候不改寫 return address 么... 橫豎都搞得很麻煩...

------

再從優化的角度看

x86-64 System V ABI 中, C 函數的前 6 個參數都可以放在寄存器中. 所以大部分函數 call 是不需要壓參數的 (不過 call 之前還是要保存現場把 caller 的寄存器壓到棧中, 可以看成延遲了壓棧), 那就和先壓返回地址效果一樣了. 大部分情況下都是"實現 tailcall 就很容易了".


把壓棧和跳轉分開,讓編譯器甚至程序員計算返回地址,即使可以,性能安全性也是很大的問題。除了把返回地址放在函數棧幀的頂部,也可以給函數的參數區域大小預留本函數或者裡面可能的tail call中最大值,就不用擔心參數放不下了。

tail call對x86和命令式語言幫助不大吧,反正有循環,所以也沒見到與發雜的tail call優化。


高冷的回答,x86架構的指令決定的


C語言支持可變參數函數,因此被調用函數不知道傳遞的參數個數有多少,參數傳遞的時候是從右向左依次壓棧,最後call指令會把返回地址壓棧並跳轉到被調用的函數。

如果先壓返回地址入棧,那被調用函數就需要知道參數個數有多少,這樣才能找到返回地址。可變參數函數的特性決定了C語言的被調用的函數不知道參數有多少,也決定了參數需要從右向左依次入棧,返回地址最後入棧,並且函數調用方需要負責清理傳遞參數的棧。

---------------------

只回答了問題頭,關於尾遞歸請參考其它答案,題主補充的方法也是一個不錯的方案。


但因為C語言在x86中調用函數是使用call指令,彙編中call指令相當於:

push eip
jmp funcAddr

很明顯只能在調用函數前將參數壓棧,或者使用寄存器傳值(fastcall),否則call funcAddr就直接進入了函數體.

如果要達到題主的想法那就不能用call指令,而用jmp.但這樣函數執行完沒辦法返回被調用者繼續執行,因為無法直接獲得eip的值並push,除非用一些hack,而且還要加上偏移,編譯器不可能這麼做.

但是嘛一些編譯器會做尾遞歸優化,將尾遞歸轉換為和循環相似的結構.

比如我寫了個階乘的例子:

int _fac(int n,int ret)
{
if (n == 0)
return ret;
return _fac(n - 1, ret*n);
}

int fac(int n)
{
return _fac(n, 1);
}

生成的彙編為:

LOOP1:
test ecx,ecx //ecx循環變數
je LOOP1: //n為0時循環結束
imul edx,ecx
dec ecx
jne LOOP2:
LOOP2:
mov eax,edx //edx保存結果
ret

調用時並沒有將參數壓棧而是使用ecx和edx傳遞,初始分別為n-1和n.優化過後的代碼顯然沒有出現遞歸而是和循環相似.

調用fac(n)的彙編如下:

mov edx,dword ptr [n] //edx=n
test edx,edx
jne Addr1 //n非零進入函數
lea eax,[edx+1]
jmp Addr2 //n為0,置返回值eax為1,跳過函數
Addr1:
lea ecx,[edx-1] //ecx=n-1
call fac
Addr2:

話說要實現尾遞歸優化也不需要先將返回地址壓棧,完全可以修改參數[ebp+0x04],[ebp+0x08]…然後jmp到函數頭部.其實無論是先後壓棧返回地址都不影響尾遞歸優化(參數數目不變),但後壓棧返回地址方便得多.

最後補個hack獲取eip的辦法:

call GetEIP
GetEIP:
mov eax,[esp]
add eax, 0x0A //hack number
mov [esp], eax
ret

返回值eax為eip.如果要先壓棧返回地址的話,接下來還要算出push參數和jmp的代碼長加到eax上然後push,很明顯編譯器絕對不會這麼做.


看函數聲明的調度方式啊,fastcall 前三個就不壓棧,直接用寄存器放參數。


對的,看調用方式


推薦閱讀:

C/C++中如何在main()函數之前執行一條語句?
哪些類型的應用必須用c語言寫?
關於菲波那契數列的一個低級問題?
C語言可否自定義數值類型(或是任意個位元組的數值類型)?
利用異或方式交換兩個變數值的原理是什麼?

TAG:C編程語言 |