关于栈
- 函数调用栈的典型内存布局
- 栈帧 (Stack Frame) 的边界由栈帧基地址指针
EBP
和 栈指针ESP
界定,EBP
指向当前栈帧底部 (高地址),在当前栈帧内位置固定;ESP
指向当前栈帧顶部 (低地址); - 当程序执行时,
ESP
会随着数据的入栈和出栈而移动,因此函数中对大部分数据的访问都基于EBP
进行。
- 栈帧 (Stack Frame) 的边界由栈帧基地址指针
- 栈帧存放着参数,局部变量及恢复前一栈帧所需要的数据等。
进程栈
进程虚拟地址空间中的栈区,正指的是我们所说的进程栈。进程栈是属于用户态栈,和进程虚拟地址空间 (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
17gary@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->sp(sp
指向struct pt_regs
对象,该结构体用于保存用户进程或者线程的寄存器现场) - 重要的是,线程栈不能动态增长,一旦用尽就没了,这是和生成进程的 fork() 不同的地方。
- 由于线程的 mm->start_stack 栈地址和所属进程相同,所以线程栈的起始地址并没有存放在
- 线程栈是从进程的地址空间中 mmap 出来的一块内存区域,原则上是线程私有的;但是同一个进程的所有线程在生成的时候会浅拷贝线程生成者
task_struct
的很多字段,其中包括所有的vma
,因此如果愿意,其它线程也还是可以访问到的,于是一定要注意! - 为什么需要单独的线程栈?不能共享同一个进程栈吗?
- Linux 调度程序中并没有区分线程和进程 (线程是调度的基本单位),当调度程序需要唤醒“进程”的时候,必然需要恢复进程的上下文环境,也就是进程栈;线程和父进程完全共享一份地址空间,如果栈也用同一个,那就会遇到以下问题:
- 假如进程的栈指针初始值为 0x7ffc80000000:父进程 A 先执行,调用了一些函数后栈指针
esp
为 0x7ffc8000FF00,此时父进程主动休眠了; - 接着调度器唤醒子线程 A1:
- 如果此时 A1 的栈指针
esp
为初始值 0x7ffc80000000,则线程 A1 一但出现函数调用,必然会破坏父进程 A 已入栈的数据; - 如果此时线程 A1 的栈指针和父进程最后更新的值一致,
esp
为 0x7ffc8000FF00,那线程 A1 进行一些函数调用后,栈指针esp
增加到 0x7ffc8000FFFF,然后线程 A1 休眠;调度器再次换成父进程 A 执行,那这个时候父进程的栈指针是应该为 0x7ffc8000FF00 还是 0x7ffc8000FFFF 呢? - 无论栈指针被设置到哪个值,都会有问题不是吗?
- 如果此时 A1 的栈指针
- 假如进程的栈指针初始值为 0x7ffc80000000:父进程 A 先执行,调用了一些函数后栈指针
- Linux 调度程序中并没有区分线程和进程 (线程是调度的基本单位),当调度程序需要唤醒“进程”的时候,必然需要恢复进程的上下文环境,也就是进程栈;线程和父进程完全共享一份地址空间,如果栈也用同一个,那就会遇到以下问题:
进程内核栈
在每一个进程的生命周期中,必然会通过到系统调用陷入内核。在执行系统调用陷入内核之后,这些内核代码所使用的栈并不是原先进程用户空间中的栈,而是一个单独内核空间的栈,这个称作进程内核栈。
进程内核栈在进程创建的时候,通过 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
4union 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
11register 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));
}
- 由于
thread_union
结构体是从thread_info_cache
的 Slab 缓存池中申请出来的,而thread_info_cache
在kmem_cache_create
创建的时候,保证了地址是THREAD_SIZE
对齐的; - 因此只需要对栈指针进行
THREAD_SIZE
对齐,即可获得thread_union
的地址,也就获得了thread_info
的地址:直接将esp
的地址与上~(THREAD_SIZE - 1)
- 由于
中断栈
进程陷入内核态的时候,需要内核栈来支持内核函数调用;中断也是如此,当系统收到中断事件后,进行中断处理的时候,也需要中断栈来支持函数调用。
- 由于系统中断的时候,系统当然是处于内核态的,所以中断栈是可以和内核栈共享的(但是具体是否共享,这和具体处理架构密切相关)。
- 中断栈独立于内核栈
x86 上中断栈就是独立于内核栈的,独立的中断栈所在内存空间的分配发生在
arch/x86/kernel/irq_32.c
的 irq_ctx_init() 函数中(如果是多处理器系统,那么每个处理器都会有一个独立的中断栈)。- 函数使用 __alloc_pages() 在低端内存区分配 2 个物理页面,也就是 8KB 大小的空间。
- 这个函数还会为 softirq 分配一个同样大小的独立堆栈,如此说来,softirq 将不会在 hardirq 的中断栈上执行,而是在自己的上下文中执行。
- ARM 上中断栈和内核栈则是共享的;中断栈和内核栈共享有一个负面因素,如果中断发生嵌套,可能会造成栈溢出,从而可能会破坏到内核栈的一些重要数据,所以栈空间有时候难免会捉襟见肘。
- 中断栈独立于内核栈
- 为什么需要单独中断栈?
这个问题其实不对,ARM 架构就没有独立的中断栈。