1. 程式人生 > >作業系統_有關一個hello world程式誕生到消亡的幾個開放性問題

作業系統_有關一個hello world程式誕生到消亡的幾個開放性問題

作業系統中hello world程式從誕生到消亡的開放性問題

關鍵詞: ELF格式; 編譯連結過程; 可執行檔案的格式; 可執行程式的載入; 可執行程式的開始執行; hello world在記憶體中的映象; 定址; 排程程式; 記憶體管理; 系統呼叫; hello world程式解除安裝;

預備問題

什麼是ELF格式(編譯器)

開放性問題1

hello world的編譯連結過程和hello world上可執行檔案的格式、hello world可執行程式的載入以及如何開始執行

開放性問題2

hello world在記憶體中的映象

補充問題

  • 定址

  • 排程程式

  • 記憶體管理

  • 系統呼叫

  • hello world程式解除安裝

預備問題

以下是百度百科中對什麼是ELF檔案的定義

ELF(Executable and Linking Format)是一種物件檔案的格式,是Linux的主要可執行檔案格式。

ELF檔案由4部分組成,分別是ELF頭(ELF header)、程式頭表(Program header table)、節(Section)和節頭表(Section header table)。實際上,一個檔案中不一定包含全部內容,而且他們的位置也未必如同所示這樣安排,只有ELF頭的位置是固定的,其餘各部分的位置、大小等資訊由ELF頭中的各項值來決定。

