深入變量的“案發”現場

深入變量的“案發”現場
 作者:楊小華
       當《絕對能夠測試你的C語言功力的幾個問題》第一次出現在CSDN首頁時,我就進入了張老師Blog。客觀上說,出的題目比較基礎,但每一題都說出一個所以然來,恐怕不是很簡單。過了幾天就貼出了《語言測試題的講解分析》,我懷着好奇的心情進去看了看。發現裏面讚揚的也有,詆譭的也有。韓愈《師說》裏面講過:聞道有先後,術業有專攻。張老師自然有他的可取之處,也有不知道的知識點。
       OK,言歸正轉,現在開始對試題中的第二題進行探討。題目如下:
int x=35;
char str[10];
strcpy(str,"www.it315.org"/*13個字母*/);
//:此時xstrlen(str)的值分別是多少?
       我們先不去探討答案是多少,但我覺得這題與編譯器有關。張老師的答案也不是沒有道理,網友秦始皇的回答也有道理。肯定有人開始懷疑了,你到底在說什麼?這也對,那也對,究竟什麼是對的。好的,我們現在就開始分析。
一、     
在具體講解之前,我們先來明確棧的幾個概念:滿棧與空棧,升序棧與降序棧。
       滿棧是指棧指針指向上次寫的最後一個數據單元,而空棧的棧指針指向第一個空閒單元。一個降序棧是在內存中反向增長(就是從應用程序空間結束處開始反向增長),而升序棧在內存中正向增長。
       RISC機器使用傳統的滿降序棧(FD Full Descending)。如果使用符合X86規定的編譯器,它通常把你的棧指針設置在應用程序空間的結束處並接着使用一個滿降序棧。用來存放一個函數的局部變量、參數、返回地址和其它臨時變量的棧區域稱爲棧幀(stack frame)。(關於這部分的詳細信息請參看我另外一篇文章《通過Linux內核源碼看函數調用之前世今生》)。如圖1所示:

1棧幀佈局
二、      目標文件格式
各個系統之間,目標文件格式都不相同。第一個從貝爾實驗室誕生的UNIX系統使用的是a.out格式。System V Unix的早期版本使用的是COFF(Common Object File Format 一般目標文件格式)。Windows使用的是COFF的一種變種,叫做PE格式(Portable Executable 可移植可執行)。現代Unix――比如Linux,各種BSD ,以及Sun Solaris――使用的是Unix ELF(Executable and Linkable Format,可執行和可鏈接格式)。儘管以下的討論集中在ELF上,但不管是哪種格式,基本的概念都是相似的。
如果變量x和str是局部變量,那麼肯定是放在棧中。如果他們兩者都是全局變量,那麼x放在.data段(.data:存放已初始化的全局變量),str放在.bss段(.bss:存放未初始化的全局變量)。
一個典型的ELF可執行目標文件信息佈局如圖2所示:
2典型的ELF可執行文件格式
       每個程序都有一個運行時存儲器映像,如圖3所示:
3Linux運行時存儲器映像
       在Linux系統中,代碼段總是從地址0x08048000處開始。數據段在接下來的下一個4KB對齊的地址處。運行時堆在接下來的讀寫段之後的第一個4KB對齊的地址處,並通過malloc庫往上增長。開始於0x40000000處的段是爲共享庫保留的。用戶棧總是從地址0xbfffffff處開始,並向下增長的(向低地址方向增長)。從棧的上部開始於地址0xc0000000處的段是爲操作系統駐留存儲器的部分的代碼和數據保留的。通過這裏講解之後,你應該徹底懂得了滿降序棧的含義。
三、      尋址與字節順序
幾乎在所有的機器上,多字節對象都以連續的字節序列存放,對象的地址爲所使用的字節序列中最小的地址。比如,一個int型的變量x的地址爲0x100,也就是說&x=0x100,那麼x的四字節將被存儲在內存中的0x100,0x101,0x102和0x103。
某些機器選擇在存儲器中按照從最低有效位字節到最高有效位字節的順序存儲對象,而
另一些機器則按照從最高有效位字節到最低有效位字節的順序來存儲對象。前者我們稱爲小尾端(little-endian),比如Intel的機器都採用這種規則,後者稱爲大尾端(big-endian),如IBM,Motorola等機器。
       假設x類型爲int,地址位於0x100處,有一個16進制的值爲0x12345678,分別在大尾端和小尾端的存儲方式爲:
       大尾端:
                                 0x103                  0x102                0x101               0x100
……
78
56
34
12
……
       小尾端:
                                 0x103                  0x102                0x101               0x100
……
12
34
56
78
……
       注意,在字0x12345678中,高位字節的16進製爲0x12,而低位字節爲0x78。不管是在大尾端機器中,還是小尾端機器中,輸出的x的值都爲0x12345678。
四、      透過彙編代碼看變量存儲佈局
我們從局部變量和全局變量兩個方面來,分別在Windows下的VC++6.0和Linux下的GCC來探討這個題目。
假設程序如下:
1      #include <stdio.h>
2      #include <string.h>
3     
4      int main()
5      {
6           int x = 35;
7           char str[10];
8           strcpy(str,"www.it315.org"/*共13個字母*/);
9           printf("%d/n",x);
10             return 0;
11      }
       這段程序在VC++6.0中的反彙編代碼如下:
