转载地址:http://www.poluoluo.com/server/201107/138420.html
在研究dahdi驱动的时候,见到了一些get_user,put_user的函数,不知道其来由,故而搜索了这篇文章,前面对Linux内存的框架描述不是很清晰,描述的有一点乱,如果没有刚性需求,建议不用怎么关注,倒不如直接看那几个图片。对我非常有用的地方就是几个函数的介绍,介绍的比较详细,对应用有需求的可以着重看一个这几个函数。
Linux 内存
在 Linux 中,用户内存和内核内存是独立的,在各自的地址空间实现。地址空间是虚拟的,就是说地址是从物理内存中抽象出来的(通过一个简短描述的过程)。由于地址空间是虚拟的,所以可以存在很多。事实上,内核本身驻留在一个地址空间中,每个进程驻留在自己的地址空间。这些地址空间由虚拟内存地址组成,允许一些带有独立地址空间的进程指向一个相对较小的物理地址空间(在机器的物理内存中)。不仅仅是方便,而且更安全。因为每个地址空间是独立且隔离的,因此很安全。
但是与安全性相关联的成本很高。因为每个进程(和内核)会有相同地址指向不同的物理内存区域,不可能立即共享内存。幸运的是,有一些解决方案。用户进程可以通过 Portable Operating System Interface for UNIX? (POSIX) 共享的内存机制(shmem)共享内存,但有一点要说明,每个进程可能有一个指向相同物理内存区域的不同虚拟地址。
虚拟内存到物理内存的映射通过页表完成,这是在底层软件中实现的(见图 1)。硬件本身提供映射,但是内核管理表及其配置。注意这里的显示,进程可能有一个大的地址空间,但是很少见,就是说小的地址空间的区域(页面)通过页表指向物理内存。这允许进程仅为随时需要的网页指定大的地址空间。
图 1. 页表提供从虚拟地址到物理地址的映射
由于缺乏为进程定义内存的能力,底层物理内存被过度使用。通过一个称为 paging(然而,在 Linux 中通常称为 swap)的进程,很少使用的页面将自动移到一个速度较慢的存储设备(比如磁盘),来容纳需要被访问的其它页面(见图 2 )。这一行为允许,在将很少使用的页面迁移到磁盘来提高物理内存使用的同时,计算机中的物理内存为应用程序更容易需要的页面提供服务。注意,一些页面可以指向文件,在这种情况下,如果页面是脏(dirty)的,数据将被冲洗,如果页面是干净的(clean),直接丢掉。
图 2. 通过将很少使用的页面迁移到速度慢且便宜的存储器,交换使物理内存空间得到了更好的利用
MMU-less 架构
不是所有的处理器都有 MMU。因此,uClinux 发行版(微控制器 Linux)支持操作的一个地址空间。该架构缺乏 MMU 提供的保护,但是允许 Linux 运行另一类处理器。
选择一个页面来交换存储的过程被称为一个页面置换算法,可以通过使用许多算法(至少是最近使用的)来实现。该进程在请求存储位置时发生,存储位置的页面不在存储器中(在存储器管理单元 [MMU] 中无映射)。这个事件被称为一个页面错误 并被硬件(MMU)删除,出现页面错误中断后该事件由防火墙管理。该栈的详细说明见 图 3。
Linux 提供一个有趣的交换实现,该实现提供许多有用的特性。Linux 交换系统允许创建和使用多个交换分区和优先权,这支持存储设备上的交换层次结构,这些存储设备提供不同的性能参数(例如,固态磁盘 [SSD] 上的一级交换和速度较慢的存储设备上的较大的二级交换)。为 SSD 交换附加一个更高的优先级使其可以使用直至耗尽;直到那时,页面才能被写入优先级较低的交换分区。
图 3. 地址空间和虚拟 - 物理地址映射的元素
并不是所有的页面都适合交换。考虑到响应中断的内核代码或者管理页表和交换逻辑的代码,显然,这些页面决不能被换出,因此它们是固定的,或者是永久地驻留在内存中。尽管内核页面不需要进行交换,然而用户页面需要,但是它们可以被固定,通过 mlock(或 mlockall)函数来锁定页面。这就是用户空间内存访问函数的目的。如果内核假设一个用户传递的地址是有效的且是可访问的,最终可能会出现内核严重错误(kernel panic)(例如,因为用户页面被换出,而导致内核中的页面错误)。该应用程序编程接口(API)确保这些边界情况被妥善处理。
内核 API
现在,让我们来研究一下用户操作用户内存的内核 API。请注意,这涉及内核和用户空间接口,而下一部分将研究其他的一些内存 API。用户空间内存访问函数在表 1 中列出。
表 1. 用户空间内存访问 API
函数 | 描述 |
access_ok | 检查用户空间内存指针的有效性 |
get_user | 从用户空间获取一个简单变量 |
put_user | 输入一个简单变量到用户空间 |
clear_user | 清除用户空间中的一个块,或者将其归零。 |
copy_to_user | 将一个数据块从内核复制到用户空间 |
copy_from_user | 将一个数据块从用户空间复制到内核 |
strnlen_user | 获取内存空间中字符串缓冲区的大小 |
strncpy_from_user | 从用户空间复制一个字符串到内核 |
正如您所期望的,这些函数的实现架构是独立的。例如在 x86 架构中,您可以使用 ./linux/arch/x86/lib/usercopy_32.c 和 usercopy_64.c 中的源代码找到这些函数以及在 ./linux/arch/x86/include/asm/uaccess.h 中定义的字符串。
当数据移动函数的规则涉及到复制调用的类型时(简单 VS. 聚集),这些函数的作用如图 4 所示。
图 4. 使用 User Space Memory Access API 进行数据移动
access_ok 函数
您可以使用 access_ok 函数在您想要访问的用户空间检查指针的有效性。调用函数提供指向数据块的开始的指针、块大小和访问类型(无论这个区域是用来读还是写的)。函数原型定义如下:
access_ok( type, addr, size );
type 参数可以被指定为 VERIFY_READ 或 VERIFY_WRITE。VERIFY_WRITE 也可以识别内存区域是否可读以及可写(尽管访问仍然会生成 -EFAULT)。该函数简单检查地址可能是在用户空间,而不是内核。
get_user 函数
要从用户空间读取一个简单变量,可以使用 get_user 函数,该函数适用于简单数据类型,比如,char 和 int,但是像结构体这类较大的数据类型,必须使用 copy_from_user 函数。该原型接受一个变量(存储数据)和一个用户空间地址来进行 Read 操作:
get_user( x, ptr );
get_user 函数将映射到两个内部函数其中的一个。在系统内部,这个函数决定被访问变量的大小(根据提供的变量存储结果)并通过 __get_user_x 形成一个内部调用。成功时该函数返回 0,一般情况下,get_user 和 put_user 函数比它们的块复制副本要快一些,如果是小类型被移动的话,应该用它们。
put_user 函数
您可以使用 put_user 函数来将一个简单变量从内核写入用户空间。和 get_user 一样,它接受一个变量(包含要写的值)和一个用户空间地址作为写目标:
put_user( x, ptr );
和 get_user 一样,put_user 函数被内部映射到 put_user_x 函数,成功时,返回 0,出现错误时,返回 -EFAULT。
clear_user 函数
clear_user 函数被用于将用户空间的内存块清零。该函数采用一个指针(用户空间中)和一个型号进行清零,这是以字节定义的:
clear_user( ptr, n );
在内部,clear_user 函数首先检查用户空间指针是否可写(通过 access_ok),然后调用内部函数(通过内联组装方式编码)来执行 Clear 操作。使用带有 repeat 前缀的字符串指令将该函数优化成一个非常紧密的循环。它将返回不可清除的字节数,如果操作成功,则返回 0。
copy_to_user 函数
copy_to_user 函数将数据块从内核复制到用户空间。该函数接受一个指向用户空间缓冲区的指针、一个指向内存缓冲区的指针、以及一个以字节定义的长度。该函数在成功时,返回 0,否则返回一个非零数,指出不能发送的字节数。
copy_to_user( to, from, n );
检查了向用户缓冲区写入的功能之后(通过 access_ok),内部函数 __copy_to_user 被调用,它反过来调用 __copy_from_user_inatomic(在 ./linux/arch/x86/include/asm/uaccess_XX.h 中。其中 XX 是 32 或者 64 ,具体取决于架构。)在确定了是否执行 1、2 或 4 字节复制之后,该函数调用 __copy_to_user_ll,这就是实际工作进行的地方。在损坏的硬件中(在 i486 之前,WP 位在管理模式下不可用),页表可以随时替换,需要将想要的页面固定到内存,使它们在处理时不被换出。i486 之后,该过程只不过是一个优化的副本。
copy_from_user 函数
copy_from_user 函数将数据块从用户空间复制到内核缓冲区。它接受一个目的缓冲区(在内核空间)、一个源缓冲区(从用户空间)和一个以字节定义的长度。和 copy_to_user 一样,该函数在成功时,返回 0 ,否则返回一个非零数,指出不能复制的字节数。
copy_from_user( to, from, n );
该函数首先检查从用户空间源缓冲区读取的能力(通过 access_ok),然后调用 __copy_from_user,最后调用 __copy_from_user_ll。从此开始,根据构架,为执行从用户缓冲区到内核缓冲区的零拷贝(不可用字节)而进行一个调用。优化组装函数包含管理功能。
----------------------------------------------------------------------------------------------------------------------------------
copy_to_user分析
- static inline unsigned long __must_check copy_from_user(void *to, const void __user *from, unsigned long n)
- {
- if (access_ok(VERIFY_READ, from, n))
- n = __copy_from_user(to, from, n);
- else /* security hole - plug it */
- memset(to, 0, n);
- return n;
- }
-
- static inline unsigned long __must_check copy_to_user(void __user *to, const void *from, unsigned long n)
- {
- if (access_ok(VERIFY_WRITE, to, n))
- n = __copy_to_user(to, from, n);
- return n;
- }
- /*
- * 带有MMU的构架应该覆盖这两个函数
- */
- #ifndef __copy_from_user
- static inline __must_check long __copy_from_user(void *to,
- const void __user * from, unsigned long n)
- {
- if (__builtin_constant_p(n)) {
- switch(n) {
- case 1:
- *(u8 *)to = *(u8 __force *)from;
- return 0;
- case 2:
- *(u16 *)to = *(u16 __force *)from;
- return 0;
- case 4:
- *(u32 *)to = *(u32 __force *)from;
- return 0;
- #ifdef CONFIG_64BIT
- case 8:
- *(u64 *)to = *(u64 __force *)from;
- return 0;
- #endif
- default:
- break;
- }
- }
-
- memcpy(to, (const void __force *)from, n);
- return 0;
- }
- #endif
-
- #ifndef __copy_to_user
- static inline __must_check long __copy_to_user(void __user *to,
- const void *from, unsigned long n)
- {
- if (__builtin_constant_p(n)) {
- switch(n) {
- case 1:
- *(u8 __force *)to = *(u8 *)from;
- return 0;
- case 2:
- *(u16 __force *)to = *(u16 *)from;
- return 0;
- case 4:
- *(u32 __force *)to = *(u32 *)from;
- return 0;
- #ifdef CONFIG_64BIT
- case 8:
- *(u64 __force *)to = *(u64 *)from;
- return 0;
- #endif
- default:
- break;
- }
- }
-
- memcpy((void __force *)to, from, n);
- return 0;
- }
- #endif
- GCC的内建函数 __builtin_constant_p 用于判断一个值是否为编译时常数,如果参数值是常数,函数返回 1,否则返回 0。
- #ifndef __HAVE_ARCH_MEMCPY
- /**
- * memcpy - Copy one area of memory to another
- * @dest: Where to copy to
- * @src: Where to copy from
- * @count: The size of the area.
- *
- * You should not use this function to access IO space, use memcpy_toio()
- * or memcpy_fromio() instead.
- */
- void *memcpy(void *dest, const void *src, size_t count)
- {
- char *tmp = dest;
- const char *s = src;
-
- while (count--)
- *tmp++ = *s++;
- return dest;
- }
- EXPORT_SYMBOL(memcpy);
- #endif
- /* We use 33-bit arithmetic here... */
- #define __range_ok(addr,size) ({ \
- unsigned long flag, roksum; \
- __chk_user_ptr(addr); \
- __asm__("adds %1, %2, %3; sbcccs %1, %1, %0; movcc %0, #0" \
- : "=&r" (flag), "=&r" (roksum) \
- : "r" (addr), "Ir" (size), "0" (current_thread_info()->addr_limit) \
- : "cc"); \
- flag; })
- ......
- #define access_ok(type,addr,size) (__range_ok(addr,size) == 0)
- ......
现在我们来仔细分析__range_ok这个宏:
(1)unsigned long flag, roksum;\\定义两个变量
- flag:保存结果的变量:非零代表地址无效,零代表地址可以访问。初始存放非零值(current_thread_info()->addr_limit),也就是当前进程的地址上限值。
- roksum:保存要访问的地址范围末端,用于和当前进程地址空间限制数据做比较
(2)__chk_user_ptr(addr);\\定义是一个空函数
但是这个函数涉及到__CHECKER__宏的判断,__CHECKER__宏在通过Sparse(Semantic Parser for C)工具对内核代码进行检查时会定义的。在使用make C=1或C=2时便会调用该工具,这个工具可以检查在代码中声明了sparse所能检查到的相关属性的内核函数和变量。
如果定义了__CHECKER__,在网上的资料中这样解释的:__chk_user_ptr和__chk_io_ptr在这里只声明函数,没有函数体,目的就是在编译过程中Sparse能够捕捉到编译错误,检查参数的类型。
如果没有定义__CHECKER__,这就是一个空函数。
(3)接下来的汇编,我适当地翻译如下:
adds %1, %2, %3
以下的两个指令都带有条件CC,也就是当C=0的时候才执行。
如果上面的加法指令进位了(C=1),则以下的指令都不执行,flag就为初始值current_thread_info()->addr_limit(非零值),并返回。
如果没有进位(C=0),就执行下面的指令
sbcccs %1, %1, %0
roksum = roksum - flag,也就是(addr + size)- (current_thread_info()->addr_limit),操作影响符号位。
如果(addr + size)>=(current_thread_info()->addr_limit),则C=1
如果(addr + size)<(current_thread_info()->addr_limit),则C=0
(4)flag;
返回flag值
综上所诉:__range_ok宏其实等价于:
如果(addr + size)>=(current_thread_info()->addr_limit),返回非零值
如果(addr + size)<(current_thread_info()->addr_limit),返回零
而access_ok就是检验将要操作的用户空间的地址范围是否在当前进程的用户地址空间限制中。这个宏的功能很简单,完全可以用C实现,不是必须使用汇编。个人理解:由于这两个函数使用频繁,就使用汇编来实现部分功能来增加效率。
从这里再次可以认识到,copy_from_user与copy_to_user的使用是结合进程上下文的,因为他们要访问“user”的内存空间,这个“user”必须是某个特定的进程。通过上面的源码就知道,其中使用了current_thread_info()来检查空间是否可以访问。如果在驱动中使用这两个函数,必须是在实现系统调用的函数中使用,不可在实现中断处理的函数中使用。如果在中断上下文中使用了,那代码就很可能操作了根本不相关的进程地址空间。
其次由于操作的页面可能被换出,这两个函数可能会休眠,所以同样不可在中断上下文中使用。
-----------------------------------------------------------------------------------
关于access_ok中的检查如果(addr + size)<(current_thread_info()->addr_limit),返回零,阅读如下内容有助于理解
#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/string.h>
#include <linux/mm.h>
#include <linux/syscalls.h>
#include <asm/unistd.h>
#include <asm/uaccess.h>
#define MY_FILE "/root/LogFile"
char buf[128];
struct file *file = NULL;
static int __init init(void)
{
mm_segment_t old_fs;
printk("Hello, I'm the module that intends to write messages to file.\n");
if(file == NULL)
file = filp_open(MY_FILE, O_RDWR | O_APPEND | O_CREAT, 0644);
if (IS_ERR(file)) {
printk("error occured while opening file %s, exiting...\n", MY_FILE);
return 0;
}
sprintf(buf,"%s", "The Messages.");
old_fs = get_fs();
set_fs(KERNEL_DS);
file->f_op->write(file, (char *)buf, sizeof(buf), &file->f_pos);
set_fs(old_fs);
return 0;
}
static void __exit fini(void)
{
if(file != NULL)
filp_close(file, NULL);
}
module_init(init);
module_exit(fini);
MODULE_LICENSE("GPL");
其中:
typedef struct {
unsigned long seg;
} mm_segment_t;
#define KERNEL_DS MAKE_MM_SEG(0xFFFFFFFFUL)
#define MAKE_MM_SEG(s) ((mm_segment_t) { (s) })
基本思想:
一个是要记得编译的时候加上-D__KERNEL_SYSCALLS__
另外源文件里面要#include <linux/unistd.h>
如果报错,很可能是因为使用的缓冲区超过了用户空间的地址范围。一般系统调用会要求你使用的缓冲区不能在内核区。这个可以用set_fs()、get_fs()来解决。在读写文件前先得到当前fs:
mm_segment_t old_fs=get_fs();
并设置当前fs为内核fs:set_fs(KERNEL_DS);
在读写文件后再恢复原先fs: set_fs(old_fs);
set_fs()、get_fs()等相关宏在文件include/asm/uaccess.h中定义。
个人感觉这个办法比较简单。
另外就是用flip_open函数打开文件,得到struct file *的指针fp。使用指针fp进行相应操作,如读文件可以用fp->f_ops->read。最后用filp_close()函数关闭文件。 filp_open()、filp_close()函数在fs/open.c定义,在include/linux/fs.h中声明。
解释一点:
系 统调用本来是提供给用户空间的程序访问的,所以,对传递给它的参数(比如上面的buf),它默认会认为来自用户空间,在->write()函数中, 为了保护内核空间,一般会用get_fs()得到的值来和USER_DS进行比较,从而防止用户空间程序“蓄意”破坏内核空间;
而现在要在内核空间使用系统调用,此时传递给->write()的参数地址就是内核空间的地址了,在USER_DS之上(USER_DS ~ KERNEL_DS),如果不做任何其它处理,在write()函数中,会认为该地址超过了USER_DS范围,所以会认为是用户空间的“蓄意破坏”,从 而不允许进一步的执行; 为了解决这个问题; set_fs(KERNEL_DS);将其能访问的空间限制扩大到KERNEL_DS,这样就可以在内核顺利使用系统调用了!
补充:
我看了一下源码,在include/asm/uaccess.h中,有如下定义:
#define MAKE_MM_SEG(s) ((mm_segment_t) { (s) })
#define KERNEL_DS MAKE_MM_SEG(0xFFFFFFFF)
#define USER_DS MAKE_MM_SEG(PAGE_OFFSET)
#define get_ds() (KERNEL_DS)
#define get_fs() (current->addr_limit)
#define set_fs(x) (current->addr_limit = (x))
而它的注释也很清楚:
/*
* The fs value determines whether argument validity checking should be
* performed or not. If get_fs() == USER_DS, checking is performed, with
* get_fs() == KERNEL_DS, checking is bypassed.
*
* For historical reasons, these macros are grossly misnamed.
*/
因此可以看到,fs的值是作为是否进行参数检查的标志。系统调用的参数要求必须来自用户空间,所以,当在内核中使用系统调用的时候,set_fs(get_ds())改变了用户空间的限制,即扩大了用户空间范围,因此即可使用在内核中的参数了
----------------------------------------------------------------------------------------------------
前言
在程序的执行过程中,因为遇到某种障碍而使 CPU 无法最终访问到相应的物理内存单元,即无法完成从虚拟地址到物理地址映射的时候,CPU 会产生一次缺页异常,从而进行相应的缺页异常处理。基于 CPU 的这一特性,Linux 采用了请求调页(Demand Paging)和写时复制(Copy On Write)的技术
1. 请求调页是一种动态内存分配技术,它把页框的分配推迟到不能再推迟为止。这种技术的动机是:进程开始运行的时候并不访问地址空间中的全部内容。事实上,有一部分地址也许永远也不会被进程所使用。程序的局部性原理也保证了在程序执行的每个阶段,真正使用的进程页只有一小部分,对于临时用不到的页,其所在的页框可以由其它进程使用。因此,请求分页技术增加了系统中的空闲页框的平均数,使内存得到了很好的利用。从另外一个角度来看,在不改变内存大小的情况下,请求分页能够提高系统的吞吐量。当进程要访问的页不在内存中的时候,就通过缺页异常处理将所需页调入内存中。
2. 写时复制主要应用于系统调用fork,父子进程以只读方式共享页框,当其中之一要修改页框时,内核才通过缺页异常处理程序分配一个新的页框,并将页框标记为可写。这种处理方式能够较大的提高系统的性能,这和Linux创建进程的操作过程有一定的关系。在一般情况下,子进程被创建以后会马上通过系统调用execve将一个可执行程序的映象装载进内存中,此时会重新分配子进程的页框。那么,如果fork的时候就对页框进行复制的话,显然是很不合适的。
在上述的两种情况下出现缺页异常,进程运行于用户态,异常处理程序可以让进程从出现异常的指令处恢复执行,使用户感觉不到异常的发生。当然,也会有异常无法正常恢复的情况,这时,异常处理程序会进行一些善后的工作,并结束该进程。也就是说,运行在用户态的进程如果出现缺页异常,不会对操作系统核心的稳定性造成影响。 那么对于运行在核心态的进程如果发生了无法正常恢复的缺页异常,应该如何处理呢?是否会导致系统的崩溃呢?是否能够解决好内核态缺页异常对于操作系统核心的稳定性来说会产生很大的影响,如果一个误操作就会造成系统的Oops,这对于用户来说显然是不能容忍的 。本文正是针对这个问题,介绍了一种Linux内核中所采取的解决方法。
在读者继续往下阅读之前,有一点需要先说明一下,本文示例中所选的代码取自于Linux-2.4.0,编译环境是gcc-2.96,objdump的版本是2.11.93.0.2,具体的版本信息可以通过以下的命令进行查询:
$ gcc -v
Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.96/specs
gcc version 2.96 20000731 (Red Hat Linux 7.3 2.96-110)
$ objdump -v
GNU objdump 2.11.93.0.2 20020207
Copyright 2002 Free Software Foundation, Inc.
回页首
GCC的扩展功能
由于本文中会用到GCC的扩展功能,即汇编器as中提供的.section伪操作,在文章开始之前我再作一个简要的介绍。此伪操作对于不同的可执行文件格式有不同的解释,我也不一一列举,仅对我们所感兴趣的Linux中常用的ELF格式的用法加以描述,其指令格式如下:
.section NAME[, "FLAGS"]
大家所熟知的C程序一般由以下的几个部分组成:代码段(text section)、初始化数据段(data section)、非初始化数据段(bss section)、栈(heap)以及堆(stack),具体的地址空间布局可以参考《UNIX环境高级编程》一书。
在Linux内核中,通过使用.section的伪操作,可以把随后的代码汇编到一个由NAME指定的段中。而FLAGS字段则说明了该段的属性,它可以用下面介绍的单个字符来表示,也可以是多个字符的组合。
'a' 可重定位的段
'w' 可写段
'x' 可执行段
'W' 可合并的段
's' 共享段
举个例子来说明,读者在后面会看到的:.section .fixup, "ax"
这样的一条指令定义了一个名为.fixup的段,随后的指令会被加入到这个段中,该段的属性是可重定位并可执行。
回页首
内核缺页异常处理
运行在核心态的进程经常需要访问用户地址空间的内容,但是谁都无法保证内核所得到的这些从用户空间传入的地址信息是"合法"的。为了保护内核不受错误信息的攻击,需要验证这些从用户空间传入的地址信息的正确性。
在老版本的Linux中,这个工作是通过函数verify_area来完成的:
extern inline int verify_area(int type, const void * addr, unsigned long size)
该函数验证了是否可以以type中说明的访问类型(read or write)访问从地址addr开始、大小为size的一块虚拟存储区域。为了做到这一点,verify_read首先需要找到包含地址addr的虚拟存储区域(vma)。一般的情况下(正确运行的程序)这个测试都会成功返回,在少数情况下才会出现失败的情况。也就是说,大部分的情况下内核在一些无用的验证操作上花费了不算短的时间,这从操作系统运行效率的角度来说是不可接受的。
为了解决这个问题,现在的Linux设计中将验证的工作交给虚存中的硬件设备来完成。当系统启动分页机制以后,如果一条指令的虚拟地址所对应的页框(page frame)不在内存中或者访问的类型有错误,就会发生缺页异常。处理器把引起缺页异常的虚拟地址装到寄存器CR2中,并提供一个出错码,指示引起缺页异常的存储器访问的类型,随后调用Linux的缺页异常处理函数进行处理。
Linux中进行缺页异常处理的函数如下:
asmlinkage void do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
……………………
__asm__("movl %%cr2,%0":"=r" (address));
……………………
vma = find_vma(mm, address);
if (!vma)
goto bad_area;
if (vma->vm_start <= address)
goto good_area;
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4) {
if (address + 32 < regs->esp)
goto bad_area;
……………………
bad_area:
……………………
no_context:
/* Are we prepared to handle this kernel fault? */
if ((fixup = search_exception_table(regs->eip)) != 0) {
regs->eip = fixup;
return;
}
………………………
}
首先让我们来看看传给这个函数调用的两个参数:它们都是通过entry.S在堆栈中建立的(arch/i386/kernel/entry.S),参数regs指向保存在堆栈中的寄存器,error_code中存放着异常的出错码,具体的堆栈布局参见图一(堆栈的生成过程请参考《Linux内核源代码情景分析》一书)
该函数首先从CPU的控制寄存器CR2中获取出现缺页异常的虚拟地址。由于缺页异常处理程序需要处理的缺页异常类型很多,分支也很复杂。基于本文的主旨,我们只关心以下的几种内核缺页异常处理的情况:
1. 程序要访问的内核地址空间的内容不在内存中,先跳转到标号vmalloc_fault,如果当前访问的内容所对应的页目录项不在内存中,再跳转到标号no_context;
2. 缺页异常发生在中断或者内核线程中,跳转到标号no_context;
3. 程序在核心态运行时访问用户空间的数据,被访问的数据不在内存中
a) 出现异常的虚拟地址在进程的某个vma中,但是系统内存无法分配空闲页框(page frame),则先跳转到标号out_of_memory,再跳转到标号no_context;
b) 出现异常的虚拟地址不属于进程任一个vma,而且不属于堆栈扩展的范畴,则先跳转到标号bad_area,最终也是到达标号no_context。
从上面的这几种情况来看,我们关注的焦点最后集中到标号no_context处,即对函数search_exception_table的调用。这个函数的作用就是通过发生缺页异常的指令(regs->eip)在异常表(exception table)中寻找下一条可以继续运行的指令(fixup)。这里提到的异常表包含一些地址对,地址对中的前一个地址表示出现异常的指令的地址,后一个表示当前一个指令出现错误时,程序可以继续得以执行的修复地址。
如果这个查找操作成功的话,缺页异常处理程序将堆栈中的返回地址(regs->eip)修改成修复地址并返回,随后,发生异常的进程将按照fixup中安排好的指令继续执行下去。当然,如果无法找到与之匹配的修复地址,系统只有打印出出错信息并停止运作。
那么,这个所谓的修复地址又是如何生成的呢?是系统自动生成的吗?答案当然是否定的,这些修复指令都是编程人员通过as提供的扩展功能写进内核源码中的。下面我们就来分析一下其实现机制。
回页首
异常表的实现机制
笔者取include/asm-i386/uaccess.h中的宏定义__copy_user编写了一段程序作为例子加以讲解。
/* hello.c */
#include <stdio.h>
#include <string.h>
#define __copy_user(to,from,size) \
do { \
int __d0, __d1; \
__asm__ __volatile__( \
"0: rep; movsl\n" \
" movl %3,%0\n" \
"1: rep; movsb\n" \
"2:\n" \
".section .fixup,\"ax\"\n" \
"3: lea 0(%3,%0,4),%0\n" \
" jmp 2b\n" \
".previous\n" \
".section __ex_table,\"a\"\n" \
" .align 4\n" \
" .long 0b,3b\n" \
" .long 1b,2b\n" \
".previous" \
: "=&c"(size), "=&D" (__d0), "=&S" (__d1) \
: "r"(size & 3), "0"(size / 4), "1"(to), "2"(from) \
: "memory"); \
} while (0)
int main(void)
{
const char *string = "Hello, world!";
char buf[20];
unsigned long n, m;
m = n = strlen(string);
__copy_user(buf, string, n);
buf[m] = '\0';
printf("%s\n", buf);
exit(0);
}
先看看本程序的执行结果:
$ gcc hello.c -o hello
$ ./hello
Hello, world!
显然,这就是一个简单的"hello world"程序,那为什么要写得这么复杂呢?程序中的一大段汇编代码在内核中才能体现出其价值,笔者将其加入到上面的程序中,是为了后面的分析而准备的。
系统在核心态运行的时候,参数是通过寄存器来传递的,由于寄存器所能够传递的信息有限,所以传递的参数大多数是指针。要使用指针所指向的更大块的数据,就需要将用户空间的数据拷贝到系统空间来。上面的__copy_user在内核中正是扮演着这样的一个拷贝数据的角色,当然,内核中这样的宏定义还很多,笔者也只是取其中的一个来讲解,读者如果感兴趣的话可以看完本文以后自行学习。
如果读者对于简单的嵌入式汇编还不是很了解的话,可以参考《Linux内核源代码情景分析》一书。下面我们将程序编译成汇编程序来加以分析:
$ gcc -S hello.c
/* hello.s */
movl -60(%ebp), %eax
andl $3, %eax
movl -60(%ebp), %edx
movl %edx, %ecx
shrl $2, %ecx
leal -56(%ebp), %edi
movl -12(%ebp), %esi
#APP
0: rep; movsl
movl %eax,%ecx
1: rep; movsb
2:
.section .fixup,"ax"
3: lea 0(%eax,%ecx,4),%ecx
jmp 2b
.previous
.section __ex_table,"a"
.align 4
.long 0b,3b
.long 1b,2b
.previous
#NO_APP
movl %ecx, %eax
从上面通过gcc生成的汇编程序中,我们可以很容易的找到访问用户地址空间的指令,也就是程序中的标号为0和1的两条语句。而程序中伪操作.section的作用就是定义了.fixup和__ex_table这样的两个段,那么这两段在可执行程序中又是如何安排的呢?下面就通过objdump给读者一个直观的概念:
$ objdump --section-headers hello
hello: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 00000013 080480f4 080480f4 000000f4 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
………………………………
9 .init 00000018 080482e0 080482e0 000002e0 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
10 .plt 00000070 080482f8 080482f8 000002f8 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
11 .text 000001c0 08048370 08048370 00000370 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
12 .fixup 00000009 08048530 08048530 00000530 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
13 .fini 0000001e 0804853c 0804853c 0000053c 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
14 .rodata 00000019 0804855c 0804855c 0000055c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
15 __ex_table 00000010 08048578 08048578 00000578 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
16 .data 00000010 08049588 08049588 00000588 2**2
CONTENTS, ALLOC, LOAD, DATA
CONTENTS, READONLY
………………………………
26 .note 00000078 00000000 00000000 0000290d 2**0
CONTENTS, READONLY
上面通过objdump显示出来的可执行程序的头部信息中,有一些是读者所熟悉的,例如.text、.data以及被笔者省略掉的.bss,而我们所关心的是12和15,也就是.fixup和__ex_table。对照hello.s中段的定义来看,两个段声明中的FLAGS字段分别为'ax'和'a',而objdump的结果显示,.fixup段是可重定位的代码段,__ex_table段是可重定位的数据段,两者是吻合的。
那么为什么要通过.section定义独立的段呢?为了解开这个问题的答案,我们需要进一步看看我们所写的代码在可执行文件中是如何表示的。
$objdump --disassemble --section=.text hello
hello: file format elf32-i386
Disassembly of section .text:
8048498: 8b 45 c4 mov 0xffffffc4(%ebp),%eax
804849b: 83 e0 03 and $0x3,%eax
804849e: 8b 55 c4 mov 0xffffffc4(%ebp),%edx
80484a1: 89 d1 mov %edx,%ecx
80484a3: c1 e9 02 shr $0x2,%ecx
80484a6: 8d 7d c8 lea 0xffffffc8(%ebp),%edi
80484a9: 8b 75 f4 mov 0xfffffff4(%ebp),%esi
80484ac: f3 a5 repz movsl %ds:(%esi),%es:(%edi)
80484ae: 89 c1 mov %eax,%ecx
80484b0: f3 a4 repz movsb %ds:(%esi),%es:(%edi)
80484b2: 89 c8 mov %ecx,%eax
前面的hello.s中的汇编片断在可执行文件中就是通过上面的11条指定来表达,读者也许会问,由.section伪操作定义的段怎么不见了?别着急,慢慢往下看,由.section伪操作定义的段并不在正常的程序执行路径上,它们是被安排在可执行文件的其它地方了:
$objdump --disassemble --section=.fixup hello
hello: file format elf32-i386
Disassembly of section .fixup:
08048530 <.fixup>:
8048530: 8d 4c 88 00 lea 0x0(%eax,%ecx,4),%ecx
8048534: e9 79 ff ff ff jmp 80484b2 <main+0x42>
由此可见,.fixup是作为一个单独的段出现在可执行程序中的,而此段中所包含的语句则正好是和源程序hello.c中的两条语句相对应的。
将.fixup段和.text段独立开来的目的是为了提高CPU流水线的利用率。熟悉体系结构的读者应该知道,当前的CPU引入了流水线技术来加快指令的执行,即在执行当前指令的同时,要将下面的一条甚至多条指令预取到流水线中。这种技术在面对程序执行分支的时候遇到了问题:如果预取的指令并不是程序下一步要执行的分支,那么流水线中的所有指令都要被排空,这对系统的性能会产生一定的影响。在我们的这个程序中,如果将.fixup段的指令安排在正常执行的.text段中,当程序执行到前面的指令时,这几条很少执行的指令会被预取到流水线中,正常的执行必然会引起流水线的排空操作,这显然会降低整个系统的性能。
下面我们就可以看到异常表是如何形成的了:
$objdump --full-contents --section=__ex_table hello
hello: file format elf32-i386
Contents of section __ex_table:
8048578 ac840408 30850408 b0840408 b2840408 ....0...........
由于x86使用小尾端的编址方式,上面的这段数据比较凌乱。让我把上面的__ex_table中的内容转变成大家通常看到的样子,相信会更容易理解一些:
8048578 80484ac 8048530 80484b0 80484b2 ....0...........
上面的红色部分就是我们最感兴趣的地方,而这段数据是如何形成的呢?将前面objdump生成的可执行程序中的汇编语句和hello.c中的源程序结合起来看,就可以发现一些有趣的东西了!
先让我们回头看看hello.c中__ex_table段的语句 .long 0b,3b。其中标签0b(b代表backward,即往回的标签0)是可能出现异常的指令的地址。结合objdump生成的可执行程序.text段的汇编语句可以知道标签0就是80484ac:
原始的汇编语句: 0: rep; movsl
链接到可执行程序后: 80484ac: f3 a5 repz movsl %ds:(%esi),%es:(%edi)
而标签3就是处理异常的指令的地址,在我们的这个例子中就是80484b0:
原始的汇编语句: 3: lea 0(%eax,%ecx,4),%ecx
链接到可执行程序后: 8048530: 8d 4c 88 00 lea 0x0(%eax,%ecx,4),%ecx
因此,相应的汇编语句
.section __ex_table,"a"
.align 4
.long 0b,3b
就变成了: 8048578 80484ac 8048530 …………
这样,异常表中的地址对(80484ac,8048530)就诞生了,而对于地址对(80484b0 80484b2)的生成,情况相同,不再赘述。
读到这儿了,有一件事要告诉读者的是,其实例子中异常表的安排在用户空间是不会得到执行的。当运行在用户态的进程访问到标签0处的指令出现缺页异常时,do_page_fault只会将该指令对应的进程页调入内存中,使指令能够重新正确执行,或者直接就杀死该进程,并不会到达函数search_exception_table处。
也许有的读者会问了,既然不执行,前面的例子和围绕例子所展开的讨论又有什么作用呢?大家大可打消这样的疑虑,我们前面的分析并没有白费,因为真正的内核异常表中地址对的生成机制和前面讲述的原理是完全一样的,笔者通过一个运行在用户空间的程序来讲解也是希望让读者能够更加容易的理解异常表的机制,不至于陷入到内核源码的汪洋大海中去。现在,我们可以自己通过objdump工具查看一下内核中的异常表:
$objdump --full-contents --section=__ex_table vmlinux
vmlinux: file format elf32-i386
Contents of section __ex_table:
c024ac80 e36d10c0 e66d10c0 8b7110c0 6c7821c0
……………………
做一下转化:
c024ac80 c0106de3 c0106de6 c010718b c021786c
上面的vmlinux就是编译内核所生成的内核可执行程序。和本文给出的例子相比,唯一的不同就是此时的地址对中的异常指令地址和修复地址都是内核空间的虚拟地址。也正是在内核中,异常表才真正发挥着它应有的作用。
回页首
总结
下面我对前面所讲述的内容做一个归纳,希望读者能够对内核缺页异常处理有一个清楚的认识:
进程访问内核地址空间的"非法"地址c010718b
存储管理部件(MMU)产生一个缺页异常;
CPU调用函数do_page_fault;
do_page_fault调用函数search_exception_table(regs->eip == c010718b);
search_exception_table在异常表中查找地址c010718b,并返回地址对中的修复地址c021786c;
do_page_fault将堆栈中的返回地址eip修改成c021786c并返回;
代码按照缺页异常处理程序的返回地址继续执行,也就是从c021786c开始继续执行。
将验证用户空间地址信息"合法"性的工作交给硬件来完成(通过缺页异常的方式)其实就是一种Lazy Computation,也就是等到真正出现缺页异常的时候才进行处理。通过本文的分析可以看出,这种方法与本文前面所提到的通过verify_area来验证的方法相比,较好的避免了系统在无用验证上的开销,能够有效的提高系统的性能。 此外,在分析源码的过程中读者会发现,异常表并不仅仅用在缺页异常处理程序中,在通用保护(General Protection)异常等地方,也同样用到了这一技术。
由此可见,异常表是一种广泛应用于Linux内核中的异常处理方法。在系统软件的设计中,异常表也应该成为一种提高系统稳定性的重要手段