第 10 章

内存是一条长街

内存是一条长街

想象一条无限长的街道,每栋房子门口都有一块门牌号,门牌号从 0 开始,一直往后编。每栋房子大小完全一样,刚好能装下 1 个字节的数据。你想找到某个数据,只需要知道它住在几号——这就是内存地址的全部本质。

这条"街道"就是内存(RAM,随机访问存储器)。"随机"的意思是:不管你要访问第 1 号还是第 10 亿号,花的时间是一样的(不像磁盘,顺序访问比随机访问快很多)。

Level 1:建立直觉

内存的基本单位

内存的基本单位是字节(byte),每个字节有 8 个比特,可以存储 0-255 之间的一个值。

每个字节有唯一的地址(address),就是门牌号。在 64 位系统上,地址是一个 64 位的数字(虽然实际上只用了约 48 位)。

常见的数据大小:

数据类型        大小    地址范围(举例)
char (1字节)   1 byte  地址 0x1000
short (2字节)  2 bytes 地址 0x1000-0x1001
int (4字节)    4 bytes 地址 0x1000-0x1003
long (8字节)   8 bytes 地址 0x1000-0x1007
double (8字节) 8 bytes 地址 0x1000-0x1007

多字节数据存储时,有一个重要问题:高字节在前还是低字节在前?

大端(Big-Endian):高字节放低地址(最重要的字节在"前面") 小端(Little-Endian):低字节放低地址(最不重要的字节在"前面")

以整数 0x12345678 为例:

内存地址:  0x1000  0x1001  0x1002  0x1003
大端:       0x12    0x34    0x56    0x78
小端:       0x78    0x56    0x34    0x12

x86-64 和 ARM64 都是小端(Little-Endian)。网络协议(TCP/IP)用大端。所以从网络接收数据时,常常需要字节序转换(htonlntohl 等函数)。

内存的四个区域

一个运行中的程序,其内存空间通常分为四个主要区域:

高地址
┌────────────────────────────────┐
│          内核空间               │  操作系统内核(用户程序不可访问)
├────────────────────────────────┤
│      栈(Stack)                │  ↓ 向下增长
│   函数调用的局部变量、返回地址   │
│          ...                   │
├────────────────────────────────┤
│                                │  (空闲,两者之间的间隙)
├────────────────────────────────┤
│       堆(Heap)                │  ↑ 向上增长
│    动态分配的内存(malloc等)    │
├────────────────────────────────┤
│     BSS 段                     │  未初始化全局/静态变量(默认0)
│     数据段(Data)              │  已初始化全局/静态变量
│     代码段(Text)              │  程序指令(只读)
└────────────────────────────────┘
低地址(0x0000...)

这四个区域的特性:

指针:地址的代名词

在 C/C++ 里,指针就是一个存着内存地址的变量:

int x = 42;        // x 在内存某地址(假设 0x1000),值为 42
int* p = &x;       // p 的值是 0x1000(x 的地址)

printf("%d\n", x);   // 42(直接访问)
printf("%d\n", *p);  // 42(通过指针访问,"解引用")

*p = 100;          // 通过指针修改 x
printf("%d\n", x); // 100

指针和地址直接对应:p = 0x1000 意味着"去 0x1000 这个地址找数据"。

指针算术

int arr[5] = {10, 20, 30, 40, 50};
int* p = arr;         // p 指向 arr[0](地址 0x1000)

printf("%d\n", *p);        // 10
printf("%d\n", *(p + 1));  // 20(地址 0x1004,+4 因为 int 是4字节)
printf("%d\n", *(p + 2));  // 30(地址 0x1008)

p + 1 不是地址加 1,而是加上数据类型的大小(int 是 4 字节)。这就是为什么 C 的数组和指针可以无缝互换:arr[i] 等价于 *(arr + i)

Level 2:原理剖析

DRAM:内存的物理实现

