名字修饰

名字修饰(name decoration),也称为名字重整名字改编(name mangling),是现代计算机程序设计语言编译器用于解决由于程序实体的名字必须唯一而导致的问题的一种技术。

它提供了在函数结构体或其它的数据类型的名字中编码附加信息一种方法,用于从编译器中向链接器传递更多语义信息。

该需求产生于程序设计语言允许不同的条目使用相同的标识符,包括它们占据不同的命名空间(典型的命名空间是由一个模块、一个类或显式的namespace指示来定义的)或者有不同的签名(例如函数重载)。

任何由编译器产生的目标代码通常与另一部分的目标代码(产生于同一款或不同款的编译器)通过链接器把它们链接起来。链接器需要一大堆每个程序实体信息。例如正确链接一个函数需要它的名字、参数个数和它们的类型,等等。

C语言的名字修饰

虽然在不支持函数重载的程序设计语言(例如C语言和经典Pascal语言)中基本上不需要名字修饰,但是它们在一些情况下它们还是用了名字修饰来提供了函数的附加信息。 例如,目标于微软Windows平台的编译器支持许多调用约定。这用于决定哪个参数传入子程序的方式和结果返回的方式。因为不同的调用约定彼此不兼容,所以编译器根据的调用约定重整了链接符号。

名字修饰方案由微软公司首创,目前已经被许多其它的编译器非正式采用,例如Digital Mars公司、Borland公司以及Windows移植版的GNU GCC。该方案甚至被其它语言采用,例如PascalD语言DelphiFortranC#。这允许用这此语言写的子程序使用不同于自身默认的调用约定来调用或被调用于已存在的Windows库。

当编译下列C语言代码的的时候:

int _cdecl    f (int x) { return 0; }
int _stdcall  g (int y) { return 0; }
int _fastcall h (int z) { return 0; }

32位编译器对其分别进行名字修饰后的结果是:

_f
_g@4
@h@4

对于stdcallfastcall调用约定的名字修饰方案中,函数分别被编码为_name@X@name@X,其中X是形参列表的参数中的十进制的字节数,包括用fastcall传入寄存器的。而对于cdecl调用约定,简单地在函数名前加上一条下划线。

注意Windows的64位Microsoft C的调用约定中没有前导下划线。在一些很罕见的地方,这个差异可能导致代码移植到64位上的时候产生无法解析的外部符号。例如Fortran代码可以使用'alias'(别名)来链接到C方法,如下所示:

SUBROUTINE f()
!DEC$ ATTRIBUTES C, ALIAS:'_f' :: f
END SUBROUTINE

这在32位平台下编译链接得很好,但是在64位的平台将导致无法解析的外部符号'_f'。一个可行的办法是完全不使用'alias'(其中方法名典型的在C语言和Fortran语言中需要大写化),或使用BIND选项:

SUBROUTINE f() BIND(C,NAME="f")
END SUBROUTINE

Visual Basic 6这样的较老的语言,也需要在声明DLL的输出函数时使用Alias,例如:

Public Declare Function test2 Lib "PackingDLL.dll" Alias "_test2@4" (ByVal param As Integer) As Integer

在C语言中,多数编译器还改编在翻译单元中的静态函数和变量(和在C++中的声明为静态或放置在匿名命名空间中的函数和变量),使用与非静态版本相同的修改规则。如果有着相同的名字(和C++中的参数)的函数,也定义和使用在不同的翻译单元中,它也改编为相同的名字,这潜在的会导致冲撞。但是,如果它们分别在自己的翻译单元中被调用,则它们将不是等价的。编译器通常自由的对这些函数施加任意改编,因为直接从其他翻译单元访问这些函数是非法的,所以它们永远不需要在不同的目标代码之间链接。为了防止链接冲突,编译器将使用标准的改编,但使用所谓的'local'符号。在链接很多这种翻译单元的时候,可能出现多个有相同名字的的函数定义,但是结果代码依据调用来自何处而只链接它自己的那个函数。这通常使用重定位英语Relocation (computing)机制来完成。

C++语言的名字修饰

C++编译器是名字修饰使用得出名的编译器。第一个C++编译器的实现是翻译成C语言源代码,以便于让C编译器编译成目标代码。正因如此,符号名必须遵守C语言的标识符规则。直至后来,能直接产生机器语言或汇编语言的编译器出现了以后,系统的链接器也是基本上不支持C++的符号的,所以仍然需要名字修饰。

C++语言并没有规定一个标准的名字修饰方式,所以各款编译器都使用各自的名字修饰方式。C++还有一套复杂的语言特性,例如模板命名空间运算符重载。这改变了基于上下文或用法的特定符号的意义。关于这些特性的元数据能够用改编(修饰)调试符号的名字来消除二义性。正因为这些特性的名字修饰系统并没有跨编译器标准化,所以几乎没有链接器可以链接不同编译器产生的目标代码。

简单样例

考虑一个下面的C++程序中的两个f()的定义:

int  f (void) { return 1; }
int  f (int)  { return 0; }
void g (void) { int i = f(), j = f(0); }

这些是不同的函数,除了函数名相同以外没有任何关系。如果不做任何改变直接把它们当成C代码,结果将导致一个错误——C语言不允许两个函数同名。所以,C++编译器将会把它们的类型信息编码成符号名,结果类似下面的的代码:

int  __f_v (void) { return 1; }
int  __f_i (int)  { return 0; }
void __g_v (void) { int i = __f_v(), j = __f_i(0); }

注意g()也被名字修饰了,虽然没有任何名字冲突。名字修饰应用于C++的任何符号。

复杂样例

一个更复杂一点的样例,下面考虑一个现实生活中的例子,该例子被GNU GCC 3.x的名字修饰规则实现过。改编下列的示例类,改编过的符号在各自的标识符名字下面显示。

namespace wikipedia 
{
   class article 
   {
   public:
      std::string format (void); 
         /* = _ZN9wikipedia7article6formatEv */

      bool print_to (std::ostream&); 
         /* = _ZN9wikipedia7article8print_toERSo */

      class wikilink 
      {
      public:
         wikilink (std::string const& name);
            /* = _ZN9wikipedia7article8wikilinkC1ERKSs */
      };
   };
}

全部被改编过的符号由_Z开头(注意用下划线加大写英文字母是C语言的保留标识符),所以与用户标识符的冲突可以被避免)。嵌套的名字(包括命名空间和类),后面再接一个N,最后一个E。例如wikipedia::article::format将成为:

_ZN·9wikipedia·7article·6format·E  

函数后面接形参的类型信息,例如format()是一个形参为void的函数,于是就接一个v,结果是:

_ZN·9wikipedia·7article·6format·E·v

对于print_to,使用了一个标准类型std::ostream(或更准确地说是std::basic_ostream<char, char_traits<char> >),有着特殊的别名So,所以,这个类型的一个引用类型就是RSo,这个函数的完整名字是:

_ZN·9wikipedia·7article·8print_to·E·RSo

不同编译器如何名字修饰相同的函数

无论多么平凡的C++标识符,名字修饰规则都没有标准方式,所以不同的编译器产商(甚至相同编译器的不同版本,或相同编译器在不同平台上)的名字修饰规则都截然不同,也就意味着基本上都不兼容。看看C++编译器是怎么名字修饰相同的函数的:

编译器 void h(int) void h(int, char) void h(void)
GCC 3.x及更高 _Z1hi _Z1hic _Z1hv
Clang 1.x及更高[1]
Intel C++ 8.0 for Linux
HP aC++ A.05.55 IA-64
IAR EWARM C++ 5.4 ARM
IAR EWARM C++ 7.4 ARM _Z<number>hi _Z<number>hic _Z<number>hv
GCC 2.9.x h__Fi h__Fic h__Fv
HP aC++ A.03.45 PA-RISC
Microsoft Visual C++ v6-v10 (修饰详情) ?h@@YAXH@Z ?h@@YAXHD@Z ?h@@YAXXZ
Digital Mars C++
Borland C++ v3.1 @h$qi @h$qizc @h$qv
OpenVMS C++ v6.5 (ARM mode) H__XI H__XIC H__XV
OpenVMS C++ v6.5 (ANSI mode) CXX$__7H__FIC26CDH77 CXX$__7H__FV2CB06E8
OpenVMS C++ X7.1 IA-64 CXX$_Z1HI2DSQ26A CXX$_Z1HIC2NP3LI4 CXX$_Z1HV0BCA19V
SunPro CC __1cBh6Fi_v_ __1cBh6Fic_v_ __1cBh6F_v_
Tru64 C++ v6.5 (ARM mode) h__Xi h__Xic h__Xv
Tru64 C++ v6.5 (ANSI mode) __7h__Fi __7h__Fic __7h__Fv
Watcom C++ 10.6 W?h$n(i)v W?h$n(ia)v W?h$n()v

