guest@blog.cmj.tw: ~/posts $

Tiny CC


Let be a pro

Fabrice Bellard 是目前當代我最推崇的程式設計師。他是一個很天才也很多產的工程師, 他著名的作品包含了: FFmpeg 、[QEMU][2] 以及 JsLinux 。其中 JsLinux 是他開始學習 JavaScript 之後覺得很有趣,開發出來在 Browser 可以運行的虛擬機。

TinyCC 是他在 1996 年寫出來的一個 C Compiler,是目前號稱編譯速度最快的 C Compiler,也是 GNU/Linux 下最小的 ANSI C Compiler。目前可以從 Github 下載到目前最新版本的 source code。

Layout

TinyCC 主要是由 tcc.c 開始,連接 libtcc.a 這個函式庫所組成的。libtcc.a 是由 libtcc.c、tccpp.c、tccgen.c、tccelf.c、tccasm.c、tccrun.c、X86_64.c 與 i368-asm.c 所構成的。從 tcc.c 中的 main 開始看起,可以了解開頭的流程用在處理 compile 階段送進來的參數,直到 tcc_add_file 開始才進入到 compile 的階段。

compile 流程包含了:

  1. 讀取檔案,並且記錄目前的讀取狀態。
  2. 將檔案內容切成若干的 Token。
  3. 根據每個 Token 來產生不同的 binary。

在第一個步驟,tcc 會利用 tcc_open 將檔案開啟並且將 fd 存在 tcc 內部的狀態機 (TCCState) 當中。在這個步驟,會一起設定狀態機的初始狀態。

ST_FUNC int tcc_open(TCCState *s1, const char *filename)
{
    int fd;
    if (strcmp(filename, "-") == 0)
        fd = 0, filename = "stdin";
    else
        fd = open(filename, O_RDONLY | O_BINARY);
    if ((s1->verbose == 2 && fd >= 0) || s1->verbose == 3)
        printf("%s %*s%s\n", fd < 0 ? "nf":"->",
               (int)(s1->include_stack_ptr - s1->include_stack), "", filename);
    if (fd < 0)
        return -1;

    tcc_open_bf(s1, filename, 0);
    file->fd = fd;
    return fd;
}

進入第二個步驟前,會根據是否需要處理 Preprocess 而判斷是否要進入 tcc_preprocess。正常來說會進入到 tcc_compile,並且送入狀態 s1 當作參數。 在進入 tokenize 階段前,會先塞入所需要的 elf symbols (put_elf_sym): 這裡塞入為所編譯檔案的檔案名稱。接著使用 setjmp 來標記一個 Label ,用來當 compile 失敗的時候,利用 tcc_error 中的 longjmp 來達到 try-catch 的功能。最後呼叫 next 來拿到下一個 Token。

if (setjmp(s1->error_jmp_buf) == 0) {
    s1->nb_errors = 0;
    s1->error_set_jmp_enabled = 1;

    ch = file->buf_ptr[0];
    tok_flags = TOK_FLAG_BOL | TOK_FLAG_BOF;
    parse_flags = PARSE_FLAG_PREPROCESS | PARSE_FLAG_TOK_NUM;
    pvtop = vtop;
    next();
    decl(VT_CONST);
    if (tok != TOK_EOF)
        expect("declaration");
    if (pvtop != vtop)
        tcc_warning("internal compiler error: vstack leak? (%d)", vtop - pvtop);

    /* end of translation unit info */
    if (s1->do_debug) {
        put_stabs_r(NULL, N_SO, 0, 0,
                    text_section->data_offset, text_section, section_sym);
    }
}

在 next 的開頭會跟去 flag 是否有設定 PARSE_FLAG_SPACES,來決定是呼叫 next_nomacro_spc 或者 next_nomacro,後者是前者的包裝: 持續呼叫 next_nomacro_spc 直到讀到空白字元為止。在裡面的實作使用 next_nomacro1 來真正的得到下一個 Token 並且分類這個 token 屬於哪一種型態。

