我的经验,可靠的产品不是设计出来的,而是通过持续的解决BUG逐步达到的,当然我不是说设计不重要,我是想强调在产品中提供一些能够帮助诊断问题的设施 是非常有必要的。在这方面,最常见的手段显然就是系统日志了。嵌入式系统由定制的硬件平台、定制的驱动程序、定制的系统组件,以及定制的应用程序构成。这 么多定制的组件凑在一起,要在一个比较短的时间内把他们凑在一起,配合良好顺利运行不容易。他们中的任何部分都有可能出问题,因此应用程序级别的日志模块 显然不足以记录足够的信息,我们需要系统级的解决方案。在Windows CE平台,最有用的日志毫无疑问应该是CE内置的串口输出的调试信息,它的最大优点就是基本上CE平台的所有模块,从boot loader到OAL,从driver到application,都支持并且都能很容易的支持往串口输出调试信息。基本上你要让我诊断一个windows CE问题,我首先想知道的就是你有没有串口输出的调试信息,如果有,问题可能呢就解决了一半。但是在实际的使用中,要得到出问题时的串口输出又是很困难 的,这是因为:1,有些设备可能根本就没有提供串口;2,很少有人在使用设备时还接着跟串口线到电脑上,并且开着HypterTerminal;3,有些 错误不容易重现,即使你的设备有串口输出,你也有串口线,还有电脑可接,但是你已经没法重现错误了。
这篇文章我想谈谈怎么做一个系统级的日志模块,可以把往串口输出的所有调试信息捕捉到日志文件中。这听起来好像不难做到,基本上你要做的是在BSP中重写 OEMWriteDebugString(),把原本往串口输出的字符串重定向到文件中。没错,本质上说起来也就是这么回事,但是要做到这一点,有很多技 术上的问题必须要考虑到。主要的难点有两点:
1,日志文件的存储牵扯到文件系统。这就带来三个问题,一是文件系统只有在kernel启动后并且文 件系统相关的驱动程序加载以后才能访问。在boot loader引导阶段,以及kernel初始化阶段,文件系统是不能用的,日志的存储怎么解决?二是对文件的写操作一般速度比较慢,对系统性能有严重影 响。三是文件存取依赖于文件系统的驱动程序栈(包括文件系统驱动、分区管理驱动、块设备驱动),还涉及到进程/线程间的上下文切换,这相比起串口输出来说 复杂度提高了不止一个数量级,因此可靠性大大降低。
2,系统中的任何模块在任何状态下都可能输出调试信息。什么意思?这意味着:一,你要非常小 心,不能让一个模块的输出block掉另一个模块的输出,输出时的同步锁是必须的,但是锁定状态下的代码必须非常可靠且快速。二,有些输出非常特别,比如 说从中断处理例程输出的调试信息,此时所有硬件中断以及线程调度器都处于禁止状态,也就是说所有可能引起阻塞的API都不能用。比如你可能想在 OEMWriteDebugString()中把字符串缓存在内存中,然后用SetEvent去触发等待在这个event上的某个线程把它写进日志文件, 在这里SetEvent就会把系统挂起-因为此时线程调度器没法工作。
下面说说设计要点:
1,日志模块提供给其他模块输出信 息的接口实现(即OEMWriteDebugString的实现)的可靠性和性能非常重要。因为系统中的所有模块都跟它有依赖关系。在有实时要求的系统 中,这个接口也必须满足实时性。这是说,这个接口从调用到返回,其调用过程所需时间在理论上要有明确的上限。但是对于文件系统来说,这三点都很难满足。因 此缓存日志是必须的,基本想法是在OEMWriteDebugString不涉及任何跟文件有关的操作,只是把日志缓存在内存中,真正的写日志文件操作由 单独的线程完成。这跟CE的中断处理手法很像,分为ISR和IST两部分:OEMWriteDebugString是ISR,写日志文件线程是IST。这 同时也解决了如何存储文件系统可用之前的日志输出问题。
2,缓存如何分配及访问?简单的做法是在内存中保留一块固定区域,在boot loader阶段可以直接访问。在kernel阶段,这块内存不归内核的内存子系统管理,logging ISR(即OEMWriteDebugString)可以直接访问,IST中可用SetKMode API(CE5)进入内核态访问,在CE6中IST运行在内核态,也可以直接访问。如果使用其他方法,比如动态内存分配,你要考虑在kernel阶段如何 读取boot loader阶段输出的日志,还有在IST中如何访问这块缓存,特别要当心address aliasing的问题。
3,Time stamp如何取。time stamp对于日志系统来说是非常必要的,一般的应用程序级别的日志模块的time stamp可以用GetSystemTime之类的windows API来拿。但是这里显然不能用,因为,在boot loader阶段它根本不存在,在kernel阶段,ISR调它可能引起系统挂起,在IST中调用虽然没问题,但为时已晚。我的做法是用 GetTickCount记录时间,因为tick count机制一般由CPU实现,简单可靠而且快速。在IST中,用GetSystemTime取得当前时间同时取得当前tick count,和日志产生时记录的tick count相比较,就可得到真正的时间戳。
4,Logging ISR如何通知IST处理日志信息。简单的想法是在IST中等待一个event,在ISR中用SetEvent触发。问题是,如前所述,处理来自中断调用 例程的日志时调用SetEvent会导致系统挂起。我的解决方法是给日志模块分配一个逻辑中断号,在OEMWriteDebugString中用 NKSetInterruptEvent触发中断,在IST中的处理(Hook中断,等待中断触发等)跟CE标准的IST实现一样。
5,OEMWriteDebugString 的实现。这是整个日志模块最关键的部分。它的实现必须满足:高可靠性(任何情况下都不能导致调用它的线程或中断处理例程挂起)、高性能(时间开销必须尽可 能小)、实时性(有明确的时间上限)、完全可重入(允许多个模块同时调用)。
6, 如何同步对缓存的访问。在boot loader阶段,除非你打开了硬件中断而且在中断响应代码中输出日志信息,否则不需要同步,因为boot loader本身是单任务系统,不存在对缓存的并发访问问题。在kernel运行阶段情况就不同了,windows ce的kernel是一个多任务系统,任何模块在任意时刻都可能调用OEMWriteDebugString输出信息(写访问),logging IST在任意时刻都可能从缓存中读取信息(读访问),因此日志模块是一个multi-writer,single-reader的系统,对缓存的访问必须 进行同步。有哪些方法可以选择?
- 方法A,众所周知,在应用程序级别的开发中典型的做法是使用mutex、event等内核对象来 同步writer和reader。问题在于我们的writer很特殊(即OEMWriteDebugString),它的调用者不光包括普通的线程,还包 括中断响应例程(ISR)和异常处理例程(Data/Prefetch Abort Handler),在处理这些特殊的例程时CPU是不允许切换到其他上下文执行的。另外,即使只是在普通的线程上下文环境下使用这些内核对象进行同步,也 可能引起死锁。比如你有一个应用程序,它依赖于一个DLL,这个DLL在加载时在DllMain会输出一些调试信息(通过RETAILMSG或 OutputDebugString),提示它被加载了。这种情况很常见吧,但是你可能意识不到这就会引起死锁。问题出在DllMain是在一个进程级别 (process wide)的锁保护下执行的,换句话说DllMain被调用时该进程中的所有其他线程都处于阻塞状态,如果这其中的某个线程在被挂起前正好也输出了一行调 试信息,获得了OEMWriteDebugString的写访问锁,就会导致死锁,这是因为DllMain获得的Loader lock阻塞了该进程的其他线程,而在他输出调试信息时需要获取的OEMWriteDebugString的writer lock被该进程的其他线程获取,而这个线程永远没有机会被唤醒了-这是典型的lock order inversion。关于DLL的加载锁,请看微软的白皮书:Best Practices for Creating DLLs。简单地说,常规的内核对象方法是行不通的。
- 方 法B,关键的麻烦在于OEMWriteDebugString的实现需要同步又不能引入阻塞操作,可以考虑把它放到SysCall的保护下执行,代价是在 OEMWriteDebugString执行时,中断不能响应,线程调度器也不能工作,这会影响整个系统的中断响应性能和线程调度性能,从而影响了系统的 实时性。
- 方法C,也可以考虑把缓存按writer分块,比如等分成1000块,每个writer占一块。也就是说,系统中同时运行的线 程,加上ISR,Data Abort Handler, Prefectch Abort Handler等数目不能超过1000,这样writer在写访问时就不会互相影响。缺点是同一个writer上一次输出的信息被reader处理之前, 无法输出下一个信息。更糟的是,这种设计间接引入了输出接口对文件系统的依赖,会严重降低系统的可靠性,也不可行。
- 方法D,这是我实际采用的方法。虽然常规的基于内核对象的锁无法使用,幸运的是我们还有一种简单的锁可用又不会导致阻塞,那就是Interlocked型API(InterlockedExchange,InterlockedIncrement等等)。虽然Interlocked型API只能保证对32 位简单数值型变量的操作的原子性,不能保证代码段执行的原子性。但是它有更重要的优点,它非常简单、可靠、高效,而且不会引起阻塞 (Interlocked型API的实现如何在保证操作的原子性的同时又不阻塞其他线程是一个值得一提的话题,后面有机会再单独介绍)。如何利用这些 API实现我们需要的multi-writer/single-reader模型?我的想法是,由于日志是按时间顺序进来的,可把缓存块构造成一个FIFO的环形队列。给每条日志的空间如何分配?简单的做法是可以事先把这块缓存等分成N小块,每条信息将占据一块。这样做的优点是处理起来逻辑简单而且快速,给日志信息分配空间可以简化成申请一个空闲块的索引(用Interlocked API保证申请索引操作的原子性),真正的缓存日志操作不需要加锁。由于信息块是等分的,需要锁定的申请索引操作和日志信息的长度无关,因此是O(1)的效率。又由于日志存储在队列中,已分配块和空闲块只需用两个索引值(第一个已分配块的索引和最后一个已分配块的索引)就可界定,处理起来非常简单。缺点是过长的信息不得不被截断,这个问题可以通过适当的设定块的大小,以及在接口规范中明确指出日志的最大长度来缓解,因为日志的主要目的是让使用者了解系统运行时的状态,如果有必要,可以通过重新组织输出信息来减小长度。
7,确定缓存和块的大小。这可能需要你对自己的系统做一个观测,看看正常情况下日志输出量有多大,然后再加一些缓冲。每个块需要多大空间?正常的日志一行差不多是几十个字符,很少有超过100个字符的,因此256字节应该是足够的,如果系统内存比较小,128字节应该也够了。假如同时存储1000条日志,那么总缓存大约为256K。
8,日志的辅助信息。在日志产生的同时,记录下它的产生时间和线程ID/TEB等信息对诊断问题也是很有帮助。比如在《如何诊断Windows CE的应用程序崩溃》一文中提到的,应用程序崩溃时CE内核会输出相关的崩溃信息(Data Abort/Prefetch Abort等),其中包含了崩溃线程的TEB值,如果该线程之前还有过一些日志输出,就可以很容易的把崩溃线程和源代码对应起来。
9,日志产生速度太快,reader来不及处理导致缓存溢出怎么办?显然你只能丢掉一些日志,问题是丢掉最新的还是最老的?我倾向于丢掉最新的,因为一般来说只要缓存足够大,这种情况很少发生,出现这种情况一般是某个线程进入死循环,在里面不停地输出信息,因此重要的是保留死循环出现时的日志,而不是这些死循环中发出的垃圾日志。
10,OEMWriteDebugString的性能如何?先来估算一下OEMWriteDebugString往串口输出的效率。假设串口设置为11520波特率,8位数据位,1位停止位,无奇偶校验,则传输速率=115200/9/1000=12.8 byte/ms,传输一个字节所花时间为:10^6/12800=78 us/byte。这是理论上的算出的最大值,实际情况中还有OEMWriteDebugString的调用开销,在我的系统中(CPU主频为520MHz,CPU系统总线为208MHz,外部总线(SDRAM)为104MHz),实际测值为93 us/byte。在OEMWriteDebugString中禁止往串口输出,可以测得OEMWriteDebugString的调用开销,大约为5 us,也就是说用RETAILMSG输出一行日志,所花时间大约为5~6微秒。这是一个相当高效的接口了,如果你的CPU支持微秒级计时器,你可以用它诊断一些高速模块,比如网络接口芯片的数据传输问题。