ARM裸机SWI软件中断异常机制详解:从异常向量表到系统调用实现

发布时间:2026/10/17 5:20:45
ARM裸机SWI软件中断异常机制详解:从异常向量表到系统调用实现 在ARM裸机或者RTOS开发里第一次听到“SWISoftware Interrupt软件中断”这个词多少会觉得有点抽象。其实说白了它就是一条SWI 立即数指令程序执行到这里主动触发一次异常CPU从当前模式切到特权模式SVC模式跳到一个提前安排好的入口去执行代码。我最早对它产生兴趣是因为想在裸机工程里把“应用逻辑”和“底层寄存器操作”隔离开上层想打印一个字符不直接操作串口寄存器而是发一个SWI请求由底层服务代码完成真正的输出。这个思路后来在RTOS的任务切换、系统调用设计里反复出现。这篇笔记围绕一个完整的SWI异常程序示例展开把异常向量表、启动代码、SWI处理函数、主程序调用串成一条线给正在学ARM异常机制或者想在工程里实现类似“系统服务”层的读者做参考。1. SWI异常到底是什么先建立整体认知1.1 软件中断和硬件中断的本质区别先分清楚一个概念硬件中断比如外部按键触发IRQ和软件中断SWI是两类完全不同的东西。硬件中断是异步的CPU不知道哪一条指令执行时会来处理起来要考虑临界区、嵌套、中断延迟SWI是同步的CPU在执行到SWI指令的“那一瞬间”进入异常执行完处理函数后回到SWI的下一条指令继续跑。同步带来的最大好处是程序对“什么时候会进入异常”有明确的预期所以SWI非常适合用来实现“服务调用”。举个例子你在写一个裸机串口Demo业务代码里想打印调试信息。最粗暴的做法是业务代码直接操作串口寄存器这在项目小的时候没什么代码一多就乱成一锅粥底层驱动换了寄存器地址所有业务代码都要跟着改业务代码和硬件耦合太深也没法单独测试。如果定义一个SWI调用约定——你要打印什么就往约定的内存位置放参数然后执行SWI指令底层在SWI处理函数里统一完成硬件操作那么业务代码和硬件之间就隔了一层“墙”。这就是SWI最初的工程价值提供受控的特权服务入口。操作系统里的系统调用本质上就是这么干的。1.2 ARM处理器收到SWI指令后做了什么当ARM处理器执行到SWI指令时硬件会自动顺序完成下面几件事把当前程序状态寄存器CPSR保存到SVC模式下的SPSR_SVC把SWI指令的下一条指令地址保存到LR_SVC作为异常返回地址根据CPSR的模式位把处理器切换到SVC模式设置CPSR的I位屏蔽IRQ中断FIQ没有被自动屏蔽强制PC跳到0x08也就是异常向量表中SWI对应的槽位。这几步完全是硬件完成的不需要软件干预。注意第2步LR_SVC保存的是下一条指令地址而不是SWI指令本身的地址这一点非常关键后面取SWI号的时候还要用到。用一句话概括就是发生SWI时硬件帮你把“程序状态”和“返回地址”这两个现场都存好了处理函数的任务就是在干完活之后把这两个现场恢复让程序像没发生过异常一样继续。很多人学到这里会纠结ARM三级流水线的PC8问题我的经验是在SWI异常上架构已经把返回地址算好了LR就是SWI指令地址4你直接往后推偏移就行不用自己去套PC8公式。1.3 SWI指令格式一条指令如何携带“功能号”SWI指令在ARM指令集里有固定的二进制格式高4位是条件码接着4位固定为1111作为SWI指令标识低24位是一个立即数一般叫SWI号或者注释字段。也就是说SWI 0x123456这条指令机器码大致是0xEF123456低24位就是0x123456。我们写汇编时写的SWI 0、SWI 1编译后就是这个格式。这个24位立即数在工程里常被当作“功能号”使用。比如你设计一个微型系统服务层SWI 0表示打印一个字符ASWI 1表示打印字符BSWI 2表示计算两个数相加。处理函数启动后通过读取当前SWI指令的低24位就知道用户要的是哪个服务。理论上一条SWI指令能携带2^24个功能号实际根本用不完所以如何规划功能号很自由——只要发送端和处理端约定一致就行。这里埋一个坑上面说的是ARM状态下的32位SWI指令。如果工程切换到Thumb状态SWI指令变成16位只携带8位立即数取指令的地址偏移也从4变成2。这个细节到第4章排错时会专门说。2. 示例程序整体框架从启动代码到主循环写完原理我习惯先搭骨架再填肉。下面这个示例结构是从一个最简裸机工程里抽出来的去掉了板级相关配置只保留和SWI机制直接相关的部分。整个工程分三段启动汇编代码、SWI处理汇编代码、C语言主程序。读者拿到手按顺序看就能复现。2.1 异常向量表的搭建所有ARM异常处理都从异常向量表开始。向量表通常放在地址0开始的一段连续内存里每4字节一个入口。经典ARM的向量表布局是地址偏移异常类型说明0x00Reset复位异常上电入口0x04Undefined Instruction未定义指令异常0x08Software Interrupt软件中断SWI本文主角0x0CPrefetch Abort预取指异常0x10Data Abort数据访问异常0x14Reserved保留0x18IRQ普通中断0x1CFIQ快速中断启动代码里最常见的做法是每个槽位放一条ldr pc, xxx把PC直接加载成对应处理函数的绝对地址.text .global _start _start: ldr pc, Reset_Handler ldr pc, Undefined_Handler ldr pc, SWI_Handler ldr pc, Prefetch_Handler ldr pc, DataAbort_Handler nop ldr pc, IRQ_Handler ldr pc, FIQ_Handler有人会问用b Reset_Handler不是更简洁吗两种写法都能用。b是相对跳转范围±32MB如果代码量小、链接地址离向量表不远确实没问题但向量表可能被映射到0地址代码却链接到SDRAM的高地址用b就要小心范围。ldr pc, label是绝对跳转汇编器会把label的绝对地址放到附近的字面量池里运行时不依赖当前PC位置通用性更好。我在示例里统一用ldr pc我自己写启动代码时也几乎不用b放向量表。2.2 模式栈初始化SWI能在哪一级执行处理器进入SWI异常后会自动切到SVC模式。问题是SVC模式下的栈指针sp是独立的如果不提前初始化处理器在SWI处理函数里一压栈就把数据写到了非法地址程序直接崩。所以启动代码里必须先把各模式SP准备好。下面这段代码假设RAM的起始地址是0x30000000从顶端往下分配SVC栈和IRQ栈.equ USR_MODE, 0x10 .equ FIQ_MODE, 0x11 .equ IRQ_MODE, 0x12 .equ SVC_MODE, 0x13 .equ SYS_MODE, 0x1F .equ SVC_STACK_TOP, 0x30004000 .equ IRQ_STACK_TOP, 0x30003F00 Reset_Handler: 先拿到当前CPSR低5位是模式位 mrs r0, cpsr bic r0, r0, #0x1F 切到IRQ模式设置IRQ栈 mrs r1, cpsr bic r1, r1, #0x1F orr r1, r1, #IRQ_MODE msr cpsr_c, r1 ldr sp, IRQ_STACK_TOP 切到SVC模式设置SVC栈 mrs r1, cpsr bic r1, r1, #0x1F orr r1, r1, #SVC_MODE msr cpsr_c, r1 ldr sp, SVC_STACK_TOP 切回原模式复位后本来就是SVC这里保持清晰 msr cpsr_c, r0 板级初始化时钟、内存控制器、串口等这里略 bl main b .这段代码里的模式栈地址是我随意给的读者要按自己板子的RAM大小和链接脚本调整。核心思路是只要程序可能进入哪个特权模式就要给那个模式准备独立栈。SWI只用SVC栈但IRQ栈最好也一起初始化否则后面一旦开IRQ同样的坑会再踩一次。2.3 主流程设计用户代码如何发起SWI调用示例的主程序逻辑很简单初始化串口后循环调用三个软中断服务让串口依次输出A、B、C三个字符。这是验证SWI机制是否能跑通的最小闭环。void uart_init(void); void uart_putc(char c); void delay(void); int main(void) { uart_init(); while (1) { __asm volatile (swi 0); // 请求服务0 delay(); __asm volatile (swi 1); // 请求服务1 delay(); __asm volatile (swi 2); // 请求服务2 delay(); } return 0; }在GCC工具链里__asm volatile (swi 0)就能在C代码里插入SWI指令。如果用的是Keil/ARMCC写法略有不同__asm void swi_call_0(void) { SWI 0 }然后在C代码里直接调用swi_call_0()。两种方式都能达到目的。我个人更推荐GCC的写法因为调用形式更直观还能直接在asm语句里加寄存器约束方便后续传参。3. 核心代码逐段拆解SWI处理函数的完整实现这一章是全文的重头戏。SWI处理函数虽然短但里面的每一行背后都有一个容易踩的坑。我按处理流程分四步拆开讲。3.1 入口现场的保存与恢复处理器进入SWI异常后虽然硬件保存了CPSR和返回地址但你自己的寄存器现场还是乱的r0-r3可能存着调用者的参数lr是异常返回地址如果处理函数里还要调用其它函数必须先把现场保护起来。标准写法是用栈批量保存SWI_Handler: stmfd sp!, {r0-r3, r4, lr} 保存现场注意这里用的是SVC栈 ... ldmfd sp!, {r0-r3, r4, lr} 恢复现场 movs pc, lr 返回同时恢复CPSRstmfd sp!, {r0-r3, r4, lr}相当于批量压栈。我特意多保存了r4是因为下面取SWI号时要借用r4当临时变量如果只用r0一取号就把调用者参数覆盖了。这点初学者很容易忽略写完ldr r0, [lr, #-4]之后r0的值就变了后面再想用调用者的r0参数就没戏了。3.2 从返回地址回溯SWI指令提取功能号这是SWI处理和其它异常处理最大的区别处理函数需要知道用户“想要什么服务”而这个信息就藏在触发异常的SWI指令编码里。前面说过硬件保存的LR是SWI指令的下一条指令地址所以SWI指令本身在LR-4这个地址。用ldr把它取出来再屏蔽高8位低24位就是功能号ldr r4, [lr, #-4] 加载SWI指令编码 bic r4, r4, #0xFF000000 去掉高8位得到24位功能号这段代码我建议死记[lr, #-4]、bic #0xFF000000两个关键点各错一个都会出问题。bic可以换成and r4, r4, #0x00FFFFFF效果一样。拿到功能号之后就是常规的比较分发cmp r4, #0 bleq swi_service_0 cmp r4, #1 bleq swi_service_1 cmp r4, #2 bleq swi_service_2bleq是“相等则带链接跳转”跳转到C函数swi_service_0函数返回后用bx lr回到这里继续判断下一个条件。如果功能号匹配就只执行对应的服务不会走到后面的分支。3.3 带参数的SWI调用寄存器传参与返回值处理上面的示例只演示了“按功能号分发”实际项目中用户肯定希望传参数、拿结果。最自然的做法是用寄存器传参主程序把参数放进r0、r1执行SWI指令处理函数算完把结果放回r0。这里有个关键陷阱我们一进SWI_Handler就把r0-r3压栈了如果不做处理返回时ldmfd sp!, {r0-r3, r4, lr}会把r0恢复成SWI指令执行前的值处理函数存进去的结果就被覆盖了。解决办法是在恢复现场之前把结果写回栈里保存r0的位置。看这个加法服务的完整汇编流程SWI_Handler: stmfd sp!, {r0-r3, r4, lr} 此时栈顶偏移0处是r0的副本 ldr r4, [lr, #-4] bic r4, r4, #0xFF000000 cmp r4, #3 bleq swi_add swi_add的参数来自r0、r1结果存到r0 str r0, [sp] 把结果写回栈中r0副本 ldmfd sp!, {r0-r3, r4, lr} movs pc, lr对应的C服务函数volatile int g_sum; void swi_add(void) { // 按AAPCS约定r0和r1是函数入参 // 但C函数里拿不到这里用寄存器变量内嵌汇编直接读 int a, b; __asm volatile (mov %0, r0 : r(a)); __asm volatile (mov %0, r1 : r(b)); g_sum a b; __asm volatile (mov r0, %0 :: r(g_sum)); }这版C代码写得比较笨拙只是为了说明问题。更干净的做法是服务函数直接用汇编写或者用内嵌汇编的输入输出约束把寄存器操作封装好。重点是要理解SWI处理函数就像一个经过特权检查的函数调用寄存器的传递规则和普通C函数调用不完全一样你要自己保证返回时r0有意义。主程序发起调用volatile int result; void trigger_add(void) { int arg0 10; int arg1 32; __asm volatile ( mov r0, %0\n mov r1, %1\n swi 3\n mov %2, r0\n : r(result) : r(arg0), r(arg1) : r0, r1, memory ); }这段内嵌汇编的意思是先把arg0和arg1放r0、r1执行SWI 3再把r0里的结果存到result。GCC的约束写法第一次看会有点绕但配合注释其实不难理解。它比全局变量传参更贴近真实系统调用的样子推荐有精力就按这个方向练。3.4 返回指令MOVS PC, LR的原理SWI处理函数的最后一条指令我专门拿出来讲因为这里既有原理又有实用技巧。普通函数返回用mov pc, lr或者bx lr但异常处理返回用的是带S后缀的movs pc, lr。S后缀的作用是把SPSR恢复到CPSR。你回忆一下1.2节的内容进入SWI异常时硬件把旧CPSR存到了SPSR_SVC现在要返回用户态或者返回到原来的特权模式就必须把SPSR读回CPSR否则程序仍然停留在SVC模式各种特权、中断屏蔽状态都是错的。movs pc, lr相当于同时完成两件事PC LR也就是跳回SWI下一条指令CPSR SPSR_SVC也就是恢复异常发生前的状态。如果只写mov pc, lrPC回去了但模式还留在SVC、IRQ可能还关着程序下一步很可能莫名其妙地崩掉。这是SWI示例里最经典的一个bug。4. 常见问题与排查技巧实录我写的这个示例在板子上跑通的路上坑不少。这一章把实际调试中最常遇到的几个问题整理成清单方便读者对照排查。4.1 SWI一触发程序就跑飞这个现象可以说是SWI学习者的第一道坎。程序原本跑得好好的执行到SWI指令就死机PC跳到0x08后找不回来。常见原因有三个SVC模式的栈没初始化。这是最容易被忽视的。启动代码如果只设置了当前模式的spSWI一进来stmfd sp!, {...}会往非法地址写数据第一次压栈就崩。向量表没放对位置或者链接脚本里0x08处不是SWI_Handler。检查反汇编代码确认0x08地址上的指令确实是ldr pc, SWI_Handler。返回指令写了mov pc, lr忘了S。程序跳回后仍然滞留在SVC模式后面再遇到未定义指令或者IRQ整个状态全乱。排查手段很简单在SWI_Handler入口和返回处各放一个串口打印看能不能进、能不能出。如果进了出不来重点查返回指令如果压根没进重点查向量表和SVC栈。4.2 SWI号读出来不对或服务分支乱跳[lr, #-4]这条指令本身没问题但好多人读到的高24位没清干净或者条件码干扰判断。最典型的错误是只做了ldr r4, [lr, #-4]没有bic/and然后拿r4和1比较。SWI指令编码高8位不是0直接比较永远不相等。还有一个坑是Thumb状态。如果主程序以Thumb指令集运行比如在C代码里开了-mthumbSWI指令变成16位机器码布局完全不同。这时候要从[lr, #-2]取指令提取的是低8位不是24位。我见过有人把ARM状态的处理器代码和Thumb状态混着用最后SWI号全乱套。运行状态SWI指令长度取指令地址立即数位数ARM状态32位LR - 424位Thumb状态16位LR - 28位如果你的工程支持ARM/Thumb混合编译务必确认当前调用SWI的模式是哪种或者干脆统一用ARM状态。4.3 寄存器被覆盖导致参数丢失带参SWI调用时服务函数读到的r0、r1经常不是主程序传入的值。原因是处理函数一开始执行ldr r4, [lr, #-4]时如果借用了r0或r1当临时变量调用者参数就被冲掉了。所以我在3.1节特意用了r4来存SWI号就是为了避免碰r0-r3。另外如果服务函数内部还调用了其它C函数编译器会按AAPCS规则自由使用r0-r3这也会覆盖原始参数。这种情况下可以把参数先移到r4-r7或者压栈保护再调用子函数。裸机代码里没有操作系统帮你管寄存器一切都要自己小心。4.4 问题与排查速查表症状优先怀疑方向快速验证手段一执行SWI就死机SVC栈未初始化检查启动代码是否设置了SVC模式SP能进SWI但返回后跑飞返回指令少了S后缀确认使用movs pc, lr服务分支全都不匹配功能号提取不对检查bic是否清掉高8位传参永远是0或乱值r0-r3被内部覆盖用r4保存功能号或参数压栈板子在Thumb模式下乱跳Thumb/ARM状态混用检查编译选项和当前CPSR的T位5. 关于SWI的进阶思考与工程建议示例跑通之后让我对SWI有了更深的使用体会。它其实就像是一个手动挡的“系统调用”没有操作系统的时候我们照样可以用异常机制做一层受控的服务入口。理解了这一点再去回看RTOS源码里那些SVC/SWI相关代码思路会清晰很多。5.1 SWI与系统调用的关系很多嵌入式开发者都是从STM32的HAL库入门的被库函数保护得很好很少接触异常机制。一旦进入ARM9、Cortex-A这类处理器做裸机或轻量RTOS开发就会发现一个现象RTOS的任务切换里那个SVC指令触发流程和SWI几乎一模一样。实际上在Cortex-M上软件中断对应的异常就是SVCSuperVisor Call它就是SWI在ARMv7-M架构里的继承者。系统调用的本质是“用户程序请求特权服务”。用户态代码不能直接访问硬件寄存器于是把参数放好执行一条SVC/SWI指令CPU切到特权模式异常处理程序根据系统调用号跳转到对应内核函数执行完后再用异常返回机制切回用户态。这个模型和本文的示例完全同构只不过把串口输出换成了调度器切换、网络收发、文件读写。我个人建议想深入嵌入式OS的人一定要亲手写一遍SWI示例理解“异常”并不是只有外部中断这一种玩法。5.2 像设计API一样设计SWI服务层如果SWI只用来打印A、B、C那确实有点大材小用。真正工程中我更倾向于把它设计成一套稳定的内部服务接口。比如SWI 0x01UART发送一个字节SWI 0x02UART接收一个字节SWI 0x03读取系统TickSWI 0x04触发任务切换每个服务接口都对应一个C函数函数原型固定调用者只关心接口不关心底层寄存器。这样上层代码可测试、可移植底层替换硬件时只需修改SWI处理函数。我在一个裸机项目里用这个思路把LED、串口、按键全部包成了SWI服务后来换主控芯片业务代码一行没改只改了一层的实现。当然SWI也不是万能的它比普通函数调用多了模式切换的开销不适合高频调用比如每微秒一次的数据流处理还有就是SVC模式下的栈要付出额外的内存成本。所以工程上合理的做法是低频的、需要特权隔离的操作走SWI高频的热点路径还是直接编译链接调用更快。最后再分享一个我调试SWI时的小技巧把SWI_Handler入口地址打印出来和反汇编文件里0x08处的跳转目标对比能快速确认向量表是否生效在SWI服务函数里临时加一个不同颜色的LED翻转比看串口日志更直观因为异常处理现场可能本身就影响串口寄存器状态。这个笔记里的示例代码直接抄到自己的裸机工程上把栈地址和串口函数换成自己板子的基本就能跑通。SWI这个东西看着是ARM手册里一个冷僻的小章节真正用起来才知道它有多顺手。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询