malloc与kmalloc的底层差异:从虚拟内存到伙伴系统的完整链路 我平时排查Linux内存问题的时候经常会被同事问到这样一个问题同样是一块内存用户态用malloc申请内核态用kmalloc申请到底差在哪不都是向系统要一块能用的空间吗这个问题听起来简单真要回答清楚得把地址空间、页表、伙伴系统、slab分配器、缺页异常、甚至OOM killer串在一起说。最近我们正好在排查一个内核模块的内存泄漏我又花了一下午从根上把这条链路捋了一遍今天就把这套理解整理成文字给写用户态程序但想了解内核态或者刚开始写Linux驱动的朋友参考。1. 同样说“申请内存”两个世界各有各话术1.1 一个最简单的malloc背后发生了什么先看用户态最普通的一行C代码void *p malloc(4096);这行代码背后的真相可能和你想象的差很远。malloc是C标准库glibc提供的分配器它本身不是内核函数。当你调用malloc时glibc会先从自己维护的用户态堆管理器里找空闲块小块内存走tcache、fastbin、unsorted bin这些缓冲结构只有当前堆空间不够用了才会真正触达内核。触达内核通常就两条路brk调整进程堆的边界program break把用户态堆扩大mmap创建一段新的匿名内存映射通常用于超过阈值的大块分配。内核收到这两个系统调用后并不是立刻给你找物理内存页而是先在当前进程的虚拟地址空间里划出一段区域记录成一个VMAvirtual memory area。也就是说malloc返回给你的地址本质上只是一个虚拟地址承诺这块区域你可以用了但物理页面还没到位。真正让物理内存“到账”的是你访问这块内存的那个瞬间。比如你写一句p[0] 1;CPU访问这个虚拟地址时发现页表里没有对应物理页就会触发缺页异常处理器陷入内核内核分配一个物理页填好页表项再返回到用户态继续执行。这个过程是操作系统按需分配demand paging的基本逻辑。所以用户态常见的一句话“malloc申请了4KB”严格说只是申请了4KB虚拟地址空间的权限物理内存是在写操作后才真正消耗的。1.2 内核态申请内存的入口kmalloc与朋友们再看内核态最常见的申请方式void *p kmalloc(4096, GFP_KERNEL);kmalloc是一个内核API它不经过用户态标准库直接在内存管理子系统里干活。它背后依赖slab/slub分配器从内核预先维护好的对象缓存里拿一块内存。和malloc不同kmalloc返回时这块内存对应的虚拟地址已经可以直接访问物理页也已经被分配好。你在驱动里拿到指针后马上写不会触发面向用户进程的那种缺页异常路径。内核态的内存申请远不止一个kmalloc常见入口还有kzallockmalloc加清零避免未初始化数据泄露kcalloc为数组分配并清零vmalloc分配大块虚拟地址连续、物理页不连续的内存alloc_pages直接以页为单位向伙伴系统取物理页kmem_cache_create为某种结构体创建专用的slab缓存。这里有一个特别容易混淆的点用户态程序里看到的所有内存分配其实都是进程地址空间内的映射操作而内核态的kmalloc则是内核为自己管理的核心内存区域找一个可用块。两者最终都会用到同一片物理内存但“地图”和“使用规则”完全不同。1.3 一切源于地址空间的“楚河汉界”要理解差别必须回到地址空间这个基本概念。Linux在x86-64下虚拟地址空间被分成两大部分低位部分是用户空间高位部分是内核空间。进程切换到内核态时虽然还是用当前进程的页表但通过CPU的特权级和页表权限位用户态无法访问内核空间。每个用户进程拥有独立的虚拟地址空间进程A和进程B的0x7f...地址看起来一样但映射到完全不同的物理页。内核空间则不同它是一份所有进程共享的全局地址空间。你用内核态kmalloc拿到的地址放在另一个进程的内核态代码里也能访问因为内核页表的全局映射是一样的。这就引出了两种内存申请的根本差异用户态malloc是“在这个进程的私人地图上给你划一块地”内核态kmalloc是“在大家共享的内核地图上给你勾一块地”。物理上它们脚下都是同一片RAM但在权限、隔离、共享范围、代码路径上完全是两回事。2. 物理内存分配器殊途同归都落在伙伴系统上2.1 伙伴系统是一切物理内存的最终地基无论用户态还是内核态真正管理物理内存页并负责分配物理页的是内核里的伙伴系统buddy system。它把物理内存按2的幂次分为不同阶order的连续页块比如order-0表示1个页order-1表示2个连续页order-2表示4个连续页以此类推。分配时从最小的满足要求的块里切释放时再尝试把相邻的空闲块合并成更大的块。Linux启动后除了内核镜像等保留区域大部分物理内存都挂在buddy系统上。你可以在/proc/buddyinfo里看到每个内存区域的空闲页块分布Node 0, zone Normal 496 327 104 34 15 3 2 1 0 0 0从左到右是order-0到order-10的空闲页块数量。如果order-3以上长期为0说明物理连续大页比较紧张kmalloc大块分配就容易失败。用户态进程陷入缺页异常后内核分配物理页也是通过alloc_pages从buddy系统中取页。所以你可以说用户态最终从伙伴系统拿物理页内核态kmalloc最终也基于伙伴系统拿页。区别在于用户态缺页通常一次只拿一个页而内核态kmalloc可能会请求连续的多个页作为slab缓存的后备。2.2 slab/slub分配器内核的小对象搬运工内核里大量需要申请的是几十字节到几百字节的小对象比如struct file、struct task_struct、struct inode等。如果每次都找伙伴系统要一整页浪费太大性能也不行。于是Linux内核引入了slab分配器现在主流实现是slub。slab分配器的思路很简单从伙伴系统拿若干个页切分成一组大小相同的对象然后把空闲对象挂到链表上。kmalloc按8、16、32、64等常用尺寸创建缓存比如kmalloc-32、kmalloc-64。申请时直接从这个缓存里摘一个对象释放时再挂回去。你可以通过/proc/slabinfo看到这些缓存kmalloc-128 8192 8192 128 32 1 : tunables ... kmalloc-256 2048 4096 256 16 1 : tunables ...第一列是缓存名第二列是活跃对象数第三列是总对象数后面是每个对象大小和所在页数。如果某个缓存活跃对象数持续增长那基本可以断定是内核里对应类型的对象泄漏了。这里要提一个常见误区kmalloc适合小对象并不适合申请大块连续内存。因为slab缓存最大支持一个或几个页的连续块kmalloc超过一定大小不同架构阈值不同通常到8KB/32KB以上就会变慢就不再走普通小对象缓存而是直接通过伙伴系统分配连续页。连续页数量越大越容易因为碎片失败。2.3 用户态malloc是如何“摸到”伙伴系统的用户态malloc不会直接调用伙伴系统但最终还是要靠它。malloc在用户态堆管理器里没有可用空间时会通过brk或mmap系统调用请求内核扩展地址空间。内核创建好VMA后返回用户态。当用户代码真正读写这块区域时触发缺页异常内核的缺页中断处理程序会调用alloc_pages从伙伴系统取一个物理页建立页表映射。这里有一个很经典的现象你调用malloc(1GB)然后不写它RSS几乎不会涨只有VSZ变大了。只有当你绕着这片内存逐页写入RSS才慢慢上涨。这就是“虚拟内存申请”和“物理内存消耗”分开的体现。理解这一点对排查“程序怎么占内存那么大”很有用先看VSZ其实只是地址空间范围真正物理消耗要看RSS。在/proc/self/status里VmSize: 131072 kB VmRSS: 4096 kBVmSize是虚拟内存总大小VmRSS是驻留物理内存大小。两者差几十倍都不奇怪。2.4 物理内存本身没有“用户态/内核态”标签聊到这里可能有人会问同一个物理页到底属于用户态还是内核态答案是一块物理内存本身没有什么“用户态/内核态”的标签它只决定谁在用它、通过哪条映射访问它。举个例子一个物理页可能正在被某个进程的用户态页表映射作为进程堆的一部分也可能被内核映射到直接映射区存放一个文件系统缓存甚至可能同时被两边映射比如mmap共享内存的时候物理页既出现在进程的用户空间页表里也出现在内核的某些映射中。物理内存的性质是它当前的服务对象决定的而服务对象又决定了它能被谁访问、能不能被换出、释放时走哪条回收路径。3. 虚拟地址映射方式差在天花板和灵活性3.1 用户态的“超大空间”和内核态的“固定高地址区”64位Linux下用户态虚拟地址空间理论上大到128PB量级实际还会受内核参数限制但用户程序根本不需要担心“空间不够”。内核态的空间反而更“金贵”内核代码、直接映射区、vmalloc区、fixmap区、模块区都要挤在固定的高地址段里。虽然64位下空间也很大但管理上比用户态小心得多。另一个差异是隔离性。用户进程之间的地址空间互相隔离A进程无法直接通过地址访问B进程的内存因为页表不同。而内核地址空间是所有进程共享的CPU进入内核态后看到的内核页表基本一致。这也是为什么只要拿到内核任意地址就可能提权读取系统任意内存内核空间对特权代码“不设防”。3.2 kmalloc的“物理连续”和直接映射区x86-64里的物理内存大部分都会被映射到内核的“直接映射区”。这块区域的特点是虚拟地址和物理地址只差一个固定的偏移量。只要知道虚拟地址减去偏移就能算出物理地址内核里这就是virt_to_phys和phys_to_virt。kmalloc从slab或伙伴系统拿到的是物理连续的页又因为分配地址落在直接映射区所以虚拟地址也是连续的。这个特性让驱动在处理DMA时特别方便设备需要知道物理地址CPU访问时又可以用虚拟地址两边都对得上。但对普通用户态 malloc 来说“物理连续”从来不是一个承诺。进程页表里一个连续虚拟区间可以映射到任意多个不连续的物理页只要有页表项记录就够了。内核态程序通常不为用户态分配物理连续的内存这也是用户态能支持大块虚拟内存而不怕碎片的原因之一。3.3 vmalloc物理不连续灵活性要付代价当需要申请很大的内核内存而物理连续内存又非常稀缺时内核提供了vmalloc。它只保证虚拟地址连续物理页面可以由伙伴系统任意拼凑不要求连续。听起来很像用户态的malloc对吧但代价是每次访问vmalloc区域都可能需要经过页表转换TLB压力更大内核在创建vmalloc区域时也要多花工夫建立页表。所以vmalloc适合偶尔的大块分配比如内核模块加载、io映射不适合放在热路径里频繁使用。内核还提供了kvmalloc它先试kmalloc失败时自动回退到vmalloc兼顾了小对象效率和大量分配的容错性。我自己的经验是普通驱动写内存分配时不要一上来就用vmalloc优先kmalloc除非你需要超过一两个page且对物理连续性确实没要求。3.4 32位时代的高端内存是个历史包袱聊到地址空间就不能不提高端内存。32位系统上内核只能直接映射有限物理地址空间超过阈值的那部分物理内存不在直接映射区需要动态建立映射才能访问这就是所谓的“high memory”。这也带来了kmap、kmap_atomic这些接口。随着64位普及这个问题逐渐淡出但很多老驱动代码里依然保留着类似逻辑。看到这类接口你只要知道它是为了在32位环境下访问超出直接映射范围的物理页而存在的就能读懂历史背景。64位上物理内存基本都能直接映射开发时代码可以简单很多。4. 分配时机和生命周期谁提前透支谁按需买单4.1 用户态malloc是“赊账”真正的物理页在缺页时才进来用户态malloc的本质是向内核承诺一块虚拟地址区域但不立即消耗物理内存。这个设计的好处很明显程序可能申请了一个大缓冲区实际上只用了其中一小块按需分配能省下大量物理内存。坏处是如果你真的逐页写一遍之前“看似免费”的分配会一波波地变成缺页异常内存占用会在某一瞬间突然涨高。用一句话总结用户态malloc分配的“债权”使用它时才会变成“实付”。这也是为什么很多服务刚启动时RSS不高一跑业务就飙上去往往不是“内存泄漏”而是预分配的缓冲被真正触碰了。4.2 内核态kmalloc是“现款”调用返回时物理页已到账内核态kmalloc则不是按需分配。你调用kmalloc(4096, GFP_KERNEL)函数返回时这块内存的虚拟地址和物理页都已经准备好可以直接操作不需要额外缺页流程。反过来说如果此时内存不足kmalloc不会先答应你以后再说它要么等内存回收后继续尝试要么直接返回NULL具体取决于GFP标志。GFP标志影响分配行为这里要重点看两个常用值GFP_KERNEL允许睡眠可以执行页面回收、等待内存释放。普通进程上下文用它。GFP_ATOMIC不允许睡眠用于中断处理、自旋锁保护等原子上下文。内存不足时立即失败不会原地等待。这个区别在用户态malloc里不存在因为用户态缺页处理本身允许睡眠可以等待内存回收而在中断里你是没法舒服地“睡一觉”再等内存的。4.3 overcommit与OOM用户态的宽松和内核态的紧张Linux默认允许用户态内存过量分配overcommit。你申请一个比物理内存大得多的虚拟内存malloc也可能成功等你真正写爆内存系统再通过OOM Killer选一个“合适”的进程杀掉。这种行为用大白话讲就是用户可以提前透支一大张信用卡还款日来了系统会砍掉一个用户进程来平账。内核态分配则非常克制。内核态分配内存时如果允许阻塞会先尝试回收可回收页如果实在不够很多路径宁可返回失败也不愿意无限等待。尤其在原子上下文中GFP_ATOMIC申请不到就直接失败调用方必须自行处理NULL。内核态没有“先答应你后面再说”的习惯因为一张错误的内存借条可能导致系统hang。4.4 内核内存为什么不能随便换出用户态物理页可以被换出到swap分区当系统内存吃紧时内存管理会把不活跃的用户进程页写回磁盘下次访问时再通过缺页换入。这也是为什么用户态虚拟内存看起来可以比物理内存大很多。内核态大多数内存是不能换出的原因很实际内核可能在中断、临界区、原子上下文访问这些内存这些地方无法触发一个完整的缺页换入流程。想象一下一个设备驱动在处理中断时访问自己的数据结构如果这个结构被换出了中断上下文根本没法去读磁盘并等待。所以内核只能通过逐出缓存、slab收缩等方式“释放”内存而不是把它搬去swap。判断/proc/meminfo里的SReclaimable就是可回收slabSUnreclaim则不可回收。5. 性能与开销的直观对比一次分配到底走了多远5.1 用户态malloc不一定每次都进内核很多人以为“用户态malloc一定调用系统调用”这其实是误解。glibc的malloc实现了多层缓存尤其是tcachethread-local cache小对象分配完全可以做到用户态原子操作进入内核的开销为零。只有当tcache和fastbin都不够用需要扩堆或新建mmap区域时才会触达内核。所以用户态小对象分配的性能并不总是差。真正开销大的场景是程序频繁申请大量新内存导致glibc不断调用mmap/brk然后写内存触发缺页。缺页需要进入内核、分配物理页、填页表、返回用户态这个来回成本才是大头。5.2 内核态kmalloc也有隐形成本kmalloc虽没有系统调用和用户态到内核态的上下文切换但也不能说零成本。slab分配器在并发情况下需要处理锁竞争GFP_ATOMIC更可能使用自旋锁并关闭抢占。分配缓存不足时slab可能要从伙伴系统申请新页而伙伴系统又要考虑内存碎片和回收。不过对于常规小对象kmalloc确实很快因为它靠的是“预分配好的对象池”比缺页分配加页表建立要轻得多。这就是为什么驱动里批量申请小结构体时通常感觉不到慢。5.3 从系统指标里看出两边的内存动向实际排查时我常用这几个入口观察两边内存使用/proc/meminfo看总内存、Slab、SReclaimable、SUnreclaim判断内核缓存是否异常增加/proc/slabinfo按缓存维度跟踪内核对象数量定位是哪个模块在泄漏free -g看available是否逐步下降/proc/buddyinfo看连续物理页块有没有碎片化用户态程序则用strace -e brk,mmap -p PID跟踪堆扩展配合/proc/PID/status看VmRSS变化。如果怀疑用户态进程“内存占用高”先看它是VM大小还是RSS增长如果是RSS增长且peak出现在特定流程很可能就是真实的数据缓冲如果strace里同时有大量mmap不再munmap那可能是分配器策略或存在泄漏。内核态则优先查slabinfo很多内核泄漏在slab里涨得非常规律。5.4 一个容易踩坑的“速度”对比我在驱动和用户态程序间做过一个小测试同时批量创建/销毁几百万个小对象。用户态malloc配上tcache速度快得离谱但如果每次分配后都真正写一遍缺页开销立刻显现。内核态kmalloc没有缺页但连续几百万次分配会频繁触发slab扩张和回收。这个测试的教训是别把“内核态分配一定更快”当绝对真理。快不快取决于分配频率、对象大小、是否真实触碰数据、是否触发缺页、是否有锁竞争。脱离场景谈性能就是耍流氓。6. 常见误区与选择建议6.1 “内核态申请一定更快”为什么是错觉内核态kmalloc免去了系统调用和缺页确实在某些场景下更快比如驱动中断处理里需要小块内存GFP_ATOMIC直接摘一个对象最踏实。但用户态如果你是拿大块mmap映射然后只顺序访问一次内核态分配完还得考虑DMA、锁、回收路径并不一定占便宜。另外GFP_ATOMIC在内存紧张时可能非常不可靠它不让睡眠、不回收失败率高。这种“快”更像是一条窄路通畅的时候很快堵车了直接封路。6.2 “内存占用高”的锅到底该谁背最近常看到网上讨论Edge、Chrome、Java进程内存占用大甚至“Antimalware Service Executable占内存”这类热词本质上都是用户态进程的问题浏览器要缓存标签页JVM堆要管理对象GC回收不及时就会上涨。这些内存都通过用户态malloc或语言运行时分配器申请和内核态kmalloc关系不大。反过来如果你发现free显示的used上涨、available下降但每个用户进程RSS都不高那就要怀疑Slab增长比如某个内核模块在泄漏。用/proc/slabinfo对比前后数据是最直接的定位手段。6.3 实际开发中怎么选申请路径根据我的经验可以简单按下面的方向选用户态程序中普通数据就用malloc/new这类分配器需要跨进程共享用shm_open mmap需要大文件映射用mmap内核态驱动/模块中小对象用kmalloc/kzalloc最好配专用kmem_cache_create需要较大内存但不强求物理连续用kvmalloc或vmalloc必须物理连续且用于DMA用kmalloc或直接alloc_pages并留意架构DMA限制中断上下文只能用GFP_ATOMIC而且分配的代码路径必须短平快用户态进程不要试图直接访问内核内存页表权限会拦截只能通过内核提供的接口系统调用、netlink、procfs等来传数据。我在实际写驱动时还有一个习惯能用栈变量解决的问题就不要动用kmalloc。栈分配没有锁、没有回收成本最省事。需要较长生命周期或较大块时才考虑堆分配。这个习惯在用户态同样适用很多人一上来就new一个几百字节的对象但在热路径上这种分配对性能的伤害往往比想象中大。写这篇文章时我又翻了一遍手上几个驱动模块的分配路径发现很多问题其实就是没分清“用户态映射兜底”和“内核态实际分配”之间的边界。明白了两者差在哪很多内存疑难杂症其实真的不难定位。