Skip to main content
All articles
·2 min read·
工程Linux

第一次碰 device tree

從 x86 的世界跳到 ARM 才發現 原來 kernel 不一定認得你的硬體

進公司之前都在 x86 的世界 裝系統 跑 server 寫程式 從來沒想過 kernel 是怎麼知道機器上有哪些硬體的 直到工作上碰到 ARM 平台 才第一次聽到 device tree 這個東西

這篇從 x86 為什麼不需要講起 再到 ARM 為什麼需要 然後看 DTS 實際長什麼樣 最後是開機的時候這棵樹怎麼被用掉

x86 為什麼不需要

x86 的平台上硬體會自己報告自己 firmware 開機把平台資訊放進 ACPI table PCIe 跟 USB 這些匯流排協議本身就定義了枚舉 kernel 開機掃一輪 device ID 都拿得到 再用 ID 去配對 driver

所以在 x86 上從來不用告訴 kernel 硬體長什麼樣 是整套 firmware 跟匯流排規格幫你把這件事做完了

(嚴格說 x86 也不是沒有硬體描述 ACPI 的 DSDT 就是類似的東西 只是 firmware 廠商幫你寫好了 你感覺不到它存在)

ARM 的世界不一樣

embedded SoC 上大部分的 peripheral 是 memory-mapped 直接掛在匯流排位址空間上 I2C SPI 這些匯流排也沒有標準的枚舉機制 裝置不會自我介紹 kernel 沒辦法用問的 只能有人直接告訴它 有哪些裝置 base address 在哪 用哪條中斷 吃哪個 clock

這份用來告訴它的描述檔 就是 device tree

以前是寫死在 code 裡

device tree 之前 ARM 的做法是 board file 每塊板子在 kernel tree 裡放一份 C code 把硬體資訊 hardcode 進去 板子一多 arch/arm 底下就爆炸 每家廠商都往裡面塞自己的板子 Linus 2011 年還為這個公開發過飆 之後 ARM 陣營才把 PowerPC 那邊行之有年的 device tree 搬過來

硬體描述從 code 抽出來變成資料 kernel tree 才瘦回去 所以這個東西不是為了優雅 是被逼出來的

DTS 長什麼樣

直接看一段(改編自 upstream 的 Tegra DTS):

i2c@7000c000 {
    compatible = "nvidia,tegra210-i2c";
    reg = <0x0 0x7000c000 0x0 0x100>;
    interrupts = <GIC_SPI 38 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&tegra_car TEGRA210_CLK_I2C1>;
    status = "okay";

    temp-sensor@4c {
        compatible = "ti,tmp451";
        reg = <0x4c>;
    };
};

幾個重點:

  • node 名稱是「類型@位址」 子裝置直接掛在底下 I2C controller 下面就是它匯流排上的 sensor
  • compatible 是整個機制的核心 格式是 vendor,device kernel 拿它去比對 driver 註冊的 of_match_table 對上了才會呼叫 probe
  • reg 對 controller 是 MMIO 位址跟長度 對 I2C 裝置就是 slave address 同一個屬性的意義跟著父節點的匯流排走
  • &tegra_car 這種是 phandle 參考到另一個 node clock 電源 pinmux 都是這樣跨 node 接起來的
  • status 設 disabled 這個裝置就當作不存在

reg 為什麼是四個數字 因為位址跟長度各用幾個 cell 是父節點用 #address-cells 跟 #size-cells 宣告的 64-bit 位址就是兩個 cell 拼起來 <0x0 0x7000c000> 其實是一個 64-bit 的 0x7000c000 第一次看的時候完全不懂這串數字在幹嘛 知道 cell 的規則之後才讀得動別人的 DTS

實際的檔案還有分層 SoC 共用的部分寫在 dtsi 通常把裝置全部 disabled 板子的 dts include 進來 把實際有接線的打開 再覆寫差異 描述一塊新板子 常常只要寫出跟公版差在哪就好

Driver 那邊長什麼樣

配對的另一半在 driver 的 code 裡:

static const struct of_device_id tegra_i2c_of_match[] = {
    { .compatible = "nvidia,tegra210-i2c" },
    { /* sentinel */ },
};

static struct platform_driver tegra_i2c_driver = {
    .probe  = tegra_i2c_probe,
    .driver = {
        .name = "tegra-i2c",
        .of_match_table = tegra_i2c_of_match,
    },
};

kernel 把 DT node 的 compatible 拿來跟 of_match_table 裡的字串比對 對上了就呼叫 probe 裝置跟 driver 在這一刻接起來

probe 裡面再用 API 把 DT 裡描述的資源撈出來 位址用 platform_get_resource 拿 中斷用 platform_get_irq 拿 自訂的屬性用 of_property_read_u32 這類函式讀 所以 driver 本身不寫死任何板子的細節 全部從樹上長出來

從 DTS 到開機

DTS 用 dtc 編譯成 DTB 開機時 bootloader 把 DTB 載進記憶體 位址交給 kernel kernel 解析整棵樹 幫每個 node 生出對應的 device 再跟 driver 配對

樹上除了硬體還有兩個特別的 node chosen 放的是開機參數 embedded 平台的 kernel command line 常常就是 bootloader 塞進這裡的 aliases 幫 node 取短名 serial0 i2c0 這種編號就是在這裡定的

kernel tree 裡每家廠商的 DTS 都放在 arch/arm64/boot/dts 底下自己的目錄 翻一翻會發現一塊板子的完整描述其實就是幾個檔案 include 來 include 去

跑起來的系統 /proc/device-tree 底下就是 kernel 實際拿到的整棵樹 驅動起不來的時候 先來這裡對 compatible 有沒有打錯 status 是不是還是 disabled reg 對不對 dtc 也可以把 DTB 反編譯回 DTS 檢查最後生效的到底是哪一版

這個設計把硬體描述跟 code 完全分開 同一份 kernel image 換一份 DTB 就能跑完全不同的板子

學習筆記

(另一個有趣的地方 ARM server 走的其實是 ACPI 不是 device tree SBSA 那套標準就是要讓 ARM server 開機起來像 x86 一樣不用板子描述 所以 device tree 準確說是 embedded 世界的解法 不是 ARM 的解法)

這篇只講到最基本的骨架 binding 的 schema 驗證 DT overlay 動態疊加這些就先跳過 之後有機會再寫

在 x86 待久了會以為硬體被自動認出來是理所當然 到了 ARM 才知道那是 firmware 跟匯流排規格幫你把事情做完了 沒有人幫你做的時候 就得自己把板子描述清楚

算是補上了一塊一直不知道自己缺的拼圖