前言: 栈的基本逻辑
进行函数调用时, 会将当前指令处的地址压入(push
)到栈顶, 从函数返回时, 从栈顶弹出(pop
)返回地址, 并跳转过去, 这便是栈的一个使用方法。其次, 在c语言中, 声明一个局部变量, 也会将其压入栈顶(但不是一定的, 我们也可以声明一个寄存器变量, 此时它就代表一个寄存器而不占用栈空间)。然而过深的函数调用会使得栈压入过多的内容, 声明一个较大的局部变量(例如char a[999999]
)也会占用大量的栈空间。因此有必要在程序中保持栈的平衡, 比如尽量规避使用递归调用, 因为它是不可控的, 会大量push
而不及时pop
, 使栈占用过多, 造成栈溢出
的后果。一个设计良好的算法, 应该避免递归调用, 避免在一个函数中声明一个大的变量。
linux的”main线程“的栈安排
在linux中, pthread和最初的main函数代表的线程(这里我们用main线程
来归纳)使用不同的栈。其中main线程
的栈由系统内核维护, 可以通过getrlimit
得到main线程
限制, 下面的代码演示了这一点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #define _GNU_SOURCE
#include <sys/resource.h> #include <stdio.h>
int main() { struct rlimit stack_info; getrlimit(RLIMIT_STACK, &stack_info); printf("%lu %lu\n", stack_info.rlim_cur, stack_info.rlim_max); }
|
来观察输出, 当前使用的限制是8M, 因此我们目前的main线程最多使用8M的栈空间。如果我们要支持更大的栈空间, 可以使用setrlimit来设置当前限制, 你可以将其改为一个极大的数字(甚至大于你的电脑ram, 但要求小于rlit_max, 这个rlit_max是你最大能设置的数值)。每当栈空间不够时, 内核负责给你申请新的页表, 使你能使用更大的栈(俗称懒加载)。当你的栈空间使用已经达到了rlim_cur, 此时内核就不再为你申请新的页表, 而是直接把这个进程杀死了, 下面的程序演示了栈溢出时进程被杀死的情形。
1 2 3 4 5 6 7 8 9
| #define _GNU_SOURCE
int main() { main(); }
|
值得注意的是, setrlimit只能root用户才能使用, 如果你以普通用户的权限运行setrlimit函数, 那么这个函数将不会起作用且返回Permission Denied
错误。
pthread线程的栈安排
当我们运行pthread_create时, 我们可以指定attr, 我们一般写多线程程序时直接将其指定为NULL
, 表示使用默认的配置, 下面说明了一个普通的pthread线程的运行方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| #define _GNU_SOURCE
#include <stdio.h> #include <unistd.h> #include <pthread.h> #include <stdlib.h>
void* new_routine(void *arg) { while(1) { printf("%d\n", *(int*)arg); } }
int main() { int *arg = malloc(sizeof(int)); *arg = 200; pthread_t tid; pthread_create(&tid, NULL, new_routine, arg); sleep(10); }
|
那么, 以默认配置创建的pthread, 栈在哪里呢? 阅读gnu pthread源码, 可以发现, pthread创建的线程使用mmap
匿名映射为其分配栈空间, 且分配的栈空间较小。阅读linux的内存安排相关文章可以知道内核为mmap
在内存模型中占有某部分区域, 我们知道堆从低地址处向上增长, 栈在高地址处向下增长, 而mmap映射区
就在它们之间, 下面的图片可以看的很清楚。

mmap
可以将文件映射到文件映射区, 这里不展开这种用法。此外它还能进行匿名映射
, 这种映射专用于申请内存, 大小必须为系统页大小的整数倍, 并只有写入某个页的时候, 系统内核才会分配这个页来实现懒加载
, 下面是一个实验, 说明mmap
的懒加载机制。打开资源监视器, 运行程序, 查看内存占用情况。注释掉程序中标记的一行, 再运行程序查看内存占用情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| #define _GNU_SOURCE
#include <stdio.h> #include <sys/mman.h> #include <unistd.h> #include <stdlib.h>
#define SIZE ((unsigned long)262144)
int main() { char *addr = mmap(NULL, SIZE*4096, PROT_WRITE|PROT_READ, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0); if (addr == MAP_FAILED) { perror("mmap error"); exit(-1); }
sleep(5); for (size_t i = 0; i < SIZE; i++) { if (i == SIZE/2) { sleep(3); } addr[i] = 1; printf("%d\n", addr[i]); } sleep(10);
}
|
值得留意的是, mmap
的第三个参数prot
(代表protect
), 是该段内存的访问权限, 除了可以通过mmap
设置, 在mmap
后, 还能通过mprotect
修改。当然你可以选择修改映射的全部部分, 也可以只修改映射的一部分, 在本文的末尾, 我们将利用mprotect
实现动态栈。
既然pthread
能用自己申请的mmap
匿名映射作为栈, 那么我们能否自己指定栈大小呢? 答案是肯定的, 下面的程序展示了这种自己设置栈的思路。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| #define _GNU_SOURCE
#include <stdio.h> #include <sys/mman.h> #include <unistd.h> #include <stdlib.h> #include <signal.h> #include <pthread.h> #include <sys/signal.h>
void do_func(int i) { printf("%d\n", i); do_func(i+1); }
void *new_func(void *) { do_func(1); }
int main() { void *new_stack = malloc(PTHREAD_STACK_MIN*2); printf("%p\n", new_stack);
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstack(&attr, new_stack, PTHREAD_STACK_MIN*2); pthread_t tid; pthread_create(&tid, &attr, new_func, NULL); pthread_join(tid, NULL); }
|
可以使用mmap
或malloc
等函数获得的空间作为栈, 只要是一块可用的内存空间, 都能成为pthread
的栈。
实现动态栈的思路
现在有了前面的知识储备, 我们能够开始实现动态栈了, 下面将展示如何利用页错误(SEGMENT FAULT
)信号处理函数来实现栈的动态增长, 步骤如下。
- 使用
mmap
一次性映射超大内存(可以大于自身机器的ram大小, 我们这里规定栈最大为4000K, 你可以自行调大), 由此来为pthread
预留足够的栈空间
- 使用
mprotect
修改之前映射的内存, 开放最顶端的一部分区域为可读写, 并将紧邻的一个页空间作为警戒区(权限设置为NONE)
- 创建使用该
mmap
的线程, attr
中指定这个映射地址
- 线程运行程序中, 设置信号处理函数, 发生
SIGSEGV
时, 此信号处理函数被调用, 在此处重新调用mprotect
扩大mmap
可读写区域的大小, 并重新设置警戒区
下面是这种方法的代码, 运行并修改info.max_alloc_page_num = 1000;
, 将1000改的更大, 观察资源消耗情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| #define _GNU_SOURCE
#include <stdio.h> #include <sys/mman.h> #include <unistd.h> #include <stdlib.h> #include <signal.h> #include <pthread.h> #include <sys/signal.h>
struct stack_info { void *mmap_addr; unsigned long alloc_page_num; unsigned long max_alloc_page_num; };
struct stack_info info;
void do_func(int i) { printf("%d\n", i); do_func(++i); }
void handler(int signo, siginfo_t *siginfo, void *) { info.alloc_page_num *= 2; if(info.alloc_page_num > info.max_alloc_page_num) { printf("segment overflow\n"); sleep(3); exit(1); }
mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num+1-info.alloc_page_num)*4096), info.alloc_page_num*4096, PROT_READ|PROT_WRITE); mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num-info.alloc_page_num)*4096), 4096, PROT_NONE); }
void *new_routine(void *data) { stack_t s = { .ss_flags = 0, .ss_size = 4096, .ss_sp = malloc(4096) }; int n = sigaltstack(&s, NULL); struct sigaction action; action.sa_flags = SA_SIGINFO|SA_RESTART|SA_ONSTACK; sigfillset(&action.sa_mask); action.sa_sigaction = handler; sigaction(SIGSEGV, &action, NULL);
do_func(0); }
int main() { info.alloc_page_num = 10; info.max_alloc_page_num = 1000; info.mmap_addr = mmap(NULL, (info.max_alloc_page_num+1)*4096, PROT_NONE, MAP_ANONYMOUS|MAP_PRIVATE,-1, 0); mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num+1-info.alloc_page_num)*4096), info.alloc_page_num*4096, PROT_READ|PROT_WRITE); mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num+1-info.alloc_page_num-1)*4096), 4096, PROT_NONE);
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstack(&attr, info.mmap_addr, (info.max_alloc_page_num+1)*4096); pthread_t tid; int n = pthread_create(&tid, &attr, new_routine, NULL); pthread_join(tid, NULL); }
|
写在最后, 一些QA
Q: 为什么要给信号处理函数单独的栈?
A: 如果不单独设置, 内核将使用程序当前的栈作为信号处理函数的栈, 然而这里已经没有空间, 内核发现后会直接杀死进程
Q: 为什么可以给mmap
指定超大的空间, 甚至大于机器?
A: mmap
匿名映射是懒加载的, 只有当程序尝试写入某个4k区间, 内核才会从内存中申请物理空间
Q: 把栈搞这么大, 有什么意义?
A: 没有意义, 我认为可以优化算法, 避免递归的算法, 而不是想着用更大的栈空间, 良好的程序设计, 栈总是平衡的
Q: 为什么要一次性先申请足够大的mmap
匿名映射?
A: 栈的空间必须保证连续, 在一次mmap
后, 操作系统不能保下一个mmap
分配的内存块紧挨者第一块
参考文献