Linux內核分析(一)通過彙編代碼,理解程序在計算機中是如何運行的

作者:于波

   聲明:原創作品轉載請註明出處
   來源:《Linux內核分析》MOOC課程http://mooc.study.163.com/course/USTC-1000029000

    首先說一下背景,這篇博文是網易雲課堂中《Linux內核分析》課程的一個作業,要求通過分析彙編代碼,解釋清楚程序在計算機中是如何運行的;函數調用和返回的過程中,棧是如何變化的。
    這篇文章假設讀者已經有了一定的彙編語言基礎,能看懂基本的彙編指令。因此文章中並沒有從零開始介紹每個彙編執行的細節。

一、 使用的示例程序
    我將使用下面的源程序作爲分析的目標,其中的幾個常量值選擇了一些特殊的數值,是爲了讓我們在查看彙編代碼和內存時能更容易的找到他們。

  1. int g(int x)
  2. {
  3.   return x + 0x3456789A;
  4. }
  5.  
  6. int f(int x)
  7. {
  8.   return g(x);
  9. }
  10.  
  11. int main(void)
  12. {
  13.   return f(0x12345678) + 127;
  14. }

  我們的程序很簡單,main函數調用了函數f,並給函數f傳遞了一個十六進制的32位整數0x12345678,函數f調用函數g,同時也傳遞main傳入的參數,函數g將傳入的參數增加一個32位值0x3456789A 並作爲計算結果返回,最後main函數拿到函數f的返回值,給返回值增加十進制的127並返回,程序結束。

    我們將上面的代碼保存到一個C文件中,命名爲main.c.

 

二、構造彙編代碼

    上面的C程序計算過程很容易看清楚,但是隻從C程序是無法瞭解這段程序是如何在計算機上執行的,我們需要把示例的C程序編譯成彙編程序進行分析。

    gcc提供一個選項可以只執行編譯成彙編指令,而不執行鏈接: gcc -S -o main.s main.c -m32

    -S表示僅把程序翻譯成彙編代碼,不執行鏈接;-o指定輸出文件的名字,這裏的輸出指定爲main.s, -m32表示將程序編譯成32位的格式。

    然後我們就可以在main.s中得到我們的彙編代碼了,查看該文件,應該像下面這個樣子:    

 

其中以 ‘.’ 開頭的行(上圖黃色字體開頭的行),是鏈接器會使用的一些標記,對我們人工分析彙編代碼來說是沒用的,爲了排除干擾,可以全部刪除它們。可以得到下面的更加清晰的彙編代碼:
1. g:
2.        pushl   %ebp
3.        movl    %esp, %ebp
4.        movl    8(%ebp), %eax
5.        addl    $878082202, %eax
6.        popl    %ebp
7.        ret
8. f:
9.        pushl   %ebp
10.       movl    %esp, %ebp
11.       subl    $4, %esp
12.       movl    8(%ebp), %eax
13.       movl    %eax, (%esp)
14.       call    g
15.       leave
16        ret
17. main:
18.       pushl   %ebp
19.       movl    %esp, %ebp
20.       subl    $4, %esp
21.       movl    $305419896, (%esp)
22.       call    f
23.       addl    $127, %eax
24.       leave
25.       ret
    (順帶提一下的是,如果我們只拿到了別人的可執行程序,又想窺視一些程序內部的東西的時候,還可以使用objdump命令來執行反彙編操作,比如如果我們拿到一個可執行文件main, 可以執行objdump -d main > main.dump 將二進制的可執行文件反彙編,將反彙編結果存入文件main.dump中。這樣得到的彙編程序格式會和上面的略微有些差別,會加入一些加載器使用的段信息和每行程序的標號信息等,但結果也可以用來進行程序運行過程的分析。)

三、分析程序執行過程
    
