⚠️ 阅读前必看(尤其如果你是刚了解嵌入式的新人)

本文是一篇“思想笔记”,不是一份“可抄作业的工程模板”。

整篇文章的核心目的,是让你理解嵌入式 C 语言封装背后的动机和演进逻辑——为什么要分层、为什么用函数指针、为什么要“继承”。文中的代码都是为说明思想而刻意简化的最小示例(伪代码级别),省略了大量工业级项目里必须考虑的细节,比如:

  • 外设的初始化(时钟、GPIO 配置)
  • 对象的实例化(this 指针、构造函数)
  • 错误处理、参数校验
  • 代码的跨平台与可移植性工程实践

这些“壳子”是为了让你以后省事,但现在故意找麻烦的。
本文只负责把“为什么要找这个麻烦”讲清楚,至于“怎么系统地找这个麻烦(完整实现)”,那是下一阶段的事情。

如果你是新人: 请不要误以为这就是 C 语言 OOP 的全部,也不要直接把这些示例代码不加修改地用在真实项目里。


⚠️ 关于函数指针的一点重要提醒(新手必读)

下面介绍的"函数指针封装"是一个很强大的工具,但它不是免费的午餐

什么时候用会很舒服?

  • 你的 MCU 资源比较充裕(比如 STM32、ESP32、ARM Cortex-M 系列,ROM > 32KB,RAM > 4KB)
  • 你需要运行时动态切换硬件实现(比如同时兼容 STM32 和 GD32)
  • 你写的驱动库要给多个项目复用

什么时候要慎重考虑?
你用的是资源极其受限的 MCU,例如:

  • 51 单片机(尤其 ROM < 8KB、RAM < 256B)
  • PIC、AVR 的小容量型号
  • 任何 RAM 只有几百字节、ROM 只有几 KB 的芯片

函数指针会带来两个代价:

  1. 间接调用开销:比直接调用函数慢一点(虽然通常你感觉不到,但在高频中断里可能出问题)
  2. 代码体积增加:函数指针表、函数地址存储会额外占用宝贵的 ROM/RAM

给新人的一句话总结:
如果你刚开始学嵌入式,用的又是 STM32 这种资源充足的芯片,放心用函数指针去理解封装思想。如果你玩到 51 单片机或做极致成本优化时,再回来记住"函数指针有代价"就够了。别因为这个提醒就不敢学,也别以后忘了这个提醒滥用它。

希望这篇笔记能帮你建立起"封装思维",而不是给你一个生硬的模板。


面向对象相当于套壳子
这些壳子全都是 “为了以后更省事,而现在故意找的麻烦”。


一、先看:不套任何壳子的代码是什么样的

伪代码:

// 直接操作寄存器点亮 LED
void light_on(void)  { GPIOA->BSRR = GPIO_BSRR_BS1; }
void light_off(void) { GPIOA->BSRR = GPIO_BSRR_BR1; }

int main(void)
{
    while(1)
    {
        light_on();
        delay_ms(500);
        light_off();
        delay_ms(500);
    }
}

优点:简单、直接、一眼就能看懂
缺点:换芯片的时候,非常麻烦

二、现在换成 GD32 芯片或者其他芯片呢?

你要做什么?

  1. GPIOA->BSRR 改成 GD32 的 GPIOA->BOP
  2. GPIO_BSRR_BS1 改成 GPIO_BOP_BOP1
  3. GPIO_BSRR_BR1 改成 GPIO_BOP_BR1

如果只有这两个函数,改起来很简单对吧?

但如果你的程序里有 100 个这样的函数呢?

  • UART 发送、UART 接收
  • SPI 读写、I2C 读写
  • ADC 采样、PWM 输出
  • 定时器中断、外部中断

你要改 100 个地方!而且很容易漏改,漏改一个就是一个 bug。

三、第一层壳子:把硬件相关的代码单独放一个文件

这是第一个最简单的壳子,也是 90% 的项目都应该用的,没有任何复杂概念:

第一步:创建 light_drv.h 头文件

伪代码:

// light_drv.h
#ifndef __LIGHT_DRV_H__
#define __LIGHT_DRV_H__

void light_on(void);
void light_off(void);

#endif

第二步:创建 light_drv_stm32.c 源文件

伪代码:

// light_drv_stm32.c
#include "light_drv.h"
#include "stm32f10x.h"

void light_on(void)  { GPIOA->BSRR = GPIO_BSRR_BS1; }
void light_off(void) { GPIOA->BSRR = GPIO_BSRR_BR1; }

第三步:主函数只包含头文件,直接调用

伪代码:

// main.c
#include "light_drv.h"

