微波EDA网,见证研发工程师的成长!
首页 > 硬件设计 > 嵌入式设计 > 嵌入式软件设计中查找缺陷的几个技巧

嵌入式软件设计中查找缺陷的几个技巧

时间:03-06 来源:希赛网 点击:

有时候,采用堆栈深度分析过程可能是无法做到,或者是不实际的。如果无法得到运行时库或C库的源代码,而编译器厂商又没有提供任何堆栈使用信息,就不可能进行完整的堆栈分析。在这种情况下,有两种选择:

  1. 在测试期间,观察堆栈所能达到的深度,并保证有较大的堆栈空间余量。

  2. 检测堆栈溢出,并采取改进措施。

  观察堆栈深度的方法很简单:

  * 向整个内存堆栈区写入一个特定的数据图案符号,如55AA。
  * 在预期使用最大堆栈空间的条件下运行系统。
  * 使用仿真器或其它工具检查堆栈存储区,看有多少符号图案由于堆栈的使用而被改写了。

  当然,这些步骤并不能保证在一些不同条件下不会需要更多的堆栈,但确实可以表明所需要的最小堆栈数。

  使用带内存管理单元(MMU)的处理器时,有可能检测出运行时的堆栈溢出现象。MMU将内存划分为多个区域,用一个受保护的内存段来"警戒"堆栈区域。发生堆栈溢出时,处理器将访问这个受保护段。这个操作将引发一个异常事件(如产生SIGSEGV信号),可被程序捕获到。创建线程时,与实时POSIX标准兼容的RTOS提供有这种堆栈警戒功能选项,大大简化了编程人员的工作。GNU工具等其它开发环境包含有编译器开关,可在程序中添加实现堆栈警戒功能所需的代码,但它们仍然依靠底层操作系统来有效地处理堆栈溢出。但是,按照这种方式检测溢出还只是问题的一部分。为了使这类设计更为有效,系统必须能够从堆栈溢出中恢复过来并继续正确地工作。

  在一个对安全或任务要求严格的应用中,系统运行时在测试或检测堆栈溢出期间监视堆栈的深度可能并不是一项足够的风险控制措施。对于一些应用,必须确保系统绝对不会越出所分配的堆栈范围;只有通过完整的堆栈深度分析才能证明这一点。这意味着,如果整个程序在同一内存空间运行,则必须对所有代码执行这项分析。不过,如果使用MMU,分析常可简化。在设计系统时,可将所有关键代码置于一个或多个独立线程内,而这些线程分别在各自的保护内存段中运行。这样,只要对这些关键线程进行堆栈使用分析就可以了。当然,这项简化设计假定当非关键线程溢出其堆栈并失效时,关键线程仍可正确执行。

  由于分析工作所需的堆栈使用数据来自汇编语言清单,因此修改代码时,相应模块的堆栈使用信息必须予以更新。如果使用不同的编译器版本,或者改变了优化设置,也必须复核整个分析过程。在理想情况下,编译器将提供每个函数(如果不是每个线程的话)的堆栈使用数量,因为它拥有计算需要的所有信息。例如,瑞萨公司提供有Call Walker,这是该公司高性能的Embedded Workshop开发环境的一部分。这个工具可以图形化地显示每个函数使用的调用树和堆栈,包括运行时库和C库的函数。Call Walker也能找出使用堆栈数量最大的路径。使用这样的工具可以实现步骤1到步骤3的自动化。

  大部分软件开发项目依靠结合代码检查、结构测试和功能测试来识别软件缺陷。尽管这些传统技术非常重要,而且能发现大多数软件问题,但它们无法检查出当今复杂系统中的许多共性错误。本文将介绍如何避免那些隐蔽然而常见的错误,并介绍的几个技巧帮助工程师发现软件中隐藏的错误。

  二、竞争条件

  当两个或更多独立线程同时访问同一资源时,就出现了竞争条件。竞争条件的影响多种多样,取决于具体的情况。清单1解释了一个潜在的竞争条件。函数Update_Sensor()通过调用get_raw()来读取传感器的原始数据。在处理过程中,该数据被乘上一个定标因子,并加上一个偏移量。处理是在该数据的一个临时副本上进行的,然后,该临时副本被写入共享变量。
