16 KiB
how to locate bug in your code
[toc]
只讨论运行中的bug(指程序的运行结果不符合你的期望)和异常(直接异常终止)。编译期出现的warning和error不在此范畴,他们都可以通过直接阅读报错信息解决。
Debug方法论
首先,请阅读module和bsp的标准使用示例,检查你的代码和示例是否有所不同。如果你正在编写一个新的模块,务必使用增量式编程的方法构建你的模块,每完成一部分的功能就进行单元测试,看看是否符合你的期望。千万不要一口气写完,这时候再来测试,你就无从下手了。
如果确认软件没有问题,先别急着单步调试,检查一下硬件连接是否正确,包括CAN的H和L,串口的TX RX以及电源和GND。 如果你的硬件有多个相同的接口但是使用不同的功能,你得好好看看是否插错接口了。 软件中对hdevice(hcan/hspi/htim等)的设定是否和CUBEMX中配置的一致? 模块的id和地址是否写错。
修改之后要保存,编译,再调试/下载。建议你把自动保存打开,并勤劳地commit。push时注意reset这些调试时产生的commit,合并成一次提交。你也可以新建一个分支用于解决bug,这是git的最佳实践。
同时注意条件编译的兼容,你是否测试一些应用或模块,却使用了错误的 #define
?
完成上面的两步仍然无济于事,开始单步调试吧。在调试的同时,再仔细看看你的代码是否写错变量名和运算符,比如,把 ==
写成了 =
,那么判断条件将永远是 true
。在值得怀疑的地方,打开汇编视图,查看你要访问的地址是否正确。一步一步往下的同时,关注调用栈是否符合你的期望,添加必要的变量到调试窗口,看看他们何时发生意想不到的变化。条件断点是一个杀手级功能,你可以设置一些条件,让条件满足时程序在此处停下。下面的一些常见问题可能对你的调试有所帮助。
如果一切正常(或者应该说你没有发现异常,虽然他确实在那里),试着查看你使用的外设寄存器值。这时候你要用到芯片的数据手册和功能描述手册。在运行的每一步中,对照datasheet的寄存器状态和改变,看看是否符合你期望的程序行为。寄存器分为控制寄存器/状态就寄存器/数据寄存器,参照datasheet就可以明白这些寄存器是控制哪些功能,描述什么状态以及内部是什么数据。
如果你的数据是连续或规则的,记得使用Ozone可视化工具配合条件断点。
也可以让copilot帮帮你,选中你认为可能出错的代码,选择copilot brush-》debug
一些常见问题
HardFault_Handler()
99%是由于野指针和非法内存访问导致的。在HardFault函数内添加一句 asm("bx lr");
, 并在此处加上断点。当代码运行至此处时,选择跳出,程序会跳转回出错之前最后一句执行的语句。
查看你是否在出错前的最后一次操作中访问了非法地址或使用了已经被析构的指针。 memcpy
的目标地址和源地址重合也可能引发硬件错误。另外,如果使用指针访问了一个非对齐地址(请参考__pack()相关的说明),这是CMSIS架构中不允许的(有些架构可以修改启动文件使其支持);例如你有四个uint8类型的数据被存储在0x03-0x07的地址内,这时候你通过强制类型转换,以float的方式读取这四个字节,就会发生非对齐访问。 虽然结构体可以通过 __pack(1)
来压缩,编译器会对结构体变量进行处理,在读取非对齐字段时分别读取拆分的两个部分再进行合并,从而支持非对齐访问;但前述的行为却是未定义的,编译器在编译代码的时候并不知道你会以分开的方式访问这段内存,即使知道,他也无法预测栈上分配的空间是否能对齐。
常见的错误还包括使用未初始化的指针(内部可能时垃圾值,指向未知的地址)和初始化为NULL的指针(指向0x00地址)。free
一个指针两次也可能导致错误。
通信外设传递的数据没有进行压缩
经典的 __pack()
问题。这种问题大多出现在结构体的传输上。用于通信的结构体请在两端用 #pragma __pack(1)
和 #pragma __pack()
包裹。否则传输时会出现空字节,使得数据和你使用的协议对不上号。
Delay或定时器卡死永远不跳出
Systick和HAL_Delay以及使用TIM来定时的方法,都需要通过中断来更新时间。如其重装载计数器上限为65535,当计数达到此值时会触发中断,在中断处理函数中,增加一次溢出的时间。如果此时有更高优先级的中断或同优先级的中断正在运行,且他们耗时很长 or 调用了这些依赖中断的Delay函数,那么将会形成死锁,永不见天日。 如果中断被关闭,这些计时也无法更新。 这里推荐使用DWT定时器(在 bsp_dwt
中实现),其重载计数器是64位的,按照stm32f407 168MHz的运行频率,需要两天多的时间才会发生溢出,因此仅依赖其重载计数器,计算两次tick的差值就可以实现高精度的定时(除非你delay超过两天,你在搞笑)。
静态变量的陷阱
注意,函数内的静态变量只会在程序启动的时候初始化一次,之后不论多少次重入,都会保存上一次修改的值。
然而,如果静态变量被放在头文件里,则每个包含该头文件的其他源文件,都会拥有一份自己的备份,这些备份之间是互不影响的(详见编译期的头文件展开)。 千万不要认为静态变量是全局的,它只是在当前文件内有效。
指针越界读写/内存泄漏
有时候,你发现你使用的变量值变成了奇怪的数,或者你的程序突然崩溃了,但是你并没有在代码中对这个变量进行过修改。这时候,你应该检查一下你的指针是否越界了。 你也许没有正确的将void*指针cast成期望的类型,使其访问了不该访问的位置;例如,一个uint8类型长度为4数组,你希望将其转化为float进行读取。但你在声明数组时只分配了3个字节,使用float*访问就会触及未知的第四个字节,第四个位置上存放的可能是其他变量的值,这时候访问其他变量就会出现奇怪的值。 或者,你在 memset()
和 memcpy()
的时候没有正确设置源地址和目标地址或长度。
实时系统
中断中使用实时系统的接口时记得使用有 ISR
后缀的版本,它们对中断的调用做了特殊处理。所有中断的优先级都是高于RTOS中任务的优先级的。
若期望某个任务以1KHz频率运行,你可能会在任务循环外加一个 OS_Delay(1)
,但是你的任务执行时间要是超过了1ms,系统的调度就会出现异常,倘若你还在此任务内认为每次进入的时间间隔是1ms并据此编写了一些依赖周期性的代码,那便是大错特错了。
对于实时性和周期性要求高的任务,使用 vTaskDelayUntil()
,这可以获得更高的定时精度。
不同的任务/中断调用了相同的函数,或使用了共享变量/全局变量/函数内的static变量会导致读&写冲突,也被称作数据竞争。这一般只会在任务繁重且中断频率高时出现。要避免这种情况,访问共享变量时应进入临界区(关闭全局中断)或使用互斥锁。消息队列也是一个不错的选择,这些功能在FreeRTOS中都提供了支持。
中断
中断不要放入太复杂的逻辑和运算。使用标志位或将要处理的数据转移到队列中,于任务中检查标志位,判断是否要进行数据的处理。否则可能出现中断过于复杂/频繁使得通信出现overrun error
强烈建议初始化时不要使用和中断相关的功能,若你在中断过程中访问了一些尚未初始化的变量,就很大概率会出现前述的野指针/越界等问题。 非要使用,请添加一个标志位,并让中断判断初始化是否完成。当前框架在初始化机器人的时候关闭了全局中断,所以千万不要使用中断和与symstick/HALtick有关的延时!
强制类型转换
long long的范围比float小。无符号和有符号数直接转换可能变成负数。应该在一切可能表达运算顺序错误的地方加上括号以明确操作的顺序,即使你知道不同运算符的优先级。 使用移位操作要注意你的变量类型,请不要相信编译器的临时变量生成,务必加上类型转换。
例如:#define MY_MACRO_NUMBER 3.0f
,使用宏定义一些带小数点的数据时记得加上.0或f后缀,干脆两个都加。无符号定义要加u后缀。
宏
换行用 \
,注意同一个代码块展开后用花括号 {}
包裹,特别注意宏展开之后是直接的文本替换!!!
用已有的宏定义宏并且进行运算时,要将后面的字段用括号包围,因为:
#define YOUR_DEF SOMETHING
// 宏通过空格来解析替换字段,YOUR_DEF空格后的第一个字段当作替换文本
宏只在当前文件生效,如果宏放在.c那么对其他的文件是不可见的,这也一般称作私有宏。
典型debug案例一
这是一个结合了软件和硬件且有多模块耦合的异常。该bug发生在调试平衡步兵的底盘过程当中。
引发bug的原因
- 指针在强制类型转换中变成了错误的类型,使得指向的内存地址被错误地修改
- CAN总线负载过大导致电机反馈消息丢失
这里是发生bug的代码片段:
static void LKMotorDecode(CANInstance *_instance)
{
static LKMotor_Measure_t *measure;
static uint8_t *rx_buff;
rx_buff = _instance->rx_buff;
measure = &((LKMotorInstance *)_instance)->measure; // 通过caninstance保存的id获取对应的motorinstance
// 上面一行应为: measure = &(((LKMotorInstance *)_instance->id)->measure);
measure->last_ecd = measure->ecd;
measure->ecd = ...
// ....
}
这是问题1的出处。can instance中保存了父指针,即拥有该instance的LKMotorInstance。这里想通过强制类型转换将 void*
类型的 id
转换成电机的instance指针类型并访问其measure成员变量以从CAN反馈的报文中更新量测值。然而却直接将can instance转换成motor instance。
随后,更新之后的数据被覆写到can instance内部,使得其成员变量改变,包括hcan、txbuf、rxbuf、tx/rxlen等。hcan是HAL定义的can句柄类型,里面保存了指向can状态和控制寄存器的指针以及其他HAL状态信息,然而其值被电机反馈回来的值覆写,之后HAL的接口访问hcan时将引起异常。
第二个问题则不是显式存在的:
void MotorControlTask()
{
DJIMotorControl();
HTMotorControl();
LKMotorControl();
ServeoMotorControl();
StepMotorControl();
}
这是motortask的内容,此任务将以500hz的频率运行。在发生bug时,我们将4个HT04电机和2个LK MF9025电机全部连接到CAN1上。注意,HT04不支持多电机指令,因此占用的带宽较大。在 LKMotorControl()
完成参考值计算和CAN发送之后立刻会调用 HTMotorControl()
,后者需要连续发送4条报文。而HT和LK电机都会在接收到控制指令之后发送反馈信息报文。由于HT电机的控制在LK电机控制之后立刻执行,导致总线被占据,LK电机发送的反馈数据仲裁失败无法获得总线占有权,使得主机收不到反馈数据。
bug的发现和定位的尝试
程序的大体情况如下,当时进行轮足式倒立摆机器人的测试,启用了balance.c,在其中注册了4个HT04电机(can1)和2个LK9025电机(can2)。控制报文的发送频率均为500Hz。
测试时发现,9025电机可以接收到mcu发送的控制指令并响应,但是mcu始终无法获得反馈值,LKMotorInstance->measure
的所有成员变量一直是零。由于CAN是总线架构,电机能接收到数据说明通信正常。HT04电机也可以正常控制并收到反馈信息。在 LKMotorDecode()
函数中添加断点发现能够成功进入1~2次,随后便引发HardFault。
此时内心有些动摇,开始检查硬件连线。我们尝试把LK电机也挂载到CAN1总线上。开始单步调试,发现LK电机可以正常接收一次反馈报文,之后就进入 Hardfault_handler()
。HT和DJI电机均无此问题。进一步进行每条指令的调试,发现在成功接收到一次报文之后(接收报文指的是can发生中断并在处理函数中调用LK电机的解码函数,我们并没有查看measure值是否刷新,实际上这时候反馈值仍然为零),进入该电机的控制报文发送时,通过在 Hardfault_handler()
中添加汇编语句 asm("bx lr")
,即跳转到最后一次执行的指令,发现访问 hcan->state
会引起硬件错误。遇到这种情况,说明发生了越界访问或使用了野指针。检查hcan的值,发现是一个非常大的地址。因此怀疑hcan指针被其他的内存访问语句修改。
有了方向之后,进一步对每一个函数都进行单步进入调试,同时时刻监测hcan1的值。然而,这时候出现即使一开始就单步调试也无法进入LK电机解码函数的问题。于是,怀疑是CAN过滤器的配置问题,使得LK电机反馈报文被过滤,检查LK的接收id无误后,认为可能由于LK电机的发送和接收ID都比较大(0x140和0x280),CAN标准ID放不下。但是查阅CAN specification后发现standar ID可以容纳11位的值,应该不会有问题。于是把过滤器配置为mask模式,让bxCAN控制器接收所有报文(即不进行过滤)。然而还是不奏效,仍然无法收到数据。
这时候想起HT电机是不支持多电机控制指令的,因此500Hz的控制频率似乎有些过高,相当于2ms内要完成2x4+1+2=11次CAN报文的发送。计算1M波特率下最大通信速率,果然超出了负载。于是降低 MotorTask()
的频率为200Hz,果然能重新接收到数据了。
继续单步调试,终于发现在 LKMotorDecode()
中,通过强制类型转换获取LKMotorInstance的时候,用错了变量,使得反馈值被写入电机的 CANInstance
内,导致hcan指向随机的地址,最终造成访问时引发hardfault。
修改之后,将LK电机挂载到CAN2上,控制频率回到500Hz,程序正常运行。
解决方案
均衡总线负载,调节任务运行时间。
典型debug案例二
这仍然是一个CAN总线引发的bug。使用的电机均为DJI电机。当多个电机接入时,会产生反馈值跳变的情况。起初认为总线负载过高,(控制频率为500Hz,反馈频率均为1kHz,计算之后得出CAN的负载率接近90%),但将电机减少为一半甚至更少时仍然出现此问题。单独使用CAN1且仅挂载一个电机则问题消失,同时使用CAN1和CAN2(不论单个总线挂载几个电机)则问题再次出现。
单步调试发现反馈值并未因指针越界而被纂改。仔细检查代码的计算发现并未出错,打开Ozone查看反馈值曲线,发现确实偶发跳变,但跳变值并未超出反馈值范围,即即使发生跳变值仍然在正常范围内,因此不像是总线负载过大导致数据帧错误或指针越界修改的随机值。加入多个电机同时查看反馈值,发现反馈跳变之后会和另一电机的反馈值相同,呈现“你跳到我我跳到你”的图景。怀疑CAN中断被重入,即一个中断未完成时另一个CAN报文到来,打断了当前的中断并执行了相同的反馈解码函数。但CAN1和CAN2的中断优先级均为5,因此不可能打断彼此。打开CubeMX查看初始化配置,发现两个CAN的FIFO0和FIFO1中断优先级不同,分别是5和6。则FIFO1的溢出中断会被FIFO0打断,且我们在电机的解码函数中使用了一些静态变量用于存储触发接收中断的电机报文的相关信息,故而新进入的中断覆写了之前中断的静态变量值,使得之前中断在恢复之后存储了前者的值,导致自身反馈错误。
将优先级统一设为5,编译之后重新运行,反馈值正常。
“同时使用CAN1和CAN2(不论几个电机)则问题再次出现。” 导致此问题的原因是初始化CAN时按照rxid分配FIFO,因此注册的电机会被交替分配到不同的FIFO,故不论注册了几个电机(只要多于2)、注册到哪条总线都会出现FIFO1中断被FIFO0打断的情况。