电子产业一站式赋能平台

PCB联盟网

搜索
查看: 53|回复: 0
收起左侧

如何在单片机 C 语言中实现面向对象的编程效果?

[复制链接]

310

主题

310

帖子

2764

积分

三级会员

Rank: 3Rank: 3

积分
2764
发表于 7 天前 | 显示全部楼层 |阅读模式
关注公众号,回复“入门资料”获取单片机入门到高级开挂教程
开发板带你入门,我们带你飞

文 | 无际(微信:2777492857)
全文约6017字,阅读大约需要 15 分钟
这段时间在研究esp32的代码,他们提供的库,非常面向对象,不得不说,写这个库的人,水平很高。
           
你可能会想:“搞错没?C 语言?面向对象?那不是 C++、Java、Python 这些语言的专属技能吗?咱们 C 语言,朴实无华,一把梭哈干到底,讲究的就是一个快、准、狠,要啥自行车?”
           
此言差矣!
           
C 语言天生不支持 OOP 的所有特性,它没有类(class)、没有继承(inheritance)、没有多态(polymorphism)的直接语法支持。
           
但这并不意味着我们就得永远停留在“全局变量满天飞,函数调用理不清的史前时代。
           
特别是当你的项目越来越大,逻辑越来越复杂,比如我们无际单片机的项目6(4G+WiFi+Lora报警网关)。

5zniadfxgov64039514825.png

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

    xfopdvn2j2y64039514925.jpg


    下面是更多无际原创的个人成长经历、行业经验、技术干货。
    1.电子工程师是怎样的成长之路?10年5000字总结
    2.如何快速看懂别人的代码和思维
    3.单片机开发项目全局变量太多怎么管理?
    4.C语言开发单片机为什么大多数都采用全局变量的形式
    5.单片机怎么实现模块化编程?实用程度让人发指!
    6.c语言回调函数的使用及实际作用详解

    7.手把手教你c语言队列实现代码,通俗易懂超详细!

    8.c语言指针用法详解,通俗易懂超详细!
  • 回复

    使用道具 举报

    发表回复

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则


    联系客服 关注微信 下载APP 返回顶部 返回列表