【Linux】内核学习笔记(十三)——进程地址空间

本篇是《Linux内核学习笔记》系列的第十三篇,讲解内存管理系统另一重要概念:进程地址空间。这是操作系统虚拟化的重要实现部分。

上一篇物理内存的管理见这篇文章:【Linux】内核学习笔记(十一)——内存管理。

虚拟内存可以让所有进程都以为自己拥有一份完整、平坦的地址空间(当然能不能访问是另一回事)。接下来我们会学习背后的实现机制。

对进程地址空间的实现机制有了解之后,再去看那些八股,会觉得八股不过是对计算机知识的高度凝练总结。可这样的学习顺序带来的效果却远远好于直接去背那些晦涩难懂的烂“真理”。

1. 进程的地址空间有什么?

第一,是可执行文件本身的那些东西,例如代码、数据等等,还会有加载器分配的bss空间和栈空间。

第二,动态链接库的代码、数据、bss段等也会映射到进程地址空间。

第三,内存映射文件和共享内存段。

第四,malloc获取的内存当然也映射到进程地址空间。

所有这些区域在进程地址空间中的映射都不能相互覆盖。

2. 内存描述符mm_struct

struct mm_struct {
	struct {
		struct vm_area_struct *mmap;		/* list of VMAs */
		struct rb_root mm_rb;
		u64 vmacache_seqnum;                   /* per-thread vmacache */
#ifdef CONFIG_MMU
		unsigned long (*get_unmapped_area) (struct file *filp,
				unsigned long addr, unsigned long len,
				unsigned long pgoff, unsigned long flags);
#endif
		unsigned long mmap_base;	/* base of mmap area */
		unsigned long mmap_legacy_base;	/* base of mmap area in bottom-up allocations */
#ifdef CONFIG_HAVE_ARCH_COMPAT_MMAP_BASES
		/* Base adresses for compatible mmap() */
		unsigned long mmap_compat_base;
		unsigned long mmap_compat_legacy_base;
#endif
		unsigned long task_size;	/* size of task vm space */
		unsigned long highest_vm_end;	/* highest vma end address */
		pgd_t * pgd;

又是一个大结构体,我们只放一部分。有一些关键字段需要说明。

mm_users表示该结构体真实用户的数量,mm_count表示其被引用的次数。这么说实在是太抽象了,我们举个例子:如果有8个用户线程共享该地址空间,那么mm_users为8,但mm_count为1(注意:mm_count初始化的时候就是1)。这时,一名内核线程高调路过,它虽然不食人间烟火,根本用不着用户地址空间这么庸俗低效的东西(即进程描述符中的mm域为NULL),但天宫的规矩总归要守的吧?访问内核的内存,那么内核的地址映射还是要得,并不是所有内存都可以用物理地址访问(还记得vmalloc吗?)。所以,内核线程要执行的时候,得有一个内存描述符,不过是找被抢占线程借来的,然后放到自己的mm_active域上,这时借来的内存描述符mm_count就加1了。

这时我们会看到,假如被抢占线程后面比内核线程先执行完,那么mm_users可能就为0了,这时内核将为mm_count减1。然而得亏内核线程对其进行引用,mm_count始终大于0,可以保证这个内核线程正常执行完毕。

如果上面的讲述还没有讲清楚,没关系,可以先往下读,回头再仔细琢磨。

mmap和mm_rb这两个不同的数据结构实际上包括了相同的对象:即该地址空间的全部内存区域。但mmap是一个链表,便于迅速地进行遍历、插入和删除;mm_rb将所有内存区域节点组织成一棵红黑树,可以在稳定的O(logn)时间内查到任一需要的内存区域。这就是传说中的线索树,双剑合璧,天下无敌(bushi

所有的mm_struct通过mmlist域连接到一个双向链表中,它的首节点是init_mm内存描述符,代表init进程的地址空间。操作该链表时自然需要锁,它是mmlist_lock:

__cacheline_aligned_in_smp DEFINE_SPINLOCK(mmlist_lock);

我们也能想象到,内核里大部分公用数据结构都是用自旋锁保护起来的,因为它最便携,适用范围最广。

2.1. mm_struct和内核线程

进程描述符中有mm和mm_active两个域。对于用户线程,mm就其拥有的的内存描述符,正在使用的当然也是这个描述符,所以mm_active和mm完全一样。但对于内核线程,它不关心用户空间页表,mm直接设为NULL,mm_active则为正在借用的内存描述符。

这是为什么呢?因为内核空间不需要访问任何用户空间内存,更不需要有自己的内存描述符和页表。然而,访存总是需要页表的,例如x64 CPU启动后不久就已经通过置位CR0寄存器,决定每一次访存都需要访问页表。为了避免内核线程在内存描述符和页表上浪费空间,以及CPU访问新地址空间时产生cache缺失,内核线程可以直接使用一个当前进程的内存描述符,毕竟描述符指向的内存空间是完全一样的。

如果面试官问:内核线程和用户线程的区别在哪里?你可以说:内核线程没有用户地址空间,它的进程描述符中mm为NULL,换言之——内核线程没有用户上下文。很自然地就答出来了。

2.2. 分配内存描述符

内存描述符从哪里来?我们已经学过物理内存分配了,它可以直接从高速缓存里拿到。

为了创建一个新的进程描述符,有时我们需要从slab里拿一个全新的内存描述符,然后把父进程的内容拷贝进去:

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
	struct mm_struct *mm, *oldmm;
	...
	mm = dup_mm(tsk, current->mm);
	if (!mm)
		goto fail_nomem;

good_mm:
	tsk->mm = mm;
	tsk->active_mm = mm;
	return 0;
}

在dup_mm中,程序调用allocate_mm获取一个全新的内存描述符,然后从current->mm中进行拷贝。

还有些时候,我们指定了CLONE_VM标志,让子进程和父进程共享地址空间。我们把这种进程叫做线程。怎么样?是不是瞬间明白了Linux进程和线程的区别?Linux里,线程只是进程的一个特例,它共享一些特别的资源。换言之,Linux上的是内核级线程,内核能感受到每一个线程的存在并调度它们,以及分配资源。这么去理解内核级线程不比背书好多了?

