题目:
•main () •{ • int a[10]; • int i; • for (i = 0; i <= 10; i++ ){ • a[i] = 1; • } •}该代码在什么情况下有什么问题(2种情况)?
答案:
在栈空间变量按地址递增方式分配的情况下,陷入死循环。
在栈空间变量按地址递减方式分配的情况下,堆栈溢出预备知识:
什么是堆栈?
~~~~~~~~~~~~~
堆栈是一个在计算机科学中经常使用的抽象数据类型. 堆栈中的物体具有一个特性:最后一个放入堆栈中的物体总是被最先拿出来, 这个特性通常称为后进先处(LIFO)队列.
堆栈中定义了一些操作. 两个最重要的是PUSH和POP. PUSH操作在堆栈的顶部加入一个元素. POP操作相反, 在堆栈顶部移去一个元素, 并将堆栈的大小减一.
为什么使用堆栈?
~~~~~~~~~~~~~~~~
现代计算机被设计成能够理解人们头脑中的高级语言. 在使用高级语言构造程序时最重要的技术是过程(procedure)和函数(function). 从这一点来看, 一个过程调用可
以象跳转(jump)命令那样改变程序的控制流程, 但是与跳转不同的是, 当工作完成时,函数把控制权返回给调用之后的语句或指令. 这种高级抽象实现起来要靠堆栈的帮助.
堆栈也用于给函数中使用的局部变量动态分配空间, 同样给函数传递参数和函数返回值也要用到堆栈.
堆栈区域
~~~~~~~~~~
堆栈是一块保存数据的连续内存. 一个名为堆栈指针(SP)的寄存器指向堆栈的顶部.堆栈的底部在一个固定的地址. 堆栈的大小在运行时由内核动态地调整. CPU实现指令
PUSH和POP, 向堆栈中添加元素和从中移去元素.
堆栈由逻辑堆栈帧组成. 当调用函数时逻辑堆栈帧被压入栈中, 当函数返回时逻辑堆栈帧被从栈中弹出. 堆栈帧包括函数的参数, 函数地局部变量, 以及恢复前一个堆栈
帧所需要的数据, 其中包括在函数调用时指令指针(IP)的值.
堆栈既可以向下增长(向内存低地址)也可以向上增长, 这依赖于具体的实现. 在我们的例子中, 堆栈是向下增长的. 这是很多计算机的实现方式, 包括Intel, Motorola,
SPARC和MIPS处理器. 堆栈指针(SP)也是依赖于具体实现的. 它可以指向堆栈的最后地址,或者指向堆栈之后的下一个空闲可用地址. 在我们的讨论当中, SP指向堆栈的最后地址.
除了堆栈指针(SP指向堆栈顶部的的低地址)之外, 为了使用方便还有指向帧内固定地址的指针叫做帧指针(FP). 有些文章把它叫做局部基指针(LB-local base pointer).
从理论上来说, 局部变量可以用SP加偏移量来引用. 然而, 当有字被压栈和出栈后, 这些偏移量就变了. 尽管在某些情况下编译器能够跟踪栈中的字操作, 由此可以修正偏移
量, 但是在某些情况下不能. 而且在所有情况下, 要引入可观的管理开销. 而且在有些机器上, 比如Intel处理器, 由SP加偏移量访问一个变量需要多条指令才能实现.
因此, 许多编译器使用第二个寄存器, FP, 对于局部变量和函数参数都可以引用, 因为它们到FP的距离不会受到PUSH和POP操作的影响. 在Intel CPU中, BP(EBP)用于这
个目的. 在Motorola CPU中, 除了A7(堆栈指针SP)之外的任何地址寄存器都可以做FP.考虑到我们堆栈的增长方向, 从FP的位置开始计算, 函数参数的偏移量是正值, 而局部
变量的偏移量是负值.
当一个例程被调用时所必须做的第一件事是保存前一个FP(这样当例程退出时就可以恢复). 然后它把SP复制到FP, 创建新的FP, 把SP向前移动为局部变量保留空间. 这称为
例程的序幕(prolog)工作. 当例程退出时, 堆栈必须被清除干净, 这称为例程的收尾(epilog)工作. Intel的ENTER和LEAVE指令, Motorola的LINK和UNLINK指令, 都可以用于
有效地序幕和收尾工作.
下面我们用一个简单的例子来展示堆栈的模样:
example1.c:
------------------------------------------------------------------------------
void function(int a, int b, int c) {
char buffer1[5];
char buffer2[10];
}
void main() {
function(1,2,3);
}
------------------------------------------------------------------------------
为了理解程序在调用function()时都做了哪些事情, 我们使用gcc的-S选项编译, 以产
生汇编代码输出:
$ gcc -S -o example1.s example1.c
通过查看汇编语言输出, 我们看到对function()的调用被翻译成:
pushl $3
pushl $2
pushl $1
call function
以从后往前的顺序将function的三个参数压入栈中, 然后调用function(). 指令call会把指令指针(IP)也压入栈中. 我们把这被保存的IP称为返回地址(RET). 在函数中所做的第一件事情是例程的序幕工作:
pushl %ebp
movl %esp,%ebp
subl $20,%esp
将帧指针EBP压入栈中. 然后把当前的SP复制到EBP, 使其成为新的帧指针. 我们把这个被保存的FP叫做SFP. 接下来将SP的值减小, 为局部变量保留空间.
我们必须牢记:内存只能以字为单位寻址. 在这里一个字是4个字节, 32位. 因此5字节的缓冲区会占用8个字节(2个字)的内存空间, 而10个字节的缓冲区会占用12个字节(3个字)的内存空间. 这就是为什么SP要减掉20的原因. 这样我们就可以想象function()被调用时堆栈的模样(每个空格代表一个字节):
内存低地址 内存高地址
buffer2 buffer1 sfp ret a b c
<------ [ ][ ][ ][ ][ ][ ][ ]
堆栈顶部 堆栈底部
缓冲区溢出
~~~~~~~~~~~~
缓冲区溢出是向一个缓冲区填充超过它处理能力的数据所造成的结果. 如何利用这个经常出现的编程错误来执行任意代码呢? 让我们来看看另一个例子:
example2.c
------------------------------------------------------------------------------
void function(char *str) {
char buffer[16];
strcpy(buffer,str);
}
void main() {
char large_string[256];
int i;
for( i = 0; i < 255; i++)
large_string[i] = 'A';
function(large_string);
}
------------------------------------------------------------------------------
这个程序的函数含有一个典型的内存缓冲区编码错误. 该函数没有进行边界检查就复制提供的字符串, 错误地使用了strcpy()而没有使用strncpy(). 如果你运行这个程序就会产生段错误. 让我们看看在调用函数时堆栈的模样:
内存低地址 内存高地址
buffer sfp ret *str
<------ [ ][ ][ ][ ]
堆栈顶部 堆栈底部
这里发生了什么事? 为什么我们得到一个段错误? 答案很简单: strcpy()将*str的内容(larger_string[])复制到buffer[]里, 直到在字符串中碰到一个空字符. 显然, buffer[]比*str小很多. buffer[]只有16个字节长, 而我们却试图向里面填入256个字节的内容. 这意味着在buffer之后, 堆栈中250个字节全被覆盖. 包括SFP, RET, 甚至*str!我们已经把large_string全都填成了A. A的十六进制值为0x41. 这意味着现在的返回地址是0x41414141. 这已经在进程的地址空间之外了. 当函数返回时, 程序试图读取返回地址的下一个指令, 此时我们就得到一个段错误.
现在回到刚开始的题目:
•main ()•{• int a[10];• int i;• for (i = 0; i <= 10; i++ ){• a[i] = 1;• }•}如果栈按地址递减方式分配,也就是说栈是向下增长,则栈空间如下:内存低地址 内存高地址i a[0]..a[9] sfp<------ [ ][ ][ ]堆栈顶部 堆栈底部当执行for (i = 0; i <= 10; i++ ){ a[i] = 1;}时,由于a[0]是在内存低地址,a[9]为内存高地址,也就是a[0]靠近i变量的空间,a[9]靠近sfp的空间,当赋值a[10]时,将sfp的空间覆盖,造成栈溢出。如果栈按地址递增方式分配,也就是说栈是向上增长,则栈空间如下:内存低地址 内存高地址sfp a[0]..a[9] i[ ][ ] [ ]---------->堆栈底部 堆栈顶部当执行for (i = 0; i <= 10; i++ ){ a[i] = 1;}时,由于a[0]是在内存低地址,a[9]为内存高地址,也就是a[0]靠近sfp的空间,a[9]靠近i的内存空间,当赋值a[10]时,将i的空间覆盖,使i的值又赋值为1,造成死循环。