论嵌入式单片机软件架构

文章目录

  • 流水式
  • 中断前后台式
  • 任务式
  • 状态机
  • 模块、分层与封装
    • 模块
    • 分层
    • 封装
    • 架构示例
  • uCOS-II操作系统多任务
  • 编程规范

这注定是一篇耗时很长的文章,做个标记,文章始于2019年1月8日,不知何时完结。
为什么会想到写这篇文章呢br> 因为作为一个从单片机汇编语言开始,到单片机C编程、再到ARM实时操作系统、一步一步摸爬滚打走到Linux世界的嵌入式软件工程师,也有十二年了;而今主要在Linux环境下写嵌入式软件,我不想把单片机领域积累的知识就此慢慢遗忘,遂决定把我以往的知识做一下总结,提炼到一篇文章当中,这不是一件容易的事;写到这里,以下都不知从何写起,待我构思几天吧…

经过几天的回忆与思考,分析了我的成长路径,选择一些我自认为是成长关键点的位置,总结一个关键字,一步一步来讨论关于嵌入式单片机软件的架构。
我先把总结出的关键字写出来吧:流水式、中断前后台、任务式、状态机、模块、分层、封装,操作系统多任务;这些就是我总结出的关键字,写这篇博客我决定不参照其他博客,只把我心中所想以及以前的代码示例或者记录翻出来,再加修改,然后呈现出来,供大家品鉴。

下面就从我的第一份工作说起吧。

流水式

我的第一份工作是从PIC单片机开始的,使用汇编语言;当时刚刚毕业,在学校学的是51单片机,毕业设计使用51单片机做一个飞利浦的RC50013.56MHz的读卡器,那时对编程还处于朦胧状态,不存在架构的思想,就是按照功能一步一步的写。

所谓流水式,就是按照工作的流程一步一步实现,直到完成流程内的所有事情,最后再回到起始,从头开始。我用这样的思想写的第一份代码,是控制一个双向交流电机平移门,平移门就是开门与关门。流程如下:

  • 任务的确定:一个框架到底要划分出哪些任务呢我们要从宏观上对嵌入式系统的整体功能有明确的了解,弄清楚业务逻辑,然后根据功能与业务逻辑抽象出具体的任务、最后设计任务之间的数据交互的接口,基本就设计好一个框架了。
  • 任务职责的划分:每个任务要做的事情必须分清楚,不能少做,也不能多做,任务与任务之间的的接口最好只是数据的交换,好的任务划分使任务要完成的事情是单一的、明确的、逻辑清晰的,这样可以降低任务之间的耦合性,在功能扩展与问题定位时会很明确。
  • 数据接口的定义:确定好任务之后,最重要的就是数据接口的定义了,这实际上涉及到具体的应用细节了,不过我们可以采用一点面向对象的思想,将数据进行封装,对每一件事物设置一个数据结构;明确各任务之间数据交互的对象与规则,例如谁生成数据、谁使用数据。
  • 框架之下是细节:对应具体的任务,要具体的分析;具体设计某一个任务怎么实现,我们需要抓住这几点:完成任务需要的资源、任务由谁触发、任务执行的条件、任务的输入是什么、任务的输出是什么、谁来接收任务的输出;抓住这几点任务实现就已经很明确了。

在以上示例图中需要说明的是,有些情况下,可以不设计单独的数据存储任务,而是提供简单的存储器的读写函数即可;但是当涉及的存储器操作比较频繁,且类似于SPI或IIC接口的Flash时,因为写操作会耗时,因此最好不要再写函数中使用delay方式,进行硬延时;设计一个存储器管理任务,统一管理数据的读写,在任务内部完成读写操作,而向外部提供读写操作的数据接口与触发条件。

关于段式液晶的显示,我不得不说一说我的一个心得。

段式液晶显示一般都是一个液晶驱动芯片,来驱动4(COM)*32(SEG)=128段液晶显示,芯片内部有一个RAM区,来对应128的显示与否。
那么这个显示任务应该怎么实现呢r> 首先关于芯片的寄存器读写就不说了,我们主要关注实现显示任务的环节。
根据以上示意图可以看出,需要从显示数据缓冲区中取出数据然后显示,但是基本上显示的数据内容并不能完美的匹配到液晶的显示段上,总会存在差别;那么我们怎样由要显示的数据内容,转换到液晶显示段呢是增加一个显示数据内容到显示段的映射过程。那么整个显示任务可以用一下几步完成:

  • 显示任务从数据缓冲区中取出数据
  • 映射过程将数据内容映射到映射数据缓冲区
  • 由芯片读写函数将映射数据缓冲区数据写入驱动芯片。

那么当一些显示段需要闪烁怎么办呢r> 其实液晶显示任务,不必要实时向芯片写入显示内容,只需要每250ms或者500ms,向芯片写入一次数据即可,这样我们可以对需要闪烁的显示段做标记,然后按位取反即可完成闪烁的目的。
其实我还实现了没有液晶驱动芯片,全靠代码实现4(COM)*32(SEG)=128段液晶显示需要的驱动波形输出,只不过这个代码随着我的电脑被偷而消失了,这个代码我当时完成后,成就感可是很高的啊。

这里说的任务式编程框架,还没有对任务的实现方法做具体的讨论,简单的任务可以将代码堆砌在一个函数中完成,那么复杂的任务怎么办呢节我将说一说任务实现的思想:状态机。

状态机

状态机,我还是在学数字电子的时候,设计过一个自动售货机的状态机实现,那时觉得特了不起;后来在编程的世界里,又一次让它武装了我的编程思想。
什么是状态机呢r> 用数字电子的术语来说就是:有限状态的无限循环,在代码世界我们可以说,完成一个任务就是在不同状态之间切换,每个状态做一点简单的事情,多个状态联合完成一件复杂的事情。

这里我要说到一个思想:分解,所谓分解就是将复杂的事情简单化,将一个复杂的任务用递归法一步一步分解,直到不能再分解为止,由每一小步完成一点事情,最终合起来完成这个复杂的任务。

说起分解,其实在任务式编程中,就已经用到了分解的思想,只不过不够细致;细致的分解需要我们做到,首先从宏观上理解系统的功能,然后划分宏观的任务;然后在每一个任务上在细分子任务、子子任务、直到任务的每一处细节,最后设计数据结构与变量,将任务的细节串联起来;这就叫所谓的面向过程编程吧。

再说任务被我们分解之后,我们怎样表示这些被分解的步骤呢:状态
我称每一个被分解出的步骤叫做:状态,同一步骤下再细分的步骤称为:子状态;所有这些状态的联合,称为状态机。

设计状态机的要点:

  • 状态机的状态始终是有限个数的。
  • 状态机的状态是收敛的,即状态永远是可以循环的,不存在未知的状态
  • 状态机内每一个状态的进入与退出条件是明确的,不能存在状态内的死循环
  • 状态机内的每一个状态都必须存在上一状态与下一状态,可以有多个上下状态
  • 单一状态内,完成的事情不能过多,耗时很长;否则就要将此状态继续分解为多个子状态

使用状态机编程后,代码的特点

  • 状态机编程,基本上在程序的主循环将看不到任何硬延时,到这个阶段我们也不允许代码中出现任何硬延时
  • 由各种状态以及子状态编写出每一个函数基本不会超过200行;我们可以此为标准,当一个函数的代码需要超过20行时,会增加阅读的难度,可以增加子状态,在子状态中分步完成。

我们以GPRS模块的管理为例来说明一下状态机编程吧!
管理一个GPRS模块需要完成以下事情:

  • GPRS模块的开机
  • SIM卡识别
  • 络注册
  • 建立 络连接
  • 络数据收发
  • 短信的收发
  • GPRS模块关机

实际上以上列出步骤,就可以作为GPRS模块管理的基本状态,具体到每一个状态下,再细分到每一个条AT指令作为一个步骤,这样我们就可以在GPRS模块管理任务中无任何延时等待的情况下完成。
状态机中的延时等待如何完成:这需要借助定时中断与状态机数据结构,在定时中断里完成计时操作,状态自行检查是否计时完毕。

不得不说一下这个定时中断,定时中断不能做太多的事情,而是在定时中断里设置一个标志位,表明发生了定时中断,在程序的主循环设置一个定时中断任务,检测这个定时中断标志位,确定是否执行定时中断任务。到这里,我们发现,其实中断前后台、任务式、状态机,这几种编程方式在这里就都用到了。

最后要说的一点是,再借助一点模块与封装的思想,设计一个数据收发与短信接口和相关的数据接口与变量,将其封装为一个接口,使用信 通知方式,外部任务就可以很好的通过这个接口完成数据与短信的收发,而不用关心其细节。

模块、分层与封装

随着嵌入式系统功能越多,平台体系越来越复杂,代码文件也越来越多;我们不仅需要好的编程方法,也需要好好管理我们的代码文件;我们还需要借助一点面向对象的编程思想,来助力我们设计更好的框架。那么模块、分层、封装这三点就可以很好的帮助我们。

模块

什么是模块呢候模块与任务很难分清楚。

  • 从代码层面来说,当完成一件事情只需要一个任务时,那么模块与任务就没有多大区别,可以称为单任务的模块;当完成一件事情需要多个任务时,我称这件事情为一个模块,也称为多任务模块,所以说模块处于任务的上一层,在宏观一点的层面上表述一个功能,也叫作功能模块。
  • 从文件结构组织上说,一般将这个功能模块所包含的代码文件组织在一起,放在一个文件夹下方便管理,使得文件结构清晰,易于维护。

分层

这个概念我想大家都很熟悉,最常见的就是ISO 络7层结构,以及经典的5层TCP/IP架构了。可是为什么要分层,怎样分层,分层可以为我们带来什么好处呢p>

  • 为什么要分层:宏观上讲,分层可以让代码的业务逻辑更加清晰,每一层专注于自己事情,有利于任务的分解,设计出更好的框架
  • 怎样分层:对于分层我们怎么确定要分哪些层呢分层,就是我们在对任务进行划分并分解时,可以看看这些任务中有没有一些共性或者相同的操作,我们把这些相同的地方提取出来,单独作为一个模块,让这个模块为那些任务提供操作支持,那么这个模块在经过封装后,就可以作为一个层次存在;所以层次就是经过封装的模块;最简单的嵌入式系统可以分为:驱动层、应用层。
  • 分层的优点:分层是一种任务的抽象,它可以将众多任务中相同的功能代码集中在一起,单独作为一个抽象层,为其它任务提供服务,这样可以减少重复性的代码,提高代码的利用率;另外分层使逻辑层次更加清晰,问题的定位、功能的扩展与维护更加容易

其实分层之后,我们更倾向于把每一层叫做一个模块,所谓模块化编程,就是靠分层实现的。

封装

什么是封装呢我们说一个层次,必然有它的上一层与下一层,那么它怎样与上一层和下一层打交道呢层次完成一些事情,它需要什么资源、触发条件是什么,什么时候完成,完成之后输出结果给谁p>

对于每一层,可以这样总结,它对下一层提出请求(request),下一层对上接收请求(request),处理(process)这个请求要完成事情,然后对上层给出一个应答(reponse),

这样我们对每一层要做的事情可以总结为:请求(request)——>处理(process)——>应答(reponse)

