Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 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 自己想拨号,怎么办?

把号码给它。

号码存了·但 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 是谁,拨通就行。号码对不对那是你的事。

三种 LED·同一个 test_led

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,写法更简洁,骨子里还是同一件事:把一段逻辑传给别人执行。

回到刚才的延迟决定。视频里给了一句总结:函数指针的本质是延迟决定,不是现在就定死,而是到时候再说。写代码的时候不决定调谁,运行的时候再决定。

C vs C++·延迟决定

金句·延迟决定

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);
}

onoff 这两个函数指针的 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 站这一期视频:

《C 语言·函数指针传参|把号码给别人拨·延迟决定》

视频里这一期叫“把号码给别人拨“。号码是函数指针,朋友是 test_led。号码不是你自己拨的,是你交给别人去拨的。

视频金句:函数指针的本质是延迟决定,不是现在就定死,而是到时候再说。越晚做决定,选择越多。

下一章

test_led(on, off, id) 三个参数还行。

但一颗 LED 你不光要测开关,还要测翻转、调亮度、读状态、读名字。on / off / toggle / set_brightness / get_state / get_name 一路加下去,test_led 的参数列表跟着膨胀,声明换两次行才写得下,调用一次写五行。

参数列表长到换行。

下一篇:第 9 章 · 参数长到换行