1. 程式人生 > >深入理解計算機系統----第九章虛擬記憶體

深入理解計算機系統----第九章虛擬記憶體

原文連結 https://www.jianshu.com/p/e1b82b230917

虛擬儲存器又叫做虛擬記憶體,我們現在的作業系統普遍都支援了虛擬記憶體,這樣做是因為我們同時執行著太多的程式了,就目前我電腦的狀態來看,我既要開啟瀏覽器,又要聽歌,可能同時還登陸的有QQ,如果不使用虛擬記憶體4G的記憶體空間很快就會被耗盡,而一旦沒有了記憶體空間,其他程式就無法載入了。虛擬記憶體的出現就是為了解決這個問題,當一個程式開始執行的時候,其實是為每個程式單獨建立了一個頁表(這個以後講),只將一部分放入記憶體中,以後根據實際的需要隨時從硬碟中調入內容。當然虛擬記憶體不僅僅只有這個功能,我們的作業系統也是在記憶體中執行著的,虛擬記憶體同時還提供了一種保護,這樣做其他程序就不會損壞掉系統的記憶體空間。那麼虛擬記憶體是如何實現的呢?

1.1 物理定址和虛擬定址


虛擬記憶體主要是一種地址擴充套件技術,主要是建立和管理兩套地址系統:實體地址和虛擬地址。由虛擬地址空間(硬碟上)裝入程序,其實際執行是在實體地址空間(記憶體上)承載程序的執行。虛擬地址空間比實體地址空間要大的多,作業系統同時承擔著管理者兩套地址空間的轉換。我們來看看什麼是物理定址:

主存的每個地址都是唯一的,第一個位元組地址為0,接下來為2,以此類推。CPU使用這種訪問方式就是物理定址。上圖所示就是CPU通過地址匯流排傳遞讀取主存中4號地址開始處的內容並通過資料匯流排傳送到CPU的暫存器中。

當然地址匯流排也不是無限大的,我們通常所說的32位系統,其定址能力是2^32 = 4 294 967 296B(4GB)也就是說記憶體條插的再多也沒有用,地址匯流排只能最多訪問到4GB的地址內容。我們前面說過4GB的實體記憶體空間其實並不大(如果是獨佔的話)。這時候科學家們想到了一個很好的方法,建立虛擬定址方式,使用一個成為MMU的地址翻譯工具將虛擬地址翻譯成實體地址在提供訪問,如下圖:

 

使用虛擬定址的時候,cpu先是生成一個虛擬地址:4100再經過地址翻譯器,將4100翻譯成實體地址。

我們說過虛擬地址要比實體地址大的多,為啥還要麻煩的將實體地址轉成虛擬地址呢?虛擬地址的發明究竟是為了什麼,我們知道對記憶體的訪問要比硬碟的訪問快10000倍,如果我們在記憶體中沒有找到相應的內容(不命中),而需要到硬碟上找的話,我們必須要提供相對來說高效率的訪問方式。這時候就建立了一個虛擬儲存器,管理著磁碟,以每頁的方式進行整合,每個頁面的大小4kb-2mb不等,加上偏移量就成為了一個虛擬地址。比如4100,說明的就是頁4編號,偏移100處的位置。這就比挨個挨個單獨定址要快的多。

1.2 地址空間


地址空間是一個非負整數的集合{0,1,2,……},一個32位的系統中有:2^32 = 4 294 967 296B(4GB)個有效地址。地址空間的概念很重要,我們必須要清楚資料物件(位元組)和它的屬性(地址)的區別, 舉個例子:我和我老婆住在蒼溪縣xx小區7棟1單元,這個就是我的屬性:地址。另外,住在家的我和我老婆就是資料物件(位元組)。虛擬儲存器的基本思想是:主存中的每個位元組都有一個選自虛擬地址空間的虛擬地址和一個選自實體地址空間的實體地址。

1.3 虛擬儲存器的工作原理


我們先來看看虛擬記憶體,就windows系統而言是儲存�在磁碟上的一個檔案,存放於C盤的pagefile.sys點選屬性可以看到其大小為3.96G,這相當於一個倉庫,儲存著臨時需要又還沒用到的資料。

