前阵子给一台服务器做例行巡检,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 上,一个线程在内核态执行时使用的核心栈通常只有 16KB,32 位系统上是 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_Ct | 05 | 已重映射扇区数,持续增长说明坏道在增多 |
| Reallocated_Event_Count | C4 | 重定位事件总次数 |
| Current_Pending_Sector | C5 | 当前等待重映射的扇区数 |
| Offline_Uncorrectable | C6 | 离线扫描无法修正的扇区数 |
判断逻辑很简单: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里的重分配计数。底层系统最奇妙的地方就在这里:内存栈和存储扇区看似八竿子打不着,底层的逻辑却惊人地一致,都在回答同一个问题——当原来的物理位置不可用时,系统怎么保证逻辑地址的持续可用。理解了这个,再回头看“用户栈和核心栈的物理位置”这个题目,你会觉得整个内存管理的骨架都清晰了。