Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

memory_study

一个以实验为主的 C++ 内存与资源管理学习仓库。内容从内存地址、原始指针和动态数组开始,逐步扩展到智能指针、内存资源、分配器、placement new、对象布局、继承、虚函数表,最后通过 crypt_file 综合演示内存池、智能指针、多线程流水线和 OpenSSL DES 加解密。

项目中的代码强调“观察过程”:很多类会输出构造、复制、赋值和析构日志。调试时建议同时观察变量地址、调用堆栈、对象生命周期以及 Visual Studio 的内存窗口。

你可以学到什么

  • 代码区、全局区、静态区、栈和堆中的地址有什么区别。
  • 指针变量、指针保存的地址、指针指向的对象分别是什么。
  • new/deletenew[]/delete[] 为什么必须配对。
  • 连续二维数组和 int** 指针数组的内存结构差异。
  • const int*int* constconst int* const 的含义。
  • unique_ptrshared_ptrweak_ptr 的所有权模型。
  • 内存分配与对象构造为什么是两个不同操作。
  • allocatormemory_resource 和内存池解决什么问题。
  • 单继承、多继承、虚继承和虚函数表如何影响对象布局。
  • 如何把大文件处理拆成“读取 -> 计算 -> 写入”的并行流水线。

仓库结构

memory_study/
├── bin/       crypt_file 的运行目录、OpenSSL DLL 和测试数据
├── include/   OpenSSL 头文件
├── lib/       OpenSSL 导入库及相关模块
├── src/
│   ├── memory_address/
│   ├── 101pointer/
│   ├── 103ptr_array/
│   ├── 104ptr_void_const/
│   ├── 106unique_ptr/
│   ├── 107shared_ptr/
│   ├── 108weak_ptr/
│   ├── 109memory_resource/
│   ├── 110operator_new/
│   ├── 111allocator/
│   ├── 112uninitialized_copy/
│   ├── 113function_arg_ptr/
│   ├── 114_string_vector_mem/
│   ├── 115class_mem/
│   ├── 116class_virtual_mem/
│   └── crypt_file/
└── README.md

构建环境

当前工程主要面向:

  • Windows 10/11
  • Visual Studio
  • MSVC 平台工具集 v145
  • C++20
  • x64 平台

每个示例都有独立的 .slnx.vcxproj

src/101pointer/101pointer.slnx
src/107shared_ptr/107shared_ptr.slnx
src/109memory_resource/109memory_resource.slnx
src/116class_virtual_mem/116class_virtual_mem.slnx
src/crypt_file/crypt_file.slnx

在 Visual Studio 中打开对应 .slnx,选择 Debug|x64 后构建。没有 v145 时,可以在项目属性中重新定向到本机已安装的平台工具集。

Visual Studio Developer PowerShell 构建示例:

msbuild src\101pointer\101pointer.vcxproj /p:Configuration=Debug /p:Platform=x64

示例总览

示例 源码实际演示 重点观察
memory_address 打印函数、全局变量、静态变量、堆对象、指针变量和局部变量地址 地址分区和同类对象的相邻关系
101pointer 取地址、解引用、动态分配、删除后置空 delete 不会自动把指针改成 nullptr
103ptr_array 栈数组、动态数组、连续二维数组、int** 二维数组 指针步长、数组大小、两层释放
104ptr_void_const void*、类型转换、常量指针 不同 cast 的意图和 const 约束
106unique_ptr 独占所有权、数组、移动、reset/release、自定义删除器 所有权转移后源指针为空
107shared_ptr 引用计数、复制、重置、自定义删除器、别名构造 控制块与实际保存的指针可以不同
108weak_ptr 循环引用、lock()、非拥有观察者 weak_ptr 不增加强引用计数
109memory_resource synchronized_pool_resource、分配、释放和 release() 池化分配与大块分配
110operator_new 全局和类级 new/delete、placement new 分配、构造、析构、释放四个步骤
111allocator allocator_traits、自定义分配器、容器分配 分配内存不等于构造对象
112uninitialized_copy memcpycopyuninitialized_copyconstruct_at 已初始化目标与未初始化目标的差异
113function_arg_ptr 数组退化、指针输出参数、智能指针参数 为什么输出内存需要二级指针或引用
114_string_vector_mem string/vector 的缓冲区、扩容、复制和返回值 data() 地址可能在扩容后变化
115class_mem 工厂创建、单继承、多继承、菱形继承、虚继承 基类子对象在派生对象中的地址偏移
116class_virtual_mem 虚函数覆盖、虚表地址、手工调用虚表项 虚表布局是编译器 ABI,不是标准接口
crypt_file OpenSSL DES、内存池、智能指针、三线程文件流水线 数据块所有权、结束标记、填充和线程等待

