本文首發于我的博客
關于CS:APP
《深入理解計算機系統》(Computer Systems: A Programmer's Perspective;CS:APP)這本書作為CMU核心課程的核心教材,一直被眾人所推崇。這本書的主要內容就如它的英文名稱那樣:以一個程序員的視角看待計算機系統(現在的中文書名翻譯給人一種這本書非常精深的錯覺)。實際上這本書的內容并沒有太過于深入,并且一直都作為計算機科學與技術專業低年級的計算機基礎課來開設。所需要的前置知識也不是很多,一般來說學習過C語言之后就可以看了,并不需要提前學習匯編(本書第三章會講解匯編的基礎內容)。但個人感覺在學習過王爽的8086匯編以后學習本書的匯編會順利不少。
我在三月份時得知本書第三版的英文版即將出版就早早預訂了(第三版中文翻譯版早已出版),苦苦等待一個月以后終于如愿成為了這版CS:APP的第一批讀者。
讀這本書的感受第一就是非常地爽,可以說這本書可以引領你從表層的程序一直深入到計算機內部的運作方式中,里面對于一些概念的理解也是給人一種前所未有的透徹感覺(溢出的圖形表示、補碼的權值理解等等)都切中了問題的本質。
除了書本上的內容,CMU的課程官網上還提供了9個lab,這9個lab也一直深受CMU開設的課程的學生們的喜愛,在lab中我們可以將在各章中學習到的知識運用到解決一個有趣的問題中,并且通過自動化的評分機制評估對知識的掌握程度。這9個lab同樣是這本書的核心內容。
Data Lab
實驗代碼見GitHub
簡介
在嚴格限制的條件下實現簡單的邏輯、補碼、浮點數操作函數。
本lab旨在幫助學生理解C中各類型的位表示和操作符對數據的位級作用行為。
所用工具
VS Code
-用于代碼編寫
gcc
-用于編譯
第一部分 整數
所編寫的程序必須滿足如下要求:
- 只能使用0-255的整型常數
- 只能使用函數參數與函數內聲明的局部變量
- 只能使用如下單目操作符:! ~
- 只能使用如下雙目操作符:& ^ | + << >>
- 最多只能使用有限個運算符(等于號、括號不計),可以認為使用的運算符個數越少得分越高
- 一些函數可能對操作符有更多的限制(在題目前以操作符限制給出)
- 禁止使用任何控制結構如 if do while for switch等
- 禁止定義或使用任何宏
- 禁止定義任何函數
- 禁止調用任何函數(除了可以使用printf輸出中間變量,但提交時必須去掉)
- 禁止使用任何形式的類型轉換
- 禁止使用int以外的任何類型(包括結構體、數組、聯合體)
可以假設程序在如下環境的機器上運行:
- 采用補碼表示整型
- 32位int
- 執行算術右移
- 右移超過類型的長度時的行為未定義
bitAnd
- 要求:只用~ |實現x&y
- 操作符限制:~ |
- 操作符使用數量限制:8
- 思路:略
int bitAnd(int x, int y) { return ~((~x) | (~y)); }
getByte
-
要求:取出x中的n號字節
編號從低位到高位從0開始
操作符使用數量限制:6
思路:將x右移n*8位之后取出低8位的值
int getByte(int x, int n) { return (x >> (n << 3)) & 0xff; }
logicalShift
- 要求:將x邏輯右移n位(0<=n<=31)
- 操作符使用數量限制:20
- 思路:將x的最高位除去后右移n位(保證高位補0),然后使用|操作符手動將最高位移動到的位置置上x的最高位。
int logicalShift(int x, int n) {
int a = 1 << 31;
return ((x & ~a) >> n) | ((!!(x & a)) << (32 + ~n));
}
bitCount
要求:統計x的二進制表示中1的數量
操作符使用數量限制:40
-
思路:
做這道題參考了stackoverflow上的一個回答,核心思想是分治:
- 將所有位分成32組,一組中只有1位
- 將相鄰兩組合為一組,組中的數值為原來兩組中的數值相加
- 重復第2步,直到合成只有1組,組中的數值即為結果
用圖片比較便于理解:
可以看到最終的0x0000000F即為1的數量15
該算法能成功的關鍵在于一開始中每組中的數值即為每組中1的數量,然后將相鄰兩組中的數值相加的過程就相當于將之前一級的1的數量匯總,不斷重復這個過程就可以將1的數量匯總到最后的一個數中。
有了算法我們還要考慮如何在題目的限制條件下實現這一算法。
為了實現將相鄰兩組中的值相加并放在合適的位置,我們采用掩碼+位移的方式,例如有掩碼:
`int mask1 = 0x55555555 (0101...0101)`
那么`x = x & mask1 + (x >> 1) & mask1;`實現了相加的過程,前面一部分先取出了一半的組,右移后再取出的就是后一半的組,再由按位相加的特點,它們相加后的值就存放在特定的位上(可以參照上面的圖理解這一過程)。
接下來只要使用不同的掩碼和不同的位移就可以一步步實現這一過程。
但是題目限制中我們只能使用0x00-0xFF的整型值,所以掩碼也需要我們進行構造。
答案如下,注意到當剩下4組,每組8位的時候我們就可以直接位移相加再取出低8位得到它們的和。
int bitCount(int x) {
// referenced :
// https://stackoverflow.com/questions/3815165/how-to-implement-bitcount-using-only-bitwise-operators
int mask1 = 0x55;
int mask2 = 0x33;
int mask3 = 0x0F;
int result = 0;
mask1 = mask1 | (mask1 << 8);
mask1 = mask1 | (mask1 << 16);
mask2 = mask2 | (mask2 << 8);
mask2 = mask2 | (mask2 << 16);
mask3 = mask3 | (mask3 << 8);
mask3 = mask3 | (mask3 << 16);
result = (x & mask1) + ((x >> 1) & mask1);
result = (result & mask2) + ((result >> 2) & mask2);
result = (result & mask3) + ((result >> 4) & mask3);
return (result + (result >> 8) + (result >> 16) + (result >> 24)) & 0xff;
}
bang
要求:不使用!實現!操作符
操作符限制:~ & ^ | + << >>
操作符使用數量限制:12
-
思路:
!操作符的含義是0變為1,非0變為0,我們自然可以想到要做的是區分非零和零,零相對的非零數有一個非常明顯的特征是-0=0,而對于非零數,取負后必定是一正一負而不可能相等,利用這一點,可以得出非零數與它的相反數進行|運算后符號位一定為1,我們將符號位取出并取反就可以返回正確的值。
int bang(int x) { return 1 & (1 ^ ((x | (~x + 1)) >> 31)); }
tmin
- 要求:返回補碼表示的整型的最小值
- 操作符使用數量限制:4
- 思路:按照補碼的權值理解,只要將權為-32的位置為1即可
int tmin(void) { return 1 << 31; }
fitBits
-
要求:如果x可以l只用n位補碼表示則返回1,否則返回0
1 <= n <= 32
操作符使用數量限制:15
-
思路:
如果x可以用n位補碼表示,那么左移多余的位的個數后再右移回來的數值一定與原值相等,這個方法利用了左移后溢出的位會被丟棄,而右移回來時的是補符號位,如果丟棄了1或者右移時補的是1都會導致值的改變,而這兩種情況也正說明了x不可以只用n位補碼表示。
int fitsBits(int x, int n) {
int a = 33 + ~n;
return !((x << a >> a) ^ x);
}
divpwr2
- 要求:計算x/(2^n) 0 <= n <= 30 結果向零取整
- 操作符使用數量限制:15
- 思路:對于正數,我們直接把x右移n位就可以得到向零取整的結果(實際上是向下取整);對于負數,雖然我們右移n位可以得到結果,但是這個結果是向下取整的,所以我們需要適當地加上1來補為向零取整,那么我們什么時候需要加1呢?整除時當然不用,在不能整除時就都需要加上1來調整,如何判斷是否整除?只要移出的位中有一個不為0,那么就表示無法整除。?
int divpwr2(int x, int n) {
int a = 1 << 31;
int isALessThanZero = !!(x & a);
int isXHasMoreBit = (!!((~(a >> (32 + ~n))) & x));
return (x >> n) + (isXHasMoreBit & isALessThanZero);
}
negate
- 要求:計算-x
- 操作符使用數量限制:5
- 思路:略。
int negate(int x) { return ~x + 1; }
isPositive
- 要求:如果x大于0返回1,否則返回0
- 操作符使用數量限制:8
- 思路:檢測符號位與x是否為0即可。
int isPositive(int x) { return !((x & (1 << 31)) | !x); }
isLessOrEqual
- 要求:如果x小于等于y則返回1,否則返回0
- 操作符使用數量限制:24
- 思路:本題的基本思路是判斷x-y得到的值是否小于等于0,但是要考慮溢出帶來的影響,首先定義了兩個變量xp,yp分別表示x,y是否大于等于0。return的表達式的含義為并并非x大于等于0且y小于0的情況下(&的后半部分),如果x-y小于或等于0或x小于零且y大于等于0,則返回1。
int isLessOrEqual(int x, int y) {
int t = 1 << 31;
int xp = !(x & t);
int yp = !(y & t);
int p = x + ~y + 1;
return (!!(((!xp) & yp) | ((p & t) | !p))) & (!(xp & (!yp)));
}
ilog2
- 要求:返回x求以2為底的對數的結果 向下取整
- 操作符使用數量限制:90
- 思路:本題參照了陳志浩學長的答案。
解題算法的核心思想是二分查找,首先我們要明白這道題實際上想讓我們求的是什么,經過觀察我們可以得出結論,一個數求以2為底的對數的結果就相當于它二進制中位置最高的1的序號(序號從零開始由低位到高位)。那么我們需要做的就是查找并記錄這個位置最高的1的位置。
算法過程如下:- 如果x >> 16的結果大于0,那么可以說明最高位的位置至少是16,那么我們可以將結果的第4位置1(序號編號規則同上),因為2 ^ 4 = 16,反之置0說明結果小于16.
- 下面考慮兩種情況,如果第1步中x >> 16 大于0,說明我們需要在16位之后的第8位(第24位,相當于再二分)再進行二分查找,如果x >> 16小于0,那我們需要在16位之前的第8位(第8位,相當于再二分)進行查找,那么我們可以得出,下次查找時的范圍為x >> (8 + result) (result表示上一步得到的結果(0或16)),這個+result的意義可以認為是重新確定開始進一步二分查找的位置。
如果x >> (8 + result) 的結果大于0,那么說明結果(result)的第3位必為1,相當于在結果上加上了查找到的新位置,反之第3位應該仍為0. - 按照上面的思路繼續查找到不能再二分(偏移為x >> (1 + reuslt)),此時result中得到最終的最高位的位置。
算法描述起來比較難,參照代碼推理幾次就可以明白其中的巧妙之處:
int ilog2(int x) {
int result = 0;
int b4 = !!(x >> 16);
int b3 = 0;
int b2 = 0;
int b1 = 0;
int b0 = 0;
result = b4 << 4;
b3 = !!(x >> (8 + result));
result = result | (b3 << 3);
b2 = !!(x >> (4 + result));
result = result | (b2 << 2);
b1 = !!(x >> (2 + result));
result = result | (b1 << 1);
b0 = !!(x >> (1 + result));
result = result | b0;
return result;
}
第二部分 浮點數
所編寫的程序必須滿足如下要求:
- 只能使用函數參數與函數內聲明的局部變量
- 最多只能使用有限個運算符(等于號、括號不計),可以認為使用的運算符個數越少得分越高
- 禁止定義或使用任何宏
- 禁止定義任何函數
- 禁止調用任何函數(除了可以使用printf輸出中間變量,但提交時必須去掉)
- 禁止使用任何形式的類型轉換
- 禁止使用int、unsigned以外的任何類型(包括結構體、數組、聯合體)
- 禁止定義或使用任何浮點常量
也就是說在浮點數題目中,我們可以使用任意大小的整型數值,可以使用流程控制語句,可以使用任何操作符。
float_neg
-
要求:返回-f的位級表示
本題及以下所有的題目都采用unsigned int來存放位級表示
所有的浮點類型都為float
如果輸入為NaN,返回NaN
- 操作符使用數量限制:10
- 思路:對于一般的浮點數,我們只需要對它的符號位取反就可以了。需要特殊處理的只是無窮與NaN這兩種非規格化的情況
unsigned float_neg(unsigned uf) {
unsigned result = uf ^ 0x80000000;
if ((uf & 0x7F800000) == 0x7F800000 && (uf & 0x007FFFFF)) {
result = uf;
}
return result;
}
float_i2f
- 要求:實現由int到float的類型轉換
操作符使用數量限制:30
-
思路:
由于浮點數的表示中對于負數并沒有使用補碼的方式,正負號完全取決于符號位,所以對于負數輸入,我們需要做的第一步工作就是把它取負為正數再進行后面的操作。在這個過程我們需要記錄下正負,在之后的操作中需要使用。
由于浮點數與整數表示的不同,浮點數的有效數字的位置在第0-22位的23位中,并且第一個1在規格化表示中會被省略,我們只需要第一個1以后的位數,并且我們需要知道在浮點數表示中它的指數應該為多少,所以在這個過程中我們同時需要記錄第一個1出現的位置并以此決定指數。
在代碼中使用了一個i來記錄左移的位數,也就是最高位的1之前的零的個數,那么32-i就是最后的指數。
在循環中我們將整數的有效數字提前到了最前,然后將最高位移出, 這時我們用temp保存這時的狀態。供之后的舍入判斷使用。
接下來,我們需要將有效位移到正確的位置上,也就是向右位移9位。
下面按照之前的記錄把符號位置上正確的值。
現在已經處理好有效數字與符號部分,下面要做的就是處理指數部分。
之前說過32-i是指數的數值,注意我們需要將這個值加上偏移量127,再放入表示指數的位置中。
下面就要處理舍入的情況了,浮點數表示的舍入規則比較特殊,也是本題的難點。結合本題的情況進行介紹:
在右移之前我們保存了這時的狀態,因為當右移九位后原來的低九位如果有數據就會被舍棄,我們就需要根據舍棄的這九位與未被舍棄的最后一位(也就是原數第9位,下稱第9位)來判斷舍入的情況。
如果舍棄的這九位的最高位為0,那么說明舍去的數值小于保留下來的最低位表示的值的二分之一,那么我們不需要舍入。
如果舍棄的這九位的最高位為1,并且后面的位有數值,那么說明舍去的數值大于第9位表示的值的二分之一,這個時候我們需要舍入,也就是把最終結果加一。
如果舍棄的這九位的最高位為1,并且后面的位都是0,這個時候正好就是第9位表示的值的二分之一。那么這個時候我們就要看第9位,如果第9位為0,那么不舍入。如果第9位為1,那么進行舍入,也就是把最終結果加一。
unsigned float_i2f(int x) {
int i = 1;
int nega = 0;
unsigned temp;
unsigned result;
if (x & 0x80000000) {
nega = 1;
x = ~x + 1;
}
if (x == 0) {
return 0;
}
while ((x & 0x80000000) != 0x80000000) {
++i;
x <<= 1;
}
result = x << 1;
temp = result;
result >>= 9;
if (nega) {
result |= 0x80000000;
} else {
result &= 0x7FFFFFFF;
}
i = (32 - i) + 127;
result = (result & 0x807FFFFF) | (i << 23);
if ((temp & 0x00000100) == 0x00000100) {
if (temp & 0x000000FF) {
return result + 1;
} else {
if (result & 1) {
return result + 1;
} else {
return result;
}
}
}
return result;
}
float_twice
- 要求:返回2*f的位級表示
操作符使用數量限制:30
-
思路:
如果該浮點數是非規格化的,那么我們需要將它的有效數字部分左移一位就可以達到乘二的效果,這個過程需要注意兩個地方,第一是如果左移后如果有效數字的最高位溢出了,那么正好移到了指數部分成為了一個規格化的表示形式,所以我們無需擔心左移后有效數字溢出的問題。第二是左移后會導致符號位被移出,我們需要在位移之后手動置上原來的符號位。
如果該浮點數是規格化的,那么我們只需要將它的指數部分加一。
其他情況的應該直接返回原值。
實驗小結
作為CS:APP的第一個lab,絕大部分的題目在經過仔細思考與測試后是可以自主完成的,但是其中的bitCount與ilog2由于需要使用分治與二分查找的算法,自己想出來的難度還是比較大的,在卡了兩天以后還是去查了答案。