本文将会从简单到高级,告诉大家如何调试 dotnet 的代码,特别是桌面端。本文将会使用到 VisualStudio 大量的功能,通过各种好用的功能提高调试方法
在本文开始将会告诉大家一些套路,也就是遇到什么问题怎么调试,然后将会告诉大家在面对一些棘手问题,例如遇到我不熟悉的代码如何调试,遇到库里面的代码出问题如何调试
除了调试问题之外,本文还包括性能调试,有小伙伴说卡,那么卡在哪,如何找到卡的代码。有小伙伴说占用内存,那么占用内存的代码是什么?
对于客户端还包括渲染方面调试,我觉得我软件显示比较慢,那么是渲染卡还是主线程卡
欢迎小伙伴告诉我一些你的调试方法
课前测试
带着问题阅读效果将会更好
- 如何看待断点调试
- 断点调试应该优先考虑,只要代码能做断点调试的优先进行断点调试
- 断点调试是其他手段的一个辅助,在大多数调试方法里面都用到断点调试
- 在断点调试过程可以了解当前上下文变量状态,以及代码执行逻辑,甚至更改变量值更改执行顺序
- 在断点调试库或框架中最重要的是符号文件,可以通过 dotPeek 反编译生成
- 断点调试一定需要符号文件配合
- 如何看待异常调试
- 在 VisualStudio 使用第一次机会异常,无论用户有没有吞这个异常都能抓到
- 进行异常调试的套路是先通过输出窗口找到对应的异常,再从异常窗口开启
- 异常调试过程在调用堆栈可以发现调用方法的逻辑是否合预期
- 不需要符号文件和源代码都可以进行异常调试
- 异常调试需要依赖具体代码实现,如果在代码实现过程没有考虑异常,那么将无法进行异常调试
- 如何看待多线程调试
- 多线程调试过程会被断点影响,可以通过断点输出的方式降低多线程影响
- 多线程的死锁问题可以通过并行堆栈找出
- 多线程问题可以通过随机暂停方式找到对应的代码
- 在多线程中的控制台输出也会影响多线程代码运行顺序
- 调试过程重点关注多个线程访问到的值的变化以及方法调用顺序
- 在 VisualStudio 可以通过线程窗口看到当前程序开启的所有线程,同时对应线程的调用堆栈
- 如何调试已发布库?
- 在开始调试之前,需要先确定自己写的代码是否清真。应该假定调用的库的接口是符合预期的,和所使用的框架是稳定的
- 如果拿到的库不是稳定的库,或从接口实现上无法明确。可以构建出测试代码用于调试库逻辑
- 在不明确是否库的问题还是自己代码的问题的时候,在确定库代码的输入对应的输出的时候,可以自己模拟创建库的代码进行调试
- 现在微软开源了很多框架,在调试过程应该尽可能将开源代码加入调试
- 在说到性能问题的时候说的方面有哪些?
- CPU 性能
- 单线程忙碌
- 过多 IO 读写
- 渲染性能
- 内存占用
- 面对无从下手的调试的时候可以尝试哪些方向?
- 最短复现,找到最容易复现的方法
- 最小代码模拟测试,确定是否框架或库的问题
- 通过异常代码搜寻以及最短复现方法是否有相关博客
- 通过大量日志追踪
- 进行随机断点
- 从入口函数开始断点调试进入
- 在用户已经出问题的设备上,通过 dnspy 和 VS 附加调试或获取 DUMP 调试
- 查看是否在软件上版本不存在此问题,在上上版本不存在此问题等,通过二分代码找到出代码提交
- 在各大社交网络进行询问
从题目上看,最简单的调试方法从断点调试开始,想要知道题目的答案是为什么,请看本文
断点调试
从 VisualStudio 中打开源代码,进入调试模式,在调试模式里面可以通过断点的方法调试
断点调试可以用来做什么?调试分支,调试执行逻辑,调试当前运行的值
在进行断点调试的时候建议使用 DEBUG 版进行调试,此时几乎可以在任意的代码里面添加断点
在遇到任何坑的时候,第一个应该做的是通过断点调试
例如我在调试下面的代码的时候,发现软件没有按照我预期的运行
if (foo)
{
// 执行某段逻辑,但是这段逻辑没有按照期望被运行
}
此时我应该通过断点,将断点放在判断这句话
添加断点方法
添加断点有很多方法
在需要调试的代码里面,将光标定位到需要调试的代码这一行,默认快捷键按下 F9 添加断点
或者从代码这一行的左边点击一下就可以添加断点
断点可以在运行调试之前添加,可以在调试的过程添加断点,添加成功了断点可以在代码左边看到红点,此时执行到断点的地方,程序将会停在断点这里
除了在打开代码某一行进行断点之外,还可以点击工具栏的 调试-窗口-断点 打开断点设置
点击添加可以添加函数断点,函数断点需要添加限定符,完全的表达式如下
命名空间.类.方法(参数)
例如
WegaljifoWhelbaichewair.Program.Main(string[])
但是一般都可以简写,如不存在重载方法的时候,不需要添加参数,如上面代码可以去掉string[]
在没有重载的主函数。如不存在多重命名冲突的时候,可以去掉命名空间
另外,在调用堆栈里面也可以设置断点,例如在进入某个断点的时候,程序暂停,此时可以通过 调试-窗口-调用堆栈 打开调用堆栈,在调用堆栈里面可以看到进入到当前这一行代码调用的方法顺序
在对应的调用方法右击点击断点可以新建断点
最少用到的是在反编译窗口里面添加断点,点击调试-窗口-反编译在反编译窗口里面右击也可以添加断点
Use breakpoints in the debugger - Visual Studio
详细的断点调试请看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 下载
打开 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进程,不然每次编译十几分钟。其实只要存在 DPB 文件和代码文件基本上都可以在附加进程的时候断点进入原有的代码的逻辑。可以在 VisualStudio 里面不进行重新编译直接调试
附加进程调试
填坑
调试软件启动
如果有些软件是在发布的时候,刚好在软件启动的过程需要进行调试,此时就需要使用调试软件启动的方法,详细请看
异常调试
如果遇到程序运行的过程不符合预期,但是自己又不确定是哪个模块,或者代码太多逻辑很复杂,不知道在哪里下断点的效率才会高,此时可以尝试一下异常调试
异常调试的意思就是通过找到不符合预期的行为是否存在异常,通过分析异常调试
在 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()
在看到这个异常的时候,请问这是在做什么?为什么在这里炸了
写出方便调试的代码
这就是为什么异常不是用来随便扔的,想要在异常调试里面能够快速调试就需要依赖代码对异常的处理
减少线程委托使用
先举一个不好的例子,我看到有小伙伴写了这段代码
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 找到了对应的代码,从而进行断点调试
不要在静态构造函数抛出异常
填坑
区分发布代码
在一些模块,即使出现了异常还是可以正常工作,但是如果没有吃掉这个异常将会让整个软件无法使用。但是有很多逗比的开发者会写出逗比的代码,我期望让他在开发的时候就发现,于是我就通过了判断当前是 DEBUG 版还是发布版执行不同的逻辑
例如在希沃白板软件加载课件的过程,每个课件里面都有不同的页面,如果某个页面加载出现异常,我不期望用户整个课件都打不开,于是就吃掉了页面加载的异常。但是页面是很多小伙伴写的,我期望在开发的时候小伙伴就能发现这里有异常,我通过下面的代码区分了发布版
static void Foo()
{
#if DEBUG
throw new Exception("林德熙是逗比");
#else
Console.WriteLine("林德熙是逗比");
#endif
建议是在 DEBUG 下只要不符合预期就抛异常,这样可以在开发的时候减少诡异的使用
保存堆栈
很多初学 dotnet 的小伙伴喜欢吃掉全局的异常然后重新抛
try
{
Foo();
}
catch (Exception e)
{
throw e;
}
这个做法是很逗比的,在外层拿到的 e 将会丢失了在 Foo 里面的堆栈信息
更多方法
我推荐小伙伴阅读以下博客了解在代码中如何写
开启所有异常
在进入异步等的过程,会发现有一部分的异常提示不在具体的代码,而是在上一层的代码提示,此时可以通过在提示的哪个异常就开启哪个异常的方法,找到对应的代码
但是如果发现提示的异常是合并的异常,或者需要开启的太多了,可以尝试开启所有的异常
在调试窗口异常设置里面,如果前面的分类是一个方形那么就是开启默认的异常,此时有很多异常都是被忽略的。再点击一次变成勾就可以开启所有的异常
对于很多渣的软件,包括调试 VisualStudio 的过程是不建议开启所有的异常,因为有很多无关的代码特别是异常控制流程会干扰调试
通过开启所有异常的调试大家也知道异常控制流程会影响到调试的方法,在我开启所有异常的时候,如果存在很多异常控制流程的代码,那么将会在调试的时候被这些诡异的代码影响
但是有时候开启了所有的异常还没有让 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 使用方法
更多关于 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 急速调试底层库方法
案例
我和少珺在一起写一个 c/s 代码,他发现了后台返回的值他拿不到,经过了断点调试发现了后台有返回 json 字符串,但是他解析出来的是一个空的值
此时他很慌的说,我使用的 json 解析库是我自己写的
听到这里我做了一个错误的决策,我认为需要将他写的 json 解析库加入调试
其实最后发现的问题是他的 json 解析库对大小写敏感,需要添加特性修复这个问题。在少珺的 json 解析库里面,对于 json 的属性名是大小写敏感的,因为我返回的属性都是第一个字符小写的,但是他写的代码里面每个属性都符合命名规范都是第一个字符大写的,需要通过特性的方法重新定向到小写的属性名
这个决策让我和少珺多用了很长的时间,其实在使用库代码的时候,应该相信库的实现是稳定的。即使通过模块测试的方法,也只是确定是否正确使用了库提供的功能。在发现调用了某个库的方法不符合预期的时候,请先确定自己是否按照库提供的接口预期使用。
在发现某段代码出现的问题和库相关,第一时间应该是确定是否自己的代码的问题,也就是跳过和库相关的代码,认为库的代码是正确的。如果此时库的接口影响到了自己的模块的功能,可以尝试桩测试,如果在进行桩测试成功之后,那么可以认为是自己没有按照预期的使用库的接口。可以尝试使用模拟测试寻找库的正确打开方式。最后才是尝试认为这是库提供的问题
框架调试
有时候可能是框架的问题,如 .NET Framework 或 .NET Core 框架,或 WPF 框架等问题,此时需要调试微软提供的框架。调试方法请看
模拟调试
如上文所说有些调试是需要在具体的业务
网络模拟调试
使用 Fiddler 模拟
填坑
输入模拟调试
修改代码模拟输入
填坑
单元测试模拟调试
通过单元测试模拟某个接口
填坑
文件读写调试
找不到库找不到文件
加载库调试
判断文件加载的是哪些库
填坑
调试某个文件是哪个代码创建
在遇到某个特别的文件不知道是那句代码创建的,如我想要调试是哪个模块会在桌面新建一个 1.txt 文件,请看 dotnet 如何调试某个文件是哪个代码创建
界面调试
实时可视化树
填坑
渲染范围
对于 WPF 和 UWP 使用不同方法
在 WPF 可以通过 WpfPerf.exe 查看界面刷新,安装 VisualStudio 可以从 “C:\Program Files\Microsoft Windows Performance Toolkit\WPF Performance Suite\WpfPerf.exe” 找到
填坑
WPF 界面调试
请看 WPF 专项调试 这一节
WPF 专项调试
DUMP调试
收集 DUMP 有多个方法,例如打开任务管理器,右击进程选择创建转储文件。或在应用程序里面调用MiniDumpWriteDump 方法
或在 WinDbg 里面使用 .dump 创建 dump 文件
或设置注册表收集 DUMP 文件,请看 win10 uwp 收集 DUMP 文件
收集到了 DUMP 之后可以使用多个不同的工具进行调试
使用 VisualStudio 调试
填坑
使用 dotMemory 调试内存
填坑
使用 WinDbg 调试
填坑
性能调试
通过 VisualStudio 分析
填坑
CPU 调试
在遇到程序卡住的时候,或者主线程卡住的时候,可以使用 VisualStudio 调试是什么原因卡住
这里说的主线程指的是响应用户行为的线程,不一定是桌面端的主线程
程序卡住有两个原因
- 在主线程执行大任务
- 主线程等待锁
按照上面两个原因,我使用一个简单桌面端程序做了课件视频,告诉大家如何调试
如果只是程序运行比较卡,那么可以通过 dotTrace 调试
GPU 调试
通过 VisualStudio 分析,通过 PXI 通过 Vtune 调试
填坑
内存调试
内存调试主要是调试内存占用,以及内存泄露
调试内存泄露方法请看课件视频
通过 dotMemory 调试
另外,如果是调试 Linux 等服务器上的 dotnet 应用的内存占用,请看 dotnet 用 gcdump 调试应用程序内存占用
读写性能调试
通过 dot trace 找到读写文件
填坑
经验
经验里面将会包括很多套路,以下是一些案例
面对不熟悉代码的调试
填坑
通过 git 理解代码
有一些代码明明是可以使用的,但是被添加了某个业务,然后某个业务就不能和之前一样使用。在调试到这个问题的时候不能简单改回去,需要知道为什么那个逗比小伙伴要这样修改
但是这个逗比小伙伴在蹲坑,我不想去找他,我有什么方法可以知道为什么他要这样修改?
或者本金鱼经常不知道自己为什么会这样写代码,我在调试的过程发现有诡异的代码,我如何知道为什么这样做
如果代码里面存在注释,可以通过注释找到这样写的原因。如果是发现上个版本可以使用,但是这个版本被修改了,可以通过 git 的提交信息知道为什么这样修改,在修改的时候可以不掉到上次的坑
有一个笑话是我改了一个 bug 但是测试给我报了 10 个,原因在于我将之前小伙伴解的坑又踩了
填坑
不必现问题
提高复现,找到最简单步骤
填坑
二分代码
完全不知道的代码,不熟悉的模块,或不确定是全局的底层库的属性被修改
通过 git 的二分查找,如何使用 git 进行二分,请看我的课件 二分调试
通过二分注释代码
填坑
测试的环境也有影响
某一天,师兄告诉我,只有在晚上12点的时候,才会出现一个坑,其他时间都不会。这会和什么问题相关?究竟是什么的影响?请看 WPF 触摸屏应用需要了解的知识 虫文这一章
工具
高效率的调试离不开工具的辅助,我收藏的一些工具请看 在 Windows 下那些好用的调试软件
本文会经常更新,请阅读原文: https://dotnet-campus.github.io//post/dotnet-%E4%BB%A3%E7%A0%81%E8%B0%83%E8%AF%95%E6%96%B9%E6%B3%95.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 lindexi (包含链接: https://dotnet-campus.github.io/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 。