钜形不锈钢水箱 发表于 2024-5-21 22:38:43

数组降维与多级指针、函数栈帧、地址空间、可变参数原理

目录

[*]数组和指针

[*]

[*]多维数组的物理结构
[*]证明数组a和&a不同
[*]数组与指针的差别之一
[*]什么时候数组名表示整个数组?
[*]数组训练
[*]明确指针与数组的题
[*]全部的数组,都可以看成一维数组.全部的数组传参,最终都会降维成一维数组


[*]函数

[*]函数的地址
[*]函数的规范

[*]内存管理

[*]malloc返回给用户的只有申请内存的起始地址,那free是怎样准确开释动态内存申请的空间?
[*]内存级cookie
[*]C语言程序地址空间-内存验证

[*]函数栈帧

[*]熟悉相关寄存器
[*]熟悉相关汇编下令
[*]查看函数调用堆栈
[*]函数形成与开释:

[*]可变参数列表

[*]可变参数的原理
[*]C语言提供的可变参数方案

[*]stdarg.h

[*]va_list
[*]va_start
[*]va_arg
[*]va_end

[*]简单的可变参数程序
[*]可变参数的反汇编与内存分析
[*]可变参数整型提拔的反汇编分析
[*]留意事项


[*]留言

数组和指针

多维数组的物理结构

int a = { 0,1,2,3,4,5,6,7,8,9,10,11 };
for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 4; j++) {
                printf("a[%d][%d] : %p\n", i, j, &a);
        }
}在C语言中,多维数组都是一维抽象而成的,实际就是一连的多个一维数组.记得这一点,将指针和数组都化作一维来看待,解决这类问题就会简单很多.
证明数组a和&a不同

int a = {0};
//a是数组首元素地址,&a是数组的地址

int (*p) = a;//警告:间接级别不同
int (*p) = &a; //符合数组与指针的差别之一

int main()
{
        int a = { 1,2,3,4 };
        int* p = &a + 1;
        printf("%d \n", p[-1]); //指针才有的用法: p[-1] == *(p-1)
        return 0;
}什么时候数组名表示整个数组?


[*]sizeof(数组名) //sizeof括号内不能再有其他符号
[*]&数组名
只有这两种情况才表示整个数组,其他情况数组名都表示数组的首元素地址,a表示数组首元素(内容)
其他情况都会发生降维,降维成指针.
数组训练

int a = { 0,1,2,3,4,5,6,7,8,9,10,11 };
printf("%d\n", sizeof(a)); //二维数组
printf("%d\n", sizeof(a)); //二维数组第一个元素的第一个元素,即第一个一维数组的第一个元素
printf("%d\n", sizeof(a));    //二维数组的第一个元素,是第一个一维数组,没有降维,16
printf("%d\n", sizeof(a + 1));  
//二维数组的第一个元素+1,即降维成第一个一维数组的首元素地址+1.是第一个一维数组的第二个元素的地址.

printf("%d\n", sizeof(*(a+1)));  //第一个一维数组的第二个元素
printf("%d\n", sizeof(a + 1));  //降维成二维数组第一个元素的地址+1,即二维数组第二个元素的地址
printf("%d\n", sizeof(*(a+1)));     //二维数组的第二个元素,即第二个一维数组,没有发生降维,16
printf("%d\n", sizeof(&a + 1));    
//理解1:二维数组的第一个元素即第一个一维数组取地址,需要一维数组降维成指针,才能进行取地址,数组降维成指针后,就是数组的首元素地址,首元素地址再取地址就成了一维数组. (编译器不支持再升维成数组)
//理解2:二维数组的第一个元素取地址,二维数组就已经降维成二维数组的首元素地址,元素取地址,则指针对应的类型是二维数组元素的类型,+1就是加一个元素的大小.即得到二维数组的第二个元素的地址. (这种理解更符合编译器行为)

printf("%d\n", sizeof(*(&a+1))); //什么含义?
printf("%d\n", sizeof(*a));   //什么含义?
printf("%d\n", sizeof(a)); //什么含义?明确指针与数组的题

int a = { 0 };
for (int i = 0; i < 25; i++)
{
        *(&**a + i) = i;
}
for (int i = 0; i < 25; i++)
{
        printf("%d ", *(&**a + i));
}
puts("");

int(*p);//定义一个类型为int的指针变量
p = (int(*))a;//强转只是为了让编译器不警告
printf("pa=%p, pb=%p\n", &a, &p);//问题1
printf("%p,%d\n", &p - &a, &p - &a); //问题2

//先将指针模拟数组多维访问的方式降维成一维的计算方式: p = *(*(p + 4) + 2);
//然后将数组物理结构图展开
//p所指向的类型是int,因此步长是4个int
//a本身是数组,不需要降维
//编译器会查看元素是否是地址,或者说,默认指针计算就是把元素值当作地址.所以不用当心指针解引用后是指针还是数据的影响.数组传参过程,函数形参中括号[]内的常数可以省略.因为数组最终会降维成指针,而指针利用[]就没有数组这样的约束了
全部的数组,都可以看成一维数组.全部的数组传参,最终都会降维成一维数组


[*]二维数组降维
void func(int(*p),int size) //一维数组,数组元素类型为int
{ }

int main()
{
        int a = { 0 };
        func(a,5);
        return 0;
}
[*]高维数组降维
void func(int(*p),int size)//一维数组,数组元素类型为int
{ }

int main()
{
        int a = { 0 };
        func(a,5);
        return 0;
}验证: 只有第一个中括号能省略,即明确要求降维成一维指针
void func(int p[],int size)
{ }

int main()
{
        int a = { 0 };
        func(a,5);
        return 0;
}如果省略了指针第一个[]内的数值,则指针的类型就会不明确
//为什么要降维? 不降维就要拷贝整个数组,本钱开销很大,降维成指针后只必要拷贝地址
函数

函数的地址

void func()
{ }

int main()
{
        printf("%p\n", func); //常用
        printf("%p\n", &func);
        return 0;
}C语言中函数名和&函数名完全等价,都代表函数的地址
函数在程序中不可写入,只必要关心它的起始位置在那里
要生存函数的地址,就必要利用函数指针变量
函数指针可以通过圆括号()来调用指向的函数.比方p();
函数的规范


[*]在比较长的代码末端处,加上注释
//end of if
//end of for

[*]长表达式要在低优先级操作符处划分新行,操作符放在新行之首(以便突出操作符).
[*]原则上只管少利用全局变量,因为全局变量的生命周期太长,容易出错,也会长时间占用空间.其次,在多线程下会有线程安全问题,容易出错
[*]参数命名:
新旧值类型的一般遵行从右向左原则,像赋值符号一样,如字符串拷贝函数str_copy(char*dest,const char*src);
[*]函数功能要单一,不要设计多用途的函数.微软的Win32API就是典型,其函数往往因为参数不一样而功能不一,初学很容易迷惑
[*]类似的输入应当产生类似的输出.只管避免函数带有"记忆"功能,这样的函数的行为欠好预测,行为取决于某种"记忆".
"记忆"功能:如static修饰的局部变量,是函数的"记忆"存储器.
[*]避免函数有太多的参数,详细取决于业务,能简则简.如果参数过多,在利用时容易将参数类型或顺序搞错.
比方微软的WIN32API,其函数的参数往往有七八个甚至十余个
[*]类型和数目不确定的参数,要深思熟虑,因为这样的函数没有严酷的类型安全检查.如printf.
内存管理


[*]栈上开辟空间一定要明确知道空间大小,因为要压栈
[*]为什么必要动态内存,满足内存申请的机动性
[*]临时变量为什么有临时性,原因是栈空间本身就具有临时性
[*]全局数据区,随着整个程序的运行而一直存在; 生存在全局数据区的变量的声明周期都随进程
[*]我们能检查指针的合法性吗?
不能;指针如果有详细的指向(包括野指针),对应的合法性我们无法验证,因为指针指向什么用户无法得知;
一般的合法性检查指的是空指针问题,传入一个错误的非空指针(野指针)是无法检查出来的,只能从编程规范去控制.编码规范约定野指针必要置空,便于进行合法性检查.如果不置空,就是不遵循编码规范
[*]指针在遍历时,越界不一定会报
[*]什么样的程序最怕内存走漏? 常驻进程:常驻内存的程序,比方操作系统,杀毒软件,服务器等.
[*]运行起来的程序,已经和编译器没有关系了
malloc返回给用户的只有申请内存的起始地址,那free是怎样准确开释动态内存申请的空间?

