C语言面向对象(下):驱动设计技巧

C语言面向对象(下):驱动设计技巧,第1张

C语言面向对象(下):驱动设计技巧

目录

驱动设计

一、简介二、准则三、写驱动前的准备工作

1. 文件结构2. API3. 名字 三、小技巧

1. 结构体2. 回调3. 多用enum和define 四、简单例子

驱动设计 一、简介

C语言作为最底层的高级语言,它的应用场景也十分底层,尤其在嵌入式领域使用较多。而在嵌入式领域,从硬件相关性来分,一般还能再分三层,嵌入式板级支持包BSP(也有叫硬件抽象层HAL),嵌入式驱动,以及嵌入式应用。

BSP/HAL层要能抽象不同硬件的功能并向上提供统一接口,即对硬件寄存器 *** 作进行封装,实现一些最小功能的函数接口,来屏蔽不同硬件的差异,这样就能够大幅提高上层代码的复用性和可移植性。所谓最小功能函数接口,即该调用该函数能够完成某一项配置,通过不同的最小功能函数接口组合,即可完成一个具体的硬件配置。一个理想的BSP/HAL应该是不管底层硬件再怎么变,除非需要支持新功能,否则对外提供的接口以及接口对应的功能都不会改变。驱动层则是对HAL层提供接口的进一步封装,驱动层封装的目的是为了实现对硬件、软件资源的管理,如储存一些当前配置、记录当前硬件状态、向提供该驱动的资源管理及配置接口等。驱动层往往会伴随着 *** 作系统一起出现, *** 作系统可以提供任务调度、队列缓存、信号通知等功能,帮助驱动层更好地完成资源管理。应用层是面向最终产品的,通过调用前两层提供的接口,基于若干个硬件实现完整应用级的功能。当然应用层并不一定要完全依赖前两层,但是如果少了前两层的封装,应用层的代码就会显得冗长繁琐,不能聚焦应用级功能的实现,大量代码会消耗在对于所需硬件的设置、资源分配以及资源访问上。而应用级的功能往往是比较庞大复杂的,因此一个逻辑清晰地驱动层能极大简化应用层的开发难度。

本文将聚焦承上启下的嵌入式驱动,对于嵌入式驱动,但是对于驱动程序设计,其实每个人都会有自己的理解和自己的风格,这里只是谈谈自己的一些见解和经验,不一定正确,欢迎补充和指正。

二、准则

对于一个驱动维护人员,在设计或维护驱动时,要时时刻刻提醒自己以下三点,分别是功能完整性、可维护性和可用性。

功能完整性
功能完整性其实依赖于BSP/HAL层提供的接口,现在假设BSP/HAL已提供硬件所支持的所有功能。那么驱动就要考虑如何把这些功能进行整合,通过一些接口开放给应用层,使得能够满足应用层对该硬件的所有需求。可维护性
可维护性就是当一个版本的驱动已经发布,需要新增、减少功能,定位、修复BUG,版本迭代时,代码能够以较小且合理的改动完成上述要求。对于一个需求的改动,可维护性差的代码往往需要大幅改动其他原有代码,非常容易破坏源代码运行逻辑,导致出现BUG。因此可维护性是体现代码水平的重要标杆。易用性
驱动的API是提供给上层应用使用的,一份易用的API应该是精简的、一目了然的,调用者能够通过最少的语句,实现其所需功能。易用性其实体现在驱动对BSP/HAL提供功能的抽象程度,功能抽象程度越高,API就可以越少,反之功能越具体,API就越多。

从另一个角度来说,功能完整性和易用性是面向上层的,越完整越易用,你作为驱动开发者受到来自用户的骚扰就越少。可维护性是对自己和将来接盘的人负责,可维护性越高,平时要做的工作量就越少,接盘的人也更舒服。当我们采用面向对象的方法写驱动时,可维护性和易用性就能得到很大的提升。

