C语言里没有类、没有虚函数、也没有继承。但嵌入式开发中经常碰到这种需求:为一组传感器设计统一的驱动接口——温度传感器、压力传感器各自实现,上层调用时完全不用管底层差别。
C++是怎么做多态的
先看懂 C++ 的机制,才能用 C 复刻出来。
在 C++ 中,含有虚函数的类,编译器会自动生成一张虚函数表(vtable),并在每个对象内部安插一个虚表指针(vptr)指向这张表。调用虚函数时,程序先通过对象内的 vptr 找到 vtable,再从 vtable 里取出对应的函数地址,进行间接调用。
对象内存布局:
+--------+
| vptr ---+---> vtable: [ &draw, &area, &destroy ]
| x |
| y |
+--------+
base_ptr->draw() 在编译阶段并不知道具体要调用哪个函数,运行时通过 vptr 查表才能确定——这就是动态多态。
C 语言可没有编译器帮忙干这些,得自己手动造 vtable、自己维护 vptr。
函数指针嵌入结构体
最直观的办法:把函数指针直接塞进结构体里。
typedef struct {
int x;
int y;
void (*draw)(void *self);
float (*area)(void *self);
void (*destroy)(void *self);
} shape_t;
每个“对象”自带函数指针,创建对象时对应绑定上去:
void rect_draw(void *self);
float rect_area(void *self);
void rect_destroy(void *self);
shape_t *rect_create(int x, int y, int w, int h)
{
rect_t *r = malloc(sizeof(rect_t));
r->base.x = x;
r->base.y = y;
r->base.draw = rect_draw;
r->base.area = rect_area;
r->base.destroy = rect_destroy;
r->width = w;
r->height = h;
return (shape_t *)r;
}
调用时看起来挺自然:
shape_t *s = rect_create(0, 0, 10, 20);
s->draw(s);
float a = s->area(s);
s->destroy(s);
不好的地方:每个对象都保存一份函数指针,浪费内存。100 个矩形对象就会有 100 份 draw 指针,虽然它们全都指向同一个函数。在 RAM 紧张的 MCU 上,这种冗余是不可接受的。
手动 vtable
把函数指针从对象里剥离出来,放到一个单独的 vtable 结构中,同类型的所有对象共享一份 vtable。对象里只保留一个指向 vtable 的指针就行。
typedef struct shape_vtable {
void (*draw)(void *self);
float (*area)(void *self);
void (*destroy)(void *self);
} shape_vtable_t;
typedef struct {
const shape_vtable_t *vtable;
int x;
int y;
} shape_t;
通过 vtable 间接寻址时,可以用宏来简化书写:
#define SHAPE_DRAW(s) ((s)->vtable->draw(s))
#define SHAPE_AREA(s) ((s)->vtable->area(s))
#define SHAPE_DESTROY(s) ((s)->vtable->destroy(s))
矩形和圆形各定义一份 vtable,编译期就确定下来,可以放在 Flash 里:
static void rect_draw(void *self);
static float rect_area(void *self);
static void rect_destroy(void *self);
static const shape_vtable_t rect_vtable = {
.draw = rect_draw,
.area = rect_area,
.destroy = rect_destroy,
};
static void circle_draw(void *self);
static float circle_area(void *self);
static void circle_destroy(void *self);
static const shape_vtable_t circle_vtable = {
.draw = circle_draw,
.area = circle_area,
.destroy = circle_destroy,
};
创建对象时绑定对应的 vtable 指针:
typedef struct {
shape_t base;
int width;
int height;
} rect_t;
shape_t *rect_create(int x, int y, int w, int h)
{
rect_t *r = malloc(sizeof(rect_t));
r->base.vtable = &rect_vtable;
r->base.x = x;
r->base.y = y;
r->width = w;
r->height = h;
return &r->base;
}
typedef struct {
shape_t base;
int radius;
} circle_t;
shape_t *circle_create(int x, int y, int r)
{
circle_t *c = malloc(sizeof(circle_t));
c->base.vtable = &circle_vtable;
c->base.x = x;
c->base.y = y;
c->radius = r;
return &c->base;
}
这样使用时完全统一,多态的效果自然就出来了:
shape_t *shapes[3];
shapes[0] = rect_create(0, 0, 10, 20);
shapes[1] = circle_create(5, 5, 8);
shapes[2] = rect_create(1, 1, 30, 40);
for (int i = 0; i < 3; i++) {
SHAPE_DRAW(shapes[i]);
printf("area = %.2f\n", SHAPE_AREA(shapes[i]));
}
继承:结构体嵌套
C 语言里模拟继承,靠的是结构体嵌套,并且必须把“基类”结构体放在“派生类”结构体的第一个成员位置:
typedef struct {
const sensor_vtable_t *vtable;
const char *name;
uint8_t addr;
} sensor_t; // 基类
typedef struct {
sensor_t base; // 基类放第一个成员
int32_t t_fine; // 派生类自有属性
uint8_t calib[42];
} bme280_t; // 派生类
为什么非得第一个?因为 C 标准保证:结构体首个成员的地址等于结构体本身的地址。这样一来,&bme280->base 和 (sensor_t *)bme280 就是同一个地址,可以安全地向上转型。
bme280_t bme;
sensor_t *s = &bme.base; // OK,地址相同
sensor_t *s2 = (sensor_t *)&bme; // 也OK,因为base是第一个成员
如果 base 不是第一个成员,强转之后 vtable 指针的位置就对不上了,轻则读错数据,重则 HardFault 或函数指针飞进未知区域。
类型安全:container_of 宏
从基类指针反推出派生类指针,Linux 内核中常用 container_of 宏:
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
在虚函数里可以这样用:
static int bme280_read(void *self)
{
// self实际指向sensor_t,需要反推出bme280_t
bme280_t *dev = container_of(self, bme280_t, base);
// 现在可以访问dev->t_fine、dev->calib
return 0;
}
当然,由于 base 是第一个成员,也可以直接强转,效果一样。但 container_of 更安全——万一有人把 base 移到了第二个成员的位置,强转会引入 bug,而 container_of 通过宏计算偏移量,不会受影响。
以上这些技巧在嵌入式 C 开发中很常见,如果你想进一步探讨 vtable、指针等 C/C++ 底层机制,也欢迎去云栈社区一起交流。