C++中的Name Mangling(名称修饰):编译器如何编码函数签名以支持重载

好的,下面是一篇关于C++名称修饰(Name Mangling)的讲座式技术文章:

C++ 名称修饰:编译器如何编码函数签名以支持重载

大家好,今天我们来深入探讨 C++ 中一个重要的概念,即名称修饰 (Name Mangling)。名称修饰是编译器用来编码函数和变量名称的一种机制,目的是为了支持函数重载、命名空间、类等 C++ 特性。理解名称修饰对于理解 C++ 的编译、链接过程,以及与其他语言(如 C)进行交互至关重要。

为什么需要名称修饰?

在 C 语言中,每个函数都必须有唯一的名称。这意味着你不能有两个名为 add 的函数,即使它们的参数类型不同。然而,C++ 允许函数重载,即在同一个作用域内可以有多个同名函数,只要它们的参数列表不同即可。

例如:

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

double add(double a, double b) {
  return a + b;
}

如果没有某种机制来区分这两个 add 函数,编译器和链接器将无法确定应该调用哪个函数。这就是名称修饰发挥作用的地方。

名称修饰本质上是一种编码方案,它将函数名、参数类型、返回类型、命名空间、类名等信息编码成一个唯一的字符串。这个字符串就是函数在编译后的目标文件中的实际名称。

名称修饰的原理

不同的编译器厂商使用不同的名称修饰方案。因此,由一个编译器编译的代码可能无法直接链接到由另一个编译器编译的代码。然而,大多数编译器都遵循一些通用的原则。

以下是一些常见的名称修饰元素:

  1. 原始函数名: 函数的基本名称,例如 addprint 等。
  2. 参数类型: 函数参数的类型,例如 intdoublechar* 等。
  3. 返回类型: 函数的返回类型。
  4. 命名空间: 如果函数在命名空间中定义,则命名空间的名称也会被编码。
  5. 类名: 如果函数是类的成员函数,则类名也会被编码。
  6. 调用约定: 指示函数如何传递参数和清理堆栈(例如 __cdecl__stdcall__fastcall)。
  7. 其他修饰符: 例如 constvolatile 等。

一个简单的例子:

假设我们有以下 C++ 代码:

namespace MyNamespace {
  class MyClass {
  public:
    int add(int a, int b);
  };
}

MyNamespace::MyClass::add 函数的名称可能会被修饰成类似 _ZN11MyNamespace7MyClass3addEii 的字符串。这个字符串的含义如下(这只是一个假设,实际的修饰方案可能有所不同):

  • _Z:这是一个常见的名称修饰前缀,表示这是一个 C++ 函数。
  • N:表示这是一个嵌套名称。
  • 11MyNamespace:表示命名空间 MyNamespace,长度为 11。
  • 7MyClass:表示类 MyClass,长度为 7。
  • 3add:表示函数名 add,长度为 3。
  • E:表示嵌套名称的结束。
  • ii:表示两个 int 类型的参数。

不同编译器的名称修饰方案

正如前面提到的,不同的编译器使用不同的名称修饰方案。以下是一些常见编译器的名称修饰方案的简要说明:

  • Microsoft Visual C++ (MSVC): MSVC 使用一种相对复杂的名称修饰方案,它依赖于调用约定、类层次结构、模板等。MSVC 的名称修饰方案通常以 ? 开头。

    例如,int MyClass::add(int a, int b) 可能会被修饰成 ?add@MyClass@@QAEHHH@Z

  • GNU Compiler Collection (GCC) 和 Clang: GCC 和 Clang 使用 Itanium C++ ABI (Application Binary Interface) 定义的名称修饰方案。这种方案被广泛使用,特别是在 Unix-like 系统上。Itanium ABI 的名称修饰方案通常以 _Z 开头。

    例如,int MyClass::add(int a, int b) 可能会被修饰成 _ZN7MyClass3addEii

下表总结了不同编译器的一些特点:

编译器 名称修饰方案 前缀
Microsoft Visual C++ 复杂,依赖于调用约定等 ?
GCC / Clang Itanium C++ ABI _Z

名称修饰的实际例子

让我们看一些实际的 C++ 代码示例,以及它们可能被修饰后的名称(注意:这些只是示例,实际的修饰名称可能因编译器和编译选项而异)。

示例 1:简单函数

int add(int a, int b) {
  return a + b;
}
  • MSVC:?add@@YAHHH@Z
  • GCC/Clang:_Z3addii

示例 2:带有命名空间的函数

namespace MyNamespace {
  int subtract(int a, int b) {
    return a - b;
  }
}
  • MSVC:?subtract@MyNamespace@@YAHHH@Z
  • GCC/Clang:_ZN11MyNamespace8subtractEii

示例 3:类的成员函数

class MyClass {
public:
  int multiply(int a, int b) {
    return a * b;
  }
};
  • MSVC:?multiply@MyClass@@QAEHHH@Z
  • GCC/Clang:_ZN7MyClass8multiplyEii

