﻿# 进程内存布局 —— 32 位 / 64 位地址空间长什么样，看地址秒判类型

> [compile-link-load.md](/concepts/elf/compile-link-load.md) 讲了 `.text`/`.data`/`.bss` 怎么在链接时拿到地址、程序怎么被加载。但**程序跑起来之后，整个虚拟地址空间到底怎么排布**——栈在哪、堆在哪、`.so` 映射在哪、内核占哪一段——是另一个独立且极实用的话题。尤其在看 core dump、gdb backtrace、`/proc/pid/maps` 时，**扫一眼一个地址就能判断"这是栈变量 / 堆对象 / 全局量 / 代码 / 共享库"**，是排查崩溃和内存问题的基本功。


> 本篇讲：**32 位和 64 位进程的虚拟地址空间布局各长什么样、各段的典型地址范围、以及怎么根据一个地址的数值快速判断它属于哪一类**。

## 一、先立三个地基概念

1. **每个进程有独立的虚拟地址空间**：进程看到的地址全是**虚拟地址**，由 MMU+TLB 翻译成物理地址（见 [../cache/tlb.md](/concepts/cache/tlb.md)）。所以两个进程的 `0x400000` 指向**各自不同**的物理内存，互不干扰。
2. **地址空间分用户空间 + 内核空间**：低地址一大片是**用户空间**（进程自己的代码/数据/堆/栈/库），高地址一段是**内核空间**（所有进程共享同一份内核映射，但用户态**无权访问**，碰了就 SIGSEGV）。
3. **布局从两端向中间生长**：代码/数据在**低地址**固定住；**堆向上长**（低→高），**栈向下长**（高→低），中间是 mmap 区（`.so`、匿名大块内存）。堆和栈相向生长，中间的空洞就是可扩展的余量。

## 二、32 位进程布局（经典的 3G/1G 划分）

32 位地址空间总共 **2³² = 4 GB**。Linux 经典划分：**低 3 GB 给用户空间，高 1 GB 给内核**（`0xC0000000` 是分界线）。

```text
高地址 0xFFFFFFFF ┌─────────────────────────────┐
                  │  内核空间 (高 1 GB)          │  所有进程共享
                  │  0xC0000000 ~ 0xFFFFFFFF     │  用户态碰了 = SIGSEGV
    0xC0000000 ───├─────────────────────────────┤ ← 用户/内核分界线
                  │  栈 stack        ↓ 向下生长  │  局部变量/调用帧/返回地址
       ~0xBFFF... │  (从 ~0xBFFFFFFF 往低走)     │
                  ├─────────────────────────────┤
                  │            ↕                 │
                  │  mmap 区 (共享库.so/匿名映射) │  从高往低排
                  │            ↕                 │
                  ├─────────────────────────────┤
                  │  堆 heap         ↑ 向上生长  │  malloc/new;brk/mmap 扩展
                  ├─────────────────────────────┤
                  │  .bss   (未初始化全局/静态,清零) │
                  │  .data  (已初始化全局/静态)     │
                  │  .rodata(常量/字符串字面量)     │
                  │  .text  (代码,只读可执行)       │
    0x08048000 ───│  (典型从 0x08048000 起)      │
低地址 0x00000000 └─────────────────────────────┘
  ↑ 高地址在上、低地址在下;堆向上长、栈向下长,中间的空洞是二者可扩展的余量。
```

32 位典型地址特征：

- **`.text` 起始 `0x08048000`**（传统非 PIE）——看到 `0x0804xxxx` 基本就是代码/程序自身的低段。
- **栈**在用户空间顶端 `0xBFFFxxxx` 附近，向低地址生长。
- **内核空间 `0xC0000000` 以上**，用户态不可见。
- **局限**：3 GB 用户空间对大内存程序不够用（单进程最多约 3 GB），这是 64 位普及的直接动因之一。

## 三、64 位进程布局（x86-64，实际只用 48 位）

64 位理论空间 2⁶⁴ = 16 EB，大得没意义。x86-64 硬件**实际只实现 48 位**（部分新平台 57 位），有效地址 **256 TB**。而且这 48 位被划成两段、中间一大片是**非法空洞（canonical hole）**：

