文章目录
C++程序编译过程
编译过程分为四个过程:编译(编译预处理,编译,优化),汇编,链接。
- 编译预处理:处理以#开头的指令;
- 编译,优化:将源码
.cpp
文件翻译成.s
汇编代码; - 汇编:将汇编代码
.s
翻译成.o
文件; - 链接:汇编程序生成的目标文件,即
.o
文件,并不会立即执行,因为可能会出现.cpp
文件中的函数引用了另一个.cpp
文件中定义的符号或者调用了某个库文件中的函数。链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序.exe
文件。
链接分为两种:
- 静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
- 动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进程的虚拟地址的空间。
二者的优缺点:
- 静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行遍历链接生成可执行程序(更新困难);优点是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
- 动态链接:节省内存,更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。
动态链接和静态链接的比较
静态链接 | 动态链接 |
---|---|
静态链接是将程序中使用的所有库模块复制到最终的可执行文件中。 | 在动态链接中,外部库的名称放置在最终的可执行文件中,实际的链接过程发生在运行时,可执行文件和库都放置在内存中。动态链接允许多个程序使用可执行模块的单个副本。 |
静态链接由称为链接器的程序执行,静态链接发生在编译的最后一步。 | 动态链接由操作系统在运行时执行。 |
静态链接文件较大,因为外部程序内置在可执行文件中。 | 在动态链接中,只有一份程序代码保存在内存中,减少了可执行程序的大小,从而节省了内存和磁盘空间。 |
使用静态链接库的程序比使用动态链接库要快。 | 使用动态链接库的程序比使用静态链接库速度要慢 |
静态链接的程序每次加载到内存中会花费恒定的时间 | 如果动态链接库的代码已经存在于内存中,则动态链接库的程序加载时间会减少 |
静态链接中,如果外部程序代码发生变化,则必须重新编译并重新链接否则这样的改变不会体现在最终的可执行文件中。 | 动态链接中,共享模块可以更新和重新编译。 |
静态链接不会遇到兼容问题。 | 动态链接的程序依赖于兼容性的库。如果一个动态链接库发生改变,应用程序可能需要更改来适应发生变化后的动态链接库。 |
C++内存管理
C++内存分区:栈,堆,全局、静态存储区,常量存储区,代码区。
- 栈:存放函数的局部变量,函数参数,返回地址等,由编译器自动分配和释放。
- 堆:动态申请的内存空间,就是
malloc
分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。 - 全局区/静态存储区(.bss段和.data段):存放全局变量和静态变量,程序运行结束操作系统自动释放,在C语言中,未初始化的放在
.bss
段,初始化的放在.data
段,C++中不再区分。 - 常量存储区(.rodata):存放的是
const
,constexpr
和字面值,不允许修改,程序运行结束后自动释放。 - 代码区(.text):存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
#include <iostream>
using namespace std;
/*
说明:C++中不再区分初始化和未初始化的全局变量,静态变量的存储区
*/
int g_var = 0; //g_var在全局区.data段
char* gp_var; //gp_var在全局区.bss段
int main()
{
int var; //var在栈区
char* p_var; //p_var在栈区
char arr[] = "abc"; //arr为数组变量,存储在栈区;"abc"为字符串常量,存储在常量区
char* p_var1 = "123456"; //p_var1在栈区;"123456"为字符串常量,存储在常量区
static int s_var = 0; //s_var为静态变量,存储在静态存储区.data段
p_var = (char*)malloc(10); //分配得到的10个字节的区域在堆区
free(p_var);
return 0;
}
其中
.text
是编译后程序的主体,也就是程序的机器指令。.data
和.bss
保存了程序的全局变量,.data
保存初始化的全局变量,.bss
保存未初始化的全局变量。heap
堆保存程序中动态分配的内存。stack
用来进行函数调用,保存函数参数,临时变量,返回地址等。
自由存储区和堆的区别
自由存储区与堆的划分标准是申请和释放内存是使用new/delete
还是malloc/free
。C++并没有给出new/delete
应该如何实现,但很多编译器的new/delete
都是以malloc/free
为基础来实现的。从技术上来说,堆是C语言和操作系统的术语,堆是操作系统所维护的一块特殊内存,它提供了动态分配内存的功能,当运行程序调用malloc
时就会从中分配内存,调用free
可把内存交还。而自由存储区是C++中通过new
和delete
动态分配和释放对象的抽象概念,通过new
来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认使用堆来实现自由存储,即默认的全局运算符new/delete
也许会使用malloc/free
的方式申请和释放存储空间,也就是说自由存储区位于堆上。但程序员也可以通过重载操作符,改用其他内存来实现自由存储。例如全局变量做的内存池,这时自由存储区就不同于堆了。
我们需要记住的是:
堆是操作系统维护的一块内存,而自由存储区是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。
结论
- 自由存储区是C++中通过
new
与delete
动态分配和释放对象的抽象概念,而堆是C语言和操作系统的术语,是操作系统维护的一块动态分配内存。 new
所申请的内存区域在C++中称为自由存储区。通过堆实现的自由存储区,可以说new
所申请的内存区域在堆上。- 堆与自由存储区还是有区别的,它们并非等价。
栈和堆的区别
- 申请方式:栈是系统自动分配,堆是程序员主动申请;
- 申请后系统响应:分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。
- 栈在内存中是一块连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续的。
- 申请效率:栈是由系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片。
- 存放的内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。
变量的区别
全局变量,局部变量,静态全局变量,静态局部变量的区别
C++变量根据定义位置的不同,拥有不同的生命周期,具有不同的作用域。作用域可分为6种:全局作用域,局部作用域,语句作用域,类作用域,命名空间作用域和文件作用域。
从作用域看:
- 全局变量:具有全局作用域。全局变量只需在一个源文件中定义,就可以作用于所有的源文件。当然,其他不包含全局变量的源文件需要用
extern
关键字再次声明这个全局变量; - 静态全局变量:具有文件作用域。它与全局变量的区别在于如果程序包含多个文件的话,它作用于定义它的文件中,不能作用到其他文件中,即被
static
关键字修饰的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量。它们也是不同的变量。 - 局部变量:具有局部作用域。它是自动对象(auto),在程序运行期间不是一直存在,而是只在函数执行期间存在,函数的一次调用结束后,变量被撤销,其所占用的内存也被收回。
- 静态局部变量:具有局部作用域。它只被初始化一次,自从第一次被初始化直到程序运行结束都一直存在,它和全局变量的区别在于全局变量对所有的函数都是可见的,而静态局部变量只对自己的函数体始终可见。
从分配内存空间看:
- 静态存储区:全局变量,静态局部变量,静态全局变量
- 栈:局部变量
说明:
- 静态变量,栈变量(存储在栈中的变量),堆变量(存储在堆中的变量)的区别:静态变量会被放在程序的静态存储区(.data段)中(静态变量会自动初始化),这样可以在下一次调用的时候还可以保持原来的赋值。而栈变量或堆变量不能保证在下一次调用的时候依然保持原来的值。
- 静态变量和全局变量的区别:静态变量用
static
告知编译器,自己仅仅在文件作用范围内可见。
全局变量定义在头文件中有什么问题?
如果在头文件中定义全局变量,当该头文件被多个文件include
时,该头文件中的全局变量就会被定义多次,导致重复定义,因此不能在头文件中定义全局变量。
对象创建限制在堆或栈的方法
如何限制类的对象只能在堆上创建?如果限制对象只能在栈上创建?
说明:C++中的类的对象的建立分为两种:静态建立,动态建立。
- 静态建立:由编译器为对象在栈空间分配内存,直接调用类的构造函数创建对象。例如
A a;
。 - 动态建立:使用
new
关键字在堆空间上创建对象,底层首先调用operator new()
函数,在堆空间上寻找合适的内存并分配;然后,调用类的构造函数创建对象。例如:A* p = new A();
。
限制对象只能建立在堆上:
- 最直观的思想:避免直接调用类的构造函数,因为对象静态建立时,会调用类的构造函数创建对象。但是直接将类的构造函数设置为
private
并不可行,因为当构造函数设置为private
后,不能在类的外部调用构造函数来构造对象,只能用new
来建立对象,但是由于new
创建对象时,底层也会调用类的构造函数,将构造函数设置为私有后,那就无法在类的外部使用new
创建对象了。因此,这种方法不可行。 - 解决方法1:
- 将析构函数设置为
private
。原因:静态对象建立在栈上,是由编译器分配和释放内存空间,编译器为对象分配内存空间时,会对类的非静态函数进行检查,即编译器会检查析构函数的访问性。当析构函数设为private
时,编译器创建的对象就无法通过访问析构函数来释放对象的内存,因此,编译器不会在栈上为对象分配内存。
- 将析构函数设置为
class A
{
public:
A() {}
void destory()
{
delete this;
}
private:
~A(){}
};
该方法存在的问题:
-
用
new
创建的对象,通常会使用delete
释放对象的内存空间,但此时类的外部无法调用析构函数,因此类内必须定义一个destory()
函数,用来释放new
创建的对象。 -
无法解决继承问题,因为如果这个类作为基类,析构函数要设置成
virtual
,然后在派生类中重写该函数来实现多态。但此时,析构函数是私有的,派生类中无法访问。 -
解决方法2:
- 构造函数设置为
protected
,并提供一个public
的静态函数来完成这个构造,而不是在类的外部使用new
构造;将析构函数设置为protected
。原因:类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用create()
函数在堆上创建对象。
- 构造函数设置为
class A
{
protected:
A() {}
~A(){}
public:
static A* create()
{
return new A();
}
void destory()
{
delete this;
}
};
限制对象只能建立在栈上:
- 解决方法:将
operator new()
设置为private
。原因:当对象建立在堆上时,是采用new
的方式进行建立,其底层会调用operator new()
函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。
class A
{
private:
void* operator new(size_t t){} //注意函数的第一个参数和返回值都是固定的
void operator delete(void* ptr){} //重载了new就需要重载delete
public:
A() {}
~A() {}
};
内存对齐
内存对齐:编译器将程序中的每个“数据单元”安排在字的整数倍的地址指向的内存之中。
内存对齐的原则:
-
结构体变量的首地址能够被其最宽基本类型成员大小与对齐基数中的较小者所整除(10/5=2,叫做10能被5整除);
-
结构体每个成员相对于结构体首地址的偏移量(offset)都是该成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在成员之间加上填充字节(internal padding);
-
结构体的总大小为结构体最宽基本类型成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在最后一个成员之后加上填充字节(trailing padding);
每个平台都有自己默认的“对齐系数”。gcc中默认#pragma pack(4)
,可以通过预编译命名#pragma pack(n)
,n = 1,2,4,8,16来改变。
/*
说明:程序是在64位编译器下测试的
*/
#include <iostream>
using namespace std;
struct A
{
short var; //2字节
int var1; //8字节(内存对齐原则:填充2字节)2(short)+2(填充)+4(int) = 8
long var2; //12字节 8 + 4(long) = 12
char var3; //16字节(内存对齐原则:填充3个字节)12+1(char)+3(填充)=16
string s; //48字节16+32(string) = 48
};
int main()
{
short var;
int var1;
long var2;
char var3;
string s;
A ex1;
cout << sizeof(var) << endl; //2 short
cout << sizeof(var1) << endl; //4 int
cout << sizeof(var2) << endl; //4 long
cout << sizeof(var3) << endl; //1 char
cout << sizeof(s) << endl; //32 string
cout << sizeof(ex1) << endl; //48 struct
return 0;
}
进行对齐的原因:
- 某些硬件设备只能存取对齐数据,存取非对齐数据可能引发异常;
- 某些硬件设备不能保证在存取非对齐数据时,该操作是原子操作;
- 相比于存取对齐数据,存取非对齐的数据需要花费更多的时间;
- 某些处理器虽然支持非对齐数据的访问,但会引发对齐陷阱;
- 某些硬件设备只支持简单数据指令非对齐存取,不支持复杂数据指令的非对齐存取;
内存对齐的优点:
- 便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;
- 提高内存的访问效率,因为CPU在读取内存时,是一块一块的读取;
类的大小
说明:类的大小是指类的实例化对象的大小,用sizeof
对类型名操作时,结果是该类型的对象的大小。
计算原则:
- 遵循结构体的对齐原则;
- 与普通成员变量有关,与成员函数和静态成员无关,即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员以及成员函数被类的对象共享,并不属于哪个具体的对象;
- 虚函数对类的大小有影响,因为虚函数表指针的影响;
- 虚继承对类的大小有影响,因为虚基表指针的影响;
- 空类的大小是一个特殊情况,空类的大小为1,当用
new
来创建一个空类的对象时,为了保证不同对象的地址不同,空类也占用存储空间。
/*
说明:程序是在64位编译器下测试的
*/
#include <iostream>
using namespace std;
class A
{
private:
static int s_var; //不影响类的大小
const int c_var; //4字节
int var; //8字节 4 + 4(int) = 8
char var1; //12字节 8 + 1(char) + 3(填充) = 12
public:
A(int temp) : c_var(temp) {} //不影响类的大小
~A(){} //不影响类的大小
};
class B {};
int main()
{
A ex1(4);
B ex2;
cout << sizeof(ex1) << endl; //12字节
cout << sizeof(ex2) << endl; //1字节
return 0;
}
带有虚函数的情况:(注意:虚函数的个数并不影响所占内存的大小,因为类对象的内存中只保存了指向虚函数表的指针)。
/*
说明:程序是在64位编译器下测试的
*/
#include <iostream>
using namespace std;
class A
{
private:
static int s_var; //不影响类的大小
const int c_var; //4字节
int var; //8字节 4 + 4(int) = 8
char var1; //12字节 8 + 1(char) + 3(填充) = 12
public:
A(int temp) : c_var(temp) {} //不影响类的大小
~A() {} //不影响类的大小
//24字节 12 + 4(填充) + 8(指向虚函数的指针) = 24
virtual void f() { cout << "A::f" << endl; }
virtual void g() { cout << "A::g" << endl; }
virtual void h() { cout << "A::h" << endl; }
};
int main()
{
A ex1(4);
A* p;
cout << sizeof(p) << endl; //8字节 注意:指针所占的空间和指针指向的数据类型无关
cout << sizeof(ex1) << endl; //24字节
return 0;
}
请你回答一下malloc的原理,另外brk系统调用和mmap系统调用的作用分别是什么?
brk()
函数和sbrk()
函数
#include <unistd.h>
int brk(void* addr);
void* sbrk(intptr_t increment);
brk()
和sbrk()
函数可以改变堆的大小,brk()
设置.data
段的位置为参数addr
,sbrk()
增加.data
段increment
个字节。
通常,分配的内存小于128KB时,通过brk()
调用来获得虚拟内存,大于128KB时,通过mmap
函数来获得虚拟内存,进程先通过这两个系统调用获取或者扩大进程的虚拟内存,获得相应的虚拟地址,在访问这些虚拟地址的时候,通过缺页中断,让内核分配相应的物理内存,这样内存分配才完成。
malloc
采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位,当用户申请内存时,直接从堆区分配一块合适的空闲块。malloc
采用隐式链表结构将堆区分成连续的,大小不一的块,包含已分配块和未分配块;同时malloc
采用显式双向链表来管理所有的空闲块,每个空闲块记录了一个连续的,未分配的地址。
当进行内存分配时,malloc
会通过隐式链表遍历所有的空闲块,选择满足要求的块进行分配;当进行内存合并时,malloc
采用边界标记法,根据每个块的前后块是否已经分配来决定是否进行块合并。
-
空闲存储空间以空闲链表的方式组织(地址递增),每个块包含一个长度、一个指向下一块的指针以及一个指向自身存储空间的指针。( 因为程序中的某些地方可能不通过malloc调用申请,因此malloc管理的空间不一定连续。)
-
当有申请请求时,malloc会扫描空闲链表,直到找到一个足够大的块为止(首次适应)(因此每次调用malloc时并不是花费了完全相同的时间)。
-
如果该块恰好与请求的大小相符,则将其从链表中移走并返回给用户。如果该块太大,则将其分为两部分,尾部的部分分给用户,剩下的部分留在空闲链表中(更改头部信息),因此malloc分配的是一块连续的内存。
-
释放时,首先搜索空闲链表,找到可以插入被释放块的合适位置。如果与被释放块相邻的任一边是一个空闲块,则将这两个块合为一个更大的块,以减少内存碎片。
因为brk
,sbrk
,mmap
都属于系统调用,若每次申请内存因为brk、sbrk、mmap都属于系统调用,若每次申请内存,都调用这三个,那么每次都会产生系统调用,影响性能;其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果高地址的内存没有被释放,低地址的内存就不能被回收。 所以malloc采用的是内存池的管理方式(ptmalloc),Ptmalloc 采用边界标记法将内存划分成很多块,从而对内存的分配与回收进行管理。为了内存分配函数malloc的高效性,ptmalloc会预先向操作系统申请一块内存供用户使用,当我们申请和释放内存的时候,ptmalloc会将这些内存管理起来,并通过一些策略来判断是否将其回收给操作系统。这样做的最大好处就是,使用户申请和释放内存的时候更加高效,避免产生过多的内存碎片。
什么时候发生段错误?
- 错误的访问类型引起;
int main()
{
char *c = "hello world";
c[1] = 'H';
}
- 访问了不属于进程地址空间的内存,或者往受到系统保护的内存地址写数据;
int main()
{
int *p = (int*)0xC0000FFF;
*p = 10;
}
- 访问不存在的内存;
int* p = NULL;
*p = 1;
- 内存越界,数组越界,变量类型不一致等;
- 试图把一个整数按照字符串的形式输出;
- new一次,delete多次;
- 试图修改只读区,比如修改字面值常量;