ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入解析 .NET CoreCLR 中 System.Private.CoreLib 与 CLR 的互操作机制:FCall、QCall 与托管/非托管双重类型系统

深入解析 .NET CoreCLR 中 System.Private.CoreLib 与 CLR 的互操作机制:FCall、QCall 与托管/非托管双重类型系统 深入解析 .NET CoreCLR 中 System.Private.CoreLib 与 CLR 的互操作机制FCall、QCall 与托管/非托管双重类型系统【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime导读本文基于 .NET 运行时仓库dotnet/runtime中 corelib.md 的技术文档系统讲解System.Private.CoreLib.dll下文简称 CoreLib为何在 .NET 类型系统中如此特殊以及它是如何通过 FCall 与 QCall 两条桥梁与 CoreCLR 原生代码VM互相调用的。你将掌握CoreLib 与 CLR 的强耦合关系、FCall/QCall 的取舍原则与完整代码范式、方法注册表机制ecalllist.h/qcallentrypoints.cpp、托管/非托管双重类型定义binder 与corelib.h以及 GC hole 的产生原因与规避方法。文章中的每一处代码与结论均可回到仓库源码中验证。一、CoreLib 是什么类型系统的基石System.Private.CoreLib.dll是定义 .NET 类型系统核心部分以及 .NET Framework Base Class LibraryBCL重要组成部分的程序集。它在 .NET Core 时代由mscorlib更名而来因此代码与文档中大量位置仍沿用mscorlib这一称呼例如内部 binder 源码 binder.cpp 所在目录的命名。基础数据类型Object、Int32、String等全部生活在这个程序集中并且它与 CLR 之间存在极其紧密的耦合。1.1 依赖方向CoreLib 不能依赖任何其他托管程序集由于 CoreLib 定义了Object、Int32、String等基础类型它不能依赖任何其他托管程序集。但它与 CLR 之间存在强依赖大量 CoreLib 类型需要被原生代码访问因此很多托管类型的内存布局既要定义在托管代码中也要定义在 CLR 的原生代码中某些字段只在 Debug、Checked 或 Release 构建中出现条件编译因此 CoreLib 通常需要为每种构建类型分别编译。此外System.Private.CoreLib.dll会针对 64 位和 32 位分别构建其暴露的部分公共常量随位宽不同而不同。通过使用这些常量如IntPtr.SizeCoreLib 之上的绝大多数库就不需要再针对 32/64 位分别构建。1.2 CoreLib 的特殊性清单文档明确列出 CoreLib 的几个独有属性多数源于它与 CLR 的紧耦合定义 VOSVirtual Object System核心类型CoreLib 定义实现 CLR 虚拟对象系统所必需的核心类型即基础数据类型Object、Int32、String等。CLR 启动时必须加载CLR 在启动时为了加载某些系统类型必须先加载 CoreLib。进程内同时只能存在一个 CoreLib由于布局问题加载多个 CoreLib 将要求把 CLR 与 CoreLib 之间的行为约定、FCall 方法约定、数据类型布局约定正式契约化并且跨版本保持该契约相对稳定——这是当前架构下不现实的要求。强原生互操作需求CoreLib 的类型大量用于原生互操作托管异常应能正确映射为原生错误码/格式。JIT 特例优化CLR 的多个 JIT 编译器可能针对 CoreLib 中一小部分方法做特例处理既包括把方法优化掉如Math.Cos(double)也包括以特殊方式调用方法如Array.Length以及StringBuilder获取当前线程的部分实现细节。需要 P/Invoke 调用原生代码CoreLib 需要以 P/Invoke 方式调用原生代码主要进入底层操作系统偶尔进入平台适配层。需要调用 CLR 以暴露运行时专属能力例如触发垃圾回收、加载类、以非平凡方式与类型系统交互这要求托管代码与 CLR 中手动托管的原生代码之间有一座桥。CLR 也需要调用回托管代码调用托管方法、获取仅以托管方式实现的功能。从 appdomain.cpp 的源码可以看到CLR 启动时由SystemDomain::LoadBaseSystemClasses()打开系统程序集、挂接 CoreLib binderCoreLibBinder::AttachModule随后加载Object、ValueType、Enum、__Canon等基础类这正是 CoreLib 特殊地位的物理体现。二、托管代码与 CLR 代码之间的接口把上面的需求收敛一下托管代码CoreLib与 CLR 之间的接口需要满足三个基本能力托管代码与 CLR 内手动托管代码都能访问某些托管数据结构的字段托管代码能够调用 CLRCLR 能够调用托管代码。为此需要三样东西一种让 CLR 在原生代码中指定并可选校验托管对象布局的机制一套托管调原生的机制且必须支持String构造函数使用的特殊托管调用约定——由构造函数自己分配对象内存而不是常规约定中由 GC 先分配内存一套原生调托管的机制。2.1 mscorlib binder布局与方法的双向往来CLR 内部提供一个mscorlibbinder源码见 binder.cpp负责在非托管类型/字段与托管类型/字段之间建立映射。binder 会查找并加载类、允许调用托管方法还会执行简单校验确保托管代码与原生代码中声明的布局信息一致。具体来说binder 保证试图加载的托管类确实存在于 mscorlib 中该类已被加载字段偏移量正确能区分签名不同的方法重载。三、从托管到原生FCall 与 QCall 两大调用技术托管代码调用 CLR 有两种技术维度FCallInternalCallQCall托管侧标识extern方法 [MethodImpl(MethodImplOptions.InternalCall)]static extern方法类似普通 P/Invoke但指向名为QCall的库参数处理可传递裸对象引用灵活但危险像 P/Invoke 一样把所有参数按非托管类型封送GC 模式协作式cooperative切换为抢占式preemptive与普通 P/Invoke 一致GC hole / GC 饥饿风险高容易犯错低基本免疫适用场景必须优化的短路径几百条指令内默认首选机制3.1 如何选择FCall、QCall、P/Invoke 还是纯托管文档给出清晰的决策原则核心是能写托管就别写原生尽量写托管代码避开一箩筐 GC hole 问题、获得更好的调试体验、代码通常更简单。过去写 FCall 的三个理由已基本消失过去写 FCall 的理由通常是语言特性缺失性能更好实现与运行时的独特交互。如今 C# 已具备几乎全部能想到的语言特性包括 unsafe 代码和栈上分配缓冲区前两个理由已不成立。仓库实际上已将 Reflection、部分 Encoding、部分 String 操作等重度依赖 FCall 的 CLR 部分移植回托管代码。只为调用原生方法就不要用 FCall直接用 P/Invoke公共原生方法接口它应该能正确完成你的一切需求。能降低切换频率就降低能否把常见路径写在托管中、只在罕见角落用例才调用原生QCall 是前进方向FCall 只在被迫时使用被迫使用 FCall 的条件是——代码中有一条值得优化的常见短路径该短路径不超过几百条指令、不能分配 GC 内存、不能加锁、不能抛异常对应GC_NOTRIGGER、NOTHROWS约束。其他一切情况请用 QCall。FCall 的性能诉求可迁移到 QCall若确实需要 FCall 的性能可以创建 QCall 并标记 SuppressGCTransitionAttribute。QCall 还能白得 SafeHandle 封送QCall 的原生方法直接接收HANDLE类型无需担心方法体内句柄被他人释放而等价 FCall 需要自己使用SafeHandleHolder并保护SafeHandle借助 P/Invoke 封送器可以省掉这些额外管线代码。3.2 QCall 的功能行为QCall 非常像 CoreLib 到 CLR 的普通 P/Invoke源码中的权威描述见 qcall.h所有参数像普通 P/Invoke 一样按非托管类型封送像普通 P/Invoke 一样切换到抢占式 GC 模式不易出现 FCall 常见的 GC hole 与 GC 饥饿 bug。参数类型偏好首选能被 P/Invoke 封送器高效处理的原始类型——INT32、LPCWSTR、BOOL。注意布尔口味QCall 参数用BOOL而 FCall 参数用CLR_BOOL。句柄包装指向常见 EEExecution Engine结构的指针应包装进句柄类型让托管实现类型安全、避免到处写 unsafe C#例如 qcall.h 中的AssemblyHandle。对象引用的传递QCall 传入/传出对象引用需要把指向局部变量的指针包进句柄这是故意设计得繁琐的应尽可能避免。唯一被广泛接受传原始对象的常见模式是从 QCall 返回对象尤其是字符串例如下面的StringHandleOnStack。函数签名风格QCall 应实现为 C 风格函数签名便于未来 AOT 工具把托管侧 QCall 连接到原生侧实现。错误处理约定所有使用BEGIN_QCALL的 QCall 必须使用隐藏的最后一个参数错误处理器QCallExceptionStatus*并在托管侧用[ErrorHandler(typeof(QCallExceptionStatusMarshaller), ErrorLocation.HiddenLastParameter)]声明若 QCall 在进入BEGIN_QCALL之前返回包括 no-op 快速路径和平台条件排除的代码必须显式先置*qcallError 0。3.3 QCall 完整示例托管侧String.CoreCLR.cs 中String.IsInterned的真实 QCall 就是这一模式其中RuntimeHelpers.QCall即QCall库partial class Foo { // QCalls that use BEGIN_QCALL must use the hidden last parameter error handler. [ErrorHandler(typeof(QCallExceptionStatusMarshaller), ErrorLocation.HiddenLastParameter)] [LibraryImport(RuntimeHelpers.QCall, EntryPoint Foo_BarInternal, StringMarshalling StringMarshalling.Utf16)] [return: MarshalAs(UnmanagedType.Bool)] private static partial bool BarInternal(int flags, string inString, StringHandleOnStack retString); // Many QCalls have a thin managed wrapper around them to perform // as much work prior to the transition as possible. An example would be // argument validation which is easier in managed than native code. public string Bar(int flags, string inString) { if (flags ! 0) throw new ArgumentException(Invalid flags); string? retString null; // The strings are returned from QCalls by taking address // of a local variable using StringHandleOnStack. if (!BarInternal(flags, inString, new StringHandleOnStack(ref retString))) FatalError(); return retString!; } }原生侧——注意 QCall 入口必须先在 qcallentrypoints.cpp 中用DllImportEntry宏注册// All QCalls should be free functions and tagged with QCALLTYPE and extern C extern C BOOL QCALLTYPE Foo_BarInternal( int flags, LPCWSTR wszString, QCall::StringHandleOnStack retString, QCallExceptionStatus* qcallError) { // All QCalls should have QCALL_CONTRACT. // It is alias for THROWS; GC_TRIGGERS; MODE_PREEMPTIVE. QCALL_CONTRACT; // Optionally, use QCALL_CHECK instead and the expanded form of the contract // if you want to specify preconditions: // CONTRACTL { // QCALL_CHECK; // PRECONDITION(wszString ! NULL); // } CONTRACTL_END; // The only line between QCALL_CONTRACT and BEGIN_QCALL // should be the return value declaration if there is one. BOOL retVal FALSE; // The body has to be enclosed in BEGIN_QCALL/END_QCALL macro. // It initializes qcallError and captures exceptions for the managed error handler. BEGIN_QCALL; // Argument validation would ideally be in managed, but in some cases // needs to be done in native. If argument validation is done in // managed asserting in native is warranted. _ASSERTE(flags ! 0); // No need to worry about GC moving strings passed into QCall. // Marshalling pins them for us. printf(%S\n, wszString); // This is the most efficient way to return strings back // to managed code. No need to use StringBuilder. retString.Set(LHello); // You can not return from inside of BEGIN_QCALL/END_QCALL. // The return value has to be passed out in helper variable. retVal TRUE; END_QCALL; return retVal; }从 qcall.h 的宏定义可以看到底层语义BEGIN_QCALL先清零*qcallError再安装catch 后恢复处理器与托管异常捕获调度器即把原生抛出的异常捕获下来转交给托管侧 ErrorHandlerEND_QCALL卸载上述两个设施QCALL_CONTRACT等价于THROWS; GC_TRIGGERS; MODE_PREEMPTIVE;即允许抛异常、允许触发 GC、处于抢占式模式另有QCALL_CONTRACT_NO_GC_TRANSITION变体NOTHROW; GC_NOTRIGGER; MODE_COOPERATIVE;用于不需要 GC 切换的场景。3.4 FCall 的功能行为FCall 在传递对象引用方面更灵活但代码复杂度更高、犯错机会更多。对于任何长度非平凡的 FCall必须显式轮询是否发生了需要执行的 GC——因为 FCall 执行期间线程只允许 GC 以协作方式运行如果托管代码在紧循环中反复调用 FCall 而 FCall 不主动轮询就会导致 GC 饥饿。FCall 需要大量样板代码完整细节请查阅 fcall.h。3.5 GC hole、FCall 与 QCallGC hole 的更完整讨论见 CLR Code Guide 中 Is your code GC-safe? 一节。此处聚焦于为何 FCall/QCall 有那些奇怪约定FCall 参数中的对象引用不受 GC 保护如果 GC 发生这些引用仍指向对象在内存中的旧位置。因此 FCall 通常遵循这样的纪律——参数类型接收类似StringObject*的裸指针在可能触发 GC 的操作之前显式转换为STRINGREF。如果你之后还要使用某个对象引用必须在触发 GC 前对它做 GC 保护。GC hole 的定义与检测未能正确上报OBJECTREF、或未能更新内部指针interior pointer通常被称为GC hole。Debug 与 Checked 构建中OBJECTREF类每次解引用都会校验其指向合法对象指向非法对象时解引用会触发断言报错类似 Detected an invalid object reference. Possible GC hole?。QCall 如何规避 GC holeQCall 的编程模型刻意受限——强制你传入对象引用在栈上的地址。这保证该对象引用被 JIT 的报点逻辑 GC 保护且真正的对象引用不会移动因为它不在 GC 堆里。这正是 QCall 被推荐的原因让 GC hole 更难写出来。3.6 x86 上的 FCall epilog walker托管栈walker需要能从 FCall 中走出来。在较新平台上ABI 定义了栈展开约定这很容易但x86 没有 ABI 定义栈展开约定。运行时的对策是实现一个epilog walker通过模拟 FCall 的执行计算出 FCall 的返回地址与 callee-save 寄存器。这给 FCall 实现强加了限制栈上分配对象且带析构函数、或在 FCall 实现中使用异常处理等复杂构造可能让 epilog walker 困惑这会导致栈行走期间的 GC hole 或崩溃没有一份详尽的应避免构造清单——今天没问题的 FCall 实现可能在下一次 C 编译器升级后出问题该领域依赖 stress 测试与代码覆盖率来发现 bug。3.7 FCall 完整示例托管侧String.IsInternedpublic partial sealed class String { [MethodImpl(MethodImplOptions.InternalCall)] private extern string? IsInterned(); public static string? IsInterned(string str) { if (str null) { throw new ArgumentNullException(nameof(str)); } return str.IsInterned(); } }原生侧——FCall 入口必须先注册到 ecalllist.h。下面的例子展示了一个接收托管对象Object*作为裸指针的 FCall这些裸输入被视为不安全若用于 GC 敏感上下文必须先校验或转换FCIMPL1(FC_BOOL_RET, ExceptionNative::IsImmutableAgileException, Object* pExceptionUNSAFE) { FCALL_CONTRACT; ASSERT(pExceptionUNSAFE ! NULL); OBJECTREF pException (OBJECTREF) pExceptionUNSAFE; FC_RETURN_BOOL(CLRException::IsPreallocatedExceptionObject(pException)); } FCIMPLEND3.8 注册你的 QCall 或 FCall 方法CLR 必须同时知道托管类与方法名以及要调用的原生方法。二者注册位置不同。FCall 注册在 ecalllist.h使用两张数组第一张数组把命名空间类名映射到函数元素数组该函数元素数组再把方法名与签名映射到函数指针。为上面的String.IsInterned()FCall 注册第一步确保 String 类有函数元素数组注意必须按 name:namespace 对保持排序// Note these have to remain sorted by name:namespace pair ... FCClassElement(String, System, gStringFuncs) ...第二步确保gStringFuncs里有IsInterned的条目方法名有多个重载时可指定签名FCFuncStart(gStringFuncs) ... FCFuncElement(IsInterned, AppDomainNative::IsStringInterned) ... FCFuncEnd()在 ecalllist.h 与 ecalllist.h 中可以确认当前仓库正是以FCFuncStart(gStringFuncs)FCClassElement(String, System, gStringFuncs)的方式组织注册表。QCall 注册在 qcallentrypoints.cpp 的s_QCall数组中用DllImportEntry宏static const Entry s_QCall[] { ... DllImportEntry(MyQCall), ... };该文件中可以看到大量真实注册条目例如ArgIterator_Init、CustomAttribute_CreateCustomAttributeInstance、Enum_GetValuesAndNames、Delegate_BindToMethodName等覆盖了参数迭代、自定义特性、枚举、委托绑定等系统能力的 QCall 入口见 qcallentrypoints.cpp。3.9 命名约定FCall/QCall不应公开暴露应包一层托管包装方法并提供 API 评审通过的公开名称。内部 FCall/QCall 应使用Internal 后缀以与公开入口区分公开入口做错误检查后调用签名完全相同的共享 worker 函数——这与纯托管 BCL 中处理该场景的方式无异。四、托管/非托管双重类型Dual Representation某些托管类型必须在托管代码与原生代码中同时存在一致的表示。至于类型的权威定义到底在托管侧还是 CLR 原生侧答案并不重要——关键是两边必须完全一致。这样 CLR 原生代码才能快速高效地访问托管对象内的字段。另一种更复杂的方式是使用 CLR 中相当于 Reflection 的机制通过MethodTable与FieldDesc取字段值但性能不理想且不好用。对常用类型在原生代码中声明一个数据结构并保持两边同步才是合理做法。4.1 corelib.h 中的声明宏在 corelib.h 中用后缀为_U的宏描述一个类型、托管字段名、以及对应原生数据结构中的字段名还可以列出方法清单稍后按名字引用调用。真实的SafeHandle声明如下DEFINE_CLASS_U(SAFE_HANDLE, Interop, SafeHandle, SafeHandle) DEFINE_FIELD(SAFE_HANDLE, HANDLE, handle) DEFINE_FIELD_U(SAFE_HANDLE, STATE, _state, SafeHandle, m_state) DEFINE_FIELD_U(SAFE_HANDLE, OWNS_HANDLE, _ownsHandle, SafeHandle, m_ownsHandle) DEFINE_FIELD_U(SAFE_HANDLE, INITIALIZED, _fullyInitialized, SafeHandle, m_fullyInitialized) DEFINE_METHOD(SAFE_HANDLE, GET_IS_INVALID, get_IsInvalid, IM_RetBool) DEFINE_METHOD(SAFE_HANDLE, RELEASE_HANDLE, ReleaseHandle, IM_RetBool) DEFINE_METHOD(SAFE_HANDLE, DISPOSE, Dispose, IM_RetVoid) DEFINE_METHOD(SAFE_HANDLE, DISPOSE_BOOL, Dispose, IM_Bool_RetVoid)之后可以用REFT模板生成类型名如SAFEHANDLEREF。REFT模板内置了OBJECTREF的全部错误检查原生代码中可以自由解引用SAFEHANDLEREF并使用其字段——但仍然必须对这些引用做 GC 保护。五、从非托管代码调用托管代码MethodDescCallSiteCLR 显然有大量从原生调用托管的场景。为此引入了MethodDescCallSite类来接管大量管线工作。概念上你只需找到目标方法的MethodDesc*找到 this 指针对应的托管对象实例方法需要传入参数数组处理返回值。内部实现中你还需要视情况切换线程状态让 GC 能以抢占式模式运行等。下面的简化示例展示了如何用上一节提到的 binder 调用SafeHandle的虚方法ReleaseHandlevoid SafeHandle::RunReleaseMethod(SafeHandle* psh) { CONTRACTL { THROWS; GC_TRIGGERS; MODE_COOPERATIVE; } CONTRACTL_END; SAFEHANDLEREF sh(psh); GCPROTECT_BEGIN(sh); MethodDescCallSite releaseHandle(s_pReleaseHandleMethod, METHOD__SAFE_HANDLE__RELEASE_HANDLE, (OBJECTREF*)sh, TypeHandle(), TRUE); ARG_SLOT releaseArgs[] { ObjToArgSlot(sh) }; if (!(BOOL)releaseHandle.Call_RetBool(releaseArgs)) { MDA_TRIGGER_ASSISTANT(ReleaseHandleFailed, ReportViolation)(sh-GetTypeHandle(), sh-m_handle); } GCPROTECT_END(); }注意其中METHOD__SAFE_HANDLE__RELEASE_HANDLE正是DEFINE_METHOD宏生成的符号——托管/非托管双重类型定义在此刻与原生调托管机制完成了闭环。六、与其他子系统的交互Debugger调试器FCall 目前的局限之一在 Visual Studio 的 Interop混合模式调试中无法同时轻松调试托管代码与 FCall。今天在一个 FCall 上设置断点并用 Interop 调试就是不工作。这个问题大概率不会修复。七、物理架构启动路径与基础设施索引CLR 启动时CoreLib 由SystemDomain::LoadBaseSystemClasses()加载实现见 appdomain.cpp。该函数中通过PEAssembly::OpenSystem()打开系统程序集以FILE_LOAD_BEFORE_TYPE_LOAD阶段部分加载系统程序集其他代码在完成加载之前就需要访问其中的全局量CoreLibBinder::AttachModule挂接 CoreLib binder依次加载Objectg_pObjectClass、终结器方法g_pObjectFinalizerMD、__Canong_pCanonMethodTableClass并特别强调ValueType与Enum必须紧挨着先后加载MethodTable::IsChildValueType依赖此顺序。各基础设施的源码落点汇总关注点源码位置FCall 基础设施fcall.hFCall 注册表ecalllist.hQCall 基础设施qcall.hQCall 注册表qcallentrypoints.cpp通用基础设施与原生类型定义object.h托管/非托管双重类型声明corelib.hmscorlib binder 实现binder.cppCoreLib 真实 QCall 调用示例String.IsInternedString.CoreCLR.cs结语一条贯穿全文的主线CoreLib 之所以特殊根源在于它是类型系统的第一块砖——它定义Object、Int32、String却又必须与 CLR 原生代码共享这些类型的布局。围绕这一矛盾运行时演化出一整套精密的互操作体系QCall首选类 P/Invoke 的封送与抢占式 GC 模式天然规避 GC hole适合绝大多数托管需要调原生的场景FCall被迫才用为少数必须优化的短路径保留用协作式 GC 与裸对象指针换取最大控制力代价是 GC hole 与 GC 饥饿风险binder corelib.h用声明式宏锁定托管/原生两侧类型布局的一致性MethodDescCallSite让原生代码反向、安全地调用托管方法。理解这套机制不仅能帮助你阅读 CoreCLR 源码时快速定位每个系统调用String_IsInterned、Enum_GetValuesAndNames、Delegate_BindToMethodName……的托管与原生两侧实现更能让你在向运行时添加新能力时遵循正确的调用通道选择与注册流程。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表