虚拟内存[02] Linux 中的各种栈:进程栈 线程栈 内核栈 中断栈

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

关于栈

  • 函数调用栈的典型内存布局
    • 栈帧 (Stack Frame) 的边界由栈帧基地址指针 EBP 和 栈指针 ESP 界定,EBP指向当前栈帧底部 (高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部 (低地址);
    • 当程序执行时,ESP会随着数据的入栈和出栈而移动,因此函数中对大部分数据的访问都基于EBP进行。
  • 栈帧存放着参数局部变量恢复前一栈帧所需要的数据等。

进程栈

 进程虚拟地址空间中的栈区,正指的是我们所说的进程栈进程栈是属于用户态栈,和进程虚拟地址空间 (Virtual Address Space) 密切相关。


图: 32 位系统下进程地址空间默认布局(左)和进程地址空间经典布局(右)

 进程栈的初始化大小是由编译器和链接器计算出来的,但是栈的实时大小并不是固定的,Linux 内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。但是并不是说栈区可以无限增长,它也有最大限制 RLIMIT_STACK (一般为 8M),我们可以通过 ulimit 来查看或更改 RLIMIT_STACK 的值 (stack size):

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    gary@xxx:~$ ulimit -a
    core file size (blocks, -c) 0
    data seg size (kbytes, -d) unlimited
    scheduling priority (-e) 0
    file size (blocks, -f) unlimited
    pending signals (-i) 30980
    max locked memory (kbytes, -l) 64
    max memory size (kbytes, -m) unlimited
    open files (-n) 1024
    pipe size (512 bytes, -p) 8
    POSIX message queues (bytes, -q) 819200
    real-time priority (-r) 0
    stack size (kbytes, -s) 8192
    cpu time (seconds, -t) unlimited
    max user processes (-u) 30980
    virtual memory (kbytes, -v) unlimited
    file locks (-x) unlimited
  • 进程栈的动态增长实现
    • 进程在运行的过程中,通过不断向栈区压入数据,当超出栈区容量时,就会耗尽栈所对应的内存区域,这将触发一个缺页异常 (page fault)
    • 通过异常陷入内核态后,异常会被内核的 expand_stack() 函数处理,进而调用 acct_stack_growth() 来检查是否还有合适的地方用于栈的增长:
      • 如果栈的大小低于 RLIMIT_STACK,那么一般情况下栈会被加长,程序继续执行,感觉不到发生了什么事情,这是一种将栈扩展到所需大小的常规机制;
      • 如果达到了最大栈空间的大小,就会发生栈溢出 (stack overflow),进程将会收到内核发出的段错误(segmentation fault) 信号。
    • 动态栈增长是唯一一种访问未映射内存区域而被允许的情形,其他任何对未映射内存区域的访问都会触发页错误,从而导致段错误。一些被映射的区域是只读的,因此企图写这些区域也会导致段错误。

线程栈

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

  • 虽然线程的地址空间和进程一样,但是在对待其地址空间中的 stack 上还是有些区别的。
    • 对于 Linux 进程或者说主线程,其 stack 是在 fork() 的时候生成的,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝 (cow) 以及动态增长。
    • 对于主线程生成的子线程而言,其 stack 将不再是这样的了,而是事先固定下来的,使用 mmap()系统调用从进程的地址空间中 mmap 出来的一块内存区域,它不带有 VM_STACK_FLAGS 标记。
      • 由于线程的 mm->start_stack 栈地址和所属进程相同,所以线程栈的起始地址并没有存放在 task_struct 中,应该是使用 pthread_attr_t 中的 stackaddr 来初始化 task_struct->thread->spsp 指向 struct pt_regs 对象,该结构体用于保存用户进程或者线程的寄存器现场)
      • 重要的是,线程栈不能动态增长,一旦用尽就没了,这是和生成进程的 fork() 不同的地方。
  • 线程栈是从进程的地址空间中 mmap 出来的一块内存区域,原则上是线程私有的;但是同一个进程的所有线程在生成的时候会浅拷贝线程生成者 task_struct 的很多字段,其中包括所有的 vma,因此如果愿意,其它线程也还是可以访问到的,于是一定要注意!
  • 为什么需要单独的线程栈?不能共享同一个进程栈吗?
    • Linux 调度程序中并没有区分线程和进程 (线程是调度的基本单位),当调度程序需要唤醒“进程”的时候,必然需要恢复进程的上下文环境,也就是进程栈;线程和父进程完全共享一份地址空间,如果栈也用同一个,那就会遇到以下问题:
      • 假如进程的栈指针初始值为 0x7ffc80000000:父进程 A 先执行,调用了一些函数后栈指针 esp0x7ffc8000FF00,此时父进程主动休眠了;
      • 接着调度器唤醒子线程 A1:
        • 如果此时 A1 的栈指针 esp 为初始值 0x7ffc80000000,则线程 A1 一但出现函数调用,必然会破坏父进程 A 已入栈的数据;
        • 如果此时线程 A1 的栈指针和父进程最后更新的值一致,esp0x7ffc8000FF00,那线程 A1 进行一些函数调用后,栈指针 esp 增加到 0x7ffc8000FFFF,然后线程 A1 休眠;调度器再次换成父进程 A 执行,那这个时候父进程的栈指针是应该为 0x7ffc8000FF00 还是 0x7ffc8000FFFF 呢?
        • 无论栈指针被设置到哪个值,都会有问题不是吗?

