模块解耦实战:用 C 语言实现面向对象编程的三个层次

模块解耦实战:用 C 语言实现面向对象编程的三个层次

摘要: 嵌入式项目中,代码越写越乱、模块间互相纠缠、改一个驱动影响一大片------这些问题的根源往往是"用面向过程的方式写面向对象的系统"。C 语言没有 class 关键字,但完全能实现 OOP 的三大特性:封装、继承、多态。本文用一套工业项目的驱动框架,完整演示如何在 C 语言里实现面向对象编程的三个层次,以及如何用它设计可扩展、可替换、可测试的模块化系统。包含约 800 行实战代码、设计权衡分析和常见坑点。建议收藏。

一、引子:一个越改越乱的驱动框架

去年接手一个电机驱动项目,代码量约 3 万行。接手时的状态:

  • 电机控制代码里混着 UART 发送代码 :控制逻辑里直接调用了 UART_SendString("Speed: %d", speed)

  • ADC 采集代码里混着 Modbus 协议解析:ADC 中断回调里直接解析 Modbus 帧

  • 修改一处 UART 配置,要重新编译整个工程:所有模块都直接 include UART 的头文件

  • 无法单元测试:想测 PID 算法,必须连着整个硬件环境

最典型的问题:想从 UART 改成 CAN 通信,需要改十几个文件。 因为 UART 的 API 被到处直接调用,没有抽象层。

这个项目的根本问题不是"代码写得不好",而是没有做模块解耦。每个模块都知道其他模块的细节,形成了一张互相纠缠的网。

解决方案:用 C 语言实现 OOP。

二、为什么 C 语言需要 OOP

2.1 OOP 的三大特性

面向对象编程的核心是三个特性:

关键认知:OOP 是一种思维方式,不是语言的专利。 没有 class 关键字,C 依然能实现 OOP 的所有核心特性。

2.2 解耦带来的好处

模块解耦的目标是:任何一个模块的实现可以被替换,而不影响其他模块。

好处:

  • 可测试:模块可以脱离硬件单独测试

  • 可替换:UART 换成 CAN,只改底层实现

  • 可并行开发:不同人负责不同模块,接口定了就互不干扰

  • 可维护:一个模块的 Bug 不会扩散到其他模块

三、第一层:封装

3.1 封装的核心:不透明指针

目标: 调用者只能看到接口,看不到内部数据结构。

做法: 头文件只声明指针类型,结构体定义放在 .c 文件里。

复制代码
// motor.h ------ 头文件,对外接口
#ifndef __MOTOR_H
#define __MOTOR_H

#include <stdint.h>

// 前向声明:告诉编译器有这么一个类型,但不暴露细节
typedef struct motor motor_t;

// 公开的 API
motor_t *Motor_Create(uint8_t id);
void     Motor_Destroy(motor_t *self);
int      Motor_SetSpeed(motor_t *self, int32_t speed);
int32_t  Motor_GetSpeed(const motor_t *self);
int      Motor_Enable(motor_t *self);
int      Motor_Disable(motor_t *self);

#endif

// motor.c ------ 实现文件,包含内部结构
#include "motor.h"
#include <stdlib.h>

// 结构体的完整定义只在这里出现
struct motor {
    uint8_t   id;
    int32_t   target_speed;
    int32_t   actual_speed;
    uint8_t   enabled;
    // 内部实现细节,外部看不到
};

motor_t *Motor_Create(uint8_t id)
{
    motor_t *self = (motor_t *)malloc(sizeof(motor_t));
    if (!self) return NULL;

    self->id = id;
    self->target_speed = 0;
    self->actual_speed = 0;
    self->enabled = 0;
    return self;
}

void Motor_Destroy(motor_t *self)
{
    if (self) free(self);
}

int Motor_SetSpeed(motor_t *self, int32_t speed)
{
    if (!self) return -1;
    if (!self->enabled) return -2;
    self->target_speed = speed;
    return 0;
}