從上面的彙編代碼我們可以看到,對應我們的C程序,彙編程序中可以找到三個函數,main,f和g,分別對應了C程序中定義的三個函數。下面就來詳細分析彙編代碼的執行過程。
3.1 - main函數入口
    程序都是從main函數開始執行的,因此我們的分析也從main函數開始,先來看main函數的前半部分:
  17. main:
  18.       pushl   %ebp
  19.       movl    %esp, %ebp
  20.       subl    $4, %esp
  21.       movl    $305419896, (%esp)
  22.       call    f    

    首先我們需要假設一些程序開始執行時候的初始值。這裏假設剛開始的時候,棧底指針EBP和棧頂指針ESP相等,也就是從一個全新的空棧開始。同時我們假設這個初始值是1024 (真實情況下,在X86的CPU上,棧是向小地址方向變化的,它一般開始於一個比較大的值,如0xEFFFFFFF,這裏爲了人閱讀的方便,取了十進制的1024)。
    18行,pushl指令將EBP寄存器中的32位整數值壓棧,執行完成之後,棧頂指針將下移4字節,變爲1020,同時在地址1020 ~1023的四字節內存中,保存下了EBP寄存器的值,即1024;
    19行,movl指令將ESP寄存器的值賦給EBP,即當前的棧底指針也下移了,指向了與ESP相同的位置:1020;
    20行,subl指令將ESP寄存器,也就是棧頂指針的值減掉4,所以棧頂指針ESP變成了1016;
    21行,將立即數305419896存入ESP寄存器指向的位置,也就是在棧頂保存這個立即數。
    22行,call指令調用函數f,也就是跳轉到f函數,也就是彙編指令的第9行,來繼續運行我們的程序。

    等等,這樣列舉下來,是在分析程序嗎?好像完全lost的感覺。別急,下面應該會更清楚一點。
    
    我們重新回到上面五行彙編來看,但是這次分成兩組,18到20行是一組,21和22行是一組。我們先來看21和22行。
    21行中出現了一個很奇怪的數字305419896,它是從哪裏來的呢?做一下簡單的計算就會發現,305419896剛好等於0x12345678,也就是我們的C程序13行中,調用函數f時傳入的參數值。然後緊接着我們就用call指令轉到了函數f。這下我們應該明白了,原來這兩行是在調用新的函數並給函數傳遞參數。另外讓我們仔細回憶一下call指令,他的功能是調用其他的函數,但是call指令又不同於簡單的jump,jump是跳走就走了,沒特別說明就不回來了;而call指令需要我們執行完指定的函數之後,繼續運行call指令後面的指令。爲了實現這一點,call指令需要保存他後面一條指令的地址,保存到哪裏呢?也是棧上,所以call指令執行完之後,ESP又減了4,變成了1012,同時call指令的下一條指令的地址會被保存到內存地址1012~1015這四個字節上。call指令的下一條指令地址,這裏我們可以簡單的認爲它就是彙編程序的行號:23,而實際在計算機中運行時,它會是第23行彙編指令加載到的內存的邏輯地址。
    然後我們再看18到20行,這三行在操作兩個棧指針寄存器,EBP和ESP,它首先保存了老的棧底地址EBP到棧上,然後調整了棧底地址到了和原來的棧頂同樣的位置,最後調整了棧頂指針ESP到新的位置。一般來說,每個函數都有自己的運行棧,而每個函數使用的棧的大小就由20行這樣的指令來決定,這個值是編譯器爲我們算出來的,在我們的例子中,編譯器認爲main函數使用4字節的棧就夠了,因爲只需要傳一個4字節的整數給被調用的函數f,除此之外,再沒有需要用棧的地方。
    好了,總結一下main函數的前五行彙編代碼,它其實做了兩件事情:1. 給自己準備棧空間;2. 爲被調用函數f準備參數並調用它。回頭再看看他們,是不是覺得熟悉多了。下面可以跟隨程序進入到f函數裏了。

3.2 - f 函數入口
    和main函數一樣,跟隨程序的執行過程,我們也先看f函數的前半部分:
    8. f:
  9.        pushl   %ebp
  10.       movl    %esp, %ebp
  11.       subl    $4, %esp
  12.       movl    8(%ebp), %eax
  13.       movl    %eax, (%esp)
  14.       call    g

  仔細觀察會發現,f函數的9到11行和main函數的18到20行是完全一樣的,我們已經有了上面的分析,所以這裏應該能很容易看明白,f函數的這三行也是在準備自己需要的棧空間,而且和main函數一樣,它也只需要4字節的棧空間就夠了;

   而第13,14行又和main函數的21,22行很相似,也是在給一個被調用的函數g準備傳入參數,並用call指令來調用它。唯一不同的僅僅是,它不是在棧上放一個立即數,而是把寄存器EAX的值放到了棧上,作爲傳遞給g的參數。EAX是什麼呢?是在12行裏從EBP寄存器偏移8的位置讀出來的,也就是從當前函數的棧底在往回數8個字節的位置讀取出來。而這個位置的值就是在main函數的第21行中存入的那個0x12345678.

    爲了證明這一點,我們還需要再次回顧一下從main函數的21行開始,到f函數的12行爲止,棧上又多存儲過什麼。

    首先是22行的call指令,它把call指令的下一條指令的地址壓入棧中了;然後是f函數的第9行,它把上一個函數的棧底地址保存到了棧中。所以,在每個函數中,從自己的棧底往回數8字節,也就是兩個32位整數,總是能找到上層函數傳遞給自己的第一個參數;如果函數還有更多的參數,可以分別從(%EBP)+12, (%EBP)+16 等位置取到。

    總結一下f函數前半部分,它做了三件事:1. 從9到11行,準備自己的棧空間; 2. 第12行,從棧上拿到上層函數傳遞給自己的參數;3. 第13,14行,調用函數g,並把拿到的傳入參數再傳給函數g。

