一個過程調用包括將數據(以參數和返回值的形式)與控制從代碼的一部分傳遞到另一部分。除此之外,在進入時為過程的局部變量分配空間,在退出的時候釋放這些空間。數據傳遞、局部變量的分配和釋放通過操縱程序棧來實現。棧作為一種能夠實現先進后出、后進先出的數據結構,非常適合用於實現函數調用以及返回的機制。
在過程調用中主要涉及三個重要的方面:
- 傳遞控制:包括如何開始執行過程代碼,以及如何返回到開始的地方
- 傳遞數據:包括過程需要的參數以及過程的返回值
- 內存管理:如何在過程執行的時候分配內存,以及在返回之后釋放內存
棧結構
程序棧其實就是一塊內存區域,這個區域內的數據滿足先進后出的原則。從棧底到棧頂,地址由高變低。所以新加入棧的以及新開辟的空間的地址都是較小的。有兩個特殊寄存器是與棧有關的。寄存器 %ebp
叫做幀指針,保存當前棧幀開始的位置。寄存器 %esp
叫做棧指針,始終指向棧頂。棧幀(stack frame)是指為單個過程分配的那一小部分棧。大多數信息訪問都是相對於幀指針訪問的。以前經常看到這類代碼:movl 8(%ebp), %eax
意思就是將存放在比幀指針地址大8的變量移動到寄存器里。
假設過程 P(調用者)調用了過程 Q(被調用者),則 Q 的參數存放在 P 的棧幀中。調用 Q 時,P 的返回地址被壓入棧中,形成 P 的棧幀的末尾。返回地址就是當過程 Q 返回時應該繼續執行的地方。Q 的棧幀緊跟着被保存的幀指針副本開始,后面是其他寄存器的值。
棧中會存放局部變量。有下面三個原因:
- 不是所有的變量都能放到寄存器中的,沒有那么多寄存器。
- 有些局部變量是數組,或者結構體。
- 有些時候需要對某些變量使用
&
運算符,獲得其地址,因此要將其放在棧中。寄存器變量是沒有地址的。
棧向低地址方向增長。可以利用指令pushl
將數據存入棧,利用popl
將指令從棧中取出。由於棧指針%esp
始終指向棧頂,所以可以通過減小棧指針的值來分配空間,增加棧指針來釋放空間。
調用方式
下面是有關過程調用和返回的指令:
指令 | 描述 |
---|---|
call Label | 過程調用 |
call Operand | 過程調用 |
leave | 為返回准備棧 |
ret | 返回 |
call 指令的效果是將返回地址壓入棧中(也就是保存返回地址),然后跳轉到被調用過程的起始處。返回地址是在程序中緊跟在 call 后面的那條指令的地址。這樣當被調用過程返回時,執行從此(call 指令的下一條指令)繼續。
ret 指令從棧中彈出返回地址,然后跳轉到返回地址的位置。
寄存器共享
寄存器是在過程調用中唯一能被所有過程共享的資源。因此我們必須保證被調用者不會覆蓋某個調用者稍后會使用的寄存器的值。根據慣例,寄存器%eax
、%edx
、%ecx
被划分為調用者保存寄存器。當過程 P 調用過程 Q 時,Q 可以覆蓋這些寄存器的數據,而不會破壞 P 所需的數據。寄存器%ebx
、%esi
、%edi
被划分為被調用者保存寄存器,Q 在覆蓋這些寄存器的值之前,必須將其壓入棧中,然后在返回前恢復他們。看下面的例子:
int P(int x)
{
int y = x * x; //變量 y 是在調用前計算的
int z = Q(y);
return y + z; //要保證變量 y 在 Q 返回后還能使用。
}
-
基於調用者保存:過程 P 在調用 Q 之前,將 y 的值保存在自己的棧幀中;當 Q 返回時,過程 P 由於自己保存了這個值,就可以從自己的棧中取出來。
-
基於被調用者保存:過程 Q 將值 y 保存在被調用者保存寄存器。如果過程 Q 和其他任何 Q 調用的過程,想使用保存 y 值的被調用者保護寄存器,它必須將這個寄存器的值存放到棧幀中,然后在返回前恢復 y 的值。
這兩種方案都是可行的。
過程實例
考慮下面給出的C語言代碼。函數 caller 中包括一個對函數 swap_add 的調用。
int swap_add(int *xp, int *yp)
{
int x = *xp;
int y = *yp;
*xp = y;
*yp = x;
return x + y;
}
int caller()
{
int arg1 = 534;
int arg2 = 1057;//局部變量,以及作為參數
int sum = swap_add(&arg1, &arg2);
int diff = arg1 - arg2;
return sum * diff;
}
下面給出 caller 和 swap_add 的棧幀。左圖是還未執行到 int sum = swap_add(&arg1, &arg2);
語句之前。可以看到因為要對局部變量取地址,所以要把局部變量放到棧中。棧指針一直指在棧頂。幀指針目前指在最上面,代表過程 caller 的棧幀開始的位置。
調用了 swap_add
之后,棧成了左邊的樣子。因為調用函數會使用一個 call 指令。這個指令會壓入一個返回地址。緊接着開始了 swap_add
的棧幀。此時 %ebp
被更新。在被調用者函數內,使用12(%ebp)
和 8(%ebp)
就可以取到兩個參數。
下面看一下匯編代碼:
caller:
pushl %ebp # 保存舊的 %ebp 的一個副本
movl %esp, %ebp # 設置新的棧頂指針,到棧頂
subl $24, %esp # 棧指針減去24,即分配24個字節的空間
movl $534, -4(%ebp) # int arg1 = 534;
movl $1057, -8(%ebp) # int arg2 = 1057; 這兩個局部變量都保存在棧上
leal -8(%ebp), %eax # 取地址 &arg2,放到寄存器中
movl %eax, 4(%esp) # 保存到棧上
leal -4(%ebp), %eax #
movl %eax, (%esp) # 同上
call swap_add # 參數都齊全了,可以調用函數了
swap_add:
push %ebp # %ebp移動了,棧發生了改變
movl %esp, %ebp # 這兩步同 caller,是過程調用的“建立部分”
push %ebx # 這是一個被調用者保存寄存器,將舊值壓入棧中,作為棧幀的一部分
# 從這里開始才是真正的C語言代碼體現的 swap_add 內容
movl 8(%ebp), %edx # 獲得參數1,放到寄存器中
movl 12(%ebp), %ecx # 獲得參數2,放到寄存器中
movl (%edx), %ebx # int x = *xp;
movl (%ecx), %eax # int y = *yp;
movl %eax, (%edx) # *xp = y;
movl %ebx, (%ecx) # *yp = x;
addl %ebx, %eax # 計算 x + y,結果放在%eax中,所以會返回%eax中的值
popl %ebx # 這是被調用過程的“結束過程”
pop %ebp # 恢復保護寄存器,彈出棧幀指針
ret # 此時棧頂是返回地址,ret指令就彈出這個地址,然后跳轉到這個地址
# caller 剩余的代碼會緊跟在后面
movl -4(%ebp), %edx # 此時 %ebp 指的是 caller 自己的 %ebp
subl -8(%ebp), %edx # int diff = arg1 - arg2;
imull %edx, %eax # 計算 sum * diff,%eax是存放 swap_add 返回值的
leave
ret
說句題外話,看到這個 swap 函數之后,也能解答一個初學者的問題:為什么下面這個 swap 函數不能交換參數的值?
void swap(int a, int b)
{
int temp;
temp = a;
a = b;
b = temp;
}
swap 函數被調用時,用寄存器存參數 a 與 b,作為臨時存儲,然后對寄存器內的兩個值做了一通操作,並沒有影響到存儲器中 a 與 b 的值。
用 leave
指令可以使棧做好返回的准備。其作用就是將幀指針移到棧頂然后拋出。
分配給 caller 的棧幀有24個字節,8個用於局部變量,8個用於傳參,還有8個字節未使用。這主要是滿足 x86 的一個編程指導方針——對齊(alignment)的要求:一個函數使用的棧空間必須是 16 字節的整數倍,包括一開始保存的 %ebp 的 4 字節和返回值的 4 字節,所以共分配了 24 個字節。
從這個例子我們可以看到,編譯器根據簡單的慣例來產生管理棧結構的代碼。棧幀中需要包含:
- 幀指針副本,標識自己的棧幀從哪里開始
- 局部變量(如果需要)
- 臨時空間(如果需要)
- 調用其他函數之后,壓入返回信息
可以用相對於 %ebp 的偏移量來訪問變量和參數。可以用通過加減棧頂指針來釋放或分配空間。在返回時,必須將棧恢復到調用前的狀態,恢復所有的被調用者保護寄存器和 %ebp,重置 %esp。為了讓程序能正確執行,讓所有過程遵循一個統一一致的慣例是很重要的。
一個調用過程(call 指令之后)的匯編代碼包括三個部分:
- 建立部分:壓入幀指針,移動棧指針,壓入需要保存的寄存器的值。
- 主體部分:函數的功能部分
- 結束部分:恢復需要保存的值,彈出幀指針,返回。
遞歸
有了前面的的基礎,要理解遞歸就簡單很多了。為什么過程能調用自己本身呢?因為每個調用在棧中都有自己的私有空間。多個未完成的調用,他們局部變量,之間不會相互影響。棧的原則很自然地提供了一個策略:過程被調用時分配局部存儲,返回時釋放。
上一個課后題的例子:
一個具有通用結構的C函數如下:
int rfun(unsigned x)
{
if(???)
return ??? ;
unsigned nx = ??? ;
int rv = rfun(nx);
return ???;
}
給出對應的匯編代碼,其中省略了建立和完成代碼,請通過匯編代碼分析:
- 被調用者保護寄存器 %ebx 存的是什么?
- C語言代碼中的問號應該填什么?
- 描述C語言代碼的作用。
匯編代碼如下:
movl 8(%ebp), %ebx # 開頭第一句一般是取參數。
movl $0, %eax # int y = 0; (變量名隨便取的)
testl %ebx, %ebx # 測試 x
je .L3 # if(x==0) return;
movl %ebx, %eax # else { y = x;
shrl %eax # y >>= 1;}
movl %eax, (%esp) # 壓入棧中,很明顯,是為了下一次調用使用
call rfun # 遞歸調用
# 別忘了 call 會壓入返回地址,然后跳轉
movl %ebx, %edx
andl $1, %edx # %edx 中存的值是 (x & 1)
leal (%edx, %eax), %eax # 寄存器是公用的資源。
# y = (x >> 1) + ( x & 1)
.L3:
經過上面的分析可以得出解答:
%ebx
存放的是參數 x 的值。- 我們在匯編中分析出來的一個新變量
y
應該就是C語言代碼中的nx
。 - C語言代碼如下:
int rfun(unsigned x)
{
if(x == 0)
return 0;
unsigned nx = x >> 1;
int rv = rfun(nx);
return rv + (x & 0x01);
}
這段代碼的作用是:遞歸地計算一個無符號數的每一位上的數字之和。
對於遞歸,目前我覺得有一個很恰當的比喻:
我們使用詞典查詞,本身就是遞歸,為了解釋一個詞,需要用到更多詞。當你查一個詞,發現要解釋這個詞的一句話里有一個詞你不懂,於是你開始查這第二個詞。可惜的是,查第二個詞的時候仍然有不懂的詞,於是查第三個詞……這樣一直查下去,知道有一個詞的解釋你完全能看懂,那么遞歸走到了盡頭,開始返回,然后你按照查詞順序的倒序逐個看明白了之前你所查的每個詞的意思。你最開始查的那個詞,是最后才知道其意思的。
所以對於理解遞歸,很重要的一點是要理解遞歸什么時候觸及到邊界,開始返回了。
古之欲明明德於天下者,先治其國;欲治其國者,先齊其家;欲齊其家者,先修其身;欲修其身者,先正其心;欲正其心者,先誠其意;欲誠其意者,先致其知,致知在格物。物格而后知至,知至而后意誠,意誠而后心正,心正而后身修,身修而后家齊,家齊而后國治,國治而后天下平。(注:這不是遞歸,只是函數調用嵌套比較深)