int32_t Motor_GetSpeed(const motor_t *self)
{
    return self ? self->actual_speed : 0;
}

int Motor_Enable(motor_t *self)
{
    if (!self) return -1;
    self->enabled = 1;
    // 实际硬件操作
    return 0;
}

int Motor_Disable(motor_t *self)
{
    if (!self) return -1;
    self->enabled = 0;
    return 0;
}

关键点:

  • struct motor 的完整定义只在 motor.c 里,其他文件看不到

  • 调用者只能通过 motor_t * 指针使用,无法访问内部字段

  • 所有内部函数用 static 修饰,不导出符号

这带来的好处:

  • 修改内部结构不影响调用者 :改 motor 结构体的字段,其他文件不用重新编译(只需重新链接)

  • 强类型约束:编译器帮你检查是否用了错误的指针类型

  • ABI 稳定:只要 API 不变,内部实现可以随意重构

3.2 静态函数与私有方法

内部辅助函数用 static 修饰:

复制代码
// motor.c 内部
static int32_t Motor_ReadADC(uint8_t channel)
{
    // 底层 ADC 读取逻辑
    return 0;
}

static void Motor_UpdatePI(motor_t *self)
{
    // PID 计算,外部不需要知道
}

int32_t Motor_GetSpeed(const motor_t *self)
{
    // 公开 API 调用内部实现
    return self ? self->actual_speed : 0;
}

static 函数的优势:

  • 不可被外部引用:编译器可以做更激进的优化(内联、消除)

  • 不污染全局符号表 :--gc-sections 更容易裁剪

  • 文档化:一眼就能看出哪些是"公开 API",哪些是"内部实现"

3.3 静态内存 vs 动态分配

上面用 malloc 分配 motor 对象,但嵌入式里通常不用动态内存(碎片、不确定性,见第 2 篇)。

静态对象模式:

复制代码
// motor.c
static motor_t s_motors[MOTOR_MAX_COUNT];
static uint8_t s_motor_used[MOTOR_MAX_COUNT];

motor_t *Motor_Create(uint8_t id)
{
    if (id >= MOTOR_MAX_COUNT) return NULL;
    if (s_motor_used[id]) return NULL;    // 已被占用

    motor_t *self = &s_motors[id];
    self->id = id;
    s_motor_used[id] = 1;
    return self;
}

void Motor_Destroy(motor_t *self)
{
    if (!self) return;
    s_motor_used[self->id] = 0;
}

静态对象模式的优点:

  • 零碎片:全部编译期分配

  • 确定性 :Motor_Create 耗时恒定

  • 无内存泄漏:物理上不可能泄漏

缺点: 对象数量固定,不支持动态创建。

嵌入式推荐: 用静态对象模式,只在必须动态创建的场景用内存池。

四、第二层:继承

4.1 结构体嵌套模拟继承

目标: 让一个"子类型"复用父类型的字段和方法。

C 语言没有继承语法,但可以通过把父结构体作为子结构体的第一个字段来实现:

复制代码
// 父类:通用传感器接口
typedef struct {
    uint8_t  type;
    int32_t  value;
    uint32_t timestamp;
} sensor_t;

// 子类:温度传感器(复用 sensor_t 的字段)
typedef struct {
    sensor_t base;              // ★ 必须是第一个字段
    int16_t  raw_adc;
    int16_t  offset;
} temp_sensor_t;

// 子类:压力传感器
typedef struct {
    sensor_t base;
    uint32_t pressure_pa;
    uint8_t  oversample;
} pressure_sensor_t;

为什么父结构体必须是第一个字段?

因为这样 temp_sensor_t * 和 sensor_t * 的地址相同,可以安全地互相转换:

复制代码
temp_sensor_t temp;
sensor_t *base = (sensor_t *)&temp;   // 安全,地址相同

如果父结构体不是第一个字段,转换后地址会偏移,指针就错了。

4.2 向上转型(Upcast)

子类指针可以安全地转为父类指针:

复制代码
temp_sensor_t temp;
temp.base.type = SENSOR_TYPE_TEMP;