這個倉庫的的資料被分割成塊,稱為虛擬頁。虛擬儲存器的主要思想就是:在主存中快取硬碟上的虛擬頁(pagefile.sys),虛擬頁有三個狀態:未分配、快取的、未快取的。

上圖所示的是一個有8個虛擬頁的小虛擬儲存器(建立在硬碟上),虛擬頁0和3還未分配,因此在磁碟上還不存在。虛擬頁1、4和6被快取在右邊的主存中。

(記憶體訪問速度要比硬碟快10000倍,因此不命中的話代價要昂貴的多。我們前面說過是以虛擬頁來快取的,也就是分成塊,每個塊(虛擬頁)的大小4kb-2mb不等。)

我們現在來看看地址翻譯MMU是如何完成虛擬地址到實體地址的轉換的,學習這個知識是幫助我們理解虛擬儲存器是如何將虛擬也快取到主存(記憶體)中去的。

① 頁表

頁表是一個存放在記憶體中的資料結構,MMU就是通過頁表來完成虛擬地址到實體地址的轉換。這個資料結構每一個條目稱為PTE(Page Table Entry),由兩部分組成:有效位和n位地址段。有效位如果是1,那麼n位地址就指向已經在記憶體中快取好了的地址;如果為0,地址為null的話表示為分配,地址指向磁碟上的虛擬記憶體(pagefile.sys)的話就是未快取。我們來看一個典型的頁表圖:

虛擬頁vp1,2,7,4當前被快取在記憶體中,頁表上有效位設定成1,分別用PTE1,2,4,7表示。VP0和VP5(PTE0、5)未被分配,VP3和VP6被分配並指向虛擬記憶體,但未被快取。

② 頁命中

當我們使用2100虛擬地址來訪問虛擬頁2的內容的時候,就是一個頁命中。地址翻譯將指向PTE2上,由於有效位1,地址翻譯器MMU就知道VP2已經快取在記憶體中了。就使用頁表中儲存的實體地址進行訪問。

③ 缺頁

我們再來看看不命中,也就是缺頁的情況,當CPU需要VP3的一個字時,初始化是這樣的:

PTE3有效位是0,同時地址位指向了虛擬記憶體(pagefile.sys),就會觸發缺頁異常。異常處理程式會選擇犧牲一個記憶體(DRAM)中的頁,本例中選擇的是記憶體中的PP3頁的VP4,接下來核心就從虛擬記憶體中拷貝VP3到記憶體中的PP3,並使得PTE3指向記憶體中的PP3,形成如下:

(注:虛擬儲存器出現早於快取記憶體,按照習慣的說法塊被叫做頁。從虛擬記憶體到實體記憶體傳送頁的活動就叫做頁面交換。)

1.4 虛擬儲存器的作用


虛擬儲存器有諸多的好處,作業系統其實為每個程序提供了一個獨立的頁表,使用不同的頁表也就建立了獨立的虛擬地址空間,下圖展示了基本思想:

程序i將VP1對映到了記憶體的PP2處,VP2對映到了記憶體的PP7處。程序j將VP1對映到了記憶體的PP7,將VP2對映到了PP10處。

簡化連結:每個程序一個頁表後,這個程序就會覺得全世界都是它的(頁表模擬出一個虛擬儲存器),那什麼符號連結的時候(也就是符號對映到地址的時候),不再會受到記憶體中還有其他應用程式的干擾,因為我們面向的是虛擬儲存器,我們的程序的地址空間是獨立的,我這個符號放到離0偏移100的地方,那個放到離0偏移200的地方很容易就搞定了。

簡化載入:在硬碟中雙擊一個圖示,啟動一個應用程式時,實際上你都不需要將這個程式從硬碟給載入到記憶體,只需要建個頁表,然後頁表裡的編號指向的是硬碟,然後CPU訪問到具體程式碼的時候,再按照上一節的定址的方式,按需的將硬碟上的東東載入到記憶體。載入過程及其簡單了。

簡化共享:我們有很多的程序在系統中執行,但是有些程式碼,比如呼叫作業系統的API,這些API可能許多程序都要使用比如printf,這就要共享一部分記憶體,我們不需要將這部分記憶體在每個程序空間都拷貝一份,實際上每個程序都有一個頁表,而不是全域性只有一個,頁表把共享記憶體對映到同一個地方。

