news 2026/10/7 2:00:37

Harmony CodeMatcher 详解:用 IL 指令匹配与替换实现精准运行时补丁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Harmony CodeMatcher 详解:用 IL 指令匹配与替换实现精准运行时补丁
  • 开发工具

【免费下载链接】Harmony

A library for patching, replacing and decorating .NET and Mono methods during runtime

项目地址:https://gitcode.com/gh_mirrors/ha/Harmony
点击查看免费下载

Harmony 是一套面向 .NET/Mono 的运行时补丁库,而CodeMatcher是它提供给 Transpiler(IL 改写器)最强大的工具之一:它以"游标"的方式在 IL 指令序列中移动,通过匹配方法定位指令片段,再执行插入、移除或替换操作。本文以游戏 Mod 场景中"替换Kill()调用为事件 API"的真实案例为主线,完整讲解CodeMatcher、CodeMatch与Code的匹配机制、失败处理策略以及Repeat循环改写方法,并结合 CodeMatcher 源码 与单元测试深入其底层实现,帮助你写出能够在游戏/应用更新后依然稳健运行的 Transpiler。

为什么需要 CodeMatcher

传统 Transpiler 直接接收一个IEnumerable<CodeInstruction>,通过手动遍历for/foreach循环逐个检查指令来完成改写。这种方式在面对"找到某个方法调用并替换"这类需求时非常繁琐:你需要自行维护索引、比较操作码与操作数、小心处理位置偏移。

CodeMatcher正是为解决这类问题而设计。正如其在 源码 中的注释所示,它是一个 "A CodeInstruction matcher"。它内部持有:

  • 一份指令列表codes(构造时从传入的instructions深拷贝而来);
  • 一个当前游标位置Pos,初始值为-1(越界状态);
  • 上一次匹配的结果状态与错误信息。

你可以把它想象成一个"可前进、可后退、可回卷"的光标:先用Match...()/Search...()系列方法找到目标指令,再用Insert...()/Remove...()/Set...()等方法就地改写,最后通过Instructions()把修改后的指令序列交还给 Harmony 完成补丁注入。

如果你还不熟悉 Transpiler 的基础形态(参数约定、执行时机、链式执行顺序),建议先阅读仓库中的 Transpiler 入门文档。

匹配的定义:CodeMatch 与 Code

要定位 IL 中的特定片段,仅靠CodeMatcher还不够,你还需要描述"要找什么"。Harmony 提供两类描述工具:

  • CodeMatch:一条"匹配规则",用于匹配单个(或连续多个)指令;
  • Code:一组预定义的CodeMatch静态属性,可视为CodeMatch的语法糖。

CodeMatch:规则的结构

从 CodeMatch 源码 可以看出,一条匹配规则由以下可选约束组合而成:

字段含义说明
name匹配名称匹配成功后可通过CodeMatcher.NamedMatch(name)反查该位置的指令
opcodeSet允许的操作码集合命中指令的opcode必须属于该集合
operands允许的操作数集合命中指令的operand必须等于其中任意一个
predicate自定义谓词直接对CodeInstruction求值,返回bool,优先于其他约束
labels/blocks标签 / 异常块约束指令必须携带对应标签或异常块
jumpsFrom/jumpsTo跳转关系约束基于指令索引描述跳转来源/目标

匹配判定逻辑位于 CodeMatch.Matches():先看predicate,若有则直接返回谓词结果;否则依次校验opcodeSet、operands、labels、blocks、跳转约束,全部通过才算命中。

常用构造方式

// 1. 直接指定操作码与操作数 new CodeMatch(OpCodes.Call, myMethodInfo) // 2. 通过表达式定位"调用某方法"的指令(推荐,最直观) CodeMatch.Calls(() => default(DamageHandler).Kill(default)) // 3. 常用语义化工厂方法 CodeMatch.LoadsConstant(42); // 加载常量 42 CodeMatch.LoadsConstant("someStr"); // 加载字符串 CodeMatch.LoadsField(myField); // 加载字段 CodeMatch.StoresField(myField); // 存储字段 CodeMatch.IsLdarg(0); // 任何形式的 Ldarg*,参数 0 CodeMatch.IsLdloc(); // 任何形式的 Ldloc* CodeMatch.Branches(); // 任何分支指令

其中Calls(...)的实现很有趣(见 CodeMatch.cs):它把CodeInstructionExtensions.opcodesCalling(即Call/Callvirt等调用类操作码的集合)合并进opcodeSet,再通过SymbolExtensions.GetMethodInfo(expression)从 lambda 表达式中解析出目标MethodInfo作为操作数——这意味着编译器会对操作码与操作数做双重校验。

Code:更简洁的匹配书写

Code.cs 的设计目的是让匹配代码更接近真实 IL 的写法。加入using static HarmonyLib.Code;之后:

// 等价于 new CodeMatch(OpCodes.Ldarg_1) Ldarg_1 // 等价于 new CodeMatch(OpCodes.Call, myMethodInfo) Call[myMethodInfo] // 还可以附加名称,供 NamedMatch 反查 Call[myMethodInfo, "killCall"]

这类预定义属性覆盖了绝大多数常用 IL 指令(Ldarg_*、Ldloc_*、Ldc_I4_*、Call、Ret、各类分支指令等),在需要编写"指令序列模板"时能大幅压缩代码量。仓库测试 TestCodeMatcher.cs 验证了Ldc_I4_0与Call[method]两种写法与手写CodeMatch的等价性。

实战场景:替换 Kill() 调用为事件 API

原文档给出了一个典型的多层 API 场景:一个为 Mod 提供事件的游戏 API 中,基类DamageHandler负责伤害与死亡动画,其虚方法Apply()内部会调用Kill()。Mod 想在角色死亡时触发OnDeath事件,但不能直接补丁Kill()——因为这个方法还被其他 API 方法复用,直接打补丁会导致无关流程也触发事件。

正确做法:在Apply()的 Transpiler 中,找到Kill()的调用点,把它替换为 Mod 自己的MyDeathHandler()方法调用。这样事件只会在Apply()路径上触发,其他调用方不受影响。

以下完整代码来自仓库示例 patching-transpiler-codematcher.cs,其中TargetMethods()用于覆盖所有DamageHandler.Apply的派生实现(可参见文档"辅助方法"一节)。

用法一:MatchStartForward + ThrowIfInvalid 的"硬校验"写法

[HarmonyPatch] public static class DamageHandler_Apply_Patch { static IEnumerable<MethodBase> TargetMethods() { var result = new List<MethodBase>(); // ... (targeting all DamageHandler.Apply derived) return result; } static void MyDeathHandler(DamageHandler handler, Player player) { // ... } static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions /*, ILGenerator generator*/) { // 注意:如需在改写中创建 Label,请把 ILGenerator generator 传入构造参数 var codeMatcher = new CodeMatcher(instructions /*, ILGenerator generator*/); codeMatcher.MatchStartForward( CodeMatch.Calls(() => default(DamageHandler).Kill(default)) ) .ThrowIfInvalid("Could not find call to DamageHandler.Kill") .RemoveInstruction() .InsertAndAdvance( CodeInstruction.Call(() => MyDeathHandler(default, default)) ); return codeMatcher.Instructions(); } }

逐段解读:

  1. new CodeMatcher(instructions):从 Transpiler 参数构造匹配器。源码 CodeMatcher 构造函数 会对每条指令做浅拷贝,确保后续修改不影响原始列表。generator可选:若后续需要DefineLabel/CreateLabel/DeclareLocal等生成器能力,必须传入。
  2. MatchStartForward(...):从当前游标位置向前搜索匹配序列,命中后把游标停在序列起始处。其底层是Match(matches, 1, false)(见 CodeMatcher.cs):内部通过MatchSequence从每个位置尝试连续匹配全部CodeMatch。
  3. ThrowIfInvalid("..."):若匹配失败(游标越界,即Pos处于-1或>= Length),抛出InvalidOperationException,异常消息为"解释文字 - Current state is invalid"(见 源码)。这是快速失败策略:游戏更新导致 IL 结构变化时,第一时间用异常暴露问题。
  4. RemoveInstruction():删除游标当前指向的指令(即Kill()调用),见 源码。
  5. InsertAndAdvance(...):在当前位置插入指令并把游标向后移动(见 源码)。这里插入CodeInstruction.Call(() => MyDeathHandler(default, default)),与CodeMatch.Calls一样通过 lambda 表达式解析出方法引用,保持"参数个数、顺序一致"的栈平衡。
  6. return codeMatcher.Instructions():返回改写后的指令列表,由 Harmony 写入最终补丁方法。

注意栈平衡:Kill(player)与MyDeathHandler(handler, player)的入参顺序需要一致(这里都假设调用前栈上已有DamageHandler实例与Player参数),否则生成的 IL 会因栈不匹配而验证失败。

用法二:ThrowIfNotMatchForward——把"匹配 + 校验"合并成一步

原文档特别指出:ThrowIfNotMatchForward本质上是MatchStartForward与ThrowIfInvalid的连续调用合并。看 源码实现:

private void ThrowIfNotMatch(string explanation, int direction, CodeMatch[] matches) { _ = ThrowIfInvalid(explanation); var tempPos = Pos; try { if (Match(matches, direction, false).IsInvalid) throw new InvalidOperationException(explanation + " - Match failed"); } finally { Pos = tempPos; } }

它先检查当前状态是否有效,然后尝试匹配;无论成败都会恢复游标位置,失败时抛出"解释文字 - Match failed"的异常。配套的还有反向的ThrowIfNotMatchBack(向起点方向校验)和针对当前指令的ThrowIfNotMatch。

对应改写后的示例:

codeMatcher.ThrowIfNotMatchForward("Could not find call to DamageHandler.Kill", CodeMatch.Calls(() => default(DamageHandler).Kill(default)) ) .RemoveInstruction() .InsertAndAdvance( CodeInstruction.Call(() => MyDeathHandler(default, default)) );

相比用法一,这段代码更紧凑,且失败异常消息区分"状态无效"与"匹配失败"两种情形,便于定位问题。测试用例 Test_MatchStartForward_Code 展示了MatchStartForward+ThrowIfNotMatch的惯用组合,并断言游标最终落在Call mBar指令上。

用法三:IsValid + Start()——宽容的"可选项"改写

ThrowIf...系列适合"匹配必须存在"的场景。但原文档指出:在真实项目中,并非所有DamageHandler.Apply的派生实现都会调用Kill()。此时用抛异常的策略会导致部分合法方法补丁失败。

匹配失败时,游标Pos会被推进到列表末尾(越界,Pos == Length),此时IsValid为false、IsInvalid为true(见 源码 与SetOutOfBounds逻辑)。因此可以这样"软检查":

var codeMatcher = new CodeMatcher(instructions); codeMatcher.MatchStartForward( CodeMatch.Calls(() => default(DamageHandler).Kill(default)) ); if (codeMatcher.IsValid) { codeMatcher.RemoveInstruction() .InsertAndAdvance( CodeInstruction.Call(() => MyDeathHandler(default, default)) ); } codeMatcher.Start(); // Other match... return codeMatcher.Instructions();

要点:

  • IsValid判断当前游标是否在[0, Length)内,匹配失败即为false,因此if块内的改写会被跳过;
  • 跳过改写后必须调用Start()(见 源码,Pos = 0)把游标回卷到指令开头,否则后续的Match.../Search...会从"列表末尾"这个越界位置继续,导致无法命中任何内容;
  • 这种模式适合"匹配到就增强、匹配不到就原样返回"的防御式补丁,能显著提升 Mod 在游戏更新后的健壮性。

此外,ReportFailure(MethodBase method, Action<string> logger)(见 源码)提供了非异常的失败上报通道:IsValid为假时把错误信息(优先使用上次匹配的错误,否则 "Unexpected code")写入日志并返回true。如果你不想让补丁因异常中断、只想记录警告,用它比ThrowIfInvalid更合适。原文档也提醒:ThrowIfInvalid与ReportFailure这类显式校验,能够帮助你在上游代码升级后,准确定位"哪个位置、哪条补丁需要修订"。

用法四:Repeat——批量改写所有匹配点

如果Kill()在方法里可能出现多次(例如循环、多个死亡分支),就需要Repeat。它会把上一次成功的匹配操作(lastMatchCall)反复执行,直到游标越界为止:

codeMatcher.MatchStartForward( CodeMatch.Calls(() => default(DamageHandler).Kill(default)) ) // Only take the last Matching condition. .Repeat(matchAction: cm => { cm.RemoveInstruction(); cm.InsertAndAdvance( CodeInstruction.Call(() => MyDeathHandler(default, default)) ); });

看 Repeat 的实现:

public CodeMatcher Repeat(Action<CodeMatcher> matchAction, Action<string> notFoundAction = null) { var count = 0; if (lastMatchCall == null) throw new InvalidOperationException("No previous Match operation - cannot repeat"); while (IsValid) { matchAction(this); _ = lastMatchCall(); count++; } lastMatchCall = null; if (count == 0 && notFoundAction != null) notFoundAction(lastError); return this; }

运行机制与使用要点:

  1. 必须紧跟在一次Match...()之后调用:lastMatchCall只在Match(...)方法内被赋值(见 源码)。如果之前没有匹配操作,Repeat会直接抛出InvalidOperationException("No previous Match operation - cannot repeat")。
  2. 循环体执行matchAction完成改写,然后再次调用上次的匹配方法寻找下一个命中点,直到游标越界、IsValid变假。
  3. notFoundAction(可选):当一次匹配都没成功时(count == 0),以错误消息字符串为参数调用该委托。可用于集中上报"方法里根本没有 Kill 调用"这类情况。注意lastError中存放的是Match方法写入的"Cannot find {匹配描述}"之类的文本(见 CodeMatcher.cs)。
  4. 重要限制(原文档特别强调):Repeat不会重复Search...()类方法,只有Match...()系列(MatchStartForward/MatchEndForward/MatchStartBackwards/MatchEndBackwards)可被重复。
  5. 克隆陷阱:如果在matchAction内部又调用了其他Match...()方法,会覆盖lastMatchCall,导致Repeat后续循环"换了一个匹配条件"。正确的做法是在 match action 里先CodeMatcher.Clone()出一个副本(见 Clone 实现,副本会复制游标位置与匹配状态),在副本上执行额外的匹配,从而保护Repeat依赖的原匹配条件。

匹配失败机制与错误处理全景

理解失败机制是写出稳健 Transpiler 的前提。当MatchStartForward找不到目标时:

  • Pos被置为Length(越界),IsValid == false;
  • 若此时直接调用RemoveInstruction()之类的改写方法,会因codes[Pos]索引越界而抛出运行时异常——这正是要先用ThrowIfInvalid或IsValid把关的原因;
  • 失败信息记录在lastError,可供ReportFailure与Repeat的notFoundAction使用。

CodeMatcher 还提供ThrowIfFalse(explanation, Func<CodeMatcher, bool>)(见 源码),允许你用任意自定义谓词检查当前状态并抛出异常,适合表达复杂的先决条件(例如"当前位置之后的第 3 条必须是 Ret")。

完整的改写工具箱

除了匹配与校验,CodeMatcher围绕游标提供了一整套操作(均在 CodeMatcher.cs 中,可按需查阅):

类别方法说明
读取Instruction/InstructionAt(offset)/Instructions()/Instructions(count)/InstructionsWithOffsets(a, b)读取当前/相对偏移/区间的指令
游标Advance(offset)/Start()/End()移动游标;Start()回卷到 0,End()移到最后一个指令
搜索SearchForward(predicate)/SearchBackwards(predicate)按谓词搜索单条指令(注意:不参与Repeat)
改写SetInstruction(...)/Set(...)/SetAndAdvance(...)/SetInstructionAndAdvance(...)原地替换操作码/操作数
插入Insert(...)/InsertAndAdvance(...)/InsertBranch(...)在游标处插入指令;InsertBranch自动创建目标标签
移除RemoveInstruction()/RemoveInstructions(count)/RemoveInstructionsInRange(a, b)/RemoveInstructionsWithOffsets(a, b)移除单条/多条/区间指令
标签DefineLabel/CreateLabel/CreateLabelAt/AddLabels/SetJumpTo配合ILGenerator处理跳转
局部变量DeclareLocal(Type, out LocalBuilder)配合ILGenerator声明局部变量
命名匹配NamedMatch(name)通过CodeMatch的name字段反查已命中的指令

改写指令时请始终牢记 IL 的栈语义:RemoveInstruction()会把调用Kill()的整条指令删掉,因此必须同步插入一个参数签名兼容的调用指令(如MyDeathHandler),保证栈上出入参数量一致、类型匹配,否则补丁方法在 JIT 验证阶段会报InvalidProgramException类错误。

测试验证:仓库如何保证匹配行为

仓库在 HarmonyTests/Tools/TestCodeMatcher.cs 中对匹配器行为做了针对性验证:

  • Test_CodeMatch:验证new CodeMatch(OpCodes.Call, method)的opcode、opcodeSet、operand、operands字段均正确填充;
  • Test_Code_Without_Argument/Test_Code_With_Argument:验证Code语法糖Ldc_I4_0与Call[method]与手写CodeMatch完全等价;
  • Test_MatchStartForward_Code/Test_MatchStartForward_CodeMatch:以 CodeMatcherClass.cs 中的Method()(内部先调用Foo()再调用Bar("hello"))为被补丁方法,用MatchStartForward(Call[mBar])定位Bar调用,断言游标停在该Call指令上、操作数等于mBar的MethodInfo。

这些测试印证了本文用法一、用法二中的核心调用链:new CodeMatcher(instructions)→MatchStartForward(...)→ThrowIfNotMatch(...),并把"游标落在匹配序列起始处"这一语义固化成了可回归的契约。

实践建议总结

  1. 能改一处就不改多处:Transpiler 是链式执行的(多个 Mod 的 Transpiler 依次叠加),改动越少、越局部,与其他补丁共存的概率越高。用CodeMatch.Calls(...)这类"按方法引用匹配"的方式,比按指令序号硬编码(InstructionsAt固定偏移)对上游代码升级更友好。
  2. 区分硬校验与软校验:ThrowIfInvalid/ThrowIfNotMatchForward适合"匹配必须存在、缺失即报错"的场景;IsValid+Start()组合适合"匹配可选、缺失就原样通过"的场景。ReportFailure则适合"只记录不中断"的降级策略。
  3. 用好Repeat的三个前提:紧跟Match...()调用;不要在matchAction里直接嵌套其他Match...(),如需额外匹配先用Clone();用notFoundAction处理"零命中"的情形。
  4. 匹配失败后记得回卷:Match...失败会让游标停在列表末尾,继续后续匹配前调用Start()回到开头。
  5. 保持栈平衡:插入的方法调用必须与移除/替换的调用保持一致的参数入栈顺序与类型,这是 IL 改写合法性的底线。

如需进一步探索,可查阅仓库中的 CodeMatcher 源码、CodeMatch 源码、Code 源码 以及配套示例 patching-transpiler-codematcher.cs,并结合 Transpiler 文档 与 CodeMatcher API 文档 形成完整的知识闭环。

  • 开发工具

【免费下载链接】Harmony

A library for patching, replacing and decorating .NET and Mono methods during runtime

项目地址:https://gitcode.com/gh_mirrors/ha/Harmony
点击查看免费下载
上一篇:MusicFree文本按钮TextButton:简洁交互设计
下一篇:揭秘高效智能开发工具:一站式开源AI编程解决方案指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 1:55:57

RK3588嵌入式AI视觉实战:LCD显示、OpenCV与NPU模型部署全攻略

去年年底我拿到一块RK3588的开发板&#xff0c;想着用它跑完整的嵌入式AI视觉方案&#xff1a;屏幕显示、实时画面采集、OpenCV图像处理、NPU推理全链路打通。结果光是点亮那块MIPI屏就折腾了快两周&#xff0c;中间还踩了OpenCV编译、RKNN模型转换的无数坑。这个项目的核心就是…

作者头像 李华
网站建设 2026/10/7 1:53:30

【秋招必看】Java 集合面试热题(一)

目录 1.说说 Java 中 HashMap 的原理&#xff1f; 2.Java 中的 List 接口有哪些实现类&#xff1f; 3.Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别&#xff1f; 4.为什么 JDK 1.8 对 HashMap 进行了红黑树的改动&#xff1f; 5.JDK 1.8 对 HashMap 除了红黑树还…

作者头像 李华
网站建设 2026/10/7 1:52:05

caveman代理优化:降低编码代理token消耗的工程实践

1. 从"caveman"这个词说起&#xff1a;为什么原始人式编码代理反而更高效第一次看到"caveman"这个项目名&#xff0c;我脑子里蹦出来的画面是拿着石斧敲键盘的原始人。但真正用过一段时间之后&#xff0c;我反而觉得这个名字起得相当精准——它要解决的核心…

作者头像 李华