名字修飾

名字修飾(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). 

外部連結