/* return next token with macro substitution */
ST_FUNC void next(void)
{
    Sym *nested_list, *s;
    TokenString str;
    struct macro_level *ml;

 redo:
    if (parse_flags & PARSE_FLAG_SPACES)
        next_nomacro_spc();
    else
        next_nomacro();
    if (!macro_ptr) {
        /* ... ignore ... */
    } else {
        /* ... ignore ... */
    }

    /* convert preprocessor tokens into C tokens */
    if (tok == TOK_PPNUM &&
        (parse_flags & PARSE_FLAG_TOK_NUM)) {
        parse_number((char *)tokc.cstr->data);
    }
}

接著第三個步驟,呼叫 decl / decl0 來產生接下來的 binary code,藉由 type_decl 來判斷進來的 token 是哪一種資料型態,接著根據型態會有處理 function、 inline asm 、scope 或者一般資料四種方式。在一般資料狀況中,會使用 sym_push 的方式將資料存到 symbol stack 當中。 在 function 的情況中,則會呼叫 func_decl_list 來處裡 function 裡面的內容: 一開始先處理所有送進來的變數,包含將送進來的參數作 轉換 :將 array 轉換成 pointer 以及將 function 轉換成 function pointer。接著呼叫 gen_function 在 symbol table 定義一個 symbol 並且產生 binary code。

gfunc_prolog(&sym->type);
rsym = 0;
block(NULL, NULL, NULL, NULL, 0, 0);
gsym(rsym);
gfunc_epilog();

gen_function 的會執行以下幾個流程: 處理 symbol table 會使用到的 symbol,這時候填入 dummy 值。 呼叫 gfunc_prolog。這個函數會根據不同平台有不同的實作,已 x86_64 ELF 為例, 首先會判斷是否函數帶有 … 的 可變參數函數 ,接著再判斷函數是否回傳 struct, 最後將送進來的參數用 ASM / binary 的方式定義。 接著呼叫 block 來處理 scope 內的程式碼 (舉下面三個為例):

  1. 如果遇到 if 邏輯判斷式:使用 gexpr 處理 () 內的條件,然後呼叫 block 處理 scope 內的邏輯。
  2. 如果遇到 while 一樣使用 gexpr 處理 () 內的條件、呼叫 block 處理 scope 的邏輯,並用 gjmp_addr 來做到重複的邏輯。
  3. 遇到回傳 return 就使用 gexpr 來拿到表達式,呼叫 gv 來處理 vtop 內的值。最後使用 gjmp 回到 caller。

其中 gexpr 會一再的處理目前的 token,值到一個表達式結束為止。

ST_FUNC void gexpr(void)
{
    while (1) {
        expr_eq();
        if (tok != ',')
            break;
        vpop();
        next();
    }
}

然後呼叫 gfunc_epilog 來處理 function 最後的部分:填入 0xC9 確定會離開函數。 最後重新填入第一步驟中 symbol table 得值。

Conclusion

Fabrice Bellard 的程式碼真的是很難 trace 啊~ 例如在產生 jump 的 ASM code 時, 他使用 gjmp_addr 來產生,其中又會呼叫到 g 跟 oad 兩個內部使用的函數, 其中又會呼叫到一個叫作 o 的函數。光看函數名稱或者註解完全無法裡解他想要做什麼事情。

/* generate a jump to a fixed address */
void gjmp_addr(int a)
{
    int r;
    r = a - ind - 2;
    if (r == (char)r) {
        g(0xeb);
        g(r);
    } else {
        oad(0xe9, a - ind - 5);
    }
}

/* XXX: make it faster ? */
void g(int c)
{
    int ind1;
    ind1 = ind + 1;
    if (ind1 > cur_text_section->data_allocated)
        section_realloc(cur_text_section, ind1);
    cur_text_section->data[ind] = c;
    ind = ind1;
}

/* instruction + 4 bytes data. Return the address of the data */
ST_FUNC int oad(int c, int s)
{
    int ind1;

    o(c);
    ind1 = ind + 4;
    if (ind1 > cur_text_section->data_allocated)
        section_realloc(cur_text_section, ind1);
    *(int *)(cur_text_section->data + ind) = s;
    s = ind;
    ind = ind1;
    return s;
}

void o(unsigned int c)
{
    while (c) {
        g(c);
        c = c >> 8;
    }
}