我们经常在程序的反汇编代码中看到一些类似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)
…………
/*得到开始目录项的索引,从这可以看出内核是在swapper_pg_dir的768个表项开始进行建立的,其对应的线性地址就是0xc0000000以上的地
址,也就是内核在初始化它自己的页表*/
page_pde_offset=(__PAGE_OFFSET>>20);
/*pg0地址在内核编译的时候,已经是加上0xc0000000了,减去0xc00000000得到对应的物理地址*/
movl$(pg0-__PAGE_OFFSET),%edi
/*将目录表的地址传给edx,表明内核也要从0x00000000开始建立页表,这样可以保证从以物理地址取指令到以线性地址在系统空间取指令
的平稳过渡,下面会详细解释*/
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
/*内核到底要建立多少页表,也就是要映射多少内存空间,取决于这个判断条件。在内核初始化程中内核只要保证能映射到包括内
核的代码段,数据段,初始页表和用于存放动态数据结构的128k大小的空间就行*/
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/*cr3控制寄存器保存的是目录表地址*/
movl%cr0,%eax/*向cr0的最高位置1来开启映射机制*/
orl$0x80000000,%eax
movl%eax,%cr0
ljmp$__BOOT_CS,$1f/*Clearprefetchandnormalize%eip*/
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/*NULLdescriptor*/
.quad0x0000000000000000/*0x0breserved*/
.quad0x0000000000000000/*0x13reserved*/
.quad0x0000000000000000/*0x1breserved*/
.quad0x0000000000000000/*0x20unused*/
.quad0x0000000000000000/*0x28unused*/
.quad0x0000000000000000/*0x33TLSentry1*/
.quad0x0000000000000000/*0x3bTLSentry2*/
.quad0x0000000000000000/*0x43TLSentry3*/
.quad0x0000000000000000/*0x4breserved*/
.quad0x0000000000000000/*0x53reserved*/
.quad0x0000000000000000/*0x5breserved*/
.quad0x00cf9a000000ffff/*0x60kernel4GBcodeat0x00000000*/
.quad0x00cf92000000ffff/*0x68kernel4GBdataat0x00000000*/
.quad0x00cffa000000ffff/*0x73user4GBcodeat0x00000000*/
.quad0x00cff2000000ffff/*0x7buser4GBdataat0x00000000*/
.quad0x0000000000000000/*0x80TSSdescriptor*/
.quad0x0000000000000000/*0x88LDTdescriptor*/
/*SegmentsusedforcallingPnPBIOS*/
.quad0x00c09a0000000000/*0x9032-bitcode*/
.quad0x00809a0000000000/*0x9816-bitcode*/
.quad0x0080920000000000/*0xa016-bitdata*/
.quad0x0080920000000000/*0xa816-bitdata*/
.quad0x0080920000000000/*0xb016-bitdata*/
/*
*TheAPMsegmentshavebytegranularityandtheirbases
*andlimitsaresetatruntime.
*/
.quad0x00409a0000000000/*0xb8APMCScode*/
.quad0x00009a0000000000/*0xc0APMCS16code(16bit)*/
.quad0x0040920000000000/*0xc8APMDSdata*/
.quad0x0000000000000000/*0xd0-unused*/
.quad0x0000000000000000/*0xd8-unused*/
.quad0x0000000000000000/*0xe0-unused*/
.quad0x0000000000000000/*0xe8-unused*/
.quad0x0000000000000000/*0xf0-unused*/
.quad0x0000000000000000/*0xf8-GDTentry31:double-faultTSS*/
.quad0x00cffa000000ffff/*0x73user4GBcodeat0x00000000*/
我们把这个值展开成二进制:
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
。