	if (clone_flags & CLONE_VM) {
		mmget(oldmm);
		mm = oldmm;
		goto good_mm;
	}

这种情况下,就不用另行创建新的内存描述符,只需调用mmget为mm_users原子地加1,然后让tsk->mm指向父进程地内存描述符就好。

还有种更极端的:假如是拷贝一个内核线程,这种情况下连current->mm都是NULL,怎么办?

答案:内核线程哪里需要mm,以后要用的(即被调度上处理器)时候,把current->active_mm的内容拿来放到tsk->active_mm就行。

	/*
	 * Are we cloning a kernel thread?
	 *
	 * We need to steal a active VM for that..
	 */
	oldmm = current->mm;
	if (!oldmm)
		return 0;

2.3. 撤销内存描述符

撤销很简单。当一个进程退出时,会调用exit_mm,其中某一步调用mmput来为内存描述符的mm_user减1。如果内存描述符不再有拥有者,就调用mmdrop为mm_count减1。如果连mm_count都变为0,说明连它连被偷着使用的可能性都没有了,可以调用free_mm来释放该结构。

3. 虚拟内存区域VMA

3.1. vm_area_struct

vm_area_struct就代表一个地址空间上的一段独立内存范围,内核将每个内存范围都作为一个对象进行管理。这个结构体不算特别大,但注释清楚,因此全部都放上来了。整个结构体布局是按缓存行长度精心设计过的。

/*
 * This struct describes a virtual memory area. There is one of these
 * per VM-area/task. A VM area is any part of the process virtual memory
 * space that has a special rule for the page-fault handlers (ie a shared
 * library, the executable area etc).
 */
struct vm_area_struct {
	/* The first cache line has the info for VMA tree walking. */

