ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式C语言面向对象编程:封装继承多态的C实现与串口驱动实战

嵌入式C语言面向对象编程:封装继承多态的C实现与串口驱动实战 上一篇讲轮询、回调、观察者把底层变化通知应用层的线理清了。这篇往更根本的地方挖一步嵌入式用 C 写为什么绕不开面向对象做嵌入式开发时间久了你一定在技术论坛和公众号里反复看到面向对象这个词。有人纳闷嵌入式主要用 C 语言C 又不是面向对象语言谈这个干嘛这个疑问很正常。我刚入行那几年写单片机程序一个 main.c 从头写到尾GPIO 初始化、定时器配置、中断处理、通信协议、业务逻辑全堆在一起。代码量不大的时候自己写的自己维护倒也不觉得有什么问题。但嵌入式项目的复杂度这些年在肉眼可见地增长。十年前一个典型的 MCU 项目可能就几千行代码现在呢一个中等规模的嵌入式产品代码量轻松过万行要对接各类传感器支持 OTA 升级底下跑着 RTOS还得兼顾不同硬件平台的适配。代码量上去了如果还是所有东西堆一起的写法几个现实问题就冒出来改一个模块的代码莫名其妙影响了另一个模块换一颗芯片应用层代码跟着大面积修改新同事接手项目看代码看得头皮发麻多人协作时频繁出现代码冲突。面向对象思想说白了就是一套组织代码的方法论。它不要求你用 C 或者 Java它提供的是一种思路把数据和操作数据的方法打包在一起通过清晰的接口对外提供服务隐藏内部实现细节。C 没有 class 关键字但这套思路完全能用 C 实现而且嵌入式里用得比你想的普遍得多。封装、继承、多态在嵌入式里到底是什么面向对象三个核心概念在 C 里都有对应的手法一个一个说。封装把复杂性藏起来。把相关的数据和操作放在一起只暴露必要的接口内部细节外面不需要知道也不应该知道。在 C 语言中封装靠 .h 文件暴露接口、.c 文件隐藏实现来完成。最干净的手法是不透明指针opaque pointer。头文件里只前向声明结构体不放定义/* serial_device.h - 只暴露接口结构体定义藏起来 */ typedef struct serial_device serial_device_t; /* 前向声明外部看不到成员 */ int serial_init(serial_device_t *dev, uint32_t baudrate); int serial_send(serial_device_t *dev, const uint8_t *data, uint16_t len); void serial_deinit(serial_device_t *dev);/* serial_device.c - 结构体定义在这里外部文件拿不到成员 */ struct serial_device { const char *name; const serial_ops_t *ops; void *priv; /* 私有数据 */ };外部代码只能拿到serial_device_t *指针没法dev-name直接访问成员编译器直接报错因为成员定义在 .c 里它看不见。这就是 C 的封装比 C 的 private 还硬是物理隔离不是约定隔离。继承复用已有的东西。C 语言没有语法直接支持继承但核心思想能在 C 中体现让一个子类型复用父类型的数据结构和行为。最常见的方法是结构体嵌套把基类结构体作为子类结构体的第一个成员。/* 基类所有设备共有的属性 */ typedef struct { const char *name; int id; } device_base_t; /* 子类串口设备把基类放第一个成员 */ typedef struct { device_base_t base; /* 必须是第一个成员 */ uint32_t baudrate; uint8_t parity; } serial_dev_t; serial_dev_t uart1; uart1.base.name uart1; /* 通过 base 访问基类成员 */ uart1.baudrate 115200;为什么必须是第一个成员因为 C 标准保证结构体首成员的地址等于结构体本身的地址。这样serial_dev_t *和device_base_t *能安全互转传给期望基类指针的函数时直接强转就行。这是 Linux 内核container_of宏的基础也是 C 实现继承的底层依据。多态同一接口不同实现。多态是面向对象中最有价值的部分。在 C 语言中多态靠函数指针实现。把函数指针放在结构体里不同的子类在初始化时把各自的实现函数赋给这些函数指针。typedef struct { int (*init)(serial_device_t *dev, uint32_t baudrate); int (*send)(serial_device_t *dev, const uint8_t *data, uint16_t len); int (*recv)(serial_device_t *dev, uint8_t *buf, uint16_t len); void (*deinit)(serial_device_t *dev); } serial_ops_t;serial_ops_t就是一组函数指针相当于一张方法表。STM32 的串口填一套实现ESP32 的串口填另一套应用层调dev-ops-send(dev, ...)时具体跑哪套代码由dev指向的实例决定。这就是多态同一调用不同行为。把三个概念归纳成一句话封装管藏继承管复用多态管切换。三者配合使用才能发挥面向对象的真正威力。面向对象在嵌入式里到底好在哪第一硬件抽象换芯片不用重写应用层。把硬件相关的操作封装在底层应用层只通过统一的接口调用。换芯片时只改底层驱动实现应用层一行不动。这是产品线多、硬件版本多的项目最看重的。第二代码复用同类设备共用一套框架。为同类设备定义统一的接口和通用逻辑只有真正不同的部分才各自实现。比如所有串口共用serial_send的框架逻辑只有寄存器操作那几行因芯片而异。第三可维护性代码能看懂能改动。模块之间通过接口隔离每个模块职责单一。改一个模块不用担心牵连别的因为别的模块只依赖你的接口不依赖你的实现。第四团队协作接口定好各写各的。项目开始时先把模块划分好、接口定义好各模块并行开发。A 写串口驱动B 写业务逻辑只要接口对得上互不阻塞。这一条在大团队里价值尤其大。实战用 C 写一个串口设备驱动以串口设备驱动为例先看传统写法直接操作寄存器每路串口复制粘贴一套函数uart1_send、uart2_send、uart3_send各写一遍换芯片又复制一遍。再看面向对象写法把上面三个概念全用上。// serial_device.h - 抽象接口 typedef struct serial_device serial_device_t; typedef struct { int (*init)(serial_device_t *dev, uint32_t baudrate); int (*send)(serial_device_t *dev, const uint8_t *data, uint16_t len); int (*recv)(serial_device_t *dev, uint8_t *buf, uint16_t len); void (*deinit)(serial_device_t *dev); } serial_ops_t; struct serial_device { const char *name; const serial_ops_t *ops; void *priv; // 私有数据 }; // 对外统一接口 int serial_init(serial_device_t *dev, uint32_t baudrate); int serial_send(serial_device_t *dev, const uint8_t *data, uint16_t len); void serial_deinit(serial_device_t *dev);// stm32_uart.c - 具体硬件驱动 typedef struct { USART_TypeDef *instance; uint32_t clk_freq; } stm32_uart_priv_t; static int stm32_uart_init(serial_device_t *dev, uint32_t baudrate) { stm32_uart_priv_t *priv (stm32_uart_priv_t *)dev-priv; priv-instance-BRR priv-clk_freq / baudrate; priv-instance-CR1 USART_CR1_TE | USART_CR1_RE | USART_CR1_UE; return 0; } static int stm32_uart_send(serial_device_t *dev, const uint8_t *data, uint16_t len) { stm32_uart_priv_t *priv (stm32_uart_priv_t *)dev-priv; for (uint16_t i 0; i len; i) { while (!(priv-instance-SR USART_SR_TXE)); priv-instance-DR data[i]; } return len; } static const serial_ops_t stm32_uart_ops { .init stm32_uart_init, .send stm32_uart_send, .recv NULL, .deinit NULL, }; // 实例化具体设备 static stm32_uart_priv_t uart1_priv { .instance USART1, .clk_freq 72000000 }; static stm32_uart_priv_t uart2_priv { .instance USART2, .clk_freq 36000000 }; serial_device_t uart1_dev { .name uart1, .ops stm32_uart_ops, .priv uart1_priv }; serial_device_t uart2_dev { .name uart2, .ops stm32_uart_ops, .priv uart2_priv };应用层使用时不管底层是哪路串口调用方式完全一样serial_init(uart1_dev, 115200); serial_send(uart1_dev, msg, sizeof(msg));这段代码里封装、继承、多态三个要素全体现了封装硬件寄存器操作被隐藏在 stm32_uart.c 内部外部只看到serial_send继承serial_device_t作为基类priv指针挂载各芯片的私有数据多态serial_send通过函数指针调用具体实现STM32 和 ESP32 各填一套ops。这里有个设计点值得单独说priv指针。抽象层serial_device_t不认识具体芯片的寄存器但它留了个void *priv让具体驱动把私有数据挂上去。stm32_uart_priv_t里放 USART 实例和时钟频率ESP32 的 priv 里放 UART 端口号和配置。抽象层只管透传priv具体驱动自己强转回自己的类型。这个手法让抽象层和具体实现彻底解耦是 C 面向对象里最常用的一招。为什么不直接用 C聊到这肯定有人问既然要面向对象为什么不直接用 C嵌入式又不是不能跑 C。能跑但有代价。第一C 的运行时特性异常、RTTI、虚函数表会吃 Flash 和 RAM资源紧张的 MCU 扛不住。第二C 编译器对嵌入式平台支持参差不齐有些 MCU 的工具链 C 支持很弱。第三团队里 C 的功底普遍比 C 扎实用 C 写面向对象门槛低人人能维护。所以嵌入式的主流还是 C但用 C 的语法实现面向对象的思想。这套手法不是奇技淫巧是 Linux 内核、FreeRTOS、LVGL 这些大项目都在用的成熟实践。你不用它不是它不好是你还没遇到痛点。初学者该怎么学面向对象思维第一步先把 C 语言基础打扎实。结构体、函数指针、typedef、.h/.c 分离、static 关键字这些是面向对象的砖头砖头不齐盖不起来。第二步从封装开始练。把你项目里某个模块用 .h/.c 分离重构头文件只放函数声明结构体定义藏 .c 里。这是最立竿见影的一步重构完模块立刻清爽。第三步再学函数指针做多态。用函数指针替换 switch-case 分支体会同一接口不同实现的好处。上一篇讲的观察者、这篇的 ops 表都是这一步的应用。第四步最后才学继承和设计模式。结构体嵌套做继承工厂、观察者、策略这些模式。到这一步你已经能看懂 Linux 内核和 LVGL 的源码架构了。这四步别跳。很多人一上来就啃设计模式结果连封装都没练熟模式用得生硬反而把代码搞复杂。面向对象是手段不是目的目的是把代码组织得能维护、能扩展、能协作手段用错了适得其反。有用的话点个在看让更多还在一个 main.c 写到底的嵌入式工程师看到。标签嵌入式 C语言 面向对象 封装继承多态 函数指针
返回列表