mmockrs — 超越 Trait 的自由函数模拟与线程安全
摘要
mockrs 通过在运行时修改函数入口机器码并结合信号处理与线程本地存储(TLS),实现对任意函数(自由函数、具体方法、FFI 函数)的精确模拟,并在多线程环境中提供严格的线程隔离。相较传统基于 Trait 的模拟库,mockrs 不需要对生产代码进行接口化重构,适合集成测试与复杂边界场景。本文系统阐述问题背景、设计目标、顶层模型与可行性、关键方案、实现细节、跨架构差异、工程化与测试、效果评估以及局限与展望。
1. 引言
1.1 背景与动机
在 Rust 生态中,测试是构建可靠软件的基石。传统模拟库(如 mockall)功能强大,但几乎都基于 Trait 工作:开发者需要先定义 Trait,再生成模拟实现并通过依赖注入在测试时使用。这一模式鼓励面向接口编程,但也带来如下局限:
无法模拟自由函数(fn my_func(...))
无法模拟外部依赖的具体实现(未通过 Trait 暴露)
为可测试性引入不必要的设计约束(重构成本高)
难以处理 FFI 函数
1.2 问题陈述
目标是在不修改被测代码设计的前提下,能够在运行时对任意函数进行拦截与替换;并且在多线程场景中,确保仅对指定线程生效,其他线程无感。
1.3 目标与非目标
目标:
任意函数模拟(自由函数、具体方法、FFI)
线程隔离(仅影响当前线程)
易用性(提供 mock! 宏)
跨架构支持(x86_64 与 aarch64)
RAII(作用域结束自动恢复)
非目标:
性能极致优化的热点路径替换(每次调用都会引发信号)
1.4 快速上手
依赖与导入:use mockrs::mock;
基本用法:传入原函数与模拟函数的名称,返回的 Mocker 在当前线程内生效,离开作用域自动恢复。
示例:
线程隔离:mock! 仅对创建它的线程生效。其他线程调用原函数不受影响。
小贴士:
若函数被强内联,测试中可临时加 #[inline(never)] 避免内联,确保有独立入口可挂钩。
模拟函数的签名需与原函数一致(参数与返回值一致)。
2. 顶层模型与可行性
2.1 核心思路
mockrs 不在类型系统层面做替换,而是在运行时直接对函数入口的机器码做最小篡改:将第一条指令替换为陷阱指令(x86_64 上的 int3,aarch64 上的 brk)。任何线程执行到此处都会触发 SIGTRAP,由全局信号处理器接管执行流;随后根据“线程本地模拟表(TLS)”决定是跳转到模拟函数,还是执行被备份到“蹦床(Trampoline/Trunk)”中的原始指令并精确恢复。
2.2 顶层模型
全局提问:陷阱指令 + 全局信号处理器捕捉 SIGTRAP
线程本地回答:TLS 中的 G_THREAD_REPLACE_TABLE 决定本线程是否模拟以及跳转目标
蹦床(Trampoline;与实现中的变量命名保持一致,文中也称 Trunk):备份原始指令(必要时重写以修复 PC 相对寻址),并在必要时带跳回序列
RAII:Mocker 生命周期结束时撤销当前线程的模拟记录
顶层模型示意图:

2.3 高阶架构图

2.4 设计原则
最小侵入:仅修改函数入口处字节
可证恢复:未模拟线程路径具备确定性和可验证的恢复流程
架构适配:针对 x86_64 与 aarch64 的指令集特性分别采用最稳健机制
工程可用:提供 CI 与 aarch64 交叉测试保障
2.5 Linux 信号处理与寄存器修改可行性
利用 Linux 的信号处理机制(sigaction + SA_SIGINFO),用户态信号处理器可在接收 SIGTRAP 时直接读取并修改 ucontext_t 中保存的寄存器快照(如 x86_64 的 RIP/EFLAGS、aarch64 的 PC/X 寄存器)。当信号处理器返回时,内核依据该上下文恢复用户态寄存器,因而“修改后的寄存器值立即生效”,从而实现执行流重定向。这一机制是 mockrs 基于陷阱指令在用户态完成“跳转到模拟函数”或“切入蹦床”的可行性根基。
信号处理总流程(从陷阱到恢复):

关键事实(可行性证明):
用户可写的寄存器上下文:使用 sigaction 注册 SA_SIGINFO 处理器后,第三个参数为 void,在 glibc 下可转为 ucontext_t。通过 uc_mcontext 可直接读写通用寄存器与标志位:
x86_64:如 gregs[REG_RIP]、gregs[REG_EFL](EFLAGS),可将 RIP 改为模拟函数地址。
aarch64:如 uc_mcontext.pc、uc_mcontext.regs[Xn],可将 PC 改为蹦床/模拟函数地址;按需使用 X17 作为跳转寄存器。
修改当次返回点:信号处理器返回后,内核按 sigframe 恢复寄存器;因此“在处理器返回用户态前修改寄存器”即可无 syscalls 地改变后续执行流,确保低开销与确定性。
线程隔离天然成立:信号由触发陷阱的“当前线程”接收与处理,结合 TLS 决策,仅影响当前线程;其他线程即使执行同一函数,也会独立经由各自的 TLS 与处理器路径。
工程建议:
使用 sigaction(..., SA_SIGINFO | SA_RESTART | SA_ONSTACK, ...) 提升健壮性;可在高栈消耗场景配置 sigaltstack 作为信号栈。
仅在测试/开发环境使用该机制(涉及 RWX 与自修改代码);aarch64 需在写入后执行 dsb sy + ic ivau + isb 刷新 I-Cache。
3. 与 Trait 模拟的差异与动机细化
3.1 差异对比

3.2 具体局限性示例:为什么仅有 Trait Mock 与非线程独立 Mock 不够
3.2.1 基于 Trait 的 Mock 的局限性(示例)
场景 A:自由函数/FFI 难以通过接口注入
现状:许多现存代码直接调用自由函数或 FFI,例如 std::fs::read_to_string、libc::gettimeofday/clock_gettime、自定义 util::now() 等。
典型业务代码:
若用 Trait Mock,需要为可测试性重写设计,引入接口与依赖注入:
问题:
扩散式改动:business 的签名变化会沿调用栈层层传递,影响大量文件与模块。
第三方/FFI 不可控:对于外部 crate 或 C 接口,无法强制其改为 Trait 暴露,更无法修改其调用点。
API 稳定性受损:为了测试引入的 Trait/泛型约束,改变了对外 API 与类型边界,增加维护复杂度。
设计被测试绑架:把“可测试性”要求强加到生产代码,违背最小侵入原则。
场景 B:静态/关联函数、具体实现难以抽象
例如某具体类型的关联函数 T::connect() 或全局初始化函数 init_logging(),若非预先以 Trait 抽象,事后很难安全地改为依赖注入;即便能改,也需要跨层接口调整与生命周期/Send/Sync 约束处理,成本与风险都很高。
mockrs 的解法:
直接在运行时替换这些自由函数/具体实现的入口,无需改动被测代码设计;测试结束自动恢复,保持生产代码纯净。
3.2.2 非线程独立的 Mock 的局限性(以 mockcpp 为例)
背景:传统 C/C++ 打桩方案(以 mockcpp 等为代表)常通过对全局符号的进程级替换来实现自由函数模拟。这类替换通常是“全局生效”的:一旦设桩,整个进程内所有线程都会看到被替换后的行为。
并发下的典型问题(伪代码示例,展示现象而非特定 API):
现象:
污染:线程 B 在 A 的桩有效期间也会命中桩,预期与实际不一致。
竞态:若 B 在 A 清桩的同时执行 f(),可能出现未定义行为或间歇性失败。
难以并行:为了避免互相污染,只能串行化测试或使用复杂同步,降低测试吞吐量。
mockcpp 一类“进程级打桩”方案的局限性总结:
缺乏线程隔离:替换范围是进程级而非线程级,不能满足“同进程多场景并行”的测试需求。
全局状态竞争:桩的安装/卸载与被测代码的多线程调用相互交织,容易产生数据竞争与时序问题。
复杂的用例下难以维护:需要额外同步与测试编排来规避污染,成本高且脆弱。
mockrs 的对照优势:
替换记录保存在 TLS,仅对创建桩的线程生效;其他线程走“蹦床 + 恢复”路径,始终执行真实实现。
无需跨线程同步与封锁即可并行跑不同场景的测试;RAII 生命周期自动恢复,降低误用概率。
4. 关键挑战与解决方案
我们面临五个关键问题:权限(可写)、劫持(拦截执行流)、隔离(线程粒度生效)、重定位(PC 相对修复)、恢复(精确跳回)。以下给出整体解法。
4.1 权限与劫持:mprotect + 陷阱指令
使用 nix::mprotect 在运行时将目标页权限临时改为 RWX
在函数入口写入陷阱指令(x86_64 上的 int3,aarch64 上的 brk)
将被覆盖的第一条原始指令备份到“蹦床”
4.2 执行路径分发与线程隔离
全局信号处理器根据 TLS 判定:有模拟则重定向至新函数,无则执行蹦床中的原始指令
4.3 精确恢复:一次 SIGTRAP + 自包含蹦床(x86_64 / aarch64)
x86_64:不再依赖单步。未命中模拟时,信号处理器将 RIP 设为蹦床主体起始(trunk_addr+3)。蹦床根据指令类型:
非分支:执行(可能经重写的)原始指令,随后使用 jmp [rip+0] + 8 字节字面量的绝对跳回至 orig+len。
分支(相对 CALL/JMP):改写为间接绝对调用/跳转:• CALL:
CALL [RIP+0]; JMP +8; dq target_abs
(返回后继续执行蹦床尾部并回跳到 orig+len)• JMP:JMP [RIP+0]; dq target_abs
(不追加回跳,控制流直接离开)aarch64:一次 SIGTRAP,蹦床自带“指令 + 回跳/跳转”序列,语义等价。
4.4 重定位(指令重写)
x86_64:iced-x86 识别 RIP 相对寻址,挑选未使用的 caller-saved 寄存器作为“桥接寄存器”;在信号处理器中临时写入 next_ip 并执行重写后的指令
aarch64:对 B/BL 等分支生成“LDR X17, #8; BR/BLR X17; <绝对地址>”;对 ADRP/ADR 在蹦床 PC 重编码或回退到“字面量装载 + 跳转/使用”;必要时对 literal LDR 采用保守序列避免 SIGILL
5. 执行流程总览与细化
5.1 x86_64 核心执行流程

5.1.1 分支处理与局限
已支持的相对分支改写:
CALL rel32 ->
CALL [RIP+0]; JMP +8; dq target_abs
(返回后继续蹦床并回跳 orig+len)JMP 短/近 ->
JMP [RIP+0]; dq target_abs
(不追加回跳)仍存的局限:条件分支(Jcc rel8/rel32)作为首条指令暂未改写,若出现在入口,蹦床直接重编码将因相对位移基准改变而导致目标地址错误。规避建议:
尽量避免函数入口为 Jcc,或调整布局使首条为非分支/间接跳转;
在无法调整时,优先对调用点进行模拟(mock 调用者)。
5.2 aarch64 核心执行流程

5.2.1 为什么 aarch64 没有单步/写回抑制机制?是否不需要?
设计选择:aarch64 路径采用“一次 SIGTRAP + 自包含蹦床”的确定性流程,不依赖单步。信号处理器只负责把 PC 指向蹦床:
若首条是分支(B/BL/B.cond 等):在 save_old_instruction 的分支处理路径中,会将相对分支重写为“LDR X17, #8; BR/BLR X17; <绝对目标地址>”,直接跳向真实目标,不再返回原函数入口。这等价于 x86_64 中“RIP 已被改写则不写回”的效果,但由蹦床自身保证,无需在信号返回时再做判断。
若首条为非分支:蹦床末尾固定附加“LDR Xt, #8; BR Xt; <orig+len>”的跳回序列,执行完原始/重写指令后自动回到 orig+len 继续,无需第二次 SIGTRAP。
为什么不实现单步:
可用性与复杂度:x86_64 通过 EFLAGS.TF 可直接进入单步;aarch64 的单步需要操作调试状态(如 PSTATE.SS/调试寄存器)并通常经由 ptrace 等机制,在信号处理器内启停不如 x86 简便、可移植。
ISA 特性:aarch64 指令定长 4 字节,易于在蹦床中构造“绝对跳转/跳回”序列;相对分支与 PC 相对访问可以在蹦床处重编码或改为“装载绝对地址 + 间接跳转/使用”,因此无需依赖单步去“原位执行”。
稳健性与兼容:一次 SIGTRAP 避免两次陷阱带来的时序/嵌套信号问题;在 QEMU 等仿真环境更稳定。实现中也配套执行 I-Cache 刷新,保证自修改代码一致性。
性能:单次陷阱比两次陷阱更低开销。
是否不需要:是。由于蹦床已分别对“分支”和“非分支”路径提供了精确的前进/回跳语义,aarch64 不需要在信号返回时做“写回抑制”判断;控制流转移由蹦床指令序列直接体现。
6. 平台差异与原因
6.1 x86_64 与 aarch64 的差异(概览)
入口陷阱:int3(1B) vs brk(4B)
未模拟路径:两者均为一次 SIGTRAP + 自包含蹦床(原始/重写指令 + 回跳或直达分支目标)
蹦床组织:x86_64 变长指令 + 3B 头;aarch64 4B 对齐 + 跳回序列 + 字面量地址
指令重写:寄存器桥接 vs 绝对地址序列/重编码
条件分支改写:x86_64 已支持 Jcc 的“反条件短跳 + 绝对跳转”改写;aarch64 的 B.cond 暂未实现(计划中)
临时寄存器:动态选择 caller-saved vs 固定 X17
I-Cache:通常无需显式刷新 vs 需要 dsb sy + ic ivau + isb
性能形态:精确单步(两次陷阱) vs 确定性跳回(一次陷阱)
兼容性:x86_64 倚重成熟单步;aarch64 强调对齐与缓存维护的保守策略
6.2 原因分析
ISA 差异导致断点指令长度、PC 相对编码与重写成本不同
x86 I/D cache 一致性由硬件保障;ARM 对自修改代码要求显式缓存维护
平台约定(如 X17 用作跳转寄存器)与 ABI 差异影响临时寄存器选择
调试能力差异决定了单步/跳回的可移植性与稳健性
7. 实现细节
7.1 关键数据结构
全局蹦床地址表:G_TRUNK_ADDR_TABLE: Mutex<HashMap<usize, usize>>(原函数入口 -> 蹦床地址)
线程本地模拟表:G_THREAD_REPLACE_TABLE: thread_local RefCell<HashMap<usize, Vec>>(原函数 -> 模拟函数栈)
作用域恢复:Mocker 的 Drop 在本线程内出栈并清理
7.2 代码区与内存管理
通过 mmap 分配可读/写/执行的“代码区”作为蹦床区域
默认容量:8 页(PAGE_SIZE=4096),可通过环境变量 MOCKRS_CODE_AREA_SIZE_IN_PAGE 调整
Droper 的 Drop 在进程退出时 munmap 回收
7.3 内存权限
写入函数入口或蹦床时,使用 mprotect 临时修改目标页权限为 RWX
aarch64 场景下,每次写入后执行 dsb sy + ic ivau + isb 刷新指令缓存
7.4 蹦床格式
x86_64:3 字节头(old_len/new_len/replace_reg)+ 重写后的单条指令;指令变长,无固定对齐
aarch64:3 字节头 + 4 字节对齐填充 + 重写指令序列 + 跳回指令 + 8 字节字面量地址
7.5 指令重写策略
x86_64:iced-x86 解析 RIP 相对寻址,动态选择 caller-saved 寄存器作为桥接,信号路径内负责寄存器保存/恢复
aarch64:对分支/PC 相对指令进行重编码或使用“LDR X17 + BR/BLR + 字面量地址”的保守序列;在仿真环境下优先稳健方案避免 SIGILL
7.5.1 x86_64 临时寄存器(桥接寄存器)设计
设计目标:当首条指令含 RIP 相对的内存操作数(如 mov rax, [rip+disp])时,需在“蹦床地址”正确取数。做法是把该内存基址从 RIP 改写为一个“临时寄存器 r_tmp”,并在信号路径中临时把 r_tmp 设为 next_ip(orig_addr + old_len),使 [r_tmp + disp’] 与原地等价。
候选集合(遵循 System V x86_64 ABI 的 caller-saved):{ RAX, RCX, RDX, RSI, RDI, R8, R9, R10, R11 }。
选择策略:使用 iced-x86 的 InstructionInfoFactory::used_registers() 剔除所有显式/隐式已用寄存器,从候选集合中选取首个未使用者作为 r_tmp,避免破坏被测指令语义。
重写与编码:
将指令的内存基址从 RIP 改为 r_tmp;
将位移改写为 (EA - next_ip),EA 为原指令计算出的有效地址,next_ip 为原指令的下一条指令地址;
将重写后的机器码写入蹦床,并在蹦床头记录 old_len/new_len/replace_reg(r_tmp)。
信号路径与恢复:
未命中模拟:将 RIP 设为 trunk_addr+3,返回后开始执行蹦床主体。
蹦床内部:如需替换寄存器,push 保存临时寄存器;mov 临时寄存器 = orig_addr + old_len;执行重写后的指令;pop 恢复寄存器;最后通过
jmp [rip+0]
+ 8 字节字面量跳回 orig_addr + old_len。实现备注:
当前实现的上下文寄存器索引映射涵盖:RAX、RBX、RCX、RDX、RDI、RSI;后续可扩展到 R8–R11 以与候选集合完全一致。
r_tmp 属于 caller-saved,且在第二次 SIGTRAP 恢复,满足 ABI 约定与语义正确性。
示意图:x86_64 临时寄存器桥接流程