那么我们怎样完成每一层之间的衔接与交互呢,封装便出现了。我们将每一层完成各种任务所需要的资源、触发条件、输出结果等,抽象成各种不同的数据结构,用于不同层次之间的交互,层次对外提供这些可支持的数据接口,这就是封装了,封装可以让我们屏蔽每一层的细节,减小任务之间的耦合性。

架构示例

就以我比较熟悉的ZigBee 络为例,来说说模块、分层与封装吧。以下就是一个ZigBee节点的 络框架图,看似简单的4层结构,实际就是对ZigBee 络规范的提炼。

到这里,在我的认知上,中断、任务、状态机、模块、分层、封装,可能就是无操作系统的嵌入式编程终极大法了;关键就要看怎么使用这些方法了。
下面就要关注,在带有操作系统的应用环境中怎么构建嵌入式软件框架了,我比较熟悉的是UCOS-II,所以就以它来讨论吧。

uCOS-II操作系统多任务

到此我们终于到操作系统了,其实我们前面总结的任务、状态机、模块、分层、封装,它们已经可以组合成一个简单的多任务操作系统了,因为它们之间也有任务的调度、任务之间的同步、任务间的通信、信 传递、互斥锁,消息队列等等,但不能称之为实时操作系统。
那么什么是实时操作系统能r> 所谓实时系统,就是对执行的事件必须在规定的时间内完成,超出时间限制则结果就无效了。

但其实在一般的嵌入式系统中,没有这么硬性实时性要求,我们更关注的是嵌入式操作系统其它特性,以及怎样利用操作系统为我们提供的各种功能,设计更好的框架,但是框架的设计思想,不外乎就是以上列出的任务、状态机、模块、分层、封装这几点了。
那么uCOS-II都有哪些特性呢p>

  • 任务调度方式:我们要明白一个操作提供的任务调度方式,是抢占式还是非抢占式,uCOS-II属于抢占式调度,高优先级任务可以打断低优先级任务,比如在执行一些延时、信 传递、中断发生时就会发生任务调度;其实无操作系统的多任务,其任务调度可以说是非抢占式,只能有任务自己主动退出,其它任务才能执行
  • 任务之间的同步:几乎所有的操作系统都需要通过互斥锁、信 量实现任务的同步,我们主要注意任务之间不能形成互锁,造成死锁;其实无操作系统的多任务,也需要我们自行设计一些互斥锁、信 之类的变量来完成任务之间的同步。
  • 任务之间的通信:uCOS-II主要依靠消息邮箱、消息队列实现任务之间的通信;除此之外我们还可以自行设计一些全局变量,利用uCOS-II提供的信 完成任务之间的通信,其实这就是无操作系统的办法,只不过uCOS-II在传递信 时,就可以发生任务调度,而后者只能等待任务退出后,轮询到信 所传递到的目标任务
  • 任务优先级:uCOS-II支持任务预先设计优先级,并且高优先级任务可以打断低优先级任务,任务的优先级还可以临时性的修改,而无操作系统的就没有任务优先级一说,每个任务只能等待轮询到之后才执行,因此我们可以利用系统提供的功能设计出实时性更好的框架
  • 系统消耗的资源:uCOS-II系统支持每个任务单独设立私有的栈,这时我们就要考虑,整系统硬件的资源,主要是RAM空间资源,为每个任务分配多大的栈stack空间才能保证任务栈不会溢出,预留多大空间作为堆heap空间,全局变量占用多大空间;当我们在程序中不使用动态空间分配函数时,可以通过链接文件将heap空间设置为0,而无操作系统的方式,除了因程序嵌套调用需要使用一部分栈stack空间外,剩余的则都是全局变量和heap空间,同样我们可以通过链接文件将heap空间设置为0。
  • 系统的移植:要想移植一个uCOS-II系统,那么必须对目标芯片的建构由清晰和深刻的理解,至少要明白CPU的架构、中断向量的分配、CPU寄存器的用法,以及基本的汇编知识,本篇文章不是介绍操作系统怎么移植的,所以就不做介绍了。

