Linux 内核 GUP 与 FOLL_PIN:用户态 DMA 内存固定策略的工程深度剖析

Linux 内核 GUP 与 FOLL_PIN:用户态 DMA 内存固定策略的工程深度剖析

本文深入剖析 Linux 内核中 get_user_pages() 系列 API 与 FOLL_PIN 标志位在用户态 DMA 场景下的工程实践。从物理页固定生命周期管理,到 io_uring 固定 buffer、SPDK 内存注册、GPUDirect RDMA 的生产实现,帮你打通高吞吐零拷贝系统中最关键的底层环节。

一、为什么需要内存固定

在 Linux 的虚拟内存系统中,用户空间的虚拟地址经过页表翻译后才能得到物理页帧(PFN)。用户态进程持有的只是一个"映射承诺",内核随时可能因为内存压力把这些页面换出(swap),或者因为内存碎片整理把页面搬迁移位(memory compaction / CMA migration)。

这对 DMA 场景是致命的。

DMA 控制器是硬件,它不经过 CPU、不触发缺页中断。当你把一个虚拟地址对应的物理页编程到 DMA 描述符里,硬件就开始传输数据了。如果内核在此期间把该页迁移或换出,DMA 就会写到错误的物理页——可能造成数据损坏、安全漏洞(读取其他进程的内存)、甚至内核崩溃。

内存固定(Memory Pinning)就是为了解决这个问题:让内核"承诺"一组物理页在固定期间不会被迁移、换出或释放,从而安全地把物理地址交给 DMA 控制器使用。

需要内存固定的典型场景包括:

  • SPDK / DPDK:用户态 NVMe / 网卡驱动直接操作物理页
  • GPUDirect RDMA:GPU 显存与 RDMA 网卡之间直接传输,不需要经过 CPU 拷贝
  • io_uring 固定 buffer:IORING_REGISTER_BUFFERS 底层就是一组被固定的页
  • KVM 设备直通:把用户态分配的设备内存映射到虚拟机
  • eBPF MAP pinned 文件:持久化 BPF 对象到 sysfs

二、FOLL_GET 的历史包袱

在内核 4.9 之前,页固定通过 get_user_pages()(GUP)实现,配合 FOLL_GET 标志来增加页引用计数。FOLL_GET 的逻辑很简单:对被固定的每个 struct page 调用 get_page(),把 _count 引用计数 +1,防止 put_page() 释放该页。

但 FOLL_GET 有一个极其微妙的缺陷:它不能防止页面迁移。

举个例子:CMA(连续内存分配器)或 transparent huge page 子系统在内存整理时,会把页面迁移到新位置:取出旧页内容、写到新页、然后更新所有映射该旧页的页表指向新物理页。这一步不修改引用计数,只是在 PTE 里把旧 PFN 替换成新 PFN。对于普通内存访问,这是透明的——CPU 下次访问时走页表翻译,自动看到新页。但对于 DMA 场景,硬件已经拿到了旧 PFN,DMA 访问的就是已经被释放或重用的旧页。

更糟的是 FOLL_GET 还会造成类似"页面被盗"(page stealing)的长尾延迟问题。当内存紧张时,内核的页面回收(page reclaim)机制会扫描 LRU 链表找候选页。引用计数为 0 的 inactive 页是好候选;但 FOLL_GET 持有的页面即使长时间不被 DMA 使用,也因为引用计数 > 0 而无法回收,白白浪费内存。内核开发者 Shigeru Yoshida 在 2019 年的一个 patchset 中把这类问题称为"长期持有的 pin"(longterm pin)。

三、FOLL_PIN:从"引用计数"到"专用计数"

为了解决上述问题,内核 4.9+ 引入了 FOLL_PIN 标志位,同时在 struct page 上新增了 _pincount 专用字段。

关键设计变更:

旧模型 FOLL_GET 新模型 FOLL_PIN
使用 _count(通用引用计数) 使用 _pincount(专用 pin 计数)
不区分"是谁 pin 住" 记录 pin 的 owner(struct device)
无法阻止 page migration page_migration_pre pin() 检查
占用通用 refcount,影响 reclaim 页面仍可标记为不可回收但可被识别
无法清理孤儿 pin 有 page_pinner 机制做泄漏检测

FOLL_PIN 的核心 API 演进:

// 旧 API(4.9 前)
int get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
                   unsigned long start, unsigned long nr_pages,
                   int gup_flags, struct page **pages, struct vm_area_struct **vmas);

// 新 API:细粒度 pin
int pin_user_pages(unsigned long start, unsigned long nr_pages,
                   unsigned int gup_flags, struct page **pages);
int pin_user_pages_fast(unsigned long start, unsigned long nr_pages,
                        int gup_flags, struct page **pages);

