弱符号定义:__attribute__((weak)) 的实战价值
说实话,我第一次接触 __attribute__((weak)) 是在做 bootloader 开发的时候。当时有个需求——不同硬件平台要共用一套启动代码,但个别外设的初始化函数又不一样。我总不能为每个平台都 fork 一份代码吧?那维护成本就太高了。
后来一位老同事告诉我:「试试弱符号。」
嗯,这一试就用了十年。
什么是弱符号?
先看一个最简单的例子:
// 默认实现 —— 弱符号
__attribute__((weak)) void led_init(void) {
// 啥也不干,或者给个通用行为
return;
}
// 强符号版本 —— 如果用户定义了,就覆盖弱符号
void led_init(void) {
GPIO_InitTypeDef gpio = {0};
gpio.Pin = GPIO_PIN_5;
gpio.Mode = GPIO_MODE_OUTPUT_PP;
HAL_GPIO_Init(GPIOA, &gpio);
}
说白了,weak 就是给函数一个「备胎」身份。链接器在链接时,如果发现同一个符号有多个定义,它会优先选择「强符号」。只有强符号不存在时,才用弱符号。
这个机制在嵌入式开发中特别有用。你想想看,库函数可以提供一个默认实现,用户如果想定制,自己写一个同名函数就行了,完全不用改库代码。
弱符号 vs 强符号:规则很简单
| 符号类型 | 定义方式 | 链接优先级 |
|---|---|---|
| 强符号 | 普通函数/全局变量定义 | 最高 |
| 弱符号 | __attribute__((weak)) | 仅当无强符号时使用 |
| 未定义符号 | extern 声明但未实现 | 链接报错 |
规则其实就一条:强符号永远优先于弱符号。如果两个强符号重名,链接器会报重复定义错误。但弱符号不会,它很「谦让」。
我在项目中遇到的一个典型场景
几年前做一款低功耗传感器节点,MCU 是 STM32L0 系列。我们需要一个统一的延时函数,但不同版本的 HAL 库实现略有差异。
我的做法是这样的:
// 在公共模块中定义弱符号
__attribute__((weak)) void delay_ms(uint32_t ms) {
// 使用 SysTick 的默认实现
HAL_Delay(ms);
}
// 在具体板级文件中覆盖
void delay_ms(uint32_t ms) {
// 使用更精确的定时器实现
__HAL_TIM_SET_COUNTER(&htim6, 0);
while(__HAL_TIM_GET_COUNTER(&htim6) < ms * 1000);
}
这样,公共代码不需要知道底层用的是哪个定时器,板级文件只需要提供自己的 delay_ms 就行了。如果某个板子没提供,那就用默认的 HAL_Delay,也不会编译报错。
这就是弱符号的优雅之处——可选的覆盖机制。
弱符号的三种典型用法
- 库函数默认实现:库作者提供一个弱符号的默认函数,用户可自行覆盖。比如 RTOS 中的空闲钩子函数。
- 硬件抽象层(HAL):不同外设驱动共用接口,弱符号提供「空操作」版本,避免链接错误。
- 测试桩(Test Stub):单元测试时,用弱符号替换真实硬件函数,方便 mock。
弱符号的避坑指南
我曾经踩过一个坑:在同一个 .c 文件中同时定义了弱符号和强符号,结果链接器总是报重复定义。后来才发现,弱符号和强符号不能在同一个编译单元中同时存在。正确的做法是:弱符号放在库文件中,强符号放在用户代码中。
另外还有几点要注意:
- 弱符号不能用于
inline函数,因为内联函数没有独立的符号。 - 弱符号在静态库(.a)和动态库(.so)中的行为略有不同,嵌入式开发中多用静态库。
- GCC 和 ARMCC 都支持
__attribute__((weak)),但 IAR 用的是__weak,移植时要注意。
弱符号的底层原理
你可能会问:链接器是怎么区分强弱符号的?
其实很简单。编译器在生成目标文件(.o)时,会在符号表里标记每个符号的属性。弱符号的标记是 WEAK,强符号是 GLOBAL。链接器在解析符号时,看到 WEAK 就知道「这个可以覆盖」。
用 nm 命令可以查看:
$ nm firmware.elf | grep delay_ms
00001234 W delay_ms // W 表示弱符号
00005678 T delay_ms // T 表示强符号(text 段)
嗯,看到那个 W 了吗?那就是弱符号的身份证。
弱符号与函数指针的配合
我个人习惯把弱符号和函数指针结合起来用,这样更灵活:
// 定义弱符号的默认处理函数
__attribute__((weak)) void default_handler(void) {
// 默认行为:打印错误并复位
printf("Unhandled interrupt!\n");
NVIC_SystemReset();
}
// 函数指针,默认指向弱符号
void (*irq_handler)(void) = default_handler;
// 用户可以在运行时动态修改
void set_irq_handler(void (*handler)(void)) {
irq_handler = handler;
}
这样既保留了编译时的覆盖能力,又增加了运行时的灵活性。我在做中断管理框架时经常用这个模式。
弱符号的 SVG 知识图谱
下面这张图帮你理清弱符号的核心逻辑:
总结一下
弱符号不是什么高深的技术,但它解决了一个很实际的问题——如何让库代码既提供默认行为,又允许用户定制。没有它,你可能得用回调函数、条件编译或者虚函数表,那些方案要么性能差,要么代码丑。
我个人建议,在写底层驱动库时,把那些「可能被用户覆盖」的函数都声明为弱符号。这样既保持了代码的通用性,又给了用户足够的自由度。你想想看,一个优秀的嵌入式工程师,不就是要在灵活性和稳定性之间找到平衡吗?
小技巧:如果你不确定某个函数是否应该用弱符号,可以问自己一个问题:「如果用户不实现这个函数,程序还能正常运行吗?」如果能,就用弱符号给个空实现;如果不能,就别用弱符号,让链接器报错提醒用户。
嗯,弱符号就讲到这里。下次你写库函数的时候,不妨试试这个手法,真的很好用。