排除软件开发人员的水平和项目进度的原因,主要影响因素还包括软件架构,和软件缺陷的修复能力。对于量产软件,架构问题是先天性的,后期很难大改,只能前期预防;软件缺陷问题是无法避免的,只能期望快速修复。
2 软件架构问题
2.1 软件架构的特点
1)承载力
正如一艘船最多能装多少人,从软件方面来说是软件架构能承载多少业务或功能需求,当然,这需要架构师一开始架构系统的时候,就需要有一定的预见性。但也没必要为了极小概率事件增加过多的冗余。
2) 易用性
易用性决定了软件的整体开发效率,好的架构会让团队成员容易上手,子系统容易对接,开发效率高,各模块和子系统的编写只需要关注系统的设计和编码工作,其他模块间通信方面的事情架构可以提供很好的兼容。
3)扩展性
一个水杯除了用来喝水,也可用来喝酒,适应不同场景,在一定范围内满足不同的需求,是非常有必要的。软件架构也是这样,要新增更多的功能就要具备更高的扩展性。可扩展性的关键就在于新增部分不能影响其他,如果增删导致系统整体使用异常,那么这个架构的可扩展性就很差。
4)伸缩性
伸缩性就是设计的方案或系统是否可以根据需求适配不同数量的功能或子系统,在我们设计的软件系统中,架构的可伸缩性决定了架构的可适配性,例如,当硬件资源不足时,可以调整配置如flash的空间分配,支持减少一些服务但仍能正常运行。
5)容错性
软件运行中的异常,如用户的非法操作,或者软件本身的小缺陷导致整个系统无法使用,那这个架构容错性就很差。软件中的一些缺陷无法避免,但是我们应尽量保证这个缺陷的影响范围最小。倘若出现系统无法使用的情况,应该有备份方案,比如自动重启或者自动恢复数据等功能,也应该能够让开发人员及时知道问题的发生,以及问题所在的位置并记录错误信息。
在架构设计中,以上五项基本能力缺一不可,某项能力的突出并不能带动其他项,如果某一项能力比较弱,随着时间的推移,问题会越来越大,甚至系统崩溃。就像木桶原理那样,一个木桶的容量不是取决于最长的那根木板,而是取决于最短的那根。
2.2 如何规划软件架构
2.2.1 必须熟悉业务
软件是为业务服务的,业务才是“目的”,软件系统是为了达成业务系统目标的手段和方法。适应当前的业务需求是基础,充分考虑和预测未来的业务扩展,根据业务的扩展性来设计软件的扩展性。如果可预见未来没有扩展重大新业务的需求,那么相应的软件架构就没有必要采用高扩展的软件架构。比如嵌入式的传感器数据采集小设备,就没有必要把云计算等,业务范围不沾边的技术点放到其中。软件架构必须以服务业务为核心思想,不熟悉当前软件业务、和未来业务的扩展的架构师是很难设计出好的软件架构。
2.2.1 借鉴业内成熟的架构
不照搬,并不意味着不要借鉴。借鉴业内成熟的软、硬件架构是相对稳妥、高效的做法。以业内的架构为基础,根据自身业务的特点,进行适配、裁剪和增加新的功能。熟悉业内常规的、成熟的、最新的软件架构是架构师的一项基本功。但熟悉并不是意味着必须立即在目标系统中实施这些软件架构。
2.2.3 采用设计模块
设计模式(Design pattern)代表了最佳实践,设计模式是软件开发人员在开发过程中对一般问题的解决方案;是一套被反复使用的、多数人知晓的代码设计经验的总结,经过相当长的一段时间的试验和错误总结出来的。
使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性,合理地运用设计模式可以完美地解决很多问题。每种模式在现实中都有相应的原理来与之对应,每种模式都描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案,这也是设计模式能被广泛应用的原因。
用设计模式构建一个新的软件模块时,短期会让人感觉有多此一举的味道;但中长期来看,设计模式能够克服“坏”架构的特征。学习这些模式有助于经验不足的开发人员通过一种简单快捷的方式来学习软件设计。
2.2.4 合理的横向和纵向切分
横向切分 :从硬件、驱动、组件到业务层,软件分层隔离。如数据通信:PHY/MAC/IP/TCP/应用层
纵向切分 :根据业务处理流程的环节纵向切分,不同的环节为不同的模块,不同的业务功能为不同的模块,如socket网络、GNSS卫星定位。
2.2.5 按树形结构组织
按照树形结构的方式组织软件系统,不同的大功能拆分为小功能,文件夹内套文件夹的实现形式,命名上统一,方便按功能快速找到对应的源码。
2.2.6 降低模块之间的耦合度
耦合性是一种软件度量,是指一程序中模块及模块之间信息或参数依赖的程度;内聚性是一个和耦合性相对的概念,一般而言低耦合性代表高内聚性,反之亦然。
2.2.7 降低模块与模块之间通信
一个软件内模块与模块之间的通信,构成了一个内部的通信网,避免内部模块的通信采用网状结构,这种解决方案是设计模式中的中介者模式。
2.3 重构和演进架构
架构不能一成不变,要随着业务需求的演进而升级重构,一成不变的架构是危险的,总有一天架构成为业务演进的最严重的制约因素。
这种需要实际开发中除完成既定的项目外,预留部分人力进行架构升级维护,持续小改动,不定期根据业务的需求进行架构的重构,未雨绸缪。
3 软件问题的分析与解决
嵌入式软件由于调试手段的限制、部署场景的多样化、软硬件问题混合在一起、外部环境因素的影响等因素,导致软件经常会遇到一些非常难以解决的问题。
3.1 解题思想
熟悉软件的业务流程:从业务的角度发现问题、复现问题并解决问题。
熟悉软件的总体架构:软件架构是解决难题问题的基本框架,基于软件架构解决问题不会陷入到局部细节,导致修复一个问题的同时产生新的问题,不会犯原则性、方向性错误。
熟悉软件代码的实现:熟悉代码的细节,能够更好、更快的在蛛丝马迹中找到证据和突破点,甚至在问题还没有收敛前,提供一种收敛的方向,引领问题的解决,对代码的熟悉程度直接关系到解决问题的速度。
3.2 调试手段和信息不足相关问题
3.2.1 现场偶发性、难复现性引发的问题
一些偶发性现象级问题,甚至导致系统偶发性的重启,无法复现,设备重启之后,故障消失后,再也很难复现。
1)分析日志文件
从log中寻找异常提示,是应对不可重复性、偶发性故障最基本的手段。在系统某处发生异常时,一定会在log中留下蛛丝马迹,可以请客户协助提供串口日志,在log文件中查找问题。或者设备自己内部记录log,但嵌入式设备由于存储空间的限制,可能先前过于久远的信息,就会被新的信息被覆盖,针对这种情况,就需要定期清除无效日志。有些异常会导致系统重启,而重启之后,就会导致异常信息被正常重启的信息覆盖,这就需要系统能够支持log的备份。不管怎么样,log为定位现场问题提供了最基本的、最主要的信息来源。一个完善的log机制,对于定位现场问题非常有帮助。如果不满足,可能首要任务是先完善日志功能。
2)回退软件版本,紧急消除现场问题
有些现场问题,虽然偶发事件,但发生后影响严重,客户无法接受。针对这种情况,在解决问题之前,可以先把软件降级,降级到相对稳定,没有严重故障的版本。
3)比较相邻版本之间的代码改动
如果不容易复现的故障,确认在升级了某个软件版本之后才出现的,而其他现场条件都没有变化,且分析log也无法发现异常点。此时,一种高效的解决此问题的方法,就是比较两个版本之间的代码的改动。
代码改动比较少,分析代码比较容易;如果代码改动比较多,就需要根据用户描述的现象,结合前后代码的改动模块,初步分析最可能是哪个模块引起的,这种往往需要对系统架构较深刻的理解。在众多修改模块中,分析最有可能关联的代码模块的改动,然后逐一排查 。分析代码的改动与出现的现象之间可能的关联关系,对开发人员个人的技术素养和方法论有较高的要求 。比较相邻版本之间的代码改动,针对某些棘手的现场问题,有时候确实是一个非常有效的手段。
4)问题复现
虽然常规来说现场很难复现,但可以人为的修改软件、构建或增加模拟数据,人为创造或触发条件,增加故障复现的几率。在设计触发条件时,需要围绕用户描述的现场故障现象来设计触发条件,观察是否能否复现,且表现一致。
5)分析代码
根据用户描述的现象,硬分析代码,是一种通用的方法,放之四海皆准的方法,熟悉自身代码的逻辑关系是基本功,但解决问题的效率就比较难把握了。
6)增加 log 更新版本继续测
如果常规的log无法展现故障的异常,就需要在猜测有可能的部分增加日志,在现场复测。但这种日志添加的位置是否合理,决定了问题再次出现时是否能定位问题的准确性。这种方法在工程实践中,实施难度大,需要客户多次配合。
3.2.2 现象与真正的原因不在一起的问题
大多时候解决软件故障,是可以做到头痛医头,脚痛医脚。有些时候,头痛的原因并不在“头”,而在“脚”。这就需要知道“头痛” 与 “脚” 的某种关联关系。
解决这样的问题,对技术人员的综合技能的要求非常高,因为这个问题,不再是局部问题,而是发散到调查该问题的技术人员不熟悉的其他的软件组件领域。即使对于熟悉整个系统的人而言,也是一个难点,因为问题的现象与根源之间的路径是发散的,没有一个确切的路径。
首先,必须以故障的表面现象作为锚点,作为出发点。为后续进一步的调查立一个基点。根据现象找到出问题的代码,根据代码和log分析代码的表面原因。如果确实是本处代码的问题,直接在此解决即可。即头痛医头,脚痛医脚。
很多情形下,真正的原因不在显示异常的地方,比如收到了异常的事件、或参数不合理、或自身状态机的问题等。这时候就需要追溯,为什么会有这样的事件或消息?有时候,由于复杂系统的程序员没有系统的视角,常以为消除了故障表面现象就是解决了问题。很多时候站在系统的视角,可以从多个层面加以解决,消除异常事情,可以从规则过滤模块解决,也可从前置模块或后续模块解决。具体在哪儿解决最合理,这就需要有系统和结构的视角。当然,也曾遇到有人解决类似问题是屏蔽异常消息或者屏蔽ASSERT,并没从根源去消除为什么产生了异常。
3.2.3 报错点发生在第三方库内部
软件报错的地方是在第三方库,而第三方库有没有源代码或不熟悉
如果集成的第三方库没有源代码,则把这个问题上报给第三方,让第三方给出内部出错的原因,更新库或者配合抓日志分析。如果第三方库有源代码的话,可分析第三方代码,增加日志或检查传入第三方库函数的参数是否正确,是否合法;大多数时候,是错误地传入了不合适的参数给第三方库。检查使用第三方的时序是否正确,在软件系统中,时序是一个非常重要,同样的函数,同样的代码,如果时序不对,也会导致代码逻辑紊乱。不过现在提供库或者SDK,一般都有技术支持,也可直接寻求帮助。
3.2.4 软硬件结合导致的无法定位的问题
在嵌入式系统中,有时候会出现硬件异常导致软件状态或逻辑错误,硬件人员很难根据有限的信息判断硬件到底怎么了,通常软件和硬件就会反复的踢皮球。但是用户角度看到的异常是在软件这边。
由于硬件团队对客户现场的设备,通常没有检测手段来判断是否真是硬件问题的,软件团队最好能够通过日志配置,确认硬件故障单元。或者直接将坏机寄回硬件部门,软件配合复现问题,以帮助硬件团队判断。
硬件故障问题,需要特别关注供电、时钟信号,复位时间等,曾经遇到几次因为串口漏电出去导致外部传感器复位异常的问题。总之,软硬件的交合处,是容易扯皮的地方,这需要软件人员也同时了解硬件的工作原理,在出故障时,能够更好的判断是软件异常,还是硬件真的有故障。
还有一个商业上的问题,如果客户感受到是硬件的问题,需要回收设备,会造成很大的经济损失。一般情况下是软件想办法规避异常,毕竟软件复制不需要成本。
3.3 内存与指针相关问题
3.3.1 隐性的内存泄露问题
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄露是一个严重的慢性病,不会立即展现,但不知道未来的哪一天,所有的设备,会在相近的时间点爆发问题。
内存泄漏还会导致系统意外的重启,重启的原因可能千奇百怪。因此,检测和解决内存泄漏,就显得非常重要。
1)泄漏的原因
内存泄漏主要是发生在堆内存分配方式中,即malloc方式中,申请的内存没有得到释放,或者对应的指针被被覆盖,内存直接泄漏。因为内存泄漏属于程序运行中的问题,无法通过编译识别,主要在程序运行过程中来判别和诊断。
2)动态检测或监控是否内存泄露
监控系统内存,周期性监控堆中可用内存的大小,是检测系统是否有内存泄漏的最有效的手段。系统的内存短期会随着业务数据的变化而变化,但长期来看,可用的剩余可用内存会围绕一个中轴线上下波动,如果存在内存泄漏,其剩余可用内存随随着时间的推移逐渐减少。
3)如何找到在哪儿内存泄露
可以使用工具检测代码中有没有静态的内存泄露,也可以在代码中增加标记,检测长期未释放的堆是谁申请的,在代码中查找。可以参考文章《动态内存管理及防御性编程》。
3.3.2 指针跑飞的问题
指针跑飞就是指针指向不正确的位置,指针未初始化或数组/指针越界访问,导致系统崩溃。
指针跑飞是常见的问题,问题很严重,但解决起来其实并不难,指针跑飞系统crash时,如果平台软件会打印出函数调用栈、segment fault错误、代码出错的地方、coredump文件等信息。有了这些信息,再分析源代码,其实是很容发现或找出当前代码中指针跑飞的原因的。
如果基于第三方的SDK开发,指针跑飞直接就重启,可能不会有任何提示,因此,最好能够在编码时就能够提前预防,而不是等待程序跑飞之后再定位解决 。常见的手段:
1)熟悉和遵守代码编写规范,加强代码的评审,把问题消灭在编码阶段。
2)静态检测工具对代码进行检测。
3)增加边界性测试用例,一般指针异常是在边界或异常情形下发生的。
4)增加异常场景的测试,异常场景是违反常规的测试场景,这些异常业务场景,能够尽早shi发现隐藏的问题。
3.3.3 空指针的问题
空指针是“指针跑飞”的一种特殊情况,即指针为NULL,通常出现在指针用NULL值初始化后,在某些情况下没有给指针赋值,就直接使用指针范围内存。或者接收函数返回的指针变量,忽略了函数返回NULL的情形。
在使用指针前,检查指针是否为空,如果为空,在代码中执行异常处理流程,如打印出错信息,或者ASSERT,这样就可以避免引起更严重的问题,相对来说多使用一个if即可规避。
3.3.4 栈溢出导致的问题
栈溢出时会访问不存在的RAM空间,造成代码跑飞,这时无法得到溢出时的上下文数据,也无法对后续的程序修改提供有用信息。
函数递归调用,系统要在栈中不断保存函数调用时的现场和产生的变量,如果递归调用太深,就会造成栈溢出。函数内局部数组变量的内存空间过大,或者局部数组变量的下标范围溢出,破坏了栈空间中的内容。这种问题容易解决但初始不容易查到原因。如果是带操作系统的,一般系统内核会直接提示栈空间不足,将任务栈空间加大,或者不静态分配,用malloc动态创建,从堆中分配的。平时编码中禁止使用循环递归函数。
3.4 软件时序设计相关的问题
时序问题是最容易出问题的地方,“时”代表时间顺序和时效性,一旦执行顺序错乱,或执行过慢失去时效,就会导致错误。
3.4.1 消息的串行化处理
每个任务、线程,只能按顺序的处理串行的消息,然而,其他线程发送过来的消息并不是串行发送的,不同线程都是并行、异步发送消息的,这会导致线程在没有处理完一个消息,另一个消息又回来了。如何把外部的并发消息转换成线程的串行处理呢?
每个任务、线程都应有一个消息队列,外部线程向消息队列中发送数据,目标线程从消息队列中读取消息,这样所有的消息被串行在消息队列中,线程就会串行的处理每个消息,只有当一个消息处理完(函数调用返回)时,才会处理另一个消息。
3.4.2 超时或消息丢失引发的问题
一个任务、线程给另一个任务、线程发送消息,等待对方的应答,有时候对方忙,发送时队列满发送失败,或者接收方没有处理回复,等待一段时间后空闲了才处理该消息并应答时,但对于发送方已经超时。发送方超时,就需要进入异常处理。这里容易出问题,它可能会引发一连串的异常处理反应,也有可能影响后续的正常消息的处理。
消息丢失是必须考虑情况,发送方不能假设接收方一定能够收到消息,也不能假设接收方一定能够及时的回应,必须充分考虑到消息因为传输的问题丢失或对方忙,没有及时回应的情形。
消息丢失就容易产生理论上该执行的动作没有执行,或者消息里面动态内存未释放。或者消息处理慢导致对外设的控制延迟产生异常,曾经出现共享单车锁里面的马达停止消息处理不及时导致车锁无法再次上锁。尤其处理通信时序要求严格,或外设控制要及时的场景需要注意。
3.4.3 性能本身问题
数据处理尤其是复杂算法耗时,导致消息处理不及时,最终对外设的控制或者通信交互时序状态延迟,产生异常。这种只能优化算法,或对时序部分单独特殊处理,不考虑设计模式保执行效率。或者评估阶段就选择性能资源更佳的硬件方案。
3.5 异常处理不充分问题
软件设计一般是考虑正常流程,然而实际运行中,并非是理想状态,系统总会遇到各种异常,健壮的系统,能够充分考虑到各种异常情况,一旦异常发生,程序也不会轻易崩溃。
超时:增加超时定时器事件以及事件处理,不能假设对方一定应答消息。
空指针:不能假设一定能够申请到内存,要考虑到返回为NULL的情形,通过指针访问内存对象时需要及时的检查指针是否为空。
并发访问:在并发执行的系统中,如果要访问全局变量,不能假设只有一个线程访问全局变量,需要通过锁对全局共享资源进行加锁,特别是要访问全局的数据结构。
消息队列:不能假设消息队列始终有效,要考虑消息队列满或空的情形。
设计:在软件设计时就考虑软件的异常处理机制,功能层面就支持异常记录、售后调试的需求,而不是把这个工作留给编程人员。
4 非技术性问题
大规模系统中软件会分割成无数个模块,负责这些的开发人员来自不同的职能部门,这些部门又有着各自不同优先级的任务,而解决那些复杂的问题又需要这些部门的配合,才能找到真正的出错原因和找到最终的解决方案。或许最终解决问题的代码仅仅在一个组件中,或许只有简单的一两行代码,然而找到问题的原因并给定解决方案的过程却是漫长、繁琐的。
4.1 人员分配
在大规模分工的系统中,每个技术领域都是一群人负责,解决问题效率与个人的技术能力有极大的关系。有些简单技术问题,如果参与的不是合适的技术人员,例如人员的变动导致原来的软件人员不再负责该软件,那简单的问题也变得复杂。正所谓“难者不会,会者不难”。技术是长期积累的过程,同一类问题不要频繁的更换人员。
4.2 任务分配
复杂系统中,一个问题需要涉及到多个部门,一个程序员在某个环节上的放缓,导致基于这个前置条件的进度也跟着放缓,整个组织的执行就会极大的降低,从宏观上看,整个组织都很忙,但总体的效率却很低。
按优先级排序分配任务,在一个高优先任务完成前,不要处理其他任务,降低程序员并发处理故障的个数,否则“任务”切换的开销就很大。高难度与低难度任务搭配,同时处理多个高难度问题,整体效率是下降的,而且一个延期会连锁导致后续都延期。
4.3 项目分配
实现一个复杂功能,或者解决某个问题,往往可以在不同的子系统解决,各个子系统出于各自原因,都希望最终的解决方案不要放到自己的子系统,希望推到其他子系统。此时,一般是地位低的子系统人员妥协,即使其子系统的实现不是最佳方案,这种就看项目分配,需要具有一定技术背景和决策权的人协调。
5 结语
关于软件问题的分析理论,也只是拾人牙慧。理论和实际存在较大差异,真正遇到软件问题,这些方法并不一定靠谱。