理解 SVC 机制就理解了 Unidbg 的灵魂。所有的 JNI 调用、系统调用、Hook 拦截,最终都汇聚到同一个入口。
打开 IDA 看一段 ARM64 的反汇编,你迟早会撞见这样一行:
.text:0000000000401234 svc #0
短短四个字节(0x010000D4),却是整个用户态程序与内核打交道的唯一通道。open 是它,read 是它,gettimeofday 是它,mmap 也是它。所有看起来天差地别的"系统调用",到最底层全都收敛成同一条指令。
更妙的是,Unidbg 在这条指令上动了手脚 — 它不只用 SVC 来模拟系统调用,还把 JNI 调用、Hook 回调、虚拟模块的函数实现,统统伪装成了 SVC。一条 ARM 指令,扛起了整个 Unidbg 的对外交互。
理解这个机制,你会明白几件以前可能一直困惑的事:
intno=2 这个数字会出现在几乎所有 Unidbg 的报错里?这一篇,我们就拆开这条 SVC 指令,看看它在 Unidbg 内部点燃了一连串什么样的连锁反应。
SVC 全称 Supervisor Call(管理调用),在 ARMv7 中曾叫 SWI(Software Interrupt,软件中断),ARMv8 之后统一改叫 SVC。作用只有一个:让 CPU 从用户态主动切换到内核态。
它的指令格式非常朴素:
ARM32: svc #imm24 ; 24 位立即数(实际只用低 8 位)
ARM64: svc #imm16 ; 16 位立即数
机器码示例:
ARM64: D4 00 00 01 → svc #0
ARM32: EF 00 00 00 → svc #0
立即数本身在大多数 Linux 系统上是没有意义的(按惯例填 0)。系统调用号是通过寄存器传递的:
r7 | r0r6 | r0 | |
x8 | x0x5 | x0 |
举个具体例子,调用 read(fd, buf, count) 在 ARM64 上的样子是这样:
mov x0, #3 ; 第一个参数 fd = 3
mov x1, x19 ; 第二个参数 buf 指针
mov x2, #1024 ; 第三个参数 count = 1024
mov x8, #63 ; 系统调用号 __NR_read = 63 (ARM64 用 x8 装载)
svc #0 ; 触发软件中断 → CPU 从 EL0 进入 EL1
; 内核完成 read 后返回, 结果写在 x0
在真机上执行这条 svc #0 时,CPU 内部发生的事情大概是:
x8 寄存器查 syscall 表,找到对应的内核函数(如 sys_read)x0eret 指令,恢复寄存器,返回用户态的下一条指令
整条用户态代码并不知道(也不需要知道)内核里到底发生了什么。它只知道:执行 svc #0,返回时 x0 里有结果。SVC 是一道单向门 — 你把请求扔进去,等待门那边的人把答案推回来。
类比一下:SVC 就像银行柜台的取号机。你不需要知道柜员是谁、后台系统是什么、钱从哪个金库取出来。你按下按钮(执行 SVC),等叫号(CPU 恢复执行),结果就在你手上了。柜台后面的世界,对你是完全不透明的。
现在切换到 Unidbg 作者的视角。你正在写一个 ARM 模拟器,需要让里面跑的 SO 代码能调用到外部的 Java 世界(这是 JNI 的本质)。问题来了:SO 代码是真实的 ARM 机器码,它怎么"跳出"模拟器去调用宿主机上的 Java 函数?
直觉上的方案有几个:
方案 A:扫描 PLT,识别 JNI 调用
Unidbg 在加载 SO 时遍历 PLT 表(Procedure Linkage Table,过程链接表),把每个 JNI 函数的导入项替换成自定义的处理逻辑。理论可行,但实现起来非常脏 — 每加载一个 SO 都要做一次符号解析,而且难以处理动态查表((*env)->FindClass(env, "...") 这种通过函数指针的调用)。
方案 B:拦截特定函数地址
为每个 JNI 函数指定一个伪造的地址(比如 0xdeadbeef00),SO 调用到这个地址时,模拟器抛出"非法访问"异常,然后在异常处理器里识别并分发。能跑,但语义上是"用 Bug 触发功能",很别扭。
方案 C:复用 SVC 机制
把 JNI 函数表中的每一项,指向一段预先生成的 ARM 代码。这段代码里只有一条 SVC 指令。当 SO 通过函数表调用 JNI 函数时,自然会执行到这条 SVC,触发模拟器的中断回调,然后由 Unidbg 在回调里完成实际工作。
Unidbg 的作者选择了方案 C。这是一个看起来朴素、实际却精妙到令人拍案的决策。
方案 C 的精妙之处在于,它复用了 ARM 架构本来就有的"用户态 → 特权态切换"语义:
这个等价关系一旦建立,所有事情都顺了:
更精妙的是:Unidbg 不需要给每个 JNI 函数指定一个唯一的 SVC 立即数(如果用立即数区分,立即数空间很快会不够)。它的做法是:用 SVC 指令所在的内存地址本身作为唯一标识。因为每段 SVC 桩代码是动态分配在不同地址上的,PC 值就是天然的"函数 ID"。
来看一个具体的例子。Unidbg 在初始化 DalvikVM 时,会为 200 多个 JNI 函数(FindClass、GetMethodID、CallObjectMethod、NewStringUTF...)每个都生成一段桩代码:
; FindClass 的 SVC 桩 (ARM64), 由 Arm64Svc.onRegister() 生成
; Unidbg 实际把这类桩分配在高地址区, 例如 0xfffe0030
0xfffe0030: svc #0 ; 触发中断, Unidbg 在回调里以 PC 为 key 查到 FindClass 的 Java handler
0xfffe0034: ret ; handler 执行完后, Unidbg 把结果写到 x0, 这里直接返回到调用者
JNIEnv 函数表(一个连续的指针数组,对应 JNINativeInterface 结构体)的对应槽位被填上 0xfffe0030。SO 代码里的 (*env)->FindClass(env, "java/lang/String") 经过 NDK 编译后大致是这样:
ldr x8, [x0] ; x0 = env, *env 是 JNINativeInterface 表的指针
ldr x9, [x8, #0x30] ; FindClass 在表中的偏移 (ARM64 下是 0x30)
mov x0, x19 ; 第一个参数: env
mov x1, x20 ; 第二个参数: 类名字符串地址
blr x9 ; 跳转 → 0xfffe0030 → svc #0 → 中断
最关键的一步是 blr x9:CPU 跳转到 0xfffe0030,下一条指令就是 svc #0。模拟器的中断回调被触发,Unidbg 检查当前 PC = 0xfffe0030,在自己维护的 Map<Address, Svc> 里查到这个地址对应 FindClass 的 handler,调用它,把结果写回 x0。然后 SO 代码继续从 blr 的下一行执行,它完全不知道刚才发生了什么。
再打个类比:这就像你给朋友发微信,他在群里 @ 了一个机器人。机器人没有真的把消息转给后台 — 它直接在群里回复了你。从你的视角看,你只知道"我发了消息,得到了回复",并不需要关心机器人是谁、后台在哪。Unidbg 就是那个机器人,SVC 指令就是 @ 它的方式。
把这个机制串起来看,威力才显出来。我们以一次真实的 FindClass("com/example/Util") 调用为例,跟着 CPU 走一遍完整的执行流。

SO 中的 C 代码:
JNIEXPORT jstring JNICALL Java_com_example_Util_sign(
JNIEnv *env, jobject thiz, jstring input){
// 第一步: 找到 com.example.Util 类
jclass cls = (*env)->FindClass(env, "com/example/Util");
// ... 后续逻辑
}
经过 NDK 编译,FindClass 这一行会变成几条 ARM64 指令:
; X19 保存了 env 指针
ldr x0, [x19] ; x0 = *env (指向 JNINativeInterface 表)
ldr x8, [x0, #0x30] ; x8 = FindClass 函数指针 (表偏移 0x30)
mov x0, x19 ; arg0 = env
adr x1, aClassName ; arg1 = "com/example/Util" 字符串地址
blr x8 ; 调用 → 跳转到 Unidbg 注册的 SVC 桩地址
CPU 跳转到 SVC 桩地址(沿用前面的例子 0xfffe0030):
0xfffe0030: svc #0 ; 关键的一条: 把控制权交给 Backend
0xfffe0034: ret ; handler 执行完后从这里返回到 SO 代码
执行 svc #0 时,Unicorn/Dynarmic 的指令模拟器识别出这是一条特权指令,立即触发预先注册的中断回调。在 Unidbg 中,这个回调由 AbstractEmulator 链路上的中断 handler 接收,最终调用到对应架构的 SyscallHandler。
进入 Java 世界。Unidbg 的中断处理逻辑大致是这样的(伪代码,便于理解):
// 简化后的中断分发逻辑
publicvoidhook(Backend backend, int intno, Object user){
if (intno != ARMV7_EXCP_SWI && intno != ARMV8_EXCP_SVC) {
// 不是 SVC 触发的中断, 走其他路径 (如内存访问异常)
return;
}
Emulator<?> emulator = (Emulator<?>) user;
UnidbgPointer pc = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_PC);
// 关键: 用 PC 地址查 svcMemory 中注册的 handler
Svc svc = svcMemory.getSvc(pc.peer);
if (svc != null) {
// 命中 → 这是一个 JNI 调用 / 虚拟模块函数 / Hook 回调
long ret = svc.handle(emulator);
backend.reg_write(Arm64Const.UC_ARM64_REG_X0, ret);
return;
}
// 没命中 → 这是一个真正的 Linux 系统调用
long NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).longValue();
handleSyscall(emulator, NR);
}
注意这里的关键决策点:是用"PC 地址"还是"x8 寄存器"来分发,取决于这个 SVC 桩有没有被预先注册过。
这就是 Unidbg 区分两类 SVC 的方式,没有冲突,一套机制。
svcMemory.getSvc(0xfffe0030) 命中,拿到的 Svc 对象是 Unidbg 内部对 FindClass 的包装。它的 handle 方法大致是:
// DalvikVM 中 FindClass 的注册片段 (简化)
Pointer _FindClass = svcMemory.registerSvc(new Arm64Svc("FindClass") {
@Override
publiclonghandle(Emulator<?> emulator){
RegisterContext context = emulator.getContext();
Pointer env = context.getPointerArg(0); // x0
Pointer namePtr = context.getPointerArg(1); // x1
String className = namePtr.getString(0); // 读出 "com/example/Util"
// 转交给用户的 Jni 实现 (通常是 AbstractJni 子类)
DvmClass dvmClass = checkJni(vm, this).resolveClass(className);
// 把 DvmClass 注册到本地引用表, 返回它的 jobject 句柄
return dvmClass.hashCode();
}
});
这一步进入了"补环境"的领地 — resolveClass 最终会调用到你写的 AbstractJni 子类的 resolveClass 方法。如果你重写了,返回你提供的 DvmClass;如果你没重写,Unidbg 抛出"resolveClass not implemented"的异常。
等等,为什么 JNI 报错的栈帧里总有一行
svc handle? 现在你应该明白了。所有 JNI 调用的"现场"都是这一条 SVC 指令触发的中断回调。报错栈帧的最底部,永远是中断 handler;上面那一长串,才是你写的 Java 代码。
handle 方法返回一个 long(DvmClass 的句柄)。Unidbg 把它写到 x0 寄存器,然后让模拟器从 SVC 桩的下一条指令(ret)继续执行。ret 指令把控制流返回到 SO 代码中 blr x8 的下一行,整个 FindClass 调用结束。
从 SO 代码的视角看,它就像调用了一个普通的函数:传入参数,得到返回值。中间发生的所有事情 — 中断、异常、查表、回调到 Java、用户的 AbstractJni 处理 — 对它来说是完全透明的。
SVC 这道门,进出的不只是 JNI 调用。让我们看看 Unidbg 中所有走 SVC 的路径,你会发现这个设计的统一之美。

