刘欢有一首歌叫《从头再来》,歌词的核心部分是“心若在,梦就在,天地之间还有真爱,看成败人生豪迈,只不过是从头再来...”
这是一首励志的好歌,鼓励人不屈不挠,遇到困难不要轻易放弃,要重整旗鼓,“从头再来”,再次出发,再试一次。
但是,这样的话说起来容易,实际做起来常常不那么简单。人生易老,时过境迁,很多事情容不得“从头再来”。这个道理对人生如此,对软件也是如此。
以格蠹的NDB调试器为例,如果使用时忘记了某个准备动作,比如给目标机供电,就开始调试会话,那么NDB虽然兴匆匆地跑起来了,但是在试图和目标机建立CoreSight会话时,就会因为无法读到标志寄存器(IDR)而失败。
Using NTP-DAPv2 interface with VID:PID=0x0d28:0x1588, serial=123600017738abcddf21bc1c2000410fa5a5a5a547454455
NTP-DAP: SWD Supported
NTP-DAP: FW Version = 2.0.2
NTP-DAP: Serial# = 123600017738abcddf21bc1c2000410fa5a5a5a547454455
NTP-DAP: Interface Initialised (SWD)
SWCLK/TCK = 1 SWDIO/TMS = 1 TDI = 0 TDO = 0 nTRST = 0 nRESET = 1
NTP-DAP: Interface ready
clock speed 100 kHz
Error connecting DP: cannot read IDR
这时按理说,可以立刻插上电,然后点击工具条上的“重新启动”按钮,重新开始会话。
但是如果用户真的这么操作,老版本NDB并不能如预期地那样“从头再来”取得成功。而是重来失败,报告错误。
NTP:
Failed to setup NTP connection(0xfffffffc).
Make sure turn on JTAG. Try Fn + 5 on remote controller.
Failed to setup KD proxy for 0xfffffffc. Session ends.
Restarting...
Starting new session...
Err:启动调试失败: -2147467259
Starting KD session type=usb,proto=ocd,opt=rxsb,targetname=gdk3
BUG: current_target out of bounds.
Failed to init probe target.Failed to setup NTP connection(0x80004005).
Make sure turn on JTAG. Try Fn + 5 on remote controller.
Failed to setup KD proxy for 0x80004005. Session ends.
这时的解决方案只能是先关闭NDB程序,然后再次打开。这个方案虽然也可以叫“从头再来”,但是这个“重来”的回头路有点走的太多了。
对于这个“重来失败”的问题,我一直想把它解决掉。曾安排格蠹的小伙伴来解决,我自己也曾忙里偷闲,发起过几次“闪电战”,但都没能攻克这个难题。
国庆长假,到办公室加班,有比较充足的时间,我又想起了这个问题,于是从昨天上午开始发起“专项行动”,专门解决这个问题。以下是主要的战斗经过,分享给格友们。
从哪里下手呢?首先要有一个高效的环境。考虑到要反复“重来”,所以每次重来的速度要快。因为此,我选择了最轻便的GDK3做为调试目标,它具有如下优点:
- 不需要启用调试模式,上电即默认启用了调试,随时准备着迎接调试器的到来,发自内心地“欢迎调试我”。
- 供电方便,只要插上USB线就好。
- 开机速度快,没有操作系统,启动时间在眨眼之间,不到1秒。
选定了GDK3做为调试目标后,我很快就总结出了一个便捷的重现步骤:GDK3不要上电就开始调试会话,问题百分百重现,在重新开始的地方设置断点,断点命中时,给GDK3上电。这样,重现一次的时间开销只有几秒钟,可以快速复现问题,定位重启失败的原因。
有了重现环境和步骤后,另一个问题是如何调试。起初我是用NDB的正式图形界面,也就是NanoCode主程序。这时要调试的话,需要找到NDB的工作进程,因为NanoCode从Node.JS那里继承了多进程模型。
找到进程ID后,便可以把Visual Studio附加到NDB进程。
为什么使用Visual Studio,而没有使用WinDBG呢?因为VS更适合看源代码。可以先用它把问题聚焦一下。
附加好VS后,便可以中断下来进行分析,但是不高效。为了提高效率,我连续两次点击重启,这时如果没有调试的话,NDB的界面会显示如下消息,表示NDB进程内发生了异常。
如果有调试器,那么便会中断到调试器当中。这时便可以直接看到有问题的代码,不需要东找西找。
异常发生在OpenOCD(简称OCD)的开源代码中。看起来是连续两次“从头再来”把OCD的内部逻辑搞乱套了,触发了空指针。
为了能有序的“从头再来”,我认真树立了和OCD的接口逻辑,特别是重来前的“清理逻辑”。因为第一次操作虽然失败,但是却改变了很多状态,留下了很多痕迹,重来前需要清理好,该释放的要释放。
OCD提供了一个名叫target_quit()的函数,是用来清理的,但是原来的代码根本没有调用。OCD自己的主程序在退出时也没有调用。
但当我把这个target_quit()调用加到清理函数后,我明白为什么没有人调用了。因为一调用就促发崩溃。异常发生现在下面的free函数,也就是释放内存。
提到释放内存出错,很多程序员都会头疼。因为涉及到纷繁复杂,而又很容易出错的堆。
对于我来说,有太多方法对付内存出错,所以并不紧张。
当我看到这个释放调用的目标时,我很快明白这个问题的根源了:野指针。
所谓野指针,是从堆管理器的角度说的。正常的指针都是堆管理器发出去的,从堆上分的,交给堆释放。而野指针就是把不是堆上的指针给堆管理器释放。
free(target->type);
从先是从target->type这个参数的语义上看出问题。然后再通过VS的变量观察功能分析,可以看到target->type指向的是全局变量。
确定问题后,只要把它注释掉,再加个注释。
// free(target->type); //badbad, type is global, should not call free
完善了清理代码后,无论点多少次“重启”都不会崩溃了。但是重启之后仍然不能成功建立会话。下面是重来时NDB打印的消息。
NTP:
Failed to setup NTP connection(0xfffffffc).
Make sure turn on JTAG. Try Fn + 5 on remote controller.
Failed to setup KD proxy for 0xfffffffc. Session ends.
Restarting...
Starting new session...
Err:启动调试失败: -2147467259
Starting KD session type=usb,proto=ocd,opt=rxsb,targetname=gdk3
'target init' has already been called
BUG: current_target out of bounds.
Failed to init probe target.Failed to setup NTP connection(0x80004005).
Make sure turn on JTAG. Try Fn + 5 on remote controller.
Failed to setup KD proxy for 0x80004005. Session ends.
上面消息中,下面这句最关键:
'target init' has already been called
这句话的含意是'target init'已经做过一次了。是啊,重来么,是做过一次的。做过一次就不能再做么?
找到对应的源代码,又是在OCD中,因为很有代表意义,摘录如下:
static bool target_initialized;
if (target_initialized) {
LOG_INFO("'target init' has already been called");
return ERROR_OK;
}
target_initialized = true;
熟悉C语言的读者很容易看懂,这个函数故意定义了一个静态变量,执行一次后,就把这个变量设为true,第二次看到true,就不往下走,返回了。
这是故意的不支持“重来”啊。这不是技术水平问题,这是态度问题,从思想深处就没想到要支持“从头再来”。
考虑到这样的代码具有教育意义,我没有把它直接删掉,而是加了个条件编译,将其禁止。
#ifdef _TRY_RESTART_FAILURE
static bool target_initialized;
if (target_initialized) {
LOG_INFO("'target init' has already been called");
return ERROR_OK;
}
target_initialized = true;
#endif
禁止这个代码后,再次重现问题,得到下面的错误:
found
\\?\d:\work\nano\nd\ndi\x64\debug\data\ntp.cfg
command - adapter driver NTP-DAP
command - adapter driver NTP-DAP
Interface already configured, ignoring
找到对应的源代码,也是在OCD中。考虑到它具有另一种代表意义,也摘录如下:
/* check whether the interface is already configured */
if (adapter_driver) {
LOG_WARNING("Interface already configured, ignoring");
return ERROR_OK;
}
与前面判断静态变量不同,这次判断的是全局变量。
struct adapter_driver *adapter_driver;
看来是上次操作时设置了这个全局变量。
解决方法也比较简单,找到有关的清理函数——adapter_quit,在里面增加复位操作。
int adapter_quit(void)
{
if (jtag && jtag->quit) {
/* close the JTAG interface */
int result = jtag->quit();
if (ERROR_OK != result)
LOG_ERROR("failed: %d", result);
}
struct jtag_tap *t = jtag_all_taps();
while (t) {
struct jtag_tap *n = t->next_tap;
jtag_tap_free(t);
t = n;
}
__jtag_all_taps = NULL; // added by Raymond while enableing restart on 2022-10-6
jtag = NULL; // also this line.
adapter_driver = NULL;
return ERROR_OK;
}
类似这样通过判断全局变量拒绝重来的代码还有好几次。为了对本来美欧清理函数的全局变量做复位,我增加了一个清理函数,在这个清理函数里把全局变量复位。
// cleanup for restart, added by Raymond on 2022-10-6
void transport_cleanup(void)
{
allowed_transports = NULL;
session = NULL;
}
调用上述清理函数后,“重来”的过程显然走的更深入了,但仍不能工作。每次解决调一个问题,以为能工作时,常常是又出现一个新的问题。
调试的时间过得很快,昨天到了晚饭时间时,仍有问题。
昨天晚饭后,继续调试。有了白天的经验,晚上我改用Node+js脚本做目标进程,代码里判断第一次启动失败后,自动发起第二次调用。这比NDB图形界面又高效了一些。
var ret = obj.StartKD("type=usb,proto=ocd,opt=rx,targetname=gdk3", "");
console.log(ret);
if (ret != 0) {
ret = obj.StartKD("type=usb,proto=ocd,opt=rxs,targetname=gdk3", "");
console.log(ret);
}
这样调试后,比较快定位到了白天没有找到的一个问题。在比较著名的解析命令行参数的开源代码options.c中,也有一个通过全局变量记录选项号的全局变量optind。
WINGETOPT_API extern int optind; /* index of first non-option in argv */
因为这个全局变量,第二次“重来”时,解析不到命令行选项,因为第一次执行时,optind已经到了所有选项的最大值。
为此,我加了个判断,将其复位。
int parse_cmdline_args(struct command_context *cmd_ctx, int argc, const char *argv[])
{
int c;
if(optind > 1)
optind = 1; // reset it to 1, fixed by Raymond on 2022-10-6
while (1) {
/* getopt_long stores the option index here. */
int option_index = 0;
c = getopt_long(argc, argv, "hvd::l:f:s:c:", long_options, &option_index);
继续排除几个“抗拒重来”的问题后,点击“重新启动”按钮后,已经可以开始调试会话了。但是发起中断时,无法将目标中断下来。
今天上午继续调试中断失败的问题。发现NDB获取到的CPU个数为2,而GDK3其实只有一个CPU。中断(Break)时一定要收到所有CPU的Halted事件才能中断给用户,所以CPU数量出错,用户就看不到中断成功的界面。
追查源头,NDB是从OCD的target_type->num变量获取CPU数量的。而target_type->num是根据TCL脚本中的target命令来增加计数。重来时执行了两遍脚本,所以num数量不对了。
找到原因后,只要在注释调的free下面,加一个清零动作。
// free(target->type); //badbad, type is global, should not call free
target->type->num = 0; // reset to zero
加了这个修正后,“重来”操作可以顺利进入调试会话,而且中断也可以成功了。为期一天半的“专项整治”终于结束了。
归纳一下,以下不好的编码习惯会增加代码的死板性,影响“从头再来”:
第一名:使用函数内的静态变量记录标志,不执行第二遍。这样的写法,不支持函数外部复位状态,是影响“从头再来”的第一大障碍。
第二名:全局变量。全局变量在运行中难免会记录下运行痕迹,留下“岁月的痕迹”。因此,应该尽量减少全局变量的数量。为了能支持从头再来,最好给全局变量配备复位函数,在复位时将变量状态复位。
第三名:清理逻辑不完备。包括资源没有清理好,动态内存没有释放,全局状态没有恢复,清理函数没有认真测试等。
灵活性是软件的优良品格。好的代码应该是可以反复重来的,不仅能跑起来,还要能停下来,能优雅的退出,能轻松的“从头再来”。
(写文章很辛苦,恳请各位读者点击“在看”,也欢迎转发)
*************************************************
正心诚意,格物致知,以人文情怀审视软件,以软件技术改变人生
扫描下方二维码或者在微信中搜索“盛格塾”小程序,可以阅读更多文章和有声读物
也欢迎关注格友公众号