内存是一条长街
内存是一条长街
想象一条无限长的街道,每栋房子门口都有一块门牌号,门牌号从 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)用大端。所以从网络接收数据时,常常需要字节序转换(htonl、ntohl 等函数)。
内存的四个区域
一个运行中的程序,其内存空间通常分为四个主要区域:
高地址
┌────────────────────────────────┐
│ 内核空间 │ 操作系统内核(用户程序不可访问)
├────────────────────────────────┤
│ 栈(Stack) │ ↓ 向下增长
│ 函数调用的局部变量、返回地址 │
│ ... │
├────────────────────────────────┤
│ │ (空闲,两者之间的间隙)
├────────────────────────────────┤
│ 堆(Heap) │ ↑ 向上增长
│ 动态分配的内存(malloc等) │
├────────────────────────────────┤
│ BSS 段 │ 未初始化全局/静态变量(默认0)
│ 数据段(Data) │ 已初始化全局/静态变量
│ 代码段(Text) │ 程序指令(只读)
└────────────────────────────────┘
低地址(0x0000...)
这四个区域的特性:
- 代码段(Text):只读,包含程序的机器指令。多个进程可以共享同一份代码(如 libc)
- 数据段(Data/BSS):全局变量和静态变量
- 堆(Heap):动态分配,生命周期由程序员控制(或垃圾回收器)
- 栈(Stack):自动管理,函数调用时分配,返回时释放
指针:地址的代名词
在 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 [ ... ]
读取内存的过程:
- 行选通(RAS):打开某一行,把整行数据复制到行缓冲区(Row Buffer)
- 列选通(CAS):从行缓冲区读出特定列
这个过程有延迟:
- CAS 延迟(tCL):从发出读命令到数据到达,通常 14-20 个时钟周期(内存时钟)
- RAS 到 CAS 延迟(tRCD):打开行到可以访问列的延迟
- 预充电延迟(tRP):关闭当前行再打开下一行的延迟
典型内存时序: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):发出请求到数据返回的时间
- L1 缓存:~4 CPU 周期(~1ns)
- L2 缓存:~12 CPU 周期(~3ns)
- L3 缓存:~40 CPU 周期(~10ns)
- DDR5 主内存:~150 CPU 周期(~50ns)
带宽(Bandwidth):每秒能传输多少数据
- DDR5 单通道:约 50 GB/s
- DDR5 双通道:约 100 GB/s
- Apple M4 Pro(统一内存):约 273 GB/s
- NVIDIA H100 HBM3:3.35 TB/s
延迟和带宽是不同的概念:
- 延迟决定单次访问等多久
- 带宽决定一秒内能传多少数据
对于随机访问(如链表遍历),延迟是瓶颈;对于顺序访问(如数组处理),带宽是瓶颈。
内存控制器:地址到物理位置的映射
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 的内部机制
malloc 和 free 是如何工作的?
简化版内存分配器:
堆的结构(链表管理):
┌───────────┬───────────────────────────────────────────┐
│ 已用 16B │ 头部(大小=16,已用) │ 用户数据(16字节) │
├───────────┼───────────────────────────────────────────┤
│ 空闲 32B │ 头部(大小=32,空闲) │ 空闲空间(含next指针) │
├───────────┼───────────────────────────────────────────┤
│ 已用 64B │ 头部(大小=64,已用) │ 用户数据(64字节) │
└───────────┴───────────────────────────────────────────┘
malloc(n) 的简化流程:
- 遍历空闲链表,找到足够大的空闲块
- 如果找到:把空闲块分割,返回用户请求的部分
- 如果没找到:通过
brk()/mmap()系统调用向 OS 申请更多内存
free(p) 的简化流程:
- 把 p 指向的块标记为空闲
- 检查相邻块是否也是空闲,如果是则合并(减少碎片)
内存碎片是堆管理的主要挑战:
- 外部碎片:大量小的空闲块分散在各处,总空间足够但无法分配一大块
- 内部碎片:分配的块比请求的大(对齐或最小块大小要求)
现代内存分配器(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 中这一行为被修改了。跨平台代码必须对这些边界情况显式处理。