☰
malloc与kmalloc的底层差异:从虚拟内存到伙伴系统的完整链路
2026/10/6 7:21:02 网站建设 项目流程

我平时排查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:创建一段新的匿名内存映射,通常用于超过阈值的大块分配。

内核收到这两个系统调用后,并不是立刻给你找物理内存页,而是先在当前进程的虚拟地址空间里划出一段区域,记录成一个VMA(virtual 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,常见入口还有:

  • kzalloc:kmalloc加清零,避免未初始化数据泄露;
  • 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 kB

VmSize是虚拟内存总大小,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就是可回收slab,SUnreclaim则不可回收。

5. 性能与开销的直观对比:一次分配到底走了多远

5.1 用户态malloc不一定每次都进内核

很多人以为“用户态malloc一定调用系统调用”,这其实是误解。glibc的malloc实现了多层缓存,尤其是tcache(thread-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一个几百字节的对象,但在热路径上这种分配对性能的伤害,往往比想象中大。

写这篇文章时,我又翻了一遍手上几个驱动模块的分配路径,发现很多问题其实就是没分清“用户态映射兜底”和“内核态实际分配”之间的边界。明白了两者差在哪,很多内存疑难杂症,其实真的不难定位。

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

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

立即咨询