栈是什么?栈有什么作用?
首先,栈 (stack) 是一种串列形式的 数据结构。这种数据结构的特点是 后入先出 (LIFO, Last In First Out),数据只能在串列的一端 (称为:栈顶 top) 进行 推入 (push) 和 弹出 (pop) 操作。根据栈的特点,很容易的想到可以利用数组,来实现这种数据结构。但是本文要讨论的并不是软件层面的栈,而是硬件层面的栈。
大多数的处理器架构,都有实现硬件栈。有专门的栈指针寄存器,以及特定的硬件指令来完成 入栈/出栈 的操作。例如在 ARM 架构上,R13 (SP) 指针是堆栈指针寄存器,而 PUSH 是用于压栈的汇编指令,POP 则是出栈的汇编指令。
ARM 处理器拥有 37 个寄存器。这些寄存器按部分重叠组方式加以排列。每个处理器模式都有一个不同的寄存器组。编组的寄存器为处理处理器异常和特权操作提供了快速的上下文切换。
提供了下列寄存器:
- 三十个 32 位通用寄存器:
- 存在十五个通用寄存器,它们分别是 r0-r12、sp、lr
- sp (r13) 是堆栈指针。C/C++ 编译器始终将 sp 用作堆栈指针
- lr (r14) 用于存储调用子例程时的返回地址。如果返回地址存储在堆栈上,则可将 lr 用作通用寄存器
- 程序计数器 (pc):指令寄存器
- 应用程序状态寄存器 (APSR):存放算术逻辑单元 (ALU) 状态标记的副本
- 当前程序状态寄存器 (CPSR):存放 APSR 标记,当前处理器模式,中断禁用标记等
- 保存的程序状态寄存器 (SPSR):当发生异常时,使用 SPSR 来存储 CPSR
上面是栈的原理和实现,下面我们来看看栈有什么作用。栈作用可以从两个方面体现:函数调用 和多任务支持 。
函数调用
我们知道一个函数调用有以下三个基本过程:
- 调用参数的传入
- 局部变量的空间管理
- 函数返回
函数的调用必须是高效的,而数据存放在 CPU通用寄存器 或者 RAM 内存 中无疑是最好的选择。以传递调用参数为例,我们可以选择使用 CPU通用寄存器 来存放参数。但是通用寄存器的数目都是有限的,当出现函数嵌套调用时,子函数再次使用原有的通用寄存器必然会导致冲突。因此如果想用它来传递参数,那在调用子函数前,就必须先 保存原有寄存器的值,然后当子函数退出的时候再 恢复原有寄存器的值 。
函数的调用参数数目一般都相对少,因此通用寄存器是可以满足一定需求的。但是局部变量的数目和占用空间都是比较大的,再依赖有限的通用寄存器未免强人所难,因此我们可以采用某些 RAM 内存区域来存储局部变量。但是存储在哪里合适?既不能让函数嵌套调用的时候有冲突,又要注重效率。
这种情况下,栈无疑提供很好的解决办法。一、对于通用寄存器传参的冲突,我们可以再调用子函数前,将通用寄存器临时压入栈中;在子函数调用完毕后,在将已保存的寄存器再弹出恢复回来。二、而局部变量的空间申请,也只需要向下移动下栈顶指针;将栈顶指针向回移动,即可就可完成局部变量的空间释放;三、对于函数的返回,也只需要在调用子函数前,将返回地址压入栈中,待子函数调用结束后,将函数返回地址弹出给 PC 指针,即完成了函数调用的返回;
于是上述函数调用的三个基本过程,就演变记录一个栈指针的过程。每次函数调用的时候,都配套一个栈指针。即使循环嵌套调用函数,只要对应函数栈指针是不同的,也不会出现冲突。
函数调用经常是嵌套的,在同一时刻,栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等,函数调用时入栈的顺序为:
实参N~1 → 主调函数返回地址 → 主调函数帧基指针EBP → 被调函数局部变量1~N
栈帧的边界由 栈帧基地址指针 EBP 和 栈指针 ESP 界定,EBP 指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。函数调用栈的典型内存布局如下图所示:
多任务支持
然而栈的意义还不只是函数调用,有了它的存在,才能构建出操作系统的多任务模式。我们以 main 函数调用为例,main 函数包含一个无限循环体,循环体中先调用 A 函数,再调用 B 函数。
func B(): return; func A(): B(); func main(): while (1) A();
Linux 中有几种栈?各种栈的内存位置?
介绍完栈的工作原理和用途作用后,我们回归到 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) r Starting program: /home/home/misc-code/setrlimit Program received signal SIGSEGV, Segmentation fault. blow_stack () at setrlimit.c:4 4 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
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 相关结构体 */ };
二、线程栈
从 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; }
mem = mmap (NULL, size, prot, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
三、进程内核栈
在每一个进程的生命周期中,必然会通过到系统调用陷入内核。在执行系统调用陷入内核之后,这些内核代码所使用的栈并不是原先进程用户空间中的栈,而是一个单独内核空间的栈,这个称作进程内核栈。进程内核栈在进程创建的时候,通过 slab 分配器从 thread_info_cache 缓存池中分配出来,其大小为 THREAD_SIZE,一般来说是一个页大小 4K;
union thread_union { struct thread_info thread_info; unsigned long stack[THREAD_SIZE/sizeof(long)]; };
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 为什么需要区分这些栈?
为什么需要区分这些栈,其实都是设计上的问题。这里就我看到过的一些观点进行汇总,供大家讨论:
为什么需要单独的进程内核栈?
所有进程运行的时候,都可能通过系统调用陷入内核态继续执行。假设第一个进程 A 陷入内核态执行的时候,需要等待读取网卡的数据,主动调用 schedule() 让出 CPU;此时调度器唤醒了另一个进程 B,碰巧进程 B 也需要系统调用进入内核态。那问题就来了,如果内核栈只有一个,那进程 B 进入内核态的时候产生的压栈操作,必然会破坏掉进程 A 已有的内核栈数据;一但进程 A 的内核栈数据被破坏,很可能导致进程 A 的内核态无法正确返回到对应的用户态了;
为什么需要单独的线程栈?
此时 A1 的栈指针 esp 如果为初始值 0x7ffc80000000,则线程 A1 一但出现函数调用,必然会破坏父进程 A 已入栈的数据。
如果此时线程 A1 的栈指针和父进程最后更新的值一致,esp 为 0x7ffc8000FF00,那线程 A1 进行一些函数调用后,栈指针 esp 增加到 0x7ffc8000FFFF,然后线程 A1 休眠;调度器再次换成父进程 A 执行,那这个时候父进程的栈指针是应该为 0x7ffc8000FF00 还是 0x7ffc8000FFFF 呢?无论栈指针被设置到哪个值,都会有问题不是吗?
Linux 调度程序中并没有区分线程和进程,当调度程序需要唤醒”进程”的时候,必然需要恢复进程的上下文环境,也就是进程栈;但是线程和父进程完全共享一份地址空间,如果栈也用同一个那就会遇到以下问题。假如进程的栈指针初始值为 0x7ffc80000000;父进程 A 先执行,调用了一些函数后栈指针 esp 为 0x7ffc8000FF00,此时父进程主动休眠了;接着调度器唤醒子线程 A1:
进程和线程是否共享一个内核栈?
No,线程和进程创建的时候都调用 dup_task_struct 来创建 task 相关结构体,而内核栈也是在此函数中 alloc_thread_info_node 出来的。因此虽然线程和进程共享一个地址空间 mm_struct,但是并不共享一个内核栈。
为什么需要单独中断栈?
这个问题其实不对,ARM 架构就没有独立的中断栈。
审核编辑:汤梓红
全部0条评论
快来发表一下你的评论吧 !