Lazy loaded image
🌉开发框架搭建
开发框架08-设备框架引入(面向对象C程序设计)
Words 9889Read Time 25 min
2024-9-6
2024-11-8
type
date
slug
category
icon
password

一、介绍

高效软件开发离不开代码复用,根据复用粒度可分为函数级、库级、框架级(MVC,GUI,Django)、操作系统级复用。嵌入式软件开发具备专用性特点,即为特定场景下的业务需求,选型特定硬件平台、设计外设驱动和应用代码,这一特点也导致嵌入式行业代码复用程度远落后于互联网行业的原因。
我们使用分层和模块化思想指导解决嵌入式开发专用场景带来的复用问题。
  • 分层思想在计算机系统、Linux系统内核、应用层API、驱动模块种广泛存在。实践分层思想,可以使软件层级结构分明,便于管理和维护。各层之间统一接口,可以适配不同的平台和设备,提高软件的跨平台和兼容性。因此提高代码复用率。
  • 另一方面模块化思想,也是拆解软件系统的重要思想,通过封装实现细节,提供使用接口。一个项目模块,直接移植到另一个项目中简单修改甚至不用修改就能使用,通过反复验证,稳定性逐渐提高。
模块化编程可以使用面向对象方法,当然也可以使用过程C风格编程。但面向对象方法将数据和行为封装在对象中,降低模块耦合性,继承和多态特性也极大提高代码的可重用性、可维护性和扩展性。

1.1 嵌入式面向对象场景

面向对象编程在嵌入式开发中处处可见,比如STM32 HAL库、FreeRTOS、RT Thread等操作系统、FlashDB、FatFS等中间件中。另一个重要的嵌入式场景,非Linux嵌入式内核模式,假如我们以面向过程思想去阅读Linux内核、分析其实现,结果只会是越来越乱,分不清其体系架构,尤其是Linux中设备模型。下面选几个场景作简单介绍:
  1. STM32 HAL库中,定义 GPIO_TypeDef 指针封装对象, 相当于C++种this,比如下面 HAL_GPIO_ReadPin 操作对象,提供对外接口。
GPIOx 用指针方式封装对象,相当于C++种this
GPIOx 用指针方式封装对象,相当于C++种this
用宏定义将对象方法封装,使用起来更简洁
用宏定义将对象方法封装,使用起来更简洁
2. FreeRTOS、RT Thread等操作系统中,也定义了rt_smem_t、QueueHandle_t 等结构体指针来实现信号量和队列的抽象封装,并通过将对象指针传入函数接口中,对外提供操作方法。
rtt 构造、析构、方法
rtt 构造、析构、方法
freertos 句柄指针相当于this指针
freertos 句柄指针相当于this指针

1.2 什么是面向对象?

面向对象编程(Object-Oriented Programming,简称OOP)是一种程序设计范式,它将数据和操作数据的方法组合成为一个单独的实体,称为对象。每个对象可以通过调用其方法来进行操作,这些方法定义了对象的行为。面向对象编程更适合大型项目,更容易掌握程序的架构和层次设计
核心概念包括:
  1. 类(Class):类是对象的抽象,描述了一组具有相同属性和行为的对象。类定义了对象的模板,它包含了数据成员(属性)和方法(行为)。
  1. 对象(Object):对象是类的一个实例,是内存中存储的具体数据。对象具有类所定义的属性和行为。
同样面向对象编程强调了数据的抽象、封装、继承和多态等概念。

1.2.1 封装

封装(Encapsulation)是一种将数据和操作数据的方法捆绑在一起的机制。封装将对象的内部细节隐藏起来,只提供有限的接口供外部使用。其主要体现在如下几个方面:
  1. 数据隐藏:对象数据防止外部直接访问和修改状态,只能通过对象提供过的方法进行访问和操作。
  1. 接口暴露:提供一组接口供外部使用对象,接口定义了对象的行为。
  1. 信息隐藏:隐藏对象的实现细节和复杂性,使用者只需调用公开接口完成操作。
