?????爆笑教程????《看表情包學Linux》???猛戳訂閱????
?? 寫在前面:本章核心主題為?"進程地址空間",會通過驗證 Linux 進程的地址空間來開頭,拋出 "同一個值能有不同內容" 的現象,通過該現象去推導出 "虛擬地址" 的概念。然后帶著大家理解為什么虛擬地址不能是物理內存、講解進程地址空間的概念以及如何設計。講解什么是區(qū)域,對區(qū)域的理解,再引出內核中的數據結構是如何維護的,如何加載的問題。最后我們會揭秘文章開頭的驗證拋出的問題,從而引出 "寫時拷貝" 的概念。講解完寫時拷貝后,我們就能理解為什么 "同一個值能有不同內容"的現象,并且也能解釋本專欄進程開篇時拋出的 "fork為什么會有兩個返回值" 的問題了。文章的最后我們再探討一下虛擬地址空間存在的意義,
會印證 "進程本身是有獨立性的" 概念。
??????本篇博客全站熱榜排名:14
0x00 引入:地址空間是內存嗎?非也!
程序地址空間是內存嗎?不是!程序地址空間不是內存!
其實,我們稱之為程序地址空間都不準確,應該叫 進程地址空間,這是一個系統級的概念!
0x01 驗證:Linux 進程地址空間
我們來寫個代碼驗證一下 Linux 進程地址空間!
??? 代碼:Linux 進程地址空間
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int un_g_val;
int g_val = 100;
int main(int argc, char* argv[], char* env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &g_val);
printf("uninit global addr : %p\n", &un_g_val);
char* m1 = (char*)malloc(100);
printf("heap addr : %p\n", m1);
printf("stack addr : %p\n", &m1);
int i = 0;
for (i = 0; i < argc; i++) {
printf("argv addr : %p\n", argv[i]);
}
for (i = 0; env[i]; i++) {
printf("env addr : %p\n", env[i]);
}
}
?? 運行結果如下:
我們發(fā)現,整體的地址是依次增大的。
?請注意,堆和棧之間能觀察到有非常大的地址鏤空。
下面我們來驗證一下堆和棧的 "擠壓式" 增長方向的問題,在剛才的代碼中我們加上如下代碼:
/* 堆上申請四塊空間 */
char* m1 = (char*)malloc(100);
char* m2 = (char*)malloc(100);
char* m3 = (char*)malloc(100);
char* m4 = (char*)malloc(100);
printf("heap addr : %p\n", m1);
printf("heap addr : %p\n", m2);
printf("heap addr : %p\n", m3);
printf("heap addr : %p\n", m4);
現在我們再驗證一下棧區(qū),?依次入棧,我們取地址將其分別打印出來:
printf("stack addr : %p\n", &m1);
printf("stack addr : %p\n", &m2);
printf("stack addr : %p\n", &m3);
printf("stack addr : %p\n", &m4);
我們發(fā)現,堆區(qū)向地址增大方向增長,棧區(qū)向地址減少方向增長。
"堆和棧相對而生"
我們一般在 C 函數中定義的變量,通常在棧上保存,那么先定義的一定是地址比較高的,
后定義的地址一定是比較低的。因為先定義的先入棧,后定義的后入棧。
我們再來理解一下 static 變量,如何理解 static 變量?
我們知道:一個變量在函數內被定義,如果聲明其為?static,那么它的作用域不變,但它的生命周期會隨著程序存在一直存在。
憑什么在函數內定義 static 變量,該變量就能壽與天齊了?
我們可以加入一個 static 變量進剛才的代碼中,我們來觀察觀察:
static int s = 100;
我們的 s 是被初始化的,所以就被當成了全局變量,它只是一個寫在函數內的全局變量。
?這也就是為什么它能夠壽與天齊,因為它本來就是全局變量。
?? 結論:函數內定義的變量用 static 修飾,本質是編譯器會把該變量編譯進全局數據區(qū)。
0x02 感知:地址空間的存在
?? 我們還是寫代碼去觀察分析:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 100;
int main(void)
{
pid_t id = fork();
if (id == 0) {
// child
while (1) {
printf("我是子進程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
else {
// father
while (1) {
printf("我是父進程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(2);
}
}
}
?? 運行結果如下:
結論:當父子進程沒有人修改全局數據的時候,父子是共享該數據的。
?如果此時嘗試寫入,比如我們讓子進程有一個修改的操作。
我們在子進程那定義一個 flag
,?sleep(1)
?執(zhí)行五次,即五秒之后給它改值:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 100;
int main(void)
{
pid_t id = fork();
if (id == 0) {
// child
int flag = 0;
while (1) {
printf("我是子進程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
flag++;
// 五秒之后開始更改
if (flag == 5) {
g_val = 200;
printf("我是子進程,全局數據我已做修改,注意查看!\n");
}
}
}
else {
// father
while (1) {
printf("我是父進程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(2);
}
}
}
?? 運行結果如下:
?
?? 發(fā)現:父子進程讀取同一個變量(因為地址一樣),但是后續(xù)沒有人修改的情況下,父子進程讀取到的內容卻不一樣。
? 父子進程打出來的地址是一樣的,值卻不一樣???
" 媽媽生的(即答)"
既然如此,那我就告訴你真相?—— 我們在 C/C++ 中使用的地址,絕對不是物理地址!
(梅開二度) 震驚,居然不是物理地址……
聽到這就像是《三體》中所說的?"物理學從來就沒有存在過" 一樣。
?如果是物理地址,上面出現的那種現象是不可能產生的!
不是物理地址,那是什么呢?本章我們還不能證明,需要后續(xù)章節(jié)的鋪墊才能夠講解。
?我們先拋出概念:我們在 C/C++ 中使用的地址,是 虛擬地址。
虛擬地址在我們 Linux 下也稱為 線性地址,有些教材中也稱之為 邏輯地址。這三個概念實際上是不一樣的,但是在 Linux 下它是一樣的(這和其本身的空間布局有關系)。
?我們再拋出一個問題:為什么我的操作系統不讓我直接看到物理內存呢?
如果能讓你直接看到物理內存,或者讓你訪問物理內存,豈不是會出亂子。
內存就是一個硬件,不能阻攔你訪問!只能被動地進行讀取和寫入!
0x03 講解:進程地址空間
每一個進程在啟動的時侯都會讓操作系統給它創(chuàng)建一個地址空間,該地址空間就是 進程地址空間
操作系統為了管理一個進程,給該進程維護一個?task_struct
?叫做進程控制塊。
首先,每一個進程都會有一個自己的進程地址空間。
操作系統要不要管理這些進程地址空間呢?當然是要管理了,我們還是引出前幾章提出的:
先描述,再組織。
所謂的進程地址空間,其實是內核的一個數據結構!叫做?mm_struct
?。
?下面我們就來講解,究竟什么是地址空間!
在上一章,我們談論過進程的概念,競爭和獨立、并行和并發(fā),我們要需要談論其中的 獨立性。
進程具備獨立性,簡單來說就是一個進程掛掉或崩潰是不會波及其他進程的。
- 進程相關的數據結構是獨立的,進程的代碼和數據是獨立的。
?說得好,但是獨立性又和地址空間有什么關系呢?我們來講個故事。
?? 小故事環(huán)節(jié):
《重生之我是財閥老板私生子》
韓國某個財閥老板非常滴有錢,他有 3 個私生子,每個私生子都并不知道對方的存在,他們都以為自己是獨生子。因為他們彼此不知道對方的存在,所以他們在生活和工作上也沒有交集,不會有任何互相的影響(這就是獨立性的體現)。財閥老板為了維護自己的獨立性:
他就對大兒子說:"兒子,你好好學習,以后老爹錢都是你的。",大兒子一聽臥槽真好,高枕無憂,就好好學習,一想到自己以后有錢,就更想學習了。
然后又對二兒子說:"兒子,好好工作,等以后我就把公司給你。",二兒子一聽熱淚盈眶,于是就好好工作,等著將來有一天可以繼承公司。
后來又對三兒子說:"兒子,你好好干活,等你長大老爹的家產交給你!",三兒子知道自己以后會繼承老爹的所有財產,開心壞了,就努力的干活。
只要在財閥爹的可承受范圍內,孩子要多少錢他都給多少錢,所以三個兒子自然都認為自己有很多錢。財閥老板給他的三個兒子畫了一張?zhí)摂M的、不存在的大餅,讓他們都能努力學習工作干活(這個步驟就是給他們分別建立了進程地址空間)。
上面的故事中,財閥老板就是操作系統,三個私生子就是進程,
財閥老板給他的三個兒子畫的大餅,我們就稱之為 "進程地址空間"。
所以,進程地址空間并不是物理上存在的概念,而是在邏輯上抽象的一個虛擬的空間。
財閥老板給三個私生子畫餅,就是為了維護這三個私生子互相之間的獨立性,
如果讓私生子知道自己并不是唯一,那以后分割財產必然會造成矛盾,
對他來說自然就不是一件好事。
所以,進程地址空間,就是就是給進程畫的大餅。
進程地址空間 → 邏輯上抽象的概念 → 讓每個進程都認為自己獨占系統的所有資源
?? 概念:操作系統通過軟件的方式,給進程提供一個軟件視角,認為自己是獨占系統的所有資源(內存)。
0x04 理解:區(qū)域和頁表
什么叫做區(qū)域?我們來拿一張桌子來理解,初中的時候我和我的同桌分過 "38線" 。
我們把一張桌子分為兩個區(qū)域,對桌子進行區(qū)域劃分:
比如,既然要標出區(qū)域,定義一個桌面區(qū)域,其實用兩個變量就可以表示了:
struct destop_area {
int start; // 區(qū)域起始位置
int end; // 區(qū)域結束位置
};
struct destop_area A = {1,50};
struct destop_area B = {50, 100};
搶地盤對桌面區(qū)域進行劃分,調整區(qū)域的大小只需要讓?end 加上?"調整值" 就行。
這就是區(qū)域的概念,我們只需要定義 start 和 end 就可以表示了。
每個區(qū)域范圍都是可以有對應的編號的,比如以厘米為單位,我的修正帶就放在了 50cm。
我們的?mm_struct
里面不就是區(qū)域范圍嗎?所以?mm_struct
就可以靠 start 和 end 定義:
struct mm_struct {
long code_start;
long code_end;
long init_start;
long init_end;
long uninit_start;
long uninit_end;
long heap_start;
long heap_end;
long stack_start;
long stack_end;
...
}
程序加載到內存,由程序變成進程后,由操作系統給每個進程構建的一個頁表結構,就是 頁表。
我們來看看內核代碼,就是用一個 start 一個 end 來呈現區(qū)域空間。
每個區(qū)域都有一個 start 和 end,它們之間就有了地址,地址我們稱之為虛擬地址,
?然后這些虛擬地址經過頁表,就能映射到內存中了。
0x05?揭秘:原來是寫時拷貝!
? 思考:程序是如何變成進程的?
程序被編譯出來,沒有被加載的時候,程序內部有地址嗎?有!
?有沒有區(qū)域?也有!
?? 區(qū)分:我們程序內部的地址和內存的地址是沒有關系的。
編譯程序的時候,我們就認為程序是按照??~??進行編址的。
虛擬地址空間,不僅僅是操作系統會考慮,編譯器也會考慮。
每個進程都會創(chuàng)建一個 task_struct
,每一個進程都會維護一個 mm_struct
,自己有對應的區(qū)域,當我們的程序加載到內存時,程序有自己的加載到物理內存的物理地址,虛擬地址和物理地址建立映射關系,進程訪問某個區(qū)域當中的地址時,經過頁表找到對應的代碼和數據。當找到代碼和數據后,代碼加入到對應的 CPU 中,代碼中的地址在加載中就已經轉化成了線性地址/虛擬地址,所以 CPU 可以繼續(xù)照著這個邏輯向后運行。
所以剛才我們代碼測試,打印看到的虛擬地址值是一樣的,并且內容也是一樣的。在沒有人寫入的時候,虛擬地址到物理地址之間映射的頁表是一樣的,所以指向的代碼和數據都是一樣的。
因為進程具有獨立性,比如如果此時子進程把變量改了(寫入),就會導致父進程識別的問題就出現了父進程和子進程不一的情況,因為進程是具有獨立性的,所以我們就要做到互不影響。我們的子進程要進行修改了,影響到父進程怎么辦?沒關系!操作系統會出手!當我們識別到子進程要修改時,操作系統會重新給子進程開辟一段空間,并且把 100 拷貝下來,重新給進程建立映射關系,所以子進程的頁表就不再指向父進程所對應的 100 了,而直接指向新的 100。你在做修改時又把它的值從 100 改成 200 時,我們就出現了 "改的時候永遠改的是頁表的右側,左側不變" 的情況,所以最后你看到了父子進程的虛擬地址一樣,但是經過頁表映射到了不同的物理內存,所以了你看到了一個是 100 一個是 200,父子進程的數據不同的結果。
我們的操作系統當我們的父子對數據進行修改時,操作系統會給修改的一方重新開辟一塊空間,并且把原始數據拷貝到新空間當中,這種行為就是 寫時拷貝!
當父子有任何一個進程嘗試修改對應變量時,有一個人想修改,就會觸發(fā)寫時拷貝,讓他去拷貝新的物理內存,這只需要重新構建也表的映射關系,虛擬地址是不發(fā)生任何變化的,所以最終你看的結果是虛擬地址不變,而內容不同。
?現在再看,一點都不神奇了。
通過頁表,將父子進程的數據就可以通過寫時拷貝的方式,進行了分離。
這就做到父子進程具有獨立性,父子進程不互相影響。
0x06 回顧:fork 有兩個返回值的問題
我們在講解進程的第一個章節(jié)就提出過一個問題,關于?fork
?為什么有兩個返回值的問題。
當時我們還提出了兩個問題,局限于當時還沒有講到進程地址空間,所以沒有辦法深入講解。
我們當時說過要在 "進程地址空間" 講完后再講,現在就可以講了!
我們先回顧一下上下文:
?? 代碼:驗證 fork
?返回值的問題,我們把 id
?給打印出來:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main(void) {
pid_t id = fork();
printf("Hello, World! id: %d\n", id);
sleep(1);
}
fork 有兩個返回值,
pid_t id
,同一個變量為什么會有兩個返回值?
本章我們就可以理解了,因為當它?return
的時候,pid_t id
是屬于父進程的??臻g中定義的。
fork?
內部?return
?會被執(zhí)行兩次,return
?的本質就是通過寄存器將返回值寫入到接收返回值的變量中。當我們的?id = fork()
?時,誰先返回,誰就要發(fā)生?寫時拷貝。所以,同一個變量會有不同的返回值,本質是因為大家的虛擬地址是一樣的,但大家的物理地址是不一樣的。
0x07 探討:為什么要有虛擬地址空間?
如果我們沒有虛擬地址空間,直接讓進程訪問物理內存是不安全的。
有了虛擬地址空間,就是給訪問內存添加了一層軟硬關鍵層,可以對轉化過程進行審核,非法的訪問就可以被直接攔截了,可以?保護內存。
還能夠將 進程管理 和 Linux 內存管理,通過地址空間進行功能模塊的解耦。
讓進程或者程序可以以一種統一的視角看待內存!
有了虛擬地址空間,還可以讓進程或者程序可以 以統一的視角看待內存。方便以統一的方式來編譯和加載所有的可執(zhí)行程序。如此一來,就可以簡化進程本身的設計和實現。
?? [ 筆者 ]? ?王亦優(yōu)
?? [ 更新 ]? ?2023.2.14
? [ 勘誤 ]?? /* 暫無 */
?? [ 聲明 ]? ?由于作者水平有限,本文有錯誤和不準確之處在所難免,
本人也很想知道這些錯誤,懇望讀者批評指正!
?? 參考資料? C++reference[EB/OL]. []. http://www.cplusplus.com/reference/. Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. . 百度百科[EB/OL]. []. https://baike.baidu.com/.文章來源:http://www.zghlxwxcb.cn/news/detail-809163.html 比特科技. Linux[EB/OL]. 2021[2021.8.31 xiw文章來源地址http://www.zghlxwxcb.cn/news/detail-809163.html |
到了這里,關于【看表情包學Linux】進程地址空間 | 區(qū)域和頁表 | 虛擬地址空間 | 初識寫時拷貝的文章就介紹完了。如果您還想了解更多內容,請在右上角搜索TOY模板網以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網!