C++反彙編學習筆記6——變數在記憶體中的位置和訪問方式
兩年前寫的,歡迎大家吐槽!
轉載請註明出處。
1. 全域性變數和區域性變數的區別
具有初始值的全域性變數在原始碼連結時就被寫入所建立的PE檔案,當該檔案被執行時作業系統分析各個節中的資料填入對應的記憶體地址中,這時全域性變數就已經存在了,等PE檔案的分析和載入工作完成之後才執行入口點的程式碼。所以全域性變數不受作用域的影響,程式的任何位置都可以訪問。下面就來具體講述不同點。
全域性變數和常量類似都是被寫入檔案中,因此生命週期和模組相同,而其與區域性變數最大的不同之處便是生命週期。按照上面所說,全域性變數在執行第一條程式碼前便存在,知道程式退出銷燬,而區域性變數的生命週期則僅限於函式作用域內,超出作用域便會由棧平衡操作來釋放其空間。在訪問方式上,區域性變數是通過棧指標來訪問的,但是全部變數不在棧中,就無法用棧指標訪問。下面就來說說是如何訪問全域性變數的。還是先看一個例子(Debug版本):
19: void main()
20: {
;棧空間分配和初始化儲存環境略
21: scanf("%d", &g_nVariableType);
0042B45E push offset g_nVariableType (48C000h) ;通過直接定址來直接訪問全域性變數
0042B463 push offset string "%d, %d" (47CC80h)
0042B468 call @ILT+3005(_scanf) (429BC2h)
0042B46D add esp,8
22: printf("%d\r\n",g_nVariableType);
0042B470 mov eax,dword ptr [g_nVariableType (48C000h)] ;此處同上
0042B475 push eax
0042B476 push offset string "%d %d\r\n" (47CC74h)
0042B47B call @ILT+4085(_printf) (429FFAh)
0042B480 add esp,8
23: };棧平衡操作及環境恢復略
可以很清楚的看到全域性變數是用直接定址來訪問的,這是因為全域性變數儲存在檔案中,載入至記憶體時也會隨著檔案載入到記憶體的固定偏移位置,所以編譯器可以決定其用直接定址找到它。而區域性變數是儲存在棧中的,無法確定其地址,故無法使用直接定址方式,應採用相對定址。下面再來看一下多個全域性變數的情況(Debug版本):
19: void main()
20: {;棧空間分配和初始化儲存環境略
21: /*scanf("%d", &g_nVariableType);
22: printf("%d\r\n",g_nVariableType);*/
23: //全域性變數與區域性變數對比
24: intnOne = 1;
0042D8EE mov [ebp-8], 1 ;區域性變數的定義
25: intnTwo = 2;
0042D8F5 mov [ebp-14], 2 ;區域性變數的定義
26:
27: scanf("%d,%d", &nOne, &nTwo);
0042D8FC lea eax, [ebp-14] ;取區域性變數的地址,下同
0042D8FF push eax
0042D900 lea ecx, [ebp+var_8]
0042D903 push ecx
0042D904 push offset "%d, %d"
0042D909 call @ILT+3005(_scanf) (429BC2h)
0042D90E add esp,0Ch
28: printf("%d%d\r\n", nOne, nTwo);
0042D90E add esp, 0Ch
0042D911 mov eax, [ebp-14]
0042D914 push eax
0042D915 mov ecx, [ebp-8]
0042D918 push ecx
0042D919 push offset "%d %d\r\n"
29: scanf("%d, %d", &g_nVariableType,&g_nVariableType1);
0042D926 push offset g_nVariableType1 (48C28Ch) ;指令中的資料即為運算元的地址,書上說是利用立即數間接定址,我認為和直接定址一樣,下同
0042D92B push offset g_nVariableType (48C288h)
0042D930 push offset string "%d" (47CC80h)
0042D935 call @ILT+3005(_scanf) (429BC2h)
0042D93A add esp,0Ch
30: printf("%d%d\r\n", g_nVariableType, g_nVariableType1);
0042D93D mov eax,dword ptr [g_nVariableType1 (48C28Ch)]
0042D942 push eax
0042D943 mov ecx,dword ptr [g_nVariableType (48C288h)]
0042D949 push ecx
0042D94A push offset string "%d\r\n"(47CC74h)
0042D94F call @ILT+4085(_printf)(429FFAh)
0042D954 add esp,0Ch
31: };棧平衡操作及環境恢復略
從上面的例子可以看到,全域性變數的記憶體分配是從低地址開始的,通俗點說就是先定義的變數在低地址,後定義的在高地址。同時這裡也可以清楚的看到區域性變數和全域性變數的不同。
2. 區域性靜態變數
全域性靜態變數的訪問及生命週期和全域性變數相同,只是限制了不能從其他檔案訪問該變數,因此這裡不做介紹。區域性靜態變數的生命週期和全域性變數相同,並且都是在編譯連結時就寫入檔案之中,但是其作用域確是和區域性變數相同。事實上,區域性靜態變數剛開始是作為全域性變數處理,而它的初始化僅僅是對它進行賦值操作,但是編譯器是如何做到在多次執行函式時賦值操作只進行一次呢?下面通過一個例子來了解(Debug版本):
Main函式:
30: void main()
31: {;先前程式碼略
32: ;區域性靜態變數被初始化為常量,下面會有詳細介紹原因
33: staticint g_snOne = 1;
34: printf("%d \r\n", g_snOne);
004272BE mov eax,dword ptr [g_snOne (480008h)]
004272C3 push eax
004272C4 push offset string "%d \r\n" (471C6Ch)
004272C9 call @ILT+3875(_printf) (425F28h)
004272CE add esp,8
35: ;多次對區域性靜態變數初始化
36: for (int i = 0; i < 5; i++)
004272D1 mov dword ptr [i],0
004272D8 jmp main+43h (4272E3h)
004272DA mov eax,dword ptr [i]
004272DD add eax,1
004272E0 mov dword ptr [i],eax
004272E3 cmp dword ptr [i],5
004272E7 jge main+57h (4272F7h)
37: {
38: ShowStatic(i);
004272E9 mov eax,dword ptr [i]
004272EC push eax
004272ED call ShowStatic (42571Ch)
004272F2 add esp,4
39: }
004272F5 jmp main+3Ah (4272DAh)
40:};儲存環境及棧平衡操作略
下面是被呼叫的ShowStatic函式:
11: void ShowStatic(int nNumber)
12: {;先前程式碼略
13: static int g_snNumber1 = nNumber;
0042721E mov eax,dword ptr [$S1 (481328h)] ; 0x00481328空間儲存的就是g_snNumber1
;判斷標誌中的第一位是否為1,若為1則表明已經被初始化,無需再次進行初始化
00427223 and eax,1
00427226 jne ShowStatic+3Dh (42723Dh)
00427228 mov eax,dword ptr [$S1 (481328h)]
0042722D or eax,1 ;若標誌位不為1則置1
00427230 mov dword ptr [$S1 (481328h)],eax ;將標誌位元組寫回記憶體
00427235 mov eax,dword ptr [nNumber]
00427238 mov dword ptr [g_snNumber1 (481324h)],eax ;變數賦值
14: staticint g_snNumber2 = nNumber;
;這個區域性靜態變數的賦值操作和上面的一樣
0042723D mov eax,dword ptr [$S1 (481328h)]
;判斷標誌中的第二位是否為1,若為1則表明已經被初始化,無需再次進行初始化
00427242 and eax,2
00427245 jne ShowStatic+5Ch (42725Ch)
00427247 mov eax,dword ptr [$S1 (481328h)]
0042724C or eax,2 ;若第二位標誌位不為1則置1
0042724F mov dword ptr [$S1 (481328h)],eax
00427254 mov eax,dword ptr [nNumber]
00427257 mov dword ptr [g_snNumber2 (481320h)],eax
15: printf("%d \r\n", g_snNumber1);
0042725C mov eax,dword ptr [g_snNumber1(481324h)]
00427261 push eax
00427262 push offset string "%d \r\n" (471C6Ch)
00427267 call @ILT+3875(_printf) (425F28h)
0042726C add esp,8
16: printf("%d \r\n", g_snNumber2);
0042726F mov eax,dword ptr [g_snNumber2(481320h)]
00427274 push eax
00427275 push offset string "%d \r\n" (471C6Ch)
0042727A call @ILT+3875(_printf) (425F28h)
0042727F add esp,8
17: };後續程式碼略
為了區分全域性變數和靜態區域性變數,這裡採用了一個標誌位元組,由於有8位,故最多可表示8個靜態區域性變數的初始化狀態,並且這個標誌位一般都在最先定義的區域性靜態變數附近。如果變數超過了8個,那麼再定義一個標誌位元組,這個位元組一般在第9個變數的附近。在兩個變數都定義完成之後可以檢視記憶體中的標誌位元組0x00481328 03,可以很清楚的看到變成了03,也就是0x00000011,表示前兩個靜態區域性變數均已被初始化。
現在再來看main函式中的靜態區域性變數為何會變成全域性變數。用WinHex十六進位制編輯器開啟相應的.obj檔案,可以在最後找到如下資料片段:
可以看到有g_snOne字串,但是又有點不同,這裡是經過名稱粉碎之後的名字,以此來確定變數的作用域不會超出其範圍。
3. 堆變數
C/C++中使用malloc/free或new/delete來分配和釋放堆空間。在申請空間時會返回堆空間的首地址,若堆空間沒有得到及時地釋放,則會造成記憶體洩漏。只要在程式的反彙編程式碼中發現有如下特點的程式碼,那麼就很容易識別出堆變數:
.text:004282FE push 0Ah
.text:00428300 call j_malloc
.text:00428305 add esp, 4
.text:00428308 mov [ebp+var_8], eax
以及
.text:0042831B push 0Ah
.text:0042831D call j_operator_new
等,delete和free的呼叫也是如此。當然也要注意他們申請和釋放的地址必須對應起來看,否則會判斷錯誤。
瞭解了堆變數的申請及銷燬,再來看看編譯器是如何管理堆空間的。堆結構的每一次分配形成一個結點,每個節點都是使用雙向連結串列儲存的,結點的資料結構如下定義:
typedef struct _CrtMemBlockHeader
{
struct_CrtMemBlockHeader * pBlockHeaderNext;
struct_CrtMemBlockHeader * pBlockHeaderPrev;
char* szFileName;
int nLine;
#ifdef _WIN64
/* Theseitems are reversed on Win64 to eliminate gaps in the struct
* and ensure that sizeof(struct)%16 ==0, so 16-byte alignment is
* maintained in the debug heap.
*/
int nBlockUse;
size_t nDataSize;
#else /* _WIN64 */
size_t nDataSize;//堆空間的資料大小
int nBlockUse;
#endif /* _WIN64 */
long lRequest;//堆空間的申請次數
unsignedchar gap[nNoMansLandSize];//堆空間資料,第一個資料的指標就是//申請的變數的指標
/* followedby:
* unsigned char data[nDataSize];
* unsigned char anotherGap[nNoMansLandSize];
*/
}_CrtMemBlockHeader;
如上面的結構所示,#ifdef後面的是Win64程式的定義,此處用不到。#else後面定義的是用到的資料項。pBlockHeaderNext和pBlockHeaderPrev分別指向這個結點的後繼結點(指向前一次申請的堆空間)和前導結點(指向後一次申請的堆空間)。下面就來看看記憶體中的一個堆結點:
char * pCharMalloc = (char*)malloc(10);語句分配了10個char型別的空間給pCharMalloc指標。
在0x00392A10處發現10個為0的位元組,這就是分配給pCharMalloc的10個char空間。這段空間的前後都有0xfdfdfdfd資料,這是在Debug版本下的越界檢查標誌。往前四個位元組是0x0000002d表示的是堆的申請次數,0x00392A00處的資料是堆空間的大小,這裡是10所以為0x0a,0x003929F0後面的兩個資料則是前面說的兩個儲存指標的變數的值。空間釋放後,這個結點所在的記憶體變成了如下狀況:
這樣一來程式下次便可以再次分配這塊空間。