C# Debug vs. Release 生成和 Visual Studio 中的调试——从新手到专家的单篇博文






4.97/5 (30投票s)
大多数关于 C# Debug 和 Release 生成配置“开箱即用”的文章和文档是在 Roslyn 出现之前编写的。我将重新审视 2018 年我们现在能够获得哪些底层功能。
- 下载 PowerShell 脚本以检查程序集的 DebuggableAttribute,打包为 ZIP。- 585 B
- 用于检查程序集 DebuggableAttribute 的 PowerShell 脚本的存储库
引言
“开箱即用”,C# 的生成配置是 Debug 和 Release。
我原本计划写一篇入门文章,但当我深入研究内部机制时,我开始探索 Roslyn 与先前评论/官方文档内容的实际行为。因此,我将从基础开始,但也会为更有经验的 C# 开发者提供一些内容。
免责声明:对于 C# 以外的 .NET 语言,详细信息可能会略有不同。
回顾 C# 编译
C# 源代码经过 2 个编译步骤才能成为可执行的 CPU 指令。
作为您的 持续集成 的一部分,第一步将在构建服务器上完成,第二步将在应用程序运行时稍后进行。在 Visual Studio 中本地工作时,为了方便起见,这两个步骤都会在从 Debug 菜单启动应用程序后执行。
编译步骤 1:应用程序由 C# 编译器构建。您的代码被转换为通用中间语言 (CIL),它可以被任何支持 CIL 的环境执行(从现在开始,我将简称为 IL)。请注意,生成的程序集不是可读的 IL 文本,而是作为二进制数据的元数据和字节码(有工具可以以文本格式查看 IL)。
将执行一些代码优化(稍后将详细介绍)。
编译步骤 2:Just-in-time (JIT) 编译器会将 IL 转换为您的机器 CPU 可以执行的指令。但这并不会一次性全部完成——在正常的操作模式下,方法在被调用时进行编译,然后缓存以供将来使用。
JIT 编译器只是构成通用语言运行时 (CLR) 的众多服务之一,它使 CLR 能够执行 .NET 代码。
大部分代码优化将在此时执行(稍后将详细介绍)。
什么是编译器优化(一句话概括)?
这是一个改进执行速度、代码大小、功耗以及在 .NET 的情况下 JIT 编译代码所需时间等因素的过程——所有这些都不会改变功能,即程序员的原始意图。
为什么我们要在本文中关注优化?
我已说明两个步骤的编译器都会优化您的代码。Debug 和 Release 生成配置之间的关键区别在于是否禁用优化,因此您需要了解优化的影响。
C# 编译器优化
C# 编译器不会进行太多优化。它依赖于“...Jitter 来完成生成实际机器代码时的繁重优化工作。” (Eric Lippert)。但它仍然会削弱调试体验。您不需要深入了解 C# 优化知识也能理解本文,但我将举一个例子来说明其对调试的影响。
IL nop 指令(无操作)
nop 指令在低级编程中有多种用途,例如包括微小的、可预测的延迟或覆盖您希望删除的指令。在 IL 中,它用于帮助在源代码中设置的断点在调试时表现可预测。
如果我们查看禁用优化后的生成 IL
此 nop 指令直接映射到大括号,使我们可以在其上设置断点。
如果启用了优化,这将从 C# 编译器生成的 IL 中优化掉,对您的调试体验有明显影响。
有关 C# 编译器优化的更详细讨论,请参阅 Eric Lippert 的文章:《What does the optimize switch do?》。这里还有一个关于优化前后 IL 的优秀评论 here。
JIT 编译器优化
尽管 JIT 编译器需要在运行时快速完成工作,但它会进行大量优化。关于其内部机制的信息不多,它是一个非确定性的野兽(就像阿甘正传的巧克力盒子)——根据许多因素,它生成的本机代码会有所不同。即使您的应用程序正在运行,它也在进行性能分析,并可能重新编译代码以提高性能。有关 JIT 编译器优化示例的绝佳集合,请查看 Sasha Goldshtein 的文章。
我将只看一个例子来说明优化对您的调试体验的影响。
方法内联
为了展示 JIT 编译器进行的真实优化,我将向您展示汇编指令。这只是 C# 中的一个模拟,以给您一个大致的概念。
假设我有
private long Add(int a, int b)
{
return a + b;
}
public void MethodA()
{
var r = Add(a, b);
}
JIT 编译器很可能会对此进行内联展开,将对 Add()
的调用替换为方法体
Add()
:
public void MethodA()
{
var r = a + b;
}
显然,尝试单步执行被移动的代码行会很困难,并且您的堆栈跟踪也会变少。
默认生成配置
现在您已经回顾了对 .NET 编译和两个“层级”优化的理解,让我们来看看“开箱即用”的 2 个生成配置。
相当直接——Release 是完全优化的,Debug 完全不优化,正如您现在所知,这对于代码调试的难易程度至关重要。但这只是关于 debug 和 optimize 参数可能性的表面看法。
深入探讨 optimize 和 debug 参数
我试图从 Roslyn 和 mscorlib 代码中绘制这些图表,包括 CSharpCommandLineParser.cs、CodeGenerator.cs、ILEmitStyle.cs、debuggerattributes.cs、Optimizer.cs 和 OptimizationLevel.cs。蓝色平行四边形代表命令行参数,绿色代表代码库中的结果值。
OptimizationLevel 枚举
OptimizationLevel.Debug
禁用 C# 编译器的所有优化,并通过 DebuggableAttribute.DebuggingModes
禁用 JIT 优化,借助 ildasm,我们可以看到这是
考虑到这是小端字节序,它读作 0x107,即 263,相当于:Default
、DisableOptimizations
、IgnoreSymbolStoreSequencePoints
和 EnableEditAndContinue
(参见 debuggerattributes.cs)。
OptimizationLevel.Release
启用 C# 编译器的所有优化,并通过以下方式启用 JIT 优化:
DebuggableAttribute.DebuggingModes = ( 01 00 02 00 00 00 00 00 )
这仅仅是 DebuggingModes.IgnoreSymbolStoreSequencePoints
。
在这种优化级别下,“顺序点可能会被优化掉。因此,可能无法设置或命中断点。” 此外,“用户定义的局部变量可能被优化掉。调试时可能无法使用它们。” (OptimizationLevel.cs)。
IL 类型说明
IL 的类型由以下来自 ILEmitStyle.cs 的枚举定义。
如上图所示,C# 编译器生成的 IL 类型由 OptimizationLevel
决定;debug 参数不会改变这一点,除非 debug+ 与 OptimizationLevel 为 Release 时,即,除 debug+ 情况外,optimize 是唯一影响优化的参数——这与 Roslyn* 之前不同。
* 在 Jeffry Richter 的 CLR Via C# (2014) 中,他指出 optimize- 与 debug- 结合使用时,C# 编译器不优化 IL,而 JIT 编译器优化为本机代码。
ILEmitStyle.Debug
– 不优化 IL,并添加 nop 指令以将顺序点映射到 IL。ILEmitStyle.Release
– 执行所有优化。ILEmitStyle.DebugFriendlyRelease
– 只对不影响调试的 IL 进行优化。这是比较有趣的一种。它源于 debug+ 并且仅在优化后的生成(即OptimizationLevel.Release
)中生效。对于未优化(optimize-)的生成,debug+ 的行为与 debug 相同。
(CodeGenerator.cs)中的逻辑比我能描述的更清楚。
if(optimizations == OptimizationLevel.Debug)
{
_ilEmitStyle = ILEmitStyle.Debug;
}
else
{
_ilEmitStyle = IsDebugPlus() ?
ILEmitStyle.DebugFriendlyRelease :
ILEmitStyle.Release;
}
源代码文件 Optimizer.cs 中的注释指出,它们不省略任何用户定义的局部变量,并且不在语句之间将值保留在堆栈上。我很高兴读到这个,因为我在 ildasm 中对 debug+ 的实验感到有些失望,因为我只看到了局部变量的保留以及更多的堆栈推入和弹出!
没有明显的“去优化”,例如添加 nop 指令。
在 Visual Studio 中,似乎没有直接的方法可以为 C# 项目选择此 debug 标志?是否有人在生产生成中使用它?
debug, debug:full 和 debug:pdbonly 之间没有区别?
正确——尽管当前文档和帮助说明相反
它们都达到相同的效果——创建 .pdb 文件。查看 CSharpCommandLineParser.cs 可以证实这一点。为了保险起见,我还检查了使用 WinDbg 调试 pdbonly 和 full 值的功能。
它们对代码优化没有影响。
好的一面是,GitHub 上的文档更准确,尽管我认为仍然不够清楚 debug+ 的特殊行为。
我是新手……什么是 .pdb? 简单来说,.pdb 文件存储有关您的 DLL 或 EXE 的调试信息,这将帮助调试器将 IL 指令映射到原始 C# 代码。
关于 debug+?
debug+ 是它自己的东西,不能用 full 或 pdbonly 后缀。一些评论者认为它与 debug:full
相同,但这并不完全准确,如上所述——当与 optimize- 一起使用时,它的确相同,但当与 optimize+ 一起使用时,它具有自己独特的行为,如上文 DebugFriendlyRelease
下所述。
还有 debug- 或根本没有 debug 参数?
CSharpCommandLineParser.cs
中的默认值是
bool debugPlus = false;
bool emitPdb = false;
debug-
的值是
case "debug-":
if (value != null)
break;
bool emitPdb = false;
bool debugPlus = false;
因此,我们可以自信地说 debug-
和根本没有 debug 参数会产生相同的单个效果——不创建 .pdb 文件。
它们对代码优化没有影响。
禁止在模块加载时进行 JIT 优化
Visual Studio 中 Options->Debugging->General 下的一个复选框;这是Visual Studio 调试器的一个选项,不会影响您生成的程序集。
您现在应该认识到 JIT 编译器执行了大部分重要的优化,并且是映射回原始源代码进行调试的更大障碍。启用此选项后,调试器将要求 JIT 编译器忽略 DisableOptimizations
。
直到 2015 年左右,此选项默认是启用的。我之前引用了 CLR Via C#,在该书中,我们可以向 csc.exe 提供 optimize- 和 debug- 参数,并获得未优化的 C#,然后由 JIT 编译器进行优化——因此,在 Visual Studio 调试器中禁止 JIT 优化会有些用处。然而,现在任何被 JIT 优化的代码都由于 C# 优化而降低了调试体验,Microsoft 决定默认禁用它,其假设是如果您在 Visual Studio 中运行 Release 生成,您可能希望看到优化生成的行为,而牺牲调试。
通常,您只需要在需要调试外部源(如 NuGet 包)的 DLL 时才启用它。
如果您尝试从 Visual Studio 附加到生产环境中运行的 Release 生成(带有 .pdb 或其他符号源),则指示 JIT 编译器不进行优化的另一种方法是添加一个与可执行文件同名的 .ini 文件,并包含以下内容:
[.NET Framework Debugging Control]
AllowOptimize=0
仅我的代码……什么?
默认情况下,Options->Debugging→Enable Just My Code 是启用的,并且调试器将优化后的代码视为非用户代码。在启用此选项的情况下,调试器甚至不会尝试处理非用户代码。
您可以取消选中此选项,然后理论上可以命中断点。但是,您现在正在调试由 C# 和 JIT 编译器优化的代码,这些代码几乎不匹配您的原始源代码,并且调试体验极差——单步执行代码将不可预测,您很可能无法获取局部变量的值。
只有在处理您拥有 .pdb 文件的他人的 DLL 时,您才应该真正更改此选项。
仔细查看 DebuggableAttribute
上面我提到了使用 ildasm 检查程序集的清单以检查 DebuggableAttribute
。我还编写了一个简单的 PowerShell 脚本来生成更友好的结果(可在文章开头的下载链接中找到)。
Debug 生成
Release 生成
您可以忽略 IsJITTrackingEnabled
,因为它自 .NET 2.0 以来一直被 JIT 编译器忽略。JIT 编译器始终在调试期间生成跟踪信息,以将 IL 与其机器码进行匹配,并跟踪局部变量和函数参数的存储位置(来源)。
IsJITOptimizerDisabled
仅检查 DebuggingFlag
s 是否包含 DebuggingModes.DisableOptimizations
。这是启用 JIT 编译器优化的标志。
DebuggingModes.IgnoreSymbolStoreSequencePoints
告诉调试器从 IL 中推断顺序点,而不是加载 .pdb 文件,这会影响性能。顺序点用于将 IL 代码中的位置映射到 C# 源代码中的位置。JIT 编译器不会将任何 2 个顺序点编译为单个本机指令。有了这个标志,JIT 将不会加载 .pdb 文件。我不确定为什么 C# 编译器会将此标志添加到优化后的生成中——您有什么看法?
要点
debug-
(或根本没有debug
参数)现在意味着:不创建 .pdb 文件。debug
、debug:full
和debug:pdbonly
现在都会导致输出.pdb
文件。debug+
也将执行相同的操作,如果它与optimize-
一起使用。debug+
在与optimize+
一起使用时很特殊,它会生成更容易调试的 IL。- 每个“层级”的优化(C# 编译器,然后是 JIT)都会进一步削弱您的调试体验。对于
optimize+
,您将获得两个“层级”,而对于optimize-
,您将获得其中任何一个。 - 自 .NET 2.0 以来,无论是通过 VS 还是 csc.exe 生成,JIT 编译器始终会生成跟踪信息,而不管
IsJITTrackingEnabled
属性如何,DebuggableAttribute
现在始终存在。 - 可以通过 Visual Studio 调试的通用调试选项“在模块加载时禁止 JIT 优化”来指示 JIT 忽略
IsJITOptimizerDisabled
。也可以通过 .ini 文件来指示。 optimized+
将创建调试器视为非用户代码的二进制文件。您可以禁用“仅我的代码”选项,但请期望调试体验严重下降。
您可以选择
Debug:debug|debug:full|debug:pdbonly optimize+
Release:debug-|no debug argument optimize+
DebugFriendlyRelease:debug+ optimize+
但是,DebugFriendlyRelease
只能通过直接调用 Roslyn csc.exe 来实现。我很想听听任何使用过它的人的经验。我们需要什么场景来生成优化程度较低的 Release 生成 IL 呢?也许后续文章可以探讨 JIT 编译器与完全优化 IL 相比,我们能获得哪些程序集/性能?
文章历史
- 2018 年 4 月 13 日 - 第一版
- 2018 年 4 月 15 日 - 添加了 PowerShell 脚本供下载
- 2018 年 4 月 17 日 - 根据用户评论修复了输入错误