写下这篇实践总结,一方面用于笔者存档备忘,另一方面也希望对读到此文的人有所启发。
这里所说的问题一般指必现问题,即可以找到让问题必然出现的条件。对于非必现问题的处理,后面将简单讨论。
一、RTFSC ---- 不明白?请 google
不用说,这是老生长谈。通常大部分情况下用这种方式都可以快速找到导致问题的原因,前提是你要足够熟悉代码。 如果你刚接手出问题的代码,请硬着头皮把代码看下去 :-)
二、查看输出的错误信息或中间调试信息
通常嵌入式设备自带的调试定位手段相对缺乏,不比 PC,代码出了问题,可以很容易起调试器定位。这时就要靠运行的代码自身来帮忙了。
比如,可以在函数的失败返回处输出错误信息(如文件名、出错函数、出错行号、变量的值 ---- 考虑到代码安全,可以牺牲可读性,对输出文件和函数名进行编码),或者在函数执行的关键点输出调试信息,表示已运行到此处。
如果因为某种原因 ---- 例如设备没有输出接口或者不方便直接输出调试/错误信息,可以将信息记录下来,稍后再在设备上查看或者将信息记录转储到 PC 上查看。输出信息可以保存在事先开辟的一段内存缓冲区(前提是必须知道缓冲区首地址),或者更高层次一点,保存在文件系统中(如果设备自带存储卡)。
某些情况下这两种方式都不合适,例如内存缓冲区地址事先不知道,并且设备没有文件系统(或虽有文件系统但因频繁更新文件,耗费时间过多而影响性能),这时就可以走中庸之道:利用 ramdisk 将输出信息保存到文件。
笔者曾经为了采集设备启动过程中的某些信息,分别试图将输出信息打印到串口和保存到存储卡文件系统中,发现都有问题。后来采用 ramdisk 才得以解决。
需要说明的是,内存缓冲区和 ramdisk 本质上没有区别,只有存储层次之分。其细微区别在于,通常访问缓冲区不如 ramdisk 方便 ---- 缓冲区中的内容需要提供查看或保存功能,而 ramdisk 中的文件可以直接利用文件系统的复制功能导出,供后续使用。
输出调试信息可能对正常业务有一定影响,所以通常采用调试开关变量进行控制,如果该变量为真,就输出调试信息,否则就不输出。
有时我们只关心出问题的模块,可以考虑为每个模块单独配置一个调试开关,用来控制该模块自身是否输出调试信息。 还可以做到更精细化,将输出信息按重要性分级别显示,比如分为提示、警告、错误等不同级别,每个级别单独配置一个控制变量。 注意,按模块和重要性级别,是两个不同的区分维度,彼此完全正交,可以根据情况自己选择。 总的来说,在前期考虑的越周到,后面的维护和问题定位就越轻松。
通常方法一和方法二可以同时进行。
三、仿真平台复现
这里说的仿真平台,一般是指在 PC 操作系统(Windows/Linux)上运行的、能够模拟实体嵌入式设备功能的软件。 有了仿真软件,不需要购买真实设备,就可以学习使用嵌入式设备,类似 VMware Workstation 模拟 PC。
在实现原理上,仿真平台的软件代码与运行在嵌入式设备上的代码是同一套,只是编译方法不同。 换句话说,把源代码编译成在PC上跑,就得到仿真平台;正常编译,就可以在嵌入式设备上跑,有点跨平台的味道。
为什么要仿真平台? 首先可以用来培训用户,省钱。更重要的是从开发人员角度,可以利用仿真平台去复现实际设备上出现的问题(当然,仿真的程度越高复现的概率越大)。如果成功复现,问题解决就容易多了。因为仿真平台运行的操作系统通常提供强大的调试工具,能够帮助开发人员进行定位 ---- 别忘记有仿真平台的源代码。
仿真平台还有以下几个重要的作用: 新手以调试模式运行仿真平台,可以快速学习和熟悉代码(想想调试器提供的强大的断点、变量查看功能)。 此外在进行新功能开发时,可以将新增代码放在仿真平台中进行单元测试,提前排除很多错误 ---- 这些错误如果在嵌入式设备上,其定位难度和代价将成倍放大。
目前业界有技术实力的公司都实现了自己的仿真平台。
笔者曾参加某个产品的紧急开发,负责其中一个模块的一项子功能。利用仿真平台,对功能代码进行了反复严格的测试,验证好后再对代码进行性能优化。最后这个模块的质量(还要算上其他人的努力),用当时的产品开发部部长的话(当着我的面讲)来说,我们模块做的最好。
补充说明:仿真工具不必(实际上也不可能)模拟硬件设备的所有功能,只要能够模拟到我们关心的那部分就可以。 笔者以前在嵌入式设备上遇到一张奇怪的证书,其签名用 OpenSSL 验证失败。问题只是发生在证书的验证过程中,于是笔者想到构建 OpenSSL(版本与设备相同)的 VC 工程,用来仿真证书的验证过程(顺带把整个 OpenSSL 都“仿真”了一把)。具体过程可以参见《走读 OpenSSL 代码》系列。
四、动态补丁定位
如果上面几种方法均行不通,怎么办?这时动态补丁就出场了。
这里的补丁不是指类似 Linux 内核的源代码补丁(需要重新编译内核,再启动加载到设备中),而是指可以直接加载到嵌入式设备中并立即运行的二进制补丁,也常称为热补丁(hot patch/live patch),不需要重启设备就实时生效。 如果热补丁在加载后还能被卸载(即恢复到加载之前的状态),就称为动态补丁。顾名思义,动态补丁可以反复加载、卸载。补丁的原理和运行机制限于篇幅不再详述,请自行 google。我们在使用 Windows 时,经常会碰到系统自动更新漏洞补丁而不用重启 PC,这就是一种热补丁机制。
业界有技术实力的公司,其嵌入式产品可以提供动态补丁的功能(包括补丁的生成、加载、激活、卸载等)。 动态补丁具有很明显的优点:不用重启设备,就可以实时修复错误,这对于业务不能中断(比如电信领域)的场景非常有用。
通常提供产品补丁修复错误的过程如下
问题出现 ----> 问题定位方式(各种常规定位,比如看代码、仿真) ----> 定位完成 ----> 提供正常补丁
现在我们换个思路,将其变成
问题出现 ----> 问题定位方式(动态补丁定位) ----> 定位完成----> 提供正常补丁
定位问题主要步骤的顺序没变,只是定位方式变了:补丁由定位结果向前延伸变成了定位过程。
怎么做? 在任何你怀疑的地方加上调试打印语句(查看任何想查看的中间状态),加载补丁,看代码是否按你期望的结果运行。 如果不是,恭喜你,你找到了问题的线索(如果没发现异常,继续添加调试语句,直到发现可疑之处)。 好,重新修改代码的调试打印语句,再制作一个新补丁,将原来加载的补丁替换掉,再观察结果。 如此反复,直到你最终找到直接导致出错的代码。 最后,修复错误的代码,生成最终补丁,重新加载到设备中,如无意外,问题不再出现。 确认无误后提交你的修改代码到代码库,一次成功的定位结束。
本质上动态补丁定位是一种特殊的迭代开发。
我们再看动态补丁的另一个应用。有时候,我们怀疑出现问题的函数可能在多个分支中被调用,除非你对代码非常熟悉,否则无法马上确认问题发生时走的是哪个分支。 如果底层支撑系统支持函数栈回溯,则可以将函数调用栈 dump 出来。这段 dump 代码就可以写在补丁源代码中。BTW,函数调用栈也有其他用处,这点在《走读 OpenSSL 代码》系列也有说明。
说了优点,再说限制:补丁定位通常只限于产品的开发或测试环境。 如果用户实际使用场景中出现问题,可能就比较困难 ---- 用户一般不会同意你在他购买的设备上做这些”实验“。
总之一句话:平台的支撑技术够强大,业务才会轻松。
五、其他定位思路与示例
1、不能复现(或概率性复现)的问题定位
这里解释下,所谓的问题不能复现,大都发生在专门的测试场景(流程规范的公司有专门的产品测试人员),开发人员无法当场确认问题发生的原因。 或许产品使用环境很复杂,无法逐一排除得到根因;或许能确认为代码问题,但由于缺乏有效的定位手段,无从知道错误发生在哪段代码。 测试人员由于资源所限,无法将出现问题的场景和设备留给开发人员继续定位,往往换掉当时的环境,继续测试下一个用例。 若干小时/天过后,开发人员着手定位这个问题。这时悲剧来了,相同的配置和场景下问题再也不出现了,成为无头案,此谓不复现也。
或者问题虽然会出现,但是不稳定,时而出现时而不出现,有时表现为某个环境下经常出现,换个环境就不出现。
对于这类问题(严格说来,仅表示目前看来非必现),首要目标是想方设法地找出问题复现的条件。
在一次测试中,发现设备中某一项任务出现概率性挂死。该场景在多个无线终端与设备相连的情况下发生。而且在测试场景下经常出现,换个环境就很少出现。没有稳定复现,很难有头绪,只得耐着性子看代码。 经过仔细排查,发现有一行语句写错了,本来应该是
if( var == 某个常数 )
结果误写成
if( var = 某个常数 ),少了个等号(申明:不是我写的:-/),意思完全变了。
多么熟悉的错误啊。马上改正,再多次测试,没出现任务锁死。但是这样算改好了吗?这是问题的根因吗。理论上只有证明这条错误语句直接导致死锁才能让人放心。后来对着代码顺藤摸瓜,终于发现真正的原因:这个 if 判断写错后,若干个无线终端反复连接、断开设备,会导致一个散列链表形成死结(具体细节就不展开)。该链表管理的是连接设备的无线终端,以终端的地址作为散列索引。链表死锁后,再来一个终端连上来,代码会先查找链表,造成死循环,任务死锁。之所以在测试环境中经常出现,是因为测试终端地址多,较易满足形成死锁的条件。而笔者所使用的定位环境,由于终端数目较少,很难构成循环链表。知道这些后,笔者精心构造终端与设备之间的连接、断开顺序,以期触发问题。经过若干次试验后,终于成功地让链表形成死结,任务死锁。至此这个问题定位成功。
防范这种低级的编码错误问题,其实用专门的代码静态检查工具(如 PCLint)一扫就能看出来。或者最简单,改为 if( 0 == var )。
2、故障注入在特殊场景下的应用
在一次 WiFi 认证测试中,遇到模拟无线客户端攻击设备的一个场景,这需要无线网卡故意发出攻击报文,设备收到攻击报文后,再采取一定的防范措施。通常这种攻击报文在正常使用环境下不会出现,同时其手工生成和发送也比较困难,因而无法触发测试场景。笔者后来想到,在设备上加入一段故障代码,用来模拟其受到攻击的场景,并通过有条件地控制故障的发生,成功地模拟出设备受攻击的场景,使得测试过程最终得以顺利进行。
3、利用替换法确认问题嫌疑
有时候,我们怀疑问题不在自己的代码中,而是位于别的第三方模块。但通常第三方不提供源代码,怎么办?这时可以用替换法排除(或确认)对方的嫌疑。 笔者有一次定位产品问题,怀疑产品集成的第三方厂商提供的驱动模块有内存泄露。 该模块主要提供数字签名功能,为了验证猜想,笔者绕过驱动程序提供的功能,直接用自己写的代码实现签名功能。 不出所料,替换后没有内存泄露发生,这说明第三方模块的代码有问题。后来直接联系该厂商,告知他们这一现象,对方也确认是问题。 BTW,也许是缘份,后来笔者在另一次测试中,又碰到该厂商另一个产品的问题 ---- 有意思的是,这次仍是用替换法发现问题的。
4、从调试定位角度看代码风格
对于 C 语言,带 if 判断的单行语句是否用大括号包住,这是典型的编程 style 问题。如下
if(条件成立)
fun();
但是考虑到后期,可能需要在这里加调试代码,改成
if(条件成立)
{
DEBUG_PRINT(...)
fun();
}
回过去看,当初还不如直接写成
if(条件成立)
{
fun();
}
这样后续直接增加 DEBUG_PRINT(...) 这一行就可以了。当然,是否一定要这样做,也见仁见智。 笔者的想法很简单:如果后面可能需要增加工作(加大括号),不如在之前就加上。
5、利用调试标记进行选择性调试
要充分利用(如果没有就加上)附带在数据结构上的调试标记,类似 Linux 内核中 struct sk_buff 的成员 nf_trace。内核在报文转发过程中,都要同这个结构打交道,这时可以有选择地对感兴趣报文(比如满足特殊的条件)置上该标记,后续就可以根据此标记,单独跟踪满足条件的报文了。