☰
深入Linux高端内存映射:kmap/kunmap原理与实战
2026/10/11 5:48:41 网站建设 项目流程

从一次32位嵌入式平台的真实调试场景切入:我在一块物理内存512MB的ARM板上跑视频采集驱动,发现kmalloc分配的内核缓冲区始终上不了300MB,而明明有512MB物理内存躺在那里。后来才明白,问题不在内存大小,而在内核地址空间的分配方式——这趟排查把我带进了高端内存映射的世界,也让我真正吃透了kmap和kunmap这对接口的底层逻辑。如果你也在做Linux驱动、嵌入式内核移植,或者准备内核面试,这篇就以kmap/kunmap为主线,把高端内存映射的来龙去脉、实现细节和实战坑位一次讲清楚。

1. 高端内存到底解决什么问题:32位内核地址空间的稀缺账本

1.1 内核线性映射的“固定配额”从哪来

32位处理器的虚拟地址空间最大是4GB,Linux内核默认按3:1划分:用户空间占3GB(0x00000000~0xBFFFFFFF),内核空间占1GB(0xC0000000~0xFFFFFFFF)。这里的“用户空间3GB”是每个进程独立的,进程之间通过页表切换互不影响;但内核空间这1GB是全局共享的,所有进程、所有上下文看到的是同一份内核页表。

关键来了:内核为了能快速访问物理内存,把物理内存的前段直接映射到内核地址空间的固定窗口,这就是线性映射区(也叫直接映射区、低端内存映射区)。物理地址和内核虚拟地址之间只差一个固定偏移量PAGE_OFFSET,比如0xC0000000。访问这个范围内的内存,CPU不需要查二级页表结构做动态映射,直接算偏移就行,开销极小。

但窗口就这么大。内核1GB的空间不可能全给线性映射,它还要给vmalloc区域、固定映射区、高端内存永久映射区留位置。所以实际上线性映射区通常只占用约896MB(不同架构和内核配置略有差异,有的是1GB),剩下的约128MB留给vmalloc区域和kmap之类的动态映射。这就是所谓“低端内存”的配额。

1.2 高端内存区域的诞生:896MB分界线

当物理内存超过896MB(或者说超过线性映射窗口上限)时,超出的那一部分物理内存就没有对应的内核虚拟地址了。这部分内存就被划入ZONE_HIGHMEM,也就是高端内存区。高端内存页不能通过PAGE_OFFSET直接计算地址访问,内核必须先为它建立一个临时的页表映射,才能读写这块物理页。

注意一个容易混淆的点:ZONE_HIGHMEM不是一块独立的内存条,它只是指物理地址高于某个阈值的内存页集合。在32位系统上,如果你的板子只有512MB内存,理论上所有页都属于ZONE_NORMAL(低端内存),压根不涉及高端内存;但如果你做内核配置时把CONFIG_HIGHMEM开启,并且物理内存超过896MB,超出的页就会归入ZONE_HIGHMEM。我调试的那块512MB板子之所以kmalloc受限,其实是因为内核其它区域(vmalloc、固定映射)吞掉了部分1GB窗口,线性映射实际可用的比理论值少,加上分配器策略限制,导致能直接分配的内核缓冲区始终上不去。

1.3 三种映射区的分工:直接映射、vmalloc、kmap

内核1GB地址空间里的映射方式大致分三类,理清这个分类,后面看kmap就不迷糊:

映射区典型范围用途访问方式
线性映射(低端内存)0xC0000000起约896MB大部分内核堆、静态分配的页物理地址 + PAGE_OFFSET 直接计算
vmalloc区紧随线性映射之后,约128MB内的一部分非连续物理页的连续虚拟映射vmalloc/vfree 动态分配
永久映射区(PKMAP)独立划分的4MB窗口高端内存页的动态映射kmap/kunmap
固定映射区(FIXMAP)预留的一组固定虚拟地址kmap_atomic临时映射等编译期/运行期固定地址

线性映射处理的是低端内存,vmalloc解决物理不连续问题,而kmap正是专门服务于高端内存页的临时映射机制。这就是标题里那句“高端内存映射”的落点:当你要访问一个HIGHMEM页,却不能直接算地址时,kmap就是那个“把离散的物理页临时绑定到一个内核虚拟地址”的桥。

2. kmap/kunmap的完整实现链路:从调用到页表的一趟旅行

2.1 核心代码流程拆解:先看page_address

要理解kmap,先看两个关键的前提函数。第一个是page_address,它负责判断一个struct page是否已经有可用的内核虚拟地址。如果是低端内存页,直接通过__va(PFN_PHYS(page_to_pfn(page)))换算即可返回;如果是高端内存页,则要到管理结构中查它是否已经被kmap映射过,以及映射到哪个地址。

kmap本身入口非常短,早期内核的几个版本里大致是这个样子(不同版本符号有差异,但逻辑一致):

void *kmap(struct page *page) { might_sleep(); if (!PageHighMem(page)) return page_address(page); return kmap_high(page); }

注意这里的might_sleep(),它是第一个信号:kmap可能在执行过程中睡眠。原因在kmap_high里,我们马上看。

第二个关键函数是kmap_high。它的任务是:如果这个高端页还没有映射地址,就给它分配一个永久映射区的槽位;如果已经映射了,就增加该槽位的引用计数,然后返回已有的地址:

void *kmap_high(struct page *page) { unsigned long vaddr; lock_kmap(); // 保护永久映射区的自旋锁/信号量 vaddr = (unsigned long)page_address(page); if (!vaddr) vaddr = map_new_virtual(page); pkmap_count[PKMAP_NR(vaddr)]++; unlock_kmap(); return (void *)vaddr; }

2.2 PKMAP区域如何管理:last_pkmap_nr与回收循环

永久映射区(PKMAP)在内核地址空间中是一个固定大小的窗口,经典配置下是4MB,恰好可以容纳1024个页表项。每个槽位对应一个虚拟地址,kmap返回的是这个虚拟地址,而物理页就映射到这个虚拟地址上。

管理这个区域的数据结构是一个数组:pkmap_count[],数组下标是“槽位编号”,对应虚拟地址在该区域内的偏移。pkmap_count[n]的含义很微妙:

  • 值等于0:该槽位空闲,可以分配
  • 值等于1:该槽位虽然还被占用(页表项残留),但已经没有内核代码在引用它,属于“待回收”状态
  • 值大于1:有内核代码正在使用这个映射

理解了这三个状态,map_new_virtual的循环逻辑就很好懂。它从last_pkmap_nr这个游标开始,逐个检查pkmap_count,找到一个计数为0的槽位,然后设置页表项、刷新TLB、把计数置1,同时更新page的管理信息记录映射地址,返回这个虚拟地址。

如果整个区域都没有计数为0的槽位呢?这时内核会调用flush_all_zero_pkmaps,把所有计数等于1的槽位清空,重新变为可用。如果连等于1的都没有,说明所有映射都还被独占使用,那就只能睡眠等待——这就是kmap不能在中断上下文、自旋锁保护区等原子上下文调用的根本原因。

2.3 kunmap返回值与页表操作细节

kunmap的路径比kmap短得多,核心就是递减pkmap_count:

void kunmap(struct page *page) { if (!PageHighMem(page)) return; kunmap_high(page); } void kunmap_high(struct page *page) { unsigned long vaddr; int need_wakeup = 0; lock_kmap(); vaddr = (unsigned long)page_address(page); if (vaddr) { pkmap_count[PKMAP_NR(vaddr)]--; if (pkmap_count[PKMAP_NR(vaddr)] == 1) need_wakeup = 1; } unlock_kmap(); if (need_wakeup) wake_up(&pkmap_map_wait); }

看到这里你会发现,kunmap并没有立即清除页表项、没有立即刷TLB,只是把引用计数减到1,标记为“待回收”。真正的页表回收发生在后续的flush_all_zero_pkmaps里。这是一种典型的批量延迟回收策略:如果每次kunmap都立刻操作页表并flush TLB,频繁映射映射的开销会大到你无法承受。

这里必须补充一个实践结论:kmap/kunmap是配对使用的,但不是对称的。kmap可能把一个未映射的物理页“从无到有”建立一个映射,kunmap却不一定把映射立刻拆掉,它只是把映射的“活跃度”降下来。正因如此,理论上你可以在不同时间对同一个page调用多次kmap,内核会通过引用计数保证它们返回同一个地址,只要计数不回落到1,映射就不会被回收。但如果你只kmap一次就立即kunmap,下次kmap同一个页时又得重新走一遍map_new_virtual的建映射流程。

3. 我在嵌入式项目中踩过的kmap深坑

3.1 在中断上下文调用kmap导致睡眠死锁

这是我接手一个网卡驱动时遇到的真实问题。驱动在NAPI的软中断上下文中处理接收到的skb数据,需要把skb里的高端内存页映射到内核地址做校验和计算。代码里直接写了kmap(page),结果运行一段时间后系统随机崩溃,dmesg里出现“BUG: scheduling while atomic”或者干脆卡死。

根因就是我前面说的:kmap内部可能在map_new_virtual时发现PKMAP区没有空闲槽位,然后调用schedule()睡眠等待。而软中断上下文、中断上下文里睡眠直接触发原子上下文非法调度,轻则告警重则死锁。更隐蔽的是它不一定会立即触发,只有当永久映射区槽位恰好耗尽时才崩,所以这种bug在低负载测试时很难复现。

解决办法是把kmap换成kmap_atomic。kmap_atomic走的是固定映射区的per-CPU临时映射槽位,不需要全局锁,也不会睡眠,专为原子上下文设计。但注意kmap_atomic要求代码块内不能睡眠,所以校验和计算做完后要立刻kunmap_atomic,不能在映射期间调用可能睡眠的函数。

3.2 长时间持有映射导致的TLB压力

另一个问题是性能层面的。我在一块32位工业控制板上优化图像处理驱动,图像数据从DMA缓冲区搬运到高端内存页后,需要对每个页做kmap/kunmap再memcpy。起初代码写成每处理64KB就kmap一次、memcpy、kunmap,循环几百次。Perf测量发现内核态时间占比高得离谱,TLB刷新占了很大一部分开销。

原因在于每次map_new_virtual不仅要建立页表项,还要flush TLB确保旧映射失效;回收时同样要flush。这在高频小粒度映射时是灾难。优化方向有两个:一是提高映射粒度,尽量一次kmap映射尽可能大的连续物理区域(如果物理页不连续就用scatterlist配合dma_map),把映射次数降下来;二是在CPU密集用的路径上改用kmap_local或者干脆用vmap把一组合并成连续虚拟地址。关于kmap_local我在第4章详细说。

3.3 页面回收与kmap的连带问题

第三个坑和内存回收有关。高端内存页如果在kmap映射期间被回收器选中,回收器会尝试把页内容写回或者迁移。旧内核里如果页正在被kmap引用,迁移机制会检查页的映射状态;但如果你的驱动拿了一个HIGHMEM页做kmap之后长时间不kunmap,可能阻塞回收器的推进,在内存紧张时引发分配延迟。

从使用者的角度,这个坑的教训是:kmap的持有时间要短,用完立刻kunmap。别看kmap_high里只是把一个计数值从N减到N-1,如果你长时间占着N大于1,等于告诉内核“这页还很忙,别动它”,这会影响系统的内存管理全局。我在那个图像驱动里就是先批量kmap一批页的话,用到哪个就尽快释放,内存压力测试时系统响应明显更稳。

4. kmap_atomic与kmap怎么选:原子上下文的正确打开方式

4.1 kmap_atomic原理与固定映射区

kmap_atomic和kmap最大的区别是它利用固定映射区(FIXMAP)里一组预留给原子映射的地址。这组地址是编译期就确定的,每个CPU都有一套自己的映射槽位,所以不需要全局锁,也不会因为争用PKMAP槽位而睡眠。

从实现上看,经典版本里kmap_atomic需要传入一个“类型”参数(比如KM_BOUNCE_READ、KM_SKB_DATA等),这个参数决定了使用固定映射区里的第几个槽位。因为不同类型的用途可以嵌套使用不同槽位而互不干扰,内核可以安全地在一个上下文里嵌套多次kmap_atomic。每次调用只需设置页表、返回地址;kunmap_atomic时恢复原来的页表内容。整个过程禁止内核抢占和进程切换,保证映射对当前CPU始终有效。

4.2 使用kmap_atomic的注意点

实际使用中几个要点必须刻在脑子里:

  • kmap_atomic返回的地址只对当前CPU有效。它映射的是当前CPU的固定映射槽位,如果你把返回地址传给其他CPU上的任务去访问,那个任务看到的是完全不同的映射内容,甚至可能是非法地址。
  • 禁止在映射期间睡眠。这是硬约束,kmap_atomic会关闭内核抢占。常见的错误就是在kmap_atomic返回地址后调用copy_from_user、mutex_lock等可能睡眠的函数,直接触发调度器告警。
  • 读完之后立刻kunmap_atomic。虽然固定映射区的槽位有多个,但数量有限(经典实现里每种类型一个槽位,一般能嵌套数层),长期占着一个槽位而不释放,等于浪费了per-CPU稀缺资源,还会让嵌套层次变浅,其它代码路径再想kmap_atomic就没槽位可用了。

4.3 kmap/kmap_atomic/kmap_local三者的取舍

新的内核(5.x之后)引入了kmap_local系列接口,逐渐替代kmap_atomic。kmap_local在功能上类似kmap,但它使用基于当前进程上下文的本地映射,可以嵌套、可以带出函数使用(只要在同一个task内),实现上避免了全局PKMAP锁,也允许地址空间隔离。它更适合那些原本用kmap但在路径上有睡眠需求、又希望避免全局锁竞争的代码。

我根据经验给一张决策表:

场景推荐接口原因
中断/软中断/自旋锁内,只读页内容kmap_atomic 或 kmap_local_irq不睡眠、开销小
进程上下文中处理高端页,但路径可能长kmap_local避免全局锁,可嵌套
传统驱动兼容,且并发度低kmap代码改动最小,但要接受PKMAP锁开销
连续多页需要统一访问vmap 或 ioremap一次性映射一批物理页,减少kmap次数

很多老驱动至今还在用kmap,功能上没问题,但在多核系统上PKMAP区的全局锁会成为热点。新代码建议直接考虑kmap_local或者kmap_local_page,迁移成本并不高。

5. 64位时代还需要kmap吗:带着历史代码迁移内核的教训

5.1 CONFIG_HIGHMEM关闭后kmap变成什么

64位系统的线性映射窗口大到可以覆盖全部物理内存,ZONE_HIGHMEM基本是空的,CONFIG_HIGHMEM默认关闭。此时kmap和kunmap的语义被弱化为非常轻量的操作:kmap直接变成page_address(page)的封装,kunmap则空了,什么也不做。

看起来老代码在64位平台可以直接跑,但实际迁移时有两个容易忽略的点。第一,如果代码里依赖“kmap返回的是高端内存页专属地址”来做指针运算,比如拿kmap返回值减去PAGE_OFFSET推断物理地址,在64位平台上这种假设可能失效,因为page_address可能返回的是直接映射地址也可能返回NULL(如果页还没建映射),必须用page_to_pfn这套标准换算,而不是靠地址区间猜测。第二,编译选项CONFIG_HIGHMEM被关闭后,PageHighMem恒为假,某些老驱动里“PageHighMem(page)才走kmap,否则直接KERNEL_DS访问”的分支逻辑可能永远走不到另外一边,代码要重新审查。

5.2 移植驱动的替换建议

我建议在把32位时代的驱动往64位环境迁移时,做一次系统性的映射接口替换。优先把kmap换成kmap_local_page(如果内核版本够新),把kunmap换成kunmap_local;如果代码只在进程上下文短时访问,保留kmap问题也不大,但一定要确认没有在中断里用。同时检查所有kmap返回值的生命周期,确认没有跨函数传递或长期保存。

还有一类代码用kmap配合dma_map_single做DMA,这是典型的设计问题。DMA操作根本不需要内核虚拟地址,驱动应该直接使用dma_map_single/dma_alloc_coherent管理DMA缓冲区。如果因为拿到的是page而不是地址而走了kmap,建议把整个缓冲区的分配方式改成dma_alloc_coherent,既避免kmap开销,又保证DMA一致性。这个改动带来的性能提升比优化kmap本身明显得多。

6. 关于kmap的经典迷惑点:page_address、引用计数与调试手段

6.1 page_address什么时候有值

不少初学者会把page_address和kmap搞混,以为只要调用page_address就能得到任意页的地址。实际上page_address对高端内存页只有在“已经被kmap映射过且映射未回收”时才有值;对低端内存页则始终能返回线性映射地址(前提是CONFIG_HIGHMEM之外的低端内存页)。所以老内核里常见代码是先kmap(page)再用page_address(page)获取映射地址,这其实是重复劳动——kmap已经返回了地址。但查看page_address的返回值可以帮助你确认一个页是否处于已映射状态。

在使用时记住这句话:kmap负责建立映射并返回地址,page_address只负责查询。查询没有副作用,不会触发映射分配,所以多调几次不会破坏内存管理状态。

6.2 引用计数与页状态检查

判断kmap配对是否正确,一个实用手段是检查页的引用计数。kmap本身不会对struct page的引用计数做增减,它只影响pkmap_count数组。如果你怀疑驱动里某处漏了kunmap,一种排查方式是周期性打印pkmap_count数组里非零槽位的数量变化。比如写一个调试函数扫描永久映射区,统计处于“活跃计数>1”的槽位数,在驱动加载前后对比,就能看出是否有映射泄漏。

另外,CONFIG_DEBUG_HIGHMEM内核编译选项会启用对kmap/kunmap的各种校验,包括参数合法性、调用上下文检查、映射次数检查。开发和测试新版驱动时把这个选项打开,配合lockdep,能抓出大部分kmap错误用法。不过在性能敏感的设备上别长期开着它,开销不小。

6.3 我最后调一次kmap性能的实操记录

回头说那块图像处理板子。性能问题最终的解法是三步走:第一步,把原本每次一个page的kmap/kunmap改成批量kmap_local_page,一次映射连续的8个页;第二步,把memcpy改成能识别映射地址的优化实现(利用预取指令),把内存搬运吞吐拉上去;第三步,在最热的数据路径上干脆用dma_alloc_coherent申请常驻缓冲区,彻底绕开kmap。改完之后内核态CPU占比从37%降到11%,吞吐量翻了一倍以上。

这个结果给后来同事做类似工作时提供了很直观的参考:遇到kmap性能焦虑,先别纠结于kmap函数本身那点开销,要看你有没有频繁建立和拆除映射、有没有在错误的数据路径里反复做同一件事。kmap是个“打通路径的工具”,不是“数据搬运的引擎”,把工具用在该用的地方,问题自然就消了。

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

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

立即咨询