【軟體開發底層知識修煉】二十 深入理解可執行程式的結構
上一篇文章記錄了GDB除錯從入門到熟練掌握的學習全過程。點選連結檢視:【軟體開發底層知識修煉】十九 GDB除錯從入門到熟練掌握超級詳細實戰教程學習目錄
- 還記得在以前的學習Binutils工具的時候,學習了很多工具來檢視可執行程式的結構,那個時候並沒有詳細說明程式的結構,今天就來學。下面是之前學習的Binutils工具集的幾篇文章,可以參考學習:
- 【軟體開發底層知識修煉】六 Binutils輔助工具之- addr2line與strip工具
- 【軟體開發底層知識修煉】七 Binutils輔助工具之- ar工具與nm工具
- 【軟體開發底層知識修煉】八 Binutils輔助工具之- objdump工具 與 size,strings工具
本篇文章開始學習可執行程式的結構。也就是我們平時說的可執行檔案的結構。本文不會像《程式設計師的自我修養》那樣詳細解釋可執行檔案的每一個細節,我希望通過這次學習能夠對程式的結構有一個永久性的認知。當然,還是建議要把《程式設計師的自我修養》仔細閱讀完。
文章目錄
1 程式是由不同的段構成的
- 至於什麼是段,如果看了《X86組合語言-從真實模式到保護模式》應該會非常清楚。段不過就是一段記憶體結構。把類似的指令放到連續的記憶體區域就是程式碼段(.text段),把初始化了的資料放到連續的記憶體區域就是資料段(.data段),把未初始化的資料放在一起就是(.bss段)
- 程式的靜態特徵是指令和資料:實際上就是一堆二進位制放在那裡(磁碟)不動。
- 程式的動態特徵就是執行指令來處理資料:實際上就是把磁碟上的二進位制檔案載入到記憶體,讓指令來處理資料。
那麼你用C語言寫一個程式碼,它與可執行檔案的內部結構是如何對應的?
- 程式碼段(.text):
- 原始碼中的可執行語句編譯後進入程式碼段
- 程式碼段在記憶體管理單元的系統屬性中具有只讀屬性,它是不可寫的。
- 程式碼段的大小在編譯結束後,大小就直接確定了,執行的過程中不會被改變
- 程式碼段中可以包含常量資料,(如字串常量)
資料段(.data , .bss, .rodata)
資料段中用於存放原始碼中具有全域性生命週期的變數。不具有全域性生命期的區域性非靜態變數不在資料段中,而是在棧中。棧後面會講。
.bss
- .bss是儲存未初始化的變數。或者說初始化為0的變數
.data
- .data儲存的是具有非0初始值的變數
.rodata
- .rodata儲存的是const修飾的變數
為什麼同是全域性變數和靜態區域性變數,為什麼初始化的和未初始化的變數放在不同的段中?
可以這樣想,有初始化值的變數在可執行檔案中就直接將它的值儲存到檔案中,載入到記憶體的時候,直接將變數對應的值也載入到記憶體中。而未初始化的變數(或者本來就賦值為0的變數),在可執行檔案中不用儲存初始值,這減小了可執行檔案的體積,將其載入到記憶體中時啥也不管直接全部賦值為0,這也也可以提高載入的效率。總結來說就是以下兩點:
.bss段在可執行檔案中不賦初值。在載入到記憶體中時直接全部初始化為0。這樣減少了可執行檔案的體積也提高額載入的效率
.data段中,在可執行檔案中直接將變數對應的初始值儲存,載入到記憶體中時直接將檔案中對應的值載入到記憶體中即可。這也提高了程式的載入效率。
- 檔案頭(File header)
檔案頭並不是今天的重點。簡單來說檔案頭中儲存了程式的各個段的資訊,作業系統載入程式的時候,首先要讀取這個檔案頭,先計算出各個段的大小,才能從磁碟中準確的讀取相應的執行與資料。
並且在檔案頭中也記錄了類似於符號,符號變之類的資訊。這些不再多講。
1.1 程式碼示例
下面我們寫一個程式碼來使用一些具體的工具,檢視各個段。
test.c
char g_no_val; // .bss 1byte
int g_value=1; //.data 4byte
char g_str[]="D.T.SoftWare_lyy"; // .data 17byte
const int g_const=3; //.rodata 4byte
int dt_main(){
static char c_no_value; // .bss 1byte
static int c_value=2; // .data 4byte
return 0;
}
- 可以看到上述程式沒有main函式,但是我們可以指定dt_main()函式為入口函式。使用以下方式進行編譯:
- gcc -e dt_main -nostartfiles test.c -o test.out
- 得到可執行程式碼檔案test.out,使用下面的命令檢視它的各個段的資訊:
- objdump -h test.out
- 由上圖可以看到各個段的大小,起始段的地址等
- .data段大小0x1c=28位元組:我們由上述程式碼可以看到,.data段的變數一共是25位元組,由於對其,最終是28位元組,也就是16進位制的1c。.data段的起始地址是:08049ff4
- .bss段大小0x4=4位元組:這個很明顯。.bss段的起始地址是:0804a010
- .rodata段大小:4位元組:這個也很明顯。起始地址為:0804819c
- 使用nm test.out 檢視各個符號的屬性:
- 因為符號g_const所在的.rodata段只有它一個,所以它的地址自然就是.rodata段的起始地址。如上圖
其他的資訊也很容易看懂。這裡不再贅述。
- 我們還可以使用命令:objdump -s -j .rodata test.out 檢視某一個段的資訊:
2 程式中的棧結構
在最開始的那張圖:
我們始終沒有說a,b這兩個變數在哪裡。它們不在上述的那些段中。當然,它們肯定也是在某一塊記憶體中的。這塊記憶體,我們叫做:棧。
對於棧,我們這也不說特別詳細值說明棧的基本用處。
- 棧的本質是一塊連續的記憶體結構。它與資料結構中的棧不是一個概念,但是操作很像。
- 其中SP暫存器(當然這是最基本的16位的,32位的叫ESP)存的是棧頂的指標。它用於棧的入棧操作與出棧操作。
- 棧的增長方向一般是向下。這與下面即將要說的堆記憶體正好相反。
棧一般有什麼用處呢?
除了儲存類似於上述的a,b這種區域性變數以外。還有以下幾種用途。
- 中斷髮生時,棧用於儲存一些暫存器的值。
- 函式呼叫時,棧用於儲存函式的上下文資訊(活動記錄,依然是一些暫存器的值等)
- 併發程式設計時,每一個執行緒都有一個自己獨立的棧。(說是獨立有點不恰當,其實它們都是在一個大的獨立的程序空間中。)
在本文,就不打算再詳細說棧這種結構與作用。可以參考《程式設計師的自我修養》
知道了棧結構,理應還要知道堆結構。它們總是放到一起做對比。
2.2 堆(Heap)的簡要概述
在這裡我們知道堆是用於以下用途即可
- 堆,是一片閒置的記憶體空間,用於程式在執行的時候動態分配的
- 堆空間的分配需要函式的支援,比如malloc。C++的new關鍵字底層也是呼叫相應的函式
- 堆空間的使用後需要顯示的釋放(棧就不需要),一般是用free。C++中為delete。不過更加高階的語言具有垃圾回收機制
3 記憶體對映段(mmap)
上述學習了各個段以及堆結構與棧結構。
還有一種記憶體段,叫做記憶體對映段。它是用來做什麼的呢?
如果瞭解動態連結的過程,應該知道如果程式載入時需要動態連結相關動態庫的話,作業系統核心會將相應的動態庫的檔案直接對映到記憶體中。對映的位置可以稱為記憶體對映段。
還有一種情況是如果想要讀取一個檔案的內容,一點一點的讀,開銷總是相當大。如果作業系統核心直接將檔案對映到某一塊記憶體,再來讀取檔案的內容,將會塊的多。
還有一種是程式執行時可以建立匿名對映區來存放程式資料。什麼是匿名對映區?比如一個程式生產了很多資料,要將它最終存到一個檔案中。那麼不可能說生產一個數據就存放到檔案中,更加高效的做法是,在記憶體中建立一個賦值全為0的區域,將生產的資料暫時先存放到這裡,生產完或者生產了足夠多的資料後再將資料寫到磁碟上的檔案。這就是匿名對映區。畢竟磁碟的讀寫都是以扇區為單位,一個扇區大小為512位元組,一次寫多一點資料總是比你一次寫1位元組的資料更加高效。
-
有一點要明白,將檔案的內容對映到記憶體,實際上是對映到程序的虛擬地址空間,而且,對映的過程,是沒有資料的遷移的,也就是沒有資料的拷貝。
-
其實上面我們並沒有說明白為什麼將檔案對映到記憶體後再讀寫會快一些。
-
如果使用正常的read函式進行讀檔案,是需要兩次的資料拷貝:一次是核心從磁碟將檔案的內容拷貝到核心地址空間,然後再從核心地址空間拷貝到使用者地址空。這裡進行了兩次資料的拷貝。開銷比較大
但是如果是使用記憶體映段的話,就不一樣了
如下圖:
- 首先將硬碟上的檔案資料從邏輯上對映到記憶體中,這沒有資料拷貝,零耗時。
- 當用戶程式讀資料的時候,從虛擬地址空間讀,通過缺頁中斷進行檔案資料的實際載入(這裡就是真正的資料的拷貝,從檔案中拷貝到真實的實體記憶體)
- 這裡要注意一點:對映後的記憶體(虛擬記憶體)的讀寫,就是對檔案資料的讀寫。
4 總結
其實這些內容以前都見過學過。下面給一個大的程序的虛擬地址空間的記憶體分配圖:
- 當然在Linux系統中核心與使用者空間的比例是1:3,但是在windows系統中就是2:2了.
本文章參考狄泰軟體學院相關課程 想學習的可以加狄泰軟體學院群, 群聊號碼:199546072
學習探討加個人(可以免費幫忙下載CSDN資源):
qq:1126137994
微信:liu1126137994
學習交流資源分享qq群:962535112