C++ 内存与编译问题总结

目录

C++内存结构

作用域与生存周期

堆与栈

内存对齐

智能指针

shared_ptr 循环引用问题

编译与链接

内存泄漏

补充问题

include  “ ”与<>

 大端与小端


C++内存结构

C++程序内存分区

  • 代码区
    • 文件中所有的函数代码、常量以及字符串常量
    • 只读,保护程序不会被其他进程恶意修改
  • 全局/静态存储区
    • 存储全局变量和静态变量。这些变量在程序开始时分配对应内存空间,程序结束的时候则释放
    • 该区域还可以被进一步细分,初始化数据段(已经初始化的数据)、未初始化数据段
    • 动态分配的内存空间,类似于new 和 malloc分配的内存空间
    • 堆的大小是动态变化,程序员负责分配和释放
    • 栈的内存空间是编译器来自动管理,函数调用时分配空间,函数返回时释放内存区域
    • 存储函数调用的局部变量以及函数调用的返回地址
  • 常量存储区
    • 用于存储常量,不允许更改,程序结束的时候自动释放(例如初始化的字符串)

作用域与生存周期

总结

  • 全局变量
    • 作用域:全局作用域。全局变量在一个源文件中定义,可以作用其他所有源文件。不包含该定义的源文件,需要借助extern关键字再次声明该全局变量
    • 生命周期:程序运行的整个声明周期都存在,程序结束则自动销毁,由系统对资源进行回收
    • 注意:全局变量定义不要在头文件中定义,当其他文件包含该头文件时,该文件的全局变量就会被定义多次,编译时会导致重复定义出错。
  • 静态全局变量
    • 作用域:文件作用域。只作用于定义它的文件中,不同的源文件即使定义了相同的静态全局变量,两者也是不同的变量(全局变量是可以实现一次定义,全文件使用)
    • 生命周期:存在于程序的整个生命周期,与全局变量相同
  • 局部变量
    • 作用域:局部作用域。与函数的生命周期相对应,并非程序运行期间一直存在
    • 生命周期:生命周期只存在于函数调用的时候,函数调用结束后会自动销毁
  • 静态局部变量
    • 作用域:局部作用域。仅在声明静态局部变量的函数中可见
    • 生命周期:自从初始化后直到程序运行结束都一直存在

静态局部变量实验 

#include <iostream>

void staticVariableExample() {
    static int count = 0;  // 静态局部变量
    count++;
    std::cout << "Count: " << count << std::endl;
}

int main() {
    for (int i = 0; i < 5; ++i) {
        staticVariableExample();
    }
    return 0;
}

堆与栈

函数调用与栈

        当程序调用函数的时候,按照从 右往左的顺序,依次将 函数调用的参数压入栈中,并在栈中压入返回地址与当前的栈帧,然后跳转到调用函数内部。

栈空间分配测试(gdb) 

  • stact level 栈帧层 + 当前栈帧的起始地址、
  • saved rip :调用该函数的指定地址信息
  • called by frame at 地址:调用该函数的栈帧所在的地址,该地址是一个栈帧起始地址(是被哪一个栈调用的,即上一层的地址)
  • arglist at 地址,args:参数列表的地址以及当前函数参数的数值是多少
  • saved registers:保存的寄存器列表
  • rbp at 地址, rip at 地址:基址指针和指令指针的地址

//测试源码
#include <iostream>

void recursiveFunction(int a, int b) {
    int c = a + b;
    std::cout << "a: " << a << ", b: " << b << ", c: " << c << std::endl;
    if (a > 0) {
        recursiveFunction(a - 1, b + 1);
    }
} 

int main() {
    recursiveFunction(3, 1);
    return 0;
}

 栈溢出(程序运行中,栈的空间被耗尽,导致程序崩溃或者异常)

  • 原因分析
    • 深度递归:函数递归调用过深,每次递归的时候都会消耗响应的栈空间,最终导致栈溢出
    • 局部变量过大:栈空间内如果存储了较大的局部变量,同样会导致栈空间不足
    • 无限循环调用

