跳到主要内容

5 篇博文 含有标签「链接」

查看所有标签

同时调用多个版本的"相同"函数

· 阅读需 2 分钟

在软件开发中,版本管理至关重要。但是,某些极端情况,需要同时运行一个接口的不同版本的函数,那么有没有可能实现呢。

比如下面有两个版本的 fun 函数,他们函数签名,都完全一样,能在一个程序中同时调用这两个函数吗?

// v1/fun.cpp
const char* fun() {
return "fun version 1";
}

// v2/fun.cpp
const char* fun() {
return "fun version 2";
}

在上述代码中,我们定义了两个版本的函数 fun(),分别位于目录 v1/v2/ 下。接下来,我们将这两个版本编译成共享库(.so 文件):

g++ -shared -fPIC -o v1/fun.so v1/fun.cpp
g++ -shared -fPIC -o v2/fun.so v2/fun.cpp

然后,我们在 main.cpp 中调用这两个共享库中的 fun 函数:

#include <dlfcn.h>
#include <iostream>

typedef const char* (*fun_ptr)();

int main() {
// 打开 v1 版本的共享库
void* handle_v1 = dlopen("v1/fun.so", RTLD_LAZY);
fun_ptr fun_v1 = reinterpret_cast<fun_ptr>(dlsym(handle_v1, "_Z3funv"));

// 打开 v2 版本的共享库
void* handle_v2 = dlopen("v2/fun.so", RTLD_LAZY);
fun_ptr fun_v2 = reinterpret_cast<fun_ptr>(dlsym(handle_v2, "_Z3funv"));

// 调用并输出两个版本的函数
std::cout << "Calling v1/fun(): " << fun_v1() << std::endl;
std::cout << "Calling v2/fun(): " << fun_v2() << std::endl;

// 关闭共享库
dlclose(handle_v1);
dlclose(handle_v2);

return 0;
}

在上述代码中,我们使用了 dlsym 函数来获取函数指针,注意参数中的符号是 _Z3funv,而不是函数名fun。在编译并运行程序之前,你可以通过 nm v1/fun.so 查看符号表以获取正确的符号名。有关符号修饰请查看 符号修饰 最后,编译并运行程序:

g++ main.cpp -ldl
./a.out

通过以上步骤,你将能够看到以下输出结果:

Calling v1/fun(): fun version 1
Calling v2/fun(): fun version 2

通过 dl 库,我们发现在一个程序中同时调用这一个函数的不同版本是可行的。(实际项目中一定要避免这种情况,做好版本管理)

上述示例代码可在 https://github.com/wenyg/dynamic_link_example 查看

程序员的自我修养: 延迟绑定

· 阅读需 2 分钟

调动动态库中的函数时候,涉及到地址重定位,函数地址在链接的时候才能确定下来。链接器会额外生成两张表,一个 PLT(Procedure Link Table) 程序链接表,一个是 GOT(Global Offset Table) 全局偏移表,两张表都在数据段中。

  • 全局偏移表,用来存放 "外部的函数地址"。
  • 程序链接表,用来存放 "获取外部函数地址的代码"。

比如简单的函数调用

printf("Hello world\n");

实际上会先调用 plt 表

call printf@plt

printf@plt 的调用如下

jmp *printf@got

printf@got 的地址就是真正的 printf 地址。

延迟绑定

要使得所有的函数能正常调用, GOT 表中就要填入正确的函数地址。如果一开始就对所有函数就进行重定位显然效率上会有问题。所以 linux 引入延迟绑定机制,即只有在首次调用函数的时候才进行重定位

假设 GOT[4]是 用来存放 printf 的函数的地址. 延迟绑定中 printf@plt 执行过程大致如下。

jmp *GOT[4]
push $0x1
jump PLT[0]

*GOT[4] 初始值是其PLT指定的第二条指令地址(push $0x1),即又跳了回来。 PLT[0] 是一个特殊条目,它跳到动态链接器中,解析函数地址,并且重写 GOT 表,然后再调用函数。

显然经过这次步骤之后,GOT[4] 已经写入了 printf 的地址,下次调用 printf@plt 的时候会直接跳转到 printf。

程序员的自我修养(7): 动态链接

· 阅读需 8 分钟

静态库暴露有以下缺点

  1. 内存和磁盘空间占用大。如果一个lib.o被多个程序使用,那么每个程序内都会有 lib.o的一个副本,如果多个程序同时运行,在内存中还会也会有多个副本。比如 C 语言静态库,一个普通程序使用到 C 语言静态库至少 1M 以上,如果有 100 个这样的程序,那么就至少要 100M 空间。很多 linux 机器上,/bin/下甚至有上千个程序,那么就要浪费几个 G 的空间。
  2. 程序更新与发布。每次更新一个静态库,整个程序都要重新编译更新。

动态链接