	unsigned long vm_start;		/* Our start address within vm_mm. */
	unsigned long vm_end;		/* The first byte after our end address
					   within vm_mm. */

	/* linked list of VM areas per task, sorted by address */
	struct vm_area_struct *vm_next, *vm_prev;

	struct rb_node vm_rb;

	/*
	 * Largest free memory gap in bytes to the left of this VMA.
	 * Either between this VMA and vma->vm_prev, or between one of the
	 * VMAs below us in the VMA rbtree and its ->vm_prev. This helps
	 * get_unmapped_area find a free area of the right size.
	 */
	unsigned long rb_subtree_gap;

	/* Second cache line starts here. */

	struct mm_struct *vm_mm;	/* The address space we belong to. */

	/*
	 * Access permissions of this VMA.
	 * See vmf_insert_mixed_prot() for discussion.
	 */
	pgprot_t vm_page_prot;
	unsigned long vm_flags;		/* Flags, see mm.h. */

	/*
	 * For areas with an address space and backing store,
	 * linkage into the address_space->i_mmap interval tree.
	 */
	struct {
		struct rb_node rb;
		unsigned long rb_subtree_last;
	} shared;

	/*
	 * A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
	 * list, after a COW of one of the file pages.	A MAP_SHARED vma
	 * can only be in the i_mmap tree.  An anonymous MAP_PRIVATE, stack
	 * or brk vma (with NULL file) can only be in an anon_vma list.
	 */
	struct list_head anon_vma_chain; /* Serialized by mmap_lock &
					  * page_table_lock */
	struct anon_vma *anon_vma;	/* Serialized by page_table_lock */

	/* Function pointers to deal with this struct. */
	const struct vm_operations_struct *vm_ops;

	/* Information about our backing store: */
	unsigned long vm_pgoff;		/* Offset (within vm_file) in PAGE_SIZE
					   units */
	struct file * vm_file;		/* File we map to (can be NULL). */
	void * vm_private_data;		/* was vm_pte (shared mem) */

#ifdef CONFIG_SWAP
	atomic_long_t swap_readahead_info;
#endif
#ifndef CONFIG_MMU
	struct vm_region *vm_region;	/* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
	struct mempolicy *vm_policy;	/* NUMA policy for the VMA */
#endif
	struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

vm_area_struct对象代表[vm_start, vm_end)区间范围内的虚拟内存区域。

vm_flags是VMA标志,定义于mm.h,标志了内存区域是否可读、可写、可执行、可向某方向增长、为共享内存、映射到文件、页面被锁定、页面用于随机访问/顺序访问等等。最后一条有点意思,如果内存是顺序访问的,那么内核可以预读一些内容出来,因为后续很可能用到;而如果内存是随机访问的,那么内核就会做一些相反的事情,即尽量不预读。

vm_ops指向操作函数表。由于VMA可以代表任意类型的内存区域,因次不同VMA的vm_ops也不一样。

/*
 * These are the virtual MM functions - opening of an area, closing and
 * unmapping it (needed to keep files on disk up-to-date etc), pointer
 * to the functions called when a no-page or a wp-page exception occurs.
 */
struct vm_operations_struct {
	void (*open)(struct vm_area_struct * area);
	void (*close)(struct vm_area_struct * area);
	int (*split)(struct vm_area_struct * area, unsigned long addr);
	int (*mremap)(struct vm_area_struct * area);
	vm_fault_t (*fault)(struct vm_fault *vmf);
	vm_fault_t (*huge_fault)(struct vm_fault *vmf,
			enum page_entry_size pe_size);
	void (*map_pages)(struct vm_fault *vmf,
			pgoff_t start_pgoff, pgoff_t end_pgoff);
	unsigned long (*pagesize)(struct vm_area_struct * area);

	/* notification that a previously read-only page is about to become
	 * writable, if an error is returned it will cause a SIGBUS */
	vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);

	/* same as page_mkwrite when using VM_PFNMAP|VM_MIXEDMAP */
	vm_fault_t (*pfn_mkwrite)(struct vm_fault *vmf);