// 向上转型
sensor_t *s = (sensor_t *)&temp;

// 现在可以用父类接口操作子类对象

4.3 向下转型(Downcast)

父类指针转回子类指针需要显式转换,且必须确认类型:

复制代码
void ProcessSensor(sensor_t *s)
{
    if (s->type == SENSOR_TYPE_TEMP) {
        temp_sensor_t *t = (temp_sensor_t *)s;
        // 现在可以访问 t->raw_adc, t->offset
    } else if (s->type == SENSOR_TYPE_PRESSURE) {
        pressure_sensor_t *p = (pressure_sensor_t *)s;
        // 访问 p->pressure_pa
    }
}

不安全的地方: 如果类型不匹配,转换后访问会读到垃圾数据。必须在转换前检查 type 字段。

4.4 实战:驱动抽象层

用一个真实的驱动框架例子说明继承的用途:

复制代码
// hal_gpio.h ------ GPIO 抽象接口
typedef enum {
    GPIO_DIR_INPUT,
    GPIO_DIR_OUTPUT,
} gpio_dir_t;

// 父类:抽象 GPIO 设备
typedef struct gpio_dev gpio_dev_t;

struct gpio_dev {
    const char *name;
    int  (*init)(gpio_dev_t *self, gpio_dir_t dir);
    int  (*write)(gpio_dev_t *self, uint8_t value);
    int  (*read)(gpio_dev_t *self);
    void (*deinit)(gpio_dev_t *self);
};

// 子类:STM32 GPIO 实现
typedef struct {
    gpio_dev_t base;          // 继承父类
    GPIO_TypeDef *port;
    uint16_t pin;
} stm32_gpio_t;

// 子类:Linux sysfs GPIO 实现
typedef struct {
    gpio_dev_t base;
    int fd;
    uint8_t pin_num;
} linux_gpio_t;

键点: 父类定义了接口契约(init/write/read/deinit),子类提供具体实现。这是"继承 + 多态"的基础。

五、第三层:多态

5.1 函数指针表(虚表)

多态的核心:同一个接口调用,根据对象的实际类型执行不同代码。

C 语言里通过函数指针表(虚表) 实现:

复制代码
// hal_gpio.c
// 统一的 API,内部转发到具体实现
int GPIO_Init(gpio_dev_t *self, gpio_dir_t dir)
{
    if (!self || !self->init) return -1;
    return self->init(self, dir);      // ★ 多态调用
}

int GPIO_Write(gpio_dev_t *self, uint8_t value)
{
    if (!self || !self->write) return -1;
    return self->write(self, value);
}

int GPIO_Read(gpio_dev_t *self)
{
    if (!self || !self->read) return -1;
    return self->read(self);
}

具体实现:

复制代码
// stm32_gpio.c
static int stm32_gpio_init(gpio_dev_t *self, gpio_dir_t dir)
{
    stm32_gpio_t *stm = (stm32_gpio_t *)self;
    // HAL 或 LL 初始化
    return 0;
}

static int stm32_gpio_write(gpio_dev_t *self, uint8_t value)
{
    stm32_gpio_t *stm = (stm32_gpio_t *)self;
    HAL_GPIO_WritePin(stm->port, stm->pin, value ? GPIO_PIN_SET : GPIO_PIN_RESET);
    return 0;
}

static int stm32_gpio_read(gpio_dev_t *self)
{
    stm32_gpio_t *stm = (stm32_gpio_t *)self;
    return HAL_GPIO_ReadPin(stm->port, stm->pin);
}

// 构造函数:初始化父类的函数指针
gpio_dev_t *STM32_GPIO_Create(const char *name, GPIO_TypeDef *port, uint16_t pin)
{
    static stm32_gpio_t s_gpios[8];
    static uint8_t s_used = 0;

    if (s_used >= 8) return NULL;

    stm32_gpio_t *self = &s_gpios[s_used++];
    self->base.name   = name;
    self->base.init   = stm32_gpio_init;
    self->base.write  = stm32_gpio_write;
    self->base.read   = stm32_gpio_read;
    self->base.deinit = NULL;
    self->port = port;
    self->pin  = pin;
    return &self->base;
}

