vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

Core 1 把一個旗標寫成 true,Core 0 反覆讀它,讀到的永遠是 false——這兩顆 Cortex-A9 焊在同一顆 Cyclone V SoC 上,中間還有一顆理論上該負責同步快取的 SCU。

兩顆 Cortex-A9 明明同 SoC,為什麼快取死活不一致

者的嗜好是有空就從一堆開發板裡隨手抓一塊出來,想辦法讓它跑起來。這次抓到的是 Terasic DE0-Nano-SOC——一塊大約在 2015 年前後推出的小板子,核心是 Altera Cyclone V System on Chip,裡面焊了兩顆 ARM Cortex-A9。整個專案是純 Rust bare-metal(#![no_std]#![no_main]),沒有作業系統,也沒有套用哪家的完整 SDK:作者用了 aarch32-rt 這類現成的 crate 來把第二顆核心帶起來,但 SCU、L2C-310、L1 I/D 快取的驅動,是他照著 Altera 的版本一段一段仔細移植過來的,出了問題也沒有 kernel log 可以翻。作者把 MMU 頁表、L1/L2 快取、SMP 模式、SCU 都照著文件設完,最後只用一個最陽春的方法驗證成果:讓一顆核心寫一個共享旗標,另一顆核心讀。結果不是「成功」,也不是「一望即知哪裡錯」,而是一個讓人卡住的、不上不下的失敗——比起一次乾脆的當機,這種「每一段拆開來看都對,合起來卻不對」的失敗,通常更花時間查,因為沒有任何一段能被直接排除,只能一段一段重新核對。

這類低階多核心開機程式碼的麻煩之處,在於它把好幾層平常各自獨立運作的機制疊在一起——記憶體管理、快取管理、核心間協定,只要其中一層跟另一層對「目前狀態」的認知對不上,就沒有任何一層會主動報錯。接下來的排查,就是沿著這幾層一段一段拆開來核對。

Core 1 寫下 true,Core 0 讀到的還是 false

測試本身很簡單:宣告一個共享的 AtomicBool,Core 1 跑起來之後把它設成 true;Core 0 在另一邊反覆讀這個值,等它從 false 變成 true。前面每一步都照計畫走——作者自己的說法是「成功了!一路到最後那一步都沒問題」,Core 1 真的跑起來,也真的把旗標寫成 true。問題出在最後那一步的驗證:Core 0 從頭到尾沒有觀察到旗標從 false 變成 true

拖曳分隔線比較預期畫面與實測畫面 · 2 個畫面

預期畫面 flag = true Core 0 看到 true Core 1 看到 true 實測畫面 flag = false flag = true Core 0:卡住不動 Core 1:已寫入

互動圖表

理論上寫入應該讓兩顆核心同時看到同一個值,作者實測到的卻是 Core 0 停在舊值、Core 1 看到新值,兩者分岔。

這件事本身違反了 SMP 系統最基本的假設。作者在文章裡把話說得很白:兩顆核心對同一個記憶體位址看到完全不同的值,這件事「不應該發生,而且幾乎會弄壞任何一個 SMP 系統」。Cortex-A9 這類多核心設計內建了一顆 SCU(Snoop Control Unit),專門負責在多顆核心的 L1 快取之間互相「偷看」彼此的異動——理論上只要這條機制真的生效,Core 1 寫完之後,Core 0 下一次讀取就該看到新值,中間不需要程式碼再做任何額外的同步。

這種「一顆核心寫、另一顆讀,看有沒有看到」的做法,是多核心系統裡最原始也最直接的快取一致性測試。鎖、佇列、訊息傳遞這些更貼近日常開發的同步機制,全部建立在同一個更基本的假設上:一顆核心寫完的值,另一顆核心遲早要讀得到。這個假設一旦在最底層就不成立,上面疊的任何同步機制都只是蓋在錯的地基上。

麻煩的地方是,這不是一次「程式當掉、印出錯誤訊息」的失敗——快取一致性壞掉沒有例外、沒有 panic,只有一顆核心永遠停在舊值。作者能做的,只有把整條從 MMU 到 SCU 的設定路徑,一段一段拆開來核對。

頁表、記憶體屬性都照規格書設了

既然沒有作業系統,也就沒有分頁保護、沒有使用者/核心模式切換這些通常靠 MMU 撐起來的功能——這裡的虛擬位址跟實體位址是 1:1 對應,MMU 開著,純粹是為了替每一段記憶體掛上正確的快取屬性,而不是為了做位址轉換或隔離。第一段先確認記憶體地圖本身沒設錯。作者把 1024 MiB 的 DDR SDRAM 映射到位址 0x0000_0000,把 256 MiB 的系統/MPCore 周邊暫存器映射到 0xF000_0000;除錯輸出走 UART0,傳送保存暫存器(THR)在 0xFFC0_2000,線路狀態暫存器(LSR)在 0xFFC0_2014。第一層頁表用 4,096 個 32-bit entry,每個對應 1 MiB 的虛擬位址空間,合計 16 KiB——標準的 ARMv7 short-descriptor 配置。這塊板子沒有螢幕,但板上有一顆 USB 轉序列埠的轉換器:micro-USB 線接上電腦、開 minicom 就能跟板子互動——作者靠這條 UART 確認「程式到底跑到哪一步」,後面每一次核對某個暫存器有沒有設對,靠的都是從這兩個位址送出去的文字。

光是把這張頁表放進記憶體,就先撞上一個跟快取一致性無關、但很說明這個專案細膩程度的小麻煩:Rust 預設會把這種結構對齊到 1 MiB 邊界,而作者是透過 115,200 baud 的 UART 把整份映像檔灌進板子——多出來的填充位元組會直接拖慢載入速度。作者的解法是自己寫一個不強制對齊的型別,改用連結器 section 去控制實際對齊位置,繞開 Rust 內建的規則。在這種透過序列埠一行一行灌韌體的開發迴圈裡,多出來的每一個位元組都要等對應的傳輸時間;把頁表這種本來只有 16 KiB 的結構,因為對齊要求膨脹成 1 MiB,等於每次燒錄都要多等一段不必要的時間——這類細節平常不會被算進「快取一致性除錯」的範疇,卻反映出作者在這塊板子上,連最基礎的開發迴圈都得自己一件一件磨。

真正跟快取一致性有關的是記憶體屬性的標法:DDR 那 1 GiB 標成 Inner Cacheable、write-back write-allocate;周邊暫存器那一段則標成 shareable device memory。這正是 SMP 快取一致性要求的標準寫法——如果這一段設錯,後面不管怎麼調 SCU 都沒用。作者是這樣設定的。

這幾個字看起來像是規格書裡的樣板選項,但每一個都有具體作用:cacheable 決定這段記憶體能不能被快取起來、要不要走 write-back;shareable 則決定這段記憶體要不要參與硬體的一致性機制——周邊暫存器如果沒標成 shareable,就算 SCU 再怎麼盡責,也不會有人幫你同步。反過來說,這一段設對了,只代表「這塊記憶體有資格參與快取一致性」,不代表「快取一致性這件事真的會發生」——資格跟結果,是兩回事。

快取開機順序、L2C-310 也一步步帶起來了

確認記憶體屬性沒問題之後,下一段是每顆核心各自的開機順序:先設定並打開 MMU,接著才處理快取——L1 的指令快取與資料快取都是先 invalidate 再 enable,兩顆核心共用的 L2 也照同樣的方式帶起來。這個「先清空、再打開」的順序,作者自己的說法其實留了餘地:他寫的是「我想技術上你應該也要在開快取之前先把快取內容 invalidate 掉」,理由是怕處理器從 reset 出來的時候沒有自動把快取清乾淨——這是他自己的推測,不是他從哪份文件抄來的硬性規定。這一段兩顆核心各自獨立完成,互相不需要協調——真正需要協調的部分,要等到 SCU 跟 ACTLR 那一段才會出現。

兩顆核心共用的 L2 層級,走的是 Arm 的 Corelink L2C-310——Altera 向 Arm 買來、整合進 Cyclone V 的一顆 IP。作者的原話是「把它帶起來相當痛苦」,這顆控制器不像 L1 快取那樣有單一位元可以核對,牽涉的設定步驟比較多。

這裡有一個原文沒有明說、但架構上值得放在心上的細節:SCU 保證的「互相看得見」,嚴格來說只涵蓋兩顆核心各自的 L1——它是 Cortex-A9 這顆處理器核心內建的機制,只認得住在核心裡的快取。L2C-310 是另一顆獨立的控制器,不屬於 SCU 直接管轄的範圍。合理的推測是,如果問題真的出在快取一致性,L2 這一層、或是 L2 跟 SCU 之間的介接方式,會是值得一併懷疑的地方——但這只是推測。作者本人對 L2 的判斷是另一回事:他說自己「並不真的認為要開 L2 才能讓 SMP 動起來」,只是驅動既然都寫了就順手帶起來;而 SCU、L2C-310、L1 I/D 快取這三份驅動,他的說法是自己「仔細地把 Altera 的驅動移植了過來」。也就是說,這一段他不是沒碰過,而是碰過之後認為它不是必要條件。

最後,Core 1 本身要靠 Core 0 動手喚醒:透過 SoC 的 reset manager 周邊暫存器,把 Core 1 從 reset 狀態放出來。喚醒之後,兩顆核心跑的是同一份程式碼。到這一步為止,兩顆核心各自的開機路徑都已經走完,接下來要看的是把它們接在一起的那一段。

點任一元件看它做了什麼、又不知道什麼 · 4 個元件

Core 0 L1 I/D cache SCU Snoop Control Unit Core 1 L1 I/D cache Corelink L2C-310 兩核心共用的 L2 DDR SDRAM(1 GiB)

Core 0 · 做了什麼

先設定並打開 MMU,再 invalidate 後 enable L1 的 I/D 快取與 L2 快取,把 ACTLR 的 SMP 位元設起來,enable SCU 並 invalidate Core 0 的 SCU 項目,把 Boot ROM 重新映射回位址 0x0,最後透過 SoC 的 reset manager 周邊把 Core 1 從 reset 狀態放出來。

不知道:Core 1 什麼時候真的開始跑、寫了什麼進共享記憶體。

SCU · 做了什麼

先 invalidate 自己記錄的項目再 enable——理論上兩顆核心的 L1 快取內容要靠它互相 snoop 才能保持一致。

不知道:唯一具體卡住的地方是,兩份 Arm Cortex-A9 TRM 對 SCU_CTRL.EN 這個位元,一份說設 1 才是啟用,另一份說設 0 才是啟用——而作者兩個值都試過,兩個都沒動(Linux 用的是 1)。

Corelink L2C-310 · 做了什麼

Altera 向 Arm 買來整合進 Cyclone V 的 IP,作者形容「把它帶起來相當痛苦」,負責兩顆核心共用的 L2 層級。

不知道:自己有沒有正確設定完——它不像 L1/SCU 那樣有清楚對應的單一狀態位元可以核對。

Core 1 · 做了什麼

被 reset manager 放出來後跑同一份程式碼,也把 ACTLR 的 SMP 位元設起來,接著把共享的 AtomicBool 旗標寫成 true。

不知道:Core 0 到底有沒有「看到」這次寫入——實測結果是沒有。

兩顆核心都宣告了 SMP,SCU 也 enable 了——問題是「啟用」兩個字,兩份手冊講法相反

剩下的最後一段設定,是把兩顆核心真正接進快取一致性機制:兩顆核心都要在 ACTLR(Auxiliary Control Register)裡,把自己標記成處於「SMP」模式——這是理論上讓核心之間能看見彼此快取的開關之一。SCU 這邊也走同樣的流程:先 invalidate 掉 SCU 記錄的項目,再把它 enable。跟 L1 快取一樣,這裡的順序邏輯也是「先清空、再信任」。原文對 SCU 的描述其實只有一句:它是一塊「盯著每顆處理器進出什麼、然後去 invalidate 另一顆處理器快取」的硬體。至於它為什麼需要先 invalidate 再 enable,合理的推測是,SCU 內部維護一份記錄哪些位址被哪顆核心快取住的表,如果不先 invalidate 就 enable,這份表一開機就可能帶著開機前殘留的錯誤內容,SCU 自己都不確定手上的紀錄是不是準的,更不用談它拿這份紀錄去仲裁兩顆核心之間的一致性——這是從機制推回去的解釋,不是原文寫的。

ACTLR 的 SMP 位元本身邏輯很單純。原文只交代兩顆核心開機時都要把自己標成 SMP 模式,沒有說不標會發生什麼事;合理的推測是,沒開這個位元,核心會把自己當成獨立運作的單一處理器,直接放棄參與快取一致性協定,等於把 SCU 整套機制在自己這一側關掉。兩顆核心都確實開了這個位元,這一步至少不是問題——但「沒有把自己排除在協定之外」跟「協定本身正確運作」,同樣是兩件不能互相取代的事。

就是在這一步,作者撞上了全文唯一被明確點名的具體矛盾:他手上兩份不同版本的 Arm Cortex-A9 技術參考手冊(TRM),對 SCU_CTRL.EN 這個位元的說法完全相反——一份說要把它設成 1 才是啟用,另一份說要設成 0 才是啟用。這不是排版錯字或格式差異,是同一顆位元,兩份官方文件給出互斥的語意——對一個只能靠文件、沒有其他人可以問的業餘專案來說,這種矛盾特別致命:正常情況下遇到看不懂的地方,可以去問同事、去查 errata,但兩份「官方」說法互相打架時,只能去找別人實際怎麼做——作者查到的參照是 Linux,它設的是 1

同一顆位元,SCU_CTRL.EN作者手上兩份不同版本的 Arm Cortex-A9 Technical Reference Manual,對這個位元該設 1 還是設 0 才算「啟用 SCU」,講法完全相反——這是全文裡唯一有具體佐證的矛盾點。作者兩個值都試過了,用他的話說「反正兩種設法都不會動」——Linux 用的是 1。,兩份 Arm 官方文件各說各話。

換句話說,作者不是漏做了某一步,而是在「到底該不該把某個位元設成 1」這個問題上,連 Arm 自己的文件都給不出一致的答案。但最直覺的那個實驗,他已經做完了:兩個值都試過,用他的原話說「反正兩種設法都不會動」;他還補了一句參考資訊——Linux 是設 1。所以這不是「選錯值」那麼單純的問題:連慣例上該填的那個值,填下去也一樣不動。

把整段開機順序攤開重看一次,會發現每一步各自看起來都合理,串起來卻沒有一步能明確標成「這裡就是破綻」:

拖曳把手看開機順序每一步做了什麼 · 8 個步驟

step 0
拖曳把手,看每一步的狀態

如果哪一步藏著明確的錯誤,理論上應該會在某個特定步驟上看到狀態卡住、或是跟前一步矛盾——但把這八步逐一攤開之後(順序就照原文列出的 Core 0 開機清單),看不出哪一步本身有問題。這正是這起案子難查的地方:不是某一步做錯,而是某個地方「照著文件做」本身就靠不住。

案子還沒結——那顆最可疑的位元,兩個值作者都試過了

把整條路徑攤開來看:MMU 頁表與記憶體屬性照規格設好、L1/L2 快取依序開啟、ACTLR 宣告了 SMP、SCU 也 invalidate 後 enable——每一段拆開來看都「成功」,合在一起卻還是沒有讓 Core 0 看到 Core 1 的寫入。作者在文章結尾的說法很直接:他花了大把時間翻 Arm 的文件,兩顆核心就是沒有快取一致性,他完全不知道自己哪裡做錯了。這篇報導必須誠實照抄這個結尾——這不是一篇「查到最後抓到兇手」的偵探故事,是一篇線索指向某個方向、但案子還沒結案的紀錄。硬要替作者補一個他自己都沒下的結論,對讀者反而是誤導:與其看起來完整卻可能是錯的,不如老實承認查到哪裡就是哪裡。

作者把原始碼整份公開在 Codeberg 上(hello-de0-nano-soc),直接向讀者求助:如果有人找出他哪裡做錯、告訴他怎麼修,他會很樂意寫一篇後續文章,說明自己的錯誤在哪、又是誰比他更早看出來。而全文唯一有具體矛盾佐證的地方,就是那個兩份手冊互相打架的 SCU_CTRL.EN 位元——只是這條線索的狀態,跟「還沒被試過的嫌疑犯」不一樣。作者在點出兩份手冊講法相反的同一句話後面,緊接著就交代了結果:「反正兩種設法都不會動」,並補上 Linux 用的是 1 作為參照。

這句話把嫌疑的性質整個換掉了。SCU_CTRL.EN 依然可能跟這個 bug 有關——它畢竟是整條路徑上唯一一處連官方文件都給不出一致答案的地方;但它已經不是一個「值填錯了、換過來就好」的問題。兩個值都試過、兩個都失敗,代表如果兇手真的是這顆位元,那出錯的層次比「填錯數字」更深:可能是寫進去的值根本沒有真正生效,也可能是這顆位元在這顆 SoC 上的實際行為,跟兩份手冊講的都不一樣。對想幫忙除錯的讀者來說,第一件不用再做的事,就是把值換過來重跑一次測試——那條路作者已經走完了,而且走不通。

回頭看整條路徑,比較特別的地方不是「作者漏做了什麼」——而是他幾乎沒有漏做什麼。頁表屬性、快取開機順序、L2C-310、ACTLR、SCU,每一段都可以在文件裡找到出處,每一段也都照著出處做了;唯一留下具體痕跡的,是文件本身自相矛盾的那個位元——而那個矛盾點,作者也已經把兩種可能都試過一輪了。

對讀這篇文章的工程師來說,這或許比抓到一個明確的 bug 更有參考價值。低階多核心開機程式碼最難除錯的地方,往往不是「你漏做了哪一步」,而是「你照著文件做的哪一步,文件本身就是錯的」——而這種錯誤,通常只有去比對第二份、第三份獨立來源,或是找一份已知能正常運作的實作互相對照,才有機會發現。作者把這個矛盾點連同原始碼一起公開,某個角度上,正是在幫讀者省下重新踩過同一遍路徑的時間。這個習慣不只適用於 Arm 手冊——任何一份「權威文件」都可能有版本落差、勘誤還沒補上,或是描述本身就模稜兩可。

案子還沒結的收尾方式:把 MMU、快取、L2、ACTLR、SCU 全部照文件設完,結果還是不對的時候,能做的不是把同一份文件再讀一次——是把文件自相矛盾的那個點公開標出來,並且交代你已經替它試過哪些值、結果如何。少了後半段,下一個人只會重複做一次你早就做過而且失敗的實驗。