嵌入式系统中面向对象编程方法的探索与实践

73 阅读12分钟

前言

在常规的嵌入式开发中,c语言是最常用的语言。而c语言最常用的复合数据类型就是“结构体”。但结构体并不能像C++语言的class一样直接定义成员函数方法,因此通常嵌入式的开发中都是以“面向过程”的方法开发的。

但c语言的结构体中,可以定义函数指针变量成员,如果使用成员函数指针指向其对应的成员方法,以此来模拟成员函数,似乎就可以实现类似于c++中class类的效果。故借着完成这次学校嵌入式实训作业的机会,尝试使用结构体与函数指针的结合,模拟类的封装、继承与多态特性。

本文中以GPIO接口类、LED类与按键类作为示例,在STM32F103C8T6的裸机环境、STM32F407CGT6的裸机环境与FreeRTOS系统中成功移植,验证了该方法的可行性与可扩展性。

实现思路

  项目采用三层结构:

  • 硬件抽象层:GPIO接口类(Interface
  • 设备层:LED类(Led)与按键类(Key
  • 应用层:主程序或操作系统任务

  为了实现效率,选择在cubemx中进行hal库的相关配置,并直接使用裸机的方式进行编码。

1、定义一个GPIO接口类

在嵌入式开发中,大多数的操作都最终体现在芯片引脚的控制上。

  所以设计了Interface 类封装了GPIO端口与引脚,并提供了设置与读取方法,作为其他设备类的基类使用。

  关键代码片段:

#ifndef INTERFACE_H_
#define INTERFACE_H_

#include "main.h"


// 错误代码
typedef enum {
    INTERFACE_ERROR_NONE = 0,           // 无错误
    INTERFACE_ERROR_INVALID_PARAM,      // 无效参数
    INTERFACE_ERROR_NOT_INITIALIZED,    // 未初始化
    INTERFACE_ERROR_HARDWARE,           // 硬件错误
    INTERFACE_ERROR_ALREADY_CREATED,    // 实例已创建
    INTERFACE_ERROR_INSTANCE_NOT_FOUND, // 实例不存在
    INTERFACE_ERROR_ELSE                // 其他未知错误
} Interface_error_t;

// GPIO接口类
typedef struct{
        GPIO_TypeDef* port_m;
        uint16_t pin_m;
        
        Interface_error_t  (*SetPort)  (void *self, GPIO_TypeDef* port);
        GPIO_TypeDef*      (*GetPort)  (const void *self);
        Interface_error_t  (*SetPin)   (void *self , uint16_t pin);
        uint16_t           (*GetPin)   (const void *self);
} Interface;

Interface* Create_Interface(GPIO_TypeDef* port, uint16_t pin);
void destroy_Interface(Interface *interface);

#endif /*__INTERFACE_H__ */
#include "Interface.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/**
 * @brief  设置GPIO端口
 * @param  *self:Interface的实例对象
 * @return Interface错误代码
 * @note   
 */
Interface_error_t Interface_SetPort(void *self, GPIO_TypeDef* port)
{
        if(port == NULL) return INTERFACE_ERROR_INVALID_PARAM;
        
        Interface *interface = (Interface*)self;
        if(interface) interface->port_m = port;
        else return INTERFACE_ERROR_INVALID_PARAM;
        
        return INTERFACE_ERROR_NONE;
}

/**
 * @brief  获取GPIO端口
 * @param  *self:Interface的实例对象
 * @return GPIO_TypeDef* 端口指针
 * @note   
 */
GPIO_TypeDef* Interface_GetPort(const void *self)
{
        Interface *interface = (Interface*)self;
        return interface->port_m;
}
 
 /**
 * @brief  设置GPIO引脚
 * @param  *self:Interface的实例对象
 * @return Interface错误代码
 * @note   
 */
Interface_error_t Interface_SetPin(void *self, uint16_t pin)
{        
        Interface *interface = (Interface*)self;
        if(interface) interface->pin_m = pin;
        else return INTERFACE_ERROR_INVALID_PARAM;
        
        return INTERFACE_ERROR_NONE;
}

/**
 * @brief  获取GPIO引脚
 * @param  *self:Interface的实例对象
 * @return uint16_t 引脚
 * @note   
 */
uint16_t Interface_GetPin(const void *self)
{
        Interface *interface = (Interface*)self;
        return interface->pin_m;
}

/**
 * @brief  GPIO接口类的构造函数
 * @param  port:端口
 * @param  pin :引脚
 * @return 构造后的实例对象
 * @note   
 */
Interface* Create_Interface(GPIO_TypeDef* port, uint16_t pin)
{
        if (!port || pin == 0) return NULL;
        Interface *interface = (Interface *)malloc(sizeof(Interface));
        if(interface)
        {
                interface->port_m = port;
                interface->pin_m = pin;
                
                // 绑定方法
                interface->SetPort = Interface_SetPort;
                interface->GetPort = Interface_GetPort;
                interface->SetPin = Interface_SetPin;
                interface->GetPin = Interface_GetPin;
                return interface;
        }
        else return NULL;
}

/**
 * @brief  GPIO接口类的析构函数
 * @param  *interface:要释放的实例对象
 * @return 无
 * @note   
 */
void destroy_Interface(Interface *interface)
{
    free(interface);
}

以上代码实现了接口类的构造与析构函数,以及修改与获取其成员变量的成员函数。

2、定义一个LED灯类

点亮一个LED灯是嵌入式中的print("hello,world"),接下来尝试用面向对象的方法点亮一盏LED灯。

这是LED类的源代码:LED类源代码,其具体实现思路实际上与接口类类似。

// LED灯类
typedef struct{
        Interface *interface_m;       // GPIO接口类指针
        state_t state_m;              // 状态枚举
        GPIO_PinState OpenLevel_m;    // 开启电平(高电平点亮or低电平点亮)
        
        LED_error_t   (*SetInterface)   (void *self, Interface *interface);
        Interface*    (*GetInterface)   (const void *self);
        LED_error_t   (*Setstate)       (void *self, state_t state);
        state_t       (*Getstate)       (const void *self);
        LED_error_t   (*SetOpenLevel)   (void *self, GPIO_PinState state);
        GPIO_PinState (*GetOpenLevel)   (const void *self);
        
        LED_error_t   (*update)         (void *self);
        
        void          (*On)             (void *self);
        void          (*OFF)            (void *self);
        LED_error_t   (*Toggle)         (void *self);
} Led;

有三个成员变量,分别是接口类、状态的枚举(目前只有亮与灭两种状态,后续可以再添加呼吸灯或闪烁状态)、开启电平。

接口类即该LED灯用何端口与引脚控制。

开启电平是该LED灯是高电平点亮还是低电平点亮的标志,根据不同的硬件,移植该代码时可以选择不同的参数。

除了getset成员变量的方法外,LED灯还有四个成员函数,分别是“将LED灯设置为开启(ON)”、“将LED灯设置为关闭(OFF)”、“翻转LED状态(Toggle)”、“更新LED灯在现实硬件的状态(updata)。

在Toggle对应的成员函数中,只在LED状态state_mLED_OFF(关闭)或LED_ON(开启)时有效,在后续可能加上呼吸灯等状态时,这个函数不应起到任何效果。

在我的代码的设计中,将改变state_m状态与写电平的操作分开(即LED_Setstate只改状态,LED_Updata才实际写GPIO),这样做有以下几个重要好处:

  1. 逻辑与物理分离:状态改变是逻辑层的操作,而写电平是硬件层的操作。将它们分开可以使得状态管理不依赖于硬件操作,便于测试和逻辑重用。

  2.便于实现后续的闪烁与呼吸灯效果:闪烁与呼吸灯需要在中断或main函数中定时调用update函数,LED_Updata可以方便后续添加新的功能。

  3.状态回滚能力:将状态作为成员变量保存之后,后续可以方便的添加状态回滚的功能。

  4.操作性能优化:假设遇到有LED灯状态变化非常复杂的极端情况,可以将状态变化计算完后统一更新,免于每次变化状态都写电平,节省计算资源。

总的来说,这样的设计是为了后续可以方便的添加更多新的功能,提升了代码的可拓展性。也为测试代码提供了方便。

通过以上的代码,我们可以在main函数中构建LED灯类,并实现点亮LED灯的功能。

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  Led* led = Create_Led(Create_Interface(GPIOB, GPIO_PIN_3), LED_OFF);    // 创建LED类实例
  while (1)
  {
        led->Toggle(led);
        HAL_Delay(1000);
  }
}

3、定义一个按键类

cubemx完成按键的GPIO的初始化后,就可以开始定义一个按键类。

我希望按键类最后可以按照这种流程工作:

  1.使用按键类声明一个按键。

  2.在mian函数或中断中定期调用一个update函数用于检测按键电平的变化。

  3.用户根据项目需要编写对应按键的按键回调函数。

  按键类成员的设计思路:

  1.按键需要读取引脚电平,故应有GPIO接口类指针的成员。

  2.按键的电平状态判断,常见的思路就是使用状态机。在状态机中需要进行消抖、按下与松开的状态的判断,最后根据最近几次按下的时间长短与间隔判断是单击、双击、或长按的事件。所以需要有记录状态是何事件的成员。

  3.消抖与状态判断时,还有一些如有效电平、去抖时间、长按事件、双击间隔时间等常量需要定义。可以定义成员变量来保存这些常量。

  4.判断按键按下时间的方法使用时间戳的方法,所以还需要有时间戳记录的成员变量。

  5.用户需要根据实际项目需要,为不同的按键实例编写对应的按键回调函数,所以应有对应的修改回调函数指针成员变量的方法。

  所以按键类可以设计有这些成员与方法:

// 按键类
typedef struct{
    Interface *interface_m;            // GPIO接口类指针
    KeyState_t current_state_m;        // 当前状态
    KeyState_t last_stable_state_m;    // 上次稳定状态
    KeyEvent_t event_m;                // 当前事件
    
    KeyActiveLevel_t active_level_m;   // 有效电平
    uint32_t debounce_time_ms;         // 去抖时间(ms)
    uint32_t long_press_time_ms;       // 长按时间(ms)
    uint32_t double_click_time_ms;     // 双击间隔时间(ms)
        
    // 时间戳记录
    uint32_t last_state_change_time;   // 上一次状态改变时间
    uint32_t last_click_time;          // 上一次按下时间
        
    // 回调函数指针
    void (*on_click)(void *self);          // 单击回调函数
    void (*on_double_click)(void *self);   // 双击回调函数
    void (*on_long_press)(void *self);     // 长按回调函数
        
    // 方法函数指针
    KEY_error_t (*update)(void *self);                 // 更新状态函数
    GPIO_PinState (*get_pinstate)(const void *self);   // 获取引脚电平函数    
} Key;

  按键类完整源代码点击这里查看→ KEY按键类源代码

接下来就可以验证功能,可以再main函数中这样写:

 Led* led = NULL;  // 声明一个全局LED指针变量

 void Key_click_callback(void *self)
 {
         led->Toggle(led);
 }

int main(void)
{
  HAL_Init();             // hal库初始化
  SystemClock_Config();   // 系统时钟初始化
  MX_GPIO_Init();         // GPIO初始化
  
  // 创建KEY类实例
  Key* key = Create_Key(Create_Interface(GPIOB, GPIO_PIN_12), KEY_ACTIVE_LOW);
  // 将按键按键的回调函数设置为Key_click_callback
  key->set_callback(key, Key_click_callback, KEY_EVENT_CLICK); 
  
  // 创建LED类实例
led = Create_Led(Create_Interface(GPIOB, GPIO_PIN_3), LED_OFF);
  
  while (1)
  {
        key->update(key);
        HAL_Delay(10);
  }
}

这样当我们点击按键key时,就会触发Key_click_callback回调函数,回调函数内就是将led开关反转。编译烧录后就可以通过按键控制反转LED灯了。

本文暂时就介绍这三种类的定义方式,但已经足以验证嵌入式中使用面向对象编程方法是十分可行的。

面向对象方法的优势与局限

通过以上的实验,可以发现在嵌入式的编程中确实可以使用类似面向对象的方式进行编码。这样做都有什么优缺点呢?我认为有以下几个优缺点。

  1. 优点:

    1. 封装性:将数据与操作封装于类中,结构清晰;
    2. 可复用性:GPIO接口类可被多个设备类继承,减少代码冗余;
    3. 可移植性:模块化设计便于跨平台移植;
    4. 可测试性:逻辑与硬件分离,便于单元测试。
  2. 缺点:

    1. 语法繁琐,需显式传递“this”指针;
    2. 函数指针增加内存开销;
    3. 缺乏访问控制机制;
    4. 编译时类型检查较弱;

系统集成与验证

  前文中,所有代码的开发与实验都是在STM32F103C8T6的芯片中以裸机的方式完成的,接下来尝试其他的芯片与操作系统。

  在STM32F407ZGT6的移植尝试    在cubemx中初始化对应外设的GPIO口后,直接拷贝了上文编写的源代码文件到新生成的文件,并在main.c中以如下方式编写代码。

#include "main.h"
#include "gpio.h"
#include "led.h"
#include "key.h"
#include "Interface.h"

Led* led1 = NULL;
Led* led2 = NULL;

void Key1_click_callback(void *self)            // 单击按键1打开led1 
 {
     led1->On(led1);
 }
  void Key1_double_click_callback(void *self)   // 双击按键1关闭led1
 {
      led1->OFF(led1);
 }
  void Key1_long_press_callback(void *self)     // 长按按键1反转led1
 {
     led1->Toggle(led1);
 }
 
 void Key2_click_callback(void *self)           // 单击按键2打开led2
 {
     led2->On(led2);
 }
 void Key2_double_click_callback(void *self)    // 双击按键2关闭led2
 {
 {
     led2->OFF(led2);
 }
 void Key2_long_press_callback(void *self)      // 长按按键2反转led2
 {
     led2->Toggle(led2);
 }
 
 int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
     
    // 创建按键实例
    Key* key1 = Create_Key(Create_Interface(GPIOE, GPIO_PIN_4), KEY_ACTIVE_LOW);
    Key* key2 = Create_Key(Create_Interface(GPIOE, GPIO_PIN_3), KEY_ACTIVE_LOW);
      
    // 关联按键1的回调函数    
    key1->set_callback(key1, Key1_click_callback, KEY_EVENT_CLICK); 
    key1->set_callback(key1, Key1_double_click_callback, KEY_EVENT_DOUBLE_CLICK); 
    key1->set_callback(key1, Key1_long_press_callback, KEY_EVENT_LONG_PRESS); 
    
    // 关联按键2的回调函数
    key2->set_callback(key2, Key2_click_callback, KEY_EVENT_CLICK); 
    key2->set_callback(key2, Key2_double_click_callback, KEY_EVENT_DOUBLE_CLICK); 
    key2->set_callback(key2, Key2_long_press_callback, KEY_EVENT_LONG_PRESS); 
    
    // 创建led实例
    led1 = Create_Led(Create_Interface(GPIOF, GPIO_PIN_9), LED_OFF);
    led2 = Create_Led(Create_Interface(GPIOF, GPIO_PIN_10), LED_OFF);
    
    while (1)
    {
        // 循环更新按键状态,当然这个放在中断里会使代码逻辑更加清晰
        key1->update(key1);
        key2->update(key2);
        HAL_Delay(10);
    }
}

  移植后成功实现了该功能!

  在FreeRTOS操作系统的移植尝试

  同样是使用cubemx配置GPIO引脚与FreeRTOS操作系统。

  FreeRTOS的移植代码

  在生成对应代码后,拷贝上文定义的三个源代码文件,用类似的方法也成功移植实现了相同的功能。


本文成功在嵌入式C语言环境中实现了面向对象的编程模型,并通过LED与按键类的设计验证了其可行性。该方法在代码组织、模块化与可维护性方面具有明显优势,适用于中大型嵌入式项目开发。

参考资料

ARM嵌入式系统中面向对象的模块编程方法

与deepseek的对话对本文有很大的参考