☰
深入理解用户栈与核心栈的物理位置:虚拟地址到物理页帧的映射
2026/10/2 1:34:20 网站建设 项目流程

调试一台 64G 内存的机器搞到卡死之后,我才真正理解什么叫“栈的物理位置”——不是 gdb 里那个 0x7ffe... 开头的地址,而是这块内存条上的真实坐标。那次排查一个驱动程序在内核态递归过深,崩溃转储里 task->stack 指向 0xffffc90001228000,组里两个同事争论这到底是虚拟地址还是物理地址,谁也说服不了谁。后来用 crash 的 vtop 命令一翻页表,才发现从 0xffffc900... 到真正的物理页帧之间,隔着四级页表、一堆 PTE 和一个早在内核 4.9 就默认开启的 CONFIG_VMAP_STACK。用户栈和核心栈的物理位置,确实不是一句“在内存里”能糊弄过去的。这篇就把这个问题彻底讲透:两个栈各住在哪、物理页什么时候才真正分配、怎么亲手把地址翻译成物理页帧,以及这条知识链上最容易踩的坑。

1. 从两个栈的“居住证”说起:虚拟地址不是物理地址

要聊物理位置,第一件事是把概念拆干净。无论用户栈还是核心栈,程序里看到的、调试器里打印的,几乎全是虚拟地址。虚拟地址是一张“居住证”,上面写着门牌号,但门牌号不直接告诉你房子在城市的哪个角落。真正决定物理位置的,是操作系统在页表里为这个虚拟地址登记的物理页帧号(PFN)。所以“用户栈和核心栈的物理位置”这个问题,本质上是在问:这两块栈内存,最终被映射到了物理内存的哪些页帧上。

1.1 用户栈:进程地址空间里最“高”的一块

以最常见的 x86-64 Linux 为例,用户空间地址范围是 0x0000000000000000 到 0x00007fffffffffff(采用 4 级页表时)。进程地址空间里,可执行文件映射、堆、mmap 区域、共享库从低地址往高地址排,唯独用户栈被安排在用户空间的最顶端。用cat /proc/<pid>/maps看,最后一两行通常长这样:

7ffe3a592000-7ffe3a5b3000 rw-p 00000000 00:00 0 [stack] 7ffe3a5b3000-7ffe3a5b6000 r--p 00000000 00:00 0 [vvar] 7ffe3a5b6000-7ffe3a5b7000 r-xp 00000000 00:00 0 [vdso]

这段[stack]的 VMA 是 exec 加载程序时由setup_arg_pages()搭好的,把环境变量、argv、初始栈帧一口气铺在顶部,然后栈指针往下长。默认情况下RLIMIT_STACK是 8MB,也就是ulimit -s看到的 8192 KB。注意,这段 VMA 只是“地址计划”,计划里的绝大部分页此时还没有物理页支撑,真正分配物理页的动作要等到栈被访问的那一刻才发生。

1.2 核心栈:每个线程私有的内核工作台

核心栈(kernel stack)和用户栈是两套完全独立的体系。每个线程(Linux 里叫 task)都有一块私有的核心栈,大小由THREAD_SIZE决定。x86-64 上默认是 16KB,即 4 个物理页;如果开了 KASAN,会变成 32KB。这块栈是线程从用户态陷入内核态、执行系统调用、处理中断或异常时的临时工作台。为什么内核不能直接复用用户栈?因为内核态代码不能信任用户空间传进来的任何指针,而且用户栈页可能被换出、可能被用户程序自己改坏,内核必须用一块自己完全掌控、常驻物理内存的栈才能保证系统调用和中断处理的稳定性。

核心栈通常从高地址向低地址生长,最顶上的位置还留了一小段给pt_regs,用来保存用户态寄存器现场。老版本内核里,thread_info结构体放在核心栈的最底部;后来CONFIG_THREAD_INFO_IN_TASK把这个结构体挪进了task_struct,核心栈就纯粹是“栈”了。

1.3 一张表看清两个栈的“住址”差异

