【【技匠志】利用反匯編手段解析C語言函數(shù)】https://toutiao.com/group/6792908403560677900/?app=explore_article×tamp=1588344767&req_id=202005012252460100140530921E55BDFA&group_id=6792908403560677900&tt_from=copy_link&utm_source=copy_link&utm_medium=toutiao_ios&utm_campaign=client_share
1、問題的提出
函數(shù)是 C語言中的重要概念。利用好函數(shù)能夠充分利用系統(tǒng)庫的功能寫出模塊獨立、易于維護和修改的程序。函數(shù)并不是 C 語言獨有的概念,其他語言中的方法、過程等本質上都是函數(shù)。
2、解決方法
在《微機原理》 課程介紹了堆棧、匯編語言等必要的相關知識之后,通過在高級語言開發(fā)環(huán)境下反匯編C 語言程序代碼,使得學生通過分析匯編代碼來理解函數(shù)調用中的堆棧變化,可以在實踐中理解高級語言和低級語言的底層映射關系,理解函數(shù)調用的實質。本文通過在 Visual C 6.0 下反匯編一個 32 位 C語言程序的部分代碼來解析解釋函數(shù)調用的具體過程。
3、函數(shù)調用過程
函數(shù)調用過程主要由參數(shù)傳遞、地址跳轉、局部變量分配和賦初值、執(zhí)行函數(shù)體,結果返回等幾個步驟組成[1]。
3.1、參數(shù)傳遞及函數(shù)跳轉
參數(shù)由實參傳遞給形參。在底層實現(xiàn)上,即是實參按照函數(shù)調用規(guī)定壓入堆棧。參數(shù)傳遞完成后就通過CALL指令由當前程序跳轉到子程序處。
3.2、局部變量分配并賦值
函 數(shù)的'{'被認為是分配局部變量空間的時機。在匯編層面局部變量分配體現(xiàn)為堆棧中以 EBP 寄存器為基址向低地址端分配的一個連續(xù)區(qū)域,通過 EBP 寄存器的相對尋址方式來尋址函數(shù)內(nèi)的局部變量。由于堆棧增長的方向是高地址端到低地址端,因此函數(shù)中先定義的局部變量地址較大,后定義的變量地址逐漸變小,相鄰定義的變量其地址一定相鄰[2]。由于全局數(shù)據(jù)和局部數(shù)據(jù)定義在不用的數(shù)據(jù)區(qū)而并不與局部變量相鄰,根據(jù)程序局部性原理,相鄰的數(shù)據(jù)會被緩存,因此對相同的運算,局部變量作為操作數(shù)的運算效率就可能高于有全局變量參與的運算。同時,局部變量分配和回收只需要移動堆棧指針ESP,因此效率最高。
3.3、尋址函數(shù)的參數(shù)
參數(shù)存放在以 EBP 為基址的高地址端。對參數(shù)的訪問同樣是通過EBP 寄存器相對尋址操作來實現(xiàn)。
3.4、執(zhí)行函數(shù)體內(nèi)的語句
函數(shù)內(nèi)和具體功能相關的語句被轉化成一系列匯編語句。
3.5、返回值
return 語句將返回值返回到主調函數(shù)。在底層,參數(shù)是通過 EAX 寄存器或 EDX 寄存器傳遞給主調函數(shù)。
3.6、返回主調函數(shù)
函數(shù)的'}'被解釋為函數(shù)體已經(jīng)執(zhí)行完。遇到'}'時,會將堆棧中的局部變量、程序中壓入堆棧的寄存器的值全部彈出,將之前 CALL指令執(zhí)行時壓入堆棧的函數(shù)返回地址彈到指令指針寄存器 EIP,從而返回到主調函數(shù)。
3.7、堆棧平衡
堆棧平衡指的是將函數(shù)調用前壓入堆棧的參數(shù)彈出堆棧,使堆棧恢復到其調用前的狀態(tài)[3]。由于函數(shù)調用完成后,參數(shù)就是無用的數(shù)據(jù)了,因此需要將其移出堆棧。
在 C語言中不需要進行堆棧平衡。而在匯編層面上卻根據(jù)調用約定來確定由主調函數(shù)或是被調函數(shù)完成堆棧平衡。
C語言函數(shù)調用堆棧常見形式如圖 1 所示[4]:
參數(shù)由主調函數(shù)壓入堆棧,CALL 指令將函數(shù)返回地址入棧。進入子函數(shù)后,需要保存 EBP 原值、分配局部變量空間、保存寄存器初始值。函數(shù)內(nèi)通過'EBP-位移量'方式訪問局部變量,通過'EBP 位移量'方式訪問參數(shù)[5]。
每發(fā)生一次函數(shù)調用,就會在堆棧中建立一個棧幀,棧幀在函數(shù)調用后釋放。但是系統(tǒng)的堆棧資源有限,因此如果函數(shù)調用(如遞歸調用)層數(shù)過多,則可能發(fā)生堆棧溢出錯誤。
4.反匯編代碼分析
以下將函數(shù) function 的調用相關代碼在VisualC 6.0 Debug模式反匯編,通過對匯編代碼的分析揭示函數(shù)調用的關鍵點和細節(jié)。完整的 C語言程序代碼如圖 2 所示:
Function(i,&j)語句的反匯編代碼如圖 3 所示:
先 找到主函數(shù)中的局部變量 i,j(其在堆棧中位置為 EBP- 8和 EBP- 4),將其壓入堆棧。Visual C/C 的編譯器對 C 語言程序的默認函數(shù)約定為 _cdecl[6]。此參數(shù)入棧約定為自右向左,并且對函數(shù)名前加'_'修飾符。先將 j 的地址壓入堆棧,后將 i 的值壓入堆
棧。通過 call 指令調用函數(shù)。從 Call 指令可見 fuction函數(shù)編譯后加了'_'修飾符。Call 指令執(zhí)行時自動將函數(shù)的返回地址入棧,之后轉到 function 定義處開始執(zhí)行此函數(shù)。
對funciton函數(shù)的'{'的反匯編結果如圖 4 所示:
在函數(shù)內(nèi),遇到'{'時分配局部空間,并用值'0xCCH'進行初始化。未在定義時初始化的局部變量其初值就與'0xCCH'相關。因此 int 類型變量由于占四個字節(jié),其初值為 - 858993460(0xCCCCC-CCCH);兩個連續(xù)的 0xCCH 對應漢字'燙'字,因此當
以字符形式顯示函數(shù)內(nèi)未初始化的變量時會顯示為'燙燙…';指針類型變量就指向了地址為 0xCCCC-CCH 的內(nèi)存。由此在調試模式下能很容易發(fā)現(xiàn)未初始化的變量。
堆?;镜拇鎯挝粸樗淖止?jié),對于小于四字節(jié)的數(shù)據(jù)按四字節(jié)對齊方式分配空間。因此 char 類型變量 ch 雖然數(shù)據(jù)本身需要兩個字節(jié),也分配了四個字節(jié)空間。array 字節(jié)數(shù)組分配空間時每個字符占一個字節(jié),不夠四個字符時按四字節(jié)對齊存放。因此局部變量
空間總數(shù)為 40H 4 4×2 4=50H。局部變量 ch 的地址為 EBP- 4,a、b 的地址分別為 EBP- 8 ,EBP- 0CH,array數(shù)組的地址為 EBP- 10h。函數(shù)左括號右括號間的所有的語句反匯編結果如圖 5 所示:
若變量有初值,則反匯編就會為其生成一條 Mov指令為其賦值。對于沒有初值的變量其每個字節(jié)都為0xCCH。對于字符數(shù)組,情況稍微復雜一些。字符串常量'abc'被存放在全局數(shù)據(jù)區(qū)中。當需要引用其值對數(shù)組進行初始化時,實際是將全局數(shù)據(jù)拷貝到堆棧中的
局部數(shù)組 array里。由于寄存器是 32 位,每次最多只能賦值 4 個字符,因此對數(shù)組賦初值的語句反匯編后可能產(chǎn)生一至多條匯編語句。對數(shù)組內(nèi)容的訪通過[ 'EBP 數(shù)組首地址 偏移量]的寄存器間址來完成,因此局部數(shù)組初始化費時但訪問時的效率高。
在函數(shù)內(nèi)訪問局部變量和參數(shù)通過 [EBP 位移量 /- 位移量]來完成。函數(shù)返回值被放到 EAX 寄存器中供主調函數(shù)使用。
可見,在匯編層面上,函數(shù)內(nèi)部并不存儲局部變量,局部變量只有當函數(shù)調用發(fā)生時才會在棧上為函數(shù)分配空間。因此當函數(shù)調用后返回局部變量的值是錯誤的。
遇到函數(shù)'}'時的操作如圖 6 所示:
將寄存器 EDI、ESI、EBX 恢復原值;將 ESP 調回到 EBP 處;將 EBP原值彈出。此時 ESP 指向函數(shù)返回地址。執(zhí)行出棧指令,將函數(shù)的返回地址彈入 EIP 寄存器返回到主調函數(shù)。此時堆棧中只殘留有調用函數(shù)時壓入的參數(shù)還沒有清理。
主調函數(shù)中的堆棧平衡語句如圖 7 所示:
根據(jù) _cdecl 約定,需要由主調函數(shù)完成堆棧平衡。主調函數(shù)根據(jù)壓入堆棧的參數(shù)的數(shù)目 2 和參數(shù)大小,利用指令 add ESP,8 將參數(shù)全部彈出。此時堆棧就恢復到其調用前的狀態(tài)。一個完整的函數(shù)調用過程完成。