第 9 章 · 参数长到换行 · ops 操作表
配套代码:oop-in-c/code/09-ops-table/
9.1 一个真实场景
第 8 章我们用 void (*on)(int) 演示了函数指针当参数。第三个参数 int id 是通用名,够看清“延迟决定“这件事本身。
但现实里 LED 不只是个 int。第 6 章你已经把 LED 写成 base + 子类的结构(struct led_base + struct led_gpio / struct led_pwm 等)。一个真正的 test_led 应该接 struct led_base *,这样函数指针签名也跟着变成 int (*)(struct led_base *)。能拿到对象身份,能打日志,能维护状态,每个动作还能返回错码。
升级一下 test_led:
int test_led(struct led_base *me,
int (*on)(struct led_base *),
int (*off)(struct led_base *),
int (*toggle)(struct led_base *));
一个 me 指针,三个函数指针,四个参数挤在一起。问题来了。
第一个问题,长。声明换两次行才写得下,调用一次写五行:
test_led(&red_led.base,
gpio_on,
gpio_off,
gpio_toggle);
第二个问题,更要命。on / off / toggle 这三个函数指针的类型完全一样,都是 int (*)(struct led_base *)。如果调用方手抖把 on 和 off 顺序传反:
test_led(&red_led.base,
gpio_off, /* 这里本来该传 on */
gpio_on, /* 这里本来该传 off */
gpio_toggle);
编译器看不出来。类型对就放行。运行起来,开灯调到了 off 函数,关灯调到了 on 函数。该亮的时候灭,该灭的时候亮。这种 bug 能查一下午。
散装的电话号码摊了一桌子。纸条多了,丢一张都不知道。

9.2 typedef 起短名
第一步先解决“类型声明又臭又长“这件事。
int (*on)(struct led_base *me);
int (*off)(struct led_base *me);
int (*toggle)(struct led_base *me);
每个函数指针的类型字面量都要写一坨 int (*)(struct led_base *)。写一次能看懂,写第三次看着累。一周后自己看,想半天。
注意函数指针返回 int 不是 void:每个 op 都能报错(NULL check 失败、硬件不响应、参数越界),调用方拿一个 0/-1 就能判断成败。工业代码里函数指针几乎都是返回 int。
C 语言里有个工具叫 typedef,给类型起一个别名:
typedef int (*led_action_fn)(struct led_base *me);
ops 表里 on / off / toggle 三个字段类型完全一样,都是“接 struct led_base *、返回 int“。一个 typedef 名字就够,叫 led_action_fn,意思是 LED 上的一个动作。三个字段共用同一个类型名,ops 表写出来字段对得齐。
这之后 led_action_fn fp; 等价于 int (*fp)(struct led_base *me);,但读起来短一截。和你早就在用的 typedef unsigned int uint32_t 是同一个语法、同一个用途:给一个长得难读的类型起一个短名字。
typedef 在 ch01 1.7.3 节讲 typedef struct 时提过,Linus 反对的是那种“藏类型信息“的滥用(写 typedef struct foo {} foo_t; 让你看 foo_t 不知道是 struct)。函数指针 typedef 是少数 Linus 也支持的 typedef 例外:原始类型字面量太长,起短名是纯收益。Linux 内核的 struct file_operations 字段类型就是这么 typedef 一遍的。
注意 typedef 本身不分配存储、不生成代码、不引入新类型,只是给已有类型起别名。编译完字节码里看不出有过 typedef。
写代码也需要翻译,从机器能看懂的,翻译成人能看懂的。

