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

嵌入式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#includestm32f4xx_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;没有复用任何通用接口和属性强行嵌套基类凑继承格式纯属画蛇添足直接用独立结构体即可。场景 4MCU 资源极度受限继承搭配后续多态会占用少量RAM且指针强转会略微增加调试成本。如果是资源极小、功能固定的极简项目直接写死实现更高效。快速判断是否需要用继承的三个标准 1. 设备之间是否满足 is-a 是一个的从属关系 2. 是否需要统一接口操作不同的子类设备 3. 项目是否会频繁新增同类子类设备 七、总结继承给我们带来的核心能力1. 统一同类设备的数据结构规范代码形态 2. 支持子类向上转型上层代码无需区分设备类型 3. 代码可扩展、可维护从根源解决冗余问题。但继承有一个解决不了的核心问题 无法让同一接口自动适配不同设备的硬件执行逻辑 。举个例子我们可以把LED、蜂鸣器、继电器全部转为通用输出设备基类指针但上层调用统一翻转接口时代码依然不知道当前该执行LED点亮、蜂鸣器发声还是继电器吸合的硬件逻辑。想要实现 同一接口、自动适配不同硬件逻辑 的动态分发能力就需要学习下一章核心内容 多态与虚函数表 。朋友们下期再见【往期精选】你的 C 代码为什么乱看完这 18 种结构体用法就懂了嵌入式驱动架构进化全解从 51 裸机到 Linux 设备树吃透结构体对齐解决嵌入式 90% 的偶发玄学 BUG一文吃透嵌入式编译链接全过程彻底弄懂内存段布局与分区原理嵌入式架构到底该怎么分层、怎么设计接口嵌入式事件驱动架构回调函数从入门到精通嵌入式 MCU 固件升级全实战总结高效处理流数据的利器环形缓冲区Ring Buffer实现详解