Linux内核学习笔记(五)进程地址空间

进程地址空间简介

Linux是一个虚拟内存操作系统,系统中的所有进程以虚拟方式共享内存。从进程的视角来看,它独占系统中所有的物理内存,并且一个进程的地址空间可以远远大于物理内存的大小。

进程的地址空间由可寻址的虚拟内存组成,进程通过地址空间中的虚拟地址访问内存。一个进程可以选择和其他进程共享地址空间,这样的进程就是我们说的线程。

尽管进程最多可以寻址4GB的虚拟内存(在32-bit的地址空间中),但是这不代表它可以访问所有的虚拟地址,地址空间中的进程可以访问的区间如0848000-0804c000被称为为内存区域(memory area),进程可以通过内核给自己的地址空间动态的增加或者删除内存区域。

进程只能访问有效内存区域中的内存地址。内存区域有对应的权限,如可读、可写、可执行。如果内存访问的内存地址不在一个有效的内存区域中或者违反了该内存区域的访问权限,内核会杀死这个进程,并返回 “Segmentation Fault”消息。

不同的内存片段(如栈、对象代码、全局变量、映射的文件)在内核中都各自对应的独立的内存区域。

内存描述符

内核使用内存描述符来表示进程的地址空间,文件描述符是使用mm_struct结构来存储的,该结构包含了和进程地址空间相关的全部信息,定义在<linux/mm_types.h>中

struct mm_struct { 
    struct vm_area_struct  *mmap;               /* list of memory areas */ 
    struct rb_root         mm_rb;               /* red-black tree of VMAs */ 
    struct vm_area_struct  *mmap_cache;         /* last used memory area */ 
    unsigned long          free_area_cache;     /* 1st address space hole */ 
    pgd_t                  *pgd;                /* page global directory */ 
    atomic_t               mm_users;            /* address space users */ 
    atomic_t               mm_count;            /* primary usage counter */ 
    int                    map_count;           /* number of memory areas */ 
    struct rw_semaphore    mmap_sem;            /* memory area semaphore */ 
    spinlock_t             page_table_lock;     /* page table lock */ 
    struct list_head       mmlist;              /* list of all mm_structs */ 
    unsigned long          start_code;          /* start address of code */ 
    unsigned long          end_code;            /* final address of code */ 
    unsigned long          start_data;          /* start address of data */ 
    unsigned long          end_data;            /* final address of data */ 
    unsigned long          start_brk;           /* start address of heap */ 
    unsigned long          brk;                 /* final address of heap */ 
    unsigned long          start_stack;         /* start address of stack */ 
    unsigned long          arg_start;           /* start of arguments */ 
    unsigned long          arg_end;             /* end of arguments */ 
    unsigned long          env_start;           /* start of environment */ 
    unsigned long          env_end;             /* end of environment */ 
    unsigned long          rss;                 /* pages allocated */ 
    unsigned long          total_vm;            /* total number of pages */ 
    unsigned long          locked_vm;           /* number of locked pages */ 
    unsigned long          saved_auxv[AT_VECTOR_SIZE]; /* saved auxv */ 
    cpumask_t              cpu_vm_mask;         /* lazy TLB switch mask */ 
    mm_context_t           context;             /* arch-specific data */ 
    unsigned long          flags;               /* status flags */ 
    int                    core_waiters;        /* thread core dump waiters */ 
    struct core_state      *core_state;         /* core dump support */ 
    spinlock_t             ioctx_lock;          /* AIO I/O list lock */ 
    struct hlist_head      ioctx_list;          /* AIO I/O list */
};

其中:

  • mm_users正在使用该地址空间的进程数,比如9个进程共享这个地址空间(其实就是线程),mm_users就是9。
  • mm_count表示地址空间的主引用计数,mm_users大于0时,即有一个或者多个用户进程正在使用该地址空间,mm_count的值会加1,如果内核线程在使用该地址空间,mm_count也会加1。当没有用户进程也没有内核线程使用该地址空间时,mm_count将为0,此时该地址空间将被释放。
  • mmap和mm_rb域存储的数据是相同的:地址空间中所有的内存区域(memory area,使用vm_area_struct结构存储)。mmap使用链表存储,方便遍历。mm_rb使用红黑树存储,方便查找。
  • mmlist指向一个双向链表,内核使用该双向链表存储所有的mm_struct结构,该双向链表的头节点为init_mm,是init进程的地址空间描述符。

分配内存描述符

进程描述符task_struct结构中的mm域指向该进程使用的地址空间的文件描述符mm_struct结构。copy_mm()函数在fork时将父进程的内存描述符复制给子进程。mm_struct结构使用slab allocator来进行分配和回收。通常情况下一个进程都有一个独占的mm_struct结构,即独占的地址空间。如果进程在clone()时传递CLONE_VM参数,就会将mm_struct结构(即地址空间)共享给复制的子进程,这就是线程了。

销毁内存描述符

进程结束时,会调用exit_mm(),这个函数会调用mmput()将内存描述符mm_struct的使用计数mm_users减1,如果mm_users变为0,就会调用mmdrop()将mm_count减1,如果mm_count变为0了,那么就调用free_mm()宏,通过kmem_chache_frer()将mm_struct结构释放给mm_cachep slab 缓存。

mm_struct与内核线程

内核线程没有自己的进程地址空间,因此没有对应的内存描述符,所以内核线程的进程描述符的mm域为NULL。内核线程使用的是前一个进程的内存描述符,即使用该内存的地址空间。这样做的好处是避免了内核线程为内核描述符和页表浪费内存,同时避免了地址空间的切换,提高了效率。

内核线程执行时,会将进程描述符中的active_mm指针指向上一个进程的内存描述符mm_struct结构,这样在需要时,内核线程也可以使用上一个进程的页表。内核线程不访问用户空间的内存,只使用内核地址空间,

虚拟内存区域

内核使用vm_area_struct结构来表示内存区域,在linux中内存区域经常被称为虚拟内存区域(virtual memory area,缩写VMA)。vm_area_struct结构描述了一个给定地址空间中一个单独的内存区域,内核将每个内存区域作为一个单独的内存对象进行管理。每个内存区域都有特定的属性、访问权限和一系列相关的操作。这样使得每个VMA结构都可以表示不同类型的内存区域,比如进程的用户栈等。

vm_area_struct结构定义在<linux/mm_types.h<中:

struct vm_area_struct 
{ 
    struct mm_struct             *vm_mm;        /* associated mm_struct */ 
    unsigned long                vm_start;      /* VMA start, inclusive */ 
    unsigned long                vm_end;        /* VMA end , exclusive */ 
    struct vm_area_struct        *vm_next;      /* list of VMA’s */ 
    pgprot_t                     vm_page_prot;  /* access permissions */ 
    unsigned long                vm_flags;      /* flags */ 
    struct rb_node               vm_rb;         /* VMA’s node in the tree */ 
    /* links to address_space->i_mmap or i_mmap_nonlinear */
    union 
    {
        struct 
        {
            struct list_head        list; 
            void                    *parent; 
            struct vm_area_struct   *head;
        } vm_set; 
        struct prio_tree_node prio_tree_node;
    } shared; 
    struct list_head             anon_vma_node;     /* anon_vma entry */ 
    struct anon_vma              *anon_vma;         /* anonymous VMA object */ 
    struct vm_operations_struct  *vm_ops;           /* associated ops */ 
    unsigned long                vm_pgoff;          /* offset within file */ 
    struct file                  *vm_file;          /* mapped file, if any */ 
    void                         *vm_private_data;  /* private data */
};

每个VMA都对应进程地址空间中唯一的一个区间,vm_start域指向区间首地址,vm_end指向区间的尾地址(不含),内存区域的区间为[vm_start, vm_end)。同一个进程地址空间中的内存区域之间是不可以重叠的。vm_mm域指向了VMA所在的进程地址空间对应的内存描述符mm_struct结构。

vm_flags域包含bit flags。规定了该VMA中所有page的行为和信息。下表给出了所有可能的flag。

Flag Effect on the VMA and Its Pages
VM_READ Pages can be read from.
VM_WRITE Pages can be written to.
VM_EXEC Pages can be executed.
VM_SHARED Pages are shared.
VM_MAYREAD The VM_READ flag can be set.
VM_MAYWRITE The VM_WRITE flag can be set.
VM_MAYEXEC The VM_EXEC flag can be set.
VM_MAYSHARE The VM_SHARE flag can be set.
VM_GROWSDOWN The area can grow downward.
VM_GROWSUP The area can grow upward.
VM_SHM The area is used for shared memory.
VM_DENYWRITE The area maps an unwritable file.
VM_EXECUTABLE The area maps an executable file.
VM_LOCKED The pages in this area are locked.
VM_IO The area maps a device’s I/O space.
VM_SEQ_READ The pages seem to be accessed sequentially.
VM_RAND_READ The pages seem to be accessed randomly.
VM_DONTCOPY This area must not be copied on fork().
VM_DONTEXPAND This area cannot grow via mremap().
VM_RESERVED This area must not be swapped out.
VM_ACCOUNT This area is an accounted VM object.
VM_HUGETLB This area uses hugetlb pages.
VM_NONLINEAR This area is a nonlinear mapping.

