C语言模拟面向对象
C语言模拟面向对象
很多人第一次看到这种代码风格时,都会有一个问题:“用 C 语言写出面向对象的样子,是不是显得更专业”
我的看法是:专业不在于“像不像 C++”,而在于这套写法能不能真正解决工程问题。
对于机器人嵌入式控制系统来说,系统里往往同时存在底盘、电机、升降机构、武器机构、CAN 通信、PID 控制、任务调度等多个层次。如果继续用“满地全局变量 + 一堆 switch-case + 到处散落的初始化函数”去组织代码,随着功能变多,系统很快就会变得难以维护。
这时候,C 语言里的几个经典手段——结构体封装、函数指针、组合关系、统一初始化接口、对象注册——就能模拟出一套足够实用的“面向对象”风格。
这篇文章就结合一套机器人电机控制代码,聊聊:
- 为什么嵌入式系统里要用 C 模拟面向对象
- 你的这套代码到底“对象化”在什么地方
- 这种写法在机器人控制系统里有什么实际价值
- 这种设计什么时候值得用,什么时候会过度设计
- 基于现有代码,还能做哪些工程上的改进
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_MotorModule、DM_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 |
这不是形式主义,而是一套很典型的机器人控制架构。
4. 第一层抽象:baseModule 是所有模块的共同父类
从 global.c 来看,baseModule 提供了最基础的生命周期管理:初始化、运行、停止、查询状态、清除错误。它维护了模块状态和错误码,相当于所有业务对象都共享的一层“公共能力”。
void BaseModule_Init(baseModule *base); |
这层抽象的意义在于:无论上层是电机模块、结构模块还是其他执行机构,都能共享同一套生命周期接口。
如果以后你继续扩展传感器模块、云台模块、通信模块,这一层仍然可以复用。
从设计角度看,这就已经具备了“基类”的味道:它不负责具体业务,但负责给所有对象提供统一基础能力。
5. 第二层抽象:MotorModule 是电机世界里的公共父类
再往上一层,motor.c 里的 MotorModule_Create() 继续把“所有电机共有的属性”抽象出来:
- 电机型号
model - 控制模式
mode - 电机 ID
id - CAN 句柄
- 继承自
baseModule的函数入口
同时在创建阶段把基类方法挂接进去:
obj->base.Init = MotorModule_Init; |
这段代码非常关键。它说明你的对象不是“只有数据”,而是数据 + 行为入口一起交给对象本身管理。
也就是说,当我们拿到一个 MotorModule 对象时,不只是拿到一组电机参数,而是拿到一个具备初始化、运行、停止、错误处理能力的实体。
这就是 C 版本的“对象实例”。
6. 第三层抽象:DJI 与 DM 电机是具体派生类
6.1 DJI 电机:在公共电机能力上叠加编码器、PID 与反馈解析
dji_motor.c 里做的事情,本质上就是在 MotorModule 的基础上,扩展出一个更具体的对象:DJI_MotorModule。
它增加了这些能力:
- 编码器角度解析
- 圈数累计与总角度计算
- 速度、电流、温度反馈
- 位置环 / 速度环 PID 计算
- 大疆电机 CAN 帧打包与发送
创建函数里,把具体行为挂到对象上:
obj->get_moto_measure = DJIget_motor_measure; |
这就是一个非常典型的“派生对象绑定具体方法”的过程。
尤其是 Motor_PID_Calculate() 里,你已经体现了分模式控制的思想:
POSITION:位置环套速度环SPEED:直接速度闭环MIT:预留模式接口
这说明对象设计不只是为了“组织代码”,它已经进入了控制策略封装的层面。
6.2 DM 电机:同样的父类,不同的协议与控制模式
dm_motor.c 则展示了另一个具体实现:达妙电机对象。
它提供了:
- 使能 / 失能 / 清错 / 存零点命令
- 位置、速度、力矩反馈解包
- MIT 控制报文打包
- 位置速度控制报文打包
在创建阶段,同样将这些行为绑定到对象:
obj->send_cmd = DM_Motor_CMD; |
这正是多态在工程中的价值:
- 它们都属于“电机对象”
- 但不同对象的通信协议、状态解析方式、控制命令完全不同
- 上层模块不用重复写一大堆
if (is_dji) ... else if (is_dm) ...
一旦对象创建完成,具体行为已经注入到对象里了。
7. 第四层抽象:StructureModule 负责“管理多个电机”
如果说 MotorModule 解决的是“一个电机怎么抽象”,那么 StructureModule 解决的就是“一个机器人机构如何管理多个电机”。
在 structure.c 中,这个模块做了几件非常有代表性的事情:
- 它同样继承了
baseModule的公共行为 - 它维护一个电机数组
motors[] - 它记录电机 ID 数组
motor_ids[] - 它通过
AddMotor()完成子对象注册 - 它负责校验空指针、重复添加、数量上限等问题
这说明你已经不只是“给电机写驱动”,而是在做机构级对象管理。
换句话说,底盘、升降、武器,不再只是一些松散函数,而是一个个真正独立的功能对象。
这在机器人项目里非常重要,因为一个功能模块通常不只控制单一执行器,而是控制一组互相协同的执行器。
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 更适合做功能扩展
假设明天你要加入第三种电机,甚至加入舵机、气缸、线性执行器,你并不需要把整套架构推翻。
你只需要:
- 定义一个新的对象结构体
- 实现它自己的反馈解析和命令发送函数
- 在创建函数里把行为绑定好
- 把它注册进某个
StructureModule
这样扩展成本会比“直接往旧代码上堆 if-else”低很多。
9.4 更容易做模块级测试
对象边界清楚以后,测试也更容易分层。
例如你可以分别验证:
PID算法是否正常DJIget_motor_measure()的反馈解包是否正确DMset_mit_data()的打包是否符合协议StructureModule_AddMotor()是否能正确处理重复注册
当代码组织混乱时,这些测试很难独立进行。
9.5 更符合长期维护的需要
机器人项目最怕的不是“现在能跑”,而是“后面没人敢改”。
一旦代码需要交给队友、学弟、或者未来的自己维护,面向对象风格的 C 代码会比“神秘全局变量流”更友好。因为:
- 模块职责更清晰
- 新人更容易找到入口
- 修改某一类设备时,不必到全工程里搜索替换
10. 那么,这样写是不是“更专业”?
我的答案是:当它服务于复杂度管理时,它就是专业的;当它只是为了显得高级时,它就只是样子货。
值得这么写的场景
- 设备种类多,协议差异大
- 同一类对象有多个具体实现
- 系统需要长期迭代
- 需要统一生命周期管理
- 希望上层逻辑尽量不依赖底层硬件细节
不值得这么写的场景
- 只有一两个简单外设
- 模块边界非常简单
- 根本不存在扩展需求
- 为了“像 Linux 内核”而硬上抽象层
真正专业的工程师,追求的从来不是“抽象越多越高级”,而是抽象刚刚好。
Linux 内核确实大量使用了结构体嵌套、回调函数、接口分层等技巧,但它这么做不是为了炫技,而是因为面对海量设备和复杂场景,必须依赖这种方法控制复杂度。嵌入式机器人项目也是同样的道理。
11. 结合代码,再谈几个很有价值的工程细节
你的设计方向是对的,但从“能工作”走向“更稳健”,还有几个点值得继续打磨。
11.1 Create、Init、Enable 最好彻底分层
你现在已经有这个雏形了:
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 == NULL 和 motor == NULL 分开处理,或者直接返回错误码。
第二,DMset_mit_data() 里有几处把 float_to_uint() 的上下限都传成 0 的写法。从抽象层面看,这是在复用统一量化函数;但从实现层面看,这种“零跨度映射”并不稳妥。更安全的做法是:对那些本来就应该恒为 0 的字段,直接写入 0,而不是再走一层通用换算。
第三,DJImotor_Create() 同时接收 command_id 和 feedback_id,但当前对象创建时主要使用了 feedback_id - 0x200 这一套映射。这个设计未必一定有问题,但如果后续别人来维护,最好把“对象 ID、命令帧 ID、反馈帧 ID”三者的关系写得更清楚,否则接口虽然做了抽象,语义却可能变得模糊。
这些细节很值得写进工程博客里,因为它们能说明一件事:好的架构不是把代码包装得很像对象,而是在抽象之外,仍然对协议、边界条件和硬件语义保持敏感。
11.5 协议封装一定要警惕“魔法数字”
无论是 DJI 编码器圈数累计,还是 DM 的 MIT 报文打包,代码里都存在很多协议常量,例如:
409681926.28318f3.14159f0xFC / 0xFD / 0xFE / 0xFB
这些数字写在驱动里没有问题,但如果能进一步用宏、枚举、协议说明文档统一管理,代码可读性会明显提升。
对于机器人项目来说,这类“协议常量”往往比算法更容易出错。
11.6 抽象之外,还要注意实时性与可预测性
嵌入式系统不是桌面软件。你写的这些对象最终都要跑在实时控制循环里,所以除了结构优雅,还要关注:
- 是否引入了不必要的函数调用开销
- 是否有动态内存分配
- 是否有任务与中断间的数据竞争
- CAN 发送是否可能冲突
- PID 与反馈更新是否有严格时序保证
换句话说:嵌入式里的面向对象,一定要建立在实时性和确定性之上。
12. 如果继续优化这套架构,我会怎么做?
在不推翻现有设计的前提下,我会考虑下面几个方向。
12.1 把“接口表”进一步标准化
现在你的对象方法是直接一个个挂到结构体里的,这没有问题。但如果系统继续扩大,可以进一步抽象成一张接口表(类似虚函数表)。
例如:
typedef struct { |
然后每种电机对象持有自己的 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; |
这个例子本质上和工程代码是同一路思路:
Motor是抽象父类DJI_Motor是具体子类MotorVTable是一张行为表- 上层代码只和统一接口交互
这就是 C 版面向对象的核心。
14. 总结
回到最开始的问题:“用 C 模拟面向对象,是不是更专业?”
答案不是简单的“是”或“不是”。
更准确地说,当你的系统已经复杂到需要抽象层、统一接口、模块组合和长期维护时,这样写就很专业;当系统其实很简单,却硬套复杂架构时,它反而会变成负担。
这已经不是“为了看起来高级”的写法,而是为了让机器人控制系统在规模扩大后依旧可维护、可扩展、可调试。
所以真正值得学习的,不是“把 C 写得像 C++”,而是学会背后的那套工程思维:
- 先识别哪些能力是公共的
- 再抽象出统一接口
- 用组合关系组织复杂系统
- 用清晰的生命周期管理对象
- 最终让控制逻辑和硬件细节尽量解耦
这才是 C 语言模拟面向对象最有价值的地方。





