有三种事件会让CPU停止指令的正常执行,并强制将控制权转移给处理事件的特殊代码。下面是这三种事件:
- 系统调用,这时用户程序执行
ecall
指令,来让内核为它做一些事情 - 异常,一个指令(用户指令或内核指令)做了一些非法的事情,比如说除以0或者使用无效的虚拟地址
- 设备中断,当设备发出信号表明该设备需要关注时,比如当磁盘硬件完成了读或写请求时
之后用trap
作为这些情况的通用术语。通常,不管trap
发生时在执行什么代码,稍后都需要恢复,并且不需要知道发生了什么特别的事。就是说,我们通常需要trap
是透明的:这对于设备中断尤其重要,中断的代码通常不希望中断发生。
通常的执行顺序是,trap
强制将控制转移到内核,内核保存寄存器和其他状态,以便之后可以继续执行;内核执行合适的处理代码(比如,系统调用或设备驱动程序);内核恢复保存的状态,从trap
中返回;原始代码从它停止的地方继续执行。
xv6内核处理所有的trap
;trap
不会传递给用户代码。在内核中处理trap
对于系统调用来说是很自然的。这对于中断也有意义,因为隔离要求用户进程不直接使用设备,只允许内核使用设备,而且内核拥有设备处理所需要的状态,并且内核是一种在多个进程之间共享设备的便利机制。 对于异常同样也是有意义的,因为xv6对所有来自用户空间的异常都是同样的回应:kill
掉产生异常的程序。
xv6的trap
处理分四个阶段进行:
- RISC-V CPU采取的硬件动作
- 为内核C代码做准备的一些汇编指令
- 通过C语言函数
trap handler
决定怎么处理这个trap
- 系统调用或设备驱动程序服务例程
虽然这三种trap
类型的共同点说明内核可以用一条代码路径处理所有的trap
,但是对于三种不同的情况使用不同的代码会更方便:来自用户空间的traps
、来自内核空间的traps
和定时器中断
处理trap
的内核代码(汇编或C)通常被称为处理程序(handler
);第一个handler
指令通常用汇编(而不是C)编写,通常被称为vector
。
1. RISC-V陷入机制
每个RISC-V CPU都有一组控制寄存器,内核通过写入这些寄存器来告诉CPU如何处理trap
,内核也可以读取这些寄存器来知道trap
的发生。RISC-V文档包含了详细的解释。 riscv.h(kernel/riscv.h)
包含xv6使用的定义。下面是大部分主要寄存器的说明:
stvec
:内核将trap
的handler
的地址写在这,RISC-V跳转到stvec
中的地址来处理trap
sepc
:当trap
发生时,RISC-V将程序计数器保存在这里(因为程序计数器pc
随后会被stvec
中的值覆盖)。sret
(从trap
返回)指令将sepc
复制到pc
。内核可以通过写入sepc
来控制sret
返回到哪里scause
:RISC-V将引发trap
的原因编号保存在这里sscratch
:内核在这里放置了一个值,这个值在trap handler
开始时很有用sstatus
:sstatus
中的SIE位(SIE bit)控制了是否启用设备中断。如果内核清除了SIE(不设置SIE),RISC-V将推迟设备中断,直到内核设置SIE。SPP位表示trap
来自用户模式还是管理员模式,并控制sret
返回到哪种模式。
上面的寄存器与管理员模式下处理的trap
有关,它们不能在用户模式下读写。对于在机器模式下处理的trap
,有一组类似的控制寄存器;xv6只在特殊的定时器中断下使用它们。
多核芯片上的每个CPU都有一组自己的寄存器,并且在任何给定时间可能不止一个CPU在处理trap
。
当需要强制trap
时,RISC-V硬件对所有trap
类型(除了定时器中断)执行以下步骤:
- 如果
trap
是设备中断,并且sstatus
中的SIE位未设置,则不再执行以下任何操作 - 通过将
sstatus
中的SIE位清除,来禁止中断 - 将
pc
(程序计数器)拷贝到sepc
中 - 将当前模式(用户或管理员模式)保存在
sstatus
的SPP位 - 设置
scause
,来反映trap
触发的原因 - 将模式设置为管理员模式
- 将
stvec
拷贝到pc
- 在新的
pc
处开始执行(开始执行trap handler
)
注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc
外的任何寄存器。内核软件必须完成这些工作。CPU在trap
过程中只做最少工作的一个原因是:为软件提供灵活性。比如,一些操作系统在某些情况下通过省略页表切换,来提高trap
的性能。
值得思考的是,上面列出的任何步骤是否可以省略。虽然在某些情况下,一个更简单的执行序列是可以工作的,但是在一般情况下,一些步骤被忽略是危险的。比如,假设CPU没有切换程序计数器pc
,那么来自用户空间的trap
可以切换到管理员模式,却仍然运行用户指令。这些用户指令将破坏用户、内核之间的隔离,举例来说,通过修改satp
寄存器来指向一个允许访问所有物理内存的页表。因此,CPU切换到内核指定的指令地址(即stvec
)非常重要。
2. 来自用户空间的Traps
xv6处理trap
的方式不同,这取决于它是在内核中执行还是在用户代码中执行。
如果用户程序进行系统调用(ecall
指令),或做了非法的事情,或者设备中断,那么在用户空间执行时可能会发生trap
。
来自用户空间的trap
的高层路径是:uservec(kernel/trampoline.S)
,然后是usertrap(kernel/trap.c)
;返回时,usertrapret(kernel/trap.c)
,然后userret(kernel/trampoline.S)
来自用户代码的trap
比来自内核的更复杂
xv6的trap
处理设计的一个主要限制是:RISC-V硬件在强制trap
时不会切换页表。这意味着stvec
中的trap handler
程序地址必须在用户页表中有一个有效的映射。再者,xv6的trap handler
代码需要切换到内核页表,为了能够在切换后继续执行,内核页表还必须有stvec
指向的处理程序的映射。
即用户页表必须包含一个指向uservec
的映射,uservec
是stvec
指向的trap
向量表的所有指令,uservec
切换satp
使其指向内核页表;为了在切换后继续执行指令,uservec
在内核页表与用户页表中必须位于同一个地址。
xv6使用一个trampoline
页面来满足这些需求。trampoline
页面包含uservec
,uservec
是stvec
指向的xv6trap
处理代码。xv6在内核页表与每个用户页表中都将trampoline
页面映射在一个相同的虚拟地址上。这个虚拟地址就是TRAMPOLINE
,它位于虚拟地址空间的末端,因此它将位于程序自己使用的内存之上。trampoline
页面内容在trampoline.S
中设置,而且,当执行用户代码时,stvec
被设置为uservec(kernel/trampoline.S)
因为trampoline
页面被映射在用户页表中,使用PTE_U
标志,使得trap
可以在管理员模式下执行。因为trampoline
页面被映射到内核地址空间中的相同地址,所以trap handler
在切换到内核页表后可以继续执行。
uservec
的代码(trap handler
)在trampoline.S
中。当uservec
开始执行时,所有32个寄存器中包含的都是被中断用户代码所拥有的值。但是uservec
需要修改一些寄存器来设置satp
,并生成存放寄存器的地址。即这32个值需要保存在内存中的某个地方,以便在trap
返回到用户空间时可以恢复它们。存储到内存需要使用一个寄存器来保存地址,但目前还没有通用寄存器可用!
注意,执行uservec
之前,sscratch
寄存器中存放的是指向进程trapframe
的指针。
幸运的是,RISC-V以sscratch
寄存器的形式提供了帮助。uservec
开头的csrrw
指令交换a0
和sscratch
的内容。现在用户代码的a0
保存在sscratch
中;从而uservec
有一个寄存器a0
可以使用;a0
包含内核先前放在sscratch
中的值。
uservec
接下来的任务是保存32个用户寄存器。在进入用户空间之前,内核设置sscratch
,指向每一个进程的trapframe
结构,该结构有空间保存所有的用户寄存器(kernel/proc.h
)。因为satp
仍然指向用户页表,所以uservec
需要trapframe
映射到用户地址空间。当创建每个进程时,xv6为进程的trapframe
分配一个页面,然后把它跟用户虚拟地址TRAPFRAME
建立映射关系,它就在TRAMPOLINE
的下面。进程的p->trapframe
也指向trapframe
,它指向trapframe
的物理地址,这样内核就可以通过内核页表使用它。
因此,在交换a0
和sscratch
之后,a0
持有一个指向当前进程的trapframe
的指针。uservec
现在将所有用户寄存器保存在哪里,包括从sscratch
中读取的用户的a0
。
trapframe
包含当前进程的内核堆栈的地址,当前CPU的 hartid,usertrap
函数的地址,以及内核页表的地址。uservec
检索这些信息,将satp
切换到内核页表,并调用usertrap
。
usertrap
的工作是确定trap
的触发原因,处理它,然后返回(kernel/trap.c
)。它首先改变stvec
,这样在内核中的trap
将由kernelvec
而不是uservec
处理。它再次保存sepc
寄存器(保存的用户程序计数器),因为usertrap
中可能有进程切换,而那个进程可能会返回到用户空间,这会导致sepc
被覆盖。如果trap
是一个系统调用,usertrap
调用syscall
处理它;如果是设备中断,devintr
;如果是异常,内核会终止出错的进程。系统调用路径将保存的用户程序计数器加4,因为RISC-V在系统调用时,将程序指针指向ecall
指令,但用户代码需要在后续指令处继续执行。在退出时,usertrap
会检查进程是否已经被终止或者是否应该让出CPU(如果这个trap
是一个定时器中断)
返回用户空间的第一步是调用usertrapret(kernel/trap.c)
。这个函数设置RISC-V控制寄存器,准备好迎接下一个来自用户空间的trap
。这包括修改stvec
让它指向uservec
,准备uservec
依赖的trapframe
,以及将sepc
设置为先前保存的用户程序计数器。最后,usertrapret
在映射到用户和内核页表的trampoline
页面上调用userret
,这么做的原因是userret
中的汇编代码会切换页表。
usertrapret
对userret
的调用,在a0
中传递了TRAPFRAME
,在a1
中传递了指向进程用户页表的指针(kernel/trampoline.S
)。userret
将satp
切换到进程的用户页表。用户页表映射了trampoline
页面和TRAPFRAME
,但没有映射内核中的其他内容。事实上,trampoline
页面在用户页表和内核页表中被映射到相同的虚拟地址,这使得uservec
能够在修改satp
后继续执行。 userret
将trapframe
保存的用户a0
复制到sscratch
,为以后与TRAPFRAME
的交换做准备。从此开始,userret
可以使用的数据只有寄存器内容和trapframe
中的内容。接下来,userret
从trapframe
中恢复保存的所有用户寄存器,最后一次交换a0
和sscratch
来恢复用户a0
,并为下一个trap
保存TRAPFRAME
(保存在sscratch
中),然后执行sret
以返回用户空间。
3. 代码:调用系统调用
Chap2以initcode.S
调用exec
系统调用结束(user/initcode.S
)。让我们看看,用户调用是怎样在内核中实现exec
系统调用的。
initcode.S
将exec
的参数放在寄存器a0
和a1
,并将系统调用号放在a7
中。系统调用号与syscalls
数组中的条目相匹配,syscalls
是一个函数指针表(kernel/syscall.c
)。ecall
指令陷入内核,然后执行uservec
、usertrap
和syscall
。
syscall(kernel/syscall.c)
从trapframe
所保存的a7
中检索系统调用号,并使用它来索引syscalls
。对第一个系统调用来说,a7
包含了SYS_exec(kernel/syscall.h)
,从而转到对系统调用实现函数sys_exec
的调用。
当sys_exec
返回时,syscall
将其返回值记录在p->trapframe->a0
中。这将使得最初用户空间调用的exec()
函数返回这个值,因为RISC-V上的C调用约定将返回值放在a0
中。系统调用通常返回负数表示错误,返回0或正数表示成功。如果系统调用号是无效的,syscall
会打印一个错误并返回-1。
4. 代码:系统调用参数
内核中的系统调用实现需要找到用户代码传来的参数。因为用户代码调用的是系统调用包装函数(system call wrapper functions),参数最初放置在RISC-V调用约定中所规定的地方:寄存器中。内核trap
代码将用户寄存器保存到当前进程的trapframe
中,这样内核代码就能找到它们。内核函数argint
,argaddr
和argfd
从trapframe
中检索第n个系统调用参数,作为整数、指针或文件描述符。它们都调用argraw
来检索所保存的适当的用户寄存器(kernel/syscall.c
)
有些系统调用将指针作为参数传递,然后内核必须使用这些指针来读写用户内存。比如,exec
系统调用向内核传递一个指针数组,该数组指向用户空间中的字符串参数。这些指针带来了两个挑战:
- 用户程序可能有bug或是恶意的,可能向内核传递无效指针或欺骗内核访问内核空间而不是用户空间
- xv6内核页表映射与用户页表映射不同,所以内核不能使用普通指令从用户提供的地址加载或存储内容。
内核实现了能够安全地从/向用户提供的地址拷贝数据的函数。fetchstr(kernel/syscall.c)
是一个例子。exec
之类的文件系统调用使用fetchstr
从用户空间检索字符串类型的文件名参数(string file-name)。fetchstr
调用copyinstr
来完成这项工作。
copyinstr(kernel/vm.c)
从虚拟地址srcva向dst拷贝最多max字节的数据,srcva是来自用户页表的虚拟地址。由于参数pagetable
不是当前的页表,所以copyinstr
使用walkaddr
(调用walk
)在pagetable
中查找srcva,并得到srcva对应的物理地址pa0
。 内核将所有物理RAM地址映射在相应的内核虚拟地址上,因此copyinstr
能够直接从pa0
向dst拷贝字符串数据。walkaddr(kernel/vm.c)
检查用户提供的虚拟地址,保证它是这个进程的用户地址空间的一部分, 从而避免程序欺骗内核读取别处的内存。 copyout
与之相似,从内核向用户提供的地址拷贝数据。
5. 来自内核空间的Traps
xv6根据trap
发生时,执行的是用户代码还是内核代码,对CPU trap
寄存器的配置略有不同。当内核在CPU上执行时,内核将stvec
指向kernelvec(kernel/kernel.S)
处的汇编代码。由于xv6已经在内核中了,kernelvec
可以使用已经指向内核页表的satp
,也能使用已经指向有效内核堆栈的栈指针。kernelvec
将所有32个寄存器保存到堆栈上,稍后将从堆栈中恢复它们,以便被中断的内核代码可以不受干扰地恢复执行。
kernelvec
将寄存器保存在被中断的内核线程的堆栈上,这是因为寄存器的值属于那个线程。这一点是很重要的,如果trap
造成线程的切换,在这种情况下,trap
实际上将从新线程的堆栈中返回,并将被中断线程所保存的寄存器安全地留在被中断线程的堆栈中。
在保存好寄存器后,kernelvec
跳转到kerneltrap(kernel/trap.c)
。kerneltrap
用于处理两种类型的trap
:设备中断和异常。它调用devintr(kernel/trap.c)
来检查和处理设备中断。如果trap
不是设备中断,那么它一定是一个异常,在xv6内核中出现异常时通常是一个严重的错误,内核会调用panic
函数并停止执行。
如果是由于定时器中断而调用kerneltrap
,并且进程的内核线程正在运行(不是调度线程),kerneltrap
会调用yield
,把运行机会让给其他线程。在某个时刻,那些线程中的一个将会退出,让之前的线程和它的kerneltrap
重新开始。Chap7解释了yield
中发生了什么。
当kerneltrap
的工作完成后,它需要返回到被trap
中断的代码。因为yield
函数可能会扰乱保存的sepc
和sstatus
中保存的之前的模式,所以kerneltrap
会在启动时保存它们。它现在恢复这些控制寄存器,然后返回到kernelvec(kernel/kernelvec.S)
。kernelvec
将保存的寄存器从堆栈中弹出,并执行sret
。sret
会将sepc
拷贝到pc
,并恢复被中断的内核代码。
值得思考的是,如果因为定时器中断,kerneltrap
调用yield
,trap
将如何返回?
当CPU从用户空间进入内核时,xv6将CPU的stvec
设置为kernelvec
,你可以在usertrap(kernel/trap.c)
中看到这一点。当内核已经开始执行,但stvec
仍然设置为uservec
时,有一个时间窗口,在该窗口期间,没有设备中断发生是至关重要的。幸运的是,RISC-V总是在开始处理trap
时禁用中断,xv6会在设置stvec
后才再次启用中断。
6. 页错误异常(Page-fault execptions)
xv6对异常的响应非常无聊:如果异常发生在用户空间,内核会杀死出错的进程。如果内核发生了异常,内核会panic
。真正的操作系统通常会有更多有趣的响应方式。
比如,许多内核使用页错误来实现写时复制(COW)fork。为了解释写时复制fork,考虑Chap3中描述的xv6的fork
。fork
导致子进程的初始内容与父进程在fork
时的内容相同。xv6用uvmcopy(kernel/vm.c)
实现fork
,其为子进程分配物理内存,并将父进程的内存复制到其中。如果子进程能与父进程共享父进程的物理内存,这个过程将更高效。然而,直接这样实现是不行的,因为当它们向共享的栈或者堆写入时,双方的执行都会被打乱。
通过恰当使用页表权限和页错误,父进程和子进程可以安全地共享物理内存。当使用的虚拟地址在页表中没有映射,或者映射的PTE_V
标志被清除,或者映射的权限位(PTE_R
、PTE_W
、PTE_X
、PTE_U
)禁止正在尝试的操作时,CPU会引发页错误异常(即当CPU无法将虚拟地址翻译成物理地址时,会产生一个页错误异常)。
RISC-V有三种类型的页错误:加载页面错误(当load指令不能翻译虚拟地址时),存储页面错误(当store指令不能翻译虚拟地址时),指令页面错误(当程序计数器中的地址不能翻译时)。scause
寄存器中的值表示页错误的类型,stval
寄存器包含了无法翻译的地址。
COW fork 的基本计划是,父子进程最初共享所有的物理页面,但是每个页面都将它们映射为只读(清除PTE_W
标志)。父子进程可以从共享的物理内存中读取。如果任何一个进程写了一个给定的页面(执行store指令),RISC-V CPU就会产生一个页面错误异常。内核的trap handler
通过分配一个新的物理内存页面,并将出错地址所映射到的物理页面进行拷贝,将其拷贝到新的物理页面中,从而做出响应。内核更改出错进程的页表中的相关PTE,以指向新的物理页面,并允许读写,然后在导致错误的指令处继续执行出错的进程。因为PTE允许写入,所以重新执行的指令现在将无错误地执行。 写时复制需要 book-keeping 来帮助决定何时可以释放物理页面,因为每个页面可以被不同数量的页表引用,这取决于 forks,页错误,execs 和 exits。book-keeping 允许一个重要的优化:如果一个进程发生存储页面错误,并且物理页面只从该进程的页表中引用,则不需要复制。
写时复制使fork更快,因为fork不需要复制内存了。当写入时,一些内存将不得不在以后被复制,但是通常情况下,大部分的内存从来没有被复制过。 一个常见的例子是 fork 后马上调用 exec,将它的地址空间替换为新的地址空间:fork之后可能会写几页,这时候子进程只需经历几个页错误,对几个页面进行拷贝即可,之后子进程的exec会释放从父进程继承的大部分内存,这样内核就能避免完整地拷贝。所以,COW fork消除了复制所有内存的需要。 此外,COW fork 是透明的:不需要对应用程序进行任何修改就可以受益。
除了COW fork 之外,页表和页错误的组合还提供了许多有趣的可能性。 另一个广泛应用的特性叫做惰性分配(懒分配),它有两个部分。首先,当应用程序调用sbrk
请求更多内存时,内核会注意到进程内存大小的增加,但不会分配物理内存,也不会为新的虚拟地址范围创建PTE(因为PTE包含了物理地址,既然没有分配物理内存,那也就没有物理地址)。第二,当这些新地址中的一个出现页面错误时,内核会分配一个物理内存页面,并将其映射到页表中。 因为应用程序经常申请比实际需求更多的内存,所以懒分配非常有效:内核只在应用真的用到内存时才给它分配,内核不需要为应用程序从不使用的页面做任何工作。 像 COW fork 一样,内核可以对应用透明地实现懒分配。
此外,如果应用程序要求大量增加地址空间,那么没有懒分配的sbrk
是十分昂贵的:如果应用程序要求1GB的内存,内核必须分配 262144个4096字节的页面,并将这些页面的内容清零。 懒分配允许这种成本随着时间的推移而分散。 另一方面, 懒分配会导致页面错误的额外开销,这涉及到 内核态/用户态的转换。操作系统可以通过为每个页面错误分配一批连续的页面而不是一个页面,并通过为这种页面错误指定内核进入/退出的代码来降低这一成本。
利用页面错误的另一个广泛使用的功能是按需分页。在exec
中, xv6急切地将应用程序的所有文本和数据加载到内存中。由于应用程序可能很大,并且从磁盘读取数据的成本很高,因此用户可能会注意到这种启动成本:当用户从shell
启动大型应用程序时,可能需要很长时间才能看到响应。为了改善响应时间,现代内核为用户地址空间创建了页表,但是将页的PTE标记为无效。出现页面错误时,内核从磁盘读取页面内容,并将其映射到用户地址空间。与COW fork 和 懒分配一样,内核可以对应用程序透明地实现这个特性。
计算机上运行的程序可能需要比计算机RAM更多的内存。为了从容应对,操作系统可以实现磁盘分页。其思想是只在RAM中存储一小部分用户页面,而将其余的存储在磁盘的分页区域中。内核将对应于存储在分页区域(不在RAM中)的内存的PTE标记为无效。如果一个应用程序试图使用一个已经被换出到磁盘的页面,该应用程序将导致页面错误,并且该页面必须被换入。内核能够检查出错的地址,如果地址属于硬盘上的一个页,内核的trap handler
将分配一个物理内存页,并将该页面从磁盘读入内存,并修改相关的PTE以指向新分配的内存,然后恢复应用。
如果一个页面需要换页,但是没有空闲的物理内存会怎么样?在这种情况下,内核必须首先释放一个物理页面,将它调出到磁盘上的分页区域,并将引用该物理页面的PTE标记为无效。由于调出页面需要很大的开销,所以分页在不频繁的情况下表现最好:如果应用程序只使用它们的内存页面的一个子集和RAM中的子集的组合。这种属性通常被称为具有良好的引用局部性。与许多虚拟内存技术一样,内核通常以对应用程序透明的方式实现磁盘分页。
无论硬件提供多少内存,计算机通常在很少或没有空闲物理内存的情况下运行。例如,云提供商在一台机器上复用许多客户,以便经济高效地使用他们地硬件。作为另一个例子,用户在少量物理内存中运行智能手机上的许多应用程序。在这种设置中,分配页面可能需要先调出现有页面。因此,当空闲物理内存不足时,分配页面是昂贵的。
当空闲内存不足时,懒分配和按需分页特别有优势。在sbrk
或exec
中急切地分配内存会导致额外的内存调出成本。此外,还存在浪费之前的分配工作的风险,因为在应用程序使用该页面之前,操作系统可能已经将其调出。
结合分页和页错误的其他特性包括自动扩展堆栈和内存映射文件。
7. Real world
trampoline
和trapframe
可能看起来过于复杂。一个驱动力是RISC-V在强制trap
时故意做得尽可能少,以允许非常快速的trap
处理的可能性,这被证明是重要的。 因此,内核trap handler
程序的前几条指令实际上必须在用户环境中执行:用户页表和用户寄存器内容。 trap handler
程序最初不知道一些有用的事实,比如正在运行的进程的身份或者内核页表的地址。 有一种可能的解决方案,因为RISC-V提供了受保护的位置,内核可以在进入用户空间之前将信息隐藏在这些位置:sscratch
寄存器和指向内核内存但受PTE_U
保护的用户页表条目。xv6的trampoline
和trapframe
利用了这些RISC-V特性。
如果内核内存被映射到每个进程的用户页表中(用合适的权限标记),特殊的trampoline
页面就不需要了。从用户空间陷入到内核时的页表切换也不再需要。这反过来将允许内核中的系统调用实现能够使用当前进程映射的用户空间内存,从而使内核代码能够直接解引用用户指针。 很多操作系统已经使用这些想法来提高效率。 xv6没有采用这种思想,这是为了减少内核中由于无意中使用用户指针而导致的安全错误,并降低确保用户和内核虚拟地址不重叠所需的复杂性。
生产操作系统实现了写时复制fork、懒分配、按需分页、磁盘分页、内存映射文件等。此外,生产操作系统将尝试使用所有的物理内存,无论是应用程序还是缓存(比如,文件系统的缓冲区缓存,这将在Chap8中介绍)。xv6在这方面不是这样的,xv6并不希望使用所有的物理内存。此外,如果xv6耗尽了内存,它会向正在运行的应用程序返回一个错误,或者 kill 它,而不是调出另一个应用程序的页面。