电子产业一站式赋能平台

PCB联盟网

搜索
查看: 23|回复: 0
收起左侧

嵌入式软件防御式编程

[复制链接]

540

主题

540

帖子

3901

积分

四级会员

Rank: 4

积分
3901
发表于 2025-2-12 11:38:00 | 显示全部楼层 |阅读模式
防御式编程,概念来自防御式驾驶思维,永远不能确定另一位司机将要做什么,这样才能确保在他做出危险动作时你不会受到伤害;需要自我承担保护责任,即使是他人犯的错。防御式编程的主要思想是,不因外部的错误数据而被破坏。
1 保护程序免遭非法输入的破坏如果输入正确信息就会输出正确结果;反之,错误的信息输入会得出错误的结果,除非有设计的干预。对已形成产品的软件而言,仅仅“垃圾进,垃圾出”还不够;好的程序不会生成垃圾,而是做到“垃圾进,什么都不出”、“进来垃圾,出去是出错提示”或“不许垃圾进来”。
“垃圾进,垃圾出”是缺乏安全性的程序,通常有三种方法来处理垃圾进来。
1.1 检查来源于外部的数据当从用户、网络或其它外部接口获取数据时,应检查所获得的数据,确保在允许的范围内。对于数值,要确保在可接受的取值范围内;对于字符串,要确保其不超长。如果数据代表的是特定范围内的数据 (如交易ID 或性别类型),要确认其取值合乎实际,否则应拒绝。
1.2 检查子程序的输入参数检查子程序输入参数的值,本质上和检查来源于外部的数值一样,只不过数据是来自于其它子程序而非外部接口。
在输入数据时将其转换为恰当的类型,输入的数据通常都是字符串或数字的形式。例如数值有时被映射为“真”或“假”的布尔类型,有时要被映射为验证红绿蓝颜色的枚举类型。在程序中长时间传递类型不明的数据(枚举、布尔、指针都是个数字而已),会增加程序的复杂度和崩溃的可能性,如在需要输入颜色枚举值的地方输入了“假”。因此,微信公众号【嵌入式系统】提醒,应该在输入数据后立即将其转换到恰当的类型,少用魔法数。后文会阐述实用方法可用于确定程序需要检查哪些输入数据。
1.3 决定如何处理错误的输入数据一旦检测到非法的参数,该如何处理它呢?根据应用场景和需求的不同,微信公众号【嵌入式系统】提醒,后文第3节“错误处理技术”中会详细描述这些。
防御式编程是提高软件质量技术的有益辅助手段,在设计过程和调试中避免防止引入错误。防范看似微小的错误,收获可能远远超出你的想象。微信公众号【嵌入式系统】提醒可参考《高质量嵌入式软件的开发技巧》。
2 断言断言(assertion)是指在开发期间使用的、让程序在运行时进行自检的代码,通常是一个子程序或宏。断言为真则表明程序运行正常,若断言为假,则意味着它在代码中发现了意料之外的错误。断言对于大型的复杂程序或可靠性要求高的程序来说尤其有用,通过断言,程序员能更快速地排查出程序里不匹配的接口假定和错误等。
2.1 建立断言机制断言通常含有两个参数:一个描述假设为真时的情况的布尔表达式,和一个断言为假时需要显示的信息。可以用断言检查如下假定:
■  输入参数或输出参数的取值处于预期的范围内;
■ 子程序开始(或结束)时文件或流是处于打开(或关闭)的状态;
■ 仅用于输入的变量的值没有被子程序篡改;
■ 指针非空;
■ 传入子程序的数组或其他容器至少能容纳X个数据元素;
这里列出的只是一些基本假定,正常情况下,并不希望用户看到产品代码(Release版本)中的断言信息。断言通常只是在开发阶段被编译到目标代码中,而在生成产品代码时并不编译进去。在开发阶段,断言可以帮助查清相互矛盾的假定、预料之外的情况以及传给子程序的错误数据等。在生成产品代码时,可以不把断言编译进目标代码里去,以免降低系统的性能。
包括C++在内的很多语言都支持断言。如果不直接支持断言语句,可自定义实现:
/*微信公众号【嵌入式系统】断言demo*/
#ifdef DEBUG
#define ASSERT(condition) \
if(!condition)   \
     assert_handle(__FILE__ , __LINE__)
#else
#define ASSERT(condition) NULL
#endif
2.2 断言的建议错误处理代码针对预期会发生的状况,断言处理绝不应该发生的状况。错误处理通常用来检查有害的输入数据, 而断言是用于检查代码中的bug。
2.2.1 避免把需要执行的代码放到断言中如果把逻辑代码写在断言里,那么当关闭断言功能时,编译器很可能就把这些代码除排在外了。所以什么时机使用断言或者错误处理,需要分版本。
2.2.2 用断言来注解并验证前条件和后条件前条件是调用方对其所调用的代码要承担的义务,后条件是子程序执行结束后要确保为真的属性。断言是用来说明前条件和后条件的有利工具。
微信公众号【嵌入式系统】举例,子程序传入的两个参数为浮点,表示经度和纬度,(不考虑方向,只是对数值范围进行判断)。
如纬度合理范围是0到90,若数据来源是系统外部,比如GNSS芯片输出到处理器,对输入数值可以使用错误处理。
//入口前条件
if(latitude>90)
{
return -1;
}
而如果变量的值是源于可信的系统内部,并且这段程序是基于该值不会超出合法范围的假定而设计,使用断言则是非常合适的。
//内部处理完将退出的后条件
ASSERT(latitude90);
2.2.3 高健壮性的代码应先使用断言再处理错误对于每种可能出错的条件,通常子程序要么使用断言,要么使用错误处理代码来进行处理,一般不会同时使用两者。
然而,现实世界中的程序和项目通常都很混乱。在某些复杂项目中,可能同时用断言和错误处理代码来处理同一个错误。在代码中对理论上始终为真的条件都加上断言,同时也用错误处理代码处理这些错误,尤其对复杂且生命周期很长的应用程序而言,断言是非常有用的。
3 错误处理技术断言可以用于处理理论上不应发生的错误。那如何处理那些预料中可能要发生的错误呢?根据所处情形的不同,可以返回中立值、换用下一个正确数据、返回与前次相同的值、换用最接近的有效值、在日志文件中记录警告信息、返回一个错误码、调用错误处理子程序、显示出错信息或者关闭程序,或这些技术结合起来使用。
3.1 错误处理技术3.1.1 返回中立值处理错误数据的最简单的做法就是继续执行,并简单地返回一个没有危害的数值。比如数值计算可以返回0,字符串操作可以返回空字符串,指针操作可以返回空指针等。如视频游戏中的绘图子程序接收到了一个错误的颜色输入,那它可以用默认的背景色或前景色继续绘制。但对于显示病人X 光片的绘图子程序而言,最好还是不要显示某个“中立值”。
3.1.2 换用下一个正确的数据在处理数据流的时候,有时只需返回下一个正确的数据。假设以每秒10次的频率读取环境温度数据,如果某一次得到的数据有误,只需再等上1/10秒然后继续读取即可,再算法平滑处理。微信公众号【嵌入式系统】提醒可参考《嵌入式算法3---滑动平均滤波法》。
3.1.3 返回与前次相同的数据如前面提到的环境温度读取软件,某次读取中没有获得数据,那么可以简单地返回前一次的读取结果。根据应用领域的情况,温度在1/10秒的时间内不会发生太大改变。而在视频游戏里,如果要用一种无效的颜色重绘屏幕的某个区域,那么可以简单地使用上一次绘图时使用的颜色。但如果是处理自动取款机上的交易,则不能使用“和最后一次相同的答案”了,因为那可是前一个用户的银行账号。
3.1.4 换用最接近的合法值在有些情况下,可以选择返回最接近的那个合法值,例如温度计可测量范围在0到100摄氏度之间,如果检测结果大于100,可以把它替换为100。
3.1.5 把警告信息记录到日志文件中检测到错误数据时,可以选择在日志文件中记录一条警告信息,然后继续执行。这种方法可以同其他的错误处理技术结合使用。如果用到了日志文件,还要考虑是否能够安全地公开它,是否需要对其进行加密或施加其他方式的保护,是否影响正常功能。微信公众号【嵌入式系统】提醒可参考《嵌入式算法6---AES加密/解密算法》。
3.1.6 返回一个错误码只让系统的某些部分处理错误,其他部分则不在本地(局部)处理错误,而只是简单地报告说有错误发生,并信任调用上游的子程序会处理该错误。(微信公众号【嵌入式系统】提醒,区分返回值正常和错误,可以使用0和负数,也有使用正数和0的风格,一般前者比较通用)。返回错误码,更为重要的是要决定哪些只是报告所发生的错误(退出当前子程序不管后续操作),哪些应该直接处理错误(针对错误的后续补救措施)。
3.1.7 调用错误处理子程序把错误处理都集中在一个全局的错误处理子程序中。这种方法的优点在于能把错误处理的职责集中到一起,从而让调试工作更为简单、统一,而代价是整个程序都要知道这个集中点并与之紧密耦合。
3.1.8 错误发生时显示出错消息这种方法可以把错误处理的开销减到最少,然而它也可能会让用户界面中出现的信息散布到整个应用程序中。当创建一套统一协调的用户界面时,或让用户界面部分与系统的其他部分清晰地分开,或想把软件本地化到另一种不同的语言时,都会面临挑战。还要当心不能告诉系统的潜在攻击者太多东西,攻击者有时利用错误信息来发现如何攻击这个系统。
3.1.9 用最妥当的方式在局部处理错误一些设计方案要求在局部解决所有遇到的错误,而具体使用何种错误处理方法,则留给设计和实现会遇到错误的这部分系统的程序员来决定。这种方法给予每个程序员很大的灵活度,但也带来显著的风险,即系统的整体性能将无法满足对其正确性或可靠性的需求(不同子模块对同类错误的错误不一致)。
3.1.10 关闭程序有些系统一旦检测到错误就会关闭。这一方法适用于人身安全攸关的应用程序。如用作控制治疗病人的放疗设备的软件,接收到了错误的放射剂量输入数据,在这种情况下,关闭程序是最佳的选择。
3.2 健壮性与正确性正如前面视频游戏和X光机的例子,处理错误最恰当的方式要根据出现错误的软件的类别而定。错误处理方式有时更侧重于正确性,而有时则更侧重于健壮性。但严格来说,这两个术语在程度上是截然相反的。正确性意味着永不返回不准确的结果,哪怕不返回结果也比返回不准确的结果好。然而,健壮性则意味着要不断尝试采取某些措施,以保证软件可以持续地运转下去,哪怕有时做出一些不够准确的结果。
人身安全攸关的软件往往更倾向于正确性而非健壮性,消费类应用软件往往更注重健壮性而非正确性。再戏谑点,无人职守的消费电子产品只要看门狗正常,什么问题都可以暂时忽略;软件质量也要兼顾成本。
3.3 高层次设计对错误处理方式的影响对错误进行处理的方式会直接关系到软件能否满足在正确性、健壮性和其他非功能性指标方面的要求。确定一种通用的处理错误参数的方法,是架构层次(或称高层次)的设计决策。
一旦确定了某种方法,就要确保始终如一地贯彻。如果决定让高层的代码来处理错误,而低层的代码只需简单地报告错误,那么就要确保高层的代码真的处理了错误。嵌入式C语言允许忽略“函数返回的是错误码”,但千万不要忽略错误信息。检查函数的返回值,即使认定某个函数绝对不会出错。防御式编程的重点就在于防御那些未曾预料到的错误。
这些指导建议对于系统函数和自己写的函数都是成立的,在每个系统调用后检查错误码。一旦检测到错误,就记下错误代号和它的描述信息。
3.4 异常处理异常是把代码中的错误或异常事件传递给调用方的一种特殊手段。如果在一个子程序中遇到了预料之外的情况,但不知道该如何处理的话,可以抛出一个异常(嵌入式C不支持),就好比是举起双手说“我不知道该怎么处理它”。对出错的前因后果不甚了解的代码,可以把对控制权转交给系统中其他能更好地解释错误并采取措施的部分。
4 隔离程序错误4.1 隔栏船底包括多个独立的密封舱,如果船只与冰山相撞导致船体破裂,隔离舱就被封闭起来,从而保证船体的其余部位不会受到影响。隔栏就类似这种容损策略。防御式编程的隔离,是把某些接口选定为“安全” 区域的边界。对穿越安全区域边界的数据进行合法性校验,并当数据非法时做出敏锐的反映。
也可以把这种方法看做是手术室里使用的一种技术。任何东西在允许进入手术室之前都要经过消毒处理,手术室内的任何东西都可以认为是安全的。这其中最核心的设计决策是规定什么可以进入手术室,什么不可以进入。
在编程中规定,哪些子程序可认为是在安全区域内的,哪些是在安全区域外的,哪些负责清理数据。完成这一工作最简单的方法,是在得到外部数据时立即进行清理。
4.2 隔栏与断言的关系隔栏的使用,使断言和错误处理有了清晰的区分。隔栏外部的程序应使用错误处理技术,在那里对数据做的任何假定都是不安全的。而隔栏内部的程序里就应使用断言技术,因为传进来的数据应该已在通过隔栏时被清理过了。如果隔栏内部的子程序检测到了错误的数据,那应该是程序里的错误而不是数据里的错误。
规定隔栏内外的代码模块,是架构层次上的决策。也可以简单按硬件通信模块隔离。A芯片和B芯片的数据交换部分就是立隔栏的地方。
5 辅助调试的代码防御式编程的另一重要方面是完善调试、测试代码。
5.1 区分产品版和开发版入门程序员常常有个误区,不在乎debug和release版本的差异。产品级的软件要求能够快速地运行,而开发中的软件则允许运行缓慢。产品级的软件要节约资源,而开发中的软件在使用的资源时可能比较奢侈。产品级的软件不应向用户暴露可能引起危险的操作,而开发中的软件则可以提供一些额外的调试操作。
开发期间牺牲一些速度和对资源的使用,来换取一些可以让开发、测试更顺畅的内置功能。比如引入辅助调试的代码,例如某个功能每天启动一次,可以模拟加快时钟频率用于调试,或者支持配置为10分钟循环一次。
5.2 采用进攻式编程异常情况应在开发、测试阶段让它显现出来,而在产品实用时让它能够自我恢复,这种方式称为 “进攻式编程”。
如switch/case 语句处理场景,在最终的产品代码里,针对默认情况default子句(意料之外的)处理则应更稳妥一些,比如在错误日志文件中记录该消息,而开发期间进攻式编程的方法,则是使用断言语句使程序终止运行。
不要让程序员养成坏习惯,碰到警告就忽略,而应该让问题引起的麻烦越大越好,这样它才被重视、被修复。确保每一个意料之外的分支都能产生严重错误(比如让程序终止运行),或者至少让这些错误不被忽视。最好的防守正是大胆进攻,在开发阶段发现并解决各种断言重启错误,才能让产品发布后高枕无忧。
5.3 移除调试代码如果程序是个人或内部玩玩,那么调试代码留在程序里并无大碍。但如果是商用软件,则会使软件的体积变大且速度变慢,对性能造成影响。需采取一些措施避免调试代码和正式程序代码纠缠不清。
5.3.1 使用内置的预处理器嵌入式C/C++ 开发环境,可以用预处理器开关来包含或排除调试用的代码,使用预处理器的代码范例:
/*微信公众号【嵌入式系统】demo*/
#define DEBUG
#if defined(DEBUG )
    //debugging  code