很多时候我们并不能同时保证这三点,只能根据实际需求侧重其中一点或两点。如果硬要分个123,个人认为可维护性第一,易用性第二,功能完整性第三。当可维护性得到保证的时候,第二第三点只是需求问题,比较容易实现。易用性第二是因为驱动是给别人用的,对于调用者来说,一份驱动的好坏就体现在易用性上面,如果调用者不用仔细读文档或者注释,一眼就能看出这些API的功能,并且驱动行为与预期相同,那就是一份好驱动 。功能完整性最后一是因为有些犄角旮旯的鸡肋功能基本用不上,其次当前两者做得很好的时候,新增对某个功能的支持也将会变得非常容易。

三、写驱动前的准备工作 1. 文件结构

文件结构我认为是写驱动前最需要想清楚的,它会直接影响上面提到的三条准则能否较好地实现。

首先是驱动的功能和BSP/HAL功能要分开尽量不要都写在一个文件,驱动需要更加专注硬软件资源管理。BSP/HAL文件如果当不同版本的硬件功能差异过大时,可以为每中硬件提供一份BSP/HAL文件,但尽量对外提供统一接口。而在驱动层,可以依赖不同HAL文件的同一接口来实现某一具体功能。与HAL层相似,驱动层接口也并不一定只有一份文件,但驱动层是按照硬件工作模式、软件功能、管理资源的性质等分文件。

文件的层次结构划分好以后,需要明确依赖关系,应用层的文件可以依赖驱动层的文件,驱动层文件可以依赖HAL层文件,当然,为了层次明确,驱动层所依赖的HAL文件最好不要公开给应用层,导致底层API的泄露。其次是必须要避免循环依赖。而为了达成单向依赖,可能需要引入一个能够被所有层引用的头文件,专门用来定义所有层都需要用到的结构体、枚举以及宏等。为了保证不会产生循环依赖,该头文件应是纯头文件,即没有函数声明,其次应当尽量少依赖其他头文件,即使依赖,也要保证被依赖的头文件是纯头文件。

由此我们可以大致画出一个驱动的文件结构,注意这个结构具有一定普适性但并不一定是绝对的,不同驱动还是需要具体设计的。

图中核心驱动文件根据实际硬件平台依赖其中一个hal头文件,业务逻辑和公开功能对于一些简单驱动来说并不是必须的,可以一并写到核心文件中,只保留一个driver.c源文件。但对于一些复杂的驱动来说,将不同业务逻辑划分到不同的文件中能使整个驱动逻辑更清晰。一般来说驱动的核心文件只管驱动的资源分配调度以及状态管理,而业务逻辑则是负责某一具体功能的 *** 作及维护。

图中应用层依赖了驱动头文件,依次间接依赖了公共的类型定义文件以及公开类型,驱动头文件隔绝了底层硬件以及业务逻辑的代码,能够更好地保护硬件运行状态不会因错误或非法调用一些底层接口而破坏,也保证了一些变量及函数的作用域最小化。

另外,图中的公开类型和公共类型定义并不完全相同,当然在驱动比较简单的时候可以将它们放在一个文件中使用,但是也要驱动的类型留下可扩展的空间。一般来说公共类型多以define、enum为主,较少出现结构体,主要用来为一些工作模式、常量等取名,以增加可读性和规范性。而公开类型则是多以struct和define为主,主要目的是为了提供配置驱动使用的结构体类型以及默认设置,以保证功能完整性和易用性。但是就像上文中提到的,公共类型定义和公开类型都必须要尽量不依赖其他头文件,即使依赖也要保证依赖的文件是纯头文件,否则就容易出现

2. API

API即Application Programming Interface应用程序编程接口,广义上包含了函数声明、结构体、枚举、宏等,狭义上特指函数声明(即函数接口)。对于驱动来说,如果说驱动源文件是灵魂,那API就是驱动的外在表现。对于非开源的驱动,上层调用者只能看到API接口,所有驱动的功能都是通过API反映的,可见API的重要性。

