C語言執行時資料結構
段(Segment):
物件檔案/可執行檔案:
SVr4 UNIX上被稱為ELF(起初"Extensible Linker Format", 現在"Executable and Linking Format")檔案。BSD UNIX上被稱為a.out。這些格式都具有段的概念
section是存放特定型別二進位制檔案區域,section是ELF檔案的最小組織單元,段通常由多個section組成
段主要有:
- BSS段:Block Started by Symbol,放置全域性的但是沒有初始化的變數。由於BSS段的變數沒有任何值,所以不會真的在a.out檔案中儲存,所以BSS在a.out檔案中不佔空間(除了需要指明大小需要部分外),而是在執行時申請
- text段:程式碼段,放置指令
- data段:資料段,放置全域性的,而且已經初始化的變數。區域性變數不會放到a.out中,而是在執行時建立
示例:
只有main函式:
text data bss dec hex filename
14292 1532 112 15936 3e40 Hello.exe
定義全域性未初始化int:
text data bss dec hex filename
14292 1532 116 15940 3e44 Hello.exe
定義全域性初始化int:
text data bss dec hex filename
14292 1536 112 15940 3e44 Hello.exe
定義區域性未初始化int:
text data bss dec hex filename
14292 1532 112 15936 3e40 Hello.exe
定義區域性初始化int:
text data bss dec hex filename
14308 1532 112 15952 3e50 Hello.exe
結論:
- 全域性未初始化的變數放到了BSS段
- 全域性初始化的放到了data段
- 區域性未初始化變數只意味著在執行時為其分配空間,而不會在生成可執行檔案的時候分配空間或者生成相關的語句,所以不涉及BSS\DATA\TEXT段
- 區域性初始化變數不涉及BSS和DATA段,而是生成語句執行初始化
作業系統如何處理可執行檔案:
段會生成執行時連結器可以直接載入的物件,載入器直接把每個段對應到記憶體中的一部分
這些段就成為執行中程式的一塊實際的記憶體區域
highest memory address
+------------------------+
| stack segment |
| . |
| . |
| . |
+------------------------+
| BSS segment |--未初始化的全域性變數
+------------------------+
| data segment |--初始化的全域性變數
+------------------------+
| text segment |
+------------------------+
| |
+------------------------+
lowest memory address
text段:
放置程式指令,是載入器直接從檔案中複製過來的。一般情況下,text段不會改變,某些作業系統和連結器可以給段的不同section設定不同的許可權。比如:text是隻讀和只執行的,某些data是隻讀的。某些data是讀寫但是不可執行的
data段:
包含了初始化的全域性變數和靜態變數,並進行了初始化賦值
BSS段:
在data段生成之後,載入器就從可執行檔案中獲取BSS段的大小並獲取相應的空間放到data段後面。通常BSS段和data段會合並在一起,由於段在OS記憶體管理中只是一段連續的虛擬地址空間,所以相連的會被合併。所以資料段通常是最大的段
區域性變數、臨時儲存單元、函式呼叫的引數傳遞等就需要用到分配的棧空間
對於動態分配的空間還需要堆空間,堆空間是在需要的時候建立的,在第一次malloc()函式呼叫的時候
需要注意的是虛擬地址空間的最低的一部分沒有對映到實體地址空間,所以任何對這部分地址的引用都是非法的。這部分一般是從0開始的幾個位元組空間,null指標或含有很小整數值的指標將指向這裡
如果考慮到共享庫檔案,那麼實際的對映將是這樣的:
highest memory address
+--------------------------+
| stack segment |
| . |
| . |
| . |
+--------------------------+
| linker |
+---------------------------+
| unmapped segment|
| . |
+---------------------------+
| data segment |
+---------------------------+庫檔案
| text segment |
+----------------------------+
| |
+----------------------------+
| data segment |
+----------------------------+庫檔案
| text segment |
+----------------------------+
| |
+----------------------------+
| data segment |
+-----------------------------+執行程式碼
| text segment |
+-----------------------------+
| |
+-----------------------------+
lowest memory address
C執行時如何處理可執行檔案:
C在執行時會維護很多資料結構,比如棧、活動記錄、資料、堆等等
棧段:
棧段只包含一個數據結構——棧
經典的棧定義是先進後出的佇列,只有push和pop操作。但是這裡的棧不僅可以push和pop還可以改變棧中某位置的值
執行時維護一個指標,通常位於暫存器中被稱為sp,指向棧頂
棧段的作用主要有三個,兩個關於函式,一個關於表示式計算:
- 棧為函式中的區域性變數提供儲存空間,這些變數被成為"自動變數"
- 棧儲存函式呼叫時需要的維護資訊,被稱為"程式活動記錄"。包含呼叫結束時的返回地址、不能放到暫存器中的引數和儲存呼叫前的暫存器狀態
- 棧也可以作為高速暫存暫存器,當程式需要臨時儲存的時候使用。如長表示式的計算,中間結果會被放到棧中並在使用的時候取出
alloca()函式分配的空間也在棧中,但是這部分空間會被下一次函式呼叫重寫
如果不是有函式遞迴呼叫,棧是不被需要的。如果沒有遞迴呼叫,區域性變數、引數需要的空間和返回地址都可以在編譯器知道並且在BSS段分配
程式活動記錄
活動記錄的目的是追蹤呼叫鏈,每次呼叫函式都會在棧中生成一個活動記錄,活動記錄支援函式的呼叫以及記錄呼叫結束後需要恢復的狀態。具體活動記錄的設計和實現相關,活動記錄內部各區域的順序可能各不相同,也可能有一個區域儲存函式呼叫之前的暫存器值
大多數現代程式語言都支援函式內部定義函式(和資料一起)。但是C不允許函式巢狀宣告,所有的函式都必須在詞法頂層。這種限制能夠一定程度的簡化C編譯器實現
在允許巢狀函式的語言中,活動記錄會包含一個指向其外部函式的指標,這個指標被稱為靜態連結(static link)(和編譯檔案時的靜態連結區分一下),這個指標允許內部函式獲取外部函式的棧幀
雖然可能一個外部函式同時被多次呼叫,但是靜態連結總能指向正確的棧幀,訪問到正確的區域性資料
對外部函式資料的獲取被稱為上層引用(uplevel reference)
之所以被稱為靜態連結,是因為對其外部函式的指向是在編譯期確定的,而動態連結是執行時被呼叫時指向其呼叫者的棧幀
典型的活動記錄如下:
+-------------------------+
| local vars | -- 儲存如呼叫結束時需要恢復得暫存器值等
+-------------------------+
| arguments | -- 引數
+-------------------------+
| prev frame | -- 呼叫者的棧幀
+-------------------------+
| return addr | -- 返回地址
+-------------------------+
每次呼叫函式都會生成一個這樣的活動記錄。但是編譯器作者會盡量的減少儲存的資訊以提高程式的效能,比如:
- 用暫存器儲存某些資訊而不是在棧中
- 對於葉函式(不會呼叫其他函式的函式)不生成完整的棧幀,呼叫者不再儲存暫存器的值而是讓被呼叫者儲存
- 使用指向呼叫者棧幀的指標可以簡化函式返回時彈棧到先前記錄的工作
auto和static
auto的變數是函式呼叫時在棧中分配空間儲存的,函式呼叫結束的時候對應的棧空間就會被釋放並且可以被重寫。所以如果函式返回一個指標指向區域性變數就會返回一個"懸空指標",指向的值不是有效的。
如果想要返回一個函式中定義的變數,可以將其定義為static。static變數不是儲存在棧中,而是在資料段分配空間儲存。這樣變數就會在程式的生命週期中存在,即便函式呼叫結束資料也依然存在,下一次函式呼叫還可以訪問
棧幀不一定在棧中
如果把活動記錄放到暫存器中會有更好的效能。SPARC架構以"暫存器視窗"來提高棧幀的效能。晶片中有一組專門用來存放活動記錄中的引數的暫存器。空的棧幀還會被壓入棧中,如果呼叫鏈過長導致暫存器視窗被用光,那麼就會通過把暫存器中的值填充到對應的空棧幀中來釋放暫存器
執行緒控制:
每個執行緒都會有自己的棧並且用red zone和其他執行緒的棧結構區分
setjmp和longjmp
它們通過操縱活動記錄實現,這個特性彌補了C在跳轉能力上的不足
工作方式:
- setjmp(jmp_buf j)首先被呼叫,用變數j記錄當前語句所在的位置,呼叫後返回0
- longjmp(jmp_buf j,int i)在setjmp之後呼叫,返回j記錄的地址,使之看起來像是從函式setjmp()返回,而且返回值是整數i,用以區分這是從longjmp()返回的
- j的內容在使用longjmp()之後被銷燬
setjmp()儲存了當前程式計數器和當前棧頂指標的內容。然後longjmp()恢復這些內容,高效的將控制流轉移到原來的位置恢復儲存的狀態。並且會回退所有儲存的棧頂之前的棧空間
和goto的不同:
- goto語句無法跳出當前函式,但是longjmp甚至可以調到另一個檔案的函式
- longjmp只能回到之前控制流到過的某個地方,即設定了setjmp()並且被執行過的地方
setjmp/longjmp最有用的地方是錯誤恢復。只要你沒有從函式返回,如果發現了一個不可恢復的錯誤,你就可以移動控制流到之前的某個節點,然後從那裡重新開始。可以用來從多重函式呼叫中立即返回,也可以用來預防危險的程式碼
如:
switch(setjmp(jbuf)) {
case 0:
apple = *suspicious;
break;
case 1:
printf("suspicious is indeed a bad pointer\n");
break;
default:
die("unexpected value returned by setjmp");
}
如果在某個地方檢測到了這個指標危險,就可以返回到這裡
就像goto語句,setjmp/longjmp也會導致程式難以理解難以除錯,所以要儘量避免使用
UNIX下的棧段:
棧隨著程式需要增長,程式設計師可以認為棧是無限大的
UNIX使用某種虛擬記憶體模式,當嘗試獲取超過分配的空間的空間的時候就會產生一個頁錯誤,處理方式依賴於引用是否有效。核心處理非法引用的方式通常是向產生錯誤引用的程式傳送一個訊號。在棧頂之後有一個red zone,對這裡的引用不會產生錯誤,作業系統相應的會增加棧空間的大小,虛擬地址空間會相應的增加
MS-DOS的棧段:
DOS中棧的大小是由可執行檔案指定的,而且不能再執行時修改,對超過空間的訪問會導致程式失效。如果打開了檢查,就會產生棧溢位錯誤。如果超出了段的限制,也會在編譯器產生這個錯誤。Turbo C如果資料段或程式碼段過大,會產生Segment overflowed maximum size <lsegname>,80x86架構限制為64Kbytes