简单的说,动态链接就是链接过程推迟到运行时在进行,这就是动态链接的基本思想。

系统检查程序所需要的库及其依赖,并将其依次加载到内存中,当把所有库都加载完时,检查依赖关系是否满足,然后再进行链接工作,链接工作和静态链接非常相似,包括符号解析地址重定位等。

后续再加载其他程序时也一样,依次加载库和依赖,如果依赖的库在之前加载其他程序时已经被加载进去了,那么就不用再加载了,只需要链接即可。

很明显,动态链接解决了静态链接库浪费磁盘空间和内存的问题。除此之外它还减少了物理页面的换入换出,也可以增加 CPU 缓存命中率。因为不同进程间的数据和指令访问都集中在了一个共享模块上

动态链接的基本实现

动态链接的基本思想是将程序按照模块分割成各个相对独立的部分,在程序运行时才将它们链接成一个完整的程序。那么我们能不能直接使用目标文件进行动态链接呢。理论上是可行的,但实际上动态链接的实现方案和直接使用目标文件稍有差别。

动态链接涉及运行时的链接,以及多个文件的装载,必须有操作系统的支持。因为动态链接的情况下,进程的虚拟地址分布也会比静态链接更为复杂,还有一些存储管理,内存共享,进程线程也会有一些微妙的变化。

程序真正的链接工作是由动态链接器来完成的,动态链接是把链接的这个过程从程序装载前,推迟到了装载的时候。 程序每次装载都要进行重新链接,会有一定的性能损失。但是动态链接过程可以优化,比如延迟绑定。可以使得动态链接的性能损失降到最小。

据估算,动态链接相比于静态链接,性能损失约 5%。

简单动态链接例子

program1.c

#include "lib.h"
int main(){
foobar(1);
return 0;
}

lib.c

#include <stdio.h>
void foobar(int i){
printf("Print %d\n",i);
}

lib.h

#ifndef LIB_H_
#define LIB_H_

void foobar(int i);

#endif

当 program1.c 编编译成 program.o 时,编译器还不知道 foobar 的地址,当链接器将program.o链接成可执行文件时候,它必须知道 foobar 函数的性质

  • 如果 foobar 是一个定义在其它静态目标文件中的函数,那么链接器会按照静态链接规则,将 program1.o 中的 foobar 地址引用重定位
  • 如果 foobar 是一个定义在某个共享动态库中的函数,那么链接器会将这个符号的引用标记为一个动态链接的符号。不对它进行地址重定位,把这个过程留到装载时再进行。

那么链接器是如何知道 foobar 是一个静态符号还是一个动态符号呢。这就是我们链接是需要指定动态库的一个原因了。动态库中包含了完整的符号信息。链接器在解析符号时就知道它是一个动态符号还是一个静态符号。

共享对象的最终装载地址在编译时是不确定的

地址无关代码

共享对象在被装载时,如何确定它在虚拟地址空间中的位置? 为了实现动态链接,我们首先遇到的问题就会是共享对象地址的冲突问题。 程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在链接产生输出文件时,就要假设模块被装载的目标地址。很明显,如果不同的模块目标装载地址都是相同的显然是不行的。

我们能不能让共享对象在任意位置加载?换个说法就是: 共享对象在编译时能不能假设自己在进程虚拟地址空间的位置。 与此不同的是,可执行文件基本上知道自己在进程虚拟地址空间的其实位置,因为可执行文件往往是第一个被加载的文件。它可以选择一个固定的地址。

装载时重定位

一旦模块装载地址确定,即目标地址确定,那么系统对程序中所有的引用地址进行重定位。比如 foobar 相对于代码段的地址是 0x100,当模块被装载到 0x10000000时,那么就可以确定 foobar 的地址为 0x10000100, 这时候,系统遍历模块化中的重定位表,把所有对 foobar 的地址引用都重定位到 0x10000100

装载时重定位需要修改指令。那么多个进程就无法共享这些指令了。动态链接库中将指令中可修改的部分提取出来和数据部分放在一起,每个进程各自拥有一个副本。这样大部分指令就可以共享了。 这个方案就称为 地址无关代码(PIC, Position-independent Code 技术

地址引用方式

按照是否跨模块分为两类,模块内引用,模块外引用
按照不同的引用方式分为,指令引用,数据引用

static int a;
extern in b;
extern void ext();

void bar(){
a = 1; // 2.模块内部数据访问
b = 2; // 4.模块间数据访问
}

void foo(){
bar(); // 1.模块内部调用或跳转
ext(); // 3.模块外函数调用
}

编译器在编译上述内容时候,并不能确定 b 和函数 ext() 是模块外部还是模块内部的。 因为他们可能定义在同一个共享对象的其他目标文件中。由于没法确定,编译器只能把他们都当做模块外部的函数和变量来处理。

模块内部调用或跳转

这种是最简单的,模块内部的函数跳转都可以是相对地址调用,所以这种指令是不需要重定位的。只要 bar 和 foo 的相对位置不变,那么 bar() 的调用就是地址无关的,即无论模块被装载到什么位置,指令都是有效的。

模块内部数据访问

模块内各个任何一条指令和它要访问的内部数据之间的相对位置也是固定的。通过访问当前位置加上固定的偏移量就可以访问模块内部数据了。

模块间数据访问

模块间的数据访问目标地址要等到装载时才确定。要使得代码地址无关,基本思想是把跟地址相关的部分放到数据里面。动态库的做法是在数据段里建立一个指向这些变量的指针数组,也被称为 全局偏移表。当代码需要访问全局变量时,可以通过 GOT 中对应的项间接引用。GOT 本身是放在数据段的,它可以在模块装载时被修改。并且每个进程都可以有独立的副本,相互不受影响。

模块间调用跳转

和模块间数据访问类似,只不过 GOT 中保存的是目标函数地址。这种方法和简单,但是存在一些性能问题。实际上 ELF 采用了一种更加复杂和精巧的方法。见 延迟绑定

PyTorch 与 C++ Torch 混合编程问题排查

· 阅读需 2 分钟

问题

在公司的一个项目中,需要同时使用 C++ 和 Python 的一些接口。项目的主要语言是 Python,通过 pybind11 调用 C++ 的接口。然而,在实际运行过程中出现了 torch 符号未定义的问题,尽管 Python 和 C++ 使用的 torch 都是相同版本(PyTorch 调用的也是 C++ 的 so),理论上不应该出现这样的问题。

排查

尽管两个 torch 版本相同,但它们的安装方式不同。PyTorch 是通过 pip 安装的,而 C++ Libtorch 是通过源码编译的方式安装的。虽然两者版本相同,但通过使用 nm 查看符号表发现两个 torch.so 的符号表确实不一样。最终排查发现,由于 Dual ABI 的原因,libtorch 在编译时采用的是 cxx11 ABI,而 Python 中的 torch 使用的是 Pre-cxx11 ABI,两者版本的符号不兼容,导致冲突问题。

解决方案

  1. 重新编译 C++ 项目相关的代码和库,全部使用旧 ABI。然而,使用旧 ABI 编译的可行性尚待调研。考虑到目前几乎所有的代码都是以 cxx11 编译,包括很多系统库也选择了 cxx11 的 ABI,因此全部重新编译并不现实。该方案被否决。
  2. 重新编译 Python 中的 torch,使用新的 ABI。

C++ 符号修饰

· 阅读需 3 分钟

什么是符号修饰?

在 C++ 中,符号修饰是指编译器对函数和变量名进行的一种变换或修饰。这种修饰是为了解决 C++ 的函数重载、命名空间和类等特性引入的命名冲突问题。通过符号修饰,编译器可以在目标文件中唯一标识不同的实体,确保在链接阶段能够正确地找到并匹配相应的函数或变量。

符号修饰的原理

C++ 编译器通过一种名为“名字翻译”(Name Mangling)的技术来实现符号修饰。名字翻译将源代码中的函数和变量名转换成目标文件中的唯一标识符。这种转换过程包括以下几个方面:

1. 函数重载

C++ 允许函数重载,即在同一作用域内定义多个同名函数,但它们的参数类型或个数不同。为了在目标文件中区分这些重载函数,编译器会根据函数的参数类型和个数生成不同的符号。

2. 命名空间

命名空间是 C++ 中组织代码的一种方式,但它可能导致相同名字的函数或变量在目标文件中发生冲突。符号修饰通过在符号前添加命名空间信息,解决了这一问题。

3. 类成员函数

类的成员函数通常需要包含类的信息,以便正确访问对象的成员。符号修饰通过在符号中嵌入类的信息,确保了正确的成员函数匹配。

示例:符号修饰的实际应用

考虑以下 C++ 代码片段:

#include <iostream>

namespace Math {
class Calculator {
public:
int add(int a, int b);
};
}

int Math::Calculator::add(int a, int b) {
return a + b;
}

int main() {
Math::Calculator calc;
std::cout << calc.add(3, 4) << std::endl;
return 0;
}

通过编译并查看符号表,我们可以看到 Math::Calculator::add 函数的符号修饰:

$ nm a.out | grep add
000000000040157e T _ZN4Math9Calculator3addEii

在这个例子中,符号 _ZN4Math9Calculator3addEii 就是经过修饰的 Math::Calculator::add 函数名。

如何处理符号修饰

在正常情况下,大多数 C++ 程序员在日常编程中不需要过多关注符号修饰。编译器会自动处理符号修饰,而开发者通常只需使用函数和变量的原始名称即可。只有在涉及到库的开发、跨语言交互或者遇到符号冲突的特殊情况下,才需要更深入地了解符号修饰。