[Linux] 进程的虚拟地址空间
在x86体系结构中分段机制是必选的,而分页机制则可由具体的操作系统而选择,Linux通过让段的基地址为0而巧妙的绕过了基地址。因此,对于Linux来说,虚地址和线性地址是一致的。在32位的平台上,线性地址的大小为固定的4GB。并且,由于采用了保护机制,Linux内核将这4GB分为两部分,虚地址较高的1GB(0xC0000000到0xFFFFFFFF)为共享的内核空间;而较低的3GB(0x00000000到0xBFFFFFFF)为每个进程的用户空间。由于每个进程都不能直接访问内核空间,而是通过系统调用间接进入内核,因此,所有的进程都共享内核空间。而每个进程都拥有各自的用户空间,各个进程之间不能互相访问彼此的用户空间。因此,对于每一个具体的进程而言,都拥有4GB的虚拟地址空间。
一个程序在经过编译、连接之后形成的地址空间是一个虚拟的地址空间,只有当程序运行的时候才会分配具体的物理空间。由此我们可以得知,程序的虚拟地址相对来说是固定的,而物理地址则随着每一次程序的运行而有所不同。
对于内核空间而言,它与物理内存之间存在一个简单的线性关系,即存在3GB的偏移量。在Linux内核中,这个偏移量叫做PAGE_OFFSET。如果内核的某个物理地址为x,那么对应的内核虚地址就为x+PAGE_OFFSET。
对于用户空间而言,它与物理内存之间的映射远不止这么简单。与内核空间和物理空间的线性映射不同的是,分页机制将虚拟用户空间和物理地址空间分成大小相同的页,然后再通过页表将虚拟页和物理页块映射起来。
内核空间一般可以通过__get_free_page()、kmalloc()和vmalloc()来申请内核空间。只不过__get_free_page函数每次申请的都是完整的页;而后两者则依据具体参数申请以字节为单位的内存空间。此外,前两个函数申请的虚拟地址空间和物理地址空间都是连续的;vmalloc函数申请的物理地址空间并不连续。vmalloc函数通过重新建立虚拟地址空间和物理地址空间之间的映射,即新建页表项,将离散的物理地址空间映射到连续的虚拟地址空间。因此,使用该函数的开销比较大。
下面的程序简单的演示了这三个函数的使用方法。从结果中可以看出,这些函数申请的地址都在3GB(0xBFFFFFFF)以上。完整代码在如下。
static int __init menroyshow_init(void) {
printk("mmshow module is working\n");
pagemem = __get_free_page(GFP_KERNEL);
if(!pagemem)
goto gfp_fail;
printk(KERN_INFO "pagemem = 0x%lx\n",pagemem);
kmallocmem = kmalloc(100 * sizeof(char),GFP_KERNEL);
if(!kmallocmem)
goto kmalloc_fail;
printk(KERN_INFO "kmallocmem = 0x%p\n",kmallocmem);
vmallocmem = vmalloc(1000000 * sizeof(char));
if(!vmallocmem)
goto vmalloc_fail;
printk(KERN_INFO "vmallocmem = 0x%p\n",vmallocmem);
return 0;
gfp_fail:
free_page(pagemem);
kmalloc_fail:
kfree(kmallocmem);
vmalloc_fail:
vfree(vmallocmem);
return -1;
}
//运行结果:
# pagemem = 0xf3211000 # kmallocmem = 0xd581e700 # vmallocmem = 0xf9251000
每个进程够拥有属于自己的3GB的虚拟空间(用户空间),那么这个3GB的空间是如何划分的?通常,除了我们熟悉的代码段和数据段,用户空间还包括堆栈段和堆。我们可以通过下面的演示程序来了解这些区域到底负责存储程序的那些内容。 int bss_var; int data_var0 = 1; int main(int argc,char **argv) { printf("The
user space's address division of a process as follow:\n"); printf("Data segment:\n"); printf("address of \"main\" function:%p\n\n",main); printf("Data segment:\n"); printf("address of data_var:%p\n",&data_var0); static int data_var1 = 4; printf("new
end of data_var:%p\n\n",&data_var1); printf("BSS:\n"); printf("address of bss_var:%p\n\n",&bss_var); char *str = (char *)malloc(sizeof(char)*10); printf("initial heap end:%p\n",str); char *buf = (char *)malloc(sizeof(char)*10); printf("new heap
end:%p\n\n",buf); int stack_var0 = 2; printf("Stack segment:\n"); printf("initial end of stack:%p\n",&stack_var0); int stack_var1 = 3; printf("new end of stack:%p\n",&stack_var1); return 0; } //运行结果: The user space's address division of a
process as follow: Data segment: address of "main" function:0x8048454 Data segment: address of data_var:0x804a01c new end of data_var:0x804a020 BSS: address of bss_var:0x804a02c initial heap end:0x8f77008 new heap end:0x8f77018 Stack segment:
initial end of stack:0xbfe0a3b4 new end of stack:0xbfe0a3b0 可以看到,代码段存放程序的代码;数据段存放全局变量和static类型的局部变量。此外,未初始化的全局变量虽然也存在于数据段,但是这些未初始化的变量都集中在靠近数据段上边界的区域,这个区域称为BSS段。以上这些空间是进程所必须拥有的,它们在进程运行之前就分配好了。 程序中的局部变量一般被分配在堆栈段,其位于用户空间最顶部。与固定的代码段和数据段不同的是,堆栈段存储数据是从高低值往低地址延伸的。因此,在数据段到堆栈段之间,形成了一片空洞,这片空洞用于存储malloc函数所动态分配的空间,这片空洞区域被称为堆。 通过下面这个图可以更进一步的了解到进程用户空间的划分情况。
以上是关于进程用户空间划分的大致分析,上述理论在内核代码中如何体现?它将涉及到mm_struct结构和vm_area_struct结构。 每一个进程都拥有3GB大小的用户空间,而连续用户空间又按照存储内容的不同被划分成若干个区域。在内核中,主要通过mm_struct结构体和vm_area_struct结构体对进程用户空间进行描述。前者是对进程的用户空间进行整体的描述;而后者则是对用户空间中的某个区域进行描述。显然,每一个进程对应的有一个mm_struct结构和多个vm_area_struct结构。 1.mm_struct结构 最新版本中的mm_struct结构字段比较多,接下来只对部分字段做以说明。 mmap:vm_area_struct结构体类型的指针。指向进程用户空间中各区域所组成的双链表。链表方式可以高效的遍历所有元素; mm_rb:rb_root结构体类型。同样描述内存区域块,只不过采用红黑树来表示。用红黑树可以快速索引到指定的元素; mm_users:atomic_t类型。用来记录正在使用该地址空间的进程数目。比如,当前有3个进程正在共享该地址空间,那么其值为3; mm_count:atomic_t类型。记录mm_struct结构体被引用的次数。如果当前该地址空间只被两个进程所共享,那么该值为1,mm_users为2;当这两个进程都退出时,该值为0,mm_users也为0。另外,内核线程并不需要访问用户的内存空间,也并不需要创建页表。内核线程一般会直接使用前一个进程的mm_struct结构。因此该字段的计数还包括内核线程对这个结构的引用。 map_count:int类型。内存区域的个数; pgd:pgd_t类型,该结构体类型内部封装的是unsigned
long类型的数据。pgd表示的是页目录基址。当调度程序调度一个进程运行时,就将这个线性地址转化为物理地址,并写入CR3控制寄存器中; start_code, end_code, start_data, end_data:unsigned long类型。进程代码段和数据段的起始地址和终止地址; start_brk, brk, start_stack:unsigned long类型。分别为堆的起始地址和终止地址,堆栈的起始地址。上文说过,进程的堆栈段是根据需求向下(朝低地址方向)延伸的,因此这里并没有堆栈段的终止地址; arg_start,
arg_end, env_start, env_end:unsigned long类型。命令行参数所在内存的起始地址和终止地址,环境变量所在内存的起始地址和终止地址; 2.vm_area_struct结构 上面我们已经知道,该结构体描述的是进程用户空间中的一个虚拟内存区间(Virtual Memory Area,VMA)。 vm_mm:mm_struct结构体类型指针。指向该区域所属的用户空间对应的mm_struct结构体。 vm_start,vm_end:unsigned long类型。该虚存区域的起始地址和终止地址。 vm_next,vm_prev:vm_area_struct结构体类型指针。构成VMA双联表。 vm_flags:unsigned
long类型。该虚存区的标志。 vm_page_prot:pgprot_t结构体类型,内部封装了unsigned long类型。访问控制权限。 vm_ops:vm_operations_struct结构体类型。该虚存区域的操作函数接口,这些函数可以对虚存区中的页进行操作。 3.数据结构的关系 了解了上述结构体的关键字段,它们与进程之间的逻辑关系便是我们接下来要关心的重点。我们知道,一个进程在内核中使用task_struct结构对其进行描述。task_struct结构中有一个mm字段,它所指向的便是与该进程用户空间所对应的mm_struct结构体。通过上述分析,我们知道mm_struct结构中有mmap字段,它指向VMA双链表。因此,我们使用current->mm->mmap就可以获得VMA链表的头指针。那么current->mm->mmap->vm->next就可以获得指向该VMA双联表的下一个结点的指针。 4.动手查看内存区域 上述我们从代码角度分析了用户地址空间和内存区域。那么对于一个任意的进程,我们如何查看它的内存空间和所划分的内存区域? 我们先看一个简单的测试程序: int
main(void) { int i=1; char *str=NULL; str=(char *)malloc(sizeof(char)*1119); sleep(1000); return 0; } 这个程序中使用到了malloc函数,因此str变量存储于堆中。我们通过打印/proc/3530/maps文件,即可看到该进程的内存空间划分。其中3530是该进程的id。 # cat /proc/3530/maps 0014a000-00165000 r-xp 00000000
08:07 398276 /lib/ld-2.11.1.so 00165000-00166000 r--p 0001a000 08:07 398276 /lib/ld-2.11.1.so 00166000-00167000 rw-p 0001b000 08:07 398276 /lib/ld-2.11.1.so 001d8000-0032b000 r-xp 00000000 08:07 421931 /lib/tls/i686/cmov/libc-2.11.1.so 0032b000-0032c000
---p 00153000 08:07 421931 /lib/tls/i686/cmov/libc-2.11.1.so 0032c000-0032e000 r--p 00153000 08:07 421931 /lib/tls/i686/cmov/libc-2.11.1.so 0032e000-0032f000 rw-p 00155000 08:07 421931 /lib/tls/i686/cmov/libc-2.11.1.so 0032f000-00332000 rw-p 00000000
00:00 0 00441000-00442000 r-xp 00000000 00:00 0 [vdso] 08048000-08049000 r-xp 00000000 08:09 326401 /home/edsionte/test 08049000-0804a000 r--p 00000000 08:09 326401 /home/edsionte/test 0804a000-0804b000 rw-p 00001000 08:09 326401 /home/edsionte/test
08958000-08979000 rw-p 00000000 00:00 0 [heap] b78ce000-b78cf000 rw-p 00000000 00:00 0 b78dd000-b78e0000 rw-p 00000000 00:00 0 bfa6a000-bfa7f000 rw-p 00000000 00:00 0 [stack] 每一行信息依次显示的内容为内存区域其实地址-终止地址,访问权限,偏移量,主设备号:次设备号,inode,文件。 上面的信息不但包含了test可执行对象的各内存区域,而且还分别显示了
/lib/ld-2.11.1.so(动态连接程序)文件和/lib/tls/i686/cmov/libc-2.11.1.so(C库)文件的内存区域信息。 从某个内存区域的访问权限上可以大致判断该区域的类型。各个属性符号的意义为:r-read,w-write,x-execute,s-shared,p-private。因此,r-x一般代表程序的代码段,即可读,可执行。rw-可能代表数据段,BSS段和堆栈段等,即可读,可写。堆栈段从行信息的文件名就可以区分;如果某行信息的文件名为空,那么可能是BSS段。另外,上述test进程共享了内核动态库,所以在00441000-00442000行处文件名显示为vdso(Virtual
Dynamic Shared Object)。