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

組合語言裡的迴圈、陣列與指標

迴圈不過是一條往回瞄準的分支,陣列不過是一個基底位址加上算術,指標不過是一個你拿來載入的數字。看看這三個親切的 C 語言觀念,如何溶解成寥寥幾條暫存器與分支指令——並認識那讓「對快取友善的程式碼」跑得快的位址算術。

迴圈就是一條往回指的分支

在第 1 篇導覽裡,我們看著一個 if/else 變成不過就是一條條件分支——先測試,再來一個跳躍,它要嘛直接落下去、要嘛往前跳過一段。迴圈是用一模一樣的這塊樂高積木搭起來的,只多一個轉折:跳躍瞄準的是往回,瞄向一條我們已經跑過的指令。記得程式計數器平常只會往前走,一條接著一條。分支是唯一能把它拎起來、再放到別處的東西。讓它指向一個較早的位址,機器就會把同一段指令再跑一次——這,沒有更神祕的東西了,就是迴圈

想想你會怎麼親手寫出 `for (i = 0; i < n; i++)`。你需要一個暫存器存著計數器 i、一個在頂端測試 `i < n` 的辦法、夾在中間的迴圈本體、一次遞增,以及底端一條跳回測試處的分支。編譯器通常會把它塑形得很巧妙——頂端測試一次、底端往回分支——所以每一輪迭代真正的代價,就只有迴圈本體、加一次遞增、加一條分支。你寫過的每一個迴圈,整副骨架都塌縮成這樣。

# C:  sum = 0;  for (i = 0; i < n; i++)  sum += a[i];
# x10 = base address of a[]   x11 = n   x12 = sum   x13 = i

      li    x12, 0           # sum = 0
      li    x13, 0           # i = 0
loop: bge   x13, x11, done   # if i >= n, leave the loop
      # ... body goes here (next section) ...
      addi  x13, x13, 1      # i++
      j     loop             # jump BACKWARD to the test
done: # ... sum (x12) is ready ...
RISC-V 裡的一個計數迴圈。底端的「j loop」就是那條往回跳;「bge ... done」則是決定何時停下的離開測試。

陣列就是一個基底位址加上算術

現在來看迴圈本體:`sum += a[i]`。在 C 裡,`a[i]` 感覺像一個有魔法的單一步驟。硬體完全不曉得 `a[i]` 是什麼意思——它只認得位址。所以編譯器必須算出 a[i] 住在哪裡。陣列在記憶體裡的排法,是一連串大小相等的元素一個緊接一個地塞著,所以第 i 個元素的位址,就只是 `base + i x element_size`。如果 a[] 裝的是 4 位元組的整數,那麼 a[i] 的位址就是 `base + i x 4`。這個「先乘再加」就是陣列索引的全部祕密;這底下根本沒有什麼陣列型別,只有這個小小的加總。

一旦位址進了暫存器,把值取出來就是一條載入。回想 ISA 那一級:RISC-V 是一台載入-儲存機器:算術只碰暫存器,而搆到記憶體的唯一辦法就是載入或儲存。我們這裡用的「基底加位移」形狀——「位址在這個暫存器裡,再加一個小常數」——是機器寥寥幾種定址模式之一,而它正好完美對應到 `base + i x 4`。所以一次乘、一次加、一條載入,就把親切的 `a[i]` 變成矽片真正做得出來的東西。

  1. 把索引縮放成位元組位移。將 i 乘上元素大小:對 4 位元組的 int 來說,就是 i x 4。編譯器會把這做成一次左移(左移 2 位就等於乘以 4,每位移一位就乘 2 的一次方),因為位移遠比一般的乘法便宜。
  2. 加上基底位址。取出陣列的基底位址(就坐在某個暫存器裡),把那個位元組位移加上去。現在有一個暫存器握著 a[i] 的確切位址——這正是一個指向該元素的指標。
  3. 透過它載入。用「基底加位移」定址發出一條載入——「這個暫存器裡的位址,加上 0」——a[i] 的值便抵達某個暫存器,準備好給接下來的算術使用。

