重新理解变量在函数内作用域和栈的关系

引子

以前对于函数作用域和栈的关系非常不清晰,只知道栈是系统为一个程序预分配的一块空间,程序的预声明变量都存在系统的栈当中,用malloc*分配的空间则在堆当中。

“作用域”和“栈”是一个抽象的概念,并没有深入的理解他们之间的联系。

一直以来都对代码如何在计算机中运行这一块饶有兴趣,最近刚好有时间来了解一下这里面的原理。

汇编角度理解变量的函数内作用域

函数作用域内的变量,作用域内的变量,超出作用域之后,变量就无法使用了。

抛开逻辑合理性不谈,这个设计的原因是什么呢?其实还是跟机器码执行的方式有关。

所有的可执行程序,最终都是一系列的机器指令,这些指令中,已经包含了数据和程序。

尝试从汇编指令这个级别来理解这个问题的话,就是程序在编译器处理之后,会被编译成一条一条的汇编指令,程序的执行始终只能是顺序的,所有的代码逻辑都会被编译为一个汇编指令序列,他们在系统中的分布是一些顺序的指令序列,其中一些指令(包括执行return, goto和条件测试)负责控制告诉处理器,下一个将要读入的指令地址在哪。

函数的入口,本质上是一个指令序列的起始地址,计算机会从这个地址开始顺序装载后面的指令序列。

一个程序在执行的时候,可能是这样的:

                   +---------------------+    +
                   |                     |    |
                   |                     |    |
                   |                     |    |
                   |                     |    |
                   +---------------------+    |
                   |                     |    |
                   |                     |    |
                   | fucn2 code block    |    |
                   |                     |    |
                   | ......              |    |
                   |                     |    |
                   |                     |    +-----> Stack
                   |                     |    |
                 + +---------------------+    |
                 | |                     |    |
                 | |                     |    |
                 | |  func1 code block   |    |
Stack Frame1  <--+ |                     |    |
with local vars  | |  local ^ars         |    |
                 | |                     |    |
                 | |  function logic     |    |
                 | |                     |    |
                 + +---------------------+    |
                   |                     |    |
                   |                     |    |
                   |                     |    |
                   |                     |    |
                   |                     |    |
                   |    call func2       |    |
                   |    call func1       |    |
                   |                     |    |
                   +---------------------+    +

当程序执行到call func1的时候,系统会为func1所包含的指令序列创建一个stack frame(而func1的指令序列可能是一开始就装载好的,或者动态的被系统根据编译好的二级制文件创建的),在这个stack frame中,存储了本地变量,他们在指令执行的时候,会被装入寄存器进行操作。

func1执行完毕的时候,会返回call_func1的位置(有寄存器记录了函数调用前的stack中的指令地址),然后继续执行后面的指令。

在IA32体系当中,可以认为func1的stack, frame是运行时创建的,所以函数执行完毕,stack_frame会被销毁,所有的局部变量都会失效,这块内存无法再被确保是之前的状态。

在x86-64种稍有不同,可能系统不会为func1创建一个一个stack frame,所有的局部变量都存储在寄存器当中,当函数返回,寄存器状态会被恢复函数调用之前的状态,所以这些局部变量变得不可以再被引用。

当如下情况发生的时候,在x86-64种,栈帧会被创建:

局部变量太多,不能全部存放在寄存器
有些局部变量是数组或者struct
函数用取地址操作符(&)来计算一个局部变量的地址
函数必须将站上的某些参数传递到另一个函数
在修改一个被调用者保存寄存器之前,函数需要保存它的状态,以便返回的时候恢复调用者的寄存器现场。
所以,函数的作用域可以理解为是一个 栈帧/寄存器局部状态 的生命周期。当 这个生命周期结束的时候,局部变量就会失效,这个函数调用也就返回了。

当函数调用的嵌套过多层次(通常出现在递归函数当中),系统会不断的创建栈帧,最后超过一定的限额,操作系统检测到这样的行为,就会抛出“stack overflow”的错误并终止掉程序,防止可能的损害和资源消耗。

题外话1:缓冲区溢出攻击

缓冲区溢出攻击是利用C语言不会做输入数据合法性检查的特性执行的。

当从socket/stdin等渠道读入数据的时候,如果数据的大小,大于给定的buffer(比如一个数据 char [20]),如果没有对输入数据做合法性检测,那么超出的部分,会被继续写入到这个char [20] 所对应的内存区域。

计算机在处理后续的数据的时候,是不知道数组后续的数据到底是“数据”还是“程序”的,如果后续的数据,是一段合法的机器指令序列,那么这段指令序列就会被CPU读取并继续执行。

只要输入的数据符合目标机器体系结构的指令标准,那么这段代码就可以被成功运行。

输入的数据,可能会覆盖掉这个栈帧执行的后续数据段,比如讲ret指令覆盖掉,替换成自己的攻击函数执行的地址。

如果目标代码要伪造正常返回或者调用自己想调用的代码块的话,就需要知道目标机器上的栈地址的规则(在相同操作系统和体系结构的不同机器上,这个地址很可能是一致的)。

所以就衍生了几种解决方案:

栈随机化。每次启动程序栈的地址是不固定的,所以攻击者很难写出具体的机器代码来进行调用,最常发生的结果是导致目标系统crash,而不是执行了攻击代码(exploit code),现代操作系统几乎都有这种机制。
栈破坏检测。在保存的状态和buffer之间,放置了一个特殊值(成为cannary),这个值会被存储到某个特殊的寄存器,如果数据有越界写操作,那么cannary就不会维持原值,对寄存器保存的cannary和原stack frame内cannary地址的值做xorl指令,讲无法得到0,证明帧栈已经被修改,系统会退出执行,防止执行攻击代码。
限制可执行代码区域。讲目标区域标记为“RW”或者“RO”,而不会把“数据”当做“程序”执行。

题外话2:读书

这段时间一直在读《Computer Systems: A Programmer’s Perspective 》,全凭着兴趣驱使读下去。

从一开始的信息的表示和处理,到程序的机器级表示,啃得心很累,事必躬亲,遇到一个公式或者一个计算,不得不自己也跟着做一遍,导致整本书看起来进展非常缓慢,目的性也非常弱。

前两天和一个认识已久的朋友吃了个饭,晚上请教了一下学习ML方面的知识从何入手。

他说可以先walk through就好,遇到问题不必深究,只当看一下目录,把这部分知识索引起来,在具体解决某个问题的时候,再行深究,到时就要把每个步骤的公式用法,到证明都看清楚,一遍一遍直到可以自己做出来为止。

于是开始跳着看,看看自己最想知道,最感兴趣的方面,感觉茅塞顿开,需要基础知识的时候,再行查阅相关资料即可弄清。

之后思考了一下,觉得看书也是这样,了解自己最想知道的部分,先有个概念,然后对于每个必要的细节,再去深究是一种不错的学习方式,这样效率和学习深度都能兼顾到,而且事半功倍(人不可能记住所有的知识,但做一个索引是可以做到的),先知其然,而后知其所以然。

Last modification:August 14th, 2017 at 10:57 pm
If you think my article is useful to you, please feel free to appreciate

Leave a Comment