注:

  • 在OpenVMS VAX和Alpha(但不是IA-64)和Tru64上的Compaq C++编译器有两套不同的名字修饰方式。原始的,标准前的方式是ARM模式,基于描述于《C++ Annotated Reference Manual (ARM)》中的名字修饰规则。伴随着C++98标准的新特性的到来,尤其是模板,ARM方式变得越来越不合适——它不能编码确定的函数类型,或者对不同函数产生了相同的改编符号。所以它就被新的ANSI模型取代,该模型支持全部的ANSI模板,但是并不能向后兼容。
  • 在IA-64中,存在一种标准的ABI(见外部链接)。它规定了一种标准的名字修饰方式,并且被全部的IA-64编译器使用。此外,GNU GCC 3.x也在其它非Intel架构上采用了在这个标准中规定的名字修饰方式。
  • Visual Studio和Windows SDK包含了能给定一个已被名字修饰过的符号就能输出C风格函数声明的undname程序。
  • 在Microsoft Windows中,Intel编译器[2]Clang[3]为了兼容性使用了Visual C++的名字修饰规则。

从C++中链接时的C符号的处理

最常见的C++惯常的做法:

#ifdef __cplusplus 
extern "C" {
#endif
    /* ... */
#ifdef __cplusplus
}
#endif

这种写法用于确保下符号是未被C++编译器名字修饰过的——这种代码能使得C++编译器编译出的二进制目标代码中的链接符号是未经过C++名字修饰过的,就像C编译器一样。就像C语言定义是未名字修饰过的一样,C++编译器需要防止名字修饰这些标识符。

例如,C标准字符串库<string.h>通常包含了类似这样子的

#ifdef __cplusplus
extern "C" {
#endif

void *memset (void *, int, size_t);
char *strcat (char *, const char *);
int   strcmp (const char *, const char *);
char *strcpy (char *, const char *);

#ifdef __cplusplus
}
#endif

于是,例如这样的代码

if (strcmp(argv[1], "-x") == 0) 
    strcpy(a, argv[2]);
else 
    memset (a, 0, sizeof(a));

就能使用正确的、未经名字修饰过的strcmpmemset。如果没有使用extern "C",那么SunPro C++编译器会产生等价于下面的C代码:

if (__1cGstrcmp6Fpkc1_i_(argv[1], "-x") == 0) 
    __1cGstrcpy6Fpcpkc_0_(a, argv[2]);
else 
    __1cGmemset6FpviI_0_ (a, 0, sizeof(a));

而这些链接符号并不存在于C运行库中(例如 libc)。因此将导致链接错误。

C++标准化的名字修饰

标准化的C++名字修饰规则似乎能够在编译器实现之间带来更大的互操作性,但是事实上,这样的标准化自身并不能保证C++编译器的互操作性,并且它甚至能制造互操作性是可能的并且是安全的一种错觉。名字修饰仅仅是需要C++实现决定的许多ABI细节之一。其它ABI方面例如异常处理虚表的设计、结构体和栈帧填充等等,也导致了不同的互不兼容的C++实现。再者,规定一个特定的名字修饰规则会导致在实现限制(例如:符号长度限制)指挥的名字修饰方式的系统上的一些问题。名字修饰的一个标准化的需求,也会阻碍不完全不需要名字修饰的实现——例如明白C++语言的链接器。

所以,C++标准并没有尝去标准化名字修饰。相反地,《Annotated C++ Reference Manual》(又叫做ARM, ISBN 0-201-51459-1, 第7.2.1c节)主动提倡使用截然不同的名字修饰方式来防止ABI层面不兼容的链接,例如异常处理虚表设计。

虽然如此,在一些平台上[4],全部C++ ABI都被标准化了,包括名字修饰。

C++名字修饰的现实影响

当C++符号从动态链接库共享对象文件中导出时,名字修饰方式就不再是一个编译器内部的事情的。不同的编译器(或者同一款编译器的不同版本)将产生不同的名字修饰方式的二进制文件。这意味着如果编译器使用了不同方式创建了库和程序经常将导致无法解决的符号。例如,如果一个系统中有多个C++编译器(例如GNU GCC编译器和操作系统供应商的编译器)并且想安装Boost C++ Libraries,那么它需要编译两次——为操作系统供应商的编译器编译一次,为GCC再编译一次。

为了安全目的,产生不兼容的目标代码(基于不同的ABI,例如类和异常)的编译器最好使用不同的名字修饰方式。这保证了这些不兼容性能够在链接的时候被检测出来,而不是一运行软件的时候被发现(这会导致隐藏的bug和严重的稳定性问题)。

正因如此,名字修饰是对于任何C++相关的ABI都是一个要点。

通过c++filt去修饰