1:    #include <stdio.h>
2:    #include <string.h>
3:
4:    int main()
5:    {
00401010   push        ebp
00401011   mov        ebp,esp
00401013   sub         esp,50h
00401016   push        ebx
   棧幀佈局    高地址
前一個棧幀
ebp
(x=35)
 ……
……
  (str)
   ……
             低地址
 
 
00401017   push        esi
00401018   push        edi
00401019   lea          edi,[ebp-50h]
0040101C   mov         ecx,14h
00401021   mov         eax,0CCCCCCCCh
00401026   rep stos     dword ptr [edi]
6:        int x = 35;
00401028   mov         dword ptr [ebp-4],23h /*35壓進棧中*/    (1處)
7:        char str[10];
8:        strcpy(str,"www.it315.org"/*共13個字母*/);
0040102F   push        offset string "www.it315.org" (00420020)

00401034   lea          eax,[ebp-10h]    (2處)

00401037   push        eax
00401038   call         strcpy (00401100)
0040103D   add         esp,8
9:        printf("%d/n",x);
00401040   mov        ecx,dword ptr [ebp-4]    (3處)
00401043   push        ecx
00401044   push        offset string "%d/n" (0042001c)
00401049   call         printf (00401080)
0040104E   add         esp,8
10:       return 0;
00401051   xor         eax,eax
11:   }
       從以上代碼可以發現,在1處,將x的值35壓入ebp-4中,在運行2處之前,已經將字符串的值壓入棧中了,然後獲取str在棧的地址,即ebp-10h,也就是ebp-16,文中紅色箭頭所指的對方。裝載到eax寄存器中,然後也壓入棧中。衆所周知,在X86平臺上,參數的傳遞是通過棧幀來實現的,此時調用函數strcpy,將字符串的值拷貝到str的地址處。那麼此時如何存放字符串?就是問題的關鍵所在。大家可能都知道已知str字符串的地址,那麼要得到它下一個字符串的值,就是*(str+1),那麼答案就出來了。在X86平臺上,棧是往下增長的,那麼越往高處就是高地址,當進行字符串拷貝時,字符串的地址順着藍色的線朝上走。因爲該字符串長度爲13,所以覆蓋了x所在的棧中的值,最後一個字符g也就賦給了x,由於在Intel的機器中,採用的是小尾端存儲方式,所以值在棧中的佈局如圖4所示:

   棧幀佈局    高地址
前一個棧幀
ebp
(x)g
 ro.5
31ti
 .www
   ……
             低地址

        4字符串在棧中的佈局

當運行到3處時,程序將ebp-4處的值,也就是x的值壓入棧中,調用printf,所以打印出來的爲103(也就是g的值)。
       在Linux環境下,GCC編譯器似乎表現的技高一籌,得到的答案是35。下面我們來看反彙編後的代碼:
       .file "sttest.c"
       .section   .rodata
       .align 32
.LC0:
       .string     "www.it315.org"
       .string     ""
.LC1:
       .string     "%d/n"
       .text
.globl main
       .type       main, @function
main:
       pushl       %ebp
       movl       %esp, %ebp
       subl     $56, %esp
       andl      $-16, %esp
       movl       $0, %eax
       subl     %eax, %esp
       movl       $35, -12(%ebp)   /*35放到棧中,即x*/    (1處)

   棧幀佈局    高地址
前一個棧幀
ebp
 ……
 ……
 (x=35)
  ……
   ……
   ……
……
……
……
(str)
……
             低地址
 
 

       movl       $.LC0, 4(%esp)

       leal      -40(%ebp), %eax      (2處)

       movl       %eax, (%esp)

       call     strcpy
       movl       -12 (%ebp), %eax    (3處)
       movl       %eax, 4(%esp)
       movl       $.LC1, (%esp)
       call     printf
       movl       $0, %eax
       leave
       ret
       .size main, .-main
       .section   .note.GNU-stack,"",@progbits
       .ident      "GCC: (GNU) 3.3.5 (Debian 1:3.3.5-13)"
       我們從2處可以發現,str的位置在ebp-40處,取得str在棧中的地址,然後放到堆棧指針處,調用strcpy,此時我們不難發現x的地址和str的地址相差40-12=28,遠遠大於字符串的長度,所以根本不可能覆蓋x的值。如果你將字符串的長度改爲29個字符,那麼就將會覆蓋x的值。
       下面,我們來討論x和str爲全局變量的情況,也就是將6,7兩行代碼提到第3行處。
       在第二節中,我們討論過x放在.data段,str放在.bss段,從圖三中可以觀察出,讀寫段(.data,.bss)位於低地址處。對於ELF文件,一般會規定代碼段的總長度大小,低地址處是.data段,因爲.data是已經固定了的,而.bss段是在運行時纔會賦值,所以代碼段剩下的空間都是.bss的大小,注意此時.bss段的地址大於.data段的地址,所以爲.bss中的變量賦值時,根本不可能覆蓋.data段的值。把圖2倒過來看,大家就會明白了。所以,如果x和str是全局變量,str的值永遠不可能覆蓋x的值。
五、      總結
其實每一道題目後面都隱藏着很多知識,我們不能只看表面,大概差不多就行了。只要我們深究下去,可以獲得比題目本身更多的知識點。
六、      附錄
在Windows中,程序運行時存儲器映像的資料比較少,我到目前只在《編程卓越之道:深入理解計算機》一書中提及過。所以憑我的印象畫下了該圖:
5程序在Windows中運行時的存儲器映像
 
發佈了38 篇原創文章 · 獲贊 17 · 訪問量 40萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章