|
关注公众号,回复“入门资料”获取单片机入门到高级开挂教程
开发板带你入门,我们带你飞
文 | 无际(微信:2777492857)
全文约6017字,阅读大约需要 15 分钟
这段时间在研究esp32的代码,他们提供的库,非常面向对象,不得不说,写这个库的人,水平很高。
你可能会想:“搞错没?C 语言?面向对象?那不是 C++、Java、Python 这些语言的专属技能吗?咱们 C 语言,朴实无华,一把梭哈干到底,讲究的就是一个快、准、狠,要啥自行车?”
此言差矣!
C 语言天生不支持 OOP 的所有特性,它没有类(class)、没有继承(inheritance)、没有多态(polymorphism)的直接语法支持。
但这并不意味着我们就得永远停留在“全局变量满天飞,函数调用理不清的史前时代。
特别是当你的项目越来越大,逻辑越来越复杂,比如我们无际单片机的项目6(4G+WiFi+Lora报警网关)。
5zniadfxgov64039514825.png
要同时管理好几个串口、驱动不同型号的传感器、还要处理复杂的协议栈……这时候,你可能就会怀念起 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.
", self->port, self->pinMask);}// 点亮 LEDvoid 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.
", self->port, self->pinMask);}// 关闭 LEDvoid 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.
", 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 (内部实现)#include "led.h"#include // for malloc/free, if using dynamic memory#include // 结构体完整定义只在 .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.
"); } return self;}void Led_Destroy(Led_t* self){ if (self) { // 清理工作... free(self); // 释放内存 printf("LED object destroyed.
"); }}// 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 LEDvoid 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.
");}// 设置 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
", 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).
");}// TurnOff 同理,可能需要关闭 PWM 并调用基础 TurnOffvoid RgbLed_TurnOff(RgbLed_t* self){ if (!self) return; // 关闭 PWM 输出... // 调用基础 LED 的 TurnOff Led_TurnOff(&self->baseLed); printf("RGB LED turned OFF.
");}
看到没?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 { // 调用通用的 enable 方法,具体执行哪个函数取决于指针指向的对象类型 deviceList->enable(deviceList);}
在这个循环里,deviceList->enable(deviceList) 这一行代码,对于 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
xfopdvn2j2y64039514925.jpg
下面是更多无际原创的个人成长经历、行业经验、技术干货。
1.电子工程师是怎样的成长之路?10年5000字总结
2.如何快速看懂别人的代码和思维
3.单片机开发项目全局变量太多怎么管理?
4.C语言开发单片机为什么大多数都采用全局变量的形式?
5.单片机怎么实现模块化编程?实用程度让人发指!
6.c语言回调函数的使用及实际作用详解
7.手把手教你c语言队列实现代码,通俗易懂超详细!
8.c语言指针用法详解,通俗易懂超详细! |
|