ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内核异常向量表从头讲到透:ARM64布局、源码实现与调试实战

Linux内核异常向量表从头讲到透:ARM64布局、源码实现与调试实战 之前调一个平台驱动的时候系统跑着跑着突然oops控制台打出一堆寄存器现场。我瞄了一眼PC值发现落在了一个完全没见过的地址上既不在驱动代码段里也不在内核镜像的常规区域。顺着异常类型往回追最后定位到了异常向量表的跳转路径上。那次排查之后我对“linux内核异常向量表”这套机制算是彻底通了。说白了异常向量表就是CPU从正常执行流切到内核处理逻辑的那扇门门开在哪、怎么开门、进门之后往哪走决定了操作系统能不能稳定处理中断、系统调用、缺页、非法指令这些事。这篇文章我就把linux内核异常向量表从硬件布局、源码实现到调试排障一次讲透适合正在搞底层驱动、内核移植、虚拟化或者纯粹想啃内核源码的同学参考。1. 异常向量表到底是什么为什么Linux离不开它1.1 从一次panic现场说起先还原一下当时那个oops。寄存器里ESR是0x96000004对应ARM64的EC编码0x25也就是Data Abort from same EL发生在内核态访问了一个非法地址。但真正让我疑惑的是PC值它不在任何模块的符号区间里。后来用GDB连上查vbar_el1再对照内核符号表才确认落到的是el1_sync的处理入口附近。也就是说异常发生后CPU自动跳到了异常向量表里的同步异常槽位但槽位里的代码又因为现场环境不对二次触发了异常才出现了这种“异常套异常”的复杂现场。这个经历说明一件事异常向量表是一切异常处理的共同起点。不管是系统调用、缺页、外部中断还是内核态bug只要异常发生CPU就会强制跳转到向量表对应的槽位。如果向量表本身布局错了、映射出了问题或者入口代码对现场保存做得不够完整后续所有排查都会变得非常困难。1.2 中断、同步异常与异步异常ARM64异常分为两大类同步异常和异步异常。同步异常和当前指令的执行直接相关比如svc指令触发的系统调用、数据访问导致的data abort、取指导致的instruction abort、执行了未定义指令等。异步异常则和当前指令没有直接关系典型的就是中断IRQ/FIQ和SError系统错误比如总线错误。异常类型触发方式典型例子是否与当前指令同步同步异常执行指令时产生SVC系统调用、缺页、对齐错误、未定义指令是异步异常外部信号触发外设中断IRQ、快速中断FIQ、总线SError否SP是EL0的SP还是EL1的SP也会影响异常处理行为。CPU用当前异常级别下SPSel寄存器决定使用哪个栈指针而Linux在这种细节上做了明确约定EL1异常统一使用SP_EL1所以vectors表里EL1t那组槽位基本不会被用到但槽位依然保留这是为了满足ARM架构规定的表布局。1.3 为什么CPU不能“顺其自然”地处理异常很多人刚接触这里时会问异常跳转为什么非要搞一张固定位置的表不能像函数调用那样随便call一下吗原因在于异常发生瞬间CPU拿不到一个可靠的“函数指针”。函数调用是代码主动发起的call指令里就带着目标地址而异常是被动的CPU得靠架构规定好的方式去找到处理入口。如果每次都要靠软件去查询异常类型、再决定跳转目标这个查表的动作本身就会产生巨大的延迟和不确定性。所以硬件设计采用了类似“总机分机”的模型CPU碰见异常直接跳到异常向量表里固定偏移的位置每个位置对应一类异常。这就像公司前台把电话转给对应部门不需要呼叫方先查一遍通讯录。内核只需要保证向量表地址已经写到VBAR寄存器、表所在内存可访问、每个槽位里放着正确的跳转代码异常处理链路就能跑起来。2. ARM64向量表布局与Linux源码实现2.1 ARM64的4张表和16个入口ARM64的异常向量表不是简单一张表而是按“来源状态”分成4组每组包含4种异常类型。这4组分别是EL1t、EL1h、EL0tAArch64和EL0tAArch32。这里的t和h表示异常发生时用的是SP_EL0还是SP_ELxx为当前异常级别比如EL1t表示在EL1发生了异常但使用的是SP_EL0EL1h表示EL1异常且使用SP_EL1。每张表大小是0x800字节每个入口占0x80字节128字节所以16个入口刚好铺满。Linux在arch/arm64/kernel/entry.S里定义了这张表开头是这样的SYM_CODE_START(vectors) kernel_ventry 1, t, sync // Synchronous EL1t kernel_ventry 1, t, irq // IRQ EL1t kernel_ventry 1, t, fiq // FIQ EL1t kernel_ventry 1, t, error // Error EL1t kernel_ventry 1, h, sync // Synchronous EL1h kernel_ventry 1, h, irq // IRQ EL1h kernel_ventry 1, h, fiq // FIQ EL1h kernel_ventry 1, h, error // Error EL1h kernel_ventry 0, t, sync // Synchronous 64-bit EL0 kernel_ventry 0, t, irq // IRQ 64-bit EL0 kernel_ventry 0, t, fiq // FIQ 64-bit EL0 kernel_ventry 0, t, error // Error 64-bit EL0 kernel_ventry 0, t, sync // Synchronous 32-bit EL0 kernel_ventry 0, t, irq // IRQ 32-bit EL0 kernel_ventry 0, t, fiq // FIQ 32-bit EL0 kernel_ventry 0, t, error // Error 32-bit EL0 SYM_CODE_END(vectors)每个kernel_ventry展开后占128字节。如果直接放跳转指令一条b指令才4字节剩余空间要靠指令对齐和填充补齐。Linux之所以每个槽位保留128字节是为了完全兼容ARM架构的固定偏移要求。任何篡改偏移的行为都会导致异常跳转错位这种问题查起来特别隐蔽。2.2 VBAR寄存器与向量表基地址向量表的位置不是写死的由VBAR寄存器决定。ARM64下EL1、EL2、EL3分别有vbar_el1、vbar_el2、vbar_el3。内核启动时会把vectors符号的地址写入vbar_el1这样CPU在EL1收到异常时就知道该往哪跳。读取当前vbar_el1的值非常容易# 在kgdb或GDB里 p/x $vbar_el1如果这个值和内核符号表里vectors的地址对不上说明可能被别的机制改写过了比如KPTI的trampoline向量表切换。后面我会专门讲这个坑。另外向量表有对齐要求。ARM文档规定VBAR的低11位必须为0也就是2KB对齐因为整个表占2KB。内核把vectors放在自己的.text段里链接脚本会保证对齐。自己写汇编模块时如果要自定义向量表千万别忘了对齐否则CPU会直接报错。2.3 kernel_ventry宏里做了什么kernel_ventry看起来只是一行实际展开后做了不少事。它会先做一次偏移计算确认当前entry位置然后根据是EL0还是EL1异常决定要不要切换栈指针最后跳到一个统一入口上.macro kernel_ventry, el, lbl, regsize .align 7 .if \el 0 .if \regsize 64 mrs x0, tpidrro_el0 .else ... .endif .endif b el\el\()_\lbl ... .endm这里.align 7就是保证128字节对齐。el和lbl拼接出el1_sync、el0_irq这类标签再通过b指令跳过去。后面跟的nop和其他填充指令是为了把槽位撑满。从这段代码可以看出向量表本身只负责粗粒度路由EL0来的同步异常跳到el0_syncEL1来的中断跳到el1_irq。真正的细粒度判断比如这个同步异常到底是系统调用还是缺页在el0_sync/el1_sync内部再做。这种分层设计让向量表保持极短保证异常进入路径上的延迟最小。3. 异常进来之后的内核完整处理流程3.1 保存现场pt_regs是怎么攒出来的异常处理第一步永远是保存现场。ARM64架构里异常发生时硬件只会保存很少的信息到当前异常级别的SPSR_ELx、ELR_ELx和ESR_ELx通用寄存器x0~x30不会被自动保存。如果内核不保存这些寄存器处理完异常之后原来的程序根本没法继续跑。所以Linux在异常入口处会通过kernel_entry宏把所有寄存器保存到当前任务的内核栈上形成一个pt_regs结构体。pt_regs在include/linux/ptrace.h里定义ARM64版本的具体布局是struct pt_regs { union { struct user_pt_regs user_regs; struct { u64 regs[31]; }; }; u64 sp; u64 pc; u64 pstate; u64 orig_x0; s32 syscallno; u32 unused2; };保存完现场之后各种处理函数才能安全地使用通用寄存器。内核栈上这一块数据也是后续栈回溯stack unwinding的基础panic时打印的调用栈就是靠pt_regs里的pc、sp这些字段一层层回溯出来的。3.2 同步异常分发和系统调用路径同步异常入口收到异常后需要读ESR_EL1寄存器通过EC字段判断异常子类型。在el0_sync里能看到一串判断el0_sync: kernel_entry 0 mrs x25, esr_el1 lsr x24, x25, #ESR_ELx_EC_SHIFT cmp x24, #ESR_ELx_EC_SVC64 b.eq el0_svc cmp x24, #ESR_ELx_EC_DABT_LOW b.eq el0_da cmp x24, #ESR_ELx_EC_IABT_LOW b.eq el0_ia ...最常见的EC是0x15也就是AArch64的SVC指令即系统调用。走el0_svc流程时内核会根据x8寄存器里的系统调用号去sys_call_table里找对应的内核函数。这里有个关键点系统调用号存在x8里参数存在x0~x5里所以进入内核后内核会先把x8保存到pt_regs-syscallno再根据它分发。如果EC是0x24或0x25对应data abort就是大家熟悉的缺页异常路径。缺页异常处理完成后会回到用户态重新执行那条触发异常的指令这也就是“异常返回用户态后指令要重新执行”的语义基础。整个过程全靠ELR_ELx保存了触发异常的指令地址kernel_exit返回时把它恢复到PC。3.3 中断处理、软中断与等待队列中断路径相对更直接。el1_irq和el0_irq都会调用irq_enter然后通过handle_arch_irq跳到实际的中断控制器处理函数。GIC会告诉内核中断号irqdomain会把硬件中断号映射到Linux irq number最终调用到这个中断号上注册的irqaction回调。中断处理里有一类很常见的场景驱动程序在中断里唤醒一个等待队列上睡眠的进程。比如网卡收到数据包中断handler里napi_schedule或者直接在中断里wake_up_interruptible把接收线程从睡眠状态拉起来。这正好用上了linux内核的等待队列机制。整个链路从异常向量表开始到等待队列唤醒结束是一套完整的“硬件事件-内核处理-任务调度”流程。中断返回路径也很有讲究。el0_irq返回用户态前会检查当前任务是否有信号要处理、是否需要重新调度内核把这两件事放在ret_to_user流程里做。这样设计的好处是每次从内核返回用户态都是一个天然的调度点中断处理完正好能顺便处理挂起的任务不用额外打断用户程序。4. 从GIC到CPU中断怎么找到向量表入口4.1 GIC和CPU之间的握手很多人会把“中断号”和“向量表槽位”搞混。其实GIC通用中断控制器处理的是一堆外设中断源它把中断信号汇聚后通过物理IRQ线送到CPU。CPU收到这个物理IRQ后才会触发IRQ异常跳到向量表里IRQ对应的槽位。也就是说向量表里IRQ槽位只有一个不管你是网卡中断、串口中断还是定时器中断进来时都走同一个入口。进到handle_arch_irq之后GIC驱动会读出当前最高优先级的中断号再通过irq_find_mapping之类的方式转换成Linux中断号。这个过程更像是“前台接待拿到总机转接的呼叫再分发到具体部门”。4.2 handle_arch_irq是怎么注册进去的ARM64的通用irqchip框架里handle_arch_irq是一个函数指针。gic驱动初始化时会调用set_handle_irq()注册自己的处理函数set_handle_irq(gic_handle_irq);这之后异常向量表里el1_irq路径直接调用handle_arch_irq()就等于进入了GIC的处理流程。如果没有注册这个函数中断进来后handle_arch_irq是空的内核会直接panic最常见的现象就是“unexpected IRQ trap at vector”。移植内核到新平台时如果中断控制器驱动没起来就特别容易撞见这个提示。4.3 EL2虚拟化向量表KVM的入口提到虚拟化热词里那个linux内核虚拟化就很有关系了。ARM64的KVM实现依赖EL2异常级别。KVM初始化时会把Hyp相关的向量表地址写入vbar_el2。这样虚拟机里发生需要陷入hypervisor的事件时CPU会自动跳转到EL2的向量表入口。这里有个细节支持VHEVirtualization Host Extensions的硬件上内核可以直接在EL2运行vbar_el2就是内核本身的向量表。不支持VHE时KVM要单独维护一个hyp向量表做异常陷入和返回。不管哪种方式向量表依然是整个虚拟化陷入机制的第一站。搞虚拟化调试的时候看到vcpu跑飞先查vbar_el2往往比追半天vcpu状态更高效。5. 向量表调试三板斧与常见坑5.1 用QEMUGDB验证向量跳转调试异常向量表最直接的方式是QEMUGDB既能看到硬件行为又能对照源码。随手组一个环境qemu-system-aarch64 -machine virt -cpu cortex-a57 \ -kernel Image -nographic \ -append consolettyAMA0 \ -s -SGDB侧连接gdb vmlinux target remote :1234 p/x $vbar_el1 x/16i $vbar_el1 b *el1_sync continue在GDB里下断点到el1_sync然后随便触发一次系统调用或者异常程序就会停在向量表入口。这时观察内存里的指令、pt_regs的保存过程对理解整套流程特别有帮助。当年我把x/16i $vbar_el1的输出和entry.S里的源码逐行比对才真正理解kernel_ventry宏展开后每一条指令的用途。5.2 移植新板子时最常踩的向量表坑移植内核到新的SoC或开发板时异常向量表常见的坑有这么几类问题现象可能原因排查建议启动后立刻异常或反复重启向量表地址没有正确写入vbar_el1检查启动汇编里有没有设置vbar的代码interrupt handler没执行GIC初始化失败handle_arch_irq为空确认irqchip驱动有没有注册成功用户态程序一跑就段错误向量表被映射到了不可执行内存检查MMU对vectors所在页的权限设置开KPTI后异常频繁trampoline向量表映射或切换逻辑出错查entry.S里tramp_vectors和__entry_tramp_text_start符号我遇到过的一个经典问题是把内核镜像链接地址改到了非标准位置导致vectors符号地址虽然变了但MMU页表里对应区域的执行权限没开CPU一跳过去就触发instruction abort。这个坑的教训是向量表不止要“地址对”还要求“映射对”。改了内存布局之后第一件事就是确认异常向量表所在区域是可执行的。5.3 通过异常类型快速定位内核问题排查oops时ESR里的EC字段是第一个要看的。我习惯把EC编码先翻译成异常类型再决定下一步往哪查ESR EC值异常类型常见原因0x15SVC (AArch64)系统调用一般不是bug0x24Data Abort (lower EL)用户态非法指针访问0x25Data Abort (same EL)内核态野指针、越界访问0x20Instruction Abort (lower EL)用户态执行非法地址0x21Instruction Abort (same EL)内核跳到非法地址比如函数指针损坏0x00Unknown reason调试用BRK等EC值直接决定你该去查哪一类问题。EC为0x25时重点查内核内存越界EC为0x21时重点查函数指针被篡改或跳转表错乱。这个速查表我贴在工位旁边好几年了排查效率提升明显。6. 向量表周边的现代内核机制6.1 KPTI与entry trampoline现代Linux内核里异常向量表不再是单纯地放在内核镜像里就完事。开启CONFIG_UNMAP_KERNEL_AT_EL0也就是KPTI后内核在用户态可见的地址空间里只保留一个很小的entry trampoline区域其中就包含trampoline向量表。这样用户态虽然能看到一小段内核代码但看不到完整的内核镜像能在很大程度上降低侧信道攻击的风险。每次从用户态进入内核时CPU先跳到tramp_vectors再迅速切换到真正的vectors并把vbar_el1切回内核向量表。这个过程在entry.S里由tramp_alias、msr vbar_el1等指令完成。调试时如果发现vbar_el1的值是trampoline地址而不是vectors地址先别慌先确认当前是不是刚从用户态进来、还没切换完整。这个细节我一开始也搞糊涂过。6.2 BRK指令和调试器的秘密搞内核调试经常能看到“brk #0x800”这类指令。BRK也是一种同步异常它进入向量表后通过ESR里的imm字段区分是kprobes、kgdb还是BUG()宏触发的。这让我觉得向量表就像一栋大楼的门禁系统所有“访客”都得从同一个大门进但每个人拿着不同的门牌号保安再分别引导到不同楼层。理解了这个模型再去看内核里各种异常相关的机制就顺手了。kprobes在指令前打补丁、kgdb下断点、WARN/BUG宏故意触发异常本质上都是同一个套路制造一个异常让CPU走进向量表再利用异常处理机制接管控制流。搞明白异常向量表等于拿到了理解这些机制的总钥匙。6.3 编译选项和配置注意事项编译内核时涉及异常向量表的配置项不太多但每个都很关键。CONFIG_UNMAP_KERNEL_AT_EL0控制是否启用KPTI trampolineCONFIG_ARM64_PAN让内核在访问用户态内存时自动触发权限异常异常入口处要处理好PAN标志CONFIG_ARM64_SW_TTBR0_PAN则用软件方式控制页表切换。做内核移植时这些配置项最好按目标平台的硬件特性决定别为了省事直接全关也别无脑全开。改完这些配置后建议用kgdb在el1_sync上下断点确认异常入口仍然工作正常。结尾从一次看起来莫名其妙的oops到最后把异常向量表的源码、布局、调试手段全串起来这个过程给我的启发是越底层的东西越值得花时间抠明白。异常向量表代码量不大它却是整个内核响应硬件事件的总入口也是中断、系统调用、缺页、虚拟化、调试机制共同依赖的地基。你在它的入口下个断点几乎能拦住内核里所有重要事件。之后不管你是想深入研究linux内核源码还是打算做内核移植和裁剪建议都先把arch/arm64/kernel/entry.S从头到尾读一遍配合QEMU单步走几遍异常流程。读懂了这一段再看调度器、中断子系统、虚拟化这些模块都会有豁然开朗的感觉。另外给个我自己的调试小习惯遇到异常相关问题第一件事永远是读vbar_el1和ESR_EL1这两个寄存器能告诉你“门在哪”和“谁进来了”剩下的排查路径基本就清晰了。
返回列表