$ c++filt -n _ZNK3MapI10StringName3RefI8GDScriptE10ComparatorIS0_E16DefaultAllocatorE3hasERKS0_
Map<StringName, Ref<GDScript>, Comparator<StringName>, DefaultAllocator>::has(StringName const&) const

通过内置GCC ABI去修饰

#include <stdio.h>
#include <stdlib.h>
#include <cxxabi.h>

int main() {
	const char *mangled_name = "_ZNK3MapI10StringName3RefI8GDScriptE10ComparatorIS0_E16DefaultAllocatorE3hasERKS0_";
	int status = -1;
	char *demangled_name = abi::__cxa_demangle(mangled_name, NULL, NULL, &status);
	printf("Demangled: %s\n", demangled_name);
	free(demangled_name);
	return 0;
}

输出:

Demangled: Map<StringName, Ref<GDScript>, Comparator<StringName>, DefaultAllocator>::has(StringName const&) const

Java的名字修饰

在Java语言中,方法或类的签名包含了它的名字以及它的参数和可适用的返回值类型。签名的格式是有文档说明的,因为Java语言、编译器和.class文件的格式都是全部一起设计的(并且一开始就是面向对象和互操作性的)。

为内部和匿名类创建唯一的名字

匿名类的作用域局限于它们的父类,所以编译器必须为内部类产生一个“合格”的公开名字,来避免与其它相同命名空间的同名类类冲突。类似的,匿名类必须有“假的”公开名字(因为匿名类的概念仅存在于编译器内,而不存在于运行时)。所以,编译下列的Java程序:

public class foo {
    class bar {
        public int x;
    }

    public void zark () {
        Object f = new Object () {
            public String toString() {
                return "hello";
            }
        };
    }
}

将产生如下三个.class文件:

  • foo.class,包含了主类(外面的类)foo
  • foo$bar.class,包含了命名的内部类foo.bar
  • foo$1.class,包含了内部的匿名类(局部于foo.zark方法)

这些类名全是合法的(因为$符号允许用于JVM规范)并且这些名字对编译器的产生来说是“安全”的,因为Java语言的定义禁止$符号出现在常规的Java类定义中。

Java的名字解析在运行时更为复杂,因为完全合格的类名在特定的Java类加载器的实例中是唯一的。类加载器是分级次序的,并且JVM的每个编程都有一个所谓的上下文类加载器,用来预防两个不同的类加载器实例包含同名的类。系统首先尝试使用根加载器(或系统加载器)来加载类,然后往下针对上下文类加载器分级加载。

Java本地接口(JNI)

Java本地接口(JNI)允许Java语言的程序调用其它语言写的程序(通常是C或C++)。有两个名称解析与此有关,这两种都没有标准化的实现方式:

  • 从Java到本地名字的翻译[5]
  • 常规的C++名字修饰

Python的名字修饰

Python语言的名字修饰用于类的“私有”(private)成员。这种类成员的名字由前导双下划线开头,并且后缀下划线不能多于一个。例如__thing将被名字修饰,___thing__thing_同样也会被名字修饰,但是__thing____thing___就不会被名字修饰。Python运行时库不限制访问这些成员,名字修饰只是用来避免拥有同名成员的派生类发生名字冲突。

遇到需要名字修饰的时候,Python把这些名字改成单下划线加上封闭类的名字,例如:

>>> class Test(object):
...     def __mangled_name(self):
...         pass
...     def normal_name(self):
...         pass
... 
>>> [*Test.__dict__]
['__module__', '_Test__mangled_name', 'normal_name', '__dict__', '__weakref__', '__doc__']

Objective-C的名字修饰

本质上,Objective-C存在两种形式的方法,类方法(静态方法)和实例化方法。Objective-C的方法声明如下:

+ method name: argument name1:parameter1 ...
– method name: argument name1:parameter1 ...

类方法用+表示,实例化方法用-表示。一个典型的类方法声明是这样子的:

 + (id) initWithX: (int) number andY: (int) number;
 + (id) new;

实例化方法是这样子的:

  (id) value;
  (id) setValue: (id) new_value;

这样方法声明都有一个特定的内部表示法。当编译的时候,任何一个方法都会按照下列类方法的方式来命名:

_c_Class_methodname_name1_name2_ ...

这是实例化方法:

_i_Class_methodname_name1_name2_ ...

Objective-C语法中的冒号被翻译成下划线。所以Objective-C的属于Point类的类方法 + (id) initWithX: (int) number andY: (int) number;将会被翻译成_c_Point_initWithX_andY_,并且实例化方法(属于同一个类) - (id) value;将会被翻译成_i_Point_value