```text
高地址 0xFFFFFFFFFFFFFFFF ┌───────────────────────────────────┐
                          │  内核空间 (高 128 TB)              │  所有进程共享
                          │  0xFFFF800000000000 ~ 0xFFFF...FFFF │
      0xFFFF800000000000 ─├───────────────────────────────────┤
                          │  ★ 非法空洞 (canonical hole)       │  高 16 位必须全 0 或全 1
                          │  0x0000800000000000 ~ 0xFFFF7FFF... │  落这里的地址=非法,访问必崩
      0x0000800000000000 ─├───────────────────────────────────┤ ← 用户空间实际只用下面这段
                          │  栈 stack          ↓ 向下生长      │  (~0x7FFFFFFFFFFF 附近,ASLR 随机化)
           ~0x7FFF...     ├───────────────────────────────────┤
                          │            ↕                       │
                          │  mmap 区 (共享库.so/匿名映射)       │  典型 0x7Fxx… 段
                          │            ↕                       │
                          ├───────────────────────────────────┤
                          │  堆 heap           ↑ 向上生长      │  malloc/new
                          ├───────────────────────────────────┤
                          │  .bss / .data / .rodata            │  全局/静态/常量
                          │  .text (代码)                      │  非 PIE: 0x400000 起
                          │                                    │  PIE(现代默认): 随机基址如 0x55xx…
    低地址 0x000000000000 └───────────────────────────────────┘
  ↑ 用户空间只占低 128 TB (0x0 ~ 0x00007FFF…);中间的非法空洞把地址空间劈成"低半"(用户)和"高半"(内核)。
```

64 位的关键特征（**记住这几个数量级，判地址全靠它**）：

| 区域 | 典型地址 | 备注 |
|------|---------|------|
| **代码/数据（非 PIE）** | `0x400000` 起 | 传统固定基址；老程序、显式 `-no-pie` |
| **代码/数据（PIE，现代默认）** | `0x55xxxxxxxxxx` | 位置无关可执行，基址被 ASLR 随机化 |
| **堆 heap** | 紧跟数据段往上，`0x55xx…` 或 `0x0000xxxx…` 低段 | `malloc/new` |
| **mmap / 共享库 / 栈** | **`0x7Fxxxxxxxxxx`** | 用户空间高端，`.so`、大 malloc、线程栈都在这 |
| **主线程栈** | `0x7FFFFFFFxxxx` 附近 | 靠近用户空间顶 `0x00007FFF…` |
| **内核空间** | **`0xFFFFxxxxxxxxxxxx`** | 高 128TB，`0xFFFF8…` 起，用户态不可访问 |

## 四、【核心技能】看地址秒判类型

这是本篇最实用的部分。拿到一个地址（gdb 里、core 里、日志里），**先看它落在哪个数量级，就能八九不离十判断类型**。

