C语言陷阱与技巧第43节, 10条避免程序出现严重漏洞bug的实战开发经验

在嵌入式C语言程序开发中发现并解决潜在的 bug 是一项困难的工作,这一点上一节内容已经讨论的比较详细。

容易重现的“显式”bug 在C语言程序员开发阶段一般就能解决,少有的漏网之鱼也会在测试中被发现解决。最让人头疼的 bug 则是隐藏在C语言程序中的,这些 bug “喜怒无常”,因为它们会以一种微妙的方式损坏代码或者数据,系统在故障发生之前的一段时间内仍然工作正常。

C语言程序员在排查这些潜在 bug 的时候,常常会感到力不从心,因为这些 bug 不容易重现,上一节称这类 bug 为“幽灵bug”。

鉴于“幽灵bug”难以被发现,与其解决这种类型的 bug,倒不如在C语言程序开发阶段,严格要求自己遵守一些技巧和规则,尽力避免其出现。

这里介绍我在实际C语言程序开发中总结的 10 条避免“幽灵bug”的技巧和经验,上一节已经列举了 top10 ~ top6,本节将讨论剩下的 5 条经验,也即 top 5 ~ top 1。

5. 堆的碎片化

对于普通的C语言程序而言,动态内存分配是一个比较常用的开发手段。但是对于非常重视安全(safety-critical)的领域而言,动态内存分配又是非常不推荐使用的,原因之一就是堆碎片化问题。

通过C语言的 malloc() 标准库向系统申请的内存位于堆上。堆是 RAM 中预先确定的非常大的内存区域,调用 malloc() 进行的内存分配都将从堆中的“空闲”区域取出若干字节使用。

例如某段C语言程序运行时,堆是从地址 0x20200000 开始的 10 kB 字节,某两个任务均调用了 malloc() 申请 4 kB 内存使用,则堆中将剩余 2 kB 可用空间。任务使用内存完毕后,可以通过调用 free() 将内存返回给堆。

理论上,free() 将使得原本被占用的内存在随后的内存分配过程中重用。但是既然是动态内存分配,内存的分配和释放都是不可知的(或者说“随机的”),堆通常会在C语言程序运行一段时间后变成一堆碎片。

例如,原本堆中共有 10 kB 空间,某次内存分配的是第 5~8 共 4 字节,这样一来,堆已经支离破碎了,堆中剩余一个 4kB 块和一个 2 kB 块,但是它们不是相邻的,不能组合在一起,尽管堆中还有 6 个字节的内存空间剩余,但是超过 4 kB 的内存分配将失败。

所以在C语言程序开发阶段,就应该考虑到:程序长时间运行后,堆的碎片化将导致一些内存分配请求失败。

实践经验

要防止上述问题出现,最好的办法就是在开发C语言程序中不使用堆。但是,如果动态内存分配在C语言程序中是必要的,或者能够带来极大的方便,那么可以考虑使用下面这样的开发技巧。

应该明白,堆的碎片化主要是由每次分配的内存大小不同造成的,如果所有申请分配的内存大小相同,那么堆中任何空闲块都可以满足需求,堆将不再产生碎片。

基于这样的思路,我们可以编写自己的固定大小的内存池 API,构造一个类似于 malloc/free(而不是直接使用它们) 的C语言函数库,该库实际上只需要三个功能,请看下面的简易C语言代码:

handle = pool_create(block_size, num_blocks)
p_block = pool_alloc(handle)
pool_free(handle, p_block).

pool_create() 负责创建固定大小的内存池,pool_alloc() 负责申请一个固定大小的内存块并返回给 p_block 管理,pool_free() 则负责将用完的内存返回给内存池。

一些实时系统(RTOS)提供内存池服务,这时我们应该直接使用它。

4. 栈溢出

相信每个C语言程序员都知道栈溢出是非常糟糕的事情,不过,不同的栈溢出带来的后果也是不同的。区别主要在于栈溢出将哪些数据或者指令“删除”,以及C语言程序是如何使用这些数据和指令的。

相比于普通台式计算机,嵌入式C语言程序开发要更加注重栈溢出问题。有下面这几个原因,请看:
1. 嵌入式系统的 RAM 通常较小
2. 嵌入式系统通常没有虚拟内存
3. 有些基于 RTOS 的固件设计每个任务使用一个栈,这要求栈必须足够大,以适应较深的栈深度
4. 中断处理程序可能也会使用栈

不管怎么说,在C语言程序开发中,栈溢出的危害都是极大的。更复杂的是,在C语言程序开发完成后,没有多少测试可以确保栈一定足够使用。例如,我们开发的C语言程序应该无故障的运行 5 年,可又有多少测试可以保证与实际的使用环境一致,并且又有多少项目允许测试 5 年呢?

实践经验