内存区域与地址

memory_address 用实际地址帮助理解常见存储区域:

对象 常见区域 生命周期
函数机器码 代码区 .text 程序运行期间
未初始化或零初始化全局变量 .bss 程序运行期间
非零初始化全局/静态变量 .data 程序运行期间
new 分配的对象 从分配到显式释放
局部变量和指针变量本身 所在作用域或函数调用期间
int* p = new int;

这里至少有两个不同对象:

  • p 是一个指针变量,通常位于栈上,&p 是指针变量自己的地址。
  • new int 创建的 int 位于堆上,p 保存的是这个堆地址。

地址大小与指向类型的大小无关。在 64 位程序中,sizeof(p) 通常是 8,而 sizeof(*p)int 的大小,通常是 4。

地址布局由操作系统、编译器、链接器、ASLR 和优化共同决定。示例运行结果只用于观察,不能假设所有机器都采用相同地址或固定增长方向。

101:原始指针生命周期

int* p1 = new int;
*p1 = 101;
delete p1;

delete p1 完成两件事:

  1. 如果类型有析构函数,调用析构函数。
  2. 释放 p1 指向的内存。

它不会修改变量 p1 中保存的数值,所以删除后打印 p1 仍可能看到旧地址。此时它是悬空指针,不能再次解引用。推荐立即置空:

delete p1;
p1 = nullptr;

对空指针执行 delete 是安全的;对同一个非空悬空指针重复删除则是未定义行为。

103:数组与二维动态内存

数组不是普通指针

在数组所在作用域内:

int arr[10];
sizeof(arr);       // 整个数组的字节数
sizeof(arr[0]);    // 单个元素的字节数

数组传入函数后通常退化成指针,函数中的 sizeof(data) 只会得到指针大小。需要保留数组长度时,可额外传 size,或用数组引用模板。

连续二维数组

int (*arr)[3] = new int[rows][3];

解析方法:

  • 元素类型是 int[3]
  • 数量是 rows
  • 返回类型是指向一个三元素数组的指针,即 int (*)[3]
  • 整块内存连续,只需要一次 delete[] arr

列数 3 是类型的一部分,所以这种写法要求编译期已知列数。

指针数组

int** arr = new int*[rows]{};
for (int i = 0; i < rows; ++i) {
    arr[i] = new int[cols]{};
}

外层只是一组 int*,每一行再单独分配,行之间不保证连续。释放必须反向分两层进行:

for (int i = 0; i < rows; ++i) {
    delete[] arr[i];
}
delete[] arr;

现代 C++ 通常优先使用一维 vector<T> 模拟二维连续空间,或者使用 vector<vector<T>> 表示可变行长数据。

104:void* 与 const

void* 可以保存对象地址,但没有元素类型信息,不能直接解引用。恢复类型时需要显式转换:

void* raw = &num;
int* value = static_cast<int*>(raw);

常见 const 组合:

声明 指针能否改变 指向值能否通过该指针改变
const int* p 不能
int const* p 不能
int* const p 不能
const int* const p 不能 不能

const_cast 可以改变类型层面的 const 属性,但如果原对象本身确实是 const,随后写入仍是未定义行为。reinterpret_cast 只是在低层重新解释地址,不会自动解决对齐、对象生命周期或严格别名问题。

智能指针所有权模型

