弱符号是指在定义或者声明一个对象(变量、结构体成员、函数)时,在对象的前面添加 attribute ((weak)) 标志所得到的对象符号。如下所示函数即为一个弱对象符号 void test_weak_attr(void),或者称该函数是弱函数属性的、虚函数。
__attribute__((weak)) void test_weak_attr(void)
// 或者使用如下样式的定义,两者等效
void __attribute__((weak)) test_weak_attr(void)
{
printf("Weak Func!\\r\\n");
}
弱符号是相对于强符号而言的,在定义或者声明变量、函数时,未添加 attribute ((weak)) 标识的就默认为强符号。如下,最普通的函数定义,就是定义了一个强符号 void test_strong_ref(void):
void test_weak_attr(void)
{
printf("this is a strong func\\r\\n");
}
驱动程序往往需要考虑兼容性,因为要兼任很多厂商的不同型号的设备。若驱动程序中使用强符号定义一些与适配的设备的特性相关的功能,则下次适配其他设备时,该强符号函数可能需要被修改,以兼容新的设备。当适配的设备很多时,频繁地更改驱动代码将破坏驱动的可维护性。
弱符号的出现可以很好地解决该问题。弱符号的对象具有可以被重定义的功能(即可以被重载)。下面通过测试说明弱符号这种可被重载的特性。
在 test_weak_attr.c 程序中定义如下弱函数:
// test_weak_attr.c
#include < stdio.h >
__attribute__((weak)) void test_weak_attr(void)
{
printf("this is a weak func\\r\\n");
}
在 main.c 中定义如下程序:
// main.c
void test_weak_attr(void)
{
printf("this is a strong func\\r\\n");
}
void app_main(void)
{
printf("init done\\r\\n");
test_weak_attr();
}
编译运行该 main.c 程序,得到的结果是什么样子的呢?
将 main.c 中的 void test_weak_attr(void) 函数注释掉,再重新编译运行程序得到的结果是:
小结:在使用弱符号函数时,我们可以重新定义一个同名的强符号函数来替代它;若没有重新定义一个强函数来替换它,就使用弱函数的实现。弱函数就好像是一个可以被替换的“默认函数”。
值得一提的是,旧版本的编译器还可以使用如下方式的定义(仅声明无效)将一个对象定义为一个弱对象:
在 linux 的一些代码中,__weak 其实就是通过 attribute ((weak))的重命名,两者等效。
弱引用是在声明一个对象时,通过__attribute__ ((weakref()) 定义一个符号的引用关系。如下所示即定义 test_weakref() 函数弱引用 test_weak_ref() 函数。
弱引用是相对于强引用而言的。未通过 attribute ((weakref()) 的符号和实现代码之间的关系是强引用。如下即为一个强引用函数。它直接给出了 函数 test_strong_ref(void) 的实现。
在编译程序的时候,我们可以直接使用 test_strong_ref(void) 而不必担心编译不通过。如果,我没有时间去实现 test_strong_ref(void) ,还想在程序里先使用该函数那该如何呢?(是的,就是想白嫖,不想实现,还想先在程序里使用这个函数)。
这个时候弱引用就派上用场了。可以先将该函数定义为弱引用插入到代码中,待后期有时间再慢慢优化代码实现这个函数完整的功能。下面结合测试进行说明。
static void test_weakref(void) __attribute__ ((weakref("test_weak_ref")));
void app_main(void)
{
printf("init done\\r\\n");
if (test_weakref) {
test_weakref();
} else {
printf("There is no weakref\\r\\n");
}
}
测试结果:
There is no weakref
void test_weak_ref(void)
{
printf("this is a weak ref\\n");
}
static void test_weakref(void) __attribute__ ((weakref("test_weak_ref")));
void app_main(void)
{
printf("init done\\r\\n");
if (test_weakref) {
test_weakref();
} else {
printf("There is no weakref\\r\\n");
}
}
测试结果:
this is a weak ref
小结:强引用,在未定义该强引用的实现时,编译会报错误:未定义的引用。弱引用允许定义一个未实现(未实例化)的对象,这在编译的时候会将该对象处理成 NULL,编译器并不会报错。通过使用弱引用可以实现后期优化代码的功能。而避免改动使用该函数的地方。使用弱函数可以实现类似“钩子(hook)"函数的功能。
实际上,包括C、python、go 编程语言在内的很多语言 都有类似用法,本篇文章叙述的方法同样适用于这些语言的相关开发。
注意:弱引用仅在静态编译中有效,动态链接中可能无效。
弱符号、弱引用都是增强程序的可维护性的方法。弱符号通过可以被重定义的特性,实现可以被替换实现。弱引用通过可以暂时使用一个未定义的函数的功能,实现允许后期再实现该函数具体功能,而不必担心编译不通过。
为了方便理解,我们先预设一个应用场景:
我们编写了一个模拟IIC的驱动,希望它能够在不同的的平台运行,目标的平台就设为 stm32 标准库,stm32 HAL 库,stm32 LL 库,和 RT-Thread Driver 驱动库。
或许读者有疑惑,为什么同样是 stm32 ,却分成三个平台呢?这是因为从跨平台软件编写者的角度看,只要调用的库的 API 不一致,就和换一个不同的平台没有什么本质的差别,如果在代码中写死了 API 的调用,即使是同一个平台,仍然像多平台一样不能运行。
由此可以看出,跨平台的困难所在,也不是由硬件平台所导致的,而是由代码所依赖的 API 的不同导致的。同一个平台,如果依赖的 API 不同,代码就不能跨平台,同样地,不同的平台,如果依赖的 API 相同,也可以跨平台。
所以归根结底,是代码所依赖的 API 出现了不同,所以下文中所说的“平台”,实际上对应的是一套 API 。
我们继续说这个模拟 IIC 的驱动,模拟 IIC 驱动是使用 GPIO 的反转来模拟 IIC 协议,所以依赖了平台的 GPIO API,如果把调用 GPIO 的部分写死,那么换一个平台,就肯定不能在多个平台上运行。
下面我们开始讨论在多平台运行的解决方案。
我们先从最朴素简单的解决方案开始,然后逐步迭代到弱函数的方案,这样有两方面好处:
一是从简单的方案开始,循序渐进地介绍,可以降低阅读门槛。
二是可以带读者亲历一遍方案的演进过程,以及演进动因。
和给直接给结果相比,注重过程和动因更能够还原技术决策的真实过程。因为任何技术都是从简单朴素逐步演进而来的,如果直接给出最后的结果,会产生理解的断层,即使记住了几种技术的优劣,在新的场景中,面对更加多样化的实际问题也会难免乏力。
先从最朴素的方案讲起。
方案一、手动控制添加编译的 .c 文件
朴素的方案有很多,比如就是多搞几个版本的 .c 文件,比如SIMU_IIC_STM32_HAL.c ,SIMU_IIC_STM32_LL.c, SIMU_IIC_RTT.c 需要哪个就添加哪个进去编译不就完了嘛!
这种朴素的方案虽然看起来简单,但是,这几个文件中包含有共用的逻辑,例如模拟 IIC 的协议的实现,如何将 8bit 的数据依次发送,等等。
这些共用的逻辑,相当于在每个文件中都复制了一份,一旦修改到共用的逻辑,就要手动同步每个文件,这会导致代码冗余和维护难度的急剧增加,很容易出现人为失误。如果需要添加更多的平台支持,就需要再次复制修改代码。
另一个问题是,使用不同的编译工具链,添加编译文件的方式并不一样,例如,Visual Studio 和 MDK keil 通常是手动添加,而 CMake 通常直接添加目录或者通过文件后缀进行搜索。用户在使用不同的编译工具时,需要针对编译工具来分别处理,这也增加了维护的成本。
因此,我们需要寻找更加优雅的解决方案。
方案一中最核心的问题,是没有分离共用的逻辑,和各个平台的适配接口。
在通常的命名惯例中,共用的逻辑称为 Common,而各个平台的适配接口称为 Port。
在接下来的方案中,我们就会引入 Common 和 Port 分离的设计思想,Common 和 Port 的分离,使得对共用逻辑的修改和增强直接 “分发” 到了各个的 Port 中,而增添新的平台,不需要对 Common 做任何修改。
方案二、条件编译
一种更加优雅的解决方案是使用条件编译。条件编译是一种编译时根据条件选择编译代码的技术,可以通过编译器提供的宏定义和预处理指令来实现。
在我们的模拟 IIC 驱动中,可以直接编写 Common 部分,然后 Common 部分通过条件编译,可以根据平台的不同选择不同的 GPIO Port API。
例如,在 STM32 标准库中,可以使用 GPIO_SetPinMode 和 GPIO_WritePin 接口来模拟 IIC 协议,而在 STM32 HAL 库中,可以使用 HAL_GPIO_WritePin 和 HAL_GPIO_ReadPin 接口来模拟 IIC 协议。因此,在代码中可以使用如下的条件编译方式:
#if defined (USE_STM32_STD_LIB)
/* STM32 Standard Peripheral Library */
GPIO_SetPinMode(SDA_PORT, SDA_PIN, GPIO_MODE_OUTPUT_PP);
GPIO_SetPinMode(SCL_PORT, SCL_PIN, GPIO_MODE_OUTPUT_PP);
...
#elif defined (USE_STM32_HAL_LIB)
/* STM32 HAL Library */
HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET);
...
#elif defined (USE_STM32_LL_LIB)
/* STM32 LL Library */
LL_GPIO_SetOutputPin(SDA_PORT, SDA_PIN);
LL_GPIO_SetOutputPin(SCL_PORT, SCL_PIN);
...
#elif defined (USE_RTT_DRIVER)
/* RT-Thread Driver */
rt_pin_mode(SDA_PIN, PIN_MODE_OUTPUT);
rt_pin_mode(SCL_PIN, PIN_MODE_OUTPUT);
...
#endif
这样,在编译代码时,我们可以通过宏定义来选择编译使用哪个平台的代码,
从而实现跨平台运行。
然而,这种条件编译方式还是有一些问题,如果我们需要添加新的平台支持,就需要添加新的宏定义和条件编译,而且需要修改模块的源码。
方案三 函数指针
我们可以进一步分离 Common 和 Port,将其放在不同的 .c 文件中,Common 通过函数调用的方式来访问 Port 提供的接口,这样可以更加灵活和方便地添加新的平台支持。
具体实现可以通过在 Common 中定义一些函数指针类型来实现。
例如,我们可以定义一个名为 IICOps 的结构体,其中包含了一些指向函数的指针,这些函数实现了具体的 IIC 操作。
在 Port 中,我们实现这些函数,并将其指针传递给 Common 中的 IICOps 结构体。
这样,在 Common 中就可以通过调用这些函数指针来访问 Port 提供的接口了。
这种方案的好处是,添加新的平台支持时,只需要实现相应的 Port 函数,并将其指针传递给 Common 中的结构体即可,不需要修改 Common 的源码。
同时,由于 Common 和 Port 分离,不同平台的适配代码可以相互独立,修改一方不会影响到另一方,从而减少了代码冗余和维护难度。
但是,使用函数指针有两个主要的缺点,一是函数指针本身需要占据内存,增加了内存的开销,二是函数指针需要在初始化时进行加载。
具体来说,函数指针的内存开销相对于代码本身并不大,通常可以忽略不计,但在嵌入式系统中,资源有限,内存开销需要更加注意。
而函数指针的初始化需要在程序启动时进行,这也会对程序的启动时间产生一定的影响。此外,函数指针的使用可能会导致一定的运行时开销,需要在程序性能和资源利用方面做出权衡。
另外,函数指针的使用需要程序员有一定的技术水平和经验,需要合理地进行函数指针的定义、传递和调用等操作,避免出现潜在的错误和安全问题。
总的来说,函数指针是一种比较灵活和方便的方式,可以帮助我们实现代码的跨平台支持,但需要在实际应用中仔细考虑其使用场景和影响,做出合理的决策。
方案四、Common 中声明,Prot 中实现
在这种方案中,我们仍然将 Common 和 Port 分离,但是我们不再使用函数指针来访问 Port 中的接口,而是将其定义为 extern 声明,由 Port 来实现具体的函数。
具体实现可以通过在 Common 中定义一些 extern 声明的函数,这些函数实现了具体的 IIC 操作,但是并不在 Common 中实现具体的代码逻辑,而是在 Port 中实现。
在 Port 中,我们实现这些函数,并将其声明为 extern,然后在编译时链接到 Common 中。
这样,在 Common 中就可以通过调用这些函数来访问 Port 提供的接口了。
这种方案的好处是,添加新的平台支持时,只需要实现相应的 Port 函数,并在编译时链接到 Common 中即可,不需要修改 Common 的源码。
这样 Port 的实现函数的挂载就提前到了编译阶段,避免了运行时挂载函数指针的复杂性和容易出错问题。以及避免了函数指针的内存占用。
但是,和 IIC 的例子不同的是,在一些更实际的项目中,随着软件复杂度的提升, Common 中使用的 Port 函数数量会快速膨胀,这时,每个 Port 函数都必须要实现,即使这个功能非常的冷门,这样 Common 中每增加一个 Port 的依赖,都要求所有的 Port 进行及时的跟进,否则整个项目都无法编译通过。
接下来,就要有请 weak (弱函数)机制出马了
但方案四的缺点在于,在一些更实际的项目中,随着软件复杂度的提升,Common 中使用的 Port 函数数量会快速膨胀,这时,每个 Port 函数都必须要实现,即使这个功能非常的冷门,这样 Common 中每增加一个 Port 的依赖,都要求所有的 Port 进行及时的跟进,否则整个项目都无法编译通过。
方案五 弱函数
为了解决这个问题,可以使用 weak (弱函数)机制,将所有的 Port 函数都定义为 weak 函数。
weak 函数是一种特殊的函数类型,带 weak 的函数和不带 weak 的函数可以同时存在,如果有不带 weak 的函数,就会优先链接不带 weak 的实现,如果没有找到不带 weak 的实现函数,就会使用 weak 函数作为默认的实现。
即,在使用弱函数时,如果找到多个实现,链接器会选择优先级最高的实现。
在 C 语言中,可以使用 attribute((weak)) 来声明一个函数为弱函数。例如:
attribute((weak)) void port_func()
{
// 默认实现
}
attribute((weak)) void port_func()
而在 Port 中,只需要实现需要的函数即可,如果某些函数不需要实现,可以不用管它,因为在链接时会使用 Common 中的默认实现。
这样在编译时如果没有找到相应的实现函数,就会使用默认的实现,而不会导致编译错误。
而在 Port 中,只需要实现需要的函数即可,如果某些函数不需要实现,可以不用管它,因为在编译时会使用 Common 中的默认实现。
这样,在添加新的平台支持时,只需要实现需要的函数,而不用实现所有的函数,大大简化了开发的难度和工作量。同时,也避免了函数指针的内存占用和运行时挂载函数指针的复杂性和容易出错问题。
弱函数的多编译器支持
在不同的平台上, weak 的声明方法也会有所不同,因此需要自己定义一个 weak 声明,例如 MY_WEAK,来支持不同的平台:
/* Compiler */
#if defined(__ARMCC_VERSION) && (__ARMCC_VERSION >= 5000000) /* ARM Compiler \\
*/
#define MY_WEAK __attribute__((weak))
#elif defined(__IAR_SYSTEMS_ICC__) /* for IAR Compiler */
#define MY_WEAK __weak
#elif defined(__MINGW32__) /* MINGW32 Compiler */
#define MY_WEAK
#elif defined(__GNUC__) /* GNU GCC Compiler */
#define MY_WEAK __attribute__((weak))
#endif
/* default MY_WEAK */
#ifndef MY_WEAK
#define MY_WEAK
#endif
可以看到,在不同的编译器下,weak 有不同的写法,上面的这些定义包含了对 armcc5、 armclang、IAR、GCC 的支持。
然而,弱函数的方案在一些平台有一些明显的缺陷,例如,MSVC编译器是微软公司开发的C/C++编译器,在Windows操作系统下被广泛使用。与GCC和Clang等主流编译器相比,MSVC对于弱函数的支持不太完善。
MSVC 中,可以通过使用#pragma weak来声明弱函数,但是这个特性只能在 x86 和 x64 平台下使用,而在 ARM 平台下是不支持的。
此外,在一些版本的MSVC编译器中,#pragma weak 的功能也存在一些限制和bug,所以一般在 MSVC 中直接取消 weak 还会更实际一些。
用弱函数对 Port 进行分类
weak 的引入使得我们的 Port 可以根据实际的需求进行划分,而不是一股脑地必须实现所有的 Port 函数。
引入了 weak 之后,我们就有条件将 Port 划分为以下的几种:
1.核心且无默认实现的 Port
这属于必须实现的 Port,缺乏这个 Port,模块的核心功能就运行不起来。例如模拟 IIC中对 IO 的操作 Port 函数,这种 Port 用不用 weak 的区别不大,属于最硬的骨头,在设计软件时应当注意尽可能地减少这种 Port。
在设计软件时,可以直接取消这类 Port 的弱定义,让编译器在编译时就抛出错误。既然缺少了这类 Port 系统的核心功能就无法工作,那么编译通过了也没有什么意义。
这类 Port 可以直接定义一个默认实现,在大多数情况下,用户就可以不用管这个 Port 了,而在有定制需求的场合下,又可以灵活地定制。
例如弱定义一个 port_printf 用来支持跨平台软件的打印输出,默认是直接使用平台的 vprintf,对于大多数的用户来说,只需要打印到平台自带的 printf 即可,因此对于大多数用户来说,这个 Port 不用实现,就能正常使用系统了。
attribute((weak)) void port_printf(char* fmt, ...) {
va_list args;
va_start(args, fmt);
vprintf(fmt, args);
va_end(args);
}
而有定制需求的用户可以通过自己重写 port_printf() 来打印到其他的地方(比如输出到 log,或者输出到其他串口)。
在 printf 的例子中,我们默认了 printf 是所有的平台都提供了的,对 printf 进行这种假设是合理的,因为它是 libc 的标准函数。
然而,有些 Port 虽然有默认实现,却不能支持所有的平台,例如线程操作的 Port,在常见的平台中,如 linux、RT-Thread、FreeRTOS ,我们知道如何写默认实现,但是这些实现又不通用,这时我们可以在默认实现中结合条件编译,为常见的平台提供默认实现,例如:
attribute((weak)) void port_thread_start(port_thread_t* thread) {
#ifdef __linux
pthread_mutex_lock(&(thread- >mutex));
pthread_cond_signal(&(thread- >cond));
pthread_mutex_unlock(&(thread- >mutex));
#elif USE_FREERTOS
vTaskResume(thread- >thread);
#else
#error "port_thread_start() 需要用户实现"
#endif
}
这个例子中为 linux 和 FreeRTOS 提供了默认的线程启动 Port 的实现,使用 linux 或者 FreeRTOS 的用户可以通过条件编译来直接使用默认实现。
而既不用 linux 也不用 FreeRTOS 的用户,则会在编译时遇到 #error,这提示他们要自己实现 port_thread_start()。
这种写法还有一种好处,就是在不支持 weak 的平台,例如 MSVC,就可以通过 _WIN32 条件编译来进行跨平台支持。
还有一类 Port,它们比较冷门,只有部分用户会使用到,但是又难以提供默认的实现。例如用 port_reboot() 来重启硬件,每个硬件平台重启硬件的 API 都是不同的,我们无法提供一个默认的实现。
但是,没有这个 Port,也不影响系统的核心功能,只是在某些时候(例如开启了超时自动重启功能),又有这个 Port才行,这样的 Port 就属于是边缘但无默认实现的 Port。
这时,就可以选择将 Port 缺失的错误延后到运行时,具体在操作时,就可以编写一个 weak 的实现,而这个实现中抛出一个运行时错误,例如:
attribute((weak)) void port_reboot(void){
printf("Error: port_reboot() 需要用户实现\\r\\n");
while(1);
}
这样,只要不用到这个 Port,都可以编译通过且顺利运行,只有实际用到时,才会在运行时报错。
weak 在 GCC 链接静态库时的问题
这里我想特别提示一种常见的 weak 失效的问题,这种问题目前我只发现在 gcc 链接静态库时包含 weak 会出现。
gcc 在链接静态库时,默认的行为是只要找到第一个(不管是不是弱符号),就会将其链接,然后停止继续寻找,这样一来,如果你的 weak 是被第一个找到的,那么强定义的函数就失效了。
这个问题有多种解决方案,我这里只提示一种,有更好的方案可以进qq交流群:577623681 大家一起讨论。
解决方案:使用 "-Wl,--whole-archive" 选项来解决。当使用这个选项时,链接器将整个库文件都包含在链接输出文件中,而不考虑这些库文件是否实际上被使用了。这样就可以保证弱符号在整个库中得到了正确的链接,并且在可执行文件或其他库中保持有效。
需要注意的是,当使用 "-Wl,--whole-archive" 选项时,可能会将一些不必要的库文件链接到最终的可执行文件或库中,这可能会增加最终文件的大小。因此,应该仅在必要时使用这个选项。
全部0条评论
快来发表一下你的评论吧 !