清单1:竞争条件
  如果在数据写入之前,使用shared_sensor的另一个线程或ISR先占(preempt)了这个线程,它将得到原来的传感器读数。使用临时副本可以防止先占线程读取只经过部分处理的数据。不过,如果这些代码在一个数据总线不足32位的处理器上运行,就会存在竞争条件。

  在一个8位或16位的处理器上,向shared_sensor的写入操作并不是一次性完成的。在8位处理器上,写入32位浮点值可能需要四条指令,在16位处理器上可能需要两条指令。如果在对shared_sensor进行连续写入中途Update_Sensor()被先占,则先占线程将从由一部分老数据和一部分新数据组成的shared_sensor读取一个数值。根据应用的具体情况,这有可能造成严重的后果。解决的办法是锁定调度程序,或在更新共享变量期间禁止中断。

  消除竞争条件通常很简单,但找出隐藏在代码中的竞争条件则需要仔细的分析。

  对于由一个循环程序和不同ISR组成的简单系统,分析竞争条件很简单,只需检查每个ISR并识别它引用的所有共享变量。共享变量通常是这些系统中的全局数据,一旦这些共享变量被找出来之后,就可以检查它们在代码中的各次使用情况。每次访问都必须按需要进行保护,以避免潜在的冲突。在简单设计中,一般通过在关键代码段周围禁止中断来实现保护。遵守下列规则可帮助避免竞争问题:

  * 如果一个ISR对共享数据进行写入,则该ISR之外的每次可中断的读操作都必须予以保护。
  * 如果一个ISR对共享数据进行写入,则该ISR之外的任何读-修-写操作都必须予以保护。
  * 如果一个ISR读取共享数据,则对该数据的可中断写操作必须予以保护。
  * 如果一个ISR和其它代码都要检查一个硬件状态标志,以便在使用某资源之前确定其可用性,如:
  if (!resource_busy)
  {
    // Use resource
  }

  则从检查标志之时开始,到硬件设置标志表示资源不可用为止,必须采取保护措施。

  对于使用了优先级不同的多个线程的更为复杂的系统,其分析也非常相似。上述规则仍然适用于ISR使用的所有数据。此外,还必须识别出每个线程使用的共享数据。首先从系统中优先级最高的线程开始,找出它与任何优先级较低的线程共享的所有数据,然后按照上述四条规则进行保护。对于软件使用的其它每个优先级,再重复这一过程。

  注意,如果系统采用了一种循环调度算法,则特定优先级内的所有线程可在任意时刻相互先占。这意味着前述四条分析规则在考虑较低优先级的线程之外,还必须考虑同一优先级的所有线程。

  多线程系统通常使用某种类型的操作系统,它能够提供多种保护选择。可以使用互斥或信号量,或者锁定调度器。有时也可使用其它进程间通信(IPC)基本技术:通过向消息队列发送消息(而非修改共享变量)来表示数据已经改变。在许多情况下,最好由单一线程来管理共享资源,它负责处理所有的读写请求,并在内部防止访问冲突。   在复杂的代码中辨认潜在的竞争条件可能是一项乏味而又耗时的工作。相应的辅助工具从用来识别全局数据访问的简单脚本到先进的动态分析程序如Polyspace Verifier。虽然比较困难,但详尽的代码分析是识别这类错误的唯一途径。测试不大可能能够建立重复触发竞争条件所需的精确时序序列。

  三、死锁

  在共享资源的系统中,防止访问冲突极为重要,但这有可能导致另一个问题:死锁。当通过"锁定"一个资源来防止任何其它线程访问这个资源,以避免竞争条件时,必须对设计进行评估,确保绝对不会发生死锁。死锁测试通常没有什么效果,因为只有某种特定顺序的资源锁定才可能产生死锁,而一般的测试不大可能导致这种顺序。

  死锁只不过是多线程环境中一个锁定资源的问题。以下四个条件必须同时具备,才会发生死锁。防止其中任何一个条件出现都可以排除死锁的可能性:

  * 相互排除---每次只有一个线程可以使用某个锁定的资源;
  * 非先占---其它线程不能强迫另一个线程释放资源;
  * 保持并等待---线程在等待需要的其它任何资源时,保持它们已经锁定的资源;
  * 循环等待---存在一个线程循环链,其中每个线程保持链中下一个线程所需要的资源。


图1:循环等待
  图1中的资源分配图是死锁问题的一个例子。线程1首先锁定Buf资源,在保持Buf时,指向Bus,然后是Mux。如果线程1一直运行到结束,它最终将释放所有这些资源。线程2运行时,必须指向Bus、Sem,最后是Mux。线程3运行时,需要Sem和Buf。

  在这个设计实例中,无法保证任何一个线程能够在另一个线程开始执行之前结束。如果一个线程不能得到需要的某个资源,它将挂起执行(阻塞),直到该资源有效为止。在系统运行过程中,各线程都将对资源进行锁定或解锁。由于各线程运行和指向其资源的相对时序各不相同,有可能出现由于各个线程正在等待被其它线程保持的资源,导致所有线程都无法运行的情况。例如,如果线程1保持Buf,线程2保持Bus,而线程3已经取得了Sem,则系统将发生死锁。因为按照从Buf到Bus到Sem,再回到Buf的线程分配箭头,循环等待条件得到了满足。


图2:没有死锁
  潜在死锁问题识别出来之后,通常很容易进行修复。在图2中,对线程3进行了修改,使其在得到Sem之前首先设法指向Buf。这样,循环等待的条件就被打破了,系统将不会再受到死锁的影响。
