2018q1 Homework2 (accessment) ================ contributed by <`AliennCheng`> ###### tags: `syshw` # 測驗題重作及解析 ## 第 1 週測驗題 測驗 `3` ### 原始題目 在 C 程式中,表達式 `1 << 2 + 3 << 4` 求值後為何? `(a)` 512 `(b)` 16 `(c)` 26 `(d)` 52 `(e)` 25 :::info 延伸問題: * 在 C 語言規格書中,找出對應運算子優先權的描述並嘗試解讀 ::: ### 心路歷程 之所以選擇這題做 review ,純粹是因為自一開始學程式以來,從來沒有把 C 語言規格書打開來細讀過,因此想藉這次機會好好讀一次規格書,希望這次的經驗也可以讓以後遇到問題時,即時地想到:「啊,我該去翻翻規格書!」 重作這題很顯然是要直接從延伸問題下手,再回到原本的問題重新思考,因此我第一步就是去搜尋真正的「 C 語言規格書」。 ### 搜尋 ==C 語言規格書== 首先,我在[維基百科的 C 語言項目](https://zh.wikipedia.org/wiki/C%E8%AF%AD%E8%A8%80)當中,發現最新的規格是 [ISO/IEC 9899:2011 (簡稱 ==C11==)](http://www.open-std.org/jtc1/sc22/WG14/www/docs/n1570.pdf),但是在老師的共筆[你所不知道的 C 語言: 開發工具和規格標準](https://hackmd.io/s/HJFyt37Mx)當中,採用的卻是 [ISO/IEC 9899 (簡稱 ==C99==)](http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf),由於目前還沒有時間研讀該共筆,因此在這裡留個 `TODO`,而以下內容暫時以 C99 作為標準。 :::info TODO: 研讀「[你所不知道的 C 語言: 開發工具和規格標準](https://hackmd.io/s/HJFyt37Mx)」,探討為甚麼老師採用 C99 規格而非 C11。 ::: :::danger 純粹只是從 C99 開始,這是 C 和 C++ 兩個程式語言分道揚鑣的關鍵時間點 (1999 年),系列講座有包含 C11 :notes: jserv ::: >感謝老師解答疑惑!![name=AliennCheng] ### 閱讀規格書 為求內容完整,也恰巧規格書中的內容連貫,我決定把 `+` 和 `<<` 的內容全數翻譯在下方,因為這是我第一次嘗試翻譯,若有用詞不精準之處也麻煩看到的大大幫忙糾正。 #### 6.5.6 Additive operators 加法運算子 ##### 語法 ``` 加法運算式: 乘法運算式 加法運算式 + 乘法運算式 加法運算式 - 乘法運算式 ``` ##### 限制 * 在加法中,兩個運算元需「皆為算術型態 (arithmetic type)」,或是「一個為指向物件的指標,另一個為整數型態」 * 在減法中,需滿足下列條件中至少一項 * 兩個運算元皆為算術型態 * 兩個運算元皆為指向相容物件(無論是否為合格版本)的指標 :::info 原文如下,當中的 qualified 我不確定怎麼翻比較好 both operands are pointers to qualified or unqualified versions of compatible object types ::: * 左運算元為指向物件型態的指標,右運算元為整數型態 ##### 語意 * 若兩運算元皆為算術型態,則以正常的算術法則處理 * `+`的運算結果為兩運算元之和 * `-`的運算結果為首運算元減去次運算元所得之差 * 基於這兩個運算子的目的,一個「指向非陣列元素的指標」的行為,會等同於「一個指向『長度為一且其元素型態即為指標所指之物件型態的陣列』其首項元素的指標」 * 當一個具有整數型態的運算式被指標所加或減時,其結果為該指標運算元的型態。若該指標運算元指向陣列物件的其中一個元素,且該陣列足夠大時,其運算結果即為指向「自原指標所指向之索引處偏移該整數量後之索引」的指標。換句話說,若運算式 `P` 指向一個陣列物件的第 *i* 個元素,且給定變數 `N` 有值 *n* ,則運算式 `(P)+N` 及 `N+(P)` 指向該陣列的第 *i*+*n* 個元素,而運算式 `(P)-N` 指向該陣列的第 *i*-*n* 個元素(此時會自動假設其存在)。除此之外,若運算式 `P` 指向一個陣列物件的最後一個元素,則運算式 `(P)+1` 會指向該陣列物件最後一個元素的後面一個位址,而若運算式 `Q` 指向一個陣列物件最後一個元素的後面一個位址,則運算式 `(Q)-1` 會指向該陣列的最後一個位址。當運算元及運算結果的指標皆指向某一陣列物件當中的元素,或是最後一個元素的後一個位址時,該運算並不會產生溢位 (overflow),否則,該行為為未定義。如果運算結果指向陣列物件最後一個元素的後一個位址時,它不應該被 `*` 運算子所操作。 * 當兩個指標相減時,該兩個指標需指向相同的陣列物件或該陣列最後一個元素的後一位。運算結果亦為指標,其指向的位置在該陣列的索引,即為兩運算元指標所指之處在該陣列的對應索引之差。這個運算結果的大小的累加是有所定義的,且它的型態(即有號整數型態)在標頭檔 `<stddef.h>` 中被定義為 `ptrdiff_t`。若其結果指標在對應物件的型態中是不可表現的,則該行為不被定義。換句話說,在某一陣列物件中,若運算式 `P` 指向第 *i* 個元素,而運算式 `Q` 指向第 *j* 個元素,則運算式 `(P)-(Q)` 的結果型態為 `ptrdiff_t`,其內容為 *i*-*j*。更進一步地說,若運算式 `P` 指向某陣列物件的元素或是其最後一個元素的後一位址,而運算式 `Q` 指向該陣列物件的最後一個元素,則運算式 `((Q)+1)-(P)` 、`((Q)-(P))+1` 和 `((P)-((Q)+1))` 皆具有相同值,此時若 `P` 指向該陣列物件的後一位址的話,則上述三運算式之結果皆為零(儘管運算式 `(Q)+` 並不指向陣列物件中的元素)。 * **範例** 在陣列長度為變數的情況下,指標算數仍是被完全定義的 (well-defined)。 ```=clike { int n = 4, m = 3; int a[n][m]; int (*p)[m] = a; // p == &a[0] p += 1; // p == &a[1] (*p)[2] = 99; // a[1][2] == 99 n = p - a; // n == 1 } ``` * 上述範例中,如果陣列 `a` 被定義為已知的固定長度,且指標 `p` 被定義為指向一個已知為固定大小的陣列(即指向 `a`),則其結果仍會和上述相同。 #### 6.5.7 Bitwise shift operators 位移運算子 ##### 語法 位移運算式: 加法運算式 位移運算式 << 加法運算式 位移運算式 >> 加法運算式 ##### 限制 運算元需為整數型態 ##### 語意 * 運算元會提升 (promotion) 整數個位元。運算結果的型態和被提升位元後的左運算元的型態相同。若右運算元的值為負值或是大於等於左運算元可供提升的寬度時,則運算行為未被定義。 * 運算 `E1 << E2` 的結果即為 `E1` 被向左移了 `E2` 個位元 (bit positions),此時被騰出的空間將被補上零值。在這個運算中,如果 `E1` 具有無號型態,則運算的結果即為 E1×2^E2^ 的結果再除以其對應型態可表示之最大值所得之餘數。如果 `E1` 具有有號型態且不為零值,且運算 E1×2^E2^ 的結果在其對應型態中為可表示的,則該表示值即為結果,否則該運算為未定義。 * 運算 `E1 >> E2` 的結果即為 `E1` 被向右移了 `E2` 個位元。在這個運算中,如果 `E1` 具有無號型態或是具有有號型態且非負時,運算結果的值即為 E1/2^E2^。而若 `E1` 具有有號型態且為負值時,其運算結果為實作定義 (implementation-defined)。 > 翻譯時我並不瞭解甚麼是 implementation-defined,經過一番查詢才知道這代表該運算式或數值等等是「由實作品自行定義」,而實作品通常指的是編譯器。[name=AliennCheng] ### 問題答案與心得 顯然我多費了一大段功夫,但翻到一半發現答案就在眼前時也只是想說「頭都洗下去了就把它洗完吧...」 本題答案就在 Bitwise shift operations 的語法處,其中標明加法運算式包含於位移運算式,也就是說==加法運算的優先權是高於位移運算的==,並且左邊的運算優先於右邊的運算,因此本題結果如下 :::success 1 << 2 + 3 << 4 = 1 << ( 2 + 3 ) << 4 = 512 ::: :::info 但問題來了,為甚麼當初設計時會把加減法運算的權重設計在位移運算之上呢?是因為加減法的運算成本比位移運算低嗎?我至今仍然想不明白。 ::: --- ## 第 1 週測驗題 測驗 `4` ### 原始題目 考慮某些硬體平台執行乘法的時間較長,我們會以 `(x << 5) - (x)` 取代 `*` 31,那麼 `*` (-6) 該如何用 shift 搭配 add/sub 實作呢? `(a)` 2 個 shift 搭配 0 個 add/sub `(b)` 2 個 shift 搭配 2 個 add/sub `(c)` 2 個 shift 搭配 1 個 add/sub `(d)` 1 個 shift 搭配 1 個 add/sub `(e)` 0 個 shift 搭配 2 個 add/sub :::info 延伸問題: * 乘以某個整數的運算,如果拆解為 shift 和 add/sub 的組合,是否存在最小值的唯一解? ::: ### 問題解答 當我們試圖將某數 `x` 乘以 (-6) 時,可以做以下推導 ``` x * (-6) = x * ( 2 - 8 ) = x * 2 - x * 8 ``` 又 `x * 2` 等價於將 x 左移一位,而 `x * 8` 等價於將 x 左移三位,也就是說,可以用兩個位移運算加上一個減法運算得到 `x * ( -6 )` 的結果。 ### 延伸問題思考 由上面兩個例子可以發現,試圖將 `x` 乘以一個數取代成位移和加減法的組合時,需要先將該數化為 2 的次方數的運算式,也就是 2^a1^ +- 2^a2^ +- ... +- 2^an^。 因此本延伸問題等價於「是否存在最小值 k 使得表示式 2^a1^ +- 2^a2^ +- ... +- 2^ak^ 足以表示所有整數,a1, a2, ... , ak 為任意相異整數」。 我的第一直覺猜測結果是否定的,但是不知道該如何開始證明,因此我下面先暴力窮舉一番,列出「乘以 30 以內的正奇數所需要的運算數量」,看看能不能觀察出甚麼端倪。選擇用奇數純粹是因為偶數必是某個數多位移一位而已,因此若取奇數時有唯一最小值,則取偶數必存在唯一最小值。 ### 舉例 | | 3 | 5 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 23 | 25 | 27 | 29 | | ----- | - | - | - | - | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | | shift | 1 | 1 | 1 | 1 | 2 | 2 | 1 | 1 | 2 | 2 | 2 | 2 | 2 | 2 | |add/sub| 1 | 1 | 1 | 1 | 2 | 2 | 1 | 1 | 2 | 2 | 2 | 2 | 2 | 2 | ### 觀察 出乎意料地,竟然不是 1/1 就是 2/2 ,在過程中我只是用前一個數字或是最接近的 2 的次方,搭配之前出現過的較小數如 3 , 5 等湊出下一個數字而已,其實這過程就有點數學歸納法的味道,但我還是掌握不住規律,因此我決定在 32 和 64 之間的奇數再試一次,這次直接列出計算式觀察看看。 ### 再舉例一次 ``` 33 = 32 + 1; 35 = 32 + 2 + 1; 37 = 32 + 4 + 1; 39 = 32 + 8 - 1; 41 = 32 + 8 + 1; 43 = 32 + 8 + 2 + 1; 45 = 32 + 8 + 4 + 1 = 32 + 16 - 4 + 1; 47 = 32 + 16 - 1; ``` ### 再觀察 不用算到 63 我心裡就有個底了,可以發現,當 2 的次方越來越大時,間隔中所包含的數更多,儘管 2 的次方數的數量跟著增加,但要僅透過該 2 次方或下一個 2 次方,配合 1, 3, 5 這種只用一次 shift 的較小數組合出所有數的難度也隨之增加,在 41 之前都可以用 3 個 2 的次方向(也就是只靠最接近的 2 的次方配合 1, 3, 5 等只消耗一次 shift 的數)湊出,但 43, 45 就超過 2 shift 加上 2 add/sub 的限制了。 從這裡我觀察到,是不是 2 的次方項和其下一個次方項的幾何平均左右的數會造成這樣的 shift 數突破呢?下面試著測試看看... ``` 4 -> 8 之間 => 5.66 => 附近奇數皆可在 1 shift & 1 add/sub 內完成 8 -> 16 之間 => 11.31 => 可找到 11 = 8 + 2 + 1 16 -> 32 之間 => 22.63 => 附近奇數皆可在 2 shift & 2 add/sub 內完成 32 -> 64 之間 => 45.25 => 可找到 45 = 32 + 8 + 4 + 1 64 -> 128 之間 => 90.51 => 附近奇數皆可在 3 shift & 3 add/sub 內完成 128 -> 256 之間 => 181.02 => 可找到 181 = 128 + 32 + 16 + 4 + 1 這次沿著之前的規律跳一項看看 512 -> 1024 之間 => 724.08 => 可找到 725 = 512 + 256 - 32 - 8 - 2 - 1 ... ``` 若考慮偶數時就只是多一個 shift (多了把那個 1 轉成 2 的 shift),再考慮負數也只是正負號互換,因此測試應該到這邊可以結束了。 :::success 好像成功試出會突破 shift 次數的規律來,也就是每當「 2 的奇數次方項和其更高一次方的數,這兩數的幾何平均」附近,會有 shift & add/sub 次數的突破。也就是不存在最小次數的 shift & add/sub 可取代乘法。 :::info 但目前還是不瞭解為甚麼有這樣的規律,我短時間內也想不到嚴謹的證明... ::: > 其實換個角度想,如果有這種方法的話現在的電腦何必需要乘法器呢 xD [name=AliennCheng] > 編譯器最佳化時,可能會套用上述「乘以某常數」的模式去改寫程式碼,請利用 clang/gcc 的 `-Ofast` 和 `-S` 的選項觀察輸出的組合語言 [name="jserv"] --- ## 第 3 週測驗題 測驗 `2` ### 原始題目 考慮到下方函式 `shift_right_arith` 和 `shift_right_logical` 分別表示算術右移和邏輯右移,請嘗試補完程式碼。可由 `sizeof(int) * 8` 得知整數型態 `int` 的位元數 `w`,而位移量 `k` 的有效範圍在 0 到 `w - 1`。 ```=clike #include <stdio.h> int shift_right_arith(int x, int k) { int xsrl = (unsigned) x >> k; int w = sizeof(int) << P1; int mask = (int) -1 << (P2); if (x < 0) return xsrl P3 mask; return xsrl; } unsigned shift_right_logical(unsigned x, int k) { unsigned xsra = (int) x >> k; int w = sizeof(int) << P4; int mask = (int) -1 << (P5); return xsra P6 P7; } ``` ### 解題脈絡 由於前兩題已經大量使用 shift 的概念了,這部分就只有[算數 (arithmetic) 右移](https://en.wikipedia.org/wiki/Arithmetic_shift)和[邏輯 (logical) 右移](https://en.wikipedia.org/wiki/Logical_shift)兩者的差別需要特別釐清,這篇[邏輯移位和算數移位有甚麼區別?](https://edwardluo.wordpress.com/2015/04/08/%E9%80%BB%E8%BE%91%E7%A7%BB%E4%BD%8D%E5%92%8C%E7%AE%97%E6%9C%AF%E7%A7%BB%E4%BD%8D%E6%9C%89%E4%BB%80%E4%B9%88%E5%8C%BA%E5%88%AB%EF%BC%9F/)講的還滿仔細,有需要的可以參考,在此就不贅述。 回到原題,以下就分別對給定的算術及邏輯右移函式,重新梳理一次脈絡,同時把挖空的地方補上。 #### 算數右移 首先來探討一下原題目的這個函式 ```=clike= #include <stdio.h> int shift_right_arith(int x, int k) { int xsrl = (unsigned) x >> k; int w = sizeof(int) << P1; int mask = (int) -1 << (P2); if (x < 0) return xsrl P3 mask; return xsrl; } ``` 已知我們要把輸入 x (型態為 int,也就是 8 byte)右移 k 位。 第三行這邊 `>>` 的行為恰巧在上面翻譯過: ``` 運算 E1 >> E2 的結果即為 E1 被向右移了 E2 個位元。 在這個運算中,如果 E1 具有無號型態或是具有有號型態且非負時,運算結果的值即為 E1/(2^E2)。 而若 E1 具有有號型態且為負值時,其運算結果為實作定義 (implementation-defined)。 ``` 換句話說,若 x 本來就是非負值,則 xsrl 的值即是 x 原值除以 2 的 k 次方。然而,若 x 為負值時,為了保證函式運作的一致性,這裡先取了 cast 將其暫時化成無號的型態,也就是原本為負的 x 最左 bit 原是 1,但在第三行執行後暫時在左邊補上了 k 個 0。在這個時候,如果 x 原為非負值的話, xsrl 其實已經是所求了,但若 x 原為負值則還需要再經過修正。 :::info 經過上面的深入分析,這個函式的 4, 5 行是不是應該包含在 `if` 裡面執行起來比較有效率呢?又或者採用的 prediction 方式不同,在某種時候這樣的寫法會更好? ::: 接下來就是要把 x 原為負時,經過第三行後左邊補的那 k 個 0 改成 1 了。 首先,我們只知道最左邊有 k 個 1,但不知道右邊有幾個被保留下來的 bit,又因為被保留 bit 數量和 k 加起來會等於對應型態的 bit length(在這裡是 `int`),因此我們可以藉由計算總共有幾個 bit,來推算右邊需要保留幾個 bit,進而在左邊補上 1。 概念講完了,實作上第四行就是要取得 `int` 的 length,但因為 `sizeof()` 這個函式回傳的是 byte 數,因此要乘以 8 才是 bit 數,經過上面那一番翻譯之後這 shift 的行為很熟了,因此 `P1` 處便是 `log(8) = 3`。接著第五行要做一個左邊為 k 個 1 的遮罩,將剛剛補錯的 0 修正回來,又因為左移時右邊必補 0,因此拿 `int` 型態的 -1 作為媒介最直接,因為它在 bit 的觀點上是個全為 1 的值,將此值左移直到原本的最右位元移到左邊數來第 k 位便是我們所需要的遮罩,而這個位移量即是 `P2 = length - k = w - k`。最後,第七行的位置要將此遮罩蓋在 `xsrl` 上,因為遮罩的右側全為 0 不影響保留下來的值,因此我們使用 [bitwise operation](https://en.wikipedia.org/wiki/Bitwise_operation) 當中的 `|` 運算將 `xsrl` 左邊的 0 用遮罩左邊的 1 取代,也就是 `P3 = |`。 #### 邏輯右移 接著來探討下面這個函式 ```=clike= unsigned shift_right_logical(unsigned x, int k) { unsigned xsra = (int) x >> k; int w = sizeof(int) << P4; int mask = (int) -1 << (P5); return xsra P6 P7; } ``` 邏輯右移的部分就更直覺了,就是將 x 的 bit 型態往右移 k 位,騰出來的位置全補 0。特別需要注意的是,討論邏輯右移時我們希望這個行為是在掌握之中,因此輸入 x 以及回傳值皆為 unsigned 型態,以符合 `>>` 運算的要求。 可以發現這個函式是特別設計來考試用的函式,實際上第二行不需要 cast,執行完就可以回傳了。但這邊特別將 x 換成 int 的型態,也就是說這個時候我們對於左邊 k 位是未定義的,需要一個遮罩將左邊全部蓋成 0。 因此同樣道理,`P4 = 3` 先取得 `int` 的長度,`P5 = w - k` 做一個左邊 k 位為 1 其餘位為 0 的遮罩。但不同的地方在這次要把 `xsra` 的左邊用 0 取代,因此在這裡把遮罩取補數,即 `P7 = ~mask`,使其成為左邊 k 位為 0,右邊全為 1 的狀態,再利用 `P6 = &` 運算將 0 補上,且遮罩右邊全為 1 不影響 `xsra` 所保留的值。 --- :::info 延伸題目: 在 x86_64 和 Aarch32/Aarch64 找到對應的指令,並說明其作用和限制 ::: [x86_64](https://zh.wikipedia.org/wiki/X86-64) 是 Intel 以及 AMD 處理器所採用架構之一,[Aarch32/Aarch64](https://en.wikipedia.org/wiki/ARM_architecture#AArch64_features) 又稱 ARMv8 是 ARM 的架構之一,因為我本身也不夠了解,在這裡就不贅述。 :::info TODO: 詳閱[成大 wiki 中對 ARMv8 的介紹](http://wiki.csie.ncku.edu.tw/embedded/ARMv8),並進一步和現今電腦處理器主流採用的 x86_64 比較 ::: x86 的部分,可以從 intel 官方找到他們自己的[開發手冊](https://software.intel.com/en-us/articles/intel-sdm),但他們還分好幾冊實在有夠複雜,這部分我還沒搞清楚。 而 ARMv8 的部分,可以找到他們自己的[規格書](http://infocenter.arm.com/help/topic/com.arm.doc.den0024a/DEN0024A_v8_architecture_PG.pdf),這部分比較易讀,有關的敘述如下 ``` Logical Shift Right (LSR). The LSR instruction performs division by a power of 2. Arithmetic Shift Right (ASR). The ASR instruction performs division by a power of 2, preserving the sign bit. ``` ![ARMv8_shift](https://i.imgur.com/UsLgrzL.jpg) ``` The register that is specified for a shift can be 32-bit or 64-bit. The amount to be shifted can be specified either as an immediate, that is up to register size minus one, or by a register where the value is taken only from the bottom five (modulo-32) or six (modulo-64) bits. ``` :::info TODO: 簡單分析兩架構對於算數右移及邏輯右移的指令有何特色,並比較其區別。 ::: # 閱讀 [因為自動飲料機而延畢的那一年](http://opass.logdown.com/posts/1273243-the-story-of-auto-beverage-machine-1) 的啟發 :::info TODO: 補上心得 ::: # 學習筆記 :::info TODO: 未來有時間會慢慢補齊[你所不知道的 C 語言](http://hackfoldr.org/dykc/)以及 CS:APP 的內容,再陸續將自己的學習筆記補在這裡 :::