第 8 章 · 把号码给别人拨 · 函数指针传参
配套代码:oop-in-c/code/08-callback/
8.1 一个真实场景
第 7 章你学会了用一个变量存函数地址,再通过这个变量调用:
void (*fp)(int);
fp = gpio_on;
fp(15);
号码存好了,拨通了。
现在你想往前走一步:写一个工具函数 test_led,里面要做“开 → 等 → 关“三步,这样以后调它一次就跑完一颗灯的完整测试。这个函数应该不挑 LED:传谁进来都能跑完三步。
第一版自然写法是把号码写在函数体里:
void test_led(void) {
gpio_on(15); /* 写死 */
delay(500);
gpio_off(15); /* 写死 */
}
你在外面有号码(fp = gpio_on),但 test_led 拿不到。fp 是 main 里的局部变量,test_led 看不见。每次都得你自己在外面拨:先把号码存进 fp,再拿 fp 拨出去,test_led 帮不上忙。
test_led 自己想拨号,怎么办?
把号码给它。

8.2 把号码传进去:函数指针当参数
函数指针是个变量。变量能不能当参数传给函数?当然能。int 能传,指针能传,函数指针当然也能传。
改造 test_led 的签名,加两个参数,分别是“开灯号码“和“关灯号码“:
void test_led(void (*on)(int), /* 开灯号码 */
void (*off)(int), /* 关灯号码 */
int id)
{
on(id); /* 拨号: 不关心是谁 */
delay(500);
off(id); /* 拨号: 不关心是谁 */
}
test_led 内部不写 gpio_on,不写 pwm_on,只管调 on(id) 和 off(id)。
它不认识 gpio_on,也不认识 pwm_on。但它不需要认识。号码是谁的?调用方告诉它。test_led 拿到一对函数指针加一个 id,照着拨就行。
第三个参数 id 是通用名。GPIO LED 用它当引脚号,PWM LED 用它当通道号,I2C LED 用它当设备地址。test_led 不关心这个数字代表什么,原封不动传给 on(id) 和 off(id),号码自己解释。

8.3 三种 LED 都用同一个 test_led
有了 test_led(on, off, id),你可以用同一个函数测三种 LED:
test_led(gpio_on, gpio_off, 15); /* GPIO LED, id=15 引脚号 */
test_led(pwm_on, pwm_off, 3); /* PWM LED, id=3 通道号 */
test_led(i2c_on, i2c_off, 0x50); /* I2C LED, id=0x50 设备地址 */
三种 LED,同一个 test_led,跑出三种不同的开关行为。test_led 自己一行不改,行为完全由调用方传进来的两个号码决定。换一种 LED 实现,换两个号码就行。
类比一下:你把号码写在纸条上,交给朋友(test_led),朋友照着纸条拨号就行。朋友不需要认识 gpio_on 是谁,拨通就行。号码对不对那是你的事。

8.4 编译时绑定 vs 运行时绑定
来看这个改造前后的本质变化。
Before:
void test_led() {
gpio_on(15); /* 写死 */
...
gpio_off(15); /* 写死 */
}
test_led 函数体里写死了 gpio_on。编译器看到这一行,就知道要跳转到 gpio_on 那段机器码(通过链接器解析符号到地址)。编译时就定了“调谁“。要换?改源码,重新编译。
After:
void test_led(void (*on)(int), void (*off)(int), int id) {
on(id); /* 调谁? 看入参 */
...
off(id);
}
test_led 函数体里只调 on(id)。on 这个函数指针的值,编译器不知道。要等到 test_led 真的被调用、调用方把某个具体函数地址传进来,才知道要跳到哪段机器码。运行时才决定“调谁“。
这两件事各有名字:
- 编译时绑定:函数地址在链接期就固定。
- 运行时绑定:函数地址在运行时才确定。
写代码时不决定调谁,运行的时候再决定。这就是延迟决定。越晚做决定,选择越多。

8.5 这个东西叫什么
C++ 里这件事一字不改:
void test_led(void (*on)(int), void (*off)(int), int id);
声明语法、传参语法、调用语法都和 C 完全一样。这是少数 C 和 C++ 没有区别的地方。C++ 后来加了 lambda 和 std::function,写法更简洁,骨子里还是同一件事:把一段逻辑传给别人执行。
回到刚才的延迟决定。视频里给了一句总结:函数指针的本质是延迟决定,不是现在就定死,而是到时候再说。写代码的时候不决定调谁,运行的时候再决定。