#include <iostream>

void recursiveFunction() {
    int largeArray[10000]; // 大型局部变量
    std::cout << "Recursion" << std::endl;
    recursiveFunction(); // 无终止条件的递归调用
}

int main() {
    recursiveFunction();
    return 0;
}

 

  • 含义:进程运行时,需要动态申请内存空间存放数据时使用的空间,堆的内存空间由操作系统来管理(资源的释放由操作系统自动管理)
  • malloc 和 new从堆中申请内存,使用free 和 delete释放空间

 堆与栈的对比

  • 分配方式:栈编译器自动分配,堆由程序员手动分配
  • 生命周期:栈随函数调用来决定,堆则灵活,是由程序员控制
  • 内存大小:栈的内存空间通常固定且较小,堆的空间相对较大
  • 线程安全:栈的线程安全,堆的线程不安全(因为同一个进程下的内存,多线程都可以访问)
  • 场景:栈适用于局部变量、函数参数以及返回地址;堆则适用于动态数据结构以及大的数据块

内存对齐

含义

  • 编译器将程序中的每个数据的地址,都放在机器字长的整数被的地址所指向的内存中(数据按照计算机系统大小的倍数进行对齐)
  • 计算机内存的地址空间按照比特划分,但是实际情况上并不是严格按照顺序进行排放

内存对齐原因分析

  • 提高性能
    • 减少内存访问次数,从而提高性能
  •  硬件限制
    • 确保程序可以在不同硬件平台的正确性和兼容性
    • CPU访问按照机器字长为单位,不以字节为单位,机器字长又是由总线宽度决定
  • 简化硬件设计
    • 对齐的数据访问,减少了访问内存次数,从而提高内存访问效率

内存对齐规则

  • 基本对齐
    • 数据类型地址必须是该类型大小的倍数
  • 结构体对齐(重点)
    • 第一个成员在结构体变量偏移量的0位置
    • 结构体中的每个成员必须是该成员大小的整数倍
      • VS系统中,要与8比较,取较小值
    • 结构体的总大小必须是该结构体中最大成员大小的倍数,从而保证数组中每个结构体实例的对齐 
    • 例子分析
      • char a :占用1个字节,因为下一个类型是int,所以该处需要填充3字节,从而保证int的对齐
      • int b:占用4个字节
      • short c :占用2字节,所以向下填充两个字节,最终大小要保证是最大字节的倍数,即4的倍数,所以需要填充2个字节
      • 所以最终计算的结果是12字节

智能指针

智能指针管理动态分配的内存和其他资源,可以实现避免内存泄漏,其本身遵循RAII原则,实现自动创建内核和销毁内存。

unique_ptr 

  • 独占对象所有权:该指针指向的对象,只可以被该指针所占有,不能够拷贝构造和复制,但是可以移动构造和移动复制构造(即一个uinque_ptr 的对象赋值给另一个uinque_ptr 对象)
  • 作用:轻量,因为只指向一个资源,开销小;适用于资源独占的场景,利于在socket编程中以及互斥锁

#include <memory>
#include <iostream>

struct Foo {
    Foo() { std::cout << "Foo constructed\n"; }
    ~Foo() { std::cout << "Foo destroyed\n"; }
};

int main() {
    std::unique_ptr<Foo> ptr1(new Foo());
    // std::unique_ptr<Foo> ptr2 = ptr1; // 错误,不能复制
    // std::unique_ptr<Foo> ptr2(ptr1);..错误
    std::unique_ptr<Foo> ptr3 = std::move(ptr1); // 转移所有权
    return 0;
}

 shared_ptr

  • 资源共享:多个shared_ptr指针可以指向同一个对象,指针内部会维护一个引用计数
  • 使用
    • use_count():查看资源使用者个数
    • release():释放资源所有权,每一次释放会将计数减1,直到引用计数减到0,才对资源进行释放
    • shared_ptr不是线程安全的,其内部的引用计数是原子性的

#include <memory>
#include <iostream>

