进程内存布局 —— 32 位 / 64 位地址空间长什么样,看地址秒判类型
compile-link-load 讲了
.text/.data/.bss怎么在链接时拿到地址、程序怎么被加载。但程序跑起来之后,整个虚拟地址空间到底怎么排布——栈在哪、堆在哪、.so映射在哪、内核占哪一段——是另一个独立且极实用的话题。尤其在看 core dump、gdb backtrace、/proc/pid/maps时,扫一眼一个地址就能判断"这是栈变量 / 堆对象 / 全局量 / 代码 / 共享库",是排查崩溃和内存问题的基本功。
本篇讲:32 位和 64 位进程的虚拟地址空间布局各长什么样、各段的典型地址范围、以及怎么根据一个地址的数值快速判断它属于哪一类。
一、先立三个地基概念
- 每个进程有独立的虚拟地址空间:进程看到的地址全是虚拟地址,由 MMU+TLB 翻译成物理地址(见 tlb)。所以两个进程的
0x400000指向各自不同的物理内存,互不干扰。 - 地址空间分用户空间 + 内核空间:低地址一大片是用户空间(进程自己的代码/数据/堆/栈/库),高地址一段是内核空间(所有进程共享同一份内核映射,但用户态无权访问,碰了就 SIGSEGV)。
- 布局从两端向中间生长:代码/数据在低地址固定住;堆向上长(低→高),栈向下长(高→低),中间是 mmap 区(
.so、匿名大块内存)。堆和栈相向生长,中间的空洞就是可扩展的余量。
二、32 位进程布局(经典的 3G/1G 划分)
32 位地址空间总共 2³² = 4 GB。Linux 经典划分:低 3 GB 给用户空间,高 1 GB 给内核(0xC0000000 是分界线)。
高地址 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):
高地址 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 位速判表(最常用)
一句话口诀(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 直接看每段的地址范围、权限、映射的文件:
cat /proc/self/maps # 看当前 shell 进程的布局
cat /proc/$(pgrep cpu_demo)/maps # 看仓库 demo 的布局输出长这样(64 位、精简):
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 安全策略(可写与可执行互斥,防代码注入)的体现。 [heap]/[stack]/[vdso]/ 文件路径 直接标出了段的身份——比猜地址更权威。- 崩溃时想知道"
rip停在哪个段、rsp指的地方对不对",就是拿寄存器值去这张表里对(见 core-dump)。
maps是"看地址判类型"的标准答案:先用第四节口诀秒判、再用maps(或 core 里的等价信息)核实。
六、六个常见追问
关于布局图,几个最常被问到、也最容易含糊的点,一次讲清。
6.1 栈的空间有多大?
主线程栈默认 8 MB(Linux 典型值),但它是软限制、按需增长的:
- 内核不会一上来就给栈分 8MB 物理内存,而是先划一段虚拟地址区间,用到多少、缺页分多少(见 tlb 的缺页)。栈往下长时踩到未映射页,内核自动向下扩(到上限为止)。
- 上限由
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映射。 - 这也是 thread-creation 讲的"每线程独立栈"的落地位置。
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 不报错,于是悄悄改写了别人的数据。
线程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(栈越界会被 redzone 抓到)、valgrind,而不是等它崩了看 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 的 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掉。所以"一个线程狂涨堆"会导致整个进程被杀(线程共享地址空间,见 thread-creation)。 - 看 OOM:
dmesg | grep -i "killed process";调 overcommit:/proc/sys/vm/overcommit_memory。
- 内存增长本身的排查见 free、vmstat(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 装更少,见 memory-alignment) |
| 非法空洞 | 无(地址连续) | 有——高 16 位必须全 0 或全 1,否则非法 |
一句话:64 位不只是"地址变长",而是把 32 位那个"3GB 天花板"彻底掀掉(→128TB),代价是指针占 8 字节、且地址空间中间多了个非法空洞。 判地址的数量级口诀(第四节)也因此不同:32 位看
0x08/0xb7/0xbfff,64 位看0x40/0x55/0x7f/0xffff8。
七、几个连带要点
- ASLR(地址空间布局随机化):现代系统默认开启,每次运行把栈、mmap、PIE 基址随机偏移——所以你两次运行看到的栈地址不同(
0x7ffe…vs0x7ffc…)。数量级不变,判类型不受影响,但别指望地址固定。cat /proc/sys/kernel/randomize_va_space(2=全开)。 - PIE vs 非 PIE:现代默认 PIE(
0x55…随机基址);gcc -no-pie或老程序是固定0x400000。看到哪种基址就知道是不是 PIE。 - 每个线程有自己的栈:主线程栈在
0x7fff…顶端,其他线程的栈是 mmap 出来的,落在0x7f…的库映射区一带(见 thread-creation)——所以多线程程序里"栈地址"不止一段。 - 栈 vs 堆的生长方向:栈向低地址长、堆向高地址长,理解这个才明白"栈溢出"为什么会踩到低地址、
alloca为什么危险。
八、和本仓库其他文档的关系
- 上游:compile-link-load(
.text/.data/.bss怎么来的、execve 怎么把 LOAD 段 mmap 进地址空间)、elf-format(Segment/LOAD 决定哪些字节进内存、什么权限)。本篇接着讲"进内存之后"的完整布局。 - 地址翻译:tlb(虚拟地址→物理地址、页)。
- 崩溃分析:core-dump——core 里
PT_LOAD就是这套布局的快照;判断rip/rsp/野指针落在哪个段,靠的就是本篇的地址速判 +maps。 - 线程栈:thread-creation(每线程独立栈、mmap 分配)。
- 共享内存:shared-memory——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、崩溃日志的基本功。