维度用户栈核心栈
归属进程用户态地址空间每个线程/任务私有
典型大小RLIMIT_STACK,默认 8MBTHREAD_SIZE,x86-64 默认 16KB
生长方向高地址向低地址高地址向低地址
虚拟地址区域用户空间顶部[stack]vmalloc 区或内核线性映射区
物理页分配时机首次访问,由缺页异常触发创建线程时一次性分配
是否可换出可以,会写到 swap不可换出,常驻物理内存
溢出保护guard page + stack guard gapvmalloc guard page(开启后)
物理连续性不保证连续不保证连续,vmap 后更分散

这张表里最反直觉的一点是:用户栈看着是一整段连续虚拟地址,物理页却可能散落在内存条的不同位置;核心栈在开启CONFIG_VMAP_STACK之后,干脆连虚拟地址都不是连续线性映射的一部分了。

1.4 为什么非要追问物理位置

日常写业务代码,确实不需要关心栈的物理位置。但只要涉及三类场景,这个问题就会跳出来:一是性能调优,NUMA 架构下栈页落在哪个节点,直接影响线程访问延迟;二是稳定性排查,栈溢出、栈页被换出、guard page 触发,都藏在“物理位置”这个维度里;三是底层调试,拿到一个内核地址,想确认它对应哪块内存条、哪个页帧、是不是有效映射,必须做虚拟地址到物理地址的翻译。这几个需求在后面几节都会一一落地。

2. 用户栈的物理页是怎么一步步“落地”的

理解了“居住证”和“实体房”的区别之后,来看用户栈的物理页到底什么时候分配、从哪里来。

2.1 进程启动时只登记了“虚拟计划”

现代 Linux 对匿名内存几乎全部采用惰性分配。进程 exec 之后,内核只是给栈建好了 VMA,设置了起始地址、大小、权限(rw-p),但不会急着去 buddy 系统里申请 8MB 物理页。一个刚启动的进程,栈区看起来存在,实际页表里对应栈地址的 PTE 大多是空的。只有当 CPU 执行指令读写栈内存、触发缺页异常(page fault)时,内核才会真正分配物理页。这也解释了为什么一个进程刚启动时 RSS 很小:8MB 的栈虚拟区间,可能只占了几十 KB 物理页。

2.2 第一次入栈:缺页异常按下分配键

用户栈触发的缺页属于匿名页缺页。x86-64 上,缺页异常入口是do_user_addr_fault(),一路走到handle_mm_fault(),最终在do_anonymous_page()里完成物理页分配。这一步的关键代码逻辑大致是:通过vma_alloc_folio(GFP_HIGHUSER_MOVABLE, 0, vma, vaddr, false)从页分配器拿一个 order-0(4KB)的物理页,然后清空页面内容,再建立 PTE 映射。

这里有两个细节值得记住。第一,分配源是 per-cpu page list(PCP),也就是 buddy 分配器为每个 CPU 预热的空闲页缓存,速度极快;第二,遵循 NUMA “首触分配”原则,物理页会从当前线程所在内存节点分配。也就是说,栈的物理位置一定程度上取决于线程第一次动栈时跑在哪个 CPU 上。这个特性在文章后面调优部分还会提到。

2.3 栈往下长一页就缺一页:VM_GROWSDOWN 与 guard

用户栈的 VMA 带有VM_GROWSDOWN标志,意味着栈可以向下扩展。CPU 访问到当前栈 VMA 范围之下、但又在允许范围内时,缺页处理会调用expand_stack()扩展 VMA 下边界,然后继续分配物理页。但这种扩展不是无限制的:内核要检查RLIMIT_STACK上限,还要检查stack_guard_gap。更关键的是,VMA 下方通常会保留一个不可访问的保护页,或者一段空置的 vm gap,一旦线程真的踩过界,直接触发 SIGSEGV。

这就是为什么递归过深时程序报“段错误”而不是静默写坏内存:栈下方那一页根本没有映射,物理页也没分配,硬件翻译地址失败,内核判断是非法访问,直接给进程发信号。换句话说,物理页的“缺失”本身就是一道防护栏。

2.4 用户栈物理页的真实分布特征

实测过几次之后,可以把用户栈物理页的分布规律总结为三点:

  • 栈的物理页几乎都是 order-0 单页,散落在 buddy 系统的空闲页列表里,不是物理连续的大块。
  • 大量栈页倾向于落在主线程首次运行时的同一个 NUMA 节点上,但具体页帧之间可能隔着其他进程的页。
  • 栈页属于GFP_HIGHUSER_MOVABLE分配出来的可回收/可移动页,在内存压力下可以被内核回收、写入 swap。一个挂起很久的进程,它的用户栈物理页可能已经不在内存里了,等它恢复运行、访问到栈时,再靠缺页异常重新申请新物理页。

