弱符号定义:__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,也不会编译报错。

这就是弱符号的优雅之处——可选的覆盖机制

弱符号的三种典型用法

  1. 库函数默认实现:库作者提供一个弱符号的默认函数,用户可自行覆盖。比如 RTOS 中的空闲钩子函数。
  2. 硬件抽象层(HAL):不同外设驱动共用接口,弱符号提供「空操作」版本,避免链接错误。
  3. 测试桩(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 知识图谱

下面这张图帮你理清弱符号的核心逻辑:

__attribute__((weak)) 弱符号核心逻辑 弱符号定义 编译时覆盖 用户定义同名强符号 链接时选择 强符号优先于弱符号 运行时动态 函数指针可再修改 库函数默认实现 硬件抽象层 HAL 单元测试桩 注意:弱符号不能与强符号在同一编译单元共存

总结一下

弱符号不是什么高深的技术,但它解决了一个很实际的问题——如何让库代码既提供默认行为,又允许用户定制。没有它,你可能得用回调函数、条件编译或者虚函数表,那些方案要么性能差,要么代码丑。

我个人建议,在写底层驱动库时,把那些「可能被用户覆盖」的函数都声明为弱符号。这样既保持了代码的通用性,又给了用户足够的自由度。你想想看,一个优秀的嵌入式工程师,不就是要在灵活性和稳定性之间找到平衡吗?

小技巧:如果你不确定某个函数是否应该用弱符号,可以问自己一个问题:「如果用户不实现这个函数,程序还能正常运行吗?」如果能,就用弱符号给个空实现;如果不能,就别用弱符号,让链接器报错提醒用户。

嗯,弱符号就讲到这里。下次你写库函数的时候,不妨试试这个手法,真的很好用。