您现在的位置是:首页 >技术教程 >一文详解C++编程-内存模型 (内存分区,作用域与链接性,自动存储持续性,静态存储持续性,动态存储持续性,线程存储持续性)网站首页技术教程

一文详解C++编程-内存模型 (内存分区,作用域与链接性,自动存储持续性,静态存储持续性,动态存储持续性,线程存储持续性)

半只野指针 2024-07-04 11:18:06
简介一文详解C++编程-内存模型 (内存分区,作用域与链接性,自动存储持续性,静态存储持续性,动态存储持续性,线程存储持续性)

C++编程-内存模型

翻译单元

翻译单元(Translation Unit)是指在编译程序对源代码进行编译时,编译器实际处理的一份源代码文件和它所包含的所有头文件内容。在C++中,翻译单元作为编译的最小单位,被编译器分别进行编译。一个翻译单元通常包含了函数、变量、类和模板等定义以及与它们相关的声明。

在C++中,每个翻译单元会首先被预处理器处理,将所有的预处理指令展开成为实际的代码。然后编译器将这些代码转化成汇编代码,最终生成目标文件或可执行文件。

需要注意的是,如果多个翻译单元中存在同名的全局变量、函数或类,那么编译器在链接过程中就会出现问题。因此,在设计程序时,应该避免在不同的翻译单元中定义具有相同名称的实体。

作用域与链接性

作用域

作用域描述的是在翻译单元中该名称在多大的范围内是可见的,例如一个函数中定义的变量在另一个函数中是不能使用的;但是在整个翻译单元中,在所有函数定义之前的变量是可以被所有函数使用的,如下代码:

#include<iostream>
using namespace std;

const int a = 114514;

void fun1(){
    int aFun = 996;
    cout<<a<<" "<<aFun<<endl;
    // cout<<bFun<<endl;
}

void fun2(){
    int bFun = 777;
    cout<<a<<" "<<bFun<<endl;
    // cout<<aFun<<endl;
}

int main(){
    fun1();
    fun2();
    return 0;
}

两函数调用对方定义的变量是失败的,但是调用全局变量是成功的

需要注意的是,一般情况下某名称的定义处就是其作用域起始点(排除一些特殊声明),并且局部代码块中的相同名称具有隐藏全局名称的特性

链接性

链接描述了该名称在不同翻译单元之间的共享,链接性为外部名称可以在翻译单元间共享,链接性为内部只能为当前一个翻译单元中的函数共享

链接性为外部

在多文件程序中,如果一个名称在连接时可以与其他编译单元交互,那么该名称就具有外部链接,具有外部链接的名称将被引入到目标翻译单元中,并且经由连接程序处理,同时这个名称必须是唯一的,通常有以下的外部定义:

  • 非内联的成员函数,例如:returnType className::meberFunction(dataType dataList)这样的函数定义
  • 非内联的非静态普通函数,例如:returnType functionName(dataType dataList)即刚接触的C++时遇到的函数
  • 非静态的全局定义

示例代码:

//
//classTest.h
//

#ifndef CLASSTEST_H_
#define CLASSTEST_H_

#include<string>
#include<iostream>
extern std::string ikun;  // 声明非静态全局变量 并且说明该变量定义在其他文件

class classTest{
public:
    void print();  // 在源码文件中定义非内联成员函数
};

void printData();   // 声明非内联非静态的普通函数 未写extern的情况下 函数外部链接性是默认的

#endif
//
//calssTest.cpp
//
#include "classTest.h"
std::string ikun = "sing jump rap basketball";

void classTest::print(){
    std::cout<<"Hello World"<<std::endl;
}

void printData(){
    std::cout<<"This is function test"<<std::endl;
    std::cout<<ikun<<std::endl;
}
//
// main.cpp
//
#include "classTest.h"

int main(){

    classTest object;
    object.print();
    printData();
    std::cout<<ikun<<std::endl;
    ikun = "Hello World";
    printData();
    std::cout<<ikun<<std::endl;
    return 0;
}

可以看到外部链接性的代码是可以供其他翻译单元代码进行交互,即它们被带入到了当前的编译单元中,并且在修改之后,其他模块使用时也发生了改变

链接性为内部

在程序中一个名称对于它自身所在的翻译单元是局部的,进行连接时不可能与其他编译单元中的相同名称相冲突,并且具有内部连接的名称不会被链接到目标翻译单元中,也就是不能够与其他翻译单元交互,有以下的内部链接性:

  • 静态全局变量定义
  • 类的定义
  • 内联函数的定义
  • 枚举体,共用体的定义
  • 名称空间中的const常量

这里类的内部链接性是取决于定义方式,如果说它是类文件,那么使用对应的头文件就可以在其他编译单元中访问,如果是在单独一个文件中的类(枚举体与共用体)定义那将是无法访问的(例如下面自动存储持续性中的代码)。

内联函数就更好理解了,它本质上是空间换时间,类似于代码块的替换,我们主要介绍静态全局变量与名称空间中的const常量

静态全局变量(注意下面的代码是错误的)

//
// externTest
//
#include<iostream>
static int data1 = 12345;
//
// main.cpp
//
#include<iostream>

int main(){

    extern int data1;  // 构建失败 不能够使用其他文件中的静态全局变量
    std::cout<<data1<<std::endl;

    return 0;
}

命名空间中的const(同样的此代码同样是构建失败的)

//
// file.h
//

#ifndef UNTITLED1_FILE_H
#define UNTITLED1_FILE_H

namespace mySpace{
    extern const int a ;  // 在头文件中声明
}

#endif //UNTITLED1_FILE_H

//
// file.cpp
//

#include "file.h"

namespace mySpace{
    const int a = 22 ; // 在源文件中定义 
}
#include <iostream>
#include"file.h"

int main() {
    std::cout<<mySpace::a<<std::endl;  // 构建失败 不能够在定义外的文件使用 链接性是内部的
    return 0;
}

由于这种内部链接性不会将对应的名称链接到对应的编译单元,所以在同一项目中的不同编译单元中可以使用重名的名称而不冲突

头文件的链接性

头文件和源文件的链接性是不同的。

头文件仅包含声明,它们通常被用来共享代码和提供接口给其他源文件使用。头文件本身并不会被编译成目标文件,也没有与之对应的符号表。因此,头文件不会产生任何链接性。

相比之下,源文件中的变量、函数和其他类型的符号都会在编译时被翻译成可执行代码或者目标文件,并且会在链接阶段与其他目标文件合并。这些符号具有不同的链接属性,例如:

  • 外部符号:在一个源文件中定义的变量或函数可以被其他源文件访问,这种符号称为外部符号(external symbol)。
  • 内部符号:在一个源文件中定义的变量或函数如果只能在当前文件中访问,就称为内部符号(internal symbol),使用static关键字可以将其声明为内部符号。
  • 公共符号:如果多个源文件中定义了同名的外部变量或函数,那么需要使用extern关键字将它们声明为公共符号(common symbol),链接器会将这些定义合并起来。

总之,头文件不会产生链接性,它们只是用来共享代码和接口;源文件中的符号会在编译和链接阶段产生链接性,并且具有不同的链接属性。

代码块

代码块是由花括号中包含的一系列代码,函数就是一种常见的代码块,我们也可以在函数之中嵌入代码块,下面给出最简单示例:

#include <iostream>
using namespace std;

int main(){

    {
        int flag = 11;
        cout<<flag<<endl;
    }

    return 0;
}

基于这种特性,我们不难理解,为什么循环结构与分支中定义的变量是无法在该结构外使用的,因为不属于该代码块的代码对于其外的部分是透明的,换句话说,他们的作用域被局限在了代码块内

