C语言中的函数指针与虚函数表模拟

在C语言模拟面向对象的技巧里,函数指针和虚函数表风格的操作表,是最核心的一层机制。当系统开始管理越来越多的设备、模块和控制逻辑时,单纯依靠一堆分散的函数和条件分支,代码会越来越难维护。

在嵌入式系统中,如何用 C 语言的函数指针模拟“对象方法”,再进一步模拟“虚函数表”和“多态调用”?


一、为什么普通 C 写法会越来越难维护

先看最朴素的写法。假设我们有两类电机:

  • DJI 电机
  • DM 电机

一开始也许只需要做“使能”:

if (motor_type == DJI_MOTOR) {
dji_motor_enable();
} else if (motor_type == DM_MOTOR) {
dm_motor_enable();
}

后来需求变多了,还要设置目标值、解析反馈、执行控制更新:

if (motor_type == DJI_MOTOR) {
dji_motor_enable();
dji_motor_set_ref(ref);
dji_motor_decode_feedback(rx_data);
dji_motor_control_update();
} else if (motor_type == DM_MOTOR) {
dm_motor_enable();
dm_motor_set_ref(ref);
dm_motor_decode_feedback(rx_data);
dm_motor_control_update();
}

刚开始看起来没什么问题,但随着项目扩大,问题会越来越明显:

  1. 分支越来越多
    每增加一种设备,就会多出一串 if-else 或 switch-case。

  2. 上层知道太多底层细节
    控制层本来只想“让电机工作”,结果却必须知道“这是哪一种电机、用什么协议、反馈格式是什么”。

  3. 扩展成本高
    新增一种设备时,不只是加新代码,还要修改很多旧代码。

  4. 模块耦合严重
    应用层直接依赖具体驱动函数,导致系统边界模糊。

这就是把嵌入式项目做成“对象化组织”的原因。系统复杂后,我们需要一种办法把数据和行为绑定起来,并为上层提供统一接口。


二、函数指针:在 C 中绑定“数据”和“行为”

C 语言没有类,没有成员函数,但它有一个非常强大的机制:函数指针。
函数指针的本质就是一个变量里保存了函数的地址,我们可以把这个数据应该调用什么函数也一起存起来。

先看一个最简单的例子:

#include <stdio.h>

void say_hello(void) {
printf("hello\n");
}

int main(void) {
void (*func)(void) = say_hello;
func();
return 0;
}

这里的 func 就是一个函数指针。
它指向 say_hello,所以 func() 实际上等价于调用 say_hello()。

把这个思路进一步扩展,就可以在结构体中保存函数指针,让结构体既表示“状态”,也知道“该怎么做事”。

例如:

typedef struct Motor Motor;

typedef struct {
void (*enable)(Motor *self);
void (*disable)(Motor *self);
void (*set_ref)(Motor *self, float ref);
} MotorOps;

struct Motor {
const MotorOps *ops;
float ref;
float speed;
float position;
};

这里已经有一点对象的感觉了:

  • Motor 保存数据
  • MotorOps 保存行为接口
  • ops 把数据和行为关联起来

调用时就可以写成:

motor->ops->enable(motor);
motor->ops->set_ref(motor, 10.0f);

三、方法调用

函数签名里要手动传一个指针
例如:

void motor_enable(Motor *self);
void motor_set_ref(Motor *self, float ref);

在 C++ 里,成员函数调用时会隐式传入 this 指针; 但在 C 里没有这个机制,所以我们只能显式传入 self。

四、虚函数表是什么,它和普通函数指针有什么区别

1. 结构体里直接保存多个函数指针

例如:

typedef struct {
void (*enable)(void *self);
void (*disable)(void *self);
void (*set_ref)(void *self, float ref);
} Motor;

这种写法很直接,但有个明显问题:每个实例都保存一整套函数指针,如果实例很多,会浪费 RAM,这种写法不够优雅。

2. 把函数指针集中成一张“操作表”

更常见的方式是:

typedef struct {
void (*enable)(void *self);
void (*disable)(void *self);
void (*set_ref)(void *self, float ref);
void (*decode_feedback)(void *self, const unsigned char *data);
} MotorVTable;

然后对象里只保存一个指针:

typedef struct {
const MotorVTable *vptr;
float ref;
float speed;
float pos;
} MotorBase;

这样,同类对象就可以共享一张操作表。

例如所有 DJI 电机对象都共用 dji_motor_vtable,
所有 DM 电机对象都共用 dm_motor_vtable。

五、为什么“虚函数表风格”更适合嵌入式

这种写法在机器人嵌入式系统中特别有价值,主要有几个原因。

  1. 节省内存
    如果同类对象共用一张操作表,那么每个对象只需要保存一个 vptr 指针。

  2. 接口统一

控制器只关心:使能、失能、设置目标这些方法,并不关心底层是什么,这样控制器可以面向一个抽象接口编程

  1. 扩展容易
    新增一种电机时,只需要定义一种新结构体,再实现一组专用函数
    配一张新的操作表即可,原来的控制逻辑基本不用改。

  2. 更适合团队协作

驱动层、控制层、任务层都可以围绕统一接口协作。
模块边界更清晰,职责更明确。


六、如何在 C 中模拟“继承”

“继承”是另一个很常被提到的词。
当然,C 语言并没有真正的继承机制,但我们可以通过结构体嵌套来模拟。

最典型的写法就是把“基类”放在结构体的第一个成员位置:

typedef struct {
const MotorVTable *vptr;
float ref;
float speed;
float position;
} MotorBase;

typedef struct {
MotorBase base;
unsigned short ecd;
short rpm;
short torque;
} DJIMotor;

typedef struct {
MotorBase base;
unsigned char id;
float q;
float dq;
} DMMotor;

这样做的关键在于内存布局:

  • DJIMotor 的起始地址与 DJIMotor.base 的地址相同
  • DMMotor 的起始地址与 DMMotor.base 的地址也相同

因此可以把子类对象安全地“当作”父类对象来使用:

DJIMotor dji;
MotorBase *motor = (MotorBase *)&dji;

这样上层统一处理 MotorBase *,而底层仍然可以根据具体类型执行不同实现。

要注意,这不是语言级的继承,而是一种工程约定。
它成立的基础是:

  1. 父结构体放在第一个成员位置
  2. 所有接口都遵守同样的约定
  3. 类型转换清晰且可控

这类技巧在嵌入式和内核代码里非常常见。


七、如何模拟“重写”和“多态”

当我们有了:

  • 基类结构体 MotorBase
  • 操作表 MotorVTable
  • 多种具体实现 DJIMotor / DMMotor

接下来就自然会出现“重写”和“多态”。

1. “重写”是什么

同一个接口,不同实现。

例如,所有电机都要支持反馈解析:

void dji_decode_feedback(void *self, const unsigned char *data);
void dm_decode_feedback(void *self, const unsigned char *data);

它们对上层来说接口一致,但内部实现完全不同。

这就是“重写”的本质。

2. “多态”是什么

所谓多态,不是说“一个东西能做很多事”,
而是说:

调用方式统一,但具体执行哪个实现,由对象实际类型决定。

例如:

motor->vptr->decode_feedback(motor, rx_data);

对于 DJIMotor,这会调用 dji_decode_feedback;
对于 DMMotor,这会调用 dm_decode_feedback。

上层完全不需要写:

if (type == DJI) ...
else if (type == DM) ...

这就是多态带来的最大价值:消除分支,把变化封装到底层实现中。


八、一个完整的小案例:统一电机接口设计

下面给出一个简化版的完整案例,说明如何在 C 中实现“虚函数表风格的统一电机接口”。

1. 定义操作表和基类

#include <stdio.h>

typedef struct MotorBase MotorBase;

typedef struct {
void (*enable)(MotorBase *self);
void (*disable)(MotorBase *self);
void (*set_ref)(MotorBase *self, float ref);
void (*print_status)(MotorBase *self);
} MotorVTable;

