C#动态加载代码实战:反射、Roslyn与AssemblyLoadContext全解析
2026/9/9 22:56:27 网站建设 项目流程

简介:C#动态加载代码是构建插件系统与模块化应用的重要技术。这份资源面向具备基础C#语法、希望掌握反射与动态编译的开发者,提供一套可直接运行的WinForm实例源码,解决运行时按需加载、编译与执行代码的需求,提升应用灵活性。

资源共18个文件,以8个.cs核心源码为主,配合resx界面资源、ico图标及完整工程配置文件,压缩包仅82KB,轻量便于快速部署研读。已有395人学习下载。

代码内部通过Assembly.LoadFrom演示程序集加载,并借助CSharpCodeProvider与CompilerParameters实现字符串代码的内存编译,涵盖错误收集和调用逻辑。编译后的Assembly可进一步反射调用与实例化,便于二次封装。工程包含多个窗体文件,清晰展示动态执行代码的交互流程与界面设计。

通过调试实例,可直观观察动态程序集从生成、加载到调用的完整链路,理解编译错误处理与反射调用的关键细节。实例虽然小巧,但结构完整,既是教学演示的良好范本,也可作为插件式架构的起步模板。 其实真正的避坑内容被瀑布式填在正文里了。

我先从实际场景说起。做C#上位机开发的人,早晚会碰上一个需求:客户说“这个功能下周才能定,你先做整体框架”,或者“算法那边还在调,你留个口子让我下个月把新逻辑放进去”。如果你还在用“改代码—重编译—让现场停线—把整个EXE替换掉”这套流程,一次两次还行,三次四次客户就不再信任你了。动态加载代码,就是专门解决这种“系统运行中把新代码塞进去”的问题。

这篇文章围绕C#动态加载代码的实例源码来写,覆盖三种典型实现路径:反射加载DLL、Roslyn运行时编译源码、AssemblyLoadContext隔离加载与卸载。适合正在做上位机、设备控制、视觉检测、可扩展桌面应用开发的C#工程师,尤其是项目已经出现插件化、模块化迹象但还没找到合适落地方式的朋友。

1. 动态加载到底在解决什么问题——先把动机想明白

1.1 不停机更新和插件化架构的刚需

我见过太多上位机项目,一开始只有一个EXE,所有功能都堆在主程序里。通讯协议写死、视觉算法写死、流程逻辑写死。等设备出货到现场,问题来了:客户要求换一种扫码枪协议,或者PLC地址表有变动,你不得不抱着笔记本去现场,重新发布整个程序,还要祈祷现场操作员别在更新期间误触设备。

动态加载代码的核心价值,就是把“容易变的部分”从主程序里剥离出来,变成一个个可以独立更新、独立替换的模块。主程序只需要定义好接口,运行时就按照约定去找模块、加载模块、调用模块。这样一来:

  • 通讯协议升级,只替换通讯插件DLL
  • 视觉算法迭代,只替换算法插件DLL
  • 流程逻辑调整,只替换流程插件DLL
  • 主程序完全不用动,现场停机时间从“半天”缩短到“几分钟”

1.2 “加载代码”其实包含两个层面

很多初学者听到“动态加载代码”,第一反应是反射,也就是Assembly.Load + Activator.CreateInstance这套组合。没错,这是最基础、最常用的一层。但严格来说,动态加载代码还包含另一个层面:在运行时把C#源码字符串编译成程序集,再加载执行。前者是“加载已经编译好的DLL”,后者是“现写代码现编译现执行”。两者有本质区别:

维度加载程序集运行时编译源码
代码来源已编译DLL文件C#源码字符串
核心APIAssembly.Load / Assembly.LoadFromRoslyn的CSharpCompilation
典型场景插件化架构、模块热替换规则引擎、用户自定义脚本
调试难度相对低相对高
和反射的关系反射是调用手段编译完还是要反射调用

这篇文章会两条线都讲透,因为实际项目里你很可能两条线都要用。比如主程序用反射加载插件机制,但插件内部又用Roslyn实现一些用户可编辑的规则逻辑。

2. 动态加载的基础机制——程序集、反射、加载上下文先理清楚

2.1 程序集是动态加载的最小单元

C#代码编译后生成的是程序集(Assembly),对于桌面应用来说,最直观的形态就是DLL文件。一个DLL内部包含元数据(Metadata)和中间语言(IL)。元数据里记录了这个程序集里有哪些类、哪些方法、哪些字段;IL才是真正执行逻辑的地方。动态加载就是让CLR在运行时读取这些元数据,并把IL交给JIT编译为本机代码执行。

你能动态加载什么,取决于这个程序集是不是“托管程序集”。像C++写的那种原生DLL,虽然有DLL后缀,但里面没有.NET元数据,不能用Assembly.Load直接加载。这也是很多上位机工程师第一次把C++算法库改成插件机制时踩坑的地方——你想动态加载的那个东西,根本不满足托管控件的条件。

2.2 反射是连接加载与调用的桥梁

加载一个DLL之后,你面对的是一堆类型元数据。怎么实例化?怎么调方法?全靠反射。核心就这么几行:

// 加载程序集(传完整路径或文件名) Assembly assembly = Assembly.LoadFrom(@"D:\plugins\MyPlugin.dll"); // 找到目标类型 Type pluginType = assembly.GetType("MyPlugin.MainEntry"); // 创建实例 object instance = Activator.CreateInstance(pluginType); // 调用方法(强制转换为接口后再调用,而不是用InvokeMember) var plugin = (IMyPlugin)instance; string result = plugin.Execute("hello");

反射本身不复杂,复杂的是“怎么组织你的代码,让反射之后的调用变得干净”。这也是为什么我极度推荐用接口来做插件约定——主程序定义一个IMyPlugin接口,插件DLL引用同一个接口程序集并实现它。这样反射只需要负责“加载程序集、找类型、创建实例”这一小段,后续所有调用都走强类型接口,代码可读性和可维护性会好很多。

2.3 加载上下文:一个容易被忽略的关键角色

.NET程序集加载不是从磁盘读个文件那么简单,CLR内部有一套复杂的加载逻辑。默认情况下,你的主程序运行在一个默认加载上下文(DefaultContext)里。当你用Assembly.LoadFrom加载插件时,它会带着自己的依赖进入这个上下文。问题来了:如果两个插件依赖同一个DLL的不同版本,或者主程序和插件引用了不同版本的某个公共库,就容易出现版本冲突。

更麻烦的是,默认上下文里加载的程序集,理论上卸载不掉。就算你把引用全部置空,这个DLL也会一直占着文件句柄,导致你无法覆盖它。要解决“可替换”,必须用到AssemblyLoadContext——一会儿在第5章专门讲。

3. 第一个实例:扫描目录、加载插件DLL、反射调用——最经典的上位机插件架构

3.1 先定义插件接口

我习惯的工程结构是这样:

MainApp.exe // 主程序 PluginContract.dll // 接口程序集,主程序和插件都引用 Plugins/ MyPlugin.dll // 具体插件 AnotherPlugin.dll

接口程序集是关键,它不包含任何业务逻辑,只包含一组约定。比如这个最小例子:

namespace PluginContract; public interface IMyPlugin { string Name { get; } string Execute(string input); }

注意:接口这块程序集要尽量保持稳定。一旦接口变了,主程序和所有插件都要同步编译更新,那就失去热更新的意义了。

3.2 主程序扫描Plugins目录,加载并调用

下面是一段能直接跑通的最小例程。它的逻辑很简单:启动时扫描Plugins文件夹下的所有DLL,逐个加载,找出实现了IMyPlugin接口的类型,实例化后调一遍:

using System.Reflection; using PluginContract; string pluginDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins"); if (!Directory.Exists(pluginDir)) { Console.WriteLine("Plugins目录不存在"); return; } var plugins = new List<IMyPlugin>(); foreach (string dllPath in Directory.GetFiles(pluginDir, "*.dll")) { try { Assembly assembly = Assembly.LoadFrom(dllPath); Type[] types = assembly.GetTypes(); foreach (Type type in types) { if (type.IsClass && !type.IsAbstract && typeof(IMyPlugin).IsAssignableFrom(type)) { var plugin = (IMyPlugin)Activator.CreateInstance(type); plugins.Add(plugin); } } } catch (ReflectionTypeLoadException ex) { // 插件DLL依赖缺失时会出现该异常,实际项目中通常只记录 Console.WriteLine($"跳过 {dllPath}: {ex.LoaderExceptions[0]?.Message}"); } catch (Exception ex) { Console.WriteLine($"加载 {dllPath} 失败: {ex.Message}"); } } foreach (var plugin in plugins) { Console.WriteLine($"插件 {plugin.Name} 返回: {plugin.Execute("world")}"); }

这段代码相当于整个动态加载插件的骨架,我后面的项目基本都在它基础上扩展。两个地方值得专门说一下:

第一个是Assembly.LoadFrom的路径问题。LoadFrom会把程序集添加到默认加载上下文,同时会把DLL所在目录加入探测路径。这意味着如果两个插件都依赖同一个公共第三方库,你最好保证它们引用的版本一致,否则可能出现“你以为加载了A版本,实际运行用了B版本”的诡异情况。

第二个是GetTypes()的稳定性。你预期插件里都是合法类型,但实际调试时经常遇到一个DLL里混了一些依赖其他缺失程序集的类型,导致GetTypes抛出ReflectionTypeLoadException。所以这里的try-catch不是防御性写法,是刚需。

3.3 目录扫描的几个细节

  • 不要用Assembly.LoadFile,除非你清楚它的后果。LoadFile不会把DLL所在目录加入探测路径,也不会和同名的已加载程序集合并。两个插件如果都依赖同一个公共库而版本不同,LoadFile会给你加载两份,造成类型不匹配。
  • 加载前的DLL锁定问题。用LoadFrom加载DLL后,文件会被锁定,无法覆盖删除。开发调试阶段特别烦人,每次改完插件,想复制新版本到Plugins目录就被拒绝。解决思路要么是每次调试用一个副本目录,要么干脆上第5章的AssemblyLoadContext。

4. 更进一步:用Roslyn动态编译并执行C#源码——从头写代码到运行只需要几行

4.1 什么时候真的需要运行时编译?

说实话,反射加载DLL能覆盖80%的场景。但有一个场景它搞不定:如果你希望用户在不编写C#项目、不安装编译环境的情况下,只给一段C#代码文本,系统就能执行它,那就必须用Roslyn在运行时编译。典型例子是设备流程规则、算法参数联动逻辑、产品换型时用户自定义的检测决策结构。

我之前做过一个视觉检测上位机,检测完一个产品后,判定逻辑不是固定的——有的客户要求面积超限就NG,有的要求定位偏移加灰度平均超阈值才NG。与其为每个客户改主程序,不如把判定逻辑做成可编辑的源码文本,用户界面里改代码,保存后系统动态编译加载。本质上是把“产品判定”这块从系统发布周期中摘出去。

4.2 CSharpScript与CSharpCompilation的选择

Roslyn提供的API有两条路:

方式特点适用场景
CSharpScript.EvaluateAsync轻量,直接执行表达式或语句块,不产生DLL文件简单表达式、短逻辑
CSharpCompilation编译成完整的程序集,可返回Assembly对象,可加载执行复杂多类型、多次调用、需要缓存场景

CSharpScript用起来爽,但对复杂场景支持有限,而且每次执行都要重新编译解析,性能一般。CSharpCompilation虽然代码多一些,但编译产物可以复用,也更贴近“动态加载”这个主题。

下面是一个用CSharpCompilation把源码字符串编译成程序集,再反射调用的完整示例。这一步就用上了上一章的反射知识,因为编译出来的东西终究还是一个Assembly:

using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; using System.Reflection; public static class InMemoryCompiler { public static Assembly Compile(string code) { // 1. 解析源码 SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code); // 2. 指定目标框架的引用 string runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location); var refs = new List<MetadataReference> { MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Private.CoreLib.dll")), MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll")), MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Console.dll")), MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }; // 3. 创建编译单元 CSharpCompilation compilation = CSharpCompilation.Create( "DynamicAssembly_" + Guid.NewGuid().ToString("N"), new[] { syntaxTree }, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); // 4. 编译到内存流 using var ms = new MemoryStream(); EmitResult result = compilation.Emit(ms); if (!result.Success) { var errors = string.Join(Environment.NewLine, result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)); throw new InvalidOperationException($"编译失败:{Environment.NewLine}{errors}"); } ms.Seek(0, SeekOrigin.Begin); return Assembly.Load(ms.ToArray()); } }

调用时,把用户输入的完整类代码交给Compile,拿到Assembly后SOP就回到反射了:

string code = """ using System; namespace UserRules { public class MyRule { public bool Check(double value) { return value > 80; } } } """; Assembly asm = InMemoryCompiler.Compile(code); Type ruleType = asm.GetType("UserRules.MyRule"); dynamic rule = Activator.CreateInstance(ruleType); bool pass = rule.Check(88.5);

4.3 引用缺失是运行时编译的头号坑

上面示例里refs列表,只是“够跑通”的最小集合。实际项目中用户代码往往要访问自己的业务类库、Newtonsoft.Json、Log4Net等第三方库,你需要把那些DLL路径一个个加进去。汇总来说就是:

  • 程序集引用不是越多越好,每个引用都会影响编译速度
  • AppDomain.CurrentDomain.GetAssemblies()遍历当前已加载程序集并转换引用,是个省事的思路,但要注意有些动态加载的程序集物理文件已不在预期路径
  • 编译报错的定位信息,需要从Diagnostic里筛出有Location的行号信息,否则用户面对错误信息会一头雾水

5. AssemblyLoadContext——让插件真正可以卸载和覆盖的完整方案

5.1 AppDomain时代的遗留问题

.NET Framework时代,大家想卸载动态加载的DLL,只能通过创建额外的AppDomain,在子域里加载,再卸载整个AppDomain。这办法能工作,但AppDomain之间互相通信要用MarshalByRefObject做代理,跨域访问非常别扭,而且一个进程创建一堆AppDomain的开销也不小。

到了.NET Core / .NET 5+,微软提供了AssemblyLoadContext(ALC),这是更彻底的方案。ALC允许你创建多个独立的加载上下文,每个上下文可以自己解析程序集依赖,关键是可以整体卸载,从而释放文件句柄和内存。

5.2 一个可卸载的插件加载器示例

using System.Reflection; using System.Runtime.Loader; public class PluginLoadContext : AssemblyLoadContext { private readonly AssemblyDependencyResolver _resolver; public PluginLoadContext(string pluginPath) : base(isCollectible: true) { _resolver = new AssemblyDependencyResolver(pluginPath); } protected override Assembly? Load(AssemblyName assemblyName) { // 先尝试按插件目录解析依赖 string? path = _resolver.ResolveAssemblyToPath(assemblyName); if (path != null) { return LoadFromAssemblyPath(path); } return null; } protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { string? path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); if (path != null) { return LoadUnmanagedDllFromPath(path); } return IntPtr.Zero; } }

把加载动作封装到这个Context里:

public class PluginHost : IDisposable { private PluginLoadContext? _context; private Assembly? _assembly; public void Load(string dllPath) { _context = new PluginLoadContext(dllPath); _assembly = _context.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); } public IMyPlugin? CreateInstance() { Type? type = _assembly?.GetTypes() .FirstOrDefault(t => typeof(IMyPlugin).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract); return type == null ? null : (IMyPlugin)Activator.CreateInstance(type); } public void Unload() { _context?.Unload(); _context = null; _assembly = null; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } public void Dispose() => Unload(); }

5.3 卸载不彻底,通常是你自己的代码还抱着引用

ALC的Unload不是同步立即生效的。它会标记上下文为不可用,等待所有引用都被释放后由GC回收。所以Unload之后调用GC.Collect是必须的,但也不是万能的。最常见的问题是:

  • 插件实例还被某个静态字段引用着,所有引用该实例的地方都必须置空
  • 插件内部创建的事件处理器挂在主程序的静态事件上,这个委托会阻止卸载
  • 插件DLL里用了第三方的原生DLL,原生层有没有释放资源也会影响整体回收

写这类代码时,我一般会在主程序侧维护一个List<IDisposable>,插件实现IDisposable,先把业务资源释放干净,再清引用,最后才Unload。顺序乱了,卸载就是碰运气。

6. 动态加载实战中的高发问题排查——都是踩过的坑

6.1 FileNotFoundException和FileLoadException要分开看

很多人一看到这两个异常就晕。其实它们的含义完全不同:

异常含义常见根因
FileNotFoundException找不到程序集依赖DLL没拷贝到运行目录、路径参数写错
FileLoadException找到了但加载失败强命名程序集版本不匹配、已加载同名称程序集、CPU架构不匹配

排查时先看InnerException,60%的情况下根因在它里面。还有一点是事件查看器和Debug输出里常常有更有价值的绑定日志,别只盯着异常消息。

6.2 同一个DLL加载两次,得到的是两个不同的类型

这一点最容易被忽视。如果你用两个ALC分别加载同一份插件DLL,或者先用默认上下文LoadFrom一次,又用ALC加载一次,那么同一个类名依赖的程序集会指向两套不同的类型对象。它们不会因为长得一模一样就被CLR认为是同一个类型。

这在回调、事件、依赖注入场景下会引发莫名其妙的类型转换异常。解决办法是统一走同一个加载上下文,或者想办法让主程序与插件共同依赖的程序集(特别是接口程序集)只在默认上下文中加载一次。一个偏方做法:把PluginContract.dll放在主程序目录,让ALC的Load方法对Contract开头的程序集返回null,从而回退到默认上下文。

6.3 插件引用版本冲突导致线上行为诡异

插件A引用了Newtonsoft.Json 12.0,插件B引用了13.0。默认托管上下文下加载顺序先到先得,第二个插件加载时如果类型分布差异大,编译期没问题,运行期就可能遇到缺失方法或者行为差异。

通用取舍是尽量让主程序统一管理公共依赖版本,插件只写业务逻辑,不自己打包第三方大件依赖。如果插件确实需要独立依赖,就上ALC由插件自己解析,这是最干净的路径。

6.4 文件被占用,覆盖不了插件DLL

这是反射加载方案最讨厌的问题。代码在默认上下文里LoadFrom一次DLL,整个进程生命周期内该文件都无法覆盖。调试时你改了插件代码,编译输出拷不进Plugins目录,只能重启主程序。

解决办法排序如下:

  • 开发期:每次调试将插件复制到一个临时目录,主程序指向临时目录加载
  • 交付期:主程序捕获插件版本号,用版本号做子目录隔离,老版本目录留作回滚
  • 终极方案:ALC + 周期性检测文件变更 + Unload重载,实现不停机替换

6.5 加载前做一次安全检查

动态加载代码意味着你执行的代码可能来自第三方,甚至用户自己。安全风险必须考虑。简单做法有:

  • 要求插件DLL带强名称,加载前验证公钥令牌
  • 计算DLL文件的SHA256哈希,与白名单比对
  • 插件加载后启动在受限权限的线程上执行,涉及文件、网络操作自行约束

我见过不止一个上位机项目因为随意加载插件,导致现场设备被一个带恶意代码的“测试插件”把系统参数改乱了。插件机制是方便,但该做的防护不能省。

7. 一段我自己反复用的插件宿主最小骨架

最后分享一个我在多个桌面端项目中反复使用的骨架。它不算什么高深设计,比很多开源插件框架轻量得多,关键是没有多余的东西,适合小团队自己把控。

public class PluginManager : IDisposable { private readonly List<(string Path, PluginLoadContext Ctx, IMyPlugin Instance)> _plugins = new(); public void LoadAll(string pluginDir) { UnloadAll(); foreach (string dll in Directory.GetFiles(pluginDir, "*.dll")) { try { var ctx = new PluginLoadContext(Path.GetFullPath(dll)); Assembly asm = ctx.LoadFromAssemblyPath(Path.GetFullPath(dll)); Type? pluginType = asm.GetTypes() .FirstOrDefault(t => typeof(IMyPlugin).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract); if (pluginType == null) { ctx.Unload(); continue; } var instance = (IMyPlugin)Activator.CreateInstance(pluginType); instance.Initialize(); _plugins.Add((dll, ctx, instance)); Console.WriteLine($"[PluginManager] 已加载: {instance.Name}"); } catch (Exception ex) { Console.WriteLine($"[PluginManager] 加载失败 {Path.GetFileName(dll)}: {ex.Message}"); } } } public void ReplacePlugin(string dllPath) { // 找到旧插件并移除 var old = _plugins.FirstOrDefault(p => p.Path == dllPath); if (old.Ctx != null) { _plugins.Remove(old); old.Instance.Shutdown(); old.Ctx.Unload(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } // 重新加载 var ctx = new PluginLoadContext(Path.GetFullPath(dllPath)); Assembly asm = ctx.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); Type? pluginType = asm.GetTypes() .FirstOrDefault(t => typeof(IMyPlugin).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract); if (pluginType == null) { ctx.Unload(); return; } var instance = (IMyPlugin)Activator.CreateInstance(pluginType); instance.Initialize(); _plugins.Add((dllPath, ctx, instance)); Console.WriteLine($"[PluginManager] 替换完成: {instance.Name}"); } public void UnloadAll() { foreach (var p in _plugins) { p.Instance.Shutdown(); p.Ctx.Unload(); } _plugins.Clear(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } public void Dispose() => UnloadAll(); }

配套接口:

public interface IMyPlugin : IDisposable { string Name { get; } void Initialize(); void Shutdown(); }

使用方只需要:

using var manager = new PluginManager(); manager.LoadAll(@"D:\app\plugins");

这个骨架扩充方向很清晰:加配置热更新、加插件间通讯、加UI集成,都可以基于这个结构做扩展。

动态加载代码这块,说实话没有太多高深的算法,全靠基础API拼装,真正决定成败的是你有没有把加载上下文、接口约定、卸载时机、依赖冲突这几件事想清楚。我早期做插件功能时也踩过文件锁、类型不匹配的坑,后来把ALC方案用熟,这些问题基本都被框架层面消化掉了。你上手之后,建议先拿一个真实模块做试点,别一上来就把整个系统拆成插件化,否则调试成本会非常高。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询