前两天调试一个内核模块,用 gdb 看栈回溯,发现 current->stack 这个指针指向的地址很奇怪。而是,一个看起来像 vmalloc 出来的内核地址。查了一圈才意识到:内核栈不是用户栈,它的分配方式和地址空间都完全不同。
这事儿让我重新思考了一个基本问题:Linux 内核为什么需要内核栈?用户态有自己的栈,内核态再搞一个栈,这不是浪费吗?
这篇文章就从这个问题出发,把内核栈的设计讲清楚。
先搞清楚一个问题:进程在内核里怎么执行
用户态进程调用 read()、write()、ioctl() 这些系统调用时,CPU 从用户态切换到内核态。此时进程还是同一个进程,只不过执行的代码从 libc 变成了内核函数。
内核代码要执行,就得有地方存局部变量、函数调用链、返回地址。这些东西放哪?
放用户栈行不行?
不行。用户栈在用户地址空间,地址范围 0x7fff... 开头。内核代码执行时,进程的 cr3 页表还是用户进程的页表,用户栈确实映射着。但问题是:内核代码不能信任用户空间的任何东西。
如果内核用用户栈,恶意用户程序可以改写自己的栈内容,精心构造返回地址,让内核代码跳到攻击者指定的位置。这是经典的栈溢出攻击原理。内核必须有自己的栈,放在内核地址空间,用户进程无法访问。
那内核代码用一个全局栈行不行?
也不行。多个进程可能同时在内核里执行(多 CPU 并发,或单 CPU 时间片切换)。如果只有一个全局栈,进程 A 的内核函数调用链会被进程 B 覆盖,返回时全乱套。
内核栈必须是每个进程一个,切换进程时切换栈指针。
内核栈长什么样

x86_64 下,内核栈默认 16 KB(THREAD_SIZE_ORDER 决定,4 页)。栈在内核地址空间,Linux 5.x 后期起改用 __vmalloc_node_range 分配,做页级隔离——如果栈溢出,不会直接踩坏其他结构,而是触发 page fault,更安全。地址类似 0xffff...。
每个进程的 task_struct 里有一个 stack 指针,指向内核栈内存块的起始地址(低地址),rsp 初始在高位,随着压栈向下移动。
struct task_struct {
void *stack; // 指向内核栈(实际指向 thread_info)
...
};
栈布局是这样的(x86_64):
高地址 (rsp 初始位置)
+-------------------+
| thread_info 结构 | <- 栈底,存储进程关键信息
+-------------------+
| |
| 内核栈空间 | <- 向下增长
| (16 KB - sizeof(thread_info))
| |
+-------------------+
低地址 (thread_info, stack 指针)
thread_info 放在栈底,是内核栈的一部分。这样做有个好处:给定一个栈指针,只要对齐到 THREAD_SIZE,就能找到 thread_info,从而找到 current。
内核栈切换发生在什么时候