	/* called by access_process_vm when get_user_pages() fails, typically
	 * for use by special VMAs that can switch between memory and hardware
	 */
	int (*access)(struct vm_area_struct *vma, unsigned long addr,
		      void *buf, int len, int write);

	/* Called by the /proc/PID/maps code to ask the vma whether it
	 * has a special name.  Returning non-NULL will also cause this
	 * vma to be dumped unconditionally. */
	const char *(*name)(struct vm_area_struct *vma);


	/*
	 * Called by vm_normal_page() for special PTEs to find the
	 * page for @addr.  This is useful if the default behavior
	 * (using pte_page()) would not find the correct page.
	 */
	struct page *(*find_special_page)(struct vm_area_struct *vma,
					  unsigned long addr);
};

其中一些函数和调用时机如下:

  • open:指定内存区域被加入一个地址空间;
  • close:指定内存区域被从一个地址空间删除;
  • fault:当该页不在物理内存中时(缺页故障),由处理函数进行调用;
  • page_mkwrite:某页面为只读页时,由页面故障处理函数调用;

不同的进程不共享地址空间,所以拥有各自的VMA链表,即使有共享地址区间也是如此;共享地址空间的不同线程,它们拥有相同的进程描述符,自然共享VMA链表。

3.2. 查看内存区域

在命令行使用pmap命令查看某一进程的内存映射,例如:

一般只能查看属于自己的进程的地址空间。如果要查看其它用户的进程,使用sudo。

4. 操作内存区域

有一些操作是内核需要频繁执行的,例如查询某一地址是否在某个内存区域之中。内核对此定义了许多辅助函数,从而加速这一例程。

4.1. find_vma

/* Look up the first VMA which satisfies  addr < vm_end,  NULL if none. */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
	struct rb_node *rb_node;
	struct vm_area_struct *vma;

	/* Check the cache first. */
	vma = vmacache_find(mm, addr);
	if (likely(vma))
		return vma;

	rb_node = mm->mm_rb.rb_node;

	while (rb_node) {
		struct vm_area_struct *tmp;

		tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);

		if (tmp->vm_end > addr) {
			vma = tmp;
			if (tmp->vm_start <= addr)
				break;
			rb_node = rb_node->rb_left;
		} else
			rb_node = rb_node->rb_right;
	}

	if (vma)
		vmacache_update(addr, vma);
	return vma;
}

多么棒的代码!在内核重重封装抽象之下,查找VMA是如此优雅简单,代码也比大部分人写的算法题看起来更加简明易懂;注释也是如此到位,虽然仅仅两句而已。函数功能和实现我就不解释了,相信如果您有耐心读到这里的话,这点东西对您一定是小菜一碟吧?

值得注意的是,find_vma如果返回了VMA块,那么它仅仅是保证vm_end在给定地址之后而已,未必就一定包含给定的地址。

4.2. find_vma_prev

它的作用和find_vma相同,不过还能从二级指针pprev返回find_vma得到VMA的上一个VMA。如果find_vma返回的是NULL,说明所有VMA都在给定的地址之前,那么给pprev返回最后一个VMA就好。自然语言有点描述不清楚了,上代码。

/*
 * Same as find_vma, but also return a pointer to the previous VMA in *pprev.
 */
struct vm_area_struct *
find_vma_prev(struct mm_struct *mm, unsigned long addr,
			struct vm_area_struct **pprev)
{
	struct vm_area_struct *vma;

	vma = find_vma(mm, addr);
	if (vma) {
		*pprev = vma->vm_prev;
	} else {
		struct rb_node *rb_node = rb_last(&mm->mm_rb);

		*pprev = rb_node ? rb_entry(rb_node, struct vm_area_struct, vm_rb) : NULL;
	}
	return vma;
}

4.3. find_vma_intersection

返回第一个和指定地址区间相交的VMA。

/* Look up the first VMA which intersects the interval start_addr..end_addr-1,
   NULL if none.  Assume start_addr < end_addr. */