类的每一种方法都用这种方式标出。但是,为了在全部方法都用这种方式来表示的时候,能够查找到一个类能够回应的方法是很繁琐的。每个方法都赋予了唯一的符号(例如整型)。这样的符号一般叫做选择器。在Objective-C中,选择器可以被直接管理——它们在Objective-C中有特定类型——SEL

在编译期间,建立了一个把文字表述(例如_i_Point_value)映射到选择器(类型为SEL)的表。管理选择器比操作方法的文字表述更有效。注意一个选择器只能匹配一个方法名,而不是它属于的类——不同的类对同名方法可以有不同的实现。因此,方法的实现也给定了一个特定的标识符——这就叫实现指针,当然也给定了类型IMP

信息发送由编译器编码,调用id objc_msgSend (id receiver, SEL selector, ...)函数,或者它的表亲,其中receiver是信息的接收者,并且SEL决定需要调用的方法。每个类都有各自的从选择器映射到它们实现的表——实现指针指定实际方法实现的内存地址。类和实现的表是分开的。除了储存在SEL中来用IMP查找表,函数是本质上是匿名的。

选择器的SEL值在类间没有变化。这使得多态成为可能。

Objective-C运行时库负责维护方法的参数和返回值的信息。但是,这信息不是方法名的一部分,不同的类可能有很大的不同。

因为Objective-C不支持命名空间,所以没有必要对类名进行名字修饰(这个在产生的二进制文件中确实发生过)。

Fortran的名字修饰

名字修饰对于Fortran编译器也是必要的,因为原先这个语言是大小写不敏感的。随着语言的发展,产生了更多的名字修饰需求,这是因为Fortran 90标准附加的模块和其它特性。名字修饰就成为了需要解决的一个特别常见的问题,因为需要调用来自其它语言(例如C语言)的Fortran库(例如LAPACK)。

由于Fortran编译器大小写不敏感,子程序或函数的名字"FOO"必须被转换成规范的大小写方式,而且要由Fortran编译器来格式化,这样它才能无视大小写地用相同方式被链接。不同的编译器用不同的方式来实现了,没有发生过标准化。AIXHP-UX的Fortran编译器把标识符全转成小写("foo"),而克雷Unicos英语Unicos的Fortran编译器把标识符全转成大写("FOO")。GNUg77编译器把标识符转成小写后接一个下划线("foo_"),例外情况是:原先已经有下划线的标识符("FOO_BAR")转成后接两个下划线("foo_bar__"),这是f2c英语f2c设的约定。许多其它的编译器,包括SGIIRIX编译器、gfortranIntel的Fortran编译器(不包括在Microsoft Windows上),都把标识符全部转成小写后接一个下划线("foo_"和"foo_bar_")。在Microsoft Windows上,Intel Fortran编译器缺省为大写不带下划线[6]

Fortran 90模块中的标识符必须被进一步名字修饰,因为相同的子程序同可能在不同的模块提供给不同的例程。

Pascal的名字修饰

Borland的Turbo Pascal/Delphi系列

为了避免Pascal的名字修饰,可以使用:

exports
  myFunc name 'myFunc',
  myProc name 'myProc';

Free Pascal

Free Pascal支持函数重载运算符重载,所以它也使用名字修饰来支持这些特性。另外,Free Pascal能够调用由其它语言写的的外部模块定义的符号,也能导出自己的符号供其它语言调用。更多信息,详见Free Pascal Programmer's Guide页面存档备份,存于互联网档案馆)的子页面Chapter 6.2页面存档备份,存于互联网档案馆)和Chapter 7.1页面存档备份,存于互联网档案馆)。

参见

参考资料

  1. ^ Clang - Features and Goals: GCC Compatibility, 15 April 2013 [2020-09-22], (原始内容存档于2011-10-02) 
  2. ^ JBIntel_deleted_06032015. OBJ differences between Intel Compiler and VC Compiler. software.intel.com. [2020-09-22]. (原始内容存档于2019-03-29). 
  3. ^ MSVC compatibility. [13 May 2016]. (原始内容存档于2020-11-06). 
  4. ^ Itanium C++ ABI, Section 5.1 External Names (a.k.a. Mangling). [16 May 2016]. (原始内容存档于2021-01-01). 
  5. ^ Design Overview. docs.oracle.com. [2020-09-22]. (原始内容存档于2020-08-04). 
  6. ^ Summary of Mixed-Language Issues. User and Reference Guide for the Intel Fortran Compiler 15.0. Intel Corporation. [17 November 2014]. (原始内容存档于2016-08-17). 

外部链接