在嵌入式C语言程序开发中,容易复现的“显式”错误没什么好怕的,在开发测试阶段就能解决掉。最怕的就是一些潜在的,难以发现的错误。
发现并消除C语言程序中潜在的缺陷(或者说bug)是一项困难的工作,通常依靠C语言程序员扎实的基本功,以及相应的一些技巧。
有时甚至还需要一些运气。
在最坏的情况下,C语言程序中潜在的缺陷会以一种微妙的方式损坏代码或者数据,系统在故障发生之前的一段时间内仍然工作正常。所以C语言程序员在排查这些潜在 bug 的时候,常常会感到力不从心,因为这些 bug 不容易重现,偶尔的出现也极有可能被误认为是“用户误操作”。
所以,这种难以重现的潜在错误,常被C语言程序员们称为“幽灵bug”。鉴于其难以被发现,解决这种类型的 bug,倒不如在C语言程序开发阶段,严格要求自己遵守一些技巧和规则,尽力避免其出现。接下来两节将讨论 10 条避免“幽灵bug”的技巧和经验。
10. 实时性系统的时间“不均匀性”
一些实时系统不仅要求任务在规定时限内完成,而且还要求任务在完成过程中,遵守额外的时间限制,下图是一个例子,规定图中的任务(蓝框表示)必须在 10ms 内完成。
从图中可以看出,每个任务的确都是在 10ms 内完成的,但是各个任务之间的时间刻度并不均匀。在一些系统中,时间不均匀性是不可接受的,各个任务的运行时间应该把控尽可能精确。
例如,如果某个要执行的任务涉及对物理输入信号采样(读ADC值等),那么采样周期越精确,采样值的精度也就越精确。一个例子就是,光学编码器脉冲技术的采样间隔时间的不均匀性,直接影响其测量旋转轴速度的精度。
小技巧
提升任务完成时间精度的一个可选方法就是从设定任务的优先级入手。任务的优先级越高,越有机会被操作系统按照实时调度,也即时间的“不均匀性”越小。
事实上,上例提到的光学编码器脉冲技术的采样C语言程序通常放入优先级较高的 ISR 中进行。下图显示了三个不同的 10 毫秒重复采样间隔是如何受相对优先级的影响的。
在最高优先级的是 ISR,显然它能够精确的以 10 毫秒的时间间隔运行。TH 任务的优先级低于 ISR,但是它也能勉强按照 10 毫秒的时间间隔运行。再来看最下面的 TL 任务,它的优先级最低,操作系统较难保证它严格按照 10 毫秒的时间间隔运行,也即时间“不均匀性”最大。
为什么优先级高的C语言任务更有可能按照精确的时间间隔运行?这涉及到操作系统的进程管理知识,读者可以参考我之前的《Linux 学习系列文章》。
9. 不恰当的优先级分配
在对实时性要求较为严格的C语言程序开发中,我们需要明白哪些任务需要优先完成,并为其设定合理的优先级,否则可能会导致整个C语言程序无法满足实时要求。
遗憾的是,在复杂的大型C语言程序开发中,程序员很难完全正确的把所有任务恰好的按照实际的优先级排序。更糟糕的是,优先级错误排序的任务可能并不会每次都让整个C语言程序异常,它们也许运行的符合设计预期,但是却仍然有机会导致重大错误发生。
要是C语言程序在某次现场运行中出现问题,开发人员很难迅速的解决,因为我们甚至很难重现C语言程序出现的问题。
小技巧
将C语言程序拆分成若干子任务,再为子任务们分配优先级是非常重要的。这要求我们对项目非常了解,弄清各个子任务的依赖关系,被依赖最多的子任务肯定先要完成,这一准则有助于我们为子任务分配恰当的优先级。
在分配任务优先级的过程中,还应该明白,最高优先级的任务或 ISR 总是可以在任意繁忙的时刻为自己抢占CPU的使用权。
8. 优先级“反转”
当C语言程序中有多个任务需要协调彼此工作,例如共享一段数据时,优先级“反转”现象就极有可能发生。
想象一下任务 A 具有最高优先级,任务 Z 的优先级最低,但是它们共享一段数据。程序员设定任务 A 为最高优先级,说明他希望任务 A 能够随时被处理。即使CPU再繁忙,任务 A 如果需要执行,也总是能够获得 CPU 使用权。
但是,任务 Z 的优先级最低,却和任务 A 共享一段数据。假设某个时刻任务 Z 正在使用这段共享数据,任务 A 需要被投入运行了,于是任务 Z 的 CPU 使用权被剥夺给任务 A。
可是,这时共享数据被任务 Z 持有,任务 A 无法正常执行,它在尝试获取共享数据失败后,只能将 CPU 使用权再次让出,如果此时有任务 M(优先级介于 A 和 Z 之间),它反而优先执行了,这看起来就像任务的优先级颠倒了,也即优先级“反转”了。
请看上图,首先,低优先级的任务在 T1 时刻获得共享数据。在高优先级的任务抢占低优先级之后,它发现无法获取共享数据(T2 时刻),于是将 CPU 使用权让出。
中间优先级的任务对共享数据不感兴趣,它优先于低优先级任务执行(T3 时刻),于是 CPU 使用权被中间优先级任务抢占。
此时,优先级是“反转”的,这通常不符合程序员的预期,所以常会为C语言程序带来一些隐蔽的错误。因为优先级“反转”会阻碍最高优先级任务实现严格的实时性,导致C语言程序无法实时的完成重要任务。
优先级“反转”会导致的另一个大问题是,它通常不是一个容易重复的问题,所以在C语言程序开发完毕后,测试环境并不总是能够保证发现这个问题。
一个典型的例子是,1997年,美国发射的火星探测器在脱离地球时,由于程序原因不断发生重启现象。(这个我会在之后的文章里细谈,敬请关注!)
小技巧
鉴于优先级“反转”较难被发现,C语言程序员应该在开发阶段就尽力避免。这里我建议只使用互斥 API 保护实时C语言程序中的共享资源,而不是使用信号量 API。
在C语言程序开发阶段,要考虑各个任务极其相关外围工作的额外执行时间成本,保证其总是能够在设定的 deadline 之前完成。
7. 死锁
简言之,死锁其实就是多个任务之间的循环依赖关系。例如,任务 1 和任务 2 共享资源 A、B,任务 1 已经获取了资源 A,并且尝试获取资源 B。如果此时任务 2 已经获取了资源 B,且正在尝试获取资源 A,那么此时就形成了“死锁”。
任务 1 在获取到资源 B 之前是不会释放资源 A 的,而任务 2 在获取到资源 A 之前是不会释放资源 B 的,那它俩就只能永远等对方释放自己需要的资源了。
小技巧
有两个小技巧可以尽可能的避免死锁,我比较推荐第一个:即不要尝试同时获取多个互斥体,如果所有C语言任务都能做到这点,基本上不可能会有“死锁”产生。
避免任务同时获取多个互斥体还有个好处,就是在团队开发中可以减少需要记住正确使用每个互斥体的C语言程序员数量。另外,互斥体可以以句柄形式隐藏,便于与中断切换,以平衡C语言程序的性能和任务优先级。
如果读者的C语言代码不可避免的申请多个互斥体,则可以使用第二个小技巧:即保持申请互斥体的顺序。再来看上面的例子,任务 1 申请资源的顺序是 A、B,任务 2 申请资源的顺序是 B、A,这导致了“死锁产生”。
现在保持任务 1、2 申请资源的顺序一致,即任务 1 和任务 2 申请资源的顺序都是 A、B 或者都是 B、A,那么“死锁”就被避免了,读者可自己思考原因。
不过值得说明的是,虽然这个技巧可以消除“死锁”,但也会带来执行效率损失。我建议只有在处理大量无法轻松重构,多个互斥依赖项的遗留代码时,才以这种方式消除死锁。
6. 内存泄漏
即使C语言程序内存泄漏的量非常少,随着程序的运行,也有可能会耗尽所有可用空间,导致程序崩溃。正在合法使用内存区域被其他任务的C语言代码覆盖使用,或者引用了不期望的“野指针”是导致这一问题的常见原因。
在使用动态内存分配的C语言程序中,内存泄漏是一个需要小心的主要问题。内存泄漏是一个所有权管理问题。从堆分配的对象总是有一个创建者,例如调用 malloc() 并将结果指针传递给另一个任务,或插入到链表中。但是每个被分配的对象都有指定的 free() 吗?C语言程序如何知道一段内存已经没人使用了?什么时候应该将其释放呢?
小技巧
经过上面的讨论,应该明白要是能够在C语言程序开发阶段,清楚的知道每个内存分配对象的所有者,以及生命周期,避免内存泄漏就简单了。
请看下图,内存段由生产者任务(P)分配,通过消息队列发送给使用者任务,然后由使用者任务(C)销毁释放。在使用堆的实时系统中,应尽可能地遵循这一设计模式或者其他安全设计模式。
除了避免内存泄漏之外,图中所示的设计模式还方便用于防止“内存不足”错误,即当生产者任务尝试分配时,缓冲池中没有可用的缓冲区。
参考步骤如下:(1)为这种类型的分配创建一个专用的缓冲池;(2)使用队列理论来适当地调整消息队列的大小;(3)调整缓冲池的大小,以便最初为每个使用者、每个生产者提供一个可用内存段。
小结
本节主要讨论了在C语言程序开发中,实际上会有很多难以被发现的错误隐藏在代码中的。鉴于其难以被重现和解决,在程序开发阶段应该尽力避免产生,所以我计划列出 10 条在实践开发中值得注意的地方。不过限于篇幅,本文仅列举了 5 条,其他 5 条将在下一节中讨论,敬请关注。
老哥,通过百度搜了回调函数,发现了你的博客,发现你的文章写得真的非常好,例子和深度都刚刚好,很久没见过这么好的博客了,搞STM32搞了一年,虽然程序没出过大问题,但是总感觉C语言欠缺点啥,你这些文章真的有茅塞顿开的感觉,感谢!
感谢支持