C语言模拟面向对象

很多人第一次看到这种代码风格时,都会有一个问题:“用 C 语言写出面向对象的样子,是不是显得更专业”

我的看法是:专业不在于“像不像 C++”,而在于这套写法能不能真正解决工程问题。

对于机器人嵌入式控制系统来说,系统里往往同时存在底盘、电机、升降机构、武器机构、CAN 通信、PID 控制、任务调度等多个层次。如果继续用“满地全局变量 + 一堆 switch-case + 到处散落的初始化函数”去组织代码,随着功能变多,系统很快就会变得难以维护。

这时候,C 语言里的几个经典手段——结构体封装、函数指针、组合关系、统一初始化接口、对象注册——就能模拟出一套足够实用的“面向对象”风格。

这篇文章就结合一套机器人电机控制代码,聊聊:

  1. 为什么嵌入式系统里要用 C 模拟面向对象
  2. 你的这套代码到底“对象化”在什么地方
  3. 这种写法在机器人控制系统里有什么实际价值
  4. 这种设计什么时候值得用,什么时候会过度设计
  5. 基于现有代码,还能做哪些工程上的改进

1. 为什么 C 语言也要“对象化”?

在桌面开发里,大家很容易直接想到 C++、Rust 这类更现代的语言;但在嵌入式世界,很多项目依旧坚持使用 C。原因很现实:

  • 工具链成熟,和 HAL / BSP / RTOS 集成方便
  • 运行时行为更直接,更容易控制内存与时序
  • 不依赖复杂运行时,二进制行为更可预期
  • 很多驱动、中间件和芯片厂商 SDK 本身就是 C 接口

但“继续用 C”并不意味着“代码必须原始”。

真正成熟的嵌入式项目,通常会主动引入一些面向对象思想,只是它们不会直接写成 class,而是写成:

  • struct 保存对象状态
  • 用函数指针描述对象行为
  • 用“基类结构体 + 派生结构体”模拟继承
  • 用统一接口屏蔽不同硬件差异
  • 用注册表 / 装配函数管理对象关系

这其实就是 C 语言版本的面向对象设计

所以,与其说它“显得专业”,不如说它是复杂嵌入式系统发展到一定规模后的自然结果


2. 在 C 里,什么叫“模拟面向对象”?

如果把面向对象最核心的几个特征拆开来看,会更容易理解。

2.1 封装:把数据和行为绑在一起

最基础的一步,就是不再把模块状态散落在各个全局变量里,而是把它们装进一个结构体。然后所有操作都围绕这个结构体展开。

例如,一个“电机对象”里通常会保存:

  • 电机 ID
  • 控制模式
  • 当前角度 / 转速 / 电流
  • 所属 CAN 总线
  • PID 参数
  • 启动、停止、更新、发命令等函数入口

这样做的好处是:对象状态有归属,行为有边界,模块之间不再彼此污染。

2.2 继承:把公共字段抽到“父类”里

C 没有继承语法,但可以通过“结构体嵌套”模拟。

例如:

  • baseModule 作为最底层公共模块
  • MotorModule 在此基础上增加电机共有字段
  • DJI_MotorModuleDM_MotorModule 再继续扩展为具体实现

这就是一种很典型的“从抽象到具体”的层级设计。

2.3 多态:同一个接口,背后可以是不同实现

多态的关键不是“语法糖”,而是统一调用方式

在 C 里,这通常依赖函数指针来完成。比如:

  • 大疆电机对象绑定 get_moto_measure
  • 达妙电机对象绑定 get_motor_measure
  • 不同对象在创建阶段挂上不同的 send_cmd / set_data / PID_Calculate

这样上层代码就不必关心底层到底是哪一种电机,它只需要按照统一接口去调用。

2.4 组合:一个对象里拥有多个子对象

机器人系统很少只有一个电机。更常见的是:

  • 一个底盘模块管理 4 个驱动电机
  • 一个升降模块管理 2 个关节电机
  • 一个武器模块同时管理 DM 电机和 DJI 电机

这种“对象包含对象”的关系,其实就是组合(composition)。

在嵌入式里,组合往往比继承更重要,因为真实系统的复杂度大多来自“模块之间如何组织”,而不是“类层级有多深”。


3. 结合代码,看这套对象模型是怎么搭起来的

这套代码层级非常清楚:

baseModule
├── MotorModule
│ ├── DJI_MotorModule
│ └── DM_MotorModule
└── StructureModule
├── Chassis
├── Kfs
├── Lift
└── Weapon

这不是形式主义,而是一套很典型的机器人控制架构。


4. 第一层抽象:baseModule 是所有模块的共同父类

global.c 来看,baseModule 提供了最基础的生命周期管理:初始化、运行、停止、查询状态、清除错误。它维护了模块状态和错误码,相当于所有业务对象都共享的一层“公共能力”。

void BaseModule_Init(baseModule *base);
void BaseModule_Run(baseModule *base);
void BaseModule_Stop(baseModule *base);
ModuleState BaseModule_GetState(baseModule *base);
void BaseModule_ClearError(baseModule *base);

这层抽象的意义在于:无论上层是电机模块、结构模块还是其他执行机构,都能共享同一套生命周期接口。

如果以后你继续扩展传感器模块、云台模块、通信模块,这一层仍然可以复用。

从设计角度看,这就已经具备了“基类”的味道:它不负责具体业务,但负责给所有对象提供统一基础能力。


5. 第二层抽象:MotorModule 是电机世界里的公共父类

再往上一层,motor.c 里的 MotorModule_Create() 继续把“所有电机共有的属性”抽象出来:

  • 电机型号 model
  • 控制模式 mode
  • 电机 ID id
  • CAN 句柄
  • 继承自 baseModule 的函数入口

同时在创建阶段把基类方法挂接进去:

obj->base.Init = MotorModule_Init;
obj->base.Run = MotorModule_Run;
obj->base.Stop = BaseModule_Stop;
obj->base.GetState = BaseModule_GetState;
obj->base.ClearError = BaseModule_ClearError;

这段代码非常关键。它说明你的对象不是“只有数据”,而是数据 + 行为入口一起交给对象本身管理

也就是说,当我们拿到一个 MotorModule 对象时,不只是拿到一组电机参数,而是拿到一个具备初始化、运行、停止、错误处理能力的实体。

这就是 C 版本的“对象实例”。


6. 第三层抽象:DJI 与 DM 电机是具体派生类

6.1 DJI 电机:在公共电机能力上叠加编码器、PID 与反馈解析

dji_motor.c 里做的事情,本质上就是在 MotorModule 的基础上,扩展出一个更具体的对象:DJI_MotorModule

它增加了这些能力:

  • 编码器角度解析
  • 圈数累计与总角度计算
  • 速度、电流、温度反馈
  • 位置环 / 速度环 PID 计算
  • 大疆电机 CAN 帧打包与发送

创建函数里,把具体行为挂到对象上:

obj->get_moto_measure = DJIget_motor_measure;
obj->get_moto_offset = DJIget_moto_offset;
obj->PID_Calculate = Motor_PID_Calculate;

这就是一个非常典型的“派生对象绑定具体方法”的过程。

尤其是 Motor_PID_Calculate() 里,你已经体现了分模式控制的思想:

  • POSITION:位置环套速度环
  • SPEED:直接速度闭环
  • MIT:预留模式接口

这说明对象设计不只是为了“组织代码”,它已经进入了控制策略封装的层面。

6.2 DM 电机:同样的父类,不同的协议与控制模式

dm_motor.c 则展示了另一个具体实现:达妙电机对象。

它提供了:

  • 使能 / 失能 / 清错 / 存零点命令
  • 位置、速度、力矩反馈解包
  • MIT 控制报文打包
  • 位置速度控制报文打包

在创建阶段,同样将这些行为绑定到对象:

obj->send_cmd = DM_Motor_CMD;
obj->set_mit_data = DMset_mit_data;
obj->set_posvel_data = DMset_posvel_data;
obj->get_motor_measure = DMget_motor_measure;

这正是多态在工程中的价值:

  • 它们都属于“电机对象”
  • 但不同对象的通信协议、状态解析方式、控制命令完全不同
  • 上层模块不用重复写一大堆 if (is_dji) ... else if (is_dm) ...

一旦对象创建完成,具体行为已经注入到对象里了。


