重新理解变量在函数内作用域和栈的关系
引子
以前对于函数作用域和栈的关系非常不清晰,只知道栈是系统为一个程序预分配的一块空间,程序的预声明变量都存在系统的栈当中,用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就好,遇到问题不必深究,只当看一下目录,把这部分知识索引起来,在具体解决某个问题的时候,再行深究,到时就要把每个步骤的公式用法,到证明都看清楚,一遍一遍直到可以自己做出来为止。
于是开始跳着看,看看自己最想知道,最感兴趣的方面,感觉茅塞顿开,需要基础知识的时候,再行查阅相关资料即可弄清。
之后思考了一下,觉得看书也是这样,了解自己最想知道的部分,先有个概念,然后对于每个必要的细节,再去深究是一种不错的学习方式,这样效率和学习深度都能兼顾到,而且事半功倍(人不可能记住所有的知识,但做一个索引是可以做到的),先知其然,而后知其所以然。
博主真是太厉害了!!!
怎么收藏这篇文章?