C++嵌入式开发入门
前言
C++大体上与C类似,是一门广泛应用的嵌入式开发语言。但因其支持面向对象特性,在嵌入式开发的部分领域,尤其是复杂系统的构建中更具优势。本文将以ESP-IDF为例,简要介绍C++的封装与多态在嵌入式开发中的应用。
本文基于ESP32-S3,编译环境是ESP-IDF v5.3.1
本文的例子来源于AS608指纹模块在ESP-IDF环境下的驱动
正文
封装与抽象
什么是封装?
封装是面向对象编程中的把数据和操作数据的函数绑定在一起的一个概念,这样能避免受到外界的干扰和误用,从而确保了安全。
数据封装是一种把数据和操作数据的函数捆绑在一起的机制,数据抽象是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制。
C++ 通过创建类来支持封装。类用于指定对象的形式,是一种用户自定义的数据类型,它是一种封装了数据和函数的组合。类中的数据称为成员变量,函数称为成员函数。类可以被看作是一种模板,可以用来创建具有相同属性和行为的多个对象。
举一个现实生活中的真实例子,比如一台电视机,可以打开和关闭、切换频道、调整音量、添加外部组件(如音响、录像机、DVD 播放器),但是我们不知道它的内部实现细节,也就是说,我们并不知道其如何通过缆线接收信号,如何转换信号,并最终显示在屏幕上。
这样,我们就可以说电视分开了其内部实现和外部接口,我们无需知道其内部实现原理,直接通过外部接口(如遥控器)就可以操控电视。就C++而言,C++为数据抽象提供了可能。
为什么要封装?
数据抽象有两个重要的优势:
- 类的内部受到保护,不会因无意的用户级错误导致对象状态受损。
- 类实现可能随着时间的推移而发生变化,以便应对不断变化的需求,或者应对那些要求不改变用户级代码的错误。
如果只在类的私有部分定义数据成员,编写该类的作者就可以随意更改数据。如果实现发生改变,则只需要检查类的代码,看看这个改变会导致哪些影响。
上述内容在C中都可以通过头文件实现,为什么还要C++?
在C语言中,可使用名称相同的.c和.h文件代表一个硬件部件、规则协议或者外设。
C++使用访问标签管理权限,即把类的成员分为了两个部分:可被外部直接访问和不可被外部直接访问。访问标签使得抽象是强制的,如果在其他部分调用类中的私有成员,编译器会抛出错误。
C的头文件中也可以通过程序编写规范等方式实现只调用特定的接口,但这毕竟是一种非强制性的举措,如果开发人员疏忽,可能就会误调用我们不希望其调用的接口,导致程序出现错误。
以笔者的项目为例
笔者需要在程序中调用一个使用串口通信的指纹模块,而指纹模块本身具有多种功能,如初始化、校验密钥、对比指纹库等。但笔者调用时并不关心其内部实现,而只需要让指纹模块完成其该完成的动作,如添加指纹、刷指纹、删除指纹等。这样一来,我们就能把注意力从底层逻辑的实现转向模块与模块之间的配合,节省了大量的精力,便于我们管理大型项目。
上文提到,笔者使用类将指纹模块封装为一个对象,使用private权限封装内部变量和函数;使用public权限暴露指纹模块对外接口。这样一来,我们就只需要关心怎么”用“而非”怎么实现“。
class IDENTIFIER
{
private:
uint32_t IDaddr = 0XFFFFFFFF;
uint32_t IDpwd = 0x00000000; //口令验证
typedef struct
{
uint16_t pageID; //指纹ID
uint16_t mathscore; //匹配得分
}SearchResult;
typedef struct
{
uint16_t PS_max; //指纹最大容量
uint8_t PS_level; //安全等级
uint32_t PS_addr;
uint8_t PS_size; //通讯数据包大小
uint8_t PS_N; //波特率基数N
}SysPara;
void SendHead(void);
void SendAddr(void);
void SendFlag(uint8_t flag);
void SendLength(uint16_t length);
void Sendcmd(uint8_t cmd);
void SendCheck(uint16_t check);
uint8_t *JudgeStr(); //判断中断接收的数组有没有应答包
bool AS608_Check(void); //连接检查
uint8_t PS_GetImage(void); //录入图像
uint8_t PS_GenChar(uint8_t BufferID); //生成特征
uint8_t PS_Match(void); //精确比对两枚指纹特征
uint8_t PS_Search(uint8_t BufferID,uint16_t StartPage,uint16_t PageNum,SearchResult *p); //搜索指纹
uint8_t PS_RegModel(void); //合并特征(生成模板)
uint8_t PS_StoreChar(uint8_t BufferID,uint16_t PageID); //储存模板
uint8_t PS_DeletChar(uint16_t PageID,uint16_t N); //删除模板
uint8_t PS_Empty(void); //清空指纹库
uint8_t PS_WriteReg(uint8_t RegNum,uint8_t DATA); //写系统寄存器
uint8_t PS_ReadSysPara(SysPara *p); //读系统基本参数
uint8_t PS_SetAddr(uint32_t addr); //设置模块地址
uint8_t PS_WriteNotepad(uint8_t NotePageNum,uint8_t *content); //写记事本
uint8_t PS_ReadNotepad(uint8_t NotePageNum,uint8_t *note); //读记事
uint8_t PS_HighSpeedSearch(uint8_t BufferID,uint16_t StartPage,uint16_t PageNum,SearchResult *p); //高速搜索
uint8_t PS_ValidTempleteNum(uint16_t *ValidN); //读有效模板个数
uint8_t PS_HandShake(uint32_t *PS_Addr); //与AS608模块握手
uint32_t PS_GetRandomCode(); //让模块发送一个随机数
const char *EnsureMessage(uint8_t ensure); //确认码错误信息解析
void ShowErrMessage(uint8_t ensure);
public:
IDENTIFIER(); //构造函数,相当于对象初始化
~IDENTIFIER(); //析构函数,对象销毁时用于回收资源
void Add_FR(void); //添加指纹
void press_FR(void); //刷指纹
void Del_FR(void); //删除指纹
void Del_FR_Lib(void); //删除所有指纹
};
可见,指纹模块对外暴露的接口只有四个函数,还有构造与析构函数。这样,调用时只需要在四个函数中选择,实现了程序的解耦合,让程序易读。
此外,构造函数在初始化对象时自动被调用,可以用于一些初始值和初始配置的设置,如注册UART资源等。析构函数在对象被销毁时自动调用,用于回收相对应的系统资源。但由于嵌入式开发中,绝大多数对象一经调用就永不销毁,所以用到析构函数的情况很少。
下面是这个类被实例化为对象的具体案例:
void IDtask(void *arg)
{
IDENTIFIER identifier; //创建指纹识别器对象,也就是对象的实例化
identifier.Add_FR();
while (1)
{
identifier.press_FR();
#ifdef TEST //测试用,如果宏定义了TEST,就会执行printf
printf("\nIDtask任务调用\n");
#endif
vTaskDelay(5000/portTICK_PERIOD_MS);
}
}
对象一经实例化,我们就可以调用其对外暴露的接口了。如identifier.Add_FR( )
。然而,如果我们尝试在这个函数中调用identifier.IDaddr
,无论是读或写,都会导致编译器编译失败。
重载
重载声明是指一个与之前已经在该作用域内声明过的函数或方法具有相同名称的声明,但是它们的参数列表和定义(实现)不相同。
当调用一个重载函数或重载运算符时,编译器通过比较所使用的参数类型与定义中的参数类型,决定选用最合适的定义。
没看懂?举个例子吧。以下是笔者通过串口发送信息的三个函数,分别用于发送8位、16位和32位数据。
void IDUARTwrite_Bytes(uint8_t data);
void IDUARTwrite_Bytes(uint16_t data);
void IDUARTwrite_Bytes(uint32_t data);
这三个函数的具体实现也不同:
void IDUARTwrite_Bytes(uint8_t data)
{
uart_write_bytes(UART_NUM_ID, &data, 1);
}
void IDUARTwrite_Bytes(uint16_t data)
{
uint8_t data1 = data >> 8;
uart_write_bytes(UART_NUM_ID, &data1, 1);
uart_write_bytes(UART_NUM_ID, &data, 1);
}
void IDUARTwrite_Bytes(uint32_t data)
{
uint8_t data1 = data >> 24;
uint8_t data2 = data >> 16;
uint8_t data3 = data >> 8;
uart_write_bytes(UART_NUM_ID, &data1, 1);
uart_write_bytes(UART_NUM_ID, &data2, 1);
uart_write_bytes(UART_NUM_ID, &data3, 1);
uart_write_bytes(UART_NUM_ID, &data, 1);
}
那么编译器在编译时如何区分名字相同的函数呢?答案就是函数传入参数的类型和顺序。这样,我们就可以让编译器通过函数的传入参数自动区分需要调用的函数,以此实现程序的简化,可读性更强。
写了这么多,应该怎么应用呢?
以下是笔者的项目结构,按从功能到实现的顺序画的思维导图:
(可以拖动,不妨尝试一下)
---
markmap:
colorFreezeLevel: 3
---
# 指纹锁功能
## 指纹识别
### AS608指纹模块
- UART通信
## 开门动作
### 串口总线舵机
- UART通信
## 动作音效
- SU03T语音模块或者I2S通信的功放
## 物联网通信
- MQTT作为应用协议
- WIFI作为通信协议
别人是怎么在嵌入式开发中应用面向对象的设计方法的?
笔者在知网找到一篇文章:《面向对象编程方法在Cotex-M3内核芯片程序开发中的应用》。这篇文章以一款自动校时电子钟项目为例,讲了讲如何在嵌入式环境中使用面向对象的编程思想。总的来说,作者首先把各种硬件通信协议单独列出,形成通信类,再把与功能有关的应用聚合成功能类。因为通信协议都是标准的,所以在与通信协议有关的程序编写完成后,其他应用可以复用这些通信模块。作者写得比较细,连通信协议的方法声明都写到文章里了,值得一看。
后记
C++的面向对象特性还有很多,如虚函数、类的继承等。但由于嵌入式开发有时并不需要这么多的特性,所以本文暂且按下不表,后续笔者用到时会再写一篇。
此外,C++有些功能需要在运行时进行操作,这对单片机而言可能会是较大的开销,因此笔者不建议使用C++的运行时特性。