理解这个分布,后面查 pagemap、做 NUMA 优化时就不会犯迷糊。

3. 核心栈的物理位置:vmap 出现前后的两种玩法

核心栈和用户栈最大的不同在于:它是内核自己用的栈,必须随时可用,不能换出。它的物理位置在哪,跟内核版本、编译选项有直接关系。

3.1 老办法:直接映射区域里的固定一亩三分地

在CONFIG_VMAP_STACK普及之前,核心栈是从内核线性映射区(direct map)分配的。x86-64 上,这段区域地址范围通常在 0xffff888000000000 附近,物理内存被一比一映射进去,满足关系:

物理地址 = 虚拟地址 - page_offset_base

也就是说,只要你拿到了核心栈的虚拟地址,减去线性映射基址,就能直接算出物理地址。以前很多老内核调试经验里说“把栈地址和物理地址互转”,指的就是这种映射关系。老版本里核心栈通过kmem_cache_alloc_node()从thread_info_cachep这类 slab 缓存分配,地址按THREAD_SIZE对齐,thread_info就藏在栈底,用current_thread_info()一算就出来。

3.2 CONFIG_VMAP_STACK:核心栈搬进 vmalloc

从内核 4.9 开始,x86-64 默认启用CONFIG_VMAP_STACK,核心栈的分配逻辑彻底变了。内核的 fork 路径里,alloc_thread_stack_node()不再从 slab 或直接映射区拿内存,而是调用:

stack = __vmalloc_node_range(THREAD_SIZE, THREAD_ALIGN, VMALLOC_START, VMALLOC_END, THREADINFO_GFP, PAGE_KERNEL, 0, node, __builtin_return_address(0));

这块 16KB 的栈被分配在 vmalloc 区域,典型地址形如0xffffc90001228000。它的虚拟地址不再和物理地址有直接换算关系,而是由每页自己的 PTE 单独映射。启用 vmap 栈的最大收益是安全:vmalloc 可以在每块栈之间插入不可访问的 guard page,一旦核心栈溢出,CPU 会立刻触发 oops,而不是悄无声息地覆盖相邻内核对象,后者在老内核上是极难排查的内存损坏源。

但副作用也明显:栈的 4 个物理页是分别映射的,物理位置更加不连续,也失去了“虚拟地址减基址就是物理地址”的便利。排查时必须通过页表翻译才能拿到真实物理页帧。这也是为什么现在用 crash 这类工具时,vtop的出场率特别高。

3.3 核心栈为什么必须“钉”在物理内存里

核心栈不能换出,不是内核偷懒,而是体系结构上的硬约束。当 CPU 执行系统调用、中断处理时,内核代码要在这个栈上压栈、调用函数。如果栈所在物理页被换出到 swap,中断处理过程中触发缺页、再去读磁盘,这个过程本身又需要栈——典型的先有鸡还是先有蛋。所以核心栈的页从分配那一刻起就属于内核常驻内存,不允许回收。开启 vmap 栈后,这些页虽然由 vmalloc 管理,但同样不会被 swap 出去。调试时如果发现某个线程的核心栈地址解析出来的 PTE 无效,那大概率不是被换出了,而是栈页分配时就是按 4KB 单页映射的,要逐项翻译,不能想当然按大块连续内存处理。

3.4 核心栈页的分配路径与 NUMA 行为

虽然从 vmalloc 分配,但物理页最终仍然来自底层页分配器。__vmalloc_node_range()内部会按节点申请 order-0 页,分配标志里带GFP_KERNEL_ACCOUNT。和用户栈类似,它遵循 NUMA 首触原则:创建线程的 CPU 在哪个节点,物理页基本就落在哪个节点。这里有个实践推论:如果你用taskset把某个线程钉在 node 1 上,但它最初是在 node 0 被创建的,那么它的核心栈页可能在 node 0,线程运行时访问这些栈页就要跨节点,延迟明显更高。高并发场景下,尽量在目标节点上创建线程,能少交这趟“远程内存”的过路费。

4. 亲手验证:从虚拟地址到物理页帧的一条龙操作

