C/C++不能返回局部變量的指針,是一條重要的語法規則。
至於為什麼,則不是那麼顯眼。
局部變量,是分配在棧上的變量,隨著函數調用的返回而失效。
函數調用結束之後,局部變量的指針,也就是野指針了,不能在函數外繼續使用。
函數調用時的棧,也叫棧幀。它在函數開始時的棧頂,就是調用完成之後的返回地址。
局部變量,在這個基礎上分配。
函數的實參,也是局部變量。不過一般放在寄存器裡,或者比返回地址更高的地址上。
被調函數開始時,x64平臺的棧幀:
實參7
實參6
RET <- rsp指向這裡
實參0-5在寄存器裡,前6個參數通過寄存器傳遞,超過6個的參數用棧傳遞,返回地址(RET)在棧頂。
棧頂,保存在rsp寄存器裡。
為了函數返回和查找局部變量(包括實參)的方便,x64提供了rbp寄存器,用於在函數執行過程中保存棧的底部。
如果是32位的x86,寄存器是esp、ebp。
所以函數的開頭代碼,在需要分配局部變量時,一般是:
push %rbp,
mov %rsp,%rbp
sub $size,%rsp
第一行把rbp的原值保存在棧上,
第二行把rsp的值保存到rbp裡,
第三行通過把rsp往低地址移動,從而分配局部變量的內存。
size表示所有局部變量的大小,要編譯器根據函數代碼計算出來。
這個時候的棧幀變成了:
實參7
實參6
RET
rbp原值 <- rbp的新值
局部變量0
局部變量1 <- rsp的新值
假設就2個局部變量,RET表示函數調用結束時的返回地址。
這時,rbp寄存器指向的是返回地址的下一個位置,即被調函數的棧低。
這也是rbp的名字的由來:
r指的是register,寄存器,
b指的是bottom,底部,
p指的是pointer,指針,它本質上是指示函數棧的底部的指針。
rsp,指的自然是棧的頂部,s是stack。
因此,可以用rbp+對應的偏移量offset來訪問局部變量和實參。
rsp可能會改變,例如在調用子函數時,所以一般都是用rbp作為標準來訪問局部變量。
在函數結束時,要把棧幀恢復到開始之前,常用的代碼是:
mov %rbp,%rsp
pop %rbp
ret
第一行把棧底rbp覆蓋到棧頂rsp,這時的rsp指向了rbp的原值,局部變量的內存空間已經被清理。
第二行,把保存在棧上的rbp原值恢復到rbp寄存器。這個值是被調函數剛開始時的rbp值。
因為要用rbp保存棧幀的底部,然後在此基礎上分配局部變量的內存,所有先保存rbp的原值,然後再分配,否則返回調用函數之後這個值就丟了。
第三行,這時候棧頂rsp指向返回地址,棧恢復到了被調函數開始時的樣子,可以用ret指令返回了。
局部變量既然失效了,它的指針自然就不能再用了。
使用野指針,那是要出BUG的。
高級語言的特點就是,它會告訴你什麼,但不一定會告訴你為什麼,但它還要你記住什麼:(
C這種大號的彙編也是這樣。
如果局部變量是個字符數組,那麼在字符串拷貝strcpy()時溢出了,就會覆蓋返回地址。
因為返回地址就在局部變量上面不遠。
覆蓋之後,程序就不按照程式設計師的思路跑了,而是按照提供這個字符串的人的思路跑了,這就是當年著名的字符串緩衝區溢出漏洞。
最後搞得Linux關閉了堆棧的可執行權限,gcc加上了字符串溢出的檢查。
當年丹尼斯·裡奇為了節約內存,不記錄字符串的長度,而是拿字節0作為結束標誌導致的大BUG。