进程内核栈

 在每一个进程的生命周期中,必然会通过到系统调用陷入内核。在执行系统调用陷入内核之后,这些内核代码所使用的栈并不是原先进程用户空间中的栈,而是一个单独内核空间的栈,这个称作进程内核栈

 进程内核栈在进程创建的时候,通过 slab 分配器thread_info_cache 缓存池中分配出来,大小为 THREAD_SIZE,一般来说是一个页大小 4K;

  • 为什么需要单独的进程内核栈

     所有进程运行的时候,都可能通过系统调用陷入内核态继续执行:假设第一个进程 A 陷入内核态执行的时候,需要等待读取网卡的数据,主动调用 schedule() 让出 CPU;此时调度器唤醒了另一个进程 B,碰巧进程 B 也需要系统调用进入内核态;那问题就来了,如果内核栈只有一个,那进程 B 进入内核态的时候产生的压栈操作,必然会破坏掉进程 A 已有的内核栈数据,一但进程 A 的内核栈数据被破坏,很可能导致进程 A 的内核态无法正确返回到对应的用户态了。

  • 进程和子线程是否共享一个进程内核栈?

     线程和进程创建的时候都调用 dup_task_struct() 来创建 task 相关结构体,而内核栈也是在此函数中 alloc_thread_info_node() 出来的,因此虽然线程和进程共享一个地址空间 mm_struct,但是并不共享一个内核栈(本来线程就是 CPU 调度的基本单位)。

  • 进程内核栈current当前进程
    • 内核执行的大多数操作还是和某个特定的进程相关,内核代码可通过访问current来获得当前进程。

       内核开发者设计了一种能找到运行在相关 CPU 上的当前进程的机制;因为 current的引用会频繁发生,因此这种机制必须是快速的。

    • 内核将进程内核栈的头部一段空间用于存放thread_info结构体,该结构体中则记录了对应进程的描述符task_struct
  • 1
    2
    3
    4
    union thread_union {
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];
    };
    • 有了上述关联结构后,内核可以先获取到栈顶指针 esp,然后通过 esp 来获取 thread_info
    • 成功获取到 thread_info 后,直接取出它的 task 成员就成功得到了task_struct,也就是如下 current 宏的实现方法:
  • current 宏的实现方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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()
    • 由于 thread_union 结构体是从thread_info_cacheSlab 缓存池中申请出来的,而 thread_info_cachekmem_cache_create 创建的时候,保证了地址是 THREAD_SIZE对齐的;
    • 因此只需要对栈指针进行 THREAD_SIZE 对齐,即可获得 thread_union 的地址,也就获得了 thread_info 的地址:直接将 esp 的地址~(THREAD_SIZE - 1)

中断栈

 进程陷入内核态的时候,需要内核栈来支持内核函数调用;中断也是如此,当系统收到中断事件后,进行中断处理的时候,也需要中断栈来支持函数调用。

  • 由于系统中断的时候,系统当然是处于内核态的,所以中断栈是可以和内核栈共享的(但是具体是否共享,这和具体处理架构密切相关)。
    • 中断栈独立于内核栈

       x86 上中断栈就是独立于内核栈的,独立的中断栈所在内存空间的分配发生在 arch/x86/kernel/irq_32.cirq_ctx_init() 函数中(如果是多处理器系统,那么每个处理器都会有一个独立的中断栈)。

      • 函数使用 __alloc_pages() 在低端内存区分配 2 个物理页面,也就是 8KB 大小的空间。
      • 这个函数还会为 softirq 分配一个同样大小的独立堆栈,如此说来,softirq 将不会在 hardirq 的中断栈上执行,而是在自己的上下文中执行。
    • ARM 上中断栈和内核栈则是共享的;中断栈和内核栈共享有一个负面因素,如果中断发生嵌套,可能会造成栈溢出,从而可能会破坏到内核栈的一些重要数据,所以栈空间有时候难免会捉襟见肘。
  • 为什么需要单独中断栈?

    这个问题其实不对,ARM 架构就没有独立的中断栈。

References

·《Linux 中的各种栈:进程栈 线程栈 内核栈 中断栈》
·《Linux虚拟地址空间布局以及进程栈和线程栈总结》

文章目录
  1. 1. 关于栈
  2. 2. 进程栈
  3. 3. 线程栈
  4. 4. 进程内核栈
  5. 5. 中断栈
  6. 6. References