9.3 装进一个盒子:struct led_ops
名字起好了,下一步把这一组函数指针装进一个盒子。
观察一件事:on / off / toggle 是一组绑死的东西。它们一起描述了“一种 LED 的所有行为“。GPIO 风格 LED 这一组,PWM 风格 LED 那一组。一组绑死的东西打包,C 里的工具就是 struct:
struct led_ops {
led_action_fn on;
led_action_fn off;
led_action_fn toggle;
};
一个 struct,三个函数指针字段,每个字段一个名字。这就是操作表(ops table)。
test_led 现在接 ops 指针:
int test_led(struct led_base *me, const struct led_ops *ops);
test_led 自身也返回 int:调用 ops 表里任何一个动作失败就把错码往上抛,应用层一看返回值就知道这一轮跑没跑成。
调用方填好一张表,传进去:
const struct led_ops led_ops_gpio = {
.on = gpio_on,
.off = gpio_off,
.toggle = gpio_toggle,
};
test_led(&red_led.base, &led_ops_gpio);
参数列表从 4 个塞回 2 个。test_led 内部按名字访问:ops->on 永远是 on,ops->off 永远是 off。不可能传反。编译器在初始化 led_ops_gpio 时帮你对每个 .字段名 检查类型对不对(不对就编译报错)。
以前是三个散装电话号码,现在装订成一本电话簿。散装号码丢了找不到。电话簿,翻开就有,一个不少。
注意 ops 表里只装“做法不同“的行为。on / off / toggle 这种每种 LED 实现都不一样的(GPIO 拉电平、PWM 配占空比、I2C 写命令),才需要做成函数指针让运行时绑定。get_name、get_state 这种只是读父类字段的函数不用进 ops 表,沿用 ch06 引入的 led_base_get_name(&xxx.base) 直接读 base 数据就行。做法不同的进 ops 表,读数据的不进。

9.4 这个东西叫什么
把一组相关的函数指针打包进一个 struct,让别人通过表名按名访问。这件事在软件工程里有个名字。
它叫操作表(ops table)。Linux 内核源码里把这种 struct 都叫 xxx_ops:file_operations、net_device_ops、gpio_chip 里的回调集,都是 ops 表。
C++ 里换成你写:
class led_base {
public:
virtual int on() = 0;
virtual int off() = 0;
virtual int toggle() = 0;
};
class led_gpio : public led_base { ... };
class led_pwm : public led_base { ... };
带 virtual 函数的 class,C++ 编译器在背后做三件事:
- 生成一张函数指针表。每个 class 一张。
led_gpio的表里.on指向led_gpio::on,led_pwm的表里.on指向led_pwm::on。 - 在每个对象里偷偷加一个指针,指向自己 class 的那张表。
- 调用时通过这个指针查表,找到对的函数跳过去。
你这一章亲手做了第一步:手写了 struct led_ops 这张表,并填了 led_ops_gpio / led_ops_pwm 两张实例。后两步留给后面章节。
C++ 管这张表叫 vtable,虚函数表。
视频的金句版总结:结构化不是束缚,是让混乱变得可管理。散装号码会丢,电话簿不会。