static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr)
{
	struct vm_area_struct * vma = find_vma(mm,start_addr);

	if (vma && end_addr <= vma->vm_start)
		vma = NULL;
	return vma;
}

5. mmap和munmap系统调用

我们看看mmap的原型:

/* Map addresses starting near ADDR and extending for LEN bytes.  from
   OFFSET into the file FD describes according to PROT and FLAGS.  If ADDR
   is nonzero, it is the desired mapping address.  If the MAP_FIXED bit is
   set in FLAGS, the mapping will be at ADDR exactly (which must be
   page-aligned); otherwise the system chooses a convenient nearby address.
   The return value is the actual mapping address chosen or MAP_FAILED
   for errors (in which case `errno' is set).  A successful `mmap' call
   deallocates any previous mapping for the affected region.  */

extern void *mmap (void *__addr, size_t __len, int __prot,
		   int __flags, int __fd, __off_t __offset) __THROW;

这玩意可就酷了,比如:从addr传入(几乎)任意的一个数字,然后就可以返回以它附近一个地址为开始(即可能需要页对齐)的虚拟地址空间。比如传入地址0x1145141919,申请长度为80字节,那么返回的是0x1145141000;实际上到0x1145142000的地址都是可用的。当然也可以通过flags指定mmap精确返回所需要的地址。一般来说自己指定addr的话代码稳健性不好,还是建议传入NULL,内核会自己决定一个内存区域用以返回。

那这么说,我在需要malloc的地方,是不是可以直接使用mmap?还真可以,而且malloc有一部分就是用mmap实现的。不过用malloc获取非共享内存当然是更高效一些,后面我会写个专题来详细讲一下。

mmap可以通过传入的flags指定内存能否共享。要是设成MAP_SHARED,就是可以共享,这时我们拿到了一块可由不同进程共享的内存。

int* buf = mmap(NULL, 20 * sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANON, -1, 0);

父子进程要共享这片内存还是很容易的。但如果不是父子进程怎么办?其实用shmget函数就好,不必使用mmap这么底层的东西。

mmap最最常用的就是将文件映射到一段共享内存上。这可就nb了,我们可以做到一次打开,多个进程直接读取,相比于每个进程都open、read,省去了不少管目态切换、数据拷贝以及I/O的开销。

int* buf = mmap(NULL, 20 * sizeof(int), PROT_READ, MAP_SHARED, fd, 0);

mmap只是提供地址映射,不提供同步机制。但是内存都可以共享了,在里面设置一个锁结构还不是易如反掌?

munmap系统调用用于删除地址区间。

/* Deallocate any mapping for the region starting at ADDR and extending LEN
   bytes.  Returns 0 if successful, -1 for errors (and sets errno).  */
extern int munmap (void *__addr, size_t __len) __THROW;

6. 页表

说完一大堆关于内存描述符和VMA的东西,读者很可能有疑问:进程运行的时候怎么获取这些信息?总不能每一次访问虚拟地址都去内核VMA里查物理地址吧?因此,执行者——处理器,必须提供相应的硬件机制确保高效内存访问,并在页面故障时迅速跳转至处理例程。这个机制由MMU(内存管理单元)实现。

当处理器开启分页时,MMU会自动访问页表项。一般采取三级页表,我们可以理解,这种方式能够有效减少页表的内存占用。Linux就实现了三级页表,当然这也能在只支持两级页表或散列表的处理器上运行。每个进程都维护着自己的页表,它由内存描述符的pgd字段指向。

为了说明VMA和页表的关系,我们来盗个图(原文:How The Kernel Manages Your Memory),一张好图胜过千言万语。

对于x64架构,切换进程的时候,设一下CR3寄存器就好了。在用户空间中MMU会自动查询页表。

多级页表的问题在于,MMU可能多次需要多次访存,可以利用页表查询的局部性设计cache,这就是TLB,即快表。

参考资料

mm和active_mm

When should I use mmap for file access? – Stack Overflow

How The Kernel Manages Your Memory

发表评论

您的电子邮箱地址不会被公开。