01 总纲:C 语言面向对象设计概览

C 语言面向对象系列

  1. 总纲:C 语言面向对象设计概览(当前篇)
  2. 封装:结构体与多实例接口
  3. 信息隐藏:公共接口与内部实现
  4. 模块组织:命名约定与对象生命周期
  5. 数据组织:实例状态、模块状态与常量
  6. HAL 实例:STM32 外设接口中的封装设计
  7. 结构体嵌套:公共数据与行为的复用
  8. 函数指针:可替换行为与回调
  9. 操作表:使用 ops 组织对象接口
  10. 多态:统一接口、行为分发与类型转换
  11. 接口约定:必选操作、可选操作与默认实现
  12. 框架整合:应用、驱动与平台的分层
  13. 工程扩展:自动注册与 Linux 内核中的对象组织

本系列面向已经了解 C 语言和单片机基础、开始编写嵌入式项目的读者。我们以 LED 驱动为例,逐步学习如何组织对象数据、设计操作接口,以及支持不同的硬件实现。

面向对象设计(OOP)可以先理解为:将相关的数据和操作组织在一起,并明确它们的使用方式。C 没有 class 语法,但可以用结构体、函数和函数指针实现这些设计。

从重复的设备接口开始

控制三颗 LED 时,可能会先为每颗灯分别编写接口:

1
2
3
4
5
6
7
void red_led_on(void);
void green_led_on(void);
void blue_led_on(void);

void red_led_off(void);
void green_led_off(void);
void blue_led_off(void);

这类写法容易上手,但同一逻辑需要维护多份。硬件改成低电平点亮、增加亮度限幅,或要求操作前检查初始化状态时,每组函数都可能需要修改。

除了重复代码,还需要留意数据和接口的关系:

情况 可能带来的问题
每颗 LED 各有一套函数 相同逻辑重复,修改时容易遗漏
状态变量分散存放 难以区分数据属于哪个设备或模块
外部直接修改成员 软件状态可能与硬件不一致
初始化顺序只写在注释里 调用方可能在对象准备好之前使用它
上层分别处理每种硬件 新增硬件时需要修改多处分支

这些问题会影响后续接手、硬件改版、需求扩展和故障排查。对于嵌入式设备,状态错误还可能引起电机误转、继电器误动作或传感器数据误判。

用结构体和函数表示对象

先将一颗 LED 所需的数据放进结构体,再声明操作它的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdbool.h>
#include <stdint.h>

typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
bool initialized;
} Led_t;

int led_init(Led_t *me, uint8_t pin);
int led_deinit(Led_t *me);
int led_on(Led_t *me);
int led_off(Led_t *me);
int led_set_brightness(Led_t *me, uint8_t brightness);

这里的 Led_t 是类型,每个 Led_t 变量保存一颗 LED 的数据。Led_t *me 指向本次操作的对象,函数通过它读取引脚、修改状态。

led_init() 负责初始化,led_deinit() 负责结束使用和释放资源;led_on()、led_off() 和 led_set_brightness() 则描述对象支持的操作。initialized 用来记录对象是否已完成初始化。

以点亮操作为例,函数先检查指针和初始化状态,再更新对象及硬件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int led_on(Led_t *me)
{
if (me == NULL) {
return -1;
}

if (!me->initialized) {
return -2;
}

me->is_on = true;
gpio_write(me->pin, true);

return 0;
}

创建两颗 LED 时,只需要准备两份对象数据,调用同一组函数:

1
2
3
4
5
6
7
8
Led_t red;
Led_t green;

led_init(&red, 5);
led_init(&green, 6);

led_on(&red);
led_on(&green);

两次 led_on() 使用同一份实现,传入的对象地址不同,访问的引脚和状态也不同。这是本系列首先要掌握的写法。

各篇代码用于说明当前步骤,gpio_write() 等底层函数需要由具体平台提供。对象应在初始化成功后使用,相关检查和调用顺序会在第 04 篇展开。

从封装逐步扩展到多态

在前面的基础上,可以逐步加入以下设计:

设计内容 C 语言中的做法 作用
保存对象数据 struct 将单个实例的配置和状态放在一起
指定操作对象 Led_t *me 让同一函数操作不同实例
组织公开接口 led_ 等函数前缀 明确函数所属模块
管理生命周期 init/deinit 约定初始化、使用和释放的顺序
隐藏内部实现 源文件中的 static 函数 限定内部辅助函数的链接范围
区分数据范围 实例成员、模块 static 变量 区分独立状态与共享状态
复用公共部分 在结构体中嵌入 base 复用共同的数据和操作
替换单个操作 函数指针、回调 保存或传入要调用的函数
组织一组操作 ops 函数指针表 为不同设备提供同一组接口
统一调用不同实现 base + ops 由对象的操作表选择具体函数

最后一种方式称为多态:调用方使用相同接口,实际执行哪个函数由对象绑定的实现决定。新增一种设备时,如果已有接口能表达它的能力,就可以新增实现和操作表,尽量减少对上层代码的修改。

与 C++ 的对应关系

熟悉 C++ 的读者可以用下面的对照辅助理解;只有 C 基础的读者可以先继续阅读后面的教程。

本系列的 C 写法 C++ 中职责相近的写法
typedef struct { ... } Led_t; 类中的对象数据
Led_t *me 成员函数中的 this
led_init(&led, pin) 通过构造函数初始化,例如 Led led(pin)
led_deinit(&led) 析构函数 ~Led() 中的清理工作
led_on(&led) 成员函数调用 led.on()
源文件中的 static 辅助函数 供内部实现使用的 private 方法
ops 函数指针表 虚函数调用所用的操作分发机制

这些对应主要说明设计用途。C 代码需要显式传入对象指针、调用初始化与清理函数,并自行约定访问范围。后续章节会在使用这些机制时说明具体限制。

仅仅把变量放入结构体,或者提取几个可复用函数,还不足以说明模块的使用方式。还需要明确哪些数据属于对象、外部通过哪些接口访问,以及对象在什么状态下可以使用。

阅读顺序

13 篇沿着同一个 LED 示例逐步展开:

篇章 学习内容
01 总纲 了解系列路线和基本写法
02 封装 用同一组函数操作多个实例
03 信息隐藏 区分公开接口和内部实现
04 模块组织 统一命名,管理初始化和释放
05 数据组织 区分实例状态、模块状态和常量
06 HAL 实例 对照 STM32 外设接口理解封装
07 结构体嵌套 复用公共数据和行为
08 函数指针 传递操作,编写回调
09 操作表 将相关操作组织成 ops
10 多态 通过统一接口调用不同实现
11 接口约定 处理必选操作、可选操作和默认实现
12 框架整合 组织应用、驱动、板级和平台代码
13 工程扩展 理解注册机制及 Linux 内核中的相关设计

驱动、协议栈、HAL 和 RTOS 中都能见到这些组织方式。初学时可以先完成对象、接口和初始化流程;项目需要支持多种实现时,再引入 base、ops 或注册机制。第 13 篇会集中讨论如何选择。