☰
Linux驱动mmap内存映射:分离思想与性能优化实战
2026/10/14 1:05:39 网站建设 项目流程

1. 从一次字符设备驱动的"内存拷贝"困境说起

很多做过字符设备驱动的朋友都有过类似的经历:应用层要读取设备采集的一批数据,驱动里用copy_to_user老老实实拷一遍,数据量小的时候一切正常,等到单次传输量上到几兆甚至几十兆,CPU 占用率立刻飙升,帧率掉得厉害,延迟也上来了。你盯着top里那个被memcpy吃满的核心,心里明白问题出在哪——同一份数据被搬了两遍,一次从设备到内核缓冲区,一次从内核缓冲区到用户空间。

mmap就是为这类场景准备的。它让用户空间直接拿到一段映射到设备缓冲区的虚拟地址,读写这段地址就等于读写设备内存,中间那次拷贝被彻底省掉。听起来很美好,但真正动手写的时候,坑一个接一个:remap_pfn_range和nopage/fault到底该用哪个?vm_pgoff怎么参与偏移计算?为什么映射出来的地址一访问就 oops?为什么mmap成功了但数据是错的?

这篇内容就围绕 Linux 内核驱动里的mmap机制展开,重点不是把 API 罗列一遍,而是讲清楚内核编程里的分离思想——把"地址映射"和"内存分配"这两件事拆开,各司其职。这个思想贯穿整个mmap实现,理解了它,上面那些坑基本都能自己推出来。适合已经写过简单字符设备驱动、想进一步搞懂内存映射的开发者,也适合正在被大数据量传输性能问题困扰的工程师。

2. mmap 到底省掉了什么:一次映射背后的三层地址转换

2.1 从 write/read 到 mmap 的本质差异

先把这个机制的价值说透。传统的read/write路径是这样的:用户调用read(fd, buf, len),内核里驱动实现.read,用copy_to_user把数据从内核空间搬到用户空间。这里有个隐含前提——数据必须先存在于内核能访问的地方,要么是驱动自己kmalloc的缓冲区,要么是 DMA 映射后的内存。于是数据流是"设备 → 内核缓冲区 → 用户缓冲区",两次搬运。

mmap走的是另一条路。用户调用mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, offset),内核为这段虚拟地址建立页表项,直接指向设备缓冲区的物理页。之后用户读写这段地址,MMU 直接把虚拟地址翻译成设备缓冲区的物理地址,CPU 访问的就是那块内存本身。数据流变成"设备 ↔ 共享内存 ↔ 用户",中间那次拷贝没了。

注意:mmap省掉的是 CPU 拷贝,不是所有开销。缺页异常、TLB 填充、缓存一致性这些依然存在,只是在大块连续数据传输场景下,省掉memcpy的收益远大于这些固定开销。

2.2 三层地址:虚拟地址、物理地址、总线地址

要理解mmap实现,必须先把三层地址分清楚,这是后面所有代码的基础。

  • 虚拟地址:CPU 执行指令时用的地址,经过 MMU 翻译。用户空间和内核空间各有自己的虚拟地址空间。
  • 物理地址:内存条上真实的地址,MMU 翻译的目标。
  • 总线地址:设备视角看到的地址,DMA 控制器用的就是这种地址。在多数平台上物理地址和总线地址数值相同,但在有 IOMMU 或某些嵌入式平台上两者不同。

mmap的核心工作,就是把一段用户虚拟地址的页表项,填成指向某块物理内存。用户拿到虚拟地址,CPU 访问时 MMU 自动翻译,设备那边通过 DMA 或直接访问操作同一块物理内存,两边就"看见"了同一份数据。

2.3 为什么必须分离:映射逻辑与分配逻辑的解耦

这里引出本文的核心——分离思想。很多初学者写mmap时喜欢把两件事揉在一起:在.mmap回调里现场分配内存,然后立刻映射。代码能跑,但埋了一堆雷。

正确的做法是把职责拆开:

职责归属典型实现
内存分配与管理驱动初始化/业务逻辑kmalloc、dma_alloc_coherent、预留内存池
地址映射.mmap回调remap_pfn_range、vmf_insert_pfn
生命周期管理引用计数/释放回调vma->vm_ops->open/close

为什么这样拆?因为内存的分配时机和映射时机往往不一致。设备缓冲区通常在probe阶段就分配好了,而mmap是用户随时可能调用的。如果映射时现场分配,多个进程映射同一设备就会各分各的,数据根本对不上。分离之后,.mmap只负责"把已经存在的物理页告诉内核",分配的事交给更早的阶段,逻辑清晰,也不会重复分配。

这个思想和内核里"机制与策略分离"是一脉相承的:mmap提供映射机制,具体映射哪块内存、映射多大,由驱动根据业务决定。

3. 两种映射路径的选型:remap_pfn_range 与 fault 回调

3.1 remap_pfn_range:一次性映射整段物理内存

remap_pfn_range是最常用的映射方式,适合"物理内存连续、映射范围固定"的场景。它的原型是:

int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr, unsigned long pfn, unsigned long size, pgprot_t prot);

参数含义逐个说清楚:

  • vma:内核传进来的虚拟内存区域描述符,.mmap回调的第二个参数。
  • addr:要映射的用户虚拟地址起始,通常用vma->vm_start。
  • pfn:物理页帧号,等于物理地址右移PAGE_SHIFT(通常 12 位,即除以 4096)。
  • size:映射长度,通常用vma->vm_end - vma->vm_start。
  • prot:页保护属性,一般用vma->vm_page_prot。

一个典型的.mmap实现长这样:

static int my_mmap(struct file *filp, struct vm_area_struct *vma) { struct my_dev *dev = filp->private_data; unsigned long offset = vma->vm_pgoff << PAGE_SHIFT; unsigned long size = vma->vm_end - vma->vm_start; unsigned long pfn; if (offset + size > dev->buf_size) return -EINVAL; pfn = virt_to_phys(dev->buf + offset) >> PAGE_SHIFT; if (remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot)) return -EAGAIN; return 0; }

这段代码里有几个关键点值得展开。vma->vm_pgoff是用户传入的offset参数右移PAGE_SHIFT得到的页帧号,所以还原真实字节偏移要左移回去。边界检查offset + size > dev->buf_size绝对不能省,否则用户传个超大 offset 就能映射到缓冲区之外的内存,这是实打实的安全漏洞。

virt_to_phys把内核虚拟地址转成物理地址,再右移得到 pfn。注意virt_to_phys只对内核直接映射区(低端内存)的地址有效,vmalloc分配的地址不能用它,得用vmalloc_to_pfn。

3.2 fault 回调:按需映射,适合非连续内存

当物理内存不连续,或者你想实现"用到哪页映射哪页"的懒加载,remap_pfn_range就不够用了。这时候要用vm_operations_struct里的.fault回调。

static vm_fault_t my_fault(struct vm_fault *vmf) { struct my_dev *dev = vmf->vma->vm_private_data; unsigned long offset = vmf->pgoff << PAGE_SHIFT; unsigned long pfn; if (offset >= dev->buf_size) return VM_FAULT_SIGBUS; pfn = virt_to_phys(dev->buf + offset) >> PAGE_SHIFT; return vmf_insert_pfn(vmf->vma, vmf->address, pfn); } static const struct vm_operations_struct my_vm_ops = { .fault = my_fault, }; static int my_mmap(struct file *filp, struct vm_area_struct *vma) { vma->vm_ops = &my_vm_ops; vma->vm_private_data = filp->private_data; return 0; }

.fault的触发时机是用户第一次访问某个尚未建立映射的页,内核发现页表项为空,就回调进来。vmf->pgoff是触发缺页的页号,vmf->address是对应的虚拟地址。vmf_insert_pfn负责把这一页填进页表。

两种方式的选型可以总结成一张表:

维度remap_pfn_rangefault 回调
适用内存物理连续物理可不连续
映射时机调用时一次性建立首次访问时按页建立
实现复杂度低中
大块内存性能好首次访问有缺页开销
典型场景DMA 一致性缓冲区预留内存池、分散页