类型 所有权 可复制 可移动 典型用途
unique_ptr<T> 独占 单一负责人、工厂返回值、Pimpl
shared_ptr<T> 共享 生命周期由多个对象共同决定
weak_ptr<T> 不拥有 缓存、观察者、打破循环引用

106:unique_ptr

优先使用:

auto p = std::make_unique<Data>();
auto arr = std::make_unique<int[]>(1024);

关键操作:

  • get():查看裸指针,不转移所有权。
  • reset(newPtr):销毁旧对象并接管新对象。
  • release():放弃所有权并返回裸指针,不销毁对象;调用者必须负责释放。
  • std::move():把所有权转移到另一个 unique_ptr
  • 自定义删除器:用于文件句柄、Socket、第三方库对象或复合资源。

示例中的 PacketDelete 负责释放 XPacket 内部缓冲区和对象本身。需要注意,数组缓冲区应与其分配方式匹配:如果使用 new[] 分配,就必须使用 delete[]

107:shared_ptr

shared_ptr 通常包含两部分概念:

shared_ptr 对象
  ├── 当前保存的指针
  └── 指向控制块

控制块
  ├── 强引用计数
  ├── 弱引用计数
  ├── 删除器
  └── 被管理对象信息

复制 shared_ptr 会增加强引用计数。最后一个强引用销毁或 reset() 时,被管理对象才释放。

别名构造:

std::shared_ptr<int> member(owner, &owner->index1);

member 保存的是成员地址,但与 owner 共享控制块。即使原 owner 离开作用域,只要别名指针仍存在,整个 XData 对象就不会销毁。

控制块的引用计数操作是线程安全的,但被管理对象的数据不是自动线程安全的。多个线程修改 XData 仍需要同步。

108:weak_ptr

两个对象互相保存 shared_ptr 会形成引用环:

A --shared_ptr--> B
^                 |
|----shared_ptr---|

外部引用消失后,A 和 B 的强引用计数仍不为零,因此都无法析构。把其中一个方向改成 weak_ptr 后,弱引用不参与所有权计数。

访问前使用:

if (auto b = weak.lock()) {
    // b 是临时 shared_ptr,对象在此作用域内有效
}

如果对象已经销毁,lock() 返回空 shared_ptr

分配内存与构造对象

C++ 对象创建可以拆成两个动作:

1. 获得一块满足大小和对齐要求的原始存储
2. 在这块存储上执行构造函数,建立对象生命周期

销毁也对应两个动作:

1. 调用析构函数,结束对象生命周期
2. 把原始存储归还给分配器

109:memory_resource

std::pmr::synchronized_pool_resource 是线程安全的池化内存资源。示例配置 pool_options,循环申请多个 1MB 块,再申请一个更大的块。

常用操作:

  • allocate(bytes, alignment):获取原始字节存储。
  • deallocate(ptr, bytes, alignment):归还存储。
  • release():释放该资源持有的全部内存。

调用 deallocate() 时使用的大小和对齐参数应与分配时一致。synchronized_pool_resource 内部带同步;单线程场景可了解 unsynchronized_pool_resource

110:重载 new/delete 与 placement new

普通表达式:

TestMem* p = new TestMem;

大致执行:

  1. 调用匹配的 operator new(sizeof(TestMem)) 获取内存。
  2. 在内存上调用 TestMem 构造函数。

delete p 则先析构,再调用匹配的 operator delete

placement new 不申请内存,只在现有存储上构造:

void* storage = ...;
TestMem* p = new (storage) TestMem;
p->~TestMem();

示例中保留了一段明确标注的错误代码:对象通过 placement new 构造在 new int[] 返回的缓冲区上,却执行 delete mem3。这会尝试用错误方式释放底层存储,属于未定义行为,可能在 Debug 运行时触发堆错误。正确顺序是手动析构,然后按原始分配类型执行 delete[] buf2

111:allocator

allocator.allocate(n) 只获得足够容纳 n 个对象的未初始化存储,不调用构造函数。allocator_traits 提供统一操作:

allocator_traits<A>::construct(alloc, address, args...);
allocator_traits<A>::destroy(alloc, address);