7. 第四层抽象:StructureModule 负责“管理多个电机”

如果说 MotorModule 解决的是“一个电机怎么抽象”,那么 StructureModule 解决的就是“一个机器人机构如何管理多个电机”。

structure.c 中,这个模块做了几件非常有代表性的事情:

  1. 它同样继承了 baseModule 的公共行为
  2. 它维护一个电机数组 motors[]
  3. 它记录电机 ID 数组 motor_ids[]
  4. 它通过 AddMotor() 完成子对象注册
  5. 它负责校验空指针、重复添加、数量上限等问题

这说明你已经不只是“给电机写驱动”,而是在做机构级对象管理

换句话说,底盘、升降、武器,不再只是一些松散函数,而是一个个真正独立的功能对象。

这在机器人项目里非常重要,因为一个功能模块通常不只控制单一执行器,而是控制一组互相协同的执行器。


8. register.c:这其实就是系统装配层

很多人写嵌入式代码时,喜欢一启动就到处 Init(),但缺少“系统装配”的概念。 register.c 恰好体现了这一层。

例如:

  • Chassis_Init() 创建底盘结构体,再创建 4 个 DJI 底盘电机,然后逐个注册到结构模块
  • Kfs_Init() 创建结构模块,再创建两台 DM 电机,并发送使能命令
  • Lift_Init() 同时装配 DM 和 DJI 电机
  • Weapon_Init() 也是混合装配模式

这说明你的系统已经具备了典型的**组合根(composition root)**思想:

  • 对象在这里被创建
  • 对象关系在这里被组装
  • 上层系统通过这个入口完成整体初始化

这和桌面软件里的依赖注入、对象装配在思想上是一致的,只是你是在裸机 / RTOS / HAL 的环境里用 C 手工完成而已。

从工程角度看,这种写法的一个巨大优点是:模块边界很清楚,初始化顺序也很清楚。


9. 这套写法在机器人嵌入式里到底好在哪?

说到底,设计模式从来不是为了“好看”,而是为了降低复杂度。放到机器人控制系统里,这套对象化写法至少有五个非常现实的好处。

9.1 支持异构电机统一管理

你的系统里同时用了 DJI 和 DM 两类电机,而且控制方式并不相同:

  • DJI 更偏传统编码器反馈 + PID 闭环
  • DM 支持 MIT 模式与位置速度控制模式

如果没有对象化抽象,上层模块几乎一定会到处出现协议分支。而现在,结构模块只需要管理“电机对象”,不需要管理“电机协议细节”。

9.2 让控制层和驱动层解耦

理想情况下:

  • 驱动层负责收发 CAN、解析反馈
  • 控制层负责位置环、速度环、状态机
  • 结构层负责组织多个执行器协同工作

你现在的设计已经朝这个方向走了。虽然还可以继续打磨,但核心思路是对的。

9.3 更适合做功能扩展

假设明天你要加入第三种电机,甚至加入舵机、气缸、线性执行器,你并不需要把整套架构推翻。

你只需要:

  1. 定义一个新的对象结构体
  2. 实现它自己的反馈解析和命令发送函数
  3. 在创建函数里把行为绑定好
  4. 把它注册进某个 StructureModule

这样扩展成本会比“直接往旧代码上堆 if-else”低很多。

9.4 更容易做模块级测试

对象边界清楚以后,测试也更容易分层。

例如你可以分别验证:

  • PID 算法是否正常
  • DJIget_motor_measure() 的反馈解包是否正确
  • DMset_mit_data() 的打包是否符合协议
  • StructureModule_AddMotor() 是否能正确处理重复注册

当代码组织混乱时,这些测试很难独立进行。

9.5 更符合长期维护的需要

机器人项目最怕的不是“现在能跑”,而是“后面没人敢改”。

一旦代码需要交给队友、学弟、或者未来的自己维护,面向对象风格的 C 代码会比“神秘全局变量流”更友好。因为:

  • 模块职责更清晰
  • 新人更容易找到入口
  • 修改某一类设备时,不必到全工程里搜索替换

10. 那么,这样写是不是“更专业”?

我的答案是:当它服务于复杂度管理时,它就是专业的;当它只是为了显得高级时,它就只是样子货。