### 4.1 64 位速判表（最常用）

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<c>> #E3F2FD
  BorderColor<<c>> #1976D2
  BackgroundColor<<h>> #C8E6C9
  BorderColor<<h>> #388E3C
  BackgroundColor<<m>> #FFF9C4
  BorderColor<<m>> #F9A825
  BackgroundColor<<k>> #FFCDD2
  BorderColor<<k>> #C62828
}
rectangle "0x400000 ~ 0x4xxxxx\n**代码/全局数据**(非 PIE)\n.text/.data/.bss" <<c>> as A
rectangle "0x55xx… (6 字节)\n**代码/堆**(PIE 程序)\n随机基址,ELF 映像或堆" <<c>> as B
rectangle "0x7Fxx… (6 字节, 接近 0x7FFF…)\n**栈 / mmap / 共享库**\n局部变量、.so、大块 mmap" <<m>> as C
rectangle "0xFFFFxxxx… (高位全 F)\n**内核空间**\n用户态不该拿到这种地址" <<k>> as D
note bottom of A : 数字'小'(百万级)\n→ 程序自身低段
note bottom of C : 数字'大'(0x7F 打头)\n→ 栈/库/映射
@enduml
```

**一句话口诀**（64 位）：

| 地址长这样 | 多半是 | 为什么 |
|-----------|--------|--------|
| `0x400000`~`0x4fffff`（小、6 位十六进制）| **代码 / 全局 / bss**（非 PIE）| 传统固定低基址 |
| `0x55…`（12 位十六进制、`55` 打头）| **代码或堆**（PIE 程序）| ASLR 把 PIE 映像放这一带，堆紧随其后 |
| `0x7f…`（`7f` 打头，接近 `0x7fff…`）| **栈 / 共享库 / mmap** | 用户空间高端，栈从顶往下、库 mmap 在这 |
| `0x7ffd…` / `0x7fff…`（更靠顶）| **栈**（局部变量）| 主线程栈贴着用户空间顶部 |
| `0xffff8…`（高位全 f）| **内核地址** | 用户态程序里出现它 = 十有八九是 bug |
| `0x0` 或 `0x8`、`0x10` 这种极小值 | **空指针 / 野指针** | 解引用它就是经典 SIGSEGV |

> 实战直觉：**gdb 里一个变量地址是 `0x7fffffffe4a0` → 栈上的局部变量；是 `0x555555xxx` → PIE 程序的代码或堆；是 `0x7ffff7xxx` → 某个 `.so` 里的东西；是 `0x0`/`0x18` 这种 → 空指针加偏移，崩溃元凶。**

### 4.2 32 位速判

| 地址长这样 | 多半是 |
|-----------|--------|
| `0x0804xxxx` | **代码 / 数据**（传统 `.text` 从 `0x08048000` 起）|
| `0x0809…`~`0x08xx…` 稍高 | **堆**（紧跟数据段）|
| `0xb7xxxxxx` / `0xb6…` | **共享库 / mmap** |
| `0xbffff…` | **栈**（用户空间顶端 `0xC0000000` 以下）|
| `0xc0000000` 以上 | **内核空间** |
| `0x0` / 极小值 | **空指针** |

## 五、拿真实进程验证：`/proc/pid/maps`

判断不用猜——**每个运行中的进程都有一张真实的地址映射表**，`cat /proc/<pid>/maps` 直接看每段的地址范围、权限、映射的文件：

```bash
cat /proc/self/maps        # 看当前 shell 进程的布局
cat /proc/$(pgrep cpu_demo)/maps    # 看仓库 demo 的布局
```

输出长这样（64 位、精简）：

```bash
555555554000-555555555000 r-xp ... /path/cpu_demo   ← 代码段(.text,r-x)
555555754000-555555755000 rw-p ... /path/cpu_demo   ← 数据段(.data/.bss,rw-)
555555755000-555555776000 rw-p ... [heap]           ← 堆
7ffff7a00000-7ffff7bcd000 r-xp ... /lib/libc.so.6   ← 共享库代码
7ffffffde000-7ffffffff000 rw-p ... [stack]          ← 栈
ffffffffff600000-...      --xp ... [vsyscall]        ← 内核提供的特殊页
```

读这张表的要点：

- **每行 = 一段连续虚拟内存**：`起始-结束 权限(rwxp) 偏移 设备 inode 文件路径`。
- **权限 `r-xp`**（读+执行）几乎必是**代码段**；**`rw-p`**（读+写、不可执行）是数据/堆/栈。这也是 [W^X 安全策略](/concepts/elf/elf-format.md)（可写与可执行互斥，防代码注入）的体现。
- **`[heap]` / `[stack]` / `[vdso]` / 文件路径** 直接标出了段的身份——比猜地址更权威。
- 崩溃时想知道"`rip` 停在哪个段、`rsp` 指的地方对不对"，就是拿寄存器值去这张表里对（见 [../crash/core-dump.md](/crash/core-dump.md)）。

> `maps` 是"看地址判类型"的**标准答案**：先用第四节口诀秒判、再用 `maps`（或 core 里的等价信息）核实。

## 六、六个常见追问

关于布局图,几个最常被问到、也最容易含糊的点,一次讲清。

### 6.1 栈的空间有多大?

**主线程栈默认 8 MB**(Linux 典型值),但它是**软限制、按需增长**的:

- 内核不会一上来就给栈分 8MB 物理内存,而是先划一段虚拟地址区间,**用到多少、缺页分多少**(见 [../cache/tlb.md](/concepts/cache/tlb.md) 的缺页)。栈往下长时踩到未映射页,内核自动向下扩(到上限为止)。
- 上限由 `ulimit -s`(`RLIMIT_STACK`)控制,查/改:`ulimit -s`(默认常是 8192 KB)。
- **超过上限 = 栈溢出**:再往下长踩到栈区之外的**保护页(guard page)** → 触发 `SIGSEGV`(段错误)。深递归、栈上开巨大数组(`char buf[10*1024*1024]`)、`alloca` 过量都会撞它。
- **线程栈**不同:`pthread_create` 时由 `pthread_attr_setstacksize` 决定,默认也常是 8MB,但**从 mmap 区分配**(见下)。

### 6.2 不同线程的栈都在图里那个 stack 段吗?

**不是。** 图里标 `[stack]` 的那一段**只是主线程的栈**(贴着用户空间顶端 `0x7fff…`)。

- **其他线程的栈是 `pthread_create` 时 `mmap` 出来的**,落在**中间的 mmap 区**(`0x7f…` 一带),和 `.so`、匿名映射混在一起——**不在 `[stack]` 段**。
- 所以多线程程序的 `/proc/pid/maps` 里,`[stack]` 只有一个(主线程),其余线程栈表现为一段段普通的匿名 `rw-p` 映射。
- 这也是 [../process/thread-creation.md](/concepts/process/thread-creation.md) 讲的"每线程独立栈"的落地位置。

### 6.3 一个线程会踩到别的线程的栈吗?

**正常情况下不会,但栈溢出时可能。**

- 各线程栈是**独立的 mmap 区间**,彼此隔开,还在末尾放**保护页(guard page,通常 1 页,不可访问)**。正常使用各写各的,不会互相踩。
- **但栈溢出可能击穿**:若某线程栈猛涨越过 guard page,轻则触发 `SIGSEGV`(guard page 拦住,这是设计目的),重则——历史上著名的 **Stack Clash** 漏洞——若一次分配跨度太大**跳过**了 guard page,就可能落到相邻线程栈或堆上,**悄悄改写别人的数据**(不崩、但数据损坏,极难排查)。现代内核用更大的 guard gap 缓解。
- 实践:线程栈别开太小、别在栈上放巨型对象、深递归改迭代。

**追问:所以线程真能写坏别的线程的栈(只要跨过 guard page)?而且难挡、难发现?** —— **是的,理论上确实如此,这正是 Stack Clash 类问题最棘手的地方。** 拆开说清:

- **为什么能写坏**:guard page 只是**一页(4KB)** 的不可访问区。它能拦住的是"栈**一步一步**往下长、刚好踩到这一页"的正常溢出——踩中→SIGSEGV,安全崩掉。但如果程序**一次性把栈指针推下去一大截**(比如 `alloca(很大)`、`char buf[8*1024*1024]` 这种巨型栈对象、超深递归的大帧),就可能**一跳跨过整个 guard page**,直接落到它下面——那里可能是**另一个线程的栈**或堆。此时访问的是**合法映射的内存**,MMU 不报错,于是**悄悄改写了别人的数据**。

```text
   线程B 栈 (高地址) │  ← 正常各写各的
   ─────────────────┤
   guard page (4KB) │  ← 只有一页!正常溢出踩它 → SIGSEGV 拦住 ✅
   ─────────────────┤
   线程A 栈          │  ← alloca(9MB) 一跳跨过 guard page,
                     │     栈指针直接落到这下面 → 踩进堆/别人的栈 ❌
   ─────────────────┤
   堆 / 其他映射      │  ← 被 A "悄悄"写坏,MMU 不报错(是合法内存)