struct Foo {
    Foo() { std::cout << "Foo constructed\n"; }
    ~Foo() { std::cout << "Foo destroyed\n"; }
};

int main() {
    std::shared_ptr<Foo> ptr1(new Foo());
    std::shared_ptr<Foo> ptr2 = ptr1; // 共享
    return 0;
}

weak_ptr

  • 作用:指向share_ptr指针所指向的对象,目的是解决shared_ptr所带来的循环引用(两个shared_ptr指针互相指)问题
  • 使用
    • weak_ptr 转换为shared_ptr的时候,可以访问shared_ptr所指向的资源,但是不会影响share_ptr指针的计数
    • shared_ptr被weak_ptr指向时的释放:一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放,即使有weak_ptr指向对象,对象也还是会被释放。
    • 使用weak_ptr访问对象的时候,需要调用lock去检查weak_ptr所指向的对象是否存在:如果存在则会返回一个shared_ptr的指针

shared_ptr 循环引用问题

总结:两个对象相互引用对方的shared_ptr的时候,双方的引用计数都不会变成0,所以导致对象都不会被销毁,最终会导致内存泄漏

#include <iostream>
#include <memory>

class B; // 前向声明

class A {
public:
    std::shared_ptr<B> ptrB;
    ~A() { std::cout << "A destroyed" << std::endl; }
};

class B {
public:
    std::shared_ptr<A> ptrA;
    ~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        a->ptrB = b;
        b->ptrA = a;
    } // a和b出了作用域,但不会被销毁
    std::cout << "End of main" << std::endl;
    return 0;
}

weak_ptr打破循环引用思路

  • a持有一个指向b的shared_ptr指针,这样就增加了b的引用计数
  • b持有一个指向a的weak_ptr指针,但是这样不会增加a的引用计数
  • 销毁过程
    • a和b两者超出作用域后,其shared_ptr引用计数会递减
    • 引用计数降为0的时候,a被销毁
    • a销毁后,其内部成员ptrB(指向b的shared_ptr)也会被销毁,所以此时b的引用计数也就会降至0,所以b也就被销毁了
#include <iostream>
#include <memory>

class B; // 前向声明

class A {
public:
    std::shared_ptr<B> ptrB;
    ~A() { std::cout << "A destroyed" << std::endl; }
};

class B {
public:
    std::weak_ptr<A> ptrA; // 使用weak_ptr打破循环引用
    ~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        a->ptrB = b;
        b->ptrA = a; // 使用weak_ptr
    } // a和b出了作用域,会被正确销毁
    std::cout << "End of main" << std::endl;
    return 0;
}

编译与链接

编译的处理过程代码转换为二进制指令==编译器读取源文件,源文件翻译成ELF,ELF文件经过操作系统进行加载执行)

  • 预处理:处理源代码中的预处理指令。取出注释以及头文件、条件编译指令、宏的替换等
  • 编译:CPP源文件翻译成.s的汇编代码。
  • 汇编:汇编代码.s 翻译成机器指令.o文件,一个cpp文件只可以生成一个.o文件
  • 链接:将生成的.o文件打包成一个整体,从而生成一个可被操作系统加载执行的ELF程序文件。

静态链接

  • 静态链接是将代码运行所需要的库函数在编译的时候,全部放到可执行文件中。生成的可执行文件就包含的所依赖的库代码,所以在运行的时候,不需要外部库的支持。
  • 过程
    • 编译源文件,生成目标文件
    • 链接器将目标文件和库文件合并成一个可执行文件
  • 编译链接实验步骤说明
    • g++ -c library.cpp -o library.o :仅编译,将cpp文件输出称为library.o文件
    • ar rcs libstatic.a library.o:创建静态库 libstatic.a,并将library.o文件添加进去
    • g++ main.cpp -L. -lstatic -o static_example:main文件连接上创建的静态库,创建出可执行文件
root@hcss-ecs-b4a9:/home/test/test_o# tree
.
├── library.cpp
├── library.o
├── libstatic.a
├── main.cpp
└── static_example
g++ -c library.cpp -o library.o
ar rcs libstatic.a library.o
g++ main.cpp -L. -lstatic -o static_example
./static_example

