☰
STM32弱函数__weak深度解析:编译链接与回调覆盖机制
2026/10/21 7:25:44 网站建设 项目流程

1. 先看 __weak 到底解决什么问题

做 STM32 开发的人,早晚会在某一个外设驱动文件里看到类似下面这段代码:

__weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { UNUSED(huart); }

第一次见的时候,很多人会愣一下:这个__weak加在函数前面到底是什么意思?为什么固件库里的函数不直接写实现,非要套一个看起来像是“未完成代码”的壳子?

我的理解很简单:__weak翻译成大白话就是“弱定义”,它给函数提供了一个默认实现,但允许你在别的地方写一个同名的强定义把它替换掉。如果最终链接的时候没有找到你的强定义,就用固件库里的默认版本;如果找到你的强定义,默认版本就自动作废,全程不需要改库文件,也不需要跑什么脚本,链接器会自动完成选择。

这套机制解决的痛点非常实际。以串口接收为例,固件库不知道你的业务逻辑,它只负责把数据从硬件上收进来,收完以后该通知谁、要不要解析、要不要转发,这是应用层的事。于是库作者就在中断处理流程里留一个回调钩子,默认实现是空壳,你在自己的代码里实现同名函数,库的中断流程就会自动跳到你的函数里。这个“留钩子”的动作,靠的就是__weak。

所以第一个关键认知是:__weak不是 C 语言标准里的东西,而是编译器提供的扩展能力,它的作用范围在“链接”这一步,而不是“编译”这一步。很多人在编译阶段没理解这一点,导致后面遇到覆盖不生效、重复定义、断点进不了自己函数时完全摸不着头绪。

适合看这篇内容的人包括:刚接触 STM32 固件库、偶尔被弱函数回调绕晕的初学者,以及用过一段时间但没仔细研究过符号背后的链接规则的开发者。这篇是实操经验的总结,我希望你看完以后,不只是会用,还能在出问题时自己排查。

2. 编译链接视角:__weak 的本质是一次“符号级让步”

2.1 从目标文件说起:每个 .c 文件都有一张“符号清单”

要理解__weak,必须先把编译和链接拆开来看。我们写的每个.c文件,先会被编译器单独处理成.o目标文件。这个过程中,编译器能看到的是单个文件的全部内容,但它看不到其他文件里有什么。比如你在main.c里写了一个HAL_UART_RxCpltCallback,编译器只知道当前文件里有一个同名函数,它不知道固件库里早就定义了一个__weak版本。

真正做“合并”和“裁决”的是链接器。链接器手里拿着所有目标文件的符号表,然后按规则处理:哪个文件需要用到某个函数,哪个文件提供了这个函数,有没有冲突,最终把它们拼成一个可以烧录的固件。

每个函数在符号表里都对应一行记录,普通函数默认是“强符号”;带__weak的函数默认是“弱符号”。链接器看到两个同名强符号会直接报multiple definition并停止,这是很常见的错误。但如果一个是强符号、一个是弱符号,链接器的选择规则是:用强符号,弱符号直接丢弃。如果两个都是弱符号,不报错,但选择结果不具备确定性,这点后面会细说。

用生活里的场景来类比:强符号相当于公司里明确的负责人,每个岗位只允许有一个;弱符号相当于候选人名单上提前填好的默认人选,如果后来有正式负责人上岗,默认人选自动让位。这里的“自动让位”就是链接器干的活。

2.2 不要和 #ifdef 混淆:弱不是编译期的判断

有人刚接触__weak时,会下意识把它当成一种“预处理开关”,以为只要定义了某个宏,默认版本就被删掉。这是不对的。

#ifdef是预处理期动作,发生在编译之前,条件不满足的代码根本不会进入编译流程。__weak是链接期动作,它的默认代码仍然会被编译成目标文件,只是最后链接时可能被链接器丢弃。

这个区别在排查问题时有实际意义。如果你发现自己的覆盖函数没生效,很多人第一反应是去看宏定义、去查头文件,但问题往往出在链接层面:你的目标文件根本没参与链接,或者符号拼写不一致,或者函数被编译器优化成了局部处理逻辑。方向错了,排查就会绕很大的弯。

还有一个常见误区是认为“弱函数不会占空间”。实际上,只要覆盖函数没有出现,默认弱函数就会被链接进固件,它一样会占用 Flash。只是这个函数往往很小,不容易被察觉。

2.3 不同工具链下的弱定义写法

__weak本身是 CMSIS 头文件里做了一层封装,它会根据当前编译器展开成对应的语法。常见情况大致是这样:

  • 基于 GCC 的工具链,比如 STM32CubeIDE 默认的arm-none-eabi-gcc,__weak会展开成__attribute__((weak))。
  • 商业 ARM 编译器环境下,__weak被识别为编译器关键字。
  • 有些环境下还支持 GCC 风格的另一写法:void func(void) __attribute__((weak));

所以你在很多固件库里看到的是这种写法:

__weak void MyFunc(void) { // 默认实现 }

而如果你自己写代码,用 GCC 工具链时也可以直接这么写:

void MyFunc(void) __attribute__((weak)); void MyFunc(void) { // 默认实现 }

这两种写法都能表达“这是一个弱函数”的意思,但从工程可移植性来看,我建议优先使用固件库里已经封装好的__weak关键字,而不是手写属性,这样换编译器时不用大面积改代码。

3. STM32 工程中最常碰到的三类弱函数

3.1 外设回调函数:最典型的例子

在 STM32 的 HAL 库里,几乎所有外设中断回调函数都是弱函数。举几个常见的:

__weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart); __weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin); __weak void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim);

这类回调函数的特点是:外设中断发生后,固件库内部的处理函数会被调用,处理完通用事务后,会去调用这个回调函数。因为它是弱函数,所以用户不需要改固件库源文件,只需要在自己工程的某一个.c文件里写一个同名同参数的强函数,中断流程就会自动进入你的函数。

我最初调试串口接收时踩过一次坑:在main.c里实现了HAL_UART_RxCpltCallback,但平时项目里文件很多,我根本没注意到同一个工程还有一个旧的外设例程文件,里面也定义了一个同样的回调函数。如果两个都是强定义,编译链接时报的是multiple definition,错误提示足以吸引我。可怕的是其中有一个是弱定义,链接器不报错,静默选择了一个版本,我打断点发现自己的代码没被执行。那次之后,我养成了一个习惯:凡是覆盖固件库回调,先在工程里搜索同名函数,确认只有一个强定义存在。

3.2 中断服务函数:启动文件里的弱符号

另一个常见位置是启动文件和例程里的中断处理函数。比如常见的SysTick_Handler、HardFault_Handler,在启动文件里往往先放一个弱定义版本:

__weak void Default_Handler(void) { while (1); }

启动文件还会给每个可能的中断向量名做弱别名,比如:

void WWDG_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));

这个设计的目的是:开发者的中断向量表总得有归处,但开发者不一定为每一个中断都写处理函数。如果某个中断触发了但没有写对应的处理函数,程序就跳到Default_Handler,而默认实现是一个死循环。这么做能让开发早期的问题暴露出来,而不是程序发生后静默丢失异常。

但这里也有一个容易被忽略的问题:如果你在代码里写了WWDG_IRQHandler,却发现执行的是默认死循环,大概率是函数名拼写错了,或者函数放在了没有被参与编译的文件里。中断向量表的符号匹配是精确匹配,少一个下划线、大小写不对,链接器都不会把它当成同一个符号。

3.3 自己设计模块时造一个“弱钩子”

除了覆盖库函数,__weak也很适合在项目内部做“可定制扩展点”。比如写一个按键管理模块,你可以在模块里定义一个处理按键事件的默认函数:

__weak void App_KeyEventCallback(uint8_t key_id, uint8_t event) { // 默认为空,等待应用层实现 }

然后在按键扫描任务里调用这个函数。应用层如果想处理按键事件,只需要在别处定义一个强函数覆盖它。这样模块层和应用层的耦合就降低了:模块不需要知道应用层怎么处理事件,应用层也不需要去改模块代码。

我用这个方式写过输入管理、状态机通知、错误日志上报之类的小模块。它的好处是,不同项目复用同一个模块时,不需要为每个项目的特殊逻辑拆分版本,只在应用文件里实现自己的钩子函数即可。

4. 覆盖弱函数的正确姿势

4.1 标准操作:同名同参数同返回值,且不能是 static

覆盖弱函数要满足两个硬性条件:函数签名必须完全一致;你的定义必须是全局可见的强定义,也就是不能加static。

一个正确的覆盖写法是这样的,放在你自己的app_uart.c里:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 收到一帧数据的业务处理 uint8_t tmp = huart->Instance->DR; // 继续开启下一次接收 HAL_UART_Receive_IT(huart, &rx_data, 1); }

注意返回类型、参数类型、参数顺序都不能变。如果你写成HAL_UART_RxCpltCallback(void),或者int HAL_UART_RxCpltCallback(...),就算名字一样,系统也不会认为这是同一个函数,最终生效的还是库里的弱版本。

另外特别提醒:不要在同一个.c文件里既写一个强定义又写一个同名的弱定义。虽然链接器能处理跨文件的强弱定义,但同一个编译单元里出现两个同名函数,编译器在编译阶段就会报重复定义,根本走不到链接器那一步。

4.2 覆盖没生效?先检查这五个地方

我根据实际经验整理了一个排查清单,遇到弱函数没生效时按顺序检查:

第一,函数签名是否完全一致。多一个static、少一个参数、返回值变了,都会导致符号不匹配。

第二,你的.c文件是否真的参与了编译。在 IDE 里新建文件后,如果没有加入编译路径,或者文件被排除在构建之外,整个目标文件都不会进入链接,弱版本自然不会被替换。检查方法是用构建日志确认这个.c文件对应的.o文件出现在链接列表里。

第三,工程里是否只有你的这一个强定义。用“搜索整个工程”的方式搜索函数名,确认没有其他文件也定义了这个同名函数,尤其注意例程文件夹和旧代码备份。

第四,函数是否被放在extern "C"环境里。有些工程会混用 C 和 C++,如果你在 C++ 文件里实现覆盖函数,但没有extern "C"修饰,C++ 编译器会对函数名做 name mangling,链接器看到的是带参数的符号名,和固件库里的纯 C 符号对不上。

第五,是否存在高优先级的中断入口或包装层,实际执行的路径并没有走到你期望的回调。比如有人以为HAL_UART_RxCpltCallback只要收到一个字节就会触发,但实际上接收中断可能被另一个 DMA 回调路径抢先处理了,回调入口根本没被调用。这不是符号问题,而是逻辑路径问题。

4.3 不要直接修改固件库源文件

以前有些项目组遇到回调不合适,第一反应是把__weak去掉,直接在库源文件里填充业务代码。短期看省事,长期看是给自己埋雷。

库文件一改,后续更新固件库时就没法平滑替换了;万一项目组里有人同步了整个库目录,你的修改会被静默覆盖。而且库源文件里的业务代码会让这个库失去通用性,下一个项目复用时还得挨个清理。

正确做法永远是:保留库里的弱定义,在应用层提供强定义覆盖。库文件保持“出厂状态”,你带走的只是一份独立的应用代码。这也是为什么固件库作者坚持用弱函数而不是直接用条件编译的原因之一:弱函数让“扩展”发生在代码之外,而不是代码之内。

5. 三个容易混淆的细节

5.1 __weak 与 static inline 不能随便混用

不少人会在头文件里写一个static inline版本的默认回调,希望既能让编译器内联,又允许用户覆盖。这种想法的出发点很好,但结果往往不是预期的。

static inline函数在每个包含它的源文件里都有一份自己的副本,它可能根本不会产生一个可供链接器合并的全局符号。如果某个函数被直接内联到调用处,那调用点调用的就是头文件里的那版本,你再在别的文件里写一个同名的强函数,链接器根本不知道这件事,因为内联版本压根没参与链接。

所以实用的建议是:要么用头文件里的普通extern声明加库文件里的__weak定义,要么在头文件里用宏和条件编译控制实现,但不要试图用static inline去配合__weak搞“默认实现 + 用户覆盖”的组合。遇到这种模式,先想清楚你更在乎的是性能内联,还是符号覆盖。

5.2 两个弱定义同时存在时,结果未必符合预期

很多人以为“弱函数就是可以让用户覆盖”,但其实如果用户自己的定义也加了__weak,那就变成两个弱符号并存了。这种情况下,链接器不会报错,但它也不保证选哪个。

GNU 工具链在处理多个弱定义时,有相当高的概率会选择链接顺序里先出现的那一个。但这属于“当前实现下的行为”,不是标准文档承诺的可移植特性。工程上我不建议依赖这个特性,否则换一次工程文件的排列顺序、改一次链接脚本,回调就可能悄悄变了。

所以,你自己的覆盖函数千万不要写成这样:

__weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 你以为你覆盖了库,实际上你们俩都是弱符号 }

正确的做法是写成普通强函数,清清爽爽,不带__weak。

5.3 弱属性放在声明里和放在定义里,作用范围不一样

__weak可以出现在函数声明上,也可以出现在函数定义上。不同位置表达的意思略有差异。

最常见、也最推荐的是把弱属性放在定义上,直接告诉编译器“这个函数实现是弱符号”。如果你只在头文件里放一个带弱属性的 extern 声明,然后某个.c文件里又写了一个不带弱属性的强定义,最终这个符号会以“强符号”的身份出现。在很多工具链里,声明里的弱属性更像是建议,而不是强制约束。

为了减少这些边界情况,我的习惯是统一在.c文件的函数定义处写__weak,头文件里只写普通的extern声明。这样读代码的人一眼就能看到“这里有个默认实现”,不会产生歧义。

6. 排查技巧:怎样确认你的版本真的被链接进了固件

6.1 看 map 文件是最直接的办法

每次编译链接完成后,工程都会生成一个 map 文件。这个文件记录了最终的符号分配情况:每个函数被放在了哪个地址、空间大小是多少、来自哪个.o文件。

在 map 文件里搜索你覆盖的函数名,比如HAL_UART_RxCpltCallback。如果这条符号显示的来源是你的app_uart.o,说明你的强定义已经生效,库里的弱版本被丢弃了。如果来源显示的是某个固件库对象文件,说明你的覆盖没有成功。

只看符号列表往往不够,因为有些 map 文件很长,同名符号可能出现在不同位置。我一般会在 map 文件里同时搜函数名和对象名两处,确认唯一性。

6.2 用 nm 工具查看符号属性

除了 map 文件,链接器的配套工具里也有一个很实用的命令可用来检查符号。GCC 工具链下可以执行:

arm-none-eabi-nm 工程文件.elf | grep HAL_UART_RxCpltCallback

输出结果里如果符号类型是T,表示这是一个全局文本段强符号;如果显示W、w或类似标识,表示它是弱符号。正常覆盖之后,你期望看到的是T,而不是W,否则说明强定义没有生效。

这个方法在对比排查时特别有用,尤其是工程存在多个同名校验、链接顺序不同导致结果不同的时候,每次都能很快确认当前固件实际用的到底是哪一份实现。

6.3 反汇编验证执行路径

如果符号已经确认是强定义,但还是觉得行为不对,那就得看反汇编。在 IDE 里找到 map 文件里记录的地址,跳转到反汇编视图,或者手动在内存窗口里查看该地址的机器码,确认裸金属函数入口处的指令是否烧进去了。

实际调试中,有一种隐蔽情况是:链接器把覆盖函数的符号和库里的弱函数符号都吸收了,但某个外设中断向量表里的地址仍然指向弱函数。这个现象主要发生在中断向量部分是独立目标文件且没有参与链接时,或者启动文件里的向量表使用了传统的中断名称,而你的强函数写成了另一个功能名称。出现这种情况时,单纯清理工程缓存重新编译往往能解决一大部分奇怪问题。

7. 工程级使用建议

7.1 给你的自定义钩子函数加命名前缀

弱函数最大的软肋是“同名即覆盖”,好处是简单,坏处是容易发生无意识的符号冲突。项目内部自定义弱函数时,我强烈建议加功能前缀,避免出现Handler、Callback这类过于通用、到处都有可能出现同名定义的函数名。

比如你可以写成:

__weak void App_KeyEventCallback(uint8_t key_id, uint8_t event) { // 默认空实现 }

而不是:

__weak void KeyEvent(uint8_t key_id, uint8_t event) { // 太容易撞车 }

覆盖者在自己的文件里也容易识别,代码可读性明显更好。

7.2 能用宏和条件编译替代时,不要滥用弱函数

弱函数适合“模块默认提供一种行为,应用层可替换”的场景,但它并不适合所有扩展需求。如果一个接口需要根据若干个编译宏切换实现,直接用#ifdef更直观。如果覆盖逻辑只是配置不同,用函数指针作为钩子可能更灵活。

我见过一些工程把所有回调全设计成弱函数,结果整个工程里充满了空壳弱函数,想找哪个函数真正被使用,必须逐个搜索。过犹不及。弱函数要解决的是“不确定谁来处理”的扩展问题,不是“每个功能都留一个后门”的设计时尚。

7.3 库里的弱定义不要乱删

有些人觉得固件库里那些默认空实现的弱函数没什么用,看到就删。这是一个危险动作。弱定义的存在一方面是为了保证在没有用户实现时程序仍能链接通过,另一方面它也承担了“文档”的作用,告诉你这个扩展点的函数签名是什么。

删掉库里的弱定义后,如果某个上层模块仍然调用了这个函数,链接器会报未定义引用,除非你把所有调用点都改掉。更麻烦的是,当你从上游更新固件库时,删除的代码有可能会用另一种形式“偷偷”回来,造成编译行为变化。

所以我的原则是:固件库里的弱定义,除非你完全理解它的调用链和风险,否则一律保留。你在应用层覆盖它,不删它,两者完全不冲突。

最后再分享一点个人经验:遇到弱函数相关的问题,不要先怀疑工具链,也不要急着推翻自己的覆盖代码,第一步永远是去 map 文件里确认符号归属。符号在 map 文件里的来源对象会直接告诉你“链接器最终选择了谁”,这一步定位了 80% 的问题。剩下的问题,多半出在签名不一致、文件没参与编译、或者是多个同名定义并存这几种常规原因上。把这些常识内化之后,__weak不再是一个高深概念,它就只是一个方便你“替换默认行为”的接口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询