8.6 视频里没讲透的几个细节
8.6.1 函数指针参数和数据指针一样大
函数指针作为参数,跟一个普通的数据指针走同一条通道:32 位平台是 4 字节,64 位平台是 8 字节。在 ARM Cortex-M 上,test_led(gpio_on, gpio_off, 15) 的三个参数装在 r0、r1、r2 三个寄存器里,函数体里 on(id) 是从寄存器里取出地址直接 BLX 跳过去。和“调一个写死名字的函数“相比,机器码层面就多一条间接寻址,没有额外开销。
8.6.2 函数指针参数的 NULL 检查
void test_led(void (*on)(int), void (*off)(int), int id)
{
if (!on || !off)
return;
on(id);
delay(500);
off(id);
}
on 和 off 这两个函数指针的 NULL check 必不可少。调用方传 NULL 进来,on(id) 就是跳到地址 0 执行:在 ARM Cortex-M 上跳到向量表起点的非代码区,结果是 HardFault;在 Linux 用户态收到 SIGSEGV,进程 core dump。
工业代码硬规则:所有函数指针调用前都做 NULL check。无论这个指针来自字段、参数还是哪里。
8.7 你现在的代码在 STM32 上长什么样
PC 上跑通的 test_led(on, off, id) 函数本身一字不改。gpio_on / gpio_off / pwm_on / pwm_off / i2c_on / i2c_off 这 6 个函数的 signature 也跟 PC 版一字不变(都是 void name(int param)),只是函数体里改成调 STM32 HAL(节选自 oop-in-c/code/08-callback/platform-mcu/stm32/led_stm32.c,pin 仍是 PIN_NUM('A', 13) 编码,详见第 1 章 § 1.x PIN_NUM 编码):
void gpio_on(int pin)
{
HAL_GPIO_WritePin(PIN_PORT((uint8_t)pin), PIN_MASK((uint8_t)pin), GPIO_PIN_SET);
}
void gpio_off(int pin)
{
HAL_GPIO_WritePin(PIN_PORT((uint8_t)pin), PIN_MASK((uint8_t)pin), GPIO_PIN_RESET);
}
void pwm_on(int channel)
{
__HAL_TIM_SET_COMPARE(&htim3, (uint32_t)channel, 1000);
HAL_TIM_PWM_Start(&htim3, (uint32_t)channel);
}
/* pwm_off / i2c_on / i2c_off 同理, 见 led_stm32.c */
signature 完全一样,调用方写 test_led(gpio_on, gpio_off, 13) 这一行 PC / STM32 都能编译通过。test_led 内部 on(id) 这一句机器码也完全相同,区别只在 on 这个函数指针指向哪段代码。
这一章 snippet 是函数式的最朴素形态:每种硬件直接写 6 个独立函数。第三个参数 id 在 STM32 上 GPIO 当引脚号、PWM 当通道号、I2C 当从机地址。后续章节会把这 6 个函数收进 ops 表,再演化到 platform 层可切换的工业化形态。
8.8 工业代码里的函数指针当参数
test_led(on, off, id) 这种“测试工具函数 + 函数指针入参“的形态,在工业代码里非常常见。模式是同一个:通用的循环 / 时序 / 测试逻辑写一份,被测对象的具体动作通过函数指针入参传进来。
举几个例子:
- 设备自检函数:
int self_test(int (*read)(int), int (*write)(int, int), int dev),传一组针对某型号设备的读写函数进去,self_test自己跑流程,不关心是哪型号。 - 批量驱动 LED 矩阵:
void scan_matrix(void (*set_pixel)(int row, int col, int val), int rows, int cols),扫描逻辑一份,具体怎么把像素点亮通过函数指针传进来。 - 通用排序 / 查找:标准库
qsort(arr, n, size, compare)这一行就是函数指针当参数。compare这只字不识arr是结构体还是int,按你给的比较函数排就行。
test_led 是这一类工具函数最朴素的形态。一旦你在一份代码里写过这种函数,你就开始注意到工业代码里到处都是它的影子。
8.9 跑一遍
cd oop-in-c/code/08-callback/pc
make
./demo
预期输出:
========================================
Function pointer as a parameter.
Three LEDs, one test_led.
========================================
--- test_led(gpio_on, gpio_off, 15) ---
[test] open ...
[GPIO] pin 15 ON
[test] hold ...
[test] close ...
[GPIO] pin 15 OFF
--- test_led(pwm_on, pwm_off, 3) ---
[test] open ...
[PWM] channel 3 ON (duty 100)
[test] hold ...
[test] close ...
[PWM] channel 3 OFF
--- test_led(i2c_on, i2c_off, 0x50) ---
[test] open ...
[I2C] addr 0x50 ON (cmd 0x01)
[test] hold ...
[test] close ...
[I2C] addr 0x50 OFF
========================================
Same test_led, three different LEDs.
Late binding -- decide at runtime.
========================================
三段输出。同一个 test_led 函数体跑了三次,[test] open / hold / close 这三行都出自 test_led 内部,没动一个字符。每段中间夹的 [GPIO] / [PWM] / [I2C] 行,是调用方传进去的那对函数指针真正落到了哪段机器码。
完整源码见 oop-in-c/code/08-callback/。
8.10 视频回放
想听口播版的可以看 B 站这一期视频:
视频里这一期叫“把号码给别人拨“。号码是函数指针,朋友是 test_led。号码不是你自己拨的,是你交给别人去拨的。
视频金句:函数指针的本质是延迟决定,不是现在就定死,而是到时候再说。越晚做决定,选择越多。
下一章
test_led(on, off, id) 三个参数还行。
但一颗 LED 你不光要测开关,还要测翻转、调亮度、读状态、读名字。on / off / toggle / set_brightness / get_state / get_name 一路加下去,test_led 的参数列表跟着膨胀,声明换两次行才写得下,调用一次写五行。
参数列表长到换行。
下一篇:第 9 章 · 参数长到换行