尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Linux用户栈与内核栈:切换机制、大小限制与栈溢出排查指南

发布时间:2026/9/8 15:28:20

资讯中心
01
ARTICLE

Linux用户栈与内核栈:切换机制、大小限制与栈溢出排查指南

Linux用户栈与内核栈:切换机制、大小限制与栈溢出排查指南
在Linux上写了几年程序之后你迟早会碰到一个让新手疑惑的名词内核栈。尤其是系统崩溃时console刷出一堆“Kernel stack overflow”的报错很多人会愣一下——我自己的程序明明没写递归为什么栈会爆其实这里的“栈”跟你在用户态见到的那块栈根本不是一回事。Linux里每个运行中的进程在用户态有一套调用栈进入内核态之后又有一套完全独立的栈后者就是常说的“内核栈”。这篇文章我想把这两个栈的关系、切换机制、内核栈的大小限制和运维排查方法一次讲透。不管你是做嵌入式、搞运维还是准备Linux面试理解清楚这套双栈机制对你排查死机、OOM、驱动崩溃这类问题都有直接帮助。顺便说一句内核栈的内容在主流面试题里出现频率非常高属于那种“看起来不难问深了就露馅”的知识点。1. 用户栈与内核栈到底差在哪里先看分工很多人以为栈就是“函数调用时保存局部变量和返回地址的那块内存”然后想当然地以为无论用户态还是内核态用的都是同一个栈。实际上从CPU进入内核态的那一刻开始栈指针就被切换走了代码里所有的函数调用、局部变量、压栈参数都发生在另一块独立的内存区域里。1.1 用户栈进程自己说了算的动态区域先说说大家最熟悉的用户栈。你写一个C程序main函数里声明局部变量调用函数编译器生成压栈、弹栈的指令这些操作全部落在用户态地址空间里的一块区域上这就是用户栈。用户栈有下面这几个特点每个用户进程或者说每个线程都有自己独立的用户栈线程之间的用户栈是独立的但同一进程内的线程栈都映射在同一个地址空间里。用户栈可以动态增长。当栈指针往下接近栈底时会触发缺页异常内核在缺页处理里判断访问地址是否在栈的合法范围内是的话就分配新的物理页把栈扩大。所以普通进程的栈通常不需要提前设定死只要不超过RLIMIT_STACK的限制就行。用户栈里可以放很大的局部数组、很深的递归调用因为出错了还有栈增长这层“兜底”。在x86-64架构下用户栈地址一般从高地址向低地址生长栈的底部在高地址一侧。进程启动时内核会把初始栈指针设置到栈区的顶部然后随着函数的调用一级级往下压。1.2 内核态运行时为什么不直接用用户栈这是整个双栈机制最核心的问题。你可能会想反正底下的物理内存都是同一块用户栈那么充裕内核态进去之后直接借用一下不就行了问题是内核态根本不敢把自己的运行现场寄托在一块不可信的栈上。原因可以从安全和可靠性两个角度来解释。安全层面的问题很直接用户栈所在的内存区域是用户进程可以通过mmap、munmap、栈增长等操作随时改变的。用户在用户态可以人为地把栈指针改成任意地址然后触发系统调用进入内核。如果内核继续使用用户栈那攻击者就可以通过控制栈上的返回地址、函数指针来劫持内核控制流内核态的安全边界就崩了。即使不考虑恶意攻击多线程环境下另一个线程也可能悄悄修改共享的栈内存导致内核函数返回时跳到莫名奇妙的地址。可靠性层面的问题同样致命用户栈的页可能在内存压力下被换出到磁盘。如果你在内核态使用用户栈某一次压栈操作就可能触发缺页而缺页处理可能需要睡眠等待磁盘I/O。问题是内核在很多上下文里是不能睡眠的——比如在中断处理函数中、在自旋锁保护区间内睡眠就直接死锁或者导致内核状态不一致。内核不能冒这个险。打个比方用户栈就像酒店客人的活动区域空间大、设施全但客人随时可能改装修、换锁、进出自由内核则是酒店的员工通道和工作间必须使用自己掌控、安全干净、不会被客人干扰的区域来操作否则整个酒店运营都会出乱子。1.3 进程和内核栈的对应关系那么内核给每个进程配置了什么呢从2.4内核开始Linux为每一个进程task都单独分配了一块内核栈。具体地说task_struct结构体里有一个字段指向内核栈的地址每次进程从用户态进入内核态CPU就切换到这块栈上运行。这里有一个容易混淆的点进程的内核栈是跟随任务而存在的每个任务一份不是每CPU一份。假如系统里跑着2000个线程就有2000块内核栈而中断处理时使用的中断栈是每CPU一块两者不是同一个东西。后面第3章我会专门讲中断栈和内核栈的区别。还有一类特殊任务叫内核线程比如kworker、ksoftirqd。内核线程没有用户态地址空间它们没有用户栈所有运行都只用内核栈。你可以通过ps -ef看到这类线程的COMMAND列内容是带方括号的它们从头到尾没离开过内核态。2. 内核栈设计细节与常用参数固定大小、位置与保护跟用户栈的自由生长不同内核栈最大的特点就是“小”和“固定”。为什么Linux要设计成这个样子里面有不少历史包袱和现实考量值得展开讲清楚。2.1 内核栈到底有多大先给一个最常见的答案在x86-64架构上Linux内核栈默认大小一般是16KB有的内核配置为8KBARM64上常见16KB也有的板子用8KB。在32位x86的老内核里内核栈曾经非常小只有4KB到8KB。16KB是什么概念作为对比用户栈通常默认有8MB受ulimit -s控制内核栈只有用户栈的约千分之二。这么小的空间里要容纳内核函数调用链、局部变量、中断嵌套时压入的寄存器现场空间非常紧张。所以在内核开发界一直流传一句告诫内核代码里不要使用大型局部数组不要写太深的递归每一个字节的栈空间都很宝贵。驱动里要是敢写一个char buf[4096];的局部变量再叠加上文件系统、块设备层的调用链栈溢出几乎是分分钟的事。这块内存的分配是在创建进程/线程时通过alloc_thread_stack_node这类接口完成的。开发者可以通过修改内核配置或宏定义调整栈大小典型路径是调整THREAD_SIZE_ORDER之类的编译选项但是一般情况下没人会去动它16KB是经过长期实践沉淀的平衡点。2.2 内核栈的布局低地址是起始高地址是栈底关于内核栈的布局有一点反直觉但要先把它理清虽然我们总说“栈向低地址生长”但内核栈内存在虚拟地址空间中的起始位置是在低地址那一端。新任务刚被创建、还没有压入任何数据时栈指针寄存器被初始化为内核栈区域的最高地址也就是“栈底”然后随着函数调用向低地址方向压栈。解释一下这个概念一块16KB的区域可以看作从地址A到A16KB。栈底在高地址端也就是A16KB初始时rsp指向A16KB第一个压栈操作把rsp减到A16KB-8。当函数调用越来越深时rsp不断往下走一直压到快要接近低地址端A时栈就快用完了。所以判断内核栈是否溢出通常看最低地址端是否被越界访问。在旧版内核里内核栈区域的最低端还存放着thread_info结构体。这个结构体里包含任务状态、标志位、地址空间信息等关键数据。问题在于一旦内核栈溢出最先被破坏的就是这块元数据。内核崩溃时的表现往往千奇百怪很难定位是栈溢出导致的。后来Linux引入了CONFIG_THREAD_INFO_IN_TASK把这个结构体从内核栈上移走放到task_struct里算是消除了一类栈溢出的连带灾害。2.3 VMAP_STACK给内核栈加一道护栏如果你用的内核版本比较新打开配置能看到CONFIG_VMAP_STACK选项。这个特性把固定物理内存连续的“直接映射栈”改成了由vmalloc分配的虚拟地址空间栈。VMAP_STACK带来的好处是栈的虚拟地址两端可以设置不可访问的guard page保护页。一旦内核栈溢出立刻会触发缺页异常内核会明确报出BUG: stack guard page was hit而不是悄悄覆盖别的内核数据最后以一种诡异的方式崩溃。这对开发驱动的朋友来说简直是一根救命稻草——栈溢出不再是玄学出错现场直接怼在你脸上。当然CONFIG_VMAP_STACK不是万能的。它并不能阻止“在栈上分配一个大数组然后完全越过guard page”的越界写因为这种写入可以整页跳过保护页。它只能对付普通的向下压栈导致的溢出。所以就算有护栏也不要因此放松对栈空间使用的警惕。2.4 为什么内核栈不能像用户栈那样动态增长很多初学者到这里会问既然用户栈能动态增长为什么内核栈不也采用“空间不够就扩张”的策略原因在于动态增长依赖缺页异常而缺页异常处理有一些非常苛刻的前提条件。用户态触发缺页时内核可以安全地睡眠、调度、执行文件I/O把页面从磁盘读进来然后再恢复用户程序执行。但内核态代码执行时可能正处于中断上下文、软中断处理、持有自旋锁、禁用抢占等状态下。这些状态下内核不能睡眠更不能执行复杂的页面分配逻辑。如果此时因为内核栈增长触发缺页内核无法安全地处理这个缺页系统就只能死给你看。所以在内核里栈空间“有多少用多少”绝不动态扩张。这也是为什么内核开发者需要时刻掂量自己的调用链会消耗多少栈空间。如果把用户栈和内核栈做个对比核心差异一目了然对比项用户栈内核栈归属每个用户进程/线程每个任务task大小默认通常8MB可增长固定常见8KB/16KB增长方式动态扩展缺页处理固定不变不动态扩张虚拟地址空间用户地址空间内核地址空间风险递归过深触发段错误溢出直接破坏内核状态是否可被用户代码访问是否用户态完全不可见3. 从系统调用到中断用户栈和内核栈是怎么完成交接的两个栈的切换并不是一句“进入内核就切换”能带过的实际的切换动作在x86-64架构上分为两种完全不同的路径一种依赖硬件自动完成一种需要内核手动操作。这也是面试里最容易考到的细节。3.1 一次read()系统调用经历什么假设你的进程调用read()从文件读取数据。在glibc层面这个函数会执行syscall指令。执行这条指令后CPU跳转到内核预设的入口点entry_SYSCALL_64接下来内核开始接管。此时有个非常重要的细节syscall指令本身不会自动切换栈指针。它不像中断那样有TSS帮你把栈切到内核栈而只是简单地把返回地址放到rcx、把RFLAGS放到r11然后把rip跳转到MSR_LSTAR指定的地址。因此在系统调用入口代码里内核需要手动完成以下几件事使用swapgs指令将GS段寄存器的基地址从用户态的值切换到内核态的per-CPU数据区。从当前CPU的TSS或per-CPU变量中读出当前任务的内核栈顶部地址把它赋给rsp。这一步等于“手动切栈”。在栈上构造pt_regs结构把用户态的寄存器现场、用户栈指针、返回地址等信息保存下来。调用真正的系统调用分发函数do_syscall_64按照系统调用号查表执行具体的内核函数。这里值得强调一下为什么syscall指令不自动切栈。设计x86-64指令集时Intel和AMD选择了让硬件负责中断/异常的切栈却让syscall/sysret走一条更快、更轻量的路径不做那些繁琐的权限检查和栈加载。这样可以大幅降低系统调用的延迟代价是操作系统软件必须在入口处理更多的细节。3.2 中断和异常路径硬件TSS帮你切栈与syscall不同当用户态代码触发异常比如缺页、除零、调试断点或者收到外部硬件中断时CPU会从IDT中断描述符表中读取对应的处理入口然后根据目标代码段的特权级自动判断是否要切换到内核栈。具体机制在x86-64上依赖一张叫TSSTask State Segment的结构。Linux为每个CPU创建一个TSS并把它内部的rsp0字段始终更新为当前运行任务的内核栈顶部地址。当CPU从用户态CPL3陷入内核态CPL0时硬件会自动从TSS中加载rsp0到rsp从而完成用户栈到内核栈的切换然后自动把SS、RSP、RFLAGS、CS、RIP压入新栈形成最开始的硬件帧。这可能让一些人困惑Linux不是早就不用硬件任务切换了吗没错Linux确实不靠TSS做任务切换但TSS并没有被废弃它被用来存放每个CPU的rsp0和几个特殊栈指针专门服务于内核栈切换和异常处理栈。这个理解对正确认识x86-64内核栈切换非常重要。3.3 返回用户态时怎么切回去内核处理完系统调用或中断后总要返回用户态。x86-64上同样有两条返回路径sysret和iret。如果条件是简单系统调用返回内核优先使用sysret指令因为它不需要经过慢速的中断返回路径。使用sysret时用户态返回地址从rcx恢复用户态RFLAGS从r11恢复同时内核会把rsp从保存好的pt_regs中恢复成用户栈指针然后执行sysretCPU跳回用户态。iret指令则用于中断/异常处理返回或者系统调用过程中改变了某些需要恢复的状态。它会从当前栈上弹出RIP、CS、RFLAGS、RSP、SS一次性还原用户态上下文。正因为iret要读栈上的这么多字段它比sysret慢不少。不管哪条返回路径都隐含着一个关键动作把栈指针从内核栈恢复到用户栈。也就是说在内核执行阶段所有函数调用都压在内核栈上只有将要跳回用户态的前一刻才会把用户栈指针恢复。3.4 中断嵌套和特殊栈避免一个栈被“多层叠加”压垮前面说的都是“用户态切内核态”时换栈那如果进程已经在内核态执行此时又来一个外部中断CPU会怎么处理因为此时CPL已经是0CPU不会再进行栈切换而是继续使用当前进程的内核栈直接往里面压入中断帧。这里就出现一个问题如果每个中断都在当前进程的内核栈上压栈那么中断嵌套深度一大内核栈很容易爆掉。为此x86-64的Linux在多数配置下为每个CPU分配了独立的中断栈IRQ stack。硬件中断到达时内核入口会判断当前是否处于中断上下文如果任务内核栈剩余空间不足就把执行切换到该CPU的中断栈上。这样中断处理函数的压栈发生在独立的中断栈上不会进一步消耗当前进程的内核栈。此外还有几个专用异常栈比如NMI栈、double fault栈、machine check栈等。这些栈存放在TSS的IST字段里硬件会按要求使用它们确保即使正常的内核栈被破坏CPU还能有可靠的栈用来执行异常处理代码。写到这里很多人会产生一个误解是不是所有运行在内核态的代码都使用内核栈答案可以在第2.3节找到——中断代码可能运行在中断栈上NMI代码可能运行在NMI栈上。这些栈都属于“内核特权态使用的栈”但在Linux语境里我们要谈的“每个任务的内核栈”指的还是任务被调度时、执行常规系统调用或异常处理时使用的那块栈。4. 实际定位与排查如何看到内核栈内容和它到底用了多深双栈机制学完接下来就是实战。遇到一台服务器卡死、一个驱动崩溃、或者一个内核线程行为异常时怎么看到对方到底卡在内核栈的哪一层这里我分享几个自己常用的排查手段。4.1 /proc 下三个最常用的栈观察入口首先是/proc/pid/stack这个文件能显示指定进程当前在内核栈上的函数调用栈。访问它需要root权限且内核需要开启相应的栈回溯支持。看到的内容长这样$ cat /proc/1/stack [0] do_wait0x1bb/0x230 [0] kernel_wait40x8d/0x130 [0] __do_sys_wait40x95/0xa0 [0] do_syscall_640x43/0xd0 [0] entry_SYSCALL_64_after_hwframe0x61/0xcb这个输出是从栈回溯出来的函数调用链从上到下是从最内层函数到入口。看懂这个栈你就能知道这个进程此刻在内核里正卡在哪个函数上。第二个是/proc/pid/wchan它只给一个符号表示这个任务当前在内核栈上阻塞等待的位置。配合ps -o pid,wchan可以快速筛查大批进程卡在哪。它的缺点是信息量太少只适合做快速筛选。第三个是/proc/pid/syscall它显示进程当前正在执行的系统调用号以及参数。它能告诉你这个任务陷入内核后执行到哪一步但不能展示完整的内核函数调用链。三个文件配合使用通常能快速定位到卡住的模块。4.2 从内核转储里还原崩溃前的栈如果系统已经彻底死机通过/proc就来不及了。正确的姿势是捕获vmcore后用crash工具分析。crash是分析内核转储文件最经典的工具只要有一个带符号的内核镜像就能把崩溃前每个进程的内核栈来回溯出来。基本操作是这样$ crash /usr/lib/debug/boot/vmlinux-$(uname -r) /var/crash/vmcore crash bt PID: 3487 TASK: ffff9e0c61398000 CPU: 2 COMMAND: java #0 [ffff9e0d02e0bd50] machine_kexec at ffffffff8105b3fe #1 [ffff9e0d02e0bda0] __crash_kexec at ffffffff81105b32 #2 [ffff9e0d02e0be70] panic at ffffffff8173987f ...在崩溃现场bt指令会把任务的内核栈回溯出来每一帧都带栈指针地址。通过对比几个关键进程的栈基本能看出是不是某一类任务反复崩溃或者是不是栈空间用尽导致double fault。crash工具还可以配合ps、foreach bt等命令批量查看所有进程的内核栈。如果一个进程的栈回溯底部重复出现同一个函数几百层那基本可以断定是递归调用导致内核栈溢出。4.3 如何量化监控每个任务的栈使用量很多时候栈没有溢出但已经用得比较深这时候最好提前发现风险。Linux内核有一个CONFIG_DEBUG_STACK_USAGE编译选项开启后内核会统计每个任务内核栈的历史最大使用深度。系统启动后可以通过查看内核日志找到类似下面这样的记录task: kworker/0:1 stack: 0xffffffffb2a38000 stack: 0 stack: 0如果内核没有开启这个选项也可以通过ftrace的stack tracer来追踪函数调用深度。stack tracer可以记录从某个入口开始的整个调用链的最大栈消耗并把它打印到trace文件里。排查驱动模块时这个工具价值极大能量化出一个操作路径到底吃掉了多少字节的栈空间。我个人的习惯是在开发新驱动或文件系统模块时打开这些调试选项跑一轮压力测试把最大栈使用量打出来如果某个路径超过6KB就值得警惕了。因为x86-64的内核栈就16KB系统调用入口、中断入口、异常处理还要预留一部分栈帧真正能随意使用的空间并没有想象中那么多。另外一个很实用的思路是在代码里临时获取“当前已用栈深度”。内核里可以通过读取当前栈指针与栈底地址的差值来估算。调试时我常在内核模块里打印current_stack_pointer()和任务栈地址确认某段代码消耗的栈空间是否符合预期。4.4 一个典型的栈溢出案例复盘去年我调一个网络驱动偶发死机而且死机前完全没有任何规律。一开始怀疑是并发问题加了各种锁还是复现。后来开启CONFIG_VMAP_STACK和CONFIG_DEBUG_STACK_USAGE重新编译内核死机现场从“整机无响应”变成了明确的栈溢出警告BUG: stack guard page was hit at ffffffffc0a62000 kernel stack overflow (page fault): 0000 [#1] SMP NOPTI日志直接指出某个驱动函数的栈越界了。顺着这个栈回溯发现是驱动里一个函数申请了一个8KB的局部缓冲区然后又调用了上层协议栈的复杂函数两层栈帧叠加直接把16KB的内核栈顶爆了。修复很简单——把局部缓冲区改成kmalloc动态分配问题立刻消失。这个案例让我深刻体会到内核栈溢出的问题很多时候不是写代码时立刻爆炸而是要等到某个特定调用路径叠加才触发属于“潜伏型bug”。所以提前开启栈保护机制、日常监控栈用量比事后分析崩溃要高效得多。5. 高频疑问和面试向速查这组概念常见考点内核栈和用户栈的内容在Linux面试题里几乎年年出现。我把常见的考点和对应的回答思路整理成一个速查表方便需要的人直接拿来复习。高频问题核心回答要点为什么系统调用时要切换栈安全边界 可靠性。用户栈不可信且可能换页/被修改内核栈由内核掌控每个线程都有独立内核栈吗是的线程和进程在Linux里都叫task创建时各自分配独立内核栈内核线程有没有用户栈没有内核线程不关联用户地址空间始终运行在内核栈上内核栈大小能改吗可以修改编译期相关宏/配置重新编译内核常见为8KB/16KB用户栈为什么可以动态增长因为缺页时内核能在进程上下文安全处理、分配新页面并恢复执行内核栈为什么不能动态增长内核常处于原子上下文/中断上下文不能睡眠无法安全处理缺页中断处理使用的栈和内核栈是同一个吗硬中断通常使用per-CPU中断栈普通系统调用/异常使用任务内核栈栈溢出一定会立刻崩溃吗不一定旧内核可能先破坏thread_info等数据后诡异崩溃开启VMAP_STACK后更易检测下面几个考点面试官经常追着问细节单独展开说一下。5.1 “用户栈指针能不能在内核里直接解引用”绝对不能。内核代码拿到用户态传入的指针后不能直接*ptr去读写。原因和用户栈不能复用完全一致用户指针指向的内存可能还没被换入、可能被其他线程修改、可能是非法地址。内核必须使用copy_from_user、copy_to_user、get_user、put_user这类专用接口这些接口内部会做地址范围检查和缺页处理。面试时如果能把这个问题和栈切换的原因联系起来回答会显得理解非常扎实。5.2 “fork之后父子进程的用户栈和内核栈是什么关系”进程通过fork()创建子进程时子进程会复制父进程的地址空间包括用户栈但真正的物理页面采用写时复制COW父子进程最初共享物理页只有一方写入时才复制。而内核栈则是全新分配的不共享。也就是说父子进程拥有内容相似但物理独立的用户页内核栈则完全各自独立。线程的情况有所不同。线程通过clone()创建和进程共享地址空间所以用户栈是共享的通常每个线程还会自己再映射一块线程栈区域但内核栈仍然是每个线程单独一份。理由是每个线程都可能同时陷入内核各跑各的系统调用不可能共用同一块内核栈。5.3 “进程被切换出去时用户栈和内核栈分别保存了什么”进程切换时内核会把当前任务在内核栈上的栈指针保存到task_struct中同时保存各种寄存器。用户栈上的内容不需要特意保存因为用户栈指针已经作为普通寄存器值保存了用户地址空间本身也保留着。下次进程恢复运行时内核先从task_struct恢复内核栈指针执行恢复现场的代码然后返回到用户态时恢复用户栈。这个过程正好体现了两个栈分工明确用户栈管用户态执行上下文内核栈管内核态执行上下文两者通过栈指针切换连接起来。5.4 开发中如何避免内核栈溢出最后说点开发层面的建议这部分对写驱动、做内核模块的同学尤其重要。第一内核函数里避免定义过大的局部变量。如果你确实需要一个较大的临时缓冲区用kmalloc分配用完释放不要图省事直接写在栈上。我见过太多驱动里的char buf[4096]在叠加了多层锁和协议栈后触发栈溢出。第二警惕递归。文件系统、网络协议这类路径天然嵌套很深如果自己的代码里再加递归很容易把栈空间吃光。能用循环解决的问题尽量不要用递归。第三不要在做完local_bh_disable或关中断后调用可能打印很多日志的函数。printk本身可能触发控制台驱动路径很长会额外消耗不少栈空间。中断关闭状态下栈空间又没法靠调度分担风险会放大。第四利用编译选项提前发现风险。开启CONFIG_FRAME_POINTER和栈溢出检测项跑完压力测试后不仅要看功能是否正常还要主动查看内核日志里有没有栈用量告警。调试版本如果发现某个路径的栈用量异常偏高尽早处理别拖到线上环境变成偶发崩溃。第五线上环境尽量保持内核版本较新并且确认开启了CONFIG_VMAP_STACK。这个配置能把栈溢出从“随机崩溃”变成“直接报错”节省大量排查时间。我自己在调驱动时甚至会把CONFIG_DEBUG_STACK_USAGE开一段时间配合自动化测试把主要调用路径的最大栈深度记录归档形成一个模块的“栈预算表”。内核栈和用户栈这个话题看起来是基础知识实际踩坑时会发现处处是细节。我个人的习惯是接触一个新的内核模块或驱动时第一件事就是看它里面有没有大局部变量、有没有可能很深的递归潜意识里给每个函数调用路径估算一下栈消耗。这套习惯帮我避免了不少线上事故。也希望这篇文章能让你在面对相同问题时少走一些弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。