在C语言程序启动时,为栈标记一个“高水位”信息(我喜欢使用 hex 23 3d 3d 23,因为这几个数字转换为 ASCII 码后,是“#==#”,像一个栅栏)。

C语言程序运行时,建立一个任务周期性检查“高水位”信息是否被修改,如果被修改了,则说明栈已经使用到“高水位”了,C语言程序应该执行记录重要信息等安全操作了,这样可以在C语言程序真正栈溢出时确保重要数据不会丢失。

3. 缺少“volatile”关键字

有些C语言程序员忘记在定义易变变量时使用 volatile 关键字,这可能会在使用编译器优化项时,编译出无法正常运行的程序。(这一点我之前有专门的文章讨论过,感兴趣的读者可以点我主页,找找看。)

例如,如果你写出了下面这两行C语言代码,其中的 g_alarm 没有使用 volatile 关键字定义:

g_alarm = ALARM_ON;
g_alarm = ALARM_OFF;

使用编译器优化项时,编译器可能会尝试忽略第一行,以使C语言程序更快、更小,但是这种优化却导致C语言程序违背程序员的初衷。但是如果将 g_alarm 定义为 volatile 的,优化器则会避免这种不预期的优化。

实践经验

如果读者在C语言项目中定义了如下变量,则应使用“volatile”关键字:
* ISR 和任何其他代码逻辑共享的全局变量
* 多个RTOS任务访问的全局变量,即使使用了锁同步
* 指向内存映射的外围寄存器的指针
* 循环计数器

注意,除了确保对给定变量进行所有读写操作外,volatile的使用还通过添加额外的“序列点”来约束编译器,也即确保C语言程序按照代码中写入的顺序访问和使用变量。

2. 竞争条件

在C语言程序开发中,如果有多个执行线程共同使用同一个全局变量,则可能会产生竞争条件。

例如,假设有两个执行线程 A、B,其中线程 A 周期性的递增全局变量(g_counter += 1;),线程 B 则偶尔重置全局变量(g_counter = 0;)。如果线程 A、B 的操作不能保证以原子方式执行,那么这样的两个线程将产生竞争条件。

想象一下,加入线程 B 在重置全局变量的那一刻,线程 A 也使用了全局变量并对其执行 +1 操作,那在内存中,计数器实际上并没有被重置,它的值从此就被破坏了,这可能会对整个C语言程序产生严重的影响。

实践经验

可以通过保护C语言代码的“关键部分”避免出现竞争条件,C语言代码的“关键部分”必须使用恰当的抢占限制,保证其以原子的方式执行。而且,为了防止涉及 ISR 的竞争条件,在另一个代码执行到关键部分期间,最好还要禁止中断。

在RTOS任务之间发生竞争的情况下,最好创建该共享对象的互斥体,每个任务在进入关键部分之前必须获取该互斥体。

1. 不可重入函数(non-reentrant function)

从技术上讲,C语言程序中的不可重入函数问题是竞争条件问题的一个特例。因此,由不可重入函数引起的运行错误是相似的,并且也不会以可重复的方式发生,这使得它们同样难以调试。

更可怕的是,与其他类型的竞争条件相比,不可重入函数在代码评审中更难被发现。

下图是一个典型的场景,RTOS任务A和B共同使用以太网驱动程序,并且是通过函数调用间接实现的。例如,任务A调用sockets层协议函数,该函数接着调用TCP层协议函数,TCP层协议函数调用IP层协议函数,IP层协议函数调用以太网驱动程序。

应注意的是,为了使系统能够可靠地工作,所有这些函数都必须是可重入的。

但是,以太网驱动模块的操作对象必定包括硬件寄存器形式的全局变量,如果这些寄存器在操作器件允许被抢占,则可能发生下面这种情况:

任务 A 使用以太网驱动模块得到了数据,但是在传输之前,却被任务 B 抢占,然后任务 B 调用 sockets 层函数->tcp 层函数->ip 层函数->以太网驱动程序,得到数据后排队传输。

这样一来,当任务 A 再次得到 CPU 请求传输自己的数据时,根据以太网控制器芯片的设计,极有可能重新传输任务 B 的数据给 A,或者产生错误。但不管是哪种情况,任务 A 的数据丢失了。

因此,这些C语言函数功能必须是可重入的。如果每个C语言函数都只使用栈变量,则无需任何操作,因为每个任务都有自己的私有栈。但是,除非经过特别设计,否则驱动程序可能是不可重入的。

使C语言函数可重入的关键在于避免外围寄存器、全局变量、持久堆对象等共享内存区域产生竞争条件,这可以通过禁用中断,或者创建互斥体实现。

实践经验:

在每个C语言库或者驱动模块中创建互斥体,互斥体本质上是不可重入的,程序应该确保获取到互斥体是任务使用持久数据或寄存器等共享资源的前提条件。

例如,可以使用互斥体避免以太网控制器的寄存器和全局数据包计数器等共享资源产生竞争条件,在操作这些对象之前,模块中所有的C语言函数必须遵守获取互斥体是操作的必要条件这一限制。

另外值得注意的是,C语言程序中不可重入函数还可能来自第三方插件、历史遗留代码,或者设备驱动程序。甚至,编译器提供的一些标准库函数也是不可重入的,我们在开发和维护C语言程序时,应注意到这些。

小结

本文主要列举了避免C语言程序出现难以调试和重现的 5 条实践经验,结合上一节,本专栏一共列举了 10 条有用的实战开发经验,但是读者应该明白,这 10 条经验只是我抛砖引玉,希望读者能够找到适合自己的开发经验。如果有可能的话,希望读者可以分享自己的心得。

阅读更多:   C语言
添加新评论

icon_redface.gificon_idea.gificon_cool.gif2016kuk.gificon_mrgreen.gif2016shuai.gif2016tp.gif2016db.gif2016ch.gificon_razz.gif2016zj.gificon_sad.gificon_cry.gif2016zhh.gificon_question.gif2016jk.gif2016bs.gificon_lol.gif2016qiao.gificon_surprised.gif2016fendou.gif2016ll.gif