栈的动态分配ALLOCA函数使⽤
哎,下班回家就开始⼤扫除,⼀直到凌晨才搞定,真的累了。但是计划的是今天必须将本⽂写完,不写完睡不着觉。那就尽快切⼊正题吧!
我们经常使⽤malloc或者new等函数或操作符来动态分配内存,这⾥的内存说的是堆内存,并且需要程序员⼿⼯释放分配的内存。malloc对应free,new对应delete。⾄于你要混着⽤,也不是不可以,只要确保逻辑和功能的正确性,还要在规范的限制范围内。这⾥我想插⼀句题外话,我个⼈觉得,只要你将⼀些具有相似特征的东西都摸透了,他们的差异你就会很明了,在此基础上,随便你怎么⽤都是成⽵在胸的,只需要考虑⼀些外界因素就可以了,⽐如前⾯说的规范等。
本⽂是针对在栈上动态分配内存进⾏讨论,分配的内存即为栈内存,栈上的内存有⼀个特点即是不⽤我们⼿⼯去释放申请的内存。栈内存由⼀个栈指针来开辟和回收,栈内存是从⾼地址向低地址增长的,增长时,栈指针向低地址⽅向移动,指针的地址值也就相应的减⼩;回收时,栈指针向⾼地址⽅向移动,地址值也就增加。所以栈内存的开辟和回收都只是指针的加减,由此相对于分配堆内存可以获得⼀定的性能提升。由这些特性,也能对为什么叫“栈”内存有更进⼀步的理解。
我们都知道,在C99标准之前,C语⾔是不⽀持变长数组的,如果想要动态开辟栈内存以达到变长数组的功能就得依靠alloca函数。其实在gcc下,c99下的变长数组后台也是依靠alloca来动态分配栈内存的,当
然这⾥不能完全说是调⽤alloca来实现的,alloca可能被优化并内联(当然你还是可以说这是在调⽤)。这⾥就不纠结这个问题了,在本⽂不属于重点。实际中,alloca函数是不推荐使⽤的,他存在很多不安全的因素,这⾥暂时不讨论这个问题,本⽂的⽬的是了解原理,获得认知,以⾄通透。
通常编译器都提供了CRT库,例如VC的诸多版本,CRT库在⼀些版本间差异还是⽐较⼤,新版本的CRT⼀般会多了很多更严格的检查和⼀些安全机制。本⽂以VS2008为例,其为alloca提供了对应的_alloca函数,编译器会将其编译为_alloca_probe_16函数,此函数位于VC_dir\VC\crt\src\intel\alloca16.asm汇编源⽂件中,此乃微软提供的汇编版本CRT相关函数。在此⽂件中,有两个版本,⼀个是16字节对齐的_alloca_probe_16,⼀个是8字节对齐的_alloca_probe_8。代码如下:
32.
默认会编译为16字节对齐的版本,仔细看⼀下,这⾥所谓的16字节对齐倒也不⼀定,lea ecx, [esp] + 8这句获得进⼊此函数之前的esp 值并写⼊ecx中,这⾥加8的原因很明显,前4个字节是保存的ecx的值,后4个字节是函数的返回地址,加8即得到上⼀层函数调⽤本函数时的esp值,这⾥没有参数压栈,参数是寄存器传递的。因此,这个ecx的值可以假设为⼀个定值(这个值也是⾄少4字节对齐的),然后下⾯3句汇编代码中,eax是外部传⼊的要开辟栈内存字节数,这个字节数始终是4字节对
齐的。那么sub ecx, eax这句之后的结果就可以是4字节对齐且⾮16字节对齐,这样⼀来,在and ecx, ( 16 - 1 )并add eax, ecx后,eax的值就是⾮16字节对齐的。⾄于8字节对齐的版本,你可以试着推算⼀下会不会存在算出的eax是⾮8字节对齐的,这个不是难点。
在此函数⾥,我们发现还没有真正的开辟栈内存,因为esp(也就是前⾯提到的栈指针,也就是栈顶指针,上⾯的汇编代码中的TOS也就是栈顶:Top of stack的意思)的值还没有减去eax(申请内存的⼤⼩)⽽改变。然后我们注意到,在pop ecx还原ecx的值(因为此函数需要ecx来协助,因此进函数就push ecx保存,然后结束之后再pop 还原)之后,还有⼀个jmp跳转,跳转到了_chkstk,此函数很明显,意为:check stack,⽤于检查堆栈是否溢出。此函数通常会被编译器插⼊到某个开辟了⼀定⼤⼩函数头部,⽤于进⼊函数时进⾏栈内存溢出检查,例如你在⼀个函数中定义⼀个较⼤的数组,此时编译器会强制插⼊_chkstk函数进⾏检查(这⾥单指VC下,其他编译器的⽅式不⼀定⼀致)。
于是,到此可以猜测,这个_alloca_probe_16函数只是负责计算实际对齐后该分配多少字节的栈内存,并保存到eax中,由于_chkstk函数也会⽤到eax的值,这⾥也是通过寄存器传参的。并且可以看出_alloca_probe_16函数和_chkstk函数联系紧密,都是直接jmp过去的。
好了,来看看_chkstk函数吧,此函数位于之前的⽬录下,也是⼀个汇编源⽂件:chkstk.asm。代码如下:
45.
此函数较之前的要稍微复杂⼀些,不过代码还是⾮常清晰易懂的。还是解释⼀下吧,先来看lea ecx, [esp] + 8 - 4这句,与
_alloca_probe_16汇编代码相⽐较,多了⼀个减4,这⾥减4是因为从_alloca_probe_16函数到_chkstk函数之间是⽤的jmp,⽽不是call,因此没有返回地址,只有保存的ecx值的4个字节,所以少4个字节的偏移就能取到esp的值了。由于_alloca_probe_16函数是保持栈平衡的,并且没有改变esp的值,因此,_chkstk函数⾥取到的esp与_alloca_probe_16函数取到的esp是⼀样的。并且也都存放到了ecx中。后⾯⼀句与_alloca_probe_16函数的逻辑⼀样,都是将ecx(esp的值)减去eax(要分配的栈内存⼤⼩,已经由_alloca_probe_16函数对齐过)。这⼀句之后,ecx的值就是新的esp的值,如果栈没有溢出,那么esp将会被设置为这个新值,于是栈内存分配成功。
继续向下分析,紧接着下⾯3句,⽤得有⼀点巧妙。sbb eax, eax,sbb乃带借位减法指令,如果前⾯的sub ecx, eax存在借位(ecx⼩于eax),则sbb之后eax的值为0xffffffff,然后再not eax,eax将变成0,然后再and ecx, eax,则ecx变为0,也就意味着新的esp值为0。这⾥先放⼀下,待会⼉再向下分析。再看前⾯,sub ecx, eax存在借位,为什么会存在这样的情况,难道_alloca_probe_16函数不检查申请内存的⼤⼩的吗?的确,他并不会关⼼你想申请多少字节,他只是与_chkstk配合,让_chkstk能
够知道申请的内存过⼤就可以了,过⼤之后可以由_chkstk进⾏检查并抛出异常。那么我们来看_alloca_probe_16函数是怎么配合_chkstk函数的检查的呢。这⼜得回到_alloca_probe_16
函数的汇编源代码中,看这三句:
03.
eax为申请的⼤⼩,ecx为新的esp值,由sub ecx, eax计算获得。把这三句代码与_chkstk函数的三句代码结合着看,这⾥如果eax过⼤(申请空间过⼤),add eax, ecx之后,会溢出,即CF位为1。然后执⾏下⼀句sbb ecx,ecx,也就等同于:ecx = ecx - ecx - CF = 0 - 1 = -1 = 0xffffffff。然后在or eax, ecx,于是eax为0xffffffff,也就是传给_chkstk函数的申请空间⼤⼩。然后再看前⾯对_chkstk函数的分析,如果eax 为0xffffffff,那么肯定会sub溢出,于是ecx(新的esp值)最后为0。再看另外⼀种情况,如果在_alloca_probe_16中,eax的值⼤于ecx的值,那么sub之后,会溢出,在and ecx, ( 16 - 1 )之后,再add eax, ecx,此刻假设不会溢出,sbb之后,ecx为0,之后再or eax,ecx不会影响eax的值,但是此时eax还是⼤于ecx(esp的值)的。当eax传⼊_chkstk之后,sub会溢出。与eax为0xffffffff的结果⼀样,都使得ecx(esp的值)的值为0。所以由上⾯两种情况分析下来,_alloca_probe_16函数和_chkstk函数之间是有⼀定的配合的。也可以说是_alloca_probe_16函数适应了_chkstk的检查⽅案。
我们再继续向下分析_chkstk吧,看后⾯两句,先是mov eax,esp将当前的esp值交给eax,注意这⾥的
esp值是_chkstk内部已经压⼊保存了ecx原始值之后的esp,这个esp也就是最初有lea ecx, [esp] + 8 - 4获得的上层esp值减4(push ecx占⽤的4字节)。获得了当前esp值之后,⼜and eax, not ( _PAGESIZE_ - 1),_PAGESIZE_为0x1000,也就是4096字节(4KB),即为windows页内存⼤⼩规则之⼀。这句代码也就是将当前esp所在的页剩下的字节全部减掉,到达这⼀页的末尾下⼀页的开始。这样做是⽅便后⾯的栈溢出检查。
之后,有两个标签cs10和cs20,cs10的开头是判断ecx是否⼩于eax,此刻的eax已经是某页的开头,如果ecx⼩于这个eax所存的地址值,则跳转到cs20标签⾥,cs20标签⾥代码很简单,进⼊就将eax减掉⼀页内存,然后是test dword ptr [eax],eax这句,这句存在⼀个内存访问,可以想象如果eax所存的内存值不可读,那么就会抛出异常。这⾥正是利⽤这⼀点,当这⾥不异常,⼜会跳转到cs10标签⾥继续⽐较,如果还是⼩,则在减⼀页,再进⾏访问,直到ecx⼤于等于eax或者抛出异常。那么再想⼀下上⾯分析的逻辑,如果申请的空间过molloc函数
⼤,ecx的值会为0,那么在cs20中判断,0会⼀直⼩于eax,这样eax会⼀直减4K,直到eax为0,这⾥显然减不到0就已经抛异常了。当eax 减到⼀定时候,则会在test dword ptr [eax],eax这句抛出⼀个栈溢出的异常,如下图:
如果继续执⾏,则会发⽣访问异常。如果申请的⼤⼩不会导致栈溢出,则当eax减到⼀定时候ecx⼤于等于eax,或者第⼀次进去时ecx就是⼤于等于eax的,则进⼊正常开辟空间的逻辑:
06.
第⼀⾏是将ecx(新的通过验证的esp)赋值给eax,然后是还原ecx的值,第三⾏就是将当前的esp值和eax做交换。esp便是开辟空间后的新值,此刻肯定⽐eax的值要⼩(栈向低地址延伸)。然后是第4句,此时eax是pop ecx之后的esp值,也就是call _alloca_probe_16函数压⼊了返回地址后的esp值,因此,第四句执⾏后,eax的值就是,_alloca_probe_16函数函数的返回地址,我们准备返回到上层,这⾥的上层不是_alloca_probe_16函数,因为他们之间不是call的,⽽是jmp的,不存在返回地址压⼊。这⾥的上层是_alloca_probe_16函数的上层。第5⾏,是将eax存⼊当前的esp指向的内存中,因为下⼀条指令ret,即将读取这个地址,并返回到上层,其间的原理请参考《)》,此⽂有相同的⽤法。
整个过程就是这样了,其实在很多C语⾔编写的实际项⽬中,还是有⽤到alloca。就我个⼈⽽⾔,我觉得不管他有什么优点和缺点,只要弄清楚了他的这些特性,完全可以规避他的缺点,⽽发挥他的优势。⽽且也确实动态分配适量的栈空间,能获得⼀些性能。本⽂只是为了介绍其原理和细节,不在此争论辩证性的论题。
如果要使⽤alloca,可以⾮常简单的使⽤,如下:
05.
不⽤⾃⼰管理释放,当函数结束时,esp会平衡。另外,需要提到的是,根据alloca申请的⼤⼩的变化,编译器可能在后台做⼀些调整,⽐如当申请的内存较⼩时,alloca直接被编译成_chkstk,⽽不会调⽤_alloca_probe_16函数,这也算是⼀个⼩⼩的优化吧。再⽐如,在VS2003下,不管申请多⼤的空间,都会将alloca直接编译成_chkstk。因为vs2003的CRT没有提供_alloca_probe_16函数的实现。
上⾯提到的alloca,在VC的CRT中其实是⼀个宏定义,#define alloca _alloca。另外还有⼀些CRT宏定义,例如_malloca,这个宏定义也等于是⼀层封装,在debug下,_malloca调⽤的是malloc,在release下,当申请的⼤⼩⼩于⼀定值时,调⽤的是alloca,否则调⽤malloc。因此,需要调⽤_freea来释放内存,_freea会根据标记,判断是malloc分配的还是alloca分配的,如果是malloc分配的堆内存则调⽤free,如果是alloca分配的栈内存,则不⽤释放。代码如下:
48.
【延伸】
这⾥延伸⼀个玩⼉的⽤法,就是在写C语⾔程序时,有多个函数参数是指针并且参数个数⼀样,这些函数的指针参数的类型都不⼀样,在C++⾥有template,在C⾥可没有。于是为了实现⼀个类似功能的东西,我们就可以⽤alloca来申请参数的空间,然后调⽤函数。代码如下:
30.
这⾥只是⼀个简单的例⼦,由于alloca申请的空间最后在函数结束时会平衡栈帧便回收了,⽽fun指针的调⽤是没有压⼊参数的,因此fun结束后不存在add esp,func函数是__cdecl调⽤约定,也不会在内部平衡栈,所以整个栈帧是平衡的。
PS:此例⼦纯属玩乐,了解其中原理⽽已,更复杂的情况,并没有测试和深⼊。
不知不觉已经凌晨3点半了,本⽂对于了解原理并熟悉汇编的朋友可能罗嗦了,可以直接略过分析,我该睡觉了!欢迎交流!
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论