番外篇 - 从 STM32 的 __weak说起弱符号、回调函数与虚函数

从 STM32 的 __weak 说起:弱符号、回调函数与虚函数

读 STM32 HAL 库的源码时,你会发现一个奇怪的关键字 __weak 比如处理 GPIO 外部中断的这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != 0x00u)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin);
HAL_GPIO_EXTI_Callback(GPIO_Pin); // 调用回调
}
}

__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
UNUSED(GPIO_Pin);
/* NOTE: 这个函数不应被修改;需要时,请在用户文件里实现 HAL_GPIO_EXTI_Callback */
}

HAL_GPIO_EXTI_Callback 是个空函数,前面挂着 __weak,注释还说”别改我,要用就去别处重写”。这到底是什么魔法?它和大家常说的”回调函数”“虚函数”又是什么关系?


一、__weak 是什么:弱符号

__weak 不是 C 语言标准的一部分,而是编译器(ARM GCC / armclang 等)提供的扩展,等价于 __attribute__((weak)) 它把一个函数(或变量)标记为弱符号(weak symbol)

要理解它,先理解链接器眼中的”符号”分两种:

  • 强符号(strong symbol):普通的函数/全局变量定义
  • 弱符号(weak symbol):加了 __weak 标记的定义,优先级低

链接器的规则很简单:

同一个名字,如果既有强定义又有弱定义,用强的、忽略弱的;如果只有弱定义,就用弱的

所以 __weak 的本质是提供一个可被覆盖的默认实现。在这个函数没人强调的的时候顶上,有人提供时就自动让位

注意!!这一切发生在链接阶段,是静态决定的 也就是说程序一旦编译链接完成, 这个函数用的是哪个版本就定死了,运行时不会再变 请记住这一点,后面对比虚函数时会用到


二、HAL 如何用弱符号实现”可重写的回调”

现在回头看 HAL 的设计就通透了:

  1. HAL 在库里给 HAL_GPIO_EXTI_Callback 提供了一个 __weak空默认实现。这样即使你什么都不写,整个工程也能正常链接通过(只是中断里什么都不做)
  2. 当你想处理中断时,只需在自己的文件(比如 main.c)里定义一个同名的、没有 __weak 的强函数:
1
2
3
4
5
6
7
8
// 你在自己的文件里写的强定义,会覆盖掉 HAL 的弱默认实现
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == Button_Pin)
{
// 处理按键 1
}
}
  1. 链接器看到有强定义,就把 HAL 那个空的弱版本丢掉,中断真正触发时调用的就是你这个

这就是 C 语言里伪重写的常见手段, C 没有面向对象的重写机制,HAL 借助弱符号,巧妙地给你留了个覆盖默认行为的口子

一个常见误解:__weak 本身不等于”回调函数”。__weak 只是”弱符号”这个链接机制;而”回调”是 HAL_GPIO_EXTI_Callback 这个函数扮演的角色(它被中断处理流程回头调用)。HAL 是”用弱符号这个机制,让回调的默认实现可以被覆盖”。一个弱符号函数未必是回调,回调也未必用弱符号实现。这两个根本就是两个概念


三、那回调函数到底是什么?

回调函数(callback)是一种通用的编程模式,核心思想叫控制反转(Inversion of Control):

打起十分精神看这段话: > 通常是”你的代码调用库”;而回调反过来 – “库在合适的时机回头调用你的代码”。

你只管提供一份实现(填空),由别人决定”什么时候调它”(我联想到了 qsort, 你提供一份比较函数的实现, 而 qsort 决定什么时候调用它) 中断来了调你的处理函数、按钮被点了调你的事件处理、排序时调你的比较函数……都是回调

在 C 语言里,实现回调主要有两种:

函数指针

把函数的地址当参数传出去,让对方调用。比如标准库的 qsort:

1
2
3
4
int cmp(const void *a, const void *b) {
return (*(int*)a - *(int*)b);
}
qsort(arr, n, sizeof(int), cmp); // 把 cmp 交给 qsort,让它在排序时回调

弱符号

不传指针,而是约定一个固定的函数名,库里放个弱默认实现,你按名字提供强定义来覆盖

这两种都是回调,只是绑定的方式不同:函数指针是运行期传递、可以随时换不同的函数;弱符号是链接期固定、全程序只能有一个覆盖版本


四、__weak和虚函数是一回事吗?

很多人会把弱函数虚函数(virtual function) 搞混,因为两者”感觉上”都像在”重写一个函数”。但它们其实完全不同的概念

虚函数是 C++ 面向对象专属的概念,离不开类和继承:

1
2
3
4
5
6
7
8
9
struct Animal {
virtual void speak() { /* 默认 */ }
};
struct Dog : Animal {
void speak() override { /* 汪 */ }
};

Animal* a = new Dog();
a->speak(); // 实际调用 Dog::speak()

机制是虚函数表(vtable) 再加上运行期动态分派 :通过基类指针调用时,程序在运行时根据对象的实际类型决定调哪个版本。也就是多态(polymorphism)

注意这里的关键词, 运行期、对象的实际类型。这和弱符号的链接期、静态固定, 天差地别


五、__weak vs 虚函数:

__weak 虚函数
适用语言 通用(C / C++ / JS / Python…) C++ 面向对象专属
实现机制 弱符号 vtable,运行期动态分派
绑定时机 链接期, 确定只有一个 运行期, 按对象类型决定
依附对象? 不需要,独立函数即可 必须通过对象/指针调用
变的是什么? 你是否提供了覆盖版本 对象的实际类型是什么
能否多种实现并存 弱符号全程序只能一个覆盖 每个派生类都能有自己的版本

最重要的是最后一行:虚函数允许同一段调用代码,对不同对象表现不同(DogCat 各汪各喵); 而__weak ,整个程序里 HAL_GPIO_EXTI_Callback 只有一个版本 这就是机制层面的区别


小结

  • __weak(弱符号): 指链接期默认实现可被同名强定义覆盖
  • 回调函数: 控制反转,库在合适的时机回头调用你的代码;HAL 用弱符号这个机制来实现可覆盖的回调
  • 虚函数: 是 C++ 面向对象里一种多态的手段, 靠 vtable 在运行期按对象类型分派

所以 HAL 里那个 HAL_GPIO_EXTI_Callback,是一个 用弱符号实现的回调函数