从内存查看malloc和free的行为:
malloc,查看内存,内存值为cd的是开辟给用户的空间
(VS中malloc分配的空间会初始化成十六进制cd)
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234254266-1708995790.png
再看free,free后可以发现开释的空间不止10个,说明malloc分配的空间不止10个
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234253742-1317873366.png
内存级cookie

​    申请多出来的空间,是编译器用来记录申请空间的详细数据.提供给free,可以或许实现准确开释申请的内存,一般情况,这些数据的大小是固定的
记录这些信息的数据,称为cookie.属于内存级的cookie
有cookie存在,会有一个内存申请多大问题.申请空间越大好还是越小好?

[*]从利用率来说,申请大空间好,因为cookie是固定大小的,如果申请的空间过小,则可利用空间占据总申请空间比率就会小.
[*]如果想申请小空间,则在栈上申请更高效.
因此,栈和堆在那里申请的问题就可以通过这个思考来决定.
C语言程序地址空间-内存验证

#include<stdio.h>
#include<stdlib.h>

void fun1()
{}

void fun2()
{}

int g_val1;
int g_val2;

int g_val3 = 0;
int g_val4 = 0;

int main()   
{            

int a1 = 0;
int a2 = 0;
static int a3 = 0;

puts("栈区增长方向验证:");
printf("stack Area: &a1=%p\n",&a1);
printf("stack Area: &a2=%p\n",&a2);

puts("堆区增长方向验证:");
int *arr1 = (int*)malloc(sizeof(int)*3);
int *arr2 = (int*)malloc(sizeof(int)*3);
printf("heap Area: arr1=%p\n",arr1);
printf("heap Area: arr2=%p\n",arr2);

puts("未初始化数据区:");
printf("uninitialized data Area: %p\n",&g_val1);
printf("uninitialized data Area: %p\n",&g_val2);

puts("已初始化数据区:");
printf("initialized data Area: %p\n",&g_val3);
printf("initialized data Area: %p\n",&g_val4);
printf("static data Area: %p\n",&a3);

puts("常量区:");
const char *str = "hello world!";
printf("constant Area: %p\n",str);

puts("代码区:");
printf("main addr: %p\n",main);
printf("code Area: fun1=%p\n",fun1);
printf("code Area: fun2=%p\n",fun2);
//在前面的先注册

return 0;   
}   https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234253122-161373062.png



函数栈帧

熟悉相关寄存器

eax:通用寄存器,保留临时数据,常用于返回值
ecx:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,生存当前指令的下一条指令的地址
熟悉相关汇编下令

mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法下令
add:加法下令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip下令
lea: 取地址指令,和mov的区别是取得是地址不是数据
查看函数调用堆栈

通过下面代码举例:
#include <stdio.h>


int Add(int a, int b)
{
    return a+b;
}

int main()
{
    int x = 0xA;
    int y = 0xB;
    int z = 0;
    z = Add(x,y);
    return 0;
}F10进入调试,走到main函数快结束位置,当前的调用堆栈:
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234252668-838215104.png
(如果是vs2013,则可以或许进入到调用main函数的源代码中,vs2019下实行完return后直接结束了,有知道怎么解决的朋友可以评论区分享.)
在调用堆栈中右键勾选显示外部代码,可以看到更详细的调用堆栈
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234252049-598326539.png
打开后能发现更多的调用堆栈,这可以简单说明main函数也是被调用的.
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234251439-492628206.png
定义变量时的内存变化和汇编代码
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234250989-483438789.png
看函数调用过程的汇编代码
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234250543-1945894824.png
ebp-14是变量y的地址,ebp-8是x的地址,即先压入y,再压入x,然后再调用函数
说明:

[*]形参实例化的顺序从右向左
[*]函数的形参在函数调用前就形成
[*]给没有形参的参数传参也是会定义的(补充验证)
函数形成与开释:

https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234249860-484251507.png
从汇编上看,函数自己的栈不包括形参
开释栈帧:
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234249289-759238483.png
因此,调用函数是有本钱的,本钱体现在时间和空间上,本质是形成和开释栈帧有本钱
末了的ret,恢复返回地址,压入eip,类似pop eip下令;即指令寄存器,恢复到主调函数要实行的下一条指令
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234248783-851544445.png
末了,可以发现,形参变量是通过push和pop管理,那么它们的地址就是一连的.
根据这个原理,我们理论上也可以通过盘算指针偏移量来修改形参的值,来实现一些功能....
现代编译器为了保证程序安全可能有各种安全手段,如栈随机化的技能,金丝雀栈保护机制,因此根据地址直接修改程序没有那么简单.
在进程中,堆栈地址是从高到低分配的.当实行一个函数的时候,将参数列表入栈,压入堆栈的高地址部分,然后入栈函数的返回地址,接着入栈函数的实行代码,这个入栈过程,堆栈地址不断递减,一些黑客就是在堆栈中修改函数返回地址,实行自己的代码来达到实行自己插入的代码段的目的.
可变参数列表

可变参数的原理

​        在函数栈帧的汇编分析中可知,函数形参在函数调用前定义,参数之间位置相对固定,且定义顺序从右往左,依次压栈.
​        因为是可变参数,如果要我们利用,我们只要知道函数形参的第一个形参的起始地址,然后根据每个参数的类型,得到它们的内存空间布局就可以利用了.

可变参数至少必要固定一个形参,否则会报错,为什么?
在C语言中,可变参数函数设计上要求至少有一个固定参数的原因重要有以下几点:

[*]标示参数开始:可变参数函数至少必要一个非可变参数作为“标记”,这是因为编译器必要知道从那里开始解析可变参数列表。这个固定参数通常用于传递关于可变参数的信息,比如参数的数目大概某种类型的标识符。比方,在printf函数中,第一个固定参数(格式化字符串)就告诉函数接下来的可变参数应该怎样被解释和处理。
[*]获取参数信息:通过这个固定的参数,可以在运行时决定怎样访问和解析后面的可变参数。比方,通太过析格式化字符串,printf可以确定必要读取多少个参数以及它们的类型。
[*]定位参数地址:在实现上,可变参数是通过栈传递的,第一个固定参数的地址可以帮助确定可变参数在栈上的起始位置。这样,通过指针算术,我们可以从这个已知位置开始访问后续的可变参数。
[*]类型安全与边界界定:虽然C语言本身并不直接支持类型安全检查,但至少有一个固定参数可以作为编写安全、有效的可变参数处理逻辑的底子。这个参数可以辅助进行根本的参数验证,尽管更复杂的类型检查通常必要在函数内部手动实现。
简而言之,这个固定的参数不仅是逻辑上的必要,也是技能实现上的必要条件,它帮助程序精确地识别和处理随后的可变数目的参数。在实际应用中,通常会联合头文件中定义的宏(如va_start, va_arg, va_end等)来遍历和处理可变参数列表。
原理是这样,但如果要我们手动去做,显然是一件非常麻烦的事情.
因此C语言提供了一套方案,提供了几个宏便于用户更方便地利用可变参数列表.


C语言提供的可变参数方案

stdarg.h

在stdarg.h文件中有如下几个宏定义:
#include <vadefs.h>

#define va_start _crt_va_start
#define va_arg _crt_va_arg
#define va_end _crt_va_end

#endif/* _INC_STDARG */其定义在vadefs.h中分别为:
//ap ~= arg
//va_list == char*
//_ADDRESSOF ~= 取地址
//v~= num
//结合下一小节的简单可变参数程序源码

