ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入剖析 .NET CoreCLR 的 MethodDesc(方法描述符)设计:方法句柄、入口点与 Precode 机制

深入剖析 .NET CoreCLR 的 MethodDesc(方法描述符)设计:方法句柄、入口点与 Precode 机制 深入剖析 .NET CoreCLR 的 MethodDesc方法描述符设计方法句柄、入口点与 Precode 机制【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime本文以 CoreCLR 的设计文档 method-descriptor.md 为主体系统讲解托管方法的内部表示 MethodDescmethod descriptor它是整个 CLR 中方法的唯一句柄负责缓存元数据中的高频信息、追踪方法运行时状态并持有方法入口点。文中将结合 src/coreclr/vm/method.hpp、src/coreclr/vm/precode.cpp 等源码逐层展开 MethodDesc 的种类划分、无虚表的多态实现、入口点槽位slot布局、MethodDescChunk 内存分块结构、SOS 调试命令以及 Precode 临时入口点与单/多可调用入口点的获取策略。读完之后你可以理解一个托管方法从元数据中的一个 token到可被调用的一条机器指令地址之间的完整生命周期并掌握定位方法代码地址的实际调试手段。MethodDescChunk 将公共的 MethodTable 与 token 高位提升到数组前方每个 MethodDesc 只保存自己在数组中的索引源自设计文档 Figure 1。一、MethodDesc 是什么托管方法的内部表示MethodDesc 是 CoreCLR 中一个托管方法managed method的内部表示其核心职责在设计文档中被归纳为四点提供唯一的方法句柄MethodDesc 可以在运行时任意位置被使用。对于普通方法一个 MethodDesc 唯一对应模块, 元数据 token, 实例化三元组instantiation 指泛型方法的类型实参集合。缓存计算成本高的元数据信息例如方法是否为 static这类被频繁询问、但从 ECMA-335 元数据中解析代价较高的属性。捕获方法的运行时状态例如该方法的机器码是否已经生成是否已 JIT。持有方法的入口点entry point这是 MethodDesc 与其他类型对象如 MethodTable最本质的区别之一。在源码中MethodDesc类定义于 src/coreclr/vm/method.hpp。该文件的注释明确说明MethodDesc 居住在MethodDescChunk中chunk 又隶属于某个EEClass即 MethodTable它们概念上是冷数据——正常的程序执行路径上不应频繁访问它们。这个注释与文档以尺寸换取性能的设计目标一脉相承。二、设计目标与非目标尺寸优先元数据兜底尺寸是第一性能目标由于每个方法都有一个 MethodDesc其内存占用被放大到方法数量级。设计文档给出的量化目标是普通非泛型方法的 MethodDesc 在当前设计下仅占 8 字节。这一点可以从源码中得到印证src/coreclr/vm/method.hpp 中定义了#ifdef TARGET_64BIT static const int ALIGNMENT_SHIFT 3; // 64 位平台8 字节对齐 #else static const int ALIGNMENT_SHIFT 2; // 32 位平台4 字节对齐 #endif static const size_t ALIGNMENT (1 ALIGNMENT_SHIFT);也就是说64 位构建下一个 MethodDesc 就是 8 字节——整个对象标志位、token 低位、slot 索引等必须全部塞进这两个字里。非目标不做全量缓存MethodDesc有意不缓存方法的全部信息。对于访问频率较低的信息典型如方法签名调用方需要回落到元数据本身去查询。源码中的大量 API如SizeOfArgStack()、IsVarArg()等见 src/coreclr/vm/method.hpp都是这种按需解析的体现。这一取舍是理解 MethodDesc 一切设计细节的总纲热路径数据内联冷数据回查元数据。三、MethodDesc 的种类Kinds设计文档列出了 8 种 MethodDesc。它们在源码中通过GetClassification()返回的分类值来判别mcFCall、mcArray、mcEEImpl、mcPInvoke、mcComInterop、mcDynamic等各类别的查询 API 集中在 src/coreclr/vm/method.hpp 的 Classifications of kinds of MethodDescs 区段。种类用途源码判据IL常规 IL 方法最常见的一类默认分类Instantiated带有泛型实例化、或在方法表上没有预分配槽位的较少见 IL 方法见 shared generics 相关实现FCall以非托管代码实现的内部方法标记了MethodImplAttribute(MethodImplOptions.InternalCall)的方法、委托构造器、tlbimp 生成的构造器相关机制见 corelib.mdIsFCall()mcFCall GetClassification()PInvokeP/Invoke 方法即标记DllImportAttribute的方法IsPInvoke()mcPInvoke GetClassification()EEImpl委托方法中由运行时提供实现的部分Invoke、BeginInvoke、EndInvoke见 dotnet-standards.md 中 ECMA-335 分区 II 关于委托的规定IsEEImpl()Array数组的运行时方法Get、Set、Address同样来自 ECMA-335 分区 II 对数组的规定IsArray()ComInteropCOM 接口方法。由于非泛型接口默认即可用于 COM 互操作该种类通常覆盖几乎所有接口方法IsCLRToCOMCall()FEATURE_COMINTEROP下Dynamic没有底层元数据的动态创建方法由 Stub-as-IL 和 LKGlight-weight code generation产生IsNoMetadata()/IsDynamicMethod()其中IsRuntimeSupplied()在源码中明确定义为 FCall 或 Array 两类实现由运行时提供。值得注意的是文档中引用的动态方法示例 DynamicMethod 正是后文 Precode 章节中无法预分配 MethodTable 槽位的典型场景之一。四、替代实现用 3 位 kind 位切换代替虚函数表从 C 的常规思路看多种类 MethodDesc应该用继承加虚函数来实现。但虚函数会强制每个对象携带一个虚表指针vtable pointer在 x86 上占 4 字节——对于 8 字节的 MethodDesc 而言是灾难性的浪费。设计文档给出的方案是放弃多态改为基于 MethodDesc kind 的分支切换而 kind 只需要 3 位即可表示。文档中的示例以GetAttrs为例DWORD MethodDesc::GetAttrs() { if (IsArray()) return ((ArrayMethodDesc*)this)-GetAttrs(); if (IsDynamic()) return ((DynamicMethodDesc*)this)-GetAttrs(); return GetMDImport()-GetMethodDefProps(GetMemberDef()); }当前源码中这一模式仍然成立各类判别函数IsArray、IsEEImpl、IsPInvoke、IsFCall、IsNoMetadata等都是对GetClassification()的等值比较见 src/coreclr/vm/method.hpp。这是3 位 kind 显式类型转换代替1 个 vptr的空间换时间决策是整个 MethodDesc 设计中最能体现工程取舍的一处。五、方法槽位Method Slots入口点在哪里每个 MethodDesc 都逻辑上拥有一个槽位每个 MethodDesc 都有一个slot里面存放该方法当前的入口点。即使是一个永远不会执行的方法例如抽象方法这个槽位也必须存在——因为运行时的多处机制依赖于入口点 ↔ MethodDesc之间的映射关系典型如IP2MD由代码地址反查方法。关键不变式invariant入口点不是创建 MethodDesc 时就急切分配的。只有当该方法被识别为将被执行的方法或者被用于虚方法覆盖virtual overriding时才会分配入口点。槽位存放在哪里mdcHasNonVtableSlot位槽位有两个可能的宿主MethodTable 中适用于需要通过槽位索引做高效查找的方法——例如虚方法、泛型类型上的方法。此时 MethodDesc 内部保存的是槽位索引用于快速定位入口点。MethodDesc 自身内部其他所有方法。这种布局改善了数据局部性、节省工作集working set而且对于动态创建的 MethodDescEdit Continue 新增的方法、泛型方法的实例化、System.Reflection.Emit.DynamicMethod之类的方法往往根本无法事先在 MethodTable 中预留槽位。决定槽位位置的是 MethodDesc 上的mdcHasNonVtableSlot位。在源码中这对应GetSlot()返回索引、HasStableEntryPoint()/GetStableEntryPoint()等接口见 src/coreclr/vm/method.hpp当方法没有稳定入口点时返回空一旦 JIT 完成SetStableEntryPointInterlocked会原子地把稳定入口点写入——这与下文 Precode 章节稳定入口点在方法生命周期内必须保持不变的不变式直接呼应。六、MethodDescChunk把公共信息提升出来的分块分配MethodDesc 以分块chunk方式分配以节省空间。同一类型下的多个方法往往共享同一个 MethodTable且元数据 token 的高位也相同方法 token 形如0x060000XX高 16 位是表标识低 16 位才是行号。MethodDescChunk的做法是把这份公共信息提升到方法数组的前面数组中的每个 MethodDesc 只需要保存自己在数组中的索引。对应关系上图Figure 1展示了MethodTable → MethodDescChunk → MethodDesc[]的链式结构。源码实现位于 src/coreclr/vm/method.hpp 的MethodDescChunk类CreateChunk(LoaderHeap* pHeap, DWORD methodDescCount, ...)按数量创建 chunkchunk 头部维护m_methodTable所属类型、m_nextchunk 链表、m_size/m_count、m_flagsAndTokenRange公共 token 高位等字段数据区紧随头部SizeOf() sizeof(MethodDescChunk) (m_size 1) * MethodDesc::ALIGNMENT反向定位同样轻量MethodDesc::GetMethodDescChunk()直接通过this - (sizeof(MethodDescChunk) 索引 * ALIGNMENT)算出 chunk 头地址src/coreclr/vm/method.hpp。这个结构精确实现了文档所述hoisting the common information in front of an array of multiple MethodDescs并且让 64 位下8 字节一个方法的目标成为可能——因为 MethodTable 指针和 token 高位都不再重复存储。七、调试 MethodDescSOS 命令速查设计文档给出了一组实用的 SOS 调试命令适用于调试器加载 SOS 扩展的场景用于在方法句柄、方法名、token、代码地址之间互查。以下是文档中的命令与示例输出可原样照抄使用DumpMD —— 转储 MethodDesc 的内容!DumpMD 00912fd8 Method Name: My.Main() Class: 009111ec MethodTable: 00912fe8md Token: 06000001 Module: 00912c14 IsJitted: yes CodeAddr: 00ca0070IP2MD —— 由代码地址反查 MethodDesc依赖入口点 ↔ MethodDesc映射这也是 slot 必须普遍存在的根本原因!ip2md 00ca007c MethodDesc: 00912fd8 Method Name: My.Main() Class: 009111ec MethodTable: 00912fe8md Token: 06000001 Module: 00912c14 IsJitted: yes CodeAddr: 00ca0070Name2EE —— 由方法名查找 MethodDesc!name2ee hello.exe My.Main Module: 00912c14 (hello.exe) Token: 0x06000001 MethodDesc: 00912fd8 Name: My.Main() JITTED Code Address: 00ca0070Token2EE —— 由 token 查找 MethodDesc在方法名奇怪、无法可靠输入时特别有用!token2ee hello.exe 0x06000001 Module: 00912c14 (hello.exe) Token: 0x06000001 MethodDesc: 00912fd Name: My.Main() JITTED Code Address: 00ca0070DumpMT -MD —— 转储给定 MethodTable 中全部 MethodDesc可对照PreJIT / JIT / NONE三种状态!DumpMT -MD 0x00912fe8 ... MethodDesc Table Entry MethodDesc JIT Name 79354bec 7913bd48 PreJIT System.Object.ToString() 793539c0 7913bd50 PreJIT System.Object.Equals(System.Object) 793539b0 7913bd68 PreJIT System.Object.GetHashCode() 7934a4c0 7913bd70 PreJIT System.Object.Finalize() 00ca0070 00912fd8 JIT My.Main() 0091303c 00912fe0 NONE My..ctor()补充一个文档提到的实用细节在debug 构建中MethodDesc 额外携带方法名与签名字段。当运行时状态严重损坏、SOS 扩展本身都无法工作时这些冗余信息仍然可以用来辨认对象身份。八、Precode临时入口点与高效 Stub 包装器入口点状态图Figure 2临时入口点precode的 target 初始指向 PreStub方法被 JIT 后 target 被原子替换为稳定入口点且稳定入口点在其后的方法生命周期内保持不变。Precode 是一段小代码片段承担两个用途临时入口点和高效的 stub 包装。文档将其描述为一个 niche code-generator窄域代码生成器专为这两种场景生成尽可能高效的机器码。理想世界中运行时动态生成的所有原生代码都应由 JIT 产出但对这两种场景而言这并不可行——Precode 因此存在。x86 上最基本的 precode 形如mov eax,pMethodDesc // 把 MethodDesc 装入暂存寄存器 jmp target // 跳转到目标用途一高效的 Stub 包装多路复用某些方法的实现由运行时以手写汇编 stub 提供P/Invoke、委托调用、多维数组的 getter/setter 等。Precode 为这些 stub 提供了一个空间高效的包装层让同一个 stub 的 worker 代码可以被多个方法复用stub 的 worker 代码被一段 precode 片段包住该片段可映射回某个 MethodDesc并跳到 worker 代码这样 worker 代码即可在多个方法间共享——这是 P/Invoke 编组 stub 的重要优化同时建立了 MethodDesc 与入口点之间的1:1 映射构成简单而高效的底层体系。用途二临时入口点懒 JIT 的关键方法在被 JIT 之前就必须拥有入口点——因为已 JIT 的代码需要有一个地址去调用它。临时入口点正是 Precode 提供的它是 stub 包装的一种特例。这是一种懒lazyJIT 策略在空间和时间上都是优化否则必须先 JIT 方法的全部传递闭包transitive closure才能执行而实际上只有真正被走过的代码分支例如 if 语句中执行的分支的依赖才需要 JIT。由于临时入口点数量很多它们必须做得很小即使以牺牲单次执行性能为代价而且每个临时入口点在真实代码生成前只执行一次。临时入口点的 target 是一个PreStub——一种专门触发方法 JIT 的特殊 stub。PreStub 会原子地把临时入口点替换为稳定入口点stable entry point。稳定入口点必须在方法生命周期内保持不变。这条不变式保证了线程安全因为方法 slot 的访问是从不加锁进行的。术语上文档做了严格区分稳定入口点可以是原生代码或precode原生代码可以是 JIT 代码也可以是 NGen/R2R 镜像中保存的代码——人们常说的 jitted code 其实指的是 native code。最复杂形态Precode Stub 原生代码三者并存Figure 3当方法执行前还需要额外工作通常是 R2R/NGen 镜像的 fixup时一个方法可能同时拥有 precode 与原生代码。此时原生代码作为 MethodDesc 的一个可选槽位存在便于以廉价且统一的方式查找方法的原生代码。九、Single Callable 与 Multi Callable 入口点入口点是用来调用方法的。MethodDesc 对外暴露了一组封装了按场景取最优入口点逻辑的方法。区分维度是这个入口点将被调用一次还是会被反复调用用临时入口点反复调用同一个方法是坏主意——每次调用都要穿过 PreStub而用临时入口点只调用一次则完全没问题临时入口点本就只应执行一次。设计文档列出的五个 API 为MethodDesc::GetSingleCallableAddrOfCodeMethodDesc::GetMultiCallableAddrOfCodeMethodDesc::TryGetMultiCallableAddrOfCodeMethodDesc::GetSingleCallableAddrOfVirtualizedCodeMethodDesc::GetMultiCallableAddrOfVirtualizedCode这些接口在当前源码中全部真实存在且被广泛使用。例如 src/coreclr/vm/appdomain.cpp 中AppDomain 初始化时用GetMultiCallableAddrOfCode()缓存PollGCHandledException与溢出异常抛出函数的入口点这些是被反复调用的热路径而 src/coreclr/vm/callhelpers.cpp 中调用构造函数时用的是GetSingleCallableAddrOfCode()。这种热调用取多态、一次性调用取单态的使用模式正是该 API 设计意图的活例证。十、Precode 的类型用指令流中的魔数字节判别Precode 存在多种专门化类型。关键约束是precode 的类型必须能从指令序列廉价地计算出来。x86/x64 上的做法是抓取固定偏移处的一个字节来判定类型——这也反过来约束了各类型 precode 的指令编码。从源码看类型判别字节集中定义在 DAC 描述中src/coreclr/vm/datadescriptor/datadescriptor.inc 里的PrecodeMachineDescriptor恰好包含文档描述的四种类型另含解释器/动态 helper 等扩展InvalidPrecodeTypePInvokeImportPrecodeTypeFixupPrecodeTypeStubPrecodeTypeThisPointerRetBufPrecodeTypeInterpreterPrecodeType、DynamicHelperPrecodeType、UMEntryPrecodeType方法侧的类型决策入口是MethodDesc::GetPrecodeType()实现于 src/coreclr/vm/method.cpp。各平台的HAS_XXX_PRECODE编译开关决定哪些专门化类型被启用文档原文All other precodes types are optional optimizations that the platform specific files turn on via HAS_XXX_PRECODE defines。10.1 StubPrecode —— 基本类型必须实现StubPrecode 是 precode 的基础形态把 MethodDesc 装入暂存寄存器后跳转。它必须被实现否则 precodes 根本无法工作并在没有其他专门化类型可用时作为兜底。x86 上的形态其中mov ebp,ebp是一条哑指令唯一作用是标记 precode 类型mov eax,pMethodDesc mov ebp,ebp // dummy instruction that marks the type of the precode jmp targettarget初始指向 PreStub随后被修补patched为最终目标。最终目标stub 或原生代码可能使用也可能不使用 eax 中的 MethodDescstub 通常使用原生代码不使用。10.2 FixupPrecode —— 省去一次寄存器装载当最终目标不需要暂存寄存器中的 MethodDesc 时使用 FixupPrecode它省掉了装载 MethodDesc的几个周期。当前大多数 stub 都采用这种更高效的形式在没有其他专门化 precode 需求时除互操作方法外基本都用它。x86 上的初始状态call永不返回Pop 出返回地址后从中找到下方的 pMethodDesc从而知道要 JIT 哪个方法call PrecodeFixupThunk // This call never returns. It pops the return address // and uses it to fetch the pMethodDesc below to find // what the method that needs to be jitted pop esi // dummy instruction that marks the type of the precode dword pMethodDesc被修补为指向最终目标后jmp target pop edi dword pMethodDesc术语注释对应文档脚注 2把 MethodDesc 放在暂存寄存器中传递的约定有时被称为MethodDesc Calling Convention。10.3 ThisPtrRetBufPrecode —— 切换 this 指针与返回缓冲用于返回值类型的开放实例委托open instance delegate把MyValueType Bar(Foo x)的调用约定转换为MyValueType Foo::Bar()的调用约定即交换 this 指针与返回缓冲return buffer两个寄存器。该 precode 总是按需分配作为真实方法入口点的包装器存在并保存在FuncPtrStubs表中——源码 src/coreclr/vm/fptrstubs.cpp 中的FuncPtrStubs::Lookup/GetFuncPtrStub正是这张表的管理实现。其 x86 形态mov eax,ecx mov ecx,edx mov edx,eax nop jmp entrypoint dw pMethodDesc注意此处nop即类型标记字节且pMethodDesc只占 2 字节dw。10.4 PInvokeImportPrecode —— 非托管 P/Invoke 目标的懒绑定PInvokeImportPrecode 用于非托管 P/Invoke 目标的懒绑定lazy binding其定位是为了便利、减少平台相关的管道代码。每个PInvokeMethodDesc除了常规 precode 外还额外持有一个 PInvokeImportPrecode。x86 上的形态mov eax,pMethodDesc mov eax,eax // dummy instruction that marks the type of the precode jmp PInvokeImportThunk // loads P/Invoke target for pMethodDesc lazily十一、小结一张表看懂 MethodDesc 的设计取舍设计问题取舍依据对象尺寸64 位下普通非泛型 MethodDesc 仅 8 字节不缓存冷数据签名等回查元数据设计文档Performance目标method.hpp 的 ALIGNMENT 定义多态 vs 空间不用虚表用 3 位 kind 分支切换GetClassification()系列判别函数入口点存放MethodTable 槽位虚方法/泛型类型或 MethodDesc 内其余mdcHasNonVtableSlot决定GetSlot()/HasStableEntryPoint()等接口内存布局MethodDescChunk 提升公共 MethodTable 与 token 高位MethodDesc 只存数组索引MethodDescChunkJIT 时机Precode 临时入口点 PreStub 原子替换实现懒 JIT 且 slot 无锁访问第八节状态图调用方式Single/Multi Callable 两套入口点获取 API按调用一次/多次区分method.cpp 中的 CallableAddrOfCode 系列precode 类型识别固定偏移处的标记字节Stub/Fixup/ThisPtrRetBuf/PInvokeImportdatadescriptor.inc需要说明适用前提本文所有源码结论均基于当前仓库中src/coreclr的 C 实现Precode 各专门化类型受平台开关HAS_XXX_PRECODE影响是否全部可用取决于具体平台构建x86/x64 之外ARM 等的 precode 形态与本文示例不同但标记字节判别 原子替换稳定入口点的整体模型一致。原文档出处docs/design/coreclr/botr/method-descriptor.md作者 Jan Kotas2006 年图示为文档内 Figure 1–3。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进