簡化儲存器分配:當一個程序使用malloc要求額外的空間時,作業系統只需要保證形成了一個連續的虛擬頁面,但可以對映到實體記憶體中任意的位置,可以隨機分散在記憶體的不同位置。

簡化保護:我們可以通過為PTE新增額外的標識位提供對儲存器的保護。

通過新新增的三個標識位:SUP:核心or使用者;READ:讀;WRITE:寫。執行在使用者模式下的程序只允許訪問SUP為否的頁面,如果一個指令違法了訪問的設定條件,就會轉到保護故障,引起一個段錯誤。

1.5 虛擬儲存器工作原理詳解:地址翻譯

地址翻譯從形式上來說就是建立一個虛擬地址空間到實體地址空間的對映關係,我們前面說過MMU使用的是頁表來實現這種對映。CPU中有一個專門的頁表基址暫存器(PTBR)指向當前頁表,使用頁表進行翻譯的時候方法如下:

每個虛擬地址由兩部分組成:虛擬頁號(VPN)+虛擬頁偏移量(VPO),當CPU生成一個虛擬地址並傳遞給MMU開始翻譯的時候,MMU利用虛擬地址的VPN來選擇相應的PTE,同時將頁表中的物理頁號(PPN)+虛擬地址的VPO就生成了相應的實體地址。(實體地址是由頁表中的物理頁號+虛擬地址中的偏移量構成

頁面命中是一個簡單的過程,我們就不做詳解,這裡來跟蹤看一下缺頁的情況:

說明:①CPU生成虛擬地址;②MMU生成PTE地址從記憶體的頁表中請求內容;③ 記憶體中的頁表返回相應的PTE值;④ PTE的有效位是0,轉到異常處理程式;⑤ 異常處理程式確定記憶體中的犧牲頁,並將其寫會到磁碟上(pagefile.sys);⑥從pagefile.sys中調入新的檔案並更新PTE。⑦ 由於PTE已經被更新好了,從新發送虛擬地址到MMU(後面就和命中的過程一樣了)

我們講了大致的地址翻譯原理,有什麼辦法能夠提高翻譯的速度嗎?

① 加入快取記憶體

快取記憶體被髮明出來的一個重要原因就是提高對記憶體的訪問速度,我們來看看加入快取記憶體後的訪問示意圖:

快取記憶體被放在儲存器和MMU之間,可以快取頁表條路。當MMU傳送一個PTEA請求的時候,優先從快取記憶體中尋找相應的PTE值,如果命中直接返回給MMU,如果不命中從記憶體中獲得併發送到快取記憶體,再由快取記憶體返回到MMU。(快取記憶體使用的是物理定址,不涉及地址保護問題,因為MMU已經加入了保護標識位)

② 加入翻譯後備緩衝器TLB

TLB是一個小的、虛擬定址的快取,其中每一行都儲存一個PTE塊,高度相連。主要是提供虛擬地址到實體地址的翻譯速度。大致範圍示意圖如下:

說明:①CPU生成一個虛擬地址併發送到MMU;② ③MMU從TLB中獲取相應的PTE④翻譯成相應的實體地址後從記憶體中請求內容;⑤ 資料從記憶體返回給CPU

③ 加入多級頁表

我們來分析一下單級頁表的弱勢之處,然後指出改進的方法。我們雙擊圖示執行一個程式的時候,在單級頁表模式下,其實是在記憶體中為這個程式建立了一個頁表,使得程式有了獨立的地址空間。我們以32位系統4GB地址空間為例,我們將實體記憶體分割為虛擬的頁面,每個頁面儲存4KB大小的內容,這樣我們總共需要1048576個頁面,才能瓜分所有的4GB空間。那麼我們的頁表要能夠完成所有實體記憶體的對映,就必須要1048576個頁表項,由於每個頁表項佔用4B的空間,那麼我們這個頁表就需要佔用4194304B(4M)的記憶體空間,每個程序都有這樣的一個4M的頁表佔用著記憶體空間,才能完成對映。

單級頁表

我們來看看有什麼方法優化一下,下面我們加入分級頁表(二級):

二級頁表

我們加入分級的思想以後,每一級的頁表就都只有4KB的大小,數量也有原來的1048576變成了1024個,兩級相乘其實表示的數量還是原來那麼多。上圖所示,一級頁表每條PTE負責對映二級頁表1024個PTE項,二級頁表的每個PTE在對映虛擬儲存器中4KB大小的位置。也就是說一級頁表每條PTE負責對映一塊4M大小的空間,而一級頁表總共有1024個頁表項,也就能用來對映完成所有實體記憶體空間。這樣做的好處是,如果一級頁表中有未被分配的專案,那麼這條PTE直接設定成null,不指向任何二級列表,也就不再佔用空間。還有一個好處是不是所有的二級列表都需要常駐記憶體,每個程序只需要在記憶體中建立一級頁表(4kb)大小,二級列表按需要的時候建立調入,這樣就更省了。

④ 綜合:一個從虛擬地址到實體地址並獲取資料的模擬

為了方便討論,我們以一個小的儲存系統作如下假設:

1> 虛擬地址大小14位:結構如下

2> 實體地址大小12位:結構如下

3> 記憶體大小為4KB,物理頁號為64個,每個頁面大小為64B,頁表如下:

4> TLB 翻譯後備快取器分成4組,每組4條,一共16個條目:

5> 快取記憶體64B大小,使用物理定址、直接對映的方式,每行4B,共計16個組:

好了,有了這些假設以後我們來看一下,當CPU讀取0x03d4處內容會發生些什麼:

此處是虛擬地址,0x03d4二進位制表示就為:(0000 1111 0101 00)14位,由於虛擬地址的低6位用來表示偏移量(每個頁面64B大小:2^6=64),剩下的高8位用來表示虛擬頁號,一共有128個虛擬頁號(2^8)。

我們從虛擬地址中:

1> 抽取出虛擬頁號為:0x0f;

2> 將虛擬頁號與TLB進行對比,為了方便,我們形成TLBT標記位,TLBI組索引;

組索引在0x03號位置,標記也為0x03,這時候回到我們的假設“4>”處進行檢查,發現0x03組,標記位0x03處的有效位是1,所以命中。取出物理頁號(PPN)0D用於構造實體地址用。實體地址就為:PPN-VPO = 0x354:

3 > 根據實體地址:0x354,我們在快取記憶體中去碰碰運氣,前面假設的時候我們說過大小為64B,我們將其分成16個條目,由:標記位+有效位+塊0-3組成。其實際存放資料的塊每個條目只有4個(0-3)所以總大小為64B,我們的實體地址要到快取記憶體中去尋找資料,就得有某種對應方式。其中實體地址的低2位用作偏移量(CO)因為每個條目只有4個數據塊,緊接著的4位表示組索引,因為一共是16個組,最後的高7位作為標記位。我們形成如下的:CO=0x0,偏移量為0也就是塊0的內容;CI = 0x05也就是第0x05組和CT:0x0d標誌位。有了這些內容以後我們返回到假設5中去尋找,發現快取記憶體中的5號索引,標記位為0x0d,並且有效,讀取塊0處的內容為36。這就是我們要返回給CPU的內容。至此完成了一個端到端地址翻譯並返回資料的手工模擬,當然我們還可能遇到很多不同的情況。如在快取記憶體中不命中,TLB不命中等等,但大致原理幾乎類似,請自行腦補。

1.6 案例研究:Intel CoreI7/ Linux儲存系統


① Core i7 地址翻譯:

Core i7採用4級頁表層次結構,CPU產生的虛擬地址,如果命中由TLB生成實體地址,如果不命中後通過4級頁表生成實體地址。實體地址如果命中優先從L1快取記憶體中獲取資料,如果不命中再從主存中獲取結果,最後傳遞給CPU

四級頁表詳解

四級頁表將虛擬地址翻譯成實體地址的過程也相當簡單,36位的虛擬地址被分割成4個9位的片。VPN1有一個到L1 PTE的偏移量,找到這個PTE以後又會包含到L2頁表的基礎地址;VPN2包含一個到L2PTE的偏移量,找到這個PTE以後又會包含到L3頁表的基礎地址;VPN3包含一個到L3PTE的偏移量,找到這個PTE以後又會包含到L4頁表的基礎地址;VPN4包含一個到L4PTE的偏移量,找到這個PTE以後就是相應的PPN(物理頁號)。

頁表條目格式說明:

② Linux虛擬儲存系統

一個單獨的Linux系統程序虛擬儲存主要分為:核心虛擬儲存器和程序虛擬儲存器。

我們主要來講一下核心虛擬儲存器:由下往上是核心的程式碼和資料結構,是每個程序共享的資料結構和程式碼;再往上是一組連續的虛擬頁面對映到相應的物理頁面的物理儲存器,大小同主存一樣大,提供很方便訪問物理頁面的任何位置。最後是每個程序不同的是頁表、task(mm)、核心棧等。

虛擬儲存器區域:

區域就是我們通常說的段,text、data、bss都是不同的區域,這些區域是被分為連續的片。每個虛擬頁面都在不同的段中,不屬於某個段的虛擬頁面是不存在的,且不能被使用。我們來看看核心中的一個task資料結構(mm):

task_struct是位於核心虛擬儲存器中對於每個程序的都不同的核心資料結構,包含執行該程序所需要的基本資訊(PID、可執行檔名稱、程式計數器等)。這個結構中有一個mm欄位,指向的是mm_struct中的pgd和mmap,其中pgd是一級頁表的基地址,mmap指向的是一個vm_area_structs的連結串列,每個該連結串列中的一個元素描述的是當前虛擬地址空間的一個段(text、data、bss等),當核心執行該程序的時候CR3暫存器就被放入了pgd。

Linux缺頁異常處理:

我們將了一些儲存器區域劃分的基礎知識,並且介紹說mmap指向的是一個連結串列,這個連結串列中的每個元素都指向該程序的相應的段,其中vm_strat是段開始的地方,vm_end是段結束的地方。

1> 訪問地址是否合法:缺頁處理程式只需要將這個地址A與vm_area_struct連結串列中的每個元素的start和end資料比較,如果都沒有的話,表示該地址不在相應的段中。就是一個段錯誤。

2> 保護異常:vm_area_struct中的vm_prot結構是包含了所有頁面的讀寫許可權,所以當對只有讀許可權的文字內容寫入資料的時候,就會引發保護異常。

3> 最後,正常缺頁。也就是相應的頁面不在實體記憶體的時候,缺頁程式就會鎖定一個犧牲頁面,將它的內容與實際需要的內容交換過來,當缺頁程式返回的時候就可以正常的訪問了。

1.7 儲存器對映


儲存器對映是通過將磁碟上的一個檔案與虛擬儲存器中的一個區域關聯起來的過程。

① 理解共享物件

一個物件被對映到虛擬儲存器的一個區域,這個區域要麼是共享物件,要麼是私有物件。如果一個程序A將一個共享物件對映X到了它的虛擬儲存器中,那麼對於也把這個共享物件X映射了的其他程序而言,程序A對共享物件X的任何讀寫操作都是可見的。下圖是程序1和程序2映射了共享區域的圖例:

私有區域:即使是私有區域在物理儲存器上也是同一個區域,如下圖程序1和程序2所對映的私有物件在物理儲存器上只是一份拷貝。

每個物件都有唯一的一個檔名,在程序1的虛擬儲存器中已經完成了私有物件到儲存器的對映,程序2如果要對映這個區域只需要將頁表條目指向已經對映好的物理儲存器位置就行了。如上圖所示,程序1和2將一個私有物件對映到了物理儲存器的一個區域並共享這個私有物件。這個物件會被標記為只讀,當其中一個程序2確實需要寫這個區域的時候,就會引發一個保護故障,核心會在物理儲存器中建立這個私有物件的一個拷貝,稱為寫時拷貝,更新頁面條目使得程序1指向這個新的條目。然後把老物件修改為可寫許可權。這樣當保護故障程式返回的時候,CPU從新執行寫的操作就不會出錯了。

② 理解fork函式如何建立獨立的虛擬地址空間

噹噹前程序呼叫fork函式的時候,核心為新程序建立各種資料結構,並分配PID。為了給新程序建立一個虛擬儲存器,它建立的當前程序的mm_struct、區域結構和頁表的一個拷貝,核心為兩個程序的每個頁表標記為只讀,並將誒個區域標記為私有的寫時拷貝。這樣當fork函式返回的時候,新程序的虛擬儲存器和當前程序的虛擬儲存器剛好相同。任何一個程序進行寫操作的時候,才會建立新的頁面。

③ 理解execve函式實際上如何載入和執行程式

1> 刪除已存在的使用者區域;

2> 對映私有區域

所有的.text、.data、.bss區域都是新建立的,這些區域是私有的、寫時拷貝。.bss是匿名檔案區域,初始化為二進位制0,棧、堆也都是初始化為0.

3> 對映共享區域;

這些共享區域是動態連結到程式然後對映到虛擬地址空間的共享區域。

4> 設定程式計數器。

最後一步就是設定當前程序的上下文計數器,並指向.text入口

④ 使用mmap函式建立新的儲存器對映

函式原型如下:

說明:

start:從地址start開始處建立,通常為NULL;length:連續物件的大小;

port:訪問許可權(PROT_EXEC\PROT_READ\PROT_WRITE\PROT_NONE);

flags:被對映物件的位(MAP_ANOE\MAP_PRIVATE\MAP_SHARED);

fd: 指定的磁碟檔案;offset:距離磁碟檔案偏移的位置處開始;

返回值:呼叫成功,返回新區域的地址。

(注:可以使用munmap刪除相應的虛擬儲存器區域)

1.8 動態儲存分配


動態儲存器分配指的是在程式執行的時候分配額外的儲存空間,分配器維護著虛擬儲存器中的堆實現這種分配。

堆是緊跟著.bss段,並向上增長,核心維護著一個brk指標,指向堆的頂部。任何一個堆中的塊要麼是已分配的要麼是空閒的。分配的方式分為兩種:顯式和隱式,我們接下來主要講一下顯示分配和實現一個分配器的基礎知識,隱式分配指的其實是分配器回收空間,這個在分配器基礎知識中有所講解,就不再另外提出了:

① 顯式分配:程式呼叫malloc和free函式

經常直到我們的程式執行的時候,我們才知道某些資料結構的大小。這時候就必須顯式的分配相應的儲存空間。如下圖所示:

使用malloc函式以具體的輸入內容分配相應大小的儲存空間,函式原型如下:

如果想要初始化儲存器為0,可以使用calloc函式。想要改變已分配的大小可以使用realloc函式

釋放是通過呼叫free函式來實現的:

ptr是指向一個已分配空間的起始位置

我們來看一個分配例項:

(a)請求一個4字大小的塊,malloc將分配好的空間的首地址返回給p1;

(b)請求一個5字大小的塊,由於使用的雙字對其,所以填充了一個空閒塊;

(c)請求一個6字大小的塊,返回給p3;

(d)釋放p2,呼叫後p2仍然指向原來的位置;

(e)請求一個2字大小的塊,在已經釋放的p2處優先分配,然後返回指標p4

② 分配器基礎知識

分配器的目標主要是找到吞吐量和利用率的契合點,那麼為什麼需要隱式的分配,因為碎片的產生會降低儲存空間的利用率

碎片:內部和外部

1>內部碎片:我們上面講到的(b)的情況,分配了一個額外的空閒塊,實現雙字對其;

2>外部碎片:(e)中如果請求7字大小的塊,即使儲存空間有這麼大,還是不行

當然,還有許多問題要思考,諸如:空閒塊如何組織、如何分配新的塊、怎麼分割和合並塊,這些技術都要求我們提供一種新的資料結構

隱式空閒連結串列:

這樣的一種結構,主要是由三部分組成:頭部、有效載荷、填充(可選);

頭部:是由塊大小+標誌位(a已分配/f空閒);有效載荷:實際的資料

應用舉例:

這個連結串列(大小/是否分配)是通過頭部中的大小欄位 隱含連線著的(頭部+大小=下一塊位置),分配器可以遍歷所有的塊,在遇到結束位(0/1)處停止。即使是要求分配一個數據塊,也要有(8/0)一個頭部,兩個字來完成。

簡單的放置策略:

1> 首次適配:從頭搜尋,遇到第一個合適的塊就停止;

2> 下次適配:從頭搜尋,遇到下一個合適的塊停止;

3> 最佳適配:全部搜尋,選擇合適的塊停止。

分割空閒塊:

適配到合適的空閒塊,分配器將空閒塊分割成兩個部分,一個是分配塊,一個是新的空閒塊:

增加堆的空間:通過呼叫sbrk函式,申請額外的儲存器空間,插入到空閒連結串列中

合併:

1> 為什麼要合併:處理假碎片現象

如上所示,雖然釋放了兩個3位元組大小的資料空間,而且空閒的空間相鄰,但是就是無法再分配4位元組的空間了,這時候就需要進行一般合併:合併的策略是立即合併和推遲合併,我們可能不立即推遲合併,如果有空間直接合並不好嗎?有時候的確還真不好,如果我們馬上合併上圖的空間後又申請3位元組的塊,那麼就會開始分割,釋放以後立即合併的話,又將是一個合併分割的過程,這樣的話推遲合併就有好處了。需要的時候再合併,就不會產生抖動了。

2> 怎樣合併:帶邊界標記

我們需要從新審視一下我們的隱式連結串列資料結構,加入新的邊界標記形成如下結構:

在連結串列的底部加入頭部同樣的格式,用a表示已分配、f表示空閒

我們列舉一下可能的所有情況:

說明:

(a):在合併的時候,由於前後都是已分配不執行合併,只是把當前塊標記位空閒:

(b):後面的塊是空閒的,當前塊與後面的塊合併,用新的塊的大小(當前塊大小+後面塊大小),更新當前塊的頭部和後面塊的腳部

(c):前面塊是空閒,前面塊與當前塊合併,用新的塊的大小(當前塊的大小+前面塊的大小),更新前面塊的頭部和當前塊的腳部

(d):三個塊都是空閒,3個塊的大小來更新前面塊的頭部和後面塊的腳部

當前已分配的頭部

注意:當(c)和(d)兩種情況,前面的塊是空閒的,才需要用到當前塊的腳部。(a)不需要更新,(b)更新的是後面塊的腳部+塊大小。如果我們把前面的塊的位存放在當前塊頭部未使用多出來的低位中,那麼已分配的塊就不需要腳部了。(當然空閒塊仍然需要腳部)

③ 綜合:實現一個簡單的分配器

我們將實現的是一個基於隱式空閒連結串列,立即邊界標記合併方式的簡單分配器。資料的結構如下:

最小的塊大小為16位元組,必須包含:頭部(8位元組)+腳部(8位元組);

隱式的空閒連結串列具有如下恆定的形式:

其中首字(對齊)是不使用的填充塊,緊跟著的是一個特殊的序言塊,由一個頭部和腳部組成,序言塊在初始化的時候建立,永不釋放。中間就是由malloc建立的普通塊,最後是一個特殊的結尾塊,序言和結尾塊都是為了消除合併邊界條件的技巧。(heap_listp總是指向序言塊)

接下來的內容,我們直接上與malloc和free有關的函式原始碼,用到什麼知識的時候再做補充:為了和系統的malloc和free函式區分,我們起名為(mm_malloc和mm_free):

這裡我們要對extend_heap函式進行說明,在擴充套件堆的時候必須要呼叫mm_init函式進行初始化,建立一個空的連結串列。初始化mm_init函式如下:

具體的擴充套件函式如下,當堆初始化或者mm_malloc函式不能匹配的時候就會進行擴充套件:

分配我們講完了,下面來看看mm_free函式和合並空閒塊函式coalesce函式,合併的4種情況:

④ 分離的空閒連結串列

我們使用單向的空閒連結串列分配時間並沒有改善,我們現在來看看比較流行的分離儲存方法,在一個分離儲存的系統中,分配器維護著一個空閒連結串列陣列,每個空閒連結串列是一些大小不同的類。我們可以按照2的冪來劃分,比如1,2,4,8,16,32.......等等。

像上圖那樣,每個大小類都是2的冪,按照升序排列就是夥伴系統。我們要分配一個9位元組的空間,就需要從前往後依次尋找,找到了第5個空閒連結串列中的空間足夠就分割它,將不需要的插入到空閒連結串列中去。如果找不到合適的,比如需要17個字的空間,就向作業系統申請額外的堆儲存器。

申請:如果我們要在16位元組的空間中分配一個4位元組大小的空間,就會首先將這個16位元組的總空間分割成兩個8位元組大小的空間,其中空閒的那部分(左邊)叫做夥伴,被放置到空閒連結串列中。我們發現8位元組的空間依然大於我們要分配的空間,就再一次將8位元組的空間分割成兩部分,每個4位元組,剛好完成分割,這時候8位元組中的左邊部分也就是夥伴,被放置到空閒連結串列中。

釋放:需要釋放4位元組空間的時候,會與其夥伴進行空閒合併,形成一個8位元組大小的空閒空間,繼續發現另外的8位元組夥伴也是空閒的,繼續合併。直到遇到的夥伴已經被分配了才停止。

⑤ 垃圾收集

垃圾收集是一種很有用的方法,當使用了malloc分配了空間卻忘記了釋放,就會造成記憶體的極大浪費。垃圾收集就是使用特殊的方法,定期回收這部分不使用或者無效的空間。

當然收集的方法分為很多種,我們只講一下【標記清除演算法】:

垃圾收集器將儲存器視為一張有向的圖,根節點儲存在暫存器、棧變數或者虛擬儲存器的全域性變數中,子節點在堆中,每個子節點對於一個已分配的塊。白色為可到達,藍色為不可到達應該被回收的區域。

垃圾回收期就是維護著這種可達圖,釋放其中不可達的節點,返回給空閒連結串列。

像c和c++這類語音不能維持可達圖的精確表示,有些不可達的節點可能會被錯誤的標識為可達,它的垃圾收集器就是一個保守的垃圾收集器。加入到malloc包中就形成這樣的形式:

當我們需要空間的時候,呼叫了malloc函式,如果找不到合適的空間就會啟用垃圾收集器,希望回收一部分可利用的空間,垃圾收集器將代替程式執行free函式,釋放的空間返回以後重新呼叫malloc,如果還是失敗才從作業系統申請額外的儲存空間。

如何標記:

標記前的狀態是這樣的:

淡藍色的塊是已分配的頭部,6個塊都是未標記的。其中根節點指向塊4,4的又分別指向了3和6.其中3又指向了1,這就形成了一個有向的連結串列,其中只有2和5不能到達。

這時候我們使用標記函式,迴圈遍歷進行標記:

標記完成以後,形成如下所示:

由於塊1、3、4、6是可達的,所以都被標記了。2和5無法標記是垃圾

sweep函式釋放所有的未標記的塊:

清除塊2和塊5以後是這樣的:

我們之所以說c和c++是保守的垃圾收集器,是因為在標記階段的isPtr函式識別並不準確,c不知道輸入的引數是否是一個指標,也不知道指向的是否是一個有效載荷的位置。(這裡需要多讀讀,還有點兒不懂)

1.9 c程式中常見的10大儲存器相關錯誤


儲存器的錯誤總是令人沮喪的,特別是在運行了一段時間之後才顯示出來,就特別特別的煩人了,我們列舉一些常見的錯誤,僅供參考:

① 間接引用壞指標:將本來的地址引用寫成了內容scanf("%d", &val)寫成scanf("%d", val)

② 讀未初始化的儲存器:

在堆中申請了一塊空間:int *y = (int *)Malloc(n * sizeof(int));由於堆中的空間是未被初始化的,下面的使用就會出錯:y[i] += A[i][j] * x[j];推薦使用calloc函式

③ 允許棧緩衝溢位:

推薦使用fgets函式

④ 假設指標和它指向的物件是相同大小的

使用int **A = (int **)Malloc(n * sizeof(int));本來是想建立的一個int *的陣列,但是sizeof上面用到的確實int

⑤ 錯位

申請了n個空間,卻要訪問n+1處位置

⑥ 引用指標而不是它指向的物件

*size--; /* This should be (*size)-- */

其中,--和*有相同的優先順序,由於這是右結合。所以先--再*,就出錯了。

⑦ 誤解指標運算

p += sizeof(int); /* Should be p++ */

指標p++就會指向下一個位置,+= int的大小的話,就跳了幾個資料了

⑧ 引用不存在的變數

本地變數在棧中建立,函式結束以後就已經不存在了。

⑨ 引用已經釋放了的塊中的資料

在行10的時候已經將塊釋放了,在行14的時候又在使用