4. mmap 系统调用流程:从用户态到内核的完整路径

mmap 系统调用是用户空间与内核空间之间建立直接内存映射的桥梁。理解其完整调用流程,对于编写正确的驱动 mmap 实现至关重要。本章将沿着用户态 mmap() → 内核 sys_mmap → do_mmap → 驱动 mmap 回调这条主线,逐层剖析每个阶段的核心职责与数据流转。

核心要点:mmap 调用链的每一层都在做“降维”处理——用户态传递虚拟地址和长度,内核将其转换为 VMA 结构,驱动最终负责将物理内存页与 VMA 建立映射关系。

4.1 用户态 mmap 调用:入口与参数

用户态程序通过 C 库封装的 mmap() 函数发起调用,其原型定义在 <sys/mman.h> 中:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

六个参数分别控制映射的地址、大小、权限、行为、文件描述符和偏移量。下表总结了每个参数的作用与典型取值:

参数 含义 典型值 / 说明
addr 建议起始虚拟地址 通常传 NULL,由内核选择
length 映射区域大小(字节) 必须是页大小(4KB)的整数倍
prot 内存保护标志 PROT_READ、PROT_WRITE、PROT_EXEC 的组合
flags 映射类型与行为 MAP_SHARED 或 MAP_PRIVATE,驱动常用 MAP_SHARED
fd 文件描述符 指向通过 open() 打开的驱动设备文件
offset 文件偏移量 驱动中通常为 0,或表示物理页帧号
驱动开发者提示:用户态传入的 offset 参数在驱动 mmap 回调中会以 vma->vm_pgoff 的形式出现。很多驱动利用这个字段传递物理页帧号(PFN),从而让用户态选择映射哪一页物理内存。

用户态调用 mmap() 后,C 库会执行 syscall 指令,陷入内核,进入系统调用处理程序。在 x86-64 架构上,系统调用号 9 对应 sys_mmap。

4.2 内核 sys_mmap:系统调用入口

内核中 sys_mmap 是 mmap 系统调用的入口函数。在较新内核版本中,它通常定义为 SYSCALL_DEFINE6(mmap, ...)。其核心工作是将用户态参数转换为内核内部结构,并调用 do_mmap()。

// 简化后的 sys_mmap 逻辑(arch/x86/kernel/sys_x86_64.c)
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
                unsigned long, prot, unsigned long, flags,
                unsigned long, fd, unsigned long, off)
{
    // 1. 参数合法性检查(长度对齐、权限校验等)
    if (offset_in_page(off))
        return -EINVAL;

    // 2. 通过 fd 获取 struct file 指针
    struct fd f = fdget(fd);
    struct file *file = f.file;

    // 3. 调用 do_mmap 完成核心映射工作
    unsigned long ret = do_mmap(file, addr, len, prot, flags, off);

    fdput(f);
    return ret;
}

sys_mmap 的关键职责可以归纳为三点:

  • 参数转换:将用户态传入的 fd(整数)转换为内核的 struct file * 指针。
  • 合法性校验:检查偏移量是否页对齐、长度是否合理、权限是否冲突。
  • 委托执行:将实际工作交给 do_mmap(),自身不做映射决策。
注意:在 32 位和 64 位系统上,sys_mmap 的实现略有差异。64 位系统通常使用 SYSCALL_DEFINE6 直接定义,而 32 位系统可能通过 sys_mmap2 处理偏移量以字节为单位的差异。

4.3 do_mmap:虚拟地址空间分配

do_mmap() 是 mmap 流程中最重要的内核函数之一,位于 mm/mmap.c。它负责在进程的虚拟地址空间中分配一块合适的区域,并创建对应的 struct vm_area_struct(VMA)结构。

// do_mmap 核心流程(mm/mmap.c,高度简化)
unsigned long do_mmap(struct file *file, unsigned long addr,
                      unsigned long len, unsigned long prot,
                      unsigned long flags, unsigned long offset)
{
    struct mm_struct *mm = current->mm;
    struct vm_area_struct *vma;

    // 1. 获取地址空间写锁
    if (mmap_write_lock_killable(mm))
        return -EINTR;

    // 2. 查找或分配虚拟地址区间
    addr = get_unmapped_area(file, addr, len, offset, flags);

    // 3. 分配并初始化 VMA 结构
    vma = vm_area_alloc(mm);
    vma->vm_start = addr;
    vma->vm_end = addr + len;
    vma->vm_flags = calc_vm_prot_bits(prot) | calc_vm_flag_bits(flags);
    vma->vm_file = get_file(file);
    vma->vm_pgoff = offset >> PAGE_SHIFT;

    // 4. 调用文件操作中的 mmap 回调(关键步骤)
    if (file) {
        ret = call_mmap(file, vma);
        if (ret)
            goto unmap_and_free_vma;
    }

    // 5. 将 VMA 插入进程的红黑树和链表
    vma_link(mm, vma, prev, rb_link, rb_parent);

    mmap_write_unlock(mm);
    return addr;
}

do_mmap() 的执行流程可以概括为以下五个阶段:

  1. 地址空间锁定:获取进程内存描述符 mm 的写锁,防止并发修改。
  2. 地址分配:调用 get_unmapped_area() 在虚拟地址空间中查找空闲区间。
  3. VMA 创建:分配并初始化 VMA 结构,设置起始地址、结束地址、标志位和文件信息。
  4. 驱动回调:如果映射关联了文件(设备驱动),调用 file->f_op->mmap()。
  5. VMA 注册:将 VMA 插入进程的 VMA 红黑树和双向链表,完成地址空间管理。
