Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

在C C++应用程序中检测内存泄漏 Detecting Memory Leaks in CC++ Applications[进行中] #220

Open
WangShuXian6 opened this issue Nov 9, 2024 · 7 comments
Labels
C C 语言 C++

Comments

@WangShuXian6
Copy link
Owner

在C C++应用程序中检测内存泄漏 Detecting Memory Leaks in CC++ Applications

01 - 介绍
002 进程内存布局
003 指针
004 使用 Visual Studio
005 在 Visual Studio 中调试
006 字节顺序
007 C 内存分配函数概述
008 C 内存分配函数 - malloc, calloc
009 C 内存分配函数 - realloc


02 - C++ 中的动态内存分配
001 C++ 分配运算符
002 new 的工作原理 - 第一部分
003 new 的工作原理 - 第二部分
004 处理 new 分配失败 - 异常
005 处理 new 分配失败 - 处理程序
006 处理 new 分配失败 - nothrow
007 无抛出 new 示例 - 第一部分
008 无抛出 new 示例 - 第二部分
010 Placement new - 第一部分
011 Placement new - 第二部分
012 Placement new - 第三部分
013 Placement new - 第四部分
014 Placement new - 第五部分
015 运算符 new 和 delete 函数


03 - 内存管理问题
001 内存管理问题
002 未初始化指针 - 第一部分
003 未初始化指针 - 第二部分
004 缓冲区溢出 - 栈
005 缓冲区溢出 - 堆
006 悬空指针 - 第一部分
007 悬空指针 - 第二部分
008 内存泄漏 - 第一部分
009 内存泄漏 - 第二部分


04 - 检测堆损坏
002 字符串类 - 第一部分
003 字符串类 - 第二部分
004 字符串类 - 第三部分
005 字符串类 - 第四部分
006 检测字符串类中的堆损坏
007 堆检查器类 - 第一部分
008 堆检查器类 - 第二部分
009 堆检查器类 - 第三部分
010 堆检查器类 - 第四部分
011 堆检查器类 - 第五部分


05 - 检测内存泄漏
001 _CrtDumpMemoryLeaks() 函数
002 _CrtDumpMemoryLeaks() 代码示例
003 泄漏检测标志
004 new 的详细泄漏转储
006 内存快照
007 内存快照 - 代码示例
008 内存检查点助手类
009 检查点问题及解决方案
010 Visual Studio 中的快照 - 第一部分
011 Visual Studio 中的快照 - 第二部分
013 报告模式和类型
014 报告模式和类型 - 代码示例
015 报告模式文件


06 - 自定义泄漏检测器
002 泄漏检测内部实现
003 内存块头和 ptmalloc() 函数
004 ptfree() 函数的实现
005 PtDumpLeaks() 函数的实现
006 添加 C++ 支持 - 第一部分
007 添加 C++ 支持 - 第二部分
008 在 Linux 上编译


07 - 堆损坏支持
001 检测堆损坏 - 内部实现
002 PtCheckMemory() 实现 - 第一部分
003 PtCheckMemory() 实现 - 第二部分
004 对齐与结构填充
005 对齐内存块头


01 - Introduction
002 Process Memory Layout
003 Pointers
004 Using Visual Studio
005 Debugging In Visual Studio
006 Byte Ordering
007 C Allocation Functions Overview
008 C Allocation Functions - malloc, calloc
009 C Allocation Functions - realloc


02 - Dynamic Memory Allocation in C++
001 C++ Allocation Operators
002 How new works - Part I
003 How new works - Part II
004 Handling new Failure - Exception
005 Handling new Failure - Handler
006 Handling new Failure - nothrow
007 Non-throwing new Example - Part I
008 Non-throwing new Example - Part II
010 Placement new - I
011 Placement new - II
012 Placement new - III
013 Placement new - IV
014 Placement new - V
015 Operator new & delete Functions


03 - Memory Management Issues
001 Memory Management Issues
002 Uninitialized Pointers - I
003 Uninitialized Pointers - II
004 Buffer Overflow - Stack
005 Buffer Overflow - Heap
006 Dangling Pointers - I
007 Dangling Pointers - II
008 Memory Leaks - I
009 Memory Leaks - II


04 - Detecting Heap Corruption
002 String class - I
003 String class - II
004 String class - III
005 String class - IV
006 Detecting Heap Corruption in String Class
007 Heap Checker Class - I
008 Heap Checker Class - II
009 Heap Checker Class - III
010 Heap Checker Class - IV
011 Heap Checker Class - V


05 - Detecting Memory leaks
001 _CrtDumpMemoryLeaks() function
002 _CrtDumpMemoryLeaks() Code Example
003 Leak Detection Flags
004 Detailed Leak Dump For new
006 Memory Snapshots
007 Memory Snapshots - Code Example
008 Memory Checkpoint Helper Class
009 Issues With Checkpoints (& Resolution)
010 Snapshots in Visual Studio - I
011 Snapshots in Visual Studio - II
013 Report Mode & Type
014 Report Mode & Type - Code Example
015 Report Mode File


06 - Custom Leak Detector
002 Leak Detection Internals
003 Memory Block Header & ptmalloc() Function
004 Implementation Of ptfree() Function
005 Implementation Of PtDumpLeaks() Function
006 Adding C++ Support - I
007 Adding C++ Support - Part II
008 Compiling on Linux


07 - Heap Corruption Support
001 Detecting Heap Corruption - Internal Implementation
002 PtCheckMemory() Implementation - I
003 PtCheckMemory() Implementation - II
004 Alignment & Structure Padding
005 Aligning Memory Block Header


@WangShuXian6 WangShuXian6 added C C 语言 C++ labels Nov 9, 2024
@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

01 - 介绍

002 进程内存布局

内存管理基础

在本节中,我将讨论内存管理的基础知识。我会介绍不同类型的存储区域,如栈、数据段和堆,并且还会解释如何在 C 和 C++ 应用程序中进行动态内存分配。具体来说,我将展示 C 语言中的内存分配函数,如 malloccallocrealloc,然后在 C++ 中,我将演示 newdelete 操作符的使用。我们还将探讨不同形式的 newdelete,如定位 new、通过 newnew 的修订版本。

进程基础

但首先,我想谈谈进程的基本概念。当程序启动时,操作系统会为该程序分配内存,这块内存被称为虚拟地址空间。虚拟地址空间为可执行文件提供了运行的环境。这块地址空间的大小取决于平台。在 32 位平台上,地址空间的大小通常是 4GB。在其他平台上,地址空间的大小可能会有所不同。

这块地址空间被划分为不同的部分,一些部分用于存储数据,比如全局数据、静态数据、局部数据和运行时数据。我们来看看从存储的角度看进程的不同部分。

内存分区

  • 低地址和高地址:这两个地址标志着内存的起始和结束位置。
  • 代码段(Code Segment):这部分存储程序的可执行代码。你在程序中编写的函数将会存储在代码段内。
  • 数据段(Data Segment):这部分存储全局变量和静态变量。
  • (Heap):用于动态分配内存。
  • (Stack):用于局部变量的存储。

从内存布局中可以看到,栈和堆之间有一个间隔。这里的箭头表示栈和堆在需要时会增长。堆会向低地址方向增长,而栈会向高地址方向增长。

局部变量的存储

我稍后会演示,函数中的局部变量是如何从高内存地址到低内存地址存储的。这是因为栈是从顶部向下增长的。

数据存储示例

以下是不同类型数据存储在虚拟地址空间中的示例:

  • 变量 jimcounter 都存储在数据段中。
  • 全局变量的作用域是整个程序范围,意味着这些变量可以在程序的任何地方访问,并且它们的生命周期与程序的生命周期相同。因此,这些变量会在程序终止时被销毁。

对于静态变量来说,其作用域仅限于定义它的函数内部。举例来说,counter 只能在定义它的函数中访问,而不能在函数外部访问。然而,静态变量的生命周期与整个程序的生命周期相同,意味着它们在程序结束时才会销毁。

局部变量

局部变量 copybyte 存储在栈中。它们的生命周期由函数控制,作用域仅限于函数内部。因此,当函数退出时,这些局部变量会自动销毁。

代码段

最后,代码段存储程序的可执行代码,即程序的各个语句。

内存布局的变化

需要注意的是,这些部分在虚拟地址空间中的位置并非总是固定的。许多现代操作系统实现了一种叫做“地址空间布局随机化”(ASLR, Address Space Layout Randomization)技术。ASLR 会使操作系统每次执行程序时将这些段放置在随机的位置。

在没有地址空间布局随机化的老旧系统中,程序的各个部分会在内存中固定位置存储,因此攻击者可能知道特定数据的地址,并能够将恶意负载注入到这些已知地址。而采用地址空间布局随机化后,攻击者很难知道数据或程序可执行文件的存储位置,这增加了攻击的难度。

总结

本节介绍了虚拟地址空间的不同部分以及它们在内存中的分布情况,尤其是栈、堆和数据段。接下来,我们将深入探讨动态内存分配,并了解如何在 C 和 C++ 中进行内存管理。

这就是本节的所有内容,下一个视频见!

003 指针

指针简介

欢迎回来!在本视频中,我将快速介绍指针。如果你已经熟悉指针的概念,可以跳过这个视频。

指针就像一个变量,但它不是存储一个值,而是存储一个内存地址。指针的语法与普通变量不同。一个指针可以持有一个变量的地址,也可以持有在堆上分配的内存地址,这部分内存被称为动态内存。

指针的用途

指针可以保存以下几种类型的地址:

  • 变量的地址:指针可以指向一个普通变量的内存地址。
  • 另一个指针的地址:指针也可以指向另一个指针。
  • 对象的地址:指针可以指向对象的内存地址。
  • 函数的地址:指针可以指向函数的内存地址。

示例

以下是一个关于指针存储不同类型内存地址的示例:

  • 栈内存:在这个例子中,main 函数内部声明的所有变量都存储在栈内存中。即使是指针变量,它们也会存储在栈内存中。
  • 堆内存:使用 new 操作符分配的内存位于堆上。指针 p2 指向通过 new 表达式分配的内存。
  • 数据段内存:全局变量 g_value 存储在数据段(或称数据段)中。指针变量 p3 指向存储在数据段中的地址。
  • 代码段内存:函数存储在代码段中。指针 FN 指向 main 函数的地址,即代码段内存中的地址。

总结

指针是一个非常强大的工具,可以让你访问和操作内存中的地址。无论是指向变量、指针、对象还是函数,指针都提供了灵活的内存访问能力。接下来,我们将通过 Visual Studio 查看一个例子,进一步加深理解。

谢谢收看,本节内容完毕!

004 使用 Visual Studio

在 Visual Studio 中创建 C++ 项目

在本视频中,我们将创建一个 C++ 项目,并简单介绍如何使用 Visual Studio 进行开发。Visual Studio 不仅可以用来开发 C++ 应用程序,还支持开发其他类型的项目,比如 C# 应用程序。

创建项目

  1. 选择新项目:在 Visual Studio 中,选择“新建项目”,然后你会看到多个不同类型的项目选项。我们需要选择 Windows Desktop Wizard,这属于 Visual C++ 的一部分。

  2. 指定项目名称和位置:在这里,我们将创建一个名为 Basics 的项目,并指定一个合适的位置来存储项目文件。你会注意到,项目的名称变化也会自动更新解决方案的名称。一个解决方案是一个容器,可以包含多个项目。当你创建一个项目时,默认情况下也会创建一个解决方案。

  3. 选择应用类型:当你点击“确定”时,它会询问你要创建哪种类型的应用程序。我们将始终选择 Console Application,因为它适合我们当前的需求。

  4. 选择空项目:在选项中选择 Empty Project 并取消勾选 Security Development Lifecycle checks,因为我们不希望 Visual Studio 为我们生成多余的代码。

添加 C++ 文件

  1. 右键点击项目:在 Solution Explorer 中,右键点击我们创建的空项目,选择 Add -> New Item

  2. 选择 C++ 文件:从文件类型中选择 C++ File,然后为它命名为 main。Visual Studio 会自动为你添加 .cpp 扩展名。

  3. 编写代码:在 main.cpp 文件中,添加如下代码:

#include <iostream>
using namespace std;

int main() {
    int x{5};  // 使用 C++ 的统一初始化语法
    cout << "Value of x: " << x << endl;
    cout << "Address of x: " << &x << endl;  // 输出 x 的内存地址
    return 0;
}

构建和运行项目

  1. 构建项目:选择 Build -> Build Solution,或者使用快捷键 Ctrl + Shift + B 来构建项目。

  2. 运行程序:构建完成后,你可以选择 Debug -> Start DebuggingStart Without Debugging 来运行程序。如果你使用的是 Visual Studio 2017 版本 15.8.7 或更高版本,程序窗口不会自动关闭。否则,旧版本的 Visual Studio 会在执行完程序后自动关闭控制台窗口。

输出和分析

在控制台输出中,你会看到两个信息:

  • x 的值:输出为 5
  • x 的地址:它显示了 x 的内存地址。

栈和内存地址

  • 由于 x 是一个局部变量,它的内存分配发生在 栈区。栈是用来存储局部变量的内存区域。

下一步,我们将使用调试功能进一步查看 x 在内存中的数据。更多内容将在下一个视频中详细讲解。

005 在 Visual Studio 中调试

Visual Studio 调试基础

在本视频中,我将向您展示如何使用 Visual Studio 进行基本的调试。

设置断点

  1. 添加断点:为了调试程序并在特定位置暂停执行,我们需要设置一个断点。在 Visual Studio 中,您可以通过点击左侧区域(即 gutter 区域)来添加断点。点击后,您会看到一个红色圆点,表示断点已经成功添加。

  2. 开始调试:按 F5(或者点击菜单中的“Start Debugging”)开始调试。程序会在遇到断点时暂停执行,这样您就可以逐行调试代码。

调试命令

  • Step Into (F11):当调试器遇到一个函数调用时,如果您使用 Step Into,它将进入这个函数内部并逐步调试函数代码。这个命令适用于您想深入查看某个函数实现的情况。

  • Step Over (F10):如果您不想进入函数内部调试,而只是想跳过函数的执行,可以使用 Step Over。这个命令会让调试器跳过函数调用并继续执行后续代码。

  • Step Out (Shift + F11):当您已经进入了一个函数并且完成了对该函数内部代码的调试时,您可以使用 Step Out 命令让调试器跳出当前函数,继续执行外层代码。

记住这些快捷键:

  • Step IntoF11
  • Step OverF10
  • Step OutShift + F11

停止调试

  • 停止调试:调试完成后,您可以选择让程序继续正常执行,或者手动停止调试。点击红色的停止按钮(或者使用快捷键 Shift + F5)来停止调试。

逐行调试

对于这个简单的程序,我们不需要设置断点,直接使用 Step Over (F10) 来逐行调试代码。在调试过程中,Visual Studio 会显示一些额外的窗口,这些窗口是用于调试的窗口。

调试窗口

  • Watch 窗口:您可以在 Watch 窗口 中观察变量的值。在这里,您可以直接输入变量名(如 x)来查看它的值。

  • Autos 窗口:这个窗口会自动显示当前作用域内的变量和它们的值。

您可以根据需要调整这些窗口的位置,将它们拖动到适合的位置。比如,我将这些窗口垂直排列,方便观察。

查看变量值

  1. 查看 X 的值:在 Watch 窗口 中输入 X,可以看到变量 X 的值。如果该变量还没有初始化,窗口中会显示为 unknown

  2. 修改 X 的值:您可以在调试过程中直接修改变量的值,而不需要停止调试并修改代码。在 Watch 窗口 中,您可以给 X 赋新值,调试器会实时更新变量的值。

查看内存地址

  1. 查看 X 的内存地址:为了查看变量 X 的内存地址,可以在 Watch 窗口 中输入 &X。这会显示出 X 的内存地址。

  2. 内存窗口:为了直接查看 X 在内存中的数据,可以使用 Memory 窗口。在该窗口中,输入 &X 来查看 X 的内存地址以及存储的值。由于 X 是一个整数,占用 4 个字节,因此它的内存表示也会以 4 个字节的方式显示。

字节顺序

当您在 Memory 窗口 中查看 X 的值时,您会注意到值可能以十六进制的形式显示。由于字节顺序(字节序)的问题,您可能会看到值显示顺序与预期不同。具体来说,整数的内存表示可能是反向的,这种现象是由 字节顺序(Big Endian 或 Little Endian)造成的。

通过右键点击 Memory 窗口,选择 Hexadecimal Display,您可以查看内存中的值的十六进制表示。

总结

通过调试功能,您可以逐行执行代码,查看变量的值,修改变量的值,甚至直接访问内存地址。这对于了解程序的执行流程、查找错误非常有帮助。关于字节顺序的问题,我们将在下一个视频中详细讨论。

下次见!

006 字节顺序

字节顺序(Byte Ordering)

在计算机的内存中,数据是以特定的顺序存储的,这个顺序被称为 字节顺序。字节顺序定义了字节在内存中存储的排列顺序。常见的字节顺序有两种:大端序(Big Endian)小端序(Little Endian)。下面我们将通过示例来了解这两种字节顺序。

大端序(Big Endian)与小端序(Little Endian)

  1. 大端序(Big Endian):在大端序中,最重要的字节(MSB,Most Significant Byte) 存储在内存的低地址,而 最不重要的字节(LSB,Least Significant Byte) 存储在内存的高地址。

  2. 小端序(Little Endian):与大端序相反,在小端序中,最不重要的字节(LSB) 存储在内存的低地址,而 最重要的字节(MSB) 存储在内存的高地址。

示例:字节顺序

假设我们声明了一个整数变量,并赋了一个很大的数字。这个数字将不会直接以这种方式存储在内存中,而是转换为等效的二进制格式。当你调试程序时,调试器通常以十六进制格式显示该数字,因为十六进制比二进制更易于阅读。

例如,假设整数的十六进制值为 0x12345678,这个值需要占用 4 个字节(因为整数通常需要 4 字节的内存)。我们可以将这个数字分成 4 个部分,每个部分占用一个字节。

  • 低地址:存储最重要的字节(MSB)
  • 高地址:存储最不重要的字节(LSB)

在大端序(Big Endian)中:

在大端序格式中,字节的存储顺序如下:

  • 低地址:存储最重要的字节(0x12
  • 接下来的地址:存储第二个字节(0x34
  • 接下来的地址:存储第三个字节(0x56
  • 高地址:存储最不重要的字节(0x78

在小端序(Little Endian)中:

在小端序格式中,字节的存储顺序如下:

  • 低地址:存储最不重要的字节(0x78
  • 接下来的地址:存储第三个字节(0x56
  • 接下来的地址:存储第二个字节(0x34
  • 高地址:存储最重要的字节(0x12

处理字节顺序的硬件架构

  • 大端序(Big Endian):用于 IBM Mainframe、Alpha 和 Sun SPARC 等平台。
  • 小端序(Little Endian):用于 Intel 和 Digital 等平台。

在 Visual Studio 中查看字节顺序

现在,让我们切换到 Visual Studio,查看该数字在内存中的表示,并通过调试器来识别字节顺序。

  1. 设置数字:我将初始化变量 X,并将其值设置为与示例中相同的数字。

  2. 查看十六进制格式:您可以右键点击变量并选择“Hexadecimal Display”来查看该数字的十六进制表示。

  3. 查看内存中的数据:我们查看变量 X 的内存地址,观察它是如何存储的。在小端序格式中,最不重要的字节首先存储在低内存地址,其他字节依次存储。最后,最重要的字节存储在高内存地址。

  4. 改变数字并查看存储情况:例如,我将 X 设置为一个较小的数字,如 25。这是一个小数字,因此它的十六进制格式很简单,其他位置的字节将为零。我们同样可以观察它在内存中的存储情况,确认它也遵循小端序格式。

小端序的实际应用

通过这个实验,我们可以确认当前机器使用的是小端序字节顺序,并且我的计算机使用的是 Intel 处理器。了解字节顺序对于后续编写内存泄漏检测器非常有帮助。我们在调试时经常需要查看内存中的数据,了解字节顺序能帮助我们更好地理解和分析内存中的值。

总结

  • 大端序小端序 是两种不同的字节顺序,决定了数据在内存中的存储方式。
  • 小端序 在 Intel 处理器中使用,而 大端序 在 IBM Mainframe 和其他一些平台中使用。
  • 在 Visual Studio 中,通过调试器可以直接查看变量的内存地址和字节顺序,这有助于我们理解程序的内存布局。

下次课我们将继续学习如何处理内存泄漏检测。再见!

007 C 内存分配函数概述

动态内存分配基础

动态内存分配是指在 上分配的内存。与在编译时分配的静态内存不同,动态内存是在 运行时 分配的。动态内存的作用范围与全局变量相同,这意味着只要你拥有该内存的地址,就可以在程序的任何地方访问该内存。接下来,我们将深入了解动态内存的生命周期、使用场景以及如何在 C++ 中进行动态内存分配。

动态内存的生命周期

动态内存的生命周期由程序员管理,而不是由系统自动管理。当堆上的内存不再需要时,程序员必须手动释放它。这与栈内存不同,栈内存的生命周期是由函数的作用范围决定的,函数返回时栈上的内存会自动释放。

为什么需要动态内存分配?

在程序中,某些情况下我们无法预先知道需要多少内存。比如:

  1. 数据量不确定
    假设你需要从外部源(如数据库)加载信息。比如,处理员工的薪资信息,而员工数量在编译时并不可知。此时,你无法为员工数据分配一个固定大小的内存空间。因此,动态内存分配非常重要。

  2. 数据结构的大小不固定
    如果你想构建数据结构,并且不希望给数据结构设置一个上限,比如你想构建一个不确定大小的数组或链表,动态内存可以让你在程序运行时根据实际需求分配内存。

  3. 更精细的生命周期控制
    栈上的数据生命周期由函数的作用范围决定,函数一结束数据就被销毁。这在某些情况下并不理想。例如,假设你在一个函数中加载了一些重要信息,其他部分的程序可能需要访问这些数据。如果数据是分配在栈上的,函数返回时数据会丢失。而如果数据是在堆上分配的,它可以在程序的任何地方访问。通过在堆上创建大对象,可以更精细地控制这些对象的生命周期,避免浪费宝贵的资源。

如何在 C++ 中进行动态内存分配

在 C++ 中,我们可以通过以下几种方式在运行时分配内存:

  1. malloc
    malloc 函数用于在堆上分配指定大小的原始内存块。分配的内存没有初始化,因此内存的内容是未知的。

  2. calloc
    calloc 函数与 malloc 类似,但它不仅分配内存,还会将内存块初始化为零。

  3. realloc
    realloc 函数用于改变已分配内存块的大小,可以增大或减小已有的内存块。

  4. free
    使用 malloccallocrealloc 分配的内存,需要通过 free 函数手动释放。

示例:如何在 Visual Studio 中使用这些函数

让我们通过一个简单的例子来演示如何在 C++ 中使用这些动态内存分配函数。

#include <iostream>
#include <cstdlib>  // 包含 malloc, calloc, realloc 和 free 的头文件

int main() {
    // 使用 malloc 分配内存
    int *arr = (int*) malloc(5 * sizeof(int));  // 分配 5 个整数大小的内存
    if (arr == nullptr) {
        std::cerr << "Memory allocation failed!" << std::endl;
        return 1;
    }

    // 使用 calloc 分配内存,并初始化为 0
    int *arr2 = (int*) calloc(5, sizeof(int));  // 分配 5 个整数并初始化为 0
    if (arr2 == nullptr) {
        std::cerr << "Memory allocation failed!" << std::endl;
        free(arr);  // 释放之前分配的内存
        return 1;
    }

    // 使用 realloc 修改已分配内存的大小
    arr = (int*) realloc(arr, 10 * sizeof(int));  // 重新分配内存,扩展为 10 个整数大小
    if (arr == nullptr) {
        std::cerr << "Memory reallocation failed!" << std::endl;
        free(arr2);  // 释放之前的内存
        return 1;
    }

    // 初始化数据
    for (int i = 0; i < 10; ++i) {
        arr[i] = i + 1;  // 为数组元素赋值
    }

    // 打印数据
    std::cout << "Array values: ";
    for (int i = 0; i < 10; ++i) {
        std::cout << arr[i] << " ";
    }
    std::cout << std::endl;

    // 释放内存
    free(arr);
    free(arr2);

    return 0;
}

代码解析

  1. malloc:我们使用 malloc 为一个整数数组分配了 5 个元素的内存空间。这个内存区域的内容是未初始化的。

  2. calloc:我们使用 calloc 为另一个数组分配了 5 个整数并初始化为零。

  3. realloc:我们通过 realloc 调整了已经分配的内存的大小,将数组大小从 5 个元素扩展到了 10 个元素。

  4. free:最后,通过 free 函数释放了之前使用 malloccallocrealloc 分配的内存,以避免内存泄漏。

总结

  • 动态内存分配为我们提供了更灵活的内存管理,可以在程序运行时根据需要分配和释放内存。
  • 在使用动态内存时,我们需要手动管理内存的分配和释放,以避免内存泄漏或访问未分配的内存。
  • 常用的内存分配函数包括:malloccallocreallocfree

理解和掌握动态内存分配对于编写高效的 C++ 程序是至关重要的,特别是在处理大量数据或构建复杂数据结构时。

008 C 内存分配函数 - malloc, calloc

使用 malloccalloc 动态分配内存

在这段代码中,我们将学习如何使用 C 语言中的 malloccalloc 函数进行动态内存分配。我们还将探讨如何调试代码,查看内存分配的过程,以及使用这两种函数的差异。

1. 使用 malloc 分配内存

在 C++ 中,malloc 函数用于在堆上分配指定大小的内存。其签名为:

void* malloc(size_t size);

malloc 函数接受一个参数,即要分配的内存的字节数,并返回一个指向分配内存的 void 指针,它可以转换为任何类型的指针。接下来,我们将在代码中演示如何使用 malloc 分配内存,并使用 static_cast 类型转换将 void* 指针转换为我们需要的类型(例如 int*)。

代码示例

#include <iostream>
#include <cstdlib>  // 包含 malloc 和 free 函数

int main() {
    // 使用 malloc 分配内存
    int *ptr = static_cast<int*>(malloc(sizeof(int)));  // 分配 1 个整数大小的内存
    if (ptr == nullptr) {
        std::cerr << "Memory allocation failed!" << std::endl;
        return 1;
    }

    // 存储数据
    *ptr = 7;

    // 打印数据
    std::cout << "Stored value: " << *ptr << std::endl;  // 输出 7

    // 释放内存
    free(ptr);

    return 0;
}

代码解析

  1. 分配内存
    我们使用 malloc 函数来分配 1 个 int 类型的内存。malloc(sizeof(int)) 确保分配的内存大小是根据平台上 int 类型的大小动态计算的,而不是使用硬编码的大小。由于 malloc 返回的是一个 void* 类型指针,我们需要使用 static_cast<int*> 将其转换为 int* 类型。

  2. 存储和打印数据
    在分配的内存中,我们将值 7 存储进去,并通过解引用指针来打印该值。

  3. 释放内存
    使用 free 函数释放之前分配的内存,以避免内存泄漏。

调试输出

在调试过程中,我们可以观察到以下内存分配的变化:

  • 当我们第一次分配内存时,Visual Studio 显示分配的内存区域为空(即未初始化的内存)。这段内存的值通常显示为一些默认的未初始化值。
  • 执行代码后,内存中的值会被更新为存储的值 7
  • 最后,当我们调用 free 函数释放内存时,内存内容会被覆盖,表示该内存区域已经不再可用。

2. 使用 calloc 分配内存

calloc 函数与 malloc 相似,但它会将分配的内存初始化为零。calloc 的签名如下:

void* calloc(size_t num_elements, size_t size_of_element);

calloc 接受两个参数:

  • num_elements:要分配的元素数量。
  • size_of_element:每个元素的大小。

例如,如果我们要分配一个整数的内存,可以将 num_elements 设置为 1,并将 size_of_element 设置为 sizeof(int)

代码示例

#include <iostream>
#include <cstdlib>  // 包含 calloc 和 free 函数

int main() {
    // 使用 calloc 分配内存
    int *ptr = static_cast<int*>(calloc(1, sizeof(int)));  // 分配 1 个整数并初始化为 0
    if (ptr == nullptr) {
        std::cerr << "Memory allocation failed!" << std::endl;
        return 1;
    }

    // 打印数据
    std::cout << "Stored value: " << *ptr << std::endl;  // 输出 0(因为 calloc 初始化为零)

    // 释放内存
    free(ptr);

    return 0;
}

代码解析

  1. 分配内存并初始化为零
    我们使用 calloc 为 1 个 int 分配内存,并且该内存会自动初始化为 0

  2. 打印数据
    由于 calloc 将内存初始化为零,因此打印出来的值为 0

  3. 释放内存
    使用 free 释放内存,避免内存泄漏。

调试输出

在调试时,可以观察到使用 calloc 分配的内存内容是零。即使内存空间已经分配成功,calloc 确保其内容初始化为零,而不是像 malloc 那样留空未初始化的内存。

总结

  • malloc 用于分配指定大小的内存,但不初始化其内容。
  • calloc 分配内存并将其初始化为零,适合于需要清空内存的场景。
  • 在 C++ 中,由于 malloccalloc 返回的是 void* 类型的指针,需要通过 static_cast 来进行类型转换。
  • 无论是使用 malloc 还是 calloc,都需要手动管理内存,使用 free 函数来释放内存,避免内存泄漏。

在下一节中,我们将讨论如何使用 realloc 来调整已分配内存的大小。

009 C 内存分配函数 - realloc

使用 realloc 函数动态调整内存

在动态内存分配的过程中,realloc 是一个非常有用的函数,它允许我们调整已经分配的内存的大小。假设我们已经为一个整数分配了内存,但是后来发现我们需要存储更多的数据,比如一个额外的整数。在这种情况下,realloc 就能帮助我们扩展原有的内存空间。下面,我们将演示如何使用 realloc 来调整内存大小。

1. 使用 realloc 扩展内存

realloc 函数的作用是改变已经分配的内存块的大小。如果原来分配的内存不够用,可以通过 realloc 函数申请更多的内存空间,并且保留原内存的数据。其函数签名如下:

void* realloc(void* ptr, size_t new_size);
  • ptr 是指向原有内存块的指针。
  • new_size 是新的内存大小(以字节为单位)。

realloc 函数会尝试将内存块扩展到新的大小。如果成功,它返回一个指向新内存块的指针。如果扩展失败,则返回 nullptr

代码示例

假设我们已经为一个整数分配了内存,但后来发现需要为两个整数分配内存。我们可以使用 realloc 来调整内存大小。

#include <iostream>
#include <cstdlib>  // 包含 malloc, realloc, free 函数

int main() {
    // 使用 malloc 分配内存
    int *ptr = static_cast<int*>(malloc(sizeof(int)));  // 分配 1 个整数的内存
    if (ptr == nullptr) {
        std::cerr << "Memory allocation failed!" << std::endl;
        return 1;
    }

    // 存储数据
    *ptr = 7;
    std::cout << "Initial stored value: " << *ptr << std::endl;

    // 使用 realloc 扩展内存,分配 2 个整数的内存
    ptr = static_cast<int*>(realloc(ptr, 2 * sizeof(int)));  // 扩展为 2 个整数的内存
    if (ptr == nullptr) {
        std::cerr << "Memory reallocation failed!" << std::endl;
        return 1;
    }

    // 存储新数据
    ptr[1] = 10;  // 在第二个位置存储值

    std::cout << "After realloc, first value: " << ptr[0] << std::endl;
    std::cout << "After realloc, second value: " << ptr[1] << std::endl;

    // 释放内存
    free(ptr);

    return 0;
}

代码解析

  1. 使用 malloc 分配内存
    我们首先使用 malloc 分配了一个整数大小的内存。然后将 7 存储在该内存中。

  2. 使用 realloc 扩展内存
    随后,我们通过 realloc 函数将内存扩展为两个整数的大小,即 2 * sizeof(int)。如果 realloc 成功,原来的数据会保留,并且新的内存空间会被初始化。注意,如果 realloc 失败,返回值会是 nullptr,这时我们应当及时处理错误。

  3. 存储新数据
    我们在扩展后的内存中存储了第二个整数 10,并打印了两个整数的值。

  4. 释放内存
    最后,我们使用 free 函数释放动态分配的内存。

调试输出

调试时,可以看到如下内存分配的过程:

  • 初始时,我们分配了一个整数的内存,并存储了 7
  • 使用 realloc 后,内存被扩展为两个整数的大小,第二个整数的值 10 被成功存储。
  • 内存被释放后,原内存区域的内容会被清空,表示该内存不再可用。

2. realloc 的问题

尽管 realloc 很强大,但它也有一些潜在的问题,尤其是在内存无法分配时,它会返回 nullptr。如果 realloc 返回 nullptr,我们会丢失原始内存的引用,这可能导致内存泄漏。因此,在使用 realloc 时,我们必须小心,确保原指针不会被覆盖。例如:

int* temp_ptr = static_cast<int*>(realloc(ptr, new_size));
if (temp_ptr == nullptr) {
    std::cerr << "Memory reallocation failed!" << std::endl;
    // 这里应该释放原内存,避免内存泄漏
} else {
    ptr = temp_ptr;  // 只有在 realloc 成功时才更新原指针
}

3. mallocrealloc 的问题

mallocrealloc 函数有时无法成功分配内存,特别是在请求的内存非常大时。例如,如果我们尝试分配一个非常大的内存块,我们可以使用 UINT32_MAX 来表示最大值,查看分配是否成功。

size_t large_size = UINT32_MAX;
ptr = static_cast<int*>(malloc(large_size));
if (ptr == nullptr) {
    std::cerr << "Could not allocate memory!" << std::endl;
}

4. C 和 C++ 内存分配的差异

在 C++ 中,使用 malloccallocrealloc 进行动态内存分配有一些缺点。主要原因是这些函数无法调用对象的构造函数,也不能正确管理 C++ 对象的析构函数。此外,malloccalloc 无法初始化内存为特定的值(除了 calloc 可以初始化为零),这在 C++ 中是一个不够灵活的特性。

  • mallocrealloc 返回 void* 指针,需要类型转换。
  • 它们无法处理对象的构造和析构,因此在分配 C++ 对象时,无法保证对象生命周期的正确管理。

因此,在 C++ 中,推荐使用 newdelete 运算符来分配和释放内存,它们支持对象的构造和析构,并且更加安全和灵活。

总结

  • realloc 用于扩展已经分配的内存,如果内存扩展成功,数据将保留在原位置。
  • 使用 realloc 时需要特别小心,避免内存分配失败导致指针丢失和内存泄漏。
  • 在 C++ 中,我们推荐使用 newdelete 来代替 malloccallocrealloc,因为它们能够正确管理对象的构造和析构。

在下一节视频中,我们将讨论如何使用 C++ 的运算符来动态分配内存,并且它们如何优于传统的 C 风格内存分配函数。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

02 - C++ 中的动态内存分配

001 C++ 分配运算符

C++ 动态内存分配:使用 newdelete 运算符

在 C++ 中,动态内存分配和释放是通过两个运算符 newdelete 来完成的。这些运算符提供了更高效且更安全的内存管理方式,与 C 语言中的 mallocfree 相比,newdelete 具有更多的功能和优势。

1. newdelete 运算符

  • new 运算符:用于在堆上分配内存,并且可以初始化该内存。如果分配的是对象内存,new 会调用对象的构造函数来初始化它。

  • delete 运算符:用于释放由 new 分配的内存,并自动调用对象的析构函数。

C++ 还提供了用于数组的动态内存分配运算符:

  • new[]:用于分配数组的内存。
  • delete[]:用于释放由 new[] 分配的数组内存。

请注意,不能混合使用 newmalloc,也不能用 malloc 释放通过 new 分配的内存,反之亦然。

2. newdelete 示例

我们来看看如何使用 newdelete 在 C++ 中动态分配和释放内存。

#include <iostream>

int main() {
    // 使用 new 运算符为一个整数分配内存
    int* ptr = new int; // 注意这里没有初始化内存,值将是垃圾值
    std::cout << "Value at allocated memory: " << *ptr << std::endl; // 输出垃圾值

    // 使用括号初始化内存
    ptr = new int(0); // 初始化为 0
    std::cout << "Value after initialization: " << *ptr << std::endl; // 输出 0

    // 使用大括号进行统一初始化(C++11 风格)
    ptr = new int{6}; // 初始化为 6
    std::cout << "Value after using curly braces: " << *ptr << std::endl; // 输出 6

    // 释放内存
    delete ptr;

    return 0;
}

代码解析:

  1. 分配内存

    • 使用 new int 分配了一个整数的内存,但没有对其进行初始化,内存中的值是未定义的,因此输出的是一个垃圾值。
  2. 初始化内存

    • 使用 new int(0) 为整数分配内存,并将其初始化为 0。此时,内存被正确初始化,并输出了 0
    • 使用 new int{6}(C++11 风格)来初始化内存为 6,这会自动调用类似构造函数的机制,将内存值设置为 6
  3. 释放内存

    • 使用 delete 运算符释放动态分配的内存,确保内存不会泄漏。

3. 分配数组的内存

如果我们需要分配一个数组的内存,可以使用 new[] 运算符。例如:

#include <iostream>

int main() {
    // 使用 new[] 分配一个包含 6 个整数的数组
    int* arr = new int[6]; // 这里不会自动初始化数组的值

    // 使用 C++11 风格的初始化
    int* arr2 = new int[6]{0}; // 初始化数组所有元素为 0

    // 打印数组的值
    for (int i = 0; i < 6; ++i) {
        std::cout << arr2[i] << " "; // 输出 0 0 0 0 0 0
    }
    std::cout << std::endl;

    // 使用 delete[] 释放数组内存
    delete[] arr;
    delete[] arr2;

    return 0;
}

代码解析:

  1. 分配数组内存

    • 使用 new int[6] 分配了一个包含 6 个整数的数组,但这些整数没有被初始化,因此它们的值是垃圾值。
    • 使用 new int[6]{0} 初始化数组的每个元素为 0,确保数组中的所有元素都被正确初始化。
  2. 释放数组内存

    • 使用 delete[] 来释放由 new[] 分配的数组内存。请注意,delete[] 用于释放数组,而 delete 用于释放单个对象。

4. C++ 与 C 的内存分配差异

在 C 语言中,我们使用 mallocfree 来进行内存分配和释放,而 C++ 则使用 newdeletenewdelete 提供了以下几个优势:

  • 初始化内存new 可以在分配内存的同时进行初始化。
  • 构造和析构函数:当分配和释放对象内存时,newdelete 会自动调用对象的构造函数和析构函数,确保对象生命周期的正确管理。
  • 类型安全new 返回一个正确类型的指针,不需要类型转换,而 malloc 返回的是 void* 指针,需要显式的类型转换。

总结

  • new 用于在堆上分配内存,并且能够初始化内存。
  • delete 用于释放由 new 分配的内存,并自动调用析构函数。
  • 使用 new[]delete[] 来分配和释放数组的内存。
  • 在 C++ 中,避免使用 mallocfree,因为它们无法自动初始化内存并且无法处理构造和析构函数。

在下一节视频中,我们将讨论 new 运算符是如何在内部工作的,进一步了解它如何管理内存。

002 new 的工作原理 - 第一部分

C++ 动态内存分配的内部实现:newoperator new

在前面的视频中,我们讨论了如何在 C++ 中使用 newdelete 运算符进行动态内存分配和释放。在本视频中,我们将深入探讨当你使用 new 运算符分配内存时,背后发生了什么。

1. new 运算符和 operator new

当你在 C++ 中使用 new 运算符来分配内存时,它会调用一个特殊的函数——operator new。这是一个已经在 C++ 标准库中实现的函数,负责为对象分配足够的内存空间。new 运算符不仅仅是分配内存,它还会初始化该内存并返回对象的指针。

举个例子,假设我们希望在堆上创建一个 Number 对象,并且初始化为 5

Number* num = new Number(5);

在这行代码中,new 运算符将:

  1. 调用 operator new 函数为 Number 类型的对象分配内存。
  2. 使用 Number(5) 来调用 Number 类的构造函数,初始化该对象。
  3. 返回该对象的内存地址,也就是 num 指针指向的位置。

2. new 运算符的实现流程

下面是 new 运算符执行的详细流程:

  1. 计算分配内存的大小

    • 在编译时,编译器会计算出要分配的内存大小。对于一个对象,大小通常是该对象类型的大小,包括数据成员、对齐和可能的虚函数表指针等。
  2. 调用 operator new

    • 一旦计算出所需的内存大小,new 会调用一个标准的库函数——operator new 来执行实际的内存分配。这个函数类似于 C 语言中的 malloc,但是它是专门为 C++ 对象设计的。它会尝试在堆上分配足够的内存来容纳对象。
  3. 内存分配失败的处理

    • operator new 可能会失败,通常是因为没有足够的堆空间。在这种情况下,它会抛出一个 std::bad_alloc 异常。我们可以捕获这个异常来处理内存分配失败的情况。
  4. 初始化对象

    • 对于用户定义的类型,内存分配完成后,构造函数会被自动调用,以初始化对象的成员变量。构造函数会设置对象的初始状态。
  5. 返回对象地址

    • 最后,new 运算符会返回分配的内存地址,也就是一个指向该对象的指针。

3. 数组的内存分配:new[]operator new[]

如果我们要为一个对象数组分配内存,使用的是 new[] 运算符。例如:

Number* numArray = new Number[5] { 1, 2, 3, 4, 5 };

此时,new[] 运算符会做如下几件事:

  1. 调用 operator new[]operator new[] 会为整个数组分配足够的内存空间。

  2. 初始化每个元素new[] 会为每个数组元素调用相应的构造函数来初始化数组。

  3. 返回指向数组的指针:最后,返回一个指向数组首元素的指针。

需要注意的是,当使用 new[] 分配内存时,必须使用 delete[] 来释放内存,以确保每个元素的析构函数都被调用。

4. 释放内存:deletedelete[]

  • delete 运算符:用于释放由 new 分配的单个对象的内存,并调用该对象的析构函数。

  • delete[] 运算符:用于释放由 new[] 分配的对象数组的内存,并依次调用每个对象的析构函数。

使用不当时,deletedelete[] 的混用会导致未定义的行为。确保在释放内存时,newdelete 之间的配对是正确的。

5. Visual Studio 调试过程

让我们回顾一下这些步骤,结合 Visual Studio 调试过程来看:

  1. 内存分配:当你运行程序并使用 new 运算符分配内存时,可以通过 Visual Studio 的调试窗口查看堆内存的分配情况。你会看到内存地址被分配,且没有被初始化的内存会显示为垃圾值。

  2. 构造函数调用:当内存分配后,Number 类的构造函数会被调用,你可以在调试过程中逐步执行,查看构造函数如何初始化对象。

  3. 释放内存:使用 deletedelete[] 时,你可以在调试器中观察到内存的释放过程,以及析构函数的调用。

总结

  • new 运算符 会通过调用 operator new 来为对象分配内存,并且在分配内存后自动调用构造函数初始化对象。
  • new[] 运算符 用于分配数组内存,并且会为数组中的每个对象调用构造函数。
  • 在释放内存时,使用 deletedelete[] 运算符来确保析构函数被正确调用。

在下一个视频中,我们将探讨 operator newoperator delete 的实现方式,看看它们是如何工作的,以及我们如何自定义这些函数来实现特定的内存分配策略。

003 new 的工作原理 - 第二部分

C++ 中 newdelete 运算符的内部工作原理

在这段视频中,我们将深入探讨 C++ 中 newdelete 运算符是如何工作的,特别是如何在编译和运行时生成汇编代码,帮助我们理解内存分配的过程。

1. 创建 Number

我们将首先创建一个简单的 Number 类,它包含一个整数成员,并且通过参数化构造函数进行初始化。为了观察内存分配和释放的细节,我们还需要实现一个析构函数,虽然在这个例子中它不是必须的。

class Number {
public:
    int value;
    // 参数化构造函数
    Number(int val) : value(val) {}

    // 析构函数
    ~Number() {
        std::cout << "Destructor called" << std::endl;
    }
};

2. 使用 newdelete 运算符

main 函数中,我们使用 new 运算符为 Number 对象分配内存,并初始化其值为 5。随后,我们使用 delete 运算符释放内存。

int main() {
    Number* num = new Number(5);  // 使用 new 运算符分配内存并初始化
    delete num;  // 使用 delete 运算符释放内存
    return 0;
}

3. 编译为 Release Build

我们选择将代码编译为 Release Build,以避免编译器生成额外的调试代码。Release Build 会进行更多的优化,这有助于我们看到更清晰的汇编代码和内存分配过程。

4. 禁用内联函数扩展

为了确保构造函数不会被内联优化,我们禁用内联函数扩展(Inline Function Expansion)。这样可以确保我们在汇编代码中能够看到 new 运算符调用时的所有细节,而不是由编译器自动优化掉。

5. 使指针变量为全局变量

将指针变量 num 声明为全局变量的原因是,编译器在 Release Build 中可能会进行激进的优化,如果指针是局部变量,可能会在优化过程中移除它。将指针设为全局变量可以避免这种情况。

Number* num;  // 将指针变量设为全局变量

6. 调试过程

现在,我们开始调试程序并查看生成的汇编代码。在 Visual Studio 中,切换到 Disassembly 窗口,以查看编译器生成的汇编代码。

  • 内存分配:
    在执行 new Number(5) 后,首先我们看到汇编代码中有一个 push 4 的指令。为什么是 4 呢?因为 Number 类仅包含一个 int 类型的成员,而 int 占用 4 个字节。因此,编译器计算出要分配的内存大小为 4 字节,并将这个值传递给 operator new 函数。

  • 调用 operator new
    operator new 函数会根据传入的大小分配内存并返回内存地址。此时,汇编代码中将返回分配的内存地址。你可以在调试窗口查看汇编中的 x 寄存器,它会存储返回的内存地址。

  • 初始化内存:
    接下来,我们看到调用了 Number 类的构造函数。此时,内存中的内容已经被初始化为 5,你可以在调试窗口中查看内存的变化。

  • 更新指针:
    在内存初始化后,指针 num 被更新为分配的内存地址。

  • 释放内存:
    最后,我们调用 delete 运算符释放内存。此时,首先调用 Number 类的析构函数,然后调用 operator delete 函数释放内存。在汇编代码中,我们看到编译器将 delete 调用嵌入到析构函数中,这是编译器的优化行为。

7. new 内存分配失败的处理

虽然在这个例子中我们没有展示 new 失败的情况,但如果 new 无法分配足够的内存,它会抛出一个 std::bad_alloc 异常。你可以自定义 new 的行为来控制它如何处理内存分配失败的情况。我们将在下一个视频中讨论这一点。

8. 总结

  • new 运算符 会计算需要分配的内存大小,并调用 operator new 函数进行内存分配。
  • 内存初始化:对于用户定义的类型,new 会在分配内存后自动调用构造函数初始化对象。
  • 内存释放delete 运算符会调用析构函数并释放内存。
  • 编译器优化:通过禁用内联函数扩展和调整代码结构,我们能够更清晰地观察 newdelete 的内部实现过程。

在下一个视频中,我们将讨论 new 失败时的处理方法,及如何自定义 new 运算符来实现自定义的内存分配策略。

004 处理 new 分配失败 - 异常

C++ 中 new 运算符内存分配失败时的处理

在这段视频中,我们将讨论 C++ 中当 new 运算符无法成功分配内存时会发生什么。我们知道,new 运算符在内存分配失败时会抛出一个 std::bad_alloc 异常。我们将通过一个简单的示例来展示这一点。

1. new 内存分配失败

在上一期视频中,我们已经了解了 new 运算符是如何工作的。new 表达式会调用 operator new 函数,后者尝试分配指定大小的内存。如果内存分配失败,默认行为是抛出 std::bad_alloc 异常。

现在,让我们来看一个例子,演示当我们请求分配的内存过大时,new 是如何抛出异常的。

2. 分配大量内存的代码示例

首先,我们需要包含 climits 头文件,因为它提供了一些宏,用于表示不同类型的最大值。在这里,我们将使用 INT_MAX,它返回系统上 int 类型的最大值,这是一个非常大的数值。

#include <iostream>
#include <climits>  // 用于获取 INT_MAX

int main() {
    const int arraySize = INT_MAX;  // 获取 int 类型的最大值

    try {
        // 使用 new 分配一个非常大的整数数组
        int* largeArray = new int[arraySize];  
    }
    catch (std::bad_alloc& e) {
        // 捕获异常并打印错误信息
        std::cout << "Memory allocation failed: " << e.what() << std::endl;
    }
    
    return 0;
}

3. 实现分配内存的循环

我们可以修改代码,让它在一个循环中多次尝试分配大块内存。通过这种方式,我们可以查看 new 在多次分配失败后是如何表现的。

#include <iostream>
#include <climits>  // 用于获取 INT_MAX

int main() {
    const int arraySize = INT_MAX / 10;  // 减小数组大小,避免编译错误

    int* arrayOfPointers[10];  // 存储指针的数组,用于存储分配的内存地址

    for (int i = 0; i < 10; i++) {
        try {
            // 尝试分配内存并存储内存地址
            arrayOfPointers[i] = new int[arraySize];
            std::cout << "Iteration " << i << ": Memory allocated successfully!" << std::endl;
        }
        catch (std::bad_alloc& e) {
            // 捕获 bad_alloc 异常并打印错误信息
            std::cout << "Iteration " << i << ": Memory allocation failed: " << e.what() << std::endl;
        }
    }
    
    return 0;
}

4. 输出结果分析

在这个代码示例中,我们在一个 for 循环中尝试分配十次大块内存,每次分配 10 分之一的 INT_MAX 大小。如果第一次分配成功,第二次分配时会抛出 std::bad_alloc 异常,因为请求的内存量过大。

输出的结果会如下所示:

Iteration 0: Memory allocated successfully!
Iteration 1: Memory allocation failed: std::bad_alloc
Iteration 2: Memory allocation failed: std::bad_alloc
Iteration 3: Memory allocation failed: std::bad_alloc
Iteration 4: Memory allocation failed: std::bad_alloc
Iteration 5: Memory allocation failed: std::bad_alloc
Iteration 6: Memory allocation failed: std::bad_alloc
Iteration 7: Memory allocation failed: std::bad_alloc
Iteration 8: Memory allocation failed: std::bad_alloc
Iteration 9: Memory allocation failed: std::bad_alloc

从输出结果中我们可以看到,new 在第一次尝试分配大内存时成功了,但在后续的分配尝试中,由于请求的内存量过大,抛出了 std::bad_alloc 异常。

5. 处理 new 内存分配失败

C++ 默认情况下,如果 new 无法分配内存,它会抛出一个 std::bad_alloc 异常。我们通过 try-catch 块来捕获此异常并进行处理。通过这种方式,我们可以在运行时处理内存分配失败的情况,而不是让程序崩溃。

6. 总结

  • new 运算符的失败处理:默认情况下,new 运算符会抛出一个 std::bad_alloc 异常,如果内存分配失败。
  • 如何使用 try-catch 捕获异常:我们可以通过 try-catch 块来捕获 std::bad_alloc 异常,避免程序崩溃并处理内存分配失败的情况。
  • 分配过大内存时的行为:当请求的内存量过大时,new 可能无法成功分配内存,进而抛出异常。

在接下来的视频中,我们将探讨如何通过自定义 newdelete 运算符来实现自定义的内存管理策略,以及如何进一步优化内存分配和释放的过程。

005 处理 new 分配失败 - 处理程序

C++ 中 new 运算符的异常处理和新处理程序

在本视频中,我们将继续讨论 new 运算符在内存分配失败时的处理方式。我们将介绍如何使用 new handler,来改变默认的抛出异常行为。当 new 运算符无法分配内存时,可以通过设置一个新的处理函数(即 new handler)来替代默认的抛出异常行为。

1. new handler 的定义

默认情况下,new 运算符在内存分配失败时会抛出 std::bad_alloc 异常,但这种行为可以通过自定义 new handler 函数来改变。设置 new handler 的方式是通过 set_new_handler 函数,它允许我们指定一个自定义函数,在内存分配失败时由 new 调用。

新处理程序的定义:我们需要创建一个没有参数和返回值的函数作为处理程序。该函数将被 new 运算符在内存分配失败时反复调用。

#include <iostream>
#include <new>        // 为 set_new_handler 提供支持
#include <thread>     // 用于 sleep_for
#include <chrono>     // 用于 sleep_for

// 定义新的处理程序函数
void new_handler() {
    std::cout << "Memory allocation failed. Retrying..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));  // 延时1秒
}

int main() {
    // 设置自定义的 new handler
    std::set_new_handler(new_handler);
    
    try {
        // 分配大块内存的代码示例
        const int arraySize = 1000000000;  // 请求分配一个极大的数组
        int* largeArray = new int[arraySize];  // 这里可能会失败
        std::cout << "Memory allocated successfully!" << std::endl;
        delete[] largeArray;  // 释放内存
    }
    catch (std::bad_alloc& e) {
        std::cout << "Caught exception: " << e.what() << std::endl;
    }

    return 0;
}

2. new handler 的工作原理

在上面的代码中,我们创建了一个 new_handler 函数,并通过 std::set_new_handler 将其注册为新的处理程序。当 new 运算符在内存分配失败时,new_handler 会被调用。

在此例中,我们让 new_handler 每次失败后延迟 1 秒再尝试分配内存。每次内存分配失败时,new_handler 函数都会被调用,直到内存可用为止。

3. 执行流程

在执行过程中,new 运算符会尝试分配内存,如果内存分配失败,它不会抛出异常,而是调用 new_handler 函数。在 new_handler 函数内部,我们使程序暂停 1 秒钟,然后它会继续尝试分配内存。这个过程会一直重复,直到内存分配成功或者程序被手动终止。

4. 代码分析

让我们详细分析执行流程:

  1. 内存分配失败:在第一次调用 new 运算符时,假设分配的内存太大,导致失败。此时 new_handler 函数会被调用,并输出 "Memory allocation failed. Retrying..." 信息,同时程序暂停 1 秒钟。

  2. 删除已分配内存:为了释放内存,我们在第二次迭代前释放第一次分配的内存,这样 new 运算符有机会再次尝试分配内存。

  3. 内存分配成功:当释放了已分配的内存之后,new 运算符成功分配内存。此时,程序继续执行,并输出 "Memory allocated successfully!" 信息。

  4. 内存分配失败并反复调用 handler:如果我们没有释放内存,程序会继续失败,导致 new_handler 被反复调用。

5. 结束语

  • new 的默认行为:当 new 运算符无法分配内存时,它会抛出 std::bad_alloc 异常。
  • 设置 new handler:通过调用 std::set_new_handler,我们可以指定一个自定义函数来处理内存分配失败的情况,而不是抛出异常。
  • new handler 的行为:在 new 失败时,new handler 会被调用,可以在其中加入一些逻辑(如延迟重试)来处理失败的情况。

在下期视频中,我们将进一步探讨如何通过其他方式来处理 new 运算符的失败,例如使用 nothrow 选项。敬请期待!

006 处理 new 分配失败 - nothrow

C++ 中的非抛出 new 运算符(Non-Throwing New)

在之前的视频中,我们已经了解了 new 运算符的基本工作原理,以及当 new 无法分配内存时发生的情况。默认情况下,new 运算符会抛出一个 std::bad_alloc 异常,但我们也可以设置一个 new handler 来处理内存分配失败的情况。然而,这种处理程序必须显式设置。在本视频中,我们将讨论另一种形式的 new 运算符,它不会抛出异常,而是返回一个空指针(nullptr)。这就是所谓的 非抛出 new

1. 非抛出 new 的特点

非抛出 new(Non-Throwing New) 版本的 new 运算符在分配内存失败时,不会抛出异常,而是返回 nullptr。这种形式的 new 运算符非常适用于不能捕获异常的场景。例如,如果你在一个 C 或其他不支持异常的语言中使用 C++ 编写的库时,异常可能无法被捕获。而使用非抛出 new 时,即使内存分配失败,程序也能继续运行,且能通过检查返回的指针是否为 nullptr 来确定是否分配成功。

这种类型的 new 运算符在 C++ 库与其他语言(如 C# 或 Java)结合使用时尤其有用,因为这些语言可能没有与 C++ 相同的异常处理机制,因此无法捕获 C++ 中抛出的异常。

2. 非抛出 new 的使用方法

要使用非抛出 new,你需要在 new 表达式中指定一个特殊的常量 std::nothrow,它定义在 new 头文件中。通过将 std::nothrow 作为参数传递给 new,你可以选择一个不会抛出异常的重载版本的 operator new 函数。如果内存分配失败,这个函数会返回 nullptr,而不是抛出异常。

代码示例

#include <iostream>
#include <new>        // 提供 std::nothrow 支持

int main() {
    const size_t arraySize = 1000000000;  // 请求分配一个极大的数组
    int* largeArray = nullptr;

    // 使用非抛出版本的 new
    largeArray = new(std::nothrow) int[arraySize];  // 如果失败,返回 nullptr

    if (largeArray == nullptr) {
        std::cout << "Failed to allocate memory" << std::endl;
    } else {
        std::cout << "Memory allocated successfully!" << std::endl;
        delete[] largeArray;  // 释放内存
    }

    return 0;
}

3. std::nothrow 的作用

std::nothrow 是一个常量,它的作用是告诉 new 运算符选择一个特定版本的内存分配函数,这个版本的 operator new 不会抛出异常。它的使用方式是将 std::nothrow 作为参数传递给 new 表达式。例如:

largeArray = new(std::nothrow) int[arraySize];

在这种情况下,如果内存分配失败,new 运算符不会抛出异常,而是返回 nullptr

4. 调试非抛出 new

让我们通过 Visual Studio 来调试这个代码,看看 non-throwing new 如何在内部工作。

  1. 设置调试点:在调用 new(std::nothrow) 语句时,右键点击代码行并选择 “Step Into Specific” 进入 operator new 子脚本(operator new[])。这样可以确保调试器不会跳过对 operator new 函数的调用。

  2. 查看 operator new 的行为:在进入 operator new 后,你会看到如果 operator new 函数抛出异常,它会在这里被捕获并返回 nullptr。值得注意的是,这种形式的 operator new 会接受 std::nothrow 常量作为参数。

5. 小结

  • 非抛出 new:使用 new(std::nothrow) 版本的 new 运算符可以确保内存分配失败时返回 nullptr,而不是抛出异常。适用于不能捕获异常的场景,尤其是在与不支持异常的语言(如 C)交互时。

  • 异常处理机制差异:这种方法特别适用于 C++ 与其他语言(如 Java 或 C#)的交互,因为这些语言的异常处理机制与 C++ 完全不同,可能无法捕获 C++ 抛出的异常。

在下一期视频中,我们将深入探讨非抛出 new 的更多用法。敬请关注!

007 无抛出 new 示例 - 第一部分

Non-Throwing new 的应用实例

在之前的视频中,我们讨论了非抛出版本的 new 运算符,该版本的 new 在分配内存失败时不会抛出异常,而是返回一个空指针 (nullptr)。今天,我们将探讨这一版本的 new 在实际应用中的重要性,并通过一些示例来说明它的应用场景。

1. 非抛出 new 的应用场景

假设我们有一个需要分配内存的大型应用程序,尤其是涉及到动态内存分配的 DLL(动态链接库)和使用该 DLL 的客户端应用程序。在这种情况下,如果内存分配失败,抛出异常可能会导致不同的语言或不同编译器的交互出现问题。因此,使用非抛出 new 可以确保内存分配失败时不会抛出异常,而是返回空指针,允许客户端代码自行决定如何处理。

2. 创建和销毁矩阵的示例代码

我们有一个简单的项目,其中包含两个导出的函数:createdestroycreate 函数接受行数和列数作为输入,创建一个二维矩阵,并进行内存分配。考虑到可能行数和列数过大导致内存分配失败,我们在代码中使用了 try 块来捕获异常。如果分配失败,程序会抛出 std::bad_alloc 异常。

创建矩阵的代码示例

#include <iostream>
#include <new> // 为了处理内存分配异常

void create(int rows, int cols) {
    try {
        int** matrix = new int*[rows];  // 为行分配内存
        for (int i = 0; i < rows; ++i) {
            matrix[i] = new int[cols];  // 为每一列分配内存
        }
        std::cout << "Memory allocated successfully!" << std::endl;
        
        // 进行一些数据处理
        std::cout << "Processing data..." << std::endl;

        // 在这里假设我们对矩阵做一些操作
        for (int i = 0; i < rows; ++i) {
            delete[] matrix[i];  // 删除每一行
        }
        delete[] matrix;  // 删除行指针数组
    } catch (const std::bad_alloc& e) {
        std::cout << "Exception caught: " << e.what() << std::endl;
        throw;  // 抛出异常让调用者决定如何处理
    }
}

3. 客户端代码

客户端程序加载 DLL(使用 Windows API),并调用 create 函数。由于 create 函数可能会抛出异常,因此客户端代码必须使用 try 块来捕获这些异常。

客户端代码示例

#include <iostream>
#include <windows.h>

typedef void (*CreateFunc)(int, int);  // 定义函数指针类型

int main() {
    HMODULE hDll = LoadLibrary("matrix.dll");  // 加载 DLL
    if (hDll) {
        CreateFunc create = (CreateFunc)GetProcAddress(hDll, "create");  // 获取函数地址
        if (create) {
            try {
                create(1000, 10000);  // 请求大矩阵,可能导致内存分配失败
            } catch (...) {
                std::cout << "Memory allocation failed." << std::endl;
            }
        } else {
            std::cout << "Failed to find the function." << std::endl;
        }
        FreeLibrary(hDll);  // 释放 DLL
    } else {
        std::cout << "Failed to load DLL." << std::endl;
    }
    return 0;
}

4. 异常抛出和捕获

当我们运行这段代码时,如果矩阵的列数过大,导致内存分配失败,程序会抛出 std::bad_alloc 异常,客户端代码会捕获并处理它。正常情况下,程序会执行数据处理代码并输出相关消息。

测试运行结果

  1. 当列数设置较小且内存分配成功时,程序正常执行并输出“Memory allocated successfully!”以及“Processing data…”。
  2. 当列数设置过大导致内存分配失败时,程序会捕获 std::bad_alloc 异常并输出相关错误信息。

5. 问题:C 语言的兼容性

但是,问题出现在如果客户端应用程序是用 C 语言编写的,那么它将无法捕获由 create 函数抛出的异常,因为 C 语言本身不支持异常处理机制。即使我们在 C++ 中使用 new 运算符并抛出异常,C 语言的代码无法处理这些异常。

这会导致 C++ 和 C 编写的混合应用程序出现问题,特别是当使用不同编译器时。不同编译器对 C++ 标准的实现方式可能有所不同,因此它们对异常的处理方式可能也不同。即使 C++ 编译器遵循相同的标准,编译器的实现也可能导致不同的行为。

6. 总结

  • 非抛出 new 的优点:使用非抛出版本的 new 运算符,当内存分配失败时不会抛出异常,而是返回 nullptr。这使得程序能够继续执行,并允许开发者自己处理内存分配失败的情况。尤其在混合语言项目中非常有用,避免了因为异常处理机制不同而导致的兼容性问题。

  • C 和 C++ 的异常处理问题:当 C++ 库抛出异常时,如果被 C 或其他不支持异常处理的语言调用,异常将无法被捕获。这也可能发生在 C++ 编写的 DLL 和使用不同编译器的客户端程序之间。

在下一期视频中,我们将讨论如何解决跨语言和跨编译器使用 C++ 异常的问题。敬请关注!

008 无抛出 new 示例 - 第二部分

当 DLL 使用不同的 C++ 编译器编译时的情况

在之前的视频中,我们探讨了在同一编译器下,当内存分配失败时抛出异常的行为以及如何通过异常处理来应对内存分配问题。然而,在实际应用中,编译器之间的差异可能会导致不同编译器生成的代码之间无法正确处理这些异常。今天,我们将通过一个示例来看看,当相同的 DLL 使用不同的 C++ 编译器编译时,可能会遇到的问题,并展示如何解决这些问题。

1. 使用 GCC 编译的 DLL

首先,我们创建了相同的 DLL,并使用 Code::Blocks 编译器(该编译器使用 GCC 编译器)编译。这与之前使用 Visual Studio 编译的 DLL 完全相同,代码和功能都没有变化,仍然是通过 new 运算符分配内存,如果分配失败,则抛出异常 std::bad_alloc。我们也在异常捕获后重新抛出了该异常。

GCC 编译器编译的 DLL 示例代码

#include <iostream>
#include <new>  // 为了处理内存分配异常

void create(int rows, int cols) {
    try {
        int** matrix = new int*[rows];  // 为行分配内存
        for (int i = 0; i < rows; ++i) {
            matrix[i] = new int[cols];  // 为每一列分配内存
        }
        std::cout << "Memory allocated successfully!" << std::endl;
        
        // 数据处理逻辑
        std::cout << "Processing data..." << std::endl;

        // 假设在此处理完数据后释放内存
        for (int i = 0; i < rows; ++i) {
            delete[] matrix[i];  // 删除每一行
        }
        delete[] matrix;  // 删除行指针数组
    } catch (const std::bad_alloc& e) {
        std::cout << "Exception caught: " << e.what() << std::endl;
        throw;  // 重新抛出异常
    }
}

2. 使用 GCC 编译的 DLL 进行客户端测试

接下来,我们从 Visual Studio 编写的客户端应用程序中调用此 DLL。我们只是简单地将加载 Visual Studio 编译的 DLL 的代码注释掉,并加载 GCC 编译的 DLL。首先,我们将列数缩小,以确保内存分配不会失败,并验证 DLL 在客户端中正常工作。

客户端代码

#include <iostream>
#include <windows.h>

typedef void (*CreateFunc)(int, int);  // 定义函数指针类型

int main() {
    HMODULE hDll = LoadLibrary("matrix_gcc.dll");  // 加载 GCC 编译的 DLL
    if (hDll) {
        CreateFunc create = (CreateFunc)GetProcAddress(hDll, "create");  // 获取函数地址
        if (create) {
            try {
                create(1000, 10000);  // 请求分配大矩阵
            } catch (...) {
                std::cout << "Memory allocation failed." << std::endl;
            }
        } else {
            std::cout << "Failed to find the function." << std::endl;
        }
        FreeLibrary(hDll);  // 释放 DLL
    } else {
        std::cout << "Failed to load DLL." << std::endl;
    }
    return 0;
}
  1. 列数设置较小,内存分配成功:程序运行时,内存分配成功,输出处理数据的消息。
  2. 列数设置较大,内存分配失败:如果我们恢复列数到之前的值,内存分配会失败并抛出 std::bad_alloc 异常。此时,客户端会捕获异常并显示“Memory allocation failed.”。

3. 异常未被捕获的情况

然而,当我们恢复为较大的列数时,程序再次运行,异常抛出时,客户端应用程序无法捕获该异常。相反,程序终止并显示以下消息:

terminate called after throwing an instance of 'std::bad_alloc'

这表明,程序因为无法处理从 DLL 中抛出的异常而崩溃了。这是因为 GCC 编译器生成的 C++ 运行时与 Visual Studio 编译器生成的 C++ 运行时不兼容。具体来说,两个编译器实现 C++ 标准的方式不同,因此它们的异常处理机制也有所不同。这意味着,使用不同编译器编译的 DLL 抛出的异常无法由客户端捕获。

4. 解决方案:使用非抛出版本的 new

为了避免编译器间的兼容性问题,最佳的解决方案是使用非抛出版本的 new 运算符。非抛出版本的 new 在内存分配失败时不会抛出异常,而是返回 nullptr,这使得客户端代码可以检查返回值,并根据需要做出相应的处理。

修改后的 create 函数

#include <iostream>
#include <new>  // 为了处理内存分配异常

void create(int rows, int cols) {
    int** matrix = nullptr;
    
    // 使用非抛出版本的 new
    matrix = new(std::nothrow) int*[rows];  // 为行分配内存
    if (!matrix) {
        std::cout << "Memory allocation failed for rows!" << std::endl;
        return;
    }

    for (int i = 0; i < rows; ++i) {
        matrix[i] = new(std::nothrow) int[cols];  // 为每一列分配内存
        if (!matrix[i]) {
            std::cout << "Memory allocation failed for columns!" << std::endl;
            delete[] matrix;  // 释放之前分配的内存
            return;
        }
    }

    std::cout << "Memory allocated successfully!" << std::endl;

    // 进行数据处理
    std::cout << "Processing data..." << std::endl;

    // 假设在此处理完数据后释放内存
    for (int i = 0; i < rows; ++i) {
        delete[] matrix[i];  // 删除每一行
    }
    delete[] matrix;  // 删除行指针数组
}

5. 最终测试

重新编译 DLL 后,回到 Visual Studio 中运行客户端应用程序。此时,由于使用了非抛出版本的 new,即使内存分配失败,函数也不会抛出异常,而是返回 nullptr。客户端代码会检查返回值,并优雅地处理内存分配失败的情况。结果是:

Data could not be retrieved.

程序不会崩溃,而是正确地处理了内存分配失败的情况。

6. 总结

  • 跨编译器兼容性问题:不同 C++ 编译器(如 Visual Studio 和 GCC)生成的运行时库在异常处理方面的实现差异可能导致异常无法在客户端程序中正确捕获。特别是在使用 DLL 时,如果 DLL 和客户端程序使用不同的编译器编译,抛出的异常可能无法被捕获。

  • 解决方案:为了避免编译器之间的兼容性问题,推荐使用非抛出版本的 new 运算符。这样,即使内存分配失败,也不会抛出异常,而是返回 nullptr,客户端程序可以自行检查并处理失败情况。

  • C++ 异常的谨慎使用:如果必须在 DLL 中抛出异常,建议在 DLL 内部捕获并处理这些异常,避免将异常抛出到外部调用者,尤其是在使用不同编译器的场景中。

在下一期视频中,我们将继续讨论如何在跨编译器的环境中更好地处理 C++ 异常。

010 Placement new - 第一部分

C++ 中的 Placement New 操作符

到目前为止,我们已经学习了 C++ 中各种 newdelete 运算符的使用情况,并且了解了当 new 无法分配内存时的默认行为。我们还讨论了如何使用处理函数来处理内存分配失败的情况,或者使用不抛出异常的版本的 new,使得 new 在失败时返回 nullptr。除此之外,我们还讨论了三种形式的 new:标准 new、数组 new 和非抛出版本的 new

现在,C++ 中还有一种特别的 new 形式,叫做 placement new。在本视频中,我们将讨论 placement new,并通过实例来理解它的用途。

1. 什么是 Placement New?

Placement new 是一种特殊形式的 new 运算符,它并不是从堆中分配内存,而是将对象或数据放置到由程序员指定的内存地址上。这意味着你需要手动提供一个已经分配的内存地址,然后 placement new 会将对象或数据放到这个位置。这种类型的 new 适用于某些特定的场景,特别是当你希望有更多的控制权来管理内存时。

2. Placement New 的使用场景

  • 嵌入式设备:在嵌入式设备中,通常需要将对象存储在已知的位置。例如,你可能需要将某些数据结构或对象存储在特定的内存区域,这时就可以使用 placement new 来控制对象的内存位置。
  • 内存池:内存池是一块大内存块,其中的一些区域被程序中的不同对象使用。使用内存池可以避免堆内存碎片化,因为你可以控制对象分配的具体位置。

3. 使用 Placement New

Placement new 的语法和标准 new 运算符略有不同。你需要提供一个内存地址,在该地址处创建对象。以下是 placement new 的基本语法:

new (address) Type;  // 在指定的地址上创建对象

其中,address 是一个有效的内存地址,Type 是要创建的对象类型。需要注意的是,placement new 不会检查该内存地址是否有效,因此你必须确保提供的内存地址是有效的。

4. Placement New 的示例

考虑以下示例,展示如何使用 placement new 来在指定内存位置创建对象:

#include <iostream>
#include <new>  // 为了使用 placement new

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass constructor called" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass destructor called" << std::endl;
    }
};

int main() {
    // 分配一块足够大的内存
    char buffer[sizeof(MyClass)];

    // 使用 placement new 在 buffer 中创建 MyClass 对象
    MyClass* obj = new (buffer) MyClass;

    // 使用对象
    std::cout << "Object created at " << (void*)buffer << std::endl;

    // 显示对象析构时的消息
    obj->~MyClass();  // 调用析构函数显式销毁对象

    return 0;
}

在这个示例中,我们首先分配了一块足够大的内存来存储 MyClass 对象。然后,使用 placement new 在这块内存上创建了一个 MyClass 对象。最后,我们显式调用了对象的析构函数,因为 placement new 并不会自动调用析构函数。

5. Placement New 和内存管理

当你使用 placement new 创建对象时,内存分配并不是由 new 运算符完成的。因此,你需要确保提供的内存地址是有效的,并且你必须手动调用析构函数来销毁对象。此外,如果对象创建过程中使用了 new 运算符,那么它分配的内存也需要显式释放。

6. C++ 标准库中的 Placement New

C++ 标准库中有许多地方使用了 placement new。一个常见的例子是在 shared_ptr 中,shared_ptr 会使用 placement new 来分配其控制块和用户定义的对象的内存:

std::shared_ptr<MyClass> ptr(new MyClass());

在这种情况下,shared_ptr 会使用 placement new 在一块内存区域同时分配控制块和对象。这使得内存管理更加高效,并避免了额外的内存分配。

7. 总结

  • Placement new 允许你将对象放置在指定的内存地址,这对于嵌入式编程和内存池管理非常有用。
  • 使用 placement new 时,你必须确保内存地址有效,并手动调用析构函数以清理对象。
  • C++ 标准库在许多地方使用了 placement new,比如 shared_ptr 和容器的分配器。

在下一期视频中,我将通过更多示例来深入讲解 placement new 的实际应用,并展示如何在实际项目中使用它来管理内存。直到那时,再见!

011 Placement new - 第二部分

Placement New 操作符的实际应用

在本视频中,我们将探讨 placement new 的实际应用,看看为什么在某些情况下我们需要使用它。

1. 使用 Placement New 创建数据

我们将首先创建一个简单的缓冲区,大小为 4 个字节,并在这些字节中存储一个整数。由于这是在栈上创建的缓冲区,我们将这个缓冲区的内存地址提供给 new 运算符。需要注意的是,new 并不关心内存地址的来源,它可以是栈内存、堆内存,甚至是共享内存。所以,new 运算符将只在该位置创建一个整数,而不会分配新的内存。因此,它不会抛出内存分配失败的异常。

#include <new>  // 为了使用 placement new
#include <iostream>

int main() {
    // 创建一个 4 字节的缓冲区
    char buffer[4];

    // 在这个缓冲区上使用 placement new 创建一个整数
    int* pInt = new (buffer) int(42);  // 在 buffer 中存储整数

    std::cout << "Value: " << *pInt << std::endl;  // 输出 42
    return 0;
}

在这个例子中,placement new 并不会分配内存,而是使用已经分配的内存空间(buffer)来创建整数。由于 new 并未分配内存,所以也不会抛出内存分配失败的异常。

2. 使用 Placement New 创建类对象

接下来,我们将通过一个更复杂的例子来演示 placement new。假设我们创建了一个类 Reporter,它在其构造函数和析构函数中打印消息。我们将使用 placement new 来在一个缓冲区中创建多个 Reporter 对象。

#include <new>
#include <iostream>

class Reporter {
public:
    Reporter() { std::cout << "Reporter constructor called" << std::endl; }
    ~Reporter() { std::cout << "Reporter destructor called" << std::endl; }
};

int main() {
    // 创建一个足够大的缓冲区来存储多个 Reporter 对象
    char buffer[sizeof(Reporter) * 4];

    // 使用 placement new 在缓冲区中创建 Reporter 对象
    Reporter* reporter1 = new (buffer) Reporter;
    Reporter* reporter2 = new (buffer + sizeof(Reporter)) Reporter;  // 第二个 Reporter 对象

    // 手动调用析构函数,因为 placement new 不会自动销毁对象
    reporter1->~Reporter();
    reporter2->~Reporter();

    return 0;
}

在这个例子中,我们使用 placement new 将两个 Reporter 对象存储在一个 4 字节大小的缓冲区中。由于我们没有使用标准的 new 运算符,因此内存并没有被 new 分配,所以我们没有使用 delete 来销毁这些对象。相反,我们显式地调用了每个对象的析构函数。

3. 使用 Placement New 创建对象数组

我们可以使用 placement new 来在数组中创建多个对象。这种方法特别适合在内存池或者已分配内存的情况下创建对象数组。

假设我们创建一个 Reporter 类的数组,并使用 placement new 在这些位置创建每个 Reporter 对象。

#include <new>
#include <iostream>

class Reporter {
public:
    Reporter() { std::cout << "Reporter constructor called" << std::endl; }
    ~Reporter() { std::cout << "Reporter destructor called" << std::endl; }
};

int main() {
    // 创建足够大的缓冲区来存储 Reporter 对象的数组
    char buffer[sizeof(Reporter) * 5];

    // 使用 placement new 在缓冲区中创建 Reporter 对象数组
    Reporter* reporters = reinterpret_cast<Reporter*>(buffer);
    for (int i = 0; i < 5; ++i) {
        new (&reporters[i]) Reporter;  // 在每个位置使用 placement new 创建 Reporter
    }

    // 手动调用析构函数销毁每个对象
    for (int i = 0; i < 5; ++i) {
        reporters[i].~Reporter();
    }

    return 0;
}

在这个例子中,我们为 5Reporter 对象分配了足够的内存,并使用 placement new 在每个位置创建了对象。由于内存是由我们手动提供的,所以我们必须手动调用每个对象的析构函数。

4. 为什么要使用 Placement New?

placement new 的一个关键优势是在你已经有一块预分配的内存时,它允许你在这块内存中直接创建对象。这样你就可以避免内存碎片化或者手动管理堆内存的复杂性。它通常用于以下场景:

  • 内存池管理:你可以预先分配一块大内存,然后使用 placement new 来按需创建对象,避免了动态分配的开销和内存碎片问题。
  • 嵌入式编程:在嵌入式系统中,内存管理通常非常严格,可能需要在已知的内存位置创建对象。
  • 性能优化:使用 placement new 可以减少内存分配操作,特别是在频繁创建和销毁大量对象的场合。

5. 结论

使用 placement new 让你可以精确地控制内存的分配和对象的创建位置。这对于特定的内存管理需求(如内存池或嵌入式编程)非常有用。虽然它比标准的 new 更加复杂(需要显式调用析构函数,手动管理内存),但是它提供了更大的灵活性和控制力。

下一期视频中,我们将继续探索如何有效使用 placement new 来优化对象的创建和销毁过程。敬请期待!

012 Placement new - 第三部分

使用 Placement New 初始化 Reporter 对象数组

在本视频中,我们将学习如何使用 placement new 操作符来初始化一个 Reporter 对象数组。我们首先需要确保分配原始内存,因为如果使用 new 操作符,它会同时分配和初始化内存,而我们只需要分配内存。

1. 分配原始内存

首先,我们不能直接使用 new 表达式来分配内存,因为它不仅会分配内存,还会调用构造函数来初始化内存。为了仅分配原始内存,我们将使用 operator new 函数。operator new 函数负责分配内存,但不会初始化它。所以,我们将直接调用 operator new 来分配足够的内存来存储五个 Reporter 对象。

#include <new>  // 为了使用 operator new
#include <iostream>

class Reporter {
public:
    Reporter() { std::cout << "Reporter constructor called" << std::endl; }
    ~Reporter() { std::cout << "Reporter destructor called" << std::endl; }
};

int main() {
    // 使用 operator new 分配原始内存,分配足够存储五个 Reporter 对象
    void* buffer = ::operator new(sizeof(Reporter) * 5);

    // 强制转换为 Reporter* 类型
    Reporter* reporters = static_cast<Reporter*>(buffer);

    // 没有调用构造函数,只有内存分配
    return 0;
}

在这个示例中,我们使用 operator new 来分配一块足够大的内存区域来存储五个 Reporter 对象。但请注意,new 运算符并没有被调用,所以没有构造函数被调用,内存只是被简单地分配了。

2. 使用 Placement New 初始化对象

接下来,我们将使用 placement new 操作符在分配的内存中初始化 Reporter 对象。我们通过循环来依次将每个 Reporter 对象放入分配的内存区域。

#include <new>  // 为了使用 placement new
#include <iostream>

class Reporter {
public:
    Reporter() { std::cout << "Reporter constructor called" << std::endl; }
    ~Reporter() { std::cout << "Reporter destructor called" << std::endl; }

    void getData() const {
        std::cout << "Reporter data" << std::endl;
    }
};

int main() {
    // 分配足够的内存来存储 5 个 Reporter 对象
    void* buffer = ::operator new(sizeof(Reporter) * 5);
    Reporter* reporters = static_cast<Reporter*>(buffer);

    // 使用 placement new 初始化每个 Reporter 对象
    for (int i = 0; i < 5; ++i) {
        new (&reporters[i]) Reporter();  // 在指定位置创建 Reporter 对象
    }

    // 显示每个 Reporter 对象的数据
    for (int i = 0; i < 5; ++i) {
        reporters[i].getData();
    }

    // 手动调用析构函数
    for (int i = 0; i < 5; ++i) {
        reporters[i].~Reporter();
    }

    // 释放内存
    ::operator delete(reporters);
    return 0;
}

在这个示例中,我们在每次循环中使用 placement new 操作符将 Reporter 对象创建在已经分配的内存区域。由于内存分配没有调用构造函数,所以必须使用 placement new 来初始化每个对象。

3. 内存地址的计算

内存的分配是连续的,因此我们可以通过偏移来计算每个 Reporter 对象存放的内存地址。假设每个 Reporter 对象占用 4 字节,那么我们可以通过将索引值加到内存地址上来计算每个对象的存储位置。

#include <new>
#include <iostream>

class Reporter {
public:
    Reporter() { std::cout << "Reporter constructor called" << std::endl; }
    ~Reporter() { std::cout << "Reporter destructor called" << std::endl; }

    void getData() const {
        std::cout << "Reporter data" << std::endl;
    }
};

int main() {
    // 分配足够大的内存来存储 5 个 Reporter 对象
    void* buffer = ::operator new(sizeof(Reporter) * 5);
    Reporter* reporters = static_cast<Reporter*>(buffer);

    // 使用 placement new 初始化每个 Reporter 对象
    for (int i = 0; i < 5; ++i) {
        new (&reporters[i]) Reporter();  // 在指定位置创建 Reporter 对象
        std::cout << "Object " << i + 1 << " is at address: " << &reporters[i] << std::endl;
    }

    // 显示每个 Reporter 对象的数据
    for (int i = 0; i < 5; ++i) {
        reporters[i].getData();
    }

    // 手动调用析构函数
    for (int i = 0; i < 5; ++i) {
        reporters[i].~Reporter();
    }

    // 释放内存
    ::operator delete(reporters);
    return 0;
}

这个例子中,我们在每次创建 Reporter 对象时,输出它的内存地址。你可以看到,每个 Reporter 对象按顺序存储在连续的内存位置。

4. 手动调用析构函数

当使用 placement new 时,内存分配和对象创建是分开处理的,因此我们需要手动调用析构函数来释放资源。如果不调用析构函数,可能会导致资源泄漏,特别是当对象涉及动态内存或其他资源时。

在上述代码中,我们使用了 ~Reporter() 来手动调用每个 Reporter 对象的析构函数。然后,我们使用 ::operator delete 释放内存,确保没有资源泄漏。

5. 总结

通过本视频中的示例,我们展示了如何使用 placement new 操作符来初始化一个对象数组。在这种情况下,我们首先通过 operator new 分配内存,然后使用 placement new 在这块内存中创建和初始化对象。需要注意的是,内存的释放和析构函数的调用都需要手动进行。

在下一个视频中,我们将继续探索 placement new 的其他应用场景。敬请期待!

013 Placement new - 第四部分

使用 Placement New 优化内存布局

在上一个视频中,我们已经看到如何使用 placement new 在数组中创建对象,从而避免临时对象的创建。在本视频中,我将展示另一个 placement new 的应用场景。我们将使用 placement new 来优化内存的布局,使得对象的不同部分可以被连续存储,从而提高访问效率。

1. 重载 new 和 delete 操作符

为了展示 placement new 的使用,我首先重载了不同形式的 newdelete 操作符。这可以帮助我们跟踪每次内存分配和释放的过程。例如,当我们尝试分配内存并释放它时,我们能够看到 newdelete 操作符是何时被调用的。如果使用数组形式的 newdelete,我们也能追踪到这些操作。

2. 创建一个 String 类

我们将创建一个 String 类,该类能够动态增长或缩小字符串的存储空间。当我们将字符串存储到这个类中时,内存会根据需要进行分配和释放。

#include <iostream>
#include <cstring>

class String {
private:
    char* buffer;  // 存储字符串的缓冲区
    int* length;   // 存储字符串的长度

public:
    // 构造函数,接受一个 C 风格的字符串
    String(const char* str) {
        length = new int;  // 分配内存存储长度
        *length = std::strlen(str);  // 计算字符串的长度

        buffer = new char[*length + 1];  // 分配内存存储字符串,包括终止字符
        std::strcpy(buffer, str);  // 复制字符串到 buffer 中
    }

    // 析构函数,释放内存
    ~String() {
        delete[] buffer;
        delete length;
    }

    // 打印当前字符串的状态
    void print() const {
        std::cout << "String: " << buffer << ", Length: " << *length << std::endl;
    }
};

这个 String 类有两个成员:bufferlengthbuffer 用于存储实际的字符串,而 length 存储字符串的长度。我们为每个成员分配了单独的内存空间,但是这些内存块在物理内存中的位置是分开的。这样在访问时,操作系统可能需要将它们加载到 CPU 缓存中两次,这会带来性能上的损失。

3. 使用 Placement New 优化内存布局

为了优化内存访问,我们希望将 bufferlength 存储在连续的内存空间中。这可以通过 placement new 来实现。具体来说,我们会为 bufferlength 分配一个连续的内存块,在这个内存块中,我们将通过 placement new 来构建它们的对象。

#include <iostream>
#include <cstring>
#include <new>  // 使用 placement new 需要包含这个头文件

class String {
private:
    char* buffer;  // 存储字符串的缓冲区
    int* length;   // 存储字符串的长度

public:
    // 构造函数,接受一个 C 风格的字符串
    String(const char* str) {
        // 分配足够的内存来存储 buffer 和 length(包括 null 终止符)
        void* memory = ::operator new(sizeof(char*) + sizeof(int*));

        // 使用 placement new 初始化 buffer 和 length
        buffer = new (memory) char[std::strlen(str) + 1];  // 在已分配的内存中创建 buffer
        length = new (static_cast<char*>(memory) + sizeof(char*)) int;  // 在 buffer 后创建 length

        *length = std::strlen(str);  // 计算字符串的长度
        std::strcpy(buffer, str);  // 复制字符串到 buffer 中
    }

    // 析构函数,释放内存
    ~String() {
        delete[] buffer;
        ::operator delete(length);  // 释放 memory 中的 length 部分
    }

    // 打印当前字符串的状态
    void print() const {
        std::cout << "String: " << buffer << ", Length: " << *length << std::endl;
    }
};

在这个实现中,我们首先分配了一块足够大的内存来存储 bufferlength。然后我们使用 placement new 将这两个对象创建在这块内存上。通过这种方式,bufferlength 存储在连续的内存区域中,从而优化了内存访问,减少了 CPU 缓存加载的次数。

4. 运行示例并观察效果

int main() {
    String str("Hello, World!");  // 创建 String 对象

    str.print();  // 打印字符串和长度

    return 0;
}

运行这个程序时,我们将看到内存是如何分配和释放的。每次调用 newdelete 时,我们可以在控制台中看到相关的输出信息,帮助我们理解 placement new 的应用效果。

5. 总结

在这个视频中,我们展示了如何使用 placement new 来优化内存布局。通过将 String 类中的两个成员 bufferlength 存储在连续的内存区域中,我们可以减少内存访问的延迟,提高程序的性能。这是 placement new 的另一个重要应用场景,特别是在内存布局对性能有较大影响的情况下。

在下一个视频中,我们将继续探讨更多关于 placement new 的使用案例。敬请期待!

014 Placement new - 第五部分

使用 Placement New 优化内存分配和访问

在前一个视频中,我们介绍了如何通过 placement new 操作符来优化内存布局,提高访问速度。本视频中,我们将继续使用 placement new,但是在一个不同的场景中。我们将实现一个新的构造函数和析构函数,通过 placement new 来优化内存分配,同时减少不必要的内存分配操作。

1. 重新实现构造函数

在这个版本的实现中,我们将使用 placement new 来优化 String 类的构造函数。首先,我们需要为字符串和长度分配足够的内存。通过使用 operator new 函数,我们可以分配一个原始的内存块,这个内存块足够容纳字符串的长度和实际的字符串内容。

#include <iostream>
#include <cstring>

class String {
private:
    void* m_pBuffer;  // 存储缓冲区(包含字符串和长度)
    size_t* m_pLength; // 存储字符串的长度

public:
    // 构造函数,接受一个 C 风格的字符串
    String(const char* str) {
        // 计算字符串的长度并分配内存(包含长度和字符串本身)
        size_t length = std::strlen(str);
        m_pBuffer = ::operator new(sizeof(size_t) + length + 1); // 为长度和字符串分配内存

        // 使用 placement new 初始化长度
        m_pLength = new (m_pBuffer) size_t(length); // 存储字符串长度
        // 使用 pointer arithmetic 将剩余内存区域用于存储字符串
        char* buffer = static_cast<char*>(m_pBuffer) + sizeof(size_t); // 计算字符串存储位置
        std::strcpy(buffer, str);  // 复制字符串
    }

    // 析构函数,释放内存
    ~String() {
        ::operator delete(m_pBuffer);  // 释放内存
    }

    // 打印字符串及其长度
    void print() const {
        char* buffer = static_cast<char*>(m_pBuffer) + sizeof(size_t); // 计算字符串存储位置
        std::cout << "String: " << buffer << ", Length: " << *m_pLength << std::endl;
    }
};

2. 内存分配与访问

在这个版本中,我们首先使用 operator new 来分配一块足够大的内存,它将同时容纳字符串长度和字符串内容。然后,我们通过 placement new 来初始化 m_pLength(字符串的长度)。接着,我们通过指针运算,将 m_pBuffer 指向字符串的存储位置,并复制字符串内容。

值得注意的是,我们只使用了一次内存分配,这减少了不必要的内存分配操作。

3. 析构函数优化

在析构函数中,我们只需要调用 operator delete 来释放整个内存块,因为 m_pLength 和字符串内容都存储在同一块内存中。我们不再需要分别释放 lengthbuffer,这使得析构函数更简洁和高效。

~String() {
    ::operator delete(m_pBuffer);  // 释放内存
}

4. 运行效果

运行以下代码,输出将会显示:

int main() {
    String str("Hello, World!");  // 创建 String 对象
    str.print();  // 打印字符串和长度
    return 0;
}

运行结果:

String: Hello, World!, Length: 13

5. 性能优化分析

通过将长度和字符串存储在连续的内存空间中,我们可以显著提高内存访问速度。因为操作系统可以将这整个内存块加载到 CPU 缓存中,从而减少内存访问的延迟。

与之前的实现相比,访问长度和字符串的效率大大提高,因为它们不再分散存储在不同的内存位置,而是连续存储,这优化了 CPU 缓存的使用。

6. 进一步优化

在当前实现中,我们仍然使用了两个指针:m_pBufferm_pLength。实际上,这可以进一步优化成只使用一个指针来同时存储字符串和长度。虽然这样可以减少指针的数量,但在访问时,我们需要执行一些指针运算。这个优化可以作为一个练习留给你,你可以尝试将这两个指针合并成一个,并通过指针算术来访问长度和字符串。

7. 标准库的类似实现

在标准库中,shared_ptr 也实现了类似的技术。函数 make_shared 会通过调用 operator new 来分配内存,同时为类型和控制块分配内存,然后使用 placement new 将对象放置在内存的正确位置。这种技术非常高效,常用于智能指针的实现。

8. 使用 Placement Delete

在当前的实现中,我们并未使用 placement delete。通常,placement delete 并不必要,除非你使用 placement new 创建了一个对象,而该对象的构造函数可能抛出异常。在这种情况下,为了避免内存泄漏,可能需要重载 placement delete 操作符来正确释放内存。

我将在后续的视频中展示如何在异常情况下使用 placement delete 来防止内存泄漏。

9. 总结

今天我们展示了如何使用 placement new 来优化内存的分配和布局,减少内存分配次数,并提高访问效率。通过将字符串的长度和内容存储在连续的内存区域中,我们能够显著提高程序的性能。虽然当前的实现已经优化了内存使用,但仍然有进一步优化的空间,可以通过减少指针数量或使用更高级的内存布局来进一步提升性能。

在下一个视频中,我们将继续探讨关于 placement newdelete 的更多应用场景。感谢观看,期待在下一个视频中见到你!

015 运算符 new 和 delete 函数

动态内存分配与 newdelete 操作符

欢迎回来!在本视频中,我们将深入探讨 C++ 中不同类型的 new 表达式,并展示这些表达式对应的函数调用。通过对比不同类型的内存分配和释放方式,你将理解 C++ 中内存管理的灵活性和细节。

1. 不同类型的 new 表达式

在 C++ 中,new 操作符有多个变体,下面我们将逐一介绍它们及其对应的函数:

  • 标准 new 表达式

    int* ptr = new int;

    这个表达式会调用 operator new 函数,接受一个 size_t 参数。我们之前在视频中已经展示过该函数。

  • 非抛出 new 表达式

    int* ptr = new(std::nothrow) int;

    这个表达式会调用 operator new 函数的另一种变体,它接受一个 no_throw_t 类型的参数。这确保了在内存分配失败时不会抛出异常,而是返回一个 nullptr

  • placement new

    int* ptr = new (buffer) int;

    这种形式的 new 会调用一个特定的函数,它使用一个 void* 类型的指针作为第二个参数。这里,placement new 只会在给定的地址上构造对象,而不会分配内存。

  • 带有用户自定义参数的 placement new

    new (buffer) MyClass(arg1, arg2);

    这种 placement new 可以接受用户提供的多个参数,允许在特定的内存位置上构造对象。

  • 数组形式的 new 表达式
    对于数组的分配,我们有类似的 new[] 表达式。例如:

    int* arr = new int[10];

    对应的函数是 operator new[],它也接受 size_t 参数。

    • 非抛出版本的数组 new

      int* arr = new(std::nothrow) int[10];
    • 数组的 placement new

      new (buffer) int[10];
    • 带有用户自定义参数的数组 placement new

      new (buffer) int[10](init_value);

2. deleteplacement delete 操作符

new 表达式对应,deleteplacement delete 操作符也有不同的变体,下面是详细的介绍:

  • 标准 delete 表达式

    delete ptr;

    这会调用 operator delete 函数,接受一个 void* 指针,指向需要释放的内存地址。

  • 非抛出 delete 表达式

    delete(std::nothrow) ptr;

    当使用非抛出版本的 new 时,内存分配失败时不会抛出异常,而是返回 nullptr,此时对应的 delete 也会被调用。

  • placement delete
    当使用 placement new 分配内存时,你可能需要使用 placement delete 来销毁对象,但不释放内存。placement delete 也没有直接的表达式对应,它通常用于需要释放构造对象的资源时。

  • 带有用户自定义参数的 placement delete
    这种形式的 placement delete 也不会释放内存,仅会销毁对象,并可接受用户自定义的参数。

  • 数组形式的 delete
    如果你使用了数组版本的 new[],你需要使用数组版本的 delete[]

    delete[] arr;

3. newmalloc 的对比

在 C 语言中,内存分配通常使用 mallocrealloc 函数,而 C++ 提供了更为灵活的 newdelete 操作符。下面是 mallocnew 之间的几个关键区别:

  • malloc 是一个函数,而 new 是一个操作符
    new 作为操作符具有更大的灵活性,可以重载,而 malloc 则无法重载。

  • malloc 需要显式指定内存大小,而 new 会自动计算大小
    使用 malloc 时需要提供大小,这可能导致错误,比如分配的内存不够大。而 new 自动计算所需的内存大小,避免了这类错误。

  • malloc 无法初始化内存,而 new 会初始化内存
    malloc 仅分配内存,但无法初始化内存内容。而 new 会调用构造函数进行初始化。对于数组,new[] 会将每个元素初始化为默认值。

  • malloc 返回 void* 类型,必须强制转换,而 new 返回正确类型的指针
    使用 malloc 时,返回的是 void*,需要进行类型转换。而 new 返回正确类型的指针,无需额外的类型转换。

  • malloc 无法调用构造函数,而 new 可以
    由于 malloc 是 C 语言的函数,它不能调用构造函数。而 new 是 C++ 操作符,可以在内存分配的同时调用对象的构造函数。

  • malloc 不能自定义,new 可以通过重载进行自定义
    malloc 是一个固定的函数,不能被重载。而 newdelete 都是操作符,可以根据需要进行重载,从而实现自定义的内存分配策略。

  • 错误处理:malloc 返回 NULL,而 new 抛出异常
    malloc 分配内存失败时,它会返回 NULL,程序员必须手动检查这个返回值。如果不检查并使用 NULL,会导致运行时错误。而 new 会抛出 std::bad_alloc 异常,这强迫程序员处理内存分配失败的情况,避免了潜在的错误。

4. 总结

通过今天的内容,我们更深入地理解了 C++ 中 newdelete 操作符的多种形式及其对应的函数。与 C 语言的 mallocrealloc 相比,C++ 的 newdelete 操作符提供了更灵活和安全的内存管理方式。此外,我们也看到,内存管理的方式会直接影响程序的性能和稳定性。

在下一节中,我们将讨论动态内存管理可能引发的问题,以及如何避免这些问题。感谢收看,我们下次见!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

03 - 内存管理问题

001 内存管理问题

内存管理中的问题及其避免方法

欢迎回来!在本节课程中,我将讨论在内存管理过程中可能遇到的一些问题,并教你如何避免这些问题。本视频将简要概述由于内存管理引发的不同类型的问题。手动内存管理如果没有正确处理,可能会引发错误。导致这些问题的原因有很多,我们将深入探讨这些原因。

1. 指针的初始化问题

指针用于存储内存地址,但在使用指针之前,我们必须确保它指向一个有效的内存地址。如果没有正确初始化指针,指针可能包含一个未知的值,这个值可能是一个有效的内存地址,也可能是无效的地址。

  • 有效地址的潜在问题:如果指针指向一个有效的地址,但这个地址可能属于程序的其他部分,并且存储了不同的数据,那么访问这个地址可能会导致错误的数据读取或覆盖。
  • 无效地址的潜在问题:如果指针指向一个无效的地址,尝试访问该地址(无论是读取还是写入)可能会导致程序崩溃或未定义行为。

2. 编译器和运行时行为

  • 编译器警告:大多数编译器会警告未初始化的变量,指针也会触发类似的警告。如果尝试访问一个未初始化的指针,编译器可能会拒绝编译代码。
  • 未定义行为:然而,并非所有编译器都会强制这一规则,某些情况下未初始化指针仍然可以通过编译,导致程序在运行时出现未定义行为。

因此,为了避免这种风险,你应该始终初始化指针。我们将在下一节视频中通过示例进一步展示未初始化指针的问题。

感谢收看,我们下次见!

002 未初始化指针 - 第一部分

未初始化指针的示例

在本视频中,我们将演示一个未初始化指针的问题。假设有一个函数用于为某些数据分配内存。我们将从用户处获取输入,并编写一个简单的条件语句。如果输入的值大于 100,我们就为一个整数分配内存,并将该地址存储在指针 p_value 中。然后,我们将返回该指针。

示例代码:

int* GetData() {
    int* p_value;
    int input;
    std::cin >> input;
    if (input > 100) {
        p_value = new int;  // 动态分配内存
        *p_value = input;   // 存储数据
        return p_value;     // 返回指针
    }
    return nullptr;
}

你可能会好奇,为什么我不直接返回值?假设这些数据不能通过值返回,所以我们为其分配内存并返回指向该数据的指针。你可能已经注意到这里有一个潜在的 bug,但我们先不处理,后面会再讲到。

接下来,我将创建一个 serialize 函数,用于将数据序列化并写入文件。该函数接受一个指向数据的指针,并将其写入文件:

序列化函数:

void Serialize(int* data) {
    std::ofstream file("data.txt");
    if (!file.is_open()) {
        std::cout << "Error opening file!" << std::endl;
        return;
    }
    file << *data;  // 写入指针指向的值
    file.close();    // 自动关闭文件
}

main 函数中,我们调用 GetData 获取数据指针,然后将其传递给 Serialize 函数,并在程序结束时释放内存:

主函数代码:

int main() {
    int* p_value = GetData();
    if (p_value) {
        Serialize(p_value);
        std::cout << "Data serialized." << std::endl;
        delete p_value;  // 释放内存
    }
    return 0;
}

问题描述

在这个示例中,有几个问题,但是我们先运行程序看看会发生什么。请注意,我们在调试模式下编译代码,调试模式下,编译器会启用标准库的检查,这有助于开发者捕获错误。发布模式下,这些检查是关闭的。

首先,我们正常运行程序,输入一个大于 100 的值(例如 250):

  • 程序正确输出 Data serialized.,并且数据被成功写入文件。
  • 退出代码为零,表示程序正常退出。

接下来,我们运行程序,输入一个小于 100 的值(例如 50):

  • 程序崩溃,出现运行时检查失败的错误,错误消息为:

    runtime check failure #3: variable 'p_value' is being used without being initialized
    

    这是因为在 Visual Studio 中,编译器检查到指针 p_value 在没有初始化的情况下被使用。很多编译器会在读取未初始化的变量之前检查其是否已初始化。如果在运行时尝试访问未初始化的指针,Visual Studio 会报告这种错误,但并不是所有编译器都会这样做。

调试模式与发布模式的差异

  • 调试模式:编译器会进行内存检查,捕获未初始化指针的问题,程序会提示错误并停止执行。
  • 发布模式:没有这些检查,程序不会立即显示错误信息,程序会崩溃,但我们不知道具体的错误原因。

在发布模式下,程序崩溃但没有详细错误提示,这意味着程序没有正常退出,退出代码不是零。由于发布模式下没有启用内存安全检查,你只能依赖其他手段进行调试和错误排查。

总结

  • 未初始化指针的风险:未初始化的指针可能指向不明确的内存地址,导致未定义行为。访问这些地址可能会导致程序崩溃或读取无效数据。
  • 调试模式的帮助:调试模式会检查未初始化的指针,帮助开发者捕捉潜在错误。
  • 发布模式的隐患:发布模式下没有内存检查,程序可能会在崩溃时不提供足够的错误信息,因此开发者需要更加小心,确保代码中的指针在使用前已经正确初始化。

我们下次见!

003 未初始化指针 - 第二部分

确保程序不崩溃的措施

在本视频中,我们将继续讨论未初始化指针的问题,并探讨如何确保程序不崩溃。为了解决这个问题,我们可以添加一个条件来检查指针的地址。问题是我们应该与什么值进行比较?

比较指针与 null 指针

如果我们将指针与空指针进行比较,可能会抛出异常。为此,我们可以编写一个 try-catch 块。但这会引入更多问题:如果内存成功分配并且在随后的代码中抛出异常,那么 delete 操作符将永远不会执行。我们稍后会处理这些情况。

程序行为示例

现在,我们先来看一下程序在这种实现下的行为。我们在调试构建下进行编译,并给出一个小于 100 的值。程序再次崩溃。为什么没有抛出异常?我们没有看到异常,但我们确实编写了代码来比较指针值与空指针。问题是,当值小于 100 时,p_value 指针会直接返回,但是指针赋值时,它是未初始化的。

在 Visual Studio 中,未初始化的内存会被赋予一个特殊的值。让我们在此处设置一个断点并调试代码。然后,我们可以查看指针的值。

调试和查看未初始化指针

在调试时,执行到下一行代码之前,我们将 p_value 的值拖到“观察窗口”(watch window)中,查看其内容。你会看到它包含一些垃圾值,这是 Visual Studio 用来表示未初始化变量的特殊值。

在继续执行代码时,我们可以看到,尽管我们给了用户输入并初始化了变量,但未初始化的指针 p_value 仍然包含那个特殊的“未初始化”值。继续调试时,程序会出现异常,这是之前我们在调试模式下看到的相同错误信息。

关闭运行时检查

为了演示关闭运行时检查后的行为,我们将关闭对未初始化变量的检查。我们可以在 项目属性 中找到 代码生成(Code Generation),并关闭 基本运行时检查(Basic Runtime Checks)。设置为默认值之后,程序将不再在运行时崩溃。

我们再次设置断点并调试程序,这次输入一个小于 100 的值。你会看到,关闭运行时检查后,指针 p_value 不再包含之前那个特殊值,而是包含了一个地址,但这并不是一个有效的内存地址。如果我们试图评估该指针,条件将无法通过检查。

程序崩溃的原因

此时,如果程序继续执行,它可能会在尝试读取该无效内存地址时崩溃。因为该地址是无效的,因此行为是未定义的。

确保指针初始化

为了避免崩溃,我们必须确保在使用指针之前对其进行初始化。我们可以使用统一初始化语法(uniform initialization)来初始化指针和变量。

初始化后的程序行为

通过将指针和变量初始化后,我们运行程序,并输入一些值。程序运行时,我们会捕获到抛出的异常,确保程序不会崩溃,而是优雅地处理了错误情况。

总结

  • 始终初始化变量和指针:未初始化的指针可能会指向不明确的内存地址,导致程序崩溃或产生未定义行为。
  • 调试模式下的运行时检查:调试模式会启用运行时检查,帮助发现未初始化变量的潜在问题。在发布模式下,这些检查是关闭的,程序可能会在没有任何警告的情况下崩溃。
  • 关闭运行时检查的风险:尽管可以关闭运行时检查,但为了避免问题,建议始终初始化变量和指针。

在下一视频中,我们将讨论缓冲区溢出和下溢问题。再见!

004 缓冲区溢出 - 栈

内存覆盖问题

在本节中,我们将讨论在处理内存时可能遇到的另一个问题,即 内存覆盖。这种情况通常发生在你为数据分配的内存不足时。如果数据存储在内存中,它可能会溢出到周围的内存区域。这种情况被称为 缓冲区溢出,因为数据会溢出到周围的内存区域。这个区域可能包含一些重要的程序代码,导致数据丢失,甚至可能导致程序崩溃。因此,缓冲区溢出 在运行时会导致 未定义行为

缓冲区溢出的常见情况

缓冲区溢出通常发生在数组和字符串处理中,特别是当你自己手动管理内存时。为了避免这种问题,建议使用 标准库容器,因为这些容器会自动管理内存,并根据需要自动扩展。

缓冲区溢出示例

接下来,我将展示一个简单的缓冲区溢出示例。我们将使用静态数组来演示缓冲区溢出。

创建静态数组

首先,我们创建一个字符数组来存储一个人的地址,假设地址最多有 15 个字符。然后,我们还创建一个字符数组来存储该人的名字。我故意创建一个较小的名字缓冲区,以展示缓冲区溢出的效果。程序会提示用户输入数据,以帮助用户理解期望的输入格式。

char address[15]; // 用来存储地址
char name[4];     // 用来存储名字(故意给它一个较小的大小)

程序会要求用户输入地址和名字,并将其打印出来。

输入数据和缓冲区溢出

如果输入的数据符合预期,例如地址为 "Park Lane" 和名字为 "Bob",程序将按预期正常工作,打印出正确的地址和名字。示例输出如下:

Address: Park Lane
Name: Bob

但是,如果在输入名字时,输入的名字长度超过了 4 个字符(例如,输入 "Robert"),程序将出现错误,提示缓冲区溢出。具体的错误信息是:

Runtime Check Failure #2: Stack Corruption - The variable 'address' was corrupted.

调试缓冲区溢出

在调试时,我们可以看到地址和名字数组的内存位置非常接近。当我们输入一个超长的名字时,名字的内容会覆盖地址的内存区域。这就是为什么程序输出的内容不正确,甚至导致了堆栈内存的损坏。

通过将 addressname 变量拖到 观察窗口(watch window),我们可以看到这两个数组的内存地址非常接近。当我们输入超长的名字时,address 数组的内存被 name 数组的内容覆盖,这会导致程序输出错误的数据。

发布模式下的表现

当我们在 发布模式(release build)下编译代码时,可能不会看到错误提示。因为编译器不会为这些局部变量添加运行时检查,这与我们之前讨论的未初始化变量类似。在发布模式下,编译器不会进行这些检查,因此不会显示类似的运行时错误。尽管程序没有崩溃,但由于内存被覆盖,输出仍然是不正确的。

解决缓冲区溢出问题

缓冲区溢出会导致程序行为不可预测,因此要避免这种情况。为此,我们可以采取以下措施:

  • 始终检查数组和字符串的边界:确保数组或字符串的大小足够大,以容纳所有数据,并避免溢出。
  • 使用动态内存分配:如果需要动态调整大小,可以使用动态内存分配(如 newmalloc)来避免静态数组溢出。
  • 使用标准库容器:如 std::vectorstd::string,它们会根据需要自动调整内存大小。

下一节预告

在下一节中,我将展示如果地址和名字是在堆上创建的情况,以及它们在堆上时会发生什么变化。再见!

005 缓冲区溢出 - 堆

堆内存中的缓冲区溢出

在上一节视频中,我们讨论了在基于栈的内存中发生缓冲区溢出时会发生什么情况。Visual Studio 在栈内存中进行了检查,并在缓冲区溢出时通知用户。现在,在这一节视频中,我们将讨论如果缓冲区溢出发生在堆内存中时,会发生什么情况。

代码改动

首先,让我们回顾一下上一节中的代码。我将把 addressname 改为指针,并在堆上分配内存。由于我们使用的是 new 运算符,所以应该使用 delete 运算符来释放内存。

char* address = new char[15]; // 在堆上为地址分配内存
char* name = new char[4];     // 在堆上为名字分配内存

正常情况

当程序运行时,数据会被存储在堆内存中而不是栈内存中。程序运行正常,没有问题。

造成缓冲区溢出

接下来,我们尝试引发缓冲区溢出。这时,程序会出现问题,退出码也不会是零。让我们在调试模式下运行程序。

堆损坏错误

当我们调试程序时,会弹出错误消息,提示堆损坏。具体的错误信息是:

Heap corruption detected after normal block number 155 at some address.
CRT detected that the application wrote to memory after end of heap buffer.

这个错误的意思是,应用程序写入了超出堆缓冲区末尾的内存。这就像往玻璃杯里倒水,如果不断倒水,最终水会溢出并洒到周围的区域。类似地,程序向缓冲区写入了数据,但这些数据太大,超出了分配的内存范围,导致了缓冲区溢出。

Visual Studio的堆调试功能

Visual Studio 在调试模式下使用调试堆库。这些库提供了很多帮助我们管理堆内存的功能。例如,调试堆库会在调用 delete 操作符时,自动检查堆的一致性。如果发生了缓冲区溢出等问题,调试堆库会显示诊断消息。

如果没有弹出消息框,您可以在 输出窗口 中查看相关的错误消息。

缓冲区下溢(Buffer Underflow)

除了缓冲区溢出,还有一种情况是 缓冲区下溢。我们可以直接在 name 指定的内存区域之前的索引位置写入一个字符,看看会发生什么。

name[-1] = 'A'; // 在分配的堆内存之前的内存位置写入数据

运行程序时,错误消息并不会弹出消息框,而是在 输出窗口 中显示:

Heap corruption detected before normal block CRT detected that the application wrote to memory before start of heap buffer.

这就是 缓冲区下溢 的情况,它会导致堆内存中分配区域之前的数据被覆盖。

使用 mallocrealloc 时的表现

如果你使用 mallocrealloc 函数来分配内存,并且发生了缓冲区溢出或下溢,程序也会显示类似的诊断消息。这些检查有助于我们发现并修复内存溢出或下溢问题。

发布模式中的表现

如果你将程序切换到发布模式(release build),就不会看到这些诊断消息了。程序运行时输出会变得不可预测。下面是发布模式下的示例:

Some part of the name was trimmed, but no error message is shown.

原因是,在发布模式下,堆管理器不使用调试功能,因此所有的检查都被移除了。这是为了保证程序性能不受影响。

结论

  • 调试模式下:Visual Studio 提供了堆内存的调试功能,可以帮助检测缓冲区溢出和下溢问题,并显示相关的诊断消息。
  • 发布模式下:由于没有调试功能,堆的检查被移除,程序可能不会报错,但内存溢出可能会导致未定义行为。

为了避免这些问题,应该尽量避免手动管理内存,使用现代 C++ 中的标准容器,如 std::vectorstd::string,它们会自动管理内存并避免缓冲区溢出和下溢。

在下一节视频中,我们将继续讨论其他内存管理问题。再见!

006 悬空指针 - 第一部分

悬挂指针 (Dangling Pointers)

在这一节视频中,我们将讨论悬挂指针。我们知道指针用于存储内存地址。如果内存地址位于堆中,意味着它是通过 new 分配的。当我们使用完该内存时,我们会删除指针指向的内存。但是,在删除内存后,指针仍然保持着该地址。这样,指针继续指向该内存地址,而这块内存已经不再有效,因为我们已经删除了它。

什么是悬挂指针?

这种指向无效内存的指针称为 悬挂指针 (Dangling Pointer)。如果我们在程序中进一步使用悬挂指针,可能会导致一些问题。

悬挂指针引发的问题

  1. 双重删除 (Double Delete)
    如果我们没有意识到指针已经被删除,可能会再次删除它。这种行为称为“双重删除”,它是未定义行为。大多数情况下,这会导致程序崩溃。

  2. 误用悬挂指针
    另一个问题是,如果我们不知情地使用了悬挂指针,可能会覆盖内存地址,写入一些数据。此时,该内存地址可能已经分配给了程序中的其他部分,导致数据丢失,程序行为不确定。

如何避免悬挂指针?

为了避免悬挂指针问题,关键在于在删除指针后立即将其赋值为 nullptr。如果之后再访问该指针(例如尝试读取或写入),它会引发访问违规(access violation),并且程序会终止。这是一个好事情,因为程序不会在无效状态下继续运行。

删除空指针的行为

如果我们再次对一个空指针调用 delete,不会有任何影响。delete 操作会直接跳过它,视为“无操作”(no-op)。

示例:悬挂指针

接下来,我们在 Visual Studio 中看一个悬挂指针的例子。

#include <iostream>
using namespace std;

int main() {
    // 动态分配内存
    int* ptr = new int(10);
    cout << "Value before delete: " << *ptr << endl;
    
    // 删除指针指向的内存
    delete ptr;
    
    // 现在 ptr 成为悬挂指针
    // 尝试访问它会导致未定义行为
    cout << "Value after delete: " << *ptr << endl; // 这将导致悬挂指针问题

    // 正确的做法是将 ptr 置为 nullptr
    ptr = nullptr;

    // 尝试再次删除空指针,什么也不会发生
    delete ptr;  // 不会有任何影响
    cout << "Pointer safely deleted." << endl;

    return 0;
}

解释

  1. 动态分配内存:我们使用 newptr 分配了内存,并将值设为 10
  2. 删除内存:通过 delete 释放了 ptr 指向的内存。此时,ptr 变成了一个悬挂指针。
  3. 悬挂指针问题:当我们尝试访问已删除的内存(通过打印 *ptr)时,会引发未定义行为。
  4. 避免悬挂指针:我们将 ptr 设置为 nullptr,这样后续的操作不会访问无效内存。
  5. 删除空指针:对 nullptr 调用 delete 不会产生任何影响,是一种安全的操作。

总结

  • 悬挂指针 是指指向已经被删除或释放的内存的指针。
  • 双重删除误用悬挂指针 都是悬挂指针常见的问题,可能导致程序崩溃或数据丢失。
  • 为了避免悬挂指针,应该在删除指针后立即将其设置为 nullptr,这样访问悬挂指针时会导致程序终止,从而避免无效状态。
  • 对空指针调用 delete 是安全的,什么也不会发生。

通过正确管理指针,特别是在删除内存后将其设为 nullptr,我们可以避免悬挂指针带来的问题,确保程序的健壮性。

007 悬空指针 - 第二部分

悬挂指针的示例

在本节视频中,我们将通过一个示例来展示悬挂指针的行为。假设我们为一个整数分配内存,并通过一个名为 print 的函数来打印这块内存的内容。然后,当我们完成使用该指针后,我们会调用 delete 来释放它的内存。

示例代码

#include <iostream>
using namespace std;

void print(int* p) {
    cout << "Value: " << *p << endl;
}

int main() {
    // 为整数分配内存
    int* p = new int(10);
    print(p);  // 打印指针指向的值
    
    // 释放内存
    delete p;
    
    return 0;
}

正常运行

在正常情况下,程序将按预期工作,打印出值 10,然后释放内存。我们将 p 指向的内存内容打印出来,并且程序结束时通过 delete 释放这块内存。

悬挂指针的情况

现在,假设我们将 delete 调用放在调用 print 函数之前:

int* p = new int(10);
delete p;   // 删除指针指向的内存
print(p);   // 尝试打印已删除的内存

发生了什么?

在这种情况下,程序的行为变得不确定。它可能会打印一些垃圾值。我们可以通过调试代码来看发生了什么。假设 p 在调试窗口中被观察,我们还可以在内存窗口中查看该地址的内容。

  1. 内存地址被覆盖:当调用 delete p 后,内存地址的内容会被覆盖,表示这块内存不再有效。
  2. 读取已删除的内存:如果我们尝试读取已删除的内存,程序会输出一些不确定的值,因为该内存已经被释放,指向的地址不再有效。

写入悬挂指针内存

更糟糕的是,如果我们在删除指针后继续写入该内存,可能会导致程序行为异常。举个例子:

* p = 100;  // 写入已删除内存

即使程序成功执行,看起来好像程序运行没有问题,实际上我们已经向一个不属于我们的内存区域写入了数据。这种行为是不可预期的,属于 未定义行为

双重删除 (Double Delete)

另一个常见的问题是 双重删除。即,如果程序员在指针被删除后再次调用 delete,会导致程序崩溃。我们可以这样修改代码来演示:

delete p;   // 删除内存
delete p;   // 尝试再次删除已删除的内存

在调试模式下,程序会崩溃,并且退出代码将不为零。这是因为我们尝试双重删除同一块内存。需要注意的是,在发布版本(Release Build)中,你可能不会看到这个崩溃现象,因为调试模式会添加一些额外的检查,而发布版本不会进行这些检查。

如何避免悬挂指针

为了避免悬挂指针和双重删除问题,我们应该在删除指针后立即将其赋值为 nullptr,这样一来,任何后续的访问操作都会导致访问违规(Access Violation),并使程序终止。这是一个很好的保护机制,因为它防止程序继续在无效状态下运行。

delete p;  // 删除指针
p = nullptr;  // 将指针设为 nullptr

示例:使用 nullptr 避免错误

int* p = new int(10);
delete p;  // 删除指针
p = nullptr;  // 将指针设为 nullptr

此时,如果我们尝试访问或删除空指针,程序将立即崩溃,并给出访问违规错误。这是因为空指针指向的内存区域通常被操作系统保护,任何尝试访问都会被阻止。

总结

  • 悬挂指针:当指针指向已经被删除的内存时,它变成悬挂指针。使用悬挂指针会导致未定义行为。
  • 双重删除:如果在删除指针后再次删除它,程序会崩溃。为了避免这种情况,在删除后立即将指针设置为 nullptr
  • 安全的做法:在删除指针后立即将其赋值为 nullptr,以防止程序访问已删除的内存。

通过这些措施,我们可以避免悬挂指针问题,并提高程序的安全性和稳定性。

008 内存泄漏 - 第一部分

内存泄漏的介绍

在前几个视频中,我们讨论了 C 和 C++ 开发人员在进行内存管理时常遇到的问题。在本视频中,我们将讨论 内存泄漏 问题,这是处理动态内存时最常见的问题。

当内存被分配后,如果我们不再需要它,应该及时释放它。但是如果内存没有被释放,内存地址就会丢失,那么这块内存将永远无法被删除。如果这种情况频繁发生,或者在程序运行时进行大量的内存分配和未释放的操作,堆内存最终将耗尽。当堆内存耗尽时,程序可能会出现不可预测的行为。

内存泄漏带来的问题

  • 操作系统增加页面文件大小:如果内存泄漏持续发生,操作系统可能会增加页面文件的大小。页面文件的增加会导致系统性能大幅下降。
  • 程序无法正常运行:程序可能无法正确响应用户输入,导致用户体验极差。
  • 程序被操作系统终止:在某些情况下,程序可能被操作系统强制终止,造成程序崩溃。

接下来,我们通过一些示例来展示内存泄漏。

示例 1:简单的内存泄漏

在下面的代码中,我们为一个整数分配内存,并在不再需要时释放它。然后,如果我们不释放指针,当 main 函数返回时,这个指针变量会被销毁,从而我们失去对这块内存的引用。此时,这块内存就无法被释放了。

#include <iostream>
using namespace std;

int main() {
    int* p = new int(10);  // 分配内存
    delete p;  // 释放内存
    return 0;
}

内存泄漏的严重性

虽然在这个简单示例中,即使存在内存泄漏,当程序退出时,操作系统会回收所有进程分配的内存,因此这个泄漏不会对程序产生严重影响。但是,这并不意味着我们可以忽视 delete 的调用。你应该始终确保在不再使用动态分配的内存时,调用 delete 来释放它。

示例 2:更复杂的内存泄漏

现在,假设我们需要创建一个应用程序,能够显示硬盘上的图像文件,就像 Windows 照片查看器一样。程序会自动加载并显示下一张或上一张图像文件。我们将编写一些代码来模拟从硬盘加载文件的过程。

尽管我们不打算加载实际的图像文件,但我们会加载文本文件,并假设这些文本文件是图像文件。代码如下:

#include <iostream>
#include <fstream>
using namespace std;

void showFile(const string& filename) {
    ifstream file(filename);  // 打开文件
    if (!file) {
        cout << "File not found!" << endl;
        return;
    }

    // 获取文件大小
    file.seekg(0, ios::end);
    size_t size = file.tellg();
    file.seekg(0, ios::beg);

    // 为文件内容分配内存
    char* buffer = new char[size];
    char line[256];  // 用于存储每一行
    while (file.getline(line, 256)) {
        strcat(buffer, line);  // 将行数据连接到 buffer
        strcat(buffer, "\n");  // 添加换行符
    }

    // 显示文件内容
    cout << buffer << endl;

    // 忘记释放内存,这会导致内存泄漏
}

内存泄漏分析

在这个示例中,我们为文件内容分配了内存(buffer),并在函数结束时打印出了文件内容。但我们并没有释放这块内存。这样,当函数执行完后,buffer 中存储的内存将不会被释放,导致内存泄漏。

虽然这段代码在程序退出时操作系统会回收内存,但如果这种情况频繁发生,程序将很快消耗掉所有的堆内存,从而导致系统性能下降。

解决方法

为了避免内存泄漏,程序员应该在不再使用分配的内存时及时释放它。例如,我们可以在 showFile 函数的末尾加入 delete[] buffer; 来释放我们分配的内存:

delete[] buffer;  // 释放内存

通过确保每次分配的内存都被正确释放,我们可以有效避免内存泄漏的发生。

结论

内存泄漏是 C 和 C++ 编程中常见的问题,尤其是在动态内存管理中。如果不及时释放内存,程序可能会导致堆内存耗尽,进而影响系统的性能甚至导致程序崩溃。为了避免内存泄漏,程序员应该始终在不再使用动态分配的内存时调用 deletedelete[] 来释放它。在使用 C++ 时,建议使用 智能指针(如 std::unique_ptrstd::shared_ptr)来管理动态内存,从而减少内存泄漏的风险。

009 内存泄漏 - 第二部分

内存管理中的问题

在上一期视频中,我们编写了一个名为 ShowFile 的函数,用来模拟从硬盘加载图像文件。在这个例子中,我们使用文本文件来模拟加载图像文件。我们完成了代码,并指出其中有一些问题。但首先,让我们尝试运行程序,看看发生了什么。

程序崩溃

运行程序后,程序崩溃了。在之前的视频中,我们已经讨论了处理内存时可能遇到的问题。这里的问题是我们分配的缓冲区没有初始化。因为没有初始化,这些缓冲区包含一些垃圾值,可能还包含 null 终止字符。正因为如此,当我们将行数据连接到缓冲区时,可能会从某个其他位置开始复制,而不是从缓冲区的开始位置开始。这就是为什么我们在分配内存后应该及时初始化内存的原因。

修复初始化问题

让我们在程序中对分配的内存进行初始化,看看问题是否得到解决。再次运行程序后,问题解决了,程序正常工作了。

内存泄漏问题

然而,虽然程序现在没有崩溃,但还有一个问题需要解决。我们在这里和这里分配的内存并没有得到释放。如果 showFile 函数反复调用,并且没有及时释放内存,就会出现内存泄漏问题。

想象一下,如果用户通过箭头键浏览多张图像,而每次浏览都调用 showFile 函数,那么每次函数调用都会分配新的内存,而这些内存从未被释放。这种情况会导致堆内存被耗尽。为了展示这一点,我们将 showFile 函数放入一个 while 循环中,并且使用 try 块来捕获异常。我们还会增加一个较大的内存分配量来模拟长期运行的情况。

模拟内存耗尽

在以下示例中,我们将为文件分配 200MB 的内存,并重复调用 showFile 函数,直到内存耗尽:

#include <iostream>
#include <fstream>
using namespace std;

void showFile(const string& filename) {
    ifstream file(filename);  // 打开文件
    if (!file) {
        cout << "File not found!" << endl;
        return;
    }

    // 获取文件大小
    file.seekg(0, ios::end);
    size_t size = file.tellg();
    file.seekg(0, ios::beg);

    // 分配内存
    char* buffer = new char[size];
    char line[256];  // 用于存储每一行
    while (file.getline(line, 256)) {
        strcat(buffer, line);  // 将行数据连接到 buffer
        strcat(buffer, "\n");  // 添加换行符
    }

    // 显示文件内容
    cout << buffer << endl;

    // 忘记释放内存,这会导致内存泄漏
}

int main() {
    try {
        while (true) {
            showFile("image.txt");  // 模拟显示图像文件
        }
    } catch (...) {
        cout << "Memory allocation failed!" << endl;
    }
    return 0;
}

程序运行情况

运行程序后,我们看到程序在一段时间内正常运行,但随着内存逐渐耗尽,程序最终抛出了 new 失败的异常(bad_alloc 异常)。这表明堆内存不足,无法分配新的内存,从而导致程序崩溃。

解决内存泄漏

为了避免内存泄漏,我们需要在每次分配内存后及时释放它。在这个例子中,我们可以在 showFile 函数结束时调用 delete[] buffer; 来释放分配的内存。此外,line 数组也需要释放(如果使用动态分配的内存)。这样就能避免程序中的内存泄漏问题。

长时间运行的应用程序中的内存泄漏

如果在一个长时间运行的应用程序中,比如服务程序或服务器程序,存在这样的代码,内存泄漏问题可能不会立即显现。程序可能会运行数天没有任何问题,但随着时间的推移,它可能会变得越来越慢,最终崩溃。而在程序崩溃之前,追踪问题的根源会非常困难,尤其是当我们不知道问题出在哪里时。

智能指针的使用

显然,手动管理内存释放可能并不总是可行的,因为有时我们可能忘记释放早先分配的内存。为了解决这个问题,C++11 提供了 智能指针,包括 shared_ptrunique_ptr,它们可以自动管理内存的释放。虽然我们可以使用智能指针来避免内存泄漏,但在这个示例中,我们想直接处理内存,因此需要确保在合适的时候手动调用 delete 来释放内存。

结论

本视频介绍了 内存泄漏 问题,以及它如何影响程序的稳定性和性能。内存泄漏通常发生在我们分配了内存但没有及时释放的情况下,特别是在长时间运行的程序中,它可能导致堆内存耗尽,从而导致程序崩溃。为了避免这种情况,程序员应该始终在不再使用分配的内存时,调用 deletedelete[] 来释放内存。

在 C++ 中,我们还可以使用智能指针来自动管理内存,减少内存泄漏的风险。智能指针是 C++11 提供的强大功能,能够有效避免内存泄漏和其他内存管理问题。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

04 - 检测堆损坏

002 字符串类 - 第一部分

动态内存管理中的问题诊断

大家好,欢迎来到本课程的下一个部分。在这一部分中,我们将学习如何诊断与动态内存管理相关的问题,包括内存覆盖和内存泄漏等问题。为了帮助我们解释这些问题以及它们的解决方案,我将创建一个名为 String 的类。这个类将大量依赖于动态内存管理,并且它将帮助我们在程序中处理字符串。

创建 String

首先,我会为项目创建一个 String 类,这个类将包含动态分配的字符串缓冲区。我们将使用一个指针指向这个缓冲区,并且我们还需要存储字符串的长度,这样就不必每次需要长度时都去计算它了。

类的设计

我们将在这个 String 类中添加以下成员:

  1. 指针:指向存储字符串的缓冲区。
  2. 字符串长度:记录字符串的长度,避免重复计算。
  3. 构造函数
    • 有参构造函数:用于初始化字符串。
    • 拷贝构造函数:用于复制字符串对象。
  4. 赋值运算符:用于将一个字符串对象赋值给另一个字符串对象。
  5. 访问函数
    • getLength():返回字符串的长度。
    • getString():返回字符串对象的原始字符串。
  6. 重载操作符
    • +:用于连接两个字符串。
    • +=:复合赋值操作符,用于将一个字符串附加到当前字符串。
    • []:下标操作符,允许通过索引访问字符串中的字符。
  7. 检查功能:如果索引大于字符串长度,抛出异常。
  8. 插入函数insert(),用于在字符串的任意位置插入新的字符串。
  9. 追加函数append(),将字符串附加到当前字符串的末尾。
  10. 赋值函数assign(),功能类似于赋值操作符,用于将一个字符串的内容复制到当前对象。

这些是我们将在 String 类中实现的函数。现在我先停在这里,我们将在下个视频中继续实现这些成员函数。再见!

003 字符串类 - 第二部分

实现 String 类成员函数

大家好,欢迎回来。让我们开始逐个实现这些函数,并且在实现过程中,我还会做出一些调整。我们将为 insertappendassign 等函数重载版本,允许它们接受原始字符串和 string 对象作为参数。这样我们就可以同时使用这两个类型。

实现成员函数

首先,我们将从实现参数化构造函数开始:

参数化构造函数

  1. 我们的目标是首先计算字符串的长度。
  2. 然后分配内存来存储该字符串。
  3. 最后,我们将从原始字符串复制内容到缓冲区中。

代码如下所示:

String::String(const char* str) {
    // 计算字符串的长度
    int len = 0;
    while (str[len] != '\0') {
        len++;
    }
    
    // 分配内存
    m_length = len;
    m_pBuffer = new char[len + 1];  // +1 是因为我们需要存储 '\0'
    
    // 将字符串复制到缓冲区
    for (int i = 0; i < len; i++) {
        m_pBuffer[i] = str[i];
    }
    m_pBuffer[len] = '\0';  // 添加字符串结束符
}

拷贝构造函数

拷贝构造函数的实现与参数化构造函数类似,只是它接受一个 string 对象作为参数。为了避免重复代码,我们可以提取出一个 allocate 函数来处理内存分配和字符串复制的过程。这样我们可以在参数化构造函数和拷贝构造函数中都使用这个函数。

String::String(const String& other) {
    allocate(other.getString());
}

void String::allocate(const char* str) {
    int len = 0;
    while (str[len] != '\0') {
        len++;
    }
    
    m_length = len;
    m_pBuffer = new char[len + 1];  // 分配内存
    for (int i = 0; i < len; i++) {
        m_pBuffer[i] = str[i];
    }
    m_pBuffer[len] = '\0';  // 添加字符串结束符
}

赋值运算符

在实现赋值运算符时,首先我们需要检查是否是自我赋值。然后调用 allocate 函数来分配新的内存,最后返回当前对象。实现代码如下:

String& String::operator=(const String& other) {
    if (this != &other) {  // 自我赋值检查
        allocate(other.getString());
    }
    return *this;  // 返回当前对象
}

这里有一个关键问题值得注意:为什么我们要有 allocateassign 两个函数?虽然这两个函数看起来似乎做了相同的事情,但是 allocate 函数是内部使用的,用于执行内存分配和字符串复制的工作。因此,allocate 函数会被封装为私有函数,不会直接暴露给外部使用,而 assign 函数则会在必要时调用 allocate

本讲内容总结

到目前为止,我们已经实现了 String 类的一些基础成员函数,包括:

  1. 参数化构造函数:用于初始化字符串。
  2. 拷贝构造函数:用于通过复制另一个 String 对象来初始化当前对象。
  3. 赋值运算符:用于将一个 String 对象赋值给另一个对象。

这些基本操作确保了我们可以正确地处理动态内存分配和拷贝。

下一步

在下一讲中,我们将继续实现 String 类的剩余函数,包括:

  • 插入函数 insert
  • 追加函数 append
  • 其他辅助操作符和方法。

在那之前,大家可以仔细复习这些已经实现的代码和概念。我们下次再见!

004 字符串类 - 第三部分

实现 String 类的 insert 函数

大家好,欢迎回来。让我们继续实现 String 类的其余成员函数。在这一讲中,我们将重点实现 insert 函数。这个函数允许我们在指定位置插入一个字符串。

insert 函数的实现

我们首先需要考虑几个检查:

  1. 位置检查:插入位置应当小于当前字符串的长度。如果位置大于字符串的长度,我们将抛出异常。
  2. 插入位置的不同情况
    • 如果位置为 0,表示将字符串插入到开头。
    • 如果位置是有效的索引(小于字符串长度),将字符串插入到指定位置。
    • 如果位置等于字符串的长度,表示将字符串插入到末尾。

我们将通过三个主要的步骤来实现这个函数:检查位置、分配新的内存以及进行字符串复制。

1. 计算新字符串的长度

我们需要计算新字符串的总长度。新字符串的长度是原始字符串的长度加上现有字符串的长度。

int newLength = m_length + strlen(str);

2. 分配临时内存

接下来,我们将为新的字符串分配内存,这个内存将同时包含现有的字符串和要插入的原始字符串。

char* pTemp = new char[newLength + 1];  // +1 为了存储终止符 '\0'

3. 插入字符串的三种情况

3.1 插入位置为 0

如果插入位置为 0,表示将原始字符串插入到当前字符串的开头。我们首先将原始字符串复制到新分配的内存中,然后将现有字符串的内容复制到后面。

if (position == 0) {
    strcpy(pTemp, str);  // 将原始字符串复制到 pTemp
    strcat(pTemp, m_pBuffer);  // 将现有字符串附加到 pTemp
}
3.2 插入位置小于字符串长度

如果插入位置小于字符串长度,我们需要将字符串分成两部分:插入位置之前的部分和插入位置之后的部分。我们将原始字符串插入到这两部分之间。

else if (position < m_length) {
    // 将插入位置前的部分复制到 pTemp
    strncpy(pTemp, m_pBuffer, position);
    // 将原始字符串连接到 pTemp
    strcat(pTemp, str);
    // 将插入位置后的部分连接到 pTemp
    strcat(pTemp, m_pBuffer + position);
}
3.3 插入位置等于字符串长度

如果插入位置等于字符串的长度,表示我们将原始字符串插入到字符串的末尾。我们首先将现有字符串复制到新内存中,然后将原始字符串附加到末尾。

else if (position == m_length) {
    strcpy(pTemp, m_pBuffer);  // 将现有字符串复制到 pTemp
    strcat(pTemp, str);  // 将原始字符串附加到 pTemp
}

4. 更新 m_pBufferm_length

最后,我们需要将新分配的内存赋值给 m_pBuffer,并更新字符串的长度。

delete[] m_pBuffer;  // 释放原来的内存
m_pBuffer = pTemp;  // 将新的内存分配给 m_pBuffer
m_length = newLength;  // 更新字符串的长度

完整代码示例

void String::insert(int position, const char* str) {
    if (position < 0 || position > m_length) {
        throw std::out_of_range("Position is out of range.");
    }

    int newLength = m_length + strlen(str);  // 计算新字符串的长度
    char* pTemp = new char[newLength + 1];  // 为新字符串分配内存

    if (position == 0) {
        strcpy(pTemp, str);  // 将原始字符串复制到 pTemp
        strcat(pTemp, m_pBuffer);  // 将现有字符串附加到 pTemp
    }
    else if (position < m_length) {
        strncpy(pTemp, m_pBuffer, position);  // 将前部分复制到 pTemp
        strcat(pTemp, str);  // 将原始字符串附加到 pTemp
        strcat(pTemp, m_pBuffer + position);  // 将后部分附加到 pTemp
    }
    else if (position == m_length) {
        strcpy(pTemp, m_pBuffer);  // 将现有字符串复制到 pTemp
        strcat(pTemp, str);  // 将原始字符串附加到 pTemp
    }

    delete[] m_pBuffer;  // 释放原来的内存
    m_pBuffer = pTemp;  // 将新的内存分配给 m_pBuffer
    m_length = newLength;  // 更新字符串的长度
}

小结

  • 我们实现了 insert 函数,该函数能够在指定位置插入原始字符串。
  • 我们考虑了不同的插入位置:插入到开头、插入到中间、插入到末尾。
  • 我们通过分配新的内存来完成插入操作,并且记得释放原来的内存以防止内存泄漏。

在下一讲中,我们将继续实现该类的其他函数,并使用这些函数来处理不同的字符串操作。到时候见!

005 字符串类 - 第四部分

实现 String 类的 append 和运算符重载函数

大家好,欢迎回来。现在我们将继续实现 String 类中的 append 函数以及一些运算符重载函数。在这一讲中,我们将重点讲解如何实现 append 函数,并且优化运算符重载(如 +=+)。

append 函数的实现

append 函数的目标是将原始字符串(raw string)附加到当前字符串的末尾。由于这个操作与在字符串末尾插入字符串相似,我们可以直接调用 insert 函数,并将插入位置设置为字符串的末尾。

1. append 函数实现

void String::append(const char* str) {
    insert(m_length, str);  // 调用 insert 函数,将字符串插入到末尾
}

在这里,我们调用了 insert 函数并将 m_length 作为插入位置,这样就能确保原始字符串附加到当前字符串的末尾。

+= 运算符重载

接下来是 += 运算符重载。在 += 运算符重载中,我们只需调用 append 函数即可。因为 append 本身已经处理了将字符串附加到现有字符串的逻辑,所以我们只需要将 append 作为 += 的实现即可。

2. += 运算符重载

String& String::operator+=(const char* str) {
    append(str);  // 使用 append 函数将字符串附加到当前字符串
    return *this;  // 返回当前对象
}

+ 运算符重载

最后,我们来实现 + 运算符重载。对于 + 运算符重载,我们需要创建一个新的 String 对象,它是将当前字符串和要连接的字符串合并后的结果。

3. + 运算符重载

String String::operator+(const char* str) {
    String temp = *this;  // 创建一个临时对象,初始化为当前对象
    temp += str;  // 使用 += 运算符将字符串附加到临时对象
    return temp;  // 返回合并后的新字符串
}

测试和解决问题

我们将使用上面实现的函数来创建字符串对象,打印字符串并插入新字符串。我们首先实现一个辅助函数 print,它接受一个 String 对象,打印字符串的内容和长度。

4. print 函数

void print(const String& str) {
    std::cout << "Length: " << str.length() << "\n";
    std::cout << "String: " << str.getString() << "\n";
}

调试和修复崩溃问题

在测试代码时,我们遇到了一个程序崩溃的情况。当我们插入字符串时,程序出现了未初始化内存的问题,导致输出了乱码并崩溃。

问题描述

insert 函数中,我们为临时缓冲区 pTemp 分配了内存,但这个内存并没有被初始化。接下来,使用 strncpystrcat 函数时,这些操作没有正确处理终止符 \0,导致缓冲区中有垃圾数据,从而引发程序崩溃。

解决方案

解决这个问题的办法是初始化内存。我们可以在分配内存时,确保内存已经被初始化为 \0,以避免垃圾数据的影响。

char* pTemp = new char[newLength + 1]();  // 使用 () 初始化内存

初始化默认构造函数

此外,我们还发现了一个问题:当使用默认构造函数创建 String 对象时,如果没有提供初始字符串,m_pBuffer 可能会是一个空指针(nullptr),这导致程序崩溃。因为空指针无法打印出来。

解决方案

当创建一个空字符串时,m_pBuffer 不应该为 nullptr,而应该分配一个包含一个字符(空字符串的终止符 \0)的内存块。

String::String() : m_length(0) {
    m_pBuffer = new char[1];  // 分配一个字节的内存
    m_pBuffer[0] = '\0';  // 初始化为空字符串
}

这样,当创建一个空字符串对象时,它将有一个有效的缓冲区,且不会导致崩溃。

完整的 String 类更新

以下是更新后的 String 类的部分实现,包括了上述修复和优化:

class String {
private:
    char* m_pBuffer;
    int m_length;

public:
    String() : m_length(0) {
        m_pBuffer = new char[1];  // 分配一个字节的内存
        m_pBuffer[0] = '\0';  // 初始化为空字符串
    }

    // 参数化构造函数
    String(const char* str) {
        m_length = strlen(str);
        m_pBuffer = new char[m_length + 1];
        strcpy(m_pBuffer, str);
    }

    // 析构函数
    ~String() {
        delete[] m_pBuffer;
    }

    // 其他成员函数,如 append、insert、operator+=、operator+ 等

    void append(const char* str) {
        insert(m_length, str);
    }

    String& operator+=(const char* str) {
        append(str);
        return *this;
    }

    String operator+(const char* str) {
        String temp = *this;
        temp += str;
        return temp;
    }

    void print() const {
        std::cout << "Length: " << m_length << "\n";
        std::cout << "String: " << m_pBuffer << "\n";
    }
};

总结

  • 我们实现了 append+=+ 运算符重载。
  • 解决了未初始化内存和空指针的问题,避免了程序崩溃。
  • 确保 String 类的对象在默认构造时得到正确初始化,防止访问空指针导致的错误。

在下一讲中,我们将继续完善这个类并进行更多的测试。到时候见!

006 检测字符串类中的堆损坏

检查堆内存的有效性

在这一讲中,我们讨论了如何通过 Visual Studio 提供的堆 API 来检查内存一致性,确保程序没有发生内存覆盖(memory overwrite)。尽管我们目前的程序运行正常,但我们需要确保在程序中没有出现堆溢出(buffer overflow)或堆下溢(buffer underflow)的情况。接下来,我将详细介绍如何通过 Visual Studio 的调试功能来检查堆内存是否被正确使用,并检测堆内存覆盖问题。

1. 使用 CRT 的内存检查功能

Visual Studio 提供了一个名为 CRTCheckMemory 的函数,它可以用来检查堆内存的有效性。这个函数属于 C 运行时库的调试版本,它通过检查堆内存块的完整性来帮助我们检测内存覆盖问题。函数的返回值为整数:

  • 如果堆内存一致性良好,则返回非零值;
  • 如果堆内存有覆盖问题,则返回零。

我们可以将这个函数直接放入 assert 宏中进行检查。

1.1 检查内存是否一致

assert(_CrtCheckMemory());  // 检查堆内存是否一致

当我们在程序中运行 CRTCheckMemory 时,程序会在堆内存不一致时崩溃,并触发断言失败。

2. 堆内存覆盖问题

虽然程序能够运行,但崩溃的原因往往是堆内存覆盖。问题在于,内存覆盖可能在程序的任何地方发生,且覆盖的内存可能与原始内存块并不在同一位置。因此,定位内存覆盖的具体位置变得十分困难。

2.1 通过调试输出定位内存问题

当我们启用调试模式并运行程序时,输出窗口会显示堆内存崩溃的诊断信息。具体信息如下:

  • Heap corruption detected(检测到堆损坏):表明堆内存中的某些区域被覆盖。
  • Allocation indices(分配索引):每次内存分配时都会产生一个索引号。例如,200 表示这是第 200 次内存分配。这个索引值可以帮助我们追踪哪个分配被覆盖。

通过这些信息,我们能够逐步定位哪个分配的内存被覆盖,从而找到问题所在。

2.2 分配索引和 break 功能

为了更精确地找到覆盖发生的位置,可以通过一个名为 _CrtSetBreakAlloc 的全局变量,设置在特定分配索引处让程序暂停。这让我们可以在内存覆盖发生时,迅速跳到调试器中查看堆栈信息。

_CrtSetBreakAlloc(155);  // 设置在第 155 次分配时暂停程序

设置完毕后,程序会在特定的分配索引处暂停,允许我们检查堆栈。

2.3 调试过程

一旦程序在某个内存分配点崩溃,我们可以在调试器中查看调用堆栈。通常,调用堆栈会显示内存分配的具体位置,帮助我们确定是哪一行代码触发了内存覆盖。

例如,我们可以看到堆栈中调用了 insert 函数,该函数可能是触发内存覆盖的地方。接着,我们可以继续检查堆栈,直到定位到实际的内存分配问题。

3. 调试和解决覆盖问题

虽然使用分配索引可以帮助我们定位内存覆盖的起点,但手动逐个检查所有分配索引是非常繁琐的。为此,我们需要一种更高效的方法来自动定位堆内存覆盖的发生位置。

在下一讲中,我将介绍一种更有效的方法,使用 CRTCheckMemory 函数并结合调试功能,帮助我们快速定位内存覆盖,而无需逐个检查每个分配索引。

小结

  • 我们通过使用 CRTCheckMemory 函数,结合 assert 宏,检查程序中堆内存的一致性。
  • 当内存覆盖发生时,调试器提供了分配索引和诊断信息,帮助我们找到问题发生的具体位置。
  • 在调试过程中,我们可以利用 CrtSetBreakAlloc 断点功能,在特定内存分配点暂停,进一步分析内存覆盖问题。

下一讲我们将深入探讨如何更高效地定位堆内存覆盖问题,并进一步优化调试过程。

007 堆检查器类 - 第一部分

Heap 检查器类的实现

在本讲中,我们将编写一个 HeapChecker 类,帮助我们定位内存覆盖的发生位置。虽然它不能提供精确的内存覆盖位置,但它可以帮助我们了解问题发生的代码区域。通过使用 CRTCheckMemory 函数,我们可以在每个访问动态内存的函数中进行检查,从而发现潜在的堆内存覆盖问题。

1. HeapChecker 类的设计

HeapChecker 类的设计遵循“资源获取即初始化”原则(RAII)。我们将确保每次访问动态内存时,CRTCheckMemory 都会被调用。实现方式是在每个函数中创建一个 HeapChecker 对象,并在该对象的析构函数中调用 CRTCheckMemory

1.1 类构造和析构

  • 构造函数:我们会在构造函数中初始化函数名和文件名,这些信息用于标识出现内存覆盖的地方。
  • 析构函数:析构函数负责在对象销毁时检查堆内存的有效性。如果发现堆内存不一致,输出一条调试信息,帮助我们定位问题发生的区域。

1.2 使用 CRTCheckMemory 进行检查

CRTCheckMemory 函数返回零时表示发生了堆内存覆盖。我们可以使用 OutputDebugStringcout 输出错误信息,来帮助我们快速定位覆盖问题。以下是 HeapChecker 类的实现。

#include <iostream>
#include <crtdbg.h>

class HeapChecker {
private:
    const char* m_pFunction;
    const char* m_pFile;

public:
    // 构造函数:初始化函数名和文件名
    HeapChecker(const char* function, const char* file) 
        : m_pFunction(function), m_pFile(file) {}

    // 析构函数:调用 CRTCheckMemory 检查内存
    ~HeapChecker() {
        if (_CrtCheckMemory() == 0) {
            std::cout << "Heap corruption detected in function: " 
                      << m_pFunction << " in file: " << m_pFile << std::endl;
            assert(0); // 断言失败,终止程序
        }
    }
};

1.3 宏定义简化使用

手动在每个函数中创建 HeapChecker 对象比较繁琐。为了简化使用,我们可以为其定义一个宏 HEAP_CHECK,该宏会自动创建 HeapChecker 对象,并初始化函数名和文件名。

#ifdef _DEBUG
#define HEAP_CHECK() HeapChecker heapChecker(__FUNCTION__, __FILE__)
#else
#define HEAP_CHECK() // 在发布版本中不做任何事情
#endif
  • 在调试版本中,HEAP_CHECK 会创建一个 HeapChecker 对象,并自动将当前函数名和文件名传递给构造函数。
  • 在发布版本中,HEAP_CHECK 什么都不做,从而避免不必要的性能开销。

2. 在程序中使用 HeapChecker

在实际使用时,我们只需要在每个需要检查内存的函数中调用 HEAP_CHECK() 宏。这样,每当访问动态内存时,就会自动进行内存检查,并在发生内存覆盖时输出调试信息。

2.1 示例代码

#include <iostream>
#include <crtdbg.h>

// 假设有一个简单的内存分配和使用函数
void someFunction() {
    HEAP_CHECK(); // 自动检查堆内存

    // 模拟动态内存分配和操作
    char* buffer = new char[100];
    strcpy(buffer, "Hello, Heap!");
    
    // 在函数结束时,HeapChecker 对象将被销毁,内存将被检查
}

int main() {
    someFunction(); // 调用该函数检查堆内存
    return 0;
}

3. 调试信息输出

当发生内存覆盖时,程序会输出类似以下的调试信息:

Heap corruption detected in function: someFunction in file: main.cpp

这条信息告诉我们,内存覆盖发生在 someFunction 函数中,并且在 main.cpp 文件内。通过这些信息,我们可以大致知道是哪个函数和文件区域发生了内存问题,方便我们进行定位和修复。

4. 总结

  • HeapChecker 类通过在构造函数中初始化函数名和文件名,结合析构函数中调用 CRTCheckMemory,帮助我们检测堆内存覆盖问题。
  • 使用宏 HEAP_CHECK,可以简化每个函数中的内存检查代码,使得堆内存检查变得更加高效和易于管理。
  • 在调试模式下,程序会输出内存覆盖的调试信息,帮助我们定位问题。

008 堆检查器类 - 第二部分

在 String 类中使用 HeapChecker 进行堆内存检查

在本讲中,我们将继续改进我们的 HeapChecker 类,使其可以帮助我们在 String 类中检测堆内存的腐败。通过将 HeapChecker 类集成到项目中,我们可以轻松定位内存覆盖的问题,并且通过它提供的调试信息,快速修复内存相关的错误。

1. 将 HeapChecker 添加到项目中

我们首先需要将 HeapChecker 类的实现添加到项目中:

  • 复制之前编写的 HeapChecker 类文件,并将其粘贴到项目目录中。
  • 在项目中,确保所有的源文件都位于同一目录下,避免文件路径不一致的问题。
  • String 类的源文件中,包含 HeapChecker 头文件。
#include "HeapChecker.h"  // 引入 HeapChecker 类的头文件

2. 使用 HeapCheck 宏进行堆内存检查

接下来,我们需要在 String 类的相关方法中添加 HeapCheck 宏,以便在执行内存分配和使用时进行堆内存检查。特别是,在分配内存和插入字符串内容的地方,我们需要使用 HeapCheck 来检查堆内存。

例如,在 allocate 方法中,我们可以添加如下的宏:

void String::allocate(int size) {
    HEAP_CHECK();  // 在每个分配内存的地方调用 HeapCheck
    m_pBuffer = new char[size];
    m_length = size;
}

类似地,在 insert 方法中,也应添加堆检查宏:

void String::insert(const char* str, int pos) {
    HEAP_CHECK();  // 在插入操作中也进行堆内存检查
    // 插入逻辑...
}

3. 运行程序并检测堆内存问题

完成堆检查宏的集成后,运行程序时,HeapChecker 会在发生堆内存覆盖时输出调试信息。例如,程序可能会输出以下内容:

Heap corruption detected in function: allocate in file: String.cpp
Heap corruption detected in function: insert in file: String.cpp

这些信息告诉我们在 allocateinsert 方法中发生了堆内存覆盖,从而帮助我们进一步检查代码。

4. 查找内存覆盖问题

通过查看 allocate 方法的代码,我们发现内存分配时没有为字符串的终止符(null terminator)分配空间。这导致了堆内存的覆盖。

m_pBuffer = new char[size];

解决方案是为终止符添加一个额外的字节,确保分配足够的内存:

m_pBuffer = new char[size + 1];  // 多分配一个字节用于 null 终止符

同样的,在 insert 方法中,我们也需要为终止符分配额外的空间。

5. 改进堆检查宏

在控制台应用程序中,堆内存覆盖的调试信息可以直接显示在控制台窗口中。但如果我们在一个 UI 应用程序中使用堆检查器,则没有控制台输出窗口,这时我们需要将调试信息输出到 Visual Studio 的输出窗口中,或者将信息写入到文件中,以便后续查看。

5.1 将信息输出到 Visual Studio 输出窗口

我们可以使用 OutputDebugString 函数将调试信息输出到 Visual Studio 的输出窗口:

OutputDebugString("Heap corruption detected...\n");

5.2 将信息写入文件

为了在大型应用程序中管理大量的堆内存覆盖信息,我们还可以将调试信息写入到一个日志文件中。这样,开发人员可以更方便地查看堆内存覆盖的详细信息。

6. 未来的改进:文件输出和输出窗口

我们将在下一个视频中进一步改进 HeapChecker 类,使其支持将调试信息输出到文件或者 Visual Studio 的输出窗口,具体改进包括:

  • 控制台模式:如果应用程序没有 UI,我们可以在控制台中显示堆覆盖信息。
  • UI 模式:对于 UI 应用程序,我们可以将调试信息发送到 Visual Studio 输出窗口,或将信息记录到文件中。
  • 日志文件:对于大型应用程序,我们可以将堆内存覆盖信息写入日志文件,以方便后期调试和定位问题。

7. 总结

  • HeapChecker 类:通过在每个涉及动态内存操作的函数中使用 HeapChecker,我们可以轻松检测堆内存覆盖。
  • 堆内存覆盖定位:通过 HeapCheck 宏,我们能迅速定位堆内存覆盖问题,尤其是在 String 类中。
  • 改进堆检查信息输出:我们讨论了如何将堆内存错误信息输出到控制台、UI 窗口或日志文件中,以适应不同的应用场景。

在接下来的视频中,我们将进一步改进 HeapChecker 类,使其更强大,更适用于不同的应用环境。

009 堆检查器类 - 第三部分

修改 HeapChecker 类以支持多种输出选项

在这一讲中,我们将对 HeapChecker 类进行修改,使得它可以根据需要选择不同的方式来展示堆内存腐败的消息。具体来说,我们将实现以下三种输出方式:

  • 控制台输出:将消息显示在控制台窗口中。
  • Visual Studio 输出窗口:将消息显示在 Visual Studio 的输出窗口中(仅在调试模式下有效)。
  • 写入文件:将堆内存腐败的消息记录到一个文本文件中。

1. 创建 OutputType 枚举

首先,我们定义一个枚举类型 OutputType 来指定堆内存腐败消息的输出方式。枚举包含三种选项:控制台 (console),Visual Studio 输出窗口 (window),和文件 (file)。

enum OutputType {
    console,  // 输出到控制台
    window,   // 输出到 Visual Studio 输出窗口
    file      // 输出到文件
};

2. 修改 HeapChecker 构造函数

接着,我们为 HeapChecker 类的构造函数添加一个新的参数,以便选择堆内存腐败消息的输出方式。这个参数是 OutputType 类型的,我们会在构造函数中初始化它。

class HeapChecker {
private:
    // 其他成员变量...
    OutputType outputType;  // 用于指定输出方式

public:
    // 构造函数
    HeapChecker(const char* function, const char* file, OutputType output)
        : m_pFunction(function), m_pFile(file), outputType(output) {
        // 初始化其他成员变量
    }

    // 析构函数和其他成员函数...
};

3. 在 HeapChecker 中根据输出方式选择输出逻辑

根据选择的输出方式,我们在 HeapChecker 的逻辑中使用 switch 语句来处理堆腐败信息的输出:

  • 如果选择 控制台,使用 std::cout 打印消息。
  • 如果选择 Visual Studio 输出窗口,使用 OutputDebugString 函数。
  • 如果选择 文件输出,我们将在下一讲实现将消息写入文本文件的功能。
#include <sstream>
#include <windows.h>  // 需要这个头文件来使用 OutputDebugString

void HeapChecker::checkMemory() {
    std::stringstream output;  // 创建一个 stringstream 实例

    // 构建堆腐败信息
    output << "Heap corruption detected in function: " << m_pFunction
           << " in file: " << m_pFile << std::endl;

    switch (outputType) {
        case console:
            // 输出到控制台
            std::cout << output.str();
            break;

        case window:
            // 输出到 Visual Studio 输出窗口
            OutputDebugString(output.str().c_str());
            break;

        case file:
            // 还未实现,下一讲实现文件输出
            break;
    }
}

4. 控制台输出

对于控制台输出,我们可以直接使用 std::cout 来打印堆内存腐败的调试信息。通过 std::stringstream 来收集消息内容,再使用 output.str() 方法获取最终的字符串并打印出来。

std::cout << output.str();  // 将消息输出到控制台

5. Visual Studio 输出窗口

当选择输出到 Visual Studio 输出窗口 时,我们需要使用 OutputDebugString 函数,它会将消息输出到 Visual Studio 的调试窗口。在 Visual Studio 中,只有在调试模式下才能看到这些信息。

OutputDebugString(output.str().c_str());  // 将消息输出到 Visual Studio 输出窗口

6. 下一个步骤:文件输出

到目前为止,我们已经实现了控制台输出和 Visual Studio 输出窗口的支持。在下一讲中,我们将实现将堆腐败信息输出到文本文件的功能。这将使得调试信息可以保存并在需要时查看。

总结

  • 我们创建了一个 OutputType 枚举来选择堆内存腐败消息的输出方式。
  • HeapChecker 类中,使用 switch 语句处理不同的输出方式。
  • 支持了两种输出方式:控制台输出和 Visual Studio 输出窗口。
  • 文件输出将在下一讲中实现。

通过这些修改,我们将能够灵活地控制堆内存检查器的输出方式,并适应不同类型的应用程序。在下一个视频中,我们将进一步改进 HeapChecker 类,使其支持将消息写入到文件中,以便更好地管理大量的调试信息。

010 堆检查器类 - 第四部分

实现堆腐败消息输出到文件

在这一讲中,我们将继续完善 HeapChecker 类的文件输出功能,确保堆腐败的调试信息能够被写入到文本文件中。我们将讨论如何自动生成文件名、打开文件并追加内容,以及如何处理文件重复写入的问题。

1. 获取应用程序文件名并创建日志文件

首先,我们需要创建一个文件,用来保存堆腐败的信息。我们可以选择硬编码一个文件名,或者使用与应用程序同名的文件。这里我们选择自动生成一个与应用程序同名的文件名。

我们可以通过 Windows API 获取当前应用程序的文件名。具体做法如下:

  • 使用 GetModuleFileNameA 获取应用程序的完整路径和文件名。
  • 使用 GetModuleHandle 获取当前进程的句柄,传递 NULL 表示获取当前进程的句柄。
  • 然后我们可以通过这些信息提取文件名并追加文件扩展名。
char fileName[MAX_PATH];
GetModuleFileNameA(GetModuleHandle(NULL), fileName, MAX_PATH);

// 获取文件名后,去掉路径部分,只保留文件名
char* lastSlash = strrchr(fileName, '\\');
if (lastSlash) {
    lastSlash[1] = '\0';  // 截断路径,保留文件名
}

// 将日志文件扩展名设为 ".log"
strcat(fileName, "log");

2. 打开文件并追加信息

我们将在 HeapChecker 类中使用 C++ 的文件流 ofstream 来操作文件。文件将以追加模式打开,每次堆腐败事件发生时,我们都将信息写入文件。

#include <fstream>
#include <sstream>

void HeapChecker::writeToFile(const std::stringstream& output) {
    std::ofstream logFile;
    logFile.open(fileName, std::ios::app);  // 以追加模式打开文件
    logFile << output.str();  // 将 stringstream 中的内容写入文件
    logFile.close();  // 关闭文件
}

3. 在 HeapChecker 类中修改输出逻辑

为了让 HeapChecker 类能够根据用户选择的输出方式(控制台、Visual Studio 输出窗口或文件)来处理堆腐败信息,我们需要修改 HeapChecker 类,允许根据传入的 OutputType 参数来决定输出目标。修改后的 HeapChecker 类的 checkMemory 函数会根据不同的输出类型处理信息。

void HeapChecker::checkMemory() {
    std::stringstream output;
    output << "Heap corruption detected in function: " << m_pFunction
           << " in file: " << m_pFile << std::endl;

    switch (outputType) {
        case console:
            std::cout << output.str();
            break;

        case window:
            OutputDebugString(output.str().c_str());
            break;

        case file:
            writeToFile(output);  // 输出到文件
            break;
    }
}

4. 修改 HeapChecker

为了方便使用,我们还需要修改 HeapChecker 宏,允许用户在调用时指定输出方式。这样,我们就不需要每次手动传递输出类型了。

#define HEAP_CHECK(type) HeapChecker(__FUNCTION__, __FILE__, type)

5. 选择输出方式

现在,我们可以在代码中选择我们想要的输出方式。在 HeapChecker 实例化时,我们传入相应的 OutputType 值。例如,我们可以选择控制台输出:

HEAP_CHECK(console);

或者选择 Visual Studio 输出窗口:

HEAP_CHECK(window);

或者选择文件输出:

HEAP_CHECK(file);

6. 文件重复写入问题

目前,每次堆腐败检测时都会打开文件并追加信息,这可能会导致每次运行时文件内容不断增加。如果我们希望每次运行程序时清空文件内容,可以在程序启动时清空文件,确保每次运行都从头开始记录。

我们可以在 HeapChecker 类中实现一个静态标志 fileOpened,用来标记文件是否已经打开。如果没有打开文件,就在程序开始时打开文件,并在程序结束时关闭它。

static bool fileOpened = false;

void HeapChecker::openFile() {
    if (!fileOpened) {
        std::ofstream logFile(fileName, std::ios::trunc);  // 清空文件
        fileOpened = true;
    }
}

在堆腐败检测的逻辑中,我们只在第一次调用时打开文件,而后续的堆腐败信息将继续追加到文件中。我们可以在程序的 main 函数中调用 HeapChecker::openFile() 来确保文件在程序启动时就被打开。

7. 测试和输出

现在,程序在检测堆内存腐败时可以根据选择的输出方式将信息输出到控制台、Visual Studio 输出窗口或文件中。我们可以选择运行程序时通过调试器查看输出,或者直接查看生成的日志文件。

输出示例

  1. 控制台输出:信息直接显示在控制台窗口。
  2. Visual Studio 输出窗口:信息显示在 Visual Studio 的“输出”窗口中,前提是你通过调试器运行程序。
  3. 文件输出:所有堆内存腐败信息将被写入到一个 .log 文件中,便于后续查看和调试。

总结

  • 我们为 HeapChecker 类增加了文件输出功能,允许用户将堆内存腐败信息写入到文件中。
  • 文件名由应用程序的名称动态生成,使用 Windows API 获取文件名并追加 .log 扩展名。
  • 我们避免了每次堆腐败检测时都打开文件,通过在程序启动时打开文件并在程序结束时关闭文件来优化性能。
  • 我们可以选择不同的输出方式(控制台、Visual Studio 输出窗口或文件)来适应不同类型的应用程序。

下一个视频将继续对 HeapChecker 类进行优化,确保我们可以在更复杂的应用场景下处理堆腐败检测问题。

011 堆检查器类 - 第五部分

处理堆腐败检测时文件打开和关闭的问题

在之前的视频中,我们实现了将堆腐败信息输出到控制台、Visual Studio 输出窗口和文本文件的功能。然而,在每次检测到堆腐败时,文件都会被重新打开和关闭。为了提高效率,我们希望避免每次堆腐败事件发生时都打开文件,而是仅在程序启动时打开文件并在程序结束时关闭文件。

1. 使用静态流对象和 inline static 特性

为了优化文件操作,我们决定在 HeapChecker 类中使用一个静态的输出流对象,这样它将在程序启动时初始化一次,并且会在程序结束时自动销毁,从而自动关闭文件。

  • 首先,我们需要包含 fstream 头文件。
  • 使用 C++17 的 inline static 特性,我们可以避免在 .cpp 文件中定义静态成员,直接在类定义中声明和初始化静态成员。
#include <fstream>  // 引入文件流操作的头文件

class HeapChecker {
public:
    inline static std::ofstream m_outStream;  // 静态输出流对象
    inline static bool m_initialized = false;  // 静态初始化标志

    static void init(OutputType type) {
        if (!m_initialized) {
            if (type == OutputType::TextFile) {
                // 获取程序名称并生成文件名
                char fileName[MAX_PATH];
                GetModuleFileNameA(GetModuleHandle(NULL), fileName, MAX_PATH);
                strcat(fileName, ".log");

                // 打开文件
                m_outStream.open(fileName, std::ios::trunc);  // 清空文件并以覆盖模式打开
                m_initialized = true;
            }
        }
    }

    // 其他成员函数...
};

2. 修改构造函数和析构函数

在构造函数中,我们不再接收输出类型的参数,也不再初始化输出流。在 HeapChecker 的构造函数中,我们通过检查 m_initialized 来确保 init 函数已经被调用。如果没有调用 init,则抛出异常,避免使用未初始化的静态输出流。

HeapChecker::HeapChecker() {
    if (!m_initialized) {
        throw std::runtime_error("HeapChecker not initialized. Call init() first.");
    }
    // 其他初始化逻辑
}

HeapChecker::~HeapChecker() {
    // 静态对象会在程序结束时自动销毁,因此文件会被自动关闭
}

3. 改进堆腐败检测逻辑

我们需要确保 init 函数在使用 HeapChecker 之前被调用,并且在堆腐败检测时不会重复打开文件。此时,我们可以改进 checkMemory 方法,确保堆腐败信息被写入已打开的文件。

void HeapChecker::checkMemory() {
    std::stringstream output;
    output << "Heap corruption detected in function: " << m_pFunction
           << " in file: " << m_pFile << std::endl;

    if (m_initialized) {
        m_outStream << output.str();  // 将堆腐败信息写入文件
    }
    // 其他输出逻辑...
}

4. 调用 init 函数并设置输出类型

现在,调用堆腐败检测宏时,不需要再传递输出类型,而是确保先调用 HeapChecker::init() 来初始化文件输出。

#define HEAP_CHECK(type) HeapChecker::init(type); HeapChecker(__FUNCTION__, __FILE__)

int main() {
    HeapChecker::init(OutputType::TextFile);  // 在使用 HeapChecker 之前初始化
    HEAP_CHECK(OutputType::TextFile);

    // 其他代码逻辑
}

5. 避免文件重复写入

为了防止每次运行程序时文件内容被不断追加,我们将修改文件的打开模式。改为覆盖模式(std::ios::trunc),每次运行时清空文件,确保文件大小不会无限增长。

m_outStream.open(fileName, std::ios::trunc);  // 使用覆盖模式,每次运行时清空文件

6. 调试堆腐败检测和输出

当我们运行程序时,堆腐败信息将被写入到文本文件中。每次程序启动时,文件都会被清空并重新写入新的堆腐败信息。我们可以打开生成的 .log 文件来查看堆腐败的详细信息。

结果示例

  1. 文件输出:每次运行程序时,生成的日志文件会被覆盖,文件大小保持较小,避免了文件内容无限增长的问题。

  2. false positive 问题:在实际的堆腐败检查中,即使我们修复了 allocate 函数中的问题,CRT check memory 仍然会检测到某些内存块的堆腐败。这个问题被称为 "false positive"(假阳性),是因为 checkMemory 会检查所有内存块,如果其中某个块被破坏,返回的结果可能是错误的。

    例如,即使我们修复了 allocate 函数中的问题,checkMemory 依然可能显示 allocate 发生了堆腐败。原因是堆腐败实际上发生在 insert 函数中,而不是 allocate 函数中。

总结

  • 我们使用 inline static 特性和静态流对象优化了文件操作,避免每次堆腐败检测时都重新打开文件。
  • 我们确保 init 函数在使用 HeapChecker 之前被调用,初始化文件输出。
  • 通过覆盖模式打开文件,每次程序运行时清空日志文件,避免文件无限增长。
  • 我们分析了 CRT check memory 的假阳性问题,并了解了如何通过改进自定义的 checkMemory 来解决这个问题。

在下一个视频中,我们将继续优化堆腐败检测,并引入更多的运行时 API 来检查内存泄漏。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

05 - 检测内存泄漏

001 _CrtDumpMemoryLeaks() 函数

使用 C 运行时函数检测内存泄漏

在本节中,我们将学习如何使用 Visual Studio 的 C 运行时库来检测内存泄漏。Visual Studio 的 C 运行时库提供了一个名为 CRT dump memory leaks 的函数,它用于帮助检测内存泄漏。像其他调试功能一样,它仅在调试版本中有效,而在发布版本中无效。

1. CRT dump memory leaks 函数简介

CRT dump memory leaks 函数在程序结束时被调用,它会遍历所有已分配的内存块。当程序释放内存时,该内存块会被标记为已释放块。如果在遍历内存块时,CRT dump memory leaks 发现某个内存块没有被标记为已释放块,那么它会认为该内存块的内存泄漏。

2. 仅在调试版本中有效

需要注意的是,CRT dump memory leaks 只在调试版本中有效。在发布版本中,编译器会移除所有对调试函数的调用。因此,为了查看诊断消息,程序必须通过调试器运行。

3. 返回值

当调用 CRT dump memory leaks 时,如果发现内存泄漏,它会返回 1,否则返回 0。这个返回值帮助我们确定程序是否存在内存泄漏。

4. 输出的诊断信息

当发现内存泄漏时,CRT dump memory leaks 会在 Visual Studio 的输出窗口中显示诊断消息。以下是一个典型的输出示例:

Detected memory leaks!
Dumping objects ->
{157} normal block at 0x004F4D28, 32 bytes long.
 Data: < ... data ... >

这里的信息包括:

  • 位置索引 (Allocation Index):这是一个分配的计数值,表示分配发生的顺序。在上面的示例中,泄漏的分配是第 157 个。
  • 块类型 (Block Type):块的类型有多个,常见的有:
    • normal block:这种类型的内存块由 mallocnew 分配。
    • client block:由 MFC(Microsoft Foundation Classes)库分配的内存块。对于非 MFC 程序,这种块通常不需要关心。
    • CRT memory block:这些是 C 运行时库分配的内存块,通常不会在输出窗口中看到。
    • ignore block:显式标记为忽略的内存块,不会显示在诊断输出中。
    • free block:已经被释放的内存块。

在我们的例子中,我们主要关心 normal block 类型的内存块。

  • 内存块的地址:指向泄漏的内存块的地址。
  • 块大小:泄漏的内存块的大小。
  • 内存数据:Visual Studio 会显示泄漏内存块中的前 16 字节数据,这有助于调试人员了解块的内容。

5. 示例代码

在 Visual Studio 中,通常在程序的末尾调用 CRT dump memory leaks 来检测内存泄漏。以下是一个使用 CRT dump memory leaks 函数的简单示例:

#include <crtdbg.h>  // 引入 C 运行时库的头文件

int main() {
    // 启用内存泄漏检测
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

    // 模拟内存分配
    int* p = new int[10];

    // 假设有一个内存泄漏
    // delete[] p;  // 如果没有释放内存,这会导致泄漏

    return 0;
}

在上面的代码中,我们使用了 _CrtSetDbgFlag 函数来启用内存泄漏检测功能,指定了两个标志:

  • _CRTDBG_ALLOC_MEM_DF:用于启用内存分配跟踪。
  • _CRTDBG_LEAK_CHECK_DF:用于启用程序结束时的内存泄漏检查。

如果程序结束时没有释放分配的内存,CRT dump memory leaks 会在输出窗口显示相关的泄漏信息。

6. 诊断输出

假设程序中有内存泄漏,输出窗口中会显示类似以下的内容:

Detected memory leaks!
Dumping objects ->
{157} normal block at 0x004F4D28, 40 bytes long.
 Data: <00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... >

7. 结论

  • CRT dump memory leaks 是一个非常有用的工具,帮助我们在调试过程中检测内存泄漏。
  • 该工具只在调试版本中有效,必须通过调试器运行程序才能查看输出。
  • 输出的诊断信息提供了关于泄漏内存块的详细信息,包括分配顺序、块类型、地址和数据。
  • 在编写程序时,我们可以利用 _CrtSetDbgFlag 配置内存泄漏检测,确保程序结束时能正确检测到未释放的内存。

下一步

在下一个视频中,我们将进一步探讨如何通过编写自定义的内存管理函数来避免内存泄漏,并改进堆腐败检测功能。

002 _CrtDumpMemoryLeaks() 代码示例

使用 CRT dump memory leaks 的示例

在这一部分,我们将展示如何使用 CRT dump memory leaks 函数来检测和调试内存泄漏问题。通过这个示例,我们将学会如何在实际应用中使用该函数,获取更多有关内存泄漏的信息,并处理一些常见的调试问题。

1. 基础内存分配和泄漏检测

首先,我们将示范如何使用 mallocnew 来分配内存,并通过 CRT dump memory leaks 来检测内存泄漏。

#include <iostream>
#include <crtdbg.h>

int main() {
    // 分配 32 字节内存
    int* pMalloc = static_cast<int*>(malloc(32));  // 使用 malloc 分配内存
    int* pNew = new int(10);  // 使用 new 分配内存并初始化

    // 调用 CRT dump memory leaks 检测泄漏
    _CrtDumpMemoryLeaks(); 

    return 0;
}
  • 该代码中我们使用 mallocnew 分配了内存,然后调用 _CrtDumpMemoryLeaks() 来输出内存泄漏的诊断信息。
  • 通过调试器运行程序,我们可以查看 Visual Studio 输出窗口中显示的内存泄漏信息。

2. 使用分配索引定位泄漏

在 Visual Studio 的输出窗口中,内存泄漏的诊断信息可能比较简单,我们需要通过分配索引来进一步定位泄漏的内存块。比如:

Detected memory leaks!
Dumping objects ->
{157} normal block at 0x004F4D28, 32 bytes long.
 Data: < ... data ... >

为了更精确地定位泄漏的位置,我们可以使用 break alloc 来设置一个断点,捕获特定的分配索引。例如:

_CrtSetBreakAlloc(157);  // 设置断点在第 157 个分配位置

这样,程序会在该分配索引处停止,帮助我们定位到泄漏的内存块。

3. 使用 CRTDBG_MAP_ALLOC 宏获取更多信息

为了获得更详细的调试信息,我们可以定义 CRTDBG_MAP_ALLOC 宏。这会使 CRT 使用调试版本的 mallocfree 函数,这些调试版本会捕获更多的内存分配信息。

#define CRTDBG_MAP_ALLOC
#include <crtdbg.h>
#include <malloc.h>  // 必须包含这个头文件才能启用调试分配器

当我们启用该宏后,mallocnew 将调用调试版本,从而提供更多的内存分配细节。例如,输出将会显示分配的源文件和行号:

Detected memory leaks!
Dumping objects ->
{157} normal block at 0x004F4D28, 32 bytes long.
 Allocated at: C:\path\to\your\file.cpp(12)
 Data: < ... data ... >

点击输出中的文件和行号,它会直接跳转到源代码中该内存分配的地方,帮助我们更精确地找到问题。

4. 手动管理 new 运算符的调试信息

虽然 malloc 的调试信息可以通过启用调试版本的 malloc 来自动显示,但 new 运算符的详细信息不会自动显示。为了在 new 操作符中也获取类似的信息,我们需要手动编写代码来实现这一功能。我们可以通过自定义重载 new 运算符来提供更多的调试信息。

5. 释放内存并避免泄漏

为了避免内存泄漏,我们需要确保在程序结束时释放所有分配的内存。在之前的例子中,我们忘记释放 mallocnew 分配的内存,导致内存泄漏:

free(pMalloc);   // 释放 malloc 分配的内存
delete pNew;     // 释放 new 分配的内存

每次分配内存后,必须调用 freedelete 来释放内存,以避免内存泄漏。

6. 关于多次调用 _CrtDumpMemoryLeaks

有时,程序可能有多个退出点,或者在程序的不同地方需要调用 CRT dump memory leaks。这时,程序可能会在释放内存之后依然报告泄漏问题。这个问题尤其在 C++ 对象(例如 std::string)的析构过程中常见,因为这些对象的析构是在程序结束后执行的,而 CRT dump memory leaks 会在程序的早期阶段报告泄漏。

例如:

std::string str = "Hello, World!";
_CrtDumpMemoryLeaks();  // 提前调用会显示内存泄漏

由于 std::string 的析构函数在程序结束时才调用,_CrtDumpMemoryLeaks 会在析构之前报告 std::string 的内存泄漏。为了避免这种情况,确保在所有退出点处调用 CRT dump memory leaks

7. 解决内存泄漏问题

解决内存泄漏的一种方式是确保在调用 CRT dump memory leaks 之前释放内存。下面是更新后的代码:

std::string str = "Hello, World!";
_CrtDumpMemoryLeaks();  // 此时内存已被释放,不会报告泄漏

8. 总结

  • CRT dump memory leaks 是 Visual Studio 提供的一个强大工具,帮助我们在调试过程中检测内存泄漏。
  • 我们可以通过设置分配索引断点、启用调试版本的 mallocnew 来获取更多的调试信息。
  • 使用 _CrtDumpMemoryLeaks 时,要确保在程序结束前释放所有内存,并在合适的时机调用该函数。
  • 在 C++ 中,特别是涉及复杂对象时,我们需要特别注意析构函数的执行顺序和调用时机。

下一步

在下一个视频中,我们将深入探讨如何管理堆内存和避免复杂程序中的内存泄漏。

003 泄漏检测标志

解决内存泄漏的常见问题

在本节中,我们将深入探讨在使用 CRT dump memory leaks 函数时可能遇到的一些问题,并展示如何通过启用调试标志来解决这些问题。

1. 多重退出点和内存泄漏检测

之前我们使用 CRT dump memory leaks 来检测内存泄漏,但在某些情况下,特别是程序有多个退出点时,手动调用 CRT dump memory leaks 就变得不太方便。例如,如果你的程序有多个退出点,那么你需要在每个退出点都调用 CRT dump memory leaks,这会增加代码的复杂性和维护成本。

此外,CRT dump memory leaks 只能在函数的结尾调用,而对于局部对象,这个函数无法正确处理内存泄漏。在下面的代码中:

std::string str = "Hello, World!";
_CrtDumpMemoryLeaks();  // 内存泄漏检查发生在这里

虽然字符串对象 str 的析构函数会在作用域结束时调用,但 CRT dump memory leaks 被调用时,字符串的析构函数还未执行。因此,str 内部分配的内存会被误报为内存泄漏。

2. 设置调试标志以解决问题

为了解决以上问题,我们可以利用 CRT 提供的调试标志。通过设置这些标志,CRT dump memory leaks 会在程序终止之前自动调用,而不需要在每个退出点手动调用它。设置这些标志可以确保在程序终止时自动检查所有内存泄漏,且即使程序手动退出,也能正常工作。

具体来说,我们需要设置以下两个标志:

  • CRTDBG_ALLOC_MDF:开启泄漏检查。
  • CRTDBG_LEAK_CHECK:确保在程序终止前调用 CRT dump memory leaks

我们可以在程序的初始化部分启用这些标志,示例如下:

#include <crtdbg.h>

int main() {
    // 启用调试标志,自动进行内存泄漏检查
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

    // 分配内存和其他操作
    std::string str = "Hello, World!";

    // 不需要显式调用 _CrtDumpMemoryLeaks(),它将在程序结束时自动调用
    return 0;
}

在设置了这两个标志后,CRT dump memory leaks 会在程序终止前自动调用,无论是正常退出还是手动退出程序时,它都会检测并报告内存泄漏。

3. 注意析构函数和局部对象的处理

即使启用了自动内存泄漏检查,依然存在一个问题:string 对象的析构函数会在程序终止前执行,而 CRT dump memory leaks 被调用时,析构函数还没有执行完。因此,字符串对象内部分配的内存可能仍然会被报告为内存泄漏。

std::string str = "Hello, World!"; 
// 程序结束时,析构函数释放内存
_CrtDumpMemoryLeaks();  // 由于析构函数执行较晚,可能会误报泄漏

为了解决这个问题,我们可以采取以下方法:确保 CRT dump memory leaks 被调用的时机与对象析构的时机匹配,或者依赖于调试标志 CRTDBG_LEAK_CHECK 来在程序结束时自动处理。

4. 总结

  • 多重退出点的挑战:当程序有多个退出点时,手动调用 CRT dump memory leaks 变得不方便。通过设置调试标志,可以自动调用该函数,避免了手动调用的麻烦。
  • 局部对象的问题CRT dump memory leaks 无法处理局部对象的内存泄漏,特别是在对象析构函数尚未执行时。为了避免此类问题,可以利用调试标志确保在程序结束时自动检查内存泄漏。
  • 解决方案:通过设置 CRTDBG_ALLOC_MEM_DFCRTDBG_LEAK_CHECK_DF 标志,确保在程序退出前自动进行内存泄漏检查,而无需在每个退出点调用 CRT dump memory leaks

下一步

在下一个视频中,我们将深入探讨如何生成详细的内存泄漏转储,尤其是对于通过 new 分配的内存。我们将讨论如何获得更多关于内存泄漏的详细信息,以帮助进一步调试和修复内存问题。

004 new 的详细泄漏转储

生成详细的内存泄漏转储

在本节中,我们将学习如何生成关于通过 new 分配的内存的更详细的内存泄漏转储。我们已经能够获得关于通过 malloc 分配的内存的详细转储,但接下来我们将演示如何获得同样详细的 new 内存泄漏信息。

1. 调试 malloc 内存分配

当我们使用 malloc 时,定义了一个宏,使得 malloc 映射到一个不同的函数 malloc_dbg,并且能够提供更多的调试信息。此函数的额外参数包括:

  • 类型:分配的块的类型。
  • 文件和行号:这两个是标准 C 宏,用于指示内存分配的位置。
  • 块类型normal block 是 Visual Studio 特有的宏,表示标准的内存分配块。

通过这些,我们可以轻松跟踪内存泄漏的位置。但如果要做到相同的事情用于 new,则需要稍微调整操作。

2. 调试 new 内存分配

为了使 new 也能提供类似于 malloc 的详细调试信息,我们需要确保 new 调用的实际函数是调试版本的 operator new。这样,我们就可以为 new 提供类似的额外参数。

void* operator new(size_t size, const char* file, int line) {
    return ::operator new(size);  // 调用标准 new 操作符
}

接下来,在使用 new 时,我们显式地传递文件名、行号等信息:

#define new (new(__FILE__, __LINE__))  // 替换 new 操作符

这样做可以确保每次使用 new 时,都能获得详细的调试信息,包括文件和行号。

3. 宏定义简化 new 操作

由于每次都手动写出这些参数显然不便,因此我们可以创建一个宏来简化这个过程:

#define debug_new new(__FILE__, __LINE__)  // 创建宏来简化调用

这样,之后你只需要使用 debug_new 来替代 new,并且就能获得调试信息。

int* p = debug_new int;  // 通过宏分配内存

4. 在调试版本和发布版本之间切换

为了确保这个宏只在调试版本中有效,我们可以通过条件编译指令来控制它:

#ifdef _DEBUG
#define debug_new new(__FILE__, __LINE__)
#else
#define debug_new new
#endif

这样,只有在调试版本中,debug_new 才会附加额外的调试信息;在发布版本中,它只是简单地调用标准的 new 操作符。

5. 查看内存泄漏信息

使用 debug_new 后,当程序运行并出现内存泄漏时,你可以在输出窗口中看到详细的调试信息。比如,文件名、行号以及泄漏的内存地址和大小。这样,你就可以轻松地定位到代码中哪个位置发生了内存泄漏。

6. 处理 std::string 类中的内存泄漏

假设我们在 std::string 类中使用了 debug_new,我们可以像下面这样替换:

std::string* str = debug_new std::string("Hello, World!");  // 使用 debug_new 分配字符串

然后,我们通过调试输出看到内存泄漏的详细信息,帮助我们确定在哪一行代码中分配了内存并且没有释放。

7. 发布版本与调试版本的区别

在发布版本中,debug_new 会被替换为标准的 new 操作符,因此在发布版本中你不会看到任何内存泄漏的输出。在调试版本中,debug_new 会调用带有调试信息的 operator new,并显示详细的泄漏信息。

8. 查找和修复内存泄漏

在定位到内存泄漏后,你可以通过释放相应的内存来修复它。在调试输出中,你会看到每个泄漏的内存块的详细信息,包括文件、行号、内存地址等,帮助你精确定位问题。

9. 总结

  • 调试 mallocnew:通过为 mallocnew 引入调试版本,我们可以获取更多关于内存分配的信息,包括文件名、行号以及分配的内存类型。
  • 使用宏简化代码:通过定义 debug_new 宏,我们可以轻松地为 new 操作符添加调试信息。
  • 条件编译:通过条件编译指令,我们确保在发布版本中不会受到调试代码的影响。
  • 发布和调试版本的不同:在发布版本中,内存泄漏检测将被禁用,而在调试版本中,我们能够查看详细的内存泄漏信息。

下一步

在下一个视频中,我们将探索如何在程序运行时检测内存泄漏,特别是对于服务程序或守护进程等无法方便退出的情况。在这类程序中,内存泄漏的检测就变得更为复杂,我们将讨论如何实时跟踪内存泄漏并解决相关问题。

006 内存快照

使用堆检查点检测内存泄漏

在本节中,我们将介绍另一种检测内存泄漏的方法:利用 C 运行时库的调试堆管理器提供的函数,捕捉堆的状态并进行比较。这种状态也称为检查点。通过创建多个检查点并进行比较,我们可以检测程序中是否存在内存泄漏。

1. 什么是堆检查点?

堆检查点是程序在运行过程中堆的状态快照。我们可以在程序的不同阶段创建多个检查点,并对比这些检查点之间的差异。如果在两个检查点之间有差异,那么就有可能发生了内存泄漏。最常见的做法是在程序开始时创建一个检查点,在程序结束时再创建一个检查点,然后比较这两个检查点的差异。

2. 如何捕获堆的状态?

C 运行时提供了一些函数,可以捕获堆的状态。这些函数会存储堆的状态到一个结构体中,这个结构体的类型是 CRT_MEM_STATE。接下来,我们将展示如何使用这些函数来捕获堆的状态并进行比较。

2.1 捕获堆的状态

通过调用 mem_checkpoint 函数,我们可以捕获堆的当前状态,并将其存储在 CRT_MEM_STATE 类型的变量中。捕获堆状态的代码如下:

CRT_MEM_STATE state;
mem_checkpoint(&state);  // 捕获堆状态到 state 变量中

2.2 比较堆的状态

如果你捕获了多个堆状态(例如,在程序的不同时间点),你可以通过调用 mem_diff 函数来比较这些状态之间的差异。如果在两个检查点之间发现了差异,mem_diff 会返回 1,否则返回 0。示例代码如下:

int result = mem_diff(&state1, &state2);  // 比较两个堆状态
if (result == 1) {
    // 存在差异,可能发生了内存泄漏
}

2.3 打印分配但未释放的内存

CRT_MEM_DUMP_ALL_OBJECTS_SINCE 函数可以用来打印所有自上次检查点以来分配但未释放的内存块。这将以类似 CRT_DUMP_MEMORY_LEAKS 的方式显示泄漏信息,非常适合检测内存泄漏。使用这个函数时,可以在程序的任意位置调用:

mem_dump_all_objects_since(&state);  // 打印自上次检查点以来的内存泄漏信息

2.4 打印堆的统计信息

mem_dump_statistics 函数提供了一个概览,告诉你每个类型的内存块中有多少块未被释放。它接受一个 CRT_MEM_STATE 类型的变量,表示两个检查点之间的差异,并打印出每个类型的泄漏块数。代码如下:

mem_dump_statistics(&state);  // 打印堆的统计信息

3. 在 Visual Studio 中的使用示例

在 Visual Studio 中,你可以使用上述函数来捕捉和比较堆的状态。我们可以在程序的多个地方插入这些函数,以便在应用程序运行过程中随时监控堆的状态。以下是一个使用这些函数的示例:

#include <crtdbg.h>

// 创建检查点并比较状态
CRT_MEM_STATE state1, state2;
mem_checkpoint(&state1);  // 捕获程序开始时的堆状态

// 执行一些内存分配操作
int* p1 = new int[100];
int* p2 = new int[200];

// 创建另一个检查点
mem_checkpoint(&state2);  // 捕获程序结束时的堆状态

// 比较两个堆状态
if (mem_diff(&state1, &state2) == 1) {
    // 如果堆状态不同,打印泄漏信息
    mem_dump_all_objects_since(&state1);
    mem_dump_statistics(&state2);
}

4. 总结

  • 堆检查点:堆的状态可以在程序的不同时间点捕获,并通过比较不同的检查点来检测内存泄漏。
  • 捕获堆状态:使用 mem_checkpoint 函数捕获堆的当前状态,存储在 CRT_MEM_STATE 类型的变量中。
  • 比较堆状态:通过调用 mem_diff 函数,比较两个堆状态之间的差异,从而检测是否发生了内存泄漏。
  • 打印泄漏信息:使用 mem_dump_all_objects_sincemem_dump_statistics 打印内存泄漏信息和统计数据。

这种方法的优势在于,它不要求程序停止运行。你可以在程序运行时动态地检查堆的状态,检测并调试内存泄漏问题。

007 内存快照 - 代码示例

如何在 Visual Studio 中捕获内存状态

在本视频中,我们将展示如何在 Visual Studio 中捕获内存的状态,以帮助检测内存泄漏。通过使用堆检查点,我们可以在程序的不同位置捕获堆的状态,并进行比较,从而找出内存泄漏。接下来,我们将一步步介绍如何使用这些功能。

1. 确定创建内存检查点的位置

在程序中,我们不应该随意地在任意位置创建内存检查点。合理的做法是将检查点放置在实际分配和释放内存的函数中。例如,在 Main 函数中,如果我们使用 malloc 为 100 字节分配内存,然后使用 new 分配内存来创建字符串对象,那么可以在这些分配内存的地方创建检查点。这样做的目的是在内存分配之前和之后捕获堆的状态,以便比较并检测是否存在内存泄漏。

2. 创建内存检查点

我们需要定义三个变量来捕获堆的状态:

  • begin:存储初始堆状态
  • end:存储结束时的堆状态
  • difference:存储这两个状态之间的差异

首先,我们在程序开始前创建第一个检查点,然后在内存分配后再创建第二个检查点。以下是具体代码:

// 创建堆状态变量
CRT_MEM_STATE begin, end, difference;

// 捕获初始状态
mem_checkpoint(&begin);

// 分配内存(例如使用 malloc 和 new)
int* p1 = (int*)malloc(100);   // 使用 malloc 分配内存
std::string* p2 = new std::string("Hello");  // 使用 new 分配内存

// 捕获结束状态
mem_checkpoint(&end);

// 比较两个检查点的差异
int result = mem_diff(&difference, &begin, &end);
if (result == 1) {
    // 如果差异不为零,说明有内存泄漏
    mem_dump_statistics(&difference);  // 打印堆统计信息
}

3. 查看堆统计信息

mem_dump_statistics 函数会打印出两个检查点之间的差异,告诉我们每个类别中有多少内存块未被释放。这有助于了解内存泄漏的具体情况。运行程序后,调试窗口中会打印出类似以下的信息:

612 bytes in 2 normal blocks

这表明我们在程序中分配了两块内存,大小为 612 字节,但它们没有被正确释放。

4. 打印所有分配但未释放的内存块

为了获取更多的细节信息,可以使用 CRT_MEM_DUMP_ALL_OBJECTS_SINCE 函数来打印自第一个检查点以来分配的所有内存块。这样,我们能够看到哪些具体的内存块发生了泄漏。代码如下:

mem_dump_all_objects_since(&begin);  // 打印自第一个检查点以来的所有内存块信息

5. 使用 debug_new 获取详细信息

如果我们希望显示每个泄漏的内存块的详细信息,例如文件名和行号,那么需要使用 debug_new 宏来代替普通的 new。这是因为 debug_new 允许在内存分配时记录文件和行号,帮助我们定位泄漏源。

首先,定义 debug_new 宏:

#ifdef _DEBUG
#define debug_new new(_NORMAL_BLOCK, __FILE__, __LINE__)
#else
#define debug_new new
#endif

然后,在程序中使用 debug_new 替代 new

std::string* p2 = debug_new std::string("Hello");

通过这种方式,内存分配的信息将包括文件名和行号,在调试时能更方便地定位问题。

6. 释放内存并避免泄漏

如果我们正确释放了内存,那么在比较检查点时,差异将为零,内存泄漏检测部分的代码将不会被触发。例如,如果我们在分配完内存后及时释放它:

delete p2;  // 释放内存
free(p1);   // 释放内存

在这种情况下,mem_diff 返回的结果将为零,表示没有发生内存泄漏,输出窗口不会显示任何泄漏信息。

7. 注意事项

  • 创建检查点的位置:检查点应该放在分配和释放内存的函数中,而不是随意地在程序中插入。
  • 资源管理:当使用堆检查点时,确保资源的释放发生在最后一个检查点创建之后。否则,像 std::string 这样的对象可能在析构时释放内存,但在创建检查点时,这些析构函数尚未执行,从而误报内存泄漏。

8. 结论

  • 我们可以通过创建内存检查点,在程序运行过程中随时捕获堆的状态,并比较不同检查点之间的差异。
  • 使用 mem_checkpointmem_diffmem_dump_statisticsmem_dump_all_objects_since 等函数,我们能够详细地检测内存泄漏。
  • 使用 debug_new 宏,可以提供更详细的泄漏信息,包括文件和行号,有助于调试。
  • 最后,确保内存分配和释放的操作与堆检查点的创建顺序匹配,避免错误地报告内存泄漏。

通过这些方法,我们可以在程序运行时动态地检测内存泄漏,而无需等到程序退出。

008 内存检查点助手类

创建内存检查点辅助类

在本视频中,我们将创建一个帮助类,用于在函数内部轻松地创建内存检查点。这将使得在程序运行时,能够方便地监控内存的分配和释放,并检测内存泄漏。

1. 定义 MemoryCheckpoint

我们首先定义一个 MemoryCheckpoint 类,该类将帮助我们捕获内存状态。类的构造函数将接受文件名和函数名,并在构造时捕获堆的状态。

类的属性

class MemoryCheckpoint {
private:
    CRT_MEM_STATE begin;  // 存储函数开始时的内存状态
    const char* file;     // 存储文件名
    const char* func;     // 存储函数名

public:
    // 构造函数:初始化文件名和函数名,并捕获堆的初始状态
    MemoryCheckpoint(const char* filename, const char* function)
        : file(filename), func(function) {
        // 捕获函数开始时的堆状态
        mem_checkpoint(&begin);
    }

    // 析构函数:在函数结束时捕获堆的最终状态并进行比较
    ~MemoryCheckpoint() {
        CRT_MEM_STATE end;
        mem_checkpoint(&end);

        // 比较开始和结束的堆状态
        CRT_MEM_STATE difference;
        int result = mem_diff(&difference, &begin, &end);

        // 如果有差异,说明函数中存在内存泄漏
        if (result == 1) {
            // 打印相关信息
            std::cout << "Memory leak detected in function " << func 
                      << " in file " << file << std::endl;
            mem_dump_all_objects_since(&begin);  // 打印泄漏的所有对象信息
            mem_dump_statistics(&difference);   // 打印统计信息
        }
    }
};

代码解析

  • begin:捕获函数开始时的堆状态。
  • filefunc:用于存储文件名和函数名,方便定位内存泄漏。
  • 构造函数:在对象创建时捕获堆的初始状态。
  • 析构函数:在函数结束时捕获堆的最终状态,并与初始状态进行比较。如果有差异,表示内存泄漏,打印相关信息并显示泄漏的对象。

2. 创建宏 MEM_CHECKPOINT

为了方便在代码中使用 MemoryCheckpoint 类,我们定义一个宏 MEM_CHECKPOINT,用于在函数中自动创建 MemoryCheckpoint 类的实例。

#ifdef _DEBUG
#define MEM_CHECKPOINT() MemoryCheckpoint __checkpoint(__FILE__, __FUNCTION__)
#else
#define MEM_CHECKPOINT()  // 在发布版本中不执行内存检查
#endif

宏解析

  • 在调试模式下,MEM_CHECKPOINT 会创建一个 MemoryCheckpoint 类的实例,并自动捕获堆的状态。
  • 在发布版本中,我们定义 MEM_CHECKPOINT 为一个空操作,这样不会影响性能。

3. 使用 MEM_CHECKPOINT

在程序中使用 MEM_CHECKPOINT 宏非常简单,只需要在分配内存的地方调用该宏即可。

void someFunction() {
    MEM_CHECKPOINT();  // 创建内存检查点

    // 分配内存
    int* p1 = new int[100];
    std::string* p2 = new std::string("Hello, World!");

    // 忘记释放内存以模拟内存泄漏
    // delete[] p1;
    // delete p2;
}

在这个例子中,我们在 someFunction 函数中调用了 MEM_CHECKPOINT,该宏会在函数入口时自动捕获堆的状态,并在函数退出时比较堆的状态,检查是否有内存泄漏。

4. 检查程序输出

编译并运行程序时,如果存在内存泄漏,输出窗口会显示类似以下信息:

Memory leak detected in function someFunction in file main.cpp

这表明在 someFunction 函数中发生了内存泄漏。

5. 整合到项目中

通过使用 MEM_CHECKPOINT,我们可以在程序的各个部分轻松地插入内存检查点。这不仅能够帮助我们在调试时检测内存泄漏,还能够确保我们不会遗漏任何可能的内存泄漏问题。

6. 下一步

到这里,我们已经完成了内存检查点辅助类的实现。在接下来的视频中,我们将展示如何将该方法应用于 string 类,以便检查该类中的内存泄漏。

009 检查点问题及解决方案

使用内存检查点类检测 string 类中的内存泄漏

在本视频中,我们将使用之前创建的内存检查点类来检查 string 类中的内存泄漏。这一过程将展示如何有效地利用内存检查点技术来检测程序中潜在的内存泄漏,并避免不必要的内存浪费。

1. 添加 MemoryCheckpoint

首先,我们将添加前面实现的 MemoryCheckpoint 类。为了避免代码重复,我们只需要将 MemoryCheckpoint 的头文件添加到项目中,然后在需要使用的地方引用该类。

#include "MemoryCheckpoint.h"  // 包含内存检查点类

2. 使用 MEM_CHECKPOINT

接下来,我们将使用 MEM_CHECKPOINT 宏来自动创建内存检查点。通过这个宏,在函数开始时会创建一个检查点,而在函数结束时,内存状态将被自动比较,以检测是否存在内存泄漏。

void someFunction() {
    MEM_CHECKPOINT();  // 创建内存检查点

    // 分配内存
    int* p1 = new int[100];
    std::string* p2 = new std::string("Hello, World!");

    // 忘记释放内存以模拟内存泄漏
    // delete[] p1;
    // delete p2;
}

3. 检查 string 类中的内存泄漏

首先我们将 MEM_CHECKPOINT 宏应用于检查 string 类中的内存泄漏。特别地,我们将在 main 函数和 create 函数中分别应用该宏。

// 创建一个字符串对象并返回
std::string createString(const char* rawStr) {
    MEM_CHECKPOINT();  // 创建内存检查点

    std::string str(rawStr);
    str.append(" - Appended Text");

    return str;  // 返回时会创建一个临时副本,可能会导致误报内存泄漏
}

4. 误报内存泄漏的原因

在上述 createString 函数中,返回一个 std::string 对象时,C++ 会创建该对象的副本。而 MEM_CHECKPOINT 宏会在该副本创建后进行内存状态的比较,这可能会导致误报泄漏,因为副本会在函数返回后销毁。

  • 在函数结束时,str 对象会被销毁,但 MEM_CHECKPOINT 会在副本创建之后检查内存状态。这就可能会误报为内存泄漏。

5. 如何避免误报

要避免误报,我们需要小心地使用 MEM_CHECKPOINT 宏,尤其是在返回对象时,或者在那些在创建时分配内存、在销毁时释放内存的函数中。

void someFunction() {
    MEM_CHECKPOINT();  // 创建内存检查点

    // 分配内存
    std::string* p = new std::string("Temporary String");
    
    // 释放内存
    delete p;
}

在上述代码中,我们确保在内存分配后适时释放内存。这样,MEM_CHECKPOINT 就能正确地检测到没有内存泄漏。

6. 在 string 类中应用 MEM_CHECKPOINT

我们将在 string 类中应用内存检查点来检测其是否有内存泄漏。

void stringFunction() {
    MEM_CHECKPOINT();  // 创建内存检查点

    std::string str1 = "Hello";
    std::string str2 = "World";
    str1.append(str2);

    // 忘记释放内存以模拟内存泄漏
}

通过在 stringFunction 中应用 MEM_CHECKPOINT,程序将自动检测是否存在内存泄漏。如果内存泄漏发生,输出窗口会显示泄漏的详细信息,并指出泄漏的文件和函数位置。

7. 输出结果

运行程序时,如果存在内存泄漏,输出窗口将显示以下信息:

Memory leak detected in function stringFunction in file main.cpp

此外,我们还可以在输出窗口查看详细的内存泄漏信息,并通过双击相应的行来查看泄漏的内存块及其分配位置。

8. 处理 string 类中的内存泄漏

由于 string 类会在函数返回时创建临时副本,因此可能会误报内存泄漏。在实际情况中,我们应该避免在返回对象时使用 MEM_CHECKPOINT,或者仅在函数结束时检查内存状态。

例如,在 string 类中,内存分配和释放可能发生在不同的函数中,因此需要小心使用 MEM_CHECKPOINT 宏。

9. 总结

通过使用内存检查点,我们能够在程序中精确地监控内存分配和释放情况,尤其是在调试阶段。相比于传统的 CRT dump memory leaks,使用内存检查点可以让我们在单个代码块内进行内存泄漏检查,而无需停止程序或重新启动应用程序。

在实际开发中,尽管内存检查点非常有用,但我们需要注意以下几点:

  • 对于返回值类型的函数,要小心副本的创建,避免误报内存泄漏。
  • 在类成员函数中,尤其是涉及内存分配和释放的函数,要注意在合适的位置创建内存检查点。

在下一个视频中,我们将介绍另一种内存泄漏检测的方法。

010 Visual Studio 中的快照 - 第一部分

使用 Visual Studio 2015 的堆分析工具检测内存泄漏

在之前的视频中,我们展示了如何手动创建内存检查点来检测内存泄漏。通过在怀疑内存泄漏的地方插入代码,创建内存状态快照并比较它们,如果发现差异,就能确定内存泄漏。今天,我们将探索另一种方法,利用 Visual Studio 2015 中的堆分析工具,可以在不修改代码的情况下创建内存快照,帮助我们检测内存泄漏。

1. 使用 Visual Studio 2015 的堆分析工具

Visual Studio 2015 引入了一项新功能,可以通过内存的标识符(ID)创建快照,而不需要修改代码。这意味着,我们无需手动插入检查点来监测内存泄漏。接下来,让我们看看如何使用这一功能。

2. 设置项目和代码示例

首先,将 string 类添加到项目中。接着,我们移除 heap checker 的引用,并为 main 函数添加一个新的单独文件。我们会展示一个简单的代码示例,演示如何使用 Visual Studio 的堆分析工具检测内存泄漏。

// 分配内存
int* p = new int[100];

// 释放内存
delete[] p;

3. 如何创建内存快照

要使用 Visual Studio 创建内存快照,首先需要在怀疑发生内存泄漏的代码部分设置一个断点。然后,启动程序并进入调试模式。当程序在断点处暂停时,可以打开诊断工具窗口。

  1. 进入 调试 -> Windows -> 显示诊断工具
  2. 这会打开一个新的诊断工具窗口。第一次使用该工具时,可能需要启用堆分析功能。因为堆分析会影响调试时应用程序的性能,所以默认情况下它是关闭的。需要手动启用堆分析功能。

4. 启用堆分析

在诊断工具窗口中,启用堆分析功能后,我们可以开始创建堆的快照。

  1. 点击 拍摄快照 按钮来创建一个快照。
  2. 此时,窗口会显示当前分配的内存和相关的内存分配信息。

5. 观察内存分配与释放

当程序运行并分配内存时,我们可以在诊断工具中看到内存的变化。首先,程序会分配一些内存。点击 拍摄快照 按钮,再次创建快照后,可以看到内存的变化。

  • 红色箭头 表示内存分配。
  • 绿色箭头 表示内存释放。

在内存释放后,可能会看到剩余的少量内存。这是因为堆管理器在分配内存时可能会分配额外的内存用于内部管理,这部分内存会稍后被释放。

6. 查看内存分配的详细信息

如果你想查看某次内存分配的详细信息,可以将鼠标悬停在该分配上,点击它,诊断工具会显示分配的类型和大小。

例如,分配的内存类型可能是宽字符内存(wide memory),大小是1KB。通过双击这条记录,我们可以查看该内存分配的调用栈,从而确定该内存在哪个函数中分配。

  • 如果内存已被释放,再次双击内存分配记录时,将不会显示分配的调用栈,因为这块内存已经被释放。

7. 观察 C 运行时分配的内存

除了程序的内存分配外,Visual Studio 还会显示一些由 C 运行时库(CRT)分配的额外内存。比如,可能会看到 40 字节内存是由 CRT 分配的,这些内存通常不需要担心,因为它们是由系统管理的。

8. 总结

通过 Visual Studio 的堆分析工具,我们可以轻松地创建内存快照并比较不同的快照,以检查内存是否已被正确释放。这种方法无需修改现有的代码,因此非常适合在不干扰程序逻辑的情况下检查内存泄漏。

优势

  • 无需修改代码:不像手动插入内存检查点,堆分析工具允许我们在不修改程序的情况下直接检测内存泄漏。
  • 实时监控:在调试过程中,可以实时查看内存分配与释放的详细情况,并精确定位泄漏发生的位置。
  • 易于使用:通过诊断工具窗口,使用堆分析工具非常直观,适合快速定位内存泄漏。

这样,使用 Visual Studio 的堆分析工具,我们可以更加高效地检测和修复内存泄漏。

011 Visual Studio 中的快照 - 第二部分

使用 Visual Studio 堆分析工具检测 string 类中的内存泄漏

在本视频中,我们将使用 Visual Studio 的堆分析工具来查找我们 string 类中的内存泄漏。为了演示这一过程,我将编写一些代码,读取磁盘上的文本文件,并将文件中的数据显示到屏幕上。这将帮助我们检测在程序运行过程中是否存在内存泄漏。

1. 准备代码

我们首先需要包含一些必需的头文件,并定义一个名为 read_log_file 的函数,该函数用于读取文件并将其内容显示在控制台上。接下来,我们将在 main 函数中多次调用该函数,读取多个文件。

#include <iostream>
#include <fstream>
#include <string>

// 读取文件并显示其内容
void read_log_file(const std::string& filename) {
    std::ifstream file(filename);
    if (file) {
        std::string line;
        while (std::getline(file, line)) {
            std::cout << line << std::endl;
        }
    }
}

2. 读取文件

main 函数中,我们首先读取当前文件 main.cppstring.cpp。由于这些文件非常小,因此内存分配不会很显著。因此,我们将改为读取一个较大的文本文件。这将帮助我们更清楚地观察内存的分配和回收。

int main() {
    read_log_file("main.cpp");
    read_log_file("string.cpp");

    // 读取一个较大的文本文件
    read_log_file("large_text_file.txt");

    system("pause");
    return 0;
}

3. 运行程序并监控内存

在程序运行时,我们可以使用 任务管理器 来观察内存分配的情况。执行时,内存会逐渐增加,直到程序终止。这个过程表明内存已经分配。当程序终止时,内存被回收,但所有内存释放操作实际上是在程序终止后才完成的。这样我们就能注意到可能的内存泄漏。

4. 使用 Visual Studio 堆分析工具

为了检测内存泄漏,我们将使用 Visual Studio 的堆分析工具。在此过程中,我们将添加断点并启用堆分析。

  1. 在程序的开始部分和结束部分添加断点。
  2. 启动调试并确保堆分析已启用。
  3. 在调试过程中,使用 "拍摄快照" 功能来记录内存的分配状态。

通过拍摄快照,我们可以比较不同时间点的内存分配情况,从而检测是否存在内存泄漏。

5. 检测内存泄漏

当我们在调试时拍摄两次快照,可以看到内存的分配和释放情况。第一次快照显示约 300 MB 的内存被分配,第二次快照显示内存被释放,但仍然存在一些小的剩余内存(约 6.78 KB)。这可能是由于 CRT 运行时分配的内部内存块,通常我们可以忽略这些。

当查看分配的内存时,我们可以发现内存泄漏的位置。通过查看分配堆栈,我们可以确定哪些函数导致了内存泄漏。在这种情况下,string 类中的某个地方分配了内存,但没有适当的释放。

6. 使用 std::string 替换自定义的 string

为了避免手动处理内存,我们可以替换为标准库中的 std::string。这将避免内存泄漏的发生,因为 std::string 会自动管理内存。

#include <string>
// 删除之前的自定义 string 类

再次调试并拍摄快照。我们可以看到,这次内存得到了正确的回收,快照显示内存没有泄漏,只有少量由 CRT 分配的内存(6.78 KB)。

7. 总结

我们通过 Visual Studio 的堆分析工具,演示了如何在不修改代码的情况下,通过创建内存快照来检测内存泄漏。这种方法非常有效,尤其是在调试大型程序时。

  • 堆分析工具:不需要修改现有代码即可检测内存泄漏。
  • 实时快照:可以实时捕捉内存分配与释放的快照,帮助快速定位内存问题。
  • 标准库的优势:使用标准库中的 std::string 类可以自动管理内存,避免手动分配和释放内存时容易发生的内存泄漏问题。

通过这些工具和方法,我们可以更加高效地检测和修复程序中的内存泄漏问题。

013 报告模式和类型

使用报告类型和报告模式控制消息显示

在前几期视频中,我们使用了 C 运行时(CRT)库提供的调试堆函数,这些函数帮助我们检测了内存相关的问题,例如堆损坏和内存泄漏。这些函数会显示不同类型的消息,这些消息可以显示在 Visual Studio 的输出窗口或单独的消息窗口中。今天,我们将了解如何通过设置报告类型和报告模式来控制显示的消息类型和显示位置。

1. 报告类型

我们可以通过设置报告类型来控制显示哪些类型的消息。报告类型主要有三种,每种类型对应不同的消息级别:

1.1 CRT_WARN

这是最常见的报告类型,代表警告信息以及其他不需要用户立即关注的消息。例如,内存泄漏信息通常会作为警告消息显示。此类消息通常显示在 Visual Studio 的调试窗口(输出窗口)。

1.2 CRT_ERROR

这个报告类型用于显示无法恢复的错误信息,通常需要用户立即处理。例如,如果调用了 abort 函数,程序会显示一个错误消息,这类消息表示程序发生了严重错误。

1.3 CRT_ASSERT

这个报告类型与断言失败有关。当程序运行到断言失败的地方时,会显示此类型的消息。断言失败通常表示程序的逻辑有问题,需要用户干预修正。

通过设置报告类型,我们可以决定想要查看哪些类型的消息。例如,如果你只希望看到警告而不想看到错误信息,你可以选择将报告类型设置为 CRT_WARN

2. 报告模式

除了设置报告类型,报告模式也可以控制消息的显示位置。报告模式有以下几种:

2.1 CRT_DEBUG_MODE_DEBUG

在此模式下,所有的报告消息都会写入 Visual Studio 的调试输出窗口(Debugger Output Window)。这适用于大多数调试情况,消息会直接显示在开发环境中,方便开发者查看。

2.2 CRT_DEBUG_MODE_FILE

如果选择此模式,所有的报告消息都会写入一个用户指定的文件中。你需要创建一个文件并通过 CRT_SET_REPORT_FILE 函数指定文件句柄。这种模式适用于需要将调试日志记录到文件中以便后续分析的情况。

2.3 CRT_DEBUG_MODE_WINDOW

这种模式会在发生报告时显示一个消息框,消息框中包含三个按钮:Abort(终止)、Retry(重试)和 Ignore(忽略)。这种模式通常用于报告需要立即用户交互的严重错误。

2.4 CRT_DEBUG_REPORT_MODE

此模式用于返回当前报告类型的模式。这有助于我们在运行时检查并决定当前的报告模式。

3. 设置报告类型和报告模式

我们可以通过 CRT_SET_REPORT_MODE 函数结合报告类型和报告模式来决定如何显示消息及其位置。例如,如果你只希望看到警告信息且这些信息显示在调试输出窗口,你可以将报告类型设置为 CRT_WARN,并将报告模式设置为 CRT_DEBUG_MODE_DEBUG

4. 示例

在下一个视频中,我将展示如何使用报告类型和报告模式来控制消息的显示。这将有助于你更精细地控制调试信息的输出,提升调试效率。

总结

  • 报告类型:决定了显示哪些类型的消息,分别是警告(CRT_WARN)、错误(CRT_ERROR)和断言失败(CRT_ASSERT)。
  • 报告模式:决定了消息显示的位置,可以选择调试输出窗口、文件或消息框等不同方式。
  • 结合使用:通过结合报告类型和报告模式,开发者可以精确控制调试消息的显示。

下一期视频将展示如何在实际应用中使用这些功能。期待与你一起深入探讨!

014 报告模式和类型 - 代码示例

改变报告模式和报告类型的示例

在本期视频中,我们将演示如何修改报告模式和报告类型。我们将使用一个简单的示例程序,该程序模拟了内存泄漏,并展示如何根据不同的报告模式处理这些消息。以下是具体的步骤和注意事项。

1. 创建内存泄漏的简单示例

首先,我们将创建一个内存泄漏的简单示例。我们将为一个字符串分配大约 5 字节的内存,然后在程序结束时检测该内存分配的泄漏。为了确保能够自动检测泄漏,我们需要设置一个标志,使得内存泄漏在程序终止前被检测出来。

char* str = new char[5];  // 分配 5 字节的内存

接着,我们运行程序,并观察 Visual Studio 输出窗口中的泄漏信息。默认情况下,所有的警告信息都会显示在调试器的输出窗口。

2. 改变报告模式为窗口模式

如果我们不希望将这些信息显示在调试窗口,而是希望显示在消息框中,可以通过 CRT_SET_REPORT_MODE 函数来修改报告模式。我们将报告类型设置为 CRT_WARN(警告),并将报告模式设置为窗口模式。

CRT_SET_REPORT_MODE(CRT_WARN, CRT_DEBUG_MODE_WINDOW);

但是,注意到 Visual Studio 中有一个 bug,当使用 CRT_DEBUG_MODE_WINDOW 模式时,程序可能会崩溃,因为它无法正确创建消息框。为了解决这个问题,我们可以手动创建一个消息框。代码如下:

MessageBox(NULL, "Nothing to be displayed", "Message", MB_OK);

这将创建一个带有 "OK" 按钮的消息框。为了使这个功能正常工作,我们需要包含 windows.h 头文件。

#include <windows.h>

3. 观察程序的执行

运行程序时,会弹出我们手动创建的消息框,显示有关内存泄漏的信息。虽然这允许我们查看泄漏信息,但这种方式并不理想,因为每条信息都会单独显示一行,这在生成大量警告时显得非常不方便。因此,不推荐将警告消息的报告模式设置为窗口模式。

然而,窗口模式的一个优点是:即使你不在调试器中运行程序,泄漏信息依然会在消息框中显示。

4. 通过文件模式记录报告

在接下来的步骤中,我们将使用文件模式来保存所有的警告消息,而不是显示在窗口中。这样,我们可以将所有的警告信息保存到一个指定的文件中,便于后续查看和分析。

5. 检查错误报告类型

接下来,我们查看错误报告类型(CRT_ERROR)。我们将故意覆盖一些内存并释放它,模拟堆损坏的情况。在调用 delete 时,C 运行时库会检查堆的完整性,如果堆被损坏,它会显示一个消息框。

delete[] str;  // 释放内存

通过修改报告类型为 CRT_ERROR,我们可以控制这些错误消息的显示方式。如果希望仅在调试窗口中显示错误信息,而不弹出消息框,可以将报告模式设置为 CRT_DEBUG_MODE_DEBUG

CRT_SET_REPORT_MODE(CRT_ERROR, CRT_DEBUG_MODE_DEBUG);

此时,程序执行时不会显示消息框,但会在输出窗口中显示错误信息。我们通过调试器运行程序,可以看到堆损坏的错误信息被输出到调试窗口。

6. 使用 abort 函数

如果我们调用 abort() 函数,则会显示一个消息框,提示 "Abort has been called"。同样,我们可以通过修改报告模式来控制消息的显示方式:

abort();

如果将报告模式更改为 CRT_DEBUG_MODE_DEBUG,则不会再显示消息框,而是直接将消息输出到调试窗口中。即使程序不通过调试器运行,调用 abort() 也不会弹出消息框。

7. 总结与建议

  • 对于 警告消息CRT_WARN),不推荐使用窗口模式,因为大量的警告信息可能导致程序的用户体验变差。可以选择将其输出到文件模式或调试窗口。
  • 对于 错误消息CRT_ERROR),推荐保留默认的行为,让错误信息通过消息框显示。这有助于及时提醒开发者严重问题。
  • 对于 断言失败CRT_ASSERT),通常也应该保留默认的行为,特别是在调试过程中。

下一期预告

在下期视频中,我们将展示如何使用报告模式中的 文件模式 来记录所有的警告消息,避免在窗口中逐行显示。感谢观看,我们下次见!

015 报告模式文件

使用文件模式记录报告

在本期视频中,我们将演示如何使用文件模式(File Mode)来记录报告信息。我们将通过将警告信息重定向到文件来展示这一过程。以下是具体步骤。

1. 设置报告模式为文件模式

首先,我们需要设置报告模式为文件模式,并指定报告类型。具体的代码如下:

CRT_SET_REPORT_MODE(CRT_WARN, CRT_DEBUG_MODE_FILE);

2. 指定报告文件

在设置报告模式为文件模式时,我们还需要指定一个文件来记录这些警告信息。为了避免使用 Windows API 手动处理文件句柄,我们将使用标准错误设备(standard error)来重定向输出到文件。

C 和 C++ 应用程序提供了三种标准设备:

  • 标准输入(standard input):来自键盘。
  • 标准输出(standard output):控制台输出。
  • 标准错误(standard error):用于错误消息,也输出到控制台。

我们可以通过 fopen 函数将标准错误输出重定向到一个文件。代码如下:

fopen("leaks.log", "w");  // 将文件 "leaks.log" 打开用于写入

接下来,我们将标准错误重定向到这个文件,并通过 CRT_SET_REPORT_FILE 函数来指定报告文件的输出。

CRT_SET_REPORT_FILE(CRT_WARN, CRT_DEBUG_MODE_FILE, stdout);

3. 编写完整的代码

我们将所有警告信息输出到标准错误设备,并通过 fopen 将标准错误重定向到 leaks.log 文件。

// 打开文件
freopen("leaks.log", "w", stderr); // 将标准错误输出重定向到文件

// 设置报告模式为文件模式,输出警告信息
CRT_SET_REPORT_MODE(CRT_WARN, CRT_DEBUG_MODE_FILE);

// 触发内存泄漏
char* str = new char[5]; // 分配内存,模拟内存泄漏

4. 运行程序

当我们运行程序时,警告信息不会再显示在调试窗口中,而是会写入到 leaks.log 文件。由于我们将标准错误设备重定向到文件中,所以警告信息会直接写入到文件里。

你可以直接运行程序,而不需要通过调试器运行。打开项目目录中的 leaks.log 文件,你会看到记录的内存泄漏信息。程序的输出窗口将不再显示这些警告。

5. 将警告信息同时显示在调试窗口和文件中

如果我们希望在调试窗口中也能看到警告信息,并同时将其写入文件,我们可以使用按位或(OR)运算符,将 CRT_DEBUG_MODE_FILECRT_DEBUG_MODE_DEBUG 组合起来:

CRT_SET_REPORT_MODE(CRT_WARN, CRT_DEBUG_MODE_FILE | CRT_DEBUG_MODE_DEBUG);

然而,要使这些信息出现在调试窗口中,必须通过调试器运行程序。否则,警告信息将仅仅显示在文件中。

6. 总结

使用文件模式,我们能够将警告、错误或断言信息记录到文件中,而不是直接显示在调试器或消息框中。这在处理大量日志信息时非常有用,因为它可以避免过多的弹窗或调试窗口中的冗余信息。

关键点:

  • 报告模式:通过 CRT_SET_REPORT_MODE 设置报告模式为文件模式(CRT_DEBUG_MODE_FILE)。
  • 标准错误重定向:使用 freopen 将标准错误输出重定向到指定的文件(例如 leaks.log)。
  • 组合模式:可以将报告模式同时设置为文件模式和调试模式,以便在文件和调试窗口中同时查看信息。

7. 结语

通过使用文件模式来记录警告信息,你可以在不打扰程序正常运行的情况下,详细记录内存泄漏或其他重要问题的信息。这种方式在调试大型程序时非常有用。如果你有任何问题或遇到困难,欢迎在论坛中提问。我将在下期视频中继续为你展示更多的技巧。感谢观看,我们下次见!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

06 - 自定义泄漏检测器

002 泄漏检测内部实现

自定义内存泄漏与堆损坏检测库的实现

在本节课程中,我们将实现一个自定义的库,帮助我们在 C 和 C++ 应用程序中检测内存泄漏和堆损坏。你可能会问,为什么要这样做,毕竟 Visual Studio 已经提供了相关的功能。原因在于,Visual Studio 提供的 Debug Heap 库是 C 运行时库的一部分,仅能在 Visual Studio 中使用。这个库无法在其他编译器上使用,也不能在 Mac 或 Linux 等平台上运行,因为它是专门为 Windows 设计的。因此,我们希望创建一个自己的库,利用标准的 C 和 C++ 特性。这样,该库就可以在支持标准 C 或 C++ 编译器的任何平台上使用。

创建自定义库的优势:

  • 跨平台支持:我们创建的库能够在所有支持 C 或 C++ 编译器的平台上运行。
  • 深入理解内存管理:实现这样一个库将帮助我们更好地理解内存管理的工作原理。

Visual Studio 如何实现内存泄漏和堆损坏检测

在我们开始实现自定义库之前,首先让我们了解一下 Visual Studio 是如何实现内存泄漏和堆损坏检测的。以下是一些核心概念。

内存分配过程

当用户通过 mallocnew 请求内存时,堆管理器会分配一个比用户请求的内存更大的内存块。为什么要分配这么大的内存块?这是为了在内存块中存储有关内存分配的信息。具体来说,这些信息包括:

  • 分配的行号:即进行内存分配的代码行号。
  • 文件名:包含内存分配的源文件名。
  • 块类型:如果你记得之前的讲解,我们讨论过内存块有五种类型。
  • 请求的内存大小:用户请求的内存块的大小。
  • 前一个块指针:指向前一个内存块的指针。
  • 下一个块指针:指向下一个内存块的指针。

当有多个内存分配时,这些内存块会形成一个双向链表。

内存块结构示例

让我们用一个例子来理解这个过程。如果用户请求分配 4 字节的内存,则系统会分配比 4 字节更大的内存块。比如,分配的内存块可能是 16 字节,其中包括以下几个部分:

  • 块头(Block Header):存储分配的信息,包括上述提到的文件名、行号、块类型等。
  • 神字节(Guard Bytes):这些区域用于检测堆损坏。它们通常在块头和用户数据之间。
  • 用户数据:这部分是分配给用户的内存,用户可以使用它。

用户请求的内存指针指向的是用户数据区,而不是包含分配信息的块头区域。块头区域包含的两个指针分别指向链表中的前一个块和下一个块。

处理内存删除

当用户通过 delete 删除内存时,内存块会从链表中移除,然后被释放。如果用户忘记删除内存指针,内存块将依然存在于链表中,直到程序结束。为了检测这种内存泄漏,我们可以在程序结束时调用 deep memory leaks 函数,该函数会遍历所有的内存块。如果某个内存块仍然在链表中,就说明该内存块存在内存泄漏。此时,系统会查看块头中的信息并打印相关信息。

内存泄漏检测原理总结

  • 内存分配:当用户请求内存时,堆管理器会分配一个更大的内存块,其中包含关于分配的详细信息。
  • 内存泄漏检测:如果用户忘记调用 delete 删除内存,该内存块将不会从链表中移除,直到调用内存泄漏检测函数。
  • 内存泄漏信息输出:检测到内存泄漏时,堆管理器会输出内存泄漏的详细信息,包括文件名、行号、分配大小等。

实现我们的自定义库

在接下来的视频中,我们将根据上述的内存分配机制,开始实现我们自己的内存泄漏和堆损坏检测库。我们的库将模仿 Visual Studio 中的实现方式,通过分配一个更大的内存块,并在块头存储分配信息来进行检测。

结语

通过了解 Visual Studio 如何实现内存泄漏和堆损坏检测的机制,我们为实现自定义的检测库奠定了基础。在下一节课中,我们将开始编写这个库的代码,并逐步实现内存泄漏和堆损坏的检测功能。希望你能在这次实践中更深入地理解内存管理的工作原理。我们下节课见!

003 内存块头和 ptmalloc() 函数

实现自定义内存泄漏检测库

在本节中,我们将开始实现一个自定义的内存泄漏检测库,命名为 PD Leak Detector。我已经创建了项目,接下来的实现将主要使用 C 语言,这样我们可以确保这个库能被 C 和 C++ 应用程序同时使用。如果我们使用 C++ 特性来实现代码,那么 C 语言应用程序将无法使用这个库。因此,为了最大程度地提高跨平台的兼容性,我们将使用 C 来实现。

设计内存块头结构

我们需要设计一个结构体来存储分配的内存块的相关信息。这个信息将包括:

  • 分配所在的函数名称。
  • 包含分配代码的文件名称。
  • 分配发生的行号。
  • 分配的内存大小。
  • 上一个内存块的指针。
  • 下一个内存块的指针。

为了实现这一点,我们将在头文件中定义一个结构体,命名为 Memory Block Header。为了保持平台的独立性,我们将避免使用 #pragma once,而是使用预处理指令。

// 定义内存块头结构
typedef struct MemoryBlockHeader {
    const char* function_name;  // 分配发生的函数名称
    const char* file_name;      // 文件名
    int line_number;            // 行号
    size_t size;                // 分配的内存大小
    struct MemoryBlockHeader* prev_block; // 指向前一个块的指针
    struct MemoryBlockHeader* next_block; // 指向下一个块的指针
} MemoryBlockHeader;

分配与释放内存的函数实现

接下来,我们将实现分配和释放内存的函数。由于 C 不支持函数重载,我们不能直接使用 mallocfree,所以我们将分别创建名为 pt_mallocpt_free 的函数。

pt_malloc 实现

pt_malloc 函数将负责内存的分配。它将申请一个比用户请求的内存更大的内存块,以便存储额外的分配信息。

  1. 内存分配:我们首先会使用 malloc 分配足够的内存,既包括用户请求的内存大小,又包括 MemoryBlockHeader 结构所需的内存空间。
  2. 检查分配是否成功:如果分配失败,则返回 NULL
  3. 初始化内存块头结构:如果分配成功,我们会初始化内存块头结构,存储分配的相关信息。
  4. 维护内存块链表:我们会维护一个双向链表,记录所有已分配的内存块。
#include <stdlib.h>
#include <string.h>
#include "MemoryBlockHeader.h"

MemoryBlockHeader* head = NULL; // 内存块链表的头指针

void* pt_malloc(size_t size, const char* function_name, const char* file_name, int line_number) {
    // 计算分配的总内存大小:用户请求的内存 + 内存块头结构的大小
    size_t total_size = sizeof(MemoryBlockHeader) + size;
    
    // 分配内存
    void* allocated_mem = malloc(total_size);
    if (allocated_mem == NULL) {
        return NULL; // 分配失败,返回 NULL
    }

    // 初始化内存块头
    MemoryBlockHeader* current_block = (MemoryBlockHeader*) allocated_mem;
    
    // 初始化内存块头结构的信息
    current_block->size = size;
    current_block->function_name = function_name;
    current_block->file_name = file_name;
    current_block->line_number = line_number;
    
    // 将当前块插入内存块链表
    if (head == NULL) {
        head = current_block;
        current_block->prev_block = NULL;
        current_block->next_block = NULL;
    } else {
        current_block->next_block = head;
        current_block->prev_block = NULL;
        head->prev_block = current_block;
        head = current_block;
    }
    
    // 返回给用户的内存地址是在内存块头后面的部分
    return (void*) ((char*)allocated_mem + sizeof(MemoryBlockHeader));
}

pt_free 实现

pt_free 函数负责释放内存。我们不能直接释放用户传递的指针,因为用户只接收到内存块的地址,而不是内存块头的地址。因此,在释放时,我们需要进行指针计算,找到内存块头,并从链表中移除该内存块。

void pt_free(void* ptr) {
    if (ptr == NULL) return;  // 检查指针是否为空

    // 计算内存块头的地址
    MemoryBlockHeader* block_header = (MemoryBlockHeader*) ((char*) ptr - sizeof(MemoryBlockHeader));

    // 从链表中移除当前内存块
    if (block_header->prev_block != NULL) {
        block_header->prev_block->next_block = block_header->next_block;
    } else {
        head = block_header->next_block; // 如果是头节点,更新头指针
    }

    if (block_header->next_block != NULL) {
        block_header->next_block->prev_block = block_header->prev_block;
    }

    // 释放内存
    free(block_header);
}

内存块链表的管理

我们创建了一个双向链表来管理内存块。每次分配内存时,新的内存块将被插入到链表的头部。而每次释放内存时,我们都需要从链表中移除相应的块。这个链表将帮助我们在程序结束时检查是否存在内存泄漏。

总结

  • 内存分配pt_malloc 函数负责分配内存,并将分配信息存储在内存块头中。
  • 内存释放pt_free 函数负责释放内存,并确保内存块从链表中被正确移除。
  • 内存块链表:内存块通过双向链表进行管理,确保我们能够检测到内存泄漏。

在下一节课中,我们将继续实现其他功能,并完成库的开发。

004 ptfree() 函数的实现

返回正确的内存地址

在之前的实现中,我们已经通过 pt_malloc 分配了内存并创建了内存块头。接下来,我们需要将正确的内存地址返回给用户。由于分配的内存块包含了内存块头信息,而用户只关心其请求的内存,因此我们需要通过指针算术来获取用户内存的正确地址。

计算返回用户内存地址

内存块头(MemoryBlockHeader)位于分配内存块的起始位置,而用户请求的内存紧跟在内存块头之后。为了让 pt_malloc 返回给用户正确的内存地址,我们只需要将 allocated_mem 指针加上内存块头的大小。

// 当前内存块头
MemoryBlockHeader* current_block = (MemoryBlockHeader*) allocated_mem;

// 返回用户内存的地址:跳过内存块头
void* user_mem = (void*) ((char*) allocated_mem + sizeof(MemoryBlockHeader));

// 返回用户内存地址
return user_mem;

这种方法使用了指针算术来跳过内存块头,并直接返回指向用户内存的地址。实际上,我们可以将这部分表达式直接写在 pt_malloc 函数中,而不需要创建一个单独的变量 user_mem,这只是为了帮助理解。

实现 pt_free 函数

接下来,我们需要实现 pt_free 函数来释放内存。释放内存时,我们必须确保传递给 pt_free 的指针有效,并且指向我们已经分配的内存块的有效部分。

检查指针是否为空

首先,在进入函数后,我们需要检查传递给 pt_free 的指针是否为空。如果为空,则直接返回,不做任何操作。因为在 C++ 中,delete 操作符也会对空指针进行忽略。

if (ptr == NULL) {
    return; // 如果指针为空,则直接返回
}

获取内存块头

由于用户接收到的指针实际上是内存块头之后的地址,我们需要通过指针算术来恢复原始的内存块头地址,以便进行内存块的管理和删除操作。

// 获取内存块头的地址
MemoryBlockHeader* current_block = (MemoryBlockHeader*) ((char*) ptr - sizeof(MemoryBlockHeader));

检查内存块是否有效

我们需要确保指针 current_block 指向的内存块属于我们管理的内存块链表。因此,我们可以编写一个辅助函数 is_valid_block 来验证该内存块是否在链表中。

int is_valid_block(MemoryBlockHeader* block) {
    MemoryBlockHeader* temp = head;
    while (temp != NULL) {
        if (temp == block) {
            return 1; // 找到匹配的块,返回有效标识
        }
        temp = temp->next_block;
    }
    return 0; // 没有找到,返回无效标识
}

如果返回值为 1,表示该内存块是有效的;如果为 0,表示该内存块不属于我们的内存块链表。

从链表中移除内存块

如果该内存块有效,我们需要将其从链表中移除。移除内存块时,需要调整相邻内存块的指针。

  • 如果当前块有前一个块(prev_block),则需要调整前一个块的 next_block 指向当前块的 next_block
  • 如果当前块有后一个块(next_block),则需要调整后一个块的 prev_block 指向当前块的 prev_block
if (is_valid_block(current_block)) {
    // 如果当前块有前一个块,更新前一个块的指针
    if (current_block->prev_block != NULL) {
        current_block->prev_block->next_block = current_block->next_block;
    } else {
        // 如果是头块,更新头指针
        head = current_block->next_block;
    }

    // 如果当前块有后一个块,更新后一个块的指针
    if (current_block->next_block != NULL) {
        current_block->next_block->prev_block = current_block->prev_block;
    }

    // 释放当前块的内存
    free(current_block);
}

总结

  • 检查指针有效性:首先检查传递的指针是否为空。
  • 获取内存块头:通过指针算术获取内存块头。
  • 验证内存块有效性:通过链表遍历,检查内存块是否属于我们管理的内存块链表。
  • 从链表中移除内存块:根据内存块的位置,调整链表中的 prev_blocknext_block 指针。
  • 释放内存:最终释放内存块。

后续步骤

在下一节课中,我们将继续实现一个函数,遍历所有内存块,输出那些尚未被释放的内存块,帮助我们检测内存泄漏。

005 PtDumpLeaks() 函数的实现

实现 pt_dump_leaks 函数

在这部分,我们将实现一个功能来检测并输出内存泄漏的信息。这个函数会遍历我们的内存块链表,检查哪些内存块没有被释放,并将其详细信息输出到屏幕上。

步骤 1:检查链表是否为空

首先,我们需要检查内存块链表是否为空。如果链表为空,意味着没有内存泄漏,因此我们可以直接打印信息并返回。

void pt_dump_leaks() {
    // 检查链表是否为空
    if (head == NULL) {
        printf("No leaks detected.\n");
        return;
    }
    
    // 如果链表不为空,打印内存泄漏信息
    pt_dump_block(head);
}

步骤 2:遍历内存块链表

如果链表不为空,我们需要遍历所有的内存块并输出它们的相关信息。为了便于输出每个内存块的详细信息,我们可以编写一个辅助函数 pt_dump_block,这个函数将会打印每个内存块的详细信息,包括泄漏的字节数、内存地址、行号、函数名以及文件名。

pt_dump_block 函数的实现

pt_dump_block 函数将接收一个 MemoryBlockHeader 指针,并输出该内存块的相关信息。

void pt_dump_block(MemoryBlockHeader* block) {
    // 打印泄漏的字节数
    printf("Leaked %zu bytes at address: %p\n", block->size, (void*) ((char*) block + 1));
    
    // 打印行号、函数名和文件名
    printf("Line Number: %d\n", block->line_number);
    printf("Function Name: %s\n", block->function_name);
    printf("File Name: %s\n", block->file_name);
}

步骤 3:创建用户头文件 tmain.h

为了方便用户使用,我们需要提供一个头文件,其中包含我们实现的所有函数的原型。这个头文件应包含 pt_mallocpt_freept_dump_leaks 函数的声明。为了便于移植,我们将使用预处理器指令,而不是 #pragma once

// tmain.h
#ifndef TMAIN_H
#define TMAIN_H

// 函数原型声明
void* pt_malloc(size_t size, const char* function_name, const char* file_name, int line_number);
void pt_free(void* ptr);
void pt_dump_leaks(void);

// 用宏定义 `debug_malloc`
#define debug_malloc(size) pt_malloc(size, __FUNCTION__, __FILE__, __LINE__)

#endif // TMAIN_H

步骤 4:修改 main.c 文件进行测试

现在,我们可以创建一个 main.c 文件,来测试我们的内存分配和泄漏检测功能。我们会使用宏 debug_malloc 来分配内存,这样用户就不需要手动传递函数名、文件名和行号了。

#include <stdio.h>
#include "tmain.h"

int main() {
    // 使用宏 debug_malloc 来简化内存分配
    int* p = debug_malloc(sizeof(int));

    // 检测内存泄漏
    pt_dump_leaks();
    
    // 释放内存
    pt_free(p);
    
    // 再次检测内存泄漏
    pt_dump_leaks();

    return 0;
}

步骤 5:调试并修复问题

在测试过程中,我们发现程序没有正确结束,并且指针 p 可能未初始化。问题出在 malloc 函数中,我们没有初始化内存块头的 nextprevious 指针。我们应该在 malloc 函数中进行初始化。

// 在 pt_malloc 函数中初始化 next 和 previous 指针
if (allocated_mem != NULL) {
    MemoryBlockHeader* current_block = (MemoryBlockHeader*) allocated_mem;
    current_block->next_block = NULL;
    current_block->prev_block = NULL;
}

步骤 6:修复输出格式

我们还发现输出格式有些问题。特别是在打印函数名、文件名和行号时,可能遗漏了换行符。我们可以在输出时进行调整。

printf("Function Name: %s\n", block->function_name);
printf("File Name: %s\n", block->file_name);

步骤 7:支持 newdelete

在未来的实现中,我们还将添加对 C++ 中的 newdelete 操作符的支持。在下一节中,我们将讨论如何处理 C++ 的内存分配和释放,确保它们与我们的 mallocfree 实现兼容。

总结

  1. pt_dump_leaks:该函数用于遍历链表,检查是否有泄漏的内存块,并打印它们的详细信息。
  2. pt_dump_block:辅助函数,用于打印内存块的信息。
  3. debug_malloc:通过宏简化内存分配的调用,避免用户手动传递函数名、文件名和行号。
  4. 修复问题:调试并修复了指针初始化问题和输出格式问题。
  5. 未来工作:添加对 C++ newdelete 的支持。

在下一节中,我们将继续探讨如何在 C++ 中实现内存分配和释放的检测。

006 添加 C++ 支持 - 第一部分

为 C++ 应用程序添加内存泄漏检测支持

在这部分,我们将实现 C++ 的 newdelete 操作符的重载,以便使用我们在 C 中实现的内存分配和释放功能。通过重载这些操作符,我们可以避免重新实现内存块链表的管理,而是利用之前实现的 mallocfree 函数。

1. 实现 C++ 重载的 newdelete 操作符

我们将在一个新的 C++ 文件中实现这些操作符的重载。这个文件将称为 p_cp.cpp。在这个文件中,我们将重载 C++ 的各种 newdelete 操作符,并调用我们之前定义的 pt_mallocpt_free 函数。

1.1 重载 newdelete 操作符

我们需要重载以下操作符:

  • operator new
  • operator new[] (为数组分配内存)
  • operator delete (释放单个内存块)
  • operator delete[] (释放内存块数组)

重载的基本思想是,当调用 newdelete 时,我们将通过重载的操作符来调用我们自己实现的 mallocfree 函数,从而能够进行内存泄漏检测。

// p_cp.cpp
#include "tmain.h"  // 引入我们定义的 C 函数原型

// 重载 operator new,用于单个内存块的分配
void* operator new(size_t size, const char* function, const char* file, int line) {
    return pt_malloc(size, function, file, line);
}

// 重载 operator new,用于数组的分配
void* operator new[](size_t size, const char* function, const char* file, int line) {
    return pt_malloc(size, function, file, line);
}

// 重载 operator delete,用于释放单个内存块
void operator delete(void* pointer) noexcept {
    pt_free(pointer);
}

// 重载 operator delete[],用于释放内存块数组
void operator delete[](void* pointer) noexcept {
    pt_free(pointer);
}

2. 防止 C 函数在 C++ 中名称被改编(名称修饰)

C++ 编译器会对函数名进行“名称修饰”(Name Mangling),这意味着 C 函数的符号在编译时会被改成特定的格式。如果我们在 C++ 中直接调用 mallocfree,它们的名称将被改变,因此我们需要防止它们被 C++ 编译器修改。

2.1 使用 extern "C" 来避免名称修饰

为了防止 C 函数被名称修饰,我们需要将这些函数放入一个单独的头文件中,并使用 extern "C" 来声明它们。extern "C" 告诉编译器这些函数应该以 C 语言的方式链接,而不是 C++ 的方式。

// d_c.h
#ifndef D_C_H
#define D_C_H

#ifdef __cplusplus
extern "C" {
#endif

// 声明 C 函数,避免名称修饰
void* pt_malloc(size_t size, const char* function, const char* file, int line_number);
void pt_free(void* ptr);

#ifdef __cplusplus
}
#endif

#endif // D_C_H

2.2 保护 C 函数接口的名称修饰

为了确保这些函数只在 C++ 文件中不会被名称修饰,我们可以通过条件编译指令来确定代码是否是在 C++ 文件中编译。C++ 编译器会定义 __cplusplus 宏,因此我们可以使用这个宏来包装 extern "C"

// 在其他 C 文件中保护 C 函数
#ifdef __cplusplus
extern "C" {
#endif

// C 函数的声明

#ifdef __cplusplus
}
#endif

3. 为 main.cmain.cpp 文件提供适配

为了确保无论是 C 文件还是 C++ 文件都能正确使用我们的内存管理函数,我们需要在这些文件中包含合适的头文件,并根据编译器的不同进行适配。

3.1 创建 main.h 文件

main.h 文件将包含用户需要的所有函数原型。无论是 C 文件还是 C++ 文件,只需包含此文件,用户就能使用内存管理功能。

// main.h
#ifndef MAIN_H
#define MAIN_H

#ifdef __cplusplus
#include "discourse_cp.h"  // 仅在 C++ 中包含
#endif

#include "d_c.h"  // 引入 C 函数原型(确保不会被名称修饰)

// 函数原型声明
void* pt_malloc(size_t size, const char* function_name, const char* file_name, int line_number);
void pt_free(void* ptr);
void pt_dump_leaks(void);

// 宏定义
#define debug_malloc(size) pt_malloc(size, __FUNCTION__, __FILE__, __LINE__)

#endif // MAIN_H

3.2 创建 discourse_cp.h 文件

如果是 C++ 文件,discourse_cp.h 文件将提供重载的 newdelete 操作符声明。

// discourse_cp.h
#ifndef DISCOURSE_CP_H
#define DISCOURSE_CP_H

// 重载的 C++ 内存分配和释放操作符
void* operator new(size_t size, const char* function, const char* file, int line);
void* operator new[](size_t size, const char* function, const char* file, int line);
void operator delete(void* pointer) noexcept;
void operator delete[](void* pointer) noexcept;

#endif // DISCOURSE_CP_H

3.3 适配 main.c 文件

在 C 文件中,我们不需要重载 newdelete 操作符。我们只需要包含 C 版本的头文件,并使用 mallocfree

// main.c
#include "main.h"

int main() {
    // 使用 debug_malloc 宏来分配内存
    int* p = debug_malloc(sizeof(int));

    // 检测内存泄漏
    pt_dump_leaks();

    // 释放内存
    pt_free(p);

    // 再次检测内存泄漏
    pt_dump_leaks();

    return 0;
}

3.4 适配 main.cpp 文件

在 C++ 文件中,我们将自动使用重载的 newdelete 操作符。

// main.cpp
#include "main.h"

int main() {
    // 使用 C++ 的 new 操作符分配内存
    int* p = new(sizeof(int), __FUNCTION__, __FILE__, __LINE__);

    // 检测内存泄漏
    pt_dump_leaks();

    // 释放内存
    delete p;

    // 再次检测内存泄漏
    pt_dump_leaks();

    return 0;
}

4. 总结

通过这一系列的修改,我们已经为 C++ 应用程序实现了内存泄漏检测功能。关键步骤包括:

  1. 重载 newdelete 操作符:在 C++ 中通过重载操作符,使其能够使用我们定义的 mallocfree 函数来进行内存分配和释放。
  2. 防止名称修饰:通过 extern "C" 防止 C 函数在 C++ 中被名称修饰,确保能够正确链接。
  3. 统一接口:用户只需包含一个 main.h 头文件,无论是 C 还是 C++ 项目,都可以使用相同的内存管理接口。

在下一步,我们将进一步优化和测试 C++ 的内存管理功能,确保其在实际应用中的稳定性和性能。

007 添加 C++ 支持 - 第二部分

为 C++ 添加内存泄漏检测支持

在这部分,我们将完成在 C++ 中通过重载 newdelete 操作符来检测内存泄漏的过程。通过这一实现,我们可以在 C++ 中使用自定义的内存管理功能,避免手动调用 mallocfree

1. 创建宏来简化 newdelete 的使用

首先,为了避免用户在调用 new 时需要手动指定所有的参数,我们可以像之前为 mallocfree 创建的宏一样,创建一个用于 new 操作符的宏。这样,用户只需要使用 new 即可,宏会自动填充所需的参数。

1.1 定义 new

我们将定义一个宏,类似于 debug_malloc,用于 new 操作符,以便自动传递 function, file, 和 line 参数。

// 在 main.h 中定义一个宏来简化 new 操作符的调用
#ifdef _DEBUG
#define debug_new(size) new(size, __FUNCTION__, __FILE__, __LINE__)
#else
#define debug_new(size) new(size)
#endif

1.2 删除 delete 宏定义

对于 delete,我们不需要创建宏,因为 C++ 中 delete 已经被重载,我们只需要调用 delete 操作符即可。

2. 检查泄漏并调用 pt_dump_leaks

在主函数中,我们将调用 new 来分配内存,并使用 delete 来释放内存。在每次操作后,我们都会调用 pt_dump_leaks 来检测内存泄漏。

// main.cpp
#include "main.h"

int main() {
    // 使用 debug_new 宏来分配内存
    int* p = debug_new(sizeof(int));

    // 检测内存泄漏
    pt_dump_leaks();

    // 释放内存
    delete p;

    // 再次检测内存泄漏
    pt_dump_leaks();

    return 0;
}

3. 处理调试和发布版本的区分

我们不希望在发布版本中包含内存泄漏检查的代码,因此,我们需要确保这些检查和宏仅在调试版本中启用。

3.1 在 main.h 中区分调试和发布版本

为了在调试版本中启用内存泄漏检查,我们可以利用 Visual Studio 提供的 _DEBUG 宏。这个宏仅在调试版本中定义,所以我们可以根据它来启用或禁用相关的代码。

// main.h
#ifndef MAIN_H
#define MAIN_H

#ifdef _DEBUG
#include "discourse_cp.h"  // 仅在调试版本中包含
#endif

#include "d_c.h"  // 引入 C 函数原型(确保不会被名称修饰)

// 函数原型声明
void* pt_malloc(size_t size, const char* function_name, const char* file_name, int line_number);
void pt_free(void* ptr);
void pt_dump_leaks(void);

// 宏定义
#ifdef _DEBUG
#define debug_malloc(size) pt_malloc(size, __FUNCTION__, __FILE__, __LINE__)
#else
#define debug_malloc(size) malloc(size)
#endif

#endif // MAIN_H

3.2 在 C++ 中条件定义 delete 操作符

为了确保 delete 操作符在发布版本中不会调用我们的自定义版本,我们还需要使用 _DEBUG 宏来仅在调试版本中启用我们自定义的 delete 操作符。

// p_cp.cpp
#include "tmain.h"  // 引入我们定义的 C 函数原型

#ifdef _DEBUG
// 重载 operator delete,用于释放单个内存块
void operator delete(void* pointer) noexcept {
    pt_free(pointer);
}

// 重载 operator delete[],用于释放内存块数组
void operator delete[](void* pointer) noexcept {
    pt_free(pointer);
}
#endif

4. 解决构建时的问题

在构建过程中,我们遇到了一些错误和警告,主要是因为我们重新定义了 new 操作符,并且预处理器在处理宏时出现了问题。为了避免这种情况,我们取消了对 main.hnew 的重定义,同时确保包括正确的头文件来解决 mallocfree 的声明问题。

4.1 解决多重定义的问题

为了避免在同一个项目中出现多个 main 函数导致的链接错误,我们决定排除掉 main.c 文件,使得 main.cpp 文件可以独立构建。

// main.c
#include "main.h"

int main() {
    // 使用 debug_malloc 宏来分配内存
    int* p = debug_malloc(sizeof(int));

    // 检测内存泄漏
    pt_dump_leaks();

    // 释放内存
    pt_free(p);

    // 再次检测内存泄漏
    pt_dump_leaks();

    return 0;
}

5. 测试和验证内存泄漏检测

在完成所有的设置后,我们开始构建和测试程序。在调试版本中,内存泄漏可以被正确检测和报告。程序会显示内存泄漏的位置信息,并且在释放内存后,检测结果会显示没有内存泄漏。

// 调试版本输出示例
Leaks detected:
4 bytes leaked at line number 6
4 bytes leaked at line number 5

在发布版本中,所有的泄漏检测功能将被禁用,程序会像正常的 C++ 程序一样运行,而不会带来额外的开销。

6. 总结

到目前为止,我们已成功地为 C++ 程序添加了内存泄漏检测支持,关键步骤如下:

  1. 重载 newdelete 操作符:通过重载 C++ 的 newdelete 操作符,将内存分配和释放交给自定义的函数来处理。
  2. 调试与发布版本的区别:使用 _DEBUG 宏,确保内存泄漏检查功能仅在调试版本中启用。
  3. 适配不同编译环境:确保程序在 Visual Studio 的调试版本和发布版本中都能正常工作,且不影响性能。

接下来,我们将在不同的编译器上测试此功能,以确保其跨平台兼容性。

008 在 Linux 上编译

在 GKE 上测试内存泄漏检测库

在本节中,我们将测试在 GKE(Google Kubernetes Engine)上构建的内存泄漏检测库。由于 GKE 不使用 Visual Studio 特定的 _DEBUG 宏,我们将手动定义该宏,并进行适当的修改,以确保库能够在不同的编译器和环境中正常工作。

1. 在 GKE 上定义 __DEBUG

在 GKE 中,默认情况下没有定义 Visual Studio 的 _DEBUG 宏,因此我们需要手动定义它,或者在编译时通过 -D 选项来定义。这样做的目的是确保我们的调试代码只在调试版本中启用,而在发布版本中禁用。

1.1 从命令行定义宏

我们可以通过 -D 选项在编译时定义 __DEBUG 宏,这样在代码中就可以使用该宏来区分调试和发布版本。

# 使用 -D 选项定义 _DEBUG 宏
gcc -D_DEBUG main.c -o main

2. 处理编译器特定的宏

在不同的编译器中,处理函数名称和签名的宏也有所不同。GCC 使用与 Visual Studio 不同的宏,因此我们需要做一些调整以支持跨平台的编译。

2.1 适配不同编译器的宏

main.h 头文件中,我们通过预处理指令来根据编译器的不同定义不同的宏。例如,Visual Studio 使用 __func__,而 GCC 使用 __PRETTY_FUNCTION__。我们将定义一个宏来统一这些名称,以确保我们的库在不同的编译器中能够正常工作。

// main.h

#ifdef _WIN32
    // Visual Studio 使用的函数名宏
    #define PT_FUNCTION_NAME __func__
#elif defined(__GNUC__)
    // GCC 使用的宏
    #define PT_FUNCTION_NAME __PRETTY_FUNCTION__
#else
    // 对于其他编译器,我们定义一个通用的宏
    #define PT_FUNCTION_NAME "Unknown Function"
#endif

2.2 替换 __func__ 为统一宏

接下来,我们将在代码中使用 PT_FUNCTION_NAME 宏来替代特定于编译器的 __func__

// 替换所有 __func__ 为 PT_FUNCTION_NAME
void* pt_malloc(size_t size) {
    printf("Allocating %zu bytes in function: %s\n", size, PT_FUNCTION_NAME);
    // 其他实现
}

3. 编译并测试 C 代码

在修改了编译器宏后,我们首先测试 C 代码。由于 GKE 使用的是 GCC,我们需要确保 C 文件以 C 代码而非 C++ 代码进行编译。

3.1 编译 C 代码

首先,我们编译 main.c 文件并生成对象文件,然后将其与其他源文件一起链接。

gcc -D_DEBUG main.c -c -o main.o
gcc -D_DEBUG d_c.c -c -o d_c.o
gcc main.o d_c.o -o my_program

3.2 运行程序

编译成功后,我们可以运行程序并检查内存泄漏。

./my_program

在运行时,程序应该能够检测到内存泄漏。

4. 编译并测试 C++ 代码

接下来,我们使用 G++ 编译器来测试 C++ 代码。由于 G++ 会将 C 文件视为 C++ 文件,因此我们需要按照先编译 C 代码、再编译 C++ 代码的顺序进行操作。

4.1 编译 C 文件

首先,编译 d_c.c 文件,生成对象文件。

gcc -D_DEBUG -c d_c.c -o d_c.o

4.2 编译 C++ 文件

接下来,编译 C++ 源文件,并与 C 文件链接。

g++ -D_DEBUG main.cpp -c -o main.o
g++ main.o d_c.o -o my_program_cpp

4.3 运行 C++ 程序

运行程序并检查是否能正确检测到内存泄漏。

./my_program_cpp

5. 解决编译时的错误

在编译过程中,我们遇到了一些常见的错误,例如未定义 size_t。这是因为 GKE 使用的标准库没有自动包含相关头文件。在这种情况下,我们只需要在相关的头文件中添加 #include <stddef.h>#include <stdlib.h>

// 在 d_c.c 中加入头文件
#include <stddef.h>  // 或者 <stdlib.h>

6. 测试发布版本

最后,我们可以测试不定义 __DEBUG 宏的发布版本。通过取消编译时对 __DEBUG 宏的定义,我们可以模拟发布版本的构建,确保在发布版本中不会启用调试功能。

# 编译发布版本时不定义 _DEBUG 宏
gcc -o my_program_release main.c d_c.c

7. 总结

通过这次测试,我们确保了我们的内存泄漏检测库可以在 GKE 上正确编译和运行,且支持跨平台的编译。关键步骤如下:

  1. 手动定义 _DEBUG:由于 GKE 不定义 Visual Studio 特有的 _DEBUG 宏,我们通过命令行定义了该宏。
  2. 适配不同编译器的宏:通过定义跨平台的 PT_FUNCTION_NAME 宏,确保库可以在不同的编译器中正确识别函数名。
  3. 分别编译 C 和 C++ 文件:使用 GCC 和 G++ 编译 C 和 C++ 代码,并确保它们能够与其他文件正确链接。
  4. 测试调试和发布版本:通过在调试和发布版本中分别定义和取消定义 _DEBUG 宏,确保调试功能仅在调试版本中启用。

通过这些步骤,我们的库现在可以在各种标准的 C 和 C++ 编译器中工作,并且能够在调试版本中检测内存泄漏。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

07 - 堆损坏支持

001 检测堆损坏 - 内部实现

添加堆内存损坏检测功能

在本节中,我们将为内存泄漏检测库增加堆内存损坏(heap corruption)检测功能。这将模仿 Visual Studio 的内存损坏检测机制,通过监控分配内存周围的特定值来识别潜在的内存覆盖错误。

1. Visual Studio 的堆内存损坏检测原理

Visual Studio 的堆内存损坏检测方法主要依赖于 "No Man's Land"(无人区)的概念。具体来说,用户分配的内存块会被已知的值包围,这些值会被用来检测内存是否被篡改。如果这些已知值发生变化,就表示分配的内存区域可能被意外覆盖,导致堆内存损坏。

1.1 No Man's Land 和内存检查

  • No Man's Land 是指用户分配的内存前后区域,这两个区域都被设置为已知的值。
  • 如果这些值在内存块使用期间发生了变化,程序就会认为发生了堆内存损坏。
  • 在 Visual Studio 中,_CrtCheckMemory 函数会遍历所有内存块,并检查这些 No Man's Land 区域。如果检测到这些区域中的值发生了变化,它会弹出对话框并给出相关错误信息。例如:
    • 如果用户内存块前的区域发生变化,显示 Heap corruption detected before normal block
    • 如果用户内存块后的区域发生变化,显示 Heap corruption detected after normal block

2. 实现内存损坏检测

为了实现类似的堆内存损坏检测机制,我们需要修改现有的内存分配器,使其能够在用户分配的内存前后添加额外的内存区域,并检查这些区域的值。

2.1 分配更大内存块

我们已经为检测内存泄漏而分配了一个比用户请求的内存更多的内存块。在这个内存块中,我们将额外分配两个区域,这两个区域就是 No Man's Land

  • 一个在用户内存块的前面;
  • 一个在用户内存块的后面。

这样,内存块的结构将会像这样:

+------------------+-------------------+-------------------+
| No Man's Land (前) | 用户的内存块         | No Man's Land (后)   |
+------------------+-------------------+-------------------+

2.2 堆块头结构

为了能够在内存块的前后存储 No Man's Land 区域,我们需要修改内存块头的结构。可以在内存块头中增加额外的空间来保存这些区域。假设我们使用一个固定的 16 字节作为 No Man's Land 区域的大小,那么每个内存块的总大小将是:

  • 用户请求的内存大小
  • 前后两个 No Man's Land 区域(每个 16 字节)

2.3 内存检查函数

我们将实现一个类似于 _CrtCheckMemory 的函数来遍历所有已分配的内存块,并检查每个内存块前后的 No Man's Land 区域的值。如果值发生变化,就表示堆内存发生了损坏。

3. 在 Visual Studio 中实现堆内存损坏检测

3.1 修改内存分配逻辑

我们需要在内存分配时增加额外的空间,用于 No Man's Land 区域。假设我们为每个内存块的前后添加 16 字节作为 No Man's Land 区域。

// 修改后的内存块头结构
struct BlockHeader {
    size_t size;                // 用户请求的内存大小
    char noMan'sLandBefore[16]; // No Man's Land - 前
    void* userMemory;           // 用户的内存块
    char noMan'sLandAfter[16];  // No Man's Land - 后
};

// 重写分配内存的函数
void* pt_malloc(size_t size) {
    // 为了包含 No Man's Land 区域,我们增加额外的内存空间
    size_t totalSize = size + 2 * sizeof(char[16]); // 用户内存和前后 No Man's Land 区域
    BlockHeader* header = (BlockHeader*)malloc(totalSize);
    if (!header) {
        return nullptr; // 内存分配失败
    }

    // 初始化 No Man's Land 区域
    memset(header->noMan'sLandBefore, 0xAA, sizeof(header->noMan'sLandBefore));
    memset(header->noMan'sLandAfter, 0xAA, sizeof(header->noMan'sLandAfter));

    // 设置用户内存的指针
    header->userMemory = (void*)(header + 1);  // 用户内存位于 BlockHeader 之后
    return header->userMemory;
}

3.2 实现内存损坏检测函数

接下来,我们实现一个函数来检查所有已分配内存块的 No Man's Land 区域的值。

void checkMemory() {
    // 遍历所有内存块,检查 No Man's Land 区域
    for (auto& block : allocatedBlocks) {
        // 检查前后 No Man's Land 区域的值是否被修改
        if (memcmp(block->noMan'sLandBefore, "\xAA\xAA\xAA\xAA", 16) != 0) {
            std::cout << "Heap corruption detected before normal block" << std::endl;
        }
        if (memcmp(block->noMan'sLandAfter, "\xAA\xAA\xAA\xAA", 16) != 0) {
            std::cout << "Heap corruption detected after normal block" << std::endl;
        }
    }
}

3.3 内存块的释放

释放内存时,我们不需要特别注意 No Man's Land 区域,因为它们只用于检查堆内存损坏。在释放内存时,仍然按照原有方式操作:

void pt_free(void* ptr) {
    BlockHeader* header = (BlockHeader*)ptr - 1; // 获取内存块头
    free(header); // 释放整个内存块(包括 No Man's Land 区域)
}

4. 测试内存损坏检测功能

  1. 在内存分配和释放过程中,我们将故意修改 No Man's Land 区域,模拟内存损坏情况,看看是否能成功检测到堆内存损坏。
  2. 在每次分配和释放内存时,我们调用 checkMemory 函数,查看是否能正确检测到损坏。
int main() {
    // 分配内存并进行内存损坏测试
    char* ptr = (char*)pt_malloc(100);
    pt_free(ptr);

    // 故意修改 No Man's Land 区域
    memcpy(ptr - 16, "corruption", 10);  // 修改前 No Man's Land 区域

    checkMemory();  // 应该能检测到堆内存损坏
}

5. 总结

我们已经完成了以下几项工作:

  1. 修改内存分配逻辑:在用户请求的内存前后添加 No Man's Land 区域,并用已知值填充这些区域。
  2. 实现内存损坏检测:通过 checkMemory 函数检测内存前后的区域是否发生变化。
  3. 测试内存损坏:通过故意篡改 No Man's Land 区域,验证是否能正确检测到堆内存损坏。

通过这些改进,我们的内存检测库现在不仅可以检测内存泄漏,还能够有效地检测堆内存损坏,模拟 Visual Studio 的内存检测功能。

002 PtCheckMemory() 实现 - 第一部分

堆内存损坏检测功能实现

在本节中,我们将为内存检测库增加堆内存损坏(heap corruption)检测功能。我们会修改内存块头结构,并添加一个“Guard”区域(防护区)来帮助检测内存是否被覆盖。通过这个功能,我们能够模拟类似于 Visual Studio 的堆内存损坏检测机制。

1. 修改内存块头

我们需要为每个内存块添加一个新的属性——Guard(防护区)。这个属性将是一个大小为 4 字节的无符号字符数组。其主要功能是防止用户内存被意外覆盖,通过在内存块的前后填充已知值来检测这些区域是否被修改。

1.1 添加 Guard 数组

首先,我们定义一个 Guard 数组,并且用一个已知值填充它。我们定义一个常量 God fill,并将其值设置为 0xFD,就像 Visual Studio 一样。

我们还需要添加一个宏 God size 来规定该防护区的大小,设置为 4 字节。

// 定义 Guard 区域大小为 4 字节
#define GOD_SIZE 4

// 定义 Guard 填充值为 0xFD
static const unsigned char GOD_FILL = 0xFD;

接下来,我们会修改内存块头结构,加入 Guard 数组。

1.2 修改内存块头结构

我们将 BlockHeader 结构体修改,增加一个 Guard 属性,并确保计算内存块总大小时,考虑到 Guard 区域。

// 修改后的内存块头结构
struct BlockHeader {
    size_t size;                // 用户请求的内存大小
    char noMan'sLandBefore[16]; // No Man's Land - 前
    void* userMemory;           // 用户的内存块
    char noMan'sLandAfter[16];  // No Man's Land - 后
    unsigned char guard[GOD_SIZE]; // Guard 区域
};
1.3 更新内存分配函数

我们需要修改内存分配函数,将 Guard 区域包含到内存块中,同时用已知值填充这些区域。

void* pt_malloc(size_t size) {
    // 增加 Guard 区域和 No Man's Land 区域的空间
    size_t totalSize = size + 2 * sizeof(char[16]) + GOD_SIZE; // 用户内存 + 前后 No Man's Land 区域 + Guard 区域
    BlockHeader* header = (BlockHeader*)malloc(totalSize);
    if (!header) {
        return nullptr; // 内存分配失败
    }

    // 初始化 No Man's Land 区域
    memset(header->noMan'sLandBefore, 0xAA, sizeof(header->noMan'sLandBefore));
    memset(header->noMan'sLandAfter, 0xAA, sizeof(header->noMan'sLandAfter));

    // 填充 Guard 区域
    memset(header->guard, GOD_FILL, GOD_SIZE);

    // 设置用户内存的指针
    header->userMemory = (void*)(header + 1);  // 用户内存位于 BlockHeader 之后
    return header->userMemory;
}

2. 填充 No Man's Land 后的 Guard 区域

在上面的代码中,我们已经处理了内存块前面的 No Man's Land 区域,并成功将 Guard 区域填充到内存块中。接下来,我们需要在内存块的尾部(用户内存之后)填充 Guard 区域。

2.1 计算内存块结束位置

为了填充 No Man's Land 后的 Guard 区域,我们需要计算内存块结束的位置。通过以下方式,我们可以得到内存块的结尾位置,并填充防护区。

void* pt_malloc(size_t size) {
    size_t totalSize = size + 2 * sizeof(char[16]) + GOD_SIZE;
    BlockHeader* header = (BlockHeader*)malloc(totalSize);
    if (!header) {
        return nullptr; // 内存分配失败
    }

    // 初始化前后 No Man's Land 区域
    memset(header->noMan'sLandBefore, 0xAA, sizeof(header->noMan'sLandBefore));
    memset(header->noMan'sLandAfter, 0xAA, sizeof(header->noMan'sLandAfter));

    // 填充 Guard 区域
    memset(header->guard, GOD_FILL, GOD_SIZE);

    // 获取指向内存块结束位置的指针
    unsigned char* pEnd = (unsigned char*)(header + 1) + size;
    
    // 填充 Guard 区域在用户内存后的部分
    memset(pEnd, GOD_FILL, GOD_SIZE);

    header->userMemory = (void*)(header + 1);
    return header->userMemory;
}

3. 实现内存损坏检查

为了检测内存是否被覆盖,我们需要创建一个函数,遍历所有已分配的内存块,并检查每个内存块的 Guard 区域是否被修改。这个函数类似于 _CrtCheckMemory

3.1 堆损坏检查函数
void p_checkMemory() {
    // 遍历所有已分配的内存块,检查 Guard 区域是否被修改
    for (auto& block : allocatedBlocks) {
        // 检查前后 No Man's Land 区域是否被篡改
        if (memcmp(block->noMan'sLandBefore, "\xAA\xAA\xAA\xAA", 16) != 0) {
            std::cout << "Heap corruption detected before normal block" << std::endl;
        }
        if (memcmp(block->noMan'sLandAfter, "\xAA\xAA\xAA\xAA", 16) != 0) {
            std::cout << "Heap corruption detected after normal block" << std::endl;
        }
        // 检查 Guard 区域是否被修改
        if (memcmp(block->guard, &GOD_FILL, GOD_SIZE) != 0) {
            std::cout << "Heap corruption detected in Guard area" << std::endl;
        }
    }
}

4. 堆损坏信息转储

为了方便调试,我们还可以实现一个辅助函数 dumpHeapCorruptionInfo,用于输出堆损坏的详细信息,帮助我们定位问题。

void dumpHeapCorruptionInfo(BlockHeader* block) {
    if (memcmp(block->noMan'sLandBefore, "\xAA\xAA\xAA\xAA", 16) != 0) {
        std::cout << "Heap corruption detected before normal block at address: " << block << std::endl;
    }
    if (memcmp(block->noMan'sLandAfter, "\xAA\xAA\xAA\xAA", 16) != 0) {
        std::cout << "Heap corruption detected after normal block at address: " << block << std::endl;
    }
    if (memcmp(block->guard, &GOD_FILL, GOD_SIZE) != 0) {
        std::cout << "Heap corruption detected in Guard area at address: " << block << std::endl;
    }
}

5. 总结

通过上述修改,我们已经为内存泄漏检测库添加了堆内存损坏检测的功能。具体步骤如下:

  1. 添加 Guard 区域:我们在内存块的前后增加了 No Man's Land 区域,并使用已知值填充这些区域。
  2. 修改内存分配函数:更新内存分配函数,确保在每个内存块中包含 Guard 区域并填充已知值。
  3. 实现内存损坏检查函数:创建了 p_checkMemory 函数,定期检查已分配内存块的 Guard 区域是否被覆盖。
  4. 提供堆损坏信息输出:实现了 dumpHeapCorruptionInfo 函数,以便在检测到堆损坏时,输出详细的错误信息。

通过这些改进,我们的内存检测库现在不仅可以检测内存泄漏,还能够有效检测堆内存损坏,提供更强大的内存管理支持。

003 PtCheckMemory() 实现 - 第二部分

堆内存损坏检测实现继续

在上一部分中,我们已经为内存泄漏检测库添加了堆内存损坏检测的基本框架。现在,我们继续实现 checkMemory 功能,目的是通过对比内存区域前后是否被修改来检测堆内存损坏。我们将通过比较内存中的 Guard 区域与预期值来识别内存是否遭到破坏。

1. 准备 Guard 区域的预期值

首先,我们为 Guard 区域创建一个缓冲区,初始化为 GodFill 值(0xFD),然后将其与内存块中的实际 Guard 区域进行比较。

1.1 定义初始化函数

为了提高代码的可读性,我们将创建两个函数来帮助计算内存区域的偏移量,从而确定需要比较的内存区域。

  • getBeginGuard:获取用户内存前 Guard 区域的指针。
  • getEndGuard:获取用户内存后 Guard 区域的指针。
// 获取用户内存前的 Guard 区域
unsigned char* getBeginGuard(BlockHeader* block) {
    return (unsigned char*)(&block->guard);
}

// 获取用户内存后的 Guard 区域
unsigned char* getEndGuard(BlockHeader* block) {
    unsigned char* pEnd = (unsigned char*)block->userMemory + block->size;
    return pEnd;
}

2. 比较 Guard 区域

我们将使用 memcmp 来比较 Guard 区域和预期值。如果两个内存区域的值不一致,表示内存已被损坏。我们会输出相关信息,帮助开发者定位问题。

2.1 实现 checkMemory 函数

checkMemory 函数中,我们将调用 getBeginGuardgetEndGuard,并用 memcmp 比较它们的值。如果 Guard 区域与预期值不匹配,我们将打印堆损坏的信息。

void p_checkMemory() {
    // 准备 Guard 区域的预期值
    unsigned char expectedGuard[GOD_SIZE];
    memset(expectedGuard, GOD_FILL, GOD_SIZE);

    // 遍历所有已分配的内存块,检查 Guard 区域是否被篡改
    for (auto& block : allocatedBlocks) {
        // 检查内存块前面的 Guard 区域
        unsigned char* beginGuard = getBeginGuard(block);
        if (memcmp(beginGuard, expectedGuard, GOD_SIZE) != 0) {
            // 如果 Guard 区域的值不一致,表示内存已被篡改
            std::cout << "Heap corruption detected before normal block at address: " << block << std::endl;
            std::cout << "File: " << __FILE__ << ", Line: " << __LINE__ << ", Function: " << __FUNCTION__ << std::endl;
        }

        // 检查内存块后面的 Guard 区域
        unsigned char* endGuard = getEndGuard(block);
        if (memcmp(endGuard, expectedGuard, GOD_SIZE) != 0) {
            // 如果 Guard 区域的值不一致,表示内存已被篡改
            std::cout << "Heap corruption detected after normal block at address: " << block << std::endl;
            std::cout << "File: " << __FILE__ << ", Line: " << __LINE__ << ", Function: " << __FUNCTION__ << std::endl;
        }
    }
}

在上面的代码中,我们首先准备了一个名为 expectedGuard 的数组,并将其初始化为 0xFD。然后,我们对每个已分配的内存块执行以下操作:

  1. 调用 getBeginGuard 获取内存块前 Guard 区域的地址,并与 expectedGuard 进行比较。
  2. 调用 getEndGuard 获取内存块后 Guard 区域的地址,并与 expectedGuard 进行比较。

如果两者不相等,就意味着内存块的 Guard 区域被修改,表示堆内存已经损坏,我们会输出相关的错误信息。

3. 测试堆内存损坏

为了验证我们的功能是否正常工作,我们将进行一些测试。我们会故意破坏内存,看看库是否能够正确检测到这些堆内存损坏。

3.1 检测内存破坏

首先,我们分配 5 字节的内存,实际需要 6 字节(包括一个空字符)。然后,我们故意修改这块内存,并调用 p_checkMemory 来检查内存损坏。

char* str = (char*)pt_malloc(5);  // 实际需要 6 字节(包括空字符)
str[5] = 'A';  // 故意修改超出范围的内存,造成堆损坏

// 调用内存检查函数,检测是否有内存破坏
p_checkMemory();

我们会看到类似以下的输出信息:

Heap corruption detected after normal block at address: 0x7ffeee6c3b90
File: main.cpp, Line: 123, Function: testHeapCorruption
3.2 检测内存损坏前

同样,我们也可以故意破坏内存块的前 Guard 区域,然后检查检测结果。

char* str2 = (char*)pt_malloc(5);
memcpy(str2, "Hello", 5);  // 故意覆盖前 Guard 区域

// 调用内存检查函数,检测是否有内存破坏
p_checkMemory();

我们会看到类似以下的输出信息:

Heap corruption detected before normal block at address: 0x7ffeee6c3b90
File: main.cpp, Line: 130, Function: testHeapCorruption

4. 检测与 new 操作符的兼容性

为了确保库兼容 C++ 的 new 操作符,我们还需要进行相关测试。在 C++ 中,new 操作符会直接调用 malloc 来分配内存,因此它应该能与我们实现的堆损坏检测功能正常配合。

int* ptr = new int[5];  // 分配一块内存
delete[] ptr;           // 删除内存

// 调用内存检查函数,检测是否有内存破坏
p_checkMemory();

如果内存损坏,p_checkMemory 会输出相应的堆损坏信息。

5. 总结

通过这一系列修改和测试,我们已经为内存泄漏检测库实现了堆内存损坏检测功能。具体步骤如下:

  1. 准备 Guard 区域的预期值:我们定义了一个缓冲区,并将其初始化为已知值 GodFill
  2. 比较内存块的 Guard 区域:通过 memcmp 函数比较内存块前后 Guard 区域的值,与预期值进行比对。
  3. 实现堆内存损坏检查:我们创建了 p_checkMemory 函数,遍历所有已分配内存块并检查 Guard 区域。
  4. 测试堆内存损坏:通过故意修改内存,验证库是否能检测到堆内存损坏。

接下来,我们将继续确保这个功能能在不同的平台和架构上正常工作。

004 对齐与结构填充

堆内存损坏检测:架构差异与内存对齐问题

在上一部分中,我们实现了堆内存损坏检测,并测试了不同类型的内存损坏。然而,在改变架构为 64 位后,程序表现出了异常的行为,无法检测到堆内存的某些损坏。这是由于编译器和处理器在处理内存时的对齐问题。我们将详细讨论这一问题,并查看为什么会发生这种情况。

1. 架构切换到 x64 平台

我们将架构切换到 x64 并重新构建程序。运行后,程序只能检测到内存损坏发生在用户分配内存后,而不能检测到发生在用户内存之前的损坏。

1.1 堆损坏输出

运行时,程序输出了以下信息:

Heap corruption detected after normal block

但并没有显示 Heap corruption detected before normal block,即使我们知道堆损坏发生在内存块之前。这表明检测的逻辑存在问题。

2. 问题分析:内存对齐

2.1 数据对齐的原理

处理器访问内存时通常会要求数据对齐。数据对齐指的是数据在内存中的位置应当满足某些条件,例如,数据应当位于能被 4 或 8 整除的内存地址上。对于 32 位处理器,数据通常需要对齐到 4 字节的边界;而对于 64 位处理器,数据则需要对齐到 8 字节的边界。

对于 32 位处理器,字长是 4 字节(即 CPU 一次性处理 4 个字节)。而对于 64 位处理器,字长是 8 字节。大部分处理器都会要求数据按字长对齐。如果数据没有对齐,处理器可能会采取额外的指令来访问这些数据,导致性能下降。

2.2 处理器与 misaligned 数据

在一些架构中,如果数据未能按字长对齐,CPU 会产生异常并终止程序。但在 x86(32 位)架构中,处理器通常能够宽容地处理 misaligned 数据(即使数据没有严格按 4 字节边界对齐,程序依然能运行)。尽管如此,处理 misaligned 数据会造成性能下降,因为处理器需要执行额外的指令来访问数据。

2.3 结构体成员与对齐

我们的问题并不是由于 misaligned 数据引起的,而是由于编译器在处理结构体时进行的填充(padding)。当结构体中某个成员的大小小于处理器的字长时,编译器会在这个成员和下一个成员之间插入填充字节,以确保下一个成员能够按字长对齐。

2.4 示例:内存块头结构体的大小分析

我们定义了一个 BlockHeader 结构体,其中包含用户请求的内存、Guard 区域等信息。为了更好地理解结构体的大小,我们检查了它在 32 位平台下的大小。

2.4.1 32 位平台下的结构体大小

首先,我们创建了 BlockHeader 结构体的实例,并查看它在 32 位平台下的大小:

BlockHeader block;
std::cout << "Size of BlockHeader: " << sizeof(block) << std::endl;

在 32 位平台上,我们期望看到结构体的大小为 28 字节。因为:

  • BlockHeader 中的成员包括:
    • 4 字节指针(用户内存指针)
    • 4 字节整数(大小)
    • 4 字节 size_t(用于存储一些大小)
    • 4 字节 m_god(Guard 区域,大小为 4 字节)

因此,总大小为 28 字节:4 + 4 + 4 + 4 + 4 + 8(用于填充字节)。

2.4.2 64 位平台下的结构体大小

当我们切换到 64 位平台时,结构体的大小不再是 28 字节。由于 64 位平台的字长为 8 字节,编译器在处理小于字长的成员时会插入填充字节,确保结构体在内存中的对齐。

在 64 位平台上,结构体大小可能会增加,主要是由于以下原因:

  • BlockHeader 中的指针大小变为 8 字节(因为 64 位平台下指针为 8 字节)。
  • 编译器会为每个小于 8 字节的数据成员插入填充字节,以保证下一个成员按 8 字节对齐。

因此,结构体的大小可能变为 40 字节(比在 32 位平台下大了 12 字节)。这种变化会影响我们在内存中查找 Guard 区域的逻辑,导致我们无法正确检测内存损坏。

3. 总结与下一步

3.1 关键问题
  • 由于处理器的字长和结构体成员的对齐方式不同,我们在 64 位平台下遇到的结构体对齐问题导致了内存损坏检测的失败。
  • 编译器会在成员之间插入填充字节,确保数据按字长对齐,从而使结构体的实际大小和我们预期的不同。
3.2 下一步

在下一部分中,我们将继续分析 64 位平台下的结构体对齐问题,解决内存检测的准确性,并确保库在不同架构下的兼容性。

005 对齐内存块头

64 位平台的内存对齐与库的堆损坏检测

在这部分,我们将继续分析在 64 位平台上发生的内存对齐问题,以及如何调整我们的库实现以便更准确地检测堆内存损坏。

1. 64 位平台结构体大小问题

我们在 64 位平台上重新编译了项目,并查看了 BlockHeader 结构体的大小。结果显示,结构体的大小为 56 字节,而我们之前计算的应该是 48 字节。让我们进一步分析为什么会出现这种情况。

1.1 结构体大小计算

结构体 BlockHeader 的成员包括:

  • 一个 8 字节的指针(因为 64 位平台下指针大小为 8 字节)。
  • 一个 8 字节的整数(代表块的大小)。
  • 一个 4 字节的 size_t 类型。
  • 一个 8 字节的 Guard 区域。

根据这些成员的大小,我们预计结构体的总大小应该是:

  • 8 字节(指针) + 8 字节(整数) + 4 字节(size_t) + 8 字节(Guard) = 28 字节。

但最终编译出的大小是 56 字节,这意味着编译器在结构体中插入了额外的填充字节(padding)。这是因为结构体中的某些成员的大小小于处理器的字长(8 字节),导致编译器为后续成员插入了填充字节以确保它们正确对齐。

1.2 编译器的填充

例如,在结构体中的 line_number 成员是 4 字节,而 size_t 是 8 字节。因此,编译器为了确保 size_t 成员按 8 字节对齐,会在 line_numbersize_t 之间插入 4 字节的填充字节。这样做可以保证后续的成员在内存中是对齐的。

2. 调试与验证:内存分配和填充

我们使用 memset 初始化了结构体的内存,并逐步给结构体成员赋值,来验证内存分配和填充是否符合预期。

2.1 检查填充

在调试过程中,我们发现当我们给 line_number 赋值时,后面有 4 字节的内存没有被修改,这就是编译器为保证 size_t 成员对齐而插入的填充字节。类似地,在初始化 Guard 区域时,编译器还会在 Guard 后插入填充字节。

这些额外的字节是为了确保结构体成员对齐,但是它们对我们的堆损坏检测逻辑造成了影响,尤其是在用户内存之前的 "无人的土地" 区域没有 Guard 区域,而是被填充字节占据。

2.2 内存对齐的调整

为了避免这种填充字节影响我们的检测,我们建议将结构体中小于处理器字长的成员移到结构体的末尾。这样,编译器在布局结构体时就不会在中间插入填充字节。

3. 结构体调整后的效果

我们将 line_number 移到了结构体的末尾,并重新编译和调试代码。这时,结构体的大小恢复为 48 字节,这是我们预期的大小。通过这种调整,我们避免了不必要的填充字节。

3.1 堆损坏检测

现在,我们可以准确地检测到堆内存的损坏,包括发生在用户内存之前的损坏。程序输出正确的堆损坏信息,如下所示:

Heap corruption detected before normal block
Heap corruption detected after normal block

4. 结论与库的最终实现

通过对结构体布局和内存对齐问题的深入分析和调整,我们成功地使库在不同架构下都能可靠地检测堆内存损坏。我们已解决了 64 位平台下由于结构体填充导致的问题,使得我们的堆损坏检测机制更加健壮。

最后,课程已经结束。如果您有任何问题,欢迎在论坛上提问,我会很乐意帮助大家。感谢您的参与,下次再见!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
C C 语言 C++
Projects
None yet
Development

No branches or pull requests

1 participant