JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

定址模式與載入-儲存

每一條載入與儲存指令都得回答同一個問題:是記憶體裡的哪一個位元組?來認識一個乾淨的 ISA 所提供的那份簡短定址模式菜單,以及那條把每一次緩慢記憶體存取都關進僅僅兩種指令裡的「載入-儲存」規則。

每一次記憶體存取都必須回答的那一個問題

我們認識過的記憶體模型,是一條又長又平的位元組陣列,每個位元組都有自己的數字位址。一條載入或儲存指令會伸進那條陣列,但它得先算出是哪一個位址——而 ISA 允許它算出該位址的那份方法菜單,就是它的一組定址模式。這聽起來像個註腳,實際上卻是一個 ISA 裡最關鍵的選擇之一:它決定了指令流能有多緊湊、多規律,以及硬體能把它解碼得多快。第 1 篇導覽只勾勒了這個想法,這裡我們要把它打開。

讀懂任何一個定址模式的好辦法,是把它當成一道算出有效位址的小公式——也就是這次存取最終落在的那個位元組編號。「把某個暫存器的內容加上一個小常數」是這樣一道公式;「取我們現在的位置,再往前跨一步」是另一道。一個 CISC 的 ISA 可能提供十幾道華麗的公式,包括在單一模式裡讀一個暫存器、把它放大、加上第二個暫存器、再加一個常數的那種。RISC-V 刻意只提供小小一撮,因為每多一個模式,就是解碼器在每一條指令上都得多做的工,無論那條指令用不用得到它。

基底加位移:那匹主力馬

RISC-V 日常存取資料的定址模式是基底加位移(也叫基底加偏移)。你把它寫成 `offset(rs1)`,有效位址就是暫存器 rs1 裡的值,加上那個常數位移。暫存器存的是基底——常常是一個陣列、一個結構或當前堆疊框的起點——而那個常數位移挑出相對於該基底的某一個格子。因為位移是一個烤進指令裡的小常數,它是個立即值,ISA 只在它身上花幾個位元(RISC-V 是十二個),這已足以涵蓋真實程式所需的那些不大的距離。

一旦你看見編譯器有多依賴它,這單單一個模式就表現得驚人地有力。一個陣列元素 `a[i]` 變成:把 `a[i]` 的位元組位置算進一個暫存器(那就成了基底),再用位移 0 載入。一個位在結構裡第三格的欄位,若每格四位元組,就只是 `12(rs1)`。一個區域變數住在離堆疊指標固定位移的地方,所以 `8(sp)` 就能直接讀到它。一道公式——暫存器加常數——默默地把陣列、結構與區域變數通通涵蓋了。這就是選對一小組、而非選一大堆,所換來的回報。

# Loading a[i] (4-byte ints) where a's start is in x10, i is in x11.

  slli  x12, x11, 2      # x12 = i * 4      (shift left by 2 = times 4)
  add   x12, x10, x12    # x12 = &a[0] + i*4   <- this register is the BASE
  lw    x13, 0(x12)      # x13 = memory[x12 + 0]   <- base-plus-offset, offset 0

# A struct field: if 'p' (in x14) points at a struct and the field
# we want sits 12 bytes in, no extra add is needed at all:

  lw    x15, 12(x14)     # x15 = memory[x14 + 12]   <- the offset does the work
基底加位移實際運作。暫存器提供算好的基底;立即位移搆到某個特定格子。同一個模式同時服務陣列與結構欄位。

搆到程式碼:相對於 PC 的定址

載入與儲存搆到的是資料;分支與跳躍搆到的是程式碼,而它們用的是一個密切相關的模式,叫做相對於 PC 的定址。一條分支不是直接寫出絕對的目標位址,而是帶著一個位移,把它加到當前的程式計數器上,產生目的地。「如果 x11 不為零,就從這裡往回跳 40 個位元組」——一個迴圈就是這樣收尾的。這個模式是同一種口味的公式——一個基底(這裡是 PC)加上一個常數位移——只是套用在指令流上,而不是資料陣列上。

相對於 PC 的定址帶來一份安靜但實在的紅利:它讓程式碼變成位置無關的。因為每個分支目標都被描述成「離當前指令的距離」,而不是一個固定位址,整段程式碼就可以被載入到記憶體的任何地方都還能運作——每一個內部跳躍仍會正確落點。這正是作業系統把程式與共享函式庫放到任何空閒位址時所需要的。同一個簡單的基底加位移想法,對準 PC,就幾乎免費地買到了可重定位的程式碼。

