10.進程地址空間(初步認識)
文章目錄
- 10.進程地址空間(初步認識)
- 一、進程地址空間的實驗現象解析
- 二、進程地址空間
- 三、虛擬內存管理
- 補充:數據的寫時拷貝(淺談)
- 補充:頁表(淺談)
- 補充:關于地址空間mm_struct初始化問題(淺談)
- 四、為什么要有虛擬地址空間
- 五、總結
一、進程地址空間的實驗現象解析
這里有一個之前我們驗證進程之間的獨立性的代碼,現在在此基礎上做一些改動,得到以下帶代碼:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>int g_value = 100; // 全局變量定義
int main()
{printf("父進程 PID:%d PPID:%d\n", getpid(), getppid()); // 獲取進程信息 pid_t id = fork(); // 創建子進程 if (id < 0) return -1; // 錯誤處理 if (id == 0) {// 子進程邏輯while(1) {printf("子進程 PID:%d PPID:%d g_value:%d 地址:%p\n",getpid(), getppid(), g_value++, &g_value); // 修改變量 sleep(1); // 延時控制 }} else {while(1){// 父進程邏輯printf("父進程 PID:%d PPID:%d g_value:%d 地址:%p\n",getpid(), getppid(), g_value, &g_value); // 保持原值 sleep(1);}}return 0;
}
運行結果:
[lisihan@hcss-ecs-b735 lession14]$ gcc -o code1 code1.c
[lisihan@hcss-ecs-b735 lession14]$ ./code1
父進程 PID:31004 PPID:27811
父進程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子進程 PID:31005 PPID:31004 g_value:100 地址:0x601054
父進程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子進程 PID:31005 PPID:31004 g_value:101 地址:0x601054
父進程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子進程 PID:31005 PPID:31004 g_value:102 地址:0x601054
父進程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子進程 PID:31005 PPID:31004 g_value:103 地址:0x601054
子進程 PID:31005 PPID:31004 g_value:104 地址:0x601054
父進程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子進程 PID:31005 PPID:31004 g_value:105 地址:0x601054
父進程 PID:31004 PPID:27811 g_value:100 地址:0x601054
父進程 PID:31004 PPID:27811 g_value:100 地址:0x601054
實驗結果特征:
-
父進程持續輸出原始值:
父進程 PID:1001 PPID:999 g_value:100 地址:0x601044
-
子進程輸出遞減序列:
子進程 PID:1002 PPID:1001 g_value:101 地址:0x601044
-
地址顯示完全相同但值獨立變化
- 變量內容不?樣,所以??進程輸出的變量絕對不是同?個變量
- 但地址值是?樣的,說明,該地址絕對不是物理地址!
- 在Linux地址下,這種地址叫做虛擬地址
- 我們在?C/C++語?所看到的地址,全部都是虛擬地址!物理地址,???概看不到,由OS統?管理。OS需要把虛擬地址轉化為物理地址
二、進程地址空間
首先要了解一個概念,就是內存布局:
如圖所示,如果從低地址到高地址,我們整個程序的內存在布局情況,包括正文部分、初始化數據、未初始化數據、堆區、棧區。我們的程序地址空間布局是依照這張圖展開的。它不叫程序地址空間,它全稱應該叫做進程地址空間,所以它不屬于語言范疇,它屬于系統范疇,這是屬于系統方面的概念。
我們也可以用一個簡單的代碼來驗證上面的結論:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>int unval;
int gval = 100;int main()
{printf("code addr: %p\n", main);printf("gval addr: %p\n", &gval);printf("unval addr: %p\n", &unval);int *mem = (int*)malloc(10*sizeof(int));printf("heap add: %p\n", mem);int a,b,c;printf("stack addr : %p\n", &a);printf("stack addr : %p\n", &b);printf("stack addr : %p\n", &c);
}
運行結果:
[lisihan@hcss-ecs-b735 lession14]$ gcc -o code2 code2.c
[lisihan@hcss-ecs-b735 lession14]$ ./code2
code addr: 0x40057d
gval addr: 0x60103c
unval addr: 0x601044
heap add: 0x20bc010
stack addr : 0x7ffeeb9704b4
stack addr : 0x7ffeeb9704b0
stack addr : 0x7ffeeb9704ac
可以看到他們的地址數,整個地址是依次增大的。很明顯,堆和棧之間,中間有一大塊的鏤空。所以,我們的程序地址空間布局是依照這張圖展開的。
這幅圖,遵循《深入理解計算機系統》等權威教材,自底向上進行繪制,認為低地址在下方,高地址在上方。以前學到的進程地址空間,可能只考慮了部分區域,甚至像共享環境變量這樣的部分也可能沒見過。
所以之前說‘程序的地址空間’是不準確的,準確的應該說成 進程地址空間 ,那該如何理解呢?看圖:
? 圖二
-
上?的圖就?矣說明問題,同?個變量,地址相同,其實是虛擬地址相同,內容不同其實是被映射到了不同的物理地址
-
對虛擬地址的理解:
-
當一個程序在用戶態運行時,操作系統會為其創建一個進程(process)實例,并為此進程“畫一張大餅”——即分配一個虛擬地址空間,使其認為自己獨占整個物理內存。多個進程間彼此隔離,互不干擾;即便父子進程共享同一份代碼和數據,其后續修改也會觸發寫時拷貝,確保各自獨立。
-
用戶態代碼訪問虛擬地址時,CPU 先查詢當前進程的頁表,將虛擬地址轉換為物理地址,然后讀寫實際內存。用戶和應用程序感知到的永遠是虛擬空間,物理空間由操作系統統一管理。
-
三、虛擬內存管理
在操作系統中,管理進程的虛擬地址空間是其核心職責。具體的管理方式始于對該空間本身的精確刻畫,即操作系統需要為每個運行中的程序定義一個專屬的描述結構。這種描述隨后被整合進程序的核心控制信息塊中。
本質上,這個虛擬地址空間是操作系統內核維護的一種關鍵數據結構。當創建一個程序時,其核心控制信息塊內會包含一個指向其物理內存使用情況的引用。為了避免程序直接操作物理內存地址,在一個進程的task_struct
中,操作系統在程序與物理內存之間引入了一個中間數據結構,其類型通常命名為 mm_struct
。該結構雖然內部復雜,但宏觀上承擔此角色。程序的核心控制信息塊內部維護著一個特定指針,該指針直接關聯到當前程序對應的專屬地址空間描述結構。這意味著每個程序都擁有自己獨立的、由操作系統定義的頁表。
struct task_struct
{
/*...*///對于普通的??進程來說該字段指向他的虛擬地址空間的??空間部分,對于內核線程來說這部分為NULL。struct mm_struct *mm; // 該字段是內核線程使?的。當該進程是內核線程時,它的mm字段為NULL,表?沒有內存地址空間,可也并不是真正的沒有,這是因為所有進程關于內核的映射都是?樣的,內核線程可以使?任意進程的地址空間。struct mm_struct *active_mm;
/*...*/
}
操作系統為每個程序構建好這個專屬的頁表(如圖2所示)后,程序在運行過程中始終通過它來訪問內存。當程序需要讀寫內存時,最終都必須經由自身這個頁表結構來完成訪問操作。
struct mm_struct
{/*...*/struct vm_area_struct *mmap; /* 指向虛擬區間(VMA)鏈表 */struct rb_root mm_rb; /* red_black樹 */unsigned long task_size; /*具有該結構體的進程的虛擬地址空間的??*//*...*/// 代碼段、數據段、堆棧段、參數段及環境段的起始和結束地址。unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;/*...*/
}
可以說,mm_struct結構是對整個??空間的描述。每?個進程都會有??獨?的mm_struct,這樣每?個進程都會有??獨?的地址空間才能互不?擾。
進程的地址空間分布情況:
雖然操作系統通過這個結構體讓程序感覺自己獨占了巨大的連續內存范圍,但程序實際能使用的物理內存量通常遠小于此視圖范圍,并且過量申請會被系統限制或終止。操作系統提供這個頁表的承諾,但并不保證其全部空間都必然映射到物理資源。
補充:數據的寫時拷貝(淺談)
fork 創建子進程后,父子進程共享同一份物理頁,頁表條目被標記為只讀。只有在任一進程對共享頁執行寫入操作時,觸發頁錯誤中斷,內核才會分配新物理頁并復制原內容,修改對應進程的頁表映射,以實現各自獨立。
通俗來講:之前提到過,進程具有獨立性即使是父子關系的進程也是如此,又因為進程 = PCB + 代碼和數據,當創建子進程時,OS會拷貝一份內核進程控制塊(PCB),代碼子進程和父進程共享,只有當子進程或父進程對數據進行寫入操作的時候,數據才會從新拷貝一份給兩個進程,換言之如果一份數據對于父子進程都是只讀的,就不會拷貝,父子進程公用一套數據。(這里只是簡單說個大概,后面會詳談)
補充:頁表(淺談)
頁表不只有虛擬地址與物理地址的映射關系,還有一些標志位,比如說讀寫權限的標志位。
-
如果有一些物理地址的內容是只讀的,當進程以寫的權限去訪問這塊物理地址的時候發現不匹配,操作系統會直接清除掉這個進程。
在學C語言的時候有一個經典例子:
char *str = "hello linux"; *str = 'H'
這段代碼在編譯的時候不會有任何報錯,但是運行的時候就會直接崩潰。原因在與str指向的是一個字符串,這個字符串儲存在字符常量區,這個區域是只讀的,不可以進行寫入操作,但在編譯的時候編譯器是無法判斷進程運行時的錯誤的,所以編譯沒有報錯。我們寫好的程序運行之后都是一個一個的進程,根據上面所學的頁表的讀寫權限的標志位也就不難理解出現這種情況的原因了。
野指針也會觸發這種情況:野指針指向的地址的訪問權限與頁表中對應物理地址的權限不匹配或者野指針指向的地址空間在頁表中根本不存在,都會使得程序無法正常運行。
查找資料:
權限位(RWX):標記內存區域的可讀、可寫、可執行屬性。例如代碼區設為只讀(R-X),嘗試寫入會觸發操作系統干預,直接終止進程。這解釋了C語言中修改字符串常量導致崩潰的底層原因——字符常量區被映射為只讀,
const
關鍵字本質是編譯器層面的輔助檢查,而真正的保護由頁表硬件機制實現。 -
當一個進程創建的時候,先加載的是內核中的PCB,然后才開始慢慢加載代碼和其他數據,這也符合我們之前對進程的理解“先描述,再組織”。所以就可能會存在內核數據結構已經加載完成了,但是代碼和數據還沒有加載到內存中的情況。還有一種情況,就是之前在進程狀態中提到的阻塞掛起狀態,此時若當前進程處于阻塞狀態,其代碼和數據占用內存但無實際意義,操作系統會將該進程的部分代碼和數據換出至磁盤。此時也會出現代碼和數據還沒有加載到內存中的情況。
頁表中還有一個標志位可以檢查目標內容是否在內存中,方便在進程運行之前把內容加載進來
查找資料:
存在位(Present Bit):指示目標數據是否在物理內存中。若為0,表示數據尚未加載或已被換出到磁盤(如Swap分區)。此時訪問會觸發“缺頁異常”,操作系統負責將所需數據從磁盤調入內存,更新頁表后恢復進程執行。此機制支撐了按需加載(Demand Paging):大型程序(如游戲)啟動時并非全部載入內存,僅加載必要部分,后續訪問時動態調入,極大提高了物理內存利用率,使小內存運行大程序成為可能。
補充:關于地址空間mm_struct初始化問題(淺談)
進程數據結構中的mm_struct結構體主要用于管理進程的內存空間。每個進程都有一個對應的 mm_struct
實例,它包含了關于該進程虛擬地址空間的所有必要信息。所以本質上這個結構體仍然需要初始化,以代碼區為例,比如初始化一個地址空間,代碼區有自己的數據結構。在mm_struct
中,有start code
和end code
表示代碼區域的開始和結束,從而劃分出區域。進程加載時需要創建PCB和地址空間,地址空間和PCB由操作系統初始化。地址空間內有多個屬性,這些屬性如何初始化?
//完整定義包含關鍵字段:
struct mm_struct {struct vm_area_struct *mmap; // 內存區域鏈表pgd_t *pgd; // 頁全局目錄atomic_t mm_users; // 用戶計數atomic_t mm_count; // 引用計數unsigned long start_code; // 代碼段起始unsigned long end_code; // 代碼段結束unsigned long start_data; // 數據段起始unsigned long end_data; // 數據段結束unsigned long start_brk; // 堆區起始unsigned long brk; // 堆區當前邊界unsigned long start_stack; // 棧區起始unsigned long arg_start; // 參數區起始unsigned long arg_end; // 參數區結束unsigned long env_start; // 環境變量起始unsigned long env_end; // 環境變量結束
};
首先介紹一個命令(工具)readelf:
readelf
是一個強大的工具,用于顯示 ELF(Executable and Linkable Format)文件的信息。ELF 文件是一種常見的二進制文件格式,在 Linux 系統中廣泛使用,包括可執行文件、共享庫和目標文件。readelf
可以幫助開發者和系統管理員檢查這些文件的內容和結構。
簡單來說,readelf可以讀取一個可執行文件的每一個分段信息,其他的功能這里暫時不說,從這里可以知道:
- 虛擬地址空間的初始化依賴于可執行程序的ELF格式特性:編譯器在生成可執行文件時,已按功能將程序劃分為多個邏輯段(Section),包括存儲機器指令的代碼段(
.text
)、存放字符串常量的只讀數據段(.rodata
)、保存已初始化全局變量的數據段(.data
)以及未初始化數據段(.bss
)。 - 通過
readelf -S
命令可解析ELF文件頭信息,獲取各段關鍵屬性:1) Size字段定義段大小(如代碼段16字節);2) Addr字段標識段在虛擬地址空間的預期起始位置;3) Flags權限標記(R
/W
/X
組合)聲明段的內存訪問規則。
四、為什么要有虛擬地址空間
- 地址空間和?表是OS創建并維護的!也就意味著凡是想使?地址空間和?表進?映射,也?定要在OS的監管之下來進?訪問!也順便保護了物理內存中的所有的合法數據,包括各個進程以及內核的相關有效數據!
- 因為有地址空間的存在和?表的映射的存在,我們的物理內存中可以對未來的數據進?任意位置的加載!物理內存的分配 和 進程的管理就可以做到沒有關系,進程管理模塊和內存管理模塊就完成了解耦合
- 因為有地址空間的存在,所以我們在C、C++語?上new, malloc空間的時候,其實是在地址空間上申請的,物理內存可以甚??個字節都不給你。?當你真正進?對物理地址空間訪問的時候,才執?內存的相關管理算法,幫你申請內存,構建?表映射關系(延遲分配),這是由操作系統?動完成,??包括進程完全0感知!!
- 因為?表的映射的存在,程序在物理內存中理論上就可以任意位置加載。它可以將地址空間上的虛擬地址和物理地址進?映射,在進程視?所有的內存分布都可以是有序的。
簡單表述:
這種設計實現三項關鍵優勢:
-
空間效率:避免一次性加載整個程序;
-
安全隔離:頁表基于
mm_struct
配置的權限攔截非法訪問(如向代碼段寫入); -
動態擴展:BSS段等未初始化區域僅預留虛擬地址范圍,實際物理頁在首次訪問時分配。最終,進程通過專屬的"內存視圖"(
mm_struct
)訪問物理內存,而操作系統通過維護編譯器預設的段屬性與硬件協作,實現了虛擬地址空間的動態管理與安全控制。
了解上述內容,我們可以回答一下問題:
Q:為什么在程序中的全局變量、字符常量具有全局性,在程序運行期間都會有效?
A:全局變量、字符常量一般存放在已初始化數據區、未初始化數據區,它不像棧和堆一樣會在進程運行期間創建和銷毀,在之前講的進程地址空間可知,進程地址空間與物理地址的映射關系在進程存在期間一直存在,所以這部分地址的映射關系不會改變,隨著進程一直存在,且全局變量的地址可以被整個程序使用。
Q:為什么父進程的環境變量能夠被子進程繼承?
A:因為在進程內存空間中命令行參數與環境變量也有一塊區域用于存放他們的數據,父進程創建子進程的時候會寫時拷貝到子進程,因此子進程可以繼承父進程的環境變量。
五、總結
AI生成
本文介紹了進程地址空間的基本概念,通過實驗展示了父子進程共享相同虛擬地址但實際物理地址不同的現象,解釋了虛擬地址與物理地址的區別。文章詳細分析了進程地址空間的內存布局結構(包括代碼段、數據段、堆棧等區域),并通過代碼示例驗證了各段地址的分布規律。重點闡述了操作系統如何通過mm_struct結構體管理進程的虛擬地址空間,以及頁表機制在虛擬地址到物理地址轉換中的作用。最后指出操作系統的內存管理機制使每個進程都擁有獨立的地址空間視圖,保證了進程間的隔離性。