struct MotorBase {
const MotorVTable *vptr;
float ref;
float speed;
};

2. 定义两种具体电机

typedef struct {
MotorBase base;
int can_id;
int ecd;
} DJIMotor;

typedef struct {
MotorBase base;
int node_id;
float torque;
} DMMotor;

3. 实现 DJI 电机行为

void dji_enable(MotorBase *self) {
DJIMotor *motor = (DJIMotor *)self;
printf("[DJI] enable, can_id=%d\n", motor->can_id);
}

void dji_disable(MotorBase *self) {
DJIMotor *motor = (DJIMotor *)self;
printf("[DJI] disable, can_id=%d\n", motor->can_id);
}

void dji_set_ref(MotorBase *self, float ref) {
self->ref = ref;
printf("[DJI] set ref = %.2f\n", ref);
}

void dji_print_status(MotorBase *self) {
DJIMotor *motor = (DJIMotor *)self;
printf("[DJI] can_id=%d, ref=%.2f, speed=%.2f\n",
motor->can_id, self->ref, self->speed);
}

4. 实现 DM 电机行为

void dm_enable(MotorBase *self) {
DMMotor *motor = (DMMotor *)self;
printf("[DM] enable, node_id=%d\n", motor->node_id);
}

void dm_disable(MotorBase *self) {
DMMotor *motor = (DMMotor *)self;
printf("[DM] disable, node_id=%d\n", motor->node_id);
}

void dm_set_ref(MotorBase *self, float ref) {
self->ref = ref;
printf("[DM] set ref = %.2f\n", ref);
}

void dm_print_status(MotorBase *self) {
DMMotor *motor = (DMMotor *)self;
printf("[DM] node_id=%d, ref=%.2f, speed=%.2f\n",
motor->node_id, self->ref, self->speed);
}

5. 定义两张虚函数表

const MotorVTable dji_vtable = {
.enable = dji_enable,
.disable = dji_disable,
.set_ref = dji_set_ref,
.print_status = dji_print_status,
};

const MotorVTable dm_vtable = {
.enable = dm_enable,
.disable = dm_disable,
.set_ref = dm_set_ref,
.print_status = dm_print_status,
};

6. 初始化对象

void dji_motor_init(DJIMotor *motor, int can_id) {
motor->base.vptr = &dji_vtable;
motor->base.ref = 0.0f;
motor->base.speed = 0.0f;
motor->can_id = can_id;
motor->ecd = 0;
}

void dm_motor_init(DMMotor *motor, int node_id) {
motor->base.vptr = &dm_vtable;
motor->base.ref = 0.0f;
motor->base.speed = 0.0f;
motor->node_id = node_id;
motor->torque = 0.0f;
}

7. 上层统一调用

int main(void) {
DJIMotor dji;
DMMotor dm;

dji_motor_init(&dji, 0x201);
dm_motor_init(&dm, 1);

MotorBase *motors[2];
motors[0] = (MotorBase *)&dji;
motors[1] = (MotorBase *)&dm;

for (int i = 0; i < 2; ++i) {
motors[i]->vptr->enable(motors[i]);
motors[i]->vptr->set_ref(motors[i], 10.0f + i);
motors[i]->vptr->print_status(motors[i]);
}

return 0;
}

从这个例子可以看到,上层逻辑已经不需要关心“这到底是 DJI 还是 DM”。
它只知道:这是一个“满足统一接口的电机对象”。

这就是 C 语言模拟虚函数表的核心价值。


九、什么时候该用,什么时候不该用

这部分可以作为很实用的经验总结。

适合使用的场景

  • 设备种类多,接口行为相似
  • 上层逻辑希望统一调用方式
  • 模块边界明确,需要长期扩展
  • 团队协作开发,需要减少耦合
  • 项目会持续演进,不是一次性代码

不太适合使用的场景

  • 系统非常小,设备类型单一
  • 项目生命周期短,快速交付优先
  • 团队成员对这种设计风格不熟悉
  • 抽象层次已经多到影响可读性

一句话总结就是:

复杂系统适合抽象,简单系统适合直接。