写在前面
经常能刷到讲解linux内核相关知识的文章以及课程,大部分是卖课的,给我的感觉就是不太好懂,甚至越讲越不懂,越讲越复杂。我今天思考了一下原因:
- 只讲源代码不讲原理。我一直想要搞懂内核,但是随便搜索得来的文章往往不能深入看,发现问题越来越多,好不容易今天看懂了,过几天就忘了,过一个月就全忘了回到起点了;我想根本的原因是,每行代码我都懂,但是不知道为什么这么写。
- 不讲历史,只讲结果。任何一个工程,不管大小,都是不断演进的,变化的。我们往往看的是结果,就是他最后的样子,至于为啥会是这样,不清楚,所以后面如果技术发生了变化,升级,新的代码还得重新理解,重新学习,越来越累。
所以,我尝试换一种方式理解Linux内核。就从mmap
开始吧。
mmap到底在做什么?
1. 看看man mmap
看官方解释往往是第一步,因为权威,准确。
NAME
mmap, munmap - map or unmap files or devices into memory>SYNOPSIS
#include <sys/mman.h>void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); int munmap(void *addr, size_t length); See NOTES for information on feature test macro requirements.
DESCRIPTION
mmap() creates a new mapping in the virtual address space of the calling process. The starting >address for the new mapping is specified in addr. The length argument specifies
the length of the mapping (which must be greater than 0).
看文字mmap
的功能就是为当前进程的虚拟内存分配一个新的映射,映射的起始地址是addr
长度length
。还可以传入一个fd
,说明可以将这个虚拟地址绑定到一个文件上。
我的理解
通过看这段文字,我大概脑子里已经有一个大概的思路了。因为我花了很长的时间已经搞懂了这些概念是什么:
- 虚拟地址在CPU在OS中到底指的是什么?
- 映射这个动作到底指的什么?
如果你清楚上面这两个问题,我来稍微解释下,理解了的可以跳过。
- 这里的
地址
是虚拟内存地址
。当Linux内核起来后,CPU就不清楚啥是物理地址了(从real mode到long mode),因为它只能接触到虚拟地址。(具体CPU如何将虚拟地址对应到物理地址的寻址过程可以参考这里); - 有了虚拟地址以后,地址这个概念发生了拓展,不再只跟内存一一对应了,它可以代表:各种连接在总线上的设备,特定的寄存器等等,可以代表你想要用
mov
指令访问的任何位置,任何设备中的数据; - 现在
虚拟地址
是个资源概念,CPU可以访问到的资源,是个抽象概念了——多一层抽象,构架就多一份灵活性,虚拟地址是个伟大的发明; - 如果我现在说映射指的是将抽象的虚拟地址具体如何绑定到设备中实在的数据的过程,应该好理解些了吧?程序员可以把这个映射理解成——硬件世界的面向对象抽象的过程。用代码表示就是:
interface Address {
void access(VirtualAddress address);
}
public class Memory impliment Adress{
public void access(VirtualAddress address){
//memory怎么通过虚拟地址访问数据
}
}
public class File impliment Adress {
public void access(VirtualAddress address){
//如何通过虚拟地址来访问文件的数据
}
}
所以,我的理解是:mmap是个分配物理内存
的函数,或者说机制。用户态进程通过调用这个函数向系统申请了一块连续的内存资源。而且,居然还可以传入一个文件,那大概就是利用虚拟地址访问文件资源了吧。大概分成这么几个步骤:
- 既然虚拟地址是个资源,尽管它是虚拟的、不存在的抽象概念,但是是资源肯定要分配。所以第一步就是从进程“广袤”的地址空间中找一段大小合适的,没有用过的虚拟地址空间来做后面的映射操作;
- 找到以后我要存下来,或者说用一个结构保存下来,后面只要找到这个结构就能操作这段虚拟地址空间;这个结构就是
vm_area_struct
; - 如果要映射到内存,我就去找一个页的物理内存,然后操作页表生成页表项就行了;物理内存维护在slab中,页表维护在
task_struct
中,登记一下就行了;相当于上面伪代码中Memory.access(virtualAddress)
的实现 - 如果是要映射到文件,那么就要实现
File.access(virtualAddress)
接口。
这是个高层的理解,也是设计的初衷。因为有了虚拟地址就可以做到mmap
,也只有虚拟地址才能做到mmap
这么灵活的构架设计。这就是所谓的机制与策略的分离
构架思想。因为:
- Linux是个通用的操作系统,未来要接入的设备五花八门,接口形式也不同,要怎么设计一套足够灵活构架解决这个问题呢?虚拟内存提供了答案。
- 虚拟内存挡在CPU与外部设备之间,对CPU屏蔽了外部设备与数据访问的复杂度,用统一的方式去访问所有的设备——也就是CPU指提供访问
机制
,no more no less。简单来说就是,CPU只提供接口,不提供具体实现; - 而设备的复杂度由设备的制造商去解决,制造商根据不同CPU构架访问资源的接口,实现自己的
access
实现,并将实现注入到OS中去就行了。这就是策略
由提供商来做,也只能由制造商来做才能繁荣整个生态; - 现在大家明白什么是驱动程序了吗?很简单,就是根据CPU/OS提供的接口,规范,机制来实现自己的策略,然后注入到OS中去跑的过程。
下面看看是不是这么回事,我们现在看看关键的内核代码。
内核mmap的代码
浅看一下,理解意思就行,内核的代码嵌套比较深,其实了解原理后,只要抓住关键点就行了。
首先就是要找到虚拟内存的接口定义
-
vm_area_struct
其实就是虚拟内存的class
对象,在其中定义了一个跟access
十分类似的字段:
struct vm_area_struct {
/* The first cache line has the info for VMA tree walking. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
......
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops; //就是这个字段
.......
} __randomize_layout;
这个字段就是了const struct vm_operations_struct *vm_ops;
,我们展开看看:
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
/* Called any time before splitting to check if it's allowed */
int (*may_split)(struct vm_area_struct *area, unsigned long addr);
int (*mremap)(struct vm_area_struct *area, unsigned long flags);
/*
* Called by mprotect() to make driver-specific permission
* checks before mprotect() is finalised. The VMA must not
* be modified. Returns 0 if eprotect() can proceed.
*/
int (*mprotect)(struct vm_area_struct *vma, unsigned long start,
unsigned long end, unsigned long newflags);
vm_fault_t (*fault)(struct vm_fault *vmf);
vm_fault_t (*huge_fault)(struct vm_fault *vmf,
enum page_entry_size pe_size);
void (*map_pages)(struct vm_fault *vmf,
pgoff_t start_pgoff, pgoff_t end_pgoff);
........... //后面还有就不贴了
};
可以看到很多接口——C语言就是函数指针。这个就是抽象类。我们可以看到有个叫做fault
的接口函数,这个函数就是x86中断中“第14名”,大名鼎鼎的pagefault exception
的处理点了。(要理解Linux内核,必须先了解CPU,要了解CPU只要了解内存怎么管理,中断怎么处理其实就够了,一点点题外话)
看到这里其实我们就能猜测这个过程了:
1、在mmap
系统调用中linux其实不用分配实际的物理内存,只要给进程分配一段资源——一段没有映射的虚拟地址空间——vma结构;
2、对vma结构做初始化,主要是为CPU机制——pagefault exception
——准备具体的实现类——映射文件还是映射内存;设置到这里就可以了,因为COW(copy on write)机制会把物理内存的分配推迟到最后一刻——中断发生的时候;
3、在pagefault exception
中肯定会做两个事情,也只需要做两个事情:
1. 根据中断进程,找到发生中断的虚拟内存——vma
结构;
2. 调用vma->fault
接口进行处理就行了。
我们先看内核代码,看看pagefault exception
是否真的是这么处理的。(先证实第3点猜测)
先看看pagefault
的入口:
/*
address就是引发pagefault中断处的虚拟地址
regs是用户态进程的CPU上下文
*/
void do_page_fault(unsigned long address, struct pt_regs *regs)
{
struct vm_area_struct *vma = NULL;
struct task_struct *tsk = current;
struct mm_struct *mm = tsk->mm;
int sig, si_code = SEGV_MAPERR;
unsigned int write = 0, exec = 0, mask;
vm_fault_t fault = VM_FAULT_SIGSEGV; /* handle_mm_fault() output */
unsigned int flags; /* handle_mm_fault() input */
........ //这里就是根据引发中断的地址找到对应的vma结构
vma = find_vma(mm, address);
......... //找到vma以后就开始调用vma相应的处理方法了
fault = handle_mm_fault(vma, address, flags, regs);
.........
}
最后调用 __do_fault
函数处理。
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret;
if (pmd_none(*vmf->pmd) && !vmf->prealloc_pte) {
vmf->prealloc_pte = pte_alloc_one(vma->vm_mm);
if (!vmf->prealloc_pte)
return VM_FAULT_OOM;
smp_wmb(); /* See comment in __pte_alloc() */
}
//这里就开始调用fault了。跟我们猜测一致。
ret = vma->vm_ops->fault(vmf);
if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY |
VM_FAULT_DONE_COW)))
return ret;
..................
return ret;
}
看到这里ret = vma->vm_ops->fault(vmf);
,确实调了fault来处理缺页,这个fault其实是个接口,是不是很像多态?所以说,面相对象是个概念,任何语言都可以实现的。
嗯嗯,非常好!跟我的猜测是一致的。现在就是要确认1,2
两个地方了,具体分配页表是在缺页中断处,mmap系统调用就是实现多态函数的绑定咯,我们看看。回到mmap
系统调用处开始找。
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, off)
{
long error;
error = -EINVAL;
if (off & ~PAGE_MASK)
goto out;
error = ksys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);
out:
return error;
}
再找ksys_mmap_pgoff
函数
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
{
............\\前面都是校验
retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
out_fput:
if (file)
fput(file);
return retval;
}
进入vm_mmap_pgoff
函数,再到do_mmap
函数,linux代码嵌套是很深的。
/*
* 这个函数完成了file->vma的绑定。
*/
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, unsigned long pgoff,
unsigned long *populate, struct list_head *uf)
{
............
/* Obtain the address to map to. we verify (or select) it and ensure
* that it represents a valid section of the address space.
*/
//获取一个没有映射的起始地址。应该是4k对齐的地址
addr = get_unmapped_area(file, addr, len, pgoff, flags);
if (IS_ERR_VALUE(addr))
return addr;
............................
//实际绑定的函数
addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);
..............
return addr;
}
实际的绑定函数是mmap_region
,怎么绑定的呢?
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma, *prev, *merge;
......................
/*
* 这里会拿到address对应的vma对象
*/
vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma)
goto out;
/*
也可能在这里拿到address对应的vma对象。不管在哪里拿到vma,到这里肯定拿到了。
*/
vma = vm_area_alloc(mm);
if (!vma) {
error = -ENOMEM;
goto unacct_error;
}
//vma起始位置
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
.....
//vma跟file绑定
vma->vm_file = get_file(file);
//这里就是完成绑定的地方了!
error = call_mmap(file, vma);
.............................
}
可以看到call_mmap
you两个参数file
与vma
,可见这个函数就是将两个对象绑定起来的地方了:
static inline int call_mmap(struct file *file, struct vm_area_struct *vma)
{
return file->f_op->mmap(file, vma);
}
这里会调用file
描述符中的mmap(file,vma)
函数完成绑定。如果我们用的文件系统是ext4
则应该去找找这个文件系统的mmap
函数的实现。
const struct file_operations ext4_file_operations = {
.llseek = ext4_llseek,
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
.iopoll = iomap_dio_iopoll,
.unlocked_ioctl = ext4_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = ext4_compat_ioctl,
#endif
//在这里定义
.mmap = ext4_file_mmap,
.mmap_supported_flags = MAP_SYNC,
.open = ext4_file_open,
.release = ext4_release_file,
.fsync = ext4_sync_file,
.get_unmapped_area = thp_get_unmapped_area,
.splice_read = generic_file_splice_read,
.splice_write = iter_file_splice_write,
.fallocate = ext4_fallocate,
};
ext4_file_operations
就是file->f_op
,可见文件系统也是用机制策略分离的构架建立的。任何文件系统,都要实现struct file_operations
接口。上面的file->f_op->mmap(file, vma);
调用的就是ext4_file_mmap(file,vma)
。
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
.....
vma->vm_ops = &ext4_file_vm_ops;
.....
return 0;
}
这里我们就看到了对于新找到的vma,如果是映射到文件,会把vma的vm_ops抽象接口改成文件定制的vm_ops——ext4_file_vm_ops
。
static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = ext4_filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = ext4_page_mkwrite,
};
我们可以看到,对于ext4
文件系统的vm_ops
接口的实现有fault
。至此,我们就找到了mmap到文件的地方了,到时候pagefault
发生的时候,会执行ext4_filemap_fault
函数进行物理内存映射,步骤是将vma对应的虚拟内存地址映射到物理地址,然后这个物理地址填充上相应文件的内容。是不是很容易理解了。
接着找找将匿名映射,也就是将vma映射到普通的物理内存。
后来我翻了下代码发现其实我想多了,Linux对于匿名映射,是没有填充fault
函数的......do_fault
直接从slab中找空闲页面就行了。这里是证据:
static inline bool vma_is_anonymous(struct vm_area_struct *vma)
{
return !vma->vm_ops;
}
如果是匿名映射
的话vma->vm_ops
是空的。
到这里就结束了,结论就是,mmap确实是根据不同的映射条件将虚拟内存空间映射到不同的资源上来统一访问的。
总结
我感觉,单纯阅读Linux源代码其实对开发帮助有限,而且对一般的非内核开发人员,一段时间不用,就会忘记,但是如果你理解了代码的机制,知道了Linux为什么要这么写,你可能长时间的记住,并且运用到自己的工作中,学习Linux其实就是要学习它的工程经验。