请使劲回答一个关于UNIX/Linux自动扩展stack的问题

有本事就出来,没本事就当鳖!

如果让我回答关于进程栈,线程栈的问题,只要问题不笼统,只要问题明确,我会一五一十地回答,正确率上九成,然而,可悲的是,问题往往他妈的都不是那么明确,因此,游戏到此结束!!艹。但是如果给我一个反问的机会,我会问提问者反问下面一个问题,记住,使出你拉屎的劲来回答:

UNIX/Linux的stack在大多数平台是向下扩展的(注意,我已经告诉他事实了,我并没有问...是如何扩展的,这是可以背诵下来并朗读出来的),在一个执行流调用了一个函数A,而该函数A在stack上分配了一个大数组导致了stack扩展(注意,这又是一段陈述,我还没有给出问题),然后A返回了,UNIX/Linux理应回收调A里面大数组分配stack空间-因为它再也没有用了,但是它并没有这么做(这里可能是一个陷阱,真的是UNIX/Linux理应这么做却没有做,还是说我只是在逗你玩...不确定,但陈述就是如此)。(注意,我的问题来了),请问,UNIX/Linux为什么这么做???!!!
       凡是回答操作系统这么规定的之类的答案,一律零分!况且,你能证明我说的一定对吗?万一我是逗你玩呢?操作系统能规定一个错的东西吗?或者我可以继续问为什么这么规定,直到像我初中的历史老师被我问到朝我眼角猛打一拳那样,如果我能找回点贱人所谓的快感,那么来打吧!问题就是这样,不管这个问题是一个伪命题还是你有自己的想法,能说5分钟的,我觉得也够可以了。该题目的答题要求如下:
时间限制:5分钟。
作答方式:全口述,不能画图,不能打手势...语言含糊不清的,表达能力不好的,算错误。
答题建议:如果你对OS虚拟内存管理以及Linux的VMA实现细节没有相当深入的理解,请不要猜测答案。请直接回答“不知道”,然后看完此文。
.................................
5分钟过去。我要公布一点我的想法了。
       首先,这个问题在本身看来,有问题。因为虽然Linux理应这么做,但它:
第一,它不一定能做到;
第二,它根本没有必要做。
那么论据是什么?凭什么这样说?

积极论点

没必要这样做。执行流还会调用别的函数或者再次调用A,频繁回收栈损耗性能;

消极论点

很难或者不能做到。stack操作是处理器控制的,和OS内核地址空间管理机制之间没有同步机制,一个函数调用结束后,CPU自动处理stack寄存器的收缩,弹出栈帧,然而它无法通知OS内存管理系统去更新进程地址空间的映射关系。

如何处理stack所在地址空间区域的争议

stack会一直扩展到碰到异常的地址B,B可能是一个readonly的地址或者是一个保护空洞,在向下扩展stack情况下,如果地址B偏上,会导致stack空间变小,如果偏下,一旦函数局部变量几乎占满了stack底到B的空间,mmap虽然也能unmap掉这段区域然后remap,然而这会使数据混乱,造成严重问题。

拍脑袋的结论

mmap或者brk期间,比较stack顶部与esp寄存器,若小于则回收(等于是正常的,大于是不可能的)。

Linux真实的做法

Linux没有判断什么esp寄存器,Linux的原则很简单,只要一个地址处在一个vma范围内或者处在stack可扩展的范围内,且拥有权限的,它就是可以访问,内核是不管这个VMA是属于stack还是heap或者别的什么,具体由应用程序自己控制,也就是说,你完全可以写一段代码,把地址空间中所有可以写的区域全部清零,这完全有可能,缓冲区溢出可能是一种蓄意的破坏,然而程序员偶然的错误也会造成破坏,虽然他们大多数都不知道错误是如何发生的。我不想用文字长篇大论Linux是如何管理VMA的,你知道这个应该是一个前提,你必须知道这个。我用一段代码以及两个图示来展示Linux系统内核是如何管理stack附近的地址空间映射的,并且在第二张图中给出,如果你非要蓄意破坏,会造成什么问题。也就是说,一旦发生莫名奇妙的错误,你必须能从细节上理解这个错误是如何发生的。

演示代码

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/mman.h>

#define LARGE       70000000
#define PAGESIZE    4096

// 该函数什么都不做,仅仅为了把stack向下扩展
// 请注意用ulimit将stack大小限制去掉,这样会更容易说明问题
void call()
{
    int i;
    char a[LARGE];

    // 请相信,一定是在中间的赋值中触发segfault,因为两边的元素要么处在stack/fixmap vma,
    // 要么处在游离的,及其孤独的,被fixmap给截断了的vma中。因此下面的赋值不会引发段错误:
    // a[0] = 1;
    // a[LARGE-1] = 1;
    for (i = 0; i < LARGE; i++) {
        a[i] = 1;
    }  
}


int main(int argc, char **argv)
{
    int i;
    char *p_map, *p_base, *p_base2;

    printf("%d\ninit state\n", getpid());

    // 获取stack的大致地址,并且PAGESIZE对齐。
    p_base = (char *)&i;
    p_base2 = (char *)(((unsigned long)p_base) & ~4095);

    // 获取pagesize对齐的用来fixmap的地址,该地址起点在当前stack的下面。
    p_base2 = (char *)((unsigned long)p_base2 - (unsigned long)36*PAGESIZE);
    getchar();

    // 调用fixmap,显然,如果你仔细在getchar期间分析了/proc/xx/maps文件并且
    // 得到了上述的那些magic number,下面的mmap无论如何是会成功的!
    p_map = (char *)mmap((void*)p_base2, PAGESIZE*3, PROT_READ | PROT_WRITE, 
                        MAP_FIXED |MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p_map == MAP_FAILED) {
        printf("failed 1\n");
    } else {
        printf("before unmap fixmap around stack\n");
        getchar();
        // 成功了就释放掉它,此时的地址空间恢复成mmap之前的状况
        munmap(p_map, PAGESIZE*3);
    }  
   
    printf("after unmap fixmap around stack\n");
    getchar();

    call();

    printf("after extend stack[first]\n");
    getchar();

    // 依然调用之前的那个一模一样的mmap进行fixmap,由于调用了call,stack
    // 空间已经扩展到了这个fixmap的fixaddress,很遗憾,成功了,然而它将stack vma
    // 一刀切成了两段。不管怎样,访问还是可以进行的。
    p_map = (char *)mmap((void*)p_base2, PAGESIZE*3, PROT_READ | PROT_WRITE,
                        MAP_FIXED |MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p_map == MAP_FAILED) {
        printf("failed 2\n");
    }

    printf("after second fixmap around stack at the same address\n");
    getchar();

    // 这里更狠!部分unmap了上面那个fixmap vma,留下一个空洞。
    munmap(p_map, PAGESIZE);

    printf("after unmap fixmap around stack incompletely\n");
    getchar();

    // 当空洞被touch的时候,不会引发stack extend!而是直接segfault!爆炸!
    call();

    // 永远不会到达这里!
    printf("after extend stack[second]\n");
    getchar();

    return 0;
}

针对上述代码的图解

下面一幅图展示一直到出事之前,该进程的stack附近的地址空间映射区域是怎么演化的:


技术分享


下面一幅图展示出事的过程以及这个事故的原因:


技术分享


测试方式

如果你觉得图是我自己画出来的,那么肯定有一个疑问,我是基于什么画出来的,事实上,我并不是通过看代码画出来的,我是通过不断查看procfs的maps文件实时了解该进程地址空间的细节,将其转换成了上面的图示,为了让我有机会到另一个终端去查maps文件,我在代码中增加了getchar调用,每次查看完maps文件,我会拍一下键盘的回车键。我的测试如下:

代码编译后的进程输出

root@abcd:~# ./a.out
7846
init state

before unmap fixmap around stack

after unmap fixmap around stack

after extend stack[first]

after second fixmap around stack at the same address

after unmap fixmap around stack incompletely

段错误 (core dumped)

以下是查看每一步进程maps文件的输出:

root@abcd:~# cat /proc/`ps -e|grep a.out|awk ‘{print $1}‘`/maps |tail -n 6|head -n 4
2b2c01483000-2b2c01487000 r--p 00157000 fe:00 387296                     /lib/libc-2.11.2.so
2b2c01487000-2b2c01488000 rw-p 0015b000 fe:00 387296                     /lib/libc-2.11.2.so
2b2c01488000-2b2c0148f000 rw-p 00000000 00:00 0
7fff6236a000-7fff6237f000 rw-p 00000000 00:00 0                          [stack]
root@abcd:~# cat /proc/`ps -e|grep a.out|awk ‘{print $1}‘`/maps |tail -n 6|head -n 4
2b2c01487000-2b2c01488000 rw-p 0015b000 fe:00 387296                     /lib/libc-2.11.2.so
2b2c01488000-2b2c0148f000 rw-p 00000000 00:00 0
7fff62359000-7fff6235c000 rw-p 00000000 00:00 0
7fff6236a000-7fff6237f000 rw-p 00000000 00:00 0                          [stack]
root@abcd:~# cat /proc/`ps -e|grep a.out|awk ‘{print $1}‘`/maps |tail -n 6|head -n 4
2b2c01483000-2b2c01487000 r--p 00157000 fe:00 387296                     /lib/libc-2.11.2.so
2b2c01487000-2b2c01488000 rw-p 0015b000 fe:00 387296                     /lib/libc-2.11.2.so
2b2c01488000-2b2c0148f000 rw-p 00000000 00:00 0
7fff6236a000-7fff6237f000 rw-p 00000000 00:00 0                          [stack]
root@abcd:~# cat /proc/`ps -e|grep a.out|awk ‘{print $1}‘`/maps |tail -n 6|head -n 4
2b2c01483000-2b2c01487000 r--p 00157000 fe:00 387296                     /lib/libc-2.11.2.so
2b2c01487000-2b2c01488000 rw-p 0015b000 fe:00 387296                     /lib/libc-2.11.2.so
2b2c01488000-2b2c0148f000 rw-p 00000000 00:00 0
7fff5e0bb000-7fff6237f000 rw-p 00000000 00:00 0                          [stack]
root@abcd:~# cat /proc/`ps -e|grep a.out|awk ‘{print $1}‘`/maps |tail -n 6|head -n 4
2b2c01488000-2b2c0148f000 rw-p 00000000 00:00 0
7fff5e0bb000-7fff62359000 rw-p 00000000 00:00 0
7fff62359000-7fff6235c000 rw-p 00000000 00:00 0
7fff6235d000-7fff6237f000 rw-p 00000000 00:00 0                          [stack]
root@abcd:~# cat /proc/`ps -e|grep a.out|awk ‘{print $1}‘`/maps |tail -n 6|head -n 4
2b2c01488000-2b2c0148f000 rw-p 00000000 00:00 0
7fff5e0bb000-7fff62359000 rw-p 00000000 00:00 0
7fff6235a000-7fff6235c000 rw-p 00000000 00:00 0
7fff6235d000-7fff6237f000 rw-p 00000000 00:00 0                          [stack]
我怕上面的文字信息太乱,格式在不同浏览器会有问题,我还特意截了一张图:


技术分享


有什么用

你可以用这种方式彻底限制一个进程的stack的大小,越界了不是报错,而是segfault。然后你可以signal捕获这个segfault,在里面把那个未完全unmap的fixmap vma以及那个可怜且孤独的残缺的stack vma给彻底unmap掉。不过这确实没什么好玩的。有什么用呢?它的作用就是让你更加深入理解Linux对虚拟地址空间的管理方式。

小Tips

本文不涉及线程栈,但是倒也不难,线程栈一般在heap区或者中间的大块mmap区动态分配,mmap的时候给它一个MAP_GROWSDOWN标志就可以了。关于它的管理方式,没啥差别。核心问题在于,缺页异常处理程序是怎么识别到一个缺页是一个vma内部的缺页(结果就是调页),还是vma外部的缺页。在后一种情况下,缺页处理逻辑还要进一步识别是stack的缺页(结果就是extend stack然后调页),还是非stack缺页(结果就是segfault...)。
       Linux的stack除非遇到本文所述的这种方式的挤兑收缩,它是永远扩展的。如果你想阅读Linux的内核代码,那么也需要理解下面的事实:
1.find_vma函数能找到vma只有一个限制,即输入地址只要小于查找vma的end即可,并非很多人想象的那样输入地址必须处在查找vma的start和end之间;
2.find_vma函数之所以实现得如此incompletely,是因为为了简化缺页中断的处理,同时也是为了提供一种更加统一的方式同时处理upgrows和downgrows的vma。

后话

虽然这个问题问得有点乱,但是如果能找上述回答连续扯5分钟的,应该是真行!不过我不知道怎样的语言表达能力可以不用图解和代码把上面的每一个细节说清楚...总之,我觉得我的这个题目是一个好题目。可以建议给看到此文的人,把它做面试题吧。凡是发现不了题目问题的以及说不出所以然的,一律不要!这真是一道好测试题啊,它是如此之好,以至于我还想再出几道比它更好的。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。