单片机程序架构 --- 二层架构

2020-03-07 17:52发布

本帖最后由 会笑的星星 于 2019-12-27 15:04 编辑
在单片机程序设计中,在着手编程之前都应该想好一个大概的程序框架,有了一个好的框架,程序的复用性、可移植性、清晰度等等都能得到提高。那么,在编程之前,如何设计程序的框架呢。为了说清楚设计方法,可以先从最简单的单片机程序架构说起,我把它叫做二层架构,如下图所示。
clipboard.png

所谓的二层架构,指的是程序里面只分为硬件层、应用层两个层。硬件层顾名思义,就是与硬件相关的寄存器初始化、寄存器设置之类的功能,比如定时器的初始化,I/O口的高低电平设置等。而应用层就是我们要做的产品逻辑功能。

乍看上去这种结构比较简单,却很实用。首先,硬件层从应用层剥离后,在你更换单片机时只需要更改硬件层的相关的程序,而此时应用层不用变化(当然,根据情况也可能有少量变化)。其次,硬件层本身可以应用到其他项目中,增加代码的复用性,这无疑有利于你使用同款单片机做
其他的产品开发。
知道了二层架构的好处,现在来看看在单片机程序中如何实现这种架构。我们先来看看硬件层的设计。
硬件层主要封装硬件寄存器相关的操作,我把所有涉及硬件相关的代码全部放在了两个文件,一个为hal.c,用于存放函数实现。一个hal.h,用于对外声明以供应用层调用。

硬件层主要分为三个部分,第一是硬件寄存器的初始化以及寄存器状态的获取的相关函数封装,第二个是硬件相关的中断,第三是硬件层的对外接口,我分别来讨论。

首先,看函数的封装。
对于程序设计来说,为了确保代码的复用性,降低代码之间的耦合度并且使得程序结构更为清晰,设计函数时,必须满足下面两点要求:
  • 函数的功能要尽量单一
  • 除非特殊情况,硬件层的函数中不要调用应用层的函数或者变量,更简单的说就是尽量不要出现下层调用上层的情况

按照上述两个原则,看看硬件层中函数封装的几个例子。
clipboard1.png
系统时钟初始化
单片机IO初始化.png
单片机IO初始化
串口初始化.png
串口初始化以及相关寄存器的设置、访问
如上图中串口模块初始化所示,初始化函数hal_uart_init()的入口没有参数传递,这意味着串口模块的波特率被初始化为固定的9600,如果调用者想修改为115200的波特率则无法通过参数传递的方式直接修改,而必须要修改这个函数中的相关寄存器(这里是_brg)。
这样做看似有点麻烦,但是并非不可取。因为串口初始化函数要支持波特率可选,必然要在该函数中根据函数形参进行一个乘除运算得出
相应的波特率(如下图所示)。而做比较复杂的乘除运算本身不是一些普通单片机擅长的,对于有些低端8位的单片机来说,在局部函数中做乘除运算可能会占用全局空间,从而减少本身就紧张的RAM空间。当然,对于一些资源充足、性能强劲的单片机来说这一点是不用担心的,通过函数的参数来自动完成波特率的计算是更好的选择,但也不一定非要如此,因为很多时候我们并不需要硬件层有这么好的通用性。因此,我更倾向于在串口初始化时不传递参数
波特率的设置.png

其次,来看看如何在硬件层中处理中断
中断函数与单片机是捆绑的,因此,中断最好也一起写到hal.c中,我一般集中写在文件的开头位置,以便后续好统一管理,如图1ms定时器中断的例子
中断示例.png

这里要注意的是,一般而言,中断中一般是要调用应用层的变量或者函数的,以便为应用层提供所需要的一些定时、串口数据接收或者发送任务等等,这时会涉及到硬件层向应用层调用的问题。要处理这个问题,我们有两个办法:
  • 直接在中断中调用应用层变量或者函数
  • 通过回调函数调用应用层的函数

如果使用第一种方法,违背了我们之前说过的硬件层函数的设计原则二。如果我们需要选择第二种方法,硬件层与应用层可以很好的实现隔离,这也是很多大型软件比如蓝牙协议栈所采用的方法 --- 底层通过回调函数的方式调用应用层函数,如此实现底层的通用性。

通过回调函数的方式实现下调上虽然想法好,但是在程序实现上往往比直接调用上层更为复杂且麻烦,一般而言还需要耗费更多的单片机资源,有时候一些低端单片机甚至连函数指针都无法支持,这个时候根本就不可能使用回调函数。而且,对于大型软件而言,比如蓝牙协议栈,它由
于需要面向不同的开发人员以开发不同的产品,对于软件隔离要求是很高的,而我们自己设计的单片机程序相比而言对通用性则没有这么高的要求。因此,综合来看,虽然使用第一种办法虽然违背了原则2,但是考虑到上述所说的各种原因,我认为很多时候使用方法1更好,不过调用
上层函数时依然需要遵循 --- 尽可能的少、尽肯能的简单
最后,来看看硬件层的对外接口
硬件层的对外接口定义在hal.h文件中,这个文件声明了hal.c中所有上层会使用的函数或者宏定义,如下面几个图所示:
GPIO宏定义.png
GPIO部分的宏定义以及函数声明
全局中断宏定义.png 全局中断的宏定义


串口部分声明.png 串口部分的宏定义以及函数声明

值得注意的是,我把相同模块相关的函数声明放在了一起,不同模块间的函数声明通过符号分割,使得整个文件组织更有结构,也更为清晰。

接口准备好后,应用层就可以直接调用了,如下图所示。

应用层调用例子.png

我们上面实现了硬件层代码(hal.c、hal.h),还剩下最后一个事情,那就是组织一下文件。这个很简单,没啥技术含量,如下图所示。
工程内的文件组织.png 工程内的文件组织

工程外的文件组织.png 工程外的文件组织


clipboard4.png 工程外文件,app文件夹内的内容


clipboard6.png 工程外文件,hal文件夹内的内容

二层架构虽然简单,但却实用,通过这样的有结构的组织程序,使得硬件层的复用性得以提高,并且整个代码的清晰度相比没有架构的程序要好不少,这样有利于减少程序的bug。二层架构虽然也有不少好处,但是只实现了硬件层的抽象,而应用层并没有实现抽象,这意味着应用层的代码过于耦合,并没有多少可复用性,要解决这个问题,我们必须要将应用层也分离出两层,一层是所谓的中间层,另一层就是应用层,这就是所谓的三层架构,这会在后面讲到。

友情提示: 此问题已得到解决,问题已经关闭,关闭后问题禁止继续编辑,回答。