嵌入式系统软件开发实用指南by海方

(一)关于日志

嵌入式系统开发过程中,很多场景下需要保留一些日志,以方便排查问题。但由于嵌入式系统一般情况下内存容量有限,所以能够保存的日志的数量也是有限的,不能什么日志都保存。所以最好提前设定一些标准,哪些日志要保存,哪些不保存。这里列出一些标准作为参考:

1. 对于正常流程,打印从硬件读取的原始数据和一些关键流程节点的信息即可。

2. 对于异常流程,由于不是一直会打印的,只会在出错的时候打印,不必担心占用很多内存,所以可以打印的详细一些。从出错的最底层函数开始,打印出函数调用的序列,这样可以更好的排查问题。基本上所有return false的地方最好都打印出来。这样可以更快的排查问题。

3. 对于不同的模块,设置它的关键词,该模块打印的每条语句都包含该关键词。比如时钟模块相关的打印都包含Clock这个单词。这样做的好处是在分析日志的时候,过滤Clock这个单词,就可以快速的找到所有与时钟有关的打印信息,不用看其它的无关信息。

4. 日志要精确的反应问题,描述要准确明确精确。维护人员看到日志就可以直接作出问题原因的判断,而不是还需要另外进行推导才能得出结论。日志还要携带相应的原始硬件数据。

(二)关于代码架构

建议代码按照模块划分类和文件。一个模块相关的所有代码放在一个文件中,文件以模块名来命名。文件内部再适当划分类。好处是所有该模块相关的流程和处理都可以在这个文件中找到,其他文件中都是调用该文件中的接口。这个类似于Linux内核中的文件组织方式。每个模块都是一个相对独立的单元。哪里用到该模块了,只需要将该文件加入项目,然后调用接口即可。方便移植和使用。

模块的划分最好以功能为依据,不要以其所运行的器件为依据。例如嵌入式系统中经常会使用FPGA这种芯片。但是千万不要因为某些功能是在FPGA上实现的,就全部放到一个名字类似FPGA.cpp的文件中。这不是按照模块划分。正确的做法是,FPGA.cpp文件中只包含读写FPGA寄存器的接口即可。如果有功能A和B,都是涉及读写FPGA寄存器的,那么建立文件A.cpp和B.cpp,分别存放功能A和B相关的代码。其中会调用FPGA.cpp中实现的FPGA寄存器读写接口。这样A模块只在A.cpp中实现。B模块只在B.cpp中实现。如果某一天不用FPGA实现A和B功能了,只需要把A.cpp和B.cpp中调用FPGA读写的接口替换成另一种器件的接口即可。这对于代码的移植和兼容性设计是有利的。

(三)关于分支管理

在实际的嵌入式系统项目开发过程中,还要面对的一个问题是分支的管理。理想的状态应该是有且仅有一个分支。但实际的操作中由于各种原因很难做到这种理想的状态。有时需要临时开发一个功能,或者对一个已有的功能做某种特殊处理,而这种特殊处理与已有的项目需求是不能兼容的,就需要临时拉一个分支,以避免代码修改影响已有项目。但总的来说,这种临时的分支应该只是临时的,一段时间后,还是要想办法把这期间的修改合并回之前的主分支。以确保长期来看还是只有一个分支。

要想实现只维护一个分支,就得保证能够用一个分支支持所有的产品类型和同一产品类型的所有项目。而要做到这一点,首先要满足上文中提到的“模块化”设计模式。然后还需要增加一些标识来区分不同的产品类别和不同的项目类别,根据实际情况可能还需要增加其他的一些区分维度。在某个模块内部,如果是通用代码逻辑,直接写即可。如果某部分逻辑针对不同产品不同项目的实现是不同的,则使用上述区分标识来区分即可。然后在模块的调用处,添加产品类别和项目类别等的判断,如果某产品某项目支持该模块,则调用之,否则,不做调用。通过这种设计,基本可以实现同一个分支的代码支持所有产品类别的所有项目。当然,还可以利用C/C++中的预处理编译宏,来把某些项目或产品不需要的代码屏蔽掉,不编译进镜像。类似于Linux内核中的做法。以上方法综合使用即可满足要求。

另外还可能涉及内核或某些库需要支持不同版本的问题,也可以使用预编译宏加以区分。例如支持Linux内核的不同版本,支持某个第三方库的不同版本等。

(四)关于层次化设计

我们的代码最好设计为层次化的。层次之间相互独立,这样做的好处是可以方便进行替换,当底层代码的实现变化时,只要保持两层之间的接口不变,则上层的代码就可以不用改动,实现自然的衔接。

例如我们在嵌入式开发中,经常会涉及到操作硬件寄存器,我们可以设计一个独立的硬件适配层,用于封装读写硬件寄存器的操作。然后向上层提供统一的接口。上层代码只需调用这些统一接口即可。当后面硬件发生变化时,只需要修改硬件适配层的代码实现即可,上层的代码完全不用改动。这样上层的代码的可复用性就会大大提高。

我们还可以设计多个层次的代码结构,每一层都向上层提供进一步的封装。进一步提高代码的灵活性和可复用性。

当然层次也不宜过多,否则可能会降低代码可读性。具体分几层要考虑具体需求的复杂度和特性。

(五)关于完整的开发过程

 开发绝不仅仅是写几个函数就万事大吉了的。一个完整的开发过程还有其他几个步骤要做。

      日常的开发过程一般是从拿到需求开始的。拿到需求后,我们先要分析需求,然后将需求转化为设计方案文档。

有了设计方案文档,我们就开始进行编写代码的工作。

        功能代码写完,还要写单元测试代码,并进行单元测试。同步的还要编写测试用例,测试用例是告诉维护代码的人想要测试这部分代码需要进行哪些操作,预期会得到哪些结果。

        到此为止只是对这个功能进行了单元测试而已。还需要将模块整合到系统中,进行系统级的联调测试,同样的也需要写测试用例。嵌入式系统往往由多个模块组成,可能你的模块单独测试时没有问题,但是放到系统中测试就会发现问题了,这些问题往往涉及模块间的接口,协议等等。

        整理一下整个的过程,包含几个部分:

需求

设计方案

编码

单元测试编码

单元测试测试用例

系统测试用例

(六)关于表驱动法

有些时候,比起使用条件分支,使用表能够简化代码流程,减少出错的可能性以后后续维护的难度。

假设我们的需求是根据两个变量A和B,计算出结果C。其中变量A可能的取值为0,1,变量B可能的取值为0,1,2. A,B和C的关系如下表所示:

A
B
C
0
0
3
0
1
5
0
2
2
1
0
7
1
1
8
1
2
9
如果使用if语句,代码看起来会是类似下面的样子:

if(0 == A && 1 == B)

C = 3;

else if(0 == A && 1 == B)

C = 5;

else if ......

后面的逻辑类似。

这样写当然也可以实现这个需求,但这种方式的缺点是很明显的。首先,需要给每一种可能性都编写一个分支,如果可能性很多的话,这组分支语句将会很长,我们说过,函数要短小简单,更长的函数更容易出错。其次,后面如果需求变了,要求增加或减少可能性,那就要修改这个函数,这也是不好的方式,已经测试过的函数被再次修改,容易引入新的错误,编写代码时最好进行扩展,不要进行修改。

我们看这个需求,它的特点是数据组合的个数是有限的而且内容是很规则的。这是就比较适合采用表的方式来写代码。

我们可以定义一个二维数组:

int buf[2][3] =

{
    {3, 5, 2},

    {7, 8, 9}

};

当A为0时,对应的就是二维数组的第一行数据,A为1时对应第二行数据。

然后再将B值作为这个一维数组的索引,就可以得到相对应的C值了。

我们可以简单的用

C = buf[A][B];

就可以直接得到C值。

把它封装成一个函数:

int getC(int A, int B)

{
    return buf[A][B];

}

对比上面的用if语句实现的方式,我们看到函数大大的简化了。

当然实际情况肯定比这个例子要复杂,但基本上可以用for循环来遍历这个数组来拿到结果值。总的来说还是比if分支的方式更简单的。

而且当需求变化时,比如B的值还可以取值为3,则只需要在数组中再增加一行即可。如果我们把代表数组大小的2和3都改用宏来定义,则只需要再改下这个宏的值即可。getC这个函数却完全不用修改。

小结:

由上面的说明我们看到,当需求满足特定的条件时,使用表驱动开发方法能够带来很多好处。所以在开发过程中要学会分析需求,如果可以使用表驱动法,建议尽量采用,可以大大简化代码复杂度,减少出错的可能。

(七)关于是否独立成一行

有时候if语句的分支里可能只有一个语句。

举个例子,我们要写一个if语句,可以有下面三种写法:

写法1:

if(expression) 语句;

写法2:

if(expression)

  语句;

写法3:

if(expression){
  语句;

}

写法4:

if(expression)

{
  语句;

}

个人建议采用写法4更好一些。

写法1不方便阅读代码,尤其是当括号中的表达式比较长时,你甚至很难找到后面那个语句从哪里开始的,不注意的话甚至看不到它,而把下一行的其他语句看作是if分支执行的语句。

写法2虽然把语句单独放到下一行了,但和后面的语句的区分也不明显。并且如果后面需求改了,需要在这个分支里加一句的话,可能会直接加到这个语句的下一行而忘记加花括号,从而引入逻辑错误,因为新加的那句无论if条件是否成立都会被执行。

写法3稍好,把语句放到下一行且加了花括号括住了。但左花括号和if在同一行,这个也不推荐。因为这样导致左右花括号不在一条竖线上,不方便查看花括号是否正确的匹配了。

写法4虽然多了一行,但花括号很容易判断是否匹配对齐使用,更容易查看代码。有些人可能觉得这样做多了一行没什么意义的无效行,当我之前也提过了,代码是写给人看的,是写给后面维护的人看的。所以如果哪一种方式更利于阅读,更有助于分析,那当然应该采用这种方式了。至于多一行无效行,我觉得倒不是什么大问题。

(八)关于代码规模

 在代码设计中,应该尽量保持函数的短小和简单,避免一个函数做的事情太多,逻辑过于复杂。

        先说函数的长度。一个函数如果做的事情很多,代码就会很长,带来的问题就是不容易发现bug。试想一下,如果一个函数只有10行代码,那么如果其中包含错误的话,是很容易发现的。如果一个函数有几百行代码,要在里面找出错误会是一件很崩溃的事情。所以,应该尽量把函数的长度减小。如果一个函数做了5件事情,那么应该把它拆分成5个函数,每个函数分别完成一件事。这样不仅便于发现问题,也方便对函数进行命名。如果我们现在要为一个业务编写函数,该业务流程包含3个步骤,那么更好的方式是先分别写3个函数,每个函数对应于一个步骤,然后写一个总的函数,在该函数中调用那3个标识每个步骤的函数。而不是把所有的东西统统塞到一个函数里。

        再说函数内的逻辑。函数内的代码逻辑也要尽量简单。和短函数类似,逻辑简单的函数也更有利于发现错误。所以,如果一个函数的代码逻辑很复杂,就要考虑能否把它拆分成几个不同的函数,拆分后的每一个函数的代码逻辑是简单的。

        保持函数简单的另一个方面是尽量使用简单通用的语法,不要写“炫技”的语法。很多编程语言除了基础语法以外,还会提供很多技巧性语法,有些程序员就会喜欢使用这些技巧行的语法,看似很高明,其实对于整个项目来说,是弊大于利的。为什么这么说呢?首先这些技巧性的语法很可能是不通用的,可能到了语言的新版本就不支持了,或者换了一个应用场合就不支持了。就是它们的通用性不够好。其次也不便于理解。有些技巧的用法可能是比较生僻的,这样别人在读这段代码的时候可能会不认识这种语法,还要先去查,不便于理解。从另一个角度说,现在的软件系统都是很庞大很复杂的,如果还要求别人每次看到这种“炫技”的代码都要花时间去查找才能理解你的代码的意思的话,那么整体上的效率就会降低很多。所以请尽量使用通用的语法来实现功能,不要“炫技”!

        代码是给人看的,是给后面来维护这段代码的人看的。所以请尽量让代码写的简单,简单的代码更容易发现错误,无论是对于代码审查还是对调试时排查错误,简单的代码显然更有优势。

(九)关于版本管理

在第一个笔记里,我们不讨论编码,也不讨论任何技术问题,而是首先讨论版本管理,是因为这个问题非常重要。

      在学校里开发程序或者自己写程序,和在企业里开发相比,一个最重要的不同就是,企业开发需要有完善的版本管理,否则一定会乱掉。

        通常,代码开发完毕后(包含编码,调试,集成测试等),会通过版本管理工具上传到存储代码的服务器上,并打一个标签,标识本次的版本号。之后会把这个打了版本标签的代码提交给系统进行集成测试。如果测试中发现问题需要修改代码的话,会在下一个版本中进行修改,版本号也会随之变化。

        系统在集成各个专业的版本时会收集专业的版本号。在测试中发现问题时需要上报缺陷,此时就可以根据专业的版本号追踪到专业的代码版本,专业就可以在相应的版本上排查和定位问题了。如果专业的版本号没有管理好,那么排查问题时就不知道究竟要在哪个版本的代码里排查了。

         一般常用的代码版本管理工具有git和SubVersion。可以自行从网上下载和学习使用。

        小结:在开发中首先要做好代码的版本管理,以便追溯和排查问题。

(十)关于几个开发小技巧