刚才详细讲过的。JNIEnv 和 JavaVM 函数表里的每一项都是一段 SVC 桩。约 200 多个函数,对应 200 多段桩代码。
关键文件:unidbg-android/src/main/java/com/github/unidbg/linux/android/dvm/DalvikVM64.java(ARM64 版)和 DalvikVM.java(ARM32 版),里面密密麻麻的 svcMemory.registerSvc(new ArmSvc(...) {...}) 就是注册过程。
SO 代码或 libc 内联汇编里的 svc #0。这一类是真的会跑到内核处理函数(Unidbg 的 ARM32SyscallHandler / Arm64SyscallHandler),需要根据 x8(或 r7)的系统调用号分发到具体的实现。
关键文件:unidbg-android/src/main/java/com/github/unidbg/linux/ARM32SyscallHandler.java 和 Arm64SyscallHandler.java。打开看看你会被 case 语句的密度震撼到 — 一个超长的 switch,每个 case 是一个 syscall 编号。
Unidbg 支持"虚拟模块"(VirtualModule)— 比如 libc.so 中的某些函数(如 __system_property_get),Unidbg 自己用 Java 实现,然后注册成一个看起来像真 SO 的模块。每个被虚拟化的函数的"代码",其实也是一段 SVC 桩。
当 SO 通过 PLT 跳转调用 __system_property_get 时,跳转的目标就是这段桩代码,于是 SVC 触发,Unidbg 接管。
Inline Hook 的实现需要在被 Hook 的函数入口插入一段代码,让原本的执行流转跳到用户的回调函数。Unidbg 的实现里,这段"插入的代码"也是一段 SVC 桩。
也就是说:当你用 HookZz Hook 了某个函数,函数入口被改成了 svc #0; ret,SO 一执行到这里就触发 SVC,Unidbg 在回调里执行你的 Java 代码。
这是 Unidbg 内置的、专门拦截 __system_property_get 系统属性获取的机制。它的工作方式是 SVC Hook — 在 SVC 中断 handler 里加一道前置检查:如果当前 PC 落在 __system_property_get 的 SVC 桩上,就调用用户注册的 PropertyProvider 来生成返回值。
层级关系是:
SO 调用 __system_property_get
↓
PLT 跳转到 Unidbg 的 SVC 桩
↓
svc #0 → 中断回调
↓
SyscallHandler 检查 PC, 命中 __system_property_get
↓
SystemPropertyHook 介入, 调用 PropertyProvider
↓
返回属性值, ret 回 SO 代码
这就回答了第十篇大纲里提到的一个常见错误:为什么 SystemPropertyHook 必须在 loadLibrary 之前注册? 因为 PLT 解析是在 loadLibrary 时完成的,PLT 表项指向的桩代码地址在那一刻就被定下来了,之后再注册 Hook,桩地址都没你的份。
你在用 Unidbg 时一定见过这种报错栈:
java.lang.UnsupportedOperationException: callObjectMethod
com/example/Util->getDeviceId(Landroid/content/Context;)Ljava/lang/String;
at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:...)
at com.github.unidbg.linux.android.dvm.DalvikVM64$XXX.handle(DalvikVM64.java:...)
at com.github.unidbg.linux.ARM64SyscallHandler.hook(ARM64SyscallHandler.java:...)
...
注意最底下那一行 — ARM64SyscallHandler.hook。所有 JNI 报错的栈底,都是 SyscallHandler 的中断回调。第一次看可能觉得"明明是 JNI 问题,关 syscall handler 什么事?",现在你知道了:JNI 报错和 syscall 报错本质上是同一类报错,都是 SVC 中断里抛出来的。
这给排错带来一个实用技巧:看到任何报错时,先看倒数第二帧(紧挨着 hook 的那一层),它会告诉你这次中断的"性质"是什么 — JNI 函数?syscall?虚拟模块?
Frida 是注入到目标进程内的 V8 脚本引擎。它能 Hook 的最底层是用户态的函数入口(PLT、函数符号、内联汇编位置),但它没法 Hook SVC 指令本身。一旦 SO 代码执行了 svc #0,控制权就交给内核了,Frida 看不到内核里发生了什么。
Unidbg 不一样。SVC 是它自己实现的,所有 SVC 中断都从它手里过。你可以:
后面的章节会介绍"指令级 Trace"和这一层关系密切:Trace 实现的本质是在 Unicorn 的指令执行回调里加日志,而 SVC 机制让你能在更高的抽象层次("这是一次外部交互",而不是"这是一条普通指令")去做监控。
回想第三篇讲的 Backend 选型。SVC 机制能解释一件事:为什么 Dynarmic 不支持指令级 Hook,但仍然支持 SVC 中断?
答案是:SVC 是 ARM 架构定义的特权指令。无论用解释执行(Unicorn)还是 JIT 编译(Dynarmic),SVC 都必须触发宿主机这边的处理器接管 — 它是模拟器和外部世界的契约边界。Dynarmic JIT 编译时,会为 SVC 指令保留一个回调钩子,编译后的本机代码执行到 SVC 时还是会调用 Unidbg 的 Java handler。
这就是为什么 Dynarmic 上 JNI 调用照样能跑、syscall 照样能处理 — 走 SVC 的那部分能力是所有 Backend 共享的。Dynarmic 只是丢掉了"在每一条普通指令前后插入回调"的能力,并没有丢掉"在 SVC 触发时插入回调"的能力。
| 是 | ||
| 是 | ||
| 是 |
理解了这个矩阵,你就能解释自己之前可能踩过的坑:在 Dynarmic 上跑某个原本在 Unicorn 上工作的脚本,如果用了 traceCode 会失效,但补环境的 JNI 回调依然正常 — 它们走的不是同一条路。
Unidbg 的 SVC 机制是一个典型的"用对了原语"的设计:
下次再看到 Unidbg 报错栈底的 ARM64SyscallHandler.hook,你应该不会再困惑了。那是一道门 — 一道用一条 ARM 指令撑起整个 Unidbg 调度系统的门。