一个以实验为主的 C++ 内存与资源管理学习仓库。内容从内存地址、原始指针和动态数组开始,逐步扩展到智能指针、内存资源、分配器、placement new、对象布局、继承、虚函数表,最后通过 crypt_file 综合演示内存池、智能指针、多线程流水线和 OpenSSL DES 加解密。
项目中的代码强调“观察过程”:很多类会输出构造、复制、赋值和析构日志。调试时建议同时观察变量地址、调用堆栈、对象生命周期以及 Visual Studio 的内存窗口。
- 代码区、全局区、静态区、栈和堆中的地址有什么区别。
- 指针变量、指针保存的地址、指针指向的对象分别是什么。
new/delete与new[]/delete[]为什么必须配对。- 连续二维数组和
int**指针数组的内存结构差异。 const int*、int* const、const int* const的含义。unique_ptr、shared_ptr、weak_ptr的所有权模型。- 内存分配与对象构造为什么是两个不同操作。
allocator、memory_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 |
memcpy、copy、uninitialized_copy、construct_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 和优化共同决定。示例运行结果只用于观察,不能假设所有机器都采用相同地址或固定增长方向。
int* p1 = new int;
*p1 = 101;
delete p1;delete p1 完成两件事:
- 如果类型有析构函数,调用析构函数。
- 释放
p1指向的内存。
它不会修改变量 p1 中保存的数值,所以删除后打印 p1 仍可能看到旧地址。此时它是悬空指针,不能再次解引用。推荐立即置空:
delete p1;
p1 = nullptr;对空指针执行 delete 是安全的;对同一个非空悬空指针重复删除则是未定义行为。
在数组所在作用域内:
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>> 表示可变行长数据。
void* 可以保存对象地址,但没有元素类型信息,不能直接解引用。恢复类型时需要显式转换:
void* raw = #
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> |
不拥有 | 是 | 是 | 缓存、观察者、打破循环引用 |
优先使用:
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[]。
shared_ptr 通常包含两部分概念:
shared_ptr 对象
├── 当前保存的指针
└── 指向控制块
控制块
├── 强引用计数
├── 弱引用计数
├── 删除器
└── 被管理对象信息
复制 shared_ptr 会增加强引用计数。最后一个强引用销毁或 reset() 时,被管理对象才释放。
别名构造:
std::shared_ptr<int> member(owner, &owner->index1);member 保存的是成员地址,但与 owner 共享控制块。即使原 owner 离开作用域,只要别名指针仍存在,整个 XData 对象就不会销毁。
控制块的引用计数操作是线程安全的,但被管理对象的数据不是自动线程安全的。多个线程修改 XData 仍需要同步。
两个对象互相保存 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. 把原始存储归还给分配器
std::pmr::synchronized_pool_resource 是线程安全的池化内存资源。示例配置 pool_options,循环申请多个 1MB 块,再申请一个更大的块。
常用操作:
allocate(bytes, alignment):获取原始字节存储。deallocate(ptr, bytes, alignment):归还存储。release():释放该资源持有的全部内存。
调用 deallocate() 时使用的大小和对齐参数应与分配时一致。synchronized_pool_resource 内部带同步;单线程场景可了解 unsynchronized_pool_resource。
普通表达式:
TestMem* p = new TestMem;大致执行:
- 调用匹配的
operator new(sizeof(TestMem))获取内存。 - 在内存上调用
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。
allocator.allocate(n) 只获得足够容纳 n 个对象的未初始化存储,不调用构造函数。allocator_traits 提供统一操作:
allocator_traits<A>::construct(alloc, address, args...);
allocator_traits<A>::destroy(alloc, address);最后由 deallocate() 释放存储。自定义分配器让容器与具体存储来源解耦,可以接入共享内存、内存池、统计器或调试分配器。
memcpy只复制字节,不执行复制构造。std::copy通过赋值写入已经存在的目标对象。std::uninitialized_copy在未初始化存储中复制构造新对象。std::construct_at在指定地址构造对象。std::destroy或std::destroy_at调用析构函数。
对含有指针、字符串或其他资源成员的非平凡对象直接 memcpy,可能造成多个对象错误共享同一内部资源,最终重复释放。
以下函数形参中的 data[] 实际仍是指针:
void Func(const char data[]);如果函数需要知道长度,应显式传入 size,使用 span,或使用保留数组大小的引用模板。
void Wrong(char* out) {
out = new char[1024];
}这里只改变函数局部的指针副本,调用者看不到新地址,并造成泄漏。可使用 char**、char*&,更推荐直接返回 vector<char>、string 或智能指针。
不能返回局部数组地址,因为函数返回时栈对象已经销毁。
string、vector 负责管理缓冲区并记录长度,适合作为函数参数和返回值。需要注意:
resize()、push_back()等操作可能触发重新分配。- 重新分配后,旧的
data()指针、迭代器和引用可能失效。 - 返回局部容器通常可以利用返回值优化或移动语义,不需要手工返回裸指针。
115class_mem 打印单继承、多继承和虚继承对象中各成员的地址。派生类指针转换成不同基类指针时,编译器可能调整地址,使其指向对应的基类子对象。
菱形继承中:
- 普通继承会包含两份共同基类
C。 - 虚继承让最派生对象只包含一份共享的
C子对象。 - 虚继承通常需要额外的实现信息,布局由编译器 ABI 决定。
TestMem 把构造函数和析构函数设为受保护,并通过 Create/Drop 控制创建和销毁入口。这是一种教学型工厂模式;现代代码通常优先返回智能指针。
116class_virtual_mem 将对象起始地址解释为虚表指针,再读取虚表项并手工调用。它用于理解 MSVC x64 下常见实现:
多态对象
├── vfptr ------> 虚函数表
└── 普通成员 ├── Test1 地址
└── Test2 地址
C++ 标准保证虚调用的行为,但不保证对象第一个字段一定是 vfptr,也不保证虚表项顺序和函数指针转换方式。手工读取虚表依赖具体编译器 ABI,不能用于通用业务代码。
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::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,只要数据块还存在,内存池就不会提前析构。
当前读取流程:
- 创建
XData。 - 从内存池申请 1024 字节。
- 调用
ifs_.read()。 - 使用
gcount()得到实际读取字节数。 - 如果到达 EOF,设置
end=true。 - 把数据推入下游
XCryptTask队列。
gcount() 是本次实际读取的字节数,最后一块可能小于 1024。不能把申请容量当成有效数据长度。
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 的倍数,也需要完整填充块,否则解密端无法区分“最后一个原始字节恰好等于填充值”和“真正的填充”。
对解密来说,输出不会比输入更大,但沿用较大的缓冲区可以简化流水线内存管理。
在 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,并检查所有填充字节是否一致。当前代码只做了部分检查。
写线程不断调用 PopFront():
- 队列为空时休眠 10ms 后重试。
- 取到数据后按
data->size()写入。 - 看到
end标记后结束循环并关闭文件。
这仍是轮询实现。更完整的版本可以使用条件变量,在入队和停止时唤醒写线程。
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 查找。
批量目录处理完成后,当前 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 释放指向的对象,不会修改指针变量。需要显式赋值 nullptr。
复制会产生两个独占所有者,无法判断谁负责释放。它允许移动,用于明确转移唯一所有权。
不建议依赖它做并发业务决策。读取后计数可能立即被其他线程改变,而且计数为 1 也不等于对象数据没有被并发访问。
对象数量和分配器元数据可能不同,数组还需要逐个析构元素。释放形式不匹配属于未定义行为。
它只复制对象表示的字节,不执行资源所有权语义。对 string、vector、智能指针等非平凡类型可能造成重复释放或内部状态损坏。
110operator_new 主动保留了错误释放示例。把 placement new 对象交给 delete 会错误释放并非由该对象普通 operator new 获得的存储。
memory_address101pointer103ptr_array104ptr_void_const113function_arg_ptr
106unique_ptr107shared_ptr108weak_ptr114_string_vector_mem
109memory_resource110operator_new111allocator112uninitialized_copy
115class_mem116class_virtual_memcrypt_file
- 在
101pointer中故意读取删除后的指针,并用 AddressSanitizer 观察诊断。 - 比较连续二维数组与逐行分配数组的地址和缓存局部性。
- 给
unique_ptr自定义删除器增加统计计数。 - 在
shared_ptr示例中打印成员别名指针和原对象地址。 - 把
weak_ptr::lock()放到对象销毁前后分别测试。 - 修正 placement new 错误释放流程,再比较析构和 delete 输出。
- 将
crypt_file队列轮询改为条件变量。 - 为
crypt_file增加显式结束数据块,并测试空文件、1024 字节文件和 2048 字节文件。
仓库中部分代码有意保留错误示范、ABI 相关操作和简化资源管理,用于观察后果。运行前应阅读对应注释。真实项目应优先采用标准容器、智能指针和 RAII,并配合编译器警告、静态分析、AddressSanitizer、单元测试和异常安全设计。