棧幀內返回地址是在local variables前還是在它們後面?

不是應該本地變數pop掉了才有的return address嗎?

是虎書太老了還是我的理解有問題?


是因為題主沒有往後讀,或者是沒留意到書中後面的講解,而是假設了x86的情況。

虎書的信息密度非常高,每句話背後可能都可以展開出很多故事。初學的時候快速讀過去很容易就漏掉了許多信息。這本書雖然薄,但是得不斷反覆閱讀和實踐才能真正吸收其中的知識。

純粹好奇問一下:請問題主讀的是虎書的哪個版本?ML / C / Java的哪一個,是哪一年印刷的版本?

(本回答下面的講解暫時忽略static link因為跟題主的問題沒直接關係)

用2002年的Java版為例:

第118頁的第6.1小節開始講Stack Frames,緊接著在下一頁(第119頁)的Figure 6.2就是題主引用的圖,然後同一小節在第123頁講解了Return Addresses。

引用書中的第123頁Return Addresses的部分文字:

On 1970s machines, the return address was pushed on the stack by the call instruction. Modern science has shown that it is faster and more flexible to pass the return address in a register, avoiding memory traffic and also avoiding the need to build any particular stack discipline into the hardware.

On modern machines, the call instruction merely puts the return address (the address of the instruction after the call) in a designated register. A non-leaf procedure will then have to write it to the stack (unless interprocedural register allocation is used), but a leaf procedure will not.

這段描述就足以讓讀者推測作者為何把一個概念中的stack frame layout畫成圖示那樣:因為作者偏向於「現代」指令集,而他所想的「現代」明顯是指RISC系的——x86被無情的鄙視為了「1970年代」的一類。誠然,8086是1978年正式推出的…

指令集會有啥關係呢?

棧幀布局(stack frame layout)的設計跟調用約定(calling convention)是密切相關的。在一個函數被調用的過程中,調用方和被調用方的典型指令序列的偽代碼是:

// caller side
pass arguments
call &
... // returns here

// callee side
frame setup // save old frame pointer, allocate stack space, etc.
... function code ...
frame teardown // deallocate stack space, restore old frame pointer, etc.
return

問題就在這個「call」指令到底幹了什麼。

==================================================

題主想像的情況

按照虎書歸類為「1970年代」的那類指令集,call指令會把返回地址壓到棧上,並將控制轉移到目標函數的入口地址。假定有參數通過棧來傳遞,那麼在調用方執行call指令之前,棧上的內容會是(假定棧的增長方向向「下」):

...
sp -&> [ arguments ]

在執行完call指令之後,棧上內容會是:

...
[ arguments ]
sp -&> [ return address ]

然後在被調用函數入口處執行完創建棧幀的代碼後,棧上內容會變成:

...
[ arguments ]
[ return address ]
fp -&> [ old fp ]
[ local vars ]
sp -&> ...

這裡假定創建棧幀的動作會

  • 保存frame pointer:把當前frame pointer寄存器壓到棧上,然後把當前stack pointer寄存器的值賦予frame pointer寄存器;
  • 然後分配棧空間:挪動stack pointer

然後就可以真正開始執行被調用函數的內容了。

在x86上,這就是(Intel語法):

push ebp
mov ebp, esp
sub esp, #frameSize

對應的,到被調用函數要返回的時候,假定銷毀棧幀的動作有:

  • 撤銷棧空間的分配:挪動stack pointer。這樣當前棧幀的空間就算被釋放了。題主說的所謂「本地變數pop掉」就是這個動作。挪動之後stack pointer寄存器所指地址就是存著old frame pointer的值的地方
  • 回復frame pointer:從棧頂彈出old frame pointer的值到frame pointer寄存器

然後執行return指令。跟call指令對應,return指令會自動從棧頂彈出一個值作為返回地址,然後把控制轉移到該地址。

在x86上,這就是:

mov esp, ebp
pop ebp
ret

有些指令集會有個帶參數版本的return,可以指定在彈出返回地址後要順帶再彈出多少個值來撤銷利用棧傳遞參數所分配的空間。這主要用於實現「被調用方清理棧」(callee clean-up)的調用約定,例如Windows/x86上所使用的stdcall。