从以上可以看出,中断、任务、状态机、模块、分层、封装,已经有了绝大部分操作系统所有的功能,只不过特性不一样,操作系统也并没为我们设计软件框架,它只是在更高层次将我们需要用到的中断、任务调度、任务同步、任务通信、资源访问等进行了抽象,写成一个系统,供我们使用,我们利用系统提供的功能更加方便,但仍然要使用状态机、模块、分层、封装设计思想,设计嵌入式软件架构。

随着工作年限的增长,渐渐形成一套我自己的做事方法,就嵌入式软件来说,不管遇到的是简单的系统还是复杂的系统,要完成这件事情,总是存在两个方面:宏观与细节
宏观上,我要对系统有个整体的把控,理清系统的方方面面,这样才能更好的划分整个系统,设计它的任务、层次、模块;这个过程就是框架的设计过程,这个过程不可能一次完成,中间必然要经历多次修改,所谓宏观就是框架
细节上,当我们设计好一个框架之后,就要设计怎么实现框架所描述的内容了,比如任务实现的步骤、数据结构、变量,接口等,在细节实现上,可能会发现框架设计的不合理之处,这时反过来就要去修改框架,如此反复几次,框架与细节才能更好的吻合。

用一句话总结:自顶向下逐层设计宏观框架,然后自底向上逐层设计细节实现,将宏观与细节完美吻合,这样我们才能对系统了如指掌。

编程规范

在框架之外,我还想说简单一下嵌入式编程一些规范上的事情,内容我也说不出来很多,只是写出我自己的一点心得。

  • 文件组织:文件组织按照功能模块,放在不同的文件夹,文件夹的名称要与文件夹内容符合,多人协作开发时,可按功能模块各自维护自己的文件夹
  • 文件包含:我们在定义与放置头文件时有两种方式,1、将所有公用头文件放置在一个文件夹内 2、按照功能模块将属于此模块头文件与其代码文件放置在同一文件夹内;另外在头文件设计上我们还要避免交叉包含,如果实在避免不了,说明功能模块划分不够彻底,还要细分。
  • 命名规范:命名规范说起来也是一门学问,涉及到文件名、函数名、变量名、其它类似宏定义名等;总的来说是采用windows的驼峰方式,还是Linux的小写加下划线方式,这个各有所好,说不好孰优孰虑;我们更应该关注的是名称本身。好的名称必然是见名知意的,不要让阅读者需要根据上下文来猜测用途,见名知意的命名方式会让我们的后续编码、维护大为受益
  • 语句表达式:不要写逻辑复杂、不易理解的表达式,这样的代码可阅读性差,从代码执行角度说,逻辑复杂的表达式,执行效率也不会很高,我们应该做到,将复杂的事情简单化
  • 代码行数:单个函数的代码行数尽量不要超过200行,也就是两屏所能包含的行数,单个函数代码量行数越多,要完成的事情越多,逻辑越复杂,出错的可能性越大,说明任务的分解不够完善
  • 函数扁平化:当一个函数内部完成一件事情,需要的条件很多时,我不倾向于根据条件逐层推进,这样代码嵌套很深,而应该先判断条件不满足,即可返回,这样函数内的嵌套层次就更加扁平化,易于阅读与理解。
  • 代码注释:说起这个,众说纷纭,我的说法是:如果前面的几条遵循的好,写出的代码将是自己对自己的注释,代码本身就是一个英文语句,我们设计好函数名、数据结构名称、变量名称,当我们逐层推进时,它就是一条语句,不用费心去写注释,而更应该关注的是我们的框架设计文档、以及对这个框架设计细节的描述、任务如何分解的描述、任务状态机的描述、分层的描述、封装的描述、最后的最后就是实现细节的描述、将代码与文档对应起来,言行一致,文档才是最好的注释。

声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

上一篇 2019年1月18日
下一篇 2019年1月18日

相关推荐