深入理解計算機系統:過程(函式呼叫棧幀變化)
一:棧幀結構
暫存器幀指標%ebp ,棧指標%erp 。
因為棧指標經常移動,所以基於地址的訪問多數是以幀指標為基礎的。而被呼叫者的棧幀一般在呼叫者下方。
實現過程
過程的實現主要就是在於資料如何在呼叫者和被呼叫者之間傳遞,以及在被呼叫者當中區域性變數記憶體的分配以及釋放。
而過程實現當中,引數傳遞以及區域性變數記憶體的分配和釋放都是通過以上介紹的棧幀來實現的,大部分情況下,我們認為過程呼叫當中做了以下幾個操作。
①、備份原來的幀指標,調整當前的幀指標到棧指標的位置,這個過程就是我們經常看到的如下兩句彙編程式碼做的事情。
pushl %ebp
movl %esp, %ebp
②、建立起來的棧幀就是為被呼叫者準備的,當被呼叫者使用棧幀時,需要給臨時變數分配預留記憶體,這一步一般是經過下面這樣的彙編程式碼處理的。
1 |
subl $ 16 ,%esp
|
③、備份被呼叫者儲存的暫存器當中的值,如果有值的話,備份的方式就是壓入棧頂。因此會採用如下的彙編程式碼處理。
1 |
pushl %ebx
|
④、使用建立好的棧幀,比如讀取和寫入,一般使用mov,push以及pop指令等等。
⑤、恢復被呼叫者暫存器當中的值,這一過程其實是從棧幀中將備份的值再恢復到暫存器,不過此時這些值可能已經不在棧頂了。因此在恢復時,大多數會使用pop指令,但也並非一定如此。
⑥、釋放被呼叫者的棧幀,釋放就意味著將棧指標加大,而具體的做法一般是直接將棧指標指向幀指標,因此會採用類似下面的彙編程式碼處理(也可能是addl)。
1 |
movl %ebp,%esp
|
⑦、恢復呼叫者的棧幀,恢復其實就是調整棧幀兩端,使得當前棧幀的區域又回到了原始的位置。因為棧指標已經在第六步調整好了,因此此時只需要將備份的原幀指標彈出到%ebp即可。類似的彙編程式碼如下。
1 |
popl %ebp
|
⑧、彈出返回地址,跳出當前過程,繼續執行呼叫者的程式碼。此時會將棧頂的返回地址彈出到PC,然後程式將按照彈出的返回地址繼續執行。這個過程一般使用ret指令完成。
過程的實現大概就是以上八個步驟組成的,不過這些步驟並不都是必須的(大部分時候,開啟編譯器的優化會優化掉很多步驟),而且第6和第7步有時會使用leave指令代替。
過程呼叫和返回
(1)call指令:call 指令有一個目標,即指明被呼叫過程起始的指令地址。直接呼叫的目標可以是一個標號,間接呼叫的目標是 * 後面跟一個操作符。它一共做兩件事,第一件是將返回地址(也就是call指令執行時PC的值)壓入棧頂,第二件是將程式跳轉到當前呼叫的方法的起始地址。第一件事是為了為過程的返回做準備,而第二件事則是真正的指令跳轉。
(2)leave指令:它也是一共做兩件事,第一件是將棧指標指向幀指標,第二件是彈出備份的原幀指標到%ebp。第一件事是為了釋放當前棧幀,第二件事是為了恢復呼叫者的棧幀。
(3)ret指令:它同樣也是做兩件事,第一件是將棧頂的返回地址彈出到PC,第二件事則是按照PC此時指示的指令地址繼續執行程式。這兩件事其實也可以認為是一件事,因為第二件事是系統自己保證的,系統總是按照PC的指令地址執行程式。
可以看出,除了call指令之外,leave和ret指令都與上面8個步驟有些不可分割的關係。call指令沒有在8個步驟當中體現,是因為它發生在進入過程之前,因此在第1步發生的時候,call指令往往已經被執行了,並且已經為ret指令準備好了返回地址。
在 IA32 中,暫存器%eax,%edx和%ecx被劃分為呼叫者儲存暫存器。當過程 P 呼叫 Q 時,Q可以覆蓋這些暫存器,而不會破壞 P 所需的資料。
暫存器%ebx,%esi和%edi被劃分為被呼叫者儲存暫存器。這裡Q 必須在覆蓋這些暫存器的值之前,先把他們儲存到棧中,並在返回前恢復它們,因為 P(或某個更高層次的過程)可能會在今後的計算中需要這些值。上面所說的過程實現的8個步驟中第三步便是如此。
int P(int x) { int y = x*x; int z = Q(y); return y+z; }
(1)可以在呼叫 Q 之前,將 y 的值儲存在自己的幀棧中;當 Q 返回時,過程 P 就可以從棧中取出y 的值。換句話說就是呼叫者 P 自己儲存這個值。 過程 P 在呼叫 Q 之前會先計算 y 的值,而且它必須保證 y 的值在 Q 返回後是可用的。這裡有兩種方法實現:
(2)可以將 y 儲存在被呼叫者儲存暫存器中。如果 Q ,或者其它 Q 呼叫的程式想使用這個暫存器,它必須將這個暫存器的值儲存在幀棧中,並在返回前恢復該值。換句話說就是被呼叫者儲存這個值。當 Q 返回到 P 時,y 的值會在被呼叫者儲存暫存器中,或者是因為暫存器根本就沒有改變,或者是因為它被儲存並恢復了。
這兩種方法在 IA32 中是都採用的。
過程的實現主要就是在於資料如何在呼叫者和被呼叫者之間傳遞,以及在被呼叫者當中區域性變數記憶體的分配以及釋放。
而過程實現當中,引數傳遞以及區域性變數記憶體的分配和釋放都是通過以上介紹的棧幀來實現的,大部分情況下,我們認為過程呼叫當中做了以下幾個操作。
①、備份原來的幀指標,調整當前的幀指標到棧指標的位置,這個過程就是我們經常看到的如下兩句彙編程式碼做的事情。
1 2 |
pushl %ebp
movl %esp, %ebp
|
②、建立起來的棧幀就是為被呼叫者準備的,當被呼叫者使用棧幀時,需要給臨時變數分配預留記憶體,這一步一般是經過下面這樣的彙編程式碼處理的。
1 |
subl $ 16 ,%esp
|
③、備份被呼叫者儲存的暫存器當中的值,如果有值的話,備份的方式就是壓入棧頂。因此會採用如下的彙編程式碼處理。
1 |
pushl %ebx
|
④、使用建立好的棧幀,比如讀取和寫入,一般使用mov,push以及pop指令等等。
⑤、恢復被呼叫者暫存器當中的值,這一過程其實是從棧幀中將備份的值再恢復到暫存器,不過此時這些值可能已經不在棧頂了。因此在恢復時,大多數會使用pop指令,但也並非一定如此。
⑥、釋放被呼叫者的棧幀,釋放就意味著將棧指標加大,而具體的做法一般是直接將棧指標指向幀指標,因此會採用類似下面的彙編程式碼處理(也可能是addl)。
1 |
movl %ebp,%esp
|
⑦、恢復呼叫者的棧幀,恢復其實就是調整棧幀兩端,使得當前棧幀的區域又回到了原始的位置。因為棧指標已經在第六步調整好了,因此此時只需要將備份的原幀指標彈出到%ebp即可。類似的彙編程式碼如下。
1 |
popl %ebp
|
⑧、彈出返回地址,跳出當前過程,繼續執行呼叫者的程式碼。此時會將棧頂的返回地址彈出到PC,然後程式將按照彈出的返回地址繼續執行。這個過程一般使用ret指令完成。
過程的實現大概就是以上八個步驟組成的,不過這些步驟並不都是必須的(大部分時候,開啟編譯器的優化會優化掉很多步驟),而且第6和第7步有時會使用leave指令代替。下面會詳細講解這些步驟。
pushl %ebp
movl %esp, %ebp