C|函数调用的栈帧机制与数组越界、缓冲区溢出

0 前置知识0.1 程序加载和数据存储
程序运行前要将代码加载到内存的代码区 , 包括全局变量和静态变量也要同时加载 。
堆区内存可以在程序运行时动态申请 。
栈区是由程序重复利用的存储区域 , 通过两个寄存器ebp和esp存储栈区的相对地址来控制栈区空间的重复使用 。函数调用时 , 开辟一个函数需要的栈区空间 , 称为一个栈帧 。函数返回时 , 偏移ebp和esp的值 , 让栈空间可以重新使用此前函数调用占用的空间 。
以下是windows平台一个程序运行时的内存分配机制:
0.2 函数调用
函数调用看似简单 , 其实是一个挺复杂的过程 。当涉及到逐级调用时 , 代码需要能够回到最初的调用点 。
例如一个人旅行 , 当经过若干十字路口时 , 如何正确回到最初开始位置?一个可行的方案是 , 每经过一个路口 , 用一张扑克牌记下当时路口的状态 , 每经过一个路口即使用一张牌 , 并放在已使用的扑克牌上面 , 当返回时 , 依次从上面拿牌读取信息即可回溯到原来位置 。这种扑克牌的放与取就是所谓的栈的“后进先出”机制 。
函数调用也要使用类似的机制 , 由编译器完成 。每一级函数调用对应一个栈帧(如同扑克牌) , 记录参数值 , 返回地址 , 一些寄存器状态 , 局部变量值 。主调函数与被调函数如何分工完成这些任务 , 不同的调用约定有不同的分工 。

0.3 数组名在一定的上下文中转换为指向数组首元素的指针
数组也是如此 , 看似简单 , 实则复杂 。
数组名在一定上下文中会转换为一个指向数组首元素的指针 , 为什么会这样 , 当然有其合理性 。
首先了解一下指针的算术运算 。(栈内存空间地址是逆增长的)
void foo(int a){ int f = 45; int *ptr = &f; *(ptr+1) = 12; // 编译通过 , 运行时错误 , 试图修改ebp *(ptr+2) = 34; // 编译通过 , 运行时错误 , 试图修改函数返回地址 *(ptr+3) = 56; // 试图修改保存函数实参值的栈内存单元 int *p = (int*)malloc(sizeof(int)*5); *(p+3) = 23; // p[3] = 23;}p+n , 是一个相对于ptr的偏移n个int地址空间的地址值 。
编译器会计算p+sizeof(int) , 至于偏移的是否是有效合法的地址 , C编译器不管 。
ptr+n也是如此 。
如有数组int arr[64] = {0};

数组名是一个基地址 , 其索引表示地址的偏移量 。arr[i]写法是指针算术运算*(arr+i)的语法糖 。编译器要考虑指针的算术运算有实用的意义 , C编译器的规定是arr+i , 其地址不是简单偏移 i 个字节 , 这样没有意义 , 而是sizeof(指向的类型)的长度的字节数 , 这样才有意义 , 其计算由编译器完成 , 当指针类型是数组时 , 偏移 i 个数组的长度没有任何意义 , 偏移 i 个数组元素的长度才有实际意义 , 这也是C编译器的规定 。如:
int a[3][4][5]; // a的类型是int[3][4][5] , 元素的类型是int[4][5] , int (*p)[4][5] = a; a + 2; // 偏移的地址是a + 2* sizeof(int)*4*5 int b[4][5]; // b的类型是int[4][5] , 元素的类型是int[5] , int (*p)[5] = b; b + 2; // 偏移的地址是b + 2* sizeof(int)*5 int c[5];// c的类型是int[5] , 元素的类型是int , int *p = c; c + 2; // 偏移的地址是b + 2* sizeof(int)C编译器只负责偏移地址的计算 , 对于数组是否越界 , 地址是否合法并不在编译时检查 。
arr[i]的 i 可以是任意signed int , 越界时就会访问到相邻栈内存 。
0.4 一些简单的汇编代码
1、mov——传送指令
定义:把一个字节、字或双字的操作数从源位置传送到目的位置 , 可以实现立即数到通用寄存器或主存的传送 , 通用寄存器与通用寄存器、主存或段寄存器之间的传送 , 主存与段寄存器之间的传送 。
举例:mv ebp, esp
解释:相当于C语言中赋值语句= , ebp=esp
2、push——进栈指令
定义:进栈指令push先将ESP减小作为当前栈顶 , 然后可以将立即数、通用寄存器和段寄存器或存储器操作数传送到当前栈顶 。
格式:push src

举例:push ebp
解释:相当于C语言中esp+=4, *esp=ebp

推荐阅读