恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用户态与内核态内存申请有何不同?从malloc到kmalloc的物理内存分配时机
首页
资讯中心
/
用户态与内核态内存申请有何不同?从malloc到kmalloc的物理内存分配时机
用户态与内核态内存申请有何不同?从malloc到kmalloc的物理内存分配时机
发布时间:2026/10/6 1:37:05
同样是一块内存用户态和内核态申请的到底差在哪我做过几年服务端性能优化也被内核驱动坑过几次。有一次排查线上服务同事跑过来指着监控问“我在代码里 malloc 了 2GB 内存为什么 free 显示内存占用一点没涨是不是机器有问题” 另一边的内核组同事接话说“我这边 kmalloc 申请 4KB空闲内存直接就降了 4KB从来没见它含糊过。” 同样都叫内存凭什么用户态申请要“等真正用到才扣款”内核态申请却“下单即扣款”这篇文章我就把这条链路彻底拆开讲讲。整个问题涉及用户态、内核态、虚拟内存、物理内存、缺页中断、页表映射、slab 分配器、伙伴系统这些环节我尽量不讲教科书直接讲清楚内存申请背后到底发生了什么什么时候真正触达物理内存以及实际项目中该怎么定位和排查这类问题。适合正在做应用层开发、中间件开发或者刚开始写内核模块、驱动、性能调优的读者看完你能少踩几个非常隐蔽的坑。1. 用户态和内核态在内存这件事上的“分工”1.1 两级特权带来的内存观差异CPU 有特权级别我们一般简化成两种内核态ring 0和用户态ring 3。这个分级不只是安全概念它直接决定了你眼里“内存”长什么样。用户态程序看到的永远是虚拟地址。你声明一个指针拿到的是一个 64 位地址空间里的某个地址这个地址不代表任何真实物理内存的位置。它的翻译工作由页表完成而页表本身存着虚拟页到物理页的映射关系。应用程序访问某个虚拟地址的时候CPU 通过 MMU内存管理单元查页表找到对应的物理页然后读写数据。如果页表里没有这项映射CPU 就会触发缺页异常把控制权交回内核由内核决定是加载对应页面、分配新页面还是直接给你进程发一个段错误。内核态看到的内存要“实”得多。因为内核本身负责管理物理内存它在很多场景下访问的就是物理地址空间或者说经过简单偏移计算就能直接对应到物理内存的线性映射区。写驱动的时候你可以直接往某个物理地址对应的内核虚拟地址写数据不需要像用户态那样经过一套完整的虚拟内存语义。所以“同样是一块内存”这个说法本身就是个坑用户态的内存是一个虚拟地址内核态的内存往往已经绑定了一块真实的物理页面。两者在出生的那一刻命运就不一样了。1.2 内存申请的核心组件都有谁不管用户态还是内核态最终都得找真正的物理内存要页。内核里管物理内存的是一个叫“伙伴系统”buddy system的分配器它负责把物理页分成 2 的幂次大小的块按 order0、1、2……管理。这是所有内存申请的终点。但终点前面的路不一样用户态申请调用接口是 malloc / calloc / realloc背后通常是 glibc 的 ptmalloc 分配器。它维护着一堆空闲链表内存不足时才通过 brk 系统调用扩展堆或者通过 mmap 系统调用映射一块新的区域。这些系统调用最终进入内核内核可能只是更新了进程的虚拟地址空间描述真正的物理页要等处理器访问到这块区域才分配。内核态申请调用接口是 kmalloc / kzalloc / vmalloc / kmem_cache_alloc。kmalloc 走 slab/slub 分配器slab 本身从 buddy 系统一次性拿大块物理页面然后切成小块缓存起来。kmalloc 返回的地址直接对应物理页面不存在“先记账后扣款”的说法。简单说用户态多了一层虚拟内存机制内核态贴着物理内存走。这也是“用户态申请内存不一定占用物理内存”这个反直觉现象的总根源。2. 申请路径拆解malloc 与 kmalloc 的两条路线2.1 用户态 malloc 的完整链路我在 Linux x86-64 环境下用 glibc 做过一次小实验完整链路可以拆成四步第一步malloc(n) 进入 glibc 的 ptmalloc。它先在自己维护的空闲链表里找大小合适的 chunkchunk 是分配器管理的基本单位包含头部元数据和可用数据区。如果找到直接返回一个地址整个过程发生在用户态不进入内核速度极快。第二步如果空闲链表里没有合适的块ptmalloc 会判断请求大小。小于 mmap 阈值默认 128KB的走 brk 系统调用把堆顶往上推大于等于阈值走 mmap 系统调用直接在进程地址空间创建一块新的匿名映射区。第三步内核收到 brk 或 mmap 请求后只做一件事修改进程的地址空间描述结构mm_struct和对应的 VMA虚拟内存区域在页表里标记这块区域可访问但不分配任何物理页。这一步的关键是 VMA 操作非常轻量只是“登记”。第四步当你第一次读或者写这块区域的数据时CPU 查页表找不到物理页触发缺页异常。内核在缺页处理里走 anonymous page 分配流程从伙伴系统拿一页物理内存把页表补上然后返回用户态继续执行。所以 malloc 的语义本质是“我预订了这块虚拟空间但只有用了才算真的下单。” 我在测试机上 malloc(512MB) 之后看 RSS实际驻留内存结果只有不到 2MB 的变化多出来的那点还是分配器自身的元数据。然后我 memset 这块区域的每一页RSS 才逐步涨上去。2.2 内核态 kmalloc 的完整链路内核态不走虚拟内存的“预订”逻辑。kmalloc(4KB) 的流程是这样的kmalloc 首先根据请求大小选择对应的 slab 缓存比如 4KB 的请求会落到 size-4k 这个 kmalloc 缓存里。slab 分配器如果还有空闲对象直接取出一个返回。如果缓存空了slab 会调用底层页分配接口从 buddy 系统申请一页或者多页物理内存。这个请求一旦成功物理内存立刻被占用空闲内存马上减少。所以 kmalloc 返回的地址背后一定挂着一块真实物理页面。你不需要“访问”它来触发分配分配这个动作本身就完成了物理内存的扣款。写内核模块时我在模块加载函数里做 kmalloc(16 * PAGE_SIZE)紧接着看系统 MemFree数字立刻少了 64KB没有任何悬念。内核态的分配路径为什么这么“急”因为内核的服务对象是整个系统它自己不能像用户进程那样依赖缺页异常来“延迟兑现”。如果内核在中断上下文或持锁情况下访问一个没有物理页的地址缺页异常一旦需要等待、换页、甚至睡眠那就可能引发死锁或系统崩溃。所以内核在绝大多数分配路径上必须“一步到位”。2.3 为什么设计成“用户态懒、内核态急”这是内存管理里最值得想明白的设计考量。用户态懒是因为虚拟内存语义给了进程一个极大的缓冲空间。fork 之后的写时复制、文件映射、共享库、栈区自动增长全都依赖“先建虚拟映射、后分配物理页”这套延迟策略。如果每次 malloc 都立即分配物理内存一个程序哪怕只用了 5% 的分配区域也会占满物理内存而且进程启动时加载几百个共享库的开销会大得吓人。overcommit 机制也跟这套逻辑绑定系统允许进程申请超过物理内存的虚拟空间靠的就是“你未必全用完”的统计假设。内核态急是因为内核代码运行在系统的信任边界上不能赌未来的访问模式。内核处理的是所有进程的缺页异常、中断、调度如果内核自己也玩延迟分配递归处理缺页就是灾难。而且内核态内存不允许被换出到磁盘一旦延迟分配又遇到内存压力系统无法通过回收内核页来缓解。所以 kernel 内存几乎都是 pin 住的分配即占用。3. 最关键的差异物理页是什么时候真正到手的3.1 用户态“触摸内存”瞬间发生了什么很多人以为 malloc 返回 NULL 才叫失败其实最常见的失败发生在你访问内存的那一刻。我在项目里见过一个典型的 bug某服务启动时按最大配额 malloc 了一块 1GB 的缓冲区代码跑了一阵子后在写入热数据时进程突然被 OOM killer 杀掉。同事不理解“内存明明够啊。” 实际上那块 1GB 缓冲区在 malloc 后根本没有物理页系统看它的虚拟内存是 1GB但物理内存可能只占几十 KB。等到业务高峰期开始写入缺页异常像洪水一样涌进来一次次从 buddy 系统拿物理页物理内存瞬间被吃掉触发了 OOM。用户态第一次访问一个匿名页时内核的缺页处理流程大致是这样的CPU 触发 page fault陷入内核内核检查 VMA 和访问权限确认这次访问合法如果是读访问且该页还没有任何映射内核可以映射到系统唯一的“零页”zero page也就是所有进程共享的一个全零物理页省一页物理内存如果是写访问内核会分配一个新的物理页拷贝零页内容建立映射标记为可写返回用户态重新执行触发缺页的指令。这就是为什么 memset 一块刚 malloc 的内存物理内存会按页增长。如果你想确认一块内存到底吃了多少物理内存看 /proc/self/status 里的 VmRSS 字段就行。我做实验时写了个小程序malloc 后打 VmRSSmemset 后打 VmRSS两个数字差多少物理内存就真实消耗了多少。3.2 内核态申请的对等实验内核态没有“零页”这种偷懒方式。kmalloc 一旦成功你看到返回地址它背后就是一个已经准备好的物理页并且页表项已经建好如果是线性映射区甚至不需要额外的页表项因为直接映射是预先建好的。内核态内存的分配时点 物理页占用时点这是和用户态最本质的区别。我在开发一个字符设备驱动时做过一个测试驱动 open 时 kmalloc 一个 4KB 缓冲区读 /proc/meminfo 的 MemFree加载驱动前和加载驱动后的差值精确等于分配大小加上 slab 元数据的开销。我把 kmalloc 改成 vmalloc 再测MemFree 同样立刻减少。内核态不管走哪条分配器路径都不会“拖欠”物理页。3.3 用一张表看清分配时机的全貌对比维度用户态 malloc内核态 kmalloc/vmalloc返回的地址虚拟地址内核虚拟地址分配入口malloc → ptmallockmalloc → slab/slub系统调用brk / mmap无直接内核函数物理页分配时机首次访问对应页时调用返回前是否会触发缺页会通常不会内存是否可换出可以换出到 swap不可换出看占用物理内存指标VmRSS / smapsMemFree / kmem 统计分配失败表现返回 NULL 或访问时段错误返回 NULL调用方需检查这张表基本可以把 90% 的疑问说清楚。剩下的 10%在地址空间和物理连续性问题上。4. 地址空间、物理连续性与分配结果的区别4.1 用户态拿到的永远是“虚拟地址”用户态的程序员常常忽略一件事你写的指针地址和物理内存地址没有直接关系。它只是页表里的一个 key。两个完全不同的虚拟地址完全有可能映射到同一个物理页比如共享内存、写时复制的父页、零页。这样做的好处是进程隔离、按需装载、内存共享都很方便坏处是如果你做高性能计算、需要操作 DMA 缓冲区光有虚拟地址是不够的你还得知道它的物理地址或者至少把内存锁在物理内存里不换出。所以做用户态高性能 IO 时我一般直接用 mmap 配 MAP_POPULATE 或者调用 mlock目的就是强制让这块虚拟内存现在就绑上物理页避免后续缺页中断带来的延迟抖动。缺点是虚拟页对应的物理页不一定连续如果硬件要求连续物理内存用户态就得通过 DMA 映射接口或巨页配合。4.2 内核态线性映射区与 vmalloc 区的抉择内核态的内存地址也不是“裸物理地址”内核同样跑在虚拟地址上。只不过内核在启动时建立了一个涵盖整个物理内存的线性映射区比如 x86-64 上物理地址 0 到某上限会被映射到从 PAGE_OFFSET 开始的一段连续虚拟地址偏移固定。kmalloc 分配出来的内存绝大多数就落在这个线性映射区所以你可以通过 __pa(address) 直接算出它的物理地址。这种映射的页表项是常驻的几乎不会缺页性能极高。vmalloc 分配的内存则不同它位于 vmalloc 区域采用非连续映射每一页都要单独建立页表项访问时可能触发 TLB miss 和页表遍历。所以 vmalloc 适合大块、不要求物理连续、不常在热路径上使用的内存比如模块加载、转储缓冲区。我做驱动时对小的控制结构用 kmalloc对可能很大的 DMA 描述符表或日志缓冲区用 vmalloc性能差异在基准测试里非常明显。物理连续性这件事内核态比用户态更在意因为很多硬件网卡、磁盘控制器、GPU做 DMA 时要求源地址和目标地址在物理上是连续的。kmalloc 的 GFP_DMA 标志就是为这种场景准备的。如果贸然把一个用户态 malloc 的缓冲区地址传给 DMA 引擎硬件访问的物理页可能被换了可能不连续轻则数据错乱重则系统崩溃。4.3 大页与内存分配效率的额外补充用户态分配超过 mmap 阈值的大块内存默认走 mmap 映射普通 4KB 页。这种情况下1GB 缓冲区需要 26 万多个页表项缺页时也要一个一个处理这是用户态大内存分配最大的性能痛点。我在一个数据处理服务里遇到这种情况启动时预分配 8GB 缓冲区虽然用 MAP_POPULATE 把物理页一次性分配好了但初始化耗时长达数秒。后来改成 HugeTLB 巨页2MB 页页表项数量降到几千初始化时间缩短到一个零头。巨页还能减少 TLB miss对大内存随机访问的性能提升非常明显。内核态也有类似选择。如果驱动需要超大且连续的物理内存kmalloc 无能为力会考虑 alloc_pages 配合高阶 order或使用 CMA连续内存分配器。这类内存分配时机更严格往往还要考虑内存碎片、zone 限制、唤醒 kswapd 等问题。这些细节在实际项目中都会影响系统的稳定性不能只看“够用”。5. 实操怎么判断一块内存走到了哪一步5.1 用户态观测手段从 RSS 到 smaps最直观的方法是看进程的 VmRSS。写个简单 C 程序#include stdio.h #include stdlib.h #include string.h #include unistd.h void print_rss() { FILE *f fopen(/proc/self/status, r); char line[256]; while (fgets(line, sizeof(line), f)) { if (strncmp(line, VmRSS:, 6) 0) { fputs(line, stdout); break; } } fclose(f); } int main() { size_t size 512 * 1024 * 1024; char *p malloc(size); printf(after malloc:\n); print_rss(); memset(p, 0, size); printf(after memset:\n); print_rss(); free(p); return 0; }在 Linux 上编译运行你会看到 after malloc 和 after memset 的 VmRSS 差距巨大这等于把“惰性分配”直接摆在眼前。如果你只想拿一块虚拟空间但不着急用可以追加 madvise(p, size, MADV_DONTNEED) 或者 MADV_COLD 提示内核提前回收这比手动 memset 后等着系统回收要高效得多。排查内存泄漏时smaps 比 status 更好用。smaps_rollup 可以按区域看到匿名页、页表、共享页的统计smaps 的每个 VMA 条目里有 RSS、PSS、Private_Clean、Private_Dirty 等字段能帮你定位到底是哪段区域在涨内存。我排查一个边缘缓存服务的“慢泄漏”时就是靠对比不同时间点的 smaps 输出发现某个 mmap 区域持续上涨最后定位到是第三方库没有释放某个映射。5.2 内核态观测手段从 MemFree 到 slabtop内核态验证分配是否真的生效方法更直接。看 /proc/meminfo 的 MemFree加载一个做 kmalloc 的内核模块前后对比数值变化就是实际占用。如果你怀疑 slab 层出了问题用 slabtop 看各个 slab 缓存的对象数量和内存占用非常方便。kmalloc 的内存会归类到 kmalloc-* 这些缓存现在新版内核会根据配置展示为 kmalloc-rcl-* 或 kmalloc-cg-* 等slabtop 里能看到每个 size 缓存的对象数。我排查一个驱动反复加载卸载导致内存只增不减的问题时就是用 slabtop 发现某个 kmalloc 缓存的对象数在卸载后没有归零确认是模块卸载时漏了释放某个全局缓冲区。内核态排查还有一个实用技巧开启内存分配失败栈追踪。比如在调用 kmalloc 前用 WARN_ON(!ptr) 或者直接打开 page_owner 功能能在内存分配失败时拿到调用栈快速定位是谁在哪个路径上偷偷吃内存。生产环境里内存泄漏的定位难点往往不是“谁占着内存”而是“谁没释放”page_owner 能回答这个核心问题。5.3 常见问题速查现象可能原因排查思路用户态 malloc 大内存free 命令内存没降没触达物理页只有虚拟地址登记看 VmRSS不要用 free 判断进程第一次写大块缓冲时突然卡顿大量缺页中断逐页分配mmap MAP_POPULATE / mlock / 改用巨页kmalloc 返回 NULL驱动加载失败buddy 内存碎片或 zone 受限用 vmalloc 替代、调整 order、开启 CMA模块卸载后内存占用不清零slab 对象泄漏slabtop 对比、code review 释放路径用户态访问已 free 内存偶发段错误虚拟地址已解映射用 ASAN / valgrind 定位释放后再访问服务被 OOM killer 杀掉但 RSS 不高可能是瞬时缺页暴涨或进程虚拟内存巨高看 dmesg 的 OOM 日志、统计缺页次数vmalloc 区域地址访问慢非连续映射导致 TLB miss热路径改用 kmalloc / 线性映射我在实际项目里还养成一个习惯用户态大内存预分配后第一时间在代码里按页对整块区域做一次校验写入不是 memset而是顺序写入固定值再读一遍这一步能提前发现“能不能立刻拿到物理内存”的问题也能避免把缺页延迟带到线上峰值。如果不方便预写至少要保证对大缓冲区的首次访问是顺序的别一上来就随机跳着写否则缺页次数会非常难看。内核态这边我的经验是小的热数据结构用 kmalloc大的、低频的、不要求连续的直接 vmalloc能复用对象就上 kmem_cache别频繁 kmalloc/kfree所有分配必须检查返回值尤其是原子上下文里的 GFP_ATOMIC 分配失败率比你想象的高。踩过几次坑之后你会明白用户态和内核态内存申请的差别不是一句话能带过的它影响着你调试的方向、性能分析的指标以及生产环境出问题时你第一时间该查哪张表。以后再有人问“同样是一块内存用户态和内核态申请的到底差在哪”你可以直接让他去做一遍上面的小实验比背多少概念都管用。