內存映射

由一個文件到一塊內存的映射

內存映射文件,是由一個文件到一塊內存的映射。Win32提供了允許應用程序把文件映射到一個進程的函數 (CreateFileMapping)。內存映射文件與虛擬內存有些類似,通過內存映射文件可以保留一個地址空間的區域,同時將物理存儲器提交給此區域,內存文件映射的物理存儲器來自一個已經存在於磁碟上的文件,而且在對該文件進行操作之前必須首先對文件進行映射。使用內存映射文件處理存儲於磁碟上的文件時,將不必再對文件執行I/O操作,使得內存映射文件在處理大數據量的文件時能起到相當重要的作用。

簡介


我們經常在程序的反彙編代碼中看到一些類似0x32118965這樣的地址,操作系統中稱為線性地址,或虛擬地址。虛擬地址有什麼用?虛擬地址
又是如何轉換為物理內存地址的呢?本章將對此作一個簡要闡述。

定址概述


現代意義上的操作系統都處於32位保護模式下。每個進程一般都能定址4G的物理空間。但是我們的物理內存一般都是幾百M,進程怎麼能獲得4G的物理空間呢?這就是使用了虛擬地址的好處,通常我們使用一種叫做虛擬內存的技術來實現,因為可以使用硬碟中的一部分來當作內存使用。
另外一點現在操作系統都劃分為系統空間和用戶空間,使用虛擬地址可以很好的保護內核空間不被用戶空間破壞。
對於虛擬地址如何轉為物理地址,這個轉換過程有操作系統和CPU共同完成。操作系統為CPU設置好頁表。CPU通過MMU單元進行地址轉換。

工具


現在的內核都很大,因此我們需要某種工具來閱讀龐大的源代碼體系,現在的內核開發工具都選用vim+ctag+cscope瀏覽內核代碼,網上已有
現成的makefile文件用來生成ctags/cscope/etags。
一、用法:
找一個空目錄,把附件Makefile拷貝進去。然後在該目錄中選擇性地運行如下make命令:
$make
將處理/usr/src/linux下的源文件,在當前目錄生成ctags,cscope
註:SRCDIR用來指定內核源代碼目錄,如果沒有指定,則預設為/usr/src/linux/
1)只創建ctags
$makeSRCDIR=/usr/src/linux-2.6.12/tags
2)只創建cscope
$makeSRCDIR=/usr/src/linux-2.6.12/cscope
3)創建ctags和cscope
$makeSRCDIR=/usr/src/linux-2.6.12/
4)只創建etags
$makeSRCDIR=/usr/src/linux-2.6.12/TAGS
二、處理時包括的內核源文件:
1)不包括drivers,sound目錄
2)不包括無關的體系結構目錄
3)fs目錄只包括頂層目錄和ext2,proc目錄
三、最簡單的ctags命令
1)進入
進入vim后,用
:tagfunc_name
跳到函數func_name
2)看函數(identifier)
想進入游標所在的函數,用
CTRL+]
3)回退
回退用CTRL+T

選取


本次論文分析,我選取的是linux-2.6.10版本的內核。最新的內核代碼為2.6.25。但是現在主流的伺服器都使用的是RedHatAS4的機器,它使
用2.6.9的內核。我選取2.6.10是因為它很接近2.6.9,現在紅帽企業Linux4以Linux2.6.9內核為基礎,是最穩定、最強大的商業產品。在2004
年期間,Fedora等開源項目為Linux2.6內核技術的更加成熟提供了一個環境,這使得紅帽企業Linuxv.4內核可以提供比以前版本更多更好的
功能和演演算法,具體包括:
1通用的邏輯CPU調度程序:處理多內核和超線程CPU。
2基於對象的逆向映射虛擬內存:提高了內存受限系統的性能。
3讀複製更新:針對操作系統數據結構的SMP演演算法優化。
4多I/O調度程序:可根據應用環境進行選擇。
5增強的SMP和NUMA支持:提高了大型伺服器的性能和可擴展性。
6網路中斷緩和(NAPI):提高了大流量網路的性能。
Linux2.6內核使用了許多技術來改進對大量內存的使用,使得Linux比以往任何時候都更適用於企業。包括反向映射(reversemapping) 、使用更大的內存頁、頁表條目存儲在高端內存中,以及更穩定的管理器。因此,我選取linux-2.6.10內核版本作為分析對象。
內核對頁表的設置
CPU做出映射的前提是操作系統要為其準備好內核頁表,而對於頁表的設置,內核在系統啟動的初期和系統初始化完成後都分別進行了設置。
3.1與內存映射相關的幾個宏
這幾個宏把無符號整數轉換成對應的類型
#define__pte(x)((pte_t){(x)})
#define__pmd(x)((pmd_t){(x)})
#define__pgd(x)((pgd_t){(x)})
#define__pgprot(x)((pgprot_t){(x)})
根據x把它轉換成對應的無符號整數
#definepte_val(x)((x).pte_low)
#definepmd_val(x)((x).pmd)
#definepgd_val(x)((x).pgd)
#definepgprot_val(x)((x).pgprot)
把內核空間的線性地址轉換為物理地址
#define__pa(x)((unsignedlong)(x)-PAGE_OFFSET)
把物理地址轉化為線性地址
#define__va(x)((void*)((unsignedlong)(x)+PAGE_OFFSET))
x是頁表項值,通過pte_pfn得到其對應的物理頁框號,最後通過pfn_to_page得到對應的物理頁描述符
#definepte_page(x)pfn_to_page(pte_pfn(x))
如果對應的表項值為0,返回1
#definepte_none(x)(!(x).pte_low)
x是頁表項值,右移12位后得到其對應的物理頁框號
#definepte_pfn(x)((unsignedlong)(((x).pte_low>>PAGE_SHIFT)))
根據頁框號和頁表項的屬性值合併成一個頁表項值
#definepfn_pte(pfn,prot)__pte(((pfn)<
根據頁框號和頁表項的屬性值合併成一個中間表項值
#definepfn_pmd(pfn,prot)__pmd(((pfn)<
向一個表項中寫入指定的值
#defineset_pte(pteptr,pteval)(*(pteptr)=pteval)
#defineset_pte_atomic(pteptr,pteval)set_pte(pteptr,pteval)
#defineset_pmd(pmdptr,pmdval)(*(pmdptr)=pmdval)
#defineset_pgd(pgdptr,pgdval)(*(pgdptr)=pgdval)
根據線性地址得到高10位值,也就是在目錄表中的索引
#definepgd_index(address)(((address)>>PGDIR_SHIFT)&(PTRS_PER_PGD-1))
根據頁描述符和屬性得到一個頁表項值
#definemk_pte(page,pgprot)pfn_pte(page_to_pfn(page),(pgprot))

初始化


