本书的原著为:《Design Patterns for Embedded Systems in C ——An Embedded Software Engineering Toolkit 》,讲解的是嵌入式系统设计模式,是一本不可多得的好书。
本系列描述我对书中内容的理解。本文章描述访问硬件的设计模式之一:硬件代理模式。
硬件代理模式
(Hardware Proxy Pattern) 是硬件抽象的典型模式。目的是封装细节。该模式通过创建软件模块来封装对特定硬件设备的操作,隐藏底层硬件的实现细节和复杂性,提供标准的接口给上层应用程序使用。
假设有一个嵌入式系统需要访问内存、传感器等硬件设备。如果每个应用都直接在程序中访问操作底层硬件,当更换了相同功能的不同硬件设备时,有可能硬件接口并不一致,而且对硬件的操作与控制方式也并不一样。这种情况下,就需要为每个硬件设备编写特定的访问代码,增加了软件开发的复杂性和工作量。
而采用硬件代理模式后,可以创建一个硬件代理来封装对内存、传感器等硬件设备的操作。应用程序通过调用硬件代理提供的标准接口来访问硬件设备,无需关心底层硬件的具体实现。当更换了相同功能的不同硬件设备时,只需要修改硬件代理的实现即可,无需修改客户程序的代码。这样就简化了软件与硬件的交互过程,提高了软件的可移植性和可维护性。
在这个过程中,最难的是定义一系列标准接口。标准接口是对硬件的抽象,而只要涉及到抽象,大都伴随艰难的脑力劳动。一个好的接口,能带来什么益处?答案是稳定。硬件可能随时会变,但好的接口通常不变,它比硬件要稳定。因此在接口之上的应用层,都不用变。这就将变化控制在硬件层这个狭小的范围内,从而让变更变得容易和可控。
抽象
硬件代理模式使用类或结构体来封装对硬件设备的所有访问,而不管其物理接口如何。硬件可以是内存、中断映射,也可以是通过总线、网络连接的设备。硬件代理提供一些服务,这些服务可以与硬件设备交互:初始化、配置、关闭、读写数据等。硬件代理为上层应用提供了一个与编码和连接无关的接口,因此如果硬件设备接口或者连接方式发生变化,则可以方便的修改现有代码。
问题
如果每个上层应用(书中称为“客户端” )都直接访问硬件设备,则由于硬件更改而导致的问题会加剧。比如数据编码方式、内存地址或连接方式发生变化,则必须跟踪并修改每一个上层应用。通过提供位于上层应用和硬件之间的代理,可以极大的减少硬件更改带来的影响。为了便于维护,高层应用不应该知道底层次代码的细节,包括数据编码方式、加密方式、压缩方式等。这些详细信息应由具有内部私有函数的硬件代理进行管理。
模式结构
模式结构见下图。
硬件代理客户端可能有多个,但是硬件设备只有 1 个硬件代理。硬件代理具有公共函数和私有的函数和数据。在 UML 图中,数据放在首部,比如 deviceAddr: void *
,函数放在数据的下方,比如 initialize():void
;公用函数和数据使用正常字体,私有函数和数据用带下划线的斜体。
模式详情
硬件设备
硬件设备
是一个具体的硬件实体,它本身无需编程即可运作。把它放到图中,只是为了便于理解模式结构。硬件代理
与 硬件设备
之间的 关联
是通过硬件接口实现的,这些接口可以是一个寄存器地址或其他类似机制。关联关系使一个类知道另外一个类的属性和方法,这里硬件代理与硬件设备是双向关联,大家相互知道对方的细节。
硬件代理
硬件代理封装了针对特定硬件的数据和函数,为每种硬件设备提供了一套统一的接口。通常每个硬件都会有 initialize()
、configure()
和 disable()
等基本操作函数,此外,硬件代理还提供了一组公共函数,用于对硬件进行读写访问。
尽管模式结构图中只标识出一个 access()
和 mutate()
函数,但实际应用中通常有多个这样的函数,每个函数都针对特定的读写目标,具有明确的语义和用途。例如,accessMotorSpeed()
函数用于读取电机的速度,而 accessMotorDirection()
函数则用于读取电机的旋转方向。
这种设计方式使得上层应用程序(客户端)可以通过调用硬件代理提供的函数来与硬件进行交互,而无需关心底层硬件的具体实现细节。这不仅简化了软件与硬件之间的交互过程,还提高了软件的可移植性和可维护性。
硬件代理关键函数和数据为:
initialize()
:在首次使用之前调用,初始化设备。configure()
:用于配置硬件设备。disable()
:关闭或禁用硬件设备。access_xxx()
:从硬件设备读取数据。但在实际编程中,更常见的做法是使用get_xxx()
。mutate_xxx()
:向硬件设备写入数据。但在实际编程中,更常见的做法是使用set_xxx()
。
在编程和软件设计中,函数名通常会给出关于函数功能的一些提示。
access
和mutate
这两个名字就很有代表性,它们分别暗示了读取(或访问)数据和修改(或变更)数据的操作。
- access 函数通常用于读取或检索数据,但不修改它。例如,在数据库编程中,一个 access 函数可能是用来从数据库表中读取记录的。在对象导向编程中,access 方法(也称为 getter 方法)可能用于读取对象的某个属性值。
- mutate 函数则用于修改或变更数据。在数据库编程中,这可能意味着更新数据库表中的记录。在对象导向编程中,mutate 方法(也称为 setter 方法)可能用于设置或修改对象的某个属性值。
marshal()
:私有函数。将上层应用的数据格式转换成硬件可以理解的数据格式。可能需要加密、压缩或打包。这确保了硬件设备接口的特殊性对上层应用(客户端)是隐藏的。实际硬件设备所需格式的数据被称为“原生格式”数据。容易被软件操作的数据被称为“呈现格式”数据。由于原生格式对上层应用是隐藏的,因此上层应用无法访问此函数。
通过将数据处理和转换的逻辑封装在私有函数中,软件设计可以确保上层应用代码与硬件设备接口的复杂性隔离开来。这样,上层应用开发者只需要关心如何操作
呈现格式
的数据,而不需要了解如何将这些数据转换为硬件设备能理解的原生格式
。这简化了上层应用的开发工作,并提高了系统的可维护性。
unmarshal()
:私有函数。将硬件数据转换成上层应用可以理解的数据格式。可能需要解密、解压缩或解包。也就是将原生格式数据转换成呈现格式数据。与marshal()
函数一样,隐藏了硬件设备的细节,因此上层应用无法访问此函数。deviceAddr
:私有变量。提供了对硬件的低层次直接访问。在代理模式中,它显示为void*
,但它可能是一个整形(int*
)或其它数据类型。如果使用了更复杂的方式来访问设备,如 RS232 串行端口或以太网连接,那么这个数据类型及其访问方法将更加复杂。无论如何,硬件代理提供的公共函数完全隐藏了代理如何连接到实际硬件设备的过程。上层应用无法直接访问这个变量。
代理客户端
也就是使用硬件代理的上层应用。上层应用知道硬件代理的公用函数和数据,然后调用这些服务来访问硬件设备。
结果
这种模式非常常见,它提供了封装硬件接口和编码细节的所有好处。它为实际的硬件接口提供了灵活性,使其可以在不改变上层应用代码的情况下变更硬件。这是因为硬件细节都封装在硬件代理中。这意味着上层应用通常不知道数据的原生格式,而只以呈现格式操作它们。
然而,这可能会对运行时性能产生负面影响。有时,让上层应用了解编码细节并以原生格式操作数据可能更高效。但是,这会降低系统的可维护性,因为如果硬件接口或编码发生变化,就需要修改上层应用。
实现策略
如第1章所述,在 C 语言中,类可以通过不同的方式实现,从简单的文件到使用结构体来存储类属性,再到使用支持真正多态性的虚函数表。所有这些方法都可以用来实现这个非常简单的模式。
通常,一个硬件代理支持特定设备的所有功能,并且每个独立的设备都使用一个不同的硬件代理,但这并不是一条绝对的规则。将设备分离成不同的部分意味着这些设备可以遵循独立的维护路径,因此对于未来来说非常灵活。
一般来说,最好将实际硬件的位编码、加密和数据压缩隐藏在上层应用之外。然而,也可以通过使原生格式对上层应用可见来实现该模式,在在这种情况下,无需使用 marshal()
和 unmarshal()
函数。
实例
见原书。