A partial archive of discourse.readcsbooksforgreatgood.org as of Thursday April 25, 2024.

静态链接在解决符号过程中出现无法发现重复符号的隐患

dianqk
2022-04-17

发现问题

当两个 static library 分别定义了一个相同的符号时,静态链接可能不会有任何异常提示,最终运行时出现问题。

我们来看下面这个例子:

创建两个目录,分别为 addsubadd 目录内容如下:

// add/add.c
#include "add.h"
#include "myfun.h"
#include "add.h"
int add(int a, int b) {
    myfun();
    return a + b;
}

// add/myfun.c
#include <stdio.h>
void myfun(void) {
    printf("myfun in libadd.a\n");
}

// add/add.h
#ifndef add_h
#define add_h
int add(int a, int b);
#endif

// add/myfun.h
#ifndef myfun_h
#define myfun_h
void myfun(void);
#endif

sub 目录内容如下:

// sub/sub.c
#include "myfun.h"
#include "sub.h"
int sub(int a, int b) {
    myfun();
    return a - b;
}

// sub/myfun.c
#include <stdio.h>
void myfun(void) {
    printf("myfun in libsub.a\n");
}

// sub/sub.h
#ifndef sub_h
#define sub_h
int sub(int a, int b);
#endif

// sub/myfun.h
#ifndef myfun_h
#define myfun_h
void myfun(void);
#endif

我们再给根目录添加一个 main.o 调用对应的函数:

#include "add/add.h"
#include "sub/sub.h"

int main() {
    add(1, 1);
    sub(1, 1);
}

使用以下命令编译:

# 在 add 目录下生成 libadd.a
pushd add
gcc -c add.c myfun.c
ar rcs libadd.a add.o myfun.o
popd

# 在 sub 目录下生成 libsub.a
pushd sub
gcc -c sub.c myfun.c
ar rcs libsub.a sub.o myfun.o
popd

gcc -c main.c
gcc -o prog main.o ./add/libadd.a ./sub/libsub.a

这里既然有两个都是 strong 的符号,应当出现重复定义符号的报错,实际执行会发现没有任何的报错或告警,同时运行结果如下:

$ ./prog
myfun in libadd.a
myfun in libadd.a

如果调换一下上面的 static library 参数顺序,得到的结果如下:

$ ./prog 
myfun in libsub.a
myfun in libsub.a

而我们期望运行结果应当是:

  1. 启动调用到 main 函数
  2. main 函数调用 addadd 调用 libadd.a 中的 myfun,输出 myfun in libadd.a
  3. 接下来,main 函数调用 subsub 调用 libsub.a 中的 myfun,输出 myfun in libsub.a

对应的输出为:

myfun in libadd.a
myfun in libsub.a

问题分析

为了了解这里到底发生了什么,我们重新来看 7.6 Symbol Resolution 章节(特别是 7.6.3 How Linkers Use Static Libraries to Resolve References 小节)。

根据这一章可以得到,链接器会一个一个地处理用户的参数,如果是 object file 则将对应的符号添加到 E,同时更新新增(未定义)引用的符号到 U 和定义符号到 D,如果是 archive(static library)则仅从 U 中查找匹配的未定义符号,如果有匹配,则将这个 static library 中对应的 object file 也添加进来(规则和遇到一个 object file 相同)。

这里的规则描述不够完整,详细内容可以从书中查找。

这里我一般通俗地理解为,传递给链接器的 object file 是需求方,表示这些都要添加到最终的可执行文件,缺失的符号由 static library 作为供给方提供。

根据上述规则,我们按步骤模拟例子中的解决符号的过程:

首先输入 main.o,结果如下:

E U D
main add main
sub

下一个输入的参数为 ./add/libadd.a,可以知道这里提供了符号 add,将 (./add/libadd.a)add.o 添加进来,更新符号:

E U D
main sub main
add myfun add

在 U 中删除了 add,又引入了 myfun,此时 U 还不是空的,也没有遍历完 libadd.a 的内容(准确说没有达到不动点),将 (./add/libadd.a)myfun.o 添加进来,更新符号:

E U D
main sub main
add add
myfun myfun

接下来读取 ./sub/libsub.a,由于 (./sub/libsub.a)sub.o 提供了未定义的 sub 符号,将这个文件加进来,更新符号:

E U D
main main
add add
myfun myfun
sub sub

因为可以在 D 中找到 myfun,所以我们不会将 sub.o 带来的 myfun 再添加到 U 中。
此时解决符号的过程结束。

我们可以看到,整个符号查找过程中,根本不会打开 (./sub/libsub.a)myfun.o 这个文件,也不会知道这里还有个 myfun 的符号呢!

未解决符号集为空,解决符号的过程就结束了,链接器不一定会读取所有的文件,也无法知道这个冲突的存在。

思考

那么现实中这种情况是否会发生呢?什么场景下会发生?
很遗憾,这个情况可能已经埋很多的项目中。
一个常见的场景:我需要用到某个公开的三方库,同时进行一些修改。当其他人也遇到这个场景时,做了不一样的修改过程,最终生成可执行文件后,抛弃一个三方库的实现,最终一定导致某一个组件使用了对方的实现。(Bug 就来了)

还好,我们可以有多种解决的手段应对该问题,一个是从静态链接的角度考虑,我不能相信所有的静态库都是安全无冲突的,我需要把所有的 static library 都看一看,另一个是从 SDK 开发角度考虑,我使用了一个第三方的开源库,我应当将它以某种方式隐藏起来,避免和其他人的冲突。

uFzK3VbZ8aAVmt2h
2022-04-18

LZ 的实验设计得很好 :+1:

书中强调 the ordering of libraries and object files on the command line is significant, 然而没说链接算法是不是有问题,所以我们也许不得不注意库和目标文件的排列顺序,细想似乎也没有其他更好的方法

试了试其他 linker 结果也一样

-fuse-ld=bfd
Use the bfd linker instead of the default linker.
-fuse-ld=gold
Use the gold linker instead of the default linker.

C++ 可以通过 namespace 来解决这个问题,库的作者应该把代码写到库的命名空间里面。C 也可以实现命名空间,可以通过命名来实现,比如

void libsub_myfun();

参考 Why doesn't ANSI C have namespaces? - Stack Overflow

也可以用 struct 实现:

// sub/myfun.h
#ifndef myfun_h
#define myfun_h
struct somelib {
    void (*myfun) (void);
};

extern const struct somelib somelib;
#endif

// sub/myfun.c
#include "myfun.h"
#include <stdio.h>

void hello(void) { printf("myfun in libsub.a\n"); }

const struct somelib somelib = {.myfun = hello};

// sub.c
#include "sub.h"
#include "myfun.h"
int sub(int a, int b)
{
    somelib.myfun();
    return a - b;
}

结果:

$ ./a.out 
myfun in libadd.a
myfun in libsub.a

参考:For completeness there are several ways to achieve the “benefits” you might get from namespaces, in C.

所以库的作者应该避免这种情况。对用户而言,如果不小心用了没有命名空间保护的库,应该可以快速写一个适配器解决。

dianqk
2022-04-18

传给链接器的文件顺序对产物会有很大的影响,这一点可能是我们容易忽略的。
而上面这个例子就更严重了,不论什么样的顺序都不能得到正确结果了。主要还是链接算法的细节:当未解决符号为空,不会再读新的文件加载符号了。

另外除了手动修改这些符号,可能还有一些其他的方式可能可以解决这个问题,比如:

  • 配置 -fvisibility 调整符号的可见性
  • 使用 -frewrite-map-file 批量重写符号名

检测的方法我在 Linux 上还没看到有什么参数可以用来发现这个问题。
在 macOS 上,ld64 提供了 -all_load 参数可以加载所有 object file 来发现这个问题。