有一次我在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的DFSR(Data Fault Status Register)会记录具体的fault原因,DFAR(Data Fault Address Register)则记录引发fault的虚拟地址。常见几个状态编码:
| DFSR编码 | 含义 | 典型场景 |
|---|---|---|
| 0b00101 | Translation fault, first level | 一级页表项无效 |
| 0b00111 | Translation fault, second level | 二级页表项无效 |
| 0b00110 | Permission fault, second level | 页表在但权限不足 |
| 0b00001 | Alignment fault | 访问地址未对齐 |
举个例子,用户态程序访问一个从未映射过的地址0x00010000,MMU遍历页表时发现二级页表项无效,于是触发Data Abort异常,DFAR写入0x00010000,DFSR写入0b00111(二级翻译错误)。内核拿到这对信息,才知道“哦,是哪个地址,什么原因”。
如果是取指令阶段发生异常,ARMv7-A走的是Prefetch Abort,对应的寄存器是IFAR和IFSR,处理逻辑和Data Abort大同小异,只是场景不同。
1.3 用户态与内核态地址空间的分水岭:TTBR0/TTBR1
ARMv7-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与内核态fault
vector_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 = 0x55,malloc分配的这块区域是匿名映射——它背后没有文件,纯粹是进程私有的内存。因此最终会走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 TLB
PMD和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-Write,COW)。它的好处是极致的:fork之后如果立即exec,就完全不需要复制物理页,省时省内存。只有当父子进程中某一个真的去写时,才需要把物理页拆开。
在代码里,子进程执行*p = 0xaa时,MMU翻译地址成功——PTE存在且指向一个合法的物理页——但权限检查失败,因为PTE是只读的。DFSR会记录一个permission fault,CPU再次进入Data Abort。这次do_page_fault仍然找到了vma,vma本身是可写的,但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 Abort,DFAR记录地址0,DFSR记录一个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 fault | vma合法、权限正确 | do_anonymous_page + wp_page_copy | 分配物理页,继续执行 |
| fork后写共享页 | permission fault | vma合法、PTE只读 | do_wp_page | 复制物理页,继续执行 |
| 写NULL地址 | translation fault | vma不存在 | 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] *pgd=9e878811, *pte=00000000, *ppte=00000000这里的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”当作默认动作。多数时候,问题在十分钟内就能定位到具体那一条内存操作上。这种掌控感,比单纯背多少面试题都实在。