深入理解用户栈与核心栈的物理位置:虚拟地址到物理页帧的映射
调试一台 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_SIZEx86-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-04KB的物理页然后清空页面内容再建立 PTE 映射。这里有两个细节值得记住。第一分配源是 per-cpu page listPCP也就是 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就是核心栈的起始虚拟地址。下一步直接 vtopcrash 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) 0x1ff0x1ffPUD 索引(vaddr 30) 0x1ff0x145PMD 索引(vaddr 21) 0x1ff0x091PTE 索引(vaddr 12) 0x1ff0x280页内偏移vaddr 0xfff0x000真正手工实现时你需要从 CR3 寄存器拿到进程页表基址对内核空间地址则是主内核页表然后逐级读出下一级页表地址直到最后一层 PTE 的 PFN最后乘以页大小加上页内偏移就是物理地址。这个过程的本质就是把 CPU 缺页时硬件自动做的事重做一遍用来排查“为什么这个地址翻译不出来”最有效。如果读到某级条目是 0就说明该级页表缺失虚拟地址自然不可用。4.4 验证时最容易翻车的三个细节第一个细节是权限。用户态读自己的 pagemappresent 位能看到但 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 为 0PFN 字段不再代表物理页帧。此时说“栈的物理位置”已经没有意义——它不物理了。这也是调试挂起进程、抓完 vmcore 之后分析时特别要注意的点vmcore 里很多用户栈页可能是 swap 形态需要用 swap 信息恢复内容不能直接拿 PFN 去读物理内存。高可用场景里如果不想让关键进程的栈被换出可以研究mlock()或者把进程内存放到不可回收的 cgroup 配置里但这要付出常驻内存的代价属于典型的空间换稳定。5.4 实践经验NUMA 首触分配与线程迁移的取舍结合前面几节我最后分享一条在实践中反复验证过的经验。如果你的程序对延迟敏感请遵守三条规则一是线程创建时就固定 CPU 亲和性用sched_setaffinity()绑到目标节点确保核心栈页首触分配在正确节点二是线程的主栈访问要尽量发生在线程被绑定的节点上避免先在其他节点跑起来把栈页都分配错了位置三是一旦发现线程跨节点迁移不要只盯着堆内存用户栈和核心栈的远程访问同样会造成可观的延迟波动。用numastat观察局部命中率如果 miss 比例突然升高先检查是不是线程搬家导致栈页留在老家。对这个话题我实际排查时最顺手的一条命令组合是先用 crash 的bt确认内核栈地址再vtop出物理页帧最后用/proc/iomem和zoneinfo交叉确认节点归属。这套动作看起来笨但在定位“栈地址翻译失败”“跨节点访问异常”“栈溢出写坏相邻内存”这几类问题上几乎百试百灵。栈这个东西越往底层挖越有意思但前提是先把虚拟地址和物理位置的关系刻在脑子里。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →