有一天我写了一个牛逼的函数:
static int64_t factorial(int64_t n) {
if (n == 1) {
return n;
}
return n * factorial(n - 1);
}
由于我相信这个函数足够牛逼所以我打算委以重任,于是我:
factorial(0xffff);
然而当我按下 Command-R 的时候,却看到了这样一个令人失望的景象:
我们初中二年级就学过,栈空间的长度是很有限的,如此深的递归调用势必会导致爆栈,那么我们的栈到底有多大呢?emmm,这可能跟你用的编译器和操作系统都有关,我大 macOS 下的栈空间是 8 MB 左右,用 vmmap 命令就能看到:
那么为啥我们的 app 会挂掉呢?因为鸡贼的操作系统给你每个 Stack 区的上下边界都加了几个 guard 页面,范围很大,你可以看到有 56 MB 那么大,它是不可写的。所以一旦我们把栈打爆,势必会碰到这个 guard 页面,然后你就 EXC_BAD_ACCESS 了~
那么,我有个大胆的想法:
虽然系统给我们的栈区小得可怜,那我们换一个大一点的空间不就得了?比如我们反手跟系统要 100 MB 的页面:
const size_t stack_size = 1024 * 1024 * 100;
vm_address_t addr;
if (vm_allocate(mach_task_self(), &addr, stack_size, YES)) {
abort();
}
然后移花接木,把栈区换掉,100 MB 的空间足够我们完成所有 reasonable 递归深度函数的调用了。
咋换呢?我们先来复习一下初中二年级就学过的 Calling Convention,当调用一个函数时发生了什么:
此时的栈空间长这个样子:
call 指令是一个复合指令,我们来查一手 x86 手册:
从伪代码可知,call = push rip + mov rip,接下来 callee 函数开始 prologue 阶段,也就是保存 rbp + 重建 rbp + 开辟栈空间。
换栈空间的时机放在 callee 处会由于 prologue 的存在变得比较棘手,需要借助 __attribute__((naked)) 来实现,很是麻烦。显然,放在 caller 那里处理会简单很多:
static void trampoline() {
const size_t stack_size = 1024 * 1024 * 100;
vm_address_t addr;
if (vm_allocate(mach_task_self(), &addr, stack_size, YES)) {
abort();
}
// 我们初中二年级就学过栈是向低地址增长的...
addr += stack_size;
// 04.06 更新:Darwin 要求栈 16 bytes 对齐
addr -= 8;
// 先把要调用的函数的地址找出来,PIC 下函数地址是 rip + offset
void *entry_addr = (void *) &factorial;
__asm__("movq %%rsp, %%r8n" // 先把原来的 rsp 存到一个寄存器,一会用
"movq %0, %%rspn" // 换上我们的豪宅“栈“地址
"pushq %%r8n" // 把原来的 rsp 压入新栈
"movq $0xffff, %%rdin" // 准备 factorial 参数
"callq *%1n" // 一切就绪,调用之!
"popq %%rspn" // 调用完了,把 rsp 恢复成原来的蜗居
:
: "r"(addr), "r"(entry_addr)
: "%rdi", "%r8");
// 忘记释放豪宅了...算了先这样吧
}
就是这么简单,我们再调用一次试试:
65535 的递归深度,已经超越常规了...
最后总结一下
- 栈不一定非要是真的栈,系统给你的不够用就换掉
- 一般的协程库在实现时也会在堆区分配空间给新的栈使用
- 本文方法切勿直接在实际项目中使用,栈对齐啥的都没做,要用的话自己把这部分做掉