其中:

  • VM_READ,VM_WRITE,VM_EXEC flag 规定了该VMA内的page的读、写、执行权限。
  • VM_SHARED 规定了VMA中映射是否可以多个进程共享。
  • VM_IO 说明该VMA是否是一个设备的I/O空间的映射。
  • VM_SEQ_READ 表示程序正在对映射内容进行有序读,即线性的和连续的读。此时内核可以选择是否进行预读(read-ahead)以提高效率。预读是估计后面的数据很可能被用到,所以有意按顺序多读取额外的数据。
  • VM_RADN_READ 表示程序正在对映射内容进行随机的无序读,此时预读就没有价值了。

vm_area_struct结构中的vm_ops指向VMA的操作函数表,内核使用函数表中的函数操作VMA。

在linux中通过cat /proc/pid/maps 或者 pmap pid可以查看进程的有哪些内存区域以及他们的位置、大小、权限等信息。

如果一个内存区域是共享的或者不可写的,那么进程只为backing file保存一份映射。查看进程的内存区域时,会发现有很多.so这样的C库,因为C库是共享的,内核不需要为每个使用C库的进程创建一个VMA保存其使用的C库的text、data、bss内存区域,节省了大量的空间。

操作内存区域

内核中定义了多个管理内存区域的函数。

find_vma()

struct vm_area_struct * find_vma(struct mm_struct *mm, unsigned long addr);
找到第一个包含addr的内存区域或者第一个起始地址大于addr的内存区域,没有则返回NULL。

find_vma_prev()

struct vm_area_struct * find_vma_prev(struct mm_struct *mm, unsigned long addr, struct vm_area_struct **pprev)
返回位于addr前面的最后一个内存区域,没有则返回NULL。

find_vma_intersection()

static inline struct vm_area_struct *find_vma_intersection(struct mm_struct *mm, unsigned long start_addr,unsigned long end_addr)
返回第一个和给定的地址区间有重叠的内存区域,没有则返回NULL。

do_mmap()

unsigned long do_mmap(struct file *file, unsigned long addr,unsigned long len, unsigned long prot,unsigned long flag, unsigned long offset)
创建一个新的地址区间,如果这个新的内存区域和一个已有的内存区域相邻并且有着同样的访问权限,就将这个两个内存区域合并为一个。如果不能合并就创建一个新的内存区域。分配失败会返回一个负值。

mmap()

void * mmap2(void *start, size_t length, int prot, int flags, int fd, off_t pgoff)
这个是暴露给用户空间的系统调用接口,定义为mmap2()是因为这是mmap()第二个变种。mmap会调用do_mmap()来进行地址区间的分配。

do_munmap()

int do_munmap(struct mm_struct *mm, unsigned long start, size_t len)
从地址空间中移除一个地址区间,start规定了区间的起始位置,len规定了区间的字节长度。

munmap()

int munmap(void *start, size_t length)
这是暴露给用户空间的系统调用接口,munmap()会调用do_munmap()来移除由start和length规定的地址区间。

页表(page table)

虽然应用程序操作的是虚拟内存,但是处理器直接操作的是物理内存。所以当应用程序访问一个虚拟地址时,虚拟地址会被转换为物理地址,然后再交给处理器。这个操作是通过页表来完成的。页表中的页表项指向下一级页表或者一个物理页。

Linux使用的是三级页表,多级页表可以减少内存占用。第一级是PGD(page global directory),PGD是一个pgd_t类型的数组(pgd_t通常为unsigned long),PGD的页表项指向二级页表。二级页表是PMD(page middle directory),是一个pmd_t类型的数组,PMD的页表项指向三级页表。三级页表就是原始的页表,页表项为pte_t类型,指向一个物理页。使用页表进行虚拟地址转换时,首先访问一级页表中对应的页表项,然后访问该页表项指向的二级页表,在二级页表中找到对应的页表项,然后访问该页表项指向的三级页表,最终在三级页表中找到虚拟地址所在页对应的物理页,然后将虚拟地址转换成物理地址。使用多级页表时,上级流表的页表项是可能为空,即不指向任何下级流表,这种情况下对应的下级流表在内存中是不存在的的,从而减少内存开销。因为大部分进程都有自己的流表项,所以总共节省的内存空间是很可观的。

每个进程都有自己的页表(线程会共享地址空间,所以页表是共享的),内存描述符mm_struct结构的pgd域指向的是一级页表PGD。由于几乎每次对虚拟内存页的访问都需要先进行地址转换,所以页表的性能非常重要,但是在内存中查找的速度是有限的,为了加快查找速度,大部分处理器都实现了TLB(translation lookaside buffer)。TLB是虚拟地址-物理地址映射的硬件缓存,每次访问一个虚拟地址时,处理器都会首先检查TLB是否缓存了该虚拟地址到物理地址的映射,如果有,直接返回,如果没有,就需要通过多级页表进行地址转换。

参考资料

《Linux Kernel Development 3rd Edition》
《Understanding The Linux Kernel 3rd Edition》

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页