尧图精选

用户栈与核心栈的物理位置:从页表映射到SMART重分配

🕒 发布时间:2026/10/2 9:05:33 📁 来源:尧图网络
前阵子给一台服务器做例行巡检smartctl输出里有一条“扇区物理位置重分配事件计数: 当前值100 最差值100 临界值0”。乍一看分值全绿但再看一眼 RAW 列我意识到这块盘的固件正在后台悄悄给有问题的扇区“搬家”把数据从即将报废的物理位置挪到备用区域。这套逻辑一下子让我想起平时排查内核问题时总绕不开的那个话题——用户栈和核心栈的物理位置。在操作系统里搞明白“你写的局部变量到底放在哪”不能只停留在“用户栈在栈顶、核心栈在内核地址空间”这种层面。真正要回答的是三件事虚拟地址落在哪个区间、对应的物理页在哪里、缺页或换页时这个位置能不能变。存储扇区的坏块重映射和内存栈的物理页管理有一个共通点逻辑地址都可以保持不变变的只是底下那块物理介质的位置。但用户栈和核心栈在“能不能搬家”这件事上待遇完全不同。这篇就从两个栈的物理位置说起中间会穿插我排过的一些内核栈溢出问题最后绕回 SMART 里那串“扇区物理位置重分配事件计数”。你会发现底层系统之间都是相通的。适合正在啃 Linux 内核内存管理、常和驱动崩溃或 SMART 报警打交道的读者。1. 用户栈和核心栈各自在什么“位置”1.1 用户栈进程地址空间里那位“高位住户”Linux 下每个进程默认有一个主线程栈在 x86_64 上它的起始虚拟地址通常位于地址空间的最高处附近也就是0x7ffffffff000左右方向是向下增长。函数每调用一层就往低地址压一帧局部变量就存在这里。这个区域在/proc/PID/maps里会标注成[stack]。线程栈则是由pthread_create时 glibc 通过mmap在进程地址空间里找一块区域栈顶对齐、向下扩展。第一件事要明确平时说的“栈顶”往往是高地址压栈是往地址减小的方向走很多新手在这里绕晕。从物理位置的角度讲用户栈的虚拟地址范围并不等于物理地址范围。一个默认 8MB 的栈最初可能只有几页映射了物理页其余全是悬空的虚拟地址只有你访问到那一页、触发缺页异常内核才会分配物理页并挂上页表项。所以你在栈上声明一个 1MB 的数组如果完全不写它实际不占物理内存一旦往里写数据页才真的分配出来。这也是为什么 Linux 的栈上限ulimit -s限制的其实是虚拟地址空间大小而不是物理内存占用。现代发行版普遍开启了 ASLR主线程栈的起始地址会有随机偏移。所以排查问题时别看到别人贴的0x7ffffffde000就以为你的也一样。判断栈的位置最靠谱的方式是直接看/proc/self/maps不要凭经验猜地址。1.2 核心栈内核给每个线程留的“独立小房间”和用户栈的巨大空间相比核心栈显得相当“抠门”。在 x86_64 上一个线程在内核态执行时使用的核心栈通常只有 16KB32 位系统上是 8KB。这个栈在每个线程创建时单独分配内核用它执行系统调用、中断处理、驱动代码。它的虚拟地址落在内核地址空间不在进程的用户地址空间里。也就是说即使用户栈有 8MB一进内核就换成了一个 16KB 的小房间。核心栈的物理位置有两种实现方式。老式做法是用连续物理页映射到内核线性区虚拟地址和物理地址只差一个固定偏移好处是地址翻译简单、性能好坏处是栈溢出时没有保护页越界写会直接污染相邻物理页破坏很隐蔽。新内核默认开启CONFIG_VMAP_STACK核心栈改用 vmalloc 区域映射各页之间插入了不可写的 guard page。一旦栈越界碰到保护页立刻触发异常把“静默破坏”变成“当场崩溃”反而更容易排查。但无论哪种实现核心栈的物理页一旦分配就常驻内存不会参与 LRU 换出。为了一个可能只有几微秒的系统调用Linux 宁可让每个线程都握着一块不可回收的物理内存这是为了保证内核路径在任何情况下都能安全执行。1.3 两个栈的关键差异速查维度用户栈核心栈虚拟地址归属进程用户空间内核地址空间典型大小默认 8MB可调8KB/16KB 固定物理页分配按需缺页分配线程创建时分配能否换出可以换出并按需换入不能换出始终常驻缺页是否允许睡眠允许不允许睡眠会死锁溢出典型后果进程 SIGSEGV内核 panic 或提权保护手段guard gap、栈保护guard page、KASAN这张表基本把两者物理位置的差异说清了。下面展开讲背后的机制。2. 物理内存视角虚拟地址到物理页的映射2.1 页表把“逻辑位置”翻译成“物理位置”的翻译官CPU 执行指令时访问的是虚拟地址。虚拟地址经过 MMU 查找页表最终换算成物理地址。页表条目里记录了物理页帧号、权限、存在位等信息。对用户栈和核心栈来说“物理位置”就是这一条页表映射指向的物理页帧号而“能不能动”取决于这条映射是否被拆除、物理页是否被回收。写内核代码的朋友都知道用户态指针不能直接拿来解引用得用copy_from_user或copy_to_user。主要原因之一就是用户栈的页可能不在内存里进入内核后访问用户页必须经过缺页异常处理这个流程允许睡眠等待。如果你在自旋锁里直接访问用户栈页面一旦缺页就会睡眠自旋锁持有状态被破坏轻则死锁重则 panic。这就是两个栈“物理位置”差异带来的编码约束。页表还有一个特点每个进程的用户页表都不一样而内核地址空间的映射是所有进程共享的。所以核心栈虽然属于某个线程但内核页表在进程切换时都会把内核映射部分保持原样核心栈的地址不需要随进程切换而切换这就是它“一直可见”的原因。2.2 用户栈物理页按需分配还能换出缺页异常是用户栈物理位置变化的真正入口。第一次访问某个栈页时CPU 找不到该虚拟地址的页表项触发缺页内核检查发现这个地址在栈的 VMA 范围内并且满足写权限就分配一个物理页在页表里建立映射然后返回用户态重新执行那条指令。这个过程对用户进程透明但会带来延迟也意味着用户栈的物理页是动态分配的。当内存压力增大内核的页面回收机制会挑出进程的匿名页把数据写入 swap 区在页表项里标记为“不在内存”。之后进程再次访问该地址再次触发缺页内核从 swap 读回数据重新分配物理页并映射。这个“读回”过程需要磁盘操作允许进程睡眠等待所以用户栈物理页可以换出。它的物理位置始终可变甚至同一虚拟地址在不同时间映射到不同的物理页帧。这也解释了为什么“栈”的物理位置对普通开发者是无感的只要虚拟地址有效物理页在哪都无所谓。但性能上如果 swap 抖动严重你会发现线程卡顿明显其实就是缺页频繁在发生。2.3 核心栈物理页常驻内存从不参与换页核心栈不能走换页这条路。原因很直接内核很多代码运行在原子上下文里比如持锁、中断处理、软中断等这些环境不允许睡眠。换页需要分配内存、等待 IO、获取锁这一整套操作随时可能睡眠。一旦睡眠持有锁的内核路径就会死锁甚至引发竞态。所以核心栈的物理页从不放进 LRU 回收链表也不会有“核心栈 swap 到磁盘”这种操作。它从线程创建时分配到线程销毁时释放生命周期和线程严格绑定。在非 VMAP_STACK 的老式配置里核心栈物理页还必须连续因为线性映射假设虚拟地址和物理地址差一个固定偏移连续虚拟页必然对应连续物理页。这在嵌入式场景里是个痛点内存碎片化可能导致分配连续页面失败从而线程创建失败。VMAP_STACK 把这个约束放宽了物理页可以不连续虚拟地址连续就行分配成功率更高这也是现代内核普遍开启的原因之一。常驻内存的另一个代价是如果系统创建大量线程即使它们大部分时间休眠核心栈也会占用不少物理内存。比如一个 4GB 内存的服务器开 1000 个线程每个核心 16KB仅核心栈就要约 16MB这部分完全没法回收。运维看到“线程数高导致内存占用高”其中就有核心栈的功劳。3. 物理位置决定命运栈溢出的两种“死法”3.1 用户栈溢出进程崩溃世界照常用户栈溢出的最常见姿势是递归过深、局部数组过大。现代编译器会在用户态加栈保护ProPolice在栈帧里放一个金丝雀值函数返回前检查一旦被改写直接 abort。另外栈的末端有不可访问的 guard page。如果溢出刚好越过栈底CPU 访问保护页会触发 SIGSEGV进程带着栈回溯退出。对系统来说这只是“一个进程挂掉”内核和其他进程不受影响。但用户栈溢出也不总是温和的。如果溢出方向不是顺着栈底而是越过了 VMA 边界写进了另一个线程栈、堆或 mmap 区域就可能破坏其他数据导致难以定位的崩溃。内核有stack_guard_gap机制专门在栈区域下方留出一段不可映射的空隙防止向下溢出直接撞上其他映射。我在服务端排查过一个随机段错误gdb 里每次崩溃位置都不一样最后定位到是函数里定义了一个 4MB 的临时数组栈上限只有 8MB加几层调用后直接压爆。这类问题在代码 review 里很难看出来主要靠运行时压测和栈监控。3.2 核心栈溢出内核比你想象的脆弱核心栈溢出要严重得多。一个 16KB 的小栈只要驱动里有一个稍大的局部数组或者调用链太深很快就越界了。老式线性映射下越界写会直接覆盖旁边物理页的内容这些物理页可能属于其他内核数据结构、页表甚至文件缓存。因为不会立即崩溃系统可能运行一阵子后才以莫名其妙的方式挂掉——数据损坏、随机 panic甚至权限绕过。这也是内核漏洞里提权漏洞最常见的来源。开启 VMAP_STACK 后核心栈的页与页之间隔了 guard page越界写一旦碰到保护页会立刻触发异常产生类似BUG: stack guard page was hit的 oops。系统会直接 panic。看起来很粗暴但比“带伤运行然后突然爆炸”容易排查太多至少你能通过崩溃点知道问题来自哪里。我遇到过一起典型的网卡驱动问题驱动在中断上下文里调用了一个带 4KB 局部缓冲的函数。平时流量小没事一旦流量上来中断触发频率提高核心栈在特定条件下就越界了。核心栈小中断路径尤其危险因为你不知道中断嵌套会占用多少栈也不知道驱动里的辅助函数到底用了多少。3.3 实践中更有效的防护和排查手段排查栈问题我实际用下来最顺手的几招第一开发环境务必开启CONFIG_VMAP_STACK和CONFIG_PAGE_TABLE_CHECK把越界问题提前暴露出来。第二利用 tracefs 里的stack_max_size观测内核栈峰值。挂载 tracefs 后清空统计、跑负载、再看数值# 挂载 tracefs新版内核路径 mount -t tracefs none /sys/kernel/tracing # 查看当前内核栈最大使用量 cat /sys/kernel/tracing/stack_max_size # 清空统计 echo 0 /sys/kernel/tracing/stack_max_size # 触发负载后再次查看 cat /sys/kernel/tracing/stack_max_size如果这个值已经接近 16KB说明你的内核栈余量很少某个驱动或路径正在吃栈一定要追。第三用 ftrace 的 stacktrace 插件抓调用链的栈占用能定位到具体函数路径。第四驱动开发时尽量避免在栈上放大对象超过一两个页面就改用kmalloc或kvzalloc。用户态侧可以用stress-ng --stack 1压一下栈深度或者临时调小ulimit -s观察服务是否还稳定。4. 从内存物理位置到扇区物理位置SMART重分配事件的读法4.1 扇区物理位置重分配事件计数到底是什么从内存跳回存储。SMART 里“扇区物理位置重分配事件计数”对应属性 ID 05英文名通常是Reallocated Sectors Count或Reallocated Event Count。含义是当硬盘发现某个逻辑扇区对应的物理区域不可靠坏道、NAND 坏块、ECC 异常固件会把该逻辑扇区重映射到预留的备用物理区域同时在属性里累加计数。标题里那行输出扇区物理位置重分配事件计数: 当前值100 最差值100 临界值0就是smartctl对这块板卡或硬盘该项属性的归一化健康评分。这和内存页的“换出换入”有些类似逻辑地址LBA不变底下的物理位置PBA变了上层文件系统完全无感。不同的是内存换页是系统频繁主动为之而扇区重分配是固件被动触发的“缓兵之计”。每次重分配都意味着盘上有一个物理位置寿终正寝备用区域也在被一点点消耗。4.2 手把手读取和判断这组数值用 smartmontools 可以直接看到详情sudo smartctl -A /dev/sda输出里找到Reallocated_Sector_Ct或Reallocated_Event_Count。对标准 ATA 硬盘一般是属性ID关键信息Reallocated_Sector_Ct05已重映射扇区数持续增长说明坏道在增多Reallocated_Event_CountC4重定位事件总次数Current_Pending_SectorC5当前等待重映射的扇区数Offline_UncorrectableC6离线扫描无法修正的扇区数判断逻辑很简单VALUE和WORST越高越好THRESH是故障下限。本例100/100/0表示当前健康度满分、历史最低也是满分、阈值是 0单看分数确实很健康。但必须同时看 RAW 列RAW 才是原始计数。对 05 来说RAW 是 0 最好如果 RAW 已经几十甚至上百说明盘上实际发生过不少次坏块重分配。有一个坑不同厂商的 SMART 实现差异很大。有些盘的 RAW 值是拼接多字段的十六进制直接看十进制会误判有些盘的 VALUE 常年保持在 100直到突然降到 0 才报警。所以我给自己定的习惯是先看 RAW 变化趋势再看 VALUE/WORST最后结合smartctl -H和smartctl -l error综合判断。单个属性不能代表整块盘的健康程度。4.3 重分配事件对系统的影响和监控建议重分配事件增多带来的直接影响是访问延迟不稳定。每遇到一次坏扇区固件要先做错误校验、重映射再读备用区开销远高于正常读。持续增长通常意味着盘在老化。对服务器运维我会配置 smartd 做主动监控/dev/sda -a -o on -S on -W 0,45,55 -m root -M test大意是开启所有属性监控、开启离线数据采集、温度阈值设为 0/45/55 摄氏度出问题发邮件给 root。如果盘已经在持续增长重分配计数我会优先备份数据而不是等到Current_Pending_Sector爆发再去处理。对于 SSD情况类似但要看Wear_Leveling_Count磨损均衡、Media_Wearout_Indicator等属性。有些 SSD 主控会把 SMART 数值全部映射成 100这时更要关注 RAW 和厂商工具。5. 可复用的排查小工具与个人心得5.1 查看用户栈从 maps 到 gdb排查用户栈位置和大小最常用的是/proc/PID/maps$ cat /proc/1234/maps | grep stack 7ffd5f2a8000-7ffd5f2ca000 rw-p 00000000 00:00 0 [stack]前两个十六进制数就是栈的虚拟地址范围rw-p 表示可读写、私有映射。调试时用 gdb 的info proc mappings也能看到同样信息。程序内如果想获取线程栈信息可以用pthread_getattr_np配合pthread_attr_getstack。5.2 查看核心栈tracefs 与 crash核心栈不像用户栈那样每个进程都能直接看到。我常用的路径有两个一个是在线看/proc/PID/stack它能输出该任务当前在内核里的栈回溯另一个是配合 tracefs 的stack_max_size测量内核栈使用峰值。如果系统已经 panic 并抓到了 vmcore用 crash 工具执行bt可以查看每个线程的内核栈调用链再配合kmem查看对应物理页信息能直接定位到栈物理位置和越界点。5.3 最后想说的几点日常工作中我对“物理位置”相关的问题始终保持怀疑态度。用户栈的物理页可以随便换存储扇区的物理位置也可以重映射唯独核心栈的 16KB 是“死磕”在内存里的不能缺页、不能换出、不能重映射。它既是内核稳定性的保障也是整个系统里最脆弱的一块区域。遇到随机 panic除了看日志我建议你先查两处/sys/kernel/tracing/stack_max_size和smartctl -A里的重分配计数。底层系统最奇妙的地方就在这里内存栈和存储扇区看似八竿子打不着底层的逻辑却惊人地一致都在回答同一个问题——当原来的物理位置不可用时系统怎么保证逻辑地址的持续可用。理解了这个再回头看“用户栈和核心栈的物理位置”这个题目你会觉得整个内存管理的骨架都清晰了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →