地址空間排布
這段空間中自上而下,地址是增長的,棧是向地址減小方向增長,里面存放函數中的臨時變量,而堆是向地址增長方向增長,malloc開辟的地址空間存放在堆區,堆棧之間的共享區域,主要用來加載動態庫。
驗證地址空間排布
#include<stdio.h>
#include<stdlib.h>
int g_val_1;//未初始化
int g_val_2 = 100;//初始化
////
int main(int argc, char *argv[], char *env[])
{printf("code addr: %p\n", main);//代碼起始地址const char *str = "hello bit";printf("read only string addr: %p\n", str);//str是指針變量(棧區),str指向字符常量"h"(字符常量區)printf("init global value addr: %p\n", &g_val_2);//printf("uninit global value addr: %p\n", &g_val_1);char *mem = (char*)malloc(100);char *mem1 = (char*)malloc(100);char *mem2 = (char*)malloc(100);printf("heap addr: %p\n", mem);printf("heap addr: %p\n", mem1);printf("heap addr: %p\n", mem2);printf("stack addr: %p\n", &str);printf("stack addr: %p\n", &mem);static int a = 0;int b;int c;printf("a = stack addr: %p\n", &a);printf("stack addr: %p\n", &b);printf("stack addr: %p\n", &c);int i = 0;for(; argv[i]; i++)printf("argv[%d]: %p\n", i, argv[i]);for(i=0; env[i]; i++)printf("env[%d]: %p\n", i, env[i]);
}
運行結果:
我們可以看到代碼區的地址是最小的,
static修飾的全局變量,編譯的時候已經被編譯到全局數據區了。
#include<stdio.h>
#include<unistd.h>
int g_val = 0;
int main()
{printf("begin.....%d\n",g_val);pid_t id = fork();if(id==0){//childint count = 0;while(1){printf("child: pid: %d,ppid: %d, g_val:%d, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);sleep(1);count++;if(count == 5){g_val = 100;}}}else if(id>0){//fatherwhile(1){printf("father: pod: %d,ppid: %d, g_val:%d, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);sleep(1);}}else{//todo}return 0;
}
在五秒后,子進程的g_val變成了100,但父進程的g_val沒有改變,但神奇的是,他們倆的地址竟然還完全相同。
怎么可能同一個變量,同一個地址,同時讀取,讀到了不同的內容。
結論:如果變量的地址,是物理地址,不可能存在上面的現象。絕對不是物理地址,是線性地址或者是虛擬地址。平時寫的c++/c用的指針,指針里面的地址全部都不是物理地址
進程地址空間的概念
每一個進程在創建時不僅要創建內核pcb還要創建進程地址空間
父進程pcb中會有指針指向這塊地址空間,
頁表是一種key-value式的表格,左側對應虛擬地址,右側對應實際物理地址
當父進程創建子進程時,先創建子進程pcb結構,子進程有自己獨立的頁表結構,此時子進程數據和代碼都和父進程相同。子進程一開始頁表為空,一個全局變量在父子進程具有相同的虛擬地址,當子進程往頁表中寫入時,發現父子進程指向相同的物理地址,系統會為子進程重新開辟一段空間來存儲這個全局變量,此時父子進程相同的虛擬地址會映射到不同的物理地址。
先經過寫時拷貝--是由操作系統自動完成的。本質重新開辟空間,但是在這個過程中,左側的虛擬地址是0感知的,不會影響他。所在上上面例子中,將g_val改成100后進行打印,得到的地址相同,都是虛擬地址(子進程的虛擬地址繼承自父進程),但內容不同是因為父進程通過虛擬地址查頁表映射到一塊物理地址,而子進程通過頁表映射映射到物理內存的另一個地址。
地址空間究竟是什么?
什么叫做地址空間?
前提是一個進程一定是正在運行,在32位計算機中,有32位的地址和數據總線,cpu和內存之間的線叫做系統總線,內存和外設之間的線叫做io總線。拷貝的本質是磁盤向內存充放電的過程。,每一根地址總線只有0,1,32跟會有2的32次方中組合,2^32*1bit=4GB.
所以地址空間就是你的地址總線排列組合形成地址范圍[0,2^32)
所謂的進程地址空間,本質是一個描述進程可視范圍的大小
如何理解地址空間上的區域劃分?-
地址空間一定要存在各種區域劃分,要對線性地址進行start和end即可。地址空間本質是內核的一個數據結構對象,類似pcb一樣,地址空間也是要被操作系統管理的,先描述后組織。
struct mm_struct
{unsigned long code_start;//代碼區unsigned long code_end;unsigned long init_start;//初始化區unsigned long init_end;unsigned long uninit_start;//未初始化區unsigned long uninit_end;unsigned long heap_start;//堆區unsigned long heap_end;unsigned long stack_start;//棧區unsigned long stack_end;//...等等
}
所以在創建一個進程時,先要創建對應pcb,再創建對應結構體mm_struct,并劃分區域,32位默認大小為4GB。在范圍內,連續的空間中,每一個最小單位都可以有地址,每個地址都可以被小胖(進程)直接使用。
為什么要有進程地址空間
我們再來思考什么叫做進程?以及為什么要有進程地址空間?
如果進程直接訪問物理內存,那么看到的地址就是物理地址,而語言中有指針,如果指針越界了,一個進程的指針指向了另一個進程的代碼和數據,那么進程的獨立性,便無法保證,因為物理內存暴露,其中就有可能有惡意程序直接通過物理地址,進行內存數據的篡改,如果里面的數據有賬號密碼就可以改密碼,即使操作系統不讓改,也可以讀取。
增加進程虛擬地址空間可以讓我們訪問內存的時候,增加一個轉換的過程,在這個轉換的過程中,可以對我們的尋址請求進行審查,所以一旦異常訪問,直接攔截,該請求不會到達物理內存,保護物理內存。
我們在寫代碼的時候肯定了解過指針越界,我們知道地址空間有各個區域,那么指針越界一定會出現錯誤嗎?
不一定,越界可能他還是在自己的合法區域。比如他本來指向的是棧區,越界后它依然指向棧區,編譯器的檢查機制認為這是合法的,當你指針本來指向數據區,結果指針后來指向了字符常量區,編譯器就會根據mm_struct里面的start,end區間來判斷你有沒有越界,此時發現你越界了就會報錯了,這是其中的一種檢查,第二種檢查為:頁表因為將每個虛擬地址的區域映射到了物理內存,其實頁表也有一種權限管理,當你對數據區進行映射時,數據區是可以讀寫的,相應的在頁表中的映射關系中的權限就是可讀可寫,但是當你對代碼區和字符常量區進行映射時,因為這兩個區域是只讀的,相應的在頁表中的映射關系中的權限就是只讀,如果你對這段區域進行了寫,通過頁表當中的權限管理,操作系統就直接就將這個進程干掉。
頁表
cr3寄存器
在cpu中會有一個cr3寄存器,這個寄存器會保留當前頁表的起始地址,本質上屬于進程的硬件上下文,所以當進程切換時,會帶走寄存器的數據。(這個cr3是物理地址)
頁表會給我們提供很好的權限管理
我們來思考一個問題,我們如何知道某個區域是只讀還是可寫入的呢?
頁表中會有一個標志位來說明該區域是只讀還是可寫入。若一個位置權限是可讀,當我們嘗試對這個位置進行寫入時,此時頁表會直接進行攔截,相當于進行了一次非法操作,操作系統會直接終止這個進程。
此時我們來看一段代碼
char *str ="hello bit";
*str='H';
return 0;
按照我們之前的理解,“hello bit”存在于字符常量區,是不可以被修改的。
但我們今天深入思考一下,為什么代碼是只讀?字符常量區是只讀的?他們是如何加載到只讀區域的?物理內存沒有只讀的概念,可以隨意進行讀寫,沒有權限管理。
這是因為在頁表中的地址映射關系中,頁表中的標志位是只讀的,所以操作系統才會攔截你,跨權限時進程才會被操作系統終止。
我們再來思考一個問題
我們知道,當內存空間不足的時候,處于阻塞態的進程是可以被掛起的,代碼和數據被換出內存,那我們如何知道這個進程已經被掛起了呢?你怎么知道你的進程的代碼和數據在不在內存中?
如果進程在內存,并且狀態時R,說明處于運行態,如果代碼和數據在內存中,并且狀態是S,說明進程正在被阻塞,可是Linux內核狀態沒有掛起轉換態,我們如何知道進程此時是被掛起呢?
操作系統對大文件可以實現分批加載(打游戲時游戲大小十幾個G,而你的內存只有八個G),現在操作系統用多少給你加載多少。頁表中還有一個標志位來表示對應的代碼和數據是否已經被加載到內存中。若這個標志位為1.表示已經被加載,我們直接讀取對應的物理地址,找到對應的物理內存進行訪問。當標志位為0表示當前代碼和數據并未加載到內存中,操作系統會觸發缺頁中斷。我們會找到這個可執行程序的代碼和數據,在內存中申請一塊內存,然后把可執行程序剩余的代碼和數據加載到內存中,然后把這段內存的地址填寫到對應的頁表當中。我們再訪問可以訪問對應的代碼和數據了。
這個時候我們就知道為什么某個區域是只讀還是可讀可寫了,因為通過頁表對他進行管理。
我們再思考一下
當進程在被創建的時候,是先創建內核數據結構還是先加載對應的可執行程序呢?
答案是先創建內核數據結構,即先要為該進程創建對應的pcb,進程地址空間,頁表對應關系,然后才慢慢加載可執行程序,可能你的程序已經跑起來了,但還沒加載完
因為有地址空間和頁表的存在,我們可以將進程管理模塊和內存管理模塊進行解耦合!
我們再來談進程。進程=內核數據結構(tast_struct&&mm_struct地址空間空間&&頁表)+程序的代碼和數據。所以進程在切換的時候不僅要切換pcb還要切換進程地址空間和頁表。
我們經常說進程具有獨立性,是怎么做到的?每一個進程都有獨立的內核數據結構,在物理內存中加載的代碼和數據都是獨立的。在物理內存中加載的數據可以隨便加載,是無序的,但經過頁表的映射,可以在虛擬地址中有序的排布,這就有了各種區域(正文代碼區,初始化區,未初始化區等等)