9.5 视频里没讲透的几个细节
9.5.1 designated initializer 是 C99 的礼物
const struct led_ops led_ops_gpio = {
.on = gpio_on,
.off = gpio_off,
.toggle = gpio_toggle,
};
这种 .字段名 = 值 的写法叫 designated initializer,C99 引入。好处三条:
- 不依赖字段顺序。哪天 struct 里调换字段顺序,已有 ops 表代码不用改。
- 可读性好。不用数到第几个字段。
- 未列出的字段自动初始化为 0 或 NULL(C99 标准 6.7.8 节第 21 段)。这条对 ops 表特别有用:某种 LED 不支持的行为可以不填,调用方做 NULL check 即可。
C89 没有这个语法,只能按字段顺序填:
const struct led_ops led_ops_gpio = {
gpio_on, /* on */
gpio_off, /* off */
gpio_toggle, /* toggle */
};
字段顺序一变就全乱。Linux 内核早期代码很多这种“按位置 init“,后期重构都改成了 designated initializer。本书一律用 designated 写法。
9.5.2 ops 表里某些字段不填怎么办
哪天往 struct led_ops 里加一个新字段,比如 set_brightness,让支持调光的 LED 用。GPIO LED 硬件上没有调光能力,PWM LED 才有。GPIO 那张 ops 表填到 set_brightness 时干脆不写:
const struct led_ops led_ops_gpio = {
.on = gpio_on,
.off = gpio_off,
.toggle = gpio_toggle,
/* set_brightness 不填, designated initializer 自动置 NULL */
};
调用方就得在用之前做 NULL check:
if (ops->set_brightness)
ops->set_brightness(me, 50);
else
printf("This LED doesn't support brightness control\n");
NULL check 这件事,工业代码里函数指针调用前几乎都做。本章配套代码 test_led 入口处对 on / off / toggle 三个字段都跑了 NULL check(led_base.c 里的 test_led),就是这个习惯。指针来源(字段、参数、全局都算)不重要,重要的是用之前查一次。
9.5.3 ops 表为什么是 const
ops 表通常加 const 修饰:
const struct led_ops led_ops_gpio = { ... };
const 这一层有三件事在背后发生:
- 链接时进
.rodata段。MCU 上烧到 Flash 上,零 RAM 占用。 - 运行时只读。试图改
led_ops_gpio.on = some_other_fn直接 SIGSEGV(Linux)或 HardFault(MCU)。这一层防御工业代码视为硬要求,防止运行时把字段改成野指针。 - 共享。100 颗同类型 LED 共享同一张 12 字节的 ops 表,不每颗一份。
extern 暴露给用户,调用方拿 &led_ops_gpio 就是 ops 表的地址:
/* led.h */
extern const struct led_ops led_ops_gpio;
extern const struct led_ops led_ops_pwm;
9.5.4 typedef 的命名风格
工程上函数指针 typedef 的命名见过几种:
| 风格 | 例子 | 出处 |
|---|---|---|
小写 _fn 后缀 | led_action_fn | Linux 内核(request_threaded_irq 的 irq_handler_t) |
小写 _t 后缀 | led_action_t | POSIX 习惯(pthread_handler_t) |
驼峰 Func 后缀 | LedActionFunc | Win32 / Qt |
本书统一用 _fn 后缀,和 Linux 内核驱动模型一致。一个项目里挑一种用到底就行,最忌讳同一份代码三种风格混用。
9.5.5 struct 名字小写 vs 大写 typedef
ops 表的 struct 名字也有两派:
struct led_ops { /* Linux 内核风格, 小写带前缀 */
led_action_fn on;
/* ... */
};
typedef struct { /* 早期 C 风格, 大写驼峰 */
led_action_fn on;
/* ... */
} LedOps_t;
本书用 struct led_ops 的写法,和 ch01 已经定下的“struct 名字小写、不藏类型信息“风格一致。Linus 在内核编码风格文档里反对的就是 LedOps_t 这种 typedef。读到 LedOps_t 不知道它是 struct 还是基本类型,要去翻定义。struct led_ops 写出来就明白。
9.5.6 视频版与配套代码版的三处差异
差异原则详见前言「配套代码 vs 视频版」。下面是本章具体差异。
视频 EP14 演示用的 ops 表设计是这样:
- typedef 三个不同名字:
led_on_fn / led_off_fn / led_brightness_fn,都返回void - struct 字段:
on / off / set_brightness(最后一个接int val调亮度)
本章配套代码 oop-in-c/code/09-ops-table/ 用的是另一种风格:
typedef int (*led_action_fn)(struct led_base *me);
struct led_ops {
led_action_fn on;
led_action_fn off;
led_action_fn toggle;
};
三处差异:typedef 名 / 返回类型 / 字段名。两边讲的是同一件事,函数指针装进同一张表,按名字访问。差异在于:
- 统一 typedef vs 多个 typedef。配套代码用单一
led_action_fn,让 ops 表的字段类型完全一致,跨章节代码包从 ch09 到 ch15 共享同一个签名,读者跟着改增量看得清楚。视频用三个不同 typedef 是教学上更直观(每种操作有自己的类型名),适合短视频展示。 - 返回 int vs 返回 void。配套代码返回
int,每个 op 都能报错(NULL check 失败、硬件不响应、参数越界),更接近工业代码。视频用void是教学简化。 - toggle vs set_brightness。
toggle在 PC 上跑得出“开关来回切换“的可视效果,set_brightness在 PC 上没法可视化亮度变化。代码包用toggle让 demo 能看出区别。
跑代码以代码包为准,看视频以视频画面为准,两边讲的是同一个机制。
9.6 你现在的代码在 STM32 上长什么样
STM32 端胶水还是 ch01 那套。led_base.h / led_base.c / led_gpio.h / led_gpio.c / led_pwm.h / led_pwm.c / main.c 一字不改。
注意从这一章起每种 LED 子类拆到自己的 led_xxx.h/.c 一对文件里,方便往后加 I2C 等更多种类时直接新增一对 led_i2c.h/.c、老子类完全不动。这件事在 ch11 三种子类同台 dispatch 时彻底兑现。
ops 表 led_ops_gpio / led_ops_pwm 编译后进 .rodata 段,烧到 Flash 上。运行时常驻,所有 GPIO 类 LED 共享同一份 12 字节的 ops 表:
/* 真实芯片 .map 文件里能看到 */
.rodata
...
led_ops_gpio 0x08001234 12
led_ops_pwm 0x08001240 12
100 颗 LED 仅有 24 字节 ops 表代价。每颗 LED 自己的 struct 实例(在 .bss 或栈上)和这张 ops 表无关。
本节用的还是函数式包装的 platform 抽象层(platform_gpio_write(pin, value) 这种独立函数),是教学简化版。真正工业级的 platform 抽象用 ops 表的形式。后面章节会把 platform 层从函数式重构成 ops 表式,和工业代码对齐。
9.7 工业代码里的 ops 表
工业控制板项目里,每个驱动都有一张 ops 表。LED 驱动这样:
/* drivers/led/led.h */
struct led_base;
struct led_ops {
int (*on)(struct led_base *me);
int (*off)(struct led_base *me);
int (*toggle)(struct led_base *me);
};
/* drivers/led/led_gpio.c */
const struct led_ops led_ops_gpio = {
.on = led_gpio_on,
.off = led_gpio_off,
.toggle = led_gpio_toggle,
};
注意 ops 表里函数指针的参数是 struct led_base *,不是某个具体子类。所有子类的 ops 表必须类型一致。
EEPROM、风扇、蜂鸣器、按键这些 driver 的 ops 表结构各不相同,但写法都是这一套:定义 ops struct,每种实现 fill 一张 const ops 表,外部传一张表进去就跑对应的实现。
这就是 Linux 内核 struct file_operations 的 OOP 骨架。你这一章亲手推了一遍。
9.8 跑一遍
cd oop-in-c/code/09-ops-table/pc
make
./demo
输出节选:
========================================
ops table: pack action pointers as struct.
test_led takes one ops pointer.
========================================
--- test_led(&red_led.base, &led_ops_gpio) ---
[test] open ...
[GPIO] Pin13 -> HIGH (ON)
[GPIO] "red" ON
[test] toggle ...
[GPIO] Pin13 -> LOW (OFF)
[GPIO] "red" OFF
[test] close ...
[GPIO] Pin13 -> LOW (OFF)
[GPIO] "red" OFF
--- test_led(&blue_led.base, &led_ops_pwm) ---
[test] open ...
[PWM] "blue" ON (channel 1, duty=70)
[test] toggle ...
[PWM] "blue" OFF (channel 1)
[test] close ...
[PWM] "blue" OFF (channel 1)
test_led 函数体没改。换一张 ops 表,跑出完全不同的行为。
完整源码见 oop-in-c/code/09-ops-table/。
9.9 视频回放
想听口播版的可以看 B 站这一期视频:

视频里这一期叫“散装电话号码 → 电话簿“。散装函数指针绑成一组共享名字的“号码本“,按名取号永远不会取错。
视频金句:结构化不是束缚,是让混乱变得可管理。散装号码会丢,电话簿不会。
下一章
ops 表手里捏着,但应用层还得主动把它传进去。每次都要:
test_led(&red_led.base, &led_ops_gpio);
test_led(&blue_led.base, &led_ops_pwm);
应用层得记住每颗 LED 该用哪张表。一旦传错,又是 bug。
这本电话簿独立存在,跟 LED 对象没绑在一起。能不能让每颗 LED 自己带着自己的电话簿?应用层只传 LED 自己,电话簿 LED 自己知道。