封装的优点包括:
  • 安全性:封装可以防止外部直接访问和修改对象的内部数据,从而保护了数据的安全性。
  • 简化接口:封装通过提供清晰简洁的接口,隐藏了对象的复杂实现细节,使得对象的使用更加简单和易于理解。
  • 隔离变化:封装可以将对象的内部实现和外部接口分离开来,当对象的内部实现发生变化时,不会影响到外部的使用者。
  • 提高可维护性:封装使得代码模块化,降低了耦合度,提高了代码的可维护性和可重用性。

1.2.2 继承

继承是一种机制,允许一个类(称为子类或派生类)从另一个类(称为父类或基类)继承属性和方法。这意味着子类可以使用父类的属性和方法,而不需要重新实现它们。继承有助于实现代码的重用和组织,同时也允许通过添加新功能来扩展现有类的行为。通过继承,子类可以在保持与父类相似性的同时具有自己的特定行为和特征。
输出结果:
Dog 派生类通过继承 Animal 基类,保持基类的属性(比如age)和方法(makeSound)。同时,Dog 派生类扩展现基类行为,添加breed 属性。

1.2.3 多态

多态(Polymorphism)是指同一个函数名在不同的对象实例上有不同的行为。这意味着可以使用统一的接口来处理不同类型的对象,而具体的行为取决于对象的类型。
多态性使得代码更加灵活和可扩展,因为它允许在不修改现有代码的情况下添加新的类和行为。
输出结果
Shape 基类只定义了 draw 函数接口。在Circle 和 Square 对象实例时,指定了不同的行为。我们可以调用同一的接口来实现对象功能,但具体行为还需要视对象类型决定。

1.3 一个综合例子

以 Linux 内核中的 RTL8150 USB 网卡驱动源码说明继承和多态。
notion image
  • 多级继承:USB 网卡以 kobject 为基类,实现多级继承,每一级的基类都扩展了各自的方法或封装了接口,供其子类RTL8150调用。
    • RTL8150 网卡通过调用祖父类 kobject 的方法 kobject_add() 将设备注册到系统
    • 通过调用 device 类的 probe() 完成驱动和设备的匹配及设备的suspend、shutdown 等功能
    • 通过调用 device 类的 probe() 完成驱动和设备的匹配及设备的suspend、shutdown 等功能
  • 多重继承:为了解决 USB 网卡多重继承问题,将多重继承简化为单继承(以 kobject 为基类的单继承),另一个继承使用接口(USB 接口)代替。
  • 多态实现:把net_device结构体看作一个基类,对于每一个实例化的结构体变量,都代表一种不同的网卡,看作 net_device 基类的子类。每一个网卡都有各自不同的 read/write 实现,并保存在各个结构体变量的 net_device_ops 里,当一个指向 net_device 结构体类型的基类指针指向不同的结构体变量时,就可以分别去调用不同子类(具体的网卡设备)的读写函数,从而实现多态。

1.3 面向对象的C编程优势

利用面向的对象的封装、继承和多态特性,通过接口、类的封装,就可以实现代码复用和软件分层。
  1. 嵌入式软件架构的基础(架构 - 结构)
    1. 在嵌入式软件开发中,良好的架构是成功的关键之一。面向对象的封装、继承和多态特性提供了一种有力的方式来构建清晰的软件架构。通过封装,我们可以将代码分解为独立的模块或类,并隐藏其内部实现细节。通过继承,我们可以建立层次化的结构,将共享的功能和行为提取到父类中,并让子类继承和扩展它们。通过多态,我们可以统一对待不同类型的对象,从而实现更灵活和可扩展的架构。
  1. 组织大规模嵌入式软件的前提(10万行、可维护性)
    1. 在大规模嵌入式软件开发中,代码的组织和可维护性是至关重要的。面向对象的封装允许将代码分解为独立的模块,每个模块都有清晰的接口和功能。这样,团队可以分工协作,每个成员专注于开发和维护自己负责的模块,而不必关心其他模块的实现细节。通过继承和多态,可以实现代码的复用,减少重复编写相似功能的情况,从而减少代码量,并提高代码的可维护性。
  1. 面向对象是设计模式的基础
    1. 面向对象编程是设计模式的基础,设计模式是解决特定问题的经验总结和最佳实践。面向对象的封装、继承和多态特性提供了实现设计模式的基础。例如,工厂模式利用多态来创建对象,观察者模式利用封装和继承来实现对象之间的解耦,策略模式利用继承和多态来实现算法的替换和扩展等等。

