mmap的调用层级与内核态陷入全过程
目录
一、用户态的三层封装
(1)第应用层发起 mmap 请求
(2)C 标准库封装:衔接用户态与内核
(3)syscall 指令:用户态→内核态的 “跳板”
二、内核入口:系统调用的 “接待处”
(一)汇编入口:快速切换与准备
(二)系统调用表:“通讯录” 式的映射
三、内核态初步处理:sys_mmap 与 sys_mmap_pgoff
(一)sys_mmap:参数校验与转换
(2)sys_mmap_pgoff:页粒度处理
四、内存映射核心流程:从 do_mmap_pgoff 到 mmap_region
(一)do_mmap_pgoff:统筹调度
(二)do_mmap:地址分配与准备
(三)mmap_region:构建 vm_area_struct ,完成映射
六、总结:理解 mmap 陷入内核的意义
七、补充说明文件映射、匿名映射的区别
(1)为什么mmap只能映射已经打开的文件
(2)对于两个进程如何使用匿名映射呢?
对于初学者而言,我们只知道mmap是用来管理一个进程的虚拟地址空间的函数,他能像brk一样挪动堆的边界,从而获得堆空间。他是malloc的底层调用,但是他具体是如何陷入内核态的,如何获得内存的,我们并不清晰。于是本篇文章将从新手入门的角度,逐层剖析这个过程,从应用程序调用到内核深处的处理,带您看清你看清每一层的作用和它们之间的联系。
一、用户态的三层封装
(1)第应用层发起 mmap
请求
当你在应用程序中写下如下代码时,就正式开启了 mmap
的 “旅程”:
#include <sys/mman.h>
void *addr = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
(这里的NULL其实是让内核自己帮我们选择一个地址,而非程序员手动指定虚拟地址)
这一步,你只是向系统 “表达需求”:希望映射一段 4096 字节的内存,以私有只读方式关联文件描述符 fd
对应的文件,偏移量为 0 。但应用程序运行在用户态,没有直接操作硬件、管理内存的权限,必须借助内核的能力,于是要触发系统调用,从用户态陷入内核态。
(2)C 标准库封装:衔接用户态与内核
应用层调用的 mmap
,实际是 C 标准库(如 glibc
)提供的封装函数。它的作用是:
- 参数校验:简单检查传入参数是否合理,比如偏移量是否符合基本规则(后续内核会做更严格校验)。
- 准备系统调用:把应用层参数整理成内核能识别的格式,然后通过
syscall
指令,带着 系统调用号(mmap
对应号为 9 ) ,正式向内核 “递请求” 。
// glibc 中 mmap 简化逻辑
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset) {// 简单校验,如偏移量页对齐初步检查(非严格)if (offset & (PAGE_SIZE - 1)) {errno = EINVAL;return MAP_FAILED;}// 触发系统调用,传递参数return (void *)syscall(SYS_mmap, addr, length, prot, flags, fd, offset);
}
在Linux中,用户态代码无法直接调用到内核函数,因为其两者运行在不同的权限等级,用户态为Ring3,、内核态为Ring0。因此,glibc中的mmap函数必须通过系统调用指令syscall从用户态切换成内核态,再由内核根据系统调用号执行对应的内核函数。
可以看到在c标准库中,有一句代码很奇怪,函数名叫做syscall。它其实就是从用户态转换成内核态的接口。它的第一个参数是内核态函数的名字或者编号,后面的参数则和c标准库的参数一致。
(3)syscall
指令:用户态→内核态的 “跳板”
syscall
是 CPU 提供的特殊指令,执行它会发生关键变化:
- 特权级切换:CPU 从用户态(特权级 3 ,权限低)切换到内核态(特权级 0 ,权限高 )。
- 上下文保存:自动保存用户态的执行现场(比如程序计数器、寄存器值 ),方便后续返回。
- 跳转到内核入口:按照系统初始化时设置的 “路线”,进入内核预先准备好的系统调用处理入口。
二、内核入口:系统调用的 “接待处”
(一)汇编入口:快速切换与准备
内核通过汇编代码(如 arch/x86/entry/entry_64.S
中的 system_call
)处理 syscall
触发的切换:
ENTRY(system_call)swapgs ; 切换 GS 寄存器,隔离用户态与内核态数据movq %rsp, PER_CPU_VAR(rsp_scratch) ; 保存用户栈指针movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp ; 切到内核栈; 保存用户态寄存器到内核栈,为内核处理做准备pushq $__USER_DSpushq PER_CPU_VAR(rsp_scratch)pushq %r11pushq $__USER_CSpushq %rcx; 根据系统调用号,找内核处理函数movq %rax, %rdi ; 系统调用号给 %rdicall *sys_call_table(, %rax, 8)
虽然我也看不懂汇编代码,但这一步,就像 “安检 + 登记”:切换到内核专属的栈空间,保存用户态信息,然后根据 %rax
里的系统调用号(mmap
是 9 ),到 系统调用表(sys_call_table
) 里找对应的内核处理函数 sys_mmap
。
(二)系统调用表:“通讯录” 式的映射
内核维护的 sys_call_table
,是一个函数指针数组,把每个系统调用号对应到内核具体处理函数:
// 简化示意
const sys_call_ptr_t sys_call_table[] =
{[__NR_read] = sys_read,[__NR_write] = sys_write,[__NR_mmap] = sys_mmap, // mmap 对应内核函数 sys_mmap// 其他系统调用...
};
虽然这里的数组下标都是一些大写字母,但他其实是宏定义的数字,与我们平常使用的数组并无本质区别。
执行 call *sys_call_table(, %rax, 8)
,就是根据 %rax
里的调用号(9 ),找到并调用 sys_mmap
,正式进入内核态的 mmap
处理逻辑。
三、内核态初步处理:sys_mmap
与 sys_mmap_pgoff
(一)sys_mmap
:参数校验与转换
sys_mmap
是内核处理 mmap
的 “第一站”,主要做这些事:
// mm/mmap.c 简化版
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,unsigned long, prot, unsigned long, flags,unsigned long, fd, unsigned long, off)
{struct file *file = NULL;int error;// 处理文件映射(非匿名映射,如 MAP_ANONYMOUS 则无文件)if (!(flags & MAP_ANONYMOUS)) {file = fget(fd); // 通过文件描述符取 file 结构体if (!file) return -EBADF;}// 严格校验:偏移量必须是页(PAGE_SIZE ,通常 4096 )的整数倍if (off & ~PAGE_MASK) {error = -EINVAL;goto out;}// 转换偏移量为页单位,调用下一层 sys_mmap_pgofferror = sys_mmap_pgoff(addr, len, prot, flags, file, off >> PAGE_SHIFT);
out:if (file) fput(file); // 释放文件引用return error;
}
这里主要做了以下这些事情:
1.创建管理文件的结构体struct file。
2.将文件描述结构体struct file和文件描述符fd进行关联。
3.偏移量校验,确保偏移量off是页的整数倍,如果不是整数倍就设置错误码,并返回。
4.调用sys_mmap_pgoff。把字节偏移量转换为页偏移量,即直接将off>>PAGE_SHIFT,并进行后续的处理过程
(2)sys_mmap_pgoff
:页粒度处理
我们来看看sys_mmap_pgoff具体做了什么事情:
SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len,unsigned long, prot, unsigned long, flags,struct file *, file, unsigned long, pgoff)
{struct mm_struct *mm = current->mm; // 当前进程内存描述符unsigned long retval;// 加锁保护进程地址空间,避免并发修改down_write(&mm->mmap_sem);retval = do_mmap_pgoff(file, addr, len, prot, flags, pgoff);up_write(&mm->mmap_sem); return retval;
}
(1)先通过
current->mm
拿到当前进程的 内存描述符(mm_struct
) ,它记录了进程地址空间的所有信息(比如已映射的内存区域、堆 / 栈位置等 )。(2)然后,加锁(
down_write
)保护进程地址空间。(防止多线程 / 进程并发修改出问题 ),调用do_mmap_pgoff
,进入更核心的内存映射流程。
其中mmap_sem是mm_struct结构体中的一个信号量,用于控制对进程虚拟内存空间的并发访问,确保线程安全。down_write和up_write就是获取读写信号量的写锁和释放写锁。
四、内存映射核心流程:从 do_mmap_pgoff
到 mmap_region
sys_mmap_pgoff
之后,会根据映射类型(文件映射、匿名巨型页映射等 ),进入不同分支,最终汇聚到 do_mmap
、mmap_region
等函数。这里以文件映射为例,拆解关键步骤:
(一)do_mmap_pgoff
:统筹调度
主要做合法性校验:长度不能为 0 ,不能超过进程地址空间最大可映射范围(
TASK_SIZE - mm->mmap_base
)。校验通过后,调用do_mmap
,进入地址分配与映射的核心逻辑。
unsigned long do_mmap_pgoff(struct file *file, unsigned long addr,unsigned long len, unsigned long prot,unsigned long flags, unsigned long pgoff)
{struct mm_struct *mm = current->mm;unsigned long retval;// 检查长度是否合法(不能为 0 ,不能超进程地址空间上限 )if (!len) return 0;if (len > TASK_SIZE - mm->mmap_base) return -ENOMEM;// 调用 do_mmap ,进一步处理retval = do_mmap(file, addr, len, prot, flags, pgoff);return retval;
}
(二)do_mmap
:地址分配与准备
unsigned long do_mmap(struct file *file, unsigned long addr,unsigned long len, unsigned long prot,unsigned long flags, unsigned long pgoff)
{// 计算/分配合适的虚拟地址addr = get_unmapped_area(file, addr, len, pgoff, flags);if (IS_ERR_VALUE(addr)) return addr;// 创建虚拟内存区域(VMA )return mmap_region(mm, file, addr, len, prot, flags, pgoff);
}
这一步分两大步骤:
get_unmapped_area
:找一块未被映射的虚拟地址区间。如果应用层传了addr=NULL
,内核会自动选一个合适的地址;如果传了具体地址,会检查该地址及后续区域是否可用,确保不会和已有映射冲突。mmap_region
:真正创建虚拟内存区域(VMA ) ,把文件和内存的映射关系 “落地”。
(三)mmap_region
:构建 vm_area_struct ,完成映射
static unsigned long mmap_region(struct mm_struct *mm, struct file *file,unsigned long addr, unsigned long len,unsigned long prot, unsigned long flags,unsigned long pgoff)
{struct vm_area_struct *vma;unsigned long vm_flags;// 分配 VMA 结构体vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);if (!vma) return -ENOMEM;// 初始化 VMA 关键属性vma->vm_mm = mm; // 关联进程内存描述符vma->vm_start = addr; // 映射起始地址vma->vm_end = addr + len; // 映射结束地址// 计算内存权限标志(如可读、可写、可执行等 )vm_flags = calc_vm_prot_bits(prot) | calc_vm_flag_bits(flags);vma->vm_flags = vm_flags; vma->vm_page_prot = vm_get_page_prot(prot);vma->vm_pgoff = pgoff; // 页粒度的偏移量// 关联文件(文件映射时 )if (file) {vma->vm_file = get_file(file);vma->vm_ops = &file->f_op->mmap; // 设置文件操作方法}// 将 VMA 插入进程地址空间if (insert_vm_struct(mm, vma)) {kmem_cache_free(vm_area_cachep, vma);return -ENOMEM;}return addr;
}
这是
mmap
在内核最核心的一步,做了以下事情:
- 分配 VMA 结构体:
VMA
(Virtual Memory Area )是内核管理进程虚拟内存的 “基本单元”,每个VMA
描述一段连续的虚拟地址区间,以及它的权限、关联文件等信息。- 初始化属性:把映射的地址范围、权限、文件关联(如果是文件映射 )等信息,填到
VMA
里。比如,vm_flags
会标识这段内存是可读、可写还是可执行,是否是共享映射等。- 插入地址空间:通过
insert_vm_struct
,把新建的VMA
插入到进程的mm_struct
管理的地址空间中。这样,进程后续访问这段虚拟地址时,内核就知道该怎么处理(比如从关联文件读数据、检查权限等 )。
六、总结:理解 mmap
陷入内核的意义
通过这样逐层拆解,能清晰看到:mmap
从用户态到内核态,是一个 “需求传递 + 权限切换 + 内核逐步处理” 的过程。每一层函数都有明确职责(校验、加锁、地址分配、构建 VMA 等 ),最终通过创建 VMA
,让进程拥有了一段 “特殊” 的虚拟内存 —— 访问它时,内核会根据 VMA
的设置,完成文件与内存的数据交换、权限检查等工作。
联系到之前的mm_struct、vm_area_struct等:
open打开文件并不会创建vm_area_struct,仅仅会创建file结构体,插入到files_struct中。这也是为什么我们使用普通的read、write函数要多次拷贝的问题。而使用了文件映射区后才会创建对应的VMA区域,(从源码中可以看到,管理vm_area_struct的链表的名字就叫做mmap)从而直接在虚拟内存中读写文件,提高了便捷性。
理解这个过程,不仅能掌握 mmap
本身的原理,也能窥见 Linux 内核 “分层处理”“权限隔离” 的设计思想:用户态提出需求,内核态安全、有序地完成复杂的资源管理工作,保障系统稳定又能灵活响应用户请求。
七、补充说明文件映射、匿名映射的区别
(1)为什么mmap只能映射已经打开的文件
mmap必须依赖open得到的fd。即mmap只能映射已经打开的文件。我们说文件都是存放于磁盘中的,一个进程打开了该文件才会在内核数据结构中创建struct file结构体,才会存放于文件描述符表中。试想一下,一文件没有打开,内核中根本就没有fd,何来的mmap呢?
不过有一种特殊情况,匿名映射并没有存在于硬盘的实体,不关联任何磁盘文件,不需要fd和struct file来刻画描述其文件状态。所以在使用mmap的时候传入的fd为-1。
(2)对于两个进程如何使用匿名映射呢?
我们知道对于一般的文件映射,因为有真实存在于磁盘中的文件实体,所以可以让两个进程都打开该文件,然后直接映射。不管进程打开多少次文件,只要是文件的同一页,则只会在物理内存中打开一份,从而做到了多个进程共享一份内存资源,实现文件映射。
然而对于匿名映射,因为没有文件实体,如何传递文件文件描述符就成为了一个大问题。在父子进程中,由于子进程会写时拷贝父进程的pcb中的一切,所以天然就有:父进程创建了一个匿名映射,子进程可以直接拿到指针使用,兄弟进程同理。
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>#define MAP_SIZE 4096 // 映射区域大小,一般设置为页大小的整数倍,这里取一个页的大小int main() {// 使用mmap创建匿名映射区,PROT_READ | PROT_WRITE表示可读可写,// MAP_ANONYMOUS | MAP_PRIVATE表示匿名映射且是私有映射,fd为-1表示不关联文件char *shared_memory = (char *)mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);if (shared_memory == MAP_FAILED) {perror("mmap");return EXIT_FAILURE;}pid_t pid = fork();if (pid == -1) {perror("fork");// 释放映射区if (munmap(shared_memory, MAP_SIZE) == -1) {perror("munmap");}return EXIT_FAILURE;} else if (pid == 0) {// 子进程printf("子进程:在修改前,共享内存的值为:%s\n", shared_memory);// 子进程修改共享内存strcpy(shared_memory, "子进程修改后的内容");printf("子进程:修改后,共享内存的值为:%s\n", shared_memory);} else {// 父进程sleep(1); // 等待子进程先执行,确保能观察到写时拷贝的效果printf("父进程:子进程修改后,自己看到的共享内存的值为:%s\n", shared_memory);// 释放映射区if (munmap(shared_memory, MAP_SIZE) == -1) {perror("munmap");}}return EXIT_SUCCESS;
}
可以看到这里子进程直接就使用了父进程的指针shared_memory,而非亲缘关系的进程由于不会写时拷贝pcb,则还需要通过socket套接字来传递文件描述符,再通过一系列操作得到读写指针。