編譯器的符號表管理

東小夫發表於2022-02-16

內容提要

在我們寫的程式碼中,有若干個變數,若干個函式;變數還會重名,還有值。編譯器卻總能找到我們指定的變數或函式,從不找錯人。在我看來,這是一個很神奇的功能。剖析一番,會發現”符號表“的身影。

符號表,儲存變數的值、函式。變數作用域依賴它,找到正確的變數也依賴它。

一起來看看符號表吧。

符號

老規矩,先從一段程式碼開始。

struct Data{
   int num1;
   int num2;
}dt;

Enum Color{RED, GREEN, BLUE};

int a,b,c,d;
typedef int INT32;

int main(int argc, char *argv[]){
   char *hi = "hello,world";
Begin:
   c = a + b;
   d = a + b;
   a = 3;
   dt.m2 = a + b;
  
   return 0;
}

從上面的程式碼中抽取下列元素:

  1. DataColor
  2. RED, GREEN, BLUE
  3. a,b,c,d
  4. INT32
  5. "hello,world"
  6. Begin

這些元素,每個都用一種資料結構儲存。這種資料結構,我們把它叫做“符號”,對應的英文術語是“Symbol”。

細心一點的讀者朋友會發現:並不是每行只有一個元素,有的行有多個元素。寫在同一行的元素屬於同一個類別。

在C語言中,有哪幾種類別的語言元素呢?先給出下面幾種類別。這些類別和上面的那幾行元素一一對應。請看。

  1. SK_Tag
  2. SK_EnumConstant
  3. SK_Variable
  4. SK_TypedefName
  5. SK_String
  6. SK_Lable

除了上述六種類別,還有這些類別。

  1. SK_Register。例如eax
  2. SK_IRegister。例如(eax)
  3. SK_Tmp。臨時變數。
  4. SK_Offset。後面再將這個類別。
  5. SK_Function。儲存函式。

符號的資料結構

Enum SymbolKind {SK_Tag, SK_EnumConstant, SK_Variable, SK_TypedefName, SK_String, SK_Lable, SK_Register, SK_IRegister, SK_Tmp, SK_Offset};

#define SYMBOL_COMMON  \
  // kind的取值是SymbolKind中的列舉值。

  int kind;      \
    // 變數的資料型別。
    Type ty;      \
    // 變數名,例如int a中的a。
  char *name;     \
    // 這個變數的別名,以後會用到。
  char *aname;    \
  // 當kind是SK_Offset時,這個值才有意義。
  int offset;     

typedef struct symbol{
  SYMBOL_COMMON
}*Symbol;

一般不直接使用Symbol儲存變數,而是使用它的“子結構”,VariableSymbol

typedef struct VariableSymbol{
  SYMBOL_COMMON
}*VariableSymbol;

馬上來用VariableSymbol儲存int a

成員 成員值
kind SK_Variable
ty int
name a
aname
offset 0

儲存dt

成員 成員值
kind SK_Tag
ty struct Dt。派生型別。
name dt
aname
offset 0

儲存dt的成員num2

成員 成員值
kind SK_Offset
ty int
name num
aname
offset 4。後面解釋。

現在可以說說什麼是符號了。符號,用來儲存變數或常量的資料結構。

仿照上面的例子,比較容易知道怎麼儲存ColorINT32等語言元素,我就不一一示範了。

公共表示式

什麼是公共表示式

在上面,我介紹了符號的資料結構,但那並不是符號的最終版本。

是的,我要補充新內容了,公共表示式。

還是先看程式碼吧。

// 從前面的例子中抽取出來的。
c = a + b;
d = a + b;
a = 3;
dt.m2 = a + b;

這段程式碼對應的中間程式碼如下。

t0: a+b
c: t0
d: t0
a: 3
t1: a+b
dt[4]: t1

cd的值都是a+b。生成一個臨時變數t0,儲存a+b的值。把t0賦值給cd。在這個過程中,只計算了一次a+ba+b就是“公共表示式”。

引入“公共表示式”,能減少最終生成的機器指令,提高程式的執行效率。

怎麼儲存公共表示式

我們設計的VariableSymbol能儲存公共表示式嗎?

我在想什麼?

