1. 起因:一次诡异的“Segmentation Fault”让我重新审视内存地址
先说件我自己踩过的事。几年前我在某个嵌入式Linux项目上调一个视频采集模块,硬件用DMA直接往某块物理内存里写数据,应用层通过read()去拿。程序大概每跑几十次就会出现一次偶发的“Segmentation Fault”,或者数据错位。当时我第一反应是驱动里buffer越界,查了半天没结果。后来把一个简单的变量地址打印出来看,发现在用户态打印的地址竟然和驱动里看到的物理地址完全不同——那一刻我才意识到,我长期以来根本没有真正理解“地址”这个东西。
后来排查发现,问题根本不在什么复杂的驱动逻辑,而是DMA需要物理地址连续的内存,但我用的是kmalloc的默认行为,在某些内核版本配置下拿到的内存并不满足连续性要求,导致DMA跨越了页边界写坏了相邻页。
就是从那以后,我开始把Linux内存这块从头系统捋了一遍:虚拟地址怎么来、物理地址怎么分、两者之间那条映射链路上每一环到底做了什么、我写的代码每次malloc和free背后内核究竟在干什么。这篇文章就是我整理过的完整笔记,基本按照“从应用层到底层”的顺序来写,希望能帮你把这条链路彻底打通。
这篇文章适合:刚入门Linux驱动开发的同学、搞嵌入式但一直对mmap和DMA糊里糊涂的开发者,以及那些觉得《深入理解Linux内核》太厚啃不动、想先建立一个整体图景再回头扣细节的人。
2. 地址这个词,在不同语境下根本不是同一个东西
2.1 三种地址:虚拟、物理、总线地址
地址是内存系统的灵魂,它决定了一个进程访问内存时到底走了哪条路。
虚拟地址(Virtual Address):每个用户态进程看到的内存,是从0开始编址的、私有且连续的空间。32位系统下理论范围是0到4GB,其中高地址的1GB归内核,低地址3GB归用户进程;64位系统下这个范围变成了一个巨大的地址空间——以x86_64为例,用户空间通常到0x00007fffffffffff,内核空间从0xffff800000000000往上。这个地址空间的数值范围本身并不真实对应物理内存条上的物理位置,只是一个“逻辑编排”。
物理地址(Physical Address):内存颗粒(RAM chips)被编址后的真实地址,是所有内存访问最终落地的地方。CPU访问内存时,经过MMU翻译后的地址就是物理地址。硬件外设通过DMA访问内存时,用的是物理地址(更准确说是总线地址,下文细说)。
总线地址(Bus Address):外设视角看到的地址,比如PCIe设备要通过BAR空间访问内存,CPU侧看到的物理地址需要经过IOMMU或硬件桥接转换为总线地址。在大多数无IOMMU的嵌入式系统里,总线地址和物理地址一致,但这个概念本身一定要知道。
这里用生活中的类比解释:虚拟地址就像你在写字楼里的工位编号(A-12-08),物理地址是这栋写字楼在全球地图上的经纬度坐标。两个工位可以挨着,但它们对应的楼的经纬度可能相隔十万八千里;反过来,一个物理位置也可以被多个工位“映射”到。这种解耦给了操作系统极大的灵活性。
2.2 为什么非要“虚拟”一层:三个根本原因
直接让程序用物理地址难道不行吗?真的不行。主要有三个层面的原因。
第一,隔离与安全。如果没有虚拟地址隔离,任何一个野指针都可能直接改写别的进程的内存,更别说恶意程序扫描整个物理内存寻找密码了。有了每进程独立的页表,进程A永远不可能访问进程B的地址空间,除非通过内核提供的进程间通信机制。
第二,连续的假象。物理内存是碎片化的——你申请一个100MB的连续缓冲区,物理内存里可能根本找不到100MB的连续物理页,都被之前反复分配释放弄得千疮百孔。但虚拟地址天然看起来是连续的,操作系统可以把分散的物理页通过页表映射成一个连续的虚拟地址范围,让用户程序无感使用。
第三,内存共享与惰性分配。多个进程运行同一个程序(比如动态链接的libc),它们的代码段data段完全可以映射到同一批物理页,物理内存只有一份。同时,程序申请了内存但可能根本不会用到,操作系统可以做“overcommit”——先把虚拟地址空间分配出去,等到真正写入触发缺页异常时才实际分配物理页。这就让物理内存的实际使用效率大幅提升,你买的8GB内存才能同时跑几百个进程。
3. 从CPU到内存:MMU和页表这条映射链
3.1 分段与分页:x86的历史包袱与Linux的选择
从CPU设计角度看,x86架构最早提供的是“分段(Segmentation) + 分页(Paging)”两套并存的机制。分段是把地址空间按逻辑段(代码段、数据段、堆栈段)划分,每个段有基地址和长度限制;分页则是把内存切成固定大小的4KB块来管理。
Linux虽然运行在x86上,但Linux几乎完全绕过了分段机制——它把所有的段基址都设成了0,段的长度设成整个地址空间大小。为什么?因为分段产生的地址空间是线性的、不重叠的,没法做到“多个段映射到同一物理区域再各自限定权限”这么灵活。而分页可以:每个页都有独立的物理页映射和权限位(可读、可写、可执行),能做非常细粒度的控制。
Linux的页表结构是四级:PGD(Page Global Directory)→ P4D(Page 4K Directory,x86_64一般是固定一层)→ PUD(Page Upper Directory)→ PMD(Page Middle Directory)→ PTE(Page Table Entry)。每个进程的mm_struct里有个pgd字段指向它自己的顶级页表。
3.2 一次地址翻译的全过程(TLB失效的情况下)
假设一个用户进程访问地址0x00401000。CPU拿到这个虚拟地址后,会把它拆成几个部分:前39位是索引PGD的,往下一层是PUD索引,再往下PMD索引,然后是PTE索引,最后低12位是页内偏移。
第一次访问时,TLB(Translation Lookaside Buffer,页表缓存的硬件单元)里没有这个地址的缓存条目,MMU必须逐级往下走:先从CR3寄存器拿到当前进程的PGD基址,用虚拟地址中的PGD索引找到对应的PGD条目,这个条目里存的是下一级页表(PUD)的物理基址;接着用PUD索引查PUD表……一直到PTE条目,它里面有一个关键字段——物理页帧号(PFN),把PFN左移12位和页内偏移组合,就得到了最终的物理地址。
注意:CPU访问内存页表本身也是一次内存访问,四级页表逐级查会导致访问内存多次。加上TLB就是为了避免每次访问都做这四级查询。TLB命中时,翻译只需要一个时钟周期左右;TLB未命中则可能要几十甚至上百个周期。
3.3 页表项里有什么:不只是“物理地址”这么简单
一个PTE条目(在x86_64上是8字节)包含了远不止物理页号。常用标志位包括:
- Present位(P):表示这个虚拟页是否已经有物理页映射。如果P=0,访问该页会触发缺页异常(Page Fault),内核据此判断是“真的没分配”还是“换出到磁盘了”还是“保护违规”。
- RW位:可写权限,0表示只读。
- User/Supervisor位:用户态程序能否访问。
- NX位:No-Execute,禁止执行——现代系统做DEP(数据执行保护)的基础。
- A/D位:Accessed和Dirty位,分别表示是否有过访问、是否有过写入。内核利用Dirty位做回写判断,利用Accessed位做内存回收的近似LRU。
- PAT位:Page Attribute Table,控制该页的缓存策略(Write-Back还是Write-Through,或者Uncacheable)。
这些标志位是理解很多系统行为的关键。比如mmap文件以后只读映射,PTE的RW位就是0,一旦代码想写入,硬件会直接抛出一个保护异常,内核根据异常的地址和原因返回SIGSEGV给你——这就是“段错误”的很多来源之一。
3.4 TLB和Cache:映射之后的硬件加速
MMU完成地址翻译后,真实的物理内存访问还会经过Cache层级:L1、L2、L3。Cache是按物理地址索引的(VIVT和VIPT混合的架构细节这里不多展开),所以即使虚拟地址相同,物理地址不同也会导致Cache flush,这也是为什么mmap后做mremap等操作会有额外的性能开销。
嵌入式开发中,dma_alloc_coherent分配的内存是带“一致(coherent)”属性的,即页表PTE里PAT位被设置成Uncacheable或不带缓存一致性协议的内存——因为DMA外设直接写内存时不会“通知”CPU的Cache,如果CPU还缓存着旧数据,读取时就会拿到脏数据。理解了这一层,你就能明白为什么驱动代码中要格外区分“DMA方向”和“Cache操作”。
4. 应用层内存分配:malloc的宏大骗局和mmap的真面目
4.1 malloc函数族和底层brk到底是什么
我们在用户态写int *p = malloc(100)时,内存是不是立刻分配的?其实不是。
malloc在glibc中是一个用户态的内存分配器(ptmalloc)。当程序第一次调用malloc申请比较大的一块内存(比如超过128KB,不同版本阈值不同),glibc会直接调用mmap系统调用分配一块匿名映射区;如果申请的小块内存,glibc会从自己维护的堆(heap)中切一块,堆不够时就通过brk系统调用扩展进程的数据段顶。
brk系统调用做的事情是移动进程的mm_struct->brk指针,即调整heap的结束地址。内核把这段地址空间“分配”给了进程,但此时只是虚拟地址空间中的VMA(Virtual Memory Area)被创建并标记为可读写,真正的物理页根本没分配。“分配”这个动作,写代码的人感觉不到,内核也不知道你需要哪些物理页,它只知道一段虚拟地址区间的页表项全都清零了(P位=0)。
直到你第一次往malloc返回的地址里写入数据时,CPU访问该虚拟地址触发缺页,内核才在这个瞬间分配一个真实的物理页、填好PTE、把权限和标志位设定好,然后重新执行刚才触发缺页的指令。这就是“写时分配”和“缺页异常”配合的经典现场。
4.2 匿名映射mmap:直接绕开malloc的路径
很多高性能服务不会用malloc,而是直接用mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)自己创建一段匿名映射。这样好处是分配大块内存时不受glibc堆碎片影响,失败可以直接得到MAP_FAILED,同时底层munmap释放时直接还给内核,不会有”碎片化”的问题。
mmap和malloc+brk还有一个重要差异:mmap创建的VMA可以在之后通过madvise来给内核提供访问模式的提示(顺序读、随机读、马上要用、不需要了),这直接影响内核的readahead、页面回收、预取等策略。而malloc返回的堆内存在glibc里管理,你没法对这些区域做细粒度的madvise。
4.3 虚拟内存区域(VMA)和进程的内存画像
每个进程的虚拟地址空间由一串VMA组成,每个VMA描述一段连续的虚拟地址范围及其属性:起始地址、结束地址、权限、映射文件、偏移量等。用cat /proc/<pid>/maps可以看到这些区域的完整列表。
VMA在内核中的管理是mm_struct->mmap链表和mm_rb红黑树并存的:红黑树用于快速查找包含某虚拟地址的VMA(每次缺页都会查),链表用于遍历和调试。
熟悉VMA的意义在于:像mprotect、mremap、munmap这些系统调用,本质都是对VMA做增加、删除、修正属性。fork()之后,子进程并不会拷贝物理页,而是把所有VMA的PTE设为只读并共享同一批物理页,直到子进程写入时触发缺页复制——这就是著名的写时复制。理解VMA,才算真正理解了地址空间的组织方式。
5. 内核态内存分配:伙伴系统、slab与DMA的恩怨
5.1 物理内存管理的起点:伙伴系统(Buddy System)
物理内存管理的最小单位是物理页(4KB,有些架构支持16KB/64KB大页)。内核把所有物理页用页帧号(PFN)管理,每个PFN对应一个struct page结构体。内存分配器按2的幂次阶(order)来组合页:order 0是一个4KB页,order 1是8KB,order 2是16KB……一直到order 10(4MB)等。
伙伴系统的核心思想是:一个order为n的连续内存块,可以分裂成两个order为n-1的“伙伴”块;释放时,如果两个伙伴块都空闲,则合并成更大块。这样分配器始终能尽量维持大块连续内存可用。
当你调用alloc_pages(gfp_mask, order)时,内核根据gfp_mask标志决定从哪个内存域(DMA域、Normal域、HighMem域)分配、是否允许睡眠、是否可以进行回收等。
5.2 kmalloc vs vmalloc:连续性和一致性的角色分配
Linux内核里有好几个内存分配接口,容易搞混。
kmalloc(size, gfp_flags):返回的是物理地址连续的内核虚拟地址。它基于slab分配器或伙伴系统,适合小内存、需要连续物理页的场景(比如DMA)。kmalloc的地址和物理地址的偏移关系在CONFIG_FLATMEM下就是一个固定差值(PAGE_OFFSET),但在CONFIG_SPARSEMEM_VMEMMAP等配置下有更复杂的变化。kzalloc:和kmalloc一样的逻辑,但会清零。vmalloc(size):保证虚拟地址连续,但不保证物理地址连续。它用alloc_page逐个分配物理页,然后映射到一段连续的虚拟地址空间。适合大数据块、不需要物理连续的场景(比如模块加载、某些内核数据结构)。vmalloc的开销比kmalloc大得多,因为要修改页表。kcalloc:为数组分配并清零。
选择的关键判断依据是:你的内存是否要交给硬件设备(DMA)使用?如果是,通常必须物理连续,得用kmalloc或更专用的dma_alloc_coherent;如果只是内核软件内部用的大块缓冲区,vmalloc更合适。
5.3 slab/slub:为什么小对象不能直接找伙伴系统
如果每次内核要创建一个task_struct就向伙伴系统申请一个整页,那内核的效率会低到没法看。所以内核引入了slab分配器(现在主流是slub),它本质是“针对特定对象类型的缓存池”。
slab从伙伴系统申请来一批页,切分成大小固定的对象(比如kmalloc-64、kmalloc-256等不同尺寸的cache,以及task_struct、inode、dentry等专用cache)。分配时从空闲链表取出一个对象,释放时归还对象,不用频繁操作页表,性能大幅提升。
注意:slab中分配的
kmalloc对象物理上是连续的,因为它是从一块连续的页切出来的,但这并不代表slab缓存使用的空间适合DMA。DMA仍然需要连续物理内存,只要大小合适、且是从DMA域分配,通常没问题,但一定要用DMA接口确认一致性。
5.4 DMA内存分配的特殊性:一致性映射和流式映射
嵌入式开发者踩坑最多的就在这里。DMA要访问的内存有两条路径。
一致性映射(Coherent DMA Mapping):用dma_alloc_coherent(dev, size, &dma_handle, gfp)分配。它同时返回:
- 内核虚拟地址(给CPU用)
- DMA总线地址(
dma_handle,给外设用)
这块内存保证物理连续且对CPU和外设都“一致”——即CPU写入后,外设一定能看到;外设写入后,CPU能立即读取。这通常意味着硬件上关闭了Cache或使用特定的一致性协议。开销较大,但使用简单,不需要手动flush。
流式映射(Streaming DMA Mapping):让一块已经存在的缓冲区交给DMA使用,典型接口是dma_map_single(dev, cpu_addr, size, direction)和dma_unmap_single。它不会做一致性保证,而是靠dma_map_single内部完成隐式的Cache flush,以及在dma_unmap_single时使Cache失效。如果用完不unmap,数据就可能不同步。
我曾经调试过一个网卡驱动,数据传输偶发丢包,查了快一天才发现网卡驱动用的是流式映射,但DMA完成后没有及时调用dma_unmap_single,CPU读到的还是Cache里的旧数据。这种问题的最优解其实是缓冲池设计:固定长度的环形buffer,每次DMA描述符之间严格配对map/unmap。
6. 物理地址与虚拟地址转换的实用工具
6.1 内核提供的转换机制
在驱动代码层面,最常用的转换是:
virt_to_phys(virt_addr):把内核直接映射区的虚拟地址转成物理地址。phys_to_virt(phys_addr):反向转换,只适用于直接映射区。__pa()与__va():底层宏,本质是地址加减固定偏移。
对于vmalloc区(比如vmalloc分配的地址、模块加载的地址),virt_to_phys是错的,必须遍历页表来计算。
用户态没有通用的虚拟到物理的公开转换接口,但Linux提供了/proc/self/pagemap这个文件。每个虚拟页对应一个64位的条目,里面包含了PFN等信息(需要root权限才能读到PFN)。可以通过读这个文件来获取某虚拟地址对应的物理页帧号,但内核版本和权限限制会导致实际可用性不同,现在很多发行版默认限制了PFN的暴露。
/proc/self/pagemap的条目格式随内核版本有变化,解析时要特别小心位偏移,不同架构和配置差异很大。
6.2 用户态查看进程内存映射:maps和smaps
最直接的工具是查看/proc/<pid>/maps,它列出了进程所有VMA:
00400000-0040a000 r-xp 00000000 08:01 1234567 /bin/foo 00609000-0060a000 rw-p 00009000 08:01 1234567 /bin/foo 7f8b4c000000-7f8b4c020000 rw-p 00000000 00:00 0 7f8b4c21d000-7f8b4c3dd000 r-xp 00000000 08:01 1234567 /lib/x86_64-linux-gnu/libc.so.6每列含义:起始-结束地址、权限、文件偏移、设备号、inode、文件路径。权限字段中的r/w/x分别是读/写/执行,最后的p表示私有(private),s表示共享(shared)。
/proc/<pid>/smaps则给出更详细的信息,包括RSS(实际占用的物理内存总量)、PSS(按共享比例分摊的物理内存)、共享清零页等。是排查内存占用问题的利器。
6.3 压测与漏内存分析:Perf、Valgrind和/proc/meminfo
排查内存泄漏方面,用户态有Valgrind的memcheck,它本质上是通过拦截malloc/free、跟踪内存读写来检测越界和泄漏。内核空间可以用kmemleak。
查看系统整体内存情况最常用/proc/meminfo,这里的MemTotal、MemFree、MemAvailable、Buffers、Cached等字段说明了内存的整体去向。/proc/buddyinfo则显示各个order的空闲块数,/proc/pagetypeinfo显示按迁移类型(可移动、可回收、不可移动)分组的页面分布,对分析物理内存碎片很有帮助。
7. 缺页异常:映射从“纸面”走向“现实”的一刻
7.1 缺页的分类:按需分配、写时复制、交换回入
当CPU访问一个PTE的P位为0的虚拟地址时,产生缺页异常(Page Fault),CPU陷入内核的do_page_fault(x86上由page_fault处理)。缺页分几大类:
- 按需分配:访问了VMA存在的、但没有物理页映射的地址。比如
malloc后第一次写入,内核在do_anonymous_page中分配一个零页并设置PTE。 - 写时复制(COW):
fork()后,父子进程共享的页PTE被设为只读,一方写入触发缺页,内核分配新物理页并复制数据,然后更新PTE为可写并指向新页。 - 换出回入:物理页被回收(swap out)到磁盘后,PTE里记录了换出位置(通常用特殊位编码和swap entry),缺页时内核从磁盘读回页并更新PTE。
- 保护错误:比如向只读页写入、向无执行权限页执行代码,这类通常直接向进程发送SIGSEGV。
7.2 缺页的处理流程和性能影响
do_page_fault的流程大致是:获取触发地址(CR2寄存器),找到该进程地址空间中对应的VMA(红黑树查找),根据VMA类型和缺页原因分派处理。如果找不到VMA,说明访问了完全非法的地址,判定为段错误。
缺页是一个昂贵的操作:在内存压力大时,分配物理页失败可能触发回收,甚至触发OOM Killer。频繁缺页的应用性能一定差——这就是为什么遍历大数组时“冷启动”慢而“热身”后快,也是为什么Nginx、Redis这类程序会想尽办法减少page fault次数。
7.3 缺页观察实战:perf、strace和khugepaged
想观察一个进程的缺页情况,可以用perf stat -e page-faults ./program统计缺页次数。也可以用strace -f -e trace=mmap,mprotect,brk观察应用到底改动了哪些地址区间。
值得一提的还有THP(Transparent Huge Pages)机制。内核通过khugepaged线程在后台尝试把小页合并成2MB的大页(Huge Page),以减少TLB miss。但THP开启时,有时候应用会碰到延迟毛刺(大量时间消耗在页拷贝或迁移上),所以很多低延迟服务会显式关闭它。
我自己在压测一个数据库时,发现开启THP后TPS反而下降,排查后发现是khugepaged频繁做页迁移导致全局锁竞争。后来关闭THP、改用进程显示的madvise方式,性能立刻恢复。
8. 内存分配几个关键监控点:从dmesg到/proc
出了内存问题怎么定位?我的经验是分三层看:先看全局,再看进程,最后看内核事件。
全局看/proc/meminfo和free -m。free显示的就是/proc/meminfo的精简版。注意MemAvailable和MemFree的区别——MemAvailable才是内核估算的“能安全分配给新程序的量”,它考虑了缓存可以回收的部分。
进程级看/proc/<pid>/status里的VmRSS、VmSize、VMPeak、VmSwap,配合smaps了解明细。
内核事件看dmesg,重点关注:“Out of memory”、Killed process这类OOM相关日志,以及page allocation failure的调用栈。page allocation failure意味着伙伴系统在这个zone里拿不出满足order的内存,配合/proc/buddyinfo看碎片情况。
另外推荐一个工具库:bcc(BPF Compiler Collection)里的oomkill、mmap相关trace工具可以实时监控这些事件。
9. 常见问题速查:为什么内存问题总那么玄
9.1 malloc成功但一写入就段错误
这种情况通常是地址本来就不合法,但用户误以为“malloc成功一定可用”。排查步骤:
- 打印
malloc返回值是否为NULL,绝大多数情况不会为NULL(因为overcommit); - 用
/proc/<pid>/maps检查该地址是否在可写VMA里; - 检查是否真的在malloc返回的范围内写入,还是指针算偏移越界了。
- 如果用了线程,检查是否有其他线程
madvise(MADV_DONTNEED)或munmap释放了这块区域。
9.2 mmap文件偏移footer对齐错误导致Bus error
mmap映射文件时,偏移量必须是系统页大小(getpagesize(),通常为4096)的整数倍。如果你试图mmap某个不以页对齐为边界的偏移,系统会返回EINVAL。但实际开发中更隐蔽的问题是:映射长度跨过了文件末尾。访问这些超出文件范围的页时,如果磁盘上对应的文件段不存在且不是稀疏文件,硬件访问时会遇到总线错误(SIGBUS)。
解决思路:映射前先确认文件大小,并把映射长度限制在文件范围内,或者对可能扩展的文件做ftruncate预留空间。
9.3 为什么free了但内存不降反升
很多新手以为free(p)释放后,进程的RSS会立即下降。实际上glibc的ptmalloc很可能把释放的内存保留在heap的bin缓存里,频繁小块的“释放”只是从用户层返回给glibc,并没有通过munmap还回内核。再加上ptmalloc的trim操作有阈值,内存不降是正常的。
如果想确定性释放大块内存,直接用mmap+munmap,或者用malloc_trim(0)强制回收heap顶部空闲内存。但粗暴地malloc_trim在高并发多线程环境有锁竞争风险,要谨慎。
9.4 反复申请释放导致虚拟地址空间碎片化
在长时间运行的服务里,反复mmap/munmap大块区域,虚拟地址空间碎片会越来越严重。64位系统通常不用担心,但32位系统用户空间只有3GB,长时间跑下来可能mmap找不到足够大的连续虚拟地址区间。
优化方式:启动时一次性mmap一个大池子,自己做内存管理;或者调整ulimit对映射数量(vm.max_map_count)的限制——默认65530,某些场景需要调大。
9.5 驱动中kmalloc一次性申请大内存失败
kmalloc内部用slab分配,超过一定大小(通常是页大小)时会退到伙伴系统,但伙伴系统在高碎片状态下可能拿不出连续的order。常见优化:
- 尽量在系统启动早期、内存碎片还不多时分配;
- 用
GFP_KERNEL允许睡眠重试,而不是GFP_ATOMIC; - 如果不需要物理连续,直接用
vmalloc,注意不要用于DMA; - 嵌入式系统可以考虑预留内存(
CMA或reserved memory),避免碎片影响。
9.6 DMA读回数据全是旧数据
这个坑我前面提过,核心还是Cache一致性问题。如果你用的是流式映射,一定要在DMA完成后调用dma_unmap_single(或dma_sync_single_for_cpu),先invlpg使CPU的Cache失效再访问。如果你用的是普通kmalloc然后直接用DMA,务必检查它是否来自dma_alloc_coherent。驱动开发中这是最经典的“隐性bug”来源。
实际操作心得:写驱动时,把“DMA内存分配”和“Cache同步”当成同一个步骤来考虑。如果选择了
dma_alloc_coherent,就不要再手动调cache操作,否则反而会出问题。
9.7 THP导致的偶发高延迟
上面说过不分场景开THP反而有负面效果。如果你的业务是低延迟的,建议在运行前显式关闭THP:
echo never > /sys/kernel/mm/transparent_hugepage/enabled如果代码里可以精确控制热区域,用madvise(addr, len, MADV_HUGEPAGE)精确开启部分区域。
10. 一个从应用层到内核层打通的小实验
最后分享一个自己经常用来验证“虚拟地址不等于物理地址”的实战小实验,很适合新手建立直观认识。
#include <stdio.h> #include <stdlib.h> #include <stdint.h> #include <unistd.h> #include <fcntl.h> #include <string.h> int main(void) { // 1. 分配一段内存并打上标记 int size = 4096; char *buf = malloc(size); strcpy(buf, "hello-memory-mapping"); printf("virtual address of buf : %p\n", (void *)buf); // 2. 读 pagemap 获取物理页帧号(需要 root) // 注意:高版本内核默认限制 PFN,需要 root + CAP_SYS_ADMIN uint64_t vaddr = (uint64_t)(uintptr_t)buf; uint64_t vpn = vaddr / sysconf(_SC_PAGESIZE); uint64_t entry = 0; FILE *fp = fopen("/proc/self/pagemap", "rb"); if (!fp) { perror("fopen pagemap"); return 1; } if (fseek(fp, vpn * sizeof(entry), SEEK_SET) != 0) { perror("fseek"); return 1; } if (fread(&entry, sizeof(entry), 1, fp) != 1) { perror("fread"); return 1; } fclose(fp); if (!(entry & (1ULL << 63))) { printf("page not present? entry=0x%llx\n", (unsigned long long)entry); return 1; } uint64_t pfn = entry & ((1ULL << 55) - 1); uint64_t phys = (pfn << 12) + (vaddr & 0xfff); printf("physical address of buf: 0x%llx\n", (unsigned long long)phys); printf("虚拟地址和物理地址存在固定的页表映射,但数值并不相同\n"); return 0; }编译运行(记得root权限):
sudo gcc -o va2pa va2pa.c && sudo ./va2pa这个程序的本质是走一遍“通过/proc/self/pagemap抓取PTE中的物理页帧号”的流程。你能很直观地看到:buf的虚拟地址打印出来是类似0x55f...,而计算出来的物理地址往往是完全不同的数值。
注意:不同内核版本对pagemap的PFN字段做了不同程度的隐藏,若entry的PFN字段读出来是0,大概率是权限受限或内核开启了
CONFIG_PROC_PAGE_MONITOR但限制了访问。
如果你在写驱动,想在内核侧直接做同样的事,可以用virt_to_phys((unsigned long)buf)(仅对直接映射区的kmalloc地址有效),配合/proc/kallsyms能对照验证。
我在实际工作中还有一个习惯:开了内核的CONFIG_DEBUG_PAGEALLOC之后跑一轮测试,这样的环境里对已释放页的访问会产生明显的错误,比自己瞪着眼睛查指针高效多了。
11. 我自己踩过的三个坑,以及怎么绕开
11.1 盲目相信“连续”这个单词
很多人写驱动时以为kmalloc一定能分配到物理连续的内存。如果分配大小不大(一次不超过一个页面),通常不会有问题;但如果你申请多个页面的连续内存,系统长时间运行、碎片化严重时,连续分配很容易失败。有几个好习惯值得养成:
- 大块连续内存尽量用
dma_alloc_coherent,如果只是给CPU用且不需要物理连续,考虑vmalloc; - 嵌入式系统可以在设备树里预留一块内存,避开碎片问题;
- 如果只能用
kmalloc,尽量在模块加载早期分配、复用、不要频繁释放。
11.2 用户态指针直接传给DMA的幻觉
用户态的指针经过页表映射后,物理地址是分散的,绝大多数情况下不能直接把用户空间缓冲区交给DMA外设用(除非是那种物理就连续且锁定的页,比如某些情况下的特制buffer)。解决办法:
- 通过内核驱动里的
copy_from_user/copy_to_user拷贝到DMA缓冲区; - 或者用
mmap把DMA缓冲区映射给用户态; - 或者用
get_user_pages把用户页锁定并获取其物理页,再构建s/g表,但这需要极小心地处理。
11.3 在错误的内存域分配了DMA内存
有些硬件(比如老式ISA DMA)只能访问物理地址在16MB以下的内存。如果驱动不做判断就随意分配,可能DMA根本触发不了。现代平台用dma_alloc_coherent通常可以自动选用合适的域(根据设备的DMA mask),但你要检查设备到底支持多少位地址。dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))等接口就是干这个的。
12. 结尾:内存这事,值得花时间打通
写这篇文章的过程,其实是我自己把Linux内存从“会用”到“懂原理”的一次复盘。从应用层的malloc、到用户态的VMA、再到内核伙伴系统和slab、最后落到DMA一致性问题,这条链路每层都像一个独立系统,但它们最终围绕的都是“地址”这个核心:虚拟地址提供了解耦,物理地址承载了真实数据,而页表和MMU用一套高效的映射机制把两边连接起来。
我个人回头看,学习内存这块最大的收获不只是会看/proc或调mmap,而是建立了一个习惯:任何程序行为或性能问题,都先问一句“这个地址映射对了没有?物理页到位了没有?Cache是不是参与了?”这四连问能帮你定位绝大多数莫名其妙的内存疑难杂症。
如果这篇文章能让你下次面对“Segmentation Fault”或DMA数据错乱时,脑子里多一张完整的地址映射图,那我花在梳理上的时间就值了。内存水很深,但从“虚拟”走到“物理”这条路,一旦走通,后面看内核其他子系统都会顺很多。