本节内容将颠覆你之前对断点调试的认知。这个调试技巧鱼鹰也用了半年多了,当时知道这个调试方法的时候特别兴奋,感觉发现了新大陆。而这个调试技巧也在鱼鹰接手公司项目代码的时候快速解决了不少疑难杂症,而前些天又扩展学习了这个技巧的功能,更是让鱼鹰在学会之后轻松解决了好几个一般调试方法很难解决的 BUG,相信这个技巧也将为鱼鹰之后的开发调试之旅发挥更大的作用。
    我们知道常规的断点调试是在想观察哪里的问题时就在对应的代码地址设置断点,并且一旦运行到断点位置会让程序自动暂停运行,这种断点调试功能确实为开发者解决 bug 立下了汗马功劳,但是这种方式有很大的局限性,因为很多时候我们并不需要让程序停下来,而只想知道是否在这段代码运行过,或者说发生问题的位置根本不能停下来,否则就会让整个系统功能出现问题,比如中断处理函数的调试,程序一旦停下了也就失去了所有中断的后续响应;比如两个设备通信,一方采用常规断点的方式调试,肯定会打断正常的通信过程,而这可不是我们想要的,我们只想知道在收到或发送数据后得到环境快照,而并不想让程序停下来。以上这些问题可以采用打印方式解决,但是打印调试也有很多弊端:
    以串口为例:
    1、你必须添加必要的打印和串口驱动代码,如果你使用 printf 函数,你还得重定向(如果对空间要求高的话,你得知道使用 printf 差不多要占用 1K 大小代码空间)。
    2、如果打印效率比较低,常规波特率 9600 和 115200 打印一个字符串耗时可能比较久,那么对于中断频率较高的函数就可能就不适用了。如果你使用 printf 函数,你还得考虑函数是否可重入问题。
    3、在代码中引入调试代码有风险,本来程序运行没有问题的,一旦引入调试代码之后可能就出现了问题,这种情况对于拥有丰富开发经验的人来说应该见怪不怪了。原因就在于打印输出时间太久,打乱了程序运行的节奏(而这也是我推荐使用 ITM 调试的一个原因,因为它的输出效率比串口要高得多),或者打印函数本身有问题,也会导致程序运行出现问题。
    4、调试完毕之后,你必须把对应的调试代码删除(不管是删除代码还是使用宏,都要进行这一步),不然会影响运行效率。而人是健忘的(也不能说健忘,可能只是因为专注于 BUG 本身,容易忘记其它细枝末节,而解决 bug 之后的欣喜更可能忘记后续处理工作了)这个时候你可以尝试用 #warnning。但是这一步还是必不可少。
    而以上问题的解决方案就是 KEIL 的断点调试窗口!
    首先打开数据观察点的窗口:
    断点窗口 - 图1
    快捷键是 Ctrl + B。
    可以看到如下窗口:
    断点窗口 - 图2
    当然你也可以通过下面这种方式打开并设置:
    断点窗口 - 图3
    从这里你会发现,其实这个窗口就是用来管理你设置的断点的。平常使用的设置断点方法只是其中的一种特例罢了。
    首先要知道的就是,调试器支持的断点数量是有限的,具体有多少视情况而定,一旦 KEIL 警告你设置断点太多,那么就要删除一些断点了:
    断点窗口 - 图4
    常规用法
    1、代码位置运行次数
    有些时候我们想知道某些代码的运行次数,比如进入中断处理函数的次数,寻常的断点设置方式必然会让程序停止在中断程序中,但有些时候我们并不希望它停下来。这个时候,你只需要打开该窗口,找到已有的对应断点位置,双击之后就可以看到类似下面的窗口:
    断点窗口 - 图5
    此时,你将 Count 的值设置的尽可能大一些,那么就可以让程序运行多次之后才停止。
    比如我们设置 Count 的值为 100 次,那么必须在该代码位置运行 100 次才会让程序暂停。当你设置完后点击【Define】后,就会询问你是否需要重新定义,你选择“是”即可。
    断点窗口 - 图6
    这样你的断点变成了这样:
    断点窗口 - 图7
    后面的 count=100 表示剩余运行次数为 100,运行 100 次后将停止程序。前面的 00 代表断点号,E 代表这是一个执行断点,0x080016B0 代表代码地址,后面的是源码位置。
    当这个断点位置运行了 2 次,重新打开该窗口(刷新数据),发现这个数变成了 98,从而可以推算出,已经运行了多少了。如果说你想让这段代码运行 2 次后停止,那么你只需要一开始设置 Count 的值为 2 即可。
    断点窗口 - 图8
    2、数据访问
    有些时候我们需要知道一些变量会在哪里被访问,那么你可以设置该变量的访问条件。比如鱼鹰想知道 emOspery 变量会在哪里被读取?那么你只需设置如下:
    断点窗口 - 图9
    定义之后就是这样:
    断点窗口 - 图10
    因为 Count 值设置为 1,所以每一次读取 emOspery 的操作都将使程序停止。比如这段代码:
    断点窗口 - 图11
    还有后面的打印函数也使用 emOsprey 变量,所以也会导致程序运行停止。可能你会感到奇怪,为什么 emOsprey++这样的操作也会涉及到读取?事实上你理解了 CPU 寄存器存在的意义也就明白了。
    而当你设置为写(Write)访问时,你会发现从复位程序开始运行后,程序会停止在某个地方,这是为什么?当你知道全局变量会在进入 main 函数之前被初始化时,你也就明白为什么了。
    在这里我们选择使用 Objects 访问,即按整个变量对象进行访问,上面的 emOsprey 变量实际上是 uint16_t,所以 len 为 2,即字节大小。也就说,如果你设置为 Objects 访问,那么它会根据实际的情况设置访问范围。
    为了更好的说明这一点,我构造一个结构体。
    断点窗口 - 图12
    这个结构体大小可以看出是 6 个字节。
    然后设置访问该结构体的条件:
    断点窗口 - 图13
    如果我们按 Objects 访问的话,那么下面的每一条语句都会导致程序运行的停止。
    断点窗口 - 图14
    这是因为这些数据都在 Osprey 结构体的范围内(从这里也可以了解到,只要在 len 的范围内的访问都会导致程序停止运行,所以你可以试试将 Size 设置得更大)。
    而如果设置为 Byte 访问的话,那么就只有第一条语句才会导致程序停止运行:
    断点窗口 - 图15
    实际上如果你希望只在某个结构体成员变量被访问时才停止,那么直接这么设置就可以:
    断点窗口 - 图16
    你会发现设置是如此之简单。
    实际上还有一种更为通用的访问方式,即按地址访问。
    上面可以看出 Ospery.Ospery1 成员变量的地址为 0x20000016(由此我们知道也可以通过这个来看出一个结构体变量的地址是多少)。所以我们可以这样设置:
    断点窗口 - 图17
    而代码位置的断点设置亦是如此。
    断点太多,怎么知道程序因何停止?看你的命令窗口就知道了:
    断点窗口 - 图18
    3、数据匹配
    有些时候,我们并不关注地址访问情况,而对变量的数据内容感兴趣。比如说鱼鹰想让变量emOspery 等于 1 时停下来,怎么设置?
    断点窗口 - 图19

    只要简单的设置 emOspery == 1 即可(注意必须设置访问条件,并且 Size 设置正确)。
    事实上你也可以设置两个变量相等作为条件:
    断点窗口 - 图20

    设置为不等也是可以的:
    断点窗口 - 图21

    当然还有其它支持的运算就靠你们自己去发现了(可支持运算:&,&&,<,<=,>,>= ,==,!=)。
    注意:以上内容可以组合使用,比如读、写条件,计数器计数等可以同时设置。满足条件时就会让程序运行停止。
    高级用法
    以上为比较常规的调试功能,现在说说鱼鹰刚学习的技能,这个技能的使用灵活性更大,而且对于解决疑难杂症更是不二之选。
    首先设置一个你需要的断点:
    断点窗口 - 图22

    打开断点窗口,并双击你之前设置的断点:
    断点窗口 - 图23

    设置 Command 为【printf(“USRAT_Init()\n”)】(注意\n,否则可能不能输出,这个应该是 KEIL 的一个 bug)。最后【Define】
    清空你之前的命令(如果你不嫌乱的话,也可以不清空):
    断点窗口 - 图24

    那么你的程序每次运行到这个代码位置都会在Command 窗口输出一条信息:
    断点窗口 - 图25

    但是你的程序并不会停止。
    如果说你想让断点代码位置运行多次之后才输出一条信息也是可以的,只要设置Count即可。
    这里可能你会问,这 printf 不就是我们写的打印函数吗?事实上,是,也不是。
    这个函数是打印函数没错,但是这是 KEIL 调用的打印函数,输出位置是 Command 窗口,和你自己写的代码没一点关系,每次触发条件时KEIL 都会调用该函数进行打印,而不会让你的程序暂停运行。事实上这个 Command 绝不仅仅只是设置 printf这么简单,如果真是这样我也不会如此推崇它了,感兴趣的可以去官网查找关于调试命令的使用方法。
    因为是利用 KEIL 去执行打印任务,所以对你的程序几乎没有任何影响,并且在你设置断点后也不用担心删除代码问题,可以放心饮用。还有一个额外的好处就是,对于所有能设置调试断点的单片机都适用,因此对于调试器也就没有过多的要求了,比如说,不管你是用JLINK、ST-LINK 还是CMSIS-DAP(CMSIS-DAP 不能使用ITM,所以鱼鹰才会想着用别的方式替代。总算是找到了,而且它在某些方面更出色),都可以这么用。
    现在摘录官网一些关于断点窗口的知识:


    表达式定义断点类型:
    §当设置标志 Read Write 或两者时,访问中断(**A)被定义 。发生指定的内存访问时会触发断点。以字节为单位指定内存访问窗口的大小,或者以表达式的对象大小指定。对于此断点类型,Expression 必须解析为内存地址和内存类型。允许的运算符(&,&&,<。<=。>,> =,= =和!=)在程序执行暂停或执行命令之前比较变量值 。
    §当
    Expression 解析为代码地址时,将执行执行中断(**E)。到达指定的代码地址时触发断点。代码地址必须引用 CPU 指令的第一个字节。
    §当 Expression 不能简化为地址时,定义条件中断(**C)。当条件表达式变为TRUE 时,断点将触发。在每条 CPU 指令之后重新计算条件表达式,并且会大大减慢程序执行速度。
    计数值指定的次数的断点表达式必须计算为 TRUE 断点触发之前的数目。
    命令被指定的μVision 执行语句,然后恢复执行程序。此处指定的命令可以是μVision 调试或信号功能。要从这些函数中暂停程序执行,请设置系统变量 break
    注意
    当在模拟器中将
    访问断点**(读或写)设置为外设寄存器(SFR)时,即使应用程序未访问外设寄存器,断点也可能触发。发生这种情况是因为μVision 模拟器在应用程序驱动和模拟器内部访问之间没有区别。


    里面有一个比较关键的就是关于条件中断(**C),如果你设置的表达式不是一个代码地址,也没有设置读写访问条件,那么就会被设置为条件中断,一旦设置为条件中断,那么会在每条汇编指令后计算表达式,这会影响程序正常运行速度,所以没有必要的话,不要设置为条件中断。
    设置断点的一般错误总结:**
    当弹出以下窗口时,说明断点设置错误,需要查看命令窗口才能知道具体错误信息。
    断点窗口 - 图26

    1. a) 断点太多

    删除一些断点即可

    1. b) 重复定义断点

    这是因为之前你已经定义了这个断点,而现在你又定义了这个断点,这个时候你可以选择覆盖之前的断点或者保留之前的断点

    1. c) 不允许对同一个资源设置不同类型断点

    这个是由于对同一个资源准备设置不同断点导致的,需要删除之前的设置的断点才行。

    1. d) 表达式错误

    检查你的表达式是否正确,注意如果你使用了运算符,那么对于浮点变量的支持好像并不正常,不管你怎么设置,都说表达式错误。

    到此,断点窗口(前期我叫它数据观察点,我也不知道从哪看到的这个词,后来觉得还是断点窗口比较准确)的内容就结束了。这个小节内容对于调试而言绝对是一大利器,也是鱼鹰决定写这个KEIL 调试系列文章的主要原因。但是以上所有的调试内容都有一个很大的局限性,就是它只能定格在某一刻(如果你使用Command 命令就不一样了),而这一刻前面的所有信息都无法知晓。这个时候就要了解另一个调试技能,ITM,它能将程序从出生(复位程序开始)到死亡(死循环或者断电)的大部分信息记录下来。这个章节内容早已发布,感兴趣的就去前面看一看咯。