1.局部变量
第一次提出局部变量,是在1960年的 ALGOL 60 语言,现在广泛使用的C语言就继承与它(语言图谱)。局部变量让一段代码相对独立,让给纸带打孔的码农从全局变量中解脱。说到与局部变量,一定会想到子程序。从现代语言看,子程序和局部变量就如前门大少与杀毒哥,如胶似漆♂形影不离。有趣的是这两个概念并不是同时出现,子程序早于局部变量,是1958年由
FORTRAN II
语言提出。当时计算机硬件还没有栈的概念,子程序的返回地址不是通过压栈,而是通过一个固定地址存储。历史上除了现在熟悉的寄存器指向内存方式的栈,还出现过寄存器栈,就是由几个固定的寄存器存储压栈数据,只能实现固定大小的栈,1971年
Intel 第一款 CPU Intel 4004 就是这样的设计,有三个栈寄存器。顺带提一下,第一款 x86 CPU Intel 8086 在1978年发布。
虽然CPU通过SP寄存器、PUSH、POP等指令提供了对局部变量的支持,但是在底层并没有局部变量的概念,局部变量特殊在,它的访问是通过SP寄存器指向的一块内存。早期CPU没有SP寄存器,可以专门拿出一个通用寄存器,用软件指令模拟。当然现在也可以,不过现在栈使用非常频繁,使用通用寄存器加指令模拟的方式会降低速度,所以CPU设计专用寄存器和专门的指令,提高处理速度。
一个重要的概念,堆栈平衡。其实没堆的事情,准确说应该称作“栈平衡”,不知为何被称作堆栈平衡,也许是怕听得人只记得一个“贱”字(笑)?。前面我们提到了栈是CPU提供的一个寄存器指向的内存地址,同一个线程中,代码使用同一个栈。这就涉及一个问题,如果前面有代码PUSH了栈但是没POP,后面的代码怎么知道现在SP指向什么数据?堆栈平衡就是说某段使用了栈的代码,在调用前和调用后,SP指针应该指向相同的位置,对后面的代码来说,栈就像没有发生过变化。
这又引出了调用约定的概念,是调用者处理堆栈平衡还是子程序自己处理堆栈平衡。注意,调用子程序后,栈实际承载了三部分数据,分别是:函数参数,调用返回地址,局部变量。调用约定处理的是函数参数使用的栈,子程序内部的局部变量还是由自己处理。常见的调用约定有
stdcall,cdecl,fastcall,naked,thiscall,调用约定还指定了多个参数的压栈顺序,有兴趣可以搜下,不展开了。
让我们看看局部变量被扒光是什么样子,对大多数子程序,局部变量的框架会是下面这个样子:
push ebp ;保存ebp
mov ebp, esp ;获取当前栈顶
sub esp, 0CC ;申请栈空间
... ;
...
...实现功能
...
...
mov esp, ebp ;注意这句
pop ebp ;
retn ;
代码短小精悍,解释一下在做什么,esp是栈专用寄存器,push或pop一下,值就变了。如果基于它来访问局部变量栈,需要考虑push和pop指令对esp的干扰,再计算局部变量偏移,非常复杂。通过上面的代码可以发现,刚进入子程序时保存了ebp并且将当时的esp赋值给ebp。从这之后,ebp不再发生变化,局部变量和传递的参数都通过“ebp+偏移”的方式,以ebp为基地址进行访问。实际上ebp也是一个专用寄存器,全名是EBP
Extended (Stack) Base Pointer
栈基指针寄存器。函数退出时,只需要把ebp赋值给esp,再还原ebp,栈就像是什么都没发生过,局部变量占用的栈就彻底被释放了。
局部变量更像是编译器维护的一个内存池,通过作用域控制内存的申请和释放。局部变量除了解决了全局变量维护复杂,代码无法复用外,还解决了另外一个困扰程序员的老大难的问题,给变量起名(笑)。
有人好奇过栈的大小吗?栈大小由编译器决定,可以通过参数改变,VC默认大小是1M。编译器实际是通过PE文件头的栈大小参数来决定PE文件载入内存时系统给栈分配多大内存。如果局部变量过大,超过1M,会报栈溢出异常,一般发生在多个函数嵌套累计申请了过多局部变量或者递归过深。
前几天前门大少调试程序,修改eip程序没崩,有点反直觉。因为根据堆栈平衡,如果有对栈的操作,并且修改了eip,堆栈是被破坏的状态,在函数返回取返回地址时一定会崩溃,但是没崩,为什么呢?就是因为上面代码中的那行“注意这句”,函数的堆栈平衡是通过ebp直接还原状态,没有通过push和pop配对的方式,所以修改eip可能会造成逻辑错误,但是不会让程序崩溃。如果修改的是ebp,那就回天乏术了。
2.全局变量
(待续)