指標:位址就只是一個數字

這裡是整篇導覽安靜的笑點:在金屬層級,指標一點也不特別。它就只是一個剛好存著位址的暫存器(或記憶體格子)——一個指名記憶體裡某個地方的數字。回頭看那段陣列程式碼:`x14` 存的是 `base + i x 4`,這正是一個指向 a[i] 的指標。「對指標解參考」不過就是從它存著的位址做一次載入;`0(x14)` 就是 `*p`。C 在整數與指標之間畫了一條粗粗的觀念界線,但硬體把兩者都看成暫存器裡的位元圖案,而同一條加法指令可以推進其中任何一個。

這解鎖了一種更俐落的走陣列方式。與其每一輪都重算 `base + i x 4`,不如保留一個會移動的指標,每次就把它加上一個元素大小。迴圈握著一個位址、透過它載入、再加 4 跨到下一個元素。這就是 C 指標算術投在組合語言上的影子——對一個 int 指標做 `p++`,暗地裡加的是 4 而不是 1,因為編譯器會按元素大小縮放。硬體並沒有什麼縮放魔法;是編譯器替你把那個 4 烤了進去。一個誠實的提醒:一個指標指名了某個位址,並不代表那個位址就輪得到你碰——一個走偏的指標會開開心心地載入垃圾,或讓程式當掉。

迴圈與快取相遇之處

在這裡,我們剛剛寫的組合語言,悄悄碰到了整個學科裡最深的效能觀念。當那條 `lw` 依序走過 a[0]、a[1]、a[2]……,它讀的是一個接一個的相鄰位址。記憶體硬體愛死這個了。快取——那張放著你最常用的書、好讓你很少需要走去圖書館的小書桌——一次抓回一整條快取線(譬如 64 位元組),而不是一次一個 int。所以一趟去記憶體的旅程,會帶回 a[i] 連同它的十六個鄰居。接下來的十五輪迭代,便發現自己的資料早已躺在書桌上了。這就是空間區域性:用了某樣東西,會讓附近的東西在接下來變得很便宜。

現在反過來。用一個很大的跨步走同一個陣列——碰 a[0]、然後 a[1000]、再 a[2000]——於是每一次載入都落在一條快取沒見過的全新快取線上。每一次都是一次快取未命中,一趟慢慢走去真正圖書館的路。指令幾乎一模一樣;它們產生的位址卻不一樣。這就是為什麼對快取友善的程式碼,能比那種算出一模一樣答案、卻對快取不友善的程式碼快上好幾倍。你迴圈裡的算術通常是免費的;真正決定你速度的,是那些位址所描出來的記憶體存取樣式。

值得帶著往前走的東西(外加一句誠實話)

退一步看,這三個觀念其實押著韻。迴圈是一條改寫程式計數器的往回分支。陣列索引是一個建出位址的「先乘再加」。指標就只是那個坐在暫存器裡、由載入解參考的位址。分支、位址算術、載入——還是第 1 篇導覽那一小撮詞彙,只是現在被排起來,去嚼穿一整批一整批的資料。所有更豐富的東西(雜湊表、鏈結串列、樹),都是用這幾個動作搭起來的,差別只在於位址用更花俏的方式算出來。

最後一句誠實話,跟這一級反覆說的同一句:今天幾乎沒有人會為了上線產品親手寫這樣的迴圈——編譯器會做這件事,而且通常比人做得好,因為它能在我們搆不到的規模上玩弄暫存器與指令排程。讀這篇導覽的理由,不是要在寫程式上贏過編譯器。而是當效能分析器指著一個熱迴圈、或一個指標臭蟲在某個令人摸不著頭緒的位址當掉、或你程式的某個版本莫名其妙比另一個慢十倍時,你能讀懂組合語言、親眼看見機器到底在做什麼——那些位址、那些載入、那個記憶體樣式。如今,讀懂機器的語言,遠比寫出它來得重要。