在i386 CPU将一个线性地址映射成物理地址的过程中,如果该地址的映射已经建立,但是发现相应页面表项或目录项中的P(present)标志位为0,则表明相应的物理页面不在内存中,从而无法完成本次内存访问。从理论上说,也许应该把这种情况称为受阻而不是失败,因为映射的关系毕竟已经建立,理应与尚未建立映射的情况有所区别,所以我们称为断开。但是,CPU的MMU硬件并不区分这两种不同的情况,只要P标志位为0就都认为是页面映射失败,CPU就会产生一次页面异常(page fault)。事实上,CPU在映射过程中首先看的就是页面表项或目录项中的P标志位。只要P标志位为0,其余各个位段的值就无意义了。至于当一个页面不在内存中时,还是因为映射尚未建立,乃是软件,也就是页面异常处理程序的事情。在越界访问的情景中,我们曾看到在函数handle_pte_fault中的开头几行:
do_page_fault=>handle_mm_fault=>handle_pte_fault
static inline int handle_pte_fault(struct mm_struct *mm,
struct vm_area_struct * vma, unsigned long address,
int write_access, pte_t * pte)
{
pte_t entry;
/*
* We need the page table lock to synchronize with kswapd
* and the SMP-safe atomic PTE updates.
*/
spin_lock(&mm->page_table_lock);
entry = *pte;
if (!pte_present(entry)) {
/*
* If it truly wasn't present, we know that kswapd
* and the PTE updates will not touch it later. So
* drop the lock.
*/
spin_unlock(&mm->page_table_lock);
if (pte_none(entry))
return do_no_page(mm, vma, address, write_access, pte);
return do_swap_page(mm, vma, address, pte, pte_to_swp_entry(entry), write_access);
}
......
这里,首先区分的是pte_present,也就是检查表项中的P标志位,看看物理页面是否在内存中。如果不在,则进而通过pte_none检查表项是否为空,即全0。如果为空就说明映射尚未建立,所以要调用do_no_page。这在以前的情景中已经看到过了。反之,如果非空,就说明映射已经建立,只是物理页面不在内存中,所以要通过do_swap_page,从交换设备上换入这个页面。本情景在handle_pte_fault之前的处理以及执行路线都与越界访问的情景相同,所以我们直接进入do_swap_page。这个函数的代码如下:
do_page_fault=>handle_mm_fault=>handle_pte_fault=>do_swap_page
static int do_swap_page(struct mm_struct * mm,
struct vm_area_struct * vma, unsigned long address,
pte_t * page_table, swp_entry_t entry, int write_access)
{
struct page *page = lookup_swap_cache(entry);
pte_t pte;
if (!page) {
lock_kernel();
swapin_readahead(entry);
page = read_swap_cache(entry);
unlock_kernel();
if (!page)
return -1;
flush_page_to_ram(page);
flush_icache_page(vma, page);
}
mm->rss++;
pte = mk_pte(page, vma->vm_page_prot);
/*
* Freeze the "shared"ness of the page, ie page_count + swap_count.
* Must lock page before transferring our swap count to already
* obtained page count.
*/
lock_page(page);
swap_free(entry);
if (write_access && !is_page_shared(page))
pte = pte_mkwrite(pte_mkdirty(pte));
UnlockPage(page);
set_pte(page_table, pte);
/* No need to invalidate - it was non-present before */
update_mmu_cache(vma, address, pte);
return 1; /* Minor fault */
}
先看看调用时传过来的参数是些什么。建议读者先回到前面通过越界访问扩充堆栈的情景中,顺着CPU的执行路线走一遍,搞清楚这些参数的来龙去脉。参数表中的mm、vma还有address是一目了然的,分别是指向当前进程的mm_struct结构的指针、所属虚存区间的vm_area_struct结构的指针以及映射失败的线性地址。
参数page_table指向映射失败的页面表项,而entry则为该表项的内容。我们以前说过,当物理页面在内存中时,页面表项是一个pte_t结构,指向一个内存页面;而当物理页面不在内存中时,则是一个swp_entry_t结构,指向一个盘上页面。二者实际上都是32位无符号整数。这里要指出,所谓不在内存中是逻辑意义上的,是对CPU的页面映射硬件而言,实际上这个页面很可能在不活跃页面队列中,甚至在活跃页面队列中。
还有一个参数write_access,表示当映射失败时所进行的访问种类(读写),这是在do_page_fault的switch语句中根据CPU产生的出错代码error_code的bit1决定的(注意,在那个switch语句中,default与case 2:之间没有break语句)。此后便逐层传了下来。
由于物理页面不在内存中,所以entry是指向一个盘上页面的类型类似于指针的索引项(加上若干标志位)。该指针逻辑上分成两部分:第一部分是页面交换设备(或文件)的序号;第二部分是页面在这个设备上(或文件中,下同)的位移,其实也就是页面序号。两部分合在一起就唯一地确定了一个盘上页面。供页面交换的设备上第一个页面(序号为0)是保留不用的,所以entry的值不可能为全0。这样才能与映射尚未建立时的页面表项相区别。
处理一次因缺页而引起的页面异常时,首先要看看相应的内存页面是否还留在swapper_space的换入换出队列中尚未最后释放。如果是的话那就省事了。所以,要先调用lookup_swap_cache。这个函数是在swap_state.c中定义的,我们把它留给读者自己阅读。
如果没有找到,就是说以前用于这个虚存页面的内存页面已经释放,现在其内容仅在于盘上了,那就要通过read_swap_cache分配一个内存页面,并且从盘上将其内容读进来。为什么在此之前要先调用swapin_readahead呢?当从磁盘上读的时候,每次仅仅读一个页面是不经济的,因为每次读盘都要经过在磁盘上寻道使磁头定位,而寻道所需的时间实际上比磁头到位以后读一个页面所需的时间要长得多。所以,比较经济的办法是:既然必须经过寻道,就干脆一次多读几个页面进来,称为一个页面集群(cluster)。由于此时并非每个读入的页面都是立即需要的,所以是预读(read ahead)。预读进来的页面都是暂时链入活跃页面队列以及swapper_space的换入、换出队列中,如果实际上确实不需要就由进程kswapd和kreclaimd在一段时间以后加以回收。这样,当调用read_swap_cache时,通常所需的页面已经在活跃队列中而只需要把它找到就行了。但是,也有可能预读时因为分配不到足够的内存页面而失败,那样就真的要再来读一次,而这一次却真是只读入一个页面了。细心的读者可能会问,这两行程序时紧挨着的,为什么前一行语句中因分配不到足够的内存页面而失败,到紧接着的下一行就有可能成功呢?这是因为,在分配内存页面失败时,内核可能会调度其他进程先运行,而被调度运行的进程可能会释放出一些内存页面,甚至被调度运行的进程可能恰好就是kswapd。因此,第一次分配内存页面失败并不一定说明紧接着的第二次也会失败。要说明这一点,我们可以再来看下函数__alloc_pages中的一个片段:
wakeup_kswapd(0);
if (gfp_mask & __GFP_WAIT) {
__set_current_state(TASK_RUNNING);
current->policy |= SCHED_YIELD;
schedule();
}
无论是swapin_readahead还是read_swap_cache,在申请分配内存页面时都把调用参数gfp_mask中的__GFP_WAIT标志位置成1,所以当分配不到内存页面时都会自愿暂时礼让,让内核调度其他进程先运行。由于在此之前先唤醒了kswapd,当本进程被调度恢复运行时,也就是从schedule返回时,再次试图分配页面已有可能成功了。即使在swapin_readahead中又失败了,在read_swap_cache中再来一次,也还是有可能(而且多半能够)成功。当然,也有可能二者都失败了,那样do_swap_page也就失败了,所以在1031行返回-1。这里,我们就不深入到swapin_readahead中去了,读者可以自行阅读。而read_swap_cache实际上是read_swap_cache_async,只是把调用参数wait设成1,表示要等待读入完成(所以实际上是同步的读入)。
#define read_swap_cache(entry) read_swap_cache_async(entry, 1);
函数read_swap_cache_async的代码在mm/swap_state.c中:
do_page_fault=>handle_mm_fault=>handle_pte_fault=>do_swap_page=>read_swap_cache_async
/*
* Locate a page of swap in physical memory, reserving swap cache space
* and reading the disk if it is not already cached. If wait==0, we are
* only doing readahead, so don't worry if the page is already locked.
*
* A failure return means that either the page allocation failed or that
* the swap entry is no longer in use.
*/
struct page * read_swap_cache_async(swp_entry_t entry, int wait)
{
struct page *found_page = 0, *new_page;
unsigned long new_page_addr;
/*
* Make sure the swap entry is still in use.
*/
if (!swap_duplicate(entry)) /* Account for the swap cache */
goto out;
/*
* Look for the page in the swap cache.
*/
found_page = lookup_swap_cache(entry);
if (found_page)
goto out_free_swap;
new_page_addr = __get_free_page(GFP_USER);
if (!new_page_addr)
goto out_free_swap; /* Out of memory */
new_page = virt_to_page(new_page_addr);
/*
* Check the swap cache again, in case we stalled above.
*/
found_page = lookup_swap_cache(entry);
if (found_page)
goto out_free_page;
/*
* Add it to the swap cache and read its contents.
*/
lock_page(new_page);
add_to_swap_cache(new_page, entry);
rw_swap_page(READ, new_page, wait);
return new_page;
out_free_page:
page_cache_release(new_page);
out_free_swap:
swap_free(entry);
out:
return found_page;
}
读者也许注意到了,这里两次调用了lookup_swap_cache。第一次是很好理解的,因为swapin_readahead也许已经把目标页面读进来了,所以要先从swapper_space队列中寻找一次。这一方面是为了节省一次从设备读入;另一方面,更重要的是防止同一个页面在内存中有两个副本。可是为什么在找不到、因而为此分配了一个内存页面以后又来一次呢?这是因为分配内存页面的过程有可能受阻,如果一时分配不到页面,当前进程就会睡眠等待,让别的进程先运行。而当这个进程再次被调度运行,并成功地分配到物理页面从__get_free_page返回时,也许另一个进程已经先把这个页面读进来了,所以要再检查一次。如果确实需要从交换设备读入,则通过add_to_swap_cache将新分配的物理页面(确切的说是它的page数据结构)挂入swapper_space队列以及active_list队列中,这个函数的代码读者已经看到过了。至于rw_swap_page,读者可以在学习了块设备驱动系列博客以后回过来读。调用read_swap_cache成功以后,所要的页面肯定已经在swapper_space队列以及active_list队列中了,并且马上就要恢复映射。
这里要着重注意一下对盘上页面的共享计数。首先,一开始时在221行就通过swap_duplicate递增了盘上页面的共享计数。如果在缓冲队列中找到了所需的页面而无需从交换设备读入,则在252行通过swap_free抵消对共享计数的递增。反之,如果需要从交换设备读入页面,则不调用swap_free,所以盘上页面的共享计数加了1。这么一来,情况就变成了这样:如果从交换设备读入页面,则盘上页面的共享计数保持不变;而如果在缓冲队列中找到了所需的页面,则共享计数减1。对此,读者不妨回过去看一下try_to_swap_out中的99行。在那里,当断开一个页面的映射时,通过swap_duplicate递增了盘上页面的共享计数。而现在恢复映射则使共享计数减1,二者是互相对应的。
还要注意对内存页面,即其page结构的使用计数。首先,在分配一个内存页面时把这个计数设成1。然后,在通过add_to_swap_cache将其链入换入换出队列(或文件映射队列)和LRU队列active_list时,又在add_to_page_cache_locked中通过page_cache_get递增了这个计数,所以当有、并且只有一个进程映射到这个换入换出页面时,其使用计数为2。如果页面来自文件映射,则由于同时又与文件读写缓冲区相联系,又多了一个用户,所以使用计数为3。但是,还有一种特殊情况,那就是通过swapin_readahead预读进来的页面。
do_page_fault=>handle_mm_fault=>handle_pte_fault=>do_swap_page=>swapin_readahead
/*
* Primitive swap readahead code. We simply read an aligned block of
* (1 << page_cluster) entries in the swap area. This method is chosen
* because it doesn't cost us any seek time. We also make sure to queue
* the 'original' request together with the readahead ones...
*/
void swapin_readahead(swp_entry_t entry)
{
int i, num;
struct page *new_page;
unsigned long offset;
/*
* Get the number of handles we should do readahead io to. Also,
* grab temporary references on them, releasing them as io completes.
*/
num = valid_swaphandles(entry, &offset);
for (i = 0; i < num; offset++, i++) {
/* Don't block on I/O for read-ahead */
if (atomic_read(&nr_async_pages) >= pager_daemon.swap_cluster
* (1 << page_cluster)) {
while (i++ < num)
swap_free(SWP_ENTRY(SWP_TYPE(entry), offset++));
break;
}
/* Ok, do the async read-ahead now */
new_page = read_swap_cache_async(SWP_ENTRY(SWP_TYPE(entry), offset), 0);
if (new_page != NULL)
page_cache_release(new_page);
swap_free(SWP_ENTRY(SWP_TYPE(entry), offset));
}
return;
}
在swapin_readahead中,循环地调用read_swap_cache_async分配和读入若干页面,因而在从read_swap_cache_async返回时,每个页面的使用计数都是2。但是,在循环中马上又通过page_cache_release递减这个计数,因为预读进来的页面并没有进程在使用。于是,这些页面就成了特殊的页面,它们在active_list中,而使用计数却是1。以后,这些页面或者是被某个进程认领,从而使用计数变成2;或者是在一段时间以后仍无进程认领,最后被refill_inactive_scan移入不活跃队列(见mm/vmscan.c的744行),那才是使用计数为1的页面应该呆的地方。
回到do_swap_page的代码中,这里的flush_page_to_ram和flush_icache_page对于i386 CPU均为空操作。代码中通过pte_mkdirty将页面表项中的D标志位置成1,表示该页面已经脏了,并且通过pte_mkwrite将页面表项中的_PAGE_RW标志位也置成1。读者也许会问:怎么可以凭着当前的访问时一次写访问就把页面表项就设置成允许写?万一本来就应该有写保护的呢?答案是,如果那样的话就根本到达不了这个地方。读者不妨回过头去看看do_page_fault中switch语句中case2。在那里,如果页面所属的区间不允许写的话(VM_WRITE标志位为0),就转到bad_area去了。还要注意,区间的可写标志VM_WRITE与页面的可写标志_PAGE_RW是不同的。VM_WRITE是个相对静态的标志位;而_PAGE_RW则更为动态,只表示当前这一个物理页面是否允许写访问,只有在VM_WRITE为1的前提下,_PAGE_RW才有可能为1,但不一定是为1。所以,在1039行中,根据vma->vm_page_prot构筑一个页面表项时,表项的_PAGE_RW标志位为0(注意VM_WRITE是vm_flags而不是vm_page_prot中的一位)。读者还可能会问,那样一来,要是当前的访问恰好是读访问,这个页面不就永远不允许写了吗?不要紧,发生写访问因访问权限不符合而引起另一次页面异常。那时,就会在handle_pte_fault中调用do_wp_page,将页面的访问权限作出改变(如果需要cow,即copy on write的话,也是在那里处理的)。我们将do_wp_page留给读者,一来因为篇幅原因,二来读者现在已经对存储管理比较熟悉,应该不会有太大的困难了。
至于紧接着的update_mmu_cache,对于i386 CPU只是个空操作,因为i386 的MMU是与CPU汇成一体的。