# 2020q3 專題: 可自我編譯的 C 編譯器 > In computer programming, self-hosting is the use of a program as part of the toolchain or operating system that produces new versions of that same program—for example, a compiler that can compile its own source code. 引述自 Wikipedia 對 self-hosting 的定義。沒錯,`shecc` 就是一個 self-hosting compiler,可先透過現有的編譯器(e.g. gcc)產生執行檔,再透過該執行檔編譯下一個版本,達到 self-host 的效果。 ## 運作原理 ### How to bootstrap? #### Stage 0 ```shell $(OUT)/$(STAGE0): $(OUT)/libc.inc $(OBJS) $(VECHO) " LD\t$@\n" $(Q)$(CC) $(OBJS) -o $@ ``` 用 `gcc` 編譯 `src` 底下的檔案,產生執行檔 `shecc` #### Stage 1 ```shell $(OUT)/$(STAGE1): $(OUT)/$(STAGE0) $(VECHO) " SHECC\t$@\n" $(Q)$(OUT)/$(STAGE0) --dump-ir -o $@ $(SRCDIR)/main.c > $(OUT)/shecc-stage1.log $(Q)chmod a+x $@ ``` 用上一個步驟產生的執行檔 `shecc` 來 "編譯" 自己(src/main.c),預期產生 ARMv7-A 的 machine code(linux ELF),此執行檔必須在 ARM platform 上執行。 另外 IR 會存在 `shecc-stage1.log` #### Stage 2 ```shell $(OUT)/$(STAGE2): $(OUT)/$(STAGE1) $(VECHO) " SHECC\t$@\n" $(Q)$(ARM_EXEC) $(OUT)/$(STAGE1) -o $@ $(SRCDIR)/main.c ``` [QEMU](https://www.qemu.org/) 可模擬在 ARM 平台執行程式,將 stage 1 產生的執行檔(arm based elf file)透過 QEMU 來執行。同樣編譯自己,將產生的執行檔命名為 `shecc-stage2.elf` #### bootstrap 比較 stage 1 和 stage 2 產生的兩個執行檔是否相同(binary aspect),如果相同代表 bootstrap 成功! ### shecc 運作原理 本段落只用 high level 的方式說明運作流程,詳細的 code tracing 放在[文末](#Trace-code-in-detail)。 萬事起頭難,trace 一個專案從 `main.c` 下手通常是一個不錯的選擇。從 `main` 中可以清楚看到 `shecc` 的運作流程: 1. global_init() 2. libc_generate() 3. parse(in) 4. code_generate() 5. elf_generate(out) 首先需要分配稍後編譯所需的資料結構 (e.g. `BLOCK`) 和填寫 elf 檔案的空間。 接著是 `parse`,在 `parse` 的前半部需要先載入程式碼,也包含 `include` 的檔案,通通存到 `SOURCE`。後半部則是開始 parsing (`parse_internal`),在開始前需要加一些 initial instruction 到 `instr_t` 內,接著就是一直讀程式碼 (`read_global_statement`),直到 EOF。(事實上 `read_global_statement` 不只是判斷語法的正確與否,他還負責產生正確的執行順序,如四則運算。簡單來說,`read_global_statement` 會產生最核心的部分: Intermediate representation,而 IR 會記錄在 `instr_t` 型態的資料結構) 下一個步驟是 code generation (`code_generate`),將 parsing 所紀錄的 instruction 產生成 ARM 的指令,這部分的程式碼在 `codegen.c` 和 `arm.c` 中。此外,這部分的實作基本上就是 1 對 1 的 mapping,由已知道的 `instr_t` (rs, rt, imm 等等都記錄在這個資料結構中) 對應到 ARM instruction (binary)。[文末](#Example-for-mapping-from-IR-to-ARM-instruction)提供簡單的範例展示如何 mapping。 最後則是輸出 ELF 的檔案 (執行檔,based on ARM),本步驟即 `elf_generate`,在 [wiki](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format) 中有對 ELF 做很清楚的規範,(仔細 trace `elf_generate_header()` 可以發現的確是照著 wiki 規範的格式產生 ELF)。再來就是把上個步驟 (透過 `emit`) 存在 `elf_code` 中的 instruction 寫到輸出的執行檔(ELF) 以上所有步驟將產生能在 ARM 平台上執行的執行檔 (當然執行前記得 chmod) ## Known Issues & Pull Request 本段落說明提交的 PR 修改了哪些功能,主要都是根據尚未通過 test bench (`tests/driver.sh`) 的語法或功能來改善。 ### Adjust maximum value in defs.h 這是我發的[第一個 PR](https://github.com/jserv/shecc/commit/ea3d5ac11c7c656cef6d759b971f34945420c2ce),在 build 專案時會在 stage 1 就 crash,透過 [gdb](https://zh.wikipedia.org/wiki/GNU%E4%BE%A6%E9%94%99%E5%99%A8) 找出在 allocate code block (`shecc` 紀錄資料的結構) 時會 segmentation fault。在 8G 的記憶體下無法分配這麼多記憶體,解法就是減小分配的記憶體。(當然,隨著 `shecc` 的長大,終究還是要增加在 `defs.h` 中定義的 limitation) ### Support divide operation 這是我發的[第二個 PR](https://github.com/jserv/shecc/commit/cd72dd4e95b581ffd85a7c4d74123e3081633768),基本上新增 operator 流程都一樣: 1. 添加 Token 2. 新增 parsing 時判斷為該 Token 的流程 3. 添加 operator 的 priority 4. 添加該 operator 該如何 encoding 成 binary (必須查 spec) ### Support global variable initialization 這是我發的[第三個 PR](https://github.com/jserv/shecc/commit/12a38bc844dd3963066a3d2bfd575e9811b840f5),這個 PR 牽涉的問題就比較多了。原本的 `shecc` 並沒有 special section for ELF,其中又屬 `.data` 這個 special section 對 initialized data 來說特別重要。這個區段是在編譯後、執行前就確定了。 為了添加這個功能,我們需要: 1. Evaluate expression before execution time 2. Modify data structure of `var_t` 3. Mechanism for writing initial value into `.data` section 為了解決第一個問題,我們需要變更運算 expression 的策略,原本的方法是產生正確的 instruction order 並等到 running time 才開始算,現在我們需要在編譯時期就算出答案。因此新增 `read_global_assignment` 、 `eval_expression_imm` 和 `read_numeric_sconstant`。 第二個問題就比較好解了,只需要多加一個欄位 `int init_val;` 給 `var_t` 即可。 最後則是需要設計新的函數來寫初始值進 `.data ` section: ```c void elf_write_data_int(int val) { elf_data[elf_data_idx++] = (val & 0x000000FF); elf_data[elf_data_idx++] = (val & 0x0000FF00) >> 8; elf_data[elf_data_idx++] = (val & 0x00FF0000) >> 16; elf_data[elf_data_idx++] = (val & 0xFF000000) >> 24; } ``` 既然已經提供了 global initialization,那就可以簡化 `shecc` 中的部分程式碼,修改於下一個 PR: [Simplify some code with global initializations](https://github.com/jserv/shecc/commit/d01804a557eb9cf9297f6441071f55bd7849962d)。 PS. This patch haven't add `.bss` section for un-initialized data. ### Rework malloc and free function 這是我發的[第五個 PR](https://github.com/jserv/shecc/commit/09a69e8176085d050c7d66e33dc7d211285a2924),原本 `shecc` 並沒有提供 `free`。為了添增功能,這次需要改動的地方在內建函式庫 `lib/c.c` 參考[文章](https://danluu.com/malloc-tutorial/)對 malloc & free 的實作來完成功能,簡單來說需要維護一條 list,每個節點都代表分配的記憶體(包含 free 後,亦在 list 上)。malloc 時會先找 list 上看是否有被釋放的記憶體,如果要求的大小小於該節點,則直接使用該記憶體。反之,如果整條 list 都找不到恰當的節點,那就需要透過系統呼叫 [`brk`](https://man7.org/linux/man-pages/man2/brk.2.html)。而 free 記憶體只需要將該節點的 `is_free` 標籤更改狀態即可。 ### Consolidate break for general loop 這是我發的[第六個 PR](https://github.com/jserv/shecc/commit/d19f0dc1dbb490a7d0fc159d45b5099875d8b84d),目前的 `shecc` 不支援在 `while` 中使用 `break`,此外,`do_while` 和 `for` 亦不支援 `break`。 原先遇到 `break` 就會跳到"應該跳的地方": ```c if (lex_accept(T_break)) { ii = add_instr(OP_jump); ii->int_param1 = break_exit_ir_index[break_level - 1]; } ``` 為了達成功能,我們需要重新設計 loop 附近的 label: ```c ir_instr_t *start_jump = add_instr(OP_jump); /* jump to while condition */ ir_instr_t *exit_label = add_instr(OP_label); ir_instr_t *exit_jump = add_instr(OP_jump); ir_instr_t *start_label = add_instr(OP_label); /* start to return to */ lex_expect(T_open_bracket); ... false_jump = add_instr(OP_jz); false_jump->param_no = 0; start_jump->int_param1 = start_label->ir_index; /* create exit jump for breaks */ break_exit_ir_index[break_level++] = exit_label->ir_index; read_body_statement(parent); break_level--; /* unconditional jump back to expression */ ... /* exit label */ ii = add_instr(OP_label); false_jump->int_param1 = ii->ir_index; exit_jump->int_param1 = ii->ir_index; ``` ### Support continue for general loop 這是我發的[第七個 PR](https://github.com/jserv/shecc/commit/13d327fcd0ccbcd639e257b94d498fa3e70a4077),原本的 `shecc` 並未提供 `continue` 語法,有別於 `break` ,這次我們還需要而外添加 Token 和控制流程。除此之外,我們還需要 `*conti_jump_ir_index` 來記錄遇到 `continue` 後會跳到哪裡。在 `while` 中較幸運的是 `continue` 需要跳的位置和 unconditional jump back to expression 一樣,所以可以直接把 `start_label->ir_index` 作為 `continue` 後的位置。 ### Consolidate prefix operation 這是我發的[第八個 PR](https://github.com/jserv/shecc/commit/d5bd5188b844b0116cb59de07ed8eb447d929e77),原先的 `shecc` 無法讓 `++` 出現在等號的左邊,如:`for(;;++i)`。因為在 `for` 和一般 statement 處理 prefix expression 是分開的,所以需要到兩個地方補齊 prefix operation,第一個是一般的 statement,在 identifier (l-value) 前必須先看看有沒有 prefix: ```c /* statement with prefix */ if (lex_accept(T_increment)) prefix_op = OP_add; else if (lex_accept(T_decrement)) prefix_op = OP_sub; /* must be an identifier */ if (!lex_peek(T_identifier, token)) error("Unexpected token"); ``` 第二個部分是在 `for` 中的 prefix operation: ```c increment = add_instr(OP_label); if (!lex_accept(T_close_bracket)) { if (lex_accept(T_increment)) prefix_op = OP_add; else if (lex_accept(T_decrement)) prefix_op = OP_sub; lex_peek(T_identifier, token); read_body_assignment(token, parent); read_body_assignment(token, parent, prefix_op); lex_expect(T_close_bracket); } ``` ### Support exclusive-or operator & Support modulo operator & Support bitwise not operator 接續的 3 個 PR 都是新增 operator,流程基本上都一樣: 1. 添加 Token 2. 新增 parsing 時判斷為該 Token 的流程 3. 添加 operator 的 priority 4. 補齊 evaluate immediate 的 operation (for global initialization) 5. 添加該 operator 該如何 encoding 成 binary (必須查 spec) ### Consolidate logical not operator 這是我發的[第 13 個 PR](https://github.com/jserv/shecc/commit/cb7935a197f6a2867766d9f30dc0b703e2d3a518),原本 `shecc` 中的 logical not (`!`) 只能用於 0 或 1,但真實的 C 語言除了 0 以外都是 true,所以本 patch 就是試圖想讓 logical not 變成後者。需要改動的地方是 code generation 的部分: ```c case OP_log_not: emit(__teq(dest_reg)); emit(__mov_i(__NE, dest_reg, 0)); emit(__mov_i(__EQ, dest_reg, 1)); ``` ### Reserve return value from main function 這是我發的[第 14 個 PR](https://github.com/jserv/shecc/commit/a159d083508c33e48030d266029f3d7e7482a44f),原本 `shecc` 中的 main function 的回傳值不會被保留,所以可以在 `tests/driver.sh` 中看到大量的 `exit` 而非 `return`,原因同前面所述。所以本 patch 預期修改此問題,並更換 `driver.sh` 中所有的 `exit` 成 `return`。 原先沒有變數來記住 main 的 `return` 值,以下宣告 `exit_ii` 來記錄: ```c ir_instr_t *exit_ii; /* exit for program */ void read_func_call(func_t *fn, int param_no, block_t *parent) { ... if (!strcmp(ii->str_param1, "main")) exit_ii->int_param1 = ii->param_no; } ``` 此外也需要在 codegen 的地方修改 `OP_exit`: ```c case OP_exit: /* syscall for 'exit' */ emit(__mov_r(__AL, __r0, OP_reg)); emit(__mov_i(__AL, __r7, 1)); emit(__svc()); ``` ### Support ternary operator 這是我發的[第 15 個 PR](https://github.com/jserv/shecc/commit/6ce22c9ae450166a67a2f5019eb16f7e01daa82f),原本的 `shecc` 並沒有提供經典的三元運算子 `?:`,本 patch 修改了四個部分的前端,來達到支援 ternary operator: 1. global variable initialization 2. local variable initialization & assignment 3. expression of return 4. parameter list in function call 其實 ternary operator 就像 `if-else`,照著 `if-else` 的邏輯來完成。以下為在 block 內的 statement 使用 ternary operator: ```c void read_ternary_operation(int dest, block_t *parent) { ir_instr_t *false_jump, *true_jump, *ii; if (!lex_accept(T_question)) return; /* ternary-operator */ false_jump = add_instr(OP_jz); false_jump->param_no = dest; /* true branch */ read_expr(dest, parent); if (!lex_accept(T_colon)) return; /* jump true branch to end of expression */ true_jump = add_instr(OP_jump); ii = add_instr(OP_label); false_jump->int_param1 = ii->ir_index; /* false branch */ read_expr(dest, parent); /* this is finish, link true jump */ ii = add_instr(OP_label); true_jump->int_param1 = ii->ir_index; } ``` ## Trace code in detail 以下試圖仔細解釋每個函數的功能 ### load_source_file ```cpp= void load_source_file(char *file) ... if ((strncmp(buffer, "#include ", 9) == 0) && (buffer[9] == '"')) { char path[MAX_LINE_LEN]; int c = strlen(file) - 1; while (c > 0 && file[c] != '/') c--; if (c) { /* prepend directory name */ strncpy(path, file, c + 1); c++; } path[c] = 0; buffer[strlen(buffer) - 2] = 0; strcpy(path + c, buffer + 10); load_source_file(path); } ``` 第一次呼叫 `load_source_file` 參數為主程式的檔名,接著若該檔案裡面又有 `include` 的話就要再呼叫 `load_source_file`。假如主程式為 `aaa/main.c` 然後裡面 `include` 路徑 `../hi.h`,那 13 行的 `path` 就是 `aaa/` 而 `buffer` 就是 `../hi.h`,經過 15 行後 `path` 變成 `aaa/../hi.h`。 ```cpp= } else { strcpy(SOURCE + source_idx, buffer); source_idx += strlen(buffer); } ``` 如果該行不是 `include`,那就把 statement 存到 `SOURCE`(char pointer and access element through `source_idx`) ### read_global_statement ### read_global_decl 這個函數扮演了很重要的角色:解析 function 或全域變數的宣告! `read_full_var_decl` 先把 type 存到 `vd->type_name`,接著 `read_inner_var_decl` 檢查: (為了方便說明,假設該行為 `int* main(int arg){` ) * 是否為指標型態?`vd->is_ptr = 1` * 將 variable name 存到 `vd`。範例為 `main` * 檢查是否為陣列?範例為非,`vd->array_size = 0` 接著回到 1666 行,上面解析到 variable name,即 `main`,所以如果是 function 的話接著預期會有 '('。 1670 行呼叫 `add_func` 註冊新的函數,在 `add_func` 中會先看看,接著解析參數(1673)。再來就是 `read_body_statement` 解析 function 內的 statement。 可以透過 `readelf` 來觀察 elf 檔案內的格式 ``` readelf -a fib ``` `token_str` 存的是 token 的字串,而非代稱。e.g. `T_break` 的 `token_str` 為 "break" ### lex_peek 純粹看看下一個 token 是否和參數傳入的 token 一樣,並回傳結果。不會 comsume 目前的 token。 ### lex_accept 參數為欲檢查的 token,如果 token 的確是下一個 token 回傳 1,並讀入(`get_next_token`)下一個 token。 ### lex_expect 激進版的 lex_accept,一定要出現預期的 token,否則報錯。 ### lex_indent 會把目前的 token 寫到指定的字串供 caller 使用,並讀下一個 token。 ### OP_address_of ```cpp ii = add_instr(OP_address_of); ii->param_no = param_no; ii->str_param1 = var->var_name; ``` `param_no` 為 load 的 destination reg. `str_param1` 為要 load 的 variable name。稍後在 codegen 時透過 `str_param1` 找(var_t)。 ## Example for mapping from IR to ARM instruction 對應 instrcution 的 encoding 規則可以參考 [ARM Architecture Reference Manual ARMv7-A and ARMv7-R edition](https://www.google.com/search?q=ARMv7-A+spec&oq=armv&aqs=chrome.0.69i59l3j69i57j0l2j69i61j69i60.3413j0j1&sourceid=chrome&ie=UTF-8)。 以下舉例如何查表,在 `codegen` 中 ```cpp=208 case OP_load_constant: /* load numeric constant */ val = ii->int_param1; if (val >= 0 && val < 256) { emit(__mov_i(__AL, dest_reg, val)); } ``` 來看看 `mov_i` 是怎麼 encoding 的。 `mov_i` 呼叫 `__mov(cond, 1, arm_mov, 0, 0, rd, imm)` ```cpp int arm_encode(arm_cond_t cond, int opcode, int rn, int rd, int op2) { return (cond << 28) + (opcode << 20) + (rn << 16) + (rd << 12) + op2; } int __mov(arm_cond_t cond, int io, int opcode, int s, int rn, int rd, int op2) { int shift = 0; ... return arm_encode(cond, s + (opcode << 1) + (io << 5), rn, rd, (shift << 8) + (op2 & 255)); } ``` 對應 spec: ![](https://i.imgur.com/Kem3Yi1.png) 可以發現 `arm_mov` 是 13,即 $1101_b$。`(opcode << 1) + (io << 5)` 會變成 $111010_b$,接著 shift 20,剛好就是指令的 25~20 bits。 ### ELF format 依據 [Wikipedia](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format),如果 size 是 4 就用 `elf_write_header_int`,如果是 2 就用兩次 `elf_write_header_byte`,注意順序,little endian。 來看看 section header 是怎麼寫的,在 `elf_generate_sections` 的 `.text` 部份,`elf_write_section_int(1)` 代表 `SHT_PROGBITS | Program data`。接著 flag 設 7 代表有三個 attribute is on。 ![](https://i.imgur.com/NE88zwv.png) #### Useful command for searching word ```shell grep --include=\*.{c,h} -rnw -e "..." ```