對操作系統中的各種緩存進行一下梳理:
(一)高速緩沖存儲器cache
1、cache的工作原理
高速緩沖存儲器利用程序訪問的局部性原理,把程序中正在使用的部分存放在一個高速的、容量較小的cache中,使CPU的訪存操作大多數針對cache進行,從而使程序的執行速度大大提高。
當CPU發出讀請求時,如果訪存地址在cache中命中,就將此地址轉換成cache地址,直接對cache進行讀操作,與主存無關;如果cache不命中,則仍需訪問主存,將此字所在的塊一次從主存調入cache中。若此時cache已滿,則需根據某種替換算法(如最久未使用算法(LRU)、先進先出算法(FIFO)、最近最少使用算法(LFU)、非最近使用算法(NMRU)等),用這個塊替換掉cache中原來的某塊信息。值得注意的是,CPU與cache之間的數據交換以字為單位,而cache和主存之間的數據交換則是以cache塊為單位的。
CPU對cache的寫入更改了cache的內容,故需選擇某種策略使得cache內容和主存內容保持一致,主要為:
全寫法(cache-through):當CPU對cache寫命中時,必須把數據同時寫入cache和主存。當某一塊需要替換時,不必把這一塊寫回主存,將新調入的塊直接覆蓋即可。這種方法實現簡單,能隨時保持主存數據的正確性,缺點是增加了訪存次數,降低了cache的效率。
寫回法(cache-back):當CPU對cache寫命中時,只修改cache的內容,而不立即寫入主存,只有當此塊被換出時才寫回主存。這種方法減少了訪存次數,但存在不一致的隱患。采用這種策略時,每個cache行必須設置一個標志位(臟位),以反映此塊是否被CPU修改過。
如果寫不命中,還需考慮是否調塊至cache的問題,非寫分配法只寫入內存,不進行調塊,非寫分配法通常與全寫法合用。寫分配法除了要寫入主存外,還要將該塊從主存調入至cache,通常與寫回法合用。
下圖轉載自: https://blog.csdn.net/wangwei222/article/details/79748597
2、cache和主存的映射方式
(1)直接映射: 主存數據塊只能裝入cache中的唯一位置。若這個位置已有內容,則產生塊沖突,原來的塊被無條件地替換出去(無需使用替換算法)。直接映射實現簡單,但不夠靈活。直接映射的關系可定義為:
j= i mod c // j為cache塊號,i為主存塊號,c是cache中的總塊數
下圖轉載自: https://www.cnblogs.com/yutingliuyl/p/6773684.html
(2)全相聯映射:可以把主存數據塊放入cache的任何位置,比較靈活,沖突概率低,但地址變換速度慢,實現成本高,查找時需全部遍歷一遍。下圖中左邊為主存,右邊為cache
(3)組相聯映射:將cache分成大小相同的組,主存中的一個數據塊可以裝入到一個組內的任何一個位置,即組間采取直接映射,組內采用全相聯,每組有多少塊就稱為幾路組相聯,可用下式表示:
j = i mod q // j為緩存塊號, i為主存塊號, q為cache組數,當q為1時為全相聯映射, q為cache塊數時為直接映射
圖源: https://www.cnblogs.com/jasmine-Jobs/p/6959261.html
最后貼幾個關於cache的層級:細說Cache-L1/L2/L3 為什么CPU緩存會分為一級緩存L1、L2、L3 CPU緩存刷新
從L1到L3,容量越來越大,速度越來越慢,L1更加注重速度, L2和L3則更加注重節能和容量。
關於緩存行:CPU cache和緩存行 CPU cache
(二)地址轉換后援緩沖器TLB
下述內容參考:https://www.cnblogs.com/alantu2018/p/9000777.html
上述cache為在得到物理地址之后,對訪問內存的一個加速,而系統虛擬地址需要通過頁表轉換為物理地址,頁表一般都很大,並且存放在內存中,所以處理器引入MMU后,讀取指令、數據需要訪問兩次內存:首先通過查詢頁表得到物理地址,然后訪問該物理地址讀取指令、數據。為了減少因為MMU導致的處理器性能下降,引入了TLB,TLB是Translation Lookaside Buffer的簡稱,可翻譯為“地址轉換后援緩沖器”,也可簡稱為“快表”。簡單地說,TLB就是頁表的Cache,其中存儲了當前最可能被訪問到的頁表項,其內容是部分頁表項的一個副本。只有在TLB無法完成地址翻譯任務時,才會到內存中查詢頁表(還有的說是查找時快表和慢表,即放在主存中的頁表同時進行,若快表中有此邏輯頁號,則將其對應的物理頁號送入主存地址寄存器,並使慢表的查找作廢),這樣就減少了頁表查詢導致的處理器性能下降。
TLB中的項由兩部分組成:標識和數據。標識中存放的是虛地址的一部分,而數據部分中存放物理頁號、存儲保護信息以及其他一些輔助信息。虛地址與TLB中項的映射方式有三種:全關聯方式、直接映射方式、分組關聯方式。直接映射方式是指每一個虛擬地址只能映射到TLB中唯一的一個表項。假設內存頁大小是8KB,TLB中有64項,采用直接映射方式時的TLB變換原理如下圖所示:
因為頁大小是8KB,所以虛擬地址的0-12bit作為頁內地址偏移。TLB表有64項,所以虛擬地址的13-18bit作為TLB表項的索引。假如虛擬地址的13-18bit是1,那么就會查詢TLB的第1項,從中取出標識,與虛擬地址的19-31位作比較,如果相等,表示TLB命中,反之,表示TLB失靶。TLB失靶時,可以由硬件將需要的頁表項加載入TLB,也可由軟件加載,具體取決於處理器設計。TLB命中時,此時翻譯得到的物理地址就是TLB第1項中的標識(即物理地址13-31位)與虛擬地址0-12bit的結合。在地址翻譯的過程中還會結合TLB項中的輔助信息判斷是否發生違反安全策略的情況,比如:要修改某一頁,但該頁是禁止修改的,此時就違反了安全策略,會觸發異常。
TLB表項更新可以有TLB硬件自動發起,也可以有軟件主動更新
1. TLB miss發生后,CPU從RAM獲取頁表項,會自動更新TLB表項
2. TLB中的表項在某些情況下是無效的,比如進程切換,更改內核頁表等,此時CPU硬件不知道哪些TLB表項是無效的,只能由軟件在這些場景下,刷新TLB。
在linux kernel軟件層,提供了豐富的TLB表項刷新方法,但是不同的體系結構提供的硬件接口不同。比如x86_32僅提供了兩種硬件接口來刷新TLB表項:
1. 向cr3寄存器寫入值時,會導致處理器自動刷新非全局頁的TLB表項
2. 在Pentium Pro以后,invlpg匯編指令用來使指定線性地址的單個TLB表項無效。
TLB內部存放的基本單位是TLB表項,TLB容量越大,所能存放的TLB表項就越多,TLB命中率就越高,但是TLB的容量是有限的。目前 Linux內核默認采用4KB大小的小頁面,如果一個程序使用512個小頁面,即2MB大小,那么至少需要512個TLB表項才能保證不會出現 TLB Miss的情況。但是如果使用2MB大小的大頁,那么只需要一個TLB表項就可以保證不會出現 TLB Miss的情況。對於消耗內存以GB為單位的大型應用程序,還可以使用以1GB為單位的大頁,從而減少 TLB Miss的情況。
(三)頁緩存和塊緩存
上述緩存機制為所需數據已經在物理內存中,而頁緩存和塊緩存則是在進行磁盤io時使用的。
(1)頁緩存:針對以頁為單位的所有操作,頁緩存實際上負責了塊設備的大部分緩存工作。頁緩存的任務在於獲得一些物理內存頁,以加速在塊設備上按頁為單位執行的操作。
(2)塊緩存:以塊為操作單位,在進行io操作時,存取的單位是設備的各個塊而不是整個內存頁,盡管也長度對所有文件系統是相同的,但是塊長度取決於特定的文件系統或其設置,因而塊緩存必須能夠處理不同長度的塊。
1、頁緩存
此部分內容來自:https://blog.csdn.net/gdj0001/article/details/80136364 及《深入Linux內核架構》
頁緩存是Linux內核一種重要的磁盤高速緩存,以頁為大小進行數據緩存,它將磁盤中最常用和最重要的數據存放到部分物理內存中,使得系統訪問塊設備時可以直接從主存中獲取塊設備數據,而不需從磁盤中獲取數據。在大多數情況下,內核在讀寫磁盤時都會使用頁緩存。內核在讀文件時,首先在已有的頁緩存中查找所讀取的數據是否已經存在。如果該頁緩存不存在,則一個新的頁將被添加到緩存中,然后用從磁盤讀取的數據填充它。如果當前物理內存足夠空閑,那么該頁將長期保留在緩存中,使得其他進程再使用該頁中的數據時不再訪問磁盤。寫操作與讀操作時類似,直接在頁緩存中修改數據,但是頁緩存中修改的數據(該頁此時被稱為Dirty Page)並不是馬上就被寫入磁盤,而是延遲幾秒鍾,以防止進程對該頁緩存中的數據再次修改。
(1)管理和查找緩存的頁:
Linux使用基數樹來管理頁緩存中包含的頁,基數樹是不平衡的,在樹的不同分支之間可能有任意數目的高度差。樹本身由兩種不同的數據結構組成,葉子是page結構的實例,源代碼中葉子使用的是void指針,意味着基數樹還可以用於其他目的。樹的結點具備兩種搜索標記,二者用於指定給定頁當前是否是臟的,或該頁是否正在向底層塊設備回寫。標記不僅對葉節點設置,還一直向上設置到根節點。如果某個層次n+1的結點設置了某個標記,其在層次n的父節點也會獲得該標記。使內核可以判斷,在某個范圍內是否有一頁或多頁設置了某個標記位。
(2)回寫修改的數據:
內核同時提供了如下幾個同步方案:
- 幾個專門的內核守護進程在后台運行,稱為pdflush,它們將周期性激活而不考慮頁緩存中當前的情況,這些守護進程掃描緩存中的頁,將超出一定時間沒有與底層塊設備同步的頁寫回。
- pdflush的第二種運作模式是:如果緩存中修改的數據項數目在短期內顯著增加,則由內核激活pdflush
- 提供相關的系統調用。可由用戶或應用程序通知內核寫回所有未同步的數據,如sync調用
通常,修改文件或其他按頁緩存的對象時,只會修改頁的一部分。為節省時間,內核在寫操作期間,將緩存中的每一頁划分為較小的單位,稱為緩沖區。在同步數據時,內核可以將回寫操作限制於那些實際發生了修改的較小單位上。
(3)地址空間address_space
為管理可以按整頁處理和緩存的各種不同對象,內核使用了address_space地址空間來抽象,將內存中的頁和特定的塊設備(或任何其他系統單元或系統單元的一部分)關聯起來。每個地址空間都有一個“宿主”作為其數據來源,大多數情況下宿主都是表示一個文件的iNode,因為所有現存的iNode都關聯到其超級塊,內核只需要掃描所有超級塊的鏈表,並跟隨相關的iNode即可獲得被緩存頁的列表。
地址空間實現了內存頁面和后備存儲器兩個單元之間的一種轉換機制:
- 內存中的頁分配到每個地址空間。這些頁的內容可以由用戶進程或內核本身使用各式各樣的方法操作,這些數據表示了緩存的內容。
- 后備存儲器指定了填充地址空間頁的數據的來源。地址空間關聯到處理器的虛擬地址空間,是由處理器在虛擬內存中管理的一個區域到源設備上對應位置之間的一個映射,如果訪問了虛擬內存中的某個位置,該位置沒有關聯到物理內存頁,內核可根據地址空間結構來找到讀取數據的來源。
內核中每個存放數據的物理頁幀都對應一個管理結構體struct page,如下:
struct page { /*flags描述page當前的狀態和其他信息,如當前的page是否是臟頁PG_dirty;是否是最新的已經同步到后備存儲的頁PG_uptodate; 是否處於lru鏈表上等*/ unsigned long flags; /*_count:引用計數,標識內核中引用該page的次數,如果要操作該page,引用計數會+1,操作完成之后-1。當該值為0時,表示沒有引用該page的位置,所以該page可以被解除映射,這在內存回收的時候是有用的*/ atomic_t _count; /*mapcount:頁表被映射的次數,也就是說page同時被多少個進程所共享,初始值為-1,如果只被一個進程的頁表映射了,該值為0。
注意區分_count和_mapcount,_mapcount表示的是被映射的次數,而_count表示的是被使用的次數;被映射了不一定被使用,但是被使用之前肯定要先被映射*/ atomic_t _mapcount; unsigned long private;//私有數據指針 /*_mapping有三種含義: a.如果mapping = 0,說明該page屬於交換緩存(swap cache); 當需要地址空間時會指定交換分區的地址空間swapper_space; b.如果mapping != 0, bit[0] = 0, 說明該page屬於頁緩存或者文件映射,mapping指向文件的地址空間address_space; c.如果mapping != 0, bit[0] !=0 說明該page為匿名映射,mapping指向struct anon_vma對象;*/ struct address_space *mapping; pgoff_t index; //在頁緩存中的索引 struct list_head lru;//當page被用戶態使用或者是當做頁緩存使用的時候,將該page連入zone中的lru鏈表,供內存回收使用 void* virtual; };
page結構體中index和mapping分別為在頁緩存中的索引和指向頁所述地址空間的指針,在系統需要判斷一個頁是否已經緩存時,使用函數find_get_page(), 該函數根據mapping和index進行查找。而頁是屬於文件的,文件中的位置是按字節偏移量指定的,而非頁緩存中的偏移量,兩者之間的轉換通過如下實現:
index = ppos >> PAGE_CACHE_SHIFT //ppos是文件的字節偏移量,而index是頁緩存中對應的偏移量
Linux使用基數樹管理頁緩存中的頁,一個文件在內存中具有唯一的inode結構標識,inode結構中有該文件所屬的設備及其標識符,因而,根據一個inode能夠確定其對應的后備設備。address_space將文件在物理內存中的頁緩存和文件及其后備設備關聯起來,可以說address_space結構體是將頁緩存和文件系統關聯起來的橋梁,其組成如下:
struct address_space { struct inode* host;/*指向與該address_space相關聯的inode節點,inode節點與address_space之間是一一對應關系*/ struct radix_tree_root page_tree;/*所有頁形成的基數樹根節點*/ spinlock_t tree_lock;/*保護page_tree的自旋鎖*/ unsigned int i_map_writable;/*VM_SHARED的計數*/ struct prio_tree_root i_mmap; struct list_head i_map_nonlinear; spinlock_t i_map_lock;/*保護i_map的自旋鎖*/ atomic_t truncate_count;/*截斷計數*/ unsigned long nrpages;/*頁總數*/ pgoff_t writeback_index;/*回寫的起始位置*/ struct address_space_operation* a_ops;/*操作表*/ unsigned long flags;/*gfp_mask掩碼與錯誤標識*/ struct backing_dev_info* backing_dev_info;/*預讀信息*/ spinlock_t private_lock;/*私有address_space鎖*/ struct list_head private_list;/*私有address_space鏈表*/ struct address_space* assoc_mapping;/*相關的緩沖*/ }
一個文件inode對應一個地址空間address_space,而一個address_space對應一個頁緩存基數樹。address_space中host成員指向其所有者的iNode結點,page_tree為該inode結點對應文件的所有頁的基數樹,i_mmap為與該地址空間相關聯的所有進程的虛擬地址區間vm_area_struct所對應的整個進程地址空間mm_struct形成的優先查找樹的根節點;vm_area_struct中如果有后備存儲,則存在prio_tree_node結構體,通過該prio_tree_node和prio_tree_root結構體,構成了所有與該address_space相關聯的進程的一棵優先查找樹,便於查找所有與該address_space相關聯的進程,頁緩存、文件和進程的關系如下:
2、關於mmap
說到頁緩存再說一下mmap的問題,一直看到說mmap文件映射可實現像操作普通內存一樣操作文件,這就讓我想mmap映射的文件到底有沒有映射進內存,答案應該是有的,用戶進程應該不會直接操作磁盤上的文件數據,還是需要將其拷貝至內存中。那么第二個問題就是,都說普通文件讀寫需要復制兩次,而內存映射文件mmap只需復制一次,至於原因,看到的解釋很多是這樣的:普通的讀寫文件為:進程調用read或是write后會陷入內核,因為這兩個函數都是系統調用,進入系統調用后,內核開始讀寫文件,假設內核在讀取文件,內核首先把文件讀入自己的內核空間,讀完之后進程在內核回歸用戶態,內核把讀入內核內存的數據再copy進入進程的用戶態內存空間。實際上我們同一份文件內容相當於讀了兩次,先讀入內核空間,再從內核空間讀入用戶空間。而mmap的作用是將進程的虛擬地址空間和文件在磁盤的位置做一一映射,做映射之后,讀寫文件雖然同樣是調用read和write但是觸發的機制已經不一樣了,mmap會返回來一個指針,指向進程邏輯地址空間的一個位置。這個時候的過程是這樣的,首先read會改寫為讀內存操作,讀內存的時候,系統發現該地址對應的物理內存是空的,觸發缺頁機制,然后再通過mmap建立的映射關系,從硬盤上將文件讀入物理內存。也就是說mmap把文件直接映射到了用戶空間,沒有經歷內核空間。
對於上述解釋,之前一直在想普通的文件讀寫為什么要把數據從內核空間拷貝至用戶空間,而不直接拷貝到用戶空間映射的物理頁面,想說是系統調用在內核處理,內核很自然地就把數據拷貝到其對應的物理頁面了,然后由於用戶空間不能訪問內核空間的數據(針對內核保護,所以用戶空間的虛擬地址應該不能直接映射到內核地址所映射的物理頁面),所以需要把數據拷貝至用戶空間,那么mmap也在內核實現,為什么可以直接把數據拷貝到用戶空間映射的物理頁面而不經過內核空間呢。
然后這兩天看到了頁緩存,又看到另一個對mmap的解釋:所有的文件內容的讀取(無論一開始是命中頁緩存還是沒有命中頁緩存)最終都是直接來源於頁緩存。當將數據從磁盤復制到頁緩存之后,還要將頁緩存的數據通過CPU復制到read調用提供的緩沖區中,這就是普通文件IO需要的兩次復制數據復制過程。其中第一次是通過DMA的方式將數據從磁盤復制到頁緩存中,本次過程只需要CPU在一開始的時候讓出總線、結束之后處理DMA中斷即可,中間不需要CPU的直接干預,CPU可以去做別的事情;第二次是將數據從頁緩存復制到進程自己的的地址空間對應的物理內存中,這個過程中需要CPU的全程干預,浪費CPU的時間和額外的物理內存空間。普通文件io后進程地址空間對應如下,圖源水印:
而通過內存映射IO---mmap,進程不但可以直接操作文件對應的物理內存,減少從內核空間到用戶空間的數據復制過程,同時可以和別的進程共享頁緩存中的數據,達到節約內存的作用。當映射一個文件到內存中的時候,內核將虛擬地址直接映射到頁緩存中。如果文件的內容不在物理內存中,操作系統不會將所映射的文件部分的全部內容直接拷貝到物理內存中,而是在使用虛擬地址訪問物理內存的時候通過缺頁異常將所需要的數據調入內存中。如果文件本身已經存在於頁緩存中,則不再通過磁盤IO調入內存。如果采用共享映射的方式,那么數據在內存中的布局如下圖所示:
按照上述解釋,所有文件內容讀取都是來源於頁緩存,那么mmap也就不是像第一種解釋所說的將文件內容映射至用戶空間對應的物理內存(其實要是說用戶空間映射的物理內存就是頁緩存的話也可以這么說),而是將文件內容拷貝至頁緩存,然后用戶空間虛擬地址直接映射到緩存頁對應的物理地址。那么問題就是:既然都可以直接映射到頁緩存了,為什么普通的文件讀寫非要進行將數據從頁緩存拷貝到用戶空間映射的物理頁面的過程呢,對此我認為是:由於頁緩存的大小總是有限的,而其中緩存的一般而言都是最近讀寫的文件內容,那么如果所有文件io都映射到頁緩存的話,緩存的換入換出肯定十分頻繁,這樣的話就違背他作為緩存的本來目的了,因此需要將其拷貝至其他內存,即用戶空間對應的物理頁面中存放,這樣下一次有新的文件讀入時可以有位置將其加入緩存中,而之前用戶空間讀入的文件內容也存放在內存中,減少頁緩存的換入換出。同時考慮到復制消耗的CPU及內存,又通過 mmap的方式提供用戶空間對頁緩存的直接映射。上述僅為我個人的理解與想法,如果看到這篇博客的人有不同的想法,還請告知。
今天看了源碼和使用之后,從使用的角度進行了思考:我們使用mmap的時候,系統會首先分配一段虛擬內存,並且在該過程中建立了vma和映射文件的關聯,向上返回虛擬內存的首地址,此時還沒有建立和物理內存的映射關系。等到第一次訪問虛擬地址時,發生缺頁異常,將對應的文件內容讀取到頁緩存,並建立虛擬內存和頁緩存的映射關系,如此只有一次將文件數據讀取頁緩存的過程。然而在使用read系統調用時,我們一般會先分配一個緩沖區buffer用於存放讀取的文件內容,然后調用read系統調用,這里涉及到另一個問題:我在程序里動態分配的這個buffer此時是不是已經分配了物理內存,即動態內存何時分配相應的物理內存(也是第一次訪問的時候?),如果在執行系統調用時buffer已經建立了物理映射,read系統調用類似地讀取文件內容到頁緩存中,由於buffer已經建立了到物理內存的映射,此時無法也沒必要將其再映射到頁緩存中,那么就需要將數據從頁緩存拷貝至buffer已經映射的物理內存中,這樣就多了一次將數據從內核空間拷貝至用戶空間的過程。
為了了解mmap的方式,去看了下mmap部分的源碼實現, 部分內容參考:https://blog.csdn.net/SweeNeil/article/details/83685812 mmap系列及 https://www.cnblogs.com/jikexianfeng/articles/5647994.html
源碼基於3.10.1
系統調用mmap在內核中的實現為:
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len, unsigned long, prot, unsigned long, flags, unsigned long, fd, off_t, offset) { if (offset & ((1 << PAGE_SHIFT) - 1)) //判斷offset是否對齊到頁大小 return -EINVAL; return sys_mmap_pgoff(addr, len, prot, flags, fd, offset >> PAGE_SHIFT); }
可以看到mmap又調用了mmap_pgoff:
SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len, unsigned long, prot, unsigned long, flags, unsigned long, fd, unsigned long, pgoff) { struct file *file = NULL; unsigned long retval = -EBADF; if (!(flags & MAP_ANONYMOUS)) {//文件映射 audit_mmap_fd(fd, flags); if (unlikely(flags & MAP_HUGETLB)) return -EINVAL; file = fget(fd); //獲取file結構體 if (!file) goto out; if (is_file_hugepages(file)) len = ALIGN(len, huge_page_size(hstate_file(file))); //調整len } else if (flags & MAP_HUGETLB) {//匿名映射大頁內存 struct user_struct *user = NULL; struct hstate *hs = hstate_sizelog((flags >> MAP_HUGE_SHIFT) & SHM_HUGE_MASK); if (!hs) return -EINVAL; len = ALIGN(len, huge_page_size(hs)); /* * VM_NORESERVE is used because the reservations will be * taken when vm_ops->mmap() is called * A dummy user value is used because we are not locking * memory so no accounting is necessary */ file = hugetlb_file_setup(HUGETLB_ANON_FILE, len, VM_NORESERVE, &user, HUGETLB_ANONHUGE_INODE, (flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK); if (IS_ERR(file)) return PTR_ERR(file); } flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE); retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);//最終調用該函數完成映射 if (file) fput(file); out: return retval; }
mmap_pgoff完成部分准備工作,然后調用函數vm_mmap_pgoff,該函數對進程的虛擬地址空間進行映射:
unsigned long vm_mmap_pgoff(struct file *file, unsigned long addr, unsigned long len, unsigned long prot, unsigned long flag, unsigned long pgoff) { unsigned long ret; struct mm_struct *mm = current->mm; unsigned long populate; ret = security_mmap_file(file, prot, flag);//selinux相關 if (!ret) { down_write(&mm->mmap_sem); ret = do_mmap_pgoff(file, addr, len, prot, flag, pgoff, &populate);//主要函數 up_write(&mm->mmap_sem); if (populate) mm_populate(ret, populate); } return ret; }
而vm_mmap_pgoff主要調用的是do_mmap_pgoff,這個函數的代碼比較長就不貼出來了,主要就是使用函數get_unmapped_area獲取未映射地址,然后調用mmap_region進行映射。
get_unmapped_area(struct file *file, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags) { unsigned long (*get_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long); unsigned long error = arch_mmap_check(addr, len, flags); if (error) return error; /* Careful about overflows.. */ if (len > TASK_SIZE) return -ENOMEM; get_area = current->mm->get_unmapped_area;//匿名映射直接調用該函數獲取虛擬內存空間 if (file && file->f_op && file->f_op->get_unmapped_area) get_area = file->f_op->get_unmapped_area;//文件映射則使用文件的相關哈數獲取虛擬內存空間 addr = get_area(file, addr, len, pgoff, flags); if (IS_ERR_VALUE(addr)) return addr; if (addr > TASK_SIZE - len) return -ENOMEM; if (addr & ~PAGE_MASK) return -EINVAL; addr = arch_rebalance_pgtables(addr, len); error = security_mmap_addr(addr); return error ? error : addr; }
可以看到在get_unmapped_area中主要進行了匿名映射與文件映射的區分。如果為匿名映射,直接使用mm->get_unmapped_are直接獲取虛擬內存空間(VMA),如果為文件映射,那么從file->f_op->get_unmapped_area中獲取虛擬內存空間,但是通過看源碼可以發現ext2_file_operation或ext3_file_operation都沒有定義get_unmapped_area的方法,因此還是調用內存描述符中的get_unmapped_area,該方法根據不同架構調用arch_get_unmapped_area()函數,在arch_get_unmapped_area函數中當addr非空,表示指定了一個特定的優先選用地址,內核會檢查該區域是否與現存區域重疊,由find_vma函數完成查找功能。當addr為空或是指定的優先地址不滿足分配條件時,內核必須遍歷進程中可用的區域,設法找到一個大小適當的空閑區域,由vm_unmapped_area函數做實際的工作,看一下vm_unmapped_area:
static inline unsigned long vm_unmapped_area(struct vm_unmapped_area_info *info) { if (!(info->flags & VM_UNMAPPED_AREA_TOPDOWN)) return unmapped_area(info); else return unmapped_area_topdown(info); }
該函數根據標志位的不同調用了不同的unmapped_area函數,他們的區別在於unmapped_area函數完成從低地址向高地址創建新的映射,而unmapped_area_topdown函數完成從高地址向低地址創建新的映射。unmapped_area函數代碼就不貼上來了,該函數先檢查進程虛擬地址空間中可用於映射空間的邊界,不滿足要求返回錯誤代號到上層應用程序。當滿足時,執行以下操作,以找到最小的空閑的虛擬地址空間滿足這次分配請求,便於兩個相鄰的vma區合並:
在while循環中 unmapped_area具體步驟如下:
1. 從vma紅黑樹的根開始遍歷
2. 若當前結點有左子樹則遍歷其左子樹,否則指向其右孩子。
3. 當某結點rb_subtree_gap可能是最后一個滿足分配請求的空隙時,遍歷結束。 rb_subtree_gap是當前結點與其前驅結點之間空隙 和 當前結點其左右子樹中的結點間的最大空隙的最大值。
4. 檢測這個結點,判斷這個結點與其前驅結點之間的空隙是否滿足分配請求。滿足則跳出循環。
5. 不滿足分配請求時,指向其右孩子,判斷其右孩子的rb_subtree_gap是否滿足當前請求。
6. 滿足則返回到2。不滿足,回退其父結點,返回到4
unmapped_area完成之后還是回到函數do_mmap_pgoff,get_unmapped_area的一系列操作只是返回了新線性區的地址,然后do_mmap_pgoff會對get_unmapped_area返回的addr進行校驗,如果addr不滿足頁對齊,那么說明get_unmapped_area函數返回的是一個錯誤碼,直接將這個錯誤碼返回即可。如果addr正常,則調用calc_vm_prot_bits函數將mmap系統調用的prot 參數合並到內部使用的 vm_flags 中,之后判斷是否為文件映射,若是則獲取文件的inode結點,當打開一個文件之后,系統就會以inode來識別這個文件。對vm_flags的一系列設置之后,最終調用mmap_region:
if (find_vma_links(mm, addr, addr + len, &prev, &rb_link, &rb_parent)) { if (do_munmap(mm, addr, len)) return -ENOMEM; goto munmap_back; }
mmap_region調用find_vma_links函數確定處於新區間之前的線性區對象的位置,以及在紅-黑樹中新線性區的位置。同時find_vma_link函數也檢查是否還存在與新區建重疊的線性區。如果這樣就調用do_munmap函數刪除新的區間,然后重復判斷。檢查無誤后,再檢查內存的可用性,可用就繼續往下通過vma_merge函數返回了一個vma:
/* * Can we just expand an old mapping? */ vma = vma_merge(mm, prev, addr, addr + len, vm_flags, NULL, file, pgoff, NULL); if (vma) goto out;
調用vma_merge檢查前一個線性區是否可以以這樣的方式進行擴展來包含新的區間。同時需要保證,前一個線性區必須與在vm_flags局部變量中存放的那些線性區具有完全相同的標志。如果前一個線性區可以擴展,那么vm_merge函數就會試圖把它與隨后的線性區進行合並,如果合並成功就直接跳轉到out。如果合並不成功,就繼續往下執行,調用kmem_cache_zalloc函數,其中調用了slab分配函數為新的線性區分配一個vm_area_struct結構,之后開始對vma進行賦值,初始化新的線性區對象。
vma->vm_file = get_file(file); error = file->f_op->mmap(file, vma); if (error) goto unmap_and_free_vma;
然后判斷是否為文件映射,若是則獲取文件描述符,將其賦給vm_file,如果是匿名映射,則vm_file設為dev/zero,這樣不需要對所有頁面進行提前置0,只有當訪問到某具體頁面的時候才會申請一個0頁。
mmap_region之后就是調用vma_link把新的線性區插入到線性區鏈表的紅黑樹中,最后將內存描述符的total_vm字段中的進程地址空間大小進行了增加,最后返回addr。
函數的大致流程就是這樣,最后我們重點還是關注文件映射,上面可以看到文件映射調用了file->f_op->mmap,以ext3文件系統為例,最終會調用generic_file_mmap.
const struct file_operations ext3_file_operations = { .llseek = generic_file_llseek, .read = do_sync_read, .write = do_sync_write, .aio_read = generic_file_aio_read, .aio_write = generic_file_aio_write, .unlocked_ioctl = ext3_ioctl, #ifdef CONFIG_COMPAT .compat_ioctl = ext3_compat_ioctl, #endif .mmap = generic_file_mmap, .open = dquot_file_open, .release = ext3_release_file, .fsync = ext3_sync_file, .splice_read = generic_file_splice_read, .splice_write = generic_file_splice_write, };
看一下generic_file_mmap:
int generic_file_mmap(struct file * file, struct vm_area_struct * vma) { struct address_space *mapping = file->f_mapping; if (!mapping->a_ops->readpage) return -ENOEXEC; file_accessed(file); vma->vm_ops = &generic_file_vm_ops; return 0; }
這里首先獲取文件f_mapping指向的地址空間,然后判斷其readpage是否為空,若空則返回錯誤碼,因為之后需要使用該函數讀取真正的文件內容,所以不能為空。這里的mapping->a_ops就是文件address_space對特定地址空間的操作函數指針,還是以ext3文件系統為例,具體的函數賦值為:
static const struct address_space_operations ext3_writeback_aops = { .readpage = ext3_readpage, .readpages = ext3_readpages, .writepage = ext3_writeback_writepage, .write_begin = ext3_write_begin, .write_end = ext3_writeback_write_end, .bmap = ext3_bmap, .invalidatepage = ext3_invalidatepage, .releasepage = ext3_releasepage, .direct_IO = ext3_direct_IO, .migratepage = buffer_migrate_page, .is_partially_uptodate = block_is_partially_uptodate, .error_remove_page = generic_error_remove_page, };
函數generic_file_mmap最后還將該vma對應的操作賦值為generic_file_vm_ops:
const struct vm_operations_struct generic_file_vm_ops = { .fault = filemap_fault, .page_mkwrite = filemap_page_mkwrite, .remap_pages = generic_file_remap_pages, };
mmap文件映射的流程大致就是這樣,主要就是分配一個可用的vma,然后將其和文件對應,這里還沒有真正地將文件的內容映射進內存。等到真正讀取內容的時候發生缺頁異常,內核調用函數do_page_fault(/arch/x86/mm/fault.c)來處理,所需處理的情況比較復雜,這里我還是重點關注文件映射部分的缺頁異常,其他部分暫不涉及。
該函數進一步調用__do_page_fault,如果異常並非出現在中斷期間,也有相關的上下文,則內核檢查進程的地址空間是否包含異常地址所在的區域:
vma = find_vma(mm, address);
find_vma用於完成上述工作,在內核發現地址有效或無效時,會分別跳轉到good_area和bad_area,當找到異常地址所在的區域后,還需根據權限判斷當前的訪問是否有效,如error_code是不可寫/不可執行的錯誤,但addr所屬vma線性區本身就不可寫/不可執行,那么就直接返回,因為問題根本不是缺頁,而是訪問無效,之后調用handle_mm_fault負責校正缺頁異常。
fault = handle_mm_fault(mm, vma, address, flags);
handle_mm_fault不依賴底層體系結構,其在內存管理的框架下獨立於系統實現。該函數確認在各級頁目錄中,通向對應於異常地址的頁表項的各個頁目錄項都存在,不存在則分配。最后調用函數handle_pte_fault分析缺頁異常的原因:
int handle_pte_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *pte, pmd_t *pmd, unsigned int flags) { pte_t entry; spinlock_t *ptl; entry = *pte; //pte為指向相關頁表項(pte_t)的指針,此處獲得頁表項 if (!pte_present(entry)) {//頁不在物理內存中 if (pte_none(entry)) {/*沒有對應的頁表項,則內核必須從頭開始加載該頁,對匿名映射稱之為按需分配,對基於文件的映射稱為按需調頁。如果vm_ops為空,則必須使用do_anonymous_page返回一個匿名頁*/ if (vma->vm_ops) {//如果該vma的操作函數集合實現了fault函數,說明是文件映射而不是匿名映射,將調用do_linear_fault分配物理頁 if (likely(vma->vm_ops->fault)) return do_linear_fault(mm, vma, address, pte, pmd, flags, entry); } return do_anonymous_page(mm, vma, address, pte, pmd, flags);//匿名映射分配物理頁 } if (pte_file(entry))//檢查頁表項是否屬於非線性映射 return do_nonlinear_fault(mm, vma, address, pte, pmd, flags, entry); return do_swap_page(mm, vma, address, pte, pmd, flags, entry);//該頁已換出,從交換區換入 } //下方是關於寫時復制的處理,此處不貼出
之前在處理mmap系統調用時,通過vma->vm_ops = &generic_file_vm_ops給vma->vm_ops進行賦值,因此直接調用函數do_linear_fault進行處理,該函數在轉換一些參數之后,將工作委托給__do_fault:
ret = vma->vm_ops->fault(vma, &vmf);
__do_fault中調用定義好的fault函數,將所需的文件數據讀入到內存頁,從前面mmap系統調用可知vma->vm_ops->fault賦值為filemap_fault,該函數首先去頁緩存中查找所需的頁是否存在,由於現在剛完成mmap,對應的頁肯定不在頁緩存中,因此調用函數page_cache_read
no_cached_page: /* * We're only likely to ever get here if MADV_RANDOM is in * effect. */ error = page_cache_read(file, offset);
page_cache_read分配一個頁面,將其加入頁緩存,並調用mapping->a_ops->readpage讀取數據,以ext3文件系統為例,mapping->a_ops->readpage賦值為ext3_readpage,該函數基於io調度將文件內容讀取到分配的緩存頁中。
static int page_cache_read(struct file *file, pgoff_t offset) { struct address_space *mapping = file->f_mapping; struct page *page; int ret; do { page = page_cache_alloc_cold(mapping); if (!page) return -ENOMEM; ret = add_to_page_cache_lru(page, mapping, offset, GFP_KERNEL); if (ret == 0) ret = mapping->a_ops->readpage(file, page); else if (ret == -EEXIST) ret = 0; /* losing race to add is OK */ page_cache_release(page); } while (ret == AOP_TRUNCATED_PAGE); return ret; }
這樣頁緩存中就有了該文件內容對應的頁,將其賦值給vmf->page,返回至__do_fault,之后的操作流程如下:
mmap系統調用的flags參數有幾個選項,其中MAP_SHARED表示創建一個共享映射的區域,多個進程可以通過共享映射方式來映射一個文件,這樣其他進程也可以看到映射內容的改變,修改后的內容會同步到磁盤文件中;MAP_PRIVATE則是創建一個私有的寫時復制的映射。多個進程可以通過私有映射的方式來映射一個文件,這樣其他進程不會看到映射內容的改變,修改后的內容也不會同步到磁盤文件中;MAP_ANONYMOUS創建一個匿名映射,即沒有關聯到文件的映射,還有其他參數此處不再說明。回到函數__do_fault,如果是對私有映射的寫訪問,那么需要拷貝該頁的一個副本,這樣的話其實就還是兩次復制操作。
但如果是共享映射的話,則調用vma->vm_ops->page_mkwrite通知進程地址空間page變為可寫,一個頁面變成可寫,那么進程有可能需要等待這個page的內容回寫成功。
page = vmf.page; if (flags & FAULT_FLAG_WRITE) {//寫訪問 if (!(vma->vm_flags & VM_SHARED)) {//私有映射 page = cow_page; anon = 1; copy_user_highpage(page, vmf.page, address, vma); __SetPageUptodate(page); } else {//共享映射 /* * If the page will be shareable, see if the backing * address space wants to know that the page is about * to become writable */ if (vma->vm_ops->page_mkwrite) { int tmp; unlock_page(page); vmf.flags = FAULT_FLAG_WRITE|FAULT_FLAG_MKWRITE; tmp = vma->vm_ops->page_mkwrite(vma, &vmf); if (unlikely(tmp & (VM_FAULT_ERROR | VM_FAULT_NOPAGE))) { ret = tmp; goto unwritable_page; } if (unlikely(!(tmp & VM_FAULT_LOCKED))) { lock_page(page); if (!page->mapping) { ret = 0; /* retry the fault */ unlock_page(page); goto unwritable_page; } } else VM_BUG_ON(!PageLocked(page)); page_mkwrite = 1; } } }
之后判斷該異常地址對應的硬件頁表項pte的內容是否與之前一致,若和orig_pte不一樣說明期間有人修改了pte,那么需要釋放page。如果一致,則將其加入進程的頁表,再合並到逆向映射數據結構中。在完成這些操作之前,使用flush_icache_page更新緩存,確保頁的內容在用戶空間可見。之后根據讀寫權限產生頁表項,根據映射類型集成到逆向映射,最后更新處理器的MMU,因為頁表已經修改。
/* Only go through if we didn't race with anybody else... */ if (likely(pte_same(*page_table, orig_pte))) {//判讀一致性 flush_icache_page(vma, page); entry = mk_pte(page, vma->vm_page_prot);//產生指向只讀頁的頁表項 if (flags & FAULT_FLAG_WRITE) entry = maybe_mkwrite(pte_mkdirty(entry), vma);//設置為可寫 if (anon) { inc_mm_counter_fast(mm, MM_ANONPAGES); page_add_new_anon_rmap(page, vma, address);//將匿名頁集成到逆向映射 } else { inc_mm_counter_fast(mm, MM_FILEPAGES); page_add_file_rmap(page);//將文件映射的頁集成到逆向映射 if (flags & FAULT_FLAG_WRITE) { dirty_page = page; get_page(dirty_page); } } set_pte_at(mm, address, page_table, entry);//填充頁表項 /* no need to invalidate: a not-present page won't be cached */ update_mmu_cache(vma, address, page_table);//更新mmu } else { if (cow_page) mem_cgroup_uncharge_page(cow_page); if (anon) page_cache_release(page);//如果不一致則釋放 else anon = 1; /* no anon but release faulted_page */ }
到這里也可以看到,共享映射的情況下就是將文件內容復制到頁緩存,然后建立進程虛擬內存到頁緩存的映射關系,達到用戶進程通過操作虛擬內存來操作文件的效果。
3、塊緩存
這塊以后看到了再加上來吧。