进程从用户态进入内核态时,CPU 自动切换栈指针。具体机制是 entry_SYSCALL_64 这段汇编代码(Linux 内核入口):
entry_SYSCALL_64:
swapgs // 切换 GS 寄存器(用户/内核 GS)
movq %rsp, PER_CPU_VAR(cpu_entry_area + tss + TSS_sp2)
// 保存用户栈指针到 per-CPU 区域
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
// 加载内核栈指针
... // 后续是系统调用处理
cpu_current_top_of_stack 是 per-CPU 变量,存的是当前进程的内核栈顶地址。
用户态 → 内核态:保存用户 rsp 到 per-CPU,加载内核 rsp。
内核态 → 用户态:从 per-CPU 恢复用户 rsp。
进程调度时,switch_to 宏也会切换栈:
#define switch_to(prev, next, last) \
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)
__switch_to_asm 汇编里:
__switch_to_asm:
movq %rsp, TASK_threadsp(%rdi) // 保存 prev 的栈指针到 task_struct
movq TASK_threadsp(%rsi), %rsp // 加载 next 的栈指针
...
jmp __switch_to
TASK_threadsp 是 task_struct 里存的内核栈指针。切换进程 = 切换栈指针。
内核栈里装了什么
跟用户栈一样,内核栈存的是:
- 函数调用链:
rbp 指向上一层的栈帧,rip 返回地址压栈 - 局部变量:内核函数里的
int i、struct file *f 这类 - 函数参数:x86_64 下前 6 个参数用寄存器传递(
rdi、rsi...),超过 6 个压栈
具体来说,系统调用入口会压一堆东西:
entry_SYSCALL_64:
...
pushq $__USER_DS // SS
pushq PER_CPU_VAR(cpu_entry_area + tss + TSS_sp2) // 用户 RSP
pushq %r11 // RFLAGS
pushq $__USER_CS // CS
pushq %rcx // 用户 RIP(syscall 指令的下一条)
...
PUSH_AND_CLEAR_REGS // 保存所有通用寄存器
这些数据加起来 100 多字节。之后内核函数正常调用,栈继续向下增长。
内核栈溢出会怎样
16 KB 说小不小,说大不大。递归太深、局部数组太大,都能把栈撑爆。
内核栈溢出会触发 double fault(两次缺页异常)或直接踩坏 thread_info。Linux 内核会在栈底放一个 canary 值(STACK_END_MAGIC),检测到被改写时打印 stack-protector: Kernel stack is corrupted in: <func> 然后 panic。
查看栈使用情况:
$ cat /proc/self/status | grep -i stack
VmStk: 136 kB # 用户栈
# 内核栈大小不在这里,是内核内部管理的
内核栈大小可以通过 ulimit -s 设置的是用户栈,内核栈是编译时固定的 16 KB(除非改 THREAD_SIZE_ORDER 重新编译)。
为什么内核栈不能太大
两个原因。
一是内核地址空间有限。 x86_64 下内核地址空间是高 128 TB(0xffff8000... 到 0xffff...),但这中间还要映射物理内存、vmalloc 区域、vmemmap 等。每个进程 16 KB 内核栈,10 万进程就是 1.6 GB。栈太大,内存扛不住。
二是 cache 局部性。 栈小,热数据更容易留在 cache 里。栈太大,局部性变差,性能下降。
内核栈 16 KB 是个折中:够大部分场景用,又不至于太占内存。有些实时内核用 8 KB,有些调试场景用 32 KB,都是权衡的结果。
内核栈 vs 用户栈:关键差异
| 特性 |
用户栈 |
内核栈 |
| 地址空间 |
用户空间 0x7fff... |
内核空间 0xffff... |
| 大小 |
可动态增长(RLIMIT_STACK,默认 8 MB) |
固定 16 KB(编译时定) |
| 分配方式 |
mmap 匿名映射 |
vmalloc(__vmalloc_node_range) |
| 可访问性 |
用户态可读写 |
用户态不可访问 |
| 数量 |
每进程一个(主线程) + 每线程一个 |
每线程一个 |
| 溢出处理 |
SIGSEGV |
kernel panic |
用户栈可以动态增长,是因为有缺页异常处理函数帮忙扩容。内核栈没有这个机制——缺页异常处理函数自己要用栈,如果栈都没有了,怎么处理?
一个实际场景:内核栈被谁用了
看一个真实进程的内核栈内容。用 crash 工具(内核调试工具):
crash> bt
PID: 1234 TASK: ffff888123456000 CPU: 0 COMMAND: "bash"
#0 [ffff888123460f90] do_nanosleep at ffffffff81234567
#1 [ffff888123460fd0] hrtimer_nanosleep at ffffffff81234a23
#2 [ffff888123461020] __x64_sys_nanosleep at ffffffff81234b89
#3 [ffff888123461030] do_syscall_64 at ffffffff81004567
#4 [ffff888123461038] entry_SYSCALL_64_after_hwframe at ffffffff81c00088
RIP: 00007f1234567890 RSP: 00007ffc12345678 RFLAGS: 00000202
bt 命令输出栈回溯。每行前面的地址 ffff888123460f90 就是内核栈上的位置。从栈底到栈顶,依次是系统调用入口保存的寄存器、do_syscall_64 的栈帧、hrtimer_nanosleep 的栈帧、do_nanosleep 的栈帧。
注意 entry_SYSCALL_64_after_hwframe 之后显示的 RIP、RSP 是用户态的寄存器值,存在内核栈上。
中断栈:内核栈的替身
上面说的都是系统调用场景。还有一种场景:硬件中断。
中断可能在任何时候发生,进程可能在用户态,也可能在内核态。如果进程已经在内核里执行,它的内核栈上已经有了一堆数据。此时中断处理函数再用同一个栈,栈可能不够用。
Linux 2.6 之后引入了中断栈(interrupt stack),每个 CPU 一个独立的栈,专门给中断处理用。中断发生时,CPU 切换到中断栈,而不是用当前进程的内核栈。
per-CPU 中断栈(x86_64):
+-------------------+
| 中断栈(16 KB) |
+-------------------+
进程内核栈:
+-------------------+
| 进程 A 的内核栈 |
+-------------------+
| 进程 B 的内核栈 |
+-------------------+
中断栈是 per-CPU 的,不随进程切换而切换。进程切换只换内核栈指针,中断栈指针是 CPU 本地的。
内核栈的分配与释放
进程创建时分配内核栈,进程退出时释放。
// kernel/fork.c
static struct task_struct *copy_process(...)
{
...
tsk->stack = alloc_thread_stack_node(tsk, node);
if (!tsk->stack)
goto fork_cleanup;
...
}
alloc_thread_stack_node 在现代内核调用 __vmalloc_node_range,支持页级隔离。对应的 free_thread_stack 在进程退出时调用。
查看当前系统有多少进程、多少内核栈:
$ ps -eLf | wc -l # 线程数
345
# 每个线程一个内核栈,16 KB × 345 ≈ 5.5 MB
为什么设计成这样
回头看最初的问题:内核为什么需要栈?
答案很清楚了:
这三条约束决定了内核栈必须存在、必须独立、必须小。理解了这三条,内核栈的所有设计细节都顺理成章。
一个小实验:看自己的内核栈
写一个内核模块,打印当前进程的栈指针:
// kernel_stack_test.c
#include <linux/module.h>
#include <linux/sched.h>
static int __init test_init(void)
{
unsigned long sp;
asm volatile("mov %%rsp, %0" : "=r"(sp));
printk(KERN_INFO "current: %p, stack: %p, sp: 0x%lx\n",
current, current->stack, sp);
return 0;
}
static void __exit test_exit(void) {}
module_init(test_init);
module_exit(test_exit);
MODULE_LICENSE("GPL");
加载模块后 dmesg 查看:
[12345.678901] current: ffff888123456000, stack 指向低地址(页首 ffff...0000),sp 在高位(0f90)。压栈越多 sp 越向 stack 靠拢
current->stack 是栈底,sp 是当前栈顶。两者差值就是栈使用量:0xf90 = 3984 字节。
总结一下
内核栈是内核设计里的一块小拼图,但它承载了三个基本约束:安全、并发、性能。用户栈在用户空间,内核栈在内核空间,两者隔离。每个进程一个内核栈,16 KB 固定大小,现代内核用 vmalloc 分配(页级隔离),老内核用 slab。中断有独立的中断栈,不占用进程的内核栈。
下次看到 current->stack 或者 thread_info 在栈底,就知道为什么这样设计了。
数据来源:Linux 6.6 内核源码 kernel/fork.c、arch/x86/entry/entry_64.S、include/linux/sched.h。