dnSpy多版本支持:.NET Framework与.NET Core调试方案

dnSpy多版本支持:.NET Framework与.NET Core调试方案

【免费下载链接】dnSpy 【免费下载链接】dnSpy 项目地址: https://gitcode.com/gh_mirrors/dns/dnSpy

一、引言:解决.NET跨版本调试的痛点

在.NET开发中,开发者常常面临需要调试不同框架版本应用程序的挑战。无论是维护遗留系统还是开发新应用,都可能需要同时处理.NET Framework和.NET Core(现为.NET 5+)项目。然而,不同框架的调试机制存在显著差异,这给开发者带来了诸多困扰:

  • 调试环境不一致:.NET Framework和.NET Core的调试引擎架构不同,需要不同的调试配置
  • 版本兼容性问题:不同.NET版本的运行时结构和API差异导致调试方法不通用
  • 调试工具链复杂:需要掌握多种调试工具和技术,增加了学习成本

dnSpy作为一款功能强大的.NET反编译和调试工具,提供了对多版本.NET框架的全面支持。本文将详细介绍如何使用dnSpy实现对.NET Framework和.NET Core应用程序的高效调试,帮助开发者轻松应对跨版本调试挑战。

读完本文,你将能够:

  • 理解dnSpy如何实现对不同.NET版本的支持
  • 掌握在dnSpy中配置和调试.NET Framework应用的方法
  • 学会使用dnSpy调试各种类型的.NET Core应用
  • 解决常见的跨版本调试问题
  • 了解dnSpy调试引擎的内部工作原理

二、dnSpy的多版本.NET调试架构

2.1 整体架构设计

dnSpy采用模块化设计,通过不同的调试引擎组件支持多种.NET版本。其核心架构如下:

mermaid

2.2 关键组件解析

dnSpy的调试功能主要依赖以下关键组件:

  1. CorDebug引擎:基于Microsoft的CorDebug接口实现,提供对CLR的底层调试支持
  2. 应用域(AppDomain)管理:处理程序域的创建、加载和卸载
  3. 模块(Module)管理:跟踪和管理加载的程序集模块
  4. 线程(Thread)管理:监控和控制调试进程中的线程
  5. 断点(Breakpoint)系统:支持源码断点、IL断点和内存断点
  6. 表达式求值器:在调试过程中计算表达式的值

这些组件协同工作,为不同版本的.NET框架提供统一的调试体验。

2.3 版本检测机制

dnSpy通过以下方式检测目标程序的.NET版本:

public static string[] GetInstalledFrameworkVersions() {
    var list = new List<string> {
        dnSpy_Debugger_DotNet_CorDebug_Resources.DbgAsm_Autodetect,
    };
    list.AddRange(DotNetHelpers.GetInstalledFrameworkVersions().OrderByDescending(x => x));
    return list.ToArray();
}

这段代码来自DotNetFrameworkStartDebuggingOptionsPage类,它通过查询系统注册表和文件系统来检测已安装的.NET Framework版本,并按降序排列,方便用户选择。

三、调试.NET Framework应用程序

3.1 配置调试环境

dnSpy为.NET Framework应用程序提供了专门的调试配置页面:

public override string DisplayName => ".NET Framework";

public ListVM<string> RuntimeVersionsVM { get; } = new ListVM<string>(CreateRuntimeVersions());

static string[] CreateRuntimeVersions() {
    var list = new List<string> {
        dnSpy_Debugger_DotNet_CorDebug_Resources.DbgAsm_Autodetect,
    };
    list.AddRange(DotNetHelpers.GetInstalledFrameworkVersions().OrderByDescending(x => x));
    return list.ToArray();
}

在调试.NET Framework应用时,你可以:

  1. 自动检测或手动选择.NET Framework版本
  2. 配置启动参数和环境变量
  3. 设置工作目录
  4. 启用/禁用托管调试助手(MDA)

3.2 调试步骤详解

3.2.1 启动新的.NET Framework进程
  1. 点击"文件" -> "开始调试" -> "启动新进程"
  2. 在弹出对话框中选择".NET Framework"选项卡
  3. 浏览并选择要调试的可执行文件(.exe)
  4. 选择目标.NET Framework版本(通常自动检测)
  5. 设置命令行参数和环境变量(如需要)
  6. 点击"确定"开始调试
