Flutter iOS 热更新深入 Dart VM:读懂函数入口、解释执行与调用桥接

下载补丁只是把文件送到设备。真正让新逻辑生效的,是 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 内部又会构建一个订单结果对象。

  1. 生成端判断 A 的调用关系可以被正确连接,将 A 映射到 base;B 对应 patch 的指令位置。
  2. VM 读取补丁快照,为 A、B 恢复相应的 Function / Code 关系。
  3. B 的普通、unchecked 等入口按各自载荷位置建立桥接;调用表与相关缓存使用对应结果。
  4. 页面事件通过已有 Engine 路径进入 Dart,A 在 CPU 上执行。
  5. A 调用 B,经匹配的 CPU → Simulator 入口导入参数和目标位置。
  6. 解释器执行 B 的条件判断,选择免运费分支。
  7. B 请求对象分配,解释器识别 runtime transition,经原生 stub 调用 VM 服务。
  8. Thread 的过渡状态、runtime 入口与 GC 协议支持这次分配。
  9. 分配返回后恢复解释执行,B 写入结果并返回。
  10. 调用桥接恢复原生调用者,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 混合执行的关键,不只是加入一个解释器,而是把 快照、入口、调用约定、线程状态和运行时服务 一起扩展到两种执行模式。原生插件与资源仍有独立边界,性能也取决于解释执行的热点及跨模式调用频率。

相关推荐
前端snow3 小时前
为什么大厂要用Postgres + mogodb的框架?
前端
运维有小邓13 小时前
2026年主流AD域管理软件有哪些?
前端
Mickey同学3 小时前
提示词注入:AI 时代的「SQL 注入」
前端
deli0073 小时前
GROUP BY 先别想当然:我把 SQL 分组语义做成了沙盘,8 个实验 + 27 条自检全绿
前端
WayneX3 小时前
开源 Vue 3 组件库 Morya UI:把组件、文档、AI 工具链一起做进一个包
前端·vue.js·前端框架
用户15741568165343 小时前
页面白屏 + Invalid prop: type check failed for prop "options"?原来是 script setup 的 ref
前端
btcSteven3 小时前
给浏览器装了个「AI 操作员」:纯聊天帮你完成任何任务
前端
高晶3 小时前
一种小功率锂电池组充电器方案
前端·架构
deli0073 小时前
AI 说写完了怎么知道它没骗你?16 项交付证据清单,我用码道做成一键核对页
前端