如果被調用函數不清理傳遞參數所使用的棧空間,那麼這件事就得由調用函數負責。這就是所謂「調用方清理棧」(caller clean-up),例如Windows/x86上所使用的cdecl,最主要的設計目的是支持C式的帶有可變長度參數(varargs)的函數的調用。

在這種大背景下,棧的布局自然會讓返回地址夾在參數和局部變數之間——call指令在時間上夾在傳遞參數與創建新棧幀之間,而它又會把返回地址壓到棧上,結果返回地址夾在參數與局部變數之間再自然不過。

然而虎書的圖並不是描述這種情況的。

==================================================

虎書的圖所描述的情況

虎書假定的是RISC風格的指令集。在這種指令集中,「call」指令並不把返回地址壓到棧上,而是把它存入一個特定寄存器里;對應的,「return」指令並不從棧頂彈出返回地址,而是從特定寄存器獲取返回地址。

正如虎書所說,這樣設計是因為它有更高的自由度:被調用方可以決定它到底需不需要把返回地址存到棧上:

  • 如果是個末端函數(leaf function,也就是不調用別的函數的函數),則沒必要把返回地址壓到棧上——反正在它返回前也不會有新的返回地址需要維護;
  • 而如果是個非末端函數(non-leaf function,也就是可能調用別的函數的函數),則還是可以把返回地址放到棧上,而且還可以自己選擇把返回地址放到棧幀的什麼位置。更有趣的是如果指令集有寄存器窗口(register window),則調用方可以直接把返回地址放在一個out register里,被調用方會從in register收到這個值,都不需要顯式去做保存動作。

看幾個實際例子:

  • MIPS:典型的「call」指令是jal(Jump-and-Link),它會把返回地址存到$ra寄存器,然後讓控制轉移到目標地址;
  • ARM:典型的「call」指令是bl(Branch-with-Link),它會把返回地址存到$r14寄存器("lr",link register),然後讓控制轉移到目標地址;
  • PowerPC:典型的「call」指令是bl(Branch-and-Link),它會把返回地址存到lr寄存器(link register),然後讓控制轉移到目標地址;
  • SPARC:典型的「call」指令就叫call,它會把當前call指令的地址存到$o7(out register 7),然後讓控制轉移到目標地址。注意SPARC是有寄存器窗口的指令集。

在這種大背景下,典型的調用約定和棧幀布局就變得跟題主所想像的不一樣了。

在被調用方創建棧幀的指令序列很可能是這樣的:

// frame setup:
save old frame pointer
allocate stack space
spill return address from register to stack

這樣的話調用序列的設計就有很大的自由度選擇要把返回地址放到棧幀的什麼地方。

虎書的Figure 2.6展示的正是其中一種可能性:被調用方可以把分配出來的棧空間的開頭部分用作保存局部變數,然後再把返回地址放到棧上。

當然調用約定/棧幀布局也可以設計為被調用方在入口處先把返回地址壓到棧上,然後再分配棧空間。

給題主看一個MIPS的調用約定的例子:https://courses.cs.washington.edu/courses/cse410/09sp/examples/MIPSCallingConventionsSummary.pdf

其中第8-9頁的non-leaf function的例子就跟虎書的圖一模一樣。

就是這樣嗯。



是你想錯了。

比如一個數組 stack 為 empty 的情況下的 top 指針可以是 0 也可以是 -1,你再想想。


x86體系,參數先壓入棧後執行call指令,call指令會把返回地址壓入棧。進入被調用函數後局部變數在棧上,出被調用函數前局部變數出棧,ret和call對應返回被調用函數。棧指令(寄存器)是一種效率上優化。其實調用函數只要能記錄下返回地址,被調函數能讀取到並返回就行。腦洞完全可以這樣,調用函數把返回地址存在某處或寄存器中,被調函數讀到並jmp回來。


對於不同的cpu構架,不同的編碼實現,,棧好像有4種。。滿遞增堆棧 滿遞減堆棧 空遞減堆棧 空遞增堆棧 。。。


剛做了一個有關的項目。

x86,ppc都是在先push fp,低地址放local。armv8則是相反。所以書上的東西,只是個例子,告訴你stack是怎麼個回事。


推薦閱讀:

x86 指令集架構是否過時?
為什麼英特爾在精簡指令集上沒有作為?
CPU架構中的棧和寄存器?

TAG:內存RAM | 編譯原理 | 編譯 | 精簡指令集RISC | 堆棧 |