載入-儲存規則,以及它為何值回票價

這一切都活在一個決定性的選擇之下:RISC-V 是一個載入-儲存架構。規則很直白,值得再精確地說一次——算術與邏輯指令的運算元只能是暫存器,絕不能是記憶體。資料在記憶體與暫存器世界之間穿越的唯一途徑,是那兩條記憶體指令:載入把一項東西從記憶體複製進暫存器,儲存把一個暫存器的值複製出去到記憶體。沒有像 CISC 機器允許的那種「在單一條指令裡把一個記憶體位置加到暫存器上」。

為什麼要接受這份表面上的不便?因為它在三個方面回本。其一是規律性:每條指令都一樣長,多數都有相同的「只用暫存器」形狀,所以解碼器與管線保持簡單又快——這就是結構交給微結構的那份禮物。其二,它把那個緩慢、延遲不定的操作——碰記憶體——關進兩種標示清楚的指令種類裡,而不是讓它躲在某條算術指令裡面。其三,當值被提早拉進暫存器檔,編譯器就能把一個載入過的值重複使用許多次,而不必再去讀記憶體,這是現有最有效的最佳化之一。

一個位址究竟落在什麼上:位元組、對齊、位元組順序

一個有效位址是一個位元組編號,但載入與儲存有寬度之分——載入一個位元組、半字、一個字、一個雙字。隨之而來兩道誠實的皺褶。資料對齊:硬體偏好(有些 ISA 甚至要求)一個 4 位元組的載入坐在 4 的倍數位址上、一個 8 位元組的載入坐在 8 的倍數上,依此類推。一次自然對齊的存取,正好塞進一次記憶體傳輸與一條快取線裡;一次未對齊的存取則可能跨在兩條之間,多花工夫,或在嚴格的機器上直接觸發例外。編譯器會精準地替結構補上填充位元組,好讓欄位保持對齊。

位元組順序(endianness):當一個多位元組數字坐在以位元組定址的記憶體裡,哪一個位元組排在最低的位址——最高有效的還是最低有效的?大端機器把最高有效的位元組擺在前面;小端機器把最低有效的擺在前面。兩者都不算錯,但記憶體模型必須釘死一種,因為一個用某種慣例寫入、卻用另一種慣例讀出的值,出來會是亂的。這在位元組離開晶片的那一刻就要緊起來——跨越網路,或在不同方式打造的機器之間共享一個檔案時。它一直隱形,直到它突然不再隱形。

關於位移如何搆到它的全寬度,還有一個誠實的細節。一個 RISC-V 位移只有 12 位元,但一個位址有 32 或 64 位元寬。在被加到基底之前,位移會先被符號擴展——把它最高的位元往左複製,填滿那個更寬的欄位——這樣一來,像 -8 這樣的小位移也仍能正確運作,讓 `-8(sp)` 能搆到剛好在堆疊指標下方的格子。同一個讓短立即值頂替成全寬度數字的把戲(在第 3 篇導覽見過),正是讓位移能從基底往上、往下兩邊都搆得到的東西。

一次載入的代價,以及在下游等著的危害

ISA 那幅乾淨的圖還藏著最後一個真相:一次載入並不免費,甚至連可靠地快都談不上。一次資料已經坐在快取裡的載入,幾個週期就回來了;一次一路未命中到主記憶體的載入,可能要花上數百個週期。定址模式算出是哪一個位元組,但取它要花多久,完全取決於底下的記憶體階層——那是微結構的故事,不是 ISA 的故事。這就是為什麼把資料安排得讓存取落在鄰近、近期用過的位置上(良好的局部性),能讓一模一樣的程式碼跑快上許多倍。

就算一次載入命中了快取,它也在下游埋下一個微妙的陷阱。在一顆管線化的晶片裡,被載入的值不會在載入發出的那一刻就準備好;它要晚一個階段才抵達。如果緊接著的下一條指令就想用那個值,管線就得等待——這就是載入-使用危害,也是為什麼一條載入通常最好擺在比需要它結果的那條指令早一點的地方。編譯器重排程式碼,正是為了把有用的工作塞進那個空檔裡。