3.3 选型背后的判断逻辑

怎么选?我的经验是看三个问题:内存连不连续、映射范围大不大、访问模式是顺序还是随机。

如果驱动用dma_alloc_coherent或kmalloc分配缓冲区,物理通常连续,直接remap_pfn_range一把梭,简单可靠。如果用的是vmalloc或者从预留内存池里按页拿的分散内存,那就必须走.fault,因为remap_pfn_range要求物理连续,硬用会映射到错误的页。

映射范围特别大(比如几百兆)时,即使内存连续,remap_pfn_range一次性建立所有页表项也有开销,而且用户可能只访问其中一小部分。这时候.fault的懒加载反而更划算。反过来,如果用户会顺序遍历整块内存,.fault每页都触发一次缺页,累积开销也不小,不如一次性映射。

提示:remap_pfn_range映射的内存默认是不可缓存的(取决于vm_page_prot)。如果映射的是普通 RAM 且希望走 CPU 缓存,要显式设置vma->vm_page_prot = pgprot_writecombine(vma->vm_page_prot)或类似属性,否则性能会差很多。这个坑我在实际项目里踩过,映射出来的内存读写比memcpy还慢,查了半天才发现是缓存属性没设对。

4. 分离思想落地:把分配、映射、释放拆成三段

4.1 分配阶段:在 probe 里把内存准备好

分离思想的第一步,是把内存分配从.mmap里挪出去,放到设备初始化的早期。以dma_alloc_coherent为例:

static int my_probe(struct platform_device *pdev) { struct my_dev *dev; dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev->buf_size = BUF_SIZE; dev->buf = dma_alloc_coherent(&pdev->dev, dev->buf_size, &dev->buf_phys, GFP_KERNEL); if (!dev->buf) { dev_err(&pdev->dev, "dma_alloc_coherent failed\n"); return -ENOMEM; } platform_set_drvdata(pdev, dev); return 0; }

这里dma_alloc_coherent一次拿到两样东西:CPU 侧虚拟地址dev->buf和设备侧总线地址dev->buf_phys。缓冲区在probe阶段就固定下来,生命周期和设备一致。后面无论多少进程调用mmap,映射的都是同一块物理内存,数据天然一致。

为什么用dma_alloc_coherent而不是kmalloc?因为设备要通过 DMA 往这块内存写数据,dma_alloc_coherent保证内存对 CPU 和设备都是一致的(coherent),不需要手动刷缓存。如果用kmalloc,DMA 写完数据后 CPU 可能读到旧缓存,得额外调dma_sync_single_for_cpu,容易漏。

4.2 映射阶段:.mmap 只做地址翻译

分配好了,.mmap就变得非常干净,只干一件事——把物理地址翻译成页表项:

static int my_mmap(struct file *filp, struct vm_area_struct *vma) { struct my_dev *dev = filp->private_data; unsigned long size = vma->vm_end - vma->vm_start; unsigned long offset = vma->vm_pgoff << PAGE_SHIFT; unsigned long pfn; if (offset + size > dev->buf_size) return -EINVAL; pfn = (dev->buf_phys + offset) >> PAGE_SHIFT; vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot); if (remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot)) return -EAGAIN; return 0; }

注意这里用的是dev->buf_phys而不是virt_to_phys(dev->buf)。因为dma_alloc_coherent返回的物理地址可能和 CPU 虚拟地址不在直接映射区,用virt_to_phys转换可能出错,直接用驱动保存的buf_phys最稳妥。这就是分离带来的好处——分配阶段已经把物理地址算好存下来了,映射阶段拿来就用,不用再猜。

pgprot_noncached把这段映射设成非缓存。对于设备寄存器或需要严格顺序访问的内存,这是必须的;对于普通数据缓冲区,如果不需要 CPU 缓存,也可以这么设,避免缓存一致性问题。

4.3 释放阶段:谁分配谁释放,映射不碰内存

释放逻辑同样遵循分离原则。.mmap建立的映射在进程退出或munmap时由内核自动拆除,驱动不需要在.mmap里注册释放回调去释放内存。内存的释放放在remove里:

static int my_remove(struct platform_device *pdev) { struct my_dev *dev = platform_get_drvdata(pdev); dma_free_coherent(&pdev->dev, dev->buf_size, dev->buf, dev->buf_phys); return 0; }

这里有个容易搞混的点:映射的拆除和内存的释放是两回事。进程 A 映射了缓冲区然后退出,内核拆掉 A 的页表项,但缓冲区本身还在,进程 B 依然能映射、能访问。只有当设备被移除、remove被调用时,缓冲区才真正释放。如果把dma_free_coherent放到某个.close回调里,第一个关闭设备的进程就会把内存释放掉,其他还在用的进程立刻 oops。

注意:如果确实需要在最后一个映射者离开时做清理(比如释放预留内存),要用引用计数。在vma->vm_ops->open里加计数,在close里减计数,减到零再释放。但绝大多数场景下,内存生命周期跟设备走就够了,别给自己找麻烦。

5. 那些年 mmap 踩过的坑:从 oops 到数据错乱的排查链路

5.1 映射成功但一访问就 oops

这是最常见的现象:mmap系统调用返回了合法地址,用户一读就段错误或者内核 oops。排查链路我一般这么走。

第一步,确认 pfn 算对了没有。在.mmap里加打印:

pr_info("mmap: offset=%lu size=%lu pfn=%lx\n", offset, size, pfn);

拿这个 pfn 左移PAGE_SHIFT得到物理地址,和/proc/iomem里的内存布局对一下,看是不是落在合法 RAM 区间。如果 pfn 明显偏大或偏小,多半是virt_to_phys用错了对象,比如对vmalloc地址用了它。

第二步,确认映射长度有没有越界。remap_pfn_range的size如果超过了实际分配的缓冲区,会把缓冲区后面的物理页也映射进来,那些页可能是别的驱动在用,一访问就出问题。边界检查必须做,而且要用>=还是>想清楚——offset + size > buf_size才是正确的,等于的情况是合法的。

第三步,看缓存属性。如果映射的是设备寄存器却用了可缓存属性,CPU 可能读到缓存里的旧值,表现为"写进去没反应"或者"读出来是随机数"。反过来,映射普通 RAM 用了非缓存属性,性能会暴跌但不至于 oops。

5.2 vm_pgoff 偏移计算错误的典型表现

vm_pgoff相关的 bug 特别隐蔽,因为小偏移时可能碰巧对。典型错误是忘了左移:

/* 错误写法 */ unsigned long offset = vma->vm_pgoff;

vm_pgoff是页号,不是字节偏移。用户传offset=4096,vm_pgoff是 1,忘了左移就变成偏移 1 字节,映射出来的数据整体错位。这种 bug 在用户传 offset 为 0 时完全看不出来,一旦传非零偏移就错乱。

还有一种错误是用户传的 offset 不是页对齐的。mmap系统调用本身要求 offset 是页大小的整数倍,否则直接返回EINVAL,轮不到驱动处理。但驱动内部如果自己算偏移,也要保证页对齐,否则remap_pfn_range的 pfn 会算错。

排查这类问题,最直接的办法是在用户态打印映射后的地址,然后往里面写一个已知模式(比如递增的整数),再在驱动里通过/dev/mem或者调试接口读物理内存对比。对不上就说明偏移算错了。

5.3 多进程映射同一设备时的数据一致性

前面反复强调分离思想,一个核心原因就是多进程场景。假设两个进程都mmap了同一个设备,如果.mmap里现场分配内存,两个进程映射到两块不同的物理内存,A 写的数据 B 看不见,业务逻辑直接崩。

用分离思想,内存在probe里分配一次,两个进程映射的是同一块物理内存,MAP_SHARED语义下 A 的写 B 立刻能看见。这里要注意mmap的标志位:MAP_SHARED才是共享,MAP_PRIVATE是写时复制,A 写会触发 COW 分配新页,B 看不到。驱动一般要求用户传MAP_SHARED,可以在.mmap里检查:

if (!(vma->vm_flags & VM_SHARED)) return -EINVAL;

这个检查能挡掉一类"为什么数据不同步"的困惑。

5.4 缓存一致性:DMA 与 CPU 抢同一块内存

设备通过 DMA 往缓冲区写数据,CPU 同时通过映射读这块内存,如果缓存属性没设对,CPU 可能一直读到缓存里的旧数据。这个问题的根源是 CPU 缓存和设备内存之间没有自动同步。

解决方案有两条路。一是用dma_alloc_coherent分配一致性内存,硬件保证 CPU 和设备看到的数据一致,代价是这类内存通常不可缓存,CPU 访问慢。二是用普通可缓存内存,在 DMA 传输前后手动调dma_sync_single_for_cpu和dma_sync_single_for_device刷缓存,性能好但容易漏调。

映射到用户空间时,如果用户要频繁读 DMA 写入的数据,我一般建议用一致性内存加非缓存映射,虽然 CPU 访问慢一点,但省去了同步的麻烦,也不会出诡异的数据不一致。如果性能实在不够,再考虑可缓存内存加显式同步,但要把同步点想清楚。

6. 从 mmap 看内核编程的分离哲学

6.1 机制与策略分离在 mmap 里的体现

mmap这套接口本身就是分离思想的产物。内核提供remap_pfn_range、vmf_insert_pfn这些机制,负责"怎么把物理页填进页表";驱动决定"映射哪块内存、映射多大、什么缓存属性",这是策略。机制稳定不变,策略因设备而异。

这种分离带来的好处是驱动代码可以写得很薄。.mmap回调通常就十几行,边界检查加一次remap_pfn_range调用,剩下的复杂度都在分配和管理阶段。代码薄意味着 bug 少,也意味着别人读你的驱动时能快速抓住重点。

反观那些把分配、映射、释放揉在一起的实现,.mmap里又是kmalloc又是remap_pfn_range又是注册回调,几十行代码纠缠在一起,出了问题时根本分不清是分配错了还是映射错了。分离之后,每个阶段职责单一,排查时能快速定位到是哪一段出了问题。

6.2 生命周期分离:映射的生死与内存的生死

前面提过,映射的拆除和内存的释放是两条独立的生命周期。这个分离特别重要,因为它们的触发条件完全不同。

映射的生命周期跟进程走:进程mmap时建立,munmap或进程退出时拆除。内核通过vma的引用计数管理,驱动基本不用操心。

内存的生命周期跟设备走:probe时分配,remove时释放。中间无论多少进程映射、解除映射,内存都在那里。

把这两条线分开,就不会出现"某个进程退出导致内存被释放,其他进程访问野指针"的问题。我见过一些实现,在.mmap里注册vma->vm_ops->close回调,在回调里释放内存,结果多进程场景下第一个关闭的进程就把内存释放了,后面的进程全崩。这就是没把生命周期分离清楚。

6.3 这种思想还能迁移到哪些驱动场景

mmap里的分离思想不是孤例,它在内核驱动里到处都能用上。

比如中断处理,上半部(硬中断)只做最紧急的事,把耗时工作推给下半部(软中断、tasklet、工作队列),这是"紧急与耗时分离"。再比如字符设备的read/write,驱动只负责数据搬运,具体的协议解析、命令组装交给上层库,这是"传输与协议分离"。

甚至设备模型的probe/remove本身就是分离的:probe负责申请资源、初始化硬件,remove负责逆操作释放资源。两者对称,中间的业务逻辑通过文件操作接口暴露。理解了这种对称的分离结构,写驱动时思路会清晰很多——先想清楚有哪些资源、各自的生命周期是什么、谁申请谁释放,然后再填具体的功能代码。

回到mmap,如果你正在写一个需要大数据量传输的驱动,我的建议是先把内存分配在probe里定下来,.mmap里只做地址翻译和边界检查,释放放在remove里。这套结构跑通之后,再根据实际的内存连续性和访问模式,决定用remap_pfn_range还是.fault。顺序别搞反,先分离再优化,比一上来就纠结用哪个 API 要高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询