//library.cpp

#include <iostream>

void libraryFunction() {
    std::cout << "Library function called!" << std::endl;
}

//main.cpp
#include <iostream>
extern void libraryFunction();

int main() {
    std::cout << "Hello, static linking!" << std::endl;
    libraryFunction();
    return 0;
}

动态链接

  • 运行时将程序与库函数链接。只要在文件运行的时候,再加载对应的动态库
  • 过程分析
    • 编译源文件生成目标文件(.o)
    • 编译器生成的可执行文件中包含对动态库引用
    • 程序运行的时候,动态加载器加载所需要的动态库
  • 编译链接步骤说明
    • g++ -fPIC -c library.cpp -o library.o
      • -fPIC:因为动态库加载的时候可能会被映射到内存的不同位置,所以需要生成位置无关代码
      • -c :仅编译不链接
    • g++ -shared -o libdynamic.so library.o(创建动态库)
      • -shared:生成动态库
      • 指定.so的名字,并输入目标文件
    • g++ main.cpp -L. -ldynamic -o dynamic_example((编译链接main,同时生成可执行文件)
      • -L:指定链接时搜索库文件的目录,在此处表示库文件在当前目录
      • -ldynamic:动态库的名字" l+ dynamic)
    • export LD_LIBRARY_PATH=.
      • 将动态库的目录添加到LD..这个环境变量中

 

//library.cpp

#include <iostream>

void libraryFunction() {
    std::cout << "Library function called!" << std::endl;
}


//main.cc
#include <iostream>
extern void libraryFunction();

int main() {
    std::cout << "Hello, dynamic linking!" << std::endl;
    libraryFunction();
    return 0;
}

 动静态链接对比

特征静态链接动态链接
文件大小
运行速度
依赖性不依赖外部库依赖外部库
更新困难(重新编译和链接)容易(直接更新静态库即可)
内存       大(每个程序都有独立副本)少(多程序共享库)
版本确保一致(编译时确定)可能不一致(运行时确定)
使用方便复杂

内存泄漏

内存泄漏即是在堆中动态申请的内存,程序使用结束时没有及时释放,生命周期已结束,但是该变量在堆中的空间没有释放,从而导致堆中可使用的内存越来越少,最终导致系统变慢或者内存不足而崩溃。

防止内存泄漏

  • 内部封装: 内存分配封装到类中,构造时创造,析构时释放
  • 智能指针:本身就是一个防止内存泄漏的工具,内部封装了自动释放机制
  • 良好编码习惯、使用内存泄漏检测工具

Valgrind 工具检测内存泄漏

//测试代码
#include <stdio.h>
#include <stdlib.h>

void memory_leak() {
    int *leak = (int *)malloc(100 * sizeof(int)); // 分配内存,但没有释放
    if (leak == NULL) {
        fprintf(stderr, "Memory allocation failed\n");
        exit(1);
    }
}

int main() {
    for (int i = 0; i < 100; ++i) {
        memory_leak();
    }
    printf("Memory leak example finished\n");
    return 0;
}

补充问题

include  “ ”与<>

#include(关键字用于标识源代码编译时所需引用头文件,编译器自动查找头文件中信息)

  • #include<>:用于包括标准库头文件。编译器在预先指定的搜索目录中进行搜索,/usr/include目录。
  • #include" " :用于包括标准库头文件。先在当前源文件目录中查找。如果没有,再到当前已经添加的系统目录中(编译时-I 指定的目录)查找,最后在 /usr/include目录查找

__has_include

  • C++17的特性,用于检查是否已经包含了某个文件
#include <iostream>

int main()
{
#if __has_include(<cstdio>)
    printf("c program");
#endif

#if __has_include("iostream")
    std::cout << "c++ program" << std::endl;
#endif

    return 0;
}

 大端与小端

大小端含义

  • 大端字节序:最高有效字节存储在最低地址
  • 小端字节序:最低有效字节存储在最高地址

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值