防御性编程

2020-12-24 21:33:29

嵌入式开发最糟糕的事情之一(尤其是在C语言中)是从函数中接收返回值-1或unknown_error,而没有其他有关失败原因的信息。它没有提供有关错误从何处冒出的信息!

作为嵌入式开发人员,我们所有人都来过这里,带来了新的电路板,驱动程序,模块和应用程序,想知道为什么以及如何陷入困境。造成这些问题的根本原因就像剥洋葱:调试时挖的每一层,我们微笑的次数减少了,流下了更多的眼泪。这些问题通常是由于开发人员错误而导致的,要么是错误地,在错误的时间调用了函数,要么是使用了错误的参数。也可能是因为系统处于故障状态,例如内存不足或死锁。

固件知道有问题,很可能知道是什么问题,但仍无助于解决问题。该软件在构建时就牢记“防御性编程”做法,即使发生错误行为,该固件也可以保持运行。

通常,我希望固件崩溃,打印出有用的错误,并准确地指出问题所在。这是“进攻性编程”的指导原则,比“防御性编程”更进一步。

在本文中,我们将深入探讨什么是防御性编程和防御性编程,防御性编程的不足之处,开发人员应如何考虑在嵌入式系统中使用它们,以及攻击性编程技术如何能够发现错误并帮助开发人员在运行时立即根除它们。

通过将防御性和攻击性程序发挥到极致,您将能够在每1000小时内找出1个错误,有效地找出导致它们的原因,并让最终用户感到满意。而且,作为奖励,请保持理智。

防御性编程是编写软件的一种做法,可以在遇到计划外的问题之后和同时进行连续操作。一个简单的示例是在调用malloc()之后检查NULL,并确保程序正常处理该情况。