使用:

复制代码
// 上层代码完全不知道底层是 STM32 还是 Linux
gpio_dev_t *led = STM32_GPIO_Create("LED", GPIOA, GPIO_PIN_5);
GPIO_Init(led, GPIO_DIR_OUTPUT);
GPIO_Write(led, 1);

换成 Linux GPIO,上层代码不用改一行:

复制代码
gpio_dev_t *led = Linux_GPIO_Create("LED", 17);   // 只是构造函数不同
GPIO_Init(led, GPIO_DIR_OUTPUT);
GPIO_Write(led, 1);

这就是多态的价值:接口不变,实现可换。

5.2 用多态重构引子里的 UART 问题

回到引子里的 UART 问题。用多态重构:

复制代码
// comm.h ------ 通信抽象接口
typedef struct comm comm_t;

struct comm {
    const char *name;
    int  (*init)(comm_t *self);
    int  (*send)(comm_t *self, const uint8_t *data, uint16_t len);
    int  (*recv)(comm_t *self, uint8_t *buf, uint16_t len);
    void (*deinit)(comm_t *self);
};

// 统一 API
int Comm_Init(comm_t *self);
int Comm_Send(comm_t *self, const uint8_t *data, uint16_t len);
int Comm_Recv(comm_t *self, uint8_t *buf, uint16_t len);

// comm.h ------ 通信抽象接口
typedef struct comm comm_t;

struct comm {
    const char *name;
    int  (*init)(comm_t *self);
    int  (*send)(comm_t *self, const uint8_t *data, uint16_t len);
    int  (*recv)(comm_t *self, uint8_t *buf, uint16_t len);
    void (*deinit)(comm_t *self);
};

// 统一 API
int Comm_Init(comm_t *self);
int Comm_Send(comm_t *self, const uint8_t *data, uint16_t len);
int Comm_Recv(comm_t *self, uint8_t *buf, uint16_t len);

// comm_uart.c ------ UART 实现
static int uart_send(comm_t *self, const uint8_t *data, uint16_t len)
{
    uart_comm_t *u = (uart_comm_t *)self;
    HAL_UART_Transmit(u->huart, (uint8_t *)data, len, 100);
    return 0;
}

comm_t *Comm_UART_Create(UART_HandleTypeDef *huart)
{
    static uart_comm_t s_uart;
    s_uart.base.send = uart_send;
    s_uart.hu = huart;
    return &s_uart.base;
}

// comm_can.c ------ CAN 实现
static int can_send(comm_t *self, const uint8_t *data, uint16_t len)
{
    can_comm_t *c = (can_comm_t *)self;
    // CAN 发送逻辑
    return 0;
}

comm_t *Comm_CAN_Create(CAN_HandleTypeDef *hcan)
{
    static can_comm_t s_can;
    s_can.base.send = can_send;
    s_can.h = hcan;
    return &s_can.base;
}

上层协议代码(如 Modbus)只依赖 comm_t 接口:

复制代码
void Modbus_SendResponse(comm_t *comm, uint8_t *frame, uint16_t len)
{
    Comm_Send(comm, frame, len);   // 不知道底层是 UART 还是 CAN
}

想从 UART 换 CAN? 只需在初始化时换个构造函数:

复制代码
// 之前
comm_t *comm = Comm_UART_Create(&huart2);

// 之后
comm_t *comm = Comm_CAN_Create(&hcan1);

// 上层 Modbus 代码一行不改

这就是模块解耦的价值。

5.3 完整的多态框架设计

一个成熟的驱动框架会进一步抽象:

复制代码
// device.h ------ 通用设备抽象
typedef struct device device_t;

typedef enum {
    DEV_OK = 0,
    DEV_ERR_PARAM,
    DEV_ERR_IO,
    DEV_ERR_TIMEOUT,
    DEV_ERR_BUSY,
} dev_err_t;