如下我们强行使用,编译器将抛出错误:

在这里插入图片描述

C++的四种内存管理策略

自动存储持续性

默认情况下,在函数中定义的变量与函数的参数的存储与销毁是自动的,它们在对应的代码中被创建,执行完成过后就会被释放,并且将内存归还给系统,我们以单文件的类示例(实际上这样写是不符合规范的):

#include <iostream>
using namespace std;

class Test{
    public:
        Test(){ cout<<"new object Test"<<endl; }             // 建立对象提示
        ~Test(){ cout<<"delete object Test"<<endl; }         // 销毁对象提示
        // Test(const Test &x){ cout<<"copy class Test"<<endl; }        // 对象拷贝提示
        void alive(){ cout<<"this object is alive"<<endl; }      // 对象使用检验
};

void function(Test copyObject){ cout<<"function is action"<<endl; }  // 函数内对象作为参数
void function(){ Test functionObject; }

int main(){

    Test object;
    function(object);
    // copyObject.alive();      // 尝试使用函数的参数对象

    cout<<"--------------------------------------------"<<endl;

    function();
    cin.get();  /* 使用cin.get()防止主函数终止 
                   使对象的创建与销毁均取决于函数function() */
                   
    return 0;
}

这里的变量是我们的 Test类对象,我们在其中使用了 cin.get()来打断程序,我们也可以使用编译器的断点功能来实现。

接下来我们先看第一部分运行结果:

在这里插入图片描述

这里主函数先创建了对象 object并且调用其构造函数,在函数运行完成后我们尝试调用其参数中的对象 copyObject运行失败,则验证了函数的参数是符合自动存储持续性的(这里由于传值调用是使用的拷贝构造函数,所以并无临时变量的构造函数参与)。

看第二部分运行结果:

在这里插入图片描述

第二部分相对于第一部分更加明显,函数中的变量创建并销毁(按任意键结束此程序时,还会调用一次析构函数,析构开始的 Test object;所创建的对象)

在这种默认情况下,创建的变量的作用域为局部,并且他们是没有链接性的,当程序开始执行代码块时他们被自动分配,代码块结束后被程序回收,他们的可见时间与作用域被完全限制

自动变量与栈

对于这种符合自动存储持续性的变量,我们称之为自动变量(注意不要与auto混淆),因为他们的存在完全由系统管理。

