# CS:APP Ch3 Machine-level Representation of Programs 透過高級語言開發效率更高也更可靠,用高級的語言開發可以在不同的機器上編譯和執行,而 assembly code 則是與特定機器密切相關。 ## 為什麼還需要學習 assembly code 呢? 1. 通過閱讀 assembly code ,可以了解編譯器如何優化、理解電腦如何執行程式並分析其中的低效率之處。 2. 試圖最大化一段關鍵程式碼效率的過程中,可以透過不斷修改程式碼,並觀察編譯器產生的 assembly code 來了解如何最大化效率。 3. 高級語言提供的抽象層會隱藏我們想了解的程式運行行為。例如了解 thread 之間是如何共享資料或是如何管理私有資料等。 ## 重要名詞 - **Instruction Set Architecture** (ISA) : 必須理解 ISA 才能夠撰寫 assembly code 或是 machine code ,裏面定義處理器狀態、指令的格式以及每個指令應該執行所發生的行為。 - **Machine code**: byte-level programs that a processor executes - **Assembly code**: A text representation of machine code - ISA 的例子有 x86, x86-64, ARM or RISC V - Instruction Set 是一個 software 和 hardware 之間的 interface。 (延伸閱讀: [計算機組織結構](https://hackmd.io/K2Gp6cozT7-rvsVG6Kktjg?view#Computer-Architecture)) - **Micro architecture**: Implementation of ISA in processor - examples: cache size and core frequency - 一般我們在看組合語言會發現裏面是不會去控制 cache 的。 * machine-level program 是由 ISA 所組成的,而 micro architecture 的概念包含的是處理器內部的構成和這些構成如何執行 ISA ,可以理解為 micro architecture 負責去實作出 ISA ,因此我們是無法透過 ISA 去控制 micro architecture 。 * 另外一個概念是不同的 processor 是可能支援相同的 ISA ,例如 intel 和 AMD 的處理器就都支援 x86 的 ISA ,但可想而知,Intel 和 AMD 的 microarchitecture 是不同的。 ## Assembly / Machine Code View 我們知道 assembly code 和 C code 之間是有所差別的,有很多細節是在編寫 C code 無法去控制的,但對於程式設計師來說,這些概念應該要能夠了解 - Program counter ( PC ) : 下一個要執行 instruction 在記憶體中的位址。 - Integer Register file : 在 x86-64 的架構下,有 16 個暫存器可以使用,每個可以儲存 64 bits 的值,可以用來儲存 address (對應於 C 的 pointer )或整數資料。 - Condition codes : 儲存 arithmetic or logical operation (例如 if 和 while) 的狀態資訊。 - Memory : 在 C 語言中我們可以使用各式各樣的資料結構、 signed / unsigned 或是指針,但對於 machine code 來說這些都只是一連串的 byte-addressed array 。 ## Turning C into Object Code ![](https://i.imgur.com/TqMdnT2.jpg) gcc 利用一整套工具將原始程式碼轉換為可執行的程式。 ### Compiler - C preprocessor 插入所有 #include 所指定的標頭檔,並擴展所有 #define 指定的 macro ,接著產生 assembly code 。 ### Assembler - 將 .s 轉為 .o - 將 instruction 轉換為二進位格式。 - object code 是 machine code 的一種,差別在於不同檔案的程式碼尚未有 linkage 。 ### Linker - resolve reference between files - Combine with static run-time libraries. ex: `malloc` , `printf` - Some libraries are dynamically linked - 所產生的 machine code 可以被處理器直接執行。 可以參考: [CS:APP CH7 Linking 筆記](https://hackmd.io/@haogroot/cs-app_ch7) ## Disassembling object code ### 使用 GDB - 進入到 GDB 之後,可以針對個別 procedure 進行 disassembling | 指令 | 效果 | | -------- | -------- | | $ disas | disassemble 當前函式 | | $ disas funcA | disassemble 函式 funcA | | $ disas 0x005566 | disassemble 位址 0x005566 附近的函式 | | $ disas 0x001234, 0x005566 | disassemble 指定位址內的程式碼 | | $ print /x $rip | 以 16進制輸出 program counter 內容 | ## 使用 objdump - 會顯示每個 instruction 的 bit pattern - 製造出幾乎一樣的 assembly code ( disassembler 所使用的指令命名規則可能和 GCC 產生的 assembly code 有些微不同 ) - 可以對完整的執行檔 (a.out) 或是 .o file 來直接執行,且 objdump 不需要知道原始 source code 就能夠 disassemble 。 ## Data types in Assembly code - Integer : 最一開始是由 16 bit 擴展為 32 bit 的架構, Intel 定義 "word" 為 16-bits 的 data type 。而 "double word" or "long word" 代表 32-bits 大小的資料,"quad word" 代表 64-bits。在 assembly code 中的 integer 大小可以為 1, 2, 4 or 8 bytes. - Floating Point : 4 (對應到 c 的 float ), 8 (對應到 c 的 double ) 和 10 bytes(對應到 long double ,但不建議使用他,並不是所有類型的機器都支援,且執行起來低效) ## x86-64 integer Register ![](https://i.imgur.com/ppiEDRP.png) 資料來源: [https://www.cs.cmu.edu/~fp/courses/15213-s07/misc/asm64-handout.pdf](https://www.cs.cmu.edu/~fp/courses/15213-s07/misc/asm64-handout.pdf) 每個 x86-64 的 CPU 都包含 16 個暫存器來儲存資料和 pointer 。這些暫存器也可以只使用其 low-order 的 4, 2 or 1 bytes ,暫存器名稱可以由圖得知。在早期的 32 bit 架構下,例如 IA-32 ,只有 8 個暫存器且以 e 開頭來命名各暫存器,例如 `%eax` , `%esp` 。 ## Operand Type in Assembly code 有三種 operand type ,分別是 Immediate, Register and Memory * **Immediate** : 內含常數,其表示方式開頭必定有 '$' ,ex: `$0x5566`, `$-123` * **Register** : 為 x86-64 integer type ,ex: `%rax`, `$r14` * **Memory** : 根據 register 給定的位址再到該記憶體位址讀出 data 。 Register Indirect addressing: `(%rax)` 代表的是先從暫存器 `%rax` 讀出內容,該內容只是一個 memory address,接著到 memory 中該位址讀出資料,這樣的動作如同在 C 中 dereferencing pointer 。 這樣的 memory addressing Mode 還有許多種格式,可以參考 [wikipedia](https://en.wikipedia.org/wiki/Addressing_mode#Indexed_absolute) 或書中 p121 ,下面投影片提供 general form。 ![](https://i.imgur.com/YfwwtZc.png) ❓Scale 為什麼只能夠是 1, 2, 4 or 8 呢? 由於我們從 memory 中讀出來的資料,必定是某一種的 data type (char, integer, long ...) ,例如我們要讀出一個 integer ,他在 memory 上的 address 必定是對齊 4 的倍數,因此 scale 必須符合各種 data type 的大小,即 1, 2, 4 or 8 。 ![](https://i.imgur.com/HgRxCGC.png) 資料來源:[http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf](http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf) ## Operations in Assembly code ### Moving Data ```wasm mov src, dst ``` src 和 dst 可以是任何一種 operand type ,但在 x86-64 有一條限制,**src 和 dst 不能同時都是 memory 的位址** 。 ### load effective address ```cpp leaq Src, Dst ``` 這個指令等同於將 &Src 寫到 Dst 中,可以為後續的操作製作出指標。 但其實這個指令還有一個妙用,可以用來計算 `x + k*y` ,其中 k 為 1, 2, 4 or 8 ,為什麼 k 只能為這4個值呢? 可以回憶前面學到的 complete memory addressing mode 搭配以下範例。 ![](https://i.imgur.com/SK8Cb0D.png) 更多的 arithmetic operations 可見下圖: ![](https://i.imgur.com/u3Ux6Rl.png) # Machine-Level Programming - Control 在 C 語言當中,我們有 `if` , `while` 等可以根據測試條件來決定執行順序,在 assembly code 中有兩種基本的機制來實現這樣的行為, 1. 計算測試條件,根據結果**改變控制 flow** ,在以下 Conditional Branch 章節探討。 2. 計算測試條件,根據結果**改變資料 flow** ,在以下 Conditional Moves 章節探討。 ## Condition Codes 除了 16 個 integer register ,CPU 還維護著一組 condition code register ,用來紀錄最近 arithmetic or logical operation 的結果,藉此來執行條件分支 (conditional branching)指令。常見的有以下: - CF Carry Flag:用來檢查 unsigned 變數運算是否 overflow - ZF Zero Flag:用來檢查運算結果是否為 0 - SF Sign Flag:用來檢查 signed 運算結果是否為負數 - OF Overflow Flag:用來檢查 signed 變數運算結果是否為負數 :warning: leadq 指令不會改變任何 condition codes, 他是用來進行地址計算的。 ## Jumping 正常情況下指令會照順序一條一條執行, jump 指令會導致執行順序切換到程式中的某個位置。在 assembly code 中, jump 指令的目的地通常會用一個 Label 來指明。 - jump 指令會 implicitly read condition codes 來決定是否執行 jump 。 ## Conditional Branch 以下圖程式碼為例,首先比較 x 跟 y 並設定 condition code,如果 `x ≤ y` 就 jump 到 Label `.L4` 。 ![](https://i.imgur.com/hH2VP7Z.jpg) 資料來源:[http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf](http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf) 再將上圖的組合語言再轉換為 C 語言幫助理解(如下圖右邊程式碼),這樣的行為如同使用 C 中的 `goto` 。 ![](https://i.imgur.com/8yyW2kk.jpg) source: [http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf](http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf) 這種根據測試條件來改變控制 flow 的作法使用在現代 CPU 上有可能會導致效能下降。 考慮到 CPU 使用 pipeline 技術,一條指令會被拆分為好幾個階段,如此 pipeline 內可以充滿待執行的指令來提升效能。 但是當遇到 conditional branch 的狀況,CPU 會採取 branch predict 方式,猜測接下來會執行哪個分支,才能夠在 pipeline 上預先放好待執行的指令 (現代 cpu 設計都試圖達到 90% 以上成功率),但是若預測失敗,也就代表許多已經執行的指令需要捨棄,造成效能上的浪費。 ## Conditional Moves 相較於 Conditional branch 可能造成效能的浪費,Conditional move 透過一次將兩種結果事先計算好,接著再根據條件是否滿足選取對應的結果,這樣子就不需要執行 branch predict ,可以依照指令順序執行,可見下圖範例。 ![](https://i.imgur.com/f4BUYp1.jpg) source: [http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf](http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf) 但是 Conditional Move 並不總是提高效率,如果分支都需要執行大量的運算,那麼當相對應條件不滿足,不滿足分支所做的工作也是白費,同樣是效能上的浪費。或是分支的執行可能會造成無法預期的結果等(可見下圖)。 ![](https://i.imgur.com/dDT31xG.jpg) source: [http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf](http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf) 老實說,編譯器並沒有足夠多的資訊來判斷該使用何種 Conditional Branch 或是 Conditional Moves 才有效率,根據 GCC 的測試結果來看,只有當兩個分支的 expression 都很容易計算時,才會使用 Conditional move ,其餘還是採用 Conditional branch 。 ## Jump table structure Switch statement 搭配 jump table 來實現, jump table 是一個 array ,內部存放著某個 code section 的 address ,當透過 index 來取得對應的 address,就可以 jump 過去,jump table 讓實現 multi-way branching 更加高效。 可見書 p159 的範例。 # Machine-Level Programming - Procedures procedure 是一種封裝程式碼的方式,用一組指定的參數和一個回傳值來實現特定功能,可以在任意地方呼叫使用 procedure 。在不同的程式語言中, procedure 有不同形式的呈現,可以是 function, method, subroutine 或 handler 等等。 ## x86-64 Stack structure C 語言使用 procedure 過程中,可以透過 stack 來管理所需要操作的資料。 - 當 procedure 需要的記憶體空間超過 register 所能提供的,就會在 stack 上分配新空間,稱之為 stack frame,下圖即為 stack frame 的架構。 - stack 的記憶體是採取後進先出的原則。若是由 procedure P 呼叫 procedure Q ,此時 stack 中已經包含了 procedure P 的 stack frame,當 Q 執行時,透過 `pushq` 幫 procedure Q 向下新增他所需要的 stack frame,procedure Q 可以在 stack 中為 local variable 分配使用的空間,當 procedure Q 要回傳時,由 `popq` 指令來增加 `%rsp` ,值得注意的是,procedure Q 所使用的記憶體並不會被清空,單純透過移動 `%rsp` 指向的位置來分配 stack 。 - stack 是往 lower address 方向擴展,因此 `%rsp` 總是指向 the lowest address 。 - 事實上,許多 procedure 根本不需要分配 stack frame ,過程中所需要的參數可以都過 register 來傳遞即可 。 這邊也意味著一件事情,當我們在寫程式碼時候,應該避免傳遞不需要的參數,避免宣告不需要的記憶體,否則會造成 stack 空間的浪費,這樣的問題在遞迴使用過程更是嚴重,過多的 recursion 會造成 stack overflow 問題。 (延伸: [tail recursion](https://stackoverflow.com/questions/33923/what-is-tail-recursion) 透過參數來傳遞結果,減少不要 stack 的使用,來避免 stackoverflow 的具體例子) - ![](https://i.imgur.com/Cjk9pW6.jpg) 假設 procedure P 呼叫 procedure Q, Q 執行完返回到 P 這樣的過程會發生以下機制: ### Passing Control: 控制轉移到 procedure Q 前: (1) 將 return address( P 中呼叫 Q 的該條指令的後面位址) push 進 stack 中。 (2) 要將 program counter 設為 Q code section 的開始位址並 jump (在 x86-64 中為 `call` 指令) 在 procedure Q 執行完回傳時: (1) 從 stack 中 pop 出 return address 並 jump (在 x86-64 中為 `ret` 指令) (2) 再將 program counter 設為 P 中呼叫 Q 的該條指令的後面位址 。 ### Passing Data 例如 P 必須向 Q 提供一個或多個參數,Q 必須能夠向 P 回傳一個值。 總共有 6 個 register 可以用來存放參數,還有一個 register `%rax` 來存放 return value ,因此要是傳遞的參數超過 6 個,必須要在 stack frame 額外分配空間,這些空間將位於圖中的 "Argument build Area" 區塊,另外,通過 stack 傳遞的資料大小都向 8 的倍數對齊。 ### Managing Local Data 以下狀況發生時,必須將 local data 存放到 stack frame 中的 "Local Variables" 區塊,且 stack frame 在 call procedure 到 called procedure 回傳時都必須被存著。 1. register 不足以存放所有的 local variable 。 2. 對一個 local variable 使用 & ,必須為它產生一個位址。 3. 某些 local variable 為 array 或 struct ,因此必須能夠透過 array 或 struct 被訪問 以下圖遞迴為例,function `who()` 呼叫 `amI()` , `amI()` 會再遞迴呼叫到 `amI()` ,可以觀察到 stack frame 是不斷的增長。 ![](https://i.imgur.com/LZr89Ka.jpg) source: [http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf](http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf) 當 `amI()` 逐一回傳時,回到了 `who()` 時,從下圖可以知道此時 `%rsp` 指向的是 `who()` 的 stack frame 。 ![](https://i.imgur.com/cvkBPhx.jpg) source: [http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf](http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf) ### Register Saving Conventions 前面提到當 register 不敷使用時,才會在 stack frame 上分配記憶體,但若是遞迴的狀況下,這些 register 可能會不斷被多個 procedure 使用到,被呼叫者 (callee) 有可能將呼叫者 (caller) 所使用的 register 內資料覆蓋掉。因此 x86-64 採用統一的 register saving conventions 來避免這種狀況。 1. Caller Saved - caller 在呼叫 procedure 之前,會將 register 內資料存在自己的 stack frame 中 - 可以理解為這類型的 register 內的值都可以自由被修改,因為 caller 本身必須負責去保存該 register 的資料。 2. Callee Saved - callee 在使用 register 之前,會先將該 register 資料存在自己的 stack frame 中 - callee 在回傳到 caller 之前,再將資料恢復到 register 中 - 舉例來說,procedure P 呼叫 procedure Q , 在進入 procedure Q 時,callee-saved register 必須由 Q 先存在 stack frame 中或是完全不去使用它,當 Q 要回傳回 P 時,再將這些 register 內的資料恢復原狀。 下面圖中詳述 x86-64 架構中各 register 的類型 ![](https://i.imgur.com/zIjOoI8.jpg) ![](https://i.imgur.com/o4L4qCu.jpg) ### Observation about Recusion stack frame 的機制讓我們使用遞迴時候不需要做任何額外的處理。 - stack frame 意味著每個 procedure 可以保有自己的 private storage - Saved register & local variables - Saved return pointer - Register saving contentions 可以避免 procedure 呼相汙染彼此的資料 - Stack 在 procedure 被呼叫時候分配,在回傳時 deallocate ,這樣的機制很自然的符合 function 的 call / return 順序。 # Machine-level Programming - Data ## 3.8 Array Access ```cpp T A[N] ``` 上面是在 C 語言宣告一個 array 的方法,他在記憶體分配一塊 L * N bytes 的連續記憶體,其中 L 為 data type T 的大小。且引入 identifier A ,可以用 A 來表示一個指向 Array A 開頭的指標。 ## Pointer Access - 每個指標都對應到一個 type 。 這個 type 表明指標該指向哪一類物件 ( object ) 。 ```cpp int *ip; char **cpp; ``` 變數 `ip` 是一個指向 int type 物件的指標,而 `cpp` 指向的物件,其本身就是一個指向 char type 物件的指標。 * 每個指標都有一個值,這個值是某個指定類型的物件的位址。 * `&` 可以產生指標,對於一個表示某個變數的表達式 Expr , `&Expr` 給出該變數位址的一個指標。 * `*` 間接引用指標,對於一個表示位址的表達式 Expr , `*Expr` 給出該位址處的值。 表達式 `Expr` 與 `* &Expr` 是相等的。 * Array 和指標緊密關聯,一個 array 的名字等於其首位元素的位址,因此 Array 變數又等同於一個指向 Arr[0] 的指標。 而 array 名字搭配上指標運算就如同陣列引用有一樣的效果,也就是說 `*(Arr + i )` 等同於 Arr[i] 。 * 陣列引用和指標運算都需要用物件大小對偏移量進行伸縮。當我們寫表達式 `p+i` ,這裡的指標 p 的值為 *`p`* ,得到的位址計算為 `p+ L*i` ,這裡 L 是與 p 相關連的 data type 的大小。 * 將指標由一種 data type 強制轉換成另一種 type ,**只改變他的 type** ,**不改變他的值**。 強制類型轉換的一個效果其實就是**改變指標運算的伸縮量**。 例如,p 是一個 char * type 的指標,他的值為 *s* ,那麼表達式 (int *) p+7 計算為 *s*+28 (強制類型轉換的優先級高於加法),而 (int *) (p+7) 計算為 *s*+7 。\ * 指標也可以指向函式,提供一個強大的儲存和向程式碼傳遞引用的功能。函式指標的值是該函式 machine code 的第一條指令的位址。 ```cpp int (*f) (int*); ``` 這是一個函式指標的宣告,要從裡(從 "f" 開始)往外讀。 因此,我們看到像 "(* f)*"* 聲明的那樣,f 是一個指標,而 *" (** f) (int *)" 代表 f 是一個指向函式的指標,這個函式是以一個 int* 作為參數。 最後,我們看到,他是指向以 int* 為參數並回傳 int 的函式的指標。 *f 兩邊的括號是必須的,否則宣告變成: ```cpp int *f (int *); ``` 會被解讀為 ```cpp (int *) f (int *); ``` 變為一個單純函式的宣告,以 int* 作為參數,並回傳 int * 。 ![](https://i.imgur.com/2qlS1Rw.jpg) | Reference | Type | Value | | -------- | -------- | -------- | | &val[i] - val | long | i | val[i] 為 index 為 i 處的值,透過 `&` 來取得指向 val[i] 的指標,而 `val` 本身也是指標,兩個指標相減就等同相同 Array 中兩個值的 index 差距。 延伸閱讀: [Pointer subtraction Confusion on stackoverflow](https://stackoverflow.com/a/3238520/4545634) 加 ![](https://i.imgur.com/uTouNKJ.jpg) ![](https://i.imgur.com/0TRGGGw.jpg) ## Alignment Principle > 如果 primitive data type 需要 K bytes, 則位址必須是 K 倍數 (通常是 2, 4 or 8),這樣的設計簡化了 CPU 和 memory 之間接口的設計。 例如一個 CPU 總是從 memory 中讀 8 bytes ,則位址就必須是 8 的倍數。如果我們能保證所有 double type data 的 address 都對齊 8 的倍數,就可以用一個記憶體操作的指令來讀或寫。否則需要執行兩次 memory access ,因為資料可能被放在兩個 8 bytes 的 memory 區塊內。 儘管沒有對齊位址 CPU 仍舊能夠正常運動,但是會造成效能的降低,因此還是建議要做好對齊。 編譯器會於 assembly code 放入以下命令,來表明 global data 怎麼對齊: ```wasm .align 8 ``` ### Struct Alignment 在一個 struct 內會有各種不同 data type ,必須要能夠滿足每種 type 的 alignment 需求,同時 initial address 也必須滿足 alignment 要求,因此 struct 最終都以內部最大的 data type 來作為 alignment 的標準。 在 struct 內可以將較大的 data type 先宣告,這樣可以避免空間浪費,參考下方投影片的例子。 ![](https://i.imgur.com/FEDwr9r.jpg) 投影片來源 : 1. http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/05-machine-basics.pdf 2. http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/06-machine-control.pdf 3. http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/07-machine-procedures.pdf 4. http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/08-machine-data.pdf 5. http://www.cs.cmu.edu/afs/cs/academic/class/15213-s20/www/lectures/09-machine-advanced.pdf ###### tags: `CS:APP`