1.4 几个错误想法

  1. 越简单越稳定 (Linux)
    1. 有些开发者认为嵌入式程序越简单越稳定,但这种观点可能有些误导。虽然简单性在软件设计中是一个重要的原则,但稳定性不仅取决于简单性。在实际情况中,稳定性是由多个因素共同影响的,包括设计质量、代码质量、测试覆盖率、错误处理机制等等。庞大如Linux 内核,其稳定性也是由严格的开发流程、质量控制和持续的维护所保证的。
  1. C 语言面向对象太鸡肋,面向对象直接用 C++
    1. 可行性:虽然 C 语言没有直接支持面向对象编程的语法和特性,但仍然可以通过一些技巧来实现面向对象的思想。结构体、函数指针和模块化编程等都可以用来模拟面向对象的概念。
    2. 技术难度:学习和应用 C++ 的确需要一定的技术难度,尤其是对于初学者或已经习惯了 C 语言的开发者来说。C++ 具有更复杂的语法和更丰富的功能,因此可能需要更长时间来掌握。
    3. 技术生态:虽然许多新一代嵌入式平台和开发工具支持 C++,并提供了丰富的 C++ 库和框架。但约 80% 嵌入式单片机仍使用 C 语言开发,完全使用C++进行面向对象开发不符合当前技术生态。
  1. 面向对象是炫技(需求决定)
    1. 面向对象编程并不是单纯的炫技,它是一种编程范式,旨在提高代码的可维护性、可重用性和可扩展性。在某些情况下,面向对象的思想可能确实不适用,但在许多情况下,它是非常合适的。在设计和开发软件时,应该根据具体需求和场景来决定是否采用面向对象编程。

1.5 小结

二、面向对象的 C 编程 - 基础篇

下面通过 STM32 芯片上简单的 GPIO 操作,逐步说明面向对象的 C 编程。
  1. CPP 面向对象
  1. C 面向对象
  1. 剥离硬件,建立抽象层
  1. 数据与行为分离 PWM 设备
  1. 分离共性 继承

2.1 CPP 面向对象

2.1.1 封装:类设计

对外接口
  • init:初始化
  • get_status:获取 IO 状态
  • set_status : 设置 IO 状态
私有属性
  • status_:状态
  • mode_:引脚模式
  • data_:引脚状态

2.1.2 接口:函数接口

初始化
void eio_pin_t::init(const char *name, enum pin_mode mode)
调用HAL库,初始化GPIO接口
参数
描述
name
引脚名称,类似于 "A.01" or "B.14" 这样
mode
GPIO引脚工作模式
支持的引脚工作模式如下:
 
设置引脚状态
void eio_pin_t::set_status(bool status)
调用HAL库,设置 GPIO 状态
参数
描述
status
状态
支持的引脚状态如下:
 
获取引脚状态
bool eio_pin_t::get_status(void)
调用HAL库,获取 GPIO 状态
参数
描述
返回
引脚状态

2.1.3 构造和使用

  • 实例化对象
  • 调用初始化方法
  • 调用设置状态方法

2.2 C 面向对象

2.2.1 封装:结构体设计

