关注公众号,回复“入门资料”获取单片机入门到高级开挂教程
开发板带你入门,我们带你飞
文 | 无际(微信:2777492857)
全文约6017字,阅读大约需要 15 分钟
这段时间在研究esp32的代码,他们提供的库,非常面向对象,不得不说,写这个库的人,水平很高。
你可能会想:“搞错没?C 语言?面向对象?那不是 C++、Java、Python 这些语言的专属技能吗?咱们 C 语言,朴实无华,一把梭哈干到底,讲究的就是一个快、准、狠,要啥自行车?”
此言差矣!
C 语言天生不支持 OOP 的所有特性,它没有类(class)、没有继承(inheritance)、没有多态(polymorphism)的直接语法支持。
但这并不意味着我们就得永远停留在“全局变量满天飞,函数调用理不清的史前时代。
特别是当你的项目越来越大,逻辑越来越复杂,比如我们无际单片机的项目6(4G+WiFi+Lora报警网关)。
要同时管理好几个串口、驱动不同型号的传感器、还要处理复杂的协议栈……这时候,你可能就会怀念起 OOP 带来的那种模块化、结构化的清爽感了。
如果把这些用全局变量的方式,全都挤在一个 global_vars.h 里,改一处怕影响全局,查问题如同大海捞针。
一个函数几百行,各种 if-else 嵌套,逻辑跳转像迷宫,维护时看到代码都摇头,想加个新功能?嗯,先祈祷别改出新 Bug 吧。
写了个 UART1 的驱动,现在要加 UART2?好,复制粘贴大法好!然后改改改……改漏一个地方,调试半天。
是不是感觉脑瓜有点疼?
别怕,今天咱们就用 C 语言,通过一些巧妙的技巧和约定,来模拟实现 OOP 的核心思想:封装、继承(有限模拟)和多态(有限模拟),让你的单片机代码也能“优雅”起来。这绝不是为了炫技,而是实实在在为了提高代码的可读性、可维护性和可重用性。
准备好了吗?发车!
一、封装 (Encapsulation):把数据和操作“关”在一起
OOP 的第一个核心思想是封装,简单说就是把数据(属性)和操作这些数据的方法(函数)捆绑在一起,形成一个独立的“对象”,并且可以隐藏内部实现细节,只暴露必要的接口给外部使用。
在 C 语言里,我们怎么模拟这个“对象”呢?答案就是我们最熟悉的 struct(结构体)!
1. 用 struct 封装数据成员
结构体天生就是用来打包不同类型数据的。我们可以把一个“对象”所需要的所有状态、配置信息等都定义在结构体里。
// 比如我们要控制一个 LED 灯
typedef struct
{
// 数据成员 (属性)
volatile uint8_t* port; // LED 连接的端口寄存器地址
uint8_t pinMask; // LED 连接的引脚掩码
int isOn; // LED 当前状态 (0: off, 1: on)
// ... 可能还有其他属性,比如亮度、闪烁模式等
} Led_t;
看,Led_t 这个结构体,就把控制一个 LED 所需的核心数据都“包”起来了。现在,我们可以创建这个结构体的实例(变量),每个实例就代表一个具体的 LED 对象。
Led_t redLed;
Led_t greenLed;
2. 用函数封装操作方法
光有数据还不行,我们还需要操作这些数据的方法。在 C 语言里,我们定义一系列函数,这些函数专门用来操作特定结构体的实例。关键在于:把结构体实例的指针作为第一个参数传递给这些函数,这就像是 C++ 或 Java 中的 this 或 self 指针,明确了这个函数是作用于哪个“对象”的。
// 初始化 LED 对象
void Led_Init(Led_t* self, volatile uint8_t* port, uint8_t pinMask)
{
if (!self) return; // 防御性编程,老铁稳!
self->port = port;
self->pinMask = pinMask;
self->isOn = 0; // 默认关闭
// 这里可能还有 GPIO 初始化代码
// *self->port &= ~self->pinMask; // 假设低电平点亮,先关闭
printf("LED on port %p, pin mask 0x%X initialized.\n", self->port, self->pinMask);
}
// 点亮 LED
void Led_TurnOn(Led_t* self)
{
if (!self) return;
// GPIO 操作,点亮 LED
// *self->port |= self->pinMask; // 假设高电平点亮
self->isOn = 1;
printf("LED (port %p, pin 0x%X) turned ON.\n", self->port, self->pinMask);
}
// 关闭 LED
void Led_TurnOff(Led_t* self)
{
if (!self) return;
// GPIO 操作,关闭 LED
// *self->port &= ~self->pinMask;
self->isOn = 0;
printf("LED (port %p, pin 0x%X) turned OFF.\n", self->port, self->pinMask);
}
// 获取 LED 状态
int Led_IsOn(Led_t* self)
{
if (!self) return -1; // 返回错误码或特定值
return self->isOn;
}
看到了吗?Led_Init、Led_TurnOn、Led_TurnOff、Led_IsOn 这些函数,都接收一个 Led_t* self 参数。通过这个 self 指针,函数内部就能访问和修改对应 LED 实例的数据了。
3. 实现数据隐藏(有限的)
纯粹的 C 语言 struct 成员默认都是“公开”的,谁拿到结构体指针都能直接访问。不过,我们可以通过一些约定和技巧来模拟“隐藏”。
(1)接口与实现分离
将结构体的定义放在 .c 文件内部,或者只在内部头文件中定义。在公开的 .h 头文件中,只提供一个**不透明指针 (opaque pointer)** 的类型声明。
// led.h (公开接口)
typedef struct Led Led_t; // 不透明指针声明
// 提供操作函数声明
Led_t* Led_Create(volatile uint8_t* port, uint8_t pinMask); // 工厂函数创建对象
void Led_Destroy(Led_t* self);
void Led_TurnOn(Led_t* self);
void Led_TurnOff(Led_t* self);
int Led_IsOn(Led_t* self);
// led.c (内部实现)
// 结构体完整定义只在 .c 文件中
struct Led
{
volatile uint8_t* port;
uint8_t pinMask;
int isOn;
// 可能还有一些内部状态变量,外部不需要知道
int internalCounter;
};
Led_t* Led_Create(volatile uint8_t* port, uint8_t pinMask)
{
Led_t* self = (Led_t*)malloc(sizeof(Led_t)); // 注意内存分配,嵌入式中可能用静态池
if (self)
{
// 这里调用内部初始化函数,或者直接初始化
self->port = port;
self->pinMask = pinMask;
self->isOn = 0;
self->internalCounter = 0; // 初始化内部变量
// GPIO 初始化...
printf("LED object created.\n");
}
return self;
}
void Led_Destroy(Led_t* self)
{
if (self)
{
// 清理工作...
free(self); // 释放内存
printf("LED object destroyed.\n");
}
}
// Led_TurnOn, Led_TurnOff, Led_IsOn 函数实现同上...
// ...
// 内部辅助函数,只在 .c 文件中可见
static void internalHelperFunction(Led_t* self)
{
// 这个函数外部无法调用
self->internalCounter++;
}
通过这种方式,外部代码只能通过 Led_t* 指针和 led.h 中声明的函数来操作 LED 对象,无法直接访问 struct Led 的内部成员(比如 internalCounter),这就实现了很好的信息隐藏和封装。当然,内存管理(malloc/free)在单片机中要特别小心,通常会使用静态分配、内存池或者在特定区域分配。
(2)使用 static 关键字
对于只在模块内部使用的辅助函数(如 internalHelperFunction),用 static 修饰,使其作用域限制在当前 .c 文件,外部无法调用。
通过以上方法,我们成功地用 C 语言模拟了 OOP 的封装特性!数据和操作绑定,接口清晰,实现细节隐藏,代码模块化程度大大提高。是不是感觉清爽多了?
二、继承 (Inheritance) / 组合 (Composition):代码复用的“高级”玩法
继承是 OOP 中实现代码复用和扩展的重要机制。
一个类可以继承另一个类(父类/基类)的属性和方法,并可以添加自己的特性或覆盖父类的方法。
在 C 语言中,直接模拟类继承比较困难且容易出错,但我们有两种常用的替代方案:结构体嵌套(组合)和 利用结构体包含函数指针(接口继承)。
1. 结构体嵌套(组合优先)
这是 C 语言中最自然、最推荐的方式,它体现了“组合优于继承”的设计原则。如果一个“类”(结构体)想要复用另一个“类”的功能,可以将后者的实例作为前者的一个成员变量。
假设我们现在要定义一个 RgbLed_t,它是一个 RGB LED,可以看作是包含了一个基础 LED 功能(开关),并增加了颜色控制。
// 基础 LED 结构体 (来自上面)
typedef struct
{
volatile uint8_t* port;
uint8_t pinMask;
int isOn;
} Led_t;
// ... Led_Init, Led_TurnOn, Led_TurnOff ...
// RGB LED 结构体
typedef struct
{
// 包含一个基础 LED 作为成员 (组合)
Led_t baseLed; // 约定:通常放在第一个位置
// RGB LED 特有的属性
uint8_t redValue;
uint8_t greenValue;
uint8_t blueValue;
// 可能还有控制 R, G, B 三个通道的引脚信息等
volatile uint8_t* redPort; uint8_t redPinMask;
volatile uint8_t* greenPort; uint8_t greenPinMask;
volatile uint8_t* bluePort; uint8_t bluePinMask;
} RgbLed_t;
// 初始化 RGB LED
void RgbLed_Init(RgbLed_t* self, volatile uint8_t* port, uint8_t pinMask, /* RGB pins... */)
{
if (!self) return;
// 初始化基础 LED 部分 - 复用!
Led_Init(&self->baseLed, port, pinMask);
// 初始化 RGB 特有部分
self->redValue = 0;
self->greenValue = 0;
self->blueValue = 0;
// ... 初始化 RGB 引脚 ...
printf("RGB LED initialized.\n");
}
// 设置 RGB 颜色
void RgbLed_SetColor(RgbLed_t* self, uint8_t r, uint8_t g, uint8_t b)
{
if (!self) return;
self->redValue = r;
self->greenValue = g;
self->blueValue = b;
// ... 通过 PWM 或其他方式控制 RGB 引脚输出 ...
printf("RGB LED color set to R:%d G:%d B:%d\n", r, g, b);
// 如果设置了颜色,通常意味着灯应该是亮的
if (!self->baseLed.isOn)
{
// 可以选择在这里自动打开基础 LED,或者让用户显式调用 TurnOn
// Led_TurnOn(&self->baseLed); // 注意,这里可能需要根据实际逻辑调整
}
}
// RGB LED 的 TurnOn 可能有特殊逻辑,比如恢复上次颜色
void RgbLed_TurnOn(RgbLed_t* self)
{
if (!self) return;
// 先调用基础 LED 的 TurnOn (如果需要控制总开关)
Led_TurnOn(&self->baseLed);
// 可能还需要根据 R,G,B 值重新设置 PWM 等
RgbLed_SetColor(self, self->redValue, self->greenValue, self->blueValue); // 恢复颜色
printf("RGB LED turned ON (explicitly).\n");
}
// TurnOff 同理,可能需要关闭 PWM 并调用基础 TurnOff
void RgbLed_TurnOff(RgbLed_t* self)
{
if (!self) return;
// 关闭 PWM 输出...
// 调用基础 LED 的 TurnOff
Led_TurnOff(&self->baseLed);
printf("RGB LED turned OFF.\n");
}
看到没?RgbLed_t 通过包含一个 Led_t baseLed 成员,复用了基础 LED 的数据和(通过调用相应函数)操作。RgbLed_Init 里直接调用 Led_Init 来初始化公共部分。这种方式结构清晰,关系明确,不容易出错。
一个 C 语言的小技巧(谨慎使用):如果你把基类结构体放在派生类结构体的第一个位置(如 Led_t baseLed; 在 RgbLed_t 的开头),那么 RgbLed_t* 指针在数值上等于其内部 baseLed 成员的地址。
这意味着,理论上你可以将 RgbLed_t* 指针强制类型转换为 Led_t* 并传递给期望 Led_t* 的函数。
RgbLed_t myRgbLed;
RgbLed_Init(&myRgbLed, ...);
// 因为 baseLed 在首位,可以这样(但不推荐直接这么用,封装性不好):
// Led_TurnOn((Led_t*)&myRgbLed); // 强制转换,调用基类方法
// 更好的方式是通过 RgbLed 自己的方法来间接调用:
RgbLed_TurnOn(&myRgbLed); // 内部会调用 Led_TurnOn(&self->baseLed)
虽然这个“强制转换”技巧看起来很像 C++ 的向上转型,但在 C 语言中依赖内存布局,可移植性和安全性稍差,更推荐通过封装好的派生类函数来调用基类功能。
2. 接口继承(模拟)
如果你更需要的是行为的扩展和统一接口,可以使用函数指针。这更接近于面向接口编程。
// 定义一个“设备”接口,包含通用的操作函数指针
typedef struct
{
void (*init)(struct Device* self);
void (*enable)(struct Device* self);
void (*disable)(struct Device* self);
// 其他通用操作...
} Device_t;
// 特定设备,比如一个传感器
typedef struct
{
Device_t baseDevice; // 包含通用设备接口 (依然是组合)
int (*read)(struct Sensor* self); // 传感器特有的读取方法
// 传感器特有数据
int lastValue;
void* privateData; // 指向具体传感器驱动数据的指针
} Sensor_t;
// 初始化函数需要设置这些函数指针
void Sensor_Init(Sensor_t* self, /* specific sensor params */)
{
self->baseDevice.init = Sensor_SpecificInit; // 指向具体的初始化实现
self->baseDevice.enable = Sensor_SpecificEnable;
self->baseDevice.disable = Sensor_SpecificDisable;
self->read = Sensor_SpecificRead;
// ... 初始化 privateData 和 lastValue ...
}
// 具体的实现函数
static void Sensor_SpecificInit(Device_t* base)
{
Sensor_t* self = (Sensor_t*)base; // 需要转换回来
// ... 传感器硬件初始化 ...
}
// ... Sensor_SpecificEnable, Sensor_SpecificDisable, Sensor_SpecificRead 实现 ...
这种方式下,你可以通过 baseDevice 的指针来调用通用的 init、enable、disable 方法,而具体的行为由初始化时设置的函数指针决定。这为我们接下来要谈的“多态”打下了基础。
三、多态 (Polymorphism):一种接口,多种形态
多态是 OOP 的精髓之一,允许我们使用一个通用的接口来处理不同类型的对象,而这些对象会各自执行其特定的行为。在 C 语言中,实现多态的主要武器就是 函数指针。
1. 利用函数指针实现多态
接上文的 Device_t 和 Sensor_t 例子,假设我们还有另一个设备 Actuator_t ,它也实现了 Device_t 接口。
typedef struct
{
Device_t baseDevice;
void (*performAction)(struct Actuator* self, int actionCode);
// 执行器特有数据
int currentState;
} Actuator_t;
// Actuator 初始化函数,设置函数指针
void Actuator_Init(Actuator_t* self, /* ... */)
{
self->baseDevice.init = Actuator_SpecificInit;
self->baseDevice.enable = Actuator_SpecificEnable;
self->baseDevice.disable = Actuator_SpecificDisable;
self->performAction = Actuator_SpecificPerformAction;
// ... 初始化 ...
}
// ... Actuator_Specific... 函数实现 ...
现在,你可以创建一个 Device_t* 类型的数组或列表,里面可以存放指向 Sensor_t 对象(的 baseDevice 成员)的指针,也可以存放指向 Actuator_t 对象(的 baseDevice 成员)的指针。
Device_t* deviceList[10];
int deviceCount = 0;
Sensor_t mySensor;
Sensor_Init(&mySensor, /* ... */);
deviceList[deviceCount++] = &mySensor.baseDevice; // 存入基类接口指针
Actuator_t myActuator;
Actuator_Init(&myActuator, /* ... */);
deviceList[deviceCount++] = &myActuator.baseDevice; // 存入基类接口指针
// 统一处理所有设备
for (int i = 0; i < deviceCount; ++i)
{
// 调用通用的 enable 方法,具体执行哪个函数取决于指针指向的对象类型
deviceList[i]->enable(deviceList[i]);
}
在这个循环里,deviceList[i]->enable(deviceList[i]) 这一行代码,对于 Sensor 对象,会调用 Sensor_SpecificEnable;对于 Actuator 对象,会调用 Actuator_SpecificEnable。
这就是多态!同一个 enable 调用,根据对象的实际“类型”(由初始化时设置的函数指针决定),表现出不同的行为。是不是有点小激动?感觉自己用 C 写出了 C++ 的 virtual 函数的味道!
2. 注意事项
•函数指针开销:函数指针调用通常比直接函数调用稍微慢一点点(需要一次额外的内存读取和间接跳转),但在大多数单片机应用中,这点性能开销几乎可以忽略不计,除非是在极度性能敏感的中断服务程序或循环内部。
•内存开销:每个对象实例都需要存储函数指针,这会增加一定的 RAM 占用。如果对象数量巨大,需要评估这个开销。
•类型安全:C 语言的函数指针不像 C++ 的虚函数那样有编译器的强类型检查。你需要确保传递给函数的指针确实是期望的类型(或者至少其内存布局兼容,如前面提到的结构体首成员技巧),并且初始化时正确设置了函数指针。否则,运行时可能会发生难以预料的错误,比如跑飞。
四、实战演练与注意事项
好了,理论武装得差不多了,我们来总结一下在单片机 C 语言中实践 OOP 风格编程的关键点和建议:
1.结构体是你的“类”:用 struct 封装数据。
2.函数操作结构体实例:函数第一个参数通常是 struct YourType* self。
3.封装靠接口分离:用 .h 提供接口(函数声明,可能用不透明指针),.c 实现细节(结构体定义,函数实现,static 内部函数)。
4.组合优于继承:用结构体嵌套(成员变量)来复用和扩展功能。
5.多态靠函数指针:在结构体中包含函数指针成员,初始化时指向具体实现,实现统一接口下的不同行为。
6.命名约定很重要:比如 TypeName_FunctionName(TypeName* self, ...) 格式,保持一致性,提高可读性。
7.内存管理需谨慎:在嵌入式环境中,动态内存分配(malloc/free)要小心碎片和失败风险。优先考虑静态分配、内存池或对象池。
8.不要过度设计:这些 OOP 模拟技巧是为了解决复杂性问题。对于简单的功能或资源极度受限的 MCU,传统的 C 风格可能更直接高效。别为了“面向对象”而“面向对象”。这是一种思想和工具,不是银弹。
9.保持务实:我们是在 C 语言的框架内“模拟” OOP,它不是真正的 OOP。要理解其局限性,比如没有构造/析构函数、没有原生访问控制符等。
总结:C 语言也能玩出花,但别忘了根本
用 C 语言模拟 OOP,就像是给你的老捷达装上了涡轮增压和运动悬挂——它依然是捷达,但跑起来确实更带劲,也更能应对复杂的路况(项目)。
这种方法能显著提升大型嵌入式 C 项目的结构化程度、可维护性和不同硬件平台的兼容性。
大家去看STM32库,esp-adf之类代码,会发现有大量这种OOP的编程思维。
当你下次面对一个盘根错节的 C 代码库,或者要开始一个可能变得庞大的新项目时,不妨试试这些“C 式 OOP”的技巧。
end
下面是更多无际原创的个人成长经历、行业经验、技术干货。
1.电子工程师是怎样的成长之路?10年5000字总结
2.如何快速看懂别人的代码和思维
3.单片机开发项目全局变量太多怎么管理?
4.C语言开发单片机为什么大多数都采用全局变量的形式?
5.单片机怎么实现模块化编程?实用程度让人发指!
6.c语言回调函数的使用及实际作用详解
7.手把手教你c语言队列实现代码,通俗易懂超详细!
8.c语言指针用法详解,通俗易懂超详细!