原理说再多,不如自己跑一遍。这一节给出两种最常用的验证手段:用户栈走/proc/self/pagemap,核心栈走 crash 工具的vtop。

4.1 用户栈:把 /proc/self/pagemap 读穿

pagemap是内核暴露给用户态的页表视图,进程可以读自己的映射。每个虚拟页对应 8 字节的条目,其中第 63 位表示页面是否 present,第 62 位表示是否在 swap,第 0-54 位是物理页帧号 PFN。注意,4.0 之后的内核要求读取 PFN 时具备CAP_SYS_ADMIN权限,所以得用 root 运行。

这里给一个可以直接抄的 C 版本:

#include <stdio.h> #include <stdint.h> #include <fcntl.h> #include <unistd.h> #include <inttypes.h> static uint64_t virt_to_phys(uint64_t vaddr) { int fd = open("/proc/self/pagemap", O_RDONLY); if (fd < 0) { perror("open pagemap"); return 0; } uint64_t entry = 0; off_t off = (off_t)(vaddr / 4096) * 8; pread(fd, &entry, 8, off); close(fd); if (!(entry & (1ULL << 63))) { printf("page not present, swapped=%d\n", (int)((entry >> 62) & 1)); return 0; } uint64_t pfn = entry & ((1ULL << 55) - 1); return pfn * 4096 + (vaddr & 4095); } int main(void) { volatile int local = 0; uint64_t va = (uint64_t)&local; uint64_t pa = virt_to_phys(va); printf("stack vaddr: 0x%016" PRIx64 "\n", va); if (pa) printf("phys addr: 0x%016" PRIx64 "\n", pa); return 0; }

编译后 root 运行,输出类似:

stack vaddr: 0x00007ffe3a5b1cac phys addr: 0x00000003e8410cac

拿到物理地址后,可以用cat /proc/zoneinfo | grep -A5 "Node 1"之类的输出交叉判断它落在哪个 NUMA 节点。跑这个实验时建议在栈上多声明几个局部变量,确保那一页真的被触达过,否则 PTE 不存在,会读到一个未 present 的条目。

4.2 核心栈:用 crash 的 vtop 翻页表

核心栈的地址拿法比用户栈绕一些,最省事的路径是 crash 工具。对着一份 vmcore 或正在运行的 vmlinux,先看任务的内核栈指针:

crash> task -R stack 1234 PID: 1234 TASK: ffff88810a4e0000 CPU: 2 COMMAND: "demo" task->stack = 0xffffc90001228000

这里0xffffc90001228000就是核心栈的起始虚拟地址。下一步直接 vtop:

crash> vtop 0xffffc90001228000 VIRTUAL PHYSICAL ffffc90001228000 3e841000 PGD: fffffe0000078003 -> 2c6c9067 PUD: 1a9c067 -> 2c6c9067 PMD: 2c6c9ff0 -> 2c6c9067 PTE: 2c6c9ff8 -> 3e841025 PAGE: 3e841000

(输出示例,数值已做处理。)最后一行 PAGE 就是核心栈起始物理地址对应的页帧。如果栈虚拟地址在 vmalloc 区,crash 会自动遍历 vmalloc 区域的页表,不需要你手动区分映射类型。

4.3 手动走一遍四级页表

没有 crash 工具、又要临时手工确认时,可以手动做页表遍历。x86-64 4 级页表下,虚拟地址从左到右拆成 PGD、PUD、PMD、PTE 四组索引,每组 9 位,加上最低 12 位页内偏移,总共 48 位可寻址空间。以0xffffc90001228000为例:

字段计算方式示例值
PGD 索引(vaddr >> 39) & 0x1ff0x1ff
PUD 索引(vaddr >> 30) & 0x1ff0x145
PMD 索引(vaddr >> 21) & 0x1ff0x091
PTE 索引(vaddr >> 12) & 0x1ff0x280
页内偏移vaddr & 0xfff0x000

真正手工实现时,你需要从 CR3 寄存器拿到进程页表基址(对内核空间地址,则是主内核页表),然后逐级读出下一级页表地址,直到最后一层 PTE 的 PFN,最后乘以页大小加上页内偏移,就是物理地址。这个过程的本质就是把 CPU 缺页时硬件自动做的事重做一遍,用来排查“为什么这个地址翻译不出来”最有效。如果读到某级条目是 0,就说明该级页表缺失,虚拟地址自然不可用。

