底层原理剖析
C++程序的编译与链接
任何编程语言,代码运行时,都只产生两种东西:指令 或 数据,代码文件执行时,先从磁盘上加载到进程的虚拟内存中,进程的虚拟内存空间分布图如下:
系统默认对.bss的数据设置为0,这就是为什么未初始化的数据都为0的原因
编译过程:生成好了指令,但不为符号分配虚拟地址
-
预编译:处理除了#prama开头的其他#命令,比如引入头文件之类的
-
编译:语义分析,语法分析,代码优化,生成汇编代码
-
汇编:为.o文件生成符号表,文件头,各种段,生成二进制可重定位的目标文件.obj
符号表:一个cpp文件里声明的数据都会生成符号到符号表,就算是引用其他文件里的变量,也会在当前cpp文件里的符号表里生成符号,只不过是UND类型的
链接阶段
- 将所有.o文件的文件段、符号表进行合并,并进行符号解析(对所有符号的引用,都找到符号定义的位置,如果找不到,说明编译失败)
- 符号解析成功之后,进行符号的重定位:为所有的符号分配虚拟地址,然后进入.text段中,为里面存储的指令中的所有符号添加上虚拟地址
- 生成可重定向文件后,添加一个program headers,记录了入口地址信息,并告诉系统在运行该程序时需要加载哪些东西进入内存,由此生成可执行文件
new-delete与malloc-free
malloc和free:C的库函数 vs new和delete:运算符
malloc是按字节开辟内存的,返回类型是void*,它只会做内存开辟;而new除了做内存开辟,还可以同时做内存初始化,比如int *p = new int (20);
,如果new的是对象的话,会调用对象的构造函数
同时malloc开辟内存失败返回nullptr;new开辟内存失败,是通过抛出bad_alloc类型的异常来判断的
对应的,free只会做内存的释放;而如果new的类型是对象的话,delete会先调用对象的析构函数,再进行内存释放
所以对于普通的编译器内置类型来说,new/free,malloc/delete混用是没有问题的
new与delete vs new[]与delete[]
对于自定义的类类型来说,在调用Test *p = new Test[]
时,不仅仅会用malloc开辟总共需要的内存空间,还会多开辟4个字节,存储开辟了多少个对象,但返回给p的地址是开始存储对象的地址;
随后调用delete []
时会先取到对象的个数,然后根据开辟的总空间大小/对象个数,一个个找到构造的对象的地址,依次调用析构函数,最后从p-4的位置free掉(要把存储对象个数的内存也free掉)
这就是为什么对于自定义的类类型来说,使用new []要与delete[]对应,而不能直接用delete的原因:delete会以为只有一个对象,只会从p的地址开始free,而不是p-4的位置
但是对于普通的编译器内置类型来说,new/delete[],new[]/delete混用没有问题
new的不同版本
c++
int *p1 = new int(20);
int *p2 = new (nothrow) int; 不抛出异常的new
const int *p3 = new const int(40); 在堆上生成常量对象的new
//定位new
int data = 0;
int *p4 = new (&data) int(50);
在指定的内存上,开辟一块4字节的内存,初值改成五十,上面这个例子运行后data就变成50
函数调用
每个函数都需要先开辟栈空间,函数调用方将实参压栈(同时开辟好函数形参的内存空间)
- 函数调用和返回地址的保存: 当一个函数被调用时,程序会将当前函数的返回地址(即调用该函数之后需要继续执行的下一条指令的地址)保存到堆栈中。这样,当被调用的函数执行完毕后,程序就可以回到正确的位置继续执行
- 参数的传递: 调用函数时,函数的参数值会被传递给被调用函数。在堆栈中,参数值会被存储在被调用函数的栈帧(stack frame)中的特定位置
- 局部变量的分配: 被调用函数中声明的局部变量也会在堆栈中分配空间。每个函数调用都会创建一个新的栈帧,栈帧包含了函数的局部变量以及其他与函数调用相关的信息
- 函数执行: 被调用函数开始执行,它可以使用传递给它的参数和分配给它的局部变量
- 返回值的处理: 当被调用函数执行完毕后,它会将返回值存储在特定的位置,通常是栈帧中的某个寄存器中。然后,程序会将之前保存的返回地址从堆栈中取出,以便返回到调用函数的正确位置
- 栈帧的销毁: 当函数返回时,它的栈帧会被销毁,释放在堆栈上分配的内存空间。程序会回到之前保存的返回地址,并继续执行调用函数的下一条指令
内联函数
普通函数:函数调用时需要进行参数压栈,函数栈帧的开辟和回退过程(这些就是函数调用的开销),如果函数开销远远大于函数实际执行指令的时间,十分影响函数调用的效率
内联函数:在编译过程中,在函数的调用点直接展开函数的具体代码,不产生响应的函数符号,不会产生函数的开销
通过在代码中声明为inline来告诉编译器,希望将该函数处理为内联函数,编译器会根据自己的判断来实际决定要不要把它处理成内联函数
不是所有的inline都会被编译器处理成内联函数,比如说递归(需要在执行过程中判断执行多少次),比如说函数有很多行代码时,可能就不会将其处理为内联
形参带默认值的函数
比如int sum(int a, int b=20)
只能从右向左给形参默认值
在调用函数前需要将参数压栈,如果有形参默认值直接压入具体数值(相比于传入变量作为实参,会提升一些效率)
不仅在定义处给形参默认值,在声明处也可以给形参默认值,但哪怕两次给的值是一样的,某个形参默认值也只能出现一次
同名函数的关系
重载:两个函数处于同一个作用域, 函数名字相同但参数列表不同。编译器根据调用时提供的参数类型和数量来选择合适的函数进行调用,提高代码灵活性和可读性,属于静态的多态
为什么c++支持函数重载,但c语言不支持:c++生成函数符号时,是由函数名+参数列表决定的;而c语言生成函数符号的时候,单纯由函数名决定
同时由于生成的函数符号不一致,在编译阶段无法链接,所以在c++代码里无法直接调用c代码里的函数,在c代码里也无法调用c++代码里的函数
除非在c++代码里使用
extern "C"{ int sum(int a, int b); }
来显式告诉编译器,以下的函数为c语言的代码,进行对应的处理
重写:也称为覆盖(虚函数表中虚函数地址的覆盖):如果派生类里有一个与基类虚函数,同名同参数列表同返回值的函数,会在虚函数表里用自己的函数覆盖掉基类的虚函数,然后把RTTI改为派生类类型,是实现多态的一种机制,在运行时会根据对象的实际类型调用恰当的函数
隐藏 (作用域的隐藏):在继承结构当中,派生类的同名成员会把基类的同名成员隐藏了,比如d.show();
会优先找派生类自己作用域的show名字成员,如果有的话,会把基类里所有叫show的函数都隐藏掉,除非指明作用域d.Base::show()