当前位置: 首页 > news >正文

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 )提供的封装函数。它的作用是:

  1. 参数校验:简单检查传入参数是否合理,比如偏移量是否符合基本规则(后续内核会做更严格校验)。
  2. 准备系统调用:把应用层参数整理成内核能识别的格式,然后通过 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);
}

这一步分两大步骤:

  1. get_unmapped_area:找一块未被映射的虚拟地址区间。如果应用层传了 addr=NULL ,内核会自动选一个合适的地址;如果传了具体地址,会检查该地址及后续区域是否可用,确保不会和已有映射冲突。
  2. 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 在内核最核心的一步,做了以下事情:

  1. 分配 VMA 结构体VMA(Virtual Memory Area )是内核管理进程虚拟内存的 “基本单元”,每个 VMA 描述一段连续的虚拟地址区间,以及它的权限、关联文件等信息。
  2. 初始化属性:把映射的地址范围、权限、文件关联(如果是文件映射 )等信息,填到 VMA 里。比如,vm_flags 会标识这段内存是可读、可写还是可执行,是否是共享映射等。
  3. 插入地址空间:通过 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套接字来传递文件描述符,再通过一系列操作得到读写指针。

http://www.xdnf.cn/news/1200691.html

相关文章:

  • 依赖倒置原则 Dependency Inversion Principle - DIP
  • 不坑盒子突然不见了怎么办?
  • VILA系列论文解读
  • 详细解释一个ros的CMakeLists.txt文件
  • AI大模型前沿:Muyan-TTS开源零样本语音合成技术解析
  • 自然语言处理NLP (1)
  • 【I】题目解析
  • vmware虚拟机中显示“网络电缆被拔出“的解决方法
  • rust-包和箱子
  • RHEL9 网络配置入门:IP 显示、主机名修改与配置文件解析
  • 电动汽车转向系统及其工作原理
  • 8.c语言指针
  • Web开发系列-第0章 Web介绍
  • SQL注入SQLi-LABS 靶场less21-25详细通关攻略
  • Ubuntu普通用户环境异常问题
  • 数学建模——灰色关联分析
  • 三、构建一个Agent
  • OpenCv中的 KNN 算法实现手写数字的识别
  • 消息队列MQ常见问题和解决方案
  • Java面试全攻略:Spring生态与微服务架构实战
  • 新手开发 App,容易陷入哪些误区?
  • Android:Reverse 实战 part 2 番外 IDA python
  • SignalR 全解析:核心原理、适用场景与 Vue + .NET Core 实战
  • [电网备考]计算机组成与原理
  • Vue 四个map的使用方法
  • Mysql 二进制安装常见问题
  • 设备独立性软件-高速缓存与缓冲区
  • GIF图像格式
  • 水稻调控组全景的综合绘制与建模揭示了复杂性状背后的调控架构
  • springboot基于Java的人力资源管理系统设计与实现