#define _INTSIZEOF(n)          ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
/* 为什么需要对齐
在可变参数函数的参数传参过程中,参数会发生整型提升,因此可变参数函数内部要能够对发生整型提升的参数进行正确的提取,需要进行对齐
*/
//计算对齐后n的大小,例如计算char,四字节对齐,则_intsizeof char为4;
//四字节对齐,如果size是1,2,3,4,则对齐size都是4. 如果size是5,6,7,8,则对齐size都是8.依次类推
//想要一个数是2,4,8,2^n的倍数,只要&上 对应2^n的二进制的1所在左边为全零,保证左边消除尾数,右边全1,保证右边数位不变 这样的数即可. 即& ~(2^n - 1).这里2^n整体表示2的倍数,如2,4,8,16....


#define _ADDRESSOF(v)          (&(v)) //计算v的地址

#define __crt_va_start_a(ap, v) ((void)(ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v)))
// va_start : ap=(char*)&num + intsizeof(num);

#define __crt_va_arg(ap, t)   (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
//1.ap先指向下一个可变参数:ap += _intsizeof(t)
//2.通过ap找到上一个可变参数的地址: ap - _intsizeof(t)
//3.解引用,获取上一个可变参数的值: *(t*)ap



#define __crt_va_end(ap)      ((void)(ap = (va_list)0))// 将指针置为无效va_list

用于定义可以访问可变参数部分的变量
va_list arg; //定义一个指向可变参数的指针变量va_list是char*类型的指针,可以按一字节的方式进行字节级别的数据读取.
typedef char* va_list;va_start

void va_start(va_list arg_ptr,prev_param);//(ANSI version)
//prev_param是可变参数部分的前一个参数,(不需要确定类型,最终会交给intsizeof计算)它可以通过第一个参数来定位可变参数的位置,使arg指向可变参数部分
va_arg

type va_arg( va_list arg_ptr, type );
//通过arg,和类型,返回对应的值
va_end

arg利用完毕后,使arg指向NULL. 即收尾工作
简单的可变参数程序

#include <stdio.h>
#include <stdarg.h>

int FindMax(int num, ...)
{
    va_list arg;
    va_start(arg, num);
    int max = 0;
    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);

    return max;
}

int main()
{
    int max = FindMax(5,0x11,0x21,0x31,0x41,0x51);
    printf("max = %d\n", max);
    return 0;
}可变参数的反汇编与内存分析

看可变参数的内存布局和汇编代码,用十六进制作为参数容易看内存
插入前:
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234248066-635122291.png
插入了一个形参
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234247226-1608451418.png
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234246673-1916765676.png
再看可变参数函数的几个宏:
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234246175-1283229374.png
定位到第一个可变参数的地址后,然后再这个地址赋值给arg,使arg指向第一个可变参数,这就是va_start的本质
再看va_arg:
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234245736-1572853845.png
va_arg可以分为两部分,第一个部分根据类型确定大小,然后将arg指向下一个可变参数.第二部分是取第二个参数的地址,往回指向,找到第一个参数,再将其赋值给max
即va_arg先指向第二个可变参数,再根据第二个可变参数找到一个参数赋值给max
再看va_end:
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234245044-311438600.png
va_end比较简单,直接指向空.
可变参数整型提拔的反汇编分析

看传char类型触发整型提拔,movsx,带符号扩展(整型提拔),并传送
​        可变参数列表传入参数是char,short,float等类型时,也会发生整型提拔,因为也是要加载到寄存器中
先看直接利用字符的情况
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234244339-231584552.png
说明字符字面值在盘算时是以整型形式表示的
再看用char变量表示的:
https://img2023.cnblogs.com/blog/2921710/202405/2921710-20240521234243881-200550070.png
数据传送指令MOV的变体。带符号扩展,并传送。这句指令就是整型提拔的本质,提拔到符合寄存器的大小
留意事项


[*]可变参数必须从头到尾逐个访问。如果你在访问了几个可变参数之后想半途终止,这是可以的,但是,如果你
想一开始就访问参数列表中间的参数,那是不行的。
[*]参数列表中至少有一个命名参数。如果连一个命名参数都没有,就无法利用va_start。
[*]这些宏是无法直接判定实际存在参数的数目。
[*]这些宏无法判定每个参数的是类型。
[*]如果在va_arg中指定了错误的类型,那么厥后果是不可预测的。
留言

如有不足的地方欢迎大家评论区留言指正。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 数组降维与多级指针、函数栈帧、地址空间、可变参数原理