关键理解:在 do_mmap() 的第 4 步中,内核会调用 call_mmap(file, vma),这个宏最终会执行 file->f_op->mmap(file, vma)。这就是驱动 mmap 回调被调用的时刻。如果驱动没有实现 mmap 回调,内核会返回 -ENODEV。

4.4 文件操作中的 mmap 回调:驱动的舞台

当 do_mmap() 调用 call_mmap() 时,控制权转移到驱动实现的 file_operations 中的 mmap 方法。这是驱动开发者唯一需要关注的函数,其原型为:

int (*mmap)(struct file *filp, struct vm_area_struct *vma);

驱动 mmap 回调接收两个参数:

  • filp:指向已打开的设备的 struct file 结构,驱动可以通过 filp->private_data 获取设备私有数据。
  • vma:指向内核已分配好的虚拟内存区域结构,驱动需要在这个 VMA 上建立物理内存映射。

驱动 mmap 回调的标准实现模式如下:

static int my_device_mmap(struct file *filp, struct vm_area_struct *vma)
{
    unsigned long pfn;       // 物理页帧号
    unsigned long size;      // 映射大小
    struct my_device *dev = filp->private_data;

    // 1. 获取映射大小(页对齐)
    size = vma->vm_end - vma->vm_start;

    // 2. 确定要映射的物理页帧号
    //    可以从 vma->vm_pgoff 获取,也可以从设备结构获取
    pfn = virt_to_phys(dev->buffer) >> PAGE_SHIFT;

    // 3. 设置 VMA 的操作函数集(可选,用于处理缺页中断)
    vma->vm_ops = &my_device_vm_ops;

    // 4. 建立页表映射(核心操作)
    if (remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot))
        return -EAGAIN;

    return 0;
}

驱动 mmap 回调的核心职责可以总结为一张表格:

职责 说明 常用 API / 方法
确定物理页帧 决定将哪块物理内存映射给用户态 virt_to_phys()、__pa()、dma_alloc_coherent()
建立页表映射 在进程页表中插入物理地址到虚拟地址的映射 remap_pfn_range()、io_remap_pfn_range()
设置 VMA 操作 注册缺页处理、关闭通知等回调 vma->vm_ops 赋值
设置 VMA 标志 标记映射类型(可锁定、可混合映射等) vma->vm_flags 位操作
最佳实践:驱动 mmap 回调中不要进行内存分配或耗时操作。它运行在持有地址空间写锁的上下文中,阻塞会影响整个进程的内存管理。物理内存的分配应在设备打开或初始化时完成,mmap 回调只负责建立映射。

4.5 完整调用流程 SVG 架构图

下图展示了从用户态 mmap() 到驱动 mmap 回调的完整调用链,以及各层之间的数据传递关系:

mmap 系统调用完整流程 用户态:mmap() 调用 内核:sys_mmap(系统调用入口) 内核:do_mmap(VMA 创建与分配) ① 锁定地址空间 → ② 分配虚拟地址区间 ③ 创建 VMA 结构 → ④ 调用驱动 mmap 回调 ⑤ 将 VMA 插入进程红黑树 驱动:file_operations->mmap 回调 ① 获取物理页帧号(PFN) ② 设置 vma->vm_ops(可选) ③ 调用 remap_pfn_range() 建立页表映射 进程页表:虚拟地址 ↔ 物理地址映射 关键数据传递:fd → struct file → VMA → vma->vm_pgoff / vma->vm_start → remap_pfn_range

4.6 流程中的关键数据结构

在整个 mmap 调用链中,有几个核心数据结构贯穿始终。理解它们之间的关系,是掌握 mmap 流程的关键:

数据结构 所在层级 核心字段(与 mmap 相关) 作用
struct file 内核 VFS 层 f_op、private_data 代表打开的设备文件,提供操作函数表
struct vm_area_struct 内核内存管理 vm_start、vm_end、vm_pgoff、vm_flags、vm_ops 描述一段连续的虚拟地址空间
struct mm_struct 内核内存管理 mmap(红黑树根)、mmap_lock 进程级内存描述符,管理所有 VMA
struct vm_operations_struct 驱动层 open、close、fault VMA 的生命周期回调,驱动可自定义
流程总结:用户态 mmap() → sys_mmap 将 fd 转为 struct file → do_mmap 创建 struct vm_area_struct → 驱动 mmap 回调通过 remap_pfn_range() 在 VMA 上建立物理映射。驱动开发者只需关注最后一个环节,但理解前三个环节有助于调试和性能优化。

4.7 常见问题与避坑指南

  • mmap 回调未被调用:检查 file_operations 结构体中 .mmap 字段是否正确定义,且设备文件已通过 open() 打开。
  • remap_pfn_range 返回 -EINVAL:通常是因为 vma->vm_start 或 size 未页对齐,或者 pfn 超出了有效物理地址范围。
  • 用户态访问映射区域触发 SIGBUS:驱动 mmap 回调中未正确建立页表映射,或者物理内存已被释放。
  • 映射后写入不生效:检查 vma->vm_flags 是否包含 VM_WRITE 标志,以及用户态 prot 参数是否包含 PROT_WRITE。
安全提醒:驱动 mmap 回调中映射的物理内存必须是驱动自身管理的、生命周期可控的内存。切勿映射其他驱动或内核子系统已使用的物理页,否则可能导致内核崩溃或安全漏洞。