#endif
这一用法可以有几种变化。除直接定义 DEBUG以外,还可以给它赋一个值,然后判断其数值,而不仅是去判断它是否已经定义了(未定义只判断的为0), 这可以区分不同级别的调试代码。
/*微信公众号【嵌入式系统】*/
#define DEBUG   100
#if DEBUG  > 10
    //...
#elif DEBUG > 20
    //...
#else
//...
#endif
如果不喜欢 #if defined 一类语句散布在代码里的各处,可以写一个预处理宏来完成同样的任务:
#if defined(DEBUG)
    #define debug_code(frmt, chengj...)    { /*debugging  code*/ }
#else
    #define debug_code(frmt, chengj...)
#endif
这种方法在使用时也可以有多种变化,微信公众号【嵌入式系统】提醒可参考《高质量嵌入式软件的开发技巧》、《项目配置与编译自动化》、《嵌入式软件分层隔离的典范》相关章节。
5.3.2 自定义调试存根可以调用一段子程序进行调试检查,在开发阶段,该子程序可执行若干操作之后才能把控制权交还给其调用方;而在产品代码里,可用一个简易或空子程序替换这个复杂的子程序,但这种方法会有一点性能损耗。
其实现就是编写一个函数指针,debug版本为其赋值指向参数校验等子函数,在release版本该函数指针为空不执行。这种情况下也可变化为通过指令动态控制是否执行调试代码片段。
5.4 产品代码中保留多少防御式代码防御式编程存在一种矛盾的观念,即在开发阶段希望错误能引人注意,宁愿看它的脸色,也不想冒险地去忽视它;但在产品发布阶段,却想让错误能尽可能地低调,让程序能稳妥地恢复或停止。
1.保留检查重要错误的代码    确定程序的哪些部分可以承担未检测出错误而造成的后果,而哪些部分不能承担。比如开发一个电子手表程序,如果在屏幕刷新部分的代码中存在未检测出的错误,可能可以忍受,因为错误造成的主要后果是屏幕显示错乱;但如果是数据存储的代码存在问题,就无法接受了,因为这种错误会导致用户的电子手表出现无法使用的结果。
2.去掉检查细微错误的代码    如果一个错误带来的影响确实微乎其微的话,可以把检查它的代码去掉。在前面的例子中,可以把检查电子手表屏幕刷新的代码去掉。这里的“去掉”并不是指永久地删除代码,而是指利用版本控制、预编译器开关或其他技术手段来不编译这段特定代码。如果程序所占的空间不是问题,也可以把错误检查代码全部保留下来,同时不动声色地把错误信息记录在日志文件。
3.去掉可能导致程序硬性崩溃的代码    程序在开发阶段检测到了错误,尽可能地引人注意,以便能修复它,实现这一目的的最好方法通常就是让程序在检测到错误后打印出一份调试信息,然后崩溃退出,这种情况使用断言对于细微的错误很有用。
然而产品阶段,例如软件用户需要在程序崩溃之前有机会保存他们的工作成果,为了让程序给他们留出足够的保存时间,用户甚至会忍受程序的一些怪异行为。相反,如果程序中的一些代码导致了用户工作成果的丢失,那无论这些代码对帮助调试程序并最终改善程序质量有多大的贡献,用户也不会心存感激。因此,如果程序里存在着可能导致数据丢失的调试代码,即因为调试功能导致正常功能崩溃的代码一定要从最终软件产品中去掉。
4.保留可以让程序稳妥地崩溃的代码    如果程序有能够检测出潜在严重错误的调试代码,那么应该保留那些能让程序稳妥地崩溃的代码。以便开发人员可利用保留下来的辅助调试信息,诊断出问题所在并修复。
5.为技术支持人员记录错误信息    可以考虑在产品代码中保留辅助调试用的代码,但要改变它们的工作方式,以便与最终产品软件相适应。如果开发时在代码里大量地使用了断言来中止程序的执行,那么在发布产品时务必把断言子程序改为向日志文件中记录信息,而不是彻底去掉这些代码。
6 防御式编程的姿态6.1 防御要点过度的防御式编程也会引起问题,如果在每一个能想到的地方,用每一种能想到的方法检查传入的数据,那么程序将会变得臃肿而缓慢。而且引入的额外代码增加了软件的复杂度。因此,要考虑什么地方需要进行防御。
■ 防御式编程技术是为了让错误更易发现、更易修改,并减少错误对产品代码的破坏。
■ 断言可以帮助尽早发现错误。
6.2 软件过程管理架构层将系统划分为多个子系统,小心定义错误处理的方法,传递给子程序的参数数目应尽量少,只传递保持子程序接口抽象所必需的参数。
对于多个程序员参与的项目,组织性的重要程度超过了个人技能。一个庞大的团队,其合力并不等于每个人能力的简单相加。
开发过程之所以重要,主要原因是软件设计决定了质量。先拼凑,再通过测试剔除缺陷的思路是无法产出高质量的软件的。
6.3 首先为人写程序,其次才是为机器计算机不关心代码是否好读,它更善于读二进制指令;编写可读性好的代码,是为了便于别人以及自己看懂。
可读的代码写起来并不比含糊的代码多花时间,运行时至少不比后者慢。如果能轻松阅读理解自己写的代码,确保该代码能工作也会更容易。不仅如此,代码在复审过程中也要阅读它;自己或别人修复错误时也要读;改动代码功能时还要读;当别人利用调用它时也要读。使代码可读性好,并非是开发过程中的可有可无部分。微信公众号【嵌入式系统】提醒可参考《代码审查那些事》《嵌入式C编码规范》《代码的保养》相关章节。
实际工作中大部分是修改维护既有代码,需要先阅读、理解以前的代码的含义,如果能在代码可读性好,附带详细说明,必能事半功倍。
7 小结正经的防御式编程不是防人,而是防代码异常。
猜你喜欢:
一个非常轻量的嵌入式日志库!
一个面向对象的C语言框架!
一个非常轻量的嵌入式线程池库!
Github上热门 C 语言项目汇总!
实用 | 10分钟教你通过网页点灯
WiFi6+蓝牙+星闪,三合一开发板,真香!
回复

使用道具 举报

发表回复

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则


联系客服 关注微信 下载APP 返回顶部 返回列表