在想,用什麼理由引出valueUse最合適。

想不到!

dt[4]的值為什麼不是t0而是t1?因為a被修改了,t0不能再作為公共表示式。

一旦公共表示式中的變數被修改,這個變數參與過的所有公共表示式都將失效,不再被當作公共表示式使用。我們設計的符號管理機制必須實現這個功能。

typedef struct valueDef{
   Symbol dst;
   int op;
   Symbol src1;
   Symbol src2;
   struct valueDef *link;
}ValueDef;
 
typedef struct valueUse{
   ValueDef def;
   struct valueUse *use;
}ValueUse;

#define SYMBOL_COMMON  \
  // kind的取值是SymbolKind中的列舉值。

  int kind;      \
    // 變數的資料型別。
    Type ty;      \
    // 變數名,例如int a中的a。
  char *name;     \
    // 這個變數的別名,以後會用到。
  char *aname;    \
  // 當kind是SK_Offset時,這個值才有意義。
  int offset;     
  // 
  ValueDef def;
  // 
  ValueUse uses;

我在SYMBOL_COMMON中新增了兩個成員defuses

每個變數參與了哪些表示式,用uses記錄。uses是一個ValueDef的單連結串列。

儲存公共表示式例項

完善一下儲存a的符號。如下。

成員 成員值
kind SK_Variable
ty int
name a
aname
offset 0
uses def0

t0:a+bValueDef記錄,記作def0

dst t0
op +
src1 儲存a的VariableSymbol
src2 儲存b的VariableSymbol
link

t1:a+bValueDef記錄,記作def1

dst T1
op +
src1 儲存a的VariableSymbol
src2 儲存b的VariableSymbol

怎麼記錄t0呢?用VariableSymbol。請看下面。

成員 成員值
kind SK_Tmp
ty int
name t0
aname
offset 0
def def0

比較一下at0的符號,至少有3個差異:

  1. t0kindSK_Tmp,因為t0是一個臨時變數。
  2. name不同。
  3. def不同。我認為,只有臨時變數的符號的def才有意義。

臨時變數的符號的uses有意義嗎?

換個問法:需要記錄臨時變數參與過的表示式嗎?我也不知道。

雜湊表

開放雜湊法

如果a+b已經出現過一次,再次遇到a+b時,將不再計算a+b。那麼,怎麼才能知道a+b有沒有出現過呢?方法是,把a+b儲存起來。

儲存一個表示式,用雜湊表很合適。我們使用的這個雜湊表使用“開放雜湊法”建立。具體方法如下:

  1. 雜湊key = (src1 + src2 + op) / 16;
  2. key相同、但實際上不同的表示式組成一個單連結串列。
    1. 這個單連結串列中的結點的資料型別是ValueDef
    2. ValueDef的成員link用來建立單連結串列。

這個雜湊表在哪裡?我把它放在函式的符號中。來看一看儲存函式的結構。

儲存函式的符號

typedef struct functionSymbol{
   SYMBOL_COMMON
    // 儲存函式的引數。
    Symbol params;
   // 儲存函式的區域性變數。
   Symbol locals;
   // 儲存公共表示式的雜湊表。
    ValueDef valNumTable[16];
}FunctionSymbol;

公共表示式經過雜湊函式處理之後,儲存到FunctionSymbol的成員valNumTable中。

這意味著,我們只處理函式中的公共表示式。對函式外面的公共表示式,不處理。

使用雜湊表的例項

一起看一看怎麼把t0:a+b儲存到雜湊表中。虛擬碼如下。

int key = ((int)src1 + (int)src2 + op) / 16;
ValueDef head = valNumTable[key];

ValueDef current = head;
ValueDef target = NULL;
while(current != NULL){
   if(current->src1 == src1 && current->src2 == src2 && current->op == op){
       target == current;
       break;
    }
   current = current->link;
}

if(target == NULL){
   ValueDef tmp;
   tmp->op = op;
   tmp->src1 = src1;
   tmp->src2 = src2;
   tmp->dst = t0;
  
   // 儲存到雜湊表中。
   tmp->link = valNumTable[key];
   valNumTable[key] = tmp;
}

