资讯动态

一文彻底讲清逻辑地址、虚拟地址与物理地址:从printf到DMA

发布时间:2026/9/18 21:22:04 来源:尧图企业网站定制
写这段内容之前我先摸个底很多人把“逻辑地址、物理地址、虚拟地址”当成三个可以互相替换的名词结果一进内核态或者一涉及设备驱动就发现全乱套了。我更愿意把这篇文章定位成一次“把地基挖开给你看”的复盘而不是教科书复述。我会先从一个最普通的打印语句讲起再一路走到页表、TLB、内核模块和DMA把三种地址的来龙去脉和踩坑点全部摊开。1. 从一句最普通的printf说起你到底打印了个什么地址1.1 同一个数三种解释居然都成立先看一段几乎所有C程序员都写过的东西#include stdio.h int main(void) { int a 42; printf(a %p\n, (void *)a); return 0; }编译运行后屏幕上会蹦出一个类似0x7ffd3a4b2c14的数字。问题来了这个数到底是逻辑地址、物理地址还是虚拟地址从C语言角度看这是“指针值”是编译器在虚拟内存布局里分配给变量a的位置从操作系统角度看这是进程用户态虚拟地址真正访问时要通过页表找到对应的物理页从CPU硬件角度看如果MMU没有参与这个数就会被直接放到地址总线上所以它也可以被视为“未经MMU翻译的、站在硬件入口处的一个地址”。同一个十六进制数站在不同抽象层上能给出完全不同的答案。这就是所有混淆的根源。1.2 结论先行在Linux用户态它是个虚拟地址这里我先给一个以后遇到类似问题可以直接套用的结论在Linux用户态程序里你打印出的指针值本质上是进程虚拟地址。理由不复杂现代Linux运行在开启MMU的分页模式下用户态程序发出的任何地址都会经过页表翻译才能变成RAM芯片认识的物理地址。用户态代码根本没有权限绕过MMU去直接访问物理地址。所以严谨地说printf打印的是“虚拟地址”如果按x86分段机制的术语较真它其实是“逻辑地址 段选择子 : 偏移”但因为Linux把用户态所有段基址都设成了0数字上与虚拟地址完全一致。 正是因为“段基址为0”这件事让虚拟地址和逻辑地址在Linux用户态几乎可以当作同一个东西看待——但在内核态和嵌入式环境里再这么混用就会出大问题。2. 三种地址的“官宣定义”一条一条理顺2.1 逻辑地址程序自己构建的坐标系在操作系统教材里逻辑地址通常被描述成“CPU生成的地址”在x86保护模式下它由段选择子和段内偏移两部分组成写作段选择子:偏移的形式比如0x23:0x00401000。段选择子不是一个普通数字它内部有明确分工高13位段描述符在GDT或LDT中的索引TI位选择GDT0还是LDT1RPL请求特权级0最高3最低。CPU拿到这个逻辑地址后会先去GDT或LDT里找到对应的段描述符拿到段基址然后执行线性地址 段描述符中的段基址 段内偏移这一步是硬件自动完成的操作系统只需要提前铺好GDT/LDT表。可以这样类比逻辑地址像是“第3排第5座”你得先找到“第3排”在哪条通道查段表再从通道口的起点往前走5个座位才能定位到具体的人。需要特别指出的是分段机制在现代Linux里几乎被架空了。内核在初始化时把代码段、数据段等所有段的基址都设为0段限长设为整个地址空间。于是“线性地址 0 偏移”逻辑地址在数值上和线性地址完全相等分段逻辑上等于被跳过了但硬件机制还在所以内核代码里仍然能看到段寄存器的身影。2.2 虚拟地址中间那个优雅的翻译层虚拟地址在很多教材里也叫线性地址是分页机制工作的起点。它是“由页目录索引、页表索引、页内偏移”拼出来的一个地址。以64位x86环境为例有效虚拟地址通常只有48位被切分成PML4索引9位对应顶层页目录共512项PDPT索引9位对应页目录指针表PD索引9位对应页目录PT索引9位对应页表页内偏移12位每页大小4KB。转换关系可以理解为CPU拿出虚拟地址逐级查这四张表最后得到“物理页帧号 页内偏移”拼成物理地址。“虚拟”二字才是重点。进程以为自己拥有从0到2^47 - 1的连续空间但这只是MMU和页表营造出来的幻象。真正物理内存可能只有8GB连续、大块、规整都只是虚拟地址空间的编排方式而已。2.3 物理地址内存芯片面前的真身物理地址是最终被放到内存总线上、RAM芯片据以读写数据的地址。它才是硬件世界的“真货币”。举个例子物理地址0x12345000这个页帧完全可以同时被映射进进程A的虚拟地址0x400000和进程B的虚拟地址0x80000000。两个进程各自觉得“这块内存是我自己的”实际上访问的是同一个物理页。这就是共享内存在地址层面得以成立的底层原因。物理地址空间还有一个特点它不全都给内存用。普通PC里一部分物理地址被分配给了MMIO内存映射I/O比如APIC、PCIe配置空间、BIOS/UEFI ROM。在x86的物理地址布局里某些范围即便插了内存条也可能被设备保留区域占用。这导致“物理地址范围小于RAM大小”或“中间有空洞”都很正常。3. 一次内存访问的完整旅程从指令Fetch到数据落回3.1 CPU内部从逻辑地址到线性地址的段机制现在我们把一次真实的读内存过程拆开。在x86实模式下逻辑地址转物理地址很简单物理地址 段寄存器 4 偏移总共20位地址线寻址1MB。保护模式下则复杂一些CPU拿到逻辑地址后根据段选择子中的索引去GDT/LDT读段描述符从描述符中取出段基址和段限长校验有效后算出线性地址。前面说过Linux把段基址设为0所以这一步在数值上是透明转换。但要注意这并不代表段机制可以被彻底无视——指令执行时的特权级检查、I/O权限位图仍然挂在段描述符上只是地址计算本身被简化了。ARM处理器则干脆不走分段路线CPU核生成的直接就是虚拟地址VA通过MMU里的页表翻译成物理地址PA。所以“逻辑地址”这个概念在ARM语境里更像是一个纯软件概念没有硬件寄存器对应的“段”参与。理解了x86和ARM的差异面试时谈起这个话题会显得更扎实。3.2 分页转换TLB快表和缺页异常从虚拟地址查页表得到物理地址听着简单但如果每次都去内存里翻四级页表性能会崩到没法用。四级表意味着一次地址转换最多要访问4次内存再算上取数据本身一次普通读也许要访问5次物理内存这是完全不可接受的。CPU的答案是TLB即转换旁路缓冲器一个容量很小的硬件Cache专门缓存“虚拟页号 - 物理页帧号”的映射关系。命中TLB时地址转换不再需要访问内存只有TLB Miss时硬件才回到页表里逐级查找查完一边更新TLB一边返回结果。页表项上还有几个关键标志位Present位表示该虚拟页是否已经映射到物理页RW位表示是否可写User位表示是否允许用户态访问Dirty位表示页是否被写过Accessed位表示是否被读过。当一个页的Present位为0而程序又去访问它时CPU直接触发缺页异常把控制权交给操作系统。缺页处理程序会根据异常原因决定下一步缺页原因处理方式真正访问的地址非法发SIGSEGV给进程通常表现为段错误页被换出到swap分区从磁盘读回更新页表恢复进程首次访问刚malloc的内存分配物理页把页表项填好重新执行指令写保护触发依据是否为写时拷贝决定复制物理页或报错这个过程对进程是透明的进程感知不到自己错过了一次物理访问。操作系统就是靠这套机制同时完成内存分配、回收、换页和隔离。3.3 实操验证让地址翻译“现出原形”光讲原理容易飘我给你一套能在自己机器上复现的验证方法。先看当前shell的虚拟内存布局cat /proc/self/maps | head输出大致是这个样子00400000-0040d000 r-xp 00000000 08:01 131074 /usr/bin/cat 0060c000-0060d000 r--p 0000c000 08:01 131074 /usr/bin/cat 7f8a1d5fc000-7f8a1d7e4000 r-xp 00000000 08:01 395964 /usr/lib/x86_64-linux-gnu/libc.so.6 7f8a1d9e3000-7f8a1d9e4000 r--p 001e7000 08:01 395964 /usr/lib/x86_64-linux-gnu/libc.so.6 7ffdcae1f000-7ffdcae40000 rw-p 00000000 00:00 0 [stack]每一行的第一列是虚拟地址区间中间的权限标志分别表示可读、可写、可执行、是否私有。右端是映射来源。这个文件揭示了一个关键事实进程看到的地址空间完全由操作系统安排用户态程序只是在使用这些“租来的”虚拟页。再看按需分配的证据。写一个小程序#include stdio.h #include stdlib.h #include string.h #include unistd.h int main(void) { size_t size 1024 * 1024 * 1024; // 1GB char *p malloc(size); printf(virtual: %p\n, p); printf(sleep 10s, check /proc/%d/statm\n, getpid()); sleep(10); memset(p, 1, size); printf(written done\n); sleep(10); return 0; }程序开始后先分配1GB但不写这时看/proc/pid/statm里的resident列会很小等memset执行完再看resident会涨起来。整个过程虚拟内存大小不变物理内存占用却持续上升。这比任何PPT都直观地说明了一件事malloc只是在虚拟地址空间画了个圈真正拿到物理页要靠缺页异常。4. 虚拟内存这层“障眼法”究竟帮我们干了什么4.1 进程隔离A看不到B的地址如果没有虚拟地址这层壳进程A想读进程B的数据只需要在自己的代码里写死一个物理地址就行了。那意味着任何程序都能翻遍整个物理内存找到系统的密码、别的进程的用户数据甚至修改内核代码。虚拟内存用最简单粗暴的方式解决这个恐怖场景每个进程有自己独立的页表虚拟地址空间互不相干。进程A的虚拟地址0x400000和进程B的0x400000落地到物理内存时完全是两个不同的物理页。CPU在切换进程时会刷新/切换页表基址寄存器x86里是CR3ARM里是TTBR从根上切断了跨进程寻址的可能。我们甚至可以做个极端实验fork()之后父子进程同时打印同一个全局变量的地址打印结果完全相同但两个进程各自修改这个变量后彼此看不到对方的改动。地址相同、数据不同这就是虚拟内存隔离的直接证据。4.2 内存超卖物理内存不够也能“撑”着物理内存再大也有限而每个进程的虚拟地址空间却有48位可用几乎是一个天文数字。一台只有8GB物理内存的机器同时跑着加起来虚拟内存超过20GB的进程这在现代系统里很正常。秘密在于操作系统允许“超卖”。进程申请的内存页很多并没有真正分配物理内存即使分配了当物理内存紧张时内核还可以把不常用的页换出到swap分区把物理页腾给更需要的进程。程序访问换出的页时会触发缺页内核再把数据换回来。这背后就是“虚拟地址空间远大于物理地址空间”带来的余量。嵌入式开发者对这个话题可能更有感触。关掉swap后内存紧张时系统不是慢而是直接OOM Kill。这就是虚拟内存小车库塞不下时内核做出的最极端裁决。4.3 共享与写时拷贝fork为什么那么快fork()创建子进程之所以快正是虚拟内存的共享机制在起作用。Linux初始化阶段并不把父进程所有内存页复制给子进程而是让父子进程的页表指向同一批物理页并把这些页全部设为只读。任何一方第一次写这些页时CPU触发写保护缺页内核在缺页处理程序里检查页是否可以被共享如果确实是“写时拷贝”页就把物理页复制一份再把父子进程各自页表更新为指向各自的私有页重新执行写指令。这个设计让fork变得极轻量也让多个进程共享同一个libc.so时只在物理内存里保留一份代码段成为可能。这里有个值得品味的细节操作系统在“正确的时机”才付出复制成本。简单说就是“该省的时候省该花的时候花”虚拟内存把所有物理页的分配和共享都调度得明明白白。4.4 简化链接与加载从0x400000开始的执着在同一个发行版里你去反汇编不同程序会发现大量程序的代码段虚拟地址都从0x400000附近开始。这不是巧合而是虚拟内存带来的福利。编译器、链接器不需要关心程序未来会被放到物理内存的哪个角落它们只要按照操作系统约定的可执行文件布局生成“以虚拟地址为基准”的指令就行。加载器把可执行文件映射到进程虚拟地址空间MMU负责后续翻译。ASLR地址空间布局随机化在此基础上增加了一点随机偏移让每次启动程序时libc基址、栈地址都不一样增加漏洞利用难度。但它并没有否定“统一虚拟地址布局”的基石安排只是在统一布局上做了扰动。5. Linux内核态与驱动开发里的地址暗坑5.1 三种地址在内核态的特殊含义到了内核态“逻辑地址”这个词又开始有另一层含义。很多内核教材和驱动程序代码里说的“逻辑地址”指的是通过线性映射区直接映射到物理地址的内核地址典型的例子是kmalloc返回的指针。具体来说x86_64上内核地址空间的PAGE_OFFSET起一个大段区间被设为直接映射区物理内存被线性地映射到那里因此物理地址 逻辑地址 - PAGE_OFFSET这个转换在内核里由virt_to_phys()完成反过来由phys_to_virt()完成。注意这个公式在大多数默认配置下成立但碰到vmalloc区、vmemmap、kasan shadow等特殊区域就不成立了不要盲目套用。vmalloc返回的地址就是另一种情况它被称为内核虚拟地址。这段空间在页表层面上是重新映射的虚拟地址连续但物理页面可能分散不能直接用virt_to_phys因为两者之间不存在一个固定的PAGE_OFFSET偏移关系。我把它们整理成一张表方便对照分配方式返回地址类型虚拟地址是否连续能否直接virt_to_phys典型用途kmalloc内核逻辑地址连续可以小块内存、驱动数据结构vmalloc内核虚拟地址连续不可以需要查页表大块稀疏内存、模块加载区ioremap内核虚拟地址由页表映射不能直接转换映射设备寄存器MMIOdma_alloc_coherentCPU虚拟地址连续不能直接但要拿dma_handleDMA缓冲区5.2 实验在模块里打印一个地址的三类翻译写一个极简内核模块来验证这个差异#include linux/module.h #include linux/slab.h #include linux/vmalloc.h #include linux/io.h static int __init addr_demo_init(void) { char *kptr; char *vptr; kptr kmalloc(4096, GFP_KERNEL); printk(kmalloc: virt%px phys%pa\n, kptr, virt_to_phys(kptr)); vptr vmalloc(4096); printk(vmalloc: addr%px\n, vptr); /* 对vmalloc( )结果直接调用virt_to_phys( )结果是不可靠的 */ vfree(vptr); kfree(kptr); return 0; } static void __exit addr_demo_exit(void) { } module_init(addr_demo_init); module_exit(addr_demo_exit); MODULE_LICENSE(GPL);编译加载后看内核日志你会发现kmalloc返回的地址和virt_to_phys转换出的物理地址之间有稳定且线性的关系而vmalloc返回的地址明显落在另一个内核地址区间里它的物理页需要遍历页表才能查出来。这里还要提一个现代内核的细节直接用%p打印内核指针时输出往往是被哈希处理过的假地址抓不到真实信息。调试时要改用%px或者用%pa打印物理地址。我第一次在5.15内核上调试时被这个特性坑了半小时看到打印结果全是乱码样的kptr_restrict保护值还以为是模块写错了。5.3 用户态mmap与物理地址的关系很多做嵌入式或驱动开发的人会有一种错觉用户在用户态mmap一个设备文件返回的地址是不是就是物理地址不是。mmap返回的永远是虚拟地址只是内核通过remap_pfn_range之类接口把设备驱动里的物理页映射到了发起调用的进程虚拟地址空间里。驱动侧的操作逻辑通常是这样的static int my_mmap(struct file *file, struct vm_area_struct *vma) { unsigned long pfn virt_to_phys(dev-buffer) PAGE_SHIFT; return remap_pfn_range(vma, vma-vm_start, pfn, vma-vm_end - vma-vm_start, vma-vm_page_prot); }用户层拿到的是虚拟地址但这个虚拟地址背后的页表项直指设备/内核分配的物理页。于是用户态进程写这个虚拟地址本质上就是在写物理页。但这里必须有驱动配合用户态没有任何办法自行“指定”某块物理内存来映射。如果你想从用户态直接访问物理地址一般是通过/dev/mem配合mmap但绝大多数现代系统都会用CONFIG_STRICT_DEVMEM限制这种访问普通权限根本打不开这也是内核的安全防线之一。5.4 DMA场景里的物理地址焦虑DMA是物理地址存在感最强的地方。设备要搬数据CPU把源地址、目的地址直接写到设备寄存器里设备自己通过总线访问内存完全不经过CPU的MMU。所以DMA描述符里填的必须是物理地址。驱动开发中常见的坑是拿到kmalloc或dma_alloc_coherent返回的CPU指针后有人顺手把这个指针直接填进DMA描述符结果设备毫无反应或者干脆把数据写坏。这一类问题排查起来非常隐蔽因为从代码上看逻辑完全正确但从地址类型上看从一开始就错了。正确做法是用dma_alloc_coherent申请缓冲区它返回两个值一个是CPU可以访问的虚拟地址另一个是设备能用的DMA地址通常是物理地址。两个地址必须分开保存、分开使用。5.5 我在调试PCIe设备时踩过的地址坑有次调试一块PCIe采集卡驱动已经能正确request_region并映射BAR空间但读写寄存器总是读到全0xF。查了一上午最后发现是设备树里配置的物理地址和驱动里ioremap用的地址差了一个偏移。ioremap返回的是虚拟地址但设备树和寄存器手册里给的是物理地址。用ioremap之前必须确保自己手里的地址是物理地址这个前提一旦错了后面所有调试方向都会白费。还有一次是用户态测试程序里我试图用一个在驱动里打印出来的“物理地址”直接在用户态访问结果自然是段错误。后来才意识到用户态根本没有办法直接访问物理地址必须通过mmap让内核把对应物理页映射进进程空间。这个“用户态无法摸到物理地址”的直觉越早建立越少踩坑。6. 一轮自测题和可复现的小实验6.1 五道高频自测题我整理了几道自己平常也拿来问身边同事的题答案都写在后面建议先自己过一遍脑子再往下看。第一题在Linux用户态程序里printf打印出的变量地址是什么类型答案虚拟地址更准确地说是进程虚拟地址空间里的一个用户态虚拟地址。在x86分段语义下可以叫逻辑地址但由于Linux用户态段基址为0数字上和虚拟地址相同。它绝不是物理地址。第二题经典教材里“逻辑地址段选择子:偏移”在现代Linux用户态还有意义吗答案硬件上还保留着但Linux通过把所有用户态段基址设为0让逻辑地址到线性地址的计算形同直通。所以这个公式在理解x86保护模式时非常关键但在Linux用户态实际调试时你很少需要真的去查GDT。第三题为什么用户态指针不能直接访问物理地址答案因为现代CPU开启MMU后用户态发出的所有地址都要经过页表翻译CPU不提供“绕过MMU直接上总线”的用户态路径。能直接放到地址总线上的地址只有物理地址而物理地址的访问权限被严格留在内核态。第四题virt_to_phys为什么不能用于vmalloc返回的地址答案因为kmalloc分配的内存位于直接映射区虚拟地址与物理地址之间存在固定的PAGE_OFFSET偏移关系。而vmalloc分配的内存位于另一个专门的虚拟地址区域映射关系是动态建立的必须先遍历页表才能找到物理地址不能靠简单做差。第五题TLB Miss一定会去访问内存中的页表吗答案如果是硬件页表遍历Hardware Page Table WalkMMU会自己按页表层级访问内存完成翻译但多数处理器还会在软件层面提供异常处理入口允许操作系统介入。无论哪种方式最终都会回到页表中找到对应项差别只是由硬件自动走完还是交给软件处理。6.2 一条命令看到进程地址空间全貌你可以随手在终端里执行sudo cat /proc/1/maps这是系统1号进程的虚拟内存映射。把它和/proc/iomem里的物理地址分配对比一下你能直观看到内核把自己的代码、数据映射到高地址区间而设备寄存器则分布在物理地址的低区域。两个文件一个讲虚拟空间一个讲物理资源对照看能极大加强“两种地址是完全不同的地图”这个印象。再看物理内存实际状态free -h cat /proc/meminfo | grep -E MemTotal|SwapTotal|Committed_ASCommitted_AS经常远大于物理内存总量这就是前面讲的内存超卖在数据上的体现。虚拟内存允许系统在一个时间点上承诺比物理内存多得多的地址空间只要不同时被全部访问就行。6.3 我踩过且值得你避开的三个坑第一个坑是给DMA控制器填地址时填成了kmalloc的返回值设备拿到一个虚拟地址直接去访问一段它根本不该碰的物理内存导致随机内存被改写。现在我做任何涉及设备地址的代码都会先确认struct device、dma_addr_t类型字段里存的是物理地址还是虚拟地址绝不混用。第二个坑是调试时用%p打印内核地址结果看到的是哈希保护值完全定位不了问题。后来才意识到现代内核默认对指针打码要用%px才能拿到真实地址。调试阶段我习惯直接%px上线前再把敏感打印清理掉。第三个坑是用/dev/mem从用户态读物理地址却忘了CONFIG_STRICT_DEVMEM的限制。这个配置在几乎所有主流发行版里都是默认打开的普通用户甚至root要绕过都需要额外配置。与其跟这个机制硬刚不如写个临时内核模块用ioremap或memremap去访问物理地址再通过/proc接口把结果暴露给用户态。6.4 后续还可以往哪个方向深入如果这三个概念你已经理清了下一个值得研究的点是CGroup和容器场景下的内存回收策略再往下是NUMA架构里“物理地址和节点距离”的关系。CPU在不同NUMA节点访问内存的延迟差异非常大驱动在分配大块内存时如果不指定node性能会莫名其妙地波动。那又是另一个由“物理地址”引出的漫长故事了。从我个人经验来说把逻辑地址、虚拟地址、物理地址彻底分清楚不是靠背定义而是靠一次次在内核日志、设备树、页表项和gdb输出里反复对照。只要你真正动手做过一次地址翻译的验证以后再遇到“这段地址怎么不对”的问题第一反应就会是先弄清手里拿的是哪种地址。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价