图3:有消息传递的死锁
  一些操作系统过多地使用消息传递来进行线程间通信和同步。在这些类型的系统中,当某线程向另一个线程传递消息时,发送线程将阻塞,直到从接收线程收到响应为止。接收线程通常将一直阻塞到从其它某个线程接收到一个消息为止。这些结构中也会发生死锁。为了给一个基于消息的操作系统建立一张资源分配图,我们利用消息通道来模拟分配的资源。图3是一个例子。线程2建立了通道T2 Ch,当它未因为等待这个通道上的一个消息而阻塞时,线程2就将"锁定"这个通道。当它阻塞并等待一个消息时,另一个线程可在这个通道上向它发送一个消息,并且这个消息将立即被接收到。

  现在考虑下面这个系统:线程1指向Mutex并在通道T2 Ch上向线程2发送消息。在线程2中的某个地方,线程2在通道T3 Ch上向线程3发送消息。线程3也在通道T4 Ch上向线程4发送消息。在线程4中的某个地方,它也尝试指向Mutex,如果得不到,它就将阻塞。显然,各资源之间存在一条循环路径,这表明有可能发生死锁。例如,如果某一时刻线程1保持Mutex而线程4尝试指向它,线程4就将在Mutex上阻塞。然后当线程3尝试在通道T4 Ch上向线程4发送一个消息时,线程3将阻塞,等待来自线程4的应答(因为线程4是由于等待Mutex而阻塞,不是为了等待这个消息)。类似地,当线程2尝试向线程3发送一个消息时,将被阻塞;线程1尝试向线程2发送一个消息时也将阻塞,由于它仍然保持着Mutex,所以系统将发生死锁。

  对付死锁的最容易的办法是通过设计进行避免。采用以下任何一条设计约束都可排除死锁出现的可能性:

  * 任意时刻线程锁定的资源不超过一个。
  * 线程开始执行前就完全分配它所需的全部资源。
  * 指向多个资源的线程必须按照一种系统范围的预设顺序来锁定(并释放)这些资源。

  如果无法通过设计来避免死锁,则应该建立资源分配图。检查资源分配图可以识别潜在的死锁。通过仔细跟踪系统中的所有线程和它们锁定的共享资源,可以维护资源分配图并周期性地进行检查,及时发现循环等待的特征。

  建立资源分配图需要识别每个受保护的共享资源,以及指向其中某一资源的所有线程。如果使用一个操作系统,可以采用下面的过程步骤:

  1. 识别所有可能阻塞的系统调用,如Mutex_Lock(),每个受保护的共享资源总是有一些与访问它有关的阻塞调用。

  2. 识别出获取共享资源的阻塞调用之后,在源代码中查找它们的各次调用情况。

  3. 对于每次调用,记录下指向资源的线程名称和该资源的名称。通常调用本身将受保护的资源作为一个参数来传递,调用在源代码中所处的位置表明了哪个线程需要该资源。通过这种方式,可以识别出所有受保护的资源以及分配资源的线程。

  4. 建立资源分配图,并检查是否有任何资源存在循环路径。当线程和共享资源较少时,画出资源分配图比较简单。在较为复杂的系统中,最好将这些信息输入分析表格,并编写一个宏来检查线程和资源分配结构,以识别潜在的死锁。编写好宏之后,就可以快速地对资源分配变化进行重新评估。编写宏时,可以忽略不会导致死锁的资源之间的循环。在表2所示的例子中,各种资源之间有许多循环,但只有线程6和线程7之间可能存在死锁。

  在一些类型的系统中,预先确定每一个共享资源并建立分配图是不实际或不可能的。此时可以增加一些额外的代码,以便在系统运行时检测出潜在的死锁。许多不同的算法都致力于优化这个检测过程,但本质上它们几乎都动态地建立某种资源分配图。只要有线程请求、分配或释放资源,分配图就会被修改和检测,以确定是否存在表明潜在死锁的循环路径。

  检测到某个死锁之后,唯一的克服方法是强迫线程释放关键的资源。通常,这意味着中断正保持着所需资源的线程。对于某些应用,这种方法可能是无法接受的。另一个有趣的解决方案是在运行时收集资源分配情况并进行事后分析处理,以确定在程序运行过程中是否有死锁情况发生。尽管这种方法并不能防止在运行时发生死锁,但它确实有助于在死锁出现后发现问题并进行修复。

  还有一些工具也可以用来帮助发现代码中的死锁。例如,Solaris程序设计员可以采用 Sun公司的LockLint工具来对代码进行统计分析。它可以发现对锁定技术的不一致用法,识别引起竞争条件和死锁的许多原因。






Copyright © 2017-2020 微波EDA网 版权所有

网站地图

Top