尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

ARMv7-A缺页中断全解析:从MMU页表到写时复制与SIGSEGV

发布时间:2026/9/17 3:07:14

资讯中心
01
ARTICLE

ARMv7-A缺页中断全解析:从MMU页表到写时复制与SIGSEGV

ARMv7-A缺页中断全解析:从MMU页表到写时复制与SIGSEGV
有一次我在ARMv7-A开发板上调一个C程序跑着跑着突然崩了dmesg里只剩几行刺眼的日志。同事瞄了一眼说“段错误呗”但在我眼里这个过程藏着Linux内存管理最精华的一条链路——从CPU发现一个不存在的页表项到内核决定给进程分配物理页还是直接甩给它一个SIGSEGV。ARMv7-A架构下的缺页中断处理就是这条链路里最关键的一环。这篇文章我打算用一个具体的案例讲透它一个进程先malloc一块内存并写入再fork后父子进程修改数据最后访问一个空指针。这三个阶段分别对应匿名页缺页、写时复制COW和非法访问的SIGSEGV把缺页中断处理的三种典型结局都覆盖到。无论你是做嵌入式Linux开发、在准备内核面试还是单纯想把内存管理这块硬骨头啃下来这篇文章都能用一条完整链路帮你串起来。1. 缺页中断之前ARMv7-A的MMU和页表先把路修好1.1 地址翻译的基本流程虚拟地址如何变成物理地址先明确一个底层事实缺页中断不是CPU随便触发的中断它是MMU在地址翻译过程中“卡住”时抛出的同步异常。所谓同步意思是它和当前正在执行的指令强相关——某条指令访问了某个虚拟地址MMU查页表发现查不到或权限不对当场就停下来报错。ARMv7-A在经典配置下使用short descriptor格式的页表地址翻译分一级和二级。一级页表叫Translation Table基地址放在TTBR0或TTBR1寄存器里。一级页表一共4096项每项4字节总共16KB。每一项要么直接映射一个1MB的段section要么指向一个二级页表。二级页表有256项每项4字节总共1KB每一项映射一个4KB页面。映射粒度一级表项类型二级表项数量典型用途1MB段Section无内核线性映射、设备映射4KB页面指向二级表256项用户进程匿名页、文件页这个结构你可以理解成一本两层目录的书一级目录翻到某一页发现指向二级目录二级目录再翻到具体页码才找到真正的物理页。如果二级目录里那个页码是空的或者标记了“无权阅读”MMU就会中止翻译转而触发缺页异常。1.2 谁来决定该不该报错权限位和有效位的作用MMU在看页表项时并不是只看“有没有地址”它还会检查权限属性。关键有位包括存在位这个PTE是否有效、读写权限位可读可写还是只读、可执行位XN、缓存属性位等。不同位的组合产生不同的异常类型。ARMv7-A的DFSRData Fault Status Register会记录具体的fault原因DFARData Fault Address Register则记录引发fault的虚拟地址。常见几个状态编码DFSR编码含义典型场景0b00101Translation fault, first level一级页表项无效0b00111Translation fault, second level二级页表项无效0b00110Permission fault, second level页表在但权限不足0b00001Alignment fault访问地址未对齐举个例子用户态程序访问一个从未映射过的地址0x00010000MMU遍历页表时发现二级页表项无效于是触发Data Abort异常DFAR写入0x00010000DFSR写入0b00111二级翻译错误。内核拿到这对信息才知道“哦是哪个地址什么原因”。如果是取指令阶段发生异常ARMv7-A走的是Prefetch Abort对应的寄存器是IFAR和IFSR处理逻辑和Data Abort大同小异只是场景不同。1.3 用户态与内核态地址空间的分水岭TTBR0/TTBR1ARMv7-A支持两个页表基地址寄存器Linux对这件事的利用非常典型用户空间地址用TTBR0管理内核空间地址用TTBR1管理分界线由TTBCR寄存器配置。常见的配置是2GB用户空间加2GB内核空间也有3GB/1GB的变体。这样做的好处很实在进程切换时只需要切换TTBR0所有进程共享同一个TTBR1指向的内核页表。所以用户态缺页和内核态缺页的“后台”不是完全一样的——用户态每个进程拥有独立的mm_struct和页表内核空间的页表则是全局共享的。这也解释了为什么内核在处理缺页时要格外小心用户态fault可以正常睡眠、等待IO内核态的某些上下文比如原子上下文根本不能进入这些路径。2. 一个Data Abort从硬件异常到do_page_fault的完整接力2.1 CPU自动完成的现场保存当MMU决定报错后ARMv7-A会切换到Abort模式一种特权异常模式同时硬件自动做三件事一是把返回地址写入LR_abt因为ARM流水线的原因这个值会比触发异常的指令地址多8字节二是把当前的CPSR保存到SPSR_abt三是跳转到异常向量表。Linux在ARMv7-A上通常把向量表放在0xFFFF0000这个高地址处向量表里有专门的一条分支指向Data Abort的处理入口。注意ARM的异常向量表不是一长串C函数而是一堆跳转指令每个异常类型对应一个入口。Data Abort对应的入口在entry-armv.S里叫vector_dabt。2.2 vector_dabt和两个分岔口用户态fault与内核态faultvector_dabt这段汇编要做的事可以概括为先判断异常发生前CPU是在用户态还是内核态然后分别走不同的保存路径。用户态路径会把用户现场的寄存器完整压栈构造一个struct pt_regs再调用C代码内核态路径会复用内核栈上已有的pt_regs避免重复压栈。进入C代码后ARMv7-A和x86的思路是一致的x86把fault地址放在CR2寄存器ARM则是用mrc指令读取DFAR、DFSR打包传给do_page_fault。对应的C函数在arch/arm/mm/fault.c里核心签名是static int __do_page_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long addr, unsigned int fsr, struct pt_regs *regs);这里addr就是DFAR读出来的虚拟地址fsr就是DFSR读出来的fault状态。内核拿到它们之后才开始真正的“裁量”。值得强调的是ARM上这个do_page_fault和x86的do_page_fault虽然名字相同但架构相关的前置判断不同核心的mm层逻辑却几乎完全一致。这其实是Linux内存管理设计得聪明的地方架构只负责把异常“翻译”成统一的数据结构真正复杂的缺页处理在mm/memory.c的通用代码里完成。2.3 内核态缺页的“补救机制”exception table处理内核态fault时有一个很关键的机制被很多人忽略——exception table异常表。内核里像copy_from_user、copy_to_user这类函数访问的是用户空间的地址如果用户传了一个坏指针同样会在内核态触发Data Abort。此时do_page_fault会发现地址属于用户空间、当前处于内核态但并不会直接oops而是去查一个叫__ex_table的异常表。这个表里记录了“哪个指令地址出错时应该跳到哪个修复地址”。如果查到了CPU就跳转到修复代码copy_from_user返回一个负的错误码内核代码继续执行。只有当异常表里也查不到时内核才会真的oops。所以“内核不能随便解引用用户指针”这句话背后真正硬核的技术支撑就是异常表。它让内核可以有控制地“吞掉”一部分缺页错误而不是把一次普通的坏指针调用变成整个系统崩溃。3. 核心案例写一页内存内核是怎么把物理页“变”出来的3.1 从一段会崩的C代码说起为了把整个流程讲具体我用下面这段代码当主线。它一点都不复杂但每个阶段都在问内核要东西#include stdio.h #include stdlib.h #include unistd.h #include sys/mman.h int main(void) { char *p malloc(4096); *p 0x55; /* 第一次写触发匿名页缺页中断 */ pid_t pid fork(); if (pid 0) { *p 0xaa; /* 子进程写触发写时复制 */ exit(0); } wait(NULL); *p 0xbb; /* 父进程写同样触发写时复制 */ char *q NULL; *q 1; /* 空指针写入触发SIGSEGV */ return 0; }运行这段程序前几行都会正常执行最后一句必然段错误。但真正值得研究的是前面几行“正常”的背后内核做了多少事。尤其是第一行*p 0x55它绝对不是简单地往某个地址塞一个字节。3.2 malloc返回一个地址但物理页还不存在首先malloc(4096)是libc的行为。大多数情况下malloc从堆里给你划一块虚拟地址空间它调用的是brk或mmap系统调用。brk只是简单调整进程的堆边界mmap也只是在当前进程的vma链表里加一段区间这两个操作都不会去分配物理内存页。也就是说malloc返回之后进程的页表里根本没有p这个地址对应的PTE。此时p对应的虚拟地址只是“在逻辑上属于这个进程”硬件上没有任何映射。这就是经典的内存惰性分配lazy allocation内核只在vma里记一笔“这段地址已经被某进程占了”至于物理页等到真去访问那一天再说。这种设计带来的好处非常实际如果程序malloc了1GB却只用了10MB物理内存就不会白白被占掉1GB。系统可以超量承诺内存overcommit让更多进程同时存在。这也是为什么malloc本身几乎不可能失败而真正写入数据时反而可能因为内存不足被OOM killer盯上。3.3 缺页进来后从vma到pte的层层拆解当CPU执行*p 0x55时地址翻译失败进入do_page_fault。此时内核第一件事不是分配物理页而是先查这个地址到底“该不该分”。它去当前进程的mm_struct里调用find_vma寻找包含addr的vma虚拟内存区域。找到vma之后还要检查访问权限这个vma是否允许写如果地址确实落在vma范围内而且权限正确内核才进入真正的缺页处理流程handle_mm_fault。这条路很长但每一级都有明确分工do_page_fault - handle_mm_fault - __handle_mm_fault - handle_pte_fault - do_anonymous_page (匿名页) - do_fault (文件映射页) - do_wp_page (写保护页)对于代码里的*p 0x55malloc分配的这块区域是匿名映射——它背后没有文件纯粹是进程私有的内存。因此最终会走do_anonymous_page。do_anonymous_page的逻辑值得仔细看它分两个阶段。第一阶段是pte_alloc确保二级页表已经存在。一级页表通常是创建进程时就有的但二级页表可以是动态分配的所以这里需要先分配一个二级页表页1KB。第二阶段才是真正分配物理页。3.4 do_anonymous_page的两个小秘密零页和真正物理页很多人以为do_anonymous_page会一口气分配一个物理页并填上PTE。其实不然。它有一个非常巧妙的优化如果当前只是读访问内核会直接映射一个全局共享的zero page零页并把PTE设置成只读。这个zero page是一个内容全为0的共享物理页所有进程第一次读匿名页时都可以映射到它。只有当你真正要写的时候写保护fault会再次触发do_wp_page才会分配一个新的物理页把zero page的内容复制过来然后把PTE改成可写。也就是说匿名页的物理内存分配其实是“写时才发生”的这一招在fork后执行exec的场景里能省下大量物理内存。对应到我们的案例里*p 0x55是写操作所以第一次缺页时就会走分配物理页的路径。内核通过alloc_zeroed_user_highpage分配一个4KB的物理页然后通过mk_pte、pte_mkdirty、pte_mkwrite等操作设置PTE最后set_pte_at把PTE写入页表。3.5 页表写好了为什么要flush TLBPMD和PTE都设置好了物理页也分配了是不是就结束了还差一步TLB。ARMv7-A的MMU会把翻译结果缓存在TLB里但CPU刚才正是因为TLB没命中才去遍历页表的。现在页表更新了如果旧的TLB项还残留着“这个地址是无效的”之类的信息下次访问还是会出错。所以在建立新PTE之后内核会调用flush_tlb_page或者依赖ARMv7-A的TLB懒失效机制确保TLB状态和页表一致。另外ARM平台还需要考虑cache一致性问题update_mmu_cache会负责把新设置的PTE做必要的cache维护保证硬件能读到最新的内存数据。3.6 返回用户态之后发生了什么当do_page_fault返回时异常处理的最后一步是恢复pt_regs中的现场然后执行一条指令让CPU重新回到用户态重新执行那条触发fault的指令。注意这不是从头开始执行程序而是重新执行*p 0x55这一条。这一次MMU查页表能查到了权限也正确写入顺利完成。这条“重新执行”的设计是整个缺页中断机制的基石。它让CPU和内核之间形成了一种默契CPU负责发现“路不通”内核负责“修路”修好之后CPU回头再走一遍。整个过程对用户态程序完全透明——你只感觉*p 0x55正常执行了根本不知道背后走过一次异常、建过二级页表、分配过物理页。这里顺带说一个ARM的细节由于ARM的LR在异常入口时保存的是“触发指令地址8”内核在恢复现场时需要通过pt_regs中的PC值做修正确保重新执行的是正确的指令而不是下一条指令。这种架构差异藏在entry-armv.S的汇编细节里平时看不到但排查诡异段错误时可能就会碰到。4. 同一套机制下的三种结局匿名页、写时复制和段错误4.1 fork之后父子进程各自写同一块内存案例继续。fork之后子进程和父进程的虚拟地址空间内容完全一样p指向同一个虚拟地址。Linux在fork的实现中并没有傻到把父进程所有物理页都复制一份而是做了一个很精明的操作把父进程所有的PTE都标记为只读并且增加对应物理页的引用计数。此时父子进程的页表仍然指向同一批物理页但谁也“不配”写。这就是写时复制Copy-On-WriteCOW。它的好处是极致的fork之后如果立即exec就完全不需要复制物理页省时省内存。只有当父子进程中某一个真的去写时才需要把物理页拆开。在代码里子进程执行*p 0xaa时MMU翻译地址成功——PTE存在且指向一个合法的物理页——但权限检查失败因为PTE是只读的。DFSR会记录一个permission faultCPU再次进入Data Abort。这次do_page_fault仍然找到了vmavma本身是可写的但PTE显示为只读说明这是典型的COW场景内核走do_wp_page。4.2 do_wp_page到底做了什么do_wp_page的第一步是先判断这个页面是私有的还是共享的。如果是VM_SHARED映射那就不是COW直接重新设置PTE权限即可但匿名私有映射比如malloc来的内存就是COW的典型对象。第二步分配一个新的物理页并把旧物理页的内容复制过去。这也就是为什么COW“写”的性能比“读”差那么多——复制一页数据是实打实的开销。第三步很关键把新物理页的PTE设置为可写然后调用flush_tlb_page清除旧的TLB项确保后续访问走新的页表。完成后父子进程各自拥有了一页独立的物理内存互相隔离。父进程执行*p 0xbb时走的是完全相同的路径。所以这段代码里的两次写其实让内核分配了两页新的物理内存。理解了这个过程你就明白为什么fork后多进程写大量内存时系统会出现明显的卡顿——每次写一页都要缺页、分配、复制、建页表。4.3 空指针访问vma缺失导致SIGSEGV代码最后一行*q 1访问NULL地址。这同样会触发Data AbortDFAR记录地址0DFSR记录一个translation fault。do_page_fault开始处理但find_vma查遍整个vma链表都找不到覆盖地址0的区域。这时答案就确定了这个地址不属于进程不是权限不够而是“根本没有这个区域”。内核走bad_area路径进一步判断如果当前是用户态就给当前进程发送SIGSEGV信号。进程的默认信号处理是终止并生成core dump于是你在shell里看到的就是“Segmentation fault”。这里有个容易混淆点CPU报告的是translation fault但内核并不直接崩溃而是把是否非法这个判定交给vma。vma是用户地址空间的法律条文页表只是执行层。只要vma合法即使PTE不存在内核也想办法分配只要vma不合法即使PTE存在内核也可能判非法。这就是缺页中断“处理”二字的真正含义——它不只是补页表而是一次完整的地址合法性裁决。4.4 三种结局的对照收束把案例中的三种缺页放在同一张表里对照脉络会非常清晰触发场景硬件异常类型vma判定内核处理路径最终结果首次写malloc内存translation faultvma合法、权限正确do_anonymous_page wp_page_copy分配物理页继续执行fork后写共享页permission faultvma合法、PTE只读do_wp_page复制物理页继续执行写NULL地址translation faultvma不存在bad_area发送SIGSEGV进程终止还有一个容易被忽略但很实用的分支栈增长。当程序访问的地址落在当前栈vma的vm_start之下附近时vma本身并不包含该地址但内核发现这个vma带有VM_GROWSDOWN标志且地址没有越过栈的允许下界于是会先expand_stack把这个vma扩展然后再走正常缺页路径。这也是为什么递归函数可以一帧一帧往下压栈而不需要一开始就预留所有栈空间。5. 实际排查和观察缺页中断的几条实用经验5.1 打开内核的fault日志开关ARMv7-A的Linux内核在默认情况下用户态段错误只在进程收到SIGSEGV时“无声”处理dmesg里不一定有详细信息。如果你想把每次fault的细节打出来需要打开CONFIG_DEBUG_USER。打开之后dmesg会输出类似这样的日志[ 123.456789] unhandled page fault (11) at 0x00000000, code 0x017 [ 123.456799] pgd c0004000 [ 123.456803] [00000000] *pgd9e878811, *pte00000000, *ppte00000000这里的code字段就包含DFSR的fault状态0x017里的低4位是7对应二级translation fault。看到这样的日志你就能立刻判断这是一个二级页表缺失而不是权限问题。5.2 用pagemap验证页面是否真的建立映射想观察一个虚拟页是否已经被映射到物理页一个实用的手段是读/proc/self/pagemap。这个文件对每个虚拟页提供一个64位的描述符里面记录了该页是否present、是否已交换等状态。下面是简化版的读取逻辑import os, struct def page_present(pid, vaddr): pagemap_path f/proc/{pid}/pagemap with open(pagemap_path, rb) as f: offset (vaddr // 4096) * 8 f.seek(offset) data f.read(8) entry struct.unpack(Q, data)[0] return bool(entry (1 63)) print(page_present(os.getpid(), 0x7f0000000000))在程序malloc后写入之前和之后分别读取你会发现写入前present位是0写入后变成1。这就是“惰性分配”最直观的证据。5.3 用perf统计缺页次数如果你怀疑某个程序的性能瓶颈在内存缺页上可以用perf来统计内核函数handle_mm_fault被调用的次数perf stat -e page-faults ./your_program perf probe --add handle_mm_fault perf record -e probe:handle_mm_fault -a ./your_program perf script正常情况下minor fault数量远多于major fault。如果major fault很多说明程序在频繁访问未被缓存的磁盘映射页这时就要考虑调整文件读取策略、加大page cache或者换用更合理的预读机制。5.4 性能优化和实时性注意事项缺页中断虽然是内核提供的正常服务但它是有代价的。每次fault都要经过异常入口、vma查找、页表建立、可能的物理页分配和TLB维护这个开销比普通的内存访问高几个数量级。在嵌入式实时场景里尤其要注意一个高优先级任务如果频繁触发缺页就可能因为页表操作和分配器的锁竞争被拖慢。常见的对策有启动阶段用MAP_POPULATE把需要的内存一次性建立好或者用mlockall锁页避免换页在实时任务里避免使用malloc和并没有预先touch过的内存。另一个频繁踩的坑是在中断上下文或持有自旋锁的代码里访问用户态内存。这种上下文根本没资格睡眠而缺页处理可能要分配内存、可能要等待磁盘IO一旦睡眠就死锁或panic。内核里的copy_from_user等接口在高版本会显式检查这种非法上下文我在老内核上就见过因为这种问题导致的神秘死机。5.5 排查段错误的固定套路最后分享我调这类问题时的固定顺序。首先看地址确认出错的虚拟地址落在用户空间还是内核空间地址本身大致对应哪段区域栈、堆、mmap、全局变量。其次查vma通过/proc/pid/maps查看地址是否真的在某个vma范围内如果在看权限是否匹配。最后查PTE如果地址在vma里但还是缺页就要考虑是不是PTE被清掉了、被换出了、或者被NUMA迁移了。这套流程看起来简单但它逼着你把缺页中断的每一个决策点都过一遍。大多数段错误其实在第一步就能定位——NULL指针、释放后再访问、栈溢出越过栈底这些基本都是vma层面的问题和页表本身没关系。真正难的是那些vma合法但PTE始终建立不起来的场景比如内存不足导致匿名页分配失败走SIGBUS或者文件映射的页在回收后被撕裂。这时才需要把do_page_fault后面的每一层代码都翻出来看。把这条链路从硬件走到软件完整跑通之后再看段错误就没那么慌了。它不再是一个玄学般的崩溃而是内核在穷尽所有合法路径后做出的一次理性裁决。我会把“先看dmesg的fault status再去/proc查vma和PTE”当作默认动作。多数时候问题在十分钟内就能定位到具体那一条内存操作上。这种掌控感比单纯背多少面试题都实在。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。