struct device {
    const char *name;
    uint8_t     id;

    // 生命周期
    dev_err_t (*init)(device_t *self);
    dev_err_t (*deinit)(device_t *self);

    // 电源管理
    dev_err_t (*suspend)(device_t *self);
    dev_err_t (*resume)(device_t *self);

    // 控制
    dev_err_t (*ioctl)(device_t *self, uint32_t cmd, void *arg);
};

// 通用 API
dev_err_t Device_Init(device_t *self);
dev_err_t Device_Deinit(device_t *self);
dev_err_t Device_Suspend(device_t *self);
dev_err_t Device_Resume(device_t *self);
dev_err_t Device_Ioctl(device_t *self, uint32_t cmd, void *arg);

具体设备只需实现自己关心的接口:

复制代码
// 一个 UART 设备
typedef struct {
    device_t base;
    UART_HandleTypeDef *huart;
    uint32_t baudrate;
} uart_device_t;

static dev_err_t uart_ioctl(device_t *self, uint32_t cmd, void *arg)
{
    uart_device_t *u = (uart_device_t *)self;
    switch (cmd) {
        case UART_CMD_SET_BAUD:
            u->baudrate = *(uint32_t *)arg;
            // 重新配置波特率
            return DEV_OK;
        case UART_CMD_GET_BAUD:
            *(uint32_t *)arg = u->baudrate;
            return DEV_OK;
        default:
            return DEV_ERR_PARAM;
    }
}

这个框架的价值:

  • 所有设备统一接口,上层代码通用

  • 新设备只需实现自己的 ioctl

  • 设备可以统一做电源管理、初始化、注销

六、C 语言 OOP 的工程实践

6.1 命名约定

复制代码
// 类型
typedef struct motor motor_t;              // 小写 + _t

// 函数:模块_动作
motor_t *Motor_Create(...);                // 大驼峰
int      Motor_SetSpeed(...);

// 常量/宏
#define MOTOR_MAX_COUNT  8                 // 全大写

// 内部函数
static int motor_calculate_pi(...);        // 小写 + 下划线

大驼峰用于公开 API,小写下划线用于内部实现,一眼能区分。

6.2 头文件组织

复制代码
// motor.h
#ifndef __MOTOR_H
#define __MOTOR_H

#ifdef __cplusplus
extern "C" {
#endif

// 包含其他头文件
#include <stdint.h>
#include <stddef.h>

// 前向声明
typedef struct motor motor_t;

// 公开 API
motor_t *Motor_Create(uint8_t id);
void     Motor_Destroy(motor_t *self);

#ifdef __cplusplus
}
#endif
#endif

要点:

  • #ifndef 卫哨防止重复 include

  • extern "C" 支持 C++ 调用

  • 只 include 必要的头文件,减少依赖

6.3 错误处理约定

统一错误码:

复制代码
typedef enum {
    ERR_OK      =  0,
    ERR_PARAM   = -1,
    ERR_NOMEM   = -2,
    ERR_IO      = -3,
    ERR_TIMEOUT = -4,
} err_t;

统一返回类型:

复制代码
err_t Motor_SetSpeed(motor_t *self, int32_t speed);

不要用 void 返回,也不要只返回 -1。 明确的错误码让调用者能区分不同错误。

6.4 避免的陷阱

静态断言示例(C11):

复制代码
_Static_assert(offsetof(temp_sensor_t, base) == 0, "base must be first");

编译期就能发现"第一个字段不是 base"的错误。

七、实战案例:完整驱动框架

把上面的机制组合成一个完整的驱动框架:

复制代码
┌─────────────────────────────────────────┐
│  应用层:Modbus、PID、状态机            │
├─────────────────────────────────────────┤
│  抽象层:comm_t、sensor_t、motor_t      │
├─────────────────────────────────────────┤
│  实现层:UART、CAN、ADC、PWM            │
├─────────────────────────────────────────┤
│  硬件层:STM32 HAL/LL 或直接寄存器     │
└─────────────────────────────────────────┘