个人认为,在设计API时,首先需要考虑的就是驱动的功能,设计的API需要能够将所有功能都包含进去。其次API必须要简单易用,简单易用的秘诀就是少,函数接口少、所需参数少、使用步骤少。然而少和功能全又是两个冲突的概念,所以我认为驱动的艺术就是在于用最少的API支持最全的功能。而编写一套简单易用且功能全API的关键就是在于对所支持功能的归类以及抽象,最好是能用同一个接口,实现不同功能的实现,这样才能在减少API的同时,又能支持尽可能多的功能。

一般来说,对于一个驱动必定需要有安装与卸载功能,驱动的安装和卸载的概念在嵌入式中其实是硬软件资源的分配与释放。此外,硬件设置也是大部分驱动不可或缺的一部分,但是很多时候不同的模式可能需要设置不同的硬件配置,这一部分也是很多驱动接口众多的原因之一。除了以上比较通用的接口外,还有像一些通信的驱动,就需要有读写 *** 作的接口,而对于一些传感器驱动,则还需要有读传感器数值的接口等,因此不同的驱动确实需要根据自身需求制定适合的API,但是万变不离其宗,我们大致可以将API接口归纳为一下几类:

    安装与卸载配置与初始化输入输出接口中断相关接口驱动信息获取接口

其中前两类是驱动必须要有的,后三类则视驱动具体需求而定,但是对于每一类接口,能少则少,没有需求的最好就不要开,才能使接口尽可能少且高度集成。

如果以面向对象的角度实现以上几类接口,那么它们会像是这样:

驱动安装
err_t driver_new_instance(const config1_t *cfg1, ..., handle_t *ret_handle);
一般提供若干基础的配置,并根据输入的配置生成驱动的控制句柄,返回值为错误码(可自定义)。这里用const修饰配置是为了表示驱动内部并不会修改这个配置。输出的控制句柄就相当于生成的对象,一般放在最后一个参数,它由驱动生成,由上层调用者保管,后续其他关于该驱动的 *** 作都需要提供这个控制句柄。

驱动卸载
err_t driver_delete_instance(handle_t handle);
只需要提供驱动的控制句柄,驱动将这个句柄相关的资源卸载并返回错误码。

驱动初始化
err_t driver_init_instance(handle_t handle, const config1_t *cfg1, const config2_t *cfg2, ...);
通过提供的控制句柄以及配置,对硬软件资源进行初始化并返回错误码。一些简单的驱动可以将初始化 *** 作并入驱动安装时完成,这样可以少一个接口。但是提供这个接口的好处是可以把一些资源延迟分配与初始化,适用于一些相对复杂的驱动。

驱动配置
err_t driver_set_xxx(handle_t handle, const config1_t *cfg1);
通过提供的控制句柄以及配置,对硬软件资源进行设置并返回错误码。驱动配置与驱动初始化具有一定的相似性,而一般来说驱动配置会更加具有针对性,针对某一具体功能或者属性,其次是可选的,不要求在驱动安装后必须调用,只是作为配置的更新、重设、追加 *** 作。

输入输出接口
err_t driver_write(handle_t handle, void *src_buf, unsinged int buf_len, unsinged int *bytes_written, ...);
err_t driver_read(handle_t handle, void *dest_buf, unsinged int buf_len, unsinged int *bytes_read, ...);
通过控制句柄写入或读出数据,这里的例子是写入/读出一个数组,有些驱动可能是固定长度的数据、或者一个字节等,需要按需设计。

驱动信息获取
err_t driver_get_info(handle_t handle, info_t *info);
根据提供的控制句柄获取驱动当前状态信息等。这个接口很多时候是不需要的,但也有部分情况需要能观察到当前驱动运行状态、资源分配状态等信息,这个接口相当于是为这个黑箱提供了一个观察窗口。

Tips:从上面的一些例子可以发现,返回值一般都是错误码,有利于向用户提供具体的错误信息,其他返回信息则通过参数列表以指针的形式取回。另外,当我们要传入一些配置的时候,最好是用一些结构体去传,这样以后扩展配置的时候直接在结构体中新增成员即可,而如果是直接传一些基本类型的参数,那么后面遇到一些新的功能需要配置,老的接口就用不了,不得不开一个新接口,或者只能将老接口改的面目全非,这些都是造成API接口膨胀或腐败的原因。

