内存不用白不用,何必在一开始就限制栈的大小,linux的机制是尽量多尽量紧凑的使用虚拟内存,原则就是你现在不用我就用,没有预留的概念,当然你可以通过系统调用实现预留,就像glibc的堆管理那样,这里所说的完全是针对于操作系统内核的,用户空间程序完全可以向操作系统通过brk或者mmap实现用户空间的内存预留。windows的实现就不是这样,windows要求程序在运行之前就限制好栈使用的内存的大小,一旦超过这个大小,哪怕向下伸展的栈下方的内存没有实体使用,那么也会触发异常,windows将栈内存的使用完全暴露给了用户空间,而linux却没有,linux透明的实现了栈内存的动态管理,一开始分配给栈的内存很小,随着函数调用深度的增加和局部变量空间的增加,栈会动态得到扩展,当前这个动态扩展的前提是向下扩展的栈的下方的内存没有被映射,也就是这些栈需要的内存还不属于任何的vm_area_struct,一旦其它非栈的内存映射映射到了离3G界限非常近的地方,那么linux用户栈将会被限制的非常小,但是那一块内存映射到哪里完全取决于应用程序自己,内核根本不管,内核只是接受brk或者mmap的参数,然后实现内存映射(本文前提,栈是向下扩展的)。
正如以上所说,linux可以实现像windows那样的栈内存的限制,也可以不限制栈内存,然后将一切交给内核来完成,下面看一下我作的实验:
#include
#define PAGE_SIZE 4096
int g;
void stubfunc()
{
g++;
printf("g:%d/n",g);
char as[PAGE_SIZE]; //作为局部变量,效果就是每调用一次该stubfunc函数,栈就会增长最少一个页面
int i = 0;
for( i=0;i < PAGE_SIZE ;i++)
memset(as+i,1,1);
//getchar();这个调用使得试验者有机会去查看/proc/XXX/maped文件,实时看到栈在扩展
stubfunc();
}
int main( int argc, char* argv[] )
{
stubfunc(); //开启递归函数调用,目的是测试linux的栈可以动态扩展到多少
}
我运行了好几次该程序,在g达到3500到4200的时候出现段错误,这说明linux的栈的大小可以扩展到3500到4200个页面的大小,当然这完全取决于内核的实现和用户空间c库的实现,如果用汇编实现那么将完全取决于内核的映射策略,但是不管怎样栈是在动态扩展。如果将此程序放到windows上运行,那么g能达到多少将完全取决于内核的实现和在链接程序的时候指定的stack_size的大小,比如我将stack_size指定为32个页面,那么g的值将在达到32左右的时候出现段错误,因为windows的栈管理是用一套很严格的机制完成的,分为提交页面和保留页面,具体请参考我的文章《windows和linux的内存管理》,一旦超越了了事先设置好的界限,那么就会出现异常,即使栈的下面都是空闲内存也不行,因为windows的执行完全是静态指定的,这里可以看到windows仅仅适合桌面应用,灵活性非常差,它只要保证桌面小应用能高效稳定执行而不能充分动态利用大型机上的充足的资源,另外,操作系统本来不应该提供过多的策略,除非操作系统的设计者认为程序员都是白痴,栈越界检查其实应该是用户空间自己的事情,内核何必插手,搞windows正是被这种策略惯坏了才不求甚解的,这种方式也不能说一无是处,最起码可以让更多的人低门槛的步入windows编程领域,然后高效快速的进行生产,而在linux下编程的几乎都是有两下子的,因为内核几乎不会为你做什么。下面看一下linux下如何进行对栈的限制,这里仅仅简单的谈谈原理。
如果想限制栈的大小,那么最起码可以检测到栈的越界,一种很显然的做法就是在栈的限制页面下面映射一个不可访问的页面,一旦栈伸展到该页面就会出现异常,其实windows也是这么做的,最起码大致原理是这样的,下面看看具体实现,注意,如果想实现一个完整的栈限制还要密切注意其它内存映射而不仅仅是栈下面的那个映射,你要保证你为了限制栈而设置的不可访问的内存段是栈紧接着下面的,并且保证这个映射一定成功,也就是说不能让别的内存段映射到此位置,但是这是一个复杂的过程,涉及到libc库对可重定位共享库映射的实现,本文不谈:
#include
#define PAGE_SIZE 4096
int g;
void stubfunc()
{
...
}
int main( int argc, char* argv[] )
{
int * ap = &arg; //栈的大概位置,因为argc在栈上,我的结果是0xbfbcddf8
unsigned long address = ( unsigned long )ap; address = (address-X*4096)&0xfffff000; //X为一个整数,并且最后将address圆整到4096的倍数,这是MAP_FIXED的要求
char * p = (char*)mmap( address, //,在address处确定性映射,我的结果是0xbf000000
0x1000, //一个页面的长度
PROT_NONE, //不可访问,一旦栈扩展到这里将会出错
MAP_FIXED|MAP_PRIVATE|MAP_ANONYMOUS|MAP_LOCKED, //载入物理内存,严格按照所给地址分配
-1, 0 );
stubfunc(); //开始递归调用检测栈限制
}
通过运行可以看到结果,g几乎可以到了X附近,为何说是附近呢?因为该程序取的ap是一个大致位置,并不是esp的位置,用c实现完全定位esp是不容易的,用汇编比较简单,直接取esp就可以了,这个实现简单的阐述了栈限制的原理。
但是问题来了,既然linux没有要求栈的确切位置,那么是不是就是说只要访问到当前栈的vm_area_struct顶端和栈以下的映射内存的末尾之间的内存,通过缺页异常都会将栈扩展到该位置呢?从do_page_fault可以看出一些端倪,事实上并不是这样,这里可以看出linux实现的随意性,本来的想法很好,就是说只要被访问的内存地址比esp低,那么就视为出错,然而有一个enter指令和pusha指令,这两个指令都会在重新设置esp之前将栈扩展,其实就是压入很多的数据,如此一来事情就不是那么显然了,恶意程序完全可以利用这个漏洞,看看do_page_fault的相关逻辑:
fastcall void __kprobes do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
...
vma = find_vma(mm, address);
if (!vma)
goto bad_area;
if (vma->vm_start <= address)
goto good_area;
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4) { //从2.6.18开始,以下的“+ 65536 + 32 * sizeof(unsigned long)”代替了原来的“+32”
if (address + 65536 + 32 * sizeof(unsigned long) < regs->esp)
goto bad_area;
}
...
}
在上面最后一个if之前有一段注释被我删去了,之所以在2.6.28之后做了更改完全是为了支持enter指令,因此才有了注释中所说的那个很厚的垫子,只要访问这个厚垫子内部的地址程序是不会出错的,这个实在不应该,因为这个地址可能已经不是栈空间,并且当前执行的也不是enter或者pusha指令,请看下面的代码:
int main( int argc, char* argv[] )
{
char stub[XXX]; //XXX是一个比较大的填充数,我取的是32768,只要能保证在下面执行psp-65664之后的结果pi的值是栈栈之外的就可以
int psp;
asm volatile("movl %%esp,%0":"=m" (psp):); //得到esp的值
int* pi = (int *)(psp-65664); //65564也就是65536+32*sizeof(unsigned long)
*pi = 10; //访问之,将导致缺页,栈将会被扩展,然而实际上这是一个十足的栈越界
}
上面的程序十分简单,如果将psp-65664换成psp-65664-n(n为正数),那么程序将出错,实际上早就应该出错了,因为栈已经越界了,在2.6.18之前的内核中测试上述代码,即使将65664换成60000也会出错,因为那些早期的版本中只允许地址在esp下面32字节的位置以内。下面看一下最后一个代码,这个代码同步推进了esp指针8个单位,那么结果就是可以允许psp-65664-8以内的地址访问不会出错:
int main( int argc, char* argv[] )
{
char stub[XXX]; //同上
int psp;
asm volatile("movl %%esp,%0":"=m" (psp):);
int* pi = (int *)(psp-65664);
__asm__("subl $8, %%esp/n/t":);
*pi = 10;
}
linux不是十全十美的,但是已经不错了,反正我是这么认为的!