私有变量定义差别
  1. CPP 中通过修饰符 private 和 public,防止外部对象访问修改;C 语言中,接口传入静态变量,防止其他文件修改私有变量 ,比如定义static eio_pin_t pin_led;,传入eio_pin_initeio_pin_get_status 等接口。
对外接口差别
  1. CPP 中通过成员访问符,确定对外接口作用域;C 语言中通过添加前缀区分作用域(eio_pin_)。
  1. CPP 中 this 指针可以访问类的私有成员;C 语言中不能用this,因为这会导致 c/c++ 混合编译会出错,而是传入指针 me,接口显得更加复杂,但这是C语言实现必须的。

2.2.2 接口:函数接口

初始化
void eio_pin_init(eio_pin_t * const me, const char *name, enum pin_mode mode)
调用HAL库,初始化GPIO接口
参数
描述
me
this 指针,指定不同实例化对象
name
引脚名称,类似于 "A.01" or "B.14" 这样
mode
GPIO引脚工作模式
支持的引脚工作模式如下:
 
设置引脚状态
void eio_pin_set_status(eio_pin_t * const me, bool status)
调用HAL库,设置 GPIO 状态
参数
描述
me
this 指针,指定不同实例化对象
status
状态
支持的引脚状态如下:
 
获取引脚状态
bool eio_pin_get_status(eio_pin_t * const me)
调用HAL库,获取 GPIO 状态
参数
描述
me
this 指针,指定不同实例化对象
返回
引脚状态

2.2.3 构造和使用

2.3 剥离硬件,建立抽象层 - PIN 设备

本小结讲解如何剥离硬件,建立硬件抽象层。
怎么抽象,怎么剥离
上图抽象层做了两件抽象,第一是抽象对上接口,为应用层提供统一接口,第二是抽象出对下接口,将硬件抽象和隔离(抽象层和驱动层分开)。

2.3.1 抽象层

抽象数据
数据结构决定算法,抽象的第一步是根据设备类型,抽象出合理的数据结构(当然抽象要剥离具体的硬件内容)。如下面数据结构
  • 引入链表链表管理 Pin 引脚;
  • 剥离具体硬件操作,建立抽象层操作接口 eio_pin_ops ,也是通过函数指针-虚函数,实现多态;
  • 剥离具体硬件属性 eio_pin_data_t(包括具体GPIO和PIN),增加 void *user_data; 泛型指针(支持所有数据格式),供用户(写硬件驱动层用户)传入,抽象层使用;
  • 增加 const char *name; ,方便通过字符串,实现更方便的人机交互接口。
对下接口
点击查看详情
  • 适配不同硬件,在驱动层定义eio_pin_t(实例化),并指定名称 name和操作 ops
  • 通过void *user_data,可将驱动层用户数据传递到抽象层。
对上接口
这里不同的是:
对于CPP,实例化对象后,可以直接调用对外接口。但C语言实现,为了保护私有变量,对象实例化是在驱动层,因此需通过 eio_pin_find 找出结构体指针(对象内存),然后调用方法,通过find方法实现了驱动层和应用层的解耦。

2.3.2 驱动层

定义驱动层数据结构,继承 eio_pin_t 抽象层,添加上名称和硬件设置。硬件配置平台相关,比如STM32 上使用 GPIO_TypeDef 类型定义。
定义抽象层所需的操作函数,名称与抽象层一致,函数名添加 “_” 为内部接口。
定义 eio_pin_t (抽象层对象),实例化该对象。
定义并初始化 eio_pin_driver_data 结构体
驱动初始化,使能时钟,注册硬件设备。

2.3.3 应用层

2.4 数据与行为分离 - PWM 设备

本小结主要介绍PWM设备如何建立,可以更加熟悉设备框架如何搭建,且该小结为下一小节分离共性作准备。

2.4.1 抽象层

抽象数据
  • 不同于 pin 设备,增加占空比 duty_ratio
  • 操作接口只有 set_duty
对下接口
对上接口