每层只依赖下一层的接口 ,不依赖具体实现。这才是真正的模块解耦。

实测效果:

  • 项目从 UART 换 CAN,改动 2 个文件(vs 原方案 15 个文件)

  • 单元测试覆盖率达到 65%(vs 原方案无法测试)

  • 新增一种传感器只需 3 小时(vs 原方案 2 天)

  • 代码总体积减少 12%(消除重复代码)

八、避坑清单

九、小结与预告

本文从一个真实的"驱动框架越改越乱"的项目出发,系统演示了在 C 语言里实现 OOP 三个层次的方法------封装(不透明指针)、继承(结构体嵌套)、多态(函数指针表)。通过一套完整的驱动抽象框架,展示了模块解耦的实战价值:UART 换 CAN 从改 15 个文件降到改 2 个。

三个核心要点:

  1. OOP 是思维方式,C 语言完全可以实现封装、继承、多态

  2. 父结构体必须是子结构体的第一个字段,否则向上转型会错

  3. 多态的价值是"接口不变、实现可换",这是模块解耦的关键

下篇预告:第 13 篇《状态机编写指南:告别 switch-case 地狱,实现表驱动与层次化状态机(HSM)》

将拆解三种状态机实现方式------朴素 switch-case、表驱动、层次化状态机(HSM),从简单到复杂逐步演进。用一个真实的通信协议解析案例,演示如何用 HSM 处理嵌套状态、如何用表驱动消除大量 if-else、以及如何可视化状态机便于调试。包含完整可移植代码和性能对比。

本文是《嵌入式系统调优高手课》的付费内容。 完整专栏收录 20 篇深度长文,涵盖 RTOS 调度器汇编级拆解、内存池设计、Cache 一致性、低功耗陷阱与安全启动。每一篇都包含真实事故复盘、可移植源码和实测数据。如果你不想再靠"重启试试"解决问题,这个专栏就是为你写的。

交付物清单(code/ 目录):

  • oop_utils.h:C 语言 OOP 的通用宏和工具(静态断言、容器宏等)

  • device.h/.c:通用设备抽象框架

  • comm_uart.c / comm_can.c:两种通信实现示例

  • motor.c/h:完整的封装示例

  • sensor_inherit.c/h:继承示例

  • oop_demo/:可运行的完整示例工程

参考资源:

  • 《Object-Oriented Programming with ANSI-C》Axel-Tobias Schreiner 著(经典必读)

  • 《Design Patterns for Embedded Systems in C》Bruce Powel Douglass 著

  • 《Patterns in C》Adam Tornhill 著

  • Linux 内核源码:字符设备驱动框架(OOP 思想的典型实践)

相关推荐
冲,干,闯1 小时前
硬件器件-晶振篇
嵌入式硬件
代码山河1 小时前
键盘输入与输出:Scanner与System.out详解
java·学习·架构·教程·面向对象·项目
瓶瓶持续学习中1 小时前
数字孪生到空间智能,5家厂商的技术路线有何不同?
大数据·人工智能·信息可视化·架构
hanhahai1 小时前
初识单片机
单片机·嵌入式硬件
2601_954811821 小时前
人工智能实验室集群选型:32节点PB级存储架构实践
人工智能·架构
深圳老胡1 小时前
STM32 工程在 VSCode 中用 AI 搭建软件骨架架构:从零到可运行的分层实践
笔记·stm32·单片机·代码规范
Acrel12341 小时前
电力物联网中的边缘计算网关:从数据采集到边缘决策的架构实践
物联网·架构·边缘计算
武汉万象奥科2 小时前
T153芯片解析:ARM+RISC-V异构架构,集成三路千兆网
arm开发·嵌入式硬件·物联网·arm核心板
恒锐丰科技林技术员2 小时前
EG393 双通道电压比较器芯片解析
经验分享·嵌入式硬件·硬件工程