本文讨论的是 unidbg 在单 Unicorn 后端下模拟多线程 native 程序时的通用架构问题。文中的命令、线程、回调与返回值均为抽象示例,不对应任何具体应用、接口或业务数据。
本文是前文《给 unidbg 装上原生时间片:如何让模拟器真正跑起多线程》 的后续。
第一版多线程方案,也就是先前的那篇文章,解决了一个最直观的问题:让主任务和 native worker 能够按原生时间片轮流执行。它把 unidbg 从依赖 syscall、JNI 回调等偶然 safe point 的协作式调度,推进到了可被指令预算稳定打断的抢占式轮转模型。
但是,“线程能够轮流执行”并不等于“多个 native 调用能够安全重叠”。当两个 Java 宿主线程向同一个 Unicorn backend 提交命令时,系统还必须回答一组更困难的问题:当前是谁在驾驶 backend?新调用如何请求通行权?旧调用暂停后由谁恢复?返回值和终态属于哪一代调用?一个调用释放 JNI local reference 时,会不会清掉另一个暂停调用仍在使用的对象?
第二版方案开始引入 backend owner、handoff、command result 和超时终态,但仍然保留了大量全局槽位和按命令号匹配的兼容逻辑。它能处理部分交接,却不能从结构上保证调用身份、运行上下文、JNI 引用和终态的一致性。
最终方案不再把“任务”作为唯一调度单位,而是在任务之上增加一层 invocation-owned runtime:每次 native 调用都拥有独立的 record、generation、submitter、carrier、immutable context、terminal 和 JNI reference scope。backend 仍然只有一个,但调用可以通过 FIFO 申请、在真实 safe point 交接、暂停和显式恢复。
可以把整个演进理解成一座单车道桥:
第一版方案的核心是 native timeslice:C 后端在执行一定数量的 guest 指令后主动停止,Java 调度器保存当前任务上下文,再从可运行队列中选择下一个任务。
这层设计是必要的。没有可预测的 native safe point,worker 能否获得执行机会只能依赖 syscall、hook 和回调返回,调度器只能不断猜测“什么时候适合切线程”。时间片让停止变成了 backend 的明确能力。
但第一版解决的是“车能不能轮流开”,并没有建立调用级交通规则。
第一版的完整执行链可以概括为:
这张图里的主语始终是 Task。它能说明“下一个运行谁”,却无法说明“这次 native 调用是谁、它的结果最终交给谁”。这正是后续重构需要补上的一层。
回头看第一版,最容易产生的误解是:既然后来又做了架构重构,那么 native timeslice 一定是失败的。事实恰好相反——时间片本身完成了它被设计出来时的目标。
第一版已经能够验证以下能力:
这些验证共同证明了一件重要的事:单 backend 上的 native 协作式抢占已经成立。
问题在于,我们当时把这个结论向前多推了一步:
这两句话看起来只差半步,实际上跨过了一条架构边界。Task 是调度对象,回答“下一刻让谁运行”;Invocation 是调用对象,回答“谁发起、谁拥有、谁等待、谁接收结果、谁负责清理”。时间片只解决了前一个问题。
如果把单 backend 比作一座一次只能通过一辆车的窄桥,第一版做成的是红绿灯系统:灯会按时变色,车辆也确实能够交替前进。但“能轮流上桥”不代表整套运输系统已经完备,因为我们还没有为每一趟运输建立独立车牌、货单、收件人和签收回执。
所以,第一版“不对”的准确含义不是时间片算法错误,而是:它通过的是 Task 轮转验收,却被拿去承担 Invocation 正确性。验收标准比真实并发语义窄了一层。
真正推动第二次重构的,不是某一处代码看起来不够优雅,而是一组无法用“线程没有跑起来”解释的运行现象。
一次 native 调用进入调度器后,可以把承载其入口和最终返回的主任务称为 carrier ,把它依赖的其他 guest 任务称为 worker 。最初的怀疑很自然:carrier 长时间没有返回,是不是 worker 根本没有创建、没有入队,或者一直没拿到 backend?
被动观测很快排除了这种简单解释:
换句话说,桥上的红绿灯在工作,辅助车辆也确实开上了桥。可是 carrier 仍可能长期停留在这样的状态:
这四个条件同时出现非常关键:
如果只看最终的 timeout,很容易得到“native 卡死”的结论;如果把任务状态、实际派发和 wall time 放在一起看,则会发现另一种可能:调用不是不可返回,而是在单 backend 的共享调度窗口中,还没获得足够且连续的推进机会,宿主就先取消了观察。
这也是第一版第一次暴露出模型裂缝的地方。Task 状态都可以是“合法的”,调度器也可以持续工作,但调用整体仍然没有一个能够被准确描述的生命周期。
发现“worker 已运行、carrier 未完成”以后,不能立刻把原因归结为 timeout,更不能直接把 timeout 调大。我们按照因果位置逐层排除了几类解释。
任务创建记录、可运行状态变化、实际 dispatch 以及上下文前进共同证明,worker 已经进入了调度闭环。若 worker 从未运行,修复方向应当是线程创建或队列;但证据表明问题发生在这些步骤之后。
另一个直觉方案是提高 carrier 优先级,甚至只调度 carrier,期待它尽快返回。受控验证显示,carrier 会走到一个真实的依赖等待点:它需要某个 worker 更新共享状态或释放同步条件才能继续。
这说明 carrier 和 worker 不是“主任务与噪声”的关系,而是协作关系。只让 carrier 上桥,就像让货车独占桥面,却把负责开闸的工程车永远挡在桥外;货车得到再多绿灯,也只能停在关闭的闸门前。
因此,问题不能通过“把所有时间都给 carrier”解决。正确目标不是单方面提高优先级,而是在保持协作推进的同时,准确计算每一次调用的等待与完成。
我们还验证过几类常见调度补丁的思路:提高某类任务的选中概率、改变队列顺序、强制把任务改成可运行、在退出前增加 drain window,或者在观察即将到期时再多跑若干轮。
这些措施可能改变某一次运行的路径,甚至让失败出现得更早或更晚,但它们无法建立稳定的因果闭环:
一个补丁只有在回答“它修复了因果链的哪一层”时才是架构修复。仅仅让某次运行通过,不足以证明队列顺序或某个额外唤醒就是根因。
被动记账揭示了最容易被忽视的一点:第一版的 deadline 是全局 wall deadline。它从宿主进入调度循环时开始计时,期间不只计算 carrier,还计算全部 worker、任务切换、hook、日志和 Java 调度开销。
因此,在一个看起来很长的观察窗口里,carrier 实际得到的 backend 执行份额可能只占一小部分。宿主时钟已经走完,并不等于 carrier 真正执行了同等时长。
一次受控验证中,放宽全局 wall cutoff 后,原本被判定超时的 carrier 能够继续推进并自然返回。这证明了一个必要事实:旧 deadline 确实可能杀死一个本来可返回的调用。
但这仍不意味着“把固定时间改大”就是最终答案。换一组任务组合或调度顺序后,同一个固定额度仍可能失效。于是结论进一步收窄:
这一区分很重要。延长 timeout 是参数修补;把提交等待、backend 占用、协作调度和调用终态分别建模,才是语义修复。
把上述证据合在一起,可以得到比“调度偶尔超时”更准确的结论:
第一版真正缺少的,是位于 Java 提交者与 native Task 之间的调用实体。没有这个实体,系统无法可靠回答:
桥梁系统的故障至此才算被准确描述:红绿灯没有坏,车辆也不是完全不动;缺的是每趟运输的身份与所有权。没有车牌,两辆同型号车会混淆;没有独立货单,后车可能覆盖前车的货物记录;没有准确回执,桥中央的一次临时停车也可能被误报成“已经送达”;所有车辆共用一个倒计时,则某辆车还没真正开多久,就可能因为别的车占桥而被判超时。
后面四个缺陷,正是这个总问题在代码中的具体表现:调度单位只有 Task、预算使用全局 wall time、交通状态与调用终态混淆,以及 JNI local reference 仍采用 VM-wide 生命周期。
第一版调度器认识的是 Task:
每个任务具有 RUNNABLE、WAITING、FINISHED 等状态,调度器按照时间片和 futex 状态轮转任务。
但是一次 Java 调用和一个 Task 并不总是一一对应:
如果系统只有 Task,没有 Invocation,就无法精确表达:
第一版常见的做法是以 command number 或当前 active command 作为关联键。这在严格串行时看起来可用,一旦允许重叠就会产生歧义。
桥梁比喻中,这相当于调度器能识别“卡车”“轿车”,却没有车牌和通行证。两辆同型号车辆先后上桥,收费站只能看到它们都叫 command-X,无法判断回执应该交给谁。
第一版通常以一次调度循环的 wall clock 作为命令预算:
这段逻辑简单,但它混合了至少四类时间:
在真机上,多个线程能够并发运行;在单 backend 中,它们必须串行分享同一段 wall time。如果把总 wall time 直接当成 carrier 的生命周期预算,就可能出现如下情况:
从宿主视角看已经过去 8 秒,从 carrier 视角看它可能刚执行了一小部分。于是 timeout 不再表达 native 生命周期,只表达“所有任务共同消耗了多少宿主时间”。
简单增大 timeout 也不能成为可靠修复,因为不同运行中的 worker 数量、等待路径和共享状态演化不同。某次运行在更大窗口内自然返回,并不能证明某个固定数值就是正确的 native 生命周期。
时间片方案增加了多种 backend 停止原因:
这解决了“emu_start() 为什么返回”的问题,但上层很容易错误地把“backend 已停止”理解成“native 命令已完成”。
实际上:
一辆车在桥中央被红灯拦停,不等于它已经抵达终点。EMU_STOP 应该触发保存现场和交接,而不是生成一个“已完成”的业务回执。
传统顺序 JNI 调用常用一个 VM 级 local-reference map:
顺序执行时,这个模型没有问题:调用开始时加入参数对象,调用结束时统一清理。
重叠调用下会出现严重污染:
这种错误很隐蔽,因为它最终可能表现为 JNI 空指针、数组访问失败,甚至被兼容路径包装成普通数值返回。表面看像业务参数错误,根因却是 local reference 的生命周期仍停留在单调用时代。
第二版在时间片调度之上增加了 command service:引入 backend owner、handoff、显式 result、watchdog 和 command terminal。相比第一版,它已经意识到“谁在驾驶 backend”和“命令是否完成”是两个独立问题。
但这一版仍处于从 task-centric 向 invocation-centric 过渡的中间态。许多正确概念被放进了全局字段、单槽 Map 和日志字符串中,因此能够处理单次交接,却难以保证重叠调用的严格隔离。
第二版通常会增加类似字段:
这些字段分别解决某一个局部问题:
问题在于它们没有被同一个 invocation 对象统一拥有。一次调用的身份散落在:
当同一 command 发生第二次调用时,旧 terminal、旧 watchdog 或旧 fault 可能覆盖新调用。代码不得不增加越来越多的判断:
这类字符串匹配只能作为 legacy fallback,不能作为并发所有权模型。
第二版常见的数据结构是:
它隐含了一个假设:同一 command 同时最多只有一条记录。
现实中可能出现:
如果新 record 覆盖旧 record,会产生三类问题:
即使把 value 改成 List<Record>,如果消费时仍只传 command,也无法解决歧义。真正的索引必须包含 carrier identity、generation 和 original submitter。
为了在 carrier 创建前预留 generation,第二版可能采用:
顺序调用时没有问题,并发 caller 会看到一个危险窗口:reservation 已进入 FIFO,但 carrier 尚未绑定。此时另一个线程也可能 reserve、bind 或取消,导致 FIFO head 与真实 carrier 关系不稳定。
如果 command、context、generation 和 carrier 不是在同一把 dispatcher 锁内建立,就不能保证:
第二版虽然有 reservation 概念,但还没有把 atomic create-and-bind 作为生产路径的不变量。
许多 native stub、JNI router 或环境模拟逻辑需要知道当前执行的 command、origin 和 detail。第二版往往通过全局字段或 System property 暴露:
问题是 caller 创建了 B,不等于 B 已经取得 backend admission。A 可能仍在 guest 中运行。如果 B 在提交时就写全局字段,A 的 JNI stub 会突然读到 B 的上下文。
ThreadLocal 也不是完整答案,因为 guest task、backend owner 和 Java submitter 不一定运行在同一个宿主线程上。运行上下文必须绑定 logical admission,而不是绑定“最近一次谁写了变量”。
第二版虽然定义了 CommandResult,但 caller 仍可能这样工作:
这里的 value 来自一次 native 调用,result 来自全局槽位。重叠时二者可能不属于同一 generation。
真正的 exact outcome 必须是一个不可拆分的整体:
任何一项无法匹配,都不应构造成功 outcome。
传统兼容路径经常在 native exception 后返回 -1:
随后 scheduled task 看到一个非空 Number,可能把它记录为:
这样 caller 会认为命令自然完成,只是返回了一个特殊数值。对 tracked invocation 而言,这是错误的终态传播。异常必须成为该 invocation 自己的 FAULT,不能借用 legacy 数值返回通道。
最终重构不再继续增加全局 gate、优先级或 timeout 例外,而是改变系统的核心抽象:
Task 负责“代码在哪里继续执行”,Invocation 负责“这次调用是谁、拥有什么、最终结果交给谁”。
最终方案以六条不变量为设计起点。
Unicorn backend 不是可重入服务。任何时刻只有一个 owner 可以调用 emu_start() 或继续 guest 执行。其他 submitter 必须通过 handoff 请求获得 logical admission。
调用身份至少由以下字段构成:
command number 只是业务类别,不是调用身份。
创建、绑定或排队都不能让 context 对 guest runtime 可见。只有取得 logical admission 的 invocation 才能投影 context;暂停和终态必须立即撤销。
exact outcome 只能由 record 自己保存的 terminal 构造。不得从全局 last-result 补 identity,也不得把 caller 看到的 value 和另一个槽位的 terminal 拼起来。
每次 tracked 调用拥有独立 local-reference scope。scope 只有在 carrier 已退役且 caller 已解析 outcome 后才释放。
PARKED、YIELDED、BACKEND_STOP 和 HANDOFF_REQUIRED 都是交通状态,不是 command terminal。TIMEOUT、FAULT、CANCELLED 也不能被转换为 COMPLETED。
最终架构可以拆成三层。
三层的职责必须严格分离:
最终方案的三层关系如下:
最下层仍然保留第一版文章中的 native timeslice 和 Task 调度器;最终版不是推翻第一版,而是在它之上补齐调用所有权和生命周期。
用桥和车的比喻,可以把主要对象映射如下:
这一章按照代码落点说明最终方案如何实现。示例保留核心逻辑,省略日志、兼容分支和非关键参数。
首先要避免一个枚举同时表达“命令是否结束”和“为什么暂停”。最终结果模型把 State 与 Kind 分开:
State 回答“是否已经形成 command terminal”,Kind 回答“具体发生了什么”。
这个拆分非常重要。若把 BACKEND_STOP 放进 completed 状态,handoff 会被错误地包装成自然返回;若把 CANCELLED 当 TIMEOUT,则一次宿主观察结束会被误写成 guest 生命周期失败。
结果匹配不能只比较 command:
这样即使两个调用使用相同 command,也不会串槽。
最终 record 聚合一次调用的全部关键身份:
为什么这些字段必须在一个对象里?因为它们的生命周期一致:
如果把它们拆进多个全局 Map,系统只能靠约定维持一致,很难形成可验证的不变量。
carrier 创建后还要检查 guest argument 中的 command:
最终建立三个相等关系:
错误在进入 backend 前失败,比运行几秒后再从异常日志猜 command 污染可靠得多。
对于已能创建 carrier 的 active mailbox 路径,不再先 reserve 再 bind,而是在 dispatcher 同一把锁中完成:
这里 carrier identity 是权威索引:
recordsByCommand 只负责查询候选集合,不再假设一个 command 只有一个 record。真正完成、fault 和 terminal 消费优先按 carrier 找到精确 record。
只有 FIFO head 可以 admitted:
FIFO 解决的是 logical admission 顺序,不等同于 Task 调度器的 round-robin。一个 invocation 一旦 admitted,普通时间片轮转不会释放它的调用级身份。
仅仅收到 EMU_STOP 还不够。系统必须证明:旧 owner 在当前 owner session 的某个真实 safe point 保存了现场,并同意让路。
记录凭证时进行精确检查:
在真正 suspend A 时,再次校验 owner session 和 safe-point sequence:
这防止旧 handoff 凭证在新的 owner session 中被重复使用。
新 requester 只有在以下条件同时成立时才请求 emu_stop():
emu_stop() 返回不生成 COMPLETED。它只促使 owner 的 emu_start() 回到 Java,随后 dispatcher 才能:
构造后不允许修改:
context 的可见性由 admission 控制,而不是由 caller 写入时间控制:
因此:
现有项目可能已经有大量 System.getProperty() reader,无法一次性全部改造。可以使用一个严格的 projection adapter:
撤销时精确恢复之前的“存在状态和值”:
它不是新的全局状态源,而是 legacy reader 的迁移桥。长期目标仍然是让行为代码通过 admitted context reader 取值。
context 是 Java-side ownership token,不应该塞进 guest Object... args,否则会改变 native ABI。
调用链采用 overload 显式传递:
底层仍按原 JNI positional ABI 编组:
CommandInvocationContext 只附着到 scheduled mailbox record,不进入 guest 寄存器。
构造器必须拒绝任何拼接结果:
这里使用对象 identity:
即使另一个结果拥有完全相同的 command、generation 和 submitter,只要它不是 record 实际保存的 terminal,也不能冒充本次 outcome。
dispatcher 完成 record 时统一补 ownership:
后续重复 terminal producer 只能得到已有 terminal,不得覆盖:
这关闭了 watchdog TIMEOUT、natural completion 和 fault retirement 之间的双发布竞争。
先定义一个与具体 VM 解耦的生命周期接口:
Android DVM 提供具体实现:
tracked JNI 参数不再进入 VM-wide map:
guest JNI 运行时也根据当前 running carrier 找到 scope:
因此 addLocalObject() 和 getObject() 自动路由:
真正释放必须同时满足:
为什么两个条件缺一不可?
这与旧的 finally { vm.deleteLocalRefs(); } 有本质区别:旧代码清空整个 VM 的 local refs,新代码只确认本 invocation 的 outcome 已解析;实际释放仍等待 carrier retirement。
在 emulator 的 RuntimeException 处理路径中,先判断当前 running task 是否拥有 tracked invocation:
dispatcher 将 fault 绑定到 carrier:
原 submitter 后续只消费自己 record 的 pending fault:
Legacy runtime exception 仍走旧兼容路径,避免一次重构破坏所有历史调用。
是否进入 tracked mailbox 应由 call site 明确声明,而不是根据“dispatcher 当前是否活跃”猜测:
显式 route 有三个好处:
真实程序经常使用 persistent single-thread executor:
一个 job 可以包含多条命令:
注意:persistent worker identity 不等于 persistent backend admission。
正确模型是:
每条 native command 都创建独立 record,worker FIFO 只保证业务提交顺序。不能把整个 job 合并成一个大 record 或长时间独占 backend,否则会抹掉主分支与 worker 分支之间真实存在的 safe-point overlap。
调度架构正确之后,还需要保证运行结果可评价。JUnit PASS 只代表测试方法结束,不代表 native transaction 完整。
一个简单可靠的账本使用 UTF-8 JSONL:
创建文件时使用 CREATE_NEW,防止覆盖旧运行:
每条事件通过单 writer 顺序写入并立即 flush:
典型事件包括:
完整性判定只看最后一条:
如果 writer 失败,finish() 或 close() 必须向测试抛出 IOException,不能出现“控制台日志看起来正常,但权威账本没有落盘”的假成功。
代码结构只有在完整时序中才能看清。下面用 A、B 两次调用说明最终模型。
这张时序图刻意把 emu_stop() 和 natural return 画成两种不同事件:前者只负责把 owner 拉回 safe point,后者才有资格形成 COMPLETED。
此时:
B 仍然只是 BOUND:
这是 admitted-only 语义最重要的时刻。B 的 Java caller 已经存在,但 guest 仍在执行 A,所以任何 runtime reader 都必须继续看到 A。
B 是 FIFO head,并发现 backend owner 属于已 admitted 的 A,于是调用:
这不是终态,只是让 A 的 active emu_start() 回到 Java。
dispatcher 获得当前 owner-session generation 和 safe-point sequence:
A 的 carrier 没有 destroy,reference scope 也没有释放。A 只是暂时离开桥面。
B 的 terminal 必须带有:
任何全局 last result 都不能替代它。
A 不会被 dispatcher 自动塞回 FIFO。原 submitter 再次请求 admission:
最终形成可验证序列:
假设一个 persistent worker 需要提交六条 native command,同时主分支可能在中间提交一条 command。
错误模型:
这会抹掉真实 overlap,使主分支无法在 worker command 之间取得执行机会。
正确模型:
Java executor FIFO 与 backend handoff FIFO 是两个正交层:
架构重构最危险的地方,是 synthetic 测试通过后立刻宣称复杂业务已经修好。正确做法是分层验证,每层只证明自己的合同。
应覆盖:
使用最小 AArch64 guest 程序验证:
必须断言:
应覆盖:
应覆盖:
一次长运行只有满足以下条件才可评价:
如果 stdout 被截断,而 JSONL 没有 RUN_END,结论只能是:
不能用 JUnit PASS 或日志前半段推断业务成功、失败或架构充分性。
推荐迁移顺序:
不建议全仓机械替换 activeCommand。某些读取只是日志标签,某些读取会改变 JNI 返回形状,两者的迁移风险完全不同。
它解决的是 unidbg 单 backend 多线程模拟的通用基础设施问题:
这套架构没有承诺:
多线程基础设施正确,只说明“车辆、桥权、货单和回执没有串错”。车辆为什么没有装上某批货物,仍然可能是更上层的业务状态问题。
我个人是从 AI 时代才开始接触逆向的,分享一点自己的实践心得,并且本文也在ai的帮助下撰写。
如果只把 AI 当成即时问答工具,协作很容易陷入循环。第一轮花很长时间解释背景,第二轮因为上下文增长开始遗忘旧约束,第三轮又提出已经验证失败的方案,几轮之后,历史假设可能被误写成当前事实。代码确实改了不少,但没有人能准确回答每一次修改究竟证明了什么。这并不完全是模型是否会写代码的问题,更关键的是工程有没有建立可继承的记忆系统。聊天记录适合讨论,却不适合作为唯一事实源;它会被截断和压缩,重要信息也会散落在不同轮次中。真正可持续的做法,是把协作所需的状态、证据、禁区和结论写进仓库,让任何一轮新的分析都从同一个基线出发。
因此,我们没有维护一篇包揽所有内容的“万能说明书”,而是让不同文档承担不同职责。 AGENTS.md 保存协作规则,说明进入任务前要读什么、哪些文件能够修改、哪些操作需要额外授权,以及实验产物应放在哪里。它更像施工现场的安全规范,解决的是“应该怎样工作”,而不是“今天具体做到了哪里”。
progress.md 是按时间增长的工程账本。一次实验修改了什么、没有修改什么,观察到了哪些原始现象,结论能够覆盖到哪一层,下一步停在哪里,都应当在这里留下记录。它保存的不是一份经过修饰的成功故事,而是完整的决策轨迹。后来的协作者不必依靠一句“这里以前试过”,而是可以查到当时试了什么、控制条件是否成立,以及为什么那个结果不能继续外推。
experiment_outcomes_and_pivots.md 专门保存负向知识。一般的开发记录喜欢写“最后怎样成功”,却很少认真记录“哪些路为什么不通”。在复杂重构中,失败实验并不是垃圾,它会缩小候选空间。提高优先级、延长 timeout、强制 wake、扩大全局锁或者增加兼容 fallback,这些都是面对并发异常时很容易再次想到的方案。如果历史已经证明它们只能改变表象,转向账本就应明确写出原假设、反证和新的方向。这样,新一轮 AI 在读取状态后,需要提出能够区分新解释的验证,而不是把旧补丁换一个参数再做一遍。
CURRENT_STATE.md 承担的角色更接近总工程师确认后的最新图纸。时间线账本可以不断追加观察,但稳定状态不应随着每次未经复核的实验来回摆动。把“实验记录”和“当前可信结论”分开,是为了防止一条单样本结果立刻改写整个架构叙事。它也明确提醒 AI:发现新的可能性并不等于已经获得修改全局结论的权力。
随着进展账本越来越长,仅靠读取文件末尾也会逐渐丢失阶段脉络,截至今天,我项目的 progress.md 已经30多万行,如果想让一个从来没接触过项目的ai进站了解项目去读这个文件,显然并不可能,所以我们又用 summarization/07 这类按月份和日期组织的文档保存阶段检查点。它们的核心不是压缩当天日志,而是建立一种可继承的推理架构——每篇文献都遵循一套稳定的结构契约:
这些文档组成了一套分层的工程记忆。规则文档负责限制行为,当前状态负责提供稳定基线,进展账本负责保存时间线,转向账本负责阻止路线循环,阶段摘要负责跨越上下文边界,对外文章则负责把内部经验脱敏后重新组织为可传播的方法。它们并不是重复记录同一件事,而是在不同粒度上回答不同问题。新一轮协作也不需要把整个仓库和全部历史一次性塞给 AI,只要从稳定状态、近期进展和相关阶段摘要恢复现场,再根据当前问题有方向地读取源码即可。
有了这套记忆系统,每次开始工作都可以执行同一个“进站”过程。先读取最新状态和历史转向,再对照禁止清单,随后确认本轮问题位于因果链的哪一层,明确允许修改的文件和能够接受的证据,最后才开始分析或实现。完成后,代码通过测试只是第一层检查,还要确认测试究竟证明了什么、哪些结论仍未覆盖,并在明确停点结束。这样做可以抑制一种很常见的冲动:看到某处出现差异,就自动向上游追踪;看到一个 timeout,就马上延长等待;看到一个全局字段,就顺手做全仓替换。每一步都必须先回答,它修复或验证的是因果链中的哪一层。
第一版原生时间片方案解决了 unidbg 多线程模拟最基础的问题:没有稳定 safe point,就谈不上可控调度。它让主任务和 worker 不再完全依赖偶然 syscall 才能轮转,是从补丁式 drain 走向调度基础设施的关键一步。
第二版命令服务进一步意识到 backend 不能被多个宿主线程同时驾驶,也开始区分 timeout、fault、handoff 和 completion。但只要调用身份仍散落在全局 result、command 单槽、active fields 和日志字符串中,重叠调用就没有严格的隔离边界。
最终重构真正改变的是系统的主语:过去所有逻辑都在问“下一个 Task 跑谁”,现在系统还会问“正在运行的是哪一次 Invocation,它为什么有资格进入 backend,它暂停时应保存什么,它的结果和 JNI 引用最终交给谁”。
红绿灯只能让车轮流过桥。要让一座单车道桥长期、安全地承载复杂交通,还需要通行证、流水号、排队规则、货单隔离、交接凭证和不可抵赖的出桥记录。
对于 unidbg 的单后端多线程模拟来说,真正困难的从来不是“多跑几个 worker”,而是让每一次暂停、恢复、返回、超时和失败,都精确地属于正确的那次调用。
而对于长期的人机协作来说,真正困难的也不是让 AI 多生成几段代码,而是让每一次观察、假设、修改、验证和转向,都精确地写入正确的工程记忆,并能被下一轮工作完整继承。
以上是我个人在 unidbg 调度模型上的一点粗浅思考,受限于水平和经验,难免有疏漏或偏颇之处,欢迎读者批评指正。
本文仅讨论 unidbg 开源框架自身调度模型的通用架构设计。文中所有命令编号、回调与返回值均为抽象占位符,不对应任何特定程序、接口或业务数据。本文不提供、不指引、不协助绕过任何具体安全防护措施。
观测事实
能证明什么
不能证明什么
worker 被创建、入队并实际执行
Task 调度闭环已经工作
调用一定能完成
carrier 仍为 RUNNABLE
carrier 不是简单睡死
它已经获得足够执行机会
常规 wait/wake 路径发生
基础同步事件可被处理
所有调用生命周期都被正确建模
放宽 wall cutoff 后能够自然返回
旧 deadline 会过早终止可返回调用
某个更大的固定数值永远正确
carrier 独占时反而进入依赖等待
worker 是必要协作者
提高 carrier 优先级可以替代并发语义
架构对象
比喻
职责
Native timeslice
红绿灯
定期让车辆停到安全位置
Backend capability
桥梁驾驶权
保证同一时刻只有一个执行者
CommandInvocationRecord
独立通行证
记录车辆身份、状态和回执
Generation
通行证流水号
区分同一 command 的多次调用
Handoff FIFO
桥头排队车道
防止后来的请求插队
HandoffYield
交警签发的让行凭证
证明旧 owner 在哪个 safe point 让路
CommandInvocationContext
车辆货单
只在车辆真正上桥时可见
CommandInvocationOutcome
出桥回执
value 与 terminal 的不可拆分结果
InvocationReferenceScope
独立行李舱
隔离 JNI local references
维度
第一版时间片
第二版命令服务
最终 Invocation Runtime
核心目标
让 Task 轮流运行
避免多人同时驾驶 backend
让重叠调用拥有精确生命周期
调度单位
Task
Task + command
Task + Invocation
backend 停止
timeslice / futex
stop + owner handoff
exact safe-point yield
backend 权限
调度循环隐式拥有
owner thread
OwnerSession + logical admission
调用身份
command 或 active field
command + 若干 generation 槽
carrier + command + generation + submitter
请求顺序
Task round-robin
全局 handoff 状态
Invocation FIFO
运行上下文
全局 active fields
global/ThreadLocal 混合
immutable admitted-only context
返回结果
Number/null
value + global result heuristic
record-owned exact outcome
异常
可能兼容为 -1
pending global fault
invocation-owned FAULT
JNI local ref
VM-wide map
VM-wide map
invocation scope
scope 释放
JNI finally 全清
carrier/task 清理
carrier retired AND outcome acknowledged
timeout
总 wall time
watchdog + 多个全局槽
exact terminal,operation cancellation 分离
可评价证据
stdout
stdout + JUnit
durable terminal ledger
已经证明:多个 Task 可以被打断、保存、恢复和轮转
错误外推:多个重叠 native invocation 因此一定能正确完成
carrier.state = RUNNABLE
carrier.waitAddress = 0
carrier.finished = false
observation = expired
如果任务本来就在 RUNNABLE,强制 wake 没有增加新信息;
如果 carrier 依赖 worker,永久优先 carrier 反而会破坏协作;
如果完成权没有归属,调整队列也不能保证结果交给正确调用;
如果 wall timeout 的含义错误,增加 drain 只是延后同一个问题。
不是旧数值太小;
也不是新数值还不够大;
而是用所有 Task 共享的 wall time,表达某一次 Invocation 的生命周期,本身就不成立。
main task
worker task #1
worker task #2
scheduled function task
这个 scheduled task 属于哪一次调用?
这个 TIMEOUT 是当前 generation 的,还是上一轮残留的?
这个 worker fault 应该交给哪个 submitter?
同一个 command 再次出现时,能否覆盖旧结果?
long start = System.currentTimeMillis();
while (hasRunnableTask()) {
Task task = pickNextTask();
task.dispatch(emulator);
if (System.currentTimeMillis() - start >= timeoutMillis) {
return null ;
}
}
命令总窗口:8 秒
carrier 实际获得 backend:1.2 秒
worker 与框架开销:6.8 秒
结果:carrier 被判 TIMEOUT
enum BackendStopReason {
NORMAL,
TIMESLICE,
EMU_STOP,
FAULT
}
private final Map<Integer, ObjRef> localObjectMap = new HashMap <>();
void deleteLocalRefs () {
for (ObjRef ref : localObjectMap.values()) {
ref.obj.onDeleteRef();
}
localObjectMap.clear();
}
调用 A:把 Object[] 参数加入 localObjectMap
调用 A:在 safe point 暂停
调用 B:把自己的参数加入同一个 localObjectMap
调用 B:完成并调用 deleteLocalRefs()
调用 A:恢复执行
调用 A:通过原 handle 读取 Object[],得到 null
private Thread backendOwnerThread;
private CommandResult lastCommandResult;
private CommandResult commandTerminalResult;
private Map<Integer, CommandResult> terminalByCommand;
private Task pendingFaultTask;
private CommandResult pendingFaultResult;
当前 task
当前 command number
当前 submitter thread
某个 generation 字段
全局 last result
某个 keyed terminal map
日志 detail 中的 cmd=...
if (result.getDetail().contains("cmd=" + cmd)) {
}
Map<Integer, CommandRecord> recordsByCommand;
Map<Integer, CommandResult> terminalsByCommand;
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。