2.4.2 驱动层

定义驱动层数据结构,继承 eio_pwm_t 抽象层,添加上名称和硬件设置。硬件设置平台相关,比如STM32 上使用 GPIO_TypeDef 和 TIM_HandleTypeDef 类型定义。
定义抽象层所需的操作函数,名称与抽象层一致,函数名添加 “_” 为内部接口。
使用抽象层定实例化对象并定义硬件相关操作接口(实现逻辑细节)
初始化 pwm_driver_data结构体
驱动初始化(完成多设备初始化,配置相关工作放在_init中),使能时钟,初始化定时器,注册设备、设备名、设备操作、驱动底层数据。
驱动层设计中定义 pwm_driver_data 数据,而 _init_set_duty 实现行为,内部未定义数据,所需的数据由userdata 传入进来,从而将数据和行为分离。

2.4.3 应用层

2.5 分离共性、继承

对比 PIN 设备和 PWM 设备抽象数据,可以发现有许多相同之处,比如链表指针、操作接口、用户数据、设备名称等。我们将设备的共性分离处理,建立基类,PIN 设备和 PWM 设备通过继承方式复用设备基类数据和接口,从而建立多重继承的设备框架,实现代码复用,降低工作量。

2.5.1 设备基类

抽象数据
  • open/close/read/write:打开、关闭、读、写
    • open/close 单片中不一定用到,比如 PIN 设备,但在 Linux 系统上,所有设备即文件,需要有序打开和关闭
    • open/close 用于跨平台
    • read/write 流设备和块设备
  • control:控制
    • 为了更好指示单片机不同设备操作,每个子类定义单独的对外接口,可以是set_mode/set_duty/set_baud等方法,更加直观
    • 抽象基类时没使用 control 方法。
  • count_open:设备打开次数,避免单次只能打开一个设备耦合
    • 单次:UART,PIN
    • 多次:SPI
  • standalone: 是独占设备
对下接口(供驱动层使用,在设备子类中调用基类注册接口)
对上接口

2.5.2 设备多态 - PIN 设备

抽象数据
  1. eio_object_t super:继承基类,必须在第一个元素位置,可以实现指针强转。
  1. 去除子类 find 方法,使用基类的 eio_find,实现统一查找。
  1. 对外接口从基类 eio_object_t 链表上去搜寻设备。
对下接口
  1. eio_pin_register 提供下层接口,将nameopsuser_data 注册到 eio_pin_t设备上;
  1. eio_pin_register 调用 eio_register ,将eio_pin_t 设备上注册到 eio_object_t 上。
  1. _obj_ops 定义为空,即基类定义的对外接口未打开,只能通过子类的接口使用。
对上接口
驱动层接口
继承的实现:强制转化 eio_pin_t *pin = (eio_pin_t *)me;

2.5.2 设备多态 - PWM 设备

PWM 设备继承基类,抽象层和驱动层处理与PIN设备类似,在此不在赘述。

2.6 小结

本节首先,通过对比CPP 和 C 面向对象编程方式,了解 C 面向对象的实现方式,其实第一节中已经大体介绍使用方式,本节前两个部分是通过PIN引脚操作嵌入式场景,补充解释。
接着,我们常说需要硬件抽象层来实现硬件隔离,提高系统的可移植性和通用性。2.3节和2.4节演示通过面向对象方式建立抽象层。
通过对比PIN设备和PWM设备抽象数据共性,在2.5节中分离出设备基类,并通过继承方式重新实现PIN设备和PWM设备,并指出其中差别。
由于篇幅有限,多态的介绍未详细解释,读者可以通过参考资料了解。

三、面向对象的C编程 - 设备篇

设备基类
elab 平台软件架构
elab 平台软件架构
设备层 → 中间件 → 驱动层
电机(抽象出电机类 ) → Modbus → uart
大设备层, 小应用层
 