7.6 核心技术栈
nix:封装 POSIX 系统调用
lazy_static:安全初始化全局静态数据
iced-x86(x86_64)与 capstone(aarch64):反汇编与编码机器指令
8. 使用约束与最佳实践
函数签名需匹配:模拟函数参数与返回值应与原函数一致。
内联与可挂钩性:被完全内联的函数没有独立入口,无法挂钩;测试中可临时标注 #[inline(never)]。
泛型函数:经单态化生成的具体实例通常可模拟,但需确保该具体实例存在可寻址符号。
FFI 函数:可模拟,需保证符号可解析且存在入口(动态链接函数通常亦可);请在测试环境中评估平台差异。
异步与线程:mock! 仅在创建它的线程生效。异步执行若跨线程调度,模拟不会随任务迁移;需要时可在单线程运行时或显式线程边界内使用。
嵌套与栈语义:同一线程对同一函数可多次 mock,形成“后进先出”的覆盖栈;Drop 时对应条目出栈并在栈空时清理。
安全与合规:依赖 RWX 可执行内存与自修改代码,仅建议在测试/开发环境使用;aarch64 下请遵循 I-Cache 刷新序列。
失败处理:未模拟线程/路径将透明走“蹦床 + 恢复”流程,确保最小侵入与可预测性。
9. 工程化与测试
CI 跨架构验证:仓库提供 GitHub Actions 工作流,除常规 x86_64 编译/测试外,还通过 qemu-user-static 在 Ubuntu 上透明执行 aarch64 目标二进制,并使用 aarch64-linux-gnu 工具链进行交叉编译与链接。关键步骤:
安装 qemu-user-static 和 binfmt-support,并启用 qemu-aarch64
安装 aarch64 交叉工具链与运行时依赖(gcc-aarch64-linux-gnu、libc6-dev-arm64-cross、libc6:arm64 等)
安装 Rust 工具链并添加 aarch64-unknown-linux-gnu 目标
设置环境变量:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
QEMU_LD_PREFIX=/usr/aarch64-linux-gnu
分别执行 cargo build/test --target aarch64-unknown-linux-gnu
本地运行 aarch64 测试:在开发机上同样可通过上面的工具链与环境变量,借助 QEMU 用户态仿真透明运行 aarch64 目标的测试。
10. 效果评估
通用性:实现“万物皆可模拟”,覆盖自由函数、具体方法与 FFI 场景
低侵入性:无需为测试重构生产代码(如强行引入 Trait)
接口简洁:mock! 宏屏蔽底层复杂度
并发安全:线程隔离设计可支撑并行测试而不发生状态污染
11. 局限性与未来展望
11.1 当前局限性
性能开销:每次调用都会触发 SIGTRAP,开销高于常规调用;不适合性能极致场景
无法模拟被完全内联的函数:编译器内联后缺失独立入口,无法挂钩
极短函数/特殊布局:若入口替换不安全,则可能失败(实践中罕见)
11.2 未来展望
性能优化:探索更高效的蹦床技术(如按需写回,减少后续陷阱)
更广平台:扩展到更多 CPU 架构与系统(含 32 位与 Windows)
更强 API:如调用次数统计、参数捕获等高级能力
11.3 aarch64 已知限制与待办
分支改写覆盖范围:
已覆盖:BL(带回跳)、B(无条件跳转,不回跳)
暂未覆盖:B.cond(条件分支)。后续将参照 x86_64 的 Jcc 方案,采用“反条件短跳 + 绝对跳转 + 尾部回跳”的等价序列,保持在蹦床处语义一致。
BL 回跳序列布局(稳定版):
采用
LDR X17, #16; BLR X17; B +16; NOP; <target_abs>; LDR X16, #8; BR X16; <orig+len>
的布局设计要点:字面量按 8 字节对齐;从 BLR 返回后通过
B +16
跳过 NOP 和 8 字节字面量,直接落到回跳序列;避免“返回落入字面量导致非法执行”的风险QEMU 行为差异:
在 qemu-user 环境下,aarch64 的 BL 蹦床序列仍可能在特定工具链/内核组合下出现异常或卡住(与模拟器实现与 icache 行为有关)
仓库测试中已将 aarch64 的 BL 用例临时标注为
#[ignore]
,建议在真机 aarch64 环境通过cargo test -- --ignored
进行验证指令缓存一致性:
每次向蹦床写入后均执行
dsb sy
、ic ivau
、isb
刷新指令缓存;仍建议避免在早期启动阶段修改可能尚未稳定映射/缓存的区域后续计划:
实现 B.cond 条件分支的等价改写
扩充更多入口形态与跨页/对齐边界的回归测试
在 CI 中引入真机或更稳定的 aarch64 运行环境,移除对
#[ignore]
的依赖
12. 安全与合规说明
代码区与目标页以 RWX 方式写入,适用于测试与开发环境;生产环境需充分评估风险与合规性
aarch64 下自修改代码需严格执行 I-Cache 刷新序列
13. 结论
mockrs 以“全局提问、线程本地回答”为核心思想,通过最小侵入的入口陷阱与稳健的架构适配,解决了传统基于 Trait 的模拟方案无法触及的自由函数/FFI 等场景,并在多线程条件下提供可证的隔离与恢复能力。该方案为 Rust 的复杂测试需求提供了强大而务实的工具基础。
附录:常见问题 FAQ
Q: 可以模拟哪些函数?A: 只要有可寻址的函数入口即可,包括自由函数、具体方法与多数 FFI 符号(动态链接函数一般也可)。闭包与内联后的代码无法模拟。
Q: 与原函数签名需要一致吗?A: 是。模拟函数的参数、返回值需与原函数一致,否则行为未定义。
Q: 遇到被内联的函数怎么办?A: 在测试配置中为目标函数临时加 #[inline(never)],确保生成独立入口。
Q: 多线程/异步下如何使用?A: mock! 仅在创建它的线程生效。异步任务若跨线程调度,模拟不会跟随任务迁移;可在单线程运行时或固定线程池中使用。
Q: 是否支持嵌套多次 mock 同一函数?A: 支持,同线程内形成覆盖栈;Drop 时按栈语义恢复。
Q: 能否用于生产环境?A: 不建议。该方案依赖 RWX 内存与自修改代码,主要为测试/调试场景设计。
Q: 模拟 FFI 有何注意事项?A: 确保符号已链接且在运行时可解析;在 aarch64 下注意 I-Cache 刷新,避免在早期启动阶段修改尚未映射完成的页。
版权声明: 本文为 InfoQ 作者【SkyFire】的原创文章。
原文链接:【http://xie.infoq.cn/article/926c8f45d7821eaed0cad8fc7】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论