嵌入式C语言之面向对象设计-继承

上一章我们讲了 面向对象封装 ,解决了单个外设的管理问题:用结构体存设备参数、用句柄传参操作设备、用初始化和销毁函数管控设备生命周期。

这套写法能让每个外设独立可控、没有全局变量污染,写单个设备完全够用。

但真正的嵌入式项目,从来不是只控制一个设备,而是 一类功能相似但硬件不同的设备 。单纯只靠封装,大概率会遇到瓶颈。

举个最常见的场景:

项目里有三种开关输出设备:LED指示灯、蜂鸣器、继电器。

从业务逻辑来看,它们一模一样:就三个操作——开、关、翻转状态。

但从底层硬件来看,它们完全不同:有的低电平点亮、有的高电平触发、有的需要延时防抖、有的需要定时关闭。

如果只用基础的封装写法,一定会陷入两个大坑:

每种设备单独写一套开关、翻转函数,代码大量复制粘贴,冗余严重;

强行写一个统一函数,内部堆满 if/else 判断设备类型,后续改一个逻辑、加一个设备都要动核心代码,扩展性极差。

这就是封装的短板: 只能管好单个设备个体,管不好一整类设备群体 。

所以我们需要OOP第二个核心特性: 继承 。

继承的意义非常简单直白: 把一类设备的公共属性、通用能力抽出来复用,只保留每个设备的独有特性,做到上层代码统一、底层硬件各玩各的 。

一、传统写法的致命痛点:同类设备无法统一管理

还是以LED、蜂鸣器、继电器这三个开关设备举例,新手最常规的写法,写三套独立控制函数:

// LED控制函数voidled_on(GPIO_TypeDef *port, uint16_t pin);voidled_off(GPIO_TypeDef *port, uint16_t pin);// 蜂鸣器控制函数voidbuz_on(GPIO_TypeDef *port, uint16_t pin);voidbuz_off(GPIO_TypeDef *port, uint16_t pin);// 继电器控制函数voidrelay_on(GPIO_TypeDef *port, uint16_t pin);voidrelay_off(GPIO_TypeDef *port, uint16_t pin);

稍微看一眼就知道问题:三套函数 逻辑几乎完全一样 ,唯一区别就是引脚、有效电平、小众硬件配置。

后续项目再加电磁阀、状态指示灯,就得再复制一套一模一样的代码。代码越写越乱、冗余爆炸,后期维护改一个bug,要改七八处,极易出错。

有些人为了精简代码,会尝试写一个统一接口,最后写出这种“缝合怪”代码:

// 伪统一接口,典型反面教材voidoutput_dev_on(int dev_type, GPIO_TypeDef *port, uint16_t pin){if(dev_type == 0){HAL_GPIO_WritePin(port, pin, GPIO_PIN_RESET); // LED低电平点亮}elseif(dev_type == 1){HAL_GPIO_WritePin(port, pin, GPIO_PIN_SET); // 蜂鸣器高电平响}// 新增设备?必须在这里加if/else分支!}

这种写法看着统一,实则隐患巨大:

上层业务逻辑死死绑定底层硬件细节,每新增一种设备、每改一种设备的硬件逻辑,都要修改这个核心公共函数。

这违背了嵌入式工程最重要的原则: 对扩展开放,对修改关闭 。

想要解决这个问题,必须用 继承思想 重构设备模型。

二、C语言模拟继承的核心:结构体嵌套+基类结构体置顶

首先说句大实话:C语言没有C++那种现成的 class 继承语法。

但所有高端嵌入式框架——Linux内核、RTOS、LVGL,全部用一套通用标准写法模拟继承: 结构体嵌套 + 基类结构体置顶 。

继承的本质一句话讲透: 把一类设备的通用特征抽出来做成父类(基类),每个设备独有的特征,留给子类自己扩展 。

1. 抽取公共父类(基类)

我们先提炼所有开关输出设备的共性:不管是LED、蜂鸣器还是继电器,都只有两个通用属性—— 设备当前状态 、 硬件有效电平 。

基于这个共性,我们定义 输出设备基类 ,只存公共数据,不绑定任何具体硬件:

#include#include"stm32f4xx_hal.h"// 输出设备基类:所有开关型外设的通用父类typedefstruct{uint8_t dev_sta; // 设备状态:0关闭 1开启GPIO_PinState active_lv; // 设备有效电平} Output_Dev_t;

