数组降维、函数栈帧、地址空间、可变参数原理
数组和指针
多维数组的物理结构
在 C 语言中,多维数组都是一维抽象而成的,实际就是连续的多个一维数组.记得这一点,将指针和数组都化作一维来看待,解决这类问题就会简单很多.
证明数组 a 和 &a 不同
数组与指针的差别之一
什么时候数组名表示整个数组?
sizeof(数组名) //sizeof 括号内不能再有其他符号
&数组名
只有这两种情况才表示整个数组,其他情况数组名都表示数组的首元素地址,a[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)
再看 free,free 后可以发现释放的空间不止 10 个,说明 malloc 分配的空间不止 10 个
内存级 cookie
申请多出来的空间,是编译器用来记录申请空间的详细数据.提供给 free,能够实现准确释放申请的内存,一般情况,这些数据的大小是固定的
记录这些信息的数据,称为 cookie.属于内存级的 cookie
有 cookie 存在,会有一个内存申请多大问题.申请空间越大好还是越小好?
从利用率来说,申请大空间好,因为 cookie 是固定大小的,如果申请的空间过小,则可利用空间占有总申请空间比率就会小.
如果想申请小空间,则在栈上申请更高效.
因此,栈和堆在哪里申请的问题就可以通过这个思考来决定.
C 语言程序地址空间-内存验证
函数栈帧
认识相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值 ecx:通用寄存器,保留临时数据,常用于返回值 ebx:通用寄存器,保留临时数据 ebp:栈底寄存器 esp:栈顶寄存器 eip:指令寄存器,保存当前指令的下一条指令的地址
认识相关汇编命令
mov:数据转移指令 push:数据入栈,同时 esp 栈顶寄存器也要发生改变 pop:数据弹出至指定位置,同时 esp 栈顶寄存器也要发生改变 sub:减法命令 add:加法命令 call:函数调用,1. 压入返回地址 2. 转入目标函数 jump:通过修改 eip,转入目标函数,进行调用 ret:恢复返回地址,压入 eip,类似 pop eip 命令
lea: 取地址指令,和 mov 的区别是取得是地址不是数据
查看函数调用堆栈
通过下面代码举例:
F10 进入调试,走到 main 函数快结束位置,当前的调用堆栈:
(如果是 vs2013,则能够进入到调用 main 函数的源代码中,vs2019 下执行完 return 后直接结束了,有知道怎么解决的朋友可以评论区分享.)
在调用堆栈中右键勾选显示外部代码,可以看到更详细的调用堆栈
打开后能发现更多的调用堆栈,这可以简单说明 main 函数也是被调用的.
定义变量时的内存变化和汇编代码
看函数调用过程的汇编代码
ebp-14 是变量 y 的地址,ebp-8 是 x 的地址,即先压入 y,再压入 x,然后再调用函数
说明:
形参实例化的顺序从右向左
函数的形参在函数调用前就形成
给没有形参的参数传参也是会定义的(补充验证)
函数形成与释放:
从汇编上看,函数自己的栈不包括形参
释放栈帧:
因此,调用函数是有成本的,成本体现在时间和空间上,本质是形成和释放栈帧有成本
最后的 ret,恢复返回地址,压入 eip,类似 pop eip 命令;即指令寄存器,恢复到主调函数要执行的下一条指令
最后,可以发现,形参变量是通过 push 和 pop 管理,那么它们的地址就是连续的.根据这个原理,我们理论上也可以通过计算指针偏移量来修改形参的值,来实现一些功能....现代编译器为了保证程序安全可能有各种安全手段,如栈随机化的技术,金丝雀栈保护机制,因此根据地址直接修改程序没有那么简单.
在进程中,堆栈地址是从高到低分配的.当执行一个函数的时候,将参数列表入栈,压入堆栈的高地址部分,然后入栈函数的返回地址,接着入栈函数的执行代码,这个入栈过程,堆栈地址不断递减,一些黑客就是在堆栈中修改函数返回地址,执行自己的代码来达到执行自己插入的代码段的目的.
可变参数列表
可变参数的原理
在函数栈帧的汇编分析中可知,函数形参在函数调用前定义,参数之间位置相对固定,且定义顺序从右往左,依次压栈.
因为是可变参数,如果要我们使用,我们只要知道函数形参的第一个形参的起始地址,然后根据每个参数的类型,得到它们的内存空间布局就可以使用了.
可变参数至少需要固定一个形参,否则会报错,为什么?
在 C 语言中,可变参数函数设计上要求至少有一个固定参数的原因主要有以下几点:
标示参数开始:可变参数函数至少需要一个非可变参数作为“标记”,这是因为编译器需要知道从哪里开始解析可变参数列表。这个固定参数通常用于传递关于可变参数的信息,比如参数的数量或者某种类型的标识符。例如,在
printf
函数中,第一个固定参数(格式化字符串)就告诉函数接下来的可变参数应该怎样被解释和处理。获取参数信息:通过这个固定的参数,可以在运行时决定如何访问和解析后面的可变参数。例如,通过分析格式化字符串,
printf
可以确定需要读取多少个参数以及它们的类型。定位参数地址:在实现上,可变参数是通过栈传递的,第一个固定参数的地址可以帮助确定可变参数在栈上的起始位置。这样,通过指针算术,我们可以从这个已知位置开始访问后续的可变参数。
类型安全与边界界定:虽然 C 语言本身并不直接支持类型安全检查,但至少有一个固定参数可以作为编写安全、有效的可变参数处理逻辑的基础。这个参数可以辅助进行基本的参数验证,尽管更复杂的类型检查通常需要在函数内部手动实现。
简而言之,这个固定的参数不仅是逻辑上的需要,也是技术实现上的必要条件,它帮助程序正确地识别和处理随后的可变数量的参数。在实际应用中,通常会结合
<stdarg.h>
头文件中定义的宏(如va_start
,va_arg
,va_end
等)来遍历和处理可变参数列表。
原理是这样,但如果要我们手动去做,显然是一件非常麻烦的事情.
因此 C 语言提供了一套方案,提供了几个宏便于用户更方便地使用可变参数列表.
C 语言提供的可变参数方案
stdarg.h
在 stdarg.h 文件中有如下几个宏定义:
其定义在 vadefs.h 中分别为:
va_list
用于定义可以访问可变参数部分的变量
va_list 是 char*类型的指针,可以按一字节的方式进行字节级别的数据读取.
va_start
它可以通过第一个参数来定位可变参数的位置,使 arg 指向可变参数部分
va_arg
通过 arg,和类型,返回对应的值
va_end
arg 使用完毕后,使 arg 指向 NULL. 即收尾工作
简单的可变参数程序
可变参数的反汇编与内存分析
看可变参数的内存布局和汇编代码,用十六进制作为参数容易看内存
插入前:
插入了一个形参
再看可变参数函数的几个宏:
定位到第一个可变参数的地址后,然后再这个地址赋值给 arg,使 arg 指向第一个可变参数,这就是 va_start 的本质
再看 va_arg:
va_arg 可以分为两部分,第一个部分根据类型确定大小,然后将 arg 指向下一个可变参数.第二部分是取第二个参数的地址,往回指向,找到第一个参数,再将其赋值给 max
即 va_arg 先指向第二个可变参数,再根据第二个可变参数找到一个参数赋值给 max
再看 va_end:
va_end 比较简单,直接指向空.
可变参数整型提升的反汇编分析
看传 char 类型触发整型提升,movsx,带符号扩展(整型提升),并传送
可变参数列表传入参数是 char,short,float 等类型时,也会发生整型提升,因为也是要加载到寄存器中
先看直接使用字符的情况
说明字符字面值在计算时是以整型形式表示的
再看用 char 变量表示的:
数据传送指令 MOV 的变体。带符号扩展,并传送。这句指令就是整型提升的本质,提升到符合寄存器的大小
注意事项
可变参数必须从头到尾逐个访问。如果你在访问了几个可变参数之后想半途终止,这是可以的,但是,如果你想一开始就访问参数列表中间的参数,那是不行的。
参数列表中至少有一个命名参数。如果连一个命名参数都没有,就无法使用 va_start。
这些宏是无法直接判断实际存在参数的数量。
这些宏无法判断每个参数的是类型。
如果在 va_arg 中指定了错误的类型,那么其后果是不可预测的。
文章转载自:HJfjfK
评论