4.4 验证时最容易翻车的三个细节

第一个细节是权限。用户态读自己的 pagemap,present 位能看到,但 PFN 字段在无特权时会被清零,别一看到 0 就以为页面不存在,先确认是不是 root。第二个细节是栈页没触达。惰性分配下,虚拟区间存在不代表页表里有 PTE,读出来的条目可能是 0,需要先保证程序真的访问过该地址。第三个细节是 5 级页表。带 LA57 的 CPU 上,地址拆分多了一层 P4D,手工计算索引时要多拆 9 位,否则后面所有索引全部错位,这是我在新平台排查中断过最久的问题。

5. 这些“物理位置”知识连着哪些坑

最后这部分不是附加题,而是前面所有原理在实际环境中会碰到的四个真实问题。

5.1 “扇区物理位置重分配事件计数”不是栈

如果你是因为“扇区物理位置重分配事件计数: 当前值100 最差值100 临界值0”这个词搜过来的,先说清楚:这是硬盘 S.M.A.R.T. 里的 Reallocated Sectors Count(重映射扇区计数)属性,编号 05。当前值 100、最差值 100、临界值 0,意思是这个健康属性得分满格,硬盘没有发生需要把物理坏扇区重映射到备用区的重分配事件。这里的“物理位置”指磁盘上的扇区物理块,属于块设备层的概念,和本文讨论的进程栈完全是两个语境。搜技术资料时遇到同名的“物理位置”关键词,先分清楚对象是内存、磁盘还是其他外设,不然很容易被带偏。

5.2 用户栈溢出为什么通常是段错误而不是写烂内存

很多人以为栈溢出就是“往低地址方向写越界,把下面的内存写坏”。实际上因为惰性分配和 guard page 的存在,用户栈越界的第一步通常是触达未映射区域,直接 SIGSEGV。真正危险的反而是那些看起来没崩的溢出:比如某次函数里数组越界不大,写的地址还在已映射的栈页内,就不会触发缺页错误,数据悄悄覆盖了相邻栈变量,表现为各种莫名其妙的逻辑错乱。排查这种问题没有捷径,只能靠 ASan、栈保护变量和仔细的代码审查。

5.3 栈页被换出后“物理位置”就消失了

用户栈的物理位置不是永久的。内存压力大时,内核可以把栈页写进 swap。这时你再查 pagemap,第 62 位置 1,第 63 位 present 为 0,PFN 字段不再代表物理页帧。此时说“栈的物理位置”已经没有意义——它不物理了。这也是调试挂起进程、抓完 vmcore 之后分析时特别要注意的点:vmcore 里很多用户栈页可能是 swap 形态,需要用 swap 信息恢复内容,不能直接拿 PFN 去读物理内存。高可用场景里,如果不想让关键进程的栈被换出,可以研究mlock()或者把进程内存放到不可回收的 cgroup 配置里,但这要付出常驻内存的代价,属于典型的空间换稳定。

5.4 实践经验:NUMA 首触分配与线程迁移的取舍

结合前面几节,我最后分享一条在实践中反复验证过的经验。如果你的程序对延迟敏感,请遵守三条规则:一是线程创建时就固定 CPU 亲和性,用sched_setaffinity()绑到目标节点,确保核心栈页首触分配在正确节点;二是线程的主栈访问要尽量发生在线程被绑定的节点上,避免先在其他节点跑起来,把栈页都分配错了位置;三是一旦发现线程跨节点迁移,不要只盯着堆内存,用户栈和核心栈的远程访问同样会造成可观的延迟波动。用numastat观察局部命中率,如果 miss 比例突然升高,先检查是不是线程搬家导致栈页留在老家。

对这个话题,我实际排查时最顺手的一条命令组合是:先用 crash 的bt确认内核栈地址,再vtop出物理页帧,最后用/proc/iomem和zoneinfo交叉确认节点归属。这套动作看起来笨,但在定位“栈地址翻译失败”“跨节点访问异常”“栈溢出写坏相邻内存”这几类问题上,几乎百试百灵。栈这个东西,越往底层挖越有意思,但前提是先把虚拟地址和物理位置的关系刻在脑子里。

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

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

立即咨询