在ARM裸机或者RTOS开发里,第一次听到“SWI(Software 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三级流水线的PC+8问题,我的经验是:在SWI异常上,架构已经把返回地址算好了,LR就是SWI指令地址+4,你直接往后推偏移就行,不用自己去套PC+8公式。
1.3 SWI指令格式:一条指令如何携带“功能号”
SWI指令在ARM指令集里有固定的二进制格式:高4位是条件码,接着4位固定为1111,作为SWI指令标识,低24位是一个立即数,一般叫SWI号或者注释字段。也就是说,SWI 0x123456这条指令,机器码大致是0xEF123456,低24位就是0x123456。我们写汇编时写的SWI 0、SWI 1,编译后就是这个格式。
这个24位立即数在工程里常被当作“功能号”使用。比如你设计一个微型系统服务层:SWI 0表示打印一个字符A,SWI 1表示打印字符B,SWI 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的向量表布局是:
| 地址偏移 | 异常类型 | 说明 |
|---|---|---|
| 0x00 | Reset | 复位异常,上电入口 |
| 0x04 | Undefined Instruction | 未定义指令异常 |
| 0x08 | Software Interrupt | 软件中断SWI,本文主角 |
| 0x0C | Prefetch Abort | 预取指异常 |
| 0x10 | Data Abort | 数据访问异常 |
| 0x14 | Reserved | 保留 |
| 0x18 | IRQ | 普通中断 |
| 0x1C | FIQ | 快速中断 |
启动代码里最常见的做法是每个槽位放一条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, lr,PC回去了,但模式还留在SVC、IRQ可能还关着,程序下一步很可能莫名其妙地崩掉。这是SWI示例里最经典的一个bug。
4. 常见问题与排查技巧实录
我写的这个示例,在板子上跑通的路上坑不少。这一章把实际调试中最常遇到的几个问题整理成清单,方便读者对照排查。
4.1 SWI一触发程序就跑飞
这个现象可以说是SWI学习者的第一道坎。程序原本跑得好好的,执行到SWI指令就死机,PC跳到0x08后找不回来。常见原因有三个:
- SVC模式的栈没初始化。这是最容易被忽视的。启动代码如果只设置了当前模式的sp,SWI一进来,
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代码里开了-mthumb),SWI指令变成16位,机器码布局完全不同。这时候要从[lr, #-2]取指令,提取的是低8位,不是24位。我见过有人把ARM状态的处理器代码和Thumb状态混着用,最后SWI号全乱套。
| 运行状态 | SWI指令长度 | 取指令地址 | 立即数位数 |
|---|---|---|---|
| ARM状态 | 32位 | LR - 4 | 24位 |
| Thumb状态 | 16位 | LR - 2 | 8位 |
如果你的工程支持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上,软件中断对应的异常就是SVC(SuperVisor Call),它就是SWI在ARMv7-M架构里的继承者。
系统调用的本质是“用户程序请求特权服务”。用户态代码不能直接访问硬件寄存器,于是把参数放好,执行一条SVC/SWI指令,CPU切到特权模式,异常处理程序根据系统调用号跳转到对应内核函数,执行完后再用异常返回机制切回用户态。这个模型和本文的示例完全同构,只不过把串口输出换成了调度器切换、网络收发、文件读写。我个人建议,想深入嵌入式OS的人,一定要亲手写一遍SWI示例,理解“异常”并不是只有外部中断这一种玩法。
5.2 像设计API一样设计SWI服务层
如果SWI只用来打印A、B、C,那确实有点大材小用。真正工程中,我更倾向于把它设计成一套稳定的内部服务接口。比如:
- SWI 0x01:UART发送一个字节
- SWI 0x02:UART接收一个字节
- SWI 0x03:读取系统Tick
- SWI 0x04:触发任务切换
每个服务接口都对应一个C函数,函数原型固定,调用者只关心接口,不关心底层寄存器。这样上层代码可测试、可移植,底层替换硬件时只需修改SWI处理函数。我在一个裸机项目里用这个思路把LED、串口、按键全部包成了SWI服务,后来换主控芯片,业务代码一行没改,只改了一层的实现。当然,SWI也不是万能的:它比普通函数调用多了模式切换的开销,不适合高频调用,比如每微秒一次的数据流处理;还有就是SVC模式下的栈要付出额外的内存成本。所以工程上合理的做法是:低频的、需要特权隔离的操作走SWI,高频的热点路径还是直接编译链接调用更快。
最后再分享一个我调试SWI时的小技巧:把SWI_Handler入口地址打印出来,和反汇编文件里0x08处的跳转目标对比,能快速确认向量表是否生效;在SWI服务函数里临时加一个不同颜色的LED翻转,比看串口日志更直观,因为异常处理现场可能本身就影响串口寄存器状态。这个笔记里的示例代码,直接抄到自己的裸机工程上,把栈地址和串口函数换成自己板子的,基本就能跑通。SWI这个东西,看着是ARM手册里一个冷僻的小章节,真正用起来才知道它有多顺手。