C 弱符号 weak/alias:可选实现、默认回调与覆盖钩子
更新时间:2026-09-24。本文是
languages/c/主题专家层的一篇,紧接 C 语言编译、链接与 ELF。当库作者想提供一个「默认什么都不做、但应用随时可以替换」的钩子,又不想让应用为了可选依赖去改库源码时,答案就是 C 弱符号。Linux 内核的架构挂钩、ARM CMSIS 的中断向量、glibc 的桩函数,背后都是同一种机制。
本文要回答的问题:C 弱符号解决了什么
- 库里写了个默认回调,应用想换成自己的版本,除了改库源码还有别的办法吗?
- 函数在整个工程里根本没有定义,链接凭什么能通过?运行时又会发生什么?
weak、weakref、alias、constructor这几个属性各自动到 ELF 的哪个部分?- 强符号和弱符号同名时,链接器按什么规则裁决?动态库里的弱符号为什么行为不一样?
一、强符号与弱符号:链接器的裁决规则
默认情况下,一个全局函数或全局变量的定义是强符号(strong symbol)。经典规则只有三条:
- 两个及以上同名强定义 → 链接器报
multiple definition错误; - 一个强定义加一个或多个弱定义 → 强定义胜出,弱定义被丢弃;
- 全是弱定义 → 不报错,链接器选用它扫到的第一个,其余丢弃。
而对弱引用(只有声明、没有任何定义)的处理更特殊:链接器不报错,把该符号的值解析为 0(地址为 NULL)。这正是「可选实现」的根基。
| 符号同名的组合 | 链接器行为 | 程序员看到的结果 |
|---|---|---|
| 强 + 强 | 报错 | multiple definition of 'foo' |
| 强 + 弱 | 取强,丢弱 | 应用的强版本静默覆盖库的默认版 |
| 弱 + 弱 | 取第一个被链接器看到的 | 不报错,但不要依赖「选哪个」 |
| 弱引用 + 无定义 | 解析为 0,链接通过 | 运行时符号地址为 NULL |
| 强引用 + 无定义 | 报错 | undefined reference to 'foo' |
需要注意变量的弱定义还有历史包袱:老式 comm 公共符号多重定义时,链接器可能按最大尺寸合并,这种行为版本间并不稳定,工程上不要依赖。彻底理解符号表与重定位如何工作,可以读 符号重定位与 C 弱符号。
二、弱定义:库给默认实现,应用链强版本替换
下面这张图画出链接器面对同名符号时的裁决与覆盖流程。入口是各目标文件导出的符号表;先判断有没有强定义,有强则强,无强才用弱;弱引用彻底找不到定义时落地为 NULL。