void do_something(void){uint8_t * buf = malloc(128); if(buf == NULL){//妥善处理! }}

防御性编程听起来不错,而且不错!如果我们不愿意,我们编写的固件绝不会由于不可预见的情况而灾难性地失败。

当直接与我们没有直接控制权的硬件,专有库和外部输入进行交互时,防御性编程的确闪耀。硬件在各种环境中可能会出现故障或表现不同,重量级的库通常充斥着错误,并且外部世界会根据通信协议发送所需的内容。

与大多数事情一样,防御性编程可能会被滥用,负面影响远远超过其好处。如果许多模块彼此重叠,那么每一个模块都使用防御性编程技术,则错误会被创建,丢失和/或掩盖。

防御性编程的关键是在固件的外部接口上使用它。外部世界和硬件之间应该有一道薄而坚固的防御性编程墙,然后您墙内的大多数代码都应该更加积极地检查错误,并在开发人员做错事时大吼大叫。

如果代码路径源自红色区域或通过红色区域,则防御性编程是一种不错的方法。

例如,假设我们有一个内部函数hash32_djb2(const char * str,size_t len),该函数接收我们应该哈希的字符串和一个长度。此功能永远不会被任何外部使用者直接使用,而只剩下内部开发人员。

uint32_t hash32_djb2(const char * str,int len){if(str == NULL){//无效的参数返回0; } ...

您只是在射击自己和您的其他开发人员。如果任何开发人员将NULL字符串传递给此函数,则它将返回0并存储为有效哈希!

NULL字符串参数可能是开发人员自己的错误,因此他们应该立即意识到该错误,否则它可能会进入生产环境。

这种写代码来解决表面错误的做法就是所谓的“攻击性”编程。(Merriam Webster定义1,“进行攻击”)

冒犯性编程虽然在单词选择上看似相反,但实际上扩展了防御性编程,并将其进一步发展。并非仅仅接受malloc()可能失败,而是具有令人反感的编程思想的软件可能会断言malloc()永远不会失败。如果malloc()调用确实失败,则该软件将致命地断言,打印足够的信息以使开发人员引起该问题或捕获核心转储,然后将其重置为已知的工作状态。

在嵌入式系统中,从硬件到软件的整个堆栈通常由单个组织构建和控制,任何错误都是该组织的工程师负责根本原因和修复的责任。冒犯性编程可能是一种有用的方法,可以消除可能需要数周时间才能复制或从未发现的表面错误。

冒犯性编程可以在软件内部采用多种形式,但是最常见的方法是针对开发人员错误和系统状态行为自由地和创造性地使用断言。

让我们来看一些假设情况,以及如何使用进攻性编程。如果您的嵌入式系统遇到以下问题:

性能问题-例如GUI冻结或对按钮按下的响应时间太慢,您可以使用看门狗或计时器和断言在系统停止运行时使系统崩溃,以便开发人员可以弄清楚到底是在消耗CPU时间。

内存问题-例如高堆栈使用率,没有可用的堆内存或过多的碎片,在检测到这些状态时会触发系统崩溃,并捕获相关部分以供开发人员进行分析以弄清系统的运行方向。很少是对malloc()或堆栈中最高功能的最终调用,而这会导致系统崩溃,而导致此崩溃的一切。

锁定问题-在RTOS功能上设置较低的超时时间(5秒),例如mutex_lock和queue_put。如果在指定的时间内操作未成功,则将超时设置为较低将导致系统崩溃,这再次使开发人员可以进一步检查根本问题。您还可以选择无限期旋转并进行看门狗清理。

这只是在冒犯编程技术的表面,但是我希望您现在对本文的目的有了一个了解!

您可能会问,为什么要使用大量的断言,计时器,看门狗和协调的故障来检测代码和固件,这是一个尚需解决的问题。嵌入式系统上的软件崩溃不仅会导致线程崩溃,而且整个系统也会崩溃。

一方面,当设备遇到无法预料的问题时,您可以使系统以不确定和不可预测的状态运行。也许堆内存不足,或者系统未能将关键事件放入队列中,而我们删除了数据,或者也许线程已死锁并且没有自动恢复机制。有些设备甚至没有电源按钮,需要手动拔下电源或取出电池!

另一方面,处于意外状态的设备本质上是损坏的设备,应将其重置。如果设备用完了,可能无法恢复,并且存在泄漏。如果线程陷入僵局,也将无法恢复。更不用说,在不确定的状态下运行会使固件面临安全漏洞,而这正是我们要避免的事情。

除了防止固件在不可预测的状态下运行之外,在错误的确切时刻触发断言和错误还有助于开发人员跟踪这些问题!如果在发生断言或故障时将设备连接到调试器,或者具有核心转储工具,则可以享受以下好处:

更快的错误修复-如果在命中某个断言时系统被暂停或捕获了核心转储,则开发人员可以访问回溯,寄存器,函数参数以及系统状态,例如堆,列表和队列。利用所有这些信息,开发人员可以更快地找出问题的根本原因。

更快的开发-如果要集成的模块中存在错误或假设,编写集成到多个模块中的新代码可能会很棘手。通过在外围层中使用断言和OffP做法,如果开发人员执行与APIspec不一致的任何事情,系统将立即向开发人员发出警报。最好不跟踪-3或unknown_error的含义以及它在系统中的冒泡方式。

意识普及-开发人员除非发现错误或将其作为错误报告放入收件箱,否则不会注意到它们。通过强制重置软件,可以提高对这些未知错误的认识,并使开发人员可以更轻松地了解它们的发生频率。

使系统恢复为正常状态-如果您的通讯线程陷入僵局,则不会有任何数据从设备发送到外界,从而使设备恢复正常状态,直到用户手动重置它为止。通过重置设备,您更有可能使设备恢复运行状态。大多数嵌入式设备的重新启动时间都非常快,因此这并不重要。

让我们深入研究一些使用冒犯性编程实践可以发现的错误的示例。

如果开发人员尝试使用API​​并将无效的参数传递给函数,请确保应用程序对它们大喊以解决问题。没有什么比接收-1返回值和深入研究10层固件代码更糟糕的发现了,真正的原因是,当最大值为16时,您传入了20个字符的字符串,或者由于未初始化的变量而意外地传递了空指针。

我唯一会选择或至少要进行激烈辩论的不使用断言来验证论据的唯一时间是,当我建立一个供组织外部人员使用的库时。在这种情况下,我将使验证断言为可选,就像FreeRTOS通过允许开发人员自己定义configASSERT来处理其RTOS功能一样。

尽管有时不赞成在嵌入式系统中使用动态内存,但是对于没有足够静态内存的复杂系统而言,通常是必要的。即使使用动态内存,也很少发生内存不足的情况。当内存不足时,系统应适应并向流入系统的任何数据施加压力,并限制内存密集型操作的速率。

但是,如果固件确实耗尽了动态内存池,我们想知道何时以及为什么发生!可能是内存泄漏或意外分配占用了大部分堆。由于系统可能会恢复,内存不足可能不是一个令人吃惊的问题,但是如果我们处于开发或内部测试阶段,请找出为什么会耗尽!

为此,我们可以在malloc()函数内部添加一个断言,以验证调用没有失败。

在对malloc()的调用永不失败的代码中,例如RTOS原语,请求和响应缓冲区的分配等,我们可以使用此断言的版本。

基于RTOS的系统的另一个常见问题是,由于处理速度不够快,队列已满。与内存耗尽一样,这并不是一个令人吃惊的问题,因为系统可能会恢复,但是我们很可能会丢掉对设备操作至关重要的事件!这是一个应进行调查的问题,理想情况下,它应避免发生。

我们可以添加一个ASSERT()来确认每个队列插入成功,或者如果需要的话甚至可以包装该函数。

无效临界事件(无效){... const bool成功= xQueueSend(q,& item,1000 / *等待1s * /)ASSERT(成功); ...}

为了帮助完成队列的调试过程,我强烈建议编写Python GDBscript来转储队列的内容。然后,当系统停止运行或您有一个核心转储时,您可以找出哪些事件正在消耗队列中的大部分空间!

(gdb)queue_print s_event_queueQueue状态:10/10队列中的事件(已满!)0:地址:0x200070c0,事件:BLE_PACKET1:地址:0x200070a8,事件:TICK_EVENT2:地址:0x20007088,事件:BLE_PACKET3:地址:0x20007070, :地址:0x20007050,事件:BLE_PACKET5:地址:0x20007038,事件:BLE_PACKET6:地址:0x20007018,事件:BLE_PACKET7:地址:0x20007000,事件:BLE_PACKET8:地址:0x20006fe0,事件:BLE_PACKET9:6:6

看起来我们的队列中塞满了通讯堆栈中的数据包,我们没有足够快地对其进行处理。现在我们知道了问题所在,可以找到解决方案。

嵌入式设备需要在合理的时间内对用户输入和通信数据包做出响应,同时保持性能,并且不显示任何滞后或停顿的迹象。

我无法计算在以前的项目中进行慢速闪存操作导致系统一次冻结2-3秒,对用户体验造成破坏或导致系统中其他操作超时的次数。这些问题最糟糕的部分是,直到为时已晚,才常常引起开发人员的注意。

为了在将固件推给外部用户之前帮助捕获这些问题,您可以创建并配置taskwatchdog,使其更具攻击性,将计时器设置为在可能较长的操作过程中等待几秒钟后进行声明,并确保在线程系统调用上设置超时。

要断言一个互斥锁已成功锁定,我们可以将超时传递给大多数RTOS调用,并断言它已成功。

void Timing_sensitive_task(void){//任务看门狗将断言const bool success = Mutex_lock(& s_mutex,1000 / * 1秒* /); ASSERT(成功); {...}}

void Timing_sensitive_task(void){//任务看门狗将断言一个互斥锁(& s_mutex,INFINITY); {...}}

由于打here和停顿并没有尽头,因此,当您构建最终要推送给客户的图片时,您可以调小或完全删除这些检查。

在为分配了malloc()的缓冲区释放了free()之后,绝对不能再由软件使用它。但是,它一直在发生。将该错误恰当地称为“释放后使用”错误2。如果系统重新使用已释放的缓冲区,则很可能不会发生任何不良情况。系统将愉快地使用缓冲区并从中写入和读取数据。

但是,有时会导致内存损坏,并以最奇怪的方式呈现出来。众所周知,内存损坏错误很难调试。

如果您在内存损坏问题上苦苦挣扎,则可能需要阅读本节,该内容是从以前的文章中捕获内存损坏错误的。

一种防止释放后使用的错误的方法是用无效的地址清除内存的整个内容,该地址在访问时将导致Cortex-M4上的HardFault并最终暂停系统或捕获核心转储。

void my_free(void * p){const size_t num_bytes = prv_get_size(p);自由(p); //将每个单词设置为0xbdbdbdbd memset(p,0xbd,num_bytes); }

检查核心转储时,回溯和缓冲区内容将显示我们的错误地址0xbdbdbdbd,我们将立即知道这是一个“使用Afterfree”错误。

我假设大多数状态机生成器或最佳做法都可以防止发生状态转换错误,但如果不是这种情况,那么这一点很重要,值得您放入状态机。

假设我们有两个状态,即kState_Flushing和kState_Committing,其中提交发生在刷新状态之后和之后。我们可以断定状态的这种变化,以进行健全性检查。

开发人员总是会犯错误。我们可以针对自己采取的一种防御措施是使用static_assert3。我通常使用这种方法来确保我的结构不超过所需的大小限制。

typedef struct {uint32_t count; uint8_t buf [12]; uint8_t new_value; //新字段} MyStruct; _Static_assert(sizeof(MyStruct)< = 16,"糟糕,太大!");

$ gcc test.ctest.c:14:1:错误:由于要求' sizeof(MyStruct)< = 16'而导致static_assert失败。 "哎呀,太大!" _Static_assert(sizeof(MyStruct)< = 16,"哎呀,太大!"); ^ ~~~~~~~~~~~ ~~~~~~~~~~~~ 1错误产生。

您可以将其用于任何可以静态定义的内容,包括alignmentof结构字段,struct和enum大小以及字符串长度。有趣的是,当我想强迫自己和其他人对某些变化进行三思时,我也会使用它。如果我想确保自己或其他任何人

_Static_assert(sizeof(MyStruct)== 16,"您要更改尺寸!""请参考https://wiki.mycompany.com / ..."&# 34;了解如何更新协议版本...");

幸运的是,这些字符串不会包含在最终的固件二进制文件中,因此您可以根据需要随意制作它们!

在某些地方,人们不想使用进攻性编程做法。主要是在开发人员无法完全控制软件,硬件或传入数据的时候。这些可以是以下任何一种:

所有这些变量或输入都不在开发人员的控制范围内,您不应该信任任何一个!开发人员将对您的平台进行最有创意,最美丽,最可怕,最滥用和最危险的事情,这是意料之外的事情,因此您应该非常谨慎。

为了防止虫子踩在草坪上,您可以使用防御性编程的垫片层隔离这些外部层。这样可确保任何错误输入,损坏的数据和恶意行为者都收到错误代码,而不会使软件崩溃。

需要重点强调的一件事是,您几乎可以始终信任并假定组织或项目内部的开发人员和代码是正确的,或者至少是正确的。考虑到这一点,我们可以声明内部软件块中的所有内容并使应用程序或系统崩溃,并向内部开发人员提供有用的数据。

简单而甜美。不要对无法保证的启动过程中运行的代码声明任何东西,因为这是在出现问题时重新启动循环的方式。我只能在启动顺序中断言是当mydevice连接到调试器时,或者绝对必要时,并且将损坏的固件运送给最终用户的机会为零。

没有比将开发板连接到调试器更好的时间来启用断言,看门狗和其他OffP实践了!有了这个功能,开发人员可以立即轻松地获得回溯和系统状态,以及通过执行未定义的行为来跟踪,以找出发生了什么,以及潜在地发现如果未修复错误则将发生的情况。

在我们研究OffP在生产环境中有意义的方面之前,我们需要确定应该以某种方式记录软件崩溃,以便开发人员可以在以后获取它们。这可以是某种形式的日志记录,跟踪或核心转储,或这些的任意组合。如果我们没有记录问题的方法,我们将永远不知道真正导致这些问题的原因以及它们发生的频率。

应该在生产版本中保持编译并启用的OffP部分是确保未定义行为不会发生在固件中的部分。这包括参数验证,检测死锁的看门狗超时,可能导致内存损坏的错误等。与使设备瘫痪相比,最好将设备恢复为已知状态。

我们经常可以考虑完全禁用的是边际问题,例如软件停顿,malloc和队列故障(wh

......