1. 新开发模块尽量多加调试打印信息

        为了方便调试,对于新开发的代码,要尽量多加一些调试打印信息。对于每一个条件分支,都加一条打印信息。对于每一个return语句返回的地方,都加一句打印信息。等功能调试完毕确认没有问题后,可以去掉部分打印,只保留最重要的一些打印。比如返回false的分支要保留,因为它表明这个函数执行出错了。一些业务流程中的关键步骤的打印也要保留,方便在运行过程中确认这些关键步骤确实执行到了。

2. 每个模块的打印信息添加统一的字符串标识

        对于某一个模块,在打印信息中都加入一个统一的标识字符串,这样在查看日志信息的时候,通过搜索这个统一的标识字符串,就可以一次性过滤出所有的相关打印了。举个例子,比如我们现在要开发一个LED点灯的模块,那么我们在这个模块里的所有打印中都加入LED这个字符串,那么代码运行起来后,只需在日志中搜索LED,即可过滤出所有我们想要的信息了。

3. 代码设计时就要考虑单元测试的问题

        编写完成一个函数后,要对它进行单元测试。在整个开发过程中,越是在早期进行测试,发现bug的难度越小,越是在晚期进行测试,发现bug的难度越大。所以千万不要等到系统集成测试的时候才去尝试定位问题,单元测试才是发现问题的最好时机。

        对于嵌入式系统开发来说,单元测试可能面临额外的问题,就是有些函数并不适合做单元测试,因为它们可能涉及操作硬件寄存器,或者涉及网络协议。

        所以我们在代码设计的时候,就要考虑这个问题。一种可行的方式是要尽量把函数拆分成不同的类别,第一类函数只涉及操作硬件寄存器,第二类只涉及协议,第三类则与硬件无关且与协议无关。

        第三类函数可以进行完全的单元测试,它们甚至可以不用下载到实际的硬件上,而只需在开发环境就可以进行充分的测试了。

        对于第一类函数,基本上只是读或写寄存器,逻辑很简单,基本不需要做单元测试。

        对于第二类函数,也可以针对协议本身进行完整的测试,可以在开发环境进行测试。

(十一)关于软硬分离

嵌入式系统的软件可以分割成与硬件有关的部分和与硬件无关的部分。与硬件有关的部分指操作硬件资源的代码,如读写寄存器,读写硬件接口等。与硬件无关的部分指软件流程部分以及操作通用硬件资源的部分,如读写OS文件。设计软件时建议将这两部分分离开。与硬件有关的代码放在单独的函数中。好处是有利于单元测试。与硬件无关的部分可以进行充分的单元测试i,不需要运行在真实的目标硬件环境上即可运行单元测试,如运行在Linux虚拟机或Windows上。实际开发过程中,可以先对这部分代码进行充分的单元测试,之后再在实际目标硬件环境中测试含硬件相关代码的全部代码。

对于与硬件相关的代码/函数,要时刻牢记读写硬件可能失败,读到的数据可能是错误的,有时候还可能会超时。所以一定要考虑增加异常处理逻辑代码,例如数据范围的校验等。

(十二)关于代码重构

首先,如果你能够按照上面的原则设计代码,应该就不需要做大的代码重构了,顶多需要做一些函数级别的代码重构。总体来说,要遵守的一个原则是:非必要不做代码重构!尤其对于已经商用了的代码,已经经过开发者测试和测试部门的完整测试,即便代码看起来比较丑,如果不是有bug,也不要轻易重构,因为一旦改了代码,相应的测试都要重新进行一遍,哪怕你只是改了一两条语句。

如果逼不得已一定要重构,也不要鲁莽行事,一下子改太多代码,尤其是把很大一部分代码逻辑完全改的面目全非,这样会不方便与原代码进行比较,以判断修改前后究竟有多大差异。建议还是分小步骤,每次只改比较少的部分,最好只针对某一个类或模块做改动。修改后,还要进行必要的测试,包含针对修改部分的测试,已经整个流程的一些关键功能点的测试,以免改动可能会影响到其他模块。

总结起来有3点,一是非必要不重构,二是重构要分步骤小改动进行,三是重构后进行必要的测试。

(十三)关于过度设计

有的人很喜欢用各种巧妙的设计模式或者代码技巧,不是不可以,但最好适度。以为这些设计模式都有其适用范围和应用场景,可能不是通用的。一旦涉及到新增需求和原有需求差异交代,可能改起来会很痛苦。扩展性不好。有时候用一些简单但是通用的语法要好过用那些精巧但是通用性差的设计模式!


------------------------------------------------------------

未完待续~~~

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值