3. 名字

千万不要小看命名,不管是变量、参数、函数、类型还是宏,都需要一个恰当好处能够反应其真实用途的名字。撇开注释及文档,名字不管对于用户还是维护人员都是用来直接理解代码的主要途径。命名问题也算是个老调重d的问题,网上有很多建议和注意点,本文再强调几点。

    不要用没有任何含义的名字!哪怕一些不重要的变量,也不要用a/a1这样的名字去命名,这种名字可以说毫无可读性,只有上天和你知道什么意思,然后过段时间就只有上天知道了。所以要根据功能或用途取一个名字。不要随便用缩写!有些不常用的缩写会让人一头雾水,只能靠猜,非常令人痛苦。对于非常用的缩写,宁可用全名,哪怕名字变得比较冗长,全名也比一个简短的缩写好。如果真的要用一个不常用的缩写,请在用到的它的地方给出注释说明清楚它到底是什么。另外一些词可能有几种缩写,请使用最常见的缩写,比如control的缩写ctrl就比cntl好,因为前者更常用更好理解。不管是驼峰命名法还是下划线命名法,都是为了增加可读性,但是最好能够统一使用一种命名法,让代码看上去更整洁。适当使用前缀及后缀。比如对于某一驱动abc,那么它的函数都用abc开头,用来标识函数所属驱动,对于hal层接口,前缀再追加hal变成abc_hal能够一目了然看出接口所属的层级。尾缀也是同样的道理,比如我们可以将typedef定义的类型以_t结尾标识为typedef的类型,可以通过尾缀标识变量单位如_Hz/_ms等。

因此,对于公开给用户的接口名字更需要深思熟虑,一要考虑到能不能反应它的功能,二要考虑以后内容如果扩充以后,这个名字还合不合适。

三、小技巧

C语言的小技巧数不胜数,本文仅是简单列举几个稍微冷门但是很有用的小技巧。

1. 结构体

位域:可以以比特为单位划分空间,极致利用空间,也方便 *** 作某一位。下例中的bit1、bit2_4、bit7一共只占一个字节,其中第5~6位以及第8位不可用。

typedef struct {
	uint8_t bit1 : 1;
	uint8_t bit2_4 : 3;
	uint8_t : 2;
	uint8_t bit7 :1;
	uint8_t byte2;
} example_t;

union:存储空间共享,其实有很多应用场景,比如和上面的位域结合起来可以方便得实现对整体或者局部的 *** 作,也可以通过union为统一空间定义不同的名字,方便实现前向兼容:

typedef union {
	struct {
		uint8_t bit1 : 1;
		uint8_t bit2_4 : 3;
		uint8_t : 2;
		uint8_t bit7 :1;
		uint8_t byte2;
	};
	uint8_t val;
} example_t;

上例中可以通过val对这个字节整体赋值,也可以通过某一位赋值,这样做的好处是可以以比特为单位给每一个字段命名,也更加方便调用。

typedef struct {
	union {
		uint32_t new_name;
		uint32_t old_name;
	};
} example_t;

上例中通过union将old_name重命名成了new_name,同时不影响原来old_name的使用,可以防止出现破坏性更改(breaking change)。

2. 回调

回调(callback)其实是一个很常用的方法,它可以隔离调用者和实现者,调用的程序不必关系回调函数如何实现,回调函数的实现者不必关心调用回调时上下文需要有哪些 *** 作,有利于降低代码耦合度。从JAVA的角度来理解,有点类似于面向切面(AOP),调用者即是切面,而实现者即是实际业务。回调常用的应用场景一般是由某一事件触发回调函数,通知实现者事件产生,如中断事件、队列事件、信号量事件等。

// caller.h
typedef enum {
	ON_RECEIVE,
	ON_SEND,
} event_t;

struct callback_func{
	void func_cb(event_t evt);
};

typedef struct callback_func *handle_t
// caller.c
#include "caller.h"