最后由 deallocate() 释放存储。自定义分配器让容器与具体存储来源解耦,可以接入共享内存、内存池、统计器或调试分配器。

112:未初始化内存算法

  • memcpy 只复制字节,不执行复制构造。
  • std::copy 通过赋值写入已经存在的目标对象。
  • std::uninitialized_copy 在未初始化存储中复制构造新对象。
  • std::construct_at 在指定地址构造对象。
  • std::destroystd::destroy_at 调用析构函数。

对含有指针、字符串或其他资源成员的非平凡对象直接 memcpy,可能造成多个对象错误共享同一内部资源,最终重复释放。

113 和 114:函数边界上的内存

数组参数退化

以下函数形参中的 data[] 实际仍是指针:

void Func(const char data[]);

如果函数需要知道长度,应显式传入 size,使用 span,或使用保留数组大小的引用模板。

输出指针

void Wrong(char* out) {
    out = new char[1024];
}

这里只改变函数局部的指针副本,调用者看不到新地址,并造成泄漏。可使用 char**char*&,更推荐直接返回 vector<char>string 或智能指针。

不能返回局部数组地址,因为函数返回时栈对象已经销毁。

stringvector

stringvector 负责管理缓冲区并记录长度,适合作为函数参数和返回值。需要注意:

  • resize()push_back() 等操作可能触发重新分配。
  • 重新分配后,旧的 data() 指针、迭代器和引用可能失效。
  • 返回局部容器通常可以利用返回值优化或移动语义,不需要手工返回裸指针。

115 和 116:对象布局

继承布局

115class_mem 打印单继承、多继承和虚继承对象中各成员的地址。派生类指针转换成不同基类指针时,编译器可能调整地址,使其指向对应的基类子对象。

菱形继承中:

  • 普通继承会包含两份共同基类 C
  • 虚继承让最派生对象只包含一份共享的 C 子对象。
  • 虚继承通常需要额外的实现信息,布局由编译器 ABI 决定。

TestMem 把构造函数和析构函数设为受保护,并通过 Create/Drop 控制创建和销毁入口。这是一种教学型工厂模式;现代代码通常优先返回智能指针。

虚函数表

116class_virtual_mem 将对象起始地址解释为虚表指针,再读取虚表项并手工调用。它用于理解 MSVC x64 下常见实现:

多态对象
  ├── vfptr ------> 虚函数表
  └── 普通成员       ├── Test1 地址
                    └── Test2 地址

C++ 标准保证虚调用的行为,但不保证对象第一个字段一定是 vfptr,也不保证虚表项顺序和函数指针转换方式。手工读取虚表依赖具体编译器 ABI,不能用于通用业务代码。

crypt_file 综合项目

crypt_file 把一个文件拆成三个线程阶段:

输入文件
   |
   v
XReadTask:每次读取 1024 字节
   |
   | shared_ptr<XData>
   v
XCryptTask:DES 加密或解密
   |
   | shared_ptr<XData>
   v
XWriteTask:写入输出文件

每个阶段继承 XIOStream,拥有自己的线程和线程安全队列。XFileCrypt 负责创建内存池、连接三个阶段、启动线程并等待完成。

核心类

责任
XData 保存数据地址、实际字节数、分配字节数和结束标记
XIOStream 提供线程启动、等待、队列和责任链下一节点
XReadTask 打开输入文件并生成 XData 数据块
XCrypt 初始化 DES 密钥、计算填充、执行块加解密
XCryptTask 从队列取数据,调用 XCrypt,把结果推给写线程
XWriteTask 从队列取结果并写入输出文件
XFileCrypt 组装和管理完整流水线

XData 的方法和字段

XData::Make(pool) 创建一个由 shared_ptr 管理的对象,并保存共享内存池。构造函数是私有的,因此外部必须通过 Make() 创建。

New(mem_size)pmr::memory_resource 分配原始存储:

data_      实际内存地址
mem_size_  申请到的容量,用于 deallocate
size_      当前有效数据字节数
end_       是否为整个流的最后一块