3.3 - g函數   

   下面我們可以跟隨程序來到g函數了。
  1. g:
  2.        pushl   %ebp
  3.        movl    %esp, %ebp
  4.        movl    8(%ebp), %eax
  5.        addl    $878082202, %eax
  6.        popl    %ebp
  7.        ret

    函數g的2,3行又是很熟悉的樣子,和main函數跟f函數的前兩行完全一樣的,也是在準備自己的棧空間,但是這次並沒有調整自己的棧頂地址,這是因爲編譯器發現這個函數根本不需要在棧上存任何東西。
    接下來的第4行,和函數f的12行是一樣的,是取出了上層函數傳遞給自己的第一個參數,並把值放到了寄存器EAX中;然後第5行,給EAX寄存器中的值增加了878082202,簡單計算一下也可以得到,這個值等於十六進制的0x3456789A,也就是C程序第三行中的常數值。
    接下來就是第6和第7行了,先來看popl %ebp,它的執行結果就是當前的棧頂,即ESP寄存器指向的內存地址的內容,彈出並賦值給寄存器EBP,也就是棧底指針變化了,變成了一個我們之前在棧上保存的一個值,在函數g中,往上找最後一次操作棧的地方,就會發現這個彈出的值其實就是在第2行中push進去的值;同時執行完成之後,ESP的值會增加4;那麼新的棧頂上存的值是什麼呢?再往回找,應該能想起來,這個地址上放的是14行中,call指令在棧上保存的cal指令的下一條指令的地址值,而這個值應該彈出並賦值給EIP,也就是我們的程序計數器,程序才能實現在執行完14行的調用函數g之後,繼續執行第15行的功能,而這正是第7行的ret指令乾的事情,它會將當前的棧頂元素彈出到EIP寄存器中。指令執行完成之後,ESP的值會在增加4,從而指向了棧的下一個元素。
    至此,我們的寄存器的狀態應該是:EBP=棧上彈出的保存值,也就是函數f調用函數g之前的EBP值,而棧頂指針ESP彈出了兩個4四字節整數,當前的值也剛好等於函數f在調用函數g之前的ESP值;而EIP被賦值了第14行的call指令保存進棧的call指令的下一行指令的地址,也就是第15行指令的執行地址。所以我們的程序在函數f調用完函數g之後,繼續往下執行了,所有的現場數據都已經恢復成了調用g之前的值。所以接下來繼續看函數f在調用完g之後的部分。

3.4 - 函數f的後半部分
    函數f在調用完函數g之後還有這麼兩句:
  15.       leave
  16        ret
  和g函數的結尾相比,不同之處在於popl %ebp 換成了leave,其實leave指令相當於下面這兩條:
    movel %ebp, %esp
    popl %ebp
    所以,如果把leave指令展開在和函數g相比的話,就變成了返回語句中多了一條 movel %ebp, $esp. 而這條指令的作用是把當前的棧頂指針調整到棧底的位置。之所以有這樣的區別是因爲,前面說過,函數g是不需要在棧上存儲任何東西的,所以他的函數開頭部分只有兩句調整棧指針的指令:
  pushl   %ebp
  movl    %esp, %ebp
而與之區別的函數f開頭部分有三句:
  pushl   %ebp
  movl    %esp, %ebp
  subl    $4, %esp
所以,對函數發來說,要在返回之前把棧指針調整回main函數調用它時的樣子,就需要多做一步,就是把棧頂地址先調整回去,然後再把棧上保存的棧底地址彈出到EBP寄存器中,而這正是leave指令完成的工作。
    類似函數g返回到函數f的過程,我們的f函數現在也可以返回到main函數了。

3.5 - 函數main的後半部分
    下面來到main函數在調用了函數f之後的處理部分:
    23.       addl    $127, %eax
  24.       leave
  25.       ret

    第24,25行就不必多說了,和函數f的返回過程是一樣的,而23行,在EAX寄存器的值上加上了127,對應着我們的C程序的第13行的操作。而最終EAX中的值就存儲了 f(0x12345678) + 127的最終結果。爲什麼用EAX呢?因爲在X86的CPU中,函數的返回值默認就是用EAX寄存器進行傳遞的,這是X86 CPU的函數調用約定。還記得我們的函數g的第五行嗎?它在計算 傳入參數 + 0x3456789A 的時候就是把值存放在了EAX寄存器中的,然後就返回了;而函數f沒有對函數g的返回值做其他的操作,而是直接返回了函數g的計算結果,所以也就不需要額外的處理;在main函數中,訪問EAX寄存器就拿到了函數f的返回值。

四、 總結
    程序的執行過程分析完了,最後我們來對程序在32位X86 CPU上的執行過程做一下總結。
    每個函數都有自己的棧空間,由EBP寄存器指定棧底,而ESP指定棧頂,函數的開始部分會給自己把要使用的棧空間準備好,通過調整EBP和ESP的值,同時爲了能在程序執行完成之後恢復上層函數的棧,在調整EBP之前會先把老的EBP值保存到棧上。
    一個函數調用另一個函數時,使用棧來傳遞參數。在每個函數中,在調整好自己要用的棧指針之後,就可以固定的從(%EBP) + 8 的位置取得上層函數傳給自己的第一個參數,如果有更多的參數,繼續從(%EBP) 開始的更大的偏移上去獲得。
    函數執行完成之後,用EAX寄存器傳遞返回值給上層函數。
    每個函數執行完成之後,會用保存在棧上的EBP值恢複函數被調用之前的棧指針,用call指令保存在棧上指令地址值去恢復EIP寄存器值,使上層函數可以在調用完本函數之後繼續向下執行。

    基本就是這樣了,希望我解釋清楚了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章