void interrupt_handler(void *args)
{
	handle_t handle = (handle_t)args;
	event_t evt;
	// 判断是否是接收中断
	if (...) {
		evt = ON_RECEIVE;
		handle->func_cb(evt);
	}
	// 判断是否是发送中断
	if (...) {
		evt = ON_SEND;
		handle->func_cb(evt);
	}
}
// Implementer
#include "caller.h"

void callback_function(event_t evt)
{
	if (evt == ON_RECEIVE) {
		// 处理接收事件
	}
	if (evt == ON_SEND) {
		// 处理发送事件
	}
}

void init(void)
{
	handle_t handle = {
		.func_cb = callback_function
	}
	// 将handle传给caller.c
	// ...
}
3. 多用enum和define

enum和define可以有效增加程序可读性,和易用性。
enum可以明确参数类型,例如:

int calculate(int a, int b, int method)
{
	switch (method) {
	case 0:
		return a + b;
	case 1:
		return a - b;
	case 2:
		return (a > b) ? (a - b) : (b - a);
	default:
		return a + b;
	}
}

上面例子中method是int类型的值,对于调用者来说,意义不明确,不知道所需功能具体的值,如果我们用enum对这些可用的算法进行命名,那么调用者就能够轻易得从enum取值范围和名字了解到该参数的功能。

typedef enum {
	METHOD_ADD,	// 加法
	METHOD_SUB, // 减法
	METHOD_SUB_ABS  // 减法绝对值
} method_t;

int calculate(int a, int b, method_t method)
{
	switch (method) {
	case METHOD_ADD:
		return a + b;
	case METHOD_SUB:
		return a - b;
	case METHOD_SUB_ABS:
		return (a > b) ? (a - b) : (b - a);
	default:
		return a + b;
	}
}

宏的妙用实在太多了,宏可以理解为用一个符号表示了某种东西,这种东西可以是一个数值、变量、符号、名字、函数、 *** 作等等,主要目的是为了减少重复代码。其他基础的功能这里不再赘述,但是有两个冷门但非常实用的符号很多人可能不知道,即#和##。

#即是将后面的变量转换为字符串,例如:

#include 
#define NULL_POINTER_CHECK(ptr)	if (ptr == NULL) { 
	const char msg[] = "the pointer '"#ptr"' is NULL";
	printf("%s", msg);
}

void test(void)
{
	int a = 1;
	int *b = &a;
	int *c = NULL;
	NULL_POINTER_CHECK(b);
	NULL_POINTER_CHECK(c); // 打印 "the pointer 'c' is NULL"
}

##则为将前后的符号拼接起来形成一个新的符号,例如:

#define MACRO_CAT_EXPAND(a, b)	a_##b
#define MACRO_CAT(a, b)			MACRO_CAT_EXPAND(a, b)
#define TEST_FUNC_NAME(name)	void MACRO_CAT(name, __LINE__)(void)

TEST_FUNC_NAME(example)
{
	printf("The function name is: %sn", __FUNCTION__); // 打印 "The function name is: example_5"
}

上例中宏展开依次为:

    TEST_FUNC_NAME(example) ——> void MACRO_CAT(example, __LINE__)(void)void MACRO_CAT(example, __LINE__)(void) ——> void MACRO_CAT_EXPAND(example, 5)(void)void MACRO_CAT_EXPAND(example, 5)(void) ——> void example_5(void)

其中__LINE__为获取当前行数,__FUNCTION__为所在函数的名字。
本例中,TEST_FUNC_NAME在第5行,因此__LINE__为5,第一个宏MACRO_CAT_EXPAND是帮助__LINE__进行展开的,如果我们没有第一个宏MACRO_CAT_EXPAND,那么__LINE__不会被展开为5,下例为没有第一个宏时的结果:

#define MACRO_CAT(a, b)			a_##b
#define TEST_FUNC_NAME(name)	void MACRO_CAT(name, __LINE__)(void)

TEST_FUNC_NAME(example)
{
	printf("The function name is: %sn", __FUNCTION__); // 打印 "The function name is: example___LINE__"
}
四、简单例子

(有缘再更)

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5702737.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存