函數調用的本質
從反匯編角度窺探平時開發調用的函數或者方法的本質。平時我們編寫的高級語言最終通過編譯器、鏈接生成機CPU執行的機器指令。 不同的CPU對應着不同着機器指令,並且每一條機器指令對應着一條匯編。
先看一個最簡單的C語言函數,這里主要通過C++來反編譯分析匯編指令。
可以通過反匯編看到調用func函數的匯編指令,當前環境是8086匯編。
通過最終的匯編指令可以看出,在執行調用一個函數:本質就是通過call指令調用函數在代碼段的地址進行直接調用。
注意:在上面的匯編指令可以看到當函數執行完畢,執行ret匯編指令退出函數。其實一個完整的函數調用必定包含call和ret指令。
那么只有了解了call和ret才能徹底從最根本了解函數的調用過程。
call 標號
1.將下一條指令的偏移地址入棧
2.轉到標號出執行指令
ret
將棧頂的值出棧,賦值給IP
下面通過匯編代碼調用printf函數標號打印HelloWorld執行驗證上面的結論。
在即將執行執行printf函數之前棧頂指針SP指向內存單元的數據。
上面說到執行函數前會將下一條指令的偏移地址入棧,上圖可以看出的下一條CPU執行的指令偏移地址IP為:000D。開始執行,看下棧頂指針SP的指向和指向內存單元的數據。
函數printf執行完畢后,執行ret指令,棧頂偏移地址出棧賦值給IP中,棧頂指針向上移動兩個字節。
不管什么開發語言最終都會轉成二進制匯編指令,對應着相應的匯編指令,本質都是一致的。這里是通過C++反匯編窺探函數調用本質。
上述介紹只是最簡單函數調用,一說到函數首先就會想到函數的三要素,函數的返回值、函數的參數、局部變量。窺探下函數返回值的實現。
如果調用函數想拿到函數返回值,就得有容器來存放返回值,我們可以想到用棧、數據區、寄存器來保存。
首先棧段不可以的,如下圖,函數內部push返回值,棧頂存儲的是CPU函數執行完畢后的IP的偏移地址。
可以考慮將返回值放入數據段,這個需要與調用者約好協議,比如越好將返回值放在ds:[0]
這樣側面證明了數據段里的數據是全局,全局區的數據是作用域是全局的。上面的實例代碼好比下面的C++代碼。
在實際中,大多數平台,windows、linux、Android等通常的做法是將方法返回值放在寄存器ax。其實這樣的效率比上面返回值放在全局區效率高,CPU從寄存器中讀取數據要快,放在全局區需要從內存先讀取到寄存器。
下面在X86環境下寫一段代碼看下匯編指令
對於函數的返回值本質清楚之后,接下來看函數的第二個要素-函數的形參。
同樣我們先考慮將參數放入數據段來實現一個求和的函數。
放在數據段是可以的,在我們概念中形參的作用於是數據函數內部,函數執行完畢形參所占用的內存空間會被回收。這樣就很明顯了,通常,形參是放在棧中的。
注意:在函數調用完畢后,一定要保證棧平衡,否者會導致棧的空間會被用完,通常保持棧平衡有兩種方式:內平棧和外平棧。
上面的案例是使用了外平棧方式,也就是在函數調用完畢后,對棧頂指針進行回復到函數調用前的位置。
對於函數的封裝性跟人覺的棧內平衡的方式會好一些,讓函數調用者不用關心內部細節。函數的形參本質了解后,接下來窺探最后一個函數的局部變量本質,這個相對復雜一些。
函數的內部需要定義局部變量,C語言特別簡單,那么在匯編中怎么分配內存空間給局部變量呢,局部變量的作用域只是當前函數,函數執行完畢后局部所棧中的空間被回收,因此局部變量空間分配還是通過棧來實現。
上面開始沒有問題,唯一缺陷是在函數內部調用函數時,由於我們沒有對bp進行恢復,一旦對函數內部在調用函數就會存存在問題, 因此需要對bp進行記錄和恢復。
