CPP(十二)——常见容器内存布局
这篇也很硬,stl容器+lambda+迭代器。废话不多说,虽然很疲惫。
直接开始吧,顺便找个时间休息一下🤡。
先记住这张表:
| 容器 | 底层结构 | 逆向重点 |
|---|---|---|
std::vector | 连续动态数组 | 三个指针、扩容、下标访问 |
std::string | 字符串存储 | SSO、长度、容量 |
std::array | 固定大小数组 | 内嵌连续内存 |
std::list | 双向链表 | 节点、前后指针 |
std::map | 通常是红黑树 | 树节点、左右子树 |
std::unordered_map | 哈希表 | bucket、哈希、链表或节点 |
std::set | 通常是红黑树 | 有序集合、节点结构 |
std::unordered_set | 哈希表 | 哈希桶和节点 |
注意:这些是常见标准库实现的典型结构,不是 C++ 标准强制规定的内存布局。
一、vector
基本的api在之前介绍过,这里不再过多赘述。
很多 C++ 标准库实现中,vector 的核心状态可以抽象为三个指针:
vector 对象┌─────────────────────────────┐│ start │ ───────┐│ finish │ ────┐ ││ end_of_storage │ ─┐ │ │└─────────────────────────────┘ │ │ │ │ │ │ ▼ ▼ ▼堆内存:┌────────┬────────┬────────┬────────┬────────┐│ 10 │ 20 │ 30 │ 未使用 │ 未使用 │└────────┴────────┴────────┴────────┴────────┘ ▲ ▲ ▲ start finish end_of_storage三个指针分别表示:
| 指针 | 含义 |
|---|---|
start | 数组起始地址 |
finish | 已构造元素的末尾后一位置 |
end_of_storage | 已分配存储空间的末尾后一位置 |
所以不能推出容量大小与已经存储的容量公式:
这里的T是模板,还记得吗?我们之前讲过,另外为啥除以sizeof(T),这个就不用说了吧,c里面算申请的容积时经常用到。
假设:
start = 0x5000finish = 0x500Cend_of_storage = 0x5010如果元素是 int,每个 4 字节:
size = (0x500C - 0x5000) / 4 = 3capacity = (0x5010 - 0x5000) / 4 = 4这意味着 vector 有 3 个元素,但分配了 4 个元素的空间。
push_back时的扩容
push_back(value) │ ▼判断 size < capacity? │ ┌────┴────┐ │ │ 是 否 │ │ ▼ ▼直接写入 分配更大的内存 │ ▼ 搬迁旧元素 │ ▼ 写入新元素 │ ▼ 释放旧内存 │ ▼ 更新三个指针ida实战
先看源码,这里操作覆盖了:访问目标索引+查看容量+扩容+初始化。我们看看ida里是个什么样子。
#include<iostream>#include<vector>
void veclab(std::vector<int>& v){ int m = v.size(); int n = v.capacity(); int a = v[0]; int b = v[2]; a=b; v.push_back(22); v.push_back(33); v.push_back(67);}
int main(){ std::vector<int> v; v.reserve(5); for(int i=0;i<5;i++){ v.push_back(i); } std::cout<<v.size(); std::cout<<v.capacity();
for(auto& x:v){ x*=2; }}先来看看初始化
v3 = (int *)operator new(a1: 20u); v17.__end_cap_.__value_ = v3 + 5; // 申请了20字节的内存,int一个占4字节,所以这里能存5个,end_cap指针指向5*4,注意这里是对指针运算 v17.__begin_ = v3; v17.__end_ = v3;接着是核心的循环插入,这里还叠加了要不要扩容的判断
for ( i = 0; i != 5; ++i ) { end = v17.__end_; if ( v17.__end_ == v17.__end_cap_.__value_ )//如果当前容量达到预设容量,就走扩容路径。 { begin = v17.__begin_; v7 = v17.__end_ - v17.__begin_;//是两个 int* 相减,所以得到的是元素数量,而不是字节数。 v8 = v7 + 1;//保留旧数组的起始地址,计算旧数组容量,v8 计算插入新元素后所需的最小容量。 if ( (unsigned __int64)(v7 + 1) >> 62 != 0 ) std::vector<int>::__throw_length_error[abi:v170000](this: &v17); v9 = (char *)v17.__end_cap_.__value_ - (char *)v17.__begin_;//转成char*,一字节,这里得到的是旧数组的字节数。 if ( ((char *)v17.__end_cap_.__value_ - (char *)v17.__begin_) >> 1 > v8 ) //v9 >> 1相当于取了原始数组的一半字节数也就是10个字节。 v8 = v9 >> 1; if ( (unsigned __int64)v9 >= 0x7FFFFFFFFFFFFFFCLL ) v10 = 0x3FFFFFFFFFFFFFFFLL; else v10 = v8; if ( v10 != 0 ) { if ( v10 >> 62 != 0 ) std::__throw_bad_array_new_length[abi:v170000](); v11 = operator new(a1: 4 * v10);//这里是申请新内存的策略,申请了新的容量v10也就是v8也就是10,扩容了10个字节! } else { v11 = 0; } // 按照旧数组大小,找到新数组里新开辟的地址。 v12 = (int *)(v11 + 4 * v7); // 把新的数据填充进来。为什么先写新元素?这是 libc++ 这段扩容实现选择的操作顺序。 *v12 = i; for ( j = v12 + 1; end != begin; --v12 ) { v14 = *--end;// 搬迁循环,喜欢从后面来。它从旧数组末尾向前读取元素,再从新数组中对应的末尾位置向前写入 *(v12 - 1) = v14;// 这时候v12填充完就是起始地址,拿到他的地址。 } v17.__begin_ = v12; v17.__end_ = j; v17.__end_cap_.__value_ = (int *)(v11 + 4 * v10);// 新数组的末尾指针。 if ( begin != nullptr ) operator delete(a1: begin);//释放旧数组。这就是一次完整的 vector 扩容。 } else//没达到容量就直接赋值。 { *v17.__end_ = i; v17.__end_ = end + 1; } }vector的动态扩容真牛叉,每次扩容原始的一半字节数,不够就再扩。
二、map红黑树
map 和 vector 的最大区别:
- vector:元素连续存储。
- map:元素通常存储在树节点中。
#include <map>
int test_map() { std::map<int, int> mp;
mp[10] = 100; mp[20] = 200; mp[5] = 50;
return mp[10];}map 会根据 key 排序。
例如:
10 / \ 5 20这是为了便于查找、插入、删除,同时维持有序性。
红黑树典型结构
可以先把一个节点理解成:
struct TreeNode { TreeNode* parent; TreeNode* left; TreeNode* right;
bool color;
int key; int value;};示意内存:
TreeNode┌──────────────────┐│ parent ││ left ││ right ││ color ││ key ││ value │└──────────────────┘真实 STL 实现还可能有头节点、哨兵节点、颜色编码、对齐填充等。
ida实战
源代码
#include <map>#include <iostream>#include <algorithm>int main(){#include <map>
std::map<std::string, int> scores; scores["alice"] = 95; // 插入/更新 scores["bob"] = 88; scores["mj"] = 67; scores["miejianguo"] = 55;
std::cout << scores["alice"]; // 读取:95 auto it = scores.find("bob"); // 查找 if (it != scores.end()) { /* 找到了 */ for (const auto &[name, score] : scores) { std::cout << name << ": " << score << "\n"; } }}反编译的代码很长,主要是红黑树的实现,我这里只贴一部分慢慢讲解,要不然太难阅读了。
初始化
看这几行:
v51.__pair1_.__value_.__left_ = nullptr;v51.__pair3_.__value_ = 0;v51.__begin_node_ = &v51.__pair1_.__value_;它们是在初始化 std::map 内部的红黑树结构。
可以先把 v51 理解成一棵树的管理对象:
| 伪代码字段 | 可以先理解为 |
|---|---|
__pair1_.__value_.__left_ | 根节点左指针 |
__pair3_.__value_ | 当前节点数量 |
__begin_node_ | 指向最小节点的迭代器入口 / begin 节点 |
这里有一个容易混淆的地方:__begin_node_ 不一定直接指向根节点。它是为了支持 begin() 快速定位最小节点而维护的入口。
std::map 底层通常是红黑树。每个节点除了保存键值对,还会保存父节点、左孩子、右孩子等链接。
插入
来看插入元素
先看源码:
scores["alice"] = 95;scores["bob"] = 88;scores["mj"] = 67;scores["miejianguo"] = 55;它们在伪代码里分别对应四段非常相似的逻辑。
以第一次插入 "alice" 为例。
准备 key
strcpy((char *)&v50.__r_.__value_.__l.0, "\nalice");这里是在构造临时 std::string,内容是 "alice"。
这里多了个换行符,有点迷。
找到是否存在这个键
我们继续往下看
v3 = std::__tree<...>::__find_equal<std::string>( this: &v51, // 这个 map __parent: &v52, // 输出参数:找到/应插入位置的父节点 __v: &v50); // 要找的 key = "\nalice"v4 = *v3; // v4 = 该位置上的节点指针(若 key 已存在则非空)
ps:完整的类型v3 = std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string>,true>,std::allocator<std::__value_type<std::string,int>>>::__find_equal<std::string>(
很长,不过我们只需要把他当作map<string,int>即可。创建新节点逻辑
接着往下走
if ( *v3 == nullptr )//在map里没找到对应的键,就开始创建一个节点。 { v5 = operator new(a1: 0x40u);//operator new(0x40) 表示申请 0x40 = 64 字节内存,作为一个红黑树节点。 data = v50.__r_.__value_.__l.__data_; //拿到键值 *(_DWORD *)(v5 + 56) = 0; v7 = *(_OWORD *)v50.__r_.__value_.__l.gap0; memset(&v50, 0, sizeof(v50)); v4 = (std::__tree_node_base<void *> *)v5; *(_QWORD *)(v5 + 48) = data; v8 = v52; *(_OWORD *)(v5 + 32) = v7; *(_QWORD *)v5 = 0; *(_QWORD *)(v5 + 8) = 0; *(_QWORD *)(v5 + 16) = v8; //把新创建的节点塞到树上 *v3 = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > >::__node_base_pointer)v5;
//更新 __begin_node_:必要时更新最小节点入口。 if ( v51.__begin_node_->__left_ != nullptr ) v51.__begin_node_ = v51.__begin_node_->__left_; std::__tree_balance_after_insert[abi:v170000]<std::__tree_node_base<void *> *>( __root: v51.__pair1_.__value_.__left_, __x: *v3);//__tree_balance_after_insert:插入后进行红黑树平衡。 ++v51.__pair3_.__value_;//节点数量加一。 }//__tree_balance_after_insert 是识别红黑树插入的重要特征。 它内部通常会处理节点颜色、父节点、叔叔节点,以及左旋、右旋等操作。注意,不是每次插入都一定发生旋转。新节点插入后,可能只需要变色,也可能需要旋转和变色。推断内存布局
根据节点的偏移,我们可以推断一下红黑树节点的布局
| 节点偏移 | 大小 | 当前伪代码中的操作 | 初步含义 |
|---|---|---|---|
+0x00 | 8 | 写 0 | 左孩子指针 |
+0x08 | 8 | 写 0 | 右孩子指针 |
+0x10 | 8 | 写 v52 | 父节点指针 |
+0x20 | 16 | 写入 v7 | std::string 内部数据 |
+0x30 | 8 | 写入 data | std::string 相关指针/存储字段 |
+0x38 | 4 | 写 0 | int value 的初始值 |
我们从ida里看看
00000000 struct __cppobj std::__tree_node<std::__value_type<std::string,int>,void *> : std::__tree_node_base<void *> // sizeof=0x4000000000 {00000020 std::__tree_node<std::__value_type<std::string,int>,void *>::__node_value_type __value_;00000040 };treenode继承了__tree_node_base
节点总大小 0x40 = 64,值区 __value_ 在 +0x20 = 32,所以基类 __tree_node_base<void\*> 大小是 32 字节。
00000000 struct std::__tree_end_node<...> // sizeof=0x800000000 pointer __left_; // +0x00
00000000 struct __tree_node_base<void*> : __end_node_type // sizeof=0x2000000000 <继承自 __tree_end_node> // +0x00 ~ +0x07,8 字节00000008 pointer __right_; // +0x0800000010 __parent_pointer __parent_; // +0x1000000018 bool __is_black_; // +0x1800000019-1F padding这样就能看出基类的数据了
| 偏移 | 字段 | 来源 |
|---|---|---|
| +0x00 | __left_ | 继承自 __tree_end_node(左孩子指针) |
| +0x08 | __right_ | 本类 |
| +0x10 | __parent_ | 本类 |
| +0x18 | __is_black_(bool,1 字节) | 本类 |
| +0x19~0x1F | padding |
整个节点的内存布局就水落石出
| 偏移 | 内容 |
|---|---|
| +0x00 | __left_(左孩子) |
| +0x08 | __right_(右孩子) |
| +0x10 | __parent_(父指针) |
| +0x18 | __is_black_(bool,颜色) |
| +0x20 | pair<const string,int> __value_ |
| +0x20 | └ const string first(0x18 字节,到 +0x38) |
| +0x38 | └ int second ← value,56 = 0x38 |
| +0x3C~0x3F | padding |
写入数据
v9 = v50.__r_.__value_.__s.0; *(_DWORD *)&v4[1].__is_black_ = 95;
v4[1] // ① 数组下标(等价 *(v4 + 1))v4[1].__is_black_ // ② 取结构体成员(等价 (*(v4+1)).__is_black_)& v4[1].__is_black_// ③ 取该成员的地址 → 得到指针(_DWORD *) ... // ④ 把指针强制转成“指向 4 字节整数”的指针* (_DWORD *) ... // ⑤ 解引用 → 一个可写的 4 字节整数左值= 95; // ⑥ 赋值
v4 类型是 __tree_node_base<void*> *,sizeof(*v4) = 0x20。
v4[1] = (char*)v4 + 0x20。
.__is_black_ 在基类里偏移 +0x18。
所以地址 = (char*)v4 + 0x20 + 0x18 = (char*)v4 + 0x38 = +56。下面是find的实现
v50.__r_.__value_.__s.0 = (std::string::__short::$5662C175DA7CC34792E979125A88A1DA)6; *(_DWORD *)&v50.__r_.__value_.__l.gap0[1] = 6451042;//6451042就是bob v37.__ptr_ = std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string>,true>,std::allocator<std::__value_type<std::string,int>>>::find<std::string>( this: &v51, __v: &v50).__ptr_;//查到结果的指针。 if ( (*(_BYTE *)&v50.__r_.__value_.__s.0 & 1) != 0 )
if (&v51.__pair1_ != v37.__ptr_) //相当于if (it != scores.end())迭代器遍历
begin_node = v51.__begin_node_;//相当于 auto it = scores.begin(); if ( (std::__compressed_pair<std::__tree_end_node<std::__tree_node_base<void *> *>> *)v51.__begin_node_ != &v51.__pair1_ ) { do { left_low = LOBYTE(begin_node[4].__left_); v40 = (left_low & 1) == 0; v41 = left_low >> 1; if ( v40 ) left = (char *)&begin_node[4].__left_ + 1; else left = (const char *)begin_node[6].__left_; if ( v40 ) v43 = v41; else v43 = (size_t)begin_node[5].__left_; //上面在处理字符串的sso v44 = std::__put_character_sequence[abi:v170000]<char,std::char_traits<char>>( __os: (std::ostream *)&std::cout, __str: left, __len: v43); v45 = std::__put_character_sequence[abi:v170000]<char,std::char_traits<char>>(__os: v44, __str: ": ", __len: 2u); v46 = (std::ostream *)std::ostream::operator<<(a1: v45, a2: LODWORD(begin_node[7].__left_)); std::__put_character_sequence[abi:v170000]<char,std::char_traits<char>>(__os: v46, __str: "\n", __len: 1u); //输出。 p_left = &begin_node[1].__left_->__left_; if ( p_left != nullptr ) { do { v48 = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > > *)p_left; p_left = (_QWORD *)*p_left; } while ( p_left != nullptr ); } else { do { v48 = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > > *)begin_node[2].__left_; v40 = v48->__begin_node_ == begin_node; begin_node = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > >::__iter_pointer)v48; } while ( !v40 ); } begin_node = (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > >::__iter_pointer)v48; } while ( v48 != (std::__tree<std::__value_type<std::string,int>,std::__map_value_compare<std::string,std::__value_type<std::string,int>,std::less<std::string >,true>,std::allocator<std::__value_type<std::string,int> > > *)&v51.__pair1_ ); } }
把最后一部分简化下:寻找中序遍历的下一个节点。p_left = &begin_node[1].__left_->__left_;//p_left = node->right->left; // 右子树的左孩子
if (p_left != nullptr){ do { v48 = ... p_left; p_left = (_QWORD *)*p_left; } while (p_left != nullptr);}else{ do { v48 = ... begin_node[2].__left_; v40 = v48->__begin_node_ == begin_node; begin_node = ... v48; } while (!v40);}情况 A:当前节点有右子树
- 先进入当前节点的右孩子。
- 再沿着左孩子一直向下。
- 最后到达的节点就是下一个节点。
情况 B:当前节点没有右子树
- 沿着父节点向上走。
- 如果当前节点是父节点的左孩子,那么父节点就是下一个节点。
- 如果当前节点是父节点的右孩子,就继续向上。
- 一直走到满足条件的位置,或者走到 end 哨兵。
这就是红黑树迭代器 ++it 的典型实现思路。
三、std::unordered_map/set:哈希表
#include <unordered_map>
int test_hash() { std::unordered_map<int, int> mp;
mp[100] = 1; mp[200] = 2;
return mp[100];}和 map 的区别:
| 对比 | map | unordered_map |
|---|---|---|
| 典型结构 | 红黑树 | 哈希表 |
| 是否有序 | 有序 | 不保证顺序 |
| 查找 | (O(\log n)) | 平均 (O(1)) |
| 关键逻辑 | 比较器 | hash + key_equal |
哈希表工作流程
key = 100 │ ▼hash(100) │ ▼计算 bucket 下标 │ ▼找到对应 bucket │ ▼遍历候选节点 │ ▼比较 key │ ▼返回 value你在 IDA 中可能看到:
- 哈希函数;
- 取模或位掩码;
- bucket 数组;
- 节点链表或其他冲突处理结构;
- key 比较。
这些是识别哈希表的重要线索。
注意:不同 libc++ 版本的哈希表结构存在差异,不要把它固定成某一种链表布局。
ida实战
源代码
#include <unordered_map>#include <unordered_set>#include <string>int main(){ std::unordered_map<std::string, int> u; u["alice"] = 95; u["bob"] = 88;
auto it = u.find("alice"); // 平均 O(1) u.count("alice"); // 0 或 1
std::unordered_set<int> s = {3, 1, 2}; s.insert(5); s.erase(3);}反编译后的伪代码很多,就不全部放上来了,感兴趣的佬可以自己编译一个so看看。
赋值
u.__p3_.__value_ = 1.0; strcpy((char *)&v15, "\nalice"); memset(&u, 0, 32); v13.__base_.__value_ = (std::string *)&v15;这里u是哈希表,__hash_table<std::__hash_value_type<std::string,int>简化一下,类型可以这么看,键是string,值是int。
接着看看赋值逻辑。
v18.__node_ = __emplace_unique_key_args( this: &u, __k: (const std::string *)&v15, ...).first;
LODWORD(v18.__node_[5].__next_) = 95;//对相应的键进行赋值。
//这里的内存布局我们稍后来看,为什么会在这里赋值。它和之前分析 map 时的 __find_equal 有相似之处。
这里的:
__emplace_unique_key_args是一个很重要的函数。
它的意思大致是:
- 根据 key 查找是否已有对应元素。
- 如果已经存在,就返回已有节点。
- 如果不存在,就创建节点并插入。
- 返回一个迭代器和插入状态。
注意,unordered_map 的底层实现不需要像红黑树那样寻找父节点和左右孩子,而是需要通过哈希值确定 bucket。
又见SSO
if ( (value & 1) != 0 ) operator delete(a1: v15.__table_.__p1_.__value_.__next_); LOBYTE(v15.__table_.__bucket_list_.__ptr_.__value_) = 6; *(_DWORD *)((char *)&v15.__table_.__bucket_list_.__ptr_.__value_ + 1) = 6451042; v13.__base_.__value_ = (std::string *)&v15;嘿!这里是不是见过?SSO对吧!
先判断是否是长字符串,如果不是长字符串就删除掉这个拷贝值。
然后对这个值进行更新,也就是后面要插入的bob。
find的逻辑
这个差不多啊,没啥好讲的。
if ( (v4 & 1) != 0 ) operator delete(a1: v15.__table_.__p1_.__value_.__next_); strcpy((char *)&v15, "\nalice"); std::__hash_table<std::__hash_value_type<std::string,int>,std::__unordered_map_hasher<std::string,std::__hash_value_type<std::string,int>,std::hash<std::string>,std::equal_to<std::string>,true>,std::__unordered_map_equal<std::string,std::__hash_value_type<std::string,int>,std::equal_to<std::string>,std::hash<std::string>,true>,std::allocator<std::__hash_value_type<std::string,int>>>::find<std::string>( this: &u, __k: (const std::string *)&v15); if ( ((__int64)v15.__table_.__bucket_list_.__ptr_.__value_ & 1) != 0 ) operator delete(a1: v15.__table_.__p1_.__value_.__next_);set初始化
v5 = 0;v13.__base_.__value_ = (std::string *)0x100000003LL; // 两个 int 打包:低32=3, 高32=1v14 = 2; // 第三个 int = 2memset(&v15, 0, 32);v15.__table_.__p3_.__value_ = 1.0; // load factor
do { __emplace_unique_key_args<int,int const&>( &v15.__table_, __k: (int*)((char*)&v13 + v5), // 第 v5/4 个 int __args: 同); v5 += 4;} while (v5 != 12); // 3 个 int,12 字节0x100000003 拆成 32 位:低 = 0x00000003 = 3,高 = 0x00000001 = 1。加上 v14 = 2,得到数组 {3, 1, 2}。
后面的循环插入很有意思,源代码里我们只是直接赋值了set,这里是循环插入。
后面又单独插入了5和删除3,同质化,这里不看,直接跳过。
析构set
next = v15.__table_.__p1_.__value_.__next_; // 链表头(所有节点串成单链表)while (next) { v7 = next->__next_; operator delete(next); // 释放节点 next = v7;}v8 = v15.__table_.__bucket_list_.__ptr_.__value_;v15.__table_.__bucket_list_.__ptr_.__value_ = nullptr;if (v8) operator delete(v8); // 释放 bucket 数组这两个成员值得重点关注。
从名字和使用方式来看:
| 成员 | 作用 |
|---|---|
__p1_.__value_.__next_ | 哈希节点链表的入口相关指针 |
__bucket_list_.__ptr_.__value_ | bucket 数组相关指针 |
析构map
v9 = u.__p1_.__value_.__next_;while (v9) { v12 = v9->__next_; if (((__int64)v9[2].__next_ & 1) != 0) // 节点里 string key 是长串 → 释放堆 operator delete(v9[4].__next_); operator delete(v9); // 释放节点 v9 = v12;}v10 = u.__bucket_list_.__ptr_.__value_;u.__bucket_list_.__ptr_.__value_ = nullptr;if (v10) operator delete(v10); // 释放 bucket 数组内存布局
std::__hash_table<int, ...> // sizeof = 0x28std::__hash_table<std::__hash_value_type<std::string,int>, ...> // sizeof = 0x28两个结构体都是0x28区别最大的点就是存储类型,一个是int,另一个是pair。整体布局如下
struct __hash_node_base<...> { // 基类,+0x00 __next_pointer __next_; // +0x00 链指针(8 字节)};
struct __hash_node<int,void*> : __hash_node_base<...> { // sizeof=0x18 size_t __hash_; // +0x08 缓存 hash(8 字节) int __value_; // +0x10 存的 int(4 字节) // +0x14..0x17 padding(对齐到 8)};合并成下面struct __hash_node<int> { // 堆上,单独分配 __next_pointer __next_; // +0 链到下一个节点 size_t __hash_; // +8 缓存的 hash 值 int __value_; // +16 ← insert 的 int 在这};
std::__hash_table<int, hash<int>, equal_to<int>, alloc<int>> // sizeof=0x28+0x00 __bucket_list_ // 16 字节:哈希桶数组管理信息//哈希核心思路就是hash = hash_function(key) index = hash % bucket_count;+0x10 __p1_ <__hash_node_base<...>> // 8 字节:全局单链表头(所有节点串起来)+0x18 __p2_ <unsigned long, hash<int>>// 16 字节:元素个数 size + 哈希函数+0x20 __p3_ <float, equal_to<int>> // 8 字节:max_load_factor + 空相等比较器+0x24..0x27 paddingstruct __hash_node_base { __next_pointer __next_; // +0x10:全局单链表头};每个节点的内存布局
| 偏移 | 字段 | 作用 |
|---|---|---|
| +0x00 | __next_ | 串链表(桶内链 + 全局链,同一个字段) |
| +0x08 | __hash_ | 缓存该 key 的 hash 值 |
| +0x10 | __value_ | 真正存的数据 int |
| +0x14~0x17 | padding | 对齐到 8 字节 |
+0x10 是全局链表头指针:所有节点通过各自的 __next_ 串成一条单链表(不是按桶分,是全局一条)。伪代码里:
next = v15.__table_.__p1_.__value_.__next_; // 取链表头while (next) { v7 = next->__next_; delete(next); next = v7; }就是遍历这条全局链表释放所有节点。空表时它指向 &__p1_ 自身(sentinel)。
注意:bucket 数组不是直接存放 int 元素的数组,而是存放节点入口相关指针的管理结构。
因为 std::hash<int> 是一个无状态的空类,没有需要保存的数据成员。libc++ 使用 __compressed_pair 的压缩存储机制,可以让这个空对象不额外占用空间。
部分信息可能已经过时









