ukasol

Blog Zephyr Serisi · 05/07

Device Tree nedir, bare-metal geliştiricinin gözünden

Zephyr, Linux'un runtime'da yaptığını compile time'a taşıdı. Devicetree nedir, bir overlay nasıl sensör ekler ve sonuç neden runtime'da hiçbir şeye mal olmaz.

Device Tree, Linux tarafından gelmeyen biri için öğrenmesi zor bir konsept; ben de zorlananlardandım.

Türkçeye “donanım tarifnamesi” diye çevirebiliriz. Her PCB için bir .dts dosyası oluşturuyorsunuz. Bu dosya işlemciye bağlı tüm elektronik birimleri hiyerarşik bir yapıda tanımlıyor.

Bu cümle doğru, ama üzerine bir şey inşa etmek zor. O yüzden bu yazı gerçek bir örneği baştan sona takip ediyor: bir STM32 Nucleo kartına BME280 sensörü eklemek; yazdığınız overlay’den, sonunda dönüştüğü makine koduna kadar. Aşağıdaki her şey Zephyr 4.4.2 ile derlendi.

board.h’den devicetree’ye#

Bare-metal’de kartın tarifi bir header’da yaşar:

/* board.h */
#define LED_PORT     GPIOA
#define LED_PIN      5
#define SENSOR_I2C   I2C1
#define SENSOR_ADDR  0x77

Çalışıyor ve üç sessiz sorunu var. Onu kimse kontrol etmiyor: bir pin numarasındaki yazım hatası sorunsuz derleniyor. Sizin kodunuz dışında kimse onu okuyamıyor; bu yüzden build, kartın hangi driver’lara ihtiyaç duyduğunu bilemiyor. Ve kodla iç içe: ilk #ifdef BOARD_REV_B ortaya çıktığı anda donanımın tarifi ile firmware’in mantığı aynı dosya oluyor.

Bir devicetree aynı bilgileri veri olarak, build’in okuyabileceği, kontrol edebileceği ve koda dönüştürebileceği bir formatta tutuyor.

Üç katman: SoC, kart, overlay#

Bir Zephyr devicetree’si katmanlardan oluşuyor ve her katmanın sahibi farklı:

  • SoC .dtsi çipi tarif ediyor: her çevre birimini adresi, kesmeleri ve clock’larıyla. Çoğu status = "disabled" olarak işaretli. Bu dosyanın sahibi silikon üreticisi ve Zephyr maintainer’ları.
  • Kart .dts SoC’u dahil ediyor ve PCB’yi tarif ediyor: hangi çevre birimleri bağlı ve nasıl, hangi pinleri kullanıyorlar, LED’ler ve butonlar nerede, hangi UART konsol. Sahibi kartı tasarlayan kişi.
  • Overlay’ler projenizin bunların üzerine yaptığı değişiklikler: eklediğiniz sensör, açılmasını istediğiniz çevre birimi, taşıdığınız pin. Bunların sahibi sizsiniz.

Zephyr’in BME280 örneğiyle gelen overlay şu:

&arduino_i2c {
    status = "okay";

    bme280@77 {
        compatible = "bosch,bme280";
        reg = <0x77>;
    };
};

Neyi söylemediğine dikkat edin: hiçbir STM32 I2C çevre biriminin adını anmıyor. Arduino konnektöründeki I2C hattının etiketi olan arduino_i2c‘ye atıf yapıyor. Nucleo kartının konnektör dosyası o etiketi tek satırla eşliyor:

arduino_i2c: &i2c1 {};

nRF52840 DK ise onu kendi i2c0‘ına eşliyor. Yani bu overlay hiç de Nucleo için yazılmış değil. Konnektör için yazılmış ve iki kart için de hiç değiştirilmeden derleniyor: Nucleo’da build STM32 I2C driver’ını, DK’da Nordic’inkini açıyor.

Build’den sonra birleştirilmiş sonuç build/zephyr/zephyr.dts içinde ve bu, build klasörünün tamamındaki en faydalı dosya. Her özelliğin yanında hangi dosyanın hangi satırından geldiğini söyleyen bir yorum var. Nucleo’daki I2C node’u, kısaltılmış ve yollar sadeleştirilmiş hâliyle:

i2c1: arduino_i2c: i2c@40005400 {
    compatible = "st,stm32-i2c-v1";              /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:316 */
    reg = < 0x40005400 0x400 >;                  /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:320 */
    interrupts = < 0x1f 0x0 >,
                 < 0x20 0x0 >;                   /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:322 */
    pinctrl-0 = < &i2c1_scl_pb8 &i2c1_sda_pb9 >; /* in zephyr/boards/st/nucleo_f401re/nucleo_f401re.dts:107 */
    clock-frequency = < 0x61a80 >;               /* in zephyr/boards/st/nucleo_f401re/nucleo_f401re.dts:110 */
    status = "okay";                             /* in arduino_i2c.overlay:14 */

    /* node '/soc/i2c@40005400/bme280@77' defined in arduino_i2c.overlay:16 */
    bme280@77 {
        compatible = "bosch,bme280"; /* in arduino_i2c.overlay:17 */
        reg = < 0x77 >;              /* in arduino_i2c.overlay:18 */
    };
};

