.NET 运行时 CallingConvention 数据契约:从方法签名到 GCRefMap 参数编码的源码级解析
【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime
导读
本文围绕 dotnet/runtime 仓库中诊断数据契约(Data Contract)体系下的CallingConvention契约展开,深入讲解它如何复用运行时自身的调用约定规则来遍历一个方法(MethodDesc)的实参签名,从而在调用者的 Transition Frame 上定位每一个参数的槽位,并判定哪些槽位承载 GC 引用。读者读完本文后将掌握:该契约的单一 APITryComputeArgGCRefMapBlob的语义与返回约定、签名解码与类型信息模型的完整链路、ArgIterator所需类型抽象的逐项适配方式、与运行时GCRefMapBuilder字节级兼容的编码规则,以及cdacstress如何用ARGITER子检查做逐字节正确性验证。文中所有结论均可在当前仓库对应源码中复现。
数据契约背景:为什么需要调用约定信息
诊断数据契约 的定位是:向诊断工具(调试器、分析器、profilers)描述 .NET 运行时进程内存中一部分内部数据结构的物理形态与语义,使工具不必依赖与目标运行时版本严格匹配的 DAC/DBI 库,即可直接读取并解释进程内存来获知运行时状态。契约是运行时对诊断工具的一种承诺:只要按契约解释内存,就能计算出有用的运行时状态信息。
对于调试器这类工具而言,一个高频需求是:给定一个正在执行的调用(callsite),知道调用者帧(caller's transition frame)上每个实参位于何处,以及其中哪些是 GC 引用(对象引用、内部指针等)。这正是CallingConvention契约的职责。它要回答的问题形如:"这个方法的第 3 个参数是一个Span<T>,它的 byref 字段被压到了哪个槽位,槽位内容是 GC 引用还是内部指针"。没有这类信息,调试器无法在断点处正确枚举栈上实参、无法报告 GC 根,也就无法可靠地进行现场分析。
契约职责边界:ABI 定义与契约的分工
CallingConvention契约并不重新描述 ABI 本身。真正定义"哪些寄存器承载哪些参数、应用何种对齐与填充规则、结构体如何提升到寄存器或压栈、varargs 如何传递"等细节的,是 CLR ABI 规范,即仓库中的 Common CLR ABI conventions。该文档按 x64(AMD64)、ARM、ARM64、x86 等架构描述了 CLR 对原生 ABI 的约定与例外,例如:
this指针被当作一种特殊参数,始终作为第一个参数传递(AMD64 的RCX,ARM/ARM64 的R0);- 托管 varargs 在可选返回缓冲区和
this指针之后、其他用户参数之前插入"vararg cookie",callee 必须把 cookie 及后续参数溢出到其 home 位置,因为实参可能以 cookie 为基址做指针运算访问; - 非 x86 平台上所有方法都必须有 unwind 信息,以便 GC 能够展开栈。
CallingConvention契约的边界在于:它不关心 ABI 规则本身"为什么"这样设计,而是把 ABI 规则应用后的结果(每个参数的寄存器/栈位置、GC 分类)以 cDAC 可消费的形式暴露出来,并且要求与运行时自身产生的结果逐字节兼容。一句话概括职责分工:ABI 规范定义规则,契约暴露规则的结果。
契约 API:TryComputeArgGCRefMapBlob
契约只暴露一个 API:
// Encode the argument GCRefMap blob for `methodDesc` byte-for-byte // compatible with the runtime's ComputeCallRefMap (frames.cpp). // Returns false when this contract declines to encode the method // (e.g. an unported ABI path); callers should map false to E_NOTIMPL. // When false, the value of `blob` is unspecified. bool TryComputeArgGCRefMapBlob(MethodDescHandle methodDesc, out byte[] blob);语义要点:
- 输入是
MethodDescHandle(见 RuntimeTypeSystem 契约,MethodDesc是托管方法在运行时的表示,无论其来源是 IL、Reflection.Emit 还是运行时动态生成); - 输出是 GCRefMap blob:一段将"帧上哪些槽位是 GC 引用"压缩编码的字节流,要求与运行时
ComputeCallRefMap(frames.cpp)产生的输出逐字节一致; - 返回
false表示该契约"拒绝"编码此方法(例如某个尚未移植的 ABI 路径,或签名/泛型上下文未被编码器覆盖),此时blob值未定义;调用方应将false映射为 COM 风格的E_NOTIMPL。
在 cDAC 托管实现中,CallingConvention_1.cs 的TryComputeArgGCRefMapBlob正是这样实现的:核心逻辑ComputeArgGCRefMapBlobCore返回null时清空blob并返回false;任何NotImplementedException(包括来自GetArgumentLayout的未移植 ABI 路径 NIE)同样被捕获并干净地降级为false,从而保证"未支持路径绝不产生错误数据"。
版本 1 概览与依赖
契约文档给出了版本 1(c1)的依赖清单,其中:
- Data descriptors used:无(契约不直接声明任何数据结构布局);
- Global variables used:无;
- Contracts used:
| Contract Name |
|---|
EcmaMetadata |
Loader |
RuntimeInfo |
RuntimeTypeSystem |
这四份依赖的职责分工与实现路径一一对应(详见后文):
- RuntimeTypeSystem:提供
MethodDescHandle的类型信息——GetMethodTable、TryGetMethodSignature、GetGenericContextLoc、IsAsyncMethod、GetBaseSize、TryGetHFAElementSize、TryGetSystemVAmd64EightByteClassification、IsByRefLike、ContainsGCPointers、GetGCDescSeries等; - EcmaMetadata:为模块提供
MetadataReader,用于读取 IL 签名与字段签名; - Loader:
GetModuleLookupMapElement配合TypeDefToMethodTable/TypeRefToMethodTable两种 lookup-map 种类,将类型 token 解析为目标类型句柄; - RuntimeInfo:提供目标架构与操作系统,用于构造
TransitionBlock。
这一"零数据描述符 + 纯算法"的形态符合数据契约设计的推荐风格:契约算法优先用 C# 风格的伪代码在Target抽象之上工作(见 contract_csharp_api_design.cs),尽量把复杂数据结构访问收敛到其他契约。
签名解码链路
契约处理的第一个阶段是"读懂方法的签名"。从源码实现CallingConvention_1.DecodeMethodSignature(CallingConvention_1.cs)可以看到完整调用链:
- 通过
RuntimeTypeSystem.GetMethodTable(methodDesc)拿到MethodDesc所属的方法表(owning method table),再经GetTypeHandle得到类型句柄; - 用
RuntimeTypeSystem.GetModule得到所属模块,并通过Loader.GetModuleHandleFromModulePtr转成ModuleHandle; - 通过
EcmaMetadata.GetMetadata(moduleHandle)取得模块的MetadataReader; - 通过
RuntimeTypeSystem.TryGetMethodSignature(methodDesc, out ReadOnlySpan<byte> signature)取得原始签名字节; - 用
System.Reflection.Metadata体系的解码器对签名进行解码。契约文档指出,概念上这与SignatureDecoder<SignatureTypeInfo, SignatureTypeContext>的模型一致;实际实现RuntimeSignatureDecoder<SignatureTypeInfo, SignatureTypeContext>额外理解运行时内部的签名元素类型(如Internal、CModInternal等运行时专用 CorElementType,定义于 RuntimeTypeSystem 契约 的CorElementType枚举),其余遵循 SRM 的解码模型。
类型提供者(type provider)的初始化包含三方面上下文:
- 所属模块:用于把
TypeDef/TypeReftoken 解析成目标类型——通过Loader.GetModuleLookupMapElement和TypeDefToMethodTable、TypeRefToMethodTable两种 lookup-map 种类(对应 Loader 契约 中的Module描述符字段TypeDefToMethodTableMap/TypeRefToMethodTableMap); - 方法的
MethodDescHandle:用于解析方法泛型参数(!!T); - 所属类型的结构类型信息:用于解析类型泛型参数(
!T)。
在 SignatureTypeInfoProvider.cs 中可以看到,GetTypeFromDefinition/GetTypeFromReference把 token 换成ModuleLookupMapKind.TypeDefToMethodTable/TypeRefToMethodTable后调用GetModuleLookupMapElement;GetGenericMethodParameter通过RuntimeTypeSystem.GetGenericMethodInstantiation(method)[index]取精确类型;GetGenericTypeParameter则优先从SignatureTypeContext.OwningType的结构化类型实参中取,其次从精确类型的GetInstantiation取。
关键设计:查表失败也不丢信息。对TypeDef或TypeRef而言,lookup map 中可能还没有目标类型句柄(类型尚未加载)。此时提供者必须保留签名中编码的是ELEMENT_TYPE_CLASS还是ELEMENT_TYPE_VALUETYPE。这个区分足以对"引用型实参"做分类,而无需强制加载类型;反过来,一个拿不到精确类型句柄的值类型,其布局是不确定的(indeterminate),不能送入需要其大小或字段布局的 ABI 路径。对应实现见 SignatureTypeInfoProvider.GetTypeFromToken:rawTypeKind为SignatureTypeKind.Class时记CorElementType.Class,为ValueType时记CorElementType.ValueType,仅当两者都不是且句柄可用时才回退到精确类型的分类。
签名类型信息模型:SignatureTypeInfo
每个解码出的类型被表示为一个结构化记录SignatureTypeInfo(SignatureTypeInfo.cs),包含四类信息:
| 信息 | 来源 | 用途 |
|---|---|---|
| 外层元素类型(Outer element type) | 签名元素类型 | 即使目标类型未完全加载,也能对基元、引用、指针、byref 和值类型分类 |
| 精确类型句柄(Exact type handle,可用时) | 模块 lookup map、泛型实例化或运行时内部签名元素 | 提供目标内存支撑的运行时布局与分类 |
| 泛型类型定义(Generic type definition,可用时) | 解码出的泛型类型 | 当精确构造类型句柄不可用时保留其定义 |
| 泛型实参(Generic arguments) | 递归签名解码 | 为嵌套值类型字段解码提供结构化的泛型上下文 |
其中"精确ITypeHandle"代表目标内存支撑的MethodTable*或TypeDesc*(RuntimeTypeSystem契约中TargetTypeHandle与TypeHandleBits的区分可参见 RuntimeTypeSystem.md)。它在这里是故意可选的:签名可以在运行时尚未为某个类型产出精确句柄之前就描述它——这正是"未加载类型也能参与调用约定计算"的核心机制。
嵌套字段的泛型替换:当遍历需要某个字段的类型时,契约从外围类型的模块读取该字段的元数据签名,并以包含该类型(owning type)的结构化信息作为泛型上下文进行解码(GetFieldTypeInfo,CallingConvention_1.cs)。这样即使不存在精确构造的方法表,嵌套字段中的泛型参数也能被替换。典型例子:Span<int>的封闭方法表(closed MT)尚未加载时,其布局可以取自开放泛型Span<T>,字段层面的 byref/ptr 区分与 T 的具体取值无关。
ArgIterator 需要的类型抽象:逐项适配
ArgIterator(共享源码 ArgIterator.cs,位于src/coreclr/tools/Common/CallingConvention/,同时被 crossgen2 与 cDAC 以文件链接方式共享)消费一个很小的类型抽象ITypeHandle(ITypeHandle.cs)来应用目标 ABI。CallingConvention契约将解码出的类型信息与目标契约适配为该接口(cDAC 侧的适配器是CdacTypeHandle,CdacTypeHandle.cs)。契约文档给出了完整的适配表:
| ArgIterator 操作 | 数据来源 | 精确布局不可用时的行为 |
|---|---|---|
IsNull | 既无签名元素类型也无精确类型句柄 | 报告"无类型" |
GetCorElementType | 签名元素类型;仅当需要把枚举值类型归一化到底层基元时使用精确句柄 | 尽可能使用结构化元素类型 |
IsValueType/IsPointerType | 签名元素类型;精确句柄作为回退 | 使用结构化分类 |
PointerSize | Target.PointerSize | 始终可用 |
GetSize/HasIndeterminateSize | 对精确值类型使用RuntimeTypeSystem.GetBaseSize | 未解析的值类型大小不确定;依赖大小的 ABI 路径被拒绝 |
RequiresAlign8 | RuntimeTypeSystem.RequiresAlign8 | 无精确句柄时返回 false |
IsHomogeneousAggregate/GetHomogeneousAggregateElementSize | RuntimeTypeSystem.TryGetHFAElementSize | 无精确句柄时返回 false |
GetSystemVAmd64PassStructInRegisterDescriptor | RuntimeTypeSystem.TryGetSystemVAmd64EightByteClassification | 无精确句柄时报告"结构体不按寄存器分类" |
IsTrivialPointerSizedStruct | 精确值类型大小 + 其实例FieldDesc列表与字段签名 | 除非精确布局证明了 x86 特例,否则返回 false |
GetFpStructInRegistersInfo | 目标相关的 RISC-V / LoongArch64 ABI 分类 | 尚未实现;契约拒绝该 ABI 路径 |
GetFieldAlignment | 目标相关的 LoongArch64 / WASM 布局 | 尚未实现;契约拒绝该 ABI 路径 |
下面逐项给出源码侧印证:
GetSize(CdacTypeHandle.GetSize):引用/指针/byref/数组/类等直接返回PointerSize;否则要求精确句柄存在(否则抛NotImplementedException,即"大小不确定 → 拒绝"),并把GetBaseSize减去对象头与 MethodTable 指针(2 * PointerSize)换算成非装箱(unboxed)布局大小——这与运行时MethodTable::BaseSize含对象头/对齐填充的语义一致;GetCorElementType(CdacTypeHandle.GetCorElementType):对值类型会镜像MetaSig::PeekArgNormalized——通过GetInternalCorElementType把枚举折叠为其底层基元(如 byte 枚举 →U1)。注释明确说明:共享ArgIterator的 x86IsArgumentInRegister依赖这一归一化,否则子指针大小的枚举会被错误地当作栈传参数;IsTrivialPointerSizedStruct(CdacTypeHandle.IsTrivialPointerSizedStruct):仅 x86 有意义,要求恰好一个实例字段且该字段为指针大小的基元(I/U/I4/U4/Ptr/FnPtr)或递归地另一个 trivial 结构体;该行为与 crossgen2 侧ILCompiler.ReadyToRun的ITypeHandle.IsTrivialPointerSizedStruct对应;- HFA/SystemV/对齐:分别映射到
RuntimeTypeSystem.TryGetHFAElementSize、TryGetSystemVAmd64EightByteClassification、GetClassAlignmentRequirement。其中 SystemV 的 eight-byte 分类在运行时中存放于EEClassOptionalFields.EightByteRegistersInfo(仅在UNIX_AMD64_ABI构建上填充,见 RuntimeTypeSystem.md 的EEClassOptionalFields描述符),分类计数为 0 或大于 2 时视为无分类。
完成每个参数与返回类型的适配器构造后,契约用目标TransitionBlock、方法调用约定(ManagedInstance/ManagedStatic)、实例/varargs 状态、泛型上下文实参状态初始化ArgIterator,然后遍历产生的参数偏移,把位置与 GC 分类编码进 GCRefMap blob。TransitionBlock的体系(含StructInRegsOffset = -2、OffsetOfFirstGCRefMapSlot等架构差异)见 TransitionBlock.cs。
GCRefMap 编码:与运行时字节级兼容
GCRefMap是运行时为每个调用点(callsite)编码"GC 摘要"的格式。原生侧的权威定义在 gcrefmap.h,契约的编码器 GCRefMapEncoder 逐条镜像了它的编码规则:
- 流边界与高位置 1 终止:编码总是从字节边界开始;每个字节的最高位用于表示编码流的结束,因此每字节只有 7 个有效位;
- 位置增量编码:
pos(槽位位置)总是编码为相对前一个位置的增量(delta); - 两比特基本单元:值 0/1/2 分别表示常见的三种构造——跳过单个槽位(SKIP)、GC 引用(REF)、内部指针(INTERIOR);值 3 表示后面跟扩展编码;
- 扩展整数编码:以 4 比特块为单位(3 个数据位 + 1 个续位,见
AppendInt),续位用于标记块的结束; - x86 前缀:x86 上编码以"callee 弹出栈大小"开始,用同一套两比特机制编码(
WriteStackPop,CbStackPop来自ArgIterator,varargs 时 x86 由调用方清理栈,故为 0)。
令牌定义与运行时一致(ArgIterator.cs):
| 令牌 | 值 | 含义 |
|---|---|---|
GCREFMAP_SKIP | 0 | 跳过单个槽位 |
GCREFMAP_REF | 1 | 槽位是 GC 引用 |
GCREFMAP_INTERIOR | 2 | 槽位是内部指针(byref / 托管指针) |
GCREFMAP_METHOD_PARAM | 3 | 方法泛型上下文实参(InstArgMethodDesc) |
GCREFMAP_TYPE_PARAM | 4 | 类型泛型上下文实参(InstArgMethodTable) |
GCREFMAP_VASIG_COOKIE | 5 | varargs 的 VASigCookie 槽位 |
cDAC 编码器的工作方式(ComputeArgGCRefMapBlobCore):
- 用
SortedDictionary<int, GCRefMapToken>累积(偏移, 令牌); - 遍历
GetArgumentLayout产出的每个实参位置,按类型分类打令牌(见下一节); - 若无任何 GC 相关实参,非 x86 直接返回空 blob;x86 仍先写
WriteStackPop前缀; - 用
GCRefMapPosFromOffset把偏移换算成位置序号(x86 上寄存器区与栈区的 pos 顺序与偏移顺序相反,故必须按 pos 顺序写出),逐个WriteToken,超过MaxGCRefMapBlobLength = 252即保守拒绝; Flush写出结尾字节——镜像原生Flush中"挂起字节低 7 位非零或 pos 为 0 才写出"的规则。
特殊参数与边界情况的令牌规则
GetArgumentLayout(CallingConvention_1.cs)与令牌编码共同处理了若干运行时特有的"隐藏参数":
this指针:hasThis时先登记ThisOffset。值类型this(且非 unboxing stub)编码为Interior,类类型this编码为Ref(与ArgDestination.GcMark的语义一致);- 泛型上下文实参(param type arg):共享泛型方法需要一个隐藏实例化实参。
GetGenericContextLoc返回InstArgMethodDesc时打MethodParam令牌,InstArgMethodTable时打TypeParam令牌,否则跳过。这对应运行时 frames.cpp 中"非 dispatch cell 时若RequiresInstArg()则SetHasParamTypeArg()"的逻辑; - async continuation:异步方法有一个始终在调用方侧传递的 continuation 实参(与实例化实参不同,它是调用约定的一部分而不仅是共享泛型实现细节)。cDAC 用
RuntimeTypeSystem.IsAsyncMethod探测并登记其偏移,元素类型记为Object; - varargs:镜像运行时
FakeGcScanRoots的短路行为——只登记 VASigCookie 槽位(GetVASigCookieOffset)并停止遍历,可变尾部实参在 GC 扫描时由 cookie 指向的签名报告,而非由本契约报告;x86 下CbStackPop为 0(调用方清理); - SystemV-AMD64 寄存器传结构体:
GetNextOffset返回StructInRegsOffset特例时,读取 eight-byte 分类描述符与ArgLocDesc,逐 eight-byte 判断:IntegerReference→Ref,IntegerByRef→Interior,SSE 分类(走 XMM 寄存器)不前进通用寄存器偏移——镜像 ArgDestination.ReportPointersFromStructInRegisters; - ByRefLike 值类型(
Span<T>等 ref struct):若结构体含托管指针字段,则沿GetFieldDescList遍历实例字段,对每个ELEMENT_TYPE_BYREF字段在结构体内偏移处发Interior令牌,并递归进入嵌套的 ByRefLike 值类型字段(深度上限MaxByRefLikeRecursionDepth = 16,防环)。镜像的是运行时ByRefPointerOffsetsReporter(siginfo.cpp);而ELEMENT_TYPE_PTR/IntPtr/void*字段明确不报告(对应 QCall 类句柄包装器不计 GC); - 含 GC 指针的传值结构体:若精确类型句柄可用且
ContainsGCPointers为真,则用GetGCDescSeries的(Offset, Size)序列,把每个指针槽打Ref令牌;偏移以装箱对象起点为参照,需减去pointerSize换算到非装箱帧内布局——镜像ReportPointersFromValueTypeArg(siginfo.cpp)。
正确性验证:cdacstress 的 ARGITER 子检查
契约正确性的"金标准"是cdacstress压测设施中的ARGITER子检查(cdacstress.cpp)。其机制为:
CDACSTRESS_ARGITER = 0x00000200(比较CallingConvention的实参枚举结果与运行时ComputeCallRefMap,见 cdacstress.cpp);- 对活动线程 transition Frame 上的每一个
MethodDesc,先由运行时自身通过ComputeCallRefMap(pMD, &builder, /*isDispatchCell*/ false)产出权威 blob(ComputeRuntimeArgGCRefMap处理了"签名无法分类返回 -1""blob 超过缓冲区返回 -2"等边界,并显式声明CONTRACT_VIOLATION(ModeViolation | GCViolation)以适配在分配器钩子中触发的 GC 模式差异); - 再请求 cDAC 通过
TryComputeArgGCRefMapBlob产出同一 blob,两者做逐字节比较,不匹配即打印运行时与 crossgen2/cDAC 双方的 blob 十六进制转储并触发断言。
因此,"字节级兼容"不是一句口号,而是有持续运行的压测作为判定依据。任何对GCRefMapEncoder位编码、WriteStackPop前缀、x86 pos 顺序或某类参数令牌规则的偏离,都会被该子检查捕获。
与运行时实现的整体对照
作为收尾,把契约实现与运行时原生侧做一次整体对照,可以看到每一处契约逻辑都能在运行时找到镜像:
| 契约侧(cDAC) | 运行时侧(CoreCLR) |
|---|---|
GetArgumentLayout+ArgIterator<CdacTypeHandle>遍历 | ComputeCallRefMap中的MetaSig+ArgIterator(frames.cpp) |
GCRefMapEncoder(CallingConvention_1.cs) | GCRefMapBuilder(gcrefmap.h) |
| ByRefLike 字段 Interior 发射 | ByRefPointerOffsetsReporter(siginfo.cpp) |
| 传值结构体 GCDesc 序列 Ref 发射 | ReportPointersFromValueTypeArg(siginfo.cpp) |
| SystemV 寄存器结构体 eight-byte 令牌 | ArgDestination::ReportPointersFromStructInRegisters |
枚举归一化(GetInternalCorElementType) | MetaSig::PeekArgNormalized |
| 共享泛型实例化实参令牌 | SetHasParamTypeArg(frames.cpp)与GCREFMAP_METHOD_PARAM/TYPE_PARAM |
| varargs VASigCookie 短路 | FakeGcScanRoots的 varargs 分支(frames.cpp) |
这一"共享同一套ArgIterator+ 各自镜像同一份编码规则"的架构,正是该契约能以较低维护成本保持与运行时字节级一致的关键:ABI 的繁复规则集中在一处共享代码,cDAC 侧只需把"签名 → 类型信息 → ITypeHandle 适配"做对即可。
延伸阅读
- 数据契约总览与规范
- RuntimeTypeSystem 契约(
ITypeHandle、MethodDescHandle、类型布局查询 API) - Loader 契约(
GetModuleLookupMapElement与 lookup-map 种类) - EcmaMetadata 契约(
MetadataReader获取) - CLR ABI 规范(本文不重述的 ABI 细节)
- 共享
ArgIterator:src/coreclr/tools/Common/CallingConvention/ArgIterator.cs - 原生 GCRefMap 编解码:src/coreclr/inc/gcrefmap.h
- 运行时权威实现:src/coreclr/vm/frames.cpp
- cDAC 契约实现:src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/CallingConvention/
- 逐字节验证设施:src/coreclr/vm/cdacstress.cpp
【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考