容量和有效长度必须分开。例如加密阶段可能申请 1032 字节,但实际只写出 1024 或 1032 字节。析构函数使用 mem_size_ 归还内存,而写文件时使用 size_

因为 XData 持有内存池的 shared_ptr,只要数据块还存在,内存池就不会提前析构。

XReadTask::Main()

当前读取流程:

  1. 创建 XData
  2. 从内存池申请 1024 字节。
  3. 调用 ifs_.read()
  4. 使用 gcount() 得到实际读取字节数。
  5. 如果到达 EOF,设置 end=true
  6. 把数据推入下游 XCryptTask 队列。

gcount() 是本次实际读取的字节数,最后一块可能小于 1024。不能把申请容量当成有效数据长度。

XCryptTask::Main()

auto out = XData::Make(mem_pool_);
int outsize = data->size() + enc_->GetPadding(data->size());
out->New(outsize);

加密输出最多需要“输入长度 + 一个完整 DES 块”的空间。DES 块大小是 8 字节。当前填充方式类似 PKCS#7:

输入尾块长度 填充值 额外字节
1 0x07 7
5 0x03 3
7 0x01 1
8 的整数倍 0x08 再增加完整 8 字节块

即使数据刚好是 8 的倍数,也需要完整填充块,否则解密端无法区分“最后一个原始字节恰好等于填充值”和“真正的填充”。

对解密来说,输出不会比输入更大,但沿用较大的缓冲区可以简化流水线内存管理。

data_sizewrite_size

XCrypt::Decrypt() 中:

  • data_size:当前这一轮解密出的块中,有多少字节属于真实数据。
  • write_size:从函数开始到当前为止,已经写入输出缓冲区的总字节数。

普通块的 data_size 是 8。最后一块解密后,out[7] 保存填充长度,因此:

data_size = block_size - out[7];

随后:

memcpy(out_data + write_size, &out, data_size);
write_size += data_size;

out_data + write_size 找到本轮写入位置,写完后累计总长度。函数最终返回 write_size

生产实现还应验证最后一个字节是否位于 1 到 8,并检查所有填充字节是否一致。当前代码只做了部分检查。

XWriteTask::Main()

写线程不断调用 PopFront()

  1. 队列为空时休眠 10ms 后重试。
  2. 取到数据后按 data->size() 写入。
  3. 看到 end 标记后结束循环并关闭文件。

这仍是轮询实现。更完整的版本可以使用条件变量,在入队和停止时唤醒写线程。

crypt_file 构建与运行

Debug|x64 配置把程序输出到仓库的 bin/。工程通过:

  • include/ 引用 OpenSSL 头文件;
  • lib/ 链接 libcrypto.lib
  • bin/ 加载 OpenSSL 运行时 DLL。

Visual Studio 调试工作目录建议设置为:

$(OutDir)

设置位置:

右键 crypt_file 项目
  -> 属性
  -> 配置属性
  -> 调试
  -> 工作目录
  -> $(OutDir)

注意选择正确的配置和平台,例如 Debug|x64

命令行批量处理:

cd E:\winshare\memory_study\bin

# 加密输入目录中的普通文件
.\crypt_file.exe .\img .\img_enc 12345678

# 解密
.\crypt_file.exe .\img_enc .\img_dec 12345678 -d

参数:

crypt_file 输入目录 输出目录 密码
crypt_file 输入目录 输出目录 密码 -d

当前代码实际上只判断是否存在第 5 个参数,未验证它是否等于 -d

相对路径为什么找不到文件

rt->Init("./img/test.png");

相对路径相对于进程的当前工作目录,不是相对于 .cpp 文件,也不一定相对于 .exe 所在目录。如果 Visual Studio 使用项目目录作为工作目录,程序会查找:

E:\winshare\memory_study\src\crypt_file\img\test.png

工作目录设为 $(OutDir) 后,才会按当前配置从 bin\img\test.png 查找。

当前 main() 的额外演示逻辑

批量目录处理完成后,当前 crypt_file.cpp 没有立即返回,还会继续运行多组硬编码演示,包括:

