
刚接触操作系统或者底层开发的读者多半都被“逻辑地址、物理地址、虚拟地址”这三个词绕晕过。写 C 语言的时候打印指针编译器告诉你地址是0x7ffe...调试器里看某个变量的地址也是这个数可老师又说 CPU 真正访问内存用的是物理地址。那这个0x7ffe...到底是哪个地址它和物理地址之间到底发生了什么这篇文章就一次把它说透。这篇内容适合三类人看一是正在学操作系统、计算机组成原理的学生二是写 C/C 或者搞嵌入式、内核驱动开发的工程师三是单纯好奇“我电脑里程序跑起来后地址到底是怎么工作的”的普通开发者。我会从三个地址的定义讲起再到程序里打印地址的规律然后手把手拆解一次地址翻译的完整过程最后给出用真实 Linux 系统验证映射关系的实操方法以及我踩过的一些坑。1. 先搞清楚这3个地址到底是不是一回事很多人卡在第一关是因为教材上的定义和开发时的实际用法对不上。严格来说这三个地址并不是同一个东西但在现代操作系统里它们经常被混着叫混久了就乱套了。1.1 三个地址的一句话定义物理地址是内存硬件层面真正使用的地址。内存条上的每个字节都有一个编号CPU 要读写哪个字节就把这个编号放到地址总线上内存控制器根据这个编号找到对应的存储单元。这个编号就是物理地址范围由硬件决定比如 32 位 CPU 通常有 32 根物理地址线最多寻址 4GB。逻辑地址是程序视角看到的地址在 x86 体系里它的标准形式是“段选择符:段内偏移”也就是段:偏移这样的二元组。编译链接出来的可执行文件里指令和数据的地址最开始也是以这种形式存在。你可以把它简单理解成“程序以为自己在用的地址”。虚拟地址也叫线性地址是分段单元把逻辑地址转换后得到的地址。如果系统同时开启分段和分页虚拟地址还要继续交给分页单元转换成物理地址。在 Linux 这种现代操作系统里段的基地址被设为了 0逻辑地址的数值和虚拟地址完全一样所以大家日常干脆就不区分了。严格来说逻辑地址和虚拟地址还差了一步分段转换。为了方便理解打个比方物理地址是仓库里真实的货架位置逻辑地址是每个人手里那张写了编号的取货单虚拟地址是取货单经过中转中心编号换算后的结果中转中心MMU再根据这个结果把你带到真实货架前。1.2 谁在“翻译”地址MMU 与操作系统的分工地址翻译不是软件做一遍、硬件做一遍那么简单的两段式而是软硬件配合完成的。负责翻译的硬件单元叫MMUMemory Management Unit它嵌在 CPU 内部。当程序执行一条指令比如mov %rax, (%rbx)CPU 拿到的%rbx里的值在当前语境下就是虚拟地址MMU 会把它转成物理地址然后才能访问真实内存。操作系统负责的是给 MMU“喂规则”。它维护了页表、段描述符这些数据结构并把页表的物理地址写入 MMU 的控制寄存器。这样 MMU 才知道如何把一个虚拟页映射到物理页帧。所以整个过程是程序发出虚拟地址请求硬件 MMU 负责翻译操作系统负责建立和维护映射关系。小心区分很多教材习惯说“CPU 发出的是物理地址”严格讲 CPU 在执行指令时内部流水线先拿到的是虚拟地址最后由 MMU 输出物理地址访问内存。这一点理解错了后面看 TLB、页表都会别扭。1.3 操作系统为什么要“多此一举”如果不搞虚拟地址直接让程序用物理地址最直接的后果就是两个程序没法安全地同时运行。程序 A 写在地址0x1000的数据程序 B 也往0x1000写互相覆盖系统直接崩溃。有了逻辑地址和虚拟地址这一层抽象每个进程都拥有一个从零开始的、独立且连续的地址空间。进程 A 的0x1000可以被操作系统映射到物理地址0x100000进程 B 的0x1000可以被映射到物理地址0x200000互相看不见。这就像每个程序都拿到一套从 1 开始的房号而真正的房间分布由物业统一调度物业就是 MMU 加操作系统。这层抽象还带来了三个额外好处进程隔离、内存管理灵活、按需分配。程序可以认为自己拥有从 0 到最大地址的整片空间实际用多少物理内存由操作系统在运行时分配物理内存碎片也能通过页映射重新规整。2. 从一条 C 语言指针说起程序里看到的那个地址是什么概念说再多不如跑一段代码。这一节我带你亲手看看程序里打印出来的地址到底长什么样、分布有什么规律。2.1 一段打印地址的代码拿 Linux x86-64 环境举例用 gcc 编译下面这段代码#include stdio.h #include stdlib.h int global_var 42; void func(void) { int stack_var 0; printf(func code addr : %p\n, (void *)func); printf(stack var addr: %p\n, (void *)stack_var); } int main(void) { int stack_var 0; int *heap_ptr malloc(1024); printf(main code addr : %p\n, (void *)main); printf(global var addr: %p\n, (void *)global_var); printf(main stack addr: %p\n, (void *)stack_var); printf(heap ptr value : %p\n, (void *)heap_ptr); func(); free(heap_ptr); return 0; }编译运行现代 Linux 默认开启 PIE也就是地址随机化gcc -o addr_demo addr_demo.c ./addr_demo我这边一次运行的结果是main code addr : 0x55c0d5f4a1a9 global var addr: 0x55c0d5f4b014 main stack addr: 0x7ffcf4143e5c heap ptr value : 0x55c0d5f6b2a0 func code addr : 0x55c0d5f4a196 func stack addr: 0x7ffcf4143e14仔细观察这些地址有几个非常明显的特征。代码段里的main和func地址挨得很近都落在0x55c0d5f4...这一段全局变量也在0x55c0d5f4...这一段但它和代码段的地址差了一段位置堆上的指针值在0x55c0d5f6...和全局变量只差大概一个段栈变量则在完全不同的0x7ffcf414...区域而且手动调用一次func()后里面的栈变量和main里的栈变量地址差了几十个字节方向是往下长低地址方向。这些地址没有一个是物理地址全部是虚拟地址。整个过程里程序没有权限看到自己的物理地址除非通过内核提供的特殊接口去查询。2.2 地址随机化为什么每次运行都不一样你会发现每次运行程序这些地址都会变化。这是 Linux 内核默认开启的地址空间布局随机化ASLR在起作用。它把栈、堆、映射基地址随机化防止恶意程序利用固定地址发起攻击。如果你希望地址固定可以用setarch $(uname -m) -R ./addr_demo临时关闭 ASLR或者编译时加上-no-pie并把内核的kernel.randomize_va_space设为 0。但在排查问题时地址随机化的影响不大关键是“区间分布”是稳定的栈永远在高地址区域0x7fff...堆和可执行文件映射通常在0x55..或0x56..附近共享库映射通常落在0x7f...。从这些地址数值也能看出一个关键信息现代 x86-64 系统只实现了 48 位虚拟地址但地址看起来比 32 位程序里的大得多。用户态可用的虚拟地址范围是从0x0000000000000000到0x00007fffffffffff也就是大约 128TB 的用户空间。0x7ffc...看起来已经很大其实离用户空间的顶0x00007fffffffffff并不远。cat /proc/self/maps这条命令能列出当前 shell 进程的完整虚拟内存布局。你会看到每行都是起始地址-结束地址后面跟着权限位和映射文件。比如55a3b5c00000-55a3b5e2d000 r-xp 00000000 08:01 12345 /bin/bash 55a3b5e2d000-55a3b5e72000 rw-p 0002d000 08:01 12345 /bin/bash 7ffce618b000-7ffce61ac000 rw-p 00000000 00:00 0 [stack]r-xp中的r表示读权限、w写权限、x执行权限最后的p表示私有映射。[stack]那一行就是栈区。多跑几个程序、多打开几个进程你会慢慢形成对虚拟地址空间布局的直觉。3. 地址翻译全流程拆解现在到了最核心的一节。了解了地址长什么样我们再深入到底层看看一个虚拟地址是如何一步步变成物理地址的。3.1 x86 的分段与分页逻辑地址到物理地址的两步走x86 体系最经典的寻址流程是“分段 分页”。逻辑地址由“段选择子”和“段内偏移”组成。CPU 拿到逻辑地址后先去全局描述符表GDT或局部描述符表LDT里查段选择子对应的段描述符得到段基址然后段基址 段内偏移就得到线性地址也就是虚拟地址。注意这里有个关键细节在 x86 保护模式下段选择子本身不直接包含基址只是一个索引需要查表。而 Linux 的实现很简单把用户态和内核态的两个主要数据段的基址都设成了 0段的长度设为整个可寻址空间。这样无论段内偏移是多少0 偏移结果都等于偏移本身逻辑地址在数值上就等于线性地址。在 Linux 这种“段基址为 0”的实现下逻辑地址和虚拟地址数值相同所以大家混着叫问题不大。但你得知道这是在操作系统刻意简化分段之后的结果不是硬件天然的行为。ARM 等架构没有强制分段直接就是虚拟地址到物理地址的分页转换。接下来虚拟地址要经过分页单元。这一步是真正的重头戏物理内存被分成固定大小的页帧虚拟地址空间被分成同样大小的页两者通过页表建立映射关系。x86-64 默认页大小是 4KB所以页内偏移是 12 位。3.2 分页的核心页表、页表项、页内偏移页表是存在内存里的多级查找结构每级表项记录着下一级页表的物理地址或者最终数据页的物理帧号。MMU 收到一个虚拟地址后把它拆成几部分页内偏移虚拟地址最低的 12 位直接作为物理地址的低 12 位。其余高位的部分按多级页表规则拆成一级一级的索引。x86-64 的 4KB 页四级页表结构是这样的虚拟地址的 48 位依次拆成 PML4 索引(9位)、目录指针索引(9位)、页目录索引(9位)、页表索引(9位)、页内偏移(12位)。每级索引对应一张 512 项的表每个表项 8 字节所以一张表正好是 4KB物理页帧对齐。为什么不直接用一张大表如果 48 位地址空间全部用手工数组映射4KB 页下需要 2^36 张页表项每个表项 8 字节得占用 500GB 内存这显然不现实。多级结构可以做到“按需建表”比如进程只用了很少的虚拟地址空间那很多中间层表项直接置空不为它们分配实际页表内存占用大幅下降。为了加速地址翻译CPU 里还有一个叫TLB的缓存专门缓存最近用过的“虚拟页号 → 物理页帧号”映射。如果 TLB 命中MMU 不需要访问内存里的多级页表直接得到物理地址如果 TLB 未命中才去逐级查页表。查完再更新 TLB并丢弃最久未使用的项。3.3 用 32 位地址现场算一遍翻译过程理论讲完还是有点虚我们手动算一遍。虽然现在主流是 64 位但 32 位两级页表的计算更直观能帮助你建立起“虚拟地址 索引 索引 偏移”的概念。假设一个 32 位进程页大小 4KB使用两级页表页目录和页表每级索引 10 位。虚拟地址0x12345678的拆分方式如下0x12345678 二进制: 0001 0010 0011 0100 0101 0110 0111 1000 页目录索引 bits[31:22] 0001001000 0x048 页表索引 bits[21:12] 1101000101 0x345 页内偏移 bits[11:0] 0110 0111 1000 0x678所以0x12345678被拆成页目录索引 (0x12345678 22) 0x3FF 0x48 页表索引 (0x12345678 12) 0x3FF 0x345 页内偏移 0x12345678 0xFFF 0x678假设系统的页目录在物理地址0x100000。CPU 取页目录第0x48项查到该项的值是0x12345678所在页面所属页表的物理地址比如页表基址为0x2000000。再取页表第0x345项这一项记录着最终物理页的帧号假设它的值是0x12C4_067。低 12 位的0x067是属性位存在位、读写位等真正有用的物理页帧号是0x12C4。最后物理地址就是物理地址 物理页帧号 12 | 页内偏移 0x12C4 12 | 0x678 0x12C4678这一步就是 MMU 每秒要重复成千上万次的操作。CPU 的高速运行和 TLB 的命中率决定了这个翻译过程几乎感知不到延迟。一旦页表项里的“存在位”为 0MMU 就会触发缺页异常交给操作系统判断是真正缺页需要从磁盘换入还是程序访问了非法地址直接报段错误。3.4 64 位下的实际情况64 位系统的地址拆法与 32 位类似只是层级更多。x86-64 当前只使用 48 位虚拟地址高 16 位要求是第 47 位的符号扩展所以合法的用户态地址前 16 位全为 0内核态地址前 16 位全为 1。这也是为什么用户空间栈地址形如0x00007fff...内核地址形如0xffffffff...。4KB 页下四级页表每级索引占 9 位加上 12 位页内偏移正好覆盖 48 位。如果是 2MB 大页则少一级索引页内偏移占 21 位如果是 1GB 大页再少一级偏移占 30 位。大页可以显著减少 TLB miss检索大数据量程序性能时可以考虑使用。注意Intel/AMD 的物理地址宽度并不等于 48 位常见处理器物理地址位宽是 48 或 52 位。虚拟地址 48 位和物理地址 48 位只是两个维度刚好都常取这个数别混在一起理解。4. 让真实系统说出映射关系光看理论还不够来点动手操作。这一节教你用 Linux 系统自带的接口把一个虚拟地址翻译成物理地址通过真实数据验证前面的原理。4.1 用 pagemap 把虚拟地址翻译成物理地址Linux 内核给每个进程提供了/proc/PID/pagemap文件里面记录了进程每个虚拟页到物理页帧的映射信息。读取方法很简单虚拟页号虚拟地址除以页大小乘以 8 字节就是它对应的页表项在 pagemap 文件里的偏移。下面的小工具可以把当前进程某个变量的虚拟地址翻译成物理地址#define _GNU_SOURCE #include stdio.h #include stdint.h #include fcntl.h #include unistd.h int main(void) { int var 123; uint64_t vaddr (uint64_t)var; uint64_t entry 0; int fd open(/proc/self/pagemap, O_RDONLY); if (fd 0) { perror(open pagemap); return 1; } // 每页偏移 8 字节 off_t pos (vaddr / 4096) * 8; if (pread(fd, entry, 8, pos) ! 8) { perror(pread); close(fd); return 1; } close(fd); if (!(entry (1ULL 63))) { printf(page not present on virt addr 0x%lx\n, vaddr); return 1; } uint64_t pfn entry ((1ULL 55) - 1); uint64_t phys (pfn 12) | (vaddr 0xFFF); printf(virtual addr 0x%lx\n, vaddr); printf(pagemap entry 0x%lx\n, entry); printf(pfn 0x%lx\n, pfn); printf(physical addr 0x%lx\n, phys); return 0; }编译运行需要 root 权限新内核还要求进程有CAP_SYS_ADMIN能力。我在 CentOS 和 Ubuntu 上执行时遇到过直接输出“page not present”的情况通常是权限不够或者页面被换出。另外注意这个接口能查到的是“当前物理页”如果页面不在内存里读出来的 entry 的 present 位为 0pagemap 不会触发换入。我在实际调试一段测试程序时看到的输出virtual addr 0x7ffd1e47c91c pagemap entry 0x800000000c12c4 pfn 0xc12c4 physical addr 0xc12cc91c0x800000000c12c4里最高位的 1 就是 present 位表示这个页真实在物理内存里。剩下的0xc12c4就是物理页帧号拼上页内偏移0x91c得到物理地址0xc12cc91c。这个操作只适合学习和验证。生产环境里普通程序根本不应该知道自己的物理地址而且访问其他进程的 pagemap 属于敏感操作只能在明确授权和合规场景下使用。4.2 解析 /proc/PID/maps 和 smaps如果你想看的是进程的虚拟内存布局而不关心物理页帧那么/proc/PID/maps是首选。它的每一行对应一个虚拟内存区域格式为地址范围 权限 偏移 设备号 文件inode 路径路径为[heap]表示堆[stack]表示栈[vdso]表示内核提供的快速系统调用虚拟映射有文件路径的则是可执行文件或共享库的映射。/proc/PID/smaps则在每段区域下面额外给出了 RSS、PSS、Private 等详细信息。RSS 表示这段虚拟区间实际驻留在物理内存中的大小单位是 kB。调试内存占用时RSS 比任务管理器里显示的虚拟大小更接近实际情况。你可以这样用# 查看当前 shell 的内存布局 cat /proc/self/maps | head -20 # 查看指定进程的详细信息 cat /proc/1234/smaps | grep -A 10 \[heap\]用这些信息排查“为什么我的程序内存涨这么高”时第一眼就看 RSS 和匿名映射区的大小。如果你发现一个内存区间的 RSS 很大但代码里没怎么申请多半是有内核态数据或者共享库被拉进来了再结合地址范围就能定位是栈溢出、堆膨胀还是共享内存增长。5. 常见问题与避坑速查表学完理论和实操这里整理一份我平时带人入门时最常遇到的问题速查表基本覆盖了新手问过的高频问题。问题原因解决/说明打印指针的地址是物理地址吗不是程序看到的都是虚拟地址需要物理地址只能通过 /proc/PID/pagemap 等接口查询两个进程里变量地址相同为什么互不影响各自有独立页表虚拟页映射到不同物理页帧这是进程隔离的基础不用胆心冲突32 位系统是不是最大只能装 4GB 内存32 位虚拟地址空间是 4GB但 PAE 可支持更大的物理内存别把虚拟地址空间和物理内存容量混成一回事malloc 返回的地址为什么不是连续的堆区既可以用 brk 扩展也会用 mmap 映射匿名内存虚拟地址可以不连续物理页更不要求连续地址连续性没有语义保证不要依赖它做偏移计算为什么栈地址总是 0x7ff.... 开头64 位用户态地址空间最大值是 0x00007fffffffffff栈被定位在用户空间高地址区域与硬件、内核布局有关只作观测习惯即可TLB miss 和缺页中断有什么区别TLB miss 只是缓存未命中页表可能合法缺页中断是页表项 present 位为 0前者硬件自动补查页表后者需要陷入操作系统处理段错误和地址翻译有什么关系访问了没有映射的虚拟页或者违反权限比如写只读页最终由缺页异常处理时判定为非法访问内核发送信号杀进程再补几个容易栽坑的点都是我自己遇到过或者帮人排查过的。坑一不要在用户态假设虚拟地址连续对应物理地址连续。比如两块内存分别是malloc分配且地址差 0x1000物理地址完全可能跨两个相隔很远的页帧。搞 DMA 操作或者想拿用户态缓冲区的物理地址去做硬件操作绝对不能靠“地址贴一块所以物理连续”来推断。坑二调试时看到的地址可能因为 ASLR 每次变化别写死地址做断言。写自动化测试时如果断言地址等于某个固定值开 ASLR 的直接挂。正确做法是用/proc/self/maps找到区域边界再判断地址是否落在合法区间内。坑三32 位地址拆页时注意符号扩展和位数。有些代码在 64 位系统上直接对指针做22结果把高 16 位符号扩展带进来索引算得乱七八糟。稳妥写法是先(uintptr_t)addr 0xFFFFFFFF强制截断再做移位。坑四不要以为内核线程和用户进程的地址翻译规则完全一样。内核线程访问的地址同样走 MMU 分页但内核空间地址的页表映射是全局共享的和每个用户进程的独立页表不同。排查内核模块问题时要多留意这个差异。坑五别忘了 TLB 是有状态的。修改页表后需要主动刷新 TLB否则 CPU 可能继续用旧的缓存映射访问已经失效的内存。用户态程序感知不到这个操作因为内核在munmap、mprotect等系统调用后会替你刷新你要是写内核模块或者裸机程序一定记得手动维护。6. 实操心得一点个人经验我个人在实际调试中最多的是用“三地址验证法”定位问题。第一步在出问题的进程里打印关键变量的虚拟地址第二步看/proc/PID/maps确认这个地址属于堆、栈还是匿名映射第三步如果怀疑和物理页有关再用 pagemap 确认页是否驻留。这个套路帮我定位过好几次堆破坏和内存泄漏的根因。比如有一次程序运行一段时间后某个指针明明没变但解引用就崩溃一查发现是堆上的对象被越界写坏了变量本身的虚拟地址始终在堆区但该页物理页已被换出过再换入时数据就变了。最后再分享一个小技巧想让地址翻译逻辑可视化地印在脑子里可以把/proc/self/pagemap的读取代码封装成一个addr2phys(void *addr)函数放在自己的调试工具库里。以后面试或者写底层代码时遇到“这地址到底在哪”的疑问一条命令就能验证比死记概念有用得多。断页、换页、大页、NUMA 绑定这些特性也都是从理解了这三类地址之后才能更好地上手。