函数栈帧与可变参数列表
一. 深刻理解函数调用时栈帧的创建和销毁
(1) 样例代码
#include <stdio.h>
int MyAdd(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int x = 0xA;
int y = 0xB;
int z = 0;
z = MyAdd(x, y);
printf("z = %x\n", z);
return 0;
}
(2) 认识寄存器
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
(3) 认识相关汇编指令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
(4) 思路图
- 注: vs2022有栈随机化的处理 所以每次看到的相关数据可能不太一直 不过我们重点关注变化原理 弱化数据
1. 起步 main函数也是要被调用的

2. MyAdd函数调用前

3. 开始调用
- call:函数调用,1. 压入返回地址 2. 转入目标函数
- 为什么要压入返回地址(call命令的下一个命令的地址): 因为函数调用完后 需要返回
- 执行call命令eip和esp会发生什么变化呢?

- 验证结果

- jump:通过修改eip,转入目标函数,进行调用 由上图知 jump将修改eip值为00BF1790

4. 调用MyAdd函数
- push是将ebp压入栈中 栈顶也会跟随移动

- 执行push前

- 执行push后

- 执行mov 是用esp的值覆盖ebp 所以执行完mov后 ebp就指向新的栈顶了 在cpu内执行 与内存无关

- 执行mov后 可以看到ebp和esp是同一个值

- sub 用esp的地址值 - 0CCh esp所减的值大小由编译器决定 执行后 esp将不再指向栈顶

- 在MyAdd函数栈帧内定义c变量

- 执行c = a + b命令

- 执行mov 把ebp + 8访问到的值 也就是临时拷贝的0xA 放入eax中
- 执行add 访问ebp + 0C的值(0xB) 与eax中存储的数据相加 得到0x15
- 执行mov 将eax中的值(0x15) 写入到ebp - 8(MyAdd中c变量的位置)
至此调用生成栈帧结束 下图为函数栈帧图

5. 返回主函数

-
执行mov 把ebp的内容放到esp 就是让esp和ebp都指向MyAdd的栈底(释放MyAdd函数栈帧)
-
pop 先把栈顶的数据(main函数栈底地址)存放到ebp中 然后esp向下移动 进行弹栈
-
ret 相当于又进行了一次pop 将调用MyAdd是call后面的命令的地址(00291918)放到eip中 esp向下移动 弹栈

- 释放临时变量的栈帧

- add 将esp + 8 然后赋到esp中
- 将eax中保存的0x15移动到main函数中的z变量中保存起来
6. 栈帧恢复

二. 函数可变参数列表
1. 使用
// demo1 求两个数中的最大值
#include<stdio.h>
#pragma warning(disable:4996)
int GetMax(int a, int b)
{
return a > b ? a : b;
}
int main()
{
int x = 0;
int y = 0;
scanf("%d %d", &x, &y);
int max = GetMax(x, y);
printf("max = %d\n", max);
return 0;
}
// 求多个数据中的最大值 不能使用数组
// 由于函数参数个数不确定 那么函数编写时 参数个数也无法确定 也就是无法编写函数
// 这个时候就可以使用可变参数列表
#include<stdio.h>
#include<stdarg.h>
#pragma warning(disable:4996)
int GetMax(int num, ...)
{
va_list arg; // 定义可以访问可变参数部分的变量 char*类型
va_start(arg, num); // 使arg指向可变参数部分
int max = va_arg(arg, int); // 根据类型 获取可变参数列表的第一个数据
for(int i = 0; i < num - 1; i++) // 依次获取其他数据
{
int cur = va_arg(arg, int);
if (cur > max)
{
max = cur;
}
}
va_end(arg); // arg使用完毕 使arg指针指向NULL
return max;
}
int main()
{
int max = GetMax(5, 43, 34, 56, 14, 65);
printf("max = %d\n", max);
return 0;
}
//如果将参数改成char类型 求char类型变量中的最大值 代码会有问题吗?
int GetMax(int num, ...)
{
va_list arg;
va_start(arg, num);
int max = va_arg(arg, int);
for(int i = 0; i < num - 1; i++)
{
int cur = va_arg(arg, int);
if (cur > max)
{
max = cur;
}
}
return max;
}
int main()
{
char a = 'a'; // ascii值 97
char b = 'b'; // 98
char c = 'c'; // 99
char d = 'd'; // 100
char e = 'e'; // 101
int max = GetMax(5, a, b, c, d, e);
printf("max = %d\n", max);
return 0;
}
//运行发现并未受影响 但是我们解析的时候 是按照 va_arg(arg, int)解析的 这是为什么?
- 查看汇编代码我们可以发现 传入的参数如果使char short float 编译器会在编译时发生类似整形提升的表现 movsx(带符号扩展传送指令)
- 函数内部使用时 通过类型提取数据 更多的是通过int或double来进行

