1.進程地址空間
感知進程地址空間
C/C++有內存的概念,內存空間包括棧、堆、代碼段等等,下面是32位下的內存分布圖,自底向上(由0x00000000
至0xFFFFFFFF
);
下面通過程序來驗證各個數據在該空間的地址,由此感知整個地址空間的分布情況。
#include <stdio.h>
#include <stdlib.h>int g_val= 10; // 初始化數據
int un_g_val; // 未初始化數據int main(int argc, char *argv[], char *env[])
{printf("code addr :%p\n", main); // main函 數地址,即為程序開始的地址printf("init global addr :%p\n", &g_val); // 初始化數據地址printf("uninit global addr :%p\n", &un_g_val);// 未初始化數據地址char *m1 = (char*)malloc (sizeof(char)*10);char *m2 = (char*)malloc (sizeof(char)*10);printf("heap1 addr :%p\n", m1); // 先申請的堆空間變量地址printf("heap2 addr :%p\n", m2); // 后申請的堆空間變量地址printf("stack1 addr :%p\n", &m1); // 先申請的棧空間變量地址printf("stack2 addr :%p\n", &m2); // 后申請的棧空間變量地址int i = 0;for (i = 0; argv[i]; i++){printf("argv%d addr :%p\n", i, &argv[i]); }for (i = 0; env[i]; i++){printf("env%d addr :%p\n", i, &env[i]); }return 0;
}
程序運行后結果如下,完全符合上圖的地址分布:
由低到高依次為正文代碼段->初始化數據->未初始化數據->堆空間->棧空間->命令行參數環境變量
同時,棧和堆中間有一大塊空白區域且向中間漸進。
root@hcss-ecs-e6eb:~/learning/Linux-learning/Process# ./myprocess
code addr :0x558e4fa8a189
init global addr :0x558e4fa8d010
uninit global addr :0x558e4fa8d018
heap1 addr :0x558e514606b0
heap2 addr :0x558e514606d0
stack1 addr :0x7fff4a3a7618
stack2 addr :0x7fff4a3a7620
argv0 addr :0x7fff4a3a7748
env0 addr :0x7fff4a3a7758
env1 addr :0x7fff4a3a7760
env2 addr :0x7fff4a3a7768
env3 addr :0x7fff4a3a7770
env4 addr :0x7fff4a3a7778
env5 addr :0x7fff4a3a7780
...
上述地址是在64位機器打印出來的,但實際上該機器內存只有2GB,而實際感知到的內存空間為 2 64 B = 2 34 G B 2^{64}B=2^{34}GB 264B=234GB,這中間可是相差了很多,可以初步推斷:C/C++所謂的內存地址不是物理地址,而是一個虛擬地址/線性地址。
通過下面的程序可以更好地進行理解:設置一個全局變量g_val
,父子進程都擁有這個全局變量,觀察子進程修改該全局變量值前后該變量在父子進程的狀態變化。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int g_val = 100;int main()
{pid_t id = fork();if (id == 0){while (1){printf("I'm child process:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);g_val = 200;printf("after modify, I'm child process:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid (), getppid(), g_val, &g_val);sleep(2);}}else {while (1){printf("I'm father process:%d, ppid:%d, g_val:%d, & g_val:%p\n", getpid(), getppid(), g_val, &g_val);sleep(2);} }return 0;
}
可以看到,修改前g_val
的值和地址在父子進程中都是一致的,但是子進程將g_val
的值進行修改之后,雖然值不同,但是地址相同。若該地址是真實的物理地址,顯然不可能出現這種情況,C/C++中使用的地址是虛擬地址。
I'm father process:1068014, ppid:1061991, g_val:100, &g_val:0x55a8287d7010
I'm child process:1068015, ppid:1068014, g_val:100, &g_val:0x55a8287d7010
after modify, I'm child process:1068015, ppid:1068014, g_val:200, &g_val:0x55a8287d7010
I'm father process:1068014, ppid:1061991, g_val:100, &g_val:0x55a8287d7010
I'm child process:1068015, ppid:1068014, g_val:200, &g_val:0x55a8287d7010
虛擬地址在Linux下也稱為線性地址。
內存是硬件,操作系統肯定不會讓用戶直接進行操作,當進程分配到CPU資源后,CPU通過一個虛擬地址來訪問貯存,這個虛擬地址被送到內存之前先轉換成適當的物理地址,這一任務叫地址翻譯。CPU芯片上叫做內存管理單元(Memory Management Unit, MMU)的專用硬件,利用存放在主存中的查詢表來動態翻譯虛擬地址,該表的內容由操作系統管理。
同一變量地址相同,其實是虛擬地址相同,內容不同是因為映射到了不同的物理地址。 這也能解釋為什么fork
函數會有兩個返回值,兩個返回id
雖然同一虛擬地址,但是其指向的物理空間卻不相同。
認識進程地址空間
地址空間(address space)是一個非負整數地址的有序集合。
操作系統通過進程控制塊task_struct
來管理進程,每個進程都有一塊獨立的進程地址空間,這個空間不是物理上內存真正存在的空間,task_struct
有一個類型為mm_struct
的成員,操作系統通過進程控制塊中的mm_struct
來管理這塊空間。在mm_struct
結構體中,通過記錄各段的首尾地址即區間方式來維護各段。
struct task_struct{//.../* 進程地址空間 1) mm: 指向進程所擁有的內存描述符 2) active_mm: active_mm指向進程運行時所使用的內存描述符對于普通進程而言,這兩個指針變量的值相同。但是,內核線程不擁有任何內存描述符,所以它們的mm成員總是為NULL。當內核線程得以運行時,它的active_mm成員被初始化為前一個運行進程的active_mm值*/struct mm_struct *mm, *active_mm;//...
};struct mm_struct{//...//維護代碼段和數據段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;//...// 頁表指針pgd_t * pgd;
}
概念上而言,虛擬內存被組織為一個由存放在磁盤上的N個連續的字節大小的單元組成的數組。每字節都有一個唯一的虛擬地址,作為到數組的索引。磁盤上數組的內容被緩存到主存中。
除此之外,mm_struct
中還有一個重要成員頁表指針,該指針指向了一個存放在物理內存中叫做頁表(page table)的數據結構,每個進程指向一個頁表,頁表是一種映射關系,虛擬地址通過MMU得到虛擬頁號和頁內偏移量,虛擬頁號通過頁表映射到該物理頁號,MMU拼接上頁內偏移量就能得到最終物理地址。
操作系統通過地址空間+頁表,將物理內存不連續的區域統一映射到同一塊區域,讓進程以統一的視角來看待內存。同時進程管理和內存管理解耦合,方便OS設計。地址空間+頁表是保護內存安全的重要手段。
2.寫時拷貝
寫時拷貝(copy-on-write, COW)就是等到修改數據時才真正分配內存空間,這是對程序性能的優化,可以延遲甚至是避免內存拷貝,當然目的就是避免不必要的內存拷貝。
進程這一抽象能夠為每個進程提供自己的私有的虛擬地址空間即進程地址空間,可以免受其他進程的錯誤讀寫。
不過,許多進程有同樣的只讀代碼區域。例如,每個運行的Linux Shell 程序 bash
的進程都有相同的代碼區域。
那么如果,每個進程都在物理內存中保持這些常用代碼的副本,就是極端的浪費了。
一個對象可以被映射到虛擬內存的一個區域,要么作為共享對象,要么作為私有對象。
一個進程對共享對象的任何寫操作,對其他將該共享對象映射到虛擬內存中的進程都是可見的。不可見的即是私有對象。
假設進程1將一個共享對象映射到它的虛擬內存的一個區域中,若假設進程2將同一共享對象也映射到它的地址空間(并不一定要和進程1在相同的虛擬地址處),會出現下圖的情況,以節省物理內存的消耗。即使對象被映射到多個共享區域,物理內存中也只需要存放共享對象的一個副本。
私有對象用一種叫做寫時拷貝的巧妙技術被映射到虛擬內存中。私有對象開始生命周期的方式與共享對象的一樣,在物理內存中只保存有私有對象的一個副本。
就拿上面程序中父子進程來說,子進程修改g_val
之前,兩個進程都將其私有對象(代碼、數據、g_val
等等)映射到他們虛擬內存的不同區域,但是共享這個對象的同一物理副本,修改g_val
前,此時相應私有區域都被標記為只讀。
然而子進程修改g_val
時,子進程試圖寫私有區域的某個頁面,這個寫操作會觸發一個保護故障。
當故障處理程序注意到保護異常是由于進程試圖寫私有的寫時拷貝區域中的一個頁面引起的,它就會在物理內存中創建這個頁面的一個新副本,更新頁表條目指向新的副本,然后恢復這個頁面的可寫權限。
寫時拷貝充分地使用了最稀有的物理內存。