示例 4:函数重载

int process(int a) {
  return a * 2;
}

double process(double a) {
  return a * 3.0;
}
  • MSVC:
    • int process(int a): ?process@@YAHH@Z
    • double process(double a): ?process@@YANH@Z (注意:这里的 ‘H’ 实际上代表 double, 只是展示用)
  • GCC/Clang:
    • int process(int a): _Z7processi
    • double process(double a): _Z7processd

如何查看修饰后的名称

有几种方法可以查看编译器生成的修饰后的名称:

  1. 使用 objdumpnm 命令: 这些命令可以从目标文件或可执行文件中提取符号表。符号表包含函数和变量的名称,包括修饰后的名称。

    例如,在 Linux 上,你可以使用以下命令:

    g++ -c my_code.cpp
    nm my_code.o

    输出将包含类似以下内容:

    0000000000000000 T _Z3addii
  2. 使用编译器选项: 一些编译器提供了选项来输出修饰后的名称。例如,在 GCC 中,你可以使用 -fdump-tree-types 选项。

  3. 使用反汇编器: 反汇编器可以将可执行文件或目标文件转换成汇编代码。汇编代码通常包含修饰后的函数名称。

  4. 使用 c++filt 命令: 专门用于将修饰后的 C++ 名称还原为可读形式的工具。它是 GNU binutils 工具集的一部分。

    c++filt _ZN7MyClass8multiplyEii

    输出将是: MyClass::multiply(int, int)

extern "C" 的作用

在 C++ 中,extern "C" 用于告诉编译器使用 C 链接约定。这意味着函数名不会被修饰。这对于与 C 代码或使用 C 链接约定的其他语言进行互操作非常有用。

例如:

extern "C" {
  int my_c_function(int a, int b);
}

在这种情况下,my_c_function 函数将不会被修饰,并且可以从 C 代码中直接调用。 如果没有extern "C", C 代码可能无法找到这个函数,因为C++编译器会对函数名进行修饰。

名称修饰与链接错误

理解名称修饰对于解决链接错误至关重要。当链接器找不到某个函数或变量时,通常是因为名称不匹配。这可能是由于以下原因:

  1. 不同的编译器: 使用不同的编译器编译的代码可能具有不同的名称修饰方案。
  2. 不同的编译选项: 不同的编译选项(例如调用约定)可能会影响名称修饰。
  3. extern "C" 的使用不一致: 如果在一个文件中使用了 extern "C",而在另一个文件中没有使用,则可能导致名称不匹配。
  4. 头文件不一致: 确保所有源文件都包含相同的头文件,以避免类型定义不一致。

例如,假设你有一个 C++ 库,其中包含以下函数:

// my_library.h
extern "C" int add(int a, int b);

// my_library.cpp
extern "C" int add(int a, int b) {
  return a + b;
}

如果你在 C 代码中调用这个函数:

// main.c
#include <stdio.h>

extern int add(int a, int b);

int main() {
  int result = add(1, 2);
  printf("Result: %dn", result);
  return 0;
}

如果你编译和链接这两个文件时没有使用 extern "C",则链接器可能会报错,因为它找不到名为 add 的函数。使用 extern "C" 可以确保 C++ 编译器不会修饰函数名,从而使 C 代码可以正确地调用它。

名称修饰和模板

模板也会影响名称修饰。由于模板可以根据不同的类型进行实例化,因此编译器需要为每个实例化生成不同的函数。这意味着模板函数的名称修饰将包含模板参数的信息。

例如:

template <typename T>
T max(T a, T b) {
  return (a > b) ? a : b;
}

max<int>max<double> 将被修饰成不同的名称。

  • GCC/Clang:
    • max<int>: _Z3maxIiET_RT0_S2_
    • max<double>: _Z3maxIdET_RT0_S2_

名称修饰工具

一些工具可以帮助你处理名称修饰:

  • c++filt (GNU binutils): 用于将修饰后的 C++ 名称还原为可读形式。
  • Visual Studio 的 undname.exe 用于将 MSVC 修饰后的名称还原为可读形式。
  • 在线名称修饰器/解修饰器: 许多在线工具可以帮助你修饰或解修饰 C++ 名称。

总结:名称修饰是C++的重要组成部分

我们了解了名称修饰在 C++ 中起到的关键作用,以及它如何使函数重载、命名空间等特性得以实现。理解不同编译器的名称修饰方案有助于解决链接错误和进行跨语言互操作。

实践:动手查看名称修饰结果

通过实际的编译和查看目标文件,可以更直观地了解名称修饰。使用 objdumpnm 命令,结合不同的编译器和编译选项,观察函数名称的变化。

深入:探索 Itanium ABI

Itanium C++ ABI 定义的名称修饰方案被广泛使用。深入研究 Itanium ABI 的文档,可以更全面地了解 C++ 名称修饰的细节。

更多IT精英技术系列讲座,到智猿学院

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注