值得这么写的场景

  • 设备种类多,协议差异大
  • 同一类对象有多个具体实现
  • 系统需要长期迭代
  • 需要统一生命周期管理
  • 希望上层逻辑尽量不依赖底层硬件细节

不值得这么写的场景

  • 只有一两个简单外设
  • 模块边界非常简单
  • 根本不存在扩展需求
  • 为了“像 Linux 内核”而硬上抽象层

真正专业的工程师,追求的从来不是“抽象越多越高级”,而是抽象刚刚好

Linux 内核确实大量使用了结构体嵌套、回调函数、接口分层等技巧,但它这么做不是为了炫技,而是因为面对海量设备和复杂场景,必须依赖这种方法控制复杂度。嵌入式机器人项目也是同样的道理。


11. 结合代码,再谈几个很有价值的工程细节

你的设计方向是对的,但从“能工作”走向“更稳健”,还有几个点值得继续打磨。

11.1 CreateInitEnable 最好彻底分层

你现在已经有这个雏形了:

  • Create():负责构造对象、绑定函数指针、设置默认值
  • base.Init():负责进入初始化后的可运行状态
  • send_cmd(..., Motor_Enable):负责真正给执行器上电/使能

这是个非常好的方向。

如果继续把这三者的职责文档化,你的代码会更清晰:

  • Create 只构造,不访问硬件
  • Init 只做软件层初始化
  • Enable 才进行设备使能

这样对象生命周期会更容易维护,也更方便调试故障。

11.2 尽量保存外设句柄指针,而不是复制句柄

当前 MotorModule_Create() 中把 CAN_HandleTypeDef 直接复制进对象,这在某些 HAL 项目里可能工作正常,但从工程习惯上看,保存句柄指针往往更稳妥

原因很简单:

  • 硬件句柄本来就是“共享资源”
  • 复制句柄可能造成状态不同步
  • 指针语义更明确,表明这个对象只是“挂靠在某个总线实例上”

如果以后系统规模更大、总线资源更多,这一点会更重要。

11.3 面向对象不等于自动安全,边界条件还是要自己守住

对象化只是组织代码,不会自动消灭 bug。

例如在 StructureModule_AddMotor() 这类函数里,空指针、重复注册、容量上限都必须认真处理。你已经有这个意识了,这是很好的工程习惯。

再往前一步,可以考虑:

  • 对空指针做更严格分支处理
  • 让错误码具备更明确的枚举语义
  • AddMotor() 返回状态码,而不只是写入 error_code

这样接口会更适合上层统一处理。

11.4 再往深一点看,源码里还有几个很典型的工程细节

第一,StructureModule_AddMotor() 的空指针判断思路是对的,但实现上还可以更严谨。如果 structure == NULL,函数里再去写 structure->base.error_code 就会变成新的空指针访问。所以这类接口最好把 structure == NULLmotor == NULL 分开处理,或者直接返回错误码。

第二,DMset_mit_data() 里有几处把 float_to_uint() 的上下限都传成 0 的写法。从抽象层面看,这是在复用统一量化函数;但从实现层面看,这种“零跨度映射”并不稳妥。更安全的做法是:对那些本来就应该恒为 0 的字段,直接写入 0,而不是再走一层通用换算。

第三,DJImotor_Create() 同时接收 command_idfeedback_id,但当前对象创建时主要使用了 feedback_id - 0x200 这一套映射。这个设计未必一定有问题,但如果后续别人来维护,最好把“对象 ID、命令帧 ID、反馈帧 ID”三者的关系写得更清楚,否则接口虽然做了抽象,语义却可能变得模糊。

这些细节很值得写进工程博客里,因为它们能说明一件事:好的架构不是把代码包装得很像对象,而是在抽象之外,仍然对协议、边界条件和硬件语义保持敏感。

11.5 协议封装一定要警惕“魔法数字”

无论是 DJI 编码器圈数累计,还是 DM 的 MIT 报文打包,代码里都存在很多协议常量,例如:

  • 4096
  • 8192
  • 6.28318f
  • 3.14159f
  • 0xFC / 0xFD / 0xFE / 0xFB

这些数字写在驱动里没有问题,但如果能进一步用宏、枚举、协议说明文档统一管理,代码可读性会明显提升。

