关于linux:浅谈Linux-中的进程栈线程栈内核栈中断栈

44次阅读

共计 8093 个字符,预计需要花费 21 分钟才能阅读完成。

栈是什么?栈有什么作用?

首先,栈 (stack) 是一种串列模式的 数据结构 。这种数据结构的特点是 后入先出 (LIFO, Last In First Out),数据只能在串列的一端 (称为:栈顶 top) 进行 推入 (push) 和 弹出 (pop) 操作。依据栈的特点,很容易的想到能够利用数组,来实现这种数据结构。然而本文要探讨的并不是软件层面的栈,而是硬件层面的栈。

大多数的处理器架构,都有实现硬件栈。有专门的栈指针寄存器,以及特定的硬件指令来实现 入栈 / 出栈 的操作。例如在 ARM 架构上,R13 (SP) 指针是堆栈指针寄存器,而 PUSH 是用于压栈的汇编指令,POP 则是出栈的汇编指令。

上面咱们来看看栈有什么作用。栈作用能够从两个方面体现:函数调用 多任务反对

一、函数调用

咱们晓得一个函数调用有以下三个根本过程:
– 调用参数的传入
– 局部变量的空间治理
– 函数返回

函数的调用必须是高效的,而数据寄存在 CPU 通用寄存器 或者 RAM 内存 中无疑是最好的抉择。以传递调用参数为例,咱们能够抉择应用 CPU 通用寄存器 来寄存参数。然而通用寄存器的数目都是无限的,当呈现函数嵌套调用时,子函数再次应用原有的通用寄存器必然会导致抵触。因而如果想用它来传递参数,那在调用子函数前,就必须先 保留原有寄存器的值 ,而后当子函数退出的时候再 复原原有寄存器的值

函数的调用参数数目个别都绝对少,因而通用寄存器是能够满足肯定需要的。然而局部变量的数目和占用空间都是比拟大的,再依赖无限的通用寄存器未免强人所难,因而咱们能够采纳某些 RAM 内存区域来存储局部变量。然而存储在哪里适合?既不能让函数嵌套调用的时候有抵触,又要重视效率。

这种状况下,栈无疑提供很好的解决办法。一、对于通用寄存器传参的抵触,咱们能够再调用子函数前,将通用寄存器长期压入栈中;在子函数调用结束后,在将已保留的寄存器再弹出复原回来。二、而局部变量的空间申请,也只须要向下挪动下栈顶指针;将栈顶指针向回挪动,即可就可实现局部变量的空间开释;三、对于函数的返回,也只须要在调用子函数前,将返回地址压入栈中,待子函数调用完结后,将函数返回地址弹出给 PC 指针,即实现了函数调用的返回;

于是上述函数调用的三个根本过程,就演变记录一个栈指针的过程。每次函数调用的时候,都配套一个栈指针。即便循环嵌套调用函数,只有对应函数栈指针是不同的,也不会呈现抵触。

须要 C /C++ Linux 服务器架构师学习材料加群(563998835)(材料包含 C /C++,Linux,golang 技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg 等),收费分享

二、多任务反对

然而栈的意义还不只是函数调用,有了它的存在,能力构建出操作系统的多任务模式。咱们以 main 函数调用为例,main 函数蕴含一个有限循环体,循环体中先调用 A 函数,再调用 B 函数。

func B():  return;func A():  B();func main():  while (1)    A();

试想在单处理器状况下,程序将永远停留在此 main 函数中。即便有另外一个工作在期待状态,程序是没法从此 main 函数外面跳转到另一个工作。因为如果是函数调用关系,实质上还是属于 main 函数的工作中,不能算多任务切换。此刻的 main 函数工作自身其实和它的栈绑定在了一起,无论如何嵌套调用函数,栈指针都在本栈范畴内挪动。

由此能够看出一个工作能够利用以下信息来表征:
1. main 函数体代码
2. main 函数栈指针
3. 以后 CPU 寄存器信息

如果咱们能够保留以上信息,则齐全能够强制让出 CPU 去解决其余工作。只有未来想继续执行此 main 工作的时候,把下面的信息复原回去即可。有了这样的先决条件,多任务就有了存在的根底,也能够看出栈存在的另一个意义。在多任务模式下,当调度程序认为有必要进行工作切换的话,只需保留工作的信息(即下面说的三个内容)。复原另一个工作的状态,而后跳转到上次运行的地位,就能够复原运行了。

可见每个工作都有本人的栈空间,正是有了独立的栈空间,为了代码重用,不同的工作甚至能够混用工作的函数体自身,例如能够一个 main 函数有两个工作实例。至此之后的操作系统的框架也造成了,譬如工作在调用 sleep() 期待的时候,能够被动让出 CPU 给别的工作应用,或者分时操作系统工作在工夫片用完是也会被迫的让出 CPU。不论是哪种办法,只有想方法切换工作的上下文空间,切换栈即可。

Linux 中有几种栈?各种栈的内存地位?

内核将栈分成四种:

  • 过程栈
  • 线程栈
  • 内核栈
  • 中断栈

一、过程栈

过程栈是属于用户态栈,和过程 虚拟地址空间 (Virtual Address Space) 密切相关。那咱们先理解下什么是虚拟地址空间:在 32 位机器下,虚拟地址空间大小为 4G。这些虚拟地址通过页表 (Page Table) 映射到物理内存,页表由操作系统保护,并被处理器的内存治理单元 (MMU) 硬件援用。每个过程都领有一套属于它本人的页表,因而对于每个过程而言都如同独享了整个虚拟地址空间。

Linux 内核将这 4G 字节的空间分为两局部,将最高的 1G 字节(0xC0000000-0xFFFFFFFF)供内核应用,称为 内核空间 。而将较低的 3G 字节(0x00000000-0xBFFFFFFF)供各个过程应用,称为 用户空间。每个过程能够通过零碎调用陷入内核态,因而内核空间是由所有过程共享的。尽管说内核和用户态过程占用了这么大地址空间,然而并不象征它们应用了这么多物理内存,仅示意它能够摆布这么大的地址空间。它们是依据须要,将物理内存映射到虚拟地址空间中应用。

Linux 对过程地址空间有个规范布局,地址空间中由各个不同的内存段组成 (Memory Segment),次要的内存段如下:
– 程序段 (Text Segment):可执行文件代码的内存映射
– 数据段 (Data Segment):可执行文件的已初始化全局变量的内存映射
– BSS 段 (BSS Segment):未初始化的全局变量或者动态变量(用零页初始化)
– 堆区 (Heap) : 存储动态内存调配,匿名的内存映射
– 栈区 (Stack) : 过程用户空间栈,由编译器主动调配开释,寄存函数的参数值、局部变量的值等
– 映射段(Memory Mapping Segment):任何内存映射文件

而下面过程虚拟地址空间中的栈区,正指的是咱们所说的过程栈。过程栈的初始化大小是由编译器和链接器计算出来的,然而栈的实时大小并不是固定的,Linux 内核会依据入栈状况对栈区进行动静增长(其实也就是增加新的页表)。然而并不是说栈区能够有限增长,它也有最大限度 RLIMIT_STACK (个别为 8M),咱们能够通过 ulimit 来查看或更改 RLIMIT_STACK 的值。

【扩大浏览】:如何确认过程栈的大小

咱们要晓得栈的大小,那必须得晓得栈的起始地址和完结地址。栈起始地址 获取很简略,只须要嵌入汇编指令获取栈指针 esp 地址即可。 栈完结地址 的获取有点麻烦,咱们须要先利用递归函数把栈搞溢出了,而后再 GDB 中把栈溢出的时候把栈指针 esp 打印进去即可。代码如下:

/* file name: stacksize.c */void *orig_stack_pointer;void blow_stack() {    blow_stack();}int main() {    __asm__("movl %esp, orig_stack_pointer");    blow_stack();    return 0;}
$ g++ -g stacksize.c -o ./stacksize$ gdb ./stacksize(gdb) rStarting program: /home/home/misc-code/setrlimitProgram received signal SIGSEGV, Segmentation fault.blow_stack () at setrlimit.c:44       blow_stack();(gdb) print (void *)$esp$1 = (void *) 0xffffffffff7ff000(gdb) print (void *)orig_stack_pointer$2 = (void *) 0xffffc800(gdb) print 0xffffc800-0xff7ff000$3 = 8378368    // Current Process Stack Size is 8M

上面对过程的地址空间有个比拟全局的介绍,那咱们看下 Linux 内核中是怎么体现下面内存布局的。内核应用内存描述符来示意过程的地址空间,该描述符示意着过程所有地址空间的信息。内存描述符由 mm_struct 构造体示意,上面给出内存描述符构造中各个域的形容,请大家联合后面的 过程内存段布局 图一起看:

struct mm_struct {struct vm_area_struct *mmap;           /* 内存区域链表 */    struct rb_root mm_rb;                  /* VMA 造成的红黑树 */    ...    struct list_head mmlist;               /* 所有 mm_struct 造成的链表 */    ...    unsigned long total_vm;                /* 全副页面数目 */    unsigned long locked_vm;               /* 上锁的页面数据 */    unsigned long pinned_vm;               /* Refcount permanently increased */    unsigned long shared_vm;               /* 共享页面数目 Shared pages (files) */    unsigned long exec_vm;                 /* 可执行页面数目 VM_EXEC & ~VM_WRITE */    unsigned long stack_vm;                /* 栈区页面数目 VM_GROWSUP/DOWN */    unsigned long def_flags;    unsigned long start_code, end_code, start_data, end_data;    /* 代码段、数据段 起始地址和完结地址 */    unsigned long start_brk, brk, start_stack;                   /* 栈区 的起始地址,堆区 起始地址和完结地址 */    unsigned long arg_start, arg_end, env_start, env_end;        /* 命令行参数 和 环境变量的 起始地址和完结地址 */    ...    /* Architecture-specific MM context */    mm_context_t context;                  /* 体系结构非凡数据 */    /* Must use atomic bitops to access the bits */    unsigned long flags;                   /* 状态标记位 */    ...    /* Coredumping and NUMA and HugePage 相干构造体 */};

【扩大浏览】:过程栈的动静增长实现

过程在运行的过程中,通过一直向栈区压入数据,当超出栈区容量时,就会耗尽栈所对应的内存区域,这将触发一个 缺页异样 (page fault)。通过异样陷入内核态后,异样会被内核的 expand_stack() 函数解决,进而调用 acct_stack_growth() 来查看是否还有适合的中央用于栈的增长。

如果栈的大小低于 RLIMIT_STACK(通常为 8MB),那么个别状况下栈会被加长,程序继续执行,感觉不到产生了什么事件,这是一种将栈扩大到所需大小的惯例机制。然而,如果达到了最大栈空间的大小,就会产生 栈溢出(stack overflow),过程将会收到内核收回的 段谬误(segmentation fault) 信号。

动静栈增长是惟一一种拜访未映射内存区域而被容许的情景,其余任何对未映射内存区域的拜访都会触发页谬误,从而导致段谬误。一些被映射的区域是只读的,因而希图写这些区域也会导致段谬误。

二、线程栈

从 Linux 内核的角度来说,其实它并没有线程的概念。Linux 把所有线程都当做过程来实现,它将线程和过程不加区分的对立到了 task_struct 中。线程仅仅被视为一个与其余过程共享某些资源的过程,而是否共享地址空间简直是过程和 Linux 中所谓线程的惟一区别。线程创立的时候,加上了 CLONE_VM 标记,这样 线程的内存描述符 将间接指向 父过程的内存描述符

  if (clone_flags & CLONE_VM) {/*     * current 是父过程而 tsk 在 fork() 执行期间是共享子过程     */    atomic_inc(¤t->mm->mm_users);    tsk->mm = current->mm;  }

尽管线程的地址空间和过程一样,然而看待其地址空间的 stack 还是有些区别的。对于 Linux 过程或者说主线程,其 stack 是在 fork 的时候生成的,实际上就是复制了父亲的 stack 空间地址,而后写时拷贝 (cow) 以及动静增长。然而对于主线程生成的子线程而言,其 stack 将不再是这样的了,而是当时固定下来的,应用 mmap 零碎调用,它不带有 VM_STACK_FLAGS 标记。这个能够从 glibc 的 nptl/allocatestack.c 中的 allocate_stack() 函数中看到:

mem = mmap (NULL, size, prot,            MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

因为线程的 mm->start_stack 栈地址和所属过程雷同,所以线程栈的起始地址并没有寄存在 task_struct 中,应该是应用 pthread_attr_t 中的 stackaddr 来初始化 task_struct->thread->sp(sp 指向 struct pt_regs 对象,该构造体用于保留用户过程或者线程的寄存器现场)。这些都不重要,重要的是,线程栈不能动静增长,一旦用尽就没了,这是和生成过程的 fork 不同的中央。因为线程栈是从过程的地址空间中 map 进去的一块内存区域,原则上是线程公有的。然而同一个过程的所有线程生成的时候浅拷贝生成者的 task_struct 的很多字段,其中包含所有的 vma,如果违心,其它线程也还是能够拜访到的,于是肯定要留神。

三、过程内核栈

在每一个过程的生命周期中,必然会通过到零碎调用陷入内核。在执行零碎调用陷入内核之后,这些内核代码所应用的栈并不是原先过程用户空间中的栈,而是一个独自内核空间的栈,这个称作过程内核栈。过程内核栈在过程创立的时候,通过 slab 分配器从 thread_info_cache 缓存池中调配进去,其大小为 THREAD_SIZE,一般来说是一个页大小 4K;

union thread_union {struct thread_info thread_info;                        unsigned long stack[THREAD_SIZE/sizeof(long)];};                                                  

thread_union 过程内核栈 和 task_struct 过程描述符有着严密的分割。因为内核常常要拜访 task_struct,高效获取以后过程的描述符是一件十分重要的事件。因而内核将过程内核栈的头部一段空间,用于寄存 thread_info 构造体,而此构造体中则记录了对应过程的描述符,两者关系如下图(对应内核函数为 dup_task_struct()):

有了上述关联构造后,内核能够先获取到栈顶指针 esp,而后通过 esp 来获取 thread_info。这里有一个小技巧,间接将 esp 的地址与上 ~(THREAD_SIZE – 1) 后即可间接取得 thread_info 的地址。因为 thread_union 构造体是从 thread_info_cache 的 Slab 缓存池中申请进去的,而 thread_info_cache 在 kmem_cache_create 创立的时候,保障了地址是 THREAD_SIZE 对齐的。因而只须要对栈指针进行 THREAD_SIZE 对齐,即可取得 thread_union 的地址,也就取得了 thread_union 的地址。胜利获取到 thread_info 后,间接取出它的 task 成员就胜利失去了 task_struct。其实下面这段形容,也就是 current 宏的实现办法:

register unsigned long current_stack_pointer asm ("sp");static inline struct thread_info *current_thread_info(void)  {return (struct thread_info *)                                        (current_stack_pointer & ~(THREAD_SIZE - 1));}                                                            #define get_current() (current_thread_info()->task)#define current get_current()                       

四、中断栈

过程陷入内核态的时候,须要内核栈来反对内核函数调用。中断也是如此,当零碎收到中断事件后,进行中断解决的时候,也须要中断栈来反对函数调用。因为零碎中断的时候,零碎当然是处于内核态的,所以中断栈是能够和内核栈共享的。然而具体是否共享,这和具体解决架构密切相关。

X86 上中断栈就是独立于内核栈的;独立的中断栈所在内存空间的调配产生在 arch/x86/kernel/irq_32.c 的 irq_ctx_init()函数中(如果是多处理器零碎,那么每个处理器都会有一个独立的中断栈),函数应用 __alloc_pages 在低端内存区调配 2 个物理页面,也就是 8KB 大小的空间。乏味的是,这个函数还会为 softirq 调配一个同样大小的独立堆栈。如此说来,softirq 将不会在 hardirq 的中断栈上执行,而是在本人的上下文中执行。

而 ARM 上中断栈和内核栈则是共享的;中断栈和内核栈共享有一个负面因素,如果中断产生嵌套,可能会造成栈溢出,从而可能会毁坏到内核栈的一些重要数据,所以栈空间有时候难免会顾此失彼。


Linux 为什么须要辨别这些栈?

为什么须要辨别这些栈,其实都是设计上的问题。这里就我看到过的一些观点进行汇总,供大家探讨:

  1. 为什么须要独自的过程内核栈?所有过程运行的时候,都可能通过零碎调用陷入内核态继续执行。假如第一个过程 A 陷入内核态执行的时候,须要期待读取网卡的数据,被动调用 schedule() 让出 CPU;此时调度器唤醒了另一个过程 B,碰巧过程 B 也须要零碎调用进入内核态。那问题就来了,如果内核栈只有一个,那过程 B 进入内核态的时候产生的压栈操作,必然会毁坏掉过程 A 已有的内核栈数据;一但过程 A 的内核栈数据被毁坏,很可能导致过程 A 的内核态无奈正确返回到对应的用户态了;
  2. 为什么须要独自的线程栈?Linux 调度程序中并没有辨别线程和过程,当调度程序须要唤醒”过程”的时候,必然须要复原过程的上下文环境,也就是过程栈;然而线程和父过程齐全共享一份地址空间,如果栈也用同一个那就会遇到以下问题。如果过程的栈指针初始值为 0x7ffc80000000;父过程 A 先执行,调用了一些函数后栈指针 esp 为 0x7ffc8000FF00,此时父过程被动休眠了;接着调度器唤醒子线程 A1:
    此时 A1 的栈指针 esp 如果为初始值 0x7ffc80000000,则线程 A1 一但呈现函数调用,必然会毁坏父过程 A 已入栈的数据。如果此时线程 A1 的栈指针和父过程最初更新的值统一,esp 为 0x7ffc8000FF00,那线程 A1 进行一些函数调用后,栈指针 esp 减少到 0x7ffc8000FFFF,而后线程 A1 休眠;调度器再次换成父过程 A 执行,那这个时候父过程的栈指针是应该为 0x7ffc8000FF00 还是 0x7ffc8000FFFF 呢?无论栈指针被设置到哪个值,都会有问题不是吗?
  3. 过程和线程是否共享一个内核栈?No,线程和过程创立的时候都调用 dup_task_struct 来创立 task 相干构造体,而内核栈也是在此函数中 alloc_thread_info_node 进去的。因而尽管线程和过程共享一个地址空间 mm_struct,然而并不共享一个内核栈。
  4. 为什么须要独自中断栈?这个问题其实不对,ARM 架构就没有独立的中断栈。

正文完
 0