理论教育 编译器设计之路:深入理解栈式存储分配

编译器设计之路:深入理解栈式存储分配

时间:2023-11-04 理论教育 版权反馈
【摘要】:在编译器设计中,栈式存储分配是一种应用极广的存储分配策略。本小节将结合几个比较特殊的语言机制讨论栈式存储分配的应用,旨在使读者对这个极其重要的机制有更深刻的理解。对于栈式存储分配机制而言,试图在一个函数体内改变AR的长度或者布局是不可能实现的。更可怕的是,由于存储块释放后仍可能被重新分配,因此,悬空引用的错误可能是致命的且不可预知的。

编译器设计之路:深入理解栈式存储分配

在编译器设计中,栈式存储分配是一种应用极广的存储分配策略。本小节将结合几个比较特殊的语言机制讨论栈式存储分配的应用,旨在使读者对这个极其重要的机制有更深刻的理解。当然,这里仅从原理的角度加以剖析,不会过多关注其中的实现细节。

1.可变参数

熟悉C语言的读者对于可变参数应该并不陌生。虽然在实际编程中,刻意应用可变参数的场合并不算太多,但是,接触可变参数的机会却并不少。最常见的例子可能就是printf、scanf了,其声明形式如下:

【声明8-2】

978-7-111-32164-4-Chapter08-17.jpg

从传参的角度来说,可变参数的传递与普通参数的传递并不存在明显的差异,都是将实参逆序压栈实现的。而可变参数的关键问题就在于函数本身是无法得知实参存储区大小的,这个信息只能由调用点计算确定。因为各调用点传递的参数个数可能是不同的,故所需的实参存储区的空间数量也是不同的。在这种情况下,实参存储区的清理工作就只能由调用点完成了,如图8-10所示。

978-7-111-32164-4-Chapter08-18.jpg

图8-10 printf调用点的反汇编程序

在处理可变参数时,C编译器只会遵守cdecl调用约定,即使用户显式地将调用约定设置为stdcall也是无效的。原因很简单,_stdcall规定清栈工作是由被调函数完成的,因此,这是不满足可变参数的基本条件的。

值得注意的是,在实现可变参数时,由调用点完成清栈工作是最常见且便捷的处理,但并不是唯一的方案。实际上,试图由函数本身完成清栈工作同样是可行的,其基本思想就是调用点将计算得到的实参存储区的实际大小也以参数的形式传递给被调函数,而被调函数则根据该参数的值在返回前完成清栈工作。当然,这个过程是需要双方协调的,目前,并没有标准的调用协议支持。

2.变长数据的分配

在一些程序设计语言中,变长数据的应用是非常广泛的,例如,字符串、变长数组等。当然,相对于定长数据而言,变长数据的灵活程度是用户更愿意接受的主要原因。不过,变长数据对编译器的设计提出了挑战,原因不难理解。对于栈式存储分配机制而言,试图在一个函数体内改变AR的长度或者布局是不可能实现的。因为一个函数的AR完全是根据局部变量及参数的实际情况计算得到的,这个计算过程是由编译器完成的。因此,也就不能接受在运行过程中动态改变局部变量的实际长度。

实际上,解决变长数据的问题并不复杂。在讲述逻辑地址空间时,笔者引入了堆的概念。在i386中,堆的管理通常是由操作系统监控的,而操作系统仅仅以系统调用的形式暴露一些管理接口供用户使用。在早期,C、Pascal等编译器并不会将用户数据分配到堆空间中,而是直接为用户提供了malloc、free之类的堆管理接口,以便用户动态申请与释放存储空间。不过,在近二十多年中,变长数据类型已经逐渐成为了程序设计语言不可缺少的组成部分。因此,编译器设计者也有必要关注这方面的话题。

图8-11描述了s0、sl两个字符串变量的内部存储形式。在系统栈中,编译器并不会根据字符串的实际长度分配存储空间,而只是为其分配一个指针空间,该空间中存储的就是一个位于堆中的数据块的地址,而该数据块中存储的才是字符串的实际值。访问字符串值时,通过间接寻址访问即可。关于堆的话题,笔者作三点说明:

978-7-111-32164-4-Chapter08-19.jpg(www.daowen.com)

图8-11 变长数据的存储示意图

(1)数据块与堆。事实上,一次系统调用申请得到一块存储区即为一个数据块,可以视作当前进程从操作系统得到的一块存储资源。除了显式释放或进程退出之外,数据块是不会自动被操作系统回收的。至于数据块的分配策略完全是由操作系统决定的,编译器、用户进程都是无法掌控的。在不引起冲突的情况下,通常,操作系统是允许用户进程动态扩展数据块容量的。当然,这种操作还是比较危险的,对环境的耦合程度太高,因此不推荐使用。

(2)越界访问可能会引起异常中断。与系统栈的访问不同,越界访问数据块可能是致命的。主要的监管工作是由操作系统完成的。当然,不同的操作系统对此的理解并不一致,管理的力度也不尽相同,但仍然不建议读者尝试。

(3)堆是线性结构。图8-11似乎并不能表现堆的实际形态,这是因为笔者更想突出堆的黑箱特性。必须澄清的是堆的内部结构仍然是线性的,只不过其分配策略对于用户是完全透明的。另外,值得注意的是,这里的堆与数据结构中的堆是完全不同的概念,仅仅名字相同而已。

3.悬空引用

在C、Pascal等支持指针的程序设计语言中,悬空引用可能是非常危险的。虽然悬空引用可以被视作一种语义错误,但几乎没有一个编译器可以在编译阶段分析出此类错误。更可怕的是,由于存储块释放后仍可能被重新分配,因此,悬空引用的错误可能是致命的且不可预知的。例如,如声明8-3所示:

【声明8-3】

978-7-111-32164-4-Chapter08-20.jpg

978-7-111-32164-4-Chapter08-21.jpg

aa函数返回局部变量i的地址。不过,从aa函数返回到main函数时,由于aa函数的AR被释放,因此,p指针的引用就是悬空引用。

但悬空引用也不是一无是处的,在处理一些复杂结构的返回值时,经常会借助指针,避免冗余的数据块复制。当然,用户必须保证这种“悬空引用”是绝对安全的。如声明8-4所示:

【声明8-4】

978-7-111-32164-4-Chapter08-22.jpg

在编译器设计中,使用这种引用机制处理复杂结构的返回值是非常有效且方便的。

免责声明:以上内容源自网络,版权归原作者所有,如有侵犯您的原创版权请告知,我们将尽快删除相关内容。

我要反馈