一條指令就是一個數字
第 1 篇介紹了那份契約;第 2 篇認識了契約所命名的暫存器與程式計數器。現在我們問一個最具體、最物理的問題:當 CPU 取出一條指令時,它到底取到的是什麼?最誠實的答案,直接來自儲存程式的想法,就是一個數字——一串躺在記憶體裡的位元圖案,本質上與程式所運算的資料毫無不同。一條 RISC-V 加法指令和整數 0x00B50533 是同樣的 32 個位元;唯一讓其中一個成為「指令」的,是程式計數器把機器指向它,並說「執行這個」。
於是 ISA 需要一本讀這個數字的規則手冊:一套把固定寬度的字組切成具名格子的方法,每個格子回答一個問題。這是哪種操作?它用了哪些暫存器?裡面有沒有烤進一個常數?這本規則手冊就是指令格式,而所謂解碼一條指令,無非就是在約定好的邊界把它的位元切開,再把每個欄位讀出來。哪怕只把邊界弄錯一個位元,你讀到的就會是另一條毫無意義的指令——這正是為什麼這些邊界在契約裡被永遠釘死。
撬開一個 R 型字組
我們來拆一條真的。R 型格式處理暫存器對暫存器的算術——add、sub、and、xor 之類。RISC-V 把它的 32 位元切成六個欄位。從低位往上:一個 7 位元的運算碼(opcode),接著三個 5 位元的暫存器編號(目的 rd,然後是來源 rs1 與 rs2),再加上兩個叫 funct3(3 位元)與 funct7(7 位元)的額外欄位,它們與運算碼一起,精確地釘死這到底是哪一種操作。為什麼每個暫存器用 5 位元?因為 2^5 等於 32,而 RISC-V 剛好有 32 個架構暫存器要命名。沒有一處是隨便定的;每個欄位的寬度,都是照它必須裝下的東西量身裁的。
Instruction: add x10, x10, x11 ( x10 <- x10 + x11 ) = 0x00B50533
31 25 24 20 19 15 14 12 11 7 6 0
+------------+--------+--------+------+--------+-------------+
| funct7 | rs2 | rs1 |funct3| rd | opcode |
| 0000000 | 01011 | 01010 | 000 | 01010 | 0110011 |
+------------+--------+--------+------+--------+-------------+
7 bits 5 bits 5 bits 3 bits 5 bits 7 bits
=11 =10 =10 = R-type ALU
opcode 0110011 says 'register arithmetic';
funct3=000 + funct7=0000000 together say 'this one is ADD'.把這張圖讀懂,整條指令就全攤在眼前了。運算碼 0110011 宣布了大類別(暫存器算術);funct3 與 funct7 再把它收窄到剛好是 add,而不是 sub 或 xor。暫存器欄位裝著 01010 與 01011——也就是數字 10 與 11——於是機器讀取暫存器 x10 與 x11,再把總和寫回 x10。這裡完全沒有立即值欄位,因為兩個運算元都已經住在暫存器裡。這正是 R 型的招牌特徵:它是純粹的暫存器對暫存器工作。
小常數住在哪裡:立即值
有很多指令需要一個不在任何暫存器裡的常數——把某個東西加 10、從位移 8 載入、往前跳 40 個位元組。RISC-V 把那個常數以立即運算元的形式,直接帶在指令本身裡面。為了騰出空間,I 型格式沿用了低位的欄位(opcode、funct3、rd、rs1),但把 rs2 與 funct7 換成最高位的一個 12 位元立即值。於是像 addi x11, x11, 10 這樣的指令,就是一個來源暫存器、一個目的暫存器,外加那個字面值 10 搭在那 12 個位元裡。沒有第二次暫存器讀取,沒有記憶體存取——那個數字就單純地在那裡。
可是 12 位元是個矮短的東西,而機器是對全寬度的 32 位元(或 64 位元)值做運算的。一個 12 位元的常數,要怎麼頂替一個 32 位元的數字?靠的是符號擴展,而在這裡,我們在資料那一級認識的二補數設計漂亮地派上了用場。立即值的最高位就是它的符號位;硬體在使用這個值之前,單純地把那個位元往左複製,填滿所有缺掉的高位。複製一個 0,數字就維持正的;複製一個 1,它就維持負的——而關鍵在於,同一個位元圖案,在 12 位元或 32 位元下都代表同一個值。一條小小的規則,就讓一個短欄位能承載整個範圍的小型有號數。
具體追一遍。數字 -4 當成 12 位元立即值是 1111 1111 1100。它最左邊的位元是 1,所以是負的,硬體就把那個 1 複製進所有缺掉的 20 個高位,做出 32 位元的值 1111 1111 1111 1111 1111 1111 1111 1100——依然剛好是 -4,只是更寬。改餵它一個正立即值,例如 +5(0000 0000 0101),最前面的 0 就被複製:它在全寬度下維持 +5。同樣的位元、同樣的意義,每次皆然——這就是整個把戲。
為什麼要固定寬度、規律的欄位
RISC-V 有少數幾種格式——R、I、S(給儲存)、B(給分支),還有再幾種——但它們刻意做得幾乎一模一樣。運算碼永遠坐在同樣的低位 7 個位元裡,而暫存器編號欄位 rs1、rs2、rd 只要出現,就永遠坐在同樣的位元位置上。這份規律性是一份禮物,不是巧合。因為 rs1 永遠在第 15 到 19 位元,硬體可以在字組一抵達的瞬間就開始取出那個暫存器的值,在它甚至還沒解碼完運算碼之前。契約的整潔,化成了赤裸裸的解碼速度。
有一個小皺褶值得誠實交代。B 型分支的立即值看起來是亂的——它的位元被打散存放,不是一塊整齊連續的欄位。這看起來很乖張,直到你看見原因:這個編碼是這樣選的,好讓每一個立即值位元都盡可能落在它在 I 型與 S 型裡所占的同一條線路上。設計者把分支立即值打散,正是為了讓符號擴展與繞線的硬體能在各格式之間共用。所以連那唯一一處看似的混亂,存在的目的也是為了讓硬體更簡單。規律性才是目標;那場打散是在為它服務。
從編碼位元到動作——以及 CISC 的轉折
一旦欄位被切出來,一小塊硬體——解碼器——就把它們轉成操控晶片其餘部分的控制訊號:讀這兩個暫存器、叫 ALU 做加法、把這個立即值做符號擴展、把結果寫到這裡。我們會在資料路徑那一級好好打造那個解碼器;現在,先把這條鏈當成一條意義的小小流水線記在腦裡。
- 取指:程式計數器交出一個位址;那個 32 位元的指令字組從記憶體被讀出。
- 解碼:在固定邊界把字組切開——運算碼、funct3/funct7、rd、rs1、rs2、立即值——再把每個欄位的意思讀出來。
- 備妥運算元:讀取被指名的暫存器,並把任何立即值符號擴展到全寬度。
- 執行並寫回:ALU 做被指名的運算;結果落進 rd,程式計數器則前進到下一個字組。
現在來說那個誠實的轉折,它讓編碼對 RISC 對 CISC 的故事至關重要。RISC-V 那固定的 32 位元字組切起來輕而易舉——下一條指令永遠落在 4 個位元組之後。像 x86 這樣的 CISC 用的是變長指令,從 1 個位元組到 15 個都有,所以解碼器在還沒把這一條部分解析完之前,甚至無法判斷下一條從哪裡開始。這讓寬幅、平行的解碼變得實實在在地更難、更耗電。不過這並不會讓 x86 變慢:現代 x86 晶片把每條複雜指令拆成小而一致、類似 RISC 的微指令(micro-ops),再把那些放在一台快速的內部引擎上跑。