2. 注意事项
- 可变参数必须从头到尾逐个访问 如果你在访问几个可变参数后想终止 这是可以的 但如果你一开始就像访问中间的参数 是不行的
- 参数列表必须要有一个命名参数 否则无法使用va_start
- va_list va_start va_arg va_end这四个宏无法判断实际存在的参数的数量和类型
- 如果在va_arg中指定了错误的类型 后果是无法预测的
3. 详细讲解四个宏的含义及原理
原理
- 可变参数列表对应的函数 最终调用也是函数调用 也要形成栈帧
- 在栈帧形成之前 临时变量会先入栈 并且根据上面对栈帧的讲解 可知临时变量之间的位置关系是固定的
- 通过观察汇编代码 可以发现短整型在可变参数部分 会默认进行整形提升 那么函数内部子啊提取该数据时就要考虑提升之后的值
(1) va_list
// va_list其实是char*类型的
va_list arg;
typedef char* va_list
(2) va_start
//使用_ADDRESSOF取到固定参数v的地址 然后根据_INTSIZEOF作为偏移量 进行取值 然后赋给va_list类型的ap变量 使arg指向可变参数部分
va_start(arg, num);
#define __crt_va_start_a(ap, v) ((void)(ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v)))
#define _ADDRESSOF(v) (&(v))
//进行四字节对齐(向上取整)
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
(3) va_arg
//作用 1.将"当前元素"提取出来 2.让ap指向下一个待访问元素
//这里暂时将_INTSIZEOF(t))理解为int的大小 也就是4字节
int max = va_arg(arg, int);
#define __crt_va_arg(ap, t) (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))

(4) va_end
//将ap指针置为NULL
va_end(arg);
#define __crt_va_end(ap) ((void)(ap = (va_list)0))
4. 难点讲解 #define _INTSIZEOF(n)
- 简单理解 位运算实例分析

- 数学理解 对齐原理与推导
前提:为了后面方便表述,我们假设sizeof(n)的值是n(char 1,short 2, int 4)
我们在32位环境下验证 sizeof(int)大小是4,其他情况我们不考虑
_INTSIZEOF(n)其实就是计算一个最小值x x满足 x >= n && x % 4 == 0 其实就是4字节对齐的方式
比如n是: 1 2 3 4 对n向sizeof(int)的最小整数倍取整 就是4
比如n是: 5 6 7 8 对n向sizeof(int)的最小整数倍取整 就是8
为什么要有4字节对齐?
短整型在可变参数部分 压栈时会发生整形提升 如果不是4字节对齐 会发生少取或者多取字节的情况
怎么做到4字节对齐?
第一步理解: 4的倍数 如何提取4 让其转换为4的倍数
既然是按照4的最小整数倍取整 那么x = 4 * m m具体是几 对于7来讲 m就是2 对齐结果是8
m具体是多少 取决于n 如果n是1 2 3 4 m就是1 如果n是5 6 7 8 m就是2
(1)如果n能整除4 m = n / 4 例如 n = 4 m = 2
(2)如果n不能整除4 m = (n / 4) + 1 例如 n = 6 m = (6 / 4) + 1 = 2
上面两种情况 如何合并成一种写法呢?
通常这样写:(n + sizeof(int) - 1) / sizeof(int) -> (n + 4 -1) / 4
如果n能整除4 那么m = (n + 3) / 4 因为n能整除4 所以+3相当于没加 等价于 n / 4
如果n不能整除4 那么n = 最大能整除4部分 + r(1 <= r < 4) m = (n +3) / 4 -> (最大能整除4部分 + r + 4) / 4
其中4<=r+3<7 -> 最大能整除4部分 / 4 + (r + 3) / 4 等价于 (n / 4) + 1
第二步理解: 最小4字节对齐数
第一步算出来m 是指存放n字节大小的数据 需要m个4字节内存块才能完整存放
而第二步x就是算 m个4字节内存块具体是多少字节空间
所以 x = m * 4 = ((n * sizeof(int) - 1) / 4) * 4
第三步理解: 理解源代码中的宏
((n + 4 - 1) / 4) * 4 设w = n + 4 -1 表达式可以写成 (w / 4) * 4 4是2^2
w / 4 相当于右移两位 无符号数 高位补0 例如 7(0111) >> 2 = 1(0001)
*4相当于 左移两位 低位补0 例如 1(0001) << 2 = 4(0100)
所以左移和右移最终结果就是将最后两个比特位置为0
那么改写为 w & ~3即可
所以简洁版: (n + 4 -1) & ~(4 - 1)
原码版: ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1)) 无需先/ 再*
989

被折叠的 条评论
为什么被折叠?