```

- **为什么难挡**:单页 guard page 挡不住"跨度 > 4KB 的一跳"。这正是 2017 年 **Stack Clash** 漏洞的核心——攻击者故意制造大跨度栈增长跳过 guard page,让栈和堆"碰撞"、改写关键数据提权。**缓解**是内核把 guard 区从"一页"扩大成 **guard gap(默认 1MB,`stack_guard_gap` 可调)**,并让编译器给大栈帧插入"栈探测(stack probing,`-fstack-clash-protection`)"——每推进一页就先摸一下,不让它一跳跨过。但**没有一劳永逸**:gap 再大也是有限的,超大 `alloca` 仍可能越过。
- **为什么难发现**:因为它**不崩**。写坏别人的栈/堆不触发任何信号(访问的是合法内存),只是别人的变量莫名其妙变了值——表现为"远处一个无关变量被改乱""偶发、不可复现的诡异行为"。这是最难排查的一类 bug:症状和原因隔着十万八千里。**抓它要靠工具**:[asan.md](/crash/asan.md)(栈越界会被 redzone 抓到)、[valgrind.md](/crash/valgrind.md),而不是等它崩了看 core。

> 一句话:**guard page 只防"小步溢出",防不住"大跨度一跳";跨过去写坏的是合法内存,所以不崩、极难查。** 根治靠"别在栈上放巨型对象/别无界递归/别滥用 alloca"+ 编译器 `-fstack-clash-protection` + 内核 guard gap,查靠 ASan/Valgrind。

### 6.4 mmap 区可以是代码也可以是数据吗?权限怎么控制?

**对,mmap 区什么都能放——放什么、什么权限,由 `mmap` 的 `prot` 参数决定**:

- `mmap` 的 `prot` = `PROT_READ | PROT_WRITE | PROT_EXEC` 的组合,**逐段独立设权限**,落到页表的 R/W/X 位、体现在 `/proc/pid/maps` 的 `rwxp` 列。
- **共享库**:`ld.so` 把 `.so` 的代码段 mmap 成 `r-xp`(读+执行)、数据段 mmap 成 `rw-p`(读+写)——同一个库,不同段不同权限。
- **匿名数据**:大块 `malloc`(glibc 超过 128KB 阈值走 mmap)、线程栈,都是 `rw-p`。
- **W^X 安全策略**:现代系统禁止 `rwxp`(同时可写+可执行),防"写入恶意代码再执行"。JIT(JVM/V8)要动态生成代码,得先 `rw-` 写好、再 `mprotect` 改成 `r-x`,不能一步到位可写可执行(见 [elf-format.md](/concepts/elf/elf-format.md) 的 W^X)。

### 6.5 堆一直涨会怎样?会 OOM 吗?

**会,但"崩在哪"分两种情况:**

- **虚拟地址耗尽**:堆向上、栈/mmap 向下,理论上会撞。但 64 位有 128TB 用户空间,几乎撞不到;**32 位才是真问题**——3GB 用户空间下堆涨到和 mmap/栈打架,`malloc` 返回 `NULL`(或 `new` 抛 `bad_alloc`)。
- **物理内存耗尽(真正的 OOM)**:更常见。`malloc` 只分**虚拟地址**,真正写入时才缺页分物理页。物理内存 + swap 都不够时:
  - `malloc` **可能仍返回非空**(Linux 默认 **overcommit**,先答应你),等你真去写、内核分不出物理页 → 触发 **OOM Killer**。
  - **OOM Killer 杀的是进程,不是线程**——它按 `oom_score` 挑一个进程(通常是吃内存最多的)整个 `SIGKILL` 掉。所以"一个线程狂涨堆"会导致**整个进程被杀**(线程共享地址空间,见 [../process/thread-creation.md](/concepts/process/thread-creation.md))。
  - 看 OOM:`dmesg | grep -i "killed process"`;调 overcommit:`/proc/sys/vm/overcommit_memory`。
- 内存增长本身的排查见 [../memory/free.md](/tools/memory/free.md)、[../cpu/vmstat.md](/tools/cpu/vmstat.md)(si/so swap)。

### 6.6 对比:32 位 vs 64 位内存布局差异

| 维度 | 32 位 | 64 位(x86-64) |
|------|-------|---------------|
| 地址空间总量 | 4 GB(2³²) | 理论 16 EB,实际 **256 TB**(48 位) |
| 用户/内核划分 | 3 GB 用户 / 1 GB 内核,界线 `0xC0000000` | 各 128 TB,中间隔**非法空洞(canonical hole)** |
| 单进程可用内存 | 约 3 GB(致命局限)| 128 TB(实际够用) |
| `.text` 典型基址 | `0x08048000`(非 PIE)| `0x400000`(非 PIE)/ `0x55…`(PIE) |
| 栈顶 | `0xBFFF…` | `0x7FFF…` |
| 共享库/mmap | `0xb7…` 一带 | `0x7f…` 一带 |
| 指针大小 | 4 字节 | 8 字节(结构体更大、cache 装更少,见 [../cache/memory-alignment.md](/concepts/cache/memory-alignment.md)) |
| 非法空洞 | 无(地址连续)| **有**——高 16 位必须全 0 或全 1,否则非法 |

> 一句话:**64 位不只是"地址变长",而是把 32 位那个"3GB 天花板"彻底掀掉(→128TB),代价是指针占 8 字节、且地址空间中间多了个非法空洞。** 判地址的数量级口诀(第四节)也因此不同:32 位看 `0x08/0xb7/0xbfff`,64 位看 `0x40/0x55/0x7f/0xffff8`。

## 七、几个连带要点

- **ASLR（地址空间布局随机化）**：现代系统默认开启，每次运行把栈、mmap、PIE 基址**随机偏移**——所以你两次运行看到的栈地址不同（`0x7ffe…` vs `0x7ffc…`）。数量级不变，判类型不受影响，但别指望地址固定。`cat /proc/sys/kernel/randomize_va_space`（2=全开）。
- **PIE vs 非 PIE**：现代默认 PIE（`0x55…` 随机基址）；`gcc -no-pie` 或老程序是固定 `0x400000`。看到哪种基址就知道是不是 PIE。
- **每个线程有自己的栈**：主线程栈在 `0x7fff…` 顶端，**其他线程的栈是 mmap 出来的**，落在 `0x7f…` 的库映射区一带（见 [../process/thread-creation.md](/concepts/process/thread-creation.md)）——所以多线程程序里"栈地址"不止一段。
- **栈 vs 堆的生长方向**：栈向低地址长、堆向高地址长，理解这个才明白"栈溢出"为什么会踩到低地址、`alloca` 为什么危险。

## 八、和本仓库其他文档的关系

- **上游**：[compile-link-load.md](/concepts/elf/compile-link-load.md)（`.text`/`.data`/`.bss` 怎么来的、execve 怎么把 LOAD 段 mmap 进地址空间）、[elf-format.md](/concepts/elf/elf-format.md)（Segment/LOAD 决定哪些字节进内存、什么权限）。**本篇接着讲"进内存之后"的完整布局**。
- **地址翻译**：[../cache/tlb.md](/concepts/cache/tlb.md)（虚拟地址→物理地址、页）。
- **崩溃分析**：[../crash/core-dump.md](/crash/core-dump.md)——core 里 `PT_LOAD` 就是这套布局的快照；判断 `rip`/`rsp`/野指针落在哪个段，靠的就是本篇的地址速判 + `maps`。
- **线程栈**：[../process/thread-creation.md](/concepts/process/thread-creation.md)（每线程独立栈、mmap 分配）。
- **共享内存**：[shared-memory.md](/concepts/elf/shared-memory.md)——POSIX/System V 共享内存映射落在 mmap 区，在 `/proc/pid/maps` 里显示为 `rw-s`（shared），POSIX 的 `/dev/shm/xxx` 直接可见。
- **观测**：`/proc/pid/maps`、`pmap <pid>`、gdb 的 `info proc mappings`。

## 九、一句话总结

> **进程虚拟地址空间从两端向中间排:低地址是代码(.text)/数据(.data/.bss)、往上是堆(向高长)、中间 mmap 区(.so/匿名映射)、高端是栈(向低长),再上面是用户态碰不得的内核空间。32 位按 3G/1G 划分(用户 0x0~0xBFFF…、内核 0xC000…以上);64 位实际用 48 位、中间隔着非法空洞,用户空间在低 128TB。判地址类型看数量级最快:64 位下 `0x400000` 小段=代码/全局、`0x55…`=PIE 代码或堆、`0x7f…`=栈/库/mmap、`0xffff8…`=内核、`0x0`附近=空指针。口诀先秒判,`/proc/pid/maps` 给标准答案——这是看 core、gdb、崩溃日志的基本功。**

