内核态与用户态进行数据交互通常是这样一种模型:内核利用自身的特权通过特定的服务程序采集、接收和处理数据;接着,用户态程序和内核服务程序进行数据交互,或接收内核态的数据,或向内核态写入数据。通过传统的那些对文件操作的系统调用就可以完成这样的工作,但是我们有时候需要通过访问用户空间的内存来直接读取内核数据,因为这样可以免去数据在内核态与用户态之间拷贝所花费的时间。
本文基于以上背景,以Linux字符设备驱动为基础,通过内存映射将内核中的一部分虚拟内存直接映射到用户空间,使得用户在访问内存时等同于直接访问内核空间,从而直接获取内核空间的数据。
1.实现原理
不管进程是在用户空间访问数据还是在内核空间访问数据,它所面临的都是虚拟地址。由于Linux对分段机制进行了特殊处理,因此这里的虚拟地址就等同于线性地址。按照一开始我们提出的要求,进程通过访问用户虚拟地址A来达到直接访问内核虚拟地址B中所存储数据的目的,这里的地址A和B必然不相同。那么,如何通过不同的虚拟地址来访问相同的数据?我们可以将虚拟地址A和B都映射到同一块物理内存,就可以实现内核空间和用户空间之间的数据共享。
示意图如下:
虚拟内存和物理内存之间如何联系?当然是通过页表了。我们在内核空间提前分配好缓冲区,并且向该缓冲区写入数据,此时内核会自动将该缓冲区对应的内核虚拟地址与实际的某一快物理内存进行关联,并将它们的映射关系保存在内核页表中。当在内核空间分配内存时时,上述工作自动被完成,比如通过kmalloc()分配内存时。
一旦在内核空间中分配了内存,随之就确定了物理内存。现在我们需要做的是将用户虚拟地址与物理内存进行关联,也就是说我们要将这个映射关系写入进程的用户页表。整个关联的过程是内核缺页异常处理程序完成的,这个处理过程比较复杂。我们要在内核中实现的并不是缺页异常处理程序,因为内核已经实现的很完美,而只需完成其中的一小部门。具体如何实现下问会详细说明。
2.用户态程序的实现
在本文所描述的内存管理试验,用户态程序首先通过open()打开字符设备文件mapdrv,该系统调用执行成功时返回文件描述符;通过mmap()将该设备文件映射到当前进程的用户空间中,该系统调用执行成功时返回指向映射区域的指针;最后通过该指针打印数据。用户态程序的实现如下所示:
#define LEN (10 * 4096)
int main(void)
{
int fd, ret = 0;
char *vadr;
int i;
if ((fd = open("/dev/mapdrv_k", O_RDWR)) < 0) {
perror("open");
ret = -1;
goto fail;
}
vadr = mmap(0, LEN, PROT_READ, MAP_PRIVATE | MAP_NORESERVE, fd, 0);
if (vadr == MAP_FAILED) {
perror("mmap");
ret = -1;
goto fail_close;
}
printf("%s\n", vadr);
if (-1 == munmap(vadr, LEN))
ret = -1;
fail_close:
close(fd);
fail:
exit(ret);
}
用户态程序的实现并不复杂,因为它的主要作用是对内核模块程序的测试。由于用户态程序是对特定的字符设备文件mapdrv进行操作,所以程序中所使用的系统调用将会调用file_operations结构中对应的钩子函数。比如mmap系统调用在执行时会调用mapdrv设备驱动中的mmap钩子函数,虽然两者同名,但是mmap钩子函数所实现的功能只是mmap系统调用执行过程中的一部门,该钩子函数是file_operations结构中的成员。两者的函数原型如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int (*mmap) (struct file *, struct vm_area_struct *);
如何实例化open和mmap等钩子函数便是整个内核模块程序实现的关键。
3.字符设备驱动程序的实现
整个内核模块程序是以字符设备驱动为基础进行实现的。该程序模块加载函数与一般字符设备驱动程序完成的工作一致:
1.申请设备号;
2.为描述字符设备的数据结构分配空间,并进行初始化;
3.将该字符设备注册到内核中;
在本文所描述的实验中,模块加载函数除了完成上述功能,还要完成以下功能:
kmalloc_area = kmalloc(MAPLEN, GFP_KERNEL);
if (!kmalloc_area)
goto fail4;
for (page = virt_to_page(kmalloc_area);
page < virt_to_page(kmalloc_area + MAPLEN); page++) {
SetPageReserved(page);
}
首先,通过kmalloc()分配一块内存用于在内核空间保存数据;通过SetPageReserved()将缓存数据的页面常驻内存,防止被换出到磁盘;将一段字符串拷贝到这片内存中。完成初始化函数后,字符设备驱动中最重要的就是实现file_operations结构中的钩子函数。在本文所述的实验中,我们只需实现三个钩子函数。
static struct file_operations mapdrv_fops = {
.owner = THIS_MODULE,
.mmap = mapdrv_mmap,
.open = mapdrv_open,
.release = mapdrv_release,
};
int mapdrv_open(struct inode *inode, struct file *file)
{
struct mapdrv *md;
printk("device is opened..\n");
md = container_of(inode->i_cdev, struct mapdrv, mapdev);
atomic_inc(&md->usage);
return 0;
}
int mapdrv_release(struct inode *inode, struct file *file)
{
struct mapdrv* md;
printk("device is closed..\n");
md = container_of(inode->i_cdev, struct mapdrv, mapdev);
atomic_dec(&md->usage);
return 0;
}
可以看到,open和release钩子函数的实现十分简单,只是打印相应语句以及更新设备的引用计数。事实上,我们不实现这两个钩子函数对整个驱动程序也没有任何影响。因为他们分别在open和close系统调用执行的过程中被调用,而这两个系统调用已经完成了打开文件和关闭文件的所有工作。因此,open和release钩子函数只是打印一些日志信息,方便用户查看。因此,同名的钩子函数和系统调用并不是等价的关系。
4.通过fault()实现内存映射
mmap系统调用是将本地文件映射到进程的用户空间,如果执行成功,进程的地址空间中会新增一块虚拟内存区域(但并不是每次调用mmap()都会增加一个vma,因为可能会出现内存区域之间的合并)
mmap钩子函数的回调只是整个系统调用执行过程中的一部分,这个钩子函数完成的主要功能是将新增的vma中的操作集进行实例化。具体实现代码如下:
int mapdrv_mmap(struct file *file, struct vm_area_struct *vma)
{
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
unsigned long size = vma->vm_end - vma->vm_start;
if (offset & ~PAGE_MASK) {
printk("offset not aligned: %ld\n", offset);
return -ENXIO;
}
if (size > MAPLEN) {
printk("size too big\n");
return -ENXIO;
}
if ((vma->vm_flags & VM_WRITE) && !(vma->vm_flags & VM_SHARED)) {
printk("writeable mappings must be shared, rejecting\n");
return -EINVAL;
}
vma->vm_flags |= VM_LOCKED;
if (offset == 0) {
vma->vm_ops = &map_vm_ops;
map_vopen(vma);
} else {
printk("offset out of range\n");
return -ENXIO;
}
return 0;
}
首先,offset中保存映射的首页在文件中的偏移量,该偏移量必须是页大小的整数倍,否则将不能进行映射。这一点在实现上将offset与PAGE_MASK宏进行位运算即可判断。接着,判断映射区域的长度是否超出了本实验中预设的长度大小。
如果上述两个条件都合法,那么接下来将进行最为重要的操作,将vma中的操作集进行实例化。vma的操作集就是专门对所属vma进行操作的钩子函数集合,内核中通过vm_operations_struct结构对其描述:
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
……
}
参考上述代码,具体的实例化操作就是定义该类型的变量map_vm_ops,实现所需钩子函数,并将该变量与vm中的操作集字段进行挂接。
我们这里用到的主要有以下三个钩子函数:
open:当指定的vma加入到一个地址空间时,该函数被调用。
close:当指定的vma从地址空间删除时,该函数被调用。
fault:当要访问的页不再物理内存时,该函数被缺页处理程序调用。
这三个钩子函数的实现代码如下:
static struct vm_operations_struct map_vm_ops = {
.open = map_vopen,
.close = map_vclose,
.fault = map_fault,
};
void map_vopen(struct vm_area_struct *vma)
{
printk("mapping vma is opened..\n");
}
void map_vclose(struct vm_area_struct *vma)
{
printk("mapping vma is closed..\n");
}
可以看到,vma的open和close两个钩子函数没有做什么具体工作,因为打开和关闭一个vma的工作全部由内核负责,但是在mmap钩子函数中我们必须显示的调用map_open()。
这里我们重点说明falut钩子函数的实现。当用户要访问vma中的页,而该页又不在内存时,将发生缺页异常,fault钩子函数会在整个缺页处理程序中被调用。整个过程大致如下:
1.找到缺页地址所在的vma。
2.如果有必要分配各级页表项。
3.如果页表项对应的物理页面不存在,则回调当前vma中的fault钩子函数,它返回物理页面描述符。
4.将物理页面地址填充到相应页表项中。
5.完毕。
可以看到,fault钩子函数所实现的主要功能就是返回所需的物理内存描述符。
根据本文第一部分所描述实现原理,我们通过kmalloc()分配一块虚拟内存,可以通过virt_to_page()获得该虚拟内存对应的物理页框描述符,最后将该物理页框描述符返回到缺页异常处理程序中。至于用户页表的更新,那是缺页异常处理程序负责的事情,我们不必理会。
int map_fault(struct vm_area_struct *vma, struct vm_fault *vmf)
{
struct page *page = virt_to_page(kmalloc_area);
get_page(page);
vmf->page = page;
printk("the requiring page is returned..\n");
return 0;
}
通过上述实现过程,我们就将用户虚拟地址A和内核虚拟地址B映射到了同一的物理内存上,从而实现进程访问用户地址时直接获得内核数据的功能。
本实验涉及的知识点比较多,比如字符设备驱动程序的基本模型,Linux内存管理等相关知识。感兴趣的童鞋可以参考:
1.内核之旅网站,http://www.kerneltravel.net/journal/v/mem.htm
2.Linux设备驱动程序,第十五章