下载补丁只是把文件送到设备。真正让新逻辑生效的,是 Dart VM 对指令读取、函数入口、调用分派和执行状态的适配。
给线上 Flutter 应用修复一段 Dart 逻辑,为什么不能下载新代码之后直接调用它?
因为 Release 应用运行的是已经编译好的 AOT 程序。函数入口、对象引用和调用关系,早已被写进快照与机器指令。iOS 又限制可执行代码的加载,新文件不能直接被当作普通原生程序执行。
一种可行的技术设计,是保留安装包中的原生 AOT 代码,让补丁指令由预先集成的解释器执行,再通过 Dart VM 把两侧连接起来。
这篇文章沿着 VM 的加载与执行链读代码:从 ReadInstructions 建立 Code 入口,到 WrapFunction 选择目标,再到分发表、Simulator 和线程过渡。代码保留关键方法、字段与计算关系,并进行了删节或语义展开;示意名称会单独标明。它们用于解释机制,不是完整 SDK 源码,也不是可直接编入 SDK 的补丁。
1. 先理解普通 AOT:VM 恢复的是一个程序
用一个简单修复作为例子:
dart
// 原实现:始终收取运费。
double calculateTotal(double subtotal, double shipping) {
return subtotal + shipping;
}
// 修改后的规则:满 99 元免运费。
double calculateUpdatedTotal(double subtotal, double shipping) {
return subtotal + (subtotal >= 99 ? 0.0 : shipping);
}
void main() {
print(calculateTotal(120, 8)); // 128.0
print(calculateUpdatedTotal(120, 8)); // 120.0
}
AOT 编译之后,产物包含两类重要内容:快照数据和指令映像。
快照数据描述程序需要的对象图,例如函数、类、类型、常量和对象池。指令映像保存函数与 stub 使用的机器指令。
启动时,Dart VM 根据快照恢复程序结构,再把相应的指令位置关联到 Code、Function 和调用分发表。它不会只拿到一个地址,就把整个文件当成 main() 执行。
text
快照数据 + 指令映像
↓ 校验、反序列化
类、函数、常量、对象池、调用表
↓ 绑定入口、初始化线程状态
执行 Dart 程序
理解热更新的第一步,是看到"函数代码"之外的这些运行时结构。
1.1 Function、Code 与 InstructionsTable 分别是什么?
这三个概念很容易被统称为"函数",但它们解决的问题不同。
Function 表达 Dart 函数的语义身份和相关信息。Code 关联编译后的代码信息及其入口。AOT 指令映像则提供实际指令字节,InstructionsTable 帮助运行时把代码索引对应到映像中的位置。
它们的关系可以粗略表示为:
text
Function:这是哪个 Dart 函数?
↓
Code:该实现的入口与相关代码信息是什么?
↓
InstructionsTable / 指令映像:指令实际位于哪里?
这个关系图省略了优化后的细节。AOT 编译器可以内联函数,部分代码对象也可能被裁剪;不能把源码中的每个函数都理解为一段必然存在、具有独立 Code 的机器码。
对热更新而言,这个区别尤其关键:函数的语义身份可以保持一致,实际执行位置和入口形式却可以改变。
例如,价格计算仍然是页面需要调用的那个函数,但它现在可以对应补丁中的指令及 CPU → Simulator 入口。这种变化需要 VM 更新运行时关系,而不是要求调用者理解补丁格式。
1.2 热更新与开发时 Hot Reload 的区别
开发时 Hot Reload 通常工作在 JIT 环境中,运行时具有代码更新能力,并且需要处理正在存活的程序状态。
本文的 release 更新会在后续启动时恢复程序,使用 AOT 产物及预先加入的混合执行能力。它并不要求手机重新编译 Dart 源码,也不把上一次进程的整个堆直接带入新程序。
| 问题 | 开发时 Hot Reload | 本文的 release 更新 |
|---|---|---|
| 新逻辑从哪里来 | 开发工具提供更新后的程序信息 | 发布系统提供编译后的补丁 |
| 是否面对正在存活的旧状态 | 通常需要处理 | 正常路径通过后续启动重建环境 |
| 主要执行条件 | JIT 开发运行时 | AOT 基础加混合执行扩展 |
| 用户设备是否需要开发编译器 | 不适用于这种比较 | 不需要现场编译源码 |
两者都改变 Dart 行为,但不能将开发工具的 Hot Reload 接口当作 release 补丁的现成实现。
2. 先读 ReadInstructions:函数入口是怎样恢复出来的?
理解后面的 wrapper,最好先看 VM 原本如何读取一个 AOT 函数。关键方法是 Deserializer::ReadInstructions。
下面保留 AOT 分支的核心字段和计算关系,省略 deferred、编译宏与完整错误检查:
cpp
// 删节代码:保留普通 AOT 入口恢复的关键关系。
void Deserializer::ReadInstructions(CodePtr code, bool deferred) {
const intptr_t index =
instructions_table_.rodata()->first_entry_with_code +
instructions_index_;
const uword payload_start = instructions_table_.EntryPointAt(index);
const uint32_t payload_info = ReadUnsigned();
const uint32_t unchecked_offset = payload_info >> 1;
const bool has_monomorphic_entrypoint = (payload_info & 0x1) != 0;
const uword entry_offset = has_monomorphic_entrypoint
? Instructions::kPolymorphicEntryOffsetAOT
: 0;
const uword monomorphic_offset = has_monomorphic_entrypoint
? Instructions::kMonomorphicEntryOffsetAOT
: 0;
const uword entry_point = payload_start + entry_offset;
const uword monomorphic_entry_point = payload_start + monomorphic_offset;
instructions_table_.SetCodeAt(instructions_index_++, code);
code->untag()->instructions_ = Instructions::null();
code->untag()->entry_point_ = entry_point;
code->untag()->unchecked_entry_point_ = entry_point + unchecked_offset;
code->untag()->monomorphic_entry_point_ = monomorphic_entry_point;
code->untag()->monomorphic_unchecked_entry_point_ =
monomorphic_entry_point + unchecked_offset;
}
这段代码没有执行函数。它在加载时把一个 Code 对象的入口信息恢复出来,让后面的调用者能够使用。
2.1 为什么索引前面要加 first_entry_with_code?
instructions_index_ 表达当前正在处理的 Code 在相应序列中的位置,而指令表并不保证每个条目都保留独立的 Code 对象。
表中存在一段没有独立 Code 的指令条目;first_entry_with_code 表示保留 Code 对象的那部分从哪里开始。因此,当前 Code 对应的表位置要加上这个起点。
text
InstructionsTable
[0 ... first_entry_with_code - 1] 没有独立 Code 的指令条目
[first_entry_with_code ...] 与保留的 Code 对象对应的条目
这不是复用与解释执行的分类边界,而是指令表与 Code 对象之间的表示关系。是否在 base 执行,需要后面的链接查询判断。
EntryPointAt(index) 找到当前函数对应的指令载荷位置。可以先把 payload_start 理解为"这组指令从哪里开始",但不要立刻把它等同于所有调用方式的入口。
2.2 payload_info 的两个部分分别表示什么?
关键计算只有两行:
cpp
const uint32_t unchecked_offset = payload_info >> 1;
const bool has_monomorphic_entrypoint = (payload_info & 0x1) != 0;
最低位保存是否有单态相关入口;其余高位保存 unchecked 入口偏移。
举个教学数值:假设 payload_info 为 17,即二进制 10001。
text
17 & 1 = 1 → 具有单态相关入口
17 >> 1 = 8 → unchecked_offset 为 8
这个 8 表示指令载荷布局中的字节偏移。它不表示需要解释多少条指令,也不表示 wrapper 元数据的编号。
entry_offset 与 monomorphic_offset 由对应的指令布局常量决定。常规调用、单态调用,以及各自的 unchecked 路径,需要使用符合编译约定的位置。
2.3 instructions_ 为什么可以是 null?
这行容易被误读:
cpp
code->untag()->instructions_ = Instructions::null();
它不表示函数没有机器指令。在该 AOT 表示中,运行时不需要为每个 Code 恢复一份独立的 Instructions 堆对象;指令已经存在于映像里,并通过表与入口地址关联。
因此,判断一个函数是否有可执行实现,不能只观察 instructions_ 字段是否为空,还要理解当前的 AOT 表示方式。
2.4 混合执行在这里改变了哪一步?
普通路径直接计算 payload_start + offset。混合路径把"计算载荷位置"和"建立可调用入口"分开。
cpp
// 删节后的混合加载路径:省略边界检查和长度计算。
const intptr_t patch_index =
instructions_table_.rodata()->first_entry_with_code +
instructions_index_;
const uword payload_start = instructions_table_.EntryPointAt(patch_index);
const uint32_t payload_info = ReadUnsigned();
const EntryPoints entries = WrapCode(
code, patch_index, payload_info, payload_start,
instructions_length, base_payload_start);
instructions_table_.SetCodeAt(instructions_index_++, code);
code->untag()->instructions_ = Instructions::null();
code->untag()->entry_point_ = entries.entry_point;
code->untag()->unchecked_entry_point_ = entries.unchecked_entry_point;
code->untag()->monomorphic_entry_point_ = entries.monomorphic_entry_point;
code->untag()->monomorphic_unchecked_entry_point_ =
entries.monomorphic_unchecked_entry_point;
区别集中在 WrapCode:输入仍然是函数的载荷位置与 payload_info,输出却是一组按执行目标处理的入口。它可能选择 base 中的指令,也可能选择 patch 中的指令,并参与后续包装。
这里还要区分初始化阶段。某些记录最初返回的是未包装入口,待加载关系准备好后再统一应用 wrapper。不能认为调用 WrapCode 返回的每个值,都已经是可以立即对外使用的最终桥接地址。
3. 再读 WrapFunction:选择 base,还是选择 patch?
WrapCode 处理具有 Code 对象的路径。对于指令表中没有独立 Code 的函数目标,读取器还需要 WrapFunction 建立对应入口。
两者共享一个核心问题:当前目标要在 CPU 上执行,还是由 Simulator 执行?
3.1 逐个分支看目标选择
下面把 base 表字段缩写为 base_instructions_table_,避免引入与机制无关的命名。保留关键分支,错误报告文本与装饰宏已省略:
cpp
// 按加载语义整理的删节代码;base 表字段使用简写。
Deserializer::EntryPoints Deserializer::WrapFunction(
intptr_t patch_index, uword patch_payload_start, uword* slot) const {
// ① 这组应用链接逻辑不包装非 app 指令。
if (instructions_type_ != app) {
return EntryPoints::Function(patch_payload_start, EntryPoints::NoWrap);
}
// ② 没有 base 表时,也不应存在要求链接到 base 的映射。
if (base_instructions_table_.IsNull()) {
if (LinkOracle::shared() != nullptr) {
FATAL("Unexpected linking state");
}
return RecordFunctionWrap(slot, patch_payload_start,
LinkOracle::RunTarget::cpu);
}
// ③ 有 base 表但没有启用链接,同样按 CPU 目标处理。
if (LinkOracle::shared() == nullptr) {
return RecordFunctionWrap(slot, patch_payload_start,
LinkOracle::RunTarget::cpu);
}
// ④ 链接信息决定这个 patch 条目的实际目标。
const auto target = RunTargetForPatchIndex(patch_index);
if (target.is_cpu()) {
const uword base_payload_start =
InstructionsTable::start_pc(base_instructions_table_.ptr()) +
static_cast<uword>(target.offset);
AssertInstructionMatch(base_payload_start, patch_payload_start);
return RecordFunctionWrap(slot, base_payload_start,
LinkOracle::RunTarget::cpu);
}
// ⑤ 无法走 CPU 复用目标的这条链接路径,使用 patch 指令。
return RecordFunctionWrap(slot, patch_payload_start,
LinkOracle::RunTarget::sim);
}
LinkOracle 在这里可以理解为"根据链接信息查询运行目标"的对象。复用判断已经由生成端准备,加载时根据映射恢复目标,不会对每个函数重新编译、重新分析源码。
前面三个分支也说明一个重要细节:地址变量叫 patch_payload_start,并不保证它此刻一定属于下载的只读补丁。 该方法参与不同的加载状态,应当结合链接状态和目标类型解释变量。
有链接映射时,target.is_cpu() 分支将载荷位置换成 base 中的对应位置;模拟目标则继续使用 patch 的位置。
text
同一个 patch 条目
├─ CPU 复用目标 → base 起点 + 链接偏移
└─ Simulator 目标 → patch 载荷位置
slot 用于传递需要按协议更新的入口槽信息。能否直接包装、是否需要记录待处理项,由下一层 RecordFunctionWrap 决定;它不是一个业务函数参数。
3.2 RunTargetForPatchIndex 用什么查映射?
运行目标查询使用指令表条目的偏移:
cpp
// 删节代码:保留指令表偏移到链接目标的转换。
LinkOracle::RunTargetOffset Deserializer::RunTargetForPatchIndex(
intptr_t index) const {
const int32_t offset = static_cast<int32_t>(
instructions_table_.rodata()->entries()[index].pc_offset);
return LinkOracle::shared()->RunTargetFromPatchOffset(offset);
}
index 是表位置,pc_offset 是映像内位置,查询结果里还有目标执行类型及目标偏移。三个概念各有用途。
使用偏移而非生成机器上的绝对地址,才能在本次进程映像基址变化之后恢复目标。文件偏移、指令映像偏移和进程地址仍需按各自的加载约定转换,不能相互直接替代。
3.3 首条指令相等,能证明两个函数一样吗?
AssertInstructionMatch 的核心检查是:
cpp
// 删节代码。
const uint32_t base_instruction =
*reinterpret_cast<const uint32_t*>(base_payload_start);
const uint32_t patch_instruction =
*reinterpret_cast<const uint32_t*>(patch_payload_start);
RELEASE_ASSERT(base_instruction == patch_instruction);
它检查映射指向的起点是否满足预期,是加载时的一项一致性断言。它没有比较整个函数,也没有遍历全部对象引用。
因此,不能把它解释成"只要首条指令一样,就复用 base"。决定复用的是前面已有的链接关系,这项检查发生在选中 CPU 目标之后。
如果代码 A 内联了旧价格函数 B,即使 A 和新 A 的首条指令相同,A 后面的行为仍可能不同。生成端必须考虑内联和依赖影响,加载端这条断言不能代替语义判断。
3.4 WrapperForRunTarget 选择的是桥接方向
目标确定以后,入口转换函数也跟着确定:
cpp
// 删节代码:保留这组反序列化包装入口的方向选择。
std::function<uword(uword)> Deserializer::WrapperForRunTarget(
LinkOracle::RunTarget target) const {
const Deserializer* self = this;
if (target == LinkOracle::RunTarget::sim) {
return [self](uword entry_point) {
return self->thread()->isolate_group()
->WrapCPUToSimEntryPoint(entry_point);
};
}
return [self](uword entry_point) {
return self->thread()->isolate_group()
->WrapSimToCPUEntryPoint(entry_point);
};
}
返回值是一个接受原始入口、输出包装入口的函数。它在这里描述入口转换过程,生成代码实际调用的是转换后得到的机器级入口,不会在每次 Dart 调用时直接执行这段 C++ lambda。
目标属于 Simulator,提供 CPU → Simulator 桥接;目标属于 CPU,提供 Simulator → CPU 桥接。这是这组包装路径对跨执行模式入口的处理,不能据此断言每个 CPU → CPU 调用都必须绕一遍解释器。
4. 读 EntryPoints 与 pending_wraps:为什么不能只改一个地址?
前面知道了 wrap 从哪里来,现在看它怎样作用到函数的多个入口。
4.1 先计算指令位置,再分别包装
下面将构造函数的初始化列表展开,保留 payload_info 的解码与四种入口关系:
cpp
// 按 EntryPoints 构造逻辑展开;省略成员声明与辅助方法。
EntryPoints::EntryPoints(uword start,
std::function<uword(uword)> wrap,
uint32_t info) {
payload_start = start;
const bool has_monomorphic = (info & 1) != 0;
const uword unchecked_offset = info >> 1;
const uword checked_offset = has_monomorphic
? Instructions::kPolymorphicEntryOffsetAOT
: 0;
entry_point = wrap(start + checked_offset);
unchecked_entry_point = wrap(start + checked_offset + unchecked_offset);
if (has_monomorphic) {
const uword mono = start + Instructions::kMonomorphicEntryOffsetAOT;
monomorphic_entry_point = wrap(mono);
monomorphic_unchecked_entry_point = wrap(mono + unchecked_offset);
} else {
monomorphic_entry_point = entry_point;
monomorphic_unchecked_entry_point = unchecked_entry_point;
}
}
最重要的关系是:
cpp
wrap(start + checked_offset + unchecked_offset)
而不是:
cpp
wrap(start + checked_offset) + unchecked_offset // 这种计算没有相同保证
unchecked_offset 属于目标指令载荷的布局,不属于 wrapper 代码布局。两种写法并不等价。
用一组教学地址看得更清楚:假设普通目标是 0x1000,unchecked 目标是 0x1008,包装分别返回 0x8000 和 0x9000。
| 项目 | 结果 |
|---|---|
| 普通入口 | wrap(0x1000) → 0x8000 |
| unchecked 入口 | wrap(0x1008) → 0x9000 |
| 把偏移加到普通 wrapper 后 | 0x8000 + 8 → 0x8008,没有指向第二个目标的保证 |
这就是常规调用能跑,某种优化调用却跳错的原因之一。
还有一个特殊情况:unchecked 偏移为零时,两个目标位置相同。实现可以复用已有包装结果,避免把同一目标人为拆成不同身份。上面的展开代码表达入口关系,没有穷举这类实现细节。
4.2 RecordFunctionWrap 为什么可能先返回 NoWrap?
cpp
// 删节代码:省略目标枚举的合法性检查。
Deserializer::EntryPoints Deserializer::RecordFunctionWrap(
uword* slot, uword instruction, LinkOracle::RunTarget target) const {
if (wraps_applied_) {
return EntryPoints(instruction, WrapperForRunTarget(target), 0);
}
pending_wraps_.Add(PendingWrap{nullptr, slot, instruction, 0, target});
return EntryPoints(instruction, EntryPoints::NoWrap, 0);
}
wraps_applied_ 描述当前反序列化实例所处的包装阶段,不是"这次设备是否已经更新"。
包装阶段已经就绪,便可按目标类型直接构造入口。尚未就绪时,先记录所需信息,使用 NoWrap 保留当前载荷关系。
这里的 NoWrap 是保持地址不变的转换:
cpp
static uword NoWrap(uword address) { return address; }
它不是授权 CPU 执行只读 patch 的开关。它表达加载期间的一个中间状态,程序对外开始使用前还必须完成对应的包装与字段同步。
具有 Code 的 RecordCodeWrap 记录 Code 指针与完整 payload_info;Function 或入口槽相关路径则按自己的协议记录信息。两种记录最终都需要在加载流程中落到实际消费者。
4.3 ApplyPendingWraps 怎样写回最终入口?
cpp
// 删节代码:省略完整合法性检查,保留两种写回目标。
void Deserializer::ApplyPendingWraps() {
while (pending_wraps_.length() > 0) {
const PendingWrap item = pending_wraps_.RemoveLast();
const auto wrap = WrapperForRunTarget(item.target);
if (item.code != nullptr) {
if (item.code->untag()->owner() == Object::null()) {
continue;
}
const EntryPoints entries(item.payload_start, wrap, item.payload_info);
item.code->untag()->entry_point_ = entries.entry_point;
item.code->untag()->unchecked_entry_point_ = entries.unchecked_entry_point;
item.code->untag()->monomorphic_entry_point_ =
entries.monomorphic_entry_point;
item.code->untag()->monomorphic_unchecked_entry_point_ =
entries.monomorphic_unchecked_entry_point;
} else if (item.slot != nullptr) {
const EntryPoints entries(item.payload_start, wrap, 0);
*item.slot = entries.entry_point;
}
}
wraps_applied_ = true;
}
RemoveLast() 取出记录;WrapperForRunTarget() 决定转换方向;EntryPoints 重建各入口;最后将结果写回 Code 字段或对应槽位。
owner() 为空的 Code 记录被跳过,说明 pending 集合并不意味着每项都必须盲目写回。正文不能把省略后的循环当成不带状态条件的"批量改地址"。
循环结束才设置 wraps_applied_。随后遇到的包装请求可以走已就绪的分支。这个标记把"先记录"和"现在可应用"的阶段衔接起来。
正常对外执行前,涉及的 Code、Function、调用表与缓存必须完成一致性处理。不要根据一个中间函数里的赋值,推断此刻应用已经能够运行。
4.4 Function 为什么还需要 PostLoad?
Function 会使用与它关联的 Code,但某些入口也缓存在 Function 上。Code 已经更新,Function 缓存却没同步,同一个函数就可能被不同调用路径以不同地址使用。
下面是 AOT PostLoad 中同步入口的核心关系:
cpp
// 删节代码:省略遍历 refs 与类型检查。
auto code = function->untag()->code();
if (!Code::IsUnknownDartCode(code)) {
function->untag()->entry_point_ = code->untag()->entry_point_;
function->untag()->unchecked_entry_point_ =
code->untag()->unchecked_entry_point_;
}
UnknownDartCode 相关路径不能按普通独立 Code 处理,其指令表入口可能已经通过另一条读取路径取得。这正是下一段分发表代码要区分两类目标的原因。
4.5 ReadDispatchTable 为什么不是一律读 Code::EntryPointOf?
表项解码后,一条重要分支是:目标有没有保留独立 Code 对象。
cpp
// 删节代码:只展示非 deferred 路径里这两类指令表条目。
if (table_index < first_entry_with_code) {
const uword payload = instructions_table().EntryPointAt(table_index);
uword slot = 0;
value = WrapFunction(table_index, payload, &slot).entry_point;
} else {
const intptr_t cluster_index = table_index - first_entry_with_code;
auto code = static_cast<CodePtr>(Ref(code_start_index() + cluster_index));
value = Code::EntryPointOf(code);
}
array[i] = value;
前一类条目没有独立 Code 可供读取,直接取得指令载荷之后,还必须经过 WrapFunction。后一类则可以找到已经恢复的 Code,使用它的入口。
first_entry_with_code 与前面 ReadInstructions 的索引计算对应起来了:同一个边界既帮助 Code 定位指令,也帮助分发表判断该通过哪条路径获得入口。
这段代码之外,还有基础对象引用、空目标、重复编码、最近使用条目与 deferred 等处理。不能把删节后的一个分支当成整个快照表格式。
动态分发表与间接静态调用表也有不同用途。使用间接静态调用编码的路径,可以在 ReadIndirectStaticCallTable 中根据 Code 引用恢复入口:
cpp
// 删节代码:保留读取顺序,省略所有权管理细节。
const intptr_t length = ReadUnsigned();
for (intptr_t i = 0; i < length; ++i) {
table[i] = Code::EntryPointOf(static_cast<CodePtr>(Ref(ReadUnsigned())));
}
这些表消费的是已恢复的入口关系。编译端必须真正发出匹配的调用编码,准备一张表并不会让所有直接调用或内联代码自动经过它。
5. CPU 与 Simulator 怎样通过桥梁互相调用?
这里的 Simulator 是 Dart VM 内部的指令解释器,不是 Xcode 的设备模拟器。它维护模拟寄存器、程序计数器和执行状态,在真机上解释指令。
混合执行会出现四种组合:
| 调用者 | 目标 | 要解决的问题 |
|---|---|---|
| CPU | CPU | 按原生约定继续执行 |
| CPU | Simulator | 进入解释器,导入参数,运行目标并返回 |
| Simulator | CPU | 进入原生桥接,取得结果后恢复解释执行 |
| Simulator | Simulator | 按解释器状态继续运行 |
VM 中需要支持 CPU → Simulator 和 Simulator → CPU 的 wrapper,以及配套 stub。入口包装必须与调用路径匹配,不能假设每次调用都重新执行一次高级语言分支判断。
例如,商品页可以仍在 CPU 上执行,新的运费函数在解释器中执行:
text
页面 AOT
→ 可调用的桥接入口
→ 导入 Dart 参数与目标位置
→ Simulator::Call / Execute
→ 运费计算
→ 返回 Dart 结果
→ 页面 AOT 继续执行
这些桥梁传递的是 Dart 调用状态,包括对象引用、参数与返回值。它们需要遵守 Dart 的调用约定,不能用普通 C++ 函数调用简单代替。
对 iOS 来说,可调用桥梁由应用已经具备的运行时模板与元数据机制承载。补丁中的指令本体仍作为数据使用;建立桥接入口与把下载代码变成可执行页,是不同的事情。
5.1 wrapper 既需要代码,也需要目标元数据
桥接不能只有一段"进入解释器"的通用指令,还需要知道本次进入哪个函数。
一种实现思路,是将可执行的桥接模板与每个目标的元数据分离:模板负责统一的过渡动作,元数据保存实际目标和需要的入口信息。
text
可执行部分:landing pad / shared stub
↓ 定位本项数据
可读取的元数据:目标指令地址、桥接所需信息
这样,更新目标变化可以通过元数据表达,桥接主体仍由预先存在的代码承担。代码页与元数据页有不同权限及生命周期,不能混为一个随意读写执行的缓冲区。
wrapper 分配、地址查找和元数据对应关系还用于让 VM 识别一个 PC 是桥接入口还是实际目标。栈追踪与执行状态转换都可能需要这个信息。
这也是为什么用 C++ 的 std::function 包一个函数,或者写一个简单的 switch,无法替代真实 wrapper:生成代码需要可按 Dart 调用约定进入的机器级入口。
5.2 用一次嵌套调用看桥接的必要性
考虑这样一条链:
text
原生页面 A
→ 补丁价格函数 B(解释执行)
→ 可复用辅助函数 C(原生)
→ 补丁回调 D(解释执行)
如果桥接只保存一个"当前是否解释执行"的全局变量,B 调 C 时清零,C 调 D 时置一,D 返回后就可能无法知道应恢复哪一层。
因此,需要保存每一层过渡的先前状态、返回位置以及相关栈信息。过渡是可嵌套的调用关系,而不是进程全局的一次开关。
执行完成后必须依次恢复:D 返回 C,C 返回 B,B 返回 A。这也解释了后续 Thread 与 transition 链改动的必要性。
6. 读 Simulator:补丁指令如何走进真正的 VM runtime?
解释器最终需要逐条读取指令,同时识别"进入原生服务"的过渡。先从执行循环看起。
6.1 ExecuteNoTrace 为什么每次都重新读取 PC?
cpp
// 删节代码:保留无跟踪执行循环。
void Simulator::ExecuteNoTrace() {
uword program_counter = get_pc();
while (program_counter != kEndSimulatingPC) {
Instr* instruction = reinterpret_cast<Instr*>(program_counter);
icount_++;
InstructionDecodeImpl(instruction);
program_counter = get_pc();
}
}
reinterpret_cast 在这里把位置解释为待读取指令的对象视图,不是把它强转为函数指针进行原生调用。
icount_++ 记录执行的指令数。真正完成模拟状态修改的是 InstructionDecodeImpl。
最后重新调用 get_pc(),而不是在循环里直接把 program_counter 加 4,因为分支、返回和过渡可以改变下一条指令的位置。
kEndSimulatingPC 是结束模拟的哨兵位置。不能把它理解成当前函数 text 区域的文件长度;返回与过渡协议决定何时结束这次执行。
6.2 InstructionDecodeImpl 怎样推进 PC?
cpp
// 删节代码:保留主要分派和 PC 更新关系。
void Simulator::InstructionDecodeImpl(Instr* instruction) {
pc_modified_ = false;
if (instruction->IsLoadStoreOp()) {
DecodeLoadStore(instruction);
} else if (instruction->IsDPImmediateOp()) {
DecodeDPImmediate(instruction);
} else if (instruction->IsCompareBranchOp()) {
DecodeCompareBranch(instruction);
} else if (instruction->IsDPRegisterOp()) {
DecodeDPRegister(instruction);
} else {
// 其余受支持的指令类别在此省略。
DecodeOtherInstruction(instruction);
}
if (!pc_modified_) {
set_pc(reinterpret_cast<int64_t>(instruction) + Instr::kInstrSize);
}
}
DecodeOtherInstruction 是省略其他分支后用于说明位置的示意名称;真实实现按具体指令类别处理,并有未支持指令的失败路径。
普通算术或访存完成后,如果没有主动修改 PC,运行时将它推进一个指令长度。分支处理若已设置新 PC,就跳过顺序推进。
这两个方法共同说明了解释执行的实际形态:读取指令,按类别改变模拟寄存器或内存状态,再决定下一位置。CPU 始终执行解码函数,patch 指令供它们读取。
6.3 RuntimeEntry::GetEntryPoint 返回的未必是原生函数地址
对象分配、类型检查等服务由 VM runtime 提供。但解释器不能把原生 C++ 地址直接当成普通模拟指令位置使用。
cpp
// 删节代码:省略编译宏与参数数量断言。
uword RuntimeEntry::GetEntryPoint() const {
uword entry = reinterpret_cast<uword>(function());
if (FLAG_use_simulator) {
Simulator::CallKind kind = is_leaf()
? (is_float() ? Simulator::kLeafFloatRuntimeCall
: Simulator::kLeafRuntimeCall)
: Simulator::kRuntimeCall;
entry = Simulator::RedirectExternalReference(
entry, kind, argument_count());
}
return entry;
}
第一行取得原生服务地址。解释执行模式下,再将地址、服务类别与参数数量交给 RedirectExternalReference,得到解释器能够识别的重定向入口。
is_leaf() 与 is_float() 参与类别选择。leaf 浮点服务和普通 runtime 不能简单用同一套参数传递规则。原实现对参数数量也有约束,删节代码没有取消这些约束。
这项能力与混合执行中的特殊 stub / 过渡机制共同服务于调用边界;不能仅从这个方法推导出每条应用 runtime 调用都会采取完全相同的重定向形式。
6.4 DecodeSimulatorToNativeTransition 在转换什么?
模拟指令流里可以包含专门用于表达原生服务过渡的指令。解码器读取其中的服务标记,再决定目标从哪里取得、使用哪一段桥接 stub。
下面抽取普通 runtime 与原生 Dart 两类目标,原始指令立即数在正文中用示意标签代替:
cpp
// 控制流解读:kRuntimeTransition / kDartOnCPU 是示意标签。
const int32_t service = instruction->Imm16Field();
const int64_t pc = get_pc();
unsigned long long target = 0;
void (*host)(unsigned long long, Simulator*) =
&CallClangCodeWithSimulatorArgs;
switch (service) {
case kRuntimeTransition:
target = static_cast<unsigned long long>(get_register(R5));
set_register(nullptr, LR, pc + Instr::kInstrSize);
break;
case kDartOnCPU:
target = static_cast<unsigned long long>(get_register(R16));
host = &RunDartOnCPU;
break;
// 其他 native、FFI、leaf runtime 和恢复路径省略。
}
SimulatorSetjmpBuffer buffer(this);
if (!setjmp(buffer.buffer_)) {
Thread* thread = Thread::Current();
SimulatorToCPU transition(this, thread);
host(target, this);
}
普通 runtime 这条路径从模拟 R5 取得目标,并保存下一指令位置到 LR。原生 Dart 路径从 R16 取得目标,并选择 RunDartOnCPU。不同服务的来源和返回约定并不相同。
host 是 C++ 层的桥接分派。实际参数和栈的适配仍由机器级 stub 完成,而不是直接把 target 转成某个任意 C++ 函数原型。
SimulatorSetjmpBuffer 配合 VM 的异常与恢复路径使用;SimulatorToCPU 管理这段原生服务调用所处的执行状态。正常返回和非本地控制转移需要遵守相关协议,不能将这个片段当作完整异常处理实现。
6.5 CallClangCodeWithSimulatorArgs 为什么还要进 stub?
cpp
// 删节代码:这里读取的是原生桥接 stub 的入口。
void CallClangCodeWithSimulatorArgs(unsigned long long target, Simulator*) {
const uword entry = StubCode::CallClangCodeWithSimulatorArgs().EntryPoint();
reinterpret_cast<void (*)(unsigned long long)>(entry)(target);
}
与执行循环中读取 patch 指令不同,这里确实发生原生函数指针调用,但调用目标是运行时已有的桥接 stub。
C++ 调用先把真正的服务目标交给 stub,由 stub 按预定规则传递模拟状态中的参数、调用服务并处理返回。名称中的 Clang 描述这组桥接服务,不能解释成"设备现在现场编译了补丁"。
FFI、native、leaf runtime 与原生 Dart 的过渡还有各自约定,不能把这一个 stub 视为所有 ABI 的通用替代。
7. 读 Thread 与 transition:如何保证来回调用后状态仍然正确?
目标地址和参数都正确,线程状态不匹配,runtime 调用仍可能失败。进入 Simulator、离开 Simulator,需要同时处理模式标记和一组 stub 入口。
7.1 Simulator::Execute 将哪些入口切换到模拟侧?
cpp
// 删节代码:只展示模式范围与两组代表性入口。
void Simulator::Execute(const char* name, SimulatorExecutionSource source) {
Thread* thread = Thread::Current();
CPUToSimulator transition(this, thread, source == ExecuteFromResume);
thread->set_call_to_runtime_entry_point(
StubCode::SimulatorCallToRuntime().EntryPoint());
thread->set_call_bootstrap_native_entry_point(
StubCode::SimulatorCallBootstrapNative().EntryPoint());
thread->set_enter_safepoint_entry_point(
StubCode::SimulatorEnterSafepoint().EntryPoint());
thread->set_exit_safepoint_entry_point(
StubCode::SimulatorExitSafepoint().EntryPoint());
// 其他 native 入口、栈状态管理与 trace 选择在此省略。
ExecuteNoTrace();
}
CPUToSimulator transition 记录进入前的相关状态,并使本次执行处于模拟模式;函数范围结束时,按过渡协议恢复。
随后分别设置 runtime、bootstrap native、safepoint 入口。生成代码通过 Thread 使用这些入口时,获得的是适合当前执行方式的 stub。
不能只设置一个 is_simulating 标记,而保留原生入口不变。模式变量表达状态,stub 地址才直接影响调用会走向哪里。
7.2 SimulatorToCPU 构造与析构分别处理什么?
cpp
// 删节代码:省略其他 native / safepoint stub 和完整基类。
class SimulatorToCPU : public Simulator::Transition {
public:
explicit SimulatorToCPU(Simulator* simulator, Thread* thread)
: Simulator::Transition(simulator, thread),
simulator_csp_(thread->simulator_csp()) {
thread->set_is_simulating(0);
thread->set_call_to_runtime_entry_point(
StubCode::CallToRuntime().EntryPoint());
}
~SimulatorToCPU() override {
thread_->set_is_simulating(1);
thread_->set_call_to_runtime_entry_point(
StubCode::SimulatorCallToRuntime().EntryPoint());
thread_->set_simulator_csp(simulator_csp_);
simulator_->set_pc(lr_);
simulator_->set_register(nullptr, LR, static_cast<int64_t>(lr_));
simulator_->top_transition_ = previous_;
}
private:
uword simulator_csp_;
};
进入原生服务时,构造函数保存相关模拟栈状态,设置原生模式与原生 runtime stub。服务返回时,析构将入口恢复到模拟侧,并恢复模拟栈、PC、LR 与上一层过渡关系。
lr_、previous_ 等状态由 transition 协议管理。正文省略的基类不是可有可无:它负责使这次过渡成为可追踪、可恢复的调用层。
set_pc(lr_) 把解释器放回继续执行的位置,而 set_register(LR, lr_) 还原模拟寄存器中的返回关系。它们共同说明"返回解释器"需要恢复执行上下文,不只是拿回一个 C++ 返回值。
其他 native 和 safepoint 入口也要切换,代码只是抽出一组代表性字段。
7.3 CPUToSimulator 为什么保存 was_simulating_?
cpp
// 删节代码:突出嵌套过渡所需的先前状态。
CPUToSimulator::CPUToSimulator(Simulator* simulator,
Thread* thread,
bool is_resuming)
: Simulator::Transition(simulator, thread),
is_resuming_(is_resuming),
was_simulating_(thread->is_simulating() != 0) {
thread->set_is_simulating(1);
}
CPUToSimulator::~CPUToSimulator() {
if (!was_simulating_) {
thread_->set_is_simulating(0);
thread_->set_call_to_runtime_entry_point(
StubCode::CallToRuntime().EntryPoint());
// 其余原生 stub 的恢复省略。
}
simulator_->set_register(nullptr, LR, static_cast<int64_t>(lr_));
simulator_->top_transition_ = previous_;
}
was_simulating_ 表示进入这层之前是否已经处于模拟模式。退出时不能无条件把模式清零,否则嵌套过渡会破坏外层解释执行的状态。
例如 AOT A → 模拟 B → 原生 C → 模拟 D,D 返回以后要恢复 C 的原生过渡,C 返回以后再恢复 B。每一层保存自己的先前关系,最终才能回到 A。
具体恢复动作由各类过渡协议共同完成,这个构造与析构片段只展示其中的关键条件。
7.4 SetupDartMutatorStateDependingOnSnapshot 怎样把表接到线程?
cpp
// 删节代码:省略编译宏、其他入口缓存与字段表初始化。
void Thread::SetupDartMutatorStateDependingOnSnapshot(IsolateGroup* group) {
auto object_store = group->object_store();
if (object_store != nullptr) {
global_object_pool_ = object_store->global_object_pool();
auto dispatch_table = group->dispatch_table();
if (dispatch_table != nullptr) {
dispatch_table_array_ = dispatch_table->ArrayOrigin();
}
const uword* calls = group->indirect_static_call_table();
if (calls != nullptr) {
indirect_static_call_table_ = calls;
}
}
}
快照已经恢复正确,只完成了程序结构这一侧。线程还需要拿到全局对象池、分发表来源和间接调用表,生成代码才能按约定访问它们。
object_store、dispatch_table 与调用表的空值检查,适应了相关初始化时点。创建过程第一次进入 isolate 时,快照可能尚未读取完;读取完成后或线程进入相应环境时,还需要按协议设置状态。
不能将一次字段设置当作对所有未来线程永久有效。每个执行环境的进入、退出和状态缓存,都要遵守 VM 的初始化协议。
8. GC 与异常怎样跨越执行边界?
混合执行仍共享 Dart 的对象模型。解释器创建的对象可能交给原生函数,原生函数也可能返回对象给解释器。
垃圾回收必须在相关执行状态中找到活对象,不能让引用只藏在 GC 无法识别的位置。过渡栈、线程状态、句柄和 safepoint 的协调,都是 VM 层工作。
异常也会跨越边界。例如:
text
CPU 上的调用者
→ 解释执行函数
→ runtime 服务抛出异常
→ 展开执行状态,转移到正确的异常处理位置
这里涉及 Exceptions::JumpToFrame、模拟栈与原生过渡链的协作。只恢复 PC 而没有正确恢复栈和线程状态,可能让异常路径跳进无效位置。
因此,混合执行验收必须包含对象分配、GC、嵌套调用、异常与 native / FFI,而不能只看一个函数能否返回正确数字。
8.1 为什么"对象地址不变"不能作为保证?
Dart 的垃圾回收可能移动可移动对象。对象引用必须保存在运行时能够识别、并在需要时更新的位置,而不能把一个临时裸地址当成永远有效的值。
例如,解释执行函数创建 Order,调用原生 runtime 期间发生 GC,随后返回继续访问订单。运行时需要知道这个订单仍然活着,解释器恢复后也必须取得正确引用。
text
解释执行持有对象
→ 进入 runtime,可能触发 GC
→ VM 识别活引用并完成必要更新
→ 返回后仍访问同一个 Dart 对象
并不是把所有模拟寄存器中的数字都当成对象。整数值、原始地址和对象引用需要按既定约定区分,否则会把非引用当成根,或漏掉真正的对象。
因此,解释器寄存器与栈、transition 信息、句柄和 VM 栈遍历之间必须协作。这是保持 Dart 对象语义的要求,不是另建一个与 VM 隔离的脚本堆。
8.2 异常路径需要恢复到哪一层?
考虑一个原生页面调用补丁函数,并由页面捕获异常:
dart
double checkedTotal(double subtotal, double shipping) {
if (subtotal < 0) {
throw ArgumentError.value(subtotal, 'subtotal');
}
return subtotal + shipping;
}
void main() {
try {
print(checkedTotal(-1, 8));
} on ArgumentError {
print('订单金额无效');
}
}
混合执行中,抛出点可能在解释器,异常构造又可能请求原生 runtime,捕获点则属于 AOT 调用者。
VM 需要依据栈与过渡链找出处理目标,恢复正确的帧、PC 和执行模式。仅在 runtime 外面加一层 C++ try/catch,不能替代 Dart 异常的语义。
GC 与异常都是日常 Dart 程序行为。只跑加减乘除,不能验证这些改造已经正确闭合。
9. 读创建检查:为什么 patch 可以只读,base 仍须可执行?
前面的入口包装和解释执行只有在映像权限一致时才成立。不能让 CPU 最终直接跳到只读 patch,也不能因为使用解释器就整体放弃可执行性要求。
Dart_CreateIsolateGroup 相关检查的核心关系,可以删节为:
cpp
// 删节代码:省略完整 API 创建参数与错误信息。
if (base_app_snapshot_instructions != nullptr) {
RELEASE_ASSERT_WITH_MSG(
VirtualMemory::IsExecutable(base_app_snapshot_instructions),
"Base snapshot instructions must be executable");
} else if (snapshot_instructions != nullptr) {
RELEASE_ASSERT_WITH_MSG(
VirtualMemory::IsExecutable(snapshot_instructions),
"Snapshot instructions must be executable");
}
有可供链接的 base 指令时,检查 base 的可执行性;没有 base 的普通路径,则检查输入 snapshot 指令的可执行性。它改变的是检查对象,不是无条件删除检查。
结合前面的代码,可以得到一条清楚的约束:
text
CPU 目标:必须落在可执行的 AOT / 桥接区域
Simulator 目标:指令可作为只读内容读取,由解释器执行语义
patch ELF 的只读映射与这套 VM 行为相配合。文件有 ELF、文件内段叫 text,都不能单独决定 CPU 会从哪里执行。
9.1 可读、可执行与存活,是三个不同条件
| 区域 | 主要用途 | 存活要求 |
|---|---|---|
| base AOT text | CPU 执行被复用实现 | 引用它的调用仍可能发生时必须保留 |
| patch 指令载荷 | 解释器读取新逻辑 | 模拟执行和链接仍使用它时必须保留 |
| wrapper 代码 | CPU 执行桥接 | 相关入口仍可能被调用时必须有效 |
| wrapper 元数据 | 描述目标及桥接信息 | 与对应入口的关系保持有效 |
加载器通过结构检查,但上层立刻释放映射,执行仍会访问无效位置。反序列化获得入口,也不意味着可以卸载产生它的映像。
文件偏移和链接偏移还要通过对应边界检查。选择 Simulator 不是忽略映像范围的理由。
9.2 为什么 hash 正确仍然不一定可运行?
文件 hash 回答"是否拿到了期望字节",快照版本和特性回答"VM 是否按同一套协议读取",入口与权限检查回答"实际执行目标是否满足约束"。
它们层次不同。文件内容一致,并不能替代引用关系、ABI、线程状态和业务执行验证。
10. 生成端也属于 Dart VM 改造的一部分
前面的运行时适配,需要编译与分析工具生成相匹配的产物。
gen_snapshot 中的预编译器、汇编器、stub 生成、对象池与分发表处理,需要与运行时使用相同的布局和调用约定。分析工具则帮助读取快照、判断复用关系和生成链接信息。
text
生成端:分析身份与依赖 → 对齐引用 → 生成指令、stub 与链接信息
运行端:恢复对象图 → 解析目标 → 包装入口 → 设置线程状态
执行端:Simulator 与 CPU 协作 → 调用 runtime → 返回或抛异常
调用编码不同、表项偏移不同、快照格式不同,都可能在执行之前或某条特定路径上失败。不能只更换 VM,而让生成端继续输出另一套约定。
10.1 预编译器需要保留可对齐的程序关系
AOT 优化会内联、去虚化、消除无用对象,还可能把对象池或调用表压紧。它们能提升普通程序的运行效率,却让不同版本之间的直接拼接更困难。
补丁生成因而需要为复用建立映射依据,并在编译时应用相应约束。涉及的工作包括函数与类身份对齐、对象池位置映射、调用表布局,以及对依赖影响的分析。
例如,一份目标程序如果通过槽位 42 调用价格函数,生成端要保证这个槽位在运行时对应目标实现。若指令已经写死了某种池加载或直接调用编码,映射变化还需要落到最终指令与引用关系中。
这不是只向快照末尾追加一段 JSON。编译器写出的引用方式与 VM 读取的引用方式必须一致。
10.2 汇编器和 stub 也参与协议
解释器需要识别 runtime / native 过渡,生成端就要发出与之匹配的指令或 stub。对象池里的 native 入口、调用类型和相关引用也需要正确序列化。
stub 可以理解为编译代码与运行时之间的一段机器级适配代码。它建立参数、线程状态与返回协议,不能随意替换成一个具有相似函数签名的 C++ 调用。
对象池编辑若改变加载位置,必须尊重原来的指令编码和序列长度约束;调用入口若使用间接表,编译端也必须实际采用该表。否则,运行时准备了一张正确的表,却没有调用者使用它。
10.3 写端与读端应保持哪些不变量?
| 不变量 | 不一致时可能出现的结果 |
|---|---|
| 快照格式与特性一致 | 在加载头检查或对象读取时失败 |
| 代码索引与指令表一致 | 取到错误载荷或越界 |
| 对象池映射与指令编码一致 | 访问错误对象、常量或 native 目标 |
| 分发表与调用协议一致 | 动态调用进入错误函数 |
| runtime 过渡编码与解释器一致 | 指令未支持或过渡服务执行错误 |
| wrapper 元数据与调用入口一致 | 参数、返回位置或目标不正确 |
这些检查帮助定位问题。它们是机制之间的合同,不意味着简单断言几项数值,就证明整个业务正确。
11. 把整条链串起来
11.1 一次实际调用的完整旅程
假设页面函数 A 可以复用 AOT,价格函数 B 使用解释执行,新 B 内部又会构建一个订单结果对象。
- 生成端判断 A 的调用关系可以被正确连接,将 A 映射到 base;B 对应 patch 的指令位置。
- VM 读取补丁快照,为 A、B 恢复相应的 Function / Code 关系。
- B 的普通、unchecked 等入口按各自载荷位置建立桥接;调用表与相关缓存使用对应结果。
- 页面事件通过已有 Engine 路径进入 Dart,A 在 CPU 上执行。
- A 调用 B,经匹配的 CPU → Simulator 入口导入参数和目标位置。
- 解释器执行 B 的条件判断,选择免运费分支。
- B 请求对象分配,解释器识别 runtime transition,经原生 stub 调用 VM 服务。
- Thread 的过渡状态、runtime 入口与 GC 协议支持这次分配。
- 分配返回后恢复解释执行,B 写入结果并返回。
- 调用桥接恢复原生调用者,A 使用结果构建页面。
这条链包含多个执行边界。它也说明用户看到的"价格变正确了",背后远不只是替换一条乘法或加法指令。
11.2 从失败表现反推哪项关系没有成立
| 失败表现 | 优先核查的关系 |
|---|---|
| 快照版本或特性错误 | 生成工具与运行时格式是否匹配 |
| 原生入口落在只读 patch 区域 | 载荷地址是否被错误当作 callable entry |
| 普通调用成功,优化调用失败 | unchecked / monomorphic 入口与偏移是否正确 |
| 动态调用失败,直接调用成功 | 分发表与 Code / Function 入口是否一致 |
| runtime 或 FFI 才失败 | transition、调用类别、ABI 与 Thread 入口 |
| 第一次 GC 或异常后失败 | 活引用、过渡栈与恢复状态 |
| 部分函数仍显示旧行为 | 复用关系、内联影响与实际调用目标 |
表中的线索只是缩小问题范围,同一种崩溃也可能由其他原因造成。应该结合实际入口、线程状态和失败阶段定位,不能仅凭一个症状就修改不相关模块。
验证时,可以围绕普通调用、动态调用、嵌套跨模式调用、对象分配、异常和 native / FFI 建立具有实际行为断言的用例。不同入口路径确实被执行,才有资格判断对应机制工作。
对于运费修复,发生的事情可以概括为:
text
新 Dart 逻辑被编译
→ 生成与原程序的连接关系
→ 设备获得补丁
→ VM 校验并反序列化快照
→ ReadInstructions 关联指令与执行目标
→ Function / Code / 分发表建立正确入口
→ Thread 接入程序状态
→ Simulator 与 CPU 经桥接共同执行
下载、差分和启动恢复负责可靠交付;这篇文章展开的 VM 改动,负责让交付的内容真正成为可运行的 Dart 程序。
iOS 混合执行的关键,不只是加入一个解释器,而是把 快照、入口、调用约定、线程状态和运行时服务 一起扩展到两种执行模式。原生插件与资源仍有独立边界,性能也取决于解释执行的热点及跨模式调用频率。