程序在运行时为了管理自动变量,会留出一段内存,称之为栈,并且新的数据被象征性地放在原有数据上面(即放在相邻的内存单元中,而不是同一个内存单元中。

程序使用两个指针来跟踪栈,一个指向栈顶,一个指向栈底。当代码块(一般情况下)运行时,自动变量将被加到栈中,栈顶指针指向变量后地下一个可用内存单元,代码块结束时,栈顶指针重置到开始时位置,并释放自动变量 的内存。

静态存储持续性

在函数定义外定义的变量与使用 static修饰的变量的存储持续性均为静态,他们作用时间是整个程序的存活周期,并且他们有三种链接性:外部内部无链接性,并且他们在运行期间是唯一的,程序无需使用栈来管理它,并且在未显式声明它时,编译器将会把它设置为0。

分别定义三种不同链接性代码:

#include <iostream>

int global = 233;  // 链接性为外部
static int one = 344;  // 链接性为内部

void function(){
    static int two = 455;   // 无链接性
}

注意不要将静态变量与静态存储持续性变量相混淆

静态持续性变量的初始化

静态初始化的两种方式

零初始化:在未初始化状态下的静态持续性变量,系统会默认采取零初始化,这种初始化对于标量类型将会进行转化,如空指针,结构体,他们的表示可能是0,但是其内部表示却有可能不是0。如指针的NULL与结构体填充位置零

常量初始化:对于已有的静态持续性变量,我们采用常量对其进行初始化,如上述代码段中的 int global = 233;就是使用的常量初始化,同时常量表达式也可用于其初始化(constexpr也增加创建常量表达式的方式)

动态初始化

#include <iostream>

int function();

int data = function();

如上述这种使用某函数的返回值作为静态持续性变量的值,我们称为动态初始化

静态持续性与外部链接(单定义原则)

在每使用一个外部变量之前都应该声明它是一个外部变量,对于这种可供不同翻译单元访问的外部变量,我们需要保证在整个文件中只被定义了一次,由此有以下两种声明:

定义声明:在声明一个变量时即为它分配内存

引用声明:引用已有的变量,不分配内存,使用 extern在当前翻译单元中声明此变量,表明此变量在其他单元中被定义(使用具有外部链接性的外部名称的方法)

注意:单定义原则不意味着不可以使用相同名称,在合法的同名名称变量均有自己独有的地址,并且保有在合法作用域下唯一的定义

静态持续性与内部链接

在同一项目中使用的两个文件中同时定义同名称非静态全局变量将会产生二义性,此时我们可以创建静态持续性变量来隐藏普通全局性变量,需要注意的是,这里的隐藏不是指全局变量与静态变量的隐藏关系,而是指在链接阶段两不同翻译单元间可以使用不同链接性的相同名称而不冲突,如下示例代码:

//
//  main.cpp
//
#include <iostream>

int function();
int a = 1;
int b = 2;

int main() {
    function();
    std::cout <<a<<" "<<b << std::endl;
    return 0;
}
//
// file.cpp
//
#include <iostream>

extern int a;
static int b = 3;

int function(){
    std::cout<<a<<" "<<b<<std::endl;
    return 0;
}

如果你在一个文件中使用extern并且定义同名称的静态全局变量,这将违背单定义原则,导致二义性

静态持续性与无链接

在代码块中创建的静态变量无链接性,在代码块中使用static修饰的变量具有静态持续性,它的存活周期贯穿整个程序存活周期,在代码块不活跃时仍然存在于内存之中,在两次代码块调用期间仍然保持不变,并且作用域仅有该代码块,作用时间也仅有代码块运行时间

#include <iostream>

void function(int x){
    static int sum = 0;
    sum +=x;
    std::cout<<"Function's sum :"<<sum<<std::endl;
}

int main(){
    static int sum = 233;
    for(int i=0;i<=3;i++)
        function(i);
    std::cout<<"Main's sum :"<<sum<<std::endl;
    return 0;
}

得到如下运行结果:

在这里插入图片描述

动态存储持续性

使用 new关键字分配的内存一直存在,直到程序结束自动释放,或者指针被delete释放,并且在此情况下,这种内存管理方式并不由作用域与链接性规则控制,而是直接由newdelete控制

注意:在一些大型程序中,有些时候需要申请大量的区块内存,这些内存可能不会被自动释放,需要使用delete

  • new运算符初始化操作
#include <iostream>
#include <iomanip>

int main(){

    double *p1 = new double(3.14159);  // 单个变量的初始化
    std::cout<<*p1<<std::endl;

    int *p2 = new int [6]{233,233,233}; // C++11及其之后版本支持这样初始化数组或结构
    for(int i=0;i<6;i++)
        std::cout<<*(p2++)<<" ";
    
    std::cout<<std::endl;

    double *p3 = new double{3.1425926535};  // C++11及之后的版本支持单个变量的初始化的另一种方式
    std::cout<<std::setiosflags(std::ios::fixed)<<std::setprecision(10)<<*p3<<std::endl;

    return 0;
}

有以下运行结果:

在这里插入图片描述

由于此处仅示例动态存储持续性,new的内存分配错误抛出,替换函数,分配函数,定位运算,重载定义运算不做详细讨论

线程存储持续性

在多线程CPU中,我们如果以thread_local声明一个变量,那么它的生命周期将会与所属线程生命周期一样长,下面给出简单示例:

#include <iostream>
#include <thread>

thread_local int test = 1;

void threadFunctionOne()
{
    test += 2;
    std::cout<<"This is thread t1's id : "
            <<std::this_thread::get_id()<<"    It's value is : "<<test<<std::endl;
}

void threadFunctionTwo()
{
    test += 3;
    std::cout<<"This is thread t2's id : "
            <<std::this_thread::get_id()<<"    It's value is : "<<test<<std::endl;
}

int main()
{
    std::cout<<"This is thread main's id : "
            <<std::this_thread::get_id()<<"    It's value is : "<<test<<std::endl;


    std::thread t1(threadFunctionOne);
    t1.join();

    // 让两线程错开运行

    std::thread t2(threadFunctionTwo);
    t2.join();

    return 0;
}

我们得到如下运行结果:

在这里插入图片描述

我们可以看到,不同线程间的test变量是独立的,互不干扰的,并且对应线程结束,他们也紧跟着被销毁

函数链接性与语言链接性

  • 函数链接性

在C/C++中不允许在一个函数中定义另一个函数,因此所有函数都是静态存储持续性的,并且在默认情况下,它们的链接性都是外部的,即可以在翻译单元之间共享,同时我们也可以在函数原型中声明 extern表明其在其他翻译单元中被定义,若要将其置为内部链接性,则使用 static进行修饰,此时在本翻译单元内将掩盖其他外部链接性定义,即本翻译单元中该名称仅对应该静态函数,这与静态持续性的内部链接性变量是一致的

注意:对于外部链接性的函数,其定义有且仅有一个,包括非自定义库文件中的名称,由于内联函数不受此规则约束,所以当内联函数置于头文件中时,包含此头文件的所有翻译单元都存在该内联函数定义,并且为了避免二义性,要求同名称函数的内联定义必须是一致的

  • 语言链接性

对于外部链接性函数的单定义规则和C++中的函数重载,他们的同时实现依托于语言链接性,就如同零初始化那样,在代码中的定义与内存中的定义可能并不一致,就像Python对类成员进行的类似于重命名的操作一样,C++也提供了此类操作,即语言链接性,并且在C++中我们可以指定其链接性:

extern "C" void function(int); // 以C的方式指定
extern void function(int); // 隐式声明 以C++方式
extern "C++" void function(int); // 显式声明 以C++方式

有意思的是,C++中还可以使用其他语言的语言链接性这里不做详细讨论

C++链接查找顺序

  • 对于静态成员,编译器将在本翻译单元中查找函数定义,否则(即外部链接性的成员)编译器将在所有编译单元中查找,若找到相同名称且违反单定义原则,将会报错
  • 在所有编译单元中均未找到,将会在库中进行搜索,并且在存在自定义库时,对于相同名称将优先使用自定义库中的名称

基于四大存储性的内存分区

代码区

代码区存放函数体的二进制代码,由操作系统进行管理

代码区内容:存放二进制的CPU运行指令,并且代码区是共享的,目的是让频繁运行的代码在内存中只有一份,代码区是只读的,防止运行时程序的指令被意外地修改

全局/静态区

全局区存放全局变量,静态变量以及常量

全局区内容:存放全局变量与静态变量,存放数字,字符,字符串常量,与其他常量(const修饰的名称),该区域数据在程序结束后由操作系统释放

注意:代码区与全局/静态区在程序运行前就已经分配好了,这两个区域用来存放所有静态存储持续性变量

栈区

存放所有自动存储持续性变量,栈区由编译器自动分配释放,存放函数的参数值,局部变量等,栈区存放代码块中定义的局部变量,栈区中的数据均是自动存储持续性变量

堆区

存放所有动态存储持续性变量,即存放new与delete操作的变量

注意:在程序运行之后才存在栈区与堆区,它们存放的数据是不同的

参考资料

C++官方文档
菜鸟教程
C++ Primer Plus

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。