现代内存使用 DRAM(Dynamic Random Access Memory,动态随机存取存储器)

"Dynamic" 的意思:每个比特用一个电容存储电荷,电容会自然放电(漏电),所以必须每隔约 64 毫秒刷新一次(重新充电),否则数据就丢失了。

内存的物理结构:

DRAM 芯片内部结构:
行选通(RAS)→ 选择哪一行
列选通(CAS)→ 选择哪一列

     列0   列1   列2   ...  列1023
行0 [ C00  C01  C02  ...  C01023 ]
行1 [ C10  C11  C12  ...  C11023 ]
行2 [ C20  C21  C22  ...  C21023 ]
...
行1023 [ ...                     ]

读取内存的过程:

  1. 行选通(RAS):打开某一行,把整行数据复制到行缓冲区(Row Buffer)
  2. 列选通(CAS):从行缓冲区读出特定列

这个过程有延迟:

典型内存时序:16-18-18-38(CL-tRCD-tRP-tRAS),每个数字是内存时钟周期数。

DDR5-6400 内存时钟 3200MHz(双数据率,所以数据率 6400MT/s),CL 延迟 16 个时钟周期 = 5 纳秒。加上其他延迟,总访问延迟约 30-50 纳秒。

对应到 CPU 时钟(3GHz = 0.33ns/周期),50ns = 约 150 个 CPU 周期的等待。这就是为什么缓存如此重要。

内存带宽与延迟

内存有两个关键性能指标:

延迟(Latency):发出请求到数据返回的时间

带宽(Bandwidth):每秒能传输多少数据

延迟和带宽是不同的概念:

对于随机访问(如链表遍历),延迟是瓶颈;对于顺序访问(如数组处理),带宽是瓶颈。

内存控制器:地址到物理位置的映射

CPU 发出的内存地址是线性地址(逻辑上连续的数字序列)。

内存控制器负责把这个线性地址映射到实际的 DRAM 的"银行(Bank)-行(Row)-列(Column)"地址。

内存地址交织(Interleaving)

多通道内存系统会把连续的地址分散到不同的内存通道(Channel)和存储体(Bank),这样连续的内存访问可以同时在多个通道/Bank 上进行,提高带宽:

线性地址      内存通道
0x0000       Channel 0 (第一个64字节)
0x0040       Channel 1 (第二个64字节)
0x0080       Channel 0 (第三个64字节)
0x00C0       Channel 1 (第四个64字节)
...

这就是为什么双通道内存(两条 DDR5)比单通道快一倍(理论带宽)——连续访问可以并行进行。

堆的管理:malloc/free 的内部机制

mallocfree 是如何工作的?

简化版内存分配器

堆的结构(链表管理):
┌───────────┬───────────────────────────────────────────┐
│ 已用 16B  │ 头部(大小=16,已用) │ 用户数据(16字节)     │
├───────────┼───────────────────────────────────────────┤
│ 空闲 32B  │ 头部(大小=32,空闲) │ 空闲空间(含next指针) │
├───────────┼───────────────────────────────────────────┤
│ 已用 64B  │ 头部(大小=64,已用) │ 用户数据(64字节)     │
└───────────┴───────────────────────────────────────────┘

malloc(n) 的简化流程:

  1. 遍历空闲链表,找到足够大的空闲块
  2. 如果找到:把空闲块分割,返回用户请求的部分
  3. 如果没找到:通过 brk()/mmap() 系统调用向 OS 申请更多内存

free(p) 的简化流程:

  1. 把 p 指向的块标记为空闲
  2. 检查相邻块是否也是空闲,如果是则合并(减少碎片)

内存碎片是堆管理的主要挑战:

现代内存分配器(jemalloc、tcmalloc、mimalloc)用复杂的算法减少碎片,并为多线程并发优化:

// 现代分配器特性
// jemalloc(Firefox、Redis使用):
// - 按大小类分桶,减少碎片
// - 线程局部缓存,减少锁竞争
// - 定期清理和归还内存给 OS

