記憶體分佈和棧空間---Memory Layout And The Stack
記憶體分佈和棧空間
Memory Layout And The Stack
原作者:Peter Jay Salzman
譯者:劉瀚文
導語
想要快速的學習使用GDB,你必須理解frames資料幀
的含義,資料幀
又叫做堆疊幀
,因為堆疊就是由資料幀構成的。想要學習堆疊的知識,我們需要了解一個執行中的程式在記憶體中是如何分佈的。這篇文章討論的主要是理論知識,但是為了讓這篇文章讀起來沒有那麼枯燥乏味,我們將引入關於在堆疊
和堆疊幀
上使用GDB的例子。
這篇文章雖然理論性較強,但是卻能為我們提供十分有價值的學習目標:
- 想要使用典型的debugger比如說GDB,就必須理解堆疊。
- 通過學習程式的記憶體分佈將有利於我們理解什麼是
segmentation fault段錯誤
段錯誤
是最常見的將程式變成‘定時炸彈’的原因。 - 瞭解程式的記憶體空間分佈常常讓我們能不通過
printf
、編譯器或甚至GDB就找到一些隱藏的非常深的錯誤。下一節的內容是由我們的客人、我的朋友——Mark Kim寫的,我們能看到像福爾摩斯一樣的偵探,Mark在冗長的程式碼中找到了一個隱藏的很深的bug,並且他僅僅用了5到10分鐘。他找到這個bug全靠盯著程式碼研究和運用程式記憶體分佈的知識,這個例子一定為讓讀者印象深刻的。
好了,我們就不繞彎子了,趕快來看看程式是怎麼在記憶體中分佈的吧!
Virtual Memory (VM)——虛擬記憶體
每當一個程式開始執行,系統核心就會提供一大塊能被從頭到尾訪問的physical memory實體記憶體
。但是多虧了Virtual Memory虛擬記憶體
,程式本身認為自己已經擁有訪問整個計算機記憶體空間的能力。你可能在有關硬體的文章中聽過虛擬記憶體
這個概念,儘管那個也叫虛擬記憶體,
但是實際上我們要討論的這個虛擬記憶體
不是那個當實體記憶體用光了之後才使用的虛擬記憶體
。我們討論的虛擬記憶體
應該由以下的原則確定:
每一個程序擁有的實體記憶體塊叫做這個程序的
虛擬記憶體
空間。程序不知道自己實體記憶體的實際結構,只能知道這個被分配給它的記憶體塊的大小和這個塊的地址空間是從0開始算的。
- 每一個程序沒辦法瞭解到其他程序擁有的
虛擬記憶體塊
- 就算一個程序已經知道了其他
虛擬記憶體塊
,但是仍然沒有辦法訪問到那一塊記憶體。
每當一個程序想要對記憶體進行讀寫,它都會從VM address虛擬記憶體地址
轉換成physical memory address實體記憶體地址
。反之,當一個OS作業系統
核心想要訪問一個虛擬記憶體
,就要把實際記憶體地址
轉換為虛擬記憶體地址
。那麼這裡就會出現兩個問題:
- 計算機要不斷的訪問記憶體,這個轉換過程必定非常常見,所以這個轉換過程就必須非常的迅速。
作業系統
怎麼來保證一個程序不會‘踏入’另一個程序的虛擬記憶體塊
中呢?
這兩個問題的答案就是實際上作業系統
本身不會去管理每個程序的虛擬記憶體
,而是通過CPU的幫助。很多CPU擁有一個叫Memory Management Unit(MMU)
的記憶體管理單元。作業系統
和MMU
會共同管理虛擬記憶體
、進行實體記憶體地址
和虛擬記憶體地址
的轉換、決定程序訪問記憶體中某個位置的許可權和決定程序在(甚至是本身的)虛擬記憶體
空間的讀寫許可權
。
過去Linux只能執行在含有MMU
的CPU上(Linux沒有辦法執行在X286框架的CPU上)。然而在1998年時,Linux裝載在了沒有MMU
的68000
上。這也是嵌入式Linux和在類似於Palm Pilot
裝置上的Linux的一大進步。
練習:
Memory Layout——記憶體分佈
上面講了虛擬記憶體
是怎麼工作的。對於多數情況,每一個程序的虛擬記憶體
是按照一個相似的、可預測的方式分佈的:
記憶體地址 | 記憶體結構 | 說明 |
---|---|---|
高地址 | Args and env vars | 命令列引數和環境變數 |
… | 堆疊(Stack) | |
… | ↓ | |
… | 未使用的記憶體空間 | |
… | ↑ | |
… | 堆(Heap) | |
… | 沒有初始化的資料段(bss) | 執行時初始化為零 |
… | 初始化了的資料段 | 執行時通過程式原始檔讀取 |
低地址 | 文字段(Text Segment) | 執行時通過程式原始檔讀取 |
>
- 文字段(Text Segment):文字段包含了真實執行的程式碼,通常是可以共享的,所以許多情況下程式可以共享文字段來滿足較低記憶體情況下的需求。這個段通常是標記為
只讀
的,好讓一個程式不能改變自己本身的結構。 - 初始化了的資料段(Initialized Data Segment):這個資料段包含了一開始就被初始化了的全域性變數。
- 沒有初始化的資料段(Uninitialized Data Segment):以前彙編程式中又稱為
bss(block started by symbol)
。這個資料段包含了一系列沒有被初始化的全域性變數。所有在這裡面的變數在執行前都被初始化為0
或者NULL pointers(空指標)
, - 棧(Stack):
棧
是包含了一系列下一部分會講到的資料幀
,當一個新的資料幀
要被新增到棧裡面的時候(比如說當一個建構函式被呼叫的時候),棧
會向下增長。 - 堆(Heap):大部分動態記憶體,無論是
C語言
的malloc()
還是C++
的new
之類的操作的變數都會從堆
上被髮放到程式中。C語言
的庫還會從堆
中獲取動態記憶體來填充personal workspace(自己的工作空間)
。因為大量的記憶體需要被不斷讀寫,所以整個堆
會往上增長。
對於給定的Object
檔案或者executable(可執行)
檔案,你都可以查明程式每一個部分的大小(注意我們這裡並不是在討論記憶體分佈而是在討論一個最終會被執行的硬碟中的檔案)。
給定Makefile和hello_world-1.c
:
1 // hello_world-1.c
2
3 #include <stdio.h>
4
5 int main(void)
6 {
7 printf("hello world\n");
8
9 return 0;
10 }
編譯這個檔案並單獨的通過以下命令進行link
:
$ gcc -W -Wall -c hello_world-1.c
$ gcc -o hello_world-1 hello_world-1.o
你可以使用size
命令來列出各個變數資料段的大小:
$ size hello_world-1 hello_world-1.o
text data bss dec hex filename
916 256 4 1176 498 hello_world-1
48 0 0 48 30 hello_world-1.o
資料段data
由已經初始化和未初始化的資料段混合而成。dec
和hex
欄位則表示整個檔案在二進位制
和十六進位制
下的大小。
你也可以使用objdump -h
和objdump -x
來得到object
檔案各部分的大小:
$ objdump -h hello_world-1.o
hello_world-1.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000023 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 00000058 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000058 2**2
ALLOC
3 .rodata 0000000d 00000000 00000000 00000058 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .note.GNU-stack 00000000 00000000 00000000 00000065 2**0
CONTENTS, READONLY
5 .comment 0000001b 00000000 00000000 00000065 2**0
CONTENTS, READONLY
練習:
size
命令並沒有列出hello_world
或hello_world.o
的堆
和棧
資料段,你覺得這是為什麼呢?- 在
hello_world-1.c
檔案中並沒有定義全域性變數。請解釋為什麼size
命令列出的結果中,object
檔案的data段
和bss段
的長度為0,而在可執行檔案中卻不為0。 size
命令和objdump
命令給出了不同的text segment(文字段)
大小。你能猜到差異從何而來嗎?(提示:這個差異有多大呢?看看在原始檔中是否有這個大小的東西。)- 選做:讀讀關於
object
檔案格式的相關知識。
Stack Frames And The Stack——資料幀和棧
我們剛剛學習了關於程式的記憶體分佈,在程式的記憶體分佈中有一個被stack frames(資料幀)
填滿的資料段叫做stack(棧)
。每一個資料幀
都代表了一個function call(函式呼叫)
。當一個函式被呼叫的時候,資料幀
的數量增加然後整個棧空間
就增長了。反過來,當一個函式返回了,資料幀
的數量就減少了然後整個棧空間
就縮小了。這一節中我們會來學習什麼是資料幀
,這一節會給出很詳細解釋,當然也會著重看看我們需要重點了解的部分。
一個程式被或多或少、互相呼叫的函式組成。每當一個函式被呼叫,一塊記憶體被擱到一塊兒放在一旁,這個記憶體塊就是在棧
裡的資料幀
。這一塊記憶體空間有許多至關重要的資訊,比如說:
- 為新呼叫的這個函式的變數提供儲存空間。
- 當這個函式返回的時候應該返回的
line number(行號)
。 - 這個函式所呼叫的引數。
每一個函式呼叫都會得到一個自己私有的資料幀
。總的來說所有資料幀
組成了一個call stack(呼叫棧)
。這裡提供一個hello_world-2.c來作為例子:
1 #include <stdio.h>
2 void first_function(void);
3 void second_function(int);
4
5 int main(void)
6 {
7 printf("hello world\n");
8 first_function();
9 printf("goodbye goodbye\n");
10
11 return 0;
12 }
13
14
15 void first_function(void)
16 {
17 int imidate = 3;
18 char broiled = 'c';
19 void *where_prohibited = NULL;
20
21 second_function(imidate);
22 imidate = 10;
23 }
24
25
26 void second_function(int a)
27 {
28 int b = a;
29 }
當這個程式開始執行就會有一個資料幀
,這個資料幀
屬於主函式main()
。因為主函式並沒有變數、引數和返回函式,所以這個資料幀
沒有什麼觀察價值。下面是主函式還沒有呼叫first_function()
的時候的記憶體分佈:
記憶體分佈 |
---|
main() 的資料幀 |
當程式呼叫first_function()
的時候,未使用的棧空間記憶體被用來為first_function()
建立一個新的資料幀
。這個資料幀由四個東西組成,分別為一個int
、一個char
、一個void*
和返回函式main()
的所在行號。下面是剛剛呼叫first_function()
時候的記憶體分佈:
記憶體分佈 |
---|
main() 的資料幀 |
first_function() 的資料幀:【int 】【char 】【void* 】【返回函式行號9 】 |
同樣的,當我們呼叫second_function()
的時候,未使用的棧空間記憶體被用來為second_function()
建立一個新的資料幀
。這個資料幀
由三個東西組成,分別為:返回函式行號、一個int
、一個int
引數a
。下面是剛剛呼叫second_function()
時候的記憶體分佈:
記憶體分佈 |
---|
main() 的資料幀 |
first_function() 的資料幀:【int 】【char 】【void* 】【返回函式行號9 】 |
second_function() 的資料幀:【int 】【a 】【返回函式行號22 】 |
當second_function()
返回時,它的資料幀
會查明返回到哪裡(first_function()
的22
行),然後返回並且該資料幀
出棧。這是剛剛返回了second_function()
時候的記憶體分佈:
記憶體分佈 |
---|
main() 的資料幀 |
first_function() 的資料幀:【int 】【char 】【void* 】【返回函式行號9 】 |
當first_function()
返回時,它的資料幀
會查明返回到哪裡(main()
的9
行),然後返回並且該資料幀
出棧。這是剛剛返回了first_function()
時候的記憶體分佈:
記憶體分佈 |
---|
main() 的資料幀 |
練習
- 假如一個程式呼叫了5個函式,那麼
棧
裡面有多少資料幀
呢? - 會不會有可能在
棧
中間的資料幀
返回到了沒有使用的記憶體空間中呢?如果有可能,那麼這對於程式來說意味著什麼呢? goto()
函式能讓一個在棧
中部的資料幀
出棧嗎?答案是不能,為什麼呢?loogjump()
函式能讓一個在棧
中部的資料幀
出棧嗎?