int main(void)
{
    while(1)
    {
        light_on();
        delay_ms(500);
        light_off();
        delay_ms(500);
    }
}

现在换 GD32 芯片,你只需要做一件事:

  1. 删除 light_drv_stm32.c
  2. 新建一个 light_drv_gd32.c,里面写 GD32 的实现
  3. light_drv_gd32.c 加到工程里

main.c 一行都不用改!

这就是第一层壳子的价值:把硬件相关的代码和上层应用代码分开

四、第二层壳子:函数指针(解决“同时支持多个硬件”的问题)

刚才的第一层壳子有一个小问题:同一时间只能支持一个硬件。你不能在同一程序里同时实现 STM32 的 LED 和 GD32 的 LED。

什么时候会遇到这种情况?

  • 你做的产品有两个版本:标准版用 STM32,低配版用 GD32
  • 你做的开发板要兼容多种芯片
  • 你写的驱动库要给多个项目用

这时候就需要第二层壳子:函数指针

伪代码(头文件):

// light_drv.h
#ifndef __LIGHT_DRV_H__
#define __LIGHT_DRV_H__

// 定义一个操作接口表
typedef struct {
    void (*turn_on)(void);
    void (*turn_off)(void);
} light_interface_t;

// 声明不同硬件的接口实例
extern const light_interface_t stm32_light_ops;
extern const light_interface_t gd32_light_ops;

#endif

伪代码(STM32 实现):

// light_drv_stm32.c
#include "light_drv.h"
#include "stm32f10x.h"

static void stm32_turn_on(void)  { GPIOA->BSRR = GPIO_BSRR_BS1; }
static void stm32_turn_off(void) { GPIOA->BSRR = GPIO_BSRR_BR1; }

const light_interface_t stm32_light_ops = {
    .turn_on  = stm32_turn_on,
    .turn_off = stm32_turn_off,
};

伪代码(GD32 实现):

// light_drv_gd32.c
#include "light_drv.h"
#include "gd32f10x.h"

static void gd32_turn_on(void)  { GPIOA->BOP = GPIO_BOP_BOP1; }
static void gd32_turn_off(void) { GPIOA->BOP = GPIO_BOP_BR1; }

const light_interface_t gd32_light_ops = {
    .turn_on  = gd32_turn_on,
    .turn_off = gd32_turn_off,
};

现在主函数怎么用?

伪代码(主函数):

// main.c
#include "light_drv.h"

// 运行时决定用哪一套硬件操作
const light_interface_t *light_iface;

int main(void)
{
    light_iface = &stm32_light_ops;
    // light_iface = &gd32_light_ops;
    
    while(1)
    {
        light_iface->turn_on();
        delay_ms(500);
        light_iface->turn_off();
        delay_ms(500);
    }
}

看到了吗? 现在可以在同一个程序里同时支持 STM32 和 GD32,只需要改一行初始化代码。

这就是第二层壳子的价值:在运行时动态切换硬件实现

五、第三层壳子:继承(解决“多个设备有相同功能”的问题)

刚才的第二层壳子还有一个小问题:每个设备都要重新写一遍相同的代码
比如你有两个灯:一个是普通的 GPIO 灯,一个是可以调光的 PWM 灯。它们都有 turn_onturn_off 功能,只有 set_brightness 是 PWM 灯特有的。

如果不用继承,你就要写两个完全独立的接口表:

伪代码(重复定义):

typedef struct {
    void (*turn_on)(void);
    void (*turn_off)(void);
} gpio_light_ops_t;

typedef struct {
    void (*turn_on)(void);
    void (*turn_off)(void);
    void (*set_brightness)(int brightness);
} pwm_light_ops_t;

这就重复了!如果以后要加一个 toggle 功能,你就要在两个结构体里都加一遍。

这时候就需要第三层壳子:结构体嵌套(也就是所谓的“继承”)

我们把相同的部分抽出来:

伪代码(基础 + 派生):

// 所有灯都有的基础操作
typedef struct {
    void (*turn_on)(void);
    void (*turn_off)(void);
} light_base_ops_t;

// GPIO 灯:仅基础操作
typedef struct {
    light_base_ops_t base;
} gpio_light_ops_t;

// PWM 灯:基础操作 + 调光
typedef struct {
    light_base_ops_t base;
    void (*set_brightness)(int brightness);
} pwm_light_ops_t;

现在如果要加 toggle 功能,只需要在 light_base_ops_t 里加一行,所有灯都会自动拥有这个功能。

这就是第三层壳子的价值:代码复用,避免重复


这里为了大家方便理解继承思想,使用的是简化版本,如果想进一步了解请前往
《“我”指针:嵌入式C多实例OOP的分水岭》
希望可以为大家提供帮助。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