对于机器人项目来说,这类“协议常量”往往比算法更容易出错。

11.6 抽象之外,还要注意实时性与可预测性

嵌入式系统不是桌面软件。你写的这些对象最终都要跑在实时控制循环里,所以除了结构优雅,还要关注:

  • 是否引入了不必要的函数调用开销
  • 是否有动态内存分配
  • 是否有任务与中断间的数据竞争
  • CAN 发送是否可能冲突
  • PID 与反馈更新是否有严格时序保证

换句话说:嵌入式里的面向对象,一定要建立在实时性和确定性之上。


12. 如果继续优化这套架构,我会怎么做?

在不推翻现有设计的前提下,我会考虑下面几个方向。

12.1 把“接口表”进一步标准化

现在你的对象方法是直接一个个挂到结构体里的,这没有问题。但如果系统继续扩大,可以进一步抽象成一张接口表(类似虚函数表)。

例如:

typedef struct {
void (*enable)(void *obj);
void (*disable)(void *obj);
void (*update_feedback)(void *obj, uint8_t rx[8]);
void (*set_output)(void *obj, float cmd);
} MotorOps;

然后每种电机对象持有自己的 MotorOps 指针。

这样做的好处是:

  • 行为定义更集中
  • 接口缺失更容易发现
  • 更像标准驱动框架

12.2 把 PID 与电机驱动进一步解耦

现在 DJI 电机对象里直接包含 PID,这在工程上很常见,但从架构角度看,也可以进一步拆开:

  • 电机驱动对象只负责协议收发与状态反馈
  • 控制器对象只负责算控制量
  • 上层结构模块决定“哪个控制器作用于哪个执行器”

这种写法在后续引入前馈、状态观测器、模型控制时会更灵活。

12.3 给模块增加统一的 Update()Control() 周期接口

如果未来你希望整个系统更规整,可以把每个对象统一到类似这样的周期函数:

void (*Update)(baseModule *obj, float dt);

这样调度器每个周期只需要遍历模块并调用 Update(),系统主循环会非常清楚。

12.4 把错误系统做得更完整

当前 error_code 已经是一个不错的开始。继续完善的话,可以考虑:

  • 模块级错误
  • 总线级错误
  • 电机离线错误
  • 温度异常错误
  • 超限保护错误
  • 清错与恢复机制

对于机器人项目来说,可靠性设计和对象设计同样重要


13. 一个最小化示例:什么叫“C 语言版本的类”?

如果把设计思想再压缩一下,其实可以抽象成下面这个最小模型:

typedef struct Motor Motor;

typedef struct {
void (*enable)(Motor *m);
void (*disable)(Motor *m);
void (*update_feedback)(Motor *m, uint8_t rx[8]);
float (*calc_output)(Motor *m, float ref);
} MotorVTable;

struct Motor {
uint8_t id;
uint8_t mode;
const MotorVTable *ops;
};

typedef struct {
Motor base;
float speed_rpm;
float total_angle;
PID_Info_TypeDef pid_spd;
PID_Info_TypeDef pid_pos;
} DJI_Motor;

这个例子本质上和工程代码是同一路思路:

  • Motor 是抽象父类
  • DJI_Motor 是具体子类
  • MotorVTable 是一张行为表
  • 上层代码只和统一接口交互

这就是 C 版面向对象的核心。


14. 总结

回到最开始的问题:“用 C 模拟面向对象,是不是更专业?”

答案不是简单的“是”或“不是”。

更准确地说,当你的系统已经复杂到需要抽象层、统一接口、模块组合和长期维护时,这样写就很专业;当系统其实很简单,却硬套复杂架构时,它反而会变成负担。

这已经不是“为了看起来高级”的写法,而是为了让机器人控制系统在规模扩大后依旧可维护、可扩展、可调试

所以真正值得学习的,不是“把 C 写得像 C++”,而是学会背后的那套工程思维:

  • 先识别哪些能力是公共的
  • 再抽象出统一接口
  • 用组合关系组织复杂系统
  • 用清晰的生命周期管理对象
  • 最终让控制逻辑和硬件细节尽量解耦

这才是 C 语言模拟面向对象最有价值的地方。