前尘篇-函数调用的背后
C++的编译程序的内存布局
实际上这个内存布局指的是C++编译程序在虚拟内存这个概念下的内存使用情况的一种规约。在linux下gcc生成的ELF可执行文件和windows下的PE可执行文件大都是按照这种内存布局策略来组织的,这是一个逻辑上的划分。并且C++程序中的一切活动都是以这种内存布局方式为指导的,包括10天后将要学习的线程级的并发。
地址:0xFFFFFFFF,内核区(操作系统) 命令行参数和环境变量 也存储在这个区域里 |
地址:0xBFFFFFFF,栈(Stack)区(自动变量(临时变量)就是在这里进行自动化管理的,函数调用的时候也使用了这里的内存),栈是向着低地址方向增长的。存放局部变量,函数参数,当前状态,函数调用信息等。 |
堆(Heap)区(使用new/delete操作的动态内存就是从这里分配的),堆的地址是向着高地址方向增长的 |
数据区(存储全局变量、静态变量的值) |
地址0x00000000 代码区(存储二进制代码),指令指针(EIP)就是指向代码段的,它可读可执行不可写 |
在linux下,进程的虚拟内存大致上就是这样分布的(这只是一个非常粗略的画法)。
内存对齐问题
另外一个关于程序内存布局需要注意的地方就是内存对齐的问题。为什么需要内存对齐呢?以64位CPU为例,CPU从内存中取数据的时候,它一次性只拿去64位(8个字节,注意了,我们平时在C++程序中计算的长度都是以字节为单位来计量长度的,字节也是内存编址的最小单位)。考虑内存对齐的一个具体案例,有如下伪代码:
int iGlobalValue = 3; double dGlobalValue = 4.5; int main(int argc,char *argv[]) { printf("Address of iGlobalValue is 0x%X \n",&iGlobalValue); printf("Address of dGlobalValue is 0x%X \n",&dGlobalValue); return (1); }
如上所示的两条printf语句,目的是为了输出iGlobalValue和dGlobalValue的地址。现在,假设iGlobalValue的地址是0x00000000,int的长度为4字节,那么dGlobalValue的地址会不会紧挨着iGlobalValue为0x00000004呢?实际上并不是,dGlobalValue的地址应该是0x00000008。这就是我们所说的内存对齐导致的,那么为什么要有内存对齐呢,假设,dGlobalValue的地址是0x00000004,CPU从内存中一次拿8个字节的数据,这意味着如果CPU想正确的拿到dGlobalValue这个double类型的数据,从从内存中取两次。第一次从0x00000000中取出8个字节的数据,并拿出其中高4字节的数据作为double类型数据的低4字节,第二次从0x00000008开始取出8个字节的数据,拿出这个8个字节的低4字节作为dGlobalValue的高4字节,至此CPU才完整的从内存中拿出了这个double类型的数据。如果使用了内存对齐,那么CPU在取dGlobalValue的时候会从0x00000008这个地址拿数据,这样CPU只需要执行一次操作就能从内存中拿到dGlobalValue这个数据。所以基于这个原因,使用了内存对齐能够提升性能。当然是不是使用内存对齐,这个不是程序员考虑的,底层硬件、操作系统、编译器都会为你做好这些事情。内存对齐的事情告诉我们,C++里数据类型标志着这个数据的大小(在内存中占用的内存单元多少),以及我们能在其上采取什么样的操作,另外,在计算结构体(struct)的大小的时候,也需要考虑内存对齐的问题。
堆和栈问题的延伸
字符串字面值的保存策略。相同的字符串常量在内存中只有一份数据,而不是每个变量持有一份常量的副本。字符串常量存放的地方叫字符串字面量池,它的存在目的是为了确保当我们使用字符串字面值的时候,在内存中只有该常量的一个副本。
代码验证:
//本例的目的是为了验证相同的字符串字面值常量在内存中只有一个副本 #include <cstdio> #include <string> using namespace std; int main(int argc,char *argv[]) { char *pLiteralConstant1 = "Hello World!"; char *pLiteralConstant2 = "Hello World!"; //如果这两个变量的指向的目标地址是一致的,那么说明相同的字面值常量是存储在同一位置的 printf("Value of strLiteralConstant1 is: 0x%X ",pLiteralConstant1); printf("Value of strLiteralConstant2 is: 0x%X",pLiteralConstant2); return (1); }
输出结果如下:
栈内存地址是连续的,它有点像数据结构里的栈,有的时候也把这里的栈称为堆栈。栈内存是不受程序员管理的,堆内存受操作系统管理里,但是程序员可以通过new/delete来进行内存管理,堆内存不是连续的,堆内存是通过链表管理的,从空闲堆链表中找到满足条件的内存块来分配,并不保证先后分配的两块内存一定会是连续的;而且堆内存包含记录内存大小以及链表节点的头,所以实际上占用的内存大小比实际申请的大小要大。这里的问题是,内存是操作系统资源,当我们使用new来申请内存时,这就涉及到操作系统状态的跃迁(用户态到内核态的变化),因此,当需要频繁申请堆内存的时候,会带来额外的性能开销。为了解决这个问题可以使用内存池的策略。
函数调用时的参数入栈结果返回
一次函数调用,函数栈的实例如下图所示。(本部分内容参考:https://blog.csdn.net/suhuaiqiang_janlay/article/details/45223865,感谢博主提供的精彩文章)
概念:栈帧,每个函数的栈称为一帧,也就是该函数的栈帧,函数栈的基地址(EBP)称为称为栈帧指针,访问函数的参数或者局部变量都是通过EBP加上偏移量获得的。
ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶
EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部
函数栈帧:ESP和EBP之间的内存空间为当前栈帧,EBP标识了当前栈帧的底部,ESP标识了当前栈帧的顶部。
寄存器:
Intel32位体系结构包含8个32位的通用寄存器。
EAX:累加(Accumulator)寄存器,常用于函数返回值。
EBX:基址(Base)寄存器,以它为基址访问内存。
ECX:计数(Counter)寄存器,常用于字符串或者循环操作中的计数器
EDX:数据(Data)寄存器,常用于乘除法或I/O指针。
ESI:源变址寄存器
EDI:目的变址寄存器
ESP:堆栈(Stack)指针寄存器,指向堆栈顶部
EBP:基址指针寄存器,指向当前堆栈底部。
EIP:指令寄存器(extended instruction pointer), 其内存放着一个指针,该指针永远指向下一条待执行的指令地址
关于函数调用,突然找到了一篇特别好的博客,我决定把它转载过来。有关函数调用的其它知识,参见以下博客:
3.函数栈帧的策略存在的一些影响(原博客不是这个名字,我只是觉得它应该可以这么理解,所以这样标注了)
4.可变参数函数的实现原理或机制(这个阅读完此博客后,我觉得这也是在函数栈上的一种巧妙应用,所以罗列在了这里)
5.堆栈溢出问题
10.C语言内存对齐
以上10篇博客,还没有理解透彻,待吃透之后再将其转到我自己的博客中来。
函数匹配
这个实际上讲的是对于C++重载函数的调用过程。函数匹配是一个过程,在这个过程中,我们把函数调用与一组重载函数中的某一个关联起来,函数匹配的过程也叫做重载确定。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。一次调用重载函数时可能有三种可能的结果:
(1)编译器找到一个与实参最佳匹配(best match)的函数,并生成调用该函数的代码。
(2)找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配(no match)的错误信息。
(3)有多余一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误信息,称为二义性错误。
另外还要注意一下作用域与重载的问题,这指的是局部变量和函数名同名的问题,在C++中名字查找发生在类型检查之前,例如有如下代码:
string read(); void fooBar(int iVal) { bool read = false; //新作用域,隐藏了外层的read string s = read(); //这里会报错,因为编译器在进行名字查找时,先找到的局部变量read,然后进行类型检查,发现read是一个bool值,于是报错 };
也就是说,一旦在当前作用域中找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体,剩下的工作就是检查函数调用是否有效了。
函数匹配的过程:
void f(); void f(int); void f(int,int); void f(doube,double d2= 3.14); f(5.6); //调用f(double ,double d2= 3.14)
针对重载函数
- 确定候选函数和可行函数
选定本次调用对应的重载函数集合,集合中的函数称为候选函数(candidate function)。候选函数具备两个特征:一是与被调用的函数同名,二是其声明在调用点可见。
在“函数匹配”的例子中具有4个名字为f的候选函数。
然后,考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数(viable function)。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。
根据这个准则,可以剔除掉两个候选函数;void f(),void f(int , int)。
如果没有找到可行函数,编译器将报告无匹配函数的错误。
实参和形参匹配的含义可能是它们具有相同的类型,也可能是实参类型和形参类型满足转换规则。
2.寻找最佳匹配
从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数,“最匹配”的基本思想是:实参类型与形参类型越接近,它们匹配的越好。显然精确匹配要比需要类型转换的匹配要更好。
3.含有多个形参的函数匹配
编译器在这种情况下确定最佳匹配时检查以下两个条件:
1.该函数每个实参的匹配都不劣于其它可行函数需要的匹配
2.至少有一个实参的匹配优于其它可行函数提供的匹配
如果有且只有一个函数满足上述两个条件,那么函数匹配成功。否则编译器将报二义性错误。
精确匹配:
编译器将实参类型到形参类型的转换划分为几个等级。排序如下:
1.精确匹配,包括以下情况
- 实参类型和形参类型相同
- 实参从数组类型或者函数类型转换成对应的指针类型
- 向实参添加顶层const或者从实参中删除顶层const
2.通过const转换实现的匹配
3.通过类型提升实现的匹配
4.通过算数类型转换或指针转换实现的匹配
5.通过类类型转换实现的匹配