1. 一次真实的加载失败现场
如果你是在老项目里维护过 .NET 3.5 插件系统的同行,多半已经遇到过这样的报错:项目里引用了新团队交付的 DLL,构建一路绿灯,运行时却抛System.BadImageFormatException或者FileLoadException,提示程序集由更新的 .NET Framework 构建,当前进程无法加载。这个场景本质上就是“dotnet 3.5 运行时想加载 4.0 运行时程序集”,既不是 DLL 损坏,也不是路径写错,而是运行时和程序集的目标运行时对不上。
我印象很深的一次,是在一个基于 .NET 3.5 的 Windows 服务里接入内部新开发的报表组件。对方交付的是 .NET 4.0 类库,本地调试时我把工程目标框架改成 4.0 一切正常,但部署到生产服务器后服务一启动就崩。打开事件日志只看到一行“服务终止”,手动执行服务主程序才发现真正的异常隐藏在Assembly.Load调用栈深处。当时第一反应是复制 DLL 不全,补了一堆引用进去,结果报错从“找不到程序集”变成了“程序集由更高版本运行时构建,无法加载”。
这条信息很关键,它直接告诉你:不是文件缺失,而是运行时版本不匹配。但 3.5 和 4.0 的开发者平时很少意识到,这背后是两个完全不同的 CLR 在打架。要解决这个需求,不能只靠改配置或者换个加载方式,你得先理解 CLR 版本和 .NET Framework 版本的关系,然后搬出官方提供的“进程内并行运行”机制,也就是进程内 SxS。
2. CLR 版本与 .NET 版本:理清血缘和兼容边界
很多人会混淆 .NET Framework 版本和 CLR 版本。简单说,CLR 是公共语言运行时,负责托管代码的执行;.NET Framework 是建立在 CLR 之上的类库和框架。不同代的 .NET Framework 可能共用同一个 CLR,跨代则无法直接兼容。
先看一张最常用的对应表:
| .NET Framework 版本 | 对应 CLR 版本 | 备注 |
|---|---|---|
| 1.0 / 1.1 | CLR 1.0 / 1.1 | 早已淘汰,可忽略 |
| 2.0 / 3.0 / 3.5 | CLR 2.0 | 3.5 本质是在 2.0 的 CLR 上叠加新类库 |
| 4.0 / 4.5 / 4.6 / 4.7 / 4.8 | CLR 4.0 | 4.x 系列共用较新的 CLR 4.0 |
所以标题里的“dotnet 3.5 运行时”,准确的说法是“CLR 2.0 托管进程”;而“4.0 运行时程序集”,指的是面向 CLR 4.0 编译的程序集。这两个 CLR 不是版本升级那么简单,CLR 2.0 和 CLR 4.0 在 GC、JIT、安全模型、线程池、类型加载规则上都存在实质差异。微软也没有提供二进制层面的向下兼容保证。
编译一个托管程序集时,C# 编译器会在生成的文件头里写入目标运行时版本。你可以用十六进制编辑器打开 DLL,或者用corflags工具查看,能看到类似CLR Header -> runtime version = v4.0.30319这样的信息。这个字段就是 CLR 加载器用来判断“这个程序集该由谁加载”的身份证。
当你在 Visual Studio 里把一个 .NET 4.0 类库项目添加到 .NET 3.5 工程时,VS 的引用管理器甚至会直接弹提示:目标框架版本更高,无法添加。这是编译器的保护。但运行时没有这层保护,一旦程序集真的进入加载流程,CLR 2.0 会检查头部版本,发现目标 CLR 比当前进程高,直接拒绝执行。
这里有个很容易被忽略的点:.NET 3.5 程序集里的很多代码,放到 .NET 4.0 进程中反而能正常运行,因为 4.0 的 CLR 对旧程序集做了一定程度的兼容处理。反过来不行,4.0 程序集在 3.5 环境里基本没有活路。所以“3.5 加载 4.0”这个方向,天然比“4.0 加载 3.5”更麻烦。
3. 加载失败的根因:进程运行时与程序集目标运行时错位
说穿了,加载失败不是某个 API 拒绝你,而是 CLR 加载器在更早的阶段就判定“这不是我的菜”。
3.1 CLR 2.0 对 CLR 4.0 程序集做了什么检查
CLR 加载程序集的流程大概分几步:读取程序集元数据、解析依赖项、匹配目标运行时版本、执行 PE 映像映射、最后才是 JIT 编译方法。运行时版本检查发生在相当靠前的位置。mscorlib本身也是按 CLR 版本区分的,CLR 2.0 进程加载的是 2.0 的mscorlib,CLR 4.0 进程加载的是 4.0 的mscorlib。一个面向 4.0 的程序集在编译时已经和 4.0 的引用程序集绑定,当 CLR 2.0 发现程序集引用的是 4.0 的基础类型时,就存在无法解析的基础依赖,于是抛出加载异常。
很多人会纠结异常类型。实际上最常见的两种是:
BadImageFormatException:元数据或 PE 结构无法被当前 CLR 识别。FileLoadException:程序集能识别,但版本或依赖不兼容。
具体抛哪个,取决于触发场景和 CLR 版本,有时候同一个 DLL 在 32 位和 64 位进程里都会给出不同异常。但它们的根因常常一致:程序集目标 CLR 高于进程当前 CLR。
3.2 用 Fusion 日志还原现场
与其猜,不如让 CLR 自己说。加载器绑定日志(Fusion Log)就是干这个的。
在 .NET Framework 4.x 里,可以通过注册表开启绑定日志:
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Fusion] "EnableLog"=dword:00000001 "LogFailures"=dword:00000001 "LogPath"="C:\\FusionLog\\"如果你是 64 位系统且跑 32 位进程,还需要看SOFTWARE\WOW6432Node\Microsoft\Fusion。开启后重新运行程序,CLR 会把每一次程序集绑定过程写到C:\FusionLog\Default\目录。失败记录里通常会有一行类似:
*** 程序集绑定日志。失败的程序集名称: Net4Lib, Version=1.0.0.0 ... 系统找不到指定的文件。 或: 此程序集是由比当前已加载的运行时更新的运行时生成的,无法加载。后一种就是运行时版本错位的实锤。看到这行,就别再补文件、改路径了,问题在运行时层面。
3.3 为什么不能简单地在 3.5 进程里 Assembly.Load
我见过不少开发者的第一反应是换一种加载 API,比如用Assembly.LoadFrom、Assembly.Load(byte[]),甚至想用反射动态加载。但这些方法最终都会把程序集提交给当前进程的 CLR 2.0 加载器。CLR 2.0 不会因为你是用反射还是直接引用就放宽版本检查,它没这个商量余地。
想在一个 CLR 2.0 进程里执行 CLR 4.0 代码,唯一合理的路径是:在同一个进程里再启动一个 CLR 4.0 运行时,通过宿主 API 加载并调用目标程序集。这就是进程内 SxS 要解决的问题。
4. 进程内 SxS:官方提供的共存机制
4.1 什么是进程内并行运行
传统上,一个 Windows 进程只能加载一个 CLR 主版本。CLR 1.0 时代就规定得死死的,你想在同一进程里跑两个不同时代的托管世界,几乎不可能。到了 .NET 4.0,微软才终于下发“进程内并行运行”能力,术语叫 In-Process Side-by-Side,缩写 SxS。它的能力是:允许 CLR 2.0 和 CLR 4.0 同时存在于同一个进程内,各自维护自己的 AppDomain、线程池、GC 堆和加载器上下文。
这个机制的实际意义很大。旧系统不用立刻整体升级,可以在原来的进程里挂一个“新运行时”,让新组件跑在 CLR 4.0 上,旧组件继续跑 CLR 2.0。两边通过宿主代码互相调用,跨运行时边界就像跨进程通信一样,物理上共享进程地址空间,逻辑上是两套独立生态。
SxS 对 CLR 1.x 没开放,所以如果你还想维护 .NET 1.1 时代的代码,基本没戏。但对 2.0 和 4.0 这个组合,是官方支持的正路。
4.2 关键宿主 API 的演进:CorBindToRuntimeEx 和 ICLRMetaHost
要启动一个新的 CLR 运行时到当前进程,靠的是 CLR Hosting API。早年间用的函数是CorBindToRuntimeEx,通过传版本串v2.0.50727或v4.0.30319来加载指定运行时。这个 API 虽然能完成工作,但设计比较老,对运行时枚举、版本策略、并发宿主场景的支持不够精细。
从 .NET 4.0 开始,官方推荐走CLRCreateInstance+ICLRMetaHost+ICLRRuntimeInfo这条新链路:
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, ...)拿到元宿主对象。ICLRMetaHost::GetRuntime(L"v4.0.30319", ...)拿到指定运行时信息。ICLRRuntimeInfo::GetInterface(CLSID_CLRRuntimeHost, ...)拿到运行时宿主接口。ICLRRuntimeHost::Start()正式启动 CLR 4.0。- 通过
ExecuteInDefaultAppDomain或 AppDomain 操作执行目标程序集。
我实际测试下来,新链路比CorBindToRuntimeEx更稳定,对版本字符串的匹配更严格,也不容易因为启动标志问题带偏走默认运行时。下面实操部分使用的就是这条新链路。
4.3 两个 CLR 之间的边界
SxS 虽然共享进程,但不是在进程内存空间里随便穿插。CLR 2.0 和 CLR 4.0 各自有独立的mscorlib,各自默认 AppDomain 也是完全隔离的。你不可能把一个 CLR 4.0 的System.String直接传给 CLR 2.0 的代码,跨边界传参必须经过基础类型转译,或者通过宿主层进行序列化。
这个边界有点像两个独立的虚拟机跑在同一台物理机上。物理资源共享,系统盘各自独立。你在虚拟机 A 里挂载的磁盘,虚拟机 B 默认看不到。理解这个边界,后续踩坑时才知道为什么会有“对象不能跨 AppDomain”的诡异异常。
5. 实操:从 .NET 3.5 进程把 .NET 4.0 程序集拉进来
下面我给一套最小可复现的实操方案。思路是:写一个原生桥 DLL,负责在当前进程内启动 CLR 4.0 并调用目标程序集;.NET 3.5 主程序通过 P/Invoke 调桥 DLL。桥 DLL 用 C++ 写,主程序和目标程序集用 C# 写。这个组合的好处是,不用在 C# 里硬啃 COM 接口 vtable,维护成本低很多。
5.1 准备三个项目
- Net4Lib:面向 .NET 4.0 的类库,提供一个静态方法给外部调用。
- Clr4HostBridge:原生 C++ DLL,导出
CallClr4Method。 - Net35Host:面向 .NET 3.5 的 Console 应用,P/Invoke 调用桥 DLL。
注意架构位数必须一致。如果主程序是 x86 进程,桥 DLL 和所有依赖都要 x86;如果主程序是 x64,则全部 x64。最佳实践是三个项目全部锁定x86或全部锁定x64,避免混合架构导致后续无谓的BadImageFormatException。这一点很多人在测试时栽过,别等到部署才发现。
5.2 写目标程序集 Net4Lib
目标类要能被ExecuteInDefaultAppDomain调用,有严格限定:必须是公共非泛型类,方法必须是公共静态,返回值必须是int,参数只能有一个string。这是 CLR 宿主 API 预设的简化入口。如果业务复杂,可以在这个入口里做分发。
using System; namespace Net4Lib { public class ReportRunner { public static int Run(string configPath) { Console.WriteLine("[CLR4] ReportRunner.Run 被调用"); Console.WriteLine("[CLR4] Environment.Version = " + Environment.Version); Console.WriteLine("[CLR4] configPath = " + configPath); // 这里可以放入实际业务逻辑 return 42; } } }编译后得到Net4Lib.dll。请确认工程目标框架是 .NET Framework 4.0 或以上,不是 .NET Core 或 .NET 5+,否则ExecuteInDefaultAppDomain这套旧 API 不适用。
5.3 写原生桥 Clr4HostBridge
C++ 工程需要引入metahost.h和mscoree.lib。VS 里新建一个动态链接库项目,文件内容大致如下:
#include <windows.h> #include <metahost.h> #include <stdio.h> #pragma comment(lib, "mscoree.lib") extern "C" __declspec(dllexport) int __stdcall CallClr4Method( const wchar_t* assemblyPath, const wchar_t* typeName, const wchar_t* methodName, const wchar_t* argument) { HRESULT hr = S_OK; ICLRMetaHost* pMetaHost = nullptr; ICLRRuntimeInfo* pRuntimeInfo = nullptr; ICLRRuntimeHost* pRuntimeHost = nullptr; hr = CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&pMetaHost); if (FAILED(hr)) { wprintf(L"CLRCreateInstance failed: 0x%08X\n", hr); return hr; } hr = pMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&pRuntimeInfo); pMetaHost->Release(); if (FAILED(hr)) { wprintf(L"GetRuntime failed: 0x%08X\n", hr); return hr; } hr = pRuntimeInfo->GetInterface(CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&pRuntimeHost); pRuntimeInfo->Release(); if (FAILED(hr)) { wprintf(L"GetInterface failed: 0x%08X\n", hr); return hr; } hr = pRuntimeHost->Start(); if (FAILED(hr)) { wprintf(L"CLR Start failed: 0x%08X\n", hr); pRuntimeHost->Release(); return hr; } DWORD result = 0; hr = pRuntimeHost->ExecuteInDefaultAppDomain( assemblyPath, typeName, methodName, argument, &result); pRuntimeHost->Stop(); pRuntimeHost->Release(); if (FAILED(hr)) { wprintf(L"ExecuteInDefaultAppDomain failed: 0x%08X\n", hr); return hr; } return (int)result; }这个桥 DLL 在同一个进程里完成了“启动 CLR 4.0 + 调用 CLR 4.0 程序集”。当 .NET 3.5 主进程调用它时,进程里就会出现两个 CLR 共存的状态。
ExecuteInDefaultAppDomain要求程序集路径是绝对路径或相对于工作目录可解析的路径,类型名必须是完全限定名。比如Net4Lib.ReportRunner。方法名是Run,参数是字符串。注意返回值:它拿到的是目标方法的int返回值,而函数本身返回类型是int,可别传成void*或者别的。
5.4 .NET 3.5 进程通过 P/Invoke 调用
主程序那边很简单,不需要加载 Net4Lib.dll 到 CLR 2.0,直接调用桥导出函数即可:
using System; using System.Runtime.InteropServices; class Program { [DllImport("Clr4HostBridge.dll", CharSet = CharSet.Unicode, CallingConvention = CallingConvention.StdCall)] static extern int CallClr4Method( string assemblyPath, string typeName, string methodName, string argument); static void Main(string[] args) { Console.WriteLine("[CLR2] Environment.Version = " + Environment.Version); string assemblyPath = System.IO.Path.GetFullPath("Net4Lib.dll"); string typeName = "Net4Lib.ReportRunner"; string methodName = "Run"; string argument = "C:\\configs\\report.xml"; int result = CallClr4Method(assemblyPath, typeName, methodName, argument); Console.WriteLine("[CLR2] CLR4 返回结果 = " + result); Console.ReadKey(); } }先确认三点:Clr4HostBridge.dll和Net4Lib.dll都放在主程序输出目录;主程序工程目标框架是 3.5;所有工程架构一致。跑起来后,控制台会先打印 3.5 进程里的Environment.Version,通常是2.0.50727.xxxx,随后桥 DLL 启动 CLR 4.0,目标方法里打印的版本就会是4.0.30319.xxxx。看到这两个版本号同时出现在一个进程中,SxS 就成功了。
如果输出里只看到 2.0 的版本号,说明桥没有被正确调用;如果连 2.0 都没看到,检查 P/Invoke 入口点或 DLL 放置路径。
5.5 想调用更复杂逻辑怎么办
ExecuteInDefaultAppDomain只支持一种方法签名,实际项目里不可能都限制成static int X(string)。更通用做法是:先在 CLR 4.0 默认 AppDomain 里建一个继承自MarshalByRefObject的入口对象,通过宿主先调用一个初始化方法把对象驻留,之后再用跨 AppDomain/proxy 的方式调用该对象的其他方法。
但在桥 DLL 层面做完整代理比较繁琐,更快的办法是让目标程序集在Run入口里自行处理复杂逻辑,比如根据配置字符串反射加载其它模块、启动自己的后台任务。入口简单,内部可以绕。真需要频繁跨边界交互时,建议直接考虑后面的进程边界方案,否则桥层会膨胀成一个失控的中转站。
6. 踩坑记录与排查清单
实操过程中有几个坑,几乎每批踩一遍。
6.1 版本字符串必须严格匹配
GetRuntime(L"v4.0.30319")这个字符串来自 CLR 安装目录里的 mscorlib 版本。如果你写的是4.0、4.0.30319或v4.0,都可能拿到REGDB_E_CLASSNOTREG或直接返回失败。不要试图偷懒,严格写v4.0.30319。
如果你想加载的是其他运行时版本,可以先枚举机器上已安装的运行时列表,再选择目标版本。ICLRMetaHost提供了EnumInstalledRuntimes接口,写个枚举工具能拿到所有可用版本串,比手写配置安全得多。
6.2 桥 DLL 的线程模型和初始化顺序
调用桥 DLL 时,CLR 4.0 的Start()会启动一个新的托管世界。这个世界的线程是从当前调用线程延续过去的,也就是说,CLR 4.0 的初始 AppDomain 运行在你当前线程上下文中。如果你的 3.5 进程是 MTA(多线程单元)状态,而目标程序集组件要求 STA,后续可能出现 COM 初始化异常。遇到这种问题,在调用桥 DLL 前先确认线程的 COM 单元状态,必要时单独起一个 STA 线程执行桥调用。
另外,CLR 4.0 一旦启动,就不会真正卸载。Stop()只是发信号,CLR 4.0 在进程结束前依然占有资源。所以不要指望频繁启动/停止来节约内存,这种设计在长活进程里会埋雷。
6.3 异常信息容易“跨层丢失”
ExecuteInDefaultAppDomain返回的 HRESULT 只代表调用是否成功,CLR 4.0 程序集里抛出的异常详情,默认不会完整传给 C++ 层。你只能看到类似0x80131604的通用错误码。反查起来很痛苦。经验是:在目标方法的入口包一层 try/catch,把异常打印到日志或能访问的文件,然后再返回固定错误码,方便快速定位。
6.4 一些让人挠头的错误码
| 错误码 | 含义 | 常见场景 |
|---|---|---|
| 0x800736B3 | 指定的程序集没有安装在系统上,或者是 SxS 程序集缺失 | 目标程序集的依赖项未部署到目标机器,或引用的 Windows 组件未安装 |
| 0x80131047 | 无法加载文件或程序集 | 程序集版本或强名称签名冲突 |
| 0x80131604 | 方法内抛了未处理异常 | CLR 4.0 目标代码内部出错,需查它自己的日志 |
| 0x80131401 | CLR 安全检查失败 | 目标程序集请求了超出进程权限的操作 |
其中0x800736B3很容易被误判成“运行时版本不对”,实际上它经常出现在目标程序集依赖了某个未安装的旧系统组件或 C++ 运行库时。多查依赖项,别把所有锅都甩给 CLR 版本。
6.5 怎么确认当前进程到底有几个 CLR
用ICLRMetaHost::EnumLoadedRuntimes可以枚举当前进程里已加载的 CLR 运行时。你可以在 3.5 主程序里再写一个调试小工具,调用桥 DLL 启动 CLR 4.0 前后各枚举一次,确认加载情况。更快的验证方式,就是在目标程序集里打印Environment.Version和系统运行时目录,能直接看到 CLR 4.0 的世界。这一步强烈建议在做集成测试时保留,至少保留到功能稳定。
7. 什么时候不要用 SxS:替代方案和选型思考
进程内 SxS 方案能解决“3.5 进程加载 4.0 程序集”,但它不是银弹。它带来的是两套 GC 堆、两套线程池、两个 CLR 生命周期,对内存足迹和异常排查都有额外成本。真的值得为它写一个 C++ 桥并忍受跨层冷启动开销吗?我建议在动手前做个决策。
7.1 能整体升级就整体升级
如果业务允许,把整个宿主从 .NET 3.5 升级到 .NET 4.x 是最省事的。绝大多数为 3.5 写的代码在 4.x 运行时下能正常编译运行,问题往往出在第三方组件和遗留配置上。升级成本通常会低于长期维护一个桥 DLL 的成本。
但注意反向兼容有个边界:有些旧代码依赖了useLegacyV2RuntimeActivationPolicy或者特定 AppDomain 行为,升级后需要额外测试。不过这依然比永久维护双 CLR 环境健康得多。
7.2 拆进程:用通信代替跨运行时调用
如果目标程序集和主程序之间是清晰的调用/返回关系,不如把 CLR 4.0 代码独立成一个 EXE 或 Windows 服务,通过 WCF、命名管道、TCP、gRPC 或简单 HTTP 通信。这样做有三个好处:一是异常隔离,4.0 那边崩溃不影响 3.5 主进程;二是开发和调试各自独立;三是以后升级 4.0 那侧不用再动 3.5 进程。代价是每次调用多一次进程边界开销,但很多业务场景完全接受。
如果目标是 COM 组件,也可以考虑把 CLR 4.0 程序集注册成 COM 可见服务,3.5 进程直接按 COM 组件方式调用。这种方式在某些老系统里意外地好用,尤其当交互模型本身就很像外部组件调用时。
7.3 我的选型建议
我个人只在两种场景下坚持用进程内 SxS:
一是目标程序集需要和主进程深度共享进程资源,比如共用文件句柄、共享内存映射、或者被某段原生代码强制要求在进程内执行。二是主程序是一个古老插件宿主,插件的加载模式已经被写死,不允许拆成独立进程改架构。
如果只是接一个新模块、跑一个报表生成、调一个数据转换服务,我大概率会选择拆进程。维护成本和安全边界都比双 CLR 共居要好。这个项目标题看着是“能不能加载”的技术问题,真正落地时往往是“值不值得这么加载”的工程问题。先把后一个问题想清楚,再回来看 Clr4HostBridge 的代码,会轻松很多。