// tcmalloc(Google开发,Chrome使用):
// - Thread Cache:每个线程有自己的小对象缓存
// - 大对象和小对象分开管理
// - 统计分析内存使用模式进行优化

Level 3 · 规范怎么定义的(资深)

内存模型的形式化定义

内存系统的行为由多层标准定义。物理内存方面,JEDEC 标准(JESD79 系列)定义了 DDR SDRAM 的电气规范、时序参数和刷新要求。DDR5(JESD79-5)引入了片上 ECC(Error Correction Code)——每 128 位数据附带 8 位纠错码,可纠正单比特错误。这意味着即使宇宙射线翻转了一个比特(称为"位翻转"或 soft error),数据仍然完整。

虚拟内存的行为由 CPU 架构手册定义。x86-64 的页表格式(4 级或 5 级页表,每页 4KB/2MB/1GB)在 Intel SDM Volume 3 Chapter 4 中有精确的位级定义:每个页表条目(PTE)的 63 位是 NX(No Execute)位、第 0 位是 Present 位、第 1 位是 Read/Write 位等。POSIX 标准(IEEE Std 1003.1)定义了用户空间的内存管理接口:mmap()mprotect()madvise() 等系统调用的语义。

C/C++ 的内存模型(ISO/IEC 14882:2020 中的 [intro.races] 和 [atomics] 章节)定义了多线程程序中内存操作的可见性规则。C++11 引入了六种内存序(memory_order_relaxed、consume、acquire、release、acq_rel、seq_cst),形式化地描述了原子操作之间的 happens-before 关系。这套模型基于 Leslie Lamport 1979 年定义的"顺序一致性"概念,是正确编写无锁数据结构的理论基础。

Level 4 · 边界与陷阱(所有人)

陷阱 1:Use-After-Free 是最危险的内存 bug

free(ptr) 后继续使用 ptr 是 C/C++ 中最常见的安全漏洞之一。释放后的内存可能被新的 malloc() 分配给其他对象,此时通过旧指针访问会读到完全不相关的数据,甚至可以通过精心构造的堆布局实现任意代码执行。Chrome 浏览器 2023 年修复的安全漏洞中,超过 70% 是 Use-After-Free 类型。这也是 Rust 诞生的核心动机——其所有权系统在编译期消除了这类 bug。

陷阱 2:DRAM 刷新可能导致微秒级延迟毛刺

DRAM 是"有损"存储——电容中的电荷会自然泄漏,必须每 64ms(DDR4/DDR5 标准要求)刷新一次所有行。刷新期间,被刷新的 bank 无法响应读写请求。在实时系统或高频交易中,这种刷新导致的延迟毛刺(约 100-300ns)可能造成问题。更极端的情况是 Rowhammer 攻击:快速反复访问同一行 DRAM 会干扰相邻行的电荷,导致位翻转。Google Project Zero 在 2015 年首次证明可以利用 Rowhammer 获取内核权限。DDR5 的片上 ECC 和 TRR(Target Row Refresh)机制部分缓解了这一问题,但 2022 年的研究表明 TRR 仍可被绕过。

陷阱 3:malloc(0) 的行为在不同平台上不一致

C 标准规定 malloc(0) 的行为是"实现定义的"——它可以返回 NULL,也可以返回一个非 NULL 的唯一指针(不可解引用)。glibc 的 malloc(0) 返回一个非 NULL 指针,而某些嵌入式 libc 返回 NULL。如果代码用 if (ptr == NULL) 检查分配失败,在 glibc 上会错误地认为分配成功。更隐蔽的是,realloc(ptr, 0) 在 C17 中被定义为"等同于 free(ptr)"并可能返回 NULL,但在 C23 中这一行为被修改了。跨平台代码必须对这些边界情况显式处理。

本章评分
4.5  / 5  (34 评分)

💬 留言讨论