Üç katman da tek bir node’da görünüyor. Adres, kesmeler ve compatible SoC dosyasından geliyor. Pinler (PB8 ve PB9) ve 400 kHz’lik hat hızı karttan. status ve sensörün kendisi overlay’den. Bir build beklemediğiniz bir şey yaptığında, kimin ne dediğini bu dosya söylüyor.

Binding’ler: compatible’a anlamını veren şey#

compatible = "bosch,bme280" serbest metinli bir yorum değil. Build onu bir binding ile eşleştiriyor: dts/bindings altında, o türden bir node’un hangi özelliklere, hangi tiplerde sahip olabileceğini ve hangilerinin zorunlu olduğunu söyleyen bir YAML dosyası. BME280’nin binding’i kısa, çünkü neredeyse her şeyi miras alıyor:

description: BME280 integrated environmental sensor

compatible: "bosch,bme280"

include: [sensor-device.yaml, i2c-device.yaml]

Devicetree’yi bir tariften kontrol edilen bir tarife dönüştüren şey binding’ler. Örneğin gpio-leds binding’i, her LED’in gpios özelliğini zorunlu olarak işaretliyor. Bir overlay’e bu özellik olmadan bir LED eklerseniz build, daha tek bir C dosyası derlenmeden duruyor:

devicetree error: 'gpios' is marked as required in 'properties:' in 'zephyr/dts/bindings/led/gpio-leds.yaml', but does not appear in <Node /leds/led_9 in bad-led.overlay:3>

Binding’i, kuralı ve overlay’inizin tam satırını söylüyor. Bunu board.h içindeki bir yazım hatasıyla karşılaştırın.

Kendi donanımınız için de binding yazabilirsiniz ve göründüğünden daha az iş. Ayarlanabilir yanıp sönme periyodu olan bir durum LED’i için bir binding:

description: |
  A status LED that blinks at a fixed period.

compatible: "fwnotes,status-led"

properties:
  gpios:
    type: phandle-array
    required: true
    description: The GPIO that drives the LED.

  blink-period-ms:
    type: int
    default: 500
    description: Full on-off period, in milliseconds.

Sonra bir kart overlay’i gerçek donanımı tarif ediyor, bu örnekte Nucleo’daki LD2’yi:

/ {
    status_led: status-led {
        compatible = "fwnotes,status-led";
        gpios = <&gpioa 5 GPIO_ACTIVE_HIGH>;
        blink-period-ms = <1000>;
    };
};

Kod da onu DT_ makrolarıyla okuyor:

#define STATUS_LED DT_NODELABEL(status_led)
#define PERIOD_MS  DT_PROP(STATUS_LED, blink_period_ms)

static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(STATUS_LED, gpios);

İki pratik detay. Bir binding, uygulamanızın ya da modülünüzün içindeki bir dts/bindings klasöründe duruyor ve build onu orada kendiliğinden buluyor. fwnotes gibi ağaç dışı bir vendor prefix’i ise, binding’lerinizin yanındaki bir vendor-prefixes.txt dosyasına yazana kadar bir uyarı (has unknown vendor prefix) alıyor. native_sim ve Nucleo için birer overlay’i olan örneğin tamamı firmware-notes içindeki device-tree klasöründe ve CI onu her push’ta çalıştırıyor.

Peki bu nasıl çalışıyor?#

Derleme sırasında CMake (west build‘in yönettiği) kartın .dts dosyasını ve overlay’lerinizi toplayıp C preprocessor’dan geçiriyor. Bir devicetree’nin #include ve GPIO_ACTIVE_HIGH gibi makroları kullanabilmesinin sebebi bu. Sonuç Zephyr’in devicetree script’lerine gidiyor. gen_edt.py onu parse edip her node’u binding’ine göre kontrol ediyor. gen_defines.py adlı bir Python scripti de devicetree_generated.h adında bir header dosyası üretiyor. Bu header içindeki DT_ makroları, donanım bilgilerini C koduna compile time’da veriyor.

010203 Kaynaklarkart .dts, .dtsive overlay'leriniz Preprocessor#include, makrolarzephyr.dts.pre gen_edt.pybinding'leregöre denetler C headerdevicetree_generated.hDT_ makroları KconfigDT_HAS_..._ENABLEDdriver'lar açılır
Bütün süreç configure aşamasında, daha hiçbir C dosyası derlenmeden çalışıyor.

Overlay’deki sensör için header’da 86 makro oluşuyor. Birkaçı:

#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_PATH "/soc/i2c@40005400/bme280@77"
#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_P_compatible {"bosch,bme280"}
#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_P_reg {119 /* 0x77 */}

Bu isimleri hiçbir zaman kendiniz yazmıyorsunuz; DT_NODELABEL(), DT_PROP() ve benzerleri onları sizin için kuruyor. Ama bir DT_ makrosu derlenmediğinde, bu header’da node’unuzu aramak build’in gerçekte ne ürettiğini görmenin en hızlı yolu.

Device Tree ayrıca Kconfig’i de besliyor: etkin her node’un compatible değeri için bir DT_HAS_<compat>_ENABLED sembolü üretiliyor ve doğru driver’ların açılıp derlenmesi bu sayede oluyor. BME280 driver’ının Kconfig’i bunun ne kadar ileri gittiğini gösteriyor:

menuconfig BME280
    bool "BME280/BMP280 sensor"
    default y
    depends on DT_HAS_BOSCH_BME280_ENABLED
    select I2C if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),i2c)
    select SPI if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),spi)

Driver, devicetree’de bir BME280 varsa kendini açıyor; I2C alt sistemini ise yalnızca o BME280 bir I2C hattı üzerindeyse açıyor. Örneğin prj.conf dosyası iki satırdan ibaret: CONFIG_SENSOR=y ve CONFIG_SENSOR_ASYNC_API=y. BME280’den de I2C’den de hiç bahsetmiyor; ikisi de açık hâle geliyor, çünkü devicetree bir I2C hattı üzerinde bir BME280 olduğunu söylüyor.

Hepsi compile time’da#

Tüm bu işlem compile time’da gerçekleşiyor, runtime’da hiçbir yük yok.

Bunu söylemek kolay; nasıl göründüğüne bakalım. Yukarıdaki durum LED’i örneğinin Nucleo için derlenmiş main() fonksiyonu, disassemble edilmiş ve kısaltılmış hâliyle:

 8000492:   mov.w   r3, #1000   @ 0x3e8
 8000496:   movs    r2, #5
 8000498:   bl  8002b7c <printk>
 ...
 800049e:   movs    r1, #32
 ...
 80004ae:   movw    r0, #5000   @ 0x1388
 80004b2:   movs    r1, #0
 80004b4:   bl  80034ee <z_impl_k_sleep>

#1000, overlay’deki yanıp sönme periyodu. #5 pin, yani PA5; #32 de onun bit maskesi, 1 << 5. #5000 ise PERIOD_MS / 2, yani yarım saniye; bu kartın saniyede 10.000 tick’ine göre çoktan kernel tick’ine çevrilmiş. Devicetree binary’ye veri olarak geçmedi. Komutların içindeki sabit operandlara dönüştü; tıpkı sayıları elle yazmışsınız gibi, tek farkla: build onları önce kontrol etti.

Linux’ta aynı mekanizma farklı çalışır: .dts bir binary blob’a derlenir ve kernel bunu runtime’da okur. Zephyr ise bunu bilinçli olarak compile time’a taşımış, çünkü hedef mikrodenetleyicilerde runtime için harcayacak ne bellek var ne de işlem gücü.

Neden uğraşmaya değer#

Bare-metal’den gelince bu başta gereksiz karmaşıklık gibi görünüyor. Ama aynı uygulama kodunu farklı bir PCB için sadece .overlay dosyasını değiştirerek derleyebiliyorsunuz. Uygulama kodu donanımı hiç tanımıyor, sadece Device Tree’den gelen soyutlamayı görüyor.

Bunun karşılığı, bir demonun hiç göstermediği yerlerde alınıyor. Sensörü başka bir I2C hattına taşıyan bir kart revizyonu, bir overlay’de tek satırlık bir değişiklik. Farklı sensörlü ikinci bir ürün varyantı bir #ifdef değil, ikinci bir overlay. Arduino konnektörü gibi konnektörlerin kendi etiketleri olduğu için de bir eklenti kart bir kez tarif edilip o konnektöre sahip her ana kartta kullanılabiliyor.

Zaman kazandıran üç alışkanlık#

  1. Şaşırtan her build’den sonra build/zephyr/zephyr.dts‘i okuyun. Nihai devicetree o ve her özellik nereden geldiğini söylüyor.
  2. Bir DT_ makrosu derlenmediğinde devicetree_generated.h içinde arayın. Hata neredeyse her zaman node’un ya da özelliğin sandığınız gibi var olmadığı anlamına geliyor; header neyin var olduğunu gösteriyor.
  3. Bir node’a doğru şekilde atıf yapın. Kendi donanımınız için node etiketlerini (DT_NODELABEL), her kartın sağlaması beklenen şeyler için takma adları (DT_ALIAS, led0 gibi), konsol gibi sistem rolleri için de chosen node’unu (DT_CHOSEN) kullanın.

Devicetree hata mesajları ayrı bir yazıyı hak ediyor ve bu serinin ilerisinde o yazı da gelecek.

Resmi Zephyr reposunda blinky örneğine bakmanızı öneririm. Konunun tamamı için Device Tree dokümantasyonu iyi bir başlangıç. Serinin bir sonraki yazısı yapılandırmanın diğer yarısını anlatıyor: Kconfig.

Sıradaki · Zephyr fundamentals Kconfig ile proje yapılandırma nasıl çalışır