// 解除 pin
void unpin_page(struct page *page);
void unpin_user_pages(struct page **pages, unsigned int npages);

pin_user_pages_fast() 是 fast path,不持有 mmap_lock,只读已有页表项,适合已经在 page cache / anon LRU 中的热页场景。如果 fast path fallback 找不到页,就必须调用 slow path pin_user_pages() 来走完整的 GUP:分配新页、处理 COW、处理 HugePage 等。

四、Slow path 的工程陷阱

fast path 是 gup_fast(),本质上是用户态编程流程的 MMU 化:

user VA → PGD → P4D → PUD → PMD → PTE → struct page *

fast path 必须持有 rcu_read_lock()(防止 page table 并发释放),逐级读页表项并做 pte_present() / pmd_trans_huge() 检查。每一步都有失败的可能:

  • 页表项未分配(缺页)→ fallback 到 slow path
  • 巨大的透明大页(THP)被拆分 → 需降级处理
  • NUMA 页面已标记为迁移候选 → FOLL_PIN 会阻塞

Slow path pin_user_pages() 则是重量级操作,工程上常见陷阱:

陷阱一:mlock 限制绕过

pin_user_pages() 在 FOLL_LONGTERM(长期 pin)场景下会检查 RLIMIT_MEMLOCK。可以通过 /etc/security/limits.conf 提升限制,或者改用 MAP_LOCKED mmap 来预锁定。但 RLIMIT 检查的是 total locked bytes,对大数据量固定(比如 GPU 把全部显存 pin 成不透明内存)会造成秒级的延迟抖动——因为 GUP slow path 需要逐页调用 follow_page_mask()。

陷阱二:mmap_sem 竞争

slow path 需要持有 mm->mmap_lock(旧称 mmap_sem)。在多线程同时做 pin 的进程中(典型的 SPDK reactor 模式),mmap_lock 的写锁竞争会导致系统调用路径上的严重延迟抖动。内核 5.8+ 已改为 rwsem 但仍无法避免写锁争用。解决方案:

  • 提前用 MAP_POPULATE mmap 把所有页预分配好
  • 用 madvise(MADV_DONTNEED) 释放冷页,避免 swap 时的额外 pin/unpin
  • 采用 io_uring 的 IORING_REGISTER_BUFFERS,一次性固定、全生命周期使用

陷阱三:脏页与回写窗口

如果被 pin 的文件映射页(page cache)被标记 dirty,FOLL_WRITE 的 pin 操作会触发 balance_dirty_pages_ratelimited() 把页面写入磁盘,避免超出脏页阈值。这会带来 100ms+ 的不可预测延迟。在 SPDK 的生产实践中推荐用 anonymous mmap(MAP_ANONYMOUS)分配 pinned buffer,避免与文件系统回写的耦合。

五、GPUDirect RDMA 中的 FOLL_PIN 工程范式

GPUDirect RDMA 是 NVIDIA 推出的直传技术,让 GPU 显存可以直接和 RDMA 网卡互相传输数据,不需要 CPU 中转。CUDA 底层的 cuMemRegister() / cuMemHostRegister() 调用的就是 FOLL_PIN 体系。

其关键差异在于引入了"owner"概念:每个 pin 的 page 必须关联到一个 struct device(GPU 设备或 RDMA 设备)。当多个设备共享同一块固定内存时,kernel 通过 page_pinner 机制追踪哪些设备持有了 pin。如果 RDMA 设备注册了一块内存但 GPU 还没 pin,突然 GPU 要求长期 pin,就会在 NUMA 拓扑检查中失败(因为 RDMA 设备 NUMA node 与 GPU 不同)。生产中的最佳实践是:

  1. 所有 device memory 注册顺序遵循 RDMA → GPU,避免反向依赖
  2. 使用 ibv_reg_mr() 的 IBV_ACCESS_ON_DEMAND 标记做按需 pin(pinned page pool)
  3. 通过 cgroups memory limit 提前预留固定内存上限,避免达到 limit 后的内核 page reclaim 死锁

六、io_uring 固定 buffer 的底层实现

IORING_REGISTER_BUFFERS 是 io_uring 在 5.1 引入的固定 buffer 机制。它调用的是 io_sqe_buffer_register() → pin_user_pages() → io_pin_pages()。一个关键设计是:io_uring 的 fixed buffer 不标记 FOLL_LONGTERM,只在 SQE 提交持有期间 pin,避免长期绑定 mm 的工程隐患。

用户态提交 IORING_OP_READ_FIXED / WRITE_FIXED
    → io_uring 从 pre-pinned pool 取 page
    → 无需每次 system call 都做 GUP
    → 节省约 0.5-2us / 调用

对于统一 buffer pool + 多次 IO 的场景,zero-copy 零拷贝的工程放大效应非常明显:10GbE line-rate 下每 1us 抖动就少发 1200 字节。

七、生产环境实战代码:SPDK 风格的固定内存分配

下面是一个完整工程示例,演示如何优雅地分配固定内存池供 SPDK 风格的用户态驱动使用:

#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

/* 分配 2MB 对齐的 huge page 并 pin */
int spdk_pinned_alloc(void **buf, size_t *sz) {
    const size_t LEN = 2 * 1024 * 1024;  // 2MB
    const size_t ALIGN = 2 * 1024 * 1024;

    /* 1. MAP_POPULATE 预分配所有页表项,避免后续首次访问缺页 */
    /* 2. MAP_HUGETLB 请求大页,降低 TLB miss */
    /* 3. MAP_LOCKED 用 mlock 绕过 pin 时的 RLIMIT 检查 */
    *buf = mmap(NULL, LEN, PROT_READ | PROT_WRITE,
                MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE |
                MAP_HUGETLB | MAP_LOCKED,
                -1, 0);
    if (*buf == MAP_FAILED) {
        perror("mmap MAP_HUGETLB failed");
        return -1;
    }

    /* 4. 用 mlock 兜底,确保不会被 swap */
    if (mlock(*buf, LEN) != 0) {
        perror("mlock failed, try increasing memlimit");
        munmap(*buf, LEN);
        return -1;
    }

    /* 5. mincore 确认所有页驻留物理内存 */
    unsigned char vec[LEN / 4096];
    if (mincore(*buf, LEN, vec) == 0) {
        for (size_t i = 0; i < LEN / 4096; i++) {
            if (!(vec[i] & 1)) {
                fprintf(stderr, "page %zu not in core\n", i);
            }
        }
    }

    *sz = LEN;
    return 0;
}

这个例子的关键设计点:

  • MAP_POPULATE:一次性建立所有 PTE,避免首次 access 时的缺页中断(page fault)。这对 latency-critical 用户态驱动至关重要。
  • MAP_HUGETLB:2MB 大页把 TLB miss 降低 512 倍。SPDK 的 NVMe 队列个数 × queue depth 典型为 64×256 = 16384 个 SQE,在 4K 小页下 TLB 压力大,巨页直接解决问题。
  • mincore:在生产系统中作为"烟雾测试",确保所有页 indeed pinned,避免因 RLIMIT 或 cgroup 限制导致隐式 unpin。

八、内核 6.x 的新进展

Linux 6.2+ 进一步优化了 FOLL_PIN:

  • **pin_user_pages() 支持 GUP_PIN_NO_ACCESS`:纯 pin 不建立用户态映射,减少误用风险
  • page migration 时如果 _pincount > 0,会直接返回 -EBUSY,这让 OOM killer 路径更可靠
  • mm: per-VMA pin 统计:/proc/<pid>/smaps 新增 Pinned: 字段,方便定位内存泄漏
  • vaddr_get_pfns() API 收敛:16 个参数逐步合并为 struct gup_args,可读性大幅提升

6.8 LTS 即将引入的一个关键修复是 FOLL_LONGTERM 与 NUMA balancing 的冲突:过去内核会把长期 pin 的页面纳入 NUMA page migration 扫描,时不时导致 <kernel>: do_migrate_pages: page X is pinned 的误报日志。修复后 longterm pin 的页面排除在 NUMA balancing 的扫描范围外,减少不必要的内核 printk 告警。

九、总结

FOLL_PIN 远不止是一个内核标志位——它是现代高吞吐零拷贝系统最底层的契约。无论是 SPDK 的用户态存储栈、NVIDIA 的 GPUDirect RDMA、还是 io_uring 的预注册 buffer pool,都依赖它保证物理页生命周期的确定性。

工程层面记住三条铁律:

  1. 快速路径好过慢路径:能用 pin_user_pages_fast / MAP_POPULATE 预分配的,永远不要走 slow path
  2. 长期 pin 必须记录 owner:不要留孤儿 pin,它会阻塞页面迁移、触发内核告警、拖慢 page reclaim
  3. 固定前测量、回收后校验:用 mincore + /proc/<pid>/smaps Pinned: 做生产可观测性

当你下次在 SPDK、io_uring 或 GPUDirect 的接口里看到"正在初始化 buffer pool,预计耗时 X ms"——恭喜,那就是 FOLL_PIN slow path 的签名。


关键词:FOLL_PIN get_user_pages SPDK io_uring GPUDirect RDMA memory pinning DMA zero-copy

参考资料:

  • Linux 内核文档 gup.c 注释
  • SPDK Memory Pinning Best Practices
  • NVIDIA GPUDirect RDMA Programming Guide (CUDA 12.x)
  • io_uring-demo: man 2 io_uring_register
  • LWN: "Long-term page pinning" series (2019-2021)
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部