test.txt
./img/test.png
test_out.png
test_dec.png

因此,即使命令行传入的目录处理成功,后续硬编码路径不存在时仍可能输出打开失败。这个程序目前更像“综合实验入口”,还不是只执行一次命令后干净退出的正式 CLI。

当前流水线的结束边界

XReadTask 只有在一次有效读取后发现 eof() 才设置 end=true。这会带来两个重要边界:

  • 空文件不会产生任何结束数据块。
  • 文件大小恰好是 1024 的整数倍时,最后一个完整块读取后可能尚未设置 EOF;下一次读取为 0 后直接退出,也没有向下游发送结束标记。

下游加密和写入线程依赖 end 才退出,因此这些情况可能导致 Wait() 一直等待。用于真实文件前,需要明确发送结束消息,或者根据已读取字节总数与文件大小判断最后一块。

安全说明

crypt_file 使用 OpenSSL 1.1.1 中的 DES-ECB 接口。它只适合学习块加密和填充:

  • DES 密钥空间过小,已经不适合现代安全需求。
  • ECB 模式会泄露相同明文块的重复结构。
  • 密码直接截断或补零到 8 字节,没有可靠的密钥派生。
  • 没有随机 IV、盐值、完整性校验或身份认证。

不要用它保护真实敏感数据。实际应用应使用经过审查的高层加密方案,例如带认证的 AES-GCM 或 ChaCha20-Poly1305,并使用标准密码派生流程。

常见问题

delete 后为什么指针仍不为空

delete 释放指向的对象,不会修改指针变量。需要显式赋值 nullptr

为什么 unique_ptr 不能复制

复制会产生两个独占所有者,无法判断谁负责释放。它允许移动,用于明确转移唯一所有权。

shared_ptruse_count() 能否用于业务判断

不建议依赖它做并发业务决策。读取后计数可能立即被其他线程改变,而且计数为 1 也不等于对象数据没有被并发访问。

为什么不能混用 deletedelete[]

对象数量和分配器元数据可能不同,数组还需要逐个析构元素。释放形式不匹配属于未定义行为。

为什么 memcpy 不能复制所有对象

它只复制对象表示的字节,不执行资源所有权语义。对 stringvector、智能指针等非平凡类型可能造成重复释放或内部状态损坏。

为什么 Debug 运行 placement new 示例可能报错

110operator_new 主动保留了错误释放示例。把 placement new 对象交给 delete 会错误释放并非由该对象普通 operator new 获得的存储。

推荐学习路线

第一阶段:地址、指针和数组

  1. memory_address
  2. 101pointer
  3. 103ptr_array
  4. 104ptr_void_const
  5. 113function_arg_ptr

第二阶段:自动资源管理

  1. 106unique_ptr
  2. 107shared_ptr
  3. 108weak_ptr
  4. 114_string_vector_mem

第三阶段:分配器与对象生命周期

  1. 109memory_resource
  2. 110operator_new
  3. 111allocator
  4. 112uninitialized_copy

第四阶段:对象布局和综合实践

  1. 115class_mem
  2. 116class_virtual_mem
  3. crypt_file

建议实验

  • 101pointer 中故意读取删除后的指针,并用 AddressSanitizer 观察诊断。
  • 比较连续二维数组与逐行分配数组的地址和缓存局部性。
  • unique_ptr 自定义删除器增加统计计数。
  • shared_ptr 示例中打印成员别名指针和原对象地址。
  • weak_ptr::lock() 放到对象销毁前后分别测试。
  • 修正 placement new 错误释放流程,再比较析构和 delete 输出。
  • crypt_file 队列轮询改为条件变量。
  • crypt_file 增加显式结束数据块,并测试空文件、1024 字节文件和 2048 字节文件。

代码边界

仓库中部分代码有意保留错误示范、ABI 相关操作和简化资源管理,用于观察后果。运行前应阅读对应注释。真实项目应优先采用标准容器、智能指针和 RAII,并配合编译器警告、静态分析、AddressSanitizer、单元测试和异常安全设计。

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages