linux内存管理-外部设备存储空间的地址映射

任何系统都免不了要有输入、输出,所以对外部设备的访问是CPU设计中的一个重要问题。一般来说,对外部设备的访问有两种不同的形式,一种叫做内存映射式(memory mapped),另一种叫IO映射式(I/O mapped)。在采用内存映射方式的CPU中,外部设备的存储单元,如控制寄存器、状态寄存器、数据寄存器等等,是作为内存的一部分出现在系统中的。CPU可以向访问一个内存单元一样的访问外部设备的存储单元,所以不需要专门设立用于外设I/O的指令。从前的PDP-11、后来的M68K、PowerPC等CPU都采用这种方式。而在采用I/O映射方式的系统则不同,外部设备的存储单元与内存分属两个不同的体系。访问内存的指令不能用来访问外部设备的存储单元,所以在X86CPU中设立了专门的IN和OUT指令,但是用于I/O指令的地址空间相对来说是很小的。事实上,现在X86的I/O地址空间已经非常拥挤。

但是,随着计算机技术的发展,人们发现单纯的I/O映射式不能满足要求的。此种方式只适合于早期的计算机技术,那时候一个外设通常都只有几个寄存器,通过这几个寄存器就可以完成对外设的所有操作了。而现在的情况却大不一样。例如,在PC机上可以插上一块图像卡,带有2MB的存储器了。所以,不管CPU的设计采用I/O映射或是存储器映射,都必须要由将外设卡上的存储器映射到内存空间,实际上是虚存空间的手段。在linux内核中,这样的映射是通过函数ioremap来建立的。

对于内存页面的管理,通常我们都是先在虚存空间分配一个虚存区间,然后为此区间分配相应的物理内存页面并建立其映射。而且这样的映射也并不是一次就建立完毕,可以在访问这些虚存页面引起页面异常时逐步地建立。但是,ioremap则不同,首先,我们先有一个物理存储区间,其地址就是外设卡上的存储器出现在总线上的地址。这地址未必就是这些存储单元在外设卡上局部的物理地址,而是在总线上由CPU所看到的地址,这中间很可能已经经历了一次地址映射,但这种映射对于CPU来说是透明的。所以有时把这种地址称为总线地址。举个例子,如果有一块智能图形卡,卡上有个微处理器。对于卡上的微处理器来说,卡上的存储器是从地址9开始的,这就是卡上局部的物理地址。但是将这块图形卡插到PC的一个PCI总线插槽上时,由PC的CPU所看到的这片物理存储区间的地址可能是从0x0000 f000 0000 0000 开始的,这中间已经有了一次映射。可是,从系统(PC)的CPU角度来说。它只知道这片物理区间是从0x0000 f000  0000 0000 开始的,这就是该区间的物理地址,所以必须反向地从物理地址出发找到一片虚存空间并建立起映射。其次,这样的需求只发生与对外部设备的操作,而这是内核的事情,所以相应的虚存区间是在系统空间(3GB以上)。在以前linux内核版本中,这个函数称为vremap,后来改成了ioremap,也突出地反映了这一点。还有,这样的页面当然不服从动态的物理内存页面分配,也不服从kswapd的换出。

先看ioremap,这是一个inline函数,定义在include/asm-i386/io.h:

extern inline void * ioremap (unsigned long offset, unsigned long size)
{
	return __ioremap(offset, size, 0);
}

实际的操作由__ioremap完成,是在arch/i386/mm/ioremap.c中定义的:

 ioremap=>__ioremap


/*
 * Generic mapping function (not visible outside):
 */

/*
 * Remap an arbitrary physical address space into the kernel virtual
 * address space. Needed when the kernel wants to access high addresses
 * directly.
 *
 * NOTE! We need to allow non-page-aligned mappings too: we will obviously
 * have to convert them into an offset in a page-aligned mapping, but the
 * caller shouldn't need to know that small detail.
 */
void * __ioremap(unsigned long phys_addr, unsigned long size, unsigned long flags)
{
	void * addr;
	struct vm_struct * area;
	unsigned long offset, last_addr;

	/* Don't allow wraparound or zero size */
	last_addr = phys_addr + size - 1;
	if (!size || last_addr < phys_addr)
		return NULL;

	/*
	 * Don't remap the low PCI/ISA area, it's always mapped..
	 */
	if (phys_addr >= 0xA0000 && last_addr < 0x100000)
		return phys_to_virt(phys_addr);

	/*
	 * Don't allow anybody to remap normal RAM that we're using..
	 */
	if (phys_addr < virt_to_phys(high_memory)) {
		char *t_addr, *t_end;
		struct page *page;

		t_addr = __va(phys_addr);
		t_end = t_addr + (size - 1);
	   
		for(page = virt_to_page(t_addr); page <= virt_to_page(t_end); page++)
			if(!PageReserved(page))
				return NULL;
	}

	/*
	 * Mappings have to be page-aligned
	 */
	offset = phys_addr & ~PAGE_MASK;
	phys_addr &= PAGE_MASK;
	size = PAGE_ALIGN(last_addr) - phys_addr;

	/*
	 * Ok, go for it..
	 */
	area = get_vm_area(size, VM_IOREMAP);
	if (!area)
		return NULL;
	addr = area->addr;
	if (remap_area_pages(VMALLOC_VMADDR(addr), phys_addr, size, flags)) {
		vfree(addr);
		return NULL;
	}
	return (void *) (offset + (char *)addr);
}

首先是一些例行检查,常常称为sanity check,或者说健康检查、卫生检查。其中109行检查的是区间的大小既不为0,也不能太大越出了32位地址空间的限制。物理地址0xA0000至0x100000用于VGA卡和BIOS,这是在系统初始化时就映射好了的,不能侵犯到这个区间中去。121行中的high_memory是在系统初始化时,根据检查到的物理内存大小设置的物理内存地址的上限(所对应的虚拟地址)。如果所要求的的phys_addr小于这个上限的话,就表示与系统的物理内存有冲突了,除非相应的物理页面原来就保留着的空洞。在通过这些检查以后,还要保证该物理地址是按页面边界对齐的(136-138行)。

完成了这些准备以后,这才言归正传。首先是要找到一片虚存区间。前面讲过,这片区间属于内核,而不属于任何一个特定的进程,所以不是在某个进程的mm_struct结构中的虚存区间队列中去寻找,而是从属于内核的虚存区间队列中去寻找。函数get_vm_area是在mm/vmalloc.c中定义的:

 ioremap=>__ioremap=>get_vm_area


struct vm_struct * get_vm_area(unsigned long size, unsigned long flags)
{
	unsigned long addr;
	struct vm_struct **p, *tmp, *area;

	area = (struct vm_struct *) kmalloc(sizeof(*area), GFP_KERNEL);
	if (!area)
		return NULL;
	size += PAGE_SIZE;
	addr = VMALLOC_START;
	write_lock(&vmlist_lock);
	for (p = &vmlist; (tmp = *p) ; p = &tmp->next) {
		if ((size + addr) < addr) {
			write_unlock(&vmlist_lock);
			kfree(area);
			return NULL;
		}
		if (size + addr < (unsigned long) tmp->addr)
			break;
		addr = tmp->size + (unsigned long) tmp->addr;
		if (addr > VMALLOC_END-size) {
			write_unlock(&vmlist_lock);
			kfree(area);
			return NULL;
		}
	}
	area->flags = flags;
	area->addr = (void *)addr;
	area->size = size;
	area->next = *p;
	*p = area;
	write_unlock(&vmlist_lock);
	return area;
}

内核为自己保持一个虚存区间队列vmlist,这是由一串vm_struct数据结构组成的一个单链表队列。这里的vm_struct和vmlist都是由内核专用的。vm_struct从概念上说类似于供进程使用的vm_area_struct,但是要简单很多,定义如下:

struct vm_struct {
	unsigned long flags;
	void * addr;
	unsigned long size;
	struct vm_struct * next;
};

struct vm_struct * vmlist;

以前讲过,内核使用的系统空间虚拟地址和物理地址间存在一种简单的映射关系,只要在物理地址上加上一个3GB的偏移就得到了内核的虚拟地址。而变量high_memory标志着具体物理内存的上限所对应的虚拟地址,这是在系统初始化时设置好的。当内核需要一片虚存地址空间时,就从这个地址以上8MB处分配。为此,在include/asm-i386/pgtable.h中定义了VMALLOC_START等有关的常数:

/* Just any arbitrary offset to the start of the vmalloc VM area: the
 * current 8MB value just means that there will be a 8MB "hole" after the
 * physical memory until the kernel virtual memory starts.  That means that
 * any out-of-bounds memory accesses will hopefully be caught.
 * The vmalloc() routines leaves a hole of 4kB between each vmalloced
 * area for the same reason. ;)
 */
#define VMALLOC_OFFSET	(8*1024*1024)
#define VMALLOC_START	(((unsigned long) high_memory + 2*VMALLOC_OFFSET-1) & \
						~(VMALLOC_OFFSET-1))
#define VMALLOC_VMADDR(x) ((unsigned long)(x))
#define VMALLOC_END	(FIXADDR_START)

源代码中的注解对于为什么要留下8MB的空洞,以及在每次分配虚存区间时也要留下一个页面的空洞(见132行)解释得很清楚:是为了便于捕捉可能的越界访问。

这里读者可能会有个问题,185行的if语句检查的是当前的起始地址加上区间大小须小于下一个区间的起始地址,这是很好理解的。可是176行在区间大小上又加了一个页面作为空洞。这个空洞页面难道不可能与下一个区间的起始地址冲突吗?这里的奥妙在于185行判定的条件是<而不是<=,并且size和addr都是按页面边界对齐的,所以185行的条件已经隐含着其中有一个页面的空洞。从get_vm_area成功返回时,就标志着所需要的一片虚存空间已经分配好了,从返回的数据结构可以得到这片空间的起始地址。下面就是建立映射的事情了。

宏定义VMALLOC_VMADDR我们已经在前面看到过了,实际上不做什么事情,只是类型转换。函数remap_area_pages的代码如下:

 ioremap=>__ioremap=>remap_area_pages


static int remap_area_pages(unsigned long address, unsigned long phys_addr,
				 unsigned long size, unsigned long flags)
{
	pgd_t * dir;
	unsigned long end = address + size;

	phys_addr -= address;
	dir = pgd_offset(&init_mm, address);
	flush_cache_all();
	if (address >= end)
		BUG();
	do {
		pmd_t *pmd;
		pmd = pmd_alloc_kernel(dir, address);
		if (!pmd)
			return -ENOMEM;
		if (remap_area_pmd(pmd, address, end - address,
					 phys_addr + address, flags))
			return -ENOMEM;
		address = (address + PGDIR_SIZE) & PGDIR_MASK;
		dir++;
	} while (address && (address < end));
	flush_tlb_all();
	return 0;
}

我们讲过,每个进程的task_struct结构中都有一个指针指向mm_struct结构。从中可以找到相应的页面目录。但是,内核空间不属于任何一个特定的进程,所以单独设置了一个内核专用的mm_struct,称为init_mm。当然,内核也没有代表它的task_struct结构,所以69行根据起始地址从init_mm中找到所属的目录项,然后就根据区间的大小走遍所有涉及的目录项。这里的68行看似奇怪,从物理地址中减去虚拟地址得出一个负的位移量,这个位移量在78-79行又与虚拟地址相加,仍旧得到物理地址。由于在循环中虚拟地址address在变(见81行),物理地址也就相应而变,第75行的pmd_alloc_kernel对于i386 CPU就是pmd_alloc

#define pmd_alloc_kernel	pmd_alloc

而inline函数pmd_alloc的定义则有两个,分别用于二级和三级映射。对于二级映射这个定义为

 ioremap=>__ioremap=>remap_area_pages=>pmd_alloc

extern inline pmd_t * pmd_alloc(pgd_t *pgd, unsigned long address)
{
	if (!pgd)
		BUG();
	return (pmd_t *) pgd;
}

可见,对于i386的二级页式映射,只是把页面目录项当成中间目录而已,与分配实际上毫无关系。即使对于采用了物理地址扩充(PAE)的Pentium CPU,虽然实现三级映射,其作用也只是找到中间目录项而已,只有在中间目录项为空时才真的分配一个。

这样,remap_area_pages中从73行开始的do-while循环,对涉及到的每个页面目录表项调用remap_area_pmd。而remap_area_pmd几乎完全一样,对涉及到的每个页面表(对i386的二级映射,每个中间目录项实际上就是一个页面表项,也可以理解为中间目录的大小为1)调用remap_area_pte,定义如下:

 ioremap=>__ioremap=>remap_area_pages=>remap_area_pmd=>remap_area_pte


static inline void remap_area_pte(pte_t * pte, unsigned long address, unsigned long size,
	unsigned long phys_addr, unsigned long flags)
{
	unsigned long end;

	address &= ~PMD_MASK;
	end = address + size;
	if (end > PMD_SIZE)
		end = PMD_SIZE;
	if (address >= end)
		BUG();
	do {
		if (!pte_none(*pte)) {
			printk("remap_area_pte: page already exists\n");
			BUG();
		}
		set_pte(pte, mk_pte_phys(phys_addr, __pgprot(_PAGE_PRESENT | _PAGE_RW | 
					_PAGE_DIRTY | _PAGE_ACCESSED | flags)));
		address += PAGE_SIZE;
		phys_addr += PAGE_SIZE;
		pte++;
	} while (address && (address < end));
}

这里只是简单地在循环中设置页面表中所有涉及的页面表项(31行)。每个表项都被预设成_PAGE_PRESENT、_PAGE_DIRTY、_PAGE_ACCESSED、_PAGE_RW。

在kswapd换出页面的情景中,我们已经看到kswapd定期地、循环地、依次地从task结构队列中找出占用内存页面最多的进程,然后就对该进程调用swap_out_mm换出一些页面。而内核的mm_struct结构init_mm是单独的,从任何一个进程的task结构中都到达不了init_mm。所以,kswapd根本就看不到init_mm的虚存空间,这些区间的页面就自然不会被换出而长驻于内存。

Guess you like

Origin blog.csdn.net/guoguangwu/article/details/120921710