本文将会从基础到高级,从简单到复杂的顺序,告诉大家如何调试 dotnet 系应用,特别是桌面端应用。本文将会向大家介绍使用 VisualStudio 大量的功能用来提高调试效率,穿插着也会介绍一些好用的调试辅助工具,以及如何编写方便调试的代码,期望大家通过阅读本文能有所收获
在本文的开始是先向大家介绍一些常见的套路,即遇到什么问题应该调试。然后从常见套路常见问题过渡到一些比较棘手问题,例如遇到我不熟悉的代码如何调试,遇到库里面的代码出问题如何调试。在这个介绍的过程里面,还会穿插介绍一些调试经验
除了调试具体的应用问题之外,本文还包括性能调试。比如有小伙伴说卡,那么卡在哪,如何找到卡的代码。有小伙伴说占用内存,那么占用内存的代码是什么?
对于客户端应用的调试还包括渲染方面调试,比如我觉得我软件显示比较慢,那么是渲染卡还是主线程卡
由于调试是一个庞大且系统的知识和经验的话题,我所了解的调试也具有很大的局限性,欢迎伙伴们告诉我一些你的调试方法
课前测试
带着问题阅读效果将会更好,以下是我列举的和本文相关的一些课前问题
- 如何看待断点调试
- 断点调试应该优先考虑,只要代码能做断点调试的优先进行断点调试
- 断点调试是其他手段的一个辅助,在大多数调试方法里面都用到断点调试
- 在断点调试过程可以了解当前上下文变量状态,以及代码执行逻辑,甚至更改变量值更改执行顺序
- 在断点调试库或框架中最重要的是符号文件,可以通过 dotPeek 反编译生成
- 断点调试一定需要符号文件配合
- 如何看待异常调试
- 在 VisualStudio 使用第一次机会异常,无论用户有没有吞这个异常都能抓到
- 进行异常调试的套路是先通过输出窗口找到对应的异常,再从异常窗口开启
- 异常调试过程在调用堆栈可以发现调用方法的逻辑是否合预期
- 不需要符号文件和源代码都可以进行异常调试
- 异常调试需要依赖具体代码实现,如果在代码实现过程没有考虑异常,那么将无法进行异常调试
- 如何看待多线程调试
- 多线程调试过程会被断点影响,可以通过断点输出的方式降低多线程影响
- 多线程的死锁问题可以通过并行堆栈找出
- 多线程问题可以通过随机暂停方式找到对应的代码
- 在多线程中的控制台输出也会影响多线程代码运行顺序
- 调试过程重点关注多个线程访问到的值的变化以及方法调用顺序
- 在 VisualStudio 可以通过线程窗口看到当前程序开启的所有线程,同时对应线程的调用堆栈
- 如何调试已发布库?
- 在开始调试之前,需要先确定自己写的代码是否清真。应该假定调用的库的接口是符合预期的,和所使用的框架是稳定的
- 如果拿到的库不是稳定的库,或从接口实现上无法明确。可以构建出测试代码用于调试库逻辑
- 在不明确是否库的问题还是自己代码的问题的时候,在确定库代码的输入对应的输出的时候,可以自己模拟创建库的代码进行调试
- 现在微软开源了很多框架,在调试过程应该尽可能将开源代码加入调试
- 在说到性能问题的时候说的方面有哪些?
- CPU 性能
- 单线程忙碌
- 过多 IO 读写
- 渲染性能
- 内存占用
- 面对无从下手的调试的时候可以尝试哪些方向?
- 最短复现,找到最容易复现的方法
- 最小代码模拟测试,确定是否框架或库的问题
- 通过异常代码搜寻以及最短复现方法是否有相关博客
- 通过大量日志追踪
- 进行随机断点
- 从入口函数开始断点调试进入
- 在用户已经出问题的设备上,通过 dnspy 和 VS 附加调试或获取 DUMP 调试
- 查看是否在软件上版本不存在此问题,在上上版本不存在此问题等,通过二分代码找到出代码提交
- 在各大社交网络进行询问
从题目上看,最简单的调试方法从断点调试开始。如果大家想要知道题目的答案是什么,以及为什么是这个答案,那么请大家继续阅读本文内容
目录
断点调试是一切代码调试的基础。科班出身的伙伴应该都听过这句话:算法+数据结构=程序。这里多做一些描述,方便学渣和非科班出身的伙伴理解。在代码调试方面,代码调试里面能获取到的信息的很重要两点就是: 现在的应用程序运行到哪了,此刻的应用程序的状态是什么。应用程序运行到哪了,下一步运行什么?这就是应用程序的逻辑算法,这里的算法是广义的算法描述,更多指的是逻辑。例如当前运行到了 A 语句,下一步准备运行 B 语句。而此刻的应用程序的状态则是了解当前的数据结构以及数据结构的参数值。对于应用程序来说,绝大部分的逻辑都是根据现有的状态决定后续的步骤和分支。例如在执行完成 B 语句之后,需要根据应用程序的某个参数决定是执行 C 语句还是回到 A 语句等。换句话说,能了解应用程序运行到哪和应用程序当前的状态,就可以了解到应用程序是否在符合预期的工作,更进一步推测过去发生了什么,和未来简要执行哪些逻辑导致发生什么,从而在应用程序非预期工作时,了解到非预期的工作的原因,从而调试出问题,进而才能更好解决
其实代码调试和推理是有很多共通之处的。看过推理剧的伙伴就知道,在推理剧里面最重要的就是信息,或者说看推理剧最怕的就是剧透。因为推理剧一个迷人的点在于信息不对称,逐步展开更多的情报信息,从而推进剧情。调试一个问题时,就和推理差不多,但也有一些不同的是在于如果每个问题都好像看一集名侦探一样,那就太累心了。代码调试是工程的一个部分,工程很讲究效率,也讲究将复杂的问题简单化工程化。在代码调试里面,获取到现在的应用程序运行到哪和此刻的应用程序的状态,相当于在推理剧里面开启上帝模式,可以了解到各方各面的信息,有了各个方面的信息,无疑将会让推理变得更加简单。很多推理剧都需要很高的智商或者说是脑洞,然而我和大部分开发者都没有到万年小学生(某滚筒洗衣机,动漫人物)的水平,那就需要应用工程学的思想,降低整个问题的复杂度,让咱能理解和进行推理。降低问题的复杂度,是需要在调试之前所编写的代码里面预埋一些逻辑,进行一些设计,才能让调试更加方便
本文将会从宇宙第一 IDE 的 Visual Studio 开始和大家介绍各个工具的使用方法和调试的手段,介绍完成工具之后,将会告诉大家一些调试的经验,以及我所遇到的调试经历,穿插告诉大家如何编写代码会更方便调试,帮助大家编写出方便调试的代码
断点调试
最最常见的调试方法就是从 VisualStudio 中打开源代码,构建项目,按下 F5 运行项目,进入调试模式,在调试模式里面就可以通过断点的方法调试
断点调试可以用来做什么?可以调试分支,可以调试执行逻辑,可以调试当前运行的值等等,如上文所述
在进行断点调试的时候建议使用 DEBUG 版进行调试,此时几乎可以在任意的代码里面添加断点,而且大量的(或者说几乎所有的)变量都不会被优化掉,调试起来更加方便。与 DEBUG 对应的是 RELEASE 版,使用 Release 版将会让应用程序运行更快,变量(准确来说是对象)回收更加及时,但同时更加难以调试,例如断点无法打上等。关于 Debug 和 Release 的其他差别,还请自行了解
在遇到任何需要调试的问题的时候,第一个应该做的和应该考虑的是通过断点调试进行调试
例如我在调试下面的代码的时候,发现软件没有按照我预期的运行
if (foo)
{
// 执行某段逻辑,但是这段逻辑没有按照期望被运行
}
此时我应该通过断点,将断点放在判断这句话。接下来将告诉大家如何在 Visual Studio 里面添加断点的方法
添加断点方法
添加断点有很多方法。最常用的是在需要调试的代码里面,将光标定位到需要调试的代码这一行,默认快捷键按下 F9 添加断点
或者从代码这一行的左边点击一下就可以添加断点,添加断点成功,将可以在代码的左边看到一个红点。当然,这句话的前提是你用的是默认的 Visual Studio 的主题,要是你很喜欢艺术,改了断点的颜色,那当我啥都没说
断点可以在运行调试之前添加,也可以在调试的过程添加断点。添加成功了断点则可以在代码左边看到红点,添加失败将会显示白点。当代码执行到断点的地方,程序将会停在断点这里
除了在打开代码文件,在某一行进行断点之外,还可以点击工具栏的 调试-窗口-断点 打开断点设置。通过断点设置,可以在更多的地方加上断点
点击添加按钮,添加函数断点,函数断点需要添加限定符,完全的表达式如下
命名空间.类.方法(参数)
例如
WegaljifoWhelbaichewair.Program.Main(string[])
但是一般都可以简写,如不存在重载方法的时候,不需要添加参数,如上面代码可以去掉 string[]
在没有重载的主函数。如不存在多重命名冲突的时候,可以去掉命名空间。可以多试试,有时候 Visual Studio 也会逗比一下,如果简写失败,那就试试写全表达式
另外,在调用堆栈里面也可以设置断点,例如在进入某个断点的时候,程序暂停,此时可以通过 调试-窗口-调用堆栈 打开调用堆栈,在调用堆栈里面可以看到进入到当前这一行代码调用的方法顺序
在对应的调用方法右击点击断点可以新建断点
最少用到的添加断点的方法是在反编译窗口里面添加断点,点击调试-窗口-反编译在反编译窗口里面右击也可以添加断点,这是这个断点将会进入反编译调试,调试难度比较高,属于压箱底手段
Use breakpoints in the debugger - Visual Studio
除了以上常用的添加断点方法之外,还有很多关于断点调试的使用方法,例如觉得在循环里面添加断点时,循环不断进入,觉得很烦,可以试试加上条件断点,只有在满足一定的条件下才进入的断点。这些细节部分本文就不再描述。详细的断点调试请看 VisualStudio 断点调试详解
变量窗口
在进入断点的时候可以做什么?可以查看当前运行到这一行代码的时候,各个变量的值。通过各个变量的值,可以了解当前的应用程序的状态,从而进行了解到应用程序是如何运行
了解当前应用程序状态的各个变量,可以通过 Visual Studio 的变量窗口进行了解。变量窗口常见的有三类: 局部变量窗口,自动变量窗口,监视窗口
点击调试-窗口-局部变量可以打开局部变量窗口,局部变量也就是本方法使用到的局部变量。这里的一个特殊的局部变量是 this
也就是当前对象本身,可以通过这个变量拿到当前对象的成员,例如当前对象的属性和字段的值
同理还有自动窗口,在自动窗口还会显示在上下文用到的变量,一般使用自动窗口会更多。自动窗口如名字所说,这是 Visual Studio 使用智能(zhang)的方法给大家提供的,有可能不符合预期,这也就需要自动变量窗口和局部变量窗口配合一起,才能更好的调试
通过自动窗口或局部变量可以看到每个变量是什么,进而了解应用程序当前的状态,从而了解当前的代码为什么这样执行
额外的,默认的 VisualStudio 将会开启隐函数求值和属性求值,也就是说默认将会调用每个对象的 ToString 方法,将 ToString 方法返回值显示出来,调用属性的 get 方法获取属性返回值,而此过程也许会影响到某些业务逻辑,或者在一些复杂应用调试的时候,因为 VisualStudio 的不安全方式访问而让应用程序炸掉。如果不期望开启此功能,可以在 工具-选项-调试 去掉启用属性求值和其他隐函数调用选项
反过来,如果看到自己在调试的时候,在自动窗口或局部变量看不到属性的内容,提示关闭隐函数求值等,相信大家也知道从哪里打开
单步调试
在进入断点之后,就可以通过单步的方法知道程序运行的逻辑,通过单步可以看到代码是如何运行的
在 VisualStudio 提供了逐语句和逐过程,这里的不同点在于逐语句是一行行运行,同时遇到了调用,会进入到方法里面。而逐过程则是在遇到方法的时候,直接跳过方法。小伙伴可以按照自己的需要进行选择,建议使用快捷键进行调试,逐语句是 F11 逐过程是 F10 配合断点时候,如在遇到某些很长的代码的时候,这里面有一段是不关心的,可以使用 F5 继续运行跳过,同时在关心的部分,通过断点让 F5 继续运行的程序会进入断点
在进行单步调试的时候需要同时关注自动窗口等的变量的值,查看值是否符合预期
符号是做什么用的
在断点调试过程中,可能遇到的问题是我添加了断点,但是代码没有停在断点里面,此时看到的 VisualStudio 本来应该是红色的断点现在变成了白色同时提示没有加载符号或符号和源代码不匹配
这就是大家说的白点问题,这个问题很多时候都是应该符号没有加载的原因,或者当前添加断点的代码不是实际运行的代码
在 VisualStudio 需要存在符号文件才能调试,符号文件包含了某段代码对应的函数和对应的代码行,所以无法添加断点的问题请先看一下提示是否没有加载符号,如果发现没有加载符号
加载符号可以通过点击调试-窗口-模块打开模块页面,找到没有加载符号的模块,通过右击加载符号
更多请看View DLLs and executables - Visual Studio Modules window
但是符号一般只有自己写的代码才有符号,很多例如框架里面的代码是没有符号的,如果没有符号就无法添加断点,没有断点就不能愉快调试代码了。本文接下来告诉大家如何通过 dotPeek 创建符号文件进行调试
条件断点
如果断点每次都进来,那么调试效率将会很低,例如我在调试的函数是在循环之内,我只有在循环到 100 次的时候才需要进行调试,难道之前的循环进来我都需要不断按下继续按钮调过?其实在 VisualStudio 是可以设置条件断点,只有符合设置的条件才进入断点,详细请看VisualStudio 断点调试详解
课件视频
以上方法都不是需要记的内容,多用就熟悉了,所有的调试方法都是从断点开始
大部分的代码调试也只需用到断点调试就可以解决
我录了一个很无聊的课件视频,包含了以上的用法,欢迎小伙伴点击下面课件
以上课件的背景是我需要开发一个RSS订阅的工具,但是软件的输出内容的博客时间不对,同时只输出一个博客的内容,另一个博客的内容没有输出
在大部分的代码调试都是可以无视代码逻辑和业务逻辑,也就是拿到任意一个项目都是能进行调试的,但是想要解决问题还是需要了解一定的业务才能做到。所以以上课件只是告诉大家如何调试
在课件中也提到了引用库出现问题的调试方法,而在上面视频仔细看的小伙伴会发现在右击加载符号的时候,其实视频是两段的。原因是我引用库本身的符号是不存在的,此时就需要用 dotPeek 反编译调试
dotPeek 反编译库调试
在很多的库的调试的时候,这些库都没有带符号文件,此时可以通过 dotPeek 反编译同时创建符号文件加载
首先需要下载 dotPeek ,可以到官网下载 dotPeek: Free .NET Decompiler & Assembly Browser by JetBrains 还可以到 csdn 下载 但 CSDN 上的版本比较旧
打开 dotPeek 然后点击启动符号服务器,然后选择所有的程序集都需要反编译创建符号
点击 dotPeek 的工具设置,可以看到这个页面,选择所有符号都需要同时复制 dotPeek 创建的符号服务器的端口
这时在 dotPeek 就创建了一个符号服务器,可以提供任意的库的符号,在 VisualStudio 调试的时候发现有某个模块没有加载符号就会尝试去符号服务器加载符号
但是现在的 VisualStudio 还不知道 dotPeek 符号服务器的存在,需要打开 VS 工具选项,在调试设置符号,粘贴刚才复制的符号服务器就可以
详细请看调试 ms 源代码 和 断点调试 Windows 源代码
断点调试适合在已知代码和模块的时候进行调试,可以做到准确定位,断点调试是所有调试的基础。只要需要调试,那么请优先考虑进行断点调试,只有在断点调试难以使用的时候才考虑使用其他方法
在项目开发的时候,有时候会遇到一些奇怪的坑,但是项目太大了,不能确定是哪个模块的问题,或者自己对整个逻辑也不熟悉,此时可以尝试使用异常调试的方法
调试对象
在 VisualStudio 中提供了给某个对象添加 ID 的功能,在软件运行的过程,整个进程有超级多的对象被创建,而在调试的时候经常发现了修改了某个对象的属性或值但实际上没有应用上。此时可能的原因是找错了对象,通过在局部变量或自动窗口等右击对应的属性可以给这些对象添加一个 id 通过 id 就可以判断当前使用的对象和之前使用的是否相同的对象
这里用一个案例说明
我遇到一个很复杂的代码,这个代码的坑大概是这样的,我已经写了更改了某个对象的 Name 属性,然后在调用 GetName 时就会去取这个属性的值,同时如果这个属性的值为空了,就会出现异常,在调试的时候的代码大概如下图
在 GetName 方法判断传入的属性是否为空,如果为空就异常
private void GetName()
{
if (string.IsNullOrEmpty(Foo.F1.Name))
{
throw new ArgumentException();
}
}
我通过断点调试发现了我成功设置了 Name 的值
但还是发现了异常,我通过搜代码的 Name 的属性赋值,发现只有上面的代码才会赋值
此时就可以尝试通过断点调试里面的给对象设置 id 的方法调试,我给了 F1 设置了一个 id 通过局部变量找到这个属性,右击创建分配了 $1
给这个属性
然后在 GetName 方法添加断点,此时发现了现在的 F1 对象没有被标记,而存在标记的值和当前的 F1 不是同一个值,也就是说明有一段代码更改了 F1 的值
而可惜我看到了 F1 代码的定义如下
public class Foo
{
public F1 F1 = new F1();
}
这样的定义的代码将会出现一个坑在于我无法和属性一样通过在 set 方法上面添加断点知道了在这段代码内有哪个地方更改了 F1 的值,只能通过看代码的形式去寻找。这也就是一个好的例子说明了禁止公开字段的重要性,公开了字段会影响断点调试
如果我将 F1 更改为属性,那么我愉快在 set 方法打上断点,注意不是一开始就打上断点,而是在我设置了 Name 属性之后才添加断点,然后按下 F5 继续运行
在进入了断点通过调用堆栈可以找到是在 OtherCode 里面有代码更改了这个值
在断点调试里面使用多个技术一起使用,如局部变量和调用堆栈等可以提高调试的速度。当然调用堆栈还有很多用途,在下文的异常调试也会用到调用堆栈也会详细告诉大家如何使用
跳过编译直接调试
卧龙岗扯淡的人说大型项目很少Start运行调试的,都是attach进程,不然每次编译十几分钟。其实只要存在 PDB 文件和代码文件基本上都可以在附加进程的时候断点进入原有的代码的逻辑。可以在 VisualStudio 里面不进行重新编译直接调试
附加进程调试
在 VisualStudio 中,可以在调试里面找到附加到进程的选项,点击附加到进程的选项将会列举出当前本机里面可以被附加的进程列表
附加进程调试也可以用来做远程调试,只需要点击查找,然后输入远程设备的 ip 地址和端口即可。支持调试 Docker 容器的进程
更多关于远程调试相关,请看下文章节
附加进程调试可以调试正在运行的进程,适合用来调试在运行过程中进程出现了卡顿或诡异行为。使用附加进程调试的方法,可以做到不需要重新构建代码,执行进程然后附加调试,可以提升一些开发速度。但如果期望调试软件的启动过程,则需要使用下文的调试软件启动方法
在自己的软件中,可以预埋反向附加调试,也就是从自己的软件里面打开调试。这个做法可以在调试时,手动进行断点,细节请看 .NET/C# 中设置当发生某个特定异常时进入断点(不借助 Visual Studio 的纯代码实现) - walterlv
如在自己软件的某个按钮中,添加如下代码
#if DEBUG
Debugger.Launch();
Debugger.Break();
#endif
点击按钮时将会触发附加调试,如果此时已附加调试,也会进入断点
调试软件启动
如果有些软件是在发布的时候,刚好在软件启动的过程需要进行调试,此时就需要使用调试软件启动的方法,详细请看
异常调试
如果遇到程序运行的过程不符合预期,但是自己又不确定是哪个模块,或者代码太多逻辑很复杂,不知道在哪里下断点的效率才会高,此时可以尝试一下异常调试
异常调试的意思就是通过找到不符合预期的行为是否存在异常,通过分析异常调试
在 VisualStudio 会提供第一次机会异常,可以直接定位到对应的第一次机会异常所在的代码
第一次机会异常调试
进行异常调试的套路是先看输出,如果出现了异常,那么在输出窗口默认可以看到异常是什么和异常的输出
如果发现在输出窗口没有显示任何的异常,此时请右击输出窗口看一下是不是没有开启异常消息
通过输入可以发现运行过程的异常,然后在调试-窗口-异常打开输出里面的异常,如我看到输出里面显示了引发的异常:“System.ArgumentException”(位于 WegaljifoWhelbaichewair.dll 中)
此时可以在异常里面开启
因为异常很多,建议通过搜的方式开启需要调试的异常而不是打开全部异常
这样再次运行的时候就会在出现异常代码停下,这里 VisualStudio 使用的是第一次机会异常,所以相对好一点,即使有小伙伴 catch 所有异常也会在抛异常的地方停下如下图
找到了异常的代码,可以在代码的调用上下进行断点调试
关于第一次机会异常请看C#/.NET 如何在第一次机会异常 FirstChanceException 中获取比较完整的异常堆栈 - walterlv
读取异常的信息
很多的异常都是带有足够的信息,一般的异常里面都有 Message 告诉小伙伴哪里的使用是不对的,如果信息很多,将会在 Data 里面附带其他辅助的信息
在异常的 StackTrace 里面会记录这个异常的调用堆栈,让小伙伴可以知道是在哪个调用顺序里面扔的
在看到一个异常的时候,第一个应该看的就是 Message 大多数的异常通过 Message 都能知道问题,如果发现 Message 里面带的数据不够,可以尝试通过 Data 里面看是否还有附加的信息。在异常的调用堆栈信息里面可以看到方法调用的顺序
需要关注的异常信息包含异常是什么异常,和异常信息是什么,例如在群里有小伙伴问我这个问题
在看到这个提示的时候首先应该看的是这是一个什么异常,从界面看到 InvalidCastException 表示转换错误,然后通过信息 Unable to cast object of type 'System.String' to type 'System.Int32'
可以知道在执行到当前这句代码的时候无法转换对象
此时通过断点看到在执行的代码如下
也就是执行到将 foo.Name
转换为 int 的时候错误,此时应该打开局部窗口看对应的 Name 是什么
通过上面图可以看到对应的 Name
的定义在 Foo 里面是 object
而实际上的类型是 string
类型
在局部变量窗口里面,将会有一列显示属性的类型,如上图。因为在 C# 里面存在类的继承,也就是可以在类里面定义一个基类或接口,而实际上的值是继承类。此时在局部变量将会这样显示 定义的类型{实际的类型}
通过局部变量窗口就可以知道对应的值是什么类型,值是什么
通过分析代码可以知道,这句代码是将一个字符串转换为整形的方法,通过基础 C# 语法就可以知道这是转换是失败的
总结一下,在进行异常调试的时候需要关注的就是异常的类型和异常信息,通过异常信息提示,了解大概的异常是什么,找到对应的代码通过局部变量窗口等知道当前执行的逻辑的实际值,从而知道为什么会出现异常
在进行异常调试的是大多数只能知道为什么会有异常,也就是代码是如何执行不对的,但不能知道直接知道如何解决。一个好的异常可以告诉开发者可以做的解决方法
好的例子
用一个好的例子说明异常调试
我在尝试小伙伴写的一个库,我写了这样的代码
var f2 = new F2();
f2.ChangeName();
在运行的时候发现在调用 ChangeName 方法给了我一个异常,在异常提示里面写了 在调用此方法之前,请先调用 Init 初始化方法
看到这个提示我就知道了在调用之前需要先初始化
不好的例子
一个不好的例子是从微软的 WPF 框架抛的异常在这个信息里面完全没有多少有用的信息
ExceptionType: System.IndexOutOfRangeException
ExceptionMessage: 索引超出了数组界限
在 System.Collections.Generic.Dictionary`2.Insert(TKey key, TValue value, Boolean add)
在 System.Windows.Input.StylusWisp.WispLogic.CoalesceAndQueueStylusEvent(RawStylusInputReport inputReport)
在 System.Windows.Input.StylusWisp.WispLogic.ProcessInputReport(RawStylusInputReport inputReport)
在 System.Windows.Input.PenContext.FirePackets(Int32 stylusPointerId, Int32[] data, Int32 timestamp)
在 System.Windows.Input.PenThreadWorker.FlushCache(Boolean goingOutOfRange)
在 System.Windows.Input.PenThreadWorker.FireEvent(PenContext penContext, Int32 evt, Int32 stylusPointerId, Int32 cPackets, Int32 cbPacket, IntPtr pPackets)
在 System.Windows.Input.PenThreadWorker.ThreadProc()
在 System.Threading.ThreadHelper.ThreadStart_Context(Object state)
在 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state)
在 System.Threading.ThreadHelper.ThreadStart()
在看到这个异常的时候,请问这是在做什么?为什么在这里炸了
另一个不好的例子是在异常里面丢失了数据,在抛出异常的时候,没有带上足够的信息,如某个代码里面判断了某个条件,然后抛出异常:
if (Marshal.GetLastWin32Error() != 0x582)
{
throw new FooException();
}
以上代码存在的坑就是没有告诉开发者具体的信息,在抛出的异常丢失了信息。优化后的抛出异常如下:
var lastWin32Error = Marshal.GetLastWin32Error();
if (lastWin32Error != 0x582)
{
throw new FooException(lastWin32Error);
}
开发者可以更好从 FooException 获取到更多的错误信息,方便了解为什么存在此异常
写出方便调试的代码
如上文的例子,可以看到,如果异常是随便扔的,那开发者在遇到异常时,也难以快速定位问题。想要在异常调试里面能够快速调试就需要依赖代码对异常的处理,下面将介绍一些常用的套路
减少线程委托使用
先举一个不好的例子,我看到有小伙伴写了这段代码
new Thread((() =>
{
throw new Exception("林德熙是逗比");
})).Start();
请问上面的代码的坑有哪些?
如果是将上面的代码写在日志或上报等无法附加调试的情况下,只能阅读输出信息,那么能看到的信息是:
ExceptionType: Exception
ExceptionMessage:林德熙是逗比
在 lindexi.exe!lindexi.Program.Main.AnonymousMethod__0_0()
在 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state)
在 System.Threading.ThreadHelper.ThreadStart()
这样完全不知道这个代码是在哪里运行的,想要添加断点进行调试也不知道是在哪里添加断点。无疑,这是一个比较差的异常抛出方式
所以推荐的方法是减少在线程里面直接使用辣么大请使用方法,推荐的是多包一层函数,如我写了这样的方法
new Thread((() =>
{
Foo();
})).Start();
static void Foo()
{
throw new Exception("林德熙是逗比");
}
这样的代码比上面的代码好一点,可以拿到的信息请看下面
ExceptionType: Exception
ExceptionMessage:林德熙是逗比
在 lindexi.exe!lindexi.Program.Foo()
在 lindexi.exe!lindexi.Program.Main.AnonymousMethod__0_0()
在 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
在 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state)
在 System.Threading.ThreadHelper.ThreadStart()
我就可以通过调用堆栈的 Foo 找到了对应的代码,从而进行断点调试
另外,上面代码的另一个坑就是抛出的是 Exception
而不是具体的异常,这样在调试的时候不方便了解具体的内容,而且也不方便调试
阅读到这里,还请新手伙伴们不要和我杠多调用一层函数的性能问题。必须说明的是,多加一层函数带来的性能损耗是非常小的,除非真的在写性能特别敏感的逻辑。即使是性能特别敏感的逻辑,多加一层函数为了提升调试效率,在大部分情况下,还是很划得来的。因为这里多加一层函数,那其他地方只要稍微少一点点逻辑,自然性能又上来
不要在静态构造函数抛出异常
尽量不要在静态构造函数里面抛出异常,无论是自己有意抛出还是调用了某些逻辑,无意被抛出异常。因为在静态构造函数存在的一个问题在于,静态构造函数的调用时机是开发者不能完全掌控的,准确来说是难以完全掌控,即使这个版本能让开发者完全掌控,也许下个版本稍微改了逻辑,调用时机也被变更。由于调用时机难以控制,调用时机取决于哪个逻辑代码先碰到对应的类型,首个碰到类型的逻辑代码将会在开始之前,先执行对应的类型的静态构造函数。于是静态构造函数里面,抛出的异常,将存在比较迷的堆栈信息
即使在 VisualStudio 里面调试,如果静态构造函数是放在某个库里面,且没有加载此库的 PDB 符号文件,那么大多数情况下在遇到抛出的异常,是难以快速反应过来是静态构造函数的异常
特别是在静态构造函数里面存在空异常时,此时将会让大量的日志收集或者埋点上报的信息无效化
而如果遇到调用时机存在多线程调用,那即使是有完全的代码,解决问题和调试都是比较玄学的,甚至对于很多新手来说,将直接无法调试。现在对于静态构造函数的调试异常的最重要一点就是: 尽量不要在静态构造函数抛出异常!!!
没错,没有什么好的方法快速调试。只有在写代码的时候,注意尽量不要在静态构造函数抛出异常
这也不代表在静态构造函数里面捕获所有的异常,如果你看到有人在静态构造函数捕获所有的异常(非特定的业务处理异常)那这个代码一定是在挖坑。因为这将会让应用程序出现不受控的情况,不受控的情况也许导致后续的其他模块出现更多非预期的行为,也就是让应用程序可能出现一些诡异的或者复现步骤很神奇的坑
如果真需要在静态构造函数里面执行稍微复杂的逻辑,那推荐将逻辑拆开作为静态函数,然后由某个逻辑明确的进行调用。注意,不是比较复杂的逻辑就需要如此做,是稍微复杂就需要拆开作为静态函数,然后由某个逻辑明确的进行调用。 拆开的静态函数,也一定不能由某个静态构造函数调用,必须是由某个明确的逻辑进行调用。那什么是明确的逻辑呢?明确的逻辑就是开发者可以完全了解在什么情况和什么时机执行的逻辑
通过让开发者能完全控制从静态构造函数拆开的逻辑,从而让应用程序减少不受控的行为,同时可以让拆开的静态函数在出现异常时,可以有更好的处理以及更好的告诉开发者是哪里出错
区分发布代码
在一些模块,即使出现了异常还是可以正常工作,但是如果没有吃掉这个异常将会让整个软件无法使用。但是有很多逗比的开发者会写出逗比的代码,我期望让他在开发的时候就发现,于是我就通过了判断当前是 DEBUG 版还是发布版执行不同的逻辑
例如在希沃白板软件加载课件的过程,每个课件里面都有不同的页面,如果某个页面加载出现异常,我不期望用户整个课件都打不开,于是就吃掉了页面加载的异常。但是页面是很多小伙伴写的,我期望在开发的时候小伙伴就能发现这里有异常,我通过下面的代码区分了发布版
static void Foo()
{
#if DEBUG
throw new FooException("林德熙是逗比");
#else
Console.WriteLine("林德熙是逗比");
#endif
建议是在 DEBUG 下只要不符合预期就抛异常,这样可以在开发的时候减少诡异的使用
更细节的做法是:
对于抛出的异常,推荐在异常信息里面告诉开发者,这个异常是仅调试下抛出的,不要慌,不要去吓开发者
static void Foo()
{
#if DEBUG
throw new FooException("Xxx,此异常仅调试下抛出");
#else
Log("Xxx");
#endif
此外,如果项目开发者比较多,也推荐附加上名字,方便其他开发者遇到异常时,可以找到能协助解决的伙伴
static void Foo()
{
#if DEBUG
throw new FooException("Xxx,此异常仅调试下抛出,请找德熙");
#else
Log("Xxx");
#endif
以上的抛出调试异常的方法是通过条件编译符控制,对于基础库来说,大部分情况下都是使用发布版本。基础库的抛出调试异常的判断,可以通过在应用程序将状态设置给基础库,从而让基础库了解到当前是否在调试模式,如以下代码是在某个叫 D1.dll 的基础库
// D1.dll
using System.Diagnostics;
using System.Linq;
using System.Reflection;
namespace Lindexi.ComponentModel
{
/// <summary>
/// 包含在运行时判断编译器编译配置中调试信息相关的属性
/// </summary>
public static class DebuggingProperties
{
/// <summary>
/// 检查当前正在运行的主程序是否是在 Debug 配置下编译生成的
/// </summary>
public static bool IsDebug
{
get
{
if (_isDebug == null)
{
Assembly assembly = Assembly.GetEntryAssembly();
if (assembly == null)
{
assembly = Assembly.GetCallingAssembly();
}
// 如果是在测试项目,从 GetEntryAssembly 拿到的因为是使用测试项目 release 版本,而且测试项目使用一些非托管,所以无法拿到值。
// 从 StackTrace 拿到的可能是 false 原因是测试项目用的不是 debug 版本
// 所以从调用堆栈拿并且从 GetCallingAssembly 两个地方都判断,如果有一个地方是 测试运行,那么就是判断当前是在测试运行
_isDebug = GetDebuggableAttribute(assembly) ||
// ReSharper disable once AssignNullToNotNullAttribute
GetDebuggableAttribute(new StackTrace()
.GetFrames().Last()
.GetMethod().Module
.Assembly);
}
return _isDebug.Value;
bool GetDebuggableAttribute(Assembly assembly)
{
DebuggableAttribute debuggableAttribute = assembly.GetCustomAttribute<DebuggableAttribute>();
return
debuggableAttribute.DebuggingFlags
.HasFlag(DebuggableAttribute.DebuggingModes.EnableEditAndContinue);
}
}
}
/// <summary>
/// 设置当前是否在调试下,用于提高首次访问性能
/// </summary>
/// <param name="isDebug"></param>
public static void SetIsDebug(bool isDebug)
{
_isDebug = isDebug;
}
private static bool? _isDebug;
}
}
在 D1.dll 基础库的其他逻辑,将会判断 IsDebug 决定是否抛出调试异常
if (DebuggingProperties.IsDebug)
{
throw new FooException("Xxx,此异常仅调试下抛出,请找德熙");
}
通过以上的方法判断是否调试下,会比判断 Debugger.IsAttached 稍微好几毛钱。通过 Debugger.IsAttached 判断的是当前是否在被附加调试,判断的性能上,稍稍微会存在一点点问题,而且也会干扰发布版本的调试逻辑,从而对于一些问题难以进行附加调试
如果担心以上代码使用反射会影响性能,那必须说明的一个点在于,以上的代码是静态的,只有首次调用才可能用到反射,即使有性能损耗,也是特别小的。另外,如果真的感觉性能比较差,也可以通过 SetIsDebug 函数,强行赋值,如此即可做到不需要调用任何反射,性能特别好
保存堆栈
很多初学 dotnet 的小伙伴喜欢吃掉全局的异常然后重新抛,就如下面的逗比代码一样
try
{
Foo();
}
catch (Exception e)
{
throw e;
}
这个做法是很逗比的,在外层拿到的 e 将会丢失了在 Foo 里面的堆栈信息。上面的代码纯属吃饱了没事干,不会有任何的优化,不仅降低性能,也让调试更加难。可选的做法有两个,要么就是不加上 catch (Exception e)
逻辑,执行运行即可
Foo();
要么就使用 throw;
直接抛出,不要接住再重新抛出
try
{
Foo();
}
catch (Exception e)
{
// 这里可以执行一些逻辑
throw;
}
如果在某些逻辑里面,确实不能立刻重新抛出异常,需要重新抛出,那正确的做法是依靠 ExceptionDispatchInfo 类型的辅助重新抛出异常,详细请参阅 使用 ExceptionDispatchInfo 捕捉并重新抛出异常 - walterlv
更多方法
我推荐小伙伴阅读以下博客了解在代码中如何写异常逻辑
开启所有异常
在进入异步等待的过程,也许会发现有一部分的异常提示不在具体的代码,而是在上一层的代码提示。这是因为异步的代码和实际代码的代码是存在出入的,异步的代码将会构建异步状态机,和实际编写的代码存在一定的出入。此时可以通过在提示的哪个异常就开启哪个异常的方法,找到对应的抛出异常的代码
但是如果发现提示的异常是合并的异常,或者需要开启的太多了,或者是每次抛出的异常都不一样,或者是难以确定准确的异常,可以尝试开启所有的异常
开启所有的异常的方法是在调试窗口异常设置里面,如果前面的分类是一个方形那么就是开启默认的异常,此时有很多异常都是被忽略的。再点击一次变成勾就可以开启所有的异常
对于很多代码写比较渣的应用软件来说,包括调试 Visual Studio 的代码是不建议开启所有的异常,因为比较渣的软件有很多无关的代码特别是异常控制流程会干扰调试
通过开启所有异常的调试会存在的问题,大家也知道异常控制流程会影响到调试的方法,在我开启所有异常的时候,如果存在很多异常控制流程的代码,那么将会在调试的时候被这些诡异的代码影响
但是有时候开启了所有的异常还没有让 VisualStudio 停在自己需要关注的代码上面,此时就需要用到调用堆栈来进行调试
调用堆栈
在找到对应的异常的过程,请通过调用堆栈看到这个方法是如何被调用的。在被调用的函数上面,可以通过双击到达函数的代码。配合在局部窗口等可以看到附近的值,这个方法可以找到代码运行的逻辑,也就是了解为什么会进入这个代码分支,和了解代码为什么如此执行
如果发现很难通过调用堆栈看出代码运行的逻辑,也可以在调用堆栈上面右击函数添加断点,然后再次运行代码。再次运行代码,在碰到对应的函数时,将可以进入函数断点,多次重新调试,配合单步调试,可以更好的理解代码运行的逻辑
很多时候通过调用堆栈可以看出来调用方法进来的路径是否符合预期,以及在不符合预期的时候各个函数的参数是什么这些参数是否符合预期
这里推荐插件调试神器OzCode可以协助看代码逻辑
通过调用堆栈和异常的方法可以快速定位代码调用是否符合预期,各个函数传入参数是否符合预期,此时的调试不限在 DEBUG 下,同时适合在用户端调试发布的代码
在调用堆栈的使用过程,会自动将没有加载符号的代码作为外部代码隐藏,也就是在开启异常的时候不会将异常代码显示,此时可以通过在调用堆栈右击,选择显示外部代码,此时将会显示所有的调用的外部代码
在外部代码里面的方法都是没有加载符号的,所以无法直接通过双击的方法进入到对应代码,此时可以通过右击加载符号加载对应模块的符号,如果这个模块属于库同时也没有符号,可以通过断点调试的使用 dotPeek 方法创建符号加载
如果在没有符号的时候,只能通过调用的方法名和传入的参数和一下局部变量调试,如果是调试的方法的方法名和所做的内容相同,同时一个方法里面的代码很少,通过看参数和局部变量和调用顺序比较简单找到坑。但是如果在调用堆栈里面无法跳到代码,例如等待 dotPeek 反编译的时间实在太长,同时这个方法的代码特别多,那么将很难进行调试
用户端调试
在用户端调试不是说只有在用户的电脑上进行调试,更多的是在没有使用自己代码进行 DEBUG 编译调试。如果现在遇到的问题是一个不带符号文件的程序出现了坑,如何调试他
在 VisualStudio 提供了附加到进程的功能,在 VisualStudio 运行的时候可以通过点击调试附加到进程,附加到现在正在运行的程序。同样先尝试复现一下,在输出窗口可以看到对应的输出的异常,在异常窗口开启对应的异常,再次复现让 VisualStudio 停在对应的异常的代码
也许此时出现异常的是在库里面,或者整个程序在运行的过程是找不到符号文件的,也就是无法定位到具体的代码。但是在调用堆栈依然可以看到用户代码调用顺序,同时在局部窗口也可以发现每次调用的局部变量
此时可以再打开一个 VisualStudio 找到对应的函数的对应代码,按照调用堆栈里面的调用逻辑,是否可以找到解决方法
上报异常
不是所有的用户都可以将你拉过去打靶,也不是所有的异常都需要解决
建议在软件运行过程中,所有没有接住的异常还有被接住但是需要解决的都进行上报
此时需要一个后台的服务器用于接受用户运行过程中上报的信息,对于异常的数据建议上报的内容包括以下
- ExceptionType
- ExceptionMessage
- Stacktrace
如果能将对应的 Data 上报就更好,对于特殊的如 AggregateException 等就需要拆开,除了以上信息还需要上报通用的信息,包括用户的 id 和系统版本安装的 .NET 版本这些
通过上报的数据找到用户报的比较多的异常优先解决,同时在软件上线的过程对于新模块的异常优先解决
因为是在后台看到上报的数据无法进行附加调试,此时上报的异常的信息就更加重要,建议小伙伴在写代码的时候考虑调试
无异常调试
当然很多异常都是小伙伴自己抛的,如果在代码里面写的不规范,例如需要抛的时候不抛,将会提高调试的难度,此时将使用无异常调试,面对无异常调试的时候一般都是界面相关,莫名发现界面没有符合预期,但是此时没有任何异常,也没有任何日志
例如我有小伙伴尝试从资源获取动画,通过播放动画修改界面
var fooStoryboard = FindResource("FooStoryboard") as Storyboard;
if (fooStoryboard != null)
{
fooStoryboard.Begin();
}
有逗比更改了 FooStoryboard 资源,让 fooStoryboard 为空,因为此时存在判断空,此时动画不存在就不执行,也就是这段代码的开发者没有考虑到防逗比也不明白异常策略,此时没有异常也无法快速定位。因为我不知道这段界面的动画代码是写在哪,我也不知道这里是不是有逗比改了动画还是有逗比修改了逻辑让动画不触发
这时就进入了无异常调试,虽然很多时候还是可以打断点的,但是因为代码太多也很难知道从哪里开始进入断点
这时的调试就没有什么高效率的方法了,推荐的做法是在入口点,例如已知功能的入口函数,或相关入口函数上添加断点。在不明白是哪个入口才能触发对应的逻辑的时候,只能通过相关的入口函数,例如我知道点击某个按钮或输入某段文本将会触发某个动画,但是此时这个动画没有被触发,也没有任何异常。那么我需要在所有的相关的点击事件和输入文本函数上面添加断点,在 VisualStudio 的摘要有一个好用的功能就是事件。如果不明确是在哪一段代码,也许可以通过事件找到在触发代码的过程发现的事件,通过事件跳转到对应的代码,在对应的代码上添加断点
在阅读完无异常调试的时候,相信小伙伴都了解到了异常的作用,以及在某些地方如何防逗比了
当然不是所有的时候都适合使用异常也许可以尝试一下日志,另外对于 WPF 和 UWP 的界面相关有另外的调试方法
用户端无代码调试
无论是否有异常都可以尝试使用这个方法,通过 dnspy 在用户端调试,可以不需要任何代码,只要在用户端能找到 exe 就可以调试
在开始之前先安利一下 dnspy 这个工具,在我的调试工具里面用的最多的除了 VisualStudio 和 SublimeText 之外就是这个工具了。这个工具可以在用户端不需要任何代码,通过反编译的方式,支持在任何库里面添加断点进行单步调试,在调试的时候和 VisualStudio 一样提供了局部变量和即使窗口等功能,我调试 WPF 触摸的时候就通过这个工具调试 WPF 框架里面的触摸代码,通过调用堆栈和局部变量知道了 WPF 框架是如何做的
如何使用 dnSpy 调试代码,请看 如何在没有第三方.NET库源码的情况下调试第三库代码? - Dotnet9
更多关于 dnspy 请看 神器如 dnSpy,无需源码也能修改 .NET 程序 - walterlv
多线程调试
现在很少有软件只是有单个线程,一般的软件都是存在多个线程一起使用,而有很多不看书的小伙伴会随意使用多线程,也就会遇到很多多线程的问题,在调试的过程中,调试多线程之前请先了解多线程。不需要了解到内核态什么的,但是需要了解以下的知识点,在不了解之前,很多小伙伴都会说垃圾微软一定是 vs 没编译好
- 异步和同步
- 异步切换上下文
- 框架里面提供了哪些多线程方案
- 线程安全方法或属性
- 多线程读写问题
- 框架里面提供哪些锁在什么时候使用
- 调度的使用方法
当前线程
在开始调试的过程,可以找到当前运行代码的对应的线程,如我在方法添加了断点,我可以看到这个方法在哪个线程运行
还是刚才的代码,我在两个方法里面修改了 Name 这个属性,然后在第三个方法判断了 Name 的值
public void ChangeName()
{
Foo.F1.Name = "lindexi";
OtherCode();
GetName(); // 抛出 ArgumentException 异常
}
private void OtherCode()
{
Foo.F1.Name = "逗比";
}
private void GetName()
{
if (Foo.F1.Name != "逗比")
{
throw new ArgumentException();
}
}
理论上代码是先调用 ChangeName 里面的 OtherCode 在这里修改了值,然后才调用 GetName 方法,也就是获取到的值就是最后一次设置的值
但是实际在调试的时候会发现,可能这个 Name 的值是 lindexi
此时尝试在 ChangeName 和 GetName 方法上面添加断点在进入断点的时候请看对应的线程是否相同,多次进入断点如果发现方法的线程是不相同的,那么就可以知道这是一个多线程问题。通过单步调试可以发现在线程 1 调用了 ChangeName 到 GetName 方法的过程,在调用 OtherCode 方法完成之后刚好有线程 2 调用了 ChangeName 方法,而在线程2修改了属性之后,在线程1就判断了属性
在调试的过程,可以点击线程,进行切换线程,可以看到在某个线程执行某段代码的时候,另一个线程在做什么,通过这个方式可以调试多线程访问资源
并行堆栈
在 VisualStudio 暂停程序的时候,例如进入断点的时候,将会同时暂停所有的线程。在 VisualStudio 提供了并行堆栈的窗口,在这个窗口里面可以看到当前进程的所有的线程对应的调用堆栈。通过并行调用堆栈可以快速看到每个线程的调用堆栈,可以快速找到自己感兴趣的方法是被哪几个线程使用
在调查程序中存在的相互等待锁的时候,推荐使用并行堆栈
如果在程序中有小伙伴写出了多个线程的代码,他使用了两个锁,但这两个锁让两个线程相互等待。此时你可以在调试的时候发现软件存在有线程在等待,不响应业务代码。在客户端开发中,如果是主线程,那么可以看到界面停止响应
遇到此问题首先看 CPU 的占用,如果 CPU 占用不高,但线程不响应,那么猜测是线程在等待锁
在 VisualStudio 点击暂停调试,此时从 调试-窗口-并行堆栈 可以打开并行堆栈
在并行堆栈里面找到当前期待响应的线程,查看此线程所等待的锁,同时寻找是否存在其他线程也在等待某个锁的函数。如果找到存在其他线程也在等待锁,回顾此线程可能访问到的逻辑,看是否锁住了当前期待响应的线程
在 dotnet 程序,如果一个线程等待的地方不是某个 IO 返回值,在客户端程序,如果主线程等待的地方不是在 GetMessage 方法,那么大概率是在等待某个锁
在并行堆栈找到多个线程都在等待锁,可以猜测当前是存在相互等待的锁。在没有源代码的情况下,依然可以通过附加调试,看到当前进程的并行堆栈在有加载符号的前提下,可以通过调用堆栈的方法名猜测当前线程是否在等待锁。一个简单的判断线程是否暂停执行的方法是进入该线程的调用堆栈方法,进行单步调试查看是否运行,如果进行单步调试的时候等待一段时间都没有进入 VisualStudio 的暂停,那么此时线程就是进入等待。如果有多个线程需要进行判断,可以不断按下运行和暂停按钮,观察线程是否改变调用堆栈的方法
锁和线程的调试
如果程序卡住,但是CPU很低,一般都是锁的问题
线程在等待某个锁,但是这个锁没有被释放,那么拥有这个锁的线程是哪个线程代码运行到哪? 调试方法请看 在 Visual Studio 2019 (16.5) 中查看托管线程正在等待的锁被哪个线程占用 - walterlv
无断点调试
有一些代码是不支持添加断点进行调试的,理论上很少有代码不能添加断点,但是存在很多添加了断点就无法继续的业务。包括了有一些功能是不支持软件暂停的,例如桌面端的调试输入和数据库通信过程。还有一些软件是在不知道是在哪一行代码添加断点,这就需要用到无断点调试
其实在上文提到的调试进程是否存在相互等待的锁让线程等待的方法的时候就提到了无断点调试的一个方法,在调试锁的问题的时候,在不知道当前线程在哪个方法等待锁的时候,如何设置断点?其实此时是无法设置断点的。推荐的方法是通过 VisualStudio 暂停进程,通过并行堆栈查看是否存在线程相互等待锁
本文将会继续告诉大家其他的无断点调试方法
不支持暂停的调试
在无断点调试里面做桌面端的小伙伴就知道,如果是在调试用户输入过程,那么此时是不支持暂停的也就无法添加断点调试,如果软件进入了暂停那么等待软件的输入将会被暂停,将无法做出连贯的功能
例如我有一个功能是书写我需要调试,但是如果我添加了断点就会打断书写的输入,在调试的时候就不能使用断点调试也就是上面提供的任何方法都不能在这里使用
如果软件是在执行某段业务的过程不支持进行暂停,需要连续执行,那么依然还有很多方法进行调试。但如果是整个软件都不支持进行暂停,除非软件本身带了日志输出内容,否则我也没有好的方法。
也就是说在开发过程,如果发现自己的某个模块需要连续执行,不支持进行暂停。那么请做好调试使用的脚手架,在下文将会讲到如何设置调试日志等
在开始调试不支持暂停的代码的时候,使用最多的是输出窗口,如果此时代码支持更改,请添加描述逻辑的足够的控制台输出
Debug.WriteLine($"进入xx方法,当前值={xx}");
在 VisualStudio 里面输出到输出窗口的内容也有一些套路
- 推荐使用 Debug.WriteLine 而不是使用 Console.WriteLine 输出
- 推荐加上一些前缀标签,用于过滤输出窗口
- 推荐带上一些格式,例如
Debug.WriteLine($"========进入xx方法=======");
这样进入关键方法时可以快速看到
更多请看 C# 如何写 DEBUG 输出
在更改代码进行调试的时候,通过添加更多的描述输出的方法是很难一次性添加对的输出的,需要小伙伴不断尝试和修改
随机暂停调试
有时候是不知道应该在哪里添加断点,而无法添加断点调试,例如有小伙伴告诉我软件什么都没做但是占用了很多的 CPU 计算,不知道是哪段代码在计算,此时就不知道在哪里添加断点
在不使用性能调试工具的前提下,是可以尝试调试对应代码。如果需要性能调试工具,请看下文的性能调试
另一个例子是在并行堆栈的时候讲到的遇到锁的问题,此时也因为不确定是哪里的代码的问题,无法添加断点
总结一下,在不知道是哪里代码问题的时候,例如资源占用或锁的问题等,此时可以尝试进行随机暂停调试。随机暂停调试的方法是在运行代码的过程中,多次按下 VisualStudio 的暂停进程的按钮,查看此时的进程停在哪里和对应的调用堆栈
使用随机暂停调试可以调试出频繁进入的函数以及部分锁的问题。例如我写了一个逗比的 Foo 函数,这个函数将会不断被一个定时器调用。此时通过随机暂停调试,可以在多次暂停的时候发现都在 Foo 函数里面,此时就可以认为这个函数被调用了很多次,或这个函数执行了很长的代码但没有返回,或者这个函数进入了锁。对不同的猜测采用不同的方法进行调试
在使用随机暂停调试的时候不一定需要有对应的代码,大多数时候只是需要查看方法的调用堆栈就可以,同时使用随机暂停调试的效率也是很高的,在阅读完本文下面的性能调试方法就可以知道,使用暂停调试的速度会比使用性能调试工具要快很多。大多数的逗比代码,只需要进行随机断点调试就可以找到是哪里写的逗比代码
通过日志调试
因为一个对外发布的软件或网站是不能时刻进行调试的,也就是大多数时候都运行但没有添加任何断点,或添加不上断点。那么此时如果有小伙伴告诉你软件不工作了,请问为什么软件不工作了
理论上除非你对这个软件十分熟悉,同时也确定是你自己的某段代码写出来的,例如下面这个例子
某一天林德熙逗比开发者在调试软件的启动过程
这个逗比开发者在软件启动过程中扔了一个异常
某个吕水逗比代码审查将代码合并到了主分支
某个洪校长发布了这个版本
某个测试小伙伴告诉某产品说软件不工作了,就是打不开
此时某头像开发者直接就去打德熙逗比开发者,因为他十分明确这一定是一个逗比问题,只有逗比开发者能写出来
但问题来了,请问林德熙逗比开发者如何能知道测试小伙伴说的软件不工作了是怎么回事?为什么软件启动不起来了
在测试的设备上,是安装不了如此重的 VisualStudio 的,于是 WPF 如何在应用程序调试启动 的方法也用不了。同时因为软件一启动就 gg 了,所以附加调试也用不了。就连 神器如 dnSpy 也被测试小姐姐说不要弄坏她的电脑不能用
此时可以怎么知道软件是运行做了什么
这时就应该用上日志的功能,一个稳定的软件一定是需要带上运行时调试的功能,最简单的运行时调试功能就是记日志
最简单的记日志的方法相信小伙伴在都用过,就是通过提示窗口,例如在写前端页面的时候一开始用的最多的就是弹出窗口在里面写调试信息内容。当然这个方法的调试效率有点低,也不适合于在用户端使用。下面让我告诉大家一些好用的方法
在开发的时候需要区分日志是在调试使用的还是在用户端使用的,这两个记录的方法和做法都有很大的不同。有一点必须明确的是无论什么方法记日志都是会影响性能的,其次不是所有人,特别是用户都关心输出的信息,所以在调试的过程记录的日志需要做以下区分
- 是否只有我关注
- 是否只有我在本次调试的时候才关注
- 是否只要调试此模块的开发者都应该关注
上面两个问题决定了什么内容应该记在日志,什么内容不应该记录日志或者不应该将此日志内容提交到代码仓库
从上面问题小伙伴就知道如何考虑记日志了,对于只有我关注的内容,也就是在我当前开发的过程我需要知道这些信息,但其他人不需要,同时这部分信息如果不断输出将会干扰其他开发者的调试。而对于只有我本次调试才关注的内容,也就是用在调试某个 bug 的时候,我需要进行日志输出,而在我解决了这个 bug 那么这些输出内容也就不需要
在我之前开发的时候就发现了团队项目让 VisualStudio 输出窗口无法使用,原因就是各个小伙伴都在往输出窗口输出只有他自己关注的内容和只有单次调试才有意义的内容。记日志不是越多越好,太多的日志信息将会让开发者关注不到关键的信息
在我开发笔迹模块的时候,就和雷哥合作,雷哥在他的项目里面通过他自己搭建的日志框架,可以做到在输出的时候指定开发者名字,只有在对应的设备上通过读取系统用户名匹配才会开启对应的日志输出。同时他的日志框架还支持模块日志开关,支持开启某个模块的日志输出,此时就可以做到雷哥写给自己看的日志,只有雷哥自己看到,而其他开发者看不到。而对整个模块的关键输出,也就是任何接手这个模块的开发者都会关注的内容,通过加上模块标签,可以在调试的时候在日志框架里面开启对应的模块标签进行调试,日常这些模块调试都不会输出,这样不仅可以在软件运行过程减少记日志耗费的时候,同时可以减少其他开发者看到不相关模块的调试日志
我现在没有找到任何一个适合和大家推荐的开源的日志追踪框架,上面说到的雷哥的日志框架也是他自己搭建的,而我现在团队里面的追踪框架我还在进行搭建
在记日志的时候,很重要的一点就是这个日志应不应该记,在问之前需要先问这个信息属于上面问题中的那方面信息。如果只是自己调试某个 bug 需要记录的日志,那么随意记录,包括记录的内容和记录的方法。例如我在调试网络访问的时候,我只需要知道服务器有没有返回数据而我不关注服务器返回的是什么,此时我记录的日志可以是 aaaaa
也就是一串只有我自己在此时才能知道含义的输出
这部分仅在某次调试才需要用到的日志没有任何要求,只要自己能懂就可以。但此部分提交应该在代码审查上拦下,不应该提交到代码仓库
另一部分是只有自己才需要知道的调试内容,这部分建议用工具或日志框架管理,例如在 VisualStudio 里面有过滤输出窗口的插件,通过每次在输出的时候带上自己的名字,然后过滤输出窗口的方法,可以让输出的内容只有自己看到
对于只有自己才需要了解的调试内容,需要在记日志的时候带上更多有用的信息,本金鱼君在写只有自己需要知道的调试内容的时候,会多写一部分注释,不然第二天调试就忘了内容
而对于模块调试内容,建议的一般方法是在有调试框架的时候,通过标签的方式输出,而对没有调试框架的时候,通过使用条件编译符的方式让只有调试这个模块的开发者才能看到
以上记日志的都是调试信息,对于调试信息应该只有在 DEBUG 下才能执行代码,不应该在发布版本包含调试信息代码的执行逻辑
如何让代码在发布版本不运行,只有在调试下运行,请看 条件编译博客
说到记日志,其实日志只是输出的内容,至于记的方法可以有多样,用的最多也是记最快的是通过输出窗口记录,建议的方法是通过 Debug
静态类进行记录而不是通过 Console
静态类进行记录。原因有二,第一是 Debug
静态类只有在调试下才能被执行,在发布版将不会执行调试输出的代码,这样可以提升性能。第二是 Debug
只有调试下输出而 Console
将会在发布版输出,同时任何其他进程可以通过调起软件的方法拿到软件进程的控制台输出,这样不仅会影响自己软件在发布版的运行性能,同时也会让其他开发者可以知道软件内容运行逻辑
另外的记日志的方法是通过文件记录和通过追踪记录,一般文件记录在于大量调试信息的记录以及在有一群逗逼小伙伴干扰了输出窗口的前提下,不得不自己新建一个文件用于记录日志。当然在进行多进程调试的时候也会用到文件日志的方法
还有一个日志记录方法是通过追踪记录,在 .NET 提供的 Trace
静态类就是追踪日志的功能,需要说明的是追踪这个功能默认在发布版和调试版都是执行代码的,同时任何调试工具都可以获取追踪输出,所以请不要在追踪输出会影响性能的内容,也不要输出关键内容
在发布版的代码里面,通过输出窗口进行记日志是很少用的方法,因为大多数发布版都会在用户端运行,在用户端运行的时候最主要的是没有开发环境。此时可选日志方案基本只有文件日志和追踪输出日志以及上报用户数据的方法
通过将日志记在文件适合于在用户端发现问题之后,通过日志看到用户的设备上软件是如何运行的。例如有用户告诉我程序某个功能无法使用,我可以通过日志发现是我请求了服务器,然后服务器没返回,只是就可以快速定位是服务器或网络相关的问题而不是定位是功能本身界面的问题
但是文件日志应该查看不容易,同时也不支持实时调试,所以通过追踪记录日志就在这里用到。在调试需要实时看到输出信息的,例如有用户告诉我他的某个功能不能用了,我远程他的设备,此时我需要实时看到软件运行的输出,那么推荐使用以下方法。在程序关键点通过 Trace
静态类作为追踪输出,然后在用户端使用 DebugView
工具就可以拿到程序里面的追踪输出
另外不是所有的用户都会在软件出现问题的时候反馈到工程师,同时也不是所有用户反馈的问题都是需要解决的。需要通过用户数量等判断优先级,此时就需要用到上报数据的方式。在微软发布每个版本的系统的时候,在每次上新功能之前,都需要添加很多埋点,这里的埋点的意思是将数据上传到自己的服务器。上传的数据包括一些异常和用户行为,以及开发认为一些不会进入的逻辑或运行性能。这样就可以在后台分析数据知道了功能的稳定性,同时还可以知道用户是如何使用软件
一个成熟的软件一定需要有成熟的日志管理方法,对于日志包含了所有程序对开发端输出的内容而与具体形式无关。在日志管理里面主要的是团队约定和管理方面,本身没有多少技术含量,即使是选用某个日志框架。也许现在我无法给大家推荐一个日志框架也和这个原因有关,每个团队每个软件都有自身的需求,很多需求都是相反的,这也就让一个统一的日志框架做不起来的原因,即使是再好的日志框架,也无法在一群逗逼的团队里面使用
说到这里和大家讲个笑话,我在开发一个有趣的 UWP 软件的时候,我用了 NLog 这个日志框架,有一天我看到了自己的调试设备的存储不够了,于是我就想到了一个好用的功能,我需要在软件里面添加清理空间的功能。软件的清理空间的功能是这样做的,通过 NLog 不断输出 林德熙是逗比 让磁盘的空间不足,于是就会执行自动的清理。同时我的日志本身也会自己清理,这样就完成了清理空间的功能
更详细方法请看 程序猿修养 日志应该如何写
辅助代码调试
辅助代码调试是相当于在代码里面添加一些工具的方式,有一部分的测试比较不明确,需要通过一些辅助的代码协助提高调试的效率
如添加中间的求值辅助,例如在执行某个逻辑的中间,此时多个值之间是有关联的关系,但是不能明确这些关系是否正确,此时可以通过写一段求值的逻辑,通过已知的变量求出关注的值。之后可以利用计算出来的值进行判断或进行输出
如果是需要进行复杂的判断,虽然可以通过 VisualStudio 的断点实现,但是如果这个调试可能是会进行多次的,此时用断点就不适合。需要在下一次也利用到的复杂的判断,可以通过辅助代码协助判断。在代码里面可以通过以下方法进入调试断点,详细请看 .NET/C# 中设置当发生某个特定异常时进入断点(不借助 Visual Studio 的纯代码实现) - walterlv
Debugger.Break();
这个功能可以埋在特定的逻辑触发,例如有测试小伙伴告诉你有一个 bug 是概率复现,而开始的调试一点都不知道是为什么。需要让其他小伙伴帮忙踩,就可以偷偷在代码里面埋下这段逻辑。在判断到某个逻辑成立则触发断点。不过需要注意的是埋下这段逻辑对你的防御力有比较大的要求,因为你的小伙伴可能会打你。执行 Debugger.Break 将会进入 VisualStudio 调试中断,尝试将这句话写入到循环里面,然后提交到代码仓库里面,请确保你的循环每次启动都会被执行,此时请穿好鞋子,观察你的小伙伴的表情,准备跑路
辅助代码也是测试多线程的好方法,如遇到可能因为多线程竞争资源问题,而当前的线程数太少了。可以尝试用辅助代码协助测试。如加大数据或线程数量,利用 Debugger.Break
方法当遇到竞争问题时进入断点
在使用辅助代码调试的时候,配合日志将会更多的提高调试的效率。如我有一个方法,这个方法在一定条件下将会进入预期外的两次,类似这样的重入问题或不成对问题。在进行辅助代码进行压力测试和配置打上每个方法进入的日志,可以提高调试效率
在后续提到的二分调试和模拟调试等,都会用到辅助代码的方式,例如特意给某些属性设置有趣的属性或特意绕过某些方法的执行等
需要注意的是,一般的辅助代码都不应该包含在 release 版本里面,因为辅助代码一般采用hack的手段,可能会降低性能或带来其他的坑。所以建议在提交之前进行注释或删除,或包含在没有定义的宏里面
即时窗口
辅助代码虽然好用,但不是每次调试之前都能想好辅助代码可以如何写的,可能在调试的过程才想到我需要一些辅助的方法帮我做一些特殊的逻辑。而不是每次在编辑代码之后都能继续执行,此时快捷的做法是在 VisualStudio 的即时窗口里面输入逻辑代码,按下回车键执行
在即时窗口将可以使用当前堆栈的局部变量,以及当前堆栈能访问到的一些变量,和静态变量。在即时窗口里面还可以调用现有程序的方法,通过即时窗口可以在调试过程执行复杂的逻辑,虽然在即时窗口没有Resharper的智能提示
这里只是提到了 VisualStudio 提供的功能,很难告诉大家在什么调试下应该用这个功能。在调试过程中,可以尝试想一下是否我现在的调试效率比较低,是否有什么工具可以协助我更快的调试
库调试
在进行库调试之前,应该充分相信使用的库的质量,也就是相信库的代码是稳定的。只有在确定了自己的业务逻辑等十分简单或调试不出来自己的业务代码存在问题的时候才尝试调试库
在断点调试的课程视频里面有和大家演示了调试到库存在的问题,下面让我详细告诉大家如何进行调试
桩测试
在开始之前,还是要相信库的质量,如果现在还有其他的逻辑调试,那么请先调试自己的代码,只有在自己的代码调试没有发现问题的时候,才进行库的调试。而如何说明自己的代码没有发现问题?在可以确定输入和输出的前提下,可以使用桩测试的方法
使用桩测试是模拟库里面的公开的代码,使用桩测试可以知道是自己逻辑的问题还是库的问题。也就是通过模拟库里面的某个调用的方法,如果发现在模拟调用的方法返回的自己预期的值时,自己的模块依然存在问题,那么此时就可以认为库是没问题的,有问题是还是自己的逻辑。而反过来就可能是实际库的工作和自己模拟的预期是不符合的,此时建议查阅库的文档,可能用法不对。在确定用法之后,就可以知道是库的问题还是使用问题
在断点调试的课程视频里面就是通过查阅文档发现开源库里面的 MR 有小伙伴解决了这个问题,所以如果是开源库,推荐在 ISSUS 或 MR 里面搜一些关键词,看是否是已知问题
在桩测试的前提是输入和输出状态确定,也就是无状态的类或内聚的是比较好调试的,静态方法也是比较好模拟的。而如果是牵一发的函数,这部分就难以进行模拟了。也就是在自己开发一个库提供给其他小伙伴用时,可以考虑的点是调试。让使用库的小伙伴可以比较明确知道输入和输出
使用桩测试不仅适合用来库调试,也适合在自己的一些逻辑上,相当于去掉干扰的逻辑。例如我有主分支的执行逻辑非预期,而这个主分支涉及很多和主分支无关的逻辑。此时可以在无关的逻辑方法直接返回预期的值或跳过部分逻辑,这样可以让调试集中在主分支,或确定以为和主分支无关的逻辑是否真的和主分支无关
其实桩测试也是辅助代码调试的方式,所以在提交代码时建议不要提交桩测试的代码
模块测试
那么反过来呢,我经过了桩测试模拟了库的实际输入输出之后,发现我的业务端的逻辑是正确的。此时就可以假定是对库的调用不符合预期。此时还不能说明是库的问题,而是需要想着是自己对库的调用不对。在进行模块测试的之前,需要想想,是否这个库的文档还没读,说不定对库里面的约束就写在文档里面。如有小伙伴说为什么在 UWP 传入 OTF 没有用,此时不是 UWP 库的问题,而是 UWP 这个框架写明了不支持 OTF 字体。如果有读文档就能知道,如果没有文档或者文档太多,或者觉得自己坑太少了,那么请尝试下面方法
模块测试的方法就是自己新建一个控制台或者单元测试项目,在这个项目里面模拟自己业务端的输入进行测试传入到具体库的模块中,尝试这个库的调用方法,看是否有不符合预期的表现。或者通过测试的项目快速找到库的正确调用方法
为什么是新建一个控制台或单元测试项目呢?主要是为了提升调试的输入,如果是在自己的大型项目里面添加模块测试,基本上等项目构建的时间已经足够长了,而在进行模块测试的时候需要不断尝试传入的值等,这部分需要不断进行启动项目,也就是项目构建的时间将会影响调试的效率。通过控制台的方式可以快速执行 Main 方法,传入值,相对来说需要的学习的知识特别少。而通过单元测试项目是我比较推荐的方法,但是缺点是需要学习一些单元测试相关知识。如创建一个单元测试,如何执行单元测试,以及如何在单元测试进行调试。在单元测试里面的优势在于可以开启多个不同的方面的测试入口,同时也能用上 Mock 等虚拟类型
广告一下一个好用的单元测试工具,特别适合用来进行模块测试
通过 CUnit 中文命名单元测试工具可以方便写入如下面代码
[TestClass]
public class DemoTest
{
[ContractTestCase]
public void Foo()
{
"当满足 A 条件时,应该发生 A' 事。".Test(() =>
{
// Arrange
// Action
// Assert
});
"当满足 B 条件时,应该发生 B' 事。".Test(() =>
{
// Arrange
// Action
// Assert
});
}
}
进行模块测试的要求是尽可能库里面的逻辑是独立的,如果库里面用到了一些静态状态,那么此时的调试可能会和在实际项目中看到的不相同,如在库中定义了下面代码
public string Foo()
{
if (Count == 1)
{
return "林德熙是逗比";
}
return "林德熙不是逗比";
}
public static int Count { set; get; }
这样的静态状态的在调试中就不好玩了,因为可能此时在进行模块调试的时候全部都是对的没问题的,也就是单独测试业务都是对的。单独测试库也全部都是对的,但是两个合起来就不对了
如果遇到这个坑那么请想到是不是库里面有一些静态状态在业务中,其他业务中被设置了。这部分在 VisualStudio 调试中可以看到静态属性等的值,但是问题在于开发者能否知道这些静态属性对应影响的逻辑是什么,在没有看到上文的 Foo 方法的实现的时候,通过看到 Count 的值能否知道 Foo 的返回值是什么?其实很难了解的。如果一个库做的很渣,一个方法依赖很多个其他类的静态属性,那么这个调试起来的难度就太大了,此时比较推荐的是将这个库的代码引入到项目里面,将这个库当成业务代码进行调试
如果将库的代码引入进来,从 NuGet 库的引用修改为代码的引用请看下文
重定向库输出
如果发现真的是库的问题,那么就需要将库加入到代码进行调试
将 Nuget 替换为 csproj 项目可以使用 DllReferencePathChanger 这个插件 使用这个插件可以将某个 Nuget 替换为项目引用
但是此时需要重新编译整个大项目才能进行调试,这样的调试的效率比较低,可以尝试编译了库的代码,将库的调试作为项目的输出文件,通过这个方法做到每次调试编译库代码就可以,提高效率详细请看下面两篇博客
Roslyn 让 VisualStudio 急速调试底层库方法
VisualStudio 通过外部调试方法快速调试库代码
案例
我和少珺在一起写一个 c/s 代码,他发现了后台返回的值他拿不到,经过了断点调试发现了后台有返回 json 字符串,但是他解析出来的是一个空的值
此时他很慌的说,我使用的 json 解析库是我自己写的
听到这里我做了一个错误的决策,我认为需要将他写的 json 解析库加入调试
其实最后发现的问题是他的 json 解析库对大小写敏感,需要添加特性修复这个问题。在少珺的 json 解析库里面,对于 json 的属性名是大小写敏感的,因为我返回的属性都是第一个字符小写的,但是他写的代码里面每个属性都符合命名规范都是第一个字符大写的,需要通过特性的方法重新定向到小写的属性名
这个决策让我和少珺多用了很长的时间,其实在使用库代码的时候,应该相信库的实现是稳定的。即使通过模块测试的方法,也只是确定是否正确使用了库提供的功能。在发现调用了某个库的方法不符合预期的时候,请先确定自己是否按照库提供的接口预期使用。
在发现某段代码出现的问题和库相关,第一时间应该是确定是否自己的代码的问题,也就是跳过和库相关的代码,认为库的代码是正确的。如果此时库的接口影响到了自己的模块的功能,可以尝试桩测试,如果在进行桩测试成功之后,那么可以认为是自己没有按照预期的使用库的接口。可以尝试使用模拟测试寻找库的正确打开方式。最后才是尝试认为这是库提供的问题
框架调试
有时候可能是框架的问题,如 .NET Framework 或 .NET Core 框架,或 WPF 框架等问题,此时需要调试微软提供的框架。调试方法请看
在调试 .NET Core 或 .NET 5 框架运行时的逻辑,可以通过在 VisualStudio 里面开启源代码链接功能,此时 VisualStudio 2019 可以辅助从 GitHub 开源仓库里面将代码拉下来调试。开启方法如下图
模拟调试
如上文所说有些调试是需要在具体的业务
网络模拟调试
使用 Fiddler 模拟
填坑
输入模拟调试
修改代码模拟输入
填坑
单元测试模拟调试
通过单元测试模拟某个接口
填坑
文件读写调试
文件读写调试里面包含了两大方面,分别是依赖库的加载调试和其他文件的读写访问问题的调试。常见的问题就是加载库找不到或加载库出错,或加载库时机不正确等问题。以及对具体的文件进行读写时,定位到具体的对文件读写的模块的问题
加载库调试
调试哪些库被进程加载,或进程在什么时机加载了库,可用到加载库调试方法。加载库调试方法分为静态调试和动态运行调试。静态调试的意思就是不运行进程,直接查看在某个机器上的依赖库引用路径。动态运行调试则可以了解到在什么时机加载了库
静态调试
什么时候会用到静态调试?常见的就是程序猿经常说的话,在我电脑上跑得好好的,为什么在你的电脑就炸了。静态调试适用于在非开发机上,如用户的设备上进行调试。静态调试加载库的重点在于尝试解决依赖缺失问题
依赖缺失问题可能包含的表现如下:
- 在我电脑上跑得好好的,为什么在你的电脑就炸了
- 软件运行到某个模块就崩溃
- 软件无法启动
软件运行过程中,需要依赖许多组件,包括直接依赖和间接依赖。在复杂的用户环境可能遭遇投毒问题
静态调试依赖缺失等问题,可以使用 Dependencies 工具
使用 Dependencies 工具可以重点在可了解是否在目标机器上有依赖缺失,用人话说就是有没有缺少 DLL 依赖。以及应用程序将加载的依赖在哪,即是否被投毒
如下图所示,我拖入了一个名为 Application.exe
的应用程序到 Dependencies 工具里面,看到了有一项名为 Lindexi.dll 被标记找不到。这就是证明存在依赖缺失问题
常见的依赖缺失问题如下图所示
某些仅有开发机才有的负载是常见的问题,如上图的 Lindexi.dll 就只有我电脑才有,自然别人家的电脑由于没有我这个 DLL 从而跑不起来。这就是程序猿说的在我电脑上跑得好好的,为什么在你的电脑就跑不起来的常见原因
额外的,特别注意是否存在名为 vcruntime140d.dll
的缺失。这个 vcruntime140d.dll
是 VC++ runtime 14.0 debug 版本的 dll 的意思。如果看到有这个 dll 的依赖,证明你的相关方提供给你的 C++ 库是使用 Debug 版本构建的,这是不能给到用户端的。最佳方法是重新让其构建一个 Release 版本的 DLL 给你。只有在实在没有办法的时候,才考虑在用户端配上开发环境
使用 Dependencies 工具除了找依赖缺失之外,还可以用来找到是否被投毒的问题。如下图所示,看大家是否能够快速看出来问题
上图里面其实存在一个比较大的问题,那就是 vcruntime140.dll 加载的居然是在 C:\Program Files (x86)\Foo
文件夹里,这就证明被投毒了。大家可以在工具里面看看有没有存在不熟悉或奇怪的路径,如果有,那就可能是投毒的问题
如我记录的 影子系统让 C++ 程序无法运行 这篇博客提到的就是非常标准的被投毒的问题,大家可以看到 MSVCR100.dll 加载的路径是在 C:\Program Files\PowerShadow\App
文件夹里面
动态调试
动态调试分为改动代码的方式和不改动代码通过 WinDbg 调试的方式
VisualStudio 配合改动代码调试
在代码里面添加监听程序集加载的 AssemblyResolve 事件或 Resolving 事件,判断加载程序集名,进入断点。如以下代码
AppDomain.CurrentDomain.AssemblyResolve += (sender, args) =>
{
if (args.Name.Contains("Lindexi"))
{
Debugger.Break();
}
return null;
};
或如下代码
AssemblyLoadContext.Default.Resolving += (context, name) =>
{
if (name.Name?.Contains("Lindexi") is true)
{
Debugger.Break();
}
return null;
};
以上代码的 Debugger.Break
方法就可以让应用程序进程进入断点,和在 Visual Studio 手动打上断点效果差不多
通过这样的方法,配合 Visual Studio 的调用堆栈,即可方便知道是哪个模块加载了程序集,在什么时机加载了程序集
WinDbg 设置在加载到某个 DLL 进入断点
在 WinDbg 里设置在加载到某个 DLL 进入断点,可以使用如下命令
sxe ld:xxx.dll
更具体用法请参阅 WinDbg 设置在加载到某个 DLL 进入断点
调试某个文件是哪个代码创建
在遇到某个特别的文件不知道是那句代码创建的,如我想要调试是哪个模块会在桌面新建一个 1.txt 文件,请看 dotnet 如何调试某个文件是哪个代码创建
界面调试
实时可视化树
填坑
渲染范围
对于 WPF 和 UWP 使用不同方法
在 WPF 可以通过 WpfPerf.exe 查看界面刷新,安装 VisualStudio 可以从 “C:\Program Files\Microsoft Windows Performance Toolkit\WPF Performance Suite\WpfPerf.exe” 找到
填坑
WPF 界面调试
请看 WPF 专项调试 这一节
WPF 专项调试
WPF 如何知道当前有多少个 DispatcherTime 在运行
DUMP调试
在开始 DUMP 调试之前,需要先有收集到 dump 文件,其次再对此 DUMP 文件进行调试
收集 DUMP 文件
收集 DUMP 有多个方法,例如打开任务管理器,右击进程选择创建转储文件。使用任务管理器时,需要小心 x86 和 x64 的区别。对于 x86 的应用进程,推荐使用 32 位的任务管理器进行收集,在 x64 的电脑上的 32 位的任务管理器默认放在 C:\windows\SysWOW64\Taskmgr.exe
路径里
或使用 ProcessExplorer 工具收集 DUMP 文件
或在 WinDbg 里面使用 .dump 创建 dump 文件。这里需要小心一点的是,如果被 WinDbg 工具暂停了进程,那此时抓到的 DUMP 文件,可能存在了一个调试异常,如果看到调试器异常还请不用慌,这是 WinDbg 暂停给出的异常,异常错误码是 0x80000003 STATUS_BREAKPOINT 表示进入断点,常见的堆栈如下
ntdll.dll!_DbgBreakPoint
ntdll.dll!_DbgUiRemoteBreakin
或设置注册表收集 DUMP 文件,请看 win10 uwp 收集 DUMP 文件
或如 dotnet 调试应用启动闪退的方法 提到的 ProcDump 工具进行收集
或在应用程序里面写代码调用 MiniDumpWriteDump 方法进行自动收集
一般收集 DUMP 都是在非开发的机器上进行收集,毕竟开发机器上直接调试不是更香。于是许多时候都需要将 DUMP 文件进行传输,一个小技巧是在传输之前先使用 7z 等工具将 dump 文件压缩一下,压缩一下一般能省非常多的空间。这是因为大部分的 dump 的进程空间都存在大片的纯零数据,这部分数据压缩工具最能压缩空间
非托管相关
被非托管玩坏了内存,可以通过 MDA 助手,在完成 PInvoke 之后立刻调用 GC 回收,从而可以方便知道是在哪个 PInvoke 里破坏了托管堆。详细请看 C# 托管堆 遭破坏 问题溯源分析 - 一线码农 - 博客园
收集到了 DUMP 之后可以使用多个不同的工具进行调试
使用 VisualStudio 调试
优先推荐使用 VisualStudio 进行调试,如果想要调试的仅只是 .NET 层的问题,那可以直接点击托管调试进行快速的调试。这时候的调试和日常的调试进程没有多少差别,只不过 DUMP 调试时不能点继续运行而已
如果想要调试的地方是可能存在的闪退等问题,可能是非托管代码导致的问题,可以使用混合调试模式。混合调试时,将同时使用 Native 调试器和 .NET 调试器,此时可以调试到更多信息。为什么开始只推荐使用托管调试?这是因为开启混合调试时,信息太多,可能干扰调试思路
填坑
使用 dotMemory 调试内存
填坑
使用 WinDbg 调试
使用 WinDbg 调试 .NET Core 系列的应用,包括 dotnet 5 和 dotnet 6 等,需要先加载 sos 才可以进行调试。方法请参阅 WinDbg 加载 dotnet core 的 sos.dll 辅助调试方法
填坑
以下是一些使用 WinDbg 配合调试 DUMP 的例子
收藏一些大佬的 DUMP 调试博客
使用 DUMP 调试还是比较难的,劝退力比较足。因为除了工具的时候比较难之外,如何进行调试,调试的思路和调试的经验都会成为劝退的原因,我收藏了一些大佬的 DUMP 调试博客,大家可以跟随大佬们的调试思路尝试调试一下
性能调试
通过 VisualStudio 分析
填坑
CPU 调试
在遇到程序卡住的时候,或者主线程卡住的时候,可以使用 VisualStudio 调试是什么原因卡住
这里说的主线程指的是响应用户行为的线程,不一定是桌面端的主线程
程序卡住优先看以下两个原因
- 在主线程执行大任务
- 主线程等待锁
按照上面两个原因,我使用一个简单桌面端程序做了课件视频,告诉大家如何调试
如果只是程序运行比较卡,而不是卡住,可以通过 dotTrace 调试,分析是哪些模块执行比较卡
GPU 调试
通过 VisualStudio 分析,通过 PIX 通过 Vtune 调试
填坑
内存调试
内存调试主要是调试内存占用,以及内存泄露
调试内存泄露方法请看课件视频
通过 dotMemory 调试
另外,如果是调试 Linux 等服务器上的 dotnet 应用的内存占用,请看 dotnet 用 gcdump 调试应用程序内存占用
更多博客:
读写性能调试
通过 dot trace 找到读写文件
填坑
远程调试
参阅:
- 使用VS远程调试其他电脑上安装的软件 - 杜文龙 - 博客园
- VisualStudio 使用 FastTunnel 辅助搭建远程调试环境
- UOS 开启 VisualStudio 远程调试 .NET 应用之旅
- VisualStudio 如何 SSH 远程调试 Linux 的 dotnet 应用的启动
经验
经验里面将会包括很多套路,以下是一些案例
应用程序闪退调试
应用程序的闪退可以分为两个阶段,第一个就是启动闪退,另一个就是运行过程闪退。对于启动闪退的情况来说,大多数的时候,应用程序内的日志等模块都没有初始化完成,此时将难以准确定位问题,优先推荐采用 dotnet 调试应用启动闪退的方法 博客提到的方法进行启动调试
如已经启动完成,在运行过程中闪退的,优先定位业务端问题,也就是做了哪些步骤之后,进行闪退。如闪退稳定可以重复演现,那么优先在开发设备上跑代码,定位问题。没有什么能够比自己跑代码定位问题来的更加清晰的
如果闪退是非必现问题,不是一定能够稳定复现的问题,是概率复现的问题的情况下,先翻日志。日志分为两个部分,一个就是应用程序自己记录的日志,在自己记录的日志里面,也许会记录错误原因,或者是前面的步骤,方便后续调查。另一个就是系统事件的日志,大部分的电脑上的系统事件的日志组件都是存活的,在这里面翻翻,也许能够有一些收获。
更多关于日志应该怎么写和应该怎么看,和关于系统事件查看器的系统日志部分,还请参阅上文的 通过日志调试 部分
日志如果没有能很好帮助定位问题的情况下,可以考虑抓一个事后现场的 DUMP 来分析。但是值得一提的是,大多数正常的时候,抓 DUMP 是无奈之举。原因是:这些是事后现场,很多时候最多只能知道应用是怎么崩的,但不知道为什么崩,就如同空异常一样,能够知道某个模块抛出空异常,以及是哪个属性或字段或参数导致的,但是难以知道为什么是空。其次是,如果能够很方便从 DUMP 找到问题,那基本都是代码上的实现缺失,如没有做防逗比措施,自己没有抛出准确的异常,没有好好写日志
抓 DUMP 的方法可以参阅 dotnet 调试应用启动闪退的方法 提到的 ProcDump 的用法来抓 DUMP 文件,然后参阅本文的 DUMP 调试一节进行调试
以及 一线码农 提供的也是通过 ProcDump 工具来抓 DUMP 的方法,请看 如何在 NET 程序万种死法中有效的生成 Dump (上) - 一线码农 - 博客园 和 .NET程序崩溃了怎么抓 Dump ? 我总结了三种方案 - 一线码农 - 博客园
面对不熟悉代码的调试
填坑
通过 git 理解代码
有一些代码明明是可以使用的,但是被添加了某个业务,然后某个业务就不能和之前一样使用。在调试到这个问题的时候不能简单改回去,需要知道为什么那个逗比小伙伴要这样修改
但是这个逗比小伙伴在蹲坑,我不想去找他,我有什么方法可以知道为什么他要这样修改?
或者本金鱼经常不知道自己为什么会这样写代码,我在调试的过程发现有诡异的代码,我如何知道为什么这样做
如果代码里面存在注释,可以通过注释找到这样写的原因。如果是发现上个版本可以使用,但是这个版本被修改了,可以通过 git 的提交信息知道为什么这样修改,在修改的时候可以不掉到上次的坑
有一个笑话是我改了一个 bug 但是测试给我报了 10 个,原因在于我将之前小伙伴解的坑又踩了
填坑
不必现问题
对于不必现问题来说,最佳做法是提高复现,找到最简单步骤。这一步大概属于有手能行,如果刚好有测试小姐姐或小哥哥的帮助,那就太好了,可以节省一些开发者占用的时间
不必现问题也可以分为两大类,一类就是崩溃问题,另一类是功能性问题。如果是导致应用崩溃的问题,请参阅本文的 应用程序闪退调试 这一章的内容
另外,无论如何,只要复现的概率足够高,且存在某个发布版本没有此问题,那都可以试试下面的二分代码的方式定位问题
填坑
二分代码
完全不知道的代码,不熟悉的模块,或不确定是全局的底层库的属性被修改
通过 git 的二分查找,如何使用 git 进行二分,请看我的课件 二分调试
我开源了一个工具,用来辅助 git 二分 commit 调试,可以先将自己应用构建出每个版本,详细请看 用于辅助做二分调试的构建每个 commit 的工具
通过二分注释代码
填坑
测试的环境也有影响
某一天,师兄告诉我,只有在晚上12点的时候,才会出现一个坑,其他时间都不会。这会和什么问题相关?究竟是什么的影响?请看 WPF 触摸屏应用需要了解的知识 虫文这一章
工具
高效率的调试离不开工具的辅助,我收藏的一些工具请看 在 Windows 下那些好用的调试软件
Roslyn 让 VisualStudio 急速调试底层库方法
VisualStudio 通过外部调试方法快速调试库代码
VisualStudio 使用 FastTunnel 辅助搭建远程调试环境
ProcessExplorer
工具下载地址: Process Explorer - Sysinternals | Microsoft Learn
使用 ProcessExplorer 了解某个进程由谁启动
故事的背景是我有一个应用被开启自启动了,但是我在各个开机启动项都找不到启动记录,不知道这个应用是怎么被启动的
我找了哪些地方?我找了常用的开机启动文件夹和注册表路径
- 当前用户专有的启动文件夹:
%AppData%\Microsoft\Windows\Start Menu\Programs
- 所有用户有效的启动文件夹:
%ProgramData%\Microsoft\Windows\Start Menu\Programs
- Userinit注册键
- 注册表地址:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
- 通常该注册表下面有一个
C:\Windows\system32\userinit.exe,
值,但这个键值是允许用逗号来分隔多个程序的,比如 C:\Windows\system32\userinit.exe,C:\lindexi.exe(举例)
- 注册表地址:
- Explorer\Run注册键
- 当前用户的注册表地址:
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run
- 机器级的注册表地址:
HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run
- 当前用户的注册表地址:
- RunServicesOnce注册键
- 当前用户的注册表地址:
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunServicesOnce
- 机器级的注册表地址:
HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\RunServicesOnce
- RunServicesOnce注册键是用来启动服务的,启动时间是在用户登录之前,而且是先于其它通过注册键启动的程序
- 当前用户的注册表地址:
- RunServices注册键
- 当前用户的注册表地址:
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunServices
- 机器级的注册表地址:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\RunServices
- RunServices注册键指定的程序紧接RunServicesOnce指定的程序之后运行,启动时间也是在用户登录之前
- 当前用户的注册表地址:
- RunOnce注册键
- 注册表地址:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce
HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunOnce
- HKEY_LOCAL_MACHINE下面的RunOnce注册键会在用户登录之后立即运行程序,运行的时机是在其它Run键指定的程序之前
- HKEY_CURRENT_USER则会启动比较慢,它会在操作系统处理其他Run键以及“启动”文件夹的内容之后运行
- 注册表地址:
- Run注册键
- 注册表地址:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
- Run是自动运行程序最常用的注册表。会先执行HKEY_LOCAL_MACHINE下的Run注册键内容,再执行HKEY_CURRENT_USER下的Run注册键内容,但两者都是在处理“启动文件夹”之前
- 注册表地址:
这些地方统统都没有找到应用程序的启动配置,那只剩一个可能,那就是被其他进程拉起来的
打开 ProcessExplorer 工具,如果没有使用树的方式显示进程,则点击工具栏的 Process Tree 按钮
找到自己的应用程序,可以快速看到上一级的进程是哪个,从而知道是哪个进程拉起来的
以上的方法只适用于拉起应用程序的进程还存活。如果拉取应用程序的进程已经退出,则通过 ProcessExplorer 工具是找不到拉起的进程的
此时最佳解决方法就是尝试重新复现问题,通过 Process Monitor 开机启动收集,获取到进程启动信息,从而了解到是哪个进程启动应用程序
使用 Process Monitor 定位应用程序被哪个进程拉起的前提是需要能够复现,如果应用程序已经被拉起了,那将无法抓取到有效信息。且使用 Process Monitor 将会输出大量的日志,分析工作量有一些
这就是为什么推荐先使用 ProcessExplorer 工具的原因。使用 ProcessExplorer 只要拉起应用程序的进程没有退出,就能快速找到拉起应用程序的进程