2. 扩展私有子类(派生类)

有了基类后,每种具体设备只需要 嵌套基类结构体 ,再加上自己独有的硬件参数,就完成了继承。

子类会自动继承基类的所有公共属性,不用重复定义,极简高效:

// LED子类:继承通用输出设备,独有硬件引脚typedefstruct{Output_Dev_t base; // 核心:嵌套基类,完成继承GPIO_TypeDef *port; // LED独有:端口uint16_t pin; // LED独有:引脚} Led_Dev_t;// 蜂鸣器子类:继承通用输出设备,独有硬件配置typedefstruct{Output_Dev_t base; // 继承公共属性GPIO_TypeDef *port;uint16_t pin;int beep_duration; // 蜂鸣器独有:响灯时长(其他设备没有)} Buzzer_Dev_t;// 继电器子类:继承通用输出设备,独有硬件配置typedefstruct{Output_Dev_t base; // 继承公共属性GPIO_TypeDef *port;uint16_t pin;int debounce_time; // 继电器独有:防抖时间(其他设备没有)} Relay_Dev_t;

所有子类自动拥有基类的设备状态、有效电平,不用重复写代码;同时每个设备可以自由加自己的专属参数,互不干扰。

到这里,C语言版的继承就彻底搭建完成了。大家可能有个问题是,上面的子类的的设备里面都有端口和引脚,为啥不把这些也抽象放进基类啊?

看着 LED、蜂鸣器、继电器都有 port / pin , 但在工程上不具备通用性, 因为 不是所有输出设备都靠 GPIO 驱动,后续你可能会遇到:

这些设备 根本没有 port 、 pin ,如果把引脚写进基类,所有子类都必须强制继承这两个无用成员,最后造成 结构体冗余、模型僵化 。

三、为啥基类要放在结构体第一个位置 1. 底层内存布局原理

C语言结构体的内存排布规则非常简单: 成员变量按照定义顺序连续排列,结构体首地址 = 第一个成员的首地址 。

我们把基类base放在子类第一个成员,就会出现一个关键特性:

子类对象的地址 等于 子类.base 的地址

简单说: &led1 == &led1.base ,二者地址完全重合。

2. 继承的核心能力:向上转型

靠着这个内存特性,我们实现了OOP最关键的能力—— 向上转型 ( 子类指针强制转为父类指针 )。

// 定义一个具体的LED设备对象Led_Dev_t led1;// 子类指针 向上转型为 通用基类指针Output_Dev_t*dev_base = (OutputDev *)&led1

转型之后,上层代码完全不用区分当前设备是LED、蜂鸣器还是继电器,统一操作 Output_Dev_t 基类指针即可。

这就是解耦的核心: 上层依赖通用抽象基类,下层实现具体硬件子类 ,上下层彻底分离。

四、用继承带来的提升

结合我们的输出设备案例,能直观看到继承的工程价值:

1. 消灭重复代码

所有设备的通用属性(状态、有效电平)全部收敛到基类,只定义一次。不用每个设备都重复写一遍状态变量、电平变量,代码量大幅精简。

2. 实现上层设备统一管理

在业务层眼里,LED、蜂鸣器、继电器不再是三个完全不同的设备,全部是 可开关的通用输出设备 。调用逻辑完全统一,不用区分设备类型。

// 上层统一翻转设备状态,适配所有输出设备voidoutput_toggle(Output_Dev_t *base){base->dev_sta = !base->dev_sta;// 通用状态逻辑统一处理,硬件差异后续用多态实现}

3. 支持扩展,不改核心代码

后续项目新增电磁阀、指示灯等输出设备,完全不用修改上层业务代码。只需要新建一个子类,嵌套基类、添加自己的独有参数即可。

// 新增电磁阀设备,零改动上层代码typedefstruct {OutputDev base;GPIO_TypeDef *port;uint16_t pin;int valve_pressure; // 电磁阀独有:压力阈值} Solenoid_Dev_t;

五、继承实战举例

这套继承写法并不是自创技巧,最典型的就是Linux内核设备模型,内核所有设备 全部继承自 struct device 基类 ,平台设备、I2C设备、SPI设备都是它的派生子类。