⛔ 应用层 → 片上 UART
notion image
notion image
真实驱动层
  • 仿真驱动层,通过程序去模拟,返回数据,验证应用层逻辑
  • 增量测试,程序逐渐健壮,不需要去硬件上测试一遍,快速收敛
  • 蓝色设备通过仿真(单元测试)通过,绿色设备通过测试通过
notion image

3.2 eLab设备基类源码分析(上)

  1. 注册机制 + elab_device_find
注册 将设备添加用户交互的名称
发现设备独占、测试调试、线程
数组去实现
  1. 读写接口 + 设备独有接口
多态,同一个接口,不同的形态
mcu pin 设备 + I2C pin 设备

3.3 按钮的多态

a. elab_button.h

数据 逻辑
  • e_sig: QPC 存储的各个事件
  • cb:对应按钮回调函数
  • state: elab_button_t 内部状态机
  • flag_dejitter: 去抖动临时变量
  • timer: 轮询需要一个定时器,取代一个定时器里实现多个任务
    • 软件定时器方便移植
    • RTOS 提供出的软件定时器,所有定时器在一个线程
  • mutex: 要求线程安全
  • elab_button_set_event_signal 提供事件信号机制
  • elab_button_set_event_callback 提供回调机制
 

b. elab_button.c

  • 驱动层判断按键是否按下
  • osTimerNew:新建定时器,回调函数为_timer_func,为osTimerPeriodic,并设置timer_attr_button
  • osStatus_t ret_os = osTimerStart(me->timer, 5); 启动 5ms 定时器
  • elab_device_register 注册到设备列表中

c. drv_button_pin.c

 
 
MCU 按钮
ADC 按钮

四、面向对象的C编程-驱动篇

  • 驱动层提供的公共接口,在 pin_export.c 中调用。
  • 驱动层初始化提供注册机制,将设备注册到设备表中
  • 驱动层将 userdata (写驱动层传入的数据)传入设备层,传入的 userdata 数据即 elab_pin_driver_t 指针,主要就是pin_name。
  • 驱动层还要实现设备层定义的接口,由于接口抽象一致性,驱动层使用的定义信息还是需要层初始化后的设备层用户数据中获取。
  • EXPORT_LEVEL_PIN_I2C 依赖 EXPORT_LEVEL_I2C 设备,EXPORT_LEVEL_PIN_MCU,因此初始化放在之后。
  • 设备层弹性,Linux 使用设备树启动,elab 使用 export 机制。

五、高内聚低耦合

3.1 第一层

  • 高内聚:一个模块实现独立的任务,“自己事情自己做”
  • 低耦合:减少“牵一发而动身”,避免一处修改,若干处都要同时修改,“个人自扫门前雪”
notion image
 
  • 用户设置回调
  • 应用层使用异步事件

3.2 第二层

高内聚:做好自己,宽容他人 (软件具有容错性,输入检出、包容)
  • 断言
  • 状态机 default
低耦合:不管索取,只求奉献
  • 同时提供回调和QPC状态机

3.3 第三层

  • 高内聚:运筹帷幄之中
  • 低耦合:决胜干里之外
    • 对象之间耦合降低
    • 通过无形消息传播(发布订阅机制、事件系统、消息队列)
Actor 并发模型 & Active object
事件驱动状态机

引用

  1. 王利涛. 嵌入式C语言自我修养:从芯片,编译器到操作系统. 嵌入式C语言自我修养:从芯片、编译器到操作系统, 2021. - 第 8 章 C语言面向对象编程思想
    1. notion image
  1. eLab/boards - 码云 - 开源中国 (gitee.com)
  1. 狗哥嵌入式的个人空间-狗哥嵌入式个人主页-哔哩哔哩视频 (bilibili.com)
  1. 【C语言面向对象】基本概念_哔哩哔哩_bilibili
上一篇
开发框架07- Doxygen 文档生成
下一篇
开发框架09-高效可靠串口通讯

Comments
Loading...