3.2.2 附加到正在运行的.NET Framework进程
  1. 点击"文件" -> "开始调试" -> "附加到进程"
  2. 在进程列表中选择目标.NET Framework进程
  3. 点击"附加"按钮
  4. 如果是32位进程在64位系统上运行,确保选择了正确的调试器架构
3.2.3 设置断点和监视
  1. 在反编译的代码窗口中点击行号旁的空白区域设置断点
  2. 使用"调试"菜单或快捷键(F9)切换断点
  3. 在"监视"窗口中添加要监视的变量或表达式
  4. 使用"局部变量"窗口查看当前作用域内的变量

3.3 处理特殊场景

3.3.1 调试COM互操作代码

当调试涉及COM互操作的.NET Framework代码时,dnSpy提供了特殊支持:

// 处理COM对象的调试代码示例
internal static CorValue? GetDereferencedValue(CorValue? value) {
    if (value is null)
        return null;
    if (value.IsReference)
        return value.IsNull ? null : value.DereferencedValue;
    return value;
}

这段代码来自DbgEngineImpl类,用于正确处理COM对象的引用和值之间的转换。

3.3.2 调试混合模式程序集

对于包含托管和非托管代码的混合模式程序集,dnSpy可以同时调试两种代码:

  1. 自动识别混合模式程序集
  2. 在托管代码和非托管代码之间无缝切换
  3. 提供对非托管代码的反汇编视图

3.4 常见问题及解决方案

问题解决方案
无法附加到进程确保dnSpy和目标进程的位数匹配(都是32位或都是64位)
断点不命中检查是否启用了优化,尝试禁用优化或重新生成符号
调试器崩溃尝试禁用托管调试助手(MDA),特别是在调试32位应用时
符号无法加载检查符号路径设置,手动指定符号文件(.pdb)位置

四、调试.NET Core应用程序

4.1 .NET Core调试架构

.NET Core采用了与.NET Framework不同的模块化架构,dnSpy通过专门的调试引擎支持这一架构:

mermaid

dnSpy使用dbgshim库与.NET Core运行时通信,该库是.NET Core调试工具链的一部分:

public static string GetDebugShimFilename(int bitness) {
    var filename = FileUtilities.GetNativeDllFilename("dbgshim");
    var basePath = Contracts.App.AppDirectories.BinDirectory;
#if NETFRAMEWORK
    basePath = Path.Combine(basePath, "debug", "core");
    switch (bitness) {
    case 32:	return Path.Combine(basePath, "x86", filename);
    case 64:	return Path.Combine(basePath, "x64", filename);
    default:	throw new ArgumentOutOfRangeException(nameof(bitness));
    }
#elif NET
    string path = Path.Combine(basePath, filename);
    // 如果dnSpy是发布版本,文件将位于应用目录中
    if (File.Exists(path))
        return path;
    // 如果从源代码运行dnSpy,文件将位于不同目录
    return Path.Combine(basePath, "runtimes", RuntimeInformation.RuntimeIdentifier, "native", filename);
#else
#error Unknown target framework
#endif
}

4.2 调试不同类型的.NET Core应用

4.2.1 调试自包含部署(SCD)应用

自包含部署的.NET Core应用包含自己的运行时,调试这类应用:

  1. 选择应用的可执行文件(通常是.exe文件)
  2. dnSpy自动检测这是一个自包含部署的应用
  3. 调试过程与普通.NET应用类似
4.2.2 调试框架依赖部署(FDD)应用

框架依赖部署的应用依赖系统中安装的.NET Core运行时:

  1. 选择应用的主DLL文件
  2. 在调试配置中指定dotnet可执行文件路径
  3. 设置命令行参数为exec your_app.dll

dnSpy通过以下代码检测.NET Core可执行文件:

public static bool IsDotNetExecutable(string filename) {
    if (!File.Exists(filename))
        return false;
    if (!PortableExecutableFileHelpers.IsExecutable(filename))
        return false;
    try {
        using (var peImage = new PEImage(filename)) {
            if ((peImage.ImageNTHeaders.FileHeader.Characteristics & Characteristics.Dll) != 0)
                return false;
            var dd = peImage.ImageNTHeaders.OptionalHeader.DataDirectories[14];
            if (dd.VirtualAddress == 0 || dd.Size < 0x48)
                return false;

            using (var mod = ModuleDefMD.Load(peImage, new ModuleCreationOptions())) {
                var asm = mod.Assembly;
                if (asm is null)
                    return false;

                var ca = asm.CustomAttributes.Find("System.Runtime.Versioning.TargetFrameworkAttribute");
                if (ca is null)
                    return false;
                if (ca.ConstructorArguments.Count != 1)
                    return false;
                string s = ca.ConstructorArguments[0].Value as UTF8String;
                if (s is null)
                    return false;

                // 解析TargetFrameworkAttribute值
                var values = s.Split(new char[] { ',' });
                if (values.Length >= 2 && values.Length <= 3) {
                    var framework = values[0].Trim();
                    if (framework == ".NETCoreApp")
                        return true;
                }

                return false;
            }
        }
    }
    catch {
    }
    return false;
}
4.2.3 调试单文件应用

.NET Core 3.0及以上支持将应用打包为单个文件,dnSpy通过特殊处理支持这类应用:

// .NET Core 2.x, 早期 .NET Core 3.0 预览版
internal static bool IsDotNetAppHostV1(string filename, [NotNullWhen(true)] out string? dllFilename) {
    // 检测apphost.exe的逻辑
    // .NET Core 1.x: apphost是重命名的dotnet.exe,假设托管dll与apphost同名但扩展名为dll
    // .NET Core 2.x-3.x: 托管dll的相对路径是exe的一部分,由MSBuild任务修补
    
    if (filename.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
        dllFilename = Path.ChangeExtension(filename, ".dll");
    else if (!filename.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
        dllFilename = filename + ".dll";
    else {
        dllFilename = null;
        return false;
    }
    if (!File.Exists(dllFilename))
        return false;
    if (PortableExecutableFileHelpers.IsPE(filename, out bool isExe, out bool hasDotNetMetadata) && (!isExe || hasDotNetMetadata))
        return false;
    if (!PortableExecutableFileHelpers.IsPE(dllFilename, out _, out hasDotNetMetadata) || !hasDotNetMetadata)
        return false;

    return true;
}

4.3 .NET Core特有的调试功能

4.3.1 调试自包含单文件应用

.NET Core 3.0引入了单文件应用部署模式,dnSpy通过解析应用主机( AppHost )来调试这类应用:

internal static bool TryGetAppHostEmbeddedDotNetDllPath(string apphostFilename, out bool couldBeAppHost, [NotNullWhen(true)] out string? dotNetDllPath) {
    dotNetDllPath = null;
    couldBeAppHost = false;
    if (!File.Exists(apphostFilename))
        return false;
    if (PortableExecutableFileHelpers.IsPE(apphostFilename, out _, out var hasDotNetMetadata) && hasDotNetMetadata)
        return false;
    try {
        var data = ReadBytes(apphostFilename, MaxAppHostExeSize);
        if (GetOffset(data, AppHostExeUnpatchedSignature) >= 0) {
            couldBeAppHost = true;
            return false;
        }
        if (GetOffset(data, AppHostExeSignature) < 0)
            return false;
        couldBeAppHost = true;
        // 从apphost中提取嵌入的DLL路径
        // ...
    }
    catch (IOException) {
    }
    return false;
}
4.3.2 调试ASP.NET Core应用

dnSpy可以调试ASP.NET Core应用,包括Web API和MVC应用:

  1. 启动ASP.NET Core应用
  2. 在dnSpy中附加到正在运行的进程
  3. 设置断点(可以在控制器方法中)
  4. 使用浏览器或其他客户端触发请求
  5. 断点命中后,可以检查请求上下文、路由数据等

4.4 .NET Core调试常见问题

问题解决方案
找不到dotnet.exe确保已安装.NET Core SDK,或手动指定dotnet.exe路径
单文件应用无法加载符号使用dotnet publish时添加/p:IncludeSymbolsInSingleFile=true参数
调试时应用立即退出检查是否设置了正确的工作目录和命令行参数
无法在Linux/macOS上调试确保安装了必要的依赖库:libunwind8liblttng-ust0

五、跨版本调试功能对比

5.1 功能支持矩阵

dnSpy为.NET Framework和.NET Core提供了丰富的调试功能,但两者之间存在一些差异:

功能.NET Framework.NET Core备注
断点设置
单步执行
变量监视
调用堆栈
异常捕获
COM对象调试.NET Core不支持COM
应用域调试.NET Core中的应用域概念已弱化
进程内并行调试
动态程序集调试
混合模式调试
编辑并继续部分支持.NET Core 3.0+支持有限的编辑并继续
托管调试助手.NET Core不支持MDA

5.2 性能对比

在不同.NET版本上,dnSpy的调试性能也有所差异:

mermaid

.NET Core及更高版本的启动时间更快,这主要得益于其模块化设计和即时编译( JIT )优化。

5.3 调试引擎实现差异

dnSpy在实现对两种框架的调试支持时,采用了不同的策略:

mermaid

对于.NET Framework,dnSpy直接使用CorDebug接口与CLR交互;而对于.NET Core,dnSpy通过dbgshim库与CoreCLR通信。

六、高级调试技术

6.1 调试动态加载的程序集

无论是.NET Framework还是.NET Core,dnSpy都能调试动态加载的程序集:

// 动态模块更新代码示例,来自DbgEngineImpl类
void UpdateDynamicModuleIds(DnModule dnModule) {
    debuggerThread.VerifyAccess();
    if (!dnModule.IsDynamic)
        return;
    var module = TryGetModule(dnModule.CorModule);
    if (module is null || !TryGetModuleData(module, out var data) || data.HasUpdatedModuleId)
        return;
    List<(DbgModule dbgModule, DnModule dnModule)>? updatedModules = null;
    lock (lockObj) {
        if (toAssemblyModules.TryGetValue(dnModule.Assembly, out var modules)) {
            for (int i = 0; i < modules.Count; i++) {
                dnModule = modules[i];
                if (!dnModule.IsDynamic)
                    continue;
                if (!toEngineModule.TryGetValue(dnModule.CorModule, out var em))
                    continue;
                if (!TryGetModuleData(em.Module, out data))
                    continue;
                dnModule.CorModule.ClearCachedDnlibName();
                var moduleId = dnModule.DnModuleId.ToModuleId();
                if (data.ModuleId == moduleId)
                    continue;
                data.UpdateModuleId(moduleId);
                if (dnModule.CorModuleDef is not null) {
                    dnModule.CorModuleDef.Name = moduleId.ModuleName;
                }
                if (updatedModules is null)
                    updatedModules = new List<(DbgModule, DnModule)>();
                updatedModules.Add((em.Module, dnModule));
            }
        }
    }
    if (updatedModules is not null) {
        foreach (var info in updatedModules) {
            var mdi = info.dnModule.CorModule.GetMetaDataImport(out uint typeToken);
            var scopeName = MDAPI.GetModuleName(mdi) ?? string.Empty;
            ((DbgCorDebugInternalModuleImpl)info.dbgModule.InternalModule).ReflectionModule!.ScopeName = scopeName;
        }
        dbgModuleMemoryRefreshedNotifier.RaiseModulesRefreshed(updatedModules.Select(a => a.dbgModule).ToArray());
    }
}

使用dnSpy调试动态加载的程序集:

  1. 确保"调试" -> "选项" -> "常规"中勾选了"自动加载动态程序集"
  2. 当程序动态加载程序集时,dnSpy会自动检测并显示它们
  3. 可以像普通程序集一样设置断点和监视变量

6.2 调试加密或混淆的程序集

dnSpy特别适合调试加密或混淆的程序集:

  1. 自动检测并解密内存中的程序集
  2. 提供高级的反混淆功能
  3. 支持动态解密和分析

对于使用强名称加密的程序集,可以使用dnSpy的"Make Everything Public"功能去除访问限制:

// 来自MakeEverythingPublic项目
public static void ProcessAssembly(AssemblyDef asm) {
    foreach (var module in asm.Modules) {
        foreach (var type in module.Types) {
            MakePublic(type);
            foreach (var method in type.Methods) {
                MakePublic(method);
                // 处理方法体
            }
            foreach (var field in type.Fields) {
                MakePublic(field);
            }
            foreach (var prop in type.Properties) {
                MakePublic(prop);
            }
            foreach (var evt in type.Events) {
                MakePublic(evt);
            }
        }
    }
}

6.3 多线程调试技巧

dnSpy提供了强大的多线程调试支持:

  1. 线程窗口:显示所有线程及其状态
  2. 线程切换:可以在不同线程之间切换调试上下文
  3. 线程冻结/解冻:可以临时冻结某些线程,专注于调试特定线程
// 线程状态更新代码示例,来自DbgEngineImpl类
void UpdateThreadProperties_CorDebug() {
    lock (lockObj) {
        foreach (var kv in toEngineThread.ToArray()) {
            var thread = kv.Key;
            var engineThread = kv.Value;
            engineThread.UpdateName(thread.Name);
            engineThread.UpdateState(thread.IsAlive ? DbgThreadState.Running : DbgThreadState.Terminated);
            engineThread.UpdatePriority(thread.Priority);
            engineThread.UpdateOSThreadId(thread.OSThreadId);
        }
    }
}

多线程调试最佳实践:

  • 使用条件断点过滤特定线程
  • 利用"冻结所有其他线程"功能隔离问题
  • 使用"线程标记"功能跟踪重要线程
  • 注意线程安全问题,如死锁和竞态条件

七、dnSpy调试引擎内部工作原理

7.1 异常处理机制

dnSpy的调试引擎能够捕获和处理各种异常:

// 异常处理代码示例,来自DbgEngineImpl类
void DnDebugger_DebugCallbackEvent(DnDebugger dbg, DebugCallbackEventArgs e) {
    string? msg;
    DbgModule? module;
    switch (e.Kind) {
    case DebugCallbackKind.Exception2:
        var e2 = (Exception2DebugCallbackEventArgs)e;
        DbgExceptionEventFlags exFlags;
        if (e2.EventType == CorDebugExceptionCallbackType.DEBUG_EXCEPTION_FIRST_CHANCE)
            exFlags = DbgExceptionEventFlags.FirstChance;
        else if (e2.EventType == CorDebugExceptionCallbackType.DEBUG_EXCEPTION_UNHANDLED) {
            exFlags = DbgExceptionEventFlags.SecondChance | DbgExceptionEventFlags.Unhandled;
            isUnhandledException = true;
        }
        else
            break;

        // 评估时忽略异常,除非是未处理的异常,这些必须始终报告
        if (dbg.IsEvaluating && e2.EventType != CorDebugExceptionCallbackType.DEBUG_EXCEPTION_UNHANDLED)
            break;

        module = TryGetModule(e2.CorFrame, e2.CorThread);
        var exObj = e2.CorThread?.CurrentException;

        string? exName = TryGetExceptionName(exObj);
        string? exMessage = TryGetExceptionMessage(exObj);
        int? hResult = TryGetExceptionHResult(exObj);

        objectFactory.CreateException(new DbgExceptionId(PredefinedExceptionCategories.DotNet, exName ?? "???"), exFlags, exMessage ?? dnSpy_Debugger_DotNet_CorDebug_Resources.ExceptionMessageIsNull, hResult, TryGetThread(e2.CorThread), module, GetMessageFlags());
        e.AddPauseReason(DebuggerPauseReason.Other);
        break;
    // 其他调试事件处理...
    }
}

7.2 断点实现机制

dnSpy支持多种类型的断点,其实现机制如下:

// 断点命中处理代码示例
void SendCodeBreakpointHitMessage_CorDebug(DbgCodeBreakpoint breakpoint, DbgThread? thread) {
    var location = breakpoint.Location;
    DbgCodeBreakpointHitOptions options;
    if (breakpoint is DbgDotNetCodeBreakpoint dotNetBp) {
        options = new DbgDotNetCodeBreakpointHitOptions(dotNetBp.CompilerId);
        if (dotNetBp.UpdateCurrentStatement)
            options = options with { UpdateCurrentStatement = true };
    }
    else
        options = DbgCodeBreakpointHitOptions.Default;
    
    objectFactory.CreateBreakpointHit(breakpoint, thread, location, options, GetMessageFlags());
}

dnSpy支持的断点类型:

  1. 源代码断点:基于源代码行号
  2. IL断点:基于中间语言指令
  3. 内存断点:基于内存地址
  4. 条件断点:满足特定条件时触发
  5. 函数断点:当特定函数被调用时触发

7.3 符号加载过程

符号加载对于调试体验至关重要,dnSpy采用了复杂的符号加载机制:

mermaid

dnSpy支持多种符号源:

  • 本地符号文件
  • 符号服务器(如Microsoft符号服务器)
  • 嵌入式符号
  • 可移植PDB

八、总结与展望

8.1 关键知识点回顾

通过本文,我们了解了dnSpy如何支持.NET Framework和.NET Core应用程序的调试:

  1. 架构设计:dnSpy采用模块化设计,通过不同的调试引擎支持多种.NET版本
  2. 配置方法:针对不同.NET版本,dnSpy提供了专门的调试配置选项
  3. 调试流程:详细介绍了在dnSpy中调试两种框架应用程序的步骤
  4. 功能对比:分析了dnSpy在不同.NET版本上的功能支持差异
  5. 高级技巧:探讨了动态加载程序集、混淆代码和多线程调试等高级主题
  6. 内部原理:深入了解了dnSpy调试引擎的异常处理、断点机制和符号加载过程

8.2 dnSpy调试功能未来发展

随着.NET平台的不断发展,dnSpy也在持续改进其调试功能:

  1. 更好的.NET 6+支持:优化对最新.NET版本的调试支持
  2. 改进的编辑并继续:扩展对.NET Core的编辑并继续支持
  3. 性能优化:减少调试开销,提高大型应用的调试性能
  4. 新UI体验:改进调试相关的用户界面
  5. 扩展API:提供更丰富的API,支持自定义调试工作流

8.3 学习资源与社区支持

要深入学习dnSpy调试功能,可以参考以下资源:

  • 官方文档:dnSpy的GitHub仓库提供了详细的文档和使用指南
  • 社区论坛:dnSpy用户社区可以解答各种使用问题
  • 源代码:dnSpy是开源项目,可以通过阅读源代码深入理解其工作原理
  • 视频教程:有许多优质的视频教程讲解dnSpy的高级使用技巧

dnSpy作为一款强大的.NET反编译和调试工具,为开发者提供了跨版本的.NET调试能力,无论是维护遗留的.NET Framework应用,还是开发基于.NET Core的新应用,dnSpy都能提供高效、可靠的调试体验。

九、附录:dnSpy调试常见问题解答

9.1 安装与配置问题

Q: 如何确保dnSpy能调试32位和64位应用程序?
A: dnSpy提供了32位和64位两个版本,确保使用与目标应用程序位数匹配的dnSpy版本。可以同时安装两个版本,根据需要选择使用。

Q: dnSpy需要安装.NET SDK吗?
A: 不需要。dnSpy是自包含的应用程序,但调试.NET Core应用时,需要目标机器上安装相应的.NET Core运行时或SDK。

9.2 调试问题

Q: 为什么断点显示为空心圆圈,无法命中?
A: 这通常表示符号未加载或不匹配。尝试:

  1. 确保符号文件(.pdb)可用
  2. 清理并重新生成项目
  3. 在dnSpy中手动加载符号

Q: 如何调试没有源代码的程序集?
A: dnSpy的反编译功能可以生成可读性强的C#代码,你可以直接在反编译代码上设置断点和监视变量,就像调试有源代码的应用程序一样。

9.3 高级问题

Q: 能否使用dnSpy调试正在运行的ASP.NET Core应用?
A: 可以。只需:

  1. 在dnSpy中选择"附加到进程"
  2. 选择正在运行的ASP.NET Core进程(通常是dotnet.exe或自包含应用的可执行文件)
  3. dnSpy会自动加载相关模块,之后就可以设置断点调试了

Q: 如何使用dnSpy调试加密的程序集?
A: dnSpy可以调试内存中的程序集,即使它们在磁盘上是加密的。当加密的程序集被加载到内存后,dnSpy可以从内存中提取并反编译它,然后进行调试。

【免费下载链接】dnSpy 【免费下载链接】dnSpy 项目地址: https://gitcode.com/gh_mirrors/dns/dnSpy

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值