可运行 demo:默认弱实现 + 强覆盖
库侧只给一个空的弱实现,并在内部通过它派发事件:
/* libhook.c */
#include <stdio.h>
__attribute__((weak)) void on_event(int code) {
(void)code; /* 默认:什么都不做 */
}
void dispatch(int code) {
on_event(code); /* 调用点在库里,实现在链接期定夺 */
}应用侧不修改库,只在自己的 .c 里提供一个同名的强定义:
/* app.c */
#include <stdio.h>
void on_event(int code) { /* 强定义,没有 weak */
printf("strong on_event: %d\n", code);
}
extern void dispatch(int code);
int main(void) { dispatch(7); return 0; }编译并观察符号:
gcc -c libhook.c
nm libhook.o
# 0000000000000000 W on_event # 大写 W:弱定义的代码符号
# 0000000000000010 T dispatch
gcc app.c libhook.o -o app
./app
# strong on_event: 7 # 强定义自动覆盖弱实现如果应用不提供强定义(main 里直接调 dispatch),程序照样链接成功,只是走空实现,什么都不打印。这就是「不改一行库代码即可挂钩,也可以完全不挂钩」。用 readelf -s 看最终可执行文件,弱符号在符号表里的绑定列为 WEAK,强符号为 GLOBAL:
readelf -s app | grep on_event
# Num: Value Size Type Bind Vis Ndx Name
# XX: 0000000000001149 22 FUNC GLOBAL DEFAULT 14 on_event覆盖后保留下来的是应用的 GLOBAL 版本;若没有强定义,这里显示的就是 WEAK。
为什么这套机制值得专门使用,而不是用函数指针注册表?对比一下就清楚了:函数指针方案要在库里定义一个全局指针变量,应用在初始化阶段把指针改写成自己的函数,占 RAM、占一次初始化时序,还要担心谁先谁后;弱符号方案把选择动作提前到链接期,最终可执行文件里只留下被选中的那一份实现,没有间接跳转、没有可变全局、没有初始化顺序问题。代价是替换发生在构建时,无法在程序运行后再换实现——凡是需要运行期热插拔的场景,仍然要回到函数指针或 dlopen 与插件。
三、弱引用:符号缺失时为 NULL,而不是链接报错
弱定义解决「给默认」,弱引用解决「可没有」。在声明上直接加 weak,或者用 GNU 扩展 weakref("target"),告诉链接器:这个符号允许找不到。
/* opt.c */
#include <stdio.h>
__attribute__((weak)) void optional_feature(void); /* 弱声明,无定义 */
int main(void) {
if (optional_feature) /* 先取地址判 NULL,再决定是否调用 */
optional_feature();
else
puts("optional feature absent");
return 0;
}gcc opt.c -o opt # 注意:没有任何文件定义 optional_feature
./opt
# optional feature absent换成普通声明,同样的命令必然是 undefined reference to 'optional_feature'。弱引用把「链接期的硬错误」变成了「运行时的一次 NULL 判断」,于是同一份二进制可以在「带可选库」和「不带可选库」两种环境下分别构建运行,嵌入式固件探测可选外设驱动、glibc 探测弱 libc 函数,用的都是这招。
weakref 还有一种带目标的写法:static __attribute__((weakref("optional_feature"))) void opt(void);。它生成的是一个静态别名引用,不需要在头文件里暴露符号名,适合把可选调用封装在单个 .c 文件内部。注意这种写法要求目标符号最终必须以弱引用形态出现,且声明和使用处在同一编译单元,否则编译器会直接报错——它本质上是给「弱声明 + 判空」语法糖,而不是另一套链接规则。
还有一个编译器细节值得知道:判空必须是「取地址」的真实语义。写 if (optional_feature) 时,编译器生成的是对符号地址的载入与比较;如果你把调用包在宏里、或者经过了一层不知道弱声明的中间函数,中间函数看到的就是普通强引用,符号缺失的链接错误会重新出现。用 nm 检查目标文件时,未定义弱引用显示为小写 w(代码)或小写 v(变量),与大写 W/V 的弱定义正好对应,排查时这一个字母的差别就能定位问题出在声明侧还是定义侧。
下图是弱引用回调钩子在运行期的解析流程:取地址这一步是整个机制的分叉点——非空走用户钩子,为空走内置兜底路径。

最致命的坑就是跳过判断直接调用:符号为 0 时等于跳转到地址 0,在有 MMU 的系统上立刻段错误,在裸机上表现为不可预期的跑飞。另外,强定义和弱声明的函数签名必须一致——C 不做签名级链接校验,参数个数或类型不符不会被链接器拦下,只会在运行时产生静默破坏。
四、alias 与 weak+alias:可覆盖的中断向量
alias("target") 让一个符号成为另一个符号的别名,二者指向同一段实现,链接后只占一份代码:
void real_handler(void) { /* ... */ }
void alias_handler(void) __attribute__((alias("real_handler")));别名单独用只是省代码;和 weak 组合起来,才是固件世界最经典的写法。ARM CMSIS 的启动文件里,几十个中断向量都指向同一个弱别名的默认处理:
__attribute__((weak)) void Default_Handler(void) {
for (;;) { } /* 默认:死循环,等调试器接入 */
}
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));用户在自己的 main.c 里定义一个同名强函数 SysTick_Handler,向量表就自动指向用户实现;不定义则保持默认。向量表在链接期就固定,没有任何运行时注册表,零 RAM 开销。
Linux 内核里 __weak(内核自封装的 weak)同样随处可见:体系结构相关的挂钩函数给出弱默认实现,某个平台在自己的代码里给强定义即可接管。典型例子是调度、时钟、电源管理里一批带 __weak 的架构回调,通用代码保证「总有实现可调」,特定 SoC 用强定义收编。但要注意时机——这种覆盖发生在 vmlinux 或模块自身的链接期,模块加载进运行中的内核后并不能靠弱符号去覆盖内核里已有的定义。内核与模块的链接背景见 Linux 内核模块 LKM。
设备树驱动框架里还能看到另一种组合:驱动把一组操作函数挂在静态的 of_device_id/struct dev_pm_ops 结构里,而这些结构整体用 __weak 或条件链接的方式提供——不需要电源管理的平台可以不提供强版本,框架核心取到的就是弱默认的空操作集。这种「弱结构体 + 强填充」是弱符号思想从单个函数向数据接口的自然延伸,判据不变:链接期看强弱,运行期看地址。
五、weak、alias、constructor 属性对比
初学者常把 constructor 也和弱符号混为一谈,其实它们动的是 ELF 完全不同的部分。
| 属性 | 作用对象 | 对 ELF 做了什么 | 典型用途 |
|---|---|---|---|
weak(定义处) | 函数/变量定义 | 符号表绑定改为 WEAK | 可被强定义覆盖的默认实现 |
weak/weakref(声明处) | 外部符号声明 | 允许未解析,值为 0 | 可选依赖、可选特性探测 |
alias("target") | 函数声明 | 生成指向 target 的同址符号 | 中断向量共用一个处理 |
weak, alias(...) | 函数声明 | 别名 + WEAK 绑定 | CMSIS 式可覆盖向量 |
constructor | 函数 | 放入 .init_array,main 前由加载器执行 | 自动注册、库初始化 |
constructor 不参与同名覆盖,它的触发依靠 .init_array 段在程序启动时被遍历执行;对应的 destructor 在退出时执行。二者与弱符号正交,可以叠加使用(一个弱定义的构造函数同样能被强定义覆盖)。启动初始化段的完整机制见 init/fini 与构造函数。工程上把弱符号放进自定义输出段、控制链接排布时,则需要配合链接脚本,见 C 弱符号与链接脚本、段与内存布局。
六、链接单元与动态链接:最容易踩的两类坑
坑一:取地址必须发生在同一个链接单元,且强定义得真的被链接进来。 弱声明和「判 NULL 后调用」必须编译在同一个 .o 里,跨编译单元靠普通头文件声明就退化成强引用。更隐蔽的是静态库 .a:链接器只在解析未定义引用时才从归档里抽取需要的成员。如果强版本所在的 .o 没有被任何引用触发抽取,覆盖根本不会发生——弱默认实现照常胜出。需要强制整库拉入时用 -Wl,--whole-archive。
坑二:动态库的弱符号受符号介入(interposition)规则支配。-fPIC 编译的共享库里,全局函数默认经由 PLT/GOT 调用,可执行文件或更早加载的库里出现同名定义(包括 LD_PRELOAD 注入)会介入替换,哪怕库里自己给的是强定义——弱符号更不例外。因此「我在 .so 里给了定义,取地址就一定非空、一定是我这个版本」在动态链接下并不成立。想让库内部绑定到自己的实现,需要 -Bsymbolic、-fno-semantic-interposition 或把符号藏进局部可见性。静态与动态链接的差异见 静态库与动态库,介入与预加载拦截的完整玩法见 LD_PRELOAD 与库注入。
| 现象 | 真实原因 | 处理 |
|---|---|---|
| 给了强定义却没覆盖 | 强定义在未被抽取的 .a 成员里 | 用 .o 直接链接或 --whole-archive |
| 不加定义能链接,运行时段错误 | 弱引用未判 NULL 直接调用 | 调用前先 if (func) |
.so 里的弱钩子行为随环境变 | 符号介入 / LD_PRELOAD / PIE | -Bsymbolic 或限制符号可见性 |
| 覆盖后参数错乱 | 强弱版本签名不一致 | 统一声明,头文件只保留一份原型 |
顺带一提变量的现代行为。GCC 10 起默认开启 -fno-common:老式 C 里「多个 .c 各写一行 int g;」会被当成试探性公共符号合并,现在直接按多个强定义报 multiple definition。这意味着强弱规则对变量也变得和函数一样严格——想提供可覆盖的默认配置变量,就老老实实加 weak,想让定义私有就加 static。这条变化曾经让大量老工程在升级编译器后突然链接失败,看到报错先检查全局变量是不是漏了初始化或 static,比逐文件排查函数快得多。
这一篇在知识体系里的位置
C 弱符号是「链接期多态」的最小工具箱:上游给默认、下游给覆盖,裁决权完全交给链接器。它的底层是符号表绑定与重定位,向前衔接 C 语言编译、链接与 ELF 与 符号重定位;放进自定义段要配合 链接脚本、段与内存布局;运行期动态选择实现的另一条路线(dlopen 插件)见 dlopen 与插件;内核侧的应用见 Linux 内核模块 LKM。ELF 装载与动态解析的完整体系在 ELF 与二进制基础。
总结
C 弱符号把「同名符号谁说了算」变成一组明确的链接规则:强定义覆盖弱定义、弱引用缺失时为 NULL、alias 共享同一份实现,weak+alias 让中断钩子在链接期零成本可替换;代价是必须老实地判 NULL、保证强定义真正进入链接,并认清动态链接下符号介入会改写答案。