// 父类 — 所有设备的"祖先"structdevice {constchar *init_name;structbus_type *bus;structdevice_driver *driver;void *platform_data;// ...};
// 子类 — 平台设备,继承 devicestructplatform_device {structdevice dev; // 首成员 = 继承constchar *name;int id;structresource *resource;// ...};
// 子类 — I2C 从设备,也继承 devicestructi2c_client {structdevice dev; // 首成员 = 继承unsignedshort addr;structi2c_adapter *adapter;// ...};

当我们要向内核注册设备时,会调用这个函数:

intdevice_register(struct device *dev);

那么不管是平台设备还是I2C设备,我们可以直接这么做:

device_register((struct device *)pdev)

直接强转为父类指针。

同样,如果我们需要从父类拿到子类的数据,可以这样干:

intmy_probe(struct device *dev){struct platform_device *pdev;
pdev = container_of(dev, struct platform_device, dev);
// 现在可以访问 platform_device 的专属字段pdev->id;pdev->resource;}

这里我们从 device 找回了 platform_device 。可以看到这里使用了 container_of 宏,该宏是这么设计的:

#define container_of(ptr, type, member) \((type *)((char*)(ptr) - offsetof(type, member)))

原理很简单,首先 dev 指向 platform_device.dev ,我们主要减去 dev 在 platform_device 中的偏移,就可以得到整个 platform_device 的起始地址。

正是依靠这套继承模型,Linux内核实现了设备子系统的统一管理:设备注册、电源管理、热插拔、sysfs挂载、模块加载卸载,全部统一逻辑,无需每个设备重复开发。

六、什么场景不适合用继承?

继承不是万能的,乱用反而会让代码更臃肿。以下四种场景,坚决不要用继承:

场景1:纯粹的数据聚合(has-a关系)

typedefstruct {UART_Config_t uart;ADC_Config_t adc;} SystemConfig_t;

只是把多个模块的参数整合在一起,不存在“是一个”的从属关系,用普通结构体嵌套即可。

场景 2:生命周期不一致的对象

typedefstruct {TaskHandle_t task; // RTOS 任务QueueHandle_t queue;} Module_t;

任务、队列属于系统资源,不属于模块本身,不存在从属关系,这里理解为一个业务模块, 拥有 一个任务、一个消息队列,所以用组合而非继承。

场景 3:为了“少写几行代码”

typedef struct {Base_t base; // 其实根本没复用任何接口int x;} Foo_t;

没有复用任何通用接口和属性,强行嵌套基类凑继承格式,纯属画蛇添足,直接用独立结构体即可。

场景 4:MCU 资源极度受限

继承搭配后续多态会占用少量RAM,且指针强转会略微增加调试成本。如果是资源极小、功能固定的极简项目,直接写死实现更高效。

快速判断是否需要用继承的三个标准 :

1. 设备之间是否满足 is-a (是一个)的从属关系? 2. 是否需要统一接口操作不同的子类设备? 3. 项目是否会频繁新增同类子类设备? 七、总结

继承给我们带来的核心能力:

1. 统一同类设备的数据结构,规范代码形态; 2. 支持子类向上转型,上层代码无需区分设备类型; 3. 代码可扩展、可维护,从根源解决冗余问题。

但继承有一个解决不了的核心问题: 无法让同一接口自动适配不同设备的硬件执行逻辑 。

举个例子:我们可以把LED、蜂鸣器、继电器全部转为通用输出设备基类指针,但上层调用统一翻转接口时,代码依然不知道当前该执行LED点亮、蜂鸣器发声还是继电器吸合的硬件逻辑。

想要实现 同一接口、自动适配不同硬件逻辑 的动态分发能力,就需要学习下一章核心内容: 多态与虚函数表 。朋友们,下期再见!

【往期精选】

你的 C 代码为什么乱?看完这 18 种结构体用法就懂了

嵌入式驱动架构进化全解:从 51 裸机到 Linux 设备树

吃透结构体对齐:解决嵌入式 90% 的偶发玄学 BUG

一文吃透嵌入式编译链接全过程:彻底弄懂内存段布局与分区原理

嵌入式架构到底该怎么分层、怎么设计接口?

嵌入式事件驱动架构:回调函数从入门到精通

嵌入式 MCU 固件升级全实战总结

高效处理流数据的利器:环形缓冲区(Ring Buffer)实现详解