當然這只是百度百科,娛樂一下就夠了。那麼具體什麼是ELF格式我會在後文中引出。(Copyright © http://blog.csdn.net/s_gy_zetrov. All Rights Reserved)

我們都知道,以C語言為例,一段完整的程式,要經過預處理-編譯-彙編-連結後才能稱為可讓cpu執行的檔案,這個過程中用到的“工具”分別叫做preprocessor, compiler, assembler, linker。這裡我並沒有使用他們的中文譯名,我也不打算用,使用英文原文是最準確的。當然後面我有可能會用到類似“編譯器”這種中文說法,對應的英文自行腦補。

編譯過程.png

使用gcc編譯鏈的話,我們會在整個編譯過程中看到很多不同字尾名的檔案,下表1是一個概覽:

                        表1 編譯過程中不同字尾名檔案含義概覽

suffix(file extension) description
hello.c 需要被預處理的C原始碼
hello.i 不應被預處理的C原始碼
hello.ii 不應被預處理的C++原始碼
hello.h 標頭檔案,不應被編譯或連結
hello.cc
hello.cp
hello.cxx
hello.cpp
hello.c++
hello.C
需要被預處理的C++原始碼
hello.s 彙編碼
hello.S 需要被預處理的彙編碼
hello.o 預設為物件檔案,檔名為將 .c, .i, .s etc替換為.o後得到

在原始碼被彙編後會生成一個目標檔案【Object files (e.g. .o, .obj)】,接著linker會為obj檔案連結動態庫,最終生成可執行檔案。

obj檔案和可執行檔案有很多種形式,這裡就包括COFF (Common Object-File Format) 和我要說的ELF檔案 (Executable and Linking Format) ,最直觀的區別就是ELF通常應用於Linux中而COFF則通常應用於Windows中。

下面列舉一些常見的object file format(見表2)

                        表2 常見的object file format簡述「摘自ref. [1]」

Object File Format description
xxxx.out The ‘.out’ format is the original file format for Unix. It consists of three sections: text, data, and bss, which are for program code, initialized data, and uninitialized data, respectively. This format is so simple that it doesn’t have any reserved place for debugging information. The only debugging format for a.out is stabs, which is encoded as a set of normal symbols with distinctive attributes.
COFF The COFF (Common Object File Format) format was introduced with System V Release 3 (SVR3) Unix. COFF files may have multiple sections, each prefixed by a header. The number of sections is limited. The COFF specification includes support for debugging but the debugging information was limited. There is no file extension for this format.
ECOFF A variant of COFF. ECOFF is an Extended COFF originally introduced for Mips and Alpha workstations.
XCOFF The IBM RS/6000 running AIX uses an object file format called XCOFF (eXtended COFF). The COFF sections, symbols, and line numbers are used, but debugging symbols are dbx-style stabs whose strings are located in the .debug section (rather than the string table). The default name for an XCOFF executable file is a.out.
PE Windows 9x and NT use the PE (Portable Executable) format for their executables. PE is basically COFF with additional headers. The extension normally .exe.
ELF The ELF (Executable and Linking Format) format came with System V Release 4 (SVR4) Unix. ELF is similar to COFF in being organized into a number of sections, but it removes many of COFF’s limitations. ELF used on most modern Unix systems, including GNU/Linux, Solaris and Irix. Also used on many embedded systems.
SOM/ESOM SOM (System Object Module) and ESOM (Extended SOM) is HP’s object file and debug format (not to be confused with IBM’s SOM, which is a cross-language Application Binary Interface - ABI).

那麼這裡就自然引出了什麼是ELF檔案,我總結一下

UNIX平臺下的三種可執行檔案中,a.out檔案格式非常緊湊,只包含了程式執行所必須的資訊(文字、資料、BSS),而且每個section的順序是固定的。coff檔案格式雖然引入了一個節區表以支援更多節區資訊,從而提高了可擴充套件性,但是這種檔案格式的重定位在連結時就已經完成,因此不支援動態連結(不過XCOFF支援)。elf檔案格式不僅動態連結,而且有很好的擴充套件性。它可以描述可重定位檔案、可執行檔案和可共享檔案(動態連結庫)三類檔案。elf格式檔案在整個編譯連結過程的最後部分生成,是一種特殊的檔案格式。最初在Unix的SVR4版本被最先引入,與它的前輩COFF格式作用一樣,是一種通用於表示「可執行檔案」、「目標檔案」、「庫」、「核心轉儲」的檔案格式。與COFF一樣,使用分割槽和表頭來劃分整個檔案的各個區域,但與COFF的區別之處在於去除了很多COFF的限制,比如debug資訊限制等。目前ELF廣泛用於Unix和Linux系統中。

預備問題和開放性問題1中的「hello world的編譯連結過程和hello world上可執行檔案的格式」部分現已解決,接下來解決「hello world可執行程式的載入以及如何開始執行」這部分問題

  • 在系統軟體層面:

    對於hello world可執行程式的載入,首先系統會建立一個新的程序(類似fork()函式),然後緊接著,這個新的程序一般會用來呼叫execve()系統呼叫執行指定的ELF檔案,即當進入execve()系統呼叫之後,就開始了可執行程式的載入。

    載入.png

    上面綠色的步驟為裝載檔案的主要函式,其主要步驟為:

    1. 檢查ELF可執行檔案格式的有效性,比如摩數、程式頭表中段的數量。
    2. 需找動態連結的“.interp”段,設定動態聯結器路徑
    3. 根據ELF可執行檔案的程式頭表的描述,對ELF檔案進行對映,比如程式碼,資料,只讀資料
    4. 初始化ELF程序環境,比如程序啟動時EDX暫存器的地址應該是DT_FINI的地址
    5. 將系統呼叫的返回地址修改成ELF可執行檔案的入口點,這個入口點取決於程式的連線方式,對於靜態連結的ELF可執行檔案,這個程式入口就是ELF檔案的檔案頭中e_entry所指的地址:對於動態連結的ELF可執行檔案,程式入口點是動態聯結器
    6. 當load_elf_binary()執行完畢,返回至do_execve()再返回至sys_execve()時,上面第5步已經把系統呼叫的返回地址改成了被裝載的ELF程式的入口地址。所以當sys_+execve()系統呼叫從核心態返回到使用者態時,EIP暫存器直接跳轉到ELF程式的入口地址,於是新的程式開始執行,ELF可執行檔案裝載完成。
    7. 開始執行可執行檔案,從此步開始就轉到機器碼的執行了。
  • 在系統硬體層面

    Hello程式被啟動後,計算機的動作過程如下:

    1. Shell程式讀取字串“./hello”中各字元到暫存器,然後存放到主存;
    2. “Enter”鍵輸入後,作業系統核心(載入程式)根據主存中的字串“hello”到磁碟上找到特定的hello目標檔案,將其包含的指令程式碼和資料(“hello, world\n”)從磁碟讀到主存,並將控制權轉交給hello程式,即將hello程式的第一條指令的地址送到PC中;
    3. 處理器從hello主程式的指令程式碼開始執行;
    4. Hello程式將“hello, world\n”串中的位元組從主存讀到暫存器,再從暫存器輸出到顯示器上。(Copyright © http://blog.csdn.net/s_gy_zetrov. All Rights Reserved)

接下來解決開放性問題2–hello world在記憶體中的映象

注:映象和映像都是Image的翻譯。

程式被執行時, 作業系統將可執行模組拷貝到記憶體的程式映像(program image)中去.

首先來看可執行程式和記憶體映像的區別:
1. 可執行程式位於磁碟中而記憶體映像位於記憶體中;
2. 可執行程式沒有堆疊,因為程式被載入到記憶體中才會分配堆疊;
3. 可執行程式雖然也有未初始化資料段但它並不被儲存在位於硬碟中的可執行檔案中;
4. 可執行程式是靜態的、不變的,而記憶體映像隨著程式的執行是在動態變化的,資料段隨著程式的執行要儲存新的變數值,棧在函式呼叫時也是不斷變化中。

Linux作業系統在載入程式時,將程式所使用的記憶體的程式映像分為5段:text(程式段)、data(資料段)、bss(bss資料段)、heap(堆)、stack(棧)。

  • text segment(程式段)

    text segment用於存放程式指令本身,Linux在執行程式時,要把這個程式的程式碼載入進記憶體,放入text segment。程式段記憶體位於整個程式所佔記憶體的最上方,並且長度固定(因為程式碼需要多少記憶體給放進去,作業系統是清楚的)。

  • data segment(資料段)

    data segment用於存放已經在程式碼中賦值的全域性變數和靜態變數。因為這類變數的資料型別(需要的記憶體大小)和其數值都已在程式碼中確定,因此,data segment緊挨著text segment,並且長度固定(這塊需要多少記憶體也已經事先知道了)。

    與bss相比,data就容易明白多了,它的名字就暗示著裡面存放著資料。當然,如果資料全是零,為了優化考慮,編譯器把它當作bss處理。通俗的說,data指那些初始化過(非零)的非const的全域性變數。特點是data型別的全域性變數是即佔檔案空間,又佔用執行時記憶體空間的。

  • bss segment(bss資料段)

    bss segment用於存放未賦值的全域性變數和靜態變數。這塊挨著data segment,長度固定。

    bss是指那些沒有初始化的和初始化為0的全域性變數

    bss型別的全域性變數只佔執行時的記憶體空間,而不佔檔案空間。(Copyright © http://blog.csdn.net/s_gy_zetrov. All Rights Reserved)

  • heap(堆)

    這塊記憶體用於存放程式所需的動態記憶體空間,比如使用malloc函式請求記憶體空間,就是從heap裡面取。這塊記憶體挨著bss,長度不確定。

  • stack(棧)

    stack用於存放區域性變數,但不包括static宣告的變數,static意味著在資料段中存放變數。當程式呼叫某個函式(包括main函式)時,這個函式內部的一些變數的數值入棧,函式呼叫完成返回後,區域性變數的數值就沒有用了,因此出棧,把記憶體讓出來給另一個函式的變數使用(程式在執行時,總是會在某一個函式呼叫裡面)。 可以把堆疊看成一個寄存、交換臨時資料的記憶體區。

可執行檔案在記憶體映像中有如下的佈局.png

正在執行的程式例項被稱為程序: 當作業系統向核心資料結構中添加了適當的資訊, 併為執行程式程式碼分配了必要的資源之後, 程式就變成了程序(記憶體映像/映象已經成為了Process)。 這裡所說的資源就包括分配給程序的地址空間和至少一個被稱為執行緒(thread)的控制流。

程序.png

補充問題

先看一個hello world程式:

#include <stdio.h> 
int main()  
{  
    printf("hello world.\n");  
    return 0;   
} 

將它轉化為彙編程式碼(只保留了其中的關鍵操作):

// 每一個程式的入口地址mainCRTStartup  
mainCRTStartup:  
00401120   push        ebp  
00401121   mov         ebp,esp  
// …  
// 呼叫GetVersion()函式,獲得作業系統版本  
00401146   call        dword ptr [__imp__GetVersion@0 (0042513c)]  
// …  
// 進行堆的初始化  
0040119E   call        _heap_init (00403c40)  
// …  
// 向程式傳遞引數  
004011D5   call        _setargv (004031a0)  
004011DA   call        _setenvp (00403050)  
004011DF   call        _cinit (00402c70)  
// …  
// 開始進入main函式的入口地址執行main函式  
00401204   call        @ILT+0(_main) (00401005)  
// …  
// 退出整個程式的執行  
00401213   call        exit (00402cb0)  
// …  

當我們啟動一個程式後,作業系統會建立一個新的程序來執行這個程式。作業系統建立程序的時候,會為其分配一定的記憶體空間(預設堆),作為其私有的虛擬地址空間。但是,作為程式執行的排程者,它並不負責程式的執行,具體的執行工作是由它建立的執行緒來完成的。每個程序都有一個主執行緒,如果是多執行緒應用程式,還可以有多個輔助執行緒。執行緒並不擁有資源(它使用的是它所屬程序的資源),但是它擁有自己的執行入口、執行的順序系列和一個終點。(Copyright © http://blog.csdn.net/s_gy_zetrov. All Rights Reserved)

當程序的主執行緒被建立之後,它會首先尋找程式當中的入口地址。我們知道,程式實質上就是一系列計算機指令,程式的入口地址代表了從哪一條指令開始執行。通常,每個程式中都有mainCRTStartup這樣一個地址,這個預設的入口函式地址是編譯器插入到程式中的(其中完成了一些必要的初始化和清理工作)。主執行緒就是找到這個地址並從這裡開始向下逐條執行程式當中的指令。在這個函式執行的時候,首先會執行一些初始化工作,例如獲得作業系統的資訊、對堆進行初始化以及完成程式引數的傳遞等等。然後,就是最關鍵的對主函式的呼叫,一句”call @ILT+0(_main)”就是跳轉到main()函式的入口地址,開始進入main()函式的執行了。mainCRTStartup所做的事情我們無法控制,而main()函式是我們編寫的。接下來看main()函式到底是如何執行的。

// main()函式入口地址  
@ILT+0(_main):  
00401005   jmp        main (00401010)  
// …  
1:    #include <stdio.h> 
2:    int main()  
3:    {  
00401010   push        ebp  
00401011   mov         ebp,esp  
00401013   sub         esp,40h  
00401016   push        ebx  
00401017   push        esi  
00401018   push        edi  
00401019   lea         edi,[ebp-40h]  
0040101C   mov         ecx,10h  
00401021   mov         eax,0CCCCCCCCh  
00401026   rep stos    dword ptr [edi]  
4:        printf("hello world. \n");  
00401028   push        offset string "hello world.\n" (00420f7c)  
0040102D   call        printf (00401060)  
00401032   add         esp,4  
5:  
6:        return 0;  
00401035   xor         eax,eax  
7:    }  
00401037   pop         edi  
00401038   pop         esi  
00401039   pop         ebx  
0040103A   add         esp,40h  
0040103D   cmp         ebp,esp  
0040103F   call        __chkesp (004010e0)  
00401044   mov         esp,ebp  
00401046   pop         ebp  
00401047   ret  
--- No source file  ----  

從彙編程式碼中我們可以看到,主函式的執行,也不過是對於一些暫存器的操作和對庫函式的呼叫而已。

在main()函式的第一句就是用”push ebp”儲存當前地址。在彙編程式碼中,ebp代表了當前地址。我們從C語言程式程式碼中看到的只是我們對於要實現的功能的描述,而真正地要實現這些功能,C語言程式背後所對應的彙編程式碼還要為我們完成很多事情。這裡的”push ebp”儲存當前地址,就是為了讓這個main()函式執行完畢後可以順利返回(也就相當於在出發的地方插上一個標籤,好讓我們可以找到回來時的路)。

除了對於暫存器的操作(push、move以及pop等彙編指令)之外,彙編程式碼中更重要的是對其他函式的呼叫,這都是通過call指令來實現的。例如,”call printf (00401060)”這個call指令就是呼叫printf函式,進入printf函式的執行以輸出字串。因為printf函式是由C語言函式庫提供的一個函式,我們這裡看不到它的具體程式碼,但是其內部與上面的main()函式都是相似的。

程式語言的一個最重要的功能是提供管理記憶體和儲存在記憶體中的物件的工具。 C提供了三種不同的方式來為物件分配記憶體:

  • 靜態記憶體分配:在編譯時在二進位制檔案中提供物件的空間; 只要包含它們的二進位制檔案被載入到記憶體中,這些物件就具有相應的生存期。
  • 自動記憶體分配:臨時物件可以儲存在堆疊中,並且在宣告它的塊被退出之後,這個空間被自動釋放和重用。
  • 動態記憶體分配:可以在執行時使用庫函式(如malloc從稱為堆的記憶體區域請求任意大小的記憶體塊; 這些塊會一直保持直到後面通過呼叫庫函式reallocfree來釋放,以供重用。

如果我們能通過呼叫庫函式來實現記憶體管理,就自然產生了另一個問題,那就是記憶體洩露。很多時候我們都沒有意識到記憶體洩露了,當然這與不好的程式設計習慣和程式設計思維的欠缺有關,但更多的時候,我們編寫的一些使用malloc函式的簡單的程式就算沒有free也不會出現什麼問題,因為作業系統幫我們做好了記憶體回收。這也就引出了最後一個問題:hello world程式解除安裝

所謂的程式解除安裝,就是作業系統對一個程式佔用空間的清理。當我們正常退出程式或異常退出程式時,作業系統總可以正確進行程式解除安裝,這是如何做到的呢?答案是C語言中的exit函式和return函式

exit函式和return函式的主要區別是:

  • exit用於在程式執行的過程中隨時結束程式,exit的引數是返回給OS的。main函式結束時也會隱式地呼叫exit函式。exit函式執行時首先會執行由atexit()函式登記的函式,然後會做一些自身的清理工作,同時重新整理所有輸出流、關閉所有開啟的流並且關閉通過標準I/O函式tmpfile()建立的臨時檔案。exit是結束一個程序,它將刪除程序使用的記憶體空間,同時把錯誤資訊返回父程序;而return是返回函式值並退出函式。

    • 通常情況:exit(0)表示程式正常, exit(1)和exit(-1)表示程式異常退出,exit(2)表示表示系統找不到指定的檔案。在整個程式中,只要呼叫exit就結束(當前程序或者在main時候為整個程式)。
  • return是語言級別的,它表示了呼叫堆疊的返回;return( )是當前函式返回,當然如果是在主函式main, 自然也就結束當前程序了,如果不是,那就是退回上一層呼叫。在多個程序時。如果有時要檢測上個程序是否正常退出。就要用到上個程序的返回值,依次類推。而exit是系統呼叫級別的,它表示了一個程序的結束。

  • exit函式是退出應用程式,並將應用程式的一個狀態返回給OS,這個狀態標識了應用程式的一些執行資訊。
  • 和機器和作業系統有關的一般是:0為正常退出,非0為非正常退出;

exit函式和return函式對於程序環境的影響與對程序的控制

  • exit(int n)其實就是直接退出程式,因為預設的標準程式入口為 int main(int argc, char** argv),返回值是int型的。在main()裡面,可以用return n,也能夠直接用exit(n)來做。Unix預設的正確退出是返回0,錯誤返回非0。

    理論上exit可以返回小於256的任何整數。返回的不同數值主要是給呼叫者作不同處理的。

  • 對於單獨的程序,是直接返回給作業系統的。如果是多程序,是返回給父程序的。父程序裡面呼叫waitpid()等函式得到子程序退出的狀態,以便作不同處理。根據相應的返回值來讓呼叫者作出相應的處理。總的說來, exit()就是當前程序把控制權返回給呼叫該程式的程式,括號裡的是返回值,告訴呼叫程式該程式的執行狀態。
  • 程序終止:

    C程式的終止分為兩種:正常終止和異常終止。

    • 正常終止分為:從 main 返回(return)、exit_exit_Exit、最後一個執行緒從其啟動例程返回、最後一個執行緒呼叫pthreade_exit

      exit_Exit在stdlib.h中,_exit在unistd.h中。

      exit()(或return 0)會呼叫終止處理程式和使用者空間的標準I/O清理程式(如fclose),_exit和_Exit不呼叫終止處理程式而直接由作業系統核心接管進行清理。main函式中exit(0)等價於return 0。

    • 異常終止分為:abort、SIGNAL、最後一個執行緒對取消請求做出響應。

visitor tracker
訪客追蹤外掛