嵌入式C语言开发,为什么绕不开面向对象?

发布时间:2026/7/25 12:14:30
嵌入式C语言开发,为什么绕不开面向对象? 一、为什么嵌入式圈子总在聊面向对象做嵌入式开发时间久了你一定在技术论坛和公众号里反复看到面向对象这个词。有人可能纳闷嵌入式开发主要用C语言C语言又不是面向对象语言谈这个干嘛这个疑问很正常。刚入行那几年写单片机程序一个 main.c 从头写到尾GPIO初始化、定时器配置、中断处理、通信协议、业务逻辑全堆在一起。代码量不大的时候自己写的自己维护倒也不觉得有什么问题。但嵌入式项目的复杂度这些年在肉眼可见地增长。十年前一个典型的MCU项目可能就几千行代码控制几个IO口跑个简单状态机就完事了。现在呢一个中等规模的嵌入式产品代码量轻松过万行要对接各类传感器支持OTA升级底下跑着RTOS还得兼顾不同硬件平台的适配。代码量上去了如果还是所有东西堆一起的写法几个非常现实的问题就会冒出来下面这张图展示了两种代码组织方式的差异面向对象思想说白了就是一套组织代码的方法论。它不要求你用C或者Java它提供的是一种思路把数据和操作数据的方法打包在一起通过清晰的接口对外提供服务隐藏内部实现细节。这种思路恰好能解决上面说的那些痛点。嵌入式圈子里反复提这个话题不是赶时髦是项目复杂度逼到了这一步。二、封装、继承、多态在嵌入式里到底是什么面向对象有三个核心概念封装、继承、多态。教科书上的定义你大概能背出来但落到嵌入式开发的语境下这三个东西到底在说什么用大白话捋一遍。封装把复杂性藏起来封装的意思很直接把相关的数据和操作放在一起只暴露必要的接口内部细节外面不需要知道也不应该知道。举个例子。你写了一个温度传感器的驱动如果不做封装调用者需要知道传感器挂在哪个I2C总线上、寄存器地址是什么、读出来的原始数据怎么换算成摄氏度。每个用到温度数据的地方都要写一堆底层操作代码。封装之后呢调用者只需要调一个 temp_sensor_read 函数拿到一个浮点数的温度值就行了。I2C怎么配的、寄存器怎么读的、换算公式是什么统统藏在模块内部。在C语言中封装通常靠 .h 文件暴露接口、 .c 文件隐藏实现来完成。把结构体的定义放在 .c 文件里头文件里只放函数声明外部就没法直接访问结构体的成员。这是C语言封装最基本也最实用的手法。继承复用已有的东西继承在C里有语法直接支持C语言里没有。但继承的核心思想可以在C中体现让一个子类型复用父类型的数据结构和行为。在嵌入式中继承最常用在设备驱动上。比如你有UART、SPI、I2C三种通信外设它们虽然底层协议完全不同但从使用者角度看都有初始化、发送数据、接收数据这些共同操作。你可以定义一个通用的通信设备结构体作为基类包含所有通信设备共有的属性和操作接口。然后让UART、SPI、I2C各自继承这个基类在共有属性的基础上添加自己特有的数据。C语言里怎么做继承最常见的方法是 结构体嵌套 把基类结构体作为子类结构体的第一个成员。这样子类的指针可以安全地转换为基类的指针因为它们的起始地址相同。多态同一接口不同实现多态是面向对象中最有价值的部分也是在嵌入式中用得最多的部分。多态说的是调用同一个接口函数根据对象的实际类型自动执行对应的具体实现。调用者不需要知道底层到底是哪个实现在干活。还是通信接口的例子。应用层代码调用 dev_send(dev, data, len) 这个 dev 可能是UART设备也可能是SPI设备。应用层不需要关心 dev_send 会根据 dev 的具体类型自动调用对应的发送函数。在C语言中多态靠 函数指针 实现。把函数指针放在结构体里不同的子类在初始化时把各自的实现函数赋给这些函数指针。调用时通过函数指针间接调用就实现了多态。把三个概念归纳成一句话 封装管藏继承管复用多态管切换。 三者配合使用才能发挥面向对象的真正威力。三、面向对象在嵌入式中到底好在哪概念讲完了说点实在的。面向对象思想在嵌入式项目中能带来哪些具体的好处结合我这些年的项目经验主要有以下四个方面。1. 硬件抽象换芯片不用重写应用层做产品开发的都知道芯片选型这事经常变。可能是因为成本、可能是因为缺货、也可能是客户突然提了新需求要换平台。如果你的应用层代码和硬件驱动绑死了换一颗芯片就意味着大面积改代码改完还得重新测试工作量非常大。面向对象思想最直接的应用就是做硬件抽象层HAL。把硬件相关的操作封装在底层应用层只通过统一的接口调用。换芯片的时候只需要重新实现底层驱动应用层代码一行不动。这在实际项目中的价值是巨大的。我经历过一个项目中途因为芯片缺货从STM32F103换到GD32F103。因为前期做了HAL层抽象整个切换过程只用了两天主要时间花在适配底层驱动和测试上应用层几乎没动。如果没有这层抽象保守估计至少要一两周。2. 代码复用同类设备共用一套框架很多嵌入式产品会有多个同类型的外设。比如一个设备上有三路串口两路SPI几个不同型号的传感器。如果每个外设都单独写一套代码不仅工作量大而且维护起来也麻烦。改一个bug要改好几个地方漏改一个就是隐患。用面向对象的思路可以为同类设备定义统一的接口和通用逻辑只有真正不同的部分才各自实现。比如三路串口串口的通用操作缓冲区管理、收发状态机、超时处理写一份每路串口只需要配置不同的硬件参数波特率、引脚、DMA通道等。这样做的好处是修bug只需要改一处新增一路串口只需要填一组配置参数不用再复制粘贴一堆代码。3. 可维护性代码能看懂能改动一个项目做完交付后面还有很长的维护期。可能过半年要加新功能可能生产中发现了bug需要修复也可能原来的开发人员离职了要交接。面向对象的代码因为模块之间通过接口隔离每个模块职责单一所以可维护性天然就比大杂烩式的代码好得多。看代码的人可以只关注某个模块的接口和实现不需要理解整个系统才能动手修改。更重要的是模块之间的依赖关系是清晰的、可控的。你改了传感器驱动的内部实现只要接口不变上层业务逻辑不受影响。不会出现改了一个定时器配置结果通信协议出了问题这种诡异的连锁反应。4. 团队协作接口定好各写各的一个人写代码的时候代码组织混乱的代价还不算太大毕竟你自己清楚每个变量是干什么的。但一旦多人协作如果代码结构不清晰全局变量满天飞那开发效率会急剧下降。面向对象的方式天然适合团队分工。项目开始的时候先把模块划分好把接口定义好。每个人负责一个或几个模块按照约定好的接口开发。只要接口定义合理各个模块可以并行开发、独立测试最后集成的时候也比较顺畅。打个比方面向对象的接口就像是建筑图纸上的管线接口。水电工和泥瓦工可以同时施工只要他们都按照图纸上标注的接口位置来做最后水管和电线一定能对接上。四、用C语言实现面向对象实战代码说了这么多道理来看看代码到底怎么写。以串口设备驱动为例先看一个没有面向对象思想的传统写法再看用面向对象思路重构后的写法对比一下差异。传统写法直接操作简单粗暴// uart.c - 传统写法void uart1_init(uint32_t baudrate){// 直接操作 UART1 的寄存器USART1-BRR SystemCoreClock / baudrate;USART1-CR1 USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;}void uart1_send(uint8_t *data, uint16_t len){for (uint16_t i 0; i len; i) {while (!(USART1-SR USART_SR_TXE));USART1-DR data[i];}}void uart2_init(uint32_t baudrate){// 又写一遍只是寄存器地址换成了 USART2USART2-BRR APB1Clock / baudrate;USART2-CR1 USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;}void uart2_send(uint8_t *data, uint16_t len){// 几乎一模一样的逻辑再抄一遍for (uint16_t i 0; i len; i) {while (!(USART2-SR USART_SR_TXE));USART2-DR data[i];}}这种写法的问题显而易见两路串口的代码高度重复如果有三路、四路呢每加一路就要复制粘贴一整套函数。修一个bug要改好几个地方漏改一处就是隐患。面向对象写法抽象 封装 多态下面用面向对象的思路重新组织这段代码。第一步定义串口设备的抽象接口// serial_device.h#ifndef SERIAL_DEVICE_H#define SERIAL_DEVICE_H#include// 前向声明typedefstruct serial_device serial_device_t;// 操作接口 (虚函数表)typedefstruct {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);int serial_recv(serial_device_t *dev, uint8_t *buf, uint16_t len);void serial_deinit(serial_device_t *dev);#endif第二步实现统一接口相当于基类的方法// serial_device.c#include serial_device.hint serial_init(serial_device_t *dev, uint32_t baudrate){if (dev dev-ops dev-ops-init) {return dev-ops-init(dev, baudrate);}return -1;}int serial_send(serial_device_t *dev, const uint8_t *data, uint16_t len){if (dev dev-ops dev-ops-send) {return dev-ops-send(dev, data, len);}return -1;}int serial_recv(serial_device_t *dev, uint8_t *buf, uint16_t len){if (dev dev-ops dev-ops-recv) {return dev-ops-recv(dev, buf, len);}return -1;}void serial_deinit(serial_device_t *dev){if (dev dev-ops dev-ops-deinit) {dev-ops-deinit(dev);}}第三步实现具体的硬件驱动相当于子类// stm32_uart.c#include serial_device.h#include stm32f1xx.h// UART 私有数据typedefstruct {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 , // 暂未实现.deinit ,};// ---- 实例化具体设备 ----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,};第四步应用层使用// app.c#include serial_device.hextern serial_device_t uart1_dev;extern serial_device_t uart2_dev;void app_run(void){// 初始化serial_init(uart1_dev, 115200);serial_init(uart2_dev, 9600);uint8_t msg Hello;// 应用层代码完全一样不管底层是哪路串口serial_send(uart1_dev, msg, sizeof(msg));serial_send(uart2_dev, msg, sizeof(msg));}来看看重构前后的结构差异这段代码里封装、继承、多态三个要素全都体现了五、初学者该怎么学面向对象思维很多初学者在学面向对象时容易走两个极端要么觉得这玩意太抽象看不懂也用不上要么一上来就追求各种设计模式把简单的事情搞复杂了。根据我带新人的经验分享几点务实的学习建议。第一步先把C语言基础打扎实面向对象思想在C语言中的载体就是 结构体 和 函数指针 。如果你对这两个东西不够熟悉后面的内容学起来会很吃力。具体来说你至少需要掌握这些都是C语言的基础知识不涉及面向对象。但它们是面向对象在C语言中落地的工具必须熟练掌握。第二步从封装开始练习三个核心概念中封装是最容易理解、也最容易上手的。建议你从自己写过的代码开始尝试做封装改造。比如你写过一个LED控制程序代码可能是这样的// 到处直接操作寄存器GPIOB-ODR | (1 5); // 点亮LEDGPIOB-ODR ~(1 5); // 熄灭LED试着把它封装成模块// led.hvoid led_init(void);void led_on(void);void led_off(void);void led_toggle(void);// led.c#include led.hstatic GPIO_TypeDef *led_port GPIOB;static uint16_t led_pin GPIO_PIN_5;void led_on(void) { led_port-ODR | led_pin; }void led_off(void) { led_port-ODR ~led_pin; }改动不大但调用者再也不需要知道LED接在哪个引脚上了。这就是封装的价值。第三步读优秀的开源代码与其自己闭门造车不如看看高手是怎么写的。嵌入式领域有几个开源项目的代码质量很高面向对象的思想运用得也很好推荐学习初学阶段不需要通读这些项目的全部代码挑一个小模块搞清楚它的结构体定义、函数指针用法、接口设计思路就够了。第四步在实际项目中刻意练习学到的东西必须在项目中用起来否则很快就会忘。建议你在自己的下一个项目中选一两个模块尝试用面向对象的方式来写。下面是一个建议的练习路径不要一开始就追求Level 3从Level 1慢慢来。每个阶段做扎实了再往上走。几个常见的坑提前提醒不要过度设计 。面向对象是工具不是目的。一个简单的LED控制程序如果只有一个LED硬要搞出基类子类虚函数表那就是杀鸡用牛刀。用最简单的方式解决问题就好等到代码真的重复了、维护真的困难了再考虑重构。不要忽视性能开销 。函数指针调用比直接函数调用多一次间接寻址在时间敏感的中断处理中要注意。结构体嵌套也会增加内存开销。对于资源极其有限的8位单片机要在代码结构和运行效率之间找平衡。不要生搬硬套C的概念 。C语言的面向对象和C的面向对象是两回事。C语言没有访问控制关键字没有构造函数和析构函数没有模板和异常处理。用C写面向对象代码追求的是思想上的借鉴不是语法上的模仿。六、写在最后回到开头的问题嵌入式开发为什么总在提面向对象原因很简单。嵌入式项目不再是从前那个几百行代码就能搞定的时代了。当代码量增长到一定规模当项目需要多人协作当产品需要支持多平台、多型号你就必须找到一种有效的方式来组织代码。面向对象思想恰好提供了这样一套经过大量实践验证的方法论。但也要记住面向对象是手段不是目的。代码写出来是给人看的是要在产品上跑的。不要为了面向对象而面向对象。项目简单的时候简单写就好项目复杂了再引入抽象和封装也不迟。真正有经验的工程师知道什么时候该用什么时候不该用。如果你现在还是一个嵌入式初学者从今天开始试着把你的下一个外设驱动封装成独立模块。不需要搞多复杂把 .h 和 .c 分清楚把接口和实现分开这就是面向对象思维的第一步。迈出这一步之后你会发现后面的路越走越顺。