內核在進入保護模式前,還沒有啟用分頁功能,在這之前內核要先建立一個臨時內核頁表,因為在進入保護模式后,內核繼續初始化直到建
立完整的內存映射機制之前,仍然需要用到頁表來映射相應的內存地址。臨時頁表的初始化是在arch/i386/kernel/head.S中進行的:
swapper_pg_dir是臨時頁全局目錄表,它是在內核編譯過程中靜態初始化的.
pg0是第一個頁表開始的地方,它也是內核編譯過程中靜態初始化的.
內核通過以下代碼建立臨時頁表:
ENTRY(startup_32)
…………
page_pde_offset=(__PAGE_OFFSET>>20);
movl$(pg0-__PAGE_OFFSET),%edi
movl$(swapper_pg_dir-__PAGE_OFFSET),%edx
movl$0x007,%eax
leal0x007(%edi),%ecx
Movl%ecx,(%edx)
movl%ecx,page_pde_offset(%edx)
addl$4,%edx
movl$1024,%ecx
11:
stosladdl$0x1000,%eax
loop11b
leal(INIT_MAP_BEYOND_END+0x007)(%edi),%ebp
cmpl%ebp,%eax
jb10b
movl%edi,(init_pg_tables_end-__PAGE_OFFSET)
在上述代碼中,內核為什麼要把用戶空間和內核空間的前幾個目錄項映射到相同的頁表中去呢,雖然在head.S中內核已經進入保護模式,但是
內核現在是處於保護模式的段式定址方式下,因為內核還沒有啟用分頁映射機制,現在都是以物理地址來取指令,如果代碼中遇到了符號地址
,只能減去0xc0000000才行,當開啟了映射機制后就不用了現在cpu中的取指令指針eip仍指向低區,如果只建立內核空間中的映射,那麼當
內核開啟映射機制后,低區中的地址就沒辦法定址了,應為沒有對應的頁表,除非遇到某個符號地址作為絕對轉移或調用子程序為止。因此
要儘快開啟CPU的頁式映射機制.
movl$swapper_pg_dir-__PAGE_OFFSET,%eax
movl%eax,%cr3
movl%cr0,%eax
orl$0x80000000,%eax
movl%eax,%cr0
ljmp$__BOOT_CS,$1f
1:
lssstack_start,%esp
通過ljmp$__BOOT_CS,$1f這條指令使CPU進入了系統空間繼續執行因為__BOOT_CS是個符號地址,地址在0xc0000000以上。
在head.S完成了內核臨時頁表的建立后,它繼續進行初始化,包括初始化INIT_TASK,也就是系統開啟后的第一個進程;建立完整的中斷處理程
序,然後重新載入GDT描述符,最後跳轉到init/main.c中的start_kernel函數繼續初始化.
3.3內核頁表的完整建立
內核在start_kernel()中繼續做第二階段的初始化,因為在這個階段中,內核已經處於保護模式下,前面只是簡單的設置了內核頁表,內核
必須首先要建立一個完整的頁表才能繼續運行,因為內存定址是內核繼續運行的前提。
pagetable_init()的代碼在mm/init.c中:
[start_kernel()>setup_arch()>paging_init()>pagetable_init()]
為了簡單起見,我忽略了對PAE選項的支持。
staticvoid__initpagetable_init(void)
{
……
pgd_t*pgd_base=swapper_pg_dir;
……
kernel_physical_mapping_init(pgd_base);
……
}
在這個函數中pgd_base變數指向了swapper_pg_dir,這正是內核目錄表的開始地址,pagetable_init()函數在通過
kernel_physical_mapping_init()函數完成內核頁表的完整建立。
kernel_physical_mapping_init函數同樣在mm/init.c中,我略去了與PAE模式相關的代碼:
staticvoid__initkernel_physical_mapping_init(pgd_t*pgd_base)
{
unsignedlongpfn;
pgd_t*pgd;
pmd_t*pmd;
pte_t*pte;
intpgd_idx,pmd_idx,pte_ofs;
pgd_idx=pgd_index(PAGE_OFFSET);
pgd=pgd_base+pgd_idx;
pfn=0;
for(;pgd_idx
pmd=one_md_table_init(pgd);
if(pfn>=max_low_pfn)
continue;
for(pmd_idx=0;pmd_idx
unsignedintaddress=pfn*PAGE_SIZE+PAGE_OFFSET;
……
pte=one_page_table_init(pmd);
for(pte_ofs=0;pte_ofs
if(is_kernel_text(address))
set_pte(pte,pfn_pte(pfn,PAGE_KERNEL_EXEC));
else
set_pte(pte,pfn_pte(pfn,PAGE_KERNEL));
……
}
}
通過作者的註釋,可以了解到這個函數的作用是把整個物理內存地址都映射到從內核空間的開始地址,即從0xc0000000的整個內核空間中,
直到物理內存映射完畢為止。這個函數比較長,而且用到很多關於內存管理方面的宏定義,理解了這個函數,就能大概理解內核是如何建立
頁表的,將這個抽象的模型完全的理解。下面將詳細分析這個函數:
函數開始定義了4個變數pgd_t*pgd,pmd_t*pmd,pte_t*pte,pfn;
pgd指向一個目錄項開始的地址,pmd指向一個中間目錄開始的地址,pte指向一個頁表開始的地址pfn是頁框號被初始為0.pgd_idx根據
pgd_index宏計算結果為768,也是內核要從目錄表中第768個表項開始進行設置。從768到1024這個256個表項被linux內核設置成內核目錄項,
低768個目錄項被用戶空間使用.pgd=pgd_base+pgd_idx;pgd便指向了第768個表項。
然後函數開始一個循環即開始填充從768到1024這256個目錄項的內容。
one_md_table_init()函數根據pgd找到指向的pmd表。
它同樣在mm/init.c中定義:
staticpmd_t*__initone_md_table_init(pgd_t*pgd)
{
pmd_t*pmd_table;
#ifdefCONFIG_X86_PAE
pmd_table=(pmd_t*)alloc_bootmem_low_pages(PAGE_SIZE);
set_pgd(pgd,__pgd(__pa(pmd_table)|_PAGE_PRESENT));
if(pmd_table!=pmd_offset(pgd,0))
BUG();
#else
pmd_table=pmd_offset(pgd,0);
#endif
returnpmd_table;
}
可以看出,如果內核不啟用PAE選項,函數將通過pmd_offset返回pgd的地址。因為linux的二級映射模型,本來就是忽略pmd中間目錄表的。
接著又個判斷語句:
>>if(pfn>=max_low_pfn)
>>continue;
這個很關鍵,max_low_pfn代表著整個物理內存一共有多少頁框。當pfn大於max_low_pfn的時候,表明內核已經把整個物理內存都映射到了系
統空間中,所以剩下有沒被填充的表項就直接忽略了。因為內核已經可以映射整個物理空間了,沒必要繼續填充剩下的表項。
緊接著的第2個for循環,在linux的3級映射模型中,是要設置pmd表的,但在2級映射中忽略,只循環一次,直接進行頁表pte的設置。
>>address=pfn*PAGE_SIZE+PAGE_OFFSET;
address是個線性地址,根據上面的語句可以看出address是從0xc000000開始的,也就是從內核空間開始,後面在設置頁表項屬性的時候會用
到它.
>>pte=one_page_table_init(pmd);
根據pmd分配一個頁表,代碼同樣在mm/init.c中:
staticpte_t*__initone_page_table_init(pmd_t*pmd)
{
if(pmd_none(*pmd)){
pte_t*page_table=(pte_t*)alloc_bootmem_low_pages(PAGE_SIZE);
set_pmd(pmd,__pmd(__pa(page_table)|_PAGE_TABLE));
if(page_table!=pte_offset_kernel(pmd,0))
BUG();
returnpage_table;
}
returnpte_offset_kernel(pmd,0);
}
pmd_none宏判斷pmd表是否為空,如果為空則要利用alloc_bootmem_low_pages分配一個4k大小的物理頁面。然後通過set_pmd(pmd,__pmd
(__pa(page_table)|_PAGE_TABLE));來設置pmd表項。page_table顯然屬於線性地址,先通過__pa宏轉化為物理地址,在與上_PAGE_TABLE宏,
此時它們還是無符號整數,在通過__pmd把無符號整數轉化為pmd類型,經過這些轉換,就得到了一個具有屬性的表項,然後通過set_pmd宏設
置pmd表項.
接著又是一個循環,設置1024個頁表項。
is_kernel_text函數根據前面提到的address來判斷address線性地址是否屬於內核代碼段,它同樣在mm/init.c中定義:
staticinlineintis_kernel_text(unsignedlongaddr)
{
if(addr>=(unsignedlong)_stext&&addr<=(unsignedlong)__init_end)
return1;
return0;
}
_stext,__init_end是個內核符號,在內核鏈接的時候生成的,分別表示內核代碼段的開始和終止地址.
如果address屬於內核代碼段,那麼在設置頁表項的時候就要加個PAGE_KERNEL_EXEC屬性,如果不是,則加個PAGE_KERNEL屬性.
#define_PAGE_KERNEL_EXEC\
(_PAGE_PRESENT|_PAGE_RW|_PAGE_DIRTY|_PAGE_ACCESSED)
#define_PAGE_KERNEL\
(_PAGE_PRESENT|_PAGE_RW|_PAGE_DIRTY|_PAGE_ACCESSED|_PAGE_NX)
最後通過set_pte(pte,pfn_pte(pfn,PAGE_KERNEL));來設置頁表項,先通過pfn_pte宏根據頁框號和頁表項的屬性值合併成一個頁表項值,
然戶在用set_pte宏把頁表項值寫到頁表項里。
當pagetable_init()函數返回后,內核已經設置好了內核頁表,緊著調用load_cr3(swapper_pg_dir);
#defineload_cr3(pgdir)\
asmvolatile("movl%0,%%cr3"::"r"(__pa(pgdir)))
將控制swapper_pg_dir送入控制寄存器cr3.每當重新設置cr3時,CPU就會將頁面映射目錄所在的頁面裝入CPU內部高速緩存中的TLB部分。現
在內存中(實際上是高速緩存中)的映射目錄變了,就要再讓CPU裝入一次。由於頁面映射機制本來就是開啟著的,所以從這條指令以後就擴大
了系統空間中有映射區域的大小,使整個映射覆蓋到整個物理內存(高端內存)除外。實際上此時swapper_pg_dir中已經改變的目錄項很可能還
在高速緩存中,所以還要通過__flush_tlb_all()將高速緩存中的內容沖刷到內存中,這樣才能保證內存中映射目錄內容的一致性。
3.4對如何構建頁表的總結
通過上述對pagetable_init()的剖析,我們可以清晰的看到,構建內核頁表,無非就是向相應的表項寫入下一級地址和屬性。在內核空間
保留著一部分內存專門用來存放內核頁表。當cpu要進行定址的時候,無論在內核空間,還是在用戶空間,都會通過這個頁表來進行映射。對於
這個函數,內核把整個物理內存空間都映射完了,當用戶空間的進程要使用物理內存時,豈不是不能做相應的映射了?其實不會的,內核
只是做了映射,映射不代表使用,這樣做是內核為了方便管理內存而已。

相關信息


4.1示例代碼
通過前面的理論分析,我們通過編寫一個簡單的程序,來分析內核是如何把線性地址映射到物理地址的。
[root@localhosttemp]#cattest.c
#include
voidtest(void)
{
printf("hello,world.\n");
}
intmain(void)
{
test();
}
這段代碼很簡單,我們故意要main調用test函數,就是想看下test函數的虛擬地址是如何映射成物理地址的。
4.2段式映射分析
我們先編譯,在反彙編下test文件
[root@localhosttemp]#gcc-otesttest.c
[root@localhosttemp]#objdump-dtest
08048368:
8048368:55push%ebp
8048369:89e5mov%esp,%ebp
804836b:83ec08sub$0x8,%esp
804836e:83ec0csub$0xc,%esp
8048371:6884840408push$0x8048484
8048376:e835ffffffcall80482b0
804837b:83c410add$0x10,%esp
804837e:c9leave
804837f:c3ret
08048380
:
8048380:55push%ebp
8048381:89e5mov%esp,%ebp
8048383:83ec08sub$0x8,%esp
8048386:83e4f0and$0xfffffff0,%esp
8048389:b800000000mov$0x0,%eax
804838e:83c00fadd$0xf,%eax
8048391:83c00fadd$0xf,%eax
8048394:c1e804shr$0x4,%eax
8048397:c1e004shl$0x4,%eax
804839a:29c4sub%eax,%esp
804839c:e8c7ffffffcall8048368
80483a1:c9leave
80483a2:c3ret
80483a3:90nop
從上述結果可以看到,ld給test()函數分配的地址為0x08048368.在elf格式的可執行文件代碼中,ld的實際位置總是從0x8000000開始安排程序
的代碼段,對每個程序都是這樣。至於程序在執行時在物理內存中的實際位置就要由內核在為其建立內存映射時臨時做出安排,具體地址則
取決於當時所分配到的物理內存頁面。假設該程序已經運行,整個映射機制都已經建立好,並且CPU正在執行main()中的call8048368這條指
令,要轉移到虛擬地址0x08048368去運行。下面將詳細介紹這個虛擬地址轉換為物理地址的映射過程.
首先是段式映射階段。由於0x08048368是一個程序的入口,更重要的是在執行的過程中是由CPU中的指令計數器EIP所指向的,所以在代碼段中
。因此,i386CPU使用代碼段寄存器CS的當前值作為段式映射的選擇子,也就是用它作為在段描述表的下標。那麼CS的值是多少呢?
GDB調試下test:
(gdb)inforeg
eax0x1016
ecx0x11
edx0x9d915c10326364
ebx0x9d6ff410317812
esp0xbfedb4800xbfedb480
ebp0xbfedb4880xbfedb488
esi0xbfedb534-1074940620
edi0xbfedb4c0-1074940736
eip0x804836e0x804836e
eflags0x282642
cs0x73115
ss0x7b123
ds0x7b123
es0x7b123
fs0x00
gs0x3351
可以看到CS的值為0x73,我們把它分解成二進位:
0000000001110011
最低2位為3,說明RPL的值為3,應為我們這個程序本省就是在用戶空間,RPL的值自然為3.
第3位為0表示這個下標在GDT中。
高13位為14,所以段描述符在GDT表的第14個表項中,我們可以到內核代碼中去驗證下:
在i386/asm/segment.h中:
#defineGDT_ENTRY_DEFAULT_USER_CS14
#define__USER_CS(GDT_ENTRY_DEFAULT_USER_CS*8+3)
可以看到段描述符的確就是GDT表的第14個表項中。
我們去GDT表看看具體的表項值是什麼,GDT的內容在arch/i386/kernel/head.S中定義:
ENTRY(cpu_gdt_table)
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x00cf9a000000ffff
.quad0x00cf92000000ffff
.quad0x00cffa000000ffff
.quad0x00cff2000000ffff
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x00c09a0000000000
.quad0x00809a0000000000
.quad0x0080920000000000
.quad0x0080920000000000
.quad0x0080920000000000
.quad0x00409a0000000000
.quad0x00009a0000000000
.quad0x0040920000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x0000000000000000
.quad0x00cffa000000ffff
我們把這個值展開成二進位:
0000000011001111111110100000000000000000000000001111111111111111
根據上述對段描述符表項值的描述,可以得出如下結論:
B0-B15,B16-B31是0,表示基地址全為0.
L0-L15,L16-L19是1,表示段的上限全是0xffff.
G位是1表示段長度單位均為4KB。
D位是1表示對段的訪問都是32位指令
P位是1表示段在內存中。
DPL是3表示特權級是3級
S位是1表示為代碼段或數據段
type為1010表示代碼段,可讀,可執行,尚未收到訪問
這個描述符指示了段從0地址開始的整個4G虛存空間,邏輯地址直接轉換為線性地址。
所以在經過段式映射后就把邏輯地址轉換成了線性地址,這也是在linux中,為什麼邏輯地址等同於線性地址的原因了。
4.3頁式映射分析
現在進入頁式映射的過程了,Linux系統中的每個進程都有其自身的頁面目錄PGD,指向這個目錄的指針保存在每個進程的mm_struct數據結構
中。每當調度一個進程進入運行的時候,內核都要為即將運行的進程設置好控制寄存器cr3,而MMU的硬體則總是從cr3中取得指向當前頁面目
錄的指針。當我們在程序中要轉移到地址0x08048368去的時候,進程正在運行,cr3早以設置好,指向我們這個進程的頁面目錄了。先將線性
地址0x08048368展開成二進位:
00001000000001001000001101101000
對照線性地址的格式,可見最高10位為二進位的0000100000,也就是十進位的32,所以MMU就以32為下標在其頁面目錄中找到其目錄項。這個
目錄項的高20位指向一個頁面表,CPU在這20位后添上12個0就得到頁面表的指針。找到頁面表以後,CPU再來看線性地址中的中間10位,
0001001000,即十進位的72.於是CPU就以此為下標在頁表中找相應的表項。表項值的高20位指向一個物理內存頁面,在後邊添上12個0就得到物
理頁面的開始地址。假設物理地址在0x620000的,線性地址的最低12位為0x368.那麼test()函數的入口地址就為0x620000+0x368=0x620368
  • 目錄