從簡單的演算法初探過程彙編 棧幀指標
從簡單的演算法初探過程彙編 棧幀指標
作者:zcabcd123
從簡單的演算法初探過程彙編
轉載自 搗亂小子
趁年輕,用程式碼實現夢想 — daoluan.net
不忽視彙編
較於我們日常接觸的高階語言,諸如c語言,c++,java等等,組合語言是更接近機器的語言,它的常用操作簡單到把一個數值(立即數,暫存器數或者儲存器資料)載入到暫存器,正是這樣,所以讓彙編完成一個程式任務,過程會比較晦澀;高階語言隱藏了很多的機器細節(比如過程(函式)棧幀的初始化,以及過程結束時棧幀的恢復),程式碼清晰易懂。
真佩服六七十年代那些大牛們,都是怎麼過來的...膜拜膜拜。寫一個100以內整數的和,即使有充分的彙編文件,這也足夠折騰我一陣子,太噁心了。但是瞭解彙編的行為方式和其中的一些重要細節,有助於理解計算機軟體和硬體的工作方式。我就一個簡單的演算法來認識一下彙編。
過程彙編前奏
過程可以理解為c中的函式,當呼叫者(caller)呼叫被呼叫者(be caller)的時候,系統會為被呼叫者在棧內分配空間,這個空間就稱為棧幀。棧的結構大概如下:
程式棧是向低地址生長的棧,與資料結構當中的棧結構類似,有後進先出的性質,暫存器%esp(stack pointer)儲存棧頂指標的地址,暫存器%ebp(** pointer)儲存幀指標的地址。 程式執行的時候,棧指標可以移動,以便增大或者縮小程式棧的空間,而幀指標是固定的,因為大多數程式棧中儲存的資料都是相對於幀指標的(幀指標+偏移量)。
當呼叫者呼叫另一個過程的時候:
- 首先,如果這個被呼叫過程如果有引數的話,呼叫的棧幀中會構造這些引數,並存入到呼叫者的棧幀中(所以上面的圖引數n...引數1,就是這個原因了);
- 將返回地址入棧。返回地址是當被呼叫過程執行完畢之後,呼叫者應該繼續執行的指令地址;它屬於呼叫者棧幀的部分,形成了呼叫者棧幀的末尾
- 到這一步就進入了被呼叫者的棧幀了,所謂當前棧幀。儲存呼叫者的幀指標,以便在之後找回呼叫者的程式棧;
- 最後進入程式執行,一般過程會sub 0xNh %esp來分配當前程式棧的大小,用來存取臨時變數啊,暫存暫存器的值啊等等。
- 如果被呼叫者又要呼叫另一個過程,回到第一步即可;
- 當過程結束之時,會將棧指標,幀指標恢復,經常會在反彙編中看到如下: 同時,返回地址會被恢復到PC。
- 這時回到了打呼叫者應該繼續執行的地方。
上面的文字可以更概括,反彙編一個過程(函式)會有建立(初始化),主體(執行),結束(返回)。之前很容易把棧和堆搞混(不是資料結構裡面),找到一個好文章與大家分享:棧和堆的區別。據說被轉了無數次了,說明寫的不錯。 過程呼叫和返回在組合語言中分別用call和ret(return)來實現。call和ret的做法不是很透明,
- call將返回地址入棧,並將PC跳轉到被呼叫過程的起始地址;
- ret與call相反,從棧中彈出返回地址,並跳轉PC。
具體看圖:
關於彙編程式碼格式
彙編程式碼最為常見的是ATT和intel彙編程式碼格式,ATT應該較為古老,但卻是GCC,OBJDUMP的預設格式。需要注意的是在帶有多個運算元的指令的情況下,列出運算元順序兩者是相反的,所以在思路上很容易混淆。例如實現%esp→%eax,有如下區別。
#intel
mov eax,esp
#ATT
movl %esp,%eax
因為受到書本的影響,所以我習慣在暫存器前加上“%”,並且我更偏好ATT格式的彙編程式碼。
反彙編具體分析
(下面的程式棧圖,我把引數入棧我在標明“引數i=?”,這可能會有點疑惑,如果“引數x=?”這樣會更好,:))
有一個簡單程式,先不管它實現了什麼功能,看下去,絕對會有收穫的。給出的c程式碼是:
#include <iostream>
using namespace std;
int fun(unsigned int x)
{
if(x == 0)
return 0;
unsigned int nx = x>>1;
int rv = fun(nx);
return (x & 0x01)+rv;
}
int main()
{
unsigned int i = 12;
fun(i);
return 0;
}
在vs2008下debug檢視彙編程式碼有如下反彙編程式碼,因為晦澀,所以摘抄瞭如下:
004110E6 jmp fun (4113A0h)
int fun(unsigned int x)
{
004113A0 push ebp
004113A1 mov ebp,esp
004113A3 sub esp,0D8h
004113A9 push ebx
004113AA push esi
004113AB push edi
004113AC lea edi,[ebp-0D8h]
004113B2 mov ecx,36h
004113B7 mov eax,0CCCCCCCCh
004113BC rep stos dword ptr es:[edi]
if(x == 0)
004113BE cmp dword ptr [x],0
004113C2 jne fun+28h (4113C8h)
return 0;
004113C4 xor eax,eax
004113C6 jmp fun+48h (4113E8h)
unsigned int nx = x>>1;
004113C8 mov eax,dword ptr [x]
004113CB shr eax,1
004113CD mov dword ptr [nx],eax
int rv = fun(nx);
004113D0 mov eax,dword ptr [nx]
004113D3 push eax
004113D4 call fun (4110E6h)
004113D9 add esp,4
004113DC mov dword ptr [rv],eax
return (x & 0x01)+rv;
004113DF mov eax,dword ptr [x]
004113E2 and eax,1
004113E5 add eax,dword ptr [rv]
}
004113E8 pop edi
004113E9 pop esi
004113EA pop ebx
004113EB add esp,0D8h
004113F1 cmp ebp,esp
004113F3 call @ILT+315(__RTC_CheckEsp) (411140h)
004113F8 mov esp,ebp
004113FA pop ebp
004113FB ret
int main()
{
00411420 push ebp
00411421 mov ebp,esp
00411423 sub esp,0CCh
00411429 push ebx
0041142A push esi
0041142B push edi
0041142C lea edi,[ebp-0CCh]
00411432 mov ecx,33h
00411437 mov eax,0CCCCCCCCh
0041143C rep stos dword ptr es:[edi]
unsigned int i = 12;
0041143E mov dword ptr [i],0Ch
fun(i);
00411445 mov eax,dword ptr [i]
00411448 push eax
00411449 call fun (4110E6h)
0041144E add esp,4
return 0;
00411451 xor eax,eax
}
00411453 pop edi
00411454 pop esi
00411455 pop ebx
00411456 add esp,0CCh
0041145C cmp ebp,esp
0041145E call @ILT+315(__RTC_CheckEsp) (411140h)
00411463 mov esp,ebp
00411465 pop ebp
00411466 ret
上面的程式碼,在第一句就間接道明瞭fun的地址。可以看到在call fun之前會有一段準備:
00411445h的指令就將fun的引數(此時i=6,還記得上面的圖嗎,引數n-引數1)和返回地址入棧,然後PC跳至004110E6h,此時main的棧幀如下:
所以,一直遞迴下去的話:
直到x==0,此時會進入if的分支執行步驟。
if(x == 0)
004113BE cmp dword ptr [x],0
004113C2 jne fun+28h (4113C8h)
return 0;
004113C4 xor eax,eax
004113C6 jmp fun+48h (4113E8h)
在彙編中,會用到異或xor邏輯運算來對一個暫存器清零(004113C4h地址的指令),由於x==0,PC跳至004113E8h,執行返回。
004113E8 pop edi
004113E9 pop esi
004113EA pop ebx
004113EB add esp,0D8h
004113F1 cmp ebp,esp
004113F3 call @ILT+315(__RTC_CheckEsp) (411140h)
004113F8 mov esp,ebp
004113FA pop ebp
004113FB ret
在這裡把被儲存的暫存器值都彈出來,恢復棧歸位,留意其中針對%esp和%ebp的操作;執行ret操作,返回,
程式繼續執行:
# int rv = fun(nx);
#004113D0 mov eax,dword ptr [nx]
#004113D3 push eax
#004113D4 call fun (4110E6h)
004113D9 add esp,4
004113DC mov dword ptr [rv],eax
rv = 0;
可以看到,處理器釋放了棧上的記憶體(%esp+4,還記得嗎,棧是向低地址增長的),因為在call之前,也就是00411448h地址處,呼叫者也就是main函式將%eax引數入棧,接著fun退出之後,引數的記憶體也就理所當然的要釋放掉。聯想一下,如果引數有很多個,那麼call之前就會有多個push,對應的,call之後就會有“add %esp n”的操作將其釋放。接著將%eax(在暫存器是用習慣當中,%eax經常被用作返回值暫存器)的值給了rv,如此一來rv就順理成章地得到了fun的返回值。接下來:
return (x & 0x01)+rv;
004113DF mov eax,dword ptr [x]
004113E2 and eax,1
004113E5 add eax,dword ptr [rv]
%eax←(x&0x01)+rv = 0x01&0x01 + 0 = 1;(提示:從這裡開始體會fun的功能)
簡單的將x&0x01+rv後送入%eax(記得嗎,%eax經常被用作返回值暫存器),此時可能會有疑問,x是從哪裡來的,答案是x存在呼叫者的棧幀內,而非被呼叫者的棧幀,因為x是函式的一個引數,dword ptr [x]應該就是對讀取了呼叫者棧幀中的x引數。該是恢復棧的時候了:
004113E8 pop edi
004113E9 pop esi
004113EA pop ebx
004113EB add esp,0D8h
004113F1 cmp ebp,esp
004113F3 call @ILT+315(__RTC_CheckEsp) (411140h)
004113F8 mov esp,ebp
004113FA pop ebp
004113FB ret
恢復棧幀,執行ret,如圖:
fun又成功返回了,程式繼續:
# int rv = fun(nx);
#004113D0 mov eax,dword ptr [nx]
#004113D3 push eax
#004113D4 call fun (4110E6h)
004113D9 add esp,4
004113DC mov dword ptr [rv],eax
rv = %eax = 1;
又回到了剛才走過的地方,但是資料有異。接下來程式執行return退出:
return (x & 0x01)+rv;
004113DF mov eax,dword ptr [x]
004113E2 and eax,1
004113E5 add eax,dword ptr [rv]
%eax←(x&0x01)+rv = 0x3&0x01 + 1 = 2;又該是ret的時候了,恢復棧:
004113E8 pop edi
004113E9 pop esi
004113EA pop ebx
004113EB add esp,0D8h
004113F1 cmp ebp,esp
004113F3 call @ILT+315(__RTC_CheckEsp) (411140h)
004113F8 mov esp,ebp
004113FA pop ebp
004113FB ret
棧幀結構如圖:
還差一次,返回之後程式繼續執行:
# int rv = fun(nx);
#004113D0 mov eax,dword ptr [nx]
#004113D3 push eax
#004113D4 call fun (4110E6h)
004113D9 add esp,4
004113DC mov dword ptr [rv],eax
rv = %eax = 2;
接下來程式return退出(不累贅了):
return (x & 0x01)+rv;
004113DF mov eax,dword ptr [x]
004113E2 and eax,1
004113E5 add eax,dword ptr [rv]
004113E8 pop edi
004113E9 pop esi
004113EA pop ebx
004113EB add esp,0D8h
004113F1 cmp ebp,esp
004113F3 call @ILT+315(__RTC_CheckEsp) (411140h)
004113F8 mov esp,ebp
004113FA pop ebp
004113FB ret
至此,程式完全退出了fun的遞迴過程,回到了主函式main,main也有自己的棧幀,因為main也是一個函式。下圖:
# fun(i);
#00411445 mov eax,dword ptr [i]
#00411448 push eax
#00411449 call fun (4110E6h)
0041144E add esp,4
return 0;
00411451 xor eax,eax
0x0041144E處,add %esp,4,目的是釋放一開始入棧的fun的引數,而主函式返回0(return 0),也是用到了異或邏輯運算xor來講%eax清零。
到這裡,相信有點明白了,在遞迴呼叫過程中,程式棧是如何變化的,並且上面的函式計算引數i中位的和。
收穫
發現這樣一個小小的遞迴程式,分析起它反彙編如有一種返璞歸真的感覺,對理解“遞迴呼叫”會更為清晰的思路。縱觀上面的分析,遞迴呼叫雖然是演算法中解決問題常用的方法,但是它對付起龐大遞迴次數的程式來說(上面因為分析所以選取的遞迴次數較少),非常消耗記憶體。 所以在寫程式的時候,在時間和空間的消耗抉擇上,需要謹慎。通過學習彙編和反彙編程式碼的分析,將更瞭解機器的行為,從而寫出更為高效的程式碼。