上面的虛擬碼正確展示了用”開放雜湊法“建立雜湊表,忽略了很多關於”公共表示式“的邏輯。

在雜湊表中找到公共表示式後,如果這個表示式中的src1或src2已經被修改過,這個公共表示式就是無效的。

另一個雜湊表

用上一個雜湊表,我們解決了儲存a+b的問題。現在,我們面臨新的問題:

a儲存在Symbol,大量的Symbol儲存在哪裡?

依然儲存在雜湊表中。這些雜湊表還會構成一個單連結串列。

為什麼要建立雜湊錶連結串列

在C語言中,存在”作用域“這個概念,英文術語是scope

int a;
int test(){
  int a = 5;
  
  return 0;
}

上面的程式碼中有兩個a,但這兩個a的作用域不同。

全域性變數a儲存在雜湊表A中,test的區域性變數a儲存在雜湊表B中。

AB一起構成雜湊連結串列。

雜湊連結串列的資料結構

bucketLinker

bucketLinker的資料結構如下。

// 雜湊表的大小。
#define BUCKET_SIZE 128
// 計算雜湊key的掩碼。
#define BUCKET_HASH_MASK 127

typedef struct bucketLinker{
  Symbol sym;
   // 指向下一個bucketLinker。
   struct bucketLinker *link;
}*BucketLinker;

Symbol有兩個成員linknext,都能指向下一個Symbol。為什麼還新建一個BucketLinker用來建立連結串列呢?因為Symbol的兩個成員linknext都有其他用途,暫且不關注。

雜湊表

再看雜湊表的資料結構。

typedef struct table{
   // BucketLinker就儲存在table中,實質是變數Symbol或函式Symbol儲存在table中。
  Symbol buckets[BUCKET_SIZE];
   // 雜湊表的層次。
   int level;
   struct table *outer;
}*Table;

level儲存”作用域“。在前面的例子中,儲存全域性變數a的雜湊表的level的值是0,儲存test的區域性變數a的雜湊表的level的值是1。

AddSymbol

本節的小標題是一個函式名,這個函式的功能是:把一個儲存了變數或函式的Symbol儲存到雜湊表中。請看虛擬碼。

AddSymbol(Symbol sym, Symbol *table){
   int key = (int)sym / BUCKET_HASH_MASK;
   // 建立一個BucketLinker
   BucketLinker linker;
   linker->sym = sym;
   
   // 如果table中沒有儲存BucketLinker連結串列,把新linker作為連結串列的第一個結點
   // 儲存到table中。
   if(table[key] == NULL){
       table[key] = linker;
    }else{
       // 如果table中儲存了BucketLinker連結串列,把新linker新增到連結串列的頭結點,
       // 然後把新連結串列也就是連結串列的頭結點儲存到table中。
       // 把sym儲存到雜湊表中。
     linker->link = table[key];
     table[key] = linker;
    }
}

再看一小段程式碼。

linker->link = table[key];
table[key] = linker;

這不就是從AddSymbol中抽出來的嗎?有必要再看一次。

因為這兩行程式碼運用了”開放雜湊法“,而我很久以前覺得”開放雜湊法“是比較有技術含量東西。

在我的工作中,未曾需要自己動手實現”開放雜湊法“。用到雜湊時,呼叫一個雜湊函式而已。

總結

關於符號表,就講完了。本文囊括了主要知識點。

第一次看符號表,我覺得有點複雜。慚愧慚愧。

做個簡單的總結吧。

在程式語言例如C語言中,存在變數、函式。要為一個變數建模,就要設計出合適的結構儲存變數的資料型別和變數名、變數值。

用型別系統儲存資料型別。

用符號表儲存變數的變數名、變數值、變數的初始值等。

符號表需具備下列功能:

  1. 儲存變數的變數名、變數值、變數的初始值等;儲存函式的函式名、引數值、函式體等。
    1. 上面這句話不全對。對錯依賴編譯器設計者的具體設計。
    2. 讀者朋友理解為:儲存變數值或函式體等。
  2. 作用域。
  3. 查詢變數、函式等。

對了,想了解型別系統,請閱讀《C語言的型別系統》。

參考資料

《C 編譯器剖析》

相關文章