通過本文,你可以了解:
1. 存儲器硬件結構;
2.分段以及對應的組織方式;
3.分頁以及對應的組織方式。
注1:本文以
Linux內核2.6.32.59本版為例,其對應的代碼可以在http://www.kernel.org/pub/linux/kernel/v2.6/longterm/v2.6.32/linux-2.6.32.59.tar.bz2找到。
注2:本文所有的英文專有名詞都是我隨便翻譯的,請對照英文原文進行理解。
注3:推薦使用Source Insight進行源碼分析。
內存組織
計算機內存屬於隨機存儲器(RAM),目前PC機廣泛使用的是DDR
SDRAM,即“雙倍速率同步動態隨機存儲器”,其本質上仍然是由n bits*m KB個內存芯片組成的,比如如果我們需要8位64KB的內存,則我們就需要2*8=16塊4bits*8KB的內存塊。由於計算機通常是以字節(Byte)進行數據交換的,所以對內存的地址編碼一般使用字節,如上我們有64KB內存,則其地址編碼為0×0000~0xFFFF,稱為物理地址。對於32位機來說,由於其“地址寄存器(AR)”是32位,也就限制了其內存的最大尋址范圍是2^32=4GB。
Linux將物理地址按4KB的大小劃分成“幀(Frame)”。為什麼是4KB?因為每一個幀都需要用一個C結構體來描述,稱之為“幀描述單元(Frame Discriptor)”,如果太小,幀描述單元顯然太多了,如果太大,那麼在內存分配時又會造成“內碎片(InnerFragments)”。早些時候,計算機的內存址都是直接映射的,由於程序裡的地址是寫死的,這就意味著每段程序每次都只能映射對應的地址空間。這無論對程序設計者與系統都是相當大的負擔。Linux使用“分段”加“分頁”來解決此問題。由於它們的存在,內存地址進入了邏輯地址時代。Linux有三種地址:邏輯地址(LogicAddress)、線性地址(Linear Address)與物理地址(Physics Address)。其關系如下:
另外,Linux支持眾多CPU架構,這裡只研究X86的,對應的源代碼為:…/X86/… 路徑。
Linux中的分段
Linux 並不使用太多的分段,原因是某些RISC機器對分段的支持不好。為此Linux的分段都存在“全局描述表(GDT)”中,GDT是一個全局 desc_struct數組(位於linux-2.6.32.59\arch\x86\include\asm),其結構如下:
#define GDT_ENTRIES 16
struct desc_struct gdt[GDT_ENTRIES];
struct desc_struct {
union {
struct {
unsigned int a;
unsigned int b;
};
struct {
u16 limit0; // 段大小
u16 base0; // 段起始位置
unsigned base1: 8, type: 4, s: 1, dpl: 2, p: 1; // type表示段類型,占4位;dpl指的段運行權限,占2位
unsigned limit: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8; //d 表示內存地址位寬,占1位
};
};
} __attribute__((packed));
所以我們可以看出,段描述結構體占8個字節,至於裡面的a,b,那是老的方式,後來使用C++ Struts的Bit Fields後更方便了。type類型由以下幾種:
enum {
DESC_TSS = 0×9,
DESC_LDT = 0×2,
DESCTYPE_S = 0×10, /* !system */
};
Linux主要使用以下幾種段:
-
內核代碼段(Kernel Code Segment):type=10,dpl=0
-
內核數據段(Kernel Data Segment):type=2,dpl=0
-
用戶代碼段(User Code Segment):type=10,dpl=3
-
用戶數據段(User Data Segment):type=2,dpl=3
-
任務狀態段(Task State Segment),每進程一個:type=9,dpl=3
其它類型可以參見linux-2.6.32.59\arch\x86\include\asm\segment.h,裡面有非常詳細的說明。
它們都存儲在“全局描述符表(GDT)”。Linux本身並不使用“局部描述符表(LDT)”,當一個進程被創建時,其指向的是一個默認的LDT,不過系統並不阻止進程創建它。也就是說一個進程最多兩個段描述符:TSS與LDT。由於Segment Selector為16位(為什麼只有16位,這個就是歷史原因了,由於X86在Real Mode下段地址只有20位,其中有效的就是16位,詳見:x86
memory segmentation,但Linux段內偏移地址高達32位,所以線性地址總共是48位),其中有效的索引位僅有13位,所以GDT的最大長度為213-1=8192,除去系統保留的12個,留給進程的只有8180個入口,那麼就意味Linux進程的最大數為8180/2=4090。需要注意的是,進程在創建的時候並不會馬上創建自己的LDT,其指向的是GDT一個默認的LDT,裡面的SD為null。只有在需要的時候進程才創建自己的LDT並把它放入GDT中。所以不管是LDT也好,TSS也好,它們都存放在GDT裡面。而對於UCS與UDS,所有的進程共享一個。這樣地址空間不會重復嗎?不會,因為線性不是最終的物理地址,每個進程還有自己的頁表,所以最終映射到物理地址是不同的。
下面我們來看看段中地址是如何轉換的。假設我們需要訪問內核數據段的0×00124部分,由代碼知其GDT的入口為13,那麼其對應的內存地址=gdtr+13*8+0×00124,假設gptr為0×02000,則最終的結果為0×02228。gdtr是一個寄存器,其為48位,用來保存GDT的第一個字節線性地址與表限。其過程如圖所示:
圖片來源於《Understand The Linux Kernel》
分頁
相對於分段來說,分頁更主流更流行一些。原因是其更靈活,其能把不同的線性地址映射到同一個物理地址上,缺點是內存必須以頁大小的整數倍分配。按現在主流的4KB一頁來說,如果程序只申請100B的數據,那內存浪費還是相當的大。為此,Linux使用了一種稱為Slab的方法來解決這個問題,後面的文章會講到。
因為頁表本身也需要存儲空間,按每頁32B來算,對於4GB內存,每頁4KB,共有1M頁,則頁表的大小為32MB,這顯然不可以接受,所以後來出現了多級頁表這個概念。2004年後Linux版本使用的是四級頁表:第一級叫“全局目錄(Page
Global Directory)“、第二級叫“頁上級目錄(Page
Upper Directory)”、第三級叫”頁中間目錄(Page
Middle Derectory)”、第四級叫”頁面表(Page
Table Entry)”,最後頁內偏移量“offset”,如下圖:
圖中的cr3是一個寄存器,它存儲“Global DIR”的地址。當進程切換發生時,它將被保存在TSS中,前面說過了TSS段表是每個進程一個。分頁在Linux內使用的地方很多,特別是進程內的地址轉換。分頁有硬件支持的,特別是旁路轉換緩沖(Translation
Lookaside Buffer)的出現,使用即使使用三級頁表的Linux在地轉轉換中的實際效果也是非常好的。與段表所有的進程都共用一個的是,每個進程都擁有自己的分頁。其實也正是因為所有進程都共享一個段表,每個進程才必須有自己的頁表,否則相同的linear地址如何映射到不同的物理地址去?下面我們著重來研究一下Linux系統中是如何表示分頁中所用到的數據結構的。
每個“幀”在Linux中都是以一個名為page(位於linux-2.6.32.59\include\linux\Mm_types.h)的結構體來存儲的。所有的頁被放在一個類型為page名為mem_map的數組中(位於linux-2.6.32.59\mm\Memory.c)。代碼如下(為了顯示方便,僅列出部分:
struct page {
unsigned long flags; /* 幀的標志位,用枚舉pageflags(位於:linux-2.6.32.59\include\linux\Page-flags.h)表示,每個值的意義詳見注釋 */
…
atomic_t _count; /* 該幀被引用的數量 */
union {
atomic_t _mapcount; /* 所有指向該幀的頁表數量*/
…
};
union {
struct {
unsigned long private; /*根據此頁的使用情況會有不同的意義,詳見源碼注釋*/
…
};
…
};
union {
pgoff_t index; /* 重要:類型即unsinged long, 指向物理幀號 */
…
};
struct list_head lru; /* 指向最近被使用的頁的雙向鏈表,cache相關*/
};
下面我們再來看看PGD頁表。每個進程的mm_struct->pgd(位於:linux-2.6.32.59\include\linux\Mm_types.h)指向自己的PGD:
struct mm_struct {
…
pgd_t * pgd;
…
}
可以看出pdg實際上是一個pgd_t結構數組,pgd_t在X86系統中就是一個usinged long,其指向的就是下一級頁表的地址。就這樣找下去,直到找到對應的頁為止,再加上頁內偏移,就可以進行內存訪問了。
例如線性地址為:0x91220B01,如下圖,如果PGD、PUD、PMD以及PTE均5位。頁內偏移12位,即頁大小4KB。
那麼這段內存的解析步驟是:
-
PGD號為24,查PGD[24]得到PUD入口;
-
PUD號為4,再查PUD[4];
-
PMD號為36,再查PMD[36];
-
PTE號為2,再查PTE[2];
-
如果最終幀地址為a:那麼最後的物理地址就是a+0×0301
需要補充的是,並不是所有的內存都是使用“分頁”,在內核初始化的時候,有100MB內存的樣子是使用直接映射的,這是因為總是要先裝入分頁的初始化代碼才能進行頁表初始化。
總結:不知不覺也寫了不少了。這次我們介紹了操作系統最基本的內存管理概念“分段”與“分頁”在Linux中的實現,可以看出其與通過的概念還是很接近的。這正證明了基礎知識的重要性。下一次我們將介紹Linux的內存初始化過程,如頁表的建立與初始化。
轉自:http://rdc.taobao.com/team/jm/archives/2063