当前位置:首页 > 技术学院 > 技术前线
[导读]在Linux系统中,栈是程序运行最基础的内存结构,函数调用、参数传递、局部变量存储都离不开栈。但很多开发者分不清Linux中的各种栈:进程栈、线程栈、内核栈、中断栈,听起来都是栈,它们到底有什么不一样?为什么要分这么多种?各自的作用是什么?其实不同的栈对应不同运行场景,从用户态到内核态,从进程到中断,每一种栈都承担着不可替代的作用,理清它们的关系,就能更清楚地理解Linux程序运行的底层逻辑。

在Linux系统中,栈是程序运行最基础的内存结构,函数调用、参数传递、局部变量存储都离不开栈。但很多开发者分不清Linux中的各种栈:进程栈、线程栈、内核栈、中断栈,听起来都是栈,它们到底有什么不一样?为什么要分这么多种?各自的作用是什么?其实不同的栈对应不同运行场景,从用户态到内核态,从进程到中断,每一种栈都承担着不可替代的作用,理清它们的关系,就能更清楚地理解Linux程序运行的底层逻辑。

先从基础讲起:栈到底是用来做什么的

不管是哪种栈,本质上都是一片遵循"后进先出"规则的连续内存区域,在程序运行中主要承担三个作用:

存储局部变量:函数内部定义的临时变量,通常都存在栈上,函数返回自动释放,不需要手动管理内存;

函数调用现场:调用函数的时候,会把返回地址、当前栈帧指针存在栈上,函数执行完回到调用点的时候,能恢复原来的执行上下文;

参数传递:函数调用的参数,通常也会通过栈传递(现在也有寄存器传参,但栈还是备用传递路径)。

对于单进程单线程的简单程序,一个栈就够用了,但Linux支持多进程、多线程,程序还会从用户态切换到内核态执行,甚至会响应硬件中断,不同的运行上下文不能共用同一个栈,否则会把调用现场搞乱,所以才需要区分出不同类型的栈,每个上下文独立用自己的栈,互不干扰。

用户态的栈:进程栈和线程栈的区别

我们先从用户态说起,用户空间的栈主要分两种:进程栈和多线程场景下的线程栈,很多新手分不清这两个,我们一步步拆解:

进程栈:单进程时代的默认栈

最早的单线程进程,整个进程只有一个栈,就是进程栈,它存在于进程的用户地址空间中。我们知道Linux进程的地址空间布局,从低地址到高地址依次是:代码段、数据段、堆、栈,栈从高地址向低地址增长,最顶端就是进程栈的栈底。

单线程进程中,所有函数调用都用这一个栈,每个函数调用对应一个栈帧,栈指针随着调用和返回移动,整个进程只有一个调用栈,我们用pstack打印进程的调用栈,单线程进程打出来就是这一个进程栈的内容。

当进程创建的时候,内核会在进程地址空间中分配进程栈的空间,默认大小在不同系统上不一样,通常是几MB到十几MB,足够单线程的函数调用用了。

线程栈:多线程时代每个线程独立的栈

到了多线程时代,同一个进程里有多个线程同时运行,每个线程都可能独立调用不同的函数,都需要自己的调用现场,如果多个线程共用同一个进程栈,调用信息会互相覆盖,根本没法正常运行。所以Linux中,‌同一个进程内的每个线程,都会有自己独立的用户态栈,也就是线程栈,原来的进程栈变成了进程创建后主线程的线程栈‌。

也就是说,现在我们说的进程栈,本质上就是主线程的线程栈,新建的子线程都会在进程的用户地址空间中,单独分配一块独立的内存作为自己的线程栈,每个线程的栈互相独立,互不干扰,每个线程函数调用都存在自己的栈上,不会混乱。

线程栈的默认大小通常是几MB,比如x86_64架构下Linux默认是8MB,如果你的线程需要递归调用很深,可以在创建线程的时候修改栈大小。但栈也不是越大越好,进程的地址空间是有限的,如果开了上千个线程,每个线程8MB,光线程栈就要占好几GB内存,很容易把地址空间耗尽,这也是为什么说"不要开太多无关线程"的原因之一。

这里我们可以总结用户态栈的规律:

单线程进程:只有一个用户栈,就是进程栈,也就是主线程栈;

多线程进程:N个线程就有N个用户栈,每个线程一个,主线程的那个就是最初的进程栈;

所有用户态栈都在进程的用户地址空间中,每个线程独立,隔离性由进程地址空间保证,同一个进程的线程虽然共享地址空间,但栈是独立的区域,不会互相干扰。

内核态的栈:每个执行上下文都要有独立栈

当程序执行系统调用,或者触发中断,CPU会从用户态切换到内核态,这时候就不能再用用户态的栈了,必须用内核态自己的栈,原因很简单:

第一,用户地址空间是每个进程独立的,内核访问用户地址空间需要特殊操作,而且内核代码自己也需要存储调用信息,放在内核栈更安全;

第二,用户态栈可能是不合法的,比如用户态栈指针被破坏了,进入内核后如果还用用户栈,内核直接崩溃,所以必须独立出内核栈。

Linux的内核栈也分两种:进程/线程的内核栈,还有专门的中断栈,我们分别来看:

进程/线程内核栈:每个线程一个独立内核栈

Linux设计中,‌每个线程都会对应一个独立的内核栈‌,和用户态线程栈对应:不管你是主线程还是子线程,只要是用户态的一个线程,内核都会给它分配一个独立的内核栈,当线程从用户态陷入内核态执行的时候,就会切换到这个内核栈运行,所有内核层面的函数调用都存在这个栈上。

这里要注意:内核栈是每个线程独立的,不是每个进程独立的,也就是说同一个进程里的N个线程,每个线程都有自己的内核栈,这一点和用户态线程栈是对应的。

内核栈的大小通常很小,比如x86_64架构下默认是16KB或者8KB,因为内核栈是存在内核地址空间中的,内核地址空间是全局共享的,不能给每个线程分配太大,所以内核函数不能定义太大的局部变量,也不能递归太深,不然很容易栈溢出,直接内核崩溃。

我们举个完整的流程例子,看线程运行时栈的切换:

一个用户线程在用户态运行,用自己的用户线程栈,调用用户函数;

线程调用read系统调用,触发软中断,CPU切换到内核态;

栈指针切换到这个线程对应的内核栈,内核开始执行read的系统调用逻辑,所有内核函数调用都存在内核栈上;

系统调用执行完成,返回用户态,栈指针切回用户线程栈,继续执行用户代码。

整个过程中,用户栈和内核栈分开,各自存各自的调用信息,互不干扰,完美隔离了用户态和内核态的执行上下文。

中断栈:专门处理中断的独立栈

那什么是中断栈,为什么不直接用进程/线程的内核栈呢?原因很简单:中断是异步发生的,不管当前内核正在执行哪个进程/线程的内核代码,中断来了都要立刻响应,如果直接复用当前进程的内核栈,万一当前内核栈本来就快满了,中断处理再压栈,就会导致栈溢出,直接把内核搞崩。

所以Linux设计了专门的中断栈:‌每个CPU核心都会分配一个独立的中断栈,硬件中断触发的时候,不管当前CPU正在运行哪个进程,都会切换到这个专门的中断栈来执行中断处理程序‌,这样就不会占用进程/线程自己的内核栈空间,避免了栈溢出的问题,哪怕当前进程的内核栈满了,中断依然能正常响应,稳定性更高。

中断栈也是每个CPU一个,大小和内核栈差不多,通常也是几KB,因为中断处理程序不能执行太长时间,栈不需要太大。这里我们区分一下:中断栈是按CPU分配的,不是按进程或者线程分配的,同一个CPU上所有中断都用同一个中断栈,因为同一时刻一个CPU只能处理一个中断,所以一个就够了。

我们再补一个完整的中断流程:

某个CPU正在执行进程A的内核代码,用的是进程A的内核栈;

硬盘来了一个硬件中断,信号发到这个CPU上;

CPU暂停当前执行,切换栈指针到这个CPU的中断栈;

在中断栈上执行中断处理程序,处理硬盘的中断请求;

中断处理完成,切回原来的栈指针,恢复执行进程A的内核代码,就好像什么都没发生一样。

除了硬件中断栈,有些架构还有专门的异常栈,处理缺页异常、页错误这种异常,逻辑和中断栈差不多,都是为了独立出栈空间,避免影响原有进程的内核栈。

四种栈的核心对比:一张表理清楚关系

我们把四种栈的核心属性整理一下,就能一眼看清楚它们的区别:

表格

栈类型 所在空间 分配方式 大小 作用 归属

进程栈(主线程栈) 用户地址空间 进程创建时分配 默认几MB 用户态主线程函数调用、存储局部变量 进程的主线程

线程栈(子线程) 用户地址空间 线程创建时分配 默认几MB 用户态子线程函数调用、存储局部变量 每个子线程一个

进程/线程内核栈 内核地址空间 线程创建时分配 通常8KB~16KB 线程陷入内核态时,存储内核函数调用信息 每个线程一个

中断栈 内核地址空间 系统启动时分配 通常几KB 处理硬件中断时,存储中断处理程序调用信息 每个CPU核心一个

从这个表格就能看出来,不同的栈区分的核心逻辑就是‌上下文隔离‌:不同的执行上下文不能共用栈,否则会覆盖调用现场,导致系统崩溃,所以每个独立上下文都配独立的栈:

用户态每个线程是独立执行上下文,所以每个线程一个用户栈;

内核态每个线程也是独立执行上下文,所以每个线程一个内核栈;

中断是异步独立的执行上下文,不能复用当前进程的栈,所以每个CPU配一个独立的中断栈。

实际开发中常见问题:和栈相关的坑

理解了这四种栈,我们就能解释很多实际开发中遇到的和栈相关的问题:

问题一:递归太深为什么会栈溢出?

我们写代码遇到栈溢出,绝大多数都是用户态线程栈溢出:递归太深,每个递归调用都要压一个栈帧,线程栈的空间只有几MB,递归深度超过了栈容量,就会覆盖栈以外的内存,导致段错误。如果确实需要很深的递归,可以手动调大线程栈的大小,或者改成非递归实现。

问题二:为什么内核函数不能定义大数组?

内核栈只有几KB,如果你在内核里定义一个char buf,大小就超过了内核栈的容量,直接导致内核栈溢出,触发内核崩溃,所以内核里要用到大缓冲区,一般会用动态分配内存,不能定义在栈上。

这个问题也能解释为什么要有中断栈:如果没有中断栈,中断处理程序刚好在内核栈快满的时候进来,哪怕只是一个小栈帧也会溢出,内核直接崩,独立的中断栈就避免了这个问题。

问题三:多线程调试的时候,为什么每个线程都有自己的调用栈?

调试多线程程序的时候,gdb可以切换查看每个线程的调用栈,本质就是因为每个线程都有自己独立的用户栈和内核栈,调用栈信息存在各自的栈里,所以能单独打印出来,这就是独立线程栈带来的好处,调试的时候也能分开看每个线程的执行状态。

问题四:为什么开太多线程会耗很多内存?

每个线程都要分配用户栈+内核栈,用户栈默认8MB,内核栈16KB,一个线程就要8MB多,开1000个线程就是8GB,哪怕这些线程什么都不做,也要占这么多内存,所以高并发服务不会用开太多线程的模型,一般用IO多路复用,少量线程处理大量连接,就能节省很多栈内存。

四种栈设计的本质:隔离与稳定

绕了一圈回来,我们会发现Linux分这么多栈,核心设计思路就是两个:隔离上下文,保证稳定性。

早期单进程单线程系统,一个栈就够了,后来有了多线程,每个线程独立执行,必须给每个线程分独立的用户栈和内核栈,不然调用信息会乱;后来为了避免中断处理导致内核栈溢出,又设计了专门的中断栈,把中断处理的栈和进程栈分开,哪怕进程栈满了,中断也能正常处理,系统更稳定。

这种"每个独立上下文配独立栈"的设计,看起来增加了复杂度,实际上给系统带来了非常好的隔离性:一个上下文的栈溢出,只会影响自己,不会干扰其他上下文,排查问题也更容易。

结语

很多人学习Linux的时候,只会关注栈的基本概念,不会区分这四种不同的栈,但实际理解了它们的区别,就能把程序运行、用户内核切换、中断处理整个流程串起来:从用户线程调用函数,到系统调用陷入内核,再到硬件中断响应,每一步都用对对应的栈,整个流程清晰稳定,互不干扰。

理解了这四种栈,我们就能解释很多开发中遇到的问题:为什么递归太深会崩溃,为什么内核不能开大数组,为什么多线程每个线程有自己的调用栈,这些问题的根源都在栈的设计上。而Linux这种"分上下文隔离设计"的思路,也值得我们学习:把不同职责的逻辑分开,给每个逻辑独立的资源,看起来多了一点 overhead,但是换来了整个系统的稳定和可维护性,这是非常成熟的设计思路。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
换一批
延伸阅读

在Linux操作系统中,栈是一种至关重要的内存结构,它遵循“后进先出”(LIFO)的原则,用于存储函数调用上下文、局部变量和临时数据。不同的执行场景对应着不同